From 9a18d370194f953a5236c59299ea0ac0d43156be Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Sep 2025 03:08:33 -0700 Subject: [PATCH 1/3] Add dragging test --- .../assets/vueNodes/simple-triple.json | 1 + browser_tests/fixtures/ComfyPage.ts | 89 ++++++++++++++++++- .../tests/vueNodes/NodeHeader.spec.ts | 2 +- .../tests/vueNodes/linkInteraction.spec.ts | 66 ++++++++++++++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 browser_tests/assets/vueNodes/simple-triple.json create mode 100644 browser_tests/tests/vueNodes/linkInteraction.spec.ts diff --git a/browser_tests/assets/vueNodes/simple-triple.json b/browser_tests/assets/vueNodes/simple-triple.json new file mode 100644 index 000000000..9b665191d --- /dev/null +++ b/browser_tests/assets/vueNodes/simple-triple.json @@ -0,0 +1 @@ +{"id":"4412323e-2509-4258-8abc-68ddeea8f9e1","revision":0,"last_node_id":39,"last_link_id":29,"nodes":[{"id":37,"type":"KSampler","pos":[3635.923095703125,870.237548828125],"size":[428,437],"flags":{},"order":0,"mode":0,"inputs":[{"localized_name":"model","name":"model","type":"MODEL","link":null},{"localized_name":"positive","name":"positive","type":"CONDITIONING","link":null},{"localized_name":"negative","name":"negative","type":"CONDITIONING","link":null},{"localized_name":"latent_image","name":"latent_image","type":"LATENT","link":null},{"localized_name":"seed","name":"seed","type":"INT","widget":{"name":"seed"},"link":null},{"localized_name":"steps","name":"steps","type":"INT","widget":{"name":"steps"},"link":null},{"localized_name":"cfg","name":"cfg","type":"FLOAT","widget":{"name":"cfg"},"link":null},{"localized_name":"sampler_name","name":"sampler_name","type":"COMBO","widget":{"name":"sampler_name"},"link":null},{"localized_name":"scheduler","name":"scheduler","type":"COMBO","widget":{"name":"scheduler"},"link":null},{"localized_name":"denoise","name":"denoise","type":"FLOAT","widget":{"name":"denoise"},"link":null}],"outputs":[{"localized_name":"LATENT","name":"LATENT","type":"LATENT","links":null}],"properties":{"Node name for S&R":"KSampler"},"widgets_values":[0,"randomize",20,8,"euler","simple",1]},{"id":38,"type":"VAEDecode","pos":[4164.01611328125,925.5230712890625],"size":[193.25,107],"flags":{},"order":1,"mode":0,"inputs":[{"localized_name":"samples","name":"samples","type":"LATENT","link":null},{"localized_name":"vae","name":"vae","type":"VAE","link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":null}],"properties":{"Node name for S&R":"VAEDecode"}},{"id":39,"type":"CLIPTextEncode","pos":[3259.289794921875,927.2508544921875],"size":[239.9375,155],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"clip","name":"clip","type":"CLIP","link":null},{"localized_name":"text","name":"text","type":"STRING","widget":{"name":"text"},"link":null}],"outputs":[{"localized_name":"CONDITIONING","name":"CONDITIONING","type":"CONDITIONING","links":null}],"properties":{"Node name for S&R":"CLIPTextEncode"},"widgets_values":[""]}],"links":[],"groups":[],"config":{},"extra":{"ds":{"scale":1.1576250000000001,"offset":[-2808.366467322067,-478.34316506594797]}},"version":0.4} \ No newline at end of file diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index c9a8820f5..2db8e9de5 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -4,7 +4,11 @@ import { test as base } from '@playwright/test' import dotenv from 'dotenv' import * as fs from 'fs' -import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph' +import type { HasBoundingRect } from '../../src/lib/litegraph/src/interfaces' +import { + type LGraphNode, + createBounds +} from '../../src/lib/litegraph/src/litegraph' import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema' import type { KeyCombo } from '../../src/schemas/keyBindingSchema' import type { useWorkspaceStore } from '../../src/stores/workspaceStore' @@ -29,6 +33,8 @@ dotenv.config() type WorkspaceStore = ReturnType +type Bounds = readonly [number, number, number, number] + class ComfyMenu { private _nodeLibraryTab: NodeLibrarySidebarTab | null = null private _workflowsTab: WorkflowsSidebarTab | null = null @@ -338,6 +344,87 @@ export class ComfyPage { return `./browser_tests/assets/${fileName}` } + async fitToView( + options: { + selectionOnly?: boolean + zoom?: number + padding?: number + } = {} + ) { + const { selectionOnly = false, zoom = 0.75, padding = 10 } = options + + const rectangles = await this.page.evaluate< + Bounds[] | null, + { selectionOnly: boolean } + >( + ({ selectionOnly }) => { + const app = window['app'] + if (!app?.canvas) return null + + const canvas = app.canvas + const items = (() => { + if (selectionOnly && canvas.selectedItems?.size) { + return Array.from(canvas.selectedItems) + } + try { + return Array.from(canvas.positionableItems ?? []) + } catch { + return [] + } + })() + + if (!items.length) return null + + const rects: Bounds[] = [] + for (const item of items) { + const rect = item?.boundingRect + if (!rect) continue + + const x = Number(rect[0]) + const y = Number(rect[1]) + const width = Number(rect[2]) + const height = Number(rect[3]) + + rects.push([x, y, width, height] as Bounds) + } + + return rects.length ? rects : null + }, + { selectionOnly } + ) + + if (!rectangles?.length) return + + const sources: HasBoundingRect[] = rectangles.map( + (rect) => ({ boundingRect: rect }) satisfies HasBoundingRect + ) + + const bounds = createBounds(sources, padding) + + if (!bounds) return + + const boundsArray = [ + Number(bounds[0]), + Number(bounds[1]), + Number(bounds[2]), + Number(bounds[3]) + ] as Bounds + + await this.page.evaluate( + ({ bounds, zoom }) => { + const app = window['app'] + if (!app?.canvas) return + + const canvas = app.canvas + canvas.ds.fitToBounds(bounds, { zoom }) + canvas.setDirty(true, true) + }, + { bounds: boundsArray, zoom } + ) + + await this.nextFrame() + } + async executeCommand(commandId: string) { await this.page.evaluate((id: string) => { return window['app'].extensionManager.command.execute(id) diff --git a/browser_tests/tests/vueNodes/NodeHeader.spec.ts b/browser_tests/tests/vueNodes/NodeHeader.spec.ts index 7a8ae5dd2..336e2672d 100644 --- a/browser_tests/tests/vueNodes/NodeHeader.spec.ts +++ b/browser_tests/tests/vueNodes/NodeHeader.spec.ts @@ -6,7 +6,7 @@ import { VueNodeFixture } from '../../fixtures/utils/vueNodeFixtures' test.describe('NodeHeader', () => { test.beforeEach(async ({ comfyPage }) => { - await comfyPage.setSetting('Comfy.UseNewMenu', 'Enabled') + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') await comfyPage.setSetting('Comfy.Graph.CanvasMenu', false) await comfyPage.setSetting('Comfy.EnableTooltips', true) await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/linkInteraction.spec.ts new file mode 100644 index 000000000..33d754b6d --- /dev/null +++ b/browser_tests/tests/vueNodes/linkInteraction.spec.ts @@ -0,0 +1,66 @@ +import type { Locator } from '@playwright/test' + +import { getSlotKey } from '../../../src/renderer/core/layout/slots/slotIdentifier' +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../fixtures/ComfyPage' + +async function getCenter(locator: Locator): Promise<{ x: number; y: number }> { + const box = await locator.boundingBox() + if (!box) throw new Error('Slot bounding box not available') + return { + x: box.x + box.width / 2, + y: box.y + box.height / 2 + } +} + +test.describe('Vue Node Link Interaction', () => { + test.beforeEach(async ({ comfyPage }) => { + await comfyPage.setSetting('Comfy.UseNewMenu', 'Top') + await comfyPage.setSetting('Comfy.VueNodes.Enabled', true) + await comfyPage.setup() + await comfyPage.loadWorkflow('vueNodes/simple-triple') + await comfyPage.vueNodes.waitForNodes() + await comfyPage.fitToView() + }) + + test('should show a link dragging out from a slot when dragging on a slot', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + + const samplerNode = samplerNodes[0] + const outputSlot = await samplerNode.getOutput(0) + await outputSlot.removeLinks() + await comfyPage.nextFrame() + + const slotKey = getSlotKey(String(samplerNode.id), 0, false) + const slotLocator = comfyPage.page.locator(`[data-slot-key="${slotKey}"]`) + await expect(slotLocator).toBeVisible() + + const start = await getCenter(slotLocator) + const canvasBox = await comfyPage.canvas.boundingBox() + if (!canvasBox) throw new Error('Canvas bounding box not available') + + // Arbitrary value + const dragTarget = { + x: start.x + 180, + y: start.y - 140 + } + + await comfyMouse.move(start) + await comfyMouse.drag(dragTarget) + await comfyPage.nextFrame() + + try { + await expect(comfyPage.canvas).toHaveScreenshot( + 'vue-node-dragging-link.png' + ) + } finally { + await comfyMouse.drop() + } + }) +}) From 939cbe089934f22c079ba7525aca7a462c93f0c8 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Sep 2025 04:47:10 -0700 Subject: [PATCH 2/3] Litegraph? Never heard if it --- browser_tests/fixtures/ComfyPage.ts | 38 +++++++++++++++++------------ 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/browser_tests/fixtures/ComfyPage.ts b/browser_tests/fixtures/ComfyPage.ts index 2db8e9de5..cad95a7aa 100644 --- a/browser_tests/fixtures/ComfyPage.ts +++ b/browser_tests/fixtures/ComfyPage.ts @@ -4,11 +4,7 @@ import { test as base } from '@playwright/test' import dotenv from 'dotenv' import * as fs from 'fs' -import type { HasBoundingRect } from '../../src/lib/litegraph/src/interfaces' -import { - type LGraphNode, - createBounds -} from '../../src/lib/litegraph/src/litegraph' +import type { LGraphNode } from '../../src/lib/litegraph/src/litegraph' import type { NodeId } from '../../src/platform/workflow/validation/schemas/workflowSchema' import type { KeyCombo } from '../../src/schemas/keyBindingSchema' import type { useWorkspaceStore } from '../../src/stores/workspaceStore' @@ -393,21 +389,33 @@ export class ComfyPage { { selectionOnly } ) - if (!rectangles?.length) return + if (!rectangles || rectangles.length === 0) return - const sources: HasBoundingRect[] = rectangles.map( - (rect) => ({ boundingRect: rect }) satisfies HasBoundingRect - ) + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity - const bounds = createBounds(sources, padding) + for (const [x, y, width, height] of rectangles) { + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x + width) + maxY = Math.max(maxY, y + height) + } - if (!bounds) return + const hasFiniteBounds = + Number.isFinite(minX) && + Number.isFinite(minY) && + Number.isFinite(maxX) && + Number.isFinite(maxY) + + if (!hasFiniteBounds) return const boundsArray = [ - Number(bounds[0]), - Number(bounds[1]), - Number(bounds[2]), - Number(bounds[3]) + minX - padding, + minY - padding, + maxX - minX + 2 * padding, + maxY - minY + 2 * padding ] as Bounds await this.page.evaluate( From e3e1d2e8e6ab1758ba3b958ddeb3e65742531368 Mon Sep 17 00:00:00 2001 From: Benjamin Lu Date: Fri, 19 Sep 2025 05:06:16 -0700 Subject: [PATCH 3/3] Add more test cases --- .../tests/vueNodes/linkInteraction.spec.ts | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts b/browser_tests/tests/vueNodes/linkInteraction.spec.ts index 33d754b6d..80e758414 100644 --- a/browser_tests/tests/vueNodes/linkInteraction.spec.ts +++ b/browser_tests/tests/vueNodes/linkInteraction.spec.ts @@ -63,4 +63,191 @@ test.describe('Vue Node Link Interaction', () => { await comfyMouse.drop() } }) + + test('should create a link when dropping on a compatible slot', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const vaeNodes = await comfyPage.getNodeRefsByType('VAEDecode') + expect(vaeNodes.length).toBeGreaterThan(0) + const vaeNode = vaeNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const vaeInput = await vaeNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(vaeNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + const start = await getCenter(outputSlot) + const target = await getCenter(inputSlot) + + await comfyMouse.move(start) + + try { + await comfyMouse.drag(target) + } finally { + await comfyMouse.drop() + } + + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(1) + expect(await vaeInput.getLinkCount()).toBe(1) + + const linkDetails = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return null + + const source = graph.getNodeById(sourceId) + if (!source) return null + + const linkId = source.outputs[0]?.links?.[0] + if (linkId == null) return null + + const link = graph.links[linkId] + if (!link) return null + + return { + originId: link.origin_id, + originSlot: link.origin_slot, + targetId: link.target_id, + targetSlot: link.target_slot + } + }, samplerNode.id) + + expect(linkDetails).not.toBeNull() + expect(linkDetails).toMatchObject({ + originId: samplerNode.id, + originSlot: 0, + targetId: vaeNode.id, + targetSlot: 0 + }) + }) + + test('should not create a link when slot types are incompatible', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode') + expect(clipNodes.length).toBeGreaterThan(0) + const clipNode = clipNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const clipInput = await clipNode.getInput(0) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(clipNode.id), 0, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + const start = await getCenter(outputSlot) + const target = await getCenter(inputSlot) + + await comfyMouse.move(start) + + try { + await comfyMouse.drag(target) + } finally { + await comfyMouse.drop() + } + + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await clipInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) + + test('should not create a link when dropping onto a slot on the same node', async ({ + comfyPage, + comfyMouse + }) => { + const samplerNodes = await comfyPage.getNodeRefsByType('KSampler') + expect(samplerNodes.length).toBeGreaterThan(0) + const samplerNode = samplerNodes[0] + + const samplerOutput = await samplerNode.getOutput(0) + const samplerInput = await samplerNode.getInput(3) + + const outputSlotKey = getSlotKey(String(samplerNode.id), 0, false) + const inputSlotKey = getSlotKey(String(samplerNode.id), 3, true) + + const outputSlot = comfyPage.page.locator( + `[data-slot-key="${outputSlotKey}"]` + ) + const inputSlot = comfyPage.page.locator( + `[data-slot-key="${inputSlotKey}"]` + ) + + await expect(outputSlot).toBeVisible() + await expect(inputSlot).toBeVisible() + + const start = await getCenter(outputSlot) + const target = await getCenter(inputSlot) + + await comfyMouse.move(start) + + try { + await comfyMouse.drag(target) + } finally { + await comfyMouse.drop() + } + + await comfyPage.nextFrame() + + expect(await samplerOutput.getLinkCount()).toBe(0) + expect(await samplerInput.getLinkCount()).toBe(0) + + const graphLinkCount = await comfyPage.page.evaluate((sourceId) => { + const app = window['app'] + const graph = app?.canvas?.graph ?? app?.graph + if (!graph) return 0 + + const source = graph.getNodeById(sourceId) + if (!source) return 0 + + return source.outputs[0]?.links?.length ?? 0 + }, samplerNode.id) + + expect(graphLinkCount).toBe(0) + }) })