Testing Web Applications
To test local web applications, write native Python Playwright scripts.
Prerequisites
Before running any Python scripts, activate the opencode virtual environment:
source ~/.local/opencode-venv/bin/activate
Then use python3 scripts/... normally. If Playwright browser binaries aren't installed yet:
playwright install chromium
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
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()
Waiting Correctly
networkidle is the right default for first load, but it is the wrong tool for asserting that a specific thing happened. Prefer waiting on the condition you actually care about.
page.wait_for_selector("[data-testid=results]", state="visible")
page.wait_for_function("() => document.querySelectorAll('.row').length > 0")
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Saved")).to_be_visible() # auto-retries
Never wait_for_timeout() as a substitute for a condition. A fixed sleep is either too short (flaky) or too long (slow), and it always hides the real race. The one legitimate use is waiting out a known animation duration before a screenshot.
Prefer role-based locators over CSS — they survive restyling and they double as an accessibility assertion:
page.get_by_role("button", name="Delete account")
page.get_by_label("Email address")
page.get_by_role("heading", level=1)
If get_by_role cannot find a control, that is usually a genuine accessibility defect, not a test problem. See building-accessible-interfaces.
Asserting Console Cleanliness
A page that renders correctly while throwing errors is broken. Capture and assert.
errors, warnings = [], []
page.on("console", lambda m: (errors if m.type == "error" else warnings).append(m.text)
if m.type in ("error", "warning") else None)
page.on("pageerror", lambda e: errors.append(f"uncaught: {e}"))
page.on("requestfailed", lambda r: errors.append(f"{r.method} {r.url} — {r.failure}"))
page.goto(URL); page.wait_for_load_state("networkidle")
assert not errors, errors
Accessibility Auditing
Run axe against the live page. This is the automated third of the accessibility check — the keyboard walk and screen-reader pass in building-accessible-interfaces are still required.
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
result = page.evaluate("async () => await axe.run()")
violations = [(v["id"], v["impact"], len(v["nodes"])) for v in result["violations"]]
assert not violations, violations
Keyboard traversal — catches missing focus styles and unreachable controls:
seq = []
for _ in range(40):
page.keyboard.press("Tab")
seq.append(page.evaluate("""() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const s = getComputedStyle(el);
return {tag: el.tagName, name: el.ariaLabel || el.innerText?.slice(0,30),
outline: s.outlineStyle, ring: s.boxShadow !== 'none'};
}"""))
invisible = [s for s in seq if s and s["outline"] == "none" and not s["ring"]]
assert not invisible, f"focusable elements with no visible focus: {invisible}"
Responsive and Visual Checks
for name, w, h in [("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 900)]:
page.set_viewport_size({"width": w, "height": h})
page.wait_for_timeout(200) # settle layout/animation
overflow = page.evaluate("""() => [...document.querySelectorAll('*')]
.filter(e => e.getBoundingClientRect().right > document.documentElement.clientWidth + 1)
.slice(0,5).map(e => e.tagName + '.' + e.className)""")
assert not overflow, f"{name}: horizontal overflow from {overflow}"
page.screenshot(path=f"/tmp/{name}.png", full_page=True)
Then read the screenshots. A passing assertion says the DOM is not overflowing; it says nothing about whether the page looks right. For a full visual audit use the reviewing-interface-quality skill.
Deterministic screenshots — disable animation and caret so repeat runs match:
page.screenshot(path="x.png", animations="disabled", caret="hide", full_page=True)
Controlling the Network
Testing error and empty states requires forcing them. This is how you exercise the state matrix from designing-user-experience.
page.route("**/api/items", lambda r: r.fulfill(status=200, json=[])) # empty state
page.route("**/api/items", lambda r: r.fulfill(status=500)) # error state
page.route("**/api/items", lambda r: r.abort()) # network failure
page.context.set_offline(True) # offline state
# Slow response — verifies the loading state actually appears
page.route("**/api/**", lambda r: (page.wait_for_timeout(3000), r.continue_())[1])
Emulate constrained conditions:
ctx = browser.new_context(color_scheme="dark", reduced_motion="reduce",
locale="ar-EG", viewport={"width": 390, "height": 844})
reduced_motion="reduce" verifies the prefers-reduced-motion block exists. color_scheme="dark" catches contrast that only fails in dark mode.
Debugging a Failing Script
browser = p.chromium.launch(headless=False, slow_mo=400) # watch it run
page.pause() # opens Playwright Inspector
PWDEBUG=1 python3 your_script.py # step through
python3 -m playwright codegen localhost:5173 # record interactions into code
Capture a trace when a failure is intermittent:
context.tracing.start(screenshots=True, snapshots=True)
# ...
context.tracing.stop(path="/tmp/trace.zip") # then: playwright show-trace /tmp/trace.zip
Common Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| Element found but click does nothing | Overlay intercepting the event | page.get_by_role(...).click() reports the interceptor — dismiss it first |
| Passes headed, fails headless | Timing, or a font/layout difference | Wait on conditions, not timeouts; set an explicit viewport |
strict mode violation |
Locator matched multiple elements | Narrow with .first, a role, or a more specific name |
| Screenshot is blank | Captured before paint | wait_for_load_state("networkidle") plus a settle timeout |
| Works once, fails on rerun | Test mutated state and did not reset | Use a fresh browser.new_context() per run |
| Selector breaks after a restyle | CSS-class-based locator | Use get_by_role / get_by_label / data-testid |
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
Related Skills
- reviewing-interface-quality — full visual/UX/a11y audit of a rendered UI
- building-accessible-interfaces — what the axe results mean and how to fix them
- designing-user-experience — the state matrix these network mocks exist to exercise
- systematic-debugging — when a test failure is a real bug, not a test bug