Files
netwatch/script/frontend-viewport-test
T
sneak 33859b1dc1
check / check (push) Waiting to run
Page is sent with Cache-Control: no-cache (closes #141)
nginx sent index.html with no Cache-Control, so a browser could keep
it after a deploy and ask for hashed assets the new build no longer
has, and show an empty page. location / now adds Cache-Control:
no-cache, which covers its try_files fallback to /index.html, and
includes security-headers.conf again, since its own add_header drops
the server's. /assets/ and the locations passed to the backend are
unchanged.

script/frontend-viewport-test fails unless / and a path that falls
back to the page carry Cache-Control: no-cache and
Content-Security-Policy, which stands for the security headers.

Model: opus-5-5
2026-10-18 23:50:38 +00:00

130 lines
5.7 KiB
Bash
Executable File

#!/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
# configs/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.
# Before that, it checks the headers the page is served with.
#
# 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/frontend-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
# configs/nginx.conf is a template: the image renders it over its own
# default.conf, with the same port and limit bin/entrypoint.sh uses.
# The empty file it includes trusts no proxy, as bin/entrypoint.sh
# writes it when TRUSTED_PROXIES is set and empty. It also includes
# the security headers, so the page runs under the shipped policy.
docker run -d --rm --name "$SERVER" \
--network "$NETWORK" --network-alias netwatch \
-e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \
-v "$ROOT/dist:/usr/share/nginx/html:ro" \
-v "$ROOT/configs/nginx.conf:/etc/nginx/templates/default.conf.template:ro" \
-v /dev/null:/etc/nginx/trusted-proxies.conf:ro \
-v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \
"$SERVER_IMAGE" > /dev/null
# The page, and a path with no file of its own, which falls back to
# it, must go out with Cache-Control: no-cache. That add_header in
# its location drops the server's own unless the location includes
# the security headers again; Content-Security-Policy stands for
# them. curl retries until nginx is listening.
for path in / /no-such-page; do
headers="$(docker exec "$SERVER" curl -sS -o /dev/null -D - \
--retry 10 --retry-connrefused --retry-delay 1 \
"http://127.0.0.1:8080$path" | tr -d '\r')"
if ! printf '%s\n' "$headers" | grep -qix 'Cache-Control: no-cache' ||
! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then
echo "frontend-viewport-test: $path needs" \
"Cache-Control: no-cache and the security headers; got:" >&2
printf '%s\n' "$headers" >&2
exit 1
fi
done
# 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 "$@"