Files
Codeman/test/respawn-controller.test.ts
T
arkonandClaude Opus 4.5 34fb33fbc6 test: add global test setup with screen limits and reach 1337 tests
- Add test/setup.ts with max 10 concurrent screen sessions limiter
- Add orphaned Claude/screen process cleanup before/after tests
- Add semaphore-based screen slot acquisition for concurrency control
- Update vitest.config.ts with setupFiles and fileParallelism: false
- Add 16 new test files for comprehensive coverage
- Update README badge to show 1337 total tests
- Update CLAUDE.md with test setup documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 11:30:10 +01:00

849 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
writeViaScreen(data: string): boolean {
this.writeBuffer.push(data);
return true;
}
// Simulate terminal output
simulateTerminalOutput(data: string): void {
this.emit('terminal', data);
}
// Simulate prompt appearing (basic prompt character)
simulatePrompt(): void {
this.emit('terminal', '❯ ');
}
// Simulate ready state with the definitive indicator
simulateReady(): void {
this.emit('terminal', '↵ send');
}
// Simulate working state
simulateWorking(): void {
this.emit('terminal', 'Thinking... ⠋');
}
// Simulate clear completion (followed by ready indicator)
simulateClearComplete(): void {
this.emit('terminal', 'conversation cleared');
setTimeout(() => this.simulateReady(), 50);
}
// Simulate init completion (followed by ready indicator)
simulateInitComplete(): void {
this.emit('terminal', 'Analyzing CLAUDE.md...');
setTimeout(() => this.simulateReady(), 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();
});
});
describe('RespawnController Configuration', () => {
let session: MockSession;
beforeEach(() => {
session = new MockSession();
});
it('should use default sendClear option', () => {
const controller = new RespawnController(session as unknown as Session, {});
expect(controller.getConfig().sendClear).toBe(true);
controller.stop();
});
it('should use default sendInit option', () => {
const controller = new RespawnController(session as unknown as Session, {});
expect(controller.getConfig().sendInit).toBe(true);
controller.stop();
});
it('should respect custom sendClear option', () => {
const controller = new RespawnController(session as unknown as Session, {
sendClear: false,
});
expect(controller.getConfig().sendClear).toBe(false);
controller.stop();
});
it('should respect custom sendInit option', () => {
const controller = new RespawnController(session as unknown as Session, {
sendInit: false,
});
expect(controller.getConfig().sendInit).toBe(false);
controller.stop();
});
it('should support kickstartPrompt option', () => {
const controller = new RespawnController(session as unknown as Session, {
kickstartPrompt: '/init please start working',
});
expect(controller.getConfig().kickstartPrompt).toBe('/init please start working');
controller.stop();
});
it('should handle zero idleTimeoutMs', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 0,
});
expect(controller.getConfig().idleTimeoutMs).toBe(0);
controller.stop();
});
it('should handle large idleTimeoutMs', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 3600000, // 1 hour
});
expect(controller.getConfig().idleTimeoutMs).toBe(3600000);
controller.stop();
});
it('should handle zero interStepDelayMs', () => {
const controller = new RespawnController(session as unknown as Session, {
interStepDelayMs: 0,
});
expect(controller.getConfig().interStepDelayMs).toBe(0);
controller.stop();
});
it('should handle empty updatePrompt', () => {
const controller = new RespawnController(session as unknown as Session, {
updatePrompt: '',
});
expect(controller.getConfig().updatePrompt).toBe('');
controller.stop();
});
it('should handle special characters in updatePrompt', () => {
const controller = new RespawnController(session as unknown as Session, {
updatePrompt: 'prompt with "quotes" and \'apostrophes\' and $variables',
});
expect(controller.getConfig().updatePrompt).toContain('"quotes"');
controller.stop();
});
it('should handle unicode in updatePrompt', () => {
const controller = new RespawnController(session as unknown as Session, {
updatePrompt: '日本語のプロンプト 🚀',
});
expect(controller.getConfig().updatePrompt).toBe('日本語のプロンプト 🚀');
controller.stop();
});
it('should handle multiline updatePrompt', () => {
const controller = new RespawnController(session as unknown as Session, {
updatePrompt: 'Line 1\nLine 2\nLine 3',
});
expect(controller.getConfig().updatePrompt).toContain('\n');
controller.stop();
});
});
describe('RespawnController State Transitions', () => {
let session: MockSession;
let controller: RespawnController;
beforeEach(() => {
session = new MockSession();
controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 50,
interStepDelayMs: 20,
});
});
afterEach(() => {
controller.stop();
});
it('should record state history', async () => {
const stateHistory: RespawnState[] = [];
controller.on('stateChanged', (state) => stateHistory.push(state));
controller.start();
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 200));
expect(stateHistory).toContain('watching');
expect(stateHistory.length).toBeGreaterThan(1);
});
it('should handle stop during state transition', async () => {
controller.start();
session.simulatePrompt();
// Wait a bit then stop during potential transition
await new Promise(resolve => setTimeout(resolve, 30));
controller.stop();
expect(controller.state).toBe('stopped');
});
it('should emit complete cycle event', async () => {
let cycleCompleted = false;
controller.on('respawnCycleCompleted', () => {
cycleCompleted = true;
});
controller.start();
session.simulateReady();
await new Promise(resolve => setTimeout(resolve, 500));
// May or may not complete depending on timing
expect(controller.isRunning).toBe(true);
controller.stop();
});
it('should handle multiple consecutive prompt detections', async () => {
let promptCount = 0;
controller.on('log', (msg) => {
if (msg.includes('Prompt detected')) promptCount++;
});
controller.start();
// Send multiple prompts rapidly
session.simulatePrompt();
session.simulatePrompt();
session.simulatePrompt();
await new Promise(resolve => setTimeout(resolve, 100));
// Should detect prompts
expect(promptCount).toBeGreaterThan(0);
});
it('should handle working state interrupting idle timeout', async () => {
let cycleStarted = false;
controller.on('respawnCycleStarted', () => {
cycleStarted = true;
});
controller.start();
session.simulatePrompt();
// Before idle timeout fires, start working
await new Promise(resolve => setTimeout(resolve, 20));
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
// Cycle may not have started due to working state
expect(controller.getStatus().workingDetected).toBe(true);
});
});
describe('RespawnController Edge Cases', () => {
let session: MockSession;
beforeEach(() => {
session = new MockSession();
});
it('should handle null session events gracefully', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
// Emit with undefined/null data
session.emit('terminal', undefined);
session.emit('terminal', null);
session.emit('terminal', '');
expect(controller.isRunning).toBe(true);
controller.stop();
});
it('should handle very long terminal lines', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
// Send a very long line
const longLine = 'a'.repeat(100000);
session.simulateTerminalOutput(longLine);
expect(controller.isRunning).toBe(true);
controller.stop();
});
it('should handle binary data in terminal', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
// Send some binary-like data
const binaryData = Buffer.from([0x00, 0x01, 0x02, 0xff, 0xfe]).toString();
session.simulateTerminalOutput(binaryData);
expect(controller.isRunning).toBe(true);
controller.stop();
});
it('should handle pause when already paused', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
controller.pause();
controller.pause(); // Double pause
expect(controller.isRunning).toBe(true);
controller.stop();
});
it('should handle resume when not paused', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
controller.resume(); // Resume when not paused
expect(controller.isRunning).toBe(true);
expect(controller.state).toBe('watching');
controller.stop();
});
it('should handle stop when already stopped', () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.stop(); // Stop when never started
controller.stop(); // Double stop
expect(controller.state).toBe('stopped');
});
it('should handle updateConfig while running', async () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 100,
updatePrompt: 'original',
});
controller.start();
session.simulatePrompt();
// Update config mid-run
controller.updateConfig({ updatePrompt: 'updated' });
await new Promise(resolve => setTimeout(resolve, 200));
expect(controller.getConfig().updatePrompt).toBe('updated');
controller.stop();
});
it('should track cycle count across multiple cycles', async () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 30,
interStepDelayMs: 10,
});
expect(controller.currentCycle).toBe(0);
controller.start();
session.simulateReady();
await new Promise(resolve => setTimeout(resolve, 150));
expect(controller.currentCycle).toBeGreaterThan(0);
controller.stop();
});
it('should provide accurate time since activity', async () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
const status = controller.getStatus();
expect(status.timeSinceActivity).toBeGreaterThanOrEqual(100);
controller.stop();
});
it('should reset time since activity on terminal input', async () => {
const controller = new RespawnController(session as unknown as Session, {
idleTimeoutMs: 1000,
});
controller.start();
await new Promise(resolve => setTimeout(resolve, 100));
session.simulateTerminalOutput('new data');
const status = controller.getStatus();
// Time should be reset or very small
expect(status.timeSinceActivity).toBeLessThan(100);
controller.stop();
});
});