The long tail of app-debugging - load that skill first for the core loop (page inspect → console/resources → logs fn → fn call → page screenshot). This reference is for when reading the page isn't enough and you have to drive it: verify state across a reload, feed the app a real file or a real camera frame, or advance a simulation's clock.
Everything here is a flag or a pattern on gipity page eval <url> <expr> - see app-debugging for the base command, its flag table, and the expression rules (it's an expression, not a statement block; return the value, don't post-process the output).
--step - many checks, one page load
Each separate gipity page command is a fresh page load. Every page eval / page inspect invocation opens the URL anew, so anything you set up in one command (a window.onerror trap, a global you assigned) is gone by the next - you can't "install a probe, then read it" across two commands.
--step runs more expressions against the SAME loaded page - each one after the last, sharing everything the page already paid for (a vision model, a booted game, a connected socket) and everything the previous step left behind. So a several-part check ("it detects my throw" → "it scores the round" → "the tally survives the next round") is one page load, not one per assertion. --json returns { result, stepResults: [...] }:
gipity page eval <url> "document.querySelector('#play').click(), 'started'" \
--step "(async()=>{ await new Promise(r=>setTimeout(r,1000)); return document.querySelector('#score').textContent; })()"
--reload - does the state survive a refresh?
--reload gives you a two-phase check across a reload: the expressions run, the page reloads in place (localStorage/sessionStorage/cookies preserved), then the --reload expression runs against the post-reload DOM. That is the direct test for "remember it if I close the tab and come back" - seed state, reload, assert the restored UI - no same-origin-iframe harness needed:
# Phase 1 saves state through the app's own UI; phase 2 proves it survives a reload:
gipity page eval "<url>" \
"(()=>{ document.querySelector('#label').value='Trip'; document.querySelector('#save').click(); return localStorage.getItem('countdown'); })()" \
--reload "({ restored: !!localStorage.getItem('countdown'), heading: document.querySelector('#display')?.textContent })"
--reload-file <path> reads the reload expression from a file.
Feeding the page real bytes and long scripts
Verifying against a real file (MP3, image, PDF, a multi-KB data blob)? Use --fixture, don't inline the bytes. The eval body is capped at 64 KB (it's delivered as one shell argument to the sandbox), and the eval runs in the page - so bulk/binary data can't be pasted into the expression. --fixture <path> hosts a local file as a public asset, hands your eval a fetch-able fixtureUrl, runs the eval, and deletes the hosted copy afterwards - no manual upload/deploy/cleanup dance. The page fetches it in-page and drives the app's own code over the real bytes:
# Real binary in, parsed result out. fixtureUrl is injected into the eval scope:
gipity page eval <url> --fixture ./sample.mp3 \
"(async()=>{ const buf = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); const tags = await window.MyApp.parseId3(buf); return { title: tags.title, hasCover: !!tags.cover }; })()"
Pass --fixture more than once for several files; each is exposed under fixtures['<basename>'] (and the first as fixtureUrl). Any size, binary-safe - the bytes travel over HTTP, never through the eval body.
Large or multi-line eval body? Put it in a file with --file <path>, don't inline it. A long or multi-line expression pasted as a shell argument is where quoting breaks - a stray quote/paren/newline and the shell hands page eval a mangled (or empty) <expr>, so the CLI sees a missing argument and prints the usage help instead of running anything. --file ./probe.js reads the body from disk verbatim (no shell quoting in play) and runs it exactly as if it were the inline <expr>:
gipity page eval <url> --file ./probe.js
App-relative import() works. A dynamic import('./packages/i18n/index.js') inside the eval resolves against the page URL, so you can load and drive the app's own ES modules without hand-building the deployed /account/project/ path. Only literal ./ and ../ specifiers are rewritten; absolute paths and full URLs pass through unchanged.
The eval runs against the live page with a ~20s in-page execution budget. A single expression that awaits several slow things (network, animation timers, polling a selector in a loop) can blow that budget; you'll get a timeout message telling you to split it. Prefer one focused expression per fact, or use --wait-for <selector> to gate on readiness instead of await-ing inside the body.
Catching an error the console didn't show you
(Only the gipity page CLI needs this - the web agent's browser tool is a sticky session that persists across calls and auto-captures errors.) To catch a boot-time or runtime error, the page itself has to record it, then you read it in a single eval. Add a tiny logger to the app's own boot (it ships with the app, so it's present on every load):
// in the app's entry, before anything that might throw:
window.__errs = [];
addEventListener('error', (e) => window.__errs.push(e.error?.stack || e.message));
addEventListener('unhandledrejection', (e) => window.__errs.push('REJECTION: ' + (e.reason?.stack || e.reason)));
# then one eval reads what threw during this load:
gipity page eval "<url>" "JSON.stringify(window.__errs)" --wait 4000
For errors that only surface after async work or a state change, give them time with --wait, or gate on --wait-for "<selector that appears once settled>". (page inspect's Console already captures uncaught errors for the load it snapshots - reach for the in-app logger when you need errors from a specific later moment, or a value the console doesn't show.)
A message-less error: (...) line in Console is a cross-origin "Script error" from a third-party <script> (e.g. a CDN) loaded without crossorigin="anonymous" - not your own logic (the Gipity SDK sets it, so its errors show real messages; the window.__errs trap can't see cross-origin ones). Add crossorigin="anonymous" to that <script> (the CDN must also send Access-Control-Allow-Origin) to read the real message.
Camera apps: point the headless browser at a picture
The camera is not one of the things the headless browser can't do (see "What the headless browser can't test" in app-debugging) - --camera <image> gives page eval / screenshot / inspect a synthetic webcam that plays your image on a loop (and a synthetic mic, so getUserMedia never prompts or fails). The app's real getUserMedia → inference → onResult → UI pipeline runs against it, so you read the app's own output instead of a hand-fed fake:
# The picture IS the camera. Read whatever the app renders from it.
gipity page eval <url> --camera ./fist.jpg --wait 6000 \
"(()=>{ document.querySelector('#start').click(); return new Promise(r => setTimeout(() => r(document.querySelector('#throw').textContent), 4000)); })()"
gipity page screenshot <url> --camera ./fist.jpg --wait 6000 # see the overlay + label it drew
Any still image works (PNG/JPEG/WebP/GIF, local path or URL). The model downloads and compiles on the first frame, so the script gets a roomier in-page budget on --camera runs (raise it with --timeout <ms> if a big model needs longer). That load is the expensive part - check everything about the app in ONE camera run, using --step (above) rather than a fresh --camera eval per assertion; the model stays warm across steps. Don't rebuild the app to expose test hooks on window: a debug surface compiled into the app the user opens is not verification, and it isn't needed.
Time-dependent logic: step the app's own clock
Never wait on real time - step the simulation. The headless browser has no GPU, so a WebGL scene software-renders at ~2-3 fps. Game engines cap their per-frame delta for stability (the 3D templates cap at 1/30s), which means simulated time advances only as fast as frames paint - roughly a twelfth of wall time. Sleep 3 real seconds expecting a 3-second charge and the player creeps about one unit - the assertion fails as a false negative, not an app bug. It bites physics, animation, canvas, and 2d-game timers alike.
When the page paints too slowly to trust a wall-clock wait, page eval says so:
⚠ Slow render: page painted at 2.6 fps. Waiting on real time (setTimeout) advances animation/physics time far slower than it looks …
The fix is to advance the simulation yourself, without rendering - deterministic and ~350× faster. The 3D templates export advance(seconds) for exactly this; it runs the same physics/player/game/feature chain a real frame runs, just without the paint:
# Does a dropped block actually fall and settle?
gipity page eval <url> "const core = await import('./js/core.js');
await core.whenReady(); // real boot, no guessed sleep
const b = core.primitives.createPart({ position:{x:0,y:20,z:0} });
core.advance(4); // 4s of WORLD time, ~150ms real, deterministic
return JSON.stringify({ dropped: +(20 - b._body.translation().y).toFixed(1), asleep: b._body.isSleeping() });"
# → {"dropped":20.4,"asleep":true}
Because advance() runs a fixed step, the same seconds gives a bit-identical result every run, on any machine - assert exact values, not tolerances. Advance in whole seconds until the bodies you care about report isSleeping(), rather than picking a duration and hoping.
For an app without such a hook, expose one: keep the step function pure (function tick(dt)), call it from your requestAnimationFrame loop, and put it on window so an eval can drive it directly. Don't setTimeout and hope.
Related skills
- app-debugging - the core loop this reference extends
- app-testing - testing functions server-side (
ctx.fn.call/callAs, the isolated test DB) - web-vision-mediapipe / web-vision-detect - the camera apps
--cameraverifies - 3d-engine - the
advance(seconds)hook the 3D templates export