Files
ComfyUI_frontend/browser_tests/tests/menu.spec.ts
Alexander Brown c25f9a0e93 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>
2026-02-23 13:33:41 -08:00

276 lines
8.8 KiB
TypeScript

import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Menu', { tag: '@ui' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
})
test('Can register sidebar tab', async ({ comfyPage }) => {
const initialChildrenCount = await comfyPage.menu.buttons.count()
await comfyPage.page.evaluate(async () => {
window.app!.extensionManager.registerSidebarTab({
id: 'search',
icon: 'pi pi-search',
title: 'search',
tooltip: 'search',
type: 'custom',
render: (el) => {
el.innerHTML = '<div>Custom search tab</div>'
}
})
})
await comfyPage.nextFrame()
const newChildrenCount = await comfyPage.menu.buttons.count()
expect(newChildrenCount).toBe(initialChildrenCount + 1)
})
test.describe('Workflows topbar tabs', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
await comfyPage.workflow.setupWorkflowsDirectory({})
})
test('Can show opened workflows', async ({ comfyPage }) => {
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
})
test('Can close saved-workflow tabs', async ({ comfyPage }) => {
const workflowName = `tempWorkflow-${test.info().title}`
await comfyPage.menu.topbar.saveWorkflow(workflowName)
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([workflowName])
await comfyPage.menu.topbar.closeWorkflowTab(workflowName)
await comfyPage.nextFrame()
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
})
})
test.describe('Topbar submmenus', () => {
test('@mobile Items fully visible on mobile screen width', async ({
comfyPage
}) => {
await comfyPage.menu.topbar.openTopbarMenu()
const topLevelMenuItem = comfyPage.page
.locator('a.p-menubar-item-link')
.first()
const isTextCutoff = await topLevelMenuItem.evaluate((el) => {
return el.scrollWidth > el.clientWidth
})
expect(isTextCutoff).toBe(false)
})
test('Clicking on active state items does not close menu', async ({
comfyPage
}) => {
// Open the menu
await comfyPage.menu.topbar.openTopbarMenu()
const menu = comfyPage.page.locator('.comfy-command-menu')
// Navigate to View menu
const viewMenuItem = comfyPage.page.locator(
'.p-menubar-item-label:text-is("View")'
)
await viewMenuItem.hover()
// Wait for submenu to appear
const viewSubmenu = comfyPage.page
.locator('.p-tieredmenu-submenu:visible')
.first()
await viewSubmenu.waitFor({ state: 'visible' })
// Find Bottom Panel menu item
const bottomPanelMenuItem = viewSubmenu
.locator('.p-tieredmenu-item:has-text("Bottom Panel")')
.first()
const bottomPanelItem = bottomPanelMenuItem.locator(
'.p-menubar-item-label:text-is("Bottom Panel")'
)
await bottomPanelItem.waitFor({ state: 'visible' })
// Get checkmark icon element
const checkmark = bottomPanelMenuItem.locator('.pi-check')
// Check initial state of bottom panel (it's initially hidden)
const { bottomPanel } = comfyPage
await expect(bottomPanel.root).not.toBeVisible()
// Checkmark should be invisible initially (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
await bottomPanelItem.click()
// Verify menu is still visible after clicking
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is now visible
await expect(bottomPanel.root).toBeVisible()
// Checkmark should now be visible (panel is shown)
await expect(checkmark).not.toHaveClass(/invisible/)
// Click Bottom Panel again to toggle it off
await bottomPanelItem.click()
// Verify menu is still visible after second click
await expect(menu).toBeVisible()
await expect(viewSubmenu).toBeVisible()
// Verify bottom panel is hidden again
await expect(bottomPanel.root).not.toBeVisible()
// Checkmark should be invisible again (panel is hidden)
await expect(checkmark).toHaveClass(/invisible/)
// Click in top-right corner to close menu (avoid hamburger menu at top-left)
const viewport = comfyPage.page.viewportSize()!
await comfyPage.page
.locator('body')
.click({ position: { x: viewport.width - 10, y: 10 } })
// Verify menu is now closed
await expect(menu).not.toBeVisible()
})
test('Displays keybinding next to item', async ({ comfyPage }) => {
await comfyPage.menu.topbar.openTopbarMenu()
const workflowMenuItem = comfyPage.menu.topbar.getMenuItem('File')
await workflowMenuItem.hover()
const exportTag = comfyPage.page.locator('.keybinding-tag', {
hasText: 'Ctrl + s'
})
expect(await exportTag.count()).toBe(1)
})
test('Can catch error when executing command', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window.app!.registerExtension({
name: 'TestExtension1',
commands: [
{
id: 'foo',
label: 'foo-command',
function: () => {
throw new Error('foo!')
}
}
],
menuCommands: [
{
path: ['ext'],
commands: ['foo']
}
]
})
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo-command'])
await expect.poll(() => comfyPage.toast.getVisibleToastCount()).toBe(1)
})
test('Can navigate Theme menu and switch between Dark and Light themes', async ({
comfyPage
}) => {
const { topbar } = comfyPage.menu
// Take initial screenshot with default theme
await comfyPage.attachScreenshot('theme-initial')
// Open the topbar menu
const menu = await topbar.openTopbarMenu()
await expect(menu).toBeVisible()
// Get theme menu items
const {
submenu: themeSubmenu,
darkTheme: darkThemeItem,
lightTheme: lightThemeItem
} = await topbar.getThemeMenuItems()
await expect(darkThemeItem).toBeVisible()
await expect(lightThemeItem).toBeVisible()
// Switch to Light theme
await topbar.switchTheme('light')
// Verify menu stays open and Light theme shows as active
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"
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('light')
// Close menu to see theme change
await topbar.closeTopbarMenu()
// Re-open menu and get theme items again
await topbar.openTopbarMenu()
const themeItems2 = await topbar.getThemeMenuItems()
// Switch back to Dark theme
await topbar.switchTheme('dark')
// Verify menu stays open and Dark theme shows as active
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"
await expect
.poll(() => comfyPage.settings.getSetting('Comfy.ColorPalette'))
.toBe('dark')
// Close menu
await topbar.closeTopbarMenu()
})
})
// Only test 'Top' to reduce test time.
// ['Bottom', 'Top']
;['Top'].forEach(async (position) => {
test(`Can migrate deprecated menu positions (${position})`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
comfyPage
}) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.settings.getSetting('Comfy.UseNewMenu')).toBe(
'Top'
)
})
})
})