check / check (push) Successful in 1m57s
tick drew no row until the round's slowest check ended, up to 24 seconds at a 30-second interval since checks time out at 80% of it. Each check now pushes its sample and redraws its row as it ends. When the last check ends, every row is redrawn, so none still reads "paused" after a pause and resume, and sorting, the summary, the health box and offline detection run once. A check that ends while paused or after its round is given up draws nothing, and the first round is still discarded as a whole. The row is looked up when the check ends, as a pin click can re-sort the rows mid-round. Unit tests run tick on the mocked clock against a stand-in page. Model: opus-5-5
179 lines
6.8 KiB
JavaScript
179 lines
6.8 KiB
JavaScript
// 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, 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 a test looks it
|
|
// up and kept in elements under its selector until the next test starts.
|
|
// 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 }),
|
|
};
|
|
|
|
// 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</);
|
|
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);
|
|
});
|