feat: Add event-based automation triggers (#2537)
This commit is contained in:
committed by
dermotduffy
parent
b701366762
commit
a31816c168
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { SubscriptionHealthMonitor } from '../../../src/ha/connection/subscription-health-monitor';
|
||||
import { HASSWebSocketSubscriptionStatus } from '../../../src/ha/connection/subscription-manager';
|
||||
|
||||
interface TestRequest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
type State = HASSWebSocketSubscriptionStatus<string, TestRequest>['state'];
|
||||
|
||||
const status = (
|
||||
state: State,
|
||||
request: TestRequest,
|
||||
key: string,
|
||||
extra?: { error?: unknown; failureCount?: number },
|
||||
): HASSWebSocketSubscriptionStatus<string, TestRequest> => ({
|
||||
state,
|
||||
request,
|
||||
key,
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe('SubscriptionHealthMonitor', () => {
|
||||
it('should report a failing key with its error and failure count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const error = new Error('boom');
|
||||
|
||||
monitor.update(
|
||||
status('failing', { id: 'a' }, 'zha_event', { error, failureCount: 2 }),
|
||||
);
|
||||
|
||||
expect(monitor.getFailures()).toEqual([
|
||||
{ key: 'zha_event', error, failureCount: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should ignore waiting so it never clears a failing key', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('waiting', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should clear a key once it subscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should clear a key once its request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const request = { id: 'a' };
|
||||
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('unsubscribed', request, 'zha_event'));
|
||||
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should report each failing key once regardless of subscriber count', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', { id: 'b' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(monitor.getFailures().map((f) => f.key)).toEqual(['zha_event']);
|
||||
});
|
||||
|
||||
it('should notify listeners only when a key changes failing state', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
const request = { id: 'a' };
|
||||
|
||||
// Healthy -> failing: one notification.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 1 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Still failing (next attempt, same key): no membership change, no notify.
|
||||
monitor.update(status('failing', request, 'zha_event', { failureCount: 2 }));
|
||||
expect(listener).toBeCalledTimes(1);
|
||||
|
||||
// Failing -> healthy: one more notification.
|
||||
monitor.update(status('subscribed', request, 'zha_event'));
|
||||
expect(listener).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should stop notifying after the returned unsubscribe is called', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
const remove = monitor.addListener(listener);
|
||||
|
||||
remove();
|
||||
monitor.update(status('failing', { id: 'a' }, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
});
|
||||
|
||||
it('should retry one request per failing key and leave healthy keys alone', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const failingA = { id: 'a' };
|
||||
const failingB = { id: 'b' };
|
||||
const healthy = { id: 'c' };
|
||||
|
||||
// Two requests on a failing key, plus a separate healthy key.
|
||||
monitor.update(status('failing', failingA, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', failingB, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('subscribed', healthy, 'deconz_event'));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
// Exactly one retry, for one of the failing key's requests; never the
|
||||
// healthy key.
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect([failingA, failingB]).toContainEqual(retry.mock.calls[0][0]);
|
||||
});
|
||||
|
||||
it('should retry the failing request even when a subscribed sibling is stored first', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const subscribed = { id: 'a' };
|
||||
const failing = { id: 'b' };
|
||||
|
||||
// Subscribed sibling recorded before the failing one on the same key.
|
||||
monitor.update(status('subscribed', subscribed, 'zha_event'));
|
||||
monitor.update(status('failing', failing, 'zha_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(1);
|
||||
expect(retry).toBeCalledWith(failing);
|
||||
});
|
||||
|
||||
it('should retry one request for each distinct failing key', () => {
|
||||
const retry = vi.fn();
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(retry);
|
||||
const a = { id: 'a' };
|
||||
const b = { id: 'b' };
|
||||
|
||||
monitor.update(status('failing', a, 'zha_event', { failureCount: 1 }));
|
||||
monitor.update(status('failing', b, 'deconz_event', { failureCount: 1 }));
|
||||
|
||||
monitor.retry();
|
||||
|
||||
expect(retry).toBeCalledTimes(2);
|
||||
expect(retry.mock.calls.map((c) => c[0])).toEqual(expect.arrayContaining([a, b]));
|
||||
});
|
||||
|
||||
it('should not notify when a never-failed request unsubscribes', () => {
|
||||
const monitor = new SubscriptionHealthMonitor<string, TestRequest>(vi.fn());
|
||||
const listener = vi.fn();
|
||||
monitor.addListener(listener);
|
||||
|
||||
monitor.update(status('unsubscribed', { id: 'a' }, 'zha_event'));
|
||||
|
||||
expect(listener).not.toBeCalled();
|
||||
expect(monitor.getFailures()).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,797 @@
|
||||
import { Connection, STATE_RUNNING, STATE_STARTING } from 'home-assistant-js-websocket';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
import {
|
||||
HASSConnectionSubscriptionManager,
|
||||
HASSWebSocketStatusCallback,
|
||||
HASSWebSocketSubscriptionStatus,
|
||||
} from '../../../src/ha/connection/subscription-manager';
|
||||
import {
|
||||
HASSWebSocketLiveness,
|
||||
HASSWebSocketOpenCallback,
|
||||
} from '../../../src/ha/connection/types';
|
||||
import { HASSSource } from '../../../src/ha/source';
|
||||
import {
|
||||
createHASS,
|
||||
createHASSSource,
|
||||
flushPromises,
|
||||
useDeterministicTimers,
|
||||
} from '../../test-utils';
|
||||
|
||||
interface TestRequest {
|
||||
key: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface CapturedOpenCall {
|
||||
request: TestRequest;
|
||||
connection: Connection;
|
||||
guard: HASSWebSocketLiveness;
|
||||
unsub: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Captures every open-callback invocation so tests can drive its dispatcher and
|
||||
// observe the guard state.
|
||||
const createRecordingOpenCallback = (): {
|
||||
openCallback: HASSWebSocketOpenCallback;
|
||||
calls: CapturedOpenCall[];
|
||||
} => {
|
||||
const calls: CapturedOpenCall[] = [];
|
||||
const openCallback: HASSWebSocketOpenCallback = async (connection, guard) => {
|
||||
const unsub = vi.fn().mockResolvedValue(undefined);
|
||||
calls.push({ request: { key: 'unused' }, connection, guard, unsub });
|
||||
return unsub;
|
||||
};
|
||||
return { openCallback, calls };
|
||||
};
|
||||
|
||||
// An open callback that always rejects, driving the retry/backoff machinery.
|
||||
const createFailingOpenCallback = (): HASSWebSocketOpenCallback =>
|
||||
vi.fn().mockRejectedValue(new Error('boom'));
|
||||
|
||||
const createManager = (
|
||||
source: HASSSource,
|
||||
): HASSConnectionSubscriptionManager<string, TestRequest> =>
|
||||
new HASSConnectionSubscriptionManager<string, TestRequest>((r) => r.key, source);
|
||||
|
||||
// Wires a source (seeded with `initial`) to a fresh manager, returning the
|
||||
// source's drivers alongside it.
|
||||
const setup = (initial: Parameters<typeof createHASSSource>[0] = createHASS()) => {
|
||||
const { source, push, getListenerCount } = createHASSSource(initial);
|
||||
return { manager: createManager(source), push, getListenerCount };
|
||||
};
|
||||
|
||||
// A HASS on a brand-new connection, so pushing it forces an era swap.
|
||||
const createSwappedHASS = (): ReturnType<typeof createHASS> => {
|
||||
const hass = createHASS();
|
||||
hass.connection = mock<Connection>();
|
||||
vi.mocked(hass.connection.subscribeEvents).mockResolvedValue(vi.fn());
|
||||
return hass;
|
||||
};
|
||||
|
||||
// @vitest-environment jsdom
|
||||
describe('HASSConnectionSubscriptionManager', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('attach lifecycle', () => {
|
||||
it('should not attach to source until first subscribe', () => {
|
||||
const { getListenerCount } = setup();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should attach on first subscribe and detach on last unsubscribe', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const req = { key: 'a' };
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should bootstrap from source on first attach', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass.connection);
|
||||
});
|
||||
|
||||
it('should defer when source has no HASS yet', async () => {
|
||||
const { manager, push } = setup(null);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
push(createHASS());
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should defer when initial HASS is not ready', async () => {
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(0);
|
||||
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('era transitions', () => {
|
||||
it('should replace KSM on connection swap and re-submit requests', async () => {
|
||||
const hass1 = createHASS();
|
||||
const { manager, push } = setup(hass1);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].connection).toBe(hass1.connection);
|
||||
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].connection).toBe(hass2.connection);
|
||||
});
|
||||
|
||||
it('should flip old-era guards to dead on swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const era1Guard = calls[0].guard;
|
||||
expect(era1Guard.isConnected()).toBe(true);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(era1Guard.isConnected()).toBe(false);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions on a connection swap', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
// The old era's subscription is closed, not abandoned.
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should close old-era subscriptions when HA goes not-ready', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should mint a fresh era when reanimating from a dead not-ready era, even with the same Connection', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
const oldGuard = calls[0].guard;
|
||||
expect(oldGuard.isConnected()).toBe(true);
|
||||
|
||||
// Go not-ready, keeping the same connection identity.
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
notReady.connection = ready.connection;
|
||||
push(notReady);
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
|
||||
// Re-ready with the SAME Connection: a fresh era must be minted so old
|
||||
// guards stay dead.
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
expect(oldGuard.isConnected()).toBe(false);
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1].guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not replace KSM on a same-connection HASS push', async () => {
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
push(hass);
|
||||
push(hass);
|
||||
await flushPromises();
|
||||
|
||||
// No retry triggered because nothing failed; same era.
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should drop the live era on not-ready and re-establish on the next ready push', async () => {
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
// Old guard should be dead.
|
||||
expect(calls[0].guard.isConnected()).toBe(false);
|
||||
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
|
||||
// Fresh era, fresh submit.
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatch', () => {
|
||||
it('should return current subscribers from getRequestsForKey synchronously', () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const r1 = { key: 'a', label: 'r1' };
|
||||
const r2 = { key: 'a', label: 'r2' };
|
||||
const r3 = { key: 'b', label: 'r3' };
|
||||
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
manager.subscribe(r3, openCallback);
|
||||
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r1, r2]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([r3]);
|
||||
|
||||
manager.unsubscribe(r1);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([r2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verify refcount and serialization are delegated to KSM', () => {
|
||||
it('should refcount per key: a single WS sub per key regardless of subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a', label: 'first' }, openCallback);
|
||||
manager.subscribe({ key: 'a', label: 'second' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should open distinct WS subs for distinct keys', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('time-spaced retries', () => {
|
||||
it('should not retry on subsequent HASS pushes; retries fire on the timer', async () => {
|
||||
useDeterministicTimers();
|
||||
const hass = createHASS();
|
||||
const { manager, push } = setup(hass);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// HASS pushes do not retry while the retry-timer is scheduled.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
push(hass);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
}
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should retry after the backoff delay elapses', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(failing).toBeCalledTimes(1);
|
||||
|
||||
// 1st retry: ~1s.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(failing).toBeCalledTimes(2);
|
||||
|
||||
// 2nd retry: ~2s.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// 3rd retry: ~4s.
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a connection swap', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager, push } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Burn through a few backoff steps so the next would be a longer delay.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
const callsBeforeSwap = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
// Swap. The fresh era should retry immediately on the new connection.
|
||||
push(createSwappedHASS());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 1);
|
||||
|
||||
// First retry on the new era is back at ~1s, not whatever the previous
|
||||
// era's accumulated delay was.
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBeforeSwap + 2);
|
||||
});
|
||||
|
||||
it('should reset the backoff on a readiness transition (not-ready -> ready)', async () => {
|
||||
useDeterministicTimers();
|
||||
const ready = createHASS();
|
||||
const { manager, push } = setup(ready);
|
||||
const failing = createFailingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
const callsBefore = vi.mocked(failing).mock.calls.length;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
push(ready);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// Re-ready triggers a fresh era and a fresh submission.
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 1);
|
||||
|
||||
// Next retry is back to ~1s (fresh backoff).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(vi.mocked(failing).mock.calls.length).toBe(callsBefore + 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stale catch token guard', () => {
|
||||
it('should not let a stale rejection from a swapped-out KSM clear the new submission marker', async () => {
|
||||
const { manager, push } = setup();
|
||||
|
||||
let rejectFirst: ((e: Error) => void) | undefined;
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
// First call (on the initial connection) rejects later.
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<() => Promise<void>>((_, reject) => {
|
||||
rejectFirst = reject;
|
||||
}),
|
||||
)
|
||||
// Subsequent calls succeed.
|
||||
.mockResolvedValue(vi.fn());
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Swap to a new connection BEFORE the first call settles.
|
||||
const hass2 = createSwappedHASS();
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Now reject the stale first call. The catch must NOT wipe the marker for
|
||||
// the in-flight second submission, so the next same-connection push must
|
||||
// NOT submit again.
|
||||
rejectFirst?.(new Error('boom'));
|
||||
await flushPromises();
|
||||
|
||||
push(hass2);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
it('should be a no-op when called without any subscribers', () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
manager.destroy();
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed KSM unsubscribe during drain', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
|
||||
it('should detach source listener, drain KSM, clear state, and flip guards dead', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guardA = calls[0].guard;
|
||||
const guardB = calls[1].guard;
|
||||
expect(guardA.isConnected()).toBe(true);
|
||||
expect(guardB.isConnected()).toBe(true);
|
||||
|
||||
manager.destroy();
|
||||
|
||||
expect(getListenerCount()).toBe(0);
|
||||
expect(guardA.isConnected()).toBe(false);
|
||||
expect(guardB.isConnected()).toBe(false);
|
||||
expect(manager.getRequestsForKey('a')).toEqual([]);
|
||||
expect(manager.getRequestsForKey('b')).toEqual([]);
|
||||
});
|
||||
|
||||
it('should close a subscription still queued in KSM when destroy is called', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
// Destroy before KSM admits the subscribe task: the durable mirror still
|
||||
// drives teardown, so the subscription is closed once it opens rather
|
||||
// than leaking.
|
||||
manager.subscribe({ key: 'a' }, openCallback);
|
||||
manager.destroy();
|
||||
await flushPromises();
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].unsub).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unsubscribe', () => {
|
||||
it('should not flip guards dead on unsubscribe as era still live for other subscribers', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const r1 = { key: 'a' };
|
||||
const r2 = { key: 'a' };
|
||||
manager.subscribe(r1, openCallback);
|
||||
manager.subscribe(r2, openCallback);
|
||||
await flushPromises();
|
||||
|
||||
const guard = calls[0].guard;
|
||||
manager.unsubscribe(r1);
|
||||
expect(guard.isConnected()).toBe(true);
|
||||
});
|
||||
|
||||
it('should detach from source when the last subscriber leaves', async () => {
|
||||
const { manager, getListenerCount } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
expect(getListenerCount()).toBe(1);
|
||||
|
||||
manager.unsubscribe(req);
|
||||
expect(getListenerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('should swallow a failed unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const failingUnsub = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(failingUnsub);
|
||||
|
||||
const req = { key: 'a' };
|
||||
manager.subscribe(req, openCallback);
|
||||
await flushPromises();
|
||||
manager.unsubscribe(req);
|
||||
await flushPromises();
|
||||
|
||||
expect(failingUnsub).toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status callback', () => {
|
||||
const collectStatuses = (): {
|
||||
statusCallback: HASSWebSocketStatusCallback<string, TestRequest>;
|
||||
events: HASSWebSocketSubscriptionStatus<string, TestRequest>[];
|
||||
} => {
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
return { statusCallback: (s) => events.push(s), events };
|
||||
};
|
||||
|
||||
it('should emit waiting then subscribed on a successful subscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(events[1].failureCount).toBeUndefined();
|
||||
expect(events[1].error).toBeUndefined();
|
||||
expect(events[1].key).toBe('a');
|
||||
});
|
||||
|
||||
it('should emit waiting when subscribed before HA is ready', () => {
|
||||
const { manager } = setup(null);
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0].state).toBe('waiting');
|
||||
});
|
||||
|
||||
it('should emit failing with the error and incremented failureCount', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, failing, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// waiting, failing(1).
|
||||
const failures = events.filter((e) => e.state === 'failing');
|
||||
expect(failures).toHaveLength(1);
|
||||
expect(String(failures[0].error)).toMatch(/boom/);
|
||||
expect(failures[0].failureCount).toBe(1);
|
||||
|
||||
// Second attempt also fails -> failing(2).
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const failures2 = events.filter((e) => e.state === 'failing');
|
||||
expect(failures2).toHaveLength(2);
|
||||
expect(failures2[1].failureCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should emit subscribed without a failureCount after eventual success', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
const subscribed = events.filter((e) => e.state === 'subscribed');
|
||||
expect(subscribed).toHaveLength(1);
|
||||
expect(subscribed[0].failureCount).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit unsubscribed on unsubscribe', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
const before = events.length;
|
||||
|
||||
manager.unsubscribe(req);
|
||||
|
||||
expect(events.length).toBe(before + 1);
|
||||
expect(events[events.length - 1].state).toBe('unsubscribed');
|
||||
});
|
||||
|
||||
it('should not emit on unsubscribe for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
const { events } = collectStatuses();
|
||||
|
||||
manager.unsubscribe({ key: 'never-subscribed' });
|
||||
|
||||
expect(events).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should emit waiting for all requests when HA goes not-ready', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const a = collectStatuses();
|
||||
const b = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, a.statusCallback);
|
||||
manager.subscribe({ key: 'b' }, openCallback, b.statusCallback);
|
||||
await flushPromises();
|
||||
a.events.length = 0;
|
||||
b.events.length = 0;
|
||||
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
push(notReady);
|
||||
|
||||
expect(a.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
expect(b.events.map((e) => e.state)).toEqual(['waiting']);
|
||||
});
|
||||
|
||||
it('should emit subscribed after an era reanimation', async () => {
|
||||
const { manager, push } = setup();
|
||||
const { openCallback } = createRecordingOpenCallback();
|
||||
const { statusCallback, events } = collectStatuses();
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
events.length = 0;
|
||||
|
||||
push(createSwappedHASS());
|
||||
await flushPromises();
|
||||
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
});
|
||||
|
||||
it('should isolate a throwing status callback from the retry state machine', async () => {
|
||||
const { manager } = setup();
|
||||
const { openCallback, calls } = createRecordingOpenCallback();
|
||||
|
||||
const events: HASSWebSocketSubscriptionStatus<string, TestRequest>[] = [];
|
||||
const statusCallback: HASSWebSocketStatusCallback<string, TestRequest> = (s) => {
|
||||
events.push(s);
|
||||
if (s.state === 'subscribed') {
|
||||
throw new Error('observer boom');
|
||||
}
|
||||
};
|
||||
|
||||
manager.subscribe({ key: 'a' }, openCallback, statusCallback);
|
||||
await flushPromises();
|
||||
|
||||
// The throw during `subscribed` is swallowed: no `failing` transition and
|
||||
// no retry.
|
||||
expect(events.map((e) => e.state)).toEqual(['waiting', 'subscribed']);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('retry', () => {
|
||||
it('should cancel the pending retry timer and submit immediately', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
|
||||
// Pending timer would fire ~1s from now. retry runs the second attempt
|
||||
// synchronously instead of waiting.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
|
||||
// Advance well past the original 1s schedule: nothing further fires.
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(openCallback).toBeCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should reset the backoff so the next failure schedules at the base delay', async () => {
|
||||
useDeterministicTimers();
|
||||
const { manager } = setup();
|
||||
const failing = createFailingOpenCallback();
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, failing);
|
||||
|
||||
// Burn through three failures to escalate the backoff.
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(failing).toBeCalledTimes(3);
|
||||
|
||||
// User clicks retry: counter resets. Next failure should schedule at ~1s
|
||||
// again, not ~8s.
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(999);
|
||||
expect(failing).toBeCalledTimes(4);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(failing).toBeCalledTimes(5);
|
||||
});
|
||||
|
||||
it('should be a no-op for an unknown request', () => {
|
||||
const { manager } = setup();
|
||||
expect(() => manager.retry({ key: 'never-subscribed' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should not submit in a dead era, but reset the backoff for next era', async () => {
|
||||
useDeterministicTimers();
|
||||
const notReady = createHASS();
|
||||
notReady.config.state = STATE_STARTING;
|
||||
const { manager, push } = setup(notReady);
|
||||
const openCallback: HASSWebSocketOpenCallback = vi
|
||||
.fn()
|
||||
.mockResolvedValue(vi.fn().mockResolvedValue(undefined));
|
||||
const req = { key: 'a' };
|
||||
|
||||
manager.subscribe(req, openCallback);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
manager.retry(req);
|
||||
await flushPromises();
|
||||
expect(openCallback).not.toBeCalled();
|
||||
|
||||
// Era starts; the request submits.
|
||||
const ready = createHASS();
|
||||
ready.config.state = STATE_RUNNING;
|
||||
push(ready);
|
||||
await flushPromises();
|
||||
expect(openCallback).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user