test: add unit tests for Debouncer, CleanupManager, and timer migration refactors

New test files for utilities that previously had no dedicated coverage,
plus migration tests validating the ralph-tracker and respawn-controller
timer refactorings work correctly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-01 16:50:23 +01:00
co-authored by Claude Opus 4.6
parent 71276c5d6e
commit 8e5f95d6ea
4 changed files with 1242 additions and 0 deletions
+333
View File
@@ -0,0 +1,333 @@
/**
* @fileoverview Tests for CleanupManager utility
*
* Verifies centralized resource cleanup: timers, intervals, watchers,
* listeners, streams, and custom cleanup functions.
*
* Port: N/A (unit tests, no server)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { CleanupManager } from '../src/utils/cleanup-manager.js';
describe('CleanupManager', () => {
let cm: CleanupManager;
beforeEach(() => {
vi.useFakeTimers();
cm = new CleanupManager();
});
afterEach(() => {
cm.dispose();
vi.useRealTimers();
});
describe('setTimeout', () => {
it('fires callback after delay', () => {
const cb = vi.fn();
cm.setTimeout(cb, 1000);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(cb).toHaveBeenCalledOnce();
});
it('auto-removes registration after firing', () => {
const cb = vi.fn();
cm.setTimeout(cb, 500);
expect(cm.resourceCount).toBe(1);
vi.advanceTimersByTime(500);
expect(cm.resourceCount).toBe(0);
});
it('does NOT fire callback after dispose', () => {
const cb = vi.fn();
cm.setTimeout(cb, 1000);
cm.dispose();
vi.advanceTimersByTime(1000);
expect(cb).not.toHaveBeenCalled();
});
it('returns a string ID', () => {
const id = cm.setTimeout(() => {}, 100);
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
});
});
describe('setInterval', () => {
it('fires repeatedly', () => {
const cb = vi.fn();
cm.setInterval(cb, 200);
vi.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(200);
expect(cb).toHaveBeenCalledTimes(3);
});
it('stops firing after dispose', () => {
const cb = vi.fn();
cm.setInterval(cb, 100);
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
cm.dispose();
vi.advanceTimersByTime(500);
expect(cb).toHaveBeenCalledTimes(1);
});
});
describe('unregister', () => {
it('cancels a specific timer by ID', () => {
const cb = vi.fn();
const id = cm.setTimeout(cb, 1000);
expect(cm.unregister(id)).toBe(true);
vi.advanceTimersByTime(1000);
expect(cb).not.toHaveBeenCalled();
});
it('removes the registration from tracking', () => {
const id = cm.setTimeout(() => {}, 1000);
expect(cm.resourceCount).toBe(1);
cm.unregister(id);
expect(cm.resourceCount).toBe(0);
});
it('returns false for unknown ID', () => {
expect(cm.unregister('nonexistent')).toBe(false);
});
it('cancels a specific interval by ID', () => {
const cb = vi.fn();
const id = cm.setInterval(cb, 100);
vi.advanceTimersByTime(100);
expect(cb).toHaveBeenCalledTimes(1);
cm.unregister(id);
vi.advanceTimersByTime(500);
expect(cb).toHaveBeenCalledTimes(1);
});
});
describe('dispose', () => {
it('is idempotent — safe to call twice', () => {
const cb = vi.fn();
cm.registerCleanup('timer', cb, 'test cleanup');
cm.dispose();
cm.dispose();
expect(cb).toHaveBeenCalledOnce();
});
it('clears all registrations', () => {
cm.setTimeout(() => {}, 1000);
cm.setInterval(() => {}, 1000);
cm.registerCleanup('watcher', () => {}, 'test');
expect(cm.resourceCount).toBe(3);
cm.dispose();
expect(cm.resourceCount).toBe(0);
});
});
describe('isStopped / isDisposed', () => {
it('returns false before dispose', () => {
expect(cm.isStopped).toBe(false);
expect(cm.isDisposed).toBe(false);
});
it('returns true after dispose', () => {
cm.dispose();
expect(cm.isStopped).toBe(true);
expect(cm.isDisposed).toBe(true);
});
});
describe('resourceCount', () => {
it('tracks active registrations', () => {
expect(cm.resourceCount).toBe(0);
cm.setTimeout(() => {}, 1000);
expect(cm.resourceCount).toBe(1);
cm.setInterval(() => {}, 1000);
expect(cm.resourceCount).toBe(2);
cm.registerCleanup('watcher', () => {}, 'w');
expect(cm.resourceCount).toBe(3);
});
it('decrements when timer fires naturally', () => {
cm.setTimeout(() => {}, 500);
cm.setTimeout(() => {}, 1000);
expect(cm.resourceCount).toBe(2);
vi.advanceTimersByTime(500);
expect(cm.resourceCount).toBe(1);
vi.advanceTimersByTime(500);
expect(cm.resourceCount).toBe(0);
});
});
describe('resourceCounts', () => {
it('returns breakdown by type', () => {
cm.setTimeout(() => {}, 1000);
cm.setTimeout(() => {}, 2000);
cm.setInterval(() => {}, 1000);
cm.registerCleanup('watcher', () => {}, 'w1');
cm.registerCleanup('listener', () => {}, 'l1');
cm.registerCleanup('stream', () => {}, 's1');
cm.registerCleanup('stream', () => {}, 's2');
const counts = cm.resourceCounts;
expect(counts.timer).toBe(2);
expect(counts.interval).toBe(1);
expect(counts.watcher).toBe(1);
expect(counts.listener).toBe(1);
expect(counts.stream).toBe(2);
});
it('returns all zeros when empty', () => {
const counts = cm.resourceCounts;
expect(counts.timer).toBe(0);
expect(counts.interval).toBe(0);
expect(counts.watcher).toBe(0);
expect(counts.listener).toBe(0);
expect(counts.stream).toBe(0);
});
});
describe('registerCleanup', () => {
it('calls cleanup function on dispose', () => {
const cleanup = vi.fn();
cm.registerCleanup('timer', cleanup, 'custom cleanup');
expect(cleanup).not.toHaveBeenCalled();
cm.dispose();
expect(cleanup).toHaveBeenCalledOnce();
});
it('returns a registration ID', () => {
const id = cm.registerCleanup('watcher', () => {}, 'test');
expect(typeof id).toBe('string');
expect(id.length).toBeGreaterThan(0);
});
});
describe('registerWatcher', () => {
it('calls close() on dispose', () => {
const watcher = { close: vi.fn() };
cm.registerWatcher(watcher, 'test watcher');
cm.dispose();
expect(watcher.close).toHaveBeenCalledOnce();
});
});
describe('registerListener', () => {
it('calls removeListener on dispose', () => {
const emitter = { removeListener: vi.fn() };
const listener = vi.fn();
cm.registerListener(emitter, 'data', listener, 'test listener');
cm.dispose();
expect(emitter.removeListener).toHaveBeenCalledWith('data', listener);
});
it('falls back to off() if no removeListener', () => {
const emitter = { off: vi.fn() };
const listener = vi.fn();
cm.registerListener(emitter, 'close', listener, 'test listener');
cm.dispose();
expect(emitter.off).toHaveBeenCalledWith('close', listener);
});
});
describe('registerStream', () => {
it('calls destroy() on dispose', () => {
const stream = { destroy: vi.fn(), close: vi.fn() };
cm.registerStream(stream, 'test stream');
cm.dispose();
expect(stream.destroy).toHaveBeenCalledOnce();
expect(stream.close).not.toHaveBeenCalled();
});
it('falls back to close() if no destroy', () => {
const stream = { close: vi.fn() };
cm.registerStream(stream, 'test stream');
cm.dispose();
expect(stream.close).toHaveBeenCalledOnce();
});
});
describe('error resilience', () => {
it('error in one cleanup does not prevent others', () => {
const cleanup1 = vi.fn();
const cleanup2 = vi.fn(() => {
throw new Error('boom');
});
const cleanup3 = vi.fn();
cm.registerCleanup('timer', cleanup1, 'first');
cm.registerCleanup('timer', cleanup2, 'exploding');
cm.registerCleanup('timer', cleanup3, 'third');
// Suppress console.error from dispose
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
cm.dispose();
expect(cleanup1).toHaveBeenCalledOnce();
expect(cleanup2).toHaveBeenCalledOnce();
expect(cleanup3).toHaveBeenCalledOnce();
errorSpy.mockRestore();
});
it('logs errors during disposal', () => {
cm.registerCleanup('timer', () => {
throw new Error('fail');
}, 'bad cleanup');
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
cm.dispose();
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining('1 errors during disposal'),
expect.stringContaining('bad cleanup')
);
errorSpy.mockRestore();
});
});
describe('getRegistrations', () => {
it('returns current registrations for debugging', () => {
cm.setTimeout(() => {}, 1000, { description: 'my timer' });
cm.registerCleanup('watcher', () => {}, 'my watcher');
const regs = cm.getRegistrations();
expect(regs).toHaveLength(2);
expect(regs.map((r) => r.description)).toContain('my timer');
expect(regs.map((r) => r.description)).toContain('my watcher');
});
});
});
+330
View File
@@ -0,0 +1,330 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { Debouncer, KeyedDebouncer } from '../src/utils/debouncer.js';
describe('Debouncer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires callback after delay', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(99);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledOnce();
});
it('resets timer when schedule() called again — only last callback fires', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
vi.advanceTimersByTime(50);
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
it('multiple rapid schedule() calls only fires the last one', () => {
const deb = new Debouncer(100);
const callbacks = Array.from({ length: 5 }, () => vi.fn());
for (const cb of callbacks) {
deb.schedule(cb);
}
vi.advanceTimersByTime(100);
for (let i = 0; i < callbacks.length - 1; i++) {
expect(callbacks[i]).not.toHaveBeenCalled();
}
expect(callbacks[callbacks.length - 1]).toHaveBeenCalledOnce();
});
it('cancel() prevents pending callback from firing', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
vi.advanceTimersByTime(50);
deb.cancel();
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it('isPending returns true when scheduled, false after fire', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
expect(deb.isPending).toBe(false);
deb.schedule(fn);
expect(deb.isPending).toBe(true);
vi.advanceTimersByTime(100);
expect(deb.isPending).toBe(false);
});
it('isPending returns false after cancel', () => {
const deb = new Debouncer(100);
deb.schedule(() => {});
expect(deb.isPending).toBe(true);
deb.cancel();
expect(deb.isPending).toBe(false);
});
it('dispose() is alias for cancel()', () => {
const deb = new Debouncer(100);
const fn = vi.fn();
deb.schedule(fn);
expect(deb.isPending).toBe(true);
deb.dispose();
expect(deb.isPending).toBe(false);
vi.advanceTimersByTime(100);
expect(fn).not.toHaveBeenCalled();
});
it('flush() cancels pending and runs provided function immediately', () => {
const deb = new Debouncer(100);
const scheduled = vi.fn();
const flushed = vi.fn();
deb.schedule(scheduled);
deb.flush(flushed);
expect(flushed).toHaveBeenCalledOnce();
expect(deb.isPending).toBe(false);
vi.advanceTimersByTime(100);
expect(scheduled).not.toHaveBeenCalled();
});
it('can be reused after firing', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
vi.advanceTimersByTime(100);
expect(fn1).toHaveBeenCalledOnce();
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn2).toHaveBeenCalledOnce();
});
it('can be reused after cancel', () => {
const deb = new Debouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule(fn1);
deb.cancel();
deb.schedule(fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
});
describe('KeyedDebouncer', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('fires per-key callback after delay', () => {
const deb = new KeyedDebouncer(100);
const fn = vi.fn();
deb.schedule('a', fn);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('independent keys fire at their own timing', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
vi.advanceTimersByTime(50);
deb.schedule('b', fnB);
vi.advanceTimersByTime(50);
expect(fnA).toHaveBeenCalledOnce();
expect(fnB).not.toHaveBeenCalled();
vi.advanceTimersByTime(50);
expect(fnB).toHaveBeenCalledOnce();
});
it('rescheduling same key resets timer — only last callback fires', () => {
const deb = new KeyedDebouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule('a', fn1);
vi.advanceTimersByTime(50);
deb.schedule('a', fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
it('cancelKey() only cancels specific key', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
deb.schedule('b', fnB);
deb.cancelKey('a');
vi.advanceTimersByTime(100);
expect(fnA).not.toHaveBeenCalled();
expect(fnB).toHaveBeenCalledOnce();
});
it('has() returns correct state per key', () => {
const deb = new KeyedDebouncer(100);
expect(deb.has('a')).toBe(false);
deb.schedule('a', () => {});
expect(deb.has('a')).toBe(true);
expect(deb.has('b')).toBe(false);
vi.advanceTimersByTime(100);
expect(deb.has('a')).toBe(false);
});
it('has() returns false after cancelKey()', () => {
const deb = new KeyedDebouncer(100);
deb.schedule('a', () => {});
deb.cancelKey('a');
expect(deb.has('a')).toBe(false);
});
it('size reflects active timer count', () => {
const deb = new KeyedDebouncer(100);
expect(deb.size).toBe(0);
deb.schedule('a', () => {});
expect(deb.size).toBe(1);
deb.schedule('b', () => {});
expect(deb.size).toBe(2);
vi.advanceTimersByTime(100);
expect(deb.size).toBe(0);
});
it('keys() returns active keys', () => {
const deb = new KeyedDebouncer(100);
deb.schedule('x', () => {});
deb.schedule('y', () => {});
deb.schedule('z', () => {});
const activeKeys = Array.from(deb.keys());
expect(activeKeys).toEqual(['x', 'y', 'z']);
});
it('dispose() cancels all keys', () => {
const deb = new KeyedDebouncer(100);
const fnA = vi.fn();
const fnB = vi.fn();
deb.schedule('a', fnA);
deb.schedule('b', fnB);
expect(deb.size).toBe(2);
deb.dispose();
expect(deb.size).toBe(0);
vi.advanceTimersByTime(100);
expect(fnA).not.toHaveBeenCalled();
expect(fnB).not.toHaveBeenCalled();
});
it('flushAll() cancels all timers and calls flush fn per active key', () => {
const deb = new KeyedDebouncer(100);
const scheduled1 = vi.fn();
const scheduled2 = vi.fn();
const flushFn = vi.fn();
deb.schedule('a', scheduled1);
deb.schedule('b', scheduled2);
deb.flushAll(flushFn);
expect(deb.size).toBe(0);
expect(flushFn).toHaveBeenCalledTimes(2);
expect(flushFn).toHaveBeenCalledWith('a');
expect(flushFn).toHaveBeenCalledWith('b');
vi.advanceTimersByTime(100);
expect(scheduled1).not.toHaveBeenCalled();
expect(scheduled2).not.toHaveBeenCalled();
});
it('flushAll() with no active keys does nothing', () => {
const deb = new KeyedDebouncer(100);
const flushFn = vi.fn();
deb.flushAll(flushFn);
expect(flushFn).not.toHaveBeenCalled();
});
it('cancelKey() on non-existent key is a no-op', () => {
const deb = new KeyedDebouncer(100);
expect(() => deb.cancelKey('nonexistent')).not.toThrow();
});
it('can be reused after dispose()', () => {
const deb = new KeyedDebouncer(100);
const fn1 = vi.fn();
const fn2 = vi.fn();
deb.schedule('a', fn1);
deb.dispose();
deb.schedule('b', fn2);
vi.advanceTimersByTime(100);
expect(fn1).not.toHaveBeenCalled();
expect(fn2).toHaveBeenCalledOnce();
});
});
+177
View File
@@ -1794,4 +1794,181 @@ Final text
expect(todos[0].priority).toBe('P0');
});
});
describe('Debouncer migration', () => {
// EVENT_DEBOUNCE_MS is 50 in ralph-tracker.ts
const EVENT_DEBOUNCE_MS = 50;
beforeEach(() => {
vi.useFakeTimers();
tracker = new RalphTracker();
tracker.enable();
});
afterEach(() => {
tracker.destroy();
vi.useRealTimers();
});
it('should debounce todoUpdate events (not fire immediately)', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] Fix the bug\n');
// Should not fire immediately
expect(handler).not.toHaveBeenCalled();
// Should fire after debounce delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(expect.arrayContaining([
expect.objectContaining({ content: expect.stringContaining('Fix the bug') }),
]));
});
it('should debounce loopUpdate events (not fire immediately)', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// enable() emits loopUpdate synchronously — capture that first
tracker.processTerminalData('<promise>SETUP</promise>\n');
const callsAfterSetup = handler.mock.calls.length;
// Iteration/Elapsed/Cycle lines use emitLoopUpdateDebounced()
tracker.processTerminalData('Elapsed: 2.5 hours\n');
// Should not fire immediately (debounced)
expect(handler).toHaveBeenCalledTimes(callsAfterSetup);
// Should fire after debounce delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(callsAfterSetup + 1);
});
it('should flush pending todoUpdate events immediately via flushPendingEvents()', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] Pending task\n');
expect(handler).not.toHaveBeenCalled();
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(1);
// Timer should be cancelled — no duplicate after delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should flush pending loopUpdate events immediately via flushPendingEvents()', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// Use Elapsed pattern which goes through emitLoopUpdateDebounced()
tracker.processTerminalData('Elapsed: 3.0 hours\n');
const callsBefore = handler.mock.calls.length;
// Should have a pending debounce (not yet fired)
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(callsBefore + 1);
// No duplicate after delay
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(callsBefore + 1);
});
it('should not fire pending events after destroy()', () => {
const todoHandler = vi.fn();
const loopHandler = vi.fn();
tracker.on('todoUpdate', todoHandler);
tracker.on('loopUpdate', loopHandler);
// Feed data that uses debounced paths
tracker.processTerminalData('- [ ] Will not arrive\n');
tracker.processTerminalData('Elapsed: 5.0 hours\n');
const todoCallsBefore = todoHandler.mock.calls.length;
const loopCallsBefore = loopHandler.mock.calls.length;
tracker.destroy();
// Advance past debounce — nothing new should fire
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS * 2);
expect(todoHandler).toHaveBeenCalledTimes(todoCallsBefore);
expect(loopHandler).toHaveBeenCalledTimes(loopCallsBefore);
});
it('should coalesce rapid todoUpdate events into one emission', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
// Feed multiple todo items rapidly (within debounce window)
tracker.processTerminalData('- [ ] Task one\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('- [ ] Task two\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('- [ ] Task three\n');
// Still within debounce window — nothing fired yet
expect(handler).not.toHaveBeenCalled();
// Advance past debounce from the last schedule
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
// The final emission should include all 3 todos
const emittedTodos = handler.mock.calls[0][0];
expect(emittedTodos).toHaveLength(3);
});
it('should coalesce rapid loopUpdate events into one emission', () => {
const handler = vi.fn();
tracker.on('loopUpdate', handler);
// Feed multiple loop state changes rapidly
tracker.processTerminalData('Iteration: 1/10\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('Elapsed: 1.5 hours\n');
vi.advanceTimersByTime(10);
tracker.processTerminalData('Cycle: 3\n');
// Still within debounce window
expect(handler).not.toHaveBeenCalled();
// Advance past debounce
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(1);
});
it('should allow new debounced events after flush', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] First batch\n');
tracker.flushPendingEvents();
expect(handler).toHaveBeenCalledTimes(1);
// Feed more data — should debounce again
tracker.processTerminalData('- [ ] Second batch\n');
expect(handler).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS);
expect(handler).toHaveBeenCalledTimes(2);
});
it('should clear pending debounced events on reset()', () => {
const handler = vi.fn();
tracker.on('todoUpdate', handler);
tracker.processTerminalData('- [ ] About to reset\n');
expect(handler).not.toHaveBeenCalled();
tracker.reset();
// Timer should have been cancelled by reset
vi.advanceTimersByTime(EVENT_DEBOUNCE_MS * 2);
expect(handler).not.toHaveBeenCalled();
});
});
});
+402
View File
@@ -2631,3 +2631,405 @@ describe('RespawnController Hook-Based Idle Detection', () => {
expect(status.confidenceLevel).toBe(100);
});
});
// ========== CleanupManager Timer Tracking Tests ==========
describe('RespawnController CleanupManager Timer Tracking', () => {
let session: MockSession;
beforeEach(() => {
session = new MockSession();
});
describe('Timer lifecycle events', () => {
it('should emit timerStarted when completion confirm timer begins', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 200,
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
});
const timerEvents: Array<{ name: string; durationMs: number; endsAt: number; reason?: string }> = [];
controller.on('timerStarted', (timer) => {
timerEvents.push(timer);
});
controller.start();
session.simulateCompletionMessage();
// Wait for completion detection to trigger timer
await new Promise(resolve => setTimeout(resolve, 100));
// Should have started at least one timer (completion-confirm or no-output-fallback)
expect(timerEvents.length).toBeGreaterThan(0);
// Each timer event should have valid fields
for (const event of timerEvents) {
expect(event.name).toBeTruthy();
expect(event.durationMs).toBeGreaterThan(0);
expect(event.endsAt).toBeGreaterThan(0);
}
controller.stop();
});
it('should emit timerStarted with correct duration for no-output-fallback', async () => {
const noOutputTimeoutMs = 300;
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000, // Long so it doesn't fire first
noOutputTimeoutMs,
aiIdleCheckEnabled: false,
});
const timerEvents: Array<{ name: string; durationMs: number }> = [];
controller.on('timerStarted', (timer) => {
timerEvents.push({ name: timer.name, durationMs: timer.durationMs });
});
controller.start();
// Send output to trigger no-output timer reset
session.simulateTerminalOutput('some output');
await new Promise(resolve => setTimeout(resolve, 100));
const noOutputTimer = timerEvents.find(e => e.name === 'no-output-fallback');
if (noOutputTimer) {
expect(noOutputTimer.durationMs).toBe(noOutputTimeoutMs);
}
controller.stop();
});
it('should emit timerCompleted when a timer fires', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 50,
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
});
const completedTimers: string[] = [];
controller.on('timerCompleted', (name: string) => {
completedTimers.push(name);
});
controller.start();
session.simulateCompletionMessage();
// Wait for completion confirm timer to fire (50ms + processing)
await new Promise(resolve => setTimeout(resolve, 200));
// At least one timer should have completed (completion-confirm)
expect(completedTimers.length).toBeGreaterThan(0);
controller.stop();
});
});
describe('Timer cancellation', () => {
it('should emit timerCancelled when working patterns interrupt idle detection', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000, // Long so we can cancel it
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
const cancelledTimers: Array<{ name: string; reason?: string }> = [];
controller.on('timerCancelled', (name: string, reason?: string) => {
cancelledTimers.push({ name, reason });
});
controller.start();
session.simulateCompletionMessage();
// Wait for timer to start
await new Promise(resolve => setTimeout(resolve, 100));
// Simulate working to cancel the completion confirm timer
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
// Working patterns should have cancelled at least one timer
const hasCancel = cancelledTimers.length > 0;
expect(hasCancel).toBe(true);
controller.stop();
});
it('should include reason in timerCancelled event', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
const cancelledTimers: Array<{ name: string; reason?: string }> = [];
controller.on('timerCancelled', (name: string, reason?: string) => {
cancelledTimers.push({ name, reason });
});
controller.start();
// Trigger Stop hook to start hook-confirm timer
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
// Then working patterns should cancel it with a reason
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
const hookCancel = cancelledTimers.find(e => e.name === 'hook-confirm');
if (hookCancel) {
expect(hookCancel.reason).toBeTruthy();
}
controller.stop();
});
});
describe('clearTimers on stop', () => {
it('should clear all active timers when stopped', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
controller.start();
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 100));
// Should have active timers
const timersBefore = controller.getActiveTimers();
expect(timersBefore.length).toBeGreaterThan(0);
// Stop should clear all
controller.stop();
const timersAfter = controller.getActiveTimers();
expect(timersAfter.length).toBe(0);
});
it('should not fire timers after stop', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 100,
noOutputTimeoutMs: 200,
aiIdleCheckEnabled: false,
});
let timerFiredAfterStop = false;
controller.start();
session.simulateCompletionMessage();
// Wait for timers to start
await new Promise(resolve => setTimeout(resolve, 50));
controller.stop();
// Listen for any events after stop
controller.on('timerCompleted', () => {
timerFiredAfterStop = true;
});
controller.on('respawnCycleStarted', () => {
timerFiredAfterStop = true;
});
// Wait longer than any timer duration
await new Promise(resolve => setTimeout(resolve, 400));
expect(timerFiredAfterStop).toBe(false);
});
});
describe('Multiple start/stop cycles', () => {
it('should recreate CleanupManager on restart (no stale timers)', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
// First cycle: start, trigger completion-related timers, stop
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
// Should have completion-confirm timer active
const firstCycleTimers = controller.getActiveTimers();
const hasCompletionConfirm = firstCycleTimers.some(t => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(true);
controller.stop();
expect(controller.getActiveTimers().length).toBe(0);
// Second cycle: start fresh — completion-confirm from first cycle should be gone
controller.start();
expect(controller.state).toBe('watching');
const restartTimers = controller.getActiveTimers();
const staleCompletionConfirm = restartTimers.some(t => t.name === 'completion-confirm');
expect(staleCompletionConfirm).toBe(false);
// Can still trigger new timers
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
const newTimers = controller.getActiveTimers();
const hasNewCompletionConfirm = newTimers.some(t => t.name === 'completion-confirm');
expect(hasNewCompletionConfirm).toBe(true);
controller.stop();
});
it('should handle rapid start/stop without timer leaks', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 50,
noOutputTimeoutMs: 200,
aiIdleCheckEnabled: false,
});
const completedTimers: string[] = [];
controller.on('timerCompleted', (name: string) => {
completedTimers.push(name);
});
// Rapid start/stop cycles
for (let i = 0; i < 5; i++) {
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 20));
controller.stop();
}
// Wait to check no stale timers fire
const countBefore = completedTimers.length;
await new Promise(resolve => setTimeout(resolve, 300));
const countAfter = completedTimers.length;
// No new timer completions should happen after final stop
expect(countAfter).toBe(countBefore);
expect(controller.state).toBe('stopped');
});
});
describe('activeTimers tracking', () => {
it('should return correct timer metadata from getActiveTimers()', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
expect(activeTimers.length).toBeGreaterThan(0);
for (const timer of activeTimers) {
expect(timer.name).toBeTruthy();
expect(typeof timer.name).toBe('string');
expect(timer.remainingMs).toBeGreaterThanOrEqual(0);
expect(timer.totalMs).toBeGreaterThan(0);
// remainingMs should not exceed totalMs
expect(timer.remainingMs).toBeLessThanOrEqual(timer.totalMs);
}
controller.stop();
});
it('should include activeTimers in detection status', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
const detectionStatus = controller.getDetectionStatus();
expect(Array.isArray(detectionStatus.activeTimers)).toBe(true);
expect(detectionStatus.activeTimers.length).toBeGreaterThan(0);
controller.stop();
});
it('should remove timers from activeTimers after they complete', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 50, // Short timer
noOutputTimeoutMs: 5000,
aiIdleCheckEnabled: false,
});
controller.start();
session.simulateCompletionMessage();
// Wait for completion confirm timer to be set up
await new Promise(resolve => setTimeout(resolve, 20));
const timersBefore = controller.getActiveTimers();
const hasCompletionConfirm = timersBefore.some(t => t.name === 'completion-confirm');
// Wait for the timer to fire
await new Promise(resolve => setTimeout(resolve, 200));
const timersAfter = controller.getActiveTimers();
const stillHasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
// If we caught the timer before it fired, it should be gone now
if (hasCompletionConfirm) {
expect(stillHasCompletionConfirm).toBe(false);
}
controller.stop();
});
it('should remove timers from activeTimers after they are cancelled', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
controller.start();
session.simulateCompletionMessage();
await new Promise(resolve => setTimeout(resolve, 100));
// Should have timers
const timersBefore = controller.getActiveTimers();
expect(timersBefore.length).toBeGreaterThan(0);
// Cancel via working
session.simulateWorking();
await new Promise(resolve => setTimeout(resolve, 100));
// completion-confirm should be removed
const timersAfter = controller.getActiveTimers();
const hasCompletionConfirm = timersAfter.some(t => t.name === 'completion-confirm');
expect(hasCompletionConfirm).toBe(false);
controller.stop();
});
it('should track hook-confirm timer when Stop hook is signaled', async () => {
const controller = new RespawnController(session as unknown as Session, {
completionConfirmMs: 5000,
noOutputTimeoutMs: 10000,
aiIdleCheckEnabled: false,
});
controller.start();
controller.signalStopHook();
await new Promise(resolve => setTimeout(resolve, 100));
const activeTimers = controller.getActiveTimers();
const hookTimer = activeTimers.find(t => t.name === 'hook-confirm');
expect(hookTimer).toBeDefined();
if (hookTimer) {
expect(hookTimer.totalMs).toBeGreaterThan(0);
expect(hookTimer.remainingMs).toBeGreaterThan(0);
}
controller.stop();
});
});
});