UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

578 lines (477 loc) 18.8 kB
/** * ProxyManager Lifecycle Tests * Tests for process spawning, initialization, shutdown, and state management */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ProxyManager } from '../../../../src/proxy/proxy-manager.js'; import { ProxyConfig } from '../../../../src/proxy/proxy-config.js'; import { FakeProxyProcessLauncher } from '../../../implementations/test/fake-process-launcher.js'; import { createMockLogger, createMockFileSystem } from '../../../test-utils/helpers/test-utils.js'; import { ILogger, IFileSystem } from '../../../../src/interfaces/external-dependencies.js'; import { ErrorMessages } from '../../../../src/utils/error-messages.js'; import { DebugLanguage } from '../../../../src/session/models.js'; describe('ProxyManager - Lifecycle', () => { let proxyManager: ProxyManager; let fakeLauncher: FakeProxyProcessLauncher; let mockLogger: ILogger; let mockFileSystem: IFileSystem; // Default test config const defaultConfig: ProxyConfig = { sessionId: 'test-session-123', language: DebugLanguage.MOCK, executablePath: 'python', adapterHost: '127.0.0.1', adapterPort: 5678, logDir: '/tmp/logs', scriptPath: 'test.py', scriptArgs: ['--arg1', 'value1'], stopOnEntry: false, justMyCode: true }; beforeEach(() => { fakeLauncher = new FakeProxyProcessLauncher(); mockLogger = createMockLogger(); mockFileSystem = createMockFileSystem(); proxyManager = new ProxyManager( null, // No adapter needed for these tests fakeLauncher, mockFileSystem, mockLogger ); }); afterEach(() => { vi.useRealTimers(); // Always restore real timers first vi.clearAllMocks(); fakeLauncher.reset(); }); describe('Constructor', () => { it('should create ProxyManager with dependencies', () => { expect(proxyManager).toBeDefined(); expect(proxyManager.isRunning()).toBe(false); expect(proxyManager.getCurrentThreadId()).toBe(null); }); }); describe('start()', () => { it('should start proxy process with correct configuration', async () => { // Prepare a fake process that will simulate successful initialization fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); // Verify process was launched expect(fakeLauncher.launchedProxies).toHaveLength(1); const launchCall = fakeLauncher.launchedProxies[0]; expect(launchCall.sessionId).toBe(defaultConfig.sessionId); // Verify initialization command was sent const fakeProxy = fakeLauncher.getLastLaunchedProxy(); expect(fakeProxy?.sentCommands).toHaveLength(1); const initCommand = fakeProxy?.sentCommands[0] as any; expect(initCommand.cmd).toBe('init'); expect(initCommand.sessionId).toBe(defaultConfig.sessionId); expect(initCommand.executablePath).toBe(defaultConfig.executablePath); expect(initCommand.adapterHost).toBe(defaultConfig.adapterHost); expect(initCommand.adapterPort).toBe(defaultConfig.adapterPort); expect(initCommand.scriptPath).toBe(defaultConfig.scriptPath); expect(initCommand.scriptArgs).toEqual(defaultConfig.scriptArgs); // Verify state expect(proxyManager.isRunning()).toBe(true); }); it('should handle dry run mode', async () => { const dryRunConfig = { ...defaultConfig, dryRunSpawn: true }; fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: dryRunConfig.sessionId, status: 'dry_run_complete', command: 'python', script: dryRunConfig.scriptPath }); // Simulate normal exit for dry run proxy.simulateExit(0); }, 50); }); await proxyManager.start(dryRunConfig); const fakeProxy = fakeLauncher.getLastLaunchedProxy(); const initCommand = fakeProxy?.sentCommands[0] as any; expect(initCommand.dryRunSpawn).toBe(true); }); it('should reject if proxy already running', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); await expect(proxyManager.start(defaultConfig)) .rejects.toThrow('Proxy already running'); }); it('should handle process spawn failure', async () => { // Mock file system to simulate proxy script not found vi.mocked(mockFileSystem.pathExists).mockResolvedValue(false); await expect(proxyManager.start(defaultConfig)) .rejects.toThrow('Bootstrap worker script not found'); }); it('should handle initialization timeout', async () => { // Setup fake timers before any async operations vi.useFakeTimers(); try { // Start the async operation that will timeout const startPromise = proxyManager.start(defaultConfig); // Immediately attach rejection expectation before advancing timers const expectPromise = expect(startPromise).rejects.toThrow(ErrorMessages.proxyInitTimeout(30)); // Advance timers using the async method await vi.advanceTimersByTimeAsync(30001); // Now await the expectation await expectPromise; } finally { // Always restore real timers vi.useRealTimers(); } }); it('should handle process exit during initialization', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateExit(1, 'SIGTERM'); }, 50); }); await expect(proxyManager.start(defaultConfig)) .rejects.toThrow('Proxy exited during initialization. Code: 1, Signal: SIGTERM'); }); it('should handle error during initialization', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateProcessError(new Error('Failed to connect to adapter')); }, 50); }); await expect(proxyManager.start(defaultConfig)) .rejects.toThrow('Failed to connect to adapter'); }); it('should set up initial breakpoints if provided', async () => { const configWithBreakpoints = { ...defaultConfig, initialBreakpoints: [ { file: 'test.py', line: 10 }, { file: 'test.py', line: 20, condition: 'x > 5' } ] }; fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: configWithBreakpoints.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(configWithBreakpoints); const fakeProxy = fakeLauncher.getLastLaunchedProxy(); const initCommand = fakeProxy?.sentCommands[0] as any; expect(initCommand.initialBreakpoints).toEqual(configWithBreakpoints.initialBreakpoints); }); }); describe('stop()', () => { beforeEach(async () => { // Start proxy first fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); }); it('should stop running proxy gracefully', async () => { const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; // Simulate graceful exit when terminate command is received fakeProxy.on('message', (msg: string) => { const parsed = JSON.parse(msg); if (parsed.cmd === 'terminate') { setTimeout(() => fakeProxy.simulateExit(0), 50); } }); await proxyManager.stop(); // Verify terminate command was sent const commands = fakeProxy.sentCommands; const terminateCommand = commands.find((cmd: any) => cmd.cmd === 'terminate'); expect(terminateCommand).toBeDefined(); // Verify state is cleaned up expect(proxyManager.isRunning()).toBe(false); expect(proxyManager.getCurrentThreadId()).toBe(null); }); it('should force kill proxy after timeout', async () => { vi.useFakeTimers(); const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; const killSpy = vi.spyOn(fakeProxy, 'kill'); // Don't respond to terminate command const stopPromise = proxyManager.stop(); // Fast-forward past graceful shutdown timeout vi.advanceTimersByTime(5001); await stopPromise; expect(killSpy).toHaveBeenCalledWith('SIGKILL'); }); it('should handle stop when proxy not running', async () => { // Stop it first const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; fakeProxy.simulateExit(0); await proxyManager.stop(); // Try to stop again await expect(proxyManager.stop()).resolves.toBeUndefined(); }); it('should handle error sending terminate command', async () => { const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; // Make sendCommand throw fakeProxy.sendCommand = vi.fn().mockImplementation(() => { throw new Error('IPC channel closed'); }); // Should still complete stop await expect(proxyManager.stop()).resolves.toBeUndefined(); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining('Error sending terminate command'), expect.any(Error) ); }); }); describe('isRunning()', () => { it('should return false when not started', () => { expect(proxyManager.isRunning()).toBe(false); }); it('should return true when proxy is running', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); expect(proxyManager.isRunning()).toBe(true); }); it('should return false after proxy exits', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); expect(proxyManager.isRunning()).toBe(true); const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; fakeProxy.simulateExit(0); expect(proxyManager.isRunning()).toBe(false); }); }); describe('getCurrentThreadId()', () => { it('should return null when not started', () => { expect(proxyManager.getCurrentThreadId()).toBe(null); }); it('should return thread ID after stopped event', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; // Simulate stopped event fakeProxy.simulateMessage({ type: 'dapEvent', sessionId: defaultConfig.sessionId, event: 'stopped', body: { threadId: 42, reason: 'breakpoint' } }); expect(proxyManager.getCurrentThreadId()).toBe(42); }); it('should return null after stop', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); setTimeout(() => { proxy.simulateMessage({ type: 'dapEvent', sessionId: defaultConfig.sessionId, event: 'stopped', body: { threadId: 42, reason: 'breakpoint' } }); }, 100); }, 50); }); await proxyManager.start(defaultConfig); // Wait for thread ID to be set await new Promise(resolve => setTimeout(resolve, 200)); expect(proxyManager.getCurrentThreadId()).toBe(42); // Stop and verify thread ID is cleared const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; fakeProxy.simulateExit(0); await proxyManager.stop(); expect(proxyManager.getCurrentThreadId()).toBe(null); }); }); describe('Process Exit Handling', () => { it('should emit exit event when proxy process exits', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); const exitPromise = new Promise<{ code: number; signal?: string }>((resolve) => { proxyManager.once('exit', (code, signal) => resolve({ code, signal })); }); const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; fakeProxy.simulateExit(0, 'SIGTERM'); const result = await exitPromise; expect(result.code).toBe(0); expect(result.signal).toBe('SIGTERM'); expect(proxyManager.isRunning()).toBe(false); }); it('should clean up pending DAP requests on exit', async () => { fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); // Start a DAP request that won't complete const dapPromise = proxyManager.sendDapRequest('stackTrace', { threadId: 1 }); // Simulate process exit const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; fakeProxy.simulateExit(1); await expect(dapPromise).rejects.toThrow('Proxy exited'); }); }); describe('Status Message Handling', () => { it('should handle IPC test message', async () => { let ipcTestReceived = false; fakeLauncher.prepareProxy((proxy) => { // Override kill to track it was called but don't actually exit const originalKill = proxy.kill.bind(proxy); proxy.kill = vi.fn((signal) => { ipcTestReceived = true; // Don't actually kill to avoid rejecting the promise return true; }); setTimeout(() => { // Send IPC test message proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'proxy_minimal_ran_ipc_test', message: 'IPC test successful' }); // Then send initialization to complete start setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }, 50); }); await proxyManager.start(defaultConfig); // Verify IPC test was received and kill was called expect(ipcTestReceived).toBe(true); const fakeProxy = fakeLauncher.getLastLaunchedProxy()!; expect(fakeProxy.kill).toHaveBeenCalled(); }); it('should emit dry-run-complete event', async () => { const config = { ...defaultConfig, dryRunSpawn: true }; fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: config.sessionId, status: 'dry_run_complete', command: 'python -m debugpy', script: config.scriptPath }); }, 50); }); const dryRunPromise = new Promise<{ command: string; script: string }>((resolve) => { proxyManager.once('dry-run-complete', (command, script) => resolve({ command, script })); }); await proxyManager.start(config); const result = await dryRunPromise; expect(result.command).toBe('python -m debugpy'); expect(result.script).toBe(config.scriptPath); }); it('should emit adapter-configured event', async () => { const adapterConfiguredPromise = new Promise<void>((resolve) => { proxyManager.once('adapter-configured', resolve); }); fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); await adapterConfiguredPromise; // Verify adapter-configured was emitted expect.assertions(0); // Just verifying the promise resolved }); }); describe('Multiple Initialization', () => { it('should only emit initialized once', async () => { let initCount = 0; proxyManager.on('initialized', () => initCount++); fakeLauncher.prepareProxy((proxy) => { setTimeout(() => { // Send multiple adapter_configured messages proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); proxy.simulateMessage({ type: 'status', sessionId: defaultConfig.sessionId, status: 'adapter_configured_and_launched' }); }, 50); }); await proxyManager.start(defaultConfig); // Give time for both messages to be processed await new Promise(resolve => setTimeout(resolve, 100)); expect(initCount).toBe(1); }); }); });