test(e2e): T2 polish vertical slice + solo regression guards

Adds 8 Playwright scenarios to modifier-profiles.spec.ts under a new
'T2 polish' describe block:

  P1  editor undo/redo across 3 distinct type-modifier adds
  P2  copy / paste wire: Copy lights the Paste button with a count
  P3  paste-type-modifier disabled when clipboard empty (baseline)
  P4  conflict panel: seed an invuln-king profile via localStorage,
      bind layout=classic, Load, observe error + Fix clears it
  P5  modifier-indicator rendered without hover (create-room path,
      with the same no-WS-server test.skip fallback T26 uses)
  P6  source-chain in pinned panel — test.fixme; ModifierPinnedPanel
      computes row.source but does not render it yet
  P7  multiplayer propose->approve e2e — test.fixme; needs a
      two-context harness this spec doesn't have today. Protocol
      coverage lives at packages/server/src/ws.modifier-profile-
      consent.test.ts.
  P8  multiplayer propose->reject e2e — same harness gap as P7.

Adds 2 regression tests to solo-smoke.spec.ts:

  - Rules drawer: clicking the backdrop (far-left of viewport)
    closes the drawer and leaves the board interactive. Regression
    guard for the stuck-overlay pointer-events bug.
  - Modifier editor: Esc closes the editor but leaves the drawer
    open (capture-phase stopImmediatePropagation); a second Esc
    then closes the drawer. Documents the nested-Esc ordering
    contract and guards against a future change that would cascade
    both closes on one keystroke.

Result: 55 Playwright passing, 3 skipped (all documented fixme).
bun run check green.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 10:13:57 -06:00
commit 92dae32f31
No known key found for this signature in database
2 changed files with 403 additions and 0 deletions

View file

@ -87,4 +87,84 @@ test.describe('Solo-play smoke (T2 preview tests)', () => {
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,
});
});
});