test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 22s
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:
102
script/frontend-viewport-test
Executable file
102
script/frontend-viewport-test
Executable file
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
# script/frontend-viewport-test: verify the responsive layout of the built
|
||||
# frontend in a real browser engine.
|
||||
#
|
||||
# Builds dist/, serves it with the same nginx image and the same nginx.conf
|
||||
# the shipping container uses, points a containerised headless Chrome at it
|
||||
# over CDP, and asserts on computed layout at every viewport width derived
|
||||
# from the app's own CSS. See test/viewport/README.md for what this covers
|
||||
# and what it cannot.
|
||||
#
|
||||
# Deliberately not part of script/check: it needs Docker and takes far
|
||||
# longer than the 20s budget make test has to stay inside.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
# chromedp/headless-shell 151.0.7922.109, 2026-08-09
|
||||
BROWSER_IMAGE="chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
|
||||
# nginx:stable-alpine, 2026-02-22 (the digest Dockerfile ships)
|
||||
SERVER_IMAGE="nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab"
|
||||
# node:22-alpine, 2026-02-22 (the digest Dockerfile builds with)
|
||||
NODE_IMAGE="node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34"
|
||||
|
||||
RUN_ID="$$-$(date +%s)"
|
||||
NETWORK="netwatch-viewport-$RUN_ID"
|
||||
SERVER="netwatch-viewport-server-$RUN_ID"
|
||||
BROWSER="netwatch-viewport-browser-$RUN_ID"
|
||||
HARNESS="netwatch-viewport-harness-$RUN_ID"
|
||||
ARTIFACT_DIR="$ROOT/tmp/viewport"
|
||||
|
||||
# Every container is named and removed here, including the harness itself:
|
||||
# `timeout` below kills the `docker run` client, not the container it
|
||||
# started, and an unnamed survivor keeps the --internal network in use so
|
||||
# `docker network rm` fails too. This host runs many sessions at once and
|
||||
# neither may be left behind.
|
||||
cleanup() {
|
||||
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
|
||||
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
|
||||
docker rm -f "$SERVER" > /dev/null 2>&1 || true
|
||||
docker network rm "$NETWORK" > /dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
|
||||
# Test what ships: the production build, not a dev server.
|
||||
"$ROOT/script/test"
|
||||
if [ ! -f "$ROOT/dist/index.html" ]; then
|
||||
echo "frontend-viewport-test: dist/index.html missing after build" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
# An --internal network has no route off the host, so the browser
|
||||
# cannot reach the real internet no matter what the page asks for.
|
||||
# Latency probes are answered by the harness instead. This also means
|
||||
# no port can be published from it, which is why the harness itself
|
||||
# runs as a third container on the same network rather than on the
|
||||
# host.
|
||||
docker network create --internal "$NETWORK" > /dev/null
|
||||
|
||||
docker run -d --rm --name "$SERVER" \
|
||||
--network "$NETWORK" --network-alias netwatch \
|
||||
-v "$ROOT/dist:/usr/share/nginx/html:ro" \
|
||||
-v "$ROOT/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
|
||||
"$SERVER_IMAGE" > /dev/null
|
||||
|
||||
# The image's own entrypoint already exposes CDP on 9222 and passes
|
||||
# --no-sandbox, so only extra flags belong here; re-specifying the
|
||||
# debugging port collides with it and leaves the endpoint bound to
|
||||
# loopback only. --hide-scrollbars keeps innerWidth equal to
|
||||
# clientWidth, so the overflow assertion has no scrollbar-sized slack
|
||||
# to hide behind, and matches the overlay scrollbars phones use.
|
||||
docker run -d --rm --name "$BROWSER" --init --shm-size=1g \
|
||||
--network "$NETWORK" \
|
||||
"$BROWSER_IMAGE" \
|
||||
--hide-scrollbars \
|
||||
> /dev/null
|
||||
|
||||
# Chrome refuses DevTools requests whose Host header is neither
|
||||
# localhost nor an IP address, so dial the container by address rather
|
||||
# than by its network alias.
|
||||
browser_ip="$(docker inspect \
|
||||
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
|
||||
"$BROWSER")"
|
||||
|
||||
timeout 900 docker run --rm --init --name "$HARNESS" \
|
||||
--network "$NETWORK" \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-v "$ROOT:/app" \
|
||||
-w /app \
|
||||
-e NETWATCH_ROOT=/app \
|
||||
-e NETWATCH_BASE_URL=http://netwatch:8080 \
|
||||
-e "NETWATCH_CDP_URL=http://$browser_ip:9222" \
|
||||
-e NETWATCH_ARTIFACT_DIR=/app/tmp/viewport \
|
||||
"$NODE_IMAGE" \
|
||||
node test/viewport/harness.js
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user