examples/js/console_logging.js
#!/usr/bin/env node
// ABOUTME: Example script for capturing browser console logs
// ABOUTME: Demonstrates log filtering, error detection, and network monitoring
/**
* Console Logging Example
*
* Captures and filters browser console messages and network activity.
* Useful for debugging JavaScript issues and monitoring API calls.
*
* Usage: node console_logging.js <url> [--errors-only] [--network]
*/
const { chromium } = require('playwright');
async function captureLogs(url, options = {}) {
const { errorsOnly = false, captureNetwork = false } = options;
const capture = {
consoleLogs: [],
networkRequests: [],
networkResponses: [],
errors: [],
};
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
// Attach console listener
page.on('console', msg => {
const logEntry = {
type: msg.type(),
text: msg.text(),
location: msg.location(),
};
if (errorsOnly && !['error', 'warning'].includes(msg.type())) {
return;
}
capture.consoleLogs.push(logEntry);
if (msg.type() === 'error') {
capture.errors.push(msg.text());
}
});
// Capture page errors (uncaught exceptions)
page.on('pageerror', err => {
capture.errors.push(err.toString());
});
// Attach network listeners if requested
if (captureNetwork) {
page.on('request', request => {
capture.networkRequests.push({
method: request.method(),
url: request.url(),
resourceType: request.resourceType(),
});
});
page.on('response', response => {
capture.networkResponses.push({
status: response.status(),
url: response.url(),
ok: response.ok(),
});
});
}
await page.goto(url);
await page.waitForLoadState('networkidle');
// Give a moment for any delayed console messages
await page.waitForTimeout(1000);
await browser.close();
return capture;
}
function printCapture(capture, showNetwork = false) {
const typeLabels = {
log: '',
info: '[INFO]',
warning: '[WARN]',
error: '[ERROR]',
debug: '[DEBUG]',
};
console.log('\n=== CONSOLE LOGS ===');
if (capture.consoleLogs.length === 0) {
console.log(' (no logs captured)');
}
for (const log of capture.consoleLogs) {
const prefix = typeLabels[log.type] || `[${log.type.toUpperCase()}]`;
console.log(` ${prefix} ${log.text}`);
}
if (capture.errors.length > 0) {
console.log('\n=== ERRORS ===');
for (const err of capture.errors) {
console.log(` [!] ${err}`);
}
}
if (showNetwork) {
console.log('\n=== NETWORK REQUESTS ===');
for (const req of capture.networkRequests) {
console.log(` --> ${req.method} ${req.url} (${req.resourceType})`);
}
console.log('\n=== NETWORK RESPONSES ===');
const failed = capture.networkResponses.filter(r => !r.ok);
if (failed.length > 0) {
console.log(' Failed responses:');
for (const res of failed) {
console.log(` <-- ${res.status} ${res.url}`);
}
} else {
console.log(` All ${capture.networkResponses.length} responses OK`);
}
}
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
console.log(`
Console Logging - Capture browser console logs and network activity
Usage: node console_logging.js <url> [options]
Arguments:
url Target URL to monitor (e.g., http://localhost:3000)
Options:
--errors-only, -e Only capture errors and warnings
--network, -n Also capture network requests and responses
--help, -h Show this help message
Example:
node console_logging.js http://localhost:3000
node console_logging.js http://localhost:3000 --errors-only --network
`);
process.exit(0);
}
const url = args.find(a => !a.startsWith('-'));
const errorsOnly = args.includes('--errors-only') || args.includes('-e');
const captureNetwork = args.includes('--network') || args.includes('-n');
console.log(`Capturing console logs from: ${url}`);
if (errorsOnly) {
console.log(' (filtering to errors and warnings only)');
}
if (captureNetwork) {
console.log(' (including network activity)');
}
try {
const captured = await captureLogs(url, { errorsOnly, captureNetwork });
printCapture(captured, captureNetwork);
console.log('\nSummary:');
console.log(` Console messages: ${captured.consoleLogs.length}`);
console.log(` Errors: ${captured.errors.length}`);
if (captureNetwork) {
console.log(` Network requests: ${captured.networkRequests.length}`);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();
examples/js/element_discovery.js
#!/usr/bin/env node
// ABOUTME: Example script for discovering interactive elements on a web page
// ABOUTME: Demonstrates locator patterns for buttons, links, inputs, and forms
/**
* Element Discovery Example
*
* Discovers and lists interactive elements on a web page.
* Useful for reconnaissance before writing automation scripts.
*
* Usage: node element_discovery.js <url>
* Example: node element_discovery.js http://localhost:3000
*/
const { chromium } = require('playwright');
async function discoverElements(url) {
const results = {
buttons: [],
links: [],
inputs: [],
forms: [],
};
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url);
await page.waitForLoadState('networkidle');
// Discover buttons
const buttons = await page.locator('button').all();
for (const btn of buttons) {
const text = (await btn.textContent()) || '';
const btnType = (await btn.getAttribute('type')) || 'button';
const btnId = (await btn.getAttribute('id')) || '';
results.buttons.push({
text: text.trim(),
type: btnType,
id: btnId,
});
}
// Discover links
const links = await page.locator('a').all();
for (const link of links) {
const text = (await link.textContent()) || '';
const href = (await link.getAttribute('href')) || '';
results.links.push({
text: text.trim(),
href: href,
});
}
// Discover inputs
const inputs = await page.locator('input, textarea, select').all();
for (const inp of inputs) {
const inpType = (await inp.getAttribute('type')) || 'text';
const inpName = (await inp.getAttribute('name')) || '';
const inpId = (await inp.getAttribute('id')) || '';
const inpPlaceholder = (await inp.getAttribute('placeholder')) || '';
results.inputs.push({
type: inpType,
name: inpName,
id: inpId,
placeholder: inpPlaceholder,
});
}
// Discover forms
const forms = await page.locator('form').all();
for (const form of forms) {
const formId = (await form.getAttribute('id')) || '';
const formAction = (await form.getAttribute('action')) || '';
const formMethod = (await form.getAttribute('method')) || 'get';
results.forms.push({
id: formId,
action: formAction,
method: formMethod,
});
}
await browser.close();
return results;
}
function printResults(results) {
console.log('\n=== BUTTONS ===');
for (const btn of results.buttons) {
console.log(` [${btn.type}] "${btn.text}" (id="${btn.id}")`);
}
console.log('\n=== LINKS ===');
for (const link of results.links) {
console.log(` "${link.text}" -> ${link.href}`);
}
console.log('\n=== INPUTS ===');
for (const inp of results.inputs) {
const selector = inp.name ? `name="${inp.name}"` : `id="${inp.id}"`;
console.log(` [${inp.type}] ${selector} placeholder="${inp.placeholder}"`);
}
console.log('\n=== FORMS ===');
for (const form of results.forms) {
console.log(` ${form.method.toUpperCase()} -> ${form.action} (id="${form.id}")`);
}
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
console.log(`
Element Discovery - Discover interactive elements on a web page
Usage: node element_discovery.js <url>
Arguments:
url Target URL to analyze (e.g., http://localhost:3000)
Options:
--help, -h Show this help message
Example:
node element_discovery.js http://localhost:3000
node element_discovery.js file:///path/to/file.html
`);
process.exit(0);
}
const url = args[0];
console.log(`Discovering elements on: ${url}`);
try {
const discovered = await discoverElements(url);
printResults(discovered);
const total = Object.values(discovered).reduce((sum, arr) => sum + arr.length, 0);
console.log(`\nTotal elements discovered: ${total}`);
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();
examples/js/screenshot_capture.js
#!/usr/bin/env node
// ABOUTME: Example script for capturing browser screenshots
// ABOUTME: Demonstrates viewport, full-page, and element-specific screenshots
/**
* Screenshot Capture Example
*
* Captures various types of screenshots from a web page:
* - Viewport (visible area)
* - Full page (scrolled content)
* - Element-specific (header, main, footer)
* - Responsive (mobile, tablet, desktop)
*
* Usage: node screenshot_capture.js <url> [--output <dir>]
*/
const { chromium } = require('playwright');
const path = require('path');
const fs = require('fs');
async function captureScreenshots(url, outputDir = '/tmp') {
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const savedFiles = [];
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(url);
await page.waitForLoadState('networkidle');
// Viewport screenshot (what's visible)
const viewportPath = path.join(outputDir, 'viewport.png');
await page.screenshot({ path: viewportPath });
savedFiles.push(viewportPath);
console.log(`Saved viewport screenshot: ${viewportPath}`);
// Full page screenshot (scrolled content)
const fullpagePath = path.join(outputDir, 'fullpage.png');
await page.screenshot({ path: fullpagePath, fullPage: true });
savedFiles.push(fullpagePath);
console.log(`Saved full-page screenshot: ${fullpagePath}`);
// Try to capture specific elements
const elementSelectors = [
['header', "header, [role='banner'], nav"],
['main', "main, [role='main'], #content, .content"],
['footer', "footer, [role='contentinfo']"],
];
for (const [name, selector] of elementSelectors) {
try {
const element = page.locator(selector).first();
if (await element.isVisible()) {
const elementPath = path.join(outputDir, `element_${name}.png`);
await element.screenshot({ path: elementPath });
savedFiles.push(elementPath);
console.log(`Saved ${name} element screenshot: ${elementPath}`);
}
} catch (e) {
console.log(`Could not capture ${name}: ${e.message}`);
}
}
// Capture with different viewport sizes (responsive testing)
const viewports = [
['mobile', 375, 667],
['tablet', 768, 1024],
['desktop', 1920, 1080],
];
for (const [name, width, height] of viewports) {
await page.setViewportSize({ width, height });
await page.waitForLoadState('networkidle');
const responsivePath = path.join(outputDir, `responsive_${name}.png`);
await page.screenshot({ path: responsivePath });
savedFiles.push(responsivePath);
console.log(`Saved ${name} (${width}x${height}) screenshot: ${responsivePath}`);
}
await browser.close();
return savedFiles;
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h') || args.length === 0) {
console.log(`
Screenshot Capture - Capture screenshots from a web page
Usage: node screenshot_capture.js <url> [options]
Arguments:
url Target URL to screenshot (e.g., http://localhost:3000)
Options:
--output, -o <dir> Output directory for screenshots (default: /tmp)
--help, -h Show this help message
Example:
node screenshot_capture.js http://localhost:3000
node screenshot_capture.js http://localhost:3000 --output /tmp/shots
`);
process.exit(0);
}
const url = args[0];
let outputDir = '/tmp';
const outputIdx = args.findIndex(a => a === '--output' || a === '-o');
if (outputIdx !== -1 && args[outputIdx + 1]) {
outputDir = args[outputIdx + 1];
}
console.log(`Capturing screenshots from: ${url}`);
console.log(`Output directory: ${outputDir}`);
try {
const files = await captureScreenshots(url, outputDir);
console.log(`\nTotal screenshots captured: ${files.length}`);
console.log('Files:');
for (const f of files) {
console.log(` - ${f}`);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
main();
examples/python/console_logging.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Example script for capturing browser console logs
# ABOUTME: Demonstrates log filtering, error detection, and network monitoring
"""
Console Logging Example
Captures and filters browser console messages and network activity.
Useful for debugging JavaScript issues and monitoring API calls.
"""
import argparse
from dataclasses import dataclass, field
from playwright.sync_api import sync_playwright, ConsoleMessage, Request, Response
@dataclass
class LogCapture:
"""Container for captured logs and network activity."""
console_logs: list[dict] = field(default_factory=list)
network_requests: list[dict] = field(default_factory=list)
network_responses: list[dict] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def capture_logs(
url: str,
errors_only: bool = False,
capture_network: bool = False,
) -> LogCapture:
"""Navigate to URL and capture console logs and network activity."""
capture = LogCapture()
def handle_console(msg: ConsoleMessage) -> None:
log_entry = {
"type": msg.type,
"text": msg.text,
"location": msg.location,
}
if errors_only and msg.type not in ("error", "warning"):
return
capture.console_logs.append(log_entry)
if msg.type == "error":
capture.errors.append(msg.text)
def handle_request(request: Request) -> None:
capture.network_requests.append({
"method": request.method,
"url": request.url,
"resource_type": request.resource_type,
})
def handle_response(response: Response) -> None:
capture.network_responses.append({
"status": response.status,
"url": response.url,
"ok": response.ok,
})
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Attach console listener
page.on("console", handle_console)
# Attach network listeners if requested
if capture_network:
page.on("request", handle_request)
page.on("response", handle_response)
# Capture page errors (uncaught exceptions)
page.on("pageerror", lambda err: capture.errors.append(str(err)))
page.goto(url)
page.wait_for_load_state("networkidle")
# Give a moment for any delayed console messages
page.wait_for_timeout(1000)
browser.close()
return capture
def print_capture(capture: LogCapture, show_network: bool = False) -> None:
"""Print captured logs in a readable format."""
type_colors = {
"log": "",
"info": "[INFO]",
"warning": "[WARN]",
"error": "[ERROR]",
"debug": "[DEBUG]",
}
print("\n=== CONSOLE LOGS ===")
if not capture.console_logs:
print(" (no logs captured)")
for log in capture.console_logs:
prefix = type_colors.get(log["type"], f"[{log['type'].upper()}]")
print(f" {prefix} {log['text']}")
if capture.errors:
print("\n=== ERRORS ===")
for err in capture.errors:
print(f" [!] {err}")
if show_network:
print("\n=== NETWORK REQUESTS ===")
for req in capture.network_requests:
print(f" --> {req['method']} {req['url']} ({req['resource_type']})")
print("\n=== NETWORK RESPONSES ===")
failed = [r for r in capture.network_responses if not r["ok"]]
if failed:
print(" Failed responses:")
for res in failed:
print(f" <-- {res['status']} {res['url']}")
else:
print(f" All {len(capture.network_responses)} responses OK")
def main() -> None:
parser = argparse.ArgumentParser(
description="Capture browser console logs and optionally network activity.",
epilog="Example: uv run console_logging.py http://localhost:3000 --errors-only --network",
)
parser.add_argument(
"url",
help="Target URL to monitor (e.g., http://localhost:3000)",
)
parser.add_argument(
"--errors-only", "-e",
action="store_true",
help="Only capture errors and warnings, ignore info/debug/log messages",
)
parser.add_argument(
"--network", "-n",
action="store_true",
help="Also capture network requests and responses",
)
args = parser.parse_args()
print(f"Capturing console logs from: {args.url}")
if args.errors_only:
print(" (filtering to errors and warnings only)")
if args.network:
print(" (including network activity)")
captured = capture_logs(
args.url,
errors_only=args.errors_only,
capture_network=args.network,
)
print_capture(captured, show_network=args.network)
print(f"\nSummary:")
print(f" Console messages: {len(captured.console_logs)}")
print(f" Errors: {len(captured.errors)}")
if args.network:
print(f" Network requests: {len(captured.network_requests)}")
if __name__ == "__main__":
main()
examples/python/element_discovery.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Example script for discovering interactive elements on a web page
# ABOUTME: Demonstrates locator patterns for buttons, links, inputs, and forms
"""
Element Discovery Example
Discovers and lists interactive elements on a web page.
Useful for reconnaissance before writing automation scripts.
"""
import argparse
from playwright.sync_api import Page, sync_playwright
# Cookie consent selectors - comprehensive list
COOKIE_SELECTORS = [
'[id*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'[data-testid="cookie-policy-dialog-accept-button"]',
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accept cookies")',
'button:has-text("Allow all")',
'button:has-text("I agree")',
'button:has-text("Agree")',
'button:has-text("Got it")',
'button:has-text("OK")',
'button:has-text("Accetta tutti")',
'button:has-text("Accetta")',
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
]
def dismiss_cookie_consent(page: Page, timeout: int = 2000) -> bool:
"""Dismiss cookie consent banner if present."""
for selector in COOKIE_SELECTORS:
try:
btn = page.locator(selector).first
if btn.is_visible(timeout=timeout):
print(f" [COOKIE] Dismissing cookie consent...")
btn.click()
page.wait_for_timeout(500)
return True
except Exception:
continue
return False
def dismiss_all_overlays(page: Page) -> None:
"""Dismiss cookie consent AND other blocking overlays (modals, popups)."""
# First: cookie consent
dismiss_cookie_consent(page)
# Second: dismiss other common overlays via JavaScript
page.evaluate('''() => {
// Close any modal/dialog overlays
const closeSelectors = [
'[aria-label="Close"]',
'[aria-label="close"]',
'button[class*="close"]',
'button[class*="dismiss"]',
'.modal-close',
'.popup-close',
'.overlay-close',
'[data-dismiss="modal"]',
'.btn-close',
'button svg[class*="close"]',
];
for (const sel of closeSelectors) {
const btn = document.querySelector(sel);
if (btn && btn.offsetParent !== null) {
btn.click();
break;
}
}
// Remove overlay elements that block interaction
const overlaySelectors = [
'.modal-backdrop',
'.overlay',
'[class*="overlay"]',
'[class*="modal-overlay"]',
'[class*="popup-overlay"]',
];
for (const sel of overlaySelectors) {
const overlays = document.querySelectorAll(sel);
overlays.forEach(el => {
if (el.style.position === 'fixed' || el.style.position === 'absolute') {
el.remove();
}
});
}
}''')
page.wait_for_timeout(300)
def discover_elements(url: str, handle_cookies: bool = True) -> dict:
"""Discover interactive elements on a page and return their details."""
results = {
"buttons": [],
"links": [],
"inputs": [],
"forms": [],
}
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
page.wait_for_load_state("networkidle")
# Handle cookie consent and other overlays
if handle_cookies:
dismiss_all_overlays(page)
# Discover buttons
buttons = page.locator("button").all()
for btn in buttons:
text = btn.text_content() or ""
btn_type = btn.get_attribute("type") or "button"
btn_id = btn.get_attribute("id") or ""
results["buttons"].append({
"text": text.strip(),
"type": btn_type,
"id": btn_id,
})
# Discover links
links = page.locator("a").all()
for link in links:
text = link.text_content() or ""
href = link.get_attribute("href") or ""
results["links"].append({
"text": text.strip(),
"href": href,
})
# Discover inputs
inputs = page.locator("input, textarea, select").all()
for inp in inputs:
inp_type = inp.get_attribute("type") or "text"
inp_name = inp.get_attribute("name") or ""
inp_id = inp.get_attribute("id") or ""
inp_placeholder = inp.get_attribute("placeholder") or ""
results["inputs"].append({
"type": inp_type,
"name": inp_name,
"id": inp_id,
"placeholder": inp_placeholder,
})
# Discover forms
forms = page.locator("form").all()
for form in forms:
form_id = form.get_attribute("id") or ""
form_action = form.get_attribute("action") or ""
form_method = form.get_attribute("method") or "get"
results["forms"].append({
"id": form_id,
"action": form_action,
"method": form_method,
})
browser.close()
return results
def print_results(results: dict) -> None:
"""Print discovered elements in a readable format."""
print("\n=== BUTTONS ===")
for btn in results["buttons"]:
print(f" [{btn['type']}] {btn['text']!r} (id={btn['id']!r})")
print("\n=== LINKS ===")
for link in results["links"]:
print(f" {link['text']!r} -> {link['href']}")
print("\n=== INPUTS ===")
for inp in results["inputs"]:
selector = f"name={inp['name']!r}" if inp["name"] else f"id={inp['id']!r}"
print(f" [{inp['type']}] {selector} placeholder={inp['placeholder']!r}")
print("\n=== FORMS ===")
for form in results["forms"]:
print(f" {form['method'].upper()} -> {form['action']} (id={form['id']!r})")
def main() -> None:
parser = argparse.ArgumentParser(
description="Discover interactive elements (buttons, links, inputs, forms) on a web page.",
epilog="Example: uv run element_discovery.py http://localhost:3000",
)
parser.add_argument(
"url",
help="Target URL to analyze (e.g., http://localhost:3000 or file:///path/to/file.html)",
)
parser.add_argument(
"--no-cookies",
action="store_true",
help="Skip cookie consent and overlay dismissal",
)
args = parser.parse_args()
print(f"Discovering elements on: {args.url}")
discovered = discover_elements(args.url, handle_cookies=not args.no_cookies)
print_results(discovered)
total = sum(len(v) for v in discovered.values())
print(f"\nTotal elements discovered: {total}")
if __name__ == "__main__":
main()
examples/python/form_interaction.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Example script for interacting with web forms
# ABOUTME: Demonstrates filling inputs, selecting options, and handling submissions
"""
Form Interaction Example
Demonstrates common form interactions: text input, selects, checkboxes, and submission.
Can run in dry-run mode to only discover fields without filling them.
"""
import argparse
from playwright.sync_api import Page, sync_playwright
# Cookie consent selectors - comprehensive list
COOKIE_SELECTORS = [
'[id*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'[data-testid="cookie-policy-dialog-accept-button"]',
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accept cookies")',
'button:has-text("Allow all")',
'button:has-text("I agree")',
'button:has-text("Agree")',
'button:has-text("Got it")',
'button:has-text("OK")',
'button:has-text("Accetta tutti")',
'button:has-text("Accetta")',
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
]
def dismiss_cookie_consent(page: Page, timeout: int = 2000) -> bool:
"""Dismiss cookie consent banner if present."""
for selector in COOKIE_SELECTORS:
try:
btn = page.locator(selector).first
if btn.is_visible(timeout=timeout):
print(f" [COOKIE] Dismissing cookie consent...")
btn.click()
page.wait_for_timeout(500)
return True
except Exception:
continue
return False
def dismiss_all_overlays(page: Page) -> None:
"""Dismiss cookie consent AND other blocking overlays (modals, popups)."""
dismiss_cookie_consent(page)
# Dismiss other common overlays via JavaScript
page.evaluate('''() => {
const closeSelectors = [
'[aria-label="Close"]', '[aria-label="close"]',
'button[class*="close"]', 'button[class*="dismiss"]',
'.modal-close', '.popup-close', '.overlay-close',
'[data-dismiss="modal"]', '.btn-close',
];
for (const sel of closeSelectors) {
const btn = document.querySelector(sel);
if (btn && btn.offsetParent !== null) { btn.click(); break; }
}
// Remove blocking overlays
document.querySelectorAll('.modal-backdrop, [class*="overlay"]').forEach(el => {
if (el.style.position === 'fixed' || el.style.position === 'absolute') el.remove();
});
}''')
page.wait_for_timeout(300)
def discover_form_fields(page: Page) -> dict:
"""Discover form fields on the page."""
fields = {
"text_inputs": [],
"selects": [],
"checkboxes": [],
"radios": [],
"textareas": [],
"submit_buttons": [],
}
# Text inputs (including email, password, etc.)
text_types = ["text", "email", "password", "tel", "number", "url", "search"]
for input_type in text_types:
inputs = page.locator(f'input[type="{input_type}"]').all()
for inp in inputs:
fields["text_inputs"].append({
"type": input_type,
"name": inp.get_attribute("name") or "",
"id": inp.get_attribute("id") or "",
"placeholder": inp.get_attribute("placeholder") or "",
"required": inp.get_attribute("required") is not None,
})
# Select dropdowns
selects = page.locator("select").all()
for sel in selects:
options = sel.locator("option").all()
option_values = [opt.get_attribute("value") for opt in options]
fields["selects"].append({
"name": sel.get_attribute("name") or "",
"id": sel.get_attribute("id") or "",
"options": option_values,
})
# Checkboxes
checkboxes = page.locator('input[type="checkbox"]').all()
for cb in checkboxes:
fields["checkboxes"].append({
"name": cb.get_attribute("name") or "",
"id": cb.get_attribute("id") or "",
"value": cb.get_attribute("value") or "",
"checked": cb.is_checked(),
})
# Radio buttons
radios = page.locator('input[type="radio"]').all()
for radio in radios:
fields["radios"].append({
"name": radio.get_attribute("name") or "",
"id": radio.get_attribute("id") or "",
"value": radio.get_attribute("value") or "",
"checked": radio.is_checked(),
})
# Textareas
textareas = page.locator("textarea").all()
for ta in textareas:
fields["textareas"].append({
"name": ta.get_attribute("name") or "",
"id": ta.get_attribute("id") or "",
"placeholder": ta.get_attribute("placeholder") or "",
})
# Submit buttons
submits = page.locator('button[type="submit"], input[type="submit"]').all()
for btn in submits:
text = btn.text_content() or btn.get_attribute("value") or "Submit"
fields["submit_buttons"].append({
"text": text.strip(),
"id": btn.get_attribute("id") or "",
})
return fields
def fill_form_example(url: str, dry_run: bool = False, handle_cookies: bool = True) -> None:
"""Demonstrate form filling with discovered fields."""
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
page.wait_for_load_state("networkidle")
# Handle cookie consent and other overlays BEFORE interacting
if handle_cookies:
dismiss_all_overlays(page)
print("Discovering form fields...")
fields = discover_form_fields(page)
print("\n=== DISCOVERED FIELDS ===")
for field_type, items in fields.items():
if items:
print(f"\n{field_type.upper()}:")
for item in items:
print(f" - {item}")
if dry_run:
print("\n[DRY RUN] Skipping form interaction")
browser.close()
return
# Example: Fill text inputs with sample data
sample_data = {
"email": "test@example.com",
"password": "SecurePassword123!",
"name": "Test User",
"username": "testuser",
"phone": "+1234567890",
"tel": "+1234567890",
"message": "This is a test message.",
"comment": "This is a test comment.",
}
print("\n=== FILLING FORM ===")
for field in fields["text_inputs"]:
field_name = field["name"].lower() or field["id"].lower()
for key, value in sample_data.items():
if key in field_name or field["type"] == key:
selector = f'[name="{field["name"]}"]' if field["name"] else f'#{field["id"]}'
try:
page.fill(selector, value)
print(f" Filled {selector} with {value!r}")
except Exception as e:
print(f" Failed to fill {selector}: {e}")
break
# Fill textareas
for field in fields["textareas"]:
field_name = field["name"].lower() or field["id"].lower()
selector = f'[name="{field["name"]}"]' if field["name"] else f'#{field["id"]}'
for key, value in sample_data.items():
if key in field_name:
try:
page.fill(selector, value)
print(f" Filled textarea {selector}")
except Exception as e:
print(f" Failed to fill textarea: {e}")
break
# Select first option in dropdowns (if any)
for field in fields["selects"]:
if field["options"] and len(field["options"]) > 1:
selector = f'[name="{field["name"]}"]' if field["name"] else f'#{field["id"]}'
value = field["options"][1] # Skip empty first option
try:
page.select_option(selector, value)
print(f" Selected {value!r} in {selector}")
except Exception as e:
print(f" Failed to select option: {e}")
# Check unchecked checkboxes (demo)
for field in fields["checkboxes"]:
if not field["checked"]:
selector = f'[name="{field["name"]}"]' if field["name"] else f'#{field["id"]}'
try:
page.check(selector)
print(f" Checked {selector}")
except Exception as e:
print(f" Failed to check: {e}")
# Take screenshot of filled form
page.screenshot(path="/tmp/form_filled.png")
print("\n Screenshot saved to /tmp/form_filled.png")
# Note: Not submitting to avoid side effects
print("\n[INFO] Form filled but NOT submitted (add page.click() to submit)")
browser.close()
def main() -> None:
parser = argparse.ArgumentParser(
description="Discover and interact with form fields on a web page.",
epilog="Example: uv run form_interaction.py http://localhost:3000/contact --dry-run",
)
parser.add_argument(
"url",
help="Target URL with a form (e.g., http://localhost:3000/signup)",
)
parser.add_argument(
"--dry-run", "-d",
action="store_true",
help="Only discover fields, do not fill or interact with them",
)
parser.add_argument(
"--discover-only",
action="store_true",
help="Alias for --dry-run: only discover fields, do not fill",
)
parser.add_argument(
"--no-cookies",
action="store_true",
help="Skip cookie consent and overlay dismissal",
)
args = parser.parse_args()
print(f"Interacting with forms on: {args.url}")
dry_run = args.dry_run or args.discover_only
if dry_run:
print(" (dry-run mode: will only discover, not fill)")
fill_form_example(args.url, dry_run=dry_run, handle_cookies=not args.no_cookies)
if __name__ == "__main__":
main()
examples/python/screenshot_capture.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Example script for capturing browser screenshots
# ABOUTME: Demonstrates viewport, full-page, and element-specific screenshots
"""
Screenshot Capture Example
Captures various types of screenshots from a web page:
- Viewport (visible area)
- Full page (scrolled content)
- Element-specific (header, main, footer)
- Responsive (mobile, tablet, desktop)
"""
import argparse
from pathlib import Path
from playwright.sync_api import Page, sync_playwright
# Cookie consent selectors - comprehensive list
COOKIE_SELECTORS = [
'[id*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'[data-testid="cookie-policy-dialog-accept-button"]',
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accept cookies")',
'button:has-text("Allow all")',
'button:has-text("I agree")',
'button:has-text("Agree")',
'button:has-text("Got it")',
'button:has-text("OK")',
'button:has-text("Accetta tutti")',
'button:has-text("Accetta")',
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
]
def dismiss_cookie_consent(page: Page, timeout: int = 2000) -> bool:
"""Dismiss cookie consent banner if present."""
for selector in COOKIE_SELECTORS:
try:
btn = page.locator(selector).first
if btn.is_visible(timeout=timeout):
print(f" [COOKIE] Dismissing cookie consent...")
btn.click()
page.wait_for_timeout(500)
return True
except Exception:
continue
return False
def dismiss_all_overlays(page: Page) -> None:
"""Dismiss cookie consent AND other blocking overlays (modals, popups)."""
dismiss_cookie_consent(page)
# Dismiss other common overlays via JavaScript
page.evaluate('''() => {
const closeSelectors = [
'[aria-label="Close"]', '[aria-label="close"]',
'button[class*="close"]', 'button[class*="dismiss"]',
'.modal-close', '.popup-close', '.overlay-close',
'[data-dismiss="modal"]', '.btn-close',
];
for (const sel of closeSelectors) {
const btn = document.querySelector(sel);
if (btn && btn.offsetParent !== null) { btn.click(); break; }
}
// Remove blocking overlays
document.querySelectorAll('.modal-backdrop, [class*="overlay"]').forEach(el => {
if (el.style.position === 'fixed' || el.style.position === 'absolute') el.remove();
});
}''')
page.wait_for_timeout(300)
def capture_screenshots(url: str, output_dir: str = "/tmp", handle_cookies: bool = True) -> list[str]:
"""Capture multiple types of screenshots and return file paths."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
saved_files = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url)
page.wait_for_load_state("networkidle")
# Handle cookie consent and other overlays
if handle_cookies:
dismiss_all_overlays(page)
# Viewport screenshot (what's visible)
viewport_path = output_path / "viewport.png"
page.screenshot(path=str(viewport_path))
saved_files.append(str(viewport_path))
print(f"Saved viewport screenshot: {viewport_path}")
# Full page screenshot (scrolled content)
fullpage_path = output_path / "fullpage.png"
page.screenshot(path=str(fullpage_path), full_page=True)
saved_files.append(str(fullpage_path))
print(f"Saved full-page screenshot: {fullpage_path}")
# Try to capture specific elements
element_selectors = [
("header", "header, [role='banner'], nav"),
("main", "main, [role='main'], #content, .content"),
("footer", "footer, [role='contentinfo']"),
]
for name, selector in element_selectors:
try:
element = page.locator(selector).first
if element.is_visible():
element_path = output_path / f"element_{name}.png"
element.screenshot(path=str(element_path))
saved_files.append(str(element_path))
print(f"Saved {name} element screenshot: {element_path}")
except Exception as e:
print(f"Could not capture {name}: {e}")
# Capture with different viewport sizes (responsive testing)
viewports = [
("mobile", 375, 667),
("tablet", 768, 1024),
("desktop", 1920, 1080),
]
for name, width, height in viewports:
page.set_viewport_size({"width": width, "height": height})
page.wait_for_load_state("networkidle")
responsive_path = output_path / f"responsive_{name}.png"
page.screenshot(path=str(responsive_path))
saved_files.append(str(responsive_path))
print(f"Saved {name} ({width}x{height}) screenshot: {responsive_path}")
browser.close()
return saved_files
def main() -> None:
parser = argparse.ArgumentParser(
description="Capture screenshots from a web page (viewport, full-page, responsive).",
epilog="Example: uv run screenshot_capture.py http://localhost:3000 --output /tmp/shots",
)
parser.add_argument(
"url",
help="Target URL to screenshot (e.g., http://localhost:3000)",
)
parser.add_argument(
"--output", "-o",
default="/tmp",
help="Output directory for screenshots (default: /tmp)",
)
parser.add_argument(
"--no-cookies",
action="store_true",
help="Skip cookie consent and overlay dismissal",
)
args = parser.parse_args()
print(f"Capturing screenshots from: {args.url}")
print(f"Output directory: {args.output}")
files = capture_screenshots(args.url, args.output, handle_cookies=not args.no_cookies)
print(f"\nTotal screenshots captured: {len(files)}")
print("Files:")
for f in files:
print(f" - {f}")
if __name__ == "__main__":
main()
examples/python/visual_compare.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Visual comparison tool for comparing two websites side by side
# ABOUTME: Takes screenshots and extracts CSS properties for analysis
"""
Visual Compare - Website Comparison Tool
Captures screenshots of two URLs and extracts key CSS properties
for visual comparison analysis. Useful for matching designs.
Usage:
uv run visual_compare.py URL1 URL2 --output /tmp/compare
"""
import argparse
import json
from pathlib import Path
from playwright.sync_api import sync_playwright
def extract_css_properties(page) -> dict:
"""Extract key CSS properties from the page."""
return page.evaluate("""
() => {
const body = document.body;
const computedStyle = window.getComputedStyle(body);
// Get first heading if exists
const h1 = document.querySelector('h1');
const h1Style = h1 ? window.getComputedStyle(h1) : null;
// Get links
const link = document.querySelector('a');
const linkStyle = link ? window.getComputedStyle(link) : null;
// Get main content container
const main = document.querySelector('main, .main, #main, [role="main"]');
const mainStyle = main ? window.getComputedStyle(main) : null;
return {
body: {
fontFamily: computedStyle.fontFamily,
fontSize: computedStyle.fontSize,
lineHeight: computedStyle.lineHeight,
color: computedStyle.color,
backgroundColor: computedStyle.backgroundColor,
letterSpacing: computedStyle.letterSpacing,
},
h1: h1Style ? {
fontFamily: h1Style.fontFamily,
fontSize: h1Style.fontSize,
fontWeight: h1Style.fontWeight,
color: h1Style.color,
} : null,
links: linkStyle ? {
color: linkStyle.color,
textDecoration: linkStyle.textDecoration,
} : null,
container: mainStyle ? {
maxWidth: mainStyle.maxWidth,
padding: mainStyle.padding,
margin: mainStyle.margin,
} : null,
viewport: {
width: window.innerWidth,
height: window.innerHeight,
}
};
}
""")
def compare_sites(url1: str, url2: str, output_dir: str = "/tmp/compare") -> dict:
"""Compare two websites visually and extract CSS properties."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
results = {"url1": url1, "url2": url2, "sites": {}}
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
for i, url in enumerate([url1, url2], 1):
site_key = f"site{i}"
page = browser.new_page()
page.set_viewport_size({"width": 1920, "height": 1080})
try:
page.goto(url, timeout=30000)
page.wait_for_load_state("networkidle")
except Exception as e:
print(f"Warning: {url} - {e}")
page.wait_for_timeout(2000)
# Take screenshots
screenshot_path = output_path / f"site{i}_desktop.png"
page.screenshot(path=str(screenshot_path))
print(f"Saved: {screenshot_path}")
# Full page
fullpage_path = output_path / f"site{i}_fullpage.png"
page.screenshot(path=str(fullpage_path), full_page=True)
print(f"Saved: {fullpage_path}")
# Extract CSS
css_props = extract_css_properties(page)
results["sites"][site_key] = {
"url": url,
"css": css_props,
"screenshots": {
"desktop": str(screenshot_path),
"fullpage": str(fullpage_path),
}
}
page.close()
browser.close()
# Save comparison results
results_path = output_path / "comparison.json"
with open(results_path, "w") as f:
json.dump(results, f, indent=2)
print(f"\nComparison data saved: {results_path}")
# Print summary
print("\n" + "=" * 60)
print("CSS COMPARISON SUMMARY")
print("=" * 60)
site1 = results["sites"]["site1"]["css"]
site2 = results["sites"]["site2"]["css"]
print(f"\n{'Property':<25} {'Site 1':<30} {'Site 2':<30}")
print("-" * 85)
for category in ["body", "h1", "links"]:
if site1.get(category) and site2.get(category):
print(f"\n[{category.upper()}]")
for prop in site1[category]:
val1 = site1[category].get(prop, "N/A")
val2 = site2[category].get(prop, "N/A") if site2.get(category) else "N/A"
# Truncate long values
val1_str = str(val1)[:28] if val1 else "N/A"
val2_str = str(val2)[:28] if val2 else "N/A"
match = "✓" if val1 == val2 else "✗"
print(f" {prop:<23} {val1_str:<30} {val2_str:<30} {match}")
return results
def main() -> None:
parser = argparse.ArgumentParser(
description="Compare two websites visually and extract CSS properties.",
epilog="Example: uv run visual_compare.py https://example.com http://localhost:3000",
)
parser.add_argument("url1", help="First URL to compare (reference)")
parser.add_argument("url2", help="Second URL to compare (target)")
parser.add_argument(
"--output", "-o",
default="/tmp/compare",
help="Output directory for screenshots and data (default: /tmp/compare)",
)
args = parser.parse_args()
print(f"Comparing:")
print(f" Reference: {args.url1}")
print(f" Target: {args.url2}")
print(f" Output: {args.output}")
print()
compare_sites(args.url1, args.url2, args.output)
if __name__ == "__main__":
main()
references/javascript-patterns.md
# ABOUTME: Detailed JavaScript/TypeScript Playwright patterns and examples
# ABOUTME: Extended from SKILL.md core patterns for in-depth reference
# JavaScript Playwright Patterns
## Core Pattern: @playwright/test Framework
```javascript
import { test, expect } from '@playwright/test';
test.describe('Feature Name', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
});
test('should do something', async ({ page }) => {
await expect(page.locator('.element')).toBeVisible();
await page.click('button');
await expect(page.locator('.result')).toContainText('Success');
});
});
```
## Cookie Consent Handling
```javascript
async function dismissCookieConsent(page, timeout = 3000) {
const cookieSelectors = [
'[id*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accetta tutti")',
'button:has-text("Allow all")',
'button:has-text("I agree")',
'button:has-text("OK")',
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
];
for (const selector of cookieSelectors) {
try {
const btn = page.locator(selector).first();
if (await btn.isVisible({ timeout })) {
await btn.click();
await page.waitForTimeout(500);
return true;
}
} catch {
continue;
}
}
return false;
}
```
## Nuclear Option: Force Remove Overlay
```javascript
async function forceRemoveOverlay(page) {
const result = await page.evaluate(() => {
let removed = 0;
const patterns = ['cookie', 'consent', 'gdpr', 'modal', 'overlay', 'popup', 'backdrop'];
for (const pattern of patterns) {
document.querySelectorAll(`[class*="${pattern}"], [id*="${pattern}"]`).forEach(el => {
const style = getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') {
el.remove();
removed++;
}
});
}
document.body.style.overflow = 'auto';
document.documentElement.style.overflow = 'auto';
return removed;
});
return result;
}
```
## Common Operations
### Screenshots
```javascript
await page.screenshot({ path: '/tmp/screenshot.png' });
await page.screenshot({ path: '/tmp/full.png', fullPage: true });
await page.locator('#element').screenshot({ path: '/tmp/element.png' });
```
### Form Interactions
```javascript
await page.fill('input[name="email"]', 'test@example.com');
await page.selectOption('select#country', 'IT');
await page.check('input[type="checkbox"]');
await page.click('button[type="submit"]');
```
### Waiting Strategies
```javascript
await page.waitForLoadState('networkidle');
await page.waitForSelector('.result');
await page.waitForTimeout(1000); // Avoid if possible
await page.locator('.btn').waitFor({ state: 'visible' });
```
### Full Debugging Pattern
```javascript
const errors = [];
const requests = [];
const responses = [];
page.on('console', msg => console.log(`[CONSOLE ${msg.type()}] ${msg.text()}`));
page.on('pageerror', err => errors.push(err.message));
page.on('request', req => requests.push(`${req.method()} ${req.url()}`));
page.on('response', res => responses.push(`${res.status()} ${res.url()}`));
await page.goto('http://localhost:3000');
await page.fill('#email', 'user@example.com');
await page.click('button[type="submit"]');
await page.waitForLoadState('networkidle');
console.log('Errors:', errors);
console.log('Failed:', responses.filter(r => r.startsWith('4') || r.startsWith('5')));
```
### Assertions
```javascript
await expect(page.locator('.element')).toBeVisible();
await expect(page.locator('.element')).toHaveText('Expected Text');
await expect(page.locator('.element')).toContainText('partial');
await expect(page.locator('.element')).toHaveCount(3);
await expect(page.locator('input')).toHaveValue('expected value');
await expect(page).toHaveURL('/expected-path');
await expect(page).toHaveTitle('Expected Title');
```
## Test Suite Patterns
### Project Structure
```
project/
├── tests/e2e/
│ ├── homepage.spec.js
│ └── forms.spec.js
├── playwright.config.js
└── package.json
```
### Playwright Config
```javascript
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: true,
},
});
```
### Page Object Pattern
```javascript
// tests/e2e/pages/TerminalPage.js
export class TerminalPage {
constructor(page) {
this.page = page;
this.input = page.locator('#input');
this.output = page.locator('.output');
}
async goto() {
await this.page.goto('/');
await this.page.waitForLoadState('networkidle');
}
async runCommand(cmd) {
await this.input.fill(cmd);
await this.input.press('Enter');
}
}
// Usage
test('help command works', async ({ page }) => {
const terminal = new TerminalPage(page);
await terminal.goto();
await terminal.runCommand('help');
await expect(terminal.output).toContainText('Navigation:');
});
```
### Data-Driven Tests
```javascript
const commands = [
{ cmd: 'help', expected: 'Navigation:' },
{ cmd: 'pwd', expected: '/home/guest' },
];
for (const { cmd, expected } of commands) {
test(`${cmd} command works`, async ({ page }) => {
await page.goto('/');
await page.fill('#input', cmd);
await page.press('#input', 'Enter');
await expect(page.locator('.output')).toContainText(expected);
});
}
```
## File URLs for Local HTML
```javascript
import path from 'path';
const htmlPath = path.resolve('/path/to/file.html');
await page.goto(`file://${htmlPath}`);
```
## Running Tests
```bash
npx playwright test # Run all
npx playwright test tests/e2e/terminal.spec.js # Specific file
npx playwright test --ui # UI mode
npx playwright test --headed # See browser
npx playwright show-report # View report
```
references/python-patterns.md
# ABOUTME: Detailed Python Playwright patterns and examples
# ABOUTME: Extended from SKILL.md core patterns for in-depth reference
# Python Playwright Patterns
## Core Pattern: Synchronous Playwright
```python
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # ALWAYS use headless=True
page = browser.new_page()
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle') # CRITICAL: Wait for JS to execute
# ... your automation logic
browser.close()
```
## Cookie Consent Handling
**CRITICAL**: Many sites display cookie banners that block interaction.
```python
def dismiss_cookie_consent(page, timeout=3000):
"""Dismiss cookie consent banner if present. Non-blocking."""
cookie_selectors = [
# Specific consent management platforms
'[id*="onetrust"] button[id*="accept"]',
'[class*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'[data-testid="cookie-policy-dialog-accept-button"]',
'[data-cookiebanner] button[data-action="accept"]',
# Generic patterns (text-based)
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accept cookies")',
'button:has-text("Accetta tutti")',
'button:has-text("Allow all")',
'button:has-text("I agree")',
'button:has-text("OK")',
# Generic patterns (attribute-based)
'[class*="cookie"] button[class*="accept"]',
'[class*="consent"] button[class*="accept"]',
'[id*="cookie"] button[class*="accept"]',
]
for selector in cookie_selectors:
try:
btn = page.locator(selector).first
if btn.is_visible(timeout=timeout):
btn.click()
page.wait_for_timeout(500)
return True
except:
continue
return False
```
## Dismiss All Overlays (Nuclear Option)
When cookie consent handling doesn't work:
```python
def dismiss_all_overlays(page):
"""Dismiss cookies AND modals/popups that block interaction."""
# Cookie consent selectors
cookie_selectors = [
'button:has-text("Accept all")', 'button:has-text("Accept All")',
'button:has-text("Accept cookies")', 'button:has-text("Allow all")',
'button:has-text("I agree")', 'button:has-text("OK")',
'button:has-text("Accetta tutti")', 'button:has-text("Accetta")',
'[class*="cookie"] button[class*="accept"]',
]
for sel in cookie_selectors:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=2000):
btn.click()
page.wait_for_timeout(500)
break
except:
continue
# Remove blocking overlays via JS
page.evaluate('''() => {
for (const sel of ['[aria-label="Close"]', '.btn-close', '[data-dismiss="modal"]']) {
const btn = document.querySelector(sel);
if (btn && btn.offsetParent !== null) { btn.click(); break; }
}
document.querySelectorAll('.modal-backdrop, [class*="overlay"]').forEach(el => {
if (getComputedStyle(el).position === 'fixed') el.remove();
});
}''')
def force_remove_overlay(page, verbose=False):
"""NUCLEAR OPTION: Forcefully remove all blocking overlays."""
result = page.evaluate('''() => {
let removed = 0;
const patterns = ['cookie', 'consent', 'modal', 'overlay', 'popup', 'backdrop', 'gdpr'];
for (const pattern of patterns) {
document.querySelectorAll(`[class*="${pattern}"], [id*="${pattern}"]`).forEach(el => {
const style = getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') {
el.remove();
removed++;
}
});
}
document.body.style.overflow = 'auto';
return removed;
}''')
if verbose:
print(f"Removed {result} blocking elements")
return result
```
## Common Operations
### Screenshots
```python
page.screenshot(path='/tmp/screenshot.png') # Viewport only
page.screenshot(path='/tmp/full.png', full_page=True) # Full page
page.locator('#element').screenshot(path='/tmp/element.png') # Single element
```
### Form Interactions
```python
page.fill('input[name="email"]', 'test@example.com')
page.select_option('select#country', 'IT')
page.check('input[type="checkbox"]')
page.click('button[type="submit"]')
```
### Waiting Strategies
```python
page.wait_for_load_state('networkidle') # Wait for network quiet
page.wait_for_selector('.result') # Wait for element
page.wait_for_timeout(1000) # Fixed wait (avoid if possible)
page.locator('.btn').wait_for(state='visible') # Wait for visibility
```
### Console Log Capture
```python
page.on('console', lambda msg: print(f'[{msg.type}] {msg.text}'))
page.goto('http://localhost:3000')
```
### Full Debugging Pattern (Console + Network + Errors)
```python
errors = []
requests = []
responses = []
page.on('console', lambda msg: print(f'[CONSOLE {msg.type}] {msg.text}'))
page.on('pageerror', lambda err: errors.append(str(err)))
page.on('request', lambda req: requests.append(f'{req.method} {req.url}'))
page.on('response', lambda res: responses.append(f'{res.status} {res.url}'))
page.goto('http://localhost:3000')
page.fill('#email', 'user@example.com')
page.fill('#password', 'secret')
page.click('button[type="submit"]')
page.wait_for_load_state('networkidle')
print(f"Errors: {errors}")
print(f"Failed requests: {[r for r in responses if '4' in r or '5' in r]}")
```
## Login + Verify Pattern
```python
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Login
page.goto('http://localhost:3000/login')
page.wait_for_load_state('networkidle')
dismiss_cookie_consent(page) # CRITICAL: Handle cookies first!
page.fill('input[type="email"]', 'user@example.com')
page.fill('input[type="password"]', 'password')
page.click('button[type="submit"]')
page.wait_for_load_state('networkidle')
# Navigate and verify
page.goto('http://localhost:3000/target-page')
page.wait_for_load_state('networkidle')
page.screenshot(path='/tmp/verification.png')
# Check specific element
dropdown = page.locator('select#my-dropdown')
print(f"Dropdown value: {dropdown.input_value()}")
browser.close()
```
## File URLs for Local HTML
```python
import pathlib
html_path = pathlib.Path('/path/to/file.html').resolve()
page.goto(f'file://{html_path}')
```
references/test-framework.md
# ABOUTME: Test framework detection and unified test runner documentation
# ABOUTME: Covers server auto-start, framework detection, and test execution
# Test Framework Integration
## Unified Test Runner
Use `test_utils.py` to detect frameworks and run tests in any project:
```bash
# Detect test frameworks only
uv run ~/.claude/skills/web-automation/scripts/test_utils.py /path/to/repo --detect-only
# Run all detected tests
uv run ~/.claude/skills/web-automation/scripts/test_utils.py /path/to/repo --run
# Run tests with server auto-start (for E2E)
uv run ~/.claude/skills/web-automation/scripts/test_utils.py /path/to/repo --run --with-server
# Run specific framework only
uv run ~/.claude/skills/web-automation/scripts/test_utils.py /path/to/repo --run --framework playwright
# Filter tests by name pattern
uv run ~/.claude/skills/web-automation/scripts/test_utils.py /path/to/repo --run --filter "login"
```
## Supported Test Frameworks
| Framework | Language | Detection |
|-----------|----------|-----------|
| Playwright | JS/TS | `@playwright/test` in package.json, playwright.config.js |
| Jest | JS/TS | `jest` in package.json, jest.config.js |
| Vitest | JS/TS | `vitest` in package.json |
| Mocha | JS/TS | `mocha` in package.json |
| Cypress | JS/TS | `cypress` in package.json |
| pytest | Python | `pytest` in requirements.txt/pyproject.toml, conftest.py |
| unittest | Python | test/ or tests/ directory |
## Server Detection Strategy
Check for these files in the repository root:
| File | Project Type | Dev Server |
|------|--------------|------------|
| `hugo.toml` / `config.toml` | Hugo | `hugo server` |
| `package.json` | Node.js | npm/yarn/pnpm |
| `pyproject.toml` | Python (modern) | uvicorn, flask, django |
| `requirements.txt` | Python (legacy) | same as above |
| `Cargo.toml` | Rust | `cargo run` |
| `go.mod` | Go | `go run` |
| `Gemfile` | Ruby | `rails server` |
**Note**: Hugo is detected first since Hugo projects often include `package.json` for asset pipelines.
## Server Auto-Start
When using `--with-server`, the script:
1. Detects the project type (Hugo, Node.js, Python)
2. Starts the appropriate dev server in background
3. Waits for server to be ready
4. Runs tests
5. Stops the server automatically
| Project Type | Server Command | Port |
|--------------|----------------|------|
| Hugo | `hugo server -D` | 1313 |
| Node.js (Next) | `npm run dev` | 3000 |
| Node.js (Vite) | `npm run dev` | 5173 |
| Python (FastAPI) | `uvicorn main:app` | 8000 |
| Python (Flask) | `flask run` | 5000 |
| Python (Django) | `python manage.py runserver` | 8000 |
## Server Utils
Use `scripts/server_utils.py` to detect and manage servers:
```bash
# Detect project type only
uv run ~/.claude/skills/web-automation/scripts/server_utils.py /path/to/repo --detect-only
# Detect and start server
uv run ~/.claude/skills/web-automation/scripts/server_utils.py /path/to/repo --start
```
Output:
```
=== PROJECT DETECTION ===
Type: nodejs
Framework: next
Start command: npm run dev
Port: 3000
URL: http://localhost:3000
```
## Typical Workflow
```bash
# 1. Detect what's available
uv run ~/.claude/skills/web-automation/scripts/test_utils.py . --detect-only
# 2. Run E2E tests with server
uv run ~/.claude/skills/web-automation/scripts/test_utils.py . --run --with-server --framework playwright
# 3. Run unit tests (no server needed)
uv run ~/.claude/skills/web-automation/scripts/test_utils.py . --run --framework jest
```
scripts/cdp_session.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Core CDP session management for Chrome DevTools debugging
# ABOUTME: Provides reusable utilities for browser launch and CDP domain management
"""
CDP Session Manager
Core utilities for Chrome DevTools Protocol interactions via Playwright.
This module is imported by other debugging scripts.
Usage as library:
from cdp_session import create_browser_and_page, enable_domains
Usage standalone (test connection):
uv run cdp_session.py http://localhost:3000 --domains Network,Runtime
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from playwright.async_api import async_playwright
if TYPE_CHECKING:
from playwright.async_api import Browser, CDPSession, Page
async def create_browser_and_page(
headless: bool = True,
) -> tuple["Browser", "Page"]:
"""
Launch Chromium and create a new page.
Args:
headless: Run browser in headless mode (default: True)
Returns:
Tuple of (browser, page)
"""
playwright = await async_playwright().start()
browser = await playwright.chromium.launch(headless=headless)
context = await browser.new_context()
page = await context.new_page()
return browser, page
async def create_cdp_session(page: "Page") -> "CDPSession":
"""
Create a CDP session from a Playwright page.
Args:
page: Playwright page object
Returns:
CDPSession for direct CDP commands
"""
return await page.context.new_cdp_session(page)
async def enable_domains(client: "CDPSession", domains: list[str]) -> None:
"""
Enable multiple CDP domains.
Args:
client: CDP session
domains: List of domain names (e.g., ["Network", "Runtime"])
"""
for domain in domains:
await client.send(f"{domain}.enable")
async def disable_domains(client: "CDPSession", domains: list[str]) -> None:
"""
Disable multiple CDP domains.
Args:
client: CDP session
domains: List of domain names
"""
for domain in domains:
try:
await client.send(f"{domain}.disable")
except Exception:
pass # Some domains may not support disable
def create_metadata(url: str, duration: float) -> dict:
"""
Create standard metadata block for output.
Args:
url: Target URL
duration: Capture duration in seconds
Returns:
Metadata dictionary
"""
return {
"url": url,
"timestamp": datetime.now(timezone.utc).isoformat(),
"duration_seconds": duration,
"browser": "Chromium",
}
def output_json(data: dict, output_file: str | None = None) -> None:
"""
Output JSON to file or stdout.
Args:
data: Dictionary to serialize
output_file: Optional file path; if None, prints to stdout
"""
json_str = json.dumps(data, indent=2, default=str)
if output_file:
with open(output_file, "w") as f:
f.write(json_str)
print(f"Output written to: {output_file}", file=sys.stderr)
else:
print(json_str)
async def test_connection(url: str, domains: list[str]) -> dict:
"""
Test CDP connection and domain availability.
Args:
url: URL to navigate to
domains: Domains to test
Returns:
Test results dictionary
"""
results = {"url": url, "domains": {}, "success": True}
browser, page = await create_browser_and_page()
try:
client = await create_cdp_session(page)
for domain in domains:
try:
await client.send(f"{domain}.enable")
results["domains"][domain] = "enabled"
except Exception as e:
results["domains"][domain] = f"error: {e}"
results["success"] = False
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
results["navigation"] = "success"
except Exception as e:
results["navigation"] = f"error: {e}"
results["success"] = False
finally:
await browser.close()
return results
async def main() -> None:
parser = argparse.ArgumentParser(
description="Test CDP connection and domain availability",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run cdp_session.py http://localhost:3000
uv run cdp_session.py http://localhost:3000 --domains Network,Runtime,Log
""",
)
parser.add_argument("url", help="URL to test connection against")
parser.add_argument(
"--domains",
default="Network,Runtime",
help="Comma-separated CDP domains to test (default: Network,Runtime)",
)
parser.add_argument(
"--output",
"-o",
help="Output file path (default: stdout)",
)
args = parser.parse_args()
domains = [d.strip() for d in args.domains.split(",")]
results = await test_connection(args.url, domains)
output_json(results, args.output)
sys.exit(0 if results["success"] else 1)
if __name__ == "__main__":
asyncio.run(main())
scripts/combined_debugger.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Combined network + console debugging in a single browser instance
# ABOUTME: Captures requests, responses, console logs, errors with automatic timestamp correlation
"""
Combined Debugger
Capture network requests, console logs, and JavaScript errors in a single browser instance.
All events are correlated by timestamp for easy analysis.
Usage:
uv run combined_debugger.py http://localhost:3000
uv run combined_debugger.py http://localhost:3000 --duration 30
uv run combined_debugger.py http://localhost:3000 --errors-only
uv run combined_debugger.py http://localhost:3000 --output /tmp/debug.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import re
import sys
from datetime import datetime, timezone
from typing import Any
from playwright.async_api import async_playwright
class CombinedDebugger:
"""Captures network requests, console messages, and exceptions in correlation."""
def __init__(
self,
filter_types: list[str] | None = None,
url_pattern: str | None = None,
errors_only: bool = False,
capture_bodies: bool = False,
max_body_size: int = 10240,
):
self.filter_types = filter_types
self.url_pattern = re.compile(url_pattern) if url_pattern else None
self.errors_only = errors_only
self.capture_bodies = capture_bodies
self.max_body_size = max_body_size
self.requests: dict[str, dict[str, Any]] = {}
self.events: list[dict[str, Any]] = []
self.start_time: float = 0
def _get_timestamp_offset(self, ms: float | None) -> float:
"""Get milliseconds since start of session."""
if ms is None:
return 0
return ms - self.start_time
def _should_capture_request(self, url: str, resource_type: str) -> bool:
"""Check if request matches filters."""
if self.filter_types:
if resource_type.lower() not in [t.lower() for t in self.filter_types]:
return False
if self.url_pattern:
if not self.url_pattern.search(url):
return False
return True
def _format_remote_object(self, obj: dict) -> Any:
"""Format a CDP RemoteObject for output."""
obj_type = obj.get("type", "undefined")
if obj_type == "undefined":
return "undefined"
elif obj_type == "object":
if obj.get("subtype") == "null":
return None
if obj.get("subtype") == "error":
return obj.get("description", str(obj.get("value")))
if "value" in obj:
return obj["value"]
return obj.get("description", f"[{obj.get('className', 'Object')}]")
elif obj_type in ("string", "number", "boolean"):
return obj.get("value")
else:
return obj.get("description", str(obj.get("value")))
async def capture(self, url: str, duration: int = 30) -> dict[str, Any]:
"""Capture network and console events in a single browser."""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
# Set start time
self.start_time = datetime.now(timezone.utc).timestamp() * 1000
# Setup network event handlers
page.on("request", self._on_request)
page.on("response", self._on_response)
page.on("requestfailed", self._on_request_failed)
# Setup console event handlers
page.on("console", self._on_console)
page.on("pageerror", self._on_page_error)
# Navigate and wait
try:
await page.goto(url, wait_until="networkidle")
except Exception as e:
self.events.append({
"type": "navigation_error",
"timestamp_ms": self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
),
"error": str(e),
})
# Wait for additional events
await asyncio.sleep(duration)
await context.close()
await browser.close()
return self._build_output()
def _on_request(self, request) -> None:
"""Handle request event."""
url = request.url
resource_type = request.resource_type
request_id = id(request)
if not self._should_capture_request(url, resource_type):
return
self.requests[request_id] = {
"id": str(request_id),
"url": url,
"method": request.method,
"type": resource_type,
"headers": dict(request.headers),
"timestamp_ms": self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
),
}
self.events.append({
"type": "request",
"timestamp_ms": self.requests[request_id]["timestamp_ms"],
"url": url,
"method": request.method,
"resource_type": resource_type,
})
def _on_response(self, response) -> None:
"""Handle response event."""
request_id = id(response.request)
if request_id not in self.requests:
return
timestamp_ms = self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
)
status = response.status
headers = dict(response.headers)
url = response.url
req_data = self.requests[request_id]
req_data["status"] = status
req_data["response_headers"] = headers
req_data["timestamp_response_ms"] = timestamp_ms
# Determine if error
is_error = status >= 400
self.events.append({
"type": "response",
"timestamp_ms": timestamp_ms,
"url": url,
"status": status,
"is_error": is_error,
})
def _on_request_failed(self, request) -> None:
"""Handle request failure."""
request_id = id(request)
if request_id not in self.requests:
return
timestamp_ms = self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
)
self.events.append({
"type": "request_failed",
"timestamp_ms": timestamp_ms,
"url": request.url,
"method": request.method,
})
def _on_console(self, msg) -> None:
"""Handle console message."""
if self.errors_only and msg.type not in ("error", "warning"):
return
timestamp_ms = self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
)
self.events.append({
"type": "console",
"timestamp_ms": timestamp_ms,
"level": msg.type,
"text": msg.text,
})
def _on_page_error(self, error) -> None:
"""Handle page error."""
timestamp_ms = self._get_timestamp_offset(
datetime.now(timezone.utc).timestamp() * 1000
)
self.events.append({
"type": "page_error",
"timestamp_ms": timestamp_ms,
"error": str(error),
})
def _build_output(self) -> dict[str, Any]:
"""Build final output with correlation."""
# Sort events by timestamp
sorted_events = sorted(self.events, key=lambda e: e["timestamp_ms"])
# Correlate errors with surrounding events
correlated_events = []
for i, event in enumerate(sorted_events):
correlated = event.copy()
# Find related events (within 100ms)
if event["type"] in ("page_error", "request_failed"):
nearby = [
e for e in sorted_events
if abs(e["timestamp_ms"] - event["timestamp_ms"]) <= 100
and e != event
]
if nearby:
correlated["related_events"] = nearby
correlated_events.append(correlated)
return {
"metadata": {
"url": "",
"timestamp": datetime.now(timezone.utc).isoformat(),
"browser": "Chromium",
"combined_capture": True,
},
"events": correlated_events,
"summary": {
"total_events": len(correlated_events),
"requests": len([e for e in correlated_events if e["type"] == "request"]),
"responses": len([e for e in correlated_events if e["type"] == "response"]),
"errors": len([e for e in correlated_events if e["type"] in ("page_error", "request_failed")]),
"console_messages": len([e for e in correlated_events if e["type"] == "console"]),
},
}
async def main():
parser = argparse.ArgumentParser(
description="Capture network requests and console output simultaneously",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Quick capture for 30 seconds
uv run combined_debugger.py http://localhost:3000
# Only errors and failures
uv run combined_debugger.py http://localhost:3000 --errors-only
# Save to file
uv run combined_debugger.py http://localhost:3000 --output /tmp/debug.json
# Longer capture
uv run combined_debugger.py http://localhost:3000 --duration 60
""",
)
parser.add_argument("url", help="URL to debug (e.g., http://localhost:3000)")
parser.add_argument(
"--duration", "-d",
type=int,
default=30,
help="Capture duration in seconds (default: 30)",
)
parser.add_argument(
"--errors-only", "-e",
action="store_true",
help="Only capture errors and failures (skip normal logs)",
)
parser.add_argument(
"--filter", "-f",
type=str,
help="Filter by resource types (comma-separated: xhr,fetch,script,etc.)",
)
parser.add_argument(
"--url-pattern", "-p",
type=str,
help="Filter requests by URL pattern (regex)",
)
parser.add_argument(
"--output", "-o",
type=str,
help="Output file path (default: stdout)",
)
args = parser.parse_args()
filter_types = None
if args.filter:
filter_types = [t.strip() for t in args.filter.split(",")]
debugger = CombinedDebugger(
filter_types=filter_types,
url_pattern=args.url_pattern,
errors_only=args.errors_only,
)
print(f"Starting combined debug capture for {args.url}...")
print(f"Duration: {args.duration} seconds", file=sys.stderr)
try:
result = await debugger.capture(args.url, args.duration)
# Output result
if args.output:
with open(args.output, "w") as f:
json.dump(result, f, indent=2)
print(f"\nDebug output saved to: {args.output}", file=sys.stderr)
else:
print(json.dumps(result, indent=2))
except KeyboardInterrupt:
print("\nCapture cancelled", file=sys.stderr)
sys.exit(130)
except Exception as e:
print(f"\nError during capture: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())
scripts/console_debugger.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Console log and JavaScript error capture using Chrome DevTools Protocol
# ABOUTME: Captures console.log/warn/error, exceptions, and unhandled promise rejections
"""
Console Debugger
Capture console logs and JavaScript errors via Chrome DevTools Protocol.
Includes stack traces and exception details.
Usage:
uv run console_debugger.py http://localhost:3000
uv run console_debugger.py http://localhost:3000 --errors-only
uv run console_debugger.py http://localhost:3000 --with-stack-traces
uv run console_debugger.py http://localhost:3000 --output /tmp/console.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import sys
from datetime import datetime, timezone
from typing import Any
from playwright.async_api import async_playwright
class ConsoleDebugger:
"""Captures console messages and exceptions via CDP Runtime domain."""
def __init__(
self,
errors_only: bool = False,
with_stack_traces: bool = True,
include_timestamps: bool = True,
):
self.errors_only = errors_only
self.with_stack_traces = with_stack_traces
self.include_timestamps = include_timestamps
self.messages: list[dict[str, Any]] = []
self.exceptions: list[dict[str, Any]] = []
def _format_remote_object(self, obj: dict) -> Any:
"""Format a CDP RemoteObject for output."""
obj_type = obj.get("type", "undefined")
if obj_type == "undefined":
return "undefined"
elif obj_type == "object":
if obj.get("subtype") == "null":
return None
if obj.get("subtype") == "error":
return obj.get("description", str(obj.get("value")))
if "value" in obj:
return obj["value"]
return obj.get("description", f"[{obj.get('className', 'Object')}]")
elif obj_type in ("string", "number", "boolean"):
return obj.get("value")
else:
return obj.get("description", str(obj.get("value")))
def _format_stack_trace(self, stack_trace: dict) -> list[dict]:
"""Format a CDP StackTrace for output."""
frames = []
for frame in stack_trace.get("callFrames", []):
frames.append(
{
"function": frame.get("functionName", "(anonymous)"),
"file": frame.get("url", ""),
"line": frame.get("lineNumber", 0) + 1,
"column": frame.get("columnNumber", 0) + 1,
}
)
return frames
def _on_console_api_called(self, params: dict) -> None:
"""Handle Runtime.consoleAPICalled event."""
msg_type = params.get("type", "log")
if self.errors_only and msg_type not in ("error", "warning", "assert"):
return
args = params.get("args", [])
formatted_args = [self._format_remote_object(arg) for arg in args]
message = {
"type": msg_type,
"level": self._type_to_level(msg_type),
"text": " ".join(str(arg) for arg in formatted_args),
"args": formatted_args,
}
if self.include_timestamps:
message["timestamp"] = params.get("timestamp", datetime.now(timezone.utc).timestamp())
if self.with_stack_traces and "stackTrace" in params:
message["stack"] = self._format_stack_trace(params["stackTrace"])
self.messages.append(message)
def _on_exception_thrown(self, params: dict) -> None:
"""Handle Runtime.exceptionThrown event."""
exception_details = params.get("exceptionDetails", {})
exception_obj = exception_details.get("exception", {})
exception = {
"type": "exception",
"level": "error",
"exception_id": exception_details.get("exceptionId"),
"text": exception_details.get("text", ""),
"description": exception_obj.get("description", ""),
"line": exception_details.get("lineNumber", 0) + 1,
"column": exception_details.get("columnNumber", 0) + 1,
"url": exception_details.get("url", ""),
}
if self.include_timestamps:
exception["timestamp"] = params.get("timestamp", datetime.now(timezone.utc).timestamp())
if self.with_stack_traces:
stack_trace = exception_details.get("stackTrace")
if stack_trace:
exception["stack"] = self._format_stack_trace(stack_trace)
self.exceptions.append(exception)
def _type_to_level(self, msg_type: str) -> str:
"""Map console type to severity level."""
mapping = {
"log": "info",
"info": "info",
"debug": "debug",
"warning": "warning",
"warn": "warning",
"error": "error",
"assert": "error",
"trace": "debug",
"dir": "info",
"dirxml": "info",
"table": "info",
"count": "info",
"timeEnd": "info",
"group": "info",
"groupCollapsed": "info",
"groupEnd": "info",
"clear": "info",
}
return mapping.get(msg_type, "info")
async def capture(
self,
url: str,
duration: float = 30.0,
wait_for_idle: bool = True,
) -> dict:
"""
Capture console messages and exceptions for a URL.
Args:
url: URL to navigate to
duration: Maximum capture duration in seconds
wait_for_idle: Wait for network idle before starting timer
Returns:
Capture results dictionary
"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
client = await context.new_cdp_session(page)
await client.send("Runtime.enable")
await client.send("Log.enable")
client.on("Runtime.consoleAPICalled", self._on_console_api_called)
client.on("Runtime.exceptionThrown", self._on_exception_thrown)
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
if wait_for_idle:
try:
await page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
await asyncio.sleep(duration)
except Exception as e:
self.exceptions.append(
{
"type": "navigation_error",
"level": "error",
"text": f"Navigation failed: {e}",
"description": str(e),
}
)
finally:
await browser.close()
all_entries = self.messages + self.exceptions
all_entries.sort(key=lambda x: x.get("timestamp", 0))
error_count = sum(1 for e in all_entries if e.get("level") == "error")
warning_count = sum(1 for e in all_entries if e.get("level") == "warning")
return {
"metadata": {
"url": url,
"timestamp": datetime.now(timezone.utc).isoformat(),
"duration_seconds": duration,
"browser": "Chromium",
"options": {
"errors_only": self.errors_only,
"with_stack_traces": self.with_stack_traces,
},
},
"data": all_entries,
"summary": {
"total": len(all_entries),
"messages": len(self.messages),
"exceptions": len(self.exceptions),
"by_level": self._count_by_level(all_entries),
"errors": error_count,
"warnings": warning_count,
},
}
def _count_by_level(self, entries: list[dict]) -> dict[str, int]:
"""Count entries by severity level."""
counts: dict[str, int] = {}
for entry in entries:
level = entry.get("level", "info")
counts[level] = counts.get(level, 0) + 1
return counts
async def main() -> None:
parser = argparse.ArgumentParser(
description="Capture console logs and JavaScript errors via Chrome DevTools Protocol",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run console_debugger.py http://localhost:3000
uv run console_debugger.py http://localhost:3000 --errors-only
uv run console_debugger.py http://localhost:3000 --with-stack-traces
uv run console_debugger.py http://localhost:3000 --duration 60 --output /tmp/console.json
Captured message types:
- Console API calls (log, warn, error, debug, info, assert, trace)
- Unhandled exceptions
- Unhandled promise rejections
""",
)
parser.add_argument("url", help="URL to inspect")
parser.add_argument(
"--duration",
"-d",
type=float,
default=10.0,
help="Capture duration in seconds (default: 10)",
)
parser.add_argument(
"--errors-only",
"-e",
action="store_true",
help="Only capture errors and warnings (skip log/info/debug)",
)
parser.add_argument(
"--with-stack-traces",
"-s",
action="store_true",
default=True,
help="Include stack traces (default: True)",
)
parser.add_argument(
"--no-stack-traces",
action="store_true",
help="Exclude stack traces from output",
)
parser.add_argument(
"--output",
"-o",
help="Output file path (default: stdout)",
)
args = parser.parse_args()
with_stack_traces = args.with_stack_traces and not args.no_stack_traces
debugger = ConsoleDebugger(
errors_only=args.errors_only,
with_stack_traces=with_stack_traces,
)
results = await debugger.capture(args.url, duration=args.duration)
json_str = json.dumps(results, indent=2, default=str)
if args.output:
with open(args.output, "w") as f:
f.write(json_str)
print(f"Output written to: {args.output}", file=sys.stderr)
else:
print(json_str)
if __name__ == "__main__":
asyncio.run(main())
scripts/cookie_consent.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Reusable cookie consent dismissal for Playwright automation
# ABOUTME: Import dismiss_cookie_consent() in any script to handle cookie banners
"""
Cookie Consent Handler for Playwright
Usage:
from cookie_consent import dismiss_cookie_consent
page.goto('https://example.com')
page.wait_for_load_state('networkidle')
dismiss_cookie_consent(page) # ALWAYS call after navigation
"""
from playwright.sync_api import Page
# Comprehensive list of cookie consent selectors, ordered by specificity
COOKIE_SELECTORS = [
# Specific consent management platforms (most reliable)
'[id*="onetrust"] button[id*="accept"]',
'[class*="onetrust"] button[id*="accept"]',
'#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll',
'[data-testid="cookie-policy-dialog-accept-button"]',
'[data-cookiebanner] button[data-action="accept"]',
'#gdpr-cookie-accept',
'.cc-accept-all',
'.cc-allow',
'[data-consent="accept"]',
'#cookie-accept',
'#accept-cookies',
'.accept-cookies',
'#acceptAllCookies',
'.acceptAllCookies',
# Generic patterns (text-based) - English
'button:has-text("Accept all")',
'button:has-text("Accept All")',
'button:has-text("Accept cookies")',
'button:has-text("Accept Cookies")',
'button:has-text("Allow all")',
'button:has-text("Allow All")',
'button:has-text("Allow cookies")',
'button:has-text("I agree")',
'button:has-text("I Accept")',
'button:has-text("Agree")',
'button:has-text("Got it")',
'button:has-text("OK")',
'button:has-text("Continue")',
# Generic patterns (text-based) - Italian
'button:has-text("Accetta tutti")',
'button:has-text("Accetta tutto")',
'button:has-text("Accetta")',
'button:has-text("Accetto")',
'button:has-text("Consenti")',
'button:has-text("Consenti tutti")',
# Generic patterns (text-based) - German
'button:has-text("Alle akzeptieren")',
'button:has-text("Akzeptieren")',
'button:has-text("Zustimmen")',
# Generic patterns (text-based) - French
'button:has-text("Accepter tout")',
'button:has-text("Accepter")',
'button:has-text("J\'accepte")',
# Generic patterns (text-based) - Spanish
'button:has-text("Aceptar todo")',
'button:has-text("Aceptar")',
# Generic patterns (attribute-based)
'[class*="cookie"] button[class*="accept"]',
'[class*="cookie"] button[class*="agree"]',
'[class*="cookie"] button[class*="allow"]',
'[class*="consent"] button[class*="accept"]',
'[class*="consent"] button[class*="agree"]',
'[class*="consent"] button[class*="allow"]',
'[id*="cookie"] button[class*="accept"]',
'[id*="consent"] button[class*="accept"]',
'[aria-label*="cookie" i] button',
'[aria-label*="consent" i] button',
'[role="dialog"] button[class*="accept"]',
'[role="dialog"] button[class*="agree"]',
# Fallback: any prominent button in cookie-related containers
'[class*="cookie-banner"] button:first-of-type',
'[class*="cookie-notice"] button:first-of-type',
'[class*="cookie-popup"] button:first-of-type',
'[class*="gdpr"] button:first-of-type',
]
def dismiss_cookie_consent(page: Page, timeout: int = 3000, verbose: bool = False) -> bool:
"""
Dismiss cookie consent banner if present. Non-blocking.
Args:
page: Playwright Page object
timeout: How long to wait for each selector (ms)
verbose: Print debug info
Returns:
True if a cookie banner was dismissed, False otherwise
Usage:
page.goto('https://example.com')
page.wait_for_load_state('networkidle')
dismiss_cookie_consent(page) # ALWAYS call after navigation
"""
for selector in COOKIE_SELECTORS:
try:
btn = page.locator(selector).first
if btn.is_visible(timeout=timeout):
if verbose:
print(f"[COOKIE] Found and clicking: {selector}")
btn.click()
page.wait_for_timeout(500) # Brief pause for banner to close
return True
except Exception:
continue
if verbose:
print("[COOKIE] No cookie consent banner found")
return False
def dismiss_cookie_consent_js(page: Page, verbose: bool = False) -> bool:
"""
Alternative: Dismiss cookie consent using JavaScript injection.
Use this if the CSS selector approach fails.
This approach clicks buttons containing accept-related text.
"""
result = page.evaluate('''() => {
const acceptTexts = [
'accept all', 'accept cookies', 'allow all', 'allow cookies',
'i agree', 'agree', 'got it', 'ok', 'continue',
'accetta', 'accetto', 'consenti',
'akzeptieren', 'zustimmen',
'accepter', "j'accepte",
'aceptar'
];
const buttons = document.querySelectorAll('button, [role="button"], a.button');
for (const btn of buttons) {
const text = btn.textContent?.toLowerCase().trim() || '';
for (const acceptText of acceptTexts) {
if (text.includes(acceptText)) {
btn.click();
return { found: true, text: btn.textContent };
}
}
}
return { found: false };
}''')
if verbose:
if result.get('found'):
print(f"[COOKIE-JS] Clicked button with text: {result.get('text')}")
else:
print("[COOKIE-JS] No cookie consent button found")
return result.get('found', False)
def dismiss_all_overlays(page: Page, verbose: bool = False) -> None:
"""
Dismiss cookie consent AND other blocking overlays (modals, popups, dialogs).
Call this after page load to ensure no overlays block interaction.
Args:
page: Playwright Page object
verbose: Print debug info
Usage:
page.goto('https://example.com')
page.wait_for_load_state('networkidle')
dismiss_all_overlays(page) # Clears cookies AND other overlays
"""
# First: cookie consent
dismiss_cookie_consent(page, verbose=verbose)
# Second: dismiss other common overlays via JavaScript
page.evaluate('''() => {
// Close any modal/dialog overlays by clicking close buttons
const closeSelectors = [
'[aria-label="Close"]',
'[aria-label="close"]',
'button[class*="close"]',
'button[class*="dismiss"]',
'.modal-close',
'.popup-close',
'.overlay-close',
'[data-dismiss="modal"]',
'.btn-close',
'button svg[class*="close"]',
'[data-testid="close-button"]',
'[data-testid="modal-close"]',
];
for (const sel of closeSelectors) {
const btn = document.querySelector(sel);
if (btn && btn.offsetParent !== null) {
btn.click();
break;
}
}
// Remove overlay elements that block interaction
const overlaySelectors = [
'.modal-backdrop',
'.overlay',
'[class*="overlay"]',
'[class*="modal-overlay"]',
'[class*="popup-overlay"]',
'[class*="backdrop"]',
];
for (const sel of overlaySelectors) {
const overlays = document.querySelectorAll(sel);
overlays.forEach(el => {
const style = window.getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') {
el.remove();
}
});
}
}''')
page.wait_for_timeout(300)
if verbose:
print("[OVERLAY] Dismissed all overlays")
def force_remove_overlay(page: Page, verbose: bool = False) -> int:
"""
NUCLEAR OPTION: Forcefully remove all blocking overlays via JavaScript.
Use this when dismiss_all_overlays() doesn't work and clicks are still blocked.
This directly removes DOM elements that cover the page.
Args:
page: Playwright Page object
verbose: Print debug info
Returns:
Number of elements removed
Usage:
page.goto('https://example.com')
page.wait_for_load_state('networkidle')
dismiss_all_overlays(page) # Try gentle approach first
# If still blocked:
removed = force_remove_overlay(page)
print(f"Removed {removed} blocking elements")
"""
result = page.evaluate('''() => {
let removed = 0;
const removedElements = [];
// 1. Remove elements by common overlay/modal class patterns
const classPatterns = [
'cookie', 'consent', 'gdpr', 'privacy', 'notice', 'banner',
'modal', 'overlay', 'popup', 'dialog', 'backdrop', 'mask',
'notification', 'alert-overlay', 'blocker'
];
for (const pattern of classPatterns) {
// Match class names containing the pattern
const elements = document.querySelectorAll(`[class*="${pattern}"]`);
for (const el of elements) {
const style = window.getComputedStyle(el);
// Only remove fixed/absolute positioned elements that cover significant area
if (style.position === 'fixed' || style.position === 'absolute') {
const rect = el.getBoundingClientRect();
// Check if element covers significant viewport area or is near top/bottom
const coversViewport = rect.width > window.innerWidth * 0.3 ||
rect.height > window.innerHeight * 0.2;
const isEdgeBanner = rect.top <= 100 || rect.bottom >= window.innerHeight - 100;
if (coversViewport || isEdgeBanner) {
removedElements.push({
tag: el.tagName,
class: el.className,
id: el.id
});
el.remove();
removed++;
}
}
}
}
// 2. Remove elements by ID patterns
const idPatterns = [
'cookie', 'consent', 'gdpr', 'privacy', 'modal', 'overlay', 'popup'
];
for (const pattern of idPatterns) {
const elements = document.querySelectorAll(`[id*="${pattern}"]`);
for (const el of elements) {
const style = window.getComputedStyle(el);
if (style.position === 'fixed' || style.position === 'absolute') {
if (!removedElements.some(r => r.id === el.id)) {
removedElements.push({
tag: el.tagName,
class: el.className,
id: el.id
});
el.remove();
removed++;
}
}
}
}
// 3. Remove any remaining full-screen fixed elements with high z-index
const fixedElements = document.querySelectorAll('*');
for (const el of fixedElements) {
const style = window.getComputedStyle(el);
if (style.position === 'fixed') {
const zIndex = parseInt(style.zIndex) || 0;
const rect = el.getBoundingClientRect();
// High z-index AND covers most of viewport
if (zIndex > 100 &&
rect.width > window.innerWidth * 0.5 &&
rect.height > window.innerHeight * 0.5) {
removedElements.push({
tag: el.tagName,
class: el.className,
id: el.id,
zIndex: zIndex
});
el.remove();
removed++;
}
}
}
// 4. Reset body overflow (often set to 'hidden' when modals are open)
document.body.style.overflow = 'auto';
document.documentElement.style.overflow = 'auto';
return { removed, elements: removedElements };
}''')
if verbose:
print(f"[FORCE-REMOVE] Removed {result['removed']} blocking elements:")
for el in result.get('elements', []):
print(f" - <{el['tag']}> class='{el.get('class', '')}' id='{el.get('id', '')}'")
return result['removed']
def set_cookie_consent_storage(page: Page, verbose: bool = False) -> None:
"""
Set common localStorage/cookie values that indicate consent was given.
Call BEFORE navigating to the page to prevent banner from appearing.
Usage:
page.goto('https://example.com') # Initial load to set context
set_cookie_consent_storage(page)
page.reload() # Reload with consent set
"""
page.evaluate('''() => {
// Common localStorage keys
const consentKeys = [
'cookie-consent', 'cookieConsent', 'cookies-accepted',
'gdpr-consent', 'gdprConsent', 'privacy-consent',
'CookieConsent', 'cookie_consent', 'cookies_accepted'
];
for (const key of consentKeys) {
localStorage.setItem(key, 'accepted');
localStorage.setItem(key, 'true');
localStorage.setItem(key, '1');
}
// Set cookies too
document.cookie = 'cookie-consent=accepted; path=/; max-age=31536000';
document.cookie = 'gdpr-consent=accepted; path=/; max-age=31536000';
}''')
if verbose:
print("[COOKIE] Set consent in localStorage and cookies")
if __name__ == "__main__":
# Demo/test
from playwright.sync_api import sync_playwright
print("Cookie Consent Handler - Demo")
print("=" * 40)
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
# Test with a site that has cookie consent
test_url = "https://www.google.com"
print(f"\nTesting with: {test_url}")
page.goto(test_url)
page.wait_for_load_state('networkidle')
dismissed = dismiss_cookie_consent(page, verbose=True)
print(f"Cookie banner dismissed: {dismissed}")
browser.close()
print("\nDone!")
scripts/network_inspector.py
# /// script
# requires-python = ">=3.11"
# dependencies = ["playwright"]
# ///
# ABOUTME: Network request/response inspector using Chrome DevTools Protocol
# ABOUTME: Captures XHR, fetch, WebSocket with timing, headers, and optional bodies
"""
Network Inspector
Capture and analyze network requests via Chrome DevTools Protocol.
Supports XHR, fetch, script, stylesheet, and WebSocket inspection.
Usage:
uv run network_inspector.py http://localhost:3000
uv run network_inspector.py http://localhost:3000 --errors-only
uv run network_inspector.py http://localhost:3000 --filter xhr,fetch --capture-bodies
uv run network_inspector.py http://localhost:3000 --url-pattern "api/" --output /tmp/network.json
"""
from __future__ import annotations
import argparse
import asyncio
import json
import re
import sys
from datetime import datetime, timezone
from typing import Any
from playwright.async_api import async_playwright
class NetworkInspector:
"""Captures network requests via CDP Network domain."""
def __init__(
self,
filter_types: list[str] | None = None,
url_pattern: str | None = None,
errors_only: bool = False,
capture_bodies: bool = False,
max_body_size: int = 10240,
):
self.filter_types = filter_types
self.url_pattern = re.compile(url_pattern) if url_pattern else None
self.errors_only = errors_only
self.capture_bodies = capture_bodies
self.max_body_size = max_body_size
self.requests: dict[str, dict[str, Any]] = {}
self.completed: list[dict[str, Any]] = []
self.client = None
def _should_capture(self, url: str, resource_type: str) -> bool:
"""Check if request matches filters."""
if self.filter_types:
if resource_type.lower() not in [t.lower() for t in self.filter_types]:
return False
if self.url_pattern:
if not self.url_pattern.search(url):
return False
return True
def _on_request_will_be_sent(self, params: dict) -> None:
"""Handle Network.requestWillBeSent event."""
request_id = params["requestId"]
request = params["request"]
resource_type = params.get("type", "Other")
if not self._should_capture(request["url"], resource_type):
return
self.requests[request_id] = {
"id": request_id,
"url": request["url"],
"method": request["method"],
"type": resource_type,
"request": {
"headers": request.get("headers", {}),
"post_data": request.get("postData"),
},
"timestamp": params.get("wallTime", datetime.now(timezone.utc).timestamp()),
"timing": {},
"response": None,
"error": None,
}
def _on_response_received(self, params: dict) -> None:
"""Handle Network.responseReceived event."""
request_id = params["requestId"]
if request_id not in self.requests:
return
response = params["response"]
self.requests[request_id]["response"] = {
"status": response["status"],
"status_text": response.get("statusText", ""),
"headers": response.get("headers", {}),
"mime_type": response.get("mimeType", ""),
"remote_address": response.get("remoteIPAddress"),
}
timing = response.get("timing")
if timing:
self.requests[request_id]["timing"] = {
"dns_ms": timing.get("dnsEnd", 0) - timing.get("dnsStart", 0),
"connect_ms": timing.get("connectEnd", 0) - timing.get("connectStart", 0),
"ssl_ms": timing.get("sslEnd", 0) - timing.get("sslStart", 0),
"ttfb_ms": timing.get("receiveHeadersEnd", 0),
}
async def _on_loading_finished(self, params: dict) -> None:
"""Handle Network.loadingFinished event."""
request_id = params["requestId"]
if request_id not in self.requests:
return
req = self.requests[request_id]
req["completed"] = True
req["encoded_data_length"] = params.get("encodedDataLength", 0)
if self.capture_bodies and self.client:
try:
body_response = await self.client.send(
"Network.getResponseBody",
{"requestId": request_id},
)
body = body_response.get("body", "")
if len(body) > self.max_body_size:
body = body[: self.max_body_size] + f"... (truncated, total {len(body)} bytes)"
req["response_body"] = body
req["body_base64"] = body_response.get("base64Encoded", False)
except Exception:
req["response_body"] = None
if not self.errors_only or req.get("error") or (req.get("response", {}).get("status", 0) >= 400):
self.completed.append(req)
del self.requests[request_id]
def _on_loading_failed(self, params: dict) -> None:
"""Handle Network.loadingFailed event."""
request_id = params["requestId"]
if request_id not in self.requests:
return
req = self.requests[request_id]
req["error"] = {
"type": params.get("type", "Unknown"),
"error_text": params.get("errorText", ""),
"canceled": params.get("canceled", False),
"blocked_reason": params.get("blockedReason"),
"cors_error": params.get("corsErrorStatus"),
}
req["completed"] = False
self.completed.append(req)
del self.requests[request_id]
async def capture(
self,
url: str,
duration: float = 30.0,
wait_for_idle: bool = True,
) -> dict:
"""
Capture network requests for a URL.
Args:
url: URL to navigate to
duration: Maximum capture duration in seconds
wait_for_idle: Wait for network idle before starting timer
Returns:
Capture results dictionary
"""
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
self.client = await context.new_cdp_session(page)
await self.client.send("Network.enable")
self.client.on("Network.requestWillBeSent", self._on_request_will_be_sent)
self.client.on("Network.responseReceived", self._on_response_received)
self.client.on(
"Network.loadingFinished",
lambda p: asyncio.create_task(self._on_loading_finished(p)),
)
self.client.on("Network.loadingFailed", self._on_loading_failed)
try:
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
if wait_for_idle:
try:
await page.wait_for_load_state("networkidle", timeout=10000)
except Exception:
pass
await asyncio.sleep(duration)
except Exception as e:
self.completed.append(
{
"id": "navigation_error",
"url": url,
"error": {"type": "NavigationError", "error_text": str(e)},
}
)
finally:
await browser.close()
error_count = sum(1 for r in self.completed if r.get("error") or (r.get("response", {}).get("status", 0) >= 400))
return {
"metadata": {
"url": url,
"timestamp": datetime.now(timezone.utc).isoformat(),
"duration_seconds": duration,
"browser": "Chromium",
"filters": {
"types": self.filter_types,
"url_pattern": self.url_pattern.pattern if self.url_pattern else None,
"errors_only": self.errors_only,
},
},
"data": self.completed,
"summary": {
"total": len(self.completed),
"errors": error_count,
"by_type": self._count_by_type(),
"by_status": self._count_by_status(),
},
}
def _count_by_type(self) -> dict[str, int]:
"""Count requests by resource type."""
counts: dict[str, int] = {}
for req in self.completed:
req_type = req.get("type", "Other")
counts[req_type] = counts.get(req_type, 0) + 1
return counts
def _count_by_status(self) -> dict[str, int]:
"""Count requests by status code range."""
counts = {"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0, "failed": 0}
for req in self.completed:
if req.get("error"):
counts["failed"] += 1
elif req.get("response"):
status = req["response"].get("status", 0)
if 200 <= status < 300:
counts["2xx"] += 1
elif 300 <= status < 400:
counts["3xx"] += 1
elif 400 <= status < 500:
counts["4xx"] += 1
elif status >= 500:
counts["5xx"] += 1
return counts
async def main() -> None:
parser = argparse.ArgumentParser(
description="Capture and analyze network requests via Chrome DevTools Protocol",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
uv run network_inspector.py http://localhost:3000
uv run network_inspector.py http://localhost:3000 --errors-only
uv run network_inspector.py http://localhost:3000 --filter xhr,fetch
uv run network_inspector.py http://localhost:3000 --url-pattern "api/" --capture-bodies
uv run network_inspector.py http://localhost:3000 --output /tmp/network.json
Resource types: Document, Stylesheet, Image, Media, Font, Script, TextTrack,
XHR, Fetch, Prefetch, EventSource, WebSocket, Manifest, Other
""",
)
parser.add_argument("url", help="URL to inspect")
parser.add_argument(
"--duration",
"-d",
type=float,
default=10.0,
help="Capture duration in seconds (default: 10)",
)
parser.add_argument(
"--filter",
"-f",
help="Comma-separated resource types to capture (e.g., xhr,fetch,script)",
)
parser.add_argument(
"--url-pattern",
"-p",
help="Regex pattern to filter URLs (e.g., 'api/')",
)
parser.add_argument(
"--errors-only",
"-e",
action="store_true",
help="Only show failed requests and 4xx/5xx responses",
)
parser.add_argument(
"--capture-bodies",
"-b",
action="store_true",
help="Capture response bodies (increases memory usage)",
)
parser.add_argument(
"--max-body-size",
type=int,
default=10240,
help="Maximum response body size to capture in bytes (default: 10240)",
)
parser.add_argument(
"--output",
"-o",
help="Output file path (default: stdout)",
)
args = parser.parse_args()
filter_types = None
if args.filter:
filter_types = [t.strip() for t in args.filter.split(",")]
inspector = NetworkInspector(
filter_types=filter_types,
url_pattern=args.url_pattern,
errors_only=args.errors_only,
capture_bodies=args.capture_bodies,
max_body_size=args.max_body_size,
)
results = await inspector.capture(args.url, duration=args.duration)
json_str = json.dumps(results, indent=2, default=str)
if args.output:
with open(args.output, "w") as f:
f.write(json_str)
print(f"Output written to: {args.output}", file=sys.stderr)
else:
print(json_str)
if __name__ == "__main__":
asyncio.run(main())
scripts/server_utils.py
# ABOUTME: Wrapper around shared server detection library
# ABOUTME: Provides CLI interface for server detection and startup
"""
Server Utilities
Detects project type from repository structure and manages dev server lifecycle.
Uses shared server_detection library from ~/.claude/lib/
Usage:
python server_utils.py <repo_path> [--start] [--detect-only]
Example:
python server_utils.py /path/to/project --detect-only
python server_utils.py . --start
"""
import sys
from pathlib import Path
# Import from shared library
sys.path.insert(0, str(Path.home() / ".claude" / "lib"))
from server_detection import (
detect_project,
start_server,
print_project_info,
is_port_in_use,
ProjectInfo,
)
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Detect project type and manage dev server lifecycle.",
epilog="Example: uv run server_utils.py /path/to/project --detect-only",
)
parser.add_argument(
"repo_path",
help="Path to the repository to analyze (e.g., . or /path/to/project)",
)
parser.add_argument(
"--detect-only", "-d",
action="store_true",
help="Only detect project type, do not start server",
)
parser.add_argument(
"--start", "-s",
action="store_true",
help="Start the detected dev server and stream output",
)
args = parser.parse_args()
print(f"Analyzing repository: {args.repo_path}")
project = detect_project(args.repo_path)
print_project_info(project)
if args.detect_only:
sys.exit(0)
if args.start:
print(f"\nStarting server...")
try:
proc = start_server(project)
print(f"Server process started (PID: {proc.pid})")
print(f"Waiting for server at {project.url}...")
if wait_for_server(project.url):
print(f"Server is ready at {project.url}")
print("Press Ctrl+C to stop")
# Stream output
try:
while True:
line = proc.stdout.readline()
if line:
print(f" {line.rstrip()}")
elif proc.poll() is not None:
break
except KeyboardInterrupt:
print("\nStopping server...")
proc.terminate()
proc.wait()
print("Server stopped")
else:
print(f"Server failed to start within timeout")
proc.terminate()
sys.exit(1)
except RuntimeError as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
scripts/test_utils.py
# ABOUTME: Helper utilities for detecting test frameworks and running tests
# ABOUTME: Supports Jest, Playwright, Vitest, pytest, and other common test runners
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Test Utilities
Detects test frameworks in a project and runs tests with optional server startup.
Usage:
python test_utils.py <repo_path> [options]
Examples:
python test_utils.py . --detect-only
python test_utils.py . --run
python test_utils.py . --run --with-server
python test_utils.py . --run --filter "e2e"
"""
import json
import subprocess
import sys
import time
import signal
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import socket
@dataclass
class TestFramework:
"""Information about a detected test framework."""
name: str
runner: str
command: list[str]
config_file: Optional[str] = None
test_dir: Optional[str] = None
@dataclass
class TestConfig:
"""Complete test configuration for a project."""
project_type: str
frameworks: list[TestFramework] = field(default_factory=list)
has_e2e: bool = False
has_unit: bool = False
working_dir: Optional[str] = None
server_required: bool = False
server_command: Optional[list[str]] = None
server_port: Optional[int] = None
def is_port_in_use(port: int) -> bool:
"""Check if a port is already in use."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
return s.connect_ex(("localhost", port)) == 0
def detect_js_test_frameworks(repo_path: Path, package_json: dict) -> list[TestFramework]:
"""Detect JavaScript/TypeScript test frameworks."""
frameworks = []
deps = {
**package_json.get("dependencies", {}),
**package_json.get("devDependencies", {}),
}
scripts = package_json.get("scripts", {})
# Playwright
if "@playwright/test" in deps or "playwright" in deps:
config_file = None
for cfg in ["playwright.config.ts", "playwright.config.js"]:
if (repo_path / cfg).exists():
config_file = cfg
break
cmd = ["npx", "playwright", "test"]
frameworks.append(TestFramework(
name="playwright",
runner="npx",
command=cmd,
config_file=config_file,
test_dir="tests/e2e" if (repo_path / "tests/e2e").exists() else "tests",
))
# Jest
if "jest" in deps or (repo_path / "jest.config.js").exists() or (repo_path / "jest.config.ts").exists():
config_file = None
for cfg in ["jest.config.js", "jest.config.ts", "jest.config.mjs"]:
if (repo_path / cfg).exists():
config_file = cfg
break
cmd = ["npx", "jest"]
frameworks.append(TestFramework(
name="jest",
runner="npx",
command=cmd,
config_file=config_file,
))
# Vitest
if "vitest" in deps:
cmd = ["npx", "vitest", "run"]
frameworks.append(TestFramework(
name="vitest",
runner="npx",
command=cmd,
config_file="vitest.config.ts" if (repo_path / "vitest.config.ts").exists() else None,
))
# Mocha
if "mocha" in deps:
cmd = ["npx", "mocha"]
frameworks.append(TestFramework(
name="mocha",
runner="npx",
command=cmd,
))
# Cypress
if "cypress" in deps:
cmd = ["npx", "cypress", "run"]
frameworks.append(TestFramework(
name="cypress",
runner="npx",
command=cmd,
config_file="cypress.config.js" if (repo_path / "cypress.config.js").exists() else None,
))
# Check npm scripts for test commands
if not frameworks:
if "test" in scripts:
cmd = ["npm", "test"]
frameworks.append(TestFramework(
name="npm-test",
runner="npm",
command=cmd,
))
if "test:unit" in scripts:
cmd = ["npm", "run", "test:unit"]
frameworks.append(TestFramework(
name="npm-test-unit",
runner="npm",
command=cmd,
))
if "test:e2e" in scripts:
cmd = ["npm", "run", "test:e2e"]
frameworks.append(TestFramework(
name="npm-test-e2e",
runner="npm",
command=cmd,
))
return frameworks
def detect_python_test_frameworks(repo_path: Path) -> list[TestFramework]:
"""Detect Python test frameworks."""
frameworks = []
# Check for pytest
has_pytest = False
pyproject = repo_path / "pyproject.toml"
requirements = repo_path / "requirements.txt"
setup_py = repo_path / "setup.py"
content = ""
if pyproject.exists():
content += pyproject.read_text()
if requirements.exists():
content += requirements.read_text()
if setup_py.exists():
content += setup_py.read_text()
if "pytest" in content.lower() or (repo_path / "pytest.ini").exists() or (repo_path / "conftest.py").exists():
has_pytest = True
# Check for test directories
test_dirs = []
for d in ["tests", "test", "tests/unit", "tests/e2e", "tests/integration"]:
if (repo_path / d).exists():
test_dirs.append(d)
if has_pytest or test_dirs:
cmd = ["pytest", "-v"]
if (repo_path / "pytest.ini").exists():
config_file = "pytest.ini"
elif (repo_path / "pyproject.toml").exists():
config_file = "pyproject.toml"
else:
config_file = None
frameworks.append(TestFramework(
name="pytest",
runner="pytest",
command=cmd,
config_file=config_file,
test_dir="tests" if (repo_path / "tests").exists() else None,
))
# Check for unittest
if not frameworks and test_dirs:
cmd = ["python", "-m", "unittest", "discover"]
frameworks.append(TestFramework(
name="unittest",
runner="python",
command=cmd,
test_dir=test_dirs[0] if test_dirs else None,
))
return frameworks
def detect_test_config(repo_path: str) -> TestConfig:
"""Detect all test frameworks and configuration for a project."""
path = Path(repo_path).resolve()
frameworks = []
project_type = "unknown"
server_required = False
server_command = None
server_port = None
# Node.js project
package_json_path = path / "package.json"
if package_json_path.exists():
with open(package_json_path) as f:
package_json = json.load(f)
project_type = "nodejs"
frameworks.extend(detect_js_test_frameworks(path, package_json))
# Check if server is needed for e2e tests
scripts = package_json.get("scripts", {})
if any(f.name in ["playwright", "cypress", "npm-test-e2e"] for f in frameworks):
server_required = True
if "dev" in scripts:
server_command = ["npm", "run", "dev"]
elif "serve" in scripts:
server_command = ["npm", "run", "serve"]
elif "start" in scripts:
server_command = ["npm", "start"]
# Hugo project (check for hugo.toml or config.toml with content/)
hugo_configs = ["hugo.toml", "hugo.yaml", "hugo.json", "config.toml", "config.yaml"]
has_hugo = any((path / cfg).exists() for cfg in hugo_configs) and (
(path / "content").exists() or (path / "layouts").exists()
)
if has_hugo:
project_type = "hugo"
server_required = True
server_command = ["hugo", "server", "-D"]
server_port = 1313
# Hugo projects often have package.json for test tooling
# Only detect JS frameworks if not already detected above
if package_json_path.exists() and not frameworks:
with open(package_json_path) as f:
package_json = json.load(f)
frameworks.extend(detect_js_test_frameworks(path, package_json))
# Python project
has_python = any([
(path / "pyproject.toml").exists(),
(path / "requirements.txt").exists(),
(path / "setup.py").exists(),
])
if has_python and project_type == "unknown":
project_type = "python"
frameworks.extend(detect_python_test_frameworks(path))
# Check for web frameworks that need server
content = ""
if (path / "pyproject.toml").exists():
content += (path / "pyproject.toml").read_text()
if (path / "requirements.txt").exists():
content += (path / "requirements.txt").read_text()
content_lower = content.lower()
if any(fw in content_lower for fw in ["fastapi", "flask", "django", "uvicorn"]):
server_required = True
if "fastapi" in content_lower or "uvicorn" in content_lower:
server_command = ["uvicorn", "main:app", "--reload"]
server_port = 8000
elif "flask" in content_lower:
server_command = ["flask", "run"]
server_port = 5000
elif "django" in content_lower:
server_command = ["python", "manage.py", "runserver"]
server_port = 8000
# Determine test types
has_e2e = any(f.name in ["playwright", "cypress", "npm-test-e2e"] for f in frameworks)
has_unit = any(f.name in ["jest", "vitest", "pytest", "unittest", "mocha", "npm-test-unit"] for f in frameworks)
return TestConfig(
project_type=project_type,
frameworks=frameworks,
has_e2e=has_e2e,
has_unit=has_unit,
working_dir=str(path),
server_required=server_required,
server_command=server_command,
server_port=server_port,
)
def wait_for_server(port: int, timeout: int = 60) -> bool:
"""Wait for server to be ready on the given port."""
start = time.time()
while time.time() - start < timeout:
if is_port_in_use(port):
return True
time.sleep(0.5)
return False
def run_tests(
config: TestConfig,
framework_filter: Optional[str] = None,
test_filter: Optional[str] = None,
with_server: bool = False,
verbose: bool = True,
) -> dict:
"""Run tests with optional server startup."""
results = {
"success": True,
"frameworks_run": [],
"server_started": False,
"errors": [],
}
server_proc = None
try:
# Start server if needed
if with_server and config.server_required and config.server_command:
port = config.server_port or 3000
if is_port_in_use(port):
if verbose:
print(f"Server already running on port {port}")
else:
if verbose:
print(f"Starting server: {' '.join(config.server_command)}")
server_proc = subprocess.Popen(
config.server_command,
cwd=config.working_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
preexec_fn=os.setsid if os.name != 'nt' else None,
)
if verbose:
print(f"Waiting for server on port {port}...")
if wait_for_server(port, timeout=30):
if verbose:
print(f"Server ready on port {port}")
results["server_started"] = True
else:
results["errors"].append(f"Server failed to start on port {port}")
results["success"] = False
return results
# Filter frameworks if specified
frameworks_to_run = config.frameworks
if framework_filter:
frameworks_to_run = [f for f in frameworks_to_run if framework_filter.lower() in f.name.lower()]
if not frameworks_to_run:
if verbose:
print("No test frameworks found to run")
results["errors"].append("No test frameworks detected")
return results
# Run each framework
for framework in frameworks_to_run:
if verbose:
print(f"\n{'='*60}")
print(f"Running {framework.name} tests...")
print(f"Command: {' '.join(framework.command)}")
print('='*60)
cmd = framework.command.copy()
# Add test filter if provided
if test_filter:
if framework.name == "playwright":
cmd.extend(["--grep", test_filter])
elif framework.name == "jest":
cmd.extend(["--testNamePattern", test_filter])
elif framework.name == "pytest":
cmd.extend(["-k", test_filter])
elif framework.name == "vitest":
cmd.extend(["--testNamePattern", test_filter])
try:
result = subprocess.run(
cmd,
cwd=config.working_dir,
capture_output=not verbose,
text=True,
)
framework_result = {
"name": framework.name,
"success": result.returncode == 0,
"returncode": result.returncode,
}
if not verbose:
framework_result["stdout"] = result.stdout
framework_result["stderr"] = result.stderr
results["frameworks_run"].append(framework_result)
if result.returncode != 0:
results["success"] = False
except Exception as e:
results["errors"].append(f"{framework.name}: {str(e)}")
results["success"] = False
finally:
# Cleanup server
if server_proc:
if verbose:
print("\nStopping server...")
try:
if os.name != 'nt':
os.killpg(os.getpgid(server_proc.pid), signal.SIGTERM)
else:
server_proc.terminate()
server_proc.wait(timeout=5)
except Exception:
server_proc.kill()
return results
def print_test_config(config: TestConfig) -> None:
"""Print detected test configuration."""
print(f"\n=== TEST CONFIGURATION ===")
print(f" Project type: {config.project_type}")
print(f" Working directory: {config.working_dir}")
print(f" Has unit tests: {config.has_unit}")
print(f" Has E2E tests: {config.has_e2e}")
print(f" Server required: {config.server_required}")
if config.server_command:
print(f" Server command: {' '.join(config.server_command)}")
if config.server_port:
print(f" Server port: {config.server_port}")
print(f"\n Detected frameworks ({len(config.frameworks)}):")
for fw in config.frameworks:
print(f" - {fw.name}")
print(f" Command: {' '.join(fw.command)}")
if fw.config_file:
print(f" Config: {fw.config_file}")
if fw.test_dir:
print(f" Test dir: {fw.test_dir}")
def main() -> None:
import argparse
parser = argparse.ArgumentParser(
description="Detect test frameworks and run tests.",
epilog="""
Examples:
uv run test_utils.py . --detect-only
uv run test_utils.py . --run
uv run test_utils.py . --run --with-server
uv run test_utils.py . --run --framework playwright
uv run test_utils.py . --run --filter "login"
""",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"repo_path",
help="Path to the repository to analyze",
)
parser.add_argument(
"--detect-only", "-d",
action="store_true",
help="Only detect test frameworks, do not run tests",
)
parser.add_argument(
"--run", "-r",
action="store_true",
help="Run detected tests",
)
parser.add_argument(
"--with-server", "-s",
action="store_true",
help="Start server before running tests (for E2E)",
)
parser.add_argument(
"--framework", "-f",
help="Only run tests for specific framework (e.g., playwright, jest)",
)
parser.add_argument(
"--filter", "-k",
help="Filter tests by name pattern",
)
parser.add_argument(
"--quiet", "-q",
action="store_true",
help="Suppress output, only show summary",
)
args = parser.parse_args()
print(f"Analyzing repository: {args.repo_path}")
config = detect_test_config(args.repo_path)
print_test_config(config)
if args.detect_only:
sys.exit(0)
if args.run:
print("\n" + "="*60)
print("RUNNING TESTS")
print("="*60)
results = run_tests(
config,
framework_filter=args.framework,
test_filter=args.filter,
with_server=args.with_server,
verbose=not args.quiet,
)
print("\n" + "="*60)
print("TEST RESULTS SUMMARY")
print("="*60)
print(f" Overall success: {results['success']}")
print(f" Server started: {results['server_started']}")
for fw_result in results["frameworks_run"]:
status = "PASS" if fw_result["success"] else "FAIL"
print(f" {fw_result['name']}: {status} (exit code: {fw_result['returncode']})")
if results["errors"]:
print(f"\n Errors:")
for err in results["errors"]:
print(f" - {err}")
sys.exit(0 if results["success"] else 1)
if __name__ == "__main__":
main()
SKILL.md
---
name: web-automation
description: >-
Web automation, debugging, and E2E testing with Playwright. Handles interactive
(login, forms, reproduce bugs) and passive modes (network/console capture).
Triggers on "e2e test", "browser test", "playwright", "screenshot", "debug UI",
"debug frontend", "reproduce bug", "network trace", "console output", "verify fix",
"test that", "verify change", "test the flow", "http://localhost", "open browser",
"click button", "fill form", "submit form", "check page", "web scraping",
"automation script", "headless browser", "browser automation", "selenium alternative",
"puppeteer alternative", "page object", "web testing", "UI testing", "frontend testing",
"visual regression", "capture network", "intercept requests", "mock API responses".
PROACTIVE: Invoke for security verification, UI fix verification, testing forms/dropdowns,
or multi-step UI flows. ON SESSION RESUME - check for pending UI verifications.
allowed-tools: Read, Write, Edit, Bash, Glob, Grep
---
# ABOUTME: Claude Code skill for web automation, debugging, and E2E testing using Playwright
# ABOUTME: Covers interactive automation, passive monitoring, screenshots, and security verification
# Web Automation with Playwright
Browser automation and debugging using Playwright in **Python** or **JavaScript/TypeScript**.
**Detailed patterns**: See `references/python-patterns.md` and `references/javascript-patterns.md`
---
## Quick Reference
| Task | Helper Script |
|------|---------------|
| Login / fill forms | `examples/python/form_interaction.py` |
| Take screenshots | `examples/python/screenshot_capture.py` |
| Handle cookie consent | `scripts/cookie_consent.py` |
| Discover page elements | `examples/python/element_discovery.py` |
| Capture network traffic | `scripts/network_inspector.py` |
| Debug console errors | `scripts/console_debugger.py` |
| Full debug (network+console) | `scripts/combined_debugger.py` |
| Compare websites visually | `examples/python/visual_compare.py` |
**Always run helpers first**:
```bash
uv run ~/.claude/skills/web-automation/examples/python/element_discovery.py http://localhost:3000
uv run ~/.claude/skills/web-automation/examples/python/screenshot_capture.py http://localhost:3000 --output /tmp/shots
```
---
## Modes of Operation
| Mode | When to Use | Example |
|------|-------------|---------|
| **Interactive** | Click, type, navigate | Login flow, form submission |
| **Passive** | Observe only | Network capture, console monitoring |
| **E2E Testing** | Automated test suites | Playwright Test framework |
---
## When to Invoke (Proactive)
1. **Verifying UI fixes** - After changing frontend code
2. **Testing form fields/dropdowns** - Verify correct values display
3. **Confirming visual changes** - Take screenshots
4. **Reproducing bugs** - Automate steps to reproduce
5. **Security verification** - After Gemini/static analysis finds issues
---
## 🔄 RESUMED SESSION CHECKPOINT
```
┌─────────────────────────────────────────────────────────────┐
│ SESSION RESUMED - WEB AUTOMATION VERIFICATION │
│ │
│ 1. Was I in the middle of browser automation? │
│ → Run: ps aux | grep -E "chromium|playwright|node" │
│ │
│ 2. Were there UI verification tasks pending? │
│ → Check summary for "verify", "test UI", "screenshot" │
│ │
│ 3. Did previous automation capture any findings? │
│ → Check /tmp/ for screenshots, debug outputs │
└─────────────────────────────────────────────────────────────┘
```
---
## Decision Flow
```
Task:
+-- Need to interact? (click, type, submit) → Interactive mode
+-- Just observe/capture? → Passive mode (combined_debugger.py)
+-- Security verification? → Passive mode + grep for sensitive patterns
```
---
## CRITICAL: Handling Overlays
**Overlays WILL block automation.** Always dismiss after `page.goto()`:
### Python (Quick Pattern)
```python
page.goto('https://example.com')
page.wait_for_load_state('networkidle')
# Dismiss cookie consent
for sel in ['button:has-text("Accept all")', '[class*="cookie"] button[class*="accept"]']:
try:
btn = page.locator(sel).first
if btn.is_visible(timeout=2000):
btn.click()
break
except:
continue
```
### Nuclear Option (Remove All Overlays)
```python
page.evaluate('''() => {
const patterns = ['cookie', 'consent', 'modal', 'overlay', 'popup', 'backdrop'];
for (const p of patterns) {
document.querySelectorAll(`[class*="${p}"], [id*="${p}"]`).forEach(el => {
if (getComputedStyle(el).position === 'fixed') el.remove();
});
}
document.body.style.overflow = 'auto';
}''')
```
**Full implementation**: See `references/python-patterns.md`
---
## Core Patterns
### Python
```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:3000')
page.wait_for_load_state('networkidle') # CRITICAL
# ... automation
browser.close()
```
### JavaScript
```javascript
import { test, expect } from '@playwright/test';
test('example', async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await expect(page.locator('.element')).toBeVisible();
});
```
---
## Common Operations
| Operation | Python | JavaScript |
|-----------|--------|------------|
| Screenshot | `page.screenshot(path='/tmp/s.png')` | `await page.screenshot({ path: '/tmp/s.png' })` |
| Full page | `page.screenshot(path='/tmp/s.png', full_page=True)` | `await page.screenshot({ path: '/tmp/s.png', fullPage: true })` |
| Fill input | `page.fill('input[name="email"]', 'x@y.com')` | `await page.fill('input[name="email"]', 'x@y.com')` |
| Select dropdown | `page.select_option('select#id', 'value')` | `await page.selectOption('select#id', 'value')` |
| Click | `page.click('button[type="submit"]')` | `await page.click('button[type="submit"]')` |
| Wait network | `page.wait_for_load_state('networkidle')` | `await page.waitForLoadState('networkidle')` |
| Wait element | `page.wait_for_selector('.result')` | `await page.waitForSelector('.result')` |
---
## Selector Strategies (Order of Preference)
1. **Role-based**: `page.get_by_role('button', name='Submit')`
2. **Text-based**: `page.get_by_text('Click me')`
3. **Test IDs**: `page.get_by_test_id('submit-btn')`
4. **CSS**: `page.locator('.btn-primary')`
5. **XPath** (last resort): `page.locator('//button[@type="submit"]')`
---
## Verification Checklist
| What to Verify | Approach |
|----------------|----------|
| Dropdown value | `page.locator('select').input_value()` |
| Input text | `page.locator('input').input_value()` |
| Element visible | `page.locator('.element').is_visible()` |
| Text content | `page.locator('.element').text_content()` |
| Page URL | `page.url` after action |
---
## Passive Debugging Scripts
| Script | Purpose | Example |
|--------|---------|---------|
| `combined_debugger.py` | Network + Console + Errors | `uv run ... --duration 30 --output /tmp/debug.json` |
| `network_inspector.py` | Network only | `uv run ... --errors-only` |
| `console_debugger.py` | Console/errors only | `uv run ... --with-stack-traces` |
### Security Verification
```bash
# After Gemini found sensitive data logging
uv run ~/.claude/skills/web-automation/scripts/console_debugger.py \
http://localhost:3000 --duration 60 --output /tmp/security.json
grep -i "password\|token\|secret\|bearer" /tmp/security.json
```
---
## Visual Comparison
**NEVER say "I cannot visually browse"**. Instead:
```bash
# Compare two sites
uv run ~/.claude/skills/web-automation/examples/python/visual_compare.py \
https://reference-site.com \
http://localhost:3000 \
--output /tmp/compare
# Then read the screenshots using Read tool
```
---
## Language Selection
| Use Case | Recommended | Reason |
|----------|-------------|--------|
| Existing JS/TS project | JavaScript | Consistent tooling |
| Existing Python project | Python | Consistent tooling |
| Quick scripts | Python | Simpler setup with `uv run` |
| Test suites | JavaScript | Better `@playwright/test` framework |
---
## Test Framework Integration
See `references/test-framework.md` for:
- Unified test runner (`test_utils.py`)
- Server auto-detection and startup
- Framework detection (Playwright, Jest, pytest, etc.)
```bash
# Detect and run tests with server
uv run ~/.claude/skills/web-automation/scripts/test_utils.py . --run --with-server
```
---
## Common Pitfalls
| Pitfall | Solution |
|---------|----------|
| Overlay blocking clicks | Call overlay dismissal after EVERY page load |
| DOM inspection before JS loads | Always `wait_for_load_state('networkidle')` first |
| Headful browser in CI | Always use `headless: true` |
| Flaky selectors | Prefer role/text selectors over CSS classes |
| Race conditions | Use explicit waits, not `wait_for_timeout` |
---
## Prerequisites
### Python
Scripts include inline dependencies (PEP 723); `uv run` auto-installs them.
### JavaScript
```bash
npm init -y
npm install -D @playwright/test
npx playwright install chromium
```
---
## Running E2E Tests
### JavaScript
```bash
npx playwright test # Run all
npx playwright test --ui # UI mode
npx playwright test --headed # See browser
```
### Python
```bash
pip install pytest-playwright
pytest tests/
```