mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
* fix(cli): make packaged skills runtime self-contained * fix(cli): address packaged skills review feedback * ci(cli): smoke packaged skills on Windows --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
1150 lines
40 KiB
TypeScript
1150 lines
40 KiB
TypeScript
/* eslint-disable max-lines -- Why: preflight tests share expensive process/preload mocks across
|
|
install, auth, agent detection, and refresh branches. */
|
|
import path from 'node:path'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
handleMock,
|
|
execFileMock,
|
|
execFileAsyncMock,
|
|
hydrateShellPathMock,
|
|
mergePathSegmentsMock,
|
|
getActiveMultiplexerMock,
|
|
getBitbucketAuthStatusMock,
|
|
getAzureDevOpsAuthStatusMock,
|
|
getGiteaAuthStatusMock,
|
|
resolveCliCommandsMock,
|
|
isCommandOnLocalPathMock,
|
|
mergePersistedWindowsPathAsyncMock,
|
|
mergePersistedWindowsPathMock
|
|
} = vi.hoisted(() => ({
|
|
handleMock: vi.fn(),
|
|
execFileMock: vi.fn(),
|
|
execFileAsyncMock: vi.fn(),
|
|
hydrateShellPathMock: vi.fn(),
|
|
mergePathSegmentsMock: vi.fn(),
|
|
getActiveMultiplexerMock: vi.fn(),
|
|
getBitbucketAuthStatusMock: vi.fn(),
|
|
getAzureDevOpsAuthStatusMock: vi.fn(),
|
|
getGiteaAuthStatusMock: vi.fn(),
|
|
resolveCliCommandsMock: vi.fn(),
|
|
isCommandOnLocalPathMock: vi.fn(),
|
|
mergePersistedWindowsPathAsyncMock: vi.fn(),
|
|
mergePersistedWindowsPathMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('electron', () => ({
|
|
ipcMain: {
|
|
handle: handleMock
|
|
}
|
|
}))
|
|
|
|
vi.mock('child_process', () => {
|
|
const execFileWithPromisify = Object.assign(execFileMock, {
|
|
[Symbol.for('nodejs.util.promisify.custom')]: execFileAsyncMock
|
|
})
|
|
return {
|
|
execFile: execFileWithPromisify,
|
|
spawn: vi.fn()
|
|
}
|
|
})
|
|
|
|
vi.mock('../startup/hydrate-shell-path', () => ({
|
|
hydrateShellPath: hydrateShellPathMock,
|
|
mergePathSegments: mergePathSegmentsMock
|
|
}))
|
|
|
|
vi.mock('../../shared/node-cli-command-resolution', () => ({
|
|
resolveCliCommands: resolveCliCommandsMock
|
|
}))
|
|
|
|
// Why (#9297): local PATH resolution is now fs-based (no where/which spawn).
|
|
// These tests express "which commands are on PATH" via the where/which mock,
|
|
// so route the resolver through that same mock to preserve their intent.
|
|
vi.mock('./command-path-resolver', () => ({
|
|
isCommandOnLocalPath: isCommandOnLocalPathMock
|
|
}))
|
|
|
|
vi.mock('../pty/windows-environment-path', () => ({
|
|
mergePersistedWindowsPathAsync: mergePersistedWindowsPathAsyncMock,
|
|
mergePersistedWindowsPath: mergePersistedWindowsPathMock
|
|
}))
|
|
|
|
vi.mock('./ssh', () => ({
|
|
getActiveMultiplexer: getActiveMultiplexerMock
|
|
}))
|
|
|
|
vi.mock('../bitbucket/client', () => ({
|
|
getBitbucketAuthStatus: getBitbucketAuthStatusMock
|
|
}))
|
|
|
|
vi.mock('../azure-devops/client', () => ({
|
|
getAzureDevOpsAuthStatus: getAzureDevOpsAuthStatusMock
|
|
}))
|
|
|
|
vi.mock('../gitea/client', () => ({
|
|
getGiteaAuthStatus: getGiteaAuthStatusMock
|
|
}))
|
|
|
|
import {
|
|
_resetPreflightCache,
|
|
detectInstalledAgents,
|
|
detectInstalledAgentsWithShellPathHydration,
|
|
registerPreflightHandlers,
|
|
runPreflightCheck
|
|
} from './preflight'
|
|
|
|
type HandlerMap = Record<string, (_event?: unknown, args?: unknown) => Promise<unknown>>
|
|
|
|
describe('preflight', () => {
|
|
const originalPlatform = process.platform
|
|
const handlers: HandlerMap = {}
|
|
const defaultBitbucketStatus = { configured: false, authenticated: false, account: null }
|
|
const defaultAzureDevOpsStatus = {
|
|
configured: false,
|
|
authenticated: false,
|
|
account: null,
|
|
baseUrl: null,
|
|
tokenConfigured: false
|
|
}
|
|
const defaultGiteaStatus = {
|
|
configured: false,
|
|
authenticated: false,
|
|
account: null,
|
|
baseUrl: null,
|
|
tokenConfigured: false
|
|
}
|
|
|
|
beforeEach(() => {
|
|
handleMock.mockReset()
|
|
execFileAsyncMock.mockReset()
|
|
hydrateShellPathMock.mockReset()
|
|
hydrateShellPathMock.mockResolvedValue({ segments: [], ok: false, failureReason: 'no_shell' })
|
|
mergePathSegmentsMock.mockReset()
|
|
getActiveMultiplexerMock.mockReset()
|
|
getBitbucketAuthStatusMock.mockReset()
|
|
getAzureDevOpsAuthStatusMock.mockReset()
|
|
getGiteaAuthStatusMock.mockReset()
|
|
mergePersistedWindowsPathAsyncMock.mockReset()
|
|
mergePersistedWindowsPathAsyncMock.mockResolvedValue(undefined)
|
|
mergePersistedWindowsPathMock.mockReset()
|
|
// Why: existing tests should keep treating `which` as the only source
|
|
// unless a case explicitly exercises the install-dir fallback.
|
|
resolveCliCommandsMock.mockReset()
|
|
resolveCliCommandsMock.mockImplementation(
|
|
(commands: string[]) => new Map(commands.map((command) => [command, command]))
|
|
)
|
|
// Why: reproduce the pre-#9297 local PATH check (spawn where/which, keep
|
|
// only absolute resolutions) so cases that stub the where/which mock still
|
|
// drive detection identically without a real subprocess.
|
|
isCommandOnLocalPathMock.mockReset()
|
|
isCommandOnLocalPathMock.mockImplementation(async (command: string) => {
|
|
const finder = process.platform === 'win32' ? 'where' : 'which'
|
|
try {
|
|
const { stdout } = await execFileAsyncMock(finder, [command])
|
|
return String(stdout)
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.some((line) => path.isAbsolute(line))
|
|
} catch {
|
|
return false
|
|
}
|
|
})
|
|
getBitbucketAuthStatusMock.mockResolvedValue(defaultBitbucketStatus)
|
|
getAzureDevOpsAuthStatusMock.mockResolvedValue(defaultAzureDevOpsStatus)
|
|
getGiteaAuthStatusMock.mockResolvedValue(defaultGiteaStatus)
|
|
_resetPreflightCache()
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'darwin'
|
|
})
|
|
|
|
for (const key of Object.keys(handlers)) {
|
|
delete handlers[key]
|
|
}
|
|
|
|
handleMock.mockImplementation((channel, handler) => {
|
|
handlers[channel] = handler
|
|
})
|
|
})
|
|
|
|
afterEach(() => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
})
|
|
|
|
// Why: every preflight run probes (in order) `git --version`, `gh --version`,
|
|
// `glab --version`, then in parallel `gh auth status` + `glab auth status` —
|
|
// five execFile calls per cycle. Tests below provide values for all five.
|
|
it('marks gh as authenticated when gh auth status exits successfully', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status).toEqual({
|
|
git: { installed: true },
|
|
gh: { installed: true, authenticated: true },
|
|
glab: { installed: true, authenticated: true },
|
|
bitbucket: defaultBitbucketStatus,
|
|
azureDevOps: defaultAzureDevOpsStatus,
|
|
gitea: defaultGiteaStatus
|
|
})
|
|
expect(execFileAsyncMock).toHaveBeenNthCalledWith(4, 'gh', ['auth', 'status'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000
|
|
})
|
|
expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000
|
|
})
|
|
})
|
|
|
|
it('treats gh as unauthenticated when gh auth status fails without auth markers', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.gh).toEqual({ installed: true, authenticated: false })
|
|
})
|
|
|
|
it('keeps older gh stderr success output from showing a false auth warning', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockRejectedValueOnce({ stderr: 'Logged in to github.com account octocat\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
|
})
|
|
|
|
it('marks glab as not installed when `glab --version` fails', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockRejectedValueOnce(new Error('command not found: glab'))
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.glab).toEqual({ installed: false, authenticated: false })
|
|
// Why: with glab uninstalled, glab auth status must not run — that
|
|
// would surface a misleading "command not found" error in logs.
|
|
expect(execFileAsyncMock).toHaveBeenCalledTimes(4)
|
|
})
|
|
|
|
it('marks glab as installed but unauthenticated when auth status fails', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitLab hosts.\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.glab).toEqual({ installed: true, authenticated: false })
|
|
})
|
|
|
|
it('times out hung local preflight probes', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
execFileAsyncMock.mockImplementation((command, args) => {
|
|
if (command === 'git') {
|
|
return Promise.resolve({ stdout: 'git version 2.0.0\n' })
|
|
}
|
|
if (command === 'gh' && Array.isArray(args) && args[0] === '--version') {
|
|
return new Promise(() => {})
|
|
}
|
|
if (command === 'glab') {
|
|
return Promise.reject(new Error('command not found: glab'))
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
|
|
const statusPromise = runPreflightCheck()
|
|
let settled = false
|
|
void statusPromise.then(
|
|
() => {
|
|
settled = true
|
|
},
|
|
() => {
|
|
settled = true
|
|
}
|
|
)
|
|
|
|
await vi.advanceTimersByTimeAsync(5000)
|
|
await Promise.resolve()
|
|
|
|
expect(settled).toBe(true)
|
|
await expect(statusPromise).resolves.toMatchObject({
|
|
git: { installed: true },
|
|
gh: { installed: false },
|
|
glab: { installed: false }
|
|
})
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('prefers the selected WSL distro when checking gh for a WSL workspace', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command === 'git') {
|
|
return { stdout: 'git version 2.0.0\n' }
|
|
}
|
|
if (command === 'gh') {
|
|
throw Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })
|
|
}
|
|
if (command === 'glab') {
|
|
throw Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })
|
|
}
|
|
if (command === 'wsl.exe') {
|
|
const script = String(args[5])
|
|
if (script.includes('gh') && script.includes('--version')) {
|
|
return { stdout: 'gh version 2.0.0\n' }
|
|
}
|
|
if (script.includes('gh') && script.includes('auth status')) {
|
|
return { stdout: 'github.com\n - Active account: true\n' }
|
|
}
|
|
throw new Error(`unexpected WSL script ${script}`)
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
|
|
const status = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
|
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*--version/)],
|
|
{ encoding: 'utf-8', timeout: 5000 }
|
|
)
|
|
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
['-d', 'Ubuntu', '--', 'sh', '-c', expect.stringMatching(/gh[\s\S]*auth status/)],
|
|
{ encoding: 'utf-8', timeout: 5000 }
|
|
)
|
|
})
|
|
|
|
it('uses the persisted Windows Path when probing host CLIs', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
mergePersistedWindowsPathMock.mockImplementation((env: Record<string, string>) => {
|
|
env.Path = 'C:\\Windows\\System32;C:\\Program Files\\GitHub CLI'
|
|
})
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
|
expect(mergePersistedWindowsPathMock).toHaveBeenCalled()
|
|
expect(execFileAsyncMock).toHaveBeenNthCalledWith(2, 'gh', ['--version'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
env: expect.objectContaining({
|
|
Path: 'C:\\Windows\\System32;C:\\Program Files\\GitHub CLI'
|
|
})
|
|
})
|
|
})
|
|
|
|
it('times out hung WSL preflight probes', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation((command, args) => {
|
|
if (command === 'git') {
|
|
return Promise.resolve({ stdout: 'git version 2.0.0\n' })
|
|
}
|
|
if (command === 'gh' || command === 'glab') {
|
|
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
|
|
}
|
|
if (
|
|
command === 'wsl.exe' &&
|
|
Array.isArray(args) &&
|
|
String(args.at(-1)).includes("'gh' --version")
|
|
) {
|
|
return new Promise(() => {})
|
|
}
|
|
if (
|
|
command === 'wsl.exe' &&
|
|
Array.isArray(args) &&
|
|
String(args.at(-1)).includes("'glab' --version")
|
|
) {
|
|
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
|
|
const statusPromise = runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
let settled = false
|
|
void statusPromise.finally(() => {
|
|
settled = true
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(5000)
|
|
await Promise.resolve()
|
|
|
|
expect(settled).toBe(true)
|
|
await expect(statusPromise).resolves.toMatchObject({
|
|
gh: { installed: false },
|
|
glab: { installed: false }
|
|
})
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
|
|
it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const firstStatus = await runPreflightCheck()
|
|
const refreshedStatus = await runPreflightCheck(true)
|
|
|
|
expect(firstStatus.gh).toEqual({ installed: true, authenticated: false })
|
|
expect(refreshedStatus.gh).toEqual({ installed: true, authenticated: true })
|
|
expect(execFileAsyncMock).toHaveBeenCalledTimes(10)
|
|
})
|
|
|
|
it('awaits the persisted Windows Path refresh before a forced host CLI preflight', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
let finishRefresh!: () => void
|
|
mergePersistedWindowsPathAsyncMock.mockImplementation(
|
|
() =>
|
|
new Promise<void>((resolve) => {
|
|
finishRefresh = resolve
|
|
})
|
|
)
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
const check = runPreflightCheck(true)
|
|
await Promise.resolve()
|
|
|
|
expect(execFileAsyncMock).not.toHaveBeenCalled()
|
|
finishRefresh()
|
|
await expect(check).resolves.toMatchObject({
|
|
gh: { installed: true, authenticated: true }
|
|
})
|
|
|
|
expect(mergePersistedWindowsPathAsyncMock).toHaveBeenNthCalledWith(1, process.env, {
|
|
forceRefresh: true
|
|
})
|
|
})
|
|
|
|
it('does not refresh host Windows Path for forced WSL preflight', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command === 'wsl.exe') {
|
|
const script = String(args[5])
|
|
if (script.includes('git') && script.includes('--version')) {
|
|
return { stdout: 'git version 2.0.0\n' }
|
|
}
|
|
if (script.includes('gh') && script.includes('--version')) {
|
|
return { stdout: 'gh version 2.0.0\n' }
|
|
}
|
|
if (script.includes('glab') && script.includes('--version')) {
|
|
return { stdout: 'glab version 1.92.1\n' }
|
|
}
|
|
if (script.includes('gh') && script.includes('auth status')) {
|
|
return { stdout: 'github.com\n - Active account: true\n' }
|
|
}
|
|
if (script.includes('glab') && script.includes('auth status')) {
|
|
return { stdout: 'Logged in to gitlab.com\n' }
|
|
}
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
|
|
await expect(runPreflightCheck(true, { wslDistro: 'Ubuntu' })).resolves.toMatchObject({
|
|
gh: { installed: true, authenticated: true }
|
|
})
|
|
|
|
expect(mergePersistedWindowsPathAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('registers the preflight handler', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const status = await handlers['preflight:check']()
|
|
|
|
expect(status).toEqual({
|
|
git: { installed: true },
|
|
gh: { installed: true, authenticated: true },
|
|
glab: { installed: true, authenticated: true },
|
|
bitbucket: defaultBitbucketStatus,
|
|
azureDevOps: defaultAzureDevOpsStatus,
|
|
gitea: defaultGiteaStatus
|
|
})
|
|
})
|
|
|
|
it('lets the IPC handler bypass the session cache when forced', async () => {
|
|
execFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockRejectedValueOnce({ stderr: 'You are not logged into any GitHub hosts.\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'gh version 2.0.0\n' })
|
|
.mockResolvedValueOnce({ stdout: 'glab version 1.92.1\n' })
|
|
.mockResolvedValueOnce({ stdout: 'github.com\n - Active account: true\n' })
|
|
.mockResolvedValueOnce({ stdout: 'Logged in to gitlab.com\n' })
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const firstStatus = await handlers['preflight:check']()
|
|
const refreshedStatus = await handlers['preflight:check'](null, { force: true })
|
|
|
|
expect(firstStatus).toEqual({
|
|
git: { installed: true },
|
|
gh: { installed: true, authenticated: false },
|
|
glab: { installed: true, authenticated: true },
|
|
bitbucket: defaultBitbucketStatus,
|
|
azureDevOps: defaultAzureDevOpsStatus,
|
|
gitea: defaultGiteaStatus
|
|
})
|
|
expect(refreshedStatus).toEqual({
|
|
git: { installed: true },
|
|
gh: { installed: true, authenticated: true },
|
|
glab: { installed: true, authenticated: true },
|
|
bitbucket: defaultBitbucketStatus,
|
|
azureDevOps: defaultAzureDevOpsStatus,
|
|
gitea: defaultGiteaStatus
|
|
})
|
|
})
|
|
|
|
it('only reports agents when which/where resolves to a real executable path', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
|
|
const target = String(args[0])
|
|
if (target === 'claude') {
|
|
return { stdout: '/Users/test/.local/bin/claude\n' }
|
|
}
|
|
if (target === 'continue') {
|
|
return { stdout: 'continue: shell built-in command\n' }
|
|
}
|
|
if (target === 'cursor-agent') {
|
|
return { stdout: '/Users/test/.local/bin/cursor-agent\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'cursor'])
|
|
})
|
|
|
|
it('does not report Claude Agent Teams when only the Orca shim is present', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'orca') {
|
|
return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual([])
|
|
})
|
|
|
|
it('reports Claude Agent Teams when both Orca and Claude are present', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'claude') {
|
|
return { stdout: '/Users/test/.local/bin/claude\n' }
|
|
}
|
|
if (String(args[0]) === 'orca') {
|
|
return { stdout: '/Applications/Orca.app/Contents/MacOS/orca\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'claude-agent-teams'])
|
|
})
|
|
|
|
it('does not report Claude Agent Teams on native Windows', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'where') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'claude') {
|
|
return { stdout: '/mock/windows/npm/claude.cmd\n' }
|
|
}
|
|
if (String(args[0]) === 'orca') {
|
|
return { stdout: '/mock/windows/programs/orca.cmd\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['claude'])
|
|
})
|
|
|
|
it('detects agents via the install-dir resolver when which fails (stripped GUI PATH)', async () => {
|
|
// Why: cold GUI launches can run detection before shell-PATH hydration
|
|
// adds user install dirs, so `which` can miss runnable CLIs.
|
|
execFileAsyncMock.mockImplementation(async (command) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
resolveCliCommandsMock.mockImplementation(
|
|
(commands: string[]) =>
|
|
new Map(
|
|
commands.map((cmd) => {
|
|
if (cmd === 'claude') {
|
|
return [cmd, '/Users/test/.local/bin/claude']
|
|
}
|
|
if (cmd === 'codex') {
|
|
return [cmd, '/Users/test/.asdf/shims/codex']
|
|
}
|
|
if (cmd === 'opencode') {
|
|
return [cmd, '/Users/test/Library/pnpm/opencode']
|
|
}
|
|
return [cmd, cmd]
|
|
})
|
|
)
|
|
)
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['claude', 'codex', 'opencode'])
|
|
expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('does not double-count an agent already found on PATH via the install-dir resolver', async () => {
|
|
// Why: the fallback should not duplicate ids when PATH already finds a CLI.
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'claude') {
|
|
return { stdout: '/Users/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
resolveCliCommandsMock.mockImplementation(
|
|
(commands: string[]) => new Map(commands.map((cmd) => [cmd, cmd]))
|
|
)
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['claude'])
|
|
expect(resolveCliCommandsMock).toHaveBeenCalledTimes(1)
|
|
expect(resolveCliCommandsMock).toHaveBeenCalledWith(expect.not.arrayContaining(['claude']))
|
|
})
|
|
|
|
it('treats an agent as not installed when the install-dir resolver throws', async () => {
|
|
// Why: transient fs errors in the fallback must not crash detection.
|
|
execFileAsyncMock.mockImplementation(async (command) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
resolveCliCommandsMock.mockImplementation(() => {
|
|
throw new Error('EACCES: permission denied')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual([])
|
|
})
|
|
|
|
it('registers agent detection through the shared launch config commands', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'openclaude') {
|
|
return { stdout: '/Users/test/.local/bin/openclaude\n' }
|
|
}
|
|
if (String(args[0]) === 'cursor-agent') {
|
|
return { stdout: '/Users/test/.local/bin/cursor-agent\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['openclaude', 'cursor'])
|
|
})
|
|
|
|
it('hydrates shell PATH before user-facing agent detection', async () => {
|
|
const originalPath = process.env.PATH
|
|
process.env.PATH = '/usr/bin'
|
|
hydrateShellPathMock.mockResolvedValueOnce({
|
|
segments: ['/home/test/.local/bin'],
|
|
ok: true,
|
|
failureReason: 'none'
|
|
})
|
|
mergePathSegmentsMock.mockImplementationOnce((segments: string[]) => {
|
|
process.env.PATH = [...segments, '/usr/bin'].join(':')
|
|
return segments
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'codex' && process.env.PATH?.startsWith('/home/test/.local/bin')) {
|
|
return { stdout: '/home/test/.local/bin/codex\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
try {
|
|
await expect(detectInstalledAgentsWithShellPathHydration()).resolves.toEqual(['codex'])
|
|
} finally {
|
|
if (originalPath === undefined) {
|
|
delete process.env.PATH
|
|
} else {
|
|
process.env.PATH = originalPath
|
|
}
|
|
}
|
|
expect(hydrateShellPathMock).toHaveBeenCalledWith()
|
|
expect(mergePathSegmentsMock).toHaveBeenCalledWith(['/home/test/.local/bin'])
|
|
})
|
|
|
|
it('does not run host shell hydration for WSL agent detection', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'wsl.exe') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
const script = String(args[5])
|
|
if (script.includes("'claude'")) {
|
|
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(
|
|
detectInstalledAgentsWithShellPathHydration({ wslDistro: 'Ubuntu' })
|
|
).resolves.toEqual(['claude'])
|
|
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not report Claude Agent Teams from WSL agent detection', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'wsl.exe') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
const script = String(args[5])
|
|
expect(script).not.toContain("'orca'")
|
|
expect(script).not.toContain("'orca-dev'")
|
|
expect(script).not.toContain("'orca-ide'")
|
|
if (script.includes("'claude'")) {
|
|
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude'])
|
|
})
|
|
|
|
it('detects Mistral Vibe from the installed vibe executable', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'vibe') {
|
|
return { stdout: '/home/test/.local/bin/vibe\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['mistral-vibe'])
|
|
})
|
|
|
|
it('deduplicates Mistral Vibe when both current and legacy executables exist', async () => {
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'vibe' || String(args[0]) === 'mistral-vibe') {
|
|
return { stdout: `/home/test/.local/bin/${String(args[0])}\n` }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents()).resolves.toEqual(['mistral-vibe'])
|
|
})
|
|
|
|
it('sends aliased detection commands through the SSH remote preflight path', async () => {
|
|
const request = vi.fn().mockResolvedValue({ agents: ['openclaude'] })
|
|
getActiveMultiplexerMock.mockReturnValue({
|
|
isDisposed: () => false,
|
|
request
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
await expect(
|
|
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
|
|
).resolves.toEqual(['openclaude'])
|
|
expect(request).toHaveBeenCalledWith('preflight.detectAgents', {
|
|
commands: expect.arrayContaining([
|
|
{ id: 'openclaude', cmd: 'openclaude' },
|
|
{ id: 'mistral-vibe', cmd: 'vibe' },
|
|
{ id: 'mistral-vibe', cmd: 'mistral-vibe' }
|
|
])
|
|
})
|
|
})
|
|
|
|
it('returns no remote agents when the SSH connection is unavailable', async () => {
|
|
getActiveMultiplexerMock.mockReturnValue(null)
|
|
|
|
registerPreflightHandlers()
|
|
|
|
await expect(
|
|
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
|
|
).resolves.toEqual([])
|
|
})
|
|
|
|
it('returns no remote agents when the SSH connection is disposed', async () => {
|
|
const request = vi.fn()
|
|
getActiveMultiplexerMock.mockReturnValue({
|
|
isDisposed: () => true,
|
|
request
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
await expect(
|
|
handlers['preflight:detectRemoteAgents'](undefined, { connectionId: 'ssh-1' })
|
|
).resolves.toEqual([])
|
|
expect(request).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('sends remote Windows shell capability probes through the SSH preflight path', async () => {
|
|
const request = vi.fn().mockResolvedValue({
|
|
wslAvailable: true,
|
|
wslDistros: ['Ubuntu'],
|
|
pwshAvailable: true,
|
|
gitBashAvailable: true,
|
|
hostPlatform: 'win32'
|
|
})
|
|
getActiveMultiplexerMock.mockReturnValue({
|
|
isDisposed: () => false,
|
|
request
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
await expect(
|
|
handlers['preflight:detectRemoteWindowsTerminalCapabilities'](undefined, {
|
|
connectionId: 'ssh-1'
|
|
})
|
|
).resolves.toEqual({
|
|
wslAvailable: true,
|
|
wslDistros: ['Ubuntu'],
|
|
pwshAvailable: true,
|
|
gitBashAvailable: true,
|
|
hostPlatform: 'win32'
|
|
})
|
|
expect(request).toHaveBeenCalledWith('preflight.detectWindowsTerminalCapabilities', {})
|
|
})
|
|
|
|
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'wsl.exe') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
const script = String(args[5])
|
|
if (script.includes("'claude'")) {
|
|
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude'])
|
|
expect(execFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
// Why: the local fallback must not report host binaries as WSL binaries.
|
|
expect(resolveCliCommandsMock).not.toHaveBeenCalled()
|
|
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
expect.arrayContaining([
|
|
'-d',
|
|
'Ubuntu',
|
|
'--',
|
|
'sh',
|
|
'-c',
|
|
expect.stringContaining("'claude'")
|
|
]),
|
|
{ encoding: 'utf-8', timeout: 10000 }
|
|
)
|
|
})
|
|
|
|
it('detects agents from the default WSL distro when requested', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'wsl.exe') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
const script = String(args[3])
|
|
if (script.includes("'codex'")) {
|
|
return { stdout: '__ORCA_AGENT_PATH__codex\t/home/test/.local/bin/codex\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
await expect(detectInstalledAgents({ wslDefault: true })).resolves.toEqual(['codex'])
|
|
expect(execFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
// Why: the local fallback must not leak into WSL detection.
|
|
expect(resolveCliCommandsMock).not.toHaveBeenCalled()
|
|
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
expect.arrayContaining(['--', 'sh', '-c', expect.stringContaining("'codex'")]),
|
|
{ encoding: 'utf-8', timeout: 10000 }
|
|
)
|
|
})
|
|
|
|
it('lets a resolved host project runtime override stale WSL context flags', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
expect(command).not.toBe('wsl.exe')
|
|
if (command === 'git' || command === 'gh' || command === 'glab') {
|
|
return { stdout: `${String(command)} ok\n` }
|
|
}
|
|
throw new Error(`unexpected command ${String(command)} ${JSON.stringify(args)}`)
|
|
})
|
|
|
|
const status = await runPreflightCheck(false, {
|
|
wslDistro: 'Ubuntu',
|
|
projectRuntime: {
|
|
status: 'resolved',
|
|
runtime: {
|
|
kind: 'windows-host',
|
|
hostPlatform: 'win32',
|
|
projectId: 'project-1',
|
|
reason: 'project-override',
|
|
cacheKey: 'project-1:windows-host'
|
|
}
|
|
}
|
|
})
|
|
|
|
expect(status.git.installed).toBe(true)
|
|
expect(mergePersistedWindowsPathMock).toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not hydrate the host PATH when refreshing agents for a resolved WSL runtime', async () => {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'wsl.exe') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
const script = String(args[5])
|
|
if (script.includes("'claude'")) {
|
|
return { stdout: '__ORCA_AGENT_PATH__claude\t/home/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const result = (await handlers['preflight:refreshAgents'](undefined, {
|
|
projectRuntime: {
|
|
status: 'resolved',
|
|
runtime: {
|
|
kind: 'wsl',
|
|
hostPlatform: 'wsl',
|
|
projectId: 'project-1',
|
|
distro: 'Ubuntu',
|
|
reason: 'project-override',
|
|
cacheKey: 'project-1:wsl:Ubuntu'
|
|
}
|
|
}
|
|
})) as {
|
|
agents: string[]
|
|
addedPathSegments: string[]
|
|
shellHydrationOk: boolean
|
|
pathSource: string
|
|
pathFailureReason: string
|
|
}
|
|
|
|
expect(result).toEqual({
|
|
agents: ['claude'],
|
|
addedPathSegments: [],
|
|
shellHydrationOk: true,
|
|
pathSource: 'sync_seed_only',
|
|
pathFailureReason: 'none'
|
|
})
|
|
expect(hydrateShellPathMock).not.toHaveBeenCalled()
|
|
expect(mergePathSegmentsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => {
|
|
// Why: the Agents settings Refresh button calls this path. It must (1) ask
|
|
// the shell hydrator for a fresh PATH, (2) merge any new segments, then
|
|
// (3) re-run `which` so newly-installed CLIs appear without a restart.
|
|
hydrateShellPathMock.mockResolvedValueOnce({
|
|
segments: ['/Users/test/.opencode/bin'],
|
|
ok: true,
|
|
failureReason: 'none'
|
|
})
|
|
mergePathSegmentsMock.mockReturnValueOnce(['/Users/test/.opencode/bin'])
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'opencode') {
|
|
return { stdout: '/Users/test/.opencode/bin/opencode\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const result = (await handlers['preflight:refreshAgents']()) as {
|
|
agents: string[]
|
|
addedPathSegments: string[]
|
|
shellHydrationOk: boolean
|
|
pathSource: string
|
|
pathFailureReason: string
|
|
}
|
|
|
|
expect(result).toEqual({
|
|
agents: ['opencode'],
|
|
addedPathSegments: ['/Users/test/.opencode/bin'],
|
|
shellHydrationOk: true,
|
|
pathSource: 'shell_hydrate',
|
|
pathFailureReason: 'none'
|
|
})
|
|
expect(hydrateShellPathMock).toHaveBeenCalledWith({ force: true })
|
|
})
|
|
|
|
it('still re-detects when the shell spawn fails — relies on the existing PATH', async () => {
|
|
hydrateShellPathMock.mockResolvedValueOnce({
|
|
segments: [],
|
|
ok: false,
|
|
failureReason: 'timeout'
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command, args) => {
|
|
if (command !== 'which') {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
}
|
|
if (String(args[0]) === 'claude') {
|
|
return { stdout: '/Users/test/.local/bin/claude\n' }
|
|
}
|
|
throw new Error('not found')
|
|
})
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const result = (await handlers['preflight:refreshAgents']()) as {
|
|
agents: string[]
|
|
addedPathSegments: string[]
|
|
shellHydrationOk: boolean
|
|
pathSource: string
|
|
pathFailureReason: string
|
|
}
|
|
|
|
expect(result.shellHydrationOk).toBe(false)
|
|
expect(result.addedPathSegments).toEqual([])
|
|
expect(result.agents).toEqual(['claude'])
|
|
// Why: drives the agent_picks `on_path:false` triage in dashboard 1562016.
|
|
// Without these fields we cannot distinguish "hydration failed" from
|
|
// "user genuinely doesn't have the binary."
|
|
expect(result.pathSource).toBe('sync_seed_only')
|
|
expect(result.pathFailureReason).toBe('timeout')
|
|
// Why: when hydration fails, we must not call merge — nothing to merge —
|
|
// otherwise we'd log a no-op "added 0 segments" event on every refresh.
|
|
expect(mergePathSegmentsMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each(['no_shell', 'spawn_error', 'empty_path'] as const)(
|
|
'classifies pathFailureReason=%s when hydration reports it',
|
|
async (failureReason) => {
|
|
hydrateShellPathMock.mockResolvedValueOnce({ segments: [], ok: false, failureReason })
|
|
execFileAsyncMock.mockRejectedValue(new Error('not found'))
|
|
|
|
registerPreflightHandlers()
|
|
|
|
const result = (await handlers['preflight:refreshAgents']()) as {
|
|
pathSource: string
|
|
pathFailureReason: string
|
|
}
|
|
|
|
expect(result.pathSource).toBe('sync_seed_only')
|
|
expect(result.pathFailureReason).toBe(failureReason)
|
|
}
|
|
)
|
|
})
|