e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate

Squash of experiment/ff-big-modules vs main.

Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.

Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.

One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.

SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.

Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.

CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.

Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
This commit is contained in:
Viktor Vaczi 2026-07-17 12:10:40 +02:00
commit 63ed1f3c1f
556 changed files with 2470 additions and 1389 deletions

View file

@ -12,7 +12,6 @@ on:
branches: [main] branches: [main]
paths: paths:
- 'tests/baseline-screenshots/**.png' - 'tests/baseline-screenshots/**.png'
- 'tests/e2e/baseline-screenshots/**.png'
concurrency: concurrency:
group: screenshot-changelog-${{ github.ref }} group: screenshot-changelog-${{ github.ref }}

View file

@ -372,6 +372,21 @@ jobs:
working-directory: tests working-directory: tests
run: npm ci run: npm ci
# Cheap hygiene gates (no build needed): the determinism lint keeps the
# banned flake patterns out of the specs, the manifest check keeps
# screenshot-manifest.json in lockstep with the committed baselines
# (a stale manifest silently disables removed-screenshot detection), and
# the CI-coverage lint proves every spec file on disk is reachable from
# the npm scripts THIS workflow invokes (a spec/project that CI never
# runs is how the web suite once rotted unnoticed).
- name: Lint test determinism + screenshot manifest + CI coverage
if: inputs.run_tests
working-directory: tests
run: |
npm run lint:determinism
npm run screenshots:manifest -- --check
npm run lint:ci-coverage
- name: Install web workspace deps (collab bundle) - name: Install web workspace deps (collab bundle)
if: inputs.run_tests if: inputs.run_tests
working-directory: web working-directory: web
@ -413,37 +428,36 @@ jobs:
working-directory: tests working-directory: tests
run: npm run setup:kicad run: npm run setup:kicad
# The e2e suites run as separate steps so one suite's failure never skips # ONE merged Playwright invocation for every apps-server suite (wx,
# the others — every suite still renders its screenshots, so the screenshot # kicad×2 engines, asyncify, coroutine — see playwright.config.ts). One
# report below can show exactly what broke/went missing. A failing step # invocation = one start-of-run outputDir wipe BEFORE anything rendered,
# still fails the JOB (no continue-on-error); the later steps run anyway # so the engine-scoped screenshots in test-results/{chromium,firefox}/
# via `!cancelled()` + "previous stage wasn't skipped" guards # accumulate naturally for the offline compare. A failure fails the JOB
# (steps.wx_e2e.outcome != 'skipped' ⇔ the build reached the tests — on a # (no continue-on-error); the report below still runs and shows exactly
# build failure everything below stays skipped, as before). # what broke or went missing. xvfb: the Firefox projects run headed on CI
- name: wxWidgets e2e (npm run test:wx) # (GPU-less VMs have no headless GL).
id: wx_e2e - name: e2e (npm run test:e2e — all suites, both engines)
id: e2e
if: inputs.run_tests if: inputs.run_tests
working-directory: tests working-directory: tests
run: npm run test:wx run: xvfb-run -a npm run test:e2e
- name: Asyncify e2e (npm run test:asyncify:firefox) # The React web-app suite (web/) against the pnpm dev stack (frontend
id: asyncify_e2e # :3048 + backend :3060, cold-started by Playwright's webServer on CI).
if: inputs.run_tests && !cancelled() && steps.wx_e2e.outcome != 'skipped' # Runs even if the main e2e failed (`!cancelled()` + not-skipped guard) so
# its screenshots still reach the report.
- name: web e2e (npm run test:web:ci)
id: web_e2e
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
working-directory: tests working-directory: tests
run: npm run test:asyncify:firefox run: xvfb-run -a npm run test:web:ci
- name: KiCad e2e (npm run test:kicad:ci)
id: kicad_e2e
if: inputs.run_tests && !cancelled() && steps.wx_e2e.outcome != 'skipped'
working-directory: tests
run: xvfb-run -a npm run test:kicad:ci
# Runtime-perf E2E (eeschema + pcbnew): measures the current build's # Runtime-perf E2E (eeschema + pcbnew): measures the current build's
# load / open+render / FPS and writes tests/test-results/perf-*.json. # load / open+render / FPS and writes tests/test-results/perf-*.json.
# Track-only — never gates the build (continue-on-error). CI is # Track-only — never gates the build (continue-on-error). CI is
# headless/SwiftShader so FPS is CPU-bound + noisy; openMs is the stable number. # headless/SwiftShader so FPS is CPU-bound + noisy; openMs is the stable number.
- name: KiCad runtime perf (track-only, non-gating) - name: KiCad runtime perf (track-only, non-gating)
if: inputs.run_tests && !cancelled() && steps.kicad_e2e.outcome != 'skipped' if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true continue-on-error: true
working-directory: tests working-directory: tests
run: xvfb-run -a npm run test:perf run: xvfb-run -a npm run test:perf
@ -458,7 +472,7 @@ jobs:
# baseline-webgl/ and flip this step gating. 3d:review writes the full # baseline-webgl/ and flip this step gating. 3d:review writes the full
# 47-pair triptych gallery for the artifact upload below. # 47-pair triptych gallery for the artifact upload below.
- name: 3D renderer parity (report-only) - name: 3D renderer parity (report-only)
if: inputs.run_tests && !cancelled() && steps.wx_e2e.outcome != 'skipped' if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true continue-on-error: true
working-directory: tests working-directory: tests
run: | run: |
@ -479,7 +493,7 @@ jobs:
# the already-produced test-results (screenshots + perf-*.json). # the already-produced test-results (screenshots + perf-*.json).
- name: Screenshot report + perf - name: Screenshot report + perf
id: report id: report
if: inputs.run_tests && !cancelled() && steps.kicad_e2e.outcome != 'skipped' if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true continue-on-error: true
working-directory: tests working-directory: tests
env: env:
@ -490,9 +504,8 @@ jobs:
# without --fail-on-change; continue-on-error also shields transient Discord hiccups. # without --fail-on-change; continue-on-error also shields transient Discord hiccups.
run: | run: |
E2E=pass E2E=pass
{ [ "${{ steps.wx_e2e.outcome }}" = "success" ] \ { [ "${{ steps.e2e.outcome }}" = "success" ] \
&& [ "${{ steps.asyncify_e2e.outcome }}" = "success" ] \ && [ "${{ steps.web_e2e.outcome }}" = "success" ]; } || E2E=fail
&& [ "${{ steps.kicad_e2e.outcome }}" = "success" ]; } || E2E=fail
npm run screenshots:check npm run screenshots:check
npm run screenshots:report -- --e2e "$E2E" npm run screenshots:report -- --e2e "$E2E"

View file

@ -7,11 +7,11 @@ The e2e tests are in /tests, with a README and WHATWORKS md files
Test determinism rules (no blind sleeps/ifs, `stableShot` screenshots, retries:0) are in tests/TESTING.md, enforced by `npm run lint:determinism`. Test determinism rules (no blind sleeps/ifs, `stableShot` screenshots, retries:0) are in tests/TESTING.md, enforced by `npm run lint:determinism`.
The e2e tests are separated per feature The e2e tests are separated per feature
Wxwidgets wasm port has hooks for finding positions of UI elements, tests use that Wxwidgets wasm port has hooks for finding positions of UI elements, tests use that
The test screenshots are tracked with git; CI's Linux render is the source of truth (tooling: tests/tools/screenshots/, see its README). The test screenshots are tracked with git, per engine (tests/baseline-screenshots/{chromium,firefox}/); CI's Linux render is the source of truth (tooling: tests/tools/screenshots/, see its README).
To update baselines, promote a CI run's render (churn-free — only meaningfully-changed images restage): `cd tests && npm run screenshots:promote -- --run <ci-run-id>`, then commit. `npm run screenshots:check` is the local gate; on each main push CI posts a screenshot-diff + runtime-perf report to Discord. To update baselines, promote a CI run's render (churn-free — only meaningfully-changed images restage): `cd tests && npm run screenshots:promote -- --run <ci-run-id>`, then commit. `npm run screenshots:check` is the local gate; on each main push CI posts a screenshot-diff + runtime-perf report to Discord.
The tests have log files in tests/logs/{wxwidgets/kicad}/{test-name} after each run where the js console and cpp logs are visible The tests have log files in tests/logs/{wxwidgets/kicad}/{test-name} after each run where the js console and cpp logs are visible
Always check screenshots for validating tests Always check screenshots for validating tests
Run e2e tests from /tests folder: `npm run test:kicad` or `npm run test:e2e` (not playwright directly) Run e2e tests from /tests folder: `npm run test:e2e` (full CI project set, one merged playwright.config.ts) or `npm run test:kicad` (firefox shortcut) — not playwright directly. One spec/engine: `npx playwright test --project=kicad-firefox kicad/pcbnew.spec.ts`. Web-app suite: `npm run test:web`.
Build kicad with docker/build.sh (includes wxwidgets build, runs in docker) Build kicad with docker/build.sh (includes wxwidgets build, runs in docker)
Build wxwidgets standalone with scripts/build-wx-wasm.sh (runs on machine, for wxwidgets-only changes) Build wxwidgets standalone with scripts/build-wx-wasm.sh (runs on machine, for wxwidgets-only changes)

View file

@ -0,0 +1,57 @@
# Web-suite rot: footprint/symbol editor library-bridge flows (5 specs)
Surfaced 2026-07-15 by wiring the web suite into CI for the first time
(branch `experiment/ff-big-modules`, runs 29414003275 / 29415027412). These
specs were green when written and have silently rotted since — the web suite
never ran in CI, so nothing caught it.
## Affected specs (marked `test.fixme` — flip back when fixed)
| spec | first-broken symptom (local, web-chromium) | CI symptom |
|---|---|---|
| `web/footprint-browse-remote.spec.ts` | boots, lib tree lists libs, but expanding `Resistor_SMD` never produces child rows (FootprintEnumerate yields nothing) | `#canvas` never visible in 180 s |
| `web/footprint-write-remote.spec.ts` | boots; New Footprint + Ctrl+S never lands an item in the backend (`/api/scopes/default/libs/<lib>/items` stays empty 30 s) | same boot timeout |
| `web/footprint-write-spike.spec.ts` | boots (route fixed); `window.__pcbjamSaved` never captures a body after New Footprint + save (`?fpwrite=1` spike provider silent) | same boot timeout |
| `web/symbol-write-remote.spec.ts` | same family as footprint-write-remote, symbol domain | same boot timeout |
| `web/symbol-write-spike.spec.ts` | same family as footprint-write-spike (`?libwrite=1`) | same boot timeout |
## Evidence pointing at one root cause
All five live in the same domain: `window.kicadLibs.request(...)` traffic from
the footprint/symbol EDITOR tools (enumerate / save). Probing a booted
`/default/projects/demo/-/footprint_editor` locally:
- the lib tree lists the origin libs (Capacitor_SMD, Diode_SMD, LED_SMD,
My Symbols, Resistor_SMD) — the *list* path works (pre-sync/IDB);
- `__libsCalls` (a wrapper capturing every `kicadLibs.request`) records ZERO
calls during boot and ZERO on expanding a lib — the per-lib
enumerate/get/save traffic never happens;
- one `[pageerror] __name is not defined` fires at boot — esbuild's
keep-names helper missing in whatever context executes a bundled callback;
prime suspect for the provider dying silently on first use.
By contrast the SCHEMATIC editor's bridge works (eeschema-fp-selector records
`index` calls), and eeschema/pcbnew tool pages boot and pass their specs — the
rot is specific to the fp/sym editor lib flows, not the bridge as a whole.
Separately fixed while triaging (NOT part of this bug): dead `/p/<slug>/<tool>/`
routes in 3 of these specs (router grammar is `/:scope/projects/:name/-/:tool`
since the scope/kind/name change), the tool-switch overlay race + `/p/` URL
asserts, and read-only-editor's cross-tab SelectFirst-order assumption.
## Why fixme and not expected-fail
The ysync convention (expected-fail repros) is right for fast unit-level
repros. These five die in 180 s boot timeouts on CI — as `test.fail()` they
would burn ~30 min of CI per run across two engines for zero extra signal.
`test.fixme` keeps them visible in every report as skipped-with-reason;
remove the marker (and delete this table row) when the bridge flow is fixed.
## CI-only sibling (kept RUNNING, conditional expected-fail)
`web/eeschema-fp-selector.spec.ts` completes its flow but records a
`[pageerror] index out of bounds` wasm trap on CI (BOTH engines,
llvmpipe/SwiftShader) that does not reproduce on a Mac with real GL — see
`test.fail(!!process.env.CI, …)` in the spec. Likely the same software-GL
render-path family as the presence ghost-wipe (SwiftShader pass-boundary
flush, webgl_gal.cpp). Needs its own investigation on a CI-like rig.

View file

@ -0,0 +1,119 @@
# occ-export flake: SwiftShader congestion → GAL recovery cascade (fixed by GL-stack swap)
Status 2026-07-16: fully root-caused with page-level canvas/context telemetry +
temp C++ instrumentation in a local docker rig. **Final remedy: CI Chromium
now runs WebGL on ANGLE → Mesa llvmpipe instead of SwiftShader**
(`tests/playwright.config.ts`, `CHROMIUM_CI_ARGS`) — the same software-GL
stack the Firefox projects use, where none of this ever happened. Four small
product fixes found along the way were kept (below); everything else was
deliberately **not** hardened — the trigger cannot occur outside congested
SwiftShader.
## Symptom
`chromium/occ-export-{dialog,done}.png` bistable on CI only: the board behind
the modal STEP-export dialog flips between runs (~1.2% changedRatio both
directions). Firefox (llvmpipe) never affected.
## The causal chain (every step observed in the rig)
1. **Transient SwiftShader failure at the first post-board-load draw**
(~10-15% of instances at `REPEAT=40-80 WORKERS=4`): `DoRePaint threw:
Requested render buffer size is not supported`, plus LRU context evictions
the moment a 3rd context appears (21/80 runs; the usual victim is a leaked
hidden 881×159 startup GAL canvas, so the `CONTEXT_LOST_WEBGL` line alone
was harmless). No flag stops it — `--max-active-webgl-contexts=64`,
`--force-gpu-mem-available-mb=4096`, `--disable-low-end-device-mode`,
`--disable-gpu-driver-bug-workarounds` all verified present and useless.
2. **`recoverFromGalError` attempt 1** (`common/draw_panel_gal.cpp:205`)
destroys + recreates the WebGL GAL (board canvas `glcanvas-2` →
`glcanvas-3`).
3. **Attempt 1 then always failed** on stale cross-context GL state:
- static `g_fontTexture` bound on the new context → `INVALID_OPERATION:
bindTexture: object does not belong to this context` → `checkGlError`
throw (**fixed — kept**: per-instance `m_fontTexture`,
`common/gal/webgl/webgl_gal.{cpp,h}`);
- sticky GL error flags left by the old GAL's teardown (its `LockCtx`
targets a lost context, so the deletes land on the current one) →
the fresh GAL's first `checkGlError` ("generating vertices buffer:
invalid enum") misattributed the leftover and threw (**fixed — kept**:
drain `glGetError()` before first-frame `init()` in
`WEBGL_GAL::BeginDrawing`). With both fixes, reinit-on-WebGL succeeds
(validated: 0 Cairo fallbacks / 60 after, vs 100% of recoveries before).
4. **Attempt 2 = silent permanent Cairo fallback** (`GAL_FALLBACK ==
GAL_TYPE_CAIRO` on emscripten, dialog suppressed). Cairo renders the board
through the wx software path — shots LOOK painted but are a different
engine → subtle whole-board diffs vs the WebGL baseline. **This engine
flip IS the original "~1.2% changedRatio both directions" flake.**
`FULLSCREEN_QUAD` (singleton VAOs) is the same static-GL-state disease and
produced `bindVertexArray: object does not belong` + one wasm abort in
rare runs — NOT fixed (unreachable once recovery never triggers; noted
here in case in-app recovery robustness is ever wanted).
5. **wx paint-flag races turned it into a stuck-blank board** (both
**fixed — kept**, `wxwidgets/src/wasm/window.cpp`):
- `Invalidate` early-out skipped the upward walk while the sweep can
strand a window "flags set, ancestors clear" → every later `Refresh()`
swallowed. Fix: always propagate to the top.
- `PaintSelf`/`PaintChildren` cleared the dirty flags AFTER dispatching
paint, so an `Invalidate` issued reentrantly from inside a paint handler
(canvas recreated mid-frame, board-load refresh mid-sweep) was clobbered
with nothing re-issuing it. Fix: `DoPaint` snapshots + clears both flags
BEFORE dispatching. (Pre-fix rigs showed stuck half-painted toolbars —
this is a real production-reachable bug, not a CI artifact.)
One rare mode ("grid-mode": board data loaded, UI painted, canvas alive,
items never drawn, zoom-to-fit doesn't help; VIEW had all 464 items and
the same Clear/DisplayBoard ordering as healthy runs) was still being
diagnosed when the GL-stack swap made it — and everything above —
unobservable: **80/80 runs clean on llvmpipe**, all dialog shots pixel-
identical (single 55.65% ratio value across all 80; SwiftShader scattered
over 55.5–56.8%).
## The fix that ships
`CHROMIUM_CI_ARGS` = `--use-gl=angle --use-angle=gl --ignore-gpu-blocklist`
(CI-gated). Requirements, all already present on CI: an X display
(`xvfb-run -a` wraps the whole e2e step) and Mesa (installed by
`playwright install --with-deps`). `--ignore-gpu-blocklist` is mandatory —
llvmpipe is on Chromium's software-GL blocklist and WebGL silently reports
unavailable without it (the exact analog of the Firefox projects'
`webgl.force-enabled`). Headless works; headed is NOT needed.
Renderer-string ground truth (Chrome falls back silently — always verify):
`gl.getExtension('WEBGL_debug_renderer_info')` must report
`ANGLE (Mesa, llvmpipe …)`, not SwiftShader.
Consequence: chromium renders shift slightly → the chromium baseline set
must be re-promoted once from the first green CI run
(`cd tests && npm run screenshots:promote -- --run <id>`).
## Test-side hardening (kept, independent)
Popup menu items register in the element registry progressively while the
menu paints; a coarse ">3 menuitems" gate + single-shot `clickMenuItem` races
(4–8/20 under contention, pre-existing). Rule (tests/TESTING.md): wait for the
specific item via `waitForRenderedByLabel(page, label, { elementType:
'menuitem' })` before every `clickMenuItem`. Applied to `occ-export`,
`occ-export-models`, `3d-viewer-models`, `load-pcb`; `pl_editor` already did
this; `clickMenuItemByText` waits internally.
## Open leads (not blocking)
- The leaked hidden 881×159 startup GAL canvas: destroying it would free a
context + memory.
- If in-app GL-error recovery ever matters on real GPUs: fix the
`FULLSCREEN_QUAD` singleton VAOs (per-instance like the font texture) and
audit remaining cross-context statics.
## Repro rig (for revalidation)
Docker `mcr.microsoft.com/playwright:v1.57.0-jammy`, worktree bind-mount,
container-local node_modules volume, `CI=1`, `xvfb-run -a npx playwright test
--project=kicad-chromium --repeat-each=N --workers=4` with a temp spec that
loads `pic_programmer`, screenshots board-load / settled / export-dialog /
post-poke, and logs the WebGL renderer string + canvas/context telemetry per
instance. Canaries: `CONTEXT_LOST_WEBGL`, `does not belong to this context`,
board-strip pixel ratio of the shots.

2
kicad

@ -1 +1 @@
Subproject commit 261621e87174886d6fd4633e8bfb7f138908d56b Subproject commit 3dcfea5e458418c1d6cc03b1baecd07424211ff7

View file

@ -19,17 +19,21 @@ This builds `apps/minimal_test.{html,js,wasm}` and standalone test apps.
```bash ```bash
npm install npm install
npm test # wx e2e specs + the asyncify race harness npm test # setup:kicad + the full merged run (same projects as CI)
``` ```
KiCad application tests are a separate, heavier suite (they need the One merged config (`playwright.config.ts`) drives every wasm suite as
docker-built KiCad WASM): `npm run test:kicad`. Run only the wx e2e specs with Playwright *projects*; `npm run test:e2e` runs the CI set: `wx-chromium`,
`npm run test:wx`. `kicad-firefox`, `kicad-chromium`, `asyncify-firefox`, `coroutine-firefox`.
The KiCad specs (heavier — they need the docker-built KiCad WASM) run on BOTH
engines; `npm run test:kicad` is the firefox-only shortcut. The React web app
suite is separate: `npm run test:web` (see `playwright-web.config.ts`).
To run specific tests: To run a subset, pick a project (and optionally a spec):
```bash ```bash
npx playwright test menu.spec.ts # Run menu tests only npx playwright test --project=wx-chromium menu.spec.ts # wx menu tests only
npx playwright test --grep "wxTimer" # Run tests matching pattern npx playwright test --project=kicad-firefox kicad/pcbnew.spec.ts
npx playwright test --project=wx-chromium --grep "wxTimer"
``` ```
## Test Structure ## Test Structure
@ -49,12 +53,14 @@ tests/
│ ├── wxwidgets.spec.ts # Comprehensive UI interaction tests │ ├── wxwidgets.spec.ts # Comprehensive UI interaction tests
│ └── ... │ └── ...
├── logs/ # Test logs (auto-generated) ├── logs/ # Test logs (auto-generated)
├── test-results/ # Screenshots (auto-generated) ├── test-results/{chromium,firefox}/ # Screenshots per engine (auto-generated)
├── baseline-screenshots/ # Reference screenshots for comparison ├── baseline-screenshots/{chromium,firefox}/ # Committed reference screenshots per engine
├── screenshot-manifest.json # Authoritative {name, engine} list (npm run screenshots:manifest)
├── apps/ # Built WASM test applications ├── apps/ # Built WASM test applications
│ ├── minimal_test.html # Main test app │ ├── minimal_test.html # Main test app
│ └── standalone/ # Individual component test apps │ └── standalone/ # Individual component test apps
└── playwright.config.ts # Playwright configuration ├── playwright.config.ts # THE merged config (wx / kicad / asyncify / coroutine / perf projects)
└── playwright-web.config.ts # React web-app suite (own server stack)
``` ```
## Logging ## Logging
@ -76,10 +82,15 @@ Example log format:
## Screenshots ## Screenshots
Tests capture screenshots to `test-results/`. Compare against baselines: Tests capture raw PNGs to `test-results/<engine>/` (engine-scoped via
`stableShot`/`shotPath`). The offline gate compares them against the committed
per-engine baselines:
```bash ```bash
../scripts/compare-screenshots.sh npm run screenshots:check
``` ```
CI's Linux render is the source of truth — update baselines by promoting a CI
run (`npm run screenshots:promote -- --run <ci-run-id>`), never by copying
local renders. Rules and details: `TESTING.md`.
## Viewing the App Directly ## Viewing the App Directly

View file

@ -1,8 +1,11 @@
# Testing rules # Testing rules
Determinism rules for the Playwright specs (`tests/e2e`, `tests/kicad`, `tests/web`). Determinism rules for the Playwright specs (`tests/e2e`, `tests/kicad`, `tests/web`).
Enforced by `npm run lint:determinism` (`tools/lint-determinism.ts`). Run specs from `tests/` Enforced by `npm run lint:determinism` (`tools/lint-determinism.ts`, gating in CI). Run specs
via `npm run test:kicad` (firefox) / `npm run test:e2e` (chromium) — not playwright directly. from `tests/` via `npm run test:e2e` (the full CI project set: wx-chromium, kicad-firefox,
kicad-chromium, asyncify-firefox, coroutine-firefox) or `npm run test:kicad` (kicad-firefox
only) — not playwright directly. One spec on one engine:
`npx playwright test --project=kicad-firefox kicad/pcbnew.spec.ts`.
## Waits — never blind ## Waits — never blind
@ -14,6 +17,13 @@ via `npm run test:kicad` (firefox) / `npm run test:e2e` (chromium) — not playw
- The **only** allowed `waitForTimeout` is an irreducible interaction dwell — a canvas/keyboard - The **only** allowed `waitForTimeout` is an irreducible interaction dwell — a canvas/keyboard
commit with no JS-observable signal — and it MUST carry a same-line marker: commit with no JS-observable signal — and it MUST carry a same-line marker:
`// eslint-disable-line -- documented interaction dwell: <why>`. `// eslint-disable-line -- documented interaction dwell: <why>`.
- **Menu clicks: wait for the specific item, not a count.** Popup items register in the element
registry progressively as they paint, so a coarse gate ("N menuitems rendered") can pass before
the item you're about to click exists — and `clickMenuItem` is single-shot. Before every
`clickMenuItem(page, 'X')`, `await waitForRenderedByLabel(page, 'X', { elementType: 'menuitem' })`
(same matcher as the click). `clickMenuItemByText` already waits internally and needs no guard.
A submenu click needs its own wait: the parent menu's still-rendered items satisfy any count gate
before the submenu paints.
## No defensive branches ## No defensive branches
@ -26,10 +36,16 @@ via `npm run test:kicad` (firefox) / `npm run test:e2e` (chromium) — not playw
## Screenshots — `stableShot`, compared offline ## Screenshots — `stableShot`, compared offline
- Capture with **`stableShot(page, 'name.png', { fullPage })`** — it settles the render (in-page - Capture with **`stableShot(page, 'name.png', { fullPage })`** — it settles the render (in-page
canvas-hash over animation frames) then writes a raw PNG to `test-results/`. It does **not** canvas-hash over animation frames) then writes a raw PNG to
assert. Never use Playwright's `toHaveScreenshot`. `test-results/<engine>/` (`chromium`/`firefox`, derived from the running browser — the same
- Comparison is offline: `npm run screenshots:check` diffs `test-results/` against the committed spec on two engines writes two files). It does **not** assert. Never use Playwright's
baselines in `tests/baseline-screenshots/` (+ `3d-regression/`, `gal-regression/`). `toHaveScreenshot`. Raw `page.screenshot`/fs writers must route through
`shotPath(page, 'name.png')` for the same engine scoping.
- Comparison is offline and per-engine: `npm run screenshots:check` diffs
`test-results/<engine>/` against the committed baselines in
`tests/baseline-screenshots/<engine>/` (+ `3d-regression/`, `gal-regression/`).
`tests/screenshot-manifest.json` (regen: `npm run screenshots:manifest`) is the authoritative
{name, engine} list — CI fails the lint step if it drifts from the baseline tree.
- **CI's Linux render is the source of truth**; baselines are promoted from CI - **CI's Linux render is the source of truth**; baselines are promoted from CI
(`npm run screenshots:promote -- --run <ci-run-id>`). A local (Mac) check shows font/render (`npm run screenshots:promote -- --run <ci-run-id>`). A local (Mac) check shows font/render
noise and is not the gate. noise and is not the gate.
@ -37,9 +53,27 @@ via `npm run test:kicad` (firefox) / `npm run test:e2e` (chromium) — not playw
## Retries ## Retries
- **`retries: 0`** in both configs. A failure is real; don't mask it with a retry. - **`retries: 0`** in both configs (`playwright.config.ts` — the merged wasm-suite config —
and `playwright-web.config.ts`). A failure is real; don't mask it with a retry.
## Every spec runs in CI — `lint:ci-coverage`
`npm run lint:ci-coverage` (`tools/lint-ci-coverage.ts`, gating in CI next to the
determinism lint) proves every `*.spec.ts` under `tests/` is actually executed by CI:
it scrapes the `npm run test:…` invocations from `.github/workflows/`, resolves them
through `package.json` to their `playwright test --config/--project` flags, and asks
Playwright itself (`--list`) which files those runs cover. No hand-maintained lists —
adding a spec in a brand-new directory is exactly what it catches.
When it fires:
- `uncovered-spec` — your new spec matches no CI-run project. Put it in a covered
`testDir`, adjust a project's `testMatch`, or add the project to a CI npm script.
- `orphan-project` — you added a config project no CI script selects. Wire it into a
CI script, or (for deliberately-local system-browser projects) add it to
`LOCAL_ONLY_PROJECTS` in the lint with a comment saying why.
## Where things are ## Where things are
- Per-test logs (JS console + cpp): `tests/logs/{wxwidgets,kicad}/<test-name>/`. - Per-test logs (JS console + cpp): `tests/logs/{wxwidgets,kicad}/<test-name>/`.
- Guard: `npm run lint:determinism`. Screenshot gate: `npm run screenshots:check`. - Guards: `npm run lint:determinism`, `npm run lint:ci-coverage`.
Screenshot gate: `npm run screenshots:check`.

View file

@ -387,73 +387,56 @@ cd tests/apps && ./build-test-apps.sh
## Baseline Screenshots ## Baseline Screenshots
The test suite captures screenshots during test runs for visual regression testing. These screenshots are compared against a baseline to detect unexpected visual changes. The test suite captures raw PNGs during test runs for visual regression testing,
compared offline against committed per-engine baselines (the calibrated gate in
`tools/screenshots/`).
### Directory Structure ### Directory Structure
``` ```
tests/ tests/
├── baseline-screenshots/ # Known-good reference screenshots (146+ files) ├── baseline-screenshots/
├── test-results/ # Screenshots from latest test run │ ├── chromium/ # Known-good references, Chromium render
└── apps/ │ └── firefox/ # Known-good references, Firefox render
└── e2e/ # Playwright test specs ├── test-results/
│ ├── chromium/ # Latest run's captures per engine
│ └── firefox/
└── screenshot-manifest.json # Authoritative {name, engine} list
``` ```
Specs write via `stableShot(page, 'name.png')` / `shotPath(page, 'name.png')`
(`e2e/utils/element-tracker.ts`) — the engine subdir is derived from the running
browser, so the same spec on two engines produces two independent captures.
### Comparing Screenshots ### Comparing Screenshots
Use the comparison script to check for visual regressions:
```bash ```bash
./scripts/compare-screenshots.sh cd tests
npm run screenshots:check
``` ```
This will: This diffs `test-results/<engine>/` against `baseline-screenshots/<engine>/` with
- Compare all baseline screenshots with current test results per-engine calibrated noise floors and reports CHANGED / ADDED / REMOVED (removed
- Report identical, different, and missing screenshots detection is driven by the manifest). On each main push, CI posts the same report
- Show file size differences (useful for detecting changes) (triptych images labeled `[chromium]`/`[firefox]`) to Discord.
Example output:
```
=== Screenshot Comparison ===
Baseline: /path/to/tests/baseline-screenshots
Test Results: /path/to/tests/test-results
=== Comparing Baseline Screenshots ===
DIFFERENT: dialogs-msgbox-info-open.png (baseline: 47468B, current: 47478B, diff: 10B / .02%)
=== Summary ===
Total baseline screenshots: 121
Identical: 54
Different: 67
Missing from test results: 0
```
### Understanding Differences
Small byte differences (<2%) are typically caused by:
- **Timestamps** in event logs (e.g., `[19:45:35]` vs `[18:49:35]`)
- **PNG compression** variations between runs
- **Anti-aliasing** differences
These are **not** visual regressions. Visually inspect screenshots if you see larger differences.
### Updating Baseline Screenshots ### Updating Baseline Screenshots
After verifying changes are intentional, update the baseline: CI's Linux render is the source of truth — never copy local (Mac) renders into
the baselines. Promote from a CI run instead (churn-free: only meaningfully
changed images restage, and the manifest regenerates automatically):
```bash ```bash
# Copy current screenshots to baseline cd tests
cp tests/test-results/*.png tests/baseline-screenshots/ npm run screenshots:promote -- --run <ci-run-id>
git commit
# Or selectively update specific screenshots
cp tests/test-results/dialog-*.png tests/baseline-screenshots/
``` ```
### Running Tests with Screenshots ### Running Tests with Screenshots
```bash ```bash
cd tests cd tests
npm test # wx e2e specs + asyncify harness (kicad: npm run test:kicad) npm test # setup + the full merged run (all CI projects)
npx playwright test --ui # Interactive mode with screenshot preview npx playwright test --ui # Interactive mode with screenshot preview
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

View file

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

View file

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View file

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Before After
Before After

Some files were not shown because too many files have changed in this diff Show more