262 lines
7.2 KiB
Markdown
262 lines
7.2 KiB
Markdown
---
|
|
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
|
|
|
|
```bash
|
|
# Single server
|
|
python scripts/with_server.py --server "npm run dev" --port 5173 -- python automation.py
|
|
|
|
# Multiple servers
|
|
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**:
|
|
```python
|
|
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') # CRITICAL for dynamic apps
|
|
# automation logic here
|
|
browser.close()
|
|
```
|
|
|
|
**Reconnaissance**:
|
|
```python
|
|
page.screenshot(path='/tmp/inspect.png', full_page=True)
|
|
page.content() # Get HTML
|
|
page.locator('button').all() # Find elements
|
|
```
|
|
|
|
## Headless Mode + Trace Viewer (Recommended for macOS)
|
|
|
|
**Problem**: Headed mode steals window focus on macOS, disrupting workflow.
|
|
|
|
**Solution**: Run headless with trace recording:
|
|
|
|
```python
|
|
import os
|
|
headless = os.getenv('HEADED') != '1' # Default headless, override with 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()
|
|
|
|
# ... test code ...
|
|
|
|
# Save trace on completion
|
|
context.tracing.stop(path="/tmp/trace_testname_SUCCESS.zip")
|
|
```
|
|
|
|
**Debug traces**:
|
|
```bash
|
|
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**:
|
|
```python
|
|
# ❌ Fails with emoji
|
|
page.locator('text="Mission Control"')
|
|
|
|
# ✅ Works with "Mission Control 🚀"
|
|
page.locator('text=/Mission Control/')
|
|
```
|
|
|
|
**Button-specific selectors**:
|
|
```python
|
|
# ❌ Too generic, matches any text
|
|
page.locator('text="Create World"')
|
|
|
|
# ✅ Specific to buttons
|
|
page.locator('button:has-text("Create World")')
|
|
```
|
|
|
|
**Form field specificity**:
|
|
```python
|
|
# ❌ Fragile, matches wrong element
|
|
page.locator('textarea').first
|
|
|
|
# ✅ Specific placeholder
|
|
page.locator('textarea[placeholder*="description"]')
|
|
page.locator('input[type="text"]').nth(2) # If index matters
|
|
```
|
|
|
|
**Wait for both visible AND enabled**:
|
|
```python
|
|
button = page.locator('button:has-text("Submit")')
|
|
expect(button).to_be_visible(timeout=5000)
|
|
expect(button).to_be_enabled(timeout=5000) # Critical for form buttons!
|
|
button.click()
|
|
```
|
|
|
|
## Form Testing Pattern
|
|
|
|
**Rule**: Fill → Wait for enabled → Click
|
|
|
|
**Wrong order (causes timeouts)**:
|
|
```python
|
|
# ❌ Button is disabled, causes "element not enabled" timeout
|
|
button.click()
|
|
textarea.fill("content")
|
|
```
|
|
|
|
**Correct order**:
|
|
```python
|
|
# ✅ Button becomes enabled after fill
|
|
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) # Now enabled
|
|
button.click()
|
|
```
|
|
|
|
**Why**: Most forms disable submit buttons until validation passes. Always fill first.
|
|
|
|
## Test Setup: Database State
|
|
|
|
**Pattern for clean test runs**:
|
|
```bash
|
|
# Reset database before tests
|
|
rm -f backend/database.db
|
|
cd backend && python -c "from src.database import init_db; import asyncio; asyncio.run(init_db())"
|
|
```
|
|
|
|
**In test runner**:
|
|
```python
|
|
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**:
|
|
|
|
```python
|
|
# Setup
|
|
context.tracing.start(screenshots=True, snapshots=True, sources=True)
|
|
logs = []
|
|
page.on("console", lambda msg: logs.append(f"[{msg.type}] {msg.text}"))
|
|
|
|
# During test - take screenshots at key steps
|
|
page.screenshot(path='/tmp/test_step1.png')
|
|
|
|
# On failure
|
|
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**:
|
|
1. Check console logs for errors first (fastest)
|
|
2. View screenshot to understand visual state
|
|
3. 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**:
|
|
1. **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`
|
|
|
|
2. **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:
|
|
```python
|
|
# ❌ Fragile - depends on DOM order
|
|
page.locator('textarea').first
|
|
|
|
# ✅ Robust - matches specific element
|
|
page.locator('textarea[placeholder="World description"]')
|
|
page.locator('button:has-text("Create")').first # If multiple, be specific
|
|
```
|
|
|
|
### "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:
|
|
```python
|
|
# Fill all required fields first
|
|
input1.fill("value1")
|
|
input2.fill("value2")
|
|
|
|
# Then wait for button to enable
|
|
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 |