test(offline): mock e2e for a device-local vs_ai game (C8)
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m43s

A Playwright spec drives the whole offline flow in the mock build: force the
installed-PWA display mode, enter offline via the Settings toggle (its readiness check
fetches the dawgs), assert the blue chrome + online-games-hidden + Stats-disabled, then
create and play a device-local English vs_ai game with a pinned bag seed (deterministic
rack NEWYMAO) -- play the opening WAY across the centre, watch the robot reply with a
real move, and reload to confirm the IndexedDB replay.

Enabling infra (all e2e-only; nothing enters the production build):
- mock/client.ts fetchDict serves the per-variant dawgs from the preview build's
  /e2edict/ (was: threw 'unsupported in mock').
- scripts/e2e-dict.mjs copies the real dawgs into dist-e2e from E2E_DICT_DIR (the ui CI
  job fetches the scrabble-dictionary release like the Go jobs; local default: the
  sibling scrabble-solver/dawg); playwright.config runs it between build and preview.
- localgame/id.ts setForcedSeed + gateway.ts window.__mock.setLocalSeed: a mock-only
  seam to pin a local game's bag seed (tree-shaken from prod).
- ci.yaml ui job: fetch the dawgs + pass E2E_DICT_DIR to the e2e step.
- docs/TESTING.md: the offline e2e + the mock-dawg wiring.

Verified: check 0 / unit 482 / e2e 198 (both engines) / app entry 113.8/114.
This commit is contained in:
Ilia Denisov
2026-07-06 20:54:03 +02:00
parent 8fbbb3c5ef
commit e9f4cb0178
8 changed files with 172 additions and 6 deletions
+5
View File
@@ -5,6 +5,7 @@
import type { GatewayClient } from './client';
import { MockGateway } from './mock/client';
import { createTransport } from './transport';
import { setForcedSeed } from './localgame/id';
import { reportOffline, reportOnline } from './connection.svelte';
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
@@ -41,6 +42,7 @@ if (isMock && typeof window !== 'undefined') {
setGameLimit(v: boolean): void;
clearEmail(): void;
confirmEmailOutOfBand(email: string): void;
setLocalSeed(seed: string): void;
};
}
).__mock = {
@@ -50,5 +52,8 @@ if (isMock && typeof window !== 'undefined') {
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
// Pin the next local game's bag seed, so the offline e2e deals a known rack (a bigint as a
// string — window hooks marshal strings cleanly).
setLocalSeed: (seed: string) => setForcedSeed(BigInt(seed)),
};
}
+14 -1
View File
@@ -17,7 +17,20 @@ export function newLocalGameId(): string {
return `${LOCAL_ID_PREFIX}${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag. */
// A test seam for the mock e2e: pinning the seed makes a local game deal a known rack, so a spec
// can play a precomputed opening word. Only the mock build's window.__mock wires setForcedSeed
// (see lib/gateway.ts); a production build never calls it, so randomSeed stays truly random.
let forcedSeed: bigint | null = null;
/** setForcedSeed pins the value randomSeed returns (the mock-e2e seam); pass null to restore
* randomness. */
export function setForcedSeed(seed: bigint | null): void {
forcedSeed = seed;
}
/** randomSeed returns a random 32-bit seed for a new local game's deterministic bag (or the pinned
* seed when a test has set one via setForcedSeed). */
export function randomSeed(): bigint {
if (forcedSeed !== null) return forcedSeed;
return BigInt(Math.floor(Math.random() * 0xffffffff));
}
+7 -3
View File
@@ -166,9 +166,13 @@ export class MockGateway implements GatewayClient {
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
return { blocked: false, permanent: false, until: '', reason: '' };
}
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// No local dictionary in mock mode; the caller falls back to the mock evaluate.
throw new Error('fetchDict unsupported in mock');
async fetchDict(variant: Variant, _version: string, opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
// in by scripts/e2e-dict.mjs from the scrabble-dictionary release, never committed), so a local
// vs_ai game can generate real moves. Version is ignored (a single served file per variant).
const res = await fetch(`/e2edict/${variant}.dawg`, { signal: opts?.signal });
if (!res.ok) throw new Error(`fetchDict ${variant}: HTTP ${res.status}`);
return res.arrayBuffer();
}
async reportLocalEval(_counts: Record<string, number>): Promise<void> {