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
2026-07-17 12:10:40 +02:00
|
|
|
/**
|
|
|
|
|
* CI-coverage guard: every spec file on disk must be RUN BY CI. Fails (exit 1)
|
|
|
|
|
* if a test can be added without CI ever executing it — the failure mode that
|
|
|
|
|
* let the web suite rot unrun for months. Run locally or in CI:
|
|
|
|
|
*
|
|
|
|
|
* npx tsx tools/lint-ci-coverage.ts # gate (exit 1 on any violation)
|
|
|
|
|
* npm run lint:ci-coverage
|
|
|
|
|
*
|
|
|
|
|
* Both sides come from ground truth, no hand-maintained file lists:
|
|
|
|
|
* - "what CI runs": `npm run test:…` invocations scraped from
|
|
|
|
|
* .github/workflows/*.yml, resolved through package.json to their
|
|
|
|
|
* `playwright test --config/--project` flags;
|
|
|
|
|
* - "what that covers": `playwright test --list --reporter=json` with those
|
|
|
|
|
* exact flags (CI=1), so testDir/testMatch/testIgnore/project semantics are
|
|
|
|
|
* Playwright's own, never re-implemented here.
|
|
|
|
|
*
|
|
|
|
|
* Rules:
|
|
|
|
|
* - uncovered-spec: a *.spec.ts under tests/ that no CI invocation lists.
|
|
|
|
|
* - orphan-project: a project defined in a config that no CI script selects
|
|
|
|
|
* and that is not explicitly allowlisted as local-only below.
|
|
|
|
|
*/
|
|
|
|
|
import { execFileSync } from 'child_process';
|
|
|
|
|
import * as fs from 'fs';
|
|
|
|
|
import * as path from 'path';
|
|
|
|
|
|
|
|
|
|
const TESTS_ROOT = path.resolve(__dirname, '..');
|
|
|
|
|
const REPO_ROOT = path.resolve(TESTS_ROOT, '..');
|
|
|
|
|
const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
|
|
|
|
|
|
|
|
|
|
// Deliberately-local projects (system browsers CI does not install, or manual
|
|
|
|
|
// policy runs). A NEW project must either be selected by a CI-invoked script
|
|
|
|
|
// or be added here on purpose — silence is exactly how the web suite rotted.
|
|
|
|
|
const LOCAL_ONLY_PROJECTS = new Set([
|
|
|
|
|
'kicad-chrome', // system Chrome, headed KiCad debugging
|
jspi: migration phases 0-7 — build knob, scheduler shim, test successor suite
Toolchain: emsdk 6.0.6 (versions.sh; cache-hash keys on it). Build knob
PCBJAM_ASYNC_BACKEND=jspi|asyncify: build-kicad-target.sh links editors with
-sJSPI + -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt + --pre-js
jspi-scheduler.js (no DYNCALLS, no post-link asyncify pipeline); wx build
stamps the backend and forces clean on flip or unknown provenance;
docker/build.sh passes the knob, seeds the emscripten ports cache from the
volume every launch, jspi postprocess = patch-env-shim only.
scripts/common/shims/jspi-scheduler.js: the JSPI successor scheduler —
token-wait registry, resume turnstile (one armed resume between engine
re-entries, SP swaps only at microtask boundaries), green-region spill
stacks (16-aligned tops), S1 embind mutator FIFO lane + parker wraps, S6
shutdown, libctx integration hooks (suspend/end/quarantine + g_current
arm/clear), SuspendError attributor, lost-wake + stuck-window watchdogs,
__wxWaitDump observability.
Embind: PARKER registrations get emscripten::async() under PCBJAM_JSPI
(wasm/bindings/pcbjam_async_policy.h). nanosleep yields route via the shim.
Tests: tests/asyncify -> tests/jspi successor suite (jspi-stack red/green
shadow-stack battery, jspi-coroutine MiniCoro harness, suspend-races
semantic scenarios + __wxWaitDump books coherence); projects jspi-firefox/
jspi-chrome (asyncify-webkit retired — no JSPI in WebKit); unconditional
Firefox JSPI pref; guard-beacons -> wait-beacons (+wxScheduler/libctxJspi
families); Makefile.wasm links test apps against JSPI with the shim as a
tracked link prerequisite.
Web: WasmTool setRo await + __wxWaitDump forensics, open-flow contained
promise, scheduler-shim.test.ts retargeted (8 green).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
2026-08-13 07:06:24 +02:00
|
|
|
'jspi-chrome', // system Chrome (real V8; run manually via test:jspi:chrome)
|
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
2026-07-17 12:10:40 +02:00
|
|
|
'coroutine-chrome', // system Chrome (real V8/GPU)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
// Dirs under tests/ that never contain source specs.
|
|
|
|
|
const EXCLUDED_DIRS = new Set([
|
|
|
|
|
'node_modules',
|
|
|
|
|
'apps',
|
|
|
|
|
'fixtures',
|
|
|
|
|
'test-results',
|
|
|
|
|
'pw-artifacts',
|
|
|
|
|
'playwright-report',
|
|
|
|
|
'logs',
|
|
|
|
|
'baseline-screenshots',
|
|
|
|
|
'3d-regression',
|
|
|
|
|
'tools',
|
|
|
|
|
'collab',
|
|
|
|
|
'scripts',
|
|
|
|
|
]);
|
|
|
|
|
|
findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.
Services / transport
E-10 retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
mirror stayed latched true after a mid-run worker death: Run stayed
disabled and the promised fresh-worker restart was unreachable for the
whole session. Retirement now dispatches a synthetic controlled-exit
straight to the installed handler (never through dispatchEvt — a
fabricated frame must not touch the credit ledger). Driving the repro
exposed two further defects, both fixed here: a replacement worker
trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
that uninitialized engine before KiCad's validate() re-init (the native
flow assumes a crashed engine survives in-process — true for the dll,
false for a dead worker). Reads now answer their empty shapes pre-init,
writes lazy-init, and init is idempotent per worker engine.
E-19 dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
client deliberately rethrows non-trap errors — so each throw leaked one
unit of the 64-frame credit window until the stream died with a
misattributed "transport exceeded". The ack moves to a finally in both
service copies; the throw still propagates (the trap machinery needs it).
E-20 the oversize-line path promises to transfer the accepted prefix, but
with the window full that flush only DEFERS, and stopEventStream wiped
the deferred queue — losing the diagnostics that explain the failure.
The terminal notice now carries them as pendingEvents; both hosts
deliver them in order, unacked (the fatal frame is outside the credit
protocol).
E-21 the 30s prefetch deadline discarded every model already collected and
reported nothing. A caller-owned progress sink ships the partials and
the omission reaches the export report. (Awaiting the aborted collection
was rejected: an in-flight source fetch is not abortable — E-4's
original disease.) Plus a serving-candidate memo, so a .wrl ref served
by its .step fallback stops re-probing the miss on every export.
Scheduler
E-14 _terminalizeNativeTrap classified by message substring, so any plain JS
error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
healthy instance. Now structural only: instanceof RuntimeError plus a
duck-typed name check (verified in this build's glue that abort() throws
a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
now latches the gate — the authoritative notification, previously
ignored.
E-15 the shim half: _pumpResume gates on terminal (catching wakes already
queued at latch time) and resolveWait refuses on terminal WITHOUT
consuming the entry, so a frame stays visibly parked rather than
resuming inside a trapped module.
E-16 the E-5 handler read the realm-global scheduler at dispatch instead of
its installing module's; also frees the per-line buffer on the non-trap
rethrow path.
E-11 get_vec trusted the worker's res.length over the transferred arrays.
Observed death shape: a 4 GiB std::vector threw an unhandled
std::length_error that exited the editor's main loop. Now clamped, with
the buffers freed on every failure path.
Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
E-18 the source contract asserted comment-string counts — rewording failed
CI while moving a guard outside its #ifdef passed. It now parses the
#ifdef regions and asserts on code.
service-stub-parity.ts pins what the four lifecycle copies must share:
credit-window equality parsed from source, the finally-ack, boot
deadlines, terminal-notice consumption. The transport numbers are now
single-sourced from the worker.
CI actually runs the gates: the web/standalone vitest suites (which had
NEVER run in CI), the reducer, the source contract and the parity tool —
with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
E-22 the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
the ngspice-stub shape with a wedgeNextBoot() repro hook.
Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.
Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.
Bumps kicad and wxwidgets to their findings-group-e tips.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:27:12 +02:00
|
|
|
// Non-playwright gates CI must keep invoking. This lint's per-spec model only
|
|
|
|
|
// understands "npm run test:*" playwright scripts; these gates (vitest for
|
|
|
|
|
// web/standalone, the ngspice transport reducer, and the findings-E source/
|
|
|
|
|
// parity contracts) live outside that model — so pin their literal workflow
|
|
|
|
|
// invocations here. Deleting a step from a workflow re-fails this lint,
|
|
|
|
|
// closing the exact "nothing runs it" rot class findings-E was about. (The
|
|
|
|
|
// vitest include glob auto-covers new *.test.ts files, so per-file coverage
|
|
|
|
|
// needs no proof.)
|
|
|
|
|
const NON_PLAYWRIGHT_GATES = [
|
|
|
|
|
'pnpm --filter @pcbjam/standalone test',
|
|
|
|
|
'npm run ngspice:worker-batch',
|
|
|
|
|
'npm run findings-e:contract',
|
|
|
|
|
'npm run findings-e:parity',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function assertNonPlaywrightGates(): void {
|
|
|
|
|
const bodies: string[] = [];
|
|
|
|
|
for (const f of fs.readdirSync(WORKFLOWS_DIR)) {
|
|
|
|
|
if (!/\.ya?ml$/.test(f)) continue;
|
|
|
|
|
bodies.push(fs.readFileSync(path.join(WORKFLOWS_DIR, f), 'utf8'));
|
|
|
|
|
}
|
|
|
|
|
const all = bodies.join('\n');
|
|
|
|
|
const missing = NON_PLAYWRIGHT_GATES.filter((cmd) => !all.includes(cmd));
|
|
|
|
|
if (missing.length) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`non-playwright CI gate(s) missing from ${WORKFLOWS_DIR}: ` +
|
|
|
|
|
missing.map((m) => `"${m}"`).join(', ') +
|
|
|
|
|
' — a gate nothing invokes protects nothing'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-07-17 12:10:40 +02:00
|
|
|
// ── 1. what CI invokes ────────────────────────────────────────────────────────
|
|
|
|
|
function ciTestScripts(): string[] {
|
|
|
|
|
const names = new Set<string>();
|
|
|
|
|
for (const f of fs.readdirSync(WORKFLOWS_DIR)) {
|
|
|
|
|
if (!/\.ya?ml$/.test(f)) continue;
|
|
|
|
|
const body = fs.readFileSync(path.join(WORKFLOWS_DIR, f), 'utf8');
|
|
|
|
|
for (const m of body.matchAll(/npm run (test:[\w:.-]+)/g)) names.add(m[1]);
|
|
|
|
|
}
|
|
|
|
|
if (!names.size) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`no "npm run test:…" invocations found under ${WORKFLOWS_DIR} — ` +
|
|
|
|
|
'either CI stopped running tests or this lint\'s scrape regex rotted'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return [...names].sort();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 2. resolve scripts to playwright invocations ─────────────────────────────
|
|
|
|
|
type Invocation = { script: string; config: string; projects: string[] };
|
|
|
|
|
|
|
|
|
|
function resolveScript(name: string): Invocation {
|
|
|
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(TESTS_ROOT, 'package.json'), 'utf8'));
|
|
|
|
|
const body: string | undefined = pkg.scripts?.[name];
|
|
|
|
|
if (!body) throw new Error(`CI invokes "npm run ${name}" but tests/package.json has no such script`);
|
|
|
|
|
|
|
|
|
|
const segment = body
|
|
|
|
|
.split('&&')
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
.find((s) => /(^|\s)playwright test(\s|$)/.test(s));
|
|
|
|
|
if (!segment) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`CI script "${name}" ("${body}") contains no "playwright test" segment — ` +
|
|
|
|
|
'unknown runner; extend lint-ci-coverage.ts to understand it'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const config = segment.match(/--config=(\S+)/)?.[1] ?? 'playwright.config.ts';
|
|
|
|
|
const projects = [...segment.matchAll(/--project=(\S+)/g)].map((m) => m[1]);
|
|
|
|
|
return { script: name, config, projects };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 3. what those invocations cover ───────────────────────────────────────────
|
|
|
|
|
type ListResult = { files: Set<string>; definedProjects: Set<string> };
|
|
|
|
|
|
|
|
|
|
function listInvocation(inv: Invocation): ListResult {
|
|
|
|
|
const args = ['playwright', 'test', '--list', '--reporter=json', `--config=${inv.config}`];
|
|
|
|
|
for (const p of inv.projects) args.push(`--project=${p}`);
|
|
|
|
|
const out = execFileSync('npx', args, {
|
|
|
|
|
cwd: TESTS_ROOT,
|
|
|
|
|
env: { ...process.env, CI: '1' },
|
|
|
|
|
encoding: 'utf8',
|
|
|
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
|
|
|
});
|
|
|
|
|
const report = JSON.parse(out);
|
|
|
|
|
|
|
|
|
|
// Reported file paths are relative to the config's rootDir (tests/ for the
|
|
|
|
|
// merged config, tests/web for the web one) — normalize to tests/-relative.
|
|
|
|
|
const rootDir: string = report.config?.rootDir ?? TESTS_ROOT;
|
|
|
|
|
const normalize = (f: string) => path.relative(TESTS_ROOT, path.resolve(rootDir, f));
|
|
|
|
|
|
|
|
|
|
const files = new Set<string>();
|
|
|
|
|
const walk = (suite: { file?: string; suites?: unknown[]; specs?: { file?: string }[] }) => {
|
|
|
|
|
if (suite.file) files.add(normalize(suite.file));
|
|
|
|
|
for (const spec of suite.specs ?? []) if (spec.file) files.add(normalize(spec.file));
|
|
|
|
|
for (const sub of (suite.suites ?? []) as typeof suite[]) walk(sub);
|
|
|
|
|
};
|
|
|
|
|
for (const s of report.suites ?? []) walk(s);
|
|
|
|
|
|
|
|
|
|
const definedProjects = new Set<string>(
|
|
|
|
|
(report.config?.projects ?? []).map((p: { name: string }) => p.name)
|
|
|
|
|
);
|
|
|
|
|
return { files, definedProjects };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── 4. the universe of spec files on disk ─────────────────────────────────────
|
|
|
|
|
function specUniverse(dir = TESTS_ROOT, rel = ''): string[] {
|
|
|
|
|
const out: string[] = [];
|
|
|
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
|
|
|
if (entry.isDirectory()) {
|
|
|
|
|
if (EXCLUDED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
|
|
|
out.push(...specUniverse(path.join(dir, entry.name), path.join(rel, entry.name)));
|
|
|
|
|
} else if (entry.name.endsWith('.spec.ts')) {
|
|
|
|
|
out.push(path.join(rel, entry.name));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── run ───────────────────────────────────────────────────────────────────────
|
findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.
Services / transport
E-10 retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
mirror stayed latched true after a mid-run worker death: Run stayed
disabled and the promised fresh-worker restart was unreachable for the
whole session. Retirement now dispatches a synthetic controlled-exit
straight to the installed handler (never through dispatchEvt — a
fabricated frame must not touch the credit ledger). Driving the repro
exposed two further defects, both fixed here: a replacement worker
trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
that uninitialized engine before KiCad's validate() re-init (the native
flow assumes a crashed engine survives in-process — true for the dll,
false for a dead worker). Reads now answer their empty shapes pre-init,
writes lazy-init, and init is idempotent per worker engine.
E-19 dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
client deliberately rethrows non-trap errors — so each throw leaked one
unit of the 64-frame credit window until the stream died with a
misattributed "transport exceeded". The ack moves to a finally in both
service copies; the throw still propagates (the trap machinery needs it).
E-20 the oversize-line path promises to transfer the accepted prefix, but
with the window full that flush only DEFERS, and stopEventStream wiped
the deferred queue — losing the diagnostics that explain the failure.
The terminal notice now carries them as pendingEvents; both hosts
deliver them in order, unacked (the fatal frame is outside the credit
protocol).
E-21 the 30s prefetch deadline discarded every model already collected and
reported nothing. A caller-owned progress sink ships the partials and
the omission reaches the export report. (Awaiting the aborted collection
was rejected: an in-flight source fetch is not abortable — E-4's
original disease.) Plus a serving-candidate memo, so a .wrl ref served
by its .step fallback stops re-probing the miss on every export.
Scheduler
E-14 _terminalizeNativeTrap classified by message substring, so any plain JS
error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
healthy instance. Now structural only: instanceof RuntimeError plus a
duck-typed name check (verified in this build's glue that abort() throws
a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
now latches the gate — the authoritative notification, previously
ignored.
E-15 the shim half: _pumpResume gates on terminal (catching wakes already
queued at latch time) and resolveWait refuses on terminal WITHOUT
consuming the entry, so a frame stays visibly parked rather than
resuming inside a trapped module.
E-16 the E-5 handler read the realm-global scheduler at dispatch instead of
its installing module's; also frees the per-line buffer on the non-trap
rethrow path.
E-11 get_vec trusted the worker's res.length over the transferred arrays.
Observed death shape: a 4 GiB std::vector threw an unhandled
std::length_error that exited the editor's main loop. Now clamped, with
the buffers freed on every failure path.
Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
E-18 the source contract asserted comment-string counts — rewording failed
CI while moving a guard outside its #ifdef passed. It now parses the
#ifdef regions and asserts on code.
service-stub-parity.ts pins what the four lifecycle copies must share:
credit-window equality parsed from source, the finally-ack, boot
deadlines, terminal-notice consumption. The transport numbers are now
single-sourced from the worker.
CI actually runs the gates: the web/standalone vitest suites (which had
NEVER run in CI), the reducer, the source contract and the parity tool —
with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
E-22 the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
the ngspice-stub shape with a wedgeNextBoot() repro hook.
Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.
Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.
Bumps kicad and wxwidgets to their findings-group-e tips.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:27:12 +02:00
|
|
|
assertNonPlaywrightGates();
|
|
|
|
|
|
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
2026-07-17 12:10:40 +02:00
|
|
|
const invocations = ciTestScripts().map(resolveScript);
|
|
|
|
|
|
|
|
|
|
const covered = new Set<string>();
|
|
|
|
|
const defined = new Map<string, string>(); // project -> config that defines it
|
|
|
|
|
const selected = new Set<string>();
|
|
|
|
|
for (const inv of invocations) {
|
|
|
|
|
const { files, definedProjects } = listInvocation(inv);
|
|
|
|
|
for (const f of files) covered.add(f);
|
|
|
|
|
for (const p of definedProjects) if (!defined.has(p)) defined.set(p, inv.config);
|
|
|
|
|
for (const p of inv.projects) selected.add(p);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const universe = specUniverse().sort();
|
|
|
|
|
const violations: string[] = [];
|
|
|
|
|
|
|
|
|
|
for (const file of universe) {
|
|
|
|
|
if (!covered.has(file)) {
|
|
|
|
|
violations.push(
|
|
|
|
|
`uncovered-spec: tests/${file} is not listed by any CI-invoked playwright run ` +
|
|
|
|
|
`(scripts: ${invocations.map((i) => i.script).join(', ')})`
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [project, config] of [...defined.entries()].sort()) {
|
|
|
|
|
if (!selected.has(project) && !LOCAL_ONLY_PROJECTS.has(project)) {
|
|
|
|
|
violations.push(
|
|
|
|
|
`orphan-project: "${project}" (${config}) is selected by no CI script and not in ` +
|
|
|
|
|
'LOCAL_ONLY_PROJECTS — wire it into a CI npm script or allowlist it on purpose'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (violations.length) {
|
|
|
|
|
for (const v of violations) console.error(`✗ ${v}`);
|
|
|
|
|
console.error(`\n${violations.length} CI-coverage violation(s)`);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(
|
|
|
|
|
`✓ CI coverage: ${universe.length} spec files all reachable via ` +
|
|
|
|
|
`${invocations.map((i) => i.script).join(' + ')}; ` +
|
|
|
|
|
`${defined.size} projects accounted for (${selected.size} on CI, ${LOCAL_ONLY_PROJECTS.size} local-only)`
|
|
|
|
|
);
|