test(offline): e2e hotseat flow + fix $state-proxy persist
Add the Playwright mock e2e for offline pass-and-play (Chromium+WebKit): create a 2-seat hotseat with a PIN-locked seat, verify the rack is withheld, unlock (wrong then right PIN), host-skip the current turn, and terminate from the lobby behind the master PIN. The e2e surfaced a persistence bug: the roster PINs are $state proxies, which fail structured-clone on the IndexedDB put (silently — best-effort store), so a created hotseat game vanished on reload. Snapshot the seats + host PIN to plain objects at the create() call site.
This commit is contained in:
@@ -0,0 +1,102 @@
|
|||||||
|
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.
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goOffline(page: Page): Promise<void> {
|
||||||
|
await page.locator('button.tab').nth(2).click(); // Settings
|
||||||
|
await page.getByRole('button', { name: /^(Offline|Оффлайн)$/ }).click();
|
||||||
|
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).
|
||||||
|
async function typePin(page: Page, digits: string): Promise<void> {
|
||||||
|
for (const d of digits) await page.locator('.pad .key', { hasText: d }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('offline hotseat (pass-and-play)', () => {
|
||||||
|
test('create with a locked seat, unlock, host-skip, terminate from the lobby', async ({ page }) => {
|
||||||
|
await forceStandalone(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await enterLobby(page);
|
||||||
|
await goOffline(page);
|
||||||
|
|
||||||
|
// A known bag seed so the locked seat deals a full rack deterministically.
|
||||||
|
await page.evaluate(() => (window as unknown as { __mock: { setLocalSeed(s: string): void } }).__mock.setLocalSeed('1'));
|
||||||
|
|
||||||
|
// New game -> the offline mode selector now offers "with friends" (hotseat).
|
||||||
|
await page.locator('button.tab').nth(0).click();
|
||||||
|
await page.getByRole('button', { name: /With friends|друзьями/i }).click();
|
||||||
|
|
||||||
|
// The player rows are disabled until the mandatory host (master) PIN is set.
|
||||||
|
await expect(page.locator('.pname').first()).toBeDisabled();
|
||||||
|
|
||||||
|
// Set the host PIN (enter + confirm), then decline taking a seat.
|
||||||
|
await page.locator('.hostpin .plink').click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /^(No|Нет)$/ }).click();
|
||||||
|
|
||||||
|
// Pick the English variant if the picker offers a choice.
|
||||||
|
const variant = page.locator('.fg select').first();
|
||||||
|
if (await variant.isEnabled()) await variant.selectOption('scrabble_en');
|
||||||
|
|
||||||
|
// Two players: Ann (PIN-locked) and Bob (open).
|
||||||
|
await page.locator('.pname').nth(0).fill('Ann');
|
||||||
|
await page.locator('.pname').nth(1).fill('Bob');
|
||||||
|
await page.locator('.prow').nth(0).locator('.plink').click(); // Ann's seat PIN
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await typePin(page, '1234');
|
||||||
|
|
||||||
|
await page.locator('button.invite').click();
|
||||||
|
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||||
|
|
||||||
|
// Ann is to move and PIN-locked: the board is visible but her rack is withheld behind Unlock.
|
||||||
|
await expect(page.locator('.unlock')).toBeVisible();
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(0);
|
||||||
|
|
||||||
|
// A wrong PIN keeps it locked; the right PIN reveals the full rack.
|
||||||
|
await page.locator('.unlock').click();
|
||||||
|
await typePin(page, '0000');
|
||||||
|
await expect(page.locator('.pad')).toBeVisible(); // still open after a wrong PIN
|
||||||
|
await typePin(page, '1234');
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// Host override: 🔐 -> master PIN -> skip the current turn -> confirm. The turn passes to Bob,
|
||||||
|
// whose seat is open, so his rack shows without a lock.
|
||||||
|
await page.locator('button.tab', { hasText: /Host|Ведущий/ }).click();
|
||||||
|
await typePin(page, '9999');
|
||||||
|
await page.getByRole('button', { name: /Skip|Пропустить/ }).click();
|
||||||
|
await page.getByRole('button', { name: /^(OK|ОК)$/ }).click();
|
||||||
|
await expect(page.locator('.unlock')).toHaveCount(0);
|
||||||
|
await expect(page.locator('.rack .tile')).toHaveCount(7);
|
||||||
|
|
||||||
|
// 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 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -230,15 +230,18 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = newLocalGameId();
|
const id = newLocalGameId();
|
||||||
|
// Snapshot the roster + PINs to plain objects: the rows/pins are $state proxies, and a proxy
|
||||||
|
// fails structured-clone when the record is persisted to IndexedDB (silently, since the store
|
||||||
|
// is best-effort) — so the game would vanish on reload. See lib/localgame/store.
|
||||||
await localSource.create({
|
await localSource.create({
|
||||||
id,
|
id,
|
||||||
variant: inviteVariant,
|
variant: inviteVariant,
|
||||||
dictVersion: version,
|
dictVersion: version,
|
||||||
seed: randomSeed(),
|
seed: randomSeed(),
|
||||||
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
multipleWords: multipleWordsForRequest(inviteVariant, multipleWords),
|
||||||
seats: buildSeats(rows),
|
seats: $state.snapshot(buildSeats(rows)),
|
||||||
hotseat: true,
|
hotseat: true,
|
||||||
hostPin,
|
hostPin: hostPin ? $state.snapshot(hostPin) : undefined,
|
||||||
});
|
});
|
||||||
navigate(`/game/${id}`);
|
navigate(`/game/${id}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
Reference in New Issue
Block a user