houserules/packages/chess/e2e/solo-smoke.spec.ts
Joey Yakimowich-Payne 6e0479703d
fix(lobby): clear stale MP creds on Play Solo to avoid blank-screen trap
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.
2026-04-19 17:03:21 -06:00

278 lines
12 KiB
TypeScript
Raw Permalink 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 { 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('; '));
});
});