mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
test: add comprehensive test suite with 129 tests
- Add PTY interactive session tests - Add SSE event streaming tests - Add scheduled runs API tests - Add edge case and error handling tests - Add integration flow tests for user workflows - Add respawn controller tests with edge cases - Add session cleanup and resource management tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* RespawnController Tests
|
||||
*
|
||||
* Tests the state machine that manages automatic respawning of Claude sessions
|
||||
* 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';
|
||||
writeBuffer: string[] = [];
|
||||
|
||||
write(data: string): void {
|
||||
this.writeBuffer.push(data);
|
||||
}
|
||||
|
||||
// Simulate terminal output
|
||||
simulateTerminalOutput(data: string): void {
|
||||
this.emit('terminal', data);
|
||||
}
|
||||
|
||||
// Simulate prompt appearing
|
||||
simulatePrompt(): void {
|
||||
this.emit('terminal', '❯ ');
|
||||
}
|
||||
|
||||
// Simulate working state
|
||||
simulateWorking(): void {
|
||||
this.emit('terminal', 'Thinking... ⠋');
|
||||
}
|
||||
|
||||
// Simulate clear completion
|
||||
simulateClearComplete(): void {
|
||||
this.emit('terminal', 'conversation cleared');
|
||||
}
|
||||
|
||||
// Simulate init completion
|
||||
simulateInitComplete(): void {
|
||||
this.emit('terminal', 'Analyzing CLAUDE.md...');
|
||||
setTimeout(() => this.simulatePrompt(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
describe('RespawnController', () => {
|
||||
let session: MockSession;
|
||||
let controller: RespawnController;
|
||||
|
||||
beforeEach(() => {
|
||||
session = new MockSession();
|
||||
controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 100, // Short timeout for testing
|
||||
interStepDelayMs: 50,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should start in stopped state', () => {
|
||||
expect(controller.state).toBe('stopped');
|
||||
expect(controller.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should have default configuration', () => {
|
||||
const config = controller.getConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.updatePrompt).toBe('update all the docs and CLAUDE.md');
|
||||
});
|
||||
|
||||
it('should allow custom configuration', () => {
|
||||
const customController = new RespawnController(session as unknown as Session, {
|
||||
updatePrompt: 'custom prompt',
|
||||
idleTimeoutMs: 10000,
|
||||
});
|
||||
const config = customController.getConfig();
|
||||
expect(config.updatePrompt).toBe('custom prompt');
|
||||
expect(config.idleTimeoutMs).toBe(10000);
|
||||
customController.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Machine', () => {
|
||||
it('should transition to watching state on start', () => {
|
||||
const states: RespawnState[] = [];
|
||||
controller.on('stateChanged', (state) => states.push(state));
|
||||
|
||||
controller.start();
|
||||
|
||||
expect(controller.state).toBe('watching');
|
||||
expect(states).toContain('watching');
|
||||
});
|
||||
|
||||
it('should not start if already running', () => {
|
||||
controller.start();
|
||||
const initialState = controller.state;
|
||||
|
||||
controller.start(); // Try to start again
|
||||
|
||||
expect(controller.state).toBe(initialState);
|
||||
});
|
||||
|
||||
it('should transition to stopped on stop', () => {
|
||||
controller.start();
|
||||
controller.stop();
|
||||
|
||||
expect(controller.state).toBe('stopped');
|
||||
expect(controller.isRunning).toBe(false);
|
||||
});
|
||||
|
||||
it('should track cycle count', () => {
|
||||
expect(controller.currentCycle).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Idle Detection', () => {
|
||||
it('should detect prompt pattern', async () => {
|
||||
const logMessages: string[] = [];
|
||||
controller.on('log', (msg) => logMessages.push(msg));
|
||||
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
// Wait for log
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
const hasPromptLog = logMessages.some(msg => msg.includes('Prompt detected'));
|
||||
expect(hasPromptLog).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect multiple prompt patterns', () => {
|
||||
controller.start();
|
||||
|
||||
// All these should trigger prompt detection
|
||||
const promptPatterns = ['❯', '\u276f', '⏵', '> ', 'tokens'];
|
||||
|
||||
for (const pattern of promptPatterns) {
|
||||
session.simulateTerminalOutput(pattern);
|
||||
}
|
||||
|
||||
// Controller should still be running after all patterns
|
||||
expect(controller.isRunning).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect working patterns and clear prompt state', () => {
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
// Simulate working - should clear prompt detected
|
||||
session.simulateWorking();
|
||||
|
||||
const status = controller.getStatus();
|
||||
expect(status.workingDetected).toBe(true);
|
||||
expect(status.promptDetected).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Respawn Cycle', () => {
|
||||
it('should start cycle when idle timeout fires', async () => {
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
// Wait for idle timeout
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(cycleStarted).toBe(true);
|
||||
expect(controller.currentCycle).toBe(1);
|
||||
});
|
||||
|
||||
it('should send update prompt during cycle', async () => {
|
||||
let stepSent: string | null = null;
|
||||
controller.on('stepSent', (step) => {
|
||||
stepSent = step;
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
// Wait for idle timeout and step
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
expect(stepSent).toBe('update');
|
||||
expect(session.writeBuffer.length).toBeGreaterThan(0);
|
||||
expect(session.writeBuffer[0]).toContain('update all the docs');
|
||||
});
|
||||
|
||||
it('should transition through states during cycle', async () => {
|
||||
const states: RespawnState[] = [];
|
||||
controller.on('stateChanged', (state) => states.push(state));
|
||||
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
// Wait for initial state change
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
// Should have transitioned through multiple states
|
||||
expect(states).toContain('watching');
|
||||
expect(states.length).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Configuration Update', () => {
|
||||
it('should update configuration', () => {
|
||||
controller.updateConfig({ updatePrompt: 'new prompt' });
|
||||
|
||||
const config = controller.getConfig();
|
||||
expect(config.updatePrompt).toBe('new prompt');
|
||||
});
|
||||
|
||||
it('should merge partial configuration', () => {
|
||||
const originalTimeout = controller.getConfig().idleTimeoutMs;
|
||||
controller.updateConfig({ updatePrompt: 'new prompt' });
|
||||
|
||||
const config = controller.getConfig();
|
||||
expect(config.idleTimeoutMs).toBe(originalTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status', () => {
|
||||
it('should provide complete status', () => {
|
||||
controller.start();
|
||||
|
||||
const status = controller.getStatus();
|
||||
|
||||
expect(status).toHaveProperty('state');
|
||||
expect(status).toHaveProperty('cycleCount');
|
||||
expect(status).toHaveProperty('lastActivityTime');
|
||||
expect(status).toHaveProperty('timeSinceActivity');
|
||||
expect(status).toHaveProperty('promptDetected');
|
||||
expect(status).toHaveProperty('workingDetected');
|
||||
expect(status).toHaveProperty('config');
|
||||
});
|
||||
|
||||
it('should track time since activity', async () => {
|
||||
controller.start();
|
||||
|
||||
// Wait a bit
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const status = controller.getStatus();
|
||||
expect(status.timeSinceActivity).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should not start when disabled', () => {
|
||||
const disabledController = new RespawnController(session as unknown as Session, {
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
disabledController.start();
|
||||
|
||||
expect(disabledController.state).toBe('stopped');
|
||||
disabledController.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pause and Resume', () => {
|
||||
it('should pause without changing state', () => {
|
||||
controller.start();
|
||||
const stateBeforePause = controller.state;
|
||||
|
||||
controller.pause();
|
||||
|
||||
expect(controller.state).toBe(stateBeforePause);
|
||||
});
|
||||
|
||||
it('should resume from watching state', () => {
|
||||
controller.start();
|
||||
controller.pause();
|
||||
controller.resume();
|
||||
|
||||
expect(controller.state).toBe('watching');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Terminal Buffer Management', () => {
|
||||
it('should handle large terminal output', () => {
|
||||
controller.start();
|
||||
|
||||
// Send lots of data
|
||||
const largeData = 'x'.repeat(20000);
|
||||
session.simulateTerminalOutput(largeData);
|
||||
|
||||
// Should not crash and controller should still work
|
||||
expect(controller.isRunning).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Emission', () => {
|
||||
it('should emit stateChanged events', async () => {
|
||||
const events: Array<{ state: RespawnState; prevState: RespawnState }> = [];
|
||||
controller.on('stateChanged', (state, prevState) => {
|
||||
events.push({ state, prevState });
|
||||
});
|
||||
|
||||
controller.start();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
expect(events[0].state).toBe('watching');
|
||||
expect(events[0].prevState).toBe('stopped');
|
||||
});
|
||||
|
||||
it('should emit log events', () => {
|
||||
const logs: string[] = [];
|
||||
controller.on('log', (msg) => logs.push(msg));
|
||||
|
||||
controller.start();
|
||||
|
||||
expect(logs.length).toBeGreaterThan(0);
|
||||
expect(logs.some(l => l.includes('Starting'))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('RespawnController Integration', () => {
|
||||
it('should handle rapid terminal data without errors', () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 1000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate rapid terminal output
|
||||
for (let i = 0; i < 100; i++) {
|
||||
session.simulateTerminalOutput(`Line ${i}\n`);
|
||||
}
|
||||
|
||||
expect(controller.isRunning).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle mixed working and idle states', async () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Alternate between working and idle
|
||||
session.simulatePrompt();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
session.simulateWorking();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
session.simulatePrompt();
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Should handle transitions gracefully
|
||||
expect(controller.isRunning).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle ANSI escape codes in terminal output', () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 1000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate output with ANSI codes
|
||||
session.simulateTerminalOutput('\x1b[32mGreen text\x1b[0m');
|
||||
session.simulateTerminalOutput('\x1b[1;34mBold blue\x1b[0m');
|
||||
session.simulateTerminalOutput('\x1b[2J\x1b[H'); // Clear screen and move cursor
|
||||
session.simulateTerminalOutput('\x1b[?25l'); // Hide cursor
|
||||
session.simulateTerminalOutput('\x1b[?25h'); // Show cursor
|
||||
|
||||
// Should handle ANSI codes without crashing
|
||||
expect(controller.isRunning).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle empty terminal output', () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 1000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate empty and whitespace output
|
||||
session.simulateTerminalOutput('');
|
||||
session.simulateTerminalOutput(' ');
|
||||
session.simulateTerminalOutput('\n\n\n');
|
||||
session.simulateTerminalOutput('\t\t');
|
||||
|
||||
expect(controller.isRunning).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle start/stop cycles without memory leaks', () => {
|
||||
const session = new MockSession();
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 100,
|
||||
});
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
session.simulateWorking();
|
||||
session.simulatePrompt();
|
||||
controller.stop();
|
||||
}
|
||||
|
||||
// If we got here without crashing, the test passes
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle Unicode prompt characters', () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 1000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Test various prompt characters
|
||||
session.simulateTerminalOutput('❯ ');
|
||||
session.simulateTerminalOutput('\u276f '); // Unicode variant
|
||||
session.simulateTerminalOutput('⏵ '); // Alternative
|
||||
|
||||
const status = controller.getStatus();
|
||||
expect(status.promptDetected).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should handle spinner animations', () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
idleTimeoutMs: 1000,
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate spinner animation
|
||||
const spinnerChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||
for (const char of spinnerChars) {
|
||||
session.simulateTerminalOutput(`Working... ${char}`);
|
||||
}
|
||||
|
||||
const status = controller.getStatus();
|
||||
expect(status.workingDetected).toBe(true);
|
||||
controller.stop();
|
||||
});
|
||||
|
||||
it('should not trigger cycle when disabled', async () => {
|
||||
const session = new MockSession();
|
||||
const controller = new RespawnController(session as unknown as Session, {
|
||||
enabled: false,
|
||||
idleTimeoutMs: 50,
|
||||
});
|
||||
|
||||
let cycleStarted = false;
|
||||
controller.on('respawnCycleStarted', () => {
|
||||
cycleStarted = true;
|
||||
});
|
||||
|
||||
controller.start();
|
||||
session.simulatePrompt();
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
|
||||
expect(cycleStarted).toBe(false);
|
||||
controller.stop();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user