Testing
Three layers, all runnable with zero Anthropic calls thanks to a fake
claude binary. See docs/DESIGN-testing.md for the rationale; this file is the
how-to.
npm test # vitest: server unit + server integration + web unit/componentnpm run test:e2e # playwright: Chromium against the built SPA + real server + fake claudenpm run test:e2e:live # same, but the REAL claude + Max token (gated; manual only)Layer 1 — unit (Vitest, fast, no network)
Section titled “Layer 1 — unit (Vitest, fast, no network)”Pure-logic + component tests. No server, no fleet, no claude.
The suite is too large to enumerate file-by-file and stay accurate, so this is the shape rather than a list. As of v0.66.2:
- Server (
packages/server/test/unit/, node env) — 100 files. Broadly: project CRUD and slugging, path containment and the system-path denylist, the config resolver and everyPADDOCK_*precedence rule, instance-config validation, transcripts (encodeProjectDir,ensureProjectChats, and thetranscripts: hostsymlink target), theclaude:sharing levers, MCP-server declaration and allowlisting, the self-MCP tool surface and its gates,SweepService,GitServiceagainst real temp repos, the GitHub OAuth device flow with a mockedfetch, schema-version guards and the turn interlock. - Web (
packages/web/src/**/*.test.{ts,tsx}, jsdom + @testing-library/react) — 72 files:lib/helpers, individual components, the route-level grids and panes, and the modals (validation plus the exact request payload each builds, with the api client mocked).
To see the current list, ls packages/server/test/unit/ — the filenames map to the
src/ module they cover.
Run just one layer:
npm run test:server # all server tests (unit + integration)npm run test:web # all web testsnpm run test:unit -w packages/server # server unit onlynpm run test:integration -w packages/server # server integration onlyLayer 2 — server integration (Vitest + fake claude)
Section titled “Layer 2 — server integration (Vitest + fake claude)”Boots the real Fastify app (buildApp()), the real @herdctl/core
FleetManager + CLI runtime, the real transcript/session machinery — against a
temp data dir, with the fake claude first on PATH. Files:
packages/server/test/integration/ — 58 of them at v0.66.2. A representative
slice, to show what this layer is for:
projects-crud.test.ts— REST CRUD, agent registration in fleet status, pins, 404/409/400 paths.chat.test.ts— a chat turn streamed over WebSocket, transcript written + discovered, history hydration on reload, context-usage readback, and resume continuity (set a codeword, resume, recall it).ws.test.ts— WS transport edge cases: ping/pong, invalid-JSON + unknown + malformed messages →chat:error, theonChatSendcatch path (unknown project),preloadContext(OVERVIEW.md injection for a new chat, no-op when there is no overview), per-chat model override (valid →ensureAgentModel; unknown → fallback),chat:tool_call+chat:message_boundary(via the fake’s[[TOOL]]/[[BOUNDARY]]directives),chat:cancel, the usage/model surfaced onchat:complete, and the legacytargetalias.routes.test.ts— REST coverage gaps: rename + delete chat (incl. unknown-slug 404s), pins (missing-file / traversal-guard / dedupe), the/contextendpoints (with + without usage), GET/overview+/changelog+/files/:name, the thinPOST …/chatsecho,/api/fleet,/api/git/push, git-route 404s, and the GitHub device-flow endpoints (connect/poll/disconnect) driven with a mockedfetch.sweep.test.ts— the post-turn curation sweep runs end-to-end: a project turn enqueues a sweep, the (tool-less) sweeper returns marker-shaped text (via the fake),SweepServiceparses it and writesOVERVIEW.md+ appends aCHANGELOG.mdbullet. UsesstartTestApp({ sweepIntervalMs: 0 })so the trailing sweep fires immediately.app-static.test.ts—buildApp({ serveStatic:true }): servingindex.htmlat/+ the SPA fallback, the JSON 404 for unknown/apipaths, and the API-only degrade when the web dist is missing.promote.test.ts— promote a one-off chat → project (#20): lists under the project, history hydrates, job re-attribution, transcript cwd-rewrite. (See “Known gaps” for resume-after-promote.)git.test.ts— status/diff/commit against a real temp git repo, and therepo:falsepath when the store isn’t a repo.
The other ~50 follow the same pattern against the real app: the queue and its
slot-versioning frames, sub-agent sidechain and background rehydration, the turn
interlock on delete/revert/promote, adopt + unadopt, declared MCP servers (including
the --mcp-config argv exposure under driveMode: batch), and the instance-config
routes. ls packages/server/test/integration/ for the current set.
The fake-claude harness (test/bin/claude)
Section titled “The fake-claude harness (test/bin/claude)”The harness pins
batchon purpose. A fakeclaudeonPATHis only reachable from the CLI runtime, and Paddock’s default drive mode issession— which routes turns throughopenChatSession→ the SDK runtime, which spawns the SDK’s own bundledclaudeand would never see the stub. Sotest/e2e/server.mjs:127setsPADDOCK_DRIVE_MODE=batchin fake mode (live mode leaves the default alone). The E2E suite therefore exercises the CLI runtime, not the runtime a real chat uses.
herdctl’s CLI runtime spawns claude from PATH and then watches the session
JSONL file it writes (it does not read the process’s stdout). So the fake:
- Parses the flags herdctl passes (
-p,--permission-mode,--model,--system-prompt,--allowedTools,--resume <id>, …) and reads the prompt from stdin. - Computes the session dir the same way herdctl does —
<claudeHome>/projects/<cwd-with-every-non-alnum→'-'>/, resolving<claudeHome>fromCLAUDE_CONFIG_DIRand only falling back to~/.claude(claudeHome()intest/bin/claude). That fallback is not the paddock case: paddock runs against its own home, so the dir is<dataDir>/claude-home/projects/<enc>— and that encoded path is the symlink to<projectDir>/.chats, so writes land in the project. Hard-coding~/.claudehere writes transcripts somewhere herdctl is not watching, and the turn dies 60s later on “Timeout waiting for new session file”. - Writes a real
<sessionId>.jsonltranscript with the exact line shapes@herdctl/core’sjsonl-parser+ the@herdctl/chattranslator consume:user→{type:"user", message:{role:"user", content:"…"}, sessionId, cwd, timestamp}(first line is neverisSidechain:true, so discovery keeps it).assistant→{type:"assistant", message:{id, role:"assistant", model, content:[{type:"text", text:"…"}], usage:{…}}, sessionId, cwd}.result→{type:"result", subtype:"success", is_error:false, session_id, result:"…", usage:{…}}(ends the watcher loop, marks success). Lines are appended with small gaps so the chokidar watcher streams them.
- New session → mints a UUID, writes
<uuid>.jsonl.--resume <id>→ appends to<id>.jsonland reads the prior transcript so it can answer continuity questions.
Scripted replies (deterministic):
PADDOCK_FAKE_SCRIPT→ a JSON file path mappingprompt → reply(exact match). The integration helper writes one fromstartTestApp({ script }).- Built-in rules: “the codeword is X” / “what was the codeword?” (continuity),
and a default
Acknowledged: <prompt>echo so the E2E can assert streamed text.
Prompt directives + sweeper replies (added for the ws/sweep coverage work — each is OPT-IN; a prompt with none of these is handled exactly as before):
[[TOOL]]anywhere in the prompt → the fake emits a pairedtool_use(assistant) +tool_result(user) around its reply, so@herdctl/chat’s translator surfaces achat:tool_callevent (exercises ws.ts’sonToolCall).[[BOUNDARY]]→ the fake emits a second assistant text block after the first, so the translator firesonBoundary→chat:message_boundary. Note: a brand-new session occasionally races the runtime’s watcher on its first read, so thews.test.tsboundary case sends this turn as a resume of an existing session (the transcript file already exists, watcher attaches reliably).- Sweeper curation prompts (detected by the literal
<<<OVERVIEW>>>the sweeper system/user prompt asks for) → the fake returns a marker-shaped reply (<<<OVERVIEW>>> … <<<CHANGELOG>>> … <<<END>>>) soSweepServicecan parse it and writeOVERVIEW.md/CHANGELOG.md. The exact text is overridable viaPADDOCK_FAKE_SWEEP(a file path whose contents become the sweeper reply). This closes the prior “sweeper output missing markers” gap — the sweep now runs cleanly in integration instead of erroring out of band.
The test-app factory
Section titled “The test-app factory”startTestApp(opts) (packages/server/test/helpers/app.ts) creates a temp
HOME + data dir, prepends test/bin to PATH, optionally git inits the
projects root, writes the fake script, and calls buildApp({ serveStatic:false }).
Returns the wired app + a teardown() that stops the fleet, restores env, and
removes the temp dir. Options: script (the fake-script map), gitRepo (init a
git repo at the projects root), and sweepIntervalMs (sets
PADDOCK_SWEEP_MIN_INTERVAL_MS; pass 0 to make the post-turn sweep fire on the
next tick instead of waiting the 5-min default). WS tests use listen() +
connectWs() (test/helpers/ws.ts), a tiny ws client with mark() +
waitFor({ from }) so a shared socket can scope each turn’s events, plus
sendRaw(text) to push a non-JSON frame (for the invalid-JSON path).
Layer 3 — E2E (Playwright + fake claude)
Section titled “Layer 3 — E2E (Playwright + fake claude)”test/e2e/ drives Chromium against the built SPA + a real server with the
fake claude. test/e2e/server.mjs boots packages/server/dist/index.js serving
packages/web/dist, against a throwaway HOME + data dir, fake claude on PATH.
playwright.config.ts runs it via webServer and waits on /api/health.
You must build first: npm run build (server + web), then npm run test:e2e.
The layer is 20 specs across four Playwright projects, against two servers. The
config (test/e2e/playwright.config.ts) declares:
| Project | Specs | Viewport | Server |
|---|---|---|---|
chromium | everything except the git and mobile specs | Desktop Chrome | 4317 |
chromium-git | journey-git-*.spec.ts | Desktop Chrome | 4318 |
mobile | journey-mobile.spec.ts | Pixel 5 (isMobile + hasTouch) | 4317 |
mobile-git | journey-mobile-git.spec.ts | Pixel 5 | 4318 |
The two servers exist because git-repo detection is cached process-wide, so a
repo-backed and a non-repo run cannot share one. Both ports derive from
PADDOCK_E2E_PORT (default 4317; the git server is always that + 1) — override
it if 4317/4318 are taken, which is also how you avoid colliding with an orphaned
server from an earlier run. Each server gets its own temp data dir.
The mobile projects reuse the same Chromium install, so a phone-sized run costs no
extra browser download in CI.
happy-path.spec.ts is the original smoke run — create a project (pick an area) → land
in it; send a chat and watch it stream, reload and see history; collapse an area
section; filter by a domain tag; promote a root chat into a project. The 19
journey-* specs are the real coverage: chat, errors, files, git changes, GitHub,
attachments + queue, sub-agents, lifecycle, preload, remount hydration, the root
workspace, tags, theme, turn notices, home attention, project view, landing, and the
two mobile journeys.
Artifacts (screenshots on failure, traces/videos on retry) go to the run’s temp
dir, never the repo. The HTML report lands there too, under <temp>/report.
Live mode (npm run test:e2e:live)
Section titled “Live mode (npm run test:e2e:live)”Sets PADDOCK_TEST_LIVE=1, which makes server.mjs use the real claude +
the Max OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and the real ~/.claude. For
occasional smoke runs only — manual/nightly, never CI. Default is always the
fake.
A production change this work required
Section titled “A production change this work required”@herdctl/core 5.13’s config loader drops runtime from fleet-level
defaults (it’s only an agent-level field there). paddock relied on
defaults.runtime: cli, so without a fix every agent silently fell back to the
SDK runtime. We now set runtime: "cli" explicitly on each agent
(keeper/sweeper) in herdctl.ts, which is what makes the fake-claude/CLI
path actually run.
Scope note.
runtimeis read only on the one-shottrigger()path —openChatSessionhard-codes the SDK runtime — so these lines govern the sweeper and any turn resolved todriveMode: batch. They do not make a real chat aclaude -psubprocess; the defaultsessionmode drives it on the SDK. And note a trigger is not automatically atrigger()call: a scheduled or event trigger resolves its drive mode exactly like a chat (project override, else the instance default), so on the defaultsessionit goes throughopenChatSessiontoo. Only the sweeper is unconditional — several source comments still say otherwise, tracked as #771. The original note here also framed this as a Max-vs-API-key choice, which was wrong: either credential works on either runtime.
index.ts was also split into a buildApp() factory (app.ts) so tests can boot the
app without binding a port or installing signal handlers — a pure seam, no behavior
change.
Known gaps / TODO for follow-up agents
Section titled “Known gaps / TODO for follow-up agents”- Resume continuity after promote — FIXED (the harness caught this, as
intended). After promoting a one-off chat into a project it used to fork a
fresh session on resume (codeword lost). Root cause was in herdctl’s
JobExecutor: it dropped an explicit
--resumewhen the agent had no stored session-info file, so an agent resuming an adopted session started fresh. Fixed upstream in @herdctl/core 5.13.1 (herdctl#263) — the executor now adopts a caller-provided resume when the transcript exists in the agent’s working dir.promote.test.tsnow asserts the resumed turn continues the same session and recalls the codeword. reattributeSession/writeAdoptionJobare covered end-to-end viapromote.test.ts(they’re private). A direct unit test would need a small export seam; left as a follow-up.- The post-turn sweeper — NOW COVERED. The fake emits a marker-shaped
sweeper reply (see “Prompt directives” above), so
sweep.test.tsdrives the real curation end-to-end (OVERVIEW.md replaced, a CHANGELOG.md bullet appended), andtest/unit/sweep.test.tscovers the coalescing / skip / watermark / retry branches. The sweep no longer errors out of band in integration runs. github-auth.ts(device flow) — NOW COVERED viatest/unit/github-auth.test.tswith a mocked globalfetch(the device-code + token + user endpoints). Found- fixed a bug along the way:
pollDeviceFlowcalledres.json()with nores.ok/parse guard, so a non-JSON token-endpoint response (gateway 5xx) threw an unhandledSyntaxErrorinstead of returning{ status: "error" }(issue #21, fixed; regression test added).
- fixed a bug along the way:
reattributeSession/writeAdoptionJobare covered end-to-end viapromote.test.ts(they’re private). A direct unit test would need a small export seam; left as a follow-up.- E2E is no longer happy-path-only. Error states
(
journey-errors.spec.ts), file pins/tabs (journey-files.spec.ts) and the git UI (journey-git-changes.spec.ts,journey-git-github.spec.ts,journey-mobile-git.spec.ts) all have specs now. What is still thin from the browser: the model picker and the context meter, which are asserted at the component and integration tiers instead. - The fake
claudecannot reach every state. It is a CLI stub, so the suite that uses it exercises the CLI runtime, not the SDK runtime a real chat uses — and it accepts transcript shapes the real Messages API would reject. Structural soundness is what these tests prove; real-API resumability is not verifiable at this tier. index.ts(the process bootstrap: bind a port + signal handlers) is intentionally left at 0% — it isn’t server logic worth a test.- No coverage figures are quoted here on purpose. The numbers this page used to
carry predate roughly 150 added test files and could not be reproduced. Measure it
yourself when you need it (
npm run test -w packages/server -- --coverage;@vitest/coverage-v8is already a devDependency) rather than trusting a figure in prose. Wiring a@vitest/coverage-v8threshold gate (herdctl uses 85%) is still the natural next step.