From 2c4aac471967553fcf18c0fba1cf78274953ac79 Mon Sep 17 00:00:00 2001 From: sneak Date: Sat, 3 Oct 2026 23:56:58 +0000 Subject: [PATCH] Frontend unit tests cover durations, colours, statistics and health (closes #21) 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 --- README.md | 9 +-- TODO.md | 10 +++ package.json | 3 +- script/frontend-test | 14 +++- src/main.js | 8 +-- test/unit/main.test.js | 160 ++++++++++++++++++++++++++++++++++++++++- 6 files changed, 190 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a9c33bb..0a9c256 100644 --- a/README.md +++ b/README.md @@ -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 git pre-commit hook - `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 - under its own 30-second timeout +- `script/test` — run `script/frontend-test`, then `backend/script/test`, the + backend's Go tests with the race detector and coverage - `script/lint` — run eslint, then golangci-lint, both in Docker, by building the `frontend-lint` and `lint` stages of `Dockerfile` without the cache - `script/fmt` — format all files (writes): prettier, then gofmt over `backend/` - `script/fmt-check` — check formatting (read-only): prettier, then gofmt - `script/check` — run test, lint, and fmt-check - `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 runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint` builds @@ -267,7 +269,6 @@ properties. ## TODO -- Add unit tests - Add configurable host list (environment variable or config file) - Add latency history export (CSV/JSON) - Add notification/alert when status changes to DEGRADED diff --git a/TODO.md b/TODO.md index 6445247..88a4f39 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,16 @@ latest run passes. # 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 #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 diff --git a/package.json b/package.json index a268a99..6acde58 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "node --test test/unit/*.test.js" }, "license": "MIT", "devDependencies": { diff --git a/script/frontend-test b/script/frontend-test index c923f70..bf34e69 100755 --- a/script/frontend-test +++ b/script/frontend-test @@ -1,14 +1,22 @@ #!/bin/sh # script/frontend-test: run the frontend test suite: the unit tests in -# test/unit/ with Node's built-in test runner, then the production -# build, which fails on broken code. +# test/unit/, through the test script in package.json, then the +# 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 ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" main() { 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 } diff --git a/src/main.js b/src/main.js index df9fae6..2793022 100644 --- a/src/main.js +++ b/src/main.js @@ -151,7 +151,7 @@ function formatUTCTimestamp(date) { // --- Duration Formatting ----------------------------------------------------- -function humanDuration(seconds) { +export function humanDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; @@ -197,7 +197,7 @@ async function detectGateway() { // --- App State --------------------------------------------------------------- -class HostState { +export class HostState { constructor(host, pinned = false) { this.name = host.name; this.url = host.url; @@ -546,7 +546,7 @@ export async function measureLatency(url, signal) { // --- Color Helpers ----------------------------------------------------------- -function latencyHex(latency) { +export function latencyHex(latency) { if (latency === null) return "#6b7280"; if (latency < 50) return "#22c55e"; if (latency < 100) return "#84cc16"; @@ -555,7 +555,7 @@ function latencyHex(latency) { return "#ef4444"; } -function latencyClass(latency, status) { +export function latencyClass(latency, status) { if (status === "offline" || status === "error" || latency === null) return "text-gray-500"; if (latency < 50) return "text-green-500"; diff --git a/test/unit/main.test.js b/test/unit/main.test.js index 38b4212..27c9859 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -1,5 +1,6 @@ -// 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. +// 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"; @@ -7,6 +8,10 @@ import { AppState, CONFIG, greyOutUI, + HostState, + humanDuration, + latencyClass, + latencyHex, measureLatency, tick, } 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); } }); + +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); + }); +}