// 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 { beforeEach, test } from "node:test"; import assert from "node:assert/strict"; import { AppState, CONFIG, greyOutUI, 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 or greyOutUI draws into is a plain object, made the first time a // test looks it up and kept in elements under its selector until the next // test starts. As on a page, writing its text replaces its markup; the // status dot greyOutUI looks for in it is not there. Drawing a sparkline // does nothing; it looks for the pixel ratio on window and finds none. let elements; beforeEach(() => { 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, querySelector: () => null, set textContent(text) { this.innerHTML = text; }, }), }; // 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; } // What was last written into the status text in host's row. function statusText(state, host) { const index = state.allHosts.indexOf(host); return elements[`.status-text[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 { CONFIG.updateInterval = 30000; const state = new AppState([ { name: "Answering", url: "https://answering.test" }, ]); const answering = state.local[0]; mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); state.tickCount = 2; const roundChecks = new AbortController(); const round = tick(state, roundChecks.signal); t.mock.timers.tick(500); // As a round started early does to the last round's checks. roundChecks.abort(); assert.notEqual(await settled(round), "still waiting"); assert.equal(latencyFigure(state, answering), undefined); }); test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => { CONFIG.updateInterval = 30000; const state = new AppState([ { name: "Answering", url: "https://answering.test" }, ]); const answering = state.local[0]; mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); state.tickCount = 2; const round = tick(state, new AbortController().signal); t.mock.timers.tick(500); state.paused = true; t.mock.timers.tick(500); assert.equal(await settled(round), "still waiting"); assert.equal(latencyFigure(state, answering), undefined); }); test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => { CONFIG.updateInterval = 30000; const state = new AppState([ { name: "Answering", url: "https://answering.test" }, ]); const answering = state.local[0]; mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); const round = tick(state, new AbortController().signal); t.mock.timers.tick(1000); assert.equal(await settled(round), "still waiting"); assert.equal(latencyFigure(state, answering), undefined); }); test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => { CONFIG.updateInterval = 30000; const state = new AppState([ { name: "Answering", url: "https://answering.test" }, ]); const answering = state.local[0]; mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity)); state.tickCount = 2; const round = tick(state, new AbortController().signal); t.mock.timers.tick(1000); assert.equal(await settled(round), "still waiting"); // The user pauses, which greys out every row, and resumes, which leaves // the rows as they are, as togglePause does. The answering target's // check has already ended, so only the redraw of every row at the end // of the round can take "paused" out of its row. state.paused = true; greyOutUI(state); state.paused = false; assert.equal(statusText(state, answering), "paused"); // The round ends when the last check times out. t.mock.timers.tick(CONFIG.requestTimeout - 1000); assert.notEqual(await settled(round), "still waiting"); for (const host of state.allHosts) { assert.notEqual(statusText(state, host), "paused", host.name); } });