[backport cloud/1.42] test: subgraph integration contracts and expanded Playwright coverage (#10329)

Backport of #10123, #9967, and #9972 to `cloud/1.42`

Includes three cherry-picks in dependency order:
1. #9972 — `fix: resolve all lint warnings` (clean)
2. #9967 — `test: harden subgraph test coverage and remove low-value
tests` (clean)
3. #10123 — `test: subgraph integration contracts and expanded
Playwright coverage` (1 conflict, auto-resolved by rerere from #10326)

See #10326 for core/1.41 backport with detailed conflict resolution
notes.

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-03-19 18:25:35 -07:00
committed by GitHub
parent 8de7795219
commit e5fcca95a9
78 changed files with 4258 additions and 1932 deletions

View File

@@ -0,0 +1,172 @@
{
"id": "9efdcc44-6372-4b4a-b6f9-789c67f052e1",
"revision": 0,
"last_node_id": 4,
"last_link_id": 0,
"nodes": [
{
"id": 4,
"type": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"pos": [689.0083557128902, 467.9999999999997],
"size": [431.8999938964844, 206.60000610351562],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["3", "text", "2"]]
},
"widgets_values": []
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Inner Subgraph",
"inputNode": {
"id": -10,
"bounding": [330, 367, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [983, 367, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CLIPTextEncode",
"pos": [510, 166],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["11111111111"]
},
{
"id": 2,
"type": "CLIPTextEncode",
"pos": [523, 438],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "clip",
"name": "clip",
"type": "CLIP",
"link": null
}
],
"outputs": [
{
"localized_name": "CONDITIONING",
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": null
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": ["22222222222"]
}
],
"groups": [],
"links": [],
"extra": {}
},
{
"id": "f5d6b5f0-64e3-4d3e-bb28-d25d8a6c182f",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Outer Subgraph",
"inputNode": {
"id": -10,
"bounding": [467, 446, 120, 40]
},
"outputNode": {
"id": -20,
"bounding": [932, 446, 120, 40]
},
"inputs": [],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 3,
"type": "9a3f232c-da11-4725-8927-b11e46d0cee4",
"pos": [647, 389],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "text"],
["2", "text"]
]
},
"widgets_values": []
}
],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 2.0975,
"offset": [-581.4780189305006, -356.3000030517576]
},
"frontendVersion": "1.43.2"
},
"version": 0.4
}

View File

@@ -33,6 +33,7 @@ export class NodeOperationsHelper {
})
}
/** Reads from `window.app.graph` (the root workflow graph). */
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}

View File

@@ -1,3 +1,4 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type {
@@ -6,6 +7,7 @@ import type {
} from '@/lib/litegraph/src/litegraph'
import type { ComfyPage } from '../ComfyPage'
import { TestIds } from '../selectors'
import type { NodeReference } from '../utils/litegraphUtils'
import { SubgraphSlotReference } from '../utils/litegraphUtils'
@@ -322,4 +324,93 @@ export class SubgraphHelper {
)
await this.comfyPage.nextFrame()
}
async isInSubgraph(): Promise<boolean> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async exitViaBreadcrumb(): Promise<void> {
const breadcrumb = this.page.getByTestId(TestIds.breadcrumb.subgraph)
const parentLink = breadcrumb.getByRole('link').first()
if (await parentLink.isVisible()) {
await parentLink.click()
} else {
await this.page.evaluate(() => {
const canvas = window.app!.canvas
const graph = canvas.graph
if (!graph) return
canvas.setGraph(graph.rootGraph)
})
}
await this.comfyPage.nextFrame()
await expect.poll(async () => this.isInSubgraph()).toBe(false)
}
async countGraphPseudoPreviewEntries(): Promise<number> {
return this.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.reduce((count, node) => {
const proxyWidgets = node.properties?.proxyWidgets
if (!Array.isArray(proxyWidgets)) return count
return (
count +
proxyWidgets.filter(
(entry) =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[1] === 'string' &&
entry[1].startsWith('$$')
).length
)
}, 0)
})
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return this.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => {
const proxyWidgets = Array.isArray(node.properties?.proxyWidgets)
? node.properties.proxyWidgets
: []
const promotedWidgets = proxyWidgets
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
.map(
([interiorNodeId, widgetName]) =>
[interiorNodeId, widgetName] as [string, string]
)
return {
hostNodeId: String(node.id),
promotedWidgets
}
})
.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
})
}
/** Reads from `window.app.canvas.graph` (viewed root or nested subgraph). */
async getNodeCount(): Promise<number> {
return this.page.evaluate(() => {
return window.app!.canvas.graph!.nodes?.length || 0
})
}
}

View File

@@ -2,6 +2,11 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export interface PromotedWidgetSnapshot {
proxyWidgets: PromotedWidgetEntry[]
widgetNames: string[]
}
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
@@ -32,6 +37,28 @@ export async function getPromotedWidgets(
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetSnapshot(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetSnapshot> {
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return {
proxyWidgets: node?.properties?.proxyWidgets ?? [],
widgetNames: (node?.widgets ?? []).map((widget) => widget.name)
}
}, nodeId)
return {
proxyWidgets: normalizePromotedWidgets(raw.proxyWidgets),
widgetNames: Array.isArray(raw.widgetNames)
? raw.widgetNames.filter(
(name): name is string => typeof name === 'string'
)
: []
}
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
@@ -48,6 +75,26 @@ export async function getPromotedWidgetCount(
return promotedWidgets.length
}
export function isPseudoPreviewEntry(entry: PromotedWidgetEntry): boolean {
return entry[1].startsWith('$$')
}
export async function getPseudoPreviewWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter(isPseudoPreviewEntry)
}
export async function getNonPreviewPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const widgets = await getPromotedWidgets(comfyPage, nodeId)
return widgets.filter((entry) => !isPseudoPreviewEntry(entry))
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,

View File

@@ -43,6 +43,31 @@ test.describe('Subgraph duplicate ID remapping', { tag: ['@subgraph'] }, () => {
expect(rootIds).toEqual([1, 2, 5])
})
test('Promoted widget tuples are stable after full page reload boot path', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const beforeSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(beforeSnapshot.length).toBeGreaterThan(0)
expect(
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
).toBe(true)
await comfyPage.page.reload()
await comfyPage.page.waitForFunction(() => !!window.app)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
await expect(async () => {
const afterSnapshot =
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
expect(afterSnapshot).toEqual(beforeSnapshot)
}).toPass({ timeout: 5_000 })
})
test('All links reference valid nodes in their graph', async ({
comfyPage
}) => {

View File

@@ -1,6 +1,7 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
// Constants
const RENAMED_INPUT_NAME = 'renamed_input'
@@ -631,6 +632,51 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
expect(updatedBreadcrumbText).toContain(UPDATED_SUBGRAPH_TITLE)
expect(updatedBreadcrumbText).not.toBe(initialBreadcrumbText)
})
test('Switching workflows while inside subgraph returns to root graph context', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
await expect(comfyPage.page.locator(SELECTORS.breadcrumb)).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(false)
})
test('Breadcrumb disappears after switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const breadcrumb = comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.locator('.p-breadcrumb')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
await comfyPage.nextFrame()
await expect(breadcrumb).toBeVisible()
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await expect(breadcrumb).toBeHidden()
})
})
test.describe('DOM Widget Promotion', () => {

View File

@@ -0,0 +1,347 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import type { PromotedWidgetEntry } from '../helpers/promotedWidgets'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import {
getPromotedWidgets,
getPseudoPreviewWidgets,
getNonPreviewPromotedWidgets
} from '../helpers/promotedWidgets'
const domPreviewSelector = '.image-preview'
const expectPromotedWidgetsToResolveToInteriorNodes = async (
comfyPage: ComfyPage,
hostSubgraphNodeId: string,
widgets: PromotedWidgetEntry[]
) => {
const interiorNodeIds = widgets.map(([id]) => id)
const results = await comfyPage.page.evaluate(
([hostId, ids]) => {
const graph = window.app!.graph!
const hostNode = graph.getNodeById(Number(hostId))
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
return ids.map((id) => {
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
return interiorNode !== null && interiorNode !== undefined
})
},
[hostSubgraphNodeId, interiorNodeIds] as const
)
for (const exists of results) {
expect(exists).toBe(true)
}
}
test.describe(
'Subgraph Lifecycle Edge Behaviors',
{ tag: ['@subgraph'] },
() => {
test.describe('Deterministic Hydrate from Serialized proxyWidgets', () => {
test('proxyWidgets entries map to real interior node IDs after load', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '11')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
widgets
)
})
test('proxyWidgets entries survive double round-trip without drift', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
initialWidgets
)
const serialized1 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized1 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterFirst = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterFirst
)
const serialized2 = await comfyPage.page.evaluate(() =>
window.app!.graph!.serialize()
)
await comfyPage.page.evaluate(
(workflow: ComfyWorkflowJSON) => window.app!.loadGraphData(workflow),
serialized2 as ComfyWorkflowJSON
)
await comfyPage.nextFrame()
const afterSecond = await getPromotedWidgets(comfyPage, '11')
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'11',
afterSecond
)
expect(afterFirst).toEqual(initialWidgets)
expect(afterSecond).toEqual(initialWidgets)
})
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
const widgets = await getPromotedWidgets(comfyPage, '2')
expect(widgets.length).toBeGreaterThan(0)
for (const [interiorNodeId] of widgets) {
expect(interiorNodeId).not.toBe('-1')
expect(Number(interiorNodeId)).toBeGreaterThan(0)
}
await expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage,
'2',
widgets
)
})
})
test.describe('Placeholder Behavior After Promoted Source Removal', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Removing promoted source node inside subgraph falls back to disconnected placeholder on exterior', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
expect(initialWidgets.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const hostNode = window.app!.canvas.graph!.getNodeById('11')
const proxyWidgets = hostNode?.properties?.proxyWidgets
return {
proxyWidgetCount: Array.isArray(proxyWidgets)
? proxyWidgets.length
: 0,
firstWidgetType: hostNode?.widgets?.[0]?.type
}
})
})
.toEqual({
proxyWidgetCount: initialWidgets.length,
firstWidgetType: 'button'
})
})
test('Promoted widget disappears from DOM after interior node deletion', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Pseudo-preview entries exist in proxyWidgets for preview subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(
pseudoWidgets.some(([, name]) => name === '$$canvas-image-preview')
).toBe(true)
})
test('Non-preview widgets coexist with pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const pseudoWidgets = await getPseudoPreviewWidgets(comfyPage, '5')
const nonPreviewWidgets = await getNonPreviewPromotedWidgets(
comfyPage,
'5'
)
expect(pseudoWidgets.length).toBeGreaterThan(0)
expect(nonPreviewWidgets.length).toBeGreaterThan(0)
expect(
nonPreviewWidgets.some(([, name]) => name === 'filename_prefix')
).toBe(true)
})
test('Unpacking subgraph clears pseudo-preview entries from graph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.nodes.find((n) => n.isSubgraphNode())
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const subgraphNodeCount = await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
return graph.nodes.filter((n) => n.isSubgraphNode()).length
})
expect(subgraphNodeCount).toBe(0)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
})
test('Removing subgraph node clears pseudo-preview DOM elements', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
const beforePseudo = await getPseudoPreviewWidgets(comfyPage, '5')
expect(beforePseudo.length).toBeGreaterThan(0)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
expect(await subgraphNode.exists()).toBe(true)
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('5')
})
expect(nodeExists).toBe(false)
await expect
.poll(async () => comfyPage.subgraph.countGraphPseudoPreviewEntries())
.toBe(0)
await expect(comfyPage.page.locator(domPreviewSelector)).toHaveCount(0)
})
test('Unpacking one subgraph does not clear sibling pseudo-preview entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.nextFrame()
const firstNodeBefore = await getPseudoPreviewWidgets(comfyPage, '7')
const secondNodeBefore = await getPseudoPreviewWidgets(comfyPage, '8')
expect(firstNodeBefore.length).toBeGreaterThan(0)
expect(secondNodeBefore.length).toBeGreaterThan(0)
await comfyPage.page.evaluate(() => {
const graph = window.app!.graph!
const subgraphNode = graph.getNodeById('7')
if (!subgraphNode || !subgraphNode.isSubgraphNode()) return
graph.unpackSubgraph(subgraphNode)
})
await comfyPage.nextFrame()
const firstNodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.graph!.getNodeById('7')
})
expect(firstNodeExists).toBe(false)
const secondNodeAfter = await getPseudoPreviewWidgets(comfyPage, '8')
expect(secondNodeAfter).toEqual(secondNodeBefore)
})
})
}
)

View File

@@ -0,0 +1,141 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from '../fixtures/ComfyPage'
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const PROMOTED_BORDER_CLASS = 'ring-component-node-widget-promoted'
/**
* Regression tests for nested subgraph promotion where multiple interior
* nodes share the same widget name (e.g. two CLIPTextEncode nodes both
* with a "text" widget).
*
* The inner subgraph (node 3) promotes both ["1","text"] and ["2","text"].
* The outer subgraph (node 4) promotes through node 3 using identity
* disambiguation (optional sourceNodeId in the promotion entry).
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10123#discussion_r2956230977
*/
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@subgraph', '@widget'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Inner subgraph node has both text widgets promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const nonPreview = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return ((innerSubgraphNode.properties?.proxyWidgets ?? []) as unknown[])
.filter(
(entry): entry is [string, string] =>
Array.isArray(entry) &&
entry.length >= 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string' &&
!entry[1].startsWith('$$')
)
.map(
([nodeId, widgetName]) => [nodeId, widgetName] as [string, string]
)
})
expect(nonPreview).toEqual([
['1', 'text'],
['2', 'text']
])
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
const widgetValues = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const outerNode = graph.getNodeById('4')
if (
!outerNode ||
typeof outerNode.isSubgraphNode !== 'function' ||
!outerNode.isSubgraphNode()
) {
return []
}
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
const textWidgets = widgetValues.filter((w) => w.name.startsWith('text'))
expect(textWidgets).toHaveLength(2)
const values = textWidgets.map((w) => w.value)
expect(values).toContain('11111111111')
expect(values).toContain('22222222222')
})
test.describe('Promoted border styling in Vue mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Intermediate subgraph widgets get promoted border, outermost does not', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
// Node 4 is the outer SubgraphNode at root level.
// Its widgets are not promoted further (no parent subgraph),
// so none of its widget wrappers should carry the promoted ring.
const outerNode = comfyPage.vueNodes.getNodeLocator('4')
await expect(outerNode).toBeVisible()
const outerPromotedRings = outerNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(outerPromotedRings).toHaveCount(0)
// Navigate into the outer subgraph (node 4) to reach node 3
await comfyPage.vueNodes.enterSubgraph('4')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
// Node 3 is the intermediate SubgraphNode whose "text" widgets
// are promoted up to the outer subgraph (node 4).
// Its widget wrappers should carry the promoted border ring.
const intermediateNode = comfyPage.vueNodes.getNodeLocator('3')
await expect(intermediateNode).toBeVisible()
const intermediatePromotedRings = intermediateNode.locator(
`.${PROMOTED_BORDER_CLASS}`
)
await expect(intermediatePromotedRings).toHaveCount(1)
})
})
}
)

View File

@@ -73,5 +73,59 @@ test.describe(
expect(progressAfter).toBeUndefined()
}).toPass({ timeout: 2_000 })
})
test('Stale progress is cleared when switching workflows while inside subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
const subgraphNodeId = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
return subgraphNode ? String(subgraphNode.id) : null
})
expect(subgraphNodeId).not.toBeNull()
await comfyPage.page.evaluate((nodeId) => {
const node = window.app!.canvas.graph!.getNodeById(nodeId)!
node.progress = 0.7
}, subgraphNodeId!)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById(
subgraphNodeId!
)
await subgraphNode.navigateIntoSubgraph()
const inSubgraph = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
expect(inSubgraph).toBe(true)
await comfyPage.workflow.loadWorkflow('default')
await comfyPage.nextFrame()
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
await comfyPage.nextFrame()
await expect(async () => {
const subgraphProgressState = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
const subgraphNode = graph.nodes.find(
(n) => typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
)
if (!subgraphNode) {
return { exists: false, progress: null }
}
return { exists: true, progress: subgraphNode.progress }
})
expect(subgraphProgressState.exists).toBe(true)
expect(subgraphProgressState.progress).toBeUndefined()
}).toPass({ timeout: 5_000 })
})
}
)

View File

@@ -2,7 +2,6 @@ import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
@@ -12,26 +11,6 @@ import {
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
@@ -179,7 +158,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
expect(await comfyPage.subgraph.isInSubgraph()).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
@@ -251,7 +230,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
@@ -285,7 +264,7 @@ test.describe(
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
@@ -331,7 +310,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -366,7 +345,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
@@ -397,7 +376,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
@@ -545,6 +524,30 @@ test.describe(
expect(widgetCount).toBeGreaterThan(0)
})
test('Multi-link input representative stays stable through save/reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(beforeSnapshot.length).toBeGreaterThan(0)
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
expect(afterSnapshot).toEqual(beforeSnapshot)
})
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
comfyPage
}) => {
@@ -702,6 +705,44 @@ test.describe(
expect(nodeExists).toBe(false)
})
test('Nested promoted widget entries reflect interior changes after slot removal', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await comfyPage.nextFrame()
const initialNames = await getPromotedWidgetNames(comfyPage, '5')
expect(initialNames.length).toBeGreaterThan(0)
const outerSubgraph = await comfyPage.nodeOps.getNodeRefById('5')
await outerSubgraph.navigateIntoSubgraph()
const removedSlotName = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
if (!graph || !('inputNode' in graph)) return null
return graph.inputs?.[0]?.name ?? null
})
expect(removedSlotName).not.toBeNull()
await comfyPage.subgraph.rightClickInputSlot()
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const finalNames = await getPromotedWidgetNames(comfyPage, '5')
const expectedNames = [...initialNames]
const removedIndex = expectedNames.indexOf(removedSlotName!)
expect(removedIndex).toBeGreaterThanOrEqual(0)
expectedNames.splice(removedIndex, 1)
expect(finalNames).toEqual(expectedNames)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
@@ -724,15 +765,7 @@ test.describe(
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')

View File

@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { getSourceNodeId } from '@/core/graph/subgraph/promotionUtils'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
@@ -78,19 +79,22 @@ function isWidgetShownOnParents(
): boolean {
return parents.some((parent) => {
if (isPromotedWidgetView(widget)) {
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
const sourceNodeId = getSourceNodeId(widget)
const interiorNodeId =
String(widgetNode.id) === String(parent.id)
? widget.sourceNodeId
: String(widgetNode.id)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
return promotionStore.isPromoted(
parent.rootGraph.id,
parent.id,
String(widgetNode.id),
widget.name
)
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})
})
}

View File

@@ -14,6 +14,10 @@ import {
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getSourceNodeId,
getWidgetName
} from '@/core/graph/subgraph/promotionUtils'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import FormSearchInput from '@/renderer/extensions/vueNodes/widgets/components/form/FormSearchInput.vue'
@@ -84,15 +88,27 @@ const widgetsList = computed((): NodeWidgetsList => {
const { widgets = [] } = node
const result: NodeWidgetsList = []
for (const { interiorNodeId, widgetName } of entries) {
for (const {
sourceNodeId: entryNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
} of entries) {
const widget = widgets.find((w) => {
if (isPromotedWidgetView(w)) {
if (
String(w.sourceNodeId) !== entryNodeId ||
w.sourceWidgetName !== sourceWidgetName
)
return false
if (!disambiguatingSourceNodeId) return true
return (
String(w.sourceNodeId) === interiorNodeId &&
w.sourceWidgetName === widgetName
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === widgetName
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
@@ -113,12 +129,11 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(interiorNode.id),
widget.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
})
)
})

View File

@@ -7,7 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import WidgetActions from './WidgetActions.vue'
@@ -93,8 +95,11 @@ describe('WidgetActions', () => {
function createMockNode(): LGraphNode {
return {
id: 1,
type: 'TestNode'
} as LGraphNode
type: 'TestNode',
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [200, 100]
} as unknown as LGraphNode
}
function mountWidgetActions(widget: IBaseWidget, node: LGraphNode) {
@@ -206,4 +211,66 @@ describe('WidgetActions', () => {
expect(wrapper.emitted('resetToDefault')![0]).toEqual(['option1'])
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = {
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
} as unknown as SubgraphNode
const node = {
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' }
} as unknown as LGraphNode
const widget = {
name: 'text',
type: 'text',
value: 'value',
label: 'Text',
options: {},
y: 0,
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
} as IBaseWidget
const promotionStore = usePromotionStore()
promotionStore.promote('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
const wrapper = mount(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
const hideButton = wrapper
.findAll('button')
.find((button) => button.text().includes('Hide input'))
expect(hideButton).toBeDefined()
await hideButton?.trigger('click')
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,10 +5,11 @@ import { useI18n } from 'vue-i18n'
import MoreButton from '@/components/button/MoreButton.vue'
import Button from '@/components/ui/button/Button.vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import {
demoteWidget,
getSourceNodeId,
promoteWidget
} from '@/core/graph/subgraph/promotionUtils'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -16,6 +17,7 @@ import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import { getWidgetDefaultValue, promptWidgetLabel } from '@/utils/widgetUtil'
import type { WidgetValue } from '@/utils/widgetUtil'
@@ -41,6 +43,7 @@ const label = defineModel<string>('label', { required: true })
const canvasStore = useCanvasStore()
const favoritedWidgetsStore = useFavoritedWidgetsStore()
const nodeDefStore = useNodeDefStore()
const promotionStore = usePromotionStore()
const { t } = useI18n()
const hasParents = computed(() => parents?.length > 0)
@@ -73,26 +76,29 @@ function handleHideInput() {
if (!parents?.length) return
if (isPromotedWidgetView(widget)) {
const sourceWidget = resolvePromotedWidgetSource(node, widget)
if (!sourceWidget) {
console.error('Could not resolve source widget for promoted widget')
return
}
const disambiguatingSourceNodeId = getSourceNodeId(widget)
demoteWidget(sourceWidget.node, sourceWidget.widget, parents)
for (const parent of parents) {
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
}
canvasStore.canvas?.setDirty(true, true)
} else {
// For regular widgets (not yet promoted), use them directly
demoteWidget(node, widget, parents)
}
canvasStore.canvas?.setDirty(true, true)
}
function handleShowInput() {
if (!parents?.length) return
promoteWidget(node, widget, parents)
canvasStore.canvas?.setDirty(true, true)
}
function handleToggleFavorite() {

View File

@@ -5,9 +5,11 @@ import { useI18n } from 'vue-i18n'
import DraggableList from '@/components/common/DraggableList.vue'
import Button from '@/components/ui/button/Button.vue'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isRecommendedWidget,
promoteWidget,
@@ -49,19 +51,29 @@ const activeWidgets = computed<WidgetItem[]>({
if (!node) return []
return promotionEntries.value.flatMap(
({ interiorNodeId, widgetName }): WidgetItem[] => {
if (interiorNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === widgetName)
({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}): WidgetItem[] => {
if (sourceNodeId === '-1') {
const widget = node.widgets.find((w) => w.name === sourceWidgetName)
if (!widget) return []
return [
[{ id: -1, title: t('subgraphStore.linked'), type: '' }, widget]
]
}
const wNode = node.subgraph._nodes_by_id[interiorNodeId]
const wNode = node.subgraph._nodes_by_id[sourceNodeId]
if (!wNode) return []
const widget = getPromotableWidgets(wNode).find(
(w) => w.name === widgetName
)
const widget = getPromotableWidgets(wNode).find((w) => {
if (w.name !== sourceWidgetName) return false
if (disambiguatingSourceNodeId && isPromotedWidgetView(w))
return (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
return true
})
if (!widget) return []
return [[wNode, widget]]
}
@@ -76,11 +88,16 @@ const activeWidgets = computed<WidgetItem[]>({
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
interiorNodeId: String(n.id),
widgetName: getWidgetName(w)
}))
value.map(([n, w]) => {
const sid = getSourceNodeId(w)
return {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
...(sid && { disambiguatingSourceNodeId: sid })
}
})
)
refreshPromotedWidgetRendering()
}
})
@@ -103,12 +120,11 @@ const candidateWidgets = computed<WidgetItem[]>(() => {
if (!node) return []
return interiorWidgets.value.filter(
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(
node.rootGraph.id,
node.id,
String(n.id),
w.name
)
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: getSourceNodeId(w)
})
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -137,8 +153,20 @@ const filteredActive = computed<WidgetItem[]>(() => {
)
})
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
node.computeSize(node.size)
node.setDirtyCanvas(true, true)
canvasStore.canvas?.setDirty(true, true)
}
function toKey(item: WidgetItem) {
return `${item[0].id}: ${item[1].name}`
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
@@ -147,49 +175,26 @@ function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
demoteWidget(node, widget, [subgraphNode])
promotionStore.demote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
getWidgetName(widget)
)
}
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
promotionStore.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
}
function showAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredCandidates.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of filteredCandidates.value) {
promote(item)
}
}
function hideAll() {
const node = activeNode.value
if (!node) return
for (const [n, w] of filteredActive.value) {
if (String(n.id) === '-1') continue
promotionStore.demote(
node.rootGraph.id,
node.id,
String(n.id),
getWidgetName(w)
)
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
demote(item)
}
}
function showRecommended() {
const node = activeNode.value
if (!node) return
for (const [n, w] of recommendedWidgets.value) {
promotionStore.promote(node.rootGraph.id, node.id, String(n.id), w.name)
for (const item of recommendedWidgets.value) {
promote(item)
}
}

View File

@@ -413,12 +413,10 @@ describe('Subgraph Promoted Pseudo Widgets', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
'10',
'$$canvas-image-preview'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
@@ -500,12 +498,10 @@ describe('Nested promoted widget mapping', () => {
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
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))
@@ -523,6 +519,70 @@ describe('Nested promoted widget mapping', () => {
])
)
})
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', () => {

View File

@@ -6,6 +6,7 @@ import { reactiveComputed } from '@vueuse/core'
import { reactive, shallowReactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
@@ -224,19 +225,21 @@ function safeWidgetMapper(
function resolvePromotedSourceByInputName(inputName: string): {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
} | null {
const resolvedTarget = resolveSubgraphInputTarget(node, inputName)
if (!resolvedTarget) return null
return {
sourceNodeId: resolvedTarget.nodeId,
sourceWidgetName: resolvedTarget.widgetName
sourceWidgetName: resolvedTarget.widgetName,
disambiguatingSourceNodeId: resolvedTarget.sourceNodeId
}
}
function resolvePromotedWidgetIdentity(widget: IBaseWidget): {
displayName: string
promotedSource: { sourceNodeId: string; sourceWidgetName: string } | null
promotedSource: PromotedWidgetSource | null
} {
if (!isPromotedWidgetView(widget)) {
return {
@@ -250,7 +253,8 @@ function safeWidgetMapper(
const displayName = promotedInputName ?? widget.name
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
@@ -297,7 +301,8 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
@@ -310,7 +315,11 @@ function safeWidgetMapper(
const effectiveWidget = sourceWidget ?? widget
const localId = isPromotedWidgetView(widget)
? String(sourceNode?.id ?? promotedSource?.sourceNodeId)
? String(
sourceNode?.id ??
promotedSource?.disambiguatingSourceNodeId ??
promotedSource?.sourceNodeId
)
: undefined
const nodeId =
subgraphId && localId ? `${subgraphId}:${localId}` : undefined

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphNode, SubgraphNode } from '@/lib/litegraph/src/litegraph'
const mocks = vi.hoisted(() => ({
publishSubgraph: vi.fn(),
@@ -46,6 +46,10 @@ function createSubgraphNode(): SubgraphNode {
return node
}
function createRegularNode(): LGraphNode {
return new LGraphNode('testnode')
}
describe('useSubgraphOperations', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -87,4 +91,16 @@ describe('useSubgraphOperations', () => {
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
it('addSubgraphToLibrary does not call publishSubgraph when selected item is not a SubgraphNode', async () => {
mocks.selectedItems = [createRegularNode()]
const { useSubgraphOperations } =
await import('@/composables/graph/useSubgraphOperations')
const { addSubgraphToLibrary } = useSubgraphOperations()
await addSubgraphToLibrary()
expect(mocks.publishSubgraph).not.toHaveBeenCalled()
})
})

View File

@@ -112,8 +112,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -126,8 +125,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
@@ -137,8 +135,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: mockUrls
}
@@ -151,8 +149,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -168,8 +165,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
@@ -192,14 +188,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'20',
'$$canvas-image-preview'
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10, 20])
@@ -221,8 +215,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const blobUrl = 'blob:http://localhost/glsl-preview'
@@ -232,8 +225,8 @@ describe(usePromotedPreviews, () => {
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -246,8 +239,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -259,8 +251,8 @@ describe(usePromotedPreviews, () => {
expect(promotedPreviews.value).toEqual([
{
interiorNodeId: '10',
widgetName: '$$canvas-image-preview',
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -273,8 +265,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -286,8 +277,7 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'99',
'$$canvas-image-preview'
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
@@ -300,14 +290,12 @@ describe(usePromotedPreviews, () => {
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'seed'
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
'10',
'$$canvas-image-preview'
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=img.png']

View File

@@ -8,8 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
type: 'image' | 'video' | 'audio'
urls: string[]
}
@@ -30,13 +30,15 @@ export function usePromotedPreviews(
if (!(node instanceof SubgraphNode)) return []
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) => e.widgetName.startsWith('$$'))
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
)
if (!pseudoEntries.length) return []
const previews: PromotedPreview[] = []
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.interiorNodeId)
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
// Read from both reactive refs to establish Vue dependency
@@ -45,7 +47,7 @@ export function usePromotedPreviews(
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.interiorNodeId
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
@@ -63,8 +65,8 @@ export function usePromotedPreviews(
: 'image'
previews.push({
interiorNodeId: entry.interiorNodeId,
widgetName: entry.widgetName,
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})

View File

@@ -2,15 +2,28 @@ import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export type ResolvedPromotedWidget = {
export interface ResolvedPromotedWidget {
node: LGraphNode
widget: IBaseWidget
}
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly sourceNodeId: string
readonly sourceWidgetName: string
/**
* The original leaf-level source node ID, used to distinguish promoted
* widgets with the same name on the same intermediate node. Unlike
* `sourceNodeId` (the direct interior node), this traces to the deepest
* origin.
*/
readonly disambiguatingSourceNodeId?: string
}
export function isPromotedWidgetView(

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { beforeEach, describe, expect, test, vi } from 'vitest'
// Barrel import must come first to avoid circular dependency
// (promotedWidgetView → widgetMap → BaseWidget → LegacyWidget → barrel)
@@ -27,9 +27,10 @@ import {
} from '@/stores/widgetValueStore'
import {
cleanupComplexPromotionFixtureNodeType,
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
@@ -48,9 +49,14 @@ vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
@@ -62,7 +68,8 @@ function setupSubgraph(
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
function setPromotions(
@@ -72,9 +79,9 @@ function setPromotions(
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
entries.map(([interiorNodeId, widgetName]) => ({
interiorNodeId,
widgetName
entries.map(([sourceNodeId, sourceWidgetName]) => ({
sourceNodeId,
sourceWidgetName
}))
)
}
@@ -97,13 +104,8 @@ function callSyncPromotions(node: SubgraphNode) {
)._syncPromotions()
}
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
describe(createPromotedWidgetView, () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
mockDomWidgetStore.widgetStates.clear()
vi.clearAllMocks()
})
@@ -113,6 +115,21 @@ describe(createPromotedWidgetView, () => {
const view = createPromotedWidgetView(subgraphNode, '42', 'myWidget')
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBeUndefined()
})
test('exposes disambiguatingSourceNodeId when provided', () => {
const [subgraphNode] = setupSubgraph()
const view = createPromotedWidgetView(
subgraphNode,
'42',
'myWidget',
undefined,
'99'
)
expect(view.sourceNodeId).toBe('42')
expect(view.sourceWidgetName).toBe('myWidget')
expect(view.disambiguatingSourceNodeId).toBe('99')
})
test('name defaults to widgetName when no displayName given', () => {
@@ -315,18 +332,10 @@ describe(createPromotedWidgetView, () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
innerNode.addWidget('text', 'myWidget', 'val', () => {})
const store = useWidgetValueStore()
const bareId = String(innerNode.id)
// No displayName → falls back to widgetName
const view1 = createPromotedWidgetView(subgraphNode, bareId, 'myWidget')
// Store label is undefined → falls back to displayName/widgetName
const state = store.getWidget(
subgraphNode.rootGraph.id,
bareId as never,
'myWidget'
)
state!.label = undefined
expect(view1.label).toBe('myWidget')
// With displayName → falls back to displayName
@@ -435,10 +444,6 @@ describe(createPromotedWidgetView, () => {
})
describe('SubgraphNode.widgets getter', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('defers promotions while subgraph node id is -1 and flushes on add', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'picker_input', type: '*' }]
@@ -465,8 +470,8 @@ describe('SubgraphNode.widgets getter', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'picker'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'picker'
}
])
})
@@ -508,8 +513,8 @@ describe('SubgraphNode.widgets getter', () => {
expect(promotions).toHaveLength(1)
expect(promotions[0]).toStrictEqual({
interiorNodeId: String(secondNode.id),
widgetName: 'picker'
sourceNodeId: String(secondNode.id),
sourceWidgetName: 'picker'
})
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].value).toBe('b')
@@ -576,7 +581,7 @@ describe('SubgraphNode.widgets getter', () => {
])
})
test('input-linked same-name widgets share value state while store-promoted peer stays independent', () => {
test('input-linked same-name widgets propagate value to all connected nodes while store-promoted peer stays independent', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'string_a', type: '*' }]
})
@@ -602,18 +607,14 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.inputNode.slots[0].connect(linkedInputA, linkedNodeA)
subgraph.inputNode.slots[0].connect(linkedInputB, linkedNodeB)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(promotedNode.id),
'string_a'
)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(linkedNodeA.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(promotedNode.id),
sourceWidgetName: 'string_a'
})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(linkedNodeA.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(2)
@@ -631,53 +632,17 @@ describe('SubgraphNode.widgets getter', () => {
linkedView.value = 'shared-value'
const widgetStore = useWidgetValueStore()
const graphId = subgraphNode.rootGraph.id
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent')
// Both linked nodes share the same SubgraphInput slot, so the value
// propagates to all connected widgets via getLinkedInputWidgets().
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent')
promotedView.value = 'independent-updated'
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeA.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(linkedNodeB.id)),
'string_a'
)?.value
).toBe('shared-value')
expect(
widgetStore.getWidget(
graphId,
stripGraphPrefix(String(promotedNode.id)),
'string_a'
)?.value
).toBe('independent-updated')
expect(linkedNodeA.widgets?.[0]?.value).toBe('shared-value')
expect(linkedNodeB.widgets?.[0]?.value).toBe('shared-value')
expect(promotedNode.widgets?.[0]?.value).toBe('independent-updated')
})
test('duplicate-name promoted views map slot linkage by view identity', () => {
@@ -698,12 +663,10 @@ describe('SubgraphNode.widgets getter', () => {
subgraph.add(independentNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(independentNode.id),
'string_a'
)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const widgets = promotedWidgets(subgraphNode)
const linkedView = widgets.find(
@@ -780,8 +743,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetB' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetB' }
])
})
@@ -811,8 +774,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(liveNode.id), widgetName: 'widgetA' },
{ interiorNodeId: '9999', widgetName: 'widgetA' }
{ sourceNodeId: String(liveNode.id), sourceWidgetName: 'widgetA' },
{ sourceNodeId: '9999', sourceWidgetName: 'widgetA' }
])
})
@@ -870,8 +833,8 @@ describe('SubgraphNode.widgets getter', () => {
subgraphNode.id
)
expect(promotions).toStrictEqual([
{ interiorNodeId: String(linkedNodeA.id), widgetName: 'string_a' },
{ interiorNodeId: String(independentNode.id), widgetName: 'string_a' }
{ sourceNodeId: String(linkedNodeA.id), sourceWidgetName: 'string_a' },
{ sourceNodeId: String(independentNode.id), sourceWidgetName: 'string_a' }
])
})
@@ -915,8 +878,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(promotions).toStrictEqual([
{
interiorNodeId: linkedEntry[0],
widgetName: linkedEntry[1]
sourceNodeId: linkedEntry[0],
sourceWidgetName: linkedEntry[1]
}
])
})
@@ -972,8 +935,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredPromotions).toStrictEqual([
{
interiorNodeId: String(activeAliasNode.id),
widgetName: 'string_a'
sourceNodeId: String(activeAliasNode.id),
sourceWidgetName: 'string_a'
}
])
@@ -1053,9 +1016,9 @@ describe('SubgraphNode.widgets getter', () => {
})
test('caches view objects across getter calls (stable references)', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const first = subgraphNode.widgets[0]
const second = subgraphNode.widgets[0]
@@ -1063,10 +1026,10 @@ describe('SubgraphNode.widgets getter', () => {
})
test('memoizes promotion list by reference', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const views1 = subgraphNode.widgets
expect(views1).toHaveLength(1)
@@ -1076,52 +1039,52 @@ describe('SubgraphNode.widgets getter', () => {
expect(views2[0]).toBe(views1[0])
// New store value with same content → same cached view object
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const views3 = subgraphNode.widgets
expect(views3[0]).toBe(views1[0])
})
test('cleans stale cache entries when promotions shrink', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
expect(subgraphNode.widgets).toHaveLength(2)
const viewA = subgraphNode.widgets[0]
// Remove widgetA from promotion list
setPromotions(subgraphNode, [['1', 'widgetB']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetB']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
// Re-adding widgetA creates a new view (old one was cleaned)
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
const newViewA = subgraphNode.widgets[1]
expect(newViewA).not.toBe(viewA)
})
test('deduplicates entries with same nodeId:widgetName', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetA']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(1)
})
test('setter is a no-op', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
// Assigning to widgets does nothing
subgraphNode.widgets = []
@@ -1160,8 +1123,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(entries).toStrictEqual([
{
interiorNodeId: String(innerNodes[0].id),
widgetName: 'stringWidget'
sourceNodeId: String(innerNodes[0].id),
sourceWidgetName: 'stringWidget'
}
])
})
@@ -1189,8 +1152,8 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(restoredEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
@@ -1327,8 +1290,8 @@ describe('SubgraphNode.widgets getter', () => {
const promotions = usePromotionStore().getPromotions(graph.id, hostNode.id)
expect(promotions).toStrictEqual([
{ interiorNodeId: '20', widgetName: 'string_a' },
{ interiorNodeId: '19', widgetName: 'string_a' }
{ sourceNodeId: '20', sourceWidgetName: 'string_a' },
{ sourceNodeId: '19', sourceWidgetName: 'string_a' }
])
const linkedView = hostWidgets[0]
@@ -1463,22 +1426,18 @@ describe('SubgraphNode.widgets getter', () => {
)
expect(hydratedEntries).toStrictEqual([
{
interiorNodeId: String(innerNode.id),
widgetName: 'widgetA'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'widgetA'
}
])
})
})
describe('widgets getter caching', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('reconciles at most once per canvas frame across repeated widgets reads', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const fakeCanvas = { frame: 12 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1486,12 +1445,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1506,9 +1465,9 @@ describe('widgets getter caching', () => {
})
test('does not re-run reconciliation when only canvas frame advances', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const fakeCanvas = { frame: 24 } as Pick<LGraphCanvas, 'frame'>
subgraphNode.rootGraph.primaryCanvas = fakeCanvas as LGraphCanvas
@@ -1516,12 +1475,12 @@ describe('widgets getter caching', () => {
const reconcileSpy = vi.spyOn(
subgraphNode as unknown as {
_buildPromotionReconcileState: (
entries: Array<{ interiorNodeId: string; widgetName: string }>,
entries: Array<{ sourceNodeId: string; sourceWidgetName: string }>,
linkedEntries: Array<{
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
}>
) => unknown
},
@@ -1573,19 +1532,19 @@ describe('widgets getter caching', () => {
})
test('preserves view identities when promotion order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const [viewA, viewB] = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets[0]).toBe(viewB)
@@ -1593,15 +1552,15 @@ describe('widgets getter caching', () => {
})
test('deduplicates by key while preserving first-occurrence order', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetB'],
['1', 'widgetA'],
['1', 'widgetB'],
['1', 'widgetA']
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB'],
[innerIds[0], 'widgetA']
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -1610,9 +1569,9 @@ describe('widgets getter caching', () => {
})
test('returns same array reference when promotions unchanged', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const result1 = subgraphNode.widgets
const result2 = subgraphNode.widgets
@@ -1620,16 +1579,16 @@ describe('widgets getter caching', () => {
})
test('returns new array after promotion change', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
const result1 = subgraphNode.widgets
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const result2 = subgraphNode.widgets
@@ -1638,12 +1597,12 @@ describe('widgets getter caching', () => {
})
test('invalidates cache on removeWidget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const result1 = subgraphNode.widgets
@@ -1657,30 +1616,26 @@ describe('widgets getter caching', () => {
})
describe('promote/demote cycle', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('promoting adds to store and widgets reflects it', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
expect(subgraphNode.widgets).toHaveLength(0)
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view = subgraphNode.widgets[0] as PromotedWidgetView
expect(view.sourceNodeId).toBe('1')
expect(view.sourceNodeId).toBe(innerIds[0])
expect(view.sourceWidgetName).toBe('widgetA')
})
test('demoting via removeWidget removes from store', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
const viewA = subgraphNode.widgets[0]
@@ -1693,16 +1648,16 @@ describe('promote/demote cycle', () => {
subgraphNode.id
)
expect(entries).toStrictEqual([
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('full promote → demote → re-promote cycle', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
// Promote
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
const view1 = subgraphNode.widgets[0]
@@ -1711,7 +1666,7 @@ describe('promote/demote cycle', () => {
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote — creates a new view since the cache was cleared
setPromotions(subgraphNode, [['1', 'widgetA']])
setPromotions(subgraphNode, [[innerIds[0], 'widgetA']])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0]).not.toBe(view1)
expect(
@@ -1721,22 +1676,18 @@ describe('promote/demote cycle', () => {
})
describe('disconnected state', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('view resolves type when interior widget exists', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('number', 'numWidget', 42, () => {})
setPromotions(subgraphNode, [['1', 'numWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'numWidget']])
expect(subgraphNode.widgets[0].type).toBe('number')
})
test('keeps promoted entry as disconnected when interior node is removed', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
expect(subgraphNode.widgets[0].type).toBe('text')
@@ -1747,9 +1698,9 @@ describe('disconnected state', () => {
})
test('view recovers when interior widget is re-added', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'myWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'myWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'myWidget']])
// Remove widget
innerNodes[0].widgets!.pop()
@@ -1768,6 +1719,197 @@ describe('disconnected state', () => {
})
})
function createThreeLevelNestedSubgraph() {
// Level C (innermost): concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'c_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('c_input', '*')
const concreteWidget = concreteNode.addWidget(
'number',
'c_input',
100,
() => {}
)
concreteInput.widget = { name: 'c_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 501 })
// Level B (middle): wraps C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'b_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 502 })
// Level A (outermost): wraps B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'a_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 503 })
return { concreteNode, concreteWidget, subgraphNodeA }
}
describe('three-level nested value propagation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('value set at outermost level propagates to concrete widget', () => {
const { concreteNode, subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].value).toBe(100)
subgraphNodeA.widgets[0].value = 200
expect(concreteNode.widgets![0].value).toBe(200)
})
test('type resolves correctly through all three layers', () => {
const { subgraphNodeA } = createThreeLevelNestedSubgraph()
expect(subgraphNodeA.widgets[0].type).toBe('number')
})
test('concrete value change is visible at the outermost level', () => {
const { concreteWidget, subgraphNodeA } = createThreeLevelNestedSubgraph()
concreteWidget.value = 999
expect(subgraphNodeA.widgets[0].value).toBe(999)
})
test('nested duplicate-name promotions resolve and update independently by disambiguating source node id', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({ rootGraph })
const firstTextNode = new LGraphNode('FirstTextNode')
firstTextNode.addWidget('text', 'text', '11111111111', () => {})
innerSubgraph.add(firstTextNode)
const secondTextNode = new LGraphNode('SecondTextNode')
secondTextNode.addWidget('text', 'text', '22222222222', () => {})
innerSubgraph.add(secondTextNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 3,
parentGraph: outerSubgraph
})
outerSubgraph.add(innerSubgraphNode)
const outerSubgraphNode = createTestSubgraphNode(outerSubgraph, {
id: 4,
parentGraph: rootGraph
})
rootGraph.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 widgets = promotedWidgets(outerSubgraphNode)
expect(widgets).toHaveLength(2)
expect(
widgets.map((widget) => widget.disambiguatingSourceNodeId)
).toStrictEqual([String(firstTextNode.id), String(secondTextNode.id)])
expect(widgets.map((widget) => widget.value)).toStrictEqual([
'11111111111',
'22222222222'
])
widgets[1].value = 'updated-second'
expect(firstTextNode.widgets?.[0]?.value).toBe('11111111111')
expect(secondTextNode.widgets?.[0]?.value).toBe('updated-second')
expect(widgets[0].value).toBe('11111111111')
expect(widgets[1].value).toBe('updated-second')
})
})
describe('multi-link representative determinism for input-based promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('first link is consistently chosen as representative for reads and writes', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph, { id: 601 })
subgraphNode.graph?.add(subgraphNode)
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('shared', '*')
firstNode.addWidget('text', 'shared', 'first-val', () => {})
firstInput.widget = { name: 'shared' }
subgraph.add(firstNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('shared', '*')
secondNode.addWidget('text', 'shared', 'second-val', () => {})
secondInput.widget = { name: 'shared' }
subgraph.add(secondNode)
const thirdNode = new LGraphNode('ThirdNode')
const thirdInput = thirdNode.addInput('shared', '*')
thirdNode.addWidget('text', 'shared', 'third-val', () => {})
thirdInput.widget = { name: 'shared' }
subgraph.add(thirdNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
subgraph.inputNode.slots[0].connect(secondInput, secondNode)
subgraph.inputNode.slots[0].connect(thirdInput, thirdNode)
const widgets = promotedWidgets(subgraphNode)
expect(widgets).toHaveLength(1)
expect(widgets[0].sourceNodeId).toBe(String(firstNode.id))
// Read returns the first link's value
expect(widgets[0].value).toBe('first-val')
// Write propagates to all linked nodes
widgets[0].value = 'updated'
expect(firstNode.widgets![0].value).toBe('updated')
expect(secondNode.widgets![0].value).toBe('updated')
expect(thirdNode.widgets![0].value).toBe('updated')
// Repeated reads are still deterministic
expect(widgets[0].value).toBe('updated')
})
})
function createFakeCanvasContext() {
return new Proxy({} as CanvasRenderingContext2D, {
get: () => vi.fn(() => ({ width: 10 }))
@@ -1831,10 +1973,6 @@ function createTwoLevelNestedSubgraph() {
}
describe('promoted combo rendering', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
test('draw shows value even when interior combo is computedDisabled', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const innerNode = firstInnerNode(innerNodes)
@@ -2120,12 +2258,12 @@ describe('promoted combo rendering', () => {
)
expect(promotions).toContainEqual({
interiorNodeId: String(subgraphNodeA.id),
widgetName: 'lora_name'
sourceNodeId: String(subgraphNodeA.id),
sourceWidgetName: 'lora_name'
})
expect(promotions).not.toContainEqual({
interiorNodeId: String(innerNode.id),
widgetName: 'lora_name'
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'lora_name'
})
})
@@ -2151,7 +2289,6 @@ describe('promoted combo rendering', () => {
describe('DOM widget promotion', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
@@ -2175,9 +2312,9 @@ describe('DOM widget promotion', () => {
}
test('draw registers position override for DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2189,9 +2326,9 @@ describe('DOM widget promotion', () => {
})
test('draw registers position override for component widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockComponentWidget(innerNodes[0], 'compWidget')
setPromotions(subgraphNode, [['1', 'compWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'compWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30)
@@ -2203,9 +2340,9 @@ describe('DOM widget promotion', () => {
})
test('draw does not register override for non-DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'textWidget', 'val', () => {})
setPromotions(subgraphNode, [['1', 'textWidget']])
setPromotions(subgraphNode, [[innerIds[0], 'textWidget']])
const view = subgraphNode.widgets[0]
view.draw!(createFakeCanvasContext(), subgraphNode, 200, 0, 30, true)
@@ -2232,14 +2369,14 @@ describe('DOM widget promotion', () => {
})
test('computeLayoutSize delegates to interior DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const domWidget = createMockDOMWidget(innerNodes[0], 'textarea')
domWidget.computeLayoutSize = vi.fn(() => ({
minHeight: 100,
maxHeight: 300,
minWidth: 0
}))
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
const result = view.computeLayoutSize!(subgraphNode)
@@ -2248,9 +2385,9 @@ describe('DOM widget promotion', () => {
})
test('demoting clears position override for DOM widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'textarea')
setPromotions(subgraphNode, [['1', 'textarea']])
setPromotions(subgraphNode, [[innerIds[0], 'textarea']])
const view = subgraphNode.widgets[0]
subgraphNode.removeWidget(view)
@@ -2261,12 +2398,12 @@ describe('DOM widget promotion', () => {
})
test('onRemoved clears position overrides for all promoted DOM widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
createMockDOMWidget(innerNodes[0], 'widgetA')
createMockDOMWidget(innerNodes[0], 'widgetB')
setPromotions(subgraphNode, [
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
// Access widgets to populate cache

View File

@@ -49,9 +49,16 @@ export function createPromotedWidgetView(
subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
displayName?: string
displayName?: string,
disambiguatingSourceNodeId?: string
): IPromotedWidgetView {
return new PromotedWidgetView(subgraphNode, nodeId, widgetName, displayName)
return new PromotedWidgetView(
subgraphNode,
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId
)
}
class PromotedWidgetView implements IPromotedWidgetView {
@@ -80,7 +87,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string
) {
this.sourceNodeId = nodeId
this.sourceWidgetName = widgetName
@@ -287,7 +295,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
}
@@ -301,7 +310,8 @@ class PromotedWidgetView implements IPromotedWidgetView {
const result = resolveConcretePromotedWidget(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
const resolved = result.status === 'resolved' ? result.resolved : undefined
@@ -341,7 +351,9 @@ class PromotedWidgetView implements IPromotedWidgetView {
if (boundWidget && isPromotedWidgetView(boundWidget)) {
return (
boundWidget.sourceNodeId === this.sourceNodeId &&
boundWidget.sourceWidgetName === this.sourceWidgetName
boundWidget.sourceWidgetName === this.sourceWidgetName &&
boundWidget.disambiguatingSourceNodeId ===
this.disambiguatingSourceNodeId
)
}

View File

@@ -18,6 +18,7 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
getPromotableWidgets,
hasUnpromotedWidgets,
isPreviewPseudoWidget,
promoteRecommendedWidgets,
pruneDisconnected
@@ -118,9 +119,12 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' },
{ interiorNodeId: String(interiorNode.id), widgetName: 'missing-widget' },
{ interiorNodeId: '9999', widgetName: 'missing-node' }
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' },
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
},
{ sourceNodeId: '9999', sourceWidgetName: 'missing-node' }
])
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
@@ -129,7 +133,9 @@ describe('pruneDisconnected', () => {
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([{ interiorNodeId: String(interiorNode.id), widgetName: 'kept' }])
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(warnSpy).toHaveBeenCalledOnce()
})
@@ -143,8 +149,8 @@ describe('pruneDisconnected', () => {
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
@@ -154,8 +160,8 @@ describe('pruneDisconnected', () => {
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
interiorNodeId: String(interiorNode.id),
widgetName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceNodeId: String(interiorNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -255,12 +261,10 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
@@ -280,12 +284,52 @@ describe('promoteRecommendedWidgets', () => {
const store = usePromotionStore()
expect(
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(glslNode.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
})
})
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { t } from '@/i18n'
import type {
@@ -26,6 +27,30 @@ export function getWidgetName(w: IBaseWidget): string {
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
return {
sourceNodeId: String(node.id),
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: getSourceNodeId(widget)
}
}
function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
for (const parent of parents) {
parent.computeSize(parent.size)
parent.setDirtyCanvas(true, true)
}
useCanvasStore().canvas?.setDirty(true, true)
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
@@ -51,16 +76,14 @@ export function promoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.promote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.promote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Promoted widget "${widgetName}" on node ${node.id}`,
message: `Promoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -71,16 +94,14 @@ export function demoteWidget(
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const nodeId = String(
isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
for (const parent of parents) {
store.demote(parent.rootGraph.id, parent.id, nodeId, widgetName)
store.demote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Demoted widget "${widgetName}" on node ${node.id}`,
message: `Demoted widget "${source.sourceWidgetName}" on node ${node.id}`,
level: 'info'
})
}
@@ -110,10 +131,9 @@ export function addWidgetPromotionOptions(
) {
const store = usePromotionStore()
const parents = getParentNodes()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -147,10 +167,9 @@ export function tryToggleWidgetPromotion() {
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const nodeId = String(node.id)
const widgetName = getWidgetName(widget)
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(s) => !store.isPromoted(s.rootGraph.id, s.id, nodeId, widgetName)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -219,12 +238,10 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
if (
store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
widget.name
)
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
@@ -242,20 +259,18 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
canvasSource
)
) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
continue
}
@@ -271,8 +286,7 @@ export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(n.id),
getWidgetName(w)
toPromotionSource(n, w)
)
}
subgraphNode.computeSize(subgraphNode.size)
@@ -285,17 +299,16 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: Array<{ interiorNodeId: string; widgetName: string }> =
[]
const removedEntries: PromotedWidgetSource[] = []
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.interiorNodeId)
const node = subgraph.getNodeById(entry.sourceNodeId)
if (!node) {
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === entry.widgetName
(iw) => iw.name === entry.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(entry)
@@ -315,9 +328,26 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(rootGraph.id, subgraphNodeId, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
})
)
)
}

View File

@@ -30,6 +30,7 @@ type PromotedWidgetStub = Pick<
> & {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
node?: SubgraphNode
}
@@ -51,7 +52,8 @@ function createPromotedWidget(
name: string,
sourceNodeId: string,
sourceWidgetName: string,
node?: SubgraphNode
node?: SubgraphNode,
disambiguatingSourceNodeId?: string
): IBaseWidget {
const promotedWidget: PromotedWidgetStub = {
name,
@@ -61,6 +63,7 @@ function createPromotedWidget(
value: undefined,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId,
node
}
return promotedWidget as IBaseWidget
@@ -94,6 +97,27 @@ describe('resolvePromotedWidgetAtHost', () => {
expect(resolved).toBeUndefined()
})
test('resolves duplicate-name promoted host widgets by disambiguating source node id', () => {
const host = createHostNode(100)
const sourceNode = addNodeToHost(host, 'source')
sourceNode.widgets = [
createPromotedWidget('text', String(sourceNode.id), 'text', host, '1'),
createPromotedWidget('text', String(sourceNode.id), 'text', host, '2')
]
const resolved = resolvePromotedWidgetAtHost(
host,
String(sourceNode.id),
'text',
'2'
)
expect(resolved).toBeDefined()
expect(
(resolved?.widget as PromotedWidgetStub).disambiguatingSourceNodeId
).toBe('2')
})
})
describe('resolveConcretePromotedWidget', () => {

View File

@@ -20,7 +20,8 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
@@ -28,6 +29,7 @@ function traversePromotedWidgetChain(
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
let hostUid = hostUidByObject.get(currentHost)
@@ -37,7 +39,7 @@ function traversePromotedWidgetChain(
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}`
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
@@ -48,8 +50,10 @@ function traversePromotedWidgetChain(
return { status: 'failure', failure: 'missing-node' }
}
const sourceWidget = sourceNode.widgets?.find(
(entry) => entry.name === currentWidgetName
const sourceWidget = findWidgetByIdentity(
sourceNode.widgets,
currentWidgetName,
currentSourceNodeId
)
if (!sourceWidget) {
return { status: 'failure', failure: 'missing-widget' }
@@ -69,22 +73,42 @@ function traversePromotedWidgetChain(
currentHost = sourceWidget.node
currentNodeId = sourceWidget.sourceNodeId
currentWidgetName = sourceWidget.sourceWidgetName
currentSourceNodeId = undefined
}
return { status: 'failure', failure: 'max-depth-exceeded' }
}
function findWidgetByIdentity(
widgets: IBaseWidget[] | undefined,
widgetName: string,
sourceNodeId?: string
): IBaseWidget | undefined {
if (!widgets) return undefined
if (sourceNodeId) {
return widgets.find(
(entry) =>
isPromotedWidgetView(entry) &&
(entry.disambiguatingSourceNodeId ?? entry.sourceNodeId) ===
sourceNodeId &&
(entry.sourceWidgetName === widgetName || entry.name === widgetName)
)
}
return widgets.find((entry) => entry.name === widgetName)
}
export function resolvePromotedWidgetAtHost(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): ResolvedPromotedWidget | undefined {
const node = hostNode.subgraph.getNodeById(nodeId)
if (!node) return undefined
const widget = node.widgets?.find(
(entry: IBaseWidget) => entry.name === widgetName
)
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
if (!widget) return undefined
return { node, widget }
@@ -93,10 +117,11 @@ export function resolvePromotedWidgetAtHost(
export function resolveConcretePromotedWidget(
hostNode: LGraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
if (!hostNode.isSubgraphNode()) {
return { status: 'failure', failure: 'invalid-host' }
}
return traversePromotedWidgetChain(hostNode, nodeId, widgetName)
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
}

View File

@@ -14,7 +14,8 @@ export function resolvePromotedWidgetSource(
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved

View File

@@ -6,7 +6,8 @@ import { resolveSubgraphInputLink } from '@/core/graph/subgraph/resolveSubgraphI
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
@@ -61,6 +62,7 @@ function addLinkedInteriorInput(
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
vi.clearAllMocks()
})
@@ -121,6 +123,21 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('seed_input')
})
test('resolves the first connected link when multiple links exist', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
// First connected wins — consistent with SubgraphNode._resolveLinkedPromotionBySubgraphInput
expect(result).toBe('first_input')
})
test('caches getTargetWidget result within the same callback evaluation', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('model')
const linked = addLinkedInteriorInput(
@@ -144,4 +161,33 @@ describe('resolveSubgraphInputLink', () => {
expect(result).toBe('ok')
expect(getWidgetFromSlot).toHaveBeenCalledTimes(1)
})
test('returns first link result with 3+ links connected', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'third_input', 'thirdWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
({ targetInput }) => targetInput.name
)
expect(result).toBe('first_input')
})
test('returns undefined when all links fail to resolve', () => {
const { subgraph, subgraphNode } = createSubgraphSetup('prompt')
addLinkedInteriorInput(subgraph, 'prompt', 'first_input', 'firstWidget')
addLinkedInteriorInput(subgraph, 'prompt', 'second_input', 'secondWidget')
const result = resolveSubgraphInputLink(
subgraphNode,
'prompt',
() => undefined
)
expect(result).toBeUndefined()
})
})

View File

@@ -19,9 +19,9 @@ export function resolveSubgraphInputLink<TResult>(
)
if (!inputSlot) return undefined
// Iterate from newest to oldest so the latest connection wins.
for (let index = inputSlot.linkIds.length - 1; index >= 0; index -= 1) {
const linkId = inputSlot.linkIds[index]
// Iterate forward so the first connected source is the promoted representative,
// matching SubgraphNode._resolveLinkedPromotionBySubgraphInput.
for (const linkId of inputSlot.linkIds) {
const link = node.subgraph.getLink(linkId)
if (!link) continue

View File

@@ -158,4 +158,93 @@ describe('resolveSubgraphInputTarget', () => {
expect(result).toBeUndefined()
})
test('resolves widget and non-widget inputs on the same nested SubgraphNode independently', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
const innerSubgraph = createTestSubgraph({
inputs: [
{ name: 'width', type: '*' },
{ name: 'audio', type: '*' }
]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 820
})
outerSubgraph.add(innerSubgraphNode)
const widthInput = innerSubgraphNode.addInput('width', '*')
innerSubgraphNode.addWidget('number', 'width', 0, () => undefined)
widthInput.widget = { name: 'width' }
const audioInput = innerSubgraphNode.addInput('audio', '*')
const widthSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'width'
)!
const audioSlot = outerSubgraph.inputNode.slots.find(
(slot) => slot.name === 'audio'
)!
widthSlot.connect(widthInput, innerSubgraphNode)
audioSlot.connect(audioInput, innerSubgraphNode)
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: '820',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toBeUndefined()
})
test('three-level nesting returns immediate child target, not deepest', () => {
// outer → middle → inner (concrete)
const innerSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
concreteNode.id = 900
const concreteInput = concreteNode.addInput('seed_input', '*')
concreteNode.addWidget('number', 'seed', 0, () => undefined)
concreteInput.widget = { name: 'seed' }
innerSubgraph.add(concreteNode)
innerSubgraph.inputNode.slots[0].connect(concreteInput, concreteNode)
const middleSubgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: '*' }]
})
const innerSubgraphNode = createTestSubgraphNode(innerSubgraph, {
id: 901
})
middleSubgraph.add(innerSubgraphNode)
const middleInput = innerSubgraphNode.addInput('seed', '*')
innerSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
middleInput.widget = { name: 'seed' }
middleSubgraph.inputNode.slots[0].connect(middleInput, innerSubgraphNode)
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'seed'
])
const middleSubgraphNode = createTestSubgraphNode(middleSubgraph, {
id: 902
})
outerSubgraph.add(middleSubgraphNode)
const outerInput = middleSubgraphNode.addInput('seed', '*')
middleSubgraphNode.addWidget('number', 'seed', 0, () => undefined)
outerInput.widget = { name: 'seed' }
outerSubgraph.inputNode.slots[0].connect(outerInput, middleSubgraphNode)
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'seed')
// Should return the immediate child (middle), not the deepest (concrete)
expect(result).toMatchObject({
nodeId: '902',
widgetName: 'seed'
})
})
})

View File

@@ -1,10 +1,12 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import { resolveSubgraphInputLink } from './resolveSubgraphInputLink'
type ResolvedSubgraphInputTarget = {
nodeId: string
widgetName: string
sourceNodeId?: string
}
export function resolveSubgraphInputTarget(
@@ -19,6 +21,16 @@ export function resolveSubgraphInputTarget(
const targetWidget = getTargetWidget()
if (!targetWidget) return undefined
if (isPromotedWidgetView(targetWidget)) {
return {
nodeId: String(inputNode.id),
widgetName: targetWidget.sourceWidgetName,
sourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
nodeId: String(inputNode.id),
widgetName: targetInput.name

View File

@@ -8,7 +8,8 @@ import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -23,33 +24,35 @@ vi.mock('@/services/litegraphService', () => ({
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[]] {
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes = []
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
return [subgraphNode, innerNodes]
const innerIds = innerNodes.map((n) => String(n.id))
return [subgraphNode, innerNodes, innerIds]
}
describe('Subgraph proxyWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
test('Can add simple widget', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
@@ -57,18 +60,20 @@ describe('Subgraph proxyWidgets', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes] = setupSubgraph(2)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'stringWidget' },
{ interiorNodeId: '2', widgetName: 'stringWidget' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
@@ -77,14 +82,14 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'value', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
@@ -92,19 +97,19 @@ describe('Subgraph proxyWidgets', () => {
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' }
])
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(subgraphNode.widgets[1].name).toBe('widgetA')
})
test('Will mirror changes to value', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
@@ -114,12 +119,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
@@ -133,12 +138,12 @@ describe('Subgraph proxyWidgets', () => {
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
@@ -154,46 +159,44 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
// Try to promote again - should not create duplicate
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(innerNodes[0].id),
'stringWidget'
)
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: innerIds[0],
sourceWidgetName: 'stringWidget'
})
expect(subgraphNode.widgets.length).toBe(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(1)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'stringWidget' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
@@ -204,35 +207,38 @@ describe('Subgraph proxyWidgets', () => {
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([{ interiorNodeId: '1', widgetName: 'widgetB' }])
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
test('removeWidgetByName removes from promotion list', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
test('removeWidget removes from promotion list', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
subgraphNode.removeWidgetByName('widgetA')
const widgetA = subgraphNode.widgets.find((w) => w.name === 'widgetA')!
subgraphNode.removeWidget(widgetA)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
})
test('removeWidget cleans up input references', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
@@ -248,12 +254,12 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ interiorNodeId: '1', widgetName: 'stringWidget' }]
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
@@ -265,23 +271,138 @@ describe('Subgraph proxyWidgets', () => {
})
test('serialize preserves proxyWidgets in properties', () => {
const [subgraphNode, innerNodes] = setupSubgraph(1)
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'widgetA', 'a', () => {})
innerNodes[0].addWidget('text', 'widgetB', 'b', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
['1', 'widgetA'],
['1', 'widgetB']
[innerIds[0], 'widgetA'],
[innerIds[0], 'widgetB']
])
})
test('multi-link representative is deterministic across repeated reads', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'shared_input', type: '*' }]
})
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
subgraphNode.graph!.add(subgraphNode)
const nodeA = new LGraphNode('NodeA')
const inputA = nodeA.addInput('shared_input', '*')
nodeA.addWidget('text', 'shared_input', 'first', () => {})
inputA.widget = { name: 'shared_input' }
subgraph.add(nodeA)
const nodeB = new LGraphNode('NodeB')
const inputB = nodeB.addInput('shared_input', '*')
nodeB.addWidget('text', 'shared_input', 'second', () => {})
inputB.widget = { name: 'shared_input' }
subgraph.add(nodeB)
const nodeC = new LGraphNode('NodeC')
const inputC = nodeC.addInput('shared_input', '*')
nodeC.addWidget('text', 'shared_input', 'third', () => {})
inputC.widget = { name: 'shared_input' }
subgraph.add(nodeC)
subgraph.inputNode.slots[0].connect(inputA, nodeA)
subgraph.inputNode.slots[0].connect(inputB, nodeB)
subgraph.inputNode.slots[0].connect(inputC, nodeC)
const firstRead = subgraphNode.widgets.map((w) => w.value)
const secondRead = subgraphNode.widgets.map((w) => w.value)
const thirdRead = subgraphNode.widgets.map((w) => w.value)
expect(firstRead).toStrictEqual(secondRead)
expect(secondRead).toStrictEqual(thirdRead)
expect(subgraphNode.widgets[0].value).toBe('first')
})
test('3-level nested promotion resolves concrete widget type and value', () => {
usePromotionStore()
// Level C: innermost subgraph with a concrete widget
const subgraphC = createTestSubgraph({
inputs: [{ name: 'deep_input', type: '*' }]
})
const concreteNode = new LGraphNode('ConcreteNode')
const concreteInput = concreteNode.addInput('deep_input', '*')
concreteNode.addWidget('number', 'deep_input', 42, () => {})
concreteInput.widget = { name: 'deep_input' }
subgraphC.add(concreteNode)
subgraphC.inputNode.slots[0].connect(concreteInput, concreteNode)
const subgraphNodeC = createTestSubgraphNode(subgraphC, { id: 301 })
// Level B: middle subgraph containing C
const subgraphB = createTestSubgraph({
inputs: [{ name: 'mid_input', type: '*' }]
})
subgraphB.add(subgraphNodeC)
subgraphNodeC._internalConfigureAfterSlots()
subgraphB.inputNode.slots[0].connect(subgraphNodeC.inputs[0], subgraphNodeC)
const subgraphNodeB = createTestSubgraphNode(subgraphB, { id: 302 })
// Level A: outermost subgraph containing B
const subgraphA = createTestSubgraph({
inputs: [{ name: 'outer_input', type: '*' }]
})
subgraphA.add(subgraphNodeB)
subgraphNodeB._internalConfigureAfterSlots()
subgraphA.inputNode.slots[0].connect(subgraphNodeB.inputs[0], subgraphNodeB)
const subgraphNodeA = createTestSubgraphNode(subgraphA, { id: 303 })
// Outermost promoted widget should resolve through all 3 levels
expect(subgraphNodeA.widgets).toHaveLength(1)
expect(subgraphNodeA.widgets[0].type).toBe('number')
expect(subgraphNodeA.widgets[0].value).toBe(42)
// Setting value at outermost level propagates to concrete widget
subgraphNodeA.widgets[0].value = 99
expect(concreteNode.widgets![0].value).toBe(99)
})
test('removeWidget cleans up promotion and input, then re-promote works', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
const view = subgraphNode.widgets[0]
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
// Remove: should clean up store AND input reference
subgraphNode.removeWidget(view)
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toHaveLength(0)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
// Re-promote: should work correctly after cleanup
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].type).toBe('text')
expect(subgraphNode.widgets[0].value).toBe('value')
})
})

View File

@@ -1,56 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
import { hasUnpromotedWidgets } from './unpromotedWidgetUtils'
describe('hasUnpromotedWidgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('returns true when subgraph has at least one enabled unpromoted widget', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(true)
})
it('returns false when all enabled widgets are already promoted', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
interiorNode.addWidget('text', 'seed', '123', () => {})
usePromotionStore().promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
String(interiorNode.id),
'seed'
)
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
it('ignores computed-disabled widgets', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
widget.computedDisabled = true
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
})

View File

@@ -1,20 +0,0 @@
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { usePromotionStore } from '@/stores/promotionStore'
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!promotionStore.isPromoted(
rootGraph.id,
subgraphNodeId,
String(interiorNode.id),
widget.name
)
)
)
}

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { parseProxyWidgets } from './promotionSchema'
describe(parseProxyWidgets, () => {
it('parses 2-tuple arrays', () => {
const input = [
['10', 'seed'],
['11', 'steps']
]
expect(parseProxyWidgets(input)).toEqual([
['10', 'seed'],
['11', 'steps']
])
})
it('parses 3-tuple arrays', () => {
const input = [
['3', 'text', '1'],
['3', 'text', '2']
]
expect(parseProxyWidgets(input)).toEqual([
['3', 'text', '1'],
['3', 'text', '2']
])
})
it('parses mixed 2-tuple and 3-tuple arrays', () => {
const input = [
['10', 'seed'],
['3', 'text', '1']
]
expect(parseProxyWidgets(input)).toEqual([
['10', 'seed'],
['3', 'text', '1']
])
})
it('returns empty array for non-array input', () => {
expect(parseProxyWidgets(undefined)).toEqual([])
expect(parseProxyWidgets('not-json{')).toEqual([])
})
it('returns empty array for invalid tuples', () => {
expect(parseProxyWidgets([['only-one']])).toEqual([])
expect(parseProxyWidgets([['a', 'b', 'c', 'd']])).toEqual([])
})
})

View File

@@ -3,18 +3,27 @@ import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
const proxyWidgetsPropertySchema = z.array(z.tuple([z.string(), z.string()]))
const proxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
try {
if (typeof property === 'string') property = JSON.parse(property)
const result = proxyWidgetsPropertySchema.safeParse(
typeof property === 'string' ? JSON.parse(property) : property
)
if (result.success) return result.data
const error = fromZodError(result.error)
throw new Error(`Invalid assignment for properties.proxyWidgets:\n${error}`)
const error = fromZodError(result.error)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
}

View File

@@ -1,4 +1,4 @@
import DOMPurify from 'dompurify'
import dompurify from 'dompurify'
import type {
ContextMenuDivElement,
@@ -16,7 +16,7 @@ const ALLOWED_STYLE_PROPS = new Set([
'border-left'
])
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
dompurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'style') {
const sanitizedStyle = data.attrValue
.split(';')
@@ -33,7 +33,7 @@ DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
})
function sanitizeMenuHTML(html: string): string {
return DOMPurify.sanitize(html, {
return dompurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR: ['style']
})

View File

@@ -244,7 +244,10 @@ describe('Graph Clearing and Callbacks', () => {
graph.id = graphId
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, '10', 'seed')
promotionStore.promote(graphId, 1 as NodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
const widgetValueStore = useWidgetValueStore()
widgetValueStore.registerWidget(graphId, {
@@ -258,14 +261,24 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(true)
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
graph.clear()
expect(promotionStore.isPromotedByAny(graphId, '10', 'seed')).toBe(false)
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()

View File

@@ -3028,14 +3028,14 @@ export class Subgraph
* @param input The input slot to remove.
*/
removeInput(input: SubgraphInput): void {
input.disconnect()
const index = this.inputs.indexOf(input)
if (index === -1) throw new Error('Input not found')
const mayContinue = this.events.dispatch('removing-input', { input, index })
if (!mayContinue) return
input.disconnect()
this.inputs.splice(index, 1)
const { length } = this.inputs
@@ -3049,8 +3049,6 @@ export class Subgraph
* @param output The output slot to remove.
*/
removeOutput(output: SubgraphOutput): void {
output.disconnect()
const index = this.outputs.indexOf(output)
if (index === -1) throw new Error('Output not found')
@@ -3060,6 +3058,8 @@ export class Subgraph
})
if (!mayContinue) return
output.disconnect()
this.outputs.splice(index, 1)
const { length } = this.outputs

View File

@@ -2018,11 +2018,6 @@ export class LGraphNode
})
}
removeWidgetByName(name: string): void {
const widget = this.widgets?.find((x) => x.name === name)
if (widget) this.removeWidget(widget)
}
removeWidget(widget: IBaseWidget): void {
if (!this.widgets)
throw new Error('removeWidget called on node without widgets')

View File

@@ -87,7 +87,7 @@ export { ContextMenu } from './ContextMenu'
export { DragAndScale } from './DragAndScale'
export { Rectangle } from './infrastructure/Rectangle'
export { RecursionError } from './infrastructure/RecursionError'
export type { SubgraphEventMap } from './infrastructure/SubgraphEventMap'
export type {
CanvasColour,
ColorOption,

View File

@@ -1,4 +1,3 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -13,10 +12,16 @@ import {
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('ExecutableNodeDTO Creation', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('ExecutableNodeDTO Creation', () => {
it('should create DTO from regular node', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -106,7 +111,7 @@ describe.skip('ExecutableNodeDTO Creation', () => {
})
})
describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
describe('ExecutableNodeDTO Path-Based IDs', () => {
it('should generate simple ID for root node', () => {
const graph = new LGraph()
const node = new LGraphNode('Root Node')
@@ -160,7 +165,7 @@ describe.skip('ExecutableNodeDTO Path-Based IDs', () => {
})
})
describe.skip('ExecutableNodeDTO Input Resolution', () => {
describe('ExecutableNodeDTO Input Resolution', () => {
it('should return undefined for unconnected inputs', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -202,7 +207,7 @@ describe.skip('ExecutableNodeDTO Input Resolution', () => {
})
})
describe.skip('ExecutableNodeDTO Output Resolution', () => {
describe('ExecutableNodeDTO Output Resolution', () => {
it('should resolve outputs for simple nodes', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -478,7 +483,7 @@ describe('Virtual node resolveVirtualOutput', () => {
})
})
describe.skip('ExecutableNodeDTO Properties', () => {
describe('ExecutableNodeDTO Properties', () => {
it('should provide access to basic properties', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -513,7 +518,7 @@ describe.skip('ExecutableNodeDTO Properties', () => {
})
})
describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
describe('ExecutableNodeDTO Memory Efficiency', () => {
it('should create lightweight objects', () => {
const graph = new LGraph()
const node = new LGraphNode('Test Node')
@@ -537,7 +542,7 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
expect(dto.hasOwnProperty('widgets')).toBe(false) // Widgets not copied
})
it('should handle disposal without memory leaks', () => {
it('should drop local references without explicit disposal', () => {
const graph = new LGraph()
const nodes: ExecutableNodeDTO[] = []
@@ -580,19 +585,20 @@ describe.skip('ExecutableNodeDTO Memory Efficiency', () => {
})
})
describe.skip('ExecutableNodeDTO Integration', () => {
describe('ExecutableNodeDTO Integration', () => {
it('should work with SubgraphNode flattening', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
const flattened = subgraphNode.getInnerNodes(new Map())
const idPattern = new RegExp(`^${subgraphNode.id}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0]).toBeInstanceOf(ExecutableNodeDTO)
expect(flattened[0].id).toMatch(/^1:\d+$/)
expect(flattened[0].id).toMatch(idPattern)
})
it.skip('should handle nested subgraph flattening', () => {
it('should handle nested subgraph flattening', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// This test needs investigation of how resolveSubgraphIdPath works
// Skip for now - will implement in edge cases test file
@@ -654,7 +660,7 @@ describe.skip('ExecutableNodeDTO Integration', () => {
})
})
describe.skip('ExecutableNodeDTO Scale Testing', () => {
describe('ExecutableNodeDTO Scale Testing', () => {
it('should create DTOs at scale', () => {
const graph = new LGraph()
const startTime = performance.now()

View File

@@ -3,13 +3,13 @@ import { describe, expect, test } from 'vitest'
import { PromotedWidgetViewManager } from '@/lib/litegraph/src/subgraph/PromotedWidgetViewManager'
type TestPromotionEntry = {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
}
function makeView(entry: TestPromotionEntry) {
const baseKey = `${entry.interiorNodeId}:${entry.widgetName}`
const baseKey = `${entry.sourceNodeId}:${entry.sourceWidgetName}`
return {
key: entry.viewKey ? `${baseKey}:${entry.viewKey}` : baseKey
@@ -19,7 +19,7 @@ function makeView(entry: TestPromotionEntry) {
describe('PromotedWidgetViewManager', () => {
test('returns memoized array when entries reference is unchanged', () => {
const manager = new PromotedWidgetViewManager<{ key: string }>()
const entries = [{ interiorNodeId: '1', widgetName: 'widgetA' }]
const entries = [{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }]
const first = manager.reconcile(entries, makeView)
const second = manager.reconcile(entries, makeView)
@@ -33,16 +33,16 @@ describe('PromotedWidgetViewManager', () => {
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }
],
makeView
)
const reordered = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -56,9 +56,9 @@ describe('PromotedWidgetViewManager', () => {
const first = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA' },
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -68,14 +68,14 @@ describe('PromotedWidgetViewManager', () => {
])
manager.reconcile(
[{ interiorNodeId: '1', widgetName: 'widgetB' }],
[{ sourceNodeId: '1', sourceWidgetName: 'widgetB' }],
makeView
)
const restored = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetB' },
{ interiorNodeId: '1', widgetName: 'widgetA' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetB' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA' }
],
makeView
)
@@ -89,8 +89,8 @@ describe('PromotedWidgetViewManager', () => {
const views = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
@@ -106,8 +106,8 @@ describe('PromotedWidgetViewManager', () => {
const firstPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)
@@ -116,8 +116,8 @@ describe('PromotedWidgetViewManager', () => {
const secondPass = manager.reconcile(
[
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotA' },
{ interiorNodeId: '1', widgetName: 'widgetA', viewKey: 'slotB' }
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotA' },
{ sourceNodeId: '1', sourceWidgetName: 'widgetA', viewKey: 'slotB' }
],
makeView
)

View File

@@ -1,10 +1,8 @@
type PromotionEntry = {
interiorNodeId: string
widgetName: string
viewKey?: string
}
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
type CreateView<TView> = (entry: PromotionEntry) => TView
type ViewManagerEntry = PromotedWidgetSource & { viewKey?: string }
type CreateView<TView> = (entry: ViewManagerEntry) => TView
/**
* Reconciles promoted widget entries to stable view instances.
@@ -18,11 +16,11 @@ export class PromotedWidgetViewManager<TView> {
private cachedEntryKeys: string[] | null = null
reconcile(
entries: readonly PromotionEntry[],
entries: readonly ViewManagerEntry[],
createView: CreateView<TView>
): TView[] {
const entryKeys = entries.map((entry) =>
this.makeKey(entry.interiorNodeId, entry.widgetName, entry.viewKey)
this.makeKey(entry.sourceNodeId, entry.sourceWidgetName, entry.viewKey)
)
if (this.cachedViews && this.areEntryKeysEqual(entryKeys))
@@ -33,8 +31,8 @@ export class PromotedWidgetViewManager<TView> {
for (const entry of entries) {
const key = this.makeKey(
entry.interiorNodeId,
entry.widgetName,
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey
)
if (seenKeys.has(key)) continue
@@ -61,12 +59,12 @@ export class PromotedWidgetViewManager<TView> {
}
getOrCreate(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
createView: () => TView,
viewKey?: string
): TView {
const key = this.makeKey(interiorNodeId, widgetName, viewKey)
const key = this.makeKey(sourceNodeId, sourceWidgetName, viewKey)
const cached = this.viewCache.get(key)
if (cached) return cached
@@ -75,17 +73,17 @@ export class PromotedWidgetViewManager<TView> {
return view
}
remove(interiorNodeId: string, widgetName: string): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName))
remove(sourceNodeId: string, sourceWidgetName: string): void {
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName))
this.invalidateMemoizedList()
}
removeByViewKey(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
viewKey: string
): void {
this.viewCache.delete(this.makeKey(interiorNodeId, widgetName, viewKey))
this.viewCache.delete(this.makeKey(sourceNodeId, sourceWidgetName, viewKey))
this.invalidateMemoizedList()
}
@@ -110,11 +108,11 @@ export class PromotedWidgetViewManager<TView> {
}
private makeKey(
interiorNodeId: string,
widgetName: string,
sourceNodeId: string,
sourceWidgetName: string,
viewKey?: string
): string {
const baseKey = `${interiorNodeId}:${widgetName}`
const baseKey = `${sourceNodeId}:${sourceWidgetName}`
return viewKey ? `${baseKey}:${viewKey}` : baseKey
}
}

View File

@@ -1,28 +1,31 @@
// TODO: Fix these tests after migration
/**
* Core Subgraph Tests
*
* This file implements fundamental tests for the Subgraph class that establish
* patterns for the rest of the testing team. These tests cover construction,
* basic I/O management, and known issues.
* patterns for the rest of the testing team. These tests cover construction
* and basic I/O management.
*/
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
createUuidv4,
RecursionError,
LGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { createUuidv4, Subgraph } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
assertSubgraphStructure,
createTestSubgraph,
createTestSubgraphData
createTestSubgraphData,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('Subgraph Construction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph Construction', () => {
it('should create a subgraph with minimal data', () => {
const subgraph = createTestSubgraph()
@@ -44,11 +47,10 @@ describe.skip('Subgraph Construction', () => {
it('should require a root graph', () => {
const subgraphData = createTestSubgraphData()
const createWithoutRoot = () =>
new Subgraph(null as unknown as LGraph, subgraphData)
expect(() => {
// @ts-expect-error Testing invalid null parameter
new Subgraph(null, subgraphData)
}).toThrow('Root graph is required')
expect(createWithoutRoot).toThrow('Root graph is required')
})
it('should accept custom name and ID', () => {
@@ -63,31 +65,9 @@ describe.skip('Subgraph Construction', () => {
expect(subgraph.id).toBe(customId)
expect(subgraph.name).toBe(customName)
})
it('should initialize with empty inputs and outputs', () => {
const subgraph = createTestSubgraph()
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
expect(subgraph.widgets).toHaveLength(0)
})
it('should have properly configured input and output nodes', () => {
const subgraph = createTestSubgraph()
// Input node should be positioned on the left
expect(subgraph.inputNode.pos[0]).toBeLessThan(100)
// Output node should be positioned on the right
expect(subgraph.outputNode.pos[0]).toBeGreaterThan(300)
// Both should reference the subgraph
expect(subgraph.inputNode.subgraph).toBe(subgraph)
expect(subgraph.outputNode.subgraph).toBe(subgraph)
})
})
describe.skip('Subgraph Input/Output Management', () => {
describe('Subgraph Input/Output Management', () => {
subgraphTest('should add a single input', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test_input', 'number')
@@ -164,163 +144,3 @@ describe.skip('Subgraph Input/Output Management', () => {
expect(simpleSubgraph.outputs.indexOf(simpleSubgraph.outputs[0])).toBe(0)
})
})
describe.skip('Subgraph Serialization', () => {
subgraphTest('should serialize empty subgraph', ({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.version).toBe(1)
expect(serialized.id).toBeTruthy()
expect(serialized.name).toBe('Empty Test Subgraph')
expect(serialized.inputs).toHaveLength(0)
expect(serialized.outputs).toHaveLength(0)
expect(serialized.nodes).toHaveLength(0)
expect(typeof serialized.links).toBe('object')
})
subgraphTest(
'should serialize subgraph with inputs and outputs',
({ simpleSubgraph }) => {
const serialized = simpleSubgraph.asSerialisable()
expect(serialized.inputs).toHaveLength(1)
expect(serialized.outputs).toHaveLength(1)
expect(serialized.inputs![0].name).toBe('input')
expect(serialized.inputs![0].type).toBe('number')
expect(serialized.outputs![0].name).toBe('output')
expect(serialized.outputs![0].type).toBe('number')
}
)
subgraphTest(
'should include input and output nodes in serialization',
({ emptySubgraph }) => {
const serialized = emptySubgraph.asSerialisable()
expect(serialized.inputNode).toBeDefined()
expect(serialized.outputNode).toBeDefined()
expect(serialized.inputNode.id).toBe(-10)
expect(serialized.outputNode.id).toBe(-20)
}
)
})
describe.skip('Subgraph Known Issues', () => {
it.todo('should enforce MAX_NESTED_SUBGRAPHS limit', () => {
// This test documents that MAX_NESTED_SUBGRAPHS = 1000 is defined
// but not actually enforced anywhere in the code.
//
// Expected behavior: Should throw error when nesting exceeds limit
// Actual behavior: No validation is performed
//
// This safety limit should be implemented to prevent runaway recursion.
})
it('should provide MAX_NESTED_SUBGRAPHS constant', () => {
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
})
it('should have recursion detection in place', () => {
// Verify that RecursionError is available and can be thrown
expect(() => {
throw new RecursionError('test recursion')
}).toThrow(RecursionError)
expect(() => {
throw new RecursionError('test recursion')
}).toThrow('test recursion')
})
})
describe.skip('Subgraph Root Graph Relationship', () => {
it('should maintain reference to root graph', () => {
const rootGraph = new LGraph()
const subgraphData = createTestSubgraphData()
const subgraph = new Subgraph(rootGraph, subgraphData)
expect(subgraph.rootGraph).toBe(rootGraph)
})
it('should inherit root graph in nested subgraphs', () => {
const rootGraph = new LGraph()
const parentData = createTestSubgraphData({
name: 'Parent Subgraph'
})
const parentSubgraph = new Subgraph(rootGraph, parentData)
// Create a nested subgraph
const nestedData = createTestSubgraphData({
name: 'Nested Subgraph'
})
const nestedSubgraph = new Subgraph(rootGraph, nestedData)
expect(nestedSubgraph.rootGraph).toBe(rootGraph)
expect(parentSubgraph.rootGraph).toBe(rootGraph)
})
})
describe.skip('Subgraph Error Handling', () => {
subgraphTest(
'should handle removing non-existent input gracefully',
({ emptySubgraph }) => {
// Create a fake input that doesn't belong to this subgraph
const fakeInput = emptySubgraph.addInput('temp', 'number')
emptySubgraph.removeInput(fakeInput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeInput(fakeInput)
}).toThrow('Input not found')
}
)
subgraphTest(
'should handle removing non-existent output gracefully',
({ emptySubgraph }) => {
// Create a fake output that doesn't belong to this subgraph
const fakeOutput = emptySubgraph.addOutput('temp', 'number')
emptySubgraph.removeOutput(fakeOutput) // Remove it first
// Now try to remove it again
expect(() => {
emptySubgraph.removeOutput(fakeOutput)
}).toThrow('Output not found')
}
)
})
describe.skip('Subgraph Integration', () => {
it("should work with LGraph's node management", () => {
const subgraph = createTestSubgraph({
nodeCount: 3
})
// Verify nodes were added to the subgraph
expect(subgraph.nodes).toHaveLength(3)
// Verify we can access nodes by ID
const firstNode = subgraph.getNodeById(1)
expect(firstNode).toBeDefined()
expect(firstNode?.title).toContain('Test Node')
})
it('should maintain link integrity', () => {
const subgraph = createTestSubgraph({
nodeCount: 2
})
const node1 = subgraph.nodes[0]
const node2 = subgraph.nodes[1]
// Connect the nodes
node1.connect(0, node2, 0)
// Verify link was created
expect(subgraph.links.size).toBe(1)
// Verify link integrity
const link = Array.from(subgraph.links.values())[0]
expect(link.origin_id).toBe(node1.id)
expect(link.target_id).toBe(node2.id)
})
})

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { assert, describe, expect, it } from 'vitest'
import { assert, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import {
LGraphGroup,
@@ -8,11 +9,19 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
function createNode(
graph: LGraph,
inputs: ISlotType[] = [],
@@ -40,8 +49,8 @@ function createNode(
graph.add(node)
return node
}
describe.skip('SubgraphConversion', () => {
describe.skip('Subgraph Unpacking Functionality', () => {
describe('SubgraphConversion', () => {
describe('Subgraph Unpacking Functionality', () => {
it('Should keep interior nodes and links', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -197,4 +206,39 @@ describe.skip('SubgraphConversion', () => {
expect(linkRefCount).toBe(4)
})
})
describe('Promotion cleanup on unpack', () => {
it('Should clear promotions for the unpacked subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNode = createNode(subgraph, [], ['number'])
innerNode.addWidget('text', 'myWidget', 'default', () => {})
const promotionStore = usePromotionStore()
const graphId = graph.id
const subgraphNodeId = subgraphNode.id
promotionStore.promote(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
expect(
promotionStore.isPromoted(graphId, subgraphNodeId, {
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'myWidget'
})
).toBe(true)
graph.unpackSubgraph(subgraphNode)
expect(graph.getNodeById(subgraphNodeId)).toBeUndefined()
expect(
promotionStore.getPromotions(graphId, subgraphNodeId)
).toHaveLength(0)
})
})
})

View File

@@ -1,21 +1,28 @@
// TODO: Fix these tests after migration
/**
* SubgraphEdgeCases Tests
*
* Tests for edge cases, error handling, and boundary conditions in the subgraph system.
* This covers unusual scenarios, invalid states, and stress testing.
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
createNestedSubgraphs,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphEdgeCases - Recursion Detection', () => {
it('should handle circular subgraph references without crashing', () => {
const sub1 = createTestSubgraph({ name: 'Sub1' })
const sub2 = createTestSubgraph({ name: 'Sub2' })
@@ -24,14 +31,11 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
const node1 = createTestSubgraphNode(sub1, { id: 1 })
const node2 = createTestSubgraphNode(sub2, { id: 2 })
// Current limitation: adding a circular reference overflows recursion depth.
sub1.add(node2)
sub2.add(node1)
// Should not crash or hang - currently throws path resolution error due to circular structure
expect(() => {
const executableNodes = new Map()
node1.getInnerNodes(executableNodes)
}).toThrow(/Node \[\d+\] not found/) // Current behavior: path resolution fails
sub2.add(node1)
}).toThrow(RangeError)
})
it('should handle deep nesting scenarios', () => {
@@ -48,20 +52,14 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
expect(firstLevel.isSubgraphNode()).toBe(true)
})
it.todo('should use WeakSet for cycle detection', () => {
// TODO: This test is currently skipped because cycle detection has a bug
// The fix is to pass 'visited' directly instead of 'new Set(visited)' in SubgraphNode.ts:299
it('should throw RangeError for self-referential subgraph', () => {
// Current limitation: creating self-referential subgraph instances overflows recursion depth.
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add to own subgraph to create cycle
subgraph.add(subgraphNode)
// Should throw due to cycle detection
const executableNodes = new Map()
expect(() => {
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(/while flattening subgraph/i)
subgraph.add(subgraphNode)
}).toThrow(RangeError)
})
it('should respect MAX_NESTED_SUBGRAPHS constant', () => {
@@ -76,7 +74,7 @@ describe.skip('SubgraphEdgeCases - Recursion Detection', () => {
})
})
describe.skip('SubgraphEdgeCases - Invalid States', () => {
describe('SubgraphEdgeCases - Invalid States', () => {
it('should handle removing non-existent inputs gracefully', () => {
const subgraph = createTestSubgraph()
const fakeInput = {
@@ -120,7 +118,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addInput(undefinedString, 'number')
}).toThrow()
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
})
it('should handle null/undefined output names', () => {
@@ -135,7 +135,9 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
expect(() => {
subgraph.addOutput(undefinedString, 'number')
}).toThrow()
}).not.toThrow()
expect(subgraph.outputs).toHaveLength(1)
})
it('should handle empty string names', () => {
@@ -160,11 +162,14 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
// Undefined type should throw error
expect(() => {
subgraph.addInput('test', undefinedString)
}).toThrow()
}).not.toThrow()
expect(() => {
subgraph.addOutput('test', undefinedString)
}).toThrow()
}).not.toThrow()
expect(subgraph.inputs).toHaveLength(1)
expect(subgraph.outputs).toHaveLength(1)
})
it('should handle duplicate slot names', () => {
@@ -185,7 +190,7 @@ describe.skip('SubgraphEdgeCases - Invalid States', () => {
})
})
describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
describe('SubgraphEdgeCases - Boundary Conditions', () => {
it('should handle empty subgraphs (no nodes, no IO)', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -239,35 +244,9 @@ describe.skip('SubgraphEdgeCases - Boundary Conditions', () => {
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(1) // Original node count
})
it('should handle very long slot names', () => {
const subgraph = createTestSubgraph()
const longName = 'a'.repeat(1000) // 1000 character name
expect(() => {
subgraph.addInput(longName, 'number')
subgraph.addOutput(longName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(longName)
expect(subgraph.outputs[0].name).toBe(longName)
})
it('should handle Unicode characters in names', () => {
const subgraph = createTestSubgraph()
const unicodeName = '测试_🚀_تست_тест'
expect(() => {
subgraph.addInput(unicodeName, 'number')
subgraph.addOutput(unicodeName, 'string')
}).not.toThrow()
expect(subgraph.inputs[0].name).toBe(unicodeName)
expect(subgraph.outputs[0].name).toBe(unicodeName)
})
})
describe.skip('SubgraphEdgeCases - Type Validation', () => {
describe('SubgraphEdgeCases - Type Validation', () => {
it('should allow connecting mismatched types (no validation currently)', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -289,18 +268,6 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
}).not.toThrow()
})
it('should handle invalid type strings', () => {
const subgraph = createTestSubgraph()
// These should not crash (current behavior)
expect(() => {
subgraph.addInput('test1', 'invalid_type')
subgraph.addInput('test2', '')
subgraph.addInput('test3', '123')
subgraph.addInput('test4', 'special!@#$%')
}).not.toThrow()
})
it('should handle complex type strings', () => {
const subgraph = createTestSubgraph()
@@ -317,7 +284,7 @@ describe.skip('SubgraphEdgeCases - Type Validation', () => {
})
})
describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
describe('SubgraphEdgeCases - Performance and Scale', () => {
it('should handle large numbers of nodes in subgraph', () => {
// Create subgraph with many nodes (keep reasonable for test speed)
const subgraph = createTestSubgraph({ nodeCount: 50 })
@@ -348,35 +315,4 @@ describe.skip('SubgraphEdgeCases - Performance and Scale', () => {
expect(subgraph.inputs).toHaveLength(0)
expect(subgraph.outputs).toHaveLength(0)
})
it('should handle concurrent modifications safely', () => {
// This test ensures the system doesn't crash under concurrent access
// Note: JavaScript is single-threaded, so this tests rapid sequential access
const subgraph = createTestSubgraph({ nodeCount: 5 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Simulate concurrent operations
const operations: Array<() => void> = []
for (let i = 0; i < 20; i++) {
operations.push(
() => {
const executableNodes = new Map()
subgraphNode.getInnerNodes(executableNodes)
},
() => {
subgraph.addInput(`concurrent_${i}`, 'number')
},
() => {
if (subgraph.inputs.length > 0) {
subgraph.removeInput(subgraph.inputs[0])
}
}
)
}
// Execute all operations - should not crash
expect(() => {
for (const op of operations) op()
}).not.toThrow()
})
})

View File

@@ -1,10 +1,11 @@
// TODO: Fix these tests after migration
import { describe, expect, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import { verifyEventSequence } from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphEvents - Event Payload Verification', () => {
describe('SubgraphEvents - Event Payload Verification', () => {
subgraphTest(
'dispatches input-added with correct payload',
({ eventCapture }) => {
@@ -199,9 +200,9 @@ describe.skip('SubgraphEvents - Event Payload Verification', () => {
)
})
describe.skip('SubgraphEvents - Event Handler Isolation', () => {
describe('SubgraphEvents - Event Handler Isolation', () => {
subgraphTest(
'continues dispatching if handler throws',
'surfaces handler errors to caller and stops propagation',
({ emptySubgraph }) => {
const handler1 = vi.fn(() => {
throw new Error('Handler 1 error')
@@ -213,15 +214,15 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
emptySubgraph.events.addEventListener('input-added', handler2)
emptySubgraph.events.addEventListener('input-added', handler3)
// The operation itself should not throw (error is isolated)
// Current runtime behavior: listener exceptions bubble out of dispatch.
expect(() => {
emptySubgraph.addInput('test', 'number')
}).not.toThrow()
}).toThrowError('Handler 1 error')
// Verify all handlers were called despite the first one throwing
// Once the first listener throws, later listeners are not invoked.
expect(handler1).toHaveBeenCalled()
expect(handler2).toHaveBeenCalled()
expect(handler3).toHaveBeenCalled()
expect(handler2).not.toHaveBeenCalled()
expect(handler3).not.toHaveBeenCalled()
// Verify the throwing handler actually received the event
expect(handler1).toHaveBeenCalledWith(
@@ -229,24 +230,6 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
type: 'input-added'
})
)
// Verify other handlers received correct event data
expect(handler2).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added',
detail: expect.objectContaining({
input: expect.objectContaining({
name: 'test',
type: 'number'
})
})
})
)
expect(handler3).toHaveBeenCalledWith(
expect.objectContaining({
type: 'input-added'
})
)
}
)
@@ -305,7 +288,7 @@ describe.skip('SubgraphEvents - Event Handler Isolation', () => {
)
})
describe.skip('SubgraphEvents - Event Sequence Testing', () => {
describe('SubgraphEvents - Event Sequence Testing', () => {
subgraphTest(
'maintains correct event sequence for inputs',
({ eventCapture }) => {
@@ -351,7 +334,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
}
)
subgraphTest('handles concurrent event handling', ({ eventCapture }) => {
subgraphTest('fires all listeners synchronously', ({ eventCapture }) => {
const { subgraph, capture } = eventCapture
const handler1 = vi.fn(() => {
@@ -393,7 +376,7 @@ describe.skip('SubgraphEvents - Event Sequence Testing', () => {
)
})
describe.skip('SubgraphEvents - Event Cancellation', () => {
describe('SubgraphEvents - Event Cancellation', () => {
subgraphTest(
'supports preventDefault() for cancellable events',
({ emptySubgraph }) => {
@@ -443,71 +426,78 @@ describe.skip('SubgraphEvents - Event Cancellation', () => {
expect(emptySubgraph.inputs).toHaveLength(0)
expect(allowHandler).toHaveBeenCalled()
})
})
describe.skip('SubgraphEvents - Event Detail Structure Validation', () => {
subgraphTest('veto preserves input connections', ({ emptySubgraph }) => {
const input = emptySubgraph.addInput('test', 'number')
const node = new LGraphNode('Interior')
node.addInput('in', 'number')
emptySubgraph.add(node)
input.connect(node.inputs[0], node)
expect(input.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-input', (event) => {
event.preventDefault()
})
emptySubgraph.removeInput(input)
expect(emptySubgraph.inputs).toContain(input)
expect(input.linkIds).not.toHaveLength(0)
})
subgraphTest('veto preserves output connections', ({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('test', 'number')
const node = new LGraphNode('Interior')
node.addOutput('out', 'number')
emptySubgraph.add(node)
output.connect(node.outputs[0], node)
expect(output.linkIds).not.toHaveLength(0)
emptySubgraph.events.addEventListener('removing-output', (event) => {
event.preventDefault()
})
emptySubgraph.removeOutput(output)
expect(emptySubgraph.outputs).toContain(output)
expect(output.linkIds).not.toHaveLength(0)
})
subgraphTest(
'validates all event detail structures match TypeScript types',
({ eventCapture }) => {
const { subgraph, capture } = eventCapture
'rename input cancellation does not prevent rename',
({ emptySubgraph }) => {
const input = emptySubgraph.addInput('original', 'number')
const input = subgraph.addInput('test_input', 'number')
subgraph.renameInput(input, 'renamed_input')
subgraph.removeInput(input)
const output = subgraph.addOutput('test_output', 'string')
subgraph.renameOutput(output, 'renamed_output')
subgraph.removeOutput(output)
const addingInputEvent = capture.getEventsByType('adding-input')[0]
expect(addingInputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
})
emptySubgraph.events.addEventListener('renaming-input', preventHandler)
const inputAddedEvent = capture.getEventsByType('input-added')[0]
expect(inputAddedEvent.detail).toEqual({
input: expect.any(Object)
})
emptySubgraph.renameInput(input, 'new_name')
const renamingInputEvent = capture.getEventsByType('renaming-input')[0]
expect(renamingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
expect(input.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
}
)
const removingInputEvent = capture.getEventsByType('removing-input')[0]
expect(removingInputEvent.detail).toEqual({
input: expect.any(Object),
index: expect.any(Number)
})
subgraphTest(
'rename output cancellation does not prevent rename',
({ emptySubgraph }) => {
const output = emptySubgraph.addOutput('original', 'number')
const addingOutputEvent = capture.getEventsByType('adding-output')[0]
expect(addingOutputEvent.detail).toEqual({
name: expect.any(String),
type: expect.any(String)
const preventHandler = vi.fn((event: Event) => {
event.preventDefault()
})
emptySubgraph.events.addEventListener('renaming-output', preventHandler)
const outputAddedEvent = capture.getEventsByType('output-added')[0]
expect(outputAddedEvent.detail).toEqual({
output: expect.any(Object)
})
emptySubgraph.renameOutput(output, 'new_name')
const renamingOutputEvent = capture.getEventsByType('renaming-output')[0]
expect(renamingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number),
oldName: expect.any(String),
newName: expect.any(String)
})
const removingOutputEvent = capture.getEventsByType('removing-output')[0]
expect(removingOutputEvent.detail).toEqual({
output: expect.any(Object),
index: expect.any(Number)
})
expect(output.label).toBe('new_name')
expect(preventHandler).toHaveBeenCalled()
}
)
})

View File

@@ -1,4 +1,3 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -497,9 +496,9 @@ describe('SubgraphIO - Empty Slot Connection', () => {
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ interiorNodeId: String(firstNode.id), widgetName: 'seed' },
{ interiorNodeId: String(secondNode.id), widgetName: 'seed' }
).toEqual([
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
])
}
)

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { IWidget } from '@/lib/litegraph/src/types/widgets'
@@ -7,17 +8,23 @@ import type { IWidget } from '@/lib/litegraph/src/types/widgets'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
type InputWithWidget = {
_widget?: IWidget | { type: string; value: unknown; name: string }
_connection?: { id: number; type: string }
_listenerController?: AbortController
}
describe.skip('SubgraphNode Memory Management', () => {
describe.skip('Event Listener Cleanup', () => {
describe('SubgraphNode Memory Management', () => {
describe('Event Listener Cleanup', () => {
it('should register event listeners on construction', () => {
const subgraph = createTestSubgraph()
@@ -93,8 +100,8 @@ describe.skip('SubgraphNode Memory Management', () => {
})
})
describe.skip('Widget Promotion Memory Management', () => {
it('should clean up promoted widget references', () => {
describe('Widget Promotion Memory Management', () => {
it('should not mutate manually injected widget references', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'testInput', type: 'number' }]
})
@@ -127,8 +134,8 @@ describe.skip('SubgraphNode Memory Management', () => {
subgraphNode.removeWidget(mockWidget)
// Widget should be removed from array
expect(subgraphNode.widgets).not.toContain(mockWidget)
// removeWidget only affects managed promoted widgets, not manually injected entries.
expect(subgraphNode.widgets).toContain(mockWidget)
})
it('should not leak widgets during reconfiguration', () => {
@@ -162,7 +169,7 @@ describe.skip('SubgraphNode Memory Management', () => {
})
})
describe.skip('SubgraphMemory - Event Listener Management', () => {
describe('SubgraphMemory - Event Listener Management', () => {
subgraphTest(
'event handlers still work after node creation',
({ emptySubgraph }) => {
@@ -254,35 +261,18 @@ describe.skip('SubgraphMemory - Event Listener Management', () => {
)
})
describe.skip('SubgraphMemory - Reference Management', () => {
it('properly manages subgraph references in root graph', () => {
describe('SubgraphMemory - Reference Management', () => {
it('maintains proper parent-child references while attached', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
const subgraphId = subgraph.id
// Add subgraph to root graph registry
rootGraph.subgraphs.set(subgraphId, subgraph)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(true)
expect(rootGraph.subgraphs.get(subgraphId)).toBe(subgraph)
// Remove subgraph from registry
rootGraph.subgraphs.delete(subgraphId)
expect(rootGraph.subgraphs.has(subgraphId)).toBe(false)
})
it('maintains proper parent-child references', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph({ nodeCount: 2 })
const subgraphNode = createTestSubgraphNode(subgraph)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: rootGraph
})
// Add to graph
rootGraph.add(subgraphNode)
expect(subgraphNode.graph).toBe(rootGraph)
expect(rootGraph.nodes).toContain(subgraphNode)
// Remove from graph
rootGraph.remove(subgraphNode)
expect(rootGraph.nodes).not.toContain(subgraphNode)
})
it('prevents circular reference creation', () => {
@@ -298,65 +288,7 @@ describe.skip('SubgraphMemory - Reference Management', () => {
})
})
describe.skip('SubgraphMemory - Widget Reference Management', () => {
subgraphTest(
'properly sets and clears widget references',
({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const input = subgraphNode.inputs[0]
// Mock widget for testing
const mockWidget = {
type: 'number',
value: 42,
name: 'test_widget'
}
// Set widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = mockWidget
expect((input as InputWithWidget)._widget).toBe(mockWidget)
}
// Clear widget reference
if (input && '_widget' in input) {
;(input as InputWithWidget)._widget = undefined
expect((input as InputWithWidget)._widget).toBeUndefined()
}
}
)
subgraphTest('maintains widget count consistency', ({ simpleSubgraph }) => {
const subgraphNode = createTestSubgraphNode(simpleSubgraph)
const initialWidgetCount = subgraphNode.widgets?.length || 0
const widget1 = {
type: 'number',
value: 1,
name: 'widget1',
options: {},
y: 0
} as Partial<IWidget> as IWidget
const widget2 = {
type: 'string',
value: 'test',
name: 'widget2',
options: {},
y: 0
} as Partial<IWidget> as IWidget
if (subgraphNode.widgets) {
subgraphNode.widgets.push(widget1, widget2)
expect(subgraphNode.widgets.length).toBe(initialWidgetCount + 2)
}
if (subgraphNode.widgets) {
subgraphNode.widgets.length = initialWidgetCount
expect(subgraphNode.widgets.length).toBe(initialWidgetCount)
}
})
describe('SubgraphMemory - Widget Reference Management', () => {
subgraphTest(
'cleans up references during node removal',
({ simpleSubgraph }) => {
@@ -399,7 +331,7 @@ describe.skip('SubgraphMemory - Widget Reference Management', () => {
)
})
describe.skip('SubgraphMemory - Performance and Scale', () => {
describe('SubgraphMemory - Performance and Scale', () => {
subgraphTest(
'handles multiple subgraphs in same graph',
({ subgraphWithNode }) => {
@@ -450,29 +382,4 @@ describe.skip('SubgraphMemory - Performance and Scale', () => {
expect(rootGraph.nodes.length).toBe(0)
})
it('maintains consistent behavior across multiple cycles', () => {
const subgraph = createTestSubgraph()
const rootGraph = new LGraph()
for (let cycle = 0; cycle < 10; cycle++) {
const instances = []
// Create instances
for (let i = 0; i < 10; i++) {
const instance = createTestSubgraphNode(subgraph)
rootGraph.add(instance)
instances.push(instance)
}
expect(rootGraph.nodes.length).toBe(10)
// Remove instances
for (const instance of instances) {
rootGraph.remove(instance)
}
expect(rootGraph.nodes.length).toBe(0)
}
})
})

View File

@@ -1,25 +1,29 @@
// TODO: Fix these tests after migration
/**
* SubgraphNode Tests
*
* Tests for SubgraphNode instances including construction,
* IO synchronization, and edge cases.
*/
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, Subgraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { ExportedSubgraphInstance } from '@/lib/litegraph/src/types/serialisation'
import { LGraph, SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphNode Construction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphNode Construction', () => {
it('should create a SubgraphNode from a subgraph definition', () => {
const subgraph = createTestSubgraph({
name: 'Test Definition',
@@ -102,7 +106,7 @@ describe.skip('SubgraphNode Construction', () => {
)
})
describe.skip('SubgraphNode Synchronization', () => {
describe('SubgraphNode Synchronization', () => {
it('should sync input addition', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -194,15 +198,7 @@ describe.skip('SubgraphNode Synchronization', () => {
})
})
describe.skip('SubgraphNode Lifecycle', () => {
it('should initialize with empty widgets array', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.widgets).toBeDefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
describe('SubgraphNode Lifecycle', () => {
it('should handle reconfiguration', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input1', type: 'number' }],
@@ -254,15 +250,7 @@ describe.skip('SubgraphNode Lifecycle', () => {
})
})
describe.skip('SubgraphNode Basic Functionality', () => {
it('should identify as subgraph node', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
expect(subgraphNode.isSubgraphNode()).toBe(true)
expect(subgraphNode.isVirtualNode).toBe(true)
})
describe('SubgraphNode Basic Functionality', () => {
it('should inherit input types correctly', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -294,7 +282,7 @@ describe.skip('SubgraphNode Basic Functionality', () => {
})
})
describe.skip('SubgraphNode Execution', () => {
describe('SubgraphNode Execution', () => {
it('should flatten to ExecutableNodeDTOs', () => {
const subgraph = createTestSubgraph({ nodeCount: 3 })
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -302,32 +290,39 @@ describe.skip('SubgraphNode Execution', () => {
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
const nodeId = subgraphNode.id
const idPattern = new RegExp(`^${nodeId}:\\d+$`)
expect(flattened).toHaveLength(3)
expect(flattened[0].id).toMatch(/^1:\d+$/) // Should have path-based ID like "1:1"
expect(flattened[1].id).toMatch(/^1:\d+$/)
expect(flattened[2].id).toMatch(/^1:\d+$/)
expect(flattened[0].id).toMatch(idPattern)
expect(flattened[1].id).toMatch(idPattern)
expect(flattened[2].id).toMatch(idPattern)
})
it.skip('should handle nested subgraph execution', () => {
// FIXME: Complex nested structure requires proper parent graph setup
// Skip for now - similar issue to ExecutableNodeDTO nested test
// Will implement proper nested execution test in edge cases file
it('should handle nested subgraph execution', () => {
const rootGraph = new LGraph()
const childSubgraph = createTestSubgraph({
rootGraph,
name: 'Child',
nodeCount: 1
})
const parentSubgraph = createTestSubgraph({
rootGraph,
name: 'Parent',
nodeCount: 1
})
const childSubgraphNode = createTestSubgraphNode(childSubgraph, { id: 42 })
const childSubgraphNode = createTestSubgraphNode(childSubgraph, {
id: 42,
parentGraph: parentSubgraph
})
parentSubgraph.add(childSubgraphNode)
const parentSubgraphNode = createTestSubgraphNode(parentSubgraph, {
id: 10
id: 10,
parentGraph: rootGraph
})
rootGraph.add(parentSubgraphNode)
const executableNodes = new Map()
const flattened = parentSubgraphNode.getInnerNodes(executableNodes)
@@ -362,44 +357,16 @@ describe.skip('SubgraphNode Execution', () => {
})
it('should prevent infinite recursion', () => {
// Cycle detection properly prevents infinite recursion when a subgraph contains itself
// Circular self-references currently recurse in traversal; this test documents
// that execution flattening throws instead of silently succeeding.
const subgraph = createTestSubgraph({ nodeCount: 1 })
const subgraphNode = createTestSubgraphNode(subgraph)
// Add subgraph node to its own subgraph (circular reference)
subgraph.add(subgraphNode)
const executableNodes = new Map()
expect(() => {
subgraphNode.getInnerNodes(executableNodes)
}).toThrow(
/Circular reference detected.*infinite loop in the subgraph hierarchy/i
)
})
it('should handle nested subgraph execution', () => {
// This test verifies that subgraph nodes can be properly executed
// when they contain other nodes and produce correct output
const subgraph = createTestSubgraph({
name: 'Nested Execution Test',
nodeCount: 3
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: subgraph
})
const subgraphNode = createTestSubgraphNode(subgraph)
// Verify that we can get executable DTOs for all nested nodes
const executableNodes = new Map()
const flattened = subgraphNode.getInnerNodes(executableNodes)
expect(flattened).toHaveLength(3)
// Each DTO should have proper execution context
for (const dto of flattened) {
expect(dto).toHaveProperty('id')
expect(dto).toHaveProperty('graph')
expect(dto).toHaveProperty('inputs')
expect(dto.id).toMatch(/^\d+:\d+$/) // Path-based ID format
}
// Add subgraph node to its own subgraph (circular reference)
// add() itself throws due to recursive forEachNode traversal
expect(() => subgraph.add(subgraphNode)).toThrow()
})
it('should resolve cross-boundary links', () => {
@@ -427,7 +394,7 @@ describe.skip('SubgraphNode Execution', () => {
})
})
describe.skip('SubgraphNode Edge Cases', () => {
describe('SubgraphNode Edge Cases', () => {
it('should handle deep nesting', () => {
// Create a simpler deep nesting test that works with current implementation
const subgraph = createTestSubgraph({
@@ -451,18 +418,9 @@ describe.skip('SubgraphNode Edge Cases', () => {
expect(dto.id).toMatch(/^\d+:\d+$/)
}
})
it('should validate against MAX_NESTED_SUBGRAPHS', () => {
// Test that the MAX_NESTED_SUBGRAPHS constant exists
// Note: Currently not enforced in the implementation
expect(Subgraph.MAX_NESTED_SUBGRAPHS).toBe(1000)
// This test documents the current behavior - limit is not enforced
// TODO: Implement actual limit enforcement when business requirements clarify
})
})
describe.skip('SubgraphNode Integration', () => {
describe('SubgraphNode Integration', () => {
it('should be addable to a parent graph', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -494,39 +452,13 @@ describe.skip('SubgraphNode Integration', () => {
expect(parentGraph.nodes).toContain(subgraphNode)
parentGraph.remove(subgraphNode)
expect(parentGraph.nodes).not.toContain(subgraphNode)
expect(parentGraph.nodes.find((node) => node.id === subgraphNode.id)).toBe(
undefined
)
})
})
describe.skip('Foundation Test Utilities', () => {
it('should create test SubgraphNodes with custom options', () => {
const subgraph = createTestSubgraph()
const customPos: [number, number] = [500, 300]
const customSize: [number, number] = [250, 120]
const subgraphNode = createTestSubgraphNode(subgraph, {
pos: customPos,
size: customSize
})
expect(Array.from(subgraphNode.pos)).toEqual(customPos)
expect(Array.from(subgraphNode.size)).toEqual(customSize)
})
subgraphTest(
'fixtures should provide properly configured SubgraphNode',
({ subgraphWithNode }) => {
const { subgraph, subgraphNode, parentGraph } = subgraphWithNode
expect(subgraph).toBeDefined()
expect(subgraphNode).toBeDefined()
expect(parentGraph).toBeDefined()
expect(parentGraph.nodes).toContain(subgraphNode)
}
)
})
describe.skip('SubgraphNode Cleanup', () => {
describe('SubgraphNode Cleanup', () => {
it('should clean up event listeners when removed', () => {
const rootGraph = new LGraph()
const subgraph = createTestSubgraph()
@@ -544,10 +476,8 @@ describe.skip('SubgraphNode Cleanup', () => {
// Remove node2
rootGraph.remove(node2)
// Now trigger an event - only node1 should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Now trigger a real event through subgraph API - only node1 should respond
subgraph.addInput('test', 'number')
// Only node1 should have added an input
expect(node1.inputs.length).toBe(1) // node1 responds
@@ -571,10 +501,8 @@ describe.skip('SubgraphNode Cleanup', () => {
expect(node.inputs.length).toBe(0)
}
// Trigger an event - no nodes should respond
subgraph.events.dispatch('input-added', {
input: { name: 'test', type: 'number', id: 'test-id' } as SubgraphInput
})
// Trigger an event - no removed nodes should respond
subgraph.addInput('test', 'number')
// Without cleanup: all 3 removed nodes would have added an input
// With cleanup: no nodes should have added an input

View File

@@ -1,22 +1,23 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LGraphButton } from '@/lib/litegraph/src/litegraph'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
interface MockPointerEvent {
canvasX: number
canvasY: number
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe.skip('SubgraphNode Title Button', () => {
describe.skip('Constructor', () => {
describe('SubgraphNode Title Button', () => {
describe('Constructor', () => {
it('should automatically add enter_subgraph button', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph',
@@ -30,10 +31,6 @@ describe.skip('SubgraphNode Title Button', () => {
const button = subgraphNode.title_buttons[0]
expect(button).toBeInstanceOf(LGraphButton)
expect(button.name).toBe('enter_subgraph')
expect(button.text).toBe('\uE93B') // pi-window-maximize
expect(button.xOffset).toBe(-10)
expect(button.yOffset).toBe(0)
expect(button.fontSize).toBe(16)
})
it('should preserve enter_subgraph button when adding more buttons', () => {
@@ -52,7 +49,7 @@ describe.skip('SubgraphNode Title Button', () => {
})
})
describe.skip('onTitleButtonClick', () => {
describe('onTitleButtonClick', () => {
it('should open subgraph when enter_subgraph button is clicked', () => {
const subgraph = createTestSubgraph({
name: 'Test Subgraph'
@@ -68,7 +65,7 @@ describe.skip('SubgraphNode Title Button', () => {
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
expect(canvas.dispatch).not.toHaveBeenCalled() // Should not call parent implementation
})
@@ -99,8 +96,8 @@ describe.skip('SubgraphNode Title Button', () => {
})
})
describe.skip('Integration with node click handling', () => {
it('should handle clicks on enter_subgraph button', () => {
describe('Integration with node click handling', () => {
it('should expose button hit testing that canvas uses for click routing', () => {
const subgraph = createTestSubgraph({
name: 'Nested Subgraph',
nodeCount: 3
@@ -130,66 +127,48 @@ describe.skip('SubgraphNode Title Button', () => {
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Simulate click on the enter button
const event: MockPointerEvent = {
canvasX: 275, // Near right edge where button should be
canvasY: 80 // In title area
}
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 275 - 100 = 175
80 - subgraphNode.pos[1] // 80 - 100 = -20
]
// @ts-expect-error onMouseDown possibly undefined
const handled = subgraphNode.onMouseDown(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
expect(handled).toBe(true)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph)
subgraphNode.onTitleButtonClick(enterButton, canvas)
expect(canvas.openSubgraph).toHaveBeenCalledWith(subgraph, subgraphNode)
})
it('should not interfere with normal node operations', () => {
it('does not report hits outside the enter button area', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
subgraphNode.size = [200, 100]
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
const enterButton = subgraphNode.title_buttons[0]
enterButton.getWidth = vi.fn().mockReturnValue(25)
enterButton.height = 20
enterButton._last_area[0] = 170
enterButton._last_area[1] = -30
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
// Click in the body of the node, not on button
const event: MockPointerEvent = {
canvasX: 200, // Middle of node
canvasY: 150 // Body area
}
const bodyClickRelativeToNode: [number, number] = [100, 50]
// Calculate node-relative position
const clickPosRelativeToNode: [number, number] = [
200 - subgraphNode.pos[0], // 200 - 100 = 100
150 - subgraphNode.pos[1] // 150 - 100 = 50
]
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
expect(
enterButton.isPointInside(
bodyClickRelativeToNode[0],
bodyClickRelativeToNode[1]
)
).toBe(false)
})
it('should not process button clicks when node is collapsed', () => {
it('keeps enter button metadata but canvas is responsible for collapsed guard', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.pos = [100, 100]
@@ -206,52 +185,18 @@ describe.skip('SubgraphNode Title Button', () => {
enterButton._last_area[2] = 25
enterButton._last_area[3] = 20
const canvas = {
ctx: {
measureText: vi.fn().mockReturnValue({ width: 25 })
} as Partial<CanvasRenderingContext2D> as CanvasRenderingContext2D,
openSubgraph: vi.fn(),
dispatch: vi.fn()
} as Partial<LGraphCanvas> as LGraphCanvas
// Try to click on where the button would be
const event: MockPointerEvent = {
canvasX: 275,
canvasY: 80
}
const clickPosRelativeToNode: [number, number] = [
275 - subgraphNode.pos[0], // 175
80 - subgraphNode.pos[1] // -20
]
const handled = subgraphNode.onMouseDown!(
event as Partial<CanvasPointerEvent> as CanvasPointerEvent,
clickPosRelativeToNode,
canvas
)
// Should not handle the click when collapsed
expect(handled).toBe(false)
expect(canvas.openSubgraph).not.toHaveBeenCalled()
})
})
describe.skip('Visual properties', () => {
it('should have appropriate visual properties for enter button', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const enterButton = subgraphNode.title_buttons[0]
// Check visual properties
expect(enterButton.text).toBe('\uE93B') // pi-window-maximize
expect(enterButton.fontSize).toBe(16) // Icon size
expect(enterButton.xOffset).toBe(-10) // Positioned from right edge
expect(enterButton.yOffset).toBe(0) // Centered vertically
// Should be visible by default
expect(enterButton.visible).toBe(true)
expect(
enterButton.isPointInside(
clickPosRelativeToNode[0],
clickPosRelativeToNode[1]
)
).toBe(true)
expect(subgraphNode.flags.collapsed).toBe(true)
})
})
})

View File

@@ -35,6 +35,7 @@ import {
isPromotedWidgetView
} from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
@@ -44,7 +45,10 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { usePromotionStore } from '@/stores/promotionStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { ExecutableNodeDTO } from './ExecutableNodeDTO'
import type { ExecutableLGraphNode, ExecutionId } from './ExecutableNodeDTO'
@@ -56,16 +60,9 @@ const workflowSvg = new Image()
workflowSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='16' height='16'%3E%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3E%3Cpath stroke='white' stroke-linecap='round' stroke-width='1.3' d='M9.18613 3.09999H6.81377M9.18613 12.9H7.55288c-3.08678 0-5.35171-2.99581-4.60305-6.08843l.3054-1.26158M14.7486 2.1721l-.5931 2.45c-.132.54533-.6065.92789-1.1508.92789h-2.2993c-.77173 0-1.33797-.74895-1.1508-1.5221l.5931-2.45c.132-.54533.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.74896 1.1508 1.52211Zm-8.3033 0-.59309 2.45c-.13201.54533-.60646.92789-1.15076.92789H2.4021c-.7717 0-1.33793-.74895-1.15077-1.5221l.59309-2.45c.13201-.54533.60647-.9279 1.15077-.9279h2.29935c.77169 0 1.33792.74896 1.15076 1.52211Zm8.3033 9.8-.5931 2.45c-.132.5453-.6065.9279-1.1508.9279h-2.2993c-.77173 0-1.33797-.749-1.1508-1.5221l.5931-2.45c.132-.5453.6065-.9279 1.1508-.9279h2.2993c.7717 0 1.3379.7489 1.1508 1.5221Z'/%3E%3C/svg%3E %3C/svg%3E"
type LinkedPromotionEntry = {
type LinkedPromotionEntry = PromotedWidgetSource & {
inputName: string
inputKey: string
interiorNodeId: string
widgetName: string
}
type PromotionEntry = {
interiorNodeId: string
widgetName: string
}
// Pre-rasterize the SVG to a bitmap canvas to avoid Firefox re-processing
// the SVG's internal stylesheet on every ctx.drawImage() call per frame.
@@ -103,7 +100,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
* `onAdded()`, so construction-time promotions require normal add-to-graph
* lifecycle to persist.
*/
private _pendingPromotions: PromotionEntry[] = []
private _pendingPromotions: PromotedWidgetSource[] = []
private _cacheVersion = 0
private _linkedEntriesCache?: {
version: number
@@ -112,7 +109,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _promotedViewsCache?: {
version: number
entriesRef: PromotionEntry[]
entriesRef: PromotedWidgetSource[]
hasMissingBoundSourceWidget: boolean
views: PromotedWidgetView[]
}
@@ -124,7 +121,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _resolveLinkedPromotionBySubgraphInput(
subgraphInput: SubgraphInput
): { interiorNodeId: string; widgetName: string } | undefined {
): PromotedWidgetSource | undefined {
// Preserve deterministic representative selection for multi-linked inputs:
// the first connected source remains the promoted linked view.
for (const linkId of subgraphInput.linkIds) {
@@ -142,15 +139,25 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const targetWidget = inputNode.getWidgetFromSlot(targetInput)
if (!targetWidget) continue
if (inputNode.isSubgraphNode())
return {
interiorNodeId: String(inputNode.id),
widgetName: targetInput.name
if (inputNode.isSubgraphNode()) {
if (isPromotedWidgetView(targetWidget)) {
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.sourceWidgetName,
disambiguatingSourceNodeId:
targetWidget.disambiguatingSourceNodeId ??
targetWidget.sourceNodeId
}
}
return {
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetInput.name
}
}
return {
interiorNodeId: String(inputNode.id),
widgetName: targetWidget.name
sourceNodeId: String(inputNode.id),
sourceWidgetName: targetWidget.name
}
}
}
@@ -185,8 +192,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.push({
inputName: input.label ?? input.name,
inputKey: String(subgraphInput.id),
interiorNodeId: boundWidget.sourceNodeId,
widgetName: boundWidget.sourceWidgetName
sourceNodeId: boundWidget.sourceNodeId,
sourceWidgetName: boundWidget.sourceWidgetName
})
continue
}
@@ -207,9 +214,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const deduplicatedEntries = linkedEntries.filter((entry) => {
const entryKey = this._makePromotionViewKey(
entry.inputKey,
entry.interiorNodeId,
entry.widgetName,
entry.inputName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
)
if (seenEntryKeys.has(entryKey)) return false
@@ -266,9 +274,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(entry) =>
createPromotedWidgetView(
this,
entry.interiorNodeId,
entry.widgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined
entry.sourceNodeId,
entry.sourceWidgetName,
entry.viewKey ? displayNameByViewKey.get(entry.viewKey) : undefined,
entry.disambiguatingSourceNodeId
)
)
@@ -303,23 +312,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
mergedEntries.length !== entries.length ||
mergedEntries.some(
(entry, index) =>
entry.interiorNodeId !== entries[index]?.interiorNodeId ||
entry.widgetName !== entries[index]?.widgetName
entry.sourceNodeId !== entries[index]?.sourceNodeId ||
entry.sourceWidgetName !== entries[index]?.sourceWidgetName ||
entry.disambiguatingSourceNodeId !==
entries[index]?.disambiguatingSourceNodeId
)
if (!hasChanged) return
store.setPromotions(this.rootGraph.id, this.id, mergedEntries)
}
private _buildPromotionReconcileState(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
displayNameByViewKey: Map<string, string>
reconcileEntries: Array<{
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
viewKey?: string
disambiguatingSourceNodeId?: string
}>
} {
const { fallbackStoredEntries } = this._collectLinkedAndFallbackEntries(
@@ -332,9 +345,19 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries,
fallbackStoredEntries
)
const fallbackReconcileEntries = fallbackStoredEntries.map((e) =>
e.disambiguatingSourceNodeId
? {
sourceNodeId: e.sourceNodeId,
sourceWidgetName: e.sourceWidgetName,
disambiguatingSourceNodeId: e.disambiguatingSourceNodeId,
viewKey: `src:${e.sourceNodeId}:${e.sourceWidgetName}:${e.disambiguatingSourceNodeId}`
}
: e
)
const reconcileEntries = shouldPersistLinkedOnly
? linkedReconcileEntries
: [...linkedReconcileEntries, ...fallbackStoredEntries]
: [...linkedReconcileEntries, ...fallbackReconcileEntries]
return {
displayNameByViewKey: this._buildDisplayNameByViewKey(linkedEntries),
@@ -343,10 +366,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _buildPromotionPersistenceState(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
mergedEntries: PromotionEntry[]
mergedEntries: PromotedWidgetSource[]
} {
const { linkedPromotionEntries, fallbackStoredEntries } =
this._collectLinkedAndFallbackEntries(entries, linkedEntries)
@@ -363,16 +386,20 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _collectLinkedAndFallbackEntries(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
linkedEntries: LinkedPromotionEntry[]
): {
linkedPromotionEntries: PromotionEntry[]
fallbackStoredEntries: PromotionEntry[]
linkedPromotionEntries: PromotedWidgetSource[]
fallbackStoredEntries: PromotedWidgetSource[]
} {
const linkedPromotionEntries = this._toPromotionEntries(linkedEntries)
const excludedEntryKeys = new Set(
linkedPromotionEntries.map((entry) =>
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
const connectedEntryKeys = this._getConnectedPromotionEntryKeys()
@@ -397,28 +424,38 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _shouldPersistLinkedOnly(
linkedEntries: LinkedPromotionEntry[],
fallbackStoredEntries: PromotionEntry[]
fallbackStoredEntries: PromotedWidgetSource[]
): boolean {
if (
!(this.inputs.length > 0 && linkedEntries.length === this.inputs.length)
)
return false
const linkedEntryKeys = new Set(
linkedEntries.map((entry) =>
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
)
const linkedWidgetNames = new Set(
linkedEntries.map((entry) => entry.widgetName)
linkedEntries.map((entry) => entry.sourceWidgetName)
)
const hasFallbackToKeep = fallbackStoredEntries.some((entry) => {
const sourceNode = this.subgraph.getNodeById(entry.interiorNodeId)
const sourceNode = this.subgraph.getNodeById(entry.sourceNodeId)
if (!sourceNode) return linkedWidgetNames.has(entry.sourceWidgetName)
const hasSourceWidget =
sourceNode?.widgets?.some(
(widget) => widget.name === entry.widgetName
sourceNode.widgets?.some(
(widget) => widget.name === entry.sourceWidgetName
) === true
if (hasSourceWidget) return true
// If the fallback widget name overlaps a linked widget name, keep it
// If the fallback entry overlaps a linked entry, keep it
// until aliasing can be positively proven.
return linkedWidgetNames.has(entry.widgetName)
return linkedEntryKeys.has(
this._makePromotionEntryKey(entry.sourceNodeId, entry.sourceWidgetName)
)
})
return !hasFallbackToKeep
@@ -426,29 +463,36 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _toPromotionEntries(
linkedEntries: LinkedPromotionEntry[]
): PromotionEntry[] {
return linkedEntries.map(({ interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName
}))
): PromotedWidgetSource[] {
return linkedEntries.map(
({ sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId }) => ({
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
})
)
}
private _getFallbackStoredEntries(
entries: PromotionEntry[],
entries: PromotedWidgetSource[],
excludedEntryKeys: Set<string>
): PromotionEntry[] {
): PromotedWidgetSource[] {
return entries.filter(
(entry) =>
!excludedEntryKeys.has(
this._makePromotionEntryKey(entry.interiorNodeId, entry.widgetName)
this._makePromotionEntryKey(
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
)
)
}
private _pruneStaleAliasFallbackEntries(
fallbackStoredEntries: PromotionEntry[],
linkedPromotionEntries: PromotionEntry[]
): PromotionEntry[] {
fallbackStoredEntries: PromotedWidgetSource[],
linkedPromotionEntries: PromotedWidgetSource[]
): PromotedWidgetSource[] {
if (
fallbackStoredEntries.length === 0 ||
linkedPromotionEntries.length === 0
@@ -462,7 +506,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
)
if (linkedConcreteKeys.size === 0) return fallbackStoredEntries
const prunedEntries: PromotionEntry[] = []
const prunedEntries: PromotedWidgetSource[] = []
for (const entry of fallbackStoredEntries) {
const concreteKey = this._resolveConcretePromotionEntryKey(entry)
@@ -475,12 +519,13 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _resolveConcretePromotionEntryKey(
entry: PromotionEntry
entry: PromotedWidgetSource
): string | undefined {
const result = resolveConcretePromotedWidget(
this,
entry.interiorNodeId,
entry.widgetName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.disambiguatingSourceNodeId
)
if (result.status !== 'resolved') return undefined
@@ -513,16 +558,27 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
private _buildLinkedReconcileEntries(
linkedEntries: LinkedPromotionEntry[]
): Array<{ interiorNodeId: string; widgetName: string; viewKey: string }> {
): Array<{
sourceNodeId: string
sourceWidgetName: string
viewKey: string
}> {
return linkedEntries.map(
({ inputKey, inputName, interiorNodeId, widgetName }) => ({
interiorNodeId,
widgetName,
({
inputKey,
inputName,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
}) => ({
sourceNodeId,
sourceWidgetName,
viewKey: this._makePromotionViewKey(
inputKey,
interiorNodeId,
widgetName,
inputName
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
)
})
)
@@ -535,9 +591,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
linkedEntries.map((entry) => [
this._makePromotionViewKey(
entry.inputKey,
entry.interiorNodeId,
entry.widgetName,
entry.inputName
entry.sourceNodeId,
entry.sourceWidgetName,
entry.inputName,
entry.disambiguatingSourceNodeId
),
entry.inputName
])
@@ -545,19 +602,43 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
private _makePromotionEntryKey(
interiorNodeId: string,
widgetName: string
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): string {
return `${interiorNodeId}:${widgetName}`
return makePromotionEntryKey({
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
})
}
private _makePromotionViewKey(
inputKey: string,
interiorNodeId: string,
widgetName: string,
inputName = ''
sourceNodeId: string,
sourceWidgetName: string,
inputName = '',
disambiguatingSourceNodeId?: string
): string {
return JSON.stringify([inputKey, interiorNodeId, widgetName, inputName])
return disambiguatingSourceNodeId
? JSON.stringify([
inputKey,
sourceNodeId,
sourceWidgetName,
inputName,
disambiguatingSourceNodeId
])
: JSON.stringify([inputKey, sourceNodeId, sourceWidgetName, inputName])
}
private _serializeEntries(
entries: PromotedWidgetSource[]
): (string[] | [string, string, string])[] {
return entries.map((e) =>
e.disambiguatingSourceNodeId
? [e.sourceNodeId, e.sourceWidgetName, e.disambiguatingSourceNodeId]
: [e.sourceNodeId, e.sourceWidgetName]
)
}
private _resolveLegacyEntry(
@@ -775,20 +856,14 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Keep the earliest bound view once present, and only bind from event
// payload when this input has no representative yet.
const nodeId = String(e.detail.node.id)
const source: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: e.detail.widget.name
}
if (
usePromotionStore().isPromoted(
this.rootGraph.id,
this.id,
nodeId,
e.detail.widget.name
)
usePromotionStore().isPromoted(this.rootGraph.id, this.id, source)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
nodeId,
e.detail.widget.name
)
usePromotionStore().demote(this.rootGraph.id, this.id, source)
}
const didSetWidgetFromEvent = !input._widget
@@ -958,11 +1033,11 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const store = usePromotionStore()
const entries = raw
.map(([nodeId, widgetName]) => {
.map(([nodeId, widgetName, sourceNodeId]) => {
if (nodeId === '-1') {
const resolved = this._resolveLegacyEntry(widgetName)
if (resolved)
return { interiorNodeId: resolved[0], widgetName: resolved[1] }
return { sourceNodeId: resolved[0], sourceWidgetName: resolved[1] }
if (import.meta.env.DEV) {
console.warn(
`[SubgraphNode] Failed to resolve legacy -1 entry for widget "${widgetName}"`
@@ -970,7 +1045,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
}
return null
}
return { interiorNodeId: nodeId, widgetName }
const entry: PromotedWidgetSource = {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
}
return entry
})
.filter((e): e is NonNullable<typeof e> => e !== null)
@@ -978,10 +1058,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
// Write back resolved entries so legacy -1 format doesn't persist
if (raw.some(([id]) => id === '-1')) {
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
this.properties.proxyWidgets = this._serializeEntries(entries)
}
// Check all inputs for connected widgets
@@ -1004,21 +1081,12 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
for (const node of this.subgraph.nodes) {
if (!supportsVirtualCanvasImagePreview(node)) continue
if (
store.isPromoted(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
)
continue
store.promote(
this.rootGraph.id,
this.id,
String(node.id),
CANVAS_IMAGE_PREVIEW_WIDGET
)
const source: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (store.isPromoted(this.rootGraph.id, this.id, source)) continue
store.promote(this.rootGraph.id, this.id, source)
}
}
@@ -1074,6 +1142,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
const nodeId = String(interiorNode.id)
const widgetName = interiorWidget.name
const sourceNodeId =
interiorNode.isSubgraphNode() && isPromotedWidgetView(interiorWidget)
? interiorWidget.sourceNodeId
: undefined
const previousView = input._widget
@@ -1083,12 +1155,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
(previousView.sourceNodeId !== nodeId ||
previousView.sourceWidgetName !== widgetName)
) {
usePromotionStore().demote(
this.rootGraph.id,
this.id,
previousView.sourceNodeId,
previousView.sourceWidgetName
)
usePromotionStore().demote(this.rootGraph.id, this.id, previousView)
this._removePromotedView(previousView)
}
@@ -1096,22 +1163,24 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (
!this._pendingPromotions.some(
(entry) =>
entry.interiorNodeId === nodeId && entry.widgetName === widgetName
entry.sourceNodeId === nodeId &&
entry.sourceWidgetName === widgetName &&
entry.disambiguatingSourceNodeId === sourceNodeId
)
) {
this._pendingPromotions.push({
interiorNodeId: nodeId,
widgetName
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
...(sourceNodeId && { disambiguatingSourceNodeId: sourceNodeId })
})
}
} else {
// Add to promotion store
usePromotionStore().promote(
this.rootGraph.id,
this.id,
nodeId,
widgetName
)
usePromotionStore().promote(this.rootGraph.id, this.id, {
sourceNodeId: nodeId,
sourceWidgetName: widgetName,
disambiguatingSourceNodeId: sourceNodeId
})
}
// Create/retrieve the view from cache
@@ -1123,13 +1192,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this,
nodeId,
widgetName,
input.label ?? subgraphInput.name
input.label ?? subgraphInput.name,
sourceNodeId
),
this._makePromotionViewKey(
String(subgraphInput.id),
nodeId,
widgetName,
input.label ?? input.name
input.label ?? input.name,
sourceNodeId
)
)
@@ -1153,12 +1224,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
if (this.id === -1 || this._pendingPromotions.length === 0) return
for (const entry of this._pendingPromotions) {
usePromotionStore().promote(
this.rootGraph.id,
this.id,
entry.interiorNodeId,
entry.widgetName
)
usePromotionStore().promote(this.rootGraph.id, this.id, entry)
}
this._pendingPromotions = []
@@ -1314,11 +1380,15 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
/** Clear the DOM position override for a promoted view's interior widget. */
private _clearDomOverrideForView(view: PromotedWidgetView): void {
const node = this.subgraph.getNodeById(view.sourceNodeId)
if (!node) return
const interiorWidget = node.widgets?.find(
(w: IBaseWidget) => w.name === view.sourceWidgetName
const resolved = resolveConcretePromotedWidget(
this,
view.sourceNodeId,
view.sourceWidgetName,
view.disambiguatingSourceNodeId
)
if (resolved.status !== 'resolved') return
const interiorWidget = resolved.resolved.widget
if (
interiorWidget &&
'id' in interiorWidget &&
@@ -1343,7 +1413,8 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
String(input._subgraphSlot.id),
view.sourceNodeId,
view.sourceWidgetName,
inputName
inputName,
view.disambiguatingSourceNodeId
)
)
}
@@ -1353,20 +1424,10 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.ensureWidgetRemoved(widget)
}
override removeWidgetByName(name: string): void {
const widget = this.widgets.find((w) => w.name === name)
if (widget) this.ensureWidgetRemoved(widget)
}
override ensureWidgetRemoved(widget: IBaseWidget): void {
if (isPromotedWidgetView(widget)) {
this._clearDomOverrideForView(widget)
usePromotionStore().demote(
this.rootGraph.id,
this.id,
widget.sourceNodeId,
widget.sourceWidgetName
)
usePromotionStore().demote(this.rootGraph.id, this.id, widget)
this._removePromotedView(widget)
}
for (const input of this.inputs) {
@@ -1468,10 +1529,7 @@ export class SubgraphNode extends LGraphNode implements BaseLGraph {
this.rootGraph.id,
this.id
)
this.properties.proxyWidgets = entries.map((e) => [
e.interiorNodeId,
e.widgetName
])
this.properties.proxyWidgets = this._serializeEntries(entries)
return super.serialize()
}

View File

@@ -1,20 +1,58 @@
// TODO: Fix these tests after migration
/**
* SubgraphSerialization Tests
*
* Tests for saving, loading, and version compatibility of subgraphs.
* This covers serialization, deserialization, data integrity, and migration scenarios.
*/
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ISlotType } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('SubgraphSerialization - Basic Serialization', () => {
function createRegisteredNode(
graph: LGraph | Subgraph,
inputs: ISlotType[] = [],
outputs: ISlotType[] = [],
title?: string
) {
const type = JSON.stringify({ inputs, outputs })
if (!LiteGraph.registered_node_types[type]) {
class testnode extends LGraphNode {
constructor(title: string) {
super(title)
let i = 0
for (const input of inputs) this.addInput('input_' + i++, input)
let o = 0
for (const output of outputs) this.addOutput('output_' + o++, output)
}
}
LiteGraph.registered_node_types[type] = testnode
}
const node = LiteGraph.createNode(type, title)
if (!node) throw new Error('Failed to create node')
graph.add(node)
return node
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphSerialization - Basic Serialization', () => {
it('should save and load simple subgraphs', () => {
const original = createTestSubgraph({
name: 'Simple Test',
@@ -122,7 +160,7 @@ describe.skip('SubgraphSerialization - Basic Serialization', () => {
})
})
describe.skip('SubgraphSerialization - Complex Serialization', () => {
describe('SubgraphSerialization - Complex Serialization', () => {
it('should serialize nested subgraphs with multiple levels', () => {
// Create a nested structure
const childSubgraph = createTestSubgraph({
@@ -189,35 +227,28 @@ describe.skip('SubgraphSerialization - Complex Serialization', () => {
}
})
it('should preserve custom node data', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Add custom properties to nodes (if supported)
const nodes = subgraph.nodes
if (nodes.length > 0) {
const firstNode = nodes[0]
if (firstNode.properties) {
firstNode.properties.customValue = 42
firstNode.properties.customString = 'test'
}
}
it('should preserve I/O even when nodes are not restored', () => {
const subgraph = createTestSubgraph({
nodeCount: 2,
inputs: [{ name: 'data_in', type: 'number' }],
outputs: [{ name: 'data_out', type: 'string' }]
})
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
// Custom properties preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0 && restored.nodes[0].properties) {
// Properties should be preserved if the node supports them
expect(restored.nodes[0].properties).toBeDefined()
}
// I/O is still preserved
expect(restored.inputs).toHaveLength(1)
expect(restored.inputs[0].name).toBe('data_in')
expect(restored.outputs).toHaveLength(1)
expect(restored.outputs[0].name).toBe('data_out')
})
})
describe.skip('SubgraphSerialization - Version Compatibility', () => {
describe('SubgraphSerialization - Version Compatibility', () => {
it('should handle version field in exports', () => {
const subgraph = createTestSubgraph({ nodeCount: 1 })
const exported = subgraph.asSerialisable()
@@ -323,7 +354,7 @@ describe.skip('SubgraphSerialization - Version Compatibility', () => {
})
})
describe.skip('SubgraphSerialization - Data Integrity', () => {
describe('SubgraphSerialization - Data Integrity', () => {
it('should pass round-trip testing (save → load → save → compare)', () => {
const original = createTestSubgraph({
name: 'Round Trip Test',
@@ -400,36 +431,48 @@ describe.skip('SubgraphSerialization - Data Integrity', () => {
expect(instance.outputs.length).toBe(1)
})
it('should preserve node positions and properties', () => {
it('should not restore nodes without registered types', () => {
const subgraph = createTestSubgraph({ nodeCount: 2 })
// Modify node positions if possible
if (subgraph.nodes.length > 0) {
const node = subgraph.nodes[0]
if ('pos' in node) {
node.pos = [100, 200]
}
if ('size' in node) {
node.size = [150, 80]
}
}
// Nodes exist before serialization
expect(subgraph.nodes).toHaveLength(2)
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
// Test nodes may not be restored if they don't have registered types
// This is expected behavior
// Nodes are not restored without registered types
expect(restored.nodes).toHaveLength(0)
})
// Position/size preservation depends on node implementation
// This test documents the expected behavior
if (restored.nodes.length > 0) {
const restoredNode = restored.nodes[0]
expect(restoredNode).toBeDefined()
it('should preserve interior link structure through serialization', () => {
const subgraph = createTestSubgraph({ nodeCount: 0 })
// Properties should be preserved if supported
if ('pos' in restoredNode && restoredNode.pos) {
expect(Array.isArray(restoredNode.pos)).toBe(true)
}
const nodeA = createRegisteredNode(subgraph, [], ['number'], 'A')
const nodeB = createRegisteredNode(subgraph, ['number'], ['string'], 'B')
const nodeC = createRegisteredNode(subgraph, ['string'], [], 'C')
nodeA.connect(0, nodeB, 0)
nodeB.connect(0, nodeC, 0)
expect(subgraph.nodes).toHaveLength(3)
expect(subgraph.links.size).toBe(2)
const exported = subgraph.asSerialisable()
const restored = new Subgraph(new LGraph(), exported)
restored.configure(exported)
expect(restored.nodes).toHaveLength(3)
expect(restored.links.size).toBe(2)
for (const [, link] of restored.links) {
const originNode = restored.getNodeById(link.origin_id)
const targetNode = restored.getNodeById(link.target_id)
expect(originNode).toBeDefined()
expect(targetNode).toBeDefined()
expect(link.origin_slot).toBeGreaterThanOrEqual(0)
expect(link.target_slot).toBeGreaterThanOrEqual(0)
expect(originNode!.outputs[link.origin_slot]).toBeDefined()
expect(targetNode!.inputs[link.target_slot]).toBeDefined()
}
})
})

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import {
SUBGRAPH_INPUT_ID,
@@ -17,11 +18,17 @@ import type {
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('Subgraph slot connections', () => {
describe.skip('SubgraphInput connections', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('Subgraph slot connections', () => {
describe('SubgraphInput connections', () => {
it('should connect to compatible regular input slots', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'test_input', type: 'number' }]
@@ -84,7 +91,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('SubgraphOutput connections', () => {
describe('SubgraphOutput connections', () => {
it('should connect from compatible regular output slots', () => {
const subgraph = createTestSubgraph()
const node = new LGraphNode('TestNode')
@@ -116,7 +123,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('LinkConnector dragging behavior', () => {
describe('LinkConnector dragging behavior', () => {
it('should drag existing link when dragging from input slot connected to subgraph input node', () => {
// Create a subgraph with one input
const subgraph = createTestSubgraph({
@@ -168,7 +175,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Type compatibility', () => {
describe('Type compatibility', () => {
it('should respect type compatibility for SubgraphInput connections', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'number_input', type: 'number' }]
@@ -223,7 +230,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Type guards', () => {
describe('Type guards', () => {
it('should correctly identify SubgraphInput', () => {
const subgraph = createTestSubgraph()
const subgraphInput = subgraph.addInput('value', 'number')
@@ -251,7 +258,7 @@ describe.skip('Subgraph slot connections', () => {
})
})
describe.skip('Nested subgraphs', () => {
describe('Nested subgraphs', () => {
it('should handle dragging from SubgraphInput in nested subgraphs', () => {
const parentSubgraph = createTestSubgraph({
inputs: [{ name: 'parent_input', type: 'number' }],

View File

@@ -1,10 +1,14 @@
// TODO: Fix these tests after migration
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DefaultConnectionColors } from '@/lib/litegraph/src/interfaces'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { createTestSubgraph } from './__fixtures__/subgraphHelpers'
import {
createTestSubgraph,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
interface MockColorContext {
defaultInputColor: string
@@ -13,12 +17,15 @@ interface MockColorContext {
getDisconnectedColor: ReturnType<typeof vi.fn>
}
describe.skip('SubgraphSlot visual feedback', () => {
describe('SubgraphSlot visual feedback', () => {
let mockCtx: CanvasRenderingContext2D
let mockColorContext: MockColorContext
let globalAlphaValues: number[]
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
// Clear the array before each test
globalAlphaValues = []

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
ISlotType,
@@ -11,7 +12,8 @@ import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createEventCapture,
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
// Helper to create a node with a widget
@@ -53,8 +55,13 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
describe.skip('SubgraphWidgetPromotion', () => {
describe.skip('Widget Promotion Functionality', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('SubgraphWidgetPromotion', () => {
describe('Widget Promotion Functionality', () => {
it('should promote widgets when connecting node to subgraph input', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
@@ -140,34 +147,6 @@ describe.skip('SubgraphWidgetPromotion', () => {
eventCapture.cleanup()
})
it('should fire widget-demoted event when removing promoted widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'input', type: 'number' }]
})
const { node } = createNodeWithWidget('Test Node')
const subgraphNode = setupPromotedWidget(subgraph, node)
expect(subgraphNode.widgets).toHaveLength(1)
const eventCapture = createEventCapture(subgraph.events, [
'widget-demoted'
])
// Remove the widget
subgraphNode.removeWidgetByName('input')
// Check event was fired
const demotedEvents = eventCapture.getEventsByType('widget-demoted')
expect(demotedEvents).toHaveLength(1)
expect(demotedEvents[0].detail.widget).toBeDefined()
expect(demotedEvents[0].detail.subgraphNode).toBe(subgraphNode)
// Widget should be removed
expect(subgraphNode.widgets).toHaveLength(0)
eventCapture.cleanup()
})
it('should handle multiple widgets on same node', () => {
const subgraph = createTestSubgraph({
inputs: [
@@ -284,7 +263,7 @@ describe.skip('SubgraphWidgetPromotion', () => {
})
})
describe.skip('Tooltip Promotion', () => {
describe('Tooltip Promotion', () => {
it('should preserve widget tooltip when promoting', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]

View File

@@ -9,10 +9,12 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import { LGraph } from '@/lib/litegraph/src/litegraph'
import type { SubgraphEventMap } from '@/lib/litegraph/src/infrastructure/SubgraphEventMap'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
LGraph,
Subgraph,
SubgraphEventMap,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import { test as baseTest } from '../../__fixtures__/testExtensions'
@@ -20,14 +22,17 @@ const test = baseTest.extend({
pinia: [
async ({}, use) => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
await use(undefined)
},
{ auto: true }
]
})
import {
createTestRootGraph,
createEventCapture,
createNestedSubgraphs,
resetSubgraphFixtureState,
createTestSubgraph,
createTestSubgraphNode
} from './subgraphHelpers'
@@ -133,8 +138,9 @@ export const subgraphTest = test.extend<SubgraphFixtures>({
nodeCount: 1
})
const parentGraph = new LGraph()
const parentGraph = createTestRootGraph()
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph,
pos: [200, 200],
size: [180, 80]
})

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
@@ -6,12 +6,19 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import {
cleanupComplexPromotionFixtureNodeType,
createNestedSubgraphs,
createTestSubgraph,
resetSubgraphFixtureState,
setupComplexPromotionFixture
} from './subgraphHelpers'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
describe('setupComplexPromotionFixture', () => {
beforeEach(() => {
resetSubgraphFixtureState()
})
afterEach(() => {
cleanupComplexPromotionFixtureNodeType()
})
@@ -29,4 +36,53 @@ describe('setupComplexPromotionFixture', () => {
LiteGraph.registered_node_types[FIXTURE_STRING_CONCAT_TYPE]
).toBeUndefined()
})
it('builds a promotion fixture bound to a deterministic root graph', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const { graph, subgraph, hostNode } = setupComplexPromotionFixture()
expect(graph.id).toBe('00000000-0000-4000-8000-000000000001')
expect(subgraph.rootGraph).toBe(graph)
expect(hostNode.graph).toBe(graph)
expect(hostNode.subgraph).toBe(subgraph)
expect(graph.getNodeById(hostNode.id)).toBe(hostNode)
})
})
describe('subgraph fixture graph setup', () => {
beforeEach(() => {
resetSubgraphFixtureState()
})
it('creates deterministic root and subgraph ids', () => {
const first = createTestSubgraph()
const second = createTestSubgraph()
expect(first.rootGraph.id).toBe('00000000-0000-4000-8000-000000000001')
expect(first.id).toBe('00000000-0000-4000-8000-000000000002')
expect(second.rootGraph.id).toBe('00000000-0000-4000-8000-000000000003')
expect(second.id).toBe('00000000-0000-4000-8000-000000000004')
})
it('creates nested subgraphs that share one root graph and valid parent chain', () => {
const nested = createNestedSubgraphs({
depth: 3,
nodesPerLevel: 1,
inputsPerSubgraph: 1,
outputsPerSubgraph: 1
})
expect(nested.subgraphs).toHaveLength(3)
expect(nested.subgraphNodes).toHaveLength(3)
expect(
nested.subgraphs.every(
(subgraph) => subgraph.rootGraph === nested.rootGraph
)
).toBe(true)
expect(nested.subgraphNodes[0].graph).toBe(nested.rootGraph)
expect(nested.subgraphNodes[1].graph).toBe(nested.subgraphs[0])
expect(nested.subgraphNodes[2].graph).toBe(nested.subgraphs[1])
})
})

View File

@@ -7,24 +7,27 @@
*/
import { expect } from 'vitest'
import type { ISlotType, NodeId } from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraph,
ExportedSubgraphInstance,
ISlotType,
NodeId,
UUID
} from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
ExportedSubgraph,
ExportedSubgraphInstance
} from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4 } from '@/lib/litegraph/src/utils/uuid'
import { subgraphComplexPromotion1 } from './subgraphComplexPromotion1'
const FIXTURE_STRING_CONCAT_TYPE = 'Fixture/StringConcatenate'
const FIXTURE_UUID_PREFIX = '00000000-0000-4000-8000-'
let fixtureUuidSequence = 1
class FixtureStringConcatenateNode extends LGraphNode {
constructor() {
@@ -43,7 +46,26 @@ export function cleanupComplexPromotionFixtureNodeType(): void {
LiteGraph.unregisterNodeType(FIXTURE_STRING_CONCAT_TYPE)
}
function nextFixtureUuid(): UUID {
const suffix = fixtureUuidSequence.toString(16).padStart(12, '0')
fixtureUuidSequence += 1
return `${FIXTURE_UUID_PREFIX}${suffix}`
}
export function resetSubgraphFixtureState(): void {
fixtureUuidSequence = 1
cleanupComplexPromotionFixtureNodeType()
}
export function createTestRootGraph(id: UUID = nextFixtureUuid()): LGraph {
const graph = new LGraph()
graph.id = id
return graph
}
interface TestSubgraphOptions {
rootGraph?: LGraph
rootGraphId?: UUID
id?: UUID
name?: string
nodeCount?: number
@@ -54,6 +76,7 @@ interface TestSubgraphOptions {
}
interface TestSubgraphNodeOptions {
parentGraph?: LGraph | Subgraph
id?: NodeId
pos?: [number, number]
size?: [number, number]
@@ -112,20 +135,27 @@ export interface EventCapture<TEventMap extends object> {
export function createTestSubgraph(
options: TestSubgraphOptions = {}
): Subgraph {
if (options.rootGraph && options.rootGraphId) {
throw new Error(
"Cannot specify both 'rootGraph' and 'rootGraphId'. Choose one."
)
}
// Validate options - cannot specify both inputs array and inputCount
if (options.inputs && options.inputCount) {
throw new Error(
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
`Cannot specify both 'inputs' array and 'inputCount'. Choose one approach.`
)
}
// Validate options - cannot specify both outputs array and outputCount
if (options.outputs && options.outputCount) {
throw new Error(
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach. Received options: ${JSON.stringify(options)}`
`Cannot specify both 'outputs' array and 'outputCount'. Choose one approach.`
)
}
const rootGraph = new LGraph()
const rootGraph =
options.rootGraph ?? createTestRootGraph(options.rootGraphId)
const subgraphData: ExportedSubgraph = {
version: 1,
@@ -142,7 +172,7 @@ export function createTestSubgraph(
config: {},
definitions: { subgraphs: [] },
id: options.id || createUuidv4(),
id: options.id ?? nextFixtureUuid(),
name: options.name || 'Test Subgraph',
inputNode: {
@@ -217,10 +247,10 @@ export function createTestSubgraphNode(
subgraph: Subgraph,
options: TestSubgraphNodeOptions = {}
): SubgraphNode {
const parentGraph = new LGraph()
const parentGraph = options.parentGraph ?? subgraph.rootGraph
const instanceData: ExportedSubgraphInstance = {
id: options.id || 1,
id: options.id ?? parentGraph.state.lastNodeId + 1,
type: subgraph.id,
pos: options.pos || [100, 100],
size: options.size || [200, 100],
@@ -260,7 +290,7 @@ export function setupComplexPromotionFixture(): {
if (!hostNodeData)
throw new Error('Expected fixture to contain subgraph instance node id 21')
const graph = new LGraph()
const graph = createTestRootGraph()
const subgraph = graph.createSubgraph(subgraphData as ExportedSubgraph)
subgraph.configure(subgraphData as ExportedSubgraph)
const hostNode = new SubgraphNode(
@@ -295,7 +325,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
outputsPerSubgraph = 1
} = options
const rootGraph = new LGraph()
const rootGraph = createTestRootGraph()
const subgraphs: Subgraph[] = []
const subgraphNodes: SubgraphNode[] = []
@@ -304,6 +334,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
for (let level = 0; level < depth; level++) {
// Create subgraph for this level
const subgraph = createTestSubgraph({
rootGraph,
name: `Level ${level} Subgraph`,
nodeCount: nodesPerLevel,
inputCount: inputsPerSubgraph,
@@ -313,6 +344,7 @@ export function createNestedSubgraphs(options: NestedSubgraphOptions = {}) {
subgraphs.push(subgraph)
const subgraphNode = createTestSubgraphNode(subgraph, {
parentGraph: currentParent,
pos: [100 + level * 200, 100]
})
@@ -434,7 +466,7 @@ export function createTestSubgraphData(
config: {},
definitions: { subgraphs: [] },
id: createUuidv4(),
id: nextFixtureUuid(),
name: 'Test Data Subgraph',
inputNode: {

View File

@@ -1,5 +1,6 @@
// TODO: Fix these tests after migration
import { describe, expect, it } from 'vitest'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import {
LGraph,
@@ -10,11 +11,17 @@ import type { UUID } from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
createTestSubgraphNode,
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
describe.skip('subgraphUtils', () => {
describe.skip('getDirectSubgraphIds', () => {
describe('subgraphUtils', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('getDirectSubgraphIds', () => {
it('should return empty set for graph with no subgraph nodes', () => {
const graph = new LGraph()
const result = getDirectSubgraphIds(graph)
@@ -65,7 +72,7 @@ describe.skip('subgraphUtils', () => {
})
})
describe.skip('findUsedSubgraphIds', () => {
describe('findUsedSubgraphIds', () => {
it('should handle graph with no subgraphs', () => {
const graph = new LGraph()
const registry = new Map<UUID, LGraph>()
@@ -98,7 +105,7 @@ describe.skip('subgraphUtils', () => {
expect(result.has(subgraph2.id)).toBe(true)
})
it('should handle circular references without infinite loop', () => {
it('throws RangeError when graph.add() creates a circular subgraph reference', () => {
const rootGraph = new LGraph()
const subgraph1 = createTestSubgraph({ name: 'Subgraph 1' })
const subgraph2 = createTestSubgraph({ name: 'Subgraph 2' })
@@ -112,18 +119,9 @@ describe.skip('subgraphUtils', () => {
subgraph1.add(node2)
// Add subgraph1 to subgraph2 (circular reference)
// Note: add() itself throws RangeError due to recursive forEachNode
const node3 = createTestSubgraphNode(subgraph1, { id: 3 })
subgraph2.add(node3)
const registry = new Map<UUID, LGraph>([
[subgraph1.id, subgraph1],
[subgraph2.id, subgraph2]
])
const result = findUsedSubgraphIds(rootGraph, registry)
expect(result.size).toBe(2)
expect(result.has(subgraph1.id)).toBe(true)
expect(result.has(subgraph2.id)).toBe(true)
expect(() => subgraph2.add(node3)).toThrow(RangeError)
})
it('should handle missing subgraphs in registry gracefully', () => {

View File

@@ -211,11 +211,10 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(
graphId,
String(this.node.id),
this.name
)
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced

View File

@@ -166,7 +166,7 @@
/>
<NodeContent
v-for="preview in promotedPreviews"
:key="`${preview.interiorNodeId}-${preview.widgetName}`"
:key="`${preview.sourceNodeId}-${preview.sourceWidgetName}`"
:node-data="nodeData"
:media="preview"
/>
@@ -257,7 +257,7 @@ import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { showNodeOptions } from '@/composables/graph/useMoreOptionsMenu'
import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/unpromotedWidgetUtils'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import {
LGraphCanvas,

View File

@@ -8,6 +8,7 @@ import type {
SafeWidgetData,
VueNodeData
} from '@/composables/graph/useGraphNodeManager'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import NodeWidgets from '@/renderer/extensions/vueNodes/components/NodeWidgets.vue'
@@ -41,9 +42,10 @@ describe('NodeWidgets', () => {
const createMockNodeData = (
nodeType: string = 'TestNode',
widgets: SafeWidgetData[] = []
widgets: SafeWidgetData[] = [],
id: string = '1'
): VueNodeData => ({
id: '1',
id,
type: nodeType,
widgets,
title: 'Test Node',
@@ -54,9 +56,10 @@ describe('NodeWidgets', () => {
outputs: []
})
const mountComponent = (nodeData?: VueNodeData) => {
const mountComponent = (nodeData?: VueNodeData, setupStores?: () => void) => {
const pinia = createTestingPinia({ stubActions: false })
setActivePinia(pinia)
setupStores?.()
return mount(NodeWidgets, {
props: {
@@ -75,6 +78,20 @@ describe('NodeWidgets', () => {
})
}
const getBorderStyles = (wrapper: ReturnType<typeof mount>) =>
(
wrapper.vm as unknown as { processedWidgets: unknown[] }
).processedWidgets.map(
(entry) =>
(
entry as {
simplified: {
borderStyle?: string
}
}
).simplified.borderStyle
)
describe('node-type prop passing', () => {
it('passes node type to widget components', () => {
const widget = createMockWidget()
@@ -257,6 +274,81 @@ describe('NodeWidgets', () => {
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('does not deduplicate promoted duplicates that differ only by disambiguating source identity', () => {
const firstPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:1',
storeNodeId: 'outer-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const secondPromoted = createMockWidget({
name: 'text',
type: 'text',
nodeId: 'outer-subgraph:2',
storeNodeId: 'outer-subgraph:2',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [
firstPromoted,
secondPromoted
])
const wrapper = mountComponent(nodeData)
expect(wrapper.findAll('.lg-node-widget')).toHaveLength(2)
})
it('applies promoted border styling to intermediate promoted widgets using host node identity', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '3')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(true)
})
it('does not apply promoted border styling to outermost widgets', async () => {
const promotedWidget = createMockWidget({
name: 'text',
type: 'combo',
nodeId: 'inner-subgraph:1',
storeNodeId: 'inner-subgraph:1',
storeName: 'text',
slotName: 'text'
})
const nodeData = createMockNodeData('SubgraphNode', [promotedWidget], '4')
const wrapper = mountComponent(nodeData, () => {
usePromotionStore().promote('graph-test', '4', {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
})
await nextTick()
const borderStyles = getBorderStyles(wrapper)
expect(borderStyles.some((style) => style?.includes('promoted'))).toBe(
false
)
})
it('hides widgets when merged store options mark them hidden', async () => {
const nodeData = createMockNodeData('TestNode', [
createMockWidget({

View File

@@ -361,10 +361,13 @@ const processedWidgets = computed((): ProcessedWidget[] => {
widgetState,
identity: { renderKey }
} of uniqueWidgets) {
const hostNodeId = String(nodeId ?? '')
const bareWidgetId = String(
stripGraphPrefix(widget.storeNodeId ?? widget.nodeId ?? nodeId ?? '')
)
const isPromotedView = !!widget.nodeId
const promotionSourceNodeId = widget.storeName
? String(bareWidgetId)
: undefined
const vueComponent =
getComponent(widget.type) ||
@@ -384,8 +387,11 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const borderStyle =
graphId &&
!isPromotedView &&
promotionStore.isPromotedByAny(graphId, String(bareWidgetId), widget.name)
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: hostNodeId,
sourceWidgetName: widget.storeName ?? widget.name,
disambiguatingSourceNodeId: promotionSourceNodeId
})
? 'ring ring-component-node-widget-promoted'
: mergedOptions.advanced
? 'ring ring-component-node-widget-advanced'

View File

@@ -1,17 +1,12 @@
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
type ResolvedWidget = {
node: LGraphNode
widget: IBaseWidget
}
export function resolveWidgetFromHostNode(
hostNode: LGraphNode | undefined,
widgetName: string
): ResolvedWidget | undefined {
): ResolvedPromotedWidget | undefined {
if (!hostNode) return undefined
const widget = hostNode.widgets?.find((entry) => entry.name === widgetName)

View File

@@ -1139,6 +1139,12 @@ export class ComfyApp {
useMissingModelStore().clearMissingModels()
if (clean !== false) {
// Reset canvas context before configuring a new graph so subgraph UI
// state from the previous workflow cannot leak into the newly loaded
// one, and so `clean()` can clear the root graph even when the user is
// currently inside a subgraph.
this.canvas.setGraph(this.rootGraph)
this.clean()
}

View File

@@ -138,11 +138,10 @@ describe('DOMWidget draw promotion behavior', () => {
widget.draw(ctx as CanvasRenderingContext2D, node, 200, 30, 40)
expect(isPromotedByAnyMock).toHaveBeenCalledWith(
'root-graph-id',
'-1',
'seed'
)
expect(isPromotedByAnyMock).toHaveBeenCalledWith('root-graph-id', {
sourceNodeId: '-1',
sourceWidgetName: 'seed'
})
expect(ctx.strokeRect).toHaveBeenCalledOnce()
expect(onDraw).toHaveBeenCalledWith(widget)
})

View File

@@ -190,11 +190,10 @@ abstract class BaseDOMWidgetImpl<V extends object | string>
const graphId = this.node.graph?.rootGraph.id
const isPromoted =
graphId &&
this.promotionStore.isPromotedByAny(
graphId,
String(this.node.id),
this.name
)
this.promotionStore.isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
if (!isPromoted) {
this.options.onDraw?.(this)
return

View File

@@ -1,11 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveSubgraphPseudoWidgetCache } from '@/services/subgraphPseudoWidgetCache'
import type {
SubgraphPseudoWidget,
SubgraphPseudoWidgetCache,
SubgraphPseudoWidgetNode,
SubgraphPromotionEntry
SubgraphPseudoWidgetNode
} from '@/services/subgraphPseudoWidgetCache'
interface TestWidget extends SubgraphPseudoWidget {
@@ -30,8 +30,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
const getNodeById = vi.fn((id: string) =>
id === 'n1' ? interiorNode : undefined
)
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -48,8 +48,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('keeps $$ fallback behavior when the backing widget is missing', () => {
const interiorNode = node('n1', [widget('other')])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: '$$canvas-image-preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: '$$canvas-image-preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -64,8 +64,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('reuses cache when promotions and node identities are unchanged', () => {
const interiorNode = node('n1', [widget('preview', true)])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -91,8 +91,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('rebuilds cache when promotions reference changes', () => {
const interiorNode = node('n1', [widget('preview', true)])
const promotionsA: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotionsA: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const base = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -100,8 +100,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
const promotionsB: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotionsB: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
@@ -116,8 +116,8 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
it('falls back to rebuild when a cached node reference goes stale', () => {
const oldNode = node('n1', [widget('preview', true)])
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'n1', widgetName: 'preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
@@ -138,16 +138,100 @@ describe('resolveSubgraphPseudoWidgetCache', () => {
expect(result.nodes).toEqual([newNode])
})
it('rebuilds cache with different results when replacement node lacks the pseudo widget', () => {
const oldNode = node('n1', [widget('preview', true)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const initial = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById: () => oldNode,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(initial.nodes).toHaveLength(1)
const replacementNode = node('n1', [widget('other')])
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: initial.cache,
promotions,
getNodeById: () => replacementNode,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.cache).not.toBe(initial.cache)
expect(result.nodes).toEqual([])
expect(result.cache.entries).toHaveLength(0)
})
it('includes all pseudo-widget promotions across multiple interior nodes', () => {
const nodeA = node('n1', [widget('preview', true)])
const nodeB = node('n2', [widget('preview', true)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' },
{ sourceNodeId: 'n2', sourceWidgetName: 'preview' }
]
const getNodeById = (id: string) => {
if (id === 'n1') return nodeA
if (id === 'n2') return nodeB
return undefined
}
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.nodes).toEqual([nodeA, nodeB])
expect(result.cache.entries).toHaveLength(2)
const reducedPromotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'preview' }
]
const reduced = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions: reducedPromotions,
getNodeById,
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(reduced.nodes).toEqual([nodeA])
expect(reduced.cache.entries).toHaveLength(1)
expect(reduced.cache.entries[0].sourceNodeId).toBe('n1')
})
it('excludes promotions where isPreviewPseudoWidget returns false', () => {
const interiorNode = node('n1', [widget('myWidget', false)])
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'n1', sourceWidgetName: 'myWidget' }
]
const result = resolveSubgraphPseudoWidgetCache<TestNode, TestWidget>({
cache: null,
promotions,
getNodeById: (id) => (id === 'n1' ? interiorNode : undefined),
isPreviewPseudoWidget: (candidate) => candidate.isPseudo === true
})
expect(result.nodes).toEqual([])
expect(result.cache.entries).toHaveLength(0)
})
it('drops cached entries when node no longer resolves', () => {
const promotions: readonly SubgraphPromotionEntry[] = [
{ interiorNodeId: 'missing', widgetName: '$$canvas-image-preview' }
const promotions: readonly PromotedWidgetSource[] = [
{ sourceNodeId: 'missing', sourceWidgetName: '$$canvas-image-preview' }
]
const cache: SubgraphPseudoWidgetCache<TestNode, TestWidget> = {
promotions,
entries: [
{
interiorNodeId: 'missing',
widgetName: '$$canvas-image-preview',
sourceNodeId: 'missing',
sourceWidgetName: '$$canvas-image-preview',
node: node('missing')
}
],

View File

@@ -1,7 +1,4 @@
export interface SubgraphPromotionEntry {
interiorNodeId: string
widgetName: string
}
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
export interface SubgraphPseudoWidget {
name: string
@@ -18,8 +15,8 @@ interface SubgraphPseudoWidgetCacheEntry<
TNode extends SubgraphPseudoWidgetNode<TWidget>,
TWidget extends SubgraphPseudoWidget
> {
interiorNodeId: string
widgetName: string
sourceNodeId: string
sourceWidgetName: string
node: TNode
}
@@ -27,7 +24,7 @@ export interface SubgraphPseudoWidgetCache<
TNode extends SubgraphPseudoWidgetNode<TWidget>,
TWidget extends SubgraphPseudoWidget
> {
promotions: readonly SubgraphPromotionEntry[]
promotions: readonly PromotedWidgetSource[]
entries: SubgraphPseudoWidgetCacheEntry<TNode, TWidget>[]
nodes: TNode[]
}
@@ -37,7 +34,7 @@ interface ResolveSubgraphPseudoWidgetCacheArgs<
TWidget extends SubgraphPseudoWidget
> {
cache: SubgraphPseudoWidgetCache<TNode, TWidget> | null
promotions: readonly SubgraphPromotionEntry[]
promotions: readonly PromotedWidgetSource[]
getNodeById: (nodeId: string) => TNode | undefined
isPreviewPseudoWidget: (widget: TWidget) => boolean
}
@@ -69,10 +66,10 @@ function isCacheStillValid<
isPreviewPseudoWidget: (widget: TWidget) => boolean
): boolean {
return cache.entries.every((entry) => {
const currentNode = getNodeById(entry.interiorNodeId)
const currentNode = getNodeById(entry.sourceNodeId)
if (!currentNode || currentNode !== entry.node) return false
return isPseudoPromotion(
entry.widgetName,
entry.sourceWidgetName,
currentNode.widgets,
isPreviewPseudoWidget
)
@@ -95,11 +92,11 @@ export function resolveSubgraphPseudoWidgetCache<
return { cache, nodes: cache.nodes }
const entries = promotions.flatMap((promotion) => {
const node = getNodeById(promotion.interiorNodeId)
const node = getNodeById(promotion.sourceNodeId)
if (!node) return []
if (
!isPseudoPromotion(
promotion.widgetName,
promotion.sourceWidgetName,
node.widgets,
isPreviewPseudoWidget
)

View File

@@ -98,6 +98,7 @@ vi.mock('@/stores/toastStore', () => ({
// Mock useDialogService
vi.mock('@/services/dialogService')
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
// Mock apiKeyAuthStore
const mockApiKeyGetAuthHeader = vi.fn().mockReturnValue(null)
@@ -185,7 +186,6 @@ describe('useFirebaseAuthStore', () => {
describe('token refresh events', () => {
beforeEach(async () => {
vi.resetModules()
vi.mock('@/platform/distribution/types', () => mockDistributionTypes)
vi.mocked(firebaseAuth.onIdTokenChanged).mockImplementation(
(_auth, callback) => {

View File

@@ -32,8 +32,8 @@ describe(usePromotionStore, () => {
it('returns entries after setPromotions', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
@@ -41,31 +41,52 @@ describe(usePromotionStore, () => {
it('returns a defensive copy', () => {
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
const result = store.getPromotions(graphA, nodeId)
result.push({ interiorNodeId: '11', widgetName: 'steps' })
result.push({ sourceNodeId: '11', sourceWidgetName: 'steps' })
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
})
describe('isPromoted', () => {
it('returns false when nothing is promoted', () => {
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true for a promoted entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(true)
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for a different widget on the same node', () => {
store.promote(graphA, nodeId, '10', 'seed')
expect(store.isPromoted(graphA, nodeId, '10', 'steps')).toBe(false)
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
@@ -74,62 +95,141 @@ describe(usePromotionStore, () => {
const nodeB = 2 as NodeId
it('returns false when nothing is promoted', () => {
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when promoted by one parent', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns true when promoted by multiple parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false after demoting from all parents', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
store.demote(graphA, nodeB, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('returns true when still promoted by one parent after partial demote', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
it('returns false for different widget on same node', () => {
store.promote(graphA, nodeA, '10', 'seed')
expect(store.isPromotedByAny(graphA, '10', 'steps')).toBe(false)
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions', () => {
it('replaces existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(store.isPromoted(graphA, nodeId, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeId, '11', 'steps')).toBe(true)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
it('clears entries when set to empty array', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.setPromotions(graphA, nodeId, [])
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('preserves order', () => {
const entries = [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
]
store.setPromotions(graphA, nodeId, entries)
expect(store.getPromotions(graphA, nodeId)).toEqual(entries)
@@ -138,74 +238,128 @@ describe(usePromotionStore, () => {
describe('promote', () => {
it('adds a new entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('does not duplicate existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('appends to existing entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
})
})
describe('demote', () => {
it('removes an existing entry', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([])
})
it('is a no-op for non-existent entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.demote(graphA, nodeId, '99', 'nonexistent')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeId, {
sourceNodeId: '99',
sourceWidgetName: 'nonexistent'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
})
it('preserves other entries', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.demote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.demote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
describe('movePromotion', () => {
it('moves an entry from one index to another', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, '12', 'cfg')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.promote(graphA, nodeId, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
store.movePromotion(graphA, nodeId, 0, 2)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' },
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' },
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op for out-of-bounds indices', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.movePromotion(graphA, nodeId, 0, 5)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
})
it('is a no-op when fromIndex equals toIndex', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphA, nodeId, '11', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeId, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeId, 1, 1)
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
})
})
@@ -216,48 +370,109 @@ describe(usePromotionStore, () => {
it('tracks across setPromotions calls', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from A — still promoted by B
store.setPromotions(graphA, nodeA, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
// Remove from B — now gone
store.setPromotions(graphA, nodeB, [])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
})
it('handles replacement via setPromotions correctly', () => {
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '10', widgetName: 'seed' },
{ interiorNodeId: '11', widgetName: 'steps' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
// Replace with different entries
store.setPromotions(graphA, nodeA, [
{ interiorNodeId: '11', widgetName: 'steps' },
{ interiorNodeId: '12', widgetName: 'cfg' }
{ sourceNodeId: '11', sourceWidgetName: 'steps' },
{ sourceNodeId: '12', sourceWidgetName: 'cfg' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(store.isPromotedByAny(graphA, '12', 'cfg')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '12',
sourceWidgetName: 'cfg'
})
).toBe(true)
})
it('stays consistent through movePromotion', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeA, '11', 'steps')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
store.movePromotion(graphA, nodeA, 0, 1)
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(true)
expect(store.isPromotedByAny(graphA, '11', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
@@ -266,52 +481,412 @@ describe(usePromotionStore, () => {
const nodeB = 2 as NodeId
it('keeps promotions separate per subgraph node', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '20', 'cfg')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '20',
sourceWidgetName: 'cfg'
})
expect(store.getPromotions(graphA, nodeA)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphA, nodeB)).toEqual([
{ interiorNodeId: '20', widgetName: 'cfg' }
{ sourceNodeId: '20', sourceWidgetName: 'cfg' }
])
})
it('demoting from one node does not affect another', () => {
store.promote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeB, '10', 'seed')
store.demote(graphA, nodeA, '10', 'seed')
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(store.isPromoted(graphA, nodeA, '10', 'seed')).toBe(false)
expect(store.isPromoted(graphA, nodeB, '10', 'seed')).toBe(true)
expect(
store.isPromoted(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeB, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
})
})
describe('clearGraph resets ref counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
it('resets isPromotedByAny after clearGraph', () => {
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphA, nodeB, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.clearGraph(graphA)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('setPromotions idempotency', () => {
it('does not double ref counts when called twice with same entries', () => {
const entries = [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
]
store.setPromotions(graphA, nodeId, entries)
store.setPromotions(graphA, nodeId, entries)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.setPromotions(graphA, nodeId, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(false)
})
})
describe('promote/demote interleaved with setPromotions', () => {
it('maintains consistent ref counts through mixed operations', () => {
const nodeA = 1 as NodeId
store.promote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [
{ sourceNodeId: '10', sourceWidgetName: 'seed' },
{ sourceNodeId: '11', sourceWidgetName: 'steps' }
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
store.demote(graphA, nodeA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '11',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('graph isolation', () => {
it('isolates promotions by graph id', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
expect(store.getPromotions(graphA, nodeId)).toEqual([
{ interiorNodeId: '10', widgetName: 'seed' }
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
})
it('clearGraph only removes one graph namespace', () => {
store.promote(graphA, nodeId, '10', 'seed')
store.promote(graphB, nodeId, '20', 'steps')
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
store.promote(graphB, nodeId, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
store.clearGraph(graphA)
expect(store.getPromotions(graphA, nodeId)).toEqual([])
expect(store.getPromotions(graphB, nodeId)).toEqual([
{ interiorNodeId: '20', widgetName: 'steps' }
{ sourceNodeId: '20', sourceWidgetName: 'steps' }
])
expect(store.isPromotedByAny(graphA, '10', 'seed')).toBe(false)
expect(store.isPromotedByAny(graphB, '20', 'steps')).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
store.isPromotedByAny(graphB, {
sourceNodeId: '20',
sourceWidgetName: 'steps'
})
).toBe(true)
})
})
describe('sourceNodeId disambiguation', () => {
it('promote with disambiguatingSourceNodeId is found by matching isPromoted', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
).toBe(true)
})
it('isPromoted with different disambiguatingSourceNodeId returns false', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '88'
})
).toBe(false)
})
it('isPromoted with undefined disambiguatingSourceNodeId does not match entry with disambiguatingSourceNodeId', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '99'
})
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '10',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('two entries with same sourceNodeId/sourceWidgetName but different disambiguatingSourceNodeId coexist', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(2)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('demote with disambiguatingSourceNodeId removes only matching entry', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
store.demote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(store.getPromotions(graphA, nodeId)).toHaveLength(1)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
expect(
store.isPromoted(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(true)
})
it('isPromotedByAny with disambiguatingSourceNodeId only matches keyed entries', () => {
store.promote(graphA, nodeId, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '2'
})
).toBe(false)
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text'
})
).toBe(false)
})
it('setPromotions with disambiguatingSourceNodeId entries maintains correct ref-counts', () => {
const nodeA = 1 as NodeId
const nodeB = 2 as NodeId
store.setPromotions(graphA, nodeA, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
store.setPromotions(graphA, nodeB, [
{
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
}
])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeA, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(true)
store.setPromotions(graphA, nodeB, [])
expect(
store.isPromotedByAny(graphA, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})
})

View File

@@ -1,27 +1,32 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
interface PromotionEntry {
interiorNodeId: string
widgetName: string
const EMPTY_PROMOTIONS: PromotedWidgetSource[] = []
export function makePromotionEntryKey(source: PromotedWidgetSource): string {
const base = `${source.sourceNodeId}:${source.sourceWidgetName}`
return source.disambiguatingSourceNodeId
? `${base}:${source.disambiguatingSourceNodeId}`
: base
}
const EMPTY_PROMOTIONS: PromotionEntry[] = []
export const usePromotionStore = defineStore('promotion', () => {
const graphPromotions = ref(new Map<UUID, Map<NodeId, PromotionEntry[]>>())
const graphPromotions = ref(
new Map<UUID, Map<NodeId, PromotedWidgetSource[]>>()
)
const graphRefCounts = ref(new Map<UUID, Map<string, number>>())
function _getPromotionsForGraph(
graphId: UUID
): Map<NodeId, PromotionEntry[]> {
): Map<NodeId, PromotedWidgetSource[]> {
const promotions = graphPromotions.value.get(graphId)
if (promotions) return promotions
const nextPromotions = new Map<NodeId, PromotionEntry[]>()
const nextPromotions = new Map<NodeId, PromotedWidgetSource[]>()
graphPromotions.value.set(graphId, nextPromotions)
return nextPromotions
}
@@ -35,22 +40,24 @@ export const usePromotionStore = defineStore('promotion', () => {
return nextRefCounts
}
function _makeKey(interiorNodeId: string, widgetName: string): string {
return `${interiorNodeId}:${widgetName}`
}
function _incrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
function _incrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
const key = makePromotionEntryKey(e)
refCounts.set(key, (refCounts.get(key) ?? 0) + 1)
}
}
function _decrementKeys(graphId: UUID, entries: PromotionEntry[]): void {
function _decrementKeys(
graphId: UUID,
entries: PromotedWidgetSource[]
): void {
const refCounts = _getRefCountsForGraph(graphId)
for (const e of entries) {
const key = _makeKey(e.interiorNodeId, e.widgetName)
const key = makePromotionEntryKey(e)
const count = (refCounts.get(key) ?? 1) - 1
if (count <= 0) {
refCounts.delete(key)
@@ -63,7 +70,7 @@ export const usePromotionStore = defineStore('promotion', () => {
function getPromotionsRef(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
): PromotedWidgetSource[] {
return (
_getPromotionsForGraph(graphId).get(subgraphNodeId) ?? EMPTY_PROMOTIONS
)
@@ -72,34 +79,35 @@ export const usePromotionStore = defineStore('promotion', () => {
function getPromotions(
graphId: UUID,
subgraphNodeId: NodeId
): PromotionEntry[] {
): PromotedWidgetSource[] {
return [...getPromotionsRef(graphId, subgraphNodeId)]
}
function isPromoted(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): boolean {
return getPromotionsRef(graphId, subgraphNodeId).some(
(e) => e.interiorNodeId === interiorNodeId && e.widgetName === widgetName
(e) =>
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
}
function isPromotedByAny(
graphId: UUID,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): boolean {
const refCounts = _getRefCountsForGraph(graphId)
return (refCounts.get(_makeKey(interiorNodeId, widgetName)) ?? 0) > 0
return (refCounts.get(makePromotionEntryKey(source)) ?? 0) > 0
}
function setPromotions(
graphId: UUID,
subgraphNodeId: NodeId,
entries: PromotionEntry[]
entries: PromotedWidgetSource[]
): void {
const promotions = _getPromotionsForGraph(graphId)
const oldEntries = promotions.get(subgraphNodeId) ?? []
@@ -117,23 +125,24 @@ export const usePromotionStore = defineStore('promotion', () => {
function promote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): void {
if (isPromoted(graphId, subgraphNodeId, interiorNodeId, widgetName)) return
if (isPromoted(graphId, subgraphNodeId, source)) return
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(graphId, subgraphNodeId, [
...entries,
{ interiorNodeId, widgetName }
])
const entry: PromotedWidgetSource = {
sourceNodeId: source.sourceNodeId,
sourceWidgetName: source.sourceWidgetName
}
if (source.disambiguatingSourceNodeId)
entry.disambiguatingSourceNodeId = source.disambiguatingSourceNodeId
setPromotions(graphId, subgraphNodeId, [...entries, entry])
}
function demote(
graphId: UUID,
subgraphNodeId: NodeId,
interiorNodeId: string,
widgetName: string
source: PromotedWidgetSource
): void {
const entries = getPromotionsRef(graphId, subgraphNodeId)
setPromotions(
@@ -141,7 +150,11 @@ export const usePromotionStore = defineStore('promotion', () => {
subgraphNodeId,
entries.filter(
(e) =>
!(e.interiorNodeId === interiorNodeId && e.widgetName === widgetName)
!(
e.sourceNodeId === source.sourceNodeId &&
e.sourceWidgetName === source.sourceWidgetName &&
e.disambiguatingSourceNodeId === source.disambiguatingSourceNodeId
)
)
)
}
@@ -169,7 +182,6 @@ export const usePromotionStore = defineStore('promotion', () => {
const [entry] = entries.splice(fromIndex, 1)
entries.splice(toIndex, 0, entry)
// Reordering does not change membership, so ref-counts remain valid.
promotions.set(subgraphNodeId, entries)
}

View File

@@ -7,10 +7,22 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { createMockChangeTracker } from '@/utils/__tests__/litegraphTestUtils'
import type { Subgraph } from '@/lib/litegraph/src/LGraph'
type MockSubgraph = Pick<Subgraph, 'id' | 'rootGraph' | '_nodes' | 'nodes'>
function createMockSubgraph(id: string, rootGraph = app.rootGraph): Subgraph {
const mockSubgraph = {
id,
rootGraph,
_nodes: [],
nodes: []
} satisfies MockSubgraph
return mockSubgraph as unknown as Subgraph
}
vi.mock('@/scripts/app', () => {
const mockCanvas = {
subgraph: null,
@@ -25,14 +37,17 @@ vi.mock('@/scripts/app', () => {
setDirty: vi.fn()
}
const mockGraph = {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
}
return {
app: {
graph: {
_nodes: [],
nodes: [],
subgraphs: new Map(),
getNodeById: vi.fn()
},
graph: mockGraph,
rootGraph: mockGraph,
canvas: mockCanvas
}
}
@@ -51,6 +66,14 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
describe('useSubgraphNavigationStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
app.rootGraph.subgraphs.clear()
app.canvas.subgraph = undefined
app.canvas.ds.scale = 1
app.canvas.ds.offset = [0, 0]
app.canvas.ds.state.scale = 1
app.canvas.ds.state.offset = [0, 0]
app.graph.getNodeById = vi.fn()
vi.resetAllMocks()
})
it('should not clear navigation stack when workflow internal state changes', async () => {
@@ -87,60 +110,109 @@ describe('useSubgraphNavigationStore', () => {
it('should preserve navigation stack per workflow', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Mock first workflow
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json',
changeTracker: createMockChangeTracker({
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
filename: 'workflow1.json'
} as ComfyWorkflow
// Set the active workflow
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
// Simulate the restore process that happens when loading a workflow
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
// Verify navigation was set
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
// Switch to a different workflow with no subgraph state (root level)
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json',
changeTracker: createMockChangeTracker({
restore: vi.fn(),
store: vi.fn()
})
} as Partial<ComfyWorkflow> as ComfyWorkflow
filename: 'workflow2.json'
} as ComfyWorkflow
const sub1 = createMockSubgraph('sub-1')
const sub2 = createMockSubgraph('sub-2')
app.rootGraph.subgraphs.set(sub1.id, sub1)
app.rootGraph.subgraphs.set(sub2.id, sub2)
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) => {
if (id === sub1.id) return [sub1.id]
if (id === sub2.id) return [sub1.id, sub2.id]
return null
})
// Workflow1 is in a nested subgraph (sub-1 -> sub-2)
app.canvas.subgraph = sub2
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
// Switch to workflow2 at root level
app.canvas.subgraph = undefined
workflowStore.activeWorkflow =
workflow2 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([])
// Switch back to workflow1 in its subgraph
app.canvas.subgraph = sub2
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([sub1.id, sub2.id])
})
it('should reset navigation on workflow switch and restore on switch back', async () => {
const navigationStore = useSubgraphNavigationStore()
const workflowStore = useWorkflowStore()
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
const workflow1 = {
path: 'workflow1.json',
filename: 'workflow1.json'
} as ComfyWorkflow
const workflow1Subgraph = createMockSubgraph('sub-1')
app.rootGraph.subgraphs.set(workflow1Subgraph.id, workflow1Subgraph)
vi.mocked(findSubgraphPathById).mockImplementation((_rootGraph, id) =>
id === workflow1Subgraph.id ? [workflow1Subgraph.id] : null
)
app.canvas.subgraph = workflow1Subgraph
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
const workflow2 = {
path: 'workflow2.json',
filename: 'workflow2.json'
} as ComfyWorkflow
app.canvas.subgraph = undefined
workflowStore.activeWorkflow =
workflow2 as typeof workflowStore.activeWorkflow
await nextTick()
// Simulate the restore process for workflow2
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState([])
expect(navigationStore.exportState()).toEqual([])
// The navigation stack should be empty for workflow2 (at root level)
expect(navigationStore.exportState()).toHaveLength(0)
app.canvas.subgraph = workflow1Subgraph
// Switch back to workflow1
workflowStore.activeWorkflow =
workflow1 as typeof workflowStore.activeWorkflow
await nextTick()
// Simulate the restore process for workflow1 again
// Since subgraphState is private, we'll simulate the effect by directly restoring navigation
navigationStore.restoreState(['subgraph-1', 'subgraph-2'])
expect(navigationStore.exportState()).toEqual([workflow1Subgraph.id])
})
// The navigation stack should be restored for workflow1
expect(navigationStore.exportState()).toHaveLength(2)
expect(navigationStore.exportState()).toEqual(['subgraph-1', 'subgraph-2'])
it('should handle restoreState with unreachable subgraph IDs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.restoreState(['nonexistent-sub'])
expect(navigationStore.exportState()).toEqual(['nonexistent-sub'])
expect(navigationStore.navigationStack).toEqual([])
})
it('should clear navigation when activeSubgraph becomes undefined', async () => {
@@ -149,12 +221,7 @@ describe('useSubgraphNavigationStore', () => {
const { findSubgraphPathById } = await import('@/utils/graphTraversalUtil')
// Create mock subgraph and graph structure
const mockSubgraph = {
id: 'subgraph-1',
rootGraph: app.graph,
_nodes: [],
nodes: []
} as Partial<Subgraph> as Subgraph
const mockSubgraph = createMockSubgraph('subgraph-1', app.graph)
// Add the subgraph to the graph's subgraphs map
app.graph.subgraphs.set('subgraph-1', mockSubgraph)

View File

@@ -10,6 +10,8 @@ import { app } from '@/scripts/app'
import { findSubgraphPathById } from '@/utils/graphTraversalUtil'
import { isNonNullish } from '@/utils/typeGuardUtil'
export const VIEWPORT_CACHE_MAX_SIZE = 32
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently
@@ -29,7 +31,7 @@ export const useSubgraphNavigationStore = defineStore(
/** LRU cache for viewport states. Key: subgraph ID or 'root' for root graph */
const viewportCache = new QuickLRU<string, DragAndScaleState>({
maxSize: 32
maxSize: VIEWPORT_CACHE_MAX_SIZE
})
/**

View File

@@ -7,7 +7,10 @@ import type { LGraph, Subgraph } from '@/lib/litegraph/src/litegraph'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import type { ComfyWorkflow } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import {
useSubgraphNavigationStore,
VIEWPORT_CACHE_MAX_SIZE
} from '@/stores/subgraphNavigationStore'
const { mockSetDirty } = vi.hoisted(() => ({
mockSetDirty: vi.fn()
@@ -260,5 +263,64 @@ describe('useSubgraphNavigationStore - Viewport Persistence', () => {
expect(navigationStore.viewportCache.has('root')).toBe(true)
expect(navigationStore.viewportCache.has('sub1')).toBe(true)
})
it('should save/restore viewports correctly across multiple subgraphs', () => {
const navigationStore = useSubgraphNavigationStore()
navigationStore.viewportCache.set('root', {
scale: 1,
offset: [0, 0]
})
navigationStore.viewportCache.set('sub-1', {
scale: 2,
offset: [100, 200]
})
navigationStore.viewportCache.set('sub-2', {
scale: 0.5,
offset: [-50, -75]
})
navigationStore.restoreViewport('sub-1')
expect(mockCanvas.ds.scale).toBe(2)
expect(mockCanvas.ds.offset).toEqual([100, 200])
navigationStore.restoreViewport('sub-2')
expect(mockCanvas.ds.scale).toBe(0.5)
expect(mockCanvas.ds.offset).toEqual([-50, -75])
navigationStore.restoreViewport('root')
expect(mockCanvas.ds.scale).toBe(1)
expect(mockCanvas.ds.offset).toEqual([0, 0])
})
it('should evict oldest viewport entry when LRU cache exceeds capacity', () => {
const navigationStore = useSubgraphNavigationStore()
const overflowEntryCount = VIEWPORT_CACHE_MAX_SIZE * 2 + 1
// QuickLRU uses double-buffering: effective capacity is up to 2 * maxSize.
// Fill enough entries so the earliest ones are fully evicted.
for (let i = 0; i < overflowEntryCount; i++) {
navigationStore.viewportCache.set(`sub-${i}`, {
scale: i + 1,
offset: [i * 10, i * 20]
})
}
expect(navigationStore.viewportCache.has('sub-0')).toBe(false)
expect(
navigationStore.viewportCache.has(`sub-${overflowEntryCount - 1}`)
).toBe(true)
mockCanvas.ds.scale = 99
mockCanvas.ds.offset = [999, 999]
mockSetDirty.mockClear()
navigationStore.restoreViewport('sub-0')
expect(mockCanvas.ds.scale).toBe(99)
expect(mockCanvas.ds.offset).toEqual([999, 999])
expect(mockSetDirty).not.toHaveBeenCalled()
})
})
})