test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 22s

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.

Every check guards its own presence, so none can pass against a page it
is not measuring. The tap-target check in particular would otherwise be
inert: an empty undersized set means both "all controls are big enough"
and "the selectors have gone stale", and the size comparison alone
cannot tell those apart. Each selector therefore declares the minimum
number of visible instances the page must contain, per selector rather
than in total, so one stale selector out of four fails rather than only
all four at once.

Layout expectation is likewise refused rather than guessed. A width is
narrow when a max-width block matches (desktop-first, what the app does
today) or, for a min-width-only mobile-first set, when it falls below
every breakpoint; a set mixing both cannot be resolved from the
conditions alone, because which block owns the reflow is a property of
the rules inside it, so the run fails with an explanation instead of
testing the right widths against the wrong expectation.

The harness was observed failing before being trusted, four times: a
planted 900px fixed-width element in a host row; the mobile reflow rule
neutered; the tap-target threshold lowered so nothing was undersized and
.pin-btn then renamed, which took the check from 8/8 green at every
touch viewport to failing at all six, naming the stale selector; and a
second media block added so the breakpoint set mixed max and min, which
aborted the run. All 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:
clawbot
2026-08-09 14:52:52 +00:00
committed by sneak
parent fbfe1df349
commit c36dc36819
14 changed files with 1313 additions and 2 deletions

224
test/viewport/viewports.js Normal file
View File

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