- Difficulty:
- intermediate
- Tools:
- bash, curl, agent-browser
Problem
A CSS change that only breaks the phone layout, or a route that starts returning 200 where it should return 404, is invisible until someone happens to look. Checking by hand after every change lasts about two days.
Trigger
Run by hand after a visual change, or from CI against a deployed URL.
Prerequisites
- 01agent-browser on PATH, and a Chrome or Chromium it can drive.
- 02A site that is actually reachable at the base URL, since the script preflights it and stops if not.
- 03Somewhere to put screenshots. Each run writes a dated directory.
Steps
- 01
Install the browser driver agent-browser
npm i -g agent-browser && agent-browser install
- 02
List every route with the status it should return bash
The ROUTES array pairs a path with an expected code, so a 404 that starts returning 200 is a failure rather than a passing row.
- 03
Run it against a local server or a deployed URL curl
scripts/ux-loop.sh https://example.com. With no argument it preflights localhost and exits early if nothing is listening.
- 04
Read the dated report and the bold cells
Each run writes its own directory with a markdown table and the screenshots. Failures are bold in the table and set exit code 2.
Payload · sh
#!/usr/bin/env bash
# UX loop: screenshot every route at phone and desktop widths, check HTTP
# status and console errors, write a dated report. Rerun after each fix.
#
# scripts/ux-loop.sh # against the dev server, http://localhost:3000
# scripts/ux-loop.sh https://automationsanonymous.com
#
# Needs agent-browser on PATH (npm i -g agent-browser && agent-browser install).
# Output lands in ux-out/<date>/ which is gitignored.
set -euo pipefail
BASE="${1:-http://localhost:${PORT:-3000}}"
BASE="${BASE%/}"
curl -sf --max-time 10 -o /dev/null "$BASE/" || { echo "No server responding at $BASE" >&2; exit 1; }
STAMP="$(date +%Y-%m-%d-%H%M%S)"
OUT="ux-out/$STAMP"
SESSION="ux-loop-$$"
mkdir -p "$OUT"
# route expected-status
ROUTES=(
"/ 200"
"/automations 200"
"/automations?tool=n8n&difficulty=beginner 200"
"/automations/back-up-one-folder-every-night 200"
"/automations/does-not-exist 404"
"/tools 200"
"/tools/cron 200"
"/tools/does-not-exist 404"
"/stacks/cron-to-slack 200"
"/stacks/cron-to-cron 404"
"/stacks/a-to-b 404"
"/blog 200"
"/blog/how-to-pick-your-first-automation 200"
"/blog/example-post 404"
"/submit 200"
"/admin/queue 200"
"/admin/automations/example-id 200"
"/admin/import 200"
"/does-not-exist 404"
"/sitemap.xml 200"
"/robots.txt 200"
"/llms.txt 200"
"/llms-full.txt 200"
"/api 200"
"/api/automations 200"
"/api/automations/back-up-one-folder-every-night 200"
"/api/automations/back-up-one-folder-every-night.md 200"
"/api/automations/does-not-exist 404"
"/api/tools 200"
"/api/tools/cron 200"
"/api/stacks/cron-to-slack 200"
"/api/blog 200"
"/api/blog/how-to-pick-your-first-automation.md 200"
)
# name width height
VIEWPORTS=(
"phone 390 844"
"desktop 1280 800"
)
REPORT="$OUT/report.md"
{
echo "# UX loop $STAMP"
echo
echo "Base: $BASE"
echo
echo "| Route | HTTP | Canonical | Robots | Description | Title | Console errors | Phone | Desktop |"
echo "|---|---|---|---|---|---|---|---|---|"
} > "$REPORT"
ab() { agent-browser --session "$SESSION" "$@"; }
# Always close the browser; mark the report if the run died partway.
trap 'rc=$?; ab close >/dev/null 2>&1 || true; if [ $rc -ne 0 ]; then echo "| ABORTED (exit $rc) | | | | | | | | |" >> "$REPORT"; echo "ux-loop aborted (exit $rc); partial report: $REPORT" >&2; fi' EXIT
slug() { echo "$1" | sed -E 's#^/$#home#; s#^/##; s#[^A-Za-z0-9]+#-#g; s#-+$##'; }
fail=0
body="$(mktemp)"
for entry in "${ROUTES[@]}"; do
read -r route expect <<< "$entry"
name="$(slug "$route")"
url="$BASE$route"
code="$(curl -s -o "$body" -w '%{http_code}' --max-time 30 "$url" || echo 000)"
# The answer-engine surface: what a crawler sees without a browser.
canonical="$(grep -oE '<link rel="canonical" href="[^"]*"' "$body" | sed -E 's/.*href="([^"]*)"/\1/' | head -1 || true)"
robots="$(grep -oE '<meta name="robots" content="[^"]*"' "$body" | sed -E 's/.*content="([^"]*)"/\1/' | head -1 || true)"
desc="$(grep -oE '<meta name="description" content="[^"]*"' "$body" | sed -E 's/.*content="([^"]*)"/\1/' | head -1 || true)"
mark="$code"
[ "$code" != "$expect" ] && { mark="**$code** (want $expect)"; fail=$((fail+1)); }
case "$route" in
/admin*) echo "$robots" | grep -q noindex || { robots="**${robots:-missing}** (want noindex)"; fail=$((fail+1)); } ;;
esac
title=""; errors=""; shots=()
for vp in "${VIEWPORTS[@]}"; do
read -r vname w h <<< "$vp"
ab set viewport "$w" "$h" >/dev/null
ab open "$url" >/dev/null
ab wait --load networkidle >/dev/null || true
[ -z "$title" ] && title="$(ab get title 2>/dev/null | tr -d '\n' || true)"
e="$(ab errors 2>/dev/null | grep -v '^$' | grep -vi 'no errors' || true)"
[ -n "$e" ] && errors="$errors $vname:$(echo "$e" | wc -l | tr -d ' ')"
shot="$OUT/$name.$vname.png"
ab screenshot --full "$shot" >/dev/null
shots+=("$shot")
done
echo "| \`$route\` | $mark | ${canonical:-none} | ${robots:-none} | $( [ -n "$desc" ] && echo "${desc:0:60}" || echo none ) | $title | ${errors:-none} | ${shots[0]} | ${shots[1]} |" >> "$REPORT"
echo "$code $route"
done
rm -f "$body"
echo
echo "Report: $REPORT"
if [ "$fail" -gt 0 ]; then
echo "$fail check(s) failed; see bold cells in the report" >&2
exit 2
fiFailure modes
- !The headless browser emulates prefers-reduced-motion by default, so animations and any once-per-session overlay never play and screenshots look wrong for reasons that are not bugs. Clear it with agent-browser set media light.
- !A click on a control below the fold silently misses, because find does not scroll. Resolve the element reference first and scroll it into view.
- !get text returns rendered text, so anything styled uppercase comes back uppercase and a lowercase grep finds nothing.
- !A route that is slow rather than broken trips the curl timeout and reports 000, which reads identically to a dead server.
JSON-LD · HowTo
{
"@context": "https://schema.org",
"@type": "HowTo",
"name": "Screenshot every route at phone and desktop width after every change",
"description": "One script walks every route in a site, checks each against an expected status code, captures the canonical, robots and description tags, screenshots at two widths, and exits non-zero when any check fails.",
"url": "https://automationsanonymous.com/automations/screenshot-every-route-after-every-change",
"tool": [
{
"@type": "HowToTool",
"name": "bash",
"url": "https://automationsanonymous.com/tools/bash"
},
{
"@type": "HowToTool",
"name": "curl",
"url": "https://automationsanonymous.com/tools/curl"
},
{
"@type": "HowToTool",
"name": "agent-browser",
"url": "https://automationsanonymous.com/tools/agent-browser"
}
],
"step": [
{
"@type": "HowToStep",
"position": 1,
"name": "Install the browser driver",
"text": "npm i -g agent-browser && agent-browser install",
"url": "https://automationsanonymous.com/tools/agent-browser"
},
{
"@type": "HowToStep",
"position": 2,
"name": "List every route with the status it should return",
"text": "The ROUTES array pairs a path with an expected code, so a 404 that starts returning 200 is a failure rather than a passing row.",
"url": "https://automationsanonymous.com/tools/bash"
},
{
"@type": "HowToStep",
"position": 3,
"name": "Run it against a local server or a deployed URL",
"text": "scripts/ux-loop.sh https://example.com. With no argument it preflights localhost and exits early if nothing is listening.",
"url": "https://automationsanonymous.com/tools/curl"
},
{
"@type": "HowToStep",
"position": 4,
"name": "Read the dated report and the bold cells",
"text": "Each run writes its own directory with a markdown table and the screenshots. Failures are bold in the table and set exit code 2."
}
],
"datePublished": "2026-09-06T19:19:17.533Z"
}