Files
orca/src/main/startup/hydrate-shell-path.test.ts
T
Neil a8f1a2d98c fix(startup): drop the other-cased PATH key from the Windows probe env
Caught by running the suite on a real Windows machine, not a mocked
platform. The spread of process.env is a plain, case-sensitive object,
while Windows resolves env names case-insensitively. Writing the captured
`Path` back onto it left the seeded value still live under `PATH`, so the
probe shell could read either one — the exact duplicate-key hazard the
win32 branch was supposed to prevent.

Drop any other-cased variant of the key before writing.
2026-08-24 19:21:52 -07:00

295 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'node:events'
import { delimiter } from 'node:path'
import type { ChildProcessWithoutNullStreams } from 'node:child_process'
import {
_resetHydrateShellPathCache,
hydrateShellPath,
mergePathSegments,
_setLaunchPathForTests,
type HydrationResult
} from './hydrate-shell-path'
const { spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('child_process', () => ({
spawn: spawnMock
}))
type HydrationSpawner = (shell: string) => Promise<HydrationResult>
function createMockShellProcess(): ChildProcessWithoutNullStreams {
const proc = new EventEmitter() as ChildProcessWithoutNullStreams
Object.assign(proc, {
stdout: new EventEmitter(),
stderr: new EventEmitter(),
stdin: new EventEmitter(),
kill: vi.fn()
})
return proc
}
describe('hydrateShellPath', () => {
const originalPath = process.env.PATH
beforeEach(() => {
_resetHydrateShellPathCache()
spawnMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('invokes the provided shell with a custom spawner and returns its segments', async () => {
let capturedShell = ''
const result = await hydrateShellPath({
shellOverride: '/bin/zsh',
spawner: async (shell) => {
capturedShell = shell
return {
segments: ['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'],
ok: true,
failureReason: 'none'
}
}
})
expect(capturedShell).toBe('/bin/zsh')
expect(result.ok).toBe(true)
expect(result.segments).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
expect(result.failureReason).toBe('none')
})
it('caches the hydration result so repeated calls do not re-spawn', async () => {
let spawnCount = 0
const spawner: HydrationSpawner = async () => {
spawnCount += 1
return { segments: ['/a'], ok: true, failureReason: 'none' }
}
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
expect(spawnCount).toBe(1)
})
it('re-spawns when force:true is passed — matches the Refresh button contract', async () => {
let spawnCount = 0
const spawner: HydrationSpawner = async () => {
spawnCount += 1
return { segments: ['/a'], ok: true, failureReason: 'none' }
}
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner })
await hydrateShellPath({ shellOverride: '/bin/zsh', spawner, force: true })
expect(spawnCount).toBe(2)
})
it('continues the probe queue after a rejected hydration', async () => {
const rejectedSpawner = vi.fn<HydrationSpawner>(async () => {
throw new Error('profile failed')
})
const successfulSpawner = vi.fn<HydrationSpawner>(async () => ({
segments: ['/current'],
ok: true,
failureReason: 'none'
}))
await expect(
hydrateShellPath({ shellOverride: '/bin/zsh', spawner: rejectedSpawner, force: true })
).rejects.toThrow('profile failed')
await expect(
hydrateShellPath({ shellOverride: '/bin/bash', spawner: successfulSpawner, force: true })
).resolves.toEqual({ segments: ['/current'], ok: true, failureReason: 'none' })
expect(successfulSpawner).toHaveBeenCalledWith('/bin/bash')
})
it('returns failureReason:no_shell when no shell is available (Windows path)', async () => {
const result = await hydrateShellPath({
shellOverride: null,
spawner: async () => {
throw new Error('spawner must not run when shell is null')
}
})
expect(result).toEqual({ segments: [], ok: false, failureReason: 'no_shell' })
})
// Why: each failure mode tagged independently so dashboards can pick the
// right fix (lengthen timeout vs investigate shell-invocation strategy vs
// surface a UX error). Spawner override stands in for the four resolve
// sites — the actual classification happens inside `spawnShellAndReadPath`,
// covered by the existing real-shell smoke surface.
it('propagates failureReason:timeout from the spawner', async () => {
const result = await hydrateShellPath({
shellOverride: '/bin/zsh',
spawner: async () => ({ segments: [], ok: false, failureReason: 'timeout' })
})
expect(result).toEqual({ segments: [], ok: false, failureReason: 'timeout' })
})
it('propagates failureReason:spawn_error from the spawner', async () => {
const result = await hydrateShellPath({
shellOverride: '/bin/zsh',
spawner: async () => ({ segments: [], ok: false, failureReason: 'spawn_error' })
})
expect(result).toEqual({ segments: [], ok: false, failureReason: 'spawn_error' })
})
it('propagates failureReason:empty_path from the spawner', async () => {
const result = await hydrateShellPath({
shellOverride: '/bin/zsh',
spawner: async () => ({ segments: [], ok: false, failureReason: 'empty_path' })
})
expect(result).toEqual({ segments: [], ok: false, failureReason: 'empty_path' })
})
it('cleans up shell listeners when hydration times out', async () => {
vi.useFakeTimers()
const proc = createMockShellProcess()
spawnMock.mockReturnValue(proc)
try {
const resultPromise = hydrateShellPath({ shellOverride: '/bin/zsh', force: true })
const assertion = expect(resultPromise).resolves.toEqual({
segments: [],
ok: false,
failureReason: 'timeout'
})
await vi.advanceTimersByTimeAsync(5000)
await assertion
expect(proc.kill).toHaveBeenCalledWith('SIGKILL')
expect(proc.stdout.listenerCount('data')).toBe(0)
expect(proc.listenerCount('error')).toBe(0)
expect(proc.listenerCount('close')).toBe(0)
} finally {
vi.useRealTimers()
}
})
async function captureProbeEnv(shell = '/bin/bash'): Promise<NodeJS.ProcessEnv> {
const proc = createMockShellProcess()
spawnMock.mockReturnValue(proc)
const resultPromise = hydrateShellPath({ shellOverride: shell, force: true })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalled())
proc.emit('close', 0)
await resultPromise
return spawnMock.mock.calls[0][2].env
}
it('probes with the launch PATH so seeded fallbacks cannot pin nvm', async () => {
process.env.PATH = `/home/u/.nvm/versions/node/v26.7.0/bin${delimiter}/usr/bin`
_setLaunchPathForTests('/usr/bin')
expect((await captureProbeEnv()).PATH).toBe('/usr/bin')
})
it('uses the PATH captured at module load, not a later mutation of process.env', async () => {
// Why: this is the anti-regression guard. patchPackagedProcessPath mutates
// process.env.PATH after this module is evaluated; the probe must not see it.
const seeded = `/seeded/newest-nvm/bin${delimiter}/usr/bin`
process.env.PATH = seeded
expect((await captureProbeEnv()).PATH).not.toBe(seeded)
})
it('sets a probe marker so rc files can take a fast path', async () => {
expect((await captureProbeEnv()).ORCA_SHELL_PATH_PROBE).toBe('1')
})
it('overwrites the captured key in place so Windows never carries both Path and PATH', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
const originalWindowsPath = process.env.Path
delete process.env.PATH
process.env.PATH = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows'
process.env.Path = 'C:\\Users\\u\\.nvm\\versions\\node\\v22.9.0\\bin;C:\\Windows'
_setLaunchPathForTests('C:\\Windows', 'Path')
try {
const env = await captureProbeEnv('C:/Git/bin/bash.exe')
expect(env.Path).toBe('C:\\Windows')
expect(env.PATH).toBeUndefined()
} finally {
if (originalWindowsPath === undefined) {
delete process.env.Path
} else {
process.env.Path = originalWindowsPath
}
}
})
})
describe('mergePathSegments', () => {
const originalPath = process.env.PATH
beforeEach(() => {
_resetHydrateShellPathCache()
})
afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
// Why: mergePathSegments joins with the platform PATH delimiter, so the
// expectations must too — hardcoding ':' made this suite fail on Windows
// dev machines even though the code under test was correct.
const joinPath = (...segments: string[]): string => segments.join(delimiter)
it('prepends new segments ahead of existing PATH entries', () => {
process.env.PATH = joinPath('/usr/bin', '/bin')
const added = mergePathSegments(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
expect(added).toEqual(['/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin'])
expect(process.env.PATH).toBe(
joinPath('/Users/tester/.opencode/bin', '/Users/tester/.cargo/bin', '/usr/bin', '/bin')
)
})
it('promotes shell segments already on PATH so shell ordering wins', () => {
process.env.PATH = joinPath('/Users/tester/.cargo/bin', '/usr/bin')
const added = mergePathSegments(['/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin'])
expect(added).toEqual(['/Users/tester/.opencode/bin'])
expect(process.env.PATH).toBe(
joinPath('/Users/tester/.cargo/bin', '/Users/tester/.opencode/bin', '/usr/bin')
)
})
it('moves user-local shell paths ahead of packaged Homebrew fallbacks', () => {
process.env.PATH = joinPath('/opt/homebrew/bin', '/Users/tester/.local/bin', '/usr/bin', '/bin')
const added = mergePathSegments(['/Users/tester/.local/bin', '/opt/homebrew/bin'])
expect(added).toEqual([])
expect(process.env.PATH).toBe(
joinPath('/Users/tester/.local/bin', '/opt/homebrew/bin', '/usr/bin', '/bin')
)
})
it('returns [] and leaves PATH untouched when given nothing', () => {
process.env.PATH = joinPath('/usr/bin', '/bin')
expect(mergePathSegments([])).toEqual([])
expect(process.env.PATH).toBe(joinPath('/usr/bin', '/bin'))
})
})