Testing
Every public part of Moonspell has its own checks. Run the ones for what you changed before opening a pull request (Contributing has the short list).
Server
npm run typecheck # tsc over the app, Nova (agent/) and the workflows
npm run lint # eslint (next core-web-vitals + typescript)
npm test # node --test over src/**/*.test.ts and edge/src/**/*.test.ts
npm run build # next build, with eve and the Workflow SDK compiled in
Builds and tests need no environment variables: the database and SDK clients are created lazily.
The unit tests sit next to the code they test, in src/lib/:
| Test | Covers |
|---|---|
client.test.ts | Each app gets its own export of a version, and the other platforms' URLs never leave the server |
web-play.test.ts | The page that runs a web export, and what may reach it |
community-drafts.test.ts | How the run script picks a draft's branch |
judge.test.ts | Reading the judge's verdict |
credit-prices.test.ts | Credit arithmetic: model calls, sandbox time, asset quotes and refunds |
sign-in.test.ts | Google and Apple sign-in rules |
models.test.ts, sprite-sheet.test.ts | The builder's 3D model and sprite requests, turning generated meshes to face forward, and cutting generated images into sprite cells |
playtest.test.ts, gameplay-clip.test.ts | Parsing playtest.json, and picking a feed clip from a recording |
edge/src/meter.test.ts runs with them and covers reading Anthropic's streamed usage.
An end-to-end run needs the whole hosted environment, including the private bundling CLI in the sandbox snapshot, so it cannot be run from a public clone. See Development setup.
Edge Worker
cd edge
npm install
npm run typecheck
printf 'REALTIME_SECRET=any-local-test-secret\n' > .dev.vars
npx wrangler dev --local --var ORIGIN_URL:http://localhost:3000 # with npm run dev on :3000
Then, in another terminal:
curl -s localhost:8787/rt/health # {"ok":true,"realtime":true}
curl -si localhost:8787/.well-known/apple-app-site-association # 200, application/json, no location header
curl -si localhost:8787/g/00000000-0000-0000-0000-000000000000 # proxied to Next (not found for an unknown build)
Publish and subscribe to one run with tokens signed by the same secret:
mint() { REALTIME_SECRET=any-local-test-secret node -e '
const c=require("node:crypto"); const [run,scope]=process.argv.slice(1);
const p=Buffer.from(JSON.stringify({run,scope,exp:Math.floor(Date.now()/1000)+600})).toString("base64url");
console.log("crt1."+p+"."+c.createHmac("sha256",process.env.REALTIME_SECRET).update(p).digest("base64url"))' "$1" "$2"; }
curl -s -X POST localhost:8787/rt/runs/test-run/events \
-H "Authorization: Bearer $(mint test-run publish)" -H 'Content-Type: application/json' \
-d '{"events":[{"type":"progress","at":"2026-01-01T12:00:00Z","text":"hello"}]}' # {"ok":true,"accepted":1,...}
npx wscat -c "ws://localhost:8787/rt/runs/test-run?token=$(mint test-run subscribe)" # first message: the snapshot
Also worth checking: a wrong token gets 401 on both routes, 101 events get 413, and an event without type gets 400. The protocol is described in Live progress.
Engine and game template
From templates/crescent-game/:
npm run typecheck # tsc --noEmit
npm test # unit tests in src/engine/**/*.test.ts
npm run export:web # a static web build in dist/
The unit tests cover the pure parts of the engine: physics, paths, zones, the level pieces, the character controller, cameras, bots and the nav grid, the combat kit, saves, the turns kit, game feel and the modules. A change that touches how a game looks or plays is also checked by playing it in a browser with npm run web; see Web. The same commands check any game repo made from the template.
The worker in a hosted run checks itself the same way: a typecheck and an export on every checkpoint (see Nova and the worker).
Game backends
The backend scaffold that games copy (templates/crescent-game/.claude/skills/realtime-backend/scaffold/backend/) has its own smoke test:
cd backend
npm ci
npm run typecheck
npm run db:local
npm run dev & # wrangler dev --local
node test/smoke.mjs # WebSocket and HTTP checks against the local Worker
node test/smoke.mjs <url> --read-only # against a deployed backend
See Backend overview.
Contracts, specs and docs
| What | Command |
|---|---|
| Generated contract files are current | npm run contracts -- --check |
| What each app is missing | npm run parity |
| These docs build, links included | npm --prefix docs-site run build |
Playtests and feed videos
A game's video on the feed is the recording of a playtest it passed. Each game's builder writes a playtest.json (the template's playtest skill): steps such as press a label, hold, tap or drag a point, expect a label, and a {"mark": "playing"} step after which the recording counts. src/lib/playtest.ts reads it, and src/lib/gameplay-clip.ts picks the liveliest stretch after the mark. The playtests themselves run against the private iOS app, with npm run playtest, so they cannot be run from a public clone. A crash, a missing label or a state that never changes fails the test; a failed game keeps its cover on the feed, and game_playtests records why.