| name | Testing Webapps |
| description | Toolkit for interacting with and testing local web applications using Playwright. Supports verifying frontend functionality, debugging UI behavior, capturing browser screenshots, and viewing browser logs. |
| license | Complete terms in LICENSE.txt |
Web Application Testing
Write native Python Playwright scripts to test local webapps.
Helper: scripts/with_server.py manages server lifecycle. Run with --help first.
Approach
Static HTML: Read file → identify selectors → write script
Dynamic webapp:
- Server not running: Use
with_server.py
- Server running: Navigate → wait networkidle → inspect → act
Server Management
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python automation.py
Script Patterns
Automation:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto('http://localhost:5173')
page.wait_for_load_state('networkidle')
browser.close()
Reconnaissance:
page.screenshot(path='/tmp/inspect.png', full_page=True)
page.content()
page.locator('button').all()
Headless Mode + Trace Viewer (Recommended for macOS)
Problem: Headed mode steals window focus on macOS, disrupting workflow.
Solution: Run headless with trace recording:
import os
headless = os.getenv('HEADED') != '1'
browser = p.chromium.launch(headless=headless)
context = browser.new_context()
context.tracing.start(screenshots=True, snapshots=True, sources=True)
page = context.new_page()
context.tracing.stop(path="/tmp/trace_testname_SUCCESS.zip")
Debug traces:
playwright show-trace /tmp/trace_testname_SUCCESS.zip
Why better than headed: Step through at your own pace, inspect DOM at any point, see network requests, no window disruption.
Selector Best Practices
Emoji-safe text matching:
page.locator('text="Mission Control"')
page.locator('text=/Mission Control/')
Button-specific selectors:
page.locator('text="Create World"')
page.locator('button:has-text("Create World")')
Form field specificity:
page.locator('textarea').first
page.locator('textarea[placeholder*="description"]')
page.locator('input[type="text"]').nth(2)
Wait for both visible AND enabled:
button = page.locator('button:has-text("Submit")')
expect(button).to_be_visible(timeout=5000)
expect(button).to_be_enabled(timeout=5000)
button.click()
Form Testing Pattern
Rule: Fill → Wait for enabled → Click
Wrong order (causes timeouts):
button.click()
textarea.fill("content")
Correct order:
textarea = page.locator('textarea[placeholder="description"]')
expect(textarea).to_be_visible(timeout=5000)
textarea.fill("content")
button = page.locator('button:has-text("Submit")')
expect(button).to_be_enabled(timeout=5000)
button.click()
Why: Most forms disable submit buttons until validation passes. Always fill first.
Test Setup: Database State
Pattern for clean test runs:
rm -f backend/database.db
cd backend && python -c "from src.database import init_db; import asyncio; asyncio.run(init_db())"
In test runner:
from pathlib import Path
import subprocess
def setup_clean_database():
"""Reset database to clean state."""
db_path = Path("backend/database.db")
if db_path.exists():
db_path.unlink()
subprocess.run([
"python", "-c",
"from src.database import init_db; import asyncio; asyncio.run(init_db())"
], cwd="backend")
Why: Prevents UUID conflicts, UNIQUE constraint violations, and flaky tests from stale data.
Debugging Triad: Screenshot + Trace + Console
Always capture all three:
context.tracing.start(screenshots=True, snapshots=True, sources=True)
logs = []
page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}"))
page.screenshot(path='/tmp/test_step1.png')
context.tracing.stop(path="/tmp/trace_FAILED.zip")
print(f"Console logs (last 20):")
for log in logs[-20:]:
print(f" {log}")
Why each matters:
- Screenshots: Visual state at failure point
- Trace: Full interaction timeline, DOM snapshots, network activity
- Console: React errors, API failures, JavaScript warnings
Debugging workflow:
- Check console logs for errors first (fastest)
- View screenshot to understand visual state
- Open trace with
playwright show-trace to step through and inspect DOM
Troubleshooting
"Fix doesn't work" - Tests still fail after code change
Symptom: Fixed a bug but tests still fail with same error.
Causes & Solutions:
-
Frontend hot reload hasn't applied changes
- Verify file:
grep "new code" file.jsx
- Check dev server console for reload confirmation
- Hard restart: Kill dev server,
npm run dev
-
Browser cache
- Use
page.goto(..., wait_until='networkidle')
- Or clear:
context.clear_cookies()
Generic selectors match wrong elements
Symptom: textarea.first or button.last fails unexpectedly or matches wrong element.
Cause: DOM structure changed or multiple matching elements exist.
Solution: Use attribute selectors:
page.locator('textarea').first
page.locator('textarea[placeholder="World description"]')
page.locator('button:has-text("Create")').first
"Element not enabled" timeouts
Symptom: page.click() times out with "element is not enabled".
Cause: Trying to click button before form validation passes.
Solution: Fill form first, then wait for enabled:
input1.fill("value1")
input2.fill("value2")
button = page.locator('button:has-text("Submit")')
expect(button).to_be_enabled(timeout=5000)
button.click()
Critical Rules
- Always
page.wait_for_load_state('networkidle') before DOM inspection
- Default to headless with trace recording for debugging without window disruption
- Fill forms before clicking submit buttons (they're usually disabled)
- Use specific selectors with attributes, not generic
.first/.last
- Capture triad: screenshots + trace + console logs for debugging
- Close browser when done
- See
examples/ for more patterns