342 lines
16 KiB
JavaScript
342 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { spawn } from "node:child_process";
|
|
import { mkdir, readFile, writeFile, copyFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { createServer as createNetServer } from "node:net";
|
|
import { PNG } from "pngjs";
|
|
import pixelmatch from "pixelmatch";
|
|
import { chromium } from "playwright";
|
|
|
|
const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === "1";
|
|
const MAX_DIFF_PIXELS = Number(process.env.UI_MAX_DIFF_PIXELS ?? 220);
|
|
const SAMPLE_MAX_CROSSINGS = Number(process.env.UI_SAMPLE_MAX_CROSSINGS ?? 1);
|
|
const SAMPLE_MAX_OVERLAPS = Number(process.env.UI_SAMPLE_MAX_OVERLAPS ?? 1);
|
|
const SAMPLE_MAX_DETOUR = Number(process.env.UI_SAMPLE_MAX_DETOUR ?? 3.6);
|
|
const DRAG_MAX_CROSSINGS = Number(process.env.UI_DRAG_MAX_CROSSINGS ?? 3);
|
|
const DRAG_MAX_OVERLAPS = Number(process.env.UI_DRAG_MAX_OVERLAPS ?? 3);
|
|
const DRAG_MAX_DETOUR = Number(process.env.UI_DRAG_MAX_DETOUR ?? 3.5);
|
|
const TIDY_MAX_CROSSINGS = Number(process.env.UI_TIDY_MAX_CROSSINGS ?? 2);
|
|
const TIDY_MAX_OVERLAPS = Number(process.env.UI_TIDY_MAX_OVERLAPS ?? 2);
|
|
const TIDY_MAX_DETOUR = Number(process.env.UI_TIDY_MAX_DETOUR ?? 4.5);
|
|
const DENSE_MAX_CROSSINGS = Number(process.env.UI_DENSE_MAX_CROSSINGS ?? 2);
|
|
const DENSE_MAX_OVERLAPS = Number(process.env.UI_DENSE_MAX_OVERLAPS ?? 2);
|
|
const DENSE_MAX_DETOUR = Number(process.env.UI_DENSE_MAX_DETOUR ?? 4.2);
|
|
const BASELINE_DIR = join(process.cwd(), "tests", "baselines", "ui");
|
|
const OUTPUT_DIR = join(process.cwd(), "output", "playwright");
|
|
const CURRENT_DIR = join(OUTPUT_DIR, "current");
|
|
const DIFF_DIR = join(OUTPUT_DIR, "diff");
|
|
const DENSE_ANALOG_PATH = join(process.cwd(), "examples", "dense-analog.json");
|
|
const REPORT_PATH = join(OUTPUT_DIR, "ui-metrics-report.json");
|
|
|
|
async function getFreePort() {
|
|
const probe = createNetServer();
|
|
await new Promise((resolve, reject) => {
|
|
probe.once("error", reject);
|
|
probe.listen(0, "127.0.0.1", resolve);
|
|
});
|
|
const addr = probe.address();
|
|
const port = typeof addr === "object" && addr ? addr.port : 8787;
|
|
await new Promise((resolve) => probe.close(resolve));
|
|
return port;
|
|
}
|
|
|
|
async function waitFor(predicate, timeoutMs = 10_000) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
try {
|
|
if (await predicate()) {
|
|
return;
|
|
}
|
|
} catch {}
|
|
await new Promise((resolve) => setTimeout(resolve, 80));
|
|
}
|
|
throw new Error(`Timed out after ${timeoutMs}ms`);
|
|
}
|
|
|
|
async function startServer(port) {
|
|
const child = spawn("node", ["src/server.js"], {
|
|
cwd: process.cwd(),
|
|
env: { ...process.env, PORT: String(port) },
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
});
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (d) => {
|
|
stdout += String(d);
|
|
});
|
|
child.stderr.on("data", (d) => {
|
|
stderr += String(d);
|
|
});
|
|
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
await waitFor(async () => {
|
|
const res = await fetch(`${baseUrl}/health`);
|
|
return res.ok;
|
|
});
|
|
|
|
return {
|
|
baseUrl,
|
|
async stop() {
|
|
if (child.exitCode !== null) return;
|
|
child.kill("SIGTERM");
|
|
await waitFor(() => child.exitCode !== null, 4_000).catch(() => {
|
|
throw new Error(`Server did not stop cleanly.\nstdout:\n${stdout}\nstderr:\n${stderr}`);
|
|
});
|
|
}
|
|
};
|
|
}
|
|
|
|
async function ensureDirs() {
|
|
await mkdir(BASELINE_DIR, { recursive: true });
|
|
await mkdir(CURRENT_DIR, { recursive: true });
|
|
await mkdir(DIFF_DIR, { recursive: true });
|
|
}
|
|
|
|
async function compareScene(page, scene) {
|
|
const currentPath = join(CURRENT_DIR, `${scene}.png`);
|
|
const baselinePath = join(BASELINE_DIR, `${scene}.png`);
|
|
const diffPath = join(DIFF_DIR, `${scene}.png`);
|
|
|
|
await page.screenshot({ path: currentPath, fullPage: true });
|
|
|
|
if (UPDATE_SNAPSHOTS || !existsSync(baselinePath)) {
|
|
await copyFile(currentPath, baselinePath);
|
|
return;
|
|
}
|
|
|
|
const [actualBuf, baselineBuf] = await Promise.all([readFile(currentPath), readFile(baselinePath)]);
|
|
const actual = PNG.sync.read(actualBuf);
|
|
const baseline = PNG.sync.read(baselineBuf);
|
|
assert.equal(actual.width, baseline.width, `scene '${scene}' width changed`);
|
|
assert.equal(actual.height, baseline.height, `scene '${scene}' height changed`);
|
|
|
|
const diff = new PNG({ width: actual.width, height: actual.height });
|
|
const diffPixels = pixelmatch(actual.data, baseline.data, diff.data, actual.width, actual.height, {
|
|
threshold: 0.15,
|
|
includeAA: false
|
|
});
|
|
|
|
if (diffPixels > MAX_DIFF_PIXELS) {
|
|
await writeFile(diffPath, PNG.sync.write(diff));
|
|
assert.fail(`scene '${scene}' changed: ${diffPixels} differing pixels (max ${MAX_DIFF_PIXELS}). diff: ${diffPath}`);
|
|
}
|
|
}
|
|
|
|
function parseStatusMetrics(statusText) {
|
|
const m = /(\d+)E,\s*(\d+)W\s*\|\s*(\d+)\s*crossings,\s*(\d+)\s*overlaps,\s*(\d+)\s*bends,\s*(\d+)\s*tie-nets,\s*([0-9.]+)x\s*detour/.exec(
|
|
statusText
|
|
);
|
|
if (!m) return null;
|
|
return {
|
|
errors: Number(m[1]),
|
|
warnings: Number(m[2]),
|
|
crossings: Number(m[3]),
|
|
overlaps: Number(m[4]),
|
|
bends: Number(m[5]),
|
|
tieNets: Number(m[6]),
|
|
detour: Number(m[7])
|
|
};
|
|
}
|
|
|
|
function parseZoomPercent(text) {
|
|
const m = /(\d+)%/.exec(String(text ?? ""));
|
|
return m ? Number(m[1]) : NaN;
|
|
}
|
|
|
|
async function run() {
|
|
await ensureDirs();
|
|
const port = await getFreePort();
|
|
const srv = await startServer(port);
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1600, height: 900 } });
|
|
await page.addInitScript(() => {
|
|
try {
|
|
window.localStorage?.clear();
|
|
window.sessionStorage?.clear();
|
|
} catch {}
|
|
});
|
|
const report = {
|
|
generated_at: new Date().toISOString(),
|
|
thresholds: {
|
|
sample: { crossings: SAMPLE_MAX_CROSSINGS, overlaps: SAMPLE_MAX_OVERLAPS, detour: SAMPLE_MAX_DETOUR },
|
|
drag: { crossings: DRAG_MAX_CROSSINGS, overlaps: DRAG_MAX_OVERLAPS, detour: DRAG_MAX_DETOUR },
|
|
tidy: { crossings: TIDY_MAX_CROSSINGS, overlaps: TIDY_MAX_OVERLAPS, detour: TIDY_MAX_DETOUR },
|
|
dense: { crossings: DENSE_MAX_CROSSINGS, overlaps: DENSE_MAX_OVERLAPS, detour: DENSE_MAX_DETOUR }
|
|
},
|
|
snapshots: {},
|
|
status: {}
|
|
};
|
|
|
|
try {
|
|
await page.goto(`${srv.baseUrl}/`);
|
|
await page.waitForSelector("#compileStatus");
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
const initialStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
const initialMetrics = parseStatusMetrics(initialStatus);
|
|
report.status.initial = { text: initialStatus, metrics: initialMetrics };
|
|
assert.ok(initialMetrics, `Unable to parse initial compile metrics from: ${initialStatus}`);
|
|
assert.ok(initialMetrics.crossings <= SAMPLE_MAX_CROSSINGS, `sample crossings too high: ${initialMetrics.crossings}`);
|
|
assert.ok(initialMetrics.overlaps <= SAMPLE_MAX_OVERLAPS, `sample overlaps too high: ${initialMetrics.overlaps}`);
|
|
assert.ok(initialMetrics.detour <= SAMPLE_MAX_DETOUR, `sample detour too high: ${initialMetrics.detour}`);
|
|
|
|
await compareScene(page, "initial");
|
|
report.snapshots.initial = "tests/baselines/ui/initial.png";
|
|
await page.keyboard.press("Control+k");
|
|
await page.getByRole("dialog", { name: "Command Palette" }).waitFor();
|
|
await page.locator("#commandInput").fill("fit");
|
|
await page.keyboard.press("Enter");
|
|
await page.waitForFunction(() => document.querySelector("#commandModal")?.classList.contains("hidden"));
|
|
|
|
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
|
|
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
|
|
const preFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent());
|
|
await page.getByRole("button", { name: "Focus current selection" }).click();
|
|
const postFocusZoom = parseZoomPercent(await page.locator("#zoomResetBtn").textContent());
|
|
assert.ok(Number.isFinite(preFocusZoom) && Number.isFinite(postFocusZoom), "zoom label should remain parseable");
|
|
assert.ok(postFocusZoom >= preFocusZoom, `focus should not zoom out selected view (${preFocusZoom}% -> ${postFocusZoom}%)`);
|
|
await page.getByRole("button", { name: "Reset view" }).click();
|
|
await compareScene(page, "selected-u2");
|
|
|
|
const target = page.locator('[data-ref="U2"]');
|
|
const box = await target.boundingBox();
|
|
assert.ok(box, "selected U2 should be draggable");
|
|
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
|
await page.mouse.down();
|
|
await page.mouse.move(box.x + box.width / 2 - 320, box.y + box.height / 2 + 220, { steps: 12 });
|
|
await page.mouse.up();
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
const dragStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
const dragMetrics = parseStatusMetrics(dragStatus);
|
|
report.status.after_drag = { text: dragStatus, metrics: dragMetrics };
|
|
assert.ok(dragMetrics, `Unable to parse drag compile metrics from: ${dragStatus}`);
|
|
assert.ok(dragMetrics.crossings <= DRAG_MAX_CROSSINGS, `dragged layout crossings too high: ${dragMetrics.crossings}`);
|
|
assert.ok(dragMetrics.overlaps <= DRAG_MAX_OVERLAPS, `dragged layout overlaps too high: ${dragMetrics.overlaps}`);
|
|
assert.ok(dragMetrics.detour <= DRAG_MAX_DETOUR, `dragged layout detour too high: ${dragMetrics.detour}`);
|
|
|
|
await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
const tidyStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
const tidyMetrics = parseStatusMetrics(tidyStatus);
|
|
report.status.after_drag_tidy = { text: tidyStatus, metrics: tidyMetrics };
|
|
assert.ok(tidyMetrics, `Unable to parse tidy compile metrics from: ${tidyStatus}`);
|
|
assert.ok(tidyMetrics.crossings <= TIDY_MAX_CROSSINGS, `drag+tidy crossings too high: ${tidyMetrics.crossings}`);
|
|
assert.ok(tidyMetrics.overlaps <= TIDY_MAX_OVERLAPS, `drag+tidy overlaps too high: ${tidyMetrics.overlaps}`);
|
|
assert.ok(tidyMetrics.detour <= TIDY_MAX_DETOUR, `drag+tidy detour too high: ${tidyMetrics.detour}`);
|
|
|
|
await page.locator("#canvasViewport").click({ position: { x: 40, y: 40 } });
|
|
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
|
|
|
|
await page.getByRole("button", { name: "View Schema" }).click();
|
|
await page.getByRole("dialog", { name: "Schemeta JSON Schema" }).waitFor();
|
|
await page.getByRole("button", { name: "Close" }).click();
|
|
await page.waitForFunction(() => document.querySelector("#schemaModal")?.classList.contains("hidden"));
|
|
|
|
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
|
|
const rowForPin = await findSymbolPinRowIndex(page, "AOUT");
|
|
assert.ok(rowForPin >= 0, "AOUT pin row should exist");
|
|
|
|
page.once("dialog", (d) => d.dismiss());
|
|
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin).getByRole("button", { name: "Remove" }).click();
|
|
assert.ok((await findSymbolPinRowIndex(page, "AOUT")) >= 0, "AOUT should remain after dismiss");
|
|
|
|
const rowForPin2 = await findSymbolPinRowIndex(page, "AOUT");
|
|
page.once("dialog", (d) => d.accept());
|
|
await page.locator("#symbolPinsList .symbolPinRow").nth(rowForPin2).getByRole("button", { name: "Remove" }).click();
|
|
assert.equal(await findSymbolPinRowIndex(page, "AOUT"), -1, "AOUT should be removed in draft");
|
|
|
|
await page.getByRole("button", { name: "Apply Symbol" }).click();
|
|
await expectText(page, "#jsonFeedback", /Destructive symbol edit detected/);
|
|
await page.getByRole("button", { name: "Preview Migration" }).click();
|
|
await page.getByRole("button", { name: "Apply Symbol" }).click();
|
|
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
await expectText(page, "#symbolMeta", /\(5 pins\)/);
|
|
await compareScene(page, "post-migration-apply");
|
|
|
|
await page.selectOption("#renderModeSelect", "explicit");
|
|
await page.getByRole("button", { name: "Run automatic tidy layout" }).click();
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
const explicitStatus = await page.locator("#compileStatus").textContent();
|
|
assert.ok(/x detour/.test(explicitStatus ?? ""), "status should include detour metric in explicit mode");
|
|
await compareScene(page, "explicit-mode-auto-tidy");
|
|
|
|
await page.getByRole("button", { name: "Reset to deterministic sample baseline" }).click();
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
await expectText(page, "#selectedSummary", /Click a component, net, or pin/);
|
|
await expectText(page, "#compileStatus", /x detour/);
|
|
const mode = await page.locator("#renderModeSelect").inputValue();
|
|
assert.equal(mode, "schematic_stub");
|
|
|
|
await page.locator("#newComponentTypeSelect").selectOption("resistor");
|
|
const beforeInstanceCount = await page.locator("[data-ref-item]").count();
|
|
await page.getByRole("button", { name: "Add Component" }).click();
|
|
await waitFor(async () => (await page.locator("[data-ref-item]").count()) >= beforeInstanceCount + 1);
|
|
|
|
const dense = await readFile(DENSE_ANALOG_PATH, "utf8");
|
|
await page.locator("#jsonEditor").fill(dense);
|
|
await page.getByRole("button", { name: "Apply JSON" }).click();
|
|
await expectText(page, "#jsonFeedback", /Applied JSON|blocked by validation/);
|
|
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
|
|
const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
const denseFeedback = String((await page.locator("#jsonFeedback").textContent()) ?? "");
|
|
assert.ok(!/blocked by validation/i.test(denseFeedback), `dense fixture apply unexpectedly blocked: ${denseFeedback}`);
|
|
const metrics = parseStatusMetrics(denseStatus);
|
|
report.status.dense = { text: denseStatus, metrics, feedback: denseFeedback };
|
|
assert.ok(metrics, `Unable to parse compile status metrics from: ${denseStatus}`);
|
|
assert.equal(metrics.errors, 0, `dense analog scene should compile with no errors (feedback: ${denseFeedback})`);
|
|
assert.ok(metrics.crossings <= DENSE_MAX_CROSSINGS, `dense analog crossings too high: ${metrics.crossings}`);
|
|
assert.ok(metrics.overlaps <= DENSE_MAX_OVERLAPS, `dense analog overlaps too high: ${metrics.overlaps}`);
|
|
assert.ok(metrics.detour <= DENSE_MAX_DETOUR, `dense analog detour too high: ${metrics.detour}`);
|
|
await compareScene(page, "dense-analog");
|
|
report.snapshots.dense = "tests/baselines/ui/dense-analog.png";
|
|
|
|
await page.setViewportSize({ width: 1280, height: 720 });
|
|
await page.getByRole("button", { name: "Fit schematic to viewport" }).click();
|
|
await expectText(page, "#compileStatus", /Compiled/);
|
|
assert.ok(await page.locator("#applyJsonBtn").isVisible(), "Apply JSON button should remain visible at laptop viewport");
|
|
await compareScene(page, "laptop-viewport");
|
|
report.snapshots.laptop = "tests/baselines/ui/laptop-viewport.png";
|
|
|
|
const compileBeforeInvalidApply = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
await page.locator("#jsonEditor").fill("{\"meta\":{\"title\":\"invalid\"},\"instances\":[],\"nets\":[]}");
|
|
await page.getByRole("button", { name: "Apply JSON" }).click();
|
|
await expectText(page, "#jsonFeedback", /blocked by validation/i);
|
|
const compileAfterInvalidApply = String((await page.locator("#compileStatus").textContent()) ?? "");
|
|
assert.equal(
|
|
compileAfterInvalidApply,
|
|
compileBeforeInvalidApply,
|
|
"invalid JSON apply should not mutate compiled workspace state"
|
|
);
|
|
report.result = "pass";
|
|
} finally {
|
|
if (!report.result) {
|
|
report.result = "fail";
|
|
}
|
|
await writeFile(REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`);
|
|
await page.close().catch(() => {});
|
|
await browser.close().catch(() => {});
|
|
await srv.stop().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function expectText(page, selector, pattern) {
|
|
await waitFor(async () => {
|
|
const text = await page.locator(selector).textContent();
|
|
return pattern.test(String(text ?? ""));
|
|
}, 10_000);
|
|
}
|
|
|
|
async function findSymbolPinRowIndex(page, pinName) {
|
|
return page.locator("#symbolPinsList .symbolPinRow .pinName").evaluateAll(
|
|
(nodes, target) => nodes.findIndex((node) => (node).value?.trim() === target),
|
|
pinName
|
|
);
|
|
}
|
|
|
|
run().catch((err) => {
|
|
console.error(err);
|
|
process.exit(1);
|
|
});
|