diff --git a/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts b/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts index f8da7a1c..5e0cd755 100644 --- a/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts +++ b/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts @@ -1015,3 +1015,129 @@ describe('Verbose discovery logging', () => { } }); }); + +describe('WhichCommandFinder internals (coverage sprint)', () => { + beforeEach(() => { + // CI runners (setup-python) export pythonLocation/PYTHON_PATH, which + // short-circuit discovery before WhichCommandFinder is ever consulted — + // neutralize them so these tests exercise the finder on every machine. + vi.stubEnv('pythonLocation', undefined); + vi.stubEnv('PythonLocation', undefined); + vi.stubEnv('PYTHON_PATH', undefined); + vi.stubEnv('PYTHON_EXECUTABLE', undefined); + spawnMock.mockReset(); + whichMock.mockReset(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + const validPythonSpawn = () => + spawnMock.mockImplementation(() => createSpawn({ exitCode: 0, stdout: 'Python 3.12.0\n' })); + + it('installs a ComSpec fallback on Windows when neither ComSpec nor COMSPEC is set', async () => { + vi.stubEnv('ComSpec', ''); + vi.stubEnv('COMSPEC', ''); + vi.stubEnv('SystemRoot', 'C:\\WinTest'); + whichMock.mockResolvedValue(['C:\\py\\python.exe']); + validPythonSpawn(); + + await expect(findPythonExecutable(undefined, undefined, undefined, 'win32')) + .resolves.toBe('C:\\py\\python.exe'); + + expect(process.env.ComSpec).toBe(path.join('C:\\WinTest', 'System32', 'cmd.exe')); + expect(process.env.COMSPEC).toBe(path.join('C:\\WinTest', 'System32', 'cmd.exe')); + }); + + it('copies Path into PATH on Windows when only Path is defined', async () => { + vi.stubEnv('PATH', ''); + vi.stubEnv('Path', 'C:\\one;C:\\two'); + whichMock.mockResolvedValue(['C:\\py\\python.exe']); + validPythonSpawn(); + + await findPythonExecutable(undefined, undefined, undefined, 'win32'); + + expect(process.env.PATH).toBe('C:\\one;C:\\two'); + }); + + it('throws CommandNotFoundError when every candidate is a Windows Store alias', async () => { + whichMock.mockResolvedValue([ + 'C:\\Users\\test\\AppData\\Local\\Microsoft\\WindowsApps\\python.exe' + ]); + validPythonSpawn(); + + // Each candidate ends in CommandNotFoundError, so discovery reports the + // aggregate not-found error rather than any resolved shim path + await expect(findPythonExecutable(undefined, undefined, undefined, 'win32')) + .rejects.toThrow(/Python not found/); + }); + + it('treats a python that fails to spawn --version as invalid (validator error arm)', async () => { + whichMock.mockResolvedValue(['C:\\broken\\python.exe']); + spawnMock.mockImplementation(() => createSpawn({ exitCode: 1, error: new Error('EACCES') })); + + await expect(findPythonExecutable(undefined, undefined, undefined, 'win32')) + .rejects.toThrow(); + expect(spawnMock).toHaveBeenCalledWith( + 'C:\\broken\\python.exe', + ['-c', expect.stringContaining('sys.exit')], + expect.anything() + ); + }); + + it('emits the verbose discovery diagnostics when DEBUG_PYTHON_DISCOVERY=true', async () => { + vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); + vi.stubEnv('PATH', 'C:\\a;;C:\\"quoted" '); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + whichMock.mockResolvedValue(['C:\\py\\python.exe']); + validPythonSpawn(); + + await findPythonExecutable(undefined, undefined, undefined, 'win32'); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Looking for command: py')); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('PATH issues found'), + expect.arrayContaining([expect.stringContaining('empty entries')]) + ); + expect(logSpy).toHaveBeenCalledWith('[PYTHON_DISCOVERY_DEBUG]', expect.any(String)); + }); + + it('runs the direct-spawn failure probe when verbose discovery is on and which fails', async () => { + vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + whichMock.mockRejectedValue(new Error('which exploded')); + spawnMock.mockImplementation(() => createSpawn({ exitCode: 0, stdout: 'Python 3.12.0\n' })); + + await expect(findPythonExecutable(undefined, undefined, undefined, 'win32')) + .rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('which failed for'), + expect.objectContaining({ message: 'which exploded' }) + ); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Direct spawn result: SUCCESS') + ); + }); + + it('falls through to auto-detection when the env-var interpreter is not found', async () => { + vi.stubEnv('PYTHON_PATH', 'ghost-python'); + // The auto-detect winner still gets a debugpy probe + spawnMock.mockImplementation(() => createSpawn({ exitCode: 0, stdout: '1.8.0\n' })); + const finder: CommandFinder = { + find: async (cmd: string) => { + if (cmd === 'ghost-python') { + throw new CommandNotFoundError(cmd); + } + return '/usr/bin/python3'; + } + }; + + await expect(findPythonExecutable(undefined, undefined, finder, 'linux')) + .resolves.toBe('/usr/bin/python3'); + }); +}); diff --git a/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts b/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts index 553a096c..ab5aac70 100644 --- a/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts +++ b/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts @@ -538,4 +538,176 @@ describe('RustDebugAdapter toolchain logic', () => { expect(capabilities.supportsSetExpression).toBe(false); }); }); + + describe('lifecycle (coverage sprint)', () => { + function healthyToolchain(): void { + vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue('/mock/vendor/codelldb'); + vi.mocked(checkRustInstallation).mockResolvedValue(true); + vi.mocked(checkCargoInstallation).mockResolvedValue(true); + vi.mocked(getRustHostTriple).mockResolvedValue('x86_64-unknown-linux-gnu'); + } + + it('initialize reaches READY and emits initialized on a healthy toolchain', async () => { + healthyToolchain(); + const initialized = vi.fn(); + adapter.on('initialized', initialized); + + await adapter.initialize(); + + expect(adapter.getState()).toBe(AdapterState.READY); + expect(initialized).toHaveBeenCalled(); + }); + + it('initialize logs validation warnings but still succeeds', async () => { + healthyToolchain(); + vi.mocked(checkRustInstallation).mockResolvedValue(false); // warning, not error + + await adapter.initialize(); + + expect(adapter.getState()).toBe(AdapterState.READY); + expect(dependencies.logger?.warn).toHaveBeenCalledWith( + expect.stringContaining('Rust toolchain not found') + ); + }); + + it('initialize throws ENVIRONMENT_INVALID and lands in ERROR when CodeLLDB is missing', async () => { + healthyToolchain(); + vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue(null); + + await expect(adapter.initialize()).rejects.toThrow(/CodeLLDB executable not found/); + expect(adapter.getState()).toBe(AdapterState.ERROR); + }); + + it('dispose resets the adapter and emits disposed', async () => { + healthyToolchain(); + await adapter.initialize(); + const disposed = vi.fn(); + adapter.on('disposed', disposed); + + await adapter.dispose(); + + expect(adapter.getState()).toBe(AdapterState.UNINITIALIZED); + expect(adapter.getCurrentThreadId()).toBeNull(); + expect(disposed).toHaveBeenCalled(); + }); + + it('collects RUST_NOT_FOUND and CARGO_NOT_FOUND warnings together', async () => { + vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue('/mock/vendor/codelldb'); + vi.mocked(checkRustInstallation).mockResolvedValue(false); + vi.mocked(checkCargoInstallation).mockResolvedValue(false); + vi.mocked(getRustHostTriple).mockResolvedValue(null); + + const result = await adapter.validateEnvironment(); + + expect(result.valid).toBe(true); + expect(result.warnings.map((w) => w.code)).toEqual( + expect.arrayContaining(['RUST_NOT_FOUND', 'CARGO_NOT_FOUND']) + ); + }); + + it('wraps unexpected validation failures in VALIDATION_ERROR', async () => { + vi.mocked(resolveCodeLLDBExecutable).mockRejectedValue(new Error('resolver exploded')); + + const result = await adapter.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors[0]).toMatchObject({ + code: 'VALIDATION_ERROR', + message: 'resolver exploded', + recoverable: false + }); + }); + + it('records a discovered dlltool path on win32', async () => { + const win = new RustDebugAdapter(createDependencies(), 'win32'); + vi.mocked(resolveCodeLLDBExecutable).mockResolvedValue('/mock/vendor/codelldb'); + vi.mocked(checkRustInstallation).mockResolvedValue(true); + vi.mocked(checkCargoInstallation).mockResolvedValue(true); + vi.mocked(getRustHostTriple).mockResolvedValue('x86_64-pc-windows-gnu'); + vi.mocked(findDlltoolExecutable).mockResolvedValue('C:/mingw64/bin/dlltool.exe'); + + await win.validateEnvironment(); + + expect((win as unknown as { dlltoolPath?: string }).dlltoolPath).toBe('C:/mingw64/bin/dlltool.exe'); + }); + }); + + describe('executable resolution edges (coverage sprint)', () => { + it('throws EXECUTABLE_NOT_FOUND when neither cargo nor rustc is available', async () => { + vi.mocked(checkCargoInstallation).mockResolvedValue(false); + vi.mocked(checkRustInstallation).mockResolvedValue(false); + + await expect(adapter.resolveExecutablePath()).rejects.toThrow(/Neither cargo nor rustc found/); + }); + + it('falls back to the prebuilt placeholder under MCP_CONTAINER=true', async () => { + vi.stubEnv('MCP_CONTAINER', 'true'); + try { + vi.mocked(checkCargoInstallation).mockResolvedValue(false); + vi.mocked(checkRustInstallation).mockResolvedValue(false); + + await expect(adapter.resolveExecutablePath()).resolves.toBe('rust-prebuilt-binary'); + expect(dependencies.logger?.warn).toHaveBeenCalledWith( + expect.stringContaining('MCP_CONTAINER') + ); + } finally { + vi.stubEnv('MCP_CONTAINER', undefined as unknown as string); + } + }); + + it('returns darwin-specific search paths for a darwin-constructed adapter', () => { + const mac = new RustDebugAdapter(createDependencies(), 'darwin'); + const paths = mac.getExecutableSearchPaths(); + expect(paths).toContain('/opt/homebrew/bin'); + expect(paths).toContain('/usr/local/bin'); + }); + + it.each(['0', 'false', 'no'])('resolveAutoSuggestGnu treats %j as disabled', (value) => { + vi.stubEnv('RUST_AUTO_SUGGEST_GNU', value); + try { + expect((adapter as unknown as { resolveAutoSuggestGnu(): boolean }).resolveAutoSuggestGnu()).toBe(false); + } finally { + vi.stubEnv('RUST_AUTO_SUGGEST_GNU', undefined as unknown as string); + } + }); + + it('getDefaultExecutableName is cargo', () => { + expect(adapter.getDefaultExecutableName()).toBe('cargo'); + }); + }); + + describe('transformLaunchConfig cargo targets (coverage sprint)', () => { + it('resolves cargo.example into target//', async () => { + const linux = new RustDebugAdapter(createDependencies(), 'linux'); + const transformed = await linux.transformLaunchConfig({ + cargo: { example: 'demo' }, + cwd: '/proj' + } as never); + + expect(String(transformed.program).replace(/\\/g, '/')).toBe('/proj/target/debug/demo'); + }); + + it('resolves cargo.test into the release dir when release is set', async () => { + const linux = new RustDebugAdapter(createDependencies(), 'linux'); + const transformed = await linux.transformLaunchConfig({ + cargo: { test: 'integration', release: true }, + cwd: '/proj' + } as never); + + expect(String(transformed.program).replace(/\\/g, '/')).toBe('/proj/target/release/integration'); + }); + + it('falls back to the default cargo binary when no target is named', async () => { + vi.mocked(getDefaultBinary).mockResolvedValue('main-bin'); + const linux = new RustDebugAdapter(createDependencies(), 'linux'); + + const transformed = await linux.transformLaunchConfig({ + cargo: {}, + cwd: '/proj' + } as never); + + expect(getDefaultBinary).toHaveBeenCalledWith('/proj'); + expect(String(transformed.program).replace(/\\/g, '/')).toBe('/proj/target/debug/main-bin'); + }); + }); }); diff --git a/tests/adapters/go/unit/go-debug-adapter.test.ts b/tests/adapters/go/unit/go-debug-adapter.test.ts index ea982b23..914c0037 100644 --- a/tests/adapters/go/unit/go-debug-adapter.test.ts +++ b/tests/adapters/go/unit/go-debug-adapter.test.ts @@ -498,4 +498,150 @@ describe('GoDebugAdapter', () => { expect(transformed.mode).toBe('test'); }); }); + + describe('validateEnvironment error and warning arms (coverage sprint)', () => { + // Scripted spawn: args[0]==='version' is `go version`, args[0]==='dap' is + // the Delve DAP support probe (`dlv dap --help`). + function scriptSpawn(opts: { goOutput?: string; dapExit?: number; dapStderr?: string }): void { + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + mockSpawn.mockImplementation(((_cmd: unknown, args?: readonly string[]) => { + const proc = new EventEmitter() as any; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + process.nextTick(() => { + if (args?.[0] === 'version') { + proc.stdout.emit('data', Buffer.from(opts.goOutput ?? 'go version go1.21.0 linux/amd64\n')); + proc.emit('exit', 0); + } else if (args?.[0] === 'dap') { + if (opts.dapStderr) { + proc.stderr.emit('data', Buffer.from(opts.dapStderr)); + } + proc.emit('exit', opts.dapExit ?? 0); + } else { + proc.emit('exit', 0); + } + }); + return proc; + }) as never); + } + + it('flags Go versions older than 1.18 as a hard error', async () => { + scriptSpawn({ goOutput: 'go version go1.17.5 linux/amd64\n' }); + + const result = await adapter.validateEnvironment(); + + expect(result.valid).toBe(false); + expect(result.errors).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'GO_VERSION_TOO_OLD', message: expect.stringContaining('1.17.5') }) + ])); + }); + + it('warns when the Go version cannot be determined', async () => { + scriptSpawn({ goOutput: 'not a version banner\n' }); + + const result = await adapter.validateEnvironment(); + + expect(result.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'GO_VERSION_CHECK_FAILED' }) + ])); + }); + + it('reports Delve without DAP support, embedding the stderr hint', async () => { + scriptSpawn({ dapExit: 1, dapStderr: 'Error: unknown command "dap" for "dlv"' }); + + const result = await adapter.validateEnvironment(); + + expect(result.valid).toBe(false); + const dapError = result.errors.find((e) => e.code === 'DELVE_DAP_NOT_SUPPORTED'); + expect(dapError?.message).toContain('go install github.com/go-delve/delve/cmd/dlv@latest'); + expect(dapError?.message).toContain('unknown command'); + expect(dapError?.recoverable).toBe(true); + }); + + it('emits CI diagnostics when CI=true', async () => { + vi.stubEnv('CI', 'true'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + scriptSpawn({}); + await adapter.validateEnvironment(); + expect(consoleSpy).toHaveBeenCalledWith('[GoDebugAdapter] Resolved Go path:', expect.any(String)); + expect(consoleSpy).toHaveBeenCalledWith('[GoDebugAdapter] Resolved Delve path:', expect.any(String)); + } finally { + consoleSpy.mockRestore(); + vi.unstubAllEnvs(); + } + }); + }); + + describe('executable resolution and version caching (coverage sprint)', () => { + it('resolveExecutablePath caches the Delve path per preferredPath key', async () => { + vi.spyOn(fs.promises, 'access').mockResolvedValue(undefined); + + const first = await adapter.resolveExecutablePath('/fake/dlv'); + const second = await adapter.resolveExecutablePath('/fake/dlv'); + + expect(second).toBe(first); + expect(mockDependencies.logger?.debug).toHaveBeenCalledWith( + expect.stringContaining('Using cached Delve path') + ); + }); + + it('checkDelveVersion parses the version banner and caches it', async () => { + mockSpawn.mockImplementation((() => { + const proc = new EventEmitter() as any; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + process.nextTick(() => { + proc.stdout.emit('data', Buffer.from('Delve Debugger\nVersion: 1.26.3\nBuild: abc\n')); + proc.emit('exit', 0); + }); + return proc; + }) as never); + + await expect(adapter.checkDelveVersion('/fake/dlv')).resolves.toBe('1.26.3'); + // Second call served from the cache — no new spawn + const spawnCalls = mockSpawn.mock.calls.length; + await expect(adapter.checkDelveVersion('/fake/dlv')).resolves.toBe('1.26.3'); + expect(mockSpawn.mock.calls.length).toBe(spawnCalls); + }); + }); + + describe('configuration surface (coverage sprint)', () => { + it('exposes the documented dependencies and install commands', () => { + const deps = adapter.getRequiredDependencies(); + expect(deps.map((d) => d.name)).toEqual(['Go', 'Delve (dlv)']); + expect(adapter.getAdapterModuleName()).toBe('dlv'); + expect(adapter.getAdapterInstallCommand()).toContain('go install'); + expect(adapter.getDefaultExecutableName()).toBe(process.platform === 'win32' ? 'dlv.exe' : 'dlv'); + expect(Array.isArray(adapter.getExecutableSearchPaths())).toBe(true); + expect(adapter.getCurrentThreadId()).toBeNull(); + }); + + it('getDefaultLaunchConfig returns the documented defaults', () => { + expect(adapter.getDefaultLaunchConfig()).toEqual({ stopOnEntry: false, justMyCode: true }); + }); + + it('buildAdapterCommand adds DAP logging flags when DEBUG is set', () => { + vi.stubEnv('DEBUG', 'debug-mcp:*'); + try { + const cmd = adapter.buildAdapterCommand({ + sessionId: 's', + adapterHost: '127.0.0.1', + adapterPort: 2345, + logDir: '/tmp', + scriptPath: 'main.go', + executablePath: '/fake/dlv' + }); + expect(cmd.command).toBe('/fake/dlv'); + expect(cmd.args).toContain('--log'); + expect(cmd.args).toContain('--log-output=dap'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('sendDapRequest is not implemented at the adapter level', async () => { + await expect(adapter.sendDapRequest('threads')).rejects.toThrow(/not implemented/); + }); + }); }); diff --git a/tests/unit/adapter-python/python-debug-adapter.test.ts b/tests/unit/adapter-python/python-debug-adapter.test.ts index 81338a16..a1b2acc8 100644 --- a/tests/unit/adapter-python/python-debug-adapter.test.ts +++ b/tests/unit/adapter-python/python-debug-adapter.test.ts @@ -481,5 +481,156 @@ describe('PythonDebugAdapter', () => { justMyCode: true }); }); + + it('propagates stopOnEntry into the attach config when provided', () => { + const adapter = new PythonDebugAdapter(createDependencies()); + const attach = adapter.transformAttachConfig({ port: 5678, stopOnEntry: true }); + expect(attach.stopOnEntry).toBe(true); + }); + }); + + describe('validateEnvironment through the real helpers (coverage sprint)', () => { + // Scripted spawn: the debugpy probe runs `-c "import debugpy; ..."`, + // the venv probe runs `-c "... real_prefix ..."`. + function scriptSpawn(opts: { debugpyOutput?: string; debugpyExit?: number; venvOutput?: string; venvError?: boolean }): void { + (spawn as Mock).mockImplementation(((_cmd: string, args: string[]) => { + const proc = new EventEmitter() as any; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + const script = args?.[1] ?? ''; + process.nextTick(() => { + if (script.includes('import debugpy')) { + if (opts.debugpyOutput) proc.stdout.emit('data', Buffer.from(opts.debugpyOutput)); + proc.emit('exit', opts.debugpyExit ?? 0); + } else if (script.includes('real_prefix')) { + if (opts.venvError) { + proc.emit('error', new Error('spawn failed')); + return; + } + if (opts.venvOutput) proc.stdout.emit('data', Buffer.from(opts.venvOutput)); + proc.emit('exit', 0); + } else { + proc.emit('exit', 0); + } + }); + return proc; + }) as never); + } + + it('warns when the Python version cannot be determined and detects a virtualenv', async () => { + findPythonExecutable.mockResolvedValue('/usr/bin/python'); + (getPythonVersion as Mock).mockResolvedValue(null); + scriptSpawn({ debugpyOutput: '1.8.0\n', venvOutput: 'True\n' }); + const deps = createDependencies(); + const adapter = new PythonDebugAdapter(deps as never); + + const result = await adapter.validateEnvironment(); + + expect(result.warnings).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'PYTHON_VERSION_CHECK_FAILED' }) + ])); + expect(deps.logger.info).toHaveBeenCalledWith( + expect.stringContaining('Virtual environment detected') + ); + }); + + it('resolves the version via getPythonVersion on cache miss and reuses the cached debugpy answer', async () => { + findPythonExecutable.mockResolvedValue('/usr/bin/python'); + (getPythonVersion as Mock).mockResolvedValue('3.12.1'); + scriptSpawn({ debugpyOutput: '1.8.0\n', venvOutput: 'False\n' }); + const adapter = new PythonDebugAdapter(createDependencies() as never); + + const first = await adapter.validateEnvironment(); + expect(first.valid).toBe(true); + expect(getPythonVersion).toHaveBeenCalledTimes(1); + + const debugpySpawns = () => (spawn as Mock).mock.calls + .filter((c) => String(c[1]?.[1] ?? '').includes('import debugpy')).length; + const before = debugpySpawns(); + await adapter.validateEnvironment(); + // Second validation: version and debugpy answers come from the cache + expect(getPythonVersion).toHaveBeenCalledTimes(1); + expect(debugpySpawns()).toBe(before); + }); + + it('treats a failing virtualenv probe as not-a-venv', async () => { + findPythonExecutable.mockResolvedValue('/usr/bin/python'); + (getPythonVersion as Mock).mockResolvedValue('3.12.1'); + scriptSpawn({ debugpyOutput: '1.8.0\n', venvError: true }); + const deps = createDependencies(); + const adapter = new PythonDebugAdapter(deps as never); + + const result = await adapter.validateEnvironment(); + + expect(result.valid).toBe(true); + expect(deps.logger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Virtual environment detected') + ); + }); + + it('emits CI diagnostics during initialize when CI=true', async () => { + vi.stubEnv('CI', 'true'); + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + findPythonExecutable.mockResolvedValue('/usr/bin/python'); + (getPythonVersion as Mock).mockResolvedValue('3.12.1'); + scriptSpawn({ debugpyOutput: '1.8.0\n', venvOutput: 'False\n' }); + const adapter = new PythonDebugAdapter(createDependencies() as never); + + await adapter.initialize(); + + expect(consoleSpy).toHaveBeenCalledWith('[PythonDebugAdapter] Starting initialize()'); + expect(consoleSpy).toHaveBeenCalledWith('[PythonDebugAdapter] Resolved Python path:', '/usr/bin/python'); + } finally { + consoleSpy.mockRestore(); + vi.unstubAllEnvs(); + } + }); + }); + + describe('platform-dependent configuration surface (coverage sprint)', () => { + function withPlatform(platform: NodeJS.Platform, fn: () => void): void { + const original = Object.getOwnPropertyDescriptor(process, 'platform')!; + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + fn(); + } finally { + Object.defineProperty(process, 'platform', original); + } + } + + it('names the default executable per platform', () => { + const adapter = new PythonDebugAdapter(createDependencies() as never); + withPlatform('win32', () => expect(adapter.getDefaultExecutableName()).toBe('py')); + withPlatform('linux', () => expect(adapter.getDefaultExecutableName()).toBe('python3')); + withPlatform('darwin', () => expect(adapter.getDefaultExecutableName()).toBe('python3')); + }); + + it('returns platform-appropriate search paths', () => { + const adapter = new PythonDebugAdapter(createDependencies() as never); + withPlatform('win32', () => { + expect(adapter.getExecutableSearchPaths()).toEqual(expect.arrayContaining(['C:\\Python312'])); + }); + withPlatform('darwin', () => { + expect(adapter.getExecutableSearchPaths()).toEqual(expect.arrayContaining(['/opt/homebrew/bin'])); + }); + withPlatform('linux', () => { + expect(adapter.getExecutableSearchPaths()).toEqual(expect.arrayContaining(['/opt/python/bin'])); + }); + }); + + it('exposes the documented dependencies and install metadata', () => { + const adapter = new PythonDebugAdapter(createDependencies() as never); + expect(adapter.getRequiredDependencies().map((d: { name: string }) => d.name)).toEqual(['Python', 'debugpy']); + expect(adapter.getAdapterModuleName()).toBe('debugpy.adapter'); + expect(adapter.getAdapterInstallCommand()).toBe('pip install debugpy'); + }); + + it('reports feature requirements for conditional breakpoints', () => { + const adapter = new PythonDebugAdapter(createDependencies() as never); + expect(adapter.getFeatureRequirements(DebugFeature.CONDITIONAL_BREAKPOINTS)).toEqual([ + { type: 'dependency', description: 'debugpy 1.0+', required: true } + ]); + }); }); }); diff --git a/tests/unit/adapters/mock-debug-adapter.test.ts b/tests/unit/adapters/mock-debug-adapter.test.ts index 0bcbac1e..097cd2e7 100644 --- a/tests/unit/adapters/mock-debug-adapter.test.ts +++ b/tests/unit/adapters/mock-debug-adapter.test.ts @@ -2,6 +2,24 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { MockDebugAdapter, MockErrorScenario } from '../../../packages/adapter-mock/src/mock-debug-adapter.js'; import { AdapterState, DebugFeature, type AdapterDependencies } from '@debugmcp/shared'; +// buildAdapterCommand probes the filesystem for the bundled .cjs process +// file; the ESM fs namespace is not spy-able, so route existsSync through a +// controllable override that defaults to the real implementation. +const fsControl = vi.hoisted(() => ({ + existsSyncOverride: null as ((p: string) => boolean) | null +})); + +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: (p: unknown) => + fsControl.existsSyncOverride + ? fsControl.existsSyncOverride(String(p)) + : actual.existsSync(p as import('fs').PathLike) + }; +}); + const createDependencies = (): AdapterDependencies => ({ logger: { info: vi.fn(), @@ -50,4 +68,204 @@ describe('MockDebugAdapter behaviour', () => { adapter.setErrorScenario(MockErrorScenario.CONNECTION_TIMEOUT); await expect(adapter.connect('127.0.0.1', 9100)).rejects.toThrow(/Connection timeout/); }); + + it('fails initialize with ERROR state when the executable scenario is configured', async () => { + adapter.setErrorScenario(MockErrorScenario.EXECUTABLE_NOT_FOUND); + await expect(adapter.initialize()).rejects.toThrow(/Mock executable not found/); + expect(adapter.getState()).toBe(AdapterState.ERROR); + }); + + it('dispose resets state and emits disposed', async () => { + await adapter.initialize(); + const disposed = vi.fn(); + adapter.on('disposed', disposed); + + await adapter.dispose(); + + expect(adapter.getState()).toBe(AdapterState.UNINITIALIZED); + expect(adapter.getCurrentThreadId()).toBeNull(); + expect(adapter.isConnected()).toBe(false); + expect(disposed).toHaveBeenCalled(); + }); + + it('rejects invalid state transitions with an AdapterError', async () => { + await adapter.initialize(); + await adapter.connect('127.0.0.1', 9000); + await adapter.disconnect(); + + // DISCONNECTED -> DEBUGGING is not a valid transition + expect(() => adapter.handleDapEvent({ seq: 1, type: 'event', event: 'continued' })) + .toThrow(/Invalid state transition: disconnected/); + }); + + it('honors a configured connection delay before connecting', async () => { + const delayed = new MockDebugAdapter(createDependencies(), { connectionDelay: 10 }); + const before = Date.now(); + await delayed.connect('127.0.0.1', 9000); + expect(Date.now() - before).toBeGreaterThanOrEqual(9); + expect(delayed.getState()).toBe(AdapterState.CONNECTED); + }); + + describe('handleDapEvent state tracking', () => { + beforeEach(async () => { + await adapter.initialize(); + await adapter.connect('127.0.0.1', 9000); + }); + + it('captures the thread id and enters DEBUGGING on stopped', () => { + adapter.handleDapEvent({ seq: 1, type: 'event', event: 'stopped', body: { threadId: 7, reason: 'breakpoint' } }); + expect(adapter.getCurrentThreadId()).toBe(7); + expect(adapter.getState()).toBe(AdapterState.DEBUGGING); + }); + + it('enters DEBUGGING on continued and forwards the event', () => { + const seen = vi.fn(); + adapter.on('continued', seen); + adapter.handleDapEvent({ seq: 2, type: 'event', event: 'continued', body: {} }); + expect(adapter.getState()).toBe(AdapterState.DEBUGGING); + expect(seen).toHaveBeenCalled(); + }); + + it('returns to CONNECTED on terminated while connected', () => { + adapter.handleDapEvent({ seq: 3, type: 'event', event: 'stopped', body: { threadId: 7 } }); + adapter.handleDapEvent({ seq: 4, type: 'event', event: 'terminated' }); + expect(adapter.getCurrentThreadId()).toBeNull(); + expect(adapter.getState()).toBe(AdapterState.CONNECTED); + }); + + it('falls to DISCONNECTED on exited when no longer connected', async () => { + adapter.handleDapEvent({ seq: 5, type: 'event', event: 'stopped', body: { threadId: 7 } }); + // Drop the connection flag without changing state (disconnect() would move state itself) + (adapter as unknown as { connected: boolean }).connected = false; + adapter.handleDapEvent({ seq: 6, type: 'event', event: 'exited', body: { exitCode: 0 } }); + expect(adapter.getState()).toBe(AdapterState.DISCONNECTED); + }); + }); + + describe('adapter command and configuration surface', () => { + const adapterConfig = { + sessionId: 'sess-1', + adapterHost: '127.0.0.1', + adapterPort: 4711, + logDir: '/tmp/logs', + scriptPath: 'ignored.js' + }; + + it('builds the adapter command around mock-adapter-process.js', () => { + const cmd = adapter.buildAdapterCommand(adapterConfig); + + expect(cmd.command).toBe(process.execPath); + expect(cmd.args[0].replace(/\\/g, '/')).toMatch(/mock-adapter-process\.js$/); + expect(cmd.args).toEqual(expect.arrayContaining(['--port', '4711', '--host', '127.0.0.1', '--session', 'sess-1'])); + expect(cmd.env?.MOCK_ADAPTER_LOG).toBe('/tmp/logs'); + }); + + it('falls back to the bundled .cjs process file when the .js is absent (npx bundle)', () => { + fsControl.existsSyncOverride = (p) => p.endsWith('.cjs'); + try { + const cmd = adapter.buildAdapterCommand(adapterConfig); + expect(cmd.args[0].replace(/\\/g, '/')).toMatch(/mock-adapter-process\.cjs$/); + } finally { + fsControl.existsSyncOverride = null; + } + }); + + it('exposes the trivial configuration surface', async () => { + expect(adapter.getRequiredDependencies()).toEqual([]); + await expect(adapter.resolveExecutablePath('/custom/node')).resolves.toBe('/custom/node'); + await expect(adapter.resolveExecutablePath()).resolves.toBe(process.execPath); + expect(adapter.getDefaultExecutableName()).toBe('node'); + expect(Array.isArray(adapter.getExecutableSearchPaths())).toBe(true); + expect(adapter.getAdapterModuleName()).toBe('mock-adapter'); + expect(adapter.getAdapterInstallCommand()).toContain('built-in'); + expect(adapter.getInstallationInstructions()).toContain('built-in'); + expect(adapter.getMissingExecutableError()).toContain('Mock executable not found'); + }); + + it('transformLaunchConfig stamps the mock adapter identity', async () => { + const transformed = await adapter.transformLaunchConfig({ stopOnEntry: true }); + expect(transformed).toEqual({ + stopOnEntry: true, + type: 'mock', + request: 'launch', + name: 'Mock Debug' + }); + }); + + it('getDefaultLaunchConfig returns the documented defaults', () => { + expect(adapter.getDefaultLaunchConfig()).toEqual({ + stopOnEntry: false, + justMyCode: true, + env: {}, + cwd: process.cwd() + }); + }); + + it('sendDapRequest logs and returns an empty response; handleDapResponse is inert', async () => { + await expect(adapter.sendDapRequest('threads')).resolves.toEqual({}); + expect(() => adapter.handleDapResponse({ seq: 1, request_seq: 1, type: 'response', command: 'threads', success: true })).not.toThrow(); + }); + + it('reports feature requirements for conditional breakpoints only', () => { + expect(adapter.getFeatureRequirements(DebugFeature.CONDITIONAL_BREAKPOINTS)).toEqual([ + { type: 'version', description: 'Mock adapter version 1.0+', required: true } + ]); + expect(adapter.getFeatureRequirements(DebugFeature.LOG_POINTS)).toEqual([]); + }); + }); + + describe('getCapabilities', () => { + it('reflects configured features inside the full capability surface', () => { + // Configured with CONDITIONAL_BREAKPOINTS + LOG_POINTS only + expect(adapter.getCapabilities()).toEqual({ + supportsConfigurationDoneRequest: true, + supportsFunctionBreakpoints: false, + supportsConditionalBreakpoints: true, + supportsHitConditionalBreakpoints: false, + supportsEvaluateForHovers: false, + exceptionBreakpointFilters: [ + { filter: 'uncaught', label: 'Uncaught Exceptions', default: false }, + { filter: 'all', label: 'All Exceptions', default: false } + ], + supportsStepBack: false, + supportsSetVariable: false, + supportsRestartFrame: false, + supportsGotoTargetsRequest: false, + supportsStepInTargetsRequest: false, + supportsCompletionsRequest: false, + supportsModulesRequest: false, + supportsRestartRequest: false, + supportsExceptionOptions: false, + supportsValueFormattingOptions: false, + supportsExceptionInfoRequest: true, + supportTerminateDebuggee: true, + supportSuspendDebuggee: false, + supportsDelayedStackTraceLoading: false, + supportsLoadedSourcesRequest: false, + supportsLogPoints: true, + supportsTerminateThreadsRequest: false, + supportsSetExpression: false, + supportsTerminateRequest: true, + supportsDataBreakpoints: false, + supportsReadMemoryRequest: false, + supportsWriteMemoryRequest: false, + supportsDisassembleRequest: false, + supportsCancelRequest: false, + supportsBreakpointLocationsRequest: false, + supportsClipboardContext: false, + supportsSteppingGranularity: false, + supportsInstructionBreakpoints: false, + supportsExceptionFilterOptions: false, + supportsSingleThreadExecutionRequests: false + }); + }); + + it('enables the feature-gated capabilities when the defaults are used', () => { + const defaults = new MockDebugAdapter(createDependencies()); + const caps = defaults.getCapabilities(); + expect(caps.supportsFunctionBreakpoints).toBe(true); + expect(caps.supportsSetVariable).toBe(true); + expect(caps.supportsLogPoints).toBe(true); + }); + }); });