// 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();