Compare commits

..

1 Commits

Author SHA1 Message Date
bigcat88
fff5e4bd30 dev: /Combo-RemoteOptions [ci skip] 2026-03-31 16:45:09 +03:00
38 changed files with 643 additions and 1415 deletions

View File

@@ -119,7 +119,7 @@ When writing new tests, follow these patterns:
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Feature Name', () => {
// Set up test environment if needed
@@ -148,12 +148,6 @@ Always check for existing helpers and fixtures before implementing new ones:
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
### Import Conventions
- Prefer `@e2e/*` for imports within `browser_tests/`
- Continue using `@/*` for imports from `src/`
- Avoid introducing new deep relative imports within `browser_tests/` when the alias is available
### Key Testing Patterns
1. **Focus elements explicitly**:

View File

@@ -1,116 +0,0 @@
{
"id": "selection-bbox-test",
"revision": 0,
"last_node_id": 3,
"last_link_id": 1,
"nodes": [
{
"id": 2,
"type": "e5fb1765-9323-4548-801a-5aead34d879e",
"pos": [300, 200],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "positive",
"type": "CONDITIONING",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [1]
}
],
"properties": {},
"widgets_values": []
},
{
"id": 3,
"type": "EmptyLatentImage",
"pos": [800, 200],
"size": [400, 200],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "latent",
"type": "LATENT",
"link": 1
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null
}
],
"properties": {},
"widgets_values": [512, 512, 1]
}
],
"links": [[1, 2, 0, 3, 0, "LATENT"]],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "e5fb1765-9323-4548-801a-5aead34d879e",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 0,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Test Subgraph",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "c5cc99d8-a2b6-4bf3-8be7-d4949ef736cd",
"name": "positive",
"type": "CONDITIONING",
"linkIds": [],
"pos": { "0": 200, "1": 220 }
}
],
"outputs": [
{
"id": "9bd488b9-e907-4c95-a7a4-85c5597a87af",
"name": "LATENT",
"type": "LATENT",
"linkIds": [],
"pos": { "0": 520, "1": 220 }
}
],
"widgets": [],
"nodes": [],
"groups": [],
"links": [],
"extra": {}
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"version": 0.4
}

View File

@@ -2,42 +2,42 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test'
import { test as base } from '@playwright/test'
import { config as dotenvConfig } from 'dotenv'
import { TestIds } from './selectors'
import { sleep } from './utils/timing'
import { comfyExpect } from './utils/customMatchers'
import { NodeBadgeMode } from '../../src/types/nodeSource'
import { ComfyActionbar } from '@e2e/helpers/actionbar'
import { ComfyTemplates } from '@e2e/helpers/templates'
import { ComfyMouse } from '@e2e/fixtures/ComfyMouse'
import { TestIds } from '@e2e/fixtures/selectors'
import { comfyExpect } from '@e2e/fixtures/utils/customMatchers'
import { assetPath } from '@e2e/fixtures/utils/paths'
import { sleep } from '@e2e/fixtures/utils/timing'
import { VueNodeHelpers } from '@e2e/fixtures/VueNodeHelpers'
import { BottomPanel } from '@e2e/fixtures/components/BottomPanel'
import { ComfyNodeSearchBox } from '@e2e/fixtures/components/ComfyNodeSearchBox'
import { ComfyNodeSearchBoxV2 } from '@e2e/fixtures/components/ComfyNodeSearchBoxV2'
import { ContextMenu } from '@e2e/fixtures/components/ContextMenu'
import { QueuePanel } from '@e2e/fixtures/components/QueuePanel'
import { SettingDialog } from '@e2e/fixtures/components/SettingDialog'
import { ComfyActionbar } from '../helpers/actionbar'
import { ComfyTemplates } from '../helpers/templates'
import { ComfyMouse } from './ComfyMouse'
import { VueNodeHelpers } from './VueNodeHelpers'
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
import { ComfyNodeSearchBoxV2 } from './components/ComfyNodeSearchBoxV2'
import { ContextMenu } from './components/ContextMenu'
import { SettingDialog } from './components/SettingDialog'
import { BottomPanel } from './components/BottomPanel'
import { QueuePanel } from './components/QueuePanel'
import {
AssetsSidebarTab,
NodeLibrarySidebarTab,
WorkflowsSidebarTab
} from '@e2e/fixtures/components/SidebarTab'
import { Topbar } from '@e2e/fixtures/components/Topbar'
import { AppModeHelper } from '@e2e/fixtures/helpers/AppModeHelper'
import { AssetsHelper } from '@e2e/fixtures/helpers/AssetsHelper'
import { CanvasHelper } from '@e2e/fixtures/helpers/CanvasHelper'
import { ClipboardHelper } from '@e2e/fixtures/helpers/ClipboardHelper'
import { CommandHelper } from '@e2e/fixtures/helpers/CommandHelper'
import { DragDropHelper } from '@e2e/fixtures/helpers/DragDropHelper'
import { FeatureFlagHelper } from '@e2e/fixtures/helpers/FeatureFlagHelper'
import { KeyboardHelper } from '@e2e/fixtures/helpers/KeyboardHelper'
import { NodeOperationsHelper } from '@e2e/fixtures/helpers/NodeOperationsHelper'
import { PerformanceHelper } from '@e2e/fixtures/helpers/PerformanceHelper'
import { QueueHelper } from '@e2e/fixtures/helpers/QueueHelper'
import { SettingsHelper } from '@e2e/fixtures/helpers/SettingsHelper'
import { SubgraphHelper } from '@e2e/fixtures/helpers/SubgraphHelper'
import { ToastHelper } from '@e2e/fixtures/helpers/ToastHelper'
import { WorkflowHelper } from '@e2e/fixtures/helpers/WorkflowHelper'
} from './components/SidebarTab'
import { Topbar } from './components/Topbar'
import { AssetsHelper } from './helpers/AssetsHelper'
import { CanvasHelper } from './helpers/CanvasHelper'
import { PerformanceHelper } from './helpers/PerformanceHelper'
import { QueueHelper } from './helpers/QueueHelper'
import { ClipboardHelper } from './helpers/ClipboardHelper'
import { CommandHelper } from './helpers/CommandHelper'
import { DragDropHelper } from './helpers/DragDropHelper'
import { FeatureFlagHelper } from './helpers/FeatureFlagHelper'
import { KeyboardHelper } from './helpers/KeyboardHelper'
import { NodeOperationsHelper } from './helpers/NodeOperationsHelper'
import { SettingsHelper } from './helpers/SettingsHelper'
import { AppModeHelper } from './helpers/AppModeHelper'
import { SubgraphHelper } from './helpers/SubgraphHelper'
import { ToastHelper } from './helpers/ToastHelper'
import { WorkflowHelper } from './helpers/WorkflowHelper'
import { assetPath } from './utils/paths'
import type { WorkspaceStore } from '../types/globals'
dotenvConfig()

View File

@@ -4,10 +4,7 @@ import type {
LGraph,
LGraphNode
} from '../../../src/lib/litegraph/src/litegraph'
import type {
ComfyWorkflowJSON,
NodeId
} from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { NodeId } from '../../../src/platform/workflow/validation/schemas/workflowSchema'
import type { ComfyPage } from '../ComfyPage'
import { DefaultGraphPositions } from '../constants/defaultGraphPositions'
import type { Position, Size } from '../types'
@@ -114,27 +111,6 @@ export class NodeOperationsHelper {
}
}
async getSerializedGraph(): Promise<ComfyWorkflowJSON> {
return this.page.evaluate(
() => window.app!.graph.serialize() as ComfyWorkflowJSON
)
}
async loadGraph(data: ComfyWorkflowJSON): Promise<void> {
await this.page.evaluate(
(d) => window.app!.loadGraphData(d, true, true, null),
data
)
}
async repositionNodes(
positions: Record<string, [number, number]>
): Promise<void> {
const data = await this.getSerializedGraph()
applyNodePositions(data, positions)
await this.loadGraph(data)
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -209,13 +185,3 @@ export class NodeOperationsHelper {
await this.comfyPage.nextFrame()
}
}
function applyNodePositions(
data: ComfyWorkflowJSON,
positions: Record<string, [number, number]>
): void {
for (const node of data.nodes) {
const pos = positions[String(node.id)]
if (pos) node.pos = pos
}
}

View File

@@ -1,112 +0,0 @@
import type { Page } from '@playwright/test'
export interface CanvasRect {
x: number
y: number
w: number
h: number
}
export interface MeasureResult {
selectionBounds: CanvasRect | null
nodeVisualBounds: Record<string, CanvasRect>
}
// Must match createBounds(selectedItems, 10) in src/extensions/core/selectionBorder.ts:19
const SELECTION_PADDING = 10
export async function measureSelectionBounds(
page: Page,
nodeIds: string[]
): Promise<MeasureResult> {
return page.evaluate(
({ ids, padding }) => {
const canvas = window.app!.canvas
const ds = canvas.ds
const selectedItems = canvas.selectedItems
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
// For collapsed nodes, use DOM element size (matches selectionBorder.ts
// which reads layoutStore.collapsedSize in Vue mode)
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const el =
id && isCollapsed
? document.querySelector(`[data-node-id="${id}"]`)
: null
const w = el instanceof HTMLElement ? el.offsetWidth : rect[2]
const h = el instanceof HTMLElement ? el.offsetHeight : rect[3]
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + w)
maxY = Math.max(maxY, rect[1] + h)
}
const selectionBounds =
selectedItems.size > 0
? {
x: minX - padding,
y: minY - padding,
w: maxX - minX + 2 * padding,
h: maxY - minY + 2 * padding
}
: null
const canvasEl = canvas.canvas as HTMLCanvasElement
const canvasRect = canvasEl.getBoundingClientRect()
const nodeVisualBounds: Record<
string,
{ x: number; y: number; w: number; h: number }
> = {}
for (const id of ids) {
const nodeEl = document.querySelector(
`[data-node-id="${id}"]`
) as HTMLElement | null
// Legacy mode: no Vue DOM element, use boundingRect directly
if (!nodeEl) {
const node = window.app!.graph._nodes.find(
(n: { id: number | string }) => String(n.id) === id
)
if (node) {
const rect = node.boundingRect
nodeVisualBounds[id] = {
x: rect[0],
y: rect[1],
w: rect[2],
h: rect[3]
}
}
continue
}
const domRect = nodeEl.getBoundingClientRect()
const footerEls = nodeEl.querySelectorAll(
'[data-testid="subgraph-enter-button"], [data-testid="node-footer"]'
)
let bottom = domRect.bottom
for (const footerEl of footerEls) {
bottom = Math.max(bottom, footerEl.getBoundingClientRect().bottom)
}
nodeVisualBounds[id] = {
x: (domRect.left - canvasRect.left) / ds.scale - ds.offset[0],
y: (domRect.top - canvasRect.top) / ds.scale - ds.offset[1],
w: domRect.width / ds.scale,
h: (bottom - domRect.top) / ds.scale
}
}
return { selectionBounds, nodeVisualBounds }
},
{ ids: nodeIds, padding: SELECTION_PADDING }
) as Promise<MeasureResult>
}

View File

@@ -332,18 +332,6 @@ export class NodeReference {
async isCollapsed() {
return !!(await this.getFlags()).collapsed
}
/** Deterministic setter using node.collapse() API (not a toggle). */
async setCollapsed(collapsed: boolean) {
await this.comfyPage.page.evaluate(
([id, collapsed]) => {
const node = window.app!.canvas.graph!.getNodeById(id)
if (!node) throw new Error('Node not found')
if (node.collapsed !== collapsed) node.collapse(true)
},
[this.id, collapsed] as const
)
await this.comfyPage.nextFrame()
}
async isBypassed() {
return (await this.getProperty<number | null | undefined>('mode')) === 4
}

View File

@@ -2,8 +2,8 @@ import type { Response } from '@playwright/test'
import { expect, mergeTests } from '@playwright/test'
import type { StatusWsMessage } from '../../src/schemas/apiSchema'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
import { webSocketFixture } from '@e2e/fixtures/ws'
import { comfyPageFixture } from '../fixtures/ComfyPage'
import { webSocketFixture } from '../fixtures/ws'
import type { WorkspaceStore } from '../types/globals'
const test = mergeTests(comfyPageFixture, webSocketFixture)

View File

@@ -1,151 +0,0 @@
import { expect } from '@playwright/test'
import type { Page } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
import { measureSelectionBounds } from '../fixtures/helpers/boundsUtils'
const SUBGRAPH_ID = '2'
const REGULAR_ID = '3'
const WORKFLOW = 'selection/subgraph-with-regular-node'
const REF_POS: [number, number] = [100, 100]
const TARGET_POSITIONS: Record<string, [number, number]> = {
'bottom-left': [50, 500],
'bottom-right': [600, 500]
}
type NodeType = 'subgraph' | 'regular'
type NodeState = 'expanded' | 'collapsed'
type Position = 'bottom-left' | 'bottom-right'
function getTargetId(type: NodeType): string {
return type === 'subgraph' ? SUBGRAPH_ID : REGULAR_ID
}
function getRefId(type: NodeType): string {
return type === 'subgraph' ? REGULAR_ID : SUBGRAPH_ID
}
async function assertSelectionEncompassesNodes(
page: Page,
comfyPage: ComfyPage,
nodeIds: string[]
) {
await comfyPage.canvas.press('Control+a')
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(2)
await comfyPage.nextFrame()
const result = await measureSelectionBounds(page, nodeIds)
expect(result.selectionBounds).not.toBeNull()
const sel = result.selectionBounds!
const selRight = sel.x + sel.w
const selBottom = sel.y + sel.h
for (const nodeId of nodeIds) {
const vis = result.nodeVisualBounds[nodeId]
expect(vis).toBeDefined()
expect(sel.x).toBeLessThanOrEqual(vis.x)
expect(selRight).toBeGreaterThanOrEqual(vis.x + vis.w)
expect(sel.y).toBeLessThanOrEqual(vis.y)
expect(selBottom).toBeGreaterThanOrEqual(vis.y + vis.h)
}
}
test.describe(
'Selection bounding box (Vue mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.vueNodes.waitForNodes()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeTypes: NodeType[] = ['subgraph', 'regular']
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const type of nodeTypes) {
for (const state of nodeStates) {
for (const pos of positions) {
test(`${type} node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
const targetId = getTargetId(type)
const refId = getRefId(type)
await comfyPage.nodeOps.repositionNodes({
[refId]: REF_POS,
[targetId]: TARGET_POSITIONS[pos]
})
await comfyPage.vueNodes.waitForNodes()
await comfyPage.vueNodes.getNodeLocator(targetId).waitFor()
await comfyPage.vueNodes.getNodeLocator(refId).waitFor()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(targetId)
await nodeRef.setCollapsed(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
refId,
targetId
])
})
}
}
}
}
)
test.describe(
'Selection bounding box (legacy mode)',
{ tag: ['@canvas', '@node'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await comfyPage.workflow.loadWorkflow(WORKFLOW)
await comfyPage.nextFrame()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.canvasOps.resetView()
})
const nodeStates: NodeState[] = ['expanded', 'collapsed']
const positions: Position[] = ['bottom-left', 'bottom-right']
for (const state of nodeStates) {
for (const pos of positions) {
test(`legacy node (${state}) at ${pos}: selection bounds encompass node`, async ({
comfyPage
}) => {
await comfyPage.nodeOps.repositionNodes({
[SUBGRAPH_ID]: REF_POS,
[REGULAR_ID]: TARGET_POSITIONS[pos]
})
await comfyPage.nextFrame()
if (state === 'collapsed') {
const nodeRef = await comfyPage.nodeOps.getNodeRefById(REGULAR_ID)
await nodeRef.setCollapsed(true)
}
await assertSelectionEncompassesNodes(comfyPage.page, comfyPage, [
SUBGRAPH_ID,
REGULAR_ID
])
})
}
}
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -1,528 +0,0 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
import type { ComfyPage } from '../../../../fixtures/ComfyPage'
const BYPASS_CLASS = /before:bg-bypass\/60/
const PIN_INDICATOR = '[data-testid="node-pin-indicator"]'
async function clickExactMenuItem(comfyPage: ComfyPage, name: string) {
await comfyPage.page.getByRole('menuitem', { name, exact: true }).click()
await comfyPage.nextFrame()
}
async function openContextMenu(comfyPage: ComfyPage, nodeTitle: string) {
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
await header.click()
await header.click({ button: 'right' })
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
async function openMultiNodeContextMenu(
comfyPage: ComfyPage,
titles: string[]
) {
// deselectAll via evaluate — clearSelection() clicks at a fixed position
// which can hit nodes or the toolbar overlay
await comfyPage.page.evaluate(() => window.app!.canvas.deselectAll())
await comfyPage.nextFrame()
for (const title of titles) {
const header = comfyPage.vueNodes
.getNodeByTitle(title)
.locator('.lg-node-header')
await header.click({ modifiers: ['ControlOrMeta'] })
}
await comfyPage.nextFrame()
const firstHeader = comfyPage.vueNodes
.getNodeByTitle(titles[0])
.locator('.lg-node-header')
const box = await firstHeader.boundingBox()
if (!box) throw new Error(`Header for "${titles[0]}" not found`)
await comfyPage.page.mouse.click(
box.x + box.width / 2,
box.y + box.height / 2,
{ button: 'right' }
)
const menu = comfyPage.page.locator('.p-contextmenu')
await menu.waitFor({ state: 'visible' })
return menu
}
function getNodeWrapper(comfyPage: ComfyPage, nodeTitle: string): Locator {
return comfyPage.page
.locator('[data-node-id]')
.filter({ hasText: nodeTitle })
.getByTestId('node-inner-wrapper')
}
async function getNodeRef(comfyPage: ComfyPage, nodeTitle: string) {
const refs = await comfyPage.nodeOps.getNodeRefsByTitle(nodeTitle)
return refs[0]
}
test.describe('Vue Node Context Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.vueNodes.waitForNodes()
})
test.describe('Single Node Actions', () => {
test('should rename node via context menu', async ({ comfyPage }) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Rename')
const titleInput = comfyPage.page.locator(
'.node-title-editor input[type="text"]'
)
await titleInput.waitFor({ state: 'visible' })
await titleInput.fill('My Renamed Sampler')
await titleInput.press('Enter')
await comfyPage.nextFrame()
const renamedNode =
comfyPage.vueNodes.getNodeByTitle('My Renamed Sampler')
await expect(renamedNode).toBeVisible()
})
test('should copy and paste node via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Copy')
// Internal clipboard paste (menu Copy uses canvas clipboard, not OS)
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should duplicate node via context menu', async ({ comfyPage }) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openContextMenu(comfyPage, 'Load Checkpoint')
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + 1
)
})
test('should pin and unpin node via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
// Pin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Pin')
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
expect(await nodeRef.isPinned()).toBe(true)
// Verify drag blocked
const header = comfyPage.vueNodes
.getNodeByTitle(nodeTitle)
.locator('.lg-node-header')
const posBeforeDrag = await header.boundingBox()
if (!posBeforeDrag) throw new Error('Header not found')
await comfyPage.canvasOps.dragAndDrop(
{ x: posBeforeDrag.x + 10, y: posBeforeDrag.y + 10 },
{ x: posBeforeDrag.x + 256, y: posBeforeDrag.y + 256 }
)
const posAfterDrag = await header.boundingBox()
expect(posAfterDrag).toEqual(posBeforeDrag)
// Unpin via context menu
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Unpin')
await expect(pinIndicator).not.toBeVisible()
expect(await nodeRef.isPinned()).toBe(false)
})
test('should bypass node and remove bypass via context menu', async ({
comfyPage
}) => {
const nodeTitle = 'Load Checkpoint'
const nodeRef = await getNodeRef(comfyPage, nodeTitle)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Bypass')
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, nodeTitle)).toHaveClass(
BYPASS_CLASS
)
await openContextMenu(comfyPage, nodeTitle)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, nodeTitle)).not.toHaveClass(
BYPASS_CLASS
)
})
test('should minimize and expand node via context menu', async ({
comfyPage
}) => {
const fixture = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture.body).toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture.body).not.toBeVisible()
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture.body).toBeVisible()
})
test('should convert node to subgraph via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
})
})
test.describe('Image Node Actions', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page
.context()
.grantPermissions(['clipboard-read', 'clipboard-write'])
await comfyPage.workflow.loadWorkflow('widgets/load_image_widget')
await comfyPage.vueNodes.waitForNodes(1)
})
test('should copy image to clipboard via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Copy Image')
// Verify the clipboard contains an image
const hasImage = await comfyPage.page.evaluate(async () => {
const items = await navigator.clipboard.read()
return items.some((item) =>
item.types.some((t) => t.startsWith('image/'))
)
})
expect(hasImage).toBe(true)
})
test('should paste image to LoadImage node via context menu', async ({
comfyPage
}) => {
// Capture the original image src from the node's preview
const imagePreview = comfyPage.page.locator('.image-preview img')
const originalSrc = await imagePreview.getAttribute('src')
// Write a test image into the browser clipboard
await comfyPage.page.evaluate(async () => {
const resp = await fetch('/api/view?filename=example.png&type=input')
const blob = await resp.blob()
await navigator.clipboard.write([
new ClipboardItem({ [blob.type]: blob })
])
})
// Right-click and select Paste Image
await openContextMenu(comfyPage, 'Load Image')
await clickExactMenuItem(comfyPage, 'Paste Image')
// Verify the image preview src changed
await expect(imagePreview).not.toHaveAttribute('src', originalSrc!)
})
test('should open image in new tab via context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const popupPromise = comfyPage.page.waitForEvent('popup')
await clickExactMenuItem(comfyPage, 'Open Image')
const popup = await popupPromise
expect(popup.url()).toContain('/api/view')
expect(popup.url()).toContain('filename=')
await popup.close()
})
test('should download image via Save Image context menu', async ({
comfyPage
}) => {
await openContextMenu(comfyPage, 'Load Image')
const downloadPromise = comfyPage.page.waitForEvent('download')
await clickExactMenuItem(comfyPage, 'Save Image')
const download = await downloadPromise
expect(download.suggestedFilename()).toBeTruthy()
})
})
test.describe('Subgraph Actions', () => {
test('should convert to subgraph and unpack back', async ({
comfyPage
}) => {
// Convert KSampler to subgraph
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('KSampler')
).not.toBeVisible()
// Unpack the subgraph
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Unpack Subgraph')
await expect(comfyPage.vueNodes.getNodeByTitle('KSampler')).toBeVisible()
await expect(
comfyPage.vueNodes.getNodeByTitle('New Subgraph')
).not.toBeVisible()
})
test('should open properties panel via Edit Subgraph Widgets', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'Empty Latent Image')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Right-click subgraph and edit widgets
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Edit Subgraph Widgets')
await expect(comfyPage.page.getByTestId('properties-panel')).toBeVisible()
})
test('should add subgraph to library and find in node library', async ({
comfyPage
}) => {
// Convert to subgraph first
await openContextMenu(comfyPage, 'KSampler')
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
await comfyPage.nextFrame()
// Add to library
await openContextMenu(comfyPage, 'New Subgraph')
await clickExactMenuItem(comfyPage, 'Add Subgraph to Library')
// Fill the blueprint name
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestBlueprint')
// Open node library sidebar and search for the blueprint
await comfyPage.page.getByRole('button', { name: 'Node Library' }).click()
await comfyPage.nextFrame()
const searchBox = comfyPage.page.getByRole('combobox', {
name: 'Search'
})
await searchBox.waitFor({ state: 'visible' })
await searchBox.fill('TestBlueprint')
await comfyPage.nextFrame()
await expect(comfyPage.page.getByText('TestBlueprint')).toBeVisible()
})
})
test.describe('Multi-Node Actions', () => {
const nodeTitles = ['Load Checkpoint', 'KSampler']
test('should batch rename selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Rename')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('MyNode')
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 1')).toBeVisible()
await expect(comfyPage.vueNodes.getNodeByTitle('MyNode 2')).toBeVisible()
})
test('should copy and paste selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Copy')
await comfyPage.page.evaluate(() => {
window.app!.canvas.pasteFromClipboard({ connectInputs: false })
})
await comfyPage.nextFrame()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should duplicate selected nodes via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Duplicate')
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount + nodeTitles.length
)
})
test('should pin and unpin selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Pin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).toBeVisible()
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Unpin')
for (const title of nodeTitles) {
const pinIndicator = comfyPage.vueNodes
.getNodeByTitle(title)
.locator(PIN_INDICATOR)
await expect(pinIndicator).not.toBeVisible()
}
})
test('should bypass and remove bypass on selected nodes via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(true)
await expect(getNodeWrapper(comfyPage, title)).toHaveClass(BYPASS_CLASS)
}
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Remove Bypass')
for (const title of nodeTitles) {
const nodeRef = await getNodeRef(comfyPage, title)
expect(await nodeRef.isBypassed()).toBe(false)
await expect(getNodeWrapper(comfyPage, title)).not.toHaveClass(
BYPASS_CLASS
)
}
})
test('should minimize and expand selected nodes via context menu', async ({
comfyPage
}) => {
const fixture1 =
await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const fixture2 = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Minimize Node')
await expect(fixture1.body).not.toBeVisible()
await expect(fixture2.body).not.toBeVisible()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Expand Node')
await expect(fixture1.body).toBeVisible()
await expect(fixture2.body).toBeVisible()
})
test('should frame selected nodes via context menu', async ({
comfyPage
}) => {
const initialGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Frame Nodes')
const newGroupCount = await comfyPage.page.evaluate(
() => window.app!.graph.groups.length
)
expect(newGroupCount).toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
expect(groupNodes.length).toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {
const initialCount = await comfyPage.nodeOps.getGraphNodesCount()
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Subgraph')
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
await expect(subgraphNode).toBeVisible()
expect(await comfyPage.nodeOps.getGraphNodesCount()).toBe(
initialCount - nodeTitles.length + 1
)
})
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -323,174 +323,6 @@ test.describe('Workflow Persistence', () => {
expect(linkCountAfter).toBe(linkCountBefore)
})
test('Closing an inactive tab with save preserves its own content', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflow called checkState on inactive tab, serializing the active graph instead'
})
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// Create B: duplicate and save
await comfyPage.command.executeCommand('Comfy.DuplicateWorkflow')
await comfyPage.nextFrame()
await comfyPage.menu.topbar.saveWorkflow(nameB)
// Add a Note node in B to mark it as modified
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
})
await comfyPage.nextFrame()
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(nodeCountA + 1)
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
// Switch to A via topbar tab (making B inactive)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive B tab via middle-click — triggers "Save before closing?"
await comfyPage.menu.topbar.getWorkflowTab(nameB).click({
button: 'middle'
})
// Click "Save" in the dirty close dialog
const saveButton = comfyPage.page.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
// Verify we're still on A with A's content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Re-open B from sidebar saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have the extra Note node we added, not A's node count
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Closing an inactive unsaved tab with save preserves its own content', async ({
comfyPage
}) => {
test.info().annotations.push({
type: 'regression',
description:
'PR #10745 — saveWorkflowAs called checkState on inactive temp tab, serializing the active graph'
})
await comfyPage.settings.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
const suffix = Date.now().toString(36)
const nameA = `test-A-${suffix}`
const nameB = `test-B-${suffix}`
// Save the default workflow as A
await comfyPage.menu.topbar.saveWorkflow(nameA)
const nodeCountA = await comfyPage.nodeOps.getNodeCount()
// Create B as an unsaved workflow with a Note node
await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow')
await comfyPage.nextFrame()
await comfyPage.page.evaluate(() => {
window.app!.graph.add(window.LiteGraph!.createNode('Note', undefined, {}))
})
await comfyPage.nextFrame()
// Trigger checkState so isModified is set
await comfyPage.page.evaluate(() => {
const em = window.app!.extensionManager as unknown as Record<
string,
{ activeWorkflow?: { changeTracker?: { checkState(): void } } }
>
em.workflow?.activeWorkflow?.changeTracker?.checkState()
})
const nodeCountB = await comfyPage.nodeOps.getNodeCount()
expect(nodeCountB).toBe(1)
expect(nodeCountA).not.toBe(nodeCountB)
// Switch to A via topbar tab (making unsaved B inactive)
await comfyPage.menu.topbar.getWorkflowTab(nameA).click()
await comfyPage.workflow.waitForWorkflowIdle()
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Close inactive unsaved B tab — triggers "Save before closing?"
await comfyPage.menu.topbar
.getWorkflowTab('Unsaved Workflow')
.click({ button: 'middle' })
// Click "Save" in the dirty close dialog (scoped to dialog)
const dialog = comfyPage.page.getByRole('dialog')
const saveButton = dialog.getByRole('button', { name: 'Save' })
await saveButton.waitFor({ state: 'visible' })
await saveButton.click()
// Fill in the filename dialog
const saveDialog = comfyPage.menu.topbar.getSaveDialog()
await saveDialog.waitFor({ state: 'visible' })
await saveDialog.fill(nameB)
await comfyPage.page.keyboard.press('Enter')
await comfyPage.workflow.waitForWorkflowIdle()
await comfyPage.nextFrame()
// Verify we're still on A with A's content
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 3000 })
.toBe(nodeCountA)
// Re-open B from sidebar saved list
const workflowsTab = comfyPage.menu.workflowsTab
await workflowsTab.open()
await workflowsTab.getPersistedItem(nameB).dblclick()
await comfyPage.workflow.waitForWorkflowIdle()
// B should have 1 node (the Note), not A's node count
await expect
.poll(() => comfyPage.nodeOps.getNodeCount(), { timeout: 5000 })
.toBe(nodeCountB)
})
test('Splitter panel sizes persist correctly in localStorage', async ({
comfyPage
}) => {

View File

@@ -363,7 +363,7 @@ Test your feature flags with different combinations:
### Example Test
```typescript
// Example from a colocated unit test
// In tests-ui/tests/api.featureFlags.test.ts
it('should handle preview metadata based on feature flag', () => {
// Mock server supports feature
api.serverFeatureFlags = { supports_preview_metadata: true }

View File

@@ -17,7 +17,7 @@ This guide covers patterns and examples for testing Pinia stores in the ComfyUI
Basic setup for testing Pinia stores:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createTestingPinia } from '@pinia/testing'
import { setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -51,7 +51,7 @@ describe('useWorkflowStore', () => {
Testing store state changes:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
@@ -72,7 +72,7 @@ it('should create a temporary workflow not clashing with persisted workflows', a
Testing store actions:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
@@ -115,7 +115,7 @@ describe('openWorkflow', () => {
Testing store getters:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
@@ -162,7 +162,7 @@ describe('getters', () => {
Mocking API and other dependencies:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
@@ -205,7 +205,7 @@ describe('syncWorkflows', () => {
Testing store watchers and reactive behavior:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
@@ -253,7 +253,7 @@ describe('Subgraphs', () => {
Testing store integration with other parts of the application:
```typescript
// Example from a colocated store unit test
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')

View File

@@ -18,7 +18,7 @@ This guide covers patterns and examples for unit testing utilities, composables,
Testing Vue composables requires handling reactivity correctly:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useServerLogs } from '@/composables/useServerLogs'
@@ -59,7 +59,7 @@ describe('useServerLogs', () => {
Testing LiteGraph-related functionality:
```typescript
// Example from a colocated LiteGraph unit test
// Example from: tests-ui/tests/litegraph.test.ts
import { LGraph, LGraphNode, LiteGraph } from '@/lib/litegraph'
import { describe, expect, it } from 'vitest'
@@ -93,7 +93,7 @@ describe('LGraph', () => {
Testing with ComfyUI workflow files:
```typescript
// Example from a colocated workflow unit test
// Example from: tests-ui/tests/comfyWorkflow.test.ts
import { describe, expect, it } from 'vitest'
import { validateComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
import { defaultGraph } from '@/scripts/defaultGraph'
@@ -125,7 +125,7 @@ describe('workflow validation', () => {
Mocking the ComfyUI API object:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useServerLogs.test.ts
import { describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
@@ -183,7 +183,7 @@ describe('Function using debounce', () => {
When you need to test real debounce/throttle behavior:
```typescript
// Example from a colocated composable unit test
// Example from: tests-ui/tests/composables/useWorkflowAutoSave.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('debounced function', () => {
@@ -223,7 +223,7 @@ describe('debounced function', () => {
Creating mock node definitions for testing:
```typescript
// Example from a colocated schema unit test
// Example from: tests-ui/tests/apiTypes.test.ts
import { describe, expect, it } from 'vitest'
import {
type ComfyNodeDef,

View File

@@ -230,6 +230,15 @@ export default defineConfig([
]
}
},
{
files: ['tests-ui/**/*'],
rules: {
'@typescript-eslint/consistent-type-imports': [
'error',
{ disallowTypeAnnotations: false }
]
}
},
{
files: ['**/*.spec.ts'],
ignores: ['browser_tests/tests/**/*.spec.ts'],

View File

@@ -1,93 +1,68 @@
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
import type { LGraphCanvas, Rectangle } from '@/lib/litegraph/src/litegraph'
import { createBounds, LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { createBounds } from '@/lib/litegraph/src/litegraph'
import { app } from '@/scripts/app'
function getSelectionBounds(canvas: LGraphCanvas): ReadOnlyRect | null {
const selectedItems = canvas.selectedItems
if (selectedItems.size <= 1) return null
if (!LiteGraph.vueNodesMode) return createBounds(selectedItems, 10)
// In Vue mode, use layoutStore.collapsedSize for collapsed nodes
// to get accurate dimensions instead of litegraph's fallback values.
const padding = 10
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (const item of selectedItems) {
const rect = item.boundingRect
const id = 'id' in item ? String(item.id) : null
const isCollapsed =
'flags' in item &&
!!(item as { flags?: { collapsed?: boolean } }).flags?.collapsed
const collapsedSize =
id && isCollapsed ? layoutStore.getNodeCollapsedSize(id) : undefined
if (collapsedSize) {
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + collapsedSize.width)
maxY = Math.max(maxY, rect[1] + collapsedSize.height)
} else {
minX = Math.min(minX, rect[0])
minY = Math.min(minY, rect[1])
maxX = Math.max(maxX, rect[0] + rect[2])
maxY = Math.max(maxY, rect[1] + rect[3])
}
}
if (!Number.isFinite(minX)) return null
return [
minX - padding,
minY - padding,
maxX - minX + 2 * padding,
maxY - minY + 2 * padding
]
}
/**
* Draws a dashed border around selected items that maintains constant pixel size
* regardless of zoom level, similar to the DOM selection overlay.
*/
function drawSelectionBorder(
ctx: CanvasRenderingContext2D,
canvas: LGraphCanvas
) {
const bounds = getSelectionBounds(canvas)
const selectedItems = canvas.selectedItems
// Only draw if multiple items selected
if (selectedItems.size <= 1) return
// Use the same bounds calculation as the toolbox
const bounds = createBounds(selectedItems, 10)
if (!bounds) return
const [x, y, width, height] = bounds
// Save context state
ctx.save()
const borderWidth = 2 / canvas.ds.scale
// Set up dashed line style that doesn't scale with zoom
const borderWidth = 2 / canvas.ds.scale // Constant 2px regardless of zoom
ctx.lineWidth = borderWidth
ctx.strokeStyle =
getComputedStyle(document.documentElement)
.getPropertyValue('--border-color')
.trim() || '#ffffff66'
// Create dash pattern that maintains visual size
const dashSize = 5 / canvas.ds.scale
ctx.setLineDash([dashSize, dashSize])
// Draw the border using the bounds directly
ctx.beginPath()
ctx.roundRect(x, y, width, height, 8 / canvas.ds.scale)
ctx.stroke()
// Restore context
ctx.restore()
}
/**
* Extension that adds a dashed selection border for multiple selected nodes
*/
const ext = {
name: 'Comfy.SelectionBorder',
async init() {
// Hook into the canvas drawing
const originalDrawForeground = app.canvas.onDrawForeground
app.canvas.onDrawForeground = function (
ctx: CanvasRenderingContext2D,
visibleArea: Rectangle
) {
// Call original if it exists
originalDrawForeground?.call(this, ctx, visibleArea)
// Draw our selection border
drawSelectionBorder(ctx, app.canvas)
}
}

View File

@@ -139,7 +139,7 @@ export const useWorkflowService = () => {
}
if (isSelfOverwrite) {
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
workflow.changeTracker?.checkState()
await saveWorkflow(workflow)
} else {
let target: ComfyWorkflow
@@ -156,7 +156,7 @@ export const useWorkflowService = () => {
app.rootGraph.extra.linearMode = isApp
target.initialMode = isApp ? 'app' : 'graph'
}
if (workflowStore.isActive(target)) target.changeTracker?.checkState()
target.changeTracker?.checkState()
await workflowStore.saveWorkflow(target)
}
@@ -173,7 +173,7 @@ export const useWorkflowService = () => {
if (workflow.isTemporary) {
await saveWorkflowAs(workflow)
} else {
if (workflowStore.isActive(workflow)) workflow.changeTracker?.checkState()
workflow.changeTracker?.checkState()
const isApp = workflow.initialMode === 'app'
const expectedPath =

View File

@@ -34,7 +34,6 @@ import type {
NodeId,
NodeLayout,
Point,
Size,
RerouteId,
RerouteLayout,
ResizeNodeOperation,
@@ -130,7 +129,6 @@ class LayoutStoreImpl implements LayoutStore {
// CustomRef cache and trigger functions
private nodeRefs = new Map<NodeId, Ref<NodeLayout | null>>()
private nodeTriggers = new Map<NodeId, () => void>()
private collapsedSizes = new Map<NodeId, Size>()
// New data structures for hit testing
private linkLayouts = new Map<LinkId, LinkLayout>()
@@ -244,10 +242,7 @@ class LayoutStoreImpl implements LayoutStore {
track()
const ynode = this.ynodes.get(nodeId)
const layout = ynode ? yNodeToLayout(ynode) : null
const collapsedSize = this.collapsedSizes.get(nodeId)
return layout && collapsedSize
? { ...layout, collapsedSize }
: layout
return layout
},
set: (newLayout: NodeLayout | null) => {
if (newLayout === null) {
@@ -1006,7 +1001,6 @@ class LayoutStoreImpl implements LayoutStore {
): void {
this.ydoc.transact(() => {
this.ynodes.clear()
this.collapsedSizes.clear()
// Note: We intentionally do NOT clear nodeRefs and nodeTriggers here.
// Vue components may already hold references to these refs, and clearing
// them would break the reactivity chain. The refs will be reused when
@@ -1552,15 +1546,6 @@ class LayoutStoreImpl implements LayoutStore {
this.currentSource = originalSource
}
updateNodeCollapsedSize(nodeId: NodeId, size: Size): void {
this.collapsedSizes.set(nodeId, size)
this.nodeTriggers.get(nodeId)?.()
}
getNodeCollapsedSize(nodeId: NodeId): Size | undefined {
return this.collapsedSizes.get(nodeId)
}
}
// Create singleton instance

View File

@@ -50,9 +50,6 @@ export interface NodeLayout {
visible: boolean
// Computed bounds for hit testing
bounds: Bounds
// Collapsed node dimensions (Vue mode only, separate from size to
// preserve expanded size across collapse/expand cycles)
collapsedSize?: Size
}
export interface SlotLayout {

View File

@@ -12,9 +12,7 @@
cn(
'group/node lg-node absolute isolate text-sm',
'flex flex-col contain-layout contain-style',
isRerouteNode
? 'h-(--node-height)'
: 'min-h-(--node-height) min-w-(--min-node-width)',
isRerouteNode ? 'h-(--node-height)' : 'min-w-(--min-node-width)',
cursorClass,
isSelected && 'outline-node-component-outline',
executing && 'outline-node-stroke-executing',
@@ -57,7 +55,8 @@
hasAnyError ? '-inset-[7px]' : '-inset-[3px]',
isSelected
? 'border-node-component-outline'
: 'border-node-stroke-executing'
: 'border-node-stroke-executing',
footerStateOutlineBottomClass
)
"
/>
@@ -67,7 +66,8 @@
cn(
'pointer-events-none absolute border border-solid border-component-node-border',
rootBorderShapeClass,
hasAnyError ? '-inset-1' : 'inset-0'
hasAnyError ? '-inset-1' : 'inset-0',
footerRootBorderBottomClass
)
"
/>
@@ -77,7 +77,7 @@
cn(
'flex flex-1 flex-col border border-solid border-transparent bg-node-component-header-surface',
'w-(--node-width)',
!isRerouteNode && 'min-w-(--min-node-width)',
!isRerouteNode && 'min-h-(--node-height) min-w-(--min-node-width)',
shapeClass,
hasAnyError && 'ring-4 ring-destructive-background',
{
@@ -196,6 +196,7 @@
:is-subgraph="!!lgraphNode?.isSubgraphNode()"
:has-any-error="hasAnyError"
:show-errors-tab-enabled="showErrorsTabEnabled"
:is-collapsed="isCollapsed"
:show-advanced-inputs-button="showAdvancedInputsButton"
:show-advanced-state="showAdvancedState"
:header-color="applyLightThemeColor(nodeData?.color)"
@@ -221,6 +222,8 @@
cn(
baseResizeHandleClasses,
handle.positionClasses,
(handle.corner === 'SE' || handle.corner === 'SW') &&
footerResizeHandleBottomClass,
handle.cursorClass,
'group-hover/node:opacity-100'
)
@@ -268,7 +271,6 @@ import { useAppMode } from '@/composables/useAppMode'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { hasUnpromotedWidgets } from '@/core/graph/subgraph/promotionUtils'
import { st } from '@/i18n'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import {
LGraphCanvas,
LGraphEventMode,
@@ -314,6 +316,7 @@ import {
import { cn } from '@/utils/tailwindUtil'
import { isTransparent } from '@/utils/colorUtil'
import type { CompassCorners } from '@/lib/litegraph/src/interfaces'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { MIN_NODE_WIDTH } from '@/renderer/core/layout/transform/graphRenderTransform'
@@ -563,6 +566,30 @@ const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
}
)
const hasFooter = computed(() => {
return !!(
(hasAnyError.value && showErrorsTabEnabled.value) ||
lgraphNode.value?.isSubgraphNode() ||
(!lgraphNode.value?.isSubgraphNode() &&
(showAdvancedState.value || showAdvancedInputsButton.value))
)
})
// Footer offset computed classes
const footerStateOutlineBottomClass = computed(() =>
hasFooter.value ? '-bottom-[35px]' : ''
)
const footerRootBorderBottomClass = computed(() =>
hasFooter.value ? '-bottom-8' : ''
)
const footerResizeHandleBottomClass = computed(() => {
if (!hasFooter.value) return ''
return hasAnyError.value ? 'bottom-[-31px]' : 'bottom-[-35px]'
})
const cursorClass = computed(() => {
if (nodeData.flags?.pinned) return 'cursor-default'
return layoutStore.isDraggingVueNodes.value

View File

@@ -1,16 +1,13 @@
<template>
<!-- Case 1: Subgraph + Error (Dual Tabs) -->
<div
v-if="isSubgraph && hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<template v-if="isSubgraph && hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -26,38 +23,37 @@
data-testid="subgraph-enter-button"
:class="
cn(
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('enterSubgraph')"
>
<div class="flex size-full items-center justify-center gap-2">
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{ t('g.enter') }}</span>
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 1b: Advanced + Error (Dual Tabs, Regular Nodes) -->
<div
<template
v-else-if="
!isSubgraph &&
hasAnyError &&
showErrorsTabEnabled &&
(showAdvancedInputsButton || showAdvancedState)
"
:class="errorWrapperStyles"
>
<Button
variant="textonly"
:class="
cn(
tabStyles,
'z-10 box-border w-1/2 rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
errorRadiusClass
getTabStyles(false),
errorTabWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -72,15 +68,15 @@
variant="textonly"
:class="
cn(
tabStyles,
'-ml-5 box-border w-[calc(50%+20px)] rounded-none bg-node-component-header-surface pt-9 pb-4 pl-5',
enterRadiusClass
getTabStyles(true),
enterTabFullWidth,
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<div class="ml-auto flex h-full w-1/2 items-center justify-center gap-2">
<span class="truncate">{{
showAdvancedState
? t('rightSidePanel.hideAdvancedShort')
@@ -95,20 +91,17 @@
/>
</div>
</Button>
</div>
</template>
<!-- Case 2: Error Only (Full Width) -->
<div
v-else-if="hasAnyError && showErrorsTabEnabled"
:class="errorWrapperStyles"
>
<template v-else-if="hasAnyError && showErrorsTabEnabled">
<Button
variant="textonly"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-destructive-background pt-9 pb-4 text-white hover:bg-destructive-background-hover',
footerRadiusClass
getTabStyles(false),
enterTabFullWidth,
'-z-5 bg-destructive-background text-white hover:bg-destructive-background-hover'
)
"
@click.stop="$emit('openErrors')"
@@ -118,27 +111,18 @@
<i class="icon-[lucide--info] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 3: Subgraph only (Full Width) -->
<div
v-else-if="isSubgraph"
:class="
cn(
'isolate -z-1 -mt-5 box-border flex',
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
)
"
>
<template v-else-if="isSubgraph">
<Button
variant="textonly"
data-testid="subgraph-enter-button"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@@ -149,47 +133,37 @@
<i class="icon-[comfy--workflow] size-4 shrink-0" />
</div>
</Button>
</div>
</template>
<!-- Case 4: Advanced Footer (Regular Nodes) -->
<div
<Button
v-else-if="showAdvancedInputsButton || showAdvancedState"
variant="textonly"
:class="
cn(
'isolate -z-1 -mt-5 box-border flex',
hasAnyError ? '-mx-1 -mb-2 w-[calc(100%+8px)] pb-1' : 'w-full'
getTabStyles(true),
hasAnyError ? 'w-[calc(100%+8px)]' : 'w-full',
'-z-10 bg-node-component-header-surface'
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<Button
variant="textonly"
:class="
cn(
tabStyles,
'box-border w-full rounded-none bg-node-component-header-surface',
hasAnyError ? 'pt-9 pb-4' : 'pt-8 pb-4',
footerRadiusClass
)
"
:style="headerColorStyle"
@click.stop="$emit('toggleAdvanced')"
>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</div>
<div class="flex size-full items-center justify-center gap-2">
<template v-if="showAdvancedState">
<span class="truncate">{{
t('rightSidePanel.hideAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--chevron-up] size-4 shrink-0" />
</template>
<template v-else>
<span class="truncate">{{
t('rightSidePanel.showAdvancedInputsButton')
}}</span>
<i class="icon-[lucide--settings-2] size-4 shrink-0" />
</template>
</div>
</Button>
</template>
<script setup lang="ts">
@@ -205,21 +179,14 @@ interface Props {
isSubgraph: boolean
hasAnyError: boolean
showErrorsTabEnabled: boolean
isCollapsed: boolean
showAdvancedInputsButton?: boolean
showAdvancedState?: boolean
headerColor?: string
shape?: RenderShape
}
const {
isSubgraph,
hasAnyError,
showErrorsTabEnabled,
showAdvancedInputsButton,
showAdvancedState,
headerColor,
shape
} = defineProps<Props>()
const props = defineProps<Props>()
defineEmits<{
(e: 'enterSubgraph'): void
@@ -228,43 +195,51 @@ defineEmits<{
}>()
const footerRadiusClass = computed(() => {
const isError = hasAnyError
switch (shape) {
const isExpanded = props.hasAnyError
switch (props.shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return isError ? 'rounded-br-[20px]' : 'rounded-br-[17px]'
return isExpanded ? 'rounded-br-[20px]' : 'rounded-br-2xl'
default:
return isError ? 'rounded-b-[20px]' : 'rounded-b-[17px]'
return isExpanded ? 'rounded-b-[20px]' : 'rounded-b-2xl'
}
})
const errorRadiusClass = computed(() => {
switch (shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
return 'rounded-br-[20px]'
default:
return 'rounded-b-[20px]'
/**
* Returns shared size/position classes for footer tabs
* @param isBackground If true, calculates styles for the background/right tab (Enter Subgraph)
*/
const getTabStyles = (isBackground = false) => {
let sizeClasses = ''
if (props.isCollapsed) {
let pt = 'pt-10'
if (isBackground) {
pt = props.hasAnyError ? 'pt-10.5' : 'pt-9'
}
sizeClasses = cn('-mt-7.5 h-15', pt)
} else {
let pt = 'pt-12.5'
if (isBackground) {
pt = props.hasAnyError ? 'pt-12.5' : 'pt-11.5'
}
sizeClasses = cn('-mt-10 h-17.5', pt)
}
})
const enterRadiusClass = computed(() => {
switch (shape) {
case RenderShape.BOX:
return ''
case RenderShape.CARD:
default:
return 'rounded-br-[20px]'
}
})
const tabStyles = 'pointer-events-auto h-9 text-xs'
const errorWrapperStyles =
'isolate -z-1 -mx-1 -mt-5 -mb-2 box-border flex w-[calc(100%+8px)] pb-1'
return cn(
'pointer-events-auto absolute top-full left-0 text-xs',
footerRadiusClass.value,
sizeClasses,
props.hasAnyError ? '-translate-x-1 translate-y-0.5' : 'translate-y-0.5'
)
}
const headerColorStyle = computed(() =>
headerColor ? { backgroundColor: headerColor } : undefined
props.headerColor ? { backgroundColor: props.headerColor } : undefined
)
// Case 1 context: Split widths
const errorTabWidth = 'w-[calc(50%+4px)]'
const enterTabFullWidth = 'w-[calc(100%+8px)]'
</script>

View File

@@ -47,8 +47,7 @@ const testState = vi.hoisted(() => ({
}))
vi.mock('@vueuse/core', () => ({
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible'),
createSharedComposable: <T>(fn: T) => fn
useDocumentVisibility: () => ref<'visible' | 'hidden'>('visible')
}))
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
@@ -100,8 +99,6 @@ function createResizeEntry(options?: {
if (collapsed) {
element.dataset.collapsed = ''
}
Object.defineProperty(element, 'offsetWidth', { value: width })
Object.defineProperty(element, 'offsetHeight', { value: height })
const rectSpy = vi.fn(() => new DOMRect(left, top, width, height))
element.getBoundingClientRect = rectSpy
const boxSizes = [{ inlineSize: width, blockSize: height }]

View File

@@ -139,35 +139,25 @@ const resizeObserver = new ResizeObserver((entries) => {
const nodeId: NodeId | undefined =
elementType === 'node' ? elementId : undefined
// Collapsed nodes: preserve expanded size but store collapsed
// dimensions separately in layoutStore for selection bounds.
// Skip collapsed nodes — their DOM height is just the header, and writing
// that back to the layout store would overwrite the stored expanded size.
if (elementType === 'node' && element.dataset.collapsed != null) {
if (nodeId) {
markElementForFreshMeasurement(element)
const body = element.querySelector(
'[data-testid^="node-inner-wrapper"]'
)
const collapsedWidth =
body instanceof HTMLElement ? body.offsetWidth : element.offsetWidth
const collapsedHeight = element.offsetHeight
const nodeLayout = layoutStore.getNodeLayoutRef(nodeId).value
if (nodeLayout) {
layoutStore.updateNodeCollapsedSize(nodeId, {
width: collapsedWidth,
height: collapsedHeight
})
}
nodesNeedingSlotResync.add(nodeId)
}
continue
}
// Measure the full root element (including footer in flow).
// min-height is applied to the root, so footer height in node.size
// does not accumulate on Vue/legacy mode switching.
const width = Math.max(0, element.offsetWidth)
const height = Math.max(0, element.offsetHeight)
// Use borderBoxSize when available; fall back to contentRect for older engines/tests
// Border box is the border included FULL wxh DOM value.
const borderBox = Array.isArray(entry.borderBoxSize)
? entry.borderBoxSize[0]
: {
inlineSize: entry.contentRect.width,
blockSize: entry.contentRect.height
}
const width = Math.max(0, borderBox.inlineSize)
const height = Math.max(0, borderBox.blockSize)
const nodeLayout = nodeId
? layoutStore.getNodeLayoutRef(nodeId).value
: null

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import { computed, onMounted, provide, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FilterOption } from '@/platform/assets/types/filterTypes'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import FormDropdown from './form/dropdown/FormDropdown.vue'
import type { FormDropdownItem, LayoutMode } from './form/dropdown/types'
import { AssetKindKey } from './form/dropdown/types'
import {
buildSearchText,
extractFilterValues,
getByPath,
mapToDropdownItem
} from '../utils/resolveItemSchema'
import { fetchRemoteRoute } from '../utils/resolveRemoteRoute'
const props = defineProps<{
modelValue?: string
widget: SimplifiedWidget<string | undefined>
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const comboSpec = computed(() => {
if (props.widget.spec && isComboInputSpec(props.widget.spec)) {
return props.widget.spec
}
return undefined
})
const remoteConfig = computed(() => comboSpec.value?.remote!)
const itemSchema = computed(() => remoteConfig.value?.item_schema!)
const rawItems = ref<unknown[]>([])
const loading = ref(false)
async function fetchItems() {
loading.value = true
try {
const res = await fetchRemoteRoute(remoteConfig.value.route, {
params: remoteConfig.value.query_params,
timeout: remoteConfig.value.timeout ?? 30000
})
const data = remoteConfig.value.response_key
? res.data[remoteConfig.value.response_key]
: res.data
rawItems.value = Array.isArray(data) ? data : []
} catch (err) {
console.error('RichComboWidget: fetch error', err)
} finally {
loading.value = false
}
}
onMounted(() => {
void fetchItems()
})
const assetKind = computed(() => {
const pt = itemSchema.value.preview_type ?? 'image'
return pt as 'image' | 'video' | 'audio'
})
provide(AssetKindKey, assetKind)
const items = computed<FormDropdownItem[]>(() =>
rawItems.value.map((raw) => mapToDropdownItem(raw, itemSchema.value))
)
const searchIndex = computed(() => {
const schema = itemSchema.value
const fields = schema.search_fields ?? [schema.label_field]
const index = new Map<string, string>()
for (const raw of rawItems.value) {
const id = String(getByPath(raw, schema.value_field) ?? '')
index.set(id, buildSearchText(raw, fields))
}
return index
})
const filterOptions = computed<FilterOption[]>(() => {
const schema = itemSchema.value
if (!schema.filter_field) return []
const values = extractFilterValues(rawItems.value, schema.filter_field)
return [
{ name: 'All', value: 'all' },
...values.map((v) => ({ name: v, value: v }))
]
})
const filterSelected = ref('all')
const layoutMode = ref<LayoutMode>('list')
const selectedSet = ref<Set<string>>(new Set())
const filteredItems = computed<FormDropdownItem[]>(() => {
const schema = itemSchema.value
if (filterSelected.value === 'all' || !schema.filter_field) {
return items.value
}
const filterField = schema.filter_field
return rawItems.value
.filter(
(raw) =>
String(getByPath(raw, filterField) ?? '') === filterSelected.value
)
.map((raw) => mapToDropdownItem(raw, schema))
})
async function searcher(
query: string,
searchItems: FormDropdownItem[],
_onCleanup: (cleanupFn: () => void) => void
): Promise<FormDropdownItem[]> {
if (!query.trim()) return searchItems
const q = query.toLowerCase()
return searchItems.filter((item) => {
const text = searchIndex.value.get(item.id) ?? item.name.toLowerCase()
return text.includes(q)
})
}
watch(
[() => props.modelValue, items],
([val]) => {
selectedSet.value.clear()
if (val) {
const item = items.value.find((i) => i.id === val)
if (item) selectedSet.value.add(item.id)
}
},
{ immediate: true }
)
function handleRefresh() {
void fetchItems()
}
function handleSelection(selected: Set<string>) {
const id = selected.values().next().value
if (id) {
emit('update:modelValue', id)
}
}
</script>
<template>
<div class="flex w-full items-center gap-1">
<FormDropdown
v-model:selected="selectedSet"
v-model:filter-selected="filterSelected"
v-model:layout-mode="layoutMode"
:items="filteredItems"
:placeholder="loading ? 'Loading...' : t('widgets.uploadSelect.placeholder')"
:multiple="false"
:filter-options="[]"
:show-sort="false"
:show-layout-switcher="false"
:searcher="searcher"
class="flex-1"
@update:selected="handleSelection"
/>
<button
v-if="remoteConfig?.refresh_button !== false"
class="flex size-7 shrink-0 items-center justify-center rounded text-secondary hover:bg-component-node-widget-background-hovered"
title="Refresh"
@pointerdown.stop
@click.stop="handleRefresh"
>
<i
:class="[
'icon-[lucide--refresh-cw] size-3.5',
loading && 'animate-spin'
]"
/>
</button>
</div>
</template>

View File

@@ -1,6 +1,11 @@
<template>
<RichComboWidget
v-if="hasItemSchema"
v-model="modelValue"
:widget
/>
<WidgetSelectDropdown
v-if="isDropdownUIWidget"
v-else-if="isDropdownUIWidget"
v-model="modelValue"
:widget
:node-type="widget.nodeType ?? nodeType"
@@ -24,6 +29,7 @@
import { computed } from 'vue'
import { assetService } from '@/platform/assets/services/assetService'
import RichComboWidget from '@/renderer/extensions/vueNodes/widgets/components/RichComboWidget.vue'
import WidgetSelectDefault from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDefault.vue'
import WidgetSelectDropdown from '@/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue'
import WidgetWithControl from '@/renderer/extensions/vueNodes/widgets/components/WidgetWithControl.vue'
@@ -53,6 +59,10 @@ const comboSpec = computed<ComboInputSpec | undefined>(() => {
return undefined
})
const hasItemSchema = computed(
() => !!comboSpec.value?.remote?.item_schema
)
const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean

View File

@@ -34,6 +34,8 @@ interface Props {
accept?: string
filterOptions?: FilterOption[]
sortOptions?: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -61,6 +63,8 @@ const {
accept,
filterOptions = [],
sortOptions = getDefaultSortOptions(),
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -232,6 +236,8 @@ function handleSelection(item: FormDropdownItem, index: number) {
v-model:base-model-selected="baseModelSelected"
:filter-options
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter

View File

@@ -20,6 +20,8 @@ interface Props {
isSelected: (item: FormDropdownItem, index: number) => boolean
filterOptions: FilterOption[]
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -31,6 +33,8 @@ const {
isSelected,
filterOptions,
sortOptions,
showSort = true,
showLayoutSwitcher = true,
showOwnershipFilter,
ownershipOptions,
showBaseModelFilter,
@@ -112,6 +116,8 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
v-model:ownership-selected="ownershipSelected"
v-model:base-model-selected="baseModelSelected"
:sort-options
:show-sort
:show-layout-switcher="showLayoutSwitcher"
:show-ownership-filter
:ownership-options
:show-base-model-filter
@@ -145,6 +151,7 @@ const virtualItems = computed<VirtualDropdownItem[]>(() =>
:preview-url="item.preview_url ?? ''"
:name="item.name"
:label="item.label"
:description="item.description"
:layout="layoutMode"
@click="emit('item-click', item, index)"
/>

View File

@@ -18,8 +18,13 @@ import type { LayoutMode, SortOption } from './types'
const { t } = useI18n()
const overlayProps = useTransformCompatOverlayProps()
defineProps<{
const {
showSort = true,
showLayoutSwitcher = true
} = defineProps<{
sortOptions: SortOption[]
showSort?: boolean
showLayoutSwitcher?: boolean
showOwnershipFilter?: boolean
ownershipOptions?: OwnershipFilterOption[]
showBaseModelFilter?: boolean
@@ -114,6 +119,7 @@ function toggleBaseModelSelection(item: FilterOption) {
/>
<Button
v-if="showSort"
ref="sortTriggerRef"
variant="textonly"
size="icon"
@@ -132,6 +138,7 @@ function toggleBaseModelSelection(item: FilterOption) {
<i class="icon-[lucide--arrow-up-down] size-4" />
</Button>
<Popover
v-if="showSort"
ref="sortPopoverRef"
:dismissable="true"
:close-on-escape="true"
@@ -309,6 +316,7 @@ function toggleBaseModelSelection(item: FilterOption) {
</Popover>
<div
v-if="showLayoutSwitcher"
:class="
cn(
actionButtonStyle,

View File

@@ -12,6 +12,7 @@ interface Props {
previewUrl: string
name: string
label?: string
description?: string
layout?: LayoutMode
}
@@ -27,11 +28,31 @@ const actualDimensions = ref<string | null>(null)
const assetKind = inject(AssetKindKey)
const isVideo = computed(() => assetKind?.value === 'video')
const isAudio = computed(() => assetKind?.value === 'audio')
const audioRef = ref<HTMLAudioElement | null>(null)
const isPlayingAudio = ref(false)
function handleClick() {
emit('click', props.index)
}
function toggleAudioPreview(event: Event) {
event.stopPropagation()
if (!audioRef.value) return
if (isPlayingAudio.value) {
audioRef.value.pause()
isPlayingAudio.value = false
} else {
void audioRef.value.play()
isPlayingAudio.value = true
}
}
function handleAudioEnded() {
isPlayingAudio.value = false
}
function handleImageLoad(event: Event) {
emit('mediaLoad', event)
if (!event.target || !(event.target instanceof HTMLImageElement)) return
@@ -107,6 +128,25 @@ function handleVideoLoad(event: Event) {
muted
@loadeddata="handleVideoLoad"
/>
<div
v-else-if="previewUrl && isAudio"
class="flex size-full cursor-pointer items-center justify-center bg-gradient-to-tr from-violet-500 via-purple-500 to-fuchsia-400"
@click.stop="toggleAudioPreview"
>
<audio
ref="audioRef"
:src="previewUrl"
preload="none"
@ended="handleAudioEnded"
/>
<i
:class="
isPlayingAudio
? 'icon-[lucide--pause] size-5 text-white'
: 'icon-[lucide--play] size-5 text-white'
"
/>
</div>
<img
v-else-if="previewUrl"
:src="previewUrl"
@@ -144,6 +184,13 @@ function handleVideoLoad(event: Event) {
>
{{ label ?? name }}
</span>
<!-- Description -->
<span
v-if="description && layout !== 'grid'"
class="text-secondary line-clamp-1 block overflow-hidden text-xs"
>
{{ description }}
</span>
<!-- Meta Data -->
<span v-if="actualDimensions" class="text-secondary block text-xs">
{{ actualDimensions }}

View File

@@ -12,7 +12,9 @@ export interface FormDropdownItem {
name: string
/** Original/alternate label (e.g., original filename) */
label?: string
/** Preview image/video URL */
/** Short description shown below the name in list view */
description?: string
/** Preview image/video/audio URL */
preview_url?: string
/** Whether the item is immutable (public model) - used for ownership filtering */
is_immutable?: boolean

View File

@@ -214,7 +214,9 @@ const addComboWidget = (
}
)
if (inputSpec.remote) {
if (inputSpec.remote && !inputSpec.remote.item_schema) {
// Skip useRemoteWidget when item_schema is present —
// RichComboWidget handles its own data fetching and rendering.
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

View File

@@ -2,10 +2,12 @@ import axios from 'axios'
import { useChainCallback } from '@/composables/functional/useChainCallback'
import type { IWidget, LGraphNode } from '@/lib/litegraph/src/litegraph'
import { isCloud } from '@/platform/distribution/types'
import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
import { api } from '@/scripts/api'
import { useAuthStore } from '@/stores/authStore'
import {
getRemoteAuthHeaders,
resolveRoute
} from '../utils/resolveRemoteRoute'
const MAX_RETRIES = 5
const TIMEOUT = 4096
@@ -21,17 +23,6 @@ interface CacheEntry<T> {
failed?: boolean
}
async function getAuthHeaders() {
if (isCloud) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
return {
...(authHeader && { headers: authHeader })
}
}
return {}
}
const dataCache = new Map<string, CacheEntry<unknown>>()
const createCacheKey = (config: RemoteWidgetConfig): string => {
@@ -73,9 +64,10 @@ const fetchData = async (
) => {
const { route, response_key, query_params, timeout = TIMEOUT } = config
const authHeaders = await getAuthHeaders()
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders(route)
const res = await axios.get(route, {
const res = await axios.get(url, {
params: query_params,
signal: controller.signal,
timeout,

View File

@@ -0,0 +1,70 @@
import type { RemoteItemSchema } from '@/schemas/nodeDefSchema'
import type { FormDropdownItem } from '@/renderer/extensions/vueNodes/widgets/components/form/dropdown/types'
/** Traverse an object by dot-path, treating numeric segments as array indices */
export function getByPath(obj: unknown, path: string): unknown {
return path.split('.').reduce((acc: unknown, key: string) => {
if (acc == null) return undefined
const idx = Number(key)
if (Number.isInteger(idx) && idx >= 0 && Array.isArray(acc)) return acc[idx]
return (acc as Record<string, unknown>)[key]
}, obj)
}
/** Resolve a label — either dot-path or template with {field.path} placeholders */
export function resolveLabel(template: string, item: unknown): string {
if (!template.includes('{')) {
return String(getByPath(item, template) ?? '')
}
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
String(getByPath(item, path) ?? '')
)
}
/** Map a raw API object to a FormDropdownItem using the item_schema */
export function mapToDropdownItem(
raw: unknown,
schema: RemoteItemSchema
): FormDropdownItem {
return {
id: String(getByPath(raw, schema.value_field) ?? ''),
name: resolveLabel(schema.label_field, raw),
description: schema.description_field
? resolveLabel(schema.description_field, raw)
: undefined,
preview_url: schema.preview_url_field
? String(getByPath(raw, schema.preview_url_field) ?? '')
: undefined
}
}
/** Extract items array from full API response using response_key */
export function extractItems(
response: unknown,
responseKey?: string
): unknown[] {
const data = responseKey ? getByPath(response, responseKey) : response
return Array.isArray(data) ? data : []
}
/** Build search text for an item from the specified search fields */
export function buildSearchText(raw: unknown, searchFields: string[]): string {
return searchFields
.map((field) => String(getByPath(raw, field) ?? ''))
.filter(Boolean)
.join(' ')
.toLowerCase()
}
/** Extract unique filter values from items */
export function extractFilterValues(
items: unknown[],
filterField: string
): string[] {
const values = new Set<string>()
for (const item of items) {
const value = getByPath(item, filterField)
if (value != null) values.add(String(value))
}
return Array.from(values).sort()
}

View File

@@ -0,0 +1,58 @@
import axios from 'axios'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useAuthStore } from '@/stores/authStore'
/**
* Check if a route is a comfy-api proxy route.
* These routes need the comfy-api base URL prepended and always require auth.
*/
function isProxyRoute(route: string): boolean {
return route.startsWith('/proxy/')
}
/**
* Resolve a RemoteOptions route to a full URL.
* - "/proxy/..." routes → prepend getComfyApiBaseUrl()
* - Everything else → use as-is
*/
export function resolveRoute(route: string): string {
if (isProxyRoute(route)) {
return getComfyApiBaseUrl() + route
}
return route
}
/**
* Get auth headers for a remote request.
* - "/proxy/..." routes → ALWAYS inject auth (comfy-api requires it)
* - Other routes → only inject auth in cloud mode
*/
export async function getRemoteAuthHeaders(
route: string
): Promise<Record<string, any>> {
if (isProxyRoute(route)) {
const authStore = useAuthStore()
const authHeader = await authStore.getAuthHeader()
if (authHeader) {
return { headers: authHeader }
}
}
return {}
}
/**
* Convenience: make an authenticated GET request to a remote route.
*/
export async function fetchRemoteRoute(
route: string,
options: {
params?: Record<string, string>
timeout?: number
signal?: AbortSignal
} = {}
) {
const url = resolveRoute(route)
const authHeaders = await getRemoteAuthHeaders(route)
return axios.get(url, { ...options, ...authHeaders })
}

View File

@@ -5,6 +5,16 @@ import { resultItemType } from '@/schemas/apiSchema'
import { CONTROL_OPTIONS } from '@/types/simplifiedWidget'
const zComboOption = z.union([z.string(), z.number()])
const zRemoteItemSchema = z.object({
value_field: z.string(),
label_field: z.string(),
preview_url_field: z.string().optional(),
preview_type: z.enum(['image', 'video', 'audio']).default('image'),
description_field: z.string().optional(),
search_fields: z.array(z.string()).optional(),
filter_field: z.string().optional()
})
const zRemoteWidgetConfig = z.object({
route: z.string().url().or(z.string().startsWith('/')),
refresh: z.number().gte(128).safe().or(z.number().lte(0).safe()).optional(),
@@ -13,7 +23,8 @@ const zRemoteWidgetConfig = z.object({
refresh_button: z.boolean().optional(),
control_after_refresh: z.enum(['first', 'last']).optional(),
timeout: z.number().gte(0).optional(),
max_retries: z.number().gte(0).optional()
max_retries: z.number().gte(0).optional(),
item_schema: zRemoteItemSchema.optional()
})
const zMultiSelectOption = z.object({
placeholder: z.string().optional(),
@@ -354,6 +365,7 @@ export const zMatchTypeOptions = z.object({
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type RemoteItemSchema = z.infer<typeof zRemoteItemSchema>
export type RemoteWidgetConfig = z.infer<typeof zRemoteWidgetConfig>
export type ComboInputOptions = z.infer<typeof zComboInputOptions>

View File

@@ -21,13 +21,13 @@
"verbatimModuleSyntax": true,
"paths": {
"@/*": ["./src/*"],
"@e2e/*": ["./browser_tests/*"],
"@/utils/formatUtil": [
"./packages/shared-frontend-utils/src/formatUtil.ts"
],
"@/utils/networkUtil": [
"./packages/shared-frontend-utils/src/networkUtil.ts"
]
],
"@tests-ui/*": ["./tests-ui/*"]
},
"typeRoots": ["src/types", "node_modules/@types", "./node_modules"],
"types": [
@@ -49,6 +49,8 @@
"src/types/**/*.d.ts",
"playwright.config.ts",
"playwright.i18n.config.ts",
"tests-ui/**/*",
"vite.config.mts",
"vitest.config.ts"
// "vitest.setup.ts",

View File

@@ -161,6 +161,7 @@ export default defineConfig({
ignored: [
'./browser_tests/**',
'./node_modules/**',
'./tests-ui/**',
'.eslintcache',
'.oxlintrc.json',
'*.config.{ts,mts}',