Skip to content

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 mode

The 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.ts enforces the dependency matrix at the source level.
  • npm run size:player checks 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.ts is the single definition of the post-script simulation stage, consumed by both the player and the editor viewport. sim.test.ts fails 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)
ScriptWhat it proves on a real device
gpu-check.tsEvery shader compiles and every pipeline builds (detail below)
gpuComputeCheck.tsThe GPU compute executor behind api.gpu: kernel output, padded texture rows and buffers read back correctly
gpu-compute-frame.tsCompute output drawn by a material, through the renderer
blend-frame.tsMaterial.blend (alpha, additive, premultiplied) gives the expected pixels
post-options-frame.tsThe acesHill tonemap, the output dither and the two bloom modes
render-target-frame.tsRender layers, render textures and target cameras
update-graph-check.tsEvery 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.tsSimulations through the shipped Renderer: update, copy and clear steps, repeats, the step uniform, Reset
instanced-draw-check.tsInstanced 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:

  1. 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 the SSAO_WGSL/AOBLUR_WGSL pair, plus each shipped starter-content material composed into the mesh shader (MESH_MATERIAL_WGSL) - reporting compile errors with line/column.
  2. 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.
  3. Runs the cull compute kernel and reads the result back, asserting it matches a CPU reference set.
  4. Probes GPU capabilities it depends on (e.g. indirect-first-instance, and whether @builtin(instance_index) includes firstInstance).

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).

WalkCovers
editor-walk-e1.mjs, editor-walk-e1-2d.mjsThe 2D Scene view and render textures
editor-walk-e2.mjsSimulations built and run in the editor
editor-walk-instanced.mjsCreate > 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.mjs

scripts/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.mjs

scripts/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 . --fix

A 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 ​

GateCommandCIVercel deploy
Unit testspnpm test✅✅ (pnpm test && pnpm build)
Typecheckpnpm -r typecheck✅via editor build
Lintpnpm lint✅-
Editor boot smokepnpm smoke✅ (real Chrome)✗ (no browser)
GPU checkspnpm test:gpuon demandon demand
Golden framepnpm goldenon demandon demand
Editor walksnode scripts/editor-walk-*.mjson demand✗
Docs screenshotspnpm docs:shotson demand✗
Game boot smokepnpm smoke:gameon demandon 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.

See also ​

Awaken: browser-native WebGPU game engine.