fix(web): CI-owned wasm artifacts + e2e harness fixes #95

Merged
funman300 merged 3 commits from fix/web-ci-reproducible-and-e2e into master 2026-06-23 17:01:04 +00:00
8 changed files with 121 additions and 104 deletions
+6 -6
View File
@@ -36,12 +36,12 @@ jobs:
id: meta id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
# WASM artifact freshness is enforced precisely by the dedicated # WASM artifact freshness is owned by the `web-wasm-rebuild` workflow,
# `web-wasm-freshness` workflow (rebuild-and-diff on every PR + master # which rebuilds pkg/ in CI on every master change to a wasm-feeding crate
# push), which supersedes the previous changed-files heuristic here. # and commits it back (CI is the single source of truth — the artifacts
# That heuristic only hard-failed on direct solitaire_web/ edits and # aren't byte-reproducible on contributor machines). That pkg/ commit then
# treated solitaire_engine/_core changes as a non-blocking notice, which # triggers this workflow, so the deployed image always ships fresh wasm.
# let the whole card_game migration ship a stale pkg/. # No drift check is needed here.
- name: Log in to Gitea registry - name: Log in to Gitea registry
uses: docker/login-action@v3 uses: docker/login-action@v3
-91
View File
@@ -1,91 +0,0 @@
name: Web WASM Freshness
# Guards against the committed solitaire_server/web/pkg/ artifacts going stale.
#
# A fresh build on a pinned toolchain is byte-for-byte reproducible (verified),
# so this job rebuilds the artifacts and fails if they differ from what is
# committed. Unlike a changed-files heuristic, this catches *any* drift in the
# wasm-feeding crates — solitaire_core / _engine / _data / _sync / _wasm / _web —
# including gameplay-logic changes that do not alter the JS API surface (e.g.
# the v4->v5 save-schema change that the old heuristic let through as a
# non-blocking "notice", leaving the deployed web build stale for ~3 weeks).
#
# Tool versions are pinned to whatever produced the committed artifacts. When
# you regenerate the artifacts with a newer toolchain, bump these in lockstep:
# rust 1.95.0 · wasm-bindgen 0.2.120 · wasm-pack 0.14.0 · binaryen 130
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_server/web/pkg/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-freshness.yml'
pull_request:
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_server/web/pkg/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-freshness.yml'
workflow_dispatch:
jobs:
freshness:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0 (pinned for reproducible wasm output)
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Fail if committed pkg differs from a fresh build
run: |
set -euo pipefail
if [ -z "$(git status --porcelain -- solitaire_server/web/pkg/)" ]; then
echo "pkg/ artifacts match a fresh build."
else
echo "::error::solitaire_server/web/pkg/ is stale — a wasm-feeding crate changed without regenerating the artifacts."
echo "Fix: run ./build_wasm.sh and commit the updated solitaire_server/web/pkg/."
git status --porcelain -- solitaire_server/web/pkg/
git diff --stat -- solitaire_server/web/pkg/
exit 1
fi
+89
View File
@@ -0,0 +1,89 @@
name: Web WASM Rebuild
# CI is the single source of truth for solitaire_server/web/pkg/.
#
# The wasm artifacts cannot be reproduced byte-for-byte on an arbitrary
# contributor machine: even with identical rustc 1.95.0 / LLVM 22.1.2, the same
# flags, the same Cargo.lock and remapped source paths, the output still differs
# by host environment. So rather than police freshness with a rebuild-and-diff
# gate (which false-failed for exactly that reason), CI rebuilds the artifacts
# itself on every master change to a wasm-feeding crate and commits them back.
#
# Result: the deployed pkg/ can't silently rot, and contributors never need to
# run build_wasm.sh by hand. The commit touches only pkg/, which is not in this
# workflow's trigger paths (so it does not re-trigger here) but does match
# docker-build's, so the refreshed wasm deploys.
on:
push:
branches: [master]
paths:
- 'solitaire_core/**'
- 'solitaire_engine/**'
- 'solitaire_data/**'
- 'solitaire_sync/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'Cargo.toml'
- 'Cargo.lock'
- 'build_wasm.sh'
- '.gitea/workflows/web-wasm-rebuild.yml'
workflow_dispatch:
concurrency:
group: web-wasm-rebuild
cancel-in-progress: false
jobs:
rebuild:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.CI_TOKEN }}
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
targets: wasm32-unknown-unknown
- name: Cache cargo build
uses: Swatinem/rust-cache@v2
- name: Install wasm-bindgen-cli + wasm-pack (pinned)
uses: taiki-e/install-action@v2
with:
tool: wasm-bindgen-cli@0.2.120,wasm-pack@0.14.0
- name: Install binaryen 130 (wasm-opt, pinned)
run: |
set -euo pipefail
curl -sSL \
https://github.com/WebAssembly/binaryen/releases/download/version_130/binaryen-version_130-x86_64-linux.tar.gz \
| tar xz
echo "$PWD/binaryen-version_130/bin" >> "$GITHUB_PATH"
- name: Rebuild WASM artifacts
run: ./build_wasm.sh
- name: Commit refreshed artifacts if changed
run: |
set -euo pipefail
if git diff --quiet -- solitaire_server/web/pkg/; then
echo "pkg/ already up to date — nothing to commit."
exit 0
fi
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
git add solitaire_server/web/pkg/
git commit -m "chore(web): regenerate wasm artifacts"
# master is unprotected; retry once if the tip moved under us.
git push origin HEAD:master || {
git fetch origin master
git rebase origin/master
git push origin HEAD:master
}
+16
View File
@@ -22,6 +22,22 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg" OUT_DIR="$REPO_ROOT/solitaire_server/web/pkg"
# Reproducible builds. The wasm artifacts otherwise bake in machine-specific
# absolute source paths (the cargo registry, the rustup std sources, and this
# checkout), so a rebuild on a different machine produces different bytes and
# the CI freshness gate (rebuild-and-diff) false-positives. Remap all three
# prefixes to fixed names so the output is byte-identical anywhere.
#
# We must use CARGO_ENCODED_RUSTFLAGS (not RUSTFLAGS) and re-state the
# getrandom backend cfg here: a `*_RUSTFLAGS` env var *replaces* — does not
# merge with — the `[target.wasm32-unknown-unknown] rustflags` in
# .cargo/config.toml, so dropping that cfg would break the wasm getrandom build.
# Keep this `--cfg` in sync with .cargo/config.toml.
CARGO_HOME_DIR="${CARGO_HOME:-$HOME/.cargo}"
RUSTUP_HOME_DIR="${RUSTUP_HOME:-$HOME/.rustup}"
US=$'\x1f' # unit separator: CARGO_ENCODED_RUSTFLAGS arg delimiter
export CARGO_ENCODED_RUSTFLAGS="--cfg${US}getrandom_backend=\"wasm_js\"${US}--remap-path-prefix=${CARGO_HOME_DIR}=/cargo${US}--remap-path-prefix=${RUSTUP_HOME_DIR}=/rustup${US}--remap-path-prefix=${REPO_ROOT}=/build"
if ! command -v wasm-pack &> /dev/null; then if ! command -v wasm-pack &> /dev/null; then
echo "error: wasm-pack not found." >&2 echo "error: wasm-pack not found." >&2
echo " Install with: cargo install wasm-pack" >&2 echo " Install with: cargo install wasm-pack" >&2
@@ -136,7 +136,7 @@ test("new game button resets move history and score", async ({ page }) => {
test("timer stops accumulating while tab is hidden", async ({ page }) => { test("timer stops accumulating while tab is hidden", async ({ page }) => {
// Install the fake clock before navigation so the game's setInterval is // Install the fake clock before navigation so the game's setInterval is
// controlled by page.clock.tick() and won't fire on real wall-clock time. // controlled by page.clock.runFor() and won't fire on real wall-clock time.
await page.clock.install(); await page.clock.install();
await page.goto("/play-classic?seed=42"); await page.goto("/play-classic?seed=42");
@@ -144,7 +144,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await waitForBridge(page); await waitForBridge(page);
// Advance 3 fake seconds to get a non-zero timer reading. // Advance 3 fake seconds to get a non-zero timer reading.
await page.clock.tick(3_000); await page.clock.runFor(3_000);
const timerAfter3s = await page.locator("#hud-timer").textContent(); const timerAfter3s = await page.locator("#hud-timer").textContent();
expect(timerAfter3s).toBe("0:03"); expect(timerAfter3s).toBe("0:03");
@@ -152,7 +152,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await setTabHidden(page, true); await setTabHidden(page, true);
// Advance 10 fake seconds while hidden. // Advance 10 fake seconds while hidden.
await page.clock.tick(10_000); await page.clock.runFor(10_000);
const timerWhileHidden = await page.locator("#hud-timer").textContent(); const timerWhileHidden = await page.locator("#hud-timer").textContent();
expect(timerWhileHidden).toBe("0:03"); // must not have advanced expect(timerWhileHidden).toBe("0:03"); // must not have advanced
@@ -160,7 +160,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => {
await setTabHidden(page, false); await setTabHidden(page, false);
// Advance 2 more fake seconds. // Advance 2 more fake seconds.
await page.clock.tick(2_000); await page.clock.runFor(2_000);
const timerAfterResume = await page.locator("#hud-timer").textContent(); const timerAfterResume = await page.locator("#hud-timer").textContent();
expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted expect(timerAfterResume).toBe("0:05"); // only 3 + 2 visible seconds counted
}); });
@@ -182,11 +182,11 @@ test("timer does not restart while tab is visible during an auto-complete or won
// the snap state correctly gates the restart. // the snap state correctly gates the restart.
// //
// Advance 2 s, then hide+show — timer should continue normally. // Advance 2 s, then hide+show — timer should continue normally.
await page.clock.tick(2_000); await page.clock.runFor(2_000);
await setTabHidden(page, true); await setTabHidden(page, true);
await page.clock.tick(5_000); await page.clock.runFor(5_000);
await setTabHidden(page, false); await setTabHidden(page, false);
await page.clock.tick(2_000); await page.clock.runFor(2_000);
const timerText = await page.locator("#hud-timer").textContent(); const timerText = await page.locator("#hud-timer").textContent();
// 2 visible + 0 hidden + 2 visible = 4 total // 2 visible + 0 hidden + 2 visible = 4 total
+3
View File
@@ -986,6 +986,9 @@ window.__FERROUS_DEBUG__ = {
snapshot() { snapshot() {
return game ? game.debug_snapshot() : null; return game ? game.debug_snapshot() : null;
}, },
serialize() {
return game ? game.serialize() : null;
},
applyLegalMove(index) { applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" }; if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index); const result = game.debug_apply_legal_move(index);
Binary file not shown.
Binary file not shown.