diff --git a/tests/core/unit/server/server-redefine-and-attach.test.ts b/tests/core/unit/server/server-redefine-and-attach.test.ts index 1ea1d91a..fc18561d 100644 --- a/tests/core/unit/server/server-redefine-and-attach.test.ts +++ b/tests/core/unit/server/server-redefine-and-attach.test.ts @@ -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'; @@ -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) => + 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(); + }); + }); }); diff --git a/tests/core/unit/session/session-manager-evaluate.test.ts b/tests/core/unit/session/session-manager-evaluate.test.ts new file mode 100644 index 00000000..808dde33 --- /dev/null +++ b/tests/core/unit/session/session-manager-evaluate.test.ts @@ -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, + 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'); + }); +}); diff --git a/tests/proxy/child-session-manager.test.ts b/tests/proxy/child-session-manager.test.ts index e5010b98..2663e97e 100644 --- a/tests/proxy/child-session-manager.test.ts +++ b/tests/proxy/child-session-manager.test.ts @@ -21,6 +21,13 @@ class MockMinimalDapClient extends EventEmitter { // Emit a stopped event shortly after the attach request (issue #295 — // simulates the entry stop firing while later adoption steps still run) static emitStoppedAfterAttach = false; + // Emit a post-attach 'initialized' (some adapters re-initialize after + // attach; drives handlePostAttachInit's replay path) + static emitInitializedAfterAttach = false; + // When set, returned verbatim for 'threads' + static threadsResponse: unknown | undefined = undefined; + // When true, shutdown() throws (drives the parent shutdown catch arm) + static shutdownThrows = false; host: string; port: number; @@ -55,6 +62,9 @@ class MockMinimalDapClient extends EventEmitter { if (command === 'attach' && MockMinimalDapClient.emitStoppedAfterAttach) { setTimeout(() => this.emit('event', { event: 'stopped', body: { reason: 'entry', threadId: 0 } }), 5); } + if (command === 'attach' && MockMinimalDapClient.emitInitializedAfterAttach) { + setTimeout(() => this.emit('event', { event: 'initialized' }), 5); + } // Simulate responses if (command === 'initialize') { @@ -64,6 +74,9 @@ class MockMinimalDapClient extends EventEmitter { return { body: { capabilities: {} } }; } if (command === 'threads') { + if (MockMinimalDapClient.threadsResponse !== undefined) { + return MockMinimalDapClient.threadsResponse; + } return { body: { threads: [{ id: 1, name: 'main' }] } }; } if (command === 'setBreakpoints') { @@ -86,6 +99,9 @@ class MockMinimalDapClient extends EventEmitter { shutdown(reason: string): void { this.shutdownCalls.push(reason); + if (MockMinimalDapClient.shutdownThrows) { + throw new Error('shutdown exploded'); + } this.connected = false; } @@ -109,6 +125,9 @@ describe('ChildSessionManager', () => { MockMinimalDapClient.suppressInitialized = false; MockMinimalDapClient.setBreakpointsResponse = undefined; MockMinimalDapClient.emitStoppedAfterAttach = false; + MockMinimalDapClient.emitInitializedAfterAttach = false; + MockMinimalDapClient.threadsResponse = undefined; + MockMinimalDapClient.shutdownThrows = false; }); describe('JavaScript policy (multi-session)', () => { @@ -910,6 +929,153 @@ describe('ChildSessionManager', () => { }); }); + describe('adoption edges and the child-safe policy (coverage sprint)', () => { + const childConfig = { + pendingId: 'edge-pending-1', + host: 'localhost', + port: 9229, + parentConfig: { type: 'pwa-node', request: 'launch' } + }; + + async function createChild(mgr: ChildSessionManager): Promise { + vi.useFakeTimers(); + try { + const promise = mgr.createChildSession(childConfig); + await vi.advanceTimersByTimeAsync(20000); + await promise; + } finally { + vi.useRealTimers(); + } + } + + it('hands children a policy that cannot spawn grandchildren', async () => { + const baseHandle = vi.fn(async (req: DebugProtocol.Request) => + req.command === 'startDebugging' ? { handled: true, wouldSpawn: true } : { handled: false } + ); + const basePolicy: AdapterPolicy = { + ...JsDebugAdapterPolicy, + getDapClientBehavior: () => ({ + ...JsDebugAdapterPolicy.getDapClientBehavior(), + handleReverseRequest: baseHandle as never + }) + }; + const mgr = new ChildSessionManager({ policy: basePolicy, host: 'localhost', port: 9229 }); + await createChild(mgr); + + const childPolicy = MockMinimalDapClient.lastInstance!.policy!; + expect(childPolicy.supportsReverseStartDebugging).toBe(false); + expect(childPolicy.childSessionStrategy).toBe('none'); + + const behavior = childPolicy.getDapClientBehavior(); + expect(behavior.mirrorBreakpointsToChild).toBe(false); + expect(behavior.pauseAfterChildAttach).toBe(false); + expect(behavior.childRoutedCommands?.size).toBe(0); + + // A grandchild-spawning reverse request is acknowledged and stopped + const spawnResult = await behavior.handleReverseRequest!( + { seq: 1, type: 'request', command: 'startDebugging' } as never, + {} as never + ); + expect(spawnResult).toEqual({ handled: true }); + + // Unhandled reverse requests pass through untouched + const passthrough = await behavior.handleReverseRequest!( + { seq: 2, type: 'request', command: 'somethingElse' } as never, + {} as never + ); + expect(passthrough).toEqual({ handled: false }); + + await mgr.shutdown(); + }); + + it('survives failing configuration requests during adoption', async () => { + MockMinimalDapClient.failCommands.set('setExceptionBreakpoints', new Error('no exception filters')); + MockMinimalDapClient.failCommands.set('configurationDone', new Error('not required')); + MockMinimalDapClient.failCommands.set('setBreakpoints', new Error('bp mirror failed')); + + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + mgr.storeBreakpoints('/abs/app.js', [{ line: 3 }]); + const created = vi.fn(); + mgr.on('childCreated', created); + + await createChild(mgr); + + expect(created).toHaveBeenCalled(); + await mgr.shutdown(); + }); + + it('replays stored breakpoints after a post-attach initialized event', async () => { + MockMinimalDapClient.emitInitializedAfterAttach = true; + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + mgr.storeBreakpoints('/abs/app.js', [{ line: 7 }]); + + await createChild(mgr); + + const child = MockMinimalDapClient.lastInstance!; + const bpRequests = child.requests.filter( + (r) => r.command === 'setBreakpoints' && + (r.args as { source?: { path?: string } })?.source?.path === '/abs/app.js' + ); + // Once from configureChild's mirror, once from the post-attach replay + expect(bpRequests.length).toBeGreaterThanOrEqual(2); + await mgr.shutdown(); + }); + + it('applies the js-debug double-pause quirk when the first thread id is 0', async () => { + MockMinimalDapClient.threadsResponse = { body: { threads: [{ id: 0, name: 'main' }] } }; + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + + await createChild(mgr); + + const pauses = MockMinimalDapClient.lastInstance!.requests + .filter((r) => r.command === 'pause') + .map((r) => (r.args as { threadId?: number })?.threadId); + expect(pauses).toEqual(expect.arrayContaining([0, 1])); + await mgr.shutdown(); + }); + + it('tolerates a threads request failure while trying to pause the child', async () => { + MockMinimalDapClient.failCommands.set('threads', new Error('threads unavailable')); + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + const created = vi.fn(); + mgr.on('childCreated', created); + + await createChild(mgr); + + expect(created).toHaveBeenCalled(); + await mgr.shutdown(); + }); + + it('rejects adoption once the child errors, ignoring the follow-up close', async () => { + vi.useFakeTimers(); + try { + MockMinimalDapClient.hangCommands.add('attach'); + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + const promise = mgr.createChildSession(childConfig); + promise.catch(() => {}); + await vi.advanceTimersByTimeAsync(50); + + const child = MockMinimalDapClient.lastInstance!; + child.emit('error', new Error('socket reset')); + child.emit('close'); // second death signal must be a no-op + + await expect(promise).rejects.toThrow(/errored during adoption: socket reset/); + await mgr.shutdown(); + } finally { + vi.useRealTimers(); + } + }); + + it('shutdown survives a child whose own shutdown throws', async () => { + const mgr = new ChildSessionManager({ policy: JsDebugAdapterPolicy, host: 'localhost', port: 9229 }); + await createChild(mgr); + MockMinimalDapClient.shutdownThrows = true; + + await expect(mgr.shutdown()).resolves.toBeUndefined(); + expect((mgr as unknown as { childSessions: Map }).childSessions.size).toBe(0); + }); + }); + describe('stored breakpoint lifecycle (issue #405)', () => { beforeEach(() => { manager = new ChildSessionManager({ diff --git a/vitest.config.ts b/vitest.config.ts index 2887b1fb..d31d78a5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -233,8 +233,13 @@ export default defineConfig({ 'packages/shared/src/models/index.ts' ], include: ['src/**/*.{ts,js}', 'packages/**/src/**/*.{ts,js}'], + // Ratcheted after the 2026-08 coverage sprint (measured: statements + // 92.9, branches 83.0). Margins absorb platform-specific branches + // (win32-only arms uncovered on linux and vice versa) — raise again + // when the measured numbers move up, never loosen to admit a regression. thresholds: { - statements: 80 + statements: 90, + branches: 80 } }, // --- Projects: parallel `unit` + serial `integration` + serial `e2e` ---