diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 5e58272..f8ac898 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -214,9 +214,21 @@ jobs: run: pnpm exec playwright install chromium webkit timeout-minutes: 5 + # The offline e2e plays a real local vs_ai game, so it needs the per-variant dawgs; fetch the + # release the same way the Go jobs do and point the mock preview's copy step (scripts/ + # e2e-dict.mjs, via E2E_DICT_DIR below) at it. + - name: Fetch dictionary DAWGs + run: | + mkdir -p "${GITHUB_WORKSPACE}/dawg" + curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz" + tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg" + ls -la "${GITHUB_WORKSPACE}/dawg" + - name: E2E smoke (mock) run: pnpm run test:e2e timeout-minutes: 5 + env: + E2E_DICT_DIR: ${{ github.workspace }}/dawg # conformance proves the client's local move preview (the ported dawg reader + # validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine: diff --git a/docs/TESTING.md b/docs/TESTING.md index e22de9c..14c0eca 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -20,7 +20,18 @@ tests or touching CI. derivation and the GCG share/copy/download choice, plus Playwright specs against the mock for the friends screen (code issue/redeem, accept a request), the lobby invitations section, the stats screen, profile editing, and the export chooser's - finished-only visibility + its signed-URL download flow (route-intercepted). + finished-only visibility + its signed-URL download flow (route-intercepted). The + **offline mode** spec (`e2e/offline.spec.ts`) plays a full device-local `vs_ai` game + end-to-end: it forces the installed-PWA display mode, enters offline through the + Settings toggle (whose readiness check fetches the enabled variants' dawgs), then creates + and plays a local game with a **pinned bag seed** (`window.__mock.setLocalSeed`, so the + rack is deterministic and the human can tap out a precomputed opening), asserting the + robot's real reply and the IndexedDB replay after a reload. A local game needs a real + dictionary, so the mock's `fetchDict` serves the per-variant dawgs from the preview + build's `/e2edict/`, copied in by `scripts/e2e-dict.mjs` from `E2E_DICT_DIR` — the `ui` + CI job fetches the `scrabble-dictionary` release like the Go jobs; locally it defaults to + the sibling `scrabble-solver/dawg`. These dawgs are never committed and never enter the + production build. - **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared `ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a real self-played 35-move game) must rasterize to a plausible PNG diff --git a/ui/e2e/offline.spec.ts b/ui/e2e/offline.spec.ts new file mode 100644 index 0000000..cc42f6e --- /dev/null +++ b/ui/e2e/offline.spec.ts @@ -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 { + 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 { + 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 { + 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/ 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); + }); +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index b3d34cd..e993a88 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -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, diff --git a/ui/scripts/e2e-dict.mjs b/ui/scripts/e2e-dict.mjs new file mode 100644 index 0000000..9c8ec20 --- /dev/null +++ b/ui/scripts/e2e-dict.mjs @@ -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}`); diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 02a9482..cfefa9c 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -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)), }; } diff --git a/ui/src/lib/localgame/id.ts b/ui/src/lib/localgame/id.ts index bba13ef..e25fc86 100644 --- a/ui/src/lib/localgame/id.ts +++ b/ui/src/lib/localgame/id.ts @@ -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)); } diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index a1219e2..6f6ebe9 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -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 { - // 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 { + // 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): Promise {