mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-05 03:59:09 +00:00
Compare commits
5 Commits
proxy-widg
...
fix/promot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
735d639d64 | ||
|
|
71a4098aa9 | ||
|
|
c601aab2c3 | ||
|
|
fd9e732b7f | ||
|
|
8a923a2094 |
@@ -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 })
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal file
119
src/core/graph/subgraph/promotedWidgetIndicator.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user