Files
orca/src/main/startup/hydrate-shell-path.test.ts
T

199 lines
6.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { EventEmitter } from 'events'
import type { ChildProcessWithoutNullStreams } from 'child_process'
import {
_resetHydrateShellPathCache,
hydrateShellPath,
mergePathSegments,
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(() => {
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('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()
}
})
})
describe('mergePathSegments', () => {
const originalPath = process.env.PATH
afterEach(() => {
if (originalPath === undefined) {
delete process.env.PATH
} else {
process.env.PATH = originalPath
}
})
it('prepends new segments ahead of existing PATH entries', () => {
process.env.PATH = '/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(
'/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin:/bin'
)
})
it('skips segments already on PATH so re-hydration is a no-op', () => {
process.env.PATH = '/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('/Users/tester/.opencode/bin:/Users/tester/.cargo/bin:/usr/bin')
})
it('returns [] and leaves PATH untouched when given nothing', () => {
process.env.PATH = '/usr/bin:/bin'
expect(mergePathSegments([])).toEqual([])
expect(process.env.PATH).toBe('/usr/bin:/bin')
})
})