Skip to main content

Overview

The Web App Testing skill provides a comprehensive toolkit for testing local web applications using Playwright. It enables you to verify frontend functionality, debug UI behavior, capture browser screenshots, and analyze browser console logs.

When to Use This Skill

Use the Web App Testing skill when you need to:
  • Test local web applications with automated browser interactions
  • Verify frontend functionality and UI behavior
  • Debug rendering issues or JavaScript errors
  • Capture screenshots for visual verification
  • Analyze browser console logs and network activity
  • Test both static HTML and dynamic web applications

Core Approach

Write native Python Playwright scripts for all testing tasks. The skill provides helper scripts that manage complex workflows as black boxes.
Always run scripts with --help first to see usage before reading source code. These scripts can be very large and will pollute your context window. They’re designed to be called directly, not ingested.

Decision Tree: Choosing Your Approach

User task → Is it static HTML?
    ├─ Yes → Read HTML file directly to identify selectors
    │         ├─ Success → Write Playwright script using selectors
    │         └─ Fails/Incomplete → Treat as dynamic (below)

    └─ No (dynamic webapp) → Is the server already running?
        ├─ No → Run: python scripts/with_server.py --help
        │        Then use the helper + write simplified Playwright script

        └─ Yes → Reconnaissance-then-action:
            1. Navigate and wait for networkidle
            2. Take screenshot or inspect DOM
            3. Identify selectors from rendered state
            4. Execute actions with discovered selectors

Helper Scripts

Server Management: with_server.py

Manages server lifecycle for testing dynamic applications. Supports multiple concurrent servers. Single server example:
python scripts/with_server.py \
  --server "npm run dev" \
  --port 5173 \
  -- python your_automation.py
Multiple servers example (backend + frontend):
python scripts/with_server.py \
  --server "cd backend && python server.py" --port 3000 \
  --server "cd frontend && npm run dev" --port 5173 \
  -- python your_automation.py
The script:
  1. Starts all specified servers in the background
  2. Waits for each port to become available (health check)
  3. Runs your automation script
  4. Automatically cleans up and stops servers when done
Your automation script only needs to contain Playwright logic—no server management code required.

Writing Automation Scripts

Basic Structure

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    # Always launch chromium in headless mode
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    
    # Server already running and ready
    page.goto('http://localhost:5173')
    
    # CRITICAL: Wait for JS to execute
    page.wait_for_load_state('networkidle')
    
    # Your automation logic here
    
    browser.close()
Critical: Always use headless=True for Chromium. When using with_server.py, servers are already running and ready—no startup code needed.

Reconnaissance-Then-Action Pattern

For dynamic applications, follow this three-step pattern:
1

Inspect Rendered DOM

# Take screenshot for visual inspection
page.screenshot(path='/tmp/inspect.png', full_page=True)

# Get full page HTML
content = page.content()

# List all buttons on page
buttons = page.locator('button').all()
for button in buttons:
    print(f"Button: {button.text_content()}")
2

Identify Selectors

Based on inspection results, identify the selectors you need:
  • Text-based: text=Submit
  • Role-based: role=button[name="Submit"]
  • CSS selectors: .submit-btn, #submit-button
  • IDs: id=submit-btn
3

Execute Actions

# Click using discovered selector
page.click('text=Submit')

# Fill form fields
page.fill('input[name="email"]', '[email protected]')

# Wait for response
page.wait_for_selector('.success-message')

Common Patterns

Static HTML Testing

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    
    # Use file:// URL for local HTML
    page.goto('file:///path/to/your/page.html')
    
    # For static HTML, networkidle may not be necessary
    # but it's still good practice
    page.wait_for_load_state('networkidle')
    
    # Test functionality
    assert page.title() == 'Expected Title'
    page.click('text=Click Me')
    
    browser.close()

Element Discovery

# Discover all interactive elements
buttons = page.locator('button').all()
links = page.locator('a').all()
inputs = page.locator('input').all()

for button in buttons:
    print(f"Button text: {button.text_content()}")
    print(f"Button visible: {button.is_visible()}")

# Find elements by role
submit = page.get_by_role('button', name='Submit')
heading = page.get_by_role('heading', name='Welcome')

# Find by test ID
username_input = page.get_by_test_id('username-input')

Capturing Console Logs

from playwright.sync_api import sync_playwright

logs = []

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    
    # Capture console messages
    page.on('console', lambda msg: logs.append({
        'type': msg.type,
        'text': msg.text
    }))
    
    # Capture errors
    page.on('pageerror', lambda exc: print(f"Error: {exc}"))
    
    page.goto('http://localhost:5173')
    page.wait_for_load_state('networkidle')
    
    # Print captured logs
    for log in logs:
        print(f"[{log['type']}] {log['text']}")
    
    browser.close()

Waiting Strategies

# Wait for specific selector
page.wait_for_selector('.loading-spinner', state='hidden')
page.wait_for_selector('.data-loaded', state='visible')

# Wait for network to be idle
page.wait_for_load_state('networkidle')

# Wait for specific timeout (use sparingly)
page.wait_for_timeout(1000)  # 1 second

# Wait for URL change
page.wait_for_url('**/dashboard')

# Wait for function to return true
page.wait_for_function('() => document.querySelector(".data").children.length > 0')

Best Practices

Before writing custom server management code:
  1. Check if a script exists in scripts/
  2. Run it with --help to see usage
  3. Invoke it directly without reading the source
This prevents context pollution from large utility scripts.
Don’t inspect the DOM before waiting for networkidle on dynamic apps:Don’t do this:
page.goto('http://localhost:5173')
content = page.content()  # Might be incomplete!
Do this:
page.goto('http://localhost:5173')
page.wait_for_load_state('networkidle')
content = page.content()  # Now complete
Prefer selectors that are resilient to changes:Good selectors:
  • text=Submit - Based on visible text
  • role=button[name="Submit"] - Based on accessibility role
  • [data-testid="submit-btn"] - Based on test IDs
Fragile selectors:
  • .MuiButton-root-123 - Generated class names
  • div > div > button:nth-child(3) - Positional selectors
The skill examples use sync_playwright() for synchronous scripts, which are simpler for most testing scenarios.For asynchronous testing (e.g., with async/await patterns), use async_playwright():
from playwright.async_api import async_playwright
import asyncio

async def main():
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        page = await browser.new_page()
        await page.goto('http://localhost:5173')
        await browser.close()

asyncio.run(main())

Common Pitfalls

Common Mistake: Inspecting DOM before JavaScript finishes executingDynamic web apps often render content via JavaScript after initial page load. Always wait for networkidle before inspecting or interacting with the page.

Pitfall: Race Conditions

# ❌ Bad: Clicking immediately might fail
page.goto('http://localhost:5173')
page.click('text=Submit')  # Button might not be ready!

# ✅ Good: Wait for element to be ready
page.goto('http://localhost:5173')
page.wait_for_load_state('networkidle')
page.wait_for_selector('text=Submit', state='visible')
page.click('text=Submit')

Pitfall: Not Closing Browser

# ❌ Bad: Browser process remains running
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto('http://localhost:5173')
    # Missing browser.close()!

# ✅ Good: Always close browser
with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto('http://localhost:5173')
    browser.close()  # Cleanup

Reference Files

The skill includes example files in the examples/ directory:
  • element_discovery.py - Discovering buttons, links, and inputs on a page
  • static_html_automation.py - Using file:// URLs for local HTML
  • console_logging.py - Capturing console logs during automation

Testing Checklist

Before running your automation:
  • Determined if the app is static or dynamic
  • Identified if server management is needed
  • Ran helper scripts with --help if needed
  • Set headless=True for Chromium
  • Added wait_for_load_state('networkidle') for dynamic apps
  • Used descriptive, resilient selectors
  • Added appropriate waits for elements
  • Included browser.close() for cleanup
  • Captured console logs if debugging

Build docs developers (and LLMs) love