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); });