Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad056556b0 | ||
|
|
dcdee6bfab |
@@ -186,17 +186,28 @@ Local hosts are tracked separately from WAN stats.
|
|||||||
|
|
||||||
### Latency measurement
|
### Latency measurement
|
||||||
|
|
||||||
GET requests with `mode: 'no-cors'`, `cache: 'no-store'` and a cache-busting
|
GET requests to each target's URL as written, with `mode: 'no-cors'` and
|
||||||
query parameter, timed with `performance.now()`. Each check times out after 80%
|
`cache: 'no-store'`, timed with `performance.now()`. No query string is added:
|
||||||
of the refresh interval (24 seconds at 30 seconds) and is then recorded as a
|
the Hetzner speed-test servers close the connection without an answer when the
|
||||||
timeout, so a round's checks have all finished before the next round is due.
|
URL has one, and `no-store` keeps the browser's cache out of the measurement.
|
||||||
When no WAN host answers, a recovery probe checks 4 WAN hosts, picked at random
|
Each check times out after 80% of the refresh interval (24 seconds at 30
|
||||||
when it starts, every half second, giving up the checks it started half a second
|
seconds) and is then recorded as a timeout, so a round's checks have all
|
||||||
before. As soon as one answers, a new round starts at once, as it does after an
|
finished before the next round is due. When no WAN host answers, a recovery
|
||||||
interval change. A round started early gives up the last round's checks if they
|
probe checks 4 WAN hosts, picked at random when it starts, every half second,
|
||||||
are still waiting, and that round records nothing more, so rounds never overlap.
|
giving up the checks it started half a second before. As soon as one answers, a
|
||||||
The browser chooses between IPv4 and IPv6 for each target, as for any request;
|
new round starts at once, as it does after an interval change. A round started
|
||||||
the local targets are IPv4 addresses.
|
early gives up the last round's checks if they are still waiting, and that round
|
||||||
|
records nothing more, so rounds never overlap. The browser chooses between IPv4
|
||||||
|
and IPv6 for each target, as for any request; the local targets are IPv4
|
||||||
|
addresses.
|
||||||
|
|
||||||
|
Each recorded check that fails writes one line to the browser console with
|
||||||
|
`console.error`, and the same line to the debug log: the target's name and URL,
|
||||||
|
the time, whether it timed out, answered over the time limit or hit a network
|
||||||
|
error (with the error the browser gives the page, such as
|
||||||
|
`TypeError: Failed to fetch`, which does not say why), and how long the request
|
||||||
|
took. A target that answers after failed checks writes one `console.info` line
|
||||||
|
with its latency and how many checks in a row had failed.
|
||||||
|
|
||||||
### Color coding
|
### Color coding
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,17 @@ Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
|
|||||||
`.golangci.yml` stays as fetched. `script/fmt` and `script/fmt-check` put
|
`.golangci.yml` stays as fetched. `script/fmt` and `script/fmt-check` put
|
||||||
`~/.local/bin` on `PATH`, which the shared workflow no longer does.
|
`~/.local/bin` on `PATH`, which the shared workflow no longer does.
|
||||||
`script/frontend-lint` and `script/frontend-check` are gone
|
`script/frontend-lint` and `script/frontend-check` are gone
|
||||||
|
- 2026-10-07: the six Hetzner targets answer again, and failed checks are
|
||||||
|
written to the browser console
|
||||||
|
([#114](https://git.eeqj.de/sneak/netwatch/issues/114)). The Hetzner
|
||||||
|
speed-test servers close the connection without an answer when the URL has a
|
||||||
|
query string, and every check added `?_cb=` and the time; checks now fetch
|
||||||
|
each target's URL as written, with `cache: 'no-store'` as before. Each
|
||||||
|
recorded check that fails writes one `console.error` line, and the same line
|
||||||
|
to the debug log, with the target's name and URL, the time, what failed and
|
||||||
|
how long the request took; a target that answers after failed checks writes
|
||||||
|
one `console.info` line. Checks the page does not record write none, so the
|
||||||
|
debug log no longer lists failures in the first round or in the recovery probe
|
||||||
- 2026-10-04: the page's footer no longer says "IPv4 only"
|
- 2026-10-04: the page's footer no longer says "IPv4 only"
|
||||||
([#111](https://git.eeqj.de/sneak/netwatch/issues/111)): each check is a
|
([#111](https://git.eeqj.de/sneak/netwatch/issues/111)): each check is a
|
||||||
`fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local
|
`fetch`, the browser picks IPv4 or IPv6 for each WAN host, and the local
|
||||||
|
|||||||
+51
-12
@@ -268,6 +268,8 @@ export class HostState {
|
|||||||
this.lastLatency = null;
|
this.lastLatency = null;
|
||||||
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
||||||
this.pinned = pinned;
|
this.pinned = pinned;
|
||||||
|
// How many recorded checks in a row have failed, up to the last one.
|
||||||
|
this.consecutiveFailures = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushSample(timestamp, result) {
|
pushSample(timestamp, result) {
|
||||||
@@ -281,6 +283,9 @@ export class HostState {
|
|||||||
if (result.error === "timeout") this.status = "error";
|
if (result.error === "timeout") this.status = "error";
|
||||||
else if (result.error) this.status = "offline";
|
else if (result.error) this.status = "offline";
|
||||||
else this.status = "online";
|
else this.status = "online";
|
||||||
|
this.consecutiveFailures = result.error
|
||||||
|
? this.consecutiveFailures + 1
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pushPaused(timestamp) {
|
pushPaused(timestamp) {
|
||||||
@@ -534,7 +539,13 @@ class Reporter {
|
|||||||
|
|
||||||
// Checks one target. The check times out after CONFIG.requestTimeout; the
|
// Checks one target. The check times out after CONFIG.requestTimeout; the
|
||||||
// caller can give it up sooner through the optional signal, which also ends
|
// caller can give it up sooner through the optional signal, which also ends
|
||||||
// it as a timeout.
|
// it as a timeout. A failed check's reason says what went wrong and after
|
||||||
|
// how long; a check that answered has none.
|
||||||
|
//
|
||||||
|
// The URL is fetched as written, with nothing added to it: the Hetzner
|
||||||
|
// speed-test servers close the connection without an answer when the URL
|
||||||
|
// has a query string, and cache: "no-store" keeps the browser's cache out
|
||||||
|
// of the measurement.
|
||||||
export async function measureLatency(url, signal) {
|
export async function measureLatency(url, signal) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeoutId = setTimeout(
|
const timeoutId = setTimeout(
|
||||||
@@ -543,13 +554,10 @@ export async function measureLatency(url, signal) {
|
|||||||
);
|
);
|
||||||
signal?.addEventListener("abort", () => controller.abort());
|
signal?.addEventListener("abort", () => controller.abort());
|
||||||
|
|
||||||
const targetUrl = new URL(url);
|
|
||||||
targetUrl.searchParams.set("_cb", Date.now().toString());
|
|
||||||
|
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(targetUrl.toString(), {
|
await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
mode: "no-cors",
|
mode: "no-cors",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -558,18 +566,28 @@ export async function measureLatency(url, signal) {
|
|||||||
const latency = Math.round(performance.now() - start);
|
const latency = Math.round(performance.now() - start);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (latency > CONFIG.maxLatency) {
|
if (latency > CONFIG.maxLatency) {
|
||||||
log.error(`${url} timeout (${latency}ms > ${CONFIG.maxLatency}ms)`);
|
return {
|
||||||
return { latency: null, error: "timeout" };
|
latency: null,
|
||||||
|
error: "timeout",
|
||||||
|
reason: `answered after ${latency} ms, over the ${CONFIG.maxLatency} ms limit`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return { latency, error: null };
|
return { latency, error: null, reason: null };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const took = Math.round(performance.now() - start);
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
if (err.name === "AbortError") {
|
if (err.name === "AbortError") {
|
||||||
log.error(`${url} timeout (aborted)`);
|
return {
|
||||||
return { latency: null, error: "timeout" };
|
latency: null,
|
||||||
|
error: "timeout",
|
||||||
|
reason: `timed out after ${took} ms (limit ${CONFIG.requestTimeout} ms)`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
log.error(`${url} unreachable`);
|
return {
|
||||||
return { latency: null, error: "unreachable" };
|
latency: null,
|
||||||
|
error: "unreachable",
|
||||||
|
reason: `network error (${err.name}: ${err.message}) after ${took} ms`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,6 +1154,26 @@ function sortAndRebuildWAN(state) {
|
|||||||
|
|
||||||
// --- Main Loop ---------------------------------------------------------------
|
// --- Main Loop ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// Writes one line to the browser console, and the same line to the debug
|
||||||
|
// log, for a check the page is about to record: for every check that
|
||||||
|
// failed, and for a check that answered after checks that failed. Called
|
||||||
|
// before host.pushSample, while host.consecutiveFailures still counts the
|
||||||
|
// checks before this one.
|
||||||
|
function logCheck(host, result) {
|
||||||
|
const target = `${host.name} ${host.url} at ${new Date().toISOString()}`;
|
||||||
|
const failed = host.consecutiveFailures;
|
||||||
|
if (result.error) {
|
||||||
|
const line = `netwatch: check failed: ${target}: ${result.reason}`;
|
||||||
|
console.error(line);
|
||||||
|
log.error(line);
|
||||||
|
} else if (failed > 0) {
|
||||||
|
const checks = failed === 1 ? "check" : "checks";
|
||||||
|
const line = `netwatch: target recovered: ${target}: answered after ${result.latency} ms, following ${failed} failed ${checks} in a row`;
|
||||||
|
console.info(line);
|
||||||
|
log.info(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function tick(state, signal, onOffline) {
|
export async function tick(state, signal, onOffline) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
@@ -1169,6 +1207,7 @@ export async function tick(state, signal, onOffline) {
|
|||||||
if (state.paused || signal.aborted || state.tickCount === 0) {
|
if (state.paused || signal.aborted || state.tickCount === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logCheck(host, r);
|
||||||
host.pushSample(ts, r);
|
host.pushSample(ts, r);
|
||||||
updateHostRow(host, state.allHosts.indexOf(host));
|
updateHostRow(host, state.allHosts.indexOf(host));
|
||||||
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
||||||
|
|||||||
+160
-13
@@ -23,12 +23,16 @@ import {
|
|||||||
// test looks it up and kept in elements under its selector until the next
|
// 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
|
// 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
|
// 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.
|
// does nothing; it looks for the pixel ratio on window and finds none. In
|
||||||
let elements;
|
// each test, console.error and console.info print nothing and keep what
|
||||||
beforeEach(() => {
|
// they are given.
|
||||||
elements = {};
|
|
||||||
});
|
|
||||||
const doNothing = () => {};
|
const doNothing = () => {};
|
||||||
|
let elements;
|
||||||
|
beforeEach((t) => {
|
||||||
|
elements = {};
|
||||||
|
t.mock.method(console, "error", doNothing);
|
||||||
|
t.mock.method(console, "info", doNothing);
|
||||||
|
});
|
||||||
const canvasContext = {
|
const canvasContext = {
|
||||||
clearRect: doNothing,
|
clearRect: doNothing,
|
||||||
beginPath: doNothing,
|
beginPath: doNothing,
|
||||||
@@ -69,8 +73,10 @@ function statusText(state, host) {
|
|||||||
// Mocks the clock for test t, so that a check lasting seconds takes no real
|
// Mocks the clock for test t, so that a check lasting seconds takes no real
|
||||||
// time, and replaces fetch with targets that each answer after
|
// time, and replaces fetch with targets that each answer after
|
||||||
// answerAfter(url) milliseconds of that clock, or never when that is
|
// answerAfter(url) milliseconds of that clock, or never when that is
|
||||||
// Infinity. Both are restored when the test ends.
|
// Infinity. The target at unreachableUrl, if one is given, does not
|
||||||
function mockTargets(t, answerAfter) {
|
// answer: after that time its fetch fails with the error a browser gives
|
||||||
|
// the page for a network error. Both are restored when the test ends.
|
||||||
|
function mockTargets(t, answerAfter, unreachableUrl) {
|
||||||
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
||||||
t.mock.method(performance, "now", () => Date.now());
|
t.mock.method(performance, "now", () => Date.now());
|
||||||
t.mock.method(
|
t.mock.method(
|
||||||
@@ -78,8 +84,12 @@ function mockTargets(t, answerAfter) {
|
|||||||
"fetch",
|
"fetch",
|
||||||
(url, { signal }) =>
|
(url, { signal }) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
const answer =
|
||||||
|
url === unreachableUrl
|
||||||
|
? () => reject(new TypeError("Failed to fetch"))
|
||||||
|
: resolve;
|
||||||
if (answerAfter(url) !== Infinity) {
|
if (answerAfter(url) !== Infinity) {
|
||||||
setTimeout(resolve, answerAfter(url));
|
setTimeout(answer, answerAfter(url));
|
||||||
}
|
}
|
||||||
signal.addEventListener("abort", () => reject(signal.reason));
|
signal.addEventListener("abort", () => reject(signal.reason));
|
||||||
}),
|
}),
|
||||||
@@ -107,6 +117,7 @@ for (const interval of [10000, 30000]) {
|
|||||||
assert.deepEqual(await settled(check), {
|
assert.deepEqual(await settled(check), {
|
||||||
latency: slowAnswer,
|
latency: slowAnswer,
|
||||||
error: null,
|
error: null,
|
||||||
|
reason: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -120,10 +131,37 @@ for (const interval of [10000, 30000]) {
|
|||||||
assert.deepEqual(await settled(check), {
|
assert.deepEqual(await settled(check), {
|
||||||
latency: null,
|
latency: null,
|
||||||
error: "timeout",
|
error: "timeout",
|
||||||
|
reason: `timed out after ${timeout} ms (limit ${timeout} ms)`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A browser can run a timer late, on a busy page or in a background tab.
|
||||||
|
// Moving the mocked clock on 30000ms at once runs the 24000ms timeout with
|
||||||
|
// the clock already at 30000ms.
|
||||||
|
test("at a 30000ms interval, a check whose timeout runs late gives how long the request took and the time limit", async (t) => {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
mockTargets(t, () => Infinity);
|
||||||
|
const check = measureLatency("https://target.test");
|
||||||
|
t.mock.timers.tick(30000);
|
||||||
|
assert.deepEqual(await settled(check), {
|
||||||
|
latency: null,
|
||||||
|
error: "timeout",
|
||||||
|
reason: "timed out after 30000 ms (limit 24000 ms)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a check fetches the target's URL as written, with no query string added", async (t) => {
|
||||||
|
mockTargets(t, () => 10);
|
||||||
|
const check = measureLatency("https://fsn1-speed.hetzner.com");
|
||||||
|
t.mock.timers.tick(10);
|
||||||
|
assert.notEqual(await settled(check), "still waiting");
|
||||||
|
assert.equal(
|
||||||
|
fetch.mock.calls[0].arguments[0],
|
||||||
|
"https://fsn1-speed.hetzner.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("at a 30000ms interval, a target answering after 1000ms shows in its row while another target's check is still waiting", async (t) => {
|
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;
|
CONFIG.updateInterval = 30000;
|
||||||
const state = new AppState([
|
const state = new AppState([
|
||||||
@@ -132,7 +170,7 @@ test("at a 30000ms interval, a target answering after 1000ms shows in its row wh
|
|||||||
const answering = state.local[0];
|
const answering = state.local[0];
|
||||||
const waiting = state.wan[0];
|
const waiting = state.wan[0];
|
||||||
// No target but the answering one ever answers.
|
// No target but the answering one ever answers.
|
||||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity));
|
||||||
// The third tick: the first is discarded as a whole, and the second ends
|
// 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.
|
// by sorting the rows, which rebuilds a page that is not here.
|
||||||
state.tickCount = 2;
|
state.tickCount = 2;
|
||||||
@@ -159,7 +197,7 @@ test("at a 30000ms interval, a check still waiting when its round is given up do
|
|||||||
{ name: "Answering", url: "https://answering.test" },
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
]);
|
]);
|
||||||
const answering = state.local[0];
|
const answering = state.local[0];
|
||||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity));
|
||||||
state.tickCount = 2;
|
state.tickCount = 2;
|
||||||
|
|
||||||
const roundChecks = new AbortController();
|
const roundChecks = new AbortController();
|
||||||
@@ -177,7 +215,7 @@ test("at a 30000ms interval, a check still waiting when the user pauses does not
|
|||||||
{ name: "Answering", url: "https://answering.test" },
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
]);
|
]);
|
||||||
const answering = state.local[0];
|
const answering = state.local[0];
|
||||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity));
|
||||||
state.tickCount = 2;
|
state.tickCount = 2;
|
||||||
|
|
||||||
const round = tick(state, new AbortController().signal);
|
const round = tick(state, new AbortController().signal);
|
||||||
@@ -194,7 +232,7 @@ test("at a 30000ms interval, a check in the first round does not show in its row
|
|||||||
{ name: "Answering", url: "https://answering.test" },
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
]);
|
]);
|
||||||
const answering = state.local[0];
|
const answering = state.local[0];
|
||||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity));
|
||||||
|
|
||||||
const round = tick(state, new AbortController().signal);
|
const round = tick(state, new AbortController().signal);
|
||||||
t.mock.timers.tick(1000);
|
t.mock.timers.tick(1000);
|
||||||
@@ -208,7 +246,7 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
|
|||||||
{ name: "Answering", url: "https://answering.test" },
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
]);
|
]);
|
||||||
const answering = state.local[0];
|
const answering = state.local[0];
|
||||||
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
mockTargets(t, (url) => (url === answering.url ? 1000 : Infinity));
|
||||||
state.tickCount = 2;
|
state.tickCount = 2;
|
||||||
|
|
||||||
const round = tick(state, new AbortController().signal);
|
const round = tick(state, new AbortController().signal);
|
||||||
@@ -231,6 +269,115 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// In the next tests a round checks one target, Target, at a 30000ms
|
||||||
|
// interval, so a check times out after 24000ms. The mocked clock starts at
|
||||||
|
// 1970-01-01T00:00:00.000Z.
|
||||||
|
|
||||||
|
// An app state with Target and no WAN targets, so its rounds check only
|
||||||
|
// Target, and whose next round is recorded: it is the third, as the first
|
||||||
|
// is discarded and the second ends by sorting the rows, which rebuilds a
|
||||||
|
// page that is not here. Returns it and Target.
|
||||||
|
function stateWithOneTarget() {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Target", url: "https://target.test" },
|
||||||
|
]);
|
||||||
|
state.wan = [];
|
||||||
|
state.tickCount = 2;
|
||||||
|
return { state, target: state.local[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// What this test wrote to the browser console with console[method].
|
||||||
|
function consoleLines(method) {
|
||||||
|
return console[method].mock.calls.map((call) => call.arguments[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => {
|
||||||
|
const { state, target } = stateWithOneTarget();
|
||||||
|
mockTargets(t, () => 23, target.url);
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(23);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.deepEqual(consoleLines("error"), [
|
||||||
|
"netwatch: check failed: Target https://target.test at 1970-01-01T00:00:00.023Z: network error (TypeError: Failed to fetch) after 23 ms",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(consoleLines("info"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a check that times out writes one console line with the target, the time, how long the request took and the time limit", async (t) => {
|
||||||
|
const { state } = stateWithOneTarget();
|
||||||
|
mockTargets(t, () => Infinity);
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(24000);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.deepEqual(consoleLines("error"), [
|
||||||
|
"netwatch: check failed: Target https://target.test at 1970-01-01T00:00:24.000Z: timed out after 24000 ms (limit 24000 ms)",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(consoleLines("info"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// An answer that took longer than CONFIG.maxLatency is recorded as a
|
||||||
|
// timeout. The limit is the check's own timeout, so such an answer is one
|
||||||
|
// that came in just as the check timed out; here it is lowered to 500ms.
|
||||||
|
test("a check answered over the time limit writes one console line with the target, the time, how long the answer took and the limit", async (t) => {
|
||||||
|
const { state } = stateWithOneTarget();
|
||||||
|
t.mock.getter(CONFIG, "maxLatency", () => 500);
|
||||||
|
mockTargets(t, () => 1000);
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.deepEqual(consoleLines("error"), [
|
||||||
|
"netwatch: check failed: Target https://target.test at 1970-01-01T00:00:01.000Z: answered after 1000 ms, over the 500 ms limit",
|
||||||
|
]);
|
||||||
|
assert.deepEqual(consoleLines("info"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a check that answers writes nothing to the console", async (t) => {
|
||||||
|
const { state } = stateWithOneTarget();
|
||||||
|
mockTargets(t, () => 30);
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(30);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.deepEqual(consoleLines("error"), []);
|
||||||
|
assert.deepEqual(consoleLines("info"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [failed, checks] of [
|
||||||
|
[1, "1 failed check"],
|
||||||
|
[3, "3 failed checks"],
|
||||||
|
]) {
|
||||||
|
test(`a check that answers after ${checks} writes one console line saying the target recovered, and the next writes nothing`, async (t) => {
|
||||||
|
const { state, target } = stateWithOneTarget();
|
||||||
|
for (let i = 0; i < failed; i++) {
|
||||||
|
target.pushSample(Date.now(), {
|
||||||
|
latency: null,
|
||||||
|
error: "unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
mockTargets(t, () => 40);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(40);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
}
|
||||||
|
assert.deepEqual(consoleLines("info"), [
|
||||||
|
`netwatch: target recovered: Target https://target.test at 1970-01-01T00:00:00.040Z: answered after 40 ms, following ${checks} in a row`,
|
||||||
|
]);
|
||||||
|
assert.deepEqual(consoleLines("error"), []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a check that fails in the first round, which is discarded, writes nothing to the console", async (t) => {
|
||||||
|
const { state, target } = stateWithOneTarget();
|
||||||
|
state.tickCount = 0;
|
||||||
|
mockTargets(t, () => 23, target.url);
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(23);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.deepEqual(consoleLines("error"), []);
|
||||||
|
assert.deepEqual(consoleLines("info"), []);
|
||||||
|
});
|
||||||
|
|
||||||
// The page shows < > " & and ' in a row's markup as
|
// The page shows < > " & and ' in a row's markup as
|
||||||
// < > " & and '.
|
// < > " & and '.
|
||||||
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
|
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user