Compare commits

...

5 Commits

Author SHA1 Message Date
Alexander Brown
faf6495c4f Merge branch 'main' into fix/double-promotion 2026-03-16 13:30:10 -07:00
Alexander Brown
39d8d0a5a2 Merge branch 'main' into fix/double-promotion 2026-03-16 13:21:37 -07:00
bymyself
b33dea7589 test: simplify nested promotion E2E test to use fixture
The dynamic nested-subgraph-creation test was consistently failing in CI
because SubgraphNode widgets aren't reliably available after wrapping one
subgraph inside another within a single test run.

Replace with a fixture-based test that loads subgraph-nested-promotion and
verifies promoted widgets are visible and survive round-trip serialization.
The core fix is fully covered by unit tests in promotionUtils.test.ts.
2026-03-15 23:11:28 -07:00
bymyself
a6f3a64772 fix: use expect.poll for inner subgraph widget readiness in E2E test
Widgets on inner SubgraphNodes may be created lazily after navigation.
Poll until the inner node has widgets before proceeding, fixing CI flake.
2026-03-15 23:01:38 -07:00
bymyself
f90953edf6 fix: use immediate node ID for nested widget promotion (#9973)
promoteWidget() and demoteWidget() were using widget.sourceNodeId
(deep leaf) instead of node.id (immediate interior SubgraphNode) when
handling PromotedWidgetView widgets. This caused nested promotions
(2+ levels deep) to record an interiorNodeId not present in the
immediate subgraph, which pruneDisconnected() would then remove.

Changes:
- getWidgetName(): always return w.name (remove sourceWidgetName branch)
- promoteWidget(): always use String(node.id)
- demoteWidget(): always use String(node.id)
- Remove unused isPromotedWidgetView import

Tests:
- Unit tests for promote/demote/getWidgetName with PromotedWidgetView
- Unit tests for pruneDisconnected with correct/incorrect entries
- E2E test for widget promoted through two levels of nesting
2026-03-15 22:44:38 -07:00
3 changed files with 162 additions and 244 deletions

View File

@@ -676,6 +676,38 @@ test.describe(
})
})
test.describe('Nested Subgraph Double Promotion', () => {
test('Nested subgraph promoted widgets are visible after load', async ({
comfyPage
}) => {
// The subgraph-nested-promotion fixture has a SubgraphNode (id 5)
// with multiple promoted widgets including ones from nested interior
// nodes. Verify they survive loading and are visible.
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, '5')
expect(widgetCount).toBeGreaterThan(0)
// Verify round-trip preserves promotions
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterRoundTrip = await getPromotedWidgetNames(comfyPage, '5')
expect(afterRoundTrip).toEqual(promotedNames)
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage

View File

@@ -1,267 +1,159 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
promoteWidget,
demoteWidget,
getWidgetName,
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@sentry/vue', () => ({ addBreadcrumb: vi.fn() }))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: updatePreviewsMock })
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
vi.mock('@/stores/subgraphNavigationStore', () => ({
useSubgraphNavigationStore: () => ({ navigationStack: [] })
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: () => ({ add: vi.fn() })
}))
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
} from './promotionUtils'
function widget(
overrides: Partial<
Pick<IBaseWidget, 'name' | 'serialize' | 'type' | 'options'>
>
function createPromotedWidgetStub(
sourceNodeId: string,
sourceWidgetName: string
): IBaseWidget {
return { name: 'widget', ...overrides } as unknown as IBaseWidget
return {
name: 'promoted-slot-name',
type: 'number',
value: 42,
y: 0,
options: {},
sourceNodeId,
sourceWidgetName
} as IBaseWidget
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
describe('promoteWidget', () => {
test('uses node.id for normal widgets', () => {
const subgraph = createTestSubgraph()
const parent = createTestSubgraphNode(subgraph, { id: 10 })
const node = { id: 42, title: 'Leaf', type: 'KSampler' }
const widget: IBaseWidget = {
name: 'seed',
type: 'number',
value: 0,
y: 0,
options: {}
}
promoteWidget(node, widget, [parent])
const store = usePromotionStore()
const entries = store.getPromotions(parent.rootGraph.id, parent.id)
expect(entries).toHaveLength(1)
expect(entries[0].interiorNodeId).toBe('42')
expect(entries[0].widgetName).toBe('seed')
})
it('returns true for $$-prefixed widget names', () => {
expect(
isPreviewPseudoWidget(widget({ name: '$$canvas-image-preview' }))
).toBe(true)
expect(isPreviewPseudoWidget(widget({ name: '$$anything' }))).toBe(true)
test('uses node.id (immediate interior) for PromotedWidgetView, not sourceNodeId', () => {
const subgraph = createTestSubgraph()
const parent = createTestSubgraphNode(subgraph, { id: 10 })
const node = { id: 77, title: 'SubgraphNodeB', type: subgraph.id }
const widget = createPromotedWidgetStub('999', 'deep_seed')
promoteWidget(node, widget, [parent])
const store = usePromotionStore()
const entries = store.getPromotions(parent.rootGraph.id, parent.id)
expect(entries).toHaveLength(1)
expect(entries[0].interiorNodeId).toBe('77')
})
})
describe('demoteWidget', () => {
test('uses node.id (immediate interior) for PromotedWidgetView demote', () => {
const subgraph = createTestSubgraph()
const parent = createTestSubgraphNode(subgraph, { id: 10 })
const store = usePromotionStore()
store.promote(parent.rootGraph.id, parent.id, '77', 'promoted-slot-name')
const node = { id: 77, title: 'SubgraphNodeB', type: subgraph.id }
const widget = createPromotedWidgetStub('999', 'deep_seed')
demoteWidget(node, widget, [parent])
const entries = store.getPromotions(parent.rootGraph.id, parent.id)
expect(entries).toHaveLength(0)
})
})
describe('getWidgetName', () => {
test('returns widget.name for normal widget', () => {
const widget: IBaseWidget = {
name: 'seed',
type: 'number',
value: 0,
y: 0,
options: {}
}
expect(getWidgetName(widget)).toBe('seed')
})
it('returns true for serialize:false with type "preview"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: false, type: 'preview' })
)
).toBe(true)
})
it('returns true for options.serialize:false with type "preview" (VHS pattern)', () => {
expect(
isPreviewPseudoWidget(
widget({
name: 'videopreview',
type: 'preview',
options: { serialize: false }
})
)
).toBe(true)
})
it('returns true for serialize:false with type "video"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'vid', serialize: false, type: 'video' })
)
).toBe(true)
})
it('returns true for serialize:false with type "audioUI"', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'audio', serialize: false, type: 'audioUI' })
)
).toBe(true)
})
it('returns false for type "preview" when serialize is not false', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'videopreview', serialize: true, type: 'preview' })
)
).toBe(false)
})
it('returns false for regular widgets', () => {
expect(
isPreviewPseudoWidget(widget({ name: 'seed', type: 'number' }))
).toBe(false)
})
it('returns false for serialize:false with unknown type', () => {
expect(
isPreviewPseudoWidget(
widget({ name: 'text', serialize: false, type: 'customtext' })
)
).toBe(false)
test('returns widget.name for PromotedWidgetView (not sourceWidgetName)', () => {
const widget = createPromotedWidgetStub('999', 'deep_seed')
expect(getWidgetName(widget)).toBe('promoted-slot-name')
})
})
describe('pruneDisconnected', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
test('keeps promotion entries referencing nodes present in the subgraph', () => {
const subgraph = createTestSubgraph()
const parent = createTestSubgraphNode(subgraph, { id: 10 })
const store = usePromotionStore()
const innerNode = new LGraphNode('InnerNode')
innerNode.addWidget('number', 'value', 0, () => undefined)
parent.subgraph.add(innerNode)
store.promote(parent.rootGraph.id, parent.id, String(innerNode.id), 'value')
pruneDisconnected(parent)
const entries = store.getPromotions(parent.rootGraph.id, parent.id)
expect(entries).toHaveLength(1)
expect(entries[0].interiorNodeId).toBe(String(innerNode.id))
})
it('removes disconnected entries and emits a dev warning', () => {
test('prunes promotion entries referencing nodes NOT in the subgraph', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const parent = createTestSubgraphNode(subgraph, { id: 10 })
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
store.promote(parent.rootGraph.id, parent.id, 'nonexistent-999', 'seed')
pruneDisconnected(subgraphNode)
pruneDisconnected(parent)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
pruneDisconnected(subgraphNode)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
})
describe('getPromotableWidgets', () => {
it('adds virtual canvas preview widget for PreviewImage nodes', () => {
const node = new LGraphNode('PreviewImage')
node.type = 'PreviewImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for SaveImage nodes', () => {
const node = new LGraphNode('SaveImage')
node.type = 'SaveImage'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('adds virtual canvas preview widget for GLSLShader nodes', () => {
const node = new LGraphNode('GLSLShader')
node.type = 'GLSLShader'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(true)
})
it('does not add virtual canvas preview widget for non-image nodes', () => {
const node = new LGraphNode('TextNode')
node.addOutput('TEXT', 'STRING')
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
it('does not add virtual canvas preview widget for ImageInvert nodes', () => {
const node = new LGraphNode('ImageInvert')
node.type = 'ImageInvert'
const widgets = getPromotableWidgets(node)
expect(
widgets.some((widget) => widget.name === CANVAS_IMAGE_PREVIEW_WIDGET)
).toBe(false)
})
})
describe('promoteRecommendedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
updatePreviewsMock.mockReset()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraph.add(interiorNode)
const previewWidget = interiorNode.addWidget(
'custom',
'videopreview',
'value',
() => {}
)
previewWidget.type = 'preview'
previewWidget.serialize = false
promoteRecommendedWidgets(subgraphNode)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
const entries = store.getPromotions(parent.rootGraph.id, parent.id)
expect(entries).toHaveLength(0)
})
})

View File

@@ -1,5 +1,4 @@
import * as Sentry from '@sentry/vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
IContextMenuValue,
@@ -20,10 +19,9 @@ import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [PartialNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
return w.name
}
/** Known non-$$ preview widget types added by core or popular extensions. */
@@ -51,9 +49,7 @@ export function promoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
@@ -71,9 +67,7 @@ export function demoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)