Playwright Integration
Set up the @s4labs/pixmoat-playwright capture client to send visual snapshots from your Playwright tests to a pixmoat server.
Prerequisites
- A pixmoat project and access token (see Using pixmoat)
- A project created in pixmoat with an access token
- Node.js 18+
- Playwright 1.30+
Installation
npm install @s4labs/pixmoat-playwright
The package has a peer dependency on @playwright/test — it uses your existing Playwright installation.
Accessibility auditing dependency
If you enable accessibility checking (see Accessibility Auditing below), install @axe-core/playwright as a peer dependency:
npm install @axe-core/playwright
This is only required when accessibility.enabled: true is set in your .pixmoat.yaml or project settings. The capture client will skip the audit gracefully if the package is not installed.
Integration Options
There are two ways to integrate pixmoat with Playwright:
| Approach | Best for | Test changes? |
|---|---|---|
| Fixture API | New tests, fine-grained control | Yes — use pixmoat.snapshot() |
| Reporter | Existing toHaveScreenshot() tests | No — zero-edit drop-in |
Both approaches require the same environment variables and global setup/teardown wiring.
Environment Variables
Set these in your CI/CD pipeline (e.g., GitLab CI/CD variables):
Required
| Variable | Description | Example |
|---|---|---|
PIXMOAT_PROJECT | Project slug | my-project |
PIXMOAT_TOKEN | Project access token | pxg_abc123... |
PIXMOAT_BRANCH | Git branch name | $CI_COMMIT_BRANCH |
PIXMOAT_COMMIT | Git commit SHA | $CI_COMMIT_SHA |
Optional
| Variable | Description | Example |
|---|---|---|
PIXMOAT_URL | pixmoat SaaS base URL (default: https://app.pixmoat.com) | https://app.pixmoat.com |
PIXMOAT_BUILD_ID | Pre-created build ID (skips startBuild) | — |
PIXMOAT_CI_URL | CI job URL for linking in the UI | $CI_PIPELINE_URL |
PIXMOAT_PR | MR/PR number for baseline branching | $CI_MERGE_REQUEST_IID |
PIXMOAT_VIEWPORTS | Default viewports (comma-separated) | 1280x720,375x812 |
PIXMOAT_DPR | Default device pixel ratio (1, 2, or 3) | 2 |
PIXMOAT_BROWSERS | Default browsers (comma-separated) | chromium,firefox,webkit |
PIXMOAT_PERFORMANCE | Collect Core Web Vitals | true |
PIXMOAT_CODEOWNERS | Set to 0 to disable CODEOWNERS auto-discovery | 0 |
Global Setup & Teardown
Both the fixture and reporter approaches need a build to be started before tests run and finished after all tests complete. Wire this into your Playwright config:
global-setup.ts
import { startBuild } from "@s4labs/pixmoat-playwright";
export default async function globalSetup() {
await startBuild();
}
startBuild() creates a new build on the pixmoat server and stores the build ID in process.env.PIXMOAT_BUILD_ID so all workers share it.
If PIXMOAT_BUILD_ID is already set (e.g., from an outer CI script), startBuild() reuses it without creating a new build.
You can also pass overrides:
await startBuild({
branch: "main",
commitSha: "abc123",
ciUrl: "https://gitlab.example.com/project/-/pipelines/42",
prNumber: 15,
});
global-teardown.ts
import { finishBuild } from "@s4labs/pixmoat-playwright";
export default async function globalTeardown() {
await finishBuild();
}
finishBuild() marks the build as complete and logs a summary (total snapshots, new/unchanged/diff counts).
playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: "./global-setup.ts",
globalTeardown: "./global-teardown.ts",
// ... your other config
});
Approach 1: Fixture API
Use the fixture API when you want explicit control over which pages are captured, with per-snapshot options like viewports, masking, and tolerance.
Basic usage
Import test from @s4labs/pixmoat-playwright instead of @playwright/test:
import { test } from "@s4labs/pixmoat-playwright";
import { expect } from "@playwright/test";
test("homepage visual check", async ({ page, pixmoat }) => {
await page.goto("/");
await pixmoat.snapshot(page, "homepage");
});
test("checkout page", async ({ page, pixmoat }) => {
await page.goto("/checkout");
await pixmoat.snapshot(page, "checkout", { fullPage: true });
});
The pixmoat fixture is automatically available when you use the re-exported test object.
Snapshot options
pixmoat.snapshot() accepts a third options parameter:
await pixmoat.snapshot(page, "dashboard", {
fullPage: true, // Capture entire scrollable page
viewport: "1280x720", // Override viewport for this snapshot
viewports: ["1280x720", "375x812"], // Capture at multiple viewports
browser: "chromium", // Override browser label
browsers: ["chromium", "firefox"], // Capture with multiple browsers
dpr: 2, // Device pixel ratio
tolerance: 0.05, // Pixel diff tolerance (0-1)
performance: false, // Skip performance metrics for this snapshot
accessibility: false, // Skip accessibility audit for this snapshot
themes: ["dark"], // Override theme capture for this snapshot
animations: "allow", // Animation mode: "end" | "pause" | "allow"
mask: [
page.locator(".timestamp"), // Mask dynamic content
{ x: 10, y: 20, w: 100, h: 50 }, // Mask by coordinates
],
});
Multi-viewport capture
Capture the same page at multiple viewport sizes in a single call:
await pixmoat.snapshot(page, "responsive-layout", {
viewports: ["1920x1080", "1280x720", "768x1024", "375x812"],
});
Each viewport produces a separate snapshot upload. The original viewport is always restored after capture, even if an error occurs.
You can also set default viewports via PIXMOAT_VIEWPORTS=1280x720,375x812 — these apply to all snapshots unless overridden per-snapshot.
Multi-browser capture
Capture with multiple browser engines:
await pixmoat.snapshot(page, "cross-browser", {
browsers: ["chromium", "firefox", "webkit"],
});
The current page’s browser is reused for matching captures. Secondary browsers are launched, navigated to the same URL, captured, and closed automatically. Failures in secondary browsers are logged as warnings — they don’t fail the test.
Multi-browser composes with multi-viewport, producing a browsers x viewports matrix of snapshots.
Masking dynamic content
Mask elements that change between runs (timestamps, avatars, ads):
await pixmoat.snapshot(page, "profile", {
mask: [
page.locator("[data-testid='avatar']"),
page.locator(".relative-time"),
],
});
Masks are resolved to bounding boxes before upload. When using multi-viewport, masks are re-resolved per viewport since element positions change.
Return value
pixmoat.snapshot() returns the comparison result:
const result = await pixmoat.snapshot(page, "checkout");
// result.result: "new" | "unchanged" | "diff"
// result.mismatch_ratio: number | null
// result.run_id: string
When using viewports or browsers, it returns an array of results.
Approach 2: Reporter
Use the reporter for zero-edit integration with existing toHaveScreenshot() tests. The reporter intercepts PNG attachments and uploads them to pixmoat, prints the review URL to the console, and generates CI report files for merge request integration — no test code changes needed.
Configuration
In playwright.config.ts:
import { defineConfig } from "@playwright/test";
export default defineConfig({
globalSetup: "./global-setup.ts",
globalTeardown: "./global-teardown.ts",
reporter: [
["html"], // Keep your existing reporters
["@s4labs/pixmoat-playwright/reporter"],
],
// ... your other config
});
The reporter reads configuration from environment variables by default. You can also pass overrides:
["@s4labs/pixmoat-playwright/reporter", {
url: "https://pixmoat.example.com",
project: "my-project",
token: "pxg_abc123",
branch: "main",
commitSha: "abc123",
reportDir: "custom-report-dir", // default: "pixmoat-report"
}],
CI Report Output
The reporter writes three files to the report directory (default: pixmoat-report/, override via PIXMOAT_REPORT_DIR or the reportDir option):
| File | Format | Purpose |
|---|---|---|
pixmoat.env | Dotenv | Exports PIXMOAT_REVIEW_URL, PIXMOAT_BUILD_ID, and PIXMOAT_CI_STATUS for GitLab artifacts:reports:dotenv. The review URL powers the MR “View app” button via environment:url. |
junit.xml | JUnit XML | Test results for GitLab artifacts:reports:junit. Contains per-screenshot testcases when agent-summary data is available, or aggregate count-based testcases as a fallback. Appears in the MR test summary widget. |
report.html | Self-contained HTML | Visual summary with build status badge, counts table, per-screenshot table (when available), and a prominent link to the pixmoat review page. Downloadable via expose_as. |
The reporter also prints the review URL to the console at the end of the test run for quick access.
See Review Merge Requests to connect the review result to your Git provider.
How it works
- Your tests use standard Playwright
toHaveScreenshot()calls — no changes needed - The reporter watches for PNG attachments on each test result
- Each screenshot is uploaded to the pixmoat build with a name derived from the test title
- Snapshot names are generated from the test title path and attachment name (e.g.,
checkout-payment-form-screenshot-1) - Upload failures are logged as warnings — they don’t fail your tests
- A summary is printed at the end of the run
Existing test — no changes required
import { test, expect } from "@playwright/test";
test("checkout form", async ({ page }) => {
await page.goto("/checkout");
// This screenshot is automatically uploaded by the reporter
await expect(page).toHaveScreenshot("checkout.png");
});
Performance Metrics
When PIXMOAT_PERFORMANCE=true is set, the client automatically collects Core Web Vitals and resource metrics from the browser:
- LCP — Largest Contentful Paint
- CLS — Cumulative Layout Shift
- INP — Interaction to Next Paint
- FCP — First Contentful Paint
- TTFB — Time to First Byte
- Resource sizes (JS, CSS, images)
- Request count
Metrics are injected via PerformanceObserver before page navigation and collected at snapshot time. You can disable collection for individual snapshots with performance: false.
Performance metrics are only available with the fixture API, not the reporter.
Accessibility Auditing
When accessibility checking is enabled, the client runs axe-core on each captured page after the screenshot and uploads structured violation data alongside the image. Violations are compared against baselines, surfaced in the review UI with screenshot overlays, and optionally block builds.
Setup
- Install the axe-core Playwright integration:
npm install @axe-core/playwright
- Enable accessibility in your
.pixmoat.yaml:
accessibility:
enabled: true
preset: wcag_aa # contrast_only | wcag_aa | wcag_aaa
gating: advisory # advisory | blocking
Or enable it in the project settings UI under Accessibility.
No test code changes are required. When enabled, the audit runs automatically on every pixmoat.snapshot() call.
Presets
| Preset | Coverage | Rules |
|---|---|---|
contrast_only | Contrast violations only | 2 rules (color-contrast, link-in-text-block) |
wcag_aa (default) | WCAG 2.1 Level A + AA | ~50 rules (contrast, labels, landmarks, ARIA, focus) |
wcag_aaa | WCAG 2.1 Level A + AA + AAA | ~60 rules (includes enhanced contrast 7:1) |
Per-snapshot opt-out
Disable the accessibility audit for individual snapshots that produce noise (e.g. third-party embedded content):
await pixmoat.snapshot(page, "third-party-widget", {
accessibility: false,
});
The visual screenshot is still captured; only the axe-core audit is skipped.
Suppressing specific rules
Add noisy rules to the rules.exclude list in your .pixmoat.yaml:
accessibility:
enabled: true
preset: wcag_aa
rules:
exclude:
- region # Landmark requirements on SPA layouts
What happens on failure
If axe-core fails to run (e.g. the page crashes, or @axe-core/playwright is not installed), the client logs a warning and continues without accessibility results. The screenshot is still captured and uploaded. Accessibility audit failures never fail your Playwright tests.
For the complete accessibility configuration reference, see Accessibility Regression Checking.
Console Capture
When console capture is enabled, the client passively records browser console messages, uncaught page errors, and failed network requests during each snapshot session. Findings are uploaded alongside the screenshot and evaluated server-side against configurable baselines — the console analogue of performance metrics and accessibility auditing.
Setup
Enable console capture in your .pixmoat.yaml:
console:
enabled: true
gating: advisory # advisory | blocking (default: advisory)
capture_levels: # which console levels to capture (default: [error, warning])
- error
- warning
fail_on: # which levels count as violations (default: [pageerror, requestfailed, error])
- pageerror
- requestfailed
- error
ignore: # regex patterns — matching entries are dropped
- "Download the React DevTools"
- "\\[HMR\\]"
allow_known: true # known-baseline findings don't re-trigger (default: true)
Or enable it in the project settings UI under Console.
No test code changes are required. When enabled, console listeners are attached automatically before page navigation on every pixmoat.snapshot() call.
How it works
- Before navigation,
page.on('console'),page.on('pageerror'), andpage.on('requestfailed')listeners are attached to the page - Messages accumulate during the test session
- At capture time, entries are filtered by
capture_levels, deduped by fingerprint, andignorepatterns are applied - The resulting entries are uploaded as a
console_logJSON multipart field (max 64 KB, max 200 entries) - The server compares fingerprints against the branch baseline and computes a result:
new,clean,warnings, orviolations
Captured entry types
| Source | Level | Description |
|---|---|---|
page.on('console') | error, warning, log, info, debug | Browser console messages. Only levels in capture_levels are kept. |
page.on('pageerror') | pageerror | Uncaught JavaScript errors. Always captured regardless of capture_levels. |
page.on('requestfailed') | requestfailed | Failed network requests. Always captured regardless of capture_levels. |
Configuration options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable/disable console capture |
gating | string | "advisory" | advisory (findings shown, never block) or blocking (violations block build finish) |
capture_levels | string[] | ["error", "warning"] | Console levels to capture. pageerror and requestfailed are always captured. |
fail_on | string[] | ["pageerror", "requestfailed", "error"] | Levels that count as violations when new findings appear |
ignore | string[] | [] | Regex patterns matched against message text — matching entries are silently dropped |
allow_known | boolean | true | When true, findings present in the baseline don’t trigger violations |
Per-snapshot opt-out
Disable console capture for individual snapshots that produce noise (e.g. third-party embedded content):
await pixmoat.snapshot(page, "third-party-widget", {
console: false,
});
The visual screenshot is still captured; only the console listeners are skipped.
Common ignore patterns
Suppress framework-specific noise with ignore patterns:
console:
enabled: true
ignore:
# React
- "Download the React DevTools"
- "Warning: ReactDOM.render is no longer supported"
# Vite / HMR
- "\\[vite\\]"
- "\\[HMR\\]"
# Next.js
- "next-dev\\.js"
- "Fast Refresh"
# Browser extensions
- "chrome-extension://"
- "moz-extension://"
# Common third-party
- "Failed to load resource.*google.*analytics"
- "Failed to load resource.*doubleclick"
Fingerprinting and baselines
Each console finding is fingerprinted by hashing the level and a normalized version of the message text. Normalization strips volatile tokens (UUIDs, large numbers, timestamps, URLs) so the same underlying error produces a stable identity across runs.
A finding is new only when its fingerprint is absent from the branch baseline. Teams with existing console errors can approve the current set as a baseline and will only be blocked by newly introduced errors.
What happens on failure
If console listeners fail to attach or drain (e.g. the page crashes), the client logs a warning and continues without console data. Console capture failures never fail your Playwright tests or prevent screenshot upload.
Console capture is only available with the fixture API, not the reporter.
CODEOWNERS Auto-Discovery
When the Playwright fixture starts a build, it automatically discovers and uploads the repository’s CODEOWNERS file. pixmoat uses this to assign changed snapshots to their code owners for reviewer routing (see Merge Request Integration — CODEOWNERS Routing).
How it works
At build start (startBuild()), the fixture searches for a CODEOWNERS file at these conventional locations, in order:
CODEOWNERS.github/CODEOWNERS.gitlab/CODEOWNERSdocs/CODEOWNERS
Paths are resolved relative to the git root. The first match is read and included as the codeowners field in the build-create payload. If no file is found, the field is omitted and no reviewer routing occurs.
Opting out
Disable CODEOWNERS discovery with an environment variable:
PIXMOAT_CODEOWNERS=0 npx playwright test
When set to 0, the fixture skips discovery entirely — even if a CODEOWNERS file exists.
CODEOWNERS format
pixmoat supports the standard CODEOWNERS syntax used by GitHub and GitLab:
# Each line: <pattern> <@owner>...
# Later rules override earlier ones for the same file (last-match-wins).
* @team-default
/src/checkout/ @team-checkout
/src/design-system/ @design
*.css @design @frontend
Supported pattern syntax: *, **, /path/to/dir/, *.ext, and directory patterns. Handles can be @user, @org/team, or email addresses.
Owner map
CODEOWNERS handles (@team-checkout) must be mapped to pixmoat users via the Owner Map in project settings or .pixmoat.yaml. See Merge Request Integration — CODEOWNERS Routing for setup.
Unresolved handles (not in the owner map) are still shown in the review UI as advisory labels but cannot be used for enforcement.
Theme Capture (Light/Dark Mode)
Capture the same pages in multiple color scheme themes to detect visual and accessibility regressions across themes (e.g. dark-mode contrast issues).
Configuration
Add a themes section to your .pixmoat.yaml:
themes:
light:
media: "(prefers-color-scheme: light)"
dark:
media: "(prefers-color-scheme: dark)"
When themes are configured, every pixmoat.snapshot() call captures the page once per theme. The theme is applied via page.emulateMedia({ colorScheme }) — Playwright’s built-in mechanism for switching prefers-color-scheme. No page reload is needed.
How themes are identified
Each theme produces a separate snapshot key by encoding the theme name in the browser dimension: chromium___dark, chromium___light (triple-underscore separator). This means each theme has its own visual baseline, accessibility baseline, and review history.
Composition with viewports and browsers
Theme capture composes with existing multi-capture modes:
- themes x viewports — all combinations are captured
- themes x browsers — theme is the inner loop within each browser dimension
For example, 2 themes x 3 viewports = 6 snapshot uploads per pixmoat.snapshot() call.
Per-snapshot theme override
Override the project default for individual snapshots:
// Capture only dark mode for this snapshot
await pixmoat.snapshot(page, "settings", { themes: ["dark"] });
Theme capture without accessibility
Theme capture is independent of accessibility auditing. You can use it for purely visual regression testing of dark mode without enabling accessibility checking.
Element Attribution
When element map collection is enabled (default: on), the client captures DOM element bounding boxes and identifiers alongside each screenshot. The server intersects these with diff regions to report which elements contain changed pixels — making diff analysis output self-explanatory without visual inspection.
How it works
- Before each screenshot, the client runs
page.evaluate()to walk the DOM - Elements with
data-testid,data-test,id, ordata-componentattributes are collected, along with semantic/interactive elements (header,nav,main,footer,button,a,form,input,h1–h6, etc.) meeting a 24×24 CSS pixel minimum size - The element map (capped at 300 entries) is uploaded as a multipart field alongside the screenshot
- At diff time, the server intersects diff regions with element rects and reports the 3 most specific overlapping elements per region
Adding data-testid for better attribution
Adding data-testid attributes to key UI components gives you precise, stable identifiers in diff reports instead of generic CSS paths.
Before (no data-testid):
<div class="checkout-form">
<button class="btn primary">Place Order</button>
</div>
Diff output attribution:
elements: ["div.checkout-form > button.btn.primary"]
After (with data-testid):
<div data-testid="checkout-form">
<button data-testid="place-order-btn" class="btn primary">Place Order</button>
</div>
Diff output attribution:
elements: ["button[data-testid=place-order-btn]"]
The data-testid identifier is stable across refactors (class names may change, test IDs don’t) and immediately tells you which component changed.
Identifier precedence: data-testid / data-test → id → data-component → CSS-ish path (max 3 levels: tag.class1.class2).
Configuration
Element map collection is enabled by default. To disable it:
- Environment variable:
PIXMOAT_ELEMENT_MAP=false - Per-snapshot:
pixmoat.snapshot(page, "name", { elementMap: false })
Privacy
The element map contains only tag names, attribute-based identifiers (data-testid, id), CSS class names, and geometry (bounding boxes). It never includes text content, attribute values beyond identifiers, URLs, or user data. See Observability — Element Map Privacy for details.
Capture Stabilisation
The client automatically stabilises the page before every screenshot to reduce flaky diffs caused by CSS animations mid-flight, late font swaps, or images still decoding. Stabilisation runs after any beforeSnapshot hook and before page.screenshot() — it never fails a capture.
What happens by default
With no configuration, every capture:
- Freezes CSS animations and transitions — injects a stylesheet setting
animation-duration,animation-delay,transition-duration,transition-delayto0s, hides the blinking text caret (caret-color: transparent), and disables smooth scrolling (scroll-behavior: auto). - Jumps animations to their end state — calls
document.getAnimations().forEach(a => a.finish())so the page shows its settled appearance. - Waits for fonts —
document.fonts.ready. - Waits for images —
img.decode()/completefor all images not yet loaded, bounded by a 3-second timeout.
Configuration
Configure stabilisation in your .pixmoat.yaml:
capture:
animations: end # end | pause | allow (default: end)
wait_for_fonts: true # default: true
wait_for_images: true # default: true
image_timeout_ms: 3000 # default: 3000
All fields are optional — omitted fields use their defaults.
Animation modes
| Mode | Behaviour | Use case |
|---|---|---|
end (default) | Freeze CSS + jump Web Animations API to final frame | Settled UI — the common case |
pause | Freeze CSS at current frame, do not advance | Deliberately capturing a mid-animation state |
allow | No animation handling at all | Testing the animation itself, or opting out entirely |
Per-snapshot overrides
Override the animation mode for individual snapshots:
// Skip all animation handling for a snapshot that tests an animation
await pixmoat.snapshot(page, "loading-spinner", { animations: "allow" });
// Capture mid-animation state
await pixmoat.snapshot(page, "slide-in-progress", { animations: "pause" });
Per-snapshot options take highest precedence, followed by .pixmoat.yaml, then built-in defaults.
CSP fallback
If a Content Security Policy blocks inline style injection (addStyleTag), the client falls back to page.emulateMedia({ reducedMotion: 'reduce' }), which many sites already honour. A warning is logged (pixmoat: stabilization degraded (CSP)) and the stabilization.degraded flag is set in the upload metadata, but the capture always proceeds.
Stabilisation metadata
Each snapshot upload includes a stabilization metadata object recording what was applied:
{
"animations": "end",
"fontsWaited": true,
"imagesWaited": true,
"attributeIgnores": 2,
"attributeHides": 0,
"degraded": false
}
In the review UI, snapshots that were stabilised show a “Stabilised” badge on the snapshot card.
Attribute-Based Ignore Regions
Mark volatile DOM elements to ignore or hide directly in your markup using data-pixmoat attributes. This lets the component author — who knows an element is volatile — declare the ignore at the source, without requiring test code changes.
data-pixmoat="ignore"
Elements with this attribute have their bounding box added to the snapshot’s ignore regions. The diff engine skips pixels inside these boxes, exactly like Locator masks specified in test code.
<!-- This ad slot changes every page load — ignore it in visual diffs -->
<div data-pixmoat="ignore" class="ad-banner">
<iframe src="https://ads.example.com/slot/123"></iframe>
</div>
<!-- Live timestamp changes on every render -->
<span data-pixmoat="ignore" class="relative-time">3 minutes ago</span>
data-pixmoat="hide"
Elements with this attribute are visually hidden (visibility: hidden) before the screenshot. Layout is preserved — surrounding elements stay in place — but the content is blanked. Use this when you want to remove visual noise without leaving a diff-ignored hole.
<!-- Random avatar that would shift layout if removed via display:none -->
<img data-pixmoat="hide" class="avatar" src="/api/random-avatar" />
How it works
During stabilisation (before page.screenshot()), the client:
- Queries all
[data-pixmoat="hide"]elements and setsvisibility: hiddenon each - Queries all
[data-pixmoat="ignore"]elements, collects their bounding boxes, scales to image-pixel coordinates (DPR-aware), and filters out zero-size elements - Merges the resulting ignore regions with any Locator masks or explicit
IgnoreRegionboxes from test code — they are unioned, not replaced
Review UI distinction
Attribute-sourced ignore regions render with a distinct colour tint (blue/info) in the review overlay, separate from manually drawn regions (amber/warning). The legend on the build review page shows counts for each source, so reviewers can tell where each ignore instruction came from.
Attributes are inert
The data-pixmoat attributes have no runtime effect on your application. They are only read during pixmoat screenshot capture. They are safe to leave in production markup — this is the same pattern used by Chromatic (data-chromatic), Percy (data-percy-*), and Happo (data-happo-hide).
GitLab CI Example
A complete CI job for a project using pixmoat:
visual-regression:
stage: test
image: mcr.microsoft.com/playwright:v1.52.0-noble
variables:
PIXMOAT_URL: "https://pixmoat.example.com"
PIXMOAT_PROJECT: "my-project"
PIXMOAT_TOKEN: ${PIXMOAT_TOKEN} # from CI/CD variables
PIXMOAT_BRANCH: ${CI_COMMIT_BRANCH}
PIXMOAT_COMMIT: ${CI_COMMIT_SHA}
PIXMOAT_CI_URL: ${CI_PIPELINE_URL}
PIXMOAT_PR: ${CI_MERGE_REQUEST_IID}
script:
- npm ci
- npx playwright install --with-deps chromium
- npx playwright test
allow_failure: true # Non-blocking until baselines established
Set PIXMOAT_TOKEN as a masked CI/CD variable in your GitLab project settings (Settings > CI/CD > Variables).
Verification
After wiring everything up, run your tests locally:
export PIXMOAT_URL="https://pixmoat.example.com"
export PIXMOAT_PROJECT="my-project"
export PIXMOAT_TOKEN="pxg_your_token"
export PIXMOAT_BRANCH="main"
export PIXMOAT_COMMIT="$(git rev-parse HEAD)"
npx playwright test
You should see:
startBuildlogs a build ID at the start- Each snapshot uploads and returns
new(first run) orunchanged/diff(subsequent runs) finishBuildprints a summary like:
pixmoat build finished: 5 snapshots (3 new, 2 unchanged, 0 diff, 0 missing)
- Open the pixmoat UI at your
PIXMOAT_URL— the build should appear under your project with all uploaded snapshots.
Local Mode
Set mode: "local" to capture screenshots to disk without any network calls, build creation,
or quota consumption. This is used with pixmoat check --local for the local development
loop; the hosted CI workflow remains the authoritative result.
Configuration
Set the mode via environment variable or config:
# Environment variable
PIXMOAT_MODE=local npx playwright test
Or in your Playwright config / pixmoat config:
// pixmoat config
{
mode: "local", // "local" or "ci" (default: "ci")
outDir: ".pixmoat/current" // default output directory
}
Behaviour in local mode
pixmoat.snapshot()writes<outDir>/<key>.pngwhere key is{name}__{viewport}__{browser}__{dpr}<outDir>/index.jsonaccumulates metadata entries (name, viewport, browser, dpr, full_page, ignore_regions, tolerance, element_map, sha256)- No network calls are made — no build creation, no manifest fetch, no upload
- Default
outDiris.pixmoat/current - The
index.jsoncarries the same metadata fields as the upload path, ensuring parity between local and CI diffing
When to use
Use local mode for the inner development loop: edit code, capture locally, run pixmoat check --local, iterate. Once satisfied, push to CI for the authoritative result.
Local mode is not a replacement for CI. Push the final capture to CI for the authoritative review.
Troubleshooting
| Issue | Solution |
|---|---|
Missing required pixmoat environment variables | Set PIXMOAT_PROJECT and PIXMOAT_TOKEN before running tests |
Error: branch and commitSha are required | Set PIXMOAT_BRANCH and PIXMOAT_COMMIT |
| Snapshots not appearing in UI | Check that PIXMOAT_PROJECT matches the project slug in pixmoat |
| Upload timeout | Ensure the pixmoat server is reachable from the CI runner |
| Reporter shows 0 uploads | Confirm your tests produce PNG attachments (use toHaveScreenshot()) |