check / check (push) Successful in 4m19s
New table-driven tests in test/unit/main.test.js: humanDuration, the figure and sparkline colours either side of each latency boundary, a target's min, max, average and median over empty, all-unreachable and mixed histories, and the four health states either side of their thresholds. src/main.js exports the four names they need. package.json gains a test script, which script/frontend-test runs with the dot reporter and, if a test fails, again with the spec reporter before failing. NODE_OPTIONS picks the reporter, since yarn appends its arguments after the test files. Model: opus-5-5
369 lines
14 KiB
JavaScript
369 lines
14 KiB
JavaScript
// 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,
|
|
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</);
|
|
assert.equal(latencyFigure(state, waiting), undefined);
|
|
assert.equal(state.tickCount, 2);
|
|
|
|
// The round ends, once, when the last check times out.
|
|
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
|
assert.notEqual(await settled(round), "still waiting");
|
|
assert.equal(state.tickCount, 3);
|
|
});
|
|
|
|
// In the next three tests, the answering target's check is still waiting
|
|
// when something happens after which its result must not show.
|
|
|
|
test("at a 30000ms interval, a check still waiting when its round is given up 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 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);
|
|
}
|
|
});
|
|
|
|
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 },
|
|
},
|
|
]) {
|
|
test(`a target's min, max, average and median latency over ${history}`, () => {
|
|
const host = hostAfter(latencies);
|
|
assert.deepEqual(
|
|
{
|
|
min: host.minLatency(),
|
|
max: host.maxLatency(),
|
|
average: host.averageLatency(),
|
|
median: host.medianLatency(),
|
|
},
|
|
statistics,
|
|
);
|
|
});
|
|
}
|
|
|
|
// An app state in which, of the WAN targets, the first timedOut timed out,
|
|
// the next answered answered after latency ms, and the rest have not been
|
|
// checked yet.
|
|
function stateAfter({ timedOut, answered, latency }) {
|
|
const state = new AppState([]);
|
|
for (const host of state.wan.slice(0, timedOut)) {
|
|
host.pushSample(Date.now(), { latency: null, error: "timeout" });
|
|
}
|
|
for (const host of state.wan.slice(timedOut, timedOut + answered)) {
|
|
host.pushSample(Date.now(), { latency, error: null });
|
|
}
|
|
return state;
|
|
}
|
|
|
|
// For each number the health is decided by, the rows put it one under, at
|
|
// and one over its threshold. A target found unreachable counts as one
|
|
// that timed out.
|
|
for (const { timedOut, 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" },
|
|
]) {
|
|
test(`with ${timedOut} WAN targets timed out and ${answered} answering after ${latency}ms, the health is ${health}`, () => {
|
|
const state = stateAfter({ timedOut, answered, latency });
|
|
assert.equal(state.healthStatus(), health);
|
|
});
|
|
}
|