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>
276 lines
8.8 KiB
TypeScript
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'
|
|
)
|
|
})
|
|
})
|
|
})
|