Add tests. Add exponential backoff contexts. Update error handling. Imrove Event Parser compatibility. Simplify SSE Session. Simplify Async Iterator.
This commit is contained in:
188
tests/utils/async-push-iterator.test.ts
Normal file
188
tests/utils/async-push-iterator.test.ts
Normal 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();
|
||||
505
tests/utils/event-emitter.test.ts
Normal file
505
tests/utils/event-emitter.test.ts
Normal 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();
|
||||
594
tests/utils/exponential-backoff.test.ts
Normal file
594
tests/utils/exponential-backoff.test.ts
Normal 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
70
tests/utils/misc.test.ts
Normal 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();
|
||||
Reference in New Issue
Block a user