mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
## Summary Introduces **Subgraph Link Only Promotion** (ADR 0009) — a new model for surfacing inner subgraph widgets on the parent SubgraphNode by *promoting through links* rather than by duplicating widget state on the host. Ships with the hygiene/refactor pass on the migration, store, and event layers that the new model depends on. ## What changes ### Subgraph Link Only Promotion (ADR 0009) Promoted widgets are defined by the link from a SubgraphNode input to the interior node, not by a duplicated widget instance on the host. Consequences: - A SubgraphNode renders inner widgets purely as a **projection** of the interior widgets and links — no host-side state to drift. - **Per-host independence**: multiple instances of the same SubgraphNode render and edit their own values without cross-talk. - **Reversible promote/demote**: structural link operation, so demote preserves host slots and external connections (#12278). ### Supporting refactors - **Migration** — Planner/classifier/repair/quarantine helpers collapsed into a single `proxyWidgetMigration` entry point with black-box round-trip coverage. Honors the source-node-id disambiguator on `proxyWidgets`, so deduplicated names (e.g. `text`, `text_1`) resolve to the right interior widget. - **Widget identity** — `appMode` unified on `WidgetEntityId`; promoted widget state is keyed by entityId across the store, DOM, and migration paths. - **SubgraphNode** — 3-key promoted-view cache replaced with a single version counter + explicit `invalidatePromotedViews()` at mutation sites; `id === -1` sentinel removed. - **Events** — `LGraph.trigger()` now dispatches node trigger payloads through `this.events`, replacing a leaky `onTrigger` monkey-patch. `SubgraphEditor` reactivity is driven from subgraph events instead of imperative refresh. - **Stores** — `appModeStore` migration helpers collapsed into `upgradeAndValidateInput`; `nodeOutputStore.*ByExecutionId` derived from the locator index; `previewExposureStore` cleanup and cycle-detection double-warn fix. - **Misc** — `Outcome` types consolidated; mutable accumulators replaced with `flatMap`; new ESLint rule forbids litegraph imports under `src/world/`. ### Tests - Browser tests for promoted widgets retagged `@vue-nodes` and rewritten to assert against the rendered Vue node DOM (via `getNodeLocator` / `getByRole('textbox')` / `enterSubgraph`) instead of `page.evaluate` graph introspection. - Per-host widget independence asserted via DOM. - Migration coverage moved to black-box round-trip tests. - Added coverage for duplicate-named promoted widget identity (ADR 0009) and the per-parent demote branch in `WidgetActions`. ## Review focus - ADR 0009 conformance of the link-only promotion model. - Disambiguator resolution path in `proxyWidgetMigration`. - Single-version-counter promoted-view cache and its `invalidatePromotedViews()` call sites. - `LGraph.trigger()` event dispatch and the `AppModeWidgetList.vue` migration off `onTrigger` (FE-667 tracks the remaining `useGraphNodeManager` conversion). ## Breaking changes None for users. Internal subgraph promotion APIs changed — see ADR 0009. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12197-feat-subgraph-link-only-widget-promotion-migration-store-hygiene-35e6d73d365081fd882cf3a69bc09956) by [Unito](https://www.unito.io) --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: GitHub Action <action@github.com> Co-authored-by: github-actions <github-actions@github.com> Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com> Co-authored-by: AustinMroz <austin@comfy.org>
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
|
|
import { comfyPageFixture as test, comfyExpect } from '@e2e/fixtures/ComfyPage'
|
|
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
import { getPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
|
|
|
|
test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
|
|
test.describe('Nested subgraph configure order', () => {
|
|
const WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
|
|
|
|
test('Loads and queues without nested promotion resolution failures', async ({
|
|
comfyPage
|
|
}) => {
|
|
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
|
comfyPage.page,
|
|
['No link found', 'Failed to resolve legacy -1']
|
|
)
|
|
|
|
try {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const responsePromise = comfyPage.page.waitForResponse('**/api/prompt')
|
|
await comfyPage.command.executeCommand('Comfy.QueuePrompt')
|
|
|
|
const response = await responsePromise
|
|
expect(warnings).toEqual([])
|
|
expect(response.ok()).toBe(true)
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
})
|
|
|
|
test.describe(
|
|
'Nested subgraph duplicate widget names',
|
|
{ tag: ['@widget', '@vue-nodes'] },
|
|
() => {
|
|
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
|
|
const OUTER_NODE_ID = '4'
|
|
const INNER_SUBGRAPH_NODE_ID = '3'
|
|
|
|
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
|
await comfyExpect(outerNode).toBeVisible()
|
|
|
|
const outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
|
|
await comfyExpect(outerWidgets).toHaveCount(1)
|
|
|
|
const exposedTextWidget = outerNode.getByRole('textbox', {
|
|
name: 'text'
|
|
})
|
|
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
|
|
|
|
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
|
|
|
|
const innerNode = comfyPage.vueNodes.getNodeLocator(
|
|
INNER_SUBGRAPH_NODE_ID
|
|
)
|
|
await comfyExpect(innerNode).toBeVisible()
|
|
|
|
const innerTextboxes = innerNode.getByRole('textbox')
|
|
await comfyExpect(innerTextboxes).toHaveCount(2)
|
|
const innerValues = await innerTextboxes.evaluateAll<
|
|
string[],
|
|
HTMLInputElement
|
|
>((boxes) => boxes.map((b) => b.value))
|
|
comfyExpect(innerValues).toContain('11111111111')
|
|
comfyExpect(innerValues).toContain('22222222222')
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Nested subgraph pack preserves promoted widget values',
|
|
{ tag: ['@widget'] },
|
|
() => {
|
|
const WORKFLOW = 'subgraphs/nested-pack-promoted-values'
|
|
const HOST_NODE_ID = '57'
|
|
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
})
|
|
|
|
test('Promoted widget values persist after packing interior nodes into nested subgraph', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
|
await comfyExpect(nodeLocator).toBeVisible()
|
|
|
|
const widthWidget = nodeLocator
|
|
.getByLabel('width', { exact: true })
|
|
.first()
|
|
const heightWidget = nodeLocator
|
|
.getByLabel('height', { exact: true })
|
|
.first()
|
|
const stepsWidget = nodeLocator
|
|
.getByLabel('steps', { exact: true })
|
|
.first()
|
|
const textWidget = nodeLocator.getByRole('textbox', { name: 'prompt' })
|
|
|
|
const widthControls =
|
|
comfyPage.vueNodes.getInputNumberControls(widthWidget)
|
|
const heightControls =
|
|
comfyPage.vueNodes.getInputNumberControls(heightWidget)
|
|
const stepsControls =
|
|
comfyPage.vueNodes.getInputNumberControls(stepsWidget)
|
|
|
|
await comfyExpect(async () => {
|
|
await comfyExpect(widthControls.input).toHaveValue('1024')
|
|
await comfyExpect(heightControls.input).toHaveValue('1024')
|
|
await comfyExpect(stepsControls.input).toHaveValue('8')
|
|
await comfyExpect(textWidget).toHaveValue(/Latina female/)
|
|
}).toPass({ timeout: 5000 })
|
|
|
|
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
|
|
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
|
|
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
|
|
await comfyExpect(nodeAfter).toBeVisible()
|
|
|
|
const widthAfter = nodeAfter
|
|
.getByLabel('width', { exact: true })
|
|
.first()
|
|
const heightAfter = nodeAfter
|
|
.getByLabel('height', { exact: true })
|
|
.first()
|
|
const stepsAfter = nodeAfter
|
|
.getByLabel('steps', { exact: true })
|
|
.first()
|
|
const textAfter = nodeAfter.getByRole('textbox', { name: 'prompt' })
|
|
|
|
const widthControlsAfter =
|
|
comfyPage.vueNodes.getInputNumberControls(widthAfter)
|
|
const heightControlsAfter =
|
|
comfyPage.vueNodes.getInputNumberControls(heightAfter)
|
|
const stepsControlsAfter =
|
|
comfyPage.vueNodes.getInputNumberControls(stepsAfter)
|
|
|
|
await comfyExpect(async () => {
|
|
await comfyExpect(widthControlsAfter.input).toHaveValue('1024')
|
|
await comfyExpect(heightControlsAfter.input).toHaveValue('1024')
|
|
await comfyExpect(stepsControlsAfter.input).toHaveValue('8')
|
|
await comfyExpect(textAfter).toHaveValue(/Latina female/)
|
|
}).toPass({ timeout: 5000 })
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Nested subgraph stale proxyWidgets',
|
|
{ tag: ['@widget'] },
|
|
() => {
|
|
const WORKFLOW = 'subgraphs/nested-subgraph-stale-proxy-widgets'
|
|
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
|
|
})
|
|
|
|
test('Outer subgraph node has no stale proxyWidgets after nested packing', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
|
|
await comfyExpect(outerNode).toBeVisible()
|
|
|
|
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
|
|
|
await comfyExpect(widgets).toHaveCount(1)
|
|
await comfyExpect(widgets.first()).toBeVisible()
|
|
|
|
const seedWidget = outerNode.getByLabel('seed', { exact: true })
|
|
await comfyExpect(seedWidget).toBeVisible()
|
|
})
|
|
}
|
|
)
|
|
|
|
test.describe(
|
|
'Nested subgraph input target resolution',
|
|
{ tag: ['@widget', '@vue-nodes'] },
|
|
() => {
|
|
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
|
|
const OUTER_NODE_ID = '5'
|
|
const INNER_SUBGRAPH_NODE_ID = '6'
|
|
|
|
test('Nested SubgraphNode promoted widgets render without resolution failures', async ({
|
|
comfyPage
|
|
}) => {
|
|
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
|
comfyPage.page,
|
|
['No link found', 'Failed to resolve legacy -1']
|
|
)
|
|
|
|
try {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
|
await comfyExpect(outerNode).toBeVisible()
|
|
|
|
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
|
await comfyExpect(
|
|
widgets,
|
|
'asset has 4 promoted widgets on outer subgraph node'
|
|
).toHaveCount(4)
|
|
|
|
expect(warnings).toEqual([])
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
|
|
test('Promoted widgets from inner SubgraphNode are visible with correct values', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
|
await comfyExpect(outerNode).toBeVisible()
|
|
|
|
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
|
await comfyExpect(widgets).toHaveCount(4)
|
|
|
|
const valueWidget = outerNode
|
|
.getByRole('textbox', { name: 'value' })
|
|
.first()
|
|
await comfyExpect(valueWidget).toBeVisible()
|
|
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
|
})
|
|
|
|
test('Promoted widgets from inner SubgraphNode carry correct source identity', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const widgets = await getPromotedWidgets(comfyPage, OUTER_NODE_ID)
|
|
return widgets
|
|
.filter(
|
|
([sourceNodeId]) => sourceNodeId === INNER_SUBGRAPH_NODE_ID
|
|
)
|
|
.map(([, sourceWidgetName]) => sourceWidgetName)
|
|
})
|
|
.toContain('value')
|
|
})
|
|
|
|
test('Serialize and reload preserves nested promoted widget visibility', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
|
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
|
|
await comfyExpect(
|
|
widgets,
|
|
'asset has 4 promoted widgets on outer subgraph node'
|
|
).toHaveCount(4)
|
|
const initialCount = await widgets.count()
|
|
|
|
await comfyPage.subgraph.serializeAndReload()
|
|
|
|
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
|
|
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)
|
|
await comfyExpect(widgetsAfter).toHaveCount(initialCount)
|
|
|
|
const valueWidget = outerNodeAfter
|
|
.getByRole('textbox', { name: 'value' })
|
|
.first()
|
|
await comfyExpect(valueWidget).toBeVisible()
|
|
await comfyExpect(valueWidget).toHaveValue(/Inner 1/)
|
|
})
|
|
}
|
|
)
|
|
})
|