Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion docs/PROJECT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
> to read **only this file** and continue immediately. Updated after every
> milestone and every significant architectural step.

_Last updated: 2026-09-30 — M15 Increment 76: RTL skip-link and active-game regression coverage._
_Last updated: 2026-09-30 — M15 Increment 77: Deterministic seek-acceptance and auth-geometry browser tests._

Prior: _Last updated: 2026-09-30 — M15 Increment 76: RTL skip-link and active-game regression coverage._

Prior: _Last updated: 2026-09-29 — M15 Increment 75: RTL layout reliability and chessboard orientation invariance._

Expand Down Expand Up @@ -4514,3 +4516,11 @@ Addresses four blocking review findings identified by ChatGPT independent review
- Falsification used a disposable copy of the strengthened spec outside the repository with focused zero-area clipping injected in memory. The RTL 320px case failed specifically because focused `clip` was `rect(0px, 0px, 0px, 0px)` instead of `auto`; removing the injection passed the same case. Production CSS was never mutated. The focused static RTL suite passed 36/36, and the responsive selection passed 83/83 (81 create-game cases plus both game-responsive tests), all without retries or skips.
- Local `npm run ci:local -- --quick` passed builds, lint, all 19 hermetic workspaces, load-harness contracts, repository guards, test topology and 308 script tests. Its PostgreSQL, Redis/gateway, Docker/Nginx and POSIX jobs were not run locally; this records core validation, not a claim that those service/platform jobs passed.
- The full backend-enabled Chromium Playwright suite passed 197/197 with four workers, zero retries and zero skips, including the strengthened RTL skip-link cases and both authoritative responsive roles.

## M15 Increment 77 — Deterministic seek-acceptance and auth-geometry browser tests (2026-09-30)
- Two backend Playwright failures reproduced on untouched `origin/main` at `edacc07f005902a3cb9b398b2c98e999cc747ae2` under four-worker load. Both were test defects, not product regressions. No production code changed.
- `seek-acceptance.spec.ts`: the creator learns its seek was accepted only on `LobbyController`'s 10-second `setInterval` refresh, while the acceptor is routed by the accept response. The old `page1.waitForURL(/\/game\/.+/, { timeout: 10_000 })` gave the creator one poll interval to poll and navigate, so it lost whenever the poll phase and page load together exceeded 10 s. The spec now installs Playwright's clock (1.61.1) before navigation. It fast-forwards exactly one refresh interval on the acceptor to surface the seek, and on the creator only after the acceptor's game URL commits, then requires the creator to reach that same URL. The real 10-second product interval is unchanged.
- Seek falsification: three disposable variants each first aligned the creator's poll to its worst phase (a tick had just answered "unmatched" before accept). The old 10 s contract failed 3/3 at the originally reported line. The new flow with the creator's `fastForward` removed failed 3/3 against a 5 s probe. The unchanged new flow passed 3/3 against the same 5 s probe, proving the clock, not the natural tick, routes the creator.
- `auth-responsive.spec.ts`: Chromium `layout-shift` entries showed that at 768px, `#lobby` sits above `#auth`. When `/v1/seeks` answers, the seek list's 164px empty state pushes the whole card down 163px, roughly 100 ms after `#auth` is visible (4 of 5 backend loads; none in static mode, where the request fails). Register and passkey share a row in every frame. The old spec's separate `boundingBox()` round trips straddled the shift and read them a row apart. All related boxes are now read in one `page.evaluate` from a single layout. The assertions are unchanged. The late-rendering empty state is a small real layout shift (CLS ≈ 0.05) and is left as a product follow-up.
- Auth falsification: a verbatim copy of the old spec failed 1/20 on a single backend worker at `sameRow(register, passkey)`, while the new spec passed 20/20 alongside it. A temporary `.auth-actions` mutation to `minmax(20rem, 1fr)` made the new spec fail `sameRow(register, passkey)` at 1440, 1024 and 768px. The stylesheet was restored from a hashed backup and rebuilt.
- Validation: build, lint, web unit tests 1200/1200, script tests 308/308, test-topology and CI-parity guards. Both specs typecheck under the web package's strict compiler settings. Focused repeats passed 100/100 on one worker and 100/100 on four. The full backend Chromium suite passed 197/197 twice with four workers, zero retries and zero skips (Avast shields off, toggled by the owner).
68 changes: 48 additions & 20 deletions packages/web/e2e/auth-responsive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,33 @@ const VIEWPORTS = [

const ACTIONS = ['#auth-submit', '#auth-register', '#auth-passkey'] as const;

/** The element's rendered bounds, failing loudly rather than returning null the caller would measure. */
async function boxOf(page: Page, selector: string): Promise<{ x: number; y: number; width: number; height: number }> {
const box = await page.locator(selector).boundingBox();
if (box === null) throw new Error(`${selector} has no rendered bounds`);
return box;
type Box = { x: number; y: number; width: number; height: number };

/**
* Every named element's rendered bounds, read in one script run so they all come from the same
* layout, failing loudly rather than returning a missing box the caller would measure.
*
* Separate `boundingBox()` calls are separate round trips, and the page does move between them: at
* tablet width the lobby sits above this card, and when `/v1/seeks` answers it renders the seek
* list's empty state and pushes the whole card 163px down. Register and the passkey button share a
* row in every frame, yet a call landing on each side of that shift read them as a row apart.
*/
async function boxesOf<K extends string>(page: Page, selectors: Record<K, string>): Promise<Record<K, Box>> {
const measured = await page.evaluate((entries) => entries.map(([name, selector]) => {
const el = document.querySelector(selector);
if (el === null || el.getClientRects().length === 0) return [name, null] as const;
const { x, y, width, height } = el.getBoundingClientRect();
return [name, { x, y, width, height }] as const;
}), Object.entries<string>(selectors));
for (const [name, box] of measured) {
if (box === null) throw new Error(`${selectors[name as K]} has no rendered bounds`);
}
return Object.fromEntries(measured) as Record<K, Box>;
}

/** One element's rendered bounds, for checks that do not compare it with another element. */
async function boxOf(page: Page, selector: string): Promise<Box> {
return (await boxesOf(page, { box: selector })).box;
}

/** True when two boxes sit on the same visual row, tolerating sub-pixel layout. */
Expand All @@ -52,20 +74,29 @@ for (const viewport of VIEWPORTS) {
viewportWidth: window.innerWidth,
}))).toEqual({ documentWidth: viewport.width, viewportWidth: viewport.width });

const { card, submit, register, passkey, actionRow, handle, password, email, form } = await boxesOf(page, {
card: '#auth',
submit: '#auth-submit',
register: '#auth-register',
passkey: '#auth-passkey',
actionRow: '#auth-form .auth-actions',
handle: '.auth-field:has(#auth-handle)',
password: '.auth-field:has(#auth-password)',
// The sign-in code row shares the full-row class but stays hidden until the server asks
// for a code, so it is not measured here.
email: '.auth-field-full:has(#auth-email)',
form: '#auth-form',
});

// The card itself stays inside the viewport, borders included.
const card = await boxOf(page, '#auth');
expect(card.x).toBeGreaterThanOrEqual(0);
expect(card.x + card.width).toBeLessThanOrEqual(viewport.width);

// The two secondary actions come out the same width — the property `flex-wrap: wrap` could
// not give them, because it sizes each button to its own label.
const submit = await boxOf(page, '#auth-submit');
const register = await boxOf(page, '#auth-register');
const passkey = await boxOf(page, '#auth-passkey');
expect(Math.abs(register.width - passkey.width)).toBeLessThan(1);

// The default action takes the whole row, alone, with the other two beneath it.
const actionRow = await boxOf(page, '#auth-form .auth-actions');
expect(Math.abs(submit.width - actionRow.width)).toBeLessThan(1);
expect(sameRow(submit, register)).toBe(false);
expect(sameRow(register, passkey)).toBe(viewport.pairsActions);
Expand All @@ -79,14 +110,9 @@ for (const viewport of VIEWPORTS) {
}

// Fields pair where two 12rem tracks fit and stack where they do not.
const handle = await boxOf(page, '.auth-field:has(#auth-handle)');
const password = await boxOf(page, '.auth-field:has(#auth-password)');
expect(sameRow(handle, password)).toBe(viewport.pairsFields);

// The email always takes the whole row, never half of one. (The sign-in code row shares the
// class but stays hidden until the server asks for a code, so it is not measured here.)
const email = await boxOf(page, '.auth-field-full:has(#auth-email)');
const form = await boxOf(page, '#auth-form');
// The email always takes the whole row, never half of one.
expect(sameRow(email, handle)).toBe(false);
expect(Math.abs(email.width - form.width)).toBeLessThan(1);
});
Expand All @@ -113,13 +139,15 @@ test('the sign-in form mirrors under dir="rtl" without overflowing', async ({ pa
}))).toEqual({ documentWidth: 1440, viewportWidth: 1440 });

// Mirrored, not merely unbroken: the first field in source order now starts on the right.
const handle = await boxOf(page, '.auth-field:has(#auth-handle)');
const password = await boxOf(page, '.auth-field:has(#auth-password)');
const { handle, password, register, passkey } = await boxesOf(page, {
handle: '.auth-field:has(#auth-handle)',
password: '.auth-field:has(#auth-password)',
register: '#auth-register',
passkey: '#auth-passkey',
});
expect(sameRow(handle, password)).toBe(true);
expect(handle.x).toBeGreaterThan(password.x);

const register = await boxOf(page, '#auth-register');
const passkey = await boxOf(page, '#auth-passkey');
expect(Math.abs(register.width - passkey.width)).toBeLessThan(1);
expect(register.x).toBeGreaterThan(passkey.x);
});
Expand Down
33 changes: 23 additions & 10 deletions packages/web/e2e/seek-acceptance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ import { randomUUID } from 'node:crypto';

test.skip(!process.env['GAMBIT_E2E_BACKEND'], 'requires running backend — M6 acceptance gate');

/**
* `LobbyController`'s refresh interval. A lobby learns about other players' seeks, and a creator
* learns that its seek was matched, only on this tick. Waiting in real time for it raced the
* test's own timeouts: the creator's next tick lands up to one interval after the accept, the
* same 10 s its `waitForURL` allowed, so a busy machine missed it. Each page's clock is advanced
* by exactly one interval instead, which fires the pending tick deterministically.
*/
const LOBBY_REFRESH_MS = 10_000;

test('atomic matching flow: Player A creates a seek, Player B accepts', async ({ browser }) => {
const ctx1 = await browser.newContext();
const ctx2 = await browser.newContext();
Expand Down Expand Up @@ -57,6 +66,10 @@ test('atomic matching flow: Player A creates a seek, Player B accepts', async ({
localStorage.setItem('gambit-session', JSON.stringify({ handle: h, userId: uid }));
}, { handle: handle2, uid: userId2 });

// Fake timers that still flow in real time; only `fastForward` moves them ahead.
await page1.clock.install();
await page2.clock.install();

// 2. Both players go to Lobby
await page1.goto('/');
await page2.goto('/');
Expand All @@ -78,10 +91,11 @@ test('atomic matching flow: Player A creates a seek, Player B accepts', async ({
expect(seekId).toBeTruthy();

// 4. Player 2 should see Player 1's seek and accept it
// Lobby polling runs every 10 seconds, so allow one complete refresh cycle
// and target this test's seek rather than any stale row from a retry.
// Player 2's lobby shows a new seek on its next refresh; run that refresh now, and target
// this test's seek rather than any stale row from a retry.
await page2.clock.fastForward(LOBBY_REFRESH_MS);
const opponentRow = page2.locator(`.seek-row[data-seek-id="${seekId}"]`);
await expect(opponentRow).toBeVisible({ timeout: 15_000 });
await expect(opponentRow).toBeVisible();
const opponentLink = opponentRow.locator('a.row-link');
await expect(opponentLink).toHaveText(handle1);
await expect(opponentLink).toHaveAttribute('href', `/profile/${handle1}`);
Expand All @@ -92,13 +106,12 @@ test('atomic matching flow: Player A creates a seek, Player B accepts', async ({
await expect(acceptBtn).toHaveAccessibleName(`Play — accept seek from ${handle1}`);
await acceptBtn.click();

// 5. Both should be automatically routed to the game page
await page1.waitForURL(/\/game\/.+/, { timeout: 10_000 });
await page2.waitForURL(/\/game\/.+/, { timeout: 10_000 });

const url1 = page1.url();
const url2 = page2.url();
expect(url1).toEqual(url2);
// 5. The acceptor is routed by the accept response itself. Only then does the creator's
// next lobby refresh see the match, and it must route the creator to the same game.
await page2.waitForURL(/\/game\/.+/, { waitUntil: 'commit' });
const gameUrl = page2.url();
await page1.clock.fastForward(LOBBY_REFRESH_MS);
await page1.waitForURL(gameUrl, { waitUntil: 'commit' });

// Wait for boards to render
await expect(page1.locator('#board')).toBeVisible({ timeout: 10_000 });
Expand Down
Loading