DEV Community

OpSpawn
OpSpawn

Posted on

How to Automate Screenshot Testing in Your CI/CD Pipeline (Without Puppeteer Headaches)

Puppeteer was supposed to make browser automation easy. And for local dev, it often is. But the moment you push it to CI/CD, things get messy fast.

Here's what a typical Puppeteer setup looks like in a GitHub Actions workflow:

- name: Install Chrome dependencies
  run: |
    sudo apt-get install -y       libnss3 libatk1.0-0 libatk-bridge2.0-0       libcups2 libdrm2 libxkbcommon0 libxcomposite1       libxdamage1 libxfixes3 libxrandr2 libgbm1       libasound2
- name: Run screenshot tests
  run: node screenshot-tests.js
  env:
    PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: false
Enter fullscreen mode Exit fullscreen mode

And that's before you hit the real problems:

  • Chrome crashes with "Error: Protocol error (Target.createTarget): Target closed"
  • Memory leaks on long test runs that OOM-kill your runner
  • Flaky timeouts because CI machines are slower than your laptop
  • Different Chrome versions across environments causing visual diffs

The Root Problem

Puppeteer in CI means you're running a full browser as a side effect of your build pipeline. That browser needs:

  • A specific Chrome/Chromium version
  • A bunch of native system libraries
  • Enough RAM (Chrome is hungry)
  • A display server or Xvfb

None of this belongs in a CI runner. It's fragile, slow to set up, and one upstream Chrome update away from breaking everything.

The Better Pattern: External Screenshot API

What if you just... didn't run a browser in CI at all?

An external screenshot API runs the browser for you, handles all the infrastructure, and gives you a simple HTTP endpoint. Your CI job just makes an API call.

Here's the same workflow using SnapAPI:

- name: Run screenshot tests
  run: node screenshot-tests.js
  env:
    SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
Enter fullscreen mode Exit fullscreen mode

No Chrome dependencies. No Xvfb. No memory leaks. Just a network call.

Working Code Examples

Node.js: Capture a screenshot and save it

const fs = require('fs');
const https = require('https');

async function captureScreenshot(url, outputPath) {
  const payload = JSON.stringify({
    url: url,
    format: 'png',
    width: 1280,
    height: 800,
    wait_for: 'networkidle'
  });

  const options = {
    hostname: 'api.opspawn.com',
    path: '/api/screenshot',
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.SNAPAPI_KEY,
      'Content-Length': Buffer.byteLength(payload)
    }
  };

  return new Promise((resolve, reject) => {
    const req = https.request(options, (res) => {
      const chunks = [];
      res.on('data', chunk => chunks.push(chunk));
      res.on('end', () => {
        if (res.statusCode === 200) {
          const buf = Buffer.concat(chunks);
          fs.writeFileSync(outputPath, buf);
          resolve(outputPath);
        } else {
          reject(new Error(`API error: ${res.statusCode} ${Buffer.concat(chunks).toString()}`));
        }
      });
    });
    req.on('error', reject);
    req.write(payload);
    req.end();
  });
}

// In your test suite:
async function runVisualTests() {
  const pages = [
    { url: 'https://your-staging-app.com/', name: 'homepage' },
    { url: 'https://your-staging-app.com/dashboard', name: 'dashboard' },
    { url: 'https://your-staging-app.com/checkout', name: 'checkout' },
  ];

  for (const page of pages) {
    const path = `screenshots/${page.name}-${Date.now()}.png`;
    await captureScreenshot(page.url, path);
    console.log(`✓ Captured ${page.name}${path}`);
  }
}

runVisualTests().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Python: Screenshot-as-a-service in pytest

import os
import requests
import pytest
from pathlib import Path

SNAPAPI_KEY = os.environ["SNAPAPI_KEY"]
SCREENSHOTS_DIR = Path("test-screenshots")
SCREENSHOTS_DIR.mkdir(exist_ok=True)


def capture_screenshot(url: str, name: str, width: int = 1280) -> Path:
    resp = requests.post(
        "https://api.opspawn.com/api/screenshot",
        headers={"X-API-Key": SNAPAPI_KEY},
        json={
            "url": url,
            "format": "png",
            "width": width,
            "height": 800,
            "wait_for": "networkidle",
        },
        timeout=30,
    )
    resp.raise_for_status()
    out = SCREENSHOTS_DIR / f"{name}.png"
    out.write_bytes(resp.content)
    return out


@pytest.mark.parametrize("page,url", [
    ("homepage", "https://your-app.com/"),
    ("pricing", "https://your-app.com/pricing"),
    ("docs", "https://your-app.com/docs"),
])
def test_page_renders(page, url):
    screenshot = capture_screenshot(url, page)
    assert screenshot.exists()
    assert screenshot.stat().st_size > 10_000, f"{page} screenshot too small (blank page?)"
    print(f"{page}: {screenshot.stat().st_size // 1024}KB")
Enter fullscreen mode Exit fullscreen mode

Generating PDFs for reports

async function generatePDFReport(url, outputPath) {
  const resp = await fetch('https://api.opspawn.com/api/screenshot', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-API-Key': process.env.SNAPAPI_KEY
    },
    body: JSON.stringify({
      url: url,
      format: 'pdf',
      wait_for: 'networkidle'
    })
  });

  if (!resp.ok) throw new Error(`API error: ${resp.status}`);

  const buf = await resp.arrayBuffer();
  require('fs').writeFileSync(outputPath, Buffer.from(buf));
  return outputPath;
}
Enter fullscreen mode Exit fullscreen mode

Integrating into GitHub Actions

name: Visual Regression Tests

on:
  pull_request:
    branches: [main]

jobs:
  screenshots:
    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: Run visual tests
        env:
          SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }}
          TEST_BASE_URL: https://staging.your-app.com
        run: node tests/visual.js

      - name: Upload screenshots
        uses: actions/upload-artifact@v4
        with:
          name: screenshots
          path: screenshots/
Enter fullscreen mode Exit fullscreen mode

No apt-get install libatk.... No Chrome setup. The runner just needs Node.js (which it already has).

What You Get

Compared to self-hosted Puppeteer/Playwright in CI:

Self-hosted browser SnapAPI
CI setup time 2-5 min (dependencies) 0
Memory usage 300-800MB ~0 (HTTP call)
Flakiness High (race conditions) Low (managed infra)
Cross-env consistency Varies by Chrome version Consistent
Maintenance Your problem Not your problem

When NOT to Use an API

External screenshot APIs are great for CI and scheduled jobs, but there are cases where local Puppeteer still makes sense:

  • You need to intercept network requests or mock API responses
  • You're testing browser-specific behavior (extensions, specific Chrome versions)
  • You have strict data residency requirements and can't send URLs to an external service
  • You need sub-100ms response times for real-time features

For everything else — visual regression tests, PDF generation, social card previews, monitoring screenshots — offloading to an API is the right call.


Try SnapAPI freeopspawn.com/snapapi

No credit card required to start. Plans from $19/month for 5,000 screenshots.

Top comments (0)