Compare commits
1
Commits
next
...
2c4aac4719
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c4aac4719 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
+11
-3
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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";
|
||||
|
||||
+158
-2
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user