Playwright architecture, selector reliability, and advanced interaction patterns.

Managing Screenshot Baselines Across Platforms

A visual test that passes on your laptop and fails the moment it reaches CI is almost never a real regression. toHaveScreenshot() compares the pixels the browser actually painted, and the pixels a browser paints depend on the operating system underneath it: font rasterisation, the installed font set, scrollbar geometry, form-control chrome and device pixel ratio all change between macOS, Linux and Windows. Playwright's default snapshot path does not encode the operating system, so a baseline written on a developer machine and a baseline written by a Linux runner land in the same file and fight over it. This page shows how to give every platform its own baseline, how to nominate a single blessed platform so only one of them gates the pipeline, and how to tell a platform artefact apart from a genuine UI change.

Three platforms writing one baseline file macOS, Linux and Windows runners all write to the same snapshot path, so each merge overwrites the previous platform's baseline. macOS dev laptop darwin · arm64 Linux CI runner linux · x64 Windows dev box win32 · x64 dashboard.png — one baseline file no platform in the path Whoever ran last owns the image every merge flips the baseline back again
Without a platform token in the snapshot path, three runners share one file and each regeneration invalidates the other two.

Root cause: the default snapshot path has no platform axis

Playwright resolves every screenshot to a file using snapshotPathTemplate, which defaults to {snapshotDir}/{testFileDir}/{testFileName}-snapshots/{arg}{-projectName}{-snapshotSuffix}{ext}. That template separates baselines by test file and by project name, but it carries no token for the host operating system, so chromium on darwin and chromium on linux resolve to exactly the same path. The rendering underneath differs anyway: macOS rasterises text through Core Text with its own hinting and stem darkening, Linux uses FreeType driven by fontconfig, and a Linux container usually lacks Helvetica, Segoe UI and every commercial font your design system asks for, so it silently falls back. Subpixel differences in glyph edges are enough to blow past the default comparison tolerance.

The second half of the problem is workflow rather than pixels. Once two machines write to one path, --update-snapshots becomes a race: an engineer regenerates locally to unblock a branch, CI fails on the same test with the opposite diff, and the file oscillates in version control until someone gives up and deletes the visual suite. This page sits under Visual Regression Testing, within Debugging & Test Observability.

Minimal reproducible example

The test below is ordinary and correct. It fails across platforms purely because of where its baseline is stored and what painted it.

import { test, expect } from '@playwright/test';

test('dashboard renders consistently', async ({ page }) => {
  await page.goto('/dashboard');

  // Wait for a stable signal before painting — a screenshot taken mid-render
  // produces a diff that looks like a platform problem but is really a race.
  await expect(page.getByRole('heading', { name: 'Revenue' })).toBeVisible();

  // Default path: tests/dashboard.spec.ts-snapshots/dashboard-chromium.png
  // The same path is used on darwin, linux and win32 — that is the bug.
  await expect(page.getByRole('main')).toHaveScreenshot('dashboard.png');
});

Run it once on macOS, commit the generated PNG, then run it on a Linux runner and you get one of two errors. If the file is missing entirely, Playwright reports Error: A snapshot doesn't exist at /work/tests/dashboard.spec.ts-snapshots/dashboard-chromium.png, writing actual. and fails the run so a missing baseline can never silently pass. If the file exists but was painted elsewhere, you get Error: Screenshot comparison failed: 12483 pixels (ratio 0.03 of all image pixels) are different. with expected, actual and diff attachments in the HTML report.

Step-by-step fix

  1. Put the platform in the snapshot path. Set snapshotPathTemplate in playwright.config.ts so {platform} and {projectName} both appear. {platform} resolves to process.platformdarwin, linux or win32 — which gives each operating system its own directory tree instead of a shared filename. Every other token stays available, so you can keep test-file scoping while adding the missing axis.
  2. Nominate one blessed platform. Pick the platform your CI actually runs — nearly always linux inside the official image — and treat its baselines as the only ones that gate merges. Keeping darwin baselines is optional and useful for local iteration, but they should never fail a pipeline, because nobody will maintain three sets of images by hand.
  3. Generate blessed baselines inside the same container CI uses. Run the update through mcr.microsoft.com/playwright:v1.55.0-noble locally so the fonts, FreeType version and browser build match the runner byte for byte. The container discipline is the same one described in Dockerizing Playwright for Headless CI.
  4. Freeze the rendering surface. Pin viewport and deviceScaleFactor in the project, and set scale: 'css', animations: 'disabled' and caret: 'hide' in expect.toHaveScreenshot so a retina laptop and a 1x runner produce the same pixel grid and no blinking cursor or half-finished transition lands in the frame.
  5. Ship the fonts with the app under test. Self-host every family as WOFF2 and declare font-family with a concrete fallback rather than a system stack. A container that lacks a font substitutes a different one, and font substitution is the single largest source of cross-platform diffs.
  6. Set tolerance per project rather than globally. Use maxDiffPixelRatio for pages whose content reflows and maxDiffPixels for tight component shots; the tuning trade-offs are covered in Tuning Screenshot Comparison Thresholds. Never raise threshold globally to silence one platform.
  7. Regenerate deliberately and review the images. Run npx playwright test --update-snapshots=changed so only the baselines that actually differ are rewritten, then read the changed PNGs in the pull request. A regeneration that touches forty files when you changed one button is telling you the environment moved, not the UI.
How snapshotPathTemplate tokens resolve Each token in the snapshot path template maps to a concrete path segment, with platform and project name separating baselines. snapshotPathTemplate resolves token by token {testFileDir} {testFileName}-snapshots {arg} {projectName} {platform} {ext} tests/visual home.spec.ts-snapshots dashboard chromium linux .png Adding platform and project name gives every runner its own baseline file
The two tokens that matter for portability are {projectName} and {platform}; everything else only organises the tree.

The configuration below implements steps 1, 4 and 6 together. {testFilePath} is relative to testDir, so the resulting tree is tests/__screenshots__/linux/chromium/visual/home.spec.ts/dashboard.png.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  // {platform} is process.platform: darwin | linux | win32.
  // {projectName} keeps chromium, firefox and webkit baselines apart.
  snapshotPathTemplate:
    '{testDir}/__screenshots__/{platform}/{projectName}/{testFilePath}/{arg}{ext}',
  expect: {
    toHaveScreenshot: {
      // Ratio, not absolute pixels — survives minor reflow on long pages.
      maxDiffPixelRatio: 0.01,
      animations: 'disabled', // freeze CSS animations and transitions
      caret: 'hide',          // blinking text caret is a per-run coin flip
      scale: 'css',           // normalise 1x runners against 2x laptops
    },
  },
  projects: [
    {
      name: 'chromium',
      use: {
        ...devices['Desktop Chrome'],
        viewport: { width: 1280, height: 720 }, // pin, never inherit
        deviceScaleFactor: 1,
      },
    },
  ],
});

Step 3 then becomes a single npm script rather than tribal knowledge. Mount the repository into the official image, install nothing extra, and run the update with the container's own browsers: docker run --rm -v "$PWD":/work -w /work mcr.microsoft.com/playwright:v1.55.0-noble npx playwright test --update-snapshots=changed. Because the image pins both the browser build and the font set, the images it writes are the images CI will compare against, and the diff you commit is reviewable instead of mysterious. Keep the image tag in lockstep with the @playwright/test version in package.json; a mismatch between the two is the most common reason a suite that was green yesterday reports thousands of differing pixels today after a routine dependency bump.

Troubleshooting variants

Diffs appear only in text, never in layout

Glyph edges differing while boxes stay put is the signature of font substitution. Compare the fonts the two environments resolve by evaluating document.fonts.check() for each family, or dump getComputedStyle(document.body).fontFamily on both machines. The fix is to self-host the family and reference it directly rather than through a system stack, then rebuild the blessed baselines inside the container. If a font is genuinely unavailable on one platform, exclude the affected region with the masking approach in Masking Dynamic Regions in Snapshots rather than raising tolerance for the whole page.

The comparison fails with a size mismatch instead of a pixel diff

Error: Snapshot comparison failed: Expected an image 1280px by 720px, received 1512px by 982px. means the viewport itself changed, so no tolerance setting can help. Either a project inherited a device descriptor with a different viewport, or the runner has a visible scrollbar that the recording machine did not — Linux and Windows reserve layout width for classic scrollbars while macOS overlays them. Pin viewport explicitly in every project, prefer element-scoped screenshots over fullPage: true for pages whose height varies with data, and check the device emulation notes in Emulating Devices, Locales and Timezones.

Baselines pass locally but fail on one browser engine only

Each engine paints form controls, focus rings and default fonts differently, so a shared baseline across chromium, firefox and webkit cannot work. {projectName} in the template already separates them; confirm the file actually lives under the project directory rather than a stale flat path left over from an older configuration. Delete orphaned baselines whenever you rename a project, since Playwright never prunes them and a stale image will keep passing forever. Engine-specific rendering differences are surveyed in Running Chromium vs Firefox vs WebKit in Playwright.

Triage tree for a failed screenshot check A decision tree separating a missing baseline, a font rendering artefact and a genuine layout regression. Screenshot check failed No baseline for this platform Baseline exists, pixels differ Regenerate in the CI container Text edges only Boxes have moved Bundle fonts, pin scale Real regression
Read the diff image before touching thresholds: text-only noise and displaced boxes have completely different remedies.

Verification

Prove the setup three ways. First, delete the entire baseline directory for the blessed platform and run the suite twice: the first run must fail with A snapshot doesn't exist, and the second, after a container-based --update-snapshots, must pass without a byte changing. That round trip confirms generation is reproducible rather than accidental. Second, run the identical command on a second machine of the blessed platform and confirm git status is clean — any file that reappears as modified marks a screenshot whose content is still non-deterministic and needs masking or a stability wait. Third, inspect a deliberate failure in the HTML report and in the Playwright Trace Viewer, where the expected, actual and diff images are attached to the failing step so you can confirm the comparison used the file you think it did. Wire the update job into the pipeline alongside the sharding setup in Running Playwright Tests in GitHub Actions with Sharding, and remember that shards must share one baseline tree, not generate their own. If a screenshot still alternates between pass and fail on the same platform, treat it as a timing defect and work through Detecting and Fixing Flaky Playwright Tests before blaming the operating system.

Frequently Asked Questions

Should I commit baselines for every platform my team uses?

Only if someone owns them. One blessed platform generated in the CI container is the maintainable default, because a baseline nobody regenerates is a baseline that silently rots. Developers on other operating systems run the visual suite with --ignore-snapshots for fast feedback on functionality, then rely on the containerised job for the pixel verdict. Teams that genuinely ship desktop-specific rendering can add a second platform, but each extra platform multiplies the images a reviewer must look at.

Why does --update-snapshots rewrite files I did not touch?

Any regeneration run on a machine whose rendering differs from the blessed one rewrites everything it compares, because every image is technically different. That is why regeneration belongs inside the same container image CI uses. Use --update-snapshots=changed to limit writes to baselines that actually failed comparison, and treat a large unexplained churn as evidence that the browser version, the base image or the font set moved underneath you.

Where do baselines live and how do I stop the repository bloating?

They live wherever snapshotPathTemplate resolves, defaulting next to the test file in a -snapshots directory. Keep the images small by screenshotting a single component or region instead of fullPage: true, since a full-page capture of a data-heavy dashboard is both huge and unstable. For suites with hundreds of images, store them through Git LFS and delete orphans whenever a test or project is renamed, because Playwright never removes a baseline it no longer references.

Can I compare across platforms with a looser tolerance instead?

Raising threshold or maxDiffPixelRatio far enough to absorb font rasterisation differences also absorbs the regressions you are trying to catch — a shifted button or a wrong colour easily fits inside the same pixel budget as antialiasing noise. Separate the baselines by platform and keep tolerance tight; tolerance should cover rendering jitter within one environment, never differences between environments.

Back to overview