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
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"),
|
|
),
|
|
};
|
|
}
|