mirror of
https://github.com/stablyai/orca.git
synced 2026-10-06 16:02:25 +00:00
Fix WSL tool detection on Windows (#2218)
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslModule from '../wsl'
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
|
||||
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
spawnMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
@@ -12,6 +15,11 @@ vi.mock('child_process', () => ({
|
||||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslModule>()),
|
||||
getDefaultWslDistro: getDefaultWslDistroMock
|
||||
}))
|
||||
|
||||
import { ghExecFileAsync, glabExecFileAsync } from './runner'
|
||||
|
||||
describe('ghExecFileAsync WSL fallback', () => {
|
||||
@@ -19,6 +27,8 @@ describe('ghExecFileAsync WSL fallback', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReturnValue(null)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
@@ -257,6 +267,30 @@ describe('ghExecFileAsync WSL fallback', () => {
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => {
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(null, { stdout: '{"resources":{}}', stderr: '' })
|
||||
})
|
||||
|
||||
await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({
|
||||
stdout: '{"resources":{}}',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "gh 'api' 'rate_limit'"],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry non-idempotent glab transient failures', async () => {
|
||||
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
|
||||
callback(
|
||||
@@ -295,6 +329,30 @@ describe('ghExecFileAsync WSL fallback', () => {
|
||||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => {
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(null, { stdout: '[]', stderr: '' })
|
||||
})
|
||||
|
||||
await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({
|
||||
stdout: '[]',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "glab 'api' 'projects'"],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('still retries idempotent glab transient failures', async () => {
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
|
||||
+59
-2
@@ -11,7 +11,7 @@ consistent across every repo-scoped subprocess call. */
|
||||
*/
|
||||
import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -121,6 +121,26 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
|
||||
const distro = getDefaultWslDistro()
|
||||
return distro ? resolveCommand(command, args, undefined, distro) : null
|
||||
}
|
||||
|
||||
function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
|
||||
if (e.code === 'ENOENT') {
|
||||
return true
|
||||
}
|
||||
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
|
||||
return (
|
||||
message.includes('enoent') &&
|
||||
(message.includes(command) || e.path === command || e.syscall === 'spawn')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a command, its arguments, and a working directory, resolve whether
|
||||
* the invocation should be routed through wsl.exe.
|
||||
@@ -502,6 +522,7 @@ export async function ghExecFileAsync(
|
||||
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedHostFallback = false
|
||||
let attemptedDefaultWslFallback = false
|
||||
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
|
||||
@@ -515,6 +536,24 @@ export async function ghExecFileAsync(
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
const { stderr } = extractExecError(err)
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
!attemptedDefaultWslFallback &&
|
||||
resolved.wsl === null &&
|
||||
!options.cwd &&
|
||||
!options.wslDistro &&
|
||||
isHostCommandMissing(err, 'gh')
|
||||
) {
|
||||
const wslResolved = resolveDefaultWslCli('gh', args)
|
||||
if (wslResolved) {
|
||||
// Why: WSL-only Windows installs have no gh.exe on the host PATH, but
|
||||
// global calls like rate_limit/auth do not carry a repo cwd to route by.
|
||||
resolved = wslResolved
|
||||
attemptedDefaultWslFallback = true
|
||||
attempt = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
|
||||
resolved = resolveHostGitHubCli('gh', args)
|
||||
attemptedHostFallback = true
|
||||
@@ -578,8 +617,9 @@ export async function glabExecFileAsync(
|
||||
args: string[],
|
||||
options: GlabExecOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedDefaultWslFallback = false
|
||||
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
|
||||
@@ -593,6 +633,23 @@ export async function glabExecFileAsync(
|
||||
} catch (err) {
|
||||
lastError = err
|
||||
const { stderr } = extractExecError(err)
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
!attemptedDefaultWslFallback &&
|
||||
resolved.wsl === null &&
|
||||
!options.cwd &&
|
||||
!options.wslDistro &&
|
||||
isHostCommandMissing(err, 'glab')
|
||||
) {
|
||||
const wslResolved = resolveDefaultWslCli('glab', args)
|
||||
if (wslResolved) {
|
||||
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
|
||||
resolved = wslResolved
|
||||
attemptedDefaultWslFallback = true
|
||||
attempt = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
|
||||
// Why: mirror gh's write-safety gate. A transient error after GitLab
|
||||
// applies a POST/PATCH/PUT/DELETE must not create duplicate comments,
|
||||
|
||||
Reference in New Issue
Block a user