Screenery

Compare / BackstopJS

Moving from BackstopJS to Playwright and Screenery

BackstopJS still works, and people still install it. But its README asks for a new maintainer, and the last release on npm is from September 2024. This guide turns your backstop.json into one Playwright test and gives the screenshots a hosted home.

Where BackstopJS stands, checked October 2026

What BackstopJS does well

Side by side

BackstopJSPlaywright + Screenery
Scenarios backstop.json A Playwright test, which can read the same file
Baseline backstop_data/bitmaps_reference, on one machine or committed A committed folder, or the version each push replaces on Screenery
Report Local HTML report Pull-request comment, and an offline HTML viewer from --out
Agents None MCP server: diff @pr-N against @latest
Published URLs None @latest follows the default branch
Maintenance Looking for a maintainer Both under active development
Price Free, open source Playwright is free. Screenery has a Free plan; prices not published yet

Commands, mapped

BackstopJSPlaywright + Screenery
backstop testnpx playwright test
npx screenery diff ./test-results --baseline ./screenery-baseline
backstop referencenpx screenery diff ./test-results --baseline ./screenery-baseline --update
backstop approveThe same --update, then commit the baseline
The HTML reportnpx screenery diff … --out ./diff-report

screenery diff needs no account and no network. It exits 1 when something changed.

Scenarios as a Playwright test

Keep backstop.json where it is. This test reads it and takes one screenshot per scenario and viewport:

// tests/backstop.spec.ts
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { test } from '@playwright/test';

const config = JSON.parse(readFileSync('backstop.json', 'utf8'));
const slug = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
// Like BackstopJS, a url starting with . or / is a file relative to the working directory.
const toUrl = (url: string) => (/^[./]/.test(url) ? pathToFileURL(join(process.cwd(), url)).href : url);
const names = new Map<string, string>();

for (const raw of config.scenarios) {
  const scenario = { ...config.scenarioDefaults, ...raw };
  for (const viewport of scenario.viewports ?? config.viewports) {
    const name = `${slug(scenario.label)}/${slug(viewport.label)}`;
    if (names.has(name)) {
      throw new Error(`"${scenario.label}" and "${names.get(name)}" would both save as ${name}. Rename one label.`);
    }
    names.set(name, scenario.label);
    test(`${scenario.label} (${viewport.label})`, async ({ page }) => {
      await page.setViewportSize({ width: viewport.width, height: viewport.height });
      await page.goto(toUrl(scenario.url));
      await page.screenshot({ path: `test-results/${name}.png`, fullPage: true });
    });
  }
}

A scenario labelled Homepage at a phone viewport is saved as test-results/homepage/phone.png and published as homepage/phone. The viewport goes last, so Screenery groups the sizes of one page together. Names must be lowercase, which is why the test slugs them. See Names.

The test follows BackstopJS on three details. A scenario's own viewports replace the global list, scenarioDefaults apply to every scenario, and a url starting with . or / is a local file. Two labels can slug to the same name, such as Account / Settings and Account Settings. The test then stops with an error instead of overwriting a screenshot. It does not add a number to tell them apart, because a name is a URL and must stay the same from run to run.

Scenario options

Most scenario keys are one Playwright line. Add the ones you use inside the test:

backstop.jsonPlaywright
readySelectorawait page.locator(scenario.readySelector).waitFor();
delayawait page.waitForTimeout(scenario.delay);
hoverSelectorawait page.hover(scenario.hoverSelector);
clickSelectorawait page.click(scenario.clickSelector);
hideSelectorsThe mask option of page.screenshot()
selectorsawait page.locator(selector).screenshot({ path });
misMatchThresholdNo exact match. See Graded changes

misMatchThreshold is a share of pixels. Screenery's threshold is how different one pixel must be, and failOn picks which grade of change fails. Start with the defaults and loosen only what is noisy.

Baselines: committed, or hosted

You have two choices, and they work together.

A committed folder works like BackstopJS's reference folder. Create it from your current screenshots and commit it:

npx playwright test
npx screenery diff ./test-results --baseline ./screenery-baseline --update
git add screenery-baseline

Fonts and anti-aliasing differ between machines, which is why BackstopJS uses Docker. Create the baseline on the same image your CI runs, or it will fail on the first run. Keep the baseline outside the folder you push.

A hosted baseline needs no folder. Every push grades each PNG against the version it replaces on Screenery. On a pull request, that is the base branch's channel, then @latest. Set failAbove in screenery.config.json to fail the push before anything is published:

{
  "diff": {
    "failAbove": 1
  }
}

failAbove is a percent. A screenshot that changed more than that fails the push, and nothing is published. alertAbove only warns. See Graded changes.

In CI

On GitHub Actions, run the test and add the Screenery step. There is no secret to store:

permissions:
  id-token: write
  contents: read
  pull-requests: write
steps:
  - uses: actions/checkout@v4
  # … install, then: npx playwright test …
  - uses: screenery/push@v1
    with:
      project: acme/dashboard
      path: test-results
      diff-baseline: ./screenery-baseline

Leave out diff-baseline if you use the hosted baseline. Each pull request gets a comment showing what changed against @latest, and a merge to the default branch updates every embedded @latest URL. In other CI systems, run the CLI with a SCREENERY_TOKEN. See Getting started.

When to stay on BackstopJS

If you want a review app rather than published URLs, compare Argos and Percy too.

Sources

Checked October 2026.

See also: all comparisons.