Skip to content

Commit 3149fbc

Browse files
committed
test: address the automated review on the custom-node suite
Twenty-four of twenty-five findings applied. Three were worth more than the line they pointed at. The COMBO output-name one was proposed as an index-based rename. Production names an output `output_name[index] || output_<index>` with no string coercion, so a non-string entry leaves the live slot's name as the raw array, and connectivity matches on slot.name === producer.slotName - renaming would have traded a duplicate-drop for a guaranteed SLOT_CONTRACT_MISMATCH. Those outputs go to unknownSlots instead. Tracing it turned up a second bug: a FALSY non-string entry was named after the slot type where production gives output_0. RawNodeDef.output_name was the actual lie at string[] - ComfyUI sets output_name = RETURN_NAMES if present else RETURN_TYPES, so combo outputs really do ship option arrays there. It is unknown[] now. The facet-order test was said to over-pin ordering. diffShapes ends in .sort(), so emission order is erased, and probesEqual compares element by element - relaxing to arrayContaining would have dropped a live contract. The real flaw was a fixture too weak to tell the two hypotheses apart; it now is. The updatedAt assertions were called tautological. They compare the entry's timestamp against the index's top-level one, which are different fields and would fail on divergence. Rewritten for clarity anyway, which did close a real gap: expect.any(Number) on the captured value is now load-bearing. Also removed a comment claiming a detection-proof row greps a label that no row greps, and a "geometry ledger" reference left over from the cut S14 tier. The remaining findings: a 40-minute job timeout sized from the 12-18.8 minute observed range, the ComfyUI pin declared once so the published summary cannot advertise a ref the run never used, manifest repo URLs validated before git clone, fail-fast on the video fixture and the input directory, dead row-14 case arms removed, --strictPort on the one script that assumes 5173, real waiting instead of isVisible's ignored timeout, HUNG_BACKEND aborting the outer batch loop so later batches stop recording false timeouts over a wedged queue, a computed drag-sweep bound instead of setTimeout(0), per-node probe isolation so a throwing pack names itself, profile and manifest shape validation, a real off() in the suite fake, graphNodeIds coverage on both branches, and a rejecting-page harness replacing four hand-rolled copies. Declined: re-throwing on startup-settings failure. That swallow predates this PR by two years, every setting it guards is either irrelevant at --workers=1 or set explicitly per spec, the fixture already asserts the resulting precondition, and re-throwing would turn any devtools-less or auth-gated backend from one console.error into a full-suite failure - which has happened (run 30309274120, 359 tests). The real defect is that the swallow cannot tell an absent endpoint from a broken write, and that belongs in its own PR against the shared fixture.
1 parent 7cbfc7e commit 3149fbc

18 files changed

Lines changed: 422 additions & 263 deletions

.github/workflows/ci-tests-custom-nodes.yaml

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ concurrency:
4949
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-dispatch-{1}', github.workflow, github.run_id) || format('{0}-{1}', github.workflow, github.ref) }}
5050
cancel-in-progress: true
5151

52+
env:
53+
COMFYUI_PIN: b08e6cf35fac50d3ca8470dffb3f9a1fbb7187d2
54+
5255
jobs:
5356
# Path gating lives here, not in a trigger-level `paths:` filter: a required
5457
# check gated by trigger paths never creates a check run on an unrelated PR
@@ -86,6 +89,8 @@ jobs:
8689
(github.event_name != 'pull_request' ||
8790
github.event.pull_request.head.repo.full_name == github.repository))
8891
runs-on: ubuntu-latest
92+
# Green runs of this job land at 13-19 minutes.
93+
timeout-minutes: 40
8994
permissions:
9095
contents: read
9196
steps:
@@ -145,7 +150,7 @@ jobs:
145150

146151
# Checks out ComfyUI, installs Python/torch/requirements and ComfyUI_devtools.
147152
# launch_server:false so we can add the manifest packs before booting.
148-
# comfyui_ref pins core so the gate is reproducible: this SHA is the
153+
# comfyui_ref pins core so the gate is reproducible: COMFYUI_PIN is the
149154
# exact master commit the suite (incl. the KJNodes ledger entries and
150155
# per-pack node counts) was last verified green against. Bump it
151156
# deliberately, in its own PR, recalibrating ledgers/counts if the run
@@ -157,7 +162,7 @@ jobs:
157162
# workflow_dispatch may override the pin (e.g. probing a core
158163
# candidate before a deliberate bump); every gating event stays
159164
# pinned.
160-
comfyui_ref: ${{ inputs.comfyui_ref || 'b08e6cf35fac50d3ca8470dffb3f9a1fbb7187d2' }}
165+
comfyui_ref: ${{ inputs.comfyui_ref || env.COMFYUI_PIN }}
161166

162167
# Pack sources are SHA-pinned, so their content is fully determined by
163168
# the manifest: cache the checked-out trees (sans .git) and restore by
@@ -206,6 +211,11 @@ jobs:
206211
if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then
207212
echo "::error::$pack: pin must be a full commit SHA (got '$pin')"; exit 1
208213
fi
214+
# git clone reads a leading `-` as an option, and options such as
215+
# --upload-pack= run a command on the runner.
216+
if ! [[ "$repo" =~ ^https://github\.com/[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
217+
echo "::error::$pack: repo must be an https://github.com/<owner>/<name> URL (got '$repo')"; exit 1
218+
fi
209219
dir="ComfyUI/custom_nodes/$pack"
210220
cache_dir="$HOME/.cache/cn-packs/$pack"
211221
# The pin marker is a SIBLING of the tree, never inside it - a
@@ -310,7 +320,11 @@ jobs:
310320
# The VHS run-tier workflow reads input/plain_video.mp4.
311321
- name: Stage run-tier assets
312322
shell: bash
313-
run: cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4
323+
run: |
324+
set -euo pipefail
325+
mkdir -p ComfyUI/input
326+
cp browser_tests/assets/plain_video.mp4 ComfyUI/input/plain_video.mp4 \
327+
|| { echo "::error::could not stage plain_video.mp4 - the VHS run tier has no input to execute on"; exit 1; }
314328
315329
# --cache-none so retried run-tier tests re-execute every node (a cached
316330
# node emits no `executing` event and would false-fail PARTIAL).
@@ -343,7 +357,7 @@ jobs:
343357
grep_args=()
344358
case "$PROOF_ROW" in
345359
0) [ -n "${GREP_FILTER:-}" ] && grep_args=(--grep "$GREP_FILTER") ;;
346-
1|2|3|9|14)
360+
1|2|3|9)
347361
grep_args=(--grep 'all nodes by tier @custom-nodes.*S(1|2|3|9):')
348362
;;
349363
esac
@@ -446,7 +460,6 @@ jobs:
446460
2) pat=': Vue mounts ' ;;
447461
3) pat='on (pristine|set-values) reload' ;;
448462
9) pat='DETECTION PROOF \(row 9\): pack node runtime failure' ;;
449-
14) pat='widgets\[[0-9]+\]\.y: expected [^,]+, got ' ;;
450463
esac
451464
grep -Eq -- "$pat" tier-isolation-proof-evidence.txt || { echo "::error::S$PROOF_ROW failure was not attributable to its Detection Proof break"; exit 1; }
452465
@@ -493,7 +506,7 @@ jobs:
493506
shell: bash
494507
env:
495508
BRANCH_TESTED: ${{ inputs.branch || github.ref_name }}
496-
COMFYUI_REF_USED: ${{ inputs.comfyui_ref || 'b08e6cf35fac50d3ca8470dffb3f9a1fbb7187d2' }}
509+
COMFYUI_REF_USED: ${{ inputs.comfyui_ref || env.COMFYUI_PIN }}
497510
GREP_FILTER: ${{ inputs.grep }}
498511
run: python3 scripts/cicd/custom-nodes-summary.py
499512

browser_tests/fixtures/customNode/autoRun.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,8 @@ export const SYNTH_PRODUCERS: Record<
5252

5353
const WIDGET_TYPES = new Set(['INT', 'FLOAT', 'STRING', 'BOOLEAN'])
5454

55-
type InputSpec = [unknown, Record<string, unknown>?] | unknown
56-
57-
function classifyInput(spec: InputSpec): 'widget' | 'socket' | 'empty-combo' {
58-
const specArray = Array.isArray(spec) ? spec : [spec]
55+
function classifyInput(spec: unknown): 'widget' | 'socket' | 'empty-combo' {
56+
const specArray: unknown[] = Array.isArray(spec) ? spec : [spec]
5957
const rawType = specArray[0]
6058
const options = specArray[1] as
6159
| { forceInput?: boolean; options?: unknown; widgetType?: string }
@@ -80,8 +78,8 @@ function classifyInput(spec: InputSpec): 'widget' | 'socket' | 'empty-combo' {
8078
: 'socket'
8179
}
8280

83-
function socketType(spec: InputSpec): string {
84-
const specArray = Array.isArray(spec) ? spec : [spec]
81+
function socketType(spec: unknown): string {
82+
const specArray: unknown[] = Array.isArray(spec) ? spec : [spec]
8583
return String(specArray[0])
8684
}
8785

browser_tests/fixtures/customNode/interactionProfiles.ts

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ export interface PackInteractionProfileFile {
4949
}
5050

5151
// Nodes whose interaction deltas are not reproducible run-to-run, keyed by
52-
// the MECHANISM (the geometry/console ledger discipline): registration-
53-
// guarded in the spec, announced in run output, omitted from baselines.
52+
// the MECHANISM (the console ledger discipline): registration-guarded in
53+
// the spec, announced in run output, omitted from baselines.
5454
// Empty until a record/compare cycle observes real instability - entries
5555
// are earned by evidence, never pre-emptively.
5656
export const INTERACTION_UNSTABLE_NODES: Record<
@@ -78,18 +78,74 @@ function profilePath(pack: string): string {
7878
return `${PROFILE_DIR}${pack}.json`
7979
}
8080

81+
const PROBES = ['connectFirst', 'connectLast', 'disconnect'] as const
82+
83+
function isNonEmptyString(value: unknown): value is string {
84+
return typeof value === 'string' && value.length > 0
85+
}
86+
87+
function isPlainObject(value: unknown): value is Record<string, unknown> {
88+
return typeof value === 'object' && value !== null && !Array.isArray(value)
89+
}
90+
91+
function isProbeResult(value: unknown): value is ProbeResult {
92+
if (value === 'NO_PRODUCER' || value === 'NO_INPUTS') return true
93+
return (
94+
Array.isArray(value) && value.every((entry) => typeof entry === 'string')
95+
)
96+
}
97+
98+
// The offending field path, or null when the file is a valid profile set.
99+
// A malformed baseline must name itself here: left unchecked it surfaces as
100+
// an unrelated TypeError deep inside comparePackProfiles.
101+
function invalidProfileField(parsed: unknown): string | null {
102+
if (!isPlainObject(parsed)) return 'root (expected a JSON object)'
103+
if (parsed.schema !== 1) return 'schema (expected 1)'
104+
if (!isPlainObject(parsed.recordedAt))
105+
return 'recordedAt (expected { core, pin })'
106+
if (!isNonEmptyString(parsed.recordedAt.core))
107+
return 'recordedAt.core (expected a non-empty string)'
108+
if (!isNonEmptyString(parsed.recordedAt.pin))
109+
return 'recordedAt.pin (expected a non-empty string)'
110+
if (!isPlainObject(parsed.nodes))
111+
return 'nodes (expected an object keyed by node type)'
112+
for (const [node, profile] of Object.entries(parsed.nodes)) {
113+
if (!isPlainObject(profile))
114+
return `nodes.${node} (expected a profile object)`
115+
for (const probe of PROBES) {
116+
const value = profile[probe]
117+
if (isProbeResult(value)) continue
118+
if (probe === 'connectLast' && value === 'SAME_AS_FIRST') continue
119+
const markers =
120+
probe === 'connectLast'
121+
? "'NO_PRODUCER', 'NO_INPUTS', or 'SAME_AS_FIRST'"
122+
: "'NO_PRODUCER' or 'NO_INPUTS'"
123+
return `nodes.${node}.${probe} (expected a string[] delta, ${markers})`
124+
}
125+
}
126+
return null
127+
}
128+
129+
function assertProfileFile(
130+
parsed: unknown,
131+
path: string
132+
): asserts parsed is PackInteractionProfileFile {
133+
const field = invalidProfileField(parsed)
134+
if (field !== null)
135+
throw new Error(
136+
`${path} is not a valid interaction profile file - ${field} - re-record it (docs/custom-node-regression-suite.md Step 5d)`
137+
)
138+
}
139+
81140
// null = no baseline recorded yet; compare mode must red on that (an
82141
// uncovered pack is the failure mode this suite bans), record mode expects it.
83142
export function loadPackProfiles(
84143
pack: string
85144
): PackInteractionProfileFile | null {
86145
const path = profilePath(pack)
87146
if (!existsSync(path)) return null
88-
const parsed = JSON.parse(readFileSync(path, 'utf-8'))
89-
if (parsed.schema !== 1 || !parsed.recordedAt?.core)
90-
throw new Error(
91-
`${path} is not schema 1 with recordedAt provenance - re-record it (docs/custom-node-regression-suite.md Step 5d)`
92-
)
147+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
148+
assertProfileFile(parsed, path)
93149
return parsed
94150
}
95151

@@ -108,7 +164,7 @@ function probesEqual(
108164
b: NodeInteractionProfile
109165
): string[] {
110166
const problems: string[] = []
111-
for (const probe of ['connectFirst', 'connectLast', 'disconnect'] as const) {
167+
for (const probe of PROBES) {
112168
const expected = a[probe]
113169
const actual = b[probe]
114170
const same =

browser_tests/fixtures/customNode/manifest.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -255,9 +255,13 @@ export function rendererPassesFor(
255255
}
256256

257257
export function loadManifest(): CoreManifestEntry[] {
258-
const entries = JSON.parse(
259-
readFileSync(dataPath('customNodeManifest.core.json'), 'utf-8')
260-
) as CoreManifestEntry[]
258+
const path = dataPath('customNodeManifest.core.json')
259+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf-8'))
260+
if (!Array.isArray(parsed))
261+
throw new Error(
262+
`custom-node manifest ${path} must be a JSON array of entries, got ${parsed === null ? 'null' : typeof parsed}`
263+
)
264+
const entries: CoreManifestEntry[] = parsed
261265
entries.forEach(assertCoreEntry)
262266
return entries
263267
}

browser_tests/fixtures/customNode/typePairing.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ export interface RawNodeDef {
99
optional?: Record<string, unknown>
1010
}
1111
output?: unknown[]
12-
output_name?: string[]
12+
// object_info repeats RETURN_TYPES here when a node declares no
13+
// RETURN_NAMES, so a combo output's entry is its option array.
14+
output_name?: unknown[]
1315
python_module?: string
1416
}
1517

@@ -25,8 +27,9 @@ export interface NormalizedNode {
2527
pack: string
2628
inputs: NormalizedSlot[]
2729
outputs: NormalizedSlot[]
28-
// Slots whose raw spec carried no recognizable type (slotTypeOf null):
29-
// recorded so a schema change can never silently shrink the corpus.
30+
// Slots the corpus cannot address: no recognizable type (slotTypeOf null)
31+
// or no string name on the instance (outputSlotName null). Recorded so a
32+
// schema change can never silently shrink the corpus.
3033
unknownSlots?: string[]
3134
}
3235

@@ -55,7 +58,7 @@ export interface PairingPlan {
5558
// Combos whose option lists match exactly ARE paired like any other type.
5659
combos: Array<SlotRef & { dir: 'in' | 'out' }>
5760
// Slots dropped at normalize time because their raw spec had no
58-
// recognizable type - surfaced here (and logged by the sweep) so a
61+
// recognizable type or name - surfaced here (and logged by the sweep) so a
5962
// backend or pack schema change cannot silently shrink the corpus.
6063
unknownShapes: string[]
6164
}
@@ -88,6 +91,14 @@ function slotTypeOf(rawType: unknown): string | null {
8891
return typeof rawType === 'string' ? rawType : null
8992
}
9093

94+
// Faithful mirror of production naming (schemas/nodeDef/migration.ts):
95+
// `output_name[index] || output_<index>`, uncoerced - so a truthy non-string
96+
// entry names the live slot something no string lookup can match (null here).
97+
function outputSlotName(rawName: unknown, index: number): string | null {
98+
if (!rawName) return `output_${index}`
99+
return typeof rawName === 'string' ? rawName : null
100+
}
101+
91102
function inputSlots(
92103
entries: Record<string, unknown> | undefined,
93104
unknown: string[]
@@ -140,18 +151,12 @@ export function normalizeNodeDefs(
140151
unknown.push(`output[${index}]`)
141152
return []
142153
}
143-
// output_name entries can be non-strings (COMBO literals repeat the
144-
// option array); the slot name must stay a string.
145-
const rawName = def.output_name?.[index]
146-
const slot: NormalizedSlot = {
147-
name:
148-
typeof rawName === 'string'
149-
? rawName || `output_${index}`
150-
: rawName === undefined
151-
? `output_${index}`
152-
: slotType,
153-
type: slotType
154+
const name = outputSlotName(def.output_name?.[index], index)
155+
if (name === null) {
156+
unknown.push(`output[${index}].name`)
157+
return []
154158
}
159+
const slot: NormalizedSlot = { name, type: slotType }
155160
if (slotType === 'COMBO') slot.comboOptions = rawType as unknown[]
156161
return [slot]
157162
})

browser_tests/fixtures/utils/consoleErrorCollector.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { ConsoleMessage, Page, TestInfo } from '@playwright/test'
22

33
export async function attachPageDiagnosticEvidence(
4-
_page: Page,
54
testInfo: Pick<TestInfo, 'attach'>,
65
name: string,
76
values: readonly string[]

browser_tests/tests/customNodes/allNodes.spec.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -632,7 +632,8 @@ for (const entry of loadManifest()) {
632632
if (key in ledger) continue
633633
const visible = await comfyPage.page
634634
.locator(`[data-node-id="${shape.id}"]`)
635-
.isVisible({ timeout: 2_000 })
635+
.waitFor({ state: 'visible', timeout: 2_000 })
636+
.then(() => true)
636637
.catch(() => false)
637638
if (!visible) failures.push(`${key}: no Vue mount`)
638639
}
@@ -1385,7 +1386,7 @@ for (const entry of loadManifest()) {
13851386
hardFailures.push(
13861387
`${verdict.key}: ${single} - add to AUTO_RUN_EXCLUDE with its mechanism`
13871388
)
1388-
break
1389+
break batchLoop
13891390
} else cannotRun.set(verdict.key, single)
13901391
}
13911392
}
@@ -1453,7 +1454,6 @@ for (const entry of loadManifest()) {
14531454
if (hardFailures.length > 0) {
14541455
console.log(autoRunFailureSummary)
14551456
await attachPageDiagnosticEvidence(
1456-
comfyPage.page,
14571457
test.info(),
14581458
'auto-run-failures.json',
14591459
hardFailures
@@ -1525,12 +1525,7 @@ test.describe('all nodes by tier @custom-nodes', () => {
15251525
}
15261526
const attachment = `${tier.toLowerCase()}-failures.json`
15271527
if (failures.length > 0)
1528-
await attachPageDiagnosticEvidence(
1529-
comfyPage.page,
1530-
test.info(),
1531-
attachment,
1532-
failures
1533-
)
1528+
await attachPageDiagnosticEvidence(test.info(), attachment, failures)
15341529
expect(
15351530
failures.length === 0,
15361531
failureSummary(`${tier} pack failures`, failures, attachment)

0 commit comments

Comments
 (0)