11import type { Page } from '@playwright/test'
22
3- import type { PromptResponse } from '@/schemas/apiSchema'
4-
53import type { ObjectInfo } from '@e2e/fixtures/customNode/objectInfoValidator'
6- import type {
7- ExecutionError ,
8- PromptEvent ,
9- RunResult
10- } from '@e2e/fixtures/customNode/runResult'
4+ import type { RawPromptEvent } from '@e2e/fixtures/customNode/promptEventScope'
5+ import {
6+ eventsForPrompt ,
7+ toPromptEvent
8+ } from '@e2e/fixtures/customNode/promptEventScope'
9+ import type { PromptCapture } from '@e2e/fixtures/customNode/promptSubmission'
10+ import {
11+ capturePromptResponse ,
12+ describePromptRejection ,
13+ serverSideFault
14+ } from '@e2e/fixtures/customNode/promptSubmission'
15+ import type { RunResult } from '@e2e/fixtures/customNode/runResult'
1116import { classifyRun } from '@e2e/fixtures/customNode/runResult'
1217import { onPromptIdResponse } from '@e2e/fixtures/utils/customNodeSuite'
1318
14- export interface RawEvent {
15- type : string
16- node ?: string | null
17- // execution_cached carries every cache-served node id (apiSchema
18- // zExecutionCachedWsMessage); NodeId is number | string on the wire.
19- nodes ?: ( string | number ) [ ]
20- prompt_id ?: string
21- output ?: unknown
22- exception_message ?: string
23- exception_type ?: string
24- node_id ?: string
25- node_type ?: string
26- traceback ?: string [ ]
27- }
19+ export { isServerSideFault } from '@e2e/fixtures/customNode/promptSubmission'
2820
2921const TERMINAL = [
3022 'execution_success' ,
3123 'execution_error' ,
3224 'execution_interrupted'
3325]
3426
35- // The /prompt rejection body is the apiSchema PromptResponse shape
36- // ({ error: string | {message}, node_errors: { <nodeId>: { class_type,
37- // errors: [{ details, message }] } } }). Flatten it to a single line naming
38- // the node class and the failing input so a VALIDATION_FAIL result is
39- // actionable instead of an empty object. Exported for a pure unit test: the
40- // happy path never runs it, so without a test a regression here would rot the
41- // diagnostic back to `{}` silently.
42- export function summarizePromptError ( body : unknown ) : string | undefined {
43- const payload = body as Partial < PromptResponse > | null
44- if ( ! payload || typeof payload !== 'object' ) return undefined
45- const parts : string [ ] = [ ]
46- const topError = payload . error
47- if ( typeof topError === 'string' ) {
48- if ( topError ) parts . push ( topError )
49- } else if ( topError ?. message ) parts . push ( topError . message )
50- for ( const [ nodeId , nodeError ] of Object . entries ( payload . node_errors ?? { } ) ) {
51- const cls = nodeError . class_type || nodeId
52- for ( const err of nodeError . errors ?? [ ] ) {
53- const detail = err . details || err . message
54- if ( detail ) parts . push ( `${ cls } : ${ detail } ` )
55- }
56- }
57- return parts . length > 0 ? parts . join ( '; ' ) : undefined
58- }
59-
60- // A non-2xx /prompt response, with attribution decided by its status: 400 is
61- // the backend REJECTING this graph (pack-attributable validation), 5xx is the
62- // backend FAILING (an environment fault that is never a per-node verdict).
63- // errorType is the body's typed provenance when the backend supplies one
64- // (Cloud infra faults carry e.g. DATABASE_ERROR; an OSS validation-time crash
65- // or a proxy 5xx arrives untyped, which is itself diagnostic).
66- interface PromptRejection {
67- status : number
68- summary ?: string
69- errorType ?: string
70- }
71-
72- function extractPromptErrorType ( body : unknown ) : string | undefined {
73- const error = ( body as Partial < PromptResponse > | null ) ?. error
74- if ( typeof error !== 'object' || error === null ) return undefined
75- const type = ( error as { type ?: unknown } ) . type
76- return typeof type === 'string' && type ? type : undefined
77- }
78-
79- const describeRejection = ( rejection : PromptRejection ) : string =>
80- rejection . summary ?? `HTTP ${ rejection . status } prompt submission failed`
81-
82- const SERVER_SIDE_FAULT_PREFIX = 'prompt submission failed server-side'
83-
84- // Callers that accumulate per-node verdicts use this to catch the fault,
85- // record it, and still report the failures found before it - a late 5xx must
86- // never mask real regressions from earlier batches.
87- export function isServerSideFault ( error : unknown ) : error is Error {
88- return (
89- error instanceof Error && error . message . startsWith ( SERVER_SIDE_FAULT_PREFIX )
90- )
91- }
92-
93- const serverSideFault = ( rejection : PromptRejection ) : Error =>
94- new Error (
95- `${ SERVER_SIDE_FAULT_PREFIX } (HTTP ${ rejection . status } POST /prompt)` +
96- ( rejection . summary ? ` - ${ rejection . summary } ` : '' ) +
97- ( rejection . errorType ? ` [type: ${ rejection . errorType } ]` : '' ) +
98- ' - backend/environment fault, not a pack validation reject'
99- )
100-
101- export function toPromptEvent ( raw : RawEvent ) : PromptEvent {
102- if ( raw . type === 'executing' )
103- return { type : 'executing' , node : raw . node ?? null }
104- if ( raw . type === 'executed' )
105- return { type : 'executed' , node : raw . node ?? null , output : raw . output }
106- if ( raw . type === 'execution_cached' )
107- return { type : 'execution_cached' , nodes : ( raw . nodes ?? [ ] ) . map ( String ) }
108- if ( raw . type === 'execution_error' || raw . type === 'execution_interrupted' ) {
109- const error : ExecutionError = {
110- exceptionMessage : raw . exception_message ?. trimEnd ( ) ,
111- exceptionType : raw . exception_type ,
112- nodeId : raw . node_id ,
113- nodeType : raw . node_type ,
114- traceback : raw . traceback
115- }
116- return { type : raw . type , error }
117- }
118- return { type : raw . type as 'execution_start' | 'execution_success' }
119- }
120-
12127/**
12228 * Drives a real ComfyUI backend through the running frontend. The verdict logic
12329 * lives in the pure `classifyRun`; this class is only the in-page IO plumbing.
@@ -156,7 +62,7 @@ export class LocalDesktopTarget {
15662 const seenPromptIds = await page . evaluate (
15763 ( types ) => {
15864 const sink = window as unknown as {
159- __cnEvents : RawEvent [ ]
65+ __cnEvents : RawPromptEvent [ ]
16066 __cnSeenPromptIds ?: string [ ]
16167 __cnTapInstalled ?: boolean
16268 }
@@ -198,28 +104,21 @@ export class LocalDesktopTarget {
198104 // the primary event filter; the seen-set above and the graph-membership
199105 // check below stay as defense in depth (capture can lose a race with a
200106 // transient refusal, and `executing` events carry no prompt id at all).
201- let capturedPromptId : string | undefined
202107 // A backend rejection answers /prompt with a non-2xx body carrying
203108 // { error, node_errors }. app.queuePrompt swallows it and just returns
204109 // false, so without capturing it here the verdict names nothing. The
205110 // status is kept alongside the summarized body because it decides
206111 // attribution (see PromptRejection).
207- let capturedRejection : PromptRejection | undefined
208- let capturedResponseSequence = 0
112+ let capture : PromptCapture = { sequence : 0 }
209113 const { detach : stopCapture , settled : captureSettled } = onPromptIdResponse (
210114 page ,
211115 ( promptId , body , status , sequence ) => {
212- if ( sequence < capturedResponseSequence ) return
213- capturedResponseSequence = sequence
214- capturedPromptId = promptId
215- capturedRejection =
216- status >= 400
217- ? {
218- status,
219- summary : summarizePromptError ( body ) ,
220- errorType : extractPromptErrorType ( body )
221- }
222- : undefined
116+ capture = capturePromptResponse ( capture , {
117+ sequence,
118+ status,
119+ body,
120+ promptId
121+ } )
223122 }
224123 )
225124
@@ -253,8 +152,8 @@ export class LocalDesktopTarget {
253152 stopCapture ( )
254153 // A captured 5xx outranks a client-side throw: the backend
255154 // demonstrably failed this submission server-side.
256- if ( capturedRejection !== undefined && capturedRejection . status >= 500 )
257- throw serverSideFault ( capturedRejection )
155+ if ( capture . rejection !== undefined && capture . rejection . status >= 500 )
156+ throw serverSideFault ( capture . rejection )
258157 return {
259158 outcome : 'VALIDATION_FAIL' ,
260159 executedNodes : [ ] ,
@@ -263,7 +162,7 @@ export class LocalDesktopTarget {
263162 // the backend's node_errors captured off the /prompt response.
264163 clientError :
265164 ( typeof queued === 'object' ? queued . __cnThrew : undefined ) ??
266- ( capturedRejection && describeRejection ( capturedRejection ) )
165+ ( capture . rejection && describePromptRejection ( capture . rejection ) )
267166 }
268167 }
269168 }
@@ -273,27 +172,27 @@ export class LocalDesktopTarget {
273172 await captureSettled ( )
274173 const captureDeadline = Date . now ( ) + 2_000
275174 while (
276- capturedPromptId === undefined &&
277- capturedRejection === undefined &&
175+ capture . promptId === undefined &&
176+ capture . rejection === undefined &&
278177 Date . now ( ) < captureDeadline
279178 ) {
280179 await new Promise ( ( resolve ) => setTimeout ( resolve , 50 ) )
281180 await captureSettled ( )
282181 }
283- if ( capturedRejection !== undefined ) {
182+ if ( capture . rejection !== undefined ) {
284183 stopCapture ( )
285- if ( capturedRejection . status >= 500 )
286- throw serverSideFault ( capturedRejection )
184+ if ( capture . rejection . status >= 500 )
185+ throw serverSideFault ( capture . rejection )
287186 return {
288187 outcome : 'VALIDATION_FAIL' ,
289188 executedNodes : [ ] ,
290189 outputsByNode : { } ,
291- clientError : describeRejection ( capturedRejection )
190+ clientError : describePromptRejection ( capture . rejection )
292191 }
293192 }
294193 // A silent permanent miss would degrade every run to the legacy filters
295194 // with no signal - make the fallback observable in the runner output.
296- if ( capturedPromptId === undefined )
195+ if ( capture . promptId === undefined )
297196 console . warn (
298197 '[customNodes] /prompt response id capture missed; falling back to seen-set filtering'
299198 )
@@ -326,7 +225,7 @@ export class LocalDesktopTarget {
326225 TERMINAL ,
327226 seenPromptIds ?? [ ] ,
328227 opts . graphNodeIds ?? null ,
329- capturedPromptId ?? null
228+ capture . promptId ?? null
330229 ] as const ,
331230 { timeout : opts . timeoutMs }
332231 )
@@ -339,18 +238,15 @@ export class LocalDesktopTarget {
339238 } )
340239 stopCapture ( )
341240
342- const raw = (
343- await page . evaluate (
344- ( ) =>
345- ( window as unknown as { __cnEvents ?: RawEvent [ ] } ) . __cnEvents ?? [ ]
346- )
347- ) . filter ( ( event ) =>
348- // Positive id match when captured (events without a prompt_id - bare
349- // `executing` strings - stay, and graph membership still vets them);
350- // otherwise the legacy seen-set exclusion.
351- capturedPromptId !== undefined
352- ? event . prompt_id === undefined || event . prompt_id === capturedPromptId
353- : ! ( event . prompt_id && ( seenPromptIds ?? [ ] ) . includes ( event . prompt_id ) )
241+ const captured = await page . evaluate (
242+ ( ) =>
243+ ( window as unknown as { __cnEvents ?: RawPromptEvent [ ] } ) . __cnEvents ??
244+ [ ]
245+ )
246+ const raw = eventsForPrompt (
247+ captured ,
248+ capture . promptId ,
249+ new Set ( seenPromptIds ?? [ ] )
354250 )
355251 const timedOut = ! raw . some ( ( event ) => TERMINAL . includes ( event . type ) )
356252 return classifyRun ( {
0 commit comments