mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 05:59:43 +02:00
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:
@@ -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';
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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
@@ -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`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user