mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user