feat(offline): implicit net-state model, two-tier version gate, unified lobby
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
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
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.
This commit is contained in:
+12
-10
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is gated to an installed
|
||||
// standalone PWA with a confirmed email. The mock account has an email; force standalone so the
|
||||
// Settings offline toggle shows.
|
||||
// Offline pass-and-play (hotseat) is offered only in offline mode, which is now implicit (the net-state
|
||||
// machine detects it — no toggle). The e2e drives the machine through the mock's __net hook. Standalone
|
||||
// display mode is still forced so the mock's offline dictionary path (served from /e2edict/) matches an
|
||||
// installed PWA.
|
||||
async function forceStandalone(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
@@ -19,11 +20,9 @@ async function enterLobby(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.locator('button.tab').nth(2).click(); // Settings
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
// The machine auto-detects offline (no toggle, no dialog); the lobby stays mounted and turns blue.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
await expect(page.locator('header.nav.offline')).toBeVisible({ timeout: 15000 });
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// typePin clicks the on-screen keypad digits (the pad has no OK button — the 4th digit is the action).
|
||||
@@ -46,9 +45,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
// randomised at start; the shuffle is seed-driven — seed '1' would seat Bob first, '2' Ann first.)
|
||||
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('2'));
|
||||
|
||||
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||
// New game -> "with friends" offers an online (invite) / offline (hotseat) segment. Offline, the
|
||||
// online segment is disabled and the offline (pass-and-play) one is forced, so the hotseat form shows.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||
await expect(page.getByRole('button', { name: /Invite a friend|Пригласить друга/i })).toBeDisabled();
|
||||
await expect(page.locator('.hostpin')).toBeVisible();
|
||||
|
||||
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||
@@ -115,12 +117,12 @@ test.describe('offline hotseat (pass-and-play)', () => {
|
||||
// Back in the lobby the active hotseat game has a kebab; terminating it needs the master PIN.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(1);
|
||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(1);
|
||||
await page.locator('.kebab').first().click();
|
||||
const del = page.locator('.rowwrap.revealed .del');
|
||||
await expect(del).toBeVisible();
|
||||
await del.click();
|
||||
await typePin(page, '9999');
|
||||
await expect(page.locator('.rowwrap')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap:not(.greyed)')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,15 +63,18 @@ test.describe('native offline-first', () => {
|
||||
expect(await page.locator('[data-cell].filled').count()).toBeGreaterThan(3);
|
||||
}).toPass({ timeout: 15000 });
|
||||
|
||||
// Back to the lobby, 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 the Stats tab re-enables. (The local vs_ai game stays
|
||||
// device-only, so the online lobby list no longer shows it — that is the intended split.)
|
||||
// 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
|
||||
|
||||
+71
-27
@@ -1,8 +1,9 @@
|
||||
import { test, expect, type Page } from './fixtures';
|
||||
|
||||
// The offline mode is gated to an installed standalone PWA with a confirmed email. The mock account
|
||||
// has an email; force the standalone display mode so the Settings offline toggle is offered. Only the
|
||||
// `(display-mode: standalone)` query is overridden — theme/reduce-motion queries pass through.
|
||||
// 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<void> {
|
||||
await page.addInitScript(() => {
|
||||
const orig = window.matchMedia.bind(window);
|
||||
@@ -15,25 +16,30 @@ async function forceStandalone(page: Page): Promise<void> {
|
||||
|
||||
// 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: a count() sampled during the pre-login splash frame returned 0, skipped the
|
||||
// click, and then hung — or latched a transient lobby tab-bar — instead of opening the real lobby.
|
||||
// this deterministic.
|
||||
async function enterLobby(page: Page): Promise<void> {
|
||||
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 (which the first-run
|
||||
// onboarding strips after it completes).
|
||||
// robust to locale, emoji variation selectors and the coachmark anchors.
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
}
|
||||
|
||||
// Pick a rack tile by its glyph and drop it on a board square (the rack of the pinned-seed game has
|
||||
// all-distinct letters, so the glyph is unambiguous).
|
||||
// Drive the net-state machine offline / online through the mock hook (no toggle, no dialog).
|
||||
async function goOffline(page: Page): Promise<void> {
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
}
|
||||
async function goOnline(page: Page): Promise<void> {
|
||||
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<void> {
|
||||
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', () => {
|
||||
test('enter via the toggle, play a local vs_ai game, persist across a reload', async ({ page }) => {
|
||||
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);
|
||||
@@ -41,23 +47,22 @@ test.describe('offline mode', () => {
|
||||
// Online: a seeded online game (vs Ann) is listed.
|
||||
await expect(page.getByText('Ann', { exact: false }).first()).toBeVisible();
|
||||
|
||||
// Enter offline through the real Settings toggle (its readiness check fetches every enabled
|
||||
// variant's dawg, served from /e2edict/ by the mock) — the header turns blue with the chip.
|
||||
await page.locator('button.tab').nth(2).click();
|
||||
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||
// 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 });
|
||||
|
||||
// Back in the (now offline) lobby: the online games are hidden and the Stats tab is disabled.
|
||||
await page.getByRole('button', { name: /Back|Назад/i }).click();
|
||||
await expect(page.locator('button.tab').nth(2)).toBeVisible();
|
||||
await expect(page.getByText('Ann', { exact: false })).toHaveCount(0);
|
||||
// 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.
|
||||
// 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();
|
||||
@@ -72,8 +77,8 @@ test.describe('offline mode', () => {
|
||||
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, so the board carries more than
|
||||
// WAY's three tiles.
|
||||
// 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 });
|
||||
@@ -83,13 +88,52 @@ test.describe('offline mode', () => {
|
||||
// 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/<id> route and the local game replays from
|
||||
// IndexedDB with every committed tile intact.
|
||||
// Reload: the hash router restores the /game/<id> 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 —
|
||||
// the wait was not reset by relaunching.
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,6 +122,54 @@ test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a
|
||||
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), an offline signal never switches the app to offline mode', async ({
|
||||
page,
|
||||
}) => {
|
||||
// The Telegram mini-app is online-only: the offline model (implicit offline, device-local games,
|
||||
// the transport kill switch) must stay inert there. Even when the net-state machine is driven to an
|
||||
// offline state, offlineMode must remain false — otherwise the chrome turns blue, the lobby greys the
|
||||
// server games and, critically, a vs_ai start would create a device-local game instead of enqueuing.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible(); // the durable mini-app session, in the lobby
|
||||
|
||||
// Drive the net-state machine to an offline state through the mock hook.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
|
||||
// Because the channel is online-only, offlineMode stays inert: on the lobby the chrome never turns blue
|
||||
// and no server game is greyed…
|
||||
await expect(page.locator('header.nav.offline')).toHaveCount(0);
|
||||
await expect(page.locator('.rowwrap.greyed')).toHaveCount(0);
|
||||
// …and New Game's quick match still offers the opponent choice (AI / random), which is hidden only in
|
||||
// real offline mode. Its presence is the positive proof that offlineMode is false — so the vs_ai start,
|
||||
// whose device-local branch is gated on offlineMode, enqueues on the server instead of creating a
|
||||
// device-local game.
|
||||
await page.locator('button.tab').nth(0).click();
|
||||
await expect(page.locator('button.opt', { hasText: '🤖' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('inside Telegram (online-only), New Game with friends offers remote invites only', async ({
|
||||
page,
|
||||
}) => {
|
||||
// A Telegram launch authenticates a durable account, so New Game shows the auto/with-friends selector.
|
||||
// "Play with friends" must offer only the remote invite — never the online/offline segment, whose
|
||||
// "Pass and play" is the device-local hotseat flow that has no place in an online-only mini-app.
|
||||
await page.addInitScript((stub) => {
|
||||
Object.assign(window, stub);
|
||||
}, webAppStub());
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
|
||||
await page.locator('button.tab').nth(0).click(); // the New tab opens the create screen
|
||||
await page.getByRole('button', { name: 'Play with friends' }).click();
|
||||
|
||||
// The online/offline segment (Invite a friend / Pass and play) is absent — only the invite form shows.
|
||||
await expect(page.getByRole('button', { name: 'Pass and play' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Invite a friend' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
|
||||
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
|
||||
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
|
||||
|
||||
+61
-18
@@ -1,34 +1,77 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import { expect, test, type Page } from './fixtures';
|
||||
|
||||
// The "update required" overlay is raised in prod when the edge's client-version gate turns away a
|
||||
// too-old build (the update_required result_code on Execute, a Subscribe FailedPrecondition). The
|
||||
// mock transport never produces one, so — like the maintenance overlay — the e2e drives it through
|
||||
// the window.__update hook (gateway.ts, mock-only). It is app-global (mounted outside the route
|
||||
// blocks in App.svelte), so it shows without a session, and it is terminal (no self-clearing poll).
|
||||
test('update overlay covers the app when the client is too old', async ({ page }) => {
|
||||
// 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 overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
// One action button (EN "Update" / RU "Обновить" depending on locale).
|
||||
await expect(overlay.getByRole('button', { name: /Update|Обновить/i })).toBeVisible();
|
||||
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 }) => {
|
||||
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 overlay = page.getByRole('alertdialog');
|
||||
await expect(overlay).toBeVisible();
|
||||
const notice = page.getByRole('alertdialog');
|
||||
await expect(notice).toBeVisible();
|
||||
|
||||
// On the web the action reloads the page to fetch the current client (on a native build it opens
|
||||
// the store instead). The reload resets the terminal store, so the app re-bootstraps: the overlay
|
||||
// is gone and the login is back.
|
||||
// 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'),
|
||||
overlay.getByRole('button', { name: /Update|Обновить/i }).click(),
|
||||
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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user