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

@ -9,6 +9,7 @@
"link-wasm": "node scripts/link-wasm.mjs",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"e2e:preview": "node scripts/build-preview.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"

View file

@ -0,0 +1,66 @@
#!/usr/bin/env node
// Build the standalone editor for PRODUCTION and serve it with `vite preview`
// — the server the web e2e suite runs against (playwright-web.config.ts), so
// the tests exercise the minified production bundle instead of the dev server.
//
// node scripts/build-preview.mjs
//
// Steps:
// 1. link-wasm.mjs: sync output/ artifacts into tests/apps/kicad and (re)make
// the public/wasm symlink — preview serves /wasm through it (vite.config's
// serveWasm plugin), NOT from dist/.
// 2. `pnpm run build` at the web/ workspace root (turbo orders the workspace
// deps: shared, sync-client, then standalone). The public/wasm symlink is
// stashed aside during the build — same move as scripts/deploy/
// build-demo.mjs — because vite would otherwise copy the whole artifact
// tree (100s of MB) into dist/. VITE_* env must be set by the caller at
// BUILD time (prod bundles bake them in; the dev server read them live).
// 3. exec `vite preview` as the persistent server (the playwright webServer
// health-checks the URL and kills the process tree on teardown).
import { execFileSync, spawn } from "node:child_process";
import { existsSync, lstatSync, renameSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const standalone = path.resolve(scriptDir, "..");
const webRoot = path.resolve(standalone, "..");
const publicWasm = path.join(standalone, "public", "wasm");
const stash = path.join(standalone, "public", ".wasm.e2e-stashed");
const isLink = (p) => {
try {
return lstatSync(p).isSymbolicLink();
} catch {
return false;
}
};
// 1. Sync artifacts + (re)create the symlink.
execFileSync("node", [path.join(scriptDir, "link-wasm.mjs")], {
stdio: "inherit",
});
// 2. Build with the symlink stashed out of public/ (restore even on failure —
// a dangling stash would make the next dev run serve nothing at /wasm).
const hadWasm = existsSync(publicWasm) || isLink(publicWasm);
if (hadWasm) renameSync(publicWasm, stash);
try {
execFileSync("pnpm", ["run", "build"], { cwd: webRoot, stdio: "inherit" });
} finally {
if (hadWasm) renameSync(stash, publicWasm);
}
// 3. Serve dist/ (vite.config preview: STANDALONE_PORT, strictPort, COOP/COEP;
// the serveWasm plugin provides /wasm from the restored symlink).
const preview = spawn("pnpm", ["exec", "vite", "preview"], {
cwd: standalone,
stdio: "inherit",
});
preview.on("exit", (code, signal) => {
process.exit(signal ? 1 : (code ?? 1));
});
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => preview.kill(sig));
}

View file

@ -4,38 +4,79 @@ import react from "@vitejs/plugin-react";
import { defineConfig, type Plugin } from "vite";
/**
* Serve /wasm/*.gz as opaque bytes WITHOUT `Content-Encoding: gzip`.
* Serve /wasm assets from public/wasm (the link-wasm.mjs symlink) in BOTH the
* dev server and `vite preview`.
*
* Vite's static (sirv) sets `Content-Encoding: gzip` for `.gz` files. The
* browser then transparently decompresses the response, so the harness's
* `fetch('images.tar.gz')` receives the DECOMPRESSED tar — and KiCad's gunzip
* of it fails with "Can't read from inflate stream: incorrect header check".
* We must hand the browser the raw gzip bytes, so we serve them ourselves with
* no Content-Encoding. Runs before the public-dir middleware.
* Dev: only `.gz` needs help. Vite's static (sirv) sets
* `Content-Encoding: gzip` for `.gz` files; the browser then transparently
* decompresses the response, so the harness's `fetch('images.tar.gz')`
* receives the DECOMPRESSED tar — and KiCad's gunzip of it fails with "Can't
* read from inflate stream: incorrect header check". We must hand the browser
* the raw gzip bytes, so we serve them ourselves with no Content-Encoding.
* Runs before the public-dir middleware.
*
* Preview: dist/ deliberately contains NO wasm (build-preview.mjs stashes the
* public/wasm symlink aside during the build, same as build-demo.mjs — it
* would copy 100s of MB into dist/), so preview serves ALL of /wasm/* from
* the symlink path here, with the same raw-.gz rule. Same-origin, so COEP
* needs no extra headers on these responses.
*/
function serveWasmGzRaw(): Plugin {
function serveWasm(): Plugin {
const publicDir = path.resolve(__dirname, "public");
const MIME: Record<string, string> = {
".js": "text/javascript",
".mjs": "text/javascript",
".wasm": "application/wasm",
".json": "application/json",
".map": "application/json",
".html": "text/html",
".data": "application/octet-stream",
".gz": "application/octet-stream",
};
const serve = (
req: { url?: string },
res: import("node:http").ServerResponse,
next: () => void,
opts: { gzOnly: boolean },
) => {
const url = req.url?.split("?")[0] ?? "";
if (!url.startsWith("/wasm/")) return next();
const isGz = /\.gz$/.test(url);
if (opts.gzOnly && !isGz) return next();
const filePath = path.join(publicDir, decodeURIComponent(url));
fs.stat(filePath, (err, st) => {
if (err || !st.isFile()) return next();
const type = MIME[path.extname(filePath)] ?? "application/octet-stream";
res.setHeader("Content-Type", type);
res.setHeader("Content-Length", st.size);
// This middleware short-circuits BEFORE vite applies server/preview
// `headers`, so it must emit the cross-origin-isolation set itself:
// kicad_editor.js doubles as the pthread WORKER script, and under
// COEP:require-corp a worker script's response must itself carry COEP —
// without it Chrome kills the load with net::ERR_BLOCKED_BY_RESPONSE
// and the editor never boots.
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
// Intentionally NO Content-Encoding for .gz so fetch() yields raw bytes.
fs.createReadStream(filePath).pipe(res);
});
};
return {
name: "serve-wasm-gz-raw",
name: "serve-wasm",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (!/^\/wasm\/.+\.gz$/.test(url)) return next();
const filePath = path.join(publicDir, decodeURIComponent(url));
fs.stat(filePath, (err, st) => {
if (err || !st.isFile()) return next();
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Length", st.size);
// Intentionally NO Content-Encoding so fetch() yields raw gzip bytes.
fs.createReadStream(filePath).pipe(res);
});
});
// Dev: sirv serves public/ fine except the .gz encoding quirk.
server.middlewares.use((req, res, next) => serve(req, res, next, { gzOnly: true }));
},
configurePreviewServer(server) {
// Preview: dist has no wasm at all — serve the whole subtree.
server.middlewares.use((req, res, next) => serve(req, res, next, { gzOnly: false }));
},
};
}
export default defineConfig({
plugins: [serveWasmGzRaw(), react()],
plugins: [serveWasm(), react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
@ -60,6 +101,11 @@ export default defineConfig({
},
},
preview: {
// Same port contract as the dev server (STANDALONE_PORT override,
// strictPort) so the e2e webServer/health-check URL is identical in both
// modes. Vite's preview default (4173) would silently strand the suite.
port: Number(process.env.STANDALONE_PORT) || 3048,
strictPort: true,
headers: {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",

View file

@ -8,6 +8,7 @@
"WASM_SRC_DIR",
"VITE_API_BASE_URL",
"VITE_LOCAL_PROJECTS",
"VITE_ALLOW_USER_OVERRIDE",
"VITE_WASM_ASSET_BASE_URL"
],
"tasks": {