Screenery

Docs / Testing and review

Vitest browser mode screenshots

Vitest's toMatchScreenshot compares a component with a reference screenshot and fails the test when they differ. Screenery publishes those references, so each component image you approved has a URL you can embed.

Your test stays the gate

Visual regression testing in Vitest needs browser mode, which became stable in Vitest 4.0. You assert with toMatchScreenshot. When a reference is missing, Vitest writes one and fails the test so you review it. After that it compares each new screenshot with the reference.

toMatchScreenshot decides whether the pixels are right. Screenery decides where they are served.

A reference only changes when someone updates it on purpose and commits it. So when you push the references from your default branch, @latest shows exactly what your tests accept. You do not need Screenery's diff gate as well. Vitest is already doing that job.

Where Vitest keeps references

From the Vitest visual regression guide and its config reference for Vitest 5.0, checked October 2026:

src/components/__screenshots__/Button.test.tsx/primary-chromium-linux.png

One folder for every reference

You could push src, but it holds fixtures, icons and other images that are not screenshots, and Screenery would publish all of them. Instead, collect the references in one folder with resolveScreenshotPath:

// vitest.config.ts
import path from 'node:path';
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: 'chromium' }],
      expect: {
        toMatchScreenshot: {
          // screenshots/button/primary-chromium-linux.png
          resolveScreenshotPath: ({ root, testFileName, arg, browserName, platform, ext }) =>
            path.join(
              root,
              'screenshots',
              testFileName.replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '').toLowerCase(),
              `${arg}-${browserName}-${platform}${ext}`,
            ),
        },
      },
    },
  },
});

Button.test.tsx becomes the folder button. Lowercasing the file name here is your choice, made once in config. Screenery itself never rewrites a name. Two test files with the same name in different folders would now share one folder. If you have those, add testFileDirectory to the path.

Moving existing references? Run vitest --update once with the new config to write them at the new paths, then delete the old __screenshots__ folders.

Names that push

Screenery names each screenshot after its path inside the folder you push, minus the extension. Each part must be lowercase letters, digits, ., _ or -. A name that breaks the rule fails the push, and nothing is published.

Always pass a lowercase name. The name Vitest builds from a test title keeps its capitals. When we tried it, test('renders the Secondary button') produced renders-the-Secondary-button-1-chromium-win32.png, and the push refused it. Name the screenshot yourself:

import { expect, test } from 'vitest';
import { page } from 'vitest/browser';

test('primary button', async () => {
  // … render the component with your framework's helper …
  await expect(page.getByRole('button', { name: 'Save' })).toMatchScreenshot('primary');
});
screenshots/button/primary-chromium-linux.png  →  button/primary-chromium-linux
https://cdn.screenery.dev/acme/dashboard/button/primary-chromium-linux@latest.png

The workflow

Run Vitest, then push the reference folder. The permissions are the same as in the minimal workflow:

name: screenshots
on:
  push:
    branches: [main]
  pull_request:

jobs:
  screenshots:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      # … install dependencies …
      - run: npx playwright install --with-deps chromium
      - run: npx vitest run
      - uses: screenery/push@v1
        with:
          project: acme/dashboard
          path: screenshots

If a screenshot differs from its reference, the Vitest step fails and the push never runs. On main the push moves @latest. On a pull request that updates references, @pr-12 and the preview comment show the new ones. Every input is on The GitHub Action.

This is the same pattern as Playwright's toHaveScreenshot goldens. See Playwright goldens and Argos folders.

Other Vitest tools

Several hosted visual testing tools support Vitest browser mode. Two of them, checked October 2026:

Both have a review step that Screenery does not have. Screenery gives you the published image, a URL that a README or design-system docs page can embed. It moves when your default branch does.