Ship Phase 3 browser QA, visual baselines, and UX consistency

This commit is contained in:
Rbanh 2026-02-18 21:43:45 -05:00
parent 347d547875
commit d5836a20d1
15 changed files with 559 additions and 13 deletions

View File

@ -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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.playwright-cli/
output/playwright/
node_modules/

View File

@ -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/`.

View File

@ -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.

151
examples/dense-analog.json Normal file
View File

@ -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." }
]
}

View File

@ -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");

View File

@ -15,6 +15,7 @@
<div class="actions">
<button id="newProjectBtn" aria-label="Create new project">New</button>
<button id="loadSampleBtn" aria-label="Load sample project">Load Sample</button>
<button id="resetSampleBtn" aria-label="Reset to deterministic sample baseline">Reset Sample</button>
<button id="importBtn" aria-label="Import Schemeta JSON file">Import JSON</button>
<button id="exportBtn" aria-label="Export Schemeta JSON file">Export JSON</button>
<button id="autoLayoutBtn" aria-label="Run automatic layout">Auto Layout</button>

87
package-lock.json generated Normal file
View File

@ -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"
}
}
}
}

View File

@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

View File

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