Files
2026-03-23 16:40:53 -05:00

353 lines
13 KiB
TypeScript

import { readFileSync } from "node:fs";
import { test, expect } from "@playwright/test";
import {
waitForComfyUI,
loadWorkflow,
extractGraphState,
triggerOrganize,
triggerOrganizeGroup,
} from "./helpers";
import { loadFixture } from "./fixtures";
import { SETTING_IDS } from "../../src/settings";
const packageVersion = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
) as { version: string };
function didAnyNodeMove(
before: Awaited<ReturnType<typeof extractGraphState>>,
after: Awaited<ReturnType<typeof extractGraphState>>,
): boolean {
for (const node of before.nodes) {
const next = after.nodes.find((candidate) => candidate.id === node.id);
if (!next) continue;
if (
Math.abs(node.pos[0] - next.pos[0]) > 1 ||
Math.abs(node.pos[1] - next.pos[1]) > 1
) {
return true;
}
}
return false;
}
test.describe("UI smoke", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
});
test("action bar organize button runs layout", async ({ page }) => {
const before = await extractGraphState(page);
await page.getByRole("button", { name: "Organize" }).click();
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("Shift+O keybinding runs layout", async ({ page }) => {
const before = await extractGraphState(page);
await page.keyboard.press("Shift+O");
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("context-aware Shift+O organizes selected groups when groups are selected", async ({ page }) => {
await loadWorkflow(page, loadFixture("group-test-simple"));
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups.map((group) => group.title);
});
expect(groupTitles.length).toBeGreaterThan(0);
// Select the first group programmatically
await page.evaluate((title: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph) as Record<string, unknown>;
const groups = graph._groups as Array<Record<string, unknown>>;
const group = groups.find((g) => g.title === title);
if (!group) throw new Error(`Group "${title}" not found`);
canvas.selectedItems = new Set([group]);
}, groupTitles[0]);
const before = await extractGraphState(page);
// Use Shift+O — should organize the selected group, not the full workflow
await page.keyboard.press("Shift+O");
await page.waitForTimeout(1000);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
test("Organize Group runs layout on the selected group", async ({ page }) => {
await loadWorkflow(page, loadFixture("group-test-simple"));
const groupTitles = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups.map((group) => group.title);
});
expect(groupTitles.length).toBeGreaterThan(0);
const before = await extractGraphState(page);
await triggerOrganizeGroup(page, groupTitles[0]);
const after = await extractGraphState(page);
expect(didAnyNodeMove(before, after)).toBe(true);
});
});
test.describe("Settings panel", () => {
async function openNodeOrganizerSettings(page: import("@playwright/test").Page): Promise<void> {
// The sidebar settings button includes the keyboard shortcut in its label
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
await page.waitForTimeout(500);
// Navigate to our extension's settings section
await page.getByText("Node Organizer", { exact: true }).click();
await page.waitForTimeout(500);
}
test.beforeEach(async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
});
test("Node Organizer appears in settings sidebar", async ({ page }) => {
await page.getByRole("button", { name: /Settings \(Ctrl/ }).click();
await expect(
page.getByText("Node Organizer", { exact: true }),
).toBeVisible();
});
test("settings panel shows About section with version and homepage", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(
page.getByText(`Version ${packageVersion.version}`),
).toBeVisible();
await expect(page.getByRole("link", { name: "Homepage" })).toBeVisible();
});
test("settings panel shows Layout section with sliders", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(page.getByText("Horizontal Gap")).toBeVisible();
await expect(page.getByText("Vertical Gap")).toBeVisible();
await expect(page.getByText("Group Padding")).toBeVisible();
await expect(page.getByText("Disconnected Node Gap")).toBeVisible();
});
test("settings panel shows Behavior and Keybindings sections", async ({ page }) => {
await openNodeOrganizerSettings(page);
await expect(
page.getByText("Fit to View After Organize"),
).toBeVisible();
await expect(page.getByText("Keyboard Shortcuts")).toBeVisible();
});
test("settings sections appear in correct order", async ({ page }) => {
await openNodeOrganizerSettings(page);
// Collect all visible text and check the relative order of section headings
const allText = await page.locator("body").innerText();
const sectionNames = ["About", "Layout", "Behavior", "Keybindings", "Advanced"];
const positions = sectionNames.map((name) => ({
name,
pos: allText.indexOf(name),
}));
// All sections should be found
for (const { name, pos } of positions) {
expect(pos, `Section "${name}" not found in settings panel`).toBeGreaterThanOrEqual(0);
}
// Verify ordering
for (let i = 1; i < positions.length; i++) {
expect(
positions[i].pos,
`"${positions[i].name}" should appear after "${positions[i - 1].name}"`,
).toBeGreaterThan(positions[i - 1].pos);
}
});
});
test.describe("Fit to view after organize", () => {
async function enableFitToView(page: import("@playwright/test").Page): Promise<void> {
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: boolean) => void };
setting.set(settingId, true);
}, SETTING_IDS.FIT_TO_VIEW);
}
async function getViewport(page: import("@playwright/test").Page) {
return page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown>;
const ds = canvas.ds as { offset: [number, number]; scale: number };
return { offset: [...ds.offset], scale: ds.scale };
});
}
test("viewport changes after organizing full workflow", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
await enableFitToView(page);
const viewportBefore = await getViewport(page);
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewportAfter = await getViewport(page);
const offsetChanged =
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
const scaleChanged =
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
expect(offsetChanged || scaleChanged).toBe(true);
});
test("fit-to-view fits entire graph, not just selected node", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
await enableFitToView(page);
// Select a single node (last node in the graph)
await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph) as Record<string, unknown>;
const nodes = graph._nodes as Array<Record<string, unknown>>;
if (nodes.length > 0) {
canvas.selectedItems = new Set([nodes[nodes.length - 1]]);
}
});
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewport = await getViewport(page);
// With fit-to-all-items, the scale should be < 2 (fitting the whole graph).
// If it zoomed to a single selected node, scale would be much larger (e.g. 3+).
// This threshold catches the bug where a selected node is zoomed into.
expect(viewport.scale).toBeLessThan(2);
});
test("fit-to-view does not run for selected-group organize", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("group-test-simple"));
await enableFitToView(page);
// Organize full workflow first to get a stable viewport
await triggerOrganize(page);
await page.waitForTimeout(1500);
const viewportBefore = await getViewport(page);
const groupTitle = await page.evaluate(() => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const canvas = appObj.canvas as Record<string, unknown> & {
getCurrentGraph?: () => Record<string, unknown> | undefined;
};
const graph = (canvas.getCurrentGraph?.() ??
canvas.graph ??
appObj.graph) as { _groups: Array<{ title: string }> };
return graph._groups[0]?.title ?? null;
});
if (groupTitle) {
await triggerOrganizeGroup(page, groupTitle);
await page.waitForTimeout(500);
const viewportAfter = await getViewport(page);
const offsetChanged =
Math.abs(viewportBefore.offset[0] - viewportAfter.offset[0]) > 1 ||
Math.abs(viewportBefore.offset[1] - viewportAfter.offset[1]) > 1;
const scaleChanged =
Math.abs(viewportBefore.scale - viewportAfter.scale) > 0.01;
expect(offsetChanged || scaleChanged).toBe(false);
}
});
});
test.describe("Spacing settings affect layout", () => {
test("custom disconnectedGap changes disconnected node placement", async ({ page }) => {
await page.goto("/");
await waitForComfyUI(page);
await loadWorkflow(page, loadFixture("simple-dag"));
// Organize with default settings
await triggerOrganize(page);
await page.waitForTimeout(1500);
const defaultState = await extractGraphState(page);
// Set a very large disconnected gap
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: number) => void };
setting.set(settingId, 400);
}, SETTING_IDS.DISCONNECTED_GAP);
// Re-organize with the new setting
await triggerOrganize(page);
await page.waitForTimeout(1500);
const customState = await extractGraphState(page);
// If there are any disconnected nodes, their positions should differ
// For workflows where all nodes are connected, positions are the same
// This test verifies the setting is read — not that every fixture has disconnected nodes
expect(customState.nodes.length).toBe(defaultState.nodes.length);
// Reset setting
await page.evaluate((settingId: string) => {
const w = window as unknown as Record<string, unknown>;
const appObj = w.app as Record<string, unknown>;
const em = appObj.extensionManager as Record<string, unknown>;
const setting = em.setting as { set: (id: string, value: number) => void };
setting.set(settingId, 150);
}, SETTING_IDS.DISCONNECTED_GAP);
});
});