From 98c327b3c6d3b8c5b5c32efb0bf222f6a14f8dae Mon Sep 17 00:00:00 2001 From: Dante Date: Wed, 29 Apr 2026 05:50:20 +0900 Subject: [PATCH 01/61] test: add unit tests for colorUtil edge cases (#11671) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Extends `colorUtil.test.ts` with boundary tests that the existing suite did not cover: malformed `parseToRgb` inputs, alpha-hex parsing through `parseToRgb`, negative hue normalization in `hsbToRgb`, the full `isTransparent` matrix, HSV-vs-HSB equivalence in `toHexFromFormat`, the bare-hex prefix path, and a non-primary-color round-trip through `hexToHsva` / `hsvaToHex`. ## Changes - **What**: Adds 13 Vitest cases across 5 new `describe` blocks (`parseToRgb edge cases`, `hsbToRgb normalization`, `isTransparent`, `toHexFromFormat`, plus a non-primary round-trip in the existing `hexToHsva / hsvaToHex` block). Uses the existing `vi.mock('es-toolkit/compat')` memoize stub. ## Review Focus - The non-primary palette round-trip allows ±1 per RGB channel because `hsbToRgb` floors while `rgbToHex` rounds; the test asserts the bound rather than exact equality. - `parseToRgb` is exercised with alpha-bearing hex (`#f008`, `#ff000080`); the function returns RGB-only, so the alpha is intentionally discarded. - `toHexFromFormat({h, s, v}, 'hsb')` covers the HSV-shaped object path that wraps `hsbToRgb`. ## Testing \`\`\`bash pnpm exec vitest run src/utils/colorUtil.test.ts pnpm format -- src/utils/colorUtil.test.ts pnpm lint pnpm typecheck pnpm knip \`\`\` 73 tests pass (60 prior + 13 new). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11671-test-add-unit-tests-for-colorUtil-edge-cases-34f6d73d36508136bac9edfae32815ec) by [Unito](https://www.unito.io) --- src/utils/colorUtil.test.ts | 78 ++++++++++++++++++++++++++++++++++++- 1 file changed, 77 insertions(+), 1 deletion(-) diff --git a/src/utils/colorUtil.test.ts b/src/utils/colorUtil.test.ts index 38bba99517..b0c9672f6f 100644 --- a/src/utils/colorUtil.test.ts +++ b/src/utils/colorUtil.test.ts @@ -8,8 +8,10 @@ import { hexToRgb, hsbToRgb, hsvaToHex, + isTransparent, parseToRgb, - rgbToHex + rgbToHex, + toHexFromFormat } from '@/utils/colorUtil' interface ColorTestCase { @@ -207,6 +209,80 @@ describe('colorUtil conversions', () => { expect(hsva.a).toBe(53) expect(hsvaToHex(hsva)).toMatch(/^#ff0000/) }) + + // Note: a round-trip test for non-primary palette colors (e.g. #80c0ff) + // is intentionally NOT included here. The current conversion path drifts + // by 1 channel (hsbToRgb floors, rgbToHex rounds), so encoding that drift + // as a passing assertion would block fixing the underlying user-visible + // ColorPicker bug. Track the source-side fix separately. + }) + + describe('parseToRgb edge cases', () => { + it.each(['', 'not-a-color', '#GGGGGG', 'cmky(1,2,3,4)'])( + 'returns black for unrecognized input %s', + (input) => { + expect(parseToRgb(input)).toEqual({ r: 0, g: 0, b: 0 }) + } + ) + + it('parses 4-digit hex with alpha and ignores the alpha channel in RGB output', () => { + // #f008 == #ff0000 with 53% alpha; parseToRgb returns RGB only. + expect(parseToRgb('#f008')).toEqual({ r: 255, g: 0, b: 0 }) + }) + + it('parses 8-digit hex and ignores the alpha channel in RGB output', () => { + expect(parseToRgb('#ff000080')).toEqual({ r: 255, g: 0, b: 0 }) + }) + }) + + describe('hsbToRgb normalization', () => { + it('normalizes negative hue', () => { + // h = -120 should map to h = 240 (blue) when s and b are both 100. + expect(hsbToRgb({ h: -120, s: 100, b: 100 })).toEqual({ + r: 0, + g: 0, + b: 255 + }) + }) + }) + + describe('isTransparent', () => { + it('returns true for the literal "transparent" keyword', () => { + expect(isTransparent('transparent')).toBe(true) + }) + + it('returns true for 5-char hex with zero alpha', () => { + expect(isTransparent('#abc0')).toBe(true) + }) + + it('returns true for 9-char hex with zero alpha', () => { + expect(isTransparent('#abcdef00')).toBe(true) + }) + + it('returns false for fully opaque hex colors', () => { + expect(isTransparent('#ff0000')).toBe(false) + expect(isTransparent('#ff0000ff')).toBe(false) + }) + }) + + describe('toHexFromFormat', () => { + it('treats an HSV object (with v field) the same as an HSB object', () => { + const hsbObject = { h: 120, s: 100, b: 100 } + const hsvObject = { h: 120, s: 100, v: 100 } + + expect(toHexFromFormat(hsvObject, 'hsb')).toBe( + toHexFromFormat(hsbObject, 'hsb') + ) + expect(toHexFromFormat(hsvObject, 'hsb')).toBe('#00ff00') + }) + + it('returns #000000 for unparseable hsb input', () => { + expect(toHexFromFormat({ h: 0 }, 'hsb')).toBe('#000000') + }) + + it('prefixes a bare 6-digit hex with #', () => { + expect(toHexFromFormat('abcdef', 'hex')).toBe('#abcdef') + }) }) }) describe('colorUtil - adjustColor', () => { From 517da289f6f44327d11cd61bb8df565c6af8cc06 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:02:33 +0100 Subject: [PATCH 02/61] feat: Search - add ghost node following setting and increase opacity (#11365) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds setting to disable the node auto-follow cursor behavior when adding nodes from the search, and increased the visibilty of Vue ghost nodes. ## Changes - **What**: - add setting - increase opacity - add test ## Review Focus ## Screenshots (if applicable) Before image After image ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11365-feat-Search-add-ghost-node-following-setting-and-increase-opacity-3466d73d3650811b9c27ed4cc930816d) by [Unito](https://www.unito.io) --- .../tests/nodeSearchBoxV2Extended.spec.ts | 27 +++ .../searchbox/NodeSearchBoxPopover.test.ts | 205 ++++++++++++++---- .../searchbox/NodeSearchBoxPopover.vue | 3 +- src/locales/en/settings.json | 4 + .../settings/constants/coreSettings.ts | 10 + .../vueNodes/components/LGraphNode.vue | 3 +- src/schemas/apiSchema.ts | 1 + 7 files changed, 208 insertions(+), 45 deletions(-) diff --git a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts index 0c216046d4..3a50e4a887 100644 --- a/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts +++ b/browser_tests/tests/nodeSearchBoxV2Extended.spec.ts @@ -369,5 +369,32 @@ test.describe('Node search box V2 extended', { tag: '@node' }, () => { await expect(searchBoxV2.nodeIdBadge.first()).toBeVisible() await expect(searchBoxV2.nodeIdBadge.first()).toContainText('VAEDecode') }) + + test('Follow-cursor disabled places node without ghost mode', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.NodeSearchBoxImpl.FollowCursor', + false + ) + const { searchBoxV2 } = comfyPage + const initialCount = await comfyPage.nodeOps.getGraphNodesCount() + + await searchBoxV2.open() + + await searchBoxV2.input.fill('KSampler') + await expect(searchBoxV2.results.first()).toBeVisible() + + await searchBoxV2.results.first().click() + await expect(searchBoxV2.input).toBeHidden() + + await expect + .poll(() => comfyPage.nodeOps.getGraphNodesCount()) + .toBe(initialCount + 1) + + await expect( + comfyPage.page.locator('[data-node-id][data-ghost]') + ).toHaveCount(0) + }) }) }) diff --git a/src/components/searchbox/NodeSearchBoxPopover.test.ts b/src/components/searchbox/NodeSearchBoxPopover.test.ts index 38077d3ba9..ec0f3fda0a 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.test.ts +++ b/src/components/searchbox/NodeSearchBoxPopover.test.ts @@ -1,59 +1,34 @@ import { createTestingPinia } from '@pinia/testing' import { render, screen } from '@testing-library/vue' import PrimeVue from 'primevue/config' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { computed, defineComponent, nextTick } from 'vue' import { createI18n } from 'vue-i18n' +import { CORE_SETTINGS } from '@/platform/settings/constants/coreSettings' +import type { Settings } from '@/schemas/apiSchema' import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore' import type { FuseFilter, FuseFilterWithValue } from '@/utils/fuseUtil' import NodeSearchBoxPopover from './NodeSearchBoxPopover.vue' -vi.mock('@/platform/settings/settingStore', () => ({ - useSettingStore: () => ({ - get: vi.fn() - }) +const coreSettingsById = Object.fromEntries(CORE_SETTINGS.map((s) => [s.id, s])) + +const { addNodeOnGraph } = vi.hoisted(() => ({ + addNodeOnGraph: vi.fn() })) vi.mock('@/services/litegraphService', () => ({ useLitegraphService: () => ({ getCanvasCenter: vi.fn(() => [0, 0]), - addNodeOnGraph: vi.fn() - }) -})) - -vi.mock('@/platform/workflow/management/stores/workflowStore', () => ({ - useWorkflowStore: () => ({ - activeWorkflow: null - }) -})) - -vi.mock('@/renderer/core/canvas/canvasStore', () => ({ - useCanvasStore: () => ({ - canvas: null, - getCanvas: vi.fn(() => ({ - linkConnector: { - events: new EventTarget(), - renderLinks: [] - } - })) - }) -})) - -vi.mock('@/stores/nodeDefStore', () => ({ - useNodeDefStore: () => ({ - nodeSearchService: { - nodeFilters: [], - inputTypeFilter: {}, - outputTypeFilter: {} - } + addNodeOnGraph }) })) type EmitAddFilter = ( filter: FuseFilterWithValue ) => void +type EmitAddNode = (nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) => void function createFilter( id: string, @@ -72,26 +47,48 @@ describe('NodeSearchBoxPopover', () => { messages: { en: {} } }) - function renderComponent() { + function renderComponent(settings: Partial = {}) { let emitAddFilter: EmitAddFilter | null = null + let emitAddNodeV1: EmitAddNode | null = null + let emitAddNodeV2: EmitAddNode | null = null const NodeSearchBoxStub = defineComponent({ name: 'NodeSearchBox', props: { filters: { type: Array, default: () => [] } }, - emits: ['addFilter'], + emits: ['addFilter', 'addNode'], setup(props, { emit }) { emitAddFilter = (filter) => emit('addFilter', filter) + emitAddNodeV1 = (nodeDef, dragEvent) => + emit('addNode', nodeDef, dragEvent) const filterCount = computed(() => props.filters.length) return { filterCount } }, template: '{{ filterCount }}' }) + const NodeSearchContentStub = defineComponent({ + name: 'NodeSearchContent', + props: { + filters: { type: Array, default: () => [] } + }, + emits: ['addFilter', 'removeFilter', 'addNode', 'hoverNode'], + setup(_, { emit }) { + emitAddNodeV2 = (nodeDef, dragEvent) => + emit('addNode', nodeDef, dragEvent) + return {} + }, + template: '
' + }) + const pinia = createTestingPinia({ stubActions: false, initialState: { + setting: { + settingValues: settings, + settingsById: coreSettingsById + }, searchBox: { visible: false } } }) @@ -101,6 +98,8 @@ describe('NodeSearchBoxPopover', () => { plugins: [i18n, PrimeVue, pinia], stubs: { NodeSearchBox: NodeSearchBoxStub, + NodeSearchContent: NodeSearchContentStub, + NodePreviewCard: true, Dialog: { template: '
', props: ['visible', 'modal', 'dismissableMask', 'pt'] @@ -109,14 +108,34 @@ describe('NodeSearchBoxPopover', () => { } }) - if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount') - - return { ...result, emitAddFilter: emitAddFilter as EmitAddFilter } + return { + ...result, + get emitAddFilter() { + if (!emitAddFilter) throw new Error('NodeSearchBox stub did not mount') + return emitAddFilter + }, + get emitAddNodeV1() { + if (!emitAddNodeV1) throw new Error('NodeSearchBox stub did not mount') + return emitAddNodeV1 + }, + get emitAddNodeV2() { + if (!emitAddNodeV2) + throw new Error('NodeSearchContent stub did not mount') + return emitAddNodeV2 + } + } } + beforeEach(() => { + addNodeOnGraph.mockReset() + addNodeOnGraph.mockReturnValue(null) + }) + describe('addFilter duplicate prevention', () => { it('should add a filter when no duplicates exist', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -125,7 +144,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should not add a duplicate filter with same id and value', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -136,7 +157,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should allow filters with same id but different values', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -147,7 +170,9 @@ describe('NodeSearchBoxPopover', () => { }) it('should allow filters with different ids but same value', async () => { - const { emitAddFilter } = renderComponent() + const { emitAddFilter } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)' + }) emitAddFilter(createFilter('outputType', 'IMAGE')) await nextTick() @@ -157,4 +182,98 @@ describe('NodeSearchBoxPopover', () => { expect(screen.getByLabelText('filter count')).toHaveTextContent('2') }) }) + + describe('addNode ghost flag (FollowCursor setting)', () => { + const nodeDef = { name: 'KSampler' } as ComfyNodeDefImpl + + it('should default ghost to true when v2 search is active and FollowCursor is unset', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default' + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true }) + ) + }) + + it('should pass ghost: true when v2 search is active and FollowCursor is enabled', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true }) + ) + }) + + it('should pass ghost: false when v2 search is active but FollowCursor is disabled', async () => { + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': false + }) + emitAddNodeV2(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should pass ghost: false when v1 legacy search box is used', async () => { + const { emitAddNodeV1 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'v1 (legacy)', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV1(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should pass ghost: false when litegraph legacy search box is used', async () => { + const { emitAddNodeV1 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'litegraph (legacy)', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV1(nodeDef) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: false }) + ) + }) + + it('should forward the dragEvent through to addNodeOnGraph', async () => { + const dragEvent = new MouseEvent('mousedown') + const { emitAddNodeV2 } = renderComponent({ + 'Comfy.NodeSearchBoxImpl': 'default', + 'Comfy.NodeSearchBoxImpl.FollowCursor': true + }) + emitAddNodeV2(nodeDef, dragEvent) + await nextTick() + + expect(addNodeOnGraph).toHaveBeenCalledWith( + nodeDef, + expect.objectContaining({ pos: expect.any(Array) }), + expect.objectContaining({ ghost: true, dragEvent }) + ) + }) + }) }) diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue index f0e8c08326..c0f5101376 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.vue +++ b/src/components/searchbox/NodeSearchBoxPopover.vue @@ -129,10 +129,11 @@ function closeDialog() { const canvasStore = useCanvasStore() function addNode(nodeDef: ComfyNodeDefImpl, dragEvent?: MouseEvent) { + const followCursor = settingStore.get('Comfy.NodeSearchBoxImpl.FollowCursor') const node = litegraphService.addNodeOnGraph( nodeDef, { pos: getNewNodeLocation() }, - { ghost: useSearchBoxV2.value, dragEvent } + { ghost: useSearchBoxV2.value && followCursor, dragEvent } ) if (!node) return diff --git a/src/locales/en/settings.json b/src/locales/en/settings.json index 22ccf5fdfd..f925dd8e42 100644 --- a/src/locales/en/settings.json +++ b/src/locales/en/settings.json @@ -312,6 +312,10 @@ "name": "Node preview", "tooltip": "Only applies to the default implementation" }, + "Comfy_NodeSearchBoxImpl_FollowCursor": { + "name": "Added nodes follow the cursor", + "tooltip": "When enabled, nodes added from the search box follow the cursor until clicked to place. Only applies to the default implementation." + }, "Comfy_NodeSearchBoxImpl_ShowCategory": { "name": "Show node category in search results", "tooltip": "Only applies to v1 (legacy)" diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index 0407adfa1a..f805a4e067 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -68,6 +68,16 @@ export const CORE_SETTINGS: SettingParams[] = [ type: 'boolean', defaultValue: true }, + { + id: 'Comfy.NodeSearchBoxImpl.FollowCursor', + category: ['Comfy', 'Node Search Box', 'FollowCursor'], + name: 'Added nodes follow the cursor', + tooltip: + 'When enabled, nodes added from the search box follow the cursor until clicked to place. Only applies to the default implementation.', + type: 'boolean', + defaultValue: true, + versionAdded: '1.44.4' + }, { id: 'Comfy.NodeSearchBoxImpl.ShowCategory', category: ['Comfy', 'Node Search Box', 'ShowCategory'], diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index a7d8c6238f..2b11e513dd 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -8,6 +8,7 @@ tabindex="0" :data-node-id="nodeData.id" :data-collapsed="isCollapsed || undefined" + :data-ghost="nodeData.flags?.ghost || undefined" :class=" cn( 'group/node lg-node absolute isolate text-sm', @@ -389,7 +390,7 @@ const muted = computed((): boolean => nodeData.mode === LGraphEventMode.NEVER) const nodeOpacity = computed(() => { const globalOpacity = settingStore.get('Comfy.Node.Opacity') ?? 1 - if (nodeData.flags?.ghost) return globalOpacity * 0.3 + if (nodeData.flags?.ghost) return globalOpacity * 0.6 // For muted/bypassed nodes, apply the 0.5 multiplier on top of global opacity if (bypassed.value || muted.value) { diff --git a/src/schemas/apiSchema.ts b/src/schemas/apiSchema.ts index e3277ada64..3b172a9c84 100644 --- a/src/schemas/apiSchema.ts +++ b/src/schemas/apiSchema.ts @@ -335,6 +335,7 @@ const zSettings = z.object({ 'Comfy.ModelLibrary.AutoLoadAll': z.boolean(), 'Comfy.ModelLibrary.NameFormat': z.enum(['filename', 'title']), 'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(), + 'Comfy.NodeSearchBoxImpl.FollowCursor': z.boolean(), 'Comfy.NodeSearchBoxImpl': z.enum([ 'default', 'v1 (legacy)', From 089051824cda75f679f4a390b389d48052223506 Mon Sep 17 00:00:00 2001 From: pythongosssss <125205205+pythongosssss@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:02:42 +0100 Subject: [PATCH 03/61] test: add tests for link related settings (#11612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ## Changes - **What**: - **Breaking**: - **Dependencies**: ## Review Focus ## Screenshots (if applicable) ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11612-test-add-tests-for-link-related-settings-34c6d73d36508145885bd017162e6fae) by [Unito](https://www.unito.io) --- .../fixtures/components/ContextMenu.ts | 10 +- .../fixtures/helpers/CanvasHelper.ts | 38 +++- .../fixtures/utils/litegraphUtils.ts | 31 +++ .../tests/linkNodeInteractionSettings.spec.ts | 208 ++++++++++++++++++ 4 files changed, 280 insertions(+), 7 deletions(-) create mode 100644 browser_tests/tests/linkNodeInteractionSettings.spec.ts diff --git a/browser_tests/fixtures/components/ContextMenu.ts b/browser_tests/fixtures/components/ContextMenu.ts index fef61ac70d..fab6059baa 100644 --- a/browser_tests/fixtures/components/ContextMenu.ts +++ b/browser_tests/fixtures/components/ContextMenu.ts @@ -4,11 +4,13 @@ import type { Locator, Page } from '@playwright/test' export class ContextMenu { public readonly primeVueMenu: Locator public readonly litegraphMenu: Locator + public readonly litegraphContextMenu: Locator public readonly menuItems: Locator constructor(public readonly page: Page) { this.primeVueMenu = page.locator('.p-contextmenu, .p-menu') this.litegraphMenu = page.locator('.litemenu') + this.litegraphContextMenu = page.locator('.litecontextmenu') this.menuItems = page.locator('.p-menuitem, .litemenu-entry') } @@ -39,7 +41,10 @@ export class ContextMenu { const litegraphVisible = await this.litegraphMenu .isVisible() .catch(() => false) - return primeVueVisible || litegraphVisible + const litegraphContextVisible = await this.litegraphContextMenu + .isVisible() + .catch(() => false) + return primeVueVisible || litegraphVisible || litegraphContextVisible } async assertHasItems(items: string[]): Promise { @@ -71,7 +76,8 @@ export class ContextMenu { async waitForHidden(): Promise { await Promise.all([ this.primeVueMenu.waitFor({ state: 'hidden' }), - this.litegraphMenu.waitFor({ state: 'hidden' }) + this.litegraphMenu.waitFor({ state: 'hidden' }), + this.litegraphContextMenu.waitFor({ state: 'hidden' }) ]) } } diff --git a/browser_tests/fixtures/helpers/CanvasHelper.ts b/browser_tests/fixtures/helpers/CanvasHelper.ts index 485ff654ef..70853df3f2 100644 --- a/browser_tests/fixtures/helpers/CanvasHelper.ts +++ b/browser_tests/fixtures/helpers/CanvasHelper.ts @@ -264,11 +264,39 @@ export class CanvasHelper { await this.page.mouse.up({ button: 'middle' }) } - async disconnectEdge(): Promise { - await this.dragAndDrop( - DefaultGraphPositions.clipTextEncodeNode1InputSlot, - DefaultGraphPositions.emptySpace - ) + async disconnectEdge( + options: { modifiers?: ('Shift' | 'Control' | 'Alt' | 'Meta')[] } = {} + ): Promise { + const { modifiers = [] } = options + for (const mod of modifiers) await this.page.keyboard.down(mod) + try { + await this.dragAndDrop( + DefaultGraphPositions.clipTextEncodeNode1InputSlot, + DefaultGraphPositions.emptySpace + ) + } finally { + for (const mod of modifiers) await this.page.keyboard.up(mod) + } + } + + async middleClick(position: Position): Promise { + await this.mouseClickAt(position, { button: 'middle' }) + } + + async dblclickGroupTitle(title: string): Promise { + const clientPos = await this.page.evaluate((targetTitle) => { + const groups = window.app!.canvas.graph?.groups ?? [] + const group = groups.find( + (g: { title: string }) => g.title === targetTitle + ) + if (!group) return null + const cx = group.pos[0] + group.size[0] / 2 + const cy = group.pos[1] + group.titleHeight / 2 + return window.app!.canvasPosToClientPos([cx, cy]) + }, title) + if (!clientPos) throw new Error(`Group "${title}" not found`) + await this.page.mouse.dblclick(clientPos[0], clientPos[1], { delay: 5 }) + await nextFrame(this.page) } async connectEdge(options: { reverse?: boolean } = {}): Promise { diff --git a/browser_tests/fixtures/utils/litegraphUtils.ts b/browser_tests/fixtures/utils/litegraphUtils.ts index 11263c06c6..0036526a65 100644 --- a/browser_tests/fixtures/utils/litegraphUtils.ts +++ b/browser_tests/fixtures/utils/litegraphUtils.ts @@ -1,5 +1,6 @@ import { expect } from '@playwright/test' +import type { SerialisableLLink } from '@/lib/litegraph/src/types/serialisation' import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema' import { ManageGroupNode } from '@e2e/fixtures/components/ManageGroupNode' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' @@ -169,6 +170,36 @@ class NodeSlotReference { [this.type, this.node.id, this.index] as const ) } + + async getLink(): Promise { + return await this.node.comfyPage.page.evaluate( + ([type, id, index]) => { + const graph = window.app!.canvas.graph! + const node = graph.getNodeById(id) + if (!node) throw new Error(`Node ${id} not found.`) + const linkId = + type === 'input' + ? node.inputs[index].link + : (node.outputs[index].links ?? [])[0] + if (linkId == null) return null + const link = + graph.links instanceof Map + ? graph.links.get(linkId) + : graph.links[linkId] + if (!link) return null + return { + id: link.id, + origin_id: link.origin_id, + origin_slot: link.origin_slot, + target_id: link.target_id, + target_slot: link.target_slot, + type: link.type, + parentId: link.parentId + } + }, + [this.type, this.node.id, this.index] as const + ) + } } export class NodeWidgetReference { diff --git a/browser_tests/tests/linkNodeInteractionSettings.spec.ts b/browser_tests/tests/linkNodeInteractionSettings.spec.ts new file mode 100644 index 0000000000..c3d0af7828 --- /dev/null +++ b/browser_tests/tests/linkNodeInteractionSettings.spec.ts @@ -0,0 +1,208 @@ +import type { ComfyPage } from '@e2e/fixtures/ComfyPage' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '@e2e/fixtures/ComfyPage' +import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions' + +const VAE_DECODE_SAMPLES_INPUT_SLOT = 0 +const DEFAULT_GROUP_TITLE = 'Group' + +test.describe('Link & node interaction settings', { tag: '@canvas' }, () => { + test.describe('Comfy.LinkRelease.Action', () => { + test('"search box" opens node search on link release', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'search box' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.searchBoxV2.input).toBeVisible() + }) + + test('"context menu" opens litegraph connection menu on link release', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'context menu' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.contextMenu.litegraphContextMenu).toBeVisible() + }) + + test('"no action" suppresses both search box and context menu', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'no action' + ) + await comfyPage.canvasOps.disconnectEdge() + await expect(comfyPage.searchBoxV2.input).toBeHidden() + await expect(comfyPage.contextMenu.litegraphContextMenu).toBeHidden() + }) + }) + + test.describe('Comfy.LinkRelease.ActionShift', () => { + test('shift+drag dispatches to ActionShift (not Action)', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.Action', + 'no action' + ) + await comfyPage.settings.setSetting( + 'Comfy.LinkRelease.ActionShift', + 'search box' + ) + + await comfyPage.canvasOps.disconnectEdge({ modifiers: ['Shift'] }) + + await expect(comfyPage.searchBoxV2.input).toBeVisible() + }) + }) + + test.describe('Comfy.Node.DoubleClickTitleToEdit', () => { + test('enabled → double-click on node title opens editor', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.DoubleClickTitleToEdit', + true + ) + const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition()) + await comfyPage.titleEditor.expectVisible() + }) + + test('disabled → double-click on node title stays hidden', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.DoubleClickTitleToEdit', + false + ) + const [node] = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') + await comfyPage.canvasOps.mouseDblclickAt(await node.getTitlePosition()) + await comfyPage.titleEditor.expectHidden() + }) + }) + + test.describe('Comfy.Group.DoubleClickTitleToEdit', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.workflow.loadWorkflow('groups/single_group_only') + }) + + test('enabled → double-click on group title opens editor', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Group.DoubleClickTitleToEdit', + true + ) + await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE) + await comfyPage.titleEditor.expectVisible() + }) + + test('disabled → double-click on group title stays hidden', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Group.DoubleClickTitleToEdit', + false + ) + await comfyPage.canvasOps.dblclickGroupTitle(DEFAULT_GROUP_TITLE) + await comfyPage.titleEditor.expectHidden() + }) + }) + + test.describe('Comfy.Node.BypassAllLinksOnDelete', () => { + test('enabled → deleting KSampler bridges EmptyLatentImage → VAEDecode.samples', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.BypassAllLinksOnDelete', + true + ) + const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler') + const [emptyLatent] = + await comfyPage.nodeOps.getNodeRefsByType('EmptyLatentImage') + const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode') + const vaeSamplesInput = await vaeDecode.getInput( + VAE_DECODE_SAMPLES_INPUT_SLOT + ) + + await test.step('precondition: KSampler feeds VAEDecode.samples', async () => { + expect( + (await vaeSamplesInput.getLink())?.origin_id, + 'VAEDecode.samples should originate from KSampler before delete' + ).toBe(kSampler.id) + }) + + await kSampler.delete() + + await expect + .poll(async () => (await vaeSamplesInput.getLink())?.origin_id ?? null) + .toBe(emptyLatent.id) + }) + + test('disabled → deleting KSampler drops VAEDecode.samples', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.BypassAllLinksOnDelete', + false + ) + const [kSampler] = await comfyPage.nodeOps.getNodeRefsByType('KSampler') + const [vaeDecode] = await comfyPage.nodeOps.getNodeRefsByType('VAEDecode') + const vaeSamplesInput = await vaeDecode.getInput( + VAE_DECODE_SAMPLES_INPUT_SLOT + ) + + await kSampler.delete() + + await expect.poll(() => vaeSamplesInput.getLink()).toBeNull() + }) + }) + + test.describe('Comfy.Node.MiddleClickRerouteNode', () => { + async function countReroutes(comfyPage: ComfyPage): Promise { + return (await comfyPage.nodeOps.getNodeRefsByType('Reroute')).length + } + + test('enabled → middle-click on an output slot creates a Reroute', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.MiddleClickRerouteNode', + true + ) + const before = await countReroutes(comfyPage) + + await comfyPage.canvasOps.middleClick( + DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + ) + + await expect.poll(() => countReroutes(comfyPage)).toBe(before + 1) + }) + + test('disabled → middle-click on an output slot does nothing', async ({ + comfyPage + }) => { + await comfyPage.settings.setSetting( + 'Comfy.Node.MiddleClickRerouteNode', + false + ) + const before = await countReroutes(comfyPage) + + await comfyPage.canvasOps.middleClick( + DefaultGraphPositions.loadCheckpointNodeClipOutputSlot + ) + await comfyPage.nextFrame() + + expect(await countReroutes(comfyPage)).toBe(before) + }) + }) +}) From c168c37c9467ed2db732a35a122404c71b99dc12 Mon Sep 17 00:00:00 2001 From: Alexander Brown Date: Tue, 28 Apr 2026 15:05:28 -0700 Subject: [PATCH 04/61] chore: update comfyui-ci-container to 0.0.17 (#11569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update `comfyui-ci-container` image to `0.0.17` across all CI workflows. | Workflow | Before | After | |---|---|---| | `ci-perf-report.yaml` | `0.0.12` | `0.0.17` | | `ci-tests-e2e.yaml` (×2) | `0.0.16` | `0.0.17` | | `pr-update-playwright-expectations.yaml` | `0.0.16` | `0.0.17` | ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11569-chore-update-comfyui-ci-container-to-0-0-17-34b6d73d365081b8a52ac995855354cb) by [Unito](https://www.unito.io) Co-authored-by: Amp --- .github/workflows/ci-perf-report.yaml | 2 +- .github/workflows/ci-tests-e2e.yaml | 4 ++-- .github/workflows/pr-update-playwright-expectations.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-perf-report.yaml b/.github/workflows/ci-perf-report.yaml index b265e0ebb7..b3c7707bd7 100644 --- a/.github/workflows/ci-perf-report.yaml +++ b/.github/workflows/ci-perf-report.yaml @@ -21,7 +21,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.12 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/ci-tests-e2e.yaml b/.github/workflows/ci-tests-e2e.yaml index 97785d254e..47101ef2d0 100644 --- a/.github/workflows/ci-tests-e2e.yaml +++ b/.github/workflows/ci-tests-e2e.yaml @@ -82,7 +82,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 60 container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} @@ -140,7 +140,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr-update-playwright-expectations.yaml b/.github/workflows/pr-update-playwright-expectations.yaml index 308c54b74f..3291d45767 100644 --- a/.github/workflows/pr-update-playwright-expectations.yaml +++ b/.github/workflows/pr-update-playwright-expectations.yaml @@ -77,7 +77,7 @@ jobs: needs: setup runs-on: ubuntu-latest container: - image: ghcr.io/comfy-org/comfyui-ci-container:0.0.16 + image: ghcr.io/comfy-org/comfyui-ci-container:0.0.17 credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} From e7640d414ba1a7cc92765b8abd47f0c3ef894b44 Mon Sep 17 00:00:00 2001 From: Kelly Yang <124ykl@gmail.com> Date: Tue, 28 Apr 2026 16:13:12 -0700 Subject: [PATCH 05/61] test: add E2E tests for ActionBarButtons toolbar component (#11561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Add E2E tests for the `ActionBarButtons` toolbar component (FE-111) - Add `data-testid="action-bar-buttons"` to the container div for stable test targeting - Register `TestIds.topbar.actionBarButtons` in `selectors.ts` ## Changes - `browser_tests/tests/actionBarButtons.spec.ts` — 6 tests across 5 scenarios: empty state, button rendering, icon rendering, multiple buttons, click handler, mobile label hiding - `src/components/topbar/ActionBarButtons.vue` — adds `data-testid` to container - `browser_tests/fixtures/selectors.ts` — registers new test ID --- > [!NOTE] > **Low Risk** > Primarily adds Playwright coverage and a `data-testid` attribute; runtime behavior is unchanged aside from an extra DOM attribute. > > **Overview** > Adds a new Playwright spec (`actionBarButtons.spec.ts`) that verifies the ActionBarButtons container empty state, rendering (label/icon), multiple buttons, click handler execution, and mobile label-hiding behavior by registering buttons via `window.app!.registerExtension`. > > Updates the UI and test selector plumbing by adding `data-testid="action-bar-buttons"` to `ActionBarButtons.vue` and exposing it as `TestIds.topbar.actionBarButtons` for stable E2E targeting. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 80f90d1f1d97ccff04ce4e3d088750a36198122e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-11561-test-add-E2E-tests-for-ActionBarButtons-toolbar-component-34b6d73d36508153874fda856a78817f) by [Unito](https://www.unito.io) --- browser_tests/fixtures/selectors.ts | 3 +- browser_tests/tests/actionBarButtons.spec.ts | 140 +++++++++++++++++++ src/components/topbar/ActionBarButtons.vue | 5 +- 3 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 browser_tests/tests/actionBarButtons.spec.ts diff --git a/browser_tests/fixtures/selectors.ts b/browser_tests/fixtures/selectors.ts index 6d24877834..917ed749b4 100644 --- a/browser_tests/fixtures/selectors.ts +++ b/browser_tests/fixtures/selectors.ts @@ -89,7 +89,8 @@ export const TestIds = { subscribeButton: 'topbar-subscribe-button', loginButton: 'login-button', loginButtonPopover: 'login-button-popover', - loginButtonPopoverLearnMore: 'login-button-popover-learn-more' + loginButtonPopoverLearnMore: 'login-button-popover-learn-more', + actionBarButtons: 'action-bar-buttons' }, nodeLibrary: { bookmarksSection: 'node-library-bookmarks-section' diff --git a/browser_tests/tests/actionBarButtons.spec.ts b/browser_tests/tests/actionBarButtons.spec.ts new file mode 100644 index 0000000000..fc015c5417 --- /dev/null +++ b/browser_tests/tests/actionBarButtons.spec.ts @@ -0,0 +1,140 @@ +import type { Page } from '@playwright/test' +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage' +import { TestIds } from '@e2e/fixtures/selectors' + +const ICON_CLASS = 'icon-[lucide--star]' +const BUTTON_LABEL = 'Test Action' +const BUTTON_TOOLTIP = 'Test action tooltip' + +async function registerTestButton( + page: Page, + opts: { + name?: string + icon?: string + label?: string + tooltip?: string + } = {} +): Promise { + await page.evaluate( + ({ name, icon, label, tooltip }) => { + window.app!.registerExtension({ + name, + actionBarButtons: [{ icon, label, tooltip, onClick: () => {} }] + }) + }, + { + name: opts.name ?? 'TestActionBarButton', + icon: opts.icon ?? ICON_CLASS, + label: opts.label ?? BUTTON_LABEL, + tooltip: opts.tooltip ?? BUTTON_TOOLTIP + } + ) +} + +test.describe('ActionBar Buttons', { tag: ['@ui'] }, () => { + test.describe('Empty state', () => { + test('container is hidden when no extension registers buttons', async ({ + comfyPage + }) => { + await expect( + comfyPage.page.getByTestId(TestIds.topbar.actionBarButtons) + ).toBeHidden() + }) + }) + + test.describe('Button rendering', () => { + test('registered button is visible with correct label', async ({ + comfyPage + }) => { + await registerTestButton(comfyPage.page) + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect(container).toBeVisible() + await expect( + container.getByRole('button', { name: BUTTON_TOOLTIP }) + ).toBeVisible() + await expect(container.getByText(BUTTON_LABEL)).toBeVisible() + }) + + test('button icon is rendered', async ({ comfyPage }) => { + await registerTestButton(comfyPage.page) + const icon = comfyPage.page + .getByTestId(TestIds.topbar.actionBarButtons) + .getByRole('button', { name: BUTTON_TOOLTIP }) + .locator('i') + await expect(icon).toHaveClass(ICON_CLASS) + }) + + test('multiple registered buttons all appear', async ({ comfyPage }) => { + await comfyPage.page.evaluate(() => { + window.app!.registerExtension({ + name: 'TestActionBarButtons', + actionBarButtons: [ + { + icon: 'icon-[lucide--star]', + label: 'First', + tooltip: 'First action', + onClick: () => {} + }, + { + icon: 'icon-[lucide--heart]', + label: 'Second', + tooltip: 'Second action', + onClick: () => {} + } + ] + }) + }) + + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect( + container.getByRole('button', { name: 'First action' }) + ).toBeVisible() + await expect( + container.getByRole('button', { name: 'Second action' }) + ).toBeVisible() + }) + }) + + test.describe('Click handler', () => { + test('clicking a button fires its onClick handler', async ({ + comfyPage + }) => { + const onClickFired = comfyPage.page.evaluate( + ({ icon, label, tooltip }) => + new Promise((resolve) => { + window.app!.registerExtension({ + name: 'TestActionBarButton', + actionBarButtons: [ + { icon, label, tooltip, onClick: () => resolve(true) } + ] + }) + }), + { icon: ICON_CLASS, label: BUTTON_LABEL, tooltip: BUTTON_TOOLTIP } + ) + + const button = comfyPage.page + .getByTestId(TestIds.topbar.actionBarButtons) + .getByRole('button', { name: BUTTON_TOOLTIP }) + await button.click() + + await expect(onClickFired).resolves.toBe(true) + }) + }) + + test.describe('Mobile layout', { tag: ['@mobile'] }, () => { + test('button label is hidden on mobile viewport', async ({ comfyPage }) => { + await registerTestButton(comfyPage.page) + const container = comfyPage.page.getByTestId( + TestIds.topbar.actionBarButtons + ) + await expect(container).toBeVisible() + await expect(container.getByText(BUTTON_LABEL)).toBeHidden() + }) + }) +}) diff --git a/src/components/topbar/ActionBarButtons.vue b/src/components/topbar/ActionBarButtons.vue index e87b1d62f5..15934d5182 100644 --- a/src/components/topbar/ActionBarButtons.vue +++ b/src/components/topbar/ActionBarButtons.vue @@ -1,5 +1,8 @@