agentsclimarketplace

Browser automation

Skill JPeetz/agent-skills/browser-automation

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.From its SKILL.md

Install
npx -y skills add JPeetz/agent-skills --skill browser-automation

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

2 things to look at

  • no licenseNo license file was found in the repository. Code published without one is not open source by default, so using it at work is a question for whoever answers licensing questions where you are.
  • 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

23.5 KB, ~5.4k tokens by cl100k_base, as published. Nobody here has run it

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

TaskPatternSelection 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 fieldpage.get_by_label("Email").fill("[email protected]")Prefer labels
Click & wait for resultawait expect(page.get_by_text("Success")).to_be_visible()Auto-retries 5s
Wait without sleepingpage.wait_for_selector("[data-testid='result']", state="visible")Never time.sleep()
Screenshotpage.screenshot(full_page=True)Redact PII before saving
Navigate SPA pagespage.goto(url, wait_until="networkidle")React/Vue/Angular
CI/DockerAdd args=["--no-sandbox"] to launch()Required on Linux containers
Handle errorsUse context managers (async with blocks)Auto-cleanup on exceptions
Wait for API responseasync with page.expect_response(...)No brittle sleeps
Respectful scrapingRespectfulScraper with 1-3s delayCheck 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

# 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

# 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 typewait_untilAdditional wait
Server-rendered (MPA)domcontentloadedNone usually
SPA / React / Vuenetworkidlewait_for_selector on key element
Streaming / WebSocketdomcontentloadedWait for specific content or state
Slow third-party embedsloadIgnore 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("[email protected]")

# 5. Placeholder
page.get_by_placeholder("Search...").fill("query")

# 6. CSS/XPath (last resort)
page.locator("form#login input[name='email']").fill("[email protected]")

Common interaction patterns:

# Form filling
await page.get_by_label("Email").fill("[email protected]")
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("[email protected]")
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

ErrorLikely causeFix
TimeoutError on gotoSlow page, network issueIncrease timeout, add retry, check URL
TimeoutError on selectorElement not rendered yetWait for visibility, check selector
Error: strict mode violationSelector matches multiple elementsUse .first, .nth(), or refine selector
Error: Target closedPage/browser was closed earlyCheck cleanup order, use context managers
Error: Element is not visibleElement exists in DOM but hiddenWait for state="visible"
net::ERR_CONNECTION_REFUSEDSite is downRetry, 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:

  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

# ✅ 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 = "[email protected]"  # 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_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

See also the companion reference files in this skill:

What ships with it: 7 files

60.6 KB alongside SKILL.md, 1 of them executable

evals/

scripts/

Keep looking

Skills are one crate of 325,949. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.