test: automated responsive-layout harness (closes #13)
check / check (push) Failing after 1s
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
This commit was merged in pull request #44.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
// 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user