Appearance
Testing and troubleshooting
Check metadata, deterministic lifecycle behavior and actual browser integration separately. A passing manifest or reducer test does not demonstrate that a game responds to touch, stays silent while paused, or releases its graphics resources.
Check a lifecycle implementation
With @fah/protocol and @fah/conformance installed, save this as check-lifecycle.mjs and run it with Node:
js
import { initial, reduce } from '@fah/protocol/lifecycle';
import { checkLifecycle } from '@fah/conformance';
const failures = checkLifecycle({ initial, reduce });
if (failures.length) throw new Error(JSON.stringify(failures, null, 2));
console.log('Lifecycle checks passed.');The runner compares ordered effects and selected state fields, including pending starts, pause holds, restart and terminal disposal. Exceptions propagate. Replace initial and reduce with your own implementation to check the same contract.
The package also exports lifecycleVectors, manifestFixture and messageFixture, plus JSON fixture subpaths for tools that do not use Node. Clone fixtures before changing them. Fixture attribution is synthetic example data.
Inspect a running game
Open the example with ?pptest=1, or send init.test: true from a host. In the game frame's browser console, inspect:
js
window.__portalTest.state();
window.__portalTest.simTime();
window.__portalTest.held();
window.__portalTest.view();Test hook
With init.test true (or ?pptest=1 standalone), the game document exposes window.__portalTest. Getters inspect state; input, step and autoplay control the game for deterministic tests and captures:
state(),run(),paused(),muted(),simTime()(seconds, stops while paused),audioState()(before the first run it readssuspended, orinterruptedin WebKit once a node is connected: nevernone),score(),held()(count of inputs currently held),view()({w, h, dpr, scale, insets}),perf()(withcallsandtrianglesof the last frame),tier();input(ev):{type: 'down'|'move'|'up', x, y, id?}in CSS px, through the same book as a real pointer (a scripted player for any scheme, aim and stick included; works while stepping);- the game's own getters named in its manifest
test.probes(for examplex(),lane()); step(n, dtMs): with the loop stopped bypause, advance exactlynfixed frames and render (used by the preview capture so a loop can be recorded frame by frame at any machine load); inover, with the loop stopped by apauseafter the end, it draws only: no update runs and no message is sent, the look and the effects move by the stepped time and one frame is drawn (so a recording can show what follows the end);autoplay(on): optional; a simple built-in player good enough to survive 8 s (used to record the preview loop).
Scripted input uses CSS pixels; host start.input uses normalized coordinates.
Run repository checks
In a complete SDK checkout:
sh
npm ci --ignore-scripts
npm run check
npx playwright install chromium webkit
npm run test:browser
BROWSER=webkit npm run test:browsercheck builds the SDK and example, runs Node tests, and installs packed packages into an isolated consumer. The SDK build enforces a 35 KiB gzip bundle budget. Browser tests exercise the actual game in standalone and iframe modes, including quality changes during loading, start, input, pause, restart and disposal, and check failed or external requests.
Documentation is checked separately with npm run docs:build and npm run test:docs in checkouts containing the documentation toolchain. See Build and serve this site.
Common problems
| Symptom | Check |
|---|---|
| Game waits or starts in standalone mode inside a host | Verify same origin, expected iframe window, pp: 2, transferred MessagePort and timely init |
| Ready never arrives | Finish loading without rAF, yield between expensive tasks, and inspect the ten-frame readiness gate |
| Sound remains locked | Forward real user gestures; do not try to unlock audio using only an asynchronous message |
| Game jumps after resume | Use SDK simulation time and pause-aware timers rather than wall time |
| Input sticks after leaving the game | Release it on cancel, lost capture, hidden, blur and pause; verify held-pointer forwarding |
| Resources accumulate across games | Register disposable resources, clear references and wait for disposal acknowledgement |
| A package install goes to npm unexpectedly | Install the local protocol and SDK tarballs together; the SDK requires the exact protocol version |
| Preview images are missing | Supply real captured media; the example build does not create preview files |
Before shipping a game
Exercise real target phones, multiple portrait sizes, reduced motion, mute, shared audio and repeated mounts. Browser automation on a development machine does not establish performance on a phone. Confirm all runtime files resolve on the same origin and all media paths and attribution are complete. Host acceptance and content-pipeline checks are separate from the SDK's test suite.
Exercise the host fixture
After npm run build and npm run example, open http://127.0.0.1:4173/host.html. This local harness mounts Gap Run, performs the real iframe handshake and records received messages in window.messages. Its init enables the test hook and starts muted. It has no player controls, shared audio service or backend; use it to inspect SDK behavior, and use Host integration to build a host.
Run the following in the host page's developer console. It waits for readiness, starts a run, verifies that pause freezes simulation time, restarts in the same document, then disposes the game. Reload the fixture to create another game after disposal. A command uses a new sequence number, and the helper waits for its ack.
js
const waitFor = async (read, label, timeoutMs = 30000) => {
const deadline = performance.now() + timeoutMs;
while (performance.now() < deadline) {
const value = read();
if (value) return value;
await new Promise(resolve => setTimeout(resolve, 20));
}
throw new Error(`Timed out: ${label}`);
};
await waitFor(() => window.messages?.find(m => m.t === 'ready'), 'ready');
let seq = Math.max(0, ...window.messages.filter(m => m.t === 'ack').map(m => m.seq));
const send = async (t, payload = {}) => {
const commandSeq = ++seq;
window.command({ t, seq: commandSeq, ...payload });
return waitFor(() => window.messages.find(m => m.t === 'ack' && m.seq === commandSeq), `${t} ack`);
};
const frame = document.querySelector('iframe');
const game = frame.contentWindow.__portalTest;
await send('start', { muted: true, seed: 7, input: { x: 0.25, y: 0.5, type: 'touch' } });
await waitFor(() => window.messages.find(m => m.t === 'first'), 'first input');
await send('pause');
const pausedAt = game.simTime();
await new Promise(resolve => setTimeout(resolve, 100));
if (game.simTime() !== pausedAt) throw new Error('Simulation advanced while paused');
await send('resume');
const previousRun = game.run();
await send('start', { muted: true, seed: 7 });
if (game.run() !== previousRun + 1) throw new Error('Restart did not create a new run');
const disposed = await send('dispose');
if (disposed.s !== 'disposed') throw new Error('Disposal was not acknowledged');
frame.src = 'about:blank';
console.log('Host lifecycle checks passed.');DevTools focus can trigger the game's blur hold. If a run stays paused, inspect game.state() and restore focus to the game; a host resume alone cannot clear a separate hidden/blur hold. The repository browser checks run this lifecycle without interactive DevTools focus changes.
Standalone test settings
A standalone game synthesizes initialization from these query parameters. In an iframe or native host that supplies init, use the corresponding host fields instead.
| Parameter | Meaning |
|---|---|
pptest=1 | Enable window.__portalTest and diagnostics |
ppcapture=1 | Hide SDK HUD and control overlays for capture; visual time advances through stepping |
muted=1 | Begin muted |
tier=low, mid or high | Select a tier without probing; omitted or other values use automatic detection |
dprcap=1.5 | Set a positive maximum base pixel ratio |
seed=7 | Set the initial unsigned seed for reproducible run sequences |
insets=62,0,90,0 | Set top, right, bottom and left insets in CSS pixels |
For example, open http://127.0.0.1:4173/g/template/?pptest=1&muted=1&tier=low&seed=7&insets=62,0,90,0 to inspect a muted low-tier game with fixed insets. Reduced motion comes from the browser's preference; use browser emulation or the operating-system setting to test it. These settings do not measure phone performance.