Screenery

Docs / Testing and review

Playwright goldens and Argos folders

If your tests already keep screenshots, Screenery publishes those files as they are. You keep your assertions. Screenery gives each accepted screenshot a URL you can embed.

Your test stays the gate

Playwright's expect(page).toHaveScreenshot() compares a page with a stored image, called a golden, and fails the test when they differ. Screenery does not replace that assertion or add another one. It publishes the files your gate already accepted.

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

Related guides: Playwright visual regression testing, Playwright screenshots in GitHub Actions and updating snapshots without the wrong-OS trap.

How files become names

Screenery names each screenshot after its path inside the folder you push, with the file extension removed:

screenshots/checkout/payment-form.png  →  checkout/payment-form
https://cdn.screenery.dev/acme/dashboard/checkout/payment-form@latest.png

Names are never rewritten. Each part of the path must be lowercase letters, digits, ., _ or -. A file whose path breaks that rule fails the push and names itself, and nothing is pushed. A capital letter is the usual cause: Checkout/Form.png fails, it does not become checkout/form.

Files that are not images or videos are skipped and listed in the log. The full rules are on URLs and asset names.

An Argos upload folder

An Argos upload folder holds nothing but screenshots: the folder you pass to argos upload, by default ./screenshots, which is where argosScreenshot writes. Push it as it is:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: screenshots

Or with the CLI. --dry-run lists the names and contacts nobody:

npx screenery push ./screenshots --project acme/dashboard --dry-run \
  --commit-timestamp 2026-09-21T00:00:00Z

Outside GitHub Actions, pass --commit-timestamp (or --run-number) so the build has an order. In Actions it is filled in for you.

The visual-diff gate works on the same folder: npx screenery diff ./screenshots --baseline ./screenery-baseline. See Visual testing.

For how the two services differ, see Screenery vs Argos.

Playwright goldens

By default Playwright keeps goldens in a {testFile}-snapshots/ folder next to each test, such as tests/example.spec.ts-snapshots/landing-chromium-linux.png. Your test folder also holds test code and often fixture images, which are not screenshots you want to publish.

Today, give the goldens a folder of their own. Set snapshotDir in playwright.config.ts to a folder outside your tests, and push that folder:

// playwright.config.ts
export default defineConfig({
  snapshotDir: './goldens',
});
- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: goldens

That folder then holds only goldens, so every file in it is a screenshot. A golden's name keeps Playwright's suffixes, so the example above publishes as example.spec.ts-snapshots/landing-chromium-linux.

Watch for capitals. Playwright builds the file name from the name you pass to toHaveScreenshot and from the Playwright project's name, and keeps their case. Use toHaveScreenshot('landing.png'), not 'Landing.png', and lowercase project names.

Push from your default branch to publish the goldens you merged. A golden only changes when someone updates it on purpose and commits it, so @latest shows exactly what your tests accept.

The --playwright-snapshots flag

Requires CLI 0.4.0 or later, which screenery/push@v1 runs. Push the whole test folder and keep the default golden layout:

npx screenery push ./tests --project acme/dashboard --playwright-snapshots

The flag publishes only media files inside a folder whose name ends in -snapshots. Fixtures and failure shots elsewhere are listed as ignored. The same flag works on screenery diff, where --update copies only the goldens into the baseline. On the Action, set playwright-snapshots: true, with path set to your test folder, because the default test-results does not contain goldens:

- uses: screenery/push@v1
  with:
    project: acme/dashboard
    path: tests
    playwright-snapshots: true

Naming your own screenshots

If you write screenshots yourself with page.screenshot(), choose the paths with care. The name becomes a URL that people paste into READMEs, and there is no redirect when it changes.

Stable screenshots

A screenshot that changes when your product did not still moves @latest and fails a diff gate for nothing. Most of that comes from fonts still loading, animations, lazy images, page height and the operating system that drew the text. Fix the browser in your config:

// playwright.config.ts
export default defineConfig({
  use: {
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 1,
    reducedMotion: 'reduce',
    timezoneId: 'UTC',
  },
});

Then let the page settle before each screenshot:

test('dashboard', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-01-15T09:00:00Z'));
  await page.goto('/');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  // Load lazy images now, and wait for them and for web fonts.
  await page.evaluate(async () => {
    document.querySelectorAll<HTMLImageElement>('img[loading="lazy"]').forEach((img) => (img.loading = 'eager'));
    await Promise.all(Array.from(document.images, (img) => img.decode().catch(() => {})));
  });
  await page.evaluate(() => document.fonts.ready);
  await page.screenshot({
    path: 'test-results/dashboard.png',
    fullPage: true,
    animations: 'disabled',
    caret: 'hide',
  });
});

To leave out a region that changes on purpose, such as a clock or an ad, see Noise, anti-aliasing and masks.

Dark mode

Capture the dark version beside the normal screenshot, with .dark before the extension. It is not a second screenshot: it keeps the same URL, and a page asks for it with ?theme=dark. See Light, dark and other themes.

for (const theme of ['light', 'dark'] as const) {
  test(`dashboard (${theme})`, async ({ page }) => {
    await page.emulateMedia({ colorScheme: theme });
    await page.goto('/');
    await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
    const suffix = theme === 'dark' ? '.dark' : '';
    await page.screenshot({ path: `test-results/dashboard${suffix}.png`, fullPage: true });
  });
}

Which test took it

Requires CLI 0.5.0. Each screenshot can say which test made it. The console then shows Captured by tests/checkout.spec.ts:42 on the screenshot, linked to that line at the commit that built it, with the browser, the viewport and the colour scheme. The channel's screenshot manifest lists the test for every URL, so you can check that every image your docs embed still has a test behind it.

The screenery package has a small helper for this. It takes the screenshot and writes a .meta.json file beside it. It does not depend on Playwright; you pass it your own page and testInfo.

// npm i -D screenery
import { test } from '@playwright/test';
import { recordOutcome, screenshot } from 'screenery/playwright';

test('pays by card', async ({ page }, testInfo) => {
  await page.goto('/checkout');
  await screenshot(page, testInfo, 'checkout/payment-form', { screenshot: { fullPage: true } });
});

// Optional: lets a push leave out a screenshot from a failed attempt that was retried.
test.afterEach(async ({}, testInfo) => recordOutcome(testInfo));

Playwright traces, for your team only

Requires CLI 0.5.0. If your project records Playwright traces (use: { trace: 'retain-on-failure' } or any mode but off), the helper also notes where each attempt's trace.zip will be saved. Push with --traces to upload them beside the screenshots:

npx screenery push test-results --project acme/dashboard --traces