mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-24 08:44:06 +00:00
## 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>
623 lines
22 KiB
TypeScript
623 lines
22 KiB
TypeScript
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)
|
|
})
|
|
})
|
|
}
|
|
)
|