Most component test suites mock their engine. This one refuses: the tests you write in this lesson run the actual compiled .wasm inside a fast DOM, so a passing suite means the integration works, not that your mocks agree with themselves. The trick that makes it possible is four lines long. Checkpoint: lesson-11.

The initSync trick

Vitest runs in Node, and the glue’s init() locates the binary with new URL(..., import.meta.url) and fetch()es it — which doesn’t serve local files in Node. The escape hatch is the glue’s synchronous sibling, and it goes in a setup file:

import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { initSync } from 'chess-engine';

// Pre-initialize the wasm module from disk; the component's later
// `await init()` sees it is already initialized and becomes a no-op
// (the browser fetch() path does not work under Node).
initSync({
  module: readFileSync(resolve(process.cwd(), 'chess-engine/pkg/chess_engine_bg.wasm'))
});

This works because both init functions start with an “already initialized?” guard — the seam lesson 5 pointed at. The component’s own code runs unchanged in tests: its await init() simply finds the work already done. No injection point, no test-only branch, no mock.

Configuration lives in vite.config.ts itself — the file’s first import becomes vitest/config and a test block appears: happy-dom as the environment, the setup file, and v8 coverage with thresholds (75% on lines, statements, and functions) that fail the run when crossed. One config file serving dev, build, and test is why the alias from lesson 5 needs no repeating.

Helpers that read like chess

The suite’s vocabulary is four small functions, and they set the tone for every test after them:

async function createBoard(props: Partial<ChessBoard> = {}): Promise<ChessBoard> {
  const el = document.createElement('chess-board') as ChessBoard;
  Object.assign(el, props);
  document.body.appendChild(el);
  await vi.waitFor(() => {
    if ((el as any).isLoading) throw new Error('still loading');
  });
  await el.updateComplete;
  return el;
}

idx('e4') converts algebraic squares to board indices so assertions talk chess, not array math. click(el, 'e2') dispatches through the shadow root. And waitForCpuReply polls isHumanTurn with a real timeout — the 300 ms thinking delay actually elapses in these tests, nothing faked with timer mocks, which keeps the suite honest about the async path a player actually experiences.

What twenty-eight tests pin down

The suite walks the app top to bottom: 64 squares and 32 pieces at the start; piece colors derived from glyphs; tooltips labeling e1 “White King”; hover previews appearing, clearing, ignoring touch, and staying silent on enemy pieces; the full click-move-reply loop; illegal destinations deselecting; last-move marks. Then the lesson-9 behaviors, which are the suite’s best section — five persistence tests including the two that only an integration suite can write honestly: seed localStorage with a position where it’s the computer’s turn, create the board, and watch it move; then corrupt the save and watch it recover. Game endings, figurine pairing in the history panel, board coordinates, the checked-king highlight, and the help drawer’s non-modality close it out.

Run npm run test:coverage and read the uncovered lines — at this point they’re almost all the engine-failed-to-load error path, which is the right thing to be uncovered. The thresholds exist so a future refactor that quietly stops testing a subsystem fails loudly.

Build it

File Action What goes in it
test/setup.ts write The initSync pre-load above
test/chess-board.test.ts write The helpers, then the ten describe blocks — write the first of each pattern yourself; the repetition within blocks is fair to take from the answer key
vite.config.ts modify Import from vitest/config; the test block with happy-dom, setup file, and coverage thresholds
package.json modify test, test:watch, test:coverage, coverage:rust, test:all scripts; vitest, @vitest/coverage-v8, happy-dom devDependencies

Done when: npm test reports 28 passed, and npm run test:coverage clears every threshold.

Answer key: lesson-10...lesson-11.

Challenge: cover the crash

The uncovered lines include the catch in firstUpdated. Write a test that makes engine loading fail (stub the dynamic import, or break the module cache), and assert the component logs instead of throwing. Harder than it sounds — that’s why it’s the challenge.

Next lesson, the last rung of the ladder: the same app, a real Chrome, and seven flows that prove the whole thing in the environment users get.