Automations Anonymous
Sheet
Display
Read
Type

Sheet 04 · /automations/screenshot-every-route-after-every-change

Screenshot every route at phone and desktop width after every change

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.

Difficulty:
intermediate

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

  1. 01

    Install the browser driver agent-browser

    npm i -g agent-browser && agent-browser install

  2. 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.

  3. 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.

  4. 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
fi

Failure 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"
}