stableFor developers

Connect Playwright

This guide adds the @s4labs/pixmoat-playwright client to an existing Playwright project. When you’re done, every npx playwright test run creates one build in pixmoat, uploads the screenshots your tests capture, and marks the build capture complete.

To try the whole loop once with a new project, follow Your first build instead. To run the tests in CI, add a pipeline from GitLab CI or GitHub Actions after this guide.

Before you start

You need:

  • a pixmoat project and a project access token (it starts with pxg_). Your first build shows where to create both.
  • Node.js 18 or later.
  • Playwright 1.30 or later (@playwright/test).

Install the client

Run this in your Playwright project:

npm install --save-dev @playwright/test @s4labs/pixmoat-playwright

The client uses the @playwright/test you already have. It installs @axe-core/playwright as its own dependency, so accessibility audits need no extra package.

Set the variables

The client reads its settings from environment variables in the Playwright process:

  • PIXMOAT_PROJECT and PIXMOAT_TOKEN are always required. Without them the run stops with Missing required pixmoat environment variables.
  • PIXMOAT_BRANCH and PIXMOAT_COMMIT tell pixmoat which branch and commit the build belongs to. startBuild() needs both to create a build, locally too. pixmoat picks baselines by branch, so in CI set both from your CI system’s variables.

Client environment variables lists every variable, its default and when to set it. Store the token as a masked CI/CD variable, never in the repository.

Start and finish the build

The client creates one build before the tests run and finishes it after they all complete. Add these three files at the root of your Playwright project.

playwright.config.ts runs global-setup.ts before the tests and global-teardown.ts after them. If your config already has testDir, use or webServer settings, keep yours and add only the two global… lines.

// Playwright config for capturing screenshots with the pixmoat fixture.
//
// File location: playwright.config.ts at the root of your Playwright project,
//                with global-setup.ts and global-teardown.ts next to it.
// Install:       npm install --save-dev @playwright/test @s4labs/pixmoat-playwright
//                npx playwright install --with-deps chromium
// Variables:     PIXMOAT_PROJECT, PIXMOAT_TOKEN, PIXMOAT_BRANCH and
//                PIXMOAT_COMMIT (required), PIXMOAT_URL (default
//                https://app.pixmoat.com). In CI, set the branch and commit
//                from your CI variables; see the CI examples.
// Command:       npx playwright test
// Expected:      each pixmoat.snapshot() call uploads a screenshot, and the run
//                ends with "[pixmoat] Build finished: ..." from global-teardown.ts.
//
// Tests capture screenshots with the fixture, not with toHaveScreenshot():
//
//   import { test } from "@s4labs/pixmoat-playwright";
//
//   test("homepage", async ({ page, pixmoat }) => {
//     await page.goto("/");
//     await pixmoat.snapshot(page, "homepage");
//   });
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./tests",
  globalSetup: "./global-setup.ts",
  globalTeardown: "./global-teardown.ts",
  use: {
    baseURL: process.env.BASE_URL ?? "http://localhost:3000",
  },
  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});

global-setup.ts calls startBuild(). It creates the build and shares its ID with every Playwright worker, so all workers upload into the same build. It also writes the ID to pixmoat-build.env, which the CI gate reads after the run. startBuild() also sends .pixmoat.yaml and your CODEOWNERS file with the new build. If PIXMOAT_BUILD_ID is already set, startBuild() adds to that build instead of creating one, and sends neither file.

New builds save their visual comparison baselines when they start. Reviews completed by other developers during the test run do not change those inputs. See Baselines during a capture. Retrying a finished build refreshes the saved baselines and replaces its previous capture results.

GitLab merged-result pipelines automatically send integration capture identity. The client checks local Git HEAD, creates a new build, and compares with the saved target baselines. Workers share the new build ID. A previous source-branch approval does not hide a difference from the target.

Wait for all uploads before finishing the build. An upload that overlaps a finish or retry can fail with capture_changed. Start a new capture rather than reuse a result from the previous one.

// pixmoat global setup: starts one build for the whole Playwright run.
//
// File location: global-setup.ts next to playwright.config.ts.
// Install:       npm install --save-dev @playwright/test @s4labs/pixmoat-playwright
// Variables:     PIXMOAT_PROJECT, PIXMOAT_TOKEN, PIXMOAT_BRANCH and
//                PIXMOAT_COMMIT (required; startBuild() throws without the
//                branch and commit), PIXMOAT_URL (default
//                https://app.pixmoat.com), and optionally PIXMOAT_CI_URL and
//                PIXMOAT_PR.
// Command:       npx playwright test
// Expected:      pixmoat-build.env contains PIXMOAT_BUILD_ID=<uuid> after the run.
//
// startBuild() stores the build ID in process.env.PIXMOAT_BUILD_ID, which only
// lives inside the Playwright process. Writing it to pixmoat-build.env hands it
// to the CI gate step that runs afterwards. If PIXMOAT_BUILD_ID is already set,
// startBuild() reuses that build instead of creating one.
import { writeFileSync } from "node:fs";
import { startBuild } from "@s4labs/pixmoat-playwright";

export default async function globalSetup(): Promise<void> {
  const buildId = await startBuild();
  writeFileSync("pixmoat-build.env", `PIXMOAT_BUILD_ID=${buildId}\n`);
}

global-teardown.ts calls finishBuild(). It marks the build capture complete, or Failed if an upload failed, and prints a summary line. It never fails the test run for visual changes. Require the separate CI gate to read the current validation result, including stale or unverifiable integration captures.

// pixmoat global teardown: marks the build complete after every test has run.
//
// File location: global-teardown.ts next to playwright.config.ts.
// Install:       npm install --save-dev @playwright/test @s4labs/pixmoat-playwright
// Variables:     the same as global-setup.ts.
// Command:       npx playwright test
// Expected:      the run ends with a line like
//                [pixmoat] Build finished: 5 snapshots (3 new, 2 unchanged, 0 diff, 0 missing)
//
// finishBuild() never fails the run, even when screenshots are waiting for
// review or were rejected. Gate CI on the build verdict in a separate step
// (see the gate job in docs/examples/gitlab/.gitlab-ci.yml).
import { finishBuild } from "@s4labs/pixmoat-playwright";

export default async function globalTeardown(): Promise<void> {
  await finishBuild();
}

Run the whole suite in one Playwright process. A test that fails before it captures, a partial run (--grep, a single --project) or a shard finishes the build without some screenshots, and those show as Removed. Completeness and removals explains why and what to do.

Capture screenshots with the fixture

Import test from @s4labs/pixmoat-playwright instead of @playwright/test. Each test then gets a pixmoat fixture, and pixmoat.snapshot(page, "<name>") captures the page and uploads it:

import { test } from "@s4labs/pixmoat-playwright";

test("homepage", async ({ page, pixmoat }) => {
  await page.goto("/");
  await pixmoat.snapshot(page, "homepage");
});

test("checkout", async ({ page, pixmoat }) => {
  await page.goto("/checkout");
  await pixmoat.snapshot(page, "checkout", { fullPage: true });
});

The name is part of the snapshot’s identity. Keep it stable: renaming a snapshot starts a new one with no history, and the old name shows as Removed.

Keep your expect assertions from @playwright/test. A passing toHaveScreenshot() assertion doesn’t send anything to pixmoat, so capture every screen you want reviewed with pixmoat.snapshot().

Capture more than one size or browser

Pass viewports or browsers to capture one snapshot per size or per browser in a single call:

await pixmoat.snapshot(page, "pricing", {
  viewports: ["1280x720", "375x812"],
  browsers: ["chromium", "firefox"],
});

This call uploads four screenshots, one per viewport and browser pair. The client resizes the page for each viewport and restores the original size afterwards, even when a capture fails.

For browsers other than the page’s own, the client opens the page’s current URL in a new browser with a fresh context, captures it and closes it. Cookies, logins and other context settings aren’t carried over, so a page behind a login shows the login page in those browsers. A failure in an extra browser is logged as a warning and doesn’t fail the test.

To use the same sizes or browsers for every snapshot, set PIXMOAT_VIEWPORTS or PIXMOAT_BROWSERS instead. The viewports and browsers keys in .pixmoat.yaml don’t do this; see Project configuration.

The Playwright browsers must be installed on the machine that runs the tests, for example with npx playwright install --with-deps chromium firefox.

Capture light and dark themes

Define your themes in the themes section of .pixmoat.yaml (see Project configuration). From then on every snapshot() call captures the page once per theme. To capture only some of them for one snapshot, pass their names in themes:

await pixmoat.snapshot(page, "settings", { themes: ["dark"] });

The client switches prefers-color-scheme for each theme without reloading the page. Each theme is a separate snapshot with its own baseline. A snapshot can’t skip themes altogether: an empty themes list captures every defined theme.

Hide content that changes on every run

Pass mask with locators or boxes for timestamps, avatars and other values that change between runs. pixmoat ignores the pixels inside them when it compares:

await pixmoat.snapshot(page, "profile", {
  mask: [
    page.locator("[data-testid='avatar']"),
    { x: 10, y: 20, w: 100, h: 50 },
  ],
});

Flaky snapshots covers masking in markup, animation handling and the other ways to keep screenshots stable.

Other snapshot options

pixmoat.snapshot() also accepts:

  • tolerance, the share of pixels that may differ before the screenshot counts as Changed, from 0 to 1.
  • animations, which overrides capture.animations for this snapshot (see Flaky snapshots).
  • performance, accessibility, console or elementMap set to false, which skips that collection for this snapshot. Setting one to true doesn’t turn on a collection that is off.
  • viewport, browser and dpr, which only label the screenshot. They don’t resize the page, switch the browser or change the pixel ratio. Use viewports, browsers or the Playwright context for that.

An option passed here wins over the matching environment variable and .pixmoat.yaml key. Your editor shows each option’s type from the package.

snapshot() returns the comparison result: result is "new", "unchanged" or "diff". When viewports, browsers or themes apply, including defaults from PIXMOAT_VIEWPORTS, PIXMOAT_BROWSERS or .pixmoat.yaml, it returns a list with one result per screenshot.

Name the elements that changed

With each screenshot, the client sends the positions of elements that have a data-testid, data-test, id or data-component attribute, and of landmarks and controls such as header, nav, button and headings. When a screenshot changes, pixmoat names the elements that contain changed pixels. It sends only tag names, those identifiers, class names and positions, never text or other attribute values.

Add data-testid to the components you care about. A change is then reported as button[data-testid=place-order-btn] instead of a path of class names that changes when styles are refactored. To turn this off, set PIXMOAT_ELEMENT_MAP=false.

Send CODEOWNERS with the build

startBuild() looks for a CODEOWNERS file in your Git repository and sends it with the build it creates, so pixmoat can show each changed screenshot’s owners and enforce code owner approval. It checks CODEOWNERS, .github/CODEOWNERS, .gitlab/CODEOWNERS, .bitbucket/CODEOWNERS and docs/CODEOWNERS from the repository root and sends the first one it finds. Nothing is sent when the tests don’t run inside a Git checkout, or when PIXMOAT_BUILD_ID is set so startBuild() doesn’t create the build.

pixmoat matches the file’s rules against snapshot names, not source file paths, so a rule for a source folder matches no snapshot. Owners also only count once their handles are mapped to pixmoat users. Approval policy explains both, with an example. To stop sending the file, set PIXMOAT_CODEOWNERS=0.

Check the run

Run the suite with the variables set:

export PIXMOAT_PROJECT="my-project"
export PIXMOAT_TOKEN="pxg_your_token"
export PIXMOAT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
export PIXMOAT_COMMIT="$(git rev-parse HEAD)"
npx playwright test

The run ends with a summary from finishBuild(). It adds a removed count when the build has Removed screenshots:

[pixmoat] Build finished: 5 snapshots (3 new, 2 unchanged, 0 diff, 0 missing)

Open the project in pixmoat. The build is at the top of the builds list. On the first run every screenshot is New; Review visual changes explains what to do next.

Screenshots taken on your laptop usually differ from CI screenshots because fonts and rendering differ. Approve baselines from CI builds, and use local runs only to check that the setup works.

CI gate

finishBuild() never fails the run, so CI stays green while screenshots wait for review. To fail CI until the review is done, add a separate step after npx playwright test that reads the build’s verdict:

npx playwright test
. ./pixmoat-build.env   # sets PIXMOAT_BUILD_ID, written by global-setup.ts
verdict=$(curl --fail --silent --show-error --max-time 330 \
  -H "Authorization: Bearer $PIXMOAT_TOKEN" \
  "${PIXMOAT_URL:-https://app.pixmoat.com}/api/builds/$PIXMOAT_BUILD_ID/agent-summary?wait=true&timeout=300" \
  | jq -r '.verdict')
echo "pixmoat verdict: $verdict"
test "$verdict" = "PASS"

The step passes only when the verdict is PASS, meaning no screenshot is waiting for a decision and none was rejected. REVIEW, REVIEW_NEW and FAIL fail it. wait=true waits up to timeout seconds (at most 300) for pixmoat to finish comparing the screenshots, not for a person to review them. If comparing takes longer, the request returns HTTP 408 and the step fails; retry it.

After someone approves the build in pixmoat, retry the gate step to turn CI green. The verdict doesn’t check the approval policy. The gate also fails on pending changes when the project is in report-only mode, so leave it out if changes shouldn’t fail CI.

GitLab CI and GitHub Actions show the gate as its own job, with the build ID passed from the capture job.

Collect performance, accessibility and console results

The same snapshot() call can collect more than pixels:

  • Performance. Set PIXMOAT_PERFORMANCE=true to collect Core Web Vitals with each snapshot. Performance and console explains the results.
  • Accessibility. Add an accessibility section to .pixmoat.yaml to run an axe audit after each screenshot. Accessibility checks shows the setup.
  • Console. Add a console section with enabled: true to .pixmoat.yaml to record console errors, uncaught page errors and failed requests. Performance and console shows the setup.

Turning these on in project settings doesn’t make the client collect them. The client decides from the environment variable or .pixmoat.yaml shown above.

Reporter (beta)

Warning: the reporter can make pixmoat retire valid baselines. It uploads only the PNG attachments it sees on test results. A passing toHaveScreenshot() assertion produces no attachment, and failed uploads aren’t counted. When the reporter finishes the build, pixmoat treats every baseline it didn’t upload as Removed, and approving those removals retires the baselines. Use the fixture in CI until the reporter accounts for every expected screenshot.

@s4labs/pixmoat-playwright/reporter is a Playwright reporter that uploads PNG attachments from test results, prints the review link and writes CI report files. If you use it despite the warning:

  • keep startBuild() in globalSetup, because only startBuild() sends .pixmoat.yaml and CODEOWNERS;
  • don’t call finishBuild() in globalTeardown, because the reporter finishes the build itself and a second finish request fails with HTTP 409.

The reporter writes pixmoat.env, junit.xml and report.html to pixmoat-report/. GitLab CI shows how to publish them, and Client environment variables lists the variables that affect it.

Troubleshooting

What you seeWhyWhat to do
Missing required pixmoat environment variablesPIXMOAT_PROJECT or PIXMOAT_TOKEN isn’t set in the Playwright process.Set both. In CI, check the variable’s key and, on GitLab, that it isn’t protected.
pixmoat: branch and commitSha are required to create a buildstartBuild() needs PIXMOAT_BRANCH and PIXMOAT_COMMIT.Set both from your CI variables, or pass branch and commitSha to startBuild().
An error that starts with pixmoat: and names .pixmoat.yaml, such as failed to parseThe file in the directory you run the tests from isn’t valid. startBuild() stops rather than create a build with the wrong settings.Fix the line named in the message. Project configuration explains which file is read.
HTTP 401 or 403The token is wrong, revoked, or belongs to another project.Create a new project access token and update the CI/CD variable.
The build doesn’t appear in pixmoatPIXMOAT_PROJECT doesn’t match the project’s slug.Copy the slug from the project’s URL.
Uploads time outEach request is limited to 30 seconds. Large screenshots on a slow connection can take longer.Raise PIXMOAT_REQUEST_TIMEOUT_MS, and check that the CI runner can reach app.pixmoat.com.
The build shows FailedAt least one upload failed, so finishBuild() didn’t mark it capture complete.Fix the upload error printed in the log and run the suite again.
Screenshots you didn’t delete show as RemovedPart of the suite didn’t run or didn’t reach snapshot().Run the whole suite again. See Completeness and removals.

GitLab Free queue captures

For the opt-in GitLab Free merge queue, use the queue-aware client and prepare the checkout before Playwright starts. PIXMOAT_GITLAB_FREE_QUEUE=1 requires a detached source MR pipeline. PIXMOAT_QUEUE_TARGET_SHA records the fetched target head. The client checks that Git HEAD matches the source and contains that target commit before uploading. A completed capture can leave visual review pending while its capture job succeeds. Failed tests or uploads still fail the job. The coordinator waits for the finished review and the full successful pipeline before merging.