mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 16:03:26 +00:00
199 lines
6.3 KiB
TypeScript
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')
|
|
})
|
|
})
|