Claude
Skills
Sign in
Back

Testing Webapps

Included with Lifetime
$97 forever

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.

Designscripts

What this skill does


# 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

Related in Design