Files
2025-11-30 09:05:19 +08:00

7.2 KiB

name, description, license
name description license
Testing Webapps 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. 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

# 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:

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:

page.screenshot(path='/tmp/inspect.png', full_page=True)
page.content() # Get HTML
page.locator('button').all() # Find elements

Problem: Headed mode steals window focus on macOS, disrupting workflow.

Solution: Run headless with trace recording:

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:

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:

# ❌ Fails with emoji
page.locator('text="Mission Control"')

# ✅ Works with "Mission Control 🚀"
page.locator('text=/Mission Control/')

Button-specific selectors:

# ❌ Too generic, matches any text
page.locator('text="Create World"')

# ✅ Specific to buttons
page.locator('button:has-text("Create World")')

Form field specificity:

# ❌ 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:

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):

# ❌ Button is disabled, causes "element not enabled" timeout
button.click()
textarea.fill("content")

Correct order:

# ✅ 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:

# 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:

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:

# 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:

# ❌ 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:

# 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