Files
Codeman/docs/archive/phase7-test-infrastructure-plan.md
arkonandClaude Opus 4.7 1c3f2f6571 docs: tighten CLAUDE.md and archive 22 completed plan docs
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>
2026-04-23 11:14:07 +02:00

36 KiB
Raw Permalink Blame History

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:

  1. Never run npx vitest run (full suite) — it kills tmux sessions. You are running inside a Codeman-managed tmux session.
  2. Run individual tests only: npx vitest run test/<file>.test.ts
  3. Never test on port 3000 — the live dev server runs there. Tests use ports 3150+.
  4. After TypeScript changes: Run tsc --noEmit to verify type checking passes.
  5. Before considering done: Run npm run lint and npm run format:check to ensure CI passes.
  6. Never kill tmux sessions — check echo $CODEMAN_MUX first.
  7. 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:

  1. Direct instantiation (respawn-controller, respawn-team-awareness): MockSession is defined at file scope and instantiated directly in tests. These can be migrated to shared mocks via simple import replacement.

  2. 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 either vi.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 timers
  • MockAiIdleChecker / MockAiPlanChecker — fully mocked AI checkers with result queueing
  • createStateTracker() / createEventRecorder() — state transition and event recording
  • FAST_TEST_CONFIG / AI_ENABLED_TEST_CONFIG — pre-configured RespawnConfig objects
  • waitForState() / waitForEvent() / createDeferred() — async test helpers
  • terminalOutputs — 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:

  1. Create a minimal Fastify instance
  2. Register only the route module under test
  3. Provide a mock context object satisfying the port interfaces
  4. 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

  1. Create test/mocks/ directory.
  2. Create test/mocks/mock-session.ts:
    • Copy the MockSession class exactly from test/respawn-test-utils.ts (lines 89–241)
    • Copy terminalOutputs helper object (tightly coupled to mock)
    • Copy createMockSession() factory function
    • Export all three: export { MockSession, createMockSession, terminalOutputs }
    • Ensure all vi imports come from vitest

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

  1. Create test/mocks/test-helpers.ts with the async utilities from respawn-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 };
}
  1. Create test/mocks/index.ts barrel:
/**
 * 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

  1. Remove the local MockSession class definition (approx. 50 lines).
  2. Add: import { MockSession } from './mocks/index.js';
  3. Verify all test methods still exist on the shared mock. The shared mock is a superset, so all existing usage should work.
  4. 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.
  5. 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 pid or isWorking properties that the shared mock doesn't have, add inline assignments in beforeEach.

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

  1. Remove the local MockSession class definition.
  2. Add: import { MockSession } from './mocks/index.js';
  3. Keep MockTeamWatcher in this file — it's test-specific and extends the real TeamWatcher, not a general-purpose mock.
  4. 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

  1. 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>;
  1. Add to test/mocks/index.ts barrel:
export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js';
  1. Create test/routes/ directory for route test files.

  2. Create test/routes/_route-test-utils.ts with 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 :id endpoint 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

  1. Remove from respawn-test-utils.ts what has been moved to shared mocks:

    • MockSession class → now in test/mocks/mock-session.ts
    • createMockSession() → now in test/mocks/mock-session.ts
    • terminalOutputs → now in test/mocks/mock-session.ts
    • waitForEvent() / createDeferred() → now in test/mocks/test-helpers.ts
  2. Keep respawn-specific utilities that don't belong in the general mocks:

    • TimeController / createTimeController() — respawn-specific timer control
    • MockAiIdleChecker / MockAiPlanChecker — respawn-specific AI mocks
    • createStateTracker() / createEventRecorder() — respawn state tracking
    • FAST_TEST_CONFIG / AI_ENABLED_TEST_CONFIG — respawn config presets
    • waitForState() — respawn state machine waiter
  3. Update imports in respawn-test-utils.ts to 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.ts MockSession auto-registers into a shared mockState.sessions Map (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