{
  "name": "app-debugging-reference",
  "title": "App Debugging Reference - Driving the Page with page eval",
  "description": "Drive a deployed page from the CLI: multi-step evals, reload checks, real-file fixtures, in-app error traps, the synthetic camera, and stepping a simulation's clock",
  "guid": "sk_plat_dbrf",
  "category": "App development",
  "requiredTools": [],
  "content": "# App Debugging Reference - Driving the Page with `page eval`\n\nThe long tail of [app-debugging](app-debugging.md) - 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.\n\nEverything here is a flag or a pattern on `gipity page eval <url> <expr>` - see [app-debugging](app-debugging.md) 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).\n\n## `--step` - many checks, one page load\n\n**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.\n\n**`--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: [...] }`:\n\n```bash\ngipity page eval <url> \"document.querySelector('#play').click(), 'started'\" \\\n  --step \"(async()=>{ await new Promise(r=>setTimeout(r,1000)); return document.querySelector('#score').textContent; })()\"\n```\n\n## `--reload` - does the state survive a refresh?\n\n**`--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:\n\n```bash\n# Phase 1 saves state through the app's own UI; phase 2 proves it survives a reload:\ngipity page eval \"<url>\" \\\n  \"(()=>{ document.querySelector('#label').value='Trip'; document.querySelector('#save').click(); return localStorage.getItem('countdown'); })()\" \\\n  --reload \"({ restored: !!localStorage.getItem('countdown'), heading: document.querySelector('#display')?.textContent })\"\n```\n\n`--reload-file <path>` reads the reload expression from a file.\n\n## Feeding the page real bytes and long scripts\n\n**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:\n\n```bash\n# Real binary in, parsed result out. fixtureUrl is injected into the eval scope:\ngipity page eval <url> --fixture ./sample.mp3 \\\n  \"(async()=>{ const buf = await fetch(fixtureUrl).then(r=>r.arrayBuffer()); const tags = await window.MyApp.parseId3(buf); return { title: tags.title, hasCover: !!tags.cover }; })()\"\n```\n\nPass `--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.\n\n**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>`:\n\n```bash\ngipity page eval <url> --file ./probe.js\n```\n\n**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.\n\n**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.\n\n## Catching an error the console didn't show you\n\n(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):\n\n```js\n// in the app's entry, before anything that might throw:\nwindow.__errs = [];\naddEventListener('error', (e) => window.__errs.push(e.error?.stack || e.message));\naddEventListener('unhandledrejection', (e) => window.__errs.push('REJECTION: ' + (e.reason?.stack || e.reason)));\n```\n\n```bash\n# then one eval reads what threw during this load:\ngipity page eval \"<url>\" \"JSON.stringify(window.__errs)\" --wait 4000\n```\n\nFor 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.)\n\nA 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.\n\n## Camera apps: point the headless browser at a picture\n\nThe camera is **not** one of the things the headless browser can't do (see \"What the headless browser can't test\" in [app-debugging](app-debugging.md)) - `--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:\n\n```bash\n# The picture IS the camera. Read whatever the app renders from it.\ngipity page eval <url> --camera ./fist.jpg --wait 6000 \\\n  \"(()=>{ document.querySelector('#start').click(); return new Promise(r => setTimeout(() => r(document.querySelector('#throw').textContent), 4000)); })()\"\n\ngipity page screenshot <url> --camera ./fist.jpg --wait 6000   # see the overlay + label it drew\n```\n\nAny 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.\n\n## Time-dependent logic: step the app's own clock\n\n**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.\n\nWhen the page paints too slowly to trust a wall-clock wait, `page eval` says so:\n\n```\n⚠ Slow render: page painted at 2.6 fps. Waiting on real time (setTimeout) advances animation/physics time far slower than it looks …\n```\n\nThe 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:\n\n```bash\n# Does a dropped block actually fall and settle?\ngipity page eval <url> \"const core = await import('./js/core.js');\n  await core.whenReady();                       // real boot, no guessed sleep\n  const b = core.primitives.createPart({ position:{x:0,y:20,z:0} });\n  core.advance(4);                              // 4s of WORLD time, ~150ms real, deterministic\n  return JSON.stringify({ dropped: +(20 - b._body.translation().y).toFixed(1), asleep: b._body.isSleeping() });\"\n# → {\"dropped\":20.4,\"asleep\":true}\n```\n\nBecause `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.\n\nFor 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.\n\n## Related skills\n\n- [app-debugging](app-debugging.md) - the core loop this reference extends\n- [app-testing](app-testing.md) - testing functions server-side (`ctx.fn.call` / `callAs`, the isolated test DB)\n- [web-vision-mediapipe](web-vision-mediapipe.md) / [web-vision-detect](web-vision-detect.md) - the camera apps `--camera` verifies\n- [3d-engine](3d-engine.md) - the `advance(seconds)` hook the 3D templates export\n"
}
