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

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

Source: Dev.to

The Root Problem ## The Better Pattern: External Screenshot API ## Working Code Examples ## Node.js: Capture a screenshot and save it ## Python: Screenshot-as-a-service in pytest ## Generating PDFs for reports ## Integrating into GitHub Actions ## What You Get ## When NOT to Use an API 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: And that's before you hit the real problems: Puppeteer in CI means you're running a full browser as a side effect of your build pipeline. That browser needs: None of this belongs in a CI runner. It's fragile, slow to set up, and one upstream Chrome update away from breaking everything. 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: No Chrome dependencies. No Xvfb. No memory leaks. Just a network call. No apt-get install libatk.... No Chrome setup. The runner just needs Node.js (which it already has). Compared to self-hosted Puppeteer/Playwright in CI: External screenshot APIs are great for CI and scheduled jobs, but there are cases where local Puppeteer still makes sense: For everything else — visual regression tests, PDF generation, social card previews, monitoring screenshots — offloading to an API is the right call. Try SnapAPI free → opspawn.com/snapapi No credit card required to start. Plans from $19/month for 5,000 screenshots. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK: - 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 CODE_BLOCK: - 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 CODE_BLOCK: - 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 CODE_BLOCK: - name: Run screenshot tests run: node screenshot-tests.js env: SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK: - name: Run screenshot tests run: node screenshot-tests.js env: SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }} CODE_BLOCK: - name: Run screenshot tests run: node screenshot-tests.js env: SNAPAPI_KEY: ${{ secrets.SNAPAPI_KEY }} COMMAND_BLOCK: 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 COMMAND_BLOCK: 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); COMMAND_BLOCK: 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); COMMAND_BLOCK: 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 COMMAND_BLOCK: 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") COMMAND_BLOCK: 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") CODE_BLOCK: 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 CODE_BLOCK: 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; } CODE_BLOCK: 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; } CODE_BLOCK: 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 CODE_BLOCK: 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/ CODE_BLOCK: 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/ - 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 - A specific Chrome/Chromium version - A bunch of native system libraries - Enough RAM (Chrome is hungry) - A display server or Xvfb - 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