CLAUDE.md: fix stale counts (types 14 to 15, SSE events ~118 to ~120), remove redundant footer sections (References list duplicated inline citations; Common Workflows bullets were self-evident or already stated; Tunnel/Memory Leak Prevention folded into neighboring sections). 251 to 234 lines. Move 22 completed implementation/phase/audit plans to docs/archive/ via git mv so history is preserved. Living reference docs remain in docs/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 KiB
Phase 7 Implementation Plan: Test Infrastructure
Source: docs/code-structure-findings.md (Phase 7 — Test Infrastructure)
Estimated effort: 2–3 days
Tasks: 11 tasks with dependencies (see dependency graph below)
Safety Constraints
Before starting ANY work, read and follow these rules:
- Never run
npx vitest run(full suite) — it kills tmux sessions. You are running inside a Codeman-managed tmux session. - Run individual tests only:
npx vitest run test/<file>.test.ts - Never test on port 3000 — the live dev server runs there. Tests use ports 3150+.
- After TypeScript changes: Run
tsc --noEmitto verify type checking passes. - Before considering done: Run
npm run lintandnpm run format:checkto ensure CI passes. - Never kill tmux sessions — check
echo $CODEMAN_MUXfirst. - Port assignments for this phase: New tests use ports 3220–3229 (see individual tasks for assignments).
Goal
Eliminate duplicated test mocks, activate the unused respawn-test-utils.ts utilities, and add route-level test coverage for the server's 12 route modules — the single largest untested area in the codebase (162 route handlers, 0 dedicated tests).
Non-goals:
- Full end-to-end integration tests (those require real Claude CLI / tmux sessions)
- 100% route coverage in this phase — focus on the highest-value route modules first
- Refactoring test patterns in existing passing tests that don't use shared mocks
- Migrating
vi.mock()-based module replacement mocks (different pattern, see Task 6/7)
Current State
Mock Duplication (Finding #9)
MockSession is defined 4 times across test files with varying levels of completeness:
| File | Properties | Methods | EventEmitter | Notes |
|---|---|---|---|---|
test/respawn-test-utils.ts |
6 | 20+ | Yes | Most complete. Includes terminal simulation, token count, ANSI output, plan mode prompts. Never imported by any test. |
test/respawn-controller.test.ts |
6 | 9 | Yes | Subset of respawn-test-utils. Missing token simulation, ANSI helpers. |
test/respawn-team-awareness.test.ts |
~6 | ~9 | Yes | Near-copy of respawn-controller.test.ts version. |
test/session-manager.test.ts |
4 | 8 | Yes | Inside vi.mock() factory — replaces ../src/session.js module. Different shape: start()/stop()/toState()/sendInput() for lifecycle testing. |
MockStateStore is defined 2 times (both inside vi.mock() factories):
| File | Shape | Methods | Mock Pattern |
|---|---|---|---|
test/session-manager.test.ts |
{ sessions, config } |
getConfig, getSessions, getSession, setSession, removeSession |
vi.mock('../src/state-store.js') |
test/ralph-loop.test.ts |
{ ralphLoop, tasks, config } |
getConfig, getRalphLoopState, setRalphLoopState, getTasks, setTask, removeTask |
vi.mock('../src/state-store.js') |
Important: Two distinct mocking patterns
The codebase uses two different mocking patterns that require different migration strategies:
-
Direct instantiation (respawn-controller, respawn-team-awareness):
MockSessionis defined at file scope and instantiated directly in tests. These can be migrated to shared mocks via simple import replacement. -
Module replacement (session-manager, ralph-loop): Mocks are defined inside
vi.mock()factories that replace entire modules (../src/session.js,../src/state-store.js). These factories run in an isolated scope and return{ Session: MockClass }or{ getStore: vi.fn(() => instance) }. Migrating these requires eithervi.hoisted()or restructuring the test's module mocking — higher risk for limited benefit.
Unused Test Utilities
test/respawn-test-utils.ts exports these utilities that no test file imports:
TimeController/createTimeController()— abstraction over vitest fake timersMockAiIdleChecker/MockAiPlanChecker— fully mocked AI checkers with result queueingcreateStateTracker()/createEventRecorder()— state transition and event recordingFAST_TEST_CONFIG/AI_ENABLED_TEST_CONFIG— pre-configured RespawnConfig objectswaitForState()/waitForEvent()/createDeferred()— async test helpersterminalOutputs— factory object for common terminal output patterns
Route Test Coverage
Currently zero dedicated tests for the 12 route modules in src/web/routes/. The existing test files that touch API endpoints:
| Test File | What It Tests | Approach |
|---|---|---|
test/api-responses.test.ts |
Response structure validation | Imports types, no HTTP calls |
test/api-generate-plan.test.ts |
Plan generation API | Mocks validation logic, Port 3191 declared |
test/auth-security.test.ts |
Auth middleware | Integration tests with WebServer, Ports 3160/3161 |
test/qr-auth.test.ts |
QR authentication | Integration + unit tests, Port 3162 |
None of these test the route handlers themselves with real HTTP requests against a running Fastify instance.
Design Decisions
Shared mocks: Superset strategy
Rather than creating a lowest-common-denominator mock, MockSession in test/mocks/ will be the superset from respawn-test-utils.ts (the most complete version). Test files that need a simpler mock can just ignore the extra methods — having unused methods costs nothing, but missing methods forces local re-definition.
vi.mock() tests: Don't migrate
The session-manager.test.ts and ralph-loop.test.ts tests define mocks inside vi.mock() factories. These use module-level replacement (replacing ../src/session.js and ../src/state-store.js entirely), which is fundamentally different from the direct-instantiation pattern. Migrating them would require vi.hoisted() or factory restructuring — high complexity for limited benefit since these mocks are already working. We leave these as-is and create the shared mocks for new tests and for the two direct-instantiation tests (Tasks 4–5).
MockStateStore: Union of both shapes
The shared MockStateStore in test/mocks/ will include methods from both existing definitions (session management + Ralph loop), so any new test can use it. Methods default to no-ops via vi.fn(). Existing vi.mock()-based tests are not migrated.
Route testing strategy: Lightweight Fastify instances
Each route test file will:
- Create a minimal
Fastifyinstance - Register only the route module under test
- Provide a mock context object satisfying the port interfaces
- Use
app.inject()(Fastify's built-in test helper) — no real HTTP, no port needed
This avoids port conflicts entirely and runs fast. Only tests that need SSE or WebSocket behavior will use a real listening server with assigned ports.
Port assignments (for tests needing real servers)
| Port | Test File | Purpose |
|---|---|---|
| 3220 | test/routes/session-routes.test.ts |
SSE integration (if needed) |
| 3221 | test/routes/system-routes.test.ts |
Status/stats endpoints |
| 3222 | test/routes/respawn-routes.test.ts |
Respawn API |
| 3223 | test/routes/ralph-routes.test.ts |
Ralph API |
| 3224–3229 | Reserved | Future route tests |
Most tests should NOT need real ports — app.inject() is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in opencode-resize.test.ts).
Task Dependencies
Task 1 (Consolidate MockSession)
Task 2 (Consolidate MockStateStore)
└──> Task 3 (Create test/mocks/ barrel)
├──> Task 4 (Migrate respawn-controller.test.ts)
├──> Task 5 (Migrate respawn-team-awareness.test.ts)
└──> Task 6 (Route test scaffold + helpers)
├──> Task 7 (Session routes tests)
└──> Task 8 (System + respawn routes tests)
Task 9 (Slim down respawn-test-utils.ts) — depends on Tasks 4, 5
Tasks 1–2 are independent and can run in parallel. Task 3 depends on Tasks 1–2. Tasks 4–6 depend on Task 3 and can run in parallel. Tasks 7–8 depend on Task 6 and can run in parallel. Task 9 depends on Tasks 4, 5 (must verify migrations work before removing duplicates from source).
Task 1: Consolidate MockSession into test/mocks/mock-session.ts
Estimated effort: 2 hours
Files created: test/mocks/mock-session.ts
Files modified: None yet (consumers migrate in Tasks 4–5)
Source
The canonical MockSession comes from test/respawn-test-utils.ts (lines 89–241). It is the most complete version with:
- All properties needed by
RespawnController:id,workingDir,status,writeBuffer,terminalBuffer,muxName write()/writeViaMux()for input simulation- Buffer inspection:
lastWrite,hasWritten(pattern),clearWriteBuffer() - Terminal simulation:
simulateTerminalOutput(),simulatePrompt(),simulateReady(),simulateCompletionMessage(),simulateWorking(),simulateClearComplete(),simulateInitComplete(),simulatePlanModePrompt(),simulateElicitationDialog(),simulateTokenCount(),simulateAnsiOutput() - Lifecycle:
close()
Implementation
- Create
test/mocks/directory. - Create
test/mocks/mock-session.ts:- Copy the
MockSessionclass exactly fromtest/respawn-test-utils.ts(lines 89–241) - Copy
terminalOutputshelper object (tightly coupled to mock) - Copy
createMockSession()factory function - Export all three:
export { MockSession, createMockSession, terminalOutputs } - Ensure all
viimports come fromvitest
- Copy the
CRITICAL: Copy the source verbatim — do NOT rewrite the simulation methods. The respawn controller's detection logic matches specific output patterns (e.g., '\u276f ' for prompt, '\u273b Worked for' for completion). Using different patterns would cause test failures.
Template
/**
* Shared MockSession for tests that need terminal simulation.
*
* Copied from test/respawn-test-utils.ts (the canonical, most complete version).
* Used by respawn, route, and subagent tests.
*/
import { EventEmitter } from 'node:events';
// Copy MockSession class exactly from test/respawn-test-utils.ts lines 89–241
export class MockSession extends EventEmitter {
// ... (copy verbatim from respawn-test-utils.ts)
}
/**
* Factory for common terminal output strings.
* Must match the patterns used in MockSession's simulate* methods.
*/
export const terminalOutputs = {
// ... (copy verbatim from respawn-test-utils.ts)
};
/**
* Convenience factory.
*/
export function createMockSession(id?: string): MockSession {
return new MockSession(id);
}
Verification
tsc --noEmit # Ensure file compiles
Task 2: Consolidate MockStateStore into test/mocks/mock-state-store.ts
Estimated effort: 1 hour
Files created: test/mocks/mock-state-store.ts
Files modified: None (existing vi.mock()-based tests are NOT migrated; this is for new route tests)
Source
Union of both existing definitions:
- From
test/session-manager.test.ts: session CRUD methods (getConfig,getSession,setSession,removeSession,getSessions) - From
test/ralph-loop.test.ts: Ralph state methods (getConfig,getRalphLoopState,setRalphLoopState,getTasks,setTask,removeTask)
Template
/**
* Shared MockStateStore for tests.
*
* Includes methods for both session management and Ralph loop testing.
* All methods are vi.fn() spies — tests can override return values as needed.
*
* NOTE: This is for direct instantiation in new tests. Existing tests that
* use vi.mock('../src/state-store.js') keep their inline definitions.
*/
import { vi } from 'vitest';
export class MockStateStore {
state: Record<string, unknown> = {
sessions: {} as Record<string, unknown>,
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {} as Record<string, unknown>,
};
// Session methods
getConfig = vi.fn(() => this.state.config);
getSessions = vi.fn(() => this.state.sessions as Record<string, unknown>);
getSession = vi.fn((id: string) => (this.state.sessions as Record<string, unknown>)[id]);
setSession = vi.fn((id: string, state: unknown) => {
(this.state.sessions as Record<string, unknown>)[id] = state;
});
removeSession = vi.fn((id: string) => {
delete (this.state.sessions as Record<string, unknown>)[id];
});
// Ralph state methods
getRalphLoopState = vi.fn(() => this.state.ralphLoop);
setRalphLoopState = vi.fn((update: Record<string, unknown>) => {
this.state.ralphLoop = { ...(this.state.ralphLoop as Record<string, unknown>), ...update };
});
// Task methods
getTasks = vi.fn(() => this.state.tasks);
setTask = vi.fn();
removeTask = vi.fn();
// Settings methods
getSettings = vi.fn(() => ({}));
setSettings = vi.fn();
// Generic persistence
save = vi.fn();
load = vi.fn();
/** Reset all state and mocks for clean test isolation */
reset(): void {
this.state = {
sessions: {},
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {},
};
vi.clearAllMocks();
}
}
Verification
tsc --noEmit
Task 3: Create test/mocks/index.ts barrel export
Estimated effort: 30 minutes
Depends on: Tasks 1, 2
Files created: test/mocks/index.ts, test/mocks/test-helpers.ts
Files modified: None
Implementation
- Create
test/mocks/test-helpers.tswith the async utilities fromrespawn-test-utils.ts:
/**
* Reusable async test helpers.
* Extracted from respawn-test-utils.ts.
*/
/** Wait for an EventEmitter to emit a specific event, with timeout */
export function waitForEvent(
emitter: { once: (event: string, listener: (...args: unknown[]) => void) => void },
event: string,
timeoutMs = 5000,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
timeoutMs,
);
emitter.once(event, (...args: unknown[]) => {
clearTimeout(timer);
resolve(args.length === 1 ? args[0] : args);
});
});
}
/** Create a deferred promise with external resolve/reject */
export function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
- Create
test/mocks/index.tsbarrel:
/**
* Shared test mocks — import from here instead of defining inline.
*
* @example
* import { MockSession, MockStateStore, terminalOutputs } from './mocks/index.js';
*/
export { MockSession, createMockSession, terminalOutputs } from './mock-session.js';
export { MockStateStore } from './mock-state-store.js';
export { waitForEvent, createDeferred } from './test-helpers.js';
Verification
tsc --noEmit
Task 4: Migrate respawn-controller.test.ts to shared mocks
Estimated effort: 30 minutes
Depends on: Task 3
Files modified: test/respawn-controller.test.ts
Steps
- Remove the local
MockSessionclass definition (approx. 50 lines). - Add:
import { MockSession } from './mocks/index.js'; - Verify all test methods still exist on the shared mock. The shared mock is a superset, so all existing usage should work.
- If the local mock had any test-specific customizations (e.g., extra properties added in
beforeEach), keep those in the test file as inline assignments on the shared instance. - Run the test to confirm it passes.
Potential issues
- The local mock's
simulateCompletionMessage()may have a slightly different output format than the shared mock's (from respawn-test-utils.ts). Verify the respawn controller's completion detection regex matches the shared mock's output pattern ('\u273b Worked for ...'). - If the local mock adds
pidorisWorkingproperties that the shared mock doesn't have, add inline assignments inbeforeEach.
Verification
npx vitest run test/respawn-controller.test.ts
Task 5: Migrate respawn-team-awareness.test.ts to shared mocks
Estimated effort: 30 minutes
Depends on: Task 3
Files modified: test/respawn-team-awareness.test.ts
Steps
- Remove the local
MockSessionclass definition. - Add:
import { MockSession } from './mocks/index.js'; - Keep
MockTeamWatcherin this file — it's test-specific and extends the realTeamWatcher, not a general-purpose mock. - Run the test to confirm it passes.
Verification
npx vitest run test/respawn-team-awareness.test.ts
Task 6: Create route test scaffold and helpers
Estimated effort: 2 hours
Depends on: Task 3
Files created: test/mocks/mock-route-context.ts, test/routes/ directory, test/routes/_route-test-utils.ts
Problem
The 12 route modules in src/web/routes/ have zero dedicated test coverage. Each route module takes (app: FastifyInstance, ctx: PortIntersection) — we need a reusable way to create mock context objects that satisfy the port interfaces.
Design
Create a MockRouteContext factory that builds a mock object satisfying all port interfaces. Each port's methods are vi.fn() stubs. Tests can override specific methods as needed.
Route registration signatures (verified)
Each route module requires a specific port intersection. The mock must satisfy all of them:
| Route Module | Required Ports |
|---|---|
registerSessionRoutes |
SessionPort & EventPort & ConfigPort & InfraPort & AuthPort |
registerSystemRoutes |
SessionPort & EventPort & ConfigPort & InfraPort & AuthPort |
registerRespawnRoutes |
SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort |
registerRalphRoutes |
SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort |
registerPlanRoutes |
SessionPort & EventPort & ConfigPort & InfraPort |
registerCaseRoutes |
EventPort & ConfigPort |
registerScheduledRoutes |
SessionPort & EventPort & InfraPort |
registerFileRoutes |
SessionPort |
registerMuxRoutes |
InfraPort |
registerPushRoutes |
InfraPort |
registerTeamRoutes |
InfraPort |
registerHookEventRoutes |
EventPort & AuthPort |
Implementation
- Create
test/mocks/mock-route-context.ts:
/**
* Mock context for route handler testing.
*
* Satisfies ALL port interfaces (SessionPort, EventPort, RespawnPort,
* ConfigPort, InfraPort, AuthPort) so any route module can be tested.
* Override specific methods in individual tests as needed.
*
* Verified against actual port interfaces in src/web/ports/:
* - SessionPort: 6 methods (sessions, addSession, cleanupSession,
* setupSessionListeners, persistSessionState, persistSessionStateNow,
* getSessionStateWithRespawn)
* - EventPort: 5 methods (broadcast, sendPushNotifications, batchTerminalData,
* broadcastSessionStateDebounced, batchTaskUpdate)
* - RespawnPort: 2 maps + 4 methods
* - ConfigPort: 5 readonly + 7 methods (incl getDefaultClaudeMdPath,
* getLightState, getLightSessionsState, stopTranscriptWatcher)
* - InfraPort: 7 readonly + 2 methods (startScheduledRun, stopScheduledRun)
* - AuthPort: 3 readonly (authSessions, qrAuthFailures, https)
*/
import { vi } from 'vitest';
import { MockSession, createMockSession } from './mock-session.js';
/**
* Creates a mock context that satisfies all port interfaces.
* Pre-populated with one session for convenience.
*/
export function createMockRouteContext(options?: { sessionId?: string }) {
const sessionId = options?.sessionId ?? 'test-session-1';
const session = createMockSession(sessionId);
const sessions = new Map<string, MockSession>();
sessions.set(sessionId, session);
return {
// -- SessionPort --
sessions,
addSession: vi.fn(),
cleanupSession: vi.fn(),
setupSessionListeners: vi.fn(),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
getSessionStateWithRespawn: vi.fn((s: unknown) => s),
// -- EventPort --
broadcast: vi.fn(),
sendPushNotifications: vi.fn(),
batchTerminalData: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
batchTaskUpdate: vi.fn(),
// -- RespawnPort --
respawnControllers: new Map(),
respawnTimers: new Map(),
setupRespawnListeners: vi.fn(),
setupTimedRespawn: vi.fn(),
restoreRespawnController: vi.fn(),
saveRespawnConfig: vi.fn(),
// -- ConfigPort --
store: {
getConfig: vi.fn(() => ({})),
getSessions: vi.fn(() => ({})),
getSession: vi.fn(),
setSession: vi.fn(),
removeSession: vi.fn(),
getSettings: vi.fn(() => ({})),
setSettings: vi.fn(),
getRalphLoopState: vi.fn(() => ({})),
setRalphLoopState: vi.fn(),
getTasks: vi.fn(() => ({})),
save: vi.fn(),
load: vi.fn(),
},
port: 3000,
https: false,
testMode: true,
serverStartTime: Date.now(),
getGlobalNiceConfig: vi.fn(async () => undefined),
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => []),
startTranscriptWatcher: vi.fn(),
stopTranscriptWatcher: vi.fn(),
// -- InfraPort --
mux: {
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
getStats: vi.fn(() => ({})),
},
runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(),
scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null,
pushStore: null,
startScheduledRun: vi.fn(),
stopScheduledRun: vi.fn(),
// -- AuthPort --
authSessions: null,
qrAuthFailures: null,
// https already declared above in ConfigPort (shared property)
// Convenience accessors (not part of any port interface)
_session: session,
_sessionId: sessionId,
};
}
export type MockRouteContext = ReturnType<typeof createMockRouteContext>;
- Add to
test/mocks/index.tsbarrel:
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
-
Create
test/routes/directory for route test files. -
Create
test/routes/_route-test-utils.tswith Fastify test helpers:
/**
* Shared utilities for route testing.
*
* Creates minimal Fastify instances with just the route module under test
* and a mock context. Uses app.inject() for HTTP testing without real ports.
*/
import Fastify, { type FastifyInstance } from 'fastify';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
export interface RouteTestHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* Creates a Fastify instance with a route module registered against a mock context.
*
* @param registerFn - The route registration function (e.g., registerSessionRoutes).
* Uses `any` for ctx parameter because route functions expect typed port intersections
* that MockRouteContext satisfies structurally but not nominally.
* @param ctxOptions - Optional overrides for the mock context
*/
export async function createRouteTestHarness(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registerFn: (app: FastifyInstance, ctx: any) => void,
ctxOptions?: { sessionId?: string },
): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
await app.ready();
return { app, ctx };
}
Why ctx: any in the harness
Route registration functions like registerSessionRoutes(app, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort) expect specific port intersection types. TypeScript won't accept unknown here because it's not assignable to the port types. The MockRouteContext satisfies the interfaces structurally (it has all the required properties and methods), but since it's not declared as implementing them, we need any at the call site. This is the standard pattern for test mocks in TypeScript.
Verification
tsc --noEmit
Task 7: Add session routes tests
Estimated effort: 4 hours
Depends on: Task 6
Files created: test/routes/session-routes.test.ts
Port: 3220 (only if SSE tests needed; prefer app.inject())
Coverage targets
src/web/routes/session-routes.ts is the largest route module (43 handlers). Focus on the most critical endpoints first:
Priority 1: Session CRUD (must test)
| Method | Path | What to test |
|---|---|---|
GET |
/api/sessions |
Returns session list; empty when no sessions |
GET |
/api/sessions/:id |
Returns session state; 404 for unknown ID |
POST |
/api/sessions |
Creates session; validates workingDir; rejects invalid paths |
DELETE |
/api/sessions/:id |
Calls cleanupSession; 404 for unknown ID |
Priority 2: Session I/O
| Method | Path | What to test |
|---|---|---|
POST |
/api/sessions/:id/input |
Sends input to session; validates input length; 404 for unknown |
POST |
/api/sessions/:id/resize |
Validates cols/rows bounds; 404 for unknown |
GET |
/api/sessions/:id/buffer |
Returns terminal buffer; 404 for unknown |
Priority 3: Session actions
| Method | Path | What to test |
|---|---|---|
POST |
/api/sessions/:id/run |
Runs prompt on session |
POST |
/api/sessions/:id/clear |
Clears session |
POST |
/api/sessions/:id/compact |
Compacts session |
POST |
/api/sessions/:id/interactive |
Starts interactive mode |
POST |
/api/sessions/:id/quick-start |
Quick start flow |
Test pattern
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
describe('session-routes', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});
afterEach(async () => {
await harness.app.close();
});
describe('GET /api/sessions', () => {
it('returns empty array when no sessions', async () => {
harness.ctx.sessions.clear();
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual([]);
});
it('returns session list with one session', async () => {
const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' });
expect(res.statusCode).toBe(200);
const sessions = JSON.parse(res.body);
expect(sessions).toHaveLength(1);
});
});
describe('GET /api/sessions/:id', () => {
it('returns 404 for unknown session', async () => {
const res = await harness.app.inject({
method: 'GET',
url: '/api/sessions/nonexistent',
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/input', () => {
it('rejects input exceeding max length', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/input`,
payload: { input: 'x'.repeat(65537) },
});
expect(res.statusCode).toBe(400);
});
});
describe('POST /api/sessions/:id/resize', () => {
it('rejects cols exceeding max', async () => {
const res = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/resize`,
payload: { cols: 501, rows: 24 },
});
expect(res.statusCode).toBe(400);
});
});
});
Key assertions to include
- 404 for unknown sessions: Every
:idendpoint must return 404 for nonexistent IDs - Input validation: Bad paths, oversized inputs, invalid resize dimensions
- Side effects: Verify
ctx.broadcast()was called with correct event type after mutations - Response shape: Verify response bodies match expected API types
Verification
npx vitest run test/routes/session-routes.test.ts
Task 8: Add system + respawn routes tests
Estimated effort: 4 hours
Depends on: Task 6
Files created: test/routes/system-routes.test.ts, test/routes/respawn-routes.test.ts
System routes (src/web/routes/system-routes.ts)
Focus on status and configuration endpoints:
| Method | Path | What to test |
|---|---|---|
GET |
/api/status |
Returns server status with uptime, session count |
GET |
/api/stats |
Returns mux stats |
GET |
/api/config |
Returns current config |
PUT |
/api/config |
Updates config; validates input |
GET |
/api/settings |
Returns user settings |
PUT |
/api/settings |
Updates settings; validates input |
GET |
/api/subagents |
Returns subagent list |
GET |
/api/screenshots |
Returns screenshot list |
Respawn routes (src/web/routes/respawn-routes.ts)
| Method | Path | What to test |
|---|---|---|
GET |
/api/sessions/:id/respawn |
Returns respawn status; null when not configured |
POST |
/api/sessions/:id/respawn/start |
Starts respawn; 404 for unknown session |
POST |
/api/sessions/:id/respawn/stop |
Stops respawn; 404 for unknown session |
PUT |
/api/sessions/:id/respawn/config |
Updates respawn config; validates |
POST |
/api/sessions/:id/respawn/enable |
Enables respawn loop |
POST |
/api/sessions/:id/respawn/disable |
Disables respawn loop |
Test patterns
Same pattern as Task 7 — createRouteTestHarness with registerSystemRoutes / registerRespawnRoutes.
For respawn tests, pre-populate ctx.respawnControllers with a mock controller in beforeEach:
beforeEach(async () => {
harness = await createRouteTestHarness(registerRespawnRoutes);
// Add a mock respawn controller for the default session
harness.ctx.respawnControllers.set(harness.ctx._sessionId, {
getState: vi.fn(() => 'idle'),
getConfig: vi.fn(() => ({})),
getStatus: vi.fn(() => ({ state: 'idle', health: 100 })),
start: vi.fn(),
stop: vi.fn(),
updateConfig: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
});
});
Verification
npx vitest run test/routes/system-routes.test.ts
npx vitest run test/routes/respawn-routes.test.ts
Task 9: Slim down respawn-test-utils.ts
Estimated effort: 30 minutes
Depends on: Tasks 4, 5
Files modified: test/respawn-test-utils.ts
After Tasks 4–5 are verified passing with shared mocks, slim down respawn-test-utils.ts to remove duplicates.
Steps
-
Remove from
respawn-test-utils.tswhat has been moved to shared mocks:MockSessionclass → now intest/mocks/mock-session.tscreateMockSession()→ now intest/mocks/mock-session.tsterminalOutputs→ now intest/mocks/mock-session.tswaitForEvent()/createDeferred()→ now intest/mocks/test-helpers.ts
-
Keep respawn-specific utilities that don't belong in the general mocks:
TimeController/createTimeController()— respawn-specific timer controlMockAiIdleChecker/MockAiPlanChecker— respawn-specific AI mockscreateStateTracker()/createEventRecorder()— respawn state trackingFAST_TEST_CONFIG/AI_ENABLED_TEST_CONFIG— respawn config presetswaitForState()— respawn state machine waiter
-
Update imports in
respawn-test-utils.tsto re-use shared mocks:import { MockSession, createMockSession, terminalOutputs } from './mocks/index.js'; import { waitForEvent, createDeferred } from './mocks/index.js'; export { MockSession, createMockSession, terminalOutputs, waitForEvent, createDeferred };
This preserves backward compatibility for any future tests that import from respawn-test-utils.ts directly while eliminating the duplication.
Verification
tsc --noEmit
npx vitest run test/respawn-controller.test.ts
npx vitest run test/respawn-team-awareness.test.ts
What is NOT in scope (and why)
Migrating session-manager.test.ts and ralph-loop.test.ts mocks
Both files define mocks inside vi.mock() factories that replace entire modules:
// session-manager.test.ts — mock replaces ../src/session.js
vi.mock('../src/session.js', () => {
class MockSession extends EventEmitter { ... }
return { Session: MockSession };
});
// ralph-loop.test.ts — mock replaces ../src/state-store.js
vi.mock('../src/state-store.js', () => {
class MockStateStore { ... }
return { getStore: vi.fn(() => instance), StateStore: MockStateStore };
});
These are fundamentally different from the direct-instantiation pattern:
- The
vi.mock()factory runs in an isolated scope — outer imports are not available - The mock class must be returned with the exact export names (
Session,getStore,StateStore) - The
session-manager.test.tsMockSession auto-registers into a sharedmockState.sessionsMap (tight coupling with test setup)
Migrating would require vi.hoisted() to share the class between factory and test scope, plus restructuring the test's module-mocking setup. This is high-complexity, high-risk refactoring with limited benefit since these tests already work. The shared MockStateStore in test/mocks/ is available for new tests (like route tests) that use direct instantiation instead.
Full integration tests with real Fastify server
Route tests use app.inject() which simulates HTTP without opening ports. Full integration tests that spin up WebServer, create real sessions, and stream SSE would be valuable but are a separate effort requiring:
- A test WebServer factory
- Session lifecycle management in tests
- SSE client test utilities
- Significantly more setup/teardown complexity
Testing auth middleware in route tests
Route tests bypass authentication (no auth middleware registered on the test Fastify instance). Auth middleware has its own dedicated tests in auth-security.test.ts and qr-auth.test.ts. Testing auth + routes together is a future integration test concern.
Testing SSE event streaming
SSE integration requires a running server with EventSource client. This is significantly more complex than app.inject() tests and is deferred. The existing sse-events.test.ts covers SSE patterns.
Complete route coverage for all 12 modules
This phase covers the 3 highest-value route modules (session, system, respawn — 98 of 162 handlers). The remaining 9 modules (ralph, plan, push, team, mux, file, scheduled, hook-event, case) should be added incrementally in follow-up work.
Summary
| Metric | Before | After |
|---|---|---|
| MockSession definitions | 4 (across 4 files) | 1 shared (2 vi.mock() copies remain, intentionally) |
| MockStateStore definitions | 2 (across 2 files) | 1 shared (2 vi.mock() copies remain, intentionally) |
Files importing from respawn-test-utils.ts |
0 | Utilities split into test/mocks/ |
| Route test files | 0 | 3 (session, system, respawn) |
| Route handlers with dedicated tests | 0 | ~30 (highest-priority endpoints) |
| Shared mock directory | None | test/mocks/ with 5 files + barrel |
Final verification checklist
# Type checking
tsc --noEmit
# Linting
npm run lint
# Formatting
npm run format:check
# Run all affected tests individually
npx vitest run test/respawn-controller.test.ts
npx vitest run test/respawn-team-awareness.test.ts
npx vitest run test/routes/session-routes.test.ts
npx vitest run test/routes/system-routes.test.ts
npx vitest run test/routes/respawn-routes.test.ts
# Verify unchanged tests still pass
npx vitest run test/session-manager.test.ts
npx vitest run test/ralph-loop.test.ts
# Dev server still starts
npx tsx src/index.ts web --port 3099 &
curl -s http://localhost:3099/api/status | jq .status # "ok"
kill %1