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.
- Put the variant last. checkout/payment-form/mobile.png, not mobile/checkout/payment-form.png. Screenery groups the screenshots in one folder as one screen at several sizes, so a leading variant splits every screen apart. Both are legal, and neither warns.
- Group by area first. marketing/, app/, admin/. Related screenshots stay together in the console, and the URLs are easy to guess.
- Treat the name as a route. Renaming a screenshot breaks every link to its old name. On the next default-branch push, the old name's @latest URL answers 404. See How @latest moves.
- A single file needs no folder. dashboard/home.png is fine. Add a variant folder only when there really are several variants.
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',
});
});
- Wait for the page, not a timer. Assert on the element your data fills. Use page.waitForLoadState('networkidle') only when there is no such element; a page that polls never reaches it.
- Stop motion. page.screenshot leaves animations running unless you pass animations: 'disabled'. toHaveScreenshot disables them by default. Hide JavaScript carousels yourself.
- Freeze time. page.clock.setFixedTime, before goto, keeps "Updated today" the same on every run.
- Lazy images. Images that a script loads on scroll ignore the loading attribute. Scroll to the bottom of the page and back before the screenshot.
- Full-page height. A page built from 100vh sections is as tall as the viewport. Keep the viewport fixed, or screenshot a locator instead of the full page.
- Capture in CI only. macOS, Windows and Linux draw fonts differently, so a screenshot from a laptop differs from CI's though nothing changed. Let one CI runner push every screenshot.
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 });
});
}
- Match how your app switches. emulateMedia works when the app follows the system setting. If the app has its own switch, set what the switch sets, for example a theme key with page.addInitScript, before the page loads.
- Push both in the same push. A .dark file without its normal screenshot is refused, because a push replaces what the channel holds.
- Same format. Save both as PNG, or both as JPEG. The dark one is served at the normal one's URL, extension and all.
- Check the dark one is dark. An app that reads its theme before the emulation lands produces two light files, and nothing else will tell you.
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));
- Where it writes. test-results/checkout/payment-form.png and test-results/checkout/payment-form.meta.json. Pass dir to use another folder.
- What it records. The test file's path from the repository root, its line and title, the browser and its version, the viewport size, the colour scheme, the page address without its query string, and the attempt number.
- Retries. When a test fails and Playwright runs it again, the retry writes the same files. If the retry fails before it reaches the screenshot, the push skips the failed attempt's screenshot and keeps the channel's previous version. Add --keep-retried-failures to push it anyway. A sharded push always pushes it, with a warning: the shards of one run must agree, and one shard cannot keep the previous version on its own.
- Masking a clock or an avatar. Pass ignore: ['.timestamp'] and the diff skips those elements without painting over them in the published image. See Masking by selector.
- Without the helper. Any tool can write the same capture block. See Descriptions and tags.
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
- Never public. A trace records the whole test session: network requests and responses, cookies, storage, console output and your test's source. So it is never served from cdn.screenery.dev, whatever the project's visibility, and no read token, embed token or signed URL opens it.
- Who can download one. Signed-in members of the project's org with the publisher role or higher. Viewers see the screenshots but not the traces.
- How. In the console, open the screenshot and press Download trace under Captured by. The link lasts five minutes. Then open the file on your own machine with the command shown beside the button: npx playwright show-trace trace-checkout-payment-form-v_….zip. Agents use the screenery_trace tool. Do not paste the download link into trace.playwright.dev or any other website: until it expires, anyone holding it can download the trace.
- Opt-in. Without --traces, nothing is uploaded. A trace that Playwright did not keep (a passing test under retain-on-failure) is skipped.
- Limits. Traces count toward your storage. Each one may be up to 50 MB on the free plan and 200 MB on pro. A trace no screenshot version points to any more is deleted after a day.