From b556c10a6965ef6f5f6353f1a7f79f53c67a95d4 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 30 Jan 2026 14:02:19 +0100 Subject: [PATCH] test(01.1-03): add unit tests for ProcessRegistry and ProcessManager - ProcessRegistry: 15 tests covering register, get, getAll, updateStatus, unregister, getByPid, clear - ProcessManager: 16 tests covering spawn, stop, stopAll, restart, isRunning - Mock execa module to avoid spawning real processes - Test exit handler behavior for both normal exit and crash scenarios --- src/process/manager.test.ts | 310 +++++++++++++++++++++++++++++++++++ src/process/registry.test.ts | 183 +++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 src/process/manager.test.ts create mode 100644 src/process/registry.test.ts diff --git a/src/process/manager.test.ts b/src/process/manager.test.ts new file mode 100644 index 0000000..12755b5 --- /dev/null +++ b/src/process/manager.test.ts @@ -0,0 +1,310 @@ +/** + * 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'; + +// 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 set up exit handler that marks process 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); + }); + }); +}); diff --git a/src/process/registry.test.ts b/src/process/registry.test.ts new file mode 100644 index 0000000..a202868 --- /dev/null +++ b/src/process/registry.test.ts @@ -0,0 +1,183 @@ +/** + * ProcessRegistry Tests + * + * Tests for the in-memory process registry. + * Verifies CRUD operations for process metadata. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ProcessRegistry } from './registry.js'; +import type { ProcessInfo } from './types.js'; + +describe('ProcessRegistry', () => { + let registry: ProcessRegistry; + + // Helper to create a ProcessInfo object + const createProcessInfo = (id: string, overrides: Partial = {}): ProcessInfo => ({ + id, + pid: 12345, + command: 'node', + args: ['server.js'], + startedAt: new Date(), + status: 'running', + ...overrides, + }); + + beforeEach(() => { + registry = new ProcessRegistry(); + }); + + describe('register()', () => { + it('should add process to registry', () => { + const info = createProcessInfo('proc-1'); + registry.register(info); + + expect(registry.size).toBe(1); + expect(registry.get('proc-1')).toBe(info); + }); + + it('should overwrite process with same ID', () => { + const info1 = createProcessInfo('proc-1', { pid: 111 }); + const info2 = createProcessInfo('proc-1', { pid: 222 }); + + registry.register(info1); + registry.register(info2); + + expect(registry.size).toBe(1); + expect(registry.get('proc-1')?.pid).toBe(222); + }); + }); + + describe('get()', () => { + it('should retrieve registered process', () => { + const info = createProcessInfo('proc-1'); + registry.register(info); + + const retrieved = registry.get('proc-1'); + expect(retrieved).toBe(info); + }); + + it('should return undefined for non-existent process', () => { + const result = registry.get('non-existent'); + expect(result).toBeUndefined(); + }); + }); + + describe('getAll()', () => { + it('should return all registered processes', () => { + const info1 = createProcessInfo('proc-1'); + const info2 = createProcessInfo('proc-2'); + const info3 = createProcessInfo('proc-3'); + + registry.register(info1); + registry.register(info2); + registry.register(info3); + + const all = registry.getAll(); + expect(all).toHaveLength(3); + expect(all).toContain(info1); + expect(all).toContain(info2); + expect(all).toContain(info3); + }); + + it('should return empty array when registry is empty', () => { + const all = registry.getAll(); + expect(all).toEqual([]); + }); + }); + + describe('updateStatus()', () => { + it('should change process status correctly', () => { + const info = createProcessInfo('proc-1', { status: 'running' }); + registry.register(info); + + const result = registry.updateStatus('proc-1', 'stopped'); + + expect(result).toBe(true); + expect(registry.get('proc-1')?.status).toBe('stopped'); + }); + + it('should return false for non-existent process', () => { + const result = registry.updateStatus('non-existent', 'stopped'); + expect(result).toBe(false); + }); + + it('should update to crashed status', () => { + const info = createProcessInfo('proc-1', { status: 'running' }); + registry.register(info); + + registry.updateStatus('proc-1', 'crashed'); + + expect(registry.get('proc-1')?.status).toBe('crashed'); + }); + }); + + describe('unregister()', () => { + it('should remove process from registry', () => { + const info = createProcessInfo('proc-1'); + registry.register(info); + + expect(registry.size).toBe(1); + + registry.unregister('proc-1'); + + expect(registry.size).toBe(0); + expect(registry.get('proc-1')).toBeUndefined(); + }); + + it('should do nothing for non-existent process', () => { + registry.register(createProcessInfo('proc-1')); + + registry.unregister('non-existent'); + + expect(registry.size).toBe(1); + }); + }); + + describe('getByPid()', () => { + it('should find process by OS PID', () => { + const info = createProcessInfo('proc-1', { pid: 54321 }); + registry.register(info); + + const found = registry.getByPid(54321); + expect(found).toBe(info); + }); + + it('should return undefined for unknown PID', () => { + registry.register(createProcessInfo('proc-1', { pid: 111 })); + + const found = registry.getByPid(99999); + expect(found).toBeUndefined(); + }); + }); + + describe('clear()', () => { + it('should remove all processes from registry', () => { + registry.register(createProcessInfo('proc-1')); + registry.register(createProcessInfo('proc-2')); + registry.register(createProcessInfo('proc-3')); + + expect(registry.size).toBe(3); + + registry.clear(); + + expect(registry.size).toBe(0); + expect(registry.getAll()).toEqual([]); + }); + }); + + describe('size', () => { + it('should return count of registered processes', () => { + expect(registry.size).toBe(0); + + registry.register(createProcessInfo('proc-1')); + expect(registry.size).toBe(1); + + registry.register(createProcessInfo('proc-2')); + expect(registry.size).toBe(2); + + registry.unregister('proc-1'); + expect(registry.size).toBe(1); + }); + }); +});