/** * E2E test helpers for ComfyUI Node Organizer. * * Provides typed extraction of graph state, layout-quality invariant * assertions, and convenience wrappers for triggering the organizer * through the browser. */ import type { Page } from "@playwright/test"; import { expect } from "@playwright/test"; import { mkdirSync } from "node:fs"; import { resolve } from "node:path"; import { e2eConfig } from "../../e2e.config.ts"; import { isGroupInsideGroup, isNodeCenterInsideGroup, } from "../../src/group-geometry"; // ── Types ──────────────────────────────────────────────────────────────────── export interface ExtractedNode { id: number; type: string; title: string; pos: [number, number]; size: [number, number]; } export interface ExtractedLink { id: number; origin_id: number; origin_slot: number; target_id: number; target_slot: number; } export interface ExtractedGroup { title: string; pos: [number, number]; size: [number, number]; } export interface GraphState { nodes: ExtractedNode[]; links: ExtractedLink[]; groups: ExtractedGroup[]; } export interface GroupMembershipSnapshot { title: string; parentTitle: string | null; memberNodeIds: number[]; } export interface SpacingMeasurements { horizontalLayerGap: number; verticalSiblingGap: number; groupPadding: number; disconnectedGap: number; } // ── AABB helpers (no tolerance parameter to keep strict) ───────────────────── interface BoundingBox { label: string; x: number; y: number; w: number; h: number; } /** AABB overlap with configurable inset tolerance (shrinks boxes before test). */ function boxesOverlap(a: BoundingBox, b: BoundingBox, tolerance: number): boolean { const ax = a.x + tolerance; const ay = a.y + tolerance; const aw = a.w - tolerance * 2; const ah = a.h - tolerance * 2; const bx = b.x + tolerance; const by = b.y + tolerance; const bw = b.w - tolerance * 2; const bh = b.h - tolerance * 2; // If shrunk to zero or negative, skip if (aw <= 0 || ah <= 0 || bw <= 0 || bh <= 0) return false; return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; } /** True if `inner` is fully contained inside `outer`. */ function isInside(inner: BoundingBox, outer: BoundingBox): boolean { return ( inner.x >= outer.x && inner.y >= outer.y && inner.x + inner.w <= outer.x + outer.w && inner.y + inner.h <= outer.y + outer.h ); } // ── Core helpers ───────────────────────────────────────────────────────────── /** * Wait for ComfyUI app to be fully loaded. * Resolves once `window.app.extensionManager` is truthy. */ export async function waitForComfyUI(page: Page): Promise { await page.waitForFunction( () => { const w = window as unknown as Record; const appObj = w.app as Record | undefined; return !!appObj?.extensionManager; }, { timeout: e2eConfig.timeouts.pageLoad }, ); } /** * Load a workflow JSON into ComfyUI via `app.loadGraphData`. * Waits for the graph to settle (nodes rendered with valid positions). */ export async function loadWorkflow( page: Page, workflow: Record, ): Promise { await page.evaluate(async (data: Record) => { const w = window as unknown as Record; const appObj = w.app as Record; // Must call as method on app to preserve `this` binding await (appObj.loadGraphData as (d: unknown) => Promise).call( appObj, data, ); }, workflow); // Wait for ALL nodes to have non-zero sizes await page.waitForFunction( () => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as | (Record & { getCurrentGraph?: () => Record | undefined; }) | undefined; const graph = (canvas?.getCurrentGraph?.() ?? canvas?.graph ?? appObj.graph) as Record | undefined; if (!graph) return false; const nodes = graph._nodes as Array> | undefined; if (!nodes || nodes.length === 0) return true; return nodes.every((n) => { const size = n.size as ArrayLike | undefined; return size && Number(size[0]) > 0 && Number(size[1]) > 0; }); }, { timeout: e2eConfig.timeouts.organize }, ); // Wait for node sizes to stabilize (widgets/text areas may resize after render) await page.waitForTimeout(500); await page.waitForFunction( () => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as | (Record & { getCurrentGraph?: () => Record | undefined; }) | undefined; const graph = (canvas?.getCurrentGraph?.() ?? canvas?.graph ?? appObj.graph) as Record | undefined; if (!graph) return true; const nodes = graph._nodes as Array> | undefined; if (!nodes) return true; // Store sizes, wait, check again — if stable, sizes won't change const key = "__sizeCheck"; const w2 = window as unknown as Record; const prev = w2[key] as string | undefined; const current = JSON.stringify( nodes.map((n) => { const s = n.size as ArrayLike; return [Number(s[0]), Number(s[1])]; }), ); w2[key] = current; return prev === current; }, { timeout: e2eConfig.timeouts.organize, polling: 300 }, ); } /** * Trigger "Organize Workflow" via canvas right-click context menu. * Clicks the canvas center, opens context menu, then clicks the menu item. */ export async function triggerOrganize(page: Page): Promise { // Capture positions before so we can detect when layout completes const beforePositions = await extractNodePositionMap(page); // Execute the organize command directly via ComfyUI's command API await page.evaluate(() => { const w = window as unknown as Record; const appObj = w.app as Record; const em = appObj.extensionManager as Record; const command = em.command as { execute: (id: string) => void }; command.execute("node-organizer.organize-workflow"); }); // Wait for layout to complete — positions should change (or stabilize for empty graphs) await waitForPositionsToChange(page, beforePositions); } export async function setNumericSetting( page: Page, settingId: string, value: number, ): Promise { await page.evaluate( ({ id, nextValue }: { id: string; nextValue: number }) => { const w = window as unknown as Record; const appObj = w.app as Record; const extensionManager = appObj.extensionManager as Record; const setting = extensionManager.setting as { set: (targetId: string, targetValue: number) => void; }; setting.set(id, nextValue); }, { id: settingId, nextValue: value }, ); } export async function setBooleanSetting( page: Page, settingId: string, value: boolean, ): Promise { await page.evaluate( ({ id, nextValue }: { id: string; nextValue: boolean }) => { const w = window as unknown as Record; const appObj = w.app as Record; const extensionManager = appObj.extensionManager as Record; const setting = extensionManager.setting as { set: (targetId: string, targetValue: boolean) => void; }; setting.set(id, nextValue); }, { id: settingId, nextValue: value }, ); } export async function setNumericSettingOverride( page: Page, settingId: string, value: number, ): Promise { await page.evaluate( ({ id, nextValue }: { id: string; nextValue: number }) => { const overrideKey = "__nodeOrganizerSettingOverrides"; const originalGetKey = "__nodeOrganizerOriginalSettingGet"; const w = window as unknown as Record; const appObj = w.app as Record; const extensionManager = appObj.extensionManager as Record; const setting = extensionManager.setting as { get?: (targetId: string) => T | undefined; } & Record; const overrides = (w[overrideKey] as Record | undefined) ?? {}; overrides[id] = nextValue; w[overrideKey] = overrides; if (typeof w[originalGetKey] !== "function") { w[originalGetKey] = setting.get?.bind(setting); setting.get = (targetId: string): T | undefined => { const activeOverrides = w[overrideKey] as | Record | undefined; if (activeOverrides && targetId in activeOverrides) { return activeOverrides[targetId] as T; } const originalGet = w[originalGetKey] as | ((settingId: string) => T | undefined) | undefined; return originalGet?.(targetId); }; } }, { id: settingId, nextValue: value }, ); } export async function clearNumericSettingOverrides(page: Page): Promise { await page.evaluate(() => { const overrideKey = "__nodeOrganizerSettingOverrides"; const originalGetKey = "__nodeOrganizerOriginalSettingGet"; const w = window as unknown as Record; const appObj = w.app as Record; const extensionManager = appObj.extensionManager as Record; const setting = extensionManager.setting as { get?: (targetId: string) => T | undefined; } & Record; const originalGet = w[originalGetKey] as | ((settingId: string) => T | undefined) | undefined; delete w[overrideKey]; if (originalGet) { setting.get = originalGet; delete w[originalGetKey]; } }); } /** * Trigger "Organize Group" for a specific group by title. * * Uses page.evaluate to programmatically select the group and invoke * the layout, since UI-based group selection is fragile. */ export async function triggerOrganizeGroup( page: Page, groupTitle: string, ): Promise { // Select the group programmatically, then execute the organize command await page.evaluate((title: string) => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as Record & { getCurrentGraph?: () => Record | undefined; }; const graph = (canvas.getCurrentGraph?.() ?? canvas.graph) as Record; const groups = graph._groups as Array>; const group = groups.find((g) => g.title === title); if (!group) { throw new Error(`Group with title "${title}" not found`); } // Select the group via canvas.selectedItems const selectedItems = new Set([group]); canvas.selectedItems = selectedItems; // Execute the organize groups command const em = appObj.extensionManager as Record; const command = em.command as { execute: (id: string) => void }; command.execute("node-organizer.organize-groups"); }, groupTitle); // Allow layout to complete await page.waitForTimeout(1000); } /** * Extract the current graph state (nodes, links, groups) from the browser. * Converts Float64Array / typed arrays to plain JS arrays. */ export async function extractGraphState(page: Page): Promise { return page.evaluate(() => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as Record & { getCurrentGraph?: () => Record | undefined; }; const graph = (canvas.getCurrentGraph?.() ?? canvas.graph ?? appObj.graph) as Record; // ── Nodes ── const packNode = ( node: Record, fallbackType: string, fallbackTitle: string, ) => { const pos = node.pos as ArrayLike; const size = node.size as ArrayLike; return { id: Number(node.id), type: (node.type as string) ?? fallbackType, title: (node.title as string) ?? (node.type as string) ?? fallbackTitle, pos: [Number(pos[0]), Number(pos[1])] as [number, number], size: [Number(size[0]), Number(size[1])] as [number, number], }; }; const rawNodes = graph._nodes as Array>; const nodes = rawNodes.map((n) => packNode(n, "Unknown", "Unknown")); const inputNode = graph.inputNode as Record | undefined; const outputNode = graph.outputNode as Record | undefined; if (inputNode) { nodes.push(packNode(inputNode, "SubgraphInput", "Subgraph Input")); } if (outputNode) { nodes.push(packNode(outputNode, "SubgraphOutput", "Subgraph Output")); } // ── Links ── const rawLinks = graph.links as | Map> | Record>; const linkEntries: Array> = rawLinks instanceof Map ? Array.from(rawLinks.values()) : Object.values(rawLinks); const links = linkEntries .filter((l): l is Record => l != null) .map((l) => ({ id: l.id as number, origin_id: l.origin_id as number, origin_slot: l.origin_slot as number, target_id: l.target_id as number, target_slot: l.target_slot as number, })); // ── Groups ── const rawGroups = (graph._groups as Array> | undefined) ?? []; const groups = rawGroups.map((g) => { const gPos = (g._pos ?? g.pos) as ArrayLike; const gSize = (g._size ?? g.size) as ArrayLike; return { title: (g.title as string) ?? "Untitled", pos: [Number(gPos[0]), Number(gPos[1])] as [number, number], size: [Number(gSize[0]), Number(gSize[1])] as [number, number], }; }); return { nodes, links, groups }; }); } export async function extractGroupMemberships( page: Page, ): Promise { const graph = await page.evaluate(() => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as Record & { getCurrentGraph?: () => Record | undefined; }; const currentGraph = (canvas.getCurrentGraph?.() ?? canvas.graph ?? appObj.graph) as { _nodes: Array>; _groups: Array>; }; return { groups: currentGraph._groups.map((group) => { const pos = (group._pos ?? group.pos) as ArrayLike; const size = (group._size ?? group.size) as ArrayLike; return { id: Number(group.id), title: String(group.title ?? "Untitled"), pos: [Number(pos[0]), Number(pos[1])] as [number, number], size: [Number(size[0]), Number(size[1])] as [number, number], }; }), nodes: currentGraph._nodes.map((node) => { const pos = node.pos as ArrayLike; const size = node.size as ArrayLike; return { id: Number(node.id), pos: [Number(pos[0]), Number(pos[1])] as [number, number], size: [Number(size[0]), Number(size[1])] as [number, number], }; }), }; }); return graph.groups .map((group) => { let parent: (typeof graph.groups)[number] | null = null; for (const candidate of graph.groups) { if (candidate.id === group.id) continue; if (!isGroupInsideGroup(group, candidate)) continue; if ( !parent || candidate.size[0] * candidate.size[1] < parent.size[0] * parent.size[1] ) { parent = candidate; } } const directChildren = graph.groups.filter((candidate) => { if (candidate.id === group.id) return false; if (!isGroupInsideGroup(candidate, group)) return false; let candidateParent: (typeof graph.groups)[number] | null = null; for (const possibleParent of graph.groups) { if (possibleParent.id === candidate.id) continue; if (!isGroupInsideGroup(candidate, possibleParent)) continue; if ( !candidateParent || possibleParent.size[0] * possibleParent.size[1] < candidateParent.size[0] * candidateParent.size[1] ) { candidateParent = possibleParent; } } return candidateParent?.id === group.id; }); const memberNodeIds = graph.nodes .filter((node) => { if (!isNodeCenterInsideGroup(node, group)) return false; return !directChildren.some((child) => isNodeCenterInsideGroup(node, child)); }) .map((node) => node.id) .sort((a, b) => a - b); return { title: group.title, parentTitle: parent?.title ?? null, memberNodeIds, }; }) .sort((a, b) => a.title.localeCompare(b.title)); } export async function extractSpacingMeasurements( page: Page, ): Promise { return page.evaluate(() => { type RuntimeNode = { title: string; pos: [number, number]; size: [number, number]; }; type RuntimeGroup = { title: string; pos: [number, number]; size: [number, number]; }; const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as Record & { getCurrentGraph?: () => Record | undefined; }; const graph = (canvas.getCurrentGraph?.() ?? canvas.graph ?? appObj.graph) as { _nodes: Array>; _groups: Array>; }; const nodes = graph._nodes.map((node) => { const pos = node.pos as ArrayLike; const size = node.size as ArrayLike; return { title: String(node.title ?? node.type ?? "Untitled"), pos: [Number(pos[0]), Number(pos[1])] as [number, number], size: [Number(size[0]), Number(size[1])] as [number, number], }; }); const groups = graph._groups.map((group) => { const pos = (group._pos ?? group.pos) as ArrayLike; const size = (group._size ?? group.size) as ArrayLike; return { title: String(group.title ?? "Untitled"), pos: [Number(pos[0]), Number(pos[1])] as [number, number], size: [Number(size[0]), Number(size[1])] as [number, number], }; }); const requireNode = (title: string): RuntimeNode => { const node = nodes.find((candidate) => candidate.title === title); if (!node) { throw new Error(`Missing spacing fixture node "${title}"`); } return node; }; const requireGroup = (title: string): RuntimeGroup => { const group = groups.find((candidate) => candidate.title === title); if (!group) { throw new Error(`Missing spacing fixture group "${title}"`); } return group; }; const sampler = requireNode("Sampler"); const decode = requireNode("Decode"); const disconnected = requireNode("Disconnected Note"); const save = requireNode("Save"); const rootLayerNodes = [ requireNode("Model Loader"), requireNode("Latent Source"), ].sort((a, b) => a.pos[1] - b.pos[1]); const promptNodes = [ requireNode("Positive Prompt"), requireNode("Negative Prompt"), ].sort((a, b) => a.pos[1] - b.pos[1]); const promptGroup = requireGroup("Prompt Cluster"); const mainNodes = [ rootLayerNodes[0], rootLayerNodes[1], promptNodes[0], promptNodes[1], sampler, decode, save, ]; const mainLeft = Math.min(...mainNodes.map((node) => node.pos[0])); const mainRight = Math.max( ...mainNodes.map((node) => node.pos[0] + node.size[0]), ); const disconnectedLeft = disconnected.pos[0]; const disconnectedRight = disconnected.pos[0] + disconnected.size[0]; const horizontalLayerGap = save.pos[0] - (decode.pos[0] + decode.size[0]); const verticalSiblingGap = rootLayerNodes[1].pos[1] - (rootLayerNodes[0].pos[1] + rootLayerNodes[0].size[1]); const leftmostPromptNode = Math.min( ...promptNodes.map((node) => node.pos[0]), ); const groupPadding = leftmostPromptNode - promptGroup.pos[0]; const disconnectedGap = disconnectedLeft >= mainRight ? disconnectedLeft - mainRight : mainLeft - disconnectedRight; return { horizontalLayerGap, verticalSiblingGap, groupPadding, disconnectedGap, }; }); } // ── Invariant assertions ───────────────────────────────────────────────────── /** * Assert layout-quality invariants on an extracted graph state. * * Checks: * 1. No node-to-node overlaps (AABB with 2px tolerance) * 2. All coordinates are finite (no NaN / Infinity) * 3. Nodes whose center falls inside a group are fully contained * 4. Topological order — sources positioned left of targets in X */ export function assertInvariants(state: GraphState): void { assertCorrectnessInvariants(state); assertTopologicalOrder(state); } /** * Correctness-only invariants (no topological order). * Use for complex real-world workflows where strict edge directionality * cannot always be satisfied due to group containment constraints. */ export function assertCorrectnessInvariants(state: GraphState): void { assertNoNodeOverlaps(state); assertFiniteCoordinates(state); assertNodesInsideGroups(state); assertNoGroupOverlaps(state); } /** No two nodes overlap (strict AABB check). */ function assertNoNodeOverlaps(state: GraphState): void { const tolerance = 0; const boxes: BoundingBox[] = state.nodes.map((n) => ({ label: `node_${n.id} (${n.title})`, x: n.pos[0], y: n.pos[1], w: n.size[0], h: n.size[1], })); const overlaps: string[] = []; for (let i = 0; i < boxes.length; i++) { for (let j = i + 1; j < boxes.length; j++) { if (boxesOverlap(boxes[i], boxes[j], tolerance)) { overlaps.push(`${boxes[i].label} overlaps ${boxes[j].label}`); } } } expect(overlaps, `Found ${overlaps.length} node overlaps`).toEqual([]); } /** All node/group coordinates are finite numbers. */ function assertFiniteCoordinates(state: GraphState): void { const errors: string[] = []; for (const n of state.nodes) { if (!Number.isFinite(n.pos[0]) || !Number.isFinite(n.pos[1])) { errors.push(`Node ${n.id} (${n.title}) has non-finite pos: [${n.pos[0]}, ${n.pos[1]}]`); } if (!Number.isFinite(n.size[0]) || !Number.isFinite(n.size[1])) { errors.push(`Node ${n.id} (${n.title}) has non-finite size: [${n.size[0]}, ${n.size[1]}]`); } } for (const g of state.groups) { if (!Number.isFinite(g.pos[0]) || !Number.isFinite(g.pos[1])) { errors.push(`Group "${g.title}" has non-finite pos: [${g.pos[0]}, ${g.pos[1]}]`); } if (!Number.isFinite(g.size[0]) || !Number.isFinite(g.size[1])) { errors.push(`Group "${g.title}" has non-finite size: [${g.size[0]}, ${g.size[1]}]`); } } expect(errors, `Found ${errors.length} non-finite coordinates`).toEqual([]); } /** Nodes whose center is inside a group must be fully inside that group. */ function assertNodesInsideGroups(state: GraphState): void { const errors: string[] = []; for (const g of state.groups) { const groupBox: BoundingBox = { label: `group "${g.title}"`, x: g.pos[0], y: g.pos[1], w: g.size[0], h: g.size[1], }; for (const n of state.nodes) { const nodeCenterX = n.pos[0] + n.size[0] / 2; const nodeCenterY = n.pos[1] + n.size[1] / 2; const centerInGroup = nodeCenterX >= groupBox.x && nodeCenterX <= groupBox.x + groupBox.w && nodeCenterY >= groupBox.y && nodeCenterY <= groupBox.y + groupBox.h; if (centerInGroup) { const nodeBox: BoundingBox = { label: `node_${n.id} (${n.title})`, x: n.pos[0], y: n.pos[1], w: n.size[0], h: n.size[1], }; if (!isInside(nodeBox, groupBox)) { errors.push( `Node ${n.id} (${n.title}) center inside group "${g.title}" but extends outside bounds`, ); } } } } expect(errors, `Found ${errors.length} nodes extending outside their groups`).toEqual([]); } /** * Topological order: flag only true inversions where the source starts * entirely to the right of the target. * Reroute nodes are excluded. */ function assertTopologicalOrder(state: GraphState): void { const nodeMap = new Map(state.nodes.map((n) => [n.id, n])); const errors: string[] = []; for (const link of state.links) { const src = nodeMap.get(link.origin_id); const tgt = nodeMap.get(link.target_id); if (!src || !tgt) continue; // Skip reroute nodes if (src.type === "Reroute" || tgt.type === "Reroute") continue; if (src.pos[0] > tgt.pos[0] + tgt.size[0]) { errors.push( `Link ${link.id}: source ${src.id} (${src.title}) left-edge ${src.pos[0].toFixed(0)} > ` + `target ${tgt.id} (${tgt.title}) right-edge ${(tgt.pos[0] + tgt.size[0]).toFixed(0)}`, ); } } expect(errors, `Found ${errors.length} topological order violations`).toEqual([]); } /** Sibling groups (neither fully contains the other) must not overlap. */ function assertNoGroupOverlaps(state: GraphState): void { const overlaps: string[] = []; for (let i = 0; i < state.groups.length; i++) { for (let j = i + 1; j < state.groups.length; j++) { const a = state.groups[i]; const b = state.groups[j]; const aBox: BoundingBox = { label: `group "${a.title}"`, x: a.pos[0], y: a.pos[1], w: a.size[0], h: a.size[1], }; const bBox: BoundingBox = { label: `group "${b.title}"`, x: b.pos[0], y: b.pos[1], w: b.size[0], h: b.size[1], }; // Skip parent-child pairs (one fully contains the other) if (isInside(aBox, bBox) || isInside(bBox, aBox)) continue; if (boxesOverlap(aBox, bBox, 0)) { overlaps.push(`${aBox.label} overlaps ${bBox.label}`); } } } expect(overlaps, `Found ${overlaps.length} sibling group overlaps`).toEqual([]); } // ── Idempotency ────────────────────────────────────────────────────────────── /** * Assert the layout is idempotent: run organize twice and compare positions. * Must be called after the first organize has already been triggered. */ export async function assertIdempotent(page: Page): Promise { const state1 = await extractGraphState(page); // Trigger organize again await triggerOrganize(page); const state2 = await extractGraphState(page); const tolerance = 0; const diffs: string[] = []; for (const n1 of state1.nodes) { const n2 = state2.nodes.find((n) => n.id === n1.id); if (!n2) { diffs.push(`Node ${n1.id} missing after second organize`); continue; } const dx = Math.abs(n1.pos[0] - n2.pos[0]); const dy = Math.abs(n1.pos[1] - n2.pos[1]); if (dx > tolerance || dy > tolerance) { diffs.push( `Node ${n1.id} (${n1.title}) moved: ` + `[${n1.pos[0].toFixed(3)}, ${n1.pos[1].toFixed(3)}] -> ` + `[${n2.pos[0].toFixed(3)}, ${n2.pos[1].toFixed(3)}]`, ); } } expect(diffs, `Layout not idempotent: ${diffs.length} nodes moved`).toEqual([]); } // ── Screenshots ────────────────────────────────────────────────────────────── const SCREENSHOTS_DIR = resolve(import.meta.dirname, "..", "screenshots"); /** * Capture a screenshot and save to tests/screenshots/.png. * Returns the absolute file path. */ export async function captureScreenshot(page: Page, name: string): Promise { mkdirSync(SCREENSHOTS_DIR, { recursive: true }); const filepath = resolve(SCREENSHOTS_DIR, `${name}.png`); await page.screenshot({ path: filepath, fullPage: true }); return filepath; } /** Fit the current graph into view using the real canvas toolbar control. */ export async function fitGraphForSnapshot(page: Page): Promise { const before = await extractViewportState(page); await page.getByRole("button", { name: /Fit View/ }).click({ force: true }); await waitForViewportToStabilize(page, before); } /** Compare only the graph canvas, not the full ComfyUI shell. */ export async function expectGraphCanvasScreenshot( page: Page, name: string, ): Promise { await fitGraphForSnapshot(page); const graphCanvas = page.locator("#graph-canvas"); await expect(graphCanvas).toHaveScreenshot(name, { animations: "disabled", caret: "hide", scale: "css", maxDiffPixelRatio: 0.01, }); } // ── Internal utilities ─────────────────────────────────────────────────────── /** Extract a lightweight id->pos map for change detection. */ async function extractNodePositionMap( page: Page, ): Promise> { const raw = await page.evaluate(() => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as Record & { getCurrentGraph?: () => Record | undefined; }; const graph = (canvas.getCurrentGraph?.() ?? canvas.graph ?? appObj.graph) as Record | undefined; if (!graph) return []; const nodes = graph._nodes as Array> | undefined; const entries: Array> = [...(nodes ?? [])]; if (graph.inputNode) { entries.push(graph.inputNode as Record); } if (graph.outputNode) { entries.push(graph.outputNode as Record); } return entries.map((n) => { const pos = n.pos as ArrayLike; return [n.id as number, Number(pos[0]), Number(pos[1])] as [number, number, number]; }); }); const map = new Map(); for (const [id, x, y] of raw) { map.set(id, [x, y]); } return map; } /** * Wait until node positions differ from a baseline snapshot. * Times out gracefully — some graphs may already be organized. */ async function waitForPositionsToChange( page: Page, before: Map, ): Promise { const deadline = Date.now() + e2eConfig.timeouts.organize; const pollMs = 200; while (Date.now() < deadline) { const current = await extractNodePositionMap(page); let changed = false; for (const [id, pos] of before) { const cur = current.get(id); if (!cur) continue; if (Math.abs(pos[0] - cur[0]) > 1 || Math.abs(pos[1] - cur[1]) > 1) { changed = true; break; } } if (changed) return; await page.waitForTimeout(pollMs); } // Not an error — layout may be a no-op for an already-organized graph } interface ViewportState { offset: [number, number]; scale: number; dirtyCanvas: boolean; dirtyBgCanvas: boolean; } async function extractViewportState(page: Page): Promise { return page.evaluate(() => { const w = window as unknown as Record; const appObj = w.app as Record; const canvas = appObj.canvas as { ds: { offset: [number, number]; scale: number }; dirty_canvas?: boolean; dirty_bgcanvas?: boolean; }; return { offset: [Number(canvas.ds.offset[0]), Number(canvas.ds.offset[1])] as [ number, number, ], scale: Number(canvas.ds.scale), dirtyCanvas: !!canvas.dirty_canvas, dirtyBgCanvas: !!canvas.dirty_bgcanvas, }; }); } function isViewportEquivalent(a: ViewportState, b: ViewportState): boolean { return ( Math.abs(a.offset[0] - b.offset[0]) <= 0.01 && Math.abs(a.offset[1] - b.offset[1]) <= 0.01 && Math.abs(a.scale - b.scale) <= 0.0001 && a.dirtyCanvas === b.dirtyCanvas && a.dirtyBgCanvas === b.dirtyBgCanvas ); } async function waitForViewportToStabilize( page: Page, before: ViewportState, ): Promise { const deadline = Date.now() + 5_000; let previous = before; let changed = false; let stablePolls = 0; while (Date.now() < deadline) { const current = await extractViewportState(page); if (!changed && !isViewportEquivalent(current, before)) { changed = true; } const isStableFrame = isViewportEquivalent(current, previous) && !current.dirtyCanvas && !current.dirtyBgCanvas; stablePolls = isStableFrame ? stablePolls + 1 : 0; if ((changed && stablePolls >= 3) || (!changed && stablePolls >= 5)) { return; } previous = current; await page.waitForTimeout(100); } }