2 Commits
Author SHA1 Message Date
clawbot 02fcd3ae8f Go tests run with -race and -cover under Go's own timeout (closes #88)
check / check (push) Successful in 2m13s
backend/script/test runs go test -timeout 30s -race -cover and, if
that fails, runs it again with -v and fails. The root script/test drops
its one 30-second timeout around both halves: from a cold Go build
cache, compiling the tests with -race used it all up. Each half keeps
its own limit. The race detector needs a C compiler: the Dockerfile
builder stage gains gcc and musl-dev, and script/bootstrap installs gcc,
with the C library headers on apt and apk, when gcc is missing; make
build still sets CGO_ENABLED=0. New tests: the health check's answer, a
valid report's answer, a report file's exact lines, and the flush at the
10 MiB threshold. The handlers TestImport stub is gone.

Model: opus-5-5
2026-10-03 13:59:54 +00:00
clawbot 0ab6418e3d Target check timeout is 80% of the refresh interval (closes #78)
check / check (push) Successful in 1m55s
Each target check now times out after 80% of the refresh interval, 24
seconds at 30 seconds, where it was capped at 3 seconds, so slow, far
targets are recorded with their real time. Rounds never overlap: a
round gives up the last round's checks if they are still waiting, which
happens only when a round starts early, after an interval change or
when the recovery probe finds a target answering. The probe still
checks every half second, giving up its previous checks, so they do not
pile up.

The frontend has its first unit tests, run by script/frontend-test with
Node's built-in test runner on a mocked clock. index.html now links
src/styles.css, which src/main.js imported, since Node cannot import
CSS.

Model: opus-5-5
2026-10-03 15:36:47 +02:00
16 changed files with 476 additions and 76 deletions
+7 -3
View File
@@ -20,7 +20,10 @@ RUN make lint
# golang:1.25-alpine (2026-02-27) # golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make # gcc and musl-dev are for make test: its race detector needs cgo, which
# Go turns on by itself once a C compiler is present. make build still
# sets CGO_ENABLED=0, so the binary stays static.
RUN apk add --no-cache gcc git make musl-dev
WORKDIR /src WORKDIR /src
@@ -65,8 +68,9 @@ RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make RUN apk add --no-cache git make
COPY . . COPY . .
# make frontend-check is the frontend half of make check (test + lint + # make frontend-check is the frontend half of make check (test + lint +
# fmt-check); its test step is the production yarn build, so this both # fmt-check); its test step runs the unit tests, then the production
# produces dist/ and gates the image on lint/fmt-check/test regressions. # yarn build, so this both produces dist/ and gates the image on
# lint/fmt-check/test regressions.
# This node stage has neither Go nor Docker; the lint and builder stages # This node stage has neither Go nor Docker; the lint and builder stages
# above gate the backend half. # above gate the backend half.
RUN make frontend-check RUN make frontend-check
+17 -9
View File
@@ -39,21 +39,23 @@ halves, so the root `make check` fails if either one is broken. We provide:
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless - `script/bootstrap` — install all dependencies (the pinned node via nvm unless
one new enough for the frontend's dependencies is installed, yarn via one new enough for the frontend's dependencies is installed, yarn via
corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least corepack, `yarn install --frozen-lockfile`, the pinned Go unless one at least
as new as `backend/go.mod` asks for is installed, and the Go modules), linking as new as `backend/go.mod` asks for is installed, the Go modules, and gcc with
what it installs itself into `~/.local/bin`, which has to be on `PATH`. It the C library headers unless gcc is installed, for the race detector in
installs no Go linter and not Docker: `make lint` runs the linter in Docker `make test`), linking what it installs itself into `~/.local/bin`, which has
to be on `PATH`. It installs no Go linter and not Docker: `make lint` runs the
linter in Docker
- `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, both - `script/test` — run `script/frontend-test`, then the backend's Go tests, each
within one 30-second timeout under its own 30-second timeout
- `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by - `script/lint` — run `script/frontend-lint`, then golangci-lint in Docker, by
building the lint stage of `Dockerfile` without the cache building the lint stage 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 production build as the frontend's test (no - `script/frontend-test` — run the unit tests in `test/unit/` with Node's
unit tests yet) built-in test runner, then the production build
- `script/frontend-lint` — run prettier in check mode - `script/frontend-lint` — run prettier in check mode
- `script/frontend-fmt` — format everything prettier understands (writes) - `script/frontend-fmt` — format everything prettier understands (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-fmt-check` — check prettier formatting (read-only)
@@ -136,8 +138,14 @@ Local hosts are tracked separately from WAN stats.
### Latency measurement ### Latency measurement
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
`performance.now()`. 1-second timeout; anything over 1000ms is clamped to `performance.now()`. Each check times out after 80% of the refresh interval (24
unreachable. IPv4 only. seconds at 30 seconds) and is then recorded as a timeout, so a round's checks
have all finished before the next round is due. When no WAN host answers, a
recovery probe checks 4 random WAN hosts every half second, giving up the checks
it started half a second before. As soon as one answers, a new round starts at
once, as it does after an interval change. A round started early gives up the
last round's checks if they are still waiting, and that round records nothing,
so rounds never overlap. IPv4 only.
### Color coding ### Color coding
+19
View File
@@ -23,6 +23,25 @@ latest run passes.
# Completed Steps # Completed Steps
- 2026-10-03: the Go tests run with the race detector and coverage (issue #88):
`backend/script/test` runs `go test -timeout 30s -race -cover ./...` and, if
that fails, runs it again with `-v` and fails. Go's `-timeout` bounds the
tests, not their compile; the root `script/test` no longer puts one 30-second
timeout around both halves, which a cold Go build cache could use up on
compiling alone. The race detector needs a C compiler: the builder stage of
`Dockerfile` has gcc and musl-dev, and `script/bootstrap` installs gcc, with
the C library headers on apt and apk, when gcc is missing; the binary is still
built with `CGO_ENABLED=0`. New tests cover the health check's answer, a valid
report's answer, a report file's exact contents, and the flush when the buffer
reaches 10 MiB; the handlers' `TestImport` stub is gone
- 2026-10-03: each target check times out after 80% of the refresh interval
(issue #78), 24 seconds at 30 seconds, where it was capped at 3 seconds. A
round started early, after an interval change or when the recovery probe finds
a target answering, gives up the last round's checks if they are still
waiting, so rounds never overlap; the recovery probe gives up its own checks
after half a second. The frontend has its first unit tests, run by
`script/frontend-test` with Node's built-in test runner; for them,
`index.html` now links `src/styles.css`, which `src/main.js` used to import
- 2026-09-29: the container sets up its own data directory (issue #75): - 2026-09-29: the container sets up its own data directory (issue #75):
`bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it `bin/entrypoint.sh`, still as root, creates `DATA_DIR` if missing and gives it
and `/data` to the `netwatch` user with mode 750 before starting the backend and `/data` to the `netwatch` user with mode 750 before starting the backend
+4 -1
View File
@@ -35,7 +35,10 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
stamped in. The version is `VERSION` from the environment; stamped in. The version is `VERSION` from the environment;
when that is unset or empty, it falls back to `git describe` inside a git when that is unset or empty, it falls back to `git describe` inside a git
checkout, then to `dev` checkout, then to `dev`
- `script/test` — run the Go tests under a 30-second timeout - `script/test` — run the Go tests with the race detector and coverage. Go's
`-timeout 30s` bounds the tests, not their compile. If they fail, they run
again with `-v` for the details, and the script fails. The race detector needs
a C compiler
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run - `script/lint` — check `.golangci.yml` against its pinned sha256, then run
golangci-lint. It runs inside the golangci-lint image of the lint stage of golangci-lint. It runs inside the golangci-lint image of the lint stage of
the root `Dockerfile`; from a checkout, run `make lint` at the repo root, the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
@@ -1,13 +0,0 @@
package handlers_test
import (
"testing"
_ "sneak.berlin/go/netwatch/internal/handlers"
)
func TestImport(t *testing.T) {
t.Parallel()
// Compilation check — verifies the package parses
// and all imports resolve.
}
@@ -0,0 +1,120 @@
package handlers_test
import (
"encoding/json"
"maps"
"net/http"
"net/http/httptest"
"slices"
"testing"
"time"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"go.uber.org/fx/fxtest"
)
// newStartedHandlers builds Handlers with a real health check for the
// server named in g, and starts them, which records the time the
// uptime counts from.
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Helper()
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.Params{Globals: g})
if err != nil {
t.Fatalf("logger: %v", err)
}
hc, err := healthcheck.New(lc,
healthcheck.Params{Globals: g, Logger: log})
if err != nil {
t.Fatalf("health check: %v", err)
}
h, err := handlers.New(lc,
handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
if err != nil {
t.Fatalf("handlers: %v", err)
}
lc.RequireStart()
t.Cleanup(lc.RequireStop)
return h
}
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
// content type, and a JSON object with exactly the fields of
// healthcheck.Response, carrying this server's name and version and
// an uptime counted from its start.
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
g := &globals.Globals{Appname: "netwatch-server", Version: "v1.2.3"}
h := newStartedHandlers(t, g)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
h.HandleHealthCheck().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
contentType := rec.Header().Get("Content-Type")
if contentType != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want %q",
contentType, "application/json; charset=utf-8")
}
var body map[string]any
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("body not a JSON object: %v (%q)", err, rec.Body.String())
}
fields := []string{
"appname", "now", "status", "uptimeHuman", "uptimeSeconds", "version",
}
if got := slices.Sorted(maps.Keys(body)); !slices.Equal(got, fields) {
t.Fatalf("fields = %v, want %v", got, fields)
}
for field, want := range map[string]string{
"appname": g.Appname, "status": "ok", "version": g.Version,
} {
if body[field] != want {
t.Errorf("%s = %v, want %q", field, body[field], want)
}
}
now, _ := body["now"].(string)
at, err := time.Parse(time.RFC3339Nano, now)
if err != nil || time.Since(at).Abs() > time.Minute {
t.Errorf("now = %q, want the current time in RFC 3339 (%v)", now, err)
}
// Started just now, so the uptime is well under a minute.
human, _ := body["uptimeHuman"].(string)
uptime, err := time.ParseDuration(human)
if err != nil || uptime > time.Minute {
t.Errorf("uptimeHuman = %q, want a duration under a minute (%v)",
human, err)
}
seconds, ok := body["uptimeSeconds"].(float64)
if !ok || seconds < 0 || seconds > time.Minute.Seconds() {
t.Errorf("uptimeSeconds = %v, want a number of seconds under a minute",
body["uptimeSeconds"])
}
}
+57
View File
@@ -46,6 +46,63 @@ func decodeStatus(t *testing.T, body []byte) string {
return resp.Status return resp.Status
} }
// TestHandleReportAcceptsValidReports checks the answer to a valid
// report, one with no hosts and one shaped as the frontend sends them:
// 200 and {"status":"ok"} as JSON.
func TestHandleReportAcceptsValidReports(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
}{
{
name: "no hosts",
body: `{"clientId":"c1","geo":null,"hosts":[],` +
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
},
{
name: "a host with a latency and an error sample",
body: `{"clientId":"c1","geo":null,"hosts":[{` +
`"name":"Example","url":"https://example.com/",` +
`"status":"error","history":[` +
`{"t":1790000000000,"latency":42,"error":null},` +
`{"t":1790000003000,"latency":null,"error":"timeout"}]}],` +
`"timestamp":"2026-10-03T12:00:00.000Z"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
h := newTestHandlers(stubAppender{}, io.Discard)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(tt.body),
)
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
contentType := rec.Header().Get("Content-Type")
if contentType != "application/json; charset=utf-8" {
t.Errorf("Content-Type = %q, want %q",
contentType, "application/json; charset=utf-8")
}
if got := rec.Body.String(); got != "{\"status\":\"ok\"}\n" {
t.Errorf("body = %q, want %q", got, "{\"status\":\"ok\"}\n")
}
})
}
}
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) { func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
t.Parallel() t.Parallel()
@@ -2,6 +2,10 @@ package reportbuf
import "time" import "time"
// FlushSizeThreshold exposes the buffer size at which Append starts
// writing a report file to the external tests.
const FlushSizeThreshold = flushSizeThreshold
// Flush writes the buffered reports to a file now, as the periodic // Flush writes the buffered reports to a file now, as the periodic
// flush does, so tests need not wait a minute for it. // flush does, so tests need not wait a minute for it.
func (b *Buffer) Flush() error { func (b *Buffer) Flush() error {
+95 -10
View File
@@ -334,7 +334,11 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
} }
} }
files := readReportFiles(t, dir) files, err := readReportFiles(dir)
if err != nil {
t.Fatalf("read report files: %v", err)
}
if len(files) != flushes { if len(files) != flushes {
t.Fatalf("%d report files after %d flushes", len(files), flushes) t.Fatalf("%d report files after %d flushes", len(files), flushes)
} }
@@ -347,6 +351,88 @@ func TestTwoFlushesInOneMillisecond(t *testing.T) {
} }
} }
// TestReportFileHoldsTheLinesAppended flushes three reports and reads
// their file back: it must decompress to exactly their JSON lines, in
// the order they were appended.
func TestReportFileHoldsTheLinesAppended(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf := startBuffer(t)
for _, id := range []int{1, 2, 3} {
err := buf.Append(map[string]int{"id": id})
if err != nil {
t.Fatalf("append report %d: %v", id, err)
}
}
err := buf.Flush()
if err != nil {
t.Fatalf("flush: %v", err)
}
files, err := readReportFiles(dir)
if err != nil {
t.Fatalf("read report files: %v", err)
}
want := `{"id":1}` + "\n" + `{"id":2}` + "\n" + `{"id":3}` + "\n"
if len(files) != 1 || files[0] != want {
t.Fatalf("report files = %q, want one holding %q", files, want)
}
}
// TestFlushAtSizeThreshold appends reports until the buffer holds
// FlushSizeThreshold bytes. The append that gets it there must write
// them all to one report file, with no call to Flush and the periodic
// flush a minute away, and no earlier append may write one.
func TestFlushAtSizeThreshold(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf := startBuffer(t)
// Large reports, so the threshold takes a few hundred appends.
pad := strings.Repeat("a", 64<<10)
var appended strings.Builder
for id := 0; appended.Len() < reportbuf.FlushSizeThreshold; id++ {
report := map[string]any{"id": id, "pad": pad}
err := buf.Append(report)
if err != nil {
t.Fatalf("append report %d: %v", id, err)
}
line, err := json.Marshal(report)
if err != nil {
t.Fatalf("marshal report %d: %v", id, err)
}
appended.Write(line)
appended.WriteByte('\n')
}
// Append writes the file in the background, so wait for it.
deadline := time.Now().Add(10 * time.Second)
for {
files, err := readReportFiles(dir)
if err == nil && len(files) == 1 && files[0] == appended.String() {
return
}
if time.Now().After(deadline) {
t.Fatalf("%d report files (error: %v), want one holding the "+
"%d bytes appended", len(files), err, appended.Len())
}
time.Sleep(10 * time.Millisecond)
}
}
// reportFilesBytes returns the total size of the report files in dir. // reportFilesBytes returns the total size of the report files in dir.
func reportFilesBytes(t *testing.T, dir string) int64 { func reportFilesBytes(t *testing.T, dir string) int64 {
t.Helper() t.Helper()
@@ -371,20 +457,19 @@ func reportFilesBytes(t *testing.T, dir string) int64 {
} }
// readReportFiles returns the decompressed contents of each report // readReportFiles returns the decompressed contents of each report
// file in dir. // file in dir. A file still being written does not decompress, so it
func readReportFiles(t *testing.T, dir string) []string { // gives an error.
t.Helper() func readReportFiles(dir string) ([]string, error) {
files := os.DirFS(dir) files := os.DirFS(dir)
names, err := fs.Glob(files, "reports-*.jsonl.zst") names, err := fs.Glob(files, "reports-*.jsonl.zst")
if err != nil { if err != nil {
t.Fatalf("list report files: %v", err) return nil, fmt.Errorf("list report files: %w", err)
} }
dec, err := zstd.NewReader(nil) dec, err := zstd.NewReader(nil)
if err != nil { if err != nil {
t.Fatalf("create zstd decoder: %v", err) return nil, fmt.Errorf("create zstd decoder: %w", err)
} }
defer dec.Close() defer dec.Close()
@@ -393,18 +478,18 @@ func readReportFiles(t *testing.T, dir string) []string {
for _, name := range names { for _, name := range names {
compressed, readErr := fs.ReadFile(files, name) compressed, readErr := fs.ReadFile(files, name)
if readErr != nil { if readErr != nil {
t.Fatalf("read %s: %v", name, readErr) return nil, fmt.Errorf("read %s: %w", name, readErr)
} }
data, decErr := dec.DecodeAll(compressed, nil) data, decErr := dec.DecodeAll(compressed, nil)
if decErr != nil { if decErr != nil {
t.Fatalf("decompress %s: %v", name, decErr) return nil, fmt.Errorf("decompress %s: %w", name, decErr)
} }
contents = append(contents, string(data)) contents = append(contents, string(data))
} }
return contents return contents, nil
} }
func writeBytes(t *testing.T, path string, n int) { func writeBytes(t *testing.T, path string, n int) {
+10 -2
View File
@@ -1,12 +1,20 @@
#!/bin/sh #!/bin/sh
# script/test: run the backend test suite. # script/test: run the backend test suite with the race detector and
# coverage. Go's own -timeout bounds the tests and not their compile,
# so a cold build cache cannot fail it. The race detector needs cgo,
# and so a C compiler. If the tests fail, they run again with -v for
# the details, and the script fails even if that run passes.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
timeout 30 go test ./... go test -timeout 30s -race -cover ./... || {
echo "--- Rerunning with -v for details ---"
go test -timeout 30s -race -v ./...
exit 1
}
} }
main "$@" main "$@"
+3
View File
@@ -9,6 +9,9 @@
type="image/svg+xml" type="image/svg+xml"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>"
/> />
<!-- Linked here, not imported by src/main.js, so the unit tests can
import that module in Node, which cannot import CSS. -->
<link rel="stylesheet" href="/src/styles.css" />
</head> </head>
<body class="bg-gray-900 text-white min-h-screen"> <body class="bg-gray-900 text-white min-h-screen">
<div id="app"></div> <div id="app"></div>
+10 -3
View File
@@ -64,7 +64,8 @@ detect_pkgmgr() {
fi fi
} }
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg> # pkg_install <nix-attr> <apt-pkgs> <brew-formula> <apk-pkgs>: the apt
# and apk arguments may each list several packages, separated by spaces.
pkg_install() { pkg_install() {
detect_pkgmgr detect_pkgmgr
case "$PKGMGR" in case "$PKGMGR" in
@@ -74,10 +75,10 @@ pkg_install() {
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update $SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1 APT_UPDATED=1
fi fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y $2
;; ;;
brew) brew install "$3" ;; brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;; apk) apk add --no-cache $4 ;;
esac esac
} }
@@ -253,6 +254,12 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi if missing git; then pkg_install git git git git; fi
# The race detector in make test needs cgo, which Go turns on only
# when it finds its C compiler, gcc on Linux. apt and apk ship the C
# library headers apart from gcc.
if missing gcc; then
pkg_install gcc "gcc libc6-dev" gcc "gcc musl-dev"
fi
ensure_node ensure_node
ensure_yarn ensure_yarn
+4 -3
View File
@@ -1,13 +1,14 @@
#!/bin/sh #!/bin/sh
# script/frontend-test: run the frontend test suite. The frontend has no # script/frontend-test: run the frontend test suite: the unit tests in
# unit tests; the production build serves as the test (fails on broken # test/unit/ with Node's built-in test runner, then the production
# code). # build, which fails on broken code.
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
timeout 30 yarn build timeout 30 yarn build
} }
+6 -3
View File
@@ -1,14 +1,17 @@
#!/bin/sh #!/bin/sh
# script/test: run the test suite for the whole repo: the frontend at # script/test: run the test suite for the whole repo: the frontend at
# the repo root, then the Go backend in backend/. Both halves together # the repo root, then the Go backend in backend/. Each half has its own
# get 30 seconds; each also keeps its own limit for the Dockerfiles. # 30-second limit, and there is none around both: from a cold Go build
# cache, compiling the backend's tests with the race detector can take
# 30 seconds on its own, and Go's -timeout leaves the compile out.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
timeout 30 sh -c 'script/frontend-test && backend/script/test' script/frontend-test
backend/script/test
} }
main "$@" main "$@"
+54 -29
View File
@@ -1,14 +1,14 @@
import "./styles.css";
// --- Configuration ----------------------------------------------------------- // --- Configuration -----------------------------------------------------------
// Timing, axis labels, and display constants. Latency above maxLatency is // Timing, axis labels, and display constants. A target check times out
// clamped to "unreachable". The sparkline Y-axis is capped at // after requestTimeout, 80% of updateInterval, so a round's checks have
// all finished before the next round is due; latency above maxLatency is
// recorded as a timeout. The sparkline Y-axis is capped at
// graphMaxLatency — values above it pin to the top of the chart but still // graphMaxLatency — values above it pin to the top of the chart but still
// display their real value in the latency figure. The history buffer holds // display their real value in the latency figure. The history buffer holds
// maxHistoryPoints samples (historyDuration / updateInterval). // maxHistoryPoints samples (historyDuration / updateInterval).
// reportInterval is how often collected samples are POSTed to the backend. // reportInterval is how often collected samples are POSTed to the backend.
const CONFIG = { export const CONFIG = {
updateInterval: 3000, updateInterval: 3000,
maxHistoryPoints: 100, maxHistoryPoints: 100,
reportInterval: 60000, reportInterval: 60000,
@@ -16,7 +16,7 @@ const CONFIG = {
return (this.maxHistoryPoints * this.updateInterval) / 1000; return (this.maxHistoryPoints * this.updateInterval) / 1000;
}, },
get requestTimeout() { get requestTimeout() {
return Math.min(this.updateInterval - 100, 3000); return this.updateInterval * 0.8;
}, },
get maxLatency() { get maxLatency() {
return this.requestTimeout; return this.requestTimeout;
@@ -503,12 +503,16 @@ class Reporter {
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { // Checks one target. The check times out after CONFIG.requestTimeout; the
// caller can give it up sooner through the optional signal, which also ends
// it as a timeout.
export async function measureLatency(url, signal) {
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout( const timeoutId = setTimeout(
() => controller.abort(), () => controller.abort(),
CONFIG.requestTimeout, CONFIG.requestTimeout,
); );
signal?.addEventListener("abort", () => controller.abort());
const targetUrl = new URL(url); const targetUrl = new URL(url);
targetUrl.searchParams.set("_cb", Date.now().toString()); targetUrl.searchParams.set("_cb", Date.now().toString());
@@ -1101,7 +1105,7 @@ function sortAndRebuildWAN(state) {
// --- Main Loop --------------------------------------------------------------- // --- Main Loop ---------------------------------------------------------------
async function tick(state, onOffline) { async function tick(state, signal, onOffline) {
const ts = Date.now(); const ts = Date.now();
if (state.paused) { if (state.paused) {
@@ -1123,11 +1127,12 @@ async function tick(state, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`); log.debug(`Tick #${state.tickCount + 1} started`);
const results = await Promise.all( const results = await Promise.all(
state.allHosts.map((h) => measureLatency(h.url)), state.allHosts.map((h) => measureLatency(h.url, signal)),
); );
// User may have paused while awaiting results — discard them // User may have paused, or the next round may have given up this
if (state.paused) return; // one's checks, while awaiting results — discard them
if (state.paused || signal.aborted) return;
state.tickCount++; state.tickCount++;
@@ -1168,9 +1173,10 @@ async function tick(state, onOffline) {
// --- Recovery Probe ---------------------------------------------------------- // --- Recovery Probe ----------------------------------------------------------
// When offline, rapidly poll 4 random WAN hosts every 500ms. As soon as any // When offline, check 4 random WAN hosts every 500ms, giving up the checks
// responds, stop probing and fire a normal tick to refresh all hosts. // started 500ms before, so at most 4 are ever waiting. As soon as one
function startRecoveryProbe(state, triggerTick) { // answers, stop probing and start a new round at once.
function startRecoveryProbe(state, startRounds) {
if (state._recoveryProbeId) return; // already running if (state._recoveryProbeId) return; // already running
const candidates = [...state.wan]; const candidates = [...state.wan];
for (let i = candidates.length - 1; i > 0; i--) { for (let i = candidates.length - 1; i > 0; i--) {
@@ -1181,15 +1187,18 @@ function startRecoveryProbe(state, triggerTick) {
log.notice( log.notice(
`Recovery probe started (${canaries.map((h) => h.name).join(", ")})`, `Recovery probe started (${canaries.map((h) => h.name).join(", ")})`,
); );
state._recoveryProbeId = setInterval(async () => { state._recoveryProbeId = setInterval(() => {
if (state.paused) return; if (state.paused) return;
const results = await Promise.all( state._recoveryProbeChecks?.abort();
canaries.map((h) => measureLatency(h.url)), const checks = new AbortController();
); state._recoveryProbeChecks = checks;
if (results.some((r) => r.error === null)) { for (const host of canaries) {
log.notice("Recovery probe: connectivity detected"); measureLatency(host.url, checks.signal).then((r) => {
stopRecoveryProbe(state); if (r.error !== null || checks.signal.aborted) return;
triggerTick(); log.notice("Recovery probe: connectivity detected");
stopRecoveryProbe(state);
startRounds();
});
} }
}, 500); }, 500);
} }
@@ -1198,6 +1207,7 @@ function stopRecoveryProbe(state) {
if (state._recoveryProbeId) { if (state._recoveryProbeId) {
clearInterval(state._recoveryProbeId); clearInterval(state._recoveryProbeId);
state._recoveryProbeId = null; state._recoveryProbeId = null;
state._recoveryProbeChecks?.abort();
} }
} }
@@ -1374,18 +1384,34 @@ async function init() {
updateClocks(); updateClocks();
setInterval(updateClocks, 1000); setInterval(updateClocks, 1000);
// Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing.
// At a steady interval they never are, as they time out at 80% of it;
// they can be when a round starts early, after an interval change or
// when the recovery probe finds a target answering.
let roundChecks = new AbortController();
function doTick() { function doTick() {
tick(state, () => startRecoveryProbe(state, doTick)); roundChecks.abort();
roundChecks = new AbortController();
tick(state, roundChecks.signal, () =>
startRecoveryProbe(state, startRounds),
);
} }
doTick(); // Starts a round now and then one every CONFIG.updateInterval.
let tickIntervalId = setInterval(doTick, CONFIG.updateInterval); let tickIntervalId;
function startRounds() {
clearInterval(tickIntervalId);
doTick();
tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
}
startRounds();
document document
.getElementById("interval-select") .getElementById("interval-select")
.addEventListener("change", (e) => { .addEventListener("change", (e) => {
const newInterval = parseInt(e.target.value, 10); const newInterval = parseInt(e.target.value, 10);
clearInterval(tickIntervalId);
CONFIG.updateInterval = newInterval; CONFIG.updateInterval = newInterval;
log.notice( log.notice(
`Interval changed to ${humanDuration(newInterval / 1000)}, history reset`, `Interval changed to ${humanDuration(newInterval / 1000)}, history reset`,
@@ -1434,8 +1460,7 @@ async function init() {
// Start immediately with new interval // Start immediately with new interval
stopRecoveryProbe(state); stopRecoveryProbe(state);
doTick(); startRounds();
tickIntervalId = setInterval(doTick, CONFIG.updateInterval);
}); });
window.addEventListener("resize", () => handleResize(state)); window.addEventListener("resize", () => handleResize(state));
@@ -1444,7 +1469,7 @@ async function init() {
// Bootstrap only when loaded as the page: a real DOM containing the #app // Bootstrap only when loaded as the page: a real DOM containing the #app
// mount point this module renders into. Importing the module in a unit test // mount point this module renders into. Importing the module in a unit test
// (which has no #app) runs nothing, so buildReport can be tested in isolation. // (which has no #app) runs nothing, so its exports can be tested in isolation.
if (typeof document !== "undefined" && document.getElementById("app")) { if (typeof document !== "undefined" && document.getElementById("app")) {
if (document.readyState === "loading") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init); document.addEventListener("DOMContentLoaded", init);
+66
View File
@@ -0,0 +1,66 @@
// 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 assert from "node:assert/strict";
import { CONFIG, measureLatency } 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 };
// 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) {
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 !== Infinity) setTimeout(resolve, answerAfter);
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;
mockTarget(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;
mockTarget(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",
});
});
}