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
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:
@@ -0,0 +1,83 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
window.matchMedia = (q: string) =>
|
||||
(q.includes('display-mode: standalone')
|
||||
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
||||
: orig(q)) as MediaQueryList;
|
||||
});
|
||||
}
|
||||
|
||||
// After a load or reload, land in the lobby (dismiss the login screen if it is shown).
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
const guest = page.getByRole('button', { name: /guest|гост/i });
|
||||
if (await guest.count()) await guest.first().click();
|
||||
// The lobby tab bar has three tabs in a fixed order: New (0), Stats (1), Settings (2). nth() is
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors (which the first-run
|
||||
// onboarding strips after it completes).
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||
// all-distinct letters, so the glyph is unambiguous).
|
||||
async function placeTile(page: Page, glyph: string, row: number, col: number): Promise<void> {
|
||||
await page.locator('.rack .tile', { hasText: glyph }).first().click();
|
||||
await page.locator(`[data-cell][data-row="${row}"][data-col="${col}"]`).click();
|
||||
}
|
||||
|
||||
test.describe('offline mode', () => {
|
||||
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||
await forceStandalone(page);
|
||||
await page.goto('/');
|
||||
await enterLobby(page);
|
||||
|
||||
// Online: a seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||
await page.locator('button.tab').nth(2).click();
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||
await expect(page.locator('button.tab').nth(1)).toBeDisabled();
|
||||
|
||||
// Create a device-local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO).
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
// The English variant's display name is "Scrabble" (Latin) in both locales — the Russian
|
||||
// variants are "Скрэббл"/"Эрудит"/"Erudite", so this uniquely selects the English game.
|
||||
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
||||
await page.locator('button.invite').click();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
|
||||
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
||||
await placeTile(page, 'W', 7, 5);
|
||||
await placeTile(page, 'A', 7, 6);
|
||||
await placeTile(page, 'Y', 7, 7);
|
||||
await expect(page.locator('[data-cell].pending')).toHaveCount(3);
|
||||
await page.locator('.make').click();
|
||||
|
||||
// The play commits and the local robot replies with a real move, so the board carries more than
|
||||
// WAY's three tiles.
|
||||
await expect(async () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).toPass({ timeout: 15000 });
|
||||
const filled = await page.locator('[data-cell].filled').count();
|
||||
|
||||
// Reload: the hash router restores the /game/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
await page.reload();
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
await expect(page.locator('[data-cell].filled')).toHaveCount(filled);
|
||||
});
|
||||
});
|
||||
@@ -21,8 +21,11 @@ export default defineConfig({
|
||||
// under /app/ and /telegram/): served from the preview root, an absolute base keeps assets at
|
||||
// /assets/ so the SPA-fallback also boots a subpath like /telegram/. Base only prefixes asset
|
||||
// URLs, so the minified JS under test is identical to the contour's.
|
||||
// After the mock build, copy the real per-variant dawgs into the preview output (dist-e2e) so the
|
||||
// offline spec can play a real local vs_ai game; the files are e2e-only (never committed, never in
|
||||
// the production build). Source: E2E_DICT_DIR (CI: the fetched release; local: the sibling).
|
||||
command:
|
||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||
'pnpm exec vite build --mode mock --base / --outDir dist-e2e --emptyOutDir && node scripts/e2e-dict.mjs && pnpm exec vite preview --outDir dist-e2e --port 4173 --strictPort',
|
||||
url: 'http://localhost:4173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copies the real per-variant dictionary DAWGs into the mock e2e preview output (dist-e2e/e2edict/)
|
||||
// so the offline spec can create and play a real local vs_ai game (the mock's fetchDict serves these
|
||||
// files — see lib/mock/client.ts). The dawgs are NEVER committed and never enter the production
|
||||
// build; they live only in the throwaway dist-e2e/ that `vite preview` serves for Playwright.
|
||||
//
|
||||
// Source directory: E2E_DICT_DIR — in CI the fetched scrabble-dictionary release
|
||||
// ($GITHUB_WORKSPACE/dawg); locally it defaults to the sibling scrabble-solver/dawg checkout. A
|
||||
// missing source only warns (so the other, dawg-free specs still run); the offline spec then fails
|
||||
// with an obvious HTTP 404 from fetchDict.
|
||||
|
||||
import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const srcDir = process.env.E2E_DICT_DIR ?? '../../scrabble-solver/dawg';
|
||||
const outDir = 'dist-e2e/e2edict';
|
||||
|
||||
// The app's Variant enum value -> the release dawg file name (matches the movegen/parity mapping).
|
||||
const dawgFor = {
|
||||
scrabble_en: 'en_sowpods',
|
||||
scrabble_ru: 'ru_scrabble',
|
||||
erudit_ru: 'ru_erudit',
|
||||
};
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
let copied = 0;
|
||||
for (const [variant, file] of Object.entries(dawgFor)) {
|
||||
const src = join(srcDir, `${file}.dawg`);
|
||||
if (!existsSync(src)) {
|
||||
console.warn(`e2e-dict: missing ${src} — the offline spec will 404 (set E2E_DICT_DIR)`);
|
||||
continue;
|
||||
}
|
||||
copyFileSync(src, join(outDir, `${variant}.dawg`));
|
||||
copied++;
|
||||
}
|
||||
console.log(`e2e-dict: copied ${copied}/${Object.keys(dawgFor).length} dawgs from ${srcDir} -> ${outDir}`);
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user