Set up UI Verify visual regression testing in a repo end to end — detect or scaffold the capture path (Storybook stories, a Playwright suite, or Vitest browser-mode component tests), install the...
Goal: take a repo from no visual testing to a green UI Verify check on every PR, with the first stories authored the cheap + deterministic way so the setup doesn't start flaky or expensive. This skill is the conductor — it decides the capture path, wires CI, then hands the authoring and determinism work to the focused skills.
UI Verify captures UI four ways. Choose by what the repo already has:
.storybook/ or @storybook/* in package.json, or is a
component/design-system library. Each story is a snapshot. This is the default; prefer it when
components exist in isolation.@playwright/test suite and you want to test assembled real
pages (the app, staging, or prod), not isolated components. Each test archives its final UI state
for UI Verify to replay + diff.@vitest/browser
with a Playwright provider), and you want those component renders diffed without adopting Storybook.
Each browser-mode test archives its final DOM. Prefer this over scaffolding Storybook only when a
browser-mode suite already exists.--screenshots; UI Verify diffs, judges, and
baselines them with no rendering. There are no stories to author and determinism is owned by your
capture harness, so Steps 2-3 (client SDK + story authoring) don't apply — install the CLI
(npm i -D uiverify), then jump to Step 4 and wire the CI upload with --screenshots ./screenshots
in place of --static-dir. Upload only the screens a PR changed and the rest carry forward.If none exists and the repo is a browser app, scaffold Storybook (npx storybook@latest init) — it's
the lower-friction path and gives you deterministic isolation for free (see storybook-visual-testing).
Storybook:
npm i -D uiverify
Playwright — swap the import so every test also archives its UI state:
- import { test, expect } from '@playwright/test';
+ import { test, expect } from '@uiverify/playwright';
npm i -D @uiverify/playwright uiverify
Vitest — add the plugin to vitest.config.ts so every browser-mode test archives its final DOM:
import { playwright } from '@vitest/browser-playwright';
import { uiverifyPlugin } from '@uiverify/vitest/plugin';
export default defineConfig({
plugins: [uiverifyPlugin()],
test: { browser: { enabled: true, provider: playwright(), instances: [{ browser: 'chromium' }] } },
});
npm i -D @uiverify/vitest @vitest/browser-playwright uiverify
Don't snapshot whatever exists as-is. Author (or refactor) the first few stories using the two authoring skills, in this order:
economical-visual-tests — collapse variant matrices into gallery/data-driven stories so you snapshot
the fewest billable shots for full coverage. Do this before determinism work — fewer stories is
less to make deterministic.storybook-visual-testing (Storybook), playwright-visual-testing (Playwright), or
vitest-visual-testing (Vitest) — remove the run-to-run variation the tool can't neutralize from
outside your app (mostly: the clock, live data, infinite animations).Add a CI workflow so every PR gets a check, and so it runs once on your default branch — that
first default-branch build is what becomes the baseline every PR diffs against, so without the push
trigger every PR shows all-new until a merge happens to seed it. Store the project's UIVERIFY_API_KEY
(a uv_proj_… key from the dashboard) as a repo secret, then run the uiverify CLI (installed in
Step 2). Change [main] to whatever your default branch is (master, develop) — a repo whose default
isn't main gets a push trigger that never fires. Accept that first build once, or add
--auto-accept-changes to the upload on your default branch to seed it automatically.
# .github/workflows/visual.yml
name: Visual
on:
push:
branches: [main] # your default branch, so the first build seeds the baseline
pull_request:
jobs:
uiverify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history so baseline ancestry works
- uses: actions/setup-node@v4
with: { node-version: 24, cache: npm }
- run: npm ci
- run: npm run build-storybook -- --stats-json # ./storybook-static + the dependency graph
- run: npx uiverify upload --static-dir storybook-static
env:
UIVERIFY_API_KEY: ${{ secrets.UIVERIFY_API_KEY }}
# Playwright / Vitest instead (browsers aren't preinstalled on the runner): run
# "npx playwright install --with-deps && npx playwright test" (or "npx vitest run"), then
# "npx uiverify upload --static-dir uiverify-archive".
# Screenshot upload (native / mobile / React Native): produce your PNGs (Detox, Maestro, etc.),
# then "npx uiverify upload --screenshots ./screenshots" in place of the build+upload steps.
Keep --stats-json on the build - it is the default, not a tuning step (see "Render only what the PR
could have changed" below). Locally the same thing is:
npm run build-storybook -- --stats-json && UIVERIFY_API_KEY=uv_proj_… npx uiverify upload --static-dir storybook-static
# Playwright: npx playwright test && npx uiverify upload --static-dir uiverify-archive
# Vitest: npx playwright install --with-deps && npx vitest run && npx uiverify upload --static-dir uiverify-archive
Optional — run only on PRs that can change the UI. The workflow above fires on every PR, and a Playwright archive has no dependency graph of its own, so it renders in full unless you add one (a Vitest or Storybook upload already renders only what the PR could have changed — see below). On a busy repo, scope the job to UI-affecting changes. Two ways, and they differ on required checks:
paths: filter — add paths: to the trigger. Simplest, but it skips the whole job, so it
reports no status. If the UI Verify check is a required status check, a PR touching none of those
paths is stuck "Expected" and can't merge. Use only when the check isn't required.dorny/paths-filter (required-check-safe) — a first job computes whether UI files changed; the
upload job always runs but guards each step on that result, so a backend-only PR no-ops yet still
reports green and never wedges the required check. Prefer this.# .github/workflows/visual.yml
on:
push:
branches: [main] # advance the baseline as PRs merge
pull_request:
jobs:
changes:
runs-on: ubuntu-latest
outputs: { ui: '${{ steps.f.outputs.ui }}' }
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: dorny/paths-filter@v4
id: f
with:
filters: |
ui:
- 'src/components/**'
- 'src/app/**'
- 'packages/ui/**' # shared UI packages
- '**/*.css'
- '.github/workflows/visual.yml' # the workflow itself
uiverify:
needs: changes
runs-on: ubuntu-latest
steps: # every step below is also guarded: if: ${{ needs.changes.outputs.ui == 'true' }}
- if: ${{ needs.changes.outputs.ui == 'true' }}
uses: actions/checkout@v4
with: { fetch-depth: 0 }
# ...the setup-node / install / build / upload steps from Step 4, each guarded the same way.
# On main, add --auto-accept-changes to the upload so the post-merge render becomes the baseline.
A missed render is silent — bias to include. An unnecessary run costs a few CI minutes; a missed
one renders nothing, the check passes green, and the unseen change becomes the next baseline every branch
inherits. So list every dir the UI is built from (components, pages, shared UI packages, global CSS) and
the workflow file itself — and don't exclude a mixed folder just because it's mostly backend: a
lib/ holding both a cn() helper the UI imports and unrelated db/AI code stays in the filter. If that
over-runs, split the UI-facing code onto its own path, then filter on that — separate for granularity;
never trade coverage for speed.
Render only what the PR could have changed — on by default for Storybook and Vitest. Whenever the upload carries a dependency graph, UI Verify renders just the stories your commit's changed files could affect and carries the rest of the baselines forward, so a PR's build time and bill track the size of the diff, not the size of the suite. It is safe from the first run - when it can't prove a story is unaffected it renders it, and a first build has nothing to carry, so it renders everything anyway. Local uploads are safe on the default too: files you haven't committed count as changed, so what they affect renders.
For Storybook the graph comes from the build, and an agent must not drop it: --stats-json writes
preview-stats.json, the dependency graph the server reads. Without it every story renders, and the
upload log says so on every run. If the repo already has a visual workflow, edit its build and upload
steps in place rather than appending new ones, or the job builds Storybook twice and registers two builds
per PR.
Vitest needs no build flag: @uiverify/vitest 1.6+ writes the Vite module graph into the archive
on its own, so the plain upload is enough:
- run: npx uiverify upload --static-dir uiverify-archive
env:
UIVERIFY_API_KEY: ${{ secrets.UIVERIFY_API_KEY }}
For Vitest the skip is only as granular as your test files: the server traces a changed source file to
the tests that import it and carries the rest forward, so keep one page/component per
*.visual.test.tsx file (each importing only what it renders). A file that colocates next to its
component (SecurityPage.visual.test.tsx beside SecurityPage.tsx) lets a one-component change skip every
other page; a single mega-test that imports half the app re-renders on every commit. A test whose imports
git can't follow (bundler output, as under Angular's unit-test builder, a workspace package's built
dist/, gitignored generated code) always renders; the test run prints [uiverify] The tests in N file(s) always render: ... with the reason. It does nothing for the Playwright path out of the box - a Playwright
archive has no dependency graph, so every test renders unless you add a preview-stats.json to the
archive directory yourself (module graph format).
Check the Storybook major before you write the build step - the flag is --stats-json on Storybook 8+,
but --webpack-stats-json on 7.x, and passing the wrong one fails the build step before the upload ever
runs.
Once wired, every PR gets a UI Verify check. The first run on your default branch captures the baseline; after that each PR is diffed against it, and a real visual regression (or an operational failure like a broken build or network error) turns the job red. Strict-by-default is intentional: a silently dropped upload must not leave CI green. If the first few diffs are noisy, don't disable the check — loop back to the determinism skill (Step 3) to remove the flake at its source.
The check gates the PR; the MCP is how your coding agent reviews it without leaving the terminal —
list builds, look at the actual before/after pixels, post them to the PR, accept baselines. Add it as a
real MCP server (Claude Code, Cursor, Codex, …) — don't hand-roll curl, or the image tools come back
as blocks a shell can't render. One command for Claude Code, run in the project root:
claude mcp add --scope project --transport http uiverify https://uiverify.ai/mcp
Then restart Claude Code, run /mcp, pick uiverify, and log in to UI Verify in the browser. That one
login reaches every project in every team the user belongs to, so it is done once, not per project, and
there is no API key in the config: the committed .mcp.json holds only the URL and each teammate logs in
with their own account.
Then hand the review loop to triage-visual-changes (it has the per-client setup and the recipes:
bucket real regressions vs noise, put the before/after in the PR, bulk-accept). On a passed build the
agent can still pull any story's current image by id to confirm it looks right.
Make it stick — install making-ui-changes and point your AGENTS.md / CLAUDE.md at it. The setup
only pays off if new UI keeps getting captured, and it will only get captured if the agent reads the
discipline on every UI change instead of when a human remembers to. So don't leave the customer to
hand-edit CLAUDE.md — do it as part of setup: install the making-ui-changes skill (the playbook
for changing UI safely — reuse before you create, add the story/capture in the same change, check the
blast radius, prove it with a visual test), then add a one-line rule to both AGENTS.md and
CLAUDE.md so every tool (Claude, Codex, Cursor, …) sees it:
## UI changes
- Before changing any component, page, or styles, read the `making-ui-changes` skill and follow it:
reuse before you create, add/update the story or capture in the same change, check the blast radius on
shared components, and prove the change with a visual test before calling it done.
That turns a one-time wiring into a habit the agent follows on every change.
economical-visual-tests applied).