UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

277 lines (222 loc) 9.15 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { EventEmitter } from 'events'; import { ProxyProcessLauncherImpl, ProcessLauncherImpl } from '../../../src/implementations/process-launcher-impl'; import { ProcessManagerImpl } from '../../../src/implementations/process-manager-impl'; import type { IChildProcess } from '../../../src/interfaces/external-dependencies'; import type { IProxyProcess } from '../../../src/interfaces/process-interfaces'; // Mock child process type interface MockChildProcess extends EventEmitter, IChildProcess { pid?: number; stdin: NodeJS.WritableStream | null; stdout: NodeJS.ReadableStream | null; stderr: NodeJS.ReadableStream | null; killed: boolean; exitCode: number | null; signalCode: string | null; kill: (signal?: string) => boolean; send: (message: any) => boolean; } // Test helper for creating mock processes function createMockProcess(pid = 12345): MockChildProcess { const proc = new EventEmitter() as MockChildProcess; proc.pid = pid; proc.killed = false; proc.exitCode = null; proc.signalCode = null; proc.kill = vi.fn().mockImplementation((signal?: string) => { if (proc.killed) { return false; } proc.killed = true; // Always emit exit event process.nextTick(() => proc.emit('exit', 0, signal || 'SIGTERM')); return true; }); proc.send = vi.fn().mockReturnValue(true); proc.stdin = new EventEmitter() as any; proc.stdout = new EventEmitter() as any; proc.stderr = new EventEmitter() as any; return proc; } describe('ProxyProcessAdapter - Prerequisites (Promise Lifecycle)', () => { let proxyLauncher: ProxyProcessLauncherImpl; let processLauncher: ProcessLauncherImpl; let processManager: ProcessManagerImpl; let mockChildProcess: MockChildProcess; let unhandledRejections: any[] = []; let unhandledRejectionHandler: (reason: any) => void; beforeEach(() => { // Don't use fake timers as they conflict with process.nextTick // Track unhandled rejections unhandledRejections = []; unhandledRejectionHandler = (reason: any) => { unhandledRejections.push(reason); }; process.on('unhandledRejection', unhandledRejectionHandler); mockChildProcess = createMockProcess(); processManager = new ProcessManagerImpl(); vi.spyOn(processManager, 'spawn').mockReturnValue(mockChildProcess); processLauncher = new ProcessLauncherImpl(processManager); proxyLauncher = new ProxyProcessLauncherImpl(processLauncher); }); afterEach(async () => { // Remove unhandled rejection listener process.removeListener('unhandledRejection', unhandledRejectionHandler); // Check for unhandled rejections expect(unhandledRejections).toHaveLength(0); vi.useRealTimers(); vi.clearAllMocks(); }); // Test 1: Process can be killed without initialization it('should handle process termination without initialization request', async () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); // Kill process immediately - no initialization requested mockChildProcess.kill(); mockChildProcess.emit('exit', 0); // Wait to ensure no unhandled rejections occur await new Promise(resolve => setTimeout(resolve, 100)); // This test should FAIL if unhandled rejections occur expect(unhandledRejections).toHaveLength(0); }); // Test 2: Lazy initialization it('should not create initialization promise until requested', () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); // Access internal state using type assertion const internalAdapter = adapter as any; // Verify no promise exists yet // This should FAIL if implementation eagerly creates promises expect(internalAdapter.initializationPromise).toBeUndefined(); // Now request initialization adapter.waitForInitialization(); expect(internalAdapter.initializationPromise).toBeDefined(); }); // Test 3: Proper rejection on early exit it('should properly reject initialization promise when process exits', async () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); const initPromise = adapter.waitForInitialization(); // Kill process before initialization completes mockChildProcess.emit('exit', 1); // Should FAIL with assertion, not ERROR with unhandled rejection await expect(initPromise).rejects.toThrow('Proxy process exited before initialization'); // Verify no unhandled rejections expect(unhandledRejections).toHaveLength(0); }); // Test 4: Multiple initialization requests it('should handle multiple initialization requests correctly', async () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); const promise1 = adapter.waitForInitialization(); const promise2 = adapter.waitForInitialization(); // Should return same promise (or both should work) // Implementation might create new promises, but both should resolve // Complete initialization mockChildProcess.emit('message', { type: 'status', status: 'adapter_configured_and_launched' }); // Both should resolve successfully await Promise.all([promise1, promise2]); expect(unhandledRejections).toHaveLength(0); }); // Test 5: Mimics the exact scenario causing 8 unhandled rejections it('should not cause unhandled rejections in typical test cleanup', async () => { // This mimics what the 8 failing tests do const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); // Don't call waitForInitialization() - just like the failing tests // Simulate afterEach cleanup mockChildProcess.kill(); mockChildProcess.emit('exit', 0); // Should complete without unhandled rejections await new Promise(resolve => setTimeout(resolve, 100)); // This assertion should FAIL if the bug exists expect(unhandledRejections).toHaveLength(0); }); // Test 6: Exit before initialization with multiple adapters it('should handle multiple adapters being killed without initialization', async () => { const adapters: IProxyProcess[] = []; // Create multiple adapters (simulating multiple tests) for (let i = 0; i < 3; i++) { const mockProc = createMockProcess(12345 + i); vi.spyOn(processManager, 'spawn').mockReturnValueOnce(mockProc); const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', `session-${i}`, { DEBUG: 'true' } ); adapters.push(adapter); // Kill immediately without initialization mockProc.kill(); mockProc.emit('exit', 0); } // Wait for all potential rejections await new Promise(resolve => setTimeout(resolve, 200)); // Should have no unhandled rejections expect(unhandledRejections).toHaveLength(0); }); // Test 7: Process error during initialization it('should handle process errors during initialization gracefully', async () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); // Add error handler to prevent Node.js from throwing const errorHandler = vi.fn(); adapter.on('error', errorHandler); // Request initialization const initPromise = adapter.waitForInitialization(); // Wait for next tick to ensure error handlers are set up await new Promise(resolve => process.nextTick(resolve)); // Emit error then exit const error = new Error('Process crashed'); mockChildProcess.emit('error', error); mockChildProcess.emit('exit', 1); // Should reject the promise properly await expect(initPromise).rejects.toThrow('Proxy process exited before initialization'); // Error should have been emitted expect(errorHandler).toHaveBeenCalledWith(error); // No unhandled rejections expect(unhandledRejections).toHaveLength(0); }); // Test 8: Initialization promise should be cleared after resolution it('should clear initialization promise after successful initialization', async () => { const adapter = proxyLauncher.launchProxy( '/path/to/proxy.js', 'session-123', { DEBUG: 'true' } ); const initPromise = adapter.waitForInitialization(); // Complete initialization mockChildProcess.emit('message', { type: 'status', status: 'adapter_configured_and_launched' }); await initPromise; // Now kill the process - should not cause unhandled rejection mockChildProcess.emit('exit', 0); await new Promise(resolve => setTimeout(resolve, 100)); expect(unhandledRejections).toHaveLength(0); }); });