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).
185 lines
7.5 KiB
JavaScript
185 lines
7.5 KiB
JavaScript
// Layout facts collected from inside the page.
|
|
//
|
|
// This function is serialised and evaluated in the browser, so it must be
|
|
// entirely self-contained: no imports, no closures over module scope. It
|
|
// only *measures*; every pass/fail decision is made back in node by
|
|
// checks.js, so failures can be reported with real numbers attached.
|
|
|
|
export function collectLayoutFacts(options) {
|
|
const describe = (el) => {
|
|
const id = el.id ? "#" + el.id : "";
|
|
const classes =
|
|
typeof el.className === "string" && el.className.trim()
|
|
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
|
|
: "";
|
|
return el.tagName.toLowerCase() + id + classes;
|
|
};
|
|
|
|
const round = (n) => Math.round(n * 10) / 10;
|
|
|
|
// Overflow propagates up every ancestor, so a single wide element
|
|
// reports as body, #app, the row, and so on. Depth lets the report
|
|
// name the deepest — that is, the actual — offender.
|
|
const depthOf = (el) => {
|
|
let depth = 0;
|
|
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
depth++;
|
|
}
|
|
return depth;
|
|
};
|
|
|
|
const isVisible = (el) => {
|
|
const style = getComputedStyle(el);
|
|
if (style.display === "none") return false;
|
|
if (style.visibility === "hidden") return false;
|
|
const rect = el.getBoundingClientRect();
|
|
return rect.width > 0 && rect.height > 0;
|
|
};
|
|
|
|
const innerWidth = window.innerWidth;
|
|
const clientWidth = document.documentElement.clientWidth;
|
|
// Under mobile emulation Chrome lets window.innerWidth *grow* to the
|
|
// width of overflowing content, exactly as a phone zooms out to fit a
|
|
// too-wide page. Measuring against it would therefore hide the
|
|
// overflow it is supposed to expose: at a 320px device width a page
|
|
// that spills to 350 reports innerWidth 350 and looks clean. Every
|
|
// comparison below is against the layout viewport instead.
|
|
const viewportWidth = Math.min(innerWidth, clientWidth);
|
|
const elements = Array.from(document.querySelectorAll("body *"));
|
|
|
|
// Elements sticking out past the right (or left) edge of the viewport.
|
|
// The document-level scrollWidth check says *that* the page overflows;
|
|
// this says *what* is doing it.
|
|
const overflowing = [];
|
|
// Elements clipping their own text. Deliberate ellipsis truncation
|
|
// (Tailwind's `truncate`) is opt-in and excluded: it is a design
|
|
// choice, not breakage.
|
|
const clipped = [];
|
|
// Elements whose content spills out of their own box without being
|
|
// clipped, past the right edge of the viewport. A block element is
|
|
// only ever as wide as its container, so text overflowing it has no
|
|
// element rect of its own to catch — but it is exactly what drags
|
|
// documentElement.scrollWidth past the viewport width, so without
|
|
// this the page-level overflow failure has nothing to point at.
|
|
const contentOverflowing = [];
|
|
|
|
for (const el of elements) {
|
|
if (!isVisible(el)) continue;
|
|
const rect = el.getBoundingClientRect();
|
|
if (rect.right > viewportWidth + 1 || rect.left < -1) {
|
|
overflowing.push({
|
|
el: describe(el),
|
|
depth: depthOf(el),
|
|
left: round(rect.left),
|
|
right: round(rect.right),
|
|
});
|
|
}
|
|
const style = getComputedStyle(el);
|
|
const clips =
|
|
style.overflowX === "hidden" || style.overflowX === "clip";
|
|
const ellipsis = style.textOverflow === "ellipsis";
|
|
const hasText = el.textContent.trim().length > 0;
|
|
const spills =
|
|
el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1;
|
|
if (clips && !ellipsis && hasText && spills) {
|
|
clipped.push({
|
|
el: describe(el),
|
|
scrollWidth: el.scrollWidth,
|
|
clientWidth: el.clientWidth,
|
|
});
|
|
}
|
|
if (
|
|
!clips &&
|
|
spills &&
|
|
rect.left + el.scrollWidth > viewportWidth + 1
|
|
) {
|
|
contentOverflowing.push({
|
|
el: describe(el),
|
|
depth: depthOf(el),
|
|
scrollWidth: el.scrollWidth,
|
|
clientWidth: el.clientWidth,
|
|
reach: round(rect.left + el.scrollWidth),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Interactive controls. The measured target is the nearest thing that
|
|
// is genuinely tappable — for a checkbox that is the <label> wrapping
|
|
// it, which is larger than the box itself and is what a finger hits.
|
|
const tapTargets = [];
|
|
for (const selector of options.interactiveSelectors) {
|
|
for (const el of document.querySelectorAll(selector)) {
|
|
if (!isVisible(el)) continue;
|
|
const target = el.closest("button, a, label, select") || el;
|
|
const rect = target.getBoundingClientRect();
|
|
tapTargets.push({
|
|
selector,
|
|
el: describe(target),
|
|
width: round(rect.width),
|
|
height: round(rect.height),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Host rows. The question is not "did it get narrower" but "did it
|
|
// reflow": the info block and the sparkline must end up stacked
|
|
// vertically and full width in the narrow layout, and side by side in
|
|
// the wide one. Both the computed flex-direction and the actual
|
|
// geometry are recorded so a row that claims to be a column but is
|
|
// still laid out side by side cannot slip through.
|
|
const rows = [];
|
|
for (const row of document.querySelectorAll(".host-row")) {
|
|
const inner = row.firstElementChild;
|
|
if (!inner) continue;
|
|
const sparkline = inner.querySelector(".sparkline-container");
|
|
const info = sparkline ? sparkline.previousElementSibling : null;
|
|
if (!sparkline || !info) continue;
|
|
const innerStyle = getComputedStyle(inner);
|
|
const innerRect = inner.getBoundingClientRect();
|
|
const infoRect = info.getBoundingClientRect();
|
|
const sparkRect = sparkline.getBoundingClientRect();
|
|
rows.push({
|
|
index: row.dataset.index,
|
|
flexDirection: innerStyle.flexDirection,
|
|
containerWidth: round(innerRect.width),
|
|
info: {
|
|
left: round(infoRect.left),
|
|
right: round(infoRect.right),
|
|
bottom: round(infoRect.bottom),
|
|
width: round(infoRect.width),
|
|
},
|
|
sparkline: {
|
|
left: round(sparkRect.left),
|
|
top: round(sparkRect.top),
|
|
width: round(sparkRect.width),
|
|
},
|
|
});
|
|
}
|
|
|
|
const localRows = Array.from(
|
|
document.querySelectorAll("#local-hosts .host-row"),
|
|
);
|
|
|
|
return {
|
|
innerWidth,
|
|
// innerWidth includes any classic scrollbar, clientWidth does not.
|
|
// Reported separately so the overflow check can hold itself to the
|
|
// narrower of the two rather than to whichever one is more
|
|
// forgiving.
|
|
documentClientWidth: document.documentElement.clientWidth,
|
|
documentScrollWidth: document.documentElement.scrollWidth,
|
|
overflowing,
|
|
contentOverflowing,
|
|
clipped,
|
|
tapTargets,
|
|
rows,
|
|
rowCount: document.querySelectorAll(".host-row").length,
|
|
numericLatencies: Array.from(
|
|
document.querySelectorAll(".latency-value"),
|
|
).filter((el) => /\d/.test(el.textContent)).length,
|
|
gatewayDetected: localRows.some((row) =>
|
|
row.textContent.includes("Local Gateway"),
|
|
),
|
|
};
|
|
}
|