14918cc94f
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Land the offline-model redesign (ANDROID_PLAN.md O1-O7): replace the explicit offline toggle with a single detected net-state machine, unify the lobby, and add a two-tier client-version gate. Contour-safe: both version vars empty => dormant, the wire change is additive, so web/pwa/vk/tg behaviour is unchanged unless the gate is deliberately configured. O1 net-state reducer (test-first). O2 store + wiring (connection/offline shims; +@capacitor/network). O3 remove the offline toggle + migrate the pref. O4 two-tier gate: hard update_required degrades to an offline Update/Play-offline notice (not terminal); soft GATEWAY_RECOMMENDED_CLIENT_VERSION -> X-Update-Recommended nudge. O5 unified lobby (device-local + greyed-from-cache server games; self-set identity; closes G-step-0). O6 create flows (with-friends online/offline segment + offline dict guard). O7 docs. Deploy/CI: wire the version gate through the deploy (GATEWAY_MIN_CLIENT_VERSION + GATEWAY_RECOMMENDED_CLIENT_VERSION as plain unprefixed vars via compose + ci.yaml + prod-deploy.yaml + write-prod-env.sh + .env.example) so the gate is live when set (test-contour commit-hash version fails open; empty => dormant). Disable the manual android-build CI workflow for now (rename .disabled). Bump the app bundle budget 127->130 KB for the added always-loaded wiring. Fix the UI Docker stage (gateway/Dockerfile): install --ignore-scripts so the Alpine/musl SPA build no longer tries to native-build sharp (a local android:assets tool, unused in the image); esbuild's binary is an optional dep so Vite still builds. Tests: gateway go green (two-tier gate over a real HTTP handler); docker build of the landing + gateway targets green locally; compose --no-interpolate confirms the gateway env; svelte-check 0/0, vitest 617, e2e 122/122 (chromium + webkit).
115 lines
7.2 KiB
TypeScript
115 lines
7.2 KiB
TypeScript
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 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 (this closes G-step-0).
|
|
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 (G-step-0)
|
|
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();
|
|
});
|
|
});
|