diff --git a/packages/adapter-dotnet/src/utils/dotnet-utils.ts b/packages/adapter-dotnet/src/utils/dotnet-utils.ts index d881b62a..043e6dd1 100644 --- a/packages/adapter-dotnet/src/utils/dotnet-utils.ts +++ b/packages/adapter-dotnet/src/utils/dotnet-utils.ts @@ -207,9 +207,10 @@ export async function findDotnetBackend( * @returns Array of process info objects */ export async function listDotnetProcesses( - logger: Logger = noopLogger + logger: Logger = noopLogger, + platform: NodeJS.Platform = process.platform ): Promise> { - if (process.platform !== 'win32') { + if (platform !== 'win32') { logger.debug?.('[Dotnet Detection] Process listing is currently Windows-only'); return []; } @@ -269,8 +270,8 @@ export async function listDotnetProcesses( * @param pid Process ID * @returns Full path to the process executable, or null if not found */ -export function getProcessExecutablePath(pid: number | string): string | null { - if (process.platform !== 'win32') { +export function getProcessExecutablePath(pid: number | string, platform: NodeJS.Platform = process.platform): string | null { + if (platform !== 'win32') { return null; } @@ -304,8 +305,8 @@ export function getProcessExecutablePath(pid: number | string): string | null { * @param pid Process ID * @returns Directory containing the process executable, or null if not found */ -export function getProcessExecutableDir(pid: number | string): string | null { - const exePath = getProcessExecutablePath(pid); +export function getProcessExecutableDir(pid: number | string, platform: NodeJS.Platform = process.platform): string | null { + const exePath = getProcessExecutablePath(pid, platform); return exePath ? path.dirname(exePath) : null; } @@ -352,8 +353,8 @@ export function getExeArchitecture(exePath: string): 'x86' | 'x64' | null { * @param pid Process ID * @returns 'x86' or 'x64', or null if detection fails */ -export function getProcessArchitecture(pid: number | string): 'x86' | 'x64' | null { - const exePath = getProcessExecutablePath(pid); +export function getProcessArchitecture(pid: number | string, platform: NodeJS.Platform = process.platform): 'x86' | 'x64' | null { + const exePath = getProcessExecutablePath(pid, platform); if (!exePath) return null; return getExeArchitecture(exePath); } diff --git a/packages/adapter-dotnet/tests/unit/dotnet-utils.test.ts b/packages/adapter-dotnet/tests/unit/dotnet-utils.test.ts index c544e827..7a97b0ff 100644 --- a/packages/adapter-dotnet/tests/unit/dotnet-utils.test.ts +++ b/packages/adapter-dotnet/tests/unit/dotnet-utils.test.ts @@ -175,24 +175,16 @@ describe('findDotnetBackend', () => { }); describe('listDotnetProcesses', () => { - const originalPlatform = process.platform; - beforeEach(() => { vi.clearAllMocks(); }); it('returns empty array on non-Windows platforms', async () => { - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'linux'); expect(result).toEqual([]); - - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); }); it('parses tasklist CSV output on Windows', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 0, stdout: [ @@ -203,35 +195,25 @@ describe('listDotnetProcesses', () => { ].join('\n') })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toHaveLength(2); expect(result).toContainEqual({ name: 'NinjaTrader.exe', pid: 12345 }); expect(result).toContainEqual({ name: 'devenv.exe', pid: 67890 }); - - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); }); it('returns empty array when tasklist fails', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 1 })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toEqual([]); - - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); }); it('returns empty array when tasklist spawn errors', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 0, error: new Error('spawn failed') })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toEqual([]); - - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); }); }); @@ -366,19 +348,11 @@ describe('findNetcoredbgExecutable (additional coverage)', () => { }); describe('listDotnetProcesses (additional coverage)', () => { - const originalPlatform = process.platform; - beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - }); - it('skips malformed CSV lines', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 0, stdout: [ @@ -389,23 +363,19 @@ describe('listDotnetProcesses (additional coverage)', () => { ].join('\n') })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toHaveLength(1); expect(result[0]).toEqual({ name: 'NinjaTrader.exe', pid: 12345 }); }); it('returns empty array for empty output', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 0, stdout: '' })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toEqual([]); }); it('recognizes all known .NET processes', async () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - spawnMock.mockReturnValue(createSpawn({ exitCode: 0, stdout: [ @@ -418,7 +388,7 @@ describe('listDotnetProcesses (additional coverage)', () => { ].join('\n') })); - const result = await listDotnetProcesses(); + const result = await listDotnetProcesses(undefined, 'win32'); expect(result).toHaveLength(5); }); }); @@ -777,70 +747,51 @@ describe('getExeArchitecture', () => { }); describe('getProcessExecutablePath', () => { - const originalPlatform = process.platform; - beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - }); - it('returns null on non-Windows platforms', () => { - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - expect(getProcessExecutablePath(1234)).toBeNull(); + expect(getProcessExecutablePath(1234, 'linux')).toBeNull(); }); it('returns executable path via WMIC on Windows', () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); spawnSyncMock.mockReturnValue({ status: 0, stdout: Buffer.from('\r\nExecutablePath=C:\\Program Files\\App\\app.exe\r\n\r\n'), stderr: Buffer.from('') }); - expect(getProcessExecutablePath(1234)).toBe('C:\\Program Files\\App\\app.exe'); + expect(getProcessExecutablePath(1234, 'win32')).toBe('C:\\Program Files\\App\\app.exe'); }); it('returns null when WMIC fails', () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); spawnSyncMock.mockReturnValue({ status: 1, stdout: Buffer.from(''), stderr: Buffer.from('') }); - expect(getProcessExecutablePath(1234)).toBeNull(); + expect(getProcessExecutablePath(1234, 'win32')).toBeNull(); }); it('returns null when WMIC output has no ExecutablePath', () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); spawnSyncMock.mockReturnValue({ status: 0, stdout: Buffer.from('No Instance(s) Available.\r\n'), stderr: Buffer.from('') }); - expect(getProcessExecutablePath(1234)).toBeNull(); + expect(getProcessExecutablePath(1234, 'win32')).toBeNull(); }); }); describe('getProcessArchitecture', () => { - const originalPlatform = process.platform; - beforeEach(() => { vi.clearAllMocks(); }); - afterEach(() => { - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - }); - it('returns null on non-Windows platforms', () => { - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - expect(getProcessArchitecture(1234)).toBeNull(); + expect(getProcessArchitecture(1234, 'linux')).toBeNull(); }); it('returns x86 for a 32-bit process', () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); - // WMIC returns exe path spawnSyncMock.mockReturnValue({ status: 0, @@ -864,14 +815,13 @@ describe('getProcessArchitecture', () => { }); closeSyncMock.mockReturnValue(undefined); - expect(getProcessArchitecture(1234)).toBe('x86'); + expect(getProcessArchitecture(1234, 'win32')).toBe('x86'); }); it('returns null when exe path cannot be determined', () => { - Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); spawnSyncMock.mockReturnValue({ status: 1, stdout: Buffer.from(''), stderr: Buffer.from('') }); - expect(getProcessArchitecture(1234)).toBeNull(); + expect(getProcessArchitecture(1234, 'win32')).toBeNull(); }); }); diff --git a/packages/adapter-javascript/src/javascript-adapter-factory.ts b/packages/adapter-javascript/src/javascript-adapter-factory.ts index 8e9a93e3..c3e21e0f 100644 --- a/packages/adapter-javascript/src/javascript-adapter-factory.ts +++ b/packages/adapter-javascript/src/javascript-adapter-factory.ts @@ -43,13 +43,14 @@ export class JavascriptAdapterFactory extends BaseAdapterFactory { * - Node.js version >= 14 * - Bundled js-debug adapter present * - TypeScript runner availability (warnings only) + * + * @param nodeVersion Node version override for tests (issue #186); defaults to process.version */ - async validate(): Promise { + async validate(nodeVersion: string = process.version): Promise { const errors: string[] = []; const warnings: string[] = []; - // Node.js version check - const nodeVersion = process.version; // e.g., v20.11.1 + // Node.js version check (nodeVersion e.g. v20.11.1) let major = 0; const m = /^v?(\d+)\./.exec(nodeVersion); if (m) { diff --git a/packages/adapter-javascript/tests/unit/javascript-adapter-factory.validate.test.ts b/packages/adapter-javascript/tests/unit/javascript-adapter-factory.validate.test.ts index f800e977..979a1e07 100644 --- a/packages/adapter-javascript/tests/unit/javascript-adapter-factory.validate.test.ts +++ b/packages/adapter-javascript/tests/unit/javascript-adapter-factory.validate.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import fs from 'fs'; import path from 'path'; import { JavascriptAdapterFactory } from '../../src/index.js'; @@ -12,18 +12,7 @@ function isVendorPath(p: unknown): boolean { } describe('JavascriptAdapterFactory.validate', () => { - let versionDescriptor: PropertyDescriptor | undefined; - - beforeEach(() => { - // Save original process.version descriptor so we can restore it - versionDescriptor = Object.getOwnPropertyDescriptor(process, 'version'); - }); - afterEach(() => { - // Restore process.version - if (versionDescriptor) { - Object.defineProperty(process, 'version', versionDescriptor); - } vi.restoreAllMocks(); vi.clearAllMocks(); }); @@ -46,12 +35,6 @@ describe('JavascriptAdapterFactory.validate', () => { }); it('flags error when Node version is too old (< 14)', async () => { - // Force Node v12 - Object.defineProperty(process, 'version', { - configurable: true, - get: () => 'v12.22.0' - }); - // Vendor present vi.spyOn(fs, 'existsSync').mockImplementation((p: unknown) => { return isVendorPath(p); @@ -59,7 +42,7 @@ describe('JavascriptAdapterFactory.validate', () => { vi.stubEnv('PATH', ''); const factory = new JavascriptAdapterFactory(); - const res = await factory.validate(); + const res = await factory.validate('v12.22.0'); expect(res.valid).toBe(false); expect(res.errors.some(e => e.includes('Node.js 14+ required') && e.includes('v12.22.0'))).toBe(true); diff --git a/packages/adapter-python/src/utils/python-utils.ts b/packages/adapter-python/src/utils/python-utils.ts index a6cae21d..54d3cada 100644 --- a/packages/adapter-python/src/utils/python-utils.ts +++ b/packages/adapter-python/src/utils/python-utils.ts @@ -31,19 +31,22 @@ export class CommandNotFoundError extends Error { } export interface CommandFinder { - find(cmd: string): Promise; + /** + * @param platform Platform override for tests (issue #186); implementations default it to process.platform + */ + find(cmd: string, platform?: NodeJS.Platform): Promise; } class WhichCommandFinder implements CommandFinder { private cache = new Map(); constructor(private useCache = true) {} - async find(cmd: string): Promise { + async find(cmd: string, platform: NodeJS.Platform = process.platform): Promise { if (this.useCache && this.cache.has(cmd)) { return this.cache.get(cmd)!; } - const isWindows = process.platform === 'win32'; + const isWindows = platform === 'win32'; const verboseDiscovery = process.env.DEBUG_PYTHON_DISCOVERY === 'true'; try { // Fix for Windows: which library fails if PATH is undefined but Path exists @@ -174,7 +177,7 @@ class WhichCommandFinder implements CommandFinder { }); // Test if we can spawn the command directly without 'which' - if (process.platform === 'win32') { + if (isWindows) { console.error(`[Python Discovery] Testing direct spawn of ${cmd}...`); const testResult = await new Promise((resolve) => { const child = spawn(cmd, ['--version'], { @@ -300,13 +303,15 @@ async function hasDebugpy(pythonPath: string, logger: Logger = noopLogger): Prom * @param preferredPath Optional preferred Python path to check first * @param logger Optional logger instance for logging detection info * @param commandFinder Optional CommandFinder instance (defaults to WhichCommandFinder) + * @param platform Platform override for tests (issue #186); defaults to the real platform */ export async function findPythonExecutable( preferredPath?: string, logger: Logger = noopLogger, - commandFinder: CommandFinder = defaultCommandFinder + commandFinder: CommandFinder = defaultCommandFinder, + platform: NodeJS.Platform = process.platform ): Promise { - const isWindows = process.platform === 'win32'; + const isWindows = platform === 'win32'; const triedPaths: string[] = []; const validPythonPaths: string[] = []; @@ -320,7 +325,7 @@ export async function findPythonExecutable( const pythonPaths = pathEntries.filter(p => p.toLowerCase().includes('python')); const debugInfo = { - platform: process.platform, + platform, CI: process.env.CI, GITHUB_ACTIONS: process.env.GITHUB_ACTIONS, PATH_defined: !!process.env.PATH, @@ -345,7 +350,7 @@ export async function findPythonExecutable( // 1. User-specified path (if provided, prefer it regardless of debugpy) if (preferredPath) { try { - const resolved = await commandFinder.find(preferredPath); + const resolved = await commandFinder.find(preferredPath, platform); triedPaths.push(`${preferredPath} → ${resolved}`); if (!isWindows || await isValidPythonExecutable(resolved, logger)) { logger.debug?.(`[Python Detection] Using user-specified Python: ${resolved}`); @@ -364,7 +369,7 @@ export async function findPythonExecutable( const envPython = process.env.PYTHON_PATH || process.env.PYTHON_EXECUTABLE; if (envPython) { try { - const resolved = await commandFinder.find(envPython); + const resolved = await commandFinder.find(envPython, platform); triedPaths.push(`${envPython} → ${resolved}`); if (!isWindows || await isValidPythonExecutable(resolved, logger)) { logger.debug?.(`[Python Detection] Using environment variable Python: ${resolved}`); @@ -418,7 +423,7 @@ export async function findPythonExecutable( for (const cmd of pythonCommands) { logger.debug?.(`[Python Detection] Trying command: ${cmd}`); try { - const resolved = await commandFinder.find(cmd); + const resolved = await commandFinder.find(cmd, platform); triedPaths.push(`${cmd} → ${resolved}`); if (!isWindows || await isValidPythonExecutable(resolved, logger)) { // Don't return immediately, collect all valid ones @@ -459,7 +464,7 @@ export async function findPythonExecutable( // Log failure details (keep concise by default in CI) if (process.env.CI === 'true') { const failureInfo = { - platform: process.platform, + platform, triedPaths: triedPaths, validPythonPaths: validPythonPaths, PATH: process.env.PATH ? 'defined' : 'undefined', 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 58a82be3..f8da7a1c 100644 --- a/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts +++ b/packages/adapter-python/tests/unit/python-utils.comprehensive.test.ts @@ -115,7 +115,6 @@ describe('WhichCommandFinder integration', () => { describe('Windows platform behavior', () => { it('handles Path to PATH conversion on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); // NOTE: process.env is case-insensitive on Windows, so PATH and Path alias the // same key. Stub PATH (undefined) FIRST, then Path, so the Path value survives on // Windows; on case-sensitive platforms PATH stays unset and the code copies Path→PATH. @@ -130,16 +129,11 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; - try { - await findPythonExecutable(undefined, loggerMock); - expect(process.env.PATH).toBeDefined(); - } finally { - platformSpy.mockRestore(); - } + await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); + expect(process.env.PATH).toBeDefined(); }); it('filters Windows Store aliases', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); vi.stubEnv('PythonLocation', undefined); @@ -154,16 +148,11 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; - try { - const result = await findPythonExecutable(undefined, loggerMock); - expect(result).toBe('C:\\Python311\\python.exe'); - } finally { - platformSpy.mockRestore(); - } + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); + expect(result).toBe('C:\\Python311\\python.exe'); }); it('handles .exe extension on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -182,17 +171,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'win32'); expect(result).toBe('C:\\Python311\\python.exe'); expect(finder.find).toHaveBeenCalled(); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('logs verbose discovery information when DEBUG_PYTHON_DISCOVERY=true', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); vi.stubEnv('PATH', 'C:\\Python311;C:\\Windows'); vi.stubEnv('pythonLocation', undefined); @@ -209,7 +196,7 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow(); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'win32')).rejects.toThrow(); // Verbose discovery logs to console.log and console.error with [PYTHON_DISCOVERY_DEBUG] expect(consoleLogSpy).toHaveBeenCalledWith( '[PYTHON_DISCOVERY_DEBUG]', @@ -217,14 +204,12 @@ describe('WhichCommandFinder integration', () => { ); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); } }); it('detects Windows Store alias by stderr content', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -248,17 +233,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow('Python not found'); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'win32')).rejects.toThrow('Python not found'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); }); describe('Environment variable handling', () => { it('uses PYTHON_EXECUTABLE environment variable', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('PYTHON_EXECUTABLE', '/opt/python/bin/python3'); vi.stubEnv('PYTHON_PATH', undefined); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); @@ -268,16 +251,11 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; - try { - const result = await findPythonExecutable(undefined, loggerMock); - expect(result).toBe('/opt/python/bin/python3'); - } finally { - platformSpy.mockRestore(); - } + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'linux'); + expect(result).toBe('/opt/python/bin/python3'); }); it('uses PythonLocation (uppercase) environment variable on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const pythonRoot = 'C:\\PythonLocation\\3.11.9'; // NOTE: process.env is case-insensitive on Windows, so pythonLocation and // PythonLocation alias the same key. Stub the lowercase variant (undefined) FIRST, @@ -299,17 +277,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); expect(result).toBe(path.join(pythonRoot, 'python.exe')); } finally { setDefaultCommandFinder({ find: async () => { throw new CommandNotFoundError(''); } }); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('uses pythonLocation on non-Windows with bin subdirectory', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); const pythonRoot = '/opt/python/3.11.9'; vi.stubEnv('pythonLocation', pythonRoot); vi.stubEnv('PYTHON_PATH', undefined); @@ -327,19 +303,17 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'linux'); expect(result).toBe(path.join(pythonRoot, 'bin', 'python3')); } finally { setDefaultCommandFinder({ find: async () => { throw new CommandNotFoundError(''); } }); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); }); describe('preferredPath parameter', () => { it('returns preferredPath immediately when valid on non-Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); const finder: CommandFinder = { @@ -352,17 +326,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable('my-python', loggerMock, finder); + const result = await findPythonExecutable('my-python', loggerMock, finder, 'linux'); expect(result).toBe('/custom/path/my-python'); - expect(finder.find).toHaveBeenCalledWith('my-python'); + expect(finder.find).toHaveBeenCalledWith('my-python', 'linux'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('skips invalid preferredPath and continues discovery', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); vi.stubEnv('PythonLocation', undefined); @@ -382,16 +354,14 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable('invalid-python', loggerMock, finder); + const result = await findPythonExecutable('invalid-python', loggerMock, finder, 'linux'); expect(result).toBe('/usr/bin/python3'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('throws error when preferredPath finder throws non-CommandNotFoundError', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); const customError = new Error('Permission denied'); @@ -403,17 +373,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable('python', loggerMock, finder)).rejects.toThrow('Permission denied'); + await expect(findPythonExecutable('python', loggerMock, finder, 'linux')).rejects.toThrow('Permission denied'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); }); describe('Multiple Python installations with debugpy preference', () => { it('prefers Python with debugpy installed', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -442,17 +410,15 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'linux'); expect(result).toBe('/usr/local/bin/python'); expect(loggerMock.debug).toHaveBeenCalledWith(expect.stringContaining('Found Python with debugpy')); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('returns first valid Python when none have debugpy', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -471,19 +437,17 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'linux'); expect(result).toBe('/usr/bin/python3'); expect(loggerMock.debug).toHaveBeenCalledWith(expect.stringContaining('debugpy will need to be installed')); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); }); describe('Error scenarios', () => { it('throws error with tried paths when no Python found', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('PYTHON_PATH', undefined); vi.stubEnv('pythonLocation', undefined); @@ -492,15 +456,10 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; - try { - await expect(findPythonExecutable(undefined, loggerMock)).rejects.toThrow(/Python not found.*Tried:/s); - } finally { - platformSpy.mockRestore(); - } + await expect(findPythonExecutable(undefined, loggerMock, undefined, 'linux')).rejects.toThrow(/Python not found.*Tried:/s); }); it('logs detailed failure info in CI environment', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('CI', 'true'); vi.stubEnv('pythonLocation', undefined); @@ -509,14 +468,10 @@ describe('WhichCommandFinder integration', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; - try { - await expect(findPythonExecutable(undefined, loggerMock)).rejects.toThrow(); - expect(loggerMock.error).toHaveBeenCalledWith( - expect.stringContaining('[PYTHON_DISCOVERY_FAILED]') - ); - } finally { - platformSpy.mockRestore(); - } + await expect(findPythonExecutable(undefined, loggerMock, undefined, 'linux')).rejects.toThrow(); + expect(loggerMock.error).toHaveBeenCalledWith( + expect.stringContaining('[PYTHON_DISCOVERY_FAILED]') + ); }); }); }); @@ -614,7 +569,6 @@ describe('WhichCommandFinder class behavior', () => { }); it('handles spawn error when checking debugpy', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -632,17 +586,15 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'linux'); // Should still return first valid Python even when debugpy check errors expect(result).toBe('/usr/bin/python3'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('logs debug messages during Python discovery', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -659,16 +611,14 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await findPythonExecutable(undefined, loggerMock, finder); + await findPythonExecutable(undefined, loggerMock, finder, 'linux'); expect(loggerMock.debug).toHaveBeenCalledWith(expect.stringContaining('[Python Detection]')); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('handles Windows Store alias detected by AppData path in stderr', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -690,16 +640,14 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow('Python not found'); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'win32')).rejects.toThrow('Python not found'); expect(loggerMock.error).toHaveBeenCalledWith(expect.stringContaining('Windows Store alias')); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('handles errors other than CommandNotFoundError in environment variable lookup', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('PYTHON_EXECUTABLE', '/invalid/python'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); @@ -712,15 +660,13 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow('Invalid path'); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'linux')).rejects.toThrow('Invalid path'); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('checks all pythonLocation candidates on non-Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); const pythonRoot = '/opt/python/3.11.9'; vi.stubEnv('pythonLocation', pythonRoot); vi.stubEnv('PYTHON_PATH', undefined); @@ -739,17 +685,15 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'linux'); expect(result).toBe(path.join(pythonRoot, 'python')); } finally { setDefaultCommandFinder({ find: async () => { throw new CommandNotFoundError(''); } }); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('continues to next pythonLocation candidate when validation fails', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const pythonRoot = 'C:\\Python311'; vi.stubEnv('pythonLocation', pythonRoot); vi.stubEnv('PYTHON_PATH', undefined); @@ -774,12 +718,11 @@ describe('WhichCommandFinder class behavior', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); expect(result).toBe(path.join(pythonRoot, 'python')); } finally { setDefaultCommandFinder({ find: async () => { throw new CommandNotFoundError(''); } }); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); }); @@ -794,7 +737,6 @@ describe('Additional edge cases', () => { }); it('handles errors during auto-detect loop', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); vi.stubEnv('PYTHON_PATH', undefined); @@ -811,15 +753,13 @@ describe('Additional edge cases', () => { try { // Should handle the unexpected error and continue to next command - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow(); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'linux')).rejects.toThrow(); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('handles Windows Store alias detected by "Windows Store" in stderr', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); @@ -837,15 +777,13 @@ describe('Additional edge cases', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow(); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'win32')).rejects.toThrow(); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('validates Python executable on Windows before returning', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('PYTHON_PATH', 'C:\\Python\\python.exe'); @@ -859,18 +797,16 @@ describe('Additional edge cases', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'win32'); expect(result).toBe('C:\\Python\\python.exe'); // Verify validation was called expect(spawnMock).toHaveBeenCalled(); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('checks multiple pythonLocation candidates when first does not exist', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const pythonRoot = 'C:\\Python311'; vi.stubEnv('pythonLocation', pythonRoot); vi.stubEnv('PYTHON_PATH', undefined); @@ -891,18 +827,16 @@ describe('Additional edge cases', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); expect(result).toBe(path.join(pythonRoot, 'python')); expect(fsExists).toHaveBeenCalled(); } finally { setDefaultCommandFinder({ find: async () => { throw new CommandNotFoundError(''); } }); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('returns first valid Python when collecting multiple candidates', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); vi.stubEnv('pythonLocation', undefined); vi.stubEnv('PYTHON_PATH', undefined); @@ -922,12 +856,11 @@ describe('Additional edge cases', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock, finder); + const result = await findPythonExecutable(undefined, loggerMock, finder, 'linux'); expect(result).toBe('/usr/bin/python3'); expect(loggerMock.debug).toHaveBeenCalledWith(expect.stringContaining('debugpy will need to be installed')); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); }); @@ -943,7 +876,6 @@ describe('Verbose discovery logging', () => { }); it('logs verbose discovery info when DEBUG_PYTHON_DISCOVERY=true on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); vi.stubEnv('CI', 'true'); vi.stubEnv('GITHUB_ACTIONS', 'true'); @@ -968,7 +900,7 @@ describe('Verbose discovery logging', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await findPythonExecutable(undefined, loggerMock, finder); + await findPythonExecutable(undefined, loggerMock, finder, 'win32'); expect(consoleLogSpy).toHaveBeenCalledWith( '[PYTHON_DISCOVERY_DEBUG]', expect.stringContaining('platform') @@ -979,14 +911,12 @@ describe('Verbose discovery logging', () => { ); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); } }); it('logs PATH issues when detected', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); vi.stubEnv('PATH', 'C:\\Path1;;C:\\Path2'); // Empty entry vi.stubEnv('pythonLocation', undefined); @@ -1004,7 +934,7 @@ describe('Verbose discovery logging', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow(); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'win32')).rejects.toThrow(); // When Python is not found, verbose discovery logs basic info expect(consoleLogSpy).toHaveBeenCalledWith( '[PYTHON_DISCOVERY_DEBUG]', @@ -1012,14 +942,12 @@ describe('Verbose discovery logging', () => { ); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); consoleErrorSpy.mockRestore(); consoleLogSpy.mockRestore(); } }); it('logs Python PATH entries when found', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); vi.stubEnv('PATH', 'C:\\Python311;C\\Windows;C:\\Python39'); vi.stubEnv('pythonLocation', undefined); @@ -1041,19 +969,17 @@ describe('Verbose discovery logging', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await findPythonExecutable(undefined, loggerMock, finder); + await findPythonExecutable(undefined, loggerMock, finder, 'win32'); expect(consoleErrorSpy).toHaveBeenCalledWith( '[PYTHON_DISCOVERY_DEBUG] Python PATH entries found:' ); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); consoleErrorSpy.mockRestore(); } }); it('logs verbose failure info when discovery fails in CI with DEBUG enabled', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'true'); vi.stubEnv('CI', 'true'); vi.stubEnv('PATH', '/usr/bin'); @@ -1072,7 +998,7 @@ describe('Verbose discovery logging', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock, finder)).rejects.toThrow(); + await expect(findPythonExecutable(undefined, loggerMock, finder, 'linux')).rejects.toThrow(); // Verify verbose failure logging expect(consoleLogSpy).toHaveBeenCalledWith( '[PYTHON_DISCOVERY_FAILED]', @@ -1084,7 +1010,6 @@ describe('Verbose discovery logging', () => { ); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); consoleLogSpy.mockRestore(); consoleErrorSpy.mockRestore(); } diff --git a/packages/adapter-python/tests/unit/python-utils.discovery.test.ts b/packages/adapter-python/tests/unit/python-utils.discovery.test.ts index 306c0fe4..68409a0d 100644 --- a/packages/adapter-python/tests/unit/python-utils.discovery.test.ts +++ b/packages/adapter-python/tests/unit/python-utils.discovery.test.ts @@ -56,7 +56,6 @@ describe('python-utils discovery behaviour', () => { }); it('auto-detects python from PATH on non-Windows when debugpy present', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); const finder: CommandFinder = { @@ -73,18 +72,16 @@ describe('python-utils discovery behaviour', () => { spawnMock.mockImplementation(() => createSpawn({ exitCode: 0, stdout: '1.8.17' })); try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'linux'); expect(result).toBe('/usr/bin/python3'); - expect(finder.find).toHaveBeenCalledWith('python3'); + expect(finder.find).toHaveBeenCalledWith('python3', 'linux'); expect(spawnMock).toHaveBeenCalled(); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); it('prefers pythonLocation when available on Windows', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); const pythonRoot = 'C:\\HostedPython\\3.11.9\\x64'; vi.stubEnv('pythonLocation', pythonRoot); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); @@ -111,7 +108,7 @@ describe('python-utils discovery behaviour', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); expect(result).toBe(path.join(pythonRoot, 'python.exe')); expect(spawnMock).toHaveBeenCalledWith( expect.stringContaining('python.exe'), @@ -121,12 +118,10 @@ describe('python-utils discovery behaviour', () => { } finally { setDefaultCommandFinder(previousFinder); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('uses PYTHON_PATH environment variable when provided', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('PYTHON_PATH', 'C:\\CustomPython\\python.exe'); vi.stubEnv('pythonLocation', undefined); @@ -155,18 +150,16 @@ describe('python-utils discovery behaviour', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'win32'); expect(result).toBe(process.env.PYTHON_PATH); - expect(finder.find).toHaveBeenCalledWith(process.env.PYTHON_PATH); + expect(finder.find).toHaveBeenCalledWith(process.env.PYTHON_PATH, 'win32'); } finally { setDefaultCommandFinder(previousFinder); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('reports discovery failure details through logger', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); vi.stubEnv('pythonLocation', undefined); vi.stubEnv('PYTHON_PATH', undefined); vi.stubEnv('PATH', ''); @@ -184,19 +177,17 @@ describe('python-utils discovery behaviour', () => { const loggerMock = { error: vi.fn(), debug: vi.fn() }; try { - await expect(findPythonExecutable(undefined, loggerMock)).rejects.toThrow('Python not found'); + await expect(findPythonExecutable(undefined, loggerMock, undefined, 'win32')).rejects.toThrow('Python not found'); expect(loggerMock.error).toHaveBeenCalledWith( expect.stringContaining('[PYTHON_DISCOVERY_FAILED]') ); } finally { setDefaultCommandFinder(previousFinder); fsExists.mockRestore(); - platformSpy.mockRestore(); } }); it('falls back to first valid python when debugpy is missing', async () => { - const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); vi.stubEnv('DEBUG_PYTHON_DISCOVERY', 'false'); const finder: CommandFinder = { @@ -214,12 +205,11 @@ describe('python-utils discovery behaviour', () => { .mockImplementationOnce(() => createSpawn({ exitCode: 1, stderr: 'ModuleNotFoundError: debugpy' })); try { - const result = await findPythonExecutable(undefined, loggerMock); + const result = await findPythonExecutable(undefined, loggerMock, undefined, 'linux'); expect(result).toBe('/usr/bin/python3'); expect(spawnMock).toHaveBeenCalledTimes(2); } finally { setDefaultCommandFinder(previousFinder); - platformSpy.mockRestore(); } }); }); diff --git a/packages/adapter-ruby/src/utils/ruby-utils.ts b/packages/adapter-ruby/src/utils/ruby-utils.ts index bb6d3362..4d473b4b 100644 --- a/packages/adapter-ruby/src/utils/ruby-utils.ts +++ b/packages/adapter-ruby/src/utils/ruby-utils.ts @@ -78,11 +78,11 @@ async function findExecutable( throw new Error(`${label} not found. Tried: ${tried.join(', ')}`); } -export function getRubySearchPaths(): string[] { +export function getRubySearchPaths(platform: NodeJS.Platform = process.platform): string[] { const paths: string[] = []; const home = process.env.HOME || process.env.USERPROFILE || ''; - if (process.platform === 'win32') { + if (platform === 'win32') { paths.push( 'C:\\Ruby31-x64\\bin', 'C:\\Ruby32-x64\\bin', @@ -90,7 +90,7 @@ export function getRubySearchPaths(): string[] { 'C:\\Ruby34-x64\\bin', path.join(home, 'scoop', 'apps', 'ruby', 'current', 'bin') ); - } else if (process.platform === 'darwin') { + } else if (platform === 'darwin') { paths.push( '/usr/local/bin', '/opt/homebrew/bin', @@ -111,11 +111,11 @@ export function getRubySearchPaths(): string[] { return Array.from(new Set(paths.filter(Boolean))); } -export function getRdbgSearchPaths(): string[] { +export function getRdbgSearchPaths(platform: NodeJS.Platform = process.platform): string[] { const paths: string[] = []; const home = process.env.HOME || process.env.USERPROFILE || ''; - if (process.platform === 'win32') { + if (platform === 'win32') { paths.push( // RubyInstaller ships rdbg (bundled debug gem) alongside ruby.exe 'C:\\Ruby31-x64\\bin', @@ -143,20 +143,22 @@ export function getRdbgSearchPaths(): string[] { export async function findRubyExecutable( preferredPath?: string, - logger: Logger = noopLogger + logger: Logger = noopLogger, + platform: NodeJS.Platform = process.platform ): Promise { const envRuby = process.env.RUBY_PATH || process.env.RUBY_EXECUTABLE; - const candidates = process.platform === 'win32' ? ['ruby.exe', 'ruby'] : ['ruby']; - return findExecutable(preferredPath, envRuby, candidates, getRubySearchPaths(), 'Ruby', logger); + const candidates = platform === 'win32' ? ['ruby.exe', 'ruby'] : ['ruby']; + return findExecutable(preferredPath, envRuby, candidates, getRubySearchPaths(platform), 'Ruby', logger); } export async function findRdbgExecutable( preferredPath?: string, - logger: Logger = noopLogger + logger: Logger = noopLogger, + platform: NodeJS.Platform = process.platform ): Promise { const envRdbg = process.env.RDBG_PATH; - const candidates = process.platform === 'win32' ? ['rdbg.bat', 'rdbg.cmd', 'rdbg.exe', 'rdbg'] : ['rdbg']; - return findExecutable(preferredPath, envRdbg, candidates, getRdbgSearchPaths(), 'rdbg', logger); + const candidates = platform === 'win32' ? ['rdbg.bat', 'rdbg.cmd', 'rdbg.exe', 'rdbg'] : ['rdbg']; + return findExecutable(preferredPath, envRdbg, candidates, getRdbgSearchPaths(platform), 'rdbg', logger); } export interface RdbgInvocation { diff --git a/packages/adapter-ruby/tests/unit/ruby-utils.test.ts b/packages/adapter-ruby/tests/unit/ruby-utils.test.ts index c57aa745..f542a149 100644 --- a/packages/adapter-ruby/tests/unit/ruby-utils.test.ts +++ b/packages/adapter-ruby/tests/unit/ruby-utils.test.ts @@ -18,35 +18,25 @@ import { const whichMock = vi.mocked(which) as unknown as ReturnType; -const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform')!; -const setPlatform = (platform: string) => - Object.defineProperty(process, 'platform', { value: platform }); - describe('search paths', () => { - afterEach(() => Object.defineProperty(process, 'platform', originalPlatform)); - it('includes RubyInstaller bin dirs for both ruby and rdbg on Windows', () => { - setPlatform('win32'); // Regression: rdbg search paths originally omitted the RubyInstaller dirs, // so a standard install found ruby but not rdbg. - expect(getRubySearchPaths()).toContain('C:\\Ruby34-x64\\bin'); - expect(getRdbgSearchPaths()).toContain('C:\\Ruby34-x64\\bin'); + expect(getRubySearchPaths('win32')).toContain('C:\\Ruby34-x64\\bin'); + expect(getRdbgSearchPaths('win32')).toContain('C:\\Ruby34-x64\\bin'); }); it('includes Homebrew paths on macOS', () => { - setPlatform('darwin'); - expect(getRubySearchPaths()).toContain('/opt/homebrew/bin'); + expect(getRubySearchPaths('darwin')).toContain('/opt/homebrew/bin'); }); it('includes system and gem paths on Linux', () => { - setPlatform('linux'); - expect(getRubySearchPaths()).toContain('/usr/bin'); - expect(getRdbgSearchPaths()).toContain('/usr/local/bin'); + expect(getRubySearchPaths('linux')).toContain('/usr/bin'); + expect(getRdbgSearchPaths('linux')).toContain('/usr/local/bin'); }); it('appends PATH entries and de-duplicates', () => { - setPlatform('linux'); - const paths = getRubySearchPaths(); + const paths = getRubySearchPaths('linux'); expect(new Set(paths).size).toBe(paths.length); }); }); diff --git a/packages/adapter-rust/src/rust-debug-adapter.ts b/packages/adapter-rust/src/rust-debug-adapter.ts index ce6f3fbd..391350db 100644 --- a/packages/adapter-rust/src/rust-debug-adapter.ts +++ b/packages/adapter-rust/src/rust-debug-adapter.ts @@ -112,7 +112,11 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { private currentThreadId: number | null = null; private connected = false; - constructor(dependencies: AdapterDependencies) { + constructor( + dependencies: AdapterDependencies, + /** Platform override for tests (issue #186); defaults to the real platform. */ + private readonly platform: NodeJS.Platform = process.platform + ) { super(); this.dependencies = dependencies; this.msvcBehavior = this.resolveMsvcBehavior(); @@ -229,8 +233,8 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { ] .filter(Boolean) .join(' '); - if (process.platform === 'win32') { - const dlltoolPath = await findDlltoolExecutable(); + if (this.platform === 'win32') { + const dlltoolPath = await findDlltoolExecutable(this.platform); if (dlltoolPath) { this.dlltoolPath = dlltoolPath; } else if (/-pc-windows-gnu/i.test(gnuSignals)) { @@ -252,7 +256,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { } // Check for Windows-specific requirements - if (process.platform === 'win32') { + if (this.platform === 'win32') { // Check for MSVC runtime (optional but recommended for native debugging) const hasMSVC = process.env['VCINSTALLDIR'] || process.env['VS140COMNTOOLS']; if (!hasMSVC) { @@ -398,13 +402,13 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { const rustupHome = process.env.RUSTUP_HOME || path.join(process.env.HOME || '', '.rustup'); const cargoHome = process.env.CARGO_HOME || path.join(process.env.HOME || '', '.cargo'); - if (process.platform === 'win32') { + if (this.platform === 'win32') { paths.push( path.join(cargoHome, 'bin'), path.join(rustupHome, 'toolchains', 'stable-x86_64-pc-windows-msvc', 'bin'), 'C:\\Program Files\\Rust\\bin' ); - } else if (process.platform === 'darwin') { + } else if (this.platform === 'darwin') { paths.push( path.join(cargoHome, 'bin'), path.join(rustupHome, 'toolchains', 'stable-x86_64-apple-darwin', 'bin'), @@ -508,7 +512,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { } private prepareCodelldbExecutablePath(originalPath: string | null): string | null { - if (!originalPath || process.platform !== 'win32' || !originalPath.includes(' ')) { + if (!originalPath || this.platform !== 'win32' || !originalPath.includes(' ')) { return originalPath; } @@ -677,7 +681,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { const args = ['--port', String(config.adapterPort)]; // Point codelldb at the vendored liblldb so it can locate its Python runtime. - const libExt = process.platform === 'darwin' ? '.dylib' : process.platform === 'win32' ? '.dll' : '.so'; + const libExt = this.platform === 'darwin' ? '.dylib' : this.platform === 'win32' ? '.dll' : '.so'; const liblldbPath = path.resolve(path.dirname(codelldbPath), '..', 'lldb', 'bin', `liblldb${libExt}`); if (existsSync(liblldbPath)) { args.push('--liblldb', liblldbPath); @@ -689,7 +693,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { const env: Record = { ...process.env as Record }; // Windows: Enable native PDB reader for MSVC-compiled Rust - if (process.platform === 'win32') { + if (this.platform === 'win32') { env.LLDB_USE_NATIVE_PDB_READER = '1'; if (this.dlltoolPath && !env.DLLTOOL) { env.DLLTOOL = this.dlltoolPath; @@ -724,7 +728,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { private resolveCodeLLDBExecutableSync(): string | null { // Determine platform directory (same logic as async resolver) - const platform = process.platform; + const platform = this.platform; const arch = process.arch; let platformDir = ''; @@ -830,7 +834,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { // Determine binary path const binaryName = await getDefaultBinary(projectRoot); const buildMode = rustConfig.cargo?.release ? 'release' : 'debug'; - const extension = process.platform === 'win32' ? '.exe' : ''; + const extension = this.platform === 'win32' ? '.exe' : ''; const binaryPath = path.join( projectRoot, 'target', @@ -887,7 +891,7 @@ export class RustDebugAdapter extends EventEmitter implements IDebugAdapter { this.dependencies.logger?.warn('[RustDebugAdapter] No binary specified, defaulting to "main"'); } - const extension = process.platform === 'win32' ? '.exe' : ''; + const extension = this.platform === 'win32' ? '.exe' : ''; launchConfig.program = path.join(targetDir, `${binaryName}${extension}`); } else { throw new AdapterError( diff --git a/packages/adapter-rust/src/utils/rust-utils.ts b/packages/adapter-rust/src/utils/rust-utils.ts index b646153d..ddba22c1 100644 --- a/packages/adapter-rust/src/utils/rust-utils.ts +++ b/packages/adapter-rust/src/utils/rust-utils.ts @@ -152,15 +152,16 @@ export async function buildRustProject( export async function getRustBinaryPath( projectPath: string, binaryName: string, - release: boolean = false + release: boolean = false, + platform: NodeJS.Platform = process.platform ): Promise { const targetDir = path.join( projectPath, 'target', release ? 'release' : 'debug' ); - - const extension = process.platform === 'win32' ? '.exe' : ''; + + const extension = platform === 'win32' ? '.exe' : ''; const binaryPath = path.join(targetDir, `${binaryName}${extension}`); try { @@ -208,7 +209,9 @@ export async function getRustHostTriple(): Promise { /** * Attempt to locate dlltool.exe in PATH, DLLTOOL env override, or rustup toolchains. */ -export async function findDlltoolExecutable(): Promise { +export async function findDlltoolExecutable( + platform: NodeJS.Platform = process.platform +): Promise { const explicit = process.env.DLLTOOL; if (explicit) { try { @@ -228,7 +231,7 @@ export async function findDlltoolExecutable(): Promise { // not on PATH } - if (process.platform === 'win32') { + if (platform === 'win32') { const rustupHome = process.env.RUSTUP_HOME || path.join(os.homedir(), '.rustup'); const toolchainsDir = path.join(rustupHome, 'toolchains'); 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 7f396dec..553a096c 100644 --- a/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts +++ b/packages/adapter-rust/tests/rust-debug-adapter.toolchain.test.ts @@ -75,14 +75,6 @@ const createDependencies = (): AdapterDependencies => ({ } }); -const setPlatform = (platform: NodeJS.Platform): (() => void) => { - const original = process.platform; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - return () => { - Object.defineProperty(process, 'platform', { value: original, configurable: true }); - }; -}; - describe('RustDebugAdapter toolchain logic', () => { let adapter: RustDebugAdapter; let dependencies: AdapterDependencies; @@ -168,41 +160,37 @@ describe('RustDebugAdapter toolchain logic', () => { }); it('warns when dlltool is missing for GNU toolchain on Windows', async () => { - const restorePlatform = setPlatform('win32'); + const winAdapter = new RustDebugAdapter(dependencies, 'win32'); vi.mocked(resolveCodeLLDBExecutable).mockResolvedValueOnce('C:\\\\codelldb.exe'); checkCargoInstallation.mockResolvedValueOnce(true); checkRustInstallation.mockResolvedValueOnce(true); getRustHostTriple.mockResolvedValueOnce('x86_64-pc-windows-gnu'); findDlltoolExecutable.mockResolvedValueOnce(undefined); - try { - const result = await adapter.validateEnvironment(); - expect(result.valid).toBe(true); - const warningCodes = result.warnings?.map((warning) => warning.code); - expect(warningCodes).toContain('DLLTOOL_NOT_FOUND'); - } finally { - restorePlatform(); - } + const result = await winAdapter.validateEnvironment(); + expect(result.valid).toBe(true); + const warningCodes = result.warnings?.map((warning) => warning.code); + expect(warningCodes).toContain('DLLTOOL_NOT_FOUND'); }); }); describe('buildAdapterCommand environment wiring', () => { it('injects dlltool path into environment when available on Windows', () => { - const restorePlatform = setPlatform('win32'); + const winAdapter = new RustDebugAdapter(dependencies, 'win32'); vi.stubEnv('PATH', '/usr/bin'); vi.stubEnv('DLLTOOL', undefined); - const adapterWithMethod = adapter as unknown as { + const adapterWithMethod = winAdapter as unknown as { resolveCodeLLDBExecutableSync: () => string | null; }; const resolveSpy = vi .spyOn(adapterWithMethod, 'resolveCodeLLDBExecutableSync') .mockReturnValue('C:\\\\CodeLLDB\\\\adapter\\\\codelldb.exe'); - (adapter as unknown as { dlltoolPath?: string }).dlltoolPath = './dlltool.exe'; + (winAdapter as unknown as { dlltoolPath?: string }).dlltoolPath = './dlltool.exe'; try { - const command = adapter.buildAdapterCommand({ + const command = winAdapter.buildAdapterCommand({ sessionId: 'session', executablePath: 'cargo', adapterHost: '127.0.0.1', @@ -218,7 +206,6 @@ describe('RustDebugAdapter toolchain logic', () => { expect(command.args).toEqual(['--port', '4000']); } finally { resolveSpy.mockRestore(); - restorePlatform(); } }); }); @@ -421,10 +408,10 @@ describe('RustDebugAdapter toolchain logic', () => { }); it('derives executable search paths per platform', () => { - const restoreLinux = setPlatform('linux'); + const linuxAdapter = new RustDebugAdapter(dependencies, 'linux'); vi.stubEnv('HOME', '/tmp/tester'); vi.stubEnv('PATH', '/usr/bin:/usr/local/bin'); - const searchPaths = adapter + const searchPaths = linuxAdapter .getExecutableSearchPaths() .map((entry) => entry.replace(/\\/g, '/')); expect(searchPaths).toEqual( @@ -435,16 +422,14 @@ describe('RustDebugAdapter toolchain logic', () => { ); expect(searchPaths.some((entry) => entry.includes('/usr/bin'))).toBe(true); expect(searchPaths.some((entry) => entry.includes('/usr/local/bin'))).toBe(true); - restoreLinux(); - const restoreWindows = setPlatform('win32'); + const winAdapter = new RustDebugAdapter(dependencies, 'win32'); vi.stubEnv('HOME', 'C:\\Users\\tester'); vi.stubEnv('RUSTUP_HOME', 'C:\\Rustup'); vi.stubEnv('CARGO_HOME', 'C:\\Cargo'); - const windowsPaths = adapter.getExecutableSearchPaths(); + const windowsPaths = winAdapter.getExecutableSearchPaths(); expect(windowsPaths.some((entry) => entry.includes('Cargo'))).toBe(true); expect(windowsPaths.some((entry) => entry.includes('Program Files'))).toBe(true); - restoreWindows(); }); it('scrubs python variables when configuring embedded environment', async () => { @@ -480,7 +465,7 @@ describe('RustDebugAdapter toolchain logic', () => { }); it('sanitizes CodeLLDB paths containing spaces on Windows', async () => { - const restorePlatform = setPlatform('win32'); + const winAdapter = new RustDebugAdapter(dependencies, 'win32'); const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codelldb-src-')); const platformDir = path.join(baseDir, 'platform dir'); const adapterDir = path.join(platformDir, 'adapter'); @@ -489,7 +474,7 @@ describe('RustDebugAdapter toolchain logic', () => { await fs.writeFile(exePath, 'binary'); await fs.writeFile(path.join(platformDir, 'version.json'), '"1.0"'); - const sanitized = (adapter as unknown as { + const sanitized = (winAdapter as unknown as { prepareCodelldbExecutablePath: (path: string) => string | null; }).prepareCodelldbExecutablePath(exePath); @@ -498,7 +483,6 @@ describe('RustDebugAdapter toolchain logic', () => { await fs.rm(path.join(os.tmpdir(), 'debug-mcp-codelldb'), { recursive: true, force: true }); await fs.rm(baseDir, { recursive: true, force: true }); - restorePlatform(); }); it('exposes adapter metadata helpers', () => { diff --git a/packages/adapter-rust/tests/rust-utils.test.ts b/packages/adapter-rust/tests/rust-utils.test.ts index 1cd63fb5..7890f7f7 100644 --- a/packages/adapter-rust/tests/rust-utils.test.ts +++ b/packages/adapter-rust/tests/rust-utils.test.ts @@ -55,12 +55,6 @@ const createTempDir = async (name: string): Promise => { return dir; }; -const overridePlatform = (platform: NodeJS.Platform) => { - const original = process.platform; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - return () => Object.defineProperty(process, 'platform', { value: original, configurable: true }); -}; - beforeEach(() => { spawnMock.mockReset(); whichMock.mockReset(); @@ -174,13 +168,14 @@ describe('rust-utils filesystem helpers', () => { const project = await createTempDir('binary'); const targetDir = path.join(project, 'target', 'debug'); await fs.mkdir(targetDir, { recursive: true }); - const binPath = path.join(targetDir, process.platform === 'win32' ? 'app.exe' : 'app'); + const binPath = path.join(targetDir, 'app.exe'); await fs.writeFile(binPath, ''); - await expect(rustUtils.getRustBinaryPath(project, 'app')).resolves.toBe(binPath); + await expect(rustUtils.getRustBinaryPath(project, 'app', false, 'win32')).resolves.toBe(binPath); + await expect(rustUtils.getRustBinaryPath(project, 'app', false, 'linux')).resolves.toBeNull(); await fs.rm(binPath); - await expect(rustUtils.getRustBinaryPath(project, 'app')).resolves.toBeNull(); + await expect(rustUtils.getRustBinaryPath(project, 'app', false, 'win32')).resolves.toBeNull(); }); }); @@ -200,7 +195,6 @@ describe('findDlltoolExecutable', () => { }); it('scans rustup toolchains on Windows platforms', async () => { - const restorePlatform = overridePlatform('win32'); const rustupHome = await createTempDir('rustup'); vi.stubEnv('RUSTUP_HOME', rustupHome); @@ -218,8 +212,6 @@ describe('findDlltoolExecutable', () => { whichMock.mockRejectedValueOnce(new Error('not found')); - await expect(rustUtils.findDlltoolExecutable()).resolves.toBe(candidate); - - restorePlatform(); + await expect(rustUtils.findDlltoolExecutable('win32')).resolves.toBe(candidate); }); }); diff --git a/packages/shared/src/interfaces/adapter-policy-rust.ts b/packages/shared/src/interfaces/adapter-policy-rust.ts index e6fe7ab7..7b642b97 100644 --- a/packages/shared/src/interfaces/adapter-policy-rust.ts +++ b/packages/shared/src/interfaces/adapter-policy-rust.ts @@ -263,7 +263,7 @@ export const RustAdapterPolicy: AdapterPolicy = { /** * Get the configuration for spawning the Rust debug adapter (CodeLLDB) */ - getAdapterSpawnConfig: (payload) => { + getAdapterSpawnConfig: (payload, platform: NodeJS.Platform = process.platform, arch: NodeJS.Architecture = process.arch) => { // If a custom adapter command was provided, use it directly if (payload.adapterCommand) { return { @@ -278,9 +278,6 @@ export const RustAdapterPolicy: AdapterPolicy = { } // Otherwise, use the vendored CodeLLDB - const platform = process.platform; - const arch = process.arch; - let platformDir = ''; if (platform === 'win32') { platformDir = arch === 'arm64' ? 'win32-arm64' : 'win32-x64'; diff --git a/packages/shared/src/interfaces/adapter-policy.ts b/packages/shared/src/interfaces/adapter-policy.ts index 9c35ba24..0625aed7 100644 --- a/packages/shared/src/interfaces/adapter-policy.ts +++ b/packages/shared/src/interfaces/adapter-policy.ts @@ -346,9 +346,11 @@ export interface AdapterPolicy { * listening — e.g. attach to a remote rdbg — so connect directly without * spawning anything). * @param payload The initialization payload containing ports, paths, etc. + * @param platform Platform override for tests (issue #186); implementations default it to process.platform + * @param arch Architecture override for tests (issue #186); implementations default it to process.arch * @returns Spawn or connect configuration, or undefined if not applicable */ - getAdapterSpawnConfig?(payload: AdapterSpawnPayload): AdapterSpawnConfig | undefined; + getAdapterSpawnConfig?(payload: AdapterSpawnPayload, platform?: NodeJS.Platform, arch?: NodeJS.Architecture): AdapterSpawnConfig | undefined; } /** diff --git a/packages/shared/tests/unit/adapter-policy-rust.test.ts b/packages/shared/tests/unit/adapter-policy-rust.test.ts index a9240740..6248ca18 100644 --- a/packages/shared/tests/unit/adapter-policy-rust.test.ts +++ b/packages/shared/tests/unit/adapter-policy-rust.test.ts @@ -20,17 +20,6 @@ vi.mock('child_process', async () => { }; }); -const setPlatform = (platform: NodeJS.Platform, arch: NodeJS.Architecture = process.arch) => { - const originalPlatform = process.platform; - const originalArch = process.arch; - Object.defineProperty(process, 'platform', { value: platform, configurable: true }); - Object.defineProperty(process, 'arch', { value: arch, configurable: true }); - return () => { - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - Object.defineProperty(process, 'arch', { value: originalArch, configurable: true }); - }; -}; - describe('RustAdapterPolicy', () => { beforeEach(() => { accessMock.mockReset(); @@ -174,18 +163,16 @@ describe('RustAdapterPolicy', () => { }); it('builds vendored codelldb command per platform', () => { - const restore = setPlatform('win32'); const config = RustAdapterPolicy.getAdapterSpawnConfig!({ adapterHost: '127.0.0.1', adapterPort: 9000, logDir: '/tmp/logs' - }); + }, 'win32', 'x64'); const normalizedCommand = config.command.replace(/\\/g, '/'); expect(normalizedCommand).toMatch(/vendor\/codelldb\/win32-x64\/adapter\/codelldb\.exe$/); expect(config.args).toEqual(['--port', '9000']); expect(config.env?.LLDB_USE_NATIVE_PDB_READER).toBe('1'); - restore(); }); });