Each target's row shows its result as soon as its check ends (closes #91)
check / check (push) Successful in 3m43s
check / check (push) Successful in 3m43s
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
This commit was merged in pull request #92.
This commit is contained in:
+172
-12
@@ -1,19 +1,70 @@
|
||||
// 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 { test } from "node:test";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { CONFIG, measureLatency } from "../../src/main.js";
|
||||
import {
|
||||
AppState,
|
||||
CONFIG,
|
||||
greyOutUI,
|
||||
measureLatency,
|
||||
tick,
|
||||
} from "../../src/main.js";
|
||||
|
||||
// measureLatency writes timeouts to the debug log, which looks for its
|
||||
// panel in the page. There is no page here.
|
||||
globalThis.document = { getElementById: () => null };
|
||||
// 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 a target that answers after answerAfter
|
||||
// milliseconds of that clock, or never when answerAfter is Infinity. Both
|
||||
// are restored when the test ends.
|
||||
function mockTarget(t, answerAfter) {
|
||||
// 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(
|
||||
@@ -21,7 +72,9 @@ function mockTarget(t, answerAfter) {
|
||||
"fetch",
|
||||
(url, { signal }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
|
||||
if (answerAfter(url) !== Infinity) {
|
||||
setTimeout(resolve, answerAfter(url));
|
||||
}
|
||||
signal.addEventListener("abort", () => reject(signal.reason));
|
||||
}),
|
||||
);
|
||||
@@ -42,7 +95,7 @@ for (const interval of [10000, 30000]) {
|
||||
|
||||
test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
|
||||
CONFIG.updateInterval = interval;
|
||||
mockTarget(t, slowAnswer);
|
||||
mockTargets(t, () => slowAnswer);
|
||||
const check = measureLatency("https://target.test");
|
||||
t.mock.timers.tick(slowAnswer);
|
||||
assert.deepEqual(await settled(check), {
|
||||
@@ -53,7 +106,7 @@ for (const interval of [10000, 30000]) {
|
||||
|
||||
test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
|
||||
CONFIG.updateInterval = interval;
|
||||
mockTarget(t, Infinity);
|
||||
mockTargets(t, () => Infinity);
|
||||
const check = measureLatency("https://target.test");
|
||||
t.mock.timers.tick(timeout - 1);
|
||||
assert.equal(await settled(check), "still waiting");
|
||||
@@ -64,3 +117,110 @@ for (const interval of [10000, 30000]) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user