Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.

This commit is contained in:
2026-07-21 12:44:33 +10:00
parent fb64b1b2ea
commit e12698ab6f
20 changed files with 3123 additions and 408 deletions

View File

@@ -0,0 +1,188 @@
import { expect, test, vi } from 'vitest';
import { AsyncPushIterator } from '../../src/utils/async-push-iterator.js';
/**
* Collects every value from the iterator into an array.
*
* @param iterator - Iterator under test.
*/
const collectAll = async <T>(iterator: AsyncPushIterator<T>): Promise<T[]> => {
const results: T[] = [];
for await (const value of iterator) {
results.push(value);
}
return results;
};
/**
* Tests that values pushed while a consumer is already waiting are delivered in order.
*/
const testPushComposedPushAndConsume = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that values pushed before `for await...of` starts are buffered and yielded
* once the consumer begins reading.
*/
const testPushComposedBuffersValuesPushedBeforeLoopStarts = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
iterator.push(2);
iterator.push(3);
const result = collectAll(iterator);
iterator.close();
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that the iterator completes with no values when nothing was pushed.
*/
const testPushComposedResolvesWithNoValues = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.close();
await expect(result).resolves.toEqual([]);
};
/**
* Tests that values pushed after {@link AsyncPushIterator.close} are ignored.
*/
const testPushComposedIgnoresValuesAfterClose = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const result = new Promise<number[]>((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
iterator.push(1);
iterator.push(2);
iterator.push(3);
iterator.close();
iterator.push(4);
await expect(result).resolves.toEqual([ 1, 2, 3 ]);
};
/**
* Tests that only one async consumer can read from the composed ReadableStream at a time.
*
* Unlike the hand-rolled async-push-iterator, the second consumer fails with a
* stream lock error rather than TooManyAsyncIteratorsError.
*/
const testPushComposedRejectsMultipleConsumers = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
const failureFlag = vi.fn();
const successfulIterator = (): Promise<number[]> =>
new Promise((resolve) => {
void (async (): Promise<void> => {
resolve(await collectAll(iterator));
})();
});
const failedIterator = (): Promise<void> =>
new Promise((resolve, reject) => {
void (async (): Promise<void> => {
try {
/* eslint-disable-next-line */
for await (const _value of iterator) {
}
} catch (error) {
failureFlag();
reject(error);
}
resolve();
})();
});
const promises = [ successfulIterator(), failedIterator().catch(() => {}) ];
iterator.close();
await Promise.all(promises);
expect(failureFlag).toHaveBeenCalledOnce();
};
/**
* Tests that closing before iteration starts lets the loop finish immediately.
*/
const testPushComposedResolvesWhenClosedBeforeLoop = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.close();
await expect(collectAll(iterator)).resolves.toEqual([]);
};
/**
* Tests that breaking out of `for await...of` early does not cancel the stream.
*
* {@link AsyncPushIterator} uses `preventCancel: true` so producers can keep pushing
* and a later consumer can read the remaining values.
*/
const testPushComposedAllowsPushingAfterEarlyBreak = async (): Promise<void> => {
const iterator = new AsyncPushIterator<number>();
iterator.push(1);
const firstPass: number[] = [];
for await (const value of iterator) {
firstPass.push(value);
break;
}
iterator.push(2);
iterator.push(3);
iterator.close();
const secondPass = await collectAll(iterator);
expect(firstPass).toEqual([ 1 ]);
expect(secondPass).toEqual([ 2, 3 ]);
};
const runTests = async (): Promise<void> => {
test('AsyncPushIterator (composed): pushes and consumes values', testPushComposedPushAndConsume);
test('AsyncPushIterator (composed): buffers values pushed before the for-await loop starts', testPushComposedBuffersValuesPushedBeforeLoopStarts);
test('AsyncPushIterator (composed): resolves with no values when nothing was pushed', testPushComposedResolvesWithNoValues);
test('AsyncPushIterator (composed): ignores values pushed after close', testPushComposedIgnoresValuesAfterClose);
test('AsyncPushIterator (composed): rejects multiple consumers', testPushComposedRejectsMultipleConsumers);
test('AsyncPushIterator (composed): resolves immediately when closed before the loop starts', testPushComposedResolvesWhenClosedBeforeLoop);
test('AsyncPushIterator (composed): keeps the stream open after an early break', testPushComposedAllowsPushingAfterEarlyBreak);
};
await runTests();

View File

@@ -0,0 +1,505 @@
import { expect, test, vi } from 'vitest';
import { EventEmitter } from '../../src/utils/event-emitter.js';
/** Simple event map used across these tests. */
type TestEvents = {
message: string;
count: number;
};
/**
* Tests that EventEmitter invokes listeners when an event is emitted.
*/
const testEventEmitterCallsListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Register the listener and emit an event.
emitter.on('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been called with the emitted payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('hello');
};
/**
* Tests that EventEmitter invokes all listeners registered for the same event.
*/
const testEventEmitterCallsMultipleListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const firstListener = vi.fn();
const secondListener = vi.fn();
// Register two listeners for the same event type.
emitter.on('count', firstListener);
emitter.on('count', secondListener);
emitter.emit('count', 42);
// Expect both listeners to receive the same payload.
expect(firstListener).toHaveBeenCalledOnce();
expect(firstListener).toHaveBeenCalledWith(42);
expect(secondListener).toHaveBeenCalledOnce();
expect(secondListener).toHaveBeenCalledWith(42);
};
/**
* Tests that EventEmitter only invokes listeners registered for the emitted event type.
*/
const testEventEmitterCallsOnlyMatchingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
// Register listeners on different event types.
emitter.on('message', messageListener);
emitter.on('count', countListener);
// Emit only the message event.
emitter.emit('message', 'hello');
// Expect only the matching listener to have been called.
expect(messageListener).toHaveBeenCalledOnce();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that EventEmitter.emit returns false when no listeners are registered.
*/
const testEventEmitterEmitReturnsFalseWithNoListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that nobody was listening.
expect(hasListeners).toBe(false);
};
/**
* Tests that EventEmitter.emit returns true when listeners are registered.
*/
const testEventEmitterEmitReturnsTrueWithListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
emitter.on('message', vi.fn());
const hasListeners = emitter.emit('message', 'hello');
// Expect emit to report that at least one listener was invoked.
expect(hasListeners).toBe(true);
};
/**
* Tests that the off callback returned by on() removes the listener.
*/
const testEventEmitterOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// on() returns an off callback that removes the listener.
const off = emitter.on('message', listener);
emitter.emit('message', 'first');
// Unsubscribe before emitting again.
off();
emitter.emit('message', 'second');
// Expect the listener to have only received the first event.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that off() removes a listener when given the same function reference.
*/
const testEventEmitterOffRemovesListenerByReference = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
emitter.off('message', listener);
emitter.emit('message', 'hello');
// Expect the listener to have been removed before the emit.
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that off() does nothing when given an unknown listener reference.
*/
const testEventEmitterOffIgnoresUnknownListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener);
// Try to remove a different function reference.
emitter.off('message', vi.fn());
emitter.emit('message', 'hello');
// Expect the original listener to still receive the event.
expect(listener).toHaveBeenCalledOnce();
};
/**
* Tests that off() does nothing when called for an event type with no listeners.
*/
const testEventEmitterOffIgnoresUnregisteredEventType = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// Call off without ever registering this listener.
emitter.off('message', listener);
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that once() listeners are invoked only one time.
*/
const testEventEmitterOnceListenerFiresOnce = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
// Expect the listener to auto-unsubscribe after the first emit.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('first');
};
/**
* Tests that once() can be added when regular listeners already exist for the event type.
*/
const testEventEmitterOnceWorksWithExistingListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const existingListener = vi.fn();
const onceListener = vi.fn();
// Register a regular listener first so the event type already exists in the map.
emitter.on('message', existingListener);
emitter.once('message', onceListener);
emitter.emit('message', 'hello');
expect(existingListener).toHaveBeenCalledOnce();
expect(onceListener).toHaveBeenCalledOnce();
};
/**
* Tests that the off callback returned by once() removes the listener before it fires.
*/
const testEventEmitterOnceOffCallbackRemovesListener = (): void => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const off = emitter.once('message', listener);
// Unsubscribe before the event is ever emitted.
off();
emitter.emit('message', 'hello');
expect(listener).not.toHaveBeenCalled();
};
/**
* Tests that removeAllListeners() clears every registered listener.
*/
const testEventEmitterRemoveAllListeners = (): void => {
const emitter = new EventEmitter<TestEvents>();
const messageListener = vi.fn();
const countListener = vi.fn();
emitter.on('message', messageListener);
emitter.on('count', countListener);
emitter.removeAllListeners();
// Emit on both event types after clearing all listeners.
emitter.emit('message', 'hello');
emitter.emit('count', 1);
expect(messageListener).not.toHaveBeenCalled();
expect(countListener).not.toHaveBeenCalled();
};
/**
* Tests that waitFor() resolves when a matching event is emitted.
*/
const testEventEmitterWaitForResolvesOnMatch = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
// Wait until an event matches the predicate.
const waitPromise = emitter.waitFor('count', (payload) => payload === 42);
// Emit a non-matching event first, then the matching one.
emitter.emit('count', 41);
emitter.emit('count', 42);
await expect(waitPromise).resolves.toBe(42);
};
/**
* Tests that waitFor() ignores non-matching events while other listeners still receive them.
*/
const testEventEmitterWaitForIgnoresNonMatchingEvents = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done');
// A regular listener should still receive every emit while waitFor filters.
emitter.on('message', listener);
emitter.emit('message', 'pending');
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
expect(listener).toHaveBeenCalledTimes(2);
};
/**
* Tests that waitFor() rejects when the timeout expires.
*/
const testEventEmitterWaitForRejectsOnTimeout = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true, 100);
// Attach the rejection handler before advancing timers so the rejection is handled.
const assertion = expect(waitPromise).rejects.toThrow('Timeout waiting for event "message"');
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() clears its timeout when it resolves before expiry.
*/
const testEventEmitterWaitForClearsTimeoutOnResolve = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
// Register waitFor with a timeout, then resolve it before the timer fires.
const waitPromise = emitter.waitFor('message', (payload) => payload === 'done', 100);
emitter.emit('message', 'done');
await expect(waitPromise).resolves.toBe('done');
// If clearTimeout was not called, advancing past the timeout would reject the promise.
await vi.advanceTimersByTimeAsync(100);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that waitFor() removes its listener after resolving.
*/
const testEventEmitterWaitForRemovesListenerAfterResolve = async (): Promise<void> => {
const emitter = new EventEmitter<TestEvents>();
const waitPromise = emitter.waitFor('message', () => true);
emitter.emit('message', 'first');
await expect(waitPromise).resolves.toBe('first');
// Register a second waitFor so we can verify the first listener was cleaned up.
const secondWaitPromise = emitter.waitFor('message', (payload) => payload === 'second');
// Emit a payload that only the second waitFor should accept.
emitter.emit('message', 'ignored');
// Track whether the second waitFor resolves too early.
let resolvedEarly = false;
/* eslint-disable-next-line */
secondWaitPromise.then(() => {
resolvedEarly = true;
});
// Yield so any premature resolution would have a chance to run.
await Promise.resolve();
expect(resolvedEarly).toBe(false);
emitter.emit('message', 'second');
await expect(secondWaitPromise).resolves.toBe('second');
};
/**
* Tests that the first debounced emit does not call clearTimeout.
*/
const testEventEmitterDebouncedFirstEmitDoesNotClearTimeout = (): void => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
const listener = vi.fn();
emitter.on('message', listener, 100);
emitter.emit('message', 'first');
// The first emit starts the debounce timer; there is nothing to clear yet.
expect(clearTimeoutSpy).not.toHaveBeenCalled();
expect(listener).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced on() listeners receive only the last payload after the debounce window.
*/
const testEventEmitterDebouncedOnListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('message', listener, 100);
// Emit several events in quick succession.
emitter.emit('message', 'first');
emitter.emit('message', 'second');
emitter.emit('message', 'third');
// Expect the listener to not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance past the debounce window.
await vi.advanceTimersByTimeAsync(100);
// Expect only the last payload to have been delivered.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('third');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that repeated debounced emits reset the debounce timer.
*/
const testEventEmitterDebouncedTimerResetsOnRepeatedEmits = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.on('count', listener, 100);
emitter.emit('count', 1);
// Advance halfway through the debounce window and emit again.
await vi.advanceTimersByTimeAsync(50);
emitter.emit('count', 2);
await vi.advanceTimersByTimeAsync(50);
// The timer was reset, so the listener should not have fired yet.
expect(listener).not.toHaveBeenCalled();
// Advance the remaining time for the reset timer to expire.
await vi.advanceTimersByTimeAsync(50);
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that a debounce time of zero behaves like a normal listener.
*/
const testEventEmitterZeroDebounceDoesNotDebounce = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
// A debounce time of zero should behave like a normal listener.
emitter.on('message', listener, 0);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
expect(listener).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that debounced once() listeners fire once with the last payload.
*/
const testEventEmitterDebouncedOnceListener = async (): Promise<void> => {
vi.useFakeTimers();
try {
const emitter = new EventEmitter<TestEvents>();
const listener = vi.fn();
emitter.once('message', listener, 100);
emitter.emit('message', 'first');
emitter.emit('message', 'second');
await vi.advanceTimersByTimeAsync(100);
// Expect the debounced once listener to fire once with the last payload.
expect(listener).toHaveBeenCalledOnce();
expect(listener).toHaveBeenCalledWith('second');
// Emit again after the debounce window; the once listener should stay removed.
emitter.emit('message', 'third');
await vi.advanceTimersByTimeAsync(100);
expect(listener).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
const runTests = async (): Promise<void> => {
test('EventEmitter: calls listeners when an event is emitted', testEventEmitterCallsListeners);
test('EventEmitter: calls multiple listeners for the same event', testEventEmitterCallsMultipleListeners);
test('EventEmitter: only calls listeners for the emitted event type', testEventEmitterCallsOnlyMatchingListeners);
test('EventEmitter: returns false when emitting with no listeners', testEventEmitterEmitReturnsFalseWithNoListeners);
test('EventEmitter: returns true when emitting with listeners', testEventEmitterEmitReturnsTrueWithListeners);
test('EventEmitter: stops calling a listener after its off callback is invoked', testEventEmitterOffCallbackRemovesListener);
test('EventEmitter: removes a listener when off is called with the same reference', testEventEmitterOffRemovesListenerByReference);
test('EventEmitter: ignores off when the listener reference is unknown', testEventEmitterOffIgnoresUnknownListener);
test('EventEmitter: ignores off for an event type with no listeners', testEventEmitterOffIgnoresUnregisteredEventType);
test('EventEmitter: calls a once listener only one time', testEventEmitterOnceListenerFiresOnce);
test('EventEmitter: registers once when listeners already exist', testEventEmitterOnceWorksWithExistingListeners);
test('EventEmitter: stops a once listener after its off callback is invoked', testEventEmitterOnceOffCallbackRemovesListener);
test('EventEmitter: removes all listeners when removeAllListeners is called', testEventEmitterRemoveAllListeners);
test('EventEmitter: resolves waitFor when a matching event is emitted', testEventEmitterWaitForResolvesOnMatch);
test('EventEmitter: ignores non-matching events while waiting with waitFor', testEventEmitterWaitForIgnoresNonMatchingEvents);
test('EventEmitter: rejects waitFor when the timeout is reached', testEventEmitterWaitForRejectsOnTimeout);
test('EventEmitter: clears the timeout when waitFor resolves before expiry', testEventEmitterWaitForClearsTimeoutOnResolve);
test('EventEmitter: removes the waitFor listener after it resolves', testEventEmitterWaitForRemovesListenerAfterResolve);
test('EventEmitter: does not clear a timeout on the first debounced emit', testEventEmitterDebouncedFirstEmitDoesNotClearTimeout);
test('EventEmitter: debounces on listeners', testEventEmitterDebouncedOnListener);
test('EventEmitter: resets the debounce timer on repeated emits', testEventEmitterDebouncedTimerResetsOnRepeatedEmits);
test('EventEmitter: does not debounce when debounceMilliseconds is zero', testEventEmitterZeroDebounceDoesNotDebounce);
test('EventEmitter: debounces once listeners and invokes them only once', testEventEmitterDebouncedOnceListener);
};
await runTests();

View File

@@ -0,0 +1,594 @@
import { expect, test, vi } from 'vitest';
import { ExponentialBackoff } from '../../src/utils/exponential-backoff.js';
/**
* A valid options object that satisfies {@link ExponentialBackoff.validateOptions}.
*/
const validExponentialBackoffOptions = {
maxDelay: 10_000,
maxAttempts: 10,
baseDelay: 1_000,
growthRate: 2,
jitter: 0.1,
};
/**
* Tests that the static {@link ExponentialBackoff.run} helper creates a throwaway instance
* with library defaults (including the default 1000ms base delay) when no options are passed.
*/
const testExponentialBackoffRunUsesDefaultOptions = async (): Promise<void> => {
// Fake timers let us advance time without waiting real seconds between retries.
vi.useFakeTimers();
// Pin Math.random to 0 so jitter does not reduce the default delay.
vi.spyOn(Math, 'random').mockReturnValue(0);
try {
// The wrapped function fails on its first invocation and succeeds on the second.
// That forces ExponentialBackoff.run down the retry path using default options.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('static-result');
// Call the static helper with no onError and no options — defaults apply entirely.
const promise = ExponentialBackoff.run(rejectThenResolveFn);
// Yield one microtask so the first (immediate) attempt completes and schedules the retry timer.
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Default baseDelay is 1000ms; advancing less would not trigger the retry yet.
await vi.advanceTimersByTimeAsync(1_000);
// The retry should have succeeded and returned the resolved value from the mock.
await expect(promise).resolves.toBe('static-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.run} accepts a partial options object and merges it
* with defaults, still retrying when only some fields are overridden.
*/
const testExponentialBackoffRunWithPartialOptions = async (): Promise<void> => {
// Same fail-then-succeed pattern; we only care that partial options still enable a retry.
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('done');
// baseDelay/jitter of 0 skip real waiting; maxAttempts: 3 gives headroom for one retry.
// onError is explicitly undefined to verify the default no-op handler is used.
const result = await ExponentialBackoff.run(rejectThenResolveFn, undefined, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
});
expect(result).toBe('done');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that calling {@link ExponentialBackoff.run} on a constructed instance applies
* the instance's stored options when no per-run options are supplied.
*/
const testExponentialBackoffInstanceRunUsesDefaultOnError = async (): Promise<void> => {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('retry me'))
.mockResolvedValueOnce('instance-result');
// Options live on the instance; run(fn) should read them instead of static defaults.
const backoff = new ExponentialBackoff({ baseDelay: 0, jitter: 0, maxAttempts: 3 });
const result = await backoff.run(rejectThenResolveFn);
expect(result).toBe('instance-result');
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
};
/**
* Tests the happy path: the wrapped function succeeds immediately and no retry machinery runs.
*/
const testExponentialBackoffSucceedsOnFirstAttempt = async (): Promise<void> => {
// Always resolves — never enters the catch/retry branch.
const resolveFn = vi.fn(async () => 'success');
const onError = vi.fn();
const result = await ExponentialBackoff.run(resolveFn, onError, {
baseDelay: 0,
jitter: 0,
});
expect(result).toBe('success');
expect(resolveFn).toHaveBeenCalledOnce();
expect(onError).not.toHaveBeenCalled();
};
/**
* Tests that retries continue across multiple failures until the function eventually resolves.
*/
const testExponentialBackoffRetriesUntilSuccess = async (): Promise<void> => {
// Three invocations: two rejections then a success on the third call.
const tripleRejectFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
// maxAttempts: 5 is high enough that we stop because fn succeeded, not because we hit the cap.
const result = await ExponentialBackoff.run(tripleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 5,
});
expect(result).toBe('success');
expect(tripleRejectFn).toHaveBeenCalledTimes(3);
};
/**
* Tests that the onError callback is invoked once for every failed attempt, including the last one
* before an ExponentialBackoffMaxRetriesHitError is thrown to the caller.
*/
const testExponentialBackoffCallsOnErrorForEachFailure = async (): Promise<void> => {
const error = new Error('temporary failure');
// Always rejects with the same error — we will exhaust all attempts.
const rejectFn = vi.fn().mockRejectedValue(error);
const onError = vi.fn();
// maxAttempts: 3 means three tries total, all of which will fail.
await expect(ExponentialBackoff.run(rejectFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 3,
})).rejects.toThrow(AggregateError);
expect(onError).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledWith(error, expect.objectContaining({}));
};
/**
* Tests that when all attempts are exhausted the caller receives an ExponentialBackoffMaxRetriesHitError
* with every task error preserved in order on the cause.
*/
const testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted = async (): Promise<void> => {
const firstError = new Error('first');
const lastError = new Error('last');
// Two distinct errors so we can prove both are collected, not just the last one.
const doubleRejectFn = vi.fn().mockRejectedValueOnce(firstError)
.mockRejectedValueOnce(lastError);
try {
await ExponentialBackoff.run(doubleRejectFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 2,
});
expect.fail('Expected AggregateError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
expect((error as AggregateError).errors).toEqual([ firstError, lastError ]);
}
expect(doubleRejectFn).toHaveBeenCalledTimes(2);
};
/**
* Tests that rejections which are not Error instances are coerced to Error before onError runs,
* so callers always observe a consistent error type in the callback.
*/
const testExponentialBackoffWrapsNonErrorThrows = async (): Promise<void> => {
// Reject with a plain string — not an Error subclass.
const rejectedFn = vi.fn().mockRejectedValue('not-an-error');
const onError = vi.fn();
// Single attempt — we fail fast and inspect what onError received.
try {
await ExponentialBackoff.run(rejectedFn, onError, {
baseDelay: 0,
jitter: 0,
maxAttempts: 1,
});
expect.fail('Expected AggregateError to be thrown');
} catch (error) {
expect(error).toBeInstanceOf(AggregateError);
const [ wrappedError ] = (error as AggregateError).errors as Error[];
expect(wrappedError).toBeInstanceOf(Error);
expect(wrappedError?.message).toBe('not-an-error');
}
expect(onError).toHaveBeenCalledOnce();
expect(onError.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onError.mock.calls?.[0]?.[0]?.message).toBe('not-an-error');
};
/**
* Tests that when the task function succeeds and the abort signal is aborted, the result is returned
* and the onError callback is not called.
*/
const testExponentialBackoffRunSuccessAndAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and succeeds
const abortAndSucceedFn = vi.fn(({ abort }) => {
abort(new Error('retry me'));
return Promise.resolve('success');
});
const onErrorFn = vi.fn();
// Run the exponential backoff with the function and the onError callback
const result = await ExponentialBackoff.run(abortAndSucceedFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
});
// Expect the result to be the success message
expect(result).toBe('success');
expect(abortAndSucceedFn).toHaveBeenCalledOnce();
// Expect the onError callback to not have been called
expect(onErrorFn).not.toHaveBeenCalled();
};
/**
* Tests that when the abort signal is aborted with an error, an AggregateError is thrown
* with the error as the message.
*/
const testExponentialBackoffRunWithAbortSignal = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowFn = vi.fn(({ abort }) => {
abort(new Error('exponential backoff aborted message'));
throw new Error('error message');
});
const onErrorFn = vi.fn();
// Define the expected error
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowFn).toHaveBeenCalledOnce();
expect(abortAndThrowFn).not.toHaveResolved();
};
/**
* Tests that when the abort signal is aborted with a string, an AggregateError is thrown
* with the string as the message.
*/
const testExponentialBackoffRunAbortedStringCreatesError = async (): Promise<void> => {
// Define the function which aborts the exponential backoff and throws an error
const abortAndThrowStringFn = vi.fn(({ abort }) => {
abort('exponential backoff aborted message');
// eslint-disable-next-line
throw 'error message';
});
const onErrorFn = vi.fn();
// Define the expected error, Note that we "abort" with just a string, not an error. They are treated equivalently.
const expectedError = new Error('Exponential backoff aborted', { cause: new Error('exponential backoff aborted message') });
// Run the exponential backoff with the function and the onError callback and expect the error to be thrown
await expect(ExponentialBackoff.run(abortAndThrowStringFn, onErrorFn, {
baseDelay: 0,
jitter: 0,
})).rejects.toThrow(expectedError);
// Expect the onError callback to have been called once with the error
expect(onErrorFn).toHaveBeenCalledOnce();
expect(onErrorFn.mock.calls?.[0]?.[0]).toBeInstanceOf(Error);
expect(onErrorFn.mock.calls?.[0]?.[0]?.message).toBe('error message');
// Expect the function to have been called once and not to have resolved
expect(abortAndThrowStringFn).toHaveBeenCalledOnce();
expect(abortAndThrowStringFn).not.toHaveResolved();
};
/**
* Tests the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run}
* as an alternative to the static helper.
*/
const testExponentialBackoffFromAndInstanceRun = async (): Promise<void> => {
const successfullyResolve = vi.fn(async () => 42);
// from() is a convenience constructor; run() on the result should behave like the static path.
const backoff = ExponentialBackoff.from({
baseDelay: 0,
jitter: 0,
});
const result = await backoff.run(successfullyResolve);
expect(result).toBe(42);
expect(successfullyResolve).toHaveBeenCalledOnce();
};
/**
* Tests that maxAttempts: 0 disables the attempt cap so retries continue until the function succeeds.
*/
const testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero = async (): Promise<void> => {
// Four invocations: three failures then success — would exceed a cap of 3 if one existed.
const tripleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockRejectedValueOnce(new Error('attempt 3'))
.mockResolvedValueOnce('eventually');
const result = await ExponentialBackoff.run(tripleRejectThenResolveFn, () => {}, {
baseDelay: 0,
jitter: 0,
maxAttempts: 0,
});
expect(result).toBe('eventually');
expect(tripleRejectThenResolveFn).toHaveBeenCalledTimes(4);
};
/**
* Tests the delay formula: each retry waits baseDelay * growthRate^attemptIndex milliseconds
* (with jitter disabled so the math is exact).
*/
const testExponentialBackoffIncreasesDelayExponentially = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 100,
growthRate: 2,
jitter: 0,
maxDelay: 10_000,
maxAttempts: 5,
});
// Attempt 0 fires synchronously on the first microtask tick.
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// After attempt 0 fails, delay = 100 * 2^0 = 100ms before attempt 1.
await vi.advanceTimersByTimeAsync(100);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// After attempt 1 fails, delay = 100 * 2^1 = 200ms before attempt 2.
await vi.advanceTimersByTimeAsync(200);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that computed delay never exceeds maxDelay even when exponential growth would go higher.
*/
const testExponentialBackoffCapsDelayAtMaxDelay = async (): Promise<void> => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(0.5);
try {
const doubleRejectThenResolveFn = vi
.fn()
.mockRejectedValueOnce(new Error('attempt 1'))
.mockRejectedValueOnce(new Error('attempt 2'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(doubleRejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 4,
jitter: 0,
maxDelay: 2_000,
maxAttempts: 5,
});
await Promise.resolve();
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(1);
// attempt 0: 1000 * 4^0 = 1000ms, below the 2000ms cap.
await vi.advanceTimersByTimeAsync(1_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(2);
// attempt 1: uncapped would be 4000ms but maxDelay clamps to 2000ms.
await vi.advanceTimersByTimeAsync(2_000);
expect(doubleRejectThenResolveFn).toHaveBeenCalledTimes(3);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that jitter subtracts up to jitter * cappedDelay from the capped delay based on Math.random.
*/
const testExponentialBackoffAppliesJitter = async (): Promise<void> => {
vi.useFakeTimers();
// random = 1 → full 10% reduction: 1000 - (1 * 0.1 * 1000) = 900ms.
vi.spyOn(Math, 'random').mockReturnValue(1);
try {
const rejectThenResolveFn = vi.fn().mockRejectedValueOnce(new Error('attempt 1'))
.mockResolvedValueOnce('success');
const promise = ExponentialBackoff.run(rejectThenResolveFn, () => {}, {
baseDelay: 1_000,
growthRate: 1,
jitter: 0.1,
maxDelay: 10_000,
maxAttempts: 3,
});
await Promise.resolve();
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
// Advancing 899ms is one ms short of the jittered delay; 900ms triggers the retry.
await vi.advanceTimersByTimeAsync(899);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(1);
expect(rejectThenResolveFn).toHaveBeenCalledTimes(2);
await expect(promise).resolves.toBe('success');
} finally {
vi.useRealTimers();
vi.restoreAllMocks();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} accepts valid options, including boundary values of 0 and 1.
*/
const testExponentialBackoffValidateOptionsAcceptsValidOptions = (): void => {
const validCases = [
validExponentialBackoffOptions,
{
...validExponentialBackoffOptions,
maxDelay: 0,
maxAttempts: 0,
baseDelay: 0,
growthRate: 0,
jitter: 0,
},
{
...validExponentialBackoffOptions,
jitter: 1,
},
] as const;
for (const options of validCases) {
expect(() => ExponentialBackoff.validateOptions(options)).not.toThrow();
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects negative numeric options.
*/
const testExponentialBackoffValidateOptionsRejectsNegativeValues = (): void => {
// Define our test cases with each value being less than 0
const negativeCases = [
{ field: 'maxDelay', value: -1 },
{ field: 'maxAttempts', value: -1 },
{ field: 'baseDelay', value: -1 },
{ field: 'growthRate', value: -1 },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of negativeCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is less than 0`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects jitter below 0 or above 1.
*/
const testExponentialBackoffValidateOptionsRejectsInvalidJitter = (): void => {
// Define our test cases with each value being less than 0 or greater than 1
const invalidJitterCases: Array<{ value: number }> = [{ value: -0.1 }, { value: 1.1 }];
// Iterate through the test cases and expect an error to be thrown
for (const { value } of invalidJitterCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
jitter: value,
})).toThrow('Invalid option: jitter is not between 0 and 1');
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects non-finite values such as Infinity.
*/
const testExponentialBackoffValidateOptionsRejectsNonFiniteValues = (): void => {
// Define our test cases with each value being Infinity
const nonFiniteCases = [
{ field: 'maxDelay', value: Infinity },
{ field: 'maxAttempts', value: Infinity },
{ field: 'baseDelay', value: Infinity },
{ field: 'growthRate', value: Infinity },
{ field: 'jitter', value: Infinity },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nonFiniteCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is not finite`);
}
};
/**
* Tests that {@link ExponentialBackoff.validateOptions} rejects NaN, which is also non-finite.
*/
const testExponentialBackoffValidateOptionsRejectsNaN = (): void => {
// Define our test cases with each value being NaN
const nanCases = [
{ field: 'maxDelay', value: Number.NaN },
{ field: 'maxAttempts', value: Number.NaN },
{ field: 'baseDelay', value: Number.NaN },
{ field: 'growthRate', value: Number.NaN },
{ field: 'jitter', value: Number.NaN },
] as const;
// Iterate through the test cases and expect an error to be thrown
for (const { field, value } of nanCases) {
expect(() =>
ExponentialBackoff.validateOptions({
...validExponentialBackoffOptions,
[field]: value,
})).toThrow(`Invalid option: ${field} is not finite`);
}
};
const runTests = async (): Promise<void> => {
test('ExponentialBackoff.run: delegates to a new instance using default options', testExponentialBackoffRunUsesDefaultOptions);
test('ExponentialBackoff.run: retries and succeeds with partial options', testExponentialBackoffRunWithPartialOptions);
test('ExponentialBackoff.run: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError);
test('ExponentialBackoff: returns the result on first success', testExponentialBackoffSucceedsOnFirstAttempt);
test('ExponentialBackoff: retries until the function succeeds', testExponentialBackoffRetriesUntilSuccess);
test('ExponentialBackoff: calls onError for each failed attempt', testExponentialBackoffCallsOnErrorForEachFailure);
test(
'ExponentialBackoff: throws ExponentialBackoffMaxRetriesHitError when max attempts are exhausted',
testExponentialBackoffThrowsMaxRetriesHitErrorWhenExhausted,
);
test('ExponentialBackoff: wraps non-Error throws before calling onError', testExponentialBackoffWrapsNonErrorThrows);
test('ExponentialBackoff: succeeds and aborts with abort signal', testExponentialBackoffRunSuccessAndAbortSignal);
test('ExponentialBackoff: aborts with abort signal', testExponentialBackoffRunWithAbortSignal);
test('ExponentialBackoff: aborts with aborted string creates error', testExponentialBackoffRunAbortedStringCreatesError);
test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun);
test('ExponentialBackoff: retries indefinitely when maxAttempts is 0', testExponentialBackoffRetriesIndefinitelyWhenMaxAttemptsIsZero);
test('ExponentialBackoff: increases delay exponentially between attempts', testExponentialBackoffIncreasesDelayExponentially);
test('ExponentialBackoff: caps delay at maxDelay', testExponentialBackoffCapsDelayAtMaxDelay);
test('ExponentialBackoff: subtracts jitter from the capped delay', testExponentialBackoffAppliesJitter);
test('ExponentialBackoff.validateOptions: accepts valid options', testExponentialBackoffValidateOptionsAcceptsValidOptions);
test('ExponentialBackoff.validateOptions: rejects negative values', testExponentialBackoffValidateOptionsRejectsNegativeValues);
test('ExponentialBackoff.validateOptions: rejects invalid jitter', testExponentialBackoffValidateOptionsRejectsInvalidJitter);
test('ExponentialBackoff.validateOptions: rejects Infinity', testExponentialBackoffValidateOptionsRejectsNonFiniteValues);
test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN);
};
await runTests();

70
tests/utils/misc.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { expect, test, vi } from 'vitest';
import { tryAsync } from '../../src/utils/misc.js';
/** Spy used to confirm the wrapped async function ran successfully. */
const successFlagFn = vi.fn();
/** Spy used to confirm the error callback was invoked on failure. */
const errorFlagFn = vi.fn();
/**
* Tests that tryAsync invokes the function and skips the error callback on success.
*/
const testTryAsyncCallsFunctionOnSuccess = async (): Promise<void> => {
// Reset spies so prior test runs do not affect call counts.
vi.clearAllMocks();
const successFn = async (): Promise<void> => {
successFlagFn();
};
await tryAsync(successFn);
// The wrapped function should run and no error handler should be called.
expect(successFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).not.toHaveBeenCalled();
};
/**
* Tests that tryAsync invokes the error callback when the function throws.
*/
const testTryAsyncCallsErrorCallbackOnFailure = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
throw new Error('test');
};
await tryAsync(errorFn, errorFlagFn);
// The success path should not run; the error callback should receive the failure.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
};
/**
* Tests that tryAsync wraps non-Error throws in Error instances before calling the error callback.
*/
const testTryAsyncConvertsNonErrorThrows = async (): Promise<void> => {
vi.clearAllMocks();
const errorFn = async (): Promise<void> => {
/* eslint-disable-next-line */
throw 'test';
};
await tryAsync(errorFn, errorFlagFn);
// Non-Error throws must be normalized to Error before onError is called.
expect(successFlagFn).not.toHaveBeenCalled();
expect(errorFlagFn).toHaveBeenCalledOnce();
expect(errorFlagFn).toHaveBeenCalledWith(new Error('test'));
};
const runTests = async (): Promise<void> => {
test('tryAsync: calls the function and skips the error callback on success', testTryAsyncCallsFunctionOnSuccess);
test('tryAsync: calls the error callback when the function fails', testTryAsyncCallsErrorCallbackOnFailure);
test('tryAsync: converts non-Error throws to Error instances', testTryAsyncConvertsNonErrorThrows);
};
await runTests();