16cd3d0411
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m42s
navigate() wrote location.hash and left router.route to the asynchronous
hashchange event. bootstrap flips app.ready in the same tick right after
navigate('/login') on an unauthenticated cold start, so for one frame the app
rendered with app.ready=true and the stale route ('lobby', from the empty hash)
— briefly mounting the lobby shell (tab bar + a doomed games.list) under the new
login screen before hashchange settled it.
Update router.route synchronously inside navigate(); the later hashchange
re-parses to the same value. This removes the boot lobby-flash (and its spurious
games.list 401) and, as the root cause, the offline e2e flake: enterLobby could
latch that transient lobby tab-bar instead of clicking through login, then the
Settings-tab click at line 44 fought the tab detaching into the login slide
(~7% of runs, both engines).
Also:
- offline.spec enterLobby: wait for the guest button and click it, instead of a
point-in-time count() that a pre-login splash frame sampled as 0 (skipping the
click and hanging / latching the transient).
- New e2e router.spec pins the synchronous-route property (RED on the old code,
which returned the previous route; GREEN now) via a mock-only __router seam.
Verify: check 0 / unit 490 / build / bundle 114.3/115 / full e2e 200 / the
offline+router stress at 40x on both engines 160/160 (was 6/80 failing).
96 lines
5.4 KiB
TypeScript
96 lines
5.4 KiB
TypeScript
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.
|
|
async function forceStandalone(page: Page): Promise<void> {
|
|
await page.addInitScript(() => {
|
|
const orig = window.matchMedia.bind(window);
|
|
window.matchMedia = (q: string) =>
|
|
(q.includes('display-mode: standalone')
|
|
? { matches: true, media: q, onchange: null, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {}, dispatchEvent: () => false }
|
|
: orig(q)) as MediaQueryList;
|
|
});
|
|
}
|
|
|
|
// 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.
|
|
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).
|
|
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).
|
|
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 }) => {
|
|
await forceStandalone(page);
|
|
await page.goto('/');
|
|
await enterLobby(page);
|
|
|
|
// 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();
|
|
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);
|
|
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.
|
|
await page.locator('.variant', { hasText: 'Scrabble' }).click();
|
|
await page.locator('button.invite').click();
|
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
|
|
|
// The human's first move is not idle-gated: the hint is available at once (no 🔒 lock badge).
|
|
await expect(page.locator('.lock')).toHaveCount(0);
|
|
|
|
// The human plays WAY horizontally across the centre (7,5)-(7,7).
|
|
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();
|
|
|
|
// The play commits and the local robot replies with a real move, 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 });
|
|
const filled = await page.locator('[data-cell].filled').count();
|
|
|
|
// Now it is the human's turn again after the robot moved: the idle hint gate arms, so the hint
|
|
// 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.
|
|
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.
|
|
await expect(page.locator('.lock')).toBeVisible();
|
|
});
|
|
});
|