# Debugging a Deployed App

After every `gipity deploy`, verify the result - don't assume it worked. The deploy → **inspect** → fix loop is how you catch a blank page, a 500, or a broken asset before the user does. These commands are the inspect half.

## `gipity page inspect <url>` - is the page healthy?

The first thing to run after a deploy. Loads the page in a real browser and reports what's wrong:

```bash
gipity page inspect https://dev.gipity.ai/<account>/<project>/
```

In the build loop, prefer `gipity deploy dev --inspect` - it deploys and runs this same report on the live URL in one command. The standalone form is for re-checking a page without deploying (or with the extra flags below).

Output:
- **Title, element count, page weight** - a near-empty element count means the page didn't render
- **Timing** - TTFB, DOM ready, load
- **Console** - every console message the page logged; JS errors show up here
- **Failed resources** - requests that 404'd or timed out (broken `src`/`href`, missing assets)
- **Layout** - flags horizontal overflow (content wider than the viewport → a stray scrollbar / broken responsive layout); `--all` lists the offending elements

| Flag | Effect |
|---|---|
| `--all` | Also report render-blocking resources, files >100KB, oversized images, overflowing elements, and LCP detail |
| `--wait <ms>` | Settle time after DOMContentLoaded before capturing (default 500) - raise it for late async work |
| `--wait-for <selector>` | Wait until a CSS selector appears before capturing (deterministic; replaces `--wait`). Use for apps that render after an async boot, e.g. `--wait-for "#app:not([hidden])"`. `--wait-timeout <ms>` caps it (default 5000). |
| `--device <name>` | Inspect as a real touch device (`mobile`, `tablet`, or an exact name like `iPhone 16 Pro` / `Pixel 9`) — touch events, mobile user-agent, DPR. Use it to catch console errors and overflow in the **mobile** layout. |
| `--no-reprobe` | Skip the automatic second probe that filters one-time transient console errors - faster, but a cold-load transient may be reported as real |
| `--no-truncate` | Show full URLs instead of middle-ellipsis |
| `--json` | Machine-readable output |

**Diagnosing a blank or broken page:** check **Console** for the JS error and **Failed resources** for anything that didn't load - a wrong asset path or an uncaught exception is almost always one of those two. An app that boots asynchronously may be snapshotted before it renders; if Console looks empty but the page seems blank, re-run with `--wait-for "<a selector that only exists once booted>"` to capture the settled state, not the loading screen.

**If `page inspect` or `page screenshot` fails with "produced no output within Ns", retry it once.** That error means the browser session was still busy with an abandoned earlier command, not that your page is broken or "too heavy to load headlessly". The session is reset automatically when this happens, so the retry gets a clean browser. Heavy WebGL/WASM apps (Three.js, Rapier, Phaser) inspect fine — a page whose external CDN genuinely hangs still returns a usable report, flagged `navigationIncomplete` with the culprit under **Failed resources**. Don't conclude the inspector can't handle your app; conclude that from a *second* identical failure.

`page inspect` does not produce an image - it reports health signals. For a screenshot, use `gipity page screenshot <url>` (below); that's the one command for captures.

## `gipity page screenshot <url>` - what does it actually look like?

Captures a PNG. With no `-o`, it auto-saves to `screenshots/ss-<host>-<yyyy-mm-dd_hh-mm-ss>.png` — a timestamped, collision-safe name in the project's screenshot history. That folder syncs to Gipity (the capture is also stored server-side at the same path, so the web view shows it instantly) but is **never deployed** to the CDN — it's a browsable visual record of the app being built. Let it use that default; don't dump captures to `/tmp` with `-o`. Add `--ephemeral` for a throwaway capture that skips the history. To look at the result, `read` the saved path — and in the web view, that read shows up as an inline thumbnail the user can click into a full-screen viewer.

```bash
gipity page screenshot https://dev.gipity.ai/<account>/<project>/
gipity page screenshot <url> --full                 # whole scrollable page
gipity page screenshot <url> --device mobile,desktop # one load, multiple viewports
```

| Flag | Effect |
|---|---|
| `--full` | Capture the full scrollable page (default: viewport only). Scrolls the page through first, so scroll-reveal / fade-in-on-scroll sections (IntersectionObserver-driven, held at `opacity:0` until in view) render into the shot instead of photographing blank below the fold. |
| `--device <names>` | Device presets: `default`, `desktop`, `laptop`, `tablet`, `mobile` (comma-separated). `mobile`/`tablet` emulate a **real touch device**; also takes exact names (`iPhone 16 Pro`, `Pixel 9`, `iPad Pro`, `Galaxy S25`, …) |
| `--viewport <WxH[@dpr]>` | Raw viewport, e.g. `414x896@2` (sizes the window only — no touch, no mobile user-agent) |
| `-o <file>` | Output filename (single viewport only) |
| `--post-load-delay <ms>` | Delay after load before capture (default 1000) |
| `--action <js>` | Run JS in the page before capturing (e.g. click a button), then settle again — for a state that only appears after an interaction |
| `--json` | Output metadata (page title, status, performance) instead of a summary |

Use `--device mobile,tablet,desktop` to check responsive layout in one shot.

**Shooting mobile UI: use `--device mobile`, not `--viewport 390x844`.** A raw viewport only resizes the window — the browser is still a desktop one, so `'ontouchstart' in window` is `false`, `navigator.maxTouchPoints` is `0`, and `@media (pointer: coarse)` doesn't match. An app that mounts its touch controls behind any of those checks will render its **desktop** layout and you'll photograph the wrong thing. `--device mobile` emulates the handset end to end — touch events, `maxTouchPoints`, `pointer: coarse`, mobile user-agent, DPR — so the mobile build is what lands in the PNG.

**Capturing a state behind an interaction** (start a game, open a menu, dismiss a modal)? Use `--action`:

```bash
gipity page screenshot <url> --action "document.getElementById('play').click()"   # in-game frame, not the start screen
```

The action runs after the post-load delay, then the page settles again so the result has painted. Don't hand-roll a `page eval` that returns a base64 image — the eval result is capped (~16KB) and truncates the PNG; `--action` writes a normal PNG to disk like any other screenshot.

## `gipity page eval <url> <expr>` - ask the page anything else

`page inspect` reports a fixed set of health signals. When you need something it doesn't cover - a computed style, an element's exact position or size, whether a node is visible, a count of matching elements - evaluate a JavaScript expression in the page (same context as the DevTools console) and get the serialized result back:

```bash
gipity page eval <url> "getComputedStyle(document.querySelector('.hero')).position"
gipity page eval <url> "document.querySelectorAll('.card').length"
gipity page eval <url> "document.querySelector('#cta').getBoundingClientRect()"
```

| Flag | Effect |
|---|---|
| `--wait <ms>` | Settle time after DOMContentLoaded before evaluating (default 500) |
| `--wait-for <selector>` | Wait until a CSS selector appears before evaluating (deterministic; replaces `--wait`). `--wait-timeout <ms>` caps it (default 5000). |
| `--reload <expr>` | After the first eval, reload the page **in place** (localStorage/sessionStorage/cookies preserved) and evaluate this second expression against the post-reload DOM. `--reload-file <path>` reads it from a file. See the persistence check below. |
| `--json` | Output `{ url, result, truncated }` instead of a summary |

The expression may be **async** - a returned Promise is awaited and its resolved value serialized, so `(async()=>{ const r = await fetch('/health'); return r.status })()` and `await window.Gipity.auth.user()` work.

The expression runs in page context only - it can read the DOM and that origin's storage, but has no server or filesystem reach. The result is serialized and length-capped; if it comes back `truncated`, narrow the expression (select fewer nodes, return a single field).

**It's an *expression*, not a statement block.** The input is spliced into `await (<expr>)`, so a bare top-level `const`/`let`/`return` fails to parse (`Unexpected token const`). To run multiple statements, wrap them in an IIFE that `return`s the value:

```bash
gipity page eval <url> "(()=>{ const el=document.getElementById('email'); el.value='a@b.com'; return { id: el.id, value: el.value, visible: !!el.offsetParent }; })()"
```

**Return the value, don't post-process the output.** `page eval` already serializes the result - make the expression compute exactly what you want and read it directly (`--json` gives `{ url, result, truncated }`). Don't pipe the output through `python3 -c`, `sed`, or a shell regex to dig a value back out - that's slower and fragile (one unescaped `(` in the URL breaks the regex). If you need three facts, return one object: `gipity page eval <url> "JSON.stringify({ booted: !!window.MyApp, cards: document.querySelectorAll('.card').length, err: window.MyApp?.lastError })"`.

**Each separate `gipity page` command is a fresh, anonymous browser.** Every `page eval`/`page inspect`/`page screenshot` invocation opens the URL in a clean profile - no localStorage, cookies, or globals from a previous command survive, so you can't "install a probe, then read it" across two commands, and a state one command set (a language choice, a signed-in session) never skews the next. To test persistence across a reload, use `--reload` (storage IS preserved within the one command); for multi-part checks on one loaded page, use `--step`.

**Driving the page harder → read [app-debugging-reference](app-debugging-reference.md):** `--step` (many assertions, one page load), `--reload` (does the state survive a refresh?), `--fixture` (feed the app a real MP3/image/PDF), `--file` (a long eval body without shell-quoting hell), the ~20s in-page budget, an in-app error trap for errors the console misses, `--camera` (point the headless browser at a picture), and stepping a simulation's clock with `advance(seconds)` instead of waiting on real time.

### What the headless browser can't test

The inspect/eval/screenshot browser is **headless with no human and no logged-in session**, so anything gated on a **trusted user gesture** won't fire from a programmatic click:

- **Audio/video playback** - the autoplay policy blocks `audio.play()` without a real tap; a programmatic click is "untrusted." You'll see no sound and no `playing` event even when the code is correct.
- **Sign-in popups** - `window.open` from a script + the full auth round-trip can't be completed (no human to log in).
- **Anything needing a real session** - by default `auth: user` calls and signed-in UI stay logged-out. **To reach pages behind a Sign-in-with-Gipity login, add `--auth`** to `page inspect`/`screenshot`/`eval`: it loads the page signed in **as you** (your own account, which auto-consents to your app), so members-only UI and `auth: user` data render. All three report the outcome (`Auth:` line / `auth` JSON field): `session established`, or `session NOT established` (anonymous view). Works for `*.gipity.ai`-hosted apps using Sign in with Gipity; apps with a custom login still need a human. See the `app-auth` skill.
Don't burn turns trying to make these "work" headlessly. Instead verify the **wiring**: the button is bound, the handler runs without throwing, the right state class toggles, `window.Gipity` is present, the function returns the expected shape - then trust a real tap for the actual sound. Reserve the full end-to-end (real audio, completed login) for a human.

### Camera apps, and other things to read next

The camera is **not** in that list - `--camera <image>` gives `page eval` / `screenshot` / `inspect` a synthetic webcam that plays your image on a loop, so a vision app's real `getUserMedia` → inference → UI pipeline runs headlessly. That, plus stepping a game's clock with `advance(seconds)` (never wait on real time - the headless browser paints at ~2-3 fps, so simulated time crawls and your assertion fails as a **false negative**), is in [app-debugging-reference](app-debugging-reference.md).

## `gipity logs fn <name>` - why did a function fail?

When a function returns a 500 or wrong data, read its execution logs:

```bash
gipity logs fn list-items            # recent runs
gipity logs fn list-items --limit 50
```

Each entry shows time, status (`ok` / `error` / `limit_exceeded`), duration, trigger type, `svc:N` when the invocation made N platform service calls, the **error message** for failed runs, and — indented beneath each run — any **`console.log` / `console.warn` / `console.error`** that invocation printed. So sprinkling `console.log` through a function and re-reading its logs is a real debugging loop: the output is captured on every run (success or failure) and persisted with that run. `console` is variadic and Node-like — objects serialize to JSON, and the full surface (`log`/`info`/`debug`/`warn`/`error`, `dir`/`table`/`trace`/`assert`/`group`/`time`) is available, so reaching for one never crashes the function. (Output is capped at 1000 lines per run, 10k chars per line, and ~64k chars total; beyond any of those a truncation marker is added.) `gipity fn logs <name>` is the equivalent under the `fn` command group.

**Did my service call (notify / email / LLM / image) actually go out?** The `svc:N` chip on an `ok` run says N service calls were made; for the authoritative per-call outcome — status, provider, latency, its own error message — read `gipity logs app --type services`. `--json` on `logs fn` emits a **top-level array** of invocation rows (`{ status, duration_ms, error_message, limits_consumed, logs, ... }`), so pipe it to `jq '.[0]'`, not `.data`.

## `gipity fn call <name> [body]` - test a function directly

Invoke a deployed function from the CLI, bypassing the frontend - the fastest way to confirm a fix:

```bash
gipity fn call list-items '{"limit": 5}'
gipity fn call list-items --data '{"limit": 5}'
```

It prints the function's JSON response.

**`gipity fn call` runs authenticated as the logged-in CLI user** - it sends your session token, so an `auth: user` function sees `ctx.auth.userId` set to *you*. This is **not** the same as `ctx.fn.call` in tests (anonymous) or a logged-out web visitor. The practical hazard: smoke-testing a mutating `auth: user` function (claim, create, purchase) against a deployed app writes **real** rows owned by your account - on prod, real production state. To exercise the logged-out path, hit the endpoint with no auth header (plain `curl -X POST https://a.gipity.ai/api/<PROJECT_GUID>/fn/<name> -d '{...}'`) and confirm it 401s.

`gipity fn list` shows every deployed function with its version and auth level. `gipity fn delete <name>` (alias `rm`, add `--yes` to skip the prompt) removes one - useful for cleaning up an orphaned function, since `gipity deploy` does NOT prune functions whose source file you deleted.

## What `gipity logs app` does and doesn't catch

The Gipity client SDK (`gipity.js`, auto-injected into every template) reports four things to `gipity logs app`:

1. **Uncaught `window` errors** - any thrown error not caught by app code.
2. **Unhandled promise rejections** - any `async` failure without a `.catch`.
3. **Non-2xx `fetch` / XHR responses** - patched transparently; agents see status, URL, and a response snippet.
4. **Manual reports** - anything app code passes to `Gipity.captureError(...)`.

What it does **not** auto-catch: a **200 OK with the wrong shape** (e.g. a function returned `{ data: { post } }` and the client expected `{ post }`), a caught exception swallowed into `console.error`, or a logical failure with no exception. The request succeeded on the wire, so the fetch patch stays quiet. For these cases use the manual capture API:

```js
// Caught error you still want to log:
try {
  doSomething();
} catch (err) {
  Gipity.captureError(err, { tags: { feature: 'editor' } });
}

// Semantically-wrong success - throw or capture manually:
const data = await Gipity.fn('add-post', { x, y, text });
if (!data?.post) {
  Gipity.captureError(new Error('add-post returned no post field'));
  return;
}
```

Using `Gipity.fn(name, body)` (see [app-development](app-development.md)) covers most of this automatically through path 1 - it throws on non-2xx, which the SDK catches as an uncaught error.

## A debugging pass

1. `gipity page inspect <url>` - console clean? resources all loading?
2. Console shows a JS error → fix the frontend code, redeploy.
3. A `fetch` to your own function failing → `gipity logs fn <name>` for the server-side error.
4. Reproduce and confirm the function fix with `gipity fn call`.
5. `gipity page screenshot --device mobile,desktop` - looks right everywhere?

## Related skills

- [app-debugging-reference](app-debugging-reference.md) - driving the page with `page eval`: `--step`, `--reload`, `--fixture`, `--file`, in-app error traps, `--camera`, and stepping a simulation's clock
- [deploy](deploy.md) - the deploy step that precedes every inspect
- [app-development](app-development.md) - writing the functions you're debugging
- [app-testing](app-testing.md) - testing those functions (`ctx.fn.call` / `callAs`, the isolated test DB)
- [web-app-basics](web-app-basics.md) - frontend patterns and common page bugs

