Browser Automation — Agent Skill
Production-grade browser automation using Playwright. 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
time.sleep(N)— brittle, slow, and flaky- Instead:
page.wait_for_selector(),page.wait_for_load_state(),expect().to_be_visible()
- Instead:
Hardcoding credentials in scripts
- Instead:
os.environ["TEST_PASSWORD"],.envfiles, or CI secrets
- Instead:
Committing screenshots with PII to version control
- Instead: Redact sensitive fields, use
clipparameter, or skip screenshots in CI
- Instead: Redact sensitive fields, use
Scraping without rate limits — you'll get IP-banned
- Instead: Use
RespectfulScraperpattern, 1+ second delays, respectrobots.txt
- Instead: Use
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
- Instead: Prioritize
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.
- Pick one API and stay consistent.
Forgetting
--no-sandboxin Docker/CI- Add
args=["--no-sandbox"]to everylaunch()call. Without it, Chromium refuses to start.
- Add
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.
Not handling cookie banners or modals before interacting with page content
- Always dismiss cookie consents, accept dialogs, or close overlays before interacting.
Leaving browser processes open on script error
- Always use context managers (
async with/withblocks) — they clean up even on exceptions.
- Always use context managers (
✅ Debugging Checklist (when things go wrong)
- Did you wait for the element to be visible before interacting?
- Is the selector valid? Test with
playwright codegento verify. - Are you using the right
wait_untilstrategy 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
finallyblock or context manager exit order.
Workflow
Follow this ordered pipeline for every browser automation task:
1. Setup
# 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=Trueby default. Useheadless=Falsewhen 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
# 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:
# 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:
# 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:
# 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
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
# 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
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
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:
- Delay between requests — at least 1 second between page loads, longer for small sites
- Respect
robots.txt— check if scraping is allowed - Set a user agent that identifies your automation — don't impersonate real browsers
- Limit concurrent connections — no more than 2-3 parallel pages to the same domain
- Stop on error patterns — if you get 429s, 403s, or captchas, back off
- Don't scrape auth-walled content without explicit permission
Credential Safety
# ✅ 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
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
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
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
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
# 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)
# 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_screenshotpattern for visual feedback
Cursor
- Native Python execution, both sync and async work
.cursor/rulescan 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.pyfor execution
OpenClaw
- Install via
pip install playwrightin 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-checkcomments 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— POM, fixtures, CI setupreferences/selector-strategies.md— Robust selector hierarchyreferences/browser-testing-workflows.md— Testing patterns