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).
5.4 KiB
Responsive-layout harness
Automated verification of the responsive layout that landed in #5. Run it with:
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. 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.scrollWidthfits 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'struncate, 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. See below.
- host-rows-stacked / host-rows-side-by-side — the rows genuinely reflow.
Computed
flex-directionand 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.
hasTouchemulation 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.
deviceScaleFactoris 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.