test(e2e): add Playwright spec for /play Bevy canvas route

play_canvas.spec.js covers the window.__FERROUS_DEBUG__ bridge on the
/play route (five tests): bridge availability + seed param, draw3 URL
param, applyLegalMove/undo round-trip, failureReport schema, and
autonomous autoplay invariant batch across 7 seeds.

All tests drive exclusively through the debug bridge — no DOM selectors,
because the Bevy canvas is a single <canvas> element with no HTML
controls.

Also update SESSION_HANDOFF.md to reflect post-v0.35.1 work (10 commits
since 2026-05-18 handoff), new e2e architecture notes, and HiDPI fix doc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 14:03:25 -07:00
parent 2cf728210e
commit 2b1ad2161a
2 changed files with 130 additions and 6 deletions
+49 -6
View File
@@ -1,16 +1,45 @@
# Ferrous Solitaire — Session Handoff # Ferrous Solitaire — Session Handoff
**Last updated:** 2026-05-18 — Three leaderboard bugs fixed, tagged v0.35.1. All commits on origin/master. **Last updated:** 2026-06-02 — Web e2e test suite complete; `/play` canvas bridge added and tested. All commits on origin/master.
--- ---
## Current state ## Current state
- **HEAD on origin/master:** `8f86d66` (fix: three leaderboard bugs) - **HEAD:** `play_canvas.spec.js` added (Playwright tests for `/play` Bevy canvas route)
- **Latest tag:** `v0.35.1` - **Latest tag:** `v0.35.1`
- **Working tree:** clean - **Working tree:** clean
- **Build:** `cargo clippy --workspace -- -D warnings` clean - **Build:** `cargo clippy --workspace -- -D warnings` clean
- **Tests:** 1277 passing / 0 failing across the workspace - **Tests:** 1243 Rust tests passing; Playwright suite in `solitaire_server/e2e/`
---
## What shipped since the last handoff (v0.35.1 → present, 2026-06-02)
| Commit | Summary |
|--------|---------|
| `64f975e` | 14 cross-platform UX/UI fixes from 500-game audit |
| `763fdb4` | Fix input: hit-test deck at correct position; accept waste click |
| `1cdb78c` | cargo fmt; add analytics domain to CSP |
| `baf524e` | Rebuild Bevy canvas WASM; add SolitaireGame interactive API |
| `9ff0585` | Remove Quaternions registry auth; canvas WASM drift guard |
| `de7ae16` | Delay first-run modal until splash screen despawns |
| `8b736ca` | Debug drag failures (temp logging, removed in next commit) |
| `8b262af` | Clamp wgpu surface to CSS pixels on HiDPI (prevented WASM panic) |
| `d45b7cb` | Add Playwright e2e test suite for web routes |
| `2cf7282` | Add `window.__FERROUS_DEBUG__` bridge to `/play` for automation |
**Key audit bugs fixed (all 7 from 500-game UX audit):** timer-after-undo, radial-menu clamping, Android resume flash, tab-hidden timer, orphaned tmp files, drag threshold 4→6px, Draw-1 recycle doc comment.
**HiDPI wgpu fix:** `WindowResolution::default().with_scale_factor_override(1.0)` added to the Bevy canvas app. Root cause was physical pixels (CSS×DPR) exceeding WebGL2's 2048px per-dimension limit on HiDPI displays.
**E2E test architecture:** three-tier — Rust unit tests → Playwright smoke/review specs → cycle regression gate. Debug bridge contract in `docs/testing-architecture.md`.
---
## What shipped before v0.35.1
See git log. CHANGELOG.md currently ends at v0.33.0 (documentation debt, low priority).
--- ---
@@ -83,9 +112,8 @@ Three bugs fixed:
### 1. CHANGELOG documentation debt ### 1. CHANGELOG documentation debt
CHANGELOG.md currently ends at v0.33.0. Entries for v0.34.0, v0.35.0, and v0.35.1 CHANGELOG.md currently ends at v0.33.0. All post-v0.33.0 work is in git log. Low
are missing. Low priority (git log is authoritative) but worth closing before the priority git log is authoritative.
next release.
### 2. Android APK launch verification (Option A) ### 2. Android APK launch verification (Option A)
@@ -128,3 +156,18 @@ and wired to `GameStateResource` events.
- **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so - **Test input-state pitfall:** `MinimalPlugins` has no input-tick system, so
`ButtonInput::just_pressed` state persists across frames unless explicitly cleared `ButtonInput::just_pressed` state persists across frames unless explicitly cleared
with `input.release(key); input.clear()` between updates. with `input.release(key); input.clear()` between updates.
- **`/play` debug bridge design:** `play.html` runs two independent WASM instances in
`Promise.all([bootstrap(), init()])`. `bootstrap()` sets `window.__FERROUS_DEBUG__`
(logic layer via `solitaire_wasm.js`); `init()` starts the Bevy canvas. The bridge
operates its own `SolitaireGame` — moves applied through the bridge do NOT affect
the Bevy visual game. This is intentional for automation/invariant checking.
- **HiDPI Bevy canvas:** `WindowResolution::default().with_scale_factor_override(1.0)`
is set in the canvas app. Without this, physical pixels exceed WebGL2's 2048px limit
on HiDPI displays, causing an immediate wgpu panic on the first resize event.
- **`/play-classic` vs `/play` in e2e:** `smoke.spec.js` + `gameplay_review.spec.js`
target `/play-classic` (DOM-heavy game.html); `play_canvas.spec.js` targets `/play`
using only the `__FERROUS_DEBUG__` bridge (no DOM selectors). `cycle_metrics.js`
supports both via `--route play-classic|play`.
@@ -0,0 +1,81 @@
const { test, expect } = require("@playwright/test");
async function gotoReadyPlay(page, seed = 42, draw3 = false) {
const suffix = draw3 ? "&draw3=" : "";
await page.goto(`/play?seed=${seed}${suffix}`);
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
window.__FERROUS_DEBUG__.seed() !== null,
null,
{ timeout: 30_000 }
);
}
test("play loads and exposes debug bridge", async ({ page }) => {
await gotoReadyPlay(page, 42);
const seed = await page.evaluate(() => window.__FERROUS_DEBUG__.seed());
expect(seed).toBe(42);
const legalMoves = await page.evaluate(() => window.__FERROUS_DEBUG__.legalMoves());
expect(Array.isArray(legalMoves)).toBeTruthy();
expect(legalMoves.length).toBeGreaterThan(0);
});
test("play respects draw3 URL param", async ({ page }) => {
await gotoReadyPlay(page, 77, true);
const snap = await page.evaluate(() => window.__FERROUS_DEBUG__.snapshot());
expect(snap).not.toBeNull();
expect(snap.draw_mode).toBe("DrawThree");
});
test("play debug bridge apply and undo work", async ({ page }) => {
await gotoReadyPlay(page, 42);
const baseline = await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length);
const applied = await page.evaluate(() => window.__FERROUS_DEBUG__.applyLegalMove(0));
expect(applied?.ok).toBeTruthy();
await expect
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
.toBe(baseline + 1);
const undone = await page.evaluate(() => window.__FERROUS_DEBUG__.undo());
expect(undone?.ok).toBeTruthy();
await expect
.poll(() => page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
.toBe(baseline);
});
test("play failure report contains replay diagnostics", async ({ page }) => {
await gotoReadyPlay(page, 42);
const report = await page.evaluate(() => window.__FERROUS_DEBUG__.failureReport());
expect(report).not.toBeNull();
expect(typeof report.seed).toBe("number");
expect(Array.isArray(report.moveHistory)).toBeTruthy();
expect(Array.isArray(report.legalMoves)).toBeTruthy();
expect(report.currentState).toBeTruthy();
expect(report.invariants).toBeTruthy();
});
test("play autonomous autoplay keeps invariants stable across seed batch", async ({ page }) => {
test.setTimeout(120_000);
const seeds = [0, 1, 2, 5, 13, 42, 77];
for (const seed of seeds) {
await gotoReadyPlay(page, seed);
const run = await page.evaluate(() =>
window.__FERROUS_DEBUG__.runAutoplay({
maxSteps: 220,
maxVisitsPerState: 2,
policy: "loop_aware",
})
);
expect(run.ok, `seed ${seed} failed: ${JSON.stringify(run)}`).toBeTruthy();
}
});