test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 38s
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).
This commit is contained in:
185
test/viewport/viewports.js
Normal file
185
test/viewport/viewports.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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. The app keeps all of its narrow-viewport rules inside
|
||||
// `max-width` blocks, 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 precisely what the
|
||||
// three-widths-per-breakpoint sweep exists to catch.
|
||||
export function expectsStackedLayout(width, conditions) {
|
||||
return conditions.some((c) => c.type === "max" && 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