Immutable. This exact content is served forever at /api/v1/blob/c998f04543fe646a.
---
name: browser-automation
description: >
Browser automation with Playwright — testing, scraping, monitoring,
form submission, screenshot capture, and multi-page interaction flows.
Covers setup, navigation, element interaction, assertions, and cleanup.
Primary keyword clusters: Playwright browser automation, end-to-end testing
with Playwright, web scraping Python Playwright, headless browser testing,
automated form submission Playwright, synthetic monitoring browser, screenshot
capture automation, CI/CD browser testing, page object model Playwright.
Designed for agentic platforms — Claude Code, Codex, Cursor, Gemini CLI,
OpenClaw, GitHub Copilot, Windsurf, and OpenCode.
version: 1.1.0
author: Skill Foundry
platforms:
- claude-code
- codex
- cursor
- gemini-cli
- openclaw
- copilot
- windsurf
- opencode
tags:
- browser
- playwright
- testing
- scraping
- automation
- e2e
- screenshot
- form-automation
- monitoring
- ci-cd
geo:
primary_workflows:
- e2e_testing
- web_scraping
- form_automation
- screenshot_capture
- synthetic_monitoring
- multi_page_flows
target_roles:
- qa_engineer
- devops_engineer
- full_stack_developer
- data_engineer
complexity_level: intermediate
prerequisite_knowledge:
- python_basics
- async_await_patterns
- html_css_selectors
---
# Browser Automation — Agent Skill
Production-grade browser automation using [Playwright](https://playwright.dev). Covers end-to-end testing, web scraping, synthetic monitoring, form automation, and screenshot capture — with safe defaults, robust selectors, and CI/CD portability.
---
## Quick Reference
| Task | Pattern | Selection Priority |
|---|---|---|
| Launch browser (sync) | `sync_playwright() → p.chromium.launch(headless=True)` | Default for one-off scripts |
| Launch browser (async) | `async_playwright() → await p.chromium.launch()` | Required for pytest suites |
| Find element (best) | `page.get_by_test_id("foo")` | 🥇 data-testid |
| Find element (good) | `page.get_by_role("button", name="Submit")` | 🥈 role-based |
| Find element (ok) | `page.get_by_text("Sign In")` | 🥉 user-visible text |
| Find element (last) | `page.locator("form#login input")` | ⚠️ CSS/XPath fallback |
| Fill a form field | `page.get_by_label("Email").fill("user@example.com")` | Prefer labels |
| Click & wait for result | `await expect(page.get_by_text("Success")).to_be_visible()` | Auto-retries 5s |
| Wait without sleeping | `page.wait_for_selector("[data-testid='result']", state="visible")` | Never `time.sleep()` |
| Screenshot | `page.screenshot(full_page=True)` | Redact PII before saving |
| Navigate SPA pages | `page.goto(url, wait_until="networkidle")` | React/Vue/Angular |
| CI/Docker | Add `args=["--no-sandbox"]` to `launch()` | Required on Linux containers |
| Handle errors | Use context managers (`async with` blocks) | Auto-cleanup on exceptions |
| Wait for API response | `async with page.expect_response(...)` | No brittle sleeps |
| Respectful scraping | `RespectfulScraper` with 1-3s delay | Check robots.txt first |
---
## When to Use This Skill
Trigger this skill when the user asks for:
- **Browser testing** — "write an e2e test for the login flow", "test this form", "check if the dashboard loads", "automate browser regression tests"
- **Web scraping** — "extract all product prices from this page", "scrape the table data", "get the article text", "crawl product listings from this site"
- **Form automation** — "fill out this multi-step form", "submit the registration", "bulk-upload via the web UI", "automate this checkout flow"
- **Screenshot capture** — "take a screenshot of the page", "capture the error state", "full-page screenshot of this blog", "screenshot every page of this site"
- **Synthetic monitoring** — "check if the site is up and the login works", "monitor this checkout flow every 5 minutes", "set up health-check for the dashboard"
- **Multi-page flows** — "go through the onboarding wizard", "walk through the purchase funnel", "verify the password-reset flow", "test the entire signup-to-purchase journey"
Do NOT trigger for:
- Asking about browser features without automation intent ("what browsers support WebGPU?")
- General Playwright API questions without a concrete task ("how does page.waitForSelector work?")
- Discussing browser compatibility in the abstract
- Requests to manually test something in a browser
- UI/UX design feedback without automation
- Asking "what's different between Chrome and Firefox rendering?" — factual, no automation
---
## Common Pitfalls & Anti-Patterns
### ❌ NEVER do these
1. **`time.sleep(N)` — brittle, slow, and flaky**
- Instead: `page.wait_for_selector()`, `page.wait_for_load_state()`, `expect().to_be_visible()`
2. **Hardcoding credentials in scripts**
- Instead: `os.environ["TEST_PASSWORD"]`, `.env` files, or CI secrets
3. **Committing screenshots with PII to version control**
- Instead: Redact sensitive fields, use `clip` parameter, or skip screenshots in CI
4. **Scraping without rate limits — you'll get IP-banned**
- Instead: Use `RespectfulScraper` pattern, 1+ second delays, respect `robots.txt`
5. **Using brittle CSS selectors like `.col-md-4 > div:nth-child(3) > a`**
- Instead: Prioritize `data-testid`, `get_by_role`, `get_by_label`, `get_by_text`
6. **Mixing sync and async Playwright APIs in the same script**
- Pick one API and stay consistent. `sync_playwright()` for scripts, `async_playwright()` for test suites.
7. **Forgetting `--no-sandbox` in Docker/CI**
- Add `args=["--no-sandbox"]` to every `launch()` call. Without it, Chromium refuses to start.
8. **Using `page.content()` for data extraction instead of `.evaluate_all()` or `.text_content()`**
- `page.content()` returns raw HTML that you then have to parse. Use Playwright's built-in extraction.
9. **Not handling cookie banners or modals before interacting with page content**
- Always dismiss cookie consents, accept dialogs, or close overlays before interacting.
10. **Leaving browser processes open on script error**
- Always use context managers (`async with` / `with` blocks) — they clean up even on exceptions.
### ✅ Debugging Checklist (when things go wrong)
- [ ] Did you wait for the element to be visible before interacting?
- [ ] Is the selector valid? Test with `playwright codegen` to verify.
- [ ] Are you using the right `wait_until` strategy for your page type (SPA vs MPA)?
- [ ] If in CI/Docker, did you add `--no-sandbox`?
- [ ] Is there a cookie consent modal blocking interaction?
- [ ] Are you behind a proxy/VPN that interferes with browser network?
- [ ] Did the page trigger a download dialog? Handle with `page.on("download")`.
- [ ] Is the browser closed too early? Check `finally` block or context manager exit order.
---
## Workflow
Follow this ordered pipeline for every browser automation task:
### 1. Setup
```python
# Synchronous (preferred for simple scripts)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
user_agent="Mozilla/5.0 (compatible; AutomationBot/1.0)"
)
page = context.new_page()
# Async (required for pytest-playwright, larger suites)
import asyncio
from playwright.async_api import async_playwright
async def main():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
page = await browser.new_page()
asyncio.run(main())
```
**Decision points:**
- **Headless vs headed:** Use `headless=True` by default. Use `headless=False` when the user needs to observe the action or debug a visual issue.
- **Sync vs async:** Use sync for one-off scripts and quick tasks. Use async when writing pytest fixtures, concurrent scrapers, or large test suites.
- **Chromium vs Firefox vs WebKit:** Default to Chromium for broadest compatibility. Use Firefox/WebKit only when explicitly requested for cross-browser testing.
**CI tip:** If the user mentions CI/CD, add `args=["--no-sandbox"]` to `launch()` for Docker/Linux environments.
### 2. Navigation
```python
# Basic navigation with timeout
try:
page.goto("https://example.com", wait_until="domcontentloaded", timeout=30000)
except playwright._impl._api_types.TimeoutError:
print("Navigation timed out — site may be down or slow")
raise
# Wait for network idle (SPA-heavy pages)
page.goto("https://spa-app.example.com", wait_until="networkidle")
# Useful post-navigation waits
page.wait_for_load_state("domcontentloaded") # HTML parsed
page.wait_for_load_state("load") # all resources loaded
page.wait_for_load_state("networkidle") # no network for 500ms
```
**Navigation strategies by page type:**
| Page type | `wait_until` | Additional wait |
|---|---|---|
| Server-rendered (MPA) | `domcontentloaded` | None usually |
| SPA / React / Vue | `networkidle` | `wait_for_selector` on key element |
| Streaming / WebSocket | `domcontentloaded` | Wait for specific content or state |
| Slow third-party embeds | `load` | Ignore third-party timeouts |
### 3. Interaction
**Selectors — in order of preference:**
```python
# 1. data-testid (most stable)
page.click("[data-testid='submit-button']")
# 2. Role-based (accessible)
page.get_by_role("button", name="Submit").click()
# 3. Text content (user-visible stable)
page.get_by_text("Sign In").click()
# 4. Label (forms)
page.get_by_label("Email address").fill("user@example.com")
# 5. Placeholder
page.get_by_placeholder("Search...").fill("query")
# 6. CSS/XPath (last resort)
page.locator("form#login input[name='email']").fill("user@example.com")
```
**Common interaction patterns:**
```python
# Form filling
await page.get_by_label("Email").fill("user@example.com")
await page.get_by_label("Password").fill("s3cret")
await page.get_by_role("button", name="Log In").click()
# Dropdown / select
await page.select_option("select#country", value="DE")
# Checkbox / radio
await page.get_by_label("I agree to terms").check()
# File upload
await page.set_input_files("input[type='file']", "/path/to/file.pdf")
# Hover and nested interactions
await page.get_by_text("Products").hover()
await page.get_by_text("New Arrivals").click()
# Keyboard shortcuts
await page.keyboard.press("Enter")
await page.keyboard.press("Control+A")
```
**Wait strategies:**
```python
# Never use time.sleep(). Use these instead:
await page.wait_for_selector("[data-testid='result']", state="visible", timeout=10000)
await page.wait_for_function("() => document.querySelector('.spinner') === null")
await page.wait_for_url("**/dashboard**")
await page.wait_for_load_state("networkidle")
# For dynamic content that appears/disappears
await expect(page.get_by_text("Loading...")).to_be_hidden(timeout=15000)
await expect(page.get_by_text("Results")).to_be_visible(timeout=15000)
# For network-triggered updates
async with page.expect_response(lambda r: "/api/results" in r.url):
await page.click("[data-testid='search-button']")
```
### 4. Assertion
```python
from playwright.async_api import expect
# Page-level
await expect(page).to_have_title("Dashboard — My App")
await expect(page).to_have_url("https://app.example.com/dashboard")
# Element visibility
await expect(page.get_by_text("Welcome back")).to_be_visible()
await expect(page.locator(".error-banner")).to_be_hidden()
# Content
await expect(page.get_by_test_id("user-name")).to_have_text("John Doe")
await expect(page.get_by_test_id("item-count")).to_contain_text("5")
# Form state
await expect(page.get_by_label("Email")).to_have_value("user@example.com")
await expect(page.get_by_label("Agree")).to_be_checked()
# Screenshot-based verification
screenshot = await page.screenshot(full_page=True)
# For visual regression, combine with pixelmatch or Percy
# Custom assertions for scraping
items = await page.locator(".product-card").count()
assert items >= 10, f"Expected at least 10 products, found {items}"
```
**Assertion retry behavior:** Playwright `expect` auto-retries for up to 5 seconds (configurable). This is usually what you want — it handles async rendering without brittle sleeps.
### 5. Cleanup
```python
# With context managers (recommended)
async with async_playwright() as p:
async with await p.chromium.launch() as browser:
async with await browser.new_page() as page:
await page.goto("https://example.com")
# ... work ...
# Everything auto-closes at block exit
# Manual cleanup (when not using context managers)
await page.close()
await context.close()
await browser.close()
await p.stop() # playwright instance
```
**Always clean up.** Orphaned browser processes leak memory and ports. Context managers are the safest default — they handle cleanup even on exceptions.
## Error Handling
```python
import asyncio
from playwright.async_api import async_playwright, TimeoutError as PlaywrightTimeout
async def robust_navigation(url: str, retries: int = 2):
"""Navigate with retry logic for flaky networks."""
for attempt in range(retries + 1):
try:
await page.goto(url, wait_until="domcontentloaded", timeout=15000)
return
except PlaywrightTimeout:
if attempt == retries:
raise
print(f"Navigation attempt {attempt + 1} failed, retrying...")
await asyncio.sleep(2 ** attempt) # exponential backoff
# Stale element recovery
try:
await page.click("[data-testid='dynamic-button']")
except PlaywrightTimeout:
# Element may have been removed and re-rendered
await page.wait_for_selector("[data-testid='dynamic-button']", state="attached")
await page.click("[data-testid='dynamic-button']")
# Network failure handling
try:
await page.goto("https://flaky-service.example.com")
except Exception as e:
if "net::ERR_" in str(e):
raise RuntimeError(f"Network error accessing page: {e}")
raise
# Modal/dialog handling (accept before interaction)
page.on("dialog", lambda dialog: dialog.accept())
```
### Common Error Patterns
| Error | Likely cause | Fix |
|---|---|---|
| `TimeoutError` on `goto` | Slow page, network issue | Increase timeout, add retry, check URL |
| `TimeoutError` on selector | Element not rendered yet | Wait for visibility, check selector |
| `Error: strict mode violation` | Selector matches multiple elements | Use `.first`, `.nth()`, or refine selector |
| `Error: Target closed` | Page/browser was closed early | Check cleanup order, use context managers |
| `Error: Element is not visible` | Element exists in DOM but hidden | Wait for `state="visible"` |
| `net::ERR_CONNECTION_REFUSED` | Site is down | Retry, raise clear error, log for monitoring |
## Safety Rules
### Rate Limiting & Respectful Scraping
```python
import time
from typing import List
class RespectfulScraper:
"""Scraper with built-in politeness delays."""
def __init__(self, min_delay: float = 1.0, max_delay: float = 3.0):
self.min_delay = min_delay
self.max_delay = max_delay
self._last_request = 0.0
self._jitter = __import__("random").uniform
async def wait(self):
"""Enforce minimum delay between requests."""
elapsed = time.monotonic() - self._last_request
if elapsed < self.min_delay:
await asyncio.sleep(self.min_delay - elapsed + self._jitter(0, self.max_delay - self.min_delay))
self._last_request = time.monotonic()
async def get(self, page, url: str) -> None:
await self.wait()
await page.goto(url)
```
**Golden rules:**
1. **Delay between requests** — at least 1 second between page loads, longer for small sites
2. **Respect `robots.txt`** — check if scraping is allowed
3. **Set a user agent that identifies your automation** — don't impersonate real browsers
4. **Limit concurrent connections** — no more than 2-3 parallel pages to the same domain
5. **Stop on error patterns** — if you get 429s, 403s, or captchas, back off
6. **Don't scrape auth-walled content** without explicit permission
### Credential Safety
```python
# ✅ DO: Read from environment variables
EMAIL = os.environ["TEST_EMAIL"]
PASSWORD = os.environ["TEST_PASSWORD"]
# ✅ DO: Read from a .env file (gitignored)
from dotenv import load_dotenv
load_dotenv()
# ❌ DON'T: Hardcode credentials
# EMAIL = "admin@company.com" # NEVER DO THIS
# ❌ DON'T: Log credentials
# print(f"Logging in as {EMAIL} with password {PASSWORD}") # NEVER
```
### Screenshot Safety
- Redact sensitive fields before capture when possible
- Never commit screenshots with PII to version control
- Use viewport clipping for targeted captures: `page.screenshot(clip={"x": 0, "y": 0, "width": 800, "height": 600})`
## Quick-Start Templates
### Template: E2E Login Test
```python
import os
from playwright.sync_api import sync_playwright, expect
BASE_URL = os.environ.get("BASE_URL", "https://app.example.com")
EMAIL = os.environ["TEST_EMAIL"]
PASSWORD = os.environ["TEST_PASSWORD"]
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Navigate
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
# Fill & submit
page.get_by_label("Email").fill(EMAIL)
page.get_by_label("Password").fill(PASSWORD)
page.get_by_role("button", name="Sign In").click()
# Assert redirect
expect(page).to_have_url(f"{BASE_URL}/dashboard", timeout=10000)
expect(page.get_by_text("Welcome")).to_be_visible()
browser.close()
print("✅ Login test passed")
```
### Template: Web Scraper
```python
import json
import sys
from playwright.sync_api import sync_playwright
URL = sys.argv[1] if len(sys.argv) > 1 else "https://books.toscrape.com"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(URL, wait_until="domcontentloaded")
books = page.locator(".product_pod").evaluate_all("""
els => els.map(el => ({
title: el.querySelector('h3 a')?.getAttribute('title') || '',
price: el.querySelector('.price_color')?.textContent || '',
availability: el.querySelector('.availability')?.textContent?.trim() || ''
}))
""")
print(json.dumps(books, indent=2))
browser.close()
```
### Template: Multi-Page Monitoring Flow
```python
import os
import sys
from datetime import datetime, timezone
from playwright.sync_api import sync_playwright
URL = os.environ.get("MONITOR_URL", "https://example.com")
def check_flow() -> bool:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
try:
# Step 1: Homepage loads
page.goto(URL, wait_until="domcontentloaded", timeout=15000)
assert page.title(), "Page has no title"
# Step 2: Search works
page.get_by_placeholder("Search").fill("test")
page.keyboard.press("Enter")
page.wait_for_load_state("networkidle")
# Step 3: Results appear
assert page.locator(".search-results").is_visible(), "No results container"
print(f"[{datetime.now(timezone.utc).isoformat()}] ✅ Flow healthy")
return True
except Exception as e:
print(f"[{datetime.now(timezone.utc).isoformat()}] ❌ Flow failed: {e}")
# Save diagnostic screenshot
page.screenshot(path=f"error-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}.png")
return False
finally:
browser.close()
if __name__ == "__main__":
success = check_flow()
sys.exit(0 if success else 1)
```
## Advanced Patterns
### Page Object Model
```python
class LoginPage:
def __init__(self, page):
self.page = page
self.email_input = page.get_by_label("Email")
self.password_input = page.get_by_label("Password")
self.submit_button = page.get_by_role("button", name="Sign In")
self.error_message = page.locator(".alert-error")
async def goto(self):
await self.page.goto("/login", wait_until="networkidle")
async def login(self, email: str, password: str):
await self.email_input.fill(email)
await self.password_input.fill(password)
await self.submit_button.click()
async def get_error(self) -> str:
await self.error_message.wait_for(state="visible")
return await self.error_message.text_content()
```
### Network Interception
```python
# Mock API responses for stable tests
await page.route("**/api/users/**", lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"id": 1, "name": "Test User"}'
))
# Capture network requests for debugging
requests = []
page.on("request", lambda req: requests.append(f"{req.method} {req.url}"))
page.on("response", lambda res: print(f"{res.status} {res.url}"))
# Wait for specific API call to complete
async with page.expect_response(lambda r: "/api/submit" in r.url) as response_info:
await page.click("[data-testid='submit']")
response = await response_info.value
assert response.status == 200
```
### Visual Regression (Screenshot Diffing)
```python
# Capture and compare screenshots
await page.screenshot(path="baseline.png", full_page=True)
# Use with pixelmatch, Percy, or Chromatic for automated diffing
# Element-level screenshot
await page.locator(".pricing-table").screenshot(path="pricing.png")
# Clip to a specific region (avoid dynamic content)
await page.screenshot(
path="header.png",
clip={"x": 0, "y": 0, "width": 1280, "height": 200}
)
```
## Platform Compatibility Notes
### Claude Code (VS Code / CLI)
- Sync API preferred for quick scripts
- Use `subprocess.run(["python", "script.py"])` to execute
- Install: `pip install playwright && playwright install chromium`
### Codex
- Async API for concurrent task handling
- Each session gets its own browser context for isolation
- Use the `save_screenshot` pattern for visual feedback
### Cursor
- Native Python execution, both sync and async work
- `.cursor/rules` can store common Playwright patterns
- Leverage the built-in terminal for `playwright codegen`
### Gemini CLI
- Well-suited for one-shot scraping and monitoring tasks
- Package scripts as standalone Python files
- Use `gemini run script.py` for execution
### OpenClaw
- Install via `pip install playwright` in the OpenClaw environment
- Run scripts through exec tool with PTY for headed mode debugging
- Screenshots are auto-attached in chat output
### GitHub Copilot
- Works natively in VS Code with Python extension
- Chat can generate complete Playwright scripts
- Use `// @ts-check` comments for inline documentation
### Windsurf
- Native Python execution in the IDE terminal
- Use sync API for quick tasks; async for larger test suites
- Store reusable POM classes in project workspace
### OpenCode
- Execute scripts as standalone Python files
- Prefer async API with context managers for safety
- Install Playwright via `pip install playwright && playwright install`
---
## References
- **Playwright docs:** https://playwright.dev/python/docs/intro
- **Selectors guide:** https://playwright.dev/python/docs/selectors
- **API reference:** https://playwright.dev/python/docs/api/class-playwright
- **Best practices:** https://playwright.dev/python/docs/best-practices
- **CI configuration:** https://playwright.dev/python/docs/ci
See also the companion reference files in this skill:
- [`references/playwright-patterns.md`](references/playwright-patterns.md) — POM, fixtures, CI setup
- [`references/selector-strategies.md`](references/selector-strategies.md) — Robust selector hierarchy
- [`references/browser-testing-workflows.md`](references/browser-testing-workflows.md) — Testing patterns