import type { Locator } from '@playwright/test' import { expect } from '@playwright/test' import type { Position } from '@vueuse/core' import { comfyPageFixture as test, testComfySnapToGridGridSize } from '@e2e/fixtures/ComfyPage' import type { ComfyPage } from '@e2e/fixtures/ComfyPage' import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions' import { TestIds } from '@e2e/fixtures/selectors' import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils' import type { WorkspaceStore } from '@e2e/types/globals' test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled') // Wait for the legacy menu to appear and canvas to settle after layout shift. await comfyPage.page.locator('.comfy-menu').waitFor({ state: 'visible' }) await comfyPage.nextFrame() }) test.describe('Item Interaction', { tag: ['@screenshot', '@node'] }, () => { test('Can select/delete all items', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items') await comfyPage.canvas.press('Control+a') await expect(comfyPage.canvas).toHaveScreenshot('selected-all.png') await comfyPage.canvas.press('Delete') await expect(comfyPage.canvas).toHaveScreenshot('deleted-all.png') }) test('Can pin/unpin items with keyboard shortcut', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('groups/mixed_graph_items') await comfyPage.canvas.press('Control+a') await comfyPage.keyboard.press('KeyP') await expect(comfyPage.canvas).toHaveScreenshot('pinned-all.png') await comfyPage.keyboard.press('KeyP') await expect(comfyPage.canvas).toHaveScreenshot('unpinned-all.png') }) }) test.describe('Node Interaction', () => { test('Can enter prompt', async ({ comfyPage }) => { const textBox = comfyPage.widgetTextBox await textBox.click() await textBox.fill('Hello World') await expect(textBox).toHaveValue('Hello World') await textBox.fill('Hello World 2') await expect(textBox).toHaveValue('Hello World 2') }) test.describe('Node Selection', () => { const multiSelectModifiers = ['Control', 'Shift', 'Meta'] as const multiSelectModifiers.forEach((modifier) => { test(`Can add multiple nodes to selection using ${modifier}+Click`, async ({ comfyPage }) => { const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') for (const node of clipNodes) { await node.click('title', { modifiers: [modifier] }) } await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(clipNodes.length) }) }) test( '@2x Can highlight selected', { tag: '@screenshot' }, async ({ comfyPage }) => { await expect(comfyPage.canvas).toHaveScreenshot('default.png') await comfyPage.canvas.click({ position: DefaultGraphPositions.textEncodeNode1 }) await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node1.png') await comfyPage.canvas.click({ position: DefaultGraphPositions.textEncodeNode2 }) await comfyPage.expectScreenshot(comfyPage.canvas, 'selected-node2.png') } ) const dragSelectNodes = async ( comfyPage: ComfyPage, clipNodes: NodeReference[] ) => { const clipNode1Pos = await clipNodes[0].getPosition() const clipNode2Pos = await clipNodes[1].getPosition() const offset = 64 await comfyPage.page.keyboard.down('Meta') await comfyPage.canvasOps.dragAndDrop( { x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset, y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset }, { x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset, y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset } ) await comfyPage.page.keyboard.up('Meta') } test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => { const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') await dragSelectNodes(comfyPage, clipNodes) await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(clipNodes.length) }) test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({ comfyPage }) => { const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') const getPositions = () => Promise.all(clipNodes.map((node) => node.getPosition())) const testDirection = async ({ direction, expectedPosition }: { direction: string expectedPosition: (originalPosition: Position) => Position }) => { const originalPositions = await getPositions() await dragSelectNodes(comfyPage, clipNodes) await comfyPage.command.executeCommand( `Comfy.Canvas.MoveSelectedNodes.${direction}` ) await comfyPage.canvas.press(`Control+Arrow${direction}`) const newPositions = await getPositions() expect(newPositions).toEqual(originalPositions.map(expectedPosition)) } await testDirection({ direction: 'Down', expectedPosition: (originalPosition) => ({ ...originalPosition, y: originalPosition.y + testComfySnapToGridGridSize }) }) await testDirection({ direction: 'Right', expectedPosition: (originalPosition) => ({ ...originalPosition, x: originalPosition.x + testComfySnapToGridGridSize }) }) await testDirection({ direction: 'Up', expectedPosition: (originalPosition) => ({ ...originalPosition, y: originalPosition.y - testComfySnapToGridGridSize }) }) await testDirection({ direction: 'Left', expectedPosition: (originalPosition) => ({ ...originalPosition, x: originalPosition.x - testComfySnapToGridGridSize }) }) }) }) test('Can drag node', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.nodeOps.dragTextEncodeNode2() // Move mouse away to avoid hover highlight on the node at the drop position. await comfyPage.canvasOps.moveMouseToEmptyArea() await comfyPage.expectScreenshot(comfyPage.canvas, 'dragged-node1.png', { maxDiffPixels: 50 }) }) test.describe('Node Duplication', () => { test.beforeEach(async ({ comfyPage }) => { // Pin this suite to the legacy canvas path so Alt+drag exercises // LGraphCanvas, not the Vue node drag handler. await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false) }) test('Can duplicate a regular node via Alt+drag', async ({ comfyPage }) => { const before = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') expect( before, 'Expected exactly 2 CLIPTextEncode nodes in default graph' ).toHaveLength(2) const target = before[0] const pos = await target.getPosition() const src = { x: pos.x + 16, y: pos.y + 16 } await comfyPage.page.mouse.move(src.x, src.y) await comfyPage.page.keyboard.down('Alt') try { await comfyPage.page.mouse.down() await comfyPage.nextFrame() await comfyPage.page.mouse.move(src.x + 120, src.y + 80, { steps: 20 }) await comfyPage.page.mouse.up() await comfyPage.nextFrame() } finally { await comfyPage.page.keyboard.up('Alt') } await comfyPage.canvasOps.moveMouseToEmptyArea() await expect .poll( async () => (await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode')).length ) .toBe(3) expect(await target.exists()).toBe(true) }) }) test.describe('Edge Interaction', { tag: '@screenshot' }, () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.LinkRelease.Action', 'no action' ) await comfyPage.settings.setSetting( 'Comfy.LinkRelease.ActionShift', 'no action' ) }) // Test both directions of edge connection. ;[{ reverse: false }, { reverse: true }].forEach(({ reverse }) => { test(`Can disconnect/connect edge ${reverse ? 'reverse' : 'normal'}`, async ({ comfyPage }) => { await comfyPage.canvasOps.disconnectEdge() await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png') await comfyPage.canvasOps.connectEdge({ reverse }) // Move mouse to empty area to avoid slot highlight. await comfyPage.canvasOps.moveMouseToEmptyArea() // Litegraph renders edge with a slight offset. await expect(comfyPage.canvas).toHaveScreenshot('default.png', { maxDiffPixels: 50 }) }) }) test('Can move link', async ({ comfyPage }) => { await comfyPage.canvasOps.dragAndDrop( DefaultGraphPositions.clipTextEncodeNode1InputSlot, DefaultGraphPositions.emptySpace ) await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png') await comfyPage.canvasOps.dragAndDrop( DefaultGraphPositions.clipTextEncodeNode2InputSlot, DefaultGraphPositions.clipTextEncodeNode1InputSlot ) await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png') }) test('Can copy link by shift-drag existing link', async ({ comfyPage }) => { await comfyPage.canvasOps.dragAndDrop( DefaultGraphPositions.clipTextEncodeNode1InputSlot, DefaultGraphPositions.emptySpace ) await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png') await comfyPage.page.keyboard.down('Shift') await comfyPage.canvasOps.dragAndDrop( DefaultGraphPositions.clipTextEncodeNode2InputLinkPath, DefaultGraphPositions.clipTextEncodeNode1InputSlot ) await comfyPage.page.keyboard.up('Shift') await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png') }) test('Auto snap&highlight when dragging link over node', async ({ comfyPage, comfyMouse }) => { await comfyPage.settings.setSetting('Comfy.Node.AutoSnapLinkToSlot', true) await comfyPage.settings.setSetting('Comfy.Node.SnapHighlightsNode', true) await comfyMouse.move(DefaultGraphPositions.clipTextEncodeNode1InputSlot) await comfyMouse.drag(DefaultGraphPositions.clipTextEncodeNode2InputSlot) await expect(comfyPage.canvas).toHaveScreenshot('snapped-highlighted.png') }) }) test( 'Can adjust widget value', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.nodeOps.adjustEmptyLatentWidth() await expect(comfyPage.canvas).toHaveScreenshot( 'adjusted-widget-value.png' ) } ) test('Link snap to slot', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('links/snap_to_slot') await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot.png') const outputSlotPos = { x: 406, y: 333 } const samplerNodeCenterPos = { x: 748, y: 77 } await comfyPage.canvasOps.dragAndDrop(outputSlotPos, samplerNodeCenterPos) await expect(comfyPage.canvas).toHaveScreenshot('snap_to_slot_linked.png') }) test( 'Can batch move links by drag with shift', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('links/batch_move_links') await expect(comfyPage.canvas).toHaveScreenshot('batch_move_links.png') const outputSlot1Pos = { x: 304, y: 127 } const outputSlot2Pos = { x: 307, y: 310 } await comfyPage.page.keyboard.down('Shift') await comfyPage.canvasOps.dragAndDrop(outputSlot1Pos, outputSlot2Pos) await comfyPage.page.keyboard.up('Shift') await expect(comfyPage.canvas).toHaveScreenshot( 'batch_move_links_moved.png' ) } ) test( 'Can batch disconnect links with ctrl+alt+click', { tag: '@screenshot' }, async ({ comfyPage }) => { const loadCheckpointClipSlotPos = { x: 332, y: 508 } await comfyPage.canvas.click({ modifiers: ['Control', 'Alt'], position: loadCheckpointClipSlotPos }) await comfyPage.expectScreenshot( comfyPage.canvas, 'batch-disconnect-links-disconnected.png' ) } ) test( 'Can toggle dom widget node open/closed', { tag: '@screenshot' }, async ({ comfyPage }) => { // Find the node whose collapse toggler matches the hardcoded position. // getNodeRefsByType order is non-deterministic, so identify by proximity. const nodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') const togglerPos = DefaultGraphPositions.textEncodeNodeToggler let targetNode = nodes[0] let minDist = Infinity for (const n of nodes) { const pos = await n.getPosition() const dist = Math.hypot(pos.x - togglerPos.x, pos.y - togglerPos.y) if (dist < minDist) { minDist = dist targetNode = n } } await expect(comfyPage.canvas).toHaveScreenshot('default.png') await comfyPage.canvas.click({ position: togglerPos }) await expect.poll(() => targetNode.isCollapsed()).toBe(true) await expect(comfyPage.canvas).toHaveScreenshot( 'text-encode-toggled-off.png' ) // Wait for the double-click window (300ms) to expire so the next // click at the same position isn't interpreted as a double-click. await expect .poll(() => comfyPage.page.evaluate(() => { const pointer = window.app!.canvas.pointer if (!pointer.eLastDown) return true return performance.now() - pointer.eLastDown.timeStamp > 300 }) ) .toBe(true) await comfyPage.canvas.click({ position: togglerPos }) await expect.poll(() => targetNode.isCollapsed()).toBe(false) // Move mouse away to avoid hover highlight differences. await comfyPage.canvasOps.moveMouseToEmptyArea() await comfyPage.expectScreenshot( comfyPage.canvas, 'text-encode-toggled-back-open.png' ) } ) test('Can close prompt dialog with canvas click (number widget)', async ({ comfyPage }) => { const numberWidgetPos = { x: 724, y: 645 } await comfyPage.canvas.click({ position: numberWidgetPos }) const legacyPrompt = comfyPage.page.locator('.graphdialog') await expect(legacyPrompt).toBeVisible() // LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256). // Retry the canvas click until the dialog actually closes. await expect(async () => { await comfyPage.canvas.click({ position: { x: 10, y: 10 } }) await expect(legacyPrompt).toBeHidden({ timeout: 500 }) }).toPass({ timeout: 5000 }) }) test('Can close prompt dialog with canvas click (text widget)', async ({ comfyPage }) => { const textWidgetPos = { x: 167, y: 143 } await comfyPage.workflow.loadWorkflow('nodes/single_save_image_node') await comfyPage.canvas.click({ position: textWidgetPos }) const legacyPrompt = comfyPage.page.locator('.graphdialog') await expect(legacyPrompt).toBeVisible() // LiteGraph's graphdialog has a 256ms dismiss guard (Date.now() - clickTime > 256). // Retry the canvas click until the dialog actually closes. await expect(async () => { await comfyPage.canvas.click({ position: { x: 10, y: 10 } }) await expect(legacyPrompt).toBeHidden({ timeout: 500 }) }).toPass({ timeout: 5000 }) }) test( 'Can double click node title to edit', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await comfyPage.canvas.dblclick({ position: { x: 50, y: 10 }, delay: 5 }) await comfyPage.page.keyboard.type('Hello World') await comfyPage.page.keyboard.press('Enter') await expect(comfyPage.canvas).toHaveScreenshot('node-title-edited.png') } ) test('Double click node body does not trigger edit', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await comfyPage.canvas.dblclick({ position: { x: 50, y: 50 }, delay: 5 }) await expect(comfyPage.page.locator('.node-title-editor')).toHaveCount(0) }) test( 'Can group selected nodes', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.GroupSelectedNodes.Padding', 10 ) await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)']) await comfyPage.page.keyboard.down('Control') await comfyPage.page.keyboard.press('KeyG') await comfyPage.page.keyboard.up('Control') await comfyPage.nextFrame() // Confirm group title await comfyPage.keyboard.press('Enter') await expect(comfyPage.canvas).toHaveScreenshot( 'group-selected-nodes.png' ) } ) test( 'Can fit group to contents', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('groups/oversized_group') await expect .poll(() => comfyPage.page.evaluate(() => { const group = window.app!.graph.groups[0] return group ? [group.size[0], group.size[1]] : null }) ) .not.toBeNull() const initialGroupSize = await comfyPage.page.evaluate(() => { const group = window.app!.graph.groups[0] return group ? [group.size[0], group.size[1]] : null }) await comfyPage.keyboard.selectAll() await comfyPage.nextFrame() await comfyPage.command.executeCommand('Comfy.Graph.FitGroupToContents') await expect .poll(() => comfyPage.page.evaluate(() => { const group = window.app!.graph.groups[0] return group ? [group.size[0], group.size[1]] : null }) ) .not.toEqual(initialGroupSize) await expect(comfyPage.canvas).toHaveScreenshot( 'group-fit-to-contents.png' ) } ) test('Can pin/unpin nodes', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)']) const nodeRef = ( await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)') )[0] await comfyPage.command.executeCommand( 'Comfy.Canvas.ToggleSelectedNodes.Pin' ) await expect.poll(() => nodeRef.isPinned()).toBe(true) await expect(comfyPage.canvas).toHaveScreenshot('nodes-pinned.png') await comfyPage.command.executeCommand( 'Comfy.Canvas.ToggleSelectedNodes.Pin' ) await expect.poll(() => nodeRef.isPinned()).toBe(false) await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png') }) test( 'Can bypass/unbypass nodes with keyboard shortcut', { tag: '@screenshot' }, async ({ comfyPage }) => { await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)']) const nodeRef = ( await comfyPage.nodeOps.getNodeRefsByTitle('CLIP Text Encode (Prompt)') )[0] await comfyPage.canvas.press('Control+b') await expect.poll(() => nodeRef.isBypassed()).toBe(true) await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png') await comfyPage.canvas.press('Control+b') await expect.poll(() => nodeRef.isBypassed()).toBe(false) await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png') } ) }) test.describe('Group Interaction', { tag: '@screenshot' }, () => { test('Can double click group title to edit', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('groups/single_group') await comfyPage.canvas.dblclick({ position: { x: 50, y: 10 }, delay: 5 }) await comfyPage.page.keyboard.type('Hello World') await comfyPage.page.keyboard.press('Enter') await expect(comfyPage.canvas).toHaveScreenshot('group-title-edited.png') }) }) test.describe('Canvas Interaction', { tag: '@screenshot' }, () => { test('Can zoom in/out', async ({ comfyPage }) => { await comfyPage.canvasOps.zoom(-100) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in.png') await comfyPage.canvasOps.zoom(200) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png') }) test('Can zoom very far out', async ({ comfyPage }) => { await comfyPage.canvasOps.zoom(100, 12) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-very-far-out.png') await comfyPage.canvasOps.zoom(-100, 12) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-back-in.png') }) test('Can zoom in/out with ctrl+shift+vertical-drag', async ({ comfyPage }) => { await comfyPage.page.keyboard.down('Control') await comfyPage.page.keyboard.down('Shift') await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 }) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png') await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 }) await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png') await comfyPage.canvasOps.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 }) await expect(comfyPage.canvas).toHaveScreenshot( 'zoomed-default-ctrl-shift.png' ) await comfyPage.page.keyboard.up('Control') await comfyPage.page.keyboard.up('Shift') }) test('Can zoom in/out after decreasing canvas zoom speed setting', async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.05) await comfyPage.canvasOps.zoom(-100, 4) await expect(comfyPage.canvas).toHaveScreenshot( 'zoomed-in-low-zoom-speed.png' ) await comfyPage.canvasOps.zoom(100, 8) await expect(comfyPage.canvas).toHaveScreenshot( 'zoomed-out-low-zoom-speed.png' ) await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1) }) test('Can zoom in/out after increasing canvas zoom speed', async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.5) await comfyPage.canvasOps.zoom(-100, 4) await expect(comfyPage.canvas).toHaveScreenshot( 'zoomed-in-high-zoom-speed.png' ) await comfyPage.canvasOps.zoom(100, 8) await expect(comfyPage.canvas).toHaveScreenshot( 'zoomed-out-high-zoom-speed.png' ) await comfyPage.settings.setSetting('Comfy.Graph.ZoomSpeed', 1.1) }) test('Can pan', async ({ comfyPage }) => { await comfyPage.canvasOps.pan({ x: 200, y: 200 }) await expect(comfyPage.canvas).toHaveScreenshot('panned.png') }) test('Cursor style changes when panning', async ({ comfyPage }) => { const getCursorStyle = async () => { return await comfyPage.page.evaluate(() => { return ( document.getElementById('graph-canvas')!.style.cursor || 'default' ) }) } await comfyPage.page.mouse.move(10, 10) await expect.poll(() => getCursorStyle()).toBe('default') await comfyPage.page.mouse.down() await expect.poll(() => getCursorStyle()).toBe('grabbing') // Move mouse should not alter cursor style. await comfyPage.page.mouse.move(10, 20) await expect.poll(() => getCursorStyle()).toBe('grabbing') await comfyPage.page.mouse.up() await expect.poll(() => getCursorStyle()).toBe('default') await comfyPage.page.keyboard.down('Space') await expect.poll(() => getCursorStyle()).toBe('grab') await comfyPage.page.mouse.down() await expect.poll(() => getCursorStyle()).toBe('grabbing') await comfyPage.page.mouse.up() await expect.poll(() => getCursorStyle()).toBe('grab') await comfyPage.page.keyboard.up('Space') await expect.poll(() => getCursorStyle()).toBe('default') }) // https://github.com/Comfy-Org/litegraph.js/pull/424 test('Properly resets dragging state after pan mode sequence', async ({ comfyPage }) => { const getCursorStyle = async () => { return await comfyPage.page.evaluate(() => { return ( document.getElementById('graph-canvas')!.style.cursor || 'default' ) }) } // Initial state check await comfyPage.page.mouse.move(10, 10) await expect.poll(() => getCursorStyle()).toBe('default') // Click and hold await comfyPage.page.mouse.down() await expect.poll(() => getCursorStyle()).toBe('grabbing') // Press space while holding click await comfyPage.page.keyboard.down('Space') await expect.poll(() => getCursorStyle()).toBe('grabbing') // Release click while space is still down await comfyPage.page.mouse.up() await expect.poll(() => getCursorStyle()).toBe('grab') // Release space await comfyPage.page.keyboard.up('Space') await expect.poll(() => getCursorStyle()).toBe('default') // Move mouse - cursor should remain default await comfyPage.page.mouse.move(20, 20) await expect.poll(() => getCursorStyle()).toBe('default') }) test('Can pan when dragging a link', async ({ comfyPage, comfyMouse }) => { const posSlot1 = DefaultGraphPositions.clipTextEncodeNode1InputSlot await comfyMouse.move(posSlot1) const posEmpty = DefaultGraphPositions.emptySpace await comfyMouse.drag(posEmpty) await expect(comfyPage.canvas).toHaveScreenshot('dragging-link1.png') await comfyPage.page.keyboard.down('Space') await comfyMouse.mouse.move(posEmpty.x + 100, posEmpty.y + 100) // Canvas should be panned. await expect(comfyPage.canvas).toHaveScreenshot( 'panning-when-dragging-link.png' ) await comfyPage.page.keyboard.up('Space') await comfyMouse.move(posEmpty) // Should be back to dragging link mode when space is released. await expect(comfyPage.canvas).toHaveScreenshot('dragging-link2.png') await comfyMouse.drop() }) test('Can pan very far and back', async ({ comfyPage }) => { // intentionally slice the edge of where the clip text encode dom widgets are await comfyPage.canvasOps.pan({ x: -800, y: -300 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-step-one.png') await comfyPage.canvasOps.pan({ x: -200, y: 0 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-step-two.png') await comfyPage.canvasOps.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-far-away.png') await comfyPage.canvasOps.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-back-from-far.png') await comfyPage.canvasOps.pan({ x: 200, y: 0 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-two.png') await comfyPage.canvasOps.pan({ x: 800, y: 300 }, { x: 1000, y: 10 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png') }) test('@mobile Can pan with touch', async ({ comfyPage }) => { await comfyPage.closeMenu() await comfyPage.canvasOps.panWithTouch({ x: 200, y: 200 }) await expect(comfyPage.canvas).toHaveScreenshot('panned-touch.png') }) }) test.describe('Widget Interaction', () => { test('Undo text input', async ({ comfyPage }) => { const textBox = comfyPage.widgetTextBox await textBox.click() await textBox.fill('') await expect(textBox).toHaveValue('') await textBox.fill('Hello World') await expect(textBox).toHaveValue('Hello World') await comfyPage.keyboard.undo(null) await expect(textBox).toHaveValue('') }) test('Undo attention edit', async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.EditAttention.Delta', 0.05) const textBox = comfyPage.widgetTextBox await textBox.click() await textBox.fill('1girl') await expect(textBox).toHaveValue('1girl') await textBox.selectText() await comfyPage.keyboard.moveUp(null) await expect(textBox).toHaveValue('(1girl:1.05)') await comfyPage.keyboard.undo(null) await expect(textBox).toHaveValue('1girl') }) }) test.describe('Load workflow', { tag: '@screenshot' }, () => { test('Can load workflow with string node id', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/string_node_id') await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png') }) test('Can load workflow with ("STRING",) input node', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('inputs/string_input') await expect(comfyPage.canvas).toHaveScreenshot('string_input.png') }) test('Creates initial workflow tab when persistence is disabled', async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.Workflow.Persist', false) await comfyPage.setup() await expect .poll(() => comfyPage.page.evaluate(() => { return (window.app!.extensionManager as WorkspaceStore).workflow .openWorkflows.length }) ) .toBeGreaterThanOrEqual(1) }) test('Restore workflow on reload (switch workflow)', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png') await comfyPage.setup({ clearStorage: false }) await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png') }) test('Restore workflow on reload (modify workflow)', async ({ comfyPage }) => { await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') const node = (await comfyPage.nodeOps.getFirstNodeRef())! await node.click('collapse') await comfyPage.canvasOps.clickEmptySpace() await expect(comfyPage.canvas).toHaveScreenshot( 'single_ksampler_modified.png' ) // Wait for V2 persistence debounce to save the modified workflow const start = Date.now() await comfyPage.page.waitForFunction((since) => { for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i) if (!key?.startsWith('Comfy.Workflow.DraftIndex.v2:')) continue const json = window.localStorage.getItem(key) if (!json) continue try { const index = JSON.parse(json) if (typeof index.updatedAt === 'number' && index.updatedAt >= since) { return true } } catch { // ignore } } return false }, start) await comfyPage.setup({ clearStorage: false }) await expect(comfyPage.canvas).toHaveScreenshot( 'single_ksampler_modified.png' ) }) const generateUniqueFilename = (extension = '') => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}${extension}` test.describe('Restore all open workflows on reload', () => { let workflowA: string let workflowB: string test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') workflowA = generateUniqueFilename() await comfyPage.menu.topbar.saveWorkflow(workflowA) workflowB = generateUniqueFilename() await comfyPage.menu.topbar.triggerTopbarCommand(['New']) await comfyPage.menu.topbar.saveWorkflow(workflowB) // Wait for sessionStorage to persist the workflow paths before reloading // V2 persistence uses sessionStorage with client-scoped keys await comfyPage.page.waitForFunction(() => { for (let i = 0; i < window.sessionStorage.length; i++) { const key = window.sessionStorage.key(i) if (key?.startsWith('Comfy.Workflow.OpenPaths:')) { return true } } return false }) await comfyPage.setup({ clearStorage: false }) }) test('Restores topbar workflow tabs after reload', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Workflow.WorkflowTabsPosition', 'Topbar' ) await expect .poll(() => comfyPage.menu.topbar.getTabNames()) .toEqual(expect.arrayContaining([workflowA, workflowB])) await expect .poll(async () => { const tabs = await comfyPage.menu.topbar.getTabNames() return ( tabs.indexOf(workflowA) < tabs.indexOf(workflowB) && tabs.indexOf(workflowA) >= 0 ) }) .toBe(true) await expect(comfyPage.menu.topbar.getActiveTab()).toContainText( workflowB ) }) test('Restores sidebar workflows after reload', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Workflow.WorkflowTabsPosition', 'Sidebar' ) await comfyPage.menu.workflowsTab.open() await expect .poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames()) .toEqual(expect.arrayContaining([workflowA, workflowB])) await expect .poll(async () => { const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames() return ws.indexOf(workflowA) < ws.indexOf(workflowB) }) .toBe(true) await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText( workflowB ) }) }) test.describe('Restore workflow tabs after browser restart', () => { let workflowA: string let workflowB: string test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') workflowA = generateUniqueFilename() await comfyPage.menu.topbar.saveWorkflow(workflowA) workflowB = generateUniqueFilename() await comfyPage.menu.topbar.triggerTopbarCommand(['New']) await comfyPage.menu.topbar.saveWorkflow(workflowB) // Wait for localStorage fallback pointers to be written await comfyPage.page.waitForFunction(() => { for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i) if (key?.startsWith('Comfy.Workflow.LastOpenPaths:')) { return true } } return false }) // Simulate browser restart: clear sessionStorage (lost on close) // but keep localStorage (survives browser restart) await comfyPage.page.evaluate(() => { sessionStorage.clear() }) await comfyPage.setup({ clearStorage: false }) }) test('Restores topbar workflow tabs after browser restart', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Workflow.WorkflowTabsPosition', 'Topbar' ) // Wait for both restored tabs to render (localStorage fallback is async) await expect( comfyPage.page.locator('.workflow-tabs .workflow-label', { hasText: workflowA }) ).toBeVisible() await expect .poll(async () => { const tabs = await comfyPage.menu.topbar.getTabNames() return ( tabs.includes(workflowA) && tabs.includes(workflowB) && tabs.indexOf(workflowA) < tabs.indexOf(workflowB) ) }) .toBe(true) await expect(comfyPage.menu.topbar.getActiveTab()).toContainText( workflowB ) }) test('Restores sidebar workflows after browser restart', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Workflow.WorkflowTabsPosition', 'Sidebar' ) await comfyPage.menu.workflowsTab.open() await expect .poll(() => comfyPage.menu.workflowsTab.getOpenedWorkflowNames()) .toEqual(expect.arrayContaining([workflowA, workflowB])) await expect .poll(async () => { const ws = await comfyPage.menu.workflowsTab.getOpenedWorkflowNames() return ws.indexOf(workflowA) < ws.indexOf(workflowB) }) .toBe(true) await expect(comfyPage.menu.workflowsTab.activeWorkflowLabel).toHaveText( workflowB ) }) }) test('Auto fit view after loading workflow', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.EnableWorkflowViewRestore', false ) await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler_fit.png') }) }) test.describe('Load duplicate workflow', () => { test('A workflow can be loaded multiple times in a row', async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await comfyPage.menu.workflowsTab.open() await comfyPage.command.executeCommand('Comfy.NewBlankWorkflow') await comfyPage.workflow.loadWorkflow('nodes/single_ksampler') await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1) }) }) test.describe('Viewport settings', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.settings.setSetting( 'Comfy.Workflow.WorkflowTabsPosition', 'Topbar' ) await comfyPage.workflow.setupWorkflowsDirectory({}) }) test('Keeps viewport settings when changing tabs', async ({ comfyPage, comfyMouse }) => { const changeTab = async (tab: Locator) => { await tab.click() await comfyPage.nextFrame() await comfyMouse.move(DefaultGraphPositions.emptySpace) // If tooltip is visible, wait for it to hide await expect( comfyPage.page.locator('.workflow-popover-fade') ).toHaveCount(0) } // Screenshot the canvas element await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', true) const toggleButton = comfyPage.page.getByTestId( TestIds.canvas.toggleMinimapButton ) await toggleButton.click() await comfyPage.settings.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.menu.topbar.saveWorkflow('Workflow A') await comfyPage.nextFrame() // Save workflow as a new file, then zoom out before screen shot await comfyPage.menu.topbar.saveWorkflowAs('Workflow B') await comfyPage.nextFrame() const tabA = comfyPage.menu.topbar.getWorkflowTab('Workflow A') await changeTab(tabA) const screenshotA = (await comfyPage.canvas.screenshot()).toString('base64') const tabB = comfyPage.menu.topbar.getWorkflowTab('Workflow B') await changeTab(tabB) await comfyMouse.move(DefaultGraphPositions.emptySpace) for (let i = 0; i < 4; i++) { await comfyMouse.wheel(0, 60) } await comfyPage.nextFrame() const screenshotB = (await comfyPage.canvas.screenshot()).toString('base64') // Ensure that the screenshots are different due to zoom level expect(screenshotB).not.toBe(screenshotA) // Go back to Workflow A await changeTab(tabA) expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe( screenshotA ) // And back to Workflow B await changeTab(tabB) expect((await comfyPage.canvas.screenshot()).toString('base64')).toBe( screenshotB ) }) }) test.describe('Canvas Navigation', { tag: '@screenshot' }, () => { test.describe('Legacy Mode', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Canvas.NavigationMode', 'legacy' ) }) test('Left-click drag in empty area should pan canvas', async ({ comfyPage }) => { await comfyPage.canvasOps.dragAndDrop( { x: 50, y: 50 }, { x: 150, y: 150 } ) await expect(comfyPage.canvas).toHaveScreenshot( 'legacy-left-drag-pan.png' ) }) test('Middle-click drag should pan canvas', async ({ comfyPage }) => { await comfyPage.page.mouse.move(50, 50) await comfyPage.page.mouse.down({ button: 'middle' }) await comfyPage.page.mouse.move(150, 150) await comfyPage.page.mouse.up({ button: 'middle' }) await comfyPage.expectScreenshot( comfyPage.canvas, 'legacy-middle-drag-pan.png' ) }) test('Mouse wheel should zoom in/out', async ({ comfyPage }) => { await comfyPage.page.mouse.move(400, 300) await comfyPage.page.mouse.wheel(0, -120) await comfyPage.expectScreenshot( comfyPage.canvas, 'legacy-wheel-zoom-in.png' ) await comfyPage.page.mouse.wheel(0, 240) await comfyPage.expectScreenshot( comfyPage.canvas, 'legacy-wheel-zoom-out.png' ) }) test('Left-click on node should not pan canvas', async ({ comfyPage }) => { await comfyPage.canvas.click({ position: DefaultGraphPositions.textEncodeNode1 }) await comfyPage.nextFrame() await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(1) await expect(comfyPage.canvas).toHaveScreenshot( 'legacy-click-node-select.png' ) }) }) test.describe('Standard Mode', () => { test.beforeEach(async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Canvas.NavigationMode', 'standard' ) }) test('Left-click drag in empty area should select nodes', async ({ comfyPage }) => { const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') const clipNode1Pos = await clipNodes[0].getPosition() const clipNode2Pos = await clipNodes[1].getPosition() const offset = 64 await comfyPage.canvasOps.dragAndDrop( { x: Math.min(clipNode1Pos.x, clipNode2Pos.x) - offset, y: Math.min(clipNode1Pos.y, clipNode2Pos.y) - offset }, { x: Math.max(clipNode1Pos.x, clipNode2Pos.x) + offset, y: Math.max(clipNode1Pos.y, clipNode2Pos.y) + offset } ) await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(clipNodes.length) await expect(comfyPage.canvas).toHaveScreenshot( 'standard-left-drag-select.png' ) }) test('Middle-click drag should pan canvas', async ({ comfyPage }) => { await comfyPage.page.mouse.move(50, 50) await comfyPage.page.mouse.down({ button: 'middle' }) await comfyPage.page.mouse.move(150, 150) await comfyPage.page.mouse.up({ button: 'middle' }) await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-middle-drag-pan.png' ) }) test('Ctrl + mouse wheel should zoom in/out', async ({ comfyPage }) => { await comfyPage.page.mouse.move(400, 300) await comfyPage.page.keyboard.down('Control') await comfyPage.page.mouse.wheel(0, -120) await comfyPage.page.keyboard.up('Control') await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-ctrl-wheel-zoom-in.png' ) await comfyPage.page.keyboard.down('Control') await comfyPage.page.mouse.wheel(0, 240) await comfyPage.page.keyboard.up('Control') await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-ctrl-wheel-zoom-out.png' ) }) test('Left-click on node should select node (not start selection box)', async ({ comfyPage }) => { await comfyPage.canvas.click({ position: DefaultGraphPositions.textEncodeNode1 }) await comfyPage.nextFrame() await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(1) await expect(comfyPage.canvas).toHaveScreenshot( 'standard-click-node-select.png' ) }) test('Space + left-click drag should pan canvas', async ({ comfyPage }) => { // Click canvas to focus it await comfyPage.canvas.click() await comfyPage.nextFrame() await comfyPage.page.keyboard.down('Space') await comfyPage.canvasOps.dragAndDrop( { x: 50, y: 50 }, { x: 150, y: 150 } ) await comfyPage.page.keyboard.up('Space') await expect(comfyPage.canvas).toHaveScreenshot( 'standard-space-drag-pan.png' ) }) test('Space key overrides default left-click behavior', async ({ comfyPage }) => { const clipNodes = await comfyPage.nodeOps.getNodeRefsByType('CLIPTextEncode') const clipNode1Pos = await clipNodes[0].getPosition() const offset = 64 await comfyPage.canvasOps.dragAndDrop( { x: clipNode1Pos.x - offset, y: clipNode1Pos.y - offset }, { x: clipNode1Pos.x + offset, y: clipNode1Pos.y + offset } ) await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBeGreaterThan(0) await comfyPage.canvasOps.clickEmptySpace() await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(0) await comfyPage.page.keyboard.down('Space') await comfyPage.canvasOps.dragAndDrop( { x: clipNode1Pos.x - offset, y: clipNode1Pos.y - offset }, { x: clipNode1Pos.x + offset, y: clipNode1Pos.y + offset } ) await comfyPage.page.keyboard.up('Space') await expect .poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount()) .toBe(0) }) }) test('Shift + mouse wheel should pan canvas horizontally', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Canvas.MouseWheelScroll', 'panning' ) await comfyPage.canvas.click() await comfyPage.expectScreenshot(comfyPage.canvas, 'standard-initial.png') await comfyPage.page.mouse.move(400, 300) await comfyPage.page.keyboard.down('Shift') await comfyPage.page.mouse.wheel(0, 120) await comfyPage.page.keyboard.up('Shift') await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-shift-wheel-pan-right.png' ) await comfyPage.page.keyboard.down('Shift') await comfyPage.page.mouse.wheel(0, -240) await comfyPage.page.keyboard.up('Shift') await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-shift-wheel-pan-left.png' ) await comfyPage.page.keyboard.down('Shift') await comfyPage.page.mouse.wheel(0, 120) await comfyPage.page.keyboard.up('Shift') await comfyPage.expectScreenshot( comfyPage.canvas, 'standard-shift-wheel-pan-center.png' ) }) test.describe('Edge Cases', () => { test('Multiple modifier keys work correctly in legacy mode', async ({ comfyPage }) => { await comfyPage.settings.setSetting( 'Comfy.Canvas.NavigationMode', 'legacy' ) await comfyPage.page.keyboard.down('Alt') await comfyPage.page.keyboard.down('Shift') await comfyPage.canvasOps.dragAndDrop( { x: 50, y: 50 }, { x: 150, y: 150 } ) await comfyPage.page.keyboard.up('Shift') await comfyPage.page.keyboard.up('Alt') await expect(comfyPage.canvas).toHaveScreenshot( 'legacy-alt-shift-drag.png' ) }) test('Cursor changes appropriately in different modes', async ({ comfyPage }) => { const getCursorStyle = async () => { return await comfyPage.page.evaluate(() => { return ( document.getElementById('graph-canvas')!.style.cursor || 'default' ) }) } await comfyPage.settings.setSetting( 'Comfy.Canvas.NavigationMode', 'legacy' ) await comfyPage.page.mouse.move(50, 50) await comfyPage.page.mouse.down() await expect.poll(() => getCursorStyle()).toBe('grabbing') await comfyPage.page.mouse.up() }) }) })