mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-19 22:09:37 +00:00
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:
237
browser_tests/assets/subgraphs/subgraph-promoted-linked.json
Normal file
237
browser_tests/assets/subgraphs/subgraph-promoted-linked.json
Normal 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
|
||||
}
|
||||
185
browser_tests/tests/subgraphPromotedSlotLinks.spec.ts
Normal file
185
browser_tests/tests/subgraphPromotedSlotLinks.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
189
src/lib/litegraph/src/__fixtures__/renamedPromotedLabels.ts
Normal file
189
src/lib/litegraph/src/__fixtures__/renamedPromotedLabels.ts
Normal 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
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user