Merge pull request 'test(offline): mock e2e for a device-local vs_ai game (C8)' (#206) from feature/offline-mock-e2e into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m28s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 1m28s
CI / conformance (push) Successful in 9s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m42s
This commit was merged in pull request #206.
This commit is contained in:
@@ -214,9 +214,21 @@ jobs:
|
|||||||
run: pnpm exec playwright install chromium webkit
|
run: pnpm exec playwright install chromium webkit
|
||||||
timeout-minutes: 5
|
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)
|
- name: E2E smoke (mock)
|
||||||
run: pnpm run test:e2e
|
run: pnpm run test:e2e
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
|
env:
|
||||||
|
E2E_DICT_DIR: ${{ github.workspace }}/dawg
|
||||||
|
|
||||||
# conformance proves the client's local move preview (the ported dawg reader +
|
# 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:
|
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
|
||||||
|
|||||||
+12
-1
@@ -20,7 +20,18 @@ tests or touching CI.
|
|||||||
derivation and the GCG share/copy/download choice, plus Playwright specs against the
|
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
|
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
|
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
|
- **Render sidecar** — `renderer/` (Node + skia-canvas executing the shared
|
||||||
`ui/src/lib/gameimage.ts`) carries a `node --test` smoke: the committed fixture (a
|
`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
|
real self-played 35-move game) must rasterize to a plausible PNG
|
||||||
|
|||||||
@@ -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
|
// 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
|
// /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.
|
// 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:
|
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',
|
url: 'http://localhost:4173',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 120_000,
|
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 type { GatewayClient } from './client';
|
||||||
import { MockGateway } from './mock/client';
|
import { MockGateway } from './mock/client';
|
||||||
import { createTransport } from './transport';
|
import { createTransport } from './transport';
|
||||||
|
import { setForcedSeed } from './localgame/id';
|
||||||
import { reportOffline, reportOnline } from './connection.svelte';
|
import { reportOffline, reportOnline } from './connection.svelte';
|
||||||
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
import { clearMaintenance, maintenanceRecovered, reportMaintenance } from './maintenance.svelte';
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
setGameLimit(v: boolean): void;
|
setGameLimit(v: boolean): void;
|
||||||
clearEmail(): void;
|
clearEmail(): void;
|
||||||
confirmEmailOutOfBand(email: string): void;
|
confirmEmailOutOfBand(email: string): void;
|
||||||
|
setLocalSeed(seed: string): void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
).__mock = {
|
).__mock = {
|
||||||
@@ -50,5 +52,8 @@ if (isMock && typeof window !== 'undefined') {
|
|||||||
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
setGameLimit: (v: boolean) => (gateway as MockGateway).setGameLimit(v),
|
||||||
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
clearEmail: () => (gateway as MockGateway).mockClearEmail(),
|
||||||
confirmEmailOutOfBand: (email: string) => (gateway as MockGateway).mockConfirmEmailOutOfBand(email),
|
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)}`;
|
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 {
|
export function randomSeed(): bigint {
|
||||||
|
if (forcedSeed !== null) return forcedSeed;
|
||||||
return BigInt(Math.floor(Math.random() * 0xffffffff));
|
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.
|
// The mock never blocks; the blocked screen is driven directly via the mock-mode __block seam.
|
||||||
return { blocked: false, permanent: false, until: '', reason: '' };
|
return { blocked: false, permanent: false, until: '', reason: '' };
|
||||||
}
|
}
|
||||||
async fetchDict(_variant: Variant, _version: string, _opts?: { signal?: AbortSignal; reload?: boolean }): Promise<ArrayBuffer> {
|
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.
|
// The offline e2e serves the real per-variant dawgs from the preview build's /e2edict/ (copied
|
||||||
throw new Error('fetchDict unsupported in mock');
|
// 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> {
|
async reportLocalEval(_counts: Record<string, number>): Promise<void> {
|
||||||
|
|||||||
Reference in New Issue
Block a user