mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 08:03:09 +00:00
Recover a working local forge CLI when an earlier PATH launcher is broken. Bound executable probes and reuse the verified selection for native operations without replaying authentication or user requests. Fixes #22975 Co-authored-by: Aashish <145881415+aashish254@users.noreply.github.com>
845 lines
30 KiB
TypeScript
845 lines
30 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as LocalCommandResolver from './command-path-resolver'
|
|
|
|
const {
|
|
handleMock,
|
|
execFileMock,
|
|
execFileAsyncMock,
|
|
runWslProcessMock,
|
|
hydrateShellPathMock,
|
|
mergePathSegmentsMock,
|
|
getActiveMultiplexerMock,
|
|
getBitbucketAuthStatusMock,
|
|
getAzureDevOpsAuthStatusMock,
|
|
getGiteaAuthStatusMock,
|
|
resolveCliCommandsMock,
|
|
isCommandOnLocalPathMock,
|
|
listLocalCommandPathsMock,
|
|
mergePersistedWindowsPathAsyncMock,
|
|
mergePersistedWindowsPathMock
|
|
} = vi.hoisted(() => ({
|
|
handleMock: vi.fn(),
|
|
execFileMock: vi.fn(),
|
|
execFileAsyncMock: vi.fn(),
|
|
runWslProcessMock: 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(),
|
|
listLocalCommandPathsMock: 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()
|
|
}
|
|
})
|
|
|
|
// WSL commands now route through the runner, not execFile('wsl.exe', ...).
|
|
vi.mock('../wsl/wsl-runner', () => ({ runWslProcess: runWslProcessMock }))
|
|
|
|
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', async (importOriginal) => ({
|
|
...(await importOriginal<typeof LocalCommandResolver>()),
|
|
isCommandOnLocalPath: isCommandOnLocalPathMock,
|
|
listLocalCommandPaths: listLocalCommandPathsMock
|
|
}))
|
|
|
|
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, registerPreflightHandlers, runPreflightCheck } from './preflight'
|
|
import {
|
|
defaultAzureDevOpsStatus,
|
|
defaultBitbucketStatus,
|
|
defaultGiteaStatus,
|
|
resetPreflightMocks,
|
|
type HandlerMap
|
|
} from './preflight-test-harness'
|
|
|
|
describe('preflight', () => {
|
|
const originalPlatform = process.platform
|
|
const handlers: HandlerMap = {}
|
|
|
|
beforeEach(() => {
|
|
resetPreflightMocks(
|
|
{
|
|
handleMock,
|
|
execFileAsyncMock,
|
|
runWslProcessMock,
|
|
hydrateShellPathMock,
|
|
mergePathSegmentsMock,
|
|
getActiveMultiplexerMock,
|
|
getBitbucketAuthStatusMock,
|
|
getAzureDevOpsAuthStatusMock,
|
|
getGiteaAuthStatusMock,
|
|
resolveCliCommandsMock,
|
|
isCommandOnLocalPathMock,
|
|
listLocalCommandPathsMock,
|
|
mergePersistedWindowsPathAsyncMock,
|
|
mergePersistedWindowsPathMock
|
|
},
|
|
handlers
|
|
)
|
|
})
|
|
|
|
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,
|
|
windowsHide: true
|
|
})
|
|
expect(execFileAsyncMock).toHaveBeenNthCalledWith(5, 'glab', ['auth', 'status'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
windowsHide: true
|
|
})
|
|
})
|
|
|
|
it.each(['gh', 'glab'])('does not switch %s copies after authentication fails', async (cli) => {
|
|
const first = `/test/first/${cli}`
|
|
const second = `/test/second/${cli}`
|
|
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
|
|
command === cli ? [first, second] : []
|
|
)
|
|
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
|
if (command === first && args[0] === 'auth') {
|
|
throw Object.assign(new Error('not authenticated'), { code: 1, stderr: 'not logged in' })
|
|
}
|
|
return { stdout: 'fixture success', stderr: '' }
|
|
})
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(cli === 'gh' ? status.gh : status.glab).toEqual({
|
|
installed: true,
|
|
authenticated: false
|
|
})
|
|
expect(
|
|
execFileAsyncMock.mock.calls.filter(([command]) => command === first).map(([, args]) => args)
|
|
).toEqual([['--version'], ['auth', 'status']])
|
|
expect(execFileAsyncMock.mock.calls.some(([command]) => command === second)).toBe(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 })
|
|
})
|
|
|
|
// Why (#22975): these two cases key the spawn mock on (command, args) instead
|
|
// of call order, because the fallback probes exactly which copy runs is the
|
|
// behaviour under test — and `gh auth status` runs in parallel with
|
|
// `glab auth status`, so a fixed sequence would not be the real one.
|
|
it('authenticates the gh copy that actually ran, not the shim PATH chose', async () => {
|
|
const shim = '/Users/octocat/.asdf/shims/gh'
|
|
const working = '/opt/homebrew/bin/gh'
|
|
const glabShim = '/Users/octocat/.asdf/shims/glab'
|
|
const glabWorking = '/usr/local/bin/glab'
|
|
const shims = new Set([shim, glabShim])
|
|
listLocalCommandPathsMock.mockImplementation(async (command: string) => {
|
|
if (command === 'gh') {
|
|
return [shim, working]
|
|
}
|
|
return command === 'glab' ? [glabShim, glabWorking] : []
|
|
})
|
|
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
|
if (shims.has(command)) {
|
|
throw Object.assign(new Error('spawn failed'), { code: 126 })
|
|
}
|
|
const auth = args[0] === 'auth'
|
|
if (command === working) {
|
|
return { stdout: auth ? 'github.com\n - Active account: true\n' : 'gh version 2.6.0\n' }
|
|
}
|
|
if (command === glabWorking) {
|
|
return { stdout: auth ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n' }
|
|
}
|
|
if (command === 'git') {
|
|
return { stdout: 'git version 2.0.0\n' }
|
|
}
|
|
throw new Error(`unexpected command ${command}`)
|
|
})
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status).toMatchObject({
|
|
gh: { installed: true, authenticated: true },
|
|
glab: { installed: true, authenticated: true }
|
|
})
|
|
for (const [cli, copy] of [
|
|
['gh', working],
|
|
['glab', glabWorking]
|
|
]) {
|
|
expect(execFileAsyncMock).toHaveBeenCalledWith(copy, ['auth', 'status'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
windowsHide: true
|
|
})
|
|
expect(execFileAsyncMock).not.toHaveBeenCalledWith(cli, ['auth', 'status'], {
|
|
encoding: 'utf-8',
|
|
timeout: 5000,
|
|
windowsHide: true
|
|
})
|
|
}
|
|
})
|
|
|
|
it('marks gh not installed when no copy PATH offers can run', async () => {
|
|
const doomed = ['/Users/octocat/.asdf/shims/gh', '/Users/octocat/.local/bin/gh']
|
|
listLocalCommandPathsMock.mockImplementation(async (command: string) =>
|
|
command === 'gh' ? doomed : []
|
|
)
|
|
execFileAsyncMock.mockImplementation(async (command: string, args: string[]) => {
|
|
if (doomed.includes(command)) {
|
|
throw Object.assign(new Error('spawn failed'), { code: 126 })
|
|
}
|
|
if (command === 'git') {
|
|
return { stdout: 'git version 2.0.0\n' }
|
|
}
|
|
if (command === 'glab') {
|
|
return {
|
|
stdout: args[0] === 'auth' ? 'Logged in to gitlab.com\n' : 'glab version 1.92.1\n'
|
|
}
|
|
}
|
|
throw new Error(`unexpected command ${command}`)
|
|
})
|
|
|
|
const status = await runPreflightCheck()
|
|
|
|
expect(status.gh).toEqual({ installed: false, authenticated: false })
|
|
expect(execFileAsyncMock).toHaveBeenCalledTimes(5)
|
|
})
|
|
|
|
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) => {
|
|
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' })
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
|
if (script.includes('gh') && script.includes('--version')) {
|
|
return {
|
|
environmentResolved: true,
|
|
code: 0,
|
|
stdout: 'gh version 2.0.0\n',
|
|
stderr: '',
|
|
timedOut: false
|
|
}
|
|
}
|
|
if (script.includes('gh') && script.includes('auth status')) {
|
|
return {
|
|
environmentResolved: true,
|
|
code: 0,
|
|
stdout: 'github.com\n - Active account: true\n',
|
|
stderr: '',
|
|
timedOut: false
|
|
}
|
|
}
|
|
throw new Error(`unexpected WSL script ${script}`)
|
|
})
|
|
|
|
const status = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
|
expect(runWslProcessMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
distro: 'Ubuntu',
|
|
loginPath: 'preferred',
|
|
script: expect.stringMatching(/gh[\s\S]*--version/)
|
|
})
|
|
)
|
|
expect(runWslProcessMock).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
distro: 'Ubuntu',
|
|
loginPath: 'preferred',
|
|
script: expect.stringMatching(/gh[\s\S]*auth status/)
|
|
})
|
|
)
|
|
})
|
|
|
|
describe('WSL preflight caching', () => {
|
|
// Why win32 + these stubs: a WSL target probes git/gh/glab through the
|
|
// runner, so every case below counts runner spawns rather than execFile.
|
|
function stubWslProbes(): void {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
execFileAsyncMock.mockImplementation(async () => {
|
|
throw Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' })
|
|
})
|
|
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => ({
|
|
environmentResolved: true,
|
|
code: 0,
|
|
stdout: script.includes('auth status')
|
|
? 'github.com\n - Active account: true\n'
|
|
: 'version 2.0.0\n',
|
|
stderr: '',
|
|
timedOut: false
|
|
}))
|
|
}
|
|
|
|
it('reuses the cached result instead of re-spawning wsl.exe probes', async () => {
|
|
stubWslProbes()
|
|
|
|
const first = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
const spawnsAfterFirst = runWslProcessMock.mock.calls.length
|
|
const second = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(spawnsAfterFirst).toBeGreaterThan(0)
|
|
expect(runWslProcessMock.mock.calls.length).toBe(spawnsAfterFirst)
|
|
expect(second).toEqual(first)
|
|
})
|
|
|
|
it('keeps each distro on its own cache entry', async () => {
|
|
stubWslProbes()
|
|
|
|
await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
const spawnsAfterUbuntu = runWslProcessMock.mock.calls.length
|
|
await runPreflightCheck(false, { wslDistro: 'Debian' })
|
|
|
|
expect(runWslProcessMock.mock.calls.length).toBeGreaterThan(spawnsAfterUbuntu)
|
|
expect(runWslProcessMock).toHaveBeenCalledWith(expect.objectContaining({ distro: 'Debian' }))
|
|
})
|
|
|
|
it('re-probes when the caller forces a refresh', async () => {
|
|
stubWslProbes()
|
|
|
|
await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
const spawnsAfterFirst = runWslProcessMock.mock.calls.length
|
|
await runPreflightCheck(true, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(runWslProcessMock.mock.calls.length).toBeGreaterThan(spawnsAfterFirst)
|
|
})
|
|
|
|
it('collapses concurrent callers onto one probe set', async () => {
|
|
stubWslProbes()
|
|
|
|
const [first, second] = await Promise.all([
|
|
runPreflightCheck(false, { wslDistro: 'Ubuntu' }),
|
|
runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
])
|
|
const singleRunSpawns = runWslProcessMock.mock.calls.length
|
|
|
|
runWslProcessMock.mockClear()
|
|
await runPreflightCheck(true, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(first).toEqual(second)
|
|
// One shared run, not two: a second full set would double the spawn count.
|
|
expect(singleRunSpawns).toBe(runWslProcessMock.mock.calls.length)
|
|
})
|
|
|
|
// Why this matters: an unreachable distro reports the same `installed:
|
|
// false` as a distro with no tooling, so a session-lifetime cache would pin
|
|
// "git not installed" until relaunch. The TTL must let it recover.
|
|
// Why these two: a slower run settling last must not overwrite a newer
|
|
// answer, or a forced refresh silently returns to the status it replaced.
|
|
it('does not let a superseded probe overwrite a newer forced refresh', async () => {
|
|
stubWslProbes()
|
|
// Why gate on phase captured at call time: the stale run's three
|
|
// --version probes all start before the refresh, and all report nothing
|
|
// installed, so it never issues auth calls. The two runs are then
|
|
// trivially distinguishable in the cached result.
|
|
let phase: 'stale' | 'fresh' = 'stale'
|
|
let releaseStale!: () => void
|
|
const staleGate = new Promise<void>((resolve) => {
|
|
releaseStale = resolve
|
|
})
|
|
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
|
const isStale = phase === 'stale'
|
|
if (isStale) {
|
|
await staleGate
|
|
}
|
|
return {
|
|
environmentResolved: true,
|
|
code: isStale ? 1 : 0,
|
|
stdout: isStale
|
|
? ''
|
|
: script.includes('auth status')
|
|
? 'github.com\n - Active account: true\n'
|
|
: 'version 2.0.0\n',
|
|
stderr: '',
|
|
timedOut: false
|
|
}
|
|
})
|
|
|
|
const stalePending = runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
phase = 'fresh'
|
|
const fresh = await runPreflightCheck(true, { wslDistro: 'Ubuntu' })
|
|
expect(fresh.git).toEqual({ installed: true })
|
|
|
|
releaseStale()
|
|
const staleResult = await stalePending
|
|
expect(staleResult.git).toEqual({ installed: false })
|
|
|
|
// The superseded run still returns its own answer to its own caller, but
|
|
// must not have become the cached one.
|
|
const cachedNow = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
expect(cachedNow.git).toEqual({ installed: true })
|
|
})
|
|
|
|
it('does not repopulate a cache that was reset while a probe was in flight', async () => {
|
|
stubWslProbes()
|
|
let release!: () => void
|
|
const gate = new Promise<void>((resolve) => {
|
|
release = resolve
|
|
})
|
|
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
|
await gate
|
|
return {
|
|
environmentResolved: true,
|
|
code: 0,
|
|
stdout: script.includes('auth status')
|
|
? 'github.com\n - Active account: true\n'
|
|
: 'version 2.0.0\n',
|
|
stderr: '',
|
|
timedOut: false
|
|
}
|
|
})
|
|
|
|
const inFlight = runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
_resetPreflightCache()
|
|
release()
|
|
await inFlight
|
|
|
|
runWslProcessMock.mockClear()
|
|
await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
expect(runWslProcessMock.mock.calls.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
it('re-probes after the cache entry expires so a transient failure self-heals', async () => {
|
|
vi.useFakeTimers()
|
|
try {
|
|
stubWslProbes()
|
|
runWslProcessMock.mockRejectedValue(new Error('distro not running'))
|
|
|
|
const failed = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
expect(failed.git).toEqual({ installed: false })
|
|
|
|
stubWslProbes()
|
|
runWslProcessMock.mockClear()
|
|
vi.advanceTimersByTime(30_000 + 1)
|
|
const recovered = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
|
|
|
expect(runWslProcessMock.mock.calls.length).toBeGreaterThan(0)
|
|
expect(recovered.git).toEqual({ installed: true })
|
|
} finally {
|
|
vi.useRealTimers()
|
|
}
|
|
})
|
|
})
|
|
|
|
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,
|
|
windowsHide: true,
|
|
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) => {
|
|
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' }))
|
|
}
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
runWslProcessMock.mockImplementation(({ script }: { script: string }) => {
|
|
if (script.includes("'gh' --version")) {
|
|
return new Promise(() => {})
|
|
}
|
|
if (script.includes("'glab' --version")) {
|
|
return Promise.reject(Object.assign(new Error('spawn ENOENT'), { code: 'ENOENT' }))
|
|
}
|
|
throw new Error(`unexpected WSL script ${script}`)
|
|
})
|
|
|
|
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) => {
|
|
throw new Error(`unexpected command ${String(command)}`)
|
|
})
|
|
runWslProcessMock.mockImplementation(async ({ script }: { script: string }) => {
|
|
const ok = (stdout: string) => ({
|
|
environmentResolved: true,
|
|
code: 0,
|
|
stdout,
|
|
stderr: '',
|
|
timedOut: false
|
|
})
|
|
if (script.includes('git') && script.includes('--version')) {
|
|
return ok('git version 2.0.0\n')
|
|
}
|
|
if (script.includes('gh') && script.includes('--version')) {
|
|
return ok('gh version 2.0.0\n')
|
|
}
|
|
if (script.includes('glab') && script.includes('--version')) {
|
|
return ok('glab version 1.92.1\n')
|
|
}
|
|
if (script.includes('gh') && script.includes('auth status')) {
|
|
return ok('github.com\n - Active account: true\n')
|
|
}
|
|
if (script.includes('glab') && script.includes('auth status')) {
|
|
return ok('Logged in to gitlab.com\n')
|
|
}
|
|
throw new Error(`unexpected WSL script ${script}`)
|
|
})
|
|
|
|
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('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()
|
|
})
|
|
})
|