UNPKG

@debugmcp/mcp-debugger

Version:

Run-time step-through debugging for LLM agents.

643 lines (512 loc) 19 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import net from 'net'; import { EventEmitter } from 'events'; import { MinimalDapClient } from '../../../src/proxy/minimal-dap.js'; import { DebugProtocol } from '@vscode/debugprotocol'; // Mock the net module vi.mock('net'); // Mock the logger vi.mock('../../../src/utils/logger.js', () => ({ createLogger: () => ({ info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() }) })); describe('MinimalDapClient', () => { let client: MinimalDapClient; let mockSocket: any; // Helper function to create a mock socket const createMockSocket = () => { const socket = new EventEmitter() as any; socket.write = vi.fn().mockReturnValue(true); socket.end = vi.fn((callback?: () => void) => { if (callback) callback(); }); socket.destroy = vi.fn(); socket.destroyed = false; return socket; }; // Helper function to create DAP protocol messages function createDapMessage(content: any): Buffer { const json = JSON.stringify(content); const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`; return Buffer.concat([Buffer.from(header), Buffer.from(json)]); } // Helper to simulate data chunks function splitBuffer(buffer: Buffer, chunkSizes: number[]): Buffer[] { const chunks: Buffer[] = []; let offset = 0; for (const size of chunkSizes) { chunks.push(buffer.slice(offset, offset + size)); offset += size; } if (offset < buffer.length) { chunks.push(buffer.slice(offset)); } return chunks; } beforeEach(() => { vi.clearAllMocks(); mockSocket = createMockSocket(); vi.mocked(net.createConnection).mockImplementation((options: any, callback?: () => void) => { // Simulate async connection if (callback) { setImmediate(callback); } return mockSocket; }); client = new MinimalDapClient('localhost', 5678); }); afterEach(() => { // Shut down the client to clear any pending timers if (client) { client.shutdown(); } vi.restoreAllMocks(); }); describe('Connection Management', () => { it('should connect successfully', async () => { const connectPromise = client.connect(); await connectPromise; expect(net.createConnection).toHaveBeenCalledWith( { host: 'localhost', port: 5678 }, expect.any(Function) ); }); it('should handle connection errors', async () => { const error = new Error('Connection refused'); // Add error handler to prevent unhandled error const errorHandler = vi.fn(); client.on('error', errorHandler); // Setup mock to emit error instead of calling success callback vi.mocked(net.createConnection).mockImplementation((options: any, callback?: () => void) => { // Don't call the success callback // Instead, emit error after the socket is returned and handlers are attached setImmediate(() => { mockSocket.emit('error', error); }); return mockSocket; }); await expect(client.connect()).rejects.toThrow('Connection refused'); expect(errorHandler).toHaveBeenCalledWith(error); // Clean up client.off('error', errorHandler); }); it('should emit close event when socket closes', async () => { await client.connect(); const closeHandler = vi.fn(); client.on('close', closeHandler); mockSocket.emit('close'); expect(closeHandler).toHaveBeenCalled(); }); it('should emit error event on socket error after connection', async () => { await client.connect(); const errorHandler = vi.fn(); client.on('error', errorHandler); const error = new Error('Socket error'); mockSocket.emit('error', error); expect(errorHandler).toHaveBeenCalledWith(error); }); }); describe('Message Parsing', () => { it('should parse a complete DAP message', async () => { await client.connect(); const response: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'initialize', success: true, body: { supportsConfigurationDoneRequest: true } }; const message = createDapMessage(response); mockSocket.emit('data', message); // Message should be processed without errors expect(mockSocket.write).not.toHaveBeenCalled(); // No response expected for incoming data }); it('should handle partial messages across multiple data events', async () => { await client.connect(); const response: DebugProtocol.Response = { seq: 2, type: 'response', request_seq: 1, command: 'setBreakpoints', success: true, body: { breakpoints: [] } }; const message = createDapMessage(response); const chunks = splitBuffer(message, [20, 30, 40]); // Split into 3 chunks // Send chunks for (const chunk of chunks) { mockSocket.emit('data', chunk); } // Message should be processed correctly despite being split }); it('should handle multiple messages in one data event', async () => { await client.connect(); const response1: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'initialize', success: true }; const response2: DebugProtocol.Response = { seq: 2, type: 'response', request_seq: 2, command: 'launch', success: true }; const message1 = createDapMessage(response1); const message2 = createDapMessage(response2); const combined = Buffer.concat([message1, message2]); mockSocket.emit('data', combined); // Both messages should be processed }); it('should handle malformed headers gracefully', async () => { await client.connect(); // Send data with invalid header const invalidData = Buffer.from('Invalid-Header: test\r\n\r\n{"type":"event"}'); mockSocket.emit('data', invalidData); // Should skip the malformed header and continue }); it('should handle invalid JSON gracefully', async () => { await client.connect(); const invalidJson = 'Content-Length: 20\r\n\r\n{invalid json content'; mockSocket.emit('data', Buffer.from(invalidJson)); // Should handle the error without crashing }); it('should handle incomplete message body', async () => { await client.connect(); // Send header but incomplete body const incompleteStart = '{"type":"response"'; const incompleteMessage = `Content-Length: 100\r\n\r\n${incompleteStart}`; mockSocket.emit('data', Buffer.from(incompleteMessage)); // Should wait for more data // Send the rest const restOfMessage = ',"seq":1,"request_seq":1,"command":"test","success":true}'; const fullJson = incompleteStart + restOfMessage; const padding = ' '.repeat(100 - fullJson.length); // Pad to match Content-Length mockSocket.emit('data', Buffer.from(restOfMessage + padding)); }); }); describe('Request/Response Handling', () => { it('should send requests with correct format', async () => { await client.connect(); const args = { source: { path: 'test.py' }, breakpoints: [] }; // Don't await - we're testing the request format, not the response // But we need to handle the promise to avoid unhandled rejection const requestPromise = client.sendRequest('setBreakpoints', args); // Handle the promise but don't await it yet requestPromise.catch(() => {}); // Prevent unhandled rejection expect(mockSocket.write).toHaveBeenCalled(); const writeCall = mockSocket.write.mock.calls[0][0]; // Verify header format expect(writeCall).toMatch(/^Content-Length: \d+\r\n\r\n/); // Extract and verify JSON const jsonStart = writeCall.indexOf('\r\n\r\n') + 4; const json = JSON.parse(writeCall.substring(jsonStart)); expect(json).toMatchObject({ seq: 1, type: 'request', command: 'setBreakpoints', arguments: args }); }); it('should correlate responses with requests', async () => { await client.connect(); // Send request const requestPromise = client.sendRequest('initialize', { clientID: 'test' }); // Simulate response const response: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'initialize', success: true, body: { supportsConfigurationDoneRequest: true } }; mockSocket.emit('data', createDapMessage(response)); const result = await requestPromise; expect(result).toEqual(response); }); it('should handle request failure', async () => { await client.connect(); const requestPromise = client.sendRequest('launch', { program: 'test.py' }); const errorResponse: DebugProtocol.Response = { seq: 2, type: 'response', request_seq: 1, command: 'launch', success: false, message: 'Failed to launch' }; mockSocket.emit('data', createDapMessage(errorResponse)); await expect(requestPromise).rejects.toThrow('Failed to launch'); }); it('should handle concurrent requests', async () => { await client.connect(); // Send multiple requests const request1 = client.sendRequest('threads'); const request2 = client.sendRequest('stackTrace', { threadId: 1 }); const request3 = client.sendRequest('scopes', { frameId: 1 }); // Respond out of order const response2: DebugProtocol.Response = { seq: 2, type: 'response', request_seq: 2, command: 'stackTrace', success: true, body: { stackFrames: [] } }; const response3: DebugProtocol.Response = { seq: 3, type: 'response', request_seq: 3, command: 'scopes', success: true, body: { scopes: [] } }; const response1: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'threads', success: true, body: { threads: [] } }; mockSocket.emit('data', createDapMessage(response2)); mockSocket.emit('data', createDapMessage(response3)); mockSocket.emit('data', createDapMessage(response1)); const [result1, result2, result3] = await Promise.all([request1, request2, request3]); expect(result1.command).toBe('threads'); expect(result2.command).toBe('stackTrace'); expect(result3.command).toBe('scopes'); }); it('should timeout requests after 30 seconds', async () => { await client.connect(); // Spy on setTimeout and immediately trigger 30s timeouts const originalSetTimeout = global.setTimeout; vi.spyOn(global, 'setTimeout').mockImplementation((callback: any, delay: any, ...args: any[]) => { if (delay === 30000) { // Use setImmediate for next tick execution setImmediate(() => callback()); return 123 as any; // fake timer ID } // Use real timers for everything else return originalSetTimeout(callback, delay, ...args); }); try { // Send request that will timeout await expect( client.sendRequest('evaluate', { expression: 'test' }) ).rejects.toThrow("DAP request 'evaluate' (seq 1) timed out"); // Verify that a late response doesn't cause issues // Simulate a late response to ensure it's properly ignored const lateResponse: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'evaluate', success: true, body: { result: 'too late' } }; // This should not throw or cause issues since the request was already rejected mockSocket.emit('data', createDapMessage(lateResponse)); } finally { vi.restoreAllMocks(); } }); it('should handle unknown response sequences', async () => { await client.connect(); const response: DebugProtocol.Response = { seq: 99, type: 'response', request_seq: 999, // Unknown request_seq command: 'unknown', success: true }; // Should not throw, just warn mockSocket.emit('data', createDapMessage(response)); }); it('should reject request if socket is destroyed', async () => { await client.connect(); mockSocket.destroyed = true; await expect(client.sendRequest('test')).rejects.toThrow('Socket not connected or destroyed'); }); }); describe('Event Handling', () => { it('should emit DAP events', async () => { await client.connect(); const outputHandler = vi.fn(); const genericHandler = vi.fn(); client.on('output', outputHandler); client.on('event', genericHandler); const outputEvent: DebugProtocol.OutputEvent = { seq: 1, type: 'event', event: 'output', body: { category: 'console', output: 'Hello, world!\n' } }; mockSocket.emit('data', createDapMessage(outputEvent)); expect(outputHandler).toHaveBeenCalledWith(outputEvent.body); expect(genericHandler).toHaveBeenCalledWith(outputEvent); }); it('should emit multiple event types', async () => { await client.connect(); const stoppedHandler = vi.fn(); const threadHandler = vi.fn(); client.on('stopped', stoppedHandler); client.on('thread', threadHandler); const stoppedEvent: DebugProtocol.StoppedEvent = { seq: 1, type: 'event', event: 'stopped', body: { reason: 'breakpoint', threadId: 1, preserveFocusHint: false, allThreadsStopped: true } }; const threadEvent: DebugProtocol.ThreadEvent = { seq: 2, type: 'event', event: 'thread', body: { reason: 'started', threadId: 1 } }; mockSocket.emit('data', createDapMessage(stoppedEvent)); mockSocket.emit('data', createDapMessage(threadEvent)); expect(stoppedHandler).toHaveBeenCalledWith(stoppedEvent.body); expect(threadHandler).toHaveBeenCalledWith(threadEvent.body); }); }); describe('Disconnection', () => { it('should disconnect gracefully', async () => { await client.connect(); client.disconnect(); expect(mockSocket.end).toHaveBeenCalled(); expect(mockSocket.destroy).toHaveBeenCalled(); }); it('should reject pending requests on disconnect', async () => { await client.connect(); const request1 = client.sendRequest('threads'); const request2 = client.sendRequest('evaluate', { expression: 'test' }); client.disconnect(); await expect(request1).rejects.toThrow('DAP client disconnected'); await expect(request2).rejects.toThrow('DAP client disconnected'); }); it('should handle multiple disconnect calls', async () => { await client.connect(); client.disconnect(); client.disconnect(); // Second call should be idempotent expect(mockSocket.end).toHaveBeenCalledTimes(1); expect(mockSocket.destroy).toHaveBeenCalledTimes(1); }); it('should remove all event listeners on disconnect', async () => { await client.connect(); const handler = vi.fn(); client.on('output', handler); client.on('stopped', handler); client.disconnect(); // Verify no listeners remain expect(client.listenerCount('output')).toBe(0); expect(client.listenerCount('stopped')).toBe(0); }); it('should handle disconnect when socket already destroyed', async () => { await client.connect(); mockSocket.destroyed = true; client.disconnect(); // Should not throw expect(mockSocket.end).not.toHaveBeenCalled(); }); }); describe('Socket Backpressure', () => { it('should handle socket write returning false', async () => { await client.connect(); // Simulate backpressure mockSocket.write.mockReturnValue(false); // Should still accept the request (current implementation doesn't handle backpressure) const promise = client.sendRequest('test'); expect(mockSocket.write).toHaveBeenCalled(); // Simulate response const response: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'test', success: true }; mockSocket.emit('data', createDapMessage(response)); await expect(promise).resolves.toEqual(response); }); }); describe('Large Message Handling', () => { it('should handle large messages split across chunks', async () => { await client.connect(); // Create a large body const largeBody = { data: 'x'.repeat(10000), items: Array(100).fill({ id: 1, name: 'test' }) }; const response: DebugProtocol.Response = { seq: 1, type: 'response', request_seq: 1, command: 'variables', success: true, body: largeBody }; const message = createDapMessage(response); // Split into many small chunks const chunkSize = 100; const chunks: Buffer[] = []; for (let i = 0; i < message.length; i += chunkSize) { chunks.push(message.slice(i, Math.min(i + chunkSize, message.length))); } // Send all chunks for (const chunk of chunks) { mockSocket.emit('data', chunk); } // Message should be processed correctly }); }); describe('Edge Cases', () => { it('should handle empty data events', async () => { await client.connect(); mockSocket.emit('data', Buffer.from('')); // Should not crash }); it('should handle messages with no command in response', async () => { await client.connect(); const malformedResponse = { seq: 1, type: 'response', request_seq: 1, success: true // Missing command field }; mockSocket.emit('data', createDapMessage(malformedResponse)); // Should handle gracefully }); it('should handle unknown message types', async () => { await client.connect(); const unknownMessage = { seq: 1, type: 'unknown', data: 'test' }; mockSocket.emit('data', createDapMessage(unknownMessage)); // Should log warning but not crash }); }); });