check / check (push) Failing after 1s
`make frontend-viewport-test` builds `dist/`, serves it from the same digest-pinned nginx image and nginx.conf the shipping container uses, and drives a digest-pinned headless Chrome over CDP. Viewport widths are derived from the app's own @media breakpoints rather than a list of phone models: each breakpoint is tested one pixel below, on, and above, plus four anchor viewports. Assertions are on computed layout — horizontal overflow, off-screen elements, clipped text, 44x44 tap targets, host-row reflow — not screenshots, and each check declares the minimum elements it must find so a stale selector fails instead of passing blind against a page it is not measuring. Kept out of `make check`: it needs Docker and takes minutes. Proven able to fail before being trusted. Model: opus-4-8
246 lines
9.9 KiB
JavaScript
246 lines
9.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.
|
|
// Each carries the smallest number of *visible* instances the page has to
|
|
// contain for the tap-target oracle to be measuring anything at all.
|
|
//
|
|
// Without those floors the check is inert: `undersized` is empty both when
|
|
// every control is large enough and when the selectors have gone stale and
|
|
// matched nothing, and the pass condition cannot tell those apart. A single
|
|
// combined floor would not be enough either — 26 pin buttons would cover
|
|
// for all three singleton controls vanishing at once — so the floor is per
|
|
// selector, and one stale selector out of four fails the check.
|
|
export const INTERACTIVE_CONTROLS = [
|
|
{ selector: "#pause-btn", minCount: 1 },
|
|
{ selector: "#interval-select", minCount: 1 },
|
|
// One per pinnable host row. `app-rendered` already requires at least
|
|
// 10 host rows, so a count below that means the pin buttons stopped
|
|
// being rendered per row rather than that there were fewer hosts.
|
|
{ selector: ".pin-btn", minCount: 10 },
|
|
{ selector: "#debug-toggle", minCount: 1 },
|
|
];
|
|
|
|
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
|
|
(control) => control.selector,
|
|
);
|
|
|
|
// 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) {
|
|
// Presence first: a selector that matches nothing contributes no
|
|
// undersized targets, so without this the check would report
|
|
// "all 0 controls are at least 44x44" and pass.
|
|
const seen = new Map();
|
|
for (const target of facts.tapTargets) {
|
|
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
|
|
}
|
|
const missing = INTERACTIVE_CONTROLS.filter(
|
|
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
const detail = [];
|
|
if (missing.length > 0) {
|
|
detail.push(
|
|
"oracle is not measuring the page: " +
|
|
summarise(
|
|
missing,
|
|
(c) =>
|
|
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
|
|
4,
|
|
),
|
|
);
|
|
}
|
|
detail.push(
|
|
undersized.length === 0
|
|
? `${facts.tapTargets.length} controls measured, all 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,
|
|
),
|
|
);
|
|
check(
|
|
`tap-targets-${MIN_TAP_TARGET_PX}px`,
|
|
missing.length === 0 && undersized.length === 0,
|
|
detail.join("; "),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|