What the test does comes first, then the widths, the checks, the canned network, what it cannot check, the files, and its relation to the unit tests. The repo's history and issue references are gone; every sentence is under about 30 words. Model: fable-5-1
This commit is contained in:
+117
-91
@@ -1,114 +1,140 @@
|
|||||||
# Responsive-layout harness
|
# The viewport test
|
||||||
|
|
||||||
Automated verification of the responsive layout that landed in #5. Run it with:
|
`make frontend-viewport-test` checks the page's layout at several window widths
|
||||||
|
in a real browser:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make frontend-viewport-test
|
make frontend-viewport-test
|
||||||
```
|
```
|
||||||
|
|
||||||
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
|
It runs the unit tests, builds the page into `dist/`, and serves it from the
|
||||||
same `configs/nginx.conf` the shipping container uses, drives a digest-pinned
|
same nginx image, with the same `configs/nginx.conf`, as the Docker image.
|
||||||
headless Chrome against it over CDP, and asserts on computed layout at every
|
Before the browser starts, it checks that `/`, and a path nginx answers with the
|
||||||
viewport width derived from the app's own CSS. Screenshots land in
|
page, are served with `Cache-Control: no-cache` and a `Content-Security-Policy`
|
||||||
`tmp/viewport/` alongside a `results.json`; they are artifacts for a human to
|
header. Then it starts a headless Chrome in a container and opens the page in it
|
||||||
look at when something fails, not the evidence. The assertions are the evidence.
|
at each width. For each width it measures the layout inside the page and checks
|
||||||
|
the measurements in Node. The checks decide pass or fail. Screenshots and a
|
||||||
|
`results.json` land in `tmp/viewport/`, for a person to look at when a check
|
||||||
|
fails.
|
||||||
|
|
||||||
The target is deliberately outside `make check`: it takes minutes, and
|
The test is not part of `make check`: it needs Docker and takes minutes, and
|
||||||
`make test` has to stay under 60 seconds.
|
`make test` has to finish in under 60 seconds.
|
||||||
|
|
||||||
## How the widths are chosen
|
## The widths
|
||||||
|
|
||||||
Not from a list of phone models. `viewports.js` parses the `@media` conditions
|
The widths come from the page's own CSS. `viewports.js` reads every `min-width`
|
||||||
out of `web/src/styles.css` and scans `web/src/main.js` and `web/index.html` for
|
and `max-width` condition out of the `@media` blocks in `web/src/styles.css`. It
|
||||||
Tailwind responsive prefixes, then tests every breakpoint it finds at one pixel
|
also looks in `web/src/main.js` and `web/index.html` for Tailwind's responsive
|
||||||
below it, exactly on it, and one pixel above it. A generic 375px "phone" test
|
class prefixes, such as `md:`, and takes their breakpoints. Each breakpoint is
|
||||||
sails straight past an off-by-one at a media query boundary; `max-width: 768px`
|
tested at one pixel below it, exactly on it, and one pixel above it. Today the
|
||||||
matches _at_ 768, and the sweep pins down which side of that line each layout is
|
CSS has one breakpoint, `max-width: 768px`, and no responsive prefix is used.
|
||||||
on.
|
|
||||||
|
|
||||||
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
|
A `max-width: 768px` block matches at 768 pixels. Testing both sides of each
|
||||||
the new breakpoint is covered without this directory being touched. The app is
|
breakpoint catches a layout that switches one pixel too early or too late. A
|
||||||
desktop-first today (all narrow rules live in `max-width` blocks); a
|
second media block, or a first `md:` class, is covered without a change to this
|
||||||
`min-width`-only, mobile-first set is handled as its inverse, and a set that
|
directory.
|
||||||
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
|
The page is desktop-first: the wide layout is the default, and every narrow rule
|
||||||
|
is in a `max-width` block. So a width is expected to show the narrow layout when
|
||||||
|
one of those blocks matches it. A page with only `min-width` blocks would be
|
||||||
|
read the other way round. A page that mixes the two kinds fails the run with a
|
||||||
|
message, because the right expectation for each width can no longer be read from
|
||||||
|
the breakpoints alone.
|
||||||
|
|
||||||
- **app-rendered** — enough host rows exist and enough of them show a numeric
|
Four more widths are fixed, each for a stated reason:
|
||||||
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: one of each single control, and one
|
|
||||||
pin button per WAN host row. 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
|
- 320 by 568 pixels: the narrowest width still in common use
|
||||||
|
- 667 by 375 pixels: a phone in landscape, inside the narrow layout
|
||||||
|
- 844 by 390 pixels: a large phone in landscape, in the wide layout but still a
|
||||||
|
touch device
|
||||||
|
- 1280 by 800 pixels: a desktop window
|
||||||
|
|
||||||
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in
|
## The checks
|
||||||
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
|
Every check runs at every width, and none stops the others.
|
||||||
|
|
||||||
The browser container runs on an `--internal` docker network and has no route to
|
- `app-rendered`: at least 10 WAN host rows exist, and at least 5 rows show a
|
||||||
the internet, so the app's latency probes cannot reach anything real. The
|
number as their latency. Without this, the other checks could pass against a
|
||||||
harness answers them itself from a fixed delay table, with a deterministic
|
blank page.
|
||||||
fraction failed outright, so the rows render a realistic spread of one-, two-
|
- `no-horizontal-overflow`: the document is no wider than the window. On a
|
||||||
and three-digit latencies plus some unreachable rows. That spread is what the
|
failure, the widest content is named.
|
||||||
layout has to survive; a `---` placeholder in every row would not exercise it.
|
- `nothing-past-viewport-edge`: no visible element extends past the window's
|
||||||
|
edge.
|
||||||
|
- `no-clipped-text`: no element hides its text behind `overflow: hidden`. Text
|
||||||
|
cut with an ellipsis, as Tailwind's `truncate` does on host names and URLs, is
|
||||||
|
allowed.
|
||||||
|
- `tap-targets-44px`, on touch widths: every interactive control is at least 44
|
||||||
|
by 44 CSS pixels. The controls are `#pause-btn`, `#interval-select`,
|
||||||
|
`#debug-toggle` and the `.pin-btn` in each WAN host row. The check also
|
||||||
|
demands that each selector matches enough visible elements: one of each single
|
||||||
|
control, and one pin button per WAN host row. A renamed class would otherwise
|
||||||
|
take its controls out of the measured set, and the check would pass on
|
||||||
|
nothing.
|
||||||
|
- `host-rows-stacked` or `host-rows-side-by-side`: each host row shows the
|
||||||
|
layout expected at that width. In the narrow layout, the row's
|
||||||
|
`flex-direction` is `column`, the graph sits below the host's details, and
|
||||||
|
each takes at least 90% of the row's width. In the wide layout, the direction
|
||||||
|
is `row` and the graph sits to the right of the details. A row that only
|
||||||
|
shrank its fixed 420-pixel column fails.
|
||||||
|
- `probing-still-runs`: the page sent at least one check to a target at this
|
||||||
|
width.
|
||||||
|
- `gateway-detection-still-runs`: the `Local Gateway` row is present at this
|
||||||
|
width, so the gateway was looked for and found.
|
||||||
|
|
||||||
## What this cannot verify
|
### The tap-target size
|
||||||
|
|
||||||
Real limits, so nobody re-parks this issue as needing hardware:
|
44 by 44 CSS pixels is the figure in Apple's Human Interface Guidelines and in
|
||||||
|
WCAG 2.2 success criterion 2.5.5, "Target Size (Enhanced)". WCAG 2.2 criterion
|
||||||
|
2.5.8 sets a lower minimum of 24 by 24 pixels, with an exception that depends on
|
||||||
|
the space around a control. The pin buttons sit against the host name they
|
||||||
|
belong to, so the test keeps 44.
|
||||||
|
|
||||||
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be
|
## The browser's network
|
||||||
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
|
The browser container runs on an internal Docker network with no route to the
|
||||||
anything overflow, is content clipped, are the controls big enough — is a
|
internet, so the page's checks reach no real host. The test answers them itself
|
||||||
function of viewport width and CSS, and is covered above.
|
from a fixed table of delays: 2, 45, 123, 456 and 780 milliseconds. Which delay
|
||||||
|
a target gets depends on its URL, and about one target in seven fails outright.
|
||||||
|
So the rows show one-, two- and three-digit latencies and some unreachable rows,
|
||||||
|
and the layout is tested against that spread. The request to
|
||||||
|
`http://192.168.1.1` is answered, so the `Local Gateway` row appears.
|
||||||
|
|
||||||
|
On each page, the test sets the interval to 1 second and waits until at least 5
|
||||||
|
rows show a latency before it measures. The page discards its first round, so
|
||||||
|
this reaches a filled page without waiting out two rounds at 3 seconds.
|
||||||
|
|
||||||
|
## What the test cannot check
|
||||||
|
|
||||||
|
- Other browser engines. The test runs Chrome. Safari uses WebKit, and bugs that
|
||||||
|
only WebKit has do not show up here.
|
||||||
|
- Real touch input. Touch emulation changes what the page is told, not how a
|
||||||
|
finger behaves. Gestures, scroll momentum, double-tap zoom and hover states on
|
||||||
|
touch are not tested.
|
||||||
|
- Physical pixels. The device pixel ratio is set, but antialiasing, colour
|
||||||
|
rendering and legibility at a given physical size are not measured.
|
||||||
|
- Fonts. The container has DejaVu, not a phone's own monospace font. Text is
|
||||||
|
close to, but not the same width as, text on a real device. A layout that fits
|
||||||
|
here by a few pixels might not fit there.
|
||||||
|
- Performance. The cost of redrawing the graphs, battery use and behaviour on a
|
||||||
|
slow connection are not measured.
|
||||||
|
- The browser's own frame. The address bar, safe-area insets and notch cutouts
|
||||||
|
are not simulated.
|
||||||
|
|
||||||
|
## The files
|
||||||
|
|
||||||
|
- `viewports.js` derives the widths from the sources.
|
||||||
|
- `facts.js` is run inside the page and measures the layout. It only measures;
|
||||||
|
it decides nothing.
|
||||||
|
- `checks.js` turns the measurements into the pass or fail of each check, with
|
||||||
|
the numbers that decided it.
|
||||||
|
- `harness.js` connects to the browser, runs each width, writes the screenshots
|
||||||
|
and `results.json`, and prints the results. `script/frontend-viewport-test`
|
||||||
|
starts it; every input it needs arrives in the environment.
|
||||||
|
|
||||||
## Relation to the unit tests
|
## Relation to the unit tests
|
||||||
|
|
||||||
Complementary layers, not two stacks. The unit tests in `test/unit/`, which
|
The unit tests in `test/unit/`, which `make test` runs with Node's built-in test
|
||||||
`make test` runs with Node's built-in test runner, exercise the functions
|
runner, call the functions `web/src/main.js` exports, with no browser. This test
|
||||||
`web/src/main.js` exports in-process with no browser. This harness exercises
|
runs the built page in a real browser engine and is the only test here that can
|
||||||
rendered layout in a real engine and is the only thing here that can see a media
|
see a media query. Checks on computed styles and element geometry belong here;
|
||||||
query. Neither replaces the other; assertions about computed styles and element
|
checks on functions belong in `test/unit/`.
|
||||||
geometry belong here, assertions about functions belong in `test/unit/`.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user