mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-13 02:37:55 +00:00
## Summary Make `WidgetId` (`graphId:nodeId:name`) the single canonical widget identity and represent subgraph promoted host widgets as ordinary store-backed widgets addressed by it. This deletes two whole indirection layers — the `world/*` widget-entity-IO layer and the `PromotedWidgetView` runtime — leaving one model: a widget's data lives in `widgetValueStore` keyed by `WidgetId`, and a `SubgraphNode` input references it via `input.widgetId`. **Net +304 lines across 107 files** (5,044 added / 4,740 deleted): production code is net **−798** (1,521 added / 2,319 deleted) while tests are net **+1,102** (3,523 added / 2,421 deleted). 10 files deleted outright, 14 added. ## What got deleted The old design wrapped every promoted subgraph widget in a synthetic `IBaseWidget` "view" object with live getters that followed the source widget, plus a manager to keep view identities stable, plus an IO indirection layer over the store. All of it is gone: - `promotedWidgetView.ts` — the `PromotedWidgetView` class (draw / pointer / DOM-sync / projection / deepest-source resolution getters) - `PromotedWidgetViewManager.ts` — view reconciliation/caching - `world/widgetValueIO.ts` — the IO wrapper over `widgetValueStore` - `world/entityIds.ts` + `world/brand.ts` — the `WidgetEntityId` branded-id layer and the `entityId` field - `widgetNodeTypeGuard.ts` — only used by the deleted view - the per-`SubgraphNode` view machinery (`_promotedViewManager`, `_cacheVersion`, view-key generation, DOM position-override cleanup) and every now-dead `isPromotedWidgetView` branch across the panel, menu, store, and util consumers - `domWidgetStore` position-override APIs (`setPositionOverride` / `clearPositionOverride`), only used to render a promoted DOM widget on a different host node ## Why it's simpler - One source of truth. A promoted host widget is `WidgetState` in the store, seeded from the source at promotion (`registerWidget` with a deep-cloned snapshot) and independent thereafter. No synthetic widget objects, no runtime source-following, no view cache to invalidate. - Resolution is data-driven. `resolveConcretePromotedWidget` walks `SubgraphNode` inputs (`input.widgetId` + `resolveSubgraphInputTarget`) instead of chasing view objects through `node.widgets`. This also **fixes two-layer nested promotion** — the previously-skipped parity test now passes and resolves through to the deepest concrete widget. - The right-panel Parameters tab renders a subgraph node's promoted widgets through the **same** store-backed path as ordinary node widgets: display reads `WidgetState` via `widget.widgetId`, and value writes go through `widgetValueStore.setValue(widgetId)`. ## Changes - **What**: - `WidgetId` branded type + `widgetId()` / `parseWidgetId()` / `isWidgetId()` and a `WidgetState` type; `widgetValueStore` is `WidgetId`-native (`registerWidget` / `getWidget` / `setValue` / `deleteWidget`). - Promotion creates host `WidgetState` then an input projection (`input.widgetId`); demotion clears it; serialization and legacy `proxyWidgets` migration round-trip through `input.widgetId`. - `promotedInputWidget.ts` projects a store-backed ordinary widget from an input slot; `SubgraphNode.widgets` is now a projected getter over inputs (kept Litegraph-shaped so the canvas renderer and extensions still read `node.widgets`). `invalidatePromotedViews()` is retained as a no-op for extension compatibility. - `promotedWidgetControl.ts` applies `control_after_generate` (e.g. seed increment) on the host node, since the interior control widget is link-fed and its value is dead; `syncPromotedComboHostOptions` mirrors interior combo options onto host state. - `multilineTextarea.ts` extracts the reusable multiline DOM-widget behavior out of `useStringWidget` and adds promoted multiline materialization via a `createPromotedHostWidget` app-layer hook (keeping Litegraph core free of Vue/Pinia/DOM). - Late-bound `LiteGraph` singleton holder (`litegraphInstance.ts`) to break a widget-init import cycle. - **Breaking**: - `IBaseWidget.entityId` removed — use `widgetId`. - `SubgraphNode.widgets` no longer exposes the old `PromotedWidgetView` objects; promoted state lives in `widgetValueStore` keyed by `input.widgetId` and `widgets` is a projection of inputs. The `widget-promoted` event now carries the concrete interior widget. Extension code reading `entityId` or relying on `PromotedWidgetView` is affected. ## Review Focus - Promotion / demotion / serialization round-tripping through `input.widgetId` + `widgetValueStore`, incl. the legacy `proxyWidgets` migration. - Snapshot-at-promotion semantics (host widget does not follow the source after creation), and the combo-options exception via `syncPromotedComboHostOptions`. - Two-layer nested resolution in `resolveConcretePromotedWidget` + `SubgraphNode` nested-source resolution. - The unified Parameters tab (`TabSubgraphInputs` → `SectionWidgets`): value edit / rename / favorite / hide / reorder for promoted inputs are wired through the store but warrant a visual/e2e pass. - Litegraph-compat seams worth a careful read: projected `SubgraphNode.widgets`, the canvas-edit `callback` bridge back to the store, host-level `control_after_generate`, and the late-bound `LiteGraph` holder / `domWidget.ts` import ordering. --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: AustinMroz <austin@comfy.org> Co-authored-by: GitHub Action <action@github.com>
733 lines
23 KiB
TypeScript
733 lines
23 KiB
TypeScript
import { expect } from '@playwright/test'
|
|
|
|
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
|
import { comfyExpect, comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
|
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
|
|
import { TestIds } from '@e2e/fixtures/selectors'
|
|
import type { PromotedWidgetEntry } from '@e2e/fixtures/utils/promotedWidgets'
|
|
import {
|
|
getPromotedWidgetCount,
|
|
getPromotedWidgetNames,
|
|
getPromotedWidgets
|
|
} from '@e2e/fixtures/utils/promotedWidgets'
|
|
|
|
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,
|
|
hostSubgraphNodeId: string,
|
|
widgets: PromotedWidgetEntry[]
|
|
) {
|
|
expect(widgets.length).toBeGreaterThan(0)
|
|
|
|
const interiorNodeIds = widgets.map(([id]) => id)
|
|
const results = await comfyPage.page.evaluate(
|
|
([hostId, ids]) => {
|
|
const graph = window.app!.graph!
|
|
const hostNode = graph.getNodeById(Number(hostId))
|
|
if (!hostNode?.isSubgraphNode()) return ids.map(() => false)
|
|
|
|
return ids.map((id) => {
|
|
const interiorNode = hostNode.subgraph.getNodeById(Number(id))
|
|
return interiorNode !== null && interiorNode !== undefined
|
|
})
|
|
},
|
|
[hostSubgraphNodeId, interiorNodeIds] as const
|
|
)
|
|
|
|
expect(results).toEqual(widgets.map(() => true))
|
|
}
|
|
|
|
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'
|
|
)
|
|
|
|
await expect
|
|
.poll(() => getPromotedWidgetCount(comfyPage, '2'))
|
|
.toBeGreaterThan(1)
|
|
|
|
const host = comfyPage.vueNodes.getNodeLocator('2')
|
|
await expect(host.getByTestId(TestIds.widgets.widget)).toHaveCount(2)
|
|
|
|
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()
|
|
}
|
|
)
|
|
|
|
test('Compressed target_slot workflow boots into a usable promoted widget state', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-compressed-target-slot'
|
|
)
|
|
|
|
await expect
|
|
.poll(async () => {
|
|
const widgets = await getPromotedWidgets(comfyPage, '2')
|
|
return widgets.some(([, widgetName]) => widgetName === 'batch_size')
|
|
})
|
|
.toBe(true)
|
|
})
|
|
|
|
test('Duplicate ID remap workflow remains navigable after a full reload boot path', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
await comfyPage.page.reload()
|
|
await comfyPage.page.waitForFunction(() => !!window.app)
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
|
|
|
await comfyPage.keyboard.press('Escape')
|
|
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
|
})
|
|
|
|
test.describe('Deterministic proxyWidgets Hydrate', () => {
|
|
test('proxyWidgets entries map to real interior node IDs after load', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
const widgets = await getPromotedWidgets(comfyPage, '11')
|
|
expect(widgets.length).toBeGreaterThan(0)
|
|
|
|
for (const [interiorNodeId] of widgets) {
|
|
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
|
}
|
|
|
|
await expectPromotedWidgetsToResolveToInteriorNodes(
|
|
comfyPage,
|
|
'11',
|
|
widgets
|
|
)
|
|
})
|
|
|
|
test('proxyWidgets entries survive double round-trip without drift', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
|
)
|
|
|
|
const initialWidgets = await getPromotedWidgets(comfyPage, '11')
|
|
expect(initialWidgets.length).toBeGreaterThan(0)
|
|
await expectPromotedWidgetsToResolveToInteriorNodes(
|
|
comfyPage,
|
|
'11',
|
|
initialWidgets
|
|
)
|
|
|
|
await comfyPage.subgraph.serializeAndReload()
|
|
|
|
const afterFirst = await getPromotedWidgets(comfyPage, '11')
|
|
await expectPromotedWidgetsToResolveToInteriorNodes(
|
|
comfyPage,
|
|
'11',
|
|
afterFirst
|
|
)
|
|
|
|
await comfyPage.subgraph.serializeAndReload()
|
|
|
|
const afterSecond = await getPromotedWidgets(comfyPage, '11')
|
|
await expectPromotedWidgetsToResolveToInteriorNodes(
|
|
comfyPage,
|
|
'11',
|
|
afterSecond
|
|
)
|
|
|
|
expect(afterFirst).toEqual(initialWidgets)
|
|
expect(afterSecond).toEqual(initialWidgets)
|
|
})
|
|
|
|
test('Compressed target_slot (-1) entries are hydrated to real IDs', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-compressed-target-slot'
|
|
)
|
|
|
|
const widgets = await getPromotedWidgets(comfyPage, '2')
|
|
expect(widgets.length).toBeGreaterThan(0)
|
|
|
|
for (const [interiorNodeId] of widgets) {
|
|
expect(interiorNodeId).not.toBe('-1')
|
|
expect(Number(interiorNodeId)).toBeGreaterThan(0)
|
|
}
|
|
|
|
await expectPromotedWidgetsToResolveToInteriorNodes(
|
|
comfyPage,
|
|
'2',
|
|
widgets
|
|
)
|
|
})
|
|
})
|
|
|
|
test.describe('Legacy And Round-Trip Coverage', () => {
|
|
let previousUseNewMenu: unknown
|
|
|
|
test.beforeEach(async ({ comfyPage }) => {
|
|
previousUseNewMenu =
|
|
await comfyPage.settings.getSetting('Comfy.UseNewMenu')
|
|
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
|
|
})
|
|
|
|
test.afterEach(async ({ comfyPage }) => {
|
|
await comfyPage.settings.setSetting(
|
|
'Comfy.UseNewMenu',
|
|
previousUseNewMenu
|
|
)
|
|
})
|
|
|
|
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-compressed-target-slot'
|
|
)
|
|
|
|
const promotedWidgets = await getPromotedWidgets(comfyPage, '2')
|
|
expect(promotedWidgets.length).toBeGreaterThan(0)
|
|
expect(
|
|
promotedWidgets.some(([interiorNodeId]) => interiorNodeId === '-1')
|
|
).toBe(false)
|
|
expect(
|
|
promotedWidgets.some(
|
|
([interiorNodeId, widgetName]) =>
|
|
interiorNodeId !== '-1' && widgetName === 'batch_size'
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
test('Promoted widgets survive serialize -> loadGraphData round-trip', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
|
|
expect(beforePromoted).toContain('text')
|
|
|
|
await comfyPage.subgraph.serializeAndReload()
|
|
|
|
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
|
|
expect(afterPromoted).toContain('text')
|
|
|
|
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
|
|
expect(widgetCount).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('Multi-link input representative stays stable through save/reload', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-multiple-promoted-widgets'
|
|
)
|
|
|
|
const beforeSnapshot = await getPromotedWidgets(comfyPage, '11')
|
|
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
|
|
|
await comfyPage.subgraph.serializeAndReload()
|
|
|
|
const afterSnapshot = await getPromotedWidgets(comfyPage, '11')
|
|
expect(afterSnapshot).toEqual(beforeSnapshot)
|
|
})
|
|
|
|
test('Cloning a subgraph node keeps promoted widget entries on original and clone', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
// Assert against the visible textbox the user sees, not the internal
|
|
// graph/widget projection.
|
|
const promotedTextWidgets = comfyPage.page.getByRole('textbox', {
|
|
name: 'text',
|
|
exact: true
|
|
})
|
|
await comfyExpect(promotedTextWidgets).toHaveCount(1)
|
|
|
|
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
|
const originalPos = await originalNode.getPosition()
|
|
|
|
await comfyPage.page.mouse.move(originalPos.x + 16, originalPos.y + 16)
|
|
await comfyPage.page.keyboard.down('Alt')
|
|
try {
|
|
await comfyPage.page.mouse.down()
|
|
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
|
|
await comfyPage.page.mouse.up()
|
|
} finally {
|
|
await comfyPage.page.keyboard.up('Alt')
|
|
}
|
|
|
|
await comfyExpect(promotedTextWidgets).toHaveCount(2)
|
|
})
|
|
|
|
test(
|
|
'Cloning a subgraph node preserves edited promoted widget values on original and clone',
|
|
{ tag: ['@vue-nodes'] },
|
|
async ({ comfyPage }) => {
|
|
await comfyPage.workflow.loadWorkflow(
|
|
'subgraphs/subgraph-with-promoted-text-widget'
|
|
)
|
|
|
|
const editedValue = 'Edited prompt that must survive cloning'
|
|
const originalTextbox = comfyPage.vueNodes
|
|
.getNodeLocator('11')
|
|
.getByRole('textbox', { name: 'text' })
|
|
await expect(originalTextbox).toBeVisible()
|
|
await expect(originalTextbox).toHaveValue('')
|
|
await originalTextbox.fill(editedValue)
|
|
|
|
const originalNode = await comfyPage.nodeOps.getNodeRefById('11')
|
|
await originalNode.click('title')
|
|
await comfyPage.clipboard.copy()
|
|
await comfyPage.clipboard.paste()
|
|
|
|
async function collectSubgraphNodeIds() {
|
|
return comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph!
|
|
return graph.nodes
|
|
.filter(
|
|
(n) =>
|
|
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
|
)
|
|
.map((n) => String(n.id))
|
|
})
|
|
}
|
|
|
|
await expect
|
|
.poll(async () => (await collectSubgraphNodeIds()).length)
|
|
.toBeGreaterThan(1)
|
|
|
|
const subgraphNodeIds = await collectSubgraphNodeIds()
|
|
for (const nodeId of subgraphNodeIds) {
|
|
const textbox = comfyPage.vueNodes
|
|
.getNodeLocator(nodeId)
|
|
.getByRole('textbox', { name: 'text' })
|
|
await expect(
|
|
textbox,
|
|
`node ${nodeId} promoted text widget reset to default after clone`
|
|
).toHaveValue(editedValue)
|
|
}
|
|
}
|
|
)
|
|
})
|
|
|
|
test.describe('Duplicate ID Remapping', () => {
|
|
test('All node IDs are globally unique after loading', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const result = await comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph!
|
|
const allGraphs = [graph, ...graph.subgraphs.values()]
|
|
const allIds = allGraphs
|
|
.flatMap((g) => g._nodes)
|
|
.map((n) => n.id)
|
|
.filter((id): id is number => typeof id === 'number')
|
|
|
|
return { allIds, uniqueCount: new Set(allIds).size }
|
|
})
|
|
|
|
expect(result.uniqueCount).toBe(result.allIds.length)
|
|
expect(result.allIds.length).toBeGreaterThanOrEqual(10)
|
|
})
|
|
|
|
test('Root graph node IDs are preserved as canonical', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const rootIds = await comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph!
|
|
return graph._nodes
|
|
.map((n) => n.id)
|
|
.filter((id): id is number => typeof id === 'number')
|
|
.sort((a, b) => a - b)
|
|
})
|
|
|
|
expect(rootIds).toEqual([1, 2, 5])
|
|
})
|
|
|
|
test('Promoted widget tuples are stable after full page reload boot path', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const beforeSnapshot =
|
|
await comfyPage.subgraph.getHostPromotedTupleSnapshot()
|
|
expect(beforeSnapshot.length).toBeGreaterThan(0)
|
|
expect(
|
|
beforeSnapshot.some(({ promotedWidgets }) => promotedWidgets.length > 0)
|
|
).toBe(true)
|
|
|
|
await comfyPage.page.reload()
|
|
await comfyPage.page.waitForFunction(() => !!window.app)
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
await expect
|
|
.poll(() => comfyPage.subgraph.getHostPromotedTupleSnapshot(), {
|
|
timeout: 5_000
|
|
})
|
|
.toEqual(beforeSnapshot)
|
|
})
|
|
|
|
test('All links reference valid nodes in their graph', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const invalidLinks = await comfyPage.page.evaluate(() => {
|
|
const graph = window.app!.canvas.graph!
|
|
const labeledGraphs: [string, typeof graph][] = [
|
|
['root', graph],
|
|
...[...graph.subgraphs.entries()].map(
|
|
([id, sg]) => [`subgraph:${id}`, sg] as [string, typeof graph]
|
|
)
|
|
]
|
|
|
|
const SENTINEL_IDS = new Set([-1, -10, -20])
|
|
const isSentinelNodeId = (id: number | string): id is number =>
|
|
typeof id === 'number' && SENTINEL_IDS.has(id)
|
|
|
|
const checkEndpoint = (
|
|
label: string,
|
|
kind: 'origin_id' | 'target_id',
|
|
id: number | string,
|
|
g: typeof graph
|
|
): string | null => {
|
|
if (isSentinelNodeId(id)) return null
|
|
if (typeof id !== 'number' || !g._nodes_by_id[id]) {
|
|
return `${label}: ${kind} ${id} invalid or not found`
|
|
}
|
|
return null
|
|
}
|
|
|
|
return labeledGraphs.flatMap(([label, g]) =>
|
|
[...g._links.values()].flatMap((link) =>
|
|
[
|
|
checkEndpoint(label, 'origin_id', link.origin_id, g),
|
|
checkEndpoint(label, 'target_id', link.target_id, g)
|
|
].filter((e): e is string => e !== null)
|
|
)
|
|
)
|
|
})
|
|
|
|
expect(invalidLinks).toEqual([])
|
|
})
|
|
|
|
test('Subgraph navigation works after ID remapping', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(DUPLICATE_IDS_WORKFLOW)
|
|
|
|
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('5')
|
|
await subgraphNode.navigateIntoSubgraph()
|
|
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(true)
|
|
|
|
await comfyPage.keyboard.press('Escape')
|
|
|
|
await expect.poll(() => comfyPage.subgraph.isInSubgraph()).toBe(false)
|
|
})
|
|
})
|
|
|
|
test.describe(
|
|
'Legacy Prefixed proxyWidget Normalization',
|
|
{ tag: ['@subgraph', '@widget', '@vue-nodes'] },
|
|
() => {
|
|
test('Loads without console warnings about failed widget resolution', async ({
|
|
comfyPage
|
|
}) => {
|
|
const { warnings, dispose } = SubgraphHelper.collectConsoleWarnings(
|
|
comfyPage.page
|
|
)
|
|
|
|
try {
|
|
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
|
|
|
comfyExpect(warnings).toEqual([])
|
|
} finally {
|
|
dispose()
|
|
}
|
|
})
|
|
|
|
test('Legacy-prefixed promoted widget renders with the normalized label after load', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
|
|
|
const outerNode = comfyPage.vueNodes.getNodeLocator('5')
|
|
await expect(outerNode).toBeVisible()
|
|
|
|
const textarea = outerNode
|
|
.getByRole('textbox', { name: 'string_a' })
|
|
.first()
|
|
await expect(textarea).toBeVisible()
|
|
await expect(textarea).toBeDisabled()
|
|
})
|
|
|
|
test('No legacy-prefixed or disconnected widgets remain on the node', async ({
|
|
comfyPage
|
|
}) => {
|
|
await comfyPage.workflow.loadWorkflow(LEGACY_PREFIXED_WORKFLOW)
|
|
|
|
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:')
|
|
})
|
|
}
|
|
)
|
|
})
|