mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* refactor(scheduler): centralize refresh intervals, add VisibilityHub, tiered flush - Add REFRESH_INTERVALS map in base.ts (34 entries) with all refresh intervals including forecasts and correlationEngine that were missing from original PR - Replace all hardcoded interval literals in App.ts with REFRESH_INTERVALS constants while preserving all conditions added since the PR was submitted (pizzint SITE_VARIANT guard, oil energy-complex condition, firms shouldRefreshFirms, temporalBaseline and intelligence shouldRefreshIntelligence guards) - Add VisibilityHub to runtime.ts: single visibilitychange listener that fans out to all registered poll loops, eliminating N duplicate document listeners - Update RefreshScheduler to use shared VisibilityHub instance per scheduler - Add tiered stagger to flushStaleRefreshes: sorts stale tasks by interval (highest frequency first), first 4 flush at 100ms steps, remainder at 300ms steps - Use local constants in flushStaleRefreshes body so new Function() test harness works - Update flush-stale-refreshes test to verify new 100ms fast-tier stagger behavior Co-authored-by: Bigwar6868 <bigwar6868@users.noreply.github.com> * fix(scheduler): remove dead statics, fix noUncheckedIndexedAccess, add missing tests - refresh-scheduler.ts: remove private static readonly FLUSH_STAGGER_FAST_MS/SLOW/FAST_COUNT declarations that were shadowed by local consts in flushStaleRefreshes; use for...of loop to avoid noUncheckedIndexedAccess TS2532 on stale array indexing; proper TS annotation on stale array satisfies noImplicitAny (matches noUncheckedIndexedAccess tsconfig) - flush-stale-refreshes.test.mjs: add stripTSAnnotations() before new Function() so the inline type annotation on stale survives the test harness; add slow-tier stagger test verifying 6 services produce [0,100,200,300,400,700] (index 4+ at 300ms) - smart-poll-loop.test.mjs: add VisibilityHub describe suite with 6 tests covering subscribe/fan-out, unsubscribe callback, auto-remove DOM listener on last unsub, multi-subscriber fan-out, destroy cleanup, and N-subscribers → 1 DOM listener * fix(scheduler): correct stagger comment and add visibilityHub integration tests P3: comment said "first 4 tasks at 100ms gaps" but the condition covers 4 gaps (5 tasks benefit from fast tier); corrected to reflect actual behavior. P2: add integration tests that pass a fake hub into startSmartPollLoop and assert subscribe() fires once on start and unsubscribe() fires once on stop(), and that no direct DOM listener is added when a hub is provided. --------- Co-authored-by: Elie Habib <elie.habib@gmail.com> Co-authored-by: Bigwar6868 <bigwar6868@users.noreply.github.com>
733 lines
22 KiB
JavaScript
733 lines
22 KiB
JavaScript
import { describe, it, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const rawSrc = readFileSync(resolve(__dirname, '..', 'src', 'services', 'runtime.ts'), 'utf-8');
|
|
|
|
function stripTS(src) {
|
|
let out = src;
|
|
out = out.replace(/\bexport\s+type\s+\w+\s*=[^;]+;/g, '');
|
|
out = out.replace(/\bexport\s+interface\s+\w+\s*\{[^}]*\}/g, '');
|
|
out = out.replace(/\bexport\s+/g, '');
|
|
out = out.replace(/\bas\s+\{[^}]+\}/g, '');
|
|
out = out.replace(/:\s*ReturnType<typeof\s+\w+>\s*\|\s*null/g, '');
|
|
out = out.replace(/:\s*AbortController\s*\|\s*null/g, '');
|
|
out = out.replace(/:\s*\(\(\)\s*=>\s*void\)\s*\|\s*null/g, '');
|
|
out = out.replace(/:\s*SmartPollReason/g, '');
|
|
out = out.replace(/:\s*SmartPollContext/g, '');
|
|
out = out.replace(/:\s*SmartPollOptions/g, '');
|
|
out = out.replace(/:\s*SmartPollLoopHandle/g, '');
|
|
out = out.replace(/:\s*Promise<void>/g, '');
|
|
out = out.replace(/:\s*Promise<boolean\s*\|\s*void>\s*\|\s*boolean\s*\|\s*void/g, '');
|
|
out = out.replace(/\(\s*ctx\s*\)\s*=>/g, '(ctx) =>');
|
|
out = out.replace(/:\s*number\s*\|\s*null/g, '');
|
|
out = out.replace(/:\s*(?:number|boolean|string|unknown|void)\b/g, '');
|
|
out = out.replace(/\?\.\s*/g, '?.');
|
|
return out;
|
|
}
|
|
|
|
const runtimeSrc = stripTS(rawSrc);
|
|
|
|
function extractBody(source, funcName) {
|
|
const sig = new RegExp(`function\\s+${funcName}\\s*\\(`);
|
|
const match = sig.exec(source);
|
|
if (!match) throw new Error(`Could not find function ${funcName}`);
|
|
|
|
const openBrace = source.indexOf('{', match.index);
|
|
if (openBrace === -1) throw new Error(`No body found for ${funcName}`);
|
|
const bodyStart = openBrace + 1;
|
|
let depth = 1;
|
|
let state = 'code';
|
|
let escaped = false;
|
|
|
|
for (let j = bodyStart; j < source.length; j++) {
|
|
const ch = source[j];
|
|
const next = source[j + 1];
|
|
|
|
if (state === 'line-comment') { if (ch === '\n') state = 'code'; continue; }
|
|
if (state === 'block-comment') { if (ch === '*' && next === '/') { state = 'code'; j++; } continue; }
|
|
if (state === 'single-quote') { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === "'") { state = 'code'; } continue; }
|
|
if (state === 'double-quote') { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '"') { state = 'code'; } continue; }
|
|
if (state === 'template') { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === '`') { state = 'code'; } continue; }
|
|
|
|
if (ch === '/' && next === '/') { state = 'line-comment'; j++; continue; }
|
|
if (ch === '/' && next === '*') { state = 'block-comment'; j++; continue; }
|
|
if (ch === "'") { state = 'single-quote'; continue; }
|
|
if (ch === '"') { state = 'double-quote'; continue; }
|
|
if (ch === '`') { state = 'template'; continue; }
|
|
if (ch === '{') { depth++; continue; }
|
|
if (ch === '}') { depth--; if (depth === 0) return source.slice(bodyStart, j); }
|
|
}
|
|
throw new Error(`Could not extract body for ${funcName}`);
|
|
}
|
|
|
|
function createFakeTimers(startMs = 1_000_000) {
|
|
const tasks = new Map();
|
|
let now = startMs;
|
|
let nextId = 1;
|
|
|
|
const sortedDueTasks = (target) =>
|
|
Array.from(tasks.entries())
|
|
.filter(([, task]) => task.at <= target)
|
|
.sort((a, b) => (a[1].at - b[1].at) || (a[0] - b[0]));
|
|
|
|
return {
|
|
get now() { return now; },
|
|
get pendingCount() { return tasks.size; },
|
|
setTimeout(fn, delay = 0) {
|
|
const id = nextId++;
|
|
tasks.set(id, { at: now + Math.max(0, delay), fn });
|
|
return id;
|
|
},
|
|
clearTimeout(id) { tasks.delete(id); },
|
|
advanceBy(ms) {
|
|
const target = now + Math.max(0, ms);
|
|
while (true) {
|
|
const due = sortedDueTasks(target);
|
|
if (!due.length) break;
|
|
const [id, task] = due[0];
|
|
tasks.delete(id);
|
|
now = task.at;
|
|
task.fn();
|
|
}
|
|
now = target;
|
|
},
|
|
async advanceByAsync(ms) {
|
|
const target = now + Math.max(0, ms);
|
|
while (true) {
|
|
const due = sortedDueTasks(target);
|
|
if (!due.length) break;
|
|
const [id, task] = due[0];
|
|
tasks.delete(id);
|
|
now = task.at;
|
|
task.fn();
|
|
await Promise.resolve();
|
|
}
|
|
now = target;
|
|
},
|
|
runAll() {
|
|
let safety = 0;
|
|
while (tasks.size > 0 && safety < 500) {
|
|
const [[id, task]] = Array.from(tasks.entries()).sort(
|
|
(a, b) => (a[1].at - b[1].at) || (a[0] - b[0])
|
|
);
|
|
tasks.delete(id);
|
|
now = task.at;
|
|
task.fn();
|
|
safety++;
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
function buildSmartPollLoop(timers, docMock) {
|
|
const isAbortErrorBody = extractBody(runtimeSrc, 'isAbortError');
|
|
const hasVisibilityApiBody = extractBody(runtimeSrc, 'hasVisibilityApi');
|
|
const isDocumentHiddenBody = extractBody(runtimeSrc, 'isDocumentHidden');
|
|
const mainBody = extractBody(runtimeSrc, 'startSmartPollLoop');
|
|
|
|
const factory = new Function(
|
|
'setTimeout', 'clearTimeout', 'Math', 'AbortController', 'document',
|
|
`
|
|
function isAbortError(error) { ${isAbortErrorBody} }
|
|
function hasVisibilityApi() { ${hasVisibilityApiBody} }
|
|
function isDocumentHidden() { ${isDocumentHiddenBody} }
|
|
return function startSmartPollLoop(poll, opts) { ${mainBody} };
|
|
`
|
|
);
|
|
|
|
return factory(
|
|
timers.setTimeout.bind(timers),
|
|
timers.clearTimeout.bind(timers),
|
|
Math,
|
|
AbortController,
|
|
docMock,
|
|
);
|
|
}
|
|
|
|
function createDocMock(hidden = false) {
|
|
const listeners = new Map();
|
|
return {
|
|
visibilityState: hidden ? 'hidden' : 'visible',
|
|
addEventListener(evt, fn) {
|
|
if (!listeners.has(evt)) listeners.set(evt, []);
|
|
listeners.get(evt).push(fn);
|
|
},
|
|
removeEventListener(evt, fn) {
|
|
if (!listeners.has(evt)) return;
|
|
const arr = listeners.get(evt);
|
|
const idx = arr.indexOf(fn);
|
|
if (idx !== -1) arr.splice(idx, 1);
|
|
},
|
|
_fire(evt) {
|
|
for (const fn of (listeners.get(evt) || [])) fn();
|
|
},
|
|
_setHidden(h) {
|
|
this.visibilityState = h ? 'hidden' : 'visible';
|
|
},
|
|
_listenerCount(evt) {
|
|
return (listeners.get(evt) || []).length;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('startSmartPollLoop', () => {
|
|
let timers;
|
|
let doc;
|
|
let startSmartPollLoop;
|
|
|
|
beforeEach(() => {
|
|
timers = createFakeTimers();
|
|
doc = createDocMock();
|
|
startSmartPollLoop = buildSmartPollLoop(timers, doc);
|
|
});
|
|
|
|
describe('scheduling', () => {
|
|
it('fires first tick after intervalMs', async () => {
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, { intervalMs: 5_000, jitterFraction: 0 });
|
|
|
|
assert.equal(calls, 0);
|
|
timers.advanceBy(4_999);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
timers.advanceBy(1);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
});
|
|
|
|
it('subsequent ticks continue firing', async () => {
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, { intervalMs: 5_000, jitterFraction: 0 });
|
|
|
|
timers.advanceBy(5_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
timers.advanceBy(5_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
|
|
timers.advanceBy(5_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 3);
|
|
});
|
|
});
|
|
|
|
describe('jitter', () => {
|
|
it('delay varies within ±jitterFraction of base interval', async () => {
|
|
const delays = [];
|
|
let lastCall = timers.now;
|
|
const poll = () => {
|
|
delays.push(timers.now - lastCall);
|
|
lastCall = timers.now;
|
|
};
|
|
|
|
startSmartPollLoop(poll, { intervalMs: 10_000, jitterFraction: 0.2 });
|
|
|
|
for (let i = 0; i < 250; i++) {
|
|
timers.advanceBy(500);
|
|
await Promise.resolve();
|
|
}
|
|
|
|
assert.ok(delays.length >= 8, `expected at least 8 calls, got ${delays.length}`);
|
|
for (const d of delays) {
|
|
assert.ok(d >= 8_000, `delay ${d} should be >= 8000`);
|
|
assert.ok(d <= 13_000, `delay ${d} should be <= 13000`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('backoff', () => {
|
|
it('doubles interval on false return, resets on success', async () => {
|
|
let returnVal = false;
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; return returnVal; }, {
|
|
intervalMs: 1_000, jitterFraction: 0, maxBackoffMultiplier: 8,
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
timers.advanceBy(2_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
|
|
timers.advanceBy(4_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 3);
|
|
|
|
returnVal = true;
|
|
timers.advanceBy(8_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 4);
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 5);
|
|
});
|
|
|
|
it('caps at maxBackoffMultiplier', async () => {
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; return false; }, {
|
|
intervalMs: 1_000, jitterFraction: 0, maxBackoffMultiplier: 4,
|
|
});
|
|
|
|
timers.advanceBy(1_000); await Promise.resolve(); // 1x
|
|
timers.advanceBy(2_000); await Promise.resolve(); // 2x
|
|
timers.advanceBy(4_000); await Promise.resolve(); // 4x (cap)
|
|
assert.equal(calls, 3);
|
|
|
|
timers.advanceBy(4_000); await Promise.resolve(); // still 4x
|
|
assert.equal(calls, 4);
|
|
});
|
|
});
|
|
|
|
describe('error backoff', () => {
|
|
it('thrown errors trigger backoff and onError', async () => {
|
|
const errors = [];
|
|
let calls = 0;
|
|
startSmartPollLoop(() => {
|
|
calls++;
|
|
throw new Error('fail');
|
|
}, {
|
|
intervalMs: 1_000, jitterFraction: 0, maxBackoffMultiplier: 4,
|
|
onError: (e) => errors.push(e),
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
assert.equal(errors.length, 1);
|
|
assert.equal(errors[0].message, 'fail');
|
|
|
|
timers.advanceBy(2_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
});
|
|
});
|
|
|
|
describe('shouldRun gating', () => {
|
|
it('poll skipped when shouldRun returns false', async () => {
|
|
let gate = false;
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 1_000, jitterFraction: 0, shouldRun: () => gate,
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
|
|
gate = true;
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
});
|
|
});
|
|
|
|
describe('runImmediately', () => {
|
|
it('fires at t=0 with reason startup', async () => {
|
|
let capturedReason = null;
|
|
startSmartPollLoop((ctx) => { capturedReason = ctx.reason; }, {
|
|
intervalMs: 5_000, runImmediately: true, jitterFraction: 0,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
assert.equal(capturedReason, 'startup');
|
|
});
|
|
});
|
|
|
|
describe('pauseWhenHidden', () => {
|
|
it('no ticks while hidden', async () => {
|
|
doc._setHidden(true);
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 1_000, jitterFraction: 0, pauseWhenHidden: true,
|
|
});
|
|
|
|
timers.advanceBy(10_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
});
|
|
|
|
it('resumes on visibility change to visible', async () => {
|
|
doc._setHidden(false);
|
|
let calls = 0;
|
|
const handle = startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 1_000, jitterFraction: 0, pauseWhenHidden: true,
|
|
visibilityDebounceMs: 0,
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
doc._setHidden(true);
|
|
doc._fire('visibilitychange');
|
|
timers.advanceBy(5_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
doc._setHidden(false);
|
|
doc._fire('visibilitychange');
|
|
await Promise.resolve();
|
|
assert.ok(calls >= 2, `expected resume poll, got ${calls}`);
|
|
|
|
handle.stop();
|
|
});
|
|
|
|
it('aborts in-flight on hide', async () => {
|
|
let aborted = false;
|
|
const handle = startSmartPollLoop(async (ctx) => {
|
|
ctx.signal?.addEventListener('abort', () => { aborted = true; });
|
|
return new Promise(() => { });
|
|
}, {
|
|
intervalMs: 1_000, jitterFraction: 0, pauseWhenHidden: true,
|
|
runImmediately: true, visibilityDebounceMs: 0,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
doc._setHidden(true);
|
|
doc._fire('visibilitychange');
|
|
assert.equal(aborted, true);
|
|
handle.stop();
|
|
});
|
|
});
|
|
|
|
describe('hiddenMultiplier', () => {
|
|
it('interval scaled when hidden (not paused)', async () => {
|
|
doc._setHidden(true);
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 1_000, hiddenMultiplier: 5, jitterFraction: 0,
|
|
pauseWhenHidden: false,
|
|
});
|
|
|
|
timers.advanceBy(4_999);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
|
|
timers.advanceBy(1);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
});
|
|
});
|
|
|
|
describe('hiddenIntervalMs', () => {
|
|
it('explicit hidden interval overrides multiplier', async () => {
|
|
doc._setHidden(true);
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 1_000, hiddenMultiplier: 100,
|
|
hiddenIntervalMs: 3_000, jitterFraction: 0,
|
|
pauseWhenHidden: false,
|
|
});
|
|
|
|
timers.advanceBy(2_999);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
|
|
timers.advanceBy(1);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
});
|
|
});
|
|
|
|
describe('refreshOnVisible', () => {
|
|
it('immediate run with reason resume on tab visible', async () => {
|
|
let capturedReason = null;
|
|
startSmartPollLoop((ctx) => { capturedReason = ctx.reason; }, {
|
|
intervalMs: 60_000, refreshOnVisible: true, jitterFraction: 0,
|
|
visibilityDebounceMs: 0,
|
|
});
|
|
|
|
doc._setHidden(true);
|
|
doc._fire('visibilitychange');
|
|
doc._setHidden(false);
|
|
doc._fire('visibilitychange');
|
|
await Promise.resolve();
|
|
assert.equal(capturedReason, 'resume');
|
|
});
|
|
});
|
|
|
|
describe('visibility debounce', () => {
|
|
it('rapid show events coalesced within visibilityDebounceMs', async () => {
|
|
let calls = 0;
|
|
startSmartPollLoop(() => { calls++; }, {
|
|
intervalMs: 60_000, refreshOnVisible: true,
|
|
visibilityDebounceMs: 500, jitterFraction: 0,
|
|
});
|
|
|
|
doc._setHidden(true);
|
|
doc._fire('visibilitychange');
|
|
doc._setHidden(false);
|
|
doc._fire('visibilitychange');
|
|
doc._setHidden(true);
|
|
doc._fire('visibilitychange');
|
|
doc._setHidden(false);
|
|
doc._fire('visibilitychange');
|
|
await Promise.resolve();
|
|
assert.equal(calls, 0);
|
|
|
|
timers.advanceBy(500);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
});
|
|
});
|
|
|
|
describe('trigger()', () => {
|
|
it('manual trigger fires immediately and resets schedule', async () => {
|
|
let calls = 0;
|
|
let lastReason = null;
|
|
const handle = startSmartPollLoop((ctx) => { calls++; lastReason = ctx.reason; }, {
|
|
intervalMs: 10_000, jitterFraction: 0,
|
|
});
|
|
|
|
handle.trigger();
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
assert.equal(lastReason, 'manual');
|
|
|
|
timers.advanceBy(10_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
assert.equal(lastReason, 'interval');
|
|
});
|
|
});
|
|
|
|
describe('stop()', () => {
|
|
it('clears timers, aborts in-flight, removes listener, isActive false', async () => {
|
|
let aborted = false;
|
|
const handle = startSmartPollLoop(async (ctx) => {
|
|
ctx.signal?.addEventListener('abort', () => { aborted = true; });
|
|
return new Promise(() => { });
|
|
}, {
|
|
intervalMs: 1_000, jitterFraction: 0, runImmediately: true,
|
|
});
|
|
|
|
await Promise.resolve();
|
|
assert.equal(handle.isActive(), true);
|
|
|
|
handle.stop();
|
|
assert.equal(handle.isActive(), false);
|
|
assert.equal(aborted, true);
|
|
assert.equal(doc._listenerCount('visibilitychange'), 0);
|
|
|
|
timers.advanceBy(10_000);
|
|
await Promise.resolve();
|
|
});
|
|
});
|
|
|
|
describe('AbortSignal', () => {
|
|
it('signal provided to poll fn', async () => {
|
|
let receivedSignal = null;
|
|
startSmartPollLoop((ctx) => { receivedSignal = ctx.signal; }, {
|
|
intervalMs: 1_000, jitterFraction: 0,
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.ok(receivedSignal instanceof AbortSignal);
|
|
});
|
|
|
|
it('abort errors do not trigger backoff', async () => {
|
|
let calls = 0;
|
|
startSmartPollLoop((ctx) => {
|
|
calls++;
|
|
const err = new Error('aborted');
|
|
err.name = 'AbortError';
|
|
throw err;
|
|
}, {
|
|
intervalMs: 1_000, jitterFraction: 0, maxBackoffMultiplier: 4,
|
|
});
|
|
|
|
timers.advanceBy(1_000); await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
timers.advanceBy(1_000); await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
});
|
|
});
|
|
|
|
describe('in-flight guard', () => {
|
|
it('concurrent calls are deferred, not dropped', async () => {
|
|
let calls = 0;
|
|
let resolvers = [];
|
|
const handle = startSmartPollLoop(() => {
|
|
calls++;
|
|
return new Promise(r => resolvers.push(r));
|
|
}, {
|
|
intervalMs: 1_000, jitterFraction: 0,
|
|
});
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 1);
|
|
|
|
resolvers[0]();
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
timers.advanceBy(1_000);
|
|
await Promise.resolve();
|
|
assert.equal(calls, 2);
|
|
|
|
resolvers[1]?.();
|
|
handle.stop();
|
|
});
|
|
});
|
|
|
|
describe('visibilityHub integration', () => {
|
|
it('subscribes to a provided hub on start and unsubscribes on stop()', () => {
|
|
let subscribeCalls = 0;
|
|
let unsubscribeCalls = 0;
|
|
const fakeHub = {
|
|
subscribe(cb) {
|
|
subscribeCalls++;
|
|
return () => { unsubscribeCalls++; };
|
|
},
|
|
};
|
|
|
|
const handle = startSmartPollLoop(() => {}, {
|
|
intervalMs: 1_000,
|
|
jitterFraction: 0,
|
|
visibilityHub: fakeHub,
|
|
});
|
|
|
|
assert.equal(subscribeCalls, 1, 'subscribe() called once on start');
|
|
assert.equal(unsubscribeCalls, 0, 'unsubscribe not called before stop');
|
|
|
|
handle.stop();
|
|
|
|
assert.equal(unsubscribeCalls, 1, 'unsubscribe() called once on stop');
|
|
assert.equal(subscribeCalls, 1, 'subscribe() not called again after stop');
|
|
});
|
|
|
|
it('uses hub callbacks for visibility changes instead of direct DOM listener', () => {
|
|
let hubCallback = null;
|
|
const fakeHub = {
|
|
subscribe(cb) {
|
|
hubCallback = cb;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
let ticks = 0;
|
|
const handle = startSmartPollLoop(() => { ticks++; return true; }, {
|
|
intervalMs: 60_000,
|
|
jitterFraction: 0,
|
|
pauseWhenHidden: true,
|
|
visibilityHub: fakeHub,
|
|
});
|
|
|
|
assert.ok(hubCallback, 'hub subscriber callback was registered');
|
|
assert.equal(doc._listenerCount('visibilitychange'), 0, 'no direct DOM listener added when hub provided');
|
|
|
|
handle.stop();
|
|
});
|
|
});
|
|
});
|
|
|
|
// Build a plain-JS VisibilityHub that mirrors the contract of the real class,
|
|
// used to test hub behavior without needing to strip TypeScript class syntax.
|
|
function buildVisibilityHub(doc) {
|
|
const hasVisibilityApiBody = extractBody(runtimeSrc, 'hasVisibilityApi');
|
|
const factory = new Function('document', `
|
|
function hasVisibilityApi() { ${hasVisibilityApiBody} }
|
|
let _listeners = new Set();
|
|
let _handler = null;
|
|
function ensureListening() {
|
|
if (_handler || !hasVisibilityApi()) return;
|
|
_handler = () => { for (const cb of _listeners) cb(); };
|
|
document.addEventListener('visibilitychange', _handler);
|
|
}
|
|
function stopListening() {
|
|
if (!_handler) return;
|
|
document.removeEventListener('visibilitychange', _handler);
|
|
_handler = null;
|
|
}
|
|
return {
|
|
subscribe(cb) {
|
|
_listeners.add(cb);
|
|
ensureListening();
|
|
return () => { _listeners.delete(cb); if (_listeners.size === 0) stopListening(); };
|
|
},
|
|
destroy() { stopListening(); _listeners.clear(); },
|
|
};
|
|
`);
|
|
return factory(doc);
|
|
}
|
|
|
|
describe('VisibilityHub', () => {
|
|
let doc;
|
|
|
|
beforeEach(() => {
|
|
doc = createDocMock();
|
|
});
|
|
|
|
it('subscribe fans out to the callback on visibilitychange', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
let fired = 0;
|
|
hub.subscribe(() => { fired++; });
|
|
doc._fire('visibilitychange');
|
|
assert.equal(fired, 1);
|
|
hub.destroy();
|
|
});
|
|
|
|
it('unsubscribe callback prevents further notifications', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
let fired = 0;
|
|
const unsub = hub.subscribe(() => { fired++; });
|
|
unsub();
|
|
doc._fire('visibilitychange');
|
|
assert.equal(fired, 0, 'unsubscribed callback must not fire');
|
|
hub.destroy();
|
|
});
|
|
|
|
it('removes the DOM listener when the last subscriber unsubscribes', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
const unsub = hub.subscribe(() => {});
|
|
assert.equal(doc._listenerCount('visibilitychange'), 1, 'listener added on first subscribe');
|
|
unsub();
|
|
assert.equal(doc._listenerCount('visibilitychange'), 0, 'listener removed when subscriber count reaches 0');
|
|
hub.destroy();
|
|
});
|
|
|
|
it('fans out to all subscribers on visibilitychange', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
const fired = [];
|
|
hub.subscribe(() => fired.push('a'));
|
|
hub.subscribe(() => fired.push('b'));
|
|
doc._fire('visibilitychange');
|
|
assert.deepEqual(fired.sort(), ['a', 'b']);
|
|
hub.destroy();
|
|
});
|
|
|
|
it('destroy clears all subscribers and removes the DOM listener', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
let fired = 0;
|
|
hub.subscribe(() => { fired++; });
|
|
hub.destroy();
|
|
doc._fire('visibilitychange');
|
|
assert.equal(fired, 0, 'no callbacks after destroy');
|
|
assert.equal(doc._listenerCount('visibilitychange'), 0, 'DOM listener removed after destroy');
|
|
});
|
|
|
|
it('multiple subscribers share one DOM listener', () => {
|
|
const hub = buildVisibilityHub(doc);
|
|
hub.subscribe(() => {});
|
|
hub.subscribe(() => {});
|
|
hub.subscribe(() => {});
|
|
assert.equal(doc._listenerCount('visibilitychange'), 1, 'N subscribers → exactly 1 DOM listener');
|
|
hub.destroy();
|
|
});
|
|
});
|