Files
scrabble-game/ui/e2e/native.spec.ts
T
Ilia Denisov 896b7f57a7 feat(newgame): skip the take-a-seat prompt for a guest offline host
A guest host has no meaningful display name to seat, so the offline 'with friends' flow's
"do you take a seat?" prompt only produced a nameless seat. For a guest the prompt is skipped:
after the master PIN, the two empty player seats show directly. A durable host is still asked.
2026-07-15 02:17:13 +02:00

119 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<void> {
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<void> {
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<void> {
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<void> {
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 Erudit vs_ai game with a pinned bag seed (deals the rack ОЖЬЯНАО). Erudit is the
// only variant the profileless offline guest is offered (the new-account default), so the single
// `.variant` click both selects it and asserts nothing else is on offer. The dictionary comes from
// the APK's bundled tier (./dict/ru_erudit@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').click();
await page.locator('button.invite').click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The human plays НОЖ 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 НОЖ's three.
await placeTile(page, 'Н', 7, 5);
await placeTile(page, 'О', 7, 6);
await placeTile(page, 'Ж', 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. A guest host skips the "do you take a
// seat?" prompt (no name to seat) and goes straight to the two open seats, so pick the sole
// offered variant (Erudit — the profileless guest's default) right after the PIN.
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 expect(page.getByRole('button', { name: /^(No|Нет)$/ })).toHaveCount(0); // no seat prompt for a guest
await page.locator('.variant').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();
});
});