Files
netwatch/test/viewport/checks.js
clawbot 14eb376d79
check / check (push) Failing after 1s
test: automated responsive-layout harness (closes #13)
`make frontend-viewport-test` builds `dist/`, serves it from the same
digest-pinned nginx image and nginx.conf the shipping container uses, and
drives a digest-pinned headless Chrome over CDP. Viewport widths are derived
from the app's own @media breakpoints rather than a list of phone models: each
breakpoint is tested one pixel below, on, and above, plus four anchor
viewports. Assertions are on computed layout — horizontal overflow, off-screen
elements, clipped text, 44x44 tap targets, host-row reflow — not screenshots,
and each check declares the minimum elements it must find so a stale selector
fails instead of passing blind against a page it is not measuring. Kept out of
`make check`: it needs Docker and takes minutes. Proven able to fail before
being trusted.

Model: opus-4-8
2026-09-21 18:29:20 +02:00

246 lines
9.9 KiB
JavaScript

// Pass/fail decisions for the responsive-layout harness.
//
// Kept in node rather than in the page so that a failure can be reported
// with the measurements that produced it. Every check runs at every
// viewport; none of them short-circuits, so one failure does not hide the
// rest.
// Minimum tap target, in CSS pixels. 44x44 is the figure in Apple's Human
// Interface Guidelines and in WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)".
// WCAG 2.2 SC 2.5.8 (level AA) sets a lower 24x24 floor, but that floor
// comes with a spacing exception these controls do not qualify for: the
// pin buttons sit directly against the host name they belong to. Held at
// 44 deliberately.
export const MIN_TAP_TARGET_PX = 44;
// The controls named in the definition of done, plus the pause button.
// Each carries the smallest number of *visible* instances the page has to
// contain for the tap-target oracle to be measuring anything at all.
//
// 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
// matched nothing, and the pass condition cannot tell those apart. A single
// combined floor would not be enough either — 26 pin buttons would cover
// for all three singleton controls vanishing at once — so the floor is per
// selector, and one stale selector out of four fails the check.
export const INTERACTIVE_CONTROLS = [
{ selector: "#pause-btn", minCount: 1 },
{ selector: "#interval-select", minCount: 1 },
// One per pinnable host row. `app-rendered` already requires at least
// 10 host rows, so a count below that means the pin buttons stopped
// being rendered per row rather than that there were fewer hosts.
{ selector: ".pin-btn", minCount: 10 },
{ selector: "#debug-toggle", minCount: 1 },
];
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
(control) => control.selector,
);
// A host row is only "reflowed" if it stacked *and* went full width.
// A row that merely shrank its 420px info column would keep
// flex-direction: row, and a row that stacked but left the info column at
// its fixed width would fail the width test.
const FULL_WIDTH_FRACTION = 0.9;
function summarise(items, format, limit = 3) {
const shown = items.slice(0, limit).map(format).join("; ");
const rest = items.length > limit ? ` (+${items.length - limit} more)` : "";
return shown + rest;
}
// Collapse an overflow report to the elements actually responsible.
// Identical elements (24 host rows all doing the same thing) are counted
// rather than listed, and the deepest ones come first, since every
// ancestor of an overflowing element also reports as overflowing.
function deepestOffenders(entries) {
const byElement = new Map();
for (const entry of entries) {
const reach = entry.reach ?? entry.right;
const existing = byElement.get(entry.el);
if (existing) {
existing.count += 1;
existing.reach = Math.max(existing.reach, reach);
} else {
byElement.set(entry.el, { ...entry, reach, count: 1 });
}
}
return [...byElement.values()].sort(
(a, b) => b.depth - a.depth || b.reach - a.reach,
);
}
function checkRowLayout(row, expectStacked) {
if (expectStacked) {
if (row.flexDirection !== "column") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`;
}
if (row.sparkline.top < row.info.bottom - 1) {
return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`;
}
const minWidth = row.containerWidth * FULL_WIDTH_FRACTION;
if (row.info.width < minWidth) {
return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
if (row.sparkline.width < minWidth) {
return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
return null;
}
if (row.flexDirection !== "row") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`;
}
if (row.sparkline.left < row.info.right - 1) {
return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`;
}
return null;
}
export function evaluateChecks(facts, viewport, probes) {
const checks = [];
const check = (name, ok, detail) => checks.push({ name, ok, detail });
// Guard against the whole harness passing vacuously because the page
// never rendered. Everything below is only meaningful if this holds.
check(
"app-rendered",
facts.rowCount >= 10 && facts.numericLatencies >= 5,
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
);
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
const culprits = deepestOffenders([
...facts.overflowing,
...facts.contentOverflowing,
]);
check(
"no-horizontal-overflow",
facts.documentScrollWidth <= viewportWidth,
`documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` +
(culprits.length === 0
? ""
: "; widest content: " +
summarise(
culprits,
(c) =>
`${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`,
)),
);
check(
"nothing-past-viewport-edge",
facts.overflowing.length === 0,
facts.overflowing.length === 0
? "no element extends past the viewport"
: `${facts.overflowing.length} element(s) past the edge: ` +
summarise(
facts.overflowing,
(o) => `${o.el} spans ${o.left}..${o.right}`,
),
);
check(
"no-clipped-text",
facts.clipped.length === 0,
facts.clipped.length === 0
? "no element hides text behind overflow (deliberate ellipsis excluded)"
: `${facts.clipped.length} element(s) clipping text: ` +
summarise(
facts.clipped,
(c) =>
`${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`,
),
);
if (viewport.touch) {
// Presence first: a selector that matches nothing contributes no
// undersized targets, so without this the check would report
// "all 0 controls are at least 44x44" and pass.
const seen = new Map();
for (const target of facts.tapTargets) {
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
}
const missing = INTERACTIVE_CONTROLS.filter(
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
);
const undersized = facts.tapTargets.filter(
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
);
const bySelector = new Map();
for (const target of undersized) {
const existing = bySelector.get(target.selector);
if (!existing || target.width * target.height < existing.area) {
bySelector.set(target.selector, {
...target,
area: target.width * target.height,
count: (existing?.count ?? 0) + 1,
});
} else {
existing.count += 1;
}
}
const detail = [];
if (missing.length > 0) {
detail.push(
"oracle is not measuring the page: " +
summarise(
missing,
(c) =>
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
4,
),
);
}
detail.push(
undersized.length === 0
? `${facts.tapTargets.length} controls measured, all at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
summarise(
[...bySelector.values()],
(t) =>
`${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`,
4,
),
);
check(
`tap-targets-${MIN_TAP_TARGET_PX}px`,
missing.length === 0 && undersized.length === 0,
detail.join("; "),
);
}
const badRows = facts.rows
.map((row) => checkRowLayout(row, viewport.expectStacked))
.filter(Boolean);
check(
viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side",
facts.rows.length > 0 && badRows.length === 0,
facts.rows.length === 0
? "no host rows were measured"
: badRows.length === 0
? `all ${facts.rows.length} rows laid out as expected`
: `${badRows.length} of ${facts.rows.length} rows wrong: ` +
summarise(badRows, (r) => r),
);
// The mobile early-return path proposed in #8 was rejected: narrow
// viewports must keep probing and keep detecting the gateway, not
// quietly skip work.
check(
"probing-still-runs",
probes.attempted > 0,
`${probes.attempted} outbound probe requests issued`,
);
check(
"gateway-detection-still-runs",
facts.gatewayDetected,
facts.gatewayDetected
? "Local Gateway row present"
: "no Local Gateway row — gateway detection did not run or did not complete",
);
return checks;
}