/** * Unit tests for process-utils.ts */ import { describe, it, expect, jest } from 'bun:test'; import { EventEmitter } from 'events'; import { killWithEscalation } from '../../../src/utils/process-utils'; import type { ChildProcess } from 'child_process'; // Mock ChildProcess using EventEmitter function createMockProcess(exitCode: number | null = null): ChildProcess { const proc = new EventEmitter() as any; proc.killed = false; proc.exitCode = exitCode; proc.kill = jest.fn((signal?: string) => { if (signal === 'SIGTERM' || signal === 'SIGKILL') { proc.killed = true; } return true; }); return proc as ChildProcess; } function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } describe('killWithEscalation', () => { it('should send SIGTERM immediately', () => { const proc = createMockProcess(); killWithEscalation(proc); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); }); it('should send SIGKILL after grace period if process still running', async () => { const proc = createMockProcess(null); // exitCode null = still running killWithEscalation(proc, 10); // SIGTERM sent immediately expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); await wait(40); // SIGKILL sent after grace period expect(proc.kill).toHaveBeenCalledWith('SIGKILL'); expect(proc.kill).toHaveBeenCalledTimes(2); }); it('should NOT send SIGKILL if process exits before grace period', async () => { const proc = createMockProcess(null); killWithEscalation(proc, 40); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); // Simulate process exit before grace timeout await wait(10); proc.exitCode = 0; proc.emit('exit', 0); await wait(60); // SIGKILL should NOT have been sent expect(proc.kill).toHaveBeenCalledTimes(1); expect(proc.kill).not.toHaveBeenCalledWith('SIGKILL'); }); it('should use default grace period of 3000ms', () => { const proc = createMockProcess(null); const originalSetTimeout = globalThis.setTimeout; const fakeTimer = { unref: () => fakeTimer, ref: () => fakeTimer, hasRef: () => false, refresh: () => fakeTimer, } as unknown as ReturnType; let observedDelay: number | undefined; globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { observedDelay = timeout; void handler; // avoid executing callback in this assertion-only test return fakeTimer; }) as typeof globalThis.setTimeout; try { killWithEscalation(proc); expect(observedDelay).toBe(3000); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); } finally { globalThis.setTimeout = originalSetTimeout; } }); it('should respect custom grace period', () => { const proc = createMockProcess(null); const originalSetTimeout = globalThis.setTimeout; const fakeTimer = { unref: () => fakeTimer, ref: () => fakeTimer, hasRef: () => false, refresh: () => fakeTimer, } as unknown as ReturnType; let observedDelay: number | undefined; globalThis.setTimeout = ((handler: TimerHandler, timeout?: number) => { observedDelay = timeout; void handler; return fakeTimer; }) as typeof globalThis.setTimeout; try { killWithEscalation(proc, 5000); expect(observedDelay).toBe(5000); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); expect(proc.kill).toHaveBeenCalledTimes(1); } finally { globalThis.setTimeout = originalSetTimeout; } }); it('should clear timer when process exits', async () => { const proc = createMockProcess(null); const originalClearTimeout = globalThis.clearTimeout; let clearCalled = false; globalThis.clearTimeout = ((id: ReturnType) => { clearCalled = true; return originalClearTimeout(id); }) as typeof globalThis.clearTimeout; try { killWithEscalation(proc, 50); // Simulate immediate exit proc.exitCode = 0; proc.emit('exit', 0); await wait(70); // Should only have SIGTERM, timer was cleared expect(clearCalled).toBe(true); expect(proc.kill).toHaveBeenCalledTimes(1); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); } finally { globalThis.clearTimeout = originalClearTimeout; } }); it('should handle process that already exited', async () => { const proc = createMockProcess(0); // Already exited killWithEscalation(proc, 10); expect(proc.kill).toHaveBeenCalledWith('SIGTERM'); await wait(30); // SIGKILL should NOT be sent because exitCode is not null expect(proc.kill).toHaveBeenCalledTimes(1); }); });