Browser automation powers web testing, scraping, and AI agent interactions. The difference between a flaky script and a reliable system comes down to understanding selectors, waiting strategies, and anti-detection patterns. This skill covers Playwright (recommended) and Puppeteer, with patterns for testing, scraping, and agentic browser control. Key insight: Playwright won the framework war. Unless you need Puppeteer's stealth ecosystem or are Chrome-only, Playwright is the better choice in 202
Browser automation powers web testing, scraping, and AI agent interactions.
The difference between a flaky script and a reliable system comes down to
understanding selectors, waiting strategies, and anti-detection patterns.
This skill covers Playwright (recommended) and Puppeteer, with patterns for
testing, scraping, and agentic browser control. Key insight: Playwright won
the framework war. Unless you need Puppeteer's stealth ecosystem or are
Chrome-only, Playwright is the better choice in 2025.
Critical distinction: Testing automation (predictable apps you control) vs
scraping/agent automation (unpredictable sites that fight back). Different
problems, different solutions.
Principles
Use user-facing locators (getByRole, getByText) over CSS/XPath
Never add manual waits - Playwright's auto-wait handles it
Each test/task should be fully isolated with fresh context
Screenshots and traces are your debugging lifeline
Headless for CI, headed for debugging
Anti-detection is cat-and-mouse - stay current or get blocked
BrowserStack - When: Cross-browser testing at scale Note: Real devices, CI integration
Patterns
Test Isolation Pattern
Each test runs in complete isolation with fresh state
When to use: Testing, any automation that needs reproducibility
TEST ISOLATION:
"""
Each test gets its own:
Browser context (cookies, storage)
Fresh page
Clean state
"""
Playwright Test Example
"""
import { test, expect } from '@playwright/test';
// Each test runs in isolated browser context
test('user can add item to cart', async ({ page }) => {
// Fresh context - no cookies, no storage from other tests
await page.goto('/products');
await page.getByRole('button', { name: 'Add to Cart' }).click();
await expect(page.getByTestId('cart-count')).toHaveText('1');
});
test('user can remove item from cart', async ({ page }) => {
// Completely isolated - cart is empty
await page.goto('/cart');
await expect(page.getByText('Your cart is empty')).toBeVisible();
});
"""
Shared Authentication Pattern
"""
// Save auth state once, reuse across tests
// setup.ts
import { test as setup } from '@playwright/test';
await page.goto('/dashboard');
// apiResponses now contains all API calls
"""
Sharp Edges
Using waitForTimeout Instead of Proper Waits
Severity: CRITICAL
Situation: Waiting for elements or page state
Symptoms:
Tests pass locally, fail in CI. Pass 9 times, fail on the 10th.
"Element not found" errors that seem random. Tests take 30+ seconds
when they should take 3.
Why this breaks:
waitForTimeout is a fixed delay. If the page loads in 500ms, you wait
2000ms anyway. If the page takes 2100ms (CI is slower), you fail.
There's no correct value - it's always either too short or too long.
await page.getByRole('button').click(); # Auto-waits for stable
NEVER use setTimeout or waitForTimeout in production code
CSS Selectors Tied to Styling Classes
Severity: HIGH
Situation: Selecting elements for interaction
Symptoms:
Tests break after CSS refactoring. Selectors like .btn-primary stop
working. Frontend redesign breaks all tests without changing behavior.
Why this breaks:
CSS class names are implementation details for styling, not semantic
meaning. When designers change from .btn-primary to .button--primary,
your tests break even though behavior is identical.
Recommended fix:
Use user-facing locators instead:
WRONG - Tied to CSS:
await page.locator('.btn-primary.submit-form').click();
await page.locator('#sidebar > div.menu > ul > li:nth-child(3)').click();
Symptoms:
Immediate 403 errors. CAPTCHA challenges. Empty pages. "Access Denied"
messages. Works for 1 request, then gets blocked.
Why this breaks:
By default, headless browsers set navigator.webdriver = true. This is
the first thing bot detection checks. It's a bright red flag that
says "I'm automated."
Recommended fix:
Use stealth plugins:
Puppeteer Stealth (best option):
import puppeteer from 'puppeteer-extra';
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
For serious scraping, consider managed solutions like Browserbase.
Tests Share State and Affect Each Other
Severity: HIGH
Situation: Running multiple tests in sequence
Symptoms:
Tests pass individually but fail when run together. Order matters -
test B fails if test A runs first. Random failures that "fix themselves"
on rerun.
Why this breaks:
Shared browser context means shared cookies, localStorage, and session
state. Test A logs in, test B expects logged-out state. Test A adds
item to cart, test B's cart count is wrong.
// 1. Save auth state to file
await context.storageState({ path: './auth.json' });
// 2. Reuse in other tests
const context = await browser.newContext({
storageState: './auth.json'
});
Never modify global state in tests
Never rely on previous test's actions
No Trace Capture for CI Failures
Severity: MEDIUM
Situation: Debugging test failures in CI
Symptoms:
"Test failed in CI" with no useful information. Can't reproduce
locally. Screenshot shows page but not what went wrong. Guessing
at root cause.
Why this breaks:
CI runs headless on different hardware. Timing is different. Network
is different. Without traces, you can't see what actually happened -
the sequence of actions, network requests, console logs.
Recommended fix:
Enable traces for failures:
playwright.config.ts:
export default defineConfig({
use: {
trace: 'retain-on-failure', # Keep trace on failure
screenshot: 'only-on-failure', # Screenshot on failure
video: 'retain-on-failure', # Video on failure
},
outputDir: './test-results',
});
Symptoms:
Works perfectly when you watch it. Fails mysteriously in CI.
"Element not visible" in headless but visible in headed mode.
Why this breaks:
Headless browsers have no display, which affects some CSS (visibility
calculations), viewport sizing, and font rendering. Some animations
behave differently. Popup windows may not work.
Symptoms:
Works for first 50 pages, then 429 errors. Suddenly all requests fail.
IP gets blocked. CAPTCHA starts appearing after successful requests.
Why this breaks:
Sites monitor request patterns. 100 requests per second from one IP
is obviously automated. Rate limits protect servers and catch scrapers.
Symptoms:
Click button, nothing happens. Test hangs. "Window not found" errors.
Actions succeed but verification fails because you're on wrong page.
Why this breaks:
target="_blank" links open new windows. Your page reference still
points to the original page. The new window exists but you're not
listening for it.
Recommended fix:
Wait for popup BEFORE triggering it:
New window/tab:
const pagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: 'Open in new tab' }).click();
const newPage = await pagePromise;
await newPage.waitForLoadState();
// Now interact with new page
await expect(newPage.getByRole('heading')).toBeVisible();
const pages = context.pages(); // Get all open pages
Can't Interact with Elements in iframes
Severity: MEDIUM
Situation: Page contains embedded iframes
Symptoms:
Element clearly visible but "not found". Selector works in DevTools
but not in Playwright. Parent page selectors work, iframe content
doesn't.
Why this breaks:
iframes are separate documents. page.locator only searches the main
frame. You need to explicitly get the iframe's frame to interact
with its contents.