// Unit tests for src/main.js, run with Node's built-in test runner by the // test script in package.json. Importing the module does not start the // page. import { beforeEach, test } from "node:test"; import assert from "node:assert/strict"; import { AppState, CONFIG, greyOutUI, hostRowHTML, HostState, humanDuration, latencyClass, latencyHex, 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); } }); // The page shows < > " & and ' in a row's markup as // < > " & and '. test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => { const host = new HostState({ name: `"x" & 'y'`, url: `https://x.test/?a="x"&b='y'`, }); const row = hostRowHTML(host, 0); assert.doesNotMatch(row, //); assert.ok( row.includes( "><b>"x" & 'y'</b>", ), ); assert.ok( row.includes( 'href="https://x.test/<b>?a="x"&b='y'"', ), ); assert.ok( row.includes( ">https://x.test/<b>?a="x"&b='y'", ), ); }); for (const [seconds, text] of [ [0, "0s"], [1, "1s"], [59, "59s"], [60, "1m"], [61, "1m1s"], [3599, "59m59s"], [3600, "1h"], [3601, "1h1s"], [3660, "1h1m"], [3661, "1h1m1s"], ]) { test(`humanDuration writes ${seconds} seconds as ${text}`, () => { assert.equal(humanDuration(seconds), text); }); } // The colour of a target's latency figure, as a class, and of its sparkline // for an answer after latency ms, as the colour coding in README.md gives // them. The rows sit either side of each boundary. for (const [latency, figure, sparkline] of [ [0, "text-green-500", "#22c55e"], [49, "text-green-500", "#22c55e"], [50, "text-lime-500", "#84cc16"], [99, "text-lime-500", "#84cc16"], [100, "text-yellow-500", "#eab308"], [199, "text-yellow-500", "#eab308"], [200, "text-orange-500", "#f97316"], [499, "text-orange-500", "#f97316"], [500, "text-red-500", "#ef4444"], ]) { test(`an answer after ${latency}ms has a ${figure} figure and a ${sparkline} sparkline`, () => { assert.equal(latencyClass(latency, "online"), figure); assert.equal(latencyHex(latency), sparkline); }); } test("a check that timed out or found its target unreachable has a grey figure and sparkline", () => { assert.equal(latencyClass(null, "error"), "text-gray-500"); assert.equal(latencyClass(null, "offline"), "text-gray-500"); assert.equal(latencyHex(null), "#6b7280"); }); // A target whose checks, in turn, answered after each of latencies ms, or, // for null, found it unreachable. function hostAfter(latencies) { const host = new HostState({ name: "Target", url: "https://target.test" }); for (const latency of latencies) { host.pushSample( Date.now(), latency === null ? { latency: null, error: "unreachable" } : { latency, error: null }, ); } return host; } for (const { history, latencies, statistics } of [ { history: "no checks", latencies: [], statistics: { min: null, max: null, average: null, median: null }, }, { history: "only unreachable checks", latencies: [null, null, null], statistics: { min: null, max: null, average: null, median: null }, }, { history: "three answers and an unreachable check", latencies: [30, null, 10, 20], statistics: { min: 10, max: 30, average: 20, median: 20 }, }, { // The median of an even number of answers is the mean of the // middle two. It and the average, 23.75, are rounded. history: "four answers", latencies: [10, 40, 20, 25], statistics: { min: 10, max: 40, average: 24, median: 23 }, }, { // Sorted as text rather than as numbers, these answers would put // 100 in the middle. The average, 39.67, is rounded. history: "answers with different numbers of digits", latencies: [100, 9, 10], statistics: { min: 9, max: 100, average: 40, median: 10 }, }, ]) { test(`a target's min, max, average and median latency over ${history}`, () => { const { min, max, avg, med } = hostAfter(latencies).historyStats(); assert.deepEqual({ min, max, average: avg, median: med }, statistics); }); } // The summary's figures come from each WAN target's last check, by the same // rules as a target's own: here four answered, one was found unreachable // and the rest have not been checked yet. The median, 22.5, and the // average, 21.25, are rounded. test("the summary's min, max, median and average latency over the WAN targets' last checks", () => { const state = new AppState([]); [30, 10, null, 25, 20].forEach((latency, i) => state.wan[i].pushSample( Date.now(), latency === null ? { latency: null, error: "unreachable" } : { latency, error: null }, ), ); assert.deepEqual(state.wanStats(), { reachable: 4, total: state.wan.length, min: 10, max: 30, med: 23, avg: 21, }); }); // An app state in which, of the WAN targets, the first timedOut timed out, // the next unreachable were found unreachable, the next answered answered // after latency ms, and the rest have not been checked yet. function stateAfter({ timedOut, unreachable, answered, latency }) { const state = new AppState([]); const results = [ ...Array(timedOut).fill({ latency: null, error: "timeout" }), ...Array(unreachable).fill({ latency: null, error: "unreachable" }), ...Array(answered).fill({ latency, error: null }), ]; results.forEach((result, i) => state.wan[i].pushSample(Date.now(), result)); return state; } // For each number the health is decided by, the rows put it one under, at // and one over its threshold. for (const { timedOut, unreachable = 0, answered, latency, health } of [ // Offline: more than 10 timed out and at most 4 answered. { timedOut: 9, answered: 4, latency: 30, health: "degraded" }, { timedOut: 10, answered: 4, latency: 30, health: "degraded" }, { timedOut: 11, answered: 4, latency: 30, health: "offline" }, { timedOut: 11, answered: 3, latency: 30, health: "offline" }, { timedOut: 11, answered: 5, latency: 30, health: "degraded" }, // Otherwise degraded: more than 4 timed out. { timedOut: 3, answered: 10, latency: 30, health: "healthy" }, { timedOut: 4, answered: 10, latency: 30, health: "healthy" }, { timedOut: 5, answered: 10, latency: 30, health: "degraded" }, // Otherwise slow: more than 3 answered after more than 1000ms. { timedOut: 0, answered: 4, latency: 999, health: "healthy" }, { timedOut: 0, answered: 4, latency: 1000, health: "healthy" }, { timedOut: 0, answered: 4, latency: 1001, health: "slow" }, { timedOut: 0, answered: 2, latency: 1001, health: "healthy" }, { timedOut: 0, answered: 3, latency: 1001, health: "healthy" }, // A target found unreachable counts as one that timed out. { timedOut: 5, unreachable: 6, answered: 4, latency: 30, health: "offline", }, { timedOut: 0, unreachable: 5, answered: 10, latency: 30, health: "degraded", }, ]) { test(`with ${timedOut} WAN targets timed out, ${unreachable} found unreachable and ${answered} answering after ${latency}ms, the health is ${health}`, () => { const state = stateAfter({ timedOut, unreachable, answered, latency }); assert.equal(state.healthStatus(), health); }); }