Frontend unit tests cover durations, colours, statistics and health (closes #21)
check / check (push) Successful in 3m30s
check / check (push) Successful in 3m30s
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 an empty history, an all-unreachable one, a mixed one and one of only answers, and the four health states either side of their thresholds, with targets found unreachable counted as timed out. 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
This commit is contained in:
@@ -47,15 +47,17 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
|||||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run `script/frontend-test`, then the backend's Go tests, each
|
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the
|
||||||
under its own 30-second timeout
|
backend's Go tests with the race detector and coverage
|
||||||
- `script/lint` — run eslint, then golangci-lint, both in Docker, by building
|
- `script/lint` — run eslint, then golangci-lint, both in Docker, by building
|
||||||
the `frontend-lint` and `lint` stages of `Dockerfile` without the cache
|
the `frontend-lint` and `lint` stages of `Dockerfile` without the cache
|
||||||
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
||||||
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
|
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
|
||||||
built-in test runner, then the production build
|
built-in test runner, through the `test` script in `package.json`, and if any
|
||||||
|
fails, run them again listing every test, and fail; then the production build.
|
||||||
|
Each run has a 30-second timeout
|
||||||
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
|
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
|
||||||
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
|
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
|
||||||
builds
|
builds
|
||||||
@@ -267,7 +269,6 @@ properties.
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- Add unit tests
|
|
||||||
- Add configurable host list (environment variable or config file)
|
- Add configurable host list (environment variable or config file)
|
||||||
- Add latency history export (CSV/JSON)
|
- Add latency history export (CSV/JSON)
|
||||||
- Add notification/alert when status changes to DEGRADED
|
- Add notification/alert when status changes to DEGRADED
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ latest run passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-03: the frontend's unit tests cover what the page computes (issue
|
||||||
|
#21): `humanDuration`, the latency colours of a figure and of a sparkline
|
||||||
|
either side of each boundary, a target's min, max, average and median latency
|
||||||
|
over an empty history, an all-unreachable one and a mixed one, and each of the
|
||||||
|
four health states either side of its thresholds. `package.json` has a `test`
|
||||||
|
script, so `yarn run test` and `npm run test` run them, and
|
||||||
|
`script/frontend-test` runs it: quietly, and if a test fails, again with every
|
||||||
|
test listed, and then fails. `src/main.js` now exports `humanDuration`,
|
||||||
|
`HostState`, `latencyClass` and `latencyHex` for the tests; nothing it does
|
||||||
|
changed
|
||||||
- 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
|
- 2026-10-03: the frontend has a real linter (issue #47, and item 2 of issue
|
||||||
#28): `eslint` with its recommended rules, set in `eslint.config.js`, runs in
|
#28): `eslint` with its recommended rules, set in `eslint.config.js`, runs in
|
||||||
a new `frontend-lint` stage of `Dockerfile`, which the frontend stage waits
|
a new `frontend-lint` stage of `Dockerfile`, which the frontend stage waits
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test test/unit/*.test.js"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+11
-3
@@ -1,14 +1,22 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/frontend-test: run the frontend test suite: the unit tests in
|
# script/frontend-test: run the frontend test suite: the unit tests in
|
||||||
# test/unit/ with Node's built-in test runner, then the production
|
# test/unit/, through the test script in package.json, then the
|
||||||
# build, which fails on broken code.
|
# production build, which fails on broken code. The tests print a dot
|
||||||
|
# each; if any fails, they run again with every test listed, and the
|
||||||
|
# script fails even if that run passes. NODE_OPTIONS chooses the
|
||||||
|
# reporter because yarn adds its arguments after the test files, where
|
||||||
|
# node would take a reporter option for one more file.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
timeout 30 node --test test/unit/*.test.js
|
NODE_OPTIONS=--test-reporter=dot timeout 30 yarn --silent run test || {
|
||||||
|
echo "--- Rerunning with every test listed for details ---"
|
||||||
|
NODE_OPTIONS=--test-reporter=spec timeout 30 yarn --silent run test
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
timeout 30 yarn build
|
timeout 30 yarn build
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -151,7 +151,7 @@ function formatUTCTimestamp(date) {
|
|||||||
|
|
||||||
// --- Duration Formatting -----------------------------------------------------
|
// --- Duration Formatting -----------------------------------------------------
|
||||||
|
|
||||||
function humanDuration(seconds) {
|
export function humanDuration(seconds) {
|
||||||
const h = Math.floor(seconds / 3600);
|
const h = Math.floor(seconds / 3600);
|
||||||
const m = Math.floor((seconds % 3600) / 60);
|
const m = Math.floor((seconds % 3600) / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
@@ -197,7 +197,7 @@ async function detectGateway() {
|
|||||||
|
|
||||||
// --- App State ---------------------------------------------------------------
|
// --- App State ---------------------------------------------------------------
|
||||||
|
|
||||||
class HostState {
|
export class HostState {
|
||||||
constructor(host, pinned = false) {
|
constructor(host, pinned = false) {
|
||||||
this.name = host.name;
|
this.name = host.name;
|
||||||
this.url = host.url;
|
this.url = host.url;
|
||||||
@@ -546,7 +546,7 @@ export async function measureLatency(url, signal) {
|
|||||||
|
|
||||||
// --- Color Helpers -----------------------------------------------------------
|
// --- Color Helpers -----------------------------------------------------------
|
||||||
|
|
||||||
function latencyHex(latency) {
|
export function latencyHex(latency) {
|
||||||
if (latency === null) return "#6b7280";
|
if (latency === null) return "#6b7280";
|
||||||
if (latency < 50) return "#22c55e";
|
if (latency < 50) return "#22c55e";
|
||||||
if (latency < 100) return "#84cc16";
|
if (latency < 100) return "#84cc16";
|
||||||
@@ -555,7 +555,7 @@ function latencyHex(latency) {
|
|||||||
return "#ef4444";
|
return "#ef4444";
|
||||||
}
|
}
|
||||||
|
|
||||||
function latencyClass(latency, status) {
|
export function latencyClass(latency, status) {
|
||||||
if (status === "offline" || status === "error" || latency === null)
|
if (status === "offline" || status === "error" || latency === null)
|
||||||
return "text-gray-500";
|
return "text-gray-500";
|
||||||
if (latency < 50) return "text-green-500";
|
if (latency < 50) return "text-green-500";
|
||||||
|
|||||||
+158
-2
@@ -1,5 +1,6 @@
|
|||||||
// Unit tests for src/main.js, run by script/frontend-test with Node's
|
// Unit tests for src/main.js, run with Node's built-in test runner by the
|
||||||
// built-in test runner. Importing the module does not start the page.
|
// test script in package.json. Importing the module does not start the
|
||||||
|
// page.
|
||||||
|
|
||||||
import { beforeEach, test } from "node:test";
|
import { beforeEach, test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -7,6 +8,10 @@ import {
|
|||||||
AppState,
|
AppState,
|
||||||
CONFIG,
|
CONFIG,
|
||||||
greyOutUI,
|
greyOutUI,
|
||||||
|
HostState,
|
||||||
|
humanDuration,
|
||||||
|
latencyClass,
|
||||||
|
latencyHex,
|
||||||
measureLatency,
|
measureLatency,
|
||||||
tick,
|
tick,
|
||||||
} from "../../src/main.js";
|
} from "../../src/main.js";
|
||||||
@@ -224,3 +229,154 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
|
|||||||
assert.notEqual(statusText(state, host), "paused", host.name);
|
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 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user