test: add unit and E2E tests for promoted widget identity and link position

Unit tests:
- widgetUtil: verify rename does not propagate to interior nodes
- promotedWidgetView: identityName vs label separation, fixture-based
  configure round-trip using renamedPromotedLabels fixture
- useGraphNodeManager: promotedLabel field for promoted/regular widgets

E2E tests (subgraphPromotedWidgetLabel):
- Rename non-propagation: interior KSampler widget names unchanged
- Fix existing tests to use widget.label instead of widget.name

E2E tests (subgraphPromotedSlotLinks, new):
- Vue-to-Legacy switch: link endpoints at slot position, not header
- Legacy-to-Vue switch: link endpoints converge after debounced reset
- Draft restore: promoted widgets visible after draft reload
- Screenshot assertions for all three scenarios

New fixtures:
- renamedPromotedLabels.ts: TS fixture for unit test configure()
- subgraph-promoted-linked.json: E2E fixture with external link
This commit is contained in:
jaeone94
2026-03-23 23:01:07 +09:00
parent e785488fa7
commit 5bddd078d3
7 changed files with 931 additions and 50 deletions

View File

@@ -0,0 +1,237 @@
{
"id": "b2c3d4e5-f6a7-8901-bcde-f12345678901",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-0001-4548-801a-5aead34d879e",
"pos": [600, 300],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"label": "my_seed",
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": 1
},
{
"label": "num_steps",
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {
"proxyWidgets": [
["1", "seed"],
["1", "steps"]
]
},
"widgets_values": []
},
{
"id": 3,
"type": "PrimitiveNode",
"pos": [200, 350],
"size": [210, 82],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"links": [1]
}
],
"title": "Seed Source",
"properties": {
"Run widget replace on values": false
},
"widgets_values": [42, "fixed"]
}
],
"links": [[1, 3, 0, 2, 1, "INT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-0001-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 2,
"lastLinkId": 6,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Renamed Labels Subgraph",
"inputNode": {
"id": -10,
"bounding": [200, 300, 120, 100]
},
"outputNode": {
"id": -20,
"bounding": [900, 400, 120, 60]
},
"inputs": [
{
"id": "slot-positive",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [1],
"pos": [220, 320]
},
{
"id": "slot-seed",
"name": "seed",
"type": "INT",
"linkIds": [3],
"label": "my_seed",
"pos": [220, 340]
},
{
"id": "slot-steps",
"name": "steps",
"type": "INT",
"linkIds": [4],
"label": "num_steps",
"pos": [220, 360]
}
],
"outputs": [
{
"id": "slot-latent-out",
"name": "LATENT",
"type": "LATENT",
"linkIds": [2],
"pos": [920, 420]
}
],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "KSampler",
"pos": [500, 200],
"size": [270, 262],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
},
{
"name": "seed",
"type": "INT",
"widget": { "name": "seed" },
"link": 3
},
{
"name": "steps",
"type": "INT",
"widget": { "name": "steps" },
"link": 4
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [2]
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [0, "randomize", 20, 8, "euler", "simple", 1]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 1,
"type": "CONDITIONING"
},
{
"id": 2,
"origin_id": 1,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "LATENT"
},
{
"id": 3,
"origin_id": -10,
"origin_slot": 1,
"target_id": 1,
"target_slot": 4,
"type": "INT"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 2,
"target_id": 1,
"target_slot": 5,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
},
"frontendVersion": "1.41.21"
},
"version": 0.4
}

View File

@@ -0,0 +1,185 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/**
* Returns the link endpoint Y position for a specific input slot on a node.
* Compares against the node's header Y to detect header-fallback.
*/
async function getLinkTargetY(
comfyPage: ComfyPage,
nodeId: string,
inputIndex: number
): Promise<{ slotY: number; headerY: number }> {
return comfyPage.page.evaluate(
([id, idx]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found`)
const slotPos = node.getConnectionPos(true, idx)
return {
slotY: slotPos[1],
headerY: node.pos[1]
}
},
[nodeId, inputIndex] as const
)
}
const LINKED_WORKFLOW = 'subgraphs/subgraph-promoted-linked'
const SUBGRAPH_NODE_ID = '2'
// Input index 1 = "seed" slot (index 0 = "positive", non-widget)
const SEED_INPUT_INDEX = 1
test.describe(
'Subgraph promoted widget link position on mode switch',
{ tag: ['@subgraph', '@canvas', '@screenshot'] },
() => {
test.describe('Vue-to-Legacy switch', () => {
test('Link endpoints render at correct slot position, not header', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Switch to legacy
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.nextFrame()
// Link endpoint should NOT be at header position
await expect
.poll(async () => {
const { slotY, headerY } = await getLinkTargetY(
comfyPage,
SUBGRAPH_NODE_ID,
SEED_INPUT_INDEX
)
return slotY - headerY
})
.toBeGreaterThan(20)
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-vue-to-legacy.png'
)
})
})
test.describe('Legacy-to-Vue switch', () => {
test('Link endpoints converge to correct slot position after mode switch', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// Load subgraph workflow and let draft persist
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.nextFrame()
await comfyPage.page.waitForTimeout(1000)
// Switch to legacy and reload — app restores draft in legacy mode
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// Switch to Vue
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
// Wait for debounced lifecycle reset (800ms + margin)
await expect
.poll(
async () => {
const { slotY, headerY } = await getLinkTargetY(
comfyPage,
SUBGRAPH_NODE_ID,
SEED_INPUT_INDEX
)
return slotY - headerY
},
{ timeout: 5000 }
)
.toBeGreaterThan(20)
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-legacy-to-vue.png'
)
})
})
test.describe('Draft restore', () => {
test('Link endpoints are correct when app restores a draft workflow on startup', async ({
comfyPage
}) => {
// 1. Enable Vue mode and workflow persistence
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.Workflow.Persist', true)
// 2. Load the subgraph workflow — persistence auto-saves a draft
await comfyPage.workflow.loadWorkflow(LINKED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// 3. Wait for debounced draft persistence (512ms debounce + margin)
await comfyPage.page.waitForTimeout(1000)
// 4. Reload — app restores the draft via tryLoadGraph (single configure)
await comfyPage.page.reload({ waitUntil: 'networkidle' })
// Wait for app to be ready (same checks as setup() but without navigation)
await comfyPage.page.waitForFunction(
() => window.app && window.app.extensionManager
)
await comfyPage.page.waitForSelector('.p-blockui-mask', {
state: 'hidden'
})
await comfyPage.nextFrame()
// 5. Verify the draft was restored with the subgraph workflow
await expect
.poll(
async () => {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas?.graph
if (!graph) return null
const sgNode = graph._nodes.find((n) => n.isSubgraphNode())
if (!sgNode) return null
return sgNode.widgets?.length ?? 0
})
},
{ timeout: 10000 }
)
.toBeGreaterThanOrEqual(2)
// 6. Verify promoted widget labels are visible in Vue DOM
await comfyPage.vueNodes.waitForNodes()
const sgNodeId = await comfyPage.page.evaluate(() => {
const sgNode = window.app!.canvas!.graph!._nodes.find((n) =>
n.isSubgraphNode()
)
return sgNode ? String(sgNode.id) : null
})
expect(sgNodeId).not.toBeNull()
const vueNode = comfyPage.vueNodes.getNodeLocator(sgNodeId!)
await expect(vueNode).toBeVisible()
const nodeBody = vueNode.locator(
`[data-testid="node-body-${sgNodeId}"]`
)
await expect(nodeBody).toContainText('my_seed')
await expect(comfyPage.canvas).toHaveScreenshot(
'promoted-link-draft-restore.png'
)
})
})
}
)

View File

@@ -1,8 +1,34 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import type { ComfyPage } from '../fixtures/ComfyPage'
/** Read interior node widget names from the subgraph without navigating into it. */
async function getInteriorWidgetNames(
comfyPage: ComfyPage,
subgraphNodeId: string,
interiorType: string
): Promise<string[]> {
return comfyPage.page.evaluate(
([nodeId, type]) => {
const sgNode = window.app!.canvas.graph!.getNodeById(
nodeId
) as unknown as SubgraphNode
const subgraph = sgNode?.subgraph
if (!subgraph) throw new Error('No subgraph found')
const interior = (subgraph._nodes as LGraphNode[]).find(
(n) => n.type === type
)
return ((interior?.widgets ?? []) as IBaseWidget[]).map((w) => w.name)
},
[subgraphNodeId, interiorType] as const
)
}
test.describe(
'Subgraph Promoted Widget Renamed Labels',
@@ -57,13 +83,13 @@ test.describe(
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const widgetNames = await comfyPage.page.evaluate((nodeId) => {
const widgetLabels = await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
return (node?.widgets ?? []).map((w) => w.name)
return (node?.widgets ?? []).map((w) => w.label)
}, SUBGRAPH_NODE_ID)
expect(widgetNames).toContain('my_seed')
expect(widgetNames).toContain('num_steps')
expect(widgetLabels).toContain('my_seed')
expect(widgetLabels).toContain('num_steps')
})
})
@@ -98,12 +124,59 @@ test.describe(
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify the promoted widget now shows the new label
const widgetNames = await comfyPage.page.evaluate((nodeId) => {
const widgetLabels = await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)
return (node?.widgets ?? []).map((w) => w.name)
return (node?.widgets ?? []).map((w) => w.label)
}, SUBGRAPH_NODE_ID)
expect(widgetNames).toContain('renamed_seed')
expect(widgetLabels).toContain('renamed_seed')
})
})
test.describe('Rename non-propagation', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Renaming a promoted widget does not change interior node widget names', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
// Read interior widget names via the subgraph object (no navigation needed)
const interiorWidgetsBefore = await getInteriorWidgetNames(
comfyPage,
SUBGRAPH_NODE_ID,
'KSampler'
)
// Rename "seed" slot from root graph
const subgraphNode =
await comfyPage.nodeOps.getNodeRefById(SUBGRAPH_NODE_ID)
await subgraphNode.navigateIntoSubgraph()
await comfyPage.subgraph.rightClickInputSlot('seed')
await comfyPage.contextMenu.clickLitegraphMenuItem('Rename Slot')
await comfyPage.nextFrame()
await comfyPage.page.waitForSelector('.graphdialog input', {
state: 'visible'
})
await comfyPage.page.fill('.graphdialog input', 'totally_new_name')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nextFrame()
// Navigate back to root
await comfyPage.subgraph.exitViaBreadcrumb()
// Verify interior widget names are unchanged
const interiorWidgetsAfter = await getInteriorWidgetNames(
comfyPage,
SUBGRAPH_NODE_ID,
'KSampler'
)
expect(interiorWidgetsAfter).toEqual(interiorWidgetsBefore)
})
})
@@ -128,8 +201,9 @@ test.describe(
}))
}, SUBGRAPH_NODE_ID)
const seedWidget = widgetNames.find((w) => w.name === 'my_seed')
const stepsWidget = widgetNames.find((w) => w.name === 'num_steps')
// name is now the stable identity (e.g. "seed"), label is the user rename
const seedWidget = widgetNames.find((w) => w.name === 'seed')
const stepsWidget = widgetNames.find((w) => w.name === 'steps')
expect(seedWidget).toBeDefined()
expect(stepsWidget).toBeDefined()

View File

@@ -197,14 +197,16 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
// Create a PromotedWidgetView with displayName="value" (subgraph input
// Create a PromotedWidgetView with identityName="value" (subgraph input
// slot name) and sourceWidgetName="prompt" (interior widget name).
// PromotedWidgetView.name returns "value", but safeWidgetMapper sets
// SafeWidgetData.name to sourceWidgetName ("prompt").
// PromotedWidgetView.name returns "value" (identityName), but
// safeWidgetMapper sets SafeWidgetData.name to sourceWidgetName ("prompt").
const promotedView = createPromotedWidgetView(
subgraphNode,
'10',
'prompt',
undefined,
undefined,
'value'
)
@@ -243,6 +245,45 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('sets promotedLabel from widget.label for promoted widget views', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const interiorNode = new LGraphNode('interior')
interiorNode.addWidget('number', 'seed', 42, () => undefined, {})
const interiorInput = interiorNode.addInput('seed', '*')
interiorInput.widget = { name: 'seed' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 200 })
const graph = subgraphNode.graph!
graph.add(subgraphNode)
// Rename the subgraph input to simulate user rename
const subgraphInput = subgraph.inputs[0]
if (subgraphInput) subgraph.renameInput(subgraphInput, 'my_seed')
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = nodeData?.widgets?.find(
(w) => w.promotedLabel !== undefined
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.promotedLabel).toBe('my_seed')
})
it('does not set promotedLabel for regular widgets', () => {
const { graph, node } = createWidgetInputGraph()
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(node.id))
const regularWidget = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(regularWidget).toBeDefined()
expect(regularWidget?.promotedLabel).toBeUndefined()
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
const subgraph = createTestSubgraph({
inputs: [

View File

@@ -6,13 +6,15 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
import {
CanvasPointer,
LGraph,
LGraphNode,
LiteGraph
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointerEvent,
LGraphCanvas,
SubgraphNode
ExportedSubgraphInstance,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -33,6 +35,10 @@ import {
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
renamedPromotedLabels,
SUBGRAPH_UUID
} from '@/lib/litegraph/src/__fixtures__/renamedPromotedLabels'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
@@ -138,7 +144,20 @@ describe(createPromotedWidgetView, () => {
expect(view.name).toBe('myWidget')
})
test('name uses displayName when provided', () => {
test('name uses identityName when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'1',
'myWidget',
'Custom Label',
undefined,
'value_1'
)
expect(view.name).toBe('value_1')
})
test('name falls back to sourceWidgetName when identityName is not provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
@@ -146,7 +165,7 @@ describe(createPromotedWidgetView, () => {
'myWidget',
'Custom Label'
)
expect(view.name).toBe('Custom Label')
expect(view.name).toBe('myWidget')
})
test('node getter returns the subgraphNode', () => {
@@ -328,17 +347,17 @@ describe(createPromotedWidgetView, () => {
expect(linkedNode.widgets?.[0].value).toBe('updated')
})
test('label falls back to displayName then widgetName', () => {
test('label falls back to displayName when no bound slot exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
// No displayName and no bound slot → undefined
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
expect(view1.label).toBe('myWidget')
expect(view1.label).toBeUndefined()
// With displayName → falls back to displayName
// With displayName but no bound slot → displayName
const view2 = createPromotedWidgetView(
subgraphNode,
bareId,
@@ -988,7 +1007,7 @@ describe('SubgraphNode.widgets getter', () => {
expect(secondNode.widgets?.[0].value).toBe('second-updated')
})
test('renaming an input updates linked promoted view display names', () => {
test('renaming an input updates label but preserves stable identity name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
@@ -1012,7 +1031,10 @@ describe('SubgraphNode.widgets getter', () => {
const afterRename = promotedWidgets(subgraphNode)[0]
if (!afterRename) throw new Error('Expected linked promoted view')
expect(afterRename.name).toBe('seed_renamed')
// name (identity) stays stable — used for slot matching
expect(afterRename.name).toBe('seed')
// label reflects the user-facing rename
expect(afterRename.label).toBe('seed_renamed')
})
test('caches view objects across getter calls (stable references)', () => {
@@ -2419,3 +2441,122 @@ describe('DOM widget promotion', () => {
)
})
})
describe('fixture: renamed promoted labels', () => {
class WidgetNode extends LGraphNode {
constructor() {
super('WidgetNode')
const seedInput = this.addInput('seed', 'INT')
seedInput.widget = { name: 'seed' }
const stepsInput = this.addInput('steps', 'INT')
stepsInput.widget = { name: 'steps' }
this.addOutput('OUTPUT', '*')
this.addWidget('number', 'seed', 0, () => {})
this.addWidget('number', 'steps', 20, () => {})
}
}
function registerSubgraphOnCreated(graph: LGraph) {
graph.events.addEventListener('subgraph-created', (e) => {
const { subgraph, data } = e.detail
const id = data.id
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(graph, subgraph, data as unknown as ExportedSubgraphInstance)
}
}
LiteGraph.registerNodeType(id, TestSubgraphNode)
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LiteGraph.registerNodeType('test/WidgetNode', WidgetNode)
})
afterEach(() => {
LiteGraph.unregisterNodeType('test/WidgetNode')
LiteGraph.unregisterNodeType(SUBGRAPH_UUID)
})
function loadFixture() {
const graph = new LGraph()
registerSubgraphOnCreated(graph)
graph.configure(structuredClone(renamedPromotedLabels))
const subgraphNode = graph._nodes.find(
(n) => n.type === SUBGRAPH_UUID
)! as SubgraphNode
return { graph, subgraphNode }
}
test('promoted widgets have correct identity names after configure', () => {
const { subgraphNode } = loadFixture()
expect(subgraphNode.isSubgraphNode()).toBe(true)
const widgets = subgraphNode.widgets
expect(widgets.length).toBeGreaterThanOrEqual(2)
const names = widgets.map((w) => w.name)
expect(names).toContain('seed')
expect(names).toContain('steps')
})
test('promoted widget labels reflect user-renamed values', () => {
const { subgraphNode } = loadFixture()
const widgets = subgraphNode.widgets
const seedWidget = widgets.find((w) => w.name === 'seed')
const stepsWidget = widgets.find((w) => w.name === 'steps')
expect(seedWidget?.label).toBe('my_seed')
expect(stepsWidget?.label).toBe('num_steps')
})
test('renaming promoted widget does not change identity name', () => {
const { subgraphNode } = loadFixture()
const seedWidget = subgraphNode.widgets.find((w) => w.name === 'seed')!
seedWidget.label = 'renamed_seed'
expect(seedWidget.name).toBe('seed')
expect(seedWidget.label).toBe('renamed_seed')
})
test('interior node widget names are not affected by exterior rename', () => {
const { subgraphNode } = loadFixture()
const seedWidget = subgraphNode.widgets.find((w) => w.name === 'seed')!
seedWidget.label = 'exterior_rename'
const interiorNode = subgraphNode.subgraph!._nodes.find(
(n) => n.type === 'test/WidgetNode'
)!
const interiorSeedWidget = interiorNode.widgets?.find(
(w) => w.name === 'seed'
)
expect(interiorSeedWidget).toBeDefined()
expect(interiorSeedWidget?.name).toBe('seed')
expect(interiorSeedWidget?.label).toBeUndefined()
})
test('renamed labels survive serialize → configure round-trip', () => {
const { graph } = loadFixture()
// Fixture already has renamed labels (my_seed, num_steps).
// Verify they survive a full serialize → configure cycle.
const serialized = graph.serialize()
const graph2 = new LGraph()
registerSubgraphOnCreated(graph2)
graph2.configure(serialized)
const subgraphNode2 = graph2._nodes.find(
(n) => n.type === SUBGRAPH_UUID
)! as SubgraphNode
const seedWidget = subgraphNode2.widgets.find((w) => w.name === 'seed')
const stepsWidget = subgraphNode2.widgets.find((w) => w.name === 'steps')
expect(seedWidget?.label).toBe('my_seed')
expect(stepsWidget?.label).toBe('num_steps')
})
})

View File

@@ -0,0 +1,189 @@
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
/**
* Subgraph with two promoted widget inputs that have user-renamed labels.
*
* Structure:
* Root graph → SubgraphNode (id 2, type = subgraph UUID)
* Interior: test/WidgetNode (id 1) with widget inputs "seed" and "steps"
*
* Subgraph inputs:
* - "seed" (label: "my_seed") → linked to interior node seed
* - "steps" (label: "num_steps") → linked to interior node steps
*
* The interior node type "test/WidgetNode" must be registered before
* configure() — see the test setup.
*/
export const SUBGRAPH_UUID = 'aaaa0000-0001-4000-8000-000000000001'
export const renamedPromotedLabels: SerialisableGraph = {
id: 'aaaa0000-0000-4000-8000-000000000001',
version: 1,
revision: 0,
state: {
lastNodeId: 2,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
nodes: [
{
id: 2,
type: SUBGRAPH_UUID,
pos: [400, 300],
size: [400, 200],
flags: {},
order: 0,
mode: 0,
inputs: [
{
label: 'my_seed',
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: null
},
{
label: 'num_steps',
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: null
}
],
outputs: [
{
name: 'OUTPUT',
type: '*',
links: null
}
],
properties: {
proxyWidgets: [
['1', 'seed'],
['1', 'steps']
]
},
widgets_values: []
}
],
links: [],
groups: [],
definitions: {
subgraphs: [
{
id: SUBGRAPH_UUID,
version: 1,
state: {
lastGroupId: 0,
lastNodeId: 1,
lastLinkId: 4,
lastRerouteId: 0
},
revision: 0,
config: {},
name: 'Renamed Labels Subgraph',
inputNode: {
id: -10,
bounding: [200, 300, 120, 100]
},
outputNode: {
id: -20,
bounding: [900, 400, 120, 60]
},
inputs: [
{
id: 'slot-seed',
name: 'seed',
type: 'INT',
linkIds: [1],
label: 'my_seed',
pos: [220, 320]
},
{
id: 'slot-steps',
name: 'steps',
type: 'INT',
linkIds: [2],
label: 'num_steps',
pos: [220, 340]
}
],
outputs: [
{
id: 'slot-out',
name: 'OUTPUT',
type: '*',
linkIds: [3],
pos: [920, 420]
}
],
widgets: [],
nodes: [
{
id: 1,
type: 'test/WidgetNode',
pos: [500, 200],
size: [270, 200],
flags: {},
order: 0,
mode: 0,
inputs: [
{
name: 'seed',
type: 'INT',
widget: { name: 'seed' },
link: 1
},
{
name: 'steps',
type: 'INT',
widget: { name: 'steps' },
link: 2
}
],
outputs: [
{
name: 'OUTPUT',
type: '*',
links: [3]
}
],
properties: {},
widgets_values: [42, 20]
}
],
groups: [],
links: [
{
id: 1,
origin_id: -10,
origin_slot: 0,
target_id: 1,
target_slot: 0,
type: 'INT'
},
{
id: 2,
origin_id: -10,
origin_slot: 1,
target_id: 1,
target_slot: 1,
type: 'INT'
},
{
id: 3,
origin_id: 1,
origin_slot: 0,
target_id: -20,
target_slot: 0,
type: '*'
}
],
extra: {}
}
]
},
config: {},
extra: {}
} as unknown as SerialisableGraph

View File

@@ -7,14 +7,6 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { getWidgetDefaultValue, renameWidget } from '@/utils/widgetUtil'
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn()
}))
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
const mockedResolve = vi.mocked(resolvePromotedWidgetSource)
describe('getWidgetDefaultValue', () => {
it('returns undefined for undefined spec', () => {
expect(getWidgetDefaultValue(undefined)).toBeUndefined()
@@ -100,46 +92,68 @@ describe('renameWidget', () => {
expect(widget.label).toBeUndefined()
})
it('renames promoted widget source when node is a subgraph without explicit parents', () => {
const sourceWidget = makeWidget({ name: 'innerSeed' })
it('does not propagate rename to interior node widgets or inputs', () => {
const interiorWidget = makeWidget({ name: 'innerSeed', label: undefined })
const interiorInput = {
name: 'innerSeed',
label: undefined,
widget: { name: 'innerSeed' }
} as INodeInputSlot
const interiorNode = makeNode({ inputs: [interiorInput] })
mockedResolve.mockReturnValue({
widget: sourceWidget,
node: interiorNode
})
// Promoted widget on SubgraphNode exterior
const promotedWidget = makeWidget({
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const subgraphNode = makeNode({ isSubgraph: true })
const subgraphInput = {
name: 'seed',
widget: { name: 'seed' }
} as INodeInputSlot
const subgraphNode = makeNode({
isSubgraph: true,
inputs: [subgraphInput]
})
const result = renameWidget(promotedWidget, subgraphNode, 'Renamed')
expect(result).toBe(true)
expect(sourceWidget.label).toBe('Renamed')
expect(interiorInput.label).toBe('Renamed')
// External label changed
expect(promotedWidget.label).toBe('Renamed')
expect(subgraphInput.label).toBe('Renamed')
// Interior widget and input remain untouched
expect(interiorWidget.label).toBeUndefined()
expect(interiorInput.label).toBeUndefined()
// Interior node was never accessed
expect(interiorNode.inputs[0].label).toBeUndefined()
})
it('does not resolve promoted widget source for non-subgraph node without parents', () => {
const promotedWidget = makeWidget({
it('only modifies the matching input, not other inputs', () => {
const widget = makeWidget({ name: 'seed' })
const matchingInput = {
name: 'seed',
sourceNodeId: '5',
sourceWidgetName: 'innerSeed'
})
const node = makeNode({ isSubgraph: false })
widget: { name: 'seed' }
} as INodeInputSlot
const otherInput = {
name: 'steps',
widget: { name: 'steps' }
} as INodeInputSlot
const node = makeNode({ inputs: [matchingInput, otherInput] })
const result = renameWidget(promotedWidget, node, 'Renamed')
renameWidget(widget, node, 'My Seed')
expect(matchingInput.label).toBe('My Seed')
expect(otherInput.label).toBeUndefined()
})
it('handles node with no inputs gracefully', () => {
const widget = makeWidget({ name: 'seed' })
const node = makeNode({ inputs: [] })
const result = renameWidget(widget, node, 'Renamed')
expect(result).toBe(true)
expect(mockedResolve).not.toHaveBeenCalled()
expect(promotedWidget.label).toBe('Renamed')
expect(widget.label).toBe('Renamed')
})
})