feat(e2e): add Playwright browser test suite for web routes
Build and Deploy / build-and-push (push) Successful in 1m6s
Web E2E / web-e2e (push) Successful in 4m40s

solitaire_server/e2e/:
- smoke.spec.js: verifies /play-classic loads, exposes window.__FERROUS_DEBUG__
  bridge, keyboard parity (Space=draw, U=undo), debug failure report, and
  replay payload builder exports schema-v2 moves.
- gameplay_review.spec.js: HUD/controls render check, stock-click + undo
  player flow, draw-mode toggle, autonomous play invariant batch, and
  cycle-detection regression guard.
- cycle_metrics.js: headless cycle-rate analysis tool; run via
  `npm run review:cycles` with configurable policy, game count, and
  thresholds. Regression gate baked into package.json scripts.
- playwright.config.js: targets the local server at http://localhost:8080.
- package.json / package-lock.json: @playwright/test 1.60.0.

.gitea/workflows/web-e2e.yml:
- Runs on pushes to solitaire_server/, solitaire_wasm/, solitaire_core/,
  or Cargo changes. Starts the server binary, waits for /health, runs
  the full Playwright suite, uploads test-results/ on failure.

docs/testing-architecture.md: documents the three-tier test strategy
  (unit → Playwright smoke → cycle regression) and the __FERROUS_DEBUG__
  bridge contract.

scripts/update_quaternions_deps.sh: helper to bump the Quaternions
  registry deps (klondike, card_game) by version and run the full
  safety gate including deterministic replay checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 12:40:30 -07:00
parent 763fdb486f
commit d45b7cb82b
9 changed files with 887 additions and 0 deletions
@@ -0,0 +1,93 @@
const { test, expect } = require("@playwright/test");
async function gotoReadyGame(page, seed = 42) {
await page.goto(`/play-classic?seed=${seed}`);
const resumeOverlay = page.locator("#resume-overlay:not(.hidden)");
if (await resumeOverlay.isVisible().catch(() => false)) {
await page.evaluate(() => localStorage.removeItem("fs_game_save"));
await page.reload();
}
await page.waitForFunction(
() =>
typeof window.__FERROUS_DEBUG__ === "object" &&
window.__FERROUS_DEBUG__.seed() !== null
);
}
test("hud and core controls render for gameplay", async ({ page }) => {
await gotoReadyGame(page, 42);
await expect(page.locator("#hud-score")).toHaveText(/Score:\s*\d+/);
await expect(page.locator("#hud-moves")).toHaveText(/Moves:\s*\d+/);
await expect(page.locator("#hud-timer")).toHaveText(/\d+:\d{2}/);
await expect(page.locator("#hud-stock")).toHaveText(/Stock:\s*\d+/);
await expect(page.locator("#btn-undo")).toBeVisible();
await expect(page.locator("#btn-new")).toBeVisible();
await expect(page.locator("#chk-draw3")).toBeVisible();
await expect(page.locator("#btn-theme")).toBeVisible();
await expect(page.locator("#board")).toBeVisible();
await expect(page.locator("#card-area .slot[data-pile='stock']")).toBeVisible();
});
test("stock click + undo button behaves like player flow", async ({ page }) => {
await gotoReadyGame(page, 42);
const baselineHistoryLen = await page.evaluate(
() => window.__FERROUS_DEBUG__.moveHistory().length
);
const stockBox = await page.locator("#card-area .slot[data-pile='stock']").boundingBox();
expect(stockBox).not.toBeNull();
await page.mouse.click(
stockBox.x + stockBox.width / 2,
stockBox.y + stockBox.height / 2
);
await expect
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
.toBe(baselineHistoryLen + 1);
await page.locator("#btn-undo").click();
await expect
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.moveHistory().length))
.toBe(baselineHistoryLen);
});
test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
await gotoReadyGame(page, 123);
await page.locator("#chk-draw3").check();
await expect(page.locator("#chk-draw3")).toBeChecked();
const applyResult = await page.evaluate(() =>
window.__FERROUS_DEBUG__.applyMove({ kind: "stock_click" })
);
expect(applyResult?.ok).toBeTruthy();
await expect
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
.toBe(true);
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
expect(payload.draw_mode).toBe("DrawThree");
expect(payload.schema_version).toBe(2);
expect(Array.isArray(payload.moves)).toBeTruthy();
expect(payload.moves.length).toBeGreaterThan(0);
});
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
test.setTimeout(120_000);
const seeds = [0, 1, 2, 3, 4, 5, 7, 11, 13, 17, 23, 29, 31, 42, 77, 99];
for (const seed of seeds) {
await gotoReadyGame(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();
}
});