diff --git a/test/cleanup-manager.test.ts b/test/cleanup-manager.test.ts new file mode 100644 index 00000000..38f74f6f --- /dev/null +++ b/test/cleanup-manager.test.ts @@ -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'); + }); + }); +}); diff --git a/test/debouncer.test.ts b/test/debouncer.test.ts new file mode 100644 index 00000000..307703c4 --- /dev/null +++ b/test/debouncer.test.ts @@ -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(); + }); +}); diff --git a/test/ralph-tracker.test.ts b/test/ralph-tracker.test.ts index 2082eb5a..34448786 100644 --- a/test/ralph-tracker.test.ts +++ b/test/ralph-tracker.test.ts @@ -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('SETUP\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(); + }); + }); }); diff --git a/test/respawn-controller.test.ts b/test/respawn-controller.test.ts index fa56bbf2..6b42f8f6 100644 --- a/test/respawn-controller.test.ts +++ b/test/respawn-controller.test.ts @@ -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(); + }); + }); +});