feat: synthetic widgets getter for SubgraphNode (proxy-widget-v2) (#8856)

## Summary

Replace the Proxy-based proxy widget system with a store-driven
architecture where `promotionStore` and `widgetValueStore` are the
single sources of truth for subgraph widget promotion and widget values,
and `SubgraphNode.widgets` is a synthetic getter composing lightweight
`PromotedWidgetView` objects from store state.

## Motivation

The subgraph widget promotion system previously scattered state across
multiple unsynchronized layers:

- **Persistence**: `node.properties.proxyWidgets` (tuples on the
LiteGraph node)
- **Runtime**: Proxy-based `proxyWidget.ts` with `Overlay` objects,
`DisconnectedWidget` singleton, and `isProxyWidget` type guards
- **UI**: Each Vue component independently calling `parseProxyWidgets()`
via `customRef` hacks
- **Mutation flags**: Imperative `widget.promoted = true/false` set on
`subgraph-opened` events

This led to 4+ independent parsings of the same data, complex cache
invalidation, and no reactive contract between the promotion state and
the rendering layer. Widget values were similarly owned by LiteGraph
with no Vue-reactive backing.

The core principle driving these changes: **Vue owns truth**. Pinia
stores are the canonical source; LiteGraph objects delegate to stores
via getters/setters; Vue components react to store state directly.

## Changes

### New stores (single sources of truth)

- **`promotionStore`** — Reactive `Map<NodeId, PromotionEntry[]>`
tracking which interior widgets are promoted on which SubgraphNode
instances. Graph-scoped by root graph ID to prevent cross-workflow state
collision. Replaces `properties.proxyWidgets` parsing, `customRef`
hacks, `widget.promoted` mutation, and the `subgraph-opened` event
listener.
- **`widgetValueStore`** — Graph-scoped `Map<WidgetKey, WidgetState>`
that is the canonical owner of widget values. `BaseWidget.value`
delegates to this store via getter/setter when a node ID is assigned.
Eliminates the need for Proxy-based value forwarding.

### Synthetic widgets getter (SubgraphNode)

`SubgraphNode.widgets` is now a getter that reads
`promotionStore.getPromotions(rootGraphId, nodeId)` and returns cached
`PromotedWidgetView` objects. No stubs, no Proxies, no fake widgets
persisted in the array. The setter is a no-op — mutations go through
`promotionStore`.

### PromotedWidgetView

A class behind a `createPromotedWidgetView` factory, implementing the
`PromotedWidgetView` interface. Delegates value/type/options/drawing to
the resolved interior widget and stores. Owns positional state (`y`,
`computedHeight`) for canvas layout. Cached by
`PromotedWidgetViewManager` for object-identity stability across frames.

### DOM widget promotion

Promoted DOM widgets (textarea, image upload, etc.) render on the
SubgraphNode surface via `positionOverride` in `domWidgetStore`.
`DomWidgets.vue` checks for overrides and uses the SubgraphNode's
coordinates instead of the interior node's.

### Promoted previews

New `usePromotedPreviews` composable resolves image/audio/video preview
widgets from promoted entries, enabling SubgraphNodes to display
previews of interior preview nodes.

### Deleted

- `proxyWidget.ts` (257 lines) — Proxy handler, `Overlay`,
`newProxyWidget`, `isProxyWidget`
- `DisconnectedWidget.ts` (39 lines) — Singleton Proxy target
- `useValueTransform.ts` (32 lines) — Replaced by store delegation

### Key architectural changes

- `BaseWidget.value` getter/setter delegates to `widgetValueStore` when
node ID is set
- `LGraph.add()` reordered: `node.graph` assigned before widget
`setNodeId` (enables store registration)
- `LGraph.clear()` cleans up graph-scoped stores to prevent stale
entries across workflow switches
- `promotionStore` and `widgetValueStore` state nested under root graph
UUID for multi-workflow isolation
- `SubgraphNode.serialize()` writes promotions back to
`properties.proxyWidgets` for persistence compatibility
- Legacy `-1` promotion entries resolved and migrated on first load with
dev warning

## Test coverage

- **3,700+ lines of new/updated tests** across 36 test files
- **Unit**: `promotionStore.test.ts`, `widgetValueStore.test.ts`,
`promotedWidgetView.test.ts` (921 lines),
`subgraphNodePromotion.test.ts`, `proxyWidgetUtils.test.ts`,
`DomWidgets.test.ts`, `PromotedWidgetViewManager.test.ts`,
`usePromotedPreviews.test.ts`, `resolvePromotedWidget.test.ts`,
`subgraphPseudoWidgetCache.test.ts`
- **E2E**: `subgraphPromotion.spec.ts` (622 lines) — promote/demote,
manual/auto promotion, paste preservation, seed control augmentation,
image preview promotion; `imagePreview.spec.ts` extended with
multi-promoted-preview coverage
- **Fixtures**: 2 new subgraph workflow fixtures for preview promotion
scenarios

## Review focus

- Graph-scoped store keying (`rootGraphId`) — verify isolation across
workflows/tabs and cleanup on `LGraph.clear()`
- `PromotedWidgetView` positional stability — `_arrangeWidgets` writes
to `y`/`computedHeight` on cached objects; getter returns fresh array
but stable object references
- DOM widget position override lifecycle — overrides set on promote,
cleared on demote/removal/subgraph navigation
- Legacy `-1` entry migration — resolved and written back on first load;
unresolvable entries dropped with dev warning
- Serialization round-trip — `promotionStore` state →
`properties.proxyWidgets` on serialize, hydrated back on configure

## Diff breakdown (excluding lockfile)

- 153 files changed, ~7,500 insertions, ~1,900 deletions (excluding
pnpm-lock.yaml churn)
- ~3,700 lines are tests
- ~300 lines deleted (proxyWidget.ts, DisconnectedWidget.ts,
useValueTransform.ts)

<!-- Fixes #ISSUE_NUMBER -->

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8856-feat-synthetic-widgets-getter-for-SubgraphNode-proxy-widget-v2-3076d73d365081c7b517f5ec7cb514f3)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Alexander Brown
2026-02-23 13:33:41 -08:00
committed by GitHub
parent d7546e68ef
commit c25f9a0e93
128 changed files with 7295 additions and 1931 deletions

View File

@@ -0,0 +1,394 @@
{
"id": "43e9499c-2512-43b5-a5a1-2485eb65da32",
"revision": 0,
"last_node_id": 8,
"last_link_id": 10,
"nodes": [
{
"id": 1,
"type": "LoadImage",
"pos": [170.55728894250745, 515.6401487466619],
"size": [282.8166809082031, 363.8333435058594],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [7, 9]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
},
{
"id": 7,
"type": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
"pos": [500.2639113468392, 519.9960755960157],
"size": [464.95001220703125, 615.8333129882812],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 7
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [10]
}
],
"properties": {
"proxyWidgets": [
["2", "$$canvas-image-preview"],
["4", "$$canvas-image-preview"]
]
},
"widgets_values": []
},
{
"id": 8,
"type": "a7a0350a-af99-4d26-9391-450b4f726206",
"pos": [1000.5293620197185, 499.9253405678786],
"size": [225, 359.8333435058594],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "image1",
"type": "IMAGE",
"link": 9
},
{
"name": "image2",
"type": "IMAGE",
"link": 10
}
],
"outputs": [],
"properties": {
"proxyWidgets": [["6", "$$canvas-image-preview"]]
},
"widgets_values": []
}
],
"links": [
[7, 1, 0, 7, 0, "IMAGE"],
[9, 1, 0, 8, 0, "IMAGE"],
[10, 7, 0, 8, 1, "IMAGE"]
],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "21dea088-e1b4-47a4-a01f-3d1bf4504001",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [297.7833638107301, 502.6302057820892, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [1052.8175480718996, 502.6302057820892, 120, 60]
},
"inputs": [
{
"id": "afc8dbc3-12e6-4b3c-9840-9b398d06e6bd",
"name": "images",
"type": "IMAGE",
"linkIds": [1, 2],
"localized_name": "images",
"pos": [397.7833638107301, 522.6302057820892]
}
],
"outputs": [
{
"id": "d0a84974-5f4d-4f4b-b23a-2e7288a9689d",
"name": "IMAGE",
"type": "IMAGE",
"linkIds": [5],
"localized_name": "IMAGE",
"pos": [1072.8175480718996, 522.6302057820892]
}
],
"widgets": [],
"nodes": [
{
"id": 4,
"type": "PreviewImage",
"pos": [767.8225773415076, 602.8695134060456],
"size": [225, 303.8333435058594],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 3
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 2,
"type": "PreviewImage",
"pos": [754.9358989867657, 188.55375831225257],
"size": [225, 303.8333435058594],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
},
{
"id": 3,
"type": "ImageInvert",
"pos": [477.783932416778, 542.2440719627998],
"size": [225, 71.83333587646484],
"flags": {
"collapsed": false
},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "image",
"name": "image",
"type": "IMAGE",
"link": 2
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [3, 5]
}
],
"properties": {
"Node name for S&R": "ImageInvert"
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 3,
"origin_id": 3,
"origin_slot": 0,
"target_id": 4,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 2,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 2,
"origin_id": -10,
"origin_slot": 0,
"target_id": 3,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 5,
"origin_id": 3,
"origin_slot": 0,
"target_id": -20,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {}
},
{
"id": "a7a0350a-af99-4d26-9391-450b4f726206",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 8,
"lastLinkId": 10,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [973.7423316105073, 561.9744246746379, 120, 80]
},
"outputNode": {
"id": -20,
"bounding": [1905.487372786412, 581.9744246746379, 120, 40]
},
"inputs": [
{
"id": "20ac4159-6814-4d40-a217-ea260152b689",
"name": "image1",
"type": "IMAGE",
"linkIds": [4],
"localized_name": "image1",
"pos": [1073.7423316105073, 581.9744246746379]
},
{
"id": "c3759a8c-914e-4450-bc41-ca683ffce96b",
"name": "image2",
"type": "IMAGE",
"linkIds": [8],
"localized_name": "image2",
"shape": 7,
"pos": [1073.7423316105073, 601.9744246746379]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 5,
"type": "ImageStitch",
"pos": [1153.7423085222254, 396.2033931749105],
"size": [270, 225.1666717529297],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "image1",
"name": "image1",
"type": "IMAGE",
"link": 4
},
{
"localized_name": "image2",
"name": "image2",
"shape": 7,
"type": "IMAGE",
"link": 8
}
],
"outputs": [
{
"localized_name": "IMAGE",
"name": "IMAGE",
"type": "IMAGE",
"links": [6]
}
],
"properties": {
"Node name for S&R": "ImageStitch"
},
"widgets_values": ["right", true, 0, "white"]
},
{
"id": 6,
"type": "PreviewImage",
"pos": [1620.4874189629757, 529.9122050216333],
"size": [225, 307.8333435058594],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 6
}
],
"outputs": [],
"properties": {
"Node name for S&R": "PreviewImage"
},
"widgets_values": []
}
],
"groups": [],
"links": [
{
"id": 6,
"origin_id": 5,
"origin_slot": 0,
"target_id": 6,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 4,
"origin_id": -10,
"origin_slot": 0,
"target_id": 5,
"target_slot": 0,
"type": "IMAGE"
},
{
"id": 8,
"origin_id": -10,
"origin_slot": 1,
"target_id": 5,
"target_slot": 1,
"type": "IMAGE"
}
],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 0.7269777827561446,
"offset": [-35.273237658266034, -55.17394203309256]
},
"frontendVersion": "1.40.8"
},
"version": 0.4
}

View File

@@ -0,0 +1,170 @@
{
"id": "preview-subgraph-test-001",
"revision": 0,
"last_node_id": 11,
"last_link_id": 2,
"nodes": [
{
"id": 5,
"type": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"pos": [318.6320139868054, 212.9091015141833],
"size": [225, 368],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 2
}
],
"outputs": [],
"properties": {
"proxyWidgets": [
["10", "filename_prefix"],
["10", "$$canvas-image-preview"]
],
"cnr_id": "comfy-core",
"ver": "0.13.0",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.6.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": []
},
{
"id": 11,
"type": "LoadImage",
"pos": [-0.5080003681592018, 211.3051121416672],
"size": [282.8333435058594, 364],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [2]
},
{
"name": "MASK",
"type": "MASK",
"links": null
}
],
"properties": {
"ue_properties": {
"widget_ue_connectable": {},
"input_ue_unconnectable": {}
},
"cnr_id": "comfy-core",
"ver": "0.13.0",
"Node name for S&R": "LoadImage"
},
"widgets_values": ["example.png", "image"]
}
],
"links": [[2, 11, 0, 5, 0, "IMAGE"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 11,
"lastLinkId": 2,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "New Subgraph",
"inputNode": {
"id": -10,
"bounding": [300, 350, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [900, 350, 120, 40]
},
"inputs": [
{
"id": "img-slot-001",
"name": "images",
"type": "IMAGE",
"linkIds": [1],
"pos": [400, 370]
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 10,
"type": "SaveImage",
"pos": [500.0046924937855, 300.0146992076527],
"size": [315, 340],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "images",
"name": "images",
"type": "IMAGE",
"link": 1
}
],
"outputs": [],
"properties": {
"cnr_id": "comfy-core",
"ver": "0.13.0",
"Node name for S&R": "SaveImage",
"ue_properties": {
"widget_ue_connectable": {},
"version": "7.6.2",
"input_ue_unconnectable": {}
}
},
"widgets_values": ["ComfyUI"]
}
],
"groups": [],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 10,
"target_slot": 0,
"type": "IMAGE"
}
],
"extra": {
"ue_links": [],
"links_added_by_ue": []
}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1.1819400303977265,
"offset": [81.66005130613983, -19.028558221588725]
},
"frontendVersion": "1.40.3",
"ue_links": [],
"links_added_by_ue": [],
"VHS_latentpreview": false,
"VHS_latentpreviewrate": 0,
"VHS_MetadataImage": true,
"VHS_KeepIntermediate": true
},
"version": 0.4
}

View File

@@ -11,7 +11,10 @@ export class CommandHelper {
): Promise<void> {
await this.page.evaluate(
({ commandId, metadata }) => {
return window['app'].extensionManager.command.execute(commandId, {
const app = window.app
if (!app) throw new Error('window.app is not available')
return app.extensionManager.command.execute(commandId, {
metadata
})
},

View File

@@ -115,6 +115,16 @@ export class DragDropHelper {
const dragOverEvent = new DragEvent('dragover', eventOptions)
const dropEvent = new DragEvent('drop', eventOptions)
const graphCanvasElement = document.querySelector('#graph-canvas')
// Keep Litegraph's drag-over node tracking in sync when the drop target is a
// Vue node DOM overlay outside of the graph canvas element.
if (graphCanvasElement && !graphCanvasElement.contains(targetElement)) {
graphCanvasElement.dispatchEvent(
new DragEvent('dragover', eventOptions)
)
}
Object.defineProperty(dropEvent, 'preventDefault', {
value: () => {},
writable: false

View File

@@ -33,6 +33,10 @@ export class NodeOperationsHelper {
})
}
async getNodeCount(): Promise<number> {
return await this.page.evaluate(() => window.app!.graph.nodes.length)
}
async getNodes(): Promise<LGraphNode[]> {
return await this.page.evaluate(() => {
return window.app!.graph.nodes

View File

@@ -36,7 +36,7 @@ export class SubgraphHelper {
const currentGraph = app.canvas!.graph!
// Check if we're in a subgraph
if (currentGraph.constructor.name !== 'Subgraph') {
if (!('inputNode' in currentGraph)) {
throw new Error(
'Not in a subgraph - this method only works inside subgraphs'
)

View File

@@ -27,6 +27,7 @@ export const TestIds = {
settingsContainer: 'settings-container',
settingsTabAbout: 'settings-tab-about',
confirm: 'confirm-dialog',
missingNodes: 'missing-nodes-warning',
about: 'about-panel',
whatsNewSection: 'whats-new-section'
},
@@ -46,8 +47,12 @@ export const TestIds = {
widgets: {
decrement: 'decrement',
increment: 'increment',
domWidgetTextarea: 'dom-widget-textarea',
subgraphEnterButton: 'subgraph-enter-button'
},
breadcrumb: {
subgraph: 'subgraph-breadcrumb'
},
templates: {
content: 'template-workflows-content',
workflowCard: (id: string) => `template-workflow-${id}`
@@ -70,6 +75,7 @@ export type TestIdValue =
| (typeof TestIds.propertiesPanel)[keyof typeof TestIds.propertiesPanel]
| (typeof TestIds.node)[keyof typeof TestIds.node]
| (typeof TestIds.widgets)[keyof typeof TestIds.widgets]
| (typeof TestIds.breadcrumb)[keyof typeof TestIds.breadcrumb]
| Exclude<
(typeof TestIds.templates)[keyof typeof TestIds.templates],
(id: string) => string

View File

@@ -128,7 +128,8 @@ class NodeSlotReference {
nodeSize: [node.size[0], node.size[1]],
rawConnectionPos: [rawPos[0], rawPos[1]],
convertedPos: [convertedPos[0], convertedPos[1]],
currentGraphType: window.app!.canvas.graph!.constructor.name
currentGraphType:
'inputNode' in window.app!.canvas.graph! ? 'Subgraph' : 'LGraph'
}
)
@@ -461,18 +462,44 @@ export class NodeReference {
// Try multiple positions to avoid DOM widget interference
const clickPositions = [
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + titleHeight + 5 },
{ x: nodePos.x + nodeSize.width / 2, y: nodePos.y + nodeSize.height / 2 },
{
x: nodePos.x + nodeSize.width / 2,
y: nodePos.y + nodeSize.height / 2
},
{ x: nodePos.x + 20, y: nodePos.y + titleHeight + 5 }
]
// Click the enter_subgraph title button (top-right of title bar).
// This is more reliable than dblclick on the node body because
// promoted DOM widgets can overlay the body and intercept events.
const subgraphButtonPos = {
x: nodePos.x + nodeSize.width - 15,
y: nodePos.y - titleHeight / 2
}
const checkIsInSubgraph = async () => {
return this.comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
return !!graph && 'inputNode' in graph
})
}
await expect(async () => {
// Try just clicking the enter button first
await this.comfyPage.canvas.click({
position: { x: 250, y: 250 },
force: true
})
await this.comfyPage.nextFrame()
await this.comfyPage.canvas.click({
position: subgraphButtonPos,
force: true
})
await this.comfyPage.nextFrame()
if (await checkIsInSubgraph()) return
for (const position of clickPositions) {
// Clear any selection first
await this.comfyPage.canvas.click({

View File

@@ -1,9 +1,5 @@
import { test as base } from '@playwright/test'
interface TestWindow extends Window {
__ws__?: Record<string, WebSocket>
}
export const webSocketFixture = base.extend<{
ws: { trigger(data: unknown, url?: string): Promise<void> }
}>({

View File

@@ -0,0 +1,64 @@
import type { ComfyPage } from '../fixtures/ComfyPage'
export type PromotedWidgetEntry = [string, string]
export function isPromotedWidgetEntry(
entry: unknown
): entry is PromotedWidgetEntry {
return (
Array.isArray(entry) &&
entry.length === 2 &&
typeof entry[0] === 'string' &&
typeof entry[1] === 'string'
)
}
export 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 raw = await comfyPage.page.evaluate((id) => {
const node = window.app!.canvas.graph!.getNodeById(id)
return node?.properties?.proxyWidgets ?? []
}, nodeId)
return normalizePromotedWidgets(raw)
}
export async function getPromotedWidgetNames(
comfyPage: ComfyPage,
nodeId: string
): Promise<string[]> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.map(([, widgetName]) => widgetName)
}
export async function getPromotedWidgetCount(
comfyPage: ComfyPage,
nodeId: string
): Promise<number> {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
return promotedWidgets.length
}
export async function getPromotedWidgetCountByName(
comfyPage: ComfyPage,
nodeId: string,
widgetName: string
): Promise<number> {
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
)
}

View File

@@ -14,7 +14,9 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
return (window.app!.extensionManager as WorkspaceStore).workflow
.activeWorkflow?.filename
})
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
await expect
.poll(() => comfyPage.page.title())
.toBe(`*${workflowName} - ComfyUI`)
})
// Failing on CI
@@ -51,7 +53,7 @@ test.describe('Browser tab title', { tag: '@smoke' }, () => {
})
test('Can display default title', async ({ comfyPage }) => {
expect(await comfyPage.page.title()).toBe('ComfyUI')
await expect.poll(() => comfyPage.page.title()).toBe('ComfyUI')
})
})
})

View File

@@ -160,12 +160,12 @@ test.describe('Change Tracker', { tag: '@workflow' }, () => {
})
// Click empty space to trigger a change detection.
await comfyPage.canvasOps.clickEmptySpace()
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(1)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await comfyPage.canvasOps.pan({ x: 10, y: 10 })
expect(await comfyPage.workflow.getUndoQueueSize()).toBe(0)
await expect.poll(() => comfyPage.workflow.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -245,11 +245,18 @@ test.describe(
await comfyPage.settings.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
const parsed = await comfyPage.page.evaluate(() => {
return window['app'].graph.serialize()
const graph = window.app!.graph!
if (typeof graph.serialize !== 'function') {
throw new Error('app.graph.serialize is not available')
}
return graph.serialize() as {
nodes: Array<{ bgcolor?: string; color?: string }>
}
})
expect(parsed.nodes).toBeDefined()
expect(Array.isArray(parsed.nodes)).toBe(true)
for (const node of parsed.nodes) {
const nodes = parsed.nodes
for (const node of nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}

View File

@@ -4,6 +4,7 @@ import { expect } from '@playwright/test'
import type { Keybinding } from '../../src/platform/keybindings/types'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { DefaultGraphPositions } from '../fixtures/constants/defaultGraphPositions'
import { TestIds } from '../fixtures/selectors'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
@@ -15,8 +16,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await expect(missingNodesWarning).toBeVisible()
})
@@ -25,8 +27,9 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
}) => {
await comfyPage.workflow.loadWorkflow('missing/missing_nodes_in_subgraph')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await expect(missingNodesWarning).toBeVisible()
// Verify the missing node text includes subgraph context
@@ -38,13 +41,14 @@ test.describe('Load workflow warning', { tag: '@ui' }, () => {
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
const missingNodesWarning = comfyPage.page.getByTestId(
TestIds.dialogs.missingNodes
)
await comfyPage.workflow.loadWorkflow('missing/missing_nodes')
await comfyPage.page
.locator('.p-dialog')
.getByRole('button', { name: 'Close' })
.click({ force: true })
await comfyPage.page.locator('.p-dialog').waitFor({ state: 'hidden' })
await expect(missingNodesWarning).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(missingNodesWarning).not.toBeVisible()
// Wait for any async operations to complete after dialog closes
await comfyPage.nextFrame()
@@ -55,9 +59,14 @@ test('Does not report warning on undo/redo', async ({ comfyPage }) => {
// Undo and redo the change
await comfyPage.keyboard.undo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await expect(async () => {
await expect(missingNodesWarning).not.toBeVisible()
}).toPass({ timeout: 5000 })
await comfyPage.keyboard.redo()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await expect(async () => {
await expect(missingNodesWarning).not.toBeVisible()
}).toPass({ timeout: 5000 })
})
test.describe('Execution error', () => {
@@ -401,7 +410,7 @@ test.describe('Signin dialog', () => {
test('Paste content to signin dialog should not paste node on canvas', async ({
comfyPage
}) => {
const nodeNum = (await comfyPage.nodeOps.getNodes()).length
const nodeNum = await comfyPage.nodeOps.getNodeCount()
await comfyPage.canvas.click({
position: DefaultGraphPositions.emptyLatentWidgetClick
})
@@ -424,6 +433,6 @@ test.describe('Signin dialog', () => {
await input.press('Control+v')
await expect(input).toHaveValue('test_password')
expect(await comfyPage.nodeOps.getNodes()).toHaveLength(nodeNum)
expect(await comfyPage.nodeOps.getNodeCount()).toBe(nodeNum)
})
})

View File

@@ -203,19 +203,19 @@ test.describe('Menu', { tag: '@ui' }, () => {
await topbar.switchTheme('light')
// Verify menu stays open and Light theme shows as active
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
// Check that Light theme is active
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeSubmenu).toBeVisible()
expect(await topbar.isMenuItemActive(lightThemeItem)).toBe(true)
}).toPass({ timeout: 5000 })
// Screenshot with light theme active
await comfyPage.attachScreenshot('theme-menu-light-active')
// Verify ColorPalette setting is set to "light"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'light'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
@@ -228,20 +228,22 @@ test.describe('Menu', { tag: '@ui' }, () => {
await topbar.switchTheme('dark')
// Verify menu stays open and Dark theme shows as active
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
// Check that Dark theme is active and Light theme is not
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(false)
await expect(async () => {
await expect(menu).toBeVisible()
await expect(themeItems2.submenu).toBeVisible()
expect(await topbar.isMenuItemActive(themeItems2.darkTheme)).toBe(true)
expect(await topbar.isMenuItemActive(themeItems2.lightTheme)).toBe(
false
)
}).toPass({ timeout: 5000 })
// Screenshot with dark theme active
await comfyPage.attachScreenshot('theme-menu-dark-active')
// Verify ColorPalette setting is set to "dark"
expect(await comfyPage.settings.getSetting('Comfy.ColorPalette')).toBe(
'dark'
)
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('dark')
// Close menu
await topbar.closeTopbarMenu()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -83,17 +83,15 @@ test.describe('Workflows sidebar', () => {
const tab = comfyPage.menu.workflowsTab
await tab.open()
await comfyPage.command.executeCommand('Comfy.LoadDefaultWorkflow')
const originalNodeCount = (await comfyPage.nodeOps.getNodes()).length
const originalNodeCount = await comfyPage.nodeOps.getNodeCount()
await tab.insertWorkflow(tab.getPersistedItem('workflow1.json'))
await expect
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
.poll(() => comfyPage.nodeOps.getNodeCount())
.toEqual(originalNodeCount + 1)
await tab.getPersistedItem('workflow1.json').click()
await expect
.poll(() => comfyPage.nodeOps.getNodes().then((n) => n.length))
.toEqual(1)
await expect.poll(() => comfyPage.nodeOps.getNodeCount()).toEqual(1)
})
test('Can rename nested workflow from opened workflow item', async ({

View File

@@ -53,7 +53,7 @@ test.describe('Subgraph Operations', { tag: ['@slow', '@subgraph'] }, () => {
): Promise<boolean> {
return await comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return graph?.constructor?.name === 'Subgraph'
return !!graph && 'inputNode' in graph
})
}

View File

@@ -0,0 +1,622 @@
import { expect } from '@playwright/test'
import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { TestIds } from '../fixtures/selectors'
import { fitToViewInstant } from '../helpers/fitToView'
import {
getPromotedWidgetNames,
getPromotedWidgetCount,
getPromotedWidgets
} from '../helpers/promotedWidgets'
/**
* Check whether we're currently in a subgraph.
*/
async function isInSubgraph(comfyPage: ComfyPage): Promise<boolean> {
return comfyPage.page.evaluate(() => {
const graph = window.app!.canvas.graph
return !!graph && 'inputNode' in graph
})
}
async function exitSubgraphViaBreadcrumb(comfyPage: ComfyPage): Promise<void> {
const breadcrumb = comfyPage.page.getByTestId(TestIds.breadcrumb.subgraph)
await breadcrumb.waitFor({ state: 'visible', timeout: 5000 })
const parentLink = breadcrumb.getByRole('link').first()
await expect(parentLink).toBeVisible()
await parentLink.click()
await comfyPage.nextFrame()
}
test.describe(
'Subgraph Widget Promotion',
{ tag: ['@subgraph', '@widget'] },
() => {
test.describe('Auto-promotion on Convert to Subgraph', () => {
test('Recommended widgets are auto-promoted when creating a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
// Select just the KSampler node (id 3) which has a "seed" widget
const ksampler = await comfyPage.nodeOps.getNodeRefById('3')
await ksampler.click('title')
const subgraphNode = await ksampler.convertToSubgraph()
await comfyPage.nextFrame()
// SubgraphNode should exist
expect(await subgraphNode.exists()).toBe(true)
// The KSampler has a "seed" widget which is in the recommended list.
// The promotion store should have at least the seed widget promoted.
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames).toContain('seed')
// SubgraphNode should have widgets (promoted views)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
test('CLIPTextEncode text widget is auto-promoted', async ({
comfyPage
}) => {
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)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
// CLIPTextEncode is in the recommendedNodes list, so its text widget
// should be promoted
expect(promotedNames).toContain('text')
})
test('SaveImage/PreviewImage nodes get pseudo-widget promoted', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select the SaveImage node (id 9 in default workflow)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
const promotedNames = await getPromotedWidgetNames(
comfyPage,
String(subgraphNode.id)
)
// SaveImage is in the recommendedNodes list, so filename_prefix is promoted
expect(promotedNames).toContain('filename_prefix')
})
})
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'
)
await comfyPage.nextFrame()
// The subgraph node (id 11) should have a text widget promoted
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textarea).toBeVisible()
await expect(textarea).toHaveCount(1)
})
test('Multiple promoted widgets all render on SubgraphNode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.nextFrame()
const textareas = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(textareas.first()).toBeVisible()
const count = await textareas.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Visibility in Vue Mode', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('Promoted text widget renders on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
// SubgraphNode (id 11) should render with its body
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
// It should have the Enter Subgraph button
const enterButton = subgraphVueNode.getByTestId('subgraph-enter-button')
await expect(enterButton).toBeVisible()
// The promoted text widget should render inside the node
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
await expect(nodeBody).toBeVisible()
// Widgets section should exist and have at least one widget
const widgets = nodeBody.locator('.lg-node-widgets > div')
await expect(widgets.first()).toBeVisible()
})
test('Enter Subgraph button navigates into subgraph in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.enterSubgraph('11')
await comfyPage.nextFrame()
expect(await isInSubgraph(comfyPage)).toBe(true)
})
test('Multiple promoted widgets render on SubgraphNode in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-widgets'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('11')
await expect(subgraphVueNode).toBeVisible()
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-11"]')
const widgets = nodeBody.locator('.lg-node-widgets > div')
const count = await widgets.count()
expect(count).toBeGreaterThan(1)
})
})
test.describe('Promoted Widget Reactivity', () => {
test('Value changes on promoted widget sync to interior widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'promoted-value-sync-test'
// Type into the promoted textarea on the SubgraphNode
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Interior CLIPTextEncode textarea should have the same value
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
})
test('Value changes on interior widget sync to promoted widget', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'interior-value-sync-test'
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Type into the interior CLIPTextEncode textarea
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await interiorTextarea.fill(testContent)
await comfyPage.nextFrame()
// Navigate back to parent graph
await exitSubgraphViaBreadcrumb(comfyPage)
// Promoted textarea on SubgraphNode should have the same value
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
})
test('Value persists through repeated navigation', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
const testContent = 'persistence-through-navigation'
// Set value on promoted widget
const textarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await textarea.fill(testContent)
// Navigate in and out multiple times
for (let i = 0; i < 3; i++) {
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
const interiorTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(interiorTextarea).toHaveValue(testContent)
await exitSubgraphViaBreadcrumb(comfyPage)
const promotedTextarea = comfyPage.page.getByTestId(
TestIds.widgets.domWidgetTextarea
)
await expect(promotedTextarea).toHaveValue(testContent)
}
})
})
test.describe('Manual Promote/Demote via Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can promote a widget from inside a subgraph', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode.navigateIntoSubgraph()
// Get the KSampler node (id 1) inside the subgraph
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
// Right-click on the KSampler's "steps" widget (index 2) to promote it
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
// Look for the Promote Widget menu entry
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should now have the promoted widget
const widgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(widgetCount).toBeGreaterThan(0)
})
test('Can un-promote a widget from inside a subgraph', async ({
comfyPage
}) => {
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()
const ksampler = await comfyPage.nodeOps.getNodeRefById('1')
const stepsWidget = await ksampler.getWidget(2)
const widgetPos = await stepsWidget.getPosition()
await comfyPage.canvas.click({
position: widgetPos,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const promoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Promote Widget/ })
await expect(promoteEntry).toBeVisible()
await promoteEntry.click()
await comfyPage.nextFrame()
// Navigate back and verify promotion took effect
await exitSubgraphViaBreadcrumb(comfyPage)
await fitToViewInstant(comfyPage)
await comfyPage.nextFrame()
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate back in and un-promote
const subgraphNode2 = await comfyPage.nodeOps.getNodeRefById('2')
await subgraphNode2.navigateIntoSubgraph()
const stepsWidget2 = await (
await comfyPage.nodeOps.getNodeRefById('1')
).getWidget(2)
const widgetPos2 = await stepsWidget2.getPosition()
await comfyPage.canvas.click({
position: widgetPos2,
button: 'right',
force: true
})
await comfyPage.nextFrame()
const unpromoteEntry = comfyPage.page
.locator('.litemenu-entry')
.filter({ hasText: /Un-Promote Widget/ })
await expect(unpromoteEntry).toBeVisible()
await unpromoteEntry.click()
await comfyPage.nextFrame()
// Navigate back to parent
await exitSubgraphViaBreadcrumb(comfyPage)
// SubgraphNode should have fewer widgets
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '2')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
test.describe('Pseudo-Widget Promotion', () => {
test('Promotion store tracks pseudo-widget entries for subgraph with preview node', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.nextFrame()
// The SaveImage node is in the recommendedNodes list, so its
// filename_prefix widget should be auto-promoted
const promotedNames = await getPromotedWidgetNames(comfyPage, '5')
expect(promotedNames.length).toBeGreaterThan(0)
expect(promotedNames).toContain('filename_prefix')
})
test('Converting SaveImage to subgraph promotes its widgets', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('default')
await fitToViewInstant(comfyPage)
// Select SaveImage (id 9)
const saveNode = await comfyPage.nodeOps.getNodeRefById('9')
await saveNode.click('title')
const subgraphNode = await saveNode.convertToSubgraph()
await comfyPage.nextFrame()
// SaveImage is a recommended node, so filename_prefix should be promoted
const nodeId = String(subgraphNode.id)
const promotedNames = await getPromotedWidgetNames(comfyPage, nodeId)
expect(promotedNames.length).toBeGreaterThan(0)
const widgetCount = await getPromotedWidgetCount(comfyPage, nodeId)
expect(widgetCount).toBeGreaterThan(0)
})
})
test.describe('Legacy And Round-Trip Coverage', () => {
test('Legacy -1 proxyWidgets entries are hydrated to concrete interior node IDs', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-compressed-target-slot'
)
await comfyPage.nextFrame()
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'
)
await comfyPage.nextFrame()
const beforePromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(beforePromoted).toContain('text')
const serialized = await comfyPage.page.evaluate(() => {
return window.app!.graph!.serialize()
})
await comfyPage.page.evaluate((workflow: ComfyWorkflowJSON) => {
return window.app!.loadGraphData(workflow)
}, serialized as ComfyWorkflowJSON)
await comfyPage.nextFrame()
const afterPromoted = await getPromotedWidgetNames(comfyPage, '11')
expect(afterPromoted).toContain('text')
const widgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(widgetCount).toBeGreaterThan(0)
})
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'
)
await comfyPage.nextFrame()
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')
await comfyPage.page.mouse.down()
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(originalPos.x + 72, originalPos.y + 72)
await comfyPage.page.mouse.up()
await comfyPage.page.keyboard.up('Alt')
await comfyPage.nextFrame()
const subgraphNodeIds = await 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))
})
expect(subgraphNodeIds.length).toBeGreaterThan(1)
for (const nodeId of subgraphNodeIds) {
const promotedWidgets = await getPromotedWidgets(comfyPage, nodeId)
expect(promotedWidgets.length).toBeGreaterThan(0)
expect(
promotedWidgets.some(([, widgetName]) => widgetName === 'text')
).toBe(true)
}
})
})
test.describe('Vue Mode - Promoted Preview Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
})
test('SubgraphNode with preview node shows hasCustomContent area in Vue mode', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-preview-node'
)
await comfyPage.vueNodes.waitForNodes()
const subgraphVueNode = comfyPage.vueNodes.getNodeLocator('5')
await expect(subgraphVueNode).toBeVisible()
// The node body should exist
const nodeBody = subgraphVueNode.locator('[data-testid="node-body-5"]')
await expect(nodeBody).toBeVisible()
})
})
test.describe('Promotion Cleanup', () => {
test('Removing subgraph node clears promotion store entries', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
await comfyPage.nextFrame()
// Verify promotions exist
const namesBefore = await getPromotedWidgetNames(comfyPage, '11')
expect(namesBefore.length).toBeGreaterThan(0)
// Delete the subgraph node
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.click('title')
await comfyPage.page.keyboard.press('Delete')
await comfyPage.nextFrame()
// Node no longer exists, so promoted widgets should be gone
const nodeExists = await comfyPage.page.evaluate(() => {
return !!window.app!.canvas.graph!.getNodeById('11')
})
expect(nodeExists).toBe(false)
})
test('Removing I/O slot removes associated promoted widget', async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-promoted-text-widget'
)
const initialWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(initialWidgetCount).toBeGreaterThan(0)
// Navigate into subgraph
const subgraphNode = await comfyPage.nodeOps.getNodeRefById('11')
await subgraphNode.navigateIntoSubgraph()
// Remove the text input slot
await comfyPage.subgraph.rightClickInputSlot('text')
await comfyPage.contextMenu.clickLitegraphMenuItem('Remove Slot')
await comfyPage.nextFrame()
// Navigate back via breadcrumb
await comfyPage.page
.getByTestId(TestIds.breadcrumb.subgraph)
.waitFor({ state: 'visible', timeout: 5000 })
const homeBreadcrumb = comfyPage.page.getByRole('link', {
name: 'subgraph-with-promoted-text-widget'
})
await homeBreadcrumb.waitFor({ state: 'visible' })
await homeBreadcrumb.click()
await comfyPage.nextFrame()
// Widget count should be reduced
const finalWidgetCount = await getPromotedWidgetCount(comfyPage, '11')
expect(finalWidgetCount).toBeLessThan(initialWidgetCount)
})
})
}
)

View File

@@ -2,15 +2,20 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../../../../fixtures/ComfyPage'
import {
getPromotedWidgetNames,
getPromotedWidgetCountByName
} from '../../../../helpers/promotedWidgets'
test.describe('Vue Nodes Image Preview', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
})
async function loadImageOnNode(comfyPage: ComfyPage) {
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes()
const loadImageNode = (
await comfyPage.nodeOps.getNodeRefsByType('LoadImage')
)[0]
@@ -25,16 +30,19 @@ test.describe('Vue Nodes Image Preview', () => {
await expect(imagePreview.locator('img')).toBeVisible()
await expect(imagePreview).toContainText('x')
return imagePreview
return {
imagePreview,
nodeId: String(loadImageNode.id)
}
}
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('opens mask editor from image preview button', async ({
comfyPage
}) => {
const imagePreview = await loadImageOnNode(comfyPage)
const { imagePreview } = await loadImageOnNode(comfyPage)
await imagePreview.locator('[role="img"]').hover()
await imagePreview.locator('[role="img"]').focus()
await comfyPage.page.getByLabel('Edit or mask image').click()
await expect(comfyPage.page.locator('.mask-editor-dialog')).toBeVisible()
@@ -42,9 +50,11 @@ test.describe('Vue Nodes Image Preview', () => {
// TODO(#8143): Re-enable after image preview sync is working in CI
test.fixme('shows image context menu options', async ({ comfyPage }) => {
await loadImageOnNode(comfyPage)
const { nodeId } = await loadImageOnNode(comfyPage)
const nodeHeader = comfyPage.vueNodes.getNodeByTitle('Load Image')
const nodeHeader = comfyPage.vueNodes
.getNodeLocator(nodeId)
.locator('.lg-node-header')
await nodeHeader.click()
await nodeHeader.click({ button: 'right' })
@@ -55,4 +65,69 @@ test.describe('Vue Nodes Image Preview', () => {
await expect(contextMenu.getByText('Save Image')).toBeVisible()
await expect(contextMenu.getByText('Open in Mask Editor')).toBeVisible()
})
test(
'renders promoted image previews for each subgraph node',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(
'subgraphs/subgraph-with-multiple-promoted-previews'
)
await comfyPage.vueNodes.waitForNodes()
const firstSubgraphNode = comfyPage.vueNodes.getNodeLocator('7')
const secondSubgraphNode = comfyPage.vueNodes.getNodeLocator('8')
await expect(firstSubgraphNode).toBeVisible()
await expect(secondSubgraphNode).toBeVisible()
const firstPromotedWidgets = await getPromotedWidgetNames(comfyPage, '7')
const secondPromotedWidgets = await getPromotedWidgetNames(comfyPage, '8')
expect(firstPromotedWidgets).toEqual([
'$$canvas-image-preview',
'$$canvas-image-preview'
])
expect(secondPromotedWidgets).toEqual(['$$canvas-image-preview'])
expect(
await getPromotedWidgetCountByName(
comfyPage,
'7',
'$$canvas-image-preview'
)
).toBe(2)
expect(
await getPromotedWidgetCountByName(
comfyPage,
'8',
'$$canvas-image-preview'
)
).toBe(1)
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')
const firstPreviewImages = firstSubgraphNode.locator('.image-preview img')
const secondPreviewImages =
secondSubgraphNode.locator('.image-preview img')
await expect(firstPreviewImages).toHaveCount(2, { timeout: 30_000 })
await expect(secondPreviewImages).toHaveCount(1, { timeout: 30_000 })
await expect(firstPreviewImages.first()).toBeVisible({ timeout: 30_000 })
await expect(firstPreviewImages.nth(1)).toBeVisible({ timeout: 30_000 })
await expect(secondPreviewImages.first()).toBeVisible({ timeout: 30_000 })
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-node-multiple-promoted-previews.png'
)
}
)
})

View File

@@ -9,17 +9,20 @@ test.describe('Vue Upload Widgets', () => {
await comfyPage.vueNodes.waitForNodes()
})
test(
'should hide canvas-only upload buttons',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
test('should hide canvas-only upload buttons', async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.workflow.loadWorkflow('widgets/all_load_widgets')
await comfyPage.vueNodes.waitForNodes()
await expect(comfyPage.canvas).toHaveScreenshot(
'vue-nodes-upload-widgets.png'
)
}
)
await expect(
comfyPage.page.getByText('choose file to upload', { exact: true })
).not.toBeVisible()
await expect
.poll(() => comfyPage.page.getByText('Error loading image').count())
.toBeGreaterThan(0)
await expect
.poll(() => comfyPage.page.getByText('Error loading video').count())
.toBeGreaterThan(0)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB