Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b07c90933 | ||
|
|
2f0489e3a4 | ||
|
|
91856fa170 |
@@ -107,9 +107,12 @@ code lives in `src/main.js` with a class-based architecture:
|
|||||||
color-coded line segments, error regions, and DPR-aware scaling
|
color-coded line segments, error regions, and DPR-aware scaling
|
||||||
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
|
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
|
||||||
`updateSummary()` / `updateHealthBox()` handle incremental updates
|
`updateSummary()` / `updateHealthBox()` handle incremental updates
|
||||||
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
|
- **`tick()`**: Main loop — measures all hosts in parallel, pushing each host's
|
||||||
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
|
sample and redrawing its row as soon as its check ends, then redraws every
|
||||||
false outage)
|
row, the summary and the health box once the last check ends. The rows are
|
||||||
|
sorted then too, after the first round that is not discarded and every tenth
|
||||||
|
round after that. When paused, pushes blank markers (no probes, no false
|
||||||
|
outage)
|
||||||
- **`Reporter`**: Posts collected samples to the backend
|
- **`Reporter`**: Posts collected samples to the backend
|
||||||
|
|
||||||
### Reporting
|
### Reporting
|
||||||
@@ -147,8 +150,8 @@ 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
|
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
|
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
|
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,
|
last round's checks if they are still waiting, and that round records nothing
|
||||||
so rounds never overlap. IPv4 only.
|
more, so rounds never overlap. IPv4 only.
|
||||||
|
|
||||||
### Color coding
|
### Color coding
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ latest run passes.
|
|||||||
that `script/fmt-check` runs on the host, and `script/frontend-check`, run by
|
that `script/fmt-check` runs on the host, and `script/frontend-check`, run by
|
||||||
the frontend stage, is now the tests and the format check. `script/bootstrap`
|
the frontend stage, is now the tests and the format check. `script/bootstrap`
|
||||||
wants node 22.13.0 or newer, as eslint 10 does
|
wants node 22.13.0 or newer, as eslint 10 does
|
||||||
|
- 2026-10-03: the tap-target check in `make frontend-viewport-test` expects one
|
||||||
|
visible pin button per WAN host row (issue #46), where it expected at least 10
|
||||||
|
of the 26, so pin buttons missing from only some rows now fail it. The host
|
||||||
|
row count the harness gathers, which the `app-rendered` check also reads, now
|
||||||
|
counts only the WAN host rows: the local host rows have no pin button
|
||||||
|
- 2026-10-03: each target's row shows its result as soon as its check ends
|
||||||
|
(issue #91), where every row waited for the round's slowest check, up to 24
|
||||||
|
seconds at a 30-second interval. Every row is still redrawn, and sorting, the
|
||||||
|
summary, the health box and offline detection still run, once, when the
|
||||||
|
round's last check ends, so no row reads "paused" after a pause and resume
|
||||||
|
during the round. A check that ends after the user pauses or after its round
|
||||||
|
is given up shows nothing, and the first round is still discarded as a whole
|
||||||
- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
|
- 2026-10-03: root no longer acts outside `/data` when it prepares `DATA_DIR`
|
||||||
(issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
|
(issue #80): `bin/entrypoint.sh` runs `netwatch-server prepare-data-dir`,
|
||||||
which refuses a `DATA_DIR` that is not `/data` or a path below it written in
|
which refuses a `DATA_DIR` that is not `/data` or a path below it written in
|
||||||
@@ -239,9 +251,11 @@ latest run passes.
|
|||||||
- 2026-08-09: automated responsive-layout harness
|
- 2026-08-09: automated responsive-layout harness
|
||||||
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
|
||||||
against the built `dist/`, viewport widths derived from the breakpoints in
|
against the built `dist/`, viewport widths derived from the breakpoints in
|
||||||
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). Every
|
`src/styles.css` ([#13](https://git.eeqj.de/sneak/netwatch/issues/13)). The
|
||||||
check carries a presence guard so none of them can pass against a page it is
|
tap-target and host-row checks each fail when they measured nothing; the
|
||||||
not actually measuring. Found two real layout defects, filed as
|
overflow, viewport-edge and clipped-text checks have no such guard of their
|
||||||
|
own and rely on the `app-rendered` check, which fails the run when the app did
|
||||||
|
not render. Found two real layout defects, filed as
|
||||||
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
|
[#42](https://git.eeqj.de/sneak/netwatch/issues/42) and
|
||||||
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
|
[#43](https://git.eeqj.de/sneak/netwatch/issues/43)
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
|
|||||||
+27
-16
@@ -263,7 +263,7 @@ class HostState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppState {
|
export class AppState {
|
||||||
constructor(localHosts) {
|
constructor(localHosts) {
|
||||||
this.wan = WAN_HOSTS.map(
|
this.wan = WAN_HOSTS.map(
|
||||||
(h) => new HostState(h, h.name === "datavi.be"),
|
(h) => new HostState(h, h.name === "datavi.be"),
|
||||||
@@ -1105,7 +1105,7 @@ function sortAndRebuildWAN(state) {
|
|||||||
|
|
||||||
// --- Main Loop ---------------------------------------------------------------
|
// --- Main Loop ---------------------------------------------------------------
|
||||||
|
|
||||||
async function tick(state, signal, onOffline) {
|
export async function tick(state, signal, onOffline) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
|
|
||||||
if (state.paused) {
|
if (state.paused) {
|
||||||
@@ -1126,12 +1126,26 @@ async function tick(state, signal, onOffline) {
|
|||||||
|
|
||||||
log.debug(`Tick #${state.tickCount + 1} started`);
|
log.debug(`Tick #${state.tickCount + 1} started`);
|
||||||
|
|
||||||
const results = await Promise.all(
|
// Each host's row shows its result as soon as its check ends. The
|
||||||
state.allHosts.map((h) => measureLatency(h.url, signal)),
|
// result is discarded if by then the user has paused or the next round
|
||||||
|
// has given up this one's checks, and in the first tick (tickCount is
|
||||||
|
// still 0), which is discarded as a whole below. The row is looked up
|
||||||
|
// when the check ends, as a pin click may have re-sorted the rows since
|
||||||
|
// the round started.
|
||||||
|
await Promise.all(
|
||||||
|
state.allHosts.map(async (host) => {
|
||||||
|
const r = await measureLatency(host.url, signal);
|
||||||
|
if (state.paused || signal.aborted || state.tickCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
host.pushSample(ts, r);
|
||||||
|
updateHostRow(host, state.allHosts.indexOf(host));
|
||||||
|
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// User may have paused, or the next round may have given up this
|
// User may have paused, or the next round may have given up this
|
||||||
// one's checks, while awaiting results — discard them
|
// one's checks, while awaiting results — skip the rest of the round
|
||||||
if (state.paused || signal.aborted) return;
|
if (state.paused || signal.aborted) return;
|
||||||
|
|
||||||
state.tickCount++;
|
state.tickCount++;
|
||||||
@@ -1142,12 +1156,9 @@ async function tick(state, signal, onOffline) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.allHosts.forEach((host, i) => {
|
// Redraw every row: if the user paused and resumed during this round,
|
||||||
const r = results[i];
|
// rows whose check ended before the resume still read "paused"
|
||||||
host.pushSample(ts, r);
|
state.allHosts.forEach((host, i) => updateHostRow(host, i));
|
||||||
updateHostRow(host, i);
|
|
||||||
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort after the first real check, then every 10 ticks thereafter
|
// Sort after the first real check, then every 10 ticks thereafter
|
||||||
if (state.tickCount === 2 || state.tickCount % 10 === 1) {
|
if (state.tickCount === 2 || state.tickCount % 10 === 1) {
|
||||||
@@ -1213,7 +1224,7 @@ function stopRecoveryProbe(state) {
|
|||||||
|
|
||||||
// --- Pause / Resume ----------------------------------------------------------
|
// --- Pause / Resume ----------------------------------------------------------
|
||||||
|
|
||||||
function greyOutUI(state) {
|
export function greyOutUI(state) {
|
||||||
// Grey out all host rows
|
// Grey out all host rows
|
||||||
state.allHosts.forEach((host, i) => {
|
state.allHosts.forEach((host, i) => {
|
||||||
const latencyEl = document.querySelector(
|
const latencyEl = document.querySelector(
|
||||||
@@ -1385,10 +1396,10 @@ async function init() {
|
|||||||
setInterval(updateClocks, 1000);
|
setInterval(updateClocks, 1000);
|
||||||
|
|
||||||
// Rounds never overlap: a round first gives up the last round's checks
|
// 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.
|
// 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;
|
// more. At a steady interval they never are, as they time out at 80% of
|
||||||
// they can be when a round starts early, after an interval change or
|
// it; they can be when a round starts early, after an interval change
|
||||||
// when the recovery probe finds a target answering.
|
// or when the recovery probe finds a target answering.
|
||||||
let roundChecks = new AbortController();
|
let roundChecks = new AbortController();
|
||||||
function doTick() {
|
function doTick() {
|
||||||
roundChecks.abort();
|
roundChecks.abort();
|
||||||
|
|||||||
+172
-12
@@ -1,19 +1,70 @@
|
|||||||
// Unit tests for src/main.js, run by script/frontend-test with Node's
|
// 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.
|
// built-in test runner. Importing the module does not start the page.
|
||||||
|
|
||||||
import { test } from "node:test";
|
import { beforeEach, test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { CONFIG, measureLatency } from "../../src/main.js";
|
import {
|
||||||
|
AppState,
|
||||||
|
CONFIG,
|
||||||
|
greyOutUI,
|
||||||
|
measureLatency,
|
||||||
|
tick,
|
||||||
|
} from "../../src/main.js";
|
||||||
|
|
||||||
// measureLatency writes timeouts to the debug log, which looks for its
|
// There is no page here, so the tests stand in for it. The debug log looks
|
||||||
// panel in the page. There is no page here.
|
// for its panel by id and finds none. Each element of a host's row that
|
||||||
globalThis.document = { getElementById: () => null };
|
// tick or greyOutUI draws into is a plain object, made the first time a
|
||||||
|
// 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
|
||||||
|
// 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.
|
||||||
|
let elements;
|
||||||
|
beforeEach(() => {
|
||||||
|
elements = {};
|
||||||
|
});
|
||||||
|
const doNothing = () => {};
|
||||||
|
const canvasContext = {
|
||||||
|
clearRect: doNothing,
|
||||||
|
beginPath: doNothing,
|
||||||
|
moveTo: doNothing,
|
||||||
|
lineTo: doNothing,
|
||||||
|
stroke: doNothing,
|
||||||
|
fill: doNothing,
|
||||||
|
fillRect: doNothing,
|
||||||
|
fillText: doNothing,
|
||||||
|
arc: doNothing,
|
||||||
|
};
|
||||||
|
globalThis.window = {};
|
||||||
|
globalThis.document = {
|
||||||
|
getElementById: () => null,
|
||||||
|
querySelector: (selector) =>
|
||||||
|
(elements[selector] ??= {
|
||||||
|
getContext: () => canvasContext,
|
||||||
|
querySelector: () => null,
|
||||||
|
set textContent(text) {
|
||||||
|
this.innerHTML = text;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// What tick last wrote into the latency figure in host's row, or undefined
|
||||||
|
// if it has written nothing there.
|
||||||
|
function latencyFigure(state, host) {
|
||||||
|
const index = state.allHosts.indexOf(host);
|
||||||
|
return elements[`.latency-value[data-host="${index}"]`]?.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What was last written into the status text in host's row.
|
||||||
|
function statusText(state, host) {
|
||||||
|
const index = state.allHosts.indexOf(host);
|
||||||
|
return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
// 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 a target that answers after answerAfter
|
// time, and replaces fetch with targets that each answer after
|
||||||
// milliseconds of that clock, or never when answerAfter is Infinity. Both
|
// answerAfter(url) milliseconds of that clock, or never when that is
|
||||||
// are restored when the test ends.
|
// Infinity. Both are restored when the test ends.
|
||||||
function mockTarget(t, answerAfter) {
|
function mockTargets(t, answerAfter) {
|
||||||
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(
|
||||||
@@ -21,7 +72,9 @@ function mockTarget(t, answerAfter) {
|
|||||||
"fetch",
|
"fetch",
|
||||||
(url, { signal }) =>
|
(url, { signal }) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
if (answerAfter !== Infinity) setTimeout(resolve, answerAfter);
|
if (answerAfter(url) !== Infinity) {
|
||||||
|
setTimeout(resolve, answerAfter(url));
|
||||||
|
}
|
||||||
signal.addEventListener("abort", () => reject(signal.reason));
|
signal.addEventListener("abort", () => reject(signal.reason));
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -42,7 +95,7 @@ for (const interval of [10000, 30000]) {
|
|||||||
|
|
||||||
test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
|
test(`at a ${interval}ms interval, an answer after ${slowAnswer}ms is recorded with its real time`, async (t) => {
|
||||||
CONFIG.updateInterval = interval;
|
CONFIG.updateInterval = interval;
|
||||||
mockTarget(t, slowAnswer);
|
mockTargets(t, () => slowAnswer);
|
||||||
const check = measureLatency("https://target.test");
|
const check = measureLatency("https://target.test");
|
||||||
t.mock.timers.tick(slowAnswer);
|
t.mock.timers.tick(slowAnswer);
|
||||||
assert.deepEqual(await settled(check), {
|
assert.deepEqual(await settled(check), {
|
||||||
@@ -53,7 +106,7 @@ for (const interval of [10000, 30000]) {
|
|||||||
|
|
||||||
test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
|
test(`at a ${interval}ms interval, a target that never answers is recorded as a timeout after ${timeout}ms`, async (t) => {
|
||||||
CONFIG.updateInterval = interval;
|
CONFIG.updateInterval = interval;
|
||||||
mockTarget(t, Infinity);
|
mockTargets(t, () => Infinity);
|
||||||
const check = measureLatency("https://target.test");
|
const check = measureLatency("https://target.test");
|
||||||
t.mock.timers.tick(timeout - 1);
|
t.mock.timers.tick(timeout - 1);
|
||||||
assert.equal(await settled(check), "still waiting");
|
assert.equal(await settled(check), "still waiting");
|
||||||
@@ -64,3 +117,110 @@ for (const interval of [10000, 30000]) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
|
]);
|
||||||
|
const answering = state.local[0];
|
||||||
|
const waiting = state.wan[0];
|
||||||
|
// No target but the answering one ever answers.
|
||||||
|
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||||
|
// 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.
|
||||||
|
state.tickCount = 2;
|
||||||
|
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(await settled(round), "still waiting");
|
||||||
|
assert.match(latencyFigure(state, answering), />1000</);
|
||||||
|
assert.equal(latencyFigure(state, waiting), undefined);
|
||||||
|
assert.equal(state.tickCount, 2);
|
||||||
|
|
||||||
|
// The round ends, once, when the last check times out.
|
||||||
|
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.equal(state.tickCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// In the next three tests, the answering target's check is still waiting
|
||||||
|
// when something happens after which its result must not show.
|
||||||
|
|
||||||
|
test("at a 30000ms interval, a check still waiting when its round is given up does not show in its row", async (t) => {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
|
]);
|
||||||
|
const answering = state.local[0];
|
||||||
|
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||||
|
state.tickCount = 2;
|
||||||
|
|
||||||
|
const roundChecks = new AbortController();
|
||||||
|
const round = tick(state, roundChecks.signal);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
// As a round started early does to the last round's checks.
|
||||||
|
roundChecks.abort();
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
assert.equal(latencyFigure(state, answering), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
|
]);
|
||||||
|
const answering = state.local[0];
|
||||||
|
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||||
|
state.tickCount = 2;
|
||||||
|
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
state.paused = true;
|
||||||
|
t.mock.timers.tick(500);
|
||||||
|
assert.equal(await settled(round), "still waiting");
|
||||||
|
assert.equal(latencyFigure(state, answering), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
|
]);
|
||||||
|
const answering = state.local[0];
|
||||||
|
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||||
|
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(await settled(round), "still waiting");
|
||||||
|
assert.equal(latencyFigure(state, answering), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => {
|
||||||
|
CONFIG.updateInterval = 30000;
|
||||||
|
const state = new AppState([
|
||||||
|
{ name: "Answering", url: "https://answering.test" },
|
||||||
|
]);
|
||||||
|
const answering = state.local[0];
|
||||||
|
mockTargets(t, (url) => (url.startsWith(answering.url) ? 1000 : Infinity));
|
||||||
|
state.tickCount = 2;
|
||||||
|
|
||||||
|
const round = tick(state, new AbortController().signal);
|
||||||
|
t.mock.timers.tick(1000);
|
||||||
|
assert.equal(await settled(round), "still waiting");
|
||||||
|
// The user pauses, which greys out every row, and resumes, which leaves
|
||||||
|
// the rows as they are, as togglePause does. The answering target's
|
||||||
|
// check has already ended, so only the redraw of every row at the end
|
||||||
|
// of the round can take "paused" out of its row.
|
||||||
|
state.paused = true;
|
||||||
|
greyOutUI(state);
|
||||||
|
state.paused = false;
|
||||||
|
assert.equal(statusText(state, answering), "paused");
|
||||||
|
|
||||||
|
// The round ends when the last check times out.
|
||||||
|
t.mock.timers.tick(CONFIG.requestTimeout - 1000);
|
||||||
|
assert.notEqual(await settled(round), "still waiting");
|
||||||
|
for (const host of state.allHosts) {
|
||||||
|
assert.notEqual(statusText(state, host), "paused", host.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ sizes straddling the breakpoint for the rotation case.
|
|||||||
excluded: it is a design choice, not breakage.
|
excluded: it is a design choice, not breakage.
|
||||||
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
||||||
touch viewports, _and_ each selector in the control list matched at least the
|
touch viewports, _and_ each selector in the control list matched at least the
|
||||||
number of visible elements it declares. The second half is what stops the
|
number of visible elements it declares: one of each single control, and one
|
||||||
check passing vacuously: with size alone, a renamed class would take its
|
pin button per WAN host row. The second half is what stops the check passing
|
||||||
controls out of the measured set and the check would report "all 0 controls
|
vacuously: with size alone, a renamed class would take its controls out of the
|
||||||
are at least 44x44" and pass. See below.
|
measured set and the check would report "all 0 controls are at least 44x44"
|
||||||
|
and pass. See below.
|
||||||
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
||||||
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
||||||
narrow layout the info block and the sparkline must each occupy essentially
|
narrow layout the info block and the sparkline must each occupy essentially
|
||||||
|
|||||||
+14
-13
@@ -15,7 +15,8 @@ export const MIN_TAP_TARGET_PX = 44;
|
|||||||
|
|
||||||
// The controls named in the definition of done, plus the pause button.
|
// The controls named in the definition of done, plus the pause button.
|
||||||
// Each carries the smallest number of *visible* instances the page has to
|
// Each carries the smallest number of *visible* instances the page has to
|
||||||
// contain for the tap-target oracle to be measuring anything at all.
|
// contain, worked out from the facts gathered from that page, for the
|
||||||
|
// tap-target oracle to be measuring every control it should.
|
||||||
//
|
//
|
||||||
// Without those floors the check is inert: `undersized` is empty both when
|
// Without those floors the check is inert: `undersized` is empty both when
|
||||||
// every control is large enough and when the selectors have gone stale and
|
// every control is large enough and when the selectors have gone stale and
|
||||||
@@ -24,13 +25,12 @@ export const MIN_TAP_TARGET_PX = 44;
|
|||||||
// for all three singleton controls vanishing at once — so the floor is per
|
// for all three singleton controls vanishing at once — so the floor is per
|
||||||
// selector, and one stale selector out of four fails the check.
|
// selector, and one stale selector out of four fails the check.
|
||||||
export const INTERACTIVE_CONTROLS = [
|
export const INTERACTIVE_CONTROLS = [
|
||||||
{ selector: "#pause-btn", minCount: 1 },
|
{ selector: "#pause-btn", minCount: () => 1 },
|
||||||
{ selector: "#interval-select", minCount: 1 },
|
{ selector: "#interval-select", minCount: () => 1 },
|
||||||
// One per pinnable host row. `app-rendered` already requires at least
|
// One per WAN host row, so pin buttons missing from even one row fail
|
||||||
// 10 host rows, so a count below that means the pin buttons stopped
|
// the check rather than only a drop below some fixed number.
|
||||||
// being rendered per row rather than that there were fewer hosts.
|
{ selector: ".pin-btn", minCount: (facts) => facts.wanRowCount },
|
||||||
{ selector: ".pin-btn", minCount: 10 },
|
{ selector: "#debug-toggle", minCount: () => 1 },
|
||||||
{ selector: "#debug-toggle", minCount: 1 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
||||||
@@ -105,8 +105,8 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
// never rendered. Everything below is only meaningful if this holds.
|
// never rendered. Everything below is only meaningful if this holds.
|
||||||
check(
|
check(
|
||||||
"app-rendered",
|
"app-rendered",
|
||||||
facts.rowCount >= 10 && facts.numericLatencies >= 5,
|
facts.wanRowCount >= 10 && facts.numericLatencies >= 5,
|
||||||
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
|
`${facts.wanRowCount} WAN host rows, ${facts.numericLatencies} showing a numeric latency`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
|
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
|
||||||
@@ -162,7 +162,8 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
const missing = INTERACTIVE_CONTROLS.filter(
|
const missing = INTERACTIVE_CONTROLS.filter(
|
||||||
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
|
(control) =>
|
||||||
|
(seen.get(control.selector) ?? 0) < control.minCount(facts),
|
||||||
);
|
);
|
||||||
|
|
||||||
const undersized = facts.tapTargets.filter(
|
const undersized = facts.tapTargets.filter(
|
||||||
@@ -184,11 +185,11 @@ export function evaluateChecks(facts, viewport, probes) {
|
|||||||
const detail = [];
|
const detail = [];
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
detail.push(
|
detail.push(
|
||||||
"oracle is not measuring the page: " +
|
"oracle is not measuring every control: " +
|
||||||
summarise(
|
summarise(
|
||||||
missing,
|
missing,
|
||||||
(c) =>
|
(c) =>
|
||||||
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
|
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount(facts)}`,
|
||||||
4,
|
4,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,7 +173,9 @@ export function collectLayoutFacts(options) {
|
|||||||
clipped,
|
clipped,
|
||||||
tapTargets,
|
tapTargets,
|
||||||
rows,
|
rows,
|
||||||
rowCount: document.querySelectorAll(".host-row").length,
|
// WAN host rows only: each has a pin button, and the tap-target
|
||||||
|
// check expects one per row. The local host rows have none.
|
||||||
|
wanRowCount: document.querySelectorAll("#wan-hosts .host-row").length,
|
||||||
numericLatencies: Array.from(
|
numericLatencies: Array.from(
|
||||||
document.querySelectorAll(".latency-value"),
|
document.querySelectorAll(".latency-value"),
|
||||||
).filter((el) => /\d/.test(el.textContent)).length,
|
).filter((el) => /\d/.test(el.textContent)).length,
|
||||||
|
|||||||
Reference in New Issue
Block a user