mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +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 };
|
||||
}
|
||||
Reference in New Issue
Block a user