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).
204 lines
7.9 KiB
JavaScript
204 lines
7.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.
|
|
export const INTERACTIVE_SELECTORS = [
|
|
"#pause-btn",
|
|
"#interval-select",
|
|
".pin-btn",
|
|
"#debug-toggle",
|
|
];
|
|
|
|
// 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) {
|
|
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;
|
|
}
|
|
}
|
|
check(
|
|
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
|
undersized.length === 0,
|
|
undersized.length === 0
|
|
? `all ${facts.tapTargets.length} controls are 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,
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|