Files

UI diff snapshot tests

One committed visual-regression baseline per page, each captured full-viewport at 1280×800 with the color scheme pinned to light, gated together in CI.

Each page's data calls are stubbed via page.route with fixed fixtures, so the rendered view is a pure function of the committed bundle and needs no element masking. live_server still serves the SPA bundle; only /v1/info / /v1/me reach the real (and deterministic) server. The shared bits (fixed viewport + light palette, the JSON-route helper, the pre-capture fonts/caret settle) live in conftest.py, so each test_*_snapshot.py only declares its own stubs.

Pages covered:

  • Empty landing (/) — the open left sidebar plus the NewChatLandingScreen ("What should we do?") hero and composer. test_landing_snapshot.py
  • Chat conversation (/c/{id}) — a fully-mocked one-turn transcript (user question + assistant markdown reply) rendered as message bubbles, with the composer below. test_chat_snapshot.py

Baselines are committed under snapshots/<test_module>/<test_name>/<name>[chromium][linux].png.

Why a single pinned renderer

Screenshots differ across rendering environments (font rasterizer, hinting, anti-aliasing), and no diff threshold can reconcile two rendering engines. So we render everywhere in one environment: a digest-pinned Playwright image (mcr.microsoft.com/playwright/python, which bakes in Chromium + fonts). CI renders in it, and you can reproduce that exact render locally with Docker — see Updating the baseline. Because the renderer is the image, your host OS doesn't matter; you just need Docker (or let CI do it).

The test is marked @pytest.mark.visual; the main e2e-ui suite (unpinned ubuntu-latest) excludes it via -m "not visual". Only ui-snapshot.yml runs it.

Is this check merge-blocking?

The check UI Snapshot (visual baselines) blocks merges only if it's listed in the repo's required-checks set (branch protection / .github/scripts/merge-ready, which is generated and synced separately). Until it's added there it's an advisory red check — visible, but not enforced. Registering it as required is a one-line change to that synced config, outside this directory.

It's safe to register as required: a PR that touches none of the render inputs skips the render via the detect job's if gate, and a job skipped by if reports success — so non-UI PRs satisfy the check instead of sitting "pending" (which is what an on: paths: filter would cause, blocking merges).

How the gate behaves

  • On every PR that touches a render input (web, the visual tests + fixtures, or the pinned toolchain — see the detect job in ui-snapshot.yml), ui-snapshot.yml renders each page and compares it to its committed baseline. Any pixel difference on any page fails the check; PRs that touch none of those skip the render (reported as a passing skip).
  • Every run (pass or fail) uploads one artifact and links it in the job summary, so the screenshots are always one click away: ui-snapshot-<run_id> carries this run's renders (snapshots/); on a mismatch snapshot_failures/ also holds the expected_ (baseline), actual_ (current) and diff_ PNGs for each failing page. That single artifact is baseline + current + diff.
  • The baselines are never changed by the compare gate. The only ways to change them are the update flows below.

Updating the baseline

When a UI change is intentional, pick whichever path fits — all render in the pinned image, so the result matches the gate. The label and Docker paths rewrite only the baselines that drift (or are missing) and leave already-passing ones byte-for-byte untouched. Review each changed image before committing.

  1. Push your branch and open the PR.
  2. Add the update-ui-snapshot label. ui-snapshot-update.yml re-renders in the same pinned image, regenerates only the baselines that drift (or are missing) — passing ones are left untouched — and commits the changed PNGs back to your branch, then removes the label and comments the result.
  3. Review the committed PNG(s) in the bot's commit.
  4. The bot pushes with the OMNIGENT_BOT_APP token, so the push re-fires the PR's checks automatically — no manual re-run. (If the App isn't configured it falls back to GITHUB_TOKEN, which won't re-trigger CI; the bot's comment says so and you push any commit to re-run.)

This works for same-repo branches only — Actions tokens can't push to a fork.

Anywhere with Docker — regenerate locally (works for forks)

tests/e2e_ui/visual/regen_baseline_docker.sh

This renders inside the exact pinned image CI uses, so the PNGs it writes match the gate byte-for-byte. Only Docker is required (it builds the SPA in a Node container, then renders the suite and rewrites only the baselines that drift — passing ones stay untouched). Review the image(s), then commit and push — your push re-runs the checks. Pass --skip-build to reuse an existing web build.

Fork PR without Docker — adopt the run's render

The failing compare run already rendered your change in the pinned image, and because it runs under GitHub Actions the plugin rewrote each drifting baseline in place under snapshots/. Pull that tree in:

tests/e2e_ui/visual/update_baseline_from_pr.sh <pr-number>

It finds the PR's UI Snapshot run, downloads the artifact, and restores its runner-rendered snapshots/ tree over the committed baselines — only the drifting ones differ. Review the image(s), then commit and push. (Manual equivalent: download the ui-snapshot-<run_id> artifact and commit its snapshots/ tree over tests/e2e_ui/visual/snapshots/.)

Workflow dispatch (non-PR branches)

GitHub → Actions → UI SnapshotRun workflow, set ref to your branch (CLI: gh workflow run ui-snapshot.yml -f ref=<your-branch>). It runs with --update-snapshots (intentionally fails); the regenerated PNG is in the ui-snapshot-<run_id> artifact to download, review, and commit. Any collaborator can dispatch against an arbitrary ref, but since the PNG is human-reviewed before it lands, an unreviewed ref can't change the baseline on its own.

Failure comments

Whenever the check fails (same-repo or fork), ui-snapshot-fail-comment.yml upserts a PR comment pointing back to these paths. It runs as workflow_run so it can comment without ever executing PR/fork code, which means it only activates once merged to main (it does not fire on its own PR).

Adding a new page snapshot

Each page is one @pytest.mark.visual test in its own test_<page>_snapshot.py. The gate, the update flows, and the artifact already cover every test in this directory, so a new page is just a test + its committed baseline — no workflow changes.

  1. Add test_<page>_snapshot.py. Take snapshot_page (fixed viewport + light palette), live_server, fulfill_json, settle_for_snapshot, and assert_snapshot from conftest.py.
  2. page.route-stub every call the page makes so the view is a pure function of the bundle — no real backend data, no live stream. Drive any dynamic data from fixed fixtures; the chat test (test_chat_snapshot.py) is the worked example (mocked session/items/agent/health + a [DONE] stream). Watch for non-determinism: relative timestamps, streaming, working shimmers, randomized ids.
  3. Navigate, wait for the page's content to finish painting (a stable selector, not a timer), call settle_for_snapshot(page), then assert_snapshot(page).
  4. Generate the baseline in the pinned image — label the PR or run regen_baseline_docker.sh — then review the PNG and commit it.

Running locally without Docker (debugging only — never commit the result)

You can exercise the test on the host to debug it, but a baseline rendered anywhere other than the pinned image will not match the gate, so never commit a PNG produced this way — a stray git add -A would commit a wrong-renderer baseline and break CI. Use the Docker path above to produce a committable PNG.

uv sync --extra all --extra dev
uv run playwright install --with-deps chromium
cd web && npm ci --legacy-peer-deps && npm run build && cd ..
# First run with no baseline creates one (and fails); subsequent runs compare:
uv run pytest tests/e2e_ui/visual -m visual --ui-skip-build