schemeta/tests/ui-regression-runner.js

228 lines
9.0 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 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");
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])
};
}
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 } });
try {
await page.goto(`${srv.baseUrl}/`);
await page.waitForSelector("#compileStatus");
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
await compareScene(page, "initial");
await page.getByRole("button", { name: /Instance U2, symbol dac_i2s/ }).click();
await expectText(page, "#selectedSummary", /U2 \(dac_i2s\)/);
await compareScene(page, "selected-u2");
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");
const dense = await readFile(DENSE_ANALOG_PATH, "utf8");
await page.locator("#jsonEditor").fill(dense);
await page.getByRole("button", { name: "Apply JSON" }).click();
await page.waitForFunction(() => document.querySelector("#compileStatus")?.textContent?.includes("Compiled"));
const denseStatus = String((await page.locator("#compileStatus").textContent()) ?? "");
const metrics = parseStatusMetrics(denseStatus);
assert.ok(metrics, `Unable to parse compile status metrics from: ${denseStatus}`);
assert.equal(metrics.errors, 0, "dense analog scene should compile with no errors");
assert.ok(metrics.crossings <= 2, `dense analog crossings too high: ${metrics.crossings}`);
assert.ok(metrics.overlaps <= 2, `dense analog overlaps too high: ${metrics.overlaps}`);
await compareScene(page, "dense-analog");
} finally {
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);
});