mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
## Summary Promoted primitive subgraph inputs (String, Int) render their link anchor at the header position instead of the widget row. Renaming subgraph input labels breaks the match entirely, causing connections to detach from their widgets visually. ## Changes - **What**: Fix widget-input slot positioning for promoted subgraph inputs in both LiteGraph and Vue (Nodes 2.0) rendering modes - `_arrangeWidgetInputSlots`: Removed Vue mode branch that skipped setting `input.pos`. Promoted widget inputs aren't rendered as `<InputSlot>` Vue components (NodeSlots filters them out), so `input.pos` is the only position fallback - `drawConnections`: Added pre-pass to arrange nodes with unpositioned widget-input slots before link rendering. The background canvas renders before the foreground canvas calls `arrange()`, so positions weren't set on the first frame - `SubgraphNode`: Sync `input.widget.name` with the display name on label rename and initial setup. The `IWidgetLocator` name diverged from `PromotedWidgetView.name` after rename, breaking all name-based slot↔widget matching (`_arrangeWidgetInputSlots`, `getWidgetFromSlot`, `getSlotFromWidget`) ## Review Focus - The `_arrangeWidgetInputSlots` rewrite iterates `_concreteInputs` directly instead of building a spread-copy map — simpler and avoids the stale index issue - `input.widget.name` is now kept in sync with the display name (`input.label ?? subgraphInput.name`). This is a semantic shift from using the raw internal name, but it's required for all name-based matching to work after renames. The value is overwritten on deserialize by `_setWidget` anyway - The `_widget` fallback in `_arrangeWidgetInputSlots` is a safety net for edge cases where the name still doesn't match (e.g., stale cache) Fixes #9998 ## Screenshots <img width="847" height="476" alt="Screenshot 2026-03-17 at 3 05 32 PM" src="https://github.com/user-attachments/assets/38f10563-f0bc-44dd-a1a5-f4a7832575d0" /> <img width="804" height="471" alt="Screenshot 2026-03-17 at 3 05 23 PM" src="https://github.com/user-attachments/assets/3237a7ee-f3e5-4084-b330-371def3415bd" /> <img width="974" height="571" alt="Screenshot 2026-03-17 at 3 05 16 PM" src="https://github.com/user-attachments/assets/cafdca46-8d9b-40e1-8561-02cbb25ee8f2" /> <img width="967" height="558" alt="Screenshot 2026-03-17 at 3 05 06 PM" src="https://github.com/user-attachments/assets/fc03ce43-906c-474d-b3bc-ddf08eb37c75" /> ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-10195-fix-subgraph-promoted-widget-input-slot-positions-after-label-rename-3266d73d365081dfa623dd94dd87c718) by [Unito](https://www.unito.io) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: jaeone94 <jaeone.prt@gmail.com>
872 lines
28 KiB
TypeScript
872 lines
28 KiB
TypeScript
import { setActivePinia } from 'pinia'
|
|
import { createTestingPinia } from '@pinia/testing'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { computed, nextTick, watch } from 'vue'
|
|
|
|
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
|
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
|
|
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
|
import {
|
|
createTestSubgraph,
|
|
createTestSubgraphNode
|
|
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
|
|
import { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
|
|
import { app } from '@/scripts/app'
|
|
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
|
|
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
|
|
import { useSettingStore } from '@/platform/settings/settingStore'
|
|
import { usePromotionStore } from '@/stores/promotionStore'
|
|
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
|
|
|
describe('Node Reactivity', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
function createTestGraph() {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('input', 'INT')
|
|
node.addWidget('number', 'testnum', 2, () => undefined, {})
|
|
graph.add(node)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
|
|
return { node, graph, vueNodeData }
|
|
}
|
|
|
|
it('widget values are reactive through the store', async () => {
|
|
const { node, graph } = createTestGraph()
|
|
const store = useWidgetValueStore()
|
|
const widget = node.widgets![0]
|
|
|
|
// Verify widget is a BaseWidget with correct value and node assignment
|
|
expect(widget).toBeInstanceOf(BaseWidget)
|
|
expect(widget.value).toBe(2)
|
|
expect((widget as BaseWidget).node.id).toBe(node.id)
|
|
|
|
// Initial value should be in store after setNodeId was called
|
|
expect(store.getWidget(graph.id, node.id, 'testnum')?.value).toBe(2)
|
|
|
|
const state = store.getWidget(graph.id, node.id, 'testnum')
|
|
if (!state) throw new Error('Expected widget state to exist')
|
|
|
|
const onValueChange = vi.fn()
|
|
const widgetValue = computed(() => state.value)
|
|
watch(widgetValue, onValueChange)
|
|
|
|
widget.value = 42
|
|
await nextTick()
|
|
|
|
expect(widgetValue.value).toBe(42)
|
|
expect(onValueChange).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('widget values remain reactive after a connection is made', async () => {
|
|
const { node, graph } = createTestGraph()
|
|
const store = useWidgetValueStore()
|
|
const onValueChange = vi.fn()
|
|
|
|
graph.trigger('node:slot-links:changed', {
|
|
nodeId: String(node.id),
|
|
slotType: NodeSlotType.INPUT
|
|
})
|
|
await nextTick()
|
|
|
|
const state = store.getWidget(graph.id, node.id, 'testnum')
|
|
if (!state) throw new Error('Expected widget state to exist')
|
|
|
|
const widgetValue = computed(() => state.value)
|
|
watch(widgetValue, onValueChange)
|
|
|
|
node.widgets![0].value = 99
|
|
await nextTick()
|
|
|
|
expect(onValueChange).toHaveBeenCalledTimes(1)
|
|
expect(widgetValue.value).toBe(99)
|
|
})
|
|
})
|
|
|
|
describe('Widget slotMetadata reactivity on link disconnect', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
function createWidgetInputGraph() {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
|
|
// Add a widget and an associated input slot (simulates "widget converted to input")
|
|
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
|
const input = node.addInput('prompt', 'STRING')
|
|
// Associate the input slot with the widget (as widgetInputs extension does)
|
|
input.widget = { name: 'prompt' }
|
|
|
|
// Start with a connected link
|
|
input.link = 42
|
|
|
|
graph.add(node)
|
|
return { graph, node }
|
|
}
|
|
|
|
it('sets slotMetadata.linked to true when input has a link', () => {
|
|
const { graph, node } = createWidgetInputGraph()
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
|
|
const nodeData = vueNodeData.get(String(node.id))
|
|
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
|
|
|
expect(widgetData?.slotMetadata).toBeDefined()
|
|
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
|
})
|
|
|
|
it('updates slotMetadata.linked to false after link disconnect event', async () => {
|
|
const { graph, node } = createWidgetInputGraph()
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
|
|
const nodeData = vueNodeData.get(String(node.id))
|
|
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
|
|
|
// Verify initially linked
|
|
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
|
|
|
// Simulate link disconnection (as LiteGraph does before firing the event)
|
|
node.inputs[0].link = null
|
|
|
|
// Fire the trigger event that LiteGraph fires on disconnect
|
|
graph.trigger('node:slot-links:changed', {
|
|
nodeId: node.id,
|
|
slotType: NodeSlotType.INPUT,
|
|
slotIndex: 0,
|
|
connected: false,
|
|
linkId: 42
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
// slotMetadata.linked should now be false
|
|
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
|
})
|
|
|
|
it('reactively updates disabled state in a derived computed after disconnect', async () => {
|
|
const { graph, node } = createWidgetInputGraph()
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
|
|
const nodeData = vueNodeData.get(String(node.id))!
|
|
|
|
// Mimic what processedWidgets does in NodeWidgets.vue:
|
|
// derive disabled from slotMetadata.linked
|
|
const derivedDisabled = computed(() => {
|
|
const widgets = nodeData.widgets ?? []
|
|
const widget = widgets.find((w) => w.name === 'prompt')
|
|
return widget?.slotMetadata?.linked ? true : false
|
|
})
|
|
|
|
// Initially linked → disabled
|
|
expect(derivedDisabled.value).toBe(true)
|
|
|
|
// Track changes
|
|
const onChange = vi.fn()
|
|
watch(derivedDisabled, onChange)
|
|
|
|
// Simulate disconnect
|
|
node.inputs[0].link = null
|
|
graph.trigger('node:slot-links:changed', {
|
|
nodeId: node.id,
|
|
slotType: NodeSlotType.INPUT,
|
|
slotIndex: 0,
|
|
connected: false,
|
|
linkId: 42
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
// The derived computed should now return false
|
|
expect(derivedDisabled.value).toBe(false)
|
|
expect(onChange).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
|
|
// Set up a subgraph with an interior node that has a "prompt" widget.
|
|
// createPromotedWidgetView resolves against this interior node.
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('interior')
|
|
interiorNode.id = 10
|
|
interiorNode.addWidget('string', 'prompt', 'hello', () => undefined, {})
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
|
|
|
// Create a PromotedWidgetView with identityName="value" (subgraph input
|
|
// slot name) and sourceWidgetName="prompt" (interior widget name).
|
|
// PromotedWidgetView.name returns "value" (identity), safeWidgetMapper
|
|
// sets SafeWidgetData.name to sourceWidgetName ("prompt").
|
|
const promotedView = createPromotedWidgetView(
|
|
subgraphNode,
|
|
'10',
|
|
'prompt',
|
|
'value',
|
|
undefined,
|
|
'value'
|
|
)
|
|
|
|
// Host the promoted view on a regular node so we can control widgets
|
|
// directly (SubgraphNode.widgets is a synthetic getter).
|
|
const graph = new LGraph()
|
|
const hostNode = new LGraphNode('host')
|
|
hostNode.widgets = [promotedView]
|
|
const input = hostNode.addInput('value', 'STRING')
|
|
input.widget = { name: 'value' }
|
|
input.link = 42
|
|
graph.add(hostNode)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(hostNode.id))
|
|
|
|
// SafeWidgetData.name is "prompt" (sourceWidgetName), but the
|
|
// input slot widget name is "value" — slotName bridges this gap.
|
|
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
|
|
expect(widgetData).toBeDefined()
|
|
expect(widgetData?.slotName).toBe('value')
|
|
expect(widgetData?.slotMetadata?.linked).toBe(true)
|
|
|
|
// Disconnect
|
|
hostNode.inputs[0].link = null
|
|
graph.trigger('node:slot-links:changed', {
|
|
nodeId: hostNode.id,
|
|
slotType: NodeSlotType.INPUT,
|
|
slotIndex: 0,
|
|
connected: false,
|
|
linkId: 42
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
expect(widgetData?.slotMetadata?.linked).toBe(false)
|
|
})
|
|
|
|
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [
|
|
{ name: 'seed', type: '*' },
|
|
{ name: 'seed', type: '*' }
|
|
]
|
|
})
|
|
|
|
const firstNode = new LGraphNode('FirstNode')
|
|
const firstInput = firstNode.addInput('seed', '*')
|
|
firstNode.addWidget('number', 'seed', 1, () => undefined, {})
|
|
firstInput.widget = { name: 'seed' }
|
|
subgraph.add(firstNode)
|
|
|
|
const secondNode = new LGraphNode('SecondNode')
|
|
const secondInput = secondNode.addInput('seed', '*')
|
|
secondNode.addWidget('number', 'seed', 2, () => undefined, {})
|
|
secondInput.widget = { name: 'seed' }
|
|
subgraph.add(secondNode)
|
|
|
|
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
|
|
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 124 })
|
|
const graph = subgraphNode.graph
|
|
if (!graph) throw new Error('Expected subgraph node graph')
|
|
graph.add(subgraphNode)
|
|
|
|
const promotedViews = subgraphNode.widgets
|
|
const secondPromotedView = promotedViews[1]
|
|
if (!secondPromotedView) throw new Error('Expected second promoted view')
|
|
|
|
;(
|
|
secondPromotedView as unknown as {
|
|
sourceNodeId: string
|
|
sourceWidgetName: string
|
|
}
|
|
).sourceNodeId = '9999'
|
|
;(
|
|
secondPromotedView as unknown as {
|
|
sourceNodeId: string
|
|
sourceWidgetName: string
|
|
}
|
|
).sourceWidgetName = 'stale_widget'
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
|
const secondMappedWidget = nodeData?.widgets?.find(
|
|
(widget) => widget.slotMetadata?.index === 1
|
|
)
|
|
if (!secondMappedWidget)
|
|
throw new Error('Expected mapped widget for slot 1')
|
|
|
|
expect(secondMappedWidget.name).not.toBe('stale_widget')
|
|
})
|
|
|
|
it('clears stale slotMetadata when input no longer matches widget', async () => {
|
|
const { graph, node } = createWidgetInputGraph()
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
|
|
const nodeData = vueNodeData.get(String(node.id))!
|
|
const widgetData = nodeData.widgets!.find((w) => w.name === 'prompt')!
|
|
|
|
expect(widgetData.slotMetadata?.linked).toBe(true)
|
|
|
|
node.inputs[0].name = 'other'
|
|
node.inputs[0].widget = { name: 'other' }
|
|
node.inputs[0].link = null
|
|
|
|
graph.trigger('node:slot-links:changed', {
|
|
nodeId: node.id,
|
|
slotType: NodeSlotType.INPUT,
|
|
slotIndex: 0,
|
|
connected: false,
|
|
linkId: 42
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
expect(widgetData.slotMetadata).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('Subgraph output slot label reactivity', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
it('updates output slot labels when node:slot-label:changed is triggered', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addOutput('original_name', 'STRING')
|
|
node.addOutput('other_name', 'STRING')
|
|
graph.add(node)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeId = String(node.id)
|
|
const nodeData = vueNodeData.get(nodeId)
|
|
if (!nodeData?.outputs) throw new Error('Expected output data to exist')
|
|
|
|
expect(nodeData.outputs[0].label).toBeUndefined()
|
|
expect(nodeData.outputs[1].label).toBeUndefined()
|
|
|
|
// Simulate what SubgraphNode does: set the label, then fire the trigger
|
|
node.outputs[0].label = 'custom_label'
|
|
graph.trigger('node:slot-label:changed', {
|
|
nodeId: node.id,
|
|
slotType: NodeSlotType.OUTPUT
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
const updatedData = vueNodeData.get(nodeId)
|
|
expect(updatedData?.outputs?.[0]?.label).toBe('custom_label')
|
|
expect(updatedData?.outputs?.[1]?.label).toBeUndefined()
|
|
})
|
|
|
|
it('updates input slot labels when node:slot-label:changed is triggered', async () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addInput('original_name', 'STRING')
|
|
graph.add(node)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeId = String(node.id)
|
|
const nodeData = vueNodeData.get(nodeId)
|
|
if (!nodeData?.inputs) throw new Error('Expected input data to exist')
|
|
|
|
expect(nodeData.inputs[0].label).toBeUndefined()
|
|
|
|
node.inputs[0].label = 'custom_label'
|
|
graph.trigger('node:slot-label:changed', {
|
|
nodeId: node.id,
|
|
slotType: NodeSlotType.INPUT
|
|
})
|
|
|
|
await nextTick()
|
|
|
|
const updatedData = vueNodeData.get(nodeId)
|
|
expect(updatedData?.inputs?.[0]?.label).toBe('custom_label')
|
|
})
|
|
|
|
it('ignores node:slot-label:changed for unknown node ids', () => {
|
|
const graph = new LGraph()
|
|
useGraphNodeManager(graph)
|
|
|
|
expect(() =>
|
|
graph.trigger('node:slot-label:changed', {
|
|
nodeId: 'missing-node',
|
|
slotType: NodeSlotType.OUTPUT
|
|
})
|
|
).not.toThrow()
|
|
})
|
|
})
|
|
|
|
describe('Subgraph Promoted Pseudo Widgets', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('interior')
|
|
interiorNode.id = 10
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
|
sourceNodeId: '10',
|
|
sourceWidgetName: '$$canvas-image-preview'
|
|
})
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const vueNode = vueNodeData.get(String(subgraphNode.id))
|
|
const promotedWidget = vueNode?.widgets?.find(
|
|
(widget) => widget.name === '$$canvas-image-preview'
|
|
)
|
|
|
|
expect(promotedWidget).toBeDefined()
|
|
expect(promotedWidget?.options?.canvasOnly).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('Nested promoted widget mapping', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
it('maps store identity to deepest concrete widget for two-layer promotions', () => {
|
|
const subgraphA = createTestSubgraph({
|
|
inputs: [{ name: 'a_input', type: '*' }]
|
|
})
|
|
const innerNode = new LGraphNode('InnerComboNode')
|
|
const innerInput = innerNode.addInput('picker_input', '*')
|
|
innerNode.addWidget('combo', 'picker', 'a', () => undefined, {
|
|
values: ['a', 'b']
|
|
})
|
|
innerInput.widget = { name: 'picker' }
|
|
subgraphA.add(innerNode)
|
|
subgraphA.inputNode.slots[0].connect(innerInput, innerNode)
|
|
|
|
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 11 })
|
|
|
|
const subgraphB = createTestSubgraph({
|
|
inputs: [{ name: 'b_input', type: '*' }]
|
|
})
|
|
subgraphB.add(subgraphNodeA)
|
|
subgraphNodeA._internalConfigureAfterSlots()
|
|
subgraphB.inputNode.slots[0].connect(subgraphNodeA.inputs[0], subgraphNodeA)
|
|
|
|
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 22 })
|
|
const graph = subgraphNodeB.graph as LGraph
|
|
graph.add(subgraphNodeB)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(subgraphNodeB.id))
|
|
const mappedWidget = nodeData?.widgets?.[0]
|
|
|
|
expect(mappedWidget).toBeDefined()
|
|
expect(mappedWidget?.type).toBe('combo')
|
|
expect(mappedWidget?.storeName).toBe('picker')
|
|
expect(mappedWidget?.storeNodeId).toBe(
|
|
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
|
|
)
|
|
})
|
|
|
|
it('keeps linked and independent same-name promotions as distinct sources', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'string_a', type: '*' }]
|
|
})
|
|
|
|
const linkedNode = new LGraphNode('LinkedNode')
|
|
const linkedInput = linkedNode.addInput('string_a', '*')
|
|
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
|
|
linkedInput.widget = { name: 'string_a' }
|
|
subgraph.add(linkedNode)
|
|
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
|
|
|
|
const independentNode = new LGraphNode('IndependentNode')
|
|
independentNode.addWidget(
|
|
'text',
|
|
'string_a',
|
|
'independent',
|
|
() => undefined,
|
|
{}
|
|
)
|
|
subgraph.add(independentNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
|
|
sourceNodeId: String(independentNode.id),
|
|
sourceWidgetName: 'string_a'
|
|
})
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
|
const promotedWidgets = nodeData?.widgets?.filter(
|
|
(widget) => widget.name === 'string_a'
|
|
)
|
|
|
|
expect(promotedWidgets).toHaveLength(2)
|
|
expect(
|
|
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
|
).toEqual(
|
|
new Set([
|
|
`${subgraph.id}:${linkedNode.id}`,
|
|
`${subgraph.id}:${independentNode.id}`
|
|
])
|
|
)
|
|
})
|
|
|
|
it('maps duplicate-name promoted views from same intermediate node to distinct store identities', () => {
|
|
const innerSubgraph = createTestSubgraph()
|
|
const firstTextNode = new LGraphNode('FirstTextNode')
|
|
firstTextNode.addWidget('text', 'text', '11111111111', () => undefined)
|
|
innerSubgraph.add(firstTextNode)
|
|
|
|
const secondTextNode = new LGraphNode('SecondTextNode')
|
|
secondTextNode.addWidget('text', 'text', '22222222222', () => undefined)
|
|
innerSubgraph.add(secondTextNode)
|
|
|
|
const outerSubgraph = createTestSubgraph()
|
|
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
|
|
id: 3,
|
|
parentGraph: outerSubgraph
|
|
})
|
|
outerSubgraph.add(innerSubgraphNode)
|
|
|
|
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, { id: 4 })
|
|
const graph = outerSubgraphNode.graph as LGraph
|
|
graph.add(outerSubgraphNode)
|
|
|
|
usePromotionStore().setPromotions(
|
|
innerSubgraphNode.rootGraph.id,
|
|
innerSubgraphNode.id,
|
|
[
|
|
{ sourceNodeId: String(firstTextNode.id), sourceWidgetName: 'text' },
|
|
{ sourceNodeId: String(secondTextNode.id), sourceWidgetName: 'text' }
|
|
]
|
|
)
|
|
|
|
usePromotionStore().setPromotions(
|
|
outerSubgraphNode.rootGraph.id,
|
|
outerSubgraphNode.id,
|
|
[
|
|
{
|
|
sourceNodeId: String(innerSubgraphNode.id),
|
|
sourceWidgetName: 'text',
|
|
disambiguatingSourceNodeId: String(firstTextNode.id)
|
|
},
|
|
{
|
|
sourceNodeId: String(innerSubgraphNode.id),
|
|
sourceWidgetName: 'text',
|
|
disambiguatingSourceNodeId: String(secondTextNode.id)
|
|
}
|
|
]
|
|
)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(outerSubgraphNode.id))
|
|
const promotedWidgets = nodeData?.widgets?.filter(
|
|
(widget) => widget.name === 'text'
|
|
)
|
|
|
|
expect(promotedWidgets).toHaveLength(2)
|
|
expect(
|
|
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
|
|
).toEqual(
|
|
new Set([
|
|
`${outerSubgraphNode.subgraph.id}:${firstTextNode.id}`,
|
|
`${outerSubgraphNode.subgraph.id}:${secondTextNode.id}`
|
|
])
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('Promoted widget sourceExecutionId', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
it('sets sourceExecutionId to the interior node execution ID for promoted widgets', () => {
|
|
const subgraph = createTestSubgraph({
|
|
inputs: [{ name: 'ckpt_input', type: '*' }]
|
|
})
|
|
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
|
|
const interiorInput = interiorNode.addInput('ckpt_input', '*')
|
|
interiorNode.addWidget(
|
|
'combo',
|
|
'ckpt_name',
|
|
'model.safetensors',
|
|
() => undefined,
|
|
{
|
|
values: ['model.safetensors']
|
|
}
|
|
)
|
|
interiorInput.widget = { name: 'ckpt_name' }
|
|
subgraph.add(interiorNode)
|
|
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
|
|
subgraphNode._internalConfigureAfterSlots()
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(subgraphNode.id))
|
|
const promotedWidget = nodeData?.widgets?.find(
|
|
(w) => w.name === 'ckpt_name'
|
|
)
|
|
|
|
expect(promotedWidget).toBeDefined()
|
|
// The interior node is inside subgraphNode (id=65),
|
|
// so its execution ID should be "65:<interiorNodeId>"
|
|
expect(promotedWidget?.sourceExecutionId).toBe(
|
|
`${subgraphNode.id}:${interiorNode.id}`
|
|
)
|
|
})
|
|
|
|
it('does not set sourceExecutionId for non-promoted widgets', () => {
|
|
const graph = new LGraph()
|
|
const node = new LGraphNode('test')
|
|
node.addWidget('number', 'steps', 20, () => undefined, {})
|
|
graph.add(node)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
|
|
const { vueNodeData } = useGraphNodeManager(graph)
|
|
const nodeData = vueNodeData.get(String(node.id))
|
|
const widget = nodeData?.widgets?.find((w) => w.name === 'steps')
|
|
|
|
expect(widget).toBeDefined()
|
|
expect(widget?.sourceExecutionId).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('reconcileNodeErrorFlags (via lastNodeErrors watcher)', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createTestingPinia({ stubActions: false }))
|
|
})
|
|
|
|
function setupGraphWithStore() {
|
|
const graph = new LGraph()
|
|
const nodeA = new LGraphNode('KSampler')
|
|
nodeA.addInput('model', 'MODEL')
|
|
nodeA.addInput('steps', 'INT')
|
|
graph.add(nodeA)
|
|
|
|
const nodeB = new LGraphNode('LoadCheckpoint')
|
|
nodeB.addInput('ckpt_name', 'STRING')
|
|
graph.add(nodeB)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
|
|
|
const settingStore = useSettingStore()
|
|
settingStore.settingValues['Comfy.RightSidePanel.ShowErrorsTab'] = true
|
|
|
|
// Initialize store (triggers watcher registration)
|
|
useGraphNodeManager(graph)
|
|
const store = useExecutionErrorStore()
|
|
return { graph, nodeA, nodeB, store }
|
|
}
|
|
|
|
it('sets has_errors on nodes referenced in lastNodeErrors', async () => {
|
|
const { nodeA, nodeB, store } = setupGraphWithStore()
|
|
|
|
store.lastNodeErrors = {
|
|
[String(nodeA.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'Too big',
|
|
details: '',
|
|
extra_info: { input_name: 'steps' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'KSampler'
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
expect(nodeA.has_errors).toBe(true)
|
|
expect(nodeB.has_errors).toBeFalsy()
|
|
})
|
|
|
|
it('sets slot hasErrors for inputs matching error input_name', async () => {
|
|
const { nodeA, store } = setupGraphWithStore()
|
|
|
|
store.lastNodeErrors = {
|
|
[String(nodeA.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'required_input_missing',
|
|
message: 'Missing',
|
|
details: '',
|
|
extra_info: { input_name: 'model' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'KSampler'
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
expect(nodeA.inputs[0].hasErrors).toBe(true)
|
|
expect(nodeA.inputs[1].hasErrors).toBe(false)
|
|
})
|
|
|
|
it('clears has_errors and slot hasErrors when errors are removed', async () => {
|
|
const { nodeA, store } = setupGraphWithStore()
|
|
|
|
store.lastNodeErrors = {
|
|
[String(nodeA.id)]: {
|
|
errors: [
|
|
{
|
|
type: 'value_bigger_than_max',
|
|
message: 'Too big',
|
|
details: '',
|
|
extra_info: { input_name: 'steps' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'KSampler'
|
|
}
|
|
}
|
|
await nextTick()
|
|
expect(nodeA.has_errors).toBe(true)
|
|
expect(nodeA.inputs[1].hasErrors).toBe(true)
|
|
|
|
store.lastNodeErrors = null
|
|
await nextTick()
|
|
|
|
expect(nodeA.has_errors).toBeFalsy()
|
|
expect(nodeA.inputs[1].hasErrors).toBe(false)
|
|
})
|
|
|
|
it('propagates has_errors to parent subgraph node', async () => {
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('InnerNode')
|
|
interiorNode.addInput('value', 'INT')
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
|
|
|
useGraphNodeManager(graph)
|
|
const store = useExecutionErrorStore()
|
|
|
|
// Error on interior node: execution ID = "50:<interiorNodeId>"
|
|
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
|
|
store.lastNodeErrors = {
|
|
[interiorExecId]: {
|
|
errors: [
|
|
{
|
|
type: 'required_input_missing',
|
|
message: 'Missing',
|
|
details: '',
|
|
extra_info: { input_name: 'value' }
|
|
}
|
|
],
|
|
dependent_outputs: [],
|
|
class_type: 'InnerNode'
|
|
}
|
|
}
|
|
await nextTick()
|
|
|
|
// Interior node should have the error
|
|
expect(interiorNode.has_errors).toBe(true)
|
|
expect(interiorNode.inputs[0].hasErrors).toBe(true)
|
|
// Parent subgraph node should also be flagged
|
|
expect(subgraphNode.has_errors).toBe(true)
|
|
})
|
|
|
|
it('sets has_errors on nodes with missing models', async () => {
|
|
const { nodeA, nodeB } = setupGraphWithStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
missingModelStore.setMissingModels([
|
|
{
|
|
nodeId: String(nodeA.id),
|
|
nodeType: 'CheckpointLoader',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'missing.safetensors',
|
|
isMissing: true
|
|
}
|
|
])
|
|
await nextTick()
|
|
|
|
expect(nodeA.has_errors).toBe(true)
|
|
expect(nodeB.has_errors).toBeFalsy()
|
|
})
|
|
|
|
it('clears has_errors when missing models are removed', async () => {
|
|
const { nodeA } = setupGraphWithStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
missingModelStore.setMissingModels([
|
|
{
|
|
nodeId: String(nodeA.id),
|
|
nodeType: 'CheckpointLoader',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'missing.safetensors',
|
|
isMissing: true
|
|
}
|
|
])
|
|
await nextTick()
|
|
expect(nodeA.has_errors).toBe(true)
|
|
|
|
missingModelStore.clearMissingModels()
|
|
await nextTick()
|
|
expect(nodeA.has_errors).toBeFalsy()
|
|
})
|
|
|
|
it('flags parent subgraph node when interior node has missing model', async () => {
|
|
const subgraph = createTestSubgraph()
|
|
const interiorNode = new LGraphNode('CheckpointLoader')
|
|
subgraph.add(interiorNode)
|
|
|
|
const subgraphNode = createTestSubgraphNode(subgraph, { id: 50 })
|
|
const graph = subgraphNode.graph as LGraph
|
|
graph.add(subgraphNode)
|
|
|
|
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
|
|
vi.spyOn(app, 'isGraphReady', 'get').mockReturnValue(true)
|
|
|
|
const settingStore = useSettingStore()
|
|
settingStore.settingValues['Comfy.RightSidePanel.ShowErrorsTab'] = true
|
|
|
|
useGraphNodeManager(graph)
|
|
useExecutionErrorStore()
|
|
const missingModelStore = useMissingModelStore()
|
|
|
|
missingModelStore.setMissingModels([
|
|
{
|
|
nodeId: `${subgraphNode.id}:${interiorNode.id}`,
|
|
nodeType: 'CheckpointLoader',
|
|
widgetName: 'ckpt_name',
|
|
isAssetSupported: false,
|
|
name: 'missing.safetensors',
|
|
isMissing: true
|
|
}
|
|
])
|
|
await nextTick()
|
|
|
|
expect(interiorNode.has_errors).toBe(true)
|
|
expect(subgraphNode.has_errors).toBe(true)
|
|
})
|
|
})
|