test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 38s
All checks were successful
check / check (push) Successful in 38s
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. The harness was observed failing before being trusted, twice: a planted 900px fixed-width element in a host row, and the mobile reflow rule neutered. Both reverted. Against the current layout it reports two real defects, filed as #42 (horizontal overflow at 320px) and #43 (tap targets below 44x44).
This commit is contained in:
258
test/viewport/harness.js
Normal file
258
test/viewport/harness.js
Normal file
@@ -0,0 +1,258 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user