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
113 lines
5.9 KiB
Markdown
113 lines
5.9 KiB
Markdown
# Responsive-layout harness
|
|
|
|
Automated verification of the responsive layout that landed in #5. Run it with:
|
|
|
|
```bash
|
|
make frontend-viewport-test
|
|
```
|
|
|
|
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
|
|
same `nginx.conf` the shipping container uses, drives a digest-pinned headless
|
|
Chrome against it over CDP, and asserts on computed layout at every viewport
|
|
width derived from the app's own CSS. Screenshots land in `tmp/viewport/`
|
|
alongside a `results.json`; they are artifacts for a human to look at when
|
|
something fails, not the evidence. The assertions are the evidence.
|
|
|
|
The target is deliberately outside `make check`: it needs Docker and takes
|
|
minutes, and `make test` has to stay under 20 seconds.
|
|
|
|
## How the widths are chosen
|
|
|
|
Not from a list of phone models. `viewports.js` parses the `@media` conditions
|
|
out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind
|
|
responsive prefixes, then tests every breakpoint it finds at one pixel below it,
|
|
exactly on it, and one pixel above it. A generic 375px "phone" test sails
|
|
straight past an off-by-one at a media query boundary; `max-width: 768px`
|
|
matches _at_ 768, and the sweep pins down which side of that line each layout is
|
|
on.
|
|
|
|
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
|
|
the new breakpoint is covered without this directory being touched. The app is
|
|
desktop-first today (all narrow rules live in `max-width` blocks); a
|
|
`min-width`-only, mobile-first set is handled as its inverse, and a set that
|
|
mixes the two makes the run fail loudly rather than test the right widths with
|
|
the wrong expectation. Four further viewports are fixed anchors, each with a
|
|
stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape
|
|
sizes straddling the breakpoint for the rotation case.
|
|
|
|
## What it asserts
|
|
|
|
- **app-rendered** — enough host rows exist and enough of them show a numeric
|
|
latency. This one exists so the rest cannot pass vacuously against a blank
|
|
page.
|
|
- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout
|
|
viewport, with the widest offending element named.
|
|
- **nothing-past-viewport-edge** — no visible element's box extends past the
|
|
viewport edge.
|
|
- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate
|
|
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
|
|
excluded: it is a design choice, not breakage.
|
|
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
|
|
touch viewports, _and_ each selector in the control list matched at least the
|
|
number of visible elements it declares. The second half is what stops the
|
|
check passing vacuously: with size alone, a renamed class would take its
|
|
controls out of the measured set and the check would report "all 0 controls
|
|
are at least 44x44" and pass. See below.
|
|
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
|
|
Computed `flex-direction` _and_ the actual geometry are checked, and in the
|
|
narrow layout the info block and the sparkline must each occupy essentially
|
|
the full row width. A row that merely shrank its 420px column would fail.
|
|
- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep
|
|
probing and keep detecting the gateway. The mobile early-return path proposed
|
|
in #8 was rejected; this is what would catch it coming back.
|
|
|
|
### The tap-target threshold
|
|
|
|
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in
|
|
WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a
|
|
lower 24x24 floor, but that floor comes with a spacing exception these controls
|
|
do not qualify for — the pin buttons sit directly against the host name they
|
|
belong to.
|
|
|
|
## Determinism
|
|
|
|
The browser container runs on an `--internal` docker network and has no route to
|
|
the internet, so the app's latency probes cannot reach anything real. The
|
|
harness answers them itself from a fixed delay table, with a deterministic
|
|
fraction failed outright, so the rows render a realistic spread of one-, two-
|
|
and three-digit latencies plus some unreachable rows. That spread is what the
|
|
layout has to survive; 24 identical `---` placeholders would not exercise it.
|
|
|
|
## What this cannot verify
|
|
|
|
Real limits, so nobody re-parks this issue as needing hardware:
|
|
|
|
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be
|
|
emulated by it; Safari-specific bugs (viewport units under a collapsing URL
|
|
bar, `-webkit-fill-available`, form control metrics) will not show up here.
|
|
- **Real touch input.** `hasTouch` emulation changes what the page is told, not
|
|
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and
|
|
hover-state fallbacks on touch are out of scope.
|
|
- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but
|
|
subpixel antialiasing, OLED colour rendering and actual legibility at a given
|
|
physical size are not measurable here.
|
|
- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text
|
|
metrics are therefore close to, but not identical to, a real device — a layout
|
|
that fits here by a few pixels might not there.
|
|
- **On-device performance.** Canvas sparkline redraw cost, battery, and
|
|
behaviour on a slow radio are not measured.
|
|
- **Browser chrome.** The address bar, safe-area insets and notch cutouts are
|
|
not simulated.
|
|
|
|
Everything else this issue was actually about — does the layout reflow, does
|
|
anything overflow, is content clipped, are the controls big enough — is a
|
|
function of viewport width and CSS, and is covered above.
|
|
|
|
## Relation to the unit test framework (#21)
|
|
|
|
Complementary layers, not two stacks. `vitest` (#21) will exercise module-level
|
|
logic in-process with no browser. This harness exercises rendered layout in a
|
|
real engine and is the only thing here that can see a media query. Neither
|
|
replaces the other; assertions about computed styles and element geometry belong
|
|
here, assertions about functions belong in `vitest`.
|