Files
scrabble-game/ui/e2e/update.spec.ts
T
Ilia Denisov 2d1fadb50c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
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 1m54s
feat(offline): implicit net-state model, two-tier version gate, unified lobby
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.

Telegram/VK stay online-only (contour review): the offline model is channel-gated via offlineCapable() (native + plain web; false in the mini-apps). offlineMode.active is hard-gated on it, so the whole model (blue chrome, unified/greyed lobby, transport kill switch, device-local vs_ai/hotseat create) stays inert in Telegram/VK regardless of the detected net state (closing a version-lock path that could have flipped them offline); and New Game's with-friends hides the online/offline segment there, leaving the remote invite alone. ARCHITECTURE already declared "Telegram/VK are exempt" - this makes the code match.

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; two new telegram.spec.ts e2e (offline signal keeps the chrome online + quick-match opponent choice visible => server enqueue; with-friends shows no pass-and-play), RED-verified; svelte-check 0/0, vitest 617, e2e 248 (chromium + webkit), build + bundle-size 127.8/130.
2026-07-13 02:50:41 +02:00

78 lines
4.0 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 { expect, test, type Page } from './fixtures';
// The two-tier client-version gate (docs/ARCHITECTURE.md §2). The mock transport never produces a real
// version signal, so — like the maintenance overlay — the e2e drives both tiers through the
// window.__update hook (gateway.ts, mock-only): __update.on() raises the HARD "update required" notice
// (the net-state machine's offlineVersionLocked), __update.recommend() raises the SOFT "update
// available" nudge.
async function enterLobby(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest|гост/i }).first().click();
await expect(page.locator('button.tab').nth(2)).toBeVisible();
}
test('the update-required notice covers the app and offers Update or Play offline', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
await expect(notice.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
await expect(notice.getByRole('button', { name: /Play offline|Играть офлайн/i })).toBeVisible();
});
test('the Update action reloads the web client', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
// On the web the action reloads the page to fetch the current client (a native build opens the store
// instead). The reload re-bootstraps to INITIAL online, so the notice is gone and the login is back.
await Promise.all([
page.waitForEvent('load'),
notice.getByRole('button', { name: /Update|Обновить/i }).click(),
]);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
});
test('Play offline dismisses the notice into the offline lobby', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
// Online first: the seeded online game (vs Ann) is listed.
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
// A too-old foreground call locks the version: the notice covers the (now offline) app.
await page.evaluate(() => (window as unknown as { __update: { on(): void } }).__update.on());
const notice = page.getByRole('alertdialog');
await expect(notice).toBeVisible();
// "Play offline" dismisses the notice and leaves the app in the offline lobby (the version lock is an
// offline state): the header is blue and the online game is gone.
await notice.getByRole('button', { name: /Play offline|Играть офлайн/i }).click();
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.locator('header.nav.offline')).toBeVisible();
// The server game (vs Ann) rides along greyed from the cache (offline, un-openable).
await expect(page.locator('.rowwrap.greyed').filter({ hasText: 'Ann' })).toBeVisible();
});
test('the soft update-available nudge shows in the lobby and dismisses', async ({ page }) => {
await page.goto('/');
await enterLobby(page);
await expect(page.locator('.nudge')).toHaveCount(0);
// A served response below the recommended version carries X-Update-Recommended: the non-blocking
// nudge appears in the lobby (above the tab bar); play is unaffected — no overlay.
await page.evaluate(() => (window as unknown as { __update: { recommend(): void } }).__update.recommend());
const nudge = page.locator('.nudge');
await expect(nudge).toBeVisible();
await expect(nudge).toContainText(/Update available|Доступно обновление/i);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
// Dismissing it (the ×) hides it for the session.
await nudge.getByRole('button', { name: /Close|Закрыть/i }).click();
await expect(page.locator('.nudge')).toHaveCount(0);
});