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.