diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 61c319e..63034c6 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -36,12 +36,12 @@ jobs: id: meta run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" - # WASM artifact freshness is enforced precisely by the dedicated - # `web-wasm-freshness` workflow (rebuild-and-diff on every PR + master - # push), which supersedes the previous changed-files heuristic here. - # That heuristic only hard-failed on direct solitaire_web/ edits and - # treated solitaire_engine/_core changes as a non-blocking notice, which - # let the whole card_game migration ship a stale pkg/. + # WASM artifact freshness is owned by the `web-wasm-rebuild` workflow, + # which rebuilds pkg/ in CI on every master change to a wasm-feeding crate + # and commits it back (CI is the single source of truth — the artifacts + # aren't byte-reproducible on contributor machines). That pkg/ commit then + # triggers this workflow, so the deployed image always ships fresh wasm. + # No drift check is needed here. - name: Log in to Gitea registry uses: docker/login-action@v3 diff --git a/.gitea/workflows/web-wasm-freshness.yml b/.gitea/workflows/web-wasm-freshness.yml deleted file mode 100644 index b800455..0000000 --- a/.gitea/workflows/web-wasm-freshness.yml +++ /dev/null @@ -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 diff --git a/.gitea/workflows/web-wasm-rebuild.yml b/.gitea/workflows/web-wasm-rebuild.yml new file mode 100644 index 0000000..7d54aae --- /dev/null +++ b/.gitea/workflows/web-wasm-rebuild.yml @@ -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 + } diff --git a/build_wasm.sh b/build_wasm.sh index 46d0be6..995d17d 100755 --- a/build_wasm.sh +++ b/build_wasm.sh @@ -22,6 +22,22 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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 echo "error: wasm-pack not found." >&2 echo " Install with: cargo install wasm-pack" >&2 diff --git a/solitaire_server/e2e/tests/game_behaviors.spec.js b/solitaire_server/e2e/tests/game_behaviors.spec.js index af1bc9e..425379f 100644 --- a/solitaire_server/e2e/tests/game_behaviors.spec.js +++ b/solitaire_server/e2e/tests/game_behaviors.spec.js @@ -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 }) => { // 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.goto("/play-classic?seed=42"); @@ -144,7 +144,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => { await waitForBridge(page); // 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(); expect(timerAfter3s).toBe("0:03"); @@ -152,7 +152,7 @@ test("timer stops accumulating while tab is hidden", async ({ page }) => { await setTabHidden(page, true); // 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(); 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); // 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(); 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. // // 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 page.clock.tick(5_000); + await page.clock.runFor(5_000); await setTabHidden(page, false); - await page.clock.tick(2_000); + await page.clock.runFor(2_000); const timerText = await page.locator("#hud-timer").textContent(); // 2 visible + 0 hidden + 2 visible = 4 total diff --git a/solitaire_server/web/game.js b/solitaire_server/web/game.js index f8978f9..dec4e95 100644 --- a/solitaire_server/web/game.js +++ b/solitaire_server/web/game.js @@ -986,6 +986,9 @@ window.__FERROUS_DEBUG__ = { snapshot() { return game ? game.debug_snapshot() : null; }, + serialize() { + return game ? game.serialize() : null; + }, applyLegalMove(index) { if (!game) return { ok: false, error: "game_not_ready" }; const result = game.debug_apply_legal_move(index); diff --git a/solitaire_server/web/pkg/canvas_bg.wasm b/solitaire_server/web/pkg/canvas_bg.wasm index e773eb8..9636109 100644 Binary files a/solitaire_server/web/pkg/canvas_bg.wasm and b/solitaire_server/web/pkg/canvas_bg.wasm differ diff --git a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm index cb078cf..2376852 100644 Binary files a/solitaire_server/web/pkg/solitaire_wasm_bg.wasm and b/solitaire_server/web/pkg/solitaire_wasm_bg.wasm differ