Skip to content

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 reads suspended, or interrupted in WebKit once a node is connected: never none), score(), held() (count of inputs currently held), view() ({w, h, dpr, scale, insets}), perf() (with calls and triangles of 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 example x(), lane());
  • step(n, dtMs): with the loop stopped by pause, advance exactly n fixed frames and render (used by the preview capture so a loop can be recorded frame by frame at any machine load); in over, with the loop stopped by a pause after 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:browser

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

SymptomCheck
Game waits or starts in standalone mode inside a hostVerify same origin, expected iframe window, pp: 2, transferred MessagePort and timely init
Ready never arrivesFinish loading without rAF, yield between expensive tasks, and inspect the ten-frame readiness gate
Sound remains lockedForward real user gestures; do not try to unlock audio using only an asynchronous message
Game jumps after resumeUse SDK simulation time and pause-aware timers rather than wall time
Input sticks after leaving the gameRelease it on cancel, lost capture, hidden, blur and pause; verify held-pointer forwarding
Resources accumulate across gamesRegister disposable resources, clear references and wait for disposal acknowledgement
A package install goes to npm unexpectedlyInstall the local protocol and SDK tarballs together; the SDK requires the exact protocol version
Preview images are missingSupply 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.

ParameterMeaning
pptest=1Enable window.__portalTest and diagnostics
ppcapture=1Hide SDK HUD and control overlays for capture; visual time advances through stepping
muted=1Begin muted
tier=low, mid or highSelect a tier without probing; omitted or other values use automatic detection
dprcap=1.5Set a positive maximum base pixel ratio
seed=7Set the initial unsigned seed for reproducible run sequences
insets=62,0,90,0Set 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.

Wire protocol 2 · Manifest 2 · SDK runtime 0.2.1