Skip to content

Commit 0c6ea96

Browse files
committed
test: address custom node e2e review findings
1 parent dcc255c commit 0c6ea96

60 files changed

Lines changed: 3701 additions & 7625 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

browser_tests/fixtures/ComfyPage.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
22
import { test as base } from '@playwright/test'
33
import { config as dotenvConfig } from 'dotenv'
44
import MCR from 'monocart-coverage-reports'
5+
import { randomUUID } from 'node:crypto'
56

67
import { COVERAGE_OUTPUT_DIR } from '@e2e/coverageConfig'
78
import {
@@ -36,6 +37,7 @@ import { assetPath } from '@e2e/fixtures/utils/paths'
3637
import { nextFrame, sleep } from '@e2e/fixtures/utils/timing'
3738
import { mockWorkspace, workspace } from '@e2e/fixtures/utils/workspaceMocks'
3839
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
40+
import { createdUserId } from '@e2e/fixtures/userIdentity'
3941
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
4042
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
4143
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
@@ -75,6 +77,12 @@ import type { WorkspaceStore } from '@e2e/types/globals'
7577

7678
dotenvConfig()
7779

80+
const TEST_RUN_ID = randomUUID()
81+
82+
export function testUsername(prefix: string, parallelIndex: number): string {
83+
return `${prefix}-${TEST_RUN_ID}-${parallelIndex}`
84+
}
85+
7886
class ComfyPropertiesPanel {
7987
readonly root: Locator
8088
readonly panelTitle: Locator
@@ -293,23 +301,17 @@ export class ComfyPage {
293301
return id ? id : await this.createUser(username)
294302
}
295303

296-
async createUser(username: string) {
304+
async createUser(username: string): Promise<string> {
297305
const resp = await this.request.post(`${this.apiUrl}/api/users`, {
298306
data: { username }
299307
})
300308

301309
if (resp.status() !== 200) {
302310
const body = await resp.text()
303-
// Persistent backends (Comfy Desktop server user storage) keep the user
304-
// across runs and do not list it via GET /api/users, so a duplicate means
305-
// it already exists. Returns the username since the generated id is not
306-
// retrievable here; only reached on single-user / default-resolving backends.
307-
if (resp.status() === 400 && body.includes('Duplicate username.'))
308-
return username
309311
throw new Error(`Failed to create user: ${body}`)
310312
}
311313

312-
return await resp.json()
314+
return createdUserId(await resp.json())
313315
}
314316

315317
async setupSettings(settings: Record<string, unknown>) {
@@ -595,7 +597,7 @@ export const comfyPageFixture = base.extend<{
595597
const comfyPage = new ComfyPage(page, request)
596598

597599
const { parallelIndex } = testInfo
598-
const username = `playwright-test-${parallelIndex}`
600+
const username = testUsername('playwright-test', parallelIndex)
599601
const isCustomNodes = testInfo.project.name === 'custom-nodes'
600602
const needsPerf =
601603
testInfo.tags.includes('@perf') || testInfo.tags.includes('@audit')

browser_tests/fixtures/customNode/ComfyTarget.ts

Lines changed: 41 additions & 145 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,29 @@
11
import type { Page } from '@playwright/test'
22

3-
import type { PromptResponse } from '@/schemas/apiSchema'
4-
53
import 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'
1116
import { classifyRun } from '@e2e/fixtures/customNode/runResult'
1217
import { 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

2921
const 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

Comments
 (0)