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).
186 lines
6.3 KiB
JavaScript
186 lines
6.3 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. 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),
|
|
};
|
|
}
|