ScreenshotNeo

BlogHow-to

How to Run Puppeteer Screenshot Tests in GitHub Actions

Set up Puppeteer screenshot tests in GitHub Actions, keep captures repeatable, and save screenshots and failure output as downloadable artifacts.

By the ScreenshotNeo team4 October 20269 min read

Run Puppeteer screenshot tests in GitHub Actions by adding a test command that launches or targets your application, waits for the page state you need, captures a page or element, and exits with a failure code when an assertion fails. Then have a workflow install dependencies from the lockfile, run that command, and upload screenshots and test output as artifacts. Artifacts preserve outputs from a completed job; dependency caches only help reuse downloads.

This guide assumes a Node.js project with Puppeteer already installed or added as a development dependency. Puppeteer provides capture APIs, but a screenshot capture alone does not compare images with a baseline. Choose and configure a visual comparison tool separately if you need pixel or perceptual diffs.

1. Add a screenshot test to your project

Puppeteer captures a page with Page.screenshot() and an element with ElementHandle.screenshot(). See the Puppeteer Screenshots guide and the Page.screenshot API reference.

Install the project dependencies and Puppeteer if it is not already present:

npm install --save-dev puppeteer

Commit the resulting package-lock.json. Create tests/screenshot.test.cjs:

const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const puppeteer = require('puppeteer');

async function main() {
  const outputDir = path.resolve('screenshots');
  await fs.mkdir(outputDir, { recursive: true });

  const browser = await puppeteer.launch({ headless: true });
  try {
    const page = await browser.newPage({
      viewport: { width: 1365, height: 900 },
      deviceScaleFactor: 1,
    });

    const errors = [];
    page.on('pageerror', error => errors.push(error.message));

    const response = await page.goto(
      process.env.BASE_URL || 'http://127.0.0.1:4173',
      { waitUntil: 'networkidle0', timeout: 30000 }
    );
    assert(response, 'Navigation did not return a response');
    assert(response.ok(), `Page returned HTTP ${response.status()}`);

    // Replace this with a selector or app-specific readiness condition.
    await page.locator('[data-testid="app-ready"]').wait();
    assert.deepEqual(errors, [], `Page errors: ${errors.join('; ')}`);

    await page.screenshot({
      path: path.join(outputDir, 'home.png'),
      fullPage: true,
    });

    const card = await page.$('[data-testid="hero-card"]');
    assert(card, 'Expected hero card was not found');
    await card.screenshot({ path: path.join(outputDir, 'hero-card.png') });
  } finally {
    await browser.close();
  }
}

main().catch(error => {
  console.error(error);
  process.exitCode = 1;
});

The example uses a local app URL and test IDs as placeholders: point BASE_URL at the app under test and replace the selectors with elements your application renders. For a unit-style capture test, the app may be started by the test command; for an integration test, start it in a prior workflow step and wait until it is ready. Ensure the test process exits nonzero on failed assertions, and close the browser even after an exception.

Add a script to package.json:

{
  "scripts": {
    "test:screenshot": "node tests/screenshot.test.cjs"
  }
}

If you use npm test for all tests, make the screenshot test part of that existing script or call it explicitly in the workflow. Avoid silently overwriting a useful test command.

2. Create the GitHub Actions workflow

Create .github/workflows/screenshot-tests.yml. GitHub’s Node.js workflow guide documents selecting Node, using npm ci, and running project commands. The versions shown below follow the provided research snapshot; verify current action tags and your project’s supported Node version before adopting them.

name: Puppeteer screenshot tests

on:
  push:
  pull_request:

jobs:
  screenshot-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v7
        with:
          node-version: '20'
          cache: npm

      - name: Install locked dependencies
        run: npm ci

      - name: Run screenshot test
        run: npm run test:screenshot
        env:
          BASE_URL: http://127.0.0.1:4173

      - name: Upload screenshots and test output
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: puppeteer-screenshots
          path: |
            screenshots/
            test-results/
          if-no-files-found: ignore
          retention-days: 7

Change the Node version, install command, test command, output paths, and artifact retention to match your project. GitHub’s artifact documentation describes artifacts as a way to retain and inspect files such as screenshots and test results after a workflow run.

Starting the application in CI

If the test does not start the application itself, add a server step before the test and wait for its readiness. For example, if your package has a start:ci script and a health endpoint, a simple polling script can prevent navigation racing the server startup:

node -e "const u='http://127.0.0.1:4173/health'; let n=0; const t=setInterval(async()=>{try{const r=await fetch(u); if(r.ok){clearInterval(t); process.exit(0)}}catch{} if(++n>60){clearInterval(t); console.error('Server did not become ready'); process.exit(1)}},1000)"

Run the server as a background process before this step, or use a project-specific process manager. Keep server logs in test-results/ if they help explain failures. The readiness URL and server command are application-specific.

3. Keep captures useful and repeatable

  • Fix the viewport and device scale. Set width, height, and deviceScaleFactor explicitly. Keep them the same when generating and reviewing comparison images.
  • Wait for meaningful readiness. Puppeteer locators wait for the element and an appropriate state. Use a locator for interactions and an app-specific readiness selector for content that depends on data or rendering. See Puppeteer page interactions.
  • Choose navigation waits deliberately. networkidle0 can be useful for pages that settle, but pages with polling, analytics, or persistent connections may never become idle. In those cases navigate with a less strict condition such as domcontentloaded, then wait for the specific UI state you need.
  • Control the page state. Use deterministic fixtures or stable test data, consistent authentication, and a known starting URL. Animations, current time, random content, and remote services can change pixels between runs; disable or stabilize them in test setup when they matter.
  • Capture the relevant area. Use fullPage: true for a whole-page view; use an element screenshot when the assertion concerns one component. Element capture scrolls the target into view and can fail if it becomes detached during capture.
  • Keep browser and dependencies consistent. Lock dependencies and use the same supported Node and Puppeteer setup when comparing runs. A lockfile makes installation reproducible, but does not make external page content deterministic.
  • Save diagnostic output. Capture screenshots and logs to known directories. Upload them with if: always() so a failed test can still leave an artifact.

4. Save failures and screenshots as artifacts

Download the artifact from the completed workflow run to inspect the image and any test output. GitHub Actions artifacts are job outputs. Caches are for reusable inputs such as package-manager downloads, not a substitute for retaining test results. GitHub also cautions that restored cache contents should be treated as untrusted input.

The workflow above uploads even if the test step fails. Ensure the paths actually exist in your project. If your test writes diagnostic files only on failure, the same artifact step can collect them; if a path is absent, if-no-files-found: ignore avoids turning a successful test into an artifact-path failure.

5. Capture is not visual comparison

The code above verifies navigation, a readiness selector, and browser page errors, then writes PNG files. It does not decide whether a screenshot differs from a baseline. Puppeteer’s screenshot methods produce image output; the reviewed documentation does not prescribe a particular baseline format, diff library, or tolerance.

To add visual regression checks, select a comparison tool and define how baselines are created, reviewed, and updated. Treat baseline changes as reviewable test data. Keep the capture environment and viewport consistent, and choose a tolerance that matches the rendering differences your team intends to catch.

6. cURL, Python, and Node.js alternatives for a screenshot

If you need a remote screenshot in a script rather than a browser-driven test in GitHub Actions, these calls show the basic shape. They capture a URL; they do not run your Puppeteer assertions or compare against a baseline. See the ScreenshotNeo API documentation for the API options.

cURL

curl -G "https://api.screenshotneo.com/v1/shot" \
  -d access_key=YOUR_API_KEY \
  --data-urlencode url=https://stripe.com \
  -o shot.webp

Python

import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"},
    timeout=90,
)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)

Node.js

const q = new URLSearchParams({
  access_key: 'YOUR_API_KEY',
  url: 'https://stripe.com',
});
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`Screenshot request failed: ${res.status}`);
await require('node:fs/promises').writeFile('shot.webp', Buffer.from(await res.arrayBuffer()));

Or skip the browser setup

ScreenshotNeo is a website screenshot API and MCP server. Its one-call API can return a screenshot or PDF; the example captures a URL as WebP. It does not run your repository’s Puppeteer assertions or visual baseline comparison.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

ScreenshotNeo accepts cookie and consent banners and removes 60+ known consent platforms, newsletter popups, and chat widgets before capture; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits cost nothing, and response headers report the page verdict and billing status. Its MCP server gives AI agents tools for screenshots, page information, and PDF capture. The free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 screenshots. Every feature is on every plan.

Create a free ScreenshotNeo account for 1,000 screenshots a month with no card.

Troubleshooting

Symptom Likely cause Fix
Navigation times out The page does not reach the selected network condition, or the app is not running. Check the URL and server startup. If the page keeps network activity open, wait for domcontentloaded and then wait for an app-specific readiness selector.
Readiness locator times out The selector is wrong, the UI has not reached the expected state, or test data differs. Confirm the selector in the app, inspect logs and page output, and ensure fixtures or authentication are set up before capture.
Element screenshot fails because target detached The page rerendered or removed the element between selection and capture. Wait for rendering to settle, reacquire the element after the update, or capture a stable parent/page. Puppeteer documents detachment as a possible element screenshot failure.
Screenshot directory is empty The test failed before capture, wrote elsewhere, or the artifact path does not match. Check the test output and configured output directory; make sure the upload path matches it. Keep the upload step conditional on always().
npm ci fails The lockfile is missing or out of sync with package.json, or the chosen Node version is unsupported. Regenerate and commit the lockfile using the project’s package manager, and select a supported Node version.
Images vary between runs Viewport, device scale, page data, animation, time, or remote content changes. Fix viewport and device scale, use stable fixtures, wait for application readiness, and disable or control variable page features where possible.
Workflow passes but screenshots are not compared Capture and visual comparison are separate tasks. Add a chosen baseline/diff tool and explicit assertions; the sample only writes images and checks basic page conditions.
Artifact upload reports no files Output paths were not created or do not match the workflow configuration. Correct the paths, create the output directory, or retain if-no-files-found: ignore when no output is expected on some runs.

Performance, reliability, and cost

Hosted runners start clean, so dependency installation and browser startup add work to each job. GitHub’s dependency cache can reduce repeated package downloads; it is an optimization and a cache miss should still allow installation. Keep cache configuration aligned with the lockfile and treat restored contents as untrusted. Uploading artifacts adds workflow storage and transfer, so keep outputs focused and set a retention period that suits review needs.

For reliability, prefer local deterministic pages and fixtures over network-dependent external pages when testing your own app. A successful screenshot capture proves that the browser produced an image; it does not prove visual correctness unless your test compares it. CI runtime and artifact retention costs depend on your GitHub plan and usage; this workflow template makes no cost or speed guarantee.

FAQ

Can I run these tests on pull requests?

Yes. The workflow listens for pull requests and pushes. Review your repository’s permissions and test setup if the job needs secrets or access to private services.

Should I commit generated screenshots?

Use artifacts for run outputs you want to inspect after CI. If you adopt baseline comparison, manage approved baselines according to the comparison tool and your review process.

Can I capture only one component?

Yes. Select its element and call ElementHandle.screenshot(); ensure it remains attached until capture completes.

Does Puppeteer decide whether two screenshots match?

No. Its screenshot APIs capture images. Add a separate comparison step if visual regression detection is required.