Screenery

Screenery / Guides

Screenshot automation for docs and READMEs with Playwright

People mean three different things by screenshot automation. This guide says which one fits docs and READMEs, then walks through a Playwright test that captures named screenshots in CI and publishes them at URLs that update on every merge.

Three kinds of screenshot automation

Desktop capture apps

These run on your own computer. They take a screenshot on a hotkey or a timer, or replay a recorded sequence, then save or upload the image. They suit bug reports, one-off how-tos and annotated images where a person decides what goes in the frame. They need someone's desktop to run on, though, and they can't tell when your UI changed. Taking the screenshot gets faster, but someone still has to notice it went stale and take it again.

URL screenshot APIs

You send a URL to a hosted service such as screenshotapi.net or Urlbox, and it sends back an image. That works well for public pages: link previews, social cards, archives, or watching a site you don't own. Your own product is harder. To get behind the login, the service needs a session for your app. To show an open dialog or a particular row of data, you script the clicks in the service's API instead of in your own code.

Capture from tests in CI

Your end-to-end tests already sign in, load data and click their way to each screen. Add page.screenshot() and the test that checks a screen also produces its picture, on every build. Kong described a version of this in Docs as Code: Screenshot Automation, driving shot-scraper from a YAML file. fastlane snapshot does the same for App Store images from iOS UI tests.

The capture is the easy half. The image still has to get from the CI runner to the docs page. You can commit it back to the repository, keep it as a workflow artifact, or push it somewhere that gives it a stable URL. Screenery does the third.

KindFitsFalls short when
Desktop capture appOne-off and annotated imagesThe UI changes and nobody retakes the shot
URL screenshot APIPublic pages, previews, monitoringThe screen is behind a login or needs clicks to reach
Tests in CIProduct screenshots in docs and READMEsYou have no end-to-end tests and no CI job

For docs and READMEs, use tests in CI. The other two kinds produce a file. A test produces a new file on every build.

A worked example with Playwright

This example signs in once, captures three named screenshots, and publishes them with GitHub Actions to a public Screenery project called acme/dashboard. It assumes you created that project with the Screenery GitHub App, as in Getting started.

1. Configure Playwright

// playwright.config.ts
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: 'tests',
  use: {
    baseURL: 'http://localhost:3000',
    viewport: { width: 1280, height: 800 },
    locale: 'en-US',
    timezoneId: 'UTC',
  },
  projects: [
    { name: 'setup', testMatch: /auth\.setup\.ts/ },
    {
      name: 'docs',
      testMatch: /docs\.spec\.ts/,
      dependencies: ['setup'],
      use: { storageState: 'playwright/.auth/user.json' },
    },
  ],
  webServer: {
    command: 'npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

A fixed viewport, locale and time zone keep the picture the same on every machine. The setup project signs in before the screenshot tests run.

2. Sign in once

// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';

setup('sign in', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill(process.env.DOCS_USER_EMAIL!);
  await page.getByLabel('Password').fill(process.env.DOCS_USER_PASSWORD!);
  await page.getByRole('button', { name: 'Sign in' }).click();
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Use a test account that exists only in the copy of the app the job starts. Add playwright/.auth to .gitignore, because the file holds a live session. Playwright's authentication guide covers other setups, such as one account per worker.

3. Capture named screenshots

// tests/docs.spec.ts
import { test, expect, type Page } from '@playwright/test';

test.beforeEach(async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-01-15T10:00:00Z'));
  await page.route('**/api/projects', (route) =>
    route.fulfill({ path: 'tests/fixtures/projects.json' }),
  );
});

async function capture(page: Page, name: string) {
  await page.evaluate(async () => {
    await document.fonts.ready;
  });
  await page.screenshot({
    path: `screenshots/${name}.png`,
    animations: 'disabled',
    caret: 'hide',
  });
}

for (const [viewport, size] of [
  ['desktop', { width: 1280, height: 800 }],
  ['mobile', { width: 390, height: 844 }],
] as const) {
  test(`projects list (${viewport})`, async ({ page }) => {
    await page.setViewportSize(size);
    await page.goto('/projects');
    await expect(page.getByRole('row')).toHaveCount(4);
    await capture(page, `projects/list/${viewport}`);
  });
}

test('billing settings', async ({ page }) => {
  await page.goto('/settings/billing');
  await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
  await capture(page, 'settings/billing');
});

The page.route call answers the app's /api/projects request with a file you commit, so the list shows the same projects on every run. Create tests/fixtures/projects.json in the shape your API returns. This example has three projects. The table adds a header row, which is why the test expects four rows.

[
  { "id": "p_1", "name": "Marketing site", "status": "Active", "updatedAt": "2026-01-14T09:00:00Z" },
  { "id": "p_2", "name": "Mobile app", "status": "Active", "updatedAt": "2026-01-12T16:30:00Z" },
  { "id": "p_3", "name": "Docs", "status": "Archived", "updatedAt": "2025-12-01T08:15:00Z" }
]

Rename the fields to match your API, and change the row count in the assertion if you add or remove projects. Playwright sends the file as the response and sets its content type from the .json extension.

Each file's path under screenshots/, minus the extension, becomes its name. screenshots/projects/list/mobile.png is published as projects/list/mobile. Names use lowercase letters, digits, ., _ and -, and the viewport goes last. See Names.

Write to a folder of your own rather than Playwright's test-results/, which also holds traces and failure images. Screenery publishes every image in the folder you push.

4. Publish from CI

Save this as .github/workflows/screenshots.yml:

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@v6
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
        env:
          DOCS_USER_EMAIL: ${{ secrets.DOCS_USER_EMAIL }}
          DOCS_USER_PASSWORD: ${{ secrets.DOCS_USER_PASSWORD }}
      - uses: screenery/push@v1
        with:
          project: acme/dashboard
          path: screenshots

id-token: write lets the job prove to Screenery which repository it runs for, so there is no Screenery secret to store. pull-requests: write lets the job post the preview comment on pull requests. The push step has no if:, so a failing test publishes nothing. Every input is on the GitHub Action reference, and Playwright screenshots in GitHub Actions explains each line.

5. Embed the URLs

The push log prints a snippet for each public screenshot. Paste it once:

![Projects list](https://cdn.screenery.dev/acme/dashboard/projects/list/desktop@latest.png)

In HTML, or to set the display width:

<img src="https://cdn.screenery.dev/acme/dashboard/settings/billing@latest.png"
     alt="Billing settings with the current plan and payment method" width="640" />

The same URL works in a GitHub README, in Markdown or MDX on any docs site, and in a help centre that accepts an image URL. For Mintlify's components, see Self-updating screenshots in Mintlify docs.

Keep them current with @latest

@latest is a channel. Screenery looks it up on every request, so an embed always shows the newest build without anyone editing the page.

The full rules are in How @latest moves.

Same screen, same picture

A docs screenshot should change when the UI changes and at no other time. Otherwise every pull request shows a dozen changed images and people stop looking. Most of the noise comes from five places.

The login

Sign in once in a setup project and reuse the saved state, as in step 2. Signing in inside every test is slower, and a login page that flickers into one screenshot is easy to miss.

Dates and data

"Updated 3 minutes ago" and a growing list of projects change the picture on every run. page.clock.setFixedTime() pins the date the page sees, and page.route() answers API calls from a fixture file. Seeding the test database works too. Playwright's mask option also hides changing parts, but it paints them a solid colour, which looks wrong in a docs page. Fixed data shows the screen the way a reader will see it.

Fonts

A Linux runner doesn't have the fonts on your laptop. If your CSS falls back to system fonts, CI renders different letter shapes and widths. Load your own web fonts, and wait for document.fonts.ready before the capture, as the helper in step 3 does. Publish only the screenshots CI takes, so every image comes from the same machine type.

Viewport and scale

Set the viewport in the config, and per test when you need a second size. Put the viewport last in the name, as in projects/list/mobile. For sharper images on high-density screens, set deviceScaleFactor: 2. That doubles the pixel size of every file, so set the display width in the docs page.

Motion

page.screenshot() lets animations run unless you pass animations: 'disabled'. A half-finished transition or a blinking caret makes two runs differ. Wait for real content with an assertion, not a fixed timeout.

Once these hold, the preview comment's count of changed screenshots means something. To fail the build when a screenshot changes by accident, give the Action a committed baseline. See Visual regression testing in CI.

FAQ

What is screenshot automation?
Taking screenshots with a program instead of by hand. The program might be a desktop app on a timer, a hosted API that renders a URL, or a test that drives your app in CI. For docs and READMEs, the test is the one that knows when the UI changed.
Can I automate screenshots without writing tests?
Yes. A URL screenshot API needs only a URL, and shot-scraper takes a YAML list of pages. Screenery publishes any folder of PNG, JPEG, WebP, AVIF, GIF, WebM or MP4 files, so the output of either can be pushed the same way. Tests win once a screenshot needs a login, seeded data or a click to reach the right state.
How do I take screenshots of pages behind a login?
Sign in once in a Playwright setup project with a test account, save the session with storageState, and let every screenshot test reuse it. The account only has to exist in the copy of the app your CI job starts, and the password stays in CI.
Why does my screenshot look different in CI than on my laptop?
The CI runner has a different operating system, different system fonts and often a different screen scale. Load your own web fonts, set the viewport and device scale factor in the Playwright config, and publish only the screenshots CI takes.
Will a pull request change the screenshots in my docs?
No. A pull request publishes to its own channel, such as @pr-12, and the preview comment shows what changed. Only a push on the default branch, or a promotion, moves @latest. The one exception is a new project's first push, which creates @latest from whatever branch it came from.

Related guides

Third-party tools are described from their own pages, checked October 2026. Follow the links for their current features.