@corralimited/snapdiff-playwright
v0.1.6
Published
SnapDiff visual regression reporter for Playwright. Captures PNGs during your tests, ships them to SnapDiff, gates merges on visual changes.
Downloads
276
Maintainers
Readme
@corralimited/snapdiff-playwright
SnapDiff visual regression for Playwright. Captures PNGs during your tests, uploads them to SnapDiff, and gates merges on visual changes.
The reporter is a thin layer over Playwright. It does not replace your test runner, your assertions, or your authentication setup. Add await snapshot(page, 'name') wherever you want a visual check, and the reporter handles upload, build creation, polling, and gating.
When to use this package
The URL-based SnapDiff GitHub Action covers public routes such as marketing sites, documentation, and any unauthenticated page. It cannot reach routes behind a login.
The Playwright reporter covers authenticated routes by reusing your existing end-to-end tests:
- Your tests already log in, whether through
storageState, OAuth, or a login flow - You add one line per page you want a visual snapshot of
- The reporter captures the page in its authenticated state and uploads it
The two packages can be used together. Keep the GitHub Action for routes such as / and /pricing, and use the reporter for /dashboard, /account, and similar authenticated pages. Both write to the same SnapDiff project.
Install
npm install -D @corralimited/snapdiff-playwright
# or
pnpm add -D @corralimited/snapdiff-playwright
# or
yarn add -D @corralimited/snapdiff-playwrightRequires @playwright/test 1.40 or later.
Configure the reporter
Add it to playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
reporter: [
['list'],
[
'@corralimited/snapdiff-playwright/reporter',
{
project: 'my-app', // your SnapDiff project slug
// apiKey: process.env.SNAPDIFF_API_KEY, // defaults to SNAPDIFF_API_KEY
// apiUrl: 'https://snapdiff.example.com', // self-hosted? set this
},
],
],
});Capturing snapshots
The recommended API is the fixture extension:
import { test, expect } from '@corralimited/snapdiff-playwright';
test('account page renders correctly', async ({ page, snapshot }) => {
await page.goto('/account');
await expect(page.locator('h1')).toBeVisible();
await snapshot('account');
});
test('billing settings render correctly', async ({ page, snapshot }) => {
await page.goto('/account/billing');
await snapshot('account-billing');
});The test import extends @playwright/test with a snapshot fixture. Existing fixtures, hooks, and configuration continue to work.
Standalone helper
If you would rather not change the test import, the package also exports a standalone snapshot(page, name):
import { test } from '@playwright/test';
import { snapshot } from '@corralimited/snapdiff-playwright';
test('account page', async ({ page }) => {
await page.goto('/account');
await snapshot(page, 'account');
});In some peer-dependency configurations, the standalone helper can miss tests when @playwright/test is duplicated under node_modules. The fixture API is unaffected and is the preferred approach.
Snapshot names must be unique across the entire test run. They map directly to baselines in SnapDiff. Use prefixes to organize related snapshots, for example account-overview, account-billing, and settings-profile.
Keep functional assertions content-agnostic
Snapshots are taken only when the test passes. If a functional assertion fails, for example toContainText('Ship') after a heading was renamed to 'Catch', the test fails before snapshot() is reached and SnapDiff never sees the page. The visual change that caused the assertion failure is then never captured for review.
The recommendation is to assert on structure rather than copy:
// Brittle: breaks whenever copy changes
await expect(page.locator('h1')).toContainText('Welcome to Acme');
// Resilient: verifies rendering and lets SnapDiff catch copy changes
await expect(page.locator('h1')).toBeVisible();To capture a snapshot regardless of test outcome, for example to inspect the page when an assertion fails, wrap the assertion in try / finally:
test('account page', async ({ page, snapshot }) => {
await page.goto('/account');
try {
await expect(page.locator('[data-testid=balance]')).toBeVisible();
} finally {
await snapshot('account');
}
});What the reporter stabilizes for you
Before each snapshot the reporter runs a stabilization pass that fixes the most common sources of flaky captures:
- Waits for
document.fonts.readyso web fonts finish loading before capture - Forces all CSS animations and transitions to complete instantly
- Hides the text caret and any visible scrollbars
- Blurs the focused element to remove focus rings
- Moves the mouse off the page to clear hover/active states
- For
fullPage: truecaptures, scrolls to the bottom and back to triggerIntersectionObserver-based lazy-load - Waits for every
<img>to finish decoding (with a 3 s safety cap)
This is automatic and not configurable — these fixes are universally desired. You still need to handle anything specific to your app:
await page.waitForSelector('[data-loaded]')— wait for a data-driven UI to settleignoreSelectors(per-page) — exclude regions that legitimately differ every run (timestamps, ad slots)- Time/locale freezing — pin
Date.now()if your page renders relative timestamps and you don't want to ignoreSelector them
Snapshot options
await snapshot(page, 'dashboard', {
fullPage: true, // capture entire scrollable page
selector: '[data-testid="main-content"]', // capture only this element
clip: { x: 0, y: 0, width: 1280, height: 720 }, // explicit region
delayMs: 500, // wait before capture (animations)
});Authentication on protected previews
If your preview deployments are behind Vercel Authentication, Cloudflare Access, basic auth, or any header-based bypass, configure it in your Playwright config — the reporter inherits whatever your tests already do. There is no SnapDiff-specific input.
Vercel Deployment Protection
// playwright.config.ts
export default defineConfig({
use: {
extraHTTPHeaders: {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET!,
'x-vercel-set-bypass-cookie': 'true',
},
},
});Generate the secret in Vercel → Settings → Deployment Protection → Protection Bypass for Automation. Reference: Vercel docs.
Cloudflare Access
use: {
extraHTTPHeaders: {
'CF-Access-Client-Id': process.env.CF_ACCESS_CLIENT_ID!,
'CF-Access-Client-Secret': process.env.CF_ACCESS_CLIENT_SECRET!,
},
},Basic auth, custom tokens
Same pattern — drop any header into extraHTTPHeaders.
Application authentication (login behind the page)
The reporter does not handle login. Authentication is performed by your existing Playwright setup:
// playwright.config.ts
export default defineConfig({
use: {
storageState: 'auth.json', // already-logged-in state
},
});// global setup (one-time login)
test.beforeAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto('/login');
await page.fill('[name=email]', process.env.TEST_USER_EMAIL!);
await page.fill('[name=password]', process.env.TEST_USER_PASSWORD!);
await page.click('button[type=submit]');
await page.waitForURL('/dashboard');
await ctx.storageState({ path: 'auth.json' });
});Whatever authentication mechanism the tests already use, whether cookies, localStorage tokens, or OAuth, is inherited by the reporter. SnapDiff has no separate authentication concept.
Reporter options
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| project | string | required | SnapDiff project slug or ID |
| apiKey | string | process.env.SNAPDIFF_API_KEY | API key |
| apiUrl | string | https://api.snapdiff.ai | Override for self-hosted SnapDiff |
| branch | string | auto-detected | Override CI detection |
| commitSha | string | auto-detected | Override CI detection |
| commitMessage | string | auto-detected | Override CI detection |
| pullRequestUrl | string | auto-detected | Override CI detection |
| wait | boolean | true | Poll the build to completion and print the per-page result. Visual changes do not fail the run — see Merge gating |
| waitTimeoutMinutes | number | 5 | Maximum polling duration |
| disabled | boolean | false | Disable the reporter, for example during local debugging |
Merge gating
When the reporter detects visual changes, the workflow does not fail and the Playwright check remains green. SnapDiff posts a separate commit status named snapdiff/visual-test through the GitHub API:
- pending — the build is processing, or changes are awaiting review
- success — no changes detected, or all changes have been approved in the dashboard
- error — the build failed for reasons unrelated to visual differences
Add snapdiff/visual-test as a required check in your branch protection rules. While the status is pending, the merge button is blocked. A reviewer opens the dashboard to approve changes (which become the new baseline) or reject them. Once approved, the status updates to success and the merge is unblocked.
To enable status posting, connect a GitHub repository and personal access token in your project's Settings page in the SnapDiff dashboard (config is per-project). The token requires the repo:status scope.
CI integration
The reporter auto-detects CI metadata for GitHub Actions, CircleCI, GitLab CI, Vercel, and Buildkite. For other systems, supply explicit overrides through reporter options.
GitHub Actions example
# .github/workflows/visual.yml
name: Visual diff
on:
pull_request:
push:
branches: [main]
jobs:
visual:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
SNAPDIFF_API_KEY: ${{ secrets.SNAPDIFF_API_KEY }}
# If your tests need credentials to log in:
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}The reporter creates a build and waits for diffs to complete. The workflow itself remains green regardless of visual changes; merge gating is handled by the snapdiff/visual-test commit status described above.
How it works
- Tests run normally.
await snapshot(page, name)callspage.screenshot()and attaches the PNG to the test result. - After each passing test, the reporter uploads attached PNGs to
POST /v1/screenshot/upload. SnapDiff returns a screenshotid. - After the test run, the reporter creates a build through
POST /v1/projects/:project/builds, referencing the returned ids assnapshots[].screenshot_id. - The reporter polls until diffing is complete and prints the result. Visual changes leave the run green and block the merge via the
snapdiff/visual-testcommit status; only infrastructure failures (upload error, build error, poll timeout) fail the run.
Pixels are captured on the CI machine and diffed by SnapDiff. There is no DOM serialization or cloud rendering — captures come from the same browsers your tests already run.
Troubleshooting
No snapshots captured. The reporter is registered but snapshot() was not called. Add await snapshot(page, 'name') inside a test that passes.
Duplicate snapshot names. Two tests captured a snapshot with the same name. Names are global; rename or add a prefix.
No API key found. Set SNAPDIFF_API_KEY in the environment, or pass apiKey in reporter options.
Build poll failed. Usually transient. Rerun the test job. If the issue persists, contact support.
Authenticated routes show the login form. The storageState file is not being loaded. Confirm playwright.config.ts has use.storageState pointing at a valid auth state file, and that the file is generated before playwright test runs.
License
MIT
