Files
netwatch/test/viewport/harness.js
clawbot c36dc36819
All checks were successful
check / check (push) Successful in 22s
test: automated responsive-layout harness (closes #13)
Verifies the mobile layout from #5 with a real browser engine instead of
by hand on a phone. make frontend-viewport-test builds dist/, serves it
from the same digest-pinned nginx image and the same nginx.conf the
shipping container uses, and drives a digest-pinned headless Chrome
against it over CDP.

Viewport widths are derived from the app's own CSS rather than from a
list of phone models: the @media conditions in src/styles.css and any
Tailwind responsive prefixes in the markup are parsed, and each
breakpoint is tested one pixel below, exactly on, and one pixel above.
max-width: 768px matches at 768, and a generic 375px test sails past
that boundary entirely. Four anchor viewports are added with stated
reasons: a 320px floor, a desktop baseline, and two phone-landscape
sizes straddling the breakpoint.

Assertions are on computed layout, not screenshots: horizontal overflow,
elements past the viewport edge, clipped text (deliberate ellipsis
truncation excluded), 44x44 minimum tap targets, and genuine reflow of
the host rows checked on both flex-direction and geometry. Probing and
gateway detection are asserted to still run at narrow widths, since the
early-return mobile path rejected in #8 is what would silently regress.
Screenshots are written to tmp/viewport/ as artifacts alongside the
results, not as the evidence.

puppeteer-core rather than playwright: it is the one variant of either
that never downloads or bundles a browser, so the browser stays a
digest-pinned image and the npm side is pinned by yarn.lock integrity.

The browser container runs on an --internal docker network with no route
off the host; the harness answers the app's latency probes itself from a
fixed delay table so the rows render a realistic spread of value widths.

Kept out of make check: it needs Docker and takes minutes, where make
test has to stay under 20 seconds.

Every check guards its own presence, so none can pass against a page it
is not measuring. The tap-target check in particular would otherwise be
inert: an empty undersized set means both "all controls are big enough"
and "the selectors have gone stale", and the size comparison alone
cannot tell those apart. Each selector therefore declares the minimum
number of visible instances the page must contain, per selector rather
than in total, so one stale selector out of four fails rather than only
all four at once.

Layout expectation is likewise refused rather than guessed. A width is
narrow when a max-width block matches (desktop-first, what the app does
today) or, for a min-width-only mobile-first set, when it falls below
every breakpoint; a set mixing both cannot be resolved from the
conditions alone, because which block owns the reflow is a property of
the rules inside it, so the run fails with an explanation instead of
testing the right widths against the wrong expectation.

The harness was observed failing before being trusted, four times: a
planted 900px fixed-width element in a host row; the mobile reflow rule
neutered; the tap-target threshold lowered so nothing was undersized and
.pin-btn then renamed, which took the check from 8/8 green at every
touch viewport to failing at all six, naming the stale selector; and a
second media block added so the breakpoint set mixed max and min, which
aborted the run. All reverted.

Against the current layout it reports two real defects, filed as #42
(horizontal overflow at 320px) and #43 (tap targets below 44x44).
2026-08-09 15:22:54 +00:00

259 lines
8.9 KiB
JavaScript

// Responsive-layout harness.
//
// Drives the built frontend in a real, containerised, digest-pinned Chrome
// over CDP and asserts on computed layout at every viewport width derived
// from the app's own CSS. Screenshots are written alongside as artifacts;
// they are not the evidence, the assertions are.
//
// This is not meant to be run by hand. `make frontend-viewport-test` brings
// up the browser and the web server and then runs this; every input it
// needs arrives in the environment.
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import puppeteer from "puppeteer-core";
import { collectLayoutFacts } from "./facts.js";
import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js";
import { deriveViewports } from "./viewports.js";
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(
`${name} is not set; run this via script/frontend-viewport-test`,
);
}
return value;
}
const ROOT = required("NETWATCH_ROOT");
const BASE_URL = required("NETWATCH_BASE_URL");
const CDP_URL = required("NETWATCH_CDP_URL");
const ARTIFACT_DIR = required("NETWATCH_ARTIFACT_DIR");
const BROWSER_TIMEOUT_MS = 60000;
const PAGE_TIMEOUT_MS = 30000;
// Canned responses for the app's outbound latency probes. The browser
// container sits on an --internal docker network and physically cannot
// reach the internet, so nothing here is about blocking traffic; it is
// about determinism. Real probes would render 24 rows of whatever the
// network happened to be doing. These delays make the rows show a
// realistic spread of value widths — one, two and three digit latencies,
// plus some unreachable rows — because that spread is what the layout has
// to survive.
const PROBE_DELAYS_MS = [2, 45, 123, 456, 780];
// One in every UNREACHABLE_MODULUS probes is failed outright so that the
// offline row rendering is exercised too.
const UNREACHABLE_MODULUS = 7;
// The gateway candidate that "answers", so gateway detection succeeds and
// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in src/main.js.
const RESPONSIVE_GATEWAY = "http://192.168.1.1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function stableHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = (hash * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
async function connectBrowser() {
const deadline = Date.now() + BROWSER_TIMEOUT_MS;
let lastError;
for (;;) {
try {
const response = await fetch(`${CDP_URL}/json/version`);
const info = await response.json();
// The endpoint advertises whatever Host it was reached on;
// pin it back to the address we actually dialled.
const endpoint = new URL(info.webSocketDebuggerUrl);
endpoint.host = new URL(CDP_URL).host;
const browser = await puppeteer.connect({
browserWSEndpoint: endpoint.toString(),
protocolTimeout: BROWSER_TIMEOUT_MS,
});
return { browser, version: info.Browser };
} catch (error) {
lastError = error;
if (Date.now() > deadline) {
throw new Error(`browser never came up: ${lastError}`);
}
await sleep(250);
}
}
}
function installProbeResponder(page, probes) {
const respond = (request, delayMs) =>
sleep(delayMs).then(() =>
request.respond({
status: 200,
contentType: "text/plain",
body: "",
}),
);
page.on("request", (request) => {
const url = request.url();
const settle = async () => {
if (url.startsWith(BASE_URL) || url.startsWith("data:")) {
return request.continue();
}
probes.attempted++;
if (url.startsWith(RESPONSIVE_GATEWAY)) {
probes.fulfilled++;
return respond(request, 5);
}
const hash = stableHash(url);
if (hash % UNREACHABLE_MODULUS === 0) {
probes.failed++;
return request.abort("connectionfailed");
}
probes.fulfilled++;
return respond(
request,
PROBE_DELAYS_MS[hash % PROBE_DELAYS_MS.length],
);
};
// The page may be torn down while a delayed response is pending;
// that is not a harness failure.
settle().catch(() => {});
});
}
async function runViewport(browser, viewport) {
const page = await browser.newPage();
const probes = { attempted: 0, fulfilled: 0, failed: 0 };
try {
page.setDefaultTimeout(PAGE_TIMEOUT_MS);
await page.setRequestInterception(true);
installProbeResponder(page, probes);
await page.setViewport({
width: viewport.width,
height: viewport.height,
deviceScaleFactor: viewport.deviceScaleFactor,
isMobile: viewport.touch,
hasTouch: viewport.touch,
isLandscape: viewport.width > viewport.height,
});
await page.goto(BASE_URL, { waitUntil: "load" });
await page.waitForSelector(".host-row");
// The app discards its first tick as a cold start, so rows only
// carry real values from the second one. Changing the interval
// restarts the loop at 1s, which reaches a populated UI without
// waiting out two default 3s intervals — and exercises the
// interval dropdown while we are at it.
await page.select("#interval-select", "1000");
await page.waitForFunction(
() =>
Array.from(document.querySelectorAll(".latency-value")).filter(
(el) => /\d/.test(el.textContent),
).length >= 5,
);
// Let the resize/redraw handlers settle before measuring.
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const facts = await page.evaluate(collectLayoutFacts, {
interactiveSelectors: INTERACTIVE_SELECTORS,
});
const screenshot = join(
ARTIFACT_DIR,
`${viewport.width}x${viewport.height}-${viewport.name}.png`,
);
await page.screenshot({ path: screenshot, fullPage: true });
return {
viewport,
probes,
facts,
screenshot,
checks: evaluateChecks(facts, viewport, probes),
};
} finally {
await page.close().catch(() => {});
}
}
function report(results, conditions, browserVersion) {
const label = (viewport) =>
`${viewport.width}x${viewport.height}`.padEnd(9) +
" " +
viewport.name.padEnd(24);
console.log(`browser: ${browserVersion}`);
console.log(`served from: ${BASE_URL} (built dist/)`);
console.log(
"breakpoints: " +
conditions
.map((c) => `${c.type}-width ${c.px}px (${c.source})`)
.join(", "),
);
console.log("");
let passed = 0;
let failed = 0;
for (const result of results) {
const bad = result.checks.filter((c) => !c.ok);
passed += result.checks.length - bad.length;
failed += bad.length;
// Passing viewports get one line. Detail is for failures.
console.log(
`${bad.length === 0 ? "PASS" : "FAIL"} ${label(result.viewport)} ` +
`${result.checks.length - bad.length}/${result.checks.length} checks` +
`${result.viewport.expectStacked ? " [narrow layout expected]" : ""}`,
);
for (const check of bad) {
console.log(` ${check.name}: ${check.detail}`);
}
if (bad.length > 0) {
console.log(` why this width: ${result.viewport.why}`);
console.log(` screenshot: ${result.screenshot}`);
}
}
console.log("");
console.log(
`${results.length} viewports, ${passed + failed} checks: ` +
`${passed} passed, ${failed} failed`,
);
console.log(`artifacts: ${ARTIFACT_DIR}`);
return failed;
}
async function main() {
const { conditions, viewports } = deriveViewports(ROOT);
mkdirSync(ARTIFACT_DIR, { recursive: true });
const { browser, version } = await connectBrowser();
const results = [];
try {
for (const viewport of viewports) {
results.push(await runViewport(browser, viewport));
}
} finally {
await browser.disconnect().catch(() => {});
}
writeFileSync(
join(ARTIFACT_DIR, "results.json"),
JSON.stringify({ browser: version, conditions, results }, null, 2) +
"\n",
);
const failed = report(results, conditions, version);
process.exitCode = failed === 0 ? 0 : 1;
}
await main();