Fury's tests come in two layers:
| Layer | Runner | Location | Server needed? | Spends tokens? | Speed |
|---|---|---|---|---|---|
| Unit | Vitest | tests/unit/**/*.test.ts |
no | no | ~10s for all 200+ |
| Browser / E2E | Playwright | tests/**/*.spec.ts (all dirs except unit) |
yes (:3879) |
some (the "live drives") | seconds to minutes |
Rule of thumb: logic that can be made pure lives behind a unit test; anything that needs the running app, the browser DOM, or a real Claude turn is a Playwright spec.
npm test # alias: npm run test:unit → vitest run
npx vitest run tests/unit/freshness.test.ts # a single file
npx vitest # watch modeConfig: vitest.config.mjs — includes tests/unit/**/*.test.ts, runs in the Node environment (no jsdom), and maps the @/… path alias to the repo root. These never start a server or a browser and never call Claude, so they're safe to run on every save.
npm run test:e2e # every non-unit spec
npm run test:e2e -- tests/editor # one directory
npm run test:e2e -- tests/live-sessions/freshness-leaf.spec.ts # one file
npm run test:e2e -- tests/mcp/mcp-fixes.spec.ts --reporter=lineConfig: playwright.config.ts.
testDir: ./tests,testIgnore: **/unit/**— Playwright runs everything undertests/except the Vitest unit folder.webServerauto-startsnpm run devonhttp://localhost:3879and reuses an already-running server (reuseExistingServer: true), so if you already havenpm run devup, the tests attach to it.headless: false— the suite runs headed by default; a browser window will open.- The
test:e2escript wrapsnode --experimental-require-module …/@playwright/test/cli.js test(needed to load the ESM Playwright CLI under this repo's toolchain); everything after--is passed straight through to the Playwright CLI.
Prerequisites for E2E:
- The dev server running (or lettable to start) on
:3879. - An authenticated Claude CLI (
claudein PATH) — required by the live drives below, which start real Claude turns.
A handful of specs POST a real turn to /api/claude-sdk and let Claude actually work. They cost tokens and can each take from ~30s to a few minutes. Run them deliberately, not in a tight loop.
| Spec | What it proves |
|---|---|
live-sessions/background-task-reassert.spec.ts |
A background task (a run_in_background Bash) posts a <task-notification> that drives an un-submitted turn; the server re-asserts processing (dots stay on, partials stripped) instead of going dark. Asserts the sdk.turn:reassert + second sdk.health:processing signature from the fury-logs. |
live-sessions/freshness-leaf.spec.ts |
The prompt-cache "freshness leaf" stays pinned live-warm for the whole active window, then counts down from the true end (no false-stale). |
live-sessions/inflight-partials-health.spec.ts |
A long multi-tool turn never leaks its in-flight partial assistant messages as intermediary bubbles across health ticks; health events carry a startedAt strip anchor. |
live-sessions/resume-cleaned-session.spec.ts, resume-summary-fidelity.spec.ts |
Resuming a session restores/continues it faithfully. |
e2e/build-calculator.spec.ts, e2e/ask-user-question.spec.ts, e2e/model-selection.spec.ts, e2e/new-session-model.spec.ts |
Full user flows that involve a real turn (building an artifact, answering an AskUserQuestion, picking a model). |
How the live drives work — and why they're reproducible:
- Each drive runs in a scratch project created outside the repo at
../fury-e2e-*(sibling of theFury/checkout), wiped and recreated per run, so it never touches your working tree. - They assert against on-disk truth: the session JSONL under
~/.claude/projects/…and the correlated~/.claude/fury-logs/telemetry (seedocs/logging-and-telemetry.md). The fury-logs are the source of truth for whether a fix engaged — e.g. a live turn can look fine in the DOM while the log shows the server never re-assertedprocessing. - Every drive cleans up after itself in
afterAll(deletes the session via the API, then prunes the JSONL, PID file, andhistory.jsonlentry). - Shared scaffolding lives in
live-sessions/drive-helpers.ts—driveTurn,resetProjectDir,reapPidFiles,furyLogLinesFor,cleanupSession, etc. Reuse it when adding a new drive rather than re-implementing the harness.
tests/
├── unit/ # Vitest — pure logic, no server, no browser
│ └── fixtures/ # sample JSONL etc. loaded by unit tests
├── live-sessions/ # Playwright — live SDK behavior + live-session detection
│ ├── drive-helpers.ts # shared harness for the live drives (not a test)
│ └── find-session.ts # sidebar-locator helper (not a test)
├── e2e/ # Playwright — end-to-end user flows (some drive real turns)
├── chat/ # Playwright — chat UI (transcript, sidebar, dialogs, TTS)
├── editor/ # Playwright — rich-text editor behavior
├── mcp/ # Playwright — MCP server management UI
└── auth-e2e.spec.ts # Playwright — login → app → logout
Deterministic coverage of the logic behind Fury's trickier subsystems, with no server or browser. Highlights:
- SDK session manager:
sdk-background-turn-reassert,sdk-error-surfacing,singleton-binding,handoff-ownership,sidechain-context,context-window-model-switch,health-startedat,strip-inflight-partials. - Freshness / live state:
freshness,live-sessions,archive-status,history-archived-filter. - Models & provider switching:
model-catalog,model-route-warm,warm-models,providerSwitch-detect/-failover/-rehydrate,pricing. - Ask-user-question:
ask-user-question-router,ask-user-question-serializer. - Task notifications / TTS:
task-notification-response,tts-preprocessing,tts-route,tts-singleton.
Many of these pair with a small pure module extracted from a component/service specifically so it can be tested here — e.g. lib/freshness.ts ↔ freshness.test.ts, and lib/transcriptStrip.ts ↔ strip-inflight-partials.test.ts.
Two flavors:
- Live drives (spend tokens — see the table above):
background-task-reassert,freshness-leaf,inflight-partials-health,resume-cleaned-session,resume-summary-fidelity. - Live-session detection (read-only APIs, no turn):
stale-detection(dead/mismatched PIDs are excluded from/api/live-sessions, and UI badges match),stuck-session(a wedged session surfaces its recovery affordances).
End-to-end flows: build-calculator, ask-user-question, model-selection, new-session-model (all drive a real turn), and delete-to-archive (UI-only).
Chat surface without a real turn: compaction-detection, debug-sidebar, debug-transcript, dialog-maximize, pre-block-style, and the TTS trio tts-playback / tts-replay-timing / tts-services.
Rich-text editor: code-spellcheck, list-exit, list-toolbar, no-autolink, table-roundtrip.
MCP server management UI: add-stdio-server, add-http-server, add-codesearch, mcp-fixes.
The full login → app → logout flow.
- Unit test files are
*.test.tsundertests/unit/; Playwright specs are*.spec.tsanywhere else undertests/. The two never overlap (testIgnorekeeps Playwright out ofunit/; Vitest'sincludekeeps it in). - Helper modules are plain
*.ts, never*.spec.ts(drive-helpers.ts,find-session.ts). Playwright's defaulttestMatchonly collects*.spec.ts, so helpers are importable without being run as tests. - Fixtures (sample JSONL, etc.) live in
tests/unit/fixtures/. - Prefer a unit test. When you find a bug in component/route code, try to lift the decision into a pure function in
lib/and cover it intests/unit/— it's faster, deterministic, and free. Reach for a live drive only when the behavior genuinely needs a running turn. - When you add a live drive, build it on
drive-helpers.ts, keep its scratch project outside the repo (../fury-e2e-<name>), clean up inafterAll, and assert the real outcome from~/.claude/fury-logs/— not just the DOM.