DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to catch visual regressions on every deploy with screenshot diffs

How to Catch Visual Regressions on Every Deploy with Screenshot Diffs

CSS changes break layouts. A z-index tweak hides a button. A font change shifts text wrapping. Unit tests don't catch any of this — and by the time a user reports it, the deploy is already live.

Visual regression testing captures screenshots before and after each deploy and diffs them. If pixels changed unexpectedly, the build fails.

The usual setup: Playwright or Puppeteer in CI, with Docker to run a browser. It works but adds 5-10 minutes to build time and a heavyweight container. Here's a lighter approach: two PageBolt API calls, one pixel diff, no browser in CI.

The workflow

deploy to staging → screenshot staging → compare to baseline → fail if diff > threshold → deploy to production
Enter fullscreen mode Exit fullscreen mode

GitHub Actions workflow

# .github/workflows/visual-regression.yml
name: Visual regression

on:
  pull_request:
    branches: [main]

jobs:
  visual-diff:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"

      - name: Install dependencies
        run: npm install pixelmatch pngjs

      - name: Wait for staging deploy
        run: |
          for i in $(seq 1 30); do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ vars.STAGING_URL }})
            [ "$STATUS" = "200" ] && break
            echo "Waiting for staging... ($i/30)"
            sleep 10
          done

      - name: Run visual diff
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          STAGING_URL: ${{ vars.STAGING_URL }}
          PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
        run: node scripts/visual-diff.js

      - name: Upload diff artifacts
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: visual-diff-results
          path: diff-output/
Enter fullscreen mode Exit fullscreen mode

Visual diff script

// scripts/visual-diff.js
import fs from "fs/promises";
import path from "path";
import { PNG } from "pngjs";
import pixelmatch from "pixelmatch";

const PAGEBOLT_API_KEY = process.env.PAGEBOLT_API_KEY;
const STAGING_URL = process.env.STAGING_URL;
const PRODUCTION_URL = process.env.PRODUCTION_URL;

// Pages to check on every deploy
const PAGES = [
  { name: "home", path: "/" },
  { name: "pricing", path: "/pricing" },
  { name: "login", path: "/login" },
  { name: "dashboard", path: "/dashboard" },
  { name: "docs", path: "/docs" },
];

// Max allowed pixel difference (0–1, fraction of total pixels)
const DIFF_THRESHOLD = 0.02; // 2%

async function screenshot(baseUrl, pagePath) {
  const res = await fetch("https://pagebolt.dev/api/v1/screenshot", {
    method: "POST",
    headers: {
      "x-api-key": PAGEBOLT_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: `${baseUrl}${pagePath}`,
      fullPage: false,
      blockBanners: true,
      blockAds: true,
    }),
  });
  return Buffer.from(await res.arrayBuffer());
}

function diffImages(baselineBuffer, currentBuffer) {
  const baseline = PNG.sync.read(baselineBuffer);
  const current = PNG.sync.read(currentBuffer);

  const { width, height } = baseline;
  const diff = new PNG({ width, height });

  const changedPixels = pixelmatch(
    baseline.data,
    current.data,
    diff.data,
    width,
    height,
    { threshold: 0.1 }
  );

  const totalPixels = width * height;
  const diffRatio = changedPixels / totalPixels;

  return { changedPixels, totalPixels, diffRatio, diffBuffer: PNG.sync.write(diff) };
}

async function main() {
  await fs.mkdir("diff-output", { recursive: true });

  let failed = false;

  for (const page of PAGES) {
    console.log(`Checking ${page.name} (${page.path})...`);

    const [baseline, current] = await Promise.all([
      screenshot(PRODUCTION_URL, page.path),
      screenshot(STAGING_URL, page.path),
    ]);

    const { changedPixels, totalPixels, diffRatio, diffBuffer } = diffImages(baseline, current);
    const pct = (diffRatio * 100).toFixed(2);

    if (diffRatio > DIFF_THRESHOLD) {
      console.error(`❌ ${page.name}: ${pct}% changed (${changedPixels}/${totalPixels} pixels)`);
      await fs.writeFile(`diff-output/${page.name}-baseline.png`, baseline);
      await fs.writeFile(`diff-output/${page.name}-current.png`, current);
      await fs.writeFile(`diff-output/${page.name}-diff.png`, diffBuffer);
      failed = true;
    } else {
      console.log(`✅ ${page.name}: ${pct}% changed (within threshold)`);
    }
  }

  if (failed) {
    console.error("\nVisual regression detected. Check diff-output/ artifacts.");
    process.exit(1);
  }

  console.log("\nAll pages within threshold. ✓");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
Enter fullscreen mode Exit fullscreen mode

Update baselines intentionally

When you make a deliberate design change, update the baseline:

# .github/workflows/update-baselines.yml
name: Update visual baselines

on:
  workflow_dispatch:
    inputs:
      pages:
        description: "Comma-separated page names to update (or 'all')"
        default: "all"

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Update baselines
        env:
          PAGEBOLT_API_KEY: ${{ secrets.PAGEBOLT_API_KEY }}
          PRODUCTION_URL: ${{ vars.PRODUCTION_URL }}
        run: node scripts/update-baselines.js ${{ github.event.inputs.pages }}
      - name: Commit updated baselines
        run: |
          git config user.email "ci@yourapp.com"
          git config user.name "CI Bot"
          git add baselines/
          git diff --staged --quiet || git commit -m "chore: update visual baselines"
          git push
Enter fullscreen mode Exit fullscreen mode

Store baselines in the repo (small teams) or S3 (large teams)

// scripts/update-baselines.js — saves screenshots to baselines/
import fs from "fs/promises";

const pages = process.argv[2] === "all"
  ? PAGES
  : PAGES.filter((p) => process.argv[2].split(",").includes(p.name));

await fs.mkdir("baselines", { recursive: true });

for (const page of pages) {
  const image = await screenshot(process.env.PRODUCTION_URL, page.path);
  await fs.writeFile(`baselines/${page.name}.png`, image);
  console.log(`Updated baseline: ${page.name}`);
}
Enter fullscreen mode Exit fullscreen mode

Post diff summary to PR

- name: Post diff summary to PR
  if: failure()
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const results = JSON.parse(fs.readFileSync('diff-output/results.json'));
      const failed = results.filter(r => r.failed);

      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `## Visual regression detected\n\n${failed.map(r =>
          `- **${r.name}**: ${r.pct}% changed (threshold: 2%)`
        ).join('\n')}\n\nSee diff artifacts in the Actions run.`
      });
Enter fullscreen mode Exit fullscreen mode

No browser in CI, no Docker container, no Playwright setup. Two HTTP calls per page, one pixel diff in Node — the whole check runs in under 30 seconds for five pages.


Try it free — 100 requests/month, no credit card. → Get started in 2 minutes

Top comments (0)