diff --git a/docs/phase7-test-infrastructure-plan.md b/docs/phase7-test-infrastructure-plan.md new file mode 100644 index 00000000..0023c081 --- /dev/null +++ b/docs/phase7-test-infrastructure-plan.md @@ -0,0 +1,953 @@ +# 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/.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 + +```typescript +/** + * 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 + +```bash +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 + +```typescript +/** + * 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 = { + sessions: {} as Record, + config: { maxConcurrentSessions: 5 }, + ralphLoop: { status: 'stopped' }, + tasks: {} as Record, + }; + + // Session methods + getConfig = vi.fn(() => this.state.config); + getSessions = vi.fn(() => this.state.sessions as Record); + getSession = vi.fn((id: string) => (this.state.sessions as Record)[id]); + setSession = vi.fn((id: string, state: unknown) => { + (this.state.sessions as Record)[id] = state; + }); + removeSession = vi.fn((id: string) => { + delete (this.state.sessions as Record)[id]; + }); + + // Ralph state methods + getRalphLoopState = vi.fn(() => this.state.ralphLoop); + setRalphLoopState = vi.fn((update: Record) => { + this.state.ralphLoop = { ...(this.state.ralphLoop as Record), ...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 + +```bash +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`: + +```typescript +/** + * 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 { + 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(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} +``` + +2. Create `test/mocks/index.ts` barrel: + +```typescript +/** + * 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 + +```bash +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 + +```bash +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 + +```bash +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`: + +```typescript +/** + * 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(); + 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; +``` + +2. Add to `test/mocks/index.ts` barrel: + +```typescript +export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js'; +``` + +3. Create `test/routes/` directory for route test files. + +4. Create `test/routes/_route-test-utils.ts` with Fastify test helpers: + +```typescript +/** + * 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 { + 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 + +```bash +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 + +```typescript +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 + +```bash +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`: + +```typescript +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 + +```bash +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: + ```typescript + 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 + +```bash +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: + +```typescript +// 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 + +```bash +# 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 +``` diff --git a/test/mocks/index.ts b/test/mocks/index.ts new file mode 100644 index 00000000..0a17f837 --- /dev/null +++ b/test/mocks/index.ts @@ -0,0 +1,11 @@ +/** + * 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'; +export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js'; diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts new file mode 100644 index 00000000..4fe5fd18 --- /dev/null +++ b/test/mocks/mock-route-context.ts @@ -0,0 +1,121 @@ +/** + * 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. + * + * Uses app.inject() — no real HTTP ports needed. + */ +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(); + sessions.set(sessionId, session); + + return { + // -- SessionPort -- + sessions, + addSession: vi.fn((s: MockSession) => { + sessions.set(s.id, s); + }), + cleanupSession: vi.fn(async () => {}), + setupSessionListeners: vi.fn(async () => {}), + persistSessionState: vi.fn(), + persistSessionStateNow: vi.fn(), + getSessionStateWithRespawn: vi.fn((s: MockSession) => s.toState()), + + // -- 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(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })), + 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(), + incrementSessionsCreated: vi.fn(), + setConfig: vi.fn(), + getAggregateStats: vi.fn(() => ({ totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0 })), + getGlobalStats: vi.fn(() => ({ sessionsCreated: 0 })), + getDailyStats: vi.fn(() => []), + cleanupStaleSessions: vi.fn(() => ({ count: 0, cleaned: [] })), + }, + 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(() => { + const result: Record[] = []; + for (const s of sessions.values()) { + result.push(s.toState()); + } + return result; + }), + startTranscriptWatcher: vi.fn(), + stopTranscriptWatcher: vi.fn(), + + // -- InfraPort -- + mux: { + createSession: vi.fn(), + killSession: vi.fn(), + listSessions: vi.fn(() => []), + getStats: vi.fn(() => ({})), + updateSessionName: vi.fn(() => true), + getSession: vi.fn(() => null), + clearRespawnConfig: vi.fn(), + updateRespawnConfig: 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; diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts new file mode 100644 index 00000000..e1337532 --- /dev/null +++ b/test/mocks/mock-session.ts @@ -0,0 +1,312 @@ +/** + * 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'; +import { vi } from 'vitest'; + +/** + * Enhanced mock session for testing RespawnController. + * Extends the existing MockSession pattern with additional utilities. + */ +export class MockSession extends EventEmitter { + id: string; + workingDir: string = '/tmp/test-workdir'; + status: 'idle' | 'working' = 'idle'; + pid: number = 12345; + isWorking: boolean = false; + ralphTracker: null = null; + writeBuffer: string[] = []; + terminalBuffer: string = ''; + + private _muxName: string | null = null; + + constructor(id: string = 'mock-session-id') { + super(); + this.id = id; + this._muxName = `codeman-test-${id.slice(0, 8)}`; + } + + /** Direct PTY write (used by session.write()) */ + write(data: string): void { + this.writeBuffer.push(data); + } + + /** Write via mux (used by respawn controller) */ + async writeViaMux(data: string): Promise { + this.writeBuffer.push(data); + return true; + } + + /** Get the last written data */ + get lastWrite(): string | undefined { + return this.writeBuffer[this.writeBuffer.length - 1]; + } + + /** Clear the write buffer */ + clearWriteBuffer(): void { + this.writeBuffer = []; + } + + /** Check if a specific command was written */ + hasWritten(pattern: string | RegExp): boolean { + return this.writeBuffer.some((data) => + typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data), + ); + } + + // ========== Terminal Output Simulation ========== + + /** Simulate raw terminal output */ + simulateTerminalOutput(data: string): void { + this.terminalBuffer += data; + this.emit('terminal', data); + } + + /** Simulate prompt appearing (legacy fallback signal) */ + simulatePrompt(): void { + this.simulateTerminalOutput('\u276f '); + this.status = 'idle'; + this.emit('idle'); + } + + /** Simulate ready state with definitive indicator (legacy) */ + simulateReady(): void { + this.simulateTerminalOutput('\u21b5 send'); + this.status = 'idle'; + this.emit('idle'); + } + + /** + * Simulate completion message (primary idle detection in Claude Code 2024+). + * This triggers the multi-layer detection flow. + */ + simulateCompletionMessage(duration: string = '2m 46s'): void { + this.simulateTerminalOutput(`\u273b Worked for ${duration}`); + this.status = 'idle'; + } + + /** Simulate working state with spinner */ + simulateWorking(text: string = 'Thinking'): void { + this.simulateTerminalOutput(`${text}... \u280b`); + this.status = 'working'; + this.emit('working'); + } + + /** Simulate /clear completion */ + simulateClearComplete(): void { + this.simulateTerminalOutput('conversation cleared'); + setTimeout(() => this.simulateCompletionMessage(), 50); + } + + /** Simulate /init completion */ + simulateInitComplete(): void { + this.simulateTerminalOutput('Analyzing CLAUDE.md...'); + setTimeout(() => this.simulateCompletionMessage(), 100); + } + + /** + * Simulate plan mode approval prompt. + * This triggers auto-accept detection. + */ + simulatePlanModePrompt(): void { + this.simulateTerminalOutput( + 'Would you like to proceed with this plan?\n' + + '\u276f 1. Yes\n' + + ' 2. No\n' + + ' 3. Type your own\n', + ); + } + + /** + * Simulate elicitation dialog (AskUserQuestion). + * This should block auto-accept. + */ + simulateElicitationDialog(): void { + this.simulateTerminalOutput('What would you like to name the new file?\n' + '> '); + } + + /** Simulate token count display */ + simulateTokenCount(tokens: number | string): void { + const formatted = + typeof tokens === 'number' + ? tokens >= 1000 + ? `${(tokens / 1000).toFixed(1)}k` + : String(tokens) + : tokens; + this.simulateTerminalOutput(`${formatted} tokens used`); + } + + /** Simulate ANSI escape codes */ + simulateAnsiOutput(text: string, color: 'green' | 'red' | 'blue' = 'green'): void { + const codes: Record = { + green: '\x1b[32m', + red: '\x1b[31m', + blue: '\x1b[34m', + }; + this.simulateTerminalOutput(`${codes[color]}${text}\x1b[0m`); + } + + /** Clear terminal buffer */ + clearTerminalBuffer(): void { + this.terminalBuffer = ''; + } + + // ========== Session Lifecycle ========== + + /** Simulate session closing */ + close(): void { + this.emit('exit', 0); + this.removeAllListeners(); + } + + /** Get mux name (for mux-based operations) */ + get muxName(): string | null { + return this._muxName; + } + + // ========== Route-test conveniences ========== + + /** Session display name */ + name: string = 'test-session'; + + /** Session color tag */ + color: string = 'default'; + + /** CLI mode */ + mode: string = 'claude'; + + /** Text output buffer (stripped of ANSI) */ + textOutput: string = ''; + + /** Structured messages */ + messages: unknown[] = []; + + /** Error buffer */ + errorBuffer: string = ''; + + /** Ralph loop state */ + ralphLoopState: Record | null = null; + + /** Ralph todo items */ + ralphTodos: unknown[] = []; + + /** Ralph todo statistics */ + ralphTodoStats: Record = {}; + + /** Currently active tools */ + activeTools: unknown[] = []; + + /** Token tracking */ + inputTokens: number = 0; + outputTokens: number = 0; + totalCost: number = 0; + + /** Terminal buffer byte length */ + get terminalBufferLength(): number { + return this.terminalBuffer.length; + } + + /** Return a state-like object for route handlers */ + toState(): Record { + return { + id: this.id, + workingDir: this.workingDir, + status: this.status, + name: this.name, + color: this.color, + mode: this.mode, + muxName: this._muxName, + }; + } + + /** Check if session is busy */ + isBusy = vi.fn(() => false); + + /** Set session color */ + setColor = vi.fn((c: string) => { + this.color = c; + }); + + /** Stub for sendInput */ + sendInput = vi.fn(); + + /** Stub for resize */ + resize = vi.fn(); + + /** Stub for runPrompt */ + runPrompt = vi.fn(async () => {}); + + /** Stub for startInteractive */ + startInteractive = vi.fn(async () => {}); + + /** Stub for startShell */ + startShell = vi.fn(async () => {}); + + /** Stub for compact */ + compact = vi.fn(); + + /** Stub for getTextOutput */ + getTextOutput = vi.fn(() => ''); + + /** Stub for getMessages */ + getMessages = vi.fn(() => []); +} + +/** + * Generate realistic terminal output for testing. + * Must match the patterns used in MockSession's simulate* methods. + */ +export const terminalOutputs = { + /** Standard completion message */ + completion(duration: string = '2m 46s'): string { + return `\n\u273b Worked for ${duration}\n 123.4k tokens used\n`; + }, + + /** Working spinner output */ + working(activity: string = 'Thinking'): string { + return `${activity}... \u280b`; + }, + + /** Plan mode prompt */ + planMode(question: string = 'Would you like to proceed?'): string { + return [`\n${question}\n`, '\u276f 1. Yes\n', ' 2. No\n', ' 3. Type your own\n'].join(''); + }, + + /** Prompt character */ + prompt(): string { + return '\n\u276f '; + }, + + /** Token count display */ + tokens(count: number): string { + const formatted = + count >= 1000000 + ? `${(count / 1000000).toFixed(1)}M` + : count >= 1000 + ? `${(count / 1000).toFixed(1)}k` + : String(count); + return ` ${formatted} tokens\n`; + }, + + /** Large output for buffer testing */ + largeOutput(sizeKb: number = 100): string { + const baseText = 'Lorem ipsum dolor sit amet. '.repeat(100); + const repetitions = Math.ceil((sizeKb * 1024) / baseText.length); + return baseText.repeat(repetitions).slice(0, sizeKb * 1024); + }, + + /** ANSI colored output */ + ansiColored(text: string): string { + return `\x1b[32m${text}\x1b[0m`; + }, +}; + +/** + * Convenience factory. + */ +export function createMockSession(id?: string): MockSession { + return new MockSession(id); +} diff --git a/test/mocks/mock-state-store.ts b/test/mocks/mock-state-store.ts new file mode 100644 index 00000000..e9d09bff --- /dev/null +++ b/test/mocks/mock-state-store.ts @@ -0,0 +1,60 @@ +/** + * 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 = { + sessions: {} as Record, + config: { maxConcurrentSessions: 5 }, + ralphLoop: { status: 'stopped' }, + tasks: {} as Record, + }; + + // Session methods + getConfig = vi.fn(() => this.state.config); + getSessions = vi.fn(() => this.state.sessions as Record); + getSession = vi.fn((id: string) => (this.state.sessions as Record)[id]); + setSession = vi.fn((id: string, state: unknown) => { + (this.state.sessions as Record)[id] = state; + }); + removeSession = vi.fn((id: string) => { + delete (this.state.sessions as Record)[id]; + }); + + // Ralph state methods + getRalphLoopState = vi.fn(() => this.state.ralphLoop); + setRalphLoopState = vi.fn((update: Record) => { + this.state.ralphLoop = { ...(this.state.ralphLoop as Record), ...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(); + } +} diff --git a/test/mocks/test-helpers.ts b/test/mocks/test-helpers.ts new file mode 100644 index 00000000..d025d24b --- /dev/null +++ b/test/mocks/test-helpers.ts @@ -0,0 +1,37 @@ +/** + * 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 { + 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(): { + promise: Promise; + resolve: (value: T) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index 206bbd6d..fa56bbf2 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { RespawnController, RespawnState, RespawnConfig } from '../src/respawn-controller.js'; import { Session } from '../src/session.js'; -import { EventEmitter } from 'node:events'; +import { MockSession } from './mocks/index.js'; /** * RespawnController Tests @@ -10,63 +10,6 @@ import { EventEmitter } from 'node:events'; * State flow: WATCHING → SENDING_UPDATE → WAITING_UPDATE → SENDING_CLEAR → WAITING_CLEAR → SENDING_INIT → WAITING_INIT → WATCHING */ -// Mock Session for testing -class MockSession extends EventEmitter { - id = 'mock-session-id'; - workingDir = '/tmp'; - status = 'idle'; - pid = 12345; // Mock PID for P1-006 health check - isWorking = false; // P0-006 Session.isWorking integration - writeBuffer: string[] = []; - - write(data: string): void { - this.writeBuffer.push(data); - } - - async writeViaMux(data: string): Promise { - this.writeBuffer.push(data); - return true; - } - - // Simulate terminal output - simulateTerminalOutput(data: string): void { - this.emit('terminal', data); - } - - // Simulate prompt appearing (basic prompt character) - legacy fallback - simulatePrompt(): void { - this.emit('terminal', '❯ '); - } - - // Simulate ready state with the definitive indicator - legacy fallback - simulateReady(): void { - this.emit('terminal', '↵ send'); - } - - // Simulate completion message (NEW - primary idle detection in Claude Code 2024+) - // This pattern triggers the multi-layer detection: "for Xm Xs" indicates work finished - simulateCompletionMessage(): void { - this.emit('terminal', '✻ Worked for 2m 46s'); - } - - // Simulate working state - simulateWorking(): void { - this.emit('terminal', 'Thinking... ⠋'); - } - - // Simulate clear completion (followed by completion message) - simulateClearComplete(): void { - this.emit('terminal', 'conversation cleared'); - setTimeout(() => this.simulateCompletionMessage(), 50); - } - - // Simulate init completion (followed by completion message) - simulateInitComplete(): void { - this.emit('terminal', 'Analyzing CLAUDE.md...'); - setTimeout(() => this.simulateCompletionMessage(), 100); - } -} - describe('RespawnController', () => { let session: MockSession; let controller: RespawnController; diff --git a/test/respawn-team-awareness.test.ts b/test/respawn-team-awareness.test.ts index 0275273f..52451afe 100644 --- a/test/respawn-team-awareness.test.ts +++ b/test/respawn-team-awareness.test.ts @@ -11,32 +11,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { RespawnController } from '../src/respawn-controller.js'; import { Session } from '../src/session.js'; import { TeamWatcher } from '../src/team-watcher.js'; -import { EventEmitter } from 'node:events'; - -// ========== Mock Session ========== - -class MockSession extends EventEmitter { - id = 'test-session-123'; - workingDir = '/tmp/test'; - status = 'idle'; - pid = 12345; - isWorking = false; - writeBuffer: string[] = []; - ralphTracker: null = null; - - async writeViaMux(data: string): Promise { - this.writeBuffer.push(data); - return true; - } - - write(data: string): void { - this.writeBuffer.push(data); - } - - simulateCompletionMessage(): void { - this.emit('terminal', '✻ Worked for 2m 46s'); - } -} +import { MockSession } from './mocks/index.js'; // ========== Mock TeamWatcher ========== diff --git a/test/respawn-test-utils.ts b/test/respawn-test-utils.ts index dc3f1b7b..4dac36b2 100644 --- a/test/respawn-test-utils.ts +++ b/test/respawn-test-utils.ts @@ -1,17 +1,11 @@ /** * @fileoverview Test Utilities for RespawnController Tests * - * Provides mocks, helpers, and utilities for testing the RespawnController - * and its related AI checker components without spawning real Claude CLI processes. + * Provides respawn-specific mocks, helpers, and utilities for testing the + * RespawnController and its related AI checker components. * - * ## Contents - * - * - MockSession: Enhanced mock session for testing - * - MockAiIdleChecker: Mock for AI idle checker with configurable verdicts - * - MockAiPlanChecker: Mock for AI plan mode checker with configurable verdicts - * - State transition helpers - * - Time manipulation utilities - * - Factory functions for pre-configured controllers + * General-purpose mocks (MockSession, terminalOutputs, etc.) have been moved + * to test/mocks/ — re-exported here for backward compatibility. * * @module test/respawn-test-utils */ @@ -19,7 +13,7 @@ import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; import type { Session } from '../src/session.js'; -import type { RespawnConfig, RespawnState, DetectionStatus } from '../src/respawn-controller.js'; +import type { RespawnConfig, RespawnState } from '../src/respawn-controller.js'; import type { AiCheckResult, AiCheckState, @@ -35,6 +29,10 @@ import type { AiPlanCheckConfig, } from '../src/ai-plan-checker.js'; +// Re-export shared mocks for backward compatibility +export { MockSession, createMockSession, terminalOutputs } from './mocks/mock-session.js'; +export { waitForEvent, createDeferred } from './mocks/test-helpers.js'; + // ========== Time Manipulation Utilities ========== /** @@ -80,166 +78,6 @@ export function createTimeController(): TimeController { }; } -// ========== MockSession ========== - -/** - * Enhanced mock session for testing RespawnController. - * Extends the existing MockSession pattern with additional utilities. - */ -export class MockSession extends EventEmitter { - id: string; - workingDir: string = '/tmp/test-workdir'; - status: 'idle' | 'working' = 'idle'; - writeBuffer: string[] = []; - terminalBuffer: string = ''; - - private _muxName: string | null = null; - - constructor(id: string = 'mock-session-id') { - super(); - this.id = id; - this._muxName = `codeman-test-${id.slice(0, 8)}`; - } - - /** Direct PTY write (used by session.write()) */ - write(data: string): void { - this.writeBuffer.push(data); - } - - /** Write via mux (used by respawn controller) */ - async writeViaMux(data: string): Promise { - this.writeBuffer.push(data); - return true; - } - - /** Get the last written data */ - get lastWrite(): string | undefined { - return this.writeBuffer[this.writeBuffer.length - 1]; - } - - /** Clear the write buffer */ - clearWriteBuffer(): void { - this.writeBuffer = []; - } - - /** Check if a specific command was written */ - hasWritten(pattern: string | RegExp): boolean { - return this.writeBuffer.some(data => - typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data) - ); - } - - // ========== Terminal Output Simulation ========== - - /** Simulate raw terminal output */ - simulateTerminalOutput(data: string): void { - this.terminalBuffer += data; - this.emit('terminal', data); - } - - /** Simulate prompt appearing (legacy fallback signal) */ - simulatePrompt(): void { - this.simulateTerminalOutput('\u276f '); - this.status = 'idle'; - this.emit('idle'); - } - - /** Simulate ready state with definitive indicator (legacy) */ - simulateReady(): void { - this.simulateTerminalOutput('\u21b5 send'); - this.status = 'idle'; - this.emit('idle'); - } - - /** - * Simulate completion message (primary idle detection in Claude Code 2024+). - * This triggers the multi-layer detection flow. - */ - simulateCompletionMessage(duration: string = '2m 46s'): void { - this.simulateTerminalOutput(`\u273b Worked for ${duration}`); - this.status = 'idle'; - } - - /** Simulate working state with spinner */ - simulateWorking(text: string = 'Thinking'): void { - this.simulateTerminalOutput(`${text}... \u280b`); - this.status = 'working'; - this.emit('working'); - } - - /** Simulate /clear completion */ - simulateClearComplete(): void { - this.simulateTerminalOutput('conversation cleared'); - setTimeout(() => this.simulateCompletionMessage(), 50); - } - - /** Simulate /init completion */ - simulateInitComplete(): void { - this.simulateTerminalOutput('Analyzing CLAUDE.md...'); - setTimeout(() => this.simulateCompletionMessage(), 100); - } - - /** - * Simulate plan mode approval prompt. - * This triggers auto-accept detection. - */ - simulatePlanModePrompt(): void { - this.simulateTerminalOutput( - 'Would you like to proceed with this plan?\n' + - '\u276f 1. Yes\n' + - ' 2. No\n' + - ' 3. Type your own\n' - ); - } - - /** - * Simulate elicitation dialog (AskUserQuestion). - * This should block auto-accept. - */ - simulateElicitationDialog(): void { - this.simulateTerminalOutput( - 'What would you like to name the new file?\n' + - '> ' - ); - } - - /** Simulate token count display */ - simulateTokenCount(tokens: number | string): void { - const formatted = typeof tokens === 'number' - ? tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens) - : tokens; - this.simulateTerminalOutput(`${formatted} tokens used`); - } - - /** Simulate ANSI escape codes */ - simulateAnsiOutput(text: string, color: 'green' | 'red' | 'blue' = 'green'): void { - const codes: Record = { - green: '\x1b[32m', - red: '\x1b[31m', - blue: '\x1b[34m', - }; - this.simulateTerminalOutput(`${codes[color]}${text}\x1b[0m`); - } - - /** Clear terminal buffer */ - clearTerminalBuffer(): void { - this.terminalBuffer = ''; - } - - // ========== Session Lifecycle ========== - - /** Simulate session closing */ - close(): void { - this.emit('exit', 0); - this.removeAllListeners(); - } - - /** Get mux name (for mux-based operations) */ - get muxName(): string | null { - return this._muxName; - } -} - // ========== MockAiIdleChecker ========== /** @@ -281,7 +119,7 @@ export class MockAiIdleChecker extends EventEmitter { constructor( public sessionId: string, config: Partial = {}, - options: MockAiIdleCheckerOptions = {} + options: MockAiIdleCheckerOptions = {}, ) { super(); this.config = { @@ -334,24 +172,14 @@ export class MockAiIdleChecker extends EventEmitter { return Math.max(0, this._cooldownEndsAt - Date.now()); } - /** - * Queue a result to be returned on the next check() call. - * Results are consumed in FIFO order. - */ queueResult(result: AiCheckResult): void { this.queuedResults.push(result); } - /** - * Queue multiple results for sequential check() calls. - */ queueResults(...results: AiCheckResult[]): void { this.queuedResults.push(...results); } - /** - * Set the next check to return IDLE verdict. - */ setNextIdle(reasoning: string = 'Mock: IDLE'): void { this.queueResult({ verdict: 'IDLE', @@ -360,9 +188,6 @@ export class MockAiIdleChecker extends EventEmitter { }); } - /** - * Set the next check to return WORKING verdict. - */ setNextWorking(reasoning: string = 'Mock: WORKING'): void { this.queueResult({ verdict: 'WORKING', @@ -371,9 +196,6 @@ export class MockAiIdleChecker extends EventEmitter { }); } - /** - * Set the next check to return ERROR verdict. - */ setNextError(reasoning: string = 'Mock: ERROR'): void { this.queueResult({ verdict: 'ERROR', @@ -400,7 +222,7 @@ export class MockAiIdleChecker extends EventEmitter { this.emit('checkStarted'); // Simulate async check - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); // Get result from queue or use default const result = this.queuedResults.shift() || { @@ -454,7 +276,7 @@ export class MockAiIdleChecker extends EventEmitter { updateConfig(config: Partial): void { const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) + Object.entries(config).filter(([, v]) => v !== undefined), ) as Partial; this.config = { ...this.config, ...filteredConfig }; if (config.enabled === false) { @@ -472,19 +294,16 @@ export class MockAiIdleChecker extends EventEmitter { // ========== Test Helpers ========== - /** Force into cooldown state for testing */ forceCooldown(durationMs: number): void { this.startCooldown(durationMs); } - /** Force into disabled state for testing */ forceDisabled(reason: string = 'Forced disabled for testing'): void { this._status = 'disabled'; this._disabledReason = reason; this.emit('disabled', reason); } - /** Force ready state for testing */ forceReady(): void { this.clearCooldown(); this._status = 'ready'; @@ -557,7 +376,7 @@ export class MockAiPlanChecker extends EventEmitter { constructor( public sessionId: string, config: Partial = {}, - options: MockAiPlanCheckerOptions = {} + options: MockAiPlanCheckerOptions = {}, ) { super(); this.config = { @@ -610,23 +429,14 @@ export class MockAiPlanChecker extends EventEmitter { return Math.max(0, this._cooldownEndsAt - Date.now()); } - /** - * Queue a result to be returned on the next check() call. - */ queueResult(result: AiPlanCheckResult): void { this.queuedResults.push(result); } - /** - * Queue multiple results for sequential check() calls. - */ queueResults(...results: AiPlanCheckResult[]): void { this.queuedResults.push(...results); } - /** - * Set the next check to return PLAN_MODE verdict. - */ setNextPlanMode(reasoning: string = 'Mock: PLAN_MODE'): void { this.queueResult({ verdict: 'PLAN_MODE', @@ -635,9 +445,6 @@ export class MockAiPlanChecker extends EventEmitter { }); } - /** - * Set the next check to return NOT_PLAN_MODE verdict. - */ setNextNotPlanMode(reasoning: string = 'Mock: NOT_PLAN_MODE'): void { this.queueResult({ verdict: 'NOT_PLAN_MODE', @@ -646,9 +453,6 @@ export class MockAiPlanChecker extends EventEmitter { }); } - /** - * Set the next check to return ERROR verdict. - */ setNextError(reasoning: string = 'Mock: ERROR'): void { this.queueResult({ verdict: 'ERROR', @@ -675,7 +479,7 @@ export class MockAiPlanChecker extends EventEmitter { this.emit('checkStarted'); // Simulate async check - await new Promise(resolve => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); // Get result from queue or use default const result = this.queuedResults.shift() || { @@ -729,7 +533,7 @@ export class MockAiPlanChecker extends EventEmitter { updateConfig(config: Partial): void { const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) + Object.entries(config).filter(([, v]) => v !== undefined), ) as Partial; this.config = { ...this.config, ...filteredConfig }; if (config.enabled === false) { @@ -747,19 +551,16 @@ export class MockAiPlanChecker extends EventEmitter { // ========== Test Helpers ========== - /** Force into cooldown state for testing */ forceCooldown(durationMs: number): void { this.startCooldown(durationMs); } - /** Force into disabled state for testing */ forceDisabled(reason: string = 'Forced disabled for testing'): void { this._status = 'disabled'; this._disabledReason = reason; this.emit('disabled', reason); } - /** Force ready state for testing */ forceReady(): void { this.clearCooldown(); this._status = 'ready'; @@ -810,37 +611,24 @@ export function createStateTracker() { const transitions: StateTransition[] = []; return { - /** Record a state transition (use as event handler) */ record(to: RespawnState, from: RespawnState): void { transitions.push({ from, to, timestamp: Date.now() }); }, - - /** Get all recorded transitions */ getTransitions(): StateTransition[] { return [...transitions]; }, - - /** Get only the state values (not timestamps) */ getStates(): RespawnState[] { - return transitions.map(t => t.to); + return transitions.map((t) => t.to); }, - - /** Check if a specific state was visited */ hasVisited(state: RespawnState): boolean { - return transitions.some(t => t.to === state); + return transitions.some((t) => t.to === state); }, - - /** Check if a specific transition occurred */ hasTransition(from: RespawnState, to: RespawnState): boolean { - return transitions.some(t => t.from === from && t.to === to); + return transitions.some((t) => t.from === from && t.to === to); }, - - /** Get the most recent state */ getCurrentState(): RespawnState | undefined { return transitions.length > 0 ? transitions[transitions.length - 1].to : undefined; }, - - /** Clear all recorded transitions */ clear(): void { transitions.length = 0; }, @@ -854,34 +642,23 @@ export function createEventRecorder() { const events: Array<{ type: string; args: unknown[]; timestamp: number }> = []; return { - /** Create a handler for a specific event type */ handler(type: string): (...args: unknown[]) => void { return (...args: unknown[]) => { events.push({ type, args, timestamp: Date.now() }); }; }, - - /** Get all recorded events */ getEvents(): Array<{ type: string; args: unknown[]; timestamp: number }> { return [...events]; }, - - /** Get events of a specific type */ getEventsOfType(type: string): Array<{ type: string; args: unknown[]; timestamp: number }> { - return events.filter(e => e.type === type); + return events.filter((e) => e.type === type); }, - - /** Check if an event type was emitted */ hasEvent(type: string): boolean { - return events.some(e => e.type === type); + return events.some((e) => e.type === type); }, - - /** Count events of a specific type */ countEvents(type: string): number { - return events.filter(e => e.type === type).length; + return events.filter((e) => e.type === type).length; }, - - /** Clear all recorded events */ clear(): void { events.length = 0; }, @@ -917,13 +694,6 @@ export const AI_ENABLED_TEST_CONFIG: Partial = { aiPlanCheckCooldownMs: 200, }; -/** - * Create a mock session typed as Session for use with RespawnController. - */ -export function createMockSession(id?: string): MockSession & Session { - return new MockSession(id) as MockSession & Session; -} - // ========== Test Assertion Helpers ========== /** @@ -931,9 +701,12 @@ export function createMockSession(id?: string): MockSession & Session { * Useful for async state transition testing. */ export async function waitForState( - controller: { state: RespawnState; on: (event: string, handler: (state: RespawnState) => void) => void }, + controller: { + state: RespawnState; + on: (event: string, handler: (state: RespawnState) => void) => void; + }, targetState: RespawnState, - timeoutMs: number = 1000 + timeoutMs: number = 1000, ): Promise { if (controller.state === targetState) return; @@ -952,94 +725,3 @@ export async function waitForState( controller.on('stateChanged', handler); }); } - -/** - * Wait for a specific event to be emitted. - */ -export async function waitForEvent( - emitter: EventEmitter, - eventName: string, - timeoutMs: number = 1000 -): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error(`Timeout waiting for event ${eventName}`)); - }, timeoutMs); - - emitter.once(eventName, (...args: unknown[]) => { - clearTimeout(timeout); - resolve(args[0] as T); - }); - }); -} - -/** - * Create a deferred promise for controlled async testing. - */ -export function createDeferred(): { - promise: Promise; - resolve: (value: T) => void; - reject: (error: Error) => void; -} { - let resolve!: (value: T) => void; - let reject!: (error: Error) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -// ========== Terminal Output Generators ========== - -/** - * Generate realistic terminal output for testing. - */ -export const terminalOutputs = { - /** Standard completion message */ - completion(duration: string = '2m 46s'): string { - return `\n\u273b Worked for ${duration}\n 123.4k tokens used\n`; - }, - - /** Working spinner output */ - working(activity: string = 'Thinking'): string { - return `${activity}... \u280b`; - }, - - /** Plan mode prompt */ - planMode(question: string = 'Would you like to proceed?'): string { - return [ - `\n${question}\n`, - '\u276f 1. Yes\n', - ' 2. No\n', - ' 3. Type your own\n', - ].join(''); - }, - - /** Prompt character */ - prompt(): string { - return '\n\u276f '; - }, - - /** Token count display */ - tokens(count: number): string { - const formatted = count >= 1000000 - ? `${(count / 1000000).toFixed(1)}M` - : count >= 1000 - ? `${(count / 1000).toFixed(1)}k` - : String(count); - return ` ${formatted} tokens\n`; - }, - - /** Large output for buffer testing */ - largeOutput(sizeKb: number = 100): string { - const baseText = 'Lorem ipsum dolor sit amet. '.repeat(100); - const repetitions = Math.ceil((sizeKb * 1024) / baseText.length); - return baseText.repeat(repetitions).slice(0, sizeKb * 1024); - }, - - /** ANSI colored output */ - ansiColored(text: string): string { - return `\x1b[32m${text}\x1b[0m`; - }, -}; diff --git a/test/routes/_route-test-utils.ts b/test/routes/_route-test-utils.ts new file mode 100644 index 00000000..dc038aa1 --- /dev/null +++ b/test/routes/_route-test-utils.ts @@ -0,0 +1,40 @@ +/** + * 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 fastifyCookie from '@fastify/cookie'; +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 { + const app = Fastify({ logger: false }); + + // Register cookie plugin — some routes access req.cookies + await app.register(fastifyCookie); + + const ctx = createMockRouteContext(ctxOptions); + + registerFn(app, ctx); + await app.ready(); + + return { app, ctx }; +} diff --git a/test/routes/respawn-routes.test.ts b/test/routes/respawn-routes.test.ts new file mode 100644 index 00000000..d65fc888 --- /dev/null +++ b/test/routes/respawn-routes.test.ts @@ -0,0 +1,228 @@ +/** + * @fileoverview Tests for respawn route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerRespawnRoutes } from '../../src/web/routes/respawn-routes.js'; + +describe('respawn-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerRespawnRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/sessions/:id/respawn ========== + + describe('GET /api/sessions/:id/respawn', () => { + it('returns disabled status when no controller exists', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/respawn`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.enabled).toBe(false); + expect(body.status).toBeNull(); + }); + + it('returns status when controller exists', async () => { + const mockController = { + getStatus: vi.fn(() => ({ + state: 'watching', + health: 100, + cycleCount: 0, + })), + getConfig: vi.fn(() => ({})), + start: vi.fn(), + stop: vi.fn(), + updateConfig: vi.fn(), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/respawn`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.enabled).toBe(true); + expect(body.state).toBe('watching'); + }); + }); + + // ========== GET /api/sessions/:id/respawn/config ========== + + describe('GET /api/sessions/:id/respawn/config', () => { + it('returns null config when no controller and no pre-saved config', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.config).toBeNull(); + expect(body.active).toBe(false); + }); + + it('returns active config when controller exists', async () => { + const mockConfig = { idleTimeoutMs: 5000, enabled: true }; + const mockController = { + getConfig: vi.fn(() => mockConfig), + getStatus: vi.fn(() => ({})), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.active).toBe(true); + expect(body.config.idleTimeoutMs).toBe(5000); + }); + }); + + // ========== POST /api/sessions/:id/respawn/stop ========== + + describe('POST /api/sessions/:id/respawn/stop', () => { + it('returns error when no controller exists', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/stop`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('stops existing controller and cleans up', async () => { + const mockController = { + stop: vi.fn(), + getConfig: vi.fn(() => ({})), + getStatus: vi.fn(() => ({})), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/stop`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(mockController.stop).toHaveBeenCalled(); + expect(harness.ctx.respawnControllers.has(harness.ctx._sessionId)).toBe(false); + expect(harness.ctx.broadcast).toHaveBeenCalledWith('respawn:stopped', { + sessionId: harness.ctx._sessionId, + }); + }); + }); + + // ========== POST /api/sessions/:id/respawn/start ========== + + describe('POST /api/sessions/:id/respawn/start', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/respawn/start', + }); + // findSessionOrFail throws with statusCode 404 + expect(res.statusCode).toBe(404); + }); + + it('rejects opencode sessions', async () => { + harness.ctx._session.mode = 'opencode'; + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/start`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== PUT /api/sessions/:id/respawn/config ========== + + describe('PUT /api/sessions/:id/respawn/config', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/sessions/nonexistent/respawn/config', + payload: { idleTimeoutMs: 5000 }, + }); + expect(res.statusCode).toBe(404); + }); + + it('rejects invalid config', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, + payload: { idleTimeoutMs: 'not-a-number' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('updates running controller config', async () => { + const mockController = { + updateConfig: vi.fn(), + getConfig: vi.fn(() => ({ idleTimeoutMs: 5000 })), + getStatus: vi.fn(() => ({})), + }; + harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never); + + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, + payload: { idleTimeoutMs: 5000 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(mockController.updateConfig).toHaveBeenCalled(); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'respawn:configUpdated', + expect.objectContaining({ sessionId: harness.ctx._sessionId }), + ); + }); + + it('saves pre-config when no controller running', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/respawn/config`, + payload: { idleTimeoutMs: 5000 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.mux.updateRespawnConfig).toHaveBeenCalled(); + }); + }); + + // ========== POST /api/sessions/:id/respawn/enable ========== + + describe('POST /api/sessions/:id/respawn/enable', () => { + it('returns 404 for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/respawn/enable', + }); + // findSessionOrFail not used here — let's check the actual response + expect(res.statusCode).toBe(404); + }); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts new file mode 100644 index 00000000..7d9a45c9 --- /dev/null +++ b/test/routes/session-routes.test.ts @@ -0,0 +1,465 @@ +/** + * @fileoverview Tests for session route handlers. + * + * Uses app.inject() (Fastify's built-in test helper) — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + */ + +import { describe, it, expect, beforeEach, afterEach, vi } 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(); + }); + + // ========== GET /api/sessions ========== + + describe('GET /api/sessions', () => { + it('returns session list when sessions exist', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/sessions' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(1); + }); + + 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([]); + }); + }); + + // ========== GET /api/sessions/:id ========== + + describe('GET /api/sessions/:id', () => { + it('returns session state for existing session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.id).toBe(harness.ctx._sessionId); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent', + }); + expect(res.statusCode).toBe(200); // returns error in body, not HTTP 404 + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + expect(body.error).toBeDefined(); + }); + }); + + // ========== DELETE /api/sessions/:id ========== + + describe('DELETE /api/sessions/:id', () => { + it('deletes existing session', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: `/api/sessions/${harness.ctx._sessionId}`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.cleanupSession).toHaveBeenCalledWith( + harness.ctx._sessionId, + true, + 'user_delete', + ); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions/nonexistent', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== DELETE /api/sessions (delete all) ========== + + describe('DELETE /api/sessions', () => { + it('deletes all sessions', async () => { + const res = await harness.app.inject({ + method: 'DELETE', + url: '/api/sessions', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data.killed).toBe(1); + expect(harness.ctx.cleanupSession).toHaveBeenCalled(); + }); + }); + + // ========== PUT /api/sessions/:id/name ========== + + describe('PUT /api/sessions/:id/name', () => { + it('renames session', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/name`, + payload: { name: 'new-name' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.name).toBe('new-name'); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + 'session:updated', + expect.anything(), + ); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/sessions/nonexistent/name', + payload: { name: 'test' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== PUT /api/sessions/:id/color ========== + + describe('PUT /api/sessions/:id/color', () => { + it('sets session color', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/color`, + payload: { color: 'blue' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.color).toBe('blue'); + }); + + it('rejects invalid color', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: `/api/sessions/${harness.ctx._sessionId}/color`, + payload: { color: 'neon-rainbow' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/input ========== + + describe('POST /api/sessions/:id/input', () => { + it('sends input to session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/input`, + payload: { input: 'hello' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/input', + payload: { input: 'hello' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects empty payload', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/input`, + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/resize ========== + + describe('POST /api/sessions/:id/resize', () => { + it('resizes session terminal', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/resize`, + payload: { cols: 120, rows: 40 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx._session.resize).toHaveBeenCalledWith(120, 40); + }); + + it('rejects cols exceeding max (500)', 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(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects rows exceeding max (200)', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/resize`, + payload: { cols: 80, rows: 201 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('rejects zero dimensions', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/resize`, + payload: { cols: 0, rows: 24 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/sessions/:id/terminal ========== + + describe('GET /api/sessions/:id/terminal', () => { + it('returns terminal buffer', async () => { + harness.ctx._session.terminalBuffer = 'hello world'; + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/terminal`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.terminalBuffer).toBeDefined(); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/terminal', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/run ========== + + describe('POST /api/sessions/:id/run', () => { + it('runs prompt on session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/run`, + payload: { prompt: 'do something' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + + it('rejects empty prompt', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/run`, + payload: { prompt: '' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/run', + payload: { prompt: 'test' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error if session is busy', async () => { + harness.ctx._session.isBusy.mockReturnValue(true); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/run`, + payload: { prompt: 'test' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/interactive ========== + + describe('POST /api/sessions/:id/interactive', () => { + it('starts interactive mode', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/interactive`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx._session.startInteractive).toHaveBeenCalled(); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions/nonexistent/interactive', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + + it('returns error if session is busy', async () => { + harness.ctx._session.isBusy.mockReturnValue(true); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/interactive`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== POST /api/sessions/:id/shell ========== + + describe('POST /api/sessions/:id/shell', () => { + it('starts shell mode', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/shell`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx._session.startShell).toHaveBeenCalled(); + }); + + it('returns error if session is busy', async () => { + harness.ctx._session.isBusy.mockReturnValue(true); + const res = await harness.app.inject({ + method: 'POST', + url: `/api/sessions/${harness.ctx._sessionId}/shell`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/sessions/:id/output ========== + + describe('GET /api/sessions/:id/output', () => { + it('returns session output data', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/output`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveProperty('textOutput'); + expect(body.data).toHaveProperty('messages'); + expect(body.data).toHaveProperty('errorBuffer'); + }); + + it('returns error for unknown session', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: '/api/sessions/nonexistent/output', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/sessions/:id/ralph-state ========== + + describe('GET /api/sessions/:id/ralph-state', () => { + it('returns ralph state data', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/ralph-state`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveProperty('loop'); + expect(body.data).toHaveProperty('todos'); + expect(body.data).toHaveProperty('todoStats'); + }); + }); + + // ========== GET /api/sessions/:id/active-tools ========== + + describe('GET /api/sessions/:id/active-tools', () => { + it('returns active tools', async () => { + const res = await harness.app.inject({ + method: 'GET', + url: `/api/sessions/${harness.ctx._sessionId}/active-tools`, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toHaveProperty('tools'); + }); + }); + + // ========== POST /api/logout ========== + + describe('POST /api/logout', () => { + it('returns success', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/logout', + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + }); +}); diff --git a/test/routes/system-routes.test.ts b/test/routes/system-routes.test.ts new file mode 100644 index 00000000..947ae32b --- /dev/null +++ b/test/routes/system-routes.test.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview Tests for system route handlers. + * + * Uses app.inject() — no real HTTP ports needed. + * Port: N/A (app.inject doesn't open ports) + * + * Note: Some system routes access singleton modules (subagentWatcher, imageWatcher) + * directly. Those routes are tested lightly here since we can't easily mock singletons + * without vi.mock(). Focus is on routes that use the port context. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js'; +import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; + +describe('system-routes', () => { + let harness: RouteTestHarness; + + beforeEach(async () => { + harness = await createRouteTestHarness(registerSystemRoutes); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + // ========== GET /api/status ========== + + describe('GET /api/status', () => { + it('returns server status', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/status' }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.getLightState).toHaveBeenCalled(); + }); + }); + + // ========== GET /api/config ========== + + describe('GET /api/config', () => { + it('returns config', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/config' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.config).toBeDefined(); + }); + }); + + // ========== PUT /api/config ========== + + describe('PUT /api/config', () => { + it('updates config with valid payload', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/config', + payload: { maxConcurrentSessions: 10 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(harness.ctx.store.setConfig).toHaveBeenCalled(); + }); + + it('rejects unknown config fields (strict schema)', async () => { + const res = await harness.app.inject({ + method: 'PUT', + url: '/api/config', + payload: { unknownField: 'invalid' }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(false); + }); + }); + + // ========== GET /api/stats ========== + + describe('GET /api/stats', () => { + it('returns aggregate stats', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/stats' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.stats).toBeDefined(); + }); + }); + + // ========== GET /api/token-stats ========== + + describe('GET /api/token-stats', () => { + it('returns daily and aggregate token stats', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/token-stats' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.daily).toBeDefined(); + expect(body.totals).toBeDefined(); + }); + }); + + // ========== GET /api/debug/memory ========== + + describe('GET /api/debug/memory', () => { + it('returns memory and map usage info', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/debug/memory' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.memory).toBeDefined(); + expect(body.memory.rssMB).toBeGreaterThan(0); + expect(body.mapSizes).toBeDefined(); + expect(body.uptime).toBeDefined(); + }); + }); + + // ========== GET /api/system/stats ========== + + describe('GET /api/system/stats', () => { + it('returns CPU and memory stats', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/system/stats' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toHaveProperty('cpu'); + expect(body).toHaveProperty('memory'); + }); + }); + + // ========== POST /api/cleanup-state ========== + + describe('POST /api/cleanup-state', () => { + it('cleans up stale session state', async () => { + const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.cleanedSessions).toBe(0); + expect(harness.ctx.store.cleanupStaleSessions).toHaveBeenCalled(); + }); + }); + + // ========== GET /api/subagents ========== + + describe('GET /api/subagents', () => { + it('returns subagent list', async () => { + const res = await harness.app.inject({ method: 'GET', url: '/api/subagents' }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + expect(body.data).toBeDefined(); + }); + }); + + // ========== POST /api/auth/revoke ========== + + describe('POST /api/auth/revoke', () => { + it('returns success even without auth sessions', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/auth/revoke', + payload: {}, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.success).toBe(true); + }); + }); +});