Ship Phase 3 browser QA, visual baselines, and UX consistency
This commit is contained in:
parent
347d547875
commit
d5836a20d1
@ -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
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
.playwright-cli/
|
||||
output/playwright/
|
||||
node_modules/
|
||||
19
README.md
19
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/`.
|
||||
|
||||
@ -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
151
examples/dense-analog.json
Normal 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." }
|
||||
]
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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
87
package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
tests/baselines/ui/dense-analog.png
Normal file
BIN
tests/baselines/ui/dense-analog.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
BIN
tests/baselines/ui/explicit-mode-auto-tidy.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
tests/baselines/ui/initial.png
Normal file
BIN
tests/baselines/ui/initial.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
BIN
tests/baselines/ui/post-migration-apply.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 127 KiB |
BIN
tests/baselines/ui/selected-u2.png
Normal file
BIN
tests/baselines/ui/selected-u2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 125 KiB |
227
tests/ui-regression-runner.js
Normal file
227
tests/ui-regression-runner.js
Normal 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);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user