Approval origins (reviewer/user/bypass, notes, grants) ride the tool message display sidecar so chips and deny blocks survive reload. The Auto-Approve explainer is persisted once per session server-side with new shorter copy; later switches persist one-line markers.
E2E tests (Playwright)
End-to-end regression tests for the GUI. They drive the real app in Chromium but are hermetic:
every /v1 request and the event WebSocket are mocked at the network layer, so tests need no
Python backend, run deterministically, and never mutate real state.
Run
npm run e2e # headless
npm run e2e:ui # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts # a single spec
Live smoke (not CI)
npm run e2e:live runs e2e-live/ (separate playwright.live.config.ts) against the real
backend on :8765. Two flavors, both skip cleanly when the backend is down:
- API-shape smoke (
api-smoke.spec.ts) — no model tokens, no creds. Asserts/v1/healthand/v1/providersreturn the shapes the GUI reads, catching drift between the mocks and the real backend. Cheap enough to run anytime the sidecar is up. - Full vertical (
fib.spec.ts, …) — asks a fresh Cowork session to producefib.mdand verifies the file lands on disk. Needs a model configured, is nondeterministic, and costs a few tokens per run. Exercises the vertical the hermetic specs mock: model wiring, the tool/approval loop, file I/O, and WebSocket streaming.
The config (playwright.config.ts) starts the Vite dev server on port 5199 (dedicated, so it
won't clash with a running npm run dev on 5173) and reuses it if already up.
How the mock works
e2e/fixtures.ts exports a test whose page has mockApi() installed before navigation:
-
page.route("**/v1/**", …)dispatches by pathname + method to fixtures whose shapes mirror the real backend (captured from a live server). Unknown endpoints return an empty-but-valid body. -
Mutations are held in per-test in-memory state so they reflect through the real UI on re-fetch: sessions (archive/rename/delete), personas (enable/surface/delete — enable implies surface, matching the backend), inbox items + the routing binding, roots, channel subscriptions.
-
The session WebSocket (
routeWebSocket) is a scripted fake agent speaking the real{type, data}event protocol:readyon connect;user_message→turn_start→ deltas →assistant_message "Echo: <text>"→turn_done; a message containing "run a tool" emitstool_proposed+permission_requiredand suspends until the client'sapprovaldecision arrives. This runs the production send/stream/approve code paths with zero model cost. -
Seed data worth knowing: the pinned session "Draft the launch note" is the newest (boot-resume target); 7 unpinned "Weekly plan N" cowork sessions exercise the sidebar peek cap; two pending Inbox items (approval on cowork, question on ops) drive the Inbox filters;
acme-notesis a disabled non-builtin persona for enable/delete flows. Providers are seeded in three states (OpenAI configured+used, Anthropic configured-unused, Z AI unconfigured w/ prefilled endpoint) —POST /v1/providersflipsconfiguredon save,/verifyfails on a key containing "bad". One automation ("Daily AI News") with a running run —POST .../runappends a run,PATCH/DELETEtoggle and remove. -
Seeded transcripts: every session's
GET /v1/sessions/{id}/messagesanswers[], so reopening starts blank.seedSessionMessages(page, sessionId, messages)(exported from fixtures) registers a later, winning route that stages full replayed history for one session — tool_calls +role:"tool"results (wired bytool_call_id),_displaysidecars,reasoning,noticemarkers, connectorsourcemessages. Use it to assert the reopen path (itemsFromMessages) — replayed step groups, connector cards, tail-error Retry — which live echo-driving can't reach. Seeseeded-history.spec.ts.
Adding a spec
import { test, expect } from "./fixtures";
test("…", async ({ page }) => {
await page.goto("/");
// interact + assert
});
If a flow reads a new endpoint, add its fixture + a route branch in fixtures.ts — the catch-all
returns {}, which will crash components that expect arrays (e.g. persona recommends). Prefer
getByRole, but note some controls (the Sources bar, the ✕ remove) take their accessible name from
inner content — target those with getByTitle/getByLabel.