UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

771 lines (629 loc) 25.5 kB
import { describe, it, expect, vi, beforeEach, afterEach, MockInstance } from 'vitest'; import { DapConnectionManager } from '../../../src/proxy/dap-proxy-connection-manager.js'; import type { IDapClient, IDapClientFactory, ILogger } from '../../../src/proxy/dap-proxy-interfaces.js'; import { DebugProtocol } from '@vscode/debugprotocol'; describe('DapConnectionManager', () => { let mockDapClient: { connect: MockInstance; disconnect: MockInstance; shutdown: MockInstance; sendRequest: MockInstance; on: MockInstance; off: MockInstance; once: MockInstance; removeAllListeners: MockInstance; }; let mockDapClientFactory: IDapClientFactory; let mockLogger: ILogger; let connectionManager: DapConnectionManager; // Test helpers const waitForRetries = async (count: number) => { for (let i = 0; i < count; i++) { await vi.advanceTimersByTimeAsync(200); // CONNECT_RETRY_INTERVAL await Promise.resolve(); // Let promises settle } }; const expectDisconnectCleanup = () => { expect(mockLogger.info).toHaveBeenCalledWith( expect.stringContaining('[ConnectionManager] Client disconnected') ); }; const errorScenarios = [ { error: new Error('ECONNREFUSED'), description: 'connection refused' }, { error: new Error('ETIMEDOUT'), description: 'timeout' }, { error: new Error('ENOTFOUND'), description: 'host not found' }, { error: new Error('Unknown error'), description: 'unknown error' } ]; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockDapClient = { connect: vi.fn(), disconnect: vi.fn(), shutdown: vi.fn().mockImplementation((reason?: string) => { // Mock implementation that mimics the real shutdown behavior // In a real implementation, this would reject pending requests }), sendRequest: vi.fn(), on: vi.fn(), off: vi.fn(), once: vi.fn(), removeAllListeners: vi.fn() }; mockDapClientFactory = { create: vi.fn().mockReturnValue(mockDapClient) }; mockLogger = { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }; connectionManager = new DapConnectionManager(mockDapClientFactory, mockLogger); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); }); describe('connectWithRetry', () => { it('should connect successfully on first attempt', async () => { mockDapClient.connect.mockResolvedValue(undefined); const resultPromise = connectionManager.connectWithRetry('localhost', 5678); // Wait for initial delay await vi.advanceTimersByTimeAsync(500); // INITIAL_CONNECT_DELAY const result = await resultPromise; expect(mockDapClientFactory.create).toHaveBeenCalledWith('localhost', 5678); expect(mockDapClient.connect).toHaveBeenCalledTimes(1); expect(mockDapClient.on).toHaveBeenCalledWith('error', expect.any(Function)); expect(mockDapClient.off).toHaveBeenCalledWith('error', expect.any(Function)); expect(result).toBe(mockDapClient); }); it('should retry on connection failure', async () => { mockDapClient.connect .mockRejectedValueOnce(new Error('ECONNREFUSED')) .mockRejectedValueOnce(new Error('ECONNREFUSED')) .mockResolvedValue(undefined); const resultPromise = connectionManager.connectWithRetry('localhost', 5678); // Wait for initial delay await vi.advanceTimersByTimeAsync(500); // Wait for 2 retries await waitForRetries(2); const result = await resultPromise; expect(mockDapClient.connect).toHaveBeenCalledTimes(3); expect(mockLogger.warn).toHaveBeenCalledTimes(2); expect(result).toBe(mockDapClient); }); it('should fail after maximum retry attempts', async () => { mockDapClient.connect.mockRejectedValue(new Error('ECONNREFUSED')); // Create expectation immediately to attach rejection handler const expectation = expect( connectionManager.connectWithRetry('localhost', 5678) ).rejects.toThrow('Failed to connect DAP client: ECONNREFUSED'); // Wait for initial delay await vi.advanceTimersByTimeAsync(500); // Wait for all 60 retries await waitForRetries(60); // Await the expectation await expectation; expect(mockDapClient.connect).toHaveBeenCalledTimes(60); expect(mockDapClient.off).toHaveBeenCalledWith('error', expect.any(Function)); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining('Failed to connect DAP client after 60 attempts') ); }); it('should handle temporary error events during connection', async () => { let tempErrorHandler: ((error: Error) => void) | undefined; mockDapClient.on.mockImplementation((event: string, handler: (error: Error) => void) => { if (event === 'error') { tempErrorHandler = handler; } }); mockDapClient.connect.mockResolvedValue(undefined); const resultPromise = connectionManager.connectWithRetry('localhost', 5678); // Wait for initial delay to ensure handler is set await vi.advanceTimersByTimeAsync(500); // Emit error during connection phase if (tempErrorHandler) { const testError = new Error('Connection error'); tempErrorHandler(testError); } await resultPromise; expect(mockLogger.debug).toHaveBeenCalledWith( expect.stringContaining('[ConnectionManager] DAP client emitted \'error\' during connection phase (expected for retries): Connection error') ); }); it.each(errorScenarios)('should handle $description error', async ({ error }) => { mockDapClient.connect .mockRejectedValueOnce(error) .mockResolvedValue(undefined); const resultPromise = connectionManager.connectWithRetry('localhost', 5678); await vi.advanceTimersByTimeAsync(500); await waitForRetries(1); const result = await resultPromise; expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining(`DAP client connect attempt 1 failed: ${error.message}`) ); expect(result).toBe(mockDapClient); }); it('should test intermediate retry counts', async () => { let connectAttempts = 0; mockDapClient.connect.mockImplementation(() => { connectAttempts++; if (connectAttempts < 10) { return Promise.reject(new Error('ECONNREFUSED')); } return Promise.resolve(); }); const resultPromise = connectionManager.connectWithRetry('localhost', 5678); await vi.advanceTimersByTimeAsync(500); // Test after 5 retries await waitForRetries(5); expect(mockDapClient.connect).toHaveBeenCalledTimes(6); // initial + 5 retries // Continue to success await waitForRetries(4); await resultPromise; expect(mockDapClient.connect).toHaveBeenCalledTimes(10); }); it('should properly clean up error handler on exception during connect', async () => { const unexpectedError = new Error('Unexpected error'); mockDapClient.connect.mockImplementation(() => { throw unexpectedError; }); // Create expectation immediately to attach rejection handler const expectation = expect( connectionManager.connectWithRetry('localhost', 5678) ).rejects.toThrow('Failed to connect DAP client'); await vi.advanceTimersByTimeAsync(500); await waitForRetries(60); // Await the expectation await expectation; // Verify error handler was removed expect(mockDapClient.off).toHaveBeenCalledWith('error', expect.any(Function)); }); }); describe('initializeSession', () => { it('should send initialize request with correct arguments', async () => { mockDapClient.sendRequest.mockResolvedValue({ success: true }); await connectionManager.initializeSession(mockDapClient as any, 'test-session-123'); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('initialize', { clientID: 'mcp-proxy-test-session-123', clientName: 'MCP Debug Proxy', adapterID: 'python', pathFormat: 'path', linesStartAt1: true, columnsStartAt1: true, supportsVariableType: true, supportsRunInTerminalRequest: false, locale: 'en-US' }); }); it('should handle initialize request failure', async () => { const error = new Error('Initialize failed'); mockDapClient.sendRequest.mockRejectedValue(error); await expect( connectionManager.initializeSession(mockDapClient as any, 'test-session') ).rejects.toThrow('Initialize failed'); }); }); describe('setupEventHandlers', () => { it('should set up all provided handlers', () => { const handlers = { onInitialized: vi.fn(), onOutput: vi.fn(), onStopped: vi.fn(), onContinued: vi.fn(), onThread: vi.fn(), onExited: vi.fn(), onTerminated: vi.fn(), onError: vi.fn(), onClose: vi.fn() }; connectionManager.setupEventHandlers(mockDapClient as any, handlers); expect(mockDapClient.on).toHaveBeenCalledWith('initialized', handlers.onInitialized); expect(mockDapClient.on).toHaveBeenCalledWith('output', handlers.onOutput); expect(mockDapClient.on).toHaveBeenCalledWith('stopped', handlers.onStopped); expect(mockDapClient.on).toHaveBeenCalledWith('continued', handlers.onContinued); expect(mockDapClient.on).toHaveBeenCalledWith('thread', handlers.onThread); expect(mockDapClient.on).toHaveBeenCalledWith('exited', handlers.onExited); expect(mockDapClient.on).toHaveBeenCalledWith('terminated', handlers.onTerminated); expect(mockDapClient.on).toHaveBeenCalledWith('error', handlers.onError); expect(mockDapClient.on).toHaveBeenCalledWith('close', handlers.onClose); }); it('should only set up provided handlers', () => { const handlers = { onInitialized: vi.fn(), onStopped: vi.fn() }; connectionManager.setupEventHandlers(mockDapClient as any, handlers); expect(mockDapClient.on).toHaveBeenCalledTimes(2); expect(mockDapClient.on).toHaveBeenCalledWith('initialized', handlers.onInitialized); expect(mockDapClient.on).toHaveBeenCalledWith('stopped', handlers.onStopped); }); it('should handle empty handlers object', () => { connectionManager.setupEventHandlers(mockDapClient as any, {}); expect(mockDapClient.on).not.toHaveBeenCalled(); expect(mockLogger.info).toHaveBeenCalledWith('[ConnectionManager] DAP event handlers set up'); }); }); describe('disconnect', () => { it('should handle null client gracefully', async () => { await connectionManager.disconnect(null); expect(mockLogger.info).toHaveBeenCalledWith( '[ConnectionManager] No active DAP client to disconnect.' ); expect(mockDapClient.sendRequest).not.toHaveBeenCalled(); }); it('should disconnect with terminateDebuggee true by default', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); await connectionManager.disconnect(mockDapClient as any); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('disconnect', { terminateDebuggee: true }); expect(mockDapClient.disconnect).toHaveBeenCalled(); expectDisconnectCleanup(); }); it('should disconnect without terminating debuggee when specified', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); await connectionManager.disconnect(mockDapClient as any, false); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('disconnect', { terminateDebuggee: false }); }); it('should handle disconnect request timeout', async () => { mockDapClient.sendRequest.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 2000)) ); const disconnectPromise = connectionManager.disconnect(mockDapClient as any); // Advance past timeout await vi.advanceTimersByTimeAsync(1100); await disconnectPromise; expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Error or timeout during DAP "disconnect" request: DAP disconnect request timed out after 1000ms') ); expect(mockDapClient.disconnect).toHaveBeenCalled(); }); it('should handle disconnect request error', async () => { const error = new Error('Disconnect failed'); mockDapClient.sendRequest.mockRejectedValue(error); await connectionManager.disconnect(mockDapClient as any); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Error or timeout during DAP "disconnect" request: Disconnect failed') ); expect(mockDapClient.disconnect).toHaveBeenCalled(); }); it('should handle error during client.disconnect()', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); mockDapClient.disconnect.mockImplementation(() => { throw new Error('Client disconnect error'); }); await connectionManager.disconnect(mockDapClient as any); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining('Error calling client.disconnect(): Client disconnect error'), expect.any(Error) ); }); it('should handle both disconnect request and client.disconnect errors', async () => { mockDapClient.sendRequest.mockRejectedValue(new Error('Request error')); mockDapClient.disconnect.mockImplementation(() => { throw new Error('Disconnect error'); }); await connectionManager.disconnect(mockDapClient as any); expect(mockLogger.warn).toHaveBeenCalledWith( expect.stringContaining('Request error') ); expect(mockLogger.error).toHaveBeenCalledWith( expect.stringContaining('Disconnect error'), expect.any(Error) ); }); it('should test race condition - disconnect completes before timeout', async () => { mockDapClient.sendRequest.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(undefined), 500)) ); const disconnectPromise = connectionManager.disconnect(mockDapClient as any); // Advance time but less than timeout await vi.advanceTimersByTimeAsync(600); await disconnectPromise; expect(mockLogger.info).toHaveBeenCalledWith( '[ConnectionManager] DAP "disconnect" request completed.' ); expect(mockLogger.warn).not.toHaveBeenCalledWith( expect.stringContaining('timeout') ); }); }); describe('sendLaunchRequest', () => { const scriptPath = '/path/to/script.py'; it('should send launch request with default arguments', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); await connectionManager.sendLaunchRequest(mockDapClient as any, scriptPath); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('launch', { program: scriptPath, stopOnEntry: true, noDebug: false, args: [], console: 'internalConsole', justMyCode: true }); }); it('should send launch request with custom arguments', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); await connectionManager.sendLaunchRequest( mockDapClient as any, scriptPath, ['--arg1', 'value1'], false, false ); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('launch', { program: scriptPath, stopOnEntry: false, noDebug: false, args: ['--arg1', 'value1'], console: 'internalConsole', justMyCode: false }); }); it('should handle launch request failure', async () => { const error = new Error('Launch failed'); mockDapClient.sendRequest.mockRejectedValue(error); await expect( connectionManager.sendLaunchRequest(mockDapClient as any, scriptPath) ).rejects.toThrow('Launch failed'); }); }); describe('setBreakpoints', () => { const sourcePath = '/path/to/source.py'; it('should set single breakpoint', async () => { const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [{ verified: true, line: 10 }] } }; mockDapClient.sendRequest.mockResolvedValue(response); const result = await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, [{ line: 10 }] ); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('setBreakpoints', { source: { path: sourcePath }, breakpoints: [{ line: 10 }] }); expect(result).toBe(response); }); it('should set multiple breakpoints', async () => { const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [ { verified: true, line: 10 }, { verified: true, line: 20 }, { verified: true, line: 30 } ] } }; mockDapClient.sendRequest.mockResolvedValue(response); const breakpoints = [ { line: 10 }, { line: 20 }, { line: 30 } ]; const result = await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, breakpoints ); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('setBreakpoints', { source: { path: sourcePath }, breakpoints: [ { line: 10 }, { line: 20 }, { line: 30 } ] }); expect(result.body.breakpoints).toHaveLength(3); }); it('should set breakpoints with conditions', async () => { const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [ { verified: true, line: 10 }, { verified: true, line: 20 } ] } }; mockDapClient.sendRequest.mockResolvedValue(response); const breakpoints = [ { line: 10, condition: 'x > 5' }, { line: 20, condition: 'y == "test"' } ]; await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, breakpoints ); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('setBreakpoints', { source: { path: sourcePath }, breakpoints: [ { line: 10, condition: 'x > 5' }, { line: 20, condition: 'y == "test"' } ] }); }); it('should handle empty breakpoints array', async () => { const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [] } }; mockDapClient.sendRequest.mockResolvedValue(response); const result = await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, [] ); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('setBreakpoints', { source: { path: sourcePath }, breakpoints: [] }); expect(result.body.breakpoints).toHaveLength(0); }); it('should handle invalid breakpoint data', async () => { const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [ { verified: false, line: -1, message: 'Invalid line number' } ] } }; mockDapClient.sendRequest.mockResolvedValue(response); const result = await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, [{ line: -1 }] ); expect(result.body.breakpoints[0].verified).toBe(false); expect(result.body.breakpoints[0].message).toBe('Invalid line number'); }); it('should handle very large breakpoint arrays', async () => { const largeBreakpointsCount = 100; const breakpoints = Array.from({ length: largeBreakpointsCount }, (_, i) => ({ line: i + 1 })); const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: breakpoints.map(bp => ({ verified: true, line: bp.line })) } }; mockDapClient.sendRequest.mockResolvedValue(response); const result = await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, breakpoints ); expect(mockLogger.info).toHaveBeenCalledWith( `[ConnectionManager] Setting ${largeBreakpointsCount} breakpoint(s) for ${sourcePath}` ); expect(result.body.breakpoints).toHaveLength(largeBreakpointsCount); }); it('should handle duplicate breakpoints in array', async () => { const breakpoints = [ { line: 10 }, { line: 10 }, // duplicate { line: 20 }, { line: 10 } // another duplicate ]; const response: DebugProtocol.SetBreakpointsResponse = { seq: 1, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: breakpoints.map(bp => ({ verified: true, line: bp.line })) } }; mockDapClient.sendRequest.mockResolvedValue(response); await connectionManager.setBreakpoints( mockDapClient as any, sourcePath, breakpoints ); // Verify all breakpoints are sent, even duplicates const sentBreakpoints = mockDapClient.sendRequest.mock.calls[0][1].breakpoints; expect(sentBreakpoints).toHaveLength(4); }); it('should handle setBreakpoints request failure', async () => { const error = new Error('Failed to set breakpoints'); mockDapClient.sendRequest.mockRejectedValue(error); await expect( connectionManager.setBreakpoints( mockDapClient as any, sourcePath, [{ line: 10 }] ) ).rejects.toThrow('Failed to set breakpoints'); }); }); describe('sendConfigurationDone', () => { it('should send configurationDone request', async () => { mockDapClient.sendRequest.mockResolvedValue(undefined); await connectionManager.sendConfigurationDone(mockDapClient as any); expect(mockDapClient.sendRequest).toHaveBeenCalledWith('configurationDone', {}); expect(mockLogger.info).toHaveBeenCalledWith( '[ConnectionManager] "configurationDone" sent.' ); }); it('should handle configurationDone request failure', async () => { const error = new Error('Configuration done failed'); mockDapClient.sendRequest.mockRejectedValue(error); await expect( connectionManager.sendConfigurationDone(mockDapClient as any) ).rejects.toThrow('Configuration done failed'); }); }); describe('State Management and Concurrent Operations', () => { it('should handle concurrent connect attempts', async () => { mockDapClient.connect.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)) ); const promise1 = connectionManager.connectWithRetry('localhost', 5678); const promise2 = connectionManager.connectWithRetry('localhost', 5679); await vi.advanceTimersByTimeAsync(600); const [client1, client2] = await Promise.all([promise1, promise2]); expect(mockDapClientFactory.create).toHaveBeenCalledTimes(2); expect(mockDapClientFactory.create).toHaveBeenCalledWith('localhost', 5678); expect(mockDapClientFactory.create).toHaveBeenCalledWith('localhost', 5679); }); it('should handle rapid disconnect/reconnect cycles', async () => { mockDapClient.connect.mockResolvedValue(undefined); mockDapClient.sendRequest.mockResolvedValue(undefined); // Connect const connectPromise = connectionManager.connectWithRetry('localhost', 5678); await vi.advanceTimersByTimeAsync(500); const client = await connectPromise; // Immediately disconnect await connectionManager.disconnect(client); // Immediately reconnect const reconnectPromise = connectionManager.connectWithRetry('localhost', 5678); await vi.advanceTimersByTimeAsync(500); await reconnectPromise; expect(mockDapClient.disconnect).toHaveBeenCalledTimes(1); expect(mockDapClientFactory.create).toHaveBeenCalledTimes(2); }); }); });