Files
ai-agent/frontend/src/mock/README.md
Gabriel Vidal 703282ec40 feat(ai-agent): in-browser mock backend behind the generated API types
Adds `frontend/src/mock/` — a fake backend that runs in the page, so the whole
PWA can be demoed and driven by an automated frontend test with no FastAPI, no
sidecar and no transcripts on disk. `?mock=1` on any URL (sticky, works against
the deployed app) or `npm run dev:mock`.

It swaps `window.fetch` (every `/api/*` route) and `window.EventSource` (the
`meta`/`transcript` SSE events) for versions served from an in-memory,
localStorage-persisted database. Writes really mutate it, and spawn/resume/
interrupt actually run: a scripted turn streams into the conversation item by
item over SSE, then stamps the notification + DONE that mark it finished.

The seed is combinatorial — harness × state × lifecycle × archived conversations,
a kitchen-sink thread with every ThreadItemKind and tool card, every
FileEntryKind, every service auth, every plan status, every FileDiffStatus /
DiffLineType — so every visual state is reachable from a cold load, and it is
deterministic (fixed clock, no RNG). Test hooks on `window.__mock`.

Rows are typed with the orval-generated models, so a backend schema change breaks
the seed at tsc time instead of letting the mock drift. main.tsx is now a boot
shim (mock installs before the app tree is imported — the server-backed Zustand
stores fetch at import time); the app tree moved to root.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 16:29:36 +02:00

4.2 KiB
Raw Permalink Blame History

src/mock/ — the in-browser mock backend

A fake backend that runs in the page, behind the same orval-generated DTOs the real one serves. It exists so the whole PWA can be demoed and driven by an automated frontend test with no FastAPI, no sidecar, no transcripts on disk.

npm run dev:mock            # Vite with the mock forced on (VITE_MOCK=1)
# …or, against any build (including the deployed one):
open https://ai-agent.lab.gabvdl.xyz/?mock=1     # sticky until ?mock=0

How it works

installMock() (called from main.tsx before the app tree is imported, since several modules fetch at import time) swaps two globals:

  • window.fetch — every same-origin /api/* request is answered by handlers.ts from the in-memory database; anything else falls through to the real fetch.
  • window.EventSource/api/events is served by a tiny hub that emits the same {type: "meta" | "transcript", ids} events backend/events.py does, so the app's SSE-driven React Query invalidation runs for real.
file role
enabled.ts the ?mock=1 / VITE_MOCK flag (tiny — the only part in the main bundle)
db.ts the database: one object, persisted to localStorage, plus the SSE hub and the detail→summary projections
seed.ts the combinatorial fixture (below)
handlers.ts the routing table — every /api/* route, reads and writes
sim.ts spawn / resume / interrupt: a scripted turn that streams
index.ts installs the interceptors, window.__mock, the MOCK API badge

Writes really mutate the db (and persist): archiving a conversation, editing a file, saving settings (the server-backed Zustand stores hit /api/ui-state/*), uploading an attachment. A spawn or resume actually runs — items are appended to the conversation one at a time, each publishing a transcript event, until the run stamps notified + a message ending in DONE and flips to finished. interrupt drops the rest of the script and records an interruption.

The seed is combinatorial

seed.ts doesn't hand-write a few plausible rows — it enumerates the cross-product of every field the UI branches on, so every visual state is reachable from a cold seed:

  • conversations — harness (claude, pi) × state (running, finished, idle) × lifecycle (nothing / committed+pushed / merged+deployed+notified), plus an archived one per harness, a subagent conversation linked to its parent, an empty transcript, and a kitchen-sink thread that contains every ThreadItemKind (text, thinking, tool_use, tool_result, interrupted, paused), every role, an error result, and one card per rendered tool: Read (+ image and screenshot widgets), Edit, Write (plan widget), Bash (+ a deploy URL), Grep, Glob, WebSearch, WebFetch, Skill, Task (subagent), TaskCreate.
  • files — every FileEntryKind × editable × estimated.
  • services — every auth value (auth, public, api, mixed, none) × router kind (http, tcp).
  • projects — with/without costs, url, package.json; both OgImage kinds.
  • plans — every status (proposed / approved / in-progress / done), half with an actual rollup. memories — one per memory type. diffs — every FileDiffStatus and DiffLineType, plus a binary and a truncated file.

It is deterministic (fixed clock, no Math.random), so a test can assert on ids, counts and totals.

Test hooks

window.__mock.db()          // the live database object
window.__mock.reset()       // wipe localStorage + reseed
window.__mock.speed(0)      // 0 = don't stream on a timer…
window.__mock.flush()       // …land every in-flight run instantly (deterministic)
window.__mock.publish({type: "meta"})   // fire an SSE event by hand

A Puppeteer/Playwright pass therefore needs no fixtures of its own: load /?mock=1, walk the routes, POST /api/spawn, flush(), assert.

Keeping it honest

The mock's rows are typed with the generated src/generated/model types (re-exported by types.ts), so a backend schema change that lands in openapi.jsonnpm run gen:api breaks the seed at tsc time instead of silently drifting from the API it pretends to be.