diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index addcfa9..0cac3fe 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -32,3 +32,15 @@ jobs: - name: Run tests run: npm test + + - name: Install Playwright browsers + run: npx playwright install chromium + + - name: Run browser regression + run: npm run test:ui + + - name: Browser artifacts listing (always) + if: always() + run: | + echo "Playwright output:" + ls -R output/playwright || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..73f016b --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.playwright-cli/ +output/playwright/ +node_modules/ diff --git a/README.md b/README.md index ac5520d..d069f9e 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Docs: CI: - `.gitea/workflows/ci.yml` runs syntax checks + full test suite on push/PR. +- CI also runs browser regression (`npm run test:ui`) after installing Playwright Chromium. ## REST API @@ -155,6 +156,7 @@ Tools: ## Workspace behavior highlights - Fit-to-view default on load/import/apply +- `Reset Sample` one-click deterministic baseline restore for QA/demo loops - Space + drag pan, wheel zoom, fit button - Net/component/pin selection with dimming + isolate toggles - Selected panel editors for component properties, full pin properties, full symbol body/pin editing, and net connect/disconnect operations @@ -166,3 +168,20 @@ Tools: - `Space` rotate selected components (or pan when no selection) - `Alt+Enter` apply current selection editor (component/pin/net) - `Alt+C` connect selected pin to chosen net + +## Browser Regression + +Run browser interaction + visual regression checks: + +```bash +npx playwright install chromium +npm run test:ui +``` + +Refresh visual baselines intentionally: + +```bash +UPDATE_SNAPSHOTS=1 npm run test:ui +``` + +Baselines are stored in `tests/baselines/ui/`. diff --git a/docs/release-checklist.md b/docs/release-checklist.md index d7ca4d5..af11b28 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -11,6 +11,7 @@ Use this checklist before cutting a release tag. ## Validation Gates - [ ] `npm test` passes. +- [ ] `npm run test:ui` passes. - [ ] Core smoke flow tested in UI: - [ ] Load sample - [ ] Edit component/pin/net/symbol @@ -22,6 +23,7 @@ Use this checklist before cutting a release tag. ## Visual Quality - [ ] Representative circuits reviewed for routing readability. +- [ ] Visual baselines updated intentionally (`tests/baselines/ui`) and screenshot diff checks pass. - [ ] Labels remain legible at common zoom levels. - [ ] No major overlap/crossing regressions vs previous release baseline. diff --git a/examples/dense-analog.json b/examples/dense-analog.json new file mode 100644 index 0000000..5f57c85 --- /dev/null +++ b/examples/dense-analog.json @@ -0,0 +1,151 @@ +{ + "meta": { + "title": "Electret mic + NPN preamp -> ESP32 ADC (dense analog)", + "version": "1.0" + }, + "symbols": { + "electret_mic_2pin": { + "symbol_id": "electret_mic_2pin", + "category": "analog", + "body": { "width": 90, "height": 56 }, + "pins": [ + { "name": "MIC+", "number": "1", "side": "right", "offset": 20, "type": "analog" }, + { "name": "MIC-", "number": "2", "side": "right", "offset": 38, "type": "ground" } + ] + }, + "npn_bjt_generic": { + "symbol_id": "npn_bjt_generic", + "category": "analog", + "body": { "width": 92, "height": 74 }, + "pins": [ + { "name": "B", "number": "1", "side": "left", "offset": 37, "type": "analog" }, + { "name": "C", "number": "2", "side": "top", "offset": 46, "type": "analog" }, + { "name": "E", "number": "3", "side": "bottom", "offset": 46, "type": "analog" } + ] + }, + "mcu_adc_1pin": { + "symbol_id": "mcu_adc_1pin", + "category": "generic", + "body": { "width": 126, "height": 64 }, + "pins": [ + { "name": "ADC_IN", "number": "1", "side": "left", "offset": 32, "type": "analog" }, + { "name": "3V3", "number": "2", "side": "top", "offset": 32, "type": "power_in" }, + { "name": "GND", "number": "3", "side": "bottom", "offset": 32, "type": "ground" } + ] + } + }, + "instances": [ + { "ref": "V1", "part": "connector", "properties": { "value": "3.3V/GND source" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "M1", "symbol": "electret_mic_2pin", "properties": { "value": "Electret mic" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "Q1", "symbol": "npn_bjt_generic", "properties": { "value": "2N3904" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "R1", "part": "resistor", "properties": { "value": "4.7k", "role": "mic bias" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "C1", "part": "capacitor", "properties": { "value": "1uF", "role": "mic AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "R2", "part": "resistor", "properties": { "value": "220k", "role": "base bias top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "R3", "part": "resistor", "properties": { "value": "100k", "role": "base bias bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "RC", "part": "resistor", "properties": { "value": "47k", "role": "collector resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "RE", "part": "resistor", "properties": { "value": "1k", "role": "emitter resistor" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "C2", "part": "capacitor", "properties": { "value": "1uF", "role": "output AC coupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "R4", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail top" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "R5", "part": "resistor", "properties": { "value": "100k", "role": "ADC mid-rail bottom" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "C3", "part": "capacitor", "properties": { "value": "100nF", "role": "ADC mid-rail filter" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "C4", "part": "capacitor", "properties": { "value": "100nF", "role": "local decoupling" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } }, + { "ref": "U1", "symbol": "mcu_adc_1pin", "properties": { "value": "ESP32-S3 (ADC pin)" }, "placement": { "x": null, "y": null, "rotation": 0, "locked": false } } + ], + "nets": [ + { + "name": "3V3", + "class": "power", + "nodes": [ + { "ref": "V1", "pin": "3V3" }, + { "ref": "R1", "pin": "1" }, + { "ref": "R2", "pin": "1" }, + { "ref": "RC", "pin": "1" }, + { "ref": "R4", "pin": "1" }, + { "ref": "C4", "pin": "1" }, + { "ref": "U1", "pin": "3V3" } + ] + }, + { + "name": "GND", + "class": "ground", + "nodes": [ + { "ref": "V1", "pin": "GND" }, + { "ref": "M1", "pin": "MIC-" }, + { "ref": "R3", "pin": "2" }, + { "ref": "RE", "pin": "2" }, + { "ref": "R5", "pin": "2" }, + { "ref": "C3", "pin": "2" }, + { "ref": "C4", "pin": "2" }, + { "ref": "U1", "pin": "GND" } + ] + }, + { + "name": "MIC_BIAS", + "class": "analog", + "nodes": [ + { "ref": "R1", "pin": "2" }, + { "ref": "M1", "pin": "MIC+" }, + { "ref": "C1", "pin": "1" } + ] + }, + { + "name": "BASE_NODE", + "class": "analog", + "nodes": [ + { "ref": "C1", "pin": "2" }, + { "ref": "R2", "pin": "2" }, + { "ref": "R3", "pin": "1" }, + { "ref": "Q1", "pin": "B" } + ] + }, + { + "name": "COLLECTOR_NODE", + "class": "analog", + "nodes": [ + { "ref": "Q1", "pin": "C" }, + { "ref": "RC", "pin": "2" }, + { "ref": "C2", "pin": "1" } + ] + }, + { + "name": "EMITTER_NODE", + "class": "analog", + "nodes": [ + { "ref": "Q1", "pin": "E" }, + { "ref": "RE", "pin": "1" } + ] + }, + { + "name": "ADC_MID", + "class": "analog", + "nodes": [ + { "ref": "R4", "pin": "2" }, + { "ref": "R5", "pin": "1" }, + { "ref": "C3", "pin": "1" }, + { "ref": "C2", "pin": "2" }, + { "ref": "U1", "pin": "ADC_IN" } + ] + } + ], + "constraints": { + "groups": [ + { + "name": "MicFrontEndCluster", + "members": ["M1", "R1", "C1", "Q1", "R2", "R3", "RC", "RE", "C4"], + "layout": "cluster" + }, + { + "name": "ADCBiasCluster", + "members": ["R4", "R5", "C3", "C2", "U1"], + "layout": "cluster" + } + ], + "near": [ + { "component": "C4", "target_pin": { "ref": "Q1", "pin": "C" } }, + { "component": "C3", "target_pin": { "ref": "U1", "pin": "ADC_IN" } } + ] + }, + "annotations": [ + { "text": "Dense analog frontend + ADC bias test case." } + ] +} diff --git a/frontend/app.js b/frontend/app.js index 885fd22..a002ea0 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -111,6 +111,7 @@ const el = { jsonEditor: document.getElementById("jsonEditor"), jsonFeedback: document.getElementById("jsonFeedback"), loadSampleBtn: document.getElementById("loadSampleBtn"), + resetSampleBtn: document.getElementById("resetSampleBtn"), newProjectBtn: document.getElementById("newProjectBtn"), importBtn: document.getElementById("importBtn"), exportBtn: document.getElementById("exportBtn"), @@ -317,6 +318,11 @@ function setStatus(text, ok = true) { el.compileStatus.className = ok ? "status-ok" : ""; } +function formatCompileStatus(result) { + const m = result?.layout_metrics ?? {}; + return `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings ?? 0} crossings, ${m.overlap_edges ?? 0} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)`; +} + function defaultProject() { return { meta: { title: "Untitled Schemeta Project" }, @@ -1631,10 +1637,7 @@ async function compileModel(model, opts = {}) { fitView(result.layout); } - const m = result.layout_metrics; - setStatus( - `Compiled (${result.errors.length}E, ${result.warnings.length}W | ${m.crossings} crossings, ${m.overlap_edges} overlaps, ${m.total_bends ?? 0} bends, ${m.label_tie_routes ?? 0} tie-nets, ${(m.detour_ratio ?? 1).toFixed(2)}x detour)` - ); + setStatus(formatCompileStatus(result)); } catch (err) { setStatus(`Compile failed: ${err.message}`, false); el.issues.textContent = `Compile error: ${err.message}`; @@ -2119,29 +2122,56 @@ async function runLayoutAction(path) { renderAll(); fitView(out.compile.layout); saveSnapshot(); - setStatus( - `Compiled (${out.compile.errors.length}E, ${out.compile.warnings.length}W | ${out.compile.layout_metrics.crossings} crossings, ${out.compile.layout_metrics.overlap_edges} overlaps, ${out.compile.layout_metrics.total_bends ?? 0} bends, ${out.compile.layout_metrics.label_tie_routes ?? 0} tie-nets)` - ); + setStatus(formatCompileStatus(out.compile)); } catch (err) { setStatus(`Layout action failed: ${err.message}`, false); } } -async function loadSample() { +async function fetchSampleModel() { const res = await fetch("/sample.schemeta.json"); if (!res.ok) { - setStatus("Sample missing.", false); - return; + throw new Error("Sample missing."); } + return res.json(); +} - const model = await res.json(); - if (state.model) { - pushHistory("load-sample"); +async function resetToSample(opts = {}) { + const push = opts.pushHistory !== false; + const before = state.model ? clone(state.model) : null; + const model = await fetchSampleModel(); + if (push && state.model) { + pushHistory("reset-sample"); } setSelectedRefs([]); state.selectedNet = null; state.selectedPin = null; + state.isolateNet = false; + state.isolateComponent = false; + state.userAdjustedView = false; + state.renderMode = "schematic_stub"; + el.renderModeSelect.value = "schematic_stub"; + state.showLabels = true; + el.showLabelsInput.checked = true; + el.instanceFilter.value = ""; + el.netFilter.value = ""; + el.instanceList.scrollTop = 0; + el.netList.scrollTop = 0; + closeSchemaModal(); await compileModel(model, { fit: true }); + if (before) { + el.jsonFeedback.textContent = `Reset sample. ${summarizeModelDelta(before, state.model)}`; + } else { + el.jsonFeedback.textContent = "Reset sample baseline loaded."; + } +} + +async function loadSample() { + try { + await resetToSample({ pushHistory: true }); + } catch (err) { + setStatus(String(err?.message ?? "Sample missing."), false); + } } function setupEvents() { @@ -3139,6 +3169,13 @@ function setupEvents() { }); el.loadSampleBtn.addEventListener("click", loadSample); + el.resetSampleBtn.addEventListener("click", async () => { + try { + await resetToSample({ pushHistory: true }); + } catch (err) { + setStatus(`Reset failed: ${err.message}`, false); + } + }); el.autoLayoutBtn.addEventListener("click", async () => { await runLayoutAction("/layout/auto"); diff --git a/frontend/index.html b/frontend/index.html index 7170f70..287bd2c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,6 +15,7 @@
+ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..bf2a9b5 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,87 @@ +{ + "name": "schemeta", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "schemeta", + "version": "0.1.0", + "devDependencies": { + "pixelmatch": "^7.1.0", + "playwright": "^1.58.2", + "pngjs": "^7.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/pixelmatch": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-7.1.0.tgz", + "integrity": "sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^7.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/pngjs": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", + "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.19.0" + } + } + } +} diff --git a/package.json b/package.json index 6db7ed3..c5097ee 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,13 @@ "start": "node src/server.js", "dev": "node --watch src/server.js", "test": "node --test", + "test:ui": "node tests/ui-regression-runner.js", + "test:ui:update-baselines": "UPDATE_SNAPSHOTS=1 node tests/ui-regression-runner.js", "mcp": "node src/mcp-server.js" + }, + "devDependencies": { + "pixelmatch": "^7.1.0", + "playwright": "^1.58.2", + "pngjs": "^7.0.0" } } diff --git a/tests/baselines/ui/dense-analog.png b/tests/baselines/ui/dense-analog.png new file mode 100644 index 0000000..fba3fa8 Binary files /dev/null and b/tests/baselines/ui/dense-analog.png differ diff --git a/tests/baselines/ui/explicit-mode-auto-tidy.png b/tests/baselines/ui/explicit-mode-auto-tidy.png new file mode 100644 index 0000000..35e1d5c Binary files /dev/null and b/tests/baselines/ui/explicit-mode-auto-tidy.png differ diff --git a/tests/baselines/ui/initial.png b/tests/baselines/ui/initial.png new file mode 100644 index 0000000..028d1ec Binary files /dev/null and b/tests/baselines/ui/initial.png differ diff --git a/tests/baselines/ui/post-migration-apply.png b/tests/baselines/ui/post-migration-apply.png new file mode 100644 index 0000000..93f1f9a Binary files /dev/null and b/tests/baselines/ui/post-migration-apply.png differ diff --git a/tests/baselines/ui/selected-u2.png b/tests/baselines/ui/selected-u2.png new file mode 100644 index 0000000..15344a0 Binary files /dev/null and b/tests/baselines/ui/selected-u2.png differ diff --git a/tests/ui-regression-runner.js b/tests/ui-regression-runner.js new file mode 100644 index 0000000..649412d --- /dev/null +++ b/tests/ui-regression-runner.js @@ -0,0 +1,227 @@ +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); +});