pcbjam/.github/workflows/wasm-build.yml
Viktor Vaczi 63ed1f3c1f 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:21:54 +02:00

544 lines
27 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: wasm-build (reusable)
# THE single KiCad WASM build+test recipe, called by ci-ubicloud.yml (main/PR) and
# release.yml (tag). Both build the SAME way — the asyncify `wasm-opt` tail is -O1
# everywhere — so the build can never diverge from what ships (the bug that shipped
# a -O1 / 3D-off demo while CI built -O1 / 3D-on). The opt_level input remains as an
# escape hatch for a one-off -O2 build, but is -O1 for both real callers, so a tag
# release FINAL-cache-hits main's build and skips the asyncify tail entirely.
#
# Two-tier output cache around docker/build.sh's --compile-only / --postprocess-only
# split. The expensive container compile (→ base wasm) is opt- and binaryen-
# independent; only the host `asyncify + wasm-opt -O` tail depends on the binaryen
# fork + opt level. So:
# - BASE cache (compile-input key): the --compile-only output (base wasm +
# sysroot headers). Reused whenever only the binaryen fork / asyncify config
# changed — the compile is skipped and just the tail reruns.
# - FINAL cache (base + binaryen SHA + opt level): the post-processed output;
# fast-path for re-running the same SHA (a tag release reusing main, a re-deploy).
# Both keys include the 3D-viewer flag, so a 3D-on and 3D-off build can never
# poison each other's cache.
on:
workflow_call:
inputs:
opt_level:
description: "Binaryen wasm-opt shrink level for the asyncify tail (-O1 everywhere; escape hatch for a one-off -O2)"
type: string
default: "-O1"
build_3d_viewer:
description: "Build the WASM 3D viewer into kicad_editor (ON/OFF)"
type: string
default: "ON"
run_tests:
description: "Run the wxWidgets + KiCad e2e suites after building"
type: boolean
default: true
no_cache:
description: "Bypass the WASM output caches (force a full rebuild this run)"
type: boolean
default: false
upload_output:
description: "Upload the publishable output/ subset as the 'wasm-output' artifact"
type: boolean
default: false
secrets:
# Declared so this reusable workflow may reference ${{ secrets.DISCORD_WEBHOOK_URL }}
# (an undeclared secret reference is a workflow startup failure). ci-ubicloud.yml
# passes it via `secrets: inherit`; release.yml doesn't (required: false) → the
# screenshot/perf report step just no-ops there.
DISCORD_WEBHOOK_URL:
required: false
jobs:
build-and-test:
name: Build all tools + KiCad e2e (Ubicloud)
# Don't run untrusted fork PRs on the paid runner (push/dispatch always run;
# same-repo PRs run). github.event_name here is the CALLER's event.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubicloud-standard-30
timeout-minutes: 300
env:
KICAD_LOG_NESTED: "1"
# Opt level for the asyncify shrink pass (the only opt-dependent step).
BINARYEN_OPT_LEVEL: ${{ inputs.opt_level }}
BUILD_3D_VIEWER: ${{ inputs.build_3d_viewer }}
# Stable docker-compose project → deterministic build-cache volume name.
COMPOSE_PROJECT_NAME: kicad-wasm-ci
steps:
- name: Install build toolchain (Binaryen from-source)
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
# xvfb: kicad e2e runs headed Firefox under a virtual display.
# autoconf/automake/make: host wxWidgets + wx test-app builds.
sudo apt-get install -y cmake ninja-build g++ libjemalloc2 xvfb autoconf automake make
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: actions/setup-node@v4
with: { node-version: 20 }
# --- cache keys --------------------------------------------------------
# base = opt-INDEPENDENT (no binaryen / opt level): docker compile out.
# final = opt-SPECIFIC: post-processed (asyncify + wasm-opt -O) out, keyed
# on the binaryen submodule SHA — the host post-process uses that fork's
# wasm-opt (--hoist-cpp-catches + --asyncify + -O), so bumping the fork
# must bust this cache — plus the opt level.
# Both include the 3D flag so 3D-on/off never share an entry.
- name: Compute build inputs
id: keys
run: |
KICAD=$(git -C kicad rev-parse HEAD)
WX=$(git -C wxwidgets rev-parse HEAD)
BIN=$(git -C binaryen rev-parse --short HEAD)
SC=$(node scripts/deploy/wasm-cache-hash.mjs)
EPOCH=$(cat .ci-cache-epoch 2>/dev/null || echo 0)
EMV=$(. scripts/common/versions.sh && echo "$EMSCRIPTEN_VERSION")
THREED='${{ inputs.build_3d_viewer }}'
BASE="kbase-${{ runner.os }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}"
FINAL="kwasm-${{ runner.os }}-bin${BIN}${{ inputs.opt_level }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}"
{
echo "kicad=$KICAD"; echo "wx=$WX"; echo "sc=$SC"; echo "epoch=$EPOCH"
echo "bin=$BIN"; echo "emv=$EMV"
echo "base_key=$BASE"; echo "final_key=$FINAL"
} >> "$GITHUB_OUTPUT"
- name: Cache control (commit message / dispatch)
id: cachectl
env:
HEAD_MSG: ${{ github.event.head_commit.message }}
PR_TITLE: ${{ github.event.pull_request.title }}
DISPATCH_NOCACHE: ${{ inputs.no_cache }}
run: |
SKIP=false
if printf '%s\n%s' "$HEAD_MSG" "$PR_TITLE" | grep -qiE '\[(no-cache|rebuild-wasm)\]'; then SKIP=true; fi
[ "$DISPATCH_NOCACHE" = "true" ] && SKIP=true
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
echo "WASM output-cache restore skip=$SKIP"
# Binaryen post-process tools (submodule fork): otherwise built from source on
# every fresh VM (~46s on 30 cores, measured run 28577824366). Cache bin/ AND
# lib/ (the tools dynamically link lib/libbinaryen.so — bin/ alone is a loader
# error at first exec, run 28585074335), keyed on the exact submodule SHA; on a
# hit BINARYEN_TRUST_PREBUILT tells build-wasm-opt.sh to skip cmake+ninja and
# trust the restored binaries. v2: v1 entries hold a poisoned bin-only layout.
# Needed by BOTH the host post-process (final-miss path) and the test-app
# build, hence not gated on run_tests.
- name: Cache Binaryen post-process tools
id: binopt-cache
uses: actions/cache@v4
with:
path: |
build-wasm/tools/binaryen-hoist-build/bin
build-wasm/tools/binaryen-hoist-build/lib
key: binopt-v2-${{ runner.os }}-${{ steps.keys.outputs.bin }}
- name: Trust prebuilt Binaryen tools (cache hit)
if: steps.binopt-cache.outputs.cache-hit == 'true'
run: echo "BINARYEN_TRUST_PREBUILT=1" >> "$GITHUB_ENV"
# The cached paths the e2e tests need: final wasms (or base, mid-build) +
# the sysroot headers the host GAL build compiles against. Same glob set for
# both tiers — only the bytes (base vs final) and the key differ.
- name: Restore FINAL WASM output cache
id: final-cache
if: steps.cachectl.outputs.skip != 'true'
uses: actions/cache/restore@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.final_key }}
# Only consulted when the final (opt-specific) cache missed: the
# opt-independent compile output, warm across -O1/-O2.
- name: Restore BASE compile cache
id: base-cache
if: steps.cachectl.outputs.skip != 'true' && steps.final-cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.base_key }}
# deps (sysroot + stamps) are only needed when we must COMPILE (base miss).
- name: Restore deps cache
id: deps-cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: deps-cache
key: deps-${{ runner.os }}-${{ hashFiles('scripts/deps/**','scripts/common/versions.sh','scripts/common/functions.sh','scripts/common/env.sh','docker/Dockerfile','docker/docker-compose.yml') }}
- name: Seed deps volume from cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit == 'true'
run: |
docker volume create kicad-wasm-ci_kicad-build-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'tar xzf /cache/deps.tar.gz -C /bw'
# PHASE 1 (base miss only): container compile of all 4 bundles → output/ base
# wasm (opt-independent). 3D viewer per input. --compile-only skips the host
# asyncify/-O tail. KICAD_PIPELINE has no effect here (no post-process to
# overlap), but the deps short-circuit on a warm volume.
- name: Compile all KiCad tools (container, base wasm)
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
run: |
export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G
echo "Compiling ALL tools (base wasm), 3D viewer=${BUILD_3D_VIEWER}, -j $(nproc)"
./docker/build.sh all --compile-only --build-deps -j "$(nproc)"
ls -lh output/*.wasm
- name: Package deps for cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit != 'true'
run: |
mkdir -p deps-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'cd /bw && tar czf /cache/deps.tar.gz sysroot stamps'
# GAL test compiles against kicad headers from the docker sysroot volume —
# export them to the host so they ride in the base cache (and thus any hit).
- name: Expose docker sysroot headers to host builds
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
run: |
VOL=kicad-wasm-ci_kicad-build-cache
mkdir -p build-wasm/sysroot
docker run --rm -v "$VOL":/bw -v "$PWD/build-wasm/sysroot":/host alpine \
sh -c 'cp -r /bw/sysroot/include /host/'
sudo chown -R "$(id -u):$(id -g)" build-wasm/sysroot
# Save the opt-independent base (compile output + headers) for cross-opt reuse.
- name: Save BASE compile cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.base_key }}
# PHASE 2 (any final miss): pure-host post-process on the base wasm —
# dyncall + finalize + asyncify + `wasm-opt ${opt_level}`. The ONLY
# opt-dependent work. No container; the binaryen submodule fork's wasm-opt
# is built on demand via scripts/binaryen-hoist-pass/build-wasm-opt.sh.
- name: Host post-process (asyncify + wasm-opt ${{ inputs.opt_level }})
if: steps.final-cache.outputs.cache-hit != 'true'
run: |
export KICAD_PIPELINE=1 BINARYEN_CORES=16
echo "Post-processing ALL tools with ${BINARYEN_OPT_LEVEL}"
./docker/build.sh all --postprocess-only
echo "wasm-opt used:"; "$(./scripts/binaryen-hoist-pass/build-wasm-opt.sh 2>/dev/null)" --version || true
ls -lh output/*.wasm
- name: Save FINAL WASM output cache
if: steps.final-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.final_key }}
# --- publishable artifact (release path) -------------------------------
- name: Upload WASM output artifact
if: inputs.upload_output
uses: actions/upload-artifact@v4
with:
name: wasm-output
if-no-files-found: error
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
!output/*.wasm.debug.wasm
# --- e2e tests (gated on run_tests) ------------------------------------
# Host emsdk toolchain (tools/emsdk): env.sh auto-installs it on first use
# (~23s: emsdk repo clone + ~340 MB from storage.googleapis.com, measured run
# 28577824366). Caching it is speed-neutral-to-slightly-positive; the real
# value is availability — without it a github.com/storage.googleapis.com
# hiccup fails every run. Keyed on the pinned EMSCRIPTEN_VERSION (versions.sh);
# the emscripten ports cache (zlib) rides along. The spent downloads/ tarballs
# are pruned below before the post-job save.
- name: Cache emsdk toolchain
if: inputs.run_tests
uses: actions/cache@v4
with:
path: tools/emsdk
key: emsdk-${{ runner.os }}-${{ steps.keys.outputs.emv }}
- name: Restore wx build cache
id: wx-cache
if: inputs.run_tests
uses: actions/cache@v4
with:
path: build-wasm/wxwidgets
key: wx-${{ runner.os }}-${{ steps.keys.outputs.wx }}-${{ hashFiles('scripts/build-wx-wasm.sh','scripts/common/versions.sh') }}
# One shared timestamp, not per-file "now": plain `touch {} +` stamps each file
# a few ns apart in readdir order, and GNU make 4.x compares ns mtimes — so any
# object touched before a generated header it depends on (wx/setup.h, pcre2.h
# via .deps/*.d) looks stale and a random subset recompiles every cache-hit run.
# Equal mtimes read as up to date.
- name: Mark restored wx objects current
if: inputs.run_tests && steps.wx-cache.outputs.cache-hit == 'true'
run: find build-wasm/wxwidgets -exec touch -d "@$(date +%s)" {} +
- name: Build wxWidgets (wxUniversal WASM)
if: inputs.run_tests
run: ./scripts/build-wx-wasm.sh
# Built wx test apps (tests/apps): without this every run recompiles, relinks
# and — the expensive part — re-runs the hoist+asyncify post-link on all ~74
# apps (~2m20s even 30-wide). Key = every build input: the wx lib identity
# (submodule SHA + the same script hashes as the wx cache key; the wx SHA also
# covers the Makefile's JS_FILES from wxwidgets/build/wasm), the KICAD
# submodule SHA (some apps compile real KiCad sources — thread_pool.cpp,
# libcontext, headers), the binaryen SHA (post-link wasm-opt), the app
# sources (tracked cpp/h/html + Makefile), and the build/post-link scripts +
# JS shims (wasm/** = shims + the wasm-opt stub). hashFiles runs at restore
# time, on a fresh checkout, so it sees only tracked sources — never build
# outputs. On a hit the build step is skipped entirely. Excluded:
# tests/apps/kicad (setup:kicad staging from output/) and gal-webgl (its own
# step below rebuilds it every run anyway).
# Gated on a wx cache HIT: the test-app build is what creates the
# libwx_*.a -> libwx_*-emscripten.a symlinks the GAL link (wx-config --libs)
# needs. A restored wx cache contains them (saved at job end, after they
# exist), but a freshly rebuilt wx tree does not — so on a wx miss the apps
# must rebuild too, or the GAL step breaks.
- name: Cache built wx test apps
id: testapps-cache
if: inputs.run_tests && steps.wx-cache.outputs.cache-hit == 'true'
uses: actions/cache@v4
with:
path: |
tests/apps
!tests/apps/kicad
!tests/apps/gal-webgl
!tests/apps/3d-webgl
key: testapps-${{ runner.os }}-wx${{ steps.keys.outputs.wx }}-k${{ steps.keys.outputs.kicad }}-bin${{ steps.keys.outputs.bin }}-${{ hashFiles('tests/apps/**/*.cpp', 'tests/apps/**/*.h', 'tests/apps/**/*.html', 'tests/apps/Makefile.wasm', 'scripts/build-wx-wasm.sh', 'scripts/build-wasm-test.sh', 'scripts/common/versions.sh', 'scripts/common/env.sh', 'scripts/common/functions.sh', 'scripts/common/apply-asyncify.sh', 'scripts/common/asyncify-imports.txt', 'scripts/common/inject-dyncall-shims.sh', 'scripts/common/shims/**', 'wasm/**') }}
- name: Build wxWidgets test apps
if: inputs.run_tests && steps.testapps-cache.outputs.cache-hit != 'true'
run: ./scripts/build-wasm-test.sh
- name: Build GAL WebGL test app
if: inputs.run_tests
run: ./scripts/build-gal-webgl-test.sh
# 3D renderer regression harness (tests/3d-regression): same recipe as GAL —
# compiles real KiCad 3D-viewer TUs + the wasm/gl1 GL1->WebGL2 layer against
# the wx build and the docker-exposed sysroot headers. The capture spec
# (e2e/3d-webgl.spec.ts) runs inside the wx e2e step below; it self-skips
# when this app is missing.
- name: Build 3D WebGL test app
if: inputs.run_tests
run: ./scripts/build-3d-webgl-test.sh
# The emsdk cache saves in the post-job phase; the downloads/ tarballs
# (~340 MB) are spent after install — drop them so they never ride in the
# cache. No-op on cache-hit runs (already pruned before the save).
- name: Prune emsdk download tarballs (cache hygiene)
if: inputs.run_tests
run: rm -rf tools/emsdk/downloads
- name: Install test deps
if: inputs.run_tests
working-directory: tests
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)
if: inputs.run_tests
working-directory: web
run: |
corepack enable
pnpm install --frozen-lockfile
# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
# kicad-validity 0001 §5) and the CLI contract the backend job runner
# keys off (exit codes, resave semantics). Both scripts skip when the
# artifact is absent, but "all" builds kicad_tools now, so here they
# bite. Needs tests npm deps (above) + web workspace deps (the corpus
# lint imports the shared codec).
- name: kicad_tools corpus lint + CLI contract
if: inputs.run_tests
working-directory: tests
run: |
npm run corpus:lint
npm run tools:contract
# Browser binaries keyed on the lockfile (which pins the playwright version).
# On a hit `playwright install` skips the downloads; --with-deps still
# apt-installs its small OS dep set either way.
- name: Cache Playwright browsers
if: inputs.run_tests
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('tests/package-lock.json') }}
- name: Install Playwright browsers
if: inputs.run_tests
working-directory: tests
run: npx playwright install --with-deps firefox chromium
- name: Stage KiCad WASM for tests
if: inputs.run_tests
working-directory: tests
run: npm run setup:kicad
# ONE merged Playwright invocation for every apps-server suite (wx,
# kicad×2 engines, asyncify, coroutine — see playwright.config.ts). One
# invocation = one start-of-run outputDir wipe BEFORE anything rendered,
# so the engine-scoped screenshots in test-results/{chromium,firefox}/
# accumulate naturally for the offline compare. A failure fails the JOB
# (no continue-on-error); the report below still runs and shows exactly
# what broke or went missing. xvfb: the Firefox projects run headed on CI
# (GPU-less VMs have no headless GL).
- name: e2e (npm run test:e2e — all suites, both engines)
id: e2e
if: inputs.run_tests
working-directory: tests
run: xvfb-run -a npm run test:e2e
# The React web-app suite (web/) against the pnpm dev stack (frontend
# :3048 + backend :3060, cold-started by Playwright's webServer on CI).
# 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
run: xvfb-run -a npm run test:web:ci
# Runtime-perf E2E (eeschema + pcbnew): measures the current build's
# load / open+render / FPS and writes tests/test-results/perf-*.json.
# Track-only — never gates the build (continue-on-error). CI is
# headless/SwiftShader so FPS is CPU-bound + noisy; openMs is the stable number.
- name: KiCad runtime perf (track-only, non-gating)
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
run: xvfb-run -a npm run test:perf
# 3D renderer parity + browser-self regression (report-only). The wx e2e
# step above captured the 47 scenario renders into
# tests/3d-regression/output/webgl; compare them against the committed
# native goldens (parity — the port-correctness meter) and the committed
# browser goldens (webgl-self). continue-on-error while the committed
# baseline-webgl/ set is Mac-Chromium-sourced: if CI's SwiftShader
# rasterizes past the 0.005 floor, promote CI's renders into
# baseline-webgl/ and flip this step gating. 3d:review writes the full
# 47-pair triptych gallery for the artifact upload below.
- name: 3D renderer parity (report-only)
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
run: |
npm run 3d:check:parity
npm run 3d:check:webgl
npm run 3d:review
# Screenshot drift gate + Discord report (perf + triptychs). Runs whenever
# the e2e suites ran — INCLUDING on e2e failure — so a wrong or missing
# screenshot is visible on Discord even on a red build (a spec that died
# before its page.screenshot() shows up as "removed"). The --e2e badge is
# computed from the suite outcomes. Report-only during rollout: compare.ts
# exits 0 without --fail-on-change and the step is continue-on-error, so it
# never blocks the build — flip to gating once the per-engine floors are
# calibrated (tests/tools/screenshots/config.ts, seeded by
# `npm run screenshots:noise`). Posts ONLY on push to main and no-ops
# without DISCORD_WEBHOOK_URL (inert on PRs/forks). No extra build — reads
# the already-produced test-results (screenshots + perf-*.json).
- name: Screenshot report + perf
id: report
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
GH_TOKEN: ${{ github.token }}
# Report-only: post the screenshot drift + perf table to Discord but NEVER fail the
# build on a screenshot difference — the Discord post is the signal. compare.ts exits 0
# without --fail-on-change; continue-on-error also shields transient Discord hiccups.
run: |
E2E=pass
{ [ "${{ steps.e2e.outcome }}" = "success" ] \
&& [ "${{ steps.web_e2e.outcome }}" = "success" ]; } || E2E=fail
npm run screenshots:check
npm run screenshots:report -- --e2e "$E2E"
# FALLBACK on failure: a minimal text-only "CI failed" notice, only when the
# rich screenshot report above did NOT post (build broke before the tests →
# report skipped, or the report itself errored). An e2e-only failure already
# posts the full report with the ❌ e2e badge + run URL — no duplicate ping.
# Uses curl, NOT the TS reporter, because on a build failure the test deps
# (npm ci) never installed. Main-push only.
- name: Discord CI-failure notice
if: failure() && github.ref == 'refs/heads/main' && github.event_name == 'push' && steps.report.outcome != 'success'
continue-on-error: true
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
run: |
[ -z "$DISCORD_WEBHOOK_URL" ] && { echo "no webhook — skipping"; exit 0; }
SHORT="$(echo "${{ github.sha }}" | cut -c1-7)"
URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
SUBJ="$(git log -1 --pretty=%s 2>/dev/null || true)"
CONTENT="❌ **CI failed** \`$SHORT\` — $SUBJ"$'\n'"$URL"
jq -n --arg c "$CONTENT" '{content:$c, allowed_mentions:{parse:[]}}' \
| curl -sS -X POST "$DISCORD_WEBHOOK_URL" -H "Content-Type: application/json" -d @- >/dev/null \
&& echo "posted CI-failure notice"
- name: Upload test logs & screenshots
if: always() && inputs.run_tests
uses: actions/upload-artifact@v4
with:
name: ubicloud-e2e-${{ github.run_id }}
path: |
tests/logs/**
tests/test-results/**
tests/pw-artifacts/**
tests/playwright-report/**
tests/3d-regression/output/**
if-no-files-found: ignore