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
225 lines
8.0 KiB
JavaScript
225 lines
8.0 KiB
JavaScript
// Viewport derivation for the responsive-layout harness.
|
|
//
|
|
// The widths tested are read out of the CSS the application actually
|
|
// ships, not taken from a list of popular phone models. A generic 375px
|
|
// "phone" test sails straight past an off-by-one error at a media query
|
|
// boundary, which is the classic way a responsive layout breaks, so
|
|
// every breakpoint found in the sources is probed three times: one pixel
|
|
// below it, exactly on it, and one pixel above it.
|
|
//
|
|
// Nothing here hardcodes 768. If someone adds a second media block or
|
|
// starts using Tailwind responsive prefixes, that breakpoint starts
|
|
// being covered without this file being edited.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
// Tailwind CSS v4 default breakpoints, in rem. The app currently uses
|
|
// none of these prefixes, so the whole table is inert until someone
|
|
// writes an `md:`-prefixed utility class.
|
|
const TAILWIND_BREAKPOINT_REM = {
|
|
sm: 40,
|
|
md: 48,
|
|
lg: 64,
|
|
xl: 80,
|
|
"2xl": 96,
|
|
};
|
|
|
|
// The app does not override the root font size, so rem and em in media
|
|
// queries resolve against the browser default.
|
|
const ROOT_FONT_SIZE_PX = 16;
|
|
|
|
// Extract every min-width / max-width condition from the @media blocks in
|
|
// a stylesheet. Returns e.g. [{ type: "max", px: 768, source: "..." }].
|
|
export function mediaConditionsFromCss(css, source) {
|
|
const conditions = [];
|
|
for (const block of css.matchAll(/@media([^{]+)\{/g)) {
|
|
const features = block[1].matchAll(
|
|
/\(\s*(min|max)-width\s*:\s*([\d.]+)(px|rem|em)\s*\)/g,
|
|
);
|
|
for (const feature of features) {
|
|
const scale = feature[3] === "px" ? 1 : ROOT_FONT_SIZE_PX;
|
|
conditions.push({
|
|
type: feature[1],
|
|
px: Math.round(Number(feature[2]) * scale),
|
|
source,
|
|
});
|
|
}
|
|
}
|
|
return conditions;
|
|
}
|
|
|
|
// Extract the breakpoints implied by Tailwind responsive prefixes used in
|
|
// markup. A prefix only counts when it opens a utility class, so `text-sm`
|
|
// does not masquerade as the `sm:` breakpoint.
|
|
export function mediaConditionsFromMarkup(sources) {
|
|
const conditions = [];
|
|
for (const { path, text } of sources) {
|
|
for (const [name, rem] of Object.entries(TAILWIND_BREAKPOINT_REM)) {
|
|
const used = new RegExp(
|
|
`(^|["'\\s])${name}:[a-z0-9[\\](),_./%-]+`,
|
|
"m",
|
|
).test(text);
|
|
if (used) {
|
|
conditions.push({
|
|
type: "min",
|
|
px: rem * ROOT_FONT_SIZE_PX,
|
|
source: path,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
return conditions;
|
|
}
|
|
|
|
// Whether a given width should be rendering the app's narrow (stacked)
|
|
// layout.
|
|
//
|
|
// Two breakpoint styles can be answered from the condition list alone:
|
|
//
|
|
// - Desktop-first, which is what the app ships today: the wide layout is
|
|
// unconditional and every narrow rule lives in a `max-width` block, so
|
|
// a width is narrow exactly when one of those blocks matches. Note that
|
|
// `max-width: 768px` matches *at* 768 — getting this inclusive boundary
|
|
// wrong in either direction is what the three-widths-per-breakpoint
|
|
// sweep exists to catch.
|
|
// - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the
|
|
// stacked layout is the unconditional base and a `min-width` block is
|
|
// what widens it, so a width is narrow exactly when it sits below every
|
|
// `min-width` breakpoint.
|
|
//
|
|
// A mix of the two cannot be resolved from the breakpoints alone — which
|
|
// block owns the host-row reflow is a property of the rules inside it, not
|
|
// of the condition — so this throws rather than guessing. Guessing is how
|
|
// the wrong expectation gets applied at the right widths and the whole
|
|
// sweep quietly verifies nothing.
|
|
export function expectsStackedLayout(width, conditions) {
|
|
const kinds = new Set(conditions.map((c) => c.type));
|
|
for (const kind of kinds) {
|
|
if (kind !== "max" && kind !== "min") {
|
|
throw new Error(
|
|
`unsupported media condition type "${kind}" in ` +
|
|
"expectsStackedLayout (test/viewport/viewports.js)",
|
|
);
|
|
}
|
|
}
|
|
if (kinds.has("max") && kinds.has("min")) {
|
|
throw new Error(
|
|
"the app now mixes max-width and min-width breakpoints (" +
|
|
conditions
|
|
.map((c) => `${c.type}-width ${c.px}px in ${c.source}`)
|
|
.join(", ") +
|
|
"), so which layout a width should be showing can no longer " +
|
|
"be inferred from the breakpoint list; teach " +
|
|
"expectsStackedLayout in test/viewport/viewports.js which " +
|
|
"block owns the host-row reflow",
|
|
);
|
|
}
|
|
if (kinds.has("min")) {
|
|
return !conditions.some((c) => width >= c.px);
|
|
}
|
|
return conditions.some((c) => width <= c.px);
|
|
}
|
|
|
|
// Viewports that are not derived from a breakpoint. Each one is here for
|
|
// a stated reason; none of them is a stand-in for "a phone".
|
|
const ANCHOR_VIEWPORTS = [
|
|
{
|
|
name: "floor-portrait",
|
|
width: 320,
|
|
height: 568,
|
|
deviceScaleFactor: 2,
|
|
touch: true,
|
|
why: "320px is the narrowest viewport still in mainstream use; nothing has to work below it",
|
|
},
|
|
{
|
|
name: "phone-landscape-narrow",
|
|
width: 667,
|
|
height: 375,
|
|
deviceScaleFactor: 2,
|
|
touch: true,
|
|
why: "phone rotated to landscape, still inside the narrow layout",
|
|
},
|
|
{
|
|
name: "phone-landscape-wide",
|
|
width: 844,
|
|
height: 390,
|
|
deviceScaleFactor: 3,
|
|
touch: true,
|
|
why: "large phone rotated to landscape: crosses into the wide layout while still being a touch device",
|
|
},
|
|
{
|
|
name: "desktop",
|
|
width: 1280,
|
|
height: 800,
|
|
deviceScaleFactor: 1,
|
|
touch: false,
|
|
why: "desktop baseline",
|
|
},
|
|
];
|
|
|
|
export function deriveViewports(root) {
|
|
const conditions = [
|
|
...mediaConditionsFromCss(
|
|
readFileSync(join(root, "src/styles.css"), "utf8"),
|
|
"src/styles.css",
|
|
),
|
|
...mediaConditionsFromMarkup([
|
|
{
|
|
path: "src/main.js",
|
|
text: readFileSync(join(root, "src/main.js"), "utf8"),
|
|
},
|
|
{
|
|
path: "index.html",
|
|
text: readFileSync(join(root, "index.html"), "utf8"),
|
|
},
|
|
]),
|
|
];
|
|
|
|
if (conditions.length === 0) {
|
|
throw new Error(
|
|
"no responsive breakpoints found in src/styles.css, src/main.js or " +
|
|
"index.html — either the responsive layout was deleted or this " +
|
|
"derivation has stopped matching the sources",
|
|
);
|
|
}
|
|
|
|
const viewports = new Map();
|
|
const add = (viewport) => {
|
|
const key = `${viewport.width}x${viewport.height}`;
|
|
if (!viewports.has(key)) viewports.set(key, viewport);
|
|
};
|
|
|
|
for (const condition of conditions) {
|
|
for (const [offset, label] of [
|
|
[-1, "below"],
|
|
[0, "at"],
|
|
[+1, "above"],
|
|
]) {
|
|
const width = condition.px + offset;
|
|
add({
|
|
name: `${condition.type}-width-${condition.px}-${label}`,
|
|
width,
|
|
// Tall enough that the whole app is laid out in one column
|
|
// without the viewport height influencing wrapping.
|
|
height: 1024,
|
|
deviceScaleFactor: 2,
|
|
touch: true,
|
|
why: `${offset === 0 ? "exactly on" : `1px ${label}`} the ${condition.type}-width: ${condition.px}px breakpoint declared in ${condition.source}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const anchor of ANCHOR_VIEWPORTS) add(anchor);
|
|
|
|
return {
|
|
conditions,
|
|
viewports: [...viewports.values()]
|
|
.map((viewport) => ({
|
|
...viewport,
|
|
expectStacked: expectsStackedLayout(viewport.width, conditions),
|
|
}))
|
|
.sort((a, b) => a.width - b.width || a.height - b.height),
|
|
};
|
|
}
|