happy-dom is fast, but it isn’t Chrome. This lesson adds the last testing
layer: seven Playwright flows against the real rendering engine, the real
fetch() of the real .wasm, and the real localStorage. The suite runs
with one command and no setup, and the config is half the teaching.
Checkpoint:
lesson-12.
A config that removes excuses
export default defineConfig({
testDir: 'e2e',
reporter: 'list',
use: {
baseURL: 'http://localhost:3199',
// System Chrome; no downloaded browsers needed.
channel: 'chrome',
},
webServer: {
command: 'npx vite --port 3199 --strictPort',
url: 'http://localhost:3199',
reuseExistingServer: true,
},
});
Two decisions carry it. channel: 'chrome' uses the Chrome already on
your machine, skipping the several-hundred-megabyte browser download that
makes teams postpone e2e forever. And the webServer block makes
Playwright boot its own Vite on a dedicated port (--strictPort so a
squatter fails loudly instead of testing the wrong app) — so
npm run test:e2e is the entire ceremony, no “start the dev server first”
footnote.
Locators that ignore the shadow
The suite reuses lesson 11’s square-index idea, but notice what it doesn’t have to do:
const square = (page: Page, sq: string) => page.locator('.square').nth(idx(sq));
.square lives inside the component’s shadow root, and Playwright’s
locators pierce shadow DOM by default — no shadowRoot! chains like the
vitest helpers needed. When a test does want component internals, it
reaches in explicitly:
const history = (page: Page) =>
page.locator('chess-board').evaluate((el) => (el as unknown as { history: string }).history);
The third tool is timing. The computer’s reply takes a real 300 ms in a real event loop, and the suite waits by polling meaning, not by sleeping:
await expect.poll(() => moveCount(page), { timeout: 5000 }).toBe(n);
Sleeps encode a guess about speed; expect.poll encodes the condition you
actually care about. It’s the single most transferable line in the file.
Seven flows, one dragnet
The scenarios: a fresh game renders the full start position; a move highlights, executes, and draws the computer’s reply plus last-move marks and a history row; New Game resets board and save; a reload preserves position, turn, and history and the game stays playable; a save on the computer’s turn resolves itself after reload (seeded by writing localStorage before the page loads — the same trick as lesson 11, now against real storage); the help drawer stays non-modal through a full exchange and closes on Escape.
The seventh is different in kind — a console dragnet:
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(e.message));
// ...play through a full flow...
expect(errors).toEqual([]);
It asserts nothing about chess. It plays a game, reloads, opens the drawer, and demands the console stayed silent — which catches the entire class of “it works, but something logs a TypeError on every move” defects that behavioral assertions walk straight past. Cheap to write, and it pays rent forever.
Build it
| File | Action | What goes in it |
|---|---|---|
playwright.config.ts |
write | The config above |
e2e/chess.spec.ts |
write | The helpers and the seven flows |
package.json |
modify | test:e2e script; test:all grows the third stage; @playwright/test devDependency |
Done when: npm run test:e2e reports 7 passed, and npm run test:all runs 19 + 28 + 7 green in one command.
Answer key: lesson-11...lesson-12.
Challenge: mate in one
Seed localStorage with a position one move from checkmate, deliver the mate by clicking, and assert the game-over banner shows and the board refuses further clicks. Every technique is already in the file — seeding, polling, square locators — so this is the challenge that proves you can compose them without a walkthrough. (Lesson 10’s Fool’s mate position, one move earlier, makes a fine seed.)
Next lesson is the reward for all this coverage: the wow layer, landed as one diff, with three green suites standing guard while everything gets prettier.