mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-05-13 02:26:22 +02:00
* feat: harness engineering P0 - linting, testing, architecture docs
Add foundational infrastructure for agent-first development:
- AGENTS.md: agent entry point with progressive disclosure to deeper docs
- ARCHITECTURE.md: 12-section system reference with source-file refs and ownership rule
- Biome 2.4.7 linter with project-tuned rules, CI workflow (lint-code.yml)
- Architectural boundary lint enforcing forward-only dependency direction (lint-boundaries.mjs)
- Unit test CI workflow (test.yml), all 1083 tests passing
- Fixed 9 pre-existing test failures (bootstrap sync, deploy-config headers, globe parity, redis mocks, geometry URL, import.meta.env null safety)
- Fixed 12 architectural boundary violations (types moved to proper layers)
- Added 3 missing cache tier entries in gateway.ts
- Synced cache-keys.ts with bootstrap.js
- Renamed docs/architecture.mdx to "Design Philosophy" with cross-references
- Deprecated legacy docs/Docs_To_Review/ARCHITECTURE.md
- Harness engineering roadmap tracking doc
* fix: address PR review feedback on harness-engineering-p0
- countries-geojson.test.mjs: skip gracefully when CDN unreachable
instead of failing CI on network issues
- country-geometry-overrides.test.mts: relax timing assertion
(250ms -> 2000ms) for constrained CI environments
- lint-boundaries.mjs: implement the documented api/ boundary check
(was documented but missing, causing false green)
* fix(lint): scan api/ .ts files in boundary check
The api/ boundary check only scanned .js/.mjs files, missing the 25
sebuf RPC .ts edge functions. Now scans .ts files with correct rules:
- Legacy .js: fully self-contained (no server/ or src/ imports)
- RPC .ts: may import server/ and src/generated/ (bundled at deploy),
but blocks imports from src/ application code
* fix(lint): detect import() type expressions in boundary lint
- Move AppContext back to app/app-context.ts (aggregate type that
references components/services/utils belongs at the top, not types/)
- Move HappyContentCategory and TechHQ to types/ (simple enums/interfaces)
- Boundary lint now catches import('@/layer') expressions, not just
from '@/layer' imports
- correlation-engine imports of AppContext marked boundary-ignore
(type-only imports of top-level aggregate)
587 lines
18 KiB
JavaScript
587 lines
18 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*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;
|
|
const 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();
|
|
});
|
|
});
|
|
});
|