Initial commit
This commit is contained in:
262
skills/testing-webapps/SKILL.md
Normal file
262
skills/testing-webapps/SKILL.md
Normal file
@@ -0,0 +1,262 @@
|
||||
---
|
||||
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
|
||||
Reference in New Issue
Block a user