@debugmcp/mcp-debugger
Version:
Run-time step-through debugging for LLM agents.
441 lines (372 loc) • 12.8 kB
text/typescript
/**
* Unit tests for DapProxyWorker
*/
import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest';
import { ChildProcess } from 'child_process';
import { DapProxyWorker } from '../../../src/proxy/dap-proxy-worker.js';
import {
DapProxyDependencies,
ProxyInitPayload,
DapCommandPayload,
TerminatePayload,
ProxyState,
IDapClient,
ILogger
} from '../../../src/proxy/dap-proxy-interfaces.js';
describe('DapProxyWorker', () => {
let worker: DapProxyWorker;
let mockDependencies: DapProxyDependencies;
let mockLogger: ILogger;
let mockDapClient: IDapClient;
let mockChildProcess: Partial<ChildProcess>;
let messageSendSpy: Mock;
beforeEach(() => {
// Create mock logger
mockLogger = {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn()
};
// Create mock DAP client
mockDapClient = {
connect: vi.fn().mockResolvedValue(undefined),
sendRequest: vi.fn().mockResolvedValue({ body: {} }),
disconnect: vi.fn(),
shutdown: vi.fn().mockImplementation(() => {
// Mock implementation that mimics the real shutdown behavior
// In a real implementation, this would reject pending requests
}),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
removeAllListeners: vi.fn()
};
// Create mock child process
mockChildProcess = {
pid: 12345,
kill: vi.fn().mockReturnValue(true),
killed: false,
on: vi.fn(),
unref: vi.fn()
};
// Create message send spy
messageSendSpy = vi.fn();
// Create mock dependencies
mockDependencies = {
loggerFactory: vi.fn().mockResolvedValue(mockLogger),
fileSystem: {
ensureDir: vi.fn().mockResolvedValue(undefined),
pathExists: vi.fn().mockResolvedValue(true)
},
processSpawner: {
spawn: vi.fn().mockReturnValue(mockChildProcess)
},
dapClientFactory: {
create: vi.fn().mockReturnValue(mockDapClient)
},
messageSender: {
send: messageSendSpy
}
};
worker = new DapProxyWorker(mockDependencies);
});
afterEach(async () => {
// Ensure worker is properly shut down after each test
// This prevents any lingering timers or connections
if (worker.getState() !== ProxyState.TERMINATED) {
await worker.shutdown();
}
// Clear all timers to prevent any lingering timeouts
vi.clearAllTimers();
// Reset all mocks
vi.clearAllMocks();
});
describe('initialization', () => {
it('should start in UNINITIALIZED state', () => {
expect(worker.getState()).toBe(ProxyState.UNINITIALIZED);
});
it('should handle init command successfully', async () => {
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py',
scriptArgs: ['arg1', 'arg2'],
stopOnEntry: true,
justMyCode: false
};
await worker.handleCommand(initPayload);
// Verify logger was created
expect(mockDependencies.loggerFactory).toHaveBeenCalledWith('test-session', '/tmp/logs');
// Verify process was spawned
expect(mockDependencies.processSpawner.spawn).toHaveBeenCalledWith(
'/usr/bin/python3',
['-m', 'debugpy.adapter', '--host', 'localhost', '--port', '5678', '--log-dir', '/tmp/logs'],
expect.any(Object)
);
// Verify DAP client was created and connected
expect(mockDependencies.dapClientFactory.create).toHaveBeenCalledWith('localhost', 5678);
expect(mockDapClient.connect).toHaveBeenCalled();
});
it('should reject init if already initialized', async () => {
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py'
};
// First init should succeed
await worker.handleCommand(initPayload);
// Second init should fail
await worker.handleCommand(initPayload);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'error',
message: expect.stringContaining('Invalid state for init')
})
);
});
// Test removed: Path validation is removed as part of "hands-off" approach
// We let debugpy handle path validation and provide natural error messages
it('should handle dry run mode', async () => {
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py',
dryRunSpawn: true
};
await worker.handleCommand(initPayload);
// Verify the status message was sent
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'status',
status: 'dry_run_complete',
command: expect.stringContaining('python3 -m debugpy.adapter')
})
);
// Verify state is TERMINATED
expect(worker.getState()).toBe(ProxyState.TERMINATED);
});
});
describe('DAP command handling', () => {
beforeEach(async () => {
// Initialize worker first
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py'
};
await worker.handleCommand(initPayload);
// Simulate initialized event to reach CONNECTED state
const onInitialized = (mockDapClient.on as Mock).mock.calls
.find(call => call[0] === 'initialized')?.[1];
if (onInitialized) {
await onInitialized();
}
});
it('should forward DAP commands to client', async () => {
const dapCommand: DapCommandPayload = {
cmd: 'dap',
sessionId: 'test-session',
requestId: 'req-123',
dapCommand: 'continue',
dapArgs: { threadId: 1 }
};
const mockResponse = {
success: true,
body: { allThreadsContinued: true }
};
(mockDapClient.sendRequest as Mock).mockResolvedValue(mockResponse);
await worker.handleCommand(dapCommand);
expect(mockDapClient.sendRequest).toHaveBeenCalledWith('continue', { threadId: 1 });
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapResponse',
requestId: 'req-123',
success: true,
body: { allThreadsContinued: true }
})
);
});
it('should handle DAP command errors', async () => {
const dapCommand: DapCommandPayload = {
cmd: 'dap',
sessionId: 'test-session',
requestId: 'req-456',
dapCommand: 'evaluate',
dapArgs: { expression: 'invalid()' }
};
(mockDapClient.sendRequest as Mock).mockRejectedValue(new Error('Evaluation failed'));
await worker.handleCommand(dapCommand);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapResponse',
requestId: 'req-456',
success: false,
error: 'Evaluation failed'
})
);
});
it('should reject DAP commands before connection', async () => {
// Create fresh worker without initialization
const newWorker = new DapProxyWorker(mockDependencies);
const dapCommand: DapCommandPayload = {
cmd: 'dap',
sessionId: 'test-session',
requestId: 'req-789',
dapCommand: 'continue'
};
await newWorker.handleCommand(dapCommand);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapResponse',
requestId: 'req-789',
success: false,
error: 'DAP client not connected'
})
);
});
});
describe('terminate handling', () => {
beforeEach(async () => {
// Initialize worker first to ensure logger is available
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py'
};
await worker.handleCommand(initPayload);
});
it('should handle terminate command', async () => {
const terminateCommand: TerminatePayload = {
cmd: 'terminate',
sessionId: 'test-session'
};
await worker.handleCommand(terminateCommand);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'status',
status: 'terminated'
})
);
expect(worker.getState()).toBe(ProxyState.TERMINATED);
});
});
describe('event handling', () => {
let eventHandlers: Record<string, (...args: unknown[]) => void>;
beforeEach(async () => {
// Initialize worker
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py'
};
await worker.handleCommand(initPayload);
// Capture event handlers
eventHandlers = {};
(mockDapClient.on as Mock).mock.calls.forEach(call => {
eventHandlers[call[0]] = call[1];
});
});
it('should handle stopped event', () => {
const stoppedBody = {
reason: 'breakpoint',
threadId: 1,
allThreadsStopped: true
};
eventHandlers['stopped'](stoppedBody);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapEvent',
event: 'stopped',
body: stoppedBody
})
);
});
it('should handle output event', () => {
const outputBody = {
category: 'stdout',
output: 'Hello, world!\n'
};
eventHandlers['output'](outputBody);
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapEvent',
event: 'output',
body: outputBody
})
);
});
it('should handle terminated event and shutdown', async () => {
const terminatedBody = { restart: false };
// The handler is not async, so we can't await it.
// It triggers shutdown(), which is async. We need to wait for it to complete.
eventHandlers['terminated'](terminatedBody);
// Give the async shutdown promise time to resolve
await new Promise(resolve => setImmediate(resolve));
expect(messageSendSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'dapEvent',
event: 'terminated',
body: terminatedBody
})
);
// Verify shutdown was called
expect(mockDapClient.shutdown).toHaveBeenCalledWith('worker shutdown');
expect(mockDapClient.disconnect).toHaveBeenCalled();
});
});
describe('shutdown', () => {
it('should clean up resources on shutdown', async () => {
// Initialize worker with all resources
const initPayload: ProxyInitPayload = {
cmd: 'init',
sessionId: 'test-session',
executablePath: '/usr/bin/python3',
adapterHost: 'localhost',
adapterPort: 5678,
logDir: '/tmp/logs',
scriptPath: '/home/user/script.py'
};
await worker.handleCommand(initPayload);
// Call shutdown
await worker.shutdown();
// Verify cleanup
expect(mockDapClient.sendRequest).toHaveBeenCalledWith('disconnect', { terminateDebuggee: true });
expect(mockDapClient.disconnect).toHaveBeenCalled();
expect(mockChildProcess.kill).toHaveBeenCalledWith('SIGTERM');
expect(worker.getState()).toBe(ProxyState.TERMINATED);
});
it('should handle shutdown when already shutting down', async () => {
await worker.shutdown();
const state1 = worker.getState();
await worker.shutdown(); // Second call
const state2 = worker.getState();
expect(state1).toBe(ProxyState.TERMINATED);
expect(state2).toBe(ProxyState.TERMINATED);
});
});
});