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).
259 lines
8.9 KiB
JavaScript
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();
|