| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- import { afterEach, describe, expect, it, vi } from 'vitest'
- import { clampTimeout, deadline, timeoutOf, TimeoutReason } from '@deepseek-ai/dsh-timeout'
- describe('TimeoutReason', () => {
- it('is an Error carrying the code and elapsed ms', () => {
- const reason = new TimeoutReason('BASH_TIMEOUT', 100)
- expect(reason).toBeInstanceOf(Error)
- expect(reason.name).toBe('TimeoutReason')
- expect(reason.code).toBe('BASH_TIMEOUT')
- expect(reason.timeoutMs).toBe(100)
- expect(reason.message).toBe('BASH_TIMEOUT after 100ms')
- })
- })
- describe('clampTimeout', () => {
- it('fills the default when the hint is absent', () => {
- expect(clampTimeout(undefined, 120_000, 600_000)).toBe(120_000)
- })
- it('caps the hint at max', () => {
- expect(clampTimeout(999_999, 120_000, 600_000)).toBe(600_000)
- })
- it('keeps a valid hint under the cap', () => {
- expect(clampTimeout(5_000, 120_000, 600_000)).toBe(5_000)
- })
- it('caps the default itself when the default exceeds max', () => {
- // min(def, max) applies even with no hint — a misconfigured backend never
- // exceeds its own cap.
- expect(clampTimeout(undefined, 900_000, 600_000)).toBe(600_000)
- })
- it('rejects a non-finite hint with the caller-provided name', () => {
- expect(() => clampTimeout(Number.NaN, 100, 200, 'bash-local: request.timeoutMs'))
- .toThrow(/bash-local: request\.timeoutMs must be a positive finite number/)
- expect(() => clampTimeout(Number.POSITIVE_INFINITY, 100, 200))
- .toThrow(/timeoutMs must be a positive finite number/)
- })
- it('rejects a non-positive hint', () => {
- expect(() => clampTimeout(0, 100, 200)).toThrow(/must be a positive finite number/)
- expect(() => clampTimeout(-1, 100, 200)).toThrow(/must be a positive finite number/)
- })
- })
- describe('deadline — timeout arm', () => {
- afterEach(() => { vi.useRealTimers() })
- it('aborts on timeout with a TimeoutReason after the elapsed ms', () => {
- vi.useFakeTimers()
- using d = deadline(undefined, 100, 'BASH_TIMEOUT')
- expect(d.signal.aborted).toBe(false)
- vi.advanceTimersByTime(100)
- expect(d.signal.aborted).toBe(true)
- const reason = timeoutOf(d.signal)
- expect(reason).toBeInstanceOf(TimeoutReason)
- expect(reason?.code).toBe('BASH_TIMEOUT')
- expect(reason?.timeoutMs).toBe(100)
- })
- it('[Symbol.dispose] clears the timer so no abort fires afterward', () => {
- vi.useFakeTimers()
- const d = deadline(undefined, 100, 'BASH_TIMEOUT')
- d[Symbol.dispose]()
- vi.advanceTimersByTime(1_000)
- expect(d.signal.aborted).toBe(false)
- expect(timeoutOf(d.signal)).toBeUndefined()
- })
- })
- describe('deadline — fuse with upstream', () => {
- it('aborts on upstream cancellation, classified as NOT a timeout', () => {
- const upstream = new AbortController()
- using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
- upstream.abort('user cancelled')
- expect(d.signal.aborted).toBe(true)
- expect(timeoutOf(d.signal)).toBeUndefined()
- })
- it('cancel wins when it fires before the timeout', () => {
- vi.useFakeTimers()
- try {
- const upstream = new AbortController()
- using d = deadline(upstream.signal, 100, 'BASH_TIMEOUT')
- upstream.abort('user cancelled') // fires first, before the 100ms timer
- vi.advanceTimersByTime(200)
- expect(d.signal.aborted).toBe(true)
- // AbortSignal.any adopts the FIRST source's reason: cancel won, so no
- // TimeoutReason even though the timer later elapsed.
- expect(timeoutOf(d.signal)).toBeUndefined()
- } finally {
- vi.useRealTimers()
- }
- })
- it('timeout wins when it fires before upstream cancellation', () => {
- vi.useFakeTimers()
- try {
- const upstream = new AbortController()
- using d = deadline(upstream.signal, 100, 'WEB_FETCH_TIMEOUT')
- vi.advanceTimersByTime(150) // past the 100ms deadline: the timer fires first
- expect(d.signal.aborted).toBe(true)
- expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
- // A later upstream abort is a no-op on the already-aborted fused signal:
- // AbortSignal.any keeps the FIRST cause, so the timeout classification stands.
- upstream.abort('too late')
- expect(timeoutOf(d.signal)?.code).toBe('WEB_FETCH_TIMEOUT')
- } finally {
- vi.useRealTimers()
- }
- })
- it('forwards a pre-aborted upstream signal immediately', () => {
- const upstream = new AbortController()
- upstream.abort('already gone')
- using d = deadline(upstream.signal, 60_000, 'BASH_TIMEOUT')
- expect(d.signal.aborted).toBe(true)
- expect(timeoutOf(d.signal)).toBeUndefined()
- })
- })
- describe('deadline — timeoutMs <= 0 (no-timeout sentinel)', () => {
- afterEach(() => { vi.useRealTimers() })
- it('arms no timer and forwards only the upstream signal', () => {
- vi.useFakeTimers()
- const upstream = new AbortController()
- using d = deadline(upstream.signal, 0, 'BASH_TIMEOUT')
- vi.advanceTimersByTime(1_000_000)
- expect(d.signal.aborted).toBe(false) // no timer ever armed
- upstream.abort('kill')
- expect(d.signal.aborted).toBe(true)
- expect(timeoutOf(d.signal)).toBeUndefined() // never a timeout
- })
- it('returns a never-aborting signal with a no-op disposer when there is no upstream', () => {
- vi.useFakeTimers()
- const d = deadline(undefined, 0, 'BASH_TIMEOUT')
- expect(() => { d[Symbol.dispose]() }).not.toThrow()
- vi.advanceTimersByTime(1_000_000)
- expect(d.signal.aborted).toBe(false)
- expect(timeoutOf(d.signal)).toBeUndefined()
- })
- it('treats a negative timeout the same as zero', () => {
- const d = deadline(undefined, -5, 'BASH_TIMEOUT')
- expect(d.signal.aborted).toBe(false)
- d[Symbol.dispose]()
- })
- })
- describe('timeoutOf', () => {
- it('classifies a bare reason carrier that holds a TimeoutReason', () => {
- const reason = new TimeoutReason('WEB_FETCH_TIMEOUT', 50)
- expect(timeoutOf({ reason })).toBe(reason)
- })
- it('returns undefined for a non-timeout reason', () => {
- expect(timeoutOf({ reason: new Error('other') })).toBeUndefined()
- expect(timeoutOf({ reason: 'user cancelled' })).toBeUndefined()
- expect(timeoutOf({})).toBeUndefined()
- })
- it('matches only the requested code when one is given', () => {
- const reason = new TimeoutReason('BASH_TIMEOUT', 100)
- expect(timeoutOf({ reason }, 'BASH_TIMEOUT')).toBe(reason)
- expect(timeoutOf({ reason }, 'WEB_FETCH_TIMEOUT')).toBeUndefined()
- })
- })
- describe('deadline — nested deadlines', () => {
- it("does not misclassify an outer deadline's timeout as the inner code", () => {
- // The upstream handed to the inner deadline is ITSELF a deadline that has
- // already timed out (outer). AbortSignal.any preserves the outer reason;
- // scoping timeoutOf to the inner code keeps the inner capability from
- // reporting the outer timeout as its own — it reads as an upstream cancel.
- const outer = new AbortController()
- outer.abort(new TimeoutReason('OUTER_TIMEOUT', 30))
- using inner = deadline(outer.signal, 60_000, 'BASH_TIMEOUT')
- expect(inner.signal.aborted).toBe(true)
- expect(timeoutOf(inner.signal, 'BASH_TIMEOUT')).toBeUndefined() // not ours → upstream-cancel path
- expect(timeoutOf(inner.signal)?.code).toBe('OUTER_TIMEOUT') // but IS a timeout, unscoped
- })
- })
|