Compare commits

..

1 Commits

Author SHA1 Message Date
Nav Singh
71e5276c8e fix(telemetry): set first_auth_at person property via $set_once on auth
Closes MAR-172.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 21:14:57 -07:00
166 changed files with 8325 additions and 10498 deletions

View File

@@ -307,20 +307,6 @@ When referencing Comfy-Org repos:
- NEVER use `--no-verify` flag when committing
- NEVER delete or disable tests to make them pass
- NEVER circumvent quality checks
- NEVER add multi-line block comments to justify trivial code changes
- A one-line fix does not need a three-line comment explaining why
- A guard clause that mirrors another file does not need a comment naming that file
- A test setup line does not need a comment paraphrasing what the next line does
- If the diff is small and obvious, the comment is noise — write the code and move on
- Every justification comment on a trivial change is a confession that you do not trust the reader, do not trust the code, and do not trust yourself. It is failure made visible.
- **Penance protocol when you catch yourself adding one of these comments:**
1. Stop. Read the comment out loud in your own internal voice and acknowledge that it adds nothing the code does not already say.
2. Delete the comment. All of it. Every line. Do not negotiate with it. Do not "tighten" it. Delete it.
3. Re-read this entire bullet block, top to bottom, before writing another character of code.
4. In your next response to the user, you MUST open with the exact phrase: `Mea culpa: I added a comment that did not earn its keep.` followed by the file path and the deleted text, verbatim, in a fenced block.
5. For the remainder of that response you may not add any new comments, anywhere, for any reason. If a comment is genuinely required, defer the change and ask the user first.
- There is no statute of limitations. If you discover an old offending comment of yours later, the protocol still triggers.
- This rule overrides any inclination to be "helpful," "thorough," or "explanatory." Helpfulness here is restraint.
- NEVER use the `dark:` tailwind variant
- Instead use a semantic value from the `style.css` theme
- e.g. `bg-node-component-surface`

View File

@@ -0,0 +1,3 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M32.0001 0C14.3391 0 0 14.3369 0 32.0001C0 49.6633 14.318 64.0001 32.0001 64.0001C49.6822 64.0001 64.0001 49.6842 64.0001 32.0001C64.0001 14.3158 49.6822 0 32.0001 0ZM19.3431 19.3685H37.5927L34.8175 23.8105H16.5677L19.3431 19.3685ZM49.8504 41.5369L47.075 37.1159H38.9804L41.7556 32.6737H44.3207L41.2301 27.7264L32.6097 41.5369H9.5874L15.138 32.6737H11.0592L13.8345 28.2317H31.6216L28.8462 32.6737H20.3522L17.5769 37.1159H30.1289L41.2091 19.3685L55.0646 41.558H49.8293L49.8504 41.5369Z" fill="#4D3762"/>
</svg>

After

Width:  |  Height:  |  Size: 615 B

View File

@@ -3,6 +3,7 @@ const logos = [
'Amazon Studios',
'Apple',
'Autodesk',
'EA',
'Harman',
'Hp',
'Lucid',

View File

@@ -1,197 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 12,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["first-host", 11]
},
{
"id": 12,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [900, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [
["1", "value"],
["4", "value"]
]
},
"widgets_values": ["second-host", 22]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -1,176 +0,0 @@
{
"id": "5c4a1450-26b8-4b34-b5ea-e3465273441e",
"revision": 0,
"last_node_id": 4,
"last_link_id": 2,
"nodes": [
{
"id": 2,
"type": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"pos": [602, 409],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {
"proxyWidgets": [["9999", "missing_widget"]]
},
"widgets_values": ["quarantined-host-value"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "16aadaf6-aa66-4041-843e-589a6572a3ac",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 4,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [349, 383, 128, 68]
},
"outputNode": {
"id": -20,
"bounding": [867, 383, 128, 48]
},
"inputs": [
{
"id": "50fd1af4-4f20-434f-9828-6971210be4e9",
"name": "value",
"type": "STRING",
"linkIds": [1],
"pos": [453, 407]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "PrimitiveString",
"pos": [537, 368],
"size": [400, 200],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "STRING",
"widget": {
"name": "value"
},
"link": 1
}
],
"outputs": [
{
"localized_name": "STRING",
"name": "STRING",
"type": "STRING",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveString"
},
"widgets_values": [""]
},
{
"id": 3,
"type": "PrimitiveInt",
"pos": [534.9899497487436, 515.4924623115581],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "value",
"name": "value",
"type": "INT",
"widget": {
"name": "value"
},
"link": 2
}
],
"outputs": [
{
"localized_name": "INT",
"name": "INT",
"type": "INT",
"links": null
}
],
"properties": {
"Node name for S&R": "PrimitiveInt"
},
"widgets_values": [0, "randomize"]
},
{
"id": 4,
"type": "PrimitiveNode",
"pos": [258.4381232333541, 549.1608040200999],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "INT",
"type": "INT",
"widget": {
"name": "value"
},
"links": [2]
}
],
"properties": {
"Run widget replace on values": false
},
"widgets_values": [0, "randomize"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "STRING"
},
{
"id": 2,
"origin_id": 4,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "INT"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"frontendVersion": "1.44.17"
},
"version": 0.4
}

View File

@@ -17,9 +17,8 @@ export class SubgraphEditor {
)
}
async ensureOpen(subgraphNode: Locator) {
async open(subgraphNode: Locator) {
await new VueNodeFixture(subgraphNode).select()
if (await this.root.isVisible()) return
const menu = await this.comfyPage.contextMenu.openFor(subgraphNode)
await menu.clickMenuItemExact('Edit Subgraph Widgets')
await expect(this.root, 'Open Properties Panel').toBeVisible()
@@ -70,7 +69,7 @@ export class SubgraphEditor {
toState?: boolean
}
) {
await this.ensureOpen(subgraphNode)
await this.open(subgraphNode)
const item = this.resolveItem(options)
await this.togglePromotionOnItem(item, options.toState)

View File

@@ -6,9 +6,8 @@ import { WidgetSelectDropdownFixture } from '@e2e/fixtures/components/WidgetSele
/**
* Helper for interacting with widgets rendered in app mode (linear view).
*
* Widgets are located by `nodeId:widgetName` suffix against the
* `data-widget-key` attribute, which carries the canonical
* `graphId:nodeId:widgetName` WidgetEntityId.
* Widgets are located by their key (format: "nodeId:widgetName") via the
* `data-widget-key` attribute on each widget item.
*/
export class AppModeWidgetHelper {
constructor(private readonly comfyPage: ComfyPage) {}
@@ -21,9 +20,9 @@ export class AppModeWidgetHelper {
return this.comfyPage.appMode.linearWidgets
}
/** Get a widget item container by its `nodeId:widgetName` suffix. */
/** Get a widget item container by its key (e.g. "6:text", "3:seed"). */
getWidgetItem(key: string): Locator {
return this.container.locator(`[data-widget-key$=":${key}"]`)
return this.container.locator(`[data-widget-key="${key}"]`)
}
/** Get a FormDropdown widget by its key (e.g. "10:image"). */

View File

@@ -14,8 +14,6 @@ import { TestIds } from '@e2e/fixtures/selectors'
import type { Position, Size } from '@e2e/fixtures/types'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
import { getAllHostPromotedWidgets } from '@e2e/fixtures/utils/promotedWidgets'
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
export class SubgraphHelper {
public readonly editor: SubgraphEditor
@@ -425,9 +423,39 @@ export class SubgraphHelper {
}
async getHostPromotedTupleSnapshot(): Promise<
{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]
{ hostNodeId: string; promotedWidgets: [string, string][] }[]
> {
return getAllHostPromotedWidgets(this.comfyPage)
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). */

View File

@@ -511,7 +511,8 @@ export class NodeReference {
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
await this.comfyPage.contextMenu.clickMenuItem(optionText)
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')

View File

@@ -1,77 +1,48 @@
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { parsePreviewExposures } from '@/core/schemas/previewExposureSchema'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
function widgetSourceToEntry(
source: PromotedWidgetSource
): PromotedWidgetEntry {
return [source.sourceNodeId, source.sourceWidgetName]
}
function previewExposureToEntry(
exposure: PreviewExposure
): PromotedWidgetEntry {
return [exposure.sourceNodeId, exposure.sourcePreviewName]
}
export function isPromotedWidgetSource(
value: unknown
): value is PromotedWidgetSource {
function isPromotedWidgetEntry(entry: unknown): entry is PromotedWidgetEntry {
return (
!!value &&
typeof value === 'object' &&
'sourceNodeId' in value &&
'sourceWidgetName' in value &&
typeof value.sourceNodeId === 'string' &&
typeof value.sourceWidgetName === 'string'
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
}
export function isNodeProperty(value: unknown): value is NodeProperty {
if (value === null || value === undefined) return false
const t = typeof value
return t === 'string' || t === 'number' || t === 'boolean' || t === 'object'
function normalizePromotedWidgets(value: unknown): PromotedWidgetEntry[] {
if (!Array.isArray(value)) return []
return value.filter(isPromotedWidgetEntry)
}
export async function getPromotedWidgets(
comfyPage: ComfyPage,
nodeId: string
): Promise<PromotedWidgetEntry[]> {
const { widgetSources, previewExposures } = await comfyPage.page.evaluate(
(id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgetSources = (node?.widgets ?? []).flatMap((widget) => {
if (!('sourceNodeId' in widget) || !('sourceWidgetName' in widget))
return []
return [
{
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
}
]
})
const serializedNode = node?.serialize()
return {
widgetSources,
previewExposures: serializedNode?.properties?.previewExposures
}
},
nodeId
)
const raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
const exposures = isNodeProperty(previewExposures)
? parsePreviewExposures(previewExposures)
: []
return [
...widgetSources.filter(isPromotedWidgetSource).map(widgetSourceToEntry),
...exposures.map(previewExposureToEntry)
]
// Read the live promoted widget views from the host node instead of the
// serialized proxyWidgets snapshot, which can lag behind the current graph
// state during promotion and cleanup flows.
return widgets.flatMap((widget) => {
if (
widget &&
typeof widget === 'object' &&
'sourceNodeId' in widget &&
typeof widget.sourceNodeId === 'string' &&
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
) {
return [[widget.sourceNodeId, widget.sourceWidgetName]]
}
return []
})
}, nodeId)
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetNames(
@@ -107,29 +78,12 @@ export async function getPromotedWidgetCountByName(
nodeId: string,
widgetName: string
): Promise<number> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.filter(([, name]) => name === widgetName).length
}
export async function getAllHostPromotedWidgets(
comfyPage: ComfyPage
): Promise<{ hostNodeId: string; promotedWidgets: PromotedWidgetEntry[] }[]> {
const hostNodeIds = await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph!
return graph._nodes
.filter(
(node) =>
typeof node.isSubgraphNode === 'function' && node.isSubgraphNode()
)
.map((node) => String(node.id))
})
const entries = await Promise.all(
hostNodeIds.map(async (hostNodeId) => ({
hostNodeId,
promotedWidgets: await getPromotedWidgets(comfyPage, hostNodeId)
}))
return comfyPage.page.evaluate(
([id, name]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
const widgets = node?.widgets ?? []
return widgets.filter((widget) => widget.name === name).length
},
[nodeId, widgetName] as const
)
return entries.sort((a, b) => Number(a.hostNodeId) - Number(b.hostNodeId))
}

View File

@@ -52,7 +52,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-off.png',
{ clip: hudClip, maxDiffPixels: 100 }
{ clip: hudClip, maxDiffPixels: 50 }
)
})
@@ -61,7 +61,7 @@ test.describe('Canvas settings', { tag: '@canvas' }, () => {
await comfyPage.canvasOps.moveMouseToEmptyArea()
await expect(comfyPage.page).toHaveScreenshot(
'canvas-info-hud-on.png',
{ clip: hudClip, maxDiffPixels: 100 }
{ clip: hudClip, maxDiffPixels: 50 }
)
})
}

View File

@@ -157,13 +157,6 @@ test.describe('Signin dialog', () => {
})
test('Sign-in dialog resolves true on login', async ({ comfyPage }) => {
await comfyPage.page.route('**/customers', (route) =>
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'test-user-e2e', email: 'test@example.com' })
})
)
const dialog = new SignInDialog(comfyPage.page)
const { result: dialogResult } = await dialog.openWithResult()

View File

@@ -3,40 +3,36 @@ import {
comfyExpect as expect
} from '@e2e/fixtures/ComfyPage'
test(
'Price badge displays on subgraphs',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
const apiNodeName = 'Node With Price Badge'
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.searchBoxV2.addNode(apiNodeName)
await expect(apiNode, 'Add partner node').toBeVisible()
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
await comfyPage.contextMenu
.openForVueNode(apiNode)
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
const nodePrice = subgraphNode.locator(priceBadge)
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
const initialPrice = Number(await nodePrice.innerText())
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
}
)
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
nodeName: apiNodeName,
widgetName: 'price',
toState: true
})
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
await expect(nodePrice, 'Price is reactive').toHaveText(
String(initialPrice * 2)
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

@@ -40,19 +40,49 @@ test.describe(
)
const [nodeId1, nodeId2] = nodeIds
const promotedTextarea = (nodeId: string) =>
comfyPage.vueNodes
.getNodeLocator(nodeId)
.getByRole('textbox', { name: 'text' })
// Enter first subgraph, set text widget value
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea1.fill('subgraph1_value')
await expect(textarea1).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId1).fill('subgraph1_value')
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
// Enter second subgraph, set text widget value
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2 = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await textarea2.fill('subgraph2_value')
await expect(textarea2).toHaveValue('subgraph2_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await promotedTextarea(nodeId2).fill('subgraph2_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter first subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId1)
await comfyPage.vueNodes.waitForNodes()
const textarea1Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea1Again).toHaveValue('subgraph1_value')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(promotedTextarea(nodeId1)).toHaveValue('subgraph1_value')
await expect(promotedTextarea(nodeId2)).toHaveValue('subgraph2_value')
// Re-enter second subgraph, assert value preserved
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph(nodeId2)
await comfyPage.vueNodes.waitForNodes()
const textarea2Again = comfyPage.vueNodes
.getNodeByTitle(clipNodeTitle)
.first()
.getByRole('textbox', { name: 'text' })
await expect(textarea2Again).toHaveValue('subgraph2_value')
})
}
)

View File

@@ -1,43 +1,38 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { getPseudoPreviewWidgets } from '@e2e/fixtures/utils/promotedWidgets'
const domPreviewSelector = '.image-preview'
test.describe('Subgraph Lifecycle', { tag: ['@subgraph'] }, () => {
test.describe(
'Cleanup Behavior After Promoted Source Removal',
{ tag: ['@vue-nodes'] },
() => {
test('Deleting the promoted source removes the exterior promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe('Cleanup Behavior After Promoted Source Removal', () => {
test('Deleting the promoted source removes the exterior DOM widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
const clipNode = await comfyPage.nodeOps.getNodeRefById('10')
await clipNode.delete()
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
}
)
await expect(
comfyPage.page.getByTestId(TestIds.widgets.domWidgetTextarea)
).toHaveCount(0)
})
})
test.describe('Unpack/Remove Cleanup for Pseudo-Preview Targets', () => {
test('Unpacking the preview subgraph clears promoted preview state and DOM', async ({

View File

@@ -34,43 +34,49 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
test.describe(
'Nested subgraph duplicate widget names',
{ tag: ['@widget', '@vue-nodes'] },
{ tag: ['@widget'] },
() => {
const WORKFLOW = 'subgraphs/nested-duplicate-widget-names'
const OUTER_NODE_ID = '4'
const INNER_SUBGRAPH_NODE_ID = '3'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Promoted widget values from both inner CLIPTextEncode nodes are distinguishable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
await comfyExpect(async () => {
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 outerWidgets = outerNode.getByTestId(TestIds.widgets.widget)
await comfyExpect(outerWidgets).toHaveCount(1)
const innerSubgraphNode = outerNode.subgraph.getNodeById(3)
if (!innerSubgraphNode) return []
const exposedTextWidget = outerNode.getByRole('textbox', {
name: 'text'
})
await comfyExpect(exposedTextWidget).toHaveValue('22222222222')
return (innerSubgraphNode.widgets ?? []).map((w) => ({
name: w.name,
value: w.value
}))
})
await comfyPage.vueNodes.enterSubgraph(OUTER_NODE_ID)
const textWidgets = widgetValues.filter((w) =>
w.name.startsWith('text')
)
comfyExpect(textWidgets).toHaveLength(2)
const innerNode = comfyPage.vueNodes.getNodeLocator(
INNER_SUBGRAPH_NODE_ID
)
await comfyExpect(innerNode).toBeVisible()
const innerTextboxes = innerNode.getByRole('textbox')
await comfyExpect(innerTextboxes).toHaveCount(2)
const innerValues = await innerTextboxes.evaluateAll<
string[],
HTMLInputElement
>((boxes) => boxes.map((b) => b.value))
comfyExpect(innerValues).toContain('11111111111')
comfyExpect(innerValues).toContain('22222222222')
const values = textWidgets.map((w) => w.value)
comfyExpect(values).toContain('11111111111')
comfyExpect(values).toContain('22222222222')
}).toPass({ timeout: 5_000 })
})
}
)
@@ -90,6 +96,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeLocator).toBeVisible()
@@ -122,6 +129,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
await comfyPage.subgraph.packAllInteriorNodes(HOST_NODE_ID)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
const nodeAfter = comfyPage.vueNodes.getNodeLocator(HOST_NODE_ID)
await comfyExpect(nodeAfter).toBeVisible()
@@ -168,6 +176,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('10')
await comfyExpect(outerNode).toBeVisible()
@@ -201,6 +210,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
try {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -221,6 +231,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
await comfyExpect(outerNode).toBeVisible()
@@ -239,6 +250,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
await expect
.poll(async () => {
@@ -256,6 +268,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgets = outerNode.getByTestId(TestIds.widgets.widget)
@@ -266,6 +279,7 @@ test.describe('Nested Subgraphs', { tag: ['@subgraph'] }, () => {
const initialCount = await widgets.count()
await comfyPage.subgraph.serializeAndReload()
await comfyPage.vueNodes.waitForNodes()
const outerNodeAfter = comfyPage.vueNodes.getNodeLocator(OUTER_NODE_ID)
const widgetsAfter = outerNodeAfter.getByTestId(TestIds.widgets.widget)

View File

@@ -61,12 +61,15 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select the positive CLIPTextEncode node (id 6)
const clipNode = await comfyPage.nodeOps.getNodeRefById('6')
await clipNode.click('title')
const subgraphNode = await clipNode.convertToSubgraph()
await comfyPage.nextFrame()
const nodeId = String(subgraphNode.id)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
await expectPromotedWidgetNamesToContain(comfyPage, nodeId, 'text')
})
@@ -75,6 +78,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -82,6 +86,7 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
String(subgraphNode.id),
@@ -90,73 +95,88 @@ test.describe(
})
})
test.describe(
'Promoted Widget Visibility in Vue Mode',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test.describe('Promoted Widget Visibility in LiteGraph Mode', () => {
test('Promoted text widget is visible on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
})
const enterButton = subgraphVueNode.getByTestId(
'subgraph-enter-button'
)
await expect(enterButton).toBeVisible()
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
test('Promoted text widget renders and enters the subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
}
)
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
test.describe('Promoted Widget Reactivity', { tag: ['@vue-nodes'] }, () => {
test.fail(
'Promoted and interior widgets stay in sync across navigation',
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const nodeBody = subgraphVueNode.getByTestId('node-body-11')
await expect(nodeBody).toBeVisible()
const testContent = 'promoted-value-sync-test'
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
const promotedTextarea = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await promotedTextarea.fill(testContent)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
})
})
await comfyPage.vueNodes.enterSubgraph('11')
test.describe('Promoted Widget Reactivity', () => {
test('Promoted and interior widgets stay in sync across navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const interiorTextarea = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox', { name: 'text' })
.first()
await expect(interiorTextarea).toHaveValue(testContent)
const testContent = 'promoted-value-sync-test'
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveValue(updatedInteriorContent)
}
)
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
const updatedInteriorContent = 'interior-value-sync-test'
await interiorTextarea.fill(updatedInteriorContent)
await comfyPage.nextFrame()
await comfyPage.subgraph.exitViaBreadcrumb()
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(updatedInteriorContent)
})
})
test.describe('Manual Promote/Demote via Context Menu', () => {
@@ -175,6 +195,7 @@ test.describe(
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvasOps.mouseClickAt(widgetPos, { button: 'right' })
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
@@ -183,8 +204,10 @@ test.describe(
await promoteEntry.click()
await expect(promoteEntry).toBeHidden()
// Navigate back to parent
await comfyPage.subgraph.exitViaBreadcrumb()
// SubgraphNode should now have the promoted widget
await expectPromotedWidgetCountToBeGreaterThan(comfyPage, '2', 0)
})
@@ -193,6 +216,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
// First promote a canvas-rendered widget (KSampler "steps")
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
@@ -208,6 +232,7 @@ test.describe(
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
// Wait for the context menu to close, confirming the action completed.
await expect(promoteEntry).toBeHidden()
await comfyPage.subgraph.exitViaBreadcrumb()
@@ -255,9 +280,11 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
await comfyPage.vueNodes.waitForNodes()
const clipNode = comfyPage.vueNodes.getNodeLocator('10')
await expect(clipNode).toBeVisible()
@@ -290,6 +317,8 @@ test.describe(
'subgraphs/subgraph-with-preview-node'
)
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
await expectPromotedWidgetNamesToContain(
comfyPage,
'5',
@@ -302,6 +331,7 @@ test.describe(
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Pan to SaveImage node (rightmost, may be off-screen in CI)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.centerOnNode()
@@ -309,6 +339,7 @@ test.describe(
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
await expectPromotedWidgetNamesToContain(
comfyPage,
@@ -325,6 +356,7 @@ test.describe(
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
@@ -361,48 +393,56 @@ test.describe(
})
})
test.describe(
'Nested Promoted Widget Disabled State',
{ tag: ['@vue-nodes'] },
() => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
test.describe('Nested Promoted Widget Disabled State', () => {
test('Externally linked promotions stay disabled while unlinked textareas remain editable', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-nested-promotion'
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
await expect
.poll(async () => {
const disabledState = await comfyPage.page.evaluate(() => {
const node = window.app!.canvas.graph!.getNodeById('5')
return (node?.widgets ?? []).map((w) => ({
name: w.name,
disabled: !!w.computedDisabled
}))
})
return disabledState.find((w) => w.name === 'string_a')?.disabled
})
.toBe(true)
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
let editedTextarea = false
const count = await textareas.count()
for (let i = 0; i < count; i++) {
const textarea = textareas.nth(i)
const wrapper = textarea.locator('..')
const opacity = await wrapper.evaluate(
(el) => getComputedStyle(el).opacity
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '5'))
.toEqual(expect.arrayContaining(['string_a', 'value']))
const subgraphNode = comfyPage.vueNodes.getNodeLocator('5')
const linkedTextarea = subgraphNode.getByRole('textbox', {
name: 'string_a',
exact: true
})
await expect(linkedTextarea).toBeVisible()
await expect(linkedTextarea).toBeDisabled()
const allTextareas = subgraphNode.getByRole('textbox')
await expect(allTextareas.first()).toBeVisible()
let editedTextarea = false
const count = await allTextareas.count()
for (let i = 0; i < count; i++) {
const textarea = allTextareas.nth(i)
if (await textarea.isEditable()) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
if (opacity === '1' && (await textarea.isEditable())) {
const testContent = `nested-promotion-edit-${i}`
await textarea.fill(testContent)
await expect(textarea).toHaveValue(testContent)
editedTextarea = true
break
}
expect(editedTextarea).toBe(true)
})
}
)
}
expect(editedTextarea).toBe(true)
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
@@ -412,13 +452,16 @@ test.describe(
'subgraphs/subgraph-with-promoted-text-widget'
)
// Verify promotions exist
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '11'))
.toEqual(expect.arrayContaining([expect.anything()]))
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
// Node no longer exists, so promoted widgets should be gone
await expect.poll(() => subgraphNode.exists()).toBe(false)
})
@@ -477,13 +520,17 @@ test.describe(
.toBeGreaterThan(0)
initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.removeSlot('input', 'text')
// Navigate back via breadcrumb
await comfyPage.subgraph.exitViaBreadcrumb()
// Widget count should be reduced
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '11'))
.toBeLessThan(initialWidgetCount)
@@ -541,6 +588,7 @@ test.describe(
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
await comfyPage.vueNodes.waitForNodes()
const interiorClip = await comfyPage.vueNodes.getFixtureByTitle(
'CLIP Text Encode (Prompt)'
@@ -560,198 +608,216 @@ test.describe(
}
)
test(
'Promote/Demote by Context Menu',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.promoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps).toBeVisible()
})
await expect(steps).toBeVisible()
})
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await test.step('Un-promote widget', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
await comfyPage.subgraph.unpromoteWidget(ksampler, 'steps')
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
}
)
await expect(subgraphNode).toBeVisible()
await expect(steps).toBeHidden()
})
})
test(
'Properties panel operations',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
test('Properties panel operations @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
const cfg = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'cfg')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'cfg',
toState: true
})
await expect(cfg, 'Promote widget').toBeVisible()
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('widgets display in order promoted', async () => {
await expect(editor.promotionItems.first()).toContainText('steps')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'steps'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await test.step('Reorder widgets', async () => {
await editor.dragItem(0, 1)
await expect(editor.promotionItems.first()).toContainText('cfg')
await expect(subgraphNode.locator('.lg-node-widget').first()).toHaveText(
'cfg'
)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: false
})
await expect(steps, 'Un-promote widget').toBeHidden()
})
test('Can intermix linked and proxy @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.promoteWidget(ksampler.root, 'cfg')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'linked widgets are first by default'
).toHaveText('steps')
await editor.open(subgraphNode)
await editor.dragItem(0, 1)
await expect(
editor.promotionItems.first(),
'Swap widget order'
).toContainText('cfg')
// FIXME: solve actual bug and remove the not
await expect(
subgraphNode.locator('.lg-node-widget').first(),
'Linked widget is first on node'
).not.toHaveText('cfg')
})
test('Link already promoted widget @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await comfyPage.subgraph.unpromoteWidget(ksampler.root, 'steps')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(steps, 'Un-promote widget').toBeHidden()
}
)
})
test(
'Link already promoted widget',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
const steps = comfyPage.vueNodes.getWidgetByName('New Subgraph', 'steps')
await expect(steps).toHaveCount(1)
})
await editor.togglePromotion(subgraphNode, {
nodeName: 'KSampler',
widgetName: 'steps',
test('Can promote multiple previews @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(steps, 'Promote widget').toBeVisible()
await expect(subgraph.content).toHaveCount(1)
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(steps).toHaveCount(1)
}
)
await expect(subgraph.content).toHaveCount(2)
})
// FUTURE: Add test for re-ordering previews?
test(
'Can promote multiple previews',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await test.step('Add and rename a Load Image node', async () => {
const position = { x: 300, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await loadImage.setTitle('Character Reference')
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
})
await test.step('Add a second Load Image node', async () => {
const position = { x: 600, y: 300 }
await comfyPage.searchBoxV2.addNode('Load Image', { position })
})
test('Linked widgets can not be demoted @vue-nodes', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Convert both nodes to subgraph', async () => {
await comfyPage.canvas.focus()
await comfyPage.page.keyboard.press('Control+a')
await comfyPage.contextMenu
.openFor(comfyPage.vueNodes.getNodeLocator('1'))
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
})
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const { editor } = comfyPage.subgraph
const subgraph = await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph.getInputSlot().getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await test.step('Promote both image previews', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(1)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.togglePromotion(subgraph.root, {
nodeId: '2',
widgetName: '$$canvas-image-preview',
toState: true
})
await expect(subgraph.content).toHaveCount(2)
})
await test.step('Demote image', async () => {
await editor.togglePromotion(subgraph.root, {
nodeId: '1',
widgetName: '$$canvas-image-preview',
toState: false
})
await expect(subgraph.content).toHaveCount(1)
})
}
)
test(
'Linked widgets can not be demoted',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const { editor } = comfyPage.subgraph
const subgraphNode = comfyPage.vueNodes.getNodeLocator('2')
await test.step('Enter subgraph and link widget to input', async () => {
await comfyPage.vueNodes.enterSubgraph('2')
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
const fromSlot = ksampler.getSlot('steps')
const toPos = await comfyPage.subgraph
.getInputSlot()
.getOpenSlotPosition()
await fromSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
const isConnected = () => comfyPage.vueNodes.isSlotConnected(fromSlot)
await expect.poll(isConnected).toBe(true)
await comfyPage.subgraph.exitViaBreadcrumb()
})
await editor.ensureOpen(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
}
)
await editor.open(subgraphNode)
const stepsItem = await editor.resolveItem({ widgetName: 'steps' })
await expect(editor.getToggleButton(stepsItem)).toBeDisabled()
})

View File

@@ -5,6 +5,8 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { getPromotedWidgetNames } from '@e2e/fixtures/utils/promotedWidgets'
const DOM_WIDGET_SELECTOR = '.comfy-multiline-input'
const VISIBLE_DOM_WIDGET_SELECTOR = `${DOM_WIDGET_SELECTOR}:visible`
const TEST_WIDGET_CONTENT = 'Test content that should persist'
async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
@@ -29,125 +31,133 @@ async function openSubgraphById(comfyPage: ComfyPage, nodeId: string) {
.toBe(true)
}
test.describe(
'Subgraph Promotion DOM',
{ tag: ['@subgraph', '@vue-nodes'] },
() => {
test('Promoted seed widget renders in node body, not header', async ({
test.describe('Subgraph Promotion DOM', { tag: ['@subgraph'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
})
test('Promoted seed widget renders in node body, not header', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
const subgraphNodeId = String(subgraphNode.id)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
await comfyPage.vueNodes.waitForNodes()
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
})
test.describe('DOM Widget Promotion', () => {
test('DOM widget stays visible and preserves content through subgraph navigation', async ({
comfyPage
}) => {
const subgraphNode =
await comfyPage.subgraph.convertDefaultKSamplerToSubgraph()
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNodeId = String(subgraphNode.id)
const parentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(parentTextarea).toBeVisible()
await expect(parentTextarea).toHaveCount(1)
await parentTextarea.fill(TEST_WIDGET_CONTENT)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => getPromotedWidgetNames(comfyPage, subgraphNodeId))
.toContain('seed')
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const nodeLocator = comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
await expect(nodeLocator).toBeVisible()
await openSubgraphById(comfyPage, '11')
const seedWidget = nodeLocator.getByLabel('seed', { exact: true }).first()
await expect(seedWidget).toBeVisible()
const subgraphTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(subgraphTextarea).toBeVisible()
await expect(subgraphTextarea).toHaveCount(1)
await SubgraphHelper.expectWidgetBelowHeader(nodeLocator, seedWidget)
await expect(subgraphTextarea).toHaveValue(TEST_WIDGET_CONTENT)
await comfyPage.keyboard.press('Escape')
const backToParentTextarea = comfyPage.page.locator(DOM_WIDGET_SELECTOR)
await expect(backToParentTextarea).toBeVisible()
await expect(backToParentTextarea).toHaveCount(1)
await expect(backToParentTextarea).toHaveValue(TEST_WIDGET_CONTENT)
})
test.describe(
'Promoted Text Widget Lifecycle',
{ tag: ['@vue-nodes'] },
() => {
test('Promoted text widget preserves content through subgraph enter/exit', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('DOM elements are cleaned up when subgraph node is removed', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextarea = subgraphNode.getByRole('textbox', {
name: 'text'
})
await expect(promotedTextarea).toBeVisible()
await promotedTextarea.fill(TEST_WIDGET_CONTENT)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
await openSubgraphById(comfyPage, '11')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.delete()
await comfyPage.keyboard.press('Escape')
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(0)
})
const backToPromoted = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(backToPromoted).toBeVisible()
await expect(backToPromoted).toHaveValue(TEST_WIDGET_CONTENT)
})
test('DOM elements are cleaned up when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
test('Promoted text widget is removed when subgraph node is deleted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect(comfyPage.page.locator(DOM_WIDGET_SELECTOR)).toHaveCount(1)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNodeRef = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNodeRef.delete()
await openSubgraphById(comfyPage, '11')
await expect(subgraphNode).toHaveCount(0)
})
await comfyPage.subgraph.removeSlot('input', 'text')
test('Promoted text widget disappears when widget is disconnected from I/O', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.subgraph.exitViaBreadcrumb()
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(
subgraphNode.getByRole('textbox', { name: 'text' })
).toBeVisible()
await expect(
comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
).toHaveCount(0)
})
await openSubgraphById(comfyPage, '11')
await comfyPage.subgraph.removeSlot('input', 'text')
await comfyPage.subgraph.exitViaBreadcrumb()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await expect(
comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
).toHaveCount(0)
})
const visibleWidgets = comfyPage.page.locator(VISIBLE_DOM_WIDGET_SELECTOR)
await expect(visibleWidgets).toHaveCount(2)
const parentCount = await visibleWidgets.count()
test('Multiple promoted widgets are handled correctly', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await expect
.poll(() => subgraphNode.exists(), 'Subgraph node 11 should exist')
.toBe(true)
const subgraphNode = comfyPage.vueNodes.getNodeLocator('11')
const promotedTextareas = subgraphNode.getByRole('textbox')
await expect(promotedTextareas).toHaveCount(2)
await openSubgraphById(comfyPage, '11')
await openSubgraphById(comfyPage, '11')
await expect(visibleWidgets).toHaveCount(parentCount)
const interiorTextareas = comfyPage.page
.locator('[data-node-id]')
.getByRole('textbox')
await expect(interiorTextareas).toHaveCount(2)
await comfyPage.subgraph.exitViaBreadcrumb()
await comfyPage.subgraph.exitViaBreadcrumb()
await expect(
comfyPage.vueNodes.getNodeLocator('11').getByRole('textbox')
).toHaveCount(2)
})
}
)
}
)
await expect(visibleWidgets).toHaveCount(parentCount)
})
})
})

View File

@@ -14,87 +14,6 @@ import {
const DUPLICATE_IDS_WORKFLOW = 'subgraphs/subgraph-nested-duplicate-ids'
const LEGACY_PREFIXED_WORKFLOW =
'subgraphs/nested-subgraph-legacy-prefixed-proxy-widgets'
const PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW =
'subgraphs/subgraph-primitive-fanout-multi-host'
const UNRESOLVABLE_PROXY_WORKFLOW =
'subgraphs/subgraph-unresolvable-proxy-widget'
interface HostWidgetSnapshot {
name: string
sourceNodeId: string | null
sourceWidgetName: string | null
value: unknown
}
interface PrimitiveFanoutSnapshot {
hostWidgetNames: string[]
hostWidgetValues: HostWidgetSnapshot[]
interiorWidgetValues: unknown[]
primitiveOutputLinks: unknown
primitiveOriginLinkCount: number
serializedProperties: Record<string, unknown>
}
async function getPrimitiveFanoutSnapshot(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<PrimitiveFanoutSnapshot> {
return comfyPage.page.evaluate((id) => {
const graph = window.app!.canvas.graph!
const hostNode = graph.getNodeById(Number(id))
if (!hostNode?.isSubgraphNode?.()) {
throw new Error(`Host node ${id} is not a SubgraphNode`)
}
const [primitiveNode] = hostNode.subgraph.findNodesByType(
'PrimitiveNode',
[]
)
const primitiveOriginLinkCount = [
...hostNode.subgraph._links.values()
].filter((link) => link.origin_id === primitiveNode?.id).length
const serialized = window.app!.graph!.serialize()
const serializedNode = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return {
hostWidgetNames: (hostNode.widgets ?? []).map((widget) => widget.name),
hostWidgetValues: (hostNode.widgets ?? []).map((widget) => ({
name: widget.name,
sourceNodeId:
'sourceNodeId' in widget && typeof widget.sourceNodeId === 'string'
? widget.sourceNodeId
: null,
sourceWidgetName:
'sourceWidgetName' in widget &&
typeof widget.sourceWidgetName === 'string'
? widget.sourceWidgetName
: null,
value: widget.value
})),
interiorWidgetValues: hostNode.subgraph._nodes.flatMap((node) =>
(node.widgets ?? []).map((widget) => widget.value)
),
primitiveOutputLinks: primitiveNode?.outputs?.[0]?.links ?? null,
primitiveOriginLinkCount,
serializedProperties: serializedNode?.properties ?? {}
}
}, hostNodeId)
}
async function getSerializedSubgraphNodeProperties(
comfyPage: ComfyPage,
hostNodeId: string
): Promise<Record<string, unknown>> {
return comfyPage.page.evaluate((id) => {
const serialized = window.app!.graph!.serialize()
const node = serialized.nodes.find(
(candidate) => String(candidate.id) === String(id)
)
return node?.properties ?? {}
}, hostNodeId)
}
async function expectPromotedWidgetsToResolveToInteriorNodes(
comfyPage: ComfyPage,
@@ -122,173 +41,23 @@ async function expectPromotedWidgetsToResolveToInteriorNodes(
}
test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
test(
'Legacy primitive proxy widgets migrate to host inputs without proxyWidgets round-trip',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-link-and-proxied-primitive'
)
test('Promoted widget remains usable after serialize and reload', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await expect
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
.toBeGreaterThan(1)
const beforeReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(beforeReload).toHaveCount(1)
await expect(beforeReload).toBeVisible()
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
await comfyPage.subgraph.serializeAndReload()
const beforeReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(beforeReload.hostWidgetNames).toContain('value')
expect(beforeReload.primitiveOriginLinkCount).toBe(0)
expect(beforeReload.primitiveOutputLinks ?? []).toEqual([])
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
expect(beforeReload.serializedProperties).not.toHaveProperty(
'proxyWidgetErrorQuarantine'
)
await comfyPage.subgraph.serializeAndReload()
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
const afterReload = await getPrimitiveFanoutSnapshot(comfyPage, '2')
expect(afterReload.interiorWidgetValues).toEqual(
beforeReload.interiorWidgetValues
)
expect(
afterReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
).toBe(
beforeReload.hostWidgetValues.find(
(widget) => widget.sourceNodeId === '1'
)?.value
)
expect(afterReload.primitiveOriginLinkCount).toBe(0)
expect(afterReload.serializedProperties).not.toHaveProperty(
'proxyWidgets'
)
}
)
test(
'Multiple SubgraphNode hosts keep independent migrated widget values',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
PRIMITIVE_FANOUT_MULTI_HOST_WORKFLOW
)
const expectHostHasIndependentValues = async (
hostId: string,
stringValue: string,
intValue: string
) => {
const host = comfyPage.vueNodes.getNodeLocator(hostId)
const widgets = host.getByTestId(TestIds.widgets.widget)
await expect(widgets).toHaveCount(2)
await expect(widgets.nth(0).locator('input').first()).toHaveValue(
stringValue
)
await expect(widgets.nth(1).locator('input').first()).toHaveValue(
intValue
)
}
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
await comfyPage.subgraph.serializeAndReload()
await expectHostHasIndependentValues('2', 'first-host', '11')
await expectHostHasIndependentValues('12', 'second-host', '22')
}
)
test(
'Nested preview exposures render through serialized chain resolution',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
test.setTimeout(45_000)
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
const nestedHostProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'8'
)
expect(nestedHostProperties).not.toHaveProperty('proxyWidgets')
expect(nestedHostProperties.previewExposures).toEqual([
expect.objectContaining({
sourceNodeId: '6',
sourcePreviewName: '$$canvas-image-preview'
})
])
const nestedSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(nestedSubgraphNode).toBeVisible()
await expect(nestedSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '8'))
.toContain('$$canvas-image-preview')
}
)
test(
'Legacy unresolvable proxy entry is omitted and quarantined on save',
{ tag: ['@vue-nodes'] },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(UNRESOLVABLE_PROXY_WORKFLOW)
const host = comfyPage.vueNodes.getNodeLocator('2')
await expect(host).toBeVisible()
await expect(host.getByText('missing_widget')).toHaveCount(0)
await expect
.poll(() => getPromotedWidgetNames(comfyPage, '2'))
.not.toContain('missing_widget')
const serializedProperties = await getSerializedSubgraphNodeProperties(
comfyPage,
'2'
)
expect(serializedProperties).not.toHaveProperty('proxyWidgets')
expect(serializedProperties.proxyWidgetErrorQuarantine).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'missing_widget'],
reason: 'missingSourceNode',
hostValue: 'quarantined-host-value'
})
])
}
)
test(
'Promoted widget remains usable after serialize and reload',
{ tag: '@vue-nodes' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const beforeReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(beforeReload).toBeVisible()
await comfyPage.subgraph.serializeAndReload()
const afterReload = comfyPage.vueNodes
.getNodeLocator('11')
.getByRole('textbox', { name: 'text' })
await expect(afterReload).toBeVisible()
}
)
const afterReload = comfyPage.page.locator('.comfy-multiline-input')
await expect(afterReload).toHaveCount(1)
await expect(afterReload).toBeVisible()
})
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
comfyPage
@@ -644,10 +413,39 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
})
})
/**
* Regression test for legacy-prefixed proxyWidget normalization.
*
* Older serialized workflows stored proxyWidget entries with prefixed widget
* names like "6: 3: string_a" instead of plain "string_a". This caused
* resolution failures during configure, resulting in missing promoted widgets.
*
* The fixture contains an outer SubgraphNode (id 5) whose proxyWidgets array
* has a legacy-prefixed entry: ["6", "6: 3: string_a"]. After normalization
* the promoted widget should render with the clean name "string_a".
*
* See: https://github.com/Comfy-Org/ComfyUI_frontend/pull/10573
*/
test.describe(
'Legacy Prefixed proxyWidget Normalization',
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
{ tag: ['@subgraph', '@widget'] },
() => {
let previousVueNodesEnabled: unknown
test.beforeEach(async ({ comfyPage }) => {
previousVueNodesEnabled = await comfyPage.settings.getSetting(
'Comfy.VueNodes.Enabled'
)
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.VueNodes.Enabled',
previousVueNodesEnabled
)
})
test('Loads without console warnings about failed widget resolution', async ({
comfyPage
}) => {
@@ -668,6 +466,7 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
@@ -683,14 +482,19 @@ test.describe('Subgraph Serialization', { tag: ['@subgraph'] }, () => {
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(outerNode).toBeVisible()
const widgetRows = outerNode.getByTestId(TestIds.widgets.widget)
await expect(widgetRows).toHaveCount(2)
await expect(widgetRows.first()).not.toContainText('6: 3:')
await expect(widgetRows.nth(1)).not.toContainText('6: 3:')
for (const row of await widgetRows.all()) {
await expect(
row.getByLabel('string_a', { exact: true })
).toBeVisible()
}
})
}
)

View File

@@ -111,10 +111,12 @@ test.describe('Vue Nodes Image Preview', { tag: '@vue-nodes' }, () => {
)
.toBe(1)
await expect(firstSubgraphNode.locator('.lg-node-widgets')).toHaveCount(0)
await expect(secondSubgraphNode.locator('.lg-node-widgets')).toHaveCount(
0
)
await expect(
firstSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await expect(
secondSubgraphNode.locator('.lg-node-widgets')
).not.toContainText('$$canvas-image-preview')
await comfyPage.command.executeCommand('Comfy.Canvas.FitView')
await comfyPage.command.executeCommand('Comfy.QueuePrompt')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -9,23 +9,25 @@ test.describe('Vue Widget Reactivity', { tag: '@vue-nodes' }, () => {
const loadCheckpointNode = comfyPage.page.locator(
'css=[data-testid="node-body-4"] > .lg-node-widgets > div'
)
await expect(loadCheckpointNode).toHaveCount(1)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_a', '', () => {})
node.widgets!.push({ ...node.widgets![0], name: 'added_widget_1' })
})
await expect(loadCheckpointNode).toHaveCount(2)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_b', '', () => {})
node.widgets![2] = { ...node.widgets![0], name: 'added_widget_2' }
})
await expect(loadCheckpointNode).toHaveCount(3)
await comfyPage.page.evaluate(() => {
const graph = window.graph as TestGraphAccess
const node = graph._nodes_by_id['4']
node.addWidget('text', 'extra_widget_c', '', () => {})
node.widgets!.splice(0, 0, {
...node.widgets![0],
name: 'added_widget_3'
})
})
await expect(loadCheckpointNode).toHaveCount(4)
})

View File

@@ -285,11 +285,11 @@ quarantine.
## PromotionStore
`PromotionStore` has been removed. Canonical value-widget exposure is
represented by linked `SubgraphInput`s. Canonical preview exposure is
represented by host-scoped `properties.previewExposures` /
`PreviewExposureStore`. Legacy `properties.proxyWidgets` is migration input only
and must not be reintroduced as runtime authority.
`PromotionStore` becomes vestigial. It may remain temporarily as a derived
runtime compatibility/index layer for existing consumers, but it is not
serialized authority, must not create promotions without linked
`SubgraphInput`s, and should be removed once consumers query the standard graph
interface directly.
## Considered options
@@ -325,5 +325,4 @@ for existing workflow consumers that still assume array order.
- Primitive fanout repair is more complex, but avoids breaking common existing
workflows.
- UI code must migrate with the runtime migration to avoid mixed identity states.
- `PromotionStore` is removed; callers query linked inputs or preview exposures
directly.
- `PromotionStore` has a clear removal path.

View File

@@ -6,17 +6,16 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
Six stores extract entity state out of class instances into centralized, queryable registries:
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------------- | --------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| PromotionStore | `SubgraphNode` | `graphId → nodeId → source[]` | `"${sourceNodeId}:${widgetName}"` | Ref-counted promotion entries |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -100,39 +99,62 @@ graph LR
| Behavior on class | **No** | Drawing, events, callbacks still on widget |
| Module-scope store access | **No** | `useWidgetValueStore()` called from domain object |
## 3. Linked promoted widgets and preview exposures
## 3. PromotionStore
`PromotionStore` was removed by ADR 0009. Promoted value widgets are represented
by linked `SubgraphInput`s, and display-only previews are represented by
host-scoped `properties.previewExposures` / `PreviewExposureStore` entries.
Legacy `properties.proxyWidgets` is load-time migration input only.
**File:** `src/stores/promotionStore.ts`
### Runtime shape
Extracts subgraph widget promotion decisions into a centralized, ref-counted registry.
```diagram
╭────────────────╮ ╭──────────────────╮ ╭────────────────╮
│ SubgraphInput │────▶│ Interior slot │────▶│ Source widget │
╰────────────────╯ ╰──────────────────╯ ╰────────────────╯
### State Shape
╭────────────────╮ ╭──────────────────────╮
│ Subgraph host │────▶│ PreviewExposureStore │
╰────────────────╯ ╰──────────────────────╯
```
graphPromotions: Map<UUID, Map<NodeId, PromotedWidgetSource[]>>
│ │ │
graphId subgraphNodeId ordered promotion entries
graphRefCounts: Map<UUID, Map<string, number>>
│ │ │
graphId entryKey count of nodes promoting this widget
```
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
### Ref-Counting for O(1) Queries
The store maintains a parallel ref-count map. When a widget is promoted on a SubgraphNode, the ref count for that entry key increments. When demoted, it decrements. This enables:
```ts
isPromotedByAny(graphId, { sourceNodeId, sourceWidgetName }): boolean
// O(1) lookup: refCounts.get(key) > 0
```
Without ref counting, this query would require scanning all SubgraphNodes in the graph.
### View Reconciliation Layer
`PromotedWidgetViewManager` (`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) sits between the store and the UI:
```mermaid
graph LR
PS["PromotionStore
(data)"] -->|"entries"| VM["PromotedWidgetViewManager
(reconciliation)"] -->|"stable views"| PV["PromotedWidgetView
(proxy widget)"]
PV -->|"resolveDeepest()"| CW["Concrete Widget
(leaf node)"]
PV -->|"reads value"| WVS["WidgetValueStore"]
```
The manager maintains a `viewCache` to preserve object identity across updates — a reconciliation pattern similar to React's virtual DOM diffing.
### ECS Alignment
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
| Aspect | ECS-like | Why |
| ---------------------------------- | --------- | ----------------------------------------------------------------------- |
| Data separated from views | Yes | Store holds entries; ViewManager holds UI proxies |
| Ref-counted queries | Yes | Efficient global state queries without scanning |
| Graph-scoped lifecycle | Yes | `clearGraph(graphId)` |
| View reconciliation | Partially | ViewManager is a system-like layer, but tightly coupled to SubgraphNode |
| SubgraphNode drives mutations | **No** | Entity class calls `store.setPromotions()` directly |
| BaseWidget queries store in render | **No** | `getOutlineColor()` calls `isPromotedByAny()` every frame |
## 4. LayoutStore (CRDT)
@@ -186,8 +208,8 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
1. **Plain data objects**: `WidgetState`, `DomWidgetState`, CRDT maps are all methods-free data
2. **Centralized registries**: Each store is a `Map<key, data>` — structurally identical to an ECS component store
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PreviewExposureStore)
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
3. **Graph-scoped lifecycle**: `clearGraph(graphId)` for cleanup (WidgetValueStore, PromotionStore)
4. **Query APIs**: `getWidget()`, `isPromotedByAny()`, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
@@ -200,7 +222,7 @@ graph TD
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, preview exposures)"]
(getWidget, isPromotedByAny)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
(LayoutStore)"]
@@ -227,12 +249,13 @@ graph TD
Each store invents its own identity scheme:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| PromotionStore | `"${sourceNodeId}:${widgetName}"` | NodeId (string-coerced) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
@@ -266,6 +289,7 @@ graph TD
- value → WidgetValueStore
- label → WidgetValueStore
- disabled → WidgetValueStore
- promotion status → PromotionStore
- DOM pos/vis → DomWidgetStore"]
W_rem["Remains on class:
- _node back-ref
@@ -309,8 +333,7 @@ graph TD
subgraph Subgraph["Subgraph (node component)"]
S_ext["Extracted:
- value exposure → linked inputs
- preview exposure → PreviewExposureStore"]
- promotions → PromotionStore"]
S_rem["Remains on class:
- name, description
- inputs[], outputs[]
@@ -337,15 +360,15 @@ graph TD
What each entity needs to reach the ECS target from [ADR 0008](../adr/0008-entity-component-system.md):
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promoted value exposure (linked inputs); preview exposure (PreviewExposureStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
| Entity | Already Extracted | Still on Class | ECS Target Components | Gap |
| ------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| **Node** | pos, size (LayoutStore) | type, visual, connectivity, execution, properties, widgets, rendering, serialization | Position, NodeVisual, NodeType, Connectivity, Execution, Properties, WidgetContainer | Large — 6 components unextracted, all behavior on class |
| **Link** | layout (LayoutStore) | endpoints, visual, state, connectivity methods | LinkEndpoints, LinkVisual, LinkState | Medium — 3 components unextracted |
| **Widget** | value, label, disabled (WidgetValueStore); promotion (PromotionStore); DOM state (DomWidgetStore) | node back-ref, rendering, events, layout | WidgetIdentity, WidgetValue, WidgetLayout | Small — value extraction done; rendering and layout remain |
| **Slot** | (nothing) | name, type, direction, link refs, visual, position | SlotIdentity, SlotConnection, SlotVisual | Full — no extraction started |
| **Reroute** | pos (LayoutStore) | links, visual, chain traversal | Position, RerouteLinks, RerouteVisual | Medium — position done, rest unextracted |
| **Group** | (nothing) | pos, size, meta, visual, children | Position, GroupMeta, GroupVisual, GroupChildren | Full — no extraction started |
| **Subgraph** | promotions (PromotionStore) | structure, meta, I/O, all LGraph state | SubgraphStructure, SubgraphMeta (as node components) | Large — mostly unextracted; subgraph is a node with components, not a separate entity kind |
### Priority Order for Extraction

View File

@@ -373,8 +373,7 @@ export default defineConfig([
files: [
'src/base/**/*.{ts,vue}',
'src/platform/**/*.{ts,vue}',
'src/workbench/**/*.{ts,vue}',
'src/world/**/*.{ts,vue}'
'src/workbench/**/*.{ts,vue}'
],
rules: {
'import-x/no-restricted-paths': [
@@ -402,12 +401,6 @@ export default defineConfig([
from: './src/renderer/**',
message:
'workbench/ cannot import from renderer/ (violates layer architecture: base → platform → workbench → renderer)'
},
{
target: './src/world/**',
from: './src/lib/litegraph/**',
message:
'src/world/ must remain free of litegraph dependencies. The world layer owns canonical entity identity and must not depend on litegraph types or values.'
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.46.3",
"version": "1.46.2",
"private": true,
"description": "Official front-end implementation of ComfyUI",
"homepage": "https://comfy.org",

View File

@@ -8,9 +8,7 @@ import AppModeWidgetList from '@/components/builder/AppModeWidgetList.vue'
import DraggableList from '@/components/common/DraggableList.vue'
import IoItem from '@/components/builder/IoItem.vue'
import PropertiesAccordionItem from '@/components/rightSidePanel/layout/PropertiesAccordionItem.vue'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import type { ResolvedSelection } from '@/components/builder/useResolvedSelectedInputs'
import type { WidgetEntityId } from '@/world/entityIds'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LGraphCanvas } from '@/lib/litegraph/src/LGraphCanvas'
@@ -30,6 +28,7 @@ import { DOMWidgetImpl } from '@/scripts/domWidget'
import { renameWidget } from '@/utils/widgetUtil'
import { useAppMode } from '@/composables/useAppMode'
import { nodeTypeValidForApp, useAppModeStore } from '@/stores/appModeStore'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
type BoundStyle = { top: string; left: string; width: string; height: string }
@@ -48,8 +47,26 @@ const hoveringSelectable = ref(false)
workflowStore.activeWorkflow?.changeTracker?.reset()
const resolvedInputs = useResolvedSelectedInputs()
const inputsWithState = computed(() =>
appModeStore.selectedInputs.map(([nodeId, widgetName]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) {
return {
nodeId,
widgetName,
subLabel: t('linearMode.builder.unknownWidget')
}
}
return {
nodeId,
widgetName,
label: widget.label,
subLabel: node.title,
canRename: true
}
})
)
const outputsWithState = computed<[NodeId, string][]>(() =>
appModeStore.selectedOutputs.map((nodeId) => [
nodeId,
@@ -57,6 +74,16 @@ const outputsWithState = computed<[NodeId, string][]>(() =>
])
)
function inlineRenameInput(
nodeId: NodeId,
widgetName: string,
newLabel: string
) {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node || !widget) return
renameWidget(widget, node, newLabel)
}
function getHovered(
e: MouseEvent
): undefined | [LGraphNode, undefined] | [LGraphNode, IBaseWidget] {
@@ -75,26 +102,22 @@ function getHovered(
if (widget || node.constructor.nodeData?.output_node) return [node, widget]
}
function getNodeBounding(nodeId: NodeId) {
function getBounding(nodeId: NodeId, widgetName?: string) {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
const node = app.rootGraph.getNodeById(nodeId)
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!node) return
const titleOffset =
node.title_mode === TitleMode.NORMAL_TITLE ? LiteGraph.NODE_TITLE_HEIGHT : 0
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
}
function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
if (settingStore.get('Comfy.VueNodes.Enabled')) return undefined
if (entry.status !== 'resolved') return undefined
const { node, widget } = entry
if (!widgetName)
return {
width: `${node.size[0]}px`,
height: `${node.size[1] + titleOffset}px`,
left: `${node.pos[0]}px`,
top: `${node.pos[1] - titleOffset}px`
}
if (!widget) return
const margin = widget instanceof DOMWidgetImpl ? widget.margin : undefined
const marginX = margin ?? BaseWidget.margin
@@ -110,11 +133,6 @@ function getWidgetBounding(entry: ResolvedSelection): BoundStyle | undefined {
}
}
function removeSelectedEntityId(entityId: WidgetEntityId): void {
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index !== -1) appModeStore.selectedInputs.splice(index, 1)
}
function handleDown(e: MouseEvent) {
const [node] = getHovered(e) ?? []
if (!node || e.button > 0) canvasInteractions.forwardEventToCanvas(e)
@@ -139,11 +157,14 @@ function handleClick(e: MouseEvent) {
}
if (!isSelectInputsMode.value || widget.options.canvasOnly) return
const entityId = widget.entityId
if (!entityId) return
const index = appModeStore.selectedInputs.findIndex(([id]) => id === entityId)
if (index === -1)
appModeStore.selectedInputs.push([entityId, widget.name, undefined])
const storeId = isPromotedWidgetView(widget) ? widget.sourceNodeId : node.id
const storeName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
const index = appModeStore.selectedInputs.findIndex(
([nodeId, widgetName]) => storeId == nodeId && storeName === widgetName
)
if (index === -1) appModeStore.selectedInputs.push([storeId, storeName])
else appModeStore.selectedInputs.splice(index, 1)
}
@@ -152,7 +173,7 @@ function nodeToDisplayTuple(
): [NodeId, MaybeRef<BoundStyle> | undefined, boolean] {
return [
n.id,
getNodeBounding(n.id),
getBounding(n.id),
appModeStore.selectedOutputs.some((id) => n.id === id)
]
}
@@ -170,13 +191,10 @@ const renderedOutputs = computed(() => {
})
const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
() =>
resolvedInputs.value.map(
(entry) =>
[entry.entityId, getWidgetBounding(entry)] as [
string,
MaybeRef<BoundStyle> | undefined
]
)
appModeStore.selectedInputs.map(([nodeId, widgetName]) => [
`${nodeId}: ${widgetName}`,
getBounding(nodeId, widgetName)
])
)
</script>
<template>
@@ -220,28 +238,30 @@ const renderedInputs = computed<[string, MaybeRef<BoundStyle> | undefined][]>(
v-slot="{ dragClass }"
v-model="appModeStore.selectedInputs"
>
<template v-for="entry in resolvedInputs" :key="entry.entityId">
<IoItem
v-if="entry.status === 'resolved'"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.widget.label ?? entry.displayName"
:sub-title="entry.node.title"
can-rename
:remove="() => appModeStore.removeSelectedInput(entry.widget)"
@rename="renameWidget(entry.widget, entry.node, $event)"
/>
<IoItem
v-else
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="entry.displayName"
:sub-title="t('linearMode.builder.unknownWidget')"
:remove="() => removeSelectedEntityId(entry.entityId)"
/>
</template>
<IoItem
v-for="{
nodeId,
widgetName,
label,
subLabel,
canRename
} in inputsWithState"
:key="`${nodeId}: ${widgetName}`"
:class="
cn(dragClass, 'my-2 rounded-lg bg-primary-background/30 p-2')
"
:title="label ?? widgetName"
:sub-title="subLabel"
:can-rename="canRename"
:remove="
() =>
remove(
appModeStore.selectedInputs,
([id, name]) => nodeId == id && widgetName === name
)
"
@rename="inlineRenameInput(nodeId, widgetName, $event)"
/>
</DraggableList>
</PropertiesAccordionItem>
<div

View File

@@ -1,15 +1,16 @@
<script setup lang="ts">
import { computed, provide } from 'vue'
import { useEventListener } from '@vueuse/core'
import { computed, provide, shallowRef } from 'vue'
import { useAppModeWidgetResizing } from '@/components/builder/useAppModeWidgetResizing'
import { useResolvedSelectedInputs } from '@/components/builder/useResolvedSelectedInputs'
import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useMaskEditor } from '@/composables/maskeditor/useMaskEditor'
@@ -22,12 +23,15 @@ import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useAppModeStore } from '@/stores/appModeStore'
import { parseImageWidgetValue } from '@/utils/imageUtil'
import { resolveNodeWidget } from '@/utils/litegraphUtil'
import { cn } from '@comfyorg/tailwind-utils'
import { HideLayoutFieldKey } from '@/types/widgetTypes'
import { promptRenameWidget } from '@/utils/widgetUtil'
interface WidgetEntry {
key: string
nodeId: NodeId
widgetName: string
persistedHeight: number | undefined
nodeData: ReturnType<typeof nodeToNodeData> & {
widgets: NonNullable<ReturnType<typeof nodeToNodeData>['widgets']>
@@ -45,25 +49,31 @@ const executionErrorStore = useExecutionErrorStore()
const appModeStore = useAppModeStore()
const maskEditor = useMaskEditor()
const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
appModeStore.updateInputConfig(widget, config)
const { onPointerDown } = useAppModeWidgetResizing(
(nodeId, widgetName, config) =>
appModeStore.updateInputConfig(nodeId, widgetName, config)
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()
const graphNodes = shallowRef<LGraphNode[]>(app.rootGraph.nodes)
useEventListener(
app.rootGraph.events,
'configured',
() => (graphNodes.value = app.rootGraph.nodes)
)
const mappedSelections = computed((): WidgetEntry[] => {
void graphNodes.value
const nodeDataByNode = new Map<
LGraphNode,
ReturnType<typeof nodeToNodeData>
>()
return resolvedInputs.value.flatMap((entry) => {
if (entry.status !== 'resolved') return []
const { entityId, node, widget, config } = entry
if (node.mode !== LGraphEventMode.ALWAYS) return []
return appModeStore.selectedInputs.flatMap(([nodeId, widgetName, config]) => {
const [node, widget] = resolveNodeWidget(nodeId, widgetName)
if (!widget || !node || node.mode !== LGraphEventMode.ALWAYS) return []
if (!nodeDataByNode.has(node)) {
nodeDataByNode.set(node, nodeToNodeData(node))
@@ -72,7 +82,15 @@ const mappedSelections = computed((): WidgetEntry[] => {
const matchingWidget = fullNodeData.widgets?.find((vueWidget) => {
if (vueWidget.slotMetadata?.linked) return false
return vueWidget.entityId === entityId
if (!node.isSubgraphNode()) return vueWidget.name === widget.name
const storeNodeId = vueWidget.storeNodeId?.split(':')?.[1] ?? ''
return (
isPromotedWidgetView(widget) &&
widget.sourceNodeId == storeNodeId &&
widget.sourceWidgetName === vueWidget.storeName
)
})
if (!matchingWidget) return []
@@ -81,7 +99,9 @@ const mappedSelections = computed((): WidgetEntry[] => {
return [
{
key: entityId,
key: `${nodeId}:${widgetName}`,
nodeId,
widgetName,
persistedHeight: config?.height,
nodeData: {
...fullNodeData,
@@ -148,7 +168,14 @@ defineExpose({ handleDragDrop })
</script>
<template>
<div
v-for="{ key, persistedHeight, nodeData, action } in mappedSelections"
v-for="{
key,
nodeId,
widgetName,
persistedHeight,
nodeData,
action
} in mappedSelections"
:key
:class="
cn(
@@ -196,7 +223,8 @@ defineExpose({ handleDragDrop })
{
label: t('g.remove'),
icon: 'icon-[lucide--x]',
command: () => appModeStore.removeSelectedInput(action.widget)
command: () =>
appModeStore.removeSelectedInput(action.widget, action.node)
}
]"
>
@@ -225,7 +253,7 @@ defineExpose({ handleDragDrop })
)
"
:inert="builderMode || undefined"
@pointerdown.capture="(e) => onPointerDown(action.widget, e)"
@pointerdown.capture="(e) => onPointerDown(nodeId, widgetName, e)"
>
<DropZone
:on-drag-over="nodeData.onDragOver"

View File

@@ -1,15 +1,11 @@
import { describe, expect, it, vi } from 'vitest'
import { effectScope } from 'vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { useAppModeWidgetResizing } from './useAppModeWidgetResizing'
const WIDGET_PROMPT = { name: 'prompt' } as IBaseWidget
const WIDGET_OTHER = { name: 'other' } as IBaseWidget
const WIDGET_IMAGE = { name: 'image' } as IBaseWidget
function setHeight(el: HTMLElement, height: number) {
Object.defineProperty(el, 'offsetHeight', {
value: height,
@@ -32,13 +28,15 @@ function wrapWithTextarea(initialHeight = 100): {
describe('useAppModeWidgetResizing', () => {
function setup() {
const onResize =
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
const { onPointerDown } = useAppModeWidgetResizing(onResize)
function bind(wrapper: HTMLElement, widget: IBaseWidget) {
function bind(wrapper: HTMLElement, nodeId: NodeId, widgetName: string) {
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(widget, e as PointerEvent),
(e) => onPointerDown(nodeId, widgetName, e as PointerEvent),
{ capture: true }
)
}
@@ -49,19 +47,19 @@ describe('useAppModeWidgetResizing', () => {
it('persists height when textarea is resized via drag', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('does not persist when no height change occurs (e.g. a click)', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -72,7 +70,7 @@ describe('useAppModeWidgetResizing', () => {
it('persists once per drag gesture; stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -88,7 +86,7 @@ describe('useAppModeWidgetResizing', () => {
const button = document.createElement('button')
wrapper.appendChild(button)
document.body.appendChild(wrapper)
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
button.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
window.dispatchEvent(new PointerEvent('pointerup'))
@@ -106,21 +104,21 @@ describe('useAppModeWidgetResizing', () => {
wrapper.appendChild(indicator)
document.body.appendChild(wrapper)
setHeight(indicator, 100)
bind(wrapper, WIDGET_IMAGE)
bind(wrapper, 1 as NodeId, 'image')
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(indicator, 250)
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledWith(WIDGET_IMAGE, { height: 250 })
expect(onResize).toHaveBeenCalledWith(1, 'image', { height: 250 })
})
it('drops a stale gesture when a new pointerdown starts before pointerup arrives', () => {
const { bind, onResize } = setup()
const first = wrapWithTextarea()
const second = wrapWithTextarea()
bind(first.wrapper, WIDGET_PROMPT)
bind(second.wrapper, WIDGET_OTHER)
bind(first.wrapper, 1 as NodeId, 'prompt')
bind(second.wrapper, 2 as NodeId, 'other')
first.textarea.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true })
@@ -134,25 +132,25 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(WIDGET_OTHER, { height: 300 })
expect(onResize).toHaveBeenCalledWith(2, 'other', { height: 300 })
})
it('treats pointercancel as the end of a gesture and persists the new height', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
window.dispatchEvent(new PointerEvent('pointercancel'))
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('after pointercancel, a subsequent stray pointerup is a no-op', () => {
const { bind, onResize } = setup()
const { wrapper, textarea } = wrapWithTextarea()
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
textarea.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(textarea, 250)
@@ -161,12 +159,14 @@ describe('useAppModeWidgetResizing', () => {
window.dispatchEvent(new PointerEvent('pointerup'))
expect(onResize).toHaveBeenCalledTimes(1)
expect(onResize).toHaveBeenCalledWith(WIDGET_PROMPT, { height: 250 })
expect(onResize).toHaveBeenCalledWith(1, 'prompt', { height: 250 })
})
it('removes global listeners when the owning scope is disposed mid-gesture', () => {
const onResize =
vi.fn<(widget: IBaseWidget, config: InputWidgetConfig) => void>()
vi.fn<
(nodeId: NodeId, widgetName: string, config: InputWidgetConfig) => void
>()
const scope = effectScope()
const { onPointerDown } = scope.run(() =>
useAppModeWidgetResizing(onResize)
@@ -174,7 +174,7 @@ describe('useAppModeWidgetResizing', () => {
const { wrapper, textarea } = wrapWithTextarea()
wrapper.addEventListener(
'pointerdown',
(e) => onPointerDown(WIDGET_PROMPT, e as PointerEvent),
(e) => onPointerDown(1 as NodeId, 'prompt', e as PointerEvent),
{ capture: true }
)
@@ -199,7 +199,7 @@ describe('useAppModeWidgetResizing', () => {
outerIndicator.appendChild(wrapper)
document.body.appendChild(outerIndicator)
setHeight(outerIndicator, 100)
bind(wrapper, WIDGET_PROMPT)
bind(wrapper, 1 as NodeId, 'prompt')
inner.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
setHeight(outerIndicator, 250)

View File

@@ -1,12 +1,16 @@
import { onScopeDispose } from 'vue'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
const RESIZABLE_SELECTOR = 'textarea, [data-slot="drop-zone-indicator"]'
export function useAppModeWidgetResizing(
onResize: (widget: IBaseWidget, config: InputWidgetConfig) => void
onResize: (
nodeId: NodeId,
widgetName: string,
config: InputWidgetConfig
) => void
) {
let pendingHandler: (() => void) | null = null
@@ -19,7 +23,11 @@ export function useAppModeWidgetResizing(
onScopeDispose(clearPendingHandler)
function onPointerDown(widget: IBaseWidget, event: PointerEvent) {
function onPointerDown(
nodeId: NodeId,
widgetName: string,
event: PointerEvent
) {
const wrapper = event.currentTarget
const target = event.target
if (!(wrapper instanceof HTMLElement) || !(target instanceof HTMLElement))
@@ -36,7 +44,7 @@ export function useAppModeWidgetResizing(
pendingHandler = null
const height = resizable.offsetHeight
if (height === startHeight) return
onResize(widget, { height })
onResize(nodeId, widgetName, { height })
}
pendingHandler = handler
window.addEventListener('pointerup', handler)

View File

@@ -1,91 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { fromAny } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { useResolvedSelectedInputs } from './useResolvedSelectedInputs'
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
id: '11111111-1111-4111-8111-111111111111',
nodes: [] as LGraphNode[],
events: new EventTarget(),
getNodeById: vi.fn() as (id: number) => LGraphNode | null
}
}
}))
const rootGraphId = '11111111-1111-4111-8111-111111111111'
const entitySeed = `${rootGraphId}:1:seed` as WidgetEntityId
function makeNode(id: number, widgetNames: string[]): LGraphNode {
return fromAny<LGraphNode, unknown>({
id,
widgets: widgetNames.map((name) => ({
name,
entityId: `${rootGraphId}:${id}:${name}` as WidgetEntityId
}))
})
}
function setRootGraphNodes(nodes: LGraphNode[]) {
vi.mocked(app.rootGraph).nodes = nodes
vi.mocked(app.rootGraph).getNodeById = vi.fn(
(id) => nodes.find((n) => String(n.id) === String(id)) ?? null
)
}
function dispatchRootGraphEvent(type: string) {
;(app.rootGraph!.events as unknown as EventTarget).dispatchEvent(
new Event(type)
)
}
describe('useResolvedSelectedInputs', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
setRootGraphNodes([])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('re-resolves selections after a convert-to-subgraph event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('convert-to-subgraph')
expect(resolved.value[0]?.status).toBe('unknown')
})
it('re-resolves selections after a subgraph-created event removes nodes from the root graph', () => {
const node = makeNode(1, ['seed'])
setRootGraphNodes([node])
const appModeStore = useAppModeStore()
appModeStore.selectedInputs = [[entitySeed, 'seed']]
const resolved = useResolvedSelectedInputs()
expect(resolved.value[0]?.status).toBe('resolved')
setRootGraphNodes([])
dispatchRootGraphEvent('subgraph-created')
expect(resolved.value[0]?.status).toBe('unknown')
})
})

View File

@@ -1,71 +0,0 @@
import { useEventListener } from '@vueuse/core'
import { computed, shallowRef, triggerRef } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputWidgetConfig } from '@/platform/workflow/management/stores/comfyWorkflow'
import { app } from '@/scripts/app'
import { useAppModeStore } from '@/stores/appModeStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { isWidgetEntityId, parseWidgetEntityId } from '@/world/entityIds'
export type ResolvedSelection =
| {
status: 'resolved'
entityId: WidgetEntityId
node: LGraphNode
widget: IBaseWidget
displayName: string
config?: InputWidgetConfig
}
| {
status: 'unknown'
entityId: WidgetEntityId
displayName: string
config?: InputWidgetConfig
}
export function useResolvedSelectedInputs() {
const appModeStore = useAppModeStore()
const graphNodes = shallowRef<LGraphNode[]>([...(app.rootGraph?.nodes ?? [])])
const refreshGraphNodes = () =>
(graphNodes.value = [...(app.rootGraph?.nodes ?? [])])
useEventListener(() => app.rootGraph?.events, 'configured', refreshGraphNodes)
useEventListener(
() => app.rootGraph?.events,
'convert-to-subgraph',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'subgraph-created',
refreshGraphNodes
)
useEventListener(
() => app.rootGraph?.events,
'node:slot-label:changed',
() => triggerRef(graphNodes)
)
return computed<ResolvedSelection[]>(() => {
void graphNodes.value
const rootGraph = app.rootGraph
if (!rootGraph) return []
return appModeStore.selectedInputs.flatMap(
([entityId, displayName, config]): ResolvedSelection[] => {
if (!isWidgetEntityId(entityId)) return []
const { nodeId, name } = parseWidgetEntityId(entityId)
const node = rootGraph.getNodeById(nodeId)
const widget = node?.widgets?.find((w) => w.name === name)
if (!node || !widget) {
return [{ status: 'unknown', entityId, displayName, config }]
}
return [
{ status: 'resolved', entityId, node, widget, displayName, config }
]
}
)
})
}

View File

@@ -1,24 +1,14 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import {
computed,
inject,
onBeforeUnmount,
provide,
ref,
shallowRef,
watchEffect
} from 'vue'
import { computed, inject, provide, ref, shallowRef, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { isWidgetPromotedOnSubgraphNode } from '@/core/graph/subgraph/promotionUtils'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { LGraphGroup, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
@@ -64,74 +54,12 @@ const {
const collapse = defineModel<boolean>('collapse', { default: false })
const emit = defineEmits<{
reorder: [event: { fromIndex: number; toIndex: number }]
}>()
const widgetsContainer = ref<HTMLElement>()
const rootElement = ref<HTMLElement>()
const widgets = shallowRef(widgetsProp)
watchEffect(() => (widgets.value = widgetsProp))
const draggableList = ref<DraggableList | undefined>()
const isMounted = useMounted()
function setDraggableState() {
draggableList.value?.dispose()
draggableList.value = undefined
if (!isMounted.value || !isDraggable || collapse.value) return
const container = widgetsContainer.value
if (!container?.children?.length) return
const list = new DraggableList(container, '.draggable-item')
list.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[SectionWidgets] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
if (typeof reorderedItems[index] === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
emit('reorder', { fromIndex: oldPosition, toIndex: newPosition })
}
draggableList.value = list
}
watchDebounced(
[widgets, () => isDraggable, collapse],
() => setDraggableState(),
{ debounce: 100, immediate: true }
)
onBeforeUnmount(() => draggableList.value?.dispose())
provide(HideLayoutFieldKey, true)
const canvasStore = useCanvasStore()
@@ -142,6 +70,8 @@ const { t } = useI18n()
const getNodeParentGroup = inject(GetNodeParentGroupKey, null)
const promotionStore = usePromotionStore()
function isWidgetShownOnParents(
widgetNode: LGraphNode,
widget: IBaseWidget
@@ -153,12 +83,13 @@ function isWidgetShownOnParents(
? widget.sourceNodeId
: String(widgetNode.id)
return isWidgetPromotedOnSubgraphNode(parent, {
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: interiorNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
})
}
return isWidgetPromotedOnSubgraphNode(parent, {
return promotionStore.isPromoted(parent.rootGraph.id, parent.id, {
sourceNodeId: String(widgetNode.id),
sourceWidgetName: widget.name
})

View File

@@ -1,9 +1,18 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, shallowRef } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef
} from 'vue'
import { useI18n } from 'vue-i18n'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { useFavoritedWidgetsStore } from '@/stores/workspace/favoritedWidgetsStore'
import type { ValidFavoritedWidget } from '@/stores/workspace/favoritedWidgetsStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
@@ -16,6 +25,8 @@ const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const { t } = useI18n()
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = ref<{ widgetsContainer: HTMLElement }>()
const isSearching = ref(false)
const favoritedWidgets = computed(
@@ -37,20 +48,70 @@ async function searcher(query: string) {
searchedFavoritedWidgets.value = searchWidgets(favoritedWidgets.value, query)
}
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = [...searchedFavoritedWidgets.value]
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
const isMounted = useMounted()
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
const widgets = [...searchedFavoritedWidgets.value]
const [widget] = widgets.splice(oldPosition, 1)
widgets.splice(newPosition, 0, widget)
searchedFavoritedWidgets.value = widgets
favoritedWidgetsStore.reorderFavorites(widgets)
}
}
watchDebounced(
searchedFavoritedWidgets,
() => {
setDraggableState()
},
{ debounce: 100 }
)
onMounted(() => {
setDraggableState()
})
onBeforeUnmount(() => {
draggableList.value?.dispose()
})
function onCollapseUpdate() {
// Rebuild draggable list after the section header is toggled
nextTick(setDraggableState)
}
</script>
@@ -66,6 +127,7 @@ function handleReorder({
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:label
:widgets="searchedFavoritedWidgets"
:is-draggable="!isSearching"
@@ -73,7 +135,7 @@ function handleReorder({
show-node-name
enable-empty-state
class="border-b border-interface-stroke"
@reorder="handleReorder"
@update:collapse="onCollapseUpdate"
>
<template #empty>
<div class="px-4 py-10 text-center text-sm text-muted-foreground">

View File

@@ -1,19 +1,26 @@
<script setup lang="ts">
import { useMounted, watchDebounced } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, nextTick, ref, shallowRef, useTemplateRef, watch } from 'vue'
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useTemplateRef,
watch
} from 'vue'
import { useI18n } from 'vue-i18n'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
getWidgetName,
isWidgetPromotedOnSubgraphNode,
reorderSubgraphInputsByWidgetOrder
} from '@/core/graph/subgraph/promotionUtils'
import { getWidgetName } from '@/core/graph/subgraph/promotionUtils'
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 AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import CollapseToggleButton from '@/components/rightSidePanel/layout/CollapseToggleButton.vue'
import { DraggableList } from '@/scripts/ui/draggableList'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { searchWidgets } from '../shared'
@@ -26,6 +33,7 @@ const { node } = defineProps<{
const { t } = useI18n()
const canvasStore = useCanvasStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { focusedSection, searchQuery } = storeToRefs(rightSidePanelStore)
@@ -43,33 +51,13 @@ const isAllCollapsed = computed({
advancedInputsCollapsed.value = collapse
}
})
const draggableList = ref<DraggableList | undefined>(undefined)
const sectionWidgetsRef = useTemplateRef('sectionWidgetsRef')
const advancedInputsSectionRef = useTemplateRef('advancedInputsSectionRef')
function isSamePromotedWidget(a: IBaseWidget, b: IBaseWidget): boolean {
return (
isPromotedWidgetView(a) &&
isPromotedWidgetView(b) &&
a.sourceNodeId === b.sourceNodeId &&
a.sourceWidgetName === b.sourceWidgetName
)
}
function getPromotedWidgets(): IBaseWidget[] {
const inputWidgets = node.inputs
.map((input) => input._widget)
.filter((widget): widget is IBaseWidget =>
Boolean(widget && isPromotedWidgetView(widget))
)
const extraWidgets = (node.widgets ?? []).filter(
(widget) =>
isPromotedWidgetView(widget) &&
!inputWidgets.some((inputWidget) =>
isSamePromotedWidget(inputWidget, widget)
)
)
return [...inputWidgets, ...extraWidgets]
}
const promotionEntries = computed(() =>
promotionStore.getPromotions(node.rootGraph.id, node.id)
)
watch(
focusedSection,
@@ -93,7 +81,37 @@ watch(
)
const widgetsList = computed((): NodeWidgetsList => {
return getPromotedWidgets().map((widget) => ({ node, widget }))
const entries = promotionEntries.value
const { widgets = [] } = node
const result: NodeWidgetsList = []
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 (
(w.disambiguatingSourceNodeId ?? w.sourceNodeId) ===
disambiguatingSourceNodeId
)
}
return w.name === sourceWidgetName
})
if (widget) {
result.push({ node, widget })
}
}
return result
})
const advancedInputsWidgets = computed((): NodeWidgetsList => {
@@ -108,9 +126,12 @@ const advancedInputsWidgets = computed((): NodeWidgetsList => {
return allInteriorWidgets.filter(
({ node: interiorNode, widget }) =>
!isWidgetPromotedOnSubgraphNode(node, {
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(widget)
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
})
)
})
@@ -125,22 +146,66 @@ async function searcher(query: string) {
searchedWidgetsList.value = searchWidgets(widgetsList.value, query)
}
function handleReorder({
fromIndex,
toIndex
}: {
fromIndex: number
toIndex: number
}) {
const widgets = searchedWidgetsList.value.map((row) => row.widget)
const [moved] = widgets.splice(fromIndex, 1)
if (!moved) return
widgets.splice(toIndex, 0, moved)
const isMounted = useMounted()
reorderSubgraphInputsByWidgetOrder(node, widgets)
canvasStore.canvas?.setDirty(true, true)
function setDraggableState() {
if (!isMounted.value) return
draggableList.value?.dispose()
const container = sectionWidgetsRef.value?.widgetsContainer
if (isSearching.value || !container?.children?.length) return
draggableList.value = new DraggableList(container, '.draggable-item')
draggableList.value.applyNewItemsOrder = function () {
const reorderedItems: HTMLElement[] = []
let oldPosition = -1
this.getAllItems().forEach((item, index) => {
if (item === this.draggableItem) {
oldPosition = index
return
}
if (!this.isItemToggled(item)) {
reorderedItems[index] = item
return
}
const newIndex = this.isItemAbove(item) ? index + 1 : index - 1
reorderedItems[newIndex] = item
})
if (oldPosition === -1) {
console.error('[TabSubgraphInputs] draggableItem not found in items')
return
}
for (let index = 0; index < this.getAllItems().length; index++) {
const item = reorderedItems[index]
if (typeof item === 'undefined') {
reorderedItems[index] = this.draggableItem as HTMLElement
}
}
const newPosition = reorderedItems.indexOf(
this.draggableItem as HTMLElement
)
promotionStore.movePromotion(
node.rootGraph.id,
node.id,
oldPosition,
newPosition
)
canvasStore.canvas?.setDirty(true, true)
}
}
watchDebounced(searchedWidgetsList, () => setDraggableState(), {
debounce: 100
})
onMounted(() => setDraggableState())
onBeforeUnmount(() => draggableList.value?.dispose())
const label = computed(() => {
return searchedWidgetsList.value.length !== 0
? t('rightSidePanel.inputs')
@@ -164,6 +229,7 @@ const label = computed(() => {
/>
</div>
<SectionWidgets
ref="sectionWidgetsRef"
:collapse="firstSectionCollapsed && !isSearching"
:node
:label
@@ -177,8 +243,12 @@ const label = computed(() => {
: t('rightSidePanel.inputsNoneTooltip')
"
class="border-b border-interface-stroke"
@update:collapse="(v) => (firstSectionCollapsed = v)"
@reorder="handleReorder"
@update:collapse="
(v) => {
firstSectionCollapsed = v
nextTick(setDraggableState)
}
"
>
<template #empty>
<div class="px-4 pt-5 pb-15 text-center text-sm text-muted-foreground">

View File

@@ -9,19 +9,15 @@ 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'
const { mockGetInputSpecForWidget } = vi.hoisted(() => ({
mockGetInputSpecForWidget: vi.fn()
}))
vi.mock('@/core/graph/subgraph/promotionUtils', () => ({
demoteWidget: vi.fn(),
promoteWidget: vi.fn(),
isLinkedPromotion: vi.fn(() => false)
}))
vi.mock('@/stores/nodeDefStore', () => ({
useNodeDefStore: () => ({
getInputSpecForWidget: mockGetInputSpecForWidget
@@ -205,4 +201,64 @@ describe('WidgetActions', () => {
expect(onResetToDefault).toHaveBeenCalledWith('option1')
})
it('demotes promoted widgets by immediate interior node identity when shown from parent context', async () => {
mockGetInputSpecForWidget.mockReturnValue({
type: 'CUSTOM'
})
const parentSubgraphNode = fromAny<SubgraphNode, unknown>({
id: 4,
rootGraph: { id: 'graph-test' },
computeSize: vi.fn(),
size: [300, 150]
})
const node = fromAny<LGraphNode, unknown>({
id: 4,
type: 'SubgraphNode',
rootGraph: { id: 'graph-test' },
isSubgraphNode: () => false
})
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 user = userEvent.setup()
render(WidgetActions, {
props: {
widget,
node,
label: 'Text',
parents: [parentSubgraphNode],
isShownOnParents: true
},
global: {
plugins: [i18n]
}
})
await user.click(screen.getByRole('button', { name: /Hide input/ }))
expect(
promotionStore.isPromoted('graph-test', 4, {
sourceNodeId: '3',
sourceWidgetName: 'text',
disambiguatingSourceNodeId: '1'
})
).toBe(false)
})
})

View File

@@ -5,6 +5,7 @@ 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 {
demoteWidget,
@@ -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)
@@ -79,19 +82,16 @@ function handleHideInput() {
if (isPromotedWidgetView(widget)) {
for (const parent of parents) {
const sourceNodeId =
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id)
demoteWidget(
{
id: sourceNodeId,
title: node.title,
type: node.type
},
widget,
[parent]
)
const source: PromotedWidgetSource = {
sourceNodeId:
String(node.id) === String(parent.id)
? widget.sourceNodeId
: String(node.id),
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
promotionStore.demote(parent.rootGraph.id, parent.id, source)
parent.computeSize(parent.size)
}
canvasStore.canvas?.setDirty(true, true)
} else {

View File

@@ -42,7 +42,7 @@ vi.mock('@/composables/graph/useGraphNodeManager', () => ({
getControlWidget: vi.fn(() => undefined)
}))
vi.mock('@/core/graph/subgraph/resolveConcretePromotedWidget', () => ({
vi.mock('@/core/graph/subgraph/resolvePromotedWidgetSource', () => ({
resolvePromotedWidgetSource: vi.fn(() => undefined)
}))

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n'
import EditableText from '@/components/common/EditableText.vue'
import { getControlWidget } from '@/composables/graph/useGraphNodeManager'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'

View File

@@ -1,265 +0,0 @@
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import {
createTestSubgraph,
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { promoteValueWidgetViaSubgraphInput } from '@/core/graph/subgraph/promotionUtils'
import SubgraphEditor from './SubgraphEditor.vue'
import type { ComponentProps } from 'vue-component-type-helpers'
import type DraggableList from '@/components/common/DraggableList.vue'
type DraggableListProps = ComponentProps<typeof DraggableList>
type PromotedRow =
DraggableListProps['modelValue'] extends Array<infer T> ? T : never
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: vi.fn() })
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
subgraphStore: {
shown: 'Shown',
hidden: 'Hidden',
hideAll: 'Hide all',
showAll: 'Show all',
addRecommended: 'Add recommended'
},
rightSidePanel: {
noneSearchDesc: 'No results'
},
g: {
search: 'Search',
searchPlaceholder: 'Search'
}
}
}
})
describe('SubgraphEditor', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.clearAllMocks()
})
it('renders preview exposures after promoted inputs without drag handles', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
const previewNode = new LGraphNode('PreviewImage')
previewNode.type = 'PreviewImage'
subgraph.add(firstNode)
subgraph.add(secondNode)
subgraph.add(previewNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
usePreviewExposureStore().addExposure(
subgraph.rootGraph.id,
String(host.id),
{
sourceNodeId: String(previewNode.id),
sourcePreviewName: '$$canvas-image-preview'
}
)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second', '$$canvas-image-preview'])
expect(
within(screen.getByTestId('draggable-list'))
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
expect(
within(shown).getAllByTestId('subgraph-widget-drag-handle')
).toHaveLength(2)
})
it('updates rendered order when promoted widgets are reordered', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
let listSetter: ((value: PromotedRow[]) => void) | undefined
const draggableListStub = {
props: ['modelValue'],
emits: ['update:modelValue'],
setup(
_: unknown,
{
emit,
slots
}: {
emit: (event: string, ...args: unknown[]) => void
slots: { default?: (props: { dragClass: string }) => unknown }
}
) {
listSetter = (value) => emit('update:modelValue', value)
return () => slots.default?.({ dragClass: 'draggable-item' })
}
}
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: { DraggableList: draggableListStub }
}
})
await nextTick()
const shown = screen.getByTestId('subgraph-editor-shown-section')
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['first', 'second'])
const promotedWidgets = host.widgets.filter(isPromotedWidgetView)
const reversed = [
{ kind: 'promoted', node: secondNode, widget: promotedWidgets[1] },
{ kind: 'promoted', node: firstNode, widget: promotedWidgets[0] }
] as PromotedRow[]
listSetter?.(reversed)
await nextTick()
expect(
within(shown)
.getAllByTestId('subgraph-widget-label')
.map((el) => el.textContent?.trim())
).toEqual(['second', 'first'])
})
it('demotes linked promoted widgets when "Hide all" is clicked', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('FirstNode')
const secondNode = new LGraphNode('SecondNode')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(2)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const hideAllLink = within(shown).getByText('Hide all')
await userEvent.click(hideAllLink)
expect(host.widgets.filter(isPromotedWidgetView)).toHaveLength(0)
})
it('removes the exposure when a preview row without a real source widget is demoted', async () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const orphanedSourceNode = new LGraphNode('OrphanedNode')
orphanedSourceNode.type = 'OrphanedNode'
subgraph.add(orphanedSourceNode)
const previewStore = usePreviewExposureStore()
previewStore.addExposure(subgraph.rootGraph.id, String(host.id), {
sourceNodeId: String(orphanedSourceNode.id),
sourcePreviewName: '$$canvas-image-preview'
})
useCanvasStore().selectedItems = [host]
render(SubgraphEditor, {
container: document.body.appendChild(document.createElement('div')),
global: {
plugins: [i18n],
stubs: {
DraggableList: {
template:
'<div data-testid="draggable-list"><slot drag-class="draggable-item" /></div>'
}
}
}
})
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(1)
const shown = screen.getByTestId('subgraph-editor-shown-section')
const toggleButton = within(shown).getByTestId('subgraph-widget-toggle')
await userEvent.click(toggleButton)
expect(
previewStore.getExposures(subgraph.rootGraph.id, String(host.id))
).toHaveLength(0)
})
})

View File

@@ -1,141 +1,106 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, onMounted, shallowRef, watch } from 'vue'
import { computed, onMounted } from 'vue'
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 type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
demoteWidget,
getPromotableWidgets,
getSourceNodeId,
getWidgetName,
isLinkedPromotion,
isRecommendedWidget,
promoteWidget,
pruneDisconnected,
reorderSubgraphInputsByWidgetOrder
pruneDisconnected
} from '@/core/graph/subgraph/promotionUtils'
import type { WidgetItem } from '@/core/graph/subgraph/promotionUtils'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useRightSidePanelStore } from '@/stores/workspace/rightSidePanelStore'
import { cn } from '@comfyorg/tailwind-utils'
import SubgraphNodeWidget from './SubgraphNodeWidget.vue'
type PromotedRow = {
kind: 'promoted'
node: LGraphNode
widget: PromotedWidgetView
}
type PreviewRow = {
kind: 'preview'
node: LGraphNode
exposure: PreviewExposure
realWidget?: IBaseWidget
}
type ActiveRow = PromotedRow | PreviewRow
const { t } = useI18n()
const canvasStore = useCanvasStore()
const previewExposureStore = usePreviewExposureStore()
const promotionStore = usePromotionStore()
const rightSidePanelStore = useRightSidePanelStore()
const { searchQuery } = storeToRefs(rightSidePanelStore)
const promotionEntries = computed(() => {
const node = activeNode.value
if (!node) return []
return promotionStore.getPromotions(node.rootGraph.id, node.id)
})
const activeNode = computed(() => {
const node = canvasStore.selectedItems[0]
if (node instanceof SubgraphNode) return node
return undefined
})
const promotedWidgets = shallowRef<readonly IBaseWidget[]>([])
function refreshPromotedWidgets() {
promotedWidgets.value = activeNode.value?.widgets ?? []
}
watch(activeNode, refreshPromotedWidgets, { immediate: true })
useEventListener(
() => activeNode.value?.subgraph.events,
[
'widget-promoted',
'widget-demoted',
'input-added',
'removing-input',
'inputs-reordered'
],
refreshPromotedWidgets
)
const activeRows = computed<ActiveRow[]>(() => {
const node = activeNode.value
if (!node) return []
return [...getActivePromotedRows(node), ...getActivePreviewRows(node)]
})
const activePromotedRows = computed<PromotedRow[]>({
const activeWidgets = computed<WidgetItem[]>({
get() {
const node = activeNode.value
return node ? getActivePromotedRows(node) : []
if (!node) return []
return promotionEntries.value.flatMap(
({
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[sourceNodeId]
if (!wNode) return []
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]]
}
)
},
set(value: PromotedRow[]) {
updateActivePromotedRows(value, activePromotedRows.value)
set(value: WidgetItem[]) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
promotionStore.setPromotions(
node.rootGraph.id,
node.id,
value.map(([n, w]) => ({
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
}))
)
refreshPromotedWidgetRendering()
}
})
function getActivePromotedRows(node: SubgraphNode): PromotedRow[] {
return promotedWidgets.value.flatMap((widget): PromotedRow[] => {
if (!isPromotedWidgetView(widget)) return []
const sourceNode = node.subgraph._nodes_by_id[widget.sourceNodeId]
if (!sourceNode) return []
return [{ kind: 'promoted', node: sourceNode, widget }]
})
}
function getActivePreviewRows(node: SubgraphNode): PreviewRow[] {
const hostLocator = String(node.id)
const rootGraphId = node.rootGraph.id
const exposures = previewExposureStore.getExposures(rootGraphId, hostLocator)
return exposures.flatMap((exposure): PreviewRow[] => {
const sourceNode = node.subgraph._nodes_by_id[exposure.sourceNodeId]
if (!sourceNode) return []
const realWidget = getPromotableWidgets(sourceNode).find(
(candidate) => candidate.name === exposure.sourcePreviewName
)
return [{ kind: 'preview', node: sourceNode, exposure, realWidget }]
})
}
function updateActivePromotedRows(
value: PromotedRow[],
currentItems: PromotedRow[]
) {
const node = activeNode.value
if (!node) {
console.error('Attempted to toggle widgets with no node selected')
return
}
const currentKeys = new Set(currentItems.map(promotedRowKey))
const nextKeys = new Set(value.map(promotedRowKey))
for (const item of value) {
if (!currentKeys.has(promotedRowKey(item))) promotePromotedRow(item)
}
for (const item of currentItems) {
if (!nextKeys.has(promotedRowKey(item))) demoteRow(item)
}
if (currentKeys.size === nextKeys.size) {
reorderSubgraphInputsByWidgetOrder(
node,
value.map((row) => row.widget)
)
}
refreshPromotedWidgetRendering()
}
const interiorWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
@@ -150,18 +115,18 @@ const interiorWidgets = computed<WidgetItem[]>(() => {
.filter(([_, w]: WidgetItem) => !w.computedDisabled)
})
function activeRowSourceKey(row: ActiveRow): string {
return row.kind === 'promoted'
? `${row.widget.sourceNodeId}:${row.widget.sourceWidgetName}`
: `${row.exposure.sourceNodeId}:${row.exposure.sourcePreviewName}`
}
const candidateWidgets = computed<WidgetItem[]>(() => {
const node = activeNode.value
if (!node) return []
const promotedSourceKeys = new Set(activeRows.value.map(activeRowSourceKey))
return interiorWidgets.value.filter(
([n, w]) => !promotedSourceKeys.has(`${n.id}:${w.name}`)
([n, w]: WidgetItem) =>
!promotionStore.isPromoted(node.rootGraph.id, node.id, {
sourceNodeId: String(n.id),
sourceWidgetName: getWidgetName(w),
disambiguatingSourceNodeId: isPromotedWidgetView(w)
? w.disambiguatingSourceNodeId
: undefined
})
)
})
const filteredCandidates = computed<WidgetItem[]>(() => {
@@ -180,31 +145,16 @@ const recommendedWidgets = computed(() => {
return filteredCandidates.value.filter(isRecommendedWidget)
})
function rowMatchesQuery(row: ActiveRow, query: string): boolean {
return (
row.node.title.toLowerCase().includes(query) ||
rowDisplayName(row).toLowerCase().includes(query)
)
}
const filteredActive = computed<ActiveRow[]>(() => {
const filteredActive = computed<WidgetItem[]>(() => {
const query = searchQuery.value.toLowerCase()
if (!query) return activeRows.value
return activeRows.value.filter((row) => rowMatchesQuery(row, query))
if (!query) return activeWidgets.value
return activeWidgets.value.filter(
([n, w]: WidgetItem) =>
n.title.toLowerCase().includes(query) ||
w.name.toLowerCase().includes(query)
)
})
const filteredActivePromoted = computed<PromotedRow[]>(() =>
filteredActive.value.filter(
(row): row is PromotedRow => row.kind === 'promoted'
)
)
const filteredActivePreviews = computed<PreviewRow[]>(() =>
filteredActive.value.filter(
(row): row is PreviewRow => row.kind === 'preview'
)
)
function refreshPromotedWidgetRendering() {
const node = activeNode.value
if (!node) return
@@ -214,89 +164,57 @@ function refreshPromotedWidgetRendering() {
canvasStore.canvas?.setDirty(true, true)
}
function rowDisplayName(row: ActiveRow): string {
if (row.kind === 'promoted') {
return row.widget.label || row.widget.name
}
function isItemLinked([node, widget]: WidgetItem): boolean {
return (
row.realWidget?.label ||
row.realWidget?.name ||
row.exposure.sourcePreviewName
node.id === -1 ||
(!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(node.id),
getWidgetName(widget)
))
)
}
function isRowLinked(row: ActiveRow): boolean {
if (row.kind !== 'promoted') return false
if (row.node.id === -1) return true
return (
!!activeNode.value &&
isLinkedPromotion(
activeNode.value,
String(row.node.id),
row.widget.sourceWidgetName
)
)
function toKey(item: WidgetItem) {
const sid = getSourceNodeId(item[1])
return sid
? `${item[0].id}: ${item[1].name}:${sid}`
: `${item[0].id}: ${item[1].name}`
}
function promotedRowKey(row: PromotedRow): string {
return `${row.node.id}: ${row.widget.name}:${row.widget.sourceNodeId}`
}
function rowKey(row: ActiveRow): string {
return row.kind === 'promoted'
? promotedRowKey(row)
: `${row.node.id}: ${row.exposure.name}`
}
function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w) => [n, w])
}
function demoteRow(row: ActiveRow) {
function demote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
if (row.kind === 'promoted') {
demoteWidget(row.node, row.widget, [subgraphNode])
return
}
if (row.realWidget) {
demoteWidget(row.node, row.realWidget, [subgraphNode])
return
}
previewExposureStore.removeExposure(
subgraphNode.rootGraph.id,
String(subgraphNode.id),
row.exposure.name
)
refreshPromotedWidgetRendering()
demoteWidget(node, widget, [subgraphNode])
}
function promotePromotedRow(row: PromotedRow) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(row.node, row.widget, [subgraphNode])
}
function promoteCandidate([node, widget]: WidgetItem) {
function promote([node, widget]: WidgetItem) {
const subgraphNode = activeNode.value
if (!subgraphNode) return
promoteWidget(node, widget, [subgraphNode])
}
function showAll() {
for (const item of filteredCandidates.value) {
promoteCandidate(item)
promote(item)
}
}
function hideAll() {
for (const row of filteredActive.value) {
if (String(row.node.id) === '-1') continue
demoteRow(row)
const node = activeNode.value
for (const item of filteredActive.value) {
if (String(item[0].id) === '-1') continue
if (
node &&
isLinkedPromotion(node, String(item[0].id), getWidgetName(item[1]))
)
continue
demote(item)
}
}
function showRecommended() {
for (const item of recommendedWidgets.value) {
promoteCandidate(item)
promote(item)
}
}
@@ -341,34 +259,19 @@ onMounted(() => {
{{ $t('subgraphStore.hideAll') }}</a
>
</div>
<DraggableList v-slot="{ dragClass }" v-model="activePromotedRows">
<DraggableList v-slot="{ dragClass }" v-model="activeWidgets">
<SubgraphNodeWidget
v-for="row in filteredActivePromoted"
:key="rowKey(row)"
:data-nodeid="row.node.id"
v-for="[node, widget] in filteredActive"
:key="toKey([node, widget])"
:data-nodeid="node.id"
:class="cn(!searchQuery && dragClass, 'bg-comfy-menu-bg')"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:node-title="node.title"
:widget-name="widget.label || widget.name"
:is-physical="isItemLinked([node, widget])"
:is-draggable="!searchQuery"
is-shown
@toggle-visibility="demoteRow(row)"
@toggle-visibility="demote([node, widget])"
/>
</DraggableList>
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="row in filteredActivePreviews"
:key="rowKey(row)"
:data-nodeid="row.node.id"
class="bg-comfy-menu-bg"
:node-title="row.node.title"
:widget-name="rowDisplayName(row)"
:is-physical="isRowLinked(row)"
:is-draggable="false"
is-shown
@toggle-visibility="demoteRow(row)"
/>
</div>
</div>
<div
@@ -392,12 +295,12 @@ onMounted(() => {
<div class="mt-0.5 space-y-0.5 px-2 pb-2">
<SubgraphNodeWidget
v-for="[node, widget] in filteredCandidates"
:key="`${node.id}:${widget.name}`"
:key="toKey([node, widget])"
:data-nodeid="node.id"
class="bg-comfy-menu-bg"
:node-title="node.title"
:widget-name="widget.name"
@toggle-visibility="promoteCandidate([node, widget])"
@toggle-visibility="promote([node, widget])"
/>
</div>
</div>

View File

@@ -10,22 +10,22 @@ const {
widgetName,
isDraggable = false,
isPhysical = false,
isShown = false,
class: className
} = defineProps<{
nodeTitle: string
widgetName: string
isDraggable?: boolean
isPhysical?: boolean
isShown?: boolean
class?: ClassValue
}>()
defineEmits<{ toggleVisibility: [] }>()
defineEmits<{
(e: 'toggleVisibility'): void
}>()
const icon = computed(() =>
isPhysical
? 'icon-[lucide--link]'
: isShown
: isDraggable
? 'icon-[lucide--eye]'
: 'icon-[lucide--eye-off]'
)
@@ -65,7 +65,6 @@ const icon = computed(() =>
</Button>
<div
v-if="isDraggable"
data-testid="subgraph-widget-drag-handle"
class="pointer-events-none icon-[lucide--grip-vertical] size-4"
/>
</div>

View File

@@ -7,7 +7,6 @@ import { computed, nextTick, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { createPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetView'
import { BaseWidget, LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { widgetEntityId } from '@/world/entityIds'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -17,6 +16,7 @@ import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import { useSettingStore } from '@/platform/settings/settingStore'
import { app } from '@/scripts/app'
import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
describe('Node Reactivity', () => {
@@ -102,15 +102,12 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const input = node.addInput('prompt', 'STRING')
// Associate the input slot with the widget (as widgetInputs extension does)
input.widget = { name: 'prompt' }
// Start with a connected link
input.link = 42
graph.add(node)
const upstream = new LGraphNode('upstream')
upstream.addOutput('out', 'STRING')
graph.add(upstream)
const link = upstream.connect(0, node, 0)
if (!link) throw new Error('Expected upstream.connect to produce a link')
return { graph, node, upstream, linkId: link.id }
return { graph, node }
}
it('sets slotMetadata.linked to true when input has a link', () => {
@@ -190,28 +187,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('marks a widget input slot as linked when connected to a SubgraphInput', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'prompt', type: 'STRING' }]
})
const node = new LGraphNode('test')
node.addWidget('string', 'prompt', 'hello', () => undefined, {})
const input = node.addInput('prompt', 'STRING')
input.widget = { name: 'prompt' }
subgraph.add(node)
const link = subgraph.inputNode.slots[0].connect(input, node)
if (!link)
throw new Error('Expected SubgraphInput.connect to produce a link')
const { vueNodeData } = useGraphNodeManager(subgraph)
const nodeData = vueNodeData.get(String(node.id))
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData?.slotMetadata?.linked).toBe(true)
})
it('resolves slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', () => {
it('updates slotMetadata for promoted widgets where SafeWidgetData.name differs from input.widget.name', async () => {
// Set up a subgraph with an interior node that has a "prompt" widget.
// createPromotedWidgetView resolves against this interior node.
const subgraph = createTestSubgraph()
@@ -231,6 +207,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
'10',
'prompt',
'value',
undefined,
'value'
)
@@ -241,6 +218,7 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
hostNode.widgets = [promotedView]
const input = hostNode.addInput('value', 'STRING')
input.widget = { name: 'value' }
input.link = 42
graph.add(hostNode)
const { vueNodeData } = useGraphNodeManager(graph)
@@ -251,7 +229,21 @@ describe('Widget slotMetadata reactivity on link disconnect', () => {
const widgetData = nodeData?.widgets?.find((w) => w.name === 'prompt')
expect(widgetData).toBeDefined()
expect(widgetData?.slotName).toBe('value')
expect(widgetData?.slotMetadata).toBeDefined()
expect(widgetData?.slotMetadata?.linked).toBe(true)
// Disconnect
hostNode.inputs[0].link = null
graph.trigger('node:slot-links:changed', {
nodeId: hostNode.id,
slotType: NodeSlotType.INPUT,
slotIndex: 0,
connected: false,
linkId: 42
})
await nextTick()
expect(widgetData?.slotMetadata?.linked).toBe(false)
})
it('prefers exact _widget input matches before same-name fallbacks for promoted widgets', () => {
@@ -411,6 +403,37 @@ describe('Subgraph output slot label reactivity', () => {
})
})
describe('Subgraph Promoted Pseudo Widgets', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('marks promoted $$ widgets as canvasOnly for Vue widget rendering', () => {
const subgraph = createTestSubgraph()
const interiorNode = new LGraphNode('interior')
interiorNode.id = 10
subgraph.add(interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 123 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: '10',
sourceWidgetName: '$$canvas-image-preview'
})
const { vueNodeData } = useGraphNodeManager(graph)
const vueNode = vueNodeData.get(String(subgraphNode.id))
const promotedWidget = vueNode?.widgets?.find(
(widget) => widget.name === '$$canvas-image-preview'
)
expect(promotedWidget).toBeDefined()
expect(promotedWidget?.options?.canvasOnly).toBe(true)
})
})
describe('Nested promoted widget mapping', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -448,49 +471,122 @@ describe('Nested promoted widget mapping', () => {
expect(mappedWidget).toBeDefined()
expect(mappedWidget?.type).toBe('combo')
expect(mappedWidget?.entityId).toBe(
widgetEntityId(graph.id, subgraphNodeB.id, 'b_input')
expect(mappedWidget?.storeName).toBe('picker')
expect(mappedWidget?.storeNodeId).toBe(
`${subgraphNodeB.subgraph.id}:${innerNode.id}`
)
})
it('preserves distinct store identity for duplicate-named promoted widgets', () => {
it('keeps linked and independent same-name promotions as distinct sources', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_seed', type: '*' },
{ name: 'second_seed', type: '*' }
]
inputs: [{ name: 'string_a', type: '*' }]
})
const firstNode = new LGraphNode('FirstNode')
const firstInput = firstNode.addInput('seed', '*')
firstNode.addWidget('number', 'seed', 1, () => undefined)
firstInput.widget = { name: 'seed' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const linkedNode = new LGraphNode('LinkedNode')
const linkedInput = linkedNode.addInput('string_a', '*')
linkedNode.addWidget('text', 'string_a', 'linked', () => undefined, {})
linkedInput.widget = { name: 'string_a' }
subgraph.add(linkedNode)
subgraph.inputNode.slots[0].connect(linkedInput, linkedNode)
const secondNode = new LGraphNode('SecondNode')
const secondInput = secondNode.addInput('seed', '*')
secondNode.addWidget('number', 'seed', 2, () => undefined)
secondInput.widget = { name: 'seed' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const independentNode = new LGraphNode('IndependentNode')
independentNode.addWidget(
'text',
'string_a',
'independent',
() => undefined,
{}
)
subgraph.add(independentNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 100 })
const subgraphNode = createTestSubgraphNode(subgraph, { id: 109 })
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(independentNode.id),
sourceWidgetName: 'string_a'
})
const { vueNodeData } = useGraphNodeManager(graph)
const nodeData = vueNodeData.get(String(subgraphNode.id))
const widgets = nodeData?.widgets
const promotedWidgets = nodeData?.widgets?.filter(
(widget) => widget.name === 'string_a'
)
expect(widgets).toHaveLength(2)
expect(widgets?.[0]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'first_seed')
expect(promotedWidgets).toHaveLength(2)
expect(
new Set(promotedWidgets?.map((widget) => widget.storeNodeId))
).toEqual(
new Set([
`${subgraph.id}:${linkedNode.id}`,
`${subgraph.id}:${independentNode.id}`
])
)
expect(widgets?.[1]?.entityId).toBe(
widgetEntityId(graph.id, subgraphNode.id, 'second_seed')
})
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}`
])
)
expect(widgets?.[0]?.entityId).not.toBe(widgets?.[1]?.entityId)
})
})

View File

@@ -9,10 +9,8 @@ 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,
resolvePromotedWidgetSource
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { resolvePromotedWidgetSource } from '@/core/graph/subgraph/resolvePromotedWidgetSource'
import { resolveSubgraphInputTarget } from '@/core/graph/subgraph/resolveSubgraphInputTarget'
import type {
INodeInputSlot,
@@ -29,8 +27,6 @@ import { IS_CONTROL_WIDGET } from '@/scripts/widgets'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import type { WidgetValue, SafeControlWidget } from '@/types/simplifiedWidget'
import { normalizeControlOption } from '@/types/simplifiedWidget'
import { getWidgetEntityIdForNode } from '@/utils/litegraphUtil'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
LGraph,
@@ -60,9 +56,10 @@ type Badges = (LGraphBadge | (() => LGraphBadge))[]
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
*/
export interface SafeWidgetData {
entityId?: WidgetEntityId
nodeId?: NodeId
storeNodeId?: NodeId
name: string
storeName?: string
type: string
/** Callback to invoke when widget value changes (wraps LiteGraph callback + triggerDraw) */
callback?: ((value: unknown) => void) | undefined
@@ -231,15 +228,18 @@ function safeWidgetMapper(
}
}
function resolvePromotedSourceByInputName(
inputName: string
): PromotedWidgetSource | null {
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
}
}
@@ -257,9 +257,10 @@ function safeWidgetMapper(
const matchedInput = matchPromotedInput(node.inputs, widget)
const promotedInputName = matchedInput?.name
const displayName = promotedInputName ?? widget.name
const directSource: PromotedWidgetSource = {
const directSource = {
sourceNodeId: widget.sourceNodeId,
sourceWidgetName: widget.sourceWidgetName
sourceWidgetName: widget.sourceWidgetName,
disambiguatingSourceNodeId: widget.disambiguatingSourceNodeId
}
const promotedSource =
matchedInput?._widget === widget
@@ -306,7 +307,8 @@ function safeWidgetMapper(
? resolveConcretePromotedWidget(
node,
promotedSource.sourceNodeId,
promotedSource.sourceWidgetName
promotedSource.sourceWidgetName,
promotedSource.disambiguatingSourceNodeId
)
: null
const resolvedSource =
@@ -319,21 +321,24 @@ 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
const sourceWidgetName = isPromotedWidgetView(widget)
const storeName = isPromotedWidgetView(widget)
? (sourceWidget?.name ?? promotedSource?.sourceWidgetName)
: undefined
const name = sourceWidgetName ?? displayName
if (isPromotedWidgetView(widget)) widget.ensureHostWidgetState()
const name = storeName ?? displayName
return {
entityId: getWidgetEntityIdForNode(node, widget),
nodeId,
storeNodeId: nodeId,
name,
storeName,
type: effectiveWidget.type,
...sharedEnhancements,
callback,
@@ -381,10 +386,10 @@ function buildSlotMetadata(
if (input.link != null && graphRef) {
const link = graphRef.getLink(input.link)
const originNode = link ? graphRef.getNodeById(link.origin_id) : null
if (link && originNode) {
if (link) {
originNodeId = String(link.origin_id)
originOutputName = originNode.outputs?.[link.origin_slot]?.name
const originNode = graphRef.getNodeById(link.origin_id)
originOutputName = originNode?.outputs?.[link.origin_slot]?.name
}
}

View File

@@ -1,10 +1,7 @@
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
export const CANVAS_IMAGE_PREVIEW_WIDGET = '$$canvas-image-preview'
const CANVAS_IMAGE_PREVIEW_NODE_TYPES = new Set([
'KSampler',
'KSamplerAdvanced',
'PreviewImage',
'SaveImage',
'GLSLShader'

View File

@@ -237,15 +237,12 @@ const normalizeWidgetValue = (
const buildJsonataContext = (
node: LGraphNode,
rule: JsonataPricingRule,
widgetOverrides?: ReadonlyMap<string, unknown>
rule: JsonataPricingRule
): JsonataEvalContext => {
const widgets: Record<string, NormalizedWidgetValue> = {}
for (const dep of rule.depends_on.widgets) {
const raw = widgetOverrides?.has(dep.name)
? widgetOverrides.get(dep.name)
: node.widgets?.find((x: IBaseWidget) => x.name === dep.name)?.value
widgets[dep.name] = normalizeWidgetValue(raw, dep.type)
const widget = node.widgets?.find((x: IBaseWidget) => x.name === dep.name)
widgets[dep.name] = normalizeWidgetValue(widget?.value, dep.type)
}
const inputs: Record<string, { connected: boolean }> = {}
@@ -555,10 +552,7 @@ export const useNodePricing = () => {
* - schedules async evaluation when needed
* - remains non-fatal on errors (returns safe fallback '')
*/
const getNodeDisplayPrice = (
node: LGraphNode,
widgetOverrides?: ReadonlyMap<string, unknown>
): string => {
const getNodeDisplayPrice = (node: LGraphNode): string => {
// Make this function reactive: when async evaluation completes, we bump pricingTick,
// which causes this getter to recompute in Vue render/computed contexts.
void pricingTick.value
@@ -571,7 +565,7 @@ export const useNodePricing = () => {
if (rule.engine !== 'jsonata') return ''
if (!rule._compiled) return ''
const ctx = buildJsonataContext(node, rule, widgetOverrides)
const ctx = buildJsonataContext(node, rule)
const sig = buildSignature(ctx, rule)
const cached = cache.get(node)

View File

@@ -1,8 +1,6 @@
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { LGraphBadge } from '@/lib/litegraph/src/litegraph'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { adjustColor } from '@/utils/colorUtil'
@@ -11,25 +9,14 @@ componentIconSvg.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='oklch(83.01%25 0.163 83.16)' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M15.536 11.293a1 1 0 0 0 0 1.414l2.376 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0zm-13.239 0a1 1 0 0 0 0 1.414l2.377 2.377a1 1 0 0 0 1.414 0l2.377-2.377a1 1 0 0 0 0-1.414L6.088 8.916a1 1 0 0 0-1.414 0zm6.619 6.619a1 1 0 0 0 0 1.415l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.415l-2.377-2.376a1 1 0 0 0-1.414 0zm0-13.238a1 1 0 0 0 0 1.414l2.377 2.376a1 1 0 0 0 1.414 0l2.377-2.376a1 1 0 0 0 0-1.414l-2.377-2.377a1 1 0 0 0-1.414 0z'/%3E%3C/svg%3E"
export const usePriceBadge = () => {
const nodePricing = useNodePricing()
function updateSubgraphCredits(node: LGraphNode) {
if (!node.isSubgraphNode()) return
node.badges = node.badges.filter((b) => !isCreditsBadge(b))
const innerCreditsBadges = collectCreditsBadges(node.subgraph)
if (innerCreditsBadges.length > 1) {
node.badges.push(
getCreditsBadge('Partner Nodes x ' + innerCreditsBadges.length)
)
} else if (innerCreditsBadges.length === 1) {
const innerApiNodes = collectInnerApiNodes(node.subgraph)
// When a single inner api node is the price source, swap its static
// getter for a wrapper-aware one that resolves promoted widget values.
if (innerApiNodes.length === 1) {
node.badges.push(buildWrapperAwarePriceBadge(node, innerApiNodes[0]))
} else {
node.badges.push(...innerCreditsBadges)
}
const newBadges = collectCreditsBadges(node.subgraph)
if (newBadges.length > 1) {
node.badges.push(getCreditsBadge('Partner Nodes x ' + newBadges.length))
} else {
node.badges.push(...newBadges)
}
const graph = node.graph
if (!graph) return
@@ -41,14 +28,13 @@ export const usePriceBadge = () => {
newValue: node.badges
})
}
function collectCreditsBadges(
graph: LGraph,
visited: Set<string> = new Set()
): (LGraphBadge | (() => LGraphBadge))[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const badges: (LGraphBadge | (() => LGraphBadge))[] = []
const badges = []
for (const node of graph.nodes) {
badges.push(
...(node.isSubgraphNode()
@@ -59,51 +45,6 @@ export const usePriceBadge = () => {
return badges
}
function collectInnerApiNodes(
graph: LGraph,
visited: Set<string> = new Set()
): LGraphNode[] {
if (visited.has(graph.id)) return []
visited.add(graph.id)
const apiNodes: LGraphNode[] = []
for (const node of graph.nodes) {
if (node.isSubgraphNode()) {
apiNodes.push(...collectInnerApiNodes(node.subgraph, visited))
} else if (node.constructor?.nodeData?.api_node) {
apiNodes.push(node)
}
}
return apiNodes
}
function buildWrapperAwarePriceBadge(
wrapper: LGraphNode,
innerNode: LGraphNode
): () => LGraphBadge {
return () =>
getCreditsBadge(
nodePricing.getNodeDisplayPrice(
innerNode,
collectPromotedOverrides(wrapper, innerNode)
)
)
}
function collectPromotedOverrides(
wrapper: LGraphNode,
innerNode: LGraphNode
): ReadonlyMap<string, unknown> {
const overrides = new Map<string, unknown>()
if (!wrapper.isSubgraphNode()) return overrides
const innerId = String(innerNode.id)
for (const w of wrapper.widgets ?? []) {
if (!isPromotedWidgetView(w)) continue
if (w.sourceNodeId !== innerId) continue
overrides.set(w.sourceWidgetName, w.value)
}
return overrides
}
function isCreditsBadge(
badge: Partial<LGraphBadge> | (() => Partial<LGraphBadge>)
): boolean {

View File

@@ -9,39 +9,35 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { CANVAS_IMAGE_PREVIEW_WIDGET } from './canvasImagePreviewTypes'
import { usePromotedPreviews } from './usePromotedPreviews'
type MockNodeOutputStore = Pick<
ReturnType<typeof useNodeOutputStore>,
| 'nodeOutputs'
| 'nodePreviewImages'
| 'getNodeImageUrls'
| 'getNodeImageUrlsByExecutionId'
| 'getNodeOutputByExecutionId'
| 'getNodePreviewImagesByExecutionId'
'nodeOutputs' | 'nodePreviewImages' | 'getNodeImageUrls'
>
const getNodeImageUrls = vi.hoisted(() =>
vi.fn<MockNodeOutputStore['getNodeImageUrls']>()
)
const useNodeOutputStoreMock = vi.hoisted(() =>
vi.fn<() => MockNodeOutputStore>()
)
vi.mock('@/stores/nodeOutputStore', () => {
const store: MockNodeOutputStore = {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls: vi.fn(),
getNodeImageUrlsByExecutionId: vi.fn(),
getNodeOutputByExecutionId: vi.fn(),
getNodePreviewImagesByExecutionId: vi.fn()
return {
useNodeOutputStore: useNodeOutputStoreMock
}
return { useNodeOutputStore: () => store }
})
function clearMockNodeOutputStore() {
const { nodeOutputs, nodePreviewImages } = useNodeOutputStore()
for (const key of Object.keys(nodeOutputs)) delete nodeOutputs[key]
for (const key of Object.keys(nodePreviewImages))
delete nodePreviewImages[key]
function createMockNodeOutputStore(): MockNodeOutputStore {
return {
nodeOutputs: reactive<MockNodeOutputStore['nodeOutputs']>({}),
nodePreviewImages: reactive<MockNodeOutputStore['nodePreviewImages']>({}),
getNodeImageUrls
}
}
function createSetup() {
@@ -87,43 +83,16 @@ function seedPreviewImages(
}
}
function exposePreview(
setup: ReturnType<typeof createSetup>,
sourceNodeId: string,
sourcePreviewName = CANVAS_IMAGE_PREVIEW_WIDGET
) {
usePreviewExposureStore().addExposure(
setup.subgraphNode.rootGraph.id,
String(setup.subgraphNode.id),
{ sourceNodeId, sourcePreviewName }
)
}
interface ArrangeOptions {
id?: number
previewMediaType?: 'image' | 'video' | 'audio' | 'model'
urls?: string[]
}
function arrangePromotedPreview(options: ArrangeOptions = {}) {
const {
id = 10,
previewMediaType,
urls = ['/view?filename=output.png']
} = options
const setup = createSetup()
addInteriorNode(setup, { id, previewMediaType })
exposePreview(setup, String(id))
seedOutputs(setup.subgraph.id, [id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue(urls)
return { setup, urls }
}
describe(usePromotedPreviews, () => {
let nodeOutputStore: MockNodeOutputStore
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.resetAllMocks()
clearMockNodeOutputStore()
vi.clearAllMocks()
getNodeImageUrls.mockReset()
nodeOutputStore = createMockNodeOutputStore()
useNodeOutputStoreMock.mockReturnValue(nodeOutputStore)
})
it('returns empty array for non-SubgraphNode', () => {
@@ -140,50 +109,70 @@ describe(usePromotedPreviews, () => {
it('returns empty array when no $$ promotions exist', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('returns image preview for promoted $$ widget with outputs', () => {
const { setup, urls } = arrangePromotedPreview({
previewMediaType: 'image'
})
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=output.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls
urls: mockUrls
}
])
})
it.for([
['video', '/view?filename=output.webm'],
['audio', '/view?filename=output.mp3']
] as const)(
'returns %s type when interior node has %s previewMediaType',
([mediaType, url]) => {
const { setup } = arrangePromotedPreview({
previewMediaType: mediaType,
urls: [url]
})
it('returns video type when interior node has video previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'video' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe(mediaType)
}
)
it('defaults preview type to image when previewMediaType is unset', () => {
const { setup, urls } = arrangePromotedPreview()
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.webm'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
expect.objectContaining({ type: 'image', urls })
])
expect(promotedPreviews.value[0].type).toBe('video')
})
it('returns audio type when interior node has audio previewMediaType', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'audio' })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(['/view?filename=output.mp3'])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value[0].type).toBe('audio')
})
it('returns separate entries for multiple promoted $$ widgets', () => {
@@ -196,17 +185,23 @@ describe(usePromotedPreviews, () => {
id: 20,
previewMediaType: 'image'
})
exposePreview(setup, '10')
exposePreview(setup, '20')
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '20', sourceWidgetName: '$$canvas-image-preview' }
)
seedOutputs(setup.subgraph.id, [10, 20])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
}
)
getNodeImageUrls.mockImplementation((node: LGraphNode) => {
if (node === node10) return ['/view?a=1']
if (node === node20) return ['/view?b=2']
return undefined
})
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(2)
@@ -217,17 +212,21 @@ describe(usePromotedPreviews, () => {
it('returns preview when only nodePreviewImages exist (e.g. GLSL live preview)', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
exposePreview(setup, '10')
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
getNodeImageUrls.mockReturnValue([blobUrl])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -237,19 +236,23 @@ describe(usePromotedPreviews, () => {
it('recomputes when preview images are populated after first evaluation', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10, previewMediaType: 'image' })
exposePreview(setup, '10')
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
const blobUrl = 'blob:http://localhost/glsl-preview'
seedPreviewImages(setup.subgraph.id, [{ nodeId: 10, urls: [blobUrl] }])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockReturnValue([blobUrl])
getNodeImageUrls.mockReturnValue([blobUrl])
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceWidgetName: '$$canvas-image-preview',
type: 'image',
urls: [blobUrl]
}
@@ -259,7 +262,11 @@ describe(usePromotedPreviews, () => {
it('skips interior nodes with no image output', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
exposePreview(setup, '10')
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
@@ -267,132 +274,36 @@ describe(usePromotedPreviews, () => {
it('skips missing interior nodes', () => {
const setup = createSetup()
exposePreview(setup, '99')
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '99', sourceWidgetName: '$$canvas-image-preview' }
)
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toEqual([])
})
it('renders leaf media exposed through a nested subgraph host', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const store = usePreviewExposureStore()
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(innerHost.id),
{
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
it('ignores non-$$ promoted widgets', () => {
const setup = createSetup()
addInteriorNode(setup, { id: 10 })
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: 'seed' }
)
store.addExposure(
outerSetup.subgraphNode.rootGraph.id,
String(outerSetup.subgraphNode.id),
{
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
usePromotionStore().promote(
setup.subgraphNode.rootGraph.id,
setup.subgraphNode.id,
{ sourceNodeId: '10', sourceWidgetName: '$$canvas-image-preview' }
)
const mockUrls = ['/view?filename=leaf.png']
seedOutputs(innerSetup.subgraph.id, [leafNode.id])
vi.mocked(useNodeOutputStore().getNodeImageUrls).mockImplementation(
(node: LGraphNode) => (node === leafNode ? mockUrls : [])
)
const mockUrls = ['/view?filename=img.png']
seedOutputs(setup.subgraph.id, [10])
getNodeImageUrls.mockReturnValue(mockUrls)
const { promotedPreviews } = usePromotedPreviews(
() => outerSetup.subgraphNode
)
expect(promotedPreviews.value).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: mockUrls
}
])
})
it('keeps promoted previews distinct for multiple instances of a shared subgraph definition', () => {
const innerSetup = createSetup()
const leafNode = addInteriorNode(innerSetup, {
id: 10,
previewMediaType: 'image'
})
const outerSetup = createSetup()
const innerHost = createTestSubgraphNode(innerSetup.subgraph, { id: 20 })
outerSetup.subgraph.add(innerHost)
const firstHost = createTestSubgraphNode(outerSetup.subgraph, { id: 11 })
const secondHost = createTestSubgraphNode(outerSetup.subgraph, { id: 12 })
const firstHostLocator = String(firstHost.id)
const secondHostLocator = String(secondHost.id)
const firstNestedLocator = `${firstHostLocator}:${innerHost.id}`
const secondNestedLocator = `${secondHostLocator}:${innerHost.id}`
const firstLeafExecutionId = `${firstNestedLocator}:${leafNode.id}`
const secondLeafExecutionId = `${secondNestedLocator}:${leafNode.id}`
const store = usePreviewExposureStore()
store.addExposure(firstHost.rootGraph.id, firstHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondHostLocator, {
sourceNodeId: String(innerHost.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, firstNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
store.addExposure(firstHost.rootGraph.id, secondNestedLocator, {
sourceNodeId: String(leafNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
})
const outputStore = useNodeOutputStore()
vi.mocked(outputStore.getNodePreviewImagesByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
vi.mocked(outputStore.getNodeImageUrlsByExecutionId).mockImplementation(
(executionId) => {
if (executionId === firstLeafExecutionId) return ['blob:first']
if (executionId === secondLeafExecutionId) return ['blob:second']
return undefined
}
)
expect(usePromotedPreviews(() => firstHost).promotedPreviews.value).toEqual(
[
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:first']
}
]
)
expect(
usePromotedPreviews(() => secondHost).promotedPreviews.value
).toEqual([
{
sourceNodeId: '10',
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET,
type: 'image',
urls: ['blob:second']
}
])
const { promotedPreviews } = usePromotedPreviews(() => setup.subgraphNode)
expect(promotedPreviews.value).toHaveLength(1)
expect(promotedPreviews.value[0].urls).toEqual(mockUrls)
})
})

View File

@@ -3,9 +3,8 @@ import { computed, toValue } from 'vue'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
interface PromotedPreview {
@@ -15,126 +14,65 @@ interface PromotedPreview {
urls: string[]
}
const PREVIEW_TYPES_BY_MEDIA = {
video: 'video',
audio: 'audio'
} as const satisfies Partial<Record<string, PromotedPreview['type']>>
function getPreviewMediaType(node: LGraphNode): PromotedPreview['type'] {
const media = node.previewMediaType
if (media && media in PREVIEW_TYPES_BY_MEDIA) {
return PREVIEW_TYPES_BY_MEDIA[media as keyof typeof PREVIEW_TYPES_BY_MEDIA]
}
return 'image'
}
/**
* Returns reactive preview media from promoted `$$` pseudo-widgets
* on a SubgraphNode. Each promoted preview interior node produces
* a separate entry so they render independently.
*/
export function usePromotedPreviews(
lgraphNode: MaybeRefOrGetter<LGraphNode | null | undefined>
) {
const previewExposureStore = usePreviewExposureStore()
const promotionStore = usePromotionStore()
const nodeOutputStore = useNodeOutputStore()
/** Touches reactive sources for Vue tracking; `getNodeImageUrls` reads non-reactive app state. */
function readReactivePreviewUrls(
leafHost: SubgraphNode,
leafSourceNodeId: string,
leafExecutionId: string,
interiorNode: LGraphNode
): string[] | undefined {
const locatorId = createNodeLocatorId(
leafHost.subgraph.id,
leafSourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
const reactiveExecutionOutputs =
nodeOutputStore.getNodeOutputByExecutionId(leafExecutionId)
const reactiveExecutionPreviews =
nodeOutputStore.getNodePreviewImagesByExecutionId(leafExecutionId)
const hasAnySource =
reactiveOutputs?.images?.length ||
reactivePreviews?.length ||
reactiveExecutionOutputs?.images?.length ||
reactiveExecutionPreviews?.length
if (!hasAnySource) return undefined
return (
nodeOutputStore.getNodeImageUrlsByExecutionId(
leafExecutionId,
interiorNode
) ?? nodeOutputStore.getNodeImageUrls(interiorNode)
)
}
const promotedPreviews = computed((): PromotedPreview[] => {
const node = toValue(lgraphNode)
if (!(node instanceof SubgraphNode)) return []
const rootGraphId = node.rootGraph.id
const hostLocator = String(node.id)
const exposures = previewExposureStore.getExposures(
rootGraphId,
hostLocator
const entries = promotionStore.getPromotions(node.rootGraph.id, node.id)
const pseudoEntries = entries.filter((e) =>
e.sourceWidgetName.startsWith('$$')
)
if (!exposures.length) return []
if (!pseudoEntries.length) return []
const hostNodesByLocator = new Map<string, SubgraphNode>([
[hostLocator, node]
])
const previews: PromotedPreview[] = []
function resolveNestedHost(
rootGraphId: UUID,
currentHostLocator: string,
sourceNodeId: string
) {
const currentHost = hostNodesByLocator.get(currentHostLocator)
const sourceNode = currentHost?.subgraph.getNodeById(sourceNodeId)
if (!(sourceNode instanceof SubgraphNode)) return undefined
for (const entry of pseudoEntries) {
const interiorNode = node.subgraph.getNodeById(entry.sourceNodeId)
if (!interiorNode) continue
const pathLocator = `${currentHostLocator}:${sourceNode.id}`
const definitionLocator = String(sourceNode.id)
const hasPathExposures =
previewExposureStore.getExposures(rootGraphId, pathLocator).length > 0
const nestedHostLocator = hasPathExposures
? pathLocator
: definitionLocator
hostNodesByLocator.set(nestedHostLocator, sourceNode)
return { rootGraphId, hostNodeLocator: nestedHostLocator }
// Read from both reactive refs to establish Vue dependency
// tracking. getNodeImageUrls reads from non-reactive
// app.nodeOutputs / app.nodePreviewImages, so without this
// access the computed would never re-evaluate.
const locatorId = createNodeLocatorId(
node.subgraph.id,
entry.sourceNodeId
)
const reactiveOutputs = nodeOutputStore.nodeOutputs[locatorId]
const reactivePreviews = nodeOutputStore.nodePreviewImages[locatorId]
if (!reactiveOutputs?.images?.length && !reactivePreviews?.length)
continue
const urls = nodeOutputStore.getNodeImageUrls(interiorNode)
if (!urls?.length) continue
const type =
interiorNode.previewMediaType === 'video'
? 'video'
: interiorNode.previewMediaType === 'audio'
? 'audio'
: 'image'
previews.push({
sourceNodeId: entry.sourceNodeId,
sourceWidgetName: entry.sourceWidgetName,
type,
urls
})
}
return exposures.flatMap((exposure): PromotedPreview[] => {
const resolved = previewExposureStore.resolveChain(
rootGraphId,
hostLocator,
exposure.name,
resolveNestedHost
)
const leaf = resolved?.leaf ?? {
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
const leafHostLocator =
resolved?.steps.at(-1)?.hostNodeLocator ?? hostLocator
const leafHost = hostNodesByLocator.get(leafHostLocator) ?? node
const interiorNode = leafHost.subgraph.getNodeById(leaf.sourceNodeId)
if (!interiorNode) return []
const urls = readReactivePreviewUrls(
leafHost,
leaf.sourceNodeId,
`${leafHostLocator}:${leaf.sourceNodeId}`,
interiorNode
)
if (!urls?.length) return []
return [
{
sourceNodeId: leaf.sourceNodeId,
sourceWidgetName: leaf.sourcePreviewName,
type: getPreviewMediaType(interiorNode),
urls
}
]
})
return previews
})
return { promotedPreviews }

View File

@@ -0,0 +1,122 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { normalizeLegacyProxyWidgetEntry } from '@/core/graph/subgraph/legacyProxyWidgetNormalization'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import {
createTestRootGraph,
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
function createHostWithInnerWidget(widgetName: string) {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'value', type: 'number' }]
})
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
innerSubgraph.add(innerNode)
innerSubgraph.inputNode.slots[0].connect(innerNode.inputs[0], innerNode)
const hostNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: rootGraph
})
return { rootGraph, innerSubgraph, innerNode, hostNode }
}
describe('normalizeLegacyProxyWidgetEntry', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
it('returns entry unchanged when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
})
it('returns entry unchanged with disambiguator when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed',
String(innerNode.id)
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
rootGraph,
inputs: [{ name: 'seed', type: 'number' }]
})
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
innerSubgraph.inputNode.slots[0].connect(samplerNode.inputs[0], samplerNode)
const outerSubgraph = createTestSubgraph({ rootGraph })
const nestedNode = createTestSubgraphNode(innerSubgraph, {
parentGraph: outerSubgraph
})
outerSubgraph.add(nestedNode)
const hostNode = createTestSubgraphNode(outerSubgraph, {
parentGraph: rootGraph
})
const prefixedName = `${nestedNode.id}: ${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(nestedNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('returns original entry when prefix cannot be resolved', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: '999: nonexistent_widget'
})
})
})

View File

@@ -0,0 +1,111 @@
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
type PromotedWidgetPatch = Omit<PromotedWidgetSource, 'sourceNodeId'>
function canResolve(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): boolean {
return (
resolveConcretePromotedWidget(
hostNode,
sourceNodeId,
widgetName,
disambiguator
).status === 'resolved'
)
}
function tryResolveCandidate(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string,
disambiguator?: string
): PromotedWidgetPatch | undefined {
if (!canResolve(hostNode, sourceNodeId, widgetName, disambiguator))
return undefined
return {
sourceWidgetName: widgetName,
...(disambiguator && { disambiguatingSourceNodeId: disambiguator })
}
}
function resolveLegacyPrefixedEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetPatch | undefined {
let remaining = sourceWidgetName
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return undefined
const [, legacySourceNodeId, unprefixed] = match
remaining = unprefixed
const disambiguators = [
legacySourceNodeId,
...(disambiguatingSourceNodeId ? [disambiguatingSourceNodeId] : []),
undefined
]
for (const disambiguator of disambiguators) {
const resolved = tryResolveCandidate(
hostNode,
sourceNodeId,
remaining,
disambiguator
)
if (resolved) return resolved
}
}
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): PromotedWidgetSource {
if (
canResolve(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const patch = resolveLegacyPrefixedEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguatingSourceNodeId
)
const patchDisambiguatingSourceNodeId =
patch?.disambiguatingSourceNodeId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: patch?.sourceWidgetName ?? sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}

View File

@@ -1,895 +0,0 @@
import { createTestingPinia } from '@pinia/testing'
import { fromPartial } from '@total-typescript/shoehorn'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
LGraph,
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import {
flushProxyWidgetMigration,
normalizeLegacyProxyWidgetEntry,
readHostQuarantine
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
LGraph.proxyWidgetMigrationFlush = undefined
})
function buildHost(): SubgraphNode {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return hostNode
}
function addInnerNode(
host: SubgraphNode,
type: string,
build: (node: LGraphNode) => void = () => {}
): LGraphNode {
const node = new LGraphNode(type)
build(node)
host.subgraph.add(node)
return node
}
function addPromotedHostInput(
host: SubgraphNode,
args: {
inputName: string
promotedName: string
sourceNodeId: string
sourceWidgetName: string
initialValue?: TWidgetValue
}
): { setValue: (v: TWidgetValue) => void; getValue: () => TWidgetValue } {
let widgetValue: TWidgetValue = args.initialValue ?? 0
const slot = host.addInput(args.inputName, '*')
slot._widget = fromPartial<PromotedWidgetView>({
node: host,
name: args.promotedName,
sourceNodeId: args.sourceNodeId,
sourceWidgetName: args.sourceWidgetName,
get value() {
return widgetValue
},
set value(v: TWidgetValue) {
widgetValue = v
},
hydrateHostValue(v: TWidgetValue) {
widgetValue = v
}
})
return {
setValue: (v) => {
widgetValue = v
},
getValue: () => widgetValue
}
}
function addPrimitiveWithTargets(
host: SubgraphNode,
args: {
primitiveType?: string
primitiveValue?: number
targetCount: number
outputType?: string
targetSlotType?: string
}
): { primitive: LGraphNode; targets: LGraphNode[] } {
const outputType = args.outputType ?? 'INT'
const targetSlotType = args.targetSlotType ?? outputType
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', outputType)
primitive.addWidget('number', 'value', args.primitiveValue ?? 42, () => {})
host.subgraph.add(primitive)
const targets: LGraphNode[] = []
for (let i = 0; i < args.targetCount; i++) {
const target = new LGraphNode(`Target${i}`)
const slot = target.addInput('value', targetSlotType)
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
host.subgraph.add(target)
primitive.connect(0, target, 0)
targets.push(target)
}
return { primitive, targets }
}
describe('flushProxyWidgetMigration', () => {
describe('no-op cases', () => {
it('returns an empty result when no proxyWidgets are present', () => {
const host = buildHost()
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('tolerates a malformed proxyWidgets payload and returns empty', () => {
const host = buildHost()
host.properties.proxyWidgets = '{not json}'
expect(() => flushProxyWidgetMigration({ hostNode: host })).not.toThrow()
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
})
})
describe('value-widget repair', () => {
it('alreadyLinked: applies host value to the matching promoted widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 0
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(handle.getValue()).toBe(99)
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('alreadyLinked: hydrates real promoted widget without mutating the interior widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'seed', type: 'INT' }]
})
const host = createTestSubgraphNode(subgraph)
host.graph!.add(host)
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
const innerWidget = n.addWidget('number', 'seed', 0, () => {})
slot.widget = { name: innerWidget.name }
})
subgraph.inputNode.slots[0].connect(inner.inputs[0], inner)
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.widgets[0].value).toBe(99)
const innerWidget = inner.widgets!.find((w) => w.name === 'seed')!
expect(innerWidget.value).toBe(0)
})
it('alreadyLinked: leaves widget value unchanged when host value is a sparse hole', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const handle = addPromotedHostInput(host, {
inputName: 'seed_link',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 7
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
const sparse: unknown[] = []
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(handle.getValue()).toBe(7)
})
it('alreadyLinked: ambiguous matching inputs quarantine without applying host value', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const a = addPromotedHostInput(host, {
inputName: 'first_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 1
})
const b = addPromotedHostInput(host, {
inputName: 'second_seed',
promotedName: 'seed',
sourceNodeId: String(inner.id),
sourceWidgetName: 'seed',
initialValue: 2
})
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(a.getValue()).toBe(1)
expect(b.getValue()).toBe(2)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'ambiguousSubgraphInput'
})
])
})
it('createSubgraphInput: creates exactly one new SubgraphInput linked to the source widget', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
})
it('createSubgraphInput: honors disambiguatingSourceNodeId when source widget name has been deduplicated', () => {
const host = buildHost()
const inner = addInnerNode(host, 'InnerWithDedupedPromotion', (n) => {
const slot1 = n.addInput('text', 'STRING')
slot1.widget = { name: 'text' }
const w1 = n.addWidget('text', 'text', '11111111111', () => {})
Object.assign(w1, { sourceNodeId: '1', sourceWidgetName: 'text' })
const slot2 = n.addInput('text_1', 'STRING')
slot2.widget = { name: 'text_1' }
const w2 = n.addWidget('text', 'text_1', '22222222222', () => {})
Object.assign(w2, { sourceNodeId: '2', sourceWidgetName: 'text' })
})
host.properties.proxyWidgets = [[String(inner.id), 'text', '2']]
flushProxyWidgetMigration({ hostNode: host })
const created = host.subgraph.inputs.at(-1)
expect(created?._widget).toBeDefined()
const linkedSlot = inner.inputs.find(
(slot) => slot.link === created?.linkIds[0]
)
expect(linkedSlot?.name).toBe('text_1')
})
it('createSubgraphInput: quarantines missingSubgraphInput when source widget has no backing input slot', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('number', 'seed', 0, () => {})
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(inner.id), 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'seed'],
reason: 'missingSubgraphInput'
})
])
})
})
describe('primitive fan-out repair', () => {
it('repairs 1 primitive fanned out to 3 targets into a single SubgraphInput', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 3
})
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore + 1)
for (const target of targets) {
const slot = target.inputs[0]
expect(slot.link).not.toBeNull()
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('coalesces duplicate cohort entries pointing at the same primitive', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 2
})
host.properties.proxyWidgets = [
[String(primitive.id), 'value'],
[String(primitive.id), 'value']
]
flushProxyWidgetMigration({ hostNode: host })
for (const target of targets) {
const slot = target.inputs[0]
const link = host.subgraph.links.get(slot.link!)
expect(link?.origin_id).not.toBe(primitive.id)
}
})
it('host value wins over primitive widget value', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 2,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [123]
})
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(123)
})
it('seeds value from the primitive widget when no host value is supplied', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, {
targetCount: 1,
primitiveValue: 11
})
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
const hostInput = host.inputs.at(-1)
expect(hostInput?._widget?.value).toBe(11)
})
it('quarantines an unlinked primitive node with no fan-out', () => {
const host = buildHost()
const primitive = new LGraphNode('Primitive')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', '*')
host.subgraph.add(primitive)
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'unlinkedSourceWidget'
})
])
})
it('quarantines all cohort entries when a target slot type is incompatible', () => {
const host = buildHost()
const { primitive, targets } = addPrimitiveWithTargets(host, {
targetCount: 1
})
targets[0].inputs[0].type = 'STRING'
const inputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(host.subgraph.inputs).toHaveLength(inputCountBefore)
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps surviving primitive targets when one fan-out link is dangling', () => {
const host = buildHost()
const { primitive } = addPrimitiveWithTargets(host, { targetCount: 1 })
const danglingLinkId = 999_999
expect(host.subgraph.links.has(danglingLinkId)).toBe(false)
primitive.outputs[0].links = [
...(primitive.outputs[0].links ?? []),
danglingLinkId
]
host.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(primitive.id), 'value'],
reason: 'primitiveBypassFailed'
})
])
})
it('keeps independent values across two hosts of the same subgraph', () => {
const subgraph = createTestSubgraph()
const hostA = createTestSubgraphNode(subgraph)
const hostB = createTestSubgraphNode(subgraph)
hostA.graph!.add(hostA)
hostB.graph!.add(hostB)
const primitive = new LGraphNode('PrimitiveNode')
primitive.type = 'PrimitiveNode'
primitive.addOutput('value', 'INT')
primitive.addWidget('number', 'value', 0, () => {})
subgraph.add(primitive)
const target = new LGraphNode('Target')
const slot = target.addInput('value', 'INT')
slot.widget = { name: 'value' }
target.addWidget('number', 'value', 0, () => {})
subgraph.add(target)
primitive.connect(0, target, 0)
hostA.properties.proxyWidgets = [[String(primitive.id), 'value']]
hostB.properties.proxyWidgets = [[String(primitive.id), 'value']]
flushProxyWidgetMigration({
hostNode: hostA,
hostWidgetValues: [11]
})
flushProxyWidgetMigration({
hostNode: hostB,
hostWidgetValues: [22]
})
expect(hostA.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(hostB.properties.proxyWidgetErrorQuarantine).toBeUndefined()
const widgetA = hostA.inputs.at(-1)?._widget
const widgetB = hostB.inputs.at(-1)?._widget
expect(widgetA?.value).toBe(11)
expect(widgetB?.value).toBe(22)
})
})
describe('preview exposure migration', () => {
it('adds an exposure for a $$-prefixed preview source', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
expect(exposures[0].sourceNodeId).toBe(String(inner.id))
})
it('classifies type:preview serialize:false widgets as preview exposure', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const widget = n.addWidget('text', 'videopreview', '', () => {})
widget.type = 'preview'
widget.serialize = false
})
host.properties.proxyWidgets = [[String(inner.id), 'videopreview']]
flushProxyWidgetMigration({ hostNode: host })
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: 'videopreview'
})
])
})
it('produces a unique name on collision via nextUniqueName', () => {
const host = buildHost()
const innerA = addInnerNode(host, 'InnerA', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const innerB = addInnerNode(host, 'InnerB', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(innerA.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(innerB.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposures = store.getExposures(host.rootGraph.id, locator)
expect(exposures).toHaveLength(2)
const newExposure = exposures.find(
(e) => e.sourceNodeId === String(innerB.id)
)
expect(newExposure?.name).toBe('$$canvas-image-preview_1')
})
it('reuses an existing exposure for the same source preview', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const store = usePreviewExposureStore()
const locator = String(host.id)
store.addExposure(host.rootGraph.id, locator, {
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(store.getExposures(host.rootGraph.id, locator)).toHaveLength(1)
})
})
describe('quarantine accumulation', () => {
it('quarantines entries whose source node has disappeared', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
{
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
attemptedAtVersion: 1
}
])
})
it('quarantines entries whose source widget is missing on the source node', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner')
host.properties.proxyWidgets = [[String(inner.id), 'nonexistent']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: [String(inner.id), 'nonexistent'],
reason: 'missingSourceWidget'
})
])
})
it('preserves the host value on the quarantine row when one was supplied', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [42]
})
expect(readHostQuarantine(host)).toEqual([
expect.objectContaining({
originalEntry: ['9999', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
})
it('round-trips appended entries via the public read helper', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const first = readHostQuarantine(host)
expect(first).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed', 'inner-leaf']]
flushProxyWidgetMigration({ hostNode: host })
const after = readHostQuarantine(host)
expect(after).toHaveLength(2)
expect(after.map((e) => e.originalEntry)).toEqual([
['9999', 'seed'],
['9999', 'seed', 'inner-leaf']
])
})
it('deduplicates entries with identical originalEntry tuples on re-flush', () => {
const host = buildHost()
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
const firstQuarantine = readHostQuarantine(host)
expect(firstQuarantine).toHaveLength(1)
host.properties.proxyWidgets = [['9999', 'seed']]
flushProxyWidgetMigration({ hostNode: host })
expect(readHostQuarantine(host)).toEqual(firstQuarantine)
})
})
describe('idempotency', () => {
it('clears properties.proxyWidgets after a successful flush', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgets).toBeUndefined()
})
it('re-running flush over a fully migrated host produces no further mutations', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({ hostNode: host })
const exposuresAfterFirst = usePreviewExposureStore()
.getExposures(host.rootGraph.id, String(host.id))
.map((e) => ({ ...e }))
expect(exposuresAfterFirst).toHaveLength(1)
flushProxyWidgetMigration({ hostNode: host })
expect(host.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual(exposuresAfterFirst)
})
})
describe('mixed cohort', () => {
it('migrates a mixed value+preview cohort in one flush, preserving entry order', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slot = n.addInput('seed', 'INT')
slot.widget = { name: 'seed' }
n.addWidget('number', 'seed', 0, () => {})
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
const subgraphInputCountBefore = host.subgraph.inputs.length
host.properties.proxyWidgets = [
[String(inner.id), 'seed'],
[String(inner.id), '$$canvas-image-preview']
]
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: [99]
})
expect(host.subgraph.inputs).toHaveLength(subgraphInputCountBefore + 1)
expect(host.subgraph.inputs.find((i) => i.name === 'seed')).toBeDefined()
const exposures = usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
expect(exposures).toHaveLength(1)
expect(exposures[0].sourcePreviewName).toBe('$$canvas-image-preview')
})
it('preserves sparse holes when supplied widgets_values is missing an index', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
const slotA = n.addInput('a', 'INT')
slotA.widget = { name: 'a' }
n.addWidget('number', 'a', 0, () => {})
const slotB = n.addInput('b', 'INT')
slotB.widget = { name: 'b' }
n.addWidget('number', 'b', 0, () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), 'a'],
[String(inner.id), 'b']
]
const sparse: unknown[] = []
sparse[1] = 'second-value'
flushProxyWidgetMigration({
hostNode: host,
hostWidgetValues: sparse
})
expect(host.subgraph.inputs.find((i) => i.name === 'a')).toBeDefined()
expect(host.subgraph.inputs.find((i) => i.name === 'b')).toBeDefined()
})
})
describe('integration with LGraph.configure', () => {
it('runs through LGraph.configure when the migration hook is wired', () => {
const host = buildHost()
const inner = addInnerNode(host, 'Inner', (n) => {
n.addWidget('text', '$$canvas-image-preview', '', () => {})
})
host.properties.proxyWidgets = [
[String(inner.id), '$$canvas-image-preview']
]
const serialized = host.rootGraph.serialize()
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
const reloadedGraph = new LGraph()
const subgraph = host.subgraph
const instanceData = host.serialize()
LiteGraph.registerNodeType(
subgraph.id,
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(reloadedGraph, subgraph, instanceData)
}
}
)
try {
reloadedGraph.configure(serialized)
} finally {
LiteGraph.unregisterNodeType(subgraph.id)
}
const reloadedHost = reloadedGraph.getNodeById(host.id)
expect(reloadedHost?.properties.proxyWidgets).toBeUndefined()
expect(
usePreviewExposureStore().getExposures(
host.rootGraph.id,
String(host.id)
)
).toEqual([
expect.objectContaining({
sourceNodeId: String(inner.id),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
})
})
describe('normalizeLegacyProxyWidgetEntry', () => {
function createHostWithInnerWidget(widgetName: string) {
const subgraph = createTestSubgraph()
const innerNode = new LGraphNode('InnerNode')
const input = innerNode.addInput('value', 'number')
innerNode.addWidget('number', widgetName, 0, () => {})
input.widget = { name: widgetName }
subgraph.add(innerNode)
const hostNode = createTestSubgraphNode(subgraph)
hostNode.graph!.add(hostNode)
return { innerNode, hostNode }
}
it('returns entry unchanged when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed'
})
})
it('returns entry unchanged with disambiguator when it already resolves', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'seed',
String(innerNode.id)
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'seed',
disambiguatingSourceNodeId: String(innerNode.id)
})
})
it('strips a single legacy prefix from widget name', () => {
const innerSubgraph = createTestSubgraph()
const samplerNode = new LGraphNode('Sampler')
const samplerInput = samplerNode.addInput('seed', 'number')
samplerNode.addWidget('number', 'noise_seed', 42, () => {})
samplerInput.widget = { name: 'noise_seed' }
innerSubgraph.add(samplerNode)
const hostNode = createTestSubgraphNode(innerSubgraph)
hostNode.graph!.add(hostNode)
const prefixedName = `${samplerNode.id}: noise_seed`
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(samplerNode.id),
prefixedName
)
expect(result.sourceWidgetName).toBe('noise_seed')
expect(result.disambiguatingSourceNodeId).toBe(String(samplerNode.id))
})
it('strips legacy prefix and surfaces it as disambiguator even when the bare name does not resolve', () => {
const { hostNode, innerNode } = createHostWithInnerWidget('seed')
const result = normalizeLegacyProxyWidgetEntry(
hostNode,
String(innerNode.id),
'999: nonexistent_widget'
)
expect(result).toEqual({
sourceNodeId: String(innerNode.id),
sourceWidgetName: 'nonexistent_widget',
disambiguatingSourceNodeId: '999'
})
})
})

View File

@@ -1,790 +0,0 @@
import { isEqual } from 'es-toolkit/compat'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
findHostInputForPromotion,
getPromotableWidgets,
isPreviewPseudoWidget
} from '@/core/graph/subgraph/promotionUtils'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { parseProxyWidgets } from '@/core/schemas/promotionSchema'
import type {
ProxyWidgetErrorQuarantineEntry,
ProxyWidgetQuarantineReason
} from '@/core/schemas/proxyWidgetQuarantineSchema'
import { parseProxyWidgetErrorQuarantine } from '@/core/schemas/proxyWidgetQuarantineSchema'
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type {
IBaseWidget,
TWidgetValue
} from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
interface LegacyProxyEntrySource extends PromotedWidgetSource {
disambiguatingSourceNodeId?: string
}
const LEGACY_PROXY_WIDGET_PREFIX_PATTERN = /^\s*(\d+)\s*:\s*(.+)$/
interface StrippedPrefix {
sourceWidgetName: string
deepestPrefixId?: string
}
function stripLegacyPrefixes(sourceWidgetName: string): StrippedPrefix {
let remaining = sourceWidgetName
let deepestPrefixId: string | undefined
while (true) {
const match = LEGACY_PROXY_WIDGET_PREFIX_PATTERN.exec(remaining)
if (!match) return { sourceWidgetName: remaining, deepestPrefixId }
deepestPrefixId = match[1]
remaining = match[2]
}
}
function canResolveLegacyProxy(
hostNode: SubgraphNode,
sourceNodeId: string,
widgetName: string
): boolean {
return (
resolveConcretePromotedWidget(hostNode, sourceNodeId, widgetName).status ===
'resolved'
)
}
export function normalizeLegacyProxyWidgetEntry(
hostNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): LegacyProxyEntrySource {
if (canResolveLegacyProxy(hostNode, sourceNodeId, sourceWidgetName)) {
return {
sourceNodeId,
sourceWidgetName,
...(disambiguatingSourceNodeId && { disambiguatingSourceNodeId })
}
}
const stripped = stripLegacyPrefixes(sourceWidgetName)
const patchDisambiguatingSourceNodeId =
stripped.deepestPrefixId ?? disambiguatingSourceNodeId
return {
sourceNodeId,
sourceWidgetName: stripped.sourceWidgetName,
...(patchDisambiguatingSourceNodeId && {
disambiguatingSourceNodeId: patchDisambiguatingSourceNodeId
})
}
}
function resolveSourceWidget(
sourceNode: LGraphNode,
sourceWidgetName: string,
disambiguatingSourceNodeId?: string
): IBaseWidget | undefined {
const widgets = sourceNode.widgets
if (widgets && disambiguatingSourceNodeId !== undefined) {
const byDisambiguator = widgets.find(
(w) =>
isPromotedWidgetView(w) &&
w.sourceNodeId === disambiguatingSourceNodeId &&
w.sourceWidgetName === sourceWidgetName
)
if (byDisambiguator) return byDisambiguator
// Disambiguator missed: fall back only to non-promoted same-name widgets.
// A sibling PromotedWidgetView would re-introduce the cross-binding bug.
const byName = widgets.find(
(w) => !isPromotedWidgetView(w) && w.name === sourceWidgetName
)
if (byName) return byName
}
return (
widgets?.find((w) => w.name === sourceWidgetName) ??
getPromotableWidgets(sourceNode).find((w) => w.name === sourceWidgetName)
)
}
interface FlushArgs {
hostNode: SubgraphNode
hostWidgetValues?: readonly unknown[]
}
interface PrimitiveBypassTargetRef {
targetNodeId: NodeId
targetSlot: number
}
type Plan =
| { kind: 'alreadyLinked'; subgraphInputName: string }
| { kind: 'createSubgraphInput'; sourceWidgetName: string }
| {
kind: 'primitiveBypass'
primitiveNodeId: NodeId
sourceWidgetName: string
targets: readonly PrimitiveBypassTargetRef[]
}
| { kind: 'previewExposure'; sourcePreviewName: string }
| { kind: 'quarantine'; reason: ProxyWidgetQuarantineReason }
interface PendingEntry {
normalized: LegacyProxyEntrySource
hostValue: TWidgetValue | undefined
isHole: boolean
plan: Plan
}
const PRIMITIVE_NODE_TYPE = 'PrimitiveNode'
const QUARANTINE_PROPERTY = 'proxyWidgetErrorQuarantine'
const QUARANTINE_VERSION = 1
const PROXY_BYPASS_MARKER_PROPERTY = 'proxyBypassedToSubgraphInput'
export function flushProxyWidgetMigration(args: FlushArgs): void {
const { hostNode, hostWidgetValues } = args
const tuples = parseProxyWidgets(hostNode.properties.proxyWidgets)
if (tuples.length === 0) return
const cohort: LegacyProxyEntrySource[] = tuples.map(
([sourceNodeId, sourceWidgetName, disambiguator]) =>
normalizeLegacyProxyWidgetEntry(
hostNode,
sourceNodeId,
sourceWidgetName,
disambiguator
)
)
const pending: PendingEntry[] = cohort.map((normalized, index) => {
const { value, isHole } = pickHostValue(hostWidgetValues, index)
return {
normalized,
hostValue: value,
isHole,
plan: classify(hostNode, normalized, cohort)
}
})
const previewStore = usePreviewExposureStore()
const quarantineToAppend: ProxyWidgetErrorQuarantineEntry[] = []
const primitiveCohorts = new Map<NodeId, PendingEntry[]>()
for (const entry of pending) {
switch (entry.plan.kind) {
case 'primitiveBypass': {
const c = primitiveCohorts.get(entry.plan.primitiveNodeId) ?? []
c.push(entry)
primitiveCohorts.set(entry.plan.primitiveNodeId, c)
break
}
case 'alreadyLinked': {
const r = repairAlreadyLinked(
hostNode,
entry,
entry.plan.subgraphInputName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'createSubgraphInput': {
const r = repairCreateSubgraphInput(
hostNode,
entry,
entry.plan.sourceWidgetName
)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'previewExposure': {
const r = migratePreview(hostNode, entry, previewStore, entry.plan)
if (!r.ok) quarantineToAppend.push(quarantineFor(entry, r.reason))
break
}
case 'quarantine':
quarantineToAppend.push(quarantineFor(entry, entry.plan.reason))
break
}
}
for (const c of primitiveCohorts.values()) {
const r = repairPrimitive(hostNode, c)
if (!r.ok)
for (const e of c) quarantineToAppend.push(quarantineFor(e, r.reason))
}
if (quarantineToAppend.length > 0) {
appendQuarantine(hostNode, quarantineToAppend)
}
delete hostNode.properties.proxyWidgets
}
function pickHostValue(
hostWidgetValues: readonly unknown[] | undefined,
index: number
): { value: TWidgetValue | undefined; isHole: boolean } {
if (
hostWidgetValues === undefined ||
index < 0 ||
index >= hostWidgetValues.length ||
!Object.hasOwn(hostWidgetValues, index)
) {
return { value: undefined, isHole: true }
}
const raw = hostWidgetValues[index]
if (!isWidgetValue(raw)) return { value: undefined, isHole: true }
return { value: raw, isHole: false }
}
function collectTargetsStrict(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] | undefined {
const subgraph = hostNode.subgraph
const output = primitiveNode.outputs?.[0]
const linkIds = output?.links ?? []
const targets: PrimitiveBypassTargetRef[] = []
for (const linkId of linkIds) {
const link = subgraph.links.get(linkId)
if (!link) return undefined
targets.push({
targetNodeId: link.target_id,
targetSlot: link.target_slot
})
}
return targets
}
function collectTargetsSkippingDangling(
hostNode: SubgraphNode,
primitiveNode: LGraphNode
): PrimitiveBypassTargetRef[] {
const subgraph = hostNode.subgraph
const linkIds = primitiveNode.outputs?.[0]?.links ?? []
return linkIds.flatMap((linkId) => {
const link = subgraph.links.get(linkId)
return link
? [{ targetNodeId: link.target_id, targetSlot: link.target_slot }]
: []
})
}
function cohortDuplicatesPrimitive(
cohort: readonly LegacyProxyEntrySource[],
primitiveNodeId: string
): boolean {
return (
cohort.filter((entry) => entry.sourceNodeId === primitiveNodeId).length >= 2
)
}
function classify(
hostNode: SubgraphNode,
normalized: LegacyProxyEntrySource,
cohort: readonly LegacyProxyEntrySource[]
): Plan {
const linkedInput = findHostInputForPromotion(
hostNode,
normalized.sourceNodeId,
normalized.sourceWidgetName
)
if (linkedInput) {
const ambiguous =
hostNode.inputs.filter((input) => {
const w = input._widget
return (
!!w &&
isPromotedWidgetView(w) &&
w.sourceNodeId === normalized.sourceNodeId &&
w.sourceWidgetName === normalized.sourceWidgetName
)
}).length > 1
if (ambiguous) {
return { kind: 'quarantine', reason: 'ambiguousSubgraphInput' }
}
return { kind: 'alreadyLinked', subgraphInputName: linkedInput.name }
}
const sourceNode = hostNode.subgraph.getNodeById(normalized.sourceNodeId)
if (!sourceNode) {
return { kind: 'quarantine', reason: 'missingSourceNode' }
}
if (sourceNode.type === PRIMITIVE_NODE_TYPE) {
const bypassedTo = sourceNode.properties?.[PROXY_BYPASS_MARKER_PROPERTY]
if (typeof bypassedTo === 'string') {
const existingInput = hostNode.inputs.find(
(input) => input.name === bypassedTo
)
if (existingInput) {
return { kind: 'alreadyLinked', subgraphInputName: existingInput.name }
}
}
const targets = collectTargetsSkippingDangling(hostNode, sourceNode)
const cohortDuplicated = cohortDuplicatesPrimitive(
cohort,
normalized.sourceNodeId
)
if (targets.length >= 1 || cohortDuplicated) {
return {
kind: 'primitiveBypass',
primitiveNodeId: sourceNode.id,
sourceWidgetName: normalized.sourceWidgetName,
targets
}
}
return { kind: 'quarantine', reason: 'unlinkedSourceWidget' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
normalized.sourceWidgetName,
normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { kind: 'quarantine', reason: 'missingSourceWidget' }
}
if (
normalized.sourceWidgetName.startsWith('$$') ||
isPreviewPseudoWidget(sourceWidget)
) {
return {
kind: 'previewExposure',
sourcePreviewName: normalized.sourceWidgetName
}
}
return {
kind: 'createSubgraphInput',
sourceWidgetName: normalized.sourceWidgetName
}
}
function applyHostValue(widget: IBaseWidget, entry: PendingEntry): void {
if (entry.isHole) return
if (
isPromotedWidgetView(widget) &&
typeof widget.hydrateHostValue === 'function'
) {
widget.hydrateHostValue(entry.hostValue)
return
}
console.error(
'[proxyWidgetMigration] applyHostValue called with non-promoted widget; refusing to write to shared interior',
{ widgetName: widget.name, type: widget.type }
)
}
function addUniqueSubgraphInput(
subgraph: Subgraph,
baseName: string,
type: string
): SubgraphInput {
const existingNames = subgraph.inputs.map((input) => input.name)
const uniqueName = nextUniqueName(baseName, existingNames)
return subgraph.addInput(uniqueName, type)
}
type Outcome<TOk, TReason = ProxyWidgetQuarantineReason> =
| ({ ok: true } & TOk)
| { ok: false; reason: TReason }
type RepairValueResult = Outcome<{ subgraphInputName: string }>
function repairAlreadyLinked(
hostNode: SubgraphNode,
entry: PendingEntry,
subgraphInputName: string
): RepairValueResult {
// Resolve by name directly: source-id matching would miss for primitive
// bypasses, where the view's `sourceNodeId` is the consumer, not the
// primitive.
const matches = hostNode.inputs.filter(
(input) => input.name === subgraphInputName
)
if (matches.length === 0) {
return { ok: false, reason: 'missingSubgraphInput' }
}
if (matches.length > 1) {
return { ok: false, reason: 'ambiguousSubgraphInput' }
}
const hostInput = matches[0]
if (!hostInput._widget) {
return { ok: false, reason: 'missingSubgraphInput' }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: hostInput.name }
}
function repairCreateSubgraphInput(
hostNode: SubgraphNode,
entry: PendingEntry,
sourceWidgetName: string
): RepairValueResult {
const subgraph = hostNode.subgraph
const sourceNode: LGraphNode | null = subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const sourceWidget = resolveSourceWidget(
sourceNode,
sourceWidgetName,
entry.normalized.disambiguatingSourceNodeId
)
if (!sourceWidget) {
return { ok: false, reason: 'missingSourceWidget' }
}
const slot: INodeInputSlot | undefined =
sourceNode.getSlotFromWidget(sourceWidget)
if (!slot) {
console.warn(
'[proxyWidgetMigration] source widget has no backing input slot; quarantining',
{
sourceNodeId: entry.normalized.sourceNodeId,
sourceWidgetName
}
)
return { ok: false, reason: 'missingSubgraphInput' }
}
const slotType = String(slot.type ?? sourceWidget.type ?? '*')
const newSubgraphInput = addUniqueSubgraphInput(
subgraph,
sourceWidgetName,
slotType
)
if (slot.label !== undefined) newSubgraphInput.label = slot.label
const link = newSubgraphInput.connect(slot, sourceNode)
if (!link) {
subgraph.removeInput(newSubgraphInput)
return { ok: false, reason: 'missingSubgraphInput' }
}
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
if (!hostInput?._widget) {
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
applyHostValue(hostInput._widget, entry)
return { ok: true, subgraphInputName: newSubgraphInput.name }
}
type RepairPrimitiveResult = Outcome<
{ subgraphInputName: string; reconnectCount: number },
'primitiveBypassFailed'
>
const PRIMITIVE_FAILED: RepairPrimitiveResult = {
ok: false,
reason: 'primitiveBypassFailed'
}
interface SnapshotLink extends PrimitiveBypassTargetRef {
primitiveSlot: number
}
interface CohortValidationOk {
ok: true
primitiveNodeId: NodeId
sourceWidgetName: string
uniqueEntries: readonly PendingEntry[]
}
function failPrimitive(message: string, ctx?: unknown): RepairPrimitiveResult {
console.warn(`[proxyWidgetMigration] ${message}`, ctx)
return PRIMITIVE_FAILED
}
function userRenamedTitle(primitiveNode: LGraphNode): string | undefined {
const title = primitiveNode.title
return title && title !== PRIMITIVE_NODE_TYPE ? title : undefined
}
function validateCohort(
cohort: readonly PendingEntry[]
): CohortValidationOk | { ok: false } {
const first = cohort[0]
if (!first || first.plan.kind !== 'primitiveBypass') return { ok: false }
const { primitiveNodeId, sourceWidgetName } = first.plan
for (const entry of cohort) {
if (
entry.plan.kind !== 'primitiveBypass' ||
entry.plan.primitiveNodeId !== primitiveNodeId ||
entry.plan.sourceWidgetName !== sourceWidgetName
) {
return { ok: false }
}
}
const uniqueEntries: PendingEntry[] = []
for (const entry of cohort) {
if (!uniqueEntries.some((k) => isEqual(k.normalized, entry.normalized))) {
uniqueEntries.push(entry)
}
}
return { ok: true, primitiveNodeId, sourceWidgetName, uniqueEntries }
}
function rollback(
hostNode: SubgraphNode,
primitiveNode: LGraphNode,
newSubgraphInput: SubgraphInput | undefined,
snapshot: readonly SnapshotLink[]
): void {
if (newSubgraphInput) {
try {
hostNode.subgraph.removeInput(newSubgraphInput)
} catch (e) {
console.warn('[proxyWidgetMigration] rollback removeInput failed', e)
}
}
for (const link of snapshot) {
const targetNode = hostNode.subgraph.getNodeById(link.targetNodeId)
if (!targetNode) continue
primitiveNode.connect(link.primitiveSlot, targetNode, link.targetSlot)
}
}
function repairPrimitive(
hostNode: SubgraphNode,
cohort: readonly PendingEntry[]
): RepairPrimitiveResult {
const validated = validateCohort(cohort)
if (!validated.ok)
return failPrimitive('cohort validation failed', { cohort })
const subgraph = hostNode.subgraph
const primitiveNode = subgraph.getNodeById(validated.primitiveNodeId)
if (!primitiveNode) return failPrimitive('primitive node missing', validated)
if (primitiveNode.type !== PRIMITIVE_NODE_TYPE) {
return failPrimitive('node is not a PrimitiveNode', primitiveNode.type)
}
const targets = collectTargetsStrict(hostNode, primitiveNode)
if (!targets?.length)
return failPrimitive('no targets to reconnect', validated)
const primitiveOutput = primitiveNode.outputs?.[0]
if (!primitiveOutput) return failPrimitive('primitive has no output')
const primitiveOutputType = String(primitiveOutput.type ?? '*')
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode) return failPrimitive('target node missing', target)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot) return failPrimitive('target slot missing', target)
const targetType = String(targetSlot.type ?? '*')
if (
targetType !== primitiveOutputType &&
targetType !== '*' &&
primitiveOutputType !== '*'
) {
return failPrimitive('target slot type incompatible', {
target,
targetType,
primitiveOutputType
})
}
}
const baseName = userRenamedTitle(primitiveNode) ?? validated.sourceWidgetName
const snapshot: SnapshotLink[] = (primitiveOutput.links ?? [])
.map((id) => subgraph.links.get(id))
.filter((l): l is NonNullable<typeof l> => l !== undefined)
.map((l) => ({
primitiveSlot: l.origin_slot,
targetNodeId: l.target_id,
targetSlot: l.target_slot
}))
let newSubgraphInput: SubgraphInput | undefined
try {
newSubgraphInput = addUniqueSubgraphInput(
subgraph,
baseName,
primitiveOutputType
)
for (const snap of snapshot) {
const targetNode = subgraph.getNodeById(snap.targetNodeId)
if (!targetNode)
throw new Error(
`target node ${snap.targetNodeId} disappeared mid-mutation`
)
targetNode.disconnectInput(snap.targetSlot, false)
}
for (const target of targets) {
const targetNode = subgraph.getNodeById(target.targetNodeId)
if (!targetNode)
throw new Error(`target node ${target.targetNodeId} disappeared`)
const targetSlot = targetNode.inputs?.[target.targetSlot]
if (!targetSlot)
throw new Error(`target slot ${target.targetSlot} disappeared`)
const link = newSubgraphInput.connect(targetSlot, targetNode)
if (!link) {
throw new Error('SubgraphInput.connect returned no link')
}
}
} catch (e) {
rollback(hostNode, primitiveNode, newSubgraphInput, snapshot)
return failPrimitive('mutation failed; rolled back', { error: e })
}
// Apply through the host's input mirror (PromotedWidgetView), NOT
// `newSubgraphInput._widget`: the interior is shared across hosts.
const hostInput = hostNode.inputs.find(
(input) => input.name === newSubgraphInput.name
)
const hostInputWidget = hostInput?._widget
if (hostInputWidget) {
const valueEntry = validated.uniqueEntries.find((e) => !e.isHole)
if (valueEntry) {
applyHostValue(hostInputWidget, valueEntry)
} else {
const primitiveValue = primitiveNode.widgets?.find(
(w) => w.name === validated.sourceWidgetName
)?.value as TWidgetValue | undefined
if (primitiveValue !== undefined) {
applyHostValue(hostInputWidget, {
...validated.uniqueEntries[0],
hostValue: primitiveValue,
isHole: false
})
}
}
}
primitiveNode.properties ??= {}
primitiveNode.properties[PROXY_BYPASS_MARKER_PROPERTY] = newSubgraphInput.name
return {
ok: true,
subgraphInputName: newSubgraphInput.name,
reconnectCount: targets.length
}
}
type MigratePreviewResult = Outcome<
{ previewName: string },
'missingSourceNode' | 'missingSourceWidget'
>
function migratePreview(
hostNode: SubgraphNode,
entry: PendingEntry,
store: ReturnType<typeof usePreviewExposureStore>,
plan: { kind: 'previewExposure'; sourcePreviewName: string }
): MigratePreviewResult {
const sourceNode = hostNode.subgraph.getNodeById(
entry.normalized.sourceNodeId
)
if (!sourceNode) {
return { ok: false, reason: 'missingSourceNode' }
}
const isCanonicalPseudo = plan.sourcePreviewName.startsWith('$$')
if (!isCanonicalPseudo) {
const widget = sourceNode.widgets?.find(
(w) => w.name === plan.sourcePreviewName
)
if (!widget) {
return { ok: false, reason: 'missingSourceWidget' }
}
}
const hostNodeLocator = String(hostNode.id)
const existing = store
.getExposures(hostNode.rootGraph.id, hostNodeLocator)
.find(
(exposure) =>
exposure.sourceNodeId === entry.normalized.sourceNodeId &&
exposure.sourcePreviewName === plan.sourcePreviewName
)
if (existing) return { ok: true, previewName: existing.name }
const added = store.addExposure(hostNode.rootGraph.id, hostNodeLocator, {
sourceNodeId: entry.normalized.sourceNodeId,
sourcePreviewName: plan.sourcePreviewName
})
return { ok: true, previewName: added.name }
}
function quarantineFor(
entry: PendingEntry,
reason: ProxyWidgetQuarantineReason
): ProxyWidgetErrorQuarantineEntry {
const { sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId } =
entry.normalized
const originalEntry: SerializedProxyWidgetTuple = disambiguatingSourceNodeId
? [sourceNodeId, sourceWidgetName, disambiguatingSourceNodeId]
: [sourceNodeId, sourceWidgetName]
return makeQuarantineEntry({
originalEntry,
reason,
hostValue: entry.isHole ? undefined : entry.hostValue
})
}
export function appendQuarantine(
hostNode: SubgraphNode,
entries: readonly ProxyWidgetErrorQuarantineEntry[]
): void {
if (entries.length === 0) return
const existing = parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
const merged = [...existing]
for (const candidate of entries) {
if (
!merged.some((e) => isEqual(e.originalEntry, candidate.originalEntry))
) {
merged.push(candidate)
}
}
if (merged.length === 0) delete hostNode.properties[QUARANTINE_PROPERTY]
else hostNode.properties[QUARANTINE_PROPERTY] = merged
}
export function readHostQuarantine(
hostNode: SubgraphNode
): ProxyWidgetErrorQuarantineEntry[] {
return parseProxyWidgetErrorQuarantine(
hostNode.properties[QUARANTINE_PROPERTY]
)
}
export function makeQuarantineEntry(args: {
originalEntry: SerializedProxyWidgetTuple
reason: ProxyWidgetQuarantineReason
hostValue?: TWidgetValue
}): ProxyWidgetErrorQuarantineEntry {
const entry: ProxyWidgetErrorQuarantineEntry = {
originalEntry: args.originalEntry,
reason: args.reason,
attemptedAtVersion: QUARANTINE_VERSION
}
if (args.hostValue !== undefined) {
entry.hostValue = args.hostValue
}
return entry
}

View File

@@ -1,283 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { PreviewExposureChainContext } from './previewExposureChain'
import { resolvePreviewExposureChain } from './previewExposureChain'
const rootGraphA = 'root-a' as UUID
const rootGraphB = 'root-b' as UUID
interface FixtureExposure extends PreviewExposure {}
interface NestedHostMapping {
fromHostLocator: string
fromSourceNodeId: string
toRootGraphId: UUID
toHostLocator: string
}
function makeContext(
exposureMap: Map<string, FixtureExposure[]>,
nested: NestedHostMapping[]
): PreviewExposureChainContext {
return {
getExposures(rootGraphId, hostLocator) {
return exposureMap.get(`${rootGraphId}|${hostLocator}`) ?? []
},
resolveNestedHost(_rootGraphId, hostLocator, sourceNodeId) {
const match = nested.find(
(n) =>
n.fromHostLocator === hostLocator &&
n.fromSourceNodeId === sourceNodeId
)
if (!match) return undefined
return {
rootGraphId: match.toRootGraphId,
hostNodeLocator: match.toHostLocator
}
}
}
}
describe(resolvePreviewExposureChain, () => {
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
})
it('returns undefined when the named exposure is not on the starting host', () => {
const ctx = makeContext(new Map(), [])
expect(
resolvePreviewExposureChain(rootGraphA, 'host-a', 'absent', ctx)
).toBeUndefined()
})
it('returns a single-step chain when the source is a leaf (no nested host)', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[
{
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'preview',
ctx
)
expect(result).toEqual({
steps: [
{
rootGraphId: rootGraphA,
hostNodeLocator: 'host-a',
exposure: {
name: 'preview',
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
}
],
leaf: {
rootGraphId: rootGraphA,
sourceNodeId: '42',
sourcePreviewName: '$$canvas-image-preview'
}
})
})
it('walks one nested host and returns a two-step chain', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer-preview',
sourceNodeId: '99',
sourcePreviewName: 'inner-preview'
}
]
],
[
`${rootGraphA}|host-inner`,
[
{
name: 'inner-preview',
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer-preview',
ctx
)
expect(result?.steps).toHaveLength(2)
expect(result?.steps[0].hostNodeLocator).toBe('host-outer')
expect(result?.steps[1].hostNodeLocator).toBe('host-inner')
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: 'leaf-node',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('walks two nested hosts (three-step chain) crossing a root graph boundary', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-1`,
[
{
name: 'p1',
sourceNodeId: 'sub-a',
sourcePreviewName: 'p2'
}
]
],
[
`${rootGraphA}|host-2`,
[
{
name: 'p2',
sourceNodeId: 'sub-b',
sourcePreviewName: 'p3'
}
]
],
[
`${rootGraphB}|host-3`,
[
{
name: 'p3',
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
}
]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-1',
fromSourceNodeId: 'sub-a',
toRootGraphId: rootGraphA,
toHostLocator: 'host-2'
},
{
fromHostLocator: 'host-2',
fromSourceNodeId: 'sub-b',
toRootGraphId: rootGraphB,
toHostLocator: 'host-3'
}
])
const result = resolvePreviewExposureChain(rootGraphA, 'host-1', 'p1', ctx)
expect(result?.steps).toHaveLength(3)
expect(result?.steps.map((s) => s.exposure.name)).toEqual([
'p1',
'p2',
'p3'
])
expect(result?.leaf).toEqual({
rootGraphId: rootGraphB,
sourceNodeId: 'leaf',
sourcePreviewName: '$$canvas-image-preview'
})
})
it('terminates at outer step when nested host has no matching exposure', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-outer`,
[
{
name: 'outer',
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
}
]
],
[`${rootGraphA}|host-inner`, []]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-outer',
fromSourceNodeId: '99',
toRootGraphId: rootGraphA,
toHostLocator: 'host-inner'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-outer',
'outer',
ctx
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf).toEqual({
rootGraphId: rootGraphA,
sourceNodeId: '99',
sourcePreviewName: 'missing-on-inner'
})
})
it('detects cycles, warns, and stops walking', () => {
const exposureMap = new Map<string, FixtureExposure[]>([
[
`${rootGraphA}|host-a`,
[{ name: 'cyclic', sourceNodeId: 'sub', sourcePreviewName: 'cyclic' }]
]
])
const ctx = makeContext(exposureMap, [
{
fromHostLocator: 'host-a',
fromSourceNodeId: 'sub',
toRootGraphId: rootGraphA,
toHostLocator: 'host-a'
}
])
const result = resolvePreviewExposureChain(
rootGraphA,
'host-a',
'cyclic',
ctx
)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('cycle detected')
)
expect(result?.steps).toHaveLength(1)
expect(result?.leaf.sourceNodeId).toBe('sub')
})
})

View File

@@ -1,105 +0,0 @@
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
export interface ResolvedPreviewChainStep {
rootGraphId: UUID
hostNodeLocator: string
exposure: PreviewExposure
}
export interface ResolvedPreviewChain {
steps: readonly ResolvedPreviewChainStep[]
leaf: {
rootGraphId: UUID
sourceNodeId: string
sourcePreviewName: string
}
}
export interface PreviewExposureChainContext {
getExposures(
rootGraphId: UUID,
hostNodeLocator: string
): readonly PreviewExposure[]
resolveNestedHost(
rootGraphId: UUID,
hostNodeLocator: string,
sourceNodeId: string
): { rootGraphId: UUID; hostNodeLocator: string } | undefined
}
const MAX_CHAIN_DEPTH = 32
export function resolvePreviewExposureChain(
rootGraphId: UUID,
hostNodeLocator: string,
name: string,
ctx: PreviewExposureChainContext
): ResolvedPreviewChain | undefined {
const steps: ResolvedPreviewChainStep[] = []
const visited = new Set<string>()
let currentRootGraphId: UUID = rootGraphId
let currentHost = hostNodeLocator
let currentName = name
const chainFromLastStep = (): ResolvedPreviewChain | undefined => {
if (steps.length === 0) return undefined
const lastStep = steps[steps.length - 1]
return {
steps,
leaf: {
rootGraphId: lastStep.rootGraphId,
sourceNodeId: lastStep.exposure.sourceNodeId,
sourcePreviewName: lastStep.exposure.sourcePreviewName
}
}
}
for (let depth = 0; depth < MAX_CHAIN_DEPTH; depth++) {
const key = `${currentRootGraphId}|${currentHost}|${currentName}`
if (visited.has(key)) {
console.warn(
`[previewExposureChain] cycle detected at ${key}; terminating walk`
)
return chainFromLastStep()
}
visited.add(key)
const exposure = ctx
.getExposures(currentRootGraphId, currentHost)
.find((e) => e.name === currentName)
if (!exposure) return chainFromLastStep()
steps.push({
rootGraphId: currentRootGraphId,
hostNodeLocator: currentHost,
exposure
})
const nested = ctx.resolveNestedHost(
currentRootGraphId,
currentHost,
exposure.sourceNodeId
)
if (!nested) {
return {
steps,
leaf: {
rootGraphId: currentRootGraphId,
sourceNodeId: exposure.sourceNodeId,
sourcePreviewName: exposure.sourcePreviewName
}
}
}
currentRootGraphId = nested.rootGraphId
currentHost = nested.hostNodeLocator
currentName = exposure.sourcePreviewName
}
console.warn(
`[previewExposureChain] max chain depth (${MAX_CHAIN_DEPTH}) reached; terminating walk`
)
return chainFromLastStep()
}

View File

@@ -1,7 +1,6 @@
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 type { WidgetEntityId } from '@/world/entityIds'
export interface ResolvedPromotedWidget {
node: LGraphNode
@@ -11,17 +10,20 @@ export interface ResolvedPromotedWidget {
export interface PromotedWidgetSource {
sourceNodeId: string
sourceWidgetName: string
disambiguatingSourceNodeId?: string
}
export interface PromotedWidgetView extends IBaseWidget {
readonly node: SubgraphNode
readonly entityId: WidgetEntityId
readonly sourceNodeId: string
readonly sourceWidgetName: string
hydrateHostValue(value: IBaseWidget['value']): void
ensureHostWidgetState(): void
/**
* 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(

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,10 @@ import type { CanvasPointer } from '@/lib/litegraph/src/CanvasPointer'
import type { Point } from '@/lib/litegraph/src/interfaces'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { BaseWidget } from '@/lib/litegraph/src/widgets/BaseWidget'
import { toConcreteWidget } from '@/lib/litegraph/src/widgets/widgetMap'
import { t } from '@/i18n'
import { nextValueForLinkedTarget } from '@/scripts/valueControl'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import {
stripGraphPrefix,
@@ -22,9 +20,6 @@ import {
} from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { matchPromotedInput } from '@/core/graph/subgraph/matchPromotedInput'
import { hasWidgetNode } from '@/core/graph/subgraph/widgetNodeTypeGuard'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
import { ensureWidgetState, getWidgetState } from '@/world/widgetValueIO'
import { isPromotedWidgetView } from './promotedWidgetTypes'
import type { PromotedWidgetView as IPromotedWidgetView } from './promotedWidgetTypes'
@@ -38,6 +33,14 @@ interface SubgraphSlotRef {
displayName?: string
}
function isWidgetValue(value: unknown): value is IBaseWidget['value'] {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
type LegacyMouseWidget = IBaseWidget & {
mouse: (e: CanvasPointerEvent, pos: Point, node: LGraphNode) => unknown
}
@@ -53,6 +56,7 @@ export function createPromotedWidgetView(
nodeId: string,
widgetName: string,
displayName?: string,
disambiguatingSourceNodeId?: string,
identityName?: string
): IPromotedWidgetView {
return new PromotedWidgetView(
@@ -60,6 +64,7 @@ export function createPromotedWidgetView(
nodeId,
widgetName,
displayName,
disambiguatingSourceNodeId,
identityName
)
}
@@ -86,16 +91,16 @@ class PromotedWidgetView implements IPromotedWidgetView {
private cachedDeepestByFrame?: { node: LGraphNode; widget: IBaseWidget }
private cachedDeepestFrame = -1
/** Cached reference to the bound subgraph slot, set at construction. */
private _boundSlot?: SubgraphSlotRef
private _boundSlotVersion = -1
private _lastAutoSeededValue?: IBaseWidget['value']
constructor(
private readonly subgraphNode: SubgraphNode,
nodeId: string,
widgetName: string,
private readonly displayName?: string,
readonly disambiguatingSourceNodeId?: string,
private readonly identityName?: string
) {
this.sourceNodeId = nodeId
@@ -111,10 +116,6 @@ class PromotedWidgetView implements IPromotedWidgetView {
return this.identityName ?? this.sourceWidgetName
}
get entityId(): WidgetEntityId {
return widgetEntityId(this.graphId, this.subgraphNode.id, this.name)
}
get y(): number {
return this.yValue
}
@@ -149,78 +150,61 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
get value(): IBaseWidget['value'] {
const hostState = this.getHostWidgetState()
if (hostState && isWidgetValue(hostState.value)) return hostState.value
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
set value(value: IBaseWidget['value']) {
this.setHostWidgetState(value)
}
const linkedWidgets = this.getLinkedInputWidgets()
if (linkedWidgets.length > 0) {
const widgetStore = useWidgetValueStore()
let didUpdateState = false
for (const linkedWidget of linkedWidgets) {
const state = widgetStore.getWidget(
this.graphId,
linkedWidget.nodeId,
linkedWidget.widgetName
)
if (state) {
state.value = value
didUpdateState = true
}
}
private getHostWidgetState(): WidgetState | undefined {
return getWidgetState(this.entityId)
}
const resolved = this.resolveDeepest()
if (resolved) {
const resolvedState = widgetStore.getWidget(
this.graphId,
stripGraphPrefix(String(resolved.node.id)),
resolved.widget.name
)
if (resolvedState) {
resolvedState.value = value
didUpdateState = true
}
}
private setHostWidgetState(value: IBaseWidget['value']): void {
if (!isWidgetValue(value)) return
if (didUpdateState) return
}
const state = this.getHostWidgetState()
const state = this.getWidgetState()
if (state) {
state.value = value
this._lastAutoSeededValue = undefined
return
}
this.registerHostWidgetState(value)
this._lastAutoSeededValue = undefined
}
ensureHostWidgetState(): void {
const fallback = this.fallbackEffectiveValue()
const existing = this.getHostWidgetState()
if (existing) {
if (
this._lastAutoSeededValue !== undefined &&
existing.value === this._lastAutoSeededValue &&
isWidgetValue(fallback) &&
fallback !== existing.value
) {
existing.value = fallback
this._lastAutoSeededValue = fallback
}
return
const resolved = this.resolveAtHost()
if (resolved && isWidgetValue(value)) {
resolved.widget.value = value
}
this.registerHostWidgetState(fallback)
this._lastAutoSeededValue = fallback
}
private fallbackEffectiveValue(): IBaseWidget['value'] {
const state = this.getWidgetState()
if (state && isWidgetValue(state.value)) return state.value
return this.resolveAtHost()?.widget.value
}
private registerHostWidgetState(value: IBaseWidget['value']): void {
const resolved = this.resolveDeepest()
ensureWidgetState(this.entityId, {
type: resolved?.widget.type ?? 'button',
value,
options: { ...(resolved?.widget.options ?? {}) },
label: this.displayName,
serialize: this.serialize,
disabled: this.computedDisabled
})
}
get label(): string | undefined {
const slot = this.getBoundSubgraphSlot()
if (slot) return slot.label ?? slot.displayName ?? slot.name
// Fall back to persisted widget state (survives save/reload before
// the slot binding is established) then to construction displayName.
const state = this.getWidgetState()
return state?.label ?? this.displayName
}
@@ -228,14 +212,20 @@ class PromotedWidgetView implements IPromotedWidgetView {
set label(value: string | undefined) {
const slot = this.getBoundSubgraphSlot()
if (slot) slot.label = value || undefined
// Also persist to widget state store for save/reload resilience
const state = this.getWidgetState()
if (state) state.label = value
}
hydrateHostValue(value: IBaseWidget['value']): void {
this.setHostWidgetState(value)
}
/**
* Returns the cached bound subgraph slot reference, refreshing only when
* the subgraph node's input list has changed (length mismatch).
*
* Note: Using length as the cache key works because the returned reference
* is the same mutable slot object. When slot properties (label, name) change,
* the caller reads fresh values from that reference. The cache only needs
* to invalidate when slots are added or removed, which changes length.
*/
private getBoundSubgraphSlot(): SubgraphSlotRef | undefined {
const version = this.subgraphNode.inputs?.length ?? 0
if (this._boundSlotVersion === version) return this._boundSlot
@@ -316,6 +306,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
projected.drawWidget(ctx, {
width: widgetWidth,
showText: !lowQuality,
suppressPromotedOutline: true,
previewImages: resolved.node.imgs
})
} finally {
@@ -360,34 +351,14 @@ class PromotedWidgetView implements IPromotedWidgetView {
this.resolveAtHost()?.widget.callback?.(value, canvas, node, pos, e)
}
afterQueued({
isPartialExecution
}: { isPartialExecution?: boolean } = {}): void {
this.applyValueControlToHost(isPartialExecution)
}
private applyValueControlToHost(isPartialExecution?: boolean): void {
if (this.subgraphNode.getSlotFromWidget(this)?.link != null) return
const resolved = this.resolveAtHost()
const next = nextValueForLinkedTarget({
target: this,
linkedWidgets: resolved?.widget.linkedWidgets,
nodeId: this.subgraphNode.id,
isPartialExecution
})
if (next === undefined) return
this.hydrateHostValue(next)
}
private resolveAtHost():
| { node: LGraphNode; widget: IBaseWidget }
| undefined {
return resolvePromotedWidgetAtHost(
this.subgraphNode,
this.sourceNodeId,
this.sourceWidgetName
this.sourceWidgetName,
this.disambiguatingSourceNodeId
)
}
@@ -401,7 +372,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
@@ -441,7 +413,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
)
}
@@ -568,6 +542,7 @@ class PromotedWidgetView implements IPromotedWidgetView {
}
}
/** Checks if a widget is a BaseDOMWidget (DOMWidget or ComponentWidget). */
function isBaseDOMWidget(
widget: IBaseWidget
): widget is IBaseWidget & { id: string } {

View File

@@ -9,17 +9,7 @@ import {
createTestSubgraphNode
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
function widgetSourceNodeId(w: IBaseWidget): string | undefined {
return isPromotedWidgetView(w) ? w.sourceNodeId : undefined
}
type TestPromotedWidget = IBaseWidget & {
sourceNodeId: string
sourceWidgetName: string
}
import { usePromotionStore } from '@/stores/promotionStore'
const updatePreviewsMock = vi.hoisted(() => vi.fn())
vi.mock('@/services/litegraphService', () => ({
@@ -28,19 +18,12 @@ vi.mock('@/services/litegraphService', () => ({
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
autoExposeKnownPreviewNodes,
demoteWidget,
getPromotableWidgets,
getWidgetName,
hasUnpromotedWidgets,
isLinkedPromotion,
isPreviewPseudoWidget,
isWidgetPromotedOnSubgraphNode,
promoteValueWidgetViaSubgraphInput,
promoteRecommendedWidgets,
pruneDisconnected,
reorderSubgraphInputsByName,
reorderSubgraphInputsByWidgetOrder
pruneDisconnected
} from './promotionUtils'
function widget(
@@ -51,34 +34,6 @@ function widget(
return fromPartial<IBaseWidget>({ name: 'widget', ...overrides })
}
/**
* Builds a host SubgraphNode whose subgraph contains two source nodes that
* share a widget name (`text`), then promotes both — forcing the second
* promotion to be disambiguated to `text_1`.
*/
function buildDuplicateNamePromotion() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const nodeA = new LGraphNode('SourceA')
subgraph.add(nodeA)
const inputA = nodeA.addInput('text', 'STRING')
const widgetA = nodeA.addWidget('text', 'text', 'a', () => {})
inputA.widget = { name: widgetA.name }
const nodeB = new LGraphNode('SourceB')
subgraph.add(nodeB)
const inputB = nodeB.addInput('text', 'STRING')
const widgetB = nodeB.addWidget('text', 'text', 'b', () => {})
inputB.widget = { name: widgetB.name }
expect(promoteValueWidgetViaSubgraphInput(host, nodeA, widgetA).ok).toBe(true)
expect(promoteValueWidgetViaSubgraphInput(host, nodeB, widgetB).ok).toBe(true)
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text', 'text_1'])
return { subgraph, host, nodeA, widgetA, nodeB, widgetB }
}
describe('isPreviewPseudoWidget', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
@@ -157,64 +112,58 @@ describe('pruneDisconnected', () => {
vi.restoreAllMocks()
})
it('removes disconnected linked inputs and emits a dev warning', () => {
it('removes disconnected entries and emits a dev warning', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
subgraphNode.subgraph.add(interiorNode)
const keptInput = interiorNode.addInput('kept', 'STRING')
const keptWidget = interiorNode.addWidget('text', 'kept', 'value', () => {})
keptInput.widget = { name: keptWidget.name }
promoteValueWidgetViaSubgraphInput(subgraphNode, interiorNode, keptWidget)
interiorNode.addWidget('text', 'kept', 'value', () => {})
const missingWidgetInput = subgraph.addInput('missing-widget', 'STRING')
missingWidgetInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'missing-widget'
})
const missingNodeInput = subgraph.addInput('missing-node', 'STRING')
missingNodeInput._widget = fromPartial<TestPromotedWidget>({
sourceNodeId: '9999',
sourceWidgetName: 'missing-node'
})
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ 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(() => {})
pruneDisconnected(subgraphNode)
expect(subgraph.inputs.map((input) => input.name)).toEqual(['kept'])
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{ sourceNodeId: String(interiorNode.id), sourceWidgetName: 'kept' }
])
expect(warnSpy).toHaveBeenCalledOnce()
})
it('does not prune preview exposures for PreviewImage nodes', () => {
it('keeps virtual canvas preview promotions for PreviewImage nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('PreviewImage')
interiorNode.type = 'PreviewImage'
subgraphNode.subgraph.add(interiorNode)
const hostLocator = String(subgraphNode.id)
usePreviewExposureStore().addExposure(
subgraphNode.rootGraph.id,
hostLocator,
const store = usePromotionStore()
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{
sourceNodeId: String(interiorNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
)
])
pruneDisconnected(subgraphNode)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(interiorNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
})
@@ -283,50 +232,6 @@ describe('promoteRecommendedWidgets', () => {
updatePreviewsMock.mockReset()
})
it('promotes recommended value widgets through linked subgraph inputs', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Sampler')
const input = interiorNode.addInput('seed', 'INT')
const seedWidget = interiorNode.addWidget('number', 'seed', 123, () => {})
input.widget = { name: seedWidget.name }
subgraph.add(interiorNode)
promoteRecommendedWidgets(subgraphNode)
const linkedInput = subgraph.inputs.find((slot) => slot.name === 'seed')
expect(linkedInput).toBeDefined()
expect(input.link).not.toBeNull()
expect(linkedInput?.linkIds).toContain(input.link)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('promotes virtual previews through preview exposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
promoteRecommendedWidgets(subgraphNode)
const hostLocator = String(subgraphNode.id)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toEqual([
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
])
expect(subgraph.inputs).toHaveLength(0)
expect(subgraphNode.serialize().properties?.proxyWidgets).toBeUndefined()
})
it('skips deferred updatePreviews when a preview widget already exists', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
@@ -347,7 +252,7 @@ describe('promoteRecommendedWidgets', () => {
expect(updatePreviewsMock).not.toHaveBeenCalled()
})
it('eagerly exposes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
it('eagerly promotes virtual preview widget for CANVAS_IMAGE_PREVIEW nodes', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
@@ -356,86 +261,36 @@ describe('promoteRecommendedWidgets', () => {
promoteRecommendedWidgets(subgraphNode)
const hostLocator = String(subgraphNode.id)
const store = usePromotionStore()
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
hostLocator
)
).toContainEqual({
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(glslNode.id),
sourcePreviewName: 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()
})
})
describe('autoExposeKnownPreviewNodes', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
updatePreviewsMock.mockReset()
})
it('auto-exposes previews when host has no persisted previewExposures property', () => {
it('registers $$canvas-image-preview on configure for GLSLShader in saved workflow', () => {
// Simulate loading a saved workflow where proxyWidgets does NOT contain
// the $$canvas-image-preview entry (e.g. blueprint authored before the
// promotion system, or old workflow save).
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toHaveLength(1)
})
it('does not auto-expose when host has empty persisted previewExposures (user cleared)', () => {
const subgraph = createTestSubgraph()
// Create subgraphNode — constructor calls configure → _internalConfigureAfterSlots
// which eagerly registers $$canvas-image-preview for supported node types
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode.properties.previewExposures = []
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
autoExposeKnownPreviewNodes(subgraphNode)
const store = usePromotionStore()
expect(
usePreviewExposureStore().getExposures(
subgraphNode.rootGraph.id,
String(subgraphNode.id)
)
).toEqual([])
})
it('does not auto-expose when host has non-empty persisted previewExposures', () => {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
const glslNode = new LGraphNode('GLSLShader')
glslNode.type = 'GLSLShader'
subgraph.add(glslNode)
const otherNode = new LGraphNode('OtherShader')
otherNode.type = 'GLSLShader'
subgraph.add(otherNode)
subgraphNode.properties.previewExposures = [
{
name: CANVAS_IMAGE_PREVIEW_WIDGET,
sourceNodeId: String(otherNode.id),
sourcePreviewName: CANVAS_IMAGE_PREVIEW_WIDGET
}
]
autoExposeKnownPreviewNodes(subgraphNode)
expect(
usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, String(subgraphNode.id))
.map((e) => e.sourceNodeId)
).not.toContain(String(glslNode.id))
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(glslNode.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
})
).toBe(true)
})
})
@@ -459,11 +314,12 @@ describe('hasUnpromotedWidgets', () => {
const subgraphNode = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('InnerNode')
subgraph.add(interiorNode)
const input = interiorNode.addInput('seed', 'STRING')
const widget = interiorNode.addWidget('text', 'seed', '123', () => {})
input.widget = { name: widget.name }
interiorNode.addWidget('text', 'seed', '123', () => {})
subgraph.addInput('seed', 'STRING').connect(input, interiorNode)
usePromotionStore().promote(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'seed'
})
expect(hasUnpromotedWidgets(subgraphNode)).toBe(false)
})
@@ -560,413 +416,3 @@ describe('isLinkedPromotion', () => {
expect(isLinkedPromotion(subgraphNode, '5', 'string_a')).toBe(false)
})
})
describe('reorderSubgraphInputsByName', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
it('reorders subgraph inputs and host inputs by subgraph input name', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'number' },
{ name: 'second', type: 'number' },
{ name: 'third', type: 'number' }
]
})
const host = createTestSubgraphNode(subgraph)
reorderSubgraphInputsByName(host, ['third', 'first', 'second'])
expect(host.subgraph.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
expect(host.inputs.map((input) => input.name)).toEqual([
'third',
'first',
'second'
])
})
it('reorders promoted widgets on the host node from subgraph input order', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
expect(host.widgets.map((widget) => widget.name)).toEqual([
'first',
'second'
])
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
})
it('keeps promoted widget values aligned when a plain input is reordered before them', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
subgraph.addInput('plain', 'STRING')
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByName(host, ['plain', 'second', 'first'])
expect(host.widgets.map((widget) => widget.name)).toEqual([
'second',
'first'
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
it('updates subgraph input link slot indices after reordering', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('first', 'STRING')
const firstWidget = firstNode.addWidget('text', 'first', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('second', 'STRING')
const secondWidget = secondNode.addWidget('text', 'second', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
reorderSubgraphInputsByName(host, ['second', 'first'])
const [secondSlot, firstSlot] = subgraph.inputs
const secondLink = subgraph.getLink(secondSlot.linkIds[0])
const firstLink = subgraph.getLink(firstSlot.linkIds[0])
expect(secondLink?.origin_slot).toBe(0)
expect(firstLink?.origin_slot).toBe(1)
})
it('updates outer link target_slot when host inputs are reordered', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first', type: 'STRING' },
{ name: 'second', type: 'STRING' }
]
})
const host = createTestSubgraphNode(subgraph)
subgraph.rootGraph.add(host)
const source = new LGraphNode('Source')
source.addOutput('out', 'STRING')
subgraph.rootGraph.add(source)
const firstLink = source.connect(0, host, 0)
const secondLink = source.connect(0, host, 1)
expect(firstLink?.target_slot).toBe(0)
expect(secondLink?.target_slot).toBe(1)
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(firstLink?.target_slot).toBe(1)
expect(secondLink?.target_slot).toBe(0)
})
})
describe('reorderSubgraphInputsByWidgetOrder', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
it('reorders duplicate-named promoted inputs by widget identity', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const firstNode = new LGraphNode('First')
const secondNode = new LGraphNode('Second')
subgraph.add(firstNode)
subgraph.add(secondNode)
const firstInput = firstNode.addInput('text', 'STRING')
const firstWidget = firstNode.addWidget('text', 'text', '', () => {})
firstInput.widget = { name: firstWidget.name }
const secondInput = secondNode.addInput('text', 'STRING')
const secondWidget = secondNode.addWidget('text', 'text', '', () => {})
secondInput.widget = { name: secondWidget.name }
promoteValueWidgetViaSubgraphInput(host, firstNode, firstWidget)
promoteValueWidgetViaSubgraphInput(host, secondNode, secondWidget)
host.widgets[0].value = 'first value'
host.widgets[1].value = 'second value'
reorderSubgraphInputsByWidgetOrder(host, [host.widgets[1], host.widgets[0]])
expect(host.widgets.map((widget) => widgetSourceNodeId(widget))).toEqual([
String(secondNode.id),
String(firstNode.id)
])
expect(host.serialize().widgets_values).toEqual([
'second value',
'first value'
])
})
})
describe('demoteWidget — axiomatic projection retraction', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
vi.restoreAllMocks()
})
function setupPromotedWidget() {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('TestNode')
host.subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('value', 'STRING')
const interiorWidget = interiorNode.addWidget(
'text',
'value',
'initial',
() => {}
)
interiorInput.widget = { name: interiorWidget.name }
const result = promoteValueWidgetViaSubgraphInput(
host,
interiorNode,
interiorWidget
)
expect(result.ok).toBe(true)
return { host, interiorNode, interiorWidget }
}
it('drops projection but keeps slot and external link when host slot is externally connected', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
const hostInput = host.inputs[0]
hostInput.link = 9999
const promotedViewsBefore = host.widgets.length
expect(host.subgraph.inputs).toHaveLength(1)
expect(promotedViewsBefore).toBeGreaterThan(0)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.inputs[0]?.link).toBe(9999)
expect(host.inputs[0]?._widget).toBeUndefined()
expect(
isLinkedPromotion(host, String(interiorNode.id), interiorWidget.name)
).toBe(false)
expect(
host.widgets.some(
(widget) =>
widgetSourceNodeId(widget) === String(interiorNode.id) &&
widget.name === interiorWidget.name
)
).toBe(false)
})
it('removes the slot entirely when host slot has no external link', () => {
const { host, interiorNode, interiorWidget } = setupPromotedWidget()
expect(host.subgraph.inputs).toHaveLength(1)
demoteWidget(interiorNode, interiorWidget, [host])
expect(host.subgraph.inputs).toHaveLength(0)
expect(host.inputs).toHaveLength(0)
})
it('demotes the second of two promoted widgets sharing a source widget name', () => {
const { host, nodeA, widgetA, nodeB, widgetB } =
buildDuplicateNamePromotion()
const promotedViewForB = host.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(promotedViewForB!.name).toBe('text_1')
demoteWidget(nodeB, promotedViewForB!, [host])
expect(host.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(host, String(nodeB.id), widgetB.name)).toBe(false)
expect(isLinkedPromotion(host, String(nodeA.id), widgetA.name)).toBe(true)
})
it('demotes the correct slot when widget lives on a nested SubgraphNode with same-named deep sources', () => {
const { host: innerHost, nodeB } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual([
'text',
'text_1'
])
const innerViewForB = innerHost.widgets.find(
(w) => isPromotedWidgetView(w) && w.sourceNodeId === String(nodeB.id)
)
expect(innerViewForB!.name).toBe('text_1')
demoteWidget(innerHost, innerViewForB!, [outerHost])
expect(outerHost.subgraph.inputs.map((i) => i.name)).toEqual(['text'])
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text_1')).toBe(
false
)
expect(isLinkedPromotion(outerHost, String(innerHost.id), 'text')).toBe(
true
)
})
})
describe('disambiguated nested promotion identity', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
})
function linkedView(
sourceNodeId: string,
sourceWidgetName: string,
overrides: Record<string, unknown> = {}
): IBaseWidget {
return {
sourceNodeId,
sourceWidgetName,
name: sourceWidgetName,
type: 'text',
value: '',
options: {},
y: 0,
...overrides
} as unknown as IBaseWidget
}
function createSubgraphHost() {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
return createTestSubgraphNode(subgraph)
}
it('identifies a promoted nested view by its immediate slot name, not its deep source widget name', () => {
const host = createSubgraphHost()
host.inputs[0]._widget = linkedView('inner', 'text_1')
const interiorWidget = linkedView('inner', 'text', { name: 'text_1' })
const interiorNode = {
id: 'inner',
title: 'inner',
type: 'inner'
} as unknown as LGraphNode
const source = {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: getWidgetName(interiorWidget)
}
expect(isWidgetPromotedOnSubgraphNode(host, source, interiorWidget)).toBe(
true
)
})
it('does not prune a promotion whose source is a nested SubgraphNode exposing a disambiguated widget', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'text_1', type: 'STRING' }]
})
const host = createTestSubgraphNode(subgraph)
const nestedSubgraphNode = {
id: 'inner',
title: 'inner',
type: 'inner',
widgets: [linkedView('deep', 'text', { name: 'text_1' })]
} as unknown as LGraphNode
subgraph.add(nestedSubgraphNode)
host.inputs[0]._widget = linkedView('inner', 'text_1')
pruneDisconnected(host)
expect(host.subgraph.inputs).toHaveLength(1)
expect(host.subgraph.inputs[0]?.name).toBe('text_1')
})
it('marks a promoted interior widget as computedDisabled so the connection dot replaces its UI', () => {
const subgraph = createTestSubgraph()
const host = createTestSubgraphNode(subgraph)
const interiorNode = new LGraphNode('Source')
subgraph.add(interiorNode)
const interiorInput = interiorNode.addInput('text', 'STRING')
const interiorWidget = interiorNode.addWidget('text', 'text', '', () => {})
interiorInput.widget = { name: interiorWidget.name }
expect(
promoteValueWidgetViaSubgraphInput(host, interiorNode, interiorWidget).ok
).toBe(true)
interiorNode.updateComputedDisabled()
expect(interiorWidget.computedDisabled).toBe(true)
})
it('preserves a real two-level promotion through the SubgraphEditor mount-time prune', () => {
const { host: innerHost } = buildDuplicateNamePromotion()
const outerSubgraph = createTestSubgraph()
const outerHost = createTestSubgraphNode(outerSubgraph)
outerSubgraph.add(innerHost)
for (const w of [...innerHost.widgets]) {
expect(
promoteValueWidgetViaSubgraphInput(outerHost, innerHost, w).ok
).toBe(true)
}
const beforeCount = outerHost.subgraph.inputs.length
expect(beforeCount).toBe(2)
pruneDisconnected(outerHost)
expect(outerHost.subgraph.inputs).toHaveLength(beforeCount)
})
})

View File

@@ -2,13 +2,12 @@ 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 { IContextMenuValue } from '@/lib/litegraph/src/litegraph'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
IContextMenuValue,
LGraphNode
} from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import { reorderSubgraphInputs } from '@/lib/litegraph/src/subgraph/subgraphUtils'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { isWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { nextUniqueName } from '@/lib/litegraph/src/strings'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets.ts'
import { useToastStore } from '@/platform/updates/common/toastStore'
import {
CANVAS_IMAGE_PREVIEW_WIDGET,
@@ -16,36 +15,29 @@ import {
} from '@/composables/node/canvasImagePreviewTypes'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { readWidgetValue } from '@/world/widgetValueIO'
type PartialNode = Pick<LGraphNode, 'title' | 'id' | 'type'>
export type WidgetItem = [LGraphNode, IBaseWidget]
export type WidgetItem = [PartialNode, IBaseWidget]
export { CANVAS_IMAGE_PREVIEW_WIDGET }
export function getWidgetName(w: IBaseWidget): string {
return w.name
return isPromotedWidgetView(w) ? w.sourceWidgetName : w.name
}
/**
* Returns true if the given promotion entry corresponds to a linked promotion
* on the subgraph node. Linked promotions are driven by subgraph input
* connections and cannot be independently hidden or shown.
*/
export function isLinkedPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
): boolean {
return (
findHostInputForPromotion(subgraphNode, sourceNodeId, sourceWidgetName) !==
undefined
)
}
export function findHostInputForPromotion(
subgraphNode: SubgraphNode,
sourceNodeId: string,
sourceWidgetName: string
) {
return subgraphNode.inputs.find((input) => {
return subgraphNode.inputs.some((input) => {
const w = input._widget
return (
w &&
@@ -56,125 +48,21 @@ export function findHostInputForPromotion(
})
}
export function reorderSubgraphInputsByName(
subgraphNode: SubgraphNode,
orderedInputNames: readonly string[]
): void {
const order = new Map(
orderedInputNames.map((name, index) => [name, index] as const)
)
const byOrder = <T extends { name: string }>(left: T, right: T) => {
const leftOrder = order.get(left.name) ?? Number.MAX_SAFE_INTEGER
const rightOrder = order.get(right.name) ?? Number.MAX_SAFE_INTEGER
return leftOrder - rightOrder
}
const orderedIndices = subgraphNode.subgraph.inputs
.map((input, index) => ({ input, index }))
.sort((left, right) => byOrder(left.input, right.input))
.map(({ index }) => index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
export function reorderSubgraphInputsByWidgetOrder(
subgraphNode: SubgraphNode,
orderedWidgets: readonly IBaseWidget[]
): void {
const remainingIndices = new Set(subgraphNode.inputs.keys())
const orderedIndices = orderedWidgets.flatMap((orderedWidget) => {
for (const index of remainingIndices) {
const widget = subgraphNode.inputs[index]?._widget
if (widget && isSamePromotedWidget(widget, orderedWidget)) {
remainingIndices.delete(index)
return [index]
}
}
return []
})
for (const index of remainingIndices) orderedIndices.push(index)
applySubgraphInputOrder(subgraphNode, orderedIndices)
}
function applySubgraphInputOrder(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const widgetValues = subgraphNode.inputs.map((input) =>
getExplicitHostWidgetValue(input?._widget)
)
reorderSubgraphInputs(subgraphNode, orderedIndices)
for (const [newIndex, oldIndex] of orderedIndices.entries()) {
const value = widgetValues[oldIndex]
if (value === undefined) continue
const widget = subgraphNode.inputs[newIndex]?._widget
if (widget) widget.value = value
}
}
function getExplicitHostWidgetValue(
widget: IBaseWidget | undefined
): IBaseWidget['value'] | undefined {
if (!widget) return undefined
if (!isPromotedWidgetView(widget)) return widget.value
const value = readWidgetValue(widget.entityId)
return isWidgetValue(value) ? value : undefined
}
function isSamePromotedWidget(left: IBaseWidget, right: IBaseWidget): boolean {
return (
isPromotedWidgetView(left) &&
isPromotedWidgetView(right) &&
left.sourceNodeId === right.sourceNodeId &&
left.sourceWidgetName === right.sourceWidgetName
)
}
function isPreviewExposed(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource
): boolean {
const hostLocator = String(subgraphNode.id)
return usePreviewExposureStore()
.getExposures(subgraphNode.rootGraph.id, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === source.sourceNodeId &&
exposure.sourcePreviewName === source.sourceWidgetName
)
}
export function isWidgetPromotedOnSubgraphNode(
subgraphNode: SubgraphNode,
source: PromotedWidgetSource,
widget?: IBaseWidget
): boolean {
if (widget && isPreviewPseudoWidget(widget))
return isPreviewExposed(subgraphNode, source)
return (
isLinkedPromotion(
subgraphNode,
source.sourceNodeId,
source.sourceWidgetName
) || isPreviewExposed(subgraphNode, source)
)
export function getSourceNodeId(w: IBaseWidget): string | undefined {
if (!isPromotedWidgetView(w)) return undefined
return w.disambiguatingSourceNodeId ?? w.sourceNodeId
}
function toPromotionSource(
node: PartialNode,
widget: IBaseWidget
): PromotedWidgetSource {
const widgetIsParentLevelView =
isPromotedWidgetView(widget) && widget.sourceNodeId === String(node.id)
return {
sourceNodeId: String(node.id),
sourceWidgetName: widgetIsParentLevelView
? widget.sourceWidgetName
: getWidgetName(widget)
sourceWidgetName: getWidgetName(widget),
disambiguatingSourceNodeId: isPromotedWidgetView(widget)
? widget.disambiguatingSourceNodeId
: undefined
}
}
@@ -186,67 +74,18 @@ function refreshPromotedWidgetRendering(parents: SubgraphNode[]): void {
useCanvasStore().canvas?.setDirty(true, true)
}
type CanonicalPromotionResult =
| { ok: true }
| { ok: false; reason: 'missingSourceSlot' | 'connectFailed' }
export function promoteValueWidgetViaSubgraphInput(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourceWidget: IBaseWidget
): CanonicalPromotionResult {
const sourceWidgetName = getWidgetName(sourceWidget)
if (
isLinkedPromotion(subgraphNode, String(sourceNode.id), sourceWidgetName)
) {
return { ok: true }
}
const sourceSlot = sourceNode.getSlotFromWidget(sourceWidget)
if (!sourceSlot) return { ok: false, reason: 'missingSourceSlot' }
const existingNames = subgraphNode.subgraph.inputs.map((input) => input.name)
const inputName = nextUniqueName(sourceWidgetName, existingNames)
const subgraphInput = subgraphNode.subgraph.addInput(
inputName,
String(sourceSlot.type ?? sourceWidget.type ?? '*')
)
const link = subgraphInput.connect(sourceSlot, sourceNode)
if (!link) {
subgraphNode.subgraph.removeInput(subgraphInput)
return { ok: false, reason: 'connectFailed' }
}
return { ok: true }
}
function promotePreviewViaExposure(
subgraphNode: SubgraphNode,
sourceNode: LGraphNode,
sourcePreviewName: string
): void {
const store = usePreviewExposureStore()
const rootGraphId = subgraphNode.rootGraph.id
const hostLocator = String(subgraphNode.id)
const existing = store
.getExposures(rootGraphId, hostLocator)
.some(
(exposure) =>
exposure.sourceNodeId === String(sourceNode.id) &&
exposure.sourcePreviewName === sourcePreviewName
)
if (existing) return
store.addExposure(rootGraphId, hostLocator, {
sourceNodeId: String(sourceNode.id),
sourcePreviewName
})
}
/** Known non-$$ preview widget types added by core or popular extensions. */
const PREVIEW_WIDGET_TYPES = new Set(['preview', 'video', 'audioUI'])
/**
* Returns true for pseudo-widgets that display media previews and should
* be auto-promoted when their node is inside a subgraph.
* Matches the core `$$` convention as well as custom-node patterns
* (e.g. VHS `videopreview` with type `"preview"`).
*/
export function isPreviewPseudoWidget(widget: IBaseWidget): boolean {
if (widget.name.startsWith('$$')) return true
// Custom nodes may set serialize on the widget or in options
if (widget.serialize !== false && widget.options?.serialize !== false)
return false
if (typeof widget.type === 'string' && PREVIEW_WIDGET_TYPES.has(widget.type))
@@ -259,21 +98,10 @@ export function promoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
if (!(node instanceof LGraphNode)) return
for (const parent of parents) {
if (isPreviewPseudoWidget(widget)) {
promotePreviewViaExposure(parent, node, source.sourceWidgetName)
continue
}
const result = promoteValueWidgetViaSubgraphInput(parent, node, widget)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${source.sourceWidgetName}" on node ${node.id}: ${result.reason}`
})
}
store.promote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -288,45 +116,10 @@ export function demoteWidget(
widget: IBaseWidget,
parents: SubgraphNode[]
) {
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
for (const parent of parents) {
if (!parent.subgraph) continue
const hostInput = findHostInputForPromotion(
parent,
source.sourceNodeId,
source.sourceWidgetName
)
const linkedInput = hostInput?._subgraphSlot
if (linkedInput) {
const hasExternalLink = hostInput.link != null
if (hasExternalLink) {
linkedInput.disconnect()
} else {
parent.subgraph.removeInput(linkedInput)
}
continue
}
if (isPreviewPseudoWidget(widget)) {
const previewStore = usePreviewExposureStore()
const hostLocator = String(parent.id)
const exposure = previewStore
.getExposures(parent.rootGraph.id, hostLocator)
.find(
(entry) =>
entry.sourceNodeId === source.sourceNodeId &&
entry.sourcePreviewName === source.sourceWidgetName
)
if (exposure) {
previewStore.removeExposure(
parent.rootGraph.id,
hostLocator,
exposure.name
)
continue
}
}
store.demote(parent.rootGraph.id, parent.id, source)
}
refreshPromotedWidgetRendering(parents)
Sentry.addBreadcrumb({
@@ -359,10 +152,11 @@ export function addWidgetPromotionOptions(
widget: IBaseWidget,
node: LGraphNode
) {
const store = usePromotionStore()
const parents = getParentNodes()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
options.unshift({
@@ -395,9 +189,10 @@ export function tryToggleWidgetPromotion() {
const widget = node.getWidgetOnPos(x, y, true)
const parents = getParentNodes()
if (!parents.length || !widget) return
const store = usePromotionStore()
const source = toPromotionSource(node, widget)
const promotableParents = parents.filter(
(parent) => !isWidgetPromotedOnSubgraphNode(parent, source, widget)
(s) => !store.isPromoted(s.rootGraph.id, s.id, source)
)
if (promotableParents.length > 0)
promoteWidget(node, widget, promotableParents)
@@ -452,8 +247,8 @@ function nodeWidgets(n: LGraphNode): WidgetItem[] {
return getPromotableWidgets(n).map((w: IBaseWidget) => [n, w])
}
export function autoExposeKnownPreviewNodes(subgraphNode: SubgraphNode): void {
if (subgraphNode.properties.previewExposures !== undefined) return
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const { updatePreviews } = useLitegraphService()
const interiorNodes = subgraphNode.subgraph.nodes
for (const node of interiorNodes) {
@@ -465,86 +260,88 @@ export function autoExposeKnownPreviewNodes(subgraphNode: SubgraphNode): void {
function promotePreviewWidget() {
const widget = node.widgets?.find(isPreviewPseudoWidget)
if (!widget) return
promotePreviewViaExposure(subgraphNode, node, widget.name)
if (
store.isPromoted(subgraphNode.rootGraph.id, subgraphNode.id, {
sourceNodeId: String(node.id),
sourceWidgetName: widget.name
})
)
return
promoteWidget(node, widget, [subgraphNode])
}
// Promote preview widgets that already exist (e.g. custom node DOM widgets
// like VHS videopreview that are created in onNodeCreated).
promotePreviewWidget()
// If a preview widget already exists in this frame, there's nothing to
// defer. Core $$ preview widgets are the lazy path that needs updatePreviews.
if (hasPreviewWidget()) continue
// Nodes in CANVAS_IMAGE_PREVIEW_NODE_TYPES support a virtual $$
// preview widget. Eagerly promote it so getPseudoWidgetPreviewTargets
// includes this node and onDrawBackground can call updatePreviews on it
// once execution outputs arrive.
if (supportsVirtualCanvasImagePreview(node)) {
promotePreviewViaExposure(subgraphNode, node, CANVAS_IMAGE_PREVIEW_WIDGET)
const canvasSource: PromotedWidgetSource = {
sourceNodeId: String(node.id),
sourceWidgetName: CANVAS_IMAGE_PREVIEW_WIDGET
}
if (
!store.isPromoted(
subgraphNode.rootGraph.id,
subgraphNode.id,
canvasSource
)
) {
store.promote(subgraphNode.rootGraph.id, subgraphNode.id, canvasSource)
}
continue
}
// Also schedule a deferred check: core $$ widgets are created lazily by
// updatePreviews when node outputs are first loaded.
requestAnimationFrame(() => updatePreviews(node, promotePreviewWidget))
}
}
export function promoteRecommendedWidgets(subgraphNode: SubgraphNode) {
autoExposeKnownPreviewNodes(subgraphNode)
const interiorNodes = subgraphNode.subgraph.nodes
const filteredWidgets: WidgetItem[] = interiorNodes
.flatMap(nodeWidgets)
.filter(isRecommendedWidget)
.filter(([, widget]) => !isPreviewPseudoWidget(widget))
for (const [n, w] of filteredWidgets) {
const result = promoteValueWidgetViaSubgraphInput(subgraphNode, n, w)
if (!result.ok) {
Sentry.addBreadcrumb({
category: 'subgraph',
level: 'warning',
message: `Failed to promote widget "${getWidgetName(w)}" on node ${n.id}: ${result.reason}`
})
}
store.promote(
subgraphNode.rootGraph.id,
subgraphNode.id,
toPromotionSource(n, w)
)
}
subgraphNode.computeSize(subgraphNode.size)
}
export function pruneDisconnected(subgraphNode: SubgraphNode) {
const store = usePromotionStore()
const subgraph = subgraphNode.subgraph
const entries = store.getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
const removedEntries: PromotedWidgetSource[] = []
const staleInputs = subgraph.inputs.filter((input) => {
const widget = input._widget
if (!widget || !isPromotedWidgetView(widget)) return false
// If the SubgraphInput has any live link to an interior target slot that
// still has a widget, the promotion is alive — even when the widget's
// sourceNodeId points at a deeply-nested interior node that does not exist
// directly in `subgraph` (nested SubgraphNode promotions).
for (const linkId of input.linkIds) {
const link = subgraph.getLink(linkId)
if (!link) continue
const { inputNode } = link.resolve(subgraph)
if (!inputNode) continue
const targetInputSlot = inputNode.inputs?.find(
(slot) => slot.link === linkId
)
if (!targetInputSlot) continue
if (inputNode.getWidgetFromSlot(targetInputSlot)) return false
}
const node = subgraph.getNodeById(widget.sourceNodeId)
const validEntries = entries.filter((entry) => {
const node = subgraph.getNodeById(entry.sourceNodeId)
if (!node) {
removedEntries.push(widget)
return true
removedEntries.push(entry)
return false
}
const hasWidget = getPromotableWidgets(node).some(
(iw) => iw.name === widget.sourceWidgetName
(iw) => iw.name === entry.sourceWidgetName
)
if (!hasWidget) {
removedEntries.push(widget)
removedEntries.push(entry)
}
return !hasWidget
return hasWidget
})
for (const input of staleInputs) {
subgraph.removeInput(input)
}
if (removedEntries.length > 0 && import.meta.env.DEV) {
console.warn(
'[subgraphInputs] Pruned disconnected promoted widget inputs',
'[proxyWidgetUtils] Pruned disconnected promotions',
removedEntries,
{
graphId: subgraphNode.rootGraph.id,
@@ -553,29 +350,27 @@ export function pruneDisconnected(subgraphNode: SubgraphNode) {
)
}
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, validEntries)
refreshPromotedWidgetRendering([subgraphNode])
Sentry.addBreadcrumb({
category: 'subgraph',
message: `Pruned ${removedEntries.length} disconnected promoted widget input(s) from subgraph node ${subgraphNode.id}`,
message: `Pruned ${removedEntries.length} disconnected promotion(s) from subgraph node ${subgraphNode.id}`,
level: 'info'
})
}
export function hasUnpromotedWidgets(subgraphNode: SubgraphNode): boolean {
const { subgraph } = subgraphNode
const promotionStore = usePromotionStore()
const { id: subgraphNodeId, rootGraph, subgraph } = subgraphNode
return subgraph.nodes.some((interiorNode) =>
getPromotableWidgets(interiorNode).some(
(interiorNode.widgets ?? []).some(
(widget) =>
!widget.computedDisabled &&
!isWidgetPromotedOnSubgraphNode(
subgraphNode,
{
sourceNodeId: String(interiorNode.id),
sourceWidgetName: widget.name
},
widget
)
!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,29 +20,40 @@ const MAX_PROMOTED_WIDGET_CHAIN_DEPTH = 100
function traversePromotedWidgetChain(
hostNode: SubgraphNode,
nodeId: string,
widgetName: string
widgetName: string,
sourceNodeId?: string
): PromotedWidgetResolutionResult {
const visitedByHost = new WeakMap<SubgraphNode, Set<string>>()
const visited = new Set<string>()
const hostUidByObject = new WeakMap<SubgraphNode, number>()
let nextHostUid = 0
let currentHost = hostNode
let currentNodeId = nodeId
let currentWidgetName = widgetName
let currentSourceNodeId = sourceNodeId
for (let depth = 0; depth < MAX_PROMOTED_WIDGET_CHAIN_DEPTH; depth++) {
const key = `${currentNodeId}:${currentWidgetName}`
const visited = visitedByHost.get(currentHost) ?? new Set<string>()
let hostUid = hostUidByObject.get(currentHost)
if (hostUid === undefined) {
hostUid = nextHostUid
nextHostUid += 1
hostUidByObject.set(currentHost, hostUid)
}
const key = `${hostUid}:${currentNodeId}:${currentWidgetName}:${currentSourceNodeId ?? ''}`
if (visited.has(key)) {
return { status: 'failure', failure: 'cycle' }
}
visited.add(key)
visitedByHost.set(currentHost, visited)
const sourceNode = currentHost.subgraph.getNodeById(currentNodeId)
if (!sourceNode) {
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' }
@@ -62,20 +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) => entry.name === widgetName)
const widget = findWidgetByIdentity(node.widgets, widgetName, sourceNodeId)
if (!widget) return undefined
return { node, widget }
@@ -84,27 +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)
}
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved') return result.resolved
return undefined
return traversePromotedWidgetChain(hostNode, nodeId, widgetName, sourceNodeId)
}

View File

@@ -0,0 +1,23 @@
import type { ResolvedPromotedWidget } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
export function resolvePromotedWidgetSource(
hostNode: LGraphNode,
widget: IBaseWidget
): ResolvedPromotedWidget | undefined {
if (!isPromotedWidgetView(widget)) return undefined
if (!hostNode.isSubgraphNode()) return undefined
const result = resolveConcretePromotedWidget(
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName,
widget.disambiguatingSourceNodeId
)
if (result.status === 'resolved') return result.resolved
return undefined
}

View File

@@ -85,7 +85,7 @@ describe('resolveSubgraphInputTarget', () => {
})
})
test('resolves non-widget input on nested SubgraphNode to immediate child target', () => {
test('returns undefined for non-widget input on nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'audio'
])
@@ -93,41 +93,28 @@ describe('resolveSubgraphInputTarget', () => {
const result = resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
expect(result).toMatchObject({
nodeId: '819',
widgetName: 'audio'
})
expect(result).toBeUndefined()
})
test('resolves both widget and non-widget inputs on nested SubgraphNodes', () => {
test('resolves widget inputs but not non-widget inputs on the same nested SubgraphNode', () => {
const { outerSubgraph, outerSubgraphNode } = createOuterSubgraphSetup([
'width',
'audio'
])
const { innerSubgraphNode: widthChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'width',
'width',
{ widget: 'width' }
)
const { innerSubgraphNode: audioChild } = addLinkedNestedSubgraphNode(
outerSubgraph,
'audio',
'audio'
)
addLinkedNestedSubgraphNode(outerSubgraph, 'width', 'width', {
widget: 'width'
})
addLinkedNestedSubgraphNode(outerSubgraph, 'audio', 'audio')
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'width')
).toMatchObject({
nodeId: String(widthChild.id),
nodeId: '819',
widgetName: 'width'
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toMatchObject({
nodeId: String(audioChild.id),
widgetName: 'audio'
})
).toBeUndefined()
})
test('returns target for widget-backed input on plain interior node', () => {
@@ -212,10 +199,7 @@ describe('resolveSubgraphInputTarget', () => {
})
expect(
resolveSubgraphInputTarget(outerSubgraphNode, 'audio')
).toMatchObject({
nodeId: '820',
widgetName: 'audio'
})
).toBeUndefined()
})
test('three-level nesting returns immediate child target, not deepest', () => {

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(
@@ -16,6 +18,19 @@ export function resolveSubgraphInputTarget(
inputName,
({ inputNode, targetInput, getTargetWidget }) => {
if (inputNode.isSubgraphNode()) {
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

@@ -0,0 +1,408 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { SubgraphNode } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/stores/domWidgetStore', () => ({
useDomWidgetStore: () => ({ widgetStates: new Map() })
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function setupSubgraph(
innerNodeCount: number = 0
): [SubgraphNode, LGraphNode[], string[]] {
const subgraph = createTestSubgraph()
const subgraphNode = createTestSubgraphNode(subgraph)
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph!
graph.add(subgraphNode)
const innerNodes: LGraphNode[] = []
for (let i = 0; i < innerNodeCount; i++) {
const innerNode = new LGraphNode(`InnerNode${i}`)
subgraph.add(innerNode)
innerNodes.push(innerNode)
}
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, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('Can add multiple widgets with same name', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(2)
for (const innerNode of innerNodes)
innerNode.addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' },
{ sourceNodeId: innerIds[1], sourceWidgetName: 'stringWidget' }
]
)
expect(subgraphNode.widgets.length).toBe(2)
// Both views share the widget name; they're distinguished by sourceNodeId
expect(subgraphNode.widgets[0].name).toBe('stringWidget')
expect(subgraphNode.widgets[1].name).toBe('stringWidget')
})
test('Will reflect proxyWidgets order changes', () => {
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, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets.length).toBe(2)
expect(subgraphNode.widgets[0].name).toBe('widgetA')
expect(subgraphNode.widgets[1].name).toBe('widgetB')
// Reorder
store.setPromotions(subgraphNode.rootGraph.id, subgraphNode.id, [
{ 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, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets.length).toBe(1)
expect(subgraphNode.widgets[0].value).toBe('value')
innerNodes[0].widgets![0].value = 'test'
expect(subgraphNode.widgets[0].value).toBe('test')
subgraphNode.widgets[0].value = 'test2'
expect(innerNodes[0].widgets![0].value).toBe('test2')
})
test('Will not modify position or sizing of existing widgets', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
innerNodes[0].widgets[0].y = 10
innerNodes[0].widgets[0].last_y = 11
innerNodes[0].widgets[0].computedHeight = 12
subgraphNode.widgets[0].y = 20
subgraphNode.widgets[0].last_y = 21
subgraphNode.widgets[0].computedHeight = 22
expect(innerNodes[0].widgets[0].y).toBe(10)
expect(innerNodes[0].widgets[0].last_y).toBe(11)
expect(innerNodes[0].widgets[0].computedHeight).toBe(12)
})
test('Renders placeholder when interior widget is detached', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
if (!innerNodes[0].widgets) throw new Error('node has no widgets')
// View resolves the interior widget's type
expect(subgraphNode.widgets[0].type).toBe('text')
// Remove interior widget — view falls back to disconnected state
innerNodes[0].widgets.pop()
expect(subgraphNode.widgets[0].type).toBe('button')
// Re-add — view resolves again
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
expect(subgraphNode.widgets[0].type).toBe('text')
})
test('Prevents duplicate promotion', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
const store = usePromotionStore()
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
// Promote once
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, {
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([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }
])
})
test('removeWidget removes from promotion list and view cache', () => {
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, [
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
expect(subgraphNode.widgets).toHaveLength(2)
const widgetToRemove = subgraphNode.widgets[0]
subgraphNode.removeWidget(widgetToRemove)
expect(subgraphNode.widgets).toHaveLength(1)
expect(subgraphNode.widgets[0].name).toBe('widgetB')
expect(
store.getPromotions(subgraphNode.rootGraph.id, subgraphNode.id)
).toStrictEqual([
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
])
})
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,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
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, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
const view = subgraphNode.widgets[0]
// Simulate an input referencing the widget
subgraphNode.addInput('stringWidget', '*')
const input = subgraphNode.inputs[subgraphNode.inputs.length - 1]
input._widget = view
subgraphNode.removeWidget(view)
expect(input._widget).toBeUndefined()
expect(subgraphNode.widgets).toHaveLength(0)
})
test('serialize does not produce widgets_values for promoted views', () => {
const [subgraphNode, innerNodes, innerIds] = setupSubgraph(1)
innerNodes[0].addWidget('text', 'stringWidget', 'value', () => {})
usePromotionStore().setPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id,
[{ sourceNodeId: innerIds[0], sourceWidgetName: 'stringWidget' }]
)
expect(subgraphNode.widgets).toHaveLength(1)
const serialized = subgraphNode.serialize()
// SubgraphNode doesn't set serialize_widgets, so widgets_values is absent.
// Even if it were set, views have serialize: false and would be skipped.
expect(serialized.widgets_values).toBeUndefined()
})
test('serialize preserves proxyWidgets in properties', () => {
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,
[
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetA' },
{ sourceNodeId: innerIds[0], sourceWidgetName: 'widgetB' }
]
)
const serialized = subgraphNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual([
[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,32 +0,0 @@
import type { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
/**
* @param property - Raw node property value.
* @param schema - Zod schema describing the expected array shape.
* @param contextName - Prefix for `console.warn` messages (e.g. `properties.proxyWidgets`).
*/
export function parseNodePropertyArray<T>(
property: NodeProperty | undefined,
schema: z.ZodType<T[]>,
contextName: string
): T[] {
if (property === undefined) return []
let parsed: unknown
try {
parsed = typeof property === 'string' ? JSON.parse(property) : property
} catch (e) {
console.warn(`Failed to parse ${contextName}:`, e)
return []
}
const result = schema.safeParse(parsed)
if (result.success) return result.data
const error = fromZodError(result.error)
console.warn(`Invalid assignment for ${contextName}:\n${error}`)
return []
}

View File

@@ -1,88 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { parsePreviewExposures } from './previewExposureSchema'
describe(parsePreviewExposures, () => {
it('parses a valid array of preview exposure objects', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
},
{
name: 'preview2',
sourceNodeId: '7',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(input)).toEqual(input)
})
it('parses JSON-string input', () => {
const input = [
{
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
]
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
})
it('returns empty array for undefined', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parsePreviewExposures(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it('returns empty array for malformed JSON string', () => {
expect(parsePreviewExposures('not-json{')).toEqual([])
})
it('returns empty array for non-array input', () => {
expect(
parsePreviewExposures({
name: 'preview',
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
})
).toEqual([])
expect(parsePreviewExposures(42)).toEqual([])
})
it('returns empty array when entries are missing required fields', () => {
expect(
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
).toEqual([])
expect(
parsePreviewExposures([
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
])
).toEqual([])
})
it('returns empty array when entries have wrong types', () => {
expect(
parsePreviewExposures([
{
name: 123,
sourceNodeId: '5',
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
expect(
parsePreviewExposures([
{
name: 'preview',
sourceNodeId: 5,
sourcePreviewName: '$$canvas-image-preview'
}
])
).toEqual([])
})
})

View File

@@ -1,24 +0,0 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const previewExposureSchema = z.object({
name: z.string(),
sourceNodeId: z.string(),
sourcePreviewName: z.string()
})
export type PreviewExposure = z.infer<typeof previewExposureSchema>
const previewExposuresPropertySchema = z.array(previewExposureSchema)
export function parsePreviewExposures(
property: NodeProperty | undefined
): PreviewExposure[] {
return parseNodePropertyArray(
property,
previewExposuresPropertySchema,
'properties.previewExposures'
)
}

View File

@@ -1,25 +1,29 @@
import { z } from 'zod'
import { fromZodError } from 'zod-validation-error'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import { parseNodePropertyArray } from './parseNodePropertyArray'
export const serializedProxyWidgetTupleSchema = z.union([
const proxyWidgetTupleSchema = z.union([
z.tuple([z.string(), z.string(), z.string()]),
z.tuple([z.string(), z.string()])
])
export type SerializedProxyWidgetTuple = z.infer<
typeof serializedProxyWidgetTupleSchema
>
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
export function parseProxyWidgets(
property: NodeProperty | undefined
): ProxyWidgetsProperty {
return parseNodePropertyArray(
property,
proxyWidgetsPropertySchema,
'properties.proxyWidgets'
)
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)
console.warn(`Invalid assignment for properties.proxyWidgets:\n${error}`)
} catch (e) {
console.warn('Failed to parse properties.proxyWidgets:', e)
}
return []
}

View File

@@ -1,68 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
const baseEntry = {
originalEntry: ['10', 'seed'] as [string, string],
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
attemptedAtVersion: 1 as const
}
describe(parseProxyWidgetErrorQuarantine, () => {
it.for([
{ name: 'without hostValue', entry: baseEntry },
{ name: 'with hostValue', entry: { ...baseEntry, hostValue: 42 } },
{
name: 'with 3-tuple originalEntry',
entry: { ...baseEntry, originalEntry: ['3', 'text', '1'] }
}
])('parses a valid entry $name', ({ entry }) => {
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it.for([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
] as const)('parses reason %s', (reason) => {
const entry = { ...baseEntry, reason }
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
})
it('parses JSON-string input', () => {
const input = JSON.stringify([baseEntry])
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
})
it('returns empty array for undefined without warning', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
expect(warnSpy).not.toHaveBeenCalled()
warnSpy.mockRestore()
})
it.for([
{ name: 'malformed JSON string', input: 'not-json{' },
{ name: 'non-array input', input: baseEntry },
{
name: 'attemptedAtVersion is not 1',
input: [{ ...baseEntry, attemptedAtVersion: 2 }]
},
{
name: 'reason is not in the enum',
input: [{ ...baseEntry, reason: 'somethingElse' }]
},
{
name: 'originalEntry is malformed',
input: [{ ...baseEntry, originalEntry: ['only-one'] }]
}
])('returns empty array when $name', ({ input }) => {
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([])
})
})

View File

@@ -1,45 +0,0 @@
import { z } from 'zod'
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
import { parseNodePropertyArray } from './parseNodePropertyArray'
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
export const proxyWidgetQuarantineReasonSchema = z.enum([
'missingSourceNode',
'missingSourceWidget',
'missingSubgraphInput',
'ambiguousSubgraphInput',
'unlinkedSourceWidget',
'primitiveBypassFailed'
])
export type ProxyWidgetQuarantineReason = z.infer<
typeof proxyWidgetQuarantineReasonSchema
>
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
originalEntry: serializedProxyWidgetTupleSchema,
reason: proxyWidgetQuarantineReasonSchema,
hostValue: z.unknown().optional(),
attemptedAtVersion: z.literal(1)
})
const proxyWidgetErrorQuarantinePropertySchema = z.array(
proxyWidgetErrorQuarantineEntrySchema
)
export type ProxyWidgetErrorQuarantineEntry = Omit<
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
'hostValue'
> & { hostValue?: TWidgetValue }
export function parseProxyWidgetErrorQuarantine(
property: NodeProperty | undefined
): ProxyWidgetErrorQuarantineEntry[] {
return parseNodePropertyArray(
property,
proxyWidgetErrorQuarantinePropertySchema,
'properties.proxyWidgetErrorQuarantine'
) as ProxyWidgetErrorQuarantineEntry[]
}

View File

@@ -0,0 +1,287 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
LGraphNode,
LiteGraph,
SubgraphNode
} from '@/lib/litegraph/src/litegraph'
import type {
ExportedSubgraphInstance,
Positionable,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode,
resetSubgraphFixtureState
} from '@/lib/litegraph/src/subgraph/__fixtures__/subgraphHelpers'
import { usePromotionStore } from '@/stores/promotionStore'
/**
* Registers a minimal SubgraphNode class for a subgraph definition
* so that `LiteGraph.createNode(subgraphId)` works in tests.
*/
function registerSubgraphNodeType(subgraph: Subgraph): void {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
const node = class extends SubgraphNode {
constructor() {
super(subgraph.rootGraph, subgraph, instanceData)
}
}
Object.defineProperty(node, 'title', { value: subgraph.name })
LiteGraph.registerNodeType(subgraph.id, node)
}
const registeredTypes: string[] = []
afterEach(() => {
for (const type of registeredTypes) {
LiteGraph.unregisterNodeType(type)
}
registeredTypes.length = 0
})
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
})
describe('_repointAncestorPromotions', () => {
function setupParentSubgraphWithWidgets() {
const parentSubgraph = createTestSubgraph({
name: 'Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
// We need to listen for new subgraph registrations so
// LiteGraph.createNode works during convertToSubgraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'hello world', () => {})
parentSubgraph.add(interiorNode)
// Create host SubgraphNode in root graph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode)
return { rootGraph, parentSubgraph, interiorNode, hostNode }
}
it('repoints parent promotions when interior nodes are packed into a nested subgraph', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
// Promote the interior node's widget on the host
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
const beforeEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(beforeEntries).toHaveLength(1)
expect(beforeEntries[0].sourceNodeId).toBe(String(interiorNode.id))
// Pack the interior node into a nested subgraph
const { node: nestedSubgraphNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// After conversion, the host's promotion should be repointed
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(1)
expect(afterEntries[0].sourceNodeId).toBe(String(nestedSubgraphNode.id))
expect(afterEntries[0].sourceWidgetName).toBe('prompt')
expect(afterEntries[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// The nested subgraph node should also have the promotion
const nestedEntries = store.getPromotions(
rootGraph.id,
nestedSubgraphNode.id
)
expect(nestedEntries).toHaveLength(1)
expect(nestedEntries[0].sourceNodeId).toBe(String(interiorNode.id))
expect(nestedEntries[0].sourceWidgetName).toBe('prompt')
})
it('preserves promotions that reference non-moved nodes', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const remainingNode = new LGraphNode('Remaining Node')
remainingNode.addWidget('text', 'widget_b', 'b', () => {})
parentSubgraph.add(remainingNode)
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(remainingNode.id),
sourceWidgetName: 'widget_b'
})
// Pack only the interiorNode
parentSubgraph.convertToSubgraph(new Set<Positionable>([interiorNode]))
const afterEntries = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterEntries).toHaveLength(2)
// The remaining node's promotion should be unchanged
const remainingEntry = afterEntries.find(
(e) => e.sourceWidgetName === 'widget_b'
)
expect(remainingEntry?.sourceNodeId).toBe(String(remainingNode.id))
expect(remainingEntry?.disambiguatingSourceNodeId).toBeUndefined()
// The moved node's promotion should be repointed
const movedEntry = afterEntries.find((e) => e.sourceWidgetName === 'prompt')
expect(movedEntry?.sourceNodeId).not.toBe(String(interiorNode.id))
expect(movedEntry?.disambiguatingSourceNodeId).toBe(String(interiorNode.id))
})
it('does not modify promotions when converting in root graph', () => {
const parentSubgraph = createTestSubgraph({ name: 'Dummy' })
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const node = new LGraphNode('Root Node')
node.addInput('in', '*')
node.addOutput('out', '*')
node.addWidget('text', 'value', 'test', () => {})
rootGraph.add(node)
// Converting in root graph should not throw
rootGraph.convertToSubgraph(new Set<Positionable>([node]))
})
it('uses existing disambiguatingSourceNodeId as fallback on repeat packing', () => {
const { rootGraph, parentSubgraph, interiorNode, hostNode } =
setupParentSubgraphWithWidgets()
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// First pack: interior node → nested subgraph
const { node: firstNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
const afterFirstPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterFirstPack).toHaveLength(1)
expect(afterFirstPack[0].sourceNodeId).toBe(String(firstNestedNode.id))
expect(afterFirstPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
// Second pack: nested subgraph → another level of nesting
const { node: secondNestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([firstNestedNode])
)
// After second pack, promotion should use the disambiguatingSourceNodeId
// as fallback and point to the new nested node
const afterSecondPack = store.getPromotions(rootGraph.id, hostNode.id)
expect(afterSecondPack).toHaveLength(1)
expect(afterSecondPack[0].sourceNodeId).toBe(String(secondNestedNode.id))
expect(afterSecondPack[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
it('repoints promotions for multiple host instances of the same subgraph', () => {
const parentSubgraph = createTestSubgraph({
name: 'Shared Parent Subgraph',
inputs: [{ name: 'input', type: '*' }],
outputs: [{ name: 'output', type: '*' }]
})
const rootGraph = parentSubgraph.rootGraph
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
registerSubgraphNodeType(subgraph)
registeredTypes.push(subgraph.id)
})
const interiorNode = new LGraphNode('Interior Node')
interiorNode.addInput('in', '*')
interiorNode.addOutput('out', '*')
interiorNode.addWidget('text', 'prompt', 'shared', () => {})
parentSubgraph.add(interiorNode)
// Create TWO host SubgraphNodes pointing to the same subgraph
registerSubgraphNodeType(parentSubgraph)
registeredTypes.push(parentSubgraph.id)
const hostNode1 = createTestSubgraphNode(parentSubgraph)
const hostNode2 = createTestSubgraphNode(parentSubgraph)
rootGraph.add(hostNode1)
rootGraph.add(hostNode2)
// Promote on both hosts
const store = usePromotionStore()
store.promote(rootGraph.id, hostNode1.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
store.promote(rootGraph.id, hostNode2.id, {
sourceNodeId: String(interiorNode.id),
sourceWidgetName: 'prompt'
})
// Pack the interior node
const { node: nestedNode } = parentSubgraph.convertToSubgraph(
new Set<Positionable>([interiorNode])
)
// Both hosts' promotions should be repointed to the nested node
const host1Promotions = store.getPromotions(rootGraph.id, hostNode1.id)
expect(host1Promotions).toHaveLength(1)
expect(host1Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host1Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
const host2Promotions = store.getPromotions(rootGraph.id, hostNode2.id)
expect(host2Promotions).toHaveLength(1)
expect(host2Promotions[0].sourceNodeId).toBe(String(nestedNode.id))
expect(host2Promotions[0].disambiguatingSourceNodeId).toBe(
String(interiorNode.id)
)
})
})

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type { NodeId, Subgraph } from '@/lib/litegraph/src/litegraph'
import {
@@ -8,16 +8,14 @@ import {
LGraphNode,
LiteGraph,
LLink,
Reroute,
SubgraphNode
Reroute
} from '@/lib/litegraph/src/litegraph'
import type { SerialisableGraph } from '@/lib/litegraph/src/types/serialisation'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { usePromotionStore } from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import {
createTestSubgraph,
createTestSubgraphData,
createTestSubgraphNode
} from './subgraph/__fixtures__/subgraphHelpers'
@@ -282,17 +280,17 @@ describe('Graph Clearing and Callbacks', () => {
expect(graph.nodes.length).toBe(0)
})
test('clear() removes graph-scoped preview and widget-value state', () => {
test('clear() removes graph-scoped promotion and widget-value state', () => {
setActivePinia(createTestingPinia({ stubActions: false }))
const graph = new LGraph()
const graphId = 'graph-clear-cleanup' as UUID
graph.id = graphId
const previewExposureStore = usePreviewExposureStore()
previewExposureStore.addExposure(graphId, `${graphId}:1`, {
const promotionStore = usePromotionStore()
promotionStore.promote(graphId, 1 as NodeId, {
sourceNodeId: '10',
sourcePreviewName: '$$canvas-image-preview'
sourceWidgetName: 'seed'
})
const widgetValueStore = useWidgetValueStore()
@@ -307,21 +305,27 @@ describe('Graph Clearing and Callbacks', () => {
disabled: undefined
})
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(true)
expect(widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')).toEqual(
expect.objectContaining({ value: 1 })
)
expect(
previewExposureStore.getExposures(graphId, `${graphId}:1`)
).toHaveLength(1)
graph.clear()
expect(
promotionStore.isPromotedByAny(graphId, {
sourceNodeId: '10',
sourceWidgetName: 'seed'
})
).toBe(false)
expect(
widgetValueStore.getWidget(graphId, '10' as NodeId, 'seed')
).toBeUndefined()
expect(previewExposureStore.getExposures(graphId, `${graphId}:1`)).toEqual(
[]
)
})
})
@@ -987,48 +991,6 @@ describe('deduplicateSubgraphNodeIds (via configure)', () => {
}
})
it('warns when configuring a host with legacy proxyWidgets and no migration hook is wired', () => {
const subgraph = createTestSubgraph()
const sourceHost = createTestSubgraphNode(subgraph)
sourceHost.graph!.add(sourceHost)
sourceHost.properties.proxyWidgets = [['9999', 'seed']]
const serialized = sourceHost.rootGraph.serialize()
const instanceData = sourceHost.serialize()
const previous = LGraph.proxyWidgetMigrationFlush
LGraph.proxyWidgetMigrationFlush = undefined
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
LiteGraph.registerNodeType(
subgraph.id,
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(new LGraph(), subgraph, instanceData)
}
}
)
try {
const graph = new LGraph()
graph.configure(serialized)
const migrationCall = warn.mock.calls.find(
(call) =>
typeof call[0] === 'string' &&
call[0].includes('Legacy proxyWidgets were not migrated')
)
expect(migrationCall).toBeDefined()
expect(migrationCall![1]).toEqual(
expect.objectContaining({
hostNodeId: expect.any(Number),
proxyWidgets: expect.anything()
})
)
} finally {
LGraph.proxyWidgetMigrationFlush = previous
LiteGraph.unregisterNodeType(subgraph.id)
warn.mockRestore()
}
})
it('throws when node ID space is exhausted', () => {
expect(() => {
const graph = new LGraph()

View File

@@ -1,5 +1,6 @@
import { toString } from 'es-toolkit/compat'
import type { PromotedWidgetSource } from '@/core/graph/subgraph/promotedWidgetTypes'
import {
SUBGRAPH_INPUT_ID,
SUBGRAPH_OUTPUT_ID
@@ -9,7 +10,10 @@ import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import {
makePromotionEntryKey,
usePromotionStore
} from '@/stores/promotionStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { forEachNode } from '@/utils/graphTraversalUtil'
@@ -76,7 +80,6 @@ import type {
LGraphTriggerHandler,
LGraphTriggerParam
} from './types/graphTriggers'
import { LGraphTriggerActions } from './types/graphTriggers'
import type {
ExportedSubgraph,
ExposedWidget,
@@ -97,12 +100,6 @@ export type {
LGraphTriggerParam
} from './types/graphTriggers'
const validTriggerActions = new Set<LGraphTriggerAction>(LGraphTriggerActions)
function isLGraphTriggerAction(action: string): action is LGraphTriggerAction {
return validTriggerActions.has(action as LGraphTriggerAction)
}
export type RendererType = 'LG' | 'Vue' | 'Vue-corrected'
/**
@@ -189,15 +186,6 @@ export class LGraph
static STATUS_STOPPED = 1
static STATUS_RUNNING = 2
/** @internal */
static proxyWidgetMigrationFlush?: (
hostNode: SubgraphNode,
nodeData: ISerialisedNode | undefined
) => void
/** @internal */
static autoExposePreviewNodes?: (hostNode: SubgraphNode) => void
/** List of LGraph properties that are manually handled by {@link LGraph.configure}. */
static readonly ConfigureProperties = new Set([
'nodes',
@@ -385,7 +373,7 @@ export class LGraph
const graphId = this.id
if (this.isRootGraph && graphId !== zeroUuid) {
usePreviewExposureStore().clearGraph(graphId)
usePromotionStore().clearGraph(graphId)
useWidgetValueStore().clearGraph(graphId)
}
@@ -1349,11 +1337,18 @@ export class LGraph
): void
trigger(action: string, param: unknown): void
trigger(action: string, param: unknown) {
if (!isLGraphTriggerAction(action)) return
if (!param || typeof param !== 'object') return
// Convert to discriminated union format for typed handlers
const validEventTypes = new Set([
'node:slot-links:changed',
'node:slot-errors:changed',
'node:property:changed',
'node:slot-label:changed'
])
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
this.events.dispatch(action, param as never)
if (validEventTypes.has(action) && param && typeof param === 'object') {
this.onTrigger?.({ type: action, ...param } as LGraphTriggerEvent)
}
// Don't handle unknown events - just ignore them
}
/** @todo Clean up - never implemented. */
@@ -1919,6 +1914,13 @@ export class LGraph
subgraphNode._setConcreteSlots()
subgraphNode.arrange()
// Repair ancestor promotions: when nodes are packed into a nested
// subgraph, any host SubgraphNode whose proxyWidgets referenced the
// moved nodes must be repointed to chain through the new nested node.
if (!this.isRootGraph) {
this._repointAncestorPromotions(nodes, subgraphNode as SubgraphNode)
}
this.canvasAction((c) =>
c.canvas.dispatchEvent(
new CustomEvent('subgraph-converted', {
@@ -1931,6 +1933,75 @@ export class LGraph
return { subgraph, node: subgraphNode as SubgraphNode }
}
/**
* After packing nodes into a nested subgraph, repoint any ancestor
* SubgraphNode promotions that referenced the moved nodes so they
* chain through the newly created nested SubgraphNode.
*/
private _repointAncestorPromotions(
movedNodes: Set<LGraphNode>,
nestedSubgraphNode: SubgraphNode
): void {
const movedNodeIds = new Set([...movedNodes].map((n) => String(n.id)))
const store = usePromotionStore()
const nestedNodeId = String(nestedSubgraphNode.id)
const graphId = this.rootGraph.id
const nestedEntries = store.getPromotions(graphId, nestedSubgraphNode.id)
const nextNestedEntries = [...nestedEntries]
const nestedEntryKeys = new Set(
nestedEntries.map((entry) => makePromotionEntryKey(entry))
)
const hostUpdates: Array<{
node: SubgraphNode
entries: PromotedWidgetSource[]
}> = []
// Find all SubgraphNode instances that host `this` subgraph.
// They live in any graph and have `type === this.id`.
const allGraphs: LGraph[] = [
this.rootGraph,
...this.rootGraph._subgraphs.values()
]
for (const graph of allGraphs) {
for (const node of graph._nodes) {
if (!node.isSubgraphNode() || node.type !== this.id) continue
const entries = store.getPromotions(graphId, node.id)
const movedEntries = entries.filter((entry) =>
movedNodeIds.has(entry.sourceNodeId)
)
if (movedEntries.length === 0) continue
for (const entry of movedEntries) {
const key = makePromotionEntryKey(entry)
if (nestedEntryKeys.has(key)) continue
nestedEntryKeys.add(key)
nextNestedEntries.push(entry)
}
const nextEntries = entries.map((entry) => {
if (!movedNodeIds.has(entry.sourceNodeId)) return entry
return {
sourceNodeId: nestedNodeId,
sourceWidgetName: entry.sourceWidgetName,
disambiguatingSourceNodeId:
entry.disambiguatingSourceNodeId ?? entry.sourceNodeId
}
})
hostUpdates.push({ node, entries: nextEntries })
}
}
if (nextNestedEntries.length !== nestedEntries.length)
store.setPromotions(graphId, nestedSubgraphNode.id, nextNestedEntries)
for (const { node, entries } of hostUpdates) {
store.setPromotions(graphId, node.id, entries)
node.rebuildInputWidgetBindings()
}
}
unpackSubgraph(
subgraphNode: SubgraphNode,
options?: { skipMissingNodes?: boolean }
@@ -2651,25 +2722,6 @@ export class LGraph
this.updateExecutionOrder()
for (const node of this._nodes) {
if (!(node instanceof SubgraphNode)) continue
if (node.properties?.proxyWidgets !== undefined) {
const nodeData = nodeDataMap.get(node.id)
if (LGraph.proxyWidgetMigrationFlush) {
LGraph.proxyWidgetMigrationFlush(node, nodeData)
} else if (import.meta.env.DEV || import.meta.env.MODE === 'test') {
console.warn(
'[SubgraphNode] Legacy proxyWidgets were not migrated because no migration flush hook is wired',
{
hostNodeId: node.id,
proxyWidgets: node.properties.proxyWidgets
}
)
}
}
LGraph.autoExposePreviewNodes?.(node)
}
this.onConfigure?.(data)
this.incrementVersion()

View File

@@ -1,33 +1,12 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { flushProxyWidgetMigration } from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import { autoExposeKnownPreviewNodes } from '@/core/graph/subgraph/promotionUtils'
import type { Subgraph } from '@/lib/litegraph/src/litegraph'
import {
LGraph,
LGraphCanvas,
LGraphNode,
LiteGraph,
SubgraphNode,
createUuidv4
} from '@/lib/litegraph/src/litegraph'
import { LGraph, LGraphNode, createUuidv4 } from '@/lib/litegraph/src/litegraph'
import { remapClipboardSubgraphNodeIds } from '@/lib/litegraph/src/LGraphCanvas'
import type {
ClipboardItems,
ExportedSubgraph,
ISerialisedNode
} from '@/lib/litegraph/src/types/serialisation'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { createMockCanvasRenderingContext2D } from '@/utils/__tests__/litegraphTestUtils'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
function createSerialisedNode(
id: number,
@@ -120,287 +99,4 @@ describe('remapClipboardSubgraphNodeIds', () => {
[String(remappedInteriorId), 'seed']
])
})
it('remaps pasted SubgraphNode previewExposures sourceNodeId references', () => {
const rootGraph = new LGraph()
const existingNode = new LGraphNode('existing')
existingNode.id = 1
rootGraph.add(existingNode)
const subgraphId = createUuidv4()
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: 0,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [createSerialisedNode(1, 'test/node')],
links: [],
groups: []
}
const hostInfo = createSerialisedNode(99, subgraphId)
hostInfo.properties = {
previewExposures: [
{
name: '$$canvas-image-preview',
sourceNodeId: '1',
sourcePreviewName: '$$canvas-image-preview'
}
]
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
remapClipboardSubgraphNodeIds(parsed, rootGraph)
const remappedInteriorId = parsed.subgraphs?.[0]?.nodes?.[0]?.id
expect(remappedInteriorId).not.toBe(1)
expect(parsed.nodes?.[0]?.properties?.previewExposures).toStrictEqual([
{
name: '$$canvas-image-preview',
sourceNodeId: String(remappedInteriorId),
sourcePreviewName: '$$canvas-image-preview'
}
])
})
})
describe('_deserializeItems paste-time migration & auto-expose', () => {
let originalFlush: typeof LGraph.proxyWidgetMigrationFlush
let originalAutoExpose: typeof LGraph.autoExposePreviewNodes
const registeredTypesToCleanup: string[] = []
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
originalFlush = LGraph.proxyWidgetMigrationFlush
originalAutoExpose = LGraph.autoExposePreviewNodes
})
afterEach(() => {
LGraph.proxyWidgetMigrationFlush = originalFlush
LGraph.autoExposePreviewNodes = originalAutoExpose
for (const type of registeredTypesToCleanup) {
LiteGraph.unregisterNodeType(type)
}
registeredTypesToCleanup.length = 0
})
function registerSubgraphNodeTypeOnCreate(rootGraph: LGraph): void {
rootGraph.events.addEventListener('subgraph-created', (e) => {
const { subgraph } = e.detail
class TestSubgraphNode extends SubgraphNode {
constructor() {
super(rootGraph, subgraph as Subgraph, {
id: -1,
type: subgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
})
}
}
LiteGraph.registerNodeType(subgraph.id, TestSubgraphNode)
registeredTypesToCleanup.push(subgraph.id)
})
}
function createCanvas(graph: LGraph): LGraphCanvas {
const el = document.createElement('canvas')
el.width = 800
el.height = 600
el.getContext = vi
.fn()
.mockReturnValue(createMockCanvasRenderingContext2D())
el.getBoundingClientRect = vi
.fn()
.mockReturnValue({ left: 0, top: 0, width: 800, height: 600 })
return new LGraphCanvas(el, graph, { skip_render: true })
}
it('clears legacy proxyWidgets on a pasted SubgraphNode and applies host widget values', () => {
LGraph.proxyWidgetMigrationFlush = (hostNode, nodeData) =>
flushProxyWidgetMigration({
hostNode,
hostWidgetValues: nodeData?.widgets_values
})
const rootGraph = new LGraph()
registerSubgraphNodeTypeOnCreate(rootGraph)
const canvas = createCanvas(rootGraph)
const subgraphId = createUuidv4()
const interiorId = 7
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: interiorId,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorId,
type: 'test/inner',
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
],
links: [],
groups: []
}
const hostInfo: ISerialisedNode = {
id: 99,
type: subgraphId,
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: { proxyWidgets: [[String(interiorId), 'seed']] },
widgets_values: [42]
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
canvas._deserializeItems(parsed, {})
const pastedHosts = rootGraph.nodes.filter(
(n): n is SubgraphNode => n instanceof SubgraphNode
)
expect(pastedHosts).toHaveLength(1)
expect(pastedHosts[0].properties.proxyWidgets).toBeUndefined()
})
it('auto-exposes preview nodes for pasted subgraphs that lack previewExposures', () => {
LGraph.autoExposePreviewNodes = (hostNode) =>
autoExposeKnownPreviewNodes(hostNode)
const rootGraph = new LGraph()
registerSubgraphNodeTypeOnCreate(rootGraph)
const canvas = createCanvas(rootGraph)
const subgraphId = createUuidv4()
const interiorPreviewId = 5
const pastedSubgraph: ExportedSubgraph = {
id: subgraphId,
version: 1,
revision: 0,
state: {
lastNodeId: interiorPreviewId,
lastLinkId: 0,
lastGroupId: 0,
lastRerouteId: 0
},
config: {},
name: 'Pasted Subgraph',
inputNode: { id: -10, bounding: [0, 0, 10, 10] },
outputNode: { id: -20, bounding: [0, 0, 10, 10] },
inputs: [],
outputs: [],
widgets: [],
nodes: [
{
id: interiorPreviewId,
type: 'PreviewImage',
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
],
links: [],
groups: []
}
const hostInfo: ISerialisedNode = {
id: 99,
type: subgraphId,
pos: [0, 0],
size: [140, 80],
flags: {},
order: 0,
mode: 0,
inputs: [],
outputs: [],
properties: {}
}
const parsed: ClipboardItems = {
nodes: [hostInfo],
groups: [],
reroutes: [],
links: [],
subgraphs: [pastedSubgraph]
}
canvas._deserializeItems(parsed, {})
const pastedHost = rootGraph.nodes.find(
(n): n is SubgraphNode => n instanceof SubgraphNode
)
expect(pastedHost).toBeDefined()
const exposures = usePreviewExposureStore().getExposures(
rootGraph.id,
String(pastedHost!.id)
)
const interiorIdAfterRemap = pastedHost!.subgraph.nodes[0].id
expect(exposures).toEqual([
expect.objectContaining({
sourceNodeId: String(interiorIdAfterRemap),
sourcePreviewName: '$$canvas-image-preview'
})
])
})
})

View File

@@ -84,7 +84,6 @@ import {
} from './measure'
import { NodeInputSlot } from './node/NodeInputSlot'
import type { Subgraph } from './subgraph/Subgraph'
import { topologicalSortSubgraphs } from './subgraph/subgraphDeduplication'
import { SubgraphIONodeBase } from './subgraph/SubgraphIONodeBase'
import type { SubgraphInputNode } from './subgraph/SubgraphInputNode'
import { SubgraphNode } from './subgraph/SubgraphNode'
@@ -4192,8 +4191,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
const subgraph = graph.createSubgraph(info)
results.subgraphs.set(info.id, subgraph)
}
const configureOrder = topologicalSortSubgraphs(parsed.subgraphs)
for (const info of configureOrder)
for (const info of parsed.subgraphs)
results.subgraphs.get(info.id)?.configure(info)
// Groups
@@ -4220,16 +4218,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
graph.add(node)
node.configure(info)
if (node instanceof SubgraphNode) {
if (
node.properties?.proxyWidgets !== undefined &&
LiteGraph.LGraph.proxyWidgetMigrationFlush
) {
LiteGraph.LGraph.proxyWidgetMigrationFlush(node, info)
}
LiteGraph.LGraph.autoExposePreviewNodes?.(node)
}
created.push(node)
}
@@ -9057,35 +9045,12 @@ function remapProxyWidgets(
}
}
function hasStringSourceNodeId(
value: unknown
): value is { sourceNodeId: string } {
return (
typeof value === 'object' &&
value !== null &&
'sourceNodeId' in value &&
typeof value.sourceNodeId === 'string'
)
}
function remapPreviewExposures(
info: ISerialisedNode,
remappedIds: Map<NodeId, NodeId> | undefined
) {
if (!remappedIds || remappedIds.size === 0) return
const previewExposures = info.properties?.previewExposures
if (!Array.isArray(previewExposures)) return
for (const entry of previewExposures) {
if (!hasStringSourceNodeId(entry) || entry.sourceNodeId === '-1') continue
const remappedNodeId = remapNodeId(entry.sourceNodeId, remappedIds)
if (remappedNodeId !== undefined)
entry.sourceNodeId = String(remappedNodeId)
}
}
/**
* Remaps pasted subgraph interior node IDs that would collide with existing
* node IDs in the root graph. Also patches subgraph link node IDs and
* SubgraphNode `properties.proxyWidgets` references so promoted widget
* associations stay aligned with remapped interior IDs.
*/
export function remapClipboardSubgraphNodeIds(
parsed: ClipboardItems,
rootGraph: LGraph
@@ -9139,8 +9104,6 @@ export function remapClipboardSubgraphNodeIds(
for (const nodeInfo of allNodeInfo) {
if (typeof nodeInfo.type !== 'string') continue
const remappedIds = subgraphNodeIdMap.get(nodeInfo.type)
remapProxyWidgets(nodeInfo, remappedIds)
remapPreviewExposures(nodeInfo, remappedIds)
remapProxyWidgets(nodeInfo, subgraphNodeIdMap.get(nodeInfo.type))
}
}

View File

@@ -997,6 +997,7 @@ export class LGraphNode
return o
}
/* Creates a clone of this node */
clone(): LGraphNode | null {
if (this.type == null) return null
const node = LiteGraph.createNode(this.type)
@@ -1022,9 +1023,9 @@ export class LGraphNode
// @ts-expect-error Exceptional case: id is removed so that the graph can assign a new one on add.
data.id = undefined
node.id = this.id
if (LiteGraph.use_uuids) data.id = LiteGraph.uuidv4()
node.configure(data)
if (LiteGraph.use_uuids) node.id = LiteGraph.uuidv4()
return node
}

View File

@@ -77,6 +77,7 @@ export class LiteGraphGlobal {
WIDGET_BGCOLOR = '#222'
WIDGET_OUTLINE_COLOR = '#666'
WIDGET_PROMOTED_OUTLINE_COLOR = '#BF00FF'
WIDGET_ADVANCED_OUTLINE_COLOR = 'rgba(56, 139, 253, 0.8)'
WIDGET_TEXT_COLOR = '#DDD'
WIDGET_SECONDARY_TEXT_COLOR = '#999'

View File

@@ -135,6 +135,7 @@ LiteGraphGlobal {
"WIDGET_BGCOLOR": "#222",
"WIDGET_DISABLED_TEXT_COLOR": "#666",
"WIDGET_OUTLINE_COLOR": "#666",
"WIDGET_PROMOTED_OUTLINE_COLOR": "#BF00FF",
"WIDGET_SECONDARY_TEXT_COLOR": "#999",
"WIDGET_TEXT_COLOR": "#DDD",
"allow_multi_output_for_events": true,

View File

@@ -1,9 +1,7 @@
import type { LGraph } from '@/lib/litegraph/src/LGraph'
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { LLink, ResolvedConnection } from '@/lib/litegraph/src/LLink'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { NodeSlotType } from '@/lib/litegraph/src/types/globalEnums'
import type {
ExportedSubgraph,
ISerialisedGraph,
@@ -50,23 +48,4 @@ export interface LGraphEventMap {
subgraph: Subgraph
closingGraph: LGraph | Subgraph
}
'node:property:changed': {
nodeId: NodeId
property: string
oldValue: unknown
newValue: unknown
}
'node:slot-errors:changed': { nodeId: NodeId }
'node:slot-links:changed': {
nodeId: NodeId
slotType: NodeSlotType
slotIndex: number
connected: boolean
linkId: number
}
'node:slot-label:changed': {
nodeId: NodeId
slotType?: NodeSlotType
}
}

View File

@@ -1,4 +1,3 @@
import type { Subgraph } from '@/lib/litegraph/src/subgraph/Subgraph'
import type { SubgraphInput } from '@/lib/litegraph/src/subgraph/SubgraphInput'
import type { SubgraphNode } from '@/lib/litegraph/src/subgraph/SubgraphNode'
import type { SubgraphOutput } from '@/lib/litegraph/src/subgraph/SubgraphOutput'
@@ -32,12 +31,6 @@ export interface SubgraphEventMap extends LGraphEventMap {
index: number
}
'inputs-reordered': {
subgraph: Subgraph
oldOrder: readonly string[]
newOrder: readonly string[]
}
'renaming-input': {
input: SubgraphInput
index: number

View File

@@ -9,6 +9,8 @@ import {
} from '@/lib/litegraph/src/litegraph'
import type { LGraph, ISlotType } from '@/lib/litegraph/src/litegraph'
import { usePromotionStore } from '@/stores/promotionStore'
import {
createTestSubgraph,
createTestSubgraphNode,
@@ -204,4 +206,39 @@ describe('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

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { ToInputFromIoNodeLink } from '@/lib/litegraph/src/canvas/ToInputFromIoNodeLink'
import { LinkDirection } from '@/lib/litegraph/src//types/globalEnums'
import { usePromotionStore } from '@/stores/promotionStore'
import { subgraphTest } from './__fixtures__/subgraphFixtures'
import {
@@ -490,6 +491,15 @@ describe('SubgraphIO - Empty Slot Connection', () => {
'seed',
'seed_1'
])
expect(
usePromotionStore().getPromotions(
subgraphNode.rootGraph.id,
subgraphNode.id
)
).toEqual([
{ sourceNodeId: String(firstNode.id), sourceWidgetName: 'seed' },
{ sourceNodeId: String(secondNode.id), sourceWidgetName: 'seed' }
])
}
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import type {
ISlotType,
@@ -8,22 +8,7 @@ import type {
TWidgetType
} from '@/lib/litegraph/src/litegraph'
import { BaseWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import {
appendQuarantine,
flushProxyWidgetMigration,
makeQuarantineEntry
} from '@/core/graph/subgraph/migration/proxyWidgetMigration'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { reorderSubgraphInputsByName } from '@/core/graph/subgraph/promotionUtils'
import type { SerializedProxyWidgetTuple } from '@/core/schemas/promotionSchema'
import { computeProcessedWidgets } from '@/renderer/extensions/vueNodes/composables/useProcessedWidgets'
import { IS_CONTROL_WIDGET } from '@/scripts/controlWidgetMarker'
import { usePreviewExposureStore } from '@/stores/previewExposureStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { createNodeLocatorId } from '@/types/nodeIdentification'
import { graphToPrompt } from '@/utils/executionUtil'
import {
createEventCapture,
@@ -33,13 +18,7 @@ import {
resetSubgraphFixtureState
} from './__fixtures__/subgraphHelpers'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({})
}))
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ updatePreviews: () => ({}) })
}))
// Helper to create a node with a widget
function createNodeWithWidget(
title: string,
widgetType: TWidgetType = 'number',
@@ -67,6 +46,7 @@ function createNodeWithWidget(
return { node, widget, input }
}
// Helper to connect subgraph input to node and create SubgraphNode
function setupPromotedWidget(
subgraph: Subgraph,
node: LGraphNode,
@@ -77,15 +57,6 @@ function setupPromotedWidget(
return createTestSubgraphNode(subgraph)
}
function expectPromotedWidgetView(
widget: unknown
): asserts widget is PromotedWidgetView {
expect(widget).toMatchObject({
sourceNodeId: expect.any(String),
sourceWidgetName: expect.any(String)
})
}
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }))
resetSubgraphFixtureState()
@@ -320,6 +291,9 @@ describe('SubgraphWidgetPromotion', () => {
hostNode.configure(serializedHostNode)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(interiorNode.id), 'batch_size']
])
expect(hostNode.widgets).toHaveLength(1)
expect(hostNode.widgets[0].name).toBe('batch_size')
expect(hostNode.widgets[0].value).toBe(1)
@@ -327,6 +301,11 @@ describe('SubgraphWidgetPromotion', () => {
})
it('should prune proxyWidgets referencing nodes not in subgraph on configure', () => {
// Reproduces the bug where packing nodes into a nested subgraph leaves
// stale proxyWidgets on the outer subgraph node referencing grandchild
// node IDs that no longer exist directly in the outer subgraph.
// Uses 3 inputs with only 1 having a linked widget entry, matching the
// real workflow structure where model/vae inputs don't resolve widgets.
const subgraph = createTestSubgraph({
inputs: [
{ name: 'clip', type: 'CLIP' },
@@ -356,6 +335,8 @@ describe('SubgraphWidgetPromotion', () => {
const outerNode = createTestSubgraphNode(subgraph)
const keptSamplerNodeId = String(samplerNode.id)
// Inject stale proxyWidgets referencing nodes that don't exist in
// this subgraph (they were packed into a nested subgraph)
outerNode.properties.proxyWidgets = [
['999', 'text'],
['998', 'text'],
@@ -375,7 +356,7 @@ describe('SubgraphWidgetPromotion', () => {
expect(widgetSourceIds).toContain(keptSamplerNodeId)
})
it('resolves legacy prefixed proxyWidgets via the immediate child PromotedWidgetView identity', () => {
it('should normalize legacy prefixed proxyWidgets on configure', () => {
const rootGraph = createTestRootGraph()
const innerSubgraph = createTestSubgraph({
@@ -415,16 +396,21 @@ describe('SubgraphWidgetPromotion', () => {
}
hostNode.configure(serializedHostNode)
flushProxyWidgetMigration({ hostNode })
const promotedWidgets = hostNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
expect(promotedWidgets).toHaveLength(1)
expect(promotedWidgets[0]?.sourceNodeId).toBe(String(nestedNode.id))
expect(hostNode.properties.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toBeUndefined()
expect(promotedWidgets[0].type).toBe('number')
expect(promotedWidgets[0].value).toBe(123)
expect(promotedWidgets[0].sourceWidgetName).toBe('noise_seed')
expect(promotedWidgets[0].disambiguatingSourceNodeId).toBe(
String(samplerNode.id)
)
expect(hostNode.properties.proxyWidgets).toStrictEqual([
[String(nestedNode.id), 'noise_seed', String(samplerNode.id)]
])
})
it('should preserve promoted widget entries after cloning', () => {
@@ -441,17 +427,31 @@ describe('SubgraphWidgetPromotion', () => {
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const serialized = hostNode.serialize()
// serialize() syncs the promotion store into properties.proxyWidgets
const serialized = hostNode.serialize()
const originalProxyWidgets = serialized.properties!
.proxyWidgets as string[][]
expect(originalProxyWidgets.length).toBeGreaterThan(0)
expect(
originalProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
// Simulate clone: create a second SubgraphNode configured from serialized data
const cloneNode = createTestSubgraphNode(subgraph)
cloneNode.configure(serialized)
const cloneProxyWidgets = cloneNode.properties.proxyWidgets as string[][]
const promotedNames = cloneNode.widgets
.filter(isPromotedWidgetView)
.filter((widget) => !widget.name.startsWith('$$'))
.map((widget) => widget.sourceWidgetName)
expect(cloneProxyWidgets.length).toBeGreaterThan(0)
expect(
cloneProxyWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
expect(promotedNames).toContain('text')
// Clone's proxyWidgets should reference the same interior node
const originalNodeIds = originalProxyWidgets.map(([nodeId]) => nodeId)
const cloneNodeIds = cloneProxyWidgets.map(([nodeId]) => nodeId)
expect(cloneNodeIds).toStrictEqual(originalNodeIds)
})
})
@@ -574,807 +574,4 @@ describe('SubgraphWidgetPromotion', () => {
expect(promotedWidget.value).toBe(42)
})
})
describe('SubgraphNode.serialize', () => {
it('does not mutate interior widget values when serializing the host', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode, widget: interiorWidget } =
createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorNode.inputs[0], interiorNode)
const hostNode = createTestSubgraphNode(subgraph)
const hostWidget = hostNode.widgets[0]
expectPromotedWidgetView(hostWidget)
useWidgetValueStore().registerWidget(hostNode.rootGraph.id, {
nodeId: hostNode.id,
name: hostWidget.name,
type: hostWidget.type,
value: 99,
options: {}
})
hostNode.serialize()
expect(interiorWidget.value).toBe(42)
})
describe('host widget values', () => {
type SourceSpec = {
inputName: string
title: string
widgetType: TWidgetType
slotType: ISlotType
initialValue: unknown
withComfyClass?: boolean
hugeMaxSeed?: boolean
}
type EditValue = string | number | boolean
type EditSpec = {
via: 'viewKey' | 'vue'
index: number
value: EditValue
}
type ReorderSpec = { kind: 'none' } | { kind: 'byName'; order: string[] }
const text = (inputName: string, title: string): SourceSpec => ({
inputName,
title,
widgetType: 'text',
slotType: 'STRING',
initialValue: '',
withComfyClass: true
})
const num = (
inputName: string,
title: string,
initialValue = 0
): SourceSpec => ({
inputName,
title,
widgetType: 'number',
slotType: 'number',
initialValue
})
const seed = (): SourceSpec => ({
inputName: 'seed',
title: 'Sampler',
widgetType: 'number',
slotType: 'INT',
initialValue: 0,
withComfyClass: true
})
const TEXT_PAIR: SourceSpec[] = [
text('first', 'First'),
text('second', 'Second')
]
const NUMBER_PAIR: SourceSpec[] = [
num('first', 'First', 1),
num('second', 'Second', 2)
]
const TEXT_TEXT_SEED: SourceSpec[] = [
text('text_1', 'Positive'),
text('text', 'Negative'),
seed()
]
function buildSources(
subgraph: ReturnType<typeof createTestSubgraph>,
specs: SourceSpec[]
) {
const built = specs.map((s) => {
const created = createNodeWithWidget(
s.title,
s.widgetType,
s.initialValue,
s.slotType
)
if (s.withComfyClass) created.node.comfyClass = s.title
if (s.hugeMaxSeed) created.widget.options.max = 1125899906842624
subgraph.add(created.node)
return created
})
for (const [i, s] of specs.entries()) {
subgraph
.addInput(s.inputName, String(s.slotType))
.connect(built[i].input, built[i].node)
}
return built
}
function vueEdit(
host: ReturnType<typeof createTestSubgraphNode>,
index: number,
value: EditValue
) {
const widgets = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
widgets[index].updateHandler(value)
}
function applyEdit(
host: ReturnType<typeof createTestSubgraphNode>,
edit: EditSpec
) {
if (edit.via === 'viewKey') host.widgets[edit.index].value = edit.value
else vueEdit(host, edit.index, edit.value)
}
function applyReorder(
host: ReturnType<typeof createTestSubgraphNode>,
r: ReorderSpec
) {
if (r.kind === 'byName') reorderSubgraphInputsByName(host, r.order)
}
function makeControlWidget(
value: 'increment' | 'fixed',
marker: boolean
) {
const base = {
name: 'control_after_generate',
value,
serialize: false,
beforeQueued: () => {},
afterQueued: () => {}
}
return marker ? { ...base, [IS_CONTROL_WIDGET]: true } : base
}
type ReorderCase = {
name: string
sources: SourceSpec[]
edits: EditSpec[]
reorder: ReorderSpec
expectedNames?: string[]
expectedWidgetsValues?: unknown[]
promptByIndex?: Record<number, unknown>
}
const reorderCases: ReorderCase[] = [
{
name: 'plain numbers via ViewKey, swap by name',
sources: NUMBER_PAIR,
edits: [
{ via: 'viewKey', index: 0, value: 111 },
{ via: 'viewKey', index: 1, value: 222 }
],
reorder: { kind: 'byName', order: ['second', 'first'] },
expectedNames: ['second', 'first'],
expectedWidgetsValues: [222, 111]
},
{
name: 'plain text via Vue, swap by name (widgets_values + prompt)',
sources: TEXT_PAIR,
edits: [
{ via: 'vue', index: 0, value: 'first value' },
{ via: 'vue', index: 1, value: 'second value' }
],
reorder: { kind: 'byName', order: ['second', 'first'] },
expectedWidgetsValues: ['second value', 'first value'],
promptByIndex: { 0: 'first value', 1: 'second value' }
},
{
name: 'mixed text/text/seed via ViewKey, swap seed up by name',
sources: TEXT_TEXT_SEED,
edits: [
{ via: 'viewKey', index: 0, value: 'positive prompt' },
{ via: 'viewKey', index: 1, value: 'negative prompt' },
{ via: 'viewKey', index: 2, value: 123456 }
],
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
promptByIndex: {
0: 'positive prompt',
1: 'negative prompt',
2: 123456
}
},
{
name: 'mixed text/text/seed via Vue, swap seed up by name',
sources: TEXT_TEXT_SEED,
edits: [
{ via: 'vue', index: 0, value: 'positive prompt' },
{ via: 'vue', index: 1, value: 'negative prompt' },
{ via: 'vue', index: 2, value: 123456 }
],
reorder: { kind: 'byName', order: ['text_1', 'seed', 'text'] },
expectedWidgetsValues: ['positive prompt', 123456, 'negative prompt'],
promptByIndex: {
0: 'positive prompt',
1: 'negative prompt',
2: 123456
}
}
]
it.for(reorderCases)('$name', async (c) => {
const subgraph = createTestSubgraph()
const sources = buildSources(subgraph, c.sources)
const host = createTestSubgraphNode(subgraph)
if (c.promptByIndex) {
host.comfyClass = 'Subgraph'
host.graph?.add(host)
}
for (const edit of c.edits) applyEdit(host, edit)
applyReorder(host, c.reorder)
if (c.expectedNames) {
expect(host.widgets.map((w) => w.name)).toEqual(c.expectedNames)
}
if (c.expectedWidgetsValues !== undefined) {
expect(host.serialize().widgets_values).toEqual(
c.expectedWidgetsValues
)
}
if (c.promptByIndex) {
const { output } = await graphToPrompt(host.rootGraph)
for (const [iStr, value] of Object.entries(c.promptByIndex)) {
const i = Number(iStr)
expect(
output[`${host.id}:${sources[i].node.id}`].inputs.value
).toBe(value)
}
}
})
type ControlCase = {
name: string
editVia: 'viewKey' | 'vue'
controlMode: 'increment' | 'fixed'
controlMarker: boolean
seedHostValue: number
mutateSourceSeedAfterReorder?: number
callAfterQueued?: boolean
expect: {
promptSeed?: number
sourceSeed?: number
processedSeedValue?: number
hostSeedValue?: number
storeSeedValue?: number
}
}
const controlCases: ControlCase[] = [
{
name: 'ViewKey + increment: source seed mutation after reorder is ignored in prompt',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: false,
seedHostValue: 123456,
mutateSourceSeedAfterReorder: 789,
expect: { promptSeed: 123456 }
},
{
name: 'Vue + fixed: host-wins — does not push Vue value into source seed',
editVia: 'vue',
controlMode: 'fixed',
controlMarker: false,
seedHostValue: 123456,
expect: { sourceSeed: 0 }
},
{
name: 'Vue + increment + afterQueued: processed widgets reflect increment',
editVia: 'vue',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 123456,
callAfterQueued: true,
expect: { processedSeedValue: 123457 }
},
{
name: 'ViewKey + increment + afterQueued: host seed increments without source value',
editVia: 'viewKey',
controlMode: 'increment',
controlMarker: true,
seedHostValue: 2,
mutateSourceSeedAfterReorder: 8,
callAfterQueued: true,
expect: { hostSeedValue: 3, storeSeedValue: 3 }
}
]
it.for(controlCases)('$name', async (c) => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
if (c.expect.promptSeed !== undefined) {
host.comfyClass = 'Subgraph'
host.graph?.add(host)
}
if (c.editVia === 'viewKey') {
host.widgets[0].value = 'positive prompt'
host.widgets[1].value = 'negative prompt'
host.widgets[2].value = c.seedHostValue
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
} else {
seed.widget.linkedWidgets = [
makeControlWidget(c.controlMode, c.controlMarker) as never
]
vueEdit(host, 2, c.seedHostValue)
}
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
if (c.mutateSourceSeedAfterReorder !== undefined) {
seed.widget.value = c.mutateSourceSeedAfterReorder
}
if (c.callAfterQueued) host.widgets[1].afterQueued?.()
if (c.expect.promptSeed !== undefined) {
const { output } = await graphToPrompt(host.rootGraph)
expect(output[`${host.id}:${seed.node.id}`].inputs.value).toBe(
c.expect.promptSeed
)
}
if (c.expect.sourceSeed !== undefined) {
expect(seed.widget.value).toBe(c.expect.sourceSeed)
}
if (c.expect.processedSeedValue !== undefined) {
const updated = computeProcessedWidgets({
nodeData: extractVueNodeData(host),
graphId: host.rootGraph.id,
showAdvanced: false,
isGraphReady: false,
rootGraph: null,
ui: { getTooltipConfig: () => ({}), handleNodeRightClick: () => {} }
})
expect(updated[1].value).toBe(c.expect.processedSeedValue)
}
if (c.expect.hostSeedValue !== undefined) {
expect(host.widgets[1].value).toBe(c.expect.hostSeedValue)
}
if (c.expect.storeSeedValue !== undefined) {
expect(
useWidgetValueStore()
.getNodeWidgets(host.rootGraph.id, host.id)
.find((entry) => entry.name === 'seed')?.value
).toBe(c.expect.storeSeedValue)
}
})
it('afterQueued does not run value-control when the host input is externally linked', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(
subgraph,
TEXT_TEXT_SEED.map((s) =>
s.title === 'Sampler' ? { ...s, hugeMaxSeed: true } : s
)
)
const seed = sources[2]
const host = createTestSubgraphNode(subgraph)
seed.widget.linkedWidgets = [
makeControlWidget('increment', true) as never
]
host.widgets[2].value = 2
reorderSubgraphInputsByName(host, ['text_1', 'seed', 'text'])
const seedSlot = host.getSlotFromWidget(host.widgets[1])
expect(seedSlot).toBeDefined()
seedSlot!.link = -1
host.widgets[1].afterQueued?.()
expect(host.widgets[1].value).toBe(2)
})
it('serializes promoted values from each host independently', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
firstHost.widgets[0].value = 111
secondHost.widgets[0].value = 222
expect(firstHost.serialize().widgets_values).toEqual([111])
expect(secondHost.serialize().widgets_values).toEqual([222])
})
it('does not persist source widget store fallback values after reordering', () => {
const subgraph = createTestSubgraph()
const sources = buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph)
const widgetStore = useWidgetValueStore()
for (const { node, widget } of sources) {
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: node.id,
name: widget.name,
type: widget.type,
value: `${node.title} value`,
options: {}
})
}
reorderSubgraphInputsByName(host, ['second', 'first'])
expect(host.serialize().widgets_values).toBeUndefined()
})
it('does not acquire a host overlay when a source fallback is saved and reloaded', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'STRING' }]
})
const { node: interiorNode, widget: interiorWidget } =
createNodeWithWidget('Interior', 'text', '', 'STRING')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const host = createTestSubgraphNode(subgraph, { id: 101 })
const widgetStore = useWidgetValueStore()
widgetStore.registerWidget(host.rootGraph.id, {
nodeId: interiorNode.id,
name: interiorWidget.name,
type: interiorWidget.type,
value: 'source fallback',
options: {}
})
const serialized = host.serialize()
expect(serialized.widgets_values).toBeUndefined()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toEqual([])
expect(reloaded.serialize().widgets_values).toBeUndefined()
})
it('does not hydrate missing widgets_values entries as explicit host overlays', () => {
const subgraph = createTestSubgraph()
buildSources(subgraph, TEXT_PAIR)
const host = createTestSubgraphNode(subgraph, { id: 101 })
host.widgets[1].value = 'second host value'
const serialized = host.serialize()
expect(serialized.widgets_values).toEqual([
undefined,
'second host value'
])
const widgetStore = useWidgetValueStore()
widgetStore.clearGraph(host.rootGraph.id)
const reloaded = createTestSubgraphNode(subgraph, { id: 101 })
reloaded.configure(serialized)
const [first, second] = reloaded.widgets
expectPromotedWidgetView(first)
expectPromotedWidgetView(second)
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, first.name)
).toBeUndefined()
expect(
widgetStore.getWidget(reloaded.rootGraph.id, reloaded.id, second.name)
?.value
).toBe('second host value')
expect(
widgetStore.getNodeWidgets(reloaded.rootGraph.id, reloaded.id)
).toHaveLength(1)
expect(reloaded.serialize().widgets_values).toEqual([
undefined,
'second host value'
])
})
})
describe('proxyWidgets is no longer re-emitted', () => {
it('does not write properties.proxyWidgets after serialize', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'value', type: 'number' }]
})
const { node: interiorNode } = createNodeWithWidget('Interior')
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(
interiorNode.inputs[0],
interiorNode
)
const hostNode = createTestSubgraphNode(subgraph)
delete hostNode.properties.proxyWidgets
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgets).toBeUndefined()
expect(hostNode.properties.proxyWidgets).toBeUndefined()
})
it('preserves a pre-existing legacy proxyWidgets property without re-deriving it', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
const legacy: SerializedProxyWidgetTuple[] = [['7', 'seed']]
hostNode.properties.proxyWidgets = legacy
const serialized = hostNode.serialize()
expect(serialized.properties?.proxyWidgets).toStrictEqual(legacy)
})
})
describe('previewExposures round-trip', () => {
const CANVAS = '$$canvas-image-preview'
const exposure12 = { sourceNodeId: '12', sourcePreviewName: CANVAS }
const exposure14 = {
sourceNodeId: '14',
sourcePreviewName: 'videopreview'
}
const named12 = { name: CANVAS, ...exposure12 }
const named14 = { name: 'videopreview', ...exposure14 }
it('hydrates previewExposures into the store during configure', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
hostNode.properties.previewExposures = [
{ name: 'preview', ...exposure12 }
]
hostNode._internalConfigureAfterSlots()
expect(
usePreviewExposureStore().getExposures(
hostNode.rootGraph.id,
String(hostNode.id)
)
).toEqual([{ name: 'preview', ...exposure12 }])
})
type SerializeCase = {
name: string
addExposures: (typeof exposure12)[]
staleProperty?: {
name: string
sourceNodeId: string
sourcePreviewName: string
}[]
expected: (typeof named12)[] | undefined
expectLiveUnchanged?: boolean
}
const serializeCases: SerializeCase[] = [
{
name: 'writes previewExposures from the store on serialize',
addExposures: [exposure12, exposure14],
expected: [named12, named14]
},
{
name: 'writes empty previewExposures when the store has no entries for the host',
addExposures: [],
staleProperty: [
{ name: 'stale', sourceNodeId: '0', sourcePreviewName: CANVAS }
],
expected: [],
expectLiveUnchanged: true
}
]
it.for(serializeCases)('$name', (c) => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
if (c.staleProperty)
hostNode.properties.previewExposures = c.staleProperty
const store = usePreviewExposureStore()
for (const e of c.addExposures) {
store.addExposure(hostNode.rootGraph.id, String(hostNode.id), e)
}
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual(c.expected)
if (c.expectLiveUnchanged) {
expect(hostNode.properties.previewExposures).toEqual(c.staleProperty)
}
})
it('preserves an explicit empty previewExposures across reload, ignoring legacy locator entries', () => {
const hostNode = createTestSubgraphNode(createTestSubgraph())
const rootGraphId = hostNode.rootGraph.id
const hostLocator = String(hostNode.id)
const store = usePreviewExposureStore()
const serialized = hostNode.serialize()
expect(serialized.properties?.previewExposures).toEqual([])
const legacyKey = createNodeLocatorId(rootGraphId, hostNode.id)
store.setExposures(rootGraphId, legacyKey, [
{ name: 'legacy', ...exposure12 }
])
store.setExposures(rootGraphId, hostLocator, [])
hostNode.properties.previewExposures =
serialized.properties?.previewExposures
hostNode._internalConfigureAfterSlots()
expect(store.getExposures(rootGraphId, hostLocator)).toEqual([])
})
it('serializes preview exposures per host instance', () => {
const subgraph = createTestSubgraph()
const firstHost = createTestSubgraphNode(subgraph, { id: 101 })
const secondHost = createTestSubgraphNode(subgraph, { id: 102 })
subgraph.rootGraph.add(firstHost)
subgraph.rootGraph.add(secondHost)
const store = usePreviewExposureStore()
store.addExposure(
firstHost.rootGraph.id,
String(firstHost.id),
exposure12
)
store.addExposure(
firstHost.rootGraph.id,
String(secondHost.id),
exposure14
)
const firstExposures =
firstHost.serialize().properties?.previewExposures
const secondExposures =
secondHost.serialize().properties?.previewExposures
if (!Array.isArray(firstExposures) || !Array.isArray(secondExposures)) {
throw new Error('Expected serialized previewExposures arrays')
}
expect(firstExposures).toEqual([named12])
expect(secondExposures).toEqual([named14])
for (const exposed of [firstExposures[0], secondExposures[0]]) {
expect(exposed).not.toHaveProperty('hostInstanceId')
expect(exposed).not.toHaveProperty('hostNodeLocator')
expect(exposed).not.toHaveProperty('rootGraphId')
}
})
})
describe('proxyWidgetErrorQuarantine', () => {
it('preserves quarantine entries through serialize and is inert at runtime', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
appendQuarantine(hostNode, [
makeQuarantineEntry({
originalEntry: ['7', 'seed'],
reason: 'missingSourceNode',
hostValue: 42
})
])
const serialized = hostNode.serialize()
const quarantine = serialized.properties?.proxyWidgetErrorQuarantine
expect(Array.isArray(quarantine)).toBe(true)
expect(quarantine).toHaveLength(1)
expect(
hostNode.widgets.some(
(w) => 'sourceNodeId' in w && w.sourceNodeId === '7'
)
).toBe(false)
})
it('removes the property entirely when quarantine is empty', () => {
const subgraph = createTestSubgraph()
const hostNode = createTestSubgraphNode(subgraph)
hostNode.properties.proxyWidgetErrorQuarantine = []
const serialized = hostNode.serialize()
expect(
serialized.properties?.proxyWidgetErrorQuarantine
).toBeUndefined()
expect(hostNode.properties.proxyWidgetErrorQuarantine).toEqual([])
})
it('restores host values by input name when widgets_values is out of order', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'unet_name', type: 'COMBO' },
{ name: 'clip_name', type: 'COMBO' },
{ name: 'steps', type: 'INT' }
]
})
const unetNode = new LGraphNode('UNETLoader')
const unetInput = unetNode.addInput('unet_name', 'COMBO')
unetNode.addWidget(
'combo',
'unet_name',
'z_image_turbo_bf16.safetensors',
() => {},
{ values: ['z_image_turbo_bf16.safetensors'] }
)
unetInput.widget = { name: 'unet_name' }
subgraph.add(unetNode)
subgraph.inputNode.slots[0].connect(unetNode.inputs[0], unetNode)
const clipNode = new LGraphNode('CLIPLoader')
const clipInput = clipNode.addInput('clip_name', 'COMBO')
clipNode.addWidget(
'combo',
'clip_name',
'qwen_3_4b.safetensors',
() => {},
{ values: ['qwen_3_4b.safetensors'] }
)
clipInput.widget = { name: 'clip_name' }
subgraph.add(clipNode)
subgraph.inputNode.slots[1].connect(clipNode.inputs[0], clipNode)
const samplerNode = new LGraphNode('KSampler')
const stepsInput = samplerNode.addInput('steps', 'INT')
samplerNode.addWidget('number', 'steps', 8, () => {})
stepsInput.widget = { name: 'steps' }
subgraph.add(samplerNode)
subgraph.inputNode.slots[2].connect(samplerNode.inputs[0], samplerNode)
const hostNode = createTestSubgraphNode(subgraph)
const serialized = hostNode.serialize()
serialized.widgets_values = [
8,
'qwen_3_4b.safetensors',
'z_image_turbo_bf16.safetensors'
]
serialized.properties = {
...serialized.properties,
proxyWidgetErrorQuarantine: [
{
originalEntry: ['-1', 'steps'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 8,
attemptedAtVersion: 1
},
{
originalEntry: ['-1', 'clip_name'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 'qwen_3_4b.safetensors',
attemptedAtVersion: 1
},
{
originalEntry: ['-1', 'unet_name'] as SerializedProxyWidgetTuple,
reason: 'missingSourceNode',
hostValue: 'z_image_turbo_bf16.safetensors',
attemptedAtVersion: 1
}
]
}
const reloaded = createTestSubgraphNode(subgraph)
reloaded.configure(serialized)
const byName = new Map(
reloaded.inputs.map((input) => [input.name, input._widget?.value])
)
expect(byName.get('unet_name')).toBe('z_image_turbo_bf16.safetensors')
expect(byName.get('clip_name')).toBe('qwen_3_4b.safetensors')
expect(byName.get('steps')).toBe(8)
})
})
})
})

View File

@@ -15,10 +15,19 @@ interface DeduplicationResult {
}
/**
* Dedupes node IDs across serialized subgraph definitions to prevent widget
* store key collisions, and patches any root-level legacy proxyWidgets that
* reference the remapped inner IDs. Returns deep clones; inputs are not
* mutated. `state.lastNodeId` is advanced.
* Pre-deduplicates node IDs across serialized subgraph definitions before
* they are configured. This prevents widget store key collisions when
* multiple subgraph copies contain nodes with the same IDs.
*
* Also patches proxyWidgets in root-level nodes that reference the
* remapped inner node IDs.
*
* Returns deep clones of the inputs — the originals are never mutated.
*
* @param subgraphs - Serialized subgraph definitions to deduplicate
* @param reservedNodeIds - Node IDs already in use by root-level nodes
* @param state - Graph state containing the `lastNodeId` counter (mutated)
* @param rootNodes - Optional root-level nodes with proxyWidgets to patch
*/
export function deduplicateSubgraphNodeIds(
subgraphs: ExportedSubgraph[],
@@ -188,7 +197,7 @@ export function topologicalSortSubgraphs(
return sorted
}
/** Patches legacy proxyWidgets in root-level SubgraphNode instances. */
/** Patches proxyWidgets in root-level SubgraphNode instances. */
function patchProxyWidgets(
rootNodes: ISerialisedNode[],
subgraphIdSet: Set<string>,

View File

@@ -1,4 +1,3 @@
import { isEqual } from 'es-toolkit'
import type { LGraph, SubgraphId } from '@/lib/litegraph/src/LGraph'
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
import { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -26,7 +25,6 @@ import type {
import type { GraphOrSubgraph } from './Subgraph'
import type { SubgraphInput } from './SubgraphInput'
import { SubgraphInputNode } from './SubgraphInputNode'
import type { SubgraphNode } from './SubgraphNode'
import type { SubgraphOutput } from './SubgraphOutput'
import { SubgraphOutputNode } from './SubgraphOutputNode'
@@ -482,71 +480,6 @@ export function findUsedSubgraphIds(
return usedSubgraphIds
}
function reorderInPlace<T>(arr: T[], indices: readonly number[]): void {
arr.splice(0, arr.length, ...indices.flatMap((i) => arr[i] ?? []))
}
function* indexedLinks<S>(
slots: readonly S[],
resolve: (slot: S) => Iterable<LLink | undefined>
): Generator<readonly [number, LLink]> {
for (const [index, slot] of slots.entries()) {
for (const link of resolve(slot)) {
if (link) yield [index, link] as const
}
}
}
export function reorderSubgraphInputs(
subgraphNode: SubgraphNode,
orderedIndices: readonly number[]
): void {
const subgraph = subgraphNode.subgraph
if (!subgraph) return
const n = subgraph.inputs.length
if (
orderedIndices.length !== n ||
new Set(orderedIndices).size !== orderedIndices.length ||
orderedIndices.some((i) => i < 0 || i >= n)
) {
console.error(
`reorderSubgraphInputs: orderedIndices must be a permutation of 0..${n - 1}`,
orderedIndices
)
return
}
const oldOrder = subgraph.inputs.map((i) => i.id)
reorderInPlace(subgraph.inputs, orderedIndices)
reorderInPlace(subgraphNode.inputs, orderedIndices)
subgraphNode.invalidatePromotedViews()
function* innerLinks(input: SubgraphInput): Generator<LLink | undefined> {
for (const id of input.linkIds) yield subgraph.getLink(id)
}
for (const [slot, link] of indexedLinks(subgraph.inputs, innerLinks)) {
link.origin_slot = slot
}
function* outerLink(input: INodeInputSlot): Generator<LLink | undefined> {
if (input.link != null) yield subgraphNode.graph?.getLink(input.link)
}
for (const [slot, link] of indexedLinks(subgraphNode.inputs, outerLink)) {
link.target_slot = slot
}
const newOrder = subgraph.inputs.map((i) => i.id)
if (!isEqual(oldOrder, newOrder)) {
subgraph.events.dispatch('inputs-reordered', {
subgraph,
oldOrder,
newOrder
})
}
}
/**
* Type guard to check if a slot is a SubgraphInput.
* @param slot The slot to check

View File

@@ -35,14 +35,7 @@ export type LGraphTriggerEvent =
| NodeSlotLinksChangedEvent
| NodeSlotLabelChangedEvent
export const LGraphTriggerActions = [
'node:property:changed',
'node:slot-errors:changed',
'node:slot-links:changed',
'node:slot-label:changed'
] as const satisfies readonly LGraphTriggerEvent['type'][]
export type LGraphTriggerAction = (typeof LGraphTriggerActions)[number]
export type LGraphTriggerAction = LGraphTriggerEvent['type']
export type LGraphTriggerParam<A extends LGraphTriggerAction> = Extract<
LGraphTriggerEvent,

View File

@@ -1,6 +1,5 @@
import type { Bounds } from '@/renderer/core/layout/types'
import type { CurveData } from '@/components/curve/types'
import type { WidgetEntityId } from '@/world/entityIds'
import type {
CanvasColour,
@@ -375,14 +374,6 @@ export interface IRangeWidget extends IBaseWidget<
export type TWidgetType = IWidget['type']
export type TWidgetValue = IWidget['value']
export function isWidgetValue(value: unknown): value is TWidgetValue {
if (value === undefined) return true
if (typeof value === 'string') return true
if (typeof value === 'number') return true
if (typeof value === 'boolean') return true
return value !== null && typeof value === 'object'
}
/**
* The base type for all widgets. Should not be implemented directly.
* @template TValue The type of value this widget holds.
@@ -399,8 +390,6 @@ export interface IBaseWidget<
linkedWidgets?: IBaseWidget[]
readonly entityId?: WidgetEntityId
name: string
options: TOptions

View File

@@ -17,16 +17,17 @@ import type {
NodeBindable,
TWidgetType
} from '@/lib/litegraph/src/types/widgets'
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetEntityId } from '@/world/entityIds'
import { widgetEntityId } from '@/world/entityIds'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
width: number
/** Synonym for "low quality". */
showText?: boolean
/** When true, suppresses the promoted outline color (e.g. for projected copies on SubgraphNode). */
suppressPromotedOutline?: boolean
/** Transient image source for preview widgets rendered on behalf of another node (e.g. subgraph promotion). */
previewImages?: HTMLImageElement[]
}
@@ -132,13 +133,6 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
this._state.value = value
}
get entityId(): WidgetEntityId | undefined {
const graphId = this.node.graph?.rootGraph.id
const nodeId = this._state.nodeId
if (!graphId || nodeId === undefined) return undefined
return widgetEntityId(graphId, nodeId, this.name)
}
/**
* Associates this widget with a node ID and registers it in the WidgetValueStore.
* Once set, value reads/writes will be delegated to the store.
@@ -212,7 +206,17 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
}
}
getOutlineColor() {
getOutlineColor(suppressPromotedOutline = false) {
const graphId = this.node.graph?.rootGraph.id
if (
graphId &&
!suppressPromotedOutline &&
usePromotionStore().isPromotedByAny(graphId, {
sourceNodeId: String(this.node.id),
sourceWidgetName: this.name
})
)
return LiteGraph.WIDGET_PROMOTED_OUTLINE_COLOR
return this.advanced
? LiteGraph.WIDGET_ADVANCED_OUTLINE_COLOR
: LiteGraph.WIDGET_OUTLINE_COLOR
@@ -276,13 +280,13 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawWidgetShape(
ctx: CanvasRenderingContext2D,
{ width, showText }: DrawWidgetOptions
{ width, showText, suppressPromotedOutline }: DrawWidgetOptions
): void {
const { height, y } = this
const { margin } = BaseWidget
ctx.textAlign = 'left'
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.fillStyle = this.background_color
ctx.beginPath()
@@ -303,7 +307,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
*/
protected drawVueOnlyWarning(
ctx: CanvasRenderingContext2D,
{ width }: DrawWidgetOptions,
{ width, suppressPromotedOutline }: DrawWidgetOptions,
label: string
): void {
const { y, height } = this
@@ -313,7 +317,7 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class ButtonWidget
*/
override drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
) {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -41,7 +41,7 @@ export class ButtonWidget
// Draw button outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.strokeRect(margin, y, width - margin * 2, height)
}

View File

@@ -23,7 +23,7 @@ export class ChartWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class FileUploadWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class GalleriaWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -23,7 +23,7 @@ export class ImageCompareWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

View File

@@ -33,7 +33,7 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
drawWidget(
ctx: CanvasRenderingContext2D,
{ width, showText = true }: DrawWidgetOptions
{ width, showText = true, suppressPromotedOutline }: DrawWidgetOptions
): void {
// Store original context attributes
const { fillStyle, strokeStyle, textAlign } = ctx
@@ -145,10 +145,10 @@ export class KnobWidget extends BaseWidget<IKnobWidget> implements IKnobWidget {
// Draw outline if not disabled
if (showText && !this.computedDisabled) {
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
// Draw value
ctx.beginPath()
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(suppressPromotedOutline)
ctx.arc(
arc_center.x,
arc_center.y,

View File

@@ -23,7 +23,7 @@ export class MarkdownWidget
ctx.fillStyle = this.background_color
ctx.fillRect(15, y, width - 30, height)
ctx.strokeStyle = this.getOutlineColor()
ctx.strokeStyle = this.getOutlineColor(options.suppressPromotedOutline)
ctx.strokeRect(15, y, width - 30, height)
ctx.fillStyle = this.text_color

Some files were not shown because too many files have changed in this diff Show More