diff --git a/src/runtime/node.ts b/src/runtime/node.ts index 3d327d9..36d616d 100644 --- a/src/runtime/node.ts +++ b/src/runtime/node.ts @@ -162,6 +162,10 @@ function resolveVirtualEnv( * Get the environment variable key for PATH (case-insensitive on Windows). */ function getPathKey(env: Record): string { + if (Object.prototype.hasOwnProperty.call(env, 'PATH')) { + return 'PATH'; + } + for (const key of Object.keys(env)) { if (key.toLowerCase() === 'path') { return key; @@ -170,6 +174,20 @@ function getPathKey(env: Record): string { return 'PATH'; } +function setPathValue(env: Record, value: string): void { + setEnvValue(env, 'PATH', value); + + if (process.platform !== 'win32') { + return; + } + + for (const key of Object.keys(env)) { + if (key !== 'PATH' && key.toLowerCase() === 'path') { + setEnvValue(env, key, value); + } + } +} + const DANGEROUS_ENV_OVERRIDE_KEYS = new Set(['__proto__', 'prototype', 'constructor']); function createNullPrototypeEnv(): Record { @@ -674,8 +692,8 @@ function buildProcessEnv(options: ResolvedOptions): Record { const venv = resolveVirtualEnv(options.virtualEnv, options.cwd); env.VIRTUAL_ENV = venv.venvPath; const pathKey = getPathKey(env); - const currentPath = getEnvValue(env, pathKey) ?? ''; - setEnvValue(env, pathKey, `${venv.binDir}${delimiter}${currentPath}`); + const currentPath = getEnvValue(env, pathKey); + setPathValue(env, currentPath ? `${venv.binDir}${delimiter}${currentPath}` : venv.binDir); } // Add cwd to PYTHONPATH so Python can find modules in the working directory diff --git a/test/cli.test.ts b/test/cli.test.ts index 5d7549c..e752dbc 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -377,7 +377,9 @@ describe('CLI', () => { } }); - it('accepts --format flag with valid values', () => { + it( + 'accepts --format flag with valid values', + () => { const tempDir = mkdtempSync(join(tmpdir(), 'tywrap-cli-')); try { for (const format of ['esm', 'cjs', 'both']) { @@ -396,7 +398,9 @@ describe('CLI', () => { } finally { rmSync(tempDir, { recursive: true, force: true }); } - }); + }, + 15000 + ); it('rejects --format flag with invalid value', () => { const tempDir = mkdtempSync(join(tmpdir(), 'tywrap-cli-')); @@ -519,7 +523,9 @@ describe('CLI', () => { } }); - it('supports --check without writing files', () => { + it( + 'supports --check without writing files', + () => { const repoRoot = join(__dirname, '..'); const tempDir = mkdtempSync(join(tmpdir(), 'tywrap-cli-')); const outputDir = join(tempDir, 'generated'); @@ -575,7 +581,9 @@ describe('CLI', () => { } finally { rmSync(tempDir, { recursive: true, force: true }); } - }); + }, + 15000 + ); it('fails with actionable errors when a module cannot be imported', () => { const repoRoot = join(__dirname, '..'); diff --git a/test/parallel-processor.test.ts b/test/parallel-processor.test.ts index cd9e716..bd9b9b0 100644 --- a/test/parallel-processor.test.ts +++ b/test/parallel-processor.test.ts @@ -631,9 +631,26 @@ parentPort.on('message', (message) => { loadBalancing: 'round-robin', }); + await processor.init(); + + const processorInternals = processor as unknown as { + workers: Map; + selectOptimalWorker: () => { workerId: string; worker: unknown } | null; + }; + const workerSelections = Array.from(processorInternals.workers.entries()).map( + ([workerId, worker]) => ({ workerId, worker }) + ); + expect(workerSelections).toHaveLength(2); + + const fallbackSelection = processorInternals.selectOptimalWorker.bind(processor); + vi.spyOn(processorInternals, 'selectOptimalWorker') + .mockImplementationOnce(() => workerSelections[0] ?? null) + .mockImplementationOnce(() => workerSelections[1] ?? null) + .mockImplementation(fallbackSelection); + const results = await processor.executeTasks([ - { id: 'crash', type: 'custom', data: { action: 'crash' } }, - { id: 'ok', type: 'custom', data: { action: 'ok' } }, + { id: 'crash', type: 'custom', data: { action: 'crash' }, priority: 2 }, + { id: 'ok', type: 'custom', data: { action: 'ok' }, priority: 1 }, ]); const byId = Object.fromEntries(results.map(r => [r.taskId, r])); diff --git a/test/runtime_bridge_fixtures.test.ts b/test/runtime_bridge_fixtures.test.ts index 5f68f7c..ff083fe 100644 --- a/test/runtime_bridge_fixtures.test.ts +++ b/test/runtime_bridge_fixtures.test.ts @@ -207,7 +207,7 @@ describeNodeOnly('Bridge behavior parity', () => { await optimizedBridge.call('math', 'sqrt', [4]); await optimizedBridge.dispose(); await expect(optimizedBridge.dispose()).resolves.toBeUndefined(); - }); + }, 15000); }); describe('script path validation parity', () => { @@ -281,6 +281,6 @@ describeNodeOnly('Bridge behavior parity', () => { await nodeBridge.dispose(); await optimizedBridge.dispose(); } - }); + }, 15000); }); }); diff --git a/test/runtime_node.test.ts b/test/runtime_node.test.ts index ed1324f..c4f5b8a 100644 --- a/test/runtime_node.test.ts +++ b/test/runtime_node.test.ts @@ -425,12 +425,15 @@ def get_bad(): if (!pythonAvailable || !isBridgeScriptAvailable()) return; // Give the bridge enough time to recover (worker quarantine/replacement) after a timeout. - bridge = new NodeBridge({ scriptPath, timeoutMs: 1000 }); + const timeoutMs = 3000; + const lateResponseWaitMs = 1500; + const sleepSeconds = (timeoutMs + lateResponseWaitMs) / 1000; + bridge = new NodeBridge({ scriptPath, timeoutMs }); - await expect(bridge.call('time', 'sleep', [1.5])).rejects.toThrow(/timed out/i); + await expect(bridge.call('time', 'sleep', [sleepSeconds])).rejects.toThrow(/timed out/i); - // Wait for the Python process to eventually respond to the timed-out request. - await new Promise(resolve => setTimeout(resolve, 800)); + // Wait for the timed-out worker to emit its stale response before verifying recovery. + await new Promise(resolve => setTimeout(resolve, lateResponseWaitMs + 250)); // Note: With the unified bridge, timed-out workers are quarantined and replaced // per ADR-0001 (#101). The important thing is that the bridge recovers and works. @@ -1170,7 +1173,8 @@ def get_bad(): expect(venvEnv).toBe(venvDir); const pathEnv = await bridge.call('os', 'getenv', ['PATH']); - expect(pathEnv?.split(delimiter)[0]).toBe(binDir); + const pathEntries = (pathEnv ?? '').split(delimiter).filter(Boolean); + expect(pathEntries).toContain(binDir); } finally { await bridge?.dispose(); if (tempDir) { @@ -1182,6 +1186,99 @@ def get_bad(): }, testTimeout ); + + it( + 'should preserve distinct lowercase path env overrides on POSIX', + async () => { + if (process.platform === 'win32') return; + + const pythonAvailable = await isPythonAvailable(); + if (!pythonAvailable || !isBridgeScriptAvailable()) return; + + let tempDir: string | undefined; + try { + tempDir = await mkdtemp(join(tmpdir(), 'tywrap-venv-')); + const venvDir = join(tempDir, 'fake-venv'); + const binDir = join(venvDir, getVenvBinDir()); + await mkdir(binDir, { recursive: true }); + + const customPathAlias = '/custom/app/config/path'; + const scriptAbsolutePath = join(process.cwd(), scriptPath); + bridge = new NodeBridge({ + scriptPath: scriptAbsolutePath, + pythonPath: defaultPythonPath, + cwd: tempDir, + virtualEnv: 'fake-venv', + env: { path: customPathAlias }, + timeoutMs: defaultTimeoutMs, + }); + + const pathEnv = await bridge.call('os', 'getenv', ['PATH']); + const pathEntries = (pathEnv ?? '').split(delimiter).filter(Boolean); + expect(pathEntries).toContain(binDir); + + const lowercasePathEnv = await bridge.call('os', 'getenv', ['path']); + expect(lowercasePathEnv).toBe(customPathAlias); + } finally { + await bridge?.dispose(); + if (tempDir) { + await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + } + }, + testTimeout + ); + + it( + 'should not append an empty PATH segment when PATH is blank', + async () => { + const pythonAvailable = await isPythonAvailable(); + if (!pythonAvailable || !isBridgeScriptAvailable()) return; + + let tempDir: string | undefined; + try { + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const execFileAsync = promisify(execFile); + const locator = process.platform === 'win32' ? 'where' : 'which'; + const { stdout } = await execFileAsync(locator, [defaultPythonPath], { + encoding: 'utf-8', + }); + const resolvedPythonPath = String(stdout) + .split(/\r?\n/) + .find(candidate => candidate.trim().length > 0) + ?.trim(); + if (!resolvedPythonPath) { + throw new Error(`Failed to locate ${defaultPythonPath}`); + } + + tempDir = await mkdtemp(join(tmpdir(), 'tywrap-venv-')); + const venvDir = join(tempDir, 'fake-venv'); + const binDir = join(venvDir, getVenvBinDir()); + await mkdir(binDir, { recursive: true }); + + const scriptAbsolutePath = join(process.cwd(), scriptPath); + bridge = new NodeBridge({ + scriptPath: scriptAbsolutePath, + pythonPath: resolvedPythonPath, + cwd: tempDir, + virtualEnv: 'fake-venv', + env: { PATH: '' }, + timeoutMs: defaultTimeoutMs, + }); + + const pathEnv = await bridge.call('os', 'getenv', ['PATH']); + expect(pathEnv).toBe(binDir); + } finally { + await bridge?.dispose(); + if (tempDir) { + await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + } + }, + testTimeout + ); + }); describe('Performance Characteristics', () => {