Capture screenshots for Difora
Render in your CI, keep names stable, and upload PNGs for review.
Install (Node 24.22.1+): run
npm install --save-dev difora to add
difora from npm (or
read the
GitHub README if
npm does not load) alongside your browser test runner. The capture
helpers below are included in CLI 0.4.0 and newer.
Playwright
Complete Playwright setup: a static site
From your site's repository root, install the test tools (Node
24.22.1+). If the repo already depends on playwright, check
its installed version with npm ls playwright and install
@playwright/test at that exact version, for example
npm install --save-dev @playwright/test@<same version as
playwright>. Use that pinned version in the install command below:
npm install --save-dev difora @playwright/test http-server
npx playwright install chromium
Commit the updated package files. Build your site into
dist/ using your existing build command, such as
npm run build. The directory must contain
index.html.
Save this as playwright.config.ts in the repository root.
Playwright starts the static server, waits for its URL, and runs each
test in two Chromium projects with distinct widths:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
webServer: {
command: 'npx http-server dist -p 4300 -a 127.0.0.1 -c-1',
url: 'http://127.0.0.1:4300',
reuseExistingServer: !process.env.CI,
},
use: { baseURL: 'http://127.0.0.1:4300' },
projects: [
{
name: 'mobile',
use: { browserName: 'chromium', viewport: { width: 640, height: 800 } },
},
{
name: 'desktop',
use: { browserName: 'chromium', viewport: { width: 1280, height: 800 } },
},
],
});
Save the test below as tests/visual/home.spec.ts. For an
app with a development server, replace
webServer.command with your app's start command and set
both URLs to its address.
import { test, expect } from '@playwright/test';
import { diforaScreenshot } from 'difora/playwright';
test('home', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
await page.evaluate(() => document.fonts.ready);
await diforaScreenshot(page, 'home', {
fullPage: true,
viewportSuffix: true,
});
});
The output directory is dir, then
DIFORA_SCREENSHOT_DIR, then ./screenshots. The
promise returns the absolute file path. Set a fixed Playwright viewport
to use viewportSuffix; a 1280-pixel viewport produces
home@1280.png and the other project produces
home@640.png. Project names and viewport heights are not
added to filenames: two projects with the same width and capture name
overwrite the same file. Use distinct names for browser/theme variants,
such as home-firefox, even with
viewportSuffix.
Build first, clear the generated screenshots, then capture:
npm run build
node -e "require('node:fs').rmSync('screenshots', { recursive: true, force: true })"
npx playwright test
This produces screenshots/home@640.png and
screenshots/home@1280.png. In Linux CI, install browsers
with npx playwright install --with-deps chromium after
npm ci; see the full CI job.
Install other browsers only if your Playwright projects use them.
To bind capture to a fixture, pass your own test after adding any custom fixtures:
import { test as base } from '@playwright/test';
import { withDifora } from 'difora/playwright';
const test = withDifora(base);
test('home', async ({ page, diforaScreenshot }) => {
await page.goto('/');
await diforaScreenshot('home-masked', {
viewportSuffix: true,
mask: [page.getByTestId('clock')],
clip: { x: 0, y: 0, width: 600, height: 400 },
});
});
Use the fixture example instead of the direct capture test, and select
the dynamic elements in your app (here,
data-testid="clock"). Both direct capture and the
withDifora fixture accept fullPage,
mask (Playwright locators),
clip (x/y/width/height), viewportSuffix and
dir. Choose full-page capture or clipping. Animations are
disabled and the text caret is hidden during capture. Your test still
needs to wait for application data, fonts and images to be ready. Keep
browser, fonts, viewport and test data stable between runs.
Monorepos and no-build sites
webServer may be an array, with one entry per app/origin.
Use absolute URLs in those tests instead of one shared
baseURL. A static site without a build step can serve its
source directory directly. This minimal
playwright.config.ts serves
site-a/index.html and site-b/index.html;
change the directories to your source or built output paths and build
only the apps that need it:
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests/visual',
webServer: [
{
command: 'npx http-server site-a -p 4300 -a 127.0.0.1 -c-1',
url: 'http://127.0.0.1:4300',
reuseExistingServer: !process.env.CI,
},
{
command: 'npx http-server site-b -p 4301 -a 127.0.0.1 -c-1 -P http://127.0.0.1:4301?',
url: 'http://127.0.0.1:4301',
reuseExistingServer: !process.env.CI,
},
],
use: { browserName: 'chromium', viewport: { width: 1280, height: 800 } },
});
The second server uses an SPA fallback:
-P http://127.0.0.1:PORT? serves the index page for deep
links. Match PORT to that server and keep the trailing
?. In your capture tests use
await page.goto('http://127.0.0.1:4300/') or
await page.goto('http://127.0.0.1:4301/settings'), and
distinct snapshot names such as site-a/home and
site-b/settings.
Manual Playwright capture
import { mkdir } from 'node:fs/promises';
await mkdir('screenshots', { recursive: true });
await page.screenshot({
path: 'screenshots/home.png',
fullPage: true,
animations: 'disabled',
caret: 'hide',
});
Storybook test runner
For projects using @storybook/test-runner, set its
post-visit hook in .storybook/test-runner.ts. Start your
Storybook first, then run test-storybook.
import type { TestRunnerConfig } from '@storybook/test-runner';
import { createPostVisit } from 'difora/storybook';
export default {
postVisit: createPostVisit({
fullPage: true,
skip: ({ id }) => id === 'components-clock--live',
}),
} satisfies TestRunnerConfig;
Names follow <title>/<story>. The hook runs
after rendering and the story's play function. skip may
return a boolean or promise. The helper supports the test-runner hook
API; it is not a Storybook Vitest-addon integration.
Cypress recipe
No Difora-specific Cypress plugin is required. Choose a dedicated screenshot directory and avoid uploading automatic failure captures as intended baselines.
// cypress.config.ts
import { defineConfig } from 'cypress';
export default defineConfig({
screenshotsFolder: 'screenshots',
screenshotOnRunFailure: false,
trashAssetsBeforeRuns: true,
});
// Your Cypress test, after the page is ready:
cy.screenshot('home', { capture: 'viewport' });
Upload screenshots after cypress run. Cypress
includes the spec path in screenshot names; keep it stable.
Capture metadata and variants
Difora 0.10.1+ supports optional capture metadata. Existing capture calls and PNG-only uploads continue to work. Enable recording on a direct capture, the fixture defaults, or the Storybook hook:
const test = withDifora(base, {
captureMetadata: { environment: { id: 'visual-ci', revision: 'image-2026-09' } },
});
test('home', async ({ diforaScreenshot }) => {
await diforaScreenshot('home', {
variant: 'chromium-desktop-dark',
captureMetadata: { theme: 'dark' },
});
});
captureMetadata: true records available browser
name/version, viewport in CSS pixels, device scale factor, color
preference, locale, time zone and capture settings. The object form
additionally declares app theme, renderer
os: { name, version? } and
environment: { id, revision? }. These labels must be
non-secret. The helper does not infer the remote renderer’s OS from the
uploader or inspect your DOM, locator text or environment variables.
Missing observations stay unknown. Per-capture metadata fields override
fixture defaults; false disables recording unless a variant
is requested.
Metadata by itself never changes a name or baseline. An explicit
variant enables recording and appends
@v-<slug>-<digest> before .png,
after an optional viewport width. Keep each variant stable across runs
and browser upgrades. Adding a variant to an existing name produces a
new snapshot and a removed old name. A browser version is descriptive
evidence, not an implicit identity.
In review, Variants combines exact Browser, Viewport, Theme, Variant and Environment filters. Removed entries use their baseline’s recorded settings. Capture details compares the baseline with the current capture; earlier discussions retain their original values. Missing values say “Not recorded.” A recorded match does not guarantee reproducible rendering. “Approve selected” applies to visible checked snapshots; “Approve all” includes snapshots hidden by filters.
Sidecars and other PNG producers
Recording writes <filename>.png.difora.json beside
each PNG. Other tools can produce the same envelope, using the SHA-256
of the exact PNG bytes:
{
"version": 1,
"imageHash": "<64 lowercase hexadecimal SHA-256 characters>",
"metadata": {
"version": 1,
"source": "provided",
"browser": { "name": "chromium", "version": "your-pinned-version" },
"viewport": { "width": 1280, "height": 720 },
"theme": "dark",
"environment": { "id": "visual-ci" },
"producer": { "name": "your-capture-tool", "version": "1.0.0" }
}
}
The metadata requires version: 1 and
source: "helper" | "provided". Other fields are optional:
variant, browser, viewport,
deviceScaleFactor, colorScheme,
theme, locale, timezone,
os, environment, capture and
producer. Browser and OS accept a name and optional
version; producer requires both. Viewport requires positive integer
width and height, each at most 32,768. Device scale factor must be
greater than 0 and at most 16. Color scheme is light,
dark or no-preference; it is separate from app
theme.
capture accepts boolean fullPage,
clip: { x, y, width, height },
animations: "disabled" | "allow",
caret: "hide" | "initial" and integer
maskLocatorCount (0–10,000). Clip coordinates are
nonnegative, dimensions positive, all at most 1,000,000 CSS pixels.
Full-page capture and clipping are mutually exclusive. Mask count means
supplied locators, not matched elements or mask geometry.
Labels allow 64 Unicode characters, except browser/OS/producer names (32) and environment revision (128). Labels use NFC normalization; empty strings and control characters are rejected. Metadata allows at most 2,048 UTF-8 bytes after canonicalization; the complete sidecar allows 4,096 bytes. Unknown keys, unsupported versions, invalid numbers, empty objects and null fields are rejected. Omit unavailable fields instead of guessing.
Use a clean directory for each run, shard and retry attempt. Recorded
captures refuse overwrites. Preserve PNGs and sidecars together in CI
artifacts and upload only after all captures finish. Stale hashes,
orphaned or malformed sidecars and incomplete capture locks stop upload
before a build is created. The client checks server capability even with
--no-merge-base; an older or disabled server produces exit
code 2 without dropping metadata. The full manifest limit is 5 MiB: use
fixed-count sharding for larger suites.
Names and clean directories
For recurring differences, open Stability history in the review app. The stability guide explains comparable environments, repeated captures and evidence-based stabilization checks.
Add screenshots/, test-results/ and
playwright-report/ to .gitignore.
The helpers preserve slash-separated groups, reject traversal and absolute paths, and sanitise unsupported characters. Changed segments receive a stable hash suffix so different labels do not silently collapse to the same path. Without metadata recording, a repeated name overwrites its previous capture with a warning. Recorded captures reject an existing destination. Use distinct names or viewport/browser suffixes for each intended snapshot.
Start each CI capture run with a fresh screenshot directory. Otherwise stale PNGs can remain in the uploaded manifest and conceal removals. The helpers never clear a directory automatically. The operating system and browser build are part of determinism: capture baselines and later runs in CI, or in the exact same environment CI uses.
Then run npx difora upload ./screenshots with
DIFORA_TOKEN set as a CI secret. See
CI recipes and exit codes.
Reference APIs: Playwright fixtures, Storybook test-runner hooks, Cypress screenshots.
Seeing environment warnings? Match your CI capture environment using a pinned local and CI recipe.
Native and mobile captures
CLI 0.12.0 adds
difora annotate <dir> --platform ios for declared
device metadata. Record PNGs with your own tooling, keep stable variant
names, then upload normally. See
native, mobile and PDF capture recipes
for flags, compatibility and verified toolchains.