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