# Testing
| Check | Command | Catches |
|---|---|---|
| Type checking | `npm run typecheck` | strict checking of every TS/TSX component, controller, canvas renderer and worker |
| Body and saves | `node --test test/*.test.mjs` | simulation rules, save v1 compatibility, bad eggs and ownership/reset write guards |
| Prompt parity | `node test/parity.mjs` | JS/Python drift in headers, stat words, word lists, look/clock/recall, event lines and offers |
| Model behavior | `train/eval_pet.py CKPT 40` | invalid tags, unresolved lookups, unanswered offers (including after an opener that skipped the answer), decisions that ignore the tummy, chatty idle turns, diaries that do not end |
| Quantization | `train/quant_eval.py CKPT EXPORT data.jsonl` | 4-bit quality loss |
| Data rules | `python3 test/data_rules.py` | sleep/offer rules, corrupt or shifted supervision, equivalent span coverage, diary masks, no-wake/mixed migration and a stand-in sleep teacher |
| Page contract | `node test/contract.mjs` | host mount/config, component ids, documented config keys and accidental imperative rendering |
| Site build | `site/build.sh /tmp/site-out` | unfilled placeholders, a config block that is not valid JSON |
| Script lifecycle | `python3 test/site_tools.py` | failed-build preservation, publication retry/immutability and preview runtime refresh |
| Brain speed and memory | `/bench.html` in every site build | load time, decode tokens/s, prefill time, page memory |
| Browser flow | `playwright-cli` against `test/dev-site.sh` or the live URL | everything the visitor sees |
Run `npm ci` first (Node.js 24+). `npm run format` formats the frontend and
new build/test scripts; `npm run format:check` checks without writing.
`npm test` runs strict type checking, prompt parity, the component/host contract,
Node's built-in body/save tests, Python data rules and script lifecycle checks.
Node executes the erasable TypeScript in the pure modules directly;
no separate test transpiler or browser framework is required.
Real browser checks use the `playwright-cli` skill. A repeatable regression
flow, including a real model download, is available for a **fresh, disposable
persistent profile**:
```sh
playwright-cli -s=pet open http://127.0.0.1:8765/pet/?debug=1 --profile=/tmp/pet-test-profile
playwright-cli -s=pet run-code --filename=test/browser-flow.js
```
It checks edited/cleared hatch names, chat, care offers, healthy medicine,
shelf/memory rendering, real diary generation, catch pointer/keyboard controls,
tab keys, all panels at 1280/768/360 px, cached reload and Web Lock takeover.
The shelf/gift and catch setup use explicit debug tags to make UI assertions
independent of model sampling. Use only a test profile: it creates a pet and
changes its state. Error recovery, imports, offline use and upgrades have
additional recipes below.
`test/fixtures/save-v1.json` is an actual browser export from the previous
vanilla app at commit `6c99c23`, captured with generic Sam/Mochi names on
2026-10-07. The save tests
require it to round-trip without changing any values; this rewrite keeps v1.
`test/site_tools.py` uses Python's standard library and the actual shell
scripts with disposable model/runtime stand-ins. It needs no downloaded
weights, teacher, GPU or browser. It exercises failed release/config/render
builds without destroying old output, runtime-publication failure followed by
same-id retry, immutable artifacts and permission overrides, and repeat
previews over HTTP, including obsolete runtime removal and `--rebuild`.
## Preact migration check receipt (2026-10-07)
Measured in headless desktop Chromium against a local build, with the real v6
model and vendored runtime; these are functional checks, not new speed results.
- `npm test`: strict types, 300 parity states/15 lists with zero mismatches,
component/host contract, eight body/save tests and ten data-rule tests passed.
`npm run format:check`, clean `npm ci`, root and subpath builds also passed.
- `test/browser-flow.js` passed: fresh download/hatch, chat, food and healthy
medicine offers, memory/shelf, real diary generation, pointer/keyboard catch,
all panels at 1280/768/360 px, cached reload and tab takeover. The regular
flow had no browser console errors or warnings. LCD screenshot inspected.
- Loaded a save from the previous vanilla app; name, owner and transcript
survived. A v99 save stayed untouched after rendering/ticking. Valid save
import, export and hard reset worked in a disposable profile.
- A deliberately missing worker script showed the retry control at 360 px;
retry loaded the real brain and preserved the egg and edited name.
- Upgrade dialog/download/swap/cache cleanup passed using two local aliases of
the same v6 export (a stand-in for two compatible releases). The pet survived
and only the selected model's files remained. This does not establish v5/v6
prompt compatibility; the release's `older` list remains empty.
- With the preview server stopped, reload loaded the cached page, both app
bundles, runtime and brain; isolation stayed enabled and a real chat reply
completed. Static explanations/build details were visible with JavaScript off.
- Not re-tested for this rewrite: physical phones, Firefox's throttled download,
actual install prompts, background alert delivery, or the 90-second
one-thread fallback. Their existing logic remains in the typed controllers.
## PR #1 integration check receipt (2026-10-07)
The fixes were integrated into the Preact/TypeScript app and checked in
headless desktop Chromium against a local build with the real v6 brain.
- Formatting, strict types, 300 parity states/15 lists with zero mismatches,
the page contract, eight body/save tests, 20 data-rule tests and four script
lifecycle tests passed. Root and subpath builds passed.
- `test/browser-flow.js` passed, including real download/generation, diary,
care, catch controls, cached reload, tab takeover and all panels at
1280/768/360 px.
- Controlled-clock checks applied a three-hour awake gap, a one-minute gap,
a half-hour sleeping gap and a ten-day absence exactly once. The ten-day
absence matched the seven-day simulation cap.
- Blocked session storage still booted at 360 px. Injected worker-constructor
and initial-message failures released the worker and showed retry; restoring
the native API loaded v6 while preserving the egg and edited name. No
uncaught errors occurred in these recovery checks.
- These checks did not retrain a model or remeasure speed. Physical phones,
Firefox, offline reload, upgrades, alerts and the watchdog stall fallback
were not re-tested for this merge.
## Preview server
```sh
export MODEL=$DATA/release/tokenling-v6 RUNTIME=$DATA/release/runtime # from export.sh and vendor.sh
test/dev-site.sh /tmp/dev-site & # stages and serves http://127.0.0.1:8765/pet/
test/dev-site.sh /tmp/dev-site --rebuild # after editing web/ TypeScript/components or the page
```
The script writes a release description for the preview and runs
`site/build.sh` (the same build as a real deploy, including `test/bench.html`),
symlinks the exported model, copies the runtime, and serves everything with
the COOP/COEP headers, so `crossOriginIsolated` is true and WASM threads work.
A full run refreshes the local runtime from `RUNTIME`, removing obsolete files
only after the new copy succeeds. `--rebuild` changes page/app files only and
leaves the staged runtime and models alone.
Options are environment variables, documented in the script header: `PORT`,
`BASE` (the URL base path, default `pet`), `MODEL_ID`, `OLDER` (export
directories staged as older brains), `NEWS`, `FEATURES`, and `TRAINED` and
`SOURCE` (text for the page's details). `ISOLATE=0` serves without the
headers for A/B tests. `ONNX_BYTES_PER_SECOND` (default `0`, unlimited)
throttles only `.onnx` responses, including URLs with a query string, for
slow-download checks; other files retain normal speed.
**Speed and memory:** open `http://127.0.0.1:8765/pet/bench.html` (query
`threads=N`, `qdq=1`, `prepack=1`, `dtype=…`, `model=ID`) and read
`window.res`; `await window.mem()` returns page memory in MB.
Stop it with `pkill -f '[s]erve.py /tmp/dev-site'`.
## Debug panel
Open the page with `?debug=1` (or `#debug`) to get a **debug** tab after the
others. It is not saved or shown otherwise, and it sends nothing anywhere.
- **Pet:** the running brain, stage, day and exact age, and whether an offer
is open; buttons that set the age to the start of each stage; an age in days
to set directly (the stage follows from the age and no "grew" line is
written, so going back to baby works); sliders for the four stats; a sick
switch; current wake probabilities and persistence count.
- **Write today's page:** debug-only write/rewrite, disabled while the brain
is busy or unavailable or a game runs. It uses today's log so far even with
fewer than two entries. An early page suppresses automatic bedtime writing;
rewrite later to include later events. A failed rewrite retains the old page.
- **Context:** the last 20 prompts sent to the brain (chat lines, lookups and
diaries), newest first, each exactly as the worker built it after trimming
to the context window, with the model's output highlighted and the stop
reason. "copy latest" copies the newest prompt and output; "copy full chat"
copies the current header and the whole stored chat (more than the model
sees once the chat is long). Prompts are kept in memory until the page
reloads.
Use it to see why the pet did something: copy the prompt and replay it
against a checkpoint, or look at which lines the context window dropped.
## Browser recipes
### Slow Firefox download and recovery
Start a fresh preview/profile with `ONNX_BYTES_PER_SECOND=2000000` in the
preview server's environment and open it with
`playwright-cli -s=slow open URL --browser=firefox --profile=/tmp/slow-profile`.
Wait for `navigator.serviceWorker.controller` before getting an egg. The
97 MB model transfer must run past 30 seconds and reach a ready brain; before
the fix it ended with `Error in input stream` at roughly 30 seconds on Linux
Firefox. Leave the storage-persistence prompt unanswered: it must not delay
the download. Check cached reload, hatch/chat and offline reload afterward.
For recovery, interrupt a model response before completion. The egg must
remain unhatched with the error and `setup-retry` visible. Restore the network,
click retry, and check that the same egg and entered pet name survive and
the download reaches ready. Check worker script failure too: its error must
release the worker so retry can start. Test the retry row at 360 px.
An interrupted response may instead report `protobuf parsing failed`; check
that the bad ONNX entry is evicted and retry downloads a fresh copy.
### Catch-up and restricted browser APIs
Use a disposable profile for controlled-clock checks. Freeze `Date.now()`,
let an animation frame establish the live tick baseline, then advance it and
dispatch `visibilitychange` while the document is visible. Set an awake pet's
energy to 80 with other needs comfortable and no poop due: after a three-hour
return energy should be 65 and stay there on the next frame, not decay twice.
A one-minute gap should still be applied once by the live tick. With lights
off, a sleeping pet at energy 10 reaches 21 after half an hour, without a
second recovery on the next frame. Also check that a ten-day absence consumes
only the capped seven-day replay and is not reapplied by the live tick. Reload
and discard the test save after replacing the clock.
Before submitting "get an egg", inject a throwing `Worker` constructor, then
repeat with a real worker whose initial `postMessage` throws. Both should
reach `brain.state === "error"` immediately, release the worker and expose
`#setup-retry`. Restore the native API and retry: a new worker must start with
the same egg and edited pet name. This exercises startup recovery without
requiring model weights; successful download/inference needs the regular
model preview below.
For blocked storage, use `page.addInitScript` to make the `sessionStorage`
getter throw `SecurityError` before loading the page. The app must still boot
without an uncaught error or horizontal overflow at 360 px. Check normal
two-tab ownership and "use it here" separately with session storage available.
### Regular flows
Use a **named session with a persistent profile** whenever the brain is
involved. An in-memory profile cannot store the model: Cache Storage `put`
fails with "Unexpected internal error" and the brain reloads every time.
```sh
playwright-cli -s=pet open http://127.0.0.1:8765/pet/ --profile=/tmp/pet-profile
playwright-cli -s=pet fill "#owner-name" "Sam" --submit
```
The owner step ("get an egg") starts the brain download. **Wait for it, then
hatch:**
```sh
playwright-cli -s=pet run-code "async page => {
await page.waitForFunction(() => ['ready','error'].includes(window.tokenling.brain.state), null, {timeout: 300000});
await page.fill('#pet-name', 'Mochi'); await page.click('#hatch-btn'); }"
playwright-cli -s=pet --raw eval "document.querySelector('#brain-status').textContent"
```
**Offers and pats:** `window.tokenling.act('feed', 'cookie')` (or `bath`,
`medicine`, `play`, `pat`), wait for `!window.tokenling.brain.busy`, then read
the chat: the offer line should be followed by a pet line with `[yes]` or
`[no]`, and `state().today.log` shows "ate a cookie" or "said no to a
cookie". Idle turns start a few seconds after the brain is ready; pause them
by keeping the tab hidden or reading `state().chat` right after a turn.
**Talk to it and read the transcript:**
```sh
playwright-cli -s=pet run-code "async page => {
const say = async (m) => { await page.fill('#chat-input', m); await page.press('#chat-input', 'Enter');
await page.waitForTimeout(500);
await page.waitForFunction(() => !window.tokenling.brain.busy, null, {timeout: 180000}); };
await say('what time is it?'); await say('my favorite animal is the frog');
return await page.evaluate(() => window.tokenling.state().chat.join('\n')); }"
```
`window.tokenling` exposes `state()` (the live state object, so tests can set
stats directly), `brain` (including `id`, the running model, and `tps` and
`prefillMs` of the last turn), `upgrade`, `view`, `act(kind, arg)`,
`petTurn(kind?)`, `wantTurn(kind?)`, `renderAll()` and
`writeDiary(dateKey?, rewrite?)` (`rewrite=true` is honored only for today in
debug mode; tests can call this or
seed `state().history` with a past day and wait for `maybeDiary()`).
**Sleep:** in debug mode, `tokenling.debug.setWakeRoll(() => 0.5)` fixes
only disturbance randomness; `setWakeRoll(null)` restores it. Reset the counter
with `resetDisturbances()`. At energy 10 a first message stays asleep with
that roll; at energy 70 it wakes. A roll of 0.99 leaves pats and lights asleep.
Offer a cookie while hungry and while stuffed: the offer itself never wakes;
only an accepted reply writes `woke up` and eats. Debug also exposes `applyTags`
and `simulate` for body checks. Confirm the sleep grime timer pauses and
resumes, and no more than two dream entries crowd the day log.
Optional `eval_pet.py CKPT 80 REPORT.json` saves metrics and all new probe
transcripts. The v6 eval adds idle/lights-off/message/pat dream validity, mumble length,
dream spread, awake replies after waking, sleeping offer answer/acceptance
rates by energy, tummy and cookie versus carrot, and `ugh`/`literally` and
positive hunger claims while stuffed by stage. It also probes medicine offers
while healthy/sick and awake/asleep. They run after the earlier
metrics. Use the same eval version for both checkpoints; results are samples,
not guarantees for every pet.
**Animations:** each CLI call takes about a second, longer than most
animations. Trigger and capture inside one `run-code`:
```sh
playwright-cli -s=pet run-code "async page => {
await page.evaluate(() => { window.tokenling.state().stats.tummy = 20; window.tokenling.act('feed','rice ball'); });
await page.waitForTimeout(300);
await page.locator('#pet-lcd').screenshot({path: 'eat.png'}); }"
```
**Mobile:** open with `--mobile` (360 px wide) and check that
`document.documentElement.scrollWidth === innerWidth`. Take a full-page
screenshot and look at the chat input, the action grid and the notes.
**Catch controls:** use a real pointer or touch gesture to hold each pad button,
slide off it, and release; movement should continue while held and stop on
release, cancellation, lost capture, blur or a hidden page. Try overlapping
directions, rapid taps, arrow/A/D keys, and Space/Enter on a focused pad button.
The pad should neither select text nor zoom. Check page scrolling and pinch
zoom outside it too. For the brain reminder, check download, upgrade, progress
and error states above the device at 360 px and desktop widths, including
after switching tabs.
**Offline:** after the brain has loaded once, stop the preview server and
reload: the page, app and runtime come from the service worker, the brain
from Cache Storage, and `crossOriginIsolated` stays true.
`playwright-cli network-state-set offline` is a weaker test (it left
`navigator.onLine` true here).
**Upgrade:** start the preview with only the older model
(`MODEL=$DATA/release/tokenling-v2`), hatch a pet, then restart it with
`MODEL=…/tokenling-v3 OLDER=…/tokenling-v2` (same port, same profile) and
reload. The pet should wake on v2 with the upgrade dialog open; "upgrade"
downloads v3 while v2 runs (`window.tokenling.upgrade`), swaps, and leaves only
v3's files in `transformers-cache`. `playwright-cli requests` should show one
request for the new `.onnx`.
For the v5 → v6 format change, stage v6 with no `OLDER` instead: the saved
pet must doze until v6 is downloaded. Check that its name, memories, chat and
diary survive the download, and that cached v5 is never loaded by the v6 app.
**Feature gating:** give a component a `brainHas("x")` disabled/class/title
gate and `data-needs="x"` for inspection, set
`window.tokenling.brain.id` to an older id that lacks `x`, call
`window.tokenling.renderAll()`, and the control is disabled with the class
`needs-brain`; set it back and it is enabled again.
**Two tabs:** open the page in a second tab: it shows "open in another tab"
and does not run the pet. "use it here" moves the pet there, and the first tab
shows the notice.
**Bad egg:** write a broken or newer save from a same-origin page that does
not run the app (the running app saves every second and would overwrite it),
for example `goto /assets/OFL-nunito.txt`, then
`eval "() => { localStorage.setItem('tokenling.v1', JSON.stringify({...JSON.parse(localStorage.getItem('tokenling.v1')), v: 99})); return 1 }"`,
and go back to the page. Check that the save is untouched, then try "start
anew" (it moves the save to `tokenling.bad`) and importing the old save.
**Alerts:** grant permission with
`run-code "async page => page.context().grantPermissions(['notifications'], {origin: ORIGIN})"`,
tick `#alerts-toggle`, and read the plan from the `tokenling-alerts` cache.
Headless Chromium always reports the page as visible, so the worker never
alerts while it is open; to test delivery, write a plan with a past item,
navigate the tab to another page in scope, post `{type: "alerts"}` to the
worker and read `registration.getNotifications()`.
**Install:** the console shows "Banner not shown: beforeinstallpromptevent.preventDefault() called"
when the page is installable; after hatching, the install suggestion shows in
the chat.
**Console:** `playwright-cli console` and `console warning`. Anything there,
especially `ERR_BLOCKED_BY_RESPONSE`, is worth chasing.
**Close up:** `playwright-cli close-all`. Do not leave sessions or profile
directories running.
## What to check after a change
- **Art or animation:** screenshot `#pet-lcd` at each stage; check eyes, mouth,
decorations, the shadow and nothing clipped at the LCD edges.
- **Simulation:** set stats through `window.tokenling.state()` and call `act`;
check meters, event lines and the day log.
- **Prompt or tools:** parity test, then `eval_pet.py`, then a live chat that
triggers each lookup and checks the app wrote the right `{result}`.
- **Page layout:** desktop (≥1280), tablet and 360 px; keyboard-only through
the tabs and action buttons; the explanation section reads without
JavaScript.
- **Saves, tabs, PWA:** the upgrade, two tabs, bad egg, offline and alerts
recipes above after touching `load()`, the boot sequence, `sw.js` or the
brain loader.
- **Brain loading:** fresh profile (the egg's download), cached reload, and
`bench.html` for speed and memory after any change to `quantize.py` or the
session options in `brain.ts`.
- **After deploy:** the same flow against the live URL with a fresh persistent
profile, plus the live checks in `deployment.md`.
## Phones
Automated checks use desktop Chromium with a 360 px viewport. Speed and memory
on real phones have not been measured; open `bench.html` on one to do so.