feat: Add event-based automation triggers (#2537)

This commit is contained in:
Dermot Duffy
2026-06-30 17:45:13 -07:00
committed by dermotduffy
parent b701366762
commit a31816c168
109 changed files with 4607 additions and 1623 deletions
@@ -86,6 +86,24 @@ describe('KeyedSubscriptionManager', () => {
expect(manager.getRequestsForKey('a')).toEqual([reqA2]);
});
it('should roll back the request when the underlying subscribeFn rejects', async () => {
const manager = create();
const subscribeFn = vi.fn().mockRejectedValue(new Error('ws-fail'));
const req = { key: 'a', callback: vi.fn() };
await expect(manager.subscribe(req, subscribeFn)).rejects.toThrow('ws-fail');
// The failed subscriber must not be left dispatching against a
// never-established connection.
expect(manager.getRequestsForKey('a')).toEqual([]);
// A subsequent successful subscribe should re-attempt the underlying call.
const successFn = vi.fn().mockResolvedValue(vi.fn());
await manager.subscribe(req, successFn);
expect(successFn).toBeCalledTimes(1);
expect(manager.getRequestsForKey('a')).toEqual([req]);
});
it('should treat unsubscribe of an unknown request as a no-op', async () => {
const manager = create();
const unsub = vi.fn();
+98
View File
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ExponentialBackoff } from '../../src/utils/exponential-backoff';
describe('ExponentialBackoff', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should start at attempts=0', () => {
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
expect(backoff.getAttempts()).toBe(0);
});
it('should compute exponential delays with the configured base', () => {
// Pin jitter to 1.0 so we can read the raw exponential values.
vi.spyOn(Math, 'random').mockReturnValue(1);
const backoff = new ExponentialBackoff({
baseSeconds: 1,
maxSeconds: 60,
jitterMin: 1,
jitterMax: 1,
});
expect(backoff.next()).toBe(1);
expect(backoff.next()).toBe(2);
expect(backoff.next()).toBe(4);
expect(backoff.next()).toBe(8);
expect(backoff.next()).toBe(16);
});
it('should cap delays at maxSeconds', () => {
vi.spyOn(Math, 'random').mockReturnValue(1);
const backoff = new ExponentialBackoff({
baseSeconds: 1,
maxSeconds: 10,
jitterMin: 1,
jitterMax: 1,
});
backoff.next();
backoff.next();
backoff.next();
backoff.next();
expect(backoff.next()).toBe(10);
expect(backoff.next()).toBe(10);
});
it('should apply jitter within the configured range', () => {
// Math.random() returns 0; jitter = jitterMin.
vi.spyOn(Math, 'random').mockReturnValue(0);
const backoff = new ExponentialBackoff({
baseSeconds: 10,
maxSeconds: 100,
jitterMin: 0.5,
jitterMax: 1.0,
});
expect(backoff.next()).toBe(5);
});
it('should increment the attempt counter on each next()', () => {
const backoff = new ExponentialBackoff({ baseSeconds: 1, maxSeconds: 60 });
expect(backoff.getAttempts()).toBe(0);
backoff.next();
expect(backoff.getAttempts()).toBe(1);
backoff.next();
expect(backoff.getAttempts()).toBe(2);
});
it('should reset the attempt counter and start over', () => {
vi.spyOn(Math, 'random').mockReturnValue(1);
const backoff = new ExponentialBackoff({
baseSeconds: 1,
maxSeconds: 60,
jitterMin: 1,
jitterMax: 1,
});
backoff.next();
backoff.next();
backoff.next();
expect(backoff.getAttempts()).toBe(3);
backoff.reset();
expect(backoff.getAttempts()).toBe(0);
expect(backoff.next()).toBe(1);
});
it('should default jitter to [0.5, 1.0] when not provided', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
const backoff = new ExponentialBackoff({ baseSeconds: 4, maxSeconds: 100 });
// jitter = 0.5 + 0 * (1.0 - 0.5) = 0.5; delay = 4 * 0.5 = 2.
expect(backoff.next()).toBe(2);
});
});
+32
View File
@@ -71,4 +71,36 @@ describe('Initializer', () => {
expect(initializer.isInitialized('foo')).toBeFalsy();
});
it('should discard an initialization that was uninitialized while it was running', async () => {
const initializer = new Initializer();
let finishInitializer: () => void = () => undefined;
const initializing = initializer.initializeIfNecessary(
'foo',
() =>
new Promise<void>((resolve) => {
finishInitializer = resolve;
}),
);
// A uninitialize lands while the initializer is still running.
initializer.uninitialize('foo');
finishInitializer();
await initializing;
expect(initializer.isInitialized('foo')).toBeFalsy();
});
it('should initialize again after being uninitialized', async () => {
const initializer = new Initializer();
await initializer.initializeIfNecessary('foo');
initializer.uninitialize('foo');
expect(initializer.isInitialized('foo')).toBeFalsy();
await initializer.initializeIfNecessary('foo');
expect(initializer.isInitialized('foo')).toBeTruthy();
});
});
+206
View File
@@ -0,0 +1,206 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RetryTimer } from '../../src/utils/retry-timer';
// @vitest-environment jsdom
describe('RetryTimer', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should schedule a callback after the current backoff delay', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
const cb = vi.fn();
timer.schedule(cb);
vi.advanceTimersByTime(999);
expect(cb).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(cb).toBeCalledTimes(1);
});
it('should advance the counter on schedule by default', () => {
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
timer.schedule(() => {});
expect(timer.getAttempts()).toBe(1);
timer.schedule(() => {});
expect(timer.getAttempts()).toBe(2);
});
it('should not advance the counter when schedule is called with advance: false', () => {
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
timer.schedule(() => {}, { advance: false });
expect(timer.getAttempts()).toBe(0);
timer.schedule(() => {}, { advance: false });
expect(timer.getAttempts()).toBe(0);
});
it('should advance the counter via advance()', () => {
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
timer.advance();
expect(timer.getAttempts()).toBe(1);
timer.advance();
expect(timer.getAttempts()).toBe(2);
});
it('should use a longer delay after advance()', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
const cb = vi.fn();
timer.advance();
timer.schedule(cb);
// Counter is 1, delay should be base * 2^1 = 2 seconds.
vi.advanceTimersByTime(1999);
expect(cb).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(cb).toBeCalledTimes(1);
});
it('should cancel a pending callback', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
const cb = vi.fn();
timer.schedule(cb);
timer.cancel();
vi.advanceTimersByTime(10_000);
expect(cb).not.toBeCalled();
});
it('should reset both the timer and the counter', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
const cb = vi.fn();
timer.advance();
timer.advance();
timer.schedule(cb, { advance: false });
expect(timer.getAttempts()).toBe(2);
expect(timer.isRunning()).toBe(true);
timer.reset();
expect(timer.getAttempts()).toBe(0);
expect(timer.isRunning()).toBe(false);
vi.advanceTimersByTime(10_000);
expect(cb).not.toBeCalled();
});
it('should report running state', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
expect(timer.isRunning()).toBe(false);
timer.schedule(() => {});
expect(timer.isRunning()).toBe(true);
vi.advanceTimersByTime(1000);
expect(timer.isRunning()).toBe(false);
});
it('should advance the counter when schedule is called with advance: true', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({ baseSeconds: 1, maxSeconds: 60 });
timer.schedule(() => {}, { advance: true });
expect(timer.getAttempts()).toBe(1);
timer.schedule(() => {}, { advance: true });
expect(timer.getAttempts()).toBe(2);
});
it('should produce a fixed delay when configured with base=max and jitter=1', () => {
// The "static delay" idiom: callers wanting a non-growing delay configure
// the backoff to flatten out. No special mode in the class.
vi.useFakeTimers();
const timer = new RetryTimer({
baseSeconds: 30,
maxSeconds: 30,
jitterMin: 1,
jitterMax: 1,
});
const cb = vi.fn();
timer.advance();
timer.advance();
timer.schedule(cb);
vi.advanceTimersByTime(29_999);
expect(cb).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(cb).toBeCalledTimes(1);
});
it('should accept a plain number as shorthand for a fixed delay', () => {
vi.useFakeTimers();
const timer = new RetryTimer(30);
const cb = vi.fn();
timer.advance();
timer.schedule(cb);
vi.advanceTimersByTime(29_999);
expect(cb).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(cb).toBeCalledTimes(1);
});
describe('setOptions', () => {
it('should apply the new backoff config to the next schedule', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({
baseSeconds: 1,
maxSeconds: 60,
jitterMin: 1,
jitterMax: 1,
});
const cb = vi.fn();
timer.setOptions({
baseSeconds: 10,
maxSeconds: 10,
jitterMin: 1,
jitterMax: 1,
});
timer.schedule(cb);
vi.advanceTimersByTime(9_999);
expect(cb).not.toBeCalled();
vi.advanceTimersByTime(1);
expect(cb).toBeCalledTimes(1);
});
it('should preserve the attempt counter and any pending callback', () => {
vi.useFakeTimers();
vi.spyOn(Math, 'random').mockReturnValue(1);
const timer = new RetryTimer({
baseSeconds: 1,
maxSeconds: 60,
jitterMin: 1,
jitterMax: 1,
});
const cb = vi.fn();
timer.advance();
timer.advance();
timer.schedule(cb, { advance: false });
expect(timer.getAttempts()).toBe(2);
expect(timer.isRunning()).toBe(true);
// Idempotent setOptions doesn't touch counter or pending timer.
timer.setOptions({
baseSeconds: 1,
maxSeconds: 60,
jitterMin: 1,
jitterMax: 1,
});
expect(timer.getAttempts()).toBe(2);
expect(timer.isRunning()).toBe(true);
});
});
});