/** * Test Process Manager * * Specialized ProcessManager for testing with additional features * for simulating failures, controlling timing, and verifying behavior. */ import { Errors } from '@omnitron-dev/titan/errors'; import { ProcessManager, ProcessSpawnerFactory } from '@omnitron-dev/titan-pm'; import { ProcessStatus } from '@omnitron-dev/titan-pm'; import type { IProcessOptions, IProcessInfo, ServiceProxy, IProcessManagerConfig } from '@omnitron-dev/titan-pm'; import { createNullLogger } from '@omnitron-dev/titan/module/logger'; import { MockProcessSpawner } from './mock-process-spawner.js'; /** * Test process manager configuration */ export interface ITestProcessManagerConfig extends IProcessManagerConfig { /** Use mock spawner for faster tests */ mock?: boolean; /** Control time for testing */ controlTime?: boolean; /** Record all operations for verification */ recordOperations?: boolean; } /** * Operation record for verification */ export interface IOperationRecord { type: 'spawn' | 'kill' | 'restart' | 'health' | 'metrics'; processId?: string; processClass?: string; timestamp: number; data?: any; } /** * Test Process Manager with additional testing capabilities */ export class TestProcessManager extends ProcessManager { private operations: IOperationRecord[] = []; private simulatedFailures = new Map(); private simulatedMetrics = new Map(); private simulatedHealth = new Map(); private timeOffset = 0; private crashHandlers = new Map void>(); private recoveryHandlers = new Map void>(); constructor(config: ITestProcessManagerConfig = {}) { // Use null logger for tests - silent by default const logger = createNullLogger(); // Register MockProcessSpawner before creating ProcessManager // This ensures the factory can use it when useMockSpawner is true if (config.mock !== false) { ProcessSpawnerFactory.setMockSpawner(MockProcessSpawner); } super(logger, { ...config, testing: { ...config.testing, useMockSpawner: config.mock !== false, }, }); // Setup test event handlers this.setupTestHandlers(); } /** * Setup test-specific event handlers */ private setupTestHandlers(): void { this.on('process:crash', (info: IProcessInfo, error: Error) => { const handler = this.crashHandlers.get(info.id); if (handler) { handler(); } }); this.on('process:ready', (info: IProcessInfo) => { const handler = this.recoveryHandlers.get(info.id); if (handler) { handler(); } }); } /** * Spawn a process with test recording */ override async spawn( ProcessClass: new (...args: any[]) => T, options: IProcessOptions = {} ): Promise> { this.recordOperation('spawn', undefined, ProcessClass.name, options); // Check for simulated failure const failureKey = `spawn:${ProcessClass.name}`; if (this.simulatedFailures.has(failureKey)) { throw this.simulatedFailures.get(failureKey)!; } return super.spawn(ProcessClass, options); } /** * Simulate a process crash */ async simulateCrash(processOrId: ServiceProxy | string): Promise { const processId = typeof processOrId === 'string' ? processOrId : (processOrId as any).__processId; const info = this.getProcess(processId); if (!info) { throw Errors.notFound('Process', processId); } // Emit crash event const error = new Error('Simulated crash for testing'); this.emit('process:crash', info, error); // Kill the actual process if it exists but preserve CRASHED status try { await this.kill(processId, 'SIGKILL'); } catch (_e) { // Ignore errors during simulated crash } // Update status AFTER kill to ensure it stays CRASHED (info as any).status = ProcessStatus.CRASHED; } /** * Wait for a process to recover */ async waitForRecovery(processOrId: ServiceProxy | string, timeout: number = 5000): Promise { const processId = typeof processOrId === 'string' ? processOrId : (processOrId as any).__processId; return new Promise((resolve) => { const timer = setTimeout(() => { this.recoveryHandlers.delete(processId); resolve(false); }, timeout); this.recoveryHandlers.set(processId, () => { clearTimeout(timer); this.recoveryHandlers.delete(processId); resolve(true); }); // Check if already recovered const info = this.getProcess(processId); if (info && info.status === ProcessStatus.RUNNING) { clearTimeout(timer); this.recoveryHandlers.delete(processId); resolve(true); } }); } /** * Simulate metrics for a process */ setMetrics(processId: string, metrics: any): void { this.simulatedMetrics.set(processId, metrics); } /** * Get metrics with simulated values */ override async getMetrics(processId: string): Promise { if (this.simulatedMetrics.has(processId)) { return this.simulatedMetrics.get(processId); } return super.getMetrics(processId); } /** * Simulate health status for a process */ setHealth(processId: string, health: any): void { this.simulatedHealth.set(processId, health); } /** * Get health with simulated values */ override async getHealth(processId: string): Promise { if (this.simulatedHealth.has(processId)) { return this.simulatedHealth.get(processId); } return super.getHealth(processId); } /** * Simulate a failure for next operation */ simulateFailure(operation: string, error: Error): void { this.simulatedFailures.set(operation, error); } /** * Clear simulated failures */ clearFailures(): void { this.simulatedFailures.clear(); } /** * Advance simulated time */ advanceTime(ms: number): void { this.timeOffset += ms; } /** * Reset simulated time */ resetTime(): void { this.timeOffset = 0; } /** * Get current time with offset */ getCurrentTime(): number { return Date.now() + this.timeOffset; } /** * Get operation history */ getOperations(): IOperationRecord[] { return [...this.operations]; } /** * Clear operation history */ clearOperations(): void { this.operations = []; } /** * Verify operation occurred */ verifyOperation(type: string, predicate?: (op: IOperationRecord) => boolean): boolean { return this.operations.some((op) => op.type === type && (!predicate || predicate(op))); } /** * Wait for a specific operation */ async waitForOperation(type: string, timeout: number = 5000): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { const op = this.operations.find((o) => o.type === type); if (op) return op; await new Promise((resolve) => setTimeout(resolve, 100)); } return null; } /** * Record an operation for verification */ private recordOperation(type: any, processId?: string, processClass?: string, data?: any): void { this.operations.push({ type, processId, processClass, timestamp: this.getCurrentTime(), data, }); } /** * Clean up test resources */ async cleanup(): Promise { this.clearFailures(); this.clearOperations(); this.resetTime(); this.simulatedMetrics.clear(); this.simulatedHealth.clear(); this.crashHandlers.clear(); this.recoveryHandlers.clear(); await this.shutdown(); } /** * Create a spy for a process method */ spyOnMethod(proxy: ServiceProxy, methodName: keyof T): any { const original = (proxy as any)[methodName]; const calls: any[] = []; (proxy as any)[methodName] = async (...args: any[]) => { calls.push({ args, timestamp: this.getCurrentTime() }); return original.apply(proxy, args); }; // Return spy-like interface return { calls, mockClear: () => (calls.length = 0), mockRestore: () => ((proxy as any)[methodName] = original), }; } /** * Assert process state */ assertProcessState(processId: string, expectedStatus: ProcessStatus): void { const info = this.getProcess(processId); if (!info) { throw Errors.notFound('Process', processId); } if (info.status !== expectedStatus) { throw Errors.badRequest(`Expected process ${processId} to have status ${expectedStatus}, but got ${info.status}`); } } /** * Get all processes of a specific class */ getProcessesByClass(ProcessClass: new (...args: any[]) => T): IProcessInfo[] { return this.listProcesses().filter((info) => info.name === ProcessClass.name); } /** * Count processes by status */ countByStatus(status: ProcessStatus): number { return this.listProcesses().filter((info) => info.status === status).length; } } /** * Create a test process manager with defaults */ export function createTestProcessManager(config?: ITestProcessManagerConfig): TestProcessManager { return new TestProcessManager({ mock: true, recordOperations: true, ...config, }); }