Skip to content

Latest commit

 

History

History
179 lines (107 loc) · 51.9 KB

File metadata and controls

179 lines (107 loc) · 51.9 KB

Testing

The unit-test suite (Vitest, src/**/*.test.ts, co-located next to what they test) covers every file under src/ except src/engine/defaultHighscore.ts (a generated data file — a single gzip+base64-encoded string constant, no logic to test), src/parser/types.ts (fully type-only — interfaces/type aliases, zero runtime exports), src/empty-node-shim.ts (an empty stub module, see Architecture), and src/vite-env.d.ts (a pure .d.ts type file, zero runtime code). npm run coverage gates on 99.9%/99.9%/99.5%/99.5% lines/statements/functions/branches (see "Setup" below for why it's not a flat 100%) and runs as a blocking CI job (.github/workflows/verify.yml) alongside the existing verify/verify-browser jobs. This doc covers the setup, the shared fixture catalog, the mocking philosophy, and the handful of techniques worth reusing rather than reinventing. It doesn't try to teach Vitest itself — see vitest.dev for that.

For the separate Playwright-driven scripts (scripts/verify-*.mjs) that exercise the real built app end-to-end, see Architecture.

For the automated balancing-bot harness (scripts/run-balancing-telemetry.mjs, npm run balancing:scan/balancing:watch/balancing:telemetry) — a separate, not-CI-wired dev tool — see Balancing Telemetry Bot. Worth knowing even if you never touch it directly: it demonstrated a real, generalizable gotcha about headed vs. headless browser automation timing — a headless Playwright session driven by a virtual clock can achieve arbitrarily fine-grained timing control (hold a key for exactly N ms, advance the engine by exactly N ms), but a headed (real, visible) session is bound by real per-frame rendering — a wait shorter than roughly one real display frame doesn't reliably produce a proportionally small effect. A convergence check tight enough to work perfectly headless can be structurally unreachable headed, with the failure invisible to any headless-only verification. See Headed vs. headless for the concrete bug this caused and the repro recipe for this class of issue.

Running the verify scripts locally

Every Playwright-driven script here (scripts/verify-*.mjs, the two determinism scripts, and the balancing-bot harness) is a consumer of infrastructure it mostly does not create. Nothing below is enforced by npm install, and every one of these failures presents as a product bug rather than a setup mistake — which is exactly what makes them worth writing down.

Node 22.22.2+ / 24.15+ / 26+, not whatever node happens to resolve to. package.json's engines is "^22.22.2 || ^24.15.0 || >=26.0.0" and README's Requirements says the same — the floor is forced by jsdom 30, not Vite (see the "Setup" section below for the derivation; Vite 8 alone would still accept 20.19+/22.12+). Read that range carefully before assuming a version is fine: it has holes, so Node 23 and 25 fail it despite being numerically above the floor. npm's engines field is advisory, not enforcing, so an older node on PATH installs fine and looks fine, and then npx vitest dies at startup with SyntaxError: The requested module 'node:util' does not provide an export named 'styleText' from inside node_modules/rolldown/ (confirmed directly on Node 18.19.1, running a single test file). Nothing in that message mentions Node versions, so it reads like a broken dependency in this repo; it isn't. styleText landed in Node 20.12/21.7, so any Node 18 fails this way regardless of what the rest of the toolchain would have tolerated. Check node --version against the floor first whenever a script dies before doing any work — and if the system Node is older, get a current one onto PATH for the shell you run these from (nvm, or your distro's equivalent) rather than trying to make the repo tolerate it.

Playwright's browser binaries are a separate install from npm install. playwright is a devDependency; the browser builds it drives are downloaded on demand and are not part of the dependency tree. CI installs them explicitly in every browser job (npx playwright install <browser> + npx playwright install-deps <browser>, .github/workflows/verify.yml) — locally that's on you:

  • npx playwright install chromium is enough for everything that defaults to Chromium: the multiplayer verifiers, verify-campaign-playthrough.mjs, verify-wad-textures.mjs, and the whole balancing-bot harness.
  • npx playwright install chromium firefox webkit is required for the two determinism scripts (verify-multiplayer-determinism.mjs, poc-cross-browser-determinism.mjs) — they import { chromium, firefox, webkit } from "playwright" and launch all three unconditionally in one run, so a missing binary fails the script outright rather than skipping a leg. Also needed to exercise CODEENSTEIN_VERIFY_BROWSER=firefox/webkit against the two cross-browser-safe scripts below.
  • npx playwright install-deps <browser> (needs sudo) covers missing OS packages, separately from the browser binary itself — WebKit in particular won't even launch without libwoff1, see the cross-browser section below.

No verify script starts a dev server. They attach to one that must already be running, and they each have their own idea of where it is:

script(s) dev server how
run-balancing-telemetry.mjs (and everything importing its DEV_SERVER_URL: watch-bot-sessions.mjs, generate-default-highscore.mjs, diagnose-level-wedge.mjs), verify-wad-textures.mjs, and all six multiplayer verifiers (connect/netcode/reconciliation/disconnect/transition/multiguest) http://localhost:5173 CODEENSTEIN_DEV_URL
verify-campaign-playthrough.mjs, verify-replay.mjs http://localhost:5183 CODEENSTEIN_DEV_URL — same variable, different default, not a hardcoded port
run-balancing-telemetry-multiplayer.mjs, run-balancing-campaign-multiplayer.mjs, verify-multiplayer-campaign.mjs starts its own, 5174 + signaling 8788 scripts/lib/multiplayerTestServers.mjs — deliberately never 5173/8787, see its own doc comment
run-perf-benchmark.mjs starts its own vite on 5199 CODEENSTEIN_PERF_PORT
verify-wad-parser.mjs, report-wad-styleset-coverage.mjs, verify-zip-reader.mjs, verify-demo-campaign.mjs, render-level-maps.mjs, verify-multiplayer-server.mjs, both determinism scripts none needed pure Node, or a browser that never navigates to the app

verify:campaign:playthrough's 5183 default exists so it doesn't collide with a manual dev session; CI overrides it (CODEENSTEIN_DEV_URL: "http://localhost:5173") precisely to reuse the one server it already started rather than booting a second.

verify:replay needs a dev server for a reason that isn't incidental. ?testHooks=1 is gated on import.meta.env.DEV, which Vite substitutes at build time, so the observation hooks it reads simply do not exist in a built bundle — this check cannot be run against vite preview or dist/. It is also Chromium-only on purpose: verify-multiplayer-determinism.mjs exists precisely because transcendental math is not bit-identical across engines, so a Firefox leg would be measuring float behaviour rather than replay fidelity and a failure there would be unattributable. Default scope is one board entry, full 17-level run (~7 min); CODEENSTEIN_REPLAY_ENTRIES=all covers all three, and CODEENSTEIN_REPLAY_LEVEL_LIMIT=2 is a ~30s smoke check that still asserts a real score-ladder step. See the script's own doc comment for why the recorded payload is its own expected-value table.

5173 is also the port a developer's own npm run dev sits on all day, and that is the dangerous part. A verify or balancing script pointed at the default finds a server, gets a 200, and runs to completion — against whatever branch that server happens to be serving, which is very often not the branch under test. There is no version handshake and no warning; the run simply measures the wrong code and reports clean. Treat the default as a convenience for the case where you started the server yourself, and for anything you intend to draw a conclusion from, start a dedicated server on its own port and point at it explicitly:

npm run dev -- --port 5174 --strictPort &          # dedicated, in the worktree under test
CODEENSTEIN_DEV_URL=http://localhost:5174 npm run verify:multiplayer-netcode

--strictPort matters: without it Vite silently picks the next free port when 5174 is taken, and you are back to not knowing what you tested.

The multiplayer verifiers need a signaling server too, and the ordering is load-bearing. scripts/multiplayer-server.mjs (default port 8787, CODEENSTEIN_MULTIPLAYER_PORT) must be running before the dev server starts, because Vite inlines VITE_* env vars at server-start time, not per request — src/multiplayer/signalingClient.ts reads import.meta.env.VITE_MULTIPLAYER_SERVER_URL, and src/main.ts's MULTIPLAYER_SERVER_CONFIGURED is evaluated once at module load. Exporting the variable into the shell you run the verify script from does nothing at all; it has to be in the environment of the vite process. Its default ALLOWED_ORIGIN is the production origin, so a local run also needs it told about localhost. The working sequence, mirroring CI's own two steps:

CODEENSTEIN_MULTIPLAYER_ALLOWED_ORIGIN=http://localhost:5174 node scripts/multiplayer-server.mjs &
VITE_MULTIPLAYER_SERVER_URL=http://127.0.0.1:8787 npm run dev -- --port 5174 --strictPort &
CODEENSTEIN_DEV_URL=http://localhost:5174 npm run verify:multiplayer-connect

Get the env var wrong and the failure is maximally unhelpful: updateMultiplayerTabEnabled() (main.ts) returns early when MULTIPLAYER_SERVER_CONFIGURED is false, leaving #tab-multiplayer at the disabled it starts with in index.html, so makeEligible() (scripts/lib/multiplayerSessionBootstrap.mjs, and the identical per-script copies) sits in its waitForFunction(() => !tab.disabled) for the full 20s and throws a bare waitForFunction: Timeout 20000ms exceeded. That reads exactly like "the Multiplayer tab never enables" — a product bug — and there is nothing in the error pointing at a build-time env var. If a multiplayer verifier times out inside makeEligible, check the dev server's environment before reading a single line of app code.

Cross-browser verification

Every Playwright-driven script in this repo used to hardcode chromium.launch() — Firefox/WebKit compatibility was entirely unverified, and a regression there would have been invisible to every check described in this doc. scripts/lib/browserEngine.mjs's resolveBrowserEngine() reads CODEENSTEIN_VERIFY_BROWSER (chromium/firefox/webkit, default chromium — zero behavior change for any caller that doesn't set it) instead.

Only scripts that never touch the real, genuinely Chromium-only window.showDirectoryPicker native dialog are safe to run against another engine — isFileSystemAccessSupported() (src/fs/workspace.ts) already feature-detects it and disables the "Select Workspace"/"Continue Run" UI with a clear message when it's missing, so that specific gap is expected and out of scope, not something to work around. Two scripts qualify and are wired up:

  • scripts/verify-campaign-playthrough.mjs stubs window.showDirectoryPicker to resolve an OPFS (navigator.storage.getDirectory()) handle instead of the real picker — OPFS returns real FileSystemDirectoryHandle/FileSystemFileHandle objects (the same interface readDirectoryTree/readFileText already use) and has much broader cross-browser support than the interactive picker dialog itself.
  • scripts/verify-wad-textures.mjs never touches the File System Access API at all — the bundled demo campaign loads via fetch, and a WAD file loads via a plain <input type="file">.

CI runs both against a browser: [chromium, firefox, webkit] matrix in the verify-browser job (.github/workflows/verify.yml) — blocking, not just informational. WebKit needed a missing OS dependency (libwoff1, WOFF font rendering, sudo-only) to even launch; npx playwright install-deps webkit in CI handles this automatically on a fresh runner.

WebKit-specific limitation, confirmed by direct check, not assumed: Playwright's own WebKit build has navigator.storage === undefined entirely (checked directly: typeof navigator.storage is "undefined" on a real page load, real Safari has supported this since 15.2) — a gap in Playwright's Linux WebKit build, not a real Safari limitation, and not something this app can work around. verify-campaign-playthrough.mjs's whole cross-browser strategy depends on OPFS to stand in for the picker, so it can't run against webkit for that reason; CI's verify:campaign:playthrough step is if: matrix.browser != 'webkit'. verify-wad-textures.mjs never touches the File System Access API at all, so it runs against all three engines including webkit with no caveat.

WebKit is worth testing despite most historically-WebKit browsers (old Opera, old Chrome) having forked into Blink over a decade ago — desktop Safari is still WebKit and is exactly the platform this matters for, given this project's explicit desktop-only scope (see notes' "support other browsers" entry).

A real Firefox-only quirk, confirmed not a bug: page.reload() fires addInitScript twice on Firefox — once for a transient intermediate document (location.href === "", where navigator.storage is genuinely undefined) that gets discarded before either verify script ever interacts with it, then once for the real final document, where everything works normally. Confirmed directly by instrumenting the init script to log location.href/typeof navigator.storage on every invocation — not something a real player's browser would ever hit, since it's specific to how Playwright's addInitScript interacts with Firefox's reload navigation lifecycle. Logged, not asserted against, in verify-campaign-playthrough.mjs's pageerror handler.

Firefox-specific WebRTC limitation in CI, confirmed real and Mozilla-acknowledged, not worked around: scripts/verify-multiplayer-connect.mjs (a third Playwright script, unrelated to the File System Access/OPFS scripts above — it proves two browser contexts can open a real RTCDataChannel via multiplayer-server.mjs's signaling flow) runs correctly against Firefox locally but is skipped for the firefox leg of CI's verify-browser matrix (if: matrix.browser != 'firefox'). Root cause, confirmed via that script's own opt-in installIceDiagnostics() (candidate/gathering-state logging, CODEENSTEIN_MULTIPLAYER_DEBUG_ICE=1): Firefox discovers its own "default route" network interface by opening a UDP socket and connect()-ing it toward a public IP purely to ask the OS which local interface would be used — no packet is ever actually sent. In GitHub Actions' sandboxed runner network, that lookup finds no internet-routable default route at all, so Firefox gathers zero ICE candidates and iceConnectionState jumps straight to "failed" within milliseconds of construction ("WebRTC: ICE failed, add a TURN server"). Chromium and WebKit gather real host candidates and connect within seconds in the identical CI job, so this isn't a general sandbox network block — it's specific to Firefox's own interface-discovery heuristic. This exact scenario is a closed Mozilla bug — Bugzilla 1659672, "ICE gathering fails in a pure LAN environment, no internet-routable default route" — resolved INVALID by Mozilla themselves, i.e. never fixed; a related, genuinely different Firefox restriction (gathering host candidates from only a single interface without a granted media permission) does have a real app-side workaround (a granted fake getUserMedia() stream) and verify-multiplayer-connect.mjs applies it regardless, since it's still correct for anyone running the script against real Firefox locally — it just doesn't touch this CI-only, zero-candidate case, where there's no default-route interface to restrict to begin with. The one remaining Firefox preference that targets this more directly, media.peerconnection.ice.force_interface, needs the exact runner network interface name, which GitHub Actions' ephemeral runners don't expose in any stable way — not a viable general fix. media.peerconnection.ice.loopback was the fifth candidate, and it does not work either — tested on CI 2026-08-11, because nowhere else can test it. It looked like the best remaining fit on paper: it lets Firefox gather candidates on the loopback interface, which is all this setup needs (host and guest are two contexts in one Playwright process on one machine), and it sidesteps the default-route discovery heuristic rather than trying to satisfy it. With the pref set and the four Firefox multiplayer steps un-skipped, verify:multiplayer-connect still failed on the runner: the host generated a session code (so signaling is fine) and the guest then died on "Data channels were not received within 15000ms". Reverted. Two things worth stating precisely so this isn't re-tested a third time. First, the dev box cannot substitute for the runner and a local pass means nothing here — Firefox passes this check locally with or without the pref, because UDP egress works, so only CI can distinguish them; the control was run first specifically to establish that. Second, this result says the pref does not make the check pass, and deliberately not that candidate gathering was unchanged — installIceDiagnostics() is opt-in behind CODEENSTEIN_MULTIPLAYER_DEBUG_ICE=1 and is off in CI, so nobody has yet seen whether loopback candidates appear and fail later, or still never appear at all. Anyone attacking this again should turn that on first; it is the one measurement this attempt did not take. This project does ship a TURN relay — coturn in docker/, with short-lived credentials minted by multiplayer-server.mjs's ICE-servers route (see Multiplayer Server Deployment) — but it is optional and off unless the signaling server is configured for it: with the relay's env vars unset that route returns 404 and clients stay STUN-only, which is what CI runs. Adding one to the CI job wouldn't rescue this case anyway, and for a reason worth stating precisely: Firefox here gathers zero candidates because it cannot identify a default-route interface at all, and allocating a relay candidate depends on that same lookup — a TURN server only helps once the browser can gather something to relay from.

WebKit-specific timing quirk, confirmed real and repeatedly caught by CI, not a WebKit bug: scripts/poc-cross-browser-determinism.mjs already established that cross-engine transcendental math (Math.sin/cos/atan2) isn't bit-identical — Chromium/Firefox/WebKit each diverge from a Node reference at a different sample index in an identical 500,000-iteration stress loop (samples #9/#7/#6 respectively), meaning WebKit diverges soonest of the three. This isn't just a stress-test artifact: step 7's own end-to-end verify scripts (verify-multiplayer-netcode.mjs's post-movement lockstep check, verify-multiplayer-reconciliation.mjs's PRNG-resync check) each independently flaked on CI's WebKit leg with a real, measured divergence from ordinary gameplay math (turning/moving is Math.sin/cos-heavy) — e.g. a genuine {x:34.78} vs {x:34.673} position gap after a few seconds of real movement, not a synthetic one. Both scripts originally asserted exact cross-peer equality once, after a single fixed wait — since periodic reconciliation only corrects drift once a second (RECONCILE_INTERVAL_TICKS), a downstream read can land in the window where a fresh, genuine divergence has already reappeared but the next correction hasn't arrived yet. Neither flake was a WebKit defect or an app bug: fixed in both scripts by polling for the first moment two peers' state agrees (pollUntilConverged(), ~150ms interval) instead of checking once at a fixed later instant — the same technique, applied twice. The general lesson, worth applying to any future script that asserts cross-peer/cross-process state equality in this codebase: never assert exact equality once after a fixed wait when the underlying value is expected to be periodically-but-not-continuously corrected (reconciliation, or anything with a similar "eventually consistent, not instantly consistent" contract) — poll for the first moment of agreement instead, and prefer running any new such check against webkit locally before trusting it clean, since it's confirmed to surface this class of timing bug fastest. This has no player-facing implication — real sessions self-correct within about a second regardless of which peer's browser diverges first, and corrections under the smoothing threshold are visually smoothed, not a stutter.

verify-multiplayer-transition.mjs runs chromium-only in CI, and it is blocking. It was made continue-on-error: true on 2026-08-01 on the theory that its failures were the intermittent combat-variance flake this paragraph used to document. That was wrong, and the disproof was already in every log: the stall coordinates repeated across runs with different gameplay seeds, because demo-campaign level 1's layout is deterministic even though its gameplay seed is randomSeed(). Three real defects came out of re-opening it (see Development History), and the job went back to blocking on 2026-08-03. The script no longer stakes its result on surviving a real firefight either — it god-modes the host via debugSetGodMode on both pages before the run, so combat variance cannot decide the outcome. If you see this job red, it is a real failure; read its own log section rather than the whole job log, and check the failure dump it prints (where the bot was, which enemies belong to the exit's room, whether they're alive). Lesson kept deliberately: "this matches a known-flaky signature" is a hypothesis, not a finding, and the cheap test — do the failures repeat at identical coordinates? — takes one grep. The rest of this paragraph is the earlier cross-browser investigation, still accurate. In its own dedicated verify-multiplayer-transition job — not a leg of the verify-browser matrix, split out 2026-07-22 so its real multi-minute cost runs concurrently with everything else instead of stacking onto the end of a shared chromium leg. Firefox is skipped for the same WebRTC ICE-gathering WONTFIX every multiplayer script here has; webkit is skipped for the following, confirmed as real combat-timing variance, not a bug in the mechanism it proves. This script used to drive a real, unscripted host-vs-enemy combat encounter across the bundled demo campaign, retrying on real, expected combat losses up to a MAX_SCENARIO_ATTEMPTS budget of 15 — that is the pre-2026-08-03 shape of the script, kept here because the webkit skip below was reasoned about under it; the script now god-modes the host instead and carries no scenario-retry budget of its own. Two real bugs were found and fixed building confidence in it (a MultiplayerBot.maybeDetourForLoot freeze-under-load bug, and a genuine lag-compensation gap in hit resolution — see notes' step 8 entry for the full writeup) — after both, the script passes reliably on chromium and firefox, and passes locally on webkit too (confirmed: a clean win on attempt 9/15, zero transport errors across the whole run). But CI's own webkit runners exhaust the full 15-attempt budget without a single win, a different outcome from every local webkit run — root-caused directly, not assumed, by checking verify-multiplayer-transition's own CI log section specifically (not the whole job log — an earlier investigation misattributed a different, preceding step's expected pageerrors, from verify-multiplayer-disconnect's own intentional mid-session browser-context teardown, to this step; verify-multiplayer-transition's own section shows zero pageerrors, purely repeated "real combat variance" losses). This lines up with WebKit's own already-documented-elsewhere tendency to diverge/perform differently under CI's sandboxed execution (see the timing-quirk paragraph above) — not something a code fix closes, since the underlying combat mechanism is already confirmed correct.

verify-multiplayer-multiguest.mjs runs chromium-only in CI, in its own dedicated verify-multiplayer-multiguest job — same split, same date, same reasoning as verify-multiplayer-transition.mjs above (the other genuinely slow, multi-minute step). Firefox is skipped for the same ICE-gathering WONTFIX; webkit is skipped for the following, same class of timing-variance issue as verify-multiplayer-transition.mjs above, confirmed not a bug in the mechanism it proves. Step 10's new 3-peer script has guest-2 join the host's session on the same code guest-1 used, relying on armNextGuestSlot (main.ts) to automatically republish a fresh offer under that code the instant guest-1 connects — a real race between guest-2's own near-instant scripted join and that async re-arm cycle (a second real RTCPeerConnection's ICE gathering plus a signaling round trip), which the script retries a bounded number of times. The retry window was widened once already, using real measured CI evidence (9 attempts, 5s apart, ~40s total, comfortably under the signaling server's own 20-requests/60s-per-IP guess-sensitive rate budget — see multiplayer-server-spec.md §4) — and still exhausted every attempt on CI's webkit runner across two separate pushes, while passing cleanly in 2 retries on local webkit runs both times and now passing reliably on CI's own chromium leg too. Root-caused directly: the mechanism itself (auto-rearm, sequential multi-guest join) is proven correct by chromium (in CI) and webkit (locally) both succeeding quickly — CI's webkit runner is measurably slower specifically at establishing this second real WebRTC connection while the first is already live and ticking, the same "webkit is measurably slower/different under CI's own sandboxed execution characteristics" pattern already established above, not a logic bug to chase further with a wider timeout.

scripts/verify-multiplayer-campaign.mjs (npm run verify:multiplayer-campaign) is the one script that actually chains multiple real level transitions, not just one — neither verify-multiplayer-transition.mjs above (proves one transition, host made invulnerable via debugSetGodMode so combat variance can't make it flaky) nor run-balancing-telemetry-multiplayer.mjs (see Balancing Telemetry Bot, deliberately one bundled level per run) ever drove a session across several consecutive levels. A real 2-player easy/Casual session, both peers bot-driven with real, un-god-moded combat, loops "drive both bots to the exit, wait for the transition, repeat" for as long as the team keeps clearing levels — a level-4 pass bar is not a stopping point; the loop keeps going for real, all the way to a genuine "campaign-complete" if the team can manage it, or a MAX_LEVEL_ITERATIONS runaway-loop safety net (the real demo campaign is 17 levels) well short of that. Starts its own isolated dev+signaling server pair (same as run-balancing-telemetry-multiplayer.mjs), chromium-only, not CI-wired — an uncapped, potentially-full-campaign-length run has no bounded worst-case wall-clock cost, unlike even the two dedicated "genuinely slow" CI jobs above. Run manually.

page.waitForFunction()'s 2-argument call form silently ignores the timeout you think you're passing — confirmed by direct experiment, found while building verify-multiplayer-disconnect.mjs. Playwright's real signature is waitForFunction(pageFunction, arg, options) — there is no 2-argument (pageFunction, options) overload at the JS runtime level, whatever the TypeScript typings might suggest. Calling it with exactly two arguments — page.waitForFunction(fn, { timeout: N }), a natural-looking shape when the predicate takes no data — makes Playwright treat { timeout: N } as arg (a value literally injected into the browser-side predicate, here simply unused) and falls back to its own built-in default timeout (30,000ms) for real, regardless of what N was. Confirmed directly: a minimal repro (page.waitForFunction(() => false, { timeout: 2000 })) took the full 30,000ms to reject, not 2,000ms. This was silently broken in every 2-argument waitForFunction call across verify-multiplayer-connect.mjs/verify-multiplayer-netcode.mjs/verify-multiplayer-reconciliation.mjs from steps 6c/7 — invisible in every one of them, purely by coincidence, since every affected call's intended timeout was either already 20_000/30_000 (close enough to, or exactly matching, Playwright's own 30s default that no test ever ran long enough to notice) or guarded a predicate that always resolved well under 30s anyway. It stopped being invisible the moment verify-multiplayer-disconnect.mjs needed a timeout genuinely longer than 30s (real ICE disconnect detection plus a 10s grace period) — that check failed at almost exactly 30,000ms even though the code read { timeout: DISCONNECT_DETECT_TIMEOUT_MS } with DISCONNECT_DETECT_TIMEOUT_MS = 90_000. Fixed everywhere by passing undefined explicitly for arg whenever the predicate takes no data: page.waitForFunction(fn, undefined, { timeout: N }). The general lesson: any page.waitForFunction() call in this codebase must use the explicit 3-argument form — undefined as the middle argument if the predicate needs no injected value — never the 2-argument shorthand, even though it type-checks and often "works" (by accident, whenever the intended timeout happens to be at or under 30s).

Running the suite

  • npm test — run once (vitest run).
  • npm run test:watch — watch mode.
  • npm run coverage — run once with coverage, enforcing the 99.9/99.9/99.5/99.5 gate (see Setup for why it isn't a flat 100%).

Setup

Versions moved together with the Node floor. vitest/@vitest/coverage-v8/jsdom sat pinned at 3.2.7/3.2.7/26.1.0 for a while specifically because vitest@4/jsdom@29+ require Node 20+, one major ahead of this project's Node 18.19.1 floor at the time. Once the floor moved to Node 20.19+/22.12+ (forced by the Vite 8 bump — see Architecture — and matched by bumping CI to Node 24), all three were bumped too: vitest@4.1.10, @vitest/coverage-v8@4.1.10, jsdom@29.1.1. That bump is also why the coverage gate below isn't a flat 100% anymore — see the thresholds comment in vitest.config.ts for the measurement-bug story.

jsdom@30 moved the floor again, and it is now the binding constraint — not Vite. jsdom@30.0.0's only breaking change was raising its own engines to ^22.22.2 || ^24.15.0 || >=26.0.0; everything else in 30.0.0/30.0.1 is CSS/getComputedStyle fixes. That range is strictly narrower than Vite 8's ^20.19.0 || >=22.12.0, so package.json's engines now mirrors jsdom's rather than Vite's. Derived, not assumed: intersecting the engines of all 136 constrained packages in package-lock.json against every real Node release yields exactly ^22.22.2 || ^24.15.0 || >=26.0.0, with jsdom the sole blocker at each boundary (22.22.1 and 24.14.0 both fail on jsdom alone; jsdom's own deps @asamuzakjp/css-color/@asamuzakjp/dom-selector are the next-strictest at ^22.13.0 || >=24.0.0).

Two things about that range are easy to misread as typos. It has holes, not just a floor: Node 23 and 25 are odd-numbered Current lines that never became LTS, and jsdom's caret ranges deliberately exclude them (Node 25 in particular reached EOL on 2026-06-01). And it drops Node 20 entirely, which costs nothing real — Node 20 reached end-of-life on 2026-04-30, before this bump landed. CI runs Node 24 and is unaffected.

Unit tests under scripts/ — outside the coverage gate, still run by CI

scripts/ code is not in the coverage denominator: vitest.config.ts sets coverage.include: ["src/**/*.ts"], so nothing under scripts/ faces the 99.9%/99.5% thresholds. It is easy to read that as "script code cannot be unit-tested here". It can, and several files are — Vitest's test discovery is separate from its coverage scope, so a scripts/**/*.test.mjs file is picked up by npm test and by CI's Vitest job like any other, it simply contributes no coverage obligation.

scripts/multiplayer-server.test.mjs established the pattern; the bot work leaned on it heavily, because scripts/lib/ had ~1400 lines of movement and combat logic with no automated check of any kind. Currently:

file what it pins
scripts/multiplayer-server.test.mjs the signaling/lobby server's routes and mailbox semantics
scripts/lib/combatPolicy.test.mjs decide() per branch, the pure geometry helpers, and the tuning-injection contract
scripts/lib/anomalyDetectors.test.mjs that the stall/oscillation/health-drain detectors fire when they should and stay quiet when they shouldn't
scripts/lib/routePlanner.test.mjs pickup ordering, and that gate ordering deliberately stays array-order
scripts/lib/abReport.test.mjs the A/B guard thresholds and the survival-curve maths
scripts/lib/laneOrchestrator.test.mjs the per-combo invocation cap that bounds campaign cost
scripts/lib/profiles.test.mjs the skill ladder itself — key order, per-knob monotonicity, a complete ranged fallback per tier, and the PROFILES_HASH staleness guard on defaultHighscore.ts
scripts/lib/profileSeparation.test.mjs the ladder grader, including that it fails on an inverted axis and on a readable-ends/unreadable-middle ladder

Two things make these worth writing rather than relying on the bot harness itself. The decision core is pure (see Balancing Telemetry Bot), so a branch can be asserted directly without a browser, in milliseconds rather than the tens of minutes a telemetry run costs. And a detector that silently stops firing is worse than no detector, because the scan keeps reporting clean — anomalyDetectors.test.mjs exists specifically to pin the negative cases.

vitest.config.ts's ?url-as-path plugin exists for one reason: src/parser/runtime.ts's Parser.init({ locateFile }) and the grammar loads in src/parser/generic/languages.ts/cParser.ts/phpParser.ts all import their .wasm file via Vite's ?url suffix, which normally resolves to a browser-shaped dev-server URL — meaningless under plain Node. The plugin (modeled on scripts/lib/loadEngineModules.mjs's urlImportAsPathPlugin, the esbuild equivalent used by the Playwright verify scripts) rewrites a ?url import into a real absolute filesystem path instead, registered with enforce: "pre" so it wins the resolution race against Vite's own built-in vite:asset plugin. If wasm loading ever starts throwing under Vitest, this is the first place to look.

environment: "node" by default, not jsdom — most files under src/ (parser, map generation, most of engine/) touch no DOM at all, so paying jsdom's setup cost repo-wide would be wasted. A DOM-touching test file opts in per-file with a // @vitest-environment jsdom docblock line at the top.

test/mocks/ — the shared fixture catalog

Kept outside src/ deliberately — excluded from coverage by directory, and outside tsconfig.json's "include": ["src"] set.

  • canvas.ts — a hand-rolled vi.fn()-based CanvasRenderingContext2D mock covering every method the engine actually calls, plus writable style properties. stubCanvasGetContext(canvas) patches HTMLCanvasElement.prototype.getContext to return it.
  • audio.tsMockAudioContext/oscillator/gain node classes (vi.stubGlobal("AudioContext", MockAudioContext)), plus a no-audio mode (stub AudioContext to undefined) for exercising audio.ts's existing fallback path.
  • fsAccess.ts — in-memory FakeFileSystemDirectoryHandle/FakeFileSystemFileHandle, covering only the surface src/fs/workspace.ts actually calls (.name, .kind, async-iterable values(), a file's getFile() returning { text() }). fakeDirectoryHandle(name, tree) builds one from a plain nested-object tree ({ "src": { "main.c": "..." } }).
  • raf.tsinstallRaf({ stubClock? }) stubs requestAnimationFrame/cancelAnimationFrame with a manually-driven FIFO queue (cancelAnimationFrame genuinely removes by id — not a no-op), returning a RafController with .flush(n, stepMs) to invoke up to n queued callbacks. stubClock: true additionally stubs performance.now/Date.now off a manually-advanced virtual clock. Gotcha: performance.now is read-only in this environment — use vi.spyOn(performance, "now").mockImplementation(...), not direct reassignment.
  • mainDom.tsbuildIndexDom() rebuilds index.html's exact structure (kept in sync by hand — main.ts's requireElement() calls run at module-load time and throw immediately against an empty document); stubResizeObserver() (jsdom has no real ResizeObserver); stubDialogElement(dialog) patches showModal()/close() onto a <dialog> (jsdom has neither — close() fires a real "close" event per spec, since main.ts relies on it).
  • mediaRecorder.ts — hand-rolled MediaRecorder/captureStream/URL.createObjectURL test doubles plus an anchor-download stub, backing the "Export Replay as webm" feature's tests.

Mocking philosophy

Mock browser/platform APIs; never mock this project's own modules, with rare, explicitly-justified exceptions (a couple of vi.doMock calls against src/fs/demoCampaign.ts/src/engine/highscores.ts in main.test.ts, each with an inline comment explaining why a prototype-level spy wasn't viable). The default is real code all the way down: a real tree-sitter parse, a real MapGenerator.generate(), a real RaycasterEngine. This is slower than mocking internals but catches real integration bugs a mocked boundary would hide — several genuine bugs during this rollout (see git log around the Phase 1–11 commits) were only caught because a test ran real logic instead of a stand-in for it.

storageCompression.ts and highscores.ts lean on this too: CompressionStream/DecompressionStream are real Node 18+ globals (no mock needed), and crypto.subtle uses node:crypto's real webcrypto (jsdom's built-in crypto has no SubtleCryptovi.stubGlobal("crypto", webcrypto) in a per-test beforeEach, not beforeAll, since a shared afterEach's vi.unstubAllGlobals() would strip a beforeAll-only stub after the first test).

jsdom gotcha, found the hard way: jsdom's own Blob shim (used automatically in any // @vitest-environment jsdom file) doesn't implement .stream() at all, unlike every real browser — storageCompression.ts's gzip/gunzip used to build their CompressionStream/DecompressionStream pipe from new Blob([bytes]).stream(), which silently broke the real compression path under jsdom (caught by compressForStorage's own try/catch, which fell back to uncompressed plain JSON — so no test ever actually exercised real gzip data until defaultHighscore.ts started shipping pre-compressed data directly and a decompress-only call had no such fallback to hide behind). Fixed with a small ReadableStream-based single-chunk stream instead of BlobReadableStream itself is a plain global jsdom doesn't shadow, so it behaves identically under jsdom and in a real browser. Worth remembering for any future code that pipes bytes through CompressionStream/similar Web Streams APIs: reach for ReadableStream directly, not Blob.stream(), if it needs to work under this project's jsdom-environment tests.

Reusable techniques

Dynamic-import-after-canvas-stub. Any module whose import chain transitively imports { textures } (a real value, export const textures = new TextureManager()) from src/engine/textures.ts calls canvas.getContext("2d") at module-load time. Statically importing such a module at the top of a test file throws before stubCanvasGetContext() ever runs. Fix: await import(...) the module after stubbing, inside the test (or a per-test setup function), never as a top-level import.

installRaf's fake-clock driver. For anything frame-loop-driven (the engine's own advance() loop, main.ts's replay step()), drive it with raf.flush(n, stepMs) rather than real timers. A tight synchronous loop of raf.flush() calls does not let a handler's own async chain (e.g. readFileText/parseFile/hashRun kicked off inside a frame callback) progress at all — real event-loop yields are required between rounds. Either poll with a waitUntil(check, timeoutMs) helper (checks check(), yields via a real setTimeout(0)-backed flushAsync() between attempts, retries until true or timeout) or interleave explicit await flushAsync() calls.

BFS-navigation through a real generated map. For engine/replay tests needing a specific outcome (reach the exit, stand on a hazard), generate a real map from a small fixture, BFS a path (bfsPath(grid, from, to), 4-directional, passable tiles = floor/hazard/door/teleporter/spike-trap, excluding secret/lore walls which need an explicit interact), then drive a live player along it either via real KeyboardEvents dispatched on the canvas (walkPath, for DOM-driven tests) or direct engine.advance(dt) calls through a minimal scripted InputSource (MinimalScriptedInput/recordNavigatedSegment in main.test.ts, for constructing a RaycasterEngine directly, outside any DOM). Since MapGenerator.generate()'s seed derives from the parsed source's own AST content (not from any runtime RNG seed), a fixture's reachability is fully deterministic — brute-force small candidate snippets offline once, then hardcode the one that works, rather than re-deriving it per test run.

Replay-payload seeding. To test replay playback without a full record → save → reload round trip, hand-construct a ReplayLevelSegment (or record a real one via recordNavigatedSegment), feed it into recordHighscore({ ..., replay: { version: 2, campaignName, levels: [segment] } }), then open it through the real "Watch Replay" UI flow. Two gotchas bit this repeatedly: a TreeNode.path (and thus a file-tree row's title attribute, and a segment's filePath) is prefixed with the workspace root's own name ("ws/main.c", never bare "main.c"); and window.__codeensteinTestHooks (see below) needs explicit clearing whenever a test constructs more than one engine.

window.__codeensteinTestHooks is real window state, not test-scoped. RaycasterEngine's constructor sets this directly on window (gated behind ?testHooks=1 in window.location.search) — not via vi.stubGlobal, so the shared afterEach's vi.unstubAllGlobals() doesn't touch it, and it survives across every test in the file. A !!testHooks() check used as a "has this test's own engine finished loading yet" signal can be satisfied by a completely unrelated earlier engine still sitting on window — including, for two replay tests during this rollout, a deliberately earlier standalone recording engine (recordNavigatedSegment constructs one to record a real win/death before the replay under test even starts) that had already reached "won", making both tests pass without ever actually observing the replay. Fixed with a global beforeEach that clears window.__codeensteinTestHooks before every test, plus an explicit delete right after any helper that deliberately constructs an engine mid-test (before that test's own engine is expected to exist). If a testHooks()-based assertion in a new test passes suspiciously fast, this is the first thing to check.

Coverage-tool caveats

A number of lines in src/main.ts carry an honest /* v8 ignore next */ (or start/stop block) rather than a test. Every one has an inline comment explaining why, and falls into one of two categories — never "this can't be tested," always "this can't happen, here's the proof":

  • TypeScript's conservative optional typing vs. what production code actually guarantees. E.g. carryover.ownedWeapons ?? [] — the type allows undefined, but every real call site that builds a carryover object always populates a real array, so the fallback is genuinely dead given the current call graph. Same shape as Array.prototype.pop()'s type allowing undefined even though "x".split("/").pop() can never actually produce it (a demoCampaign.ts precedent this pattern was copied from). If a future code change adds a new call site that doesn't populate the field, the ignored line would start silently masking a real gap again — these are call-graph-shaped assumptions, not eternal truths, and worth re-checking if the surrounding function's callers change.
  • A single call site already fully guards the condition. E.g. burstTo/restartLevel's own null checks — both are only ever called from seekBy, which already verified the same state non-null one line earlier.

Before adding a new v8 ignore, verify the claim the same way these were verified: trace every call site, or add temporary console.error instrumentation directly in the branch, run just the relevant test(s) via -t, and confirm it either never fires or always fires — then remove the instrumentation before committing. Don't guess.

What the test suite structurally cannot catch

A coverage number this high invites the wrong inference. 99.9% lines means every line ran, not that every property of what it did was checked — and two whole categories of bug in this project are unreachable by the suite by construction, not by omission. No amount of additional unit testing closes either; they need a different instrument. Both have already cost real debugging time, which is why they're written down rather than left as folklore.

Canvas layout — the tests assert what text was drawn, never where or how wide. test/mocks/canvas.ts's context is a vi.fn() recorder: it captures arguments and draws nothing. src/ui/gameHud.test.ts's helper is literally ctx.fillText.mock.calls.map(([text]) => text) — position and maxWidth are destructured away and never asserted. src/engine/hud.test.ts goes one step further and pins some anchor coordinates (expect(c.fillText).toHaveBeenCalledWith("15", 800 - 8, 30)), but an anchor is not an extent: nothing anywhere in src/**/*.test.ts asserts a fillText call's fourth argument, and nothing asserts rendered width at all. Text overflowing its box therefore produces a passing test at any coverage level, because the assertion and the bug are about different things.

The mock makes this sharper than it first looks. measureText is stubbed as (text) => ({ width: text.length * 6 }) — a flat 6px-per-character estimate that ignores ctx.font entirely. src/engine/hud.ts sizes several boxes from that measurement (const boxW = ctx.measureText(text).width + 24) and wraps lore-terminal text against it, so under test those layouts are computed from a constant that has no relationship to the 13px/22px monospace faces the real canvas uses. The code path is covered; the geometry it produces is fiction.

There is a real instance, and it is now fixed — verify the current code before repeating either version of the story. drawOverlay (src/ui/gameHud.ts) passes a clamp for the title and each body line (ctx.fillText(content.title, w / 2, y, boxW - 32)), but its two-column stats rows originally passed none, so a long combined value — "Melee 12 · Ranged 40 · Traps 8", built by statRows() — drew straight past the box edge. Every test still passed. It was caught by looking at a real browser screenshot, and fixed by giving each column its own budget: const sideMaxWidth = boxW / 2 - 24, applied to both the label and the value. What remains unclamped today is the button label in the same function and every fillText in src/engine/hud.ts — deliberate in the latter's case, since those boxes grow to fit their text instead of clipping it, but worth knowing the suite would not tell you if that stopped being true.

Bot-driven scripts cannot see window-level or canvas-mouse bugs. Both bots — Bot#dispatchSegment (scripts/lib/bot.mjs) and its multiplayer sibling (scripts/lib/multiplayerBot.mjs) — drive the game through exactly one boundary: canvas.dispatchEvent(new KeyboardEvent("keydown", { code })), with bubbles left at its default of false. Two consequences follow directly from that one line, and they hold for every bot-driven script in the repo, in every browser, headed or headless:

  • Nothing the bot sends ever reaches window. A non-bubbling event dispatched on the canvas is visible to the canvas's own listeners and to nothing above it. Any handler registered on window — the level-briefing overlay's dismiss listeners, for one — is simply not part of the system under test. (The dispatch-target rules themselves are already documented in Performance Tooling; this is the coverage consequence of them, not a restatement.)
  • Nothing the bot sends is ever a mouse event. Neither bot constructs a MouseEvent, and neither uses page.mouse. Firing is keyboard-only, via Backquote for ranged and Space for quick-melee — and Backquote is not a real control at all: src/engine/input.ts's isFireHeld() documents it as an undocumented escape hatch that exists specifically so headless automation can fire, because real players have no keyboard fire key (mouse or gamepad only). So the bot's fire path is a code path players never take, and the players' fire path is one the bot never exercises. Real page.click() calls do appear in the multiplayer setup flow (scripts/lib/multiplayerSessionBootstrap.mjs clicks #tab-demo, #multiplayer-host-create, and friends) — but those are sidebar DOM buttons. No script in this repo clicks the canvas.

This has already produced one bug that survived repeated automated repro attempts: the host-side stuck "PAUSED" overlay documented in Multiplayer Netcode, whose trigger was a stale overlay dismiss listener on window plus the canvas's mousedown — both structurally invisible to every tool described above. It took a real screen recording on real hardware.

Level layout quality is not a property any assertion holds. mapGenerator.test.ts and the generation/*.test.ts files check invariants — every room reachable, no room overlapping another, no corridor severed, one key per doorway — and all of them passed happily throughout the years in which every level was a few rooms strung along enormous identical hallways. The defect was real, obvious, and only visible as a picture: seventeen floor plans side by side, where the repetition reads instantly. npm run report:level-maps (scripts/render-level-maps.mjs) is the instrument for that class — one PNG per demo-campaign level plus the metrics that move when placement or corridor topology changes (corridor leg length, longest straight run, corridor-feature count and footprint spread, floor density against the level's own bounding box, room-graph cycle count, and rooms/entities so a silently dropped room is visible). Run it before and after any change under src/map/generation/, and look at the images, not only the table — "no single motif dominates" is not a number.

The standing conclusion is worth stating plainly, because it inverts the usual reading: "this only reproduces in real play" is sometimes a fact about the harness, not a doubt about the report. When a bug report touches visual layout or input routing, a green suite and a clean bot run are not evidence against it — they are silence. The only instrument that can speak to either is a real-browser Playwright session against the dev server, driven with genuine page.mouse/page.click interaction and inspected with a screenshot. For that class of issue it isn't optional polish; it's the only measurement available.