test: add shared mock infrastructure and route tests (phase 7)

Consolidate duplicated MockSession/MockStateStore into test/mocks/,
migrate respawn tests to shared mocks, and add 58 route tests for
session, system, and respawn endpoints using Fastify app.inject().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 14:33:18 +01:00
co-authored by Claude Opus 4.6
parent 97ca13916a
commit 442cc19c3c
13 changed files with 2421 additions and 428 deletions
+953
View File
@@ -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/<file>.test.ts`
3. **Never test on port 3000** — the live dev server runs there. Tests use ports 3150+.
4. **After TypeScript changes**: Run `tsc --noEmit` to verify type checking passes.
5. **Before considering done**: Run `npm run lint` and `npm run format:check` to ensure CI passes.
6. **Never kill tmux sessions** — check `echo $CODEMAN_MUX` first.
7. **Port assignments for this phase**: New tests use ports 3220–3229 (see individual tasks for assignments).
---
## Goal
Eliminate duplicated test mocks, activate the unused `respawn-test-utils.ts` utilities, and add route-level test coverage for the server's 12 route modules — the single largest untested area in the codebase (162 route handlers, 0 dedicated tests).
**Non-goals**:
- Full end-to-end integration tests (those require real Claude CLI / tmux sessions)
- 100% route coverage in this phase — focus on the highest-value route modules first
- Refactoring test patterns in existing passing tests that don't use shared mocks
- Migrating `vi.mock()`-based module replacement mocks (different pattern, see Task 6/7)
---
## Current State
### Mock Duplication (Finding #9)
`MockSession` is defined **4 times** across test files with varying levels of completeness:
| File | Properties | Methods | EventEmitter | Notes |
|------|-----------|---------|-------------|-------|
| `test/respawn-test-utils.ts` | 6 | 20+ | Yes | **Most complete**. Includes terminal simulation, token count, ANSI output, plan mode prompts. **Never imported by any test.** |
| `test/respawn-controller.test.ts` | 6 | 9 | Yes | Subset of respawn-test-utils. Missing token simulation, ANSI helpers. |
| `test/respawn-team-awareness.test.ts` | ~6 | ~9 | Yes | Near-copy of respawn-controller.test.ts version. |
| `test/session-manager.test.ts` | 4 | 8 | Yes | **Inside `vi.mock()` factory** — replaces `../src/session.js` module. Different shape: `start()`/`stop()`/`toState()`/`sendInput()` for lifecycle testing. |
`MockStateStore` is defined **2 times** (both inside `vi.mock()` factories):
| File | Shape | Methods | Mock Pattern |
|------|-------|---------|-------------|
| `test/session-manager.test.ts` | `{ sessions, config }` | `getConfig`, `getSessions`, `getSession`, `setSession`, `removeSession` | `vi.mock('../src/state-store.js')` |
| `test/ralph-loop.test.ts` | `{ ralphLoop, tasks, config }` | `getConfig`, `getRalphLoopState`, `setRalphLoopState`, `getTasks`, `setTask`, `removeTask` | `vi.mock('../src/state-store.js')` |
### Important: Two distinct mocking patterns
The codebase uses two different mocking patterns that require different migration strategies:
1. **Direct instantiation** (respawn-controller, respawn-team-awareness): `MockSession` is defined at file scope and instantiated directly in tests. These can be migrated to shared mocks via simple import replacement.
2. **Module replacement** (session-manager, ralph-loop): Mocks are defined inside `vi.mock()` factories that replace entire modules (`../src/session.js`, `../src/state-store.js`). These factories run in an isolated scope and return `{ Session: MockClass }` or `{ getStore: vi.fn(() => instance) }`. Migrating these requires either `vi.hoisted()` or restructuring the test's module mocking — higher risk for limited benefit.
### Unused Test Utilities
`test/respawn-test-utils.ts` exports these utilities that **no test file imports**:
- `TimeController` / `createTimeController()` — abstraction over vitest fake timers
- `MockAiIdleChecker` / `MockAiPlanChecker` — fully mocked AI checkers with result queueing
- `createStateTracker()` / `createEventRecorder()` — state transition and event recording
- `FAST_TEST_CONFIG` / `AI_ENABLED_TEST_CONFIG` — pre-configured RespawnConfig objects
- `waitForState()` / `waitForEvent()` / `createDeferred()` — async test helpers
- `terminalOutputs` — factory object for common terminal output patterns
### Route Test Coverage
Currently **zero** dedicated tests for the 12 route modules in `src/web/routes/`. The existing test files that touch API endpoints:
| Test File | What It Tests | Approach |
|-----------|--------------|----------|
| `test/api-responses.test.ts` | Response structure validation | Imports types, no HTTP calls |
| `test/api-generate-plan.test.ts` | Plan generation API | Mocks validation logic, Port 3191 declared |
| `test/auth-security.test.ts` | Auth middleware | Integration tests with WebServer, Ports 3160/3161 |
| `test/qr-auth.test.ts` | QR authentication | Integration + unit tests, Port 3162 |
None of these test the route handlers themselves with real HTTP requests against a running Fastify instance.
---
## Design Decisions
### Shared mocks: Superset strategy
Rather than creating a lowest-common-denominator mock, `MockSession` in `test/mocks/` will be the **superset** from `respawn-test-utils.ts` (the most complete version). Test files that need a simpler mock can just ignore the extra methods — having unused methods costs nothing, but missing methods forces local re-definition.
### vi.mock() tests: Don't migrate
The `session-manager.test.ts` and `ralph-loop.test.ts` tests define mocks inside `vi.mock()` factories. These use **module-level replacement** (replacing `../src/session.js` and `../src/state-store.js` entirely), which is fundamentally different from the direct-instantiation pattern. Migrating them would require `vi.hoisted()` or factory restructuring — high complexity for limited benefit since these mocks are already working. We leave these as-is and create the shared mocks for **new** tests and for the two direct-instantiation tests (Tasks 4–5).
### MockStateStore: Union of both shapes
The shared `MockStateStore` in `test/mocks/` will include methods from both existing definitions (session management + Ralph loop), so any **new** test can use it. Methods default to no-ops via `vi.fn()`. Existing `vi.mock()`-based tests are not migrated.
### Route testing strategy: Lightweight Fastify instances
Each route test file will:
1. Create a minimal `Fastify` instance
2. Register **only** the route module under test
3. Provide a mock context object satisfying the port interfaces
4. Use `app.inject()` (Fastify's built-in test helper) — no real HTTP, no port needed
This avoids port conflicts entirely and runs fast. Only tests that need SSE or WebSocket behavior will use a real listening server with assigned ports.
### Port assignments (for tests needing real servers)
| Port | Test File | Purpose |
|------|-----------|---------|
| 3220 | `test/routes/session-routes.test.ts` | SSE integration (if needed) |
| 3221 | `test/routes/system-routes.test.ts` | Status/stats endpoints |
| 3222 | `test/routes/respawn-routes.test.ts` | Respawn API |
| 3223 | `test/routes/ralph-routes.test.ts` | Ralph API |
| 3224–3229 | Reserved | Future route tests |
Most tests should NOT need real ports — `app.inject()` is preferred. Verified: ports 3220–3229 are completely unused by existing tests (highest used port is 3211 in `opencode-resize.test.ts`).
---
## Task Dependencies
```
Task 1 (Consolidate MockSession)
Task 2 (Consolidate MockStateStore)
└──> Task 3 (Create test/mocks/ barrel)
├──> Task 4 (Migrate respawn-controller.test.ts)
├──> Task 5 (Migrate respawn-team-awareness.test.ts)
└──> Task 6 (Route test scaffold + helpers)
├──> Task 7 (Session routes tests)
└──> Task 8 (System + respawn routes tests)
Task 9 (Slim down respawn-test-utils.ts) — depends on Tasks 4, 5
```
**Tasks 1–2** are independent and can run in parallel.
**Task 3** depends on Tasks 1–2.
**Tasks 4–6** depend on Task 3 and can run in parallel.
**Tasks 7–8** depend on Task 6 and can run in parallel.
**Task 9** depends on Tasks 4, 5 (must verify migrations work before removing duplicates from source).
---
## Task 1: Consolidate MockSession into `test/mocks/mock-session.ts`
**Estimated effort**: 2 hours
**Files created**: `test/mocks/mock-session.ts`
**Files modified**: None yet (consumers migrate in Tasks 4–5)
### Source
The canonical MockSession comes from `test/respawn-test-utils.ts` (lines 89–241). It is the most complete version with:
- All properties needed by `RespawnController`: `id`, `workingDir`, `status`, `writeBuffer`, `terminalBuffer`, `muxName`
- `write()` / `writeViaMux()` for input simulation
- Buffer inspection: `lastWrite`, `hasWritten(pattern)`, `clearWriteBuffer()`
- Terminal simulation: `simulateTerminalOutput()`, `simulatePrompt()`, `simulateReady()`, `simulateCompletionMessage()`, `simulateWorking()`, `simulateClearComplete()`, `simulateInitComplete()`, `simulatePlanModePrompt()`, `simulateElicitationDialog()`, `simulateTokenCount()`, `simulateAnsiOutput()`
- Lifecycle: `close()`
### Implementation
1. Create `test/mocks/` directory.
2. Create `test/mocks/mock-session.ts`:
- Copy the `MockSession` class **exactly** from `test/respawn-test-utils.ts` (lines 89–241)
- Copy `terminalOutputs` helper object (tightly coupled to mock)
- Copy `createMockSession()` factory function
- Export all three: `export { MockSession, createMockSession, terminalOutputs }`
- Ensure all `vi` imports come from `vitest`
**CRITICAL**: Copy the source verbatim — do NOT rewrite the simulation methods. The respawn controller's detection logic matches specific output patterns (e.g., `'\u276f '` for prompt, `'\u273b Worked for'` for completion). Using different patterns would cause test failures.
### Template
```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<string, unknown> = {
sessions: {} as Record<string, unknown>,
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {} as Record<string, unknown>,
};
// Session methods
getConfig = vi.fn(() => this.state.config);
getSessions = vi.fn(() => this.state.sessions as Record<string, unknown>);
getSession = vi.fn((id: string) => (this.state.sessions as Record<string, unknown>)[id]);
setSession = vi.fn((id: string, state: unknown) => {
(this.state.sessions as Record<string, unknown>)[id] = state;
});
removeSession = vi.fn((id: string) => {
delete (this.state.sessions as Record<string, unknown>)[id];
});
// Ralph state methods
getRalphLoopState = vi.fn(() => this.state.ralphLoop);
setRalphLoopState = vi.fn((update: Record<string, unknown>) => {
this.state.ralphLoop = { ...(this.state.ralphLoop as Record<string, unknown>), ...update };
});
// Task methods
getTasks = vi.fn(() => this.state.tasks);
setTask = vi.fn();
removeTask = vi.fn();
// Settings methods
getSettings = vi.fn(() => ({}));
setSettings = vi.fn();
// Generic persistence
save = vi.fn();
load = vi.fn();
/** Reset all state and mocks for clean test isolation */
reset(): void {
this.state = {
sessions: {},
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {},
};
vi.clearAllMocks();
}
}
```
### Verification
```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<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
timeoutMs,
);
emitter.once(event, (...args: unknown[]) => {
clearTimeout(timer);
resolve(args.length === 1 ? args[0] : args);
});
});
}
/** Create a deferred promise with external resolve/reject */
export function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
```
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<string, MockSession>();
sessions.set(sessionId, session);
return {
// -- SessionPort --
sessions,
addSession: vi.fn(),
cleanupSession: vi.fn(),
setupSessionListeners: vi.fn(),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
getSessionStateWithRespawn: vi.fn((s: unknown) => s),
// -- EventPort --
broadcast: vi.fn(),
sendPushNotifications: vi.fn(),
batchTerminalData: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
batchTaskUpdate: vi.fn(),
// -- RespawnPort --
respawnControllers: new Map(),
respawnTimers: new Map(),
setupRespawnListeners: vi.fn(),
setupTimedRespawn: vi.fn(),
restoreRespawnController: vi.fn(),
saveRespawnConfig: vi.fn(),
// -- ConfigPort --
store: {
getConfig: vi.fn(() => ({})),
getSessions: vi.fn(() => ({})),
getSession: vi.fn(),
setSession: vi.fn(),
removeSession: vi.fn(),
getSettings: vi.fn(() => ({})),
setSettings: vi.fn(),
getRalphLoopState: vi.fn(() => ({})),
setRalphLoopState: vi.fn(),
getTasks: vi.fn(() => ({})),
save: vi.fn(),
load: vi.fn(),
},
port: 3000,
https: false,
testMode: true,
serverStartTime: Date.now(),
getGlobalNiceConfig: vi.fn(async () => undefined),
getModelConfig: vi.fn(async () => null),
getClaudeModeConfig: vi.fn(async () => ({})),
getDefaultClaudeMdPath: vi.fn(async () => undefined),
getLightState: vi.fn(() => ({ sessions: [], status: 'ok' })),
getLightSessionsState: vi.fn(() => []),
startTranscriptWatcher: vi.fn(),
stopTranscriptWatcher: vi.fn(),
// -- InfraPort --
mux: {
createSession: vi.fn(),
killSession: vi.fn(),
listSessions: vi.fn(() => []),
getStats: vi.fn(() => ({})),
},
runSummaryTrackers: new Map(),
activePlanOrchestrators: new Map(),
scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null,
pushStore: null,
startScheduledRun: vi.fn(),
stopScheduledRun: vi.fn(),
// -- AuthPort --
authSessions: null,
qrAuthFailures: null,
// https already declared above in ConfigPort (shared property)
// Convenience accessors (not part of any port interface)
_session: session,
_sessionId: sessionId,
};
}
export type MockRouteContext = ReturnType<typeof createMockRouteContext>;
```
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<RouteTestHarness> {
const app = Fastify({ logger: false });
const ctx = createMockRouteContext(ctxOptions);
registerFn(app, ctx);
await app.ready();
return { app, ctx };
}
```
### Why `ctx: any` in the harness
Route registration functions like `registerSessionRoutes(app, ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort)` expect specific port intersection types. TypeScript won't accept `unknown` here because it's not assignable to the port types. The `MockRouteContext` satisfies the interfaces structurally (it has all the required properties and methods), but since it's not declared as implementing them, we need `any` at the call site. This is the standard pattern for test mocks in TypeScript.
### Verification
```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
```
+11
View File
@@ -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';
+121
View File
@@ -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<string, MockSession>();
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<string, unknown>[] = [];
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<typeof createMockRouteContext>;
+312
View File
@@ -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<boolean> {
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<string, string> = {
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<string, unknown> | null = null;
/** Ralph todo items */
ralphTodos: unknown[] = [];
/** Ralph todo statistics */
ralphTodoStats: Record<string, unknown> = {};
/** 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<string, unknown> {
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);
}
+60
View File
@@ -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<string, unknown> = {
sessions: {} as Record<string, unknown>,
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {} as Record<string, unknown>,
};
// Session methods
getConfig = vi.fn(() => this.state.config);
getSessions = vi.fn(() => this.state.sessions as Record<string, unknown>);
getSession = vi.fn((id: string) => (this.state.sessions as Record<string, unknown>)[id]);
setSession = vi.fn((id: string, state: unknown) => {
(this.state.sessions as Record<string, unknown>)[id] = state;
});
removeSession = vi.fn((id: string) => {
delete (this.state.sessions as Record<string, unknown>)[id];
});
// Ralph state methods
getRalphLoopState = vi.fn(() => this.state.ralphLoop);
setRalphLoopState = vi.fn((update: Record<string, unknown>) => {
this.state.ralphLoop = { ...(this.state.ralphLoop as Record<string, unknown>), ...update };
});
// Task methods
getTasks = vi.fn(() => this.state.tasks);
setTask = vi.fn();
removeTask = vi.fn();
// Settings methods
getSettings = vi.fn(() => ({}));
setSettings = vi.fn();
// Generic persistence
save = vi.fn();
load = vi.fn();
/** Reset all state and mocks for clean test isolation */
reset(): void {
this.state = {
sessions: {},
config: { maxConcurrentSessions: 5 },
ralphLoop: { status: 'stopped' },
tasks: {},
};
vi.clearAllMocks();
}
}
+37
View File
@@ -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<unknown> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for event "${event}" after ${timeoutMs}ms`)),
timeoutMs,
);
emitter.once(event, (...args: unknown[]) => {
clearTimeout(timer);
resolve(args.length === 1 ? args[0] : args);
});
});
}
/** Create a deferred promise with external resolve/reject */
export function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
} {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
+1 -58
View File
@@ -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<boolean> {
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;
+1 -26
View File
@@ -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<boolean> {
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 ==========
+26 -344
View File
@@ -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<boolean> {
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<string, string> = {
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<AiIdleCheckConfig> = {},
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<AiIdleCheckConfig>): void {
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
Object.entries(config).filter(([, v]) => v !== undefined),
) as Partial<AiIdleCheckConfig>;
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<AiPlanCheckConfig> = {},
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<AiPlanCheckConfig>): void {
const filteredConfig = Object.fromEntries(
Object.entries(config).filter(([, v]) => v !== undefined)
Object.entries(config).filter(([, v]) => v !== undefined),
) as Partial<AiPlanCheckConfig>;
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<RespawnConfig> = {
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<void> {
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<T = unknown>(
emitter: EventEmitter,
eventName: string,
timeoutMs: number = 1000
): Promise<T> {
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<T = void>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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`;
},
};
+40
View File
@@ -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<RouteTestHarness> {
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 };
}
+228
View File
@@ -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);
});
});
});
+465
View File
@@ -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);
});
});
});
+166
View File
@@ -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);
});
});
});