Files
orca/src/main/git/runner.test.ts
T

110 lines
3.6 KiB
TypeScript

// Why: covers two recent classifier fixes — Retry-After honoring on 429
// (transient detection must propagate, not silently retry on 250ms cadence)
// and stderr extraction from execFile rejections (err.message is unreliable).
import { afterEach, describe, expect, it, vi } from 'vitest'
import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner'
afterEach(() => {
vi.restoreAllMocks()
})
describe('parseRetryAfterMs', () => {
it('returns null when no Retry-After is present', () => {
expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull()
})
it('parses integer seconds', () => {
expect(parseRetryAfterMs('HTTP 429\nRetry-After: 30\n')).toBe(30_000)
})
it('handles case-insensitive header name and surrounding whitespace', () => {
expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000)
})
it('parses large stderr output without full-string retry-after matching', () => {
const matchSpy = vi.spyOn(String.prototype, 'match')
const stderr = `${'noise\n'.repeat(10_000)}Retry-After: 12\n`
expect(parseRetryAfterMs(stderr)).toBe(12_000)
const usedRetryAfterMatch = matchSpy.mock.calls.some(
([pattern]) =>
pattern instanceof RegExp &&
pattern.source.startsWith('retry-after:') &&
pattern.source.includes('[^\\r\\n]')
)
expect(usedRetryAfterMatch).toBe(false)
})
it('returns null for malformed values', () => {
expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull()
})
})
describe('isTransientGhError', () => {
it('retries 5xx errors', () => {
expect(isTransientGhError('HTTP 502 Bad Gateway')).toBe(true)
expect(isTransientGhError('http 503')).toBe(true)
})
it('retries network resets', () => {
expect(isTransientGhError('connect ECONNRESET 10.0.0.1:443')).toBe(true)
expect(isTransientGhError('socket hang up')).toBe(true)
})
it('retries 429 without Retry-After', () => {
expect(isTransientGhError('HTTP 429 Too Many Requests')).toBe(true)
})
it('does NOT retry 429 with Retry-After', () => {
// Why: when GitHub returns Retry-After, the server is telling us how long
// to wait. Retrying on our 250ms cadence just earns another 429 and burns
// the retry budget.
expect(isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n')).toBe(false)
})
it("does NOT retry 4xx that aren't 429", () => {
expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false)
expect(isTransientGhError('HTTP 404 Not Found')).toBe(false)
expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false)
})
})
describe('extractExecError', () => {
it('reads stderr and stdout from explicit fields', () => {
const err = Object.assign(new Error('Command failed'), {
stderr: 'real stderr content',
stdout: '{"data": null}'
})
expect(extractExecError(err)).toEqual({
stderr: 'real stderr content',
stdout: '{"data": null}'
})
})
it('decodes Buffer stderr/stdout', () => {
const err = Object.assign(new Error('Command failed'), {
stderr: Buffer.from('buf-stderr', 'utf-8'),
stdout: Buffer.from('buf-stdout', 'utf-8')
})
expect(extractExecError(err)).toEqual({
stderr: 'buf-stderr',
stdout: 'buf-stdout'
})
})
it('falls back to err.message when stderr/stdout are absent', () => {
const err = new Error('Some message')
expect(extractExecError(err)).toEqual({
stderr: 'Some message',
stdout: ''
})
})
it('handles non-Error rejections', () => {
expect(extractExecError('plain string error')).toEqual({
stderr: 'plain string error',
stdout: ''
})
})
})