Testing
The high-risk surface - WebGPU and React - can't be fully unit-tested, so Awaken layers several gates: fast Vitest unit tests, a headless Deno WebGPU shader/pipeline check, two Puppeteer boot smokes, and lint/typecheck via ESLint and Husky.
The gate stack
No single test can prove a WebGPU app is correct, so correctness is assembled from layers that each catch a different class of failure.
Each layer runs independently; the ones a machine can run without a GPU also run in CI and on Vercel. Visual correctness - does the shadow actually look right - still needs eyes on a pnpm dev run.
Unit tests (Vitest)
The bulk of the coverage is 1,200+ unit tests across 160+ colocated *.test.ts files run by Vitest in the Node environment. vitest.config.ts is the whole setup:
ts
export default defineConfig({
test: {
include: ["packages/**/src/**/*.test.ts", "apps/**/src/**/*.test.ts"],
environment: "node",
},
});Tests live beside the code they cover (e.g. distanceCull.ts + distanceCull.test.ts). The project is TDD-first: each feature lands with headless tests. What's covered directly is everything that's pure logic - math (mat4/quat/vec3), the ECS, the six importers, cull/batch/cache, the colour grade, and command undo/redo round-trips. The Unity-import parity harness asserts statistical invariants against a real asset pack and skips cleanly if the pack isn't present, so a fresh clone still goes green.
bash
pnpm test # run everything once (headless)
pnpm test:watch # watch modeThe golden frame (Deno WebGPU)
Compiling every shader proves the pipelines build; it does not prove the picture is right. npm run golden renders a fixed scene headlessly, reads the framebuffer back, and hashes it against a per-adapter baseline. A region report says where the picture diverged rather than only that it did.
The workflow around renderer surgery is npm run golden:save before, npm run golden after. The baseline is per-adapter because two GPUs legitimately produce different bits; a machine with no baseline records one rather than failing.
This is what makes decomposing the renderer a safe operation instead of a hopeful one.
Structural gates: layering and player size
packages/runtime/src/layering.test.tsenforces the dependency matrix at the source level.npm run size:playerchecks the built player bundle against a size budget and scans it for banned import paths, which is what keeps editor-only importer code out of shipped games.runtime/sim.tsis the single definition of the post-script simulation stage, consumed by both the player and the editor viewport.sim.test.tsfails an attempt to hand-wire the animation, skinning, particle or UI systems a second time.
GPU shader & pipeline check (Deno)
Unit tests run in Node, which has no GPU - so the WGSL shaders and render pipelines are validated separately, headlessly, using Deno's experimental WebGPU implementation.
bash
pnpm test:gpu
# → nine deno run --unstable-webgpu scripts, in order (package.json)| Script | What it proves on a real device |
|---|---|
gpu-check.ts | Every shader compiles and every pipeline builds (detail below) |
gpuComputeCheck.ts | The GPU compute executor behind api.gpu: kernel output, padded texture rows and buffers read back correctly |
gpu-compute-frame.ts | Compute output drawn by a material, through the renderer |
blend-frame.ts | Material.blend (alpha, additive, premultiplied) gives the expected pixels |
post-options-frame.ts | The acesHill tonemap, the output dither and the two bloom modes |
render-target-frame.ts | Render layers, render textures and target cameras |
update-graph-check.ts | Every update-graph node compiles, and passes into an rgba16float texture read back the expected values (Game of Life, a Loop blur, samplers, Load Texel) |
simulation-check.ts | Simulations through the shipped Renderer: update, copy and clear steps, repeats, the step uniform, Reset |
instanced-draw-check.ts | Instanced draws from a texture land where their texels say, including the Particle Field demo |
scripts/gpu-check.ts requests a real adapter and device, then:
- Compiles every WGSL shader -
MESH_WGSL,GRID_WGSL,ID_WGSL,GIZMO_WGSL,SKY_WGSL,SKY_CUBE_WGSL,MASK_WGSL,OUTLINE_WGSL,SHADOW_WGSL,POST_WGSL, the static-batch and position-prepass variants, and theSSAO_WGSL/AOBLUR_WGSLpair, plus each shipped starter-content material composed into the mesh shader (MESH_MATERIAL_WGSL) - reporting compile errors with line/column. - Builds the real pipelines from those modules against the actual bind-group and vertex-buffer layouts, catching layout mismatches a shader compile alone wouldn't.
- Runs the cull compute kernel and reads the result back, asserting it matches a CPU reference set.
- Probes GPU capabilities it depends on (e.g.
indirect-first-instance, and whether@builtin(instance_index)includesfirstInstance).
It exits non-zero if any shader fails to compile, any pipeline fails validation, or the cull readback disagrees with the reference - so a broken shader is caught before it ever reaches a browser. This requires a local Deno install and is run on demand rather than in the standard CI gate.
Editor walks (headless Chrome)
The walks drive the real editor through its visible UI in headless Chrome with WebGPU on, assert what should be on screen, and save screenshots. They share scripts/walk-browser.mjs (launches Chrome headless, accepts every native dialog) and scripts/walk-editor.mjs (starts the dev server on port 5193 or AWAKEN_WALK_PORT, boots the editor, and provides menu, dock and pixel helpers).
| Walk | Covers |
|---|---|
editor-walk-e1.mjs, editor-walk-e1-2d.mjs | The 2D Scene view and render textures |
editor-walk-e2.mjs | Simulations built and run in the editor |
editor-walk-instanced.mjs | Create > 2D > Particle Field (instanced draws from a texture) |
bash
node scripts/editor-walk-e2.mjs --out /tmp/e2 [--only step,step]The same helpers drive pnpm docs:shots, which captures this site's screenshots by name into ../docs/docs/public/img/ (--only, --list; see the docs repo README).
Boot smoke tests (Puppeteer)
Two smokes exercise the boot paths that typecheck and unit tests can't reach - the parts where "it compiles" and "it actually runs in a browser" diverge. Both use puppeteer-core (you supply Chrome via PUPPETEER_EXECUTABLE_PATH / CHROME_PATH).
Editor boot - pnpm smoke
bash
pnpm --filter @awaken/editor build # smoke needs a prior build
pnpm smoke # → node scripts/smoke.mjsscripts/smoke.mjs serves the built apps/editor/dist, loads /editor in headless Chrome, and fails if the bundle doesn't execute, if React never mounts (it waits for the .app selector), or if any non-WebGPU error or unhandled rejection fires. WebGPU itself is allowlisted - CI machines have no GPU, and the editor already surfaces that gracefully in #gpu-error, so a missing adapter is not a boot failure. This catches boot-path breakage - a bad import, a crash in a top-level effect, a broken lazy chunk - that pure tests miss.
Shipped-game boot - pnpm smoke:game
bash
pnpm build:player-template # needs a fresh template
pnpm smoke:game # → node scripts/smoke-game.mjsscripts/smoke-game.mjs stamps a minimal scene - a falling RigidBody plus a probe script - into player-template.html exactly the way Export Game does, writes a temp game.html, and opens it via file:// in headless Chrome. It passes only if the probe script runs and physics actually stepped (the body falls and the script writes SMOKE-OK to the HUD). This guards the entire shipped-game path the unit tests can't reach: single-file bundling, inline-scene pickup (the gzip + base64 __AWAKEN_SCENE_BIN__ container), precompiled behaviours picked up from an inline window.__AWAKEN_BEHAVIORS__ script (the mechanism a real export uses - not a blob: import, which Chrome blocks from a file: origin), and the player frame loop - the same path the stale-template rule protects.
Lint & Husky
Linting is ESLint with a flat config (eslint.config.mjs), and it is deliberately a correctness net, not a style enforcer - there is no Prettier. The codebase has a dense, hand-tuned style; blanket reformatting is off the table. ESLint's job is catching real bugs: dead code, accidental globals, unhandled switch cases, and React hook-dependency mistakes. Intentional patterns (non-null assertions, terse one-liners) stay allowed.
bash
pnpm lint # eslint .
pnpm lint:fix # eslint . --fixA Husky pre-commit hook (installed by the prepare: "husky" script) runs lint-staged, which applies eslint --fix to staged *.ts / *.tsx files - so obvious problems are caught before they land, without linting the whole tree on every commit.
Where each gate runs
| Gate | Command | CI | Vercel deploy |
|---|---|---|---|
| Unit tests | pnpm test | ✅ | ✅ (pnpm test && pnpm build) |
| Typecheck | pnpm -r typecheck | ✅ | via editor build |
| Lint | pnpm lint | ✅ | - |
| Editor boot smoke | pnpm smoke | ✅ (real Chrome) | ✗ (no browser) |
| GPU checks | pnpm test:gpu | on demand | on demand |
| Golden frame | pnpm golden | on demand | on demand |
| Editor walks | node scripts/editor-walk-*.mjs | on demand | ✗ |
| Docs screenshots | pnpm docs:shots | on demand | ✗ |
| Game boot smoke | pnpm smoke:game | on demand | on demand |
An optional CI workflow runs lint → pnpm -r typecheck → pnpm test → editor build → pnpm smoke (with a real Chrome) on every push to main and every PR, adding a merge-blocking check that the Vercel gate - which only gates the deploy, and has no browser - can't provide.