Files
wehub-resource-sync d18ada4ee7
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:24:41 +08:00

142 lines
4.3 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CLASSIFIER_RULES,
classify,
defaultMaxAttempts,
parseRetryAfter,
sanitizeOutput,
takeStderrTail,
virtualError
} from '../src/classifier'
describe('ErrorClassifier rules (design §7.1)', () => {
it('rule order matches the design doc', () => {
const order = CLASSIFIER_RULES.map((r) => r.category)
expect(order).toEqual([
'http-429',
'auth-required',
'geo-blocked',
'not-found',
'disk-full',
'permission-denied',
'binary-missing',
'ffmpeg',
'network-transient',
'stalled',
'cancelled-by-user',
'output-missing',
'unknown'
])
})
it.each([
['HTTP Error 429: Too Many Requests', 'http-429'],
['ERROR: Sign in to confirm your age', 'auth-required'],
['This video is not available in your country', 'geo-blocked'],
['HTTP Error 404: not found', 'not-found'],
['ENOSPC: no space left on device', 'disk-full'],
['EACCES: permission denied', 'permission-denied'],
['ffmpeg: not found', 'binary-missing'],
['Postprocessing: ffmpeg failed', 'ffmpeg'],
['ECONNRESET on socket', 'network-transient'],
['HTTP Error 503 Service Unavailable', 'network-transient'],
['nothing in particular', 'unknown']
])('classifies stderr %p as %s', (stderr, category) => {
const r = classify({ stderr })
expect(r.category).toBe(category)
})
it('binary-missing also fires on exit code 127', () => {
const r = classify({ stderr: 'random message', exitCode: 127 })
expect(r.category).toBe('binary-missing')
})
it('http-429 honors Retry-After from the explicit header', () => {
const r = classify({
stderr: 'HTTP Error 429: too many',
retryAfterHeader: '15'
})
expect(r.suggestedRetryAfterMs).toBe(15_000)
})
it('http-429 falls back to Retry-After parsed from stderr', () => {
const stderr = 'HTTP Error 429: too many\nRetry-After: 7\n'
const r = classify({ stderr })
expect(r.suggestedRetryAfterMs).toBe(7_000)
})
it('http-429 default backoff is 30s when no Retry-After present', () => {
const r = classify({ stderr: 'HTTP Error 429' })
expect(r.suggestedRetryAfterMs).toBe(30_000)
})
it('non-retryable categories carry retryable=false', () => {
for (const cat of [
'auth-required',
'geo-blocked',
'not-found',
'disk-full',
'permission-denied',
'binary-missing'
] as const) {
expect(defaultMaxAttempts(cat)).toBe(0)
const v = virtualError(cat, 'x')
expect(v.retryable).toBe(false)
}
})
it('retryable categories carry retryable=true and reasonable maxAttempts', () => {
expect(defaultMaxAttempts('http-429')).toBe(3)
expect(defaultMaxAttempts('network-transient')).toBe(5)
expect(defaultMaxAttempts('stalled')).toBe(3)
expect(defaultMaxAttempts('ffmpeg')).toBe(1)
expect(defaultMaxAttempts('unknown')).toBe(1)
})
})
describe('sanitizeOutput', () => {
it('redacts Authorization headers but keeps the key name', () => {
const out = sanitizeOutput('Authorization: Bearer abc.def')
expect(out).toBe('Authorization: <redacted>')
})
it('redacts cookie/token query-string values', () => {
expect(sanitizeOutput('?token=abc.def&foo=1')).toMatch(/<redacted>/)
expect(sanitizeOutput('password=hunter2')).toMatch(/<redacted>/)
})
})
describe('takeStderrTail', () => {
it('returns the input unchanged if under the budget', () => {
expect(takeStderrTail('hello', 10)).toBe('hello')
})
it('keeps trailing portion when too large', () => {
const big = 'x'.repeat(20_000)
const tail = takeStderrTail(big, 4_096)
expect(tail.length).toBeLessThanOrEqual(big.length)
expect(Buffer.byteLength(tail, 'utf8')).toBeLessThanOrEqual(4_096)
})
})
describe('parseRetryAfter', () => {
it('parses delta-seconds', () => {
expect(parseRetryAfter('120')).toBe(120_000)
expect(parseRetryAfter('0')).toBe(0)
})
it('parses HTTP-date', () => {
const future = new Date(Date.now() + 5_000).toUTCString()
const ms = parseRetryAfter(future)
expect(ms).not.toBeNull()
expect(ms!).toBeGreaterThan(0)
})
it('returns null for nonsense', () => {
expect(parseRetryAfter('')).toBeNull()
expect(parseRetryAfter(null)).toBeNull()
expect(parseRetryAfter('not-a-date')).toBeNull()
})
})