Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions tests/core/unit/server/server-redefine-and-attach.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Tests for redefine_classes tool and attach stopOnEntry behavior
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { McpError, ErrorCode as McpErrorCode } from '@modelcontextprotocol/sdk/types.js';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { DebugMcpServer } from '../../../../src/server.js';
Expand Down Expand Up @@ -535,4 +536,74 @@ describe('redefine_classes and attach stopOnEntry tests', () => {
expect(mockSessionManager.attachToProcess).not.toHaveBeenCalled();
});
});

describe('detach_from_process tool dispatch (coverage sprint)', () => {
const callDetach = (args: Record<string, unknown>) =>
callToolHandler({
method: 'tools/call',
params: { name: 'detach_from_process', arguments: args }
});

it('returns the detach result payload on success', async () => {
mockSessionManager.detachFromProcess.mockResolvedValue({
success: true,
state: 'running',
data: { message: 'Detached; target left running' }
});

const result = await callDetach({ sessionId: 'sess-1', terminateProcess: false });
const payload = JSON.parse(result.content[0].text);

expect(mockSessionManager.detachFromProcess).toHaveBeenCalledWith('sess-1', false);
expect(payload).toEqual({
success: true,
state: 'running',
message: 'Detached; target left running',
data: { message: 'Detached; target left running' }
});
});

it('defaults terminateProcess to false and propagates failures', async () => {
mockSessionManager.detachFromProcess.mockResolvedValue({
success: false,
state: 'error',
error: 'No attach session to detach from'
});

const result = await callDetach({ sessionId: 'sess-1' });
const payload = JSON.parse(result.content[0].text);

expect(mockSessionManager.detachFromProcess).toHaveBeenCalledWith('sess-1', false);
expect(payload.success).toBe(false);
expect(payload.message).toBe('No attach session to detach from');
});

it('maps session-state McpErrors to a stopped failure payload', async () => {
mockSessionManager.detachFromProcess.mockRejectedValue(
new McpError(McpErrorCode.InvalidParams, 'Session sess-1 not found')
);

const result = await callDetach({ sessionId: 'sess-1' });
const payload = JSON.parse(result.content[0].text);

expect(payload).toEqual({
success: false,
error: expect.stringContaining('Session sess-1 not found'),
state: 'stopped'
});
});

it('rethrows unrelated errors', async () => {
mockSessionManager.detachFromProcess.mockRejectedValue(
new McpError(McpErrorCode.InternalError, 'adapter exploded mid-flight')
);

await expect(callDetach({ sessionId: 'sess-1' })).rejects.toThrow(/adapter exploded/);
});

it('requires a sessionId', async () => {
await expect(callDetach({})).rejects.toThrow(/sessionId/);
expect(mockSessionManager.detachFromProcess).not.toHaveBeenCalled();
});
});
});
126 changes: 126 additions & 0 deletions tests/core/unit/session/session-manager-evaluate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* evaluateExpression default-frame resolution (coverage sprint).
*
* When no frameId is given, the session manager anchors evaluation to the
* top frame of the current thread's stack — this suite pins the guard rails
* around that resolution (not paused, no thread, no frames, stack errors).
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { SessionManager, SessionManagerConfig } from '../../../../src/session/session-manager.js';
import { DebugLanguage } from '@debugmcp/shared';
import { createMockDependencies } from './session-manager-test-utils.js';

function makeManager(launchArgs: { stopOnEntry: boolean } = { stopOnEntry: true }) {
const dependencies = createMockDependencies();
const config: SessionManagerConfig = {
logDirBase: '/tmp/test-sessions',
defaultDapLaunchArgs: { stopOnEntry: launchArgs.stopOnEntry, justMyCode: true }
};
return { sessionManager: new SessionManager(config, dependencies), dependencies };
}

async function createRunningSession(
sessionManager: SessionManager,
dependencies: ReturnType<typeof createMockDependencies>,
opts: { paused?: boolean } = {}
) {
const session = await sessionManager.createSession({
language: DebugLanguage.MOCK,
executablePath: 'python'
});
await sessionManager.startDebugging(session.id, 'test.py');
await vi.runAllTimersAsync();
if (opts.paused !== false) {
dependencies.mockProxyManager.simulateStopped(1, 'breakpoint');
}
return session;
}

describe('SessionManager.evaluateExpression default-frame resolution', () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
});

it('refuses to evaluate while the session is not paused', async () => {
const { sessionManager, dependencies } = makeManager({ stopOnEntry: false });
const session = await createRunningSession(sessionManager, dependencies, { paused: false });

const result = await sessionManager.evaluateExpression(session.id, '1 + 1');

expect(result.success).toBe(false);
expect(result.error).toContain('not paused');
});

it('fails cleanly when no current thread id is known', async () => {
const { sessionManager, dependencies } = makeManager();
const session = await createRunningSession(sessionManager, dependencies);
(dependencies.mockProxyManager as unknown as { _currentThreadId: number | null })._currentThreadId = null;

const result = await sessionManager.evaluateExpression(session.id, 'x');

expect(result.success).toBe(false);
expect(result.error).toContain('Unable to find thread');
});

it('anchors evaluation to the top stack frame when frameId is omitted', async () => {
const { sessionManager, dependencies } = makeManager();
const session = await createRunningSession(sessionManager, dependencies);

const evaluateArgs: unknown[] = [];
dependencies.mockProxyManager.setDapRequestHandler(async (command: string, args?: unknown) => {
if (command === 'stackTrace') {
return { body: { stackFrames: [{ id: 99, name: 'top', line: 1, column: 1 }] } };
}
if (command === 'evaluate') {
evaluateArgs.push(args);
return { body: { result: '2', type: 'int', variablesReference: 0 } };
}
return { success: true };
});

const result = await sessionManager.evaluateExpression(session.id, '1 + 1');

expect(result.success).toBe(true);
expect(evaluateArgs[0]).toMatchObject({ frameId: 99 });
});

it('fails cleanly when the paused thread reports no stack frames', async () => {
const { sessionManager, dependencies } = makeManager();
const session = await createRunningSession(sessionManager, dependencies);

dependencies.mockProxyManager.setDapRequestHandler(async (command: string) => {
if (command === 'stackTrace') {
return { body: { stackFrames: [] } };
}
return { success: true };
});

const result = await sessionManager.evaluateExpression(session.id, 'x');

expect(result.success).toBe(false);
expect(result.error).toContain('No active stack frame');
});

it('wraps stack-trace failures in an evaluation error', async () => {
const { sessionManager, dependencies } = makeManager();
const session = await createRunningSession(sessionManager, dependencies);

dependencies.mockProxyManager.setDapRequestHandler(async (command: string) => {
if (command === 'stackTrace') {
throw new Error('stack machine jammed');
}
return { success: true };
});

const result = await sessionManager.evaluateExpression(session.id, 'x');

expect(result.success).toBe(false);
expect(result.error).toContain('Unable to determine current frame');
expect(result.error).toContain('stack machine jammed');
});
});
Loading
Loading