import { test, expect, type Page } from './fixtures'; // Offline is now implicit — the net-state machine detects it; there is no toggle. The e2e drives the // machine through the mock's __net hook (the real probe watcher is inert under the mock). Standalone // display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an // installed PWA; only the `(display-mode: standalone)` query is overridden. 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, land in the lobby. The mock cold-starts on the login screen (no seeded session), so // click through as guest. Waiting for the button (rather than a point-in-time count()) is what makes // this deterministic. async function enterLobby(page: Page): Promise { await page.getByRole('button', { name: /guest|гост/i }).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. await expect(page.locator('button.tab').nth(2)).toBeVisible(); } // Drive the net-state machine offline / online through the mock hook (no toggle, no dialog). async function goOffline(page: Page): Promise { await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline()); } async function goOnline(page: Page): Promise { await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online()); } // Pick a rack tile by its glyph and drop it on a board square (the pinned-seed rack is all-distinct). 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 (implicit)', () => { test('auto-detects offline, plays a local vs_ai game, persists 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(); // The network drops: the machine auto-detects offline (no toggle, no dialog) — a toast announces it // and the header turns blue with the chip. await goOffline(page); await expect(page.locator('.toast')).toContainText(/offline|соединени/i); await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); // The (now offline) lobby stays mounted: the server game (vs Ann) rides along GREYED from the cache // (un-openable), and the Stats tab disables. await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible(); 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's first move is not idle-gated: the hint is available at once (no 🔒 lock badge). await expect(page.locator('.lock')).toHaveCount(0); // 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 (which needs the bundled // dictionary), 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(); // Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint // button shows the 🔒 lock (it lifts after 30 idle minutes on a monotonic clock — not waited here). await expect(page.locator('.lock')).toBeVisible(); // 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); // The idle-hint gate is persisted (a wall-clock unlock time), so the 🔒 survives the reload. await expect(page.locator('.lock')).toBeVisible(); }); test('self-heals to online when the network returns', async ({ page }) => { await forceStandalone(page); await page.goto('/'); await enterLobby(page); await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); // Offline, then the network returns: the machine heals to online on its own (a "back online" toast), // the offline tint drops and the online games return — no user action. await goOffline(page); await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 }); await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible(); await goOnline(page); await expect(page.locator('header.nav.offline')).toHaveCount(0, { timeout: 15000 }); // Back online, the server game is active again (no longer greyed). await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); await expect(page.locator('.rowwrap.greyed')).toHaveCount(0); await expect(page.locator('button.tab').nth(1)).toBeEnabled(); }); test('a stale deliberate-offline pref is ignored and cleared; Settings has no toggle', async ({ page }) => { await forceStandalone(page); // Seed a pre-redesign deliberate-offline flag: offline is implicit now, so it must be ignored // (nobody stuck offline) and cleared on boot. await page.addInitScript(() => localStorage.setItem('scrabble.offlineMode', '1')); await page.goto('/'); await enterLobby(page); // Booted online despite the stale pref: the online game (vs Ann) shows and the header is not blue. await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible(); await expect(page.locator('header.nav.offline')).toHaveCount(0); // The orphaned key was cleared at boot. expect(await page.evaluate(() => localStorage.getItem('scrabble.offlineMode'))).toBeNull(); // Settings no longer offers the offline toggle (the "Play mode" Online/Offline segment is gone), // even in the eligible (standalone + email) context where it used to appear. await page.locator('button.tab').nth(2).click(); await expect(page.getByRole('button', { name: /^(Offline|Оффлайн)$/ })).toHaveCount(0); }); });