108 lines
3.5 KiB
JavaScript
108 lines
3.5 KiB
JavaScript
import { afterEach, describe, expect, it } from 'bun:test';
|
|||
|
|
import { createEventPipeline } from '../event-pipeline';
|
||
|
|
|
||
|
|
const savedDocument = globalThis.document;
|
||
|
|
const savedWindow = globalThis.window;
|
||
|
|
const savedNavigator = globalThis.navigator;
|
||
|
|
|
||
|
|
afterEach(() => {
|
||
|
|
globalThis.document = savedDocument;
|
||
|
|
globalThis.window = savedWindow;
|
||
|
|
globalThis.navigator = savedNavigator;
|
||
|
|
});
|
||
|
|
|
||
|
|
// Multi-listener event-target stub. The simpler single-slot stub used in
|
||
|
|
// event-pipeline-resume.test.js would break here because waitForRetry and the
|
||
|
|
// top-level onOnline handler both register for `online`.
|
||
|
|
function createEventTarget(extras = {}) {
|
||
|
|
const listeners = new Map();
|
||
|
|
return {
|
||
|
|
...extras,
|
||
|
|
addEventListener(event, handler) {
|
||
|
|
const list = listeners.get(event);
|
||
|
|
if (list) list.add(handler);
|
||
|
|
else listeners.set(event, new Set([handler]));
|
||
|
|
},
|
||
|
|
removeEventListener(event, handler) {
|
||
|
|
listeners.get(event)?.delete(handler);
|
||
|
|
},
|
||
|
|
dispatch(event) {
|
||
|
|
const list = listeners.get(event);
|
||
|
|
if (!list) return;
|
||
|
|
for (const handler of Array.from(list)) {
|
||
|
|
handler();
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
describe('createEventPipeline — online event', () => {
|
||
|
|
it('cuts the inter-attempt wait short when `online` fires after disconnect', async () => {
|
||
|
|
globalThis.document = createEventTarget({ visibilityState: 'visible' });
|
||
|
|
globalThis.window = createEventTarget({
|
||
|
|
location: { href: 'http://127.0.0.1:3000/', origin: 'http://127.0.0.1:3000' },
|
||
|
|
});
|
||
|
|
globalThis.navigator = { onLine: false };
|
||
|
|
|
||
|
|
let sdkCallIndex = 0;
|
||
|
|
const sdk = {
|
||
|
|
global: {
|
||
|
|
event: async () => {
|
||
|
|
const idx = sdkCallIndex++;
|
||
|
|
if (idx === 0) {
|
||
|
|
// Force a real failure so the loop enters the offline backoff path
|
||
|
|
// (computeRetryDelay returns the long cap because navigator.onLine
|
||
|
|
// is false). Without our `online` interrupt this would wait the
|
||
|
|
// full hidden/offline cap of 60s and the test would time out.
|
||
|
|
throw new Error('simulated network error');
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
stream: (async function* () {
|
||
|
|
yield {
|
||
|
|
payload: {
|
||
|
|
type: 'session.status',
|
||
|
|
properties: { sessionID: 's1', status: { type: 'idle' } },
|
||
|
|
},
|
||
|
|
};
|
||
|
|
await new Promise(() => {});
|
||
|
|
})(),
|
||
|
|
};
|
||
|
|
},
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const startedAt = Date.now();
|
||
|
|
const elapsed = await new Promise((resolve) => {
|
||
|
|
let connects = 0;
|
||
|
|
const { cleanup } = createEventPipeline({
|
||
|
|
sdk,
|
||
|
|
transport: 'sse',
|
||
|
|
heartbeatTimeoutMs: 60_000,
|
||
|
|
reconnectDelayMs: 60_000,
|
||
|
|
onEvent: () => {},
|
||
|
|
onDisconnect: () => {
|
||
|
|
// We're now inside waitForRetry on the long offline cap.
|
||
|
|
// Flip the browser back online and fire the event; waitForRetry
|
||
|
|
// should resolve early and the next attempt should fire.
|
||
|
|
setTimeout(() => {
|
||
|
|
globalThis.navigator = { onLine: true };
|
||
|
|
globalThis.window.dispatch('online');
|
||
|
|
}, 30);
|
||
|
|
},
|
||
|
|
onReconnect: () => {
|
||
|
|
connects += 1;
|
||
|
|
if (connects === 1) {
|
||
|
|
cleanup();
|
||
|
|
resolve(Date.now() - startedAt);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// Two attempts: the failed one + the recovery one. If the `online`
|
||
|
|
// interrupt didn't fire, the test would have hung on the 60s offline cap.
|
||
|
|
expect(sdkCallIndex).toBe(2);
|
||
|
|
expect(elapsed).toBeLessThan(2_000);
|
||
|
|
});
|
||
|
|
});
|