/** * ProcessManager Tests * * Tests for the process lifecycle manager. * Mocks execa to avoid spawning real processes. */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { ProcessRegistry } from './registry.js'; import { ProcessManager } from './manager.js'; import type { EventBus, ProcessSpawnedEvent, ProcessStoppedEvent, ProcessCrashedEvent } from '../events/index.js'; // Mock execa module vi.mock('execa', () => { return { execa: vi.fn(), }; }); // Import execa mock after mocking import { execa } from 'execa'; const mockExeca = vi.mocked(execa); describe('ProcessManager', () => { let registry: ProcessRegistry; let manager: ProcessManager; let mockSubprocess: { pid: number; on: ReturnType; kill: ReturnType; unref: ReturnType; catch: ReturnType; then: ReturnType; }; let exitHandler: ((code: number | null, signal: string | null) => void) | null; beforeEach(() => { registry = new ProcessRegistry(); manager = new ProcessManager(registry); exitHandler = null; // Create mock subprocess mockSubprocess = { pid: 12345, on: vi.fn((event: string, handler: (code: number | null, signal: string | null) => void) => { if (event === 'exit') { exitHandler = handler; } }), kill: vi.fn(), unref: vi.fn(), catch: vi.fn().mockReturnThis(), then: vi.fn().mockReturnThis(), }; // Setup execa mock to return subprocess mockExeca.mockReturnValue(mockSubprocess as unknown as ReturnType); }); afterEach(() => { vi.clearAllMocks(); }); describe('spawn()', () => { it('should create process and register it', async () => { const info = await manager.spawn({ id: 'proc-1', command: 'node', args: ['server.js'], }); expect(info.id).toBe('proc-1'); expect(info.pid).toBe(12345); expect(info.command).toBe('node'); expect(info.args).toEqual(['server.js']); expect(info.status).toBe('running'); expect(info.startedAt).toBeInstanceOf(Date); // Verify registered in registry expect(registry.get('proc-1')).toBe(info); }); it('should call execa with correct arguments', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: ['app.js', '--port', '3000'], cwd: '/app', env: { NODE_ENV: 'production' }, }); expect(mockExeca).toHaveBeenCalledWith('node', ['app.js', '--port', '3000'], { cwd: '/app', env: expect.objectContaining({ NODE_ENV: 'production' }), detached: true, stdio: 'ignore', }); }); it('should throw if process ID already running', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); await expect( manager.spawn({ id: 'proc-1', command: 'node', args: [], }) ).rejects.toThrow("Process with id 'proc-1' is already running"); }); it('should throw if PID is undefined', async () => { mockSubprocess.pid = undefined as unknown as number; await expect( manager.spawn({ id: 'proc-1', command: 'node', args: [], }) ).rejects.toThrow("Failed to get PID for process 'proc-1'"); }); it('should set up exit handler that updates registry on normal exit', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Simulate normal exit exitHandler?.(0, null); expect(registry.get('proc-1')?.status).toBe('stopped'); }); it('should mark as crashed on non-zero exit', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Simulate crash (non-zero exit) exitHandler?.(1, null); expect(registry.get('proc-1')?.status).toBe('crashed'); }); }); describe('stop()', () => { it('should terminate process', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Make subprocess.then resolve immediately to simulate exit mockSubprocess.then.mockImplementation((cb: () => void) => { cb(); return mockSubprocess; }); await manager.stop('proc-1'); expect(mockSubprocess.kill).toHaveBeenCalledWith('SIGTERM'); }); it('should throw if process not found', async () => { await expect(manager.stop('non-existent')).rejects.toThrow( "Process with id 'non-existent' not found" ); }); it('should not throw for already stopped process', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Simulate process already stopped via exit handler exitHandler?.(0, null); // Should not throw await expect(manager.stop('proc-1')).resolves.toBeUndefined(); }); }); describe('stopAll()', () => { it('should stop all running processes', async () => { // Create multiple mock subprocesses const mockSubprocess1 = { ...mockSubprocess, pid: 111 }; const mockSubprocess2 = { ...mockSubprocess, pid: 222 }; const mockSubprocess3 = { ...mockSubprocess, pid: 333 }; let callCount = 0; mockExeca.mockImplementation(() => { callCount++; const sub = [mockSubprocess1, mockSubprocess2, mockSubprocess3][callCount - 1]; sub.then = vi.fn().mockImplementation((cb: () => void) => { cb(); return sub; }); return sub as unknown as ReturnType; }); await manager.spawn({ id: 'proc-1', command: 'node', args: [] }); await manager.spawn({ id: 'proc-2', command: 'node', args: [] }); await manager.spawn({ id: 'proc-3', command: 'node', args: [] }); await manager.stopAll(); expect(mockSubprocess1.kill).toHaveBeenCalledWith('SIGTERM'); expect(mockSubprocess2.kill).toHaveBeenCalledWith('SIGTERM'); expect(mockSubprocess3.kill).toHaveBeenCalledWith('SIGTERM'); }); }); describe('restart()', () => { it('should stop and respawn process', async () => { const mockSubprocess2 = { ...mockSubprocess, pid: 54321 }; // First spawn returns original subprocess mockExeca.mockReturnValueOnce(mockSubprocess as unknown as ReturnType); await manager.spawn({ id: 'proc-1', command: 'node', args: ['server.js'], }); // Make stop work mockSubprocess.then.mockImplementation((cb: () => void) => { cb(); return mockSubprocess; }); // Second spawn (after restart) returns new subprocess mockExeca.mockReturnValueOnce(mockSubprocess2 as unknown as ReturnType); const newInfo = await manager.restart('proc-1'); expect(newInfo.id).toBe('proc-1'); expect(newInfo.pid).toBe(54321); expect(newInfo.status).toBe('running'); }); it('should throw if process not found', async () => { await expect(manager.restart('non-existent')).rejects.toThrow( "Process with id 'non-existent' not found" ); }); }); describe('isRunning()', () => { it('should return true for running process', async () => { // Mock process.kill to not throw (process exists) const originalKill = process.kill; vi.spyOn(process, 'kill').mockImplementation(() => true); await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); expect(manager.isRunning('proc-1')).toBe(true); process.kill = originalKill; }); it('should return false for non-existent process', () => { expect(manager.isRunning('non-existent')).toBe(false); }); it('should return false and update status if process is dead', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Mock process.kill to throw (process dead) vi.spyOn(process, 'kill').mockImplementation(() => { throw new Error('ESRCH'); }); expect(manager.isRunning('proc-1')).toBe(false); expect(registry.get('proc-1')?.status).toBe('crashed'); }); it('should return false for stopped process', async () => { await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Simulate exit exitHandler?.(0, null); expect(manager.isRunning('proc-1')).toBe(false); }); }); describe('event emission', () => { let mockEventBus: EventBus; let managerWithBus: ProcessManager; beforeEach(() => { mockEventBus = { emit: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), }; managerWithBus = new ProcessManager(registry, mockEventBus); }); it('should emit ProcessSpawned event on spawn', async () => { await managerWithBus.spawn({ id: 'proc-1', command: 'node', args: ['server.js'], }); expect(mockEventBus.emit).toHaveBeenCalledWith( expect.objectContaining({ type: 'process:spawned', timestamp: expect.any(Date), payload: { processId: 'proc-1', pid: 12345, command: 'node', }, }) ); }); it('should emit ProcessStopped event on normal exit', async () => { await managerWithBus.spawn({ id: 'proc-1', command: 'node', args: [], }); // Clear spawn event vi.mocked(mockEventBus.emit).mockClear(); // Simulate normal exit (code 0) exitHandler?.(0, null); expect(mockEventBus.emit).toHaveBeenCalledWith( expect.objectContaining({ type: 'process:stopped', timestamp: expect.any(Date), payload: { processId: 'proc-1', pid: 12345, exitCode: 0, }, }) ); }); it('should emit ProcessCrashed event on non-zero exit', async () => { await managerWithBus.spawn({ id: 'proc-1', command: 'node', args: [], }); // Clear spawn event vi.mocked(mockEventBus.emit).mockClear(); // Simulate crash (non-zero exit with signal) exitHandler?.(1, 'SIGTERM'); expect(mockEventBus.emit).toHaveBeenCalledWith( expect.objectContaining({ type: 'process:crashed', timestamp: expect.any(Date), payload: { processId: 'proc-1', pid: 12345, exitCode: 1, signal: 'SIGTERM', }, }) ); }); it('should not emit events if eventBus is not provided', async () => { // Use manager without event bus await manager.spawn({ id: 'proc-1', command: 'node', args: [], }); // Simulate exit exitHandler?.(0, null); // No errors should occur (events just don't get emitted) expect(mockEventBus.emit).not.toHaveBeenCalled(); }); }); });