pcbjam/.github/workflows/wasm-build.yml
Istvan Matejcsok c421d724b0 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-31 18:19:16 +02:00

592 lines
28 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 only build knob is the
# 3D-viewer flag, ON for both real callers — so the build can never diverge from
# what ships (the bug that once shipped a 3D-off demo while CI built 3D-on).
#
# ONE output cache around docker/build.sh's --compile-only / --postprocess-only
# split. Under JSPI the wasm links fully finalized in-container; the only host
# post-process left is the deterministic, seconds-cheap ENV merge shim
# (patch-env-shim.mjs). So the cache holds the post-processed output/, keyed on
# the compile inputs alone: the kicad/wx submodule SHAs, the sc source-content
# hash (scripts/deploy/wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D-viewer flag (a 3D-on and 3D-off build can never poison
# each other's entry), and the manual .ci-cache-epoch escape hatch. On a hit
# the whole build is skipped (a tag release reuses main's entry for the same
# commit); on a miss the compile reruns and the shim is re-applied before the
# save.
on:
workflow_call:
inputs:
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 cache (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
# Read-only S3 keypair for the private R2 screenshot-baseline bucket
# (pcbjam-ci-screenshots). Optional for the same reason: without them the
# baseline fetch skips and the compare gate no-ops (report-only pipeline).
CI_SCREENSHOTS_S3_ACCESS_KEY_ID:
required: false
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY:
required: false
# WRITE keypair for the per-run screenshot uploads (runs/pcbjam/<run-id>/,
# 30-day R2 lifecycle) consumed by the morelli review app. Optional for the
# same reason: without it the upload-run step just no-ops.
CI_SCREENSHOTS_S3_WRITE_ACCESS_KEY_ID:
required: false
CI_SCREENSHOTS_S3_WRITE_SECRET_ACCESS_KEY:
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"
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
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.
# cmake/ninja/g++: host GAL/3D test-app builds.
sudo apt-get install -y cmake ninja-build g++ xvfb autoconf automake make
- uses: actions/checkout@v4
with: { submodules: recursive }
- uses: actions/setup-node@v4
with: { node-version: 20 }
# --- cache key ---------------------------------------------------------
# ONE key, from the compile inputs only: kicad/wx submodule SHAs, the sc
# source-content hash (wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D flag (3D-on/off never share an entry), and the
# manual .ci-cache-epoch. The host post-process (ENV merge shim) is
# deterministic and cheap, so the cached bytes are the fully
# post-processed output — no separate post-process tier.
- name: Compute build inputs
id: keys
run: |
KICAD=$(git -C kicad rev-parse HEAD)
WX=$(git -C wxwidgets rev-parse 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 }}'
KEY="kwasm-${{ runner.os }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}"
{
echo "kicad=$KICAD"; echo "wx=$WX"; echo "sc=$SC"; echo "epoch=$EPOCH"
echo "emv=$EMV"
echo "key=$KEY"
} >> "$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"
# The cached paths the e2e tests need: the post-processed wasms + the
# sysroot headers the host GAL build compiles against.
- name: Restore WASM output cache
id: wasm-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.key }}
# X-1: every dep tarball fetch must carry a SHA256 pin and download_file
# must enforce it. Seconds, no docker, offline — runs even on cache hits
# so a regression is caught before it can poison the deps cache.
- name: Check dependency tarball pins
run: scripts/deps/check-pins.sh
# deps (sysroot + stamps) are only needed when we must COMPILE (cache miss).
- name: Restore deps cache
id: deps-cache
if: steps.wasm-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.wasm-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 (cache miss only): container compile of all bundles → output/
# wasm, fully finalized in-container (JSPI links with the real tools).
# 3D viewer per input.
- name: Compile all KiCad tools (container)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G
echo "Compiling ALL tools, 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.wasm-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 output cache (and thus any hit).
- name: Expose docker sysroot headers to host builds
if: steps.wasm-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
# PHASE 2 (cache miss only): the sole remaining host post-process — the
# deterministic ENV merge shim (patch-env-shim.mjs) on the editor glue.
# No container needed; the CLIs (kicad_tools/occ_service/ngspice_service)
# skip even this inside build.sh.
- name: Host post-process (ENV merge shim)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
./docker/build.sh all --postprocess-only
ls -lh output/*.wasm
- name: Save WASM output cache
if: steps.wasm-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.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 and
# relinks 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 app sources
# (tracked cpp/h/html + Makefile), and the build scripts +
# JS shims (wasm/**). 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 }}-${{ 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/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
# The baseline manifest lives in R2 (written only by the morelli review
# app + its seed script) — download it FIRST so this whole run pins to
# one manifest version and the cache step below can key on its hash.
# continue-on-error + the no-creds skip (which also deletes any stale
# local copy) keep secretless callers green — every later screenshot
# step then skips rather than gating on outdated baselines.
- name: Fetch baseline manifest from R2
if: inputs.run_tests
continue-on-error: true
working-directory: tests
env:
CI_SCREENSHOTS_S3_ENDPOINT: ${{ vars.CI_SCREENSHOTS_S3_ENDPOINT }}
CI_SCREENSHOTS_S3_ACCESS_KEY_ID: ${{ secrets.CI_SCREENSHOTS_S3_ACCESS_KEY_ID }}
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: ${{ secrets.CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY }}
run: npm run screenshots:fetch-manifest
# Baseline screenshots live in a private R2 bucket, pinned by the manifest
# fetched above; this cache keyed on the manifest hash makes most fetches
# a no-op (hashFiles evaluates at step run time, AFTER the fetch; a
# skipped fetch hashes to empty → restore-keys still warms the tree).
# restore-keys makes a manifest change INCREMENTAL (restore the previous
# tree, download only the changed objects) instead of a full ~35MB
# re-download. A stale or partial restore is safe: r2-sync hash-verifies
# every file and deletes unlisted ones, and compare.ts refuses to gate
# against an incomplete cache.
- name: Cache screenshot baselines
if: inputs.run_tests
uses: actions/cache@v4
with:
path: tests/baseline-screenshots
key: baselines-${{ hashFiles('tests/.baseline-manifest.json') }}
restore-keys: |
baselines-
# continue-on-error: the screenshot pipeline is deliberately report-only
# (see the report step) — an R2 outage must not fail the build. Without
# credentials (release.yml, fork PRs) r2-sync skips with a warning and
# compare.ts later no-ops for the same reason.
- name: Fetch screenshot baselines from R2
if: inputs.run_tests
continue-on-error: true
working-directory: tests
env:
CI_SCREENSHOTS_S3_ENDPOINT: ${{ vars.CI_SCREENSHOTS_S3_ENDPOINT }}
CI_SCREENSHOTS_S3_ACCESS_KEY_ID: ${{ secrets.CI_SCREENSHOTS_S3_ACCESS_KEY_ID }}
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: ${{ secrets.CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY }}
run: npm run screenshots:fetch
# Cheap hygiene gates (no build needed): the determinism lint keeps the
# banned flake patterns out of the specs, 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). (The old screenshot-manifest --check gate went with
# the committed manifest — the manifest now lives in R2, written only by
# the morelli app, which validates on every promote.)
- name: Lint test determinism + CI coverage
if: inputs.run_tests
working-directory: tests
run: |
npm run lint:determinism
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
# The web/standalone vitest suites carry the red/green evidence for the
# findings-E service-layer fixes (worker lifecycle, transport credits,
# admission gate, models prefetch). Node-env, no wasm build needed.
- name: web standalone unit tests (vitest)
if: inputs.run_tests
working-directory: web
run: pnpm --filter @pcbjam/standalone test
# 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
# CDN worker unit tests (findings R-3: range-less GET must be 200, never 206).
- name: cdn worker unit tests
if: inputs.run_tests
working-directory: workers/cdn
run: |
npm ci
npm test
# Findings-E gates: the ngspice transport reducer (production worker
# source in a node:vm), the C++/shim source contract, and the
# stub/production parity tripwire. lint-ci-coverage asserts these exact
# invocations stay wired (NON_PLAYWRIGHT_GATES).
- name: findings-E transport reducer + source/parity contracts
if: inputs.run_tests
working-directory: tests
run: |
npm run ngspice:worker-batch
npm run findings-e:contract
npm run findings-e:parity
# 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, jspi, 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"
# Durable per-run screenshot store for the morelli review app: upload this
# run's renders + meta.json to runs/pcbjam/<run-id>/ in the R2 bucket
# (30-day lifecycle rule — GH artifacts stay the debugging archive, R2 is
# the promotion source). Runs AFTER the report step so compare.ts's
# report.json exists to embed. Needs the WRITE keypair (mapped onto the
# standard env names for this step only); without it the tool no-ops, so
# forks/secretless callers stay green. Never blocks a build.
- name: Upload run screenshots to R2
if: inputs.run_tests && !cancelled() && steps.e2e.outcome != 'skipped'
continue-on-error: true
working-directory: tests
env:
CI_SCREENSHOTS_S3_ENDPOINT: ${{ vars.CI_SCREENSHOTS_S3_ENDPOINT }}
CI_SCREENSHOTS_S3_ACCESS_KEY_ID: ${{ secrets.CI_SCREENSHOTS_S3_WRITE_ACCESS_KEY_ID }}
CI_SCREENSHOTS_S3_SECRET_ACCESS_KEY: ${{ secrets.CI_SCREENSHOTS_S3_WRITE_SECRET_ACCESS_KEY }}
run: |
E2E=pass
{ [ "${{ steps.e2e.outcome }}" = "success" ] \
&& [ "${{ steps.web_e2e.outcome }}" = "success" ]; } || E2E=fail
npm run screenshots:upload-run -- --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