# How to add visual regression testing to Playwright

# How to add visual regression testing to Playwright

Playwright's built-in `toHaveScreenshot()` breaks in CI constantly — different OS, different fonts, sub-pixel anti-aliasing differences between your Mac and the Linux runner. Percy and Applitools fix the flakiness but charge $400+/month before you hit any meaningful scale. There's a third option: screenshot every test via a hosted browser API, and do the diff yourself.

## Why API screenshots are more consistent than local browser screenshots

When you run `page.screenshot()` in Playwright, the rendering happens on whatever machine runs the test: your MacBook, a GitHub Actions Ubuntu runner, or a colleague's Windows laptop. Even with the same Playwright version, you get different results because:

- **Font rendering** differs between operating systems (macOS uses sub-pixel antialiasing, Linux does not by default)
- **GPU compositing** varies by hardware and driver version
- **System font fallbacks** differ when a custom font fails to load

PageBolt screenshots always come from the same hosted Chromium instance, same OS, same fonts. Your baseline captured on Tuesday will match the screenshot taken on the GitHub Actions runner on Friday — because both are rendered by the same browser in the same environment, not your local machine.

## Setup

Install dependencies:

```bash
npm install --save-dev @playwright/test pixelmatch pngjs
```

Add your PageBolt API key to your environment:

```bash
# .env.test (or GitHub Actions secret)
PAGEBOLT_API_KEY=YOUR_API_KEY
```

## Playwright config

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

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'http://localhost:3000',
  },
  // We don't use Playwright's built-in screenshot comparison —
  // we handle it ourselves via the PageBolt API
});
```

## The visual regression helper

```typescript
// tests/helpers/visual-regression.ts
import fs from 'fs';
import path from 'path';
import { PNG } from 'pngjs';
import pixelmatch from 'pixelmatch';

const PAGEBOLT_API = 'https://pagebolt.dev/api/v1/screenshot';
const BASELINE_DIR = path.join(__dirname, '../visual-baselines');

export async function captureViaPageBolt(pageUrl: string): Promise<Buffer> {
  const res = await fetch(PAGEBOLT_API, {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: pageUrl,
      width: 1280,
      height: 720,
      format: 'png',
      blockBanners: true,
      blockAds: true,
      waitUntil: 'networkidle2',
    }),
  });

  if (!res.ok) {
    const err = await res.json();
    throw new Error(`PageBolt error: ${err.error}`);
  }

  return Buffer.from(await res.arrayBuffer());
}

export function getBaselinePath(testName: string): string {
  return path.join(BASELINE_DIR, `${testName}.png`);
}

export function diffScreenshots(
  baseline: Buffer,
  current: Buffer
): { diffPercent: number; diffBuffer: Buffer } {
  const img1 = PNG.sync.read(baseline);
  const img2 = PNG.sync.read(current);
  const { width, height } = img1;
  const diff = new PNG({ width, height });

  const numDiffPixels = pixelmatch(
    img1.data,
    img2.data,
    diff.data,
    width,
    height,
    { threshold: 0.1 }
  );

  const diffPercent = (numDiffPixels / (width * height)) * 100;
  return { diffPercent, diffBuffer: PNG.sync.write(diff) };
}
```

## The test file

```typescript
// tests/visual/homepage.visual.spec.ts
import { test, expect } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import {
  captureViaPageBolt,
  getBaselinePath,
  diffScreenshots,
} from '../helpers/visual-regression';

const BASELINE_DIR = path.join(__dirname, '../visual-baselines');
const DIFF_DIR = path.join(__dirname, '../visual-diffs');
const DIFF_THRESHOLD_PERCENT = 0.5; // fail if more than 0.5% of pixels differ

test.beforeAll(() => {
  fs.mkdirSync(BASELINE_DIR, { recursive: true });
  fs.mkdirSync(DIFF_DIR, { recursive: true });
});

async function visualRegressionCheck(testName: string, url: string) {
  const baselinePath = getBaselinePath(testName);
  const currentScreenshot = await captureViaPageBolt(url);

  if (!fs.existsSync(baselinePath)) {
    // First run — save as baseline
    fs.writeFileSync(baselinePath, currentScreenshot);
    console.log(`Baseline saved: ${baselinePath}`);
    return;
  }

  const baseline = fs.readFileSync(baselinePath);
  const { diffPercent, diffBuffer } = diffScreenshots(baseline, currentScreenshot);

  if (diffPercent > DIFF_THRESHOLD_PERCENT) {
    // Save the diff image for debugging
    const diffPath = path.join(DIFF_DIR, `${testName}-diff.png`);
    fs.writeFileSync(diffPath, diffBuffer);
    throw new Error(
      `Visual regression: ${diffPercent.toFixed(2)}% pixels differ (threshold: ${DIFF_THRESHOLD_PERCENT}%). Diff saved to ${diffPath}`
    );
  }
}

test('homepage looks correct', async () => {
  await visualRegressionCheck('homepage', 'https://your-app.com');
});

test('pricing page looks correct', async () => {
  await visualRegressionCheck('pricing', 'https://your-app.com/pricing');
});

test('dashboard in dark mode looks correct', async () => {
  // Use the PageBolt darkMode param by extending captureViaPageBolt
  const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
    method: 'POST',
    headers: {
      'x-api-key': process.env.PAGEBOLT_API_KEY!,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      url: 'https://your-app.com/dashboard',
      width: 1440,
      height: 900,
      format: 'png',
      darkMode: true,
      blockBanners: true,
      waitForSelector: '.dashboard-content',
    }),
  });

  const screenshot = Buffer.from(await res.arrayBuffer());
  const baselinePath = getBaselinePath('dashboard-dark');

  if (!fs.existsSync(baselinePath)) {
    fs.writeFileSync(baselinePath, screenshot);
    return;
  }

  const baseline = fs.readFileSync(baselinePath);
  const { diffPercent } = diffScreenshots(baseline, screenshot);
  expect(diffPercent).toBeLessThan(DIFF_THRESHOLD_PERCENT);
});
```

## Updating baselines

When you intentionally change the UI and want to update baselines, delete the relevant PNG files from `tests/visual-baselines/` and re-run the tests. The first run will save fresh baselines. Or add a flag:

```typescript
// At the top of your test helper
const UPDATE_BASELINES = process.env.UPDATE_BASELINES === 'true';

// In visualRegressionCheck, replace the "first run" check:
if (!fs.existsSync(baselinePath) || UPDATE_BASELINES) {
  fs.writeFileSync(baselinePath, currentScreenshot);
  console.log(`Baseline saved: ${baselinePath}`);
  return;
}
```

Then run: `UPDATE_BASELINES=true npx playwright test`

## CI: GitHub Actions

```yaml
# .github/workflows/visual-regression.yml
name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  visual-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium

      - name: Run visual regression tests
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
        run: npx playwright test tests/visual/

      - name: Upload diff images on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diffs
          path: tests/visual-diffs/
```

The baselines live in your repo (`tests/visual-baselines/`). Commit them on initial setup. On every PR, the CI job compares fresh PageBolt screenshots against those committed baselines. If anything changes beyond 0.5% of pixels, the job fails and uploads the diff images as artifacts so you can review exactly what changed.

## Capturing specific components with `selector`

You don't have to screenshot the whole page. PageBolt's `selector` parameter crops the screenshot to a specific element — useful for testing a header, a pricing card, or a modal in isolation:

```typescript
const res = await fetch('https://pagebolt.dev/api/v1/screenshot', {
  method: 'POST',
  headers: {
    'x-api-key': process.env.PAGEBOLT_API_KEY!,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    url: 'https://your-app.com/pricing',
    selector: '.pricing-card.pro',
    format: 'png',
  }),
});
```

This returns a PNG cropped to just the `.pricing-card.pro` element — pixel-perfect regardless of the surrounding layout.

---

Try it free — 100 requests/month, no credit card. → [Get started in 2 minutes](https://pagebolt.dev/docs)
