import { test, expect, type Page } from './fixtures'; // Simulate the native (Capacitor) channel. @capacitor/core β€” loaded during boot by initNativeShell β€” // derives the platform from window.androidBridge / window.webkit and REPLACES any pre-set // window.Capacitor with its own shim (whose getPlatform() would otherwise report 'web'), so the // reliable native signal is the bridge marker, injected before any app script runs. clientChannel() // then resolves to 'android', which flips the offline-first cold boot (app.svelte.ts), the loader's // bundled-dict tier (lib/dict/loader.ts) and every native gate. The bridge is a stub with no real // native runtime behind it; initNativeShell tolerates that (its @capacitor/app addListener is wrapped). async function simulateNative(page: Page): Promise { await page.addInitScript(() => { (window as unknown as { androidBridge: { postMessage: () => void } }).androidBridge = { postMessage: () => {} }; (window as unknown as { Capacitor: { getPlatform: () => string } }).Capacitor = { getPlatform: () => 'android' }; }); } // The native cold boot skips the blocking login and lands straight in the lobby as a device-local // guest in (auto) offline mode: the header carries the offline tint and the tab bar is present with // no guest sign-in click. Offline the seeded online game (vs Ann) is hidden and Stats is disabled. async function expectOfflineGuestLobby(page: Page): Promise { await expect(page.locator('header.nav.offline')).toBeVisible(); 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(); } // 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(); } // typePin clicks the on-screen keypad digits (mirrors hotseat.spec β€” the pad has no OK button, so the // 4th digit is the action; wait for the dots to clear so a call right after a previous entry keeps them). async function typePin(page: Page, digits: string): Promise { await expect(page.locator('.pad .dot.on')).toHaveCount(0); for (const d of digits) await page.locator('.pad .key', { hasText: d }).click(); } test.describe('native offline-first', () => { test('cold-boots to the offline guest lobby, plays local vs_ai, reconciles a server guest online', async ({ page }) => { await simulateNative(page); await page.goto('/'); await expectOfflineGuestLobby(page); // Play a local English vs_ai game with a pinned bag seed (deals the rack NEWYMAO). The dictionary // comes from the APK's bundled tier (./dict/scrabble_en@dev.dawg served from dist-e2e), so no // network is needed β€” this is the device-local guest playing with zero connectivity. await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1')); await page.locator('button.tab').nth(0).click(); 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); the local robot replies with a // real move (which needs the bundled dictionary), so the board gains tiles beyond WAY's three. 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(); await expect(async () => { expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3); }).toPass({ timeout: 15000 }); // Back to the lobby (still offline): the device-local vs_ai game (πŸ€–) is listed. Then the network // returns: reconciliation silently mints + adopts a server guest and clears the auto-offline, so // online lights up β€” the offline tint drops, the seeded online game (vs Ann) appears and Stats // re-enables. The local game STAYS visible in the unified lobby alongside the server games β€” a // reconciled guest keeps its device-local games. await page.getByRole('button', { name: /Back|Назад/i }).click(); await expect(page.locator('button.tab').nth(2)).toBeVisible(); await expect(page.locator('.rowwrap').filter({ hasText: 'πŸ€–' })).toBeVisible(); // the local vs_ai game, listed while offline await page.evaluate(() => (window as unknown as { __native: { reconcile(): void } }).__native.reconcile()); await expect(page.locator('header.nav.offline')).toHaveCount(0); await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible({ timeout: 15000 }); await expect(page.locator('.rowwrap').filter({ hasText: 'πŸ€–' })).toBeVisible(); // still listed online β€” the reconciled guest keeps it await expect(page.locator('button.tab').nth(1)).toBeEnabled(); // The native sign-in surface is guest + email only: on Profile the Telegram + VK LINK buttons are // hidden (their login widget / full-page redirect cannot return into the WebView), while email account // management stays. loginWidgetAvailable/vkWebLinkAvailable are true under the mock, so on web these // buttons DO show (social.spec links Telegram there) β€” a count of 0 here proves the native gate, not a // merely-absent button. await page.getByRole('button', { name: /Settings/ }).click(); await page.getByRole('button', { name: 'Profile', exact: true }).click(); await expect(page.getByRole('button', { name: /Link Telegram/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /Link VK/i })).toHaveCount(0); await expect(page.getByText('you@example.com')).toBeVisible(); }); test('starts a 2-player hotseat game as an offline guest', async ({ page }) => { await simulateNative(page); await page.goto('/'); await expectOfflineGuestLobby(page); // New game -> the offline mode selector offers "with friends" (pass-and-play) to the local guest. // A minimal create: set the mandatory host (master) PIN, decline a seat, English, two open seats. await page.locator('button.tab').nth(0).click(); await page.getByRole('button', { name: /With friends|Π΄Ρ€ΡƒΠ·ΡŒΡΠΌΠΈ/i }).click(); await page.locator('.hostpin .plink').click(); await typePin(page, '9999'); await typePin(page, '9999'); await page.getByRole('button', { name: /^(No|НСт)$/ }).click(); await page.locator('.variant', { hasText: 'Scrabble' }).click(); await page.locator('.pname').nth(0).fill('Ann'); await page.locator('.pname').nth(1).fill('Bob'); await page.locator('button.invite').click(); // The hotseat game starts: the board renders β€” a 2-player local pass-and-play game running with no // network, reached straight from the native offline-first boot as the device-local guest. await expect(page.locator('[data-cell]').first()).toBeVisible(); }); });