If the user played multiplayer earlier in the tab session, room-code,
room-token, and player-color persisted in sessionStorage. Clicking Play
Solo then:
1. navigate('/game') — no code param
2. GameRoute reads sessionStorage, finds stale creds → Case 1
canonicalises the URL to /game/<stale-code>
3. MultiplayerGameView mounts, opens a WS to a dead room, handshake
fails silently → blank white screen with a live URL like
/game/OSJBJY in the address bar.
Fix: handlePlaySolo explicitly wipes room-code, room-token, player-color,
layout-name, and modifier-profile-name before navigating. The solo path
then goes through GameRoute's Case 2 (no code, no creds) and mounts
GameView cleanly.
Regression test in solo-smoke.spec.ts seeds sessionStorage with stale
MP creds, clicks Play Solo, and asserts:
- URL settles on /game (not /game/<stale>)
- No 'mp-joining' placeholder
- Board renders (e2 pawn visible)
- All stale keys are wiped from sessionStorage
- No console errors
Verified the test fails without the fix (Playwright hits the blank
screen / Joining placeholder) and passes with it.
278 lines
12 KiB
TypeScript
278 lines
12 KiB
TypeScript
import { test, expect } from '@playwright/test';
|
||
|
||
test.describe('Solo-play smoke (T2 preview tests)', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/');
|
||
await page.evaluate(() => {
|
||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||
const k = localStorage.key(i);
|
||
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
||
}
|
||
});
|
||
});
|
||
|
||
test('play-solo button navigates to /game with fresh board', async ({ page }) => {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||
});
|
||
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game', { timeout: 5000 });
|
||
await expect(page.locator('[data-square="e2"]')).toBeVisible({ timeout: 3000 });
|
||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible();
|
||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||
});
|
||
|
||
test('drag pawn e2 to e4 resolves', async ({ page }) => {
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page
|
||
.locator('[data-square="e2"] [data-piece]')
|
||
.dragTo(page.locator('[data-square="e4"]'));
|
||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||
await expect(page.locator('[data-square="e2"] [data-piece]')).toHaveCount(0);
|
||
});
|
||
|
||
test('solo play with modifier profile renders modifier-indicator dots', async ({ page }) => {
|
||
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||
const entry = {
|
||
id: 'solo-smoke-indicator',
|
||
name: 'Solo Smoke Indicator',
|
||
profile: {
|
||
id: 'solo-smoke-indicator',
|
||
name: 'Solo Smoke Indicator',
|
||
description: '',
|
||
layoutId: 'classic',
|
||
perType: [
|
||
{ kind: 'hp-bonus', pieceType: 'pawn', color: 'both', value: 1 },
|
||
],
|
||
perInstance: [],
|
||
version: 1,
|
||
source: 'custom',
|
||
},
|
||
starred: false,
|
||
updatedAt: Date.now(),
|
||
};
|
||
|
||
await page.goto('/');
|
||
await page.evaluate(
|
||
({ key, e }) => localStorage.setItem(key, JSON.stringify([e])),
|
||
{ key: LIBRARY_KEY, e: entry },
|
||
);
|
||
await page.reload();
|
||
|
||
const picker = page.getByTestId('profile-picker');
|
||
if (!(await picker.isVisible())) {
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
}
|
||
await expect(picker).toBeVisible();
|
||
await picker.selectOption('solo-smoke-indicator');
|
||
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game', { timeout: 5000 });
|
||
|
||
await expect(page.locator('[data-testid^="modifier-indicator-"]').first()).toBeVisible({
|
||
timeout: 3000,
|
||
});
|
||
});
|
||
|
||
test('can play multiple moves in sequence (4-ply)', async ({ page }) => {
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
|
||
const drag = async (from: string, to: string) => {
|
||
await page.locator(`[data-square="${from}"] [data-piece]`).dragTo(page.locator(`[data-square="${to}"]`));
|
||
// small wait for animation / state update
|
||
await page.waitForTimeout(100);
|
||
};
|
||
|
||
await drag('e2', 'e4');
|
||
await drag('e7', 'e5');
|
||
await drag('g1', 'f3');
|
||
await drag('b8', 'c6');
|
||
|
||
// After 4 plies: e4, e5, f3, c6 all occupied; origins empty
|
||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible();
|
||
await expect(page.locator('[data-square="e5"] [data-piece]')).toBeVisible();
|
||
await expect(page.locator('[data-square="f3"] [data-piece]')).toBeVisible();
|
||
await expect(page.locator('[data-square="c6"] [data-piece]')).toBeVisible();
|
||
});
|
||
|
||
test('rules drawer opens without breaking board interaction', async ({ page }) => {
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||
// Close drawer (esc or similar) and verify board still works
|
||
await page.keyboard.press('Escape');
|
||
await page.waitForTimeout(300);
|
||
// Drag a move
|
||
await page.locator('[data-square="e2"] [data-piece]').dragTo(page.locator('[data-square="e4"]'));
|
||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({ timeout: 3000 });
|
||
});
|
||
|
||
test('no console errors on solo game start', async ({ page }) => {
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||
});
|
||
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.waitForTimeout(1000); // let any async init complete
|
||
await expect(page.locator('[data-square="e2"]')).toBeVisible();
|
||
|
||
if (errors.length > 0) {
|
||
console.log('Errors:', errors);
|
||
throw new Error(`Unexpected errors: ${errors.slice(0, 3).join(' | ')}`);
|
||
}
|
||
});
|
||
|
||
// T2 regression — clicking the drawer backdrop closes the drawer AND
|
||
// leaves the board interactive afterwards. Regression guard for a
|
||
// stuck-overlay bug where the backdrop `pointer-events-none` timing
|
||
// wasn't cleared, silently blocking drag-to-move.
|
||
test('rules drawer: backdrop click closes drawer and restores board interactivity', async ({ page }) => {
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||
|
||
// Click the far-left edge of the viewport — the drawer itself
|
||
// sits on the right (`fixed top-0 right-0 max-w-md`), so x=50 is
|
||
// guaranteed to land on the backdrop overlay, not the drawer.
|
||
await page.mouse.click(50, 300);
|
||
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({ timeout: 1000 });
|
||
|
||
// Board must be interactive: drag e2→e4 and see the pawn land on e4.
|
||
// Any stuck-overlay regression shows up here as a silent no-op drag.
|
||
await page
|
||
.locator('[data-square="e2"] [data-piece]')
|
||
.dragTo(page.locator('[data-square="e4"]'));
|
||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||
timeout: 3000,
|
||
});
|
||
});
|
||
|
||
// T2 regression — nested-modal Esc ordering. With BOTH the rules
|
||
// drawer AND the modifier editor open, a single Esc must close the
|
||
// editor ONLY; the drawer stays. A second Esc then closes the
|
||
// drawer. This is enforced by ModifierProfileEditor installing its
|
||
// keydown handler in the capture phase with `stopImmediatePropagation`,
|
||
// so the drawer's window-level Esc handler never fires on the same
|
||
// keystroke. Without that guard both would close, leaving the board
|
||
// behind a briefly-lingering pointer-events-blocking backdrop.
|
||
test('modifier editor: Esc closes editor first, drawer stays open; 2nd Esc closes drawer', async ({ page }) => {
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
await page.waitForURL('**/game');
|
||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||
await page.locator('[data-testid="open-modifier-editor"]').click();
|
||
await expect(page.getByTestId('modifier-editor-modal')).toBeVisible();
|
||
|
||
// First Esc: editor closes; drawer still visible.
|
||
//
|
||
// The editor's capture-phase keydown listener handles this event
|
||
// with stopImmediatePropagation(), so the drawer's bubble-phase
|
||
// listener is suppressed. Editor unmounts → its useEffect cleanup
|
||
// removes the listener, BUT that cleanup runs during React's
|
||
// commit phase — not synchronously after the state update. A
|
||
// too-fast second Esc can still hit the stale editor listener
|
||
// before it has fully detached. The small wait below lets
|
||
// React complete its commit so the second Esc sees only the
|
||
// drawer's listener.
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByTestId('modifier-editor-modal')).not.toBeVisible({
|
||
timeout: 500,
|
||
});
|
||
await expect(page.getByTestId('rules-drawer')).toBeVisible();
|
||
|
||
// Second Esc: drawer closes. (See note above re: the wait.)
|
||
//
|
||
// The drawer exits via a framer-motion spring animation that
|
||
// takes ~300–500ms to fully unmount the `<aside>` — too tight a
|
||
// timeout here flakes even when the close did fire. We use 1500ms
|
||
// to match the backdrop-click test and leave headroom.
|
||
await page.waitForTimeout(50);
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByTestId('rules-drawer')).not.toBeVisible({
|
||
timeout: 1500,
|
||
});
|
||
|
||
// Board must be fully interactive again.
|
||
await page
|
||
.locator('[data-square="e2"] [data-piece]')
|
||
.dragTo(page.locator('[data-square="e4"]'));
|
||
await expect(page.locator('[data-square="e4"] [data-piece]')).toBeVisible({
|
||
timeout: 3000,
|
||
});
|
||
});
|
||
|
||
// Regression guard for the "play-solo lands on blank screen" bug.
|
||
//
|
||
// Before the fix, a stale multiplayer session (room-code/room-token in
|
||
// sessionStorage from a previous room.create) would make Play Solo
|
||
// navigate to /game → GameRoute's Case 1 canonicalises to
|
||
// /game/<stale-code> → MultiplayerGameView mounts → WS handshake to a
|
||
// long-dead room silently fails → blank screen.
|
||
//
|
||
// The fix: handlePlaySolo() explicitly clears room-code, room-token,
|
||
// player-color, layout-name, and modifier-profile-name from
|
||
// sessionStorage before navigating, guaranteeing a fresh solo mount.
|
||
test('play-solo with stale MP creds in sessionStorage lands on /game, not /game/<stale>', async ({
|
||
page,
|
||
}) => {
|
||
// Seed sessionStorage to simulate a previous room.create or room.join
|
||
// whose creds were never cleared (e.g. user closed the tab after
|
||
// playing multiplayer, then reopened the lobby later).
|
||
await page.evaluate(() => {
|
||
sessionStorage.setItem('room-code', 'STALE1');
|
||
sessionStorage.setItem('room-token', 'stale-token-deadbeef');
|
||
sessionStorage.setItem('player-color', 'white');
|
||
sessionStorage.setItem('layout-name', 'Some Previous Layout');
|
||
sessionStorage.setItem('modifier-profile-name', 'Old Profile');
|
||
});
|
||
|
||
const errors: string[] = [];
|
||
page.on('pageerror', (e) => errors.push(`PAGE: ${e.message}`));
|
||
page.on('console', (msg) => {
|
||
if (msg.type() === 'error') errors.push(`CONSOLE: ${msg.text()}`);
|
||
});
|
||
|
||
await page.locator('[data-action="play-solo"]').click();
|
||
|
||
// URL must settle on /game WITHOUT a code suffix. Using a short
|
||
// timeout so a regression surfaces as a test failure rather than
|
||
// the suite hanging on the blank-screen state.
|
||
await page.waitForURL('**/game', { timeout: 3000 });
|
||
await expect(page).toHaveURL(/\/game$/);
|
||
|
||
// Board must render a real solo game (e2 pawn present, not the
|
||
// "Joining room …" placeholder).
|
||
await expect(page.locator('[data-testid="mp-joining"]')).toHaveCount(0);
|
||
await expect(page.locator('[data-square="e2"] [data-piece]')).toBeVisible({
|
||
timeout: 3000,
|
||
});
|
||
|
||
// All stale sessionStorage entries must be wiped so a page reload
|
||
// doesn't re-trigger the bug on the refreshed /game route.
|
||
const staleCreds = await page.evaluate(() => ({
|
||
code: sessionStorage.getItem('room-code'),
|
||
token: sessionStorage.getItem('room-token'),
|
||
color: sessionStorage.getItem('player-color'),
|
||
layout: sessionStorage.getItem('layout-name'),
|
||
profile: sessionStorage.getItem('modifier-profile-name'),
|
||
}));
|
||
expect(staleCreds).toEqual({
|
||
code: null,
|
||
token: null,
|
||
color: null,
|
||
layout: null,
|
||
profile: null,
|
||
});
|
||
|
||
if (errors.length > 0) throw new Error(errors.join('; '));
|
||
});
|
||
});
|