Compare commits

..

5 Commits

Author SHA1 Message Date
dante01yoon
735d639d64 fix: use programmatic subgraph navigation to avoid click interception in E2E 2026-04-05 11:00:14 +09:00
dante01yoon
71a4098aa9 test: add E2E test for promoted indicator on 3-level nested subgraph (#10612) 2026-04-05 09:41:15 +09:00
dante01yoon
c601aab2c3 docs: add comment explaining promoted indicator fallback logic 2026-04-05 09:35:29 +09:00
dante01yoon
fd9e732b7f fix: also check base key in promoted widget indicator lookup
The isPromotedByAny check was only looking up the key with
disambiguatingSourceNodeId, missing promotions stored without one.
Now tries both with and without the disambiguating segment so
nested subgraph promotions are detected regardless of storage shape.

Fixes #10612
2026-04-05 00:13:39 +09:00
dante01yoon
8a923a2094 test: add failing test for promoted indicator on nested subgraphs
Reproduces #10612 where the promoted widget ring indicator is missing
on nested subgraphs due to a key mismatch in isPromotedByAny lookup.
2026-04-05 00:09:32 +09:00
6 changed files with 208 additions and 92 deletions

View File

@@ -458,4 +458,72 @@ test.describe('Subgraph Nested Scenarios', { tag: ['@subgraph'] }, () => {
})
}
)
/**
* Regression test for #10612: promoted widget indicator ring missing on
* nested subgraph nodes.
*
* Uses the 3-level nested fixture (subgraph-nested-promotion):
* Root → Sub 0 (node 5) → Sub 1 (node 6) → Sub 2 (node 9)
*
* Node 6 (Sub 1) has proxyWidgets promoting widgets from inner nodes,
* and those promotions are also promoted up to node 5 (Sub 0). When
* navigating into Sub 0, node 6 should show the promoted ring on its
* widgets.
*/
test.describe(
'Promoted indicator on 3-level nested subgraphs (#10612)',
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/subgraph-nested-promotion'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate SubgraphNode shows promoted ring inside parent subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// At root level, node 5 (Sub 0) is the outermost SubgraphNode.
// Its widgets are not promoted further, so no ring expected.
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await comfyExpect(outerNode).toBeVisible()
const outerRings = outerNode.locator(`.${PROMOTED_BORDER_CLASS}`)
await comfyExpect(outerRings).toHaveCount(0)
// Navigate into Sub 0 programmatically to avoid click interception
// from canvas overlay elements (z-999 layer).
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const node = graph.getNodeById('5')
if (node?.isSubgraphNode()) {
window.app!.canvas.setGraph(node.subgraph)
}
})
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 6 (Sub 1) has widgets promoted up to Sub 0 (node 5).
// At least one widget should carry the promoted ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('6')
await comfyExpect(intermediateNode).toBeVisible()
const intermediateRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await comfyExpect(async () => {
const count = await intermediateRings.count()
expect(
count,
'Node 6 (Sub 1) should have at least one promoted ring'
).toBeGreaterThan(0)
}).toPass({ timeout: 5000 })
})
}
)
})

View File

@@ -16,10 +16,7 @@ import type {
INodeInputSlot,
INodeOutputSlot
} from '@/lib/litegraph/src/interfaces'
import type {
IBaseWidget,
IWidgetOptions
} from '@/lib/litegraph/src/types/widgets'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
@@ -77,7 +74,6 @@ export interface SafeWidgetData {
advanced?: boolean
hidden?: boolean
read_only?: boolean
values?: IWidgetOptions['values']
}
/** Input specification from node definition */
spec?: InputSpec
@@ -226,8 +222,7 @@ function safeWidgetMapper(
canvasOnly: widget.options.canvasOnly,
advanced: widget.options?.advanced ?? widget.advanced,
hidden: widget.options.hidden,
read_only: widget.options.read_only,
values: widget.options.values
read_only: widget.options.read_only
}
}

View File

@@ -0,0 +1,119 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { mount } from '@vue/test-utils'
import { setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { describe, expect, test, vi } from 'vitest'
import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
import { usePromotionStore } from '@/stores/promotionStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: {
rootGraph: {
id: 'graph-test'
}
}
}
})
}))
function createMockWidget(
overrides: Partial<SafeWidgetData> = {}
): SafeWidgetData {
return {
nodeId: 'test_node',
name: 'test_widget',
type: 'combo',
options: undefined,
callback: undefined,
spec: undefined,
isDOMWidget: false,
slotMetadata: undefined,
...overrides
}
}
function createMockNodeData(
nodeType: string,
widgets: SafeWidgetData[],
id: string
): VueNodeData {
return {
id,
type: nodeType,
widgets,
title: 'Test Node',
mode: 0,
selected: false,
executing: false,
inputs: [],
outputs: []
}
}
function mountComponent(nodeData: VueNodeData, setupStores?: () => void) {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: { nodeData },
global: {
plugins: [pinia],
stubs: { InputSlot: true },
mocks: { $t: (key: string) => key }
}
})
}
function getBorderStyles(wrapper: ReturnType<typeof mount>) {
return fromAny<{ processedWidgets: unknown[] }, unknown>(
wrapper.vm
).processedWidgets.map(
(entry) =>
(entry as { simplified: { borderStyle?: string } }).simplified.borderStyle
)
}
describe('promoted widget indicator on nested subgraphs', () => {
test('shows promoted ring when promotion was stored without disambiguatingSourceNodeId', async () => {
// Scenario: SubBNode (id=3) inside SubA promotes a widget from
// ConcreteNode (id=1). The promotion at the SubA level is stored
// WITHOUT disambiguatingSourceNodeId because ConcreteNode is not
// itself a SubgraphNode.
//
// The widget rendered on SubBNode has storeName and storeNodeId set
// (because it's a promoted widget), so NodeWidgets.vue would normally
// compute a disambiguatingSourceNodeId from the storeNodeId.
// This causes a key mismatch: lookup key "3:text:1" vs stored "3:text".
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
// Store promotion WITHOUT disambiguatingSourceNodeId, as would
// happen for a first-level nested promotion where the inner node
// is not itself a SubgraphNode.
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
})

View File

@@ -10,15 +10,6 @@ const proxyWidgetTupleSchema = z.union([
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export interface ProxyWidgetSelector {
name?: string
selected: string
options: {
label: string
widgets?: string[][]
}[]
}
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {

View File

@@ -45,7 +45,6 @@ import {
supportsVirtualCanvasImagePreview
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type { ProxyWidgetSelector } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
makePromotionEntryKey,
@@ -117,7 +116,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
private _selectorWidget: IBaseWidget | null = null
// Declared as accessor via Object.defineProperty in constructor.
// TypeScript doesn't allow overriding a property with get/set syntax,
@@ -299,69 +297,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
return views
}
private _getWidgetsWithSelector(): IBaseWidget[] {
const views = this._getPromotedViews()
const selector = this.properties.proxyWidgetSelector as
| ProxyWidgetSelector
| undefined
if (!selector?.options?.length || !this._selectorWidget) return views
const selectedOption =
selector.options.find((opt) => opt.label === selector.selected) ??
selector.options[0]
if (!selectedOption?.widgets) return [this._selectorWidget, ...views]
const allGroupedKeys = new Set(
selector.options.flatMap((opt) =>
(opt.widgets ?? []).map(([nid, wn]) => `${nid}:${wn}`)
)
)
const selectedKeys = new Set(
selectedOption.widgets.map(([nid, wn]) => `${nid}:${wn}`)
)
const filteredViews = views.filter((v) => {
const key = `${v.sourceNodeId}:${v.sourceWidgetName}`
return !allGroupedKeys.has(key) || selectedKeys.has(key)
})
return [this._selectorWidget, ...filteredViews]
}
private _initSelectorWidget(): void {
const selector = this.properties.proxyWidgetSelector as
| ProxyWidgetSelector
| undefined
if (!selector?.options?.length) {
this._selectorWidget = null
return
}
const validLabels = selector.options.map((o) => o.label)
if (!validLabels.includes(selector.selected)) {
selector.selected = validLabels[0]
}
this._selectorWidget = {
name: selector.name ?? 'selector',
type: 'combo',
value: selector.selected,
y: 0,
serialize: false,
options: {
values: validLabels
},
callback: (value: unknown) => {
selector.selected = String(value)
this._selectorWidget!.value = String(value)
this._invalidatePromotedViewsCache()
const minSize = this.computeSize()
this.setSize([this.size[0], minSize[1]])
this.graph?.setDirtyCanvas(true, true)
}
}
}
private _invalidatePromotedViewsCache(): void {
this._cacheVersion++
}
@@ -760,7 +695,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Synthetic widgets getter — SubgraphNodes have no native widgets.
Object.defineProperty(this, 'widgets', {
get: () => this._getWidgetsWithSelector(),
get: () => this._getPromotedViews(),
set: () => {
if (import.meta.env.DEV)
console.warn(
@@ -1162,8 +1097,6 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.properties.proxyWidgets = serialized
}
this._initSelectorWidget()
// Check all inputs for connected widgets
for (const input of this.inputs) {
const subgraphInput = input._subgraphSlot

View File

@@ -390,17 +390,27 @@ const processedWidgets = computed((): ProcessedWidget[] => {
? { ...mergedOptions, disabled: true }
: mergedOptions
const borderStyle =
const sourceWidgetName = widget.storeName ?? widget.name
// Promotions stored for non-SubgraphNode inner nodes omit the
// disambiguatingSourceNodeId, but the widget's storeNodeId causes
// one to be computed here. Check both key shapes to handle nested
// subgraph promotions correctly (#10612).
const isPromoted =
graphId &&
promotionStore.isPromotedByAny(graphId, {
(promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
sourceWidgetName,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
}) ||
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName
}))
const borderStyle = isPromoted
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'
: undefined
const linkedUpstream: LinkedUpstreamInfo | undefined =
slotMetadata?.linked && slotMetadata.originNodeId