check / check (push) Successful in 1m44s
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
227 lines
8.5 KiB
JavaScript
227 lines
8.5 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,
|
|
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</);
|
|
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);
|
|
}
|
|
});
|