| name | Puppeteer Browser Automation |
| description | Automate Chrome with Puppeteer for scraping, screenshots, PDF generation, and E2E checks — correct launch options, reliable waiting with locators and waitForSelector, request interception, and headless CI execution. |
| version | 1.0.0 |
| author | thetestingacademy |
| license | MIT |
| tags | ["puppeteer","browser-automation","headless-chrome","scraping","screenshots","pdf","e2e","devtools","ci"] |
| testingTypes | ["e2e"] |
| frameworks | ["puppeteer","jest"] |
| languages | ["typescript","javascript"] |
| domains | ["web"] |
| agents | ["claude-code","cursor","github-copilot","windsurf","codex","aider","continue","cline","zed","bolt","gemini-cli","amp"] |
Puppeteer Browser Automation
This skill makes an AI agent write reliable Puppeteer scripts: launching Chrome with the right flags, navigating and interacting without race conditions, capturing screenshots and PDFs, and intercepting network requests to mock or block traffic. Trigger it when a project uses puppeteer or puppeteer-core, or when the user asks to scrape a page, generate a PDF from HTML, screenshot a site, or automate Chrome without a full test framework.
Core Principles
- Every action must wait for its precondition.
page.click() immediately after page.goto() races against rendering. Use page.locator() (auto-waiting, Puppeteer 21+) or explicit waitForSelector before every interaction.
- Never use fixed sleeps.
await new Promise(r => setTimeout(r, 3000)) is either too short (flaky) or too long (slow). Wait on selectors, network idle, or response predicates instead.
- Always close the browser in
finally. A script that throws before browser.close() leaks a Chrome process. In CI those zombies accumulate until the runner dies.
- Set
waitUntil deliberately. load waits for every image and font; domcontentloaded is enough for interaction; networkidle2 is for SPAs that fetch after load. Pick per page, do not cargo-cult networkidle0.
- Combine navigation-triggering actions with
Promise.all. Clicking a link then awaiting waitForNavigation separately misses fast navigations. Start the wait before the click.
- Request interception is your mock layer. Block analytics and images for speed, stub API responses for determinism — no proxy server needed.
Setup
npm install --save-dev puppeteer typescript tsx
import puppeteer, { Browser } from 'puppeteer';
export async function launchBrowser(): Promise<Browser> {
return puppeteer.launch({
headless: true,
args: [
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--window-size=1366,768',
],
defaultViewport: { width: 1366, height: 768 },
});
}
A complete script with correct lifecycle handling:
import { launchBrowser } from './browser';
async function main(): Promise<void> {
const browser = await launchBrowser();
try {
const page = await browser.newPage();
page.setDefaultTimeout(15_000);
await page.goto('https://practice.expandtesting.com/login', {
waitUntil: 'domcontentloaded',
});
await page.locator('#username').fill('practice');
await page.locator('#password').fill('SuperSecretPassword!');
await Promise.all([
page.waitForNavigation({ waitUntil: 'domcontentloaded' }),
page.locator('button[type="submit"]').click(),
]);
const flash = await page.locator('#flash').waitHandle();
const text = await flash.evaluate((el) => el.textContent?.trim());
if (!text?.includes('You logged into a secure area')) {
throw new Error(`Login failed, flash message: ${text}`);
}
console.log('Login OK');
} finally {
await browser.close();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
npx tsx src/check-login.ts
Patterns
Screenshots and PDF Generation
import { launchBrowser } from './browser';
const browser = await launchBrowser();
try {
const page = await browser.newPage();
await page.goto('https://qaskills.sh', { waitUntil: 'networkidle2' });
await page.screenshot({ path: 'homepage.png', fullPage: true });
const hero = await page.waitForSelector('main section:first-of-type');
await hero!.screenshot({ path: 'hero.png' });
await page.emulateMediaType('print');
await page.pdf({
path: 'homepage.pdf',
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
});
} finally {
await browser.close();
}
Request Interception: Block Noise, Stub APIs
const page = await browser.newPage();
await page.setRequestInterception(true);
page.on('request', (request) => {
const url = request.url();
const type = request.resourceType();
if (type === 'image' || type === 'font' || url.includes('google-analytics')) {
return request.abort();
}
if (url.endsWith('/api/feature-flags')) {
return request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ newCheckout: true, darkMode: false }),
});
}
return request.continue();
});
await page.goto('https://app.example.com/dashboard', { waitUntil: 'networkidle2' });
Waiting on Responses and Extracting Data
const [response] = await Promise.all([
page.waitForResponse(
(res) => res.url().includes('/api/search') && res.status() === 200,
),
page.locator('input[name="q"]').fill('playwright'),
]);
const results = (await response.json()) as { items: { title: string }[] };
const rows = await page.$$eval('table#skills tbody tr', (trs) =>
trs.map((tr) => ({
name: tr.querySelector('td:nth-child(1)')?.textContent?.trim() ?? '',
installs: Number(tr.querySelector('td:nth-child(2)')?.textContent ?? 0),
})),
);
console.log(rows.filter((r) => r.installs > 100));
Reusable Page Helper for Flaky-Free Typing
import type { Page } from 'puppeteer';
export async function clearAndType(page: Page, selector: string, value: string): Promise<void> {
const input = await page.waitForSelector(selector, { visible: true });
await input!.click({ clickCount: 3 });
await input!.press('Backspace');
await input!.type(value, { delay: 20 });
}
Best Practices
- Pin the Puppeteer version; each release bundles a specific Chrome. Mismatched
puppeteer-core + system Chrome is the top source of "works on my machine".
- Set
page.setDefaultTimeout() once per page instead of passing { timeout } everywhere.
- In Docker, use the official
ghcr.io/puppeteer/puppeteer image or install the documented dependency list — a bare node:20-slim will fail with cryptic shared-library errors.
- Reuse one
Browser across many pages; launching Chrome costs 1-2 seconds, browser.newPage() costs milliseconds.
- Capture a screenshot in your
catch block before rethrowing — page.screenshot({ path: 'failure.png' }) turns a CI mystery into a one-look diagnosis.
- For E2E test suites with assertions, fixtures, and retries, prefer Playwright; keep Puppeteer for scraping, PDF/screenshot services, and Chrome-extension automation where it excels.
Anti-Patterns
page.waitForTimeout(3000) / sleep-based waits. Replace with waitForSelector, waitForResponse, or waitForFunction.
headless: false committed to CI scripts. Headful Chrome needs a display server; CI dies with "Missing X server". Gate it behind an env var for local debugging only.
- Scraping inside
page.evaluate with variables captured from Node scope. The callback serializes to the browser; closures over Node objects throw. Pass data as arguments: page.evaluate((sel) => ..., selector).
- One giant try/catch around the whole script with no
finally close. Zombie Chrome processes exhaust CI memory.
- Enabling request interception and forgetting
request.continue() in the default branch — every request hangs and the page never loads.
- Selectors built from generated class names like
.css-1q2w3e. Use IDs, data-testid, ARIA roles, or stable attribute selectors.
When to Trigger This Skill
- The repo depends on
puppeteer or puppeteer-core, or has scripts importing them.
- The user asks to scrape a website, generate PDFs from HTML, or capture screenshots programmatically.
- A headless-Chrome task in Docker/CI is failing with sandbox,
/dev/shm, or missing-library errors.
- Automating Chrome-specific surfaces: extensions, DevTools protocol features, performance traces.
- Existing Puppeteer code is flaky and needs waits, interception, or lifecycle fixes.