Web Application Testing
To test local web applications, write native Python Playwright scripts.
Helper Scripts Available:
scripts/with_server.py- Manages server lifecycle (supports multiple servers)
Always run scripts with --help first to see usage. DO NOT read the source until you try running the script first and find that a customized solution is abslutely necessary. These scripts can be very large and thus pollute your context window. They exist to be called directly as black-box scripts rather than ingested into your context window.
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
Example: Using with_server.py
To start a server, run --help first, then use the helper:
Single server:
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.py
Multiple servers (e.g., 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
To create an automation script, include only Playwright logic (servers are managed automatically):
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
page = browser.new_page()
page.goto('http://localhost:5173') # Server already running and ready
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
# ... your automation logic
browser.close()
Reconnaissance-Then-Action Pattern
-
Inspect rendered DOM:
page.screenshot(path='/tmp/inspect.png', full_page=True) content = page.content() page.locator('button').all() -
Identify selectors from inspection results
-
Execute actions using discovered selectors
Common Pitfall
❌ Don't inspect the DOM before waiting for networkidle on dynamic apps
✅ Do wait for page.wait_for_load_state('networkidle') before inspection
Debugging CSS Layout Bugs (sticky, scroll containers, height chains)
A screenshot or a plausible-sounding root cause is not sufficient confirmation for a layout bug (sticky positioning, scroll containers, flex/grid height chains). These bugs frequently have root causes stacked across the DOM ancestor chain — a locally correct fix (e.g. position: sticky on the target element) can still fail because a parent or the shared app shell constrains it (e.g. align-items: start collapsing the grid item's height, or a shell using min-height: 100vh instead of height: 100vh so no ancestor ever actually becomes a scrolling container).
Protocol:
- Pin down the exact failing measurement before attempting a fix — not "does it look right," but a specific
getBoundingClientRect()/ computed-style /scrollTopvalue at a specific scroll position that demonstrates the bug. Example:page.evaluate("window.scrollTo(0, 1200)") rect = page.locator(".rightColumn").evaluate("el => el.getBoundingClientRect()") scroll_top = page.locator(".content").evaluate("el => el.scrollTop") - Re-run that identical measurement after every fix attempt. Only declare the bug fixed when that specific measurement now passes — not when a new theory sounds more precise than the last one, and not from a screenshot alone.
- Expect to walk up the ancestor chain. If a fix is syntactically correct but the measurement still fails, the real constraint is usually one level higher (grid/flex alignment → the element's own overflow container → a shared shell's height model). Don't stop at the first plausible explanation; keep measuring until the failure condition itself is gone.
Best Practices
- Use bundled scripts as black boxes - To accomplish a task, consider whether one of the scripts available in
scripts/can help. These scripts handle common, complex workflows reliably without cluttering the context window. Use--helpto see usage, then invoke directly. - Use
sync_playwright()for synchronous scripts - Always close the browser when done
- Use descriptive selectors:
text=,role=, CSS selectors, or IDs - Add appropriate waits:
page.wait_for_selector()orpage.wait_for_timeout()
Rendering HTML to PNG
Use uv run --with playwright — no venv setup required. Install the browser once with uv run --with playwright python3 -m playwright install chromium.
from playwright.sync_api import sync_playwright
import os
html_path = os.path.abspath('path/to/file.html')
out_path = os.path.abspath('path/to/output.png')
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(device_scale_factor=4) # 4–5x for sharp output
page.set_viewport_size({'width': 1400, 'height': 900})
page.goto(f'file://{html_path}')
page.wait_for_load_state('networkidle')
# Screenshot a specific element to avoid body padding:
page.locator('.my-container').screenshot(path=out_path)
# Or full page:
# page.screenshot(path=out_path, full_page=True)
browser.close()
Key decisions:
device_scale_factor=4or5— required for sharp text; 1x (default) looks blurry- Element screenshot (
locator.screenshot()) crops to content, avoids body padding full_page=Truecaptures everything but includes body margins — use only if you want the full document
Reference Files
- examples/ - Examples showing common patterns:
element_discovery.py- Discovering buttons, links, and inputs on a pagestatic_html_automation.py- Using file:// URLs for local HTMLconsole_logging.py- Capturing console logs during automation