Files
orca/src/main/git/runner-wsl-gh-fallback.test.ts
T

266 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as BackgroundDistroGate from '../wsl/discovery/wsl-background-distro-gate'
import {
createFakeSpawnedChild,
fakeSpawnDispatch,
fakeSpawnReturning
} from '../../shared/__fixtures__/fake-spawned-child'
import type * as WslModule from '../wsl/discovery/distro-catalog'
import { windowsSystem32Binary } from '@orca/process-host/windows-system-binary'
const { execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
execFileSyncMock: vi.fn(),
spawnMock: vi.fn(),
getDefaultWslDistroMock: vi.fn()
}))
// The default-distro fallback asks whether the distro runs before it may start it (E9).
vi.mock('../wsl/discovery/wsl-background-distro-gate', async (importOriginal) => ({
...(await importOriginal<typeof BackgroundDistroGate>()),
readWslDistroRunningState: vi.fn(async () => 'running')
}))
vi.mock('child_process', () => ({
execFile: vi.fn(),
execFileSync: execFileSyncMock,
spawn: spawnMock
}))
vi.mock('../wsl/discovery/distro-catalog', async (importOriginal) => ({
...(await importOriginal<typeof WslModule>()),
getDefaultWslDistro: getDefaultWslDistroMock
}))
import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner'
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
const PRIMARY_RATE_LIMIT_STDERR =
'gh: API rate limit exceeded for user ID 1775218. Please wait. (HTTP 403)'
// What the distro prints when the CLI is absent inside WSL but present on the host.
const WSL_GH_MISSING = 'bash: line 1: gh: command not found\n'
const TRANSIENT_502 = 'HTTP 502 Bad Gateway'
function spawnEnoent(command: string): { spawnError: Error } {
return { spawnError: Object.assign(new Error(`spawn ${command} ENOENT`), { code: 'ENOENT' }) }
}
describe('ghExecFileAsync WSL fallback', () => {
const originalPlatform = process.platform
beforeEach(() => {
spawnMock.mockReset()
getDefaultWslDistroMock.mockReset()
getDefaultWslDistroMock.mockReturnValue(null)
setDefaultWslDistroOverride(null)
_resetGhRateLimitBreaker()
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'win32'
})
})
afterEach(() => {
vi.useRealTimers()
_resetGhRateLimitBreaker()
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
})
it('does not fall back for repo-context gh calls without explicit repo context', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 }))
await expect(
ghExecFileAsync(['issue', 'list'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
})
).rejects.toThrow('gh: command not found')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not fall back for gh api calls that depend on repo-context placeholders', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: WSL_GH_MISSING, code: 1 }))
await expect(
ghExecFileAsync(['api', 'repos/stablyhq/noqa/branches/{branch}'], {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
})
).rejects.toThrow('gh: command not found')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry non-idempotent gh API transient failures', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync(['api', '-X', 'POST', 'repos/stablyai/orca/issues'])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry gh GraphQL mutation transient failures', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync([
'api',
'graphql',
'-f',
'query=mutation { addStar(input: {}) { starrable { id } } }'
])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('does not retry high-level gh edit transient failures', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
ghExecFileAsync(['issue', 'edit', '5', '--repo', 'stablyai/orca'])
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('checks a blocked WSL scope before repeating a native-to-WSL fallback', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'gh' ? spawnEnoent('gh') : { stderr: PRIMARY_RATE_LIMIT_STDERR, code: 1 }
)
)
await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toThrow('rate limit')
await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'])).rejects.toMatchObject({
ghRateLimitBlocked: true
})
expect(spawnMock).toHaveBeenCalledTimes(3)
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(['gh', 'wsl.exe', 'gh'])
})
it('checks a blocked native scope before repeating a WSL-to-native fallback', async () => {
spawnMock.mockImplementation(
fakeSpawnDispatch((program) =>
program === 'wsl.exe'
? { stderr: WSL_GH_MISSING, code: 1 }
: { stderr: PRIMARY_RATE_LIMIT_STDERR, code: 1 }
)
)
const options = {
cwd: String.raw`\\wsl.localhost\Ubuntu\home\jinwoo\stably\noqa`
}
await expect(ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)).rejects.toThrow(
'rate limit'
)
await expect(
ghExecFileAsync(['api', 'repos/acme/widgets/pulls'], options)
).rejects.toMatchObject({ ghRateLimitBlocked: true })
expect(spawnMock).toHaveBeenCalledTimes(3)
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(['wsl.exe', 'gh', 'wsl.exe'])
})
it('does not retry non-idempotent glab transient failures', async () => {
spawnMock.mockImplementation(fakeSpawnReturning({ stderr: TRANSIENT_502, code: 1 }))
await expect(
glabExecFileAsync(['api', '-X', 'POST', 'projects/stablyai%2Forca/issues/5/notes'], {
cwd: String.raw`C:\repo`
})
).rejects.toThrow('HTTP 502 Bad Gateway')
expect(spawnMock).toHaveBeenCalledTimes(1)
})
it('times out the default-WSL glab fallback and waits for full tree cleanup', async () => {
vi.useFakeTimers()
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
const wslChild = createFakeSpawnedChild(2400)
const taskkill = createFakeSpawnedChild(3600)
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
// Keep the child unsettled until taskkill reports completion.
.mockImplementationOnce(() => wslChild)
.mockImplementation(() => taskkill)
const promise = glabExecFileAsync(['auth', 'status'], { timeout: 1000 })
const rejection = expect(promise).rejects.toThrow('wsl.exe timed out.')
let rejected = false
void promise.catch(() => {
rejected = true
})
await vi.advanceTimersByTimeAsync(999)
expect(spawnMock).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(1)
expect(spawnMock).toHaveBeenCalledWith(
windowsSystem32Binary('taskkill.exe'),
['/pid', '2400', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
await Promise.resolve()
expect(rejected).toBe(false)
taskkill.emit('close', 0)
await rejection
expect(wslChild.kill).not.toHaveBeenCalled()
})
it('aborts the default-WSL glab fallback with full process-tree cleanup', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
const wslChild = createFakeSpawnedChild(2400)
const taskkill = createFakeSpawnedChild(3600)
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
.mockImplementationOnce(() => wslChild)
.mockImplementation(() => taskkill)
const controller = new AbortController()
const promise = glabExecFileAsync(['auth', 'status'], { signal: controller.signal })
const rejection = expect(promise).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(2))
controller.abort()
expect(spawnMock).toHaveBeenNthCalledWith(
2,
'wsl.exe',
['-d', 'Ubuntu', '--exec', 'bash', '-c', "'glab' 'auth' 'status'"],
expect.not.objectContaining({ signal: controller.signal })
)
expect(spawnMock).toHaveBeenCalledWith(
windowsSystem32Binary('taskkill.exe'),
['/pid', '2400', '/t', '/f'],
expect.objectContaining({ stdio: 'ignore', windowsHide: true })
)
taskkill.emit('close', 0)
await rejection
expect(wslChild.kill).not.toHaveBeenCalled()
})
it('does not wake the default WSL distro for host-only GitLab diagnostics', async () => {
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
spawnMock
.mockImplementationOnce(fakeSpawnReturning(spawnEnoent('glab')))
.mockImplementationOnce(fakeSpawnReturning({ stdout: 'Logged in to gitlab.com' }))
await expect(
glabExecFileAsync(['auth', 'status'], { allowDefaultWslFallback: false })
).rejects.toThrow('spawn glab ENOENT')
expect(spawnMock).toHaveBeenCalledTimes(1)
expect(spawnMock).toHaveBeenCalledWith(
'glab',
['auth', 'status'],
expect.objectContaining({ cwd: undefined })
)
})
})