From eb59077d1f1cac2fdc77260239192d76b7a6f1dc Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 16:06:20 -0700 Subject: [PATCH 01/10] test: cover boundary link preservation on subgraph creation The only e2e coverage of Convert to Subgraph selects every node in the workflow, so no boundary link exists and the rewiring code in _convertToSubgraphImpl is never exercised. Partial selection - the operation users actually perform - has no coverage at all. Packs KSampler + VAE Decode out of the default workflow, leaving five boundary input links from four distinct source outputs and one boundary output link, and asserts each one lands on a type-compatible slot on the new subgraph node and survives a serialize/reload round trip. --- .../subgraphCreationBoundaryLinks.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts new file mode 100644 index 00000000000..6c78054bfdf --- /dev/null +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -0,0 +1,100 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' + +test.describe( + 'Subgraph creation boundary links', + { tag: ['@slow', '@subgraph'] }, + () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') + await comfyPage.workflow.loadWorkflow('default') + await comfyPage.nodeOps.selectNodes(['KSampler', 'VAE Decode']) + const ksampler = await comfyPage.nodeOps.getNodeRefById('3') + await ksampler.convertToSubgraph() + }) + + test('rewires every boundary link onto the new subgraph node', async ({ + comfyPage + }) => { + await expect + .poll(() => + comfyPage.page.evaluate(() => { + const graph = window.app!.graph! + const host = graph.nodes.find((node) => node.isSubgraphNode()) + if (!host) return { error: 'no subgraph node' } + + const links = [...graph.links.values()] + return { + rootLinkCount: links.length, + inputCount: host.inputs.length, + outputCount: host.outputs.length, + unconnectedInputs: host.inputs.filter( + (input) => input.link == null + ).length, + sources: links + .filter((link) => link.target_id === host.id) + .map((link) => `${link.origin_id}:${link.origin_slot}`) + .sort(), + saveImageFedByHost: + links.find((link) => String(link.target_id) === '9') + ?.origin_id === host.id + } + }) + ) + .toEqual({ + rootLinkCount: 6, + inputCount: 5, + outputCount: 1, + unconnectedInputs: 0, + sources: ['4:0', '4:2', '5:0', '6:0', '7:0'], + saveImageFedByHost: true + }) + }) + + test('lands each boundary link on a type-compatible slot', async ({ + comfyPage + }) => { + await expect + .poll(() => + comfyPage.page.evaluate(() => { + const graph = window.app!.graph! + const host = graph.nodes.find((node) => node.isSubgraphNode()) + if (!host) return ['no subgraph node'] + + return [...graph.links.values()] + .filter((link) => link.target_id === host.id) + .filter( + (link) => host.inputs[link.target_slot]?.type !== link.type + ) + .map( + (link) => + `${link.type} link landed on slot ${link.target_slot} typed ${host.inputs[link.target_slot]?.type}` + ) + }) + ) + .toEqual([]) + }) + + test('preserves the rewiring across a save and reload', async ({ + comfyPage + }) => { + const serialisedLinks = () => + comfyPage.page.evaluate(() => + [...window.app!.graph!.links.values()] + .map( + (link) => + `${link.origin_id}:${link.origin_slot}->${link.target_id}:${link.target_slot}` + ) + .sort() + ) + + const beforeReload = await serialisedLinks() + await comfyPage.nodeOps.loadGraph( + await comfyPage.nodeOps.getSerializedGraph() + ) + + await expect.poll(serialisedLinks).toEqual(beforeReload) + }) + } +) From 4e821e217b2694e9bb709237b3bceca257bbdcf2 Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 16:38:34 -0700 Subject: [PATCH 02/10] test: dump graph state in the boundary link assertion diff CI shows this failing on main: the subgraph node gets its five input slots and one output slot, but the VAE boundary input (4:2) and the SaveImage boundary output are both left unwired while all four KSampler boundary inputs survive, and nine links remain in the root graph instead of six. Emit the node list, link table and host slot table so the assertion diff explains which links were dropped. --- .../subgraphCreationBoundaryLinks.spec.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 6c78054bfdf..45686e7b6cc 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -38,7 +38,29 @@ test.describe( .sort(), saveImageFedByHost: links.find((link) => String(link.target_id) === '9') - ?.origin_id === host.id + ?.origin_id === host.id, + // Diagnostics: printed as context in the assertion diff. + diagnosticHostId: String(host.id), + diagnosticNodes: graph.nodes + .map((node) => `${node.id}:${node.type}`) + .sort(), + diagnosticLinks: links + .map( + (link) => + `${link.origin_id}:${link.origin_slot}->${link.target_id}:${link.target_slot}(${link.type})` + ) + .sort(), + diagnosticHostSlots: host.inputs + .map( + (input, index) => + `${index} ${input.name}:${input.type}=${input.link ?? 'NULL'}` + ) + .concat( + host.outputs.map( + (output, index) => + `out${index} ${output.name}:${output.type}=[${(output.links ?? []).join('|')}]` + ) + ) } }) ) @@ -48,7 +70,11 @@ test.describe( outputCount: 1, unconnectedInputs: 0, sources: ['4:0', '4:2', '5:0', '6:0', '7:0'], - saveImageFedByHost: true + saveImageFedByHost: true, + diagnosticHostId: expect.anything(), + diagnosticNodes: expect.anything(), + diagnosticLinks: expect.anything(), + diagnosticHostSlots: expect.anything() }) }) From 1d375419d269ac4d27618c7ae8e8010e04a83287 Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 17:04:57 -0700 Subject: [PATCH 03/10] Revert "test: dump graph state in the boundary link assertion diff" This reverts commit 4e821e217b2694e9bb709237b3bceca257bbdcf2. --- .../subgraphCreationBoundaryLinks.spec.ts | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 45686e7b6cc..6c78054bfdf 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -38,29 +38,7 @@ test.describe( .sort(), saveImageFedByHost: links.find((link) => String(link.target_id) === '9') - ?.origin_id === host.id, - // Diagnostics: printed as context in the assertion diff. - diagnosticHostId: String(host.id), - diagnosticNodes: graph.nodes - .map((node) => `${node.id}:${node.type}`) - .sort(), - diagnosticLinks: links - .map( - (link) => - `${link.origin_id}:${link.origin_slot}->${link.target_id}:${link.target_slot}(${link.type})` - ) - .sort(), - diagnosticHostSlots: host.inputs - .map( - (input, index) => - `${index} ${input.name}:${input.type}=${input.link ?? 'NULL'}` - ) - .concat( - host.outputs.map( - (output, index) => - `out${index} ${output.name}:${output.type}=[${(output.links ?? []).join('|')}]` - ) - ) + ?.origin_id === host.id } }) ) @@ -70,11 +48,7 @@ test.describe( outputCount: 1, unconnectedInputs: 0, sources: ['4:0', '4:2', '5:0', '6:0', '7:0'], - saveImageFedByHost: true, - diagnosticHostId: expect.anything(), - diagnosticNodes: expect.anything(), - diagnosticLinks: expect.anything(), - diagnosticHostSlots: expect.anything() + saveImageFedByHost: true }) }) From 0566c82c5ae03df59b5195515a2b6402110dc9ee Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 17:08:40 -0700 Subject: [PATCH 04/10] test: assert the exact post-conversion link topology The previous expectation was wrong: it asserted six root links after packing KSampler + VAE Decode, forgetting the two CheckpointLoaderSimple CLIP links that lie entirely outside the selection and correctly survive. The real count is eight. Assert the full link table with the subgraph node rendered as HOST instead of counting, so the expectation states the intended topology and a failure names the links that moved. --- .../subgraphCreationBoundaryLinks.spec.ts | 45 +++++++++---------- 1 file changed, 20 insertions(+), 25 deletions(-) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 6c78054bfdf..50ecb94d30a 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -22,34 +22,29 @@ test.describe( comfyPage.page.evaluate(() => { const graph = window.app!.graph! const host = graph.nodes.find((node) => node.isSubgraphNode()) - if (!host) return { error: 'no subgraph node' } + if (!host) return ['no subgraph node'] + + const label = (id: string | number) => + id === host.id ? 'HOST' : String(id) - const links = [...graph.links.values()] - return { - rootLinkCount: links.length, - inputCount: host.inputs.length, - outputCount: host.outputs.length, - unconnectedInputs: host.inputs.filter( - (input) => input.link == null - ).length, - sources: links - .filter((link) => link.target_id === host.id) - .map((link) => `${link.origin_id}:${link.origin_slot}`) - .sort(), - saveImageFedByHost: - links.find((link) => String(link.target_id) === '9') - ?.origin_id === host.id - } + return [...graph.links.values()] + .map( + (link) => + `${label(link.origin_id)}:${link.origin_slot}->${label(link.target_id)}:${link.target_slot}` + ) + .sort() }) ) - .toEqual({ - rootLinkCount: 6, - inputCount: 5, - outputCount: 1, - unconnectedInputs: 0, - sources: ['4:0', '4:2', '5:0', '6:0', '7:0'], - saveImageFedByHost: true - }) + .toEqual([ + '4:0->HOST:0', + '4:1->6:0', + '4:1->7:0', + '4:2->HOST:4', + '5:0->HOST:3', + '6:0->HOST:1', + '7:0->HOST:2', + 'HOST:0->9:0' + ]) }) test('lands each boundary link on a type-compatible slot', async ({ From 269a12a2602ed972d03ccd0f4dc72f8930a8e4fe Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 21:05:10 -0700 Subject: [PATCH 05/10] test: select both nodes before converting to a subgraph VAE Decode sits past the right edge of the 1280px canvas at the default view, so its title click missed and the selection held KSampler alone. The conversion under assertion was therefore a one-node conversion, and its correct output read as four separate boundary-link defects. Fit the view first, assert the selection, and assert the exact link topology on both sides of the reload rather than comparing the reloaded graph against whatever conversion happened to produce. --- .../subgraphCreationBoundaryLinks.spec.ts | 110 ++++++++++++------ 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 50ecb94d30a..24fd759e9de 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -1,7 +1,48 @@ import { expect } from '@playwright/test' +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +/** + * Converting {KSampler, VAE Decode} out of the default workflow leaves the two + * `CheckpointLoaderSimple -> CLIP` links untouched — they sit wholly outside the + * selection — so eight links remain in the root graph. + */ +const EXPECTED_ROOT_LINKS = [ + '4:0->HOST:0', + '4:1->6:0', + '4:1->7:0', + '4:2->HOST:4', + '5:0->HOST:3', + '6:0->HOST:1', + '7:0->HOST:2', + 'HOST:0->9:0' +] + +const readRootLinks = (comfyPage: ComfyPage) => + comfyPage.page.evaluate(() => { + const graph = window.app!.graph! + const host = graph.nodes.find((node) => node.isSubgraphNode()) + if (!host) return ['no subgraph node'] + + const label = (id: string | number) => + id === host.id ? 'HOST' : String(id) + + return [...graph.links.values()] + .map( + (link) => + `${label(link.origin_id)}:${link.origin_slot}->${label(link.target_id)}:${link.target_slot}` + ) + .sort() + }) + +const selectedNodeIds = (comfyPage: ComfyPage) => + comfyPage.page.evaluate(() => + [...(window.app!.canvas.selectedItems ?? [])] + .map((item) => String((item as { id?: unknown }).id)) + .sort() + ) + test.describe( 'Subgraph creation boundary links', { tag: ['@slow', '@subgraph'] }, @@ -9,7 +50,30 @@ test.describe( test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.workflow.loadWorkflow('default') + + // VAE Decode sits past the right edge of the 1280px canvas at the default + // view, so clicking its title silently misses and the selection is left + // holding KSampler alone. + await comfyPage.command.executeCommand('Comfy.Canvas.FitView') + const [vaeDecode] = + await comfyPage.nodeOps.getNodeRefsByTitle('VAE Decode') + const canvasWidth = (await comfyPage.canvas.boundingBox())!.width + let previousX = Number.NaN + await expect + .poll(async () => { + const { x } = await vaeDecode.getTitlePosition() + const settledInView = x === previousX && x < canvasWidth + previousX = x + return settledInView + }) + .toBe(true) + await comfyPage.nodeOps.selectNodes(['KSampler', 'VAE Decode']) + expect( + await selectedNodeIds(comfyPage), + 'both nodes must be selected, or the conversion under test is not the one being asserted' + ).toEqual(['3', '8']) + const ksampler = await comfyPage.nodeOps.getNodeRefById('3') await ksampler.convertToSubgraph() }) @@ -18,33 +82,8 @@ test.describe( comfyPage }) => { await expect - .poll(() => - comfyPage.page.evaluate(() => { - const graph = window.app!.graph! - const host = graph.nodes.find((node) => node.isSubgraphNode()) - if (!host) return ['no subgraph node'] - - const label = (id: string | number) => - id === host.id ? 'HOST' : String(id) - - return [...graph.links.values()] - .map( - (link) => - `${label(link.origin_id)}:${link.origin_slot}->${label(link.target_id)}:${link.target_slot}` - ) - .sort() - }) - ) - .toEqual([ - '4:0->HOST:0', - '4:1->6:0', - '4:1->7:0', - '4:2->HOST:4', - '5:0->HOST:3', - '6:0->HOST:1', - '7:0->HOST:2', - 'HOST:0->9:0' - ]) + .poll(() => readRootLinks(comfyPage)) + .toEqual(EXPECTED_ROOT_LINKS) }) test('lands each boundary link on a type-compatible slot', async ({ @@ -74,22 +113,17 @@ test.describe( test('preserves the rewiring across a save and reload', async ({ comfyPage }) => { - const serialisedLinks = () => - comfyPage.page.evaluate(() => - [...window.app!.graph!.links.values()] - .map( - (link) => - `${link.origin_id}:${link.origin_slot}->${link.target_id}:${link.target_slot}` - ) - .sort() - ) + await expect + .poll(() => readRootLinks(comfyPage)) + .toEqual(EXPECTED_ROOT_LINKS) - const beforeReload = await serialisedLinks() await comfyPage.nodeOps.loadGraph( await comfyPage.nodeOps.getSerializedGraph() ) - await expect.poll(serialisedLinks).toEqual(beforeReload) + await expect + .poll(() => readRootLinks(comfyPage)) + .toEqual(EXPECTED_ROOT_LINKS) }) } ) From 18aae2983719f72b3c42423a47f34ce0bd030528 Mon Sep 17 00:00:00 2001 From: Connor Byrne Date: Tue, 11 Aug 2026 21:18:50 -0700 Subject: [PATCH 06/10] test: read the declared id off the selected item --- .../tests/subgraph/subgraphCreationBoundaryLinks.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 24fd759e9de..0a5d56c0e86 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -39,7 +39,7 @@ const readRootLinks = (comfyPage: ComfyPage) => const selectedNodeIds = (comfyPage: ComfyPage) => comfyPage.page.evaluate(() => [...(window.app!.canvas.selectedItems ?? [])] - .map((item) => String((item as { id?: unknown }).id)) + .map((item) => String(item.id)) .sort() ) From 625912af56209b7fe997cb96fa943f723b2638b1 Mon Sep 17 00:00:00 2001 From: DrJKL Date: Tue, 18 Aug 2026 12:50:31 -0700 Subject: [PATCH 07/10] test: simplify subgraph boundary link coverage Amp-Thread-ID: https://ampcode.com/threads/T-01a0165c-6e78-7117-9a54-4514124737d7 Co-authored-by: Amp --- .../fixtures/helpers/SubgraphHelper.ts | 35 +++++ .../subgraphCreationBoundaryLinks.spec.ts | 120 +++++------------- 2 files changed, 64 insertions(+), 91 deletions(-) diff --git a/browser_tests/fixtures/helpers/SubgraphHelper.ts b/browser_tests/fixtures/helpers/SubgraphHelper.ts index 2570e5858df..cb1a15b706b 100644 --- a/browser_tests/fixtures/helpers/SubgraphHelper.ts +++ b/browser_tests/fixtures/helpers/SubgraphHelper.ts @@ -528,6 +528,41 @@ export class SubgraphHelper { return id } + async getBoundaryLinkSnapshot() { + return this.page.evaluate(() => { + const graph = window.app!.graph! + const host = graph.nodes.find((node) => node.isSubgraphNode()) + if (!host) { + return { + rootLinks: ['no subgraph node'], + incompatibleHostInputLinks: ['no subgraph node'] + } + } + + const hostId = host.id + function label(id: string | number) { + return id === hostId ? 'HOST' : String(id) + } + + const links = [...graph.links.values()] + return { + rootLinks: links + .map( + (link) => + `${label(link.origin_id)}:${link.origin_slot}->${label(link.target_id)}:${link.target_slot}` + ) + .sort(), + incompatibleHostInputLinks: links + .filter((link) => link.target_id === host.id) + .filter((link) => host.inputs[link.target_slot]?.type !== link.type) + .map( + (link) => + `${link.type} link landed on slot ${link.target_slot} typed ${host.inputs[link.target_slot]?.type}` + ) + } + }) + } + async serializeAndReload(): Promise { const serialized = await this.page.evaluate(() => window.app!.graph!.serialize() diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index 0a5d56c0e86..bd901caab9a 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -1,53 +1,26 @@ import { expect } from '@playwright/test' -import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' -/** - * Converting {KSampler, VAE Decode} out of the default workflow leaves the two - * `CheckpointLoaderSimple -> CLIP` links untouched — they sit wholly outside the - * selection — so eight links remain in the root graph. - */ -const EXPECTED_ROOT_LINKS = [ - '4:0->HOST:0', - '4:1->6:0', - '4:1->7:0', - '4:2->HOST:4', - '5:0->HOST:3', - '6:0->HOST:1', - '7:0->HOST:2', - 'HOST:0->9:0' -] - -const readRootLinks = (comfyPage: ComfyPage) => - comfyPage.page.evaluate(() => { - const graph = window.app!.graph! - const host = graph.nodes.find((node) => node.isSubgraphNode()) - if (!host) return ['no subgraph node'] - - const label = (id: string | number) => - id === host.id ? 'HOST' : String(id) - - return [...graph.links.values()] - .map( - (link) => - `${label(link.origin_id)}:${link.origin_slot}->${label(link.target_id)}:${link.target_slot}` - ) - .sort() - }) - -const selectedNodeIds = (comfyPage: ComfyPage) => - comfyPage.page.evaluate(() => - [...(window.app!.canvas.selectedItems ?? [])] - .map((item) => String(item.id)) - .sort() - ) - -test.describe( - 'Subgraph creation boundary links', - { tag: ['@slow', '@subgraph'] }, - () => { - test.beforeEach(async ({ comfyPage }) => { +test( + 'Subgraph creation rewires boundary links to compatible slots across reload', + { tag: ['@slow', '@subgraph', '@vue-nodes'] }, + async ({ comfyPage }) => { + const expectedSnapshot = { + rootLinks: [ + '4:0->HOST:0', + '4:1->6:0', + '4:1->7:0', + '4:2->HOST:4', + '5:0->HOST:3', + '6:0->HOST:1', + '7:0->HOST:2', + 'HOST:0->9:0' + ], + incompatibleHostInputLinks: [] + } + + await test.step('Select both nodes in the default workflow', async () => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.workflow.loadWorkflow('default') @@ -55,8 +28,7 @@ test.describe( // view, so clicking its title silently misses and the selection is left // holding KSampler alone. await comfyPage.command.executeCommand('Comfy.Canvas.FitView') - const [vaeDecode] = - await comfyPage.nodeOps.getNodeRefsByTitle('VAE Decode') + const vaeDecode = await comfyPage.nodeOps.getNodeRefById('8') const canvasWidth = (await comfyPage.canvas.boundingBox())!.width let previousX = Number.NaN await expect @@ -70,60 +42,26 @@ test.describe( await comfyPage.nodeOps.selectNodes(['KSampler', 'VAE Decode']) expect( - await selectedNodeIds(comfyPage), + await comfyPage.nodeOps.getSelectedNodeIds(), 'both nodes must be selected, or the conversion under test is not the one being asserted' ).toEqual(['3', '8']) + }) + await test.step('Convert and verify the boundary links', async () => { const ksampler = await comfyPage.nodeOps.getNodeRefById('3') await ksampler.convertToSubgraph() - }) - test('rewires every boundary link onto the new subgraph node', async ({ - comfyPage - }) => { await expect - .poll(() => readRootLinks(comfyPage)) - .toEqual(EXPECTED_ROOT_LINKS) + .poll(() => comfyPage.subgraph.getBoundaryLinkSnapshot()) + .toEqual(expectedSnapshot) }) - test('lands each boundary link on a type-compatible slot', async ({ - comfyPage - }) => { - await expect - .poll(() => - comfyPage.page.evaluate(() => { - const graph = window.app!.graph! - const host = graph.nodes.find((node) => node.isSubgraphNode()) - if (!host) return ['no subgraph node'] - - return [...graph.links.values()] - .filter((link) => link.target_id === host.id) - .filter( - (link) => host.inputs[link.target_slot]?.type !== link.type - ) - .map( - (link) => - `${link.type} link landed on slot ${link.target_slot} typed ${host.inputs[link.target_slot]?.type}` - ) - }) - ) - .toEqual([]) - }) - - test('preserves the rewiring across a save and reload', async ({ - comfyPage - }) => { - await expect - .poll(() => readRootLinks(comfyPage)) - .toEqual(EXPECTED_ROOT_LINKS) - - await comfyPage.nodeOps.loadGraph( - await comfyPage.nodeOps.getSerializedGraph() - ) + await test.step('Reload and verify the boundary links', async () => { + await comfyPage.subgraph.serializeAndReload() await expect - .poll(() => readRootLinks(comfyPage)) - .toEqual(EXPECTED_ROOT_LINKS) + .poll(() => comfyPage.subgraph.getBoundaryLinkSnapshot()) + .toEqual(expectedSnapshot) }) } ) From bf99d703c367351206dddb3f99878c137f077649 Mon Sep 17 00:00:00 2001 From: DrJKL Date: Tue, 18 Aug 2026 13:11:24 -0700 Subject: [PATCH 08/10] test: extract node title viewport wait Amp-Thread-ID: https://ampcode.com/threads/T-01a0165c-6e78-7117-9a54-4514124737d7 Co-authored-by: Amp --- .../fixtures/utils/litegraphUtils.ts | 14 ++++++ .../subgraphCreationBoundaryLinks.spec.ts | 43 ++++++++----------- 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index c33d7d36dcc..603e91ebf8b 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -356,6 +356,20 @@ export class NodeReference { const nodeSize = await this.getSize() return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 } } + async waitForTitleInView(): Promise { + const canvas = await this.comfyPage.canvas.boundingBox() + if (!canvas) throw new Error('Canvas bounding box not available') + + let previousX = Number.NaN + await expect + .poll(async () => { + const { x } = await this.getTitlePosition() + const settledInView = x === previousX && x < canvas.width + previousX = x + return settledInView + }) + .toBe(true) + } async dragBy( delta: Position, options?: { diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index bd901caab9a..c8e7af40ee8 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -2,24 +2,24 @@ import { expect } from '@playwright/test' import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +const SUBGRAPH_LINKS_EXPECTED = { + rootLinks: [ + '4:0->HOST:0', + '4:1->6:0', + '4:1->7:0', + '4:2->HOST:4', + '5:0->HOST:3', + '6:0->HOST:1', + '7:0->HOST:2', + 'HOST:0->9:0' + ], + incompatibleHostInputLinks: [] +} as const + test( 'Subgraph creation rewires boundary links to compatible slots across reload', { tag: ['@slow', '@subgraph', '@vue-nodes'] }, async ({ comfyPage }) => { - const expectedSnapshot = { - rootLinks: [ - '4:0->HOST:0', - '4:1->6:0', - '4:1->7:0', - '4:2->HOST:4', - '5:0->HOST:3', - '6:0->HOST:1', - '7:0->HOST:2', - 'HOST:0->9:0' - ], - incompatibleHostInputLinks: [] - } - await test.step('Select both nodes in the default workflow', async () => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') await comfyPage.workflow.loadWorkflow('default') @@ -29,16 +29,7 @@ test( // holding KSampler alone. await comfyPage.command.executeCommand('Comfy.Canvas.FitView') const vaeDecode = await comfyPage.nodeOps.getNodeRefById('8') - const canvasWidth = (await comfyPage.canvas.boundingBox())!.width - let previousX = Number.NaN - await expect - .poll(async () => { - const { x } = await vaeDecode.getTitlePosition() - const settledInView = x === previousX && x < canvasWidth - previousX = x - return settledInView - }) - .toBe(true) + await vaeDecode.waitForTitleInView() await comfyPage.nodeOps.selectNodes(['KSampler', 'VAE Decode']) expect( @@ -53,7 +44,7 @@ test( await expect .poll(() => comfyPage.subgraph.getBoundaryLinkSnapshot()) - .toEqual(expectedSnapshot) + .toEqual(SUBGRAPH_LINKS_EXPECTED) }) await test.step('Reload and verify the boundary links', async () => { @@ -61,7 +52,7 @@ test( await expect .poll(() => comfyPage.subgraph.getBoundaryLinkSnapshot()) - .toEqual(expectedSnapshot) + .toEqual(SUBGRAPH_LINKS_EXPECTED) }) } ) From 74a103084230849faf312f11aef7fcf39f59622b Mon Sep 17 00:00:00 2001 From: DrJKL Date: Tue, 18 Aug 2026 13:29:24 -0700 Subject: [PATCH 09/10] test: validate subgraph output link types --- browser_tests/fixtures/helpers/SubgraphHelper.ts | 10 +++++++++- .../subgraph/subgraphCreationBoundaryLinks.spec.ts | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/browser_tests/fixtures/helpers/SubgraphHelper.ts b/browser_tests/fixtures/helpers/SubgraphHelper.ts index cb1a15b706b..e8a613e0a66 100644 --- a/browser_tests/fixtures/helpers/SubgraphHelper.ts +++ b/browser_tests/fixtures/helpers/SubgraphHelper.ts @@ -535,7 +535,8 @@ export class SubgraphHelper { if (!host) { return { rootLinks: ['no subgraph node'], - incompatibleHostInputLinks: ['no subgraph node'] + incompatibleHostInputLinks: ['no subgraph node'], + incompatibleHostOutputLinks: ['no subgraph node'] } } @@ -558,6 +559,13 @@ export class SubgraphHelper { .map( (link) => `${link.type} link landed on slot ${link.target_slot} typed ${host.inputs[link.target_slot]?.type}` + ), + incompatibleHostOutputLinks: links + .filter((link) => link.origin_id === host.id) + .filter((link) => host.outputs[link.origin_slot]?.type !== link.type) + .map( + (link) => + `${link.type} link left slot ${link.origin_slot} typed ${host.outputs[link.origin_slot]?.type}` ) } }) diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index c8e7af40ee8..d2f47efa4d1 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -13,7 +13,8 @@ const SUBGRAPH_LINKS_EXPECTED = { '7:0->HOST:2', 'HOST:0->9:0' ], - incompatibleHostInputLinks: [] + incompatibleHostInputLinks: [], + incompatibleHostOutputLinks: [] } as const test( From 7a03b0c973f2d496f35c865bedd352d4c4fd717f Mon Sep 17 00:00:00 2001 From: Amp Date: Thu, 20 Aug 2026 01:07:37 +0000 Subject: [PATCH 10/10] test: use rendered node viewport assertion Amp-Thread-ID: https://ampcode.com/threads/T-01a01ca8-8281-759a-84ba-1665d58a0c82 --- browser_tests/fixtures/utils/litegraphUtils.ts | 14 -------------- .../subgraph/subgraphCreationBoundaryLinks.spec.ts | 4 ++-- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 603e91ebf8b..c33d7d36dcc 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -356,20 +356,6 @@ export class NodeReference { const nodeSize = await this.getSize() return { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 } } - async waitForTitleInView(): Promise { - const canvas = await this.comfyPage.canvas.boundingBox() - if (!canvas) throw new Error('Canvas bounding box not available') - - let previousX = Number.NaN - await expect - .poll(async () => { - const { x } = await this.getTitlePosition() - const settledInView = x === previousX && x < canvas.width - previousX = x - return settledInView - }) - .toBe(true) - } async dragBy( delta: Position, options?: { diff --git a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts index d2f47efa4d1..0ee3b210771 100644 --- a/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts +++ b/browser_tests/tests/subgraph/subgraphCreationBoundaryLinks.spec.ts @@ -29,8 +29,8 @@ test( // view, so clicking its title silently misses and the selection is left // holding KSampler alone. await comfyPage.command.executeCommand('Comfy.Canvas.FitView') - const vaeDecode = await comfyPage.nodeOps.getNodeRefById('8') - await vaeDecode.waitForTitleInView() + const vaeDecode = await comfyPage.vueNodes.getFixtureByTitle('VAE Decode') + await expect(vaeDecode.header).toBeInViewport({ ratio: 1 }) await comfyPage.nodeOps.selectNodes(['KSampler', 'VAE Decode']) expect(