Loading...
Loading...
Use when testing local web applications using Playwright. Triggers include requests to verify frontend functionality, debug UI behavior, capture browser screenshots, view browser logs, or test any web application interactively. Write native Python Playwright scripts for all web application testing tasks.
npx skill4agent add osmontero/opencode-skills testing-webappssource ~/.local/opencode-venv/bin/activatepython3 scripts/...playwright install chromiumscripts/with_server.py--helpUser task → Is it static HTML?
├─ Yes → Read HTML file directly to identify selectors
│ ├─ Success → Write Playwright script using selectors
│ └─ Fails/Incomplete → Treat as dynamic (below)
│
└─ No (dynamic webapp) → Is the server already running?
├─ No → Run: python scripts/with_server.py --help
│ Then use the helper + write simplified Playwright script
│
└─ Yes → Reconnaissance-then-action:
1. Navigate and wait for networkidle
2. Take screenshot or inspect DOM
3. Identify selectors from rendered state
4. Execute actions with discovered selectors--helppython scripts/with_server.py --server "npm run dev" --port 5173 -- python your_automation.pypython scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python your_automation.pyfrom playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # Always launch chromium in headless mode
page = browser.new_page()
page.goto('http://localhost:5173') # Server already running and ready
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
# ... your automation logic
browser.close()page.screenshot(path='/tmp/inspect.png', full_page=True)
content = page.content()
page.locator('button').all()networkidlepage.wait_for_load_state('networkidle')scripts/--helpsync_playwright()text=role=page.wait_for_selector()page.wait_for_timeout()networkidlepage.wait_for_selector("[data-testid=results]", state="visible")
page.wait_for_function("() => document.querySelectorAll('.row').length > 0")
page.get_by_role("button", name="Save").click()
expect(page.get_by_text("Saved")).to_be_visible() # auto-retrieswait_for_timeout()page.get_by_role("button", name="Delete account")
page.get_by_label("Email address")
page.get_by_role("heading", level=1)get_by_rolebuilding-accessible-interfaceserrors, warnings = [], []
page.on("console", lambda m: (errors if m.type == "error" else warnings).append(m.text)
if m.type in ("error", "warning") else None)
page.on("pageerror", lambda e: errors.append(f"uncaught: {e}"))
page.on("requestfailed", lambda r: errors.append(f"{r.method} {r.url} — {r.failure}"))
page.goto(URL); page.wait_for_load_state("networkidle")
assert not errors, errorsbuilding-accessible-interfacespage.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
result = page.evaluate("async () => await axe.run()")
violations = [(v["id"], v["impact"], len(v["nodes"])) for v in result["violations"]]
assert not violations, violationsseq = []
for _ in range(40):
page.keyboard.press("Tab")
seq.append(page.evaluate("""() => {
const el = document.activeElement;
if (!el || el === document.body) return null;
const s = getComputedStyle(el);
return {tag: el.tagName, name: el.ariaLabel || el.innerText?.slice(0,30),
outline: s.outlineStyle, ring: s.boxShadow !== 'none'};
}"""))
invisible = [s for s in seq if s and s["outline"] == "none" and not s["ring"]]
assert not invisible, f"focusable elements with no visible focus: {invisible}"for name, w, h in [("mobile", 390, 844), ("tablet", 768, 1024), ("desktop", 1440, 900)]:
page.set_viewport_size({"width": w, "height": h})
page.wait_for_timeout(200) # settle layout/animation
overflow = page.evaluate("""() => [...document.querySelectorAll('*')]
.filter(e => e.getBoundingClientRect().right > document.documentElement.clientWidth + 1)
.slice(0,5).map(e => e.tagName + '.' + e.className)""")
assert not overflow, f"{name}: horizontal overflow from {overflow}"
page.screenshot(path=f"/tmp/{name}.png", full_page=True)reviewing-interface-qualitypage.screenshot(path="x.png", animations="disabled", caret="hide", full_page=True)designing-user-experiencepage.route("**/api/items", lambda r: r.fulfill(status=200, json=[])) # empty state
page.route("**/api/items", lambda r: r.fulfill(status=500)) # error state
page.route("**/api/items", lambda r: r.abort()) # network failure
page.context.set_offline(True) # offline state
# Slow response — verifies the loading state actually appears
page.route("**/api/**", lambda r: (page.wait_for_timeout(3000), r.continue_())[1])ctx = browser.new_context(color_scheme="dark", reduced_motion="reduce",
locale="ar-EG", viewport={"width": 390, "height": 844})reduced_motion="reduce"prefers-reduced-motioncolor_scheme="dark"browser = p.chromium.launch(headless=False, slow_mo=400) # watch it run
page.pause() # opens Playwright InspectorPWDEBUG=1 python3 your_script.py # step through
python3 -m playwright codegen localhost:5173 # record interactions into codecontext.tracing.start(screenshots=True, snapshots=True)
# ...
context.tracing.stop(path="/tmp/trace.zip") # then: playwright show-trace /tmp/trace.zip| Symptom | Cause | Fix |
|---|---|---|
| Element found but click does nothing | Overlay intercepting the event | |
| Passes headed, fails headless | Timing, or a font/layout difference | Wait on conditions, not timeouts; set an explicit viewport |
| Locator matched multiple elements | Narrow with |
| Screenshot is blank | Captured before paint | |
| Works once, fails on rerun | Test mutated state and did not reset | Use a fresh |
| Selector breaks after a restyle | CSS-class-based locator | Use |
element_discovery.pystatic_html_automation.pyconsole_logging.py