130 lines
6.6 KiB
Markdown
130 lines
6.6 KiB
Markdown
|
|
---
|
||
|
|
title: "Headless Browser — Visual Page Verification"
|
||
|
|
type: runbook
|
||
|
|
tags: [tooling]
|
||
|
|
related: ["[[central]]", "[[central-deploy-cutover]]"]
|
||
|
|
updated: 2026-06-29
|
||
|
|
---
|
||
|
|
|
||
|
|
# 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.
|
||
|
|
- `python3` available.
|
||
|
|
- 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.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 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.
|
||
|
|
|
||
|
|
```python
|
||
|
|
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 a `button[type="submit"]` (correct for the central GUI). For a different app, open `/login` once (public) and adjust the selectors to match its form.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## 3. Run it, then look
|
||
|
|
|
||
|
|
**Auth-exempt page (no login):**
|
||
|
|
```bash
|
||
|
|
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`):**
|
||
|
|
```bash
|
||
|
|
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) or `http://central.echo6.mesh:8000`.
|
||
|
|
- Creds: `CENTRAL_OPERATOR_USER` / `CENTRAL_OPERATOR_PASS` in `.ref/credentials`.
|
||
|
|
- `/login` + `/health` are auth-exempt; everything else 302s to `/login`.
|
||
|
|
- This loop caught a real layout bug (tables overflowing their cards) that 34 passing tests + a `302` smoke 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`):
|
||
|
|
|
||
|
|
1. `GET /login` → scrape the pre-auth `csrf_token` from the form.
|
||
|
|
2. `POST /login` with `username`/`password`/`csrf_token` → session cookie set.
|
||
|
|
3. `GET <page>` → scrape the **session** `csrf_token` from a rendered form (post-auth CSRF is a different, session-bound token).
|
||
|
|
4. `POST <action endpoint>` with that `csrf_token` for each action.
|
||
|
|
5. Re-`GET` the 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=True`** stitches 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 `/login` is cookie-bound/`itsdangerous`; post-auth token is session-bound (in `config.sessions`, rendered into every form). Don't reuse one for the other.
|
||
|
|
- **Reachability**: prefer the mesh IP / `*.echo6.mesh` name over LAN IPs. If cortex can't reach the target, `curl` it first to confirm before blaming the browser.
|
||
|
|
- **Headless-shell vs full Chromium**: `playwright install chromium` pulls the headless shell, which is enough for screenshots; no extra system libraries were required on cortex.
|