import { expect, test, vi } from 'vitest'; import { testExponentialBackoff } from './exponential-backoff-run.shared.ts'; import { ExponentialBackoff } from '../../source/exponential-backoff/exponential-backoff.ts'; import type { ExponentialBackoffOptions } from '../../source/exponential-backoff/exponential-backoff.ts'; /** * 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 the {@link ExponentialBackoff.from} factory and subsequent instance {@link ExponentialBackoff.run} * as an alternative to the static helper. */ const testExponentialBackoffFromAndInstanceRun = async (): Promise => { 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 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 => { // 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 => { // 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 omitted to verify the default no-op handler is used. const result = await ExponentialBackoff.run(rejectThenResolveFn, { 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 => { 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 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(`Exponential backoff option "${field}" is too small. Must be at least 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('Exponential backoff option "jitter" is out of bounds. Must be 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(`Exponential backoff option "${field}" is invalid. Must be a finite number`); } }; /** * Tests that {@link ExponentialBackoff.validateOptions} rejects non-integer values. */ const testExponentialBackoffValidateOptionsRejectsNonIntegerValues = (): void => { // Define our test cases with each value being a non-integer const nonIntegerCases = [{ field: 'maxAttempts', value: 1.5 }] as const; // Iterate through the test cases and expect an error to be thrown for (const { field, value } of nonIntegerCases) { expect(() => ExponentialBackoff.validateOptions({ ...validExponentialBackoffOptions, [field]: value, })).toThrow(`Exponential backoff option "${field}" is invalid. Must be an integer`); } }; /** * 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(`Exponential backoff option "${field}" is invalid. Must be a finite number`); } }; const runTests = async (): Promise => { const exponentialBackoffFactory = (options?: Partial): ExponentialBackoff => { return new ExponentialBackoff(options); }; // Test the shared ExponentialBackoff contract. testExponentialBackoff('ExponentialBackoff', exponentialBackoffFactory); // APIs that only exist on ExponentialBackoff itself. test('ExponentialBackoff: works via from and instance run', testExponentialBackoffFromAndInstanceRun); test('ExponentialBackoff: uses the default options when no options are passed', testExponentialBackoffRunUsesDefaultOptions); test('ExponentialBackoff: accepts partial options and merges them with defaults', testExponentialBackoffRunWithPartialOptions); test('ExponentialBackoff: uses the instance default onError when omitted', testExponentialBackoffInstanceRunUsesDefaultOnError); 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 non-integer values', testExponentialBackoffValidateOptionsRejectsNonIntegerValues); test('ExponentialBackoff.validateOptions: rejects NaN', testExponentialBackoffValidateOptionsRejectsNaN); }; await runTests();