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.