// Unit tests for src/main.js, run by script/frontend-test with Node's // built-in test runner. Importing the module does not start the page. import { test } from "node:test"; import assert from "node:assert/strict"; import { AppState, CONFIG, measureLatency, tick } from "../../src/main.js"; // There is no page here, so the tests stand in for it. The debug log looks // for its panel by id and finds none. Each element of a host's row that // tick draws into is a plain object, made the first time it is looked up // and kept in elements under its selector. Drawing a sparkline does // nothing; it looks for the pixel ratio on window and finds none. const elements = {}; const doNothing = () => {}; const canvasContext = { clearRect: doNothing, beginPath: doNothing, moveTo: doNothing, lineTo: doNothing, stroke: doNothing, fill: doNothing, fillRect: doNothing, fillText: doNothing, arc: doNothing, }; globalThis.window = {}; globalThis.document = { getElementById: () => null, querySelector: (selector) => (elements[selector] ??= { getContext: () => canvasContext }), }; // What tick last wrote into the latency figure in host's row, or undefined // if it has written nothing there. function latencyFigure(state, host) { const index = state.allHosts.indexOf(host); return elements[`.latency-value[data-host="${index}"]`]?.innerHTML; } // Mocks the clock for test t, so that a check lasting seconds takes no real // time, and replaces fetch with targets that each answer after // answerAfter(url) milliseconds of that clock, or never when that is // Infinity. Both are restored when the test ends. function mockTargets(t, answerAfter) { t.mock.timers.enable({ apis: ["setTimeout", "Date"] }); t.mock.method(performance, "now", () => Date.now()); t.mock.method( globalThis, "fetch", (url, { signal }) => new Promise((resolve, reject) => { if (answerAfter(url) !== Infinity) { setTimeout(resolve, answerAfter(url)); } signal.addEventListener("abort", () => reject(signal.reason)); }), ); } // The result of check if it has ended, otherwise "still waiting". function settled(check) { return Promise.race([ check, new Promise((resolve) => setImmediate(resolve, "still waiting")), ]); } for (const interval of [10000, 30000]) { const timeout = interval * 0.8; // Over 3 seconds, which the timeout used to be capped at. const slowAnswer = timeout - 1000; test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => { CONFIG.updateInterval = interval; mockTargets(t, () => slowAnswer); const check = measureLatency("https://target.test"); t.mock.timers.tick(slowAnswer); assert.deepEqual(await settled(check), { latency: slowAnswer, error: null, }); }); test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => { CONFIG.updateInterval = interval; mockTargets(t, () => Infinity); const check = measureLatency("https://target.test"); t.mock.timers.tick(timeout - 1); assert.equal(await settled(check), "still waiting"); t.mock.timers.tick(1); assert.deepEqual(await settled(check), { latency: null, error: "timeout", }); }); } test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => { CONFIG.updateInterval = 30000; const state = new AppState([ { name: "Answering", url: "https://answering.test" }, ]); const answering = state.local[0]; const waiting = state.wan[0]; // No target but the answering one ever answers. mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); // The third tick: the first is discarded as a whole, and the second ends // by sorting the rows, which rebuilds a page that is not here. state.tickCount = 2; const round = tick(state, new AbortController().signal); t.mock.timers.tick(1000); assert.equal(await settled(round), "still waiting"); assert.match(latencyFigure(state, answering), />1000