Files changed: engine/.embcache.json engine/changelog.md engine/lint-report.md vault/.trash/2026-06-19.md vault/docs/hardware/environment.md vault/docs/hardware/ip-allocation.md vault/docs/matrix/archivist.md vault/docs/matrix/matrix_host.md vault/docs/matrix/mautrix_signal.md vault/docs/matrix/synapse.md vault/docs/matrix/synapse_retention_discovery.md vault/docs/navi/cc-rules.md vault/docs/navi/deployment.md vault/docs/navi/themes.md vault/docs/services/ots-setup.md vault/docs/services/services.md vault/docs/services/usenet.md vault/docs/software/authentik.md vault/docs/software/caddy.md vault/docs/software/central.md vault/docs/software/dns.md vault/docs/software/geo-tools.md vault/docs/software/navi.md vault/docs/software/recon.md vault/docs/software/searxng.md vault/glossary.md vault/notes/echo6-landing-page-data-export.md vault/notes/ia-download-queue.md vault/projects/advbbs-project.md vault/projects/argus.md vault/projects/deploy-livesync.md vault/projects/fleet-patch-audit.md vault/projects/fleet-platform-baseline.md vault/projects/matrix-synapse-deployment.md vault/projects/meshai-config-hot-apply.md vault/projects/meshai-region-routing-plan.md vault/projects/meshai.md vault/projects/meshcore-transport.md vault/projects/meshtastic-headscale-runbook.md vault/projects/mmud-project.md vault/projects/nominatim-v5-reimport.md vault/runbooks/add-peertube-channel.md vault/runbooks/authentik-access-groups.md vault/runbooks/authentik-create-invitation.md vault/runbooks/authentik-oidc-application.md vault/runbooks/authentik-upgrade.md vault/runbooks/central-deploy-cutover.md vault/runbooks/ct-runbook.md vault/runbooks/edge2-access-reference.md vault/runbooks/expose-service-contabo.md vault/runbooks/expose-service-edge2.md vault/runbooks/expose-service-home.md vault/runbooks/fleet-magicdns-resolved-migration.md vault/runbooks/headless-browser-page-verification.md vault/runbooks/headscale-oidc-boot-order.md vault/runbooks/headscale-onboard-node.md vault/runbooks/ia-cli-reference.md vault/runbooks/ia-download-mirror.md vault/runbooks/idahomesh-bridge-setup.md vault/runbooks/idahomesh-vpn-device-setup.md vault/runbooks/lxc-service-migration.md vault/runbooks/mailcow-create-mailbox.md vault/runbooks/meshai-prod-compose-override.md vault/runbooks/meshmonitor-password-reset.md vault/runbooks/meshtastic-sidecar-node.md vault/runbooks/meshtasticd-sim-nodes-runbook.md vault/runbooks/nordvpn-lxc.md vault/runbooks/peertube-remote-runner.md vault/runbooks/pg-backup.md vault/runbooks/pi-nas-omv-runbook.md vault/runbooks/pipeline-patterns.md vault/runbooks/proxmox-create-ubuntu-vm.md vault/runbooks/proxmox-onboard-node.md vault/runbooks/pymc-repeater-kiss-tnc-reenumeration.md vault/runbooks/recon-operations.md vault/runbooks/recon-service-integration.md vault/runbooks/syncthing-add-node.md vault/runbooks/toc-cortex-pve9.2-update.md vault/session-resume/SESSION-HANDOFF-meshai-test.md
6.7 KiB
| title | type | tags | aliases | related | updated | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Headless Browser — Visual Page Verification | runbook |
|
|
2026-07-13 |
Headless Browser — Visual Page Verification
Drive a real headless browser (Playwright + Chromium) from cortex to log in and screenshot a deployed web page, then view the screenshot. Use this to actually look at a page after a deploy — it catches render/layout/styling bugs that curl status codes and unit tests sail right past (a route can return 200/302 and still look broken).
Works for any web UI reachable from cortex (central, navi, authentik, PeerTube, Mailcow, etc.) — just change the base URL, path, and credentials.
Reusing this in a prompt: tell Claude "follow the headless-browser-page-verification runbook to screenshot
<service> <path>" and point it at the creds. Everything needed is self-contained below.
Prerequisites
- Runs on cortex (where Claude Code executes). The target must be reachable from cortex — most fleet UIs are on the mesh (e.g.
http://100.64.0.12:8000) or via a caddy host. python3available.- Credentials for auth-gated pages come from
.ref/credentials(e.g.CENTRAL_OPERATOR_USER/CENTRAL_OPERATOR_PASS). Public/auth-exempt paths (/login,/health) need none. - Installing Playwright + Chromium is a package install — get Matt's OK first per host policy (he authorized it 2026-06-29). No
apt/system-dep install is needed on cortex; the browser is a self-contained download to~/.cache/ms-playwright.
1. One-time setup (per session)
The venv lives in the session scratchpad and is ephemeral — it does not survive across sessions, so re-run this each time.
# pick any working dir (scratchpad is fine)
python3 -m venv pwvenv
pwvenv/bin/pip install --quiet playwright
pwvenv/bin/playwright install chromium # downloads chromium-headless-shell (~110 MB) to ~/.cache/ms-playwright
(python -c "import playwright" succeeding is enough — playwright.__version__ is intentionally absent, not an error.)
2. The screenshot script
Save as shot.py. Parameterized by env vars; logs in only if U/P are set.
import os
from playwright.sync_api import sync_playwright
base = os.environ["BASE"].rstrip("/") # e.g. http://100.64.0.12:8000
path = os.environ.get("PATHV", "/") # e.g. /consumers
out = os.environ["OUT"] # e.g. /tmp/.../page.png
user = os.environ.get("U") # omit U/P for public pages
pw = os.environ.get("P")
with sync_playwright() as p:
b = p.chromium.launch()
pg = b.new_context(viewport={"width": 1366, "height": 1000}).new_page()
if user and pw: # form login (CSRF hidden field + cookie handled by the real browser)
pg.goto(base + "/login", wait_until="networkidle", timeout=20000)
pg.fill('input[name="username"]', user)
pg.fill('input[name="password"]', pw)
pg.click('button[type="submit"]')
pg.wait_for_load_state("networkidle", timeout=20000)
r = pg.goto(base + path, wait_until="networkidle", timeout=25000)
print("HTTP", r.status if r else "?", "| url:", pg.url, "| title:", repr(pg.title()))
pg.screenshot(path=out, full_page=True)
print("saved", out)
b.close()
Selector note: the login step assumes
input[name="username"],input[name="password"], and abutton[type="submit"](correct for the central GUI). For a different app, open/loginonce (public) and adjust the selectors to match its form.
3. Run it, then look
Auth-exempt page (no login):
BASE="http://100.64.0.12:8000" PATHV="/login" OUT="$PWD/login.png" \
pwvenv/bin/python shot.py
Authenticated page (pull creds from .ref/credentials):
BASE="http://100.64.0.12:8000" U="admin" P="<CENTRAL_OPERATOR_PASS>" \
PATHV="/consumers" OUT="$PWD/consumers.png" \
pwvenv/bin/python shot.py
Then view the image — use the Read tool on the .png (it renders images visually). That is the actual verification step: read the screenshot and confirm the page looks right (layout, columns, buttons, data), not just that it returned 200.
If final url came back as /login, the login failed (bad creds / wrong selectors) — fix before trusting the shot.
4. Worked example — central /consumers
- Base:
http://100.64.0.12:8000(mesh) orhttp://central.echo6.mesh:8000. - Creds:
CENTRAL_OPERATOR_USER/CENTRAL_OPERATOR_PASSin.ref/credentials. /login+/healthare auth-exempt; everything else 302s to/login.- This loop caught a real layout bug (tables overflowing their cards) that 34 passing tests + a
302smoke check both missed — fixed and re-verified by screenshot. See central and the deploy flow in central-deploy-cutover.
Fold it into a deploy: after restarting the service, screenshot the changed page and read it before declaring the deploy verified.
5. Extension — driving authenticated actions (not just looking)
For clicking buttons / POSTing forms (e.g. bulk-deleting via an admin page so the action is audit-logged through the app), a plain authenticated HTTP session is simpler than Playwright. Pattern (stdlib urllib + http.cookiejar):
GET /login→ scrape the pre-authcsrf_tokenfrom the form.POST /loginwithusername/password/csrf_token→ session cookie set.GET <page>→ scrape the sessioncsrf_tokenfrom a rendered form (post-auth CSRF is a different, session-bound token).POST <action endpoint>with thatcsrf_tokenfor each action.- Re-
GETthe page and diff to verify the effect.
This routes changes through the real app (CSRF + audit log + server-side guards) instead of bypassing it.
Gotchas
- Ephemeral venv — scratchpad doesn't persist; re-install per session.
full_page=Truestitches the whole scrollable page into one tall PNG — good for long admin tables.wait_until="networkidle"lets HTMX/JS settle before the shot; bump timeouts for slow pages.- Two CSRF layers (central): pre-auth token on
/loginis cookie-bound/itsdangerous; post-auth token is session-bound (inconfig.sessions, rendered into every form). Don't reuse one for the other. - Reachability: prefer the mesh IP /
*.echo6.meshname over LAN IPs. If cortex can't reach the target,curlit first to confirm before blaming the browser. - Headless-shell vs full Chromium:
playwright install chromiumpulls the headless shell, which is enough for screenshots; no extra system libraries were required on cortex.