Files
ComfyUI_frontend/src/composables/node/usePromotedPreviews.test.ts
Alexander Brown 0157b47024 feat(subgraph): Subgraph Link Only Promotion (ADR 0009) + migration/store hygiene (#12197)
## 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>
2026-05-27 00:29:11 -07:00

399 lines
13 KiB
TypeScript

import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { reactive } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
| 'nodeOutputs'
| 'nodePreviewImages'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
| 'getNodeOutputByExecutionId'
| 'getNodePreviewImagesByExecutionId'
>
vi.mock('@/stores/nodeOutputStore', () => {
const store: MockNodeOutputStore = {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls: vi.fn(),
getNodeImageUrlsByExecutionId: vi.fn(),
getNodeOutputByExecutionId: vi.fn(),
getNodePreviewImagesByExecutionId: vi.fn()
}
return { useNodeOutputStore: () => store }
})
function clearMockNodeOutputStore() {
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
for (const key of Object.keys(nodePreviewImages))
delete nodePreviewImages[key]
}
function createSetup() {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
return { subgraph, subgraphNode }
}
function addInteriorNode(
setup: ReturnType<typeof createSetup>,
options: {
id: number
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
} = { id: 10 }
): LGraphNode {
const node = new LGraphNode('test')
node.id = options.id
if (options.previewMediaType) {
node.previewMediaType = options.previewMediaType
}
setup.subgraph.add(node)
return node
}
function seedOutputs(subgraphId: string, nodeIds: Array<number | string>) {
const store = useNodeOutputStore()
for (const nodeId of nodeIds) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodeOutputs[locatorId] = {
images: [{ filename: 'output.png' }]
}
}
}
function seedPreviewImages(
subgraphId: string,
entries: Array<{ nodeId: number | string; urls: string[] }>
) {
const store = useNodeOutputStore()
for (const { nodeId, urls } of entries) {
const locatorId = createNodeLocatorId(subgraphId, nodeId)
store.nodePreviewImages[locatorId] = urls
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
interface ArrangeOptions {
id?: number
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
urls?: string[]
}
function arrangePromotedPreview(options: ArrangeOptions = {}) {
const {
id = 10,
previewMediaType,
urls = ['/view?filename=output.png']
} = options
const setup = createSetup()
addInteriorNode(setup, { id, previewMediaType })
exposePreview(setup, String(id))
seedOutputs(setup.subgraph.id, [id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
return { setup, urls }
}
describe(usePromotedPreviews, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
clearMockNodeOutputStore()
})
it('returns empty array for non-SubgraphNode', () => {
const node = new LGraphNode('test')
const { promotedPreviews } = usePromotedPreviews(() => node)
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array for null node', () => {
const { promotedPreviews } = usePromotedPreviews(() => null)
expect(promotedPreviews.value).toEqual([])
})
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('returns image preview for promoted $$ widget with outputs', () => {
const { setup, urls } = arrangePromotedPreview({
previewMediaType: 'image'
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls
}
])
})
it.for([
['video', '/view?filename=output.webm'],
['audio', '/view?filename=output.mp3']
] as const)(
'returns %s type when interior node has %s previewMediaType',
([mediaType, url]) => {
const { setup } = arrangePromotedPreview({
previewMediaType: mediaType,
urls: [url]
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe(mediaType)
}
)
it('defaults preview type to image when previewMediaType is unset', () => {
const { setup, urls } = arrangePromotedPreview()
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
expect.objectContaining({ type: 'image', urls })
])
})
it('returns separate entries for multiple promoted $$ widgets', () => {
const setup = createSetup()
const node10 = addInteriorNode(setup, {
id: 10,
previewMediaType: 'image'
})
const node20 = addInteriorNode(setup, {
id: 20,
previewMediaType: 'image'
})
exposePreview(setup, '10')
exposePreview(setup, '20')
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
expect(promotedPreviews.value[0].urls).toEqual(['/view?a=1'])
expect(promotedPreviews.value[1].urls).toEqual(['/view?b=2'])
})
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
exposePreview(setup, '10')
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
])
})
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: [blobUrl]
}
])
})
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
exposePreview(setup, '10')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('skips missing interior nodes', () => {
const setup = createSetup()
exposePreview(setup, '99')
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('renders leaf media exposed through a nested subgraph host', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
}
])
})
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
const outputStore = useNodeOutputStore()
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
vi.mocked(outputStore.getNodeImageUrlsByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:second']
}
])
})
})