init
This commit is contained in:
630
.opencode/skills/chrome-devtools/SKILL.md
Normal file
630
.opencode/skills/chrome-devtools/SKILL.md
Normal file
@@ -0,0 +1,630 @@
|
||||
---
|
||||
name: ck:chrome-devtools
|
||||
description: Automate browsers with Puppeteer CLI scripts and persistent sessions. Use for screenshots, performance analysis, network monitoring, web scraping, form automation, JavaScript debugging.
|
||||
license: Apache-2.0
|
||||
argument-hint: "[url or task]"
|
||||
metadata:
|
||||
author: claudekit
|
||||
version: "1.1.0"
|
||||
---
|
||||
|
||||
# Chrome DevTools Agent Skill
|
||||
|
||||
Browser automation via Puppeteer scripts with persistent sessions. All scripts output JSON.
|
||||
|
||||
## Skill Location
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
|
||||
```bash
|
||||
# Detect skill location (no cd needed - scripts use __dirname for paths)
|
||||
SKILL_DIR=""
|
||||
if [ -d ".opencode/skills/chrome-devtools/scripts" ]; then
|
||||
SKILL_DIR=".opencode/skills/chrome-devtools/scripts"
|
||||
elif [ -d "$HOME/.opencode/skills/chrome-devtools/scripts" ]; then
|
||||
SKILL_DIR="$HOME/.opencode/skills/chrome-devtools/scripts"
|
||||
fi
|
||||
# Run scripts with full path: node "$SKILL_DIR/script.js" --args
|
||||
```
|
||||
|
||||
## Choosing Your Approach
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| **Source-available sites** | Read source code first, write selectors directly |
|
||||
| **Unknown layouts** | Use `aria-snapshot.js` for semantic discovery |
|
||||
| **Visual inspection** | Take screenshots to verify rendering |
|
||||
| **Debug issues** | Collect console logs, analyze with session storage |
|
||||
| **Accessibility audit** | Use ARIA snapshot for semantic structure analysis |
|
||||
|
||||
## Automation Browsing Running Mode
|
||||
|
||||
Browser visibility is resolved automatically by `resolveHeadless()` in `lib/browser.js`:
|
||||
|
||||
| Environment | Default | Why |
|
||||
|-------------|---------|-----|
|
||||
| **macOS / Windows** | **Headed** (visible) | Better debugging, OAuth login support |
|
||||
| **Linux / WSL** | **Headless** | Servers typically have no display |
|
||||
| **CI** (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, `JENKINS_URL` env vars) | **Headless** | No display available |
|
||||
|
||||
Override with `--headless true` or `--headless false` on any script.
|
||||
|
||||
- Run multiple scripts/sessions in parallel to simulate real user interactions.
|
||||
- Run multiple scripts/sessions in parallel to simulate different device types (mobile, tablet, desktop).
|
||||
|
||||
## ARIA Snapshot (Element Discovery)
|
||||
|
||||
When page structure is unknown, use `aria-snapshot.js` to get a YAML-formatted accessibility tree with semantic roles, accessible names, states, and stable element references.
|
||||
|
||||
### Get ARIA Snapshot
|
||||
|
||||
```bash
|
||||
# Generate ARIA snapshot and output to stdout
|
||||
node "$SKILL_DIR/aria-snapshot.js" --url https://example.com
|
||||
|
||||
# Save to file in snapshots directory
|
||||
node "$SKILL_DIR/aria-snapshot.js" --url https://example.com --output ./.opencode/chrome-devtools/snapshots/page.yaml
|
||||
```
|
||||
|
||||
### Example YAML Output
|
||||
|
||||
```yaml
|
||||
- banner:
|
||||
- link "Hacker News" [ref=e1]
|
||||
/url: https://news.ycombinator.com
|
||||
- navigation:
|
||||
- link "new" [ref=e2]
|
||||
- link "past" [ref=e3]
|
||||
- link "comments" [ref=e4]
|
||||
- main:
|
||||
- list:
|
||||
- listitem:
|
||||
- link "Show HN: My new project" [ref=e8]
|
||||
- text: "128 points by user 3 hours ago"
|
||||
- contentinfo:
|
||||
- textbox [ref=e10]
|
||||
/placeholder: "Search"
|
||||
```
|
||||
|
||||
### Interpreting ARIA Notation
|
||||
|
||||
| Notation | Meaning |
|
||||
|----------|---------|
|
||||
| `[ref=eN]` | Stable identifier for interactive elements |
|
||||
| `[checked]` | Checkbox/radio is selected |
|
||||
| `[disabled]` | Element is inactive |
|
||||
| `[expanded]` | Accordion/dropdown is open |
|
||||
| `[level=N]` | Heading hierarchy (1-6) |
|
||||
| `/url:` | Link destination |
|
||||
| `/placeholder:` | Input placeholder text |
|
||||
| `/value:` | Current input value |
|
||||
|
||||
### Interact by Ref
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
Use `select-ref.js` to interact with elements by their ref:
|
||||
|
||||
```bash
|
||||
# Click element with ref e5
|
||||
node "$SKILL_DIR/select-ref.js" --ref e5 --action click
|
||||
|
||||
# Fill input with ref e10
|
||||
node "$SKILL_DIR/select-ref.js" --ref e10 --action fill --value "search query"
|
||||
|
||||
# Get text content
|
||||
node "$SKILL_DIR/select-ref.js" --ref e8 --action text
|
||||
|
||||
# Screenshot specific element
|
||||
node "$SKILL_DIR/select-ref.js" --ref e1 --action screenshot --output ./logo.png
|
||||
|
||||
# Focus element
|
||||
node "$SKILL_DIR/select-ref.js" --ref e10 --action focus
|
||||
|
||||
# Hover over element
|
||||
node "$SKILL_DIR/select-ref.js" --ref e5 --action hover
|
||||
```
|
||||
|
||||
### Store Snapshots
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
Store snapshots for analysis in `<project>/.opencode/chrome-devtools/snapshots/`:
|
||||
|
||||
```bash
|
||||
# Create snapshots directory
|
||||
mkdir -p .opencode/chrome-devtools/snapshots
|
||||
|
||||
# Capture and store with timestamp
|
||||
SESSION="$(date +%Y%m%d-%H%M%S)"
|
||||
node "$SKILL_DIR/aria-snapshot.js" --url https://example.com --output .opencode/chrome-devtools/snapshots/$SESSION.yaml
|
||||
```
|
||||
|
||||
### Workflow: Unknown Page Structure
|
||||
|
||||
1. **Get snapshot** to discover elements:
|
||||
```bash
|
||||
node "$SKILL_DIR/aria-snapshot.js" --url https://example.com
|
||||
```
|
||||
|
||||
2. **Identify target** from YAML output (e.g., `[ref=e5]` for a button)
|
||||
|
||||
3. **Interact by ref**:
|
||||
```bash
|
||||
node "$SKILL_DIR/select-ref.js" --ref e5 --action click
|
||||
```
|
||||
|
||||
4. **Verify result** with screenshot or new snapshot:
|
||||
```bash
|
||||
node "$SKILL_DIR/screenshot.js" --output ./result.png
|
||||
```
|
||||
|
||||
## Local HTML Files
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
**IMPORTANT**: Never browse local HTML files via `file://` protocol. Always serve via local server:
|
||||
**Why**: `file://` protocol blocks many browser features (CORS, ES modules, fetch API, service workers). Local server ensures proper HTTP behavior.
|
||||
|
||||
```bash
|
||||
# Option 1: npx serve (recommended)
|
||||
npx serve ./dist -p 3000 &
|
||||
node "$SKILL_DIR/navigate.js" --url http://localhost:3000
|
||||
|
||||
# Option 2: Python http.server
|
||||
python -m http.server 3000 --directory ./dist &
|
||||
node "$SKILL_DIR/navigate.js" --url http://localhost:3000
|
||||
```
|
||||
|
||||
**Note**: when port 3000 is busy, find an available port with `lsof -i :3000` and use a different one.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install dependencies (one-time setup)
|
||||
npm install --prefix "$SKILL_DIR"
|
||||
|
||||
# Test (browser stays running for session reuse)
|
||||
node "$SKILL_DIR/navigate.js" --url https://example.com
|
||||
# Output: {"success": true, "url": "...", "title": "..."}
|
||||
```
|
||||
|
||||
**Linux/WSL only**: Run `"$SKILL_DIR/install-deps.sh"` first for Chrome system libraries.
|
||||
|
||||
## Session Persistence
|
||||
|
||||
Browser state persists across script executions via WebSocket endpoint file (`.browser-session.json`).
|
||||
|
||||
**Default behavior**: Scripts disconnect but keep browser running for session reuse.
|
||||
|
||||
```bash
|
||||
# First script: launches browser, navigates, disconnects (browser stays running)
|
||||
node "$SKILL_DIR/navigate.js" --url https://example.com/login
|
||||
|
||||
# Subsequent scripts: connect to existing browser, reuse page state
|
||||
node "$SKILL_DIR/fill.js" --selector "#email" --value "user@example.com"
|
||||
node "$SKILL_DIR/fill.js" --selector "#password" --value "secret"
|
||||
node "$SKILL_DIR/click.js" --selector "button[type=submit]"
|
||||
|
||||
# Close browser when done
|
||||
node "$SKILL_DIR/navigate.js" --url about:blank --close true
|
||||
```
|
||||
|
||||
**Session management**:
|
||||
- `--close true`: Close browser and clear session
|
||||
- Default (no flag): Keep browser running for next script
|
||||
|
||||
## Available Scripts
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
All in `.opencode/skills/chrome-devtools/scripts/`:
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `navigate.js` | Navigate to URLs |
|
||||
| `screenshot.js` | Capture screenshots (auto-compress >5MB via Sharp) |
|
||||
| `click.js` | Click elements |
|
||||
| `fill.js` | Fill form fields |
|
||||
| `evaluate.js` | Execute JS in page context |
|
||||
| `snapshot.js` | Extract interactive elements (JSON format) |
|
||||
| `aria-snapshot.js` | Get ARIA accessibility tree (YAML format with refs) |
|
||||
| `select-ref.js` | Interact with elements by ref from ARIA snapshot |
|
||||
| `console.js` | Monitor console messages/errors |
|
||||
| `network.js` | Track HTTP requests/responses |
|
||||
| `performance.js` | Measure Core Web Vitals |
|
||||
| `ws-debug.js` | Debug WebSocket connections (basic) |
|
||||
| `ws-full-debug.js` | Debug WebSocket with full events/frames |
|
||||
| `inject-auth.js` | Inject cookies/tokens for authentication |
|
||||
| `import-cookies.js` | Import cookies from JSON/Netscape file |
|
||||
| `connect-chrome.js` | Connect to Chrome with remote debugging |
|
||||
|
||||
## Workflow Loop
|
||||
|
||||
1. **Execute** focused script for single task
|
||||
2. **Observe** JSON output
|
||||
3. **Assess** completion status
|
||||
4. **Decide** next action
|
||||
5. **Repeat** until done
|
||||
|
||||
## Writing Custom Test Scripts
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
For complex automation, write scripts to `<project>/.opencode/chrome-devtools/tmp/`:
|
||||
|
||||
```bash
|
||||
# Create tmp directory for test scripts
|
||||
mkdir -p $SKILL_DIR/.opencode/chrome-devtools/tmp
|
||||
|
||||
# Write a test script
|
||||
cat > $SKILL_DIR/.opencode/chrome-devtools/tmp/login-test.js << 'EOF'
|
||||
import { getBrowser, getPage, disconnectBrowser, outputJSON } from '../scripts/lib/browser.js';
|
||||
|
||||
async function loginTest() {
|
||||
const browser = await getBrowser();
|
||||
const page = await getPage(browser);
|
||||
|
||||
await page.goto('https://example.com/login');
|
||||
await page.type('#email', 'user@example.com');
|
||||
await page.type('#password', 'secret');
|
||||
await page.click('button[type=submit]');
|
||||
await page.waitForNavigation();
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
url: page.url(),
|
||||
title: await page.title()
|
||||
});
|
||||
|
||||
await disconnectBrowser();
|
||||
}
|
||||
|
||||
loginTest();
|
||||
EOF
|
||||
|
||||
# Run the test
|
||||
node $SKILL_DIR/.opencode/chrome-devtools/tmp/login-test.js
|
||||
```
|
||||
|
||||
**Key principles for custom scripts**:
|
||||
- Single-purpose: one script, one task
|
||||
- Always call `disconnectBrowser()` at the end (keeps browser running)
|
||||
- Use `closeBrowser()` only when ending session completely
|
||||
- Output JSON for easy parsing
|
||||
- Plain JavaScript only in `page.evaluate()` callbacks
|
||||
|
||||
## Screenshots
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
|
||||
**IMPORTANT:** Invoke "/ck:project-organization" skill to organize the outputs.
|
||||
|
||||
Store screenshots for analysis in `<project>/.opencode/chrome-devtools/screenshots/`:
|
||||
|
||||
```bash
|
||||
# Basic screenshot
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --output ./.opencode/chrome-devtools/screenshots/page.png
|
||||
|
||||
# Full page
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --output ./.opencode/chrome-devtools/screenshots/page.png --full-page true
|
||||
|
||||
# Specific element
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --selector ".main-content" --output ./.opencode/chrome-devtools/screenshots/element.png
|
||||
```
|
||||
|
||||
### Auto-Compression (Sharp)
|
||||
|
||||
Screenshots >5MB auto-compress using Sharp (4-5x faster than ImageMagick):
|
||||
|
||||
```bash
|
||||
# Default: compress if >5MB
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --output ./.opencode/chrome-devtools/screenshots/page.png
|
||||
|
||||
# Custom threshold (3MB)
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --output ./.opencode/chrome-devtools/screenshots/page.png --max-size 3
|
||||
|
||||
# Disable compression
|
||||
node "$SKILL_DIR/screenshot.js" --url https://example.com --output ./.opencode/chrome-devtools/screenshots/page.png --no-compress
|
||||
```
|
||||
|
||||
Store screenshots for analysis in `<project>/.opencode/chrome-devtools/screenshots/`.
|
||||
|
||||
## Console Log Collection & Analysis
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
|
||||
### Capture Logs
|
||||
|
||||
```bash
|
||||
# Capture all logs for 10 seconds
|
||||
node "$SKILL_DIR/console.js" --url https://example.com --duration 10000
|
||||
|
||||
# Filter by type
|
||||
node "$SKILL_DIR/console.js" --url https://example.com --types error,warn --duration 5000
|
||||
```
|
||||
|
||||
### Session Storage Pattern
|
||||
|
||||
Store logs for analysis in `<project>/.opencode/chrome-devtools/logs/<session>/`:
|
||||
|
||||
```bash
|
||||
# Create session directory
|
||||
SESSION="$(date +%Y%m%d-%H%M%S)"
|
||||
mkdir -p .opencode/chrome-devtools/logs/$SESSION
|
||||
|
||||
# Capture and store
|
||||
node "$SKILL_DIR/console.js" --url https://example.com --duration 10000 > .opencode/chrome-devtools/logs/$SESSION/console.json
|
||||
node "$SKILL_DIR/network.js" --url https://example.com > .opencode/chrome-devtools/logs/$SESSION/network.json
|
||||
|
||||
# View errors
|
||||
jq '.messages[] | select(.type=="error")' .opencode/chrome-devtools/logs/$SESSION/console.json
|
||||
```
|
||||
|
||||
### Root Cause Analysis
|
||||
|
||||
```bash
|
||||
# 1. Check for JavaScript errors
|
||||
node "$SKILL_DIR/console.js" --url https://example.com --types error,pageerror --duration 5000 | jq '.messages'
|
||||
|
||||
# 2. Correlate with network failures
|
||||
node "$SKILL_DIR/network.js" --url https://example.com | jq '.requests[] | select(.response.status >= 400)'
|
||||
|
||||
# 3. Check specific error stack traces
|
||||
node "$SKILL_DIR/console.js" --url https://example.com --types error --duration 5000 | jq '.messages[].stack'
|
||||
```
|
||||
|
||||
## Finding Elements
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
Use `snapshot.js` to discover selectors before interacting:
|
||||
|
||||
```bash
|
||||
# Get all interactive elements
|
||||
node "$SKILL_DIR/snapshot.js" --url https://example.com | jq '.elements[] | {tagName, text, selector}'
|
||||
|
||||
# Find buttons
|
||||
node "$SKILL_DIR/snapshot.js" --url https://example.com | jq '.elements[] | select(.tagName=="button")'
|
||||
|
||||
# Find by text content
|
||||
node "$SKILL_DIR/snapshot.js" --url https://example.com | jq '.elements[] | select(.text | contains("Submit"))'
|
||||
```
|
||||
|
||||
## Error Recovery
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
If script fails:
|
||||
|
||||
```bash
|
||||
# 1. Capture current state (without navigating to preserve state)
|
||||
node "$SKILL_DIR/screenshot.js" --output ./.opencode/skills/chrome-devtools/screenshots/debug.png
|
||||
|
||||
# 2. Get console errors
|
||||
node "$SKILL_DIR/console.js" --url about:blank --types error --duration 1000
|
||||
|
||||
# 3. Discover correct selector
|
||||
node "$SKILL_DIR/snapshot.js" | jq '.elements[] | select(.text | contains("Submit"))'
|
||||
|
||||
# 4. Try XPath if CSS fails
|
||||
node "$SKILL_DIR/click.js" --selector "//button[contains(text(),'Submit')]"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Web Scraping
|
||||
```bash
|
||||
node "$SKILL_DIR/evaluate.js" --url https://example.com --script "
|
||||
Array.from(document.querySelectorAll('.item')).map(el => ({
|
||||
title: el.querySelector('h2')?.textContent,
|
||||
link: el.querySelector('a')?.href
|
||||
}))
|
||||
" | jq '.result'
|
||||
```
|
||||
|
||||
### Form Automation
|
||||
```bash
|
||||
node "$SKILL_DIR/navigate.js" --url https://example.com/form
|
||||
node "$SKILL_DIR/fill.js" --selector "#search" --value "query"
|
||||
node "$SKILL_DIR/click.js" --selector "button[type=submit]"
|
||||
```
|
||||
|
||||
### Performance Testing
|
||||
```bash
|
||||
node "$SKILL_DIR/performance.js" --url https://example.com | jq '.vitals'
|
||||
```
|
||||
|
||||
## Script Options
|
||||
|
||||
All scripts support:
|
||||
- `--headless true/false` - Override auto-detected headless mode (default: auto by OS)
|
||||
- `--close true` - Close browser completely (default: stay running)
|
||||
- `--timeout 30000` - Set timeout (ms)
|
||||
- `--wait-until networkidle2` - Wait strategy
|
||||
|
||||
`navigate.js` additionally supports:
|
||||
- `--wait-for-login <pattern>` - Interactive login: open headed, wait for URL regex match
|
||||
- `--login-timeout <ms>` - Max wait for login completion (default: 300000 = 5 min)
|
||||
|
||||
## Troubleshooting
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
|
||||
| Error | Solution |
|
||||
|-------|----------|
|
||||
| `Cannot find package 'puppeteer'` | Run `npm install` in scripts directory |
|
||||
| `libnss3.so` missing (Linux) | Run `./install-deps.sh` |
|
||||
| Element not found | Use `snapshot.js` to find correct selector |
|
||||
| Script hangs | Use `--timeout 60000` or `--wait-until load` |
|
||||
| Screenshot >5MB | Auto-compressed; use `--max-size 3` for lower |
|
||||
| Session stale | Delete `.browser-session.json` and retry |
|
||||
|
||||
### Screenshot Analysis: Missing Images
|
||||
|
||||
If images don't appear in screenshots, they may be waiting for animation triggers:
|
||||
|
||||
1. **Scroll-triggered animations**: Scroll element into view first
|
||||
```bash
|
||||
node "$SKILL_DIR/evaluate.js" --script "document.querySelector('.lazy-image').scrollIntoView()"
|
||||
# Wait for animation
|
||||
node "$SKILL_DIR/evaluate.js" --script "await new Promise(r => setTimeout(r, 1000))"
|
||||
node "$SKILL_DIR/screenshot.js" --output ./result.png
|
||||
```
|
||||
|
||||
2. **Sequential animation queue**: Wait longer and retry
|
||||
```bash
|
||||
# First attempt
|
||||
node "$SKILL_DIR/screenshot.js" --url http://localhost:3000 --output ./attempt1.png
|
||||
|
||||
# Wait for animations to complete
|
||||
node "$SKILL_DIR/evaluate.js" --script "await new Promise(r => setTimeout(r, 2000))"
|
||||
|
||||
# Retry screenshot
|
||||
node "$SKILL_DIR/screenshot.js" --output ./attempt2.png
|
||||
```
|
||||
|
||||
3. **Intersection Observer animations**: Trigger by scrolling through page
|
||||
```bash
|
||||
node "$SKILL_DIR/evaluate.js" --script "window.scrollTo(0, document.body.scrollHeight)"
|
||||
node "$SKILL_DIR/evaluate.js" --script "await new Promise(r => setTimeout(r, 1500))"
|
||||
node "$SKILL_DIR/evaluate.js" --script "window.scrollTo(0, 0)"
|
||||
node "$SKILL_DIR/screenshot.js" --output ./full-loaded.png --full-page true
|
||||
```
|
||||
|
||||
## Authentication & Cookies
|
||||
|
||||
For accessing protected/authenticated pages, use one of these methods:
|
||||
|
||||
### Method 1: Inject Cookies Directly
|
||||
|
||||
Use when you have cookie values (from DevTools or manual extraction):
|
||||
|
||||
```bash
|
||||
# Inject single cookie
|
||||
node "$SKILL_DIR/inject-auth.js" --url https://site.com \
|
||||
--cookies '[{"name":"session","value":"abc123","domain":".site.com"}]'
|
||||
|
||||
# Multiple cookies with all properties
|
||||
node "$SKILL_DIR/inject-auth.js" --url https://site.com \
|
||||
--cookies '[{"name":"session","value":"abc","domain":".site.com","httpOnly":true,"secure":true}]'
|
||||
|
||||
# With Bearer token header
|
||||
node "$SKILL_DIR/inject-auth.js" --url https://api.site.com \
|
||||
--token "Bearer eyJhbG..." --header Authorization
|
||||
```
|
||||
|
||||
### Method 2: Import from Browser Extension
|
||||
|
||||
Best for complex auth (OAuth, multi-cookie sessions):
|
||||
|
||||
```bash
|
||||
# 1. Install "Cookie-Editor" or "EditThisCookie" Chrome extension
|
||||
# 2. Navigate to site → Log in manually
|
||||
# 3. Click extension → Export as JSON → Save to cookies.json
|
||||
# 4. Import into puppeteer session:
|
||||
|
||||
node "$SKILL_DIR/import-cookies.js" --file ./cookies.json --url https://site.com
|
||||
|
||||
# Netscape format (from curl/wget):
|
||||
node "$SKILL_DIR/import-cookies.js" --file ./cookies.txt --format netscape --url https://site.com
|
||||
|
||||
# Only import cookies matching target domain:
|
||||
node "$SKILL_DIR/import-cookies.js" --file ./cookies.json --url https://site.com --strict-domain
|
||||
```
|
||||
|
||||
### Method 3: Use Your Chrome Profile
|
||||
|
||||
Most reliable for complex auth (2FA, OAuth, SSO). Uses your existing Chrome session:
|
||||
|
||||
```bash
|
||||
# Use Chrome's default profile (preserves all cookies, extensions, saved passwords)
|
||||
node "$SKILL_DIR/navigate.js" --url https://site.com --use-default-profile true
|
||||
|
||||
# Use specific Chrome profile directory
|
||||
node "$SKILL_DIR/navigate.js" --url https://site.com --profile "/path/to/chrome/profile"
|
||||
```
|
||||
|
||||
**[!] Important**: Chrome must be fully closed when using its profile (single instance lock).
|
||||
|
||||
**Profile paths by OS:**
|
||||
- **macOS**: `~/Library/Application Support/Google/Chrome`
|
||||
- **Windows**: `%LOCALAPPDATA%/Google/Chrome/User Data`
|
||||
- **Linux**: `~/.config/google-chrome`
|
||||
|
||||
### Method 4: Connect to Running Chrome
|
||||
|
||||
Best for debugging (can see browser window while scripts run):
|
||||
|
||||
```bash
|
||||
# Step 1: Launch Chrome with remote debugging (in separate terminal)
|
||||
# macOS:
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --remote-debugging-port=9222
|
||||
|
||||
# Windows:
|
||||
"C:\Program Files\Google\Chrome\Application\chrome.exe" --remote-debugging-port=9222
|
||||
|
||||
# Linux:
|
||||
google-chrome --remote-debugging-port=9222
|
||||
|
||||
# Step 2: Log in manually in the Chrome window
|
||||
|
||||
# Step 3: Connect and automate
|
||||
node "$SKILL_DIR/connect-chrome.js" --browser-url http://localhost:9222 --url https://site.com
|
||||
|
||||
# Or launch Chrome automatically (opens new window):
|
||||
node "$SKILL_DIR/connect-chrome.js" --launch --port 9222 --url https://site.com
|
||||
```
|
||||
|
||||
### Method 5: Interactive Login (OAuth/SSO)
|
||||
|
||||
Best for OAuth, SSO, or any login requiring manual interaction in the browser:
|
||||
|
||||
```bash
|
||||
# Open browser at login page, wait for redirect to dashboard after OAuth
|
||||
node "$SKILL_DIR/navigate.js" --url https://app.example.com/login \
|
||||
--wait-for-login "/dashboard"
|
||||
|
||||
# With longer timeout (10 min) for slow SSO providers
|
||||
node "$SKILL_DIR/navigate.js" --url https://app.example.com/login \
|
||||
--wait-for-login "/dashboard" --login-timeout 600000
|
||||
|
||||
# Use regex for complex URL patterns
|
||||
node "$SKILL_DIR/navigate.js" --url https://app.example.com/login \
|
||||
--wait-for-login "/(dashboard|home|app)"
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
1. Opens browser in **headed mode** (always, regardless of OS)
|
||||
2. Navigates to the login URL
|
||||
3. Waits for you to complete the login flow manually (OAuth, 2FA, etc.)
|
||||
4. Detects success when URL matches the regex pattern
|
||||
5. Saves all cookies to `.auth-session.json` for 24-hour reuse
|
||||
6. Subsequent scripts reuse the authenticated session automatically
|
||||
|
||||
### Session Persistence
|
||||
|
||||
Auth sessions are saved to `.auth-session.json` for 24-hour reuse:
|
||||
|
||||
```bash
|
||||
# First script injects auth
|
||||
node "$SKILL_DIR/inject-auth.js" --url https://site.com --cookies '[...]'
|
||||
|
||||
# Subsequent scripts reuse saved auth automatically
|
||||
node "$SKILL_DIR/navigate.js" --url https://site.com/dashboard
|
||||
node "$SKILL_DIR/screenshot.js" --url https://site.com/profile --output ./profile.png
|
||||
|
||||
# Clear auth session when done
|
||||
node "$SKILL_DIR/inject-auth.js" --url https://site.com --clear true
|
||||
```
|
||||
|
||||
### Choosing the Right Method
|
||||
|
||||
| Method | Best For | Complexity |
|
||||
|--------|----------|------------|
|
||||
| Inject cookies | Simple session cookies, API tokens | Low |
|
||||
| Import from extension | Multi-cookie auth, OAuth tokens | Medium |
|
||||
| Chrome profile | 2FA, SSO, complex OAuth flows | Low* |
|
||||
| Connect to Chrome | Debugging, visual verification | Medium |
|
||||
| Interactive login | OAuth/SSO with manual browser interaction | Low |
|
||||
|
||||
*Requires Chrome to be closed first
|
||||
|
||||
## Reference Documentation
|
||||
|
||||
- `./references/cdp-domains.md` - Chrome DevTools Protocol domains
|
||||
- `./references/puppeteer-reference.md` - Puppeteer API patterns
|
||||
- `./references/performance-guide.md` - Core Web Vitals optimization
|
||||
- `./scripts/README.md` - Detailed script options
|
||||
694
.opencode/skills/chrome-devtools/references/cdp-domains.md
Normal file
694
.opencode/skills/chrome-devtools/references/cdp-domains.md
Normal file
@@ -0,0 +1,694 @@
|
||||
# Chrome DevTools Protocol (CDP) Domains Reference
|
||||
|
||||
Complete reference of CDP domains and their capabilities for browser automation and debugging.
|
||||
|
||||
## Overview
|
||||
|
||||
CDP is organized into **47 domains**, each providing specific browser capabilities. Domains are grouped by functionality:
|
||||
|
||||
- **Core** - Fundamental browser control
|
||||
- **DOM & Styling** - Page structure and styling
|
||||
- **Network & Fetch** - HTTP traffic management
|
||||
- **Page & Navigation** - Page lifecycle control
|
||||
- **Storage & Data** - Browser storage APIs
|
||||
- **Performance & Profiling** - Metrics and analysis
|
||||
- **Emulation & Simulation** - Device and network emulation
|
||||
- **Worker & Service** - Background tasks
|
||||
- **Developer Tools** - Debugging support
|
||||
|
||||
---
|
||||
|
||||
## Core Domains
|
||||
|
||||
### Runtime
|
||||
**Purpose:** Execute JavaScript, manage objects, handle promises
|
||||
|
||||
**Key Commands:**
|
||||
- `Runtime.evaluate(expression)` - Execute JavaScript
|
||||
- `Runtime.callFunctionOn(functionDeclaration, objectId)` - Call function on object
|
||||
- `Runtime.getProperties(objectId)` - Get object properties
|
||||
- `Runtime.awaitPromise(promiseObjectId)` - Wait for promise resolution
|
||||
|
||||
**Key Events:**
|
||||
- `Runtime.consoleAPICalled` - Console message logged
|
||||
- `Runtime.exceptionThrown` - Uncaught exception
|
||||
|
||||
**Use Cases:**
|
||||
- Execute custom JavaScript
|
||||
- Access page data
|
||||
- Monitor console output
|
||||
- Handle exceptions
|
||||
|
||||
---
|
||||
|
||||
### Debugger
|
||||
**Purpose:** JavaScript debugging, breakpoints, stack traces
|
||||
|
||||
**Key Commands:**
|
||||
- `Debugger.enable()` - Enable debugger
|
||||
- `Debugger.setBreakpoint(location)` - Set breakpoint
|
||||
- `Debugger.pause()` - Pause execution
|
||||
- `Debugger.resume()` - Resume execution
|
||||
- `Debugger.stepOver/stepInto/stepOut()` - Step through code
|
||||
|
||||
**Key Events:**
|
||||
- `Debugger.paused` - Execution paused
|
||||
- `Debugger.resumed` - Execution resumed
|
||||
- `Debugger.scriptParsed` - Script loaded
|
||||
|
||||
**Use Cases:**
|
||||
- Debug JavaScript errors
|
||||
- Inspect call stacks
|
||||
- Set conditional breakpoints
|
||||
- Source map support
|
||||
|
||||
---
|
||||
|
||||
### Console (Deprecated - Use Runtime/Log)
|
||||
**Purpose:** Legacy console message access
|
||||
|
||||
**Note:** Use `Runtime.consoleAPICalled` event instead for new implementations.
|
||||
|
||||
---
|
||||
|
||||
## DOM & Styling Domains
|
||||
|
||||
### DOM
|
||||
**Purpose:** Access and manipulate DOM tree
|
||||
|
||||
**Key Commands:**
|
||||
- `DOM.getDocument()` - Get root document node
|
||||
- `DOM.querySelector(nodeId, selector)` - Query selector
|
||||
- `DOM.querySelectorAll(nodeId, selector)` - Query all
|
||||
- `DOM.getAttributes(nodeId)` - Get element attributes
|
||||
- `DOM.setOuterHTML(nodeId, outerHTML)` - Replace element
|
||||
- `DOM.getBoxModel(nodeId)` - Get element layout box
|
||||
- `DOM.focus(nodeId)` - Focus element
|
||||
|
||||
**Key Events:**
|
||||
- `DOM.documentUpdated` - Document changed
|
||||
- `DOM.setChildNodes` - Child nodes updated
|
||||
|
||||
**Use Cases:**
|
||||
- Navigate DOM tree
|
||||
- Query elements
|
||||
- Modify DOM structure
|
||||
- Get element positions
|
||||
|
||||
---
|
||||
|
||||
### CSS
|
||||
**Purpose:** Inspect and modify CSS styles
|
||||
|
||||
**Key Commands:**
|
||||
- `CSS.enable()` - Enable CSS domain
|
||||
- `CSS.getComputedStyleForNode(nodeId)` - Get computed styles
|
||||
- `CSS.getInlineStylesForNode(nodeId)` - Get inline styles
|
||||
- `CSS.getMatchedStylesForNode(nodeId)` - Get matched CSS rules
|
||||
- `CSS.setStyleTexts(edits)` - Modify styles
|
||||
|
||||
**Key Events:**
|
||||
- `CSS.styleSheetAdded` - Stylesheet added
|
||||
- `CSS.styleSheetChanged` - Stylesheet modified
|
||||
|
||||
**Use Cases:**
|
||||
- Inspect element styles
|
||||
- Debug CSS issues
|
||||
- Modify styles dynamically
|
||||
- Extract stylesheet data
|
||||
|
||||
---
|
||||
|
||||
### Accessibility
|
||||
**Purpose:** Access accessibility tree
|
||||
|
||||
**Key Commands:**
|
||||
- `Accessibility.enable()` - Enable accessibility
|
||||
- `Accessibility.getFullAXTree()` - Get complete AX tree
|
||||
- `Accessibility.getPartialAXTree(nodeId)` - Get node subtree
|
||||
- `Accessibility.queryAXTree(nodeId, role, name)` - Query AX tree
|
||||
|
||||
**Use Cases:**
|
||||
- Accessibility testing
|
||||
- Screen reader simulation
|
||||
- ARIA attribute inspection
|
||||
- AX tree analysis
|
||||
|
||||
---
|
||||
|
||||
## Network & Fetch Domains
|
||||
|
||||
### Network
|
||||
**Purpose:** Monitor and control HTTP traffic
|
||||
|
||||
**Key Commands:**
|
||||
- `Network.enable()` - Enable network tracking
|
||||
- `Network.setCacheDisabled(cacheDisabled)` - Disable cache
|
||||
- `Network.setExtraHTTPHeaders(headers)` - Add custom headers
|
||||
- `Network.getCookies(urls)` - Get cookies
|
||||
- `Network.setCookie(name, value, domain)` - Set cookie
|
||||
- `Network.getResponseBody(requestId)` - Get response body
|
||||
- `Network.emulateNetworkConditions(offline, latency, downloadThroughput, uploadThroughput)` - Throttle network
|
||||
|
||||
**Key Events:**
|
||||
- `Network.requestWillBeSent` - Request starting
|
||||
- `Network.responseReceived` - Response received
|
||||
- `Network.loadingFinished` - Request completed
|
||||
- `Network.loadingFailed` - Request failed
|
||||
|
||||
**Use Cases:**
|
||||
- Monitor API calls
|
||||
- Intercept requests
|
||||
- Analyze response data
|
||||
- Simulate slow networks
|
||||
- Manage cookies
|
||||
|
||||
---
|
||||
|
||||
### Fetch
|
||||
**Purpose:** Intercept and modify network requests
|
||||
|
||||
**Key Commands:**
|
||||
- `Fetch.enable(patterns)` - Enable request interception
|
||||
- `Fetch.continueRequest(requestId, url, method, headers)` - Continue/modify request
|
||||
- `Fetch.fulfillRequest(requestId, responseCode, headers, body)` - Mock response
|
||||
- `Fetch.failRequest(requestId, errorReason)` - Fail request
|
||||
|
||||
**Key Events:**
|
||||
- `Fetch.requestPaused` - Request intercepted
|
||||
|
||||
**Use Cases:**
|
||||
- Mock API responses
|
||||
- Block requests
|
||||
- Modify request/response
|
||||
- Test error scenarios
|
||||
|
||||
---
|
||||
|
||||
## Page & Navigation Domains
|
||||
|
||||
### Page
|
||||
**Purpose:** Control page lifecycle and navigation
|
||||
|
||||
**Key Commands:**
|
||||
- `Page.enable()` - Enable page domain
|
||||
- `Page.navigate(url)` - Navigate to URL
|
||||
- `Page.reload(ignoreCache)` - Reload page
|
||||
- `Page.goBack()/goForward()` - Navigate history
|
||||
- `Page.captureScreenshot(format, quality)` - Take screenshot
|
||||
- `Page.printToPDF(landscape, displayHeaderFooter)` - Generate PDF
|
||||
- `Page.getLayoutMetrics()` - Get page dimensions
|
||||
- `Page.createIsolatedWorld(frameId)` - Create isolated context
|
||||
- `Page.handleJavaScriptDialog(accept, promptText)` - Handle alerts/confirms
|
||||
|
||||
**Key Events:**
|
||||
- `Page.loadEventFired` - Page loaded
|
||||
- `Page.domContentEventFired` - DOM ready
|
||||
- `Page.frameNavigated` - Frame navigated
|
||||
- `Page.javascriptDialogOpening` - Alert/confirm shown
|
||||
|
||||
**Use Cases:**
|
||||
- Navigate pages
|
||||
- Capture screenshots
|
||||
- Generate PDFs
|
||||
- Handle popups
|
||||
- Monitor page lifecycle
|
||||
|
||||
---
|
||||
|
||||
### Target
|
||||
**Purpose:** Manage browser targets (tabs, workers, frames)
|
||||
|
||||
**Key Commands:**
|
||||
- `Target.getTargets()` - List all targets
|
||||
- `Target.createTarget(url)` - Open new tab
|
||||
- `Target.closeTarget(targetId)` - Close tab
|
||||
- `Target.attachToTarget(targetId)` - Attach debugger
|
||||
- `Target.detachFromTarget(sessionId)` - Detach debugger
|
||||
- `Target.setDiscoverTargets(discover)` - Auto-discover targets
|
||||
|
||||
**Key Events:**
|
||||
- `Target.targetCreated` - New target created
|
||||
- `Target.targetDestroyed` - Target closed
|
||||
- `Target.targetInfoChanged` - Target updated
|
||||
|
||||
**Use Cases:**
|
||||
- Multi-tab automation
|
||||
- Service worker debugging
|
||||
- Frame inspection
|
||||
- Extension debugging
|
||||
|
||||
---
|
||||
|
||||
### Input
|
||||
**Purpose:** Simulate user input
|
||||
|
||||
**Key Commands:**
|
||||
- `Input.dispatchKeyEvent(type, key, code)` - Keyboard input
|
||||
- `Input.dispatchMouseEvent(type, x, y, button)` - Mouse input
|
||||
- `Input.dispatchTouchEvent(type, touchPoints)` - Touch input
|
||||
- `Input.synthesizePinchGesture(x, y, scaleFactor)` - Pinch gesture
|
||||
- `Input.synthesizeScrollGesture(x, y, xDistance, yDistance)` - Scroll
|
||||
|
||||
**Use Cases:**
|
||||
- Simulate clicks
|
||||
- Type text
|
||||
- Drag and drop
|
||||
- Touch gestures
|
||||
- Scroll pages
|
||||
|
||||
---
|
||||
|
||||
## Storage & Data Domains
|
||||
|
||||
### Storage
|
||||
**Purpose:** Manage browser storage
|
||||
|
||||
**Key Commands:**
|
||||
- `Storage.getCookies(browserContextId)` - Get cookies
|
||||
- `Storage.setCookies(cookies)` - Set cookies
|
||||
- `Storage.clearCookies(browserContextId)` - Clear cookies
|
||||
- `Storage.clearDataForOrigin(origin, storageTypes)` - Clear storage
|
||||
- `Storage.getUsageAndQuota(origin)` - Get storage usage
|
||||
|
||||
**Storage Types:**
|
||||
- appcache, cookies, file_systems, indexeddb, local_storage, shader_cache, websql, service_workers, cache_storage
|
||||
|
||||
**Use Cases:**
|
||||
- Cookie management
|
||||
- Clear browser data
|
||||
- Inspect storage usage
|
||||
- Test quota limits
|
||||
|
||||
---
|
||||
|
||||
### DOMStorage
|
||||
**Purpose:** Access localStorage/sessionStorage
|
||||
|
||||
**Key Commands:**
|
||||
- `DOMStorage.enable()` - Enable storage tracking
|
||||
- `DOMStorage.getDOMStorageItems(storageId)` - Get items
|
||||
- `DOMStorage.setDOMStorageItem(storageId, key, value)` - Set item
|
||||
- `DOMStorage.removeDOMStorageItem(storageId, key)` - Remove item
|
||||
|
||||
**Key Events:**
|
||||
- `DOMStorage.domStorageItemsCleared` - Storage cleared
|
||||
- `DOMStorage.domStorageItemAdded/Updated/Removed` - Item changed
|
||||
|
||||
---
|
||||
|
||||
### IndexedDB
|
||||
**Purpose:** Query IndexedDB databases
|
||||
|
||||
**Key Commands:**
|
||||
- `IndexedDB.requestDatabaseNames(securityOrigin)` - List databases
|
||||
- `IndexedDB.requestDatabase(securityOrigin, databaseName)` - Get DB structure
|
||||
- `IndexedDB.requestData(securityOrigin, databaseName, objectStoreName)` - Query data
|
||||
|
||||
**Use Cases:**
|
||||
- Inspect IndexedDB data
|
||||
- Debug database issues
|
||||
- Extract stored data
|
||||
|
||||
---
|
||||
|
||||
### CacheStorage
|
||||
**Purpose:** Manage Cache API
|
||||
|
||||
**Key Commands:**
|
||||
- `CacheStorage.requestCacheNames(securityOrigin)` - List caches
|
||||
- `CacheStorage.requestCachedResponses(cacheId, securityOrigin)` - List cached responses
|
||||
- `CacheStorage.deleteCache(cacheId)` - Delete cache
|
||||
|
||||
**Use Cases:**
|
||||
- Service worker cache inspection
|
||||
- Offline functionality testing
|
||||
|
||||
---
|
||||
|
||||
## Performance & Profiling Domains
|
||||
|
||||
### Performance
|
||||
**Purpose:** Collect performance metrics
|
||||
|
||||
**Key Commands:**
|
||||
- `Performance.enable()` - Enable performance tracking
|
||||
- `Performance.disable()` - Disable tracking
|
||||
- `Performance.getMetrics()` - Get current metrics
|
||||
|
||||
**Metrics:**
|
||||
- Timestamp, Documents, Frames, JSEventListeners, Nodes, LayoutCount, RecalcStyleCount, LayoutDuration, RecalcStyleDuration, ScriptDuration, TaskDuration, JSHeapUsedSize, JSHeapTotalSize
|
||||
|
||||
**Use Cases:**
|
||||
- Monitor page metrics
|
||||
- Track memory usage
|
||||
- Measure render times
|
||||
|
||||
---
|
||||
|
||||
### PerformanceTimeline
|
||||
**Purpose:** Access Performance Timeline API
|
||||
|
||||
**Key Commands:**
|
||||
- `PerformanceTimeline.enable(eventTypes)` - Subscribe to events
|
||||
|
||||
**Event Types:**
|
||||
- mark, measure, navigation, resource, longtask, paint, layout-shift
|
||||
|
||||
**Key Events:**
|
||||
- `PerformanceTimeline.timelineEventAdded` - New performance entry
|
||||
|
||||
---
|
||||
|
||||
### Tracing
|
||||
**Purpose:** Record Chrome trace
|
||||
|
||||
**Key Commands:**
|
||||
- `Tracing.start(categories, options)` - Start recording
|
||||
- `Tracing.end()` - Stop recording
|
||||
- `Tracing.requestMemoryDump()` - Capture memory snapshot
|
||||
|
||||
**Trace Categories:**
|
||||
- blink, cc, devtools, gpu, loading, navigation, rendering, v8, disabled-by-default-*
|
||||
|
||||
**Key Events:**
|
||||
- `Tracing.dataCollected` - Trace chunk received
|
||||
- `Tracing.tracingComplete` - Recording finished
|
||||
|
||||
**Use Cases:**
|
||||
- Deep performance analysis
|
||||
- Frame rendering profiling
|
||||
- CPU flame graphs
|
||||
- Memory profiling
|
||||
|
||||
---
|
||||
|
||||
### Profiler
|
||||
**Purpose:** CPU profiling
|
||||
|
||||
**Key Commands:**
|
||||
- `Profiler.enable()` - Enable profiler
|
||||
- `Profiler.start()` - Start CPU profiling
|
||||
- `Profiler.stop()` - Stop and get profile
|
||||
|
||||
**Use Cases:**
|
||||
- Find CPU bottlenecks
|
||||
- Optimize JavaScript
|
||||
- Generate flame graphs
|
||||
|
||||
---
|
||||
|
||||
### HeapProfiler (via Memory domain)
|
||||
**Purpose:** Memory profiling
|
||||
|
||||
**Key Commands:**
|
||||
- `Memory.getDOMCounters()` - Get DOM object counts
|
||||
- `Memory.prepareForLeakDetection()` - Prepare leak detection
|
||||
- `Memory.forciblyPurgeJavaScriptMemory()` - Force GC
|
||||
- `Memory.setPressureNotificationsSuppressed(suppressed)` - Control memory warnings
|
||||
- `Memory.simulatePressureNotification(level)` - Simulate memory pressure
|
||||
|
||||
**Use Cases:**
|
||||
- Detect memory leaks
|
||||
- Analyze heap snapshots
|
||||
- Monitor object counts
|
||||
|
||||
---
|
||||
|
||||
## Emulation & Simulation Domains
|
||||
|
||||
### Emulation
|
||||
**Purpose:** Emulate device conditions
|
||||
|
||||
**Key Commands:**
|
||||
- `Emulation.setDeviceMetricsOverride(width, height, deviceScaleFactor, mobile)` - Emulate device
|
||||
- `Emulation.setGeolocationOverride(latitude, longitude, accuracy)` - Fake location
|
||||
- `Emulation.setEmulatedMedia(media, features)` - Emulate media type
|
||||
- `Emulation.setTimezoneOverride(timezoneId)` - Override timezone
|
||||
- `Emulation.setLocaleOverride(locale)` - Override language
|
||||
- `Emulation.setUserAgentOverride(userAgent)` - Change user agent
|
||||
|
||||
**Use Cases:**
|
||||
- Mobile device testing
|
||||
- Geolocation testing
|
||||
- Print media emulation
|
||||
- Timezone/locale testing
|
||||
|
||||
---
|
||||
|
||||
### DeviceOrientation
|
||||
**Purpose:** Simulate device orientation
|
||||
|
||||
**Key Commands:**
|
||||
- `DeviceOrientation.setDeviceOrientationOverride(alpha, beta, gamma)` - Set orientation
|
||||
|
||||
**Use Cases:**
|
||||
- Test accelerometer features
|
||||
- Orientation-dependent layouts
|
||||
|
||||
---
|
||||
|
||||
## Worker & Service Domains
|
||||
|
||||
### ServiceWorker
|
||||
**Purpose:** Manage service workers
|
||||
|
||||
**Key Commands:**
|
||||
- `ServiceWorker.enable()` - Enable tracking
|
||||
- `ServiceWorker.unregister(scopeURL)` - Unregister worker
|
||||
- `ServiceWorker.startWorker(scopeURL)` - Start worker
|
||||
- `ServiceWorker.stopWorker(versionId)` - Stop worker
|
||||
- `ServiceWorker.inspectWorker(versionId)` - Debug worker
|
||||
|
||||
**Key Events:**
|
||||
- `ServiceWorker.workerRegistrationUpdated` - Registration changed
|
||||
- `ServiceWorker.workerVersionUpdated` - Version updated
|
||||
|
||||
---
|
||||
|
||||
### WebAuthn
|
||||
**Purpose:** Simulate WebAuthn/FIDO2
|
||||
|
||||
**Key Commands:**
|
||||
- `WebAuthn.enable()` - Enable virtual authenticators
|
||||
- `WebAuthn.addVirtualAuthenticator(options)` - Add virtual device
|
||||
- `WebAuthn.removeVirtualAuthenticator(authenticatorId)` - Remove device
|
||||
- `WebAuthn.addCredential(authenticatorId, credential)` - Add credential
|
||||
|
||||
**Use Cases:**
|
||||
- Test WebAuthn flows
|
||||
- Simulate biometric auth
|
||||
- Test security keys
|
||||
|
||||
---
|
||||
|
||||
## Developer Tools Support
|
||||
|
||||
### Inspector
|
||||
**Purpose:** Protocol-level debugging
|
||||
|
||||
**Key Events:**
|
||||
- `Inspector.detached` - Debugger disconnected
|
||||
- `Inspector.targetCrashed` - Target crashed
|
||||
|
||||
---
|
||||
|
||||
### Log
|
||||
**Purpose:** Collect browser logs
|
||||
|
||||
**Key Commands:**
|
||||
- `Log.enable()` - Enable log collection
|
||||
- `Log.clear()` - Clear logs
|
||||
|
||||
**Key Events:**
|
||||
- `Log.entryAdded` - New log entry
|
||||
|
||||
**Use Cases:**
|
||||
- Collect console logs
|
||||
- Monitor violations
|
||||
- Track deprecations
|
||||
|
||||
---
|
||||
|
||||
### DOMDebugger
|
||||
**Purpose:** DOM-level debugging
|
||||
|
||||
**Key Commands:**
|
||||
- `DOMDebugger.setDOMBreakpoint(nodeId, type)` - Break on DOM changes
|
||||
- `DOMDebugger.setEventListenerBreakpoint(eventName)` - Break on event
|
||||
- `DOMDebugger.setXHRBreakpoint(url)` - Break on XHR
|
||||
|
||||
**Breakpoint Types:**
|
||||
- subtree-modified, attribute-modified, node-removed
|
||||
|
||||
---
|
||||
|
||||
### DOMSnapshot
|
||||
**Purpose:** Capture complete DOM snapshot
|
||||
|
||||
**Key Commands:**
|
||||
- `DOMSnapshot.captureSnapshot(computedStyles)` - Capture full DOM
|
||||
|
||||
**Use Cases:**
|
||||
- Export page structure
|
||||
- Offline analysis
|
||||
- DOM diffing
|
||||
|
||||
---
|
||||
|
||||
### Audits (Lighthouse Integration)
|
||||
**Purpose:** Run automated audits
|
||||
|
||||
**Key Commands:**
|
||||
- `Audits.enable()` - Enable audits
|
||||
- `Audits.getEncodingIssues()` - Check encoding issues
|
||||
|
||||
---
|
||||
|
||||
### LayerTree
|
||||
**Purpose:** Inspect rendering layers
|
||||
|
||||
**Key Commands:**
|
||||
- `LayerTree.enable()` - Enable layer tracking
|
||||
- `LayerTree.compositingReasons(layerId)` - Get why layer created
|
||||
|
||||
**Key Events:**
|
||||
- `LayerTree.layerTreeDidChange` - Layers changed
|
||||
|
||||
**Use Cases:**
|
||||
- Debug rendering performance
|
||||
- Identify layer creation
|
||||
- Optimize compositing
|
||||
|
||||
---
|
||||
|
||||
## Other Domains
|
||||
|
||||
### Browser
|
||||
**Purpose:** Browser-level control
|
||||
|
||||
**Key Commands:**
|
||||
- `Browser.getVersion()` - Get browser info
|
||||
- `Browser.getBrowserCommandLine()` - Get launch args
|
||||
- `Browser.setPermission(permission, setting, origin)` - Set permissions
|
||||
- `Browser.grantPermissions(permissions, origin)` - Grant permissions
|
||||
|
||||
**Permissions:**
|
||||
- geolocation, midi, notifications, push, camera, microphone, background-sync, sensors, accessibility-events, clipboard-read, clipboard-write, payment-handler
|
||||
|
||||
---
|
||||
|
||||
### IO
|
||||
**Purpose:** File I/O operations
|
||||
|
||||
**Key Commands:**
|
||||
- `IO.read(handle, offset, size)` - Read stream
|
||||
- `IO.close(handle)` - Close stream
|
||||
|
||||
**Use Cases:**
|
||||
- Read large response bodies
|
||||
- Process binary data
|
||||
|
||||
---
|
||||
|
||||
### Media
|
||||
**Purpose:** Inspect media players
|
||||
|
||||
**Key Commands:**
|
||||
- `Media.enable()` - Track media players
|
||||
|
||||
**Key Events:**
|
||||
- `Media.playerPropertiesChanged` - Player state changed
|
||||
- `Media.playerEventsAdded` - Player events
|
||||
|
||||
---
|
||||
|
||||
### BackgroundService
|
||||
**Purpose:** Track background services
|
||||
|
||||
**Key Commands:**
|
||||
- `BackgroundService.startObserving(service)` - Track service
|
||||
|
||||
**Services:**
|
||||
- backgroundFetch, backgroundSync, pushMessaging, notifications, paymentHandler, periodicBackgroundSync
|
||||
|
||||
---
|
||||
|
||||
## Domain Dependencies
|
||||
|
||||
Some domains depend on others and must be enabled in order:
|
||||
|
||||
```
|
||||
Runtime (no dependencies)
|
||||
↓
|
||||
DOM (depends on Runtime)
|
||||
↓
|
||||
CSS (depends on DOM)
|
||||
|
||||
Network (no dependencies)
|
||||
|
||||
Page (depends on Runtime)
|
||||
↓
|
||||
Target (depends on Page)
|
||||
|
||||
Debugger (depends on Runtime)
|
||||
```
|
||||
|
||||
## Quick Command Reference
|
||||
|
||||
### Most Common Commands
|
||||
|
||||
```javascript
|
||||
// Navigation
|
||||
Page.navigate(url)
|
||||
Page.reload()
|
||||
|
||||
// JavaScript Execution
|
||||
Runtime.evaluate(expression)
|
||||
|
||||
// DOM Access
|
||||
DOM.getDocument()
|
||||
DOM.querySelector(nodeId, selector)
|
||||
|
||||
// Screenshots
|
||||
Page.captureScreenshot(format, quality)
|
||||
|
||||
// Network Monitoring
|
||||
Network.enable()
|
||||
// Listen for Network.requestWillBeSent events
|
||||
|
||||
// Console Messages
|
||||
// Listen for Runtime.consoleAPICalled events
|
||||
|
||||
// Cookies
|
||||
Network.getCookies(urls)
|
||||
Network.setCookie(...)
|
||||
|
||||
// Device Emulation
|
||||
Emulation.setDeviceMetricsOverride(width, height, ...)
|
||||
|
||||
// Performance
|
||||
Performance.getMetrics()
|
||||
Tracing.start(categories)
|
||||
Tracing.end()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Enable domains before use:** Always call `.enable()` for stateful domains
|
||||
2. **Handle events:** Subscribe to events for real-time updates
|
||||
3. **Clean up:** Disable domains when done to reduce overhead
|
||||
4. **Use sessions:** Attach to specific targets for isolated debugging
|
||||
5. **Handle errors:** Implement proper error handling for command failures
|
||||
6. **Version awareness:** Check browser version for experimental API support
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Protocol Viewer](https://chromedevtools.github.io/devtools-protocol/) - Interactive domain browser
|
||||
- [Protocol JSON](https://chromedevtools.github.io/devtools-protocol/tot/json) - Machine-readable specification
|
||||
- [Getting Started with CDP](https://github.com/aslushnikov/getting-started-with-cdp)
|
||||
- [devtools-protocol NPM](https://www.npmjs.com/package/devtools-protocol) - TypeScript definitions
|
||||
940
.opencode/skills/chrome-devtools/references/performance-guide.md
Normal file
940
.opencode/skills/chrome-devtools/references/performance-guide.md
Normal file
@@ -0,0 +1,940 @@
|
||||
# Performance Analysis Guide
|
||||
|
||||
Comprehensive guide to analyzing web performance using Chrome DevTools Protocol, Puppeteer, and chrome-devtools skill.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Core Web Vitals](#core-web-vitals)
|
||||
- [Performance Tracing](#performance-tracing)
|
||||
- [Network Analysis](#network-analysis)
|
||||
- [JavaScript Performance](#javascript-performance)
|
||||
- [Rendering Performance](#rendering-performance)
|
||||
- [Memory Analysis](#memory-analysis)
|
||||
- [Optimization Strategies](#optimization-strategies)
|
||||
|
||||
---
|
||||
|
||||
## Core Web Vitals
|
||||
|
||||
### Overview
|
||||
|
||||
Core Web Vitals are Google's standardized metrics for measuring user experience:
|
||||
|
||||
- **LCP (Largest Contentful Paint)** - Loading performance (< 2.5s good)
|
||||
- **FID (First Input Delay)** - Interactivity (< 100ms good)
|
||||
- **CLS (Cumulative Layout Shift)** - Visual stability (< 0.1 good)
|
||||
|
||||
### Measuring with chrome-devtools-mcp
|
||||
|
||||
```javascript
|
||||
// Start performance trace
|
||||
await useTool('performance_start_trace', {
|
||||
categories: ['loading', 'rendering', 'scripting']
|
||||
});
|
||||
|
||||
// Navigate to page
|
||||
await useTool('navigate_page', {
|
||||
url: 'https://example.com'
|
||||
});
|
||||
|
||||
// Wait for complete load
|
||||
await useTool('wait_for', {
|
||||
waitUntil: 'networkidle'
|
||||
});
|
||||
|
||||
// Stop trace and get data
|
||||
await useTool('performance_stop_trace');
|
||||
|
||||
// Get AI-powered insights
|
||||
const insights = await useTool('performance_analyze_insight');
|
||||
|
||||
// insights will include:
|
||||
// - LCP timing
|
||||
// - FID analysis
|
||||
// - CLS score
|
||||
// - Performance recommendations
|
||||
```
|
||||
|
||||
### Measuring with Puppeteer
|
||||
|
||||
```javascript
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Measure Core Web Vitals
|
||||
await page.goto('https://example.com', {
|
||||
waitUntil: 'networkidle2'
|
||||
});
|
||||
|
||||
const vitals = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const vitals = {
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: 0
|
||||
};
|
||||
|
||||
// LCP
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
vitals.LCP = entries[entries.length - 1].renderTime ||
|
||||
entries[entries.length - 1].loadTime;
|
||||
}).observe({ entryTypes: ['largest-contentful-paint'] });
|
||||
|
||||
// FID
|
||||
new PerformanceObserver((list) => {
|
||||
vitals.FID = list.getEntries()[0].processingStart -
|
||||
list.getEntries()[0].startTime;
|
||||
}).observe({ entryTypes: ['first-input'] });
|
||||
|
||||
// CLS
|
||||
new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
vitals.CLS += entry.value;
|
||||
}
|
||||
});
|
||||
}).observe({ entryTypes: ['layout-shift'] });
|
||||
|
||||
// Wait 5 seconds for metrics
|
||||
setTimeout(() => resolve(vitals), 5000);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('Core Web Vitals:', vitals);
|
||||
```
|
||||
|
||||
### Other Important Metrics
|
||||
|
||||
**TTFB (Time to First Byte)**
|
||||
```javascript
|
||||
const ttfb = await page.evaluate(() => {
|
||||
const [navigationEntry] = performance.getEntriesByType('navigation');
|
||||
return navigationEntry.responseStart - navigationEntry.requestStart;
|
||||
});
|
||||
```
|
||||
|
||||
**FCP (First Contentful Paint)**
|
||||
```javascript
|
||||
const fcp = await page.evaluate(() => {
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint');
|
||||
return fcpEntry ? fcpEntry.startTime : null;
|
||||
});
|
||||
```
|
||||
|
||||
**TTI (Time to Interactive)**
|
||||
```javascript
|
||||
// Requires lighthouse or manual calculation
|
||||
const tti = await page.evaluate(() => {
|
||||
// Complex calculation based on network idle and long tasks
|
||||
// Best to use Lighthouse for accurate TTI
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Tracing
|
||||
|
||||
### Chrome Trace Categories
|
||||
|
||||
**Loading:**
|
||||
- Page load events
|
||||
- Resource loading
|
||||
- Parser activity
|
||||
|
||||
**Rendering:**
|
||||
- Layout calculations
|
||||
- Paint operations
|
||||
- Compositing
|
||||
|
||||
**Scripting:**
|
||||
- JavaScript execution
|
||||
- V8 compilation
|
||||
- Garbage collection
|
||||
|
||||
**Network:**
|
||||
- HTTP requests
|
||||
- WebSocket traffic
|
||||
- Resource fetching
|
||||
|
||||
**Input:**
|
||||
- User input processing
|
||||
- Touch/scroll events
|
||||
|
||||
**GPU:**
|
||||
- GPU operations
|
||||
- Compositing work
|
||||
|
||||
### Record Performance Trace
|
||||
|
||||
**Using chrome-devtools-mcp:**
|
||||
```javascript
|
||||
// Start trace with specific categories
|
||||
await useTool('performance_start_trace', {
|
||||
categories: ['loading', 'rendering', 'scripting', 'network']
|
||||
});
|
||||
|
||||
// Perform actions
|
||||
await useTool('navigate_page', { url: 'https://example.com' });
|
||||
await useTool('wait_for', { waitUntil: 'networkidle' });
|
||||
|
||||
// Optional: Interact with page
|
||||
await useTool('click', { uid: 'button-uid' });
|
||||
|
||||
// Stop trace
|
||||
const traceData = await useTool('performance_stop_trace');
|
||||
|
||||
// Analyze trace
|
||||
const insights = await useTool('performance_analyze_insight');
|
||||
```
|
||||
|
||||
**Using Puppeteer:**
|
||||
```javascript
|
||||
// Start tracing
|
||||
await page.tracing.start({
|
||||
path: 'trace.json',
|
||||
categories: [
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-v8.cpu_profiler'
|
||||
]
|
||||
});
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com', {
|
||||
waitUntil: 'networkidle2'
|
||||
});
|
||||
|
||||
// Stop tracing
|
||||
await page.tracing.stop();
|
||||
|
||||
// Analyze in Chrome DevTools (chrome://tracing)
|
||||
```
|
||||
|
||||
### Analyze Trace Data
|
||||
|
||||
**Key Metrics from Trace:**
|
||||
|
||||
1. **Main Thread Activity**
|
||||
- JavaScript execution time
|
||||
- Layout/reflow time
|
||||
- Paint time
|
||||
- Long tasks (> 50ms)
|
||||
|
||||
2. **Network Waterfall**
|
||||
- Request start times
|
||||
- DNS lookup
|
||||
- Connection time
|
||||
- Download time
|
||||
|
||||
3. **Rendering Pipeline**
|
||||
- DOM construction
|
||||
- Style calculation
|
||||
- Layout
|
||||
- Paint
|
||||
- Composite
|
||||
|
||||
**Common Issues to Look For:**
|
||||
- Long tasks blocking main thread
|
||||
- Excessive JavaScript execution
|
||||
- Layout thrashing
|
||||
- Unnecessary repaints
|
||||
- Slow network requests
|
||||
- Large bundle sizes
|
||||
|
||||
---
|
||||
|
||||
## Network Analysis
|
||||
|
||||
### Monitor Network Requests
|
||||
|
||||
**Using chrome-devtools-mcp:**
|
||||
```javascript
|
||||
// Navigate to page
|
||||
await useTool('navigate_page', { url: 'https://example.com' });
|
||||
|
||||
// Wait for all requests
|
||||
await useTool('wait_for', { waitUntil: 'networkidle' });
|
||||
|
||||
// List all requests
|
||||
const requests = await useTool('list_network_requests', {
|
||||
resourceTypes: ['Document', 'Script', 'Stylesheet', 'Image', 'XHR', 'Fetch'],
|
||||
pageSize: 100
|
||||
});
|
||||
|
||||
// Analyze specific request
|
||||
for (const req of requests.requests) {
|
||||
const details = await useTool('get_network_request', {
|
||||
requestId: req.id
|
||||
});
|
||||
|
||||
console.log({
|
||||
url: details.url,
|
||||
method: details.method,
|
||||
status: details.status,
|
||||
size: details.encodedDataLength,
|
||||
time: details.timing.receiveHeadersEnd - details.timing.requestTime,
|
||||
cached: details.fromCache
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Using Puppeteer:**
|
||||
```javascript
|
||||
const requests = [];
|
||||
|
||||
// Capture all requests
|
||||
page.on('request', (request) => {
|
||||
requests.push({
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
resourceType: request.resourceType(),
|
||||
headers: request.headers()
|
||||
});
|
||||
});
|
||||
|
||||
// Capture responses
|
||||
page.on('response', (response) => {
|
||||
const request = response.request();
|
||||
console.log({
|
||||
url: response.url(),
|
||||
status: response.status(),
|
||||
size: response.headers()['content-length'],
|
||||
cached: response.fromCache(),
|
||||
timing: response.timing()
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('https://example.com');
|
||||
```
|
||||
|
||||
### Network Performance Metrics
|
||||
|
||||
**Calculate Total Page Weight:**
|
||||
```javascript
|
||||
let totalBytes = 0;
|
||||
let resourceCounts = {};
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const type = response.request().resourceType();
|
||||
const buffer = await response.buffer();
|
||||
|
||||
totalBytes += buffer.length;
|
||||
resourceCounts[type] = (resourceCounts[type] || 0) + 1;
|
||||
});
|
||||
|
||||
await page.goto('https://example.com');
|
||||
|
||||
console.log('Total size:', (totalBytes / 1024 / 1024).toFixed(2), 'MB');
|
||||
console.log('Resources:', resourceCounts);
|
||||
```
|
||||
|
||||
**Identify Slow Requests:**
|
||||
```javascript
|
||||
page.on('response', (response) => {
|
||||
const timing = response.timing();
|
||||
const totalTime = timing.receiveHeadersEnd - timing.requestTime;
|
||||
|
||||
if (totalTime > 1000) { // Slower than 1 second
|
||||
console.log('Slow request:', {
|
||||
url: response.url(),
|
||||
time: totalTime.toFixed(2) + 'ms',
|
||||
size: response.headers()['content-length']
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Network Throttling
|
||||
|
||||
**Simulate Slow Connection:**
|
||||
```javascript
|
||||
// Using chrome-devtools-mcp
|
||||
await useTool('emulate_network', {
|
||||
throttlingOption: 'Slow 3G' // or 'Fast 3G', 'Slow 4G'
|
||||
});
|
||||
|
||||
// Using Puppeteer
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Network.emulateNetworkConditions', {
|
||||
offline: false,
|
||||
downloadThroughput: 400 * 1024 / 8, // 400 Kbps
|
||||
uploadThroughput: 400 * 1024 / 8,
|
||||
latency: 2000 // 2000ms RTT
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Performance
|
||||
|
||||
### Identify Long Tasks
|
||||
|
||||
**Using Performance Observer:**
|
||||
```javascript
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const longTasks = [];
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
longTasks.push({
|
||||
name: entry.name,
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
|
||||
// Collect for 10 seconds
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(longTasks);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### CPU Profiling
|
||||
|
||||
**Using Puppeteer:**
|
||||
```javascript
|
||||
// Start CPU profiling
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Profiler.enable');
|
||||
await client.send('Profiler.start');
|
||||
|
||||
// Navigate and interact
|
||||
await page.goto('https://example.com');
|
||||
await page.click('.button');
|
||||
|
||||
// Stop profiling
|
||||
const { profile } = await client.send('Profiler.stop');
|
||||
|
||||
// Analyze profile (flame graph data)
|
||||
// Import into Chrome DevTools for visualization
|
||||
```
|
||||
|
||||
### JavaScript Coverage
|
||||
|
||||
**Identify Unused Code:**
|
||||
```javascript
|
||||
// Start coverage
|
||||
await Promise.all([
|
||||
page.coverage.startJSCoverage(),
|
||||
page.coverage.startCSSCoverage()
|
||||
]);
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Stop coverage
|
||||
const [jsCoverage, cssCoverage] = await Promise.all([
|
||||
page.coverage.stopJSCoverage(),
|
||||
page.coverage.stopCSSCoverage()
|
||||
]);
|
||||
|
||||
// Calculate unused bytes
|
||||
function calculateUnusedBytes(coverage) {
|
||||
let usedBytes = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const entry of coverage) {
|
||||
totalBytes += entry.text.length;
|
||||
for (const range of entry.ranges) {
|
||||
usedBytes += range.end - range.start - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
unusedBytes: totalBytes - usedBytes,
|
||||
unusedPercentage: ((totalBytes - usedBytes) / totalBytes * 100).toFixed(2)
|
||||
};
|
||||
}
|
||||
|
||||
console.log('JS Coverage:', calculateUnusedBytes(jsCoverage));
|
||||
console.log('CSS Coverage:', calculateUnusedBytes(cssCoverage));
|
||||
```
|
||||
|
||||
### Bundle Size Analysis
|
||||
|
||||
**Analyze JavaScript Bundles:**
|
||||
```javascript
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
const type = response.request().resourceType();
|
||||
|
||||
if (type === 'script') {
|
||||
const buffer = await response.buffer();
|
||||
const size = buffer.length;
|
||||
|
||||
console.log({
|
||||
url: url.split('/').pop(),
|
||||
size: (size / 1024).toFixed(2) + ' KB',
|
||||
gzipped: response.headers()['content-encoding'] === 'gzip'
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rendering Performance
|
||||
|
||||
### Layout Thrashing Detection
|
||||
|
||||
**Monitor Layout Recalculations:**
|
||||
```javascript
|
||||
// Using Performance Observer
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const measurements = [];
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (entry.entryType === 'measure' &&
|
||||
entry.name.includes('layout')) {
|
||||
measurements.push({
|
||||
name: entry.name,
|
||||
duration: entry.duration,
|
||||
startTime: entry.startTime
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['measure'] });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve(measurements);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Paint and Composite Metrics
|
||||
|
||||
**Get Paint Metrics:**
|
||||
```javascript
|
||||
const paintMetrics = await page.evaluate(() => {
|
||||
const paints = performance.getEntriesByType('paint');
|
||||
return {
|
||||
firstPaint: paints.find(p => p.name === 'first-paint')?.startTime,
|
||||
firstContentfulPaint: paints.find(p => p.name === 'first-contentful-paint')?.startTime
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
### Frame Rate Analysis
|
||||
|
||||
**Monitor FPS:**
|
||||
```javascript
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
let lastTime = performance.now();
|
||||
|
||||
function countFrames() {
|
||||
frames++;
|
||||
requestAnimationFrame(countFrames);
|
||||
}
|
||||
|
||||
countFrames();
|
||||
|
||||
setTimeout(() => {
|
||||
const now = performance.now();
|
||||
const elapsed = (now - lastTime) / 1000;
|
||||
const fps = frames / elapsed;
|
||||
resolve(fps);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Layout Shifts (CLS)
|
||||
|
||||
**Track Individual Shifts:**
|
||||
```javascript
|
||||
await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const shifts = [];
|
||||
let totalCLS = 0;
|
||||
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
totalCLS += entry.value;
|
||||
shifts.push({
|
||||
value: entry.value,
|
||||
time: entry.startTime,
|
||||
elements: entry.sources?.map(s => s.node)
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe({ entryTypes: ['layout-shift'] });
|
||||
|
||||
setTimeout(() => {
|
||||
observer.disconnect();
|
||||
resolve({ totalCLS, shifts });
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Memory Analysis
|
||||
|
||||
### Memory Metrics
|
||||
|
||||
**Get Memory Usage:**
|
||||
```javascript
|
||||
// Using chrome-devtools-mcp
|
||||
await useTool('evaluate_script', {
|
||||
expression: `
|
||||
({
|
||||
usedJSHeapSize: performance.memory?.usedJSHeapSize,
|
||||
totalJSHeapSize: performance.memory?.totalJSHeapSize,
|
||||
jsHeapSizeLimit: performance.memory?.jsHeapSizeLimit
|
||||
})
|
||||
`,
|
||||
returnByValue: true
|
||||
});
|
||||
|
||||
// Using Puppeteer
|
||||
const metrics = await page.metrics();
|
||||
console.log({
|
||||
jsHeapUsed: (metrics.JSHeapUsedSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||
jsHeapTotal: (metrics.JSHeapTotalSize / 1024 / 1024).toFixed(2) + ' MB',
|
||||
domNodes: metrics.Nodes,
|
||||
documents: metrics.Documents,
|
||||
jsEventListeners: metrics.JSEventListeners
|
||||
});
|
||||
```
|
||||
|
||||
### Memory Leak Detection
|
||||
|
||||
**Monitor Memory Over Time:**
|
||||
```javascript
|
||||
async function detectMemoryLeak(page, duration = 30000) {
|
||||
const samples = [];
|
||||
const interval = 1000; // Sample every second
|
||||
const samples_count = duration / interval;
|
||||
|
||||
for (let i = 0; i < samples_count; i++) {
|
||||
const metrics = await page.metrics();
|
||||
samples.push({
|
||||
time: i,
|
||||
heapUsed: metrics.JSHeapUsedSize
|
||||
});
|
||||
|
||||
await page.waitForTimeout(interval);
|
||||
}
|
||||
|
||||
// Analyze trend
|
||||
const firstSample = samples[0].heapUsed;
|
||||
const lastSample = samples[samples.length - 1].heapUsed;
|
||||
const increase = ((lastSample - firstSample) / firstSample * 100).toFixed(2);
|
||||
|
||||
return {
|
||||
samples,
|
||||
memoryIncrease: increase + '%',
|
||||
possibleLeak: increase > 50 // > 50% increase indicates possible leak
|
||||
};
|
||||
}
|
||||
|
||||
const leakAnalysis = await detectMemoryLeak(page, 30000);
|
||||
console.log('Memory Analysis:', leakAnalysis);
|
||||
```
|
||||
|
||||
### Heap Snapshot
|
||||
|
||||
**Capture Heap Snapshot:**
|
||||
```javascript
|
||||
const client = await page.createCDPSession();
|
||||
|
||||
// Take snapshot
|
||||
await client.send('HeapProfiler.enable');
|
||||
const { result } = await client.send('HeapProfiler.takeHeapSnapshot');
|
||||
|
||||
// Snapshot is streamed in chunks
|
||||
// Save to file or analyze programmatically
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### Image Optimization
|
||||
|
||||
**Detect Unoptimized Images:**
|
||||
```javascript
|
||||
const images = await page.evaluate(() => {
|
||||
const images = Array.from(document.querySelectorAll('img'));
|
||||
return images.map(img => ({
|
||||
src: img.src,
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
displayWidth: img.width,
|
||||
displayHeight: img.height,
|
||||
oversized: img.naturalWidth > img.width * 1.5 ||
|
||||
img.naturalHeight > img.height * 1.5
|
||||
}));
|
||||
});
|
||||
|
||||
const oversizedImages = images.filter(img => img.oversized);
|
||||
console.log('Oversized images:', oversizedImages);
|
||||
```
|
||||
|
||||
### Font Loading
|
||||
|
||||
**Detect Render-Blocking Fonts:**
|
||||
```javascript
|
||||
const fonts = await page.evaluate(() => {
|
||||
return Array.from(document.fonts).map(font => ({
|
||||
family: font.family,
|
||||
weight: font.weight,
|
||||
style: font.style,
|
||||
status: font.status,
|
||||
loaded: font.status === 'loaded'
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('Fonts:', fonts);
|
||||
```
|
||||
|
||||
### Third-Party Scripts
|
||||
|
||||
**Measure Third-Party Impact:**
|
||||
```javascript
|
||||
const thirdPartyDomains = ['googletagmanager.com', 'facebook.net', 'doubleclick.net'];
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const url = response.url();
|
||||
const isThirdParty = thirdPartyDomains.some(domain => url.includes(domain));
|
||||
|
||||
if (isThirdParty) {
|
||||
const buffer = await response.buffer();
|
||||
console.log({
|
||||
url: url,
|
||||
size: (buffer.length / 1024).toFixed(2) + ' KB',
|
||||
type: response.request().resourceType()
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Critical Rendering Path
|
||||
|
||||
**Identify Render-Blocking Resources:**
|
||||
```javascript
|
||||
await page.goto('https://example.com');
|
||||
|
||||
const renderBlockingResources = await page.evaluate(() => {
|
||||
const resources = performance.getEntriesByType('resource');
|
||||
return resources.filter(resource => {
|
||||
return (resource.initiatorType === 'link' &&
|
||||
resource.name.includes('.css')) ||
|
||||
(resource.initiatorType === 'script' &&
|
||||
!resource.name.includes('async'));
|
||||
}).map(r => ({
|
||||
url: r.name,
|
||||
duration: r.duration,
|
||||
startTime: r.startTime
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('Render-blocking resources:', renderBlockingResources);
|
||||
```
|
||||
|
||||
### Lighthouse Integration
|
||||
|
||||
**Run Lighthouse Audit:**
|
||||
```javascript
|
||||
import lighthouse from 'lighthouse';
|
||||
import { launch } from 'chrome-launcher';
|
||||
|
||||
// Launch Chrome
|
||||
const chrome = await launch({ chromeFlags: ['--headless'] });
|
||||
|
||||
// Run Lighthouse
|
||||
const { lhr } = await lighthouse('https://example.com', {
|
||||
port: chrome.port,
|
||||
onlyCategories: ['performance']
|
||||
});
|
||||
|
||||
// Get scores
|
||||
console.log({
|
||||
performanceScore: lhr.categories.performance.score * 100,
|
||||
metrics: {
|
||||
FCP: lhr.audits['first-contentful-paint'].displayValue,
|
||||
LCP: lhr.audits['largest-contentful-paint'].displayValue,
|
||||
TBT: lhr.audits['total-blocking-time'].displayValue,
|
||||
CLS: lhr.audits['cumulative-layout-shift'].displayValue,
|
||||
SI: lhr.audits['speed-index'].displayValue
|
||||
},
|
||||
opportunities: lhr.audits['opportunities']
|
||||
});
|
||||
|
||||
await chrome.kill();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Budgets
|
||||
|
||||
### Set Performance Budgets
|
||||
|
||||
```javascript
|
||||
const budgets = {
|
||||
// Core Web Vitals
|
||||
LCP: 2500, // ms
|
||||
FID: 100, // ms
|
||||
CLS: 0.1, // score
|
||||
|
||||
// Other metrics
|
||||
FCP: 1800, // ms
|
||||
TTI: 3800, // ms
|
||||
TBT: 300, // ms
|
||||
|
||||
// Resource budgets
|
||||
totalPageSize: 2 * 1024 * 1024, // 2 MB
|
||||
jsSize: 500 * 1024, // 500 KB
|
||||
cssSize: 100 * 1024, // 100 KB
|
||||
imageSize: 1 * 1024 * 1024, // 1 MB
|
||||
|
||||
// Request counts
|
||||
totalRequests: 50,
|
||||
jsRequests: 10,
|
||||
cssRequests: 5
|
||||
};
|
||||
|
||||
async function checkBudgets(page, budgets) {
|
||||
// Measure actual values
|
||||
const vitals = await measureCoreWebVitals(page);
|
||||
const resources = await analyzeResources(page);
|
||||
|
||||
// Compare against budgets
|
||||
const violations = [];
|
||||
|
||||
if (vitals.LCP > budgets.LCP) {
|
||||
violations.push(`LCP: ${vitals.LCP}ms exceeds budget of ${budgets.LCP}ms`);
|
||||
}
|
||||
|
||||
if (resources.totalSize > budgets.totalPageSize) {
|
||||
violations.push(`Page size: ${resources.totalSize} exceeds budget of ${budgets.totalPageSize}`);
|
||||
}
|
||||
|
||||
// ... check other budgets
|
||||
|
||||
return {
|
||||
passed: violations.length === 0,
|
||||
violations
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Automated Performance Testing
|
||||
|
||||
### CI/CD Integration
|
||||
|
||||
```javascript
|
||||
// performance-test.js
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
async function performanceTest(url) {
|
||||
const browser = await puppeteer.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Measure metrics
|
||||
await page.goto(url, { waitUntil: 'networkidle2' });
|
||||
const metrics = await page.metrics();
|
||||
const vitals = await measureCoreWebVitals(page);
|
||||
|
||||
await browser.close();
|
||||
|
||||
// Check against thresholds
|
||||
const thresholds = {
|
||||
LCP: 2500,
|
||||
FID: 100,
|
||||
CLS: 0.1,
|
||||
jsHeapSize: 50 * 1024 * 1024 // 50 MB
|
||||
};
|
||||
|
||||
const failed = [];
|
||||
if (vitals.LCP > thresholds.LCP) failed.push('LCP');
|
||||
if (vitals.FID > thresholds.FID) failed.push('FID');
|
||||
if (vitals.CLS > thresholds.CLS) failed.push('CLS');
|
||||
if (metrics.JSHeapUsedSize > thresholds.jsHeapSize) failed.push('Memory');
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.error('Performance test failed:', failed);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Performance test passed');
|
||||
}
|
||||
|
||||
performanceTest(process.env.TEST_URL);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Performance Testing Checklist
|
||||
|
||||
1. **Measure Multiple Times**
|
||||
- Run tests 3-5 times
|
||||
- Use median values
|
||||
- Account for variance
|
||||
|
||||
2. **Test Different Conditions**
|
||||
- Fast 3G
|
||||
- Slow 3G
|
||||
- Offline
|
||||
- CPU throttling
|
||||
|
||||
3. **Test Different Devices**
|
||||
- Mobile (low-end)
|
||||
- Mobile (high-end)
|
||||
- Desktop
|
||||
- Tablet
|
||||
|
||||
4. **Monitor Over Time**
|
||||
- Track metrics in CI/CD
|
||||
- Set up alerts for regressions
|
||||
- Create performance dashboards
|
||||
|
||||
5. **Focus on User Experience**
|
||||
- Prioritize Core Web Vitals
|
||||
- Test real user journeys
|
||||
- Consider perceived performance
|
||||
|
||||
6. **Optimize Critical Path**
|
||||
- Minimize render-blocking resources
|
||||
- Defer non-critical JavaScript
|
||||
- Optimize font loading
|
||||
- Lazy load images
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Web.dev Performance](https://web.dev/performance/)
|
||||
- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/)
|
||||
- [Core Web Vitals](https://web.dev/vitals/)
|
||||
- [Lighthouse](https://developer.chrome.com/docs/lighthouse/)
|
||||
- [WebPageTest](https://www.webpagetest.org/)
|
||||
@@ -0,0 +1,953 @@
|
||||
# Puppeteer Quick Reference
|
||||
|
||||
Complete guide to browser automation with Puppeteer - a high-level API over Chrome DevTools Protocol.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Setup](#setup)
|
||||
- [Browser & Page Management](#browser--page-management)
|
||||
- [Navigation](#navigation)
|
||||
- [Element Interaction](#element-interaction)
|
||||
- [JavaScript Execution](#javascript-execution)
|
||||
- [Screenshots & PDFs](#screenshots--pdfs)
|
||||
- [Network Interception](#network-interception)
|
||||
- [Device Emulation](#device-emulation)
|
||||
- [Performance](#performance)
|
||||
- [Common Patterns](#common-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install Puppeteer
|
||||
npm install puppeteer
|
||||
|
||||
# Install core only (bring your own Chrome)
|
||||
npm install puppeteer-core
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```javascript
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
// Launch browser
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox']
|
||||
});
|
||||
|
||||
// Open page
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Do work...
|
||||
|
||||
// Cleanup
|
||||
await browser.close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser & Page Management
|
||||
|
||||
### Launch Browser
|
||||
|
||||
```javascript
|
||||
const browser = await puppeteer.launch({
|
||||
// Visibility
|
||||
headless: false, // Show browser UI
|
||||
headless: 'new', // New headless mode (Chrome 112+)
|
||||
|
||||
// Chrome location
|
||||
executablePath: '/path/to/chrome',
|
||||
channel: 'chrome', // or 'chrome-canary', 'chrome-beta'
|
||||
|
||||
// Browser context
|
||||
userDataDir: './user-data', // Persistent profile
|
||||
|
||||
// Window size
|
||||
defaultViewport: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false
|
||||
},
|
||||
|
||||
// Advanced options
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-web-security',
|
||||
'--disable-features=IsolateOrigins',
|
||||
'--disable-site-isolation-trials',
|
||||
'--start-maximized'
|
||||
],
|
||||
|
||||
// Debugging
|
||||
devtools: true, // Open DevTools automatically
|
||||
slowMo: 250, // Slow down by 250ms per action
|
||||
|
||||
// Network
|
||||
proxy: {
|
||||
server: 'http://proxy.com:8080'
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Connect to Running Browser
|
||||
|
||||
```javascript
|
||||
// Launch Chrome with debugging
|
||||
// google-chrome --remote-debugging-port=9222
|
||||
|
||||
const browser = await puppeteer.connect({
|
||||
browserURL: 'http://localhost:9222',
|
||||
// or browserWSEndpoint: 'ws://localhost:9222/devtools/browser/...'
|
||||
});
|
||||
```
|
||||
|
||||
### Page Management
|
||||
|
||||
```javascript
|
||||
// Create new page
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Get all pages
|
||||
const pages = await browser.pages();
|
||||
|
||||
// Close page
|
||||
await page.close();
|
||||
|
||||
// Multiple pages
|
||||
const page1 = await browser.newPage();
|
||||
const page2 = await browser.newPage();
|
||||
|
||||
// Switch between pages
|
||||
await page1.bringToFront();
|
||||
```
|
||||
|
||||
### Browser Context (Incognito)
|
||||
|
||||
```javascript
|
||||
// Create isolated context
|
||||
const context = await browser.createBrowserContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
// Cleanup context
|
||||
await context.close();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Navigation
|
||||
|
||||
### Basic Navigation
|
||||
|
||||
```javascript
|
||||
// Navigate to URL
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Navigate with options
|
||||
await page.goto('https://example.com', {
|
||||
waitUntil: 'networkidle2', // or 'load', 'domcontentloaded', 'networkidle0'
|
||||
timeout: 30000 // Max wait time (ms)
|
||||
});
|
||||
|
||||
// Reload page
|
||||
await page.reload({ waitUntil: 'networkidle2' });
|
||||
|
||||
// Navigation history
|
||||
await page.goBack();
|
||||
await page.goForward();
|
||||
|
||||
// Wait for navigation
|
||||
await page.waitForNavigation({
|
||||
waitUntil: 'networkidle2'
|
||||
});
|
||||
```
|
||||
|
||||
### Wait Until Options
|
||||
|
||||
- `load` - Wait for load event
|
||||
- `domcontentloaded` - Wait for DOMContentLoaded event
|
||||
- `networkidle0` - Wait until no network connections for 500ms
|
||||
- `networkidle2` - Wait until max 2 network connections for 500ms
|
||||
|
||||
---
|
||||
|
||||
## Element Interaction
|
||||
|
||||
### Selectors
|
||||
|
||||
```javascript
|
||||
// CSS selectors
|
||||
await page.$('#id');
|
||||
await page.$('.class');
|
||||
await page.$('div > p');
|
||||
|
||||
// XPath
|
||||
await page.$x('//button[text()="Submit"]');
|
||||
|
||||
// Get all matching elements
|
||||
await page.$$('.item');
|
||||
await page.$$x('//div[@class="item"]');
|
||||
```
|
||||
|
||||
### Click Elements
|
||||
|
||||
```javascript
|
||||
// Click by selector
|
||||
await page.click('.button');
|
||||
|
||||
// Click with options
|
||||
await page.click('.button', {
|
||||
button: 'left', // or 'right', 'middle'
|
||||
clickCount: 1, // 2 for double-click
|
||||
delay: 100 // Delay between mousedown and mouseup
|
||||
});
|
||||
|
||||
// ElementHandle click
|
||||
const button = await page.$('.button');
|
||||
await button.click();
|
||||
```
|
||||
|
||||
### Type Text
|
||||
|
||||
```javascript
|
||||
// Type into input
|
||||
await page.type('#search', 'query text');
|
||||
|
||||
// Type with delay
|
||||
await page.type('#search', 'slow typing', { delay: 100 });
|
||||
|
||||
// Clear and type
|
||||
await page.$eval('#search', el => el.value = '');
|
||||
await page.type('#search', 'new text');
|
||||
```
|
||||
|
||||
### Form Interaction
|
||||
|
||||
```javascript
|
||||
// Fill input
|
||||
await page.type('#username', 'john@example.com');
|
||||
await page.type('#password', 'secret123');
|
||||
|
||||
// Select dropdown option
|
||||
await page.select('#country', 'US'); // By value
|
||||
await page.select('#country', 'USA', 'UK'); // Multiple
|
||||
|
||||
// Check/uncheck checkbox
|
||||
await page.click('input[type="checkbox"]');
|
||||
|
||||
// Choose radio button
|
||||
await page.click('input[value="option2"]');
|
||||
|
||||
// Upload file
|
||||
const input = await page.$('input[type="file"]');
|
||||
await input.uploadFile('/path/to/file.pdf');
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForNavigation();
|
||||
```
|
||||
|
||||
### Hover & Focus
|
||||
|
||||
```javascript
|
||||
// Hover over element
|
||||
await page.hover('.menu-item');
|
||||
|
||||
// Focus element
|
||||
await page.focus('#input');
|
||||
|
||||
// Blur
|
||||
await page.$eval('#input', el => el.blur());
|
||||
```
|
||||
|
||||
### Drag & Drop
|
||||
|
||||
```javascript
|
||||
const source = await page.$('.draggable');
|
||||
const target = await page.$('.drop-zone');
|
||||
|
||||
await source.drag(target);
|
||||
await source.drop(target);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
### Evaluate in Page Context
|
||||
|
||||
```javascript
|
||||
// Execute JavaScript
|
||||
const title = await page.evaluate(() => document.title);
|
||||
|
||||
// With arguments
|
||||
const text = await page.evaluate(
|
||||
(selector) => document.querySelector(selector).textContent,
|
||||
'.heading'
|
||||
);
|
||||
|
||||
// Return complex data
|
||||
const data = await page.evaluate(() => ({
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
cookies: document.cookie
|
||||
}));
|
||||
|
||||
// With ElementHandle
|
||||
const element = await page.$('.button');
|
||||
const text = await page.evaluate(el => el.textContent, element);
|
||||
```
|
||||
|
||||
### Query & Modify DOM
|
||||
|
||||
```javascript
|
||||
// Get element property
|
||||
const value = await page.$eval('#input', el => el.value);
|
||||
|
||||
// Get multiple elements
|
||||
const items = await page.$$eval('.item', elements =>
|
||||
elements.map(el => el.textContent)
|
||||
);
|
||||
|
||||
// Modify element
|
||||
await page.$eval('#input', (el, value) => {
|
||||
el.value = value;
|
||||
}, 'new value');
|
||||
|
||||
// Add class
|
||||
await page.$eval('.element', el => el.classList.add('active'));
|
||||
```
|
||||
|
||||
### Expose Functions
|
||||
|
||||
```javascript
|
||||
// Expose Node.js function to page
|
||||
await page.exposeFunction('md5', (text) =>
|
||||
crypto.createHash('md5').update(text).digest('hex')
|
||||
);
|
||||
|
||||
// Call from page context
|
||||
const hash = await page.evaluate(async () => {
|
||||
return await window.md5('hello world');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Screenshots & PDFs
|
||||
|
||||
### Screenshots
|
||||
|
||||
```javascript
|
||||
// Full page screenshot
|
||||
await page.screenshot({
|
||||
path: 'screenshot.png',
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
// Viewport screenshot
|
||||
await page.screenshot({
|
||||
path: 'viewport.png',
|
||||
fullPage: false
|
||||
});
|
||||
|
||||
// Element screenshot
|
||||
const element = await page.$('.chart');
|
||||
await element.screenshot({
|
||||
path: 'chart.png'
|
||||
});
|
||||
|
||||
// Screenshot options
|
||||
await page.screenshot({
|
||||
path: 'page.png',
|
||||
type: 'png', // or 'jpeg', 'webp'
|
||||
quality: 80, // JPEG quality (0-100)
|
||||
clip: { // Crop region
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 500,
|
||||
height: 500
|
||||
},
|
||||
omitBackground: true // Transparent background
|
||||
});
|
||||
|
||||
// Screenshot to buffer
|
||||
const buffer = await page.screenshot();
|
||||
```
|
||||
|
||||
### PDF Generation
|
||||
|
||||
```javascript
|
||||
// Generate PDF
|
||||
await page.pdf({
|
||||
path: 'page.pdf',
|
||||
format: 'A4', // or 'Letter', 'Legal', etc.
|
||||
printBackground: true,
|
||||
margin: {
|
||||
top: '1cm',
|
||||
right: '1cm',
|
||||
bottom: '1cm',
|
||||
left: '1cm'
|
||||
}
|
||||
});
|
||||
|
||||
// Custom page size
|
||||
await page.pdf({
|
||||
path: 'custom.pdf',
|
||||
width: '8.5in',
|
||||
height: '11in',
|
||||
landscape: true
|
||||
});
|
||||
|
||||
// Header and footer
|
||||
await page.pdf({
|
||||
path: 'report.pdf',
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div style="font-size:10px;">Header</div>',
|
||||
footerTemplate: '<div style="font-size:10px;">Page <span class="pageNumber"></span></div>'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Network Interception
|
||||
|
||||
### Request Interception
|
||||
|
||||
```javascript
|
||||
// Enable request interception
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
// Intercept requests
|
||||
page.on('request', (request) => {
|
||||
// Block specific resource types
|
||||
if (request.resourceType() === 'image') {
|
||||
request.abort();
|
||||
}
|
||||
// Block URLs
|
||||
else if (request.url().includes('ads')) {
|
||||
request.abort();
|
||||
}
|
||||
// Modify request
|
||||
else if (request.url().includes('api')) {
|
||||
request.continue({
|
||||
headers: {
|
||||
...request.headers(),
|
||||
'Authorization': 'Bearer token'
|
||||
}
|
||||
});
|
||||
}
|
||||
// Continue normally
|
||||
else {
|
||||
request.continue();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Responses
|
||||
|
||||
```javascript
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/user')) {
|
||||
request.respond({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
id: 1,
|
||||
name: 'Mock User'
|
||||
})
|
||||
});
|
||||
} else {
|
||||
request.continue();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Monitor Network
|
||||
|
||||
```javascript
|
||||
// Track requests
|
||||
page.on('request', (request) => {
|
||||
console.log('Request:', request.method(), request.url());
|
||||
});
|
||||
|
||||
// Track responses
|
||||
page.on('response', (response) => {
|
||||
console.log('Response:', response.status(), response.url());
|
||||
});
|
||||
|
||||
// Track failed requests
|
||||
page.on('requestfailed', (request) => {
|
||||
console.log('Failed:', request.failure().errorText, request.url());
|
||||
});
|
||||
|
||||
// Get response body
|
||||
page.on('response', async (response) => {
|
||||
if (response.url().includes('/api/data')) {
|
||||
const json = await response.json();
|
||||
console.log('API Data:', json);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Device Emulation
|
||||
|
||||
### Predefined Devices
|
||||
|
||||
```javascript
|
||||
import { devices } from 'puppeteer';
|
||||
|
||||
// Emulate iPhone
|
||||
const iPhone = devices['iPhone 13 Pro'];
|
||||
await page.emulate(iPhone);
|
||||
|
||||
// Common devices
|
||||
const iPad = devices['iPad Pro'];
|
||||
const pixel = devices['Pixel 5'];
|
||||
const galaxy = devices['Galaxy S9+'];
|
||||
|
||||
// Navigate after emulation
|
||||
await page.goto('https://example.com');
|
||||
```
|
||||
|
||||
### Custom Device
|
||||
|
||||
```javascript
|
||||
await page.emulate({
|
||||
viewport: {
|
||||
width: 375,
|
||||
height: 812,
|
||||
deviceScaleFactor: 3,
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
isLandscape: false
|
||||
},
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)...'
|
||||
});
|
||||
```
|
||||
|
||||
### Viewport Only
|
||||
|
||||
```javascript
|
||||
await page.setViewport({
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
});
|
||||
```
|
||||
|
||||
### Geolocation
|
||||
|
||||
```javascript
|
||||
// Set geolocation
|
||||
await page.setGeolocation({
|
||||
latitude: 37.7749,
|
||||
longitude: -122.4194,
|
||||
accuracy: 100
|
||||
});
|
||||
|
||||
// Grant permissions
|
||||
const context = browser.defaultBrowserContext();
|
||||
await context.overridePermissions('https://example.com', ['geolocation']);
|
||||
```
|
||||
|
||||
### Timezone & Locale
|
||||
|
||||
```javascript
|
||||
// Set timezone
|
||||
await page.emulateTimezone('America/New_York');
|
||||
|
||||
// Set locale
|
||||
await page.emulateMediaType('screen');
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
get: () => 'en-US'
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### CPU & Network Throttling
|
||||
|
||||
```javascript
|
||||
// CPU throttling
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Emulation.setCPUThrottlingRate', { rate: 4 });
|
||||
|
||||
// Network throttling
|
||||
await page.emulateNetworkConditions({
|
||||
offline: false,
|
||||
downloadThroughput: 1.5 * 1024 * 1024 / 8, // 1.5 Mbps
|
||||
uploadThroughput: 750 * 1024 / 8, // 750 Kbps
|
||||
latency: 40 // 40ms RTT
|
||||
});
|
||||
|
||||
// Predefined profiles
|
||||
await page.emulateNetworkConditions(
|
||||
puppeteer.networkConditions['Fast 3G']
|
||||
);
|
||||
|
||||
// Disable throttling
|
||||
await page.emulateNetworkConditions({
|
||||
offline: false,
|
||||
downloadThroughput: -1,
|
||||
uploadThroughput: -1,
|
||||
latency: 0
|
||||
});
|
||||
```
|
||||
|
||||
### Performance Metrics
|
||||
|
||||
```javascript
|
||||
// Get metrics
|
||||
const metrics = await page.metrics();
|
||||
console.log(metrics);
|
||||
// {
|
||||
// Timestamp, Documents, Frames, JSEventListeners,
|
||||
// Nodes, LayoutCount, RecalcStyleCount,
|
||||
// LayoutDuration, RecalcStyleDuration,
|
||||
// ScriptDuration, TaskDuration,
|
||||
// JSHeapUsedSize, JSHeapTotalSize
|
||||
// }
|
||||
```
|
||||
|
||||
### Performance Tracing
|
||||
|
||||
```javascript
|
||||
// Start tracing
|
||||
await page.tracing.start({
|
||||
path: 'trace.json',
|
||||
categories: [
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline'
|
||||
]
|
||||
});
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Stop tracing
|
||||
await page.tracing.stop();
|
||||
|
||||
// Analyze trace in chrome://tracing
|
||||
```
|
||||
|
||||
### Coverage (Code Usage)
|
||||
|
||||
```javascript
|
||||
// Start JS coverage
|
||||
await page.coverage.startJSCoverage();
|
||||
|
||||
// Start CSS coverage
|
||||
await page.coverage.startCSSCoverage();
|
||||
|
||||
// Navigate
|
||||
await page.goto('https://example.com');
|
||||
|
||||
// Stop and get coverage
|
||||
const jsCoverage = await page.coverage.stopJSCoverage();
|
||||
const cssCoverage = await page.coverage.stopCSSCoverage();
|
||||
|
||||
// Calculate unused bytes
|
||||
let totalBytes = 0;
|
||||
let usedBytes = 0;
|
||||
for (const entry of [...jsCoverage, ...cssCoverage]) {
|
||||
totalBytes += entry.text.length;
|
||||
for (const range of entry.ranges) {
|
||||
usedBytes += range.end - range.start - 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Used: ${usedBytes / totalBytes * 100}%`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Wait for Elements
|
||||
|
||||
```javascript
|
||||
// Wait for selector
|
||||
await page.waitForSelector('.element', {
|
||||
visible: true,
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
// Wait for XPath
|
||||
await page.waitForXPath('//button[text()="Submit"]');
|
||||
|
||||
// Wait for function
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.loading') === null,
|
||||
{ timeout: 10000 }
|
||||
);
|
||||
|
||||
// Wait for timeout
|
||||
await page.waitForTimeout(2000);
|
||||
```
|
||||
|
||||
### Handle Dialogs
|
||||
|
||||
```javascript
|
||||
// Alert, confirm, prompt
|
||||
page.on('dialog', async (dialog) => {
|
||||
console.log(dialog.type(), dialog.message());
|
||||
|
||||
// Accept
|
||||
await dialog.accept();
|
||||
// or reject
|
||||
// await dialog.dismiss();
|
||||
// or provide input for prompt
|
||||
// await dialog.accept('input text');
|
||||
});
|
||||
```
|
||||
|
||||
### Handle Downloads
|
||||
|
||||
```javascript
|
||||
// Set download path
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Page.setDownloadBehavior', {
|
||||
behavior: 'allow',
|
||||
downloadPath: '/path/to/downloads'
|
||||
});
|
||||
|
||||
// Trigger download
|
||||
await page.click('a[download]');
|
||||
```
|
||||
|
||||
### Multiple Pages (Tabs)
|
||||
|
||||
```javascript
|
||||
// Listen for new pages
|
||||
browser.on('targetcreated', async (target) => {
|
||||
if (target.type() === 'page') {
|
||||
const newPage = await target.page();
|
||||
console.log('New page opened:', newPage.url());
|
||||
}
|
||||
});
|
||||
|
||||
// Click link that opens new tab
|
||||
const [newPage] = await Promise.all([
|
||||
new Promise(resolve => browser.once('targetcreated', target => resolve(target.page()))),
|
||||
page.click('a[target="_blank"]')
|
||||
]);
|
||||
|
||||
console.log('New page URL:', newPage.url());
|
||||
```
|
||||
|
||||
### Frames (iframes)
|
||||
|
||||
```javascript
|
||||
// Get all frames
|
||||
const frames = page.frames();
|
||||
|
||||
// Find frame by name
|
||||
const frame = page.frames().find(f => f.name() === 'myframe');
|
||||
|
||||
// Find frame by URL
|
||||
const frame = page.frames().find(f => f.url().includes('example.com'));
|
||||
|
||||
// Main frame
|
||||
const mainFrame = page.mainFrame();
|
||||
|
||||
// Interact with frame
|
||||
await frame.click('.button');
|
||||
await frame.type('#input', 'text');
|
||||
```
|
||||
|
||||
### Infinite Scroll
|
||||
|
||||
```javascript
|
||||
async function autoScroll(page) {
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 100;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
await autoScroll(page);
|
||||
```
|
||||
|
||||
### Cookies
|
||||
|
||||
```javascript
|
||||
// Get cookies
|
||||
const cookies = await page.cookies();
|
||||
|
||||
// Set cookies
|
||||
await page.setCookie({
|
||||
name: 'session',
|
||||
value: 'abc123',
|
||||
domain: 'example.com',
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'Strict'
|
||||
});
|
||||
|
||||
// Delete cookies
|
||||
await page.deleteCookie({ name: 'session' });
|
||||
```
|
||||
|
||||
### Local Storage
|
||||
|
||||
```javascript
|
||||
// Set localStorage
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('key', 'value');
|
||||
});
|
||||
|
||||
// Get localStorage
|
||||
const value = await page.evaluate(() => {
|
||||
return localStorage.getItem('key');
|
||||
});
|
||||
|
||||
// Clear localStorage
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```javascript
|
||||
try {
|
||||
await page.goto('https://example.com', {
|
||||
waitUntil: 'networkidle2',
|
||||
timeout: 30000
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.name === 'TimeoutError') {
|
||||
console.error('Page load timeout');
|
||||
} else {
|
||||
console.error('Navigation failed:', error);
|
||||
}
|
||||
|
||||
// Take screenshot on error
|
||||
await page.screenshot({ path: 'error.png' });
|
||||
}
|
||||
```
|
||||
|
||||
### Stealth Mode (Avoid Detection)
|
||||
|
||||
```javascript
|
||||
// Hide automation indicators
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
// Override navigator.webdriver
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => false
|
||||
});
|
||||
|
||||
// Mock chrome object
|
||||
window.chrome = {
|
||||
runtime: {}
|
||||
};
|
||||
|
||||
// Mock permissions
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications' ?
|
||||
Promise.resolve({ state: 'granted' }) :
|
||||
originalQuery(parameters)
|
||||
);
|
||||
});
|
||||
|
||||
// Set realistic user agent
|
||||
await page.setUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### Take Screenshots on Error
|
||||
|
||||
```javascript
|
||||
page.on('pageerror', async (error) => {
|
||||
console.error('Page error:', error);
|
||||
await page.screenshot({ path: `error-${Date.now()}.png` });
|
||||
});
|
||||
```
|
||||
|
||||
### Console Logging
|
||||
|
||||
```javascript
|
||||
// Forward console to Node
|
||||
page.on('console', (msg) => {
|
||||
console.log('PAGE LOG:', msg.text());
|
||||
});
|
||||
```
|
||||
|
||||
### Slow Down Execution
|
||||
|
||||
```javascript
|
||||
const browser = await puppeteer.launch({
|
||||
slowMo: 250 // 250ms delay between actions
|
||||
});
|
||||
```
|
||||
|
||||
### Keep Browser Open
|
||||
|
||||
```javascript
|
||||
const browser = await puppeteer.launch({
|
||||
headless: false,
|
||||
devtools: true
|
||||
});
|
||||
|
||||
// Prevent auto-close
|
||||
await page.evaluate(() => debugger);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always close browser:** Use try/finally or process cleanup
|
||||
2. **Wait appropriately:** Use waitForSelector, not setTimeout
|
||||
3. **Handle errors:** Wrap navigation in try/catch
|
||||
4. **Optimize selectors:** Use specific selectors for reliability
|
||||
5. **Avoid race conditions:** Wait for navigation after clicks
|
||||
6. **Reuse pages:** Don't create new pages unnecessarily
|
||||
7. **Set timeouts:** Always specify reasonable timeouts
|
||||
8. **Clean up:** Close unused pages and contexts
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Puppeteer Documentation](https://pptr.dev/)
|
||||
- [Puppeteer API](https://pptr.dev/api)
|
||||
- [Puppeteer Examples](https://github.com/puppeteer/puppeteer/tree/main/examples)
|
||||
- [Awesome Puppeteer](https://github.com/transitive-bullshit/awesome-puppeteer)
|
||||
3
.opencode/skills/chrome-devtools/scripts/.gitignore
vendored
Normal file
3
.opencode/skills/chrome-devtools/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
.browser-session.json
|
||||
.auth-session.json
|
||||
290
.opencode/skills/chrome-devtools/scripts/README.md
Normal file
290
.opencode/skills/chrome-devtools/scripts/README.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Chrome DevTools Scripts
|
||||
|
||||
CLI scripts for browser automation using Puppeteer.
|
||||
|
||||
**CRITICAL**: Always check `pwd` before running scripts.
|
||||
|
||||
## Installation
|
||||
|
||||
## Skill Location
|
||||
|
||||
Skills can exist in **project-scope** or **user-scope**. Priority: project-scope > user-scope.
|
||||
|
||||
```bash
|
||||
# Detect skill location
|
||||
SKILL_DIR=""
|
||||
if [ -d ".opencode/skills/chrome-devtools/scripts" ]; then
|
||||
SKILL_DIR=".opencode/skills/chrome-devtools/scripts"
|
||||
elif [ -d "$HOME/.opencode/skills/chrome-devtools/scripts" ]; then
|
||||
SKILL_DIR="$HOME/.opencode/skills/chrome-devtools/scripts"
|
||||
fi
|
||||
cd "$SKILL_DIR"
|
||||
```
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
pwd # Should show current working directory
|
||||
cd $SKILL_DIR/.opencode/skills/chrome-devtools/scripts
|
||||
./install.sh # Auto-checks dependencies and installs
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
**Linux/WSL** - Install system dependencies first:
|
||||
```bash
|
||||
./install-deps.sh # Auto-detects OS (Ubuntu, Debian, Fedora, etc.)
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
sudo apt-get install -y libnss3 libnspr4 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1
|
||||
```
|
||||
|
||||
**All platforms** - Install Node dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
**CRITICAL**: Always check `pwd` before running scripts.
|
||||
|
||||
### inject-auth.js
|
||||
Inject authentication (cookies, tokens, storage) for testing protected routes.
|
||||
|
||||
**Workflow for testing protected routes:**
|
||||
1. User manually logs into the site in their browser
|
||||
2. User extracts cookies/tokens from browser DevTools (Application tab)
|
||||
3. Run inject-auth.js to inject auth into puppeteer session
|
||||
4. Run other scripts which will use the authenticated session
|
||||
|
||||
```bash
|
||||
# Inject cookies
|
||||
node inject-auth.js --url https://example.com --cookies '[{"name":"session","value":"abc123","domain":".example.com"}]'
|
||||
|
||||
# Inject Bearer token (stores in localStorage + sets HTTP header)
|
||||
node inject-auth.js --url https://example.com --token "Bearer eyJhbGciOi..." --header Authorization
|
||||
|
||||
# Inject localStorage items
|
||||
node inject-auth.js --url https://example.com --local-storage '{"auth_token":"xyz","user_id":"123"}'
|
||||
|
||||
# Inject sessionStorage items
|
||||
node inject-auth.js --url https://example.com --session-storage '{"temp_key":"value"}'
|
||||
|
||||
# Combined injection
|
||||
node inject-auth.js --url https://example.com \
|
||||
--cookies '[{"name":"session","value":"abc"}]' \
|
||||
--local-storage '{"user":"data"}' \
|
||||
--reload true
|
||||
|
||||
# Clear saved auth session
|
||||
node inject-auth.js --url https://example.com --cookies '[]' --clear true
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--cookies '<json>'` - JSON array of cookie objects (name, value, domain required)
|
||||
- `--token '<token>'` - Bearer token to inject
|
||||
- `--token-key '<key>'` - localStorage key for token (default: access_token)
|
||||
- `--header '<name>'` - HTTP header name for token (e.g., Authorization)
|
||||
- `--local-storage '<json>'` - JSON object of localStorage key-value pairs
|
||||
- `--session-storage '<json>'` - JSON object of sessionStorage key-value pairs
|
||||
- `--reload true` - Reload page after injection to apply auth
|
||||
- `--clear true` - Clear the saved auth session file
|
||||
|
||||
**Session persistence:** Auth is saved to `.auth-session.json` (valid 24h) and automatically applied by subsequent script runs until `--clear true` is used or browser closes.
|
||||
|
||||
### navigate.js
|
||||
Navigate to a URL.
|
||||
|
||||
```bash
|
||||
node navigate.js --url https://example.com [--wait-until networkidle2] [--timeout 30000]
|
||||
```
|
||||
|
||||
### screenshot.js
|
||||
Take a screenshot with automatic compression.
|
||||
|
||||
**Important**: Always save screenshots to `./docs/screenshots` directory.
|
||||
|
||||
```bash
|
||||
node screenshot.js --output screenshot.png [--url https://example.com] [--full-page true] [--selector .element] [--max-size 5] [--no-compress]
|
||||
```
|
||||
|
||||
**Automatic Compression**: Screenshots >5MB are automatically compressed using ImageMagick to ensure compatibility with Gemini API and Claude Code. Install ImageMagick for this feature:
|
||||
- macOS: `brew install imagemagick`
|
||||
- Linux: `sudo apt-get install imagemagick`
|
||||
|
||||
Options:
|
||||
- `--max-size N` - Custom size threshold in MB (default: 5)
|
||||
- `--no-compress` - Disable automatic compression
|
||||
- `--format png|jpeg` - Output format (default: png)
|
||||
- `--quality N` - JPEG quality 0-100 (default: auto)
|
||||
|
||||
### click.js
|
||||
Click an element.
|
||||
|
||||
```bash
|
||||
node click.js --selector ".button" [--url https://example.com] [--wait-for ".result"]
|
||||
```
|
||||
|
||||
### fill.js
|
||||
Fill form fields.
|
||||
|
||||
```bash
|
||||
node fill.js --selector "#input" --value "text" [--url https://example.com] [--clear true]
|
||||
```
|
||||
|
||||
### evaluate.js
|
||||
Execute JavaScript in page context.
|
||||
|
||||
```bash
|
||||
node evaluate.js --script "document.title" [--url https://example.com]
|
||||
```
|
||||
|
||||
### snapshot.js
|
||||
Get DOM snapshot with interactive elements.
|
||||
|
||||
```bash
|
||||
node snapshot.js [--url https://example.com] [--output snapshot.json]
|
||||
```
|
||||
|
||||
### console.js
|
||||
Monitor console messages.
|
||||
|
||||
```bash
|
||||
node console.js --url https://example.com [--types error,warn] [--duration 5000]
|
||||
```
|
||||
|
||||
### network.js
|
||||
Monitor network requests.
|
||||
|
||||
```bash
|
||||
node network.js --url https://example.com [--types xhr,fetch] [--output requests.json]
|
||||
```
|
||||
|
||||
### performance.js
|
||||
Measure performance metrics and record trace.
|
||||
|
||||
```bash
|
||||
node performance.js --url https://example.com [--trace trace.json] [--metrics] [--resources true]
|
||||
```
|
||||
|
||||
### ws-debug.js
|
||||
Debug WebSocket connections (basic mode).
|
||||
|
||||
```bash
|
||||
node ws-debug.js
|
||||
```
|
||||
|
||||
Monitors WebSocket events via CDP: created, handshake, response, closed, error.
|
||||
|
||||
### ws-full-debug.js
|
||||
Debug WebSocket connections with full event tracking.
|
||||
|
||||
```bash
|
||||
node ws-full-debug.js
|
||||
```
|
||||
|
||||
Monitors all WebSocket events including frame sent/received, with detailed logging.
|
||||
|
||||
## Common Options
|
||||
|
||||
- `--headless false` - Show browser window
|
||||
- `--close false` - Keep browser open
|
||||
- `--timeout 30000` - Set timeout in milliseconds
|
||||
- `--wait-until networkidle2` - Wait strategy (load, domcontentloaded, networkidle0, networkidle2)
|
||||
|
||||
## Selector Support
|
||||
|
||||
Scripts that accept `--selector` (click.js, fill.js, screenshot.js) support both **CSS** and **XPath** selectors.
|
||||
|
||||
### CSS Selectors (Default)
|
||||
|
||||
```bash
|
||||
# Element tag
|
||||
node click.js --selector "button" --url https://example.com
|
||||
|
||||
# Class selector
|
||||
node click.js --selector ".btn-submit" --url https://example.com
|
||||
|
||||
# ID selector
|
||||
node fill.js --selector "#email" --value "user@example.com" --url https://example.com
|
||||
|
||||
# Attribute selector
|
||||
node click.js --selector 'button[type="submit"]' --url https://example.com
|
||||
|
||||
# Complex selector
|
||||
node screenshot.js --selector "div.container > button.btn-primary" --output btn.png
|
||||
```
|
||||
|
||||
### XPath Selectors
|
||||
|
||||
XPath selectors start with `/` or `(//` and are automatically detected:
|
||||
|
||||
```bash
|
||||
# Text matching - exact
|
||||
node click.js --selector '//button[text()="Submit"]' --url https://example.com
|
||||
|
||||
# Text matching - contains
|
||||
node click.js --selector '//button[contains(text(),"Submit")]' --url https://example.com
|
||||
|
||||
# Attribute matching
|
||||
node fill.js --selector '//input[@type="email"]' --value "user@example.com"
|
||||
|
||||
# Multiple conditions
|
||||
node click.js --selector '//button[@type="submit" and contains(text(),"Save")]'
|
||||
|
||||
# Descendant selection
|
||||
node screenshot.js --selector '//div[@class="modal"]//button[@class="close"]' --output modal.png
|
||||
|
||||
# Nth element
|
||||
node click.js --selector '(//button)[2]' # Second button on page
|
||||
```
|
||||
|
||||
### Discovering Selectors
|
||||
|
||||
Use `snapshot.js` to discover correct selectors:
|
||||
|
||||
```bash
|
||||
# Get all interactive elements
|
||||
node snapshot.js --url https://example.com | jq '.elements[]'
|
||||
|
||||
# Find buttons
|
||||
node snapshot.js --url https://example.com | jq '.elements[] | select(.tagName=="BUTTON")'
|
||||
|
||||
# Find inputs
|
||||
node snapshot.js --url https://example.com | jq '.elements[] | select(.tagName=="INPUT")'
|
||||
```
|
||||
|
||||
### Security
|
||||
|
||||
XPath selectors are validated to prevent injection attacks. The following patterns are blocked:
|
||||
- `javascript:`
|
||||
- `<script`
|
||||
- `onerror=`, `onload=`, `onclick=`
|
||||
- `eval(`, `Function(`, `constructor(`
|
||||
|
||||
Selectors exceeding 1000 characters are rejected (DoS prevention).
|
||||
|
||||
## Output Format
|
||||
|
||||
All scripts output JSON to stdout:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"url": "https://example.com",
|
||||
"title": "Example Domain",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Errors are output to stderr:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Error message",
|
||||
"stack": "..."
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Tests for error handling in chrome-devtools scripts
|
||||
* Verifies scripts exit with code 1 on errors
|
||||
* Run with: node --test __tests__/error-handling.test.js
|
||||
*
|
||||
* Note: These tests verify exit code behavior. When puppeteer is not installed,
|
||||
* scripts still exit with code 1 (module not found), which validates the error path.
|
||||
* When puppeteer IS installed, missing --url triggers application-level error with code 1.
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const scriptsDir = path.join(__dirname, '..');
|
||||
|
||||
function runScript(script, args = [], timeout = 10000) {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('node', [path.join(scriptsDir, script), ...args], {
|
||||
timeout,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data; });
|
||||
proc.stderr.on('data', (data) => { stderr += data; });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
resolve({ code, stdout, stderr, combined: stdout + stderr });
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({ code: 1, stdout, stderr: err.message, combined: err.message });
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
proc.kill('SIGTERM');
|
||||
resolve({ code: null, stdout, stderr, timedOut: true, combined: stdout + stderr });
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
describe('chrome-devtools error handling', () => {
|
||||
describe('console.js', () => {
|
||||
it('should exit with code 1 when --url is missing or on error', async () => {
|
||||
const result = await runScript('console.js', []);
|
||||
assert.strictEqual(result.code, 1, 'Expected exit code 1');
|
||||
});
|
||||
|
||||
it('should output error information', async () => {
|
||||
const result = await runScript('console.js', []);
|
||||
assert.strictEqual(result.code, 1);
|
||||
// Either app-level error (--url required) or module error (puppeteer not found)
|
||||
const hasError = result.combined.toLowerCase().includes('error') ||
|
||||
result.combined.includes('--url');
|
||||
assert.ok(hasError, 'Expected error in output');
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluate.js', () => {
|
||||
it('should exit with code 1 when --url is missing or on error', async () => {
|
||||
const result = await runScript('evaluate.js', []);
|
||||
assert.strictEqual(result.code, 1, 'Expected exit code 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('navigate.js', () => {
|
||||
it('should exit with code 1 when --url is missing or on error', async () => {
|
||||
const result = await runScript('navigate.js', []);
|
||||
assert.strictEqual(result.code, 1, 'Expected exit code 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('network.js', () => {
|
||||
it('should exit with code 1 when --url is missing or on error', async () => {
|
||||
const result = await runScript('network.js', []);
|
||||
assert.strictEqual(result.code, 1, 'Expected exit code 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance.js', () => {
|
||||
it('should exit with code 1 when --url is missing or on error', async () => {
|
||||
const result = await runScript('performance.js', []);
|
||||
assert.strictEqual(result.code, 1, 'Expected exit code 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('all scripts exit code consistency', () => {
|
||||
const scripts = ['console.js', 'evaluate.js', 'navigate.js', 'network.js', 'performance.js'];
|
||||
|
||||
for (const script of scripts) {
|
||||
it(`${script} should exit 1 on invalid input or error`, async () => {
|
||||
const result = await runScript(script, []);
|
||||
assert.strictEqual(result.code, 1, `${script} should exit with code 1`);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Tests for selector parsing library
|
||||
* Run with: node --test __tests__/selector.test.js
|
||||
*/
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { parseSelector } from '../lib/selector.js';
|
||||
|
||||
describe('parseSelector', () => {
|
||||
describe('CSS Selectors', () => {
|
||||
it('should detect simple CSS selectors', () => {
|
||||
const result = parseSelector('button');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
assert.strictEqual(result.selector, 'button');
|
||||
});
|
||||
|
||||
it('should detect class selectors', () => {
|
||||
const result = parseSelector('.btn-submit');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
assert.strictEqual(result.selector, '.btn-submit');
|
||||
});
|
||||
|
||||
it('should detect ID selectors', () => {
|
||||
const result = parseSelector('#email-input');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
assert.strictEqual(result.selector, '#email-input');
|
||||
});
|
||||
|
||||
it('should detect attribute selectors', () => {
|
||||
const result = parseSelector('button[type="submit"]');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
assert.strictEqual(result.selector, 'button[type="submit"]');
|
||||
});
|
||||
|
||||
it('should detect complex CSS selectors', () => {
|
||||
const result = parseSelector('div.container > button.btn-primary:hover');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
});
|
||||
});
|
||||
|
||||
describe('XPath Selectors', () => {
|
||||
it('should detect absolute XPath', () => {
|
||||
const result = parseSelector('/html/body/button');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
assert.strictEqual(result.selector, '/html/body/button');
|
||||
});
|
||||
|
||||
it('should detect relative XPath', () => {
|
||||
const result = parseSelector('//button');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
assert.strictEqual(result.selector, '//button');
|
||||
});
|
||||
|
||||
it('should detect XPath with text matching', () => {
|
||||
const result = parseSelector('//button[text()="Click Me"]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should detect XPath with contains', () => {
|
||||
const result = parseSelector('//button[contains(text(),"Submit")]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should detect XPath with attributes', () => {
|
||||
const result = parseSelector('//input[@type="email"]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should detect grouped XPath', () => {
|
||||
const result = parseSelector('(//button)[1]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Validation', () => {
|
||||
it('should block javascript: injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//button[@onclick="javascript:alert(1)"]'),
|
||||
/XPath injection detected.*javascript:/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block <script tag injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//div[contains(text(),"<script>alert(1)</script>")]'),
|
||||
/XPath injection detected.*<script/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block onerror= injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//img[@onerror="alert(1)"]'),
|
||||
/XPath injection detected.*onerror=/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block onload= injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//body[@onload="malicious()"]'),
|
||||
/XPath injection detected.*onload=/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block onclick= injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//a[@onclick="steal()"]'),
|
||||
/XPath injection detected.*onclick=/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block eval( injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//div[eval("malicious")]'),
|
||||
/XPath injection detected.*eval\(/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block Function( injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//div[Function("return 1")()]'),
|
||||
/XPath injection detected.*Function\(/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block constructor( injection', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//div[constructor("alert(1)")()]'),
|
||||
/XPath injection detected.*constructor\(/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should be case-insensitive for security checks', () => {
|
||||
assert.throws(
|
||||
() => parseSelector('//div[@ONERROR="alert(1)"]'),
|
||||
/XPath injection detected/i
|
||||
);
|
||||
});
|
||||
|
||||
it('should block extremely long selectors (DoS prevention)', () => {
|
||||
const longSelector = '//' + 'a'.repeat(1001);
|
||||
assert.throws(
|
||||
() => parseSelector(longSelector),
|
||||
/XPath selector too long/i
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should throw on empty string', () => {
|
||||
assert.throws(
|
||||
() => parseSelector(''),
|
||||
/Selector must be a non-empty string/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on null', () => {
|
||||
assert.throws(
|
||||
() => parseSelector(null),
|
||||
/Selector must be a non-empty string/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on undefined', () => {
|
||||
assert.throws(
|
||||
() => parseSelector(undefined),
|
||||
/Selector must be a non-empty string/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw on non-string input', () => {
|
||||
assert.throws(
|
||||
() => parseSelector(123),
|
||||
/Selector must be a non-empty string/
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle selectors with special characters', () => {
|
||||
const result = parseSelector('button[data-test="submit-form"]');
|
||||
assert.strictEqual(result.type, 'css');
|
||||
});
|
||||
|
||||
it('should allow safe XPath with parentheses', () => {
|
||||
const result = parseSelector('//button[contains(text(),"Save")]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
// Should not throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('Real-World Examples', () => {
|
||||
it('should handle common button selector', () => {
|
||||
const result = parseSelector('//button[contains(text(),"Submit")]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should handle complex form selector', () => {
|
||||
const result = parseSelector('//form[@id="login-form"]//input[@type="email"]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should handle descendant selector', () => {
|
||||
const result = parseSelector('//div[@class="modal"]//button[@class="close"]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
|
||||
it('should handle nth-child equivalent', () => {
|
||||
const result = parseSelector('(//li)[3]');
|
||||
assert.strictEqual(result.type, 'xpath');
|
||||
});
|
||||
});
|
||||
});
|
||||
363
.opencode/skills/chrome-devtools/scripts/aria-snapshot.js
Executable file
363
.opencode/skills/chrome-devtools/scripts/aria-snapshot.js
Executable file
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get ARIA-based accessibility snapshot with stable element refs
|
||||
* Usage: node aria-snapshot.js [--url https://example.com] [--output snapshot.yaml]
|
||||
*
|
||||
* Returns YAML-formatted accessibility tree with:
|
||||
* - Semantic roles (button, link, textbox, heading, etc.)
|
||||
* - Accessible names (what screen readers announce)
|
||||
* - Element states (checked, disabled, expanded)
|
||||
* - Stable refs [ref=eN] that persist for interaction
|
||||
*
|
||||
* Session behavior:
|
||||
* By default, browser stays running for session persistence
|
||||
* Use --close true to fully close browser
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Get ARIA snapshot script to inject into page
|
||||
* Builds YAML-formatted accessibility tree with element references
|
||||
*/
|
||||
function getAriaSnapshotScript() {
|
||||
return `
|
||||
(function() {
|
||||
// Store refs on window for later retrieval via selectRef
|
||||
window.__chromeDevToolsRefs = window.__chromeDevToolsRefs || new Map();
|
||||
let refCounter = window.__chromeDevToolsRefCounter || 1;
|
||||
|
||||
// ARIA roles we care about for interaction
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
||||
'listbox', 'option', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
||||
'tab', 'switch', 'slider', 'spinbutton', 'searchbox', 'tree', 'treeitem',
|
||||
'grid', 'gridcell', 'row', 'rowheader', 'columnheader'
|
||||
]);
|
||||
|
||||
// Landmark roles for structure
|
||||
const LANDMARK_ROLES = new Set([
|
||||
'banner', 'navigation', 'main', 'complementary', 'contentinfo',
|
||||
'search', 'form', 'region', 'article', 'dialog', 'alertdialog'
|
||||
]);
|
||||
|
||||
// Implicit ARIA roles from HTML elements
|
||||
const IMPLICIT_ROLES = {
|
||||
'A': (el) => el.href ? 'link' : null,
|
||||
'BUTTON': () => 'button',
|
||||
'INPUT': (el) => {
|
||||
const type = el.type?.toLowerCase();
|
||||
if (type === 'checkbox') return 'checkbox';
|
||||
if (type === 'radio') return 'radio';
|
||||
if (type === 'submit' || type === 'button' || type === 'reset') return 'button';
|
||||
if (type === 'search') return 'searchbox';
|
||||
if (type === 'range') return 'slider';
|
||||
if (type === 'number') return 'spinbutton';
|
||||
return 'textbox';
|
||||
},
|
||||
'TEXTAREA': () => 'textbox',
|
||||
'SELECT': () => 'combobox',
|
||||
'OPTION': () => 'option',
|
||||
'IMG': () => 'img',
|
||||
'NAV': () => 'navigation',
|
||||
'MAIN': () => 'main',
|
||||
'HEADER': () => 'banner',
|
||||
'FOOTER': () => 'contentinfo',
|
||||
'ASIDE': () => 'complementary',
|
||||
'ARTICLE': () => 'article',
|
||||
'SECTION': (el) => el.getAttribute('aria-label') || el.getAttribute('aria-labelledby') ? 'region' : null,
|
||||
'FORM': () => 'form',
|
||||
'UL': () => 'list',
|
||||
'OL': () => 'list',
|
||||
'LI': () => 'listitem',
|
||||
'H1': () => 'heading',
|
||||
'H2': () => 'heading',
|
||||
'H3': () => 'heading',
|
||||
'H4': () => 'heading',
|
||||
'H5': () => 'heading',
|
||||
'H6': () => 'heading',
|
||||
'TABLE': () => 'table',
|
||||
'TR': () => 'row',
|
||||
'TH': () => 'columnheader',
|
||||
'TD': () => 'cell',
|
||||
'DIALOG': () => 'dialog'
|
||||
};
|
||||
|
||||
function getRole(el) {
|
||||
// Explicit role takes precedence
|
||||
const explicitRole = el.getAttribute('role');
|
||||
if (explicitRole) return explicitRole;
|
||||
|
||||
// Check implicit role
|
||||
const implicitFn = IMPLICIT_ROLES[el.tagName];
|
||||
if (implicitFn) return implicitFn(el);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getAccessibleName(el) {
|
||||
// aria-label takes precedence
|
||||
const ariaLabel = el.getAttribute('aria-label');
|
||||
if (ariaLabel) return ariaLabel.trim();
|
||||
|
||||
// aria-labelledby
|
||||
const labelledBy = el.getAttribute('aria-labelledby');
|
||||
if (labelledBy) {
|
||||
const labels = labelledBy.split(' ')
|
||||
.map(id => document.getElementById(id)?.textContent?.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
if (labels) return labels;
|
||||
}
|
||||
|
||||
// Input associated label
|
||||
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') {
|
||||
if (el.id) {
|
||||
const label = document.querySelector('label[for="' + el.id + '"]');
|
||||
if (label) return label.textContent?.trim();
|
||||
}
|
||||
// Check parent label
|
||||
const parentLabel = el.closest('label');
|
||||
if (parentLabel) {
|
||||
const labelText = parentLabel.textContent?.replace(el.value || '', '')?.trim();
|
||||
if (labelText) return labelText;
|
||||
}
|
||||
}
|
||||
|
||||
// Button/link content
|
||||
if (el.tagName === 'BUTTON' || el.tagName === 'A') {
|
||||
const text = el.textContent?.trim();
|
||||
if (text) return text.substring(0, 100);
|
||||
}
|
||||
|
||||
// Alt text for images
|
||||
if (el.tagName === 'IMG') {
|
||||
return el.alt || null;
|
||||
}
|
||||
|
||||
// Title attribute fallback
|
||||
if (el.title) return el.title.trim();
|
||||
|
||||
// Placeholder for inputs
|
||||
if (el.placeholder) return null; // Return null, will add as /placeholder
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStateFlags(el) {
|
||||
const flags = [];
|
||||
|
||||
// Checked state
|
||||
if (el.checked || el.getAttribute('aria-checked') === 'true') {
|
||||
flags.push('checked');
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
if (el.disabled || el.getAttribute('aria-disabled') === 'true') {
|
||||
flags.push('disabled');
|
||||
}
|
||||
|
||||
// Expanded state
|
||||
if (el.getAttribute('aria-expanded') === 'true') {
|
||||
flags.push('expanded');
|
||||
}
|
||||
|
||||
// Selected state
|
||||
if (el.selected || el.getAttribute('aria-selected') === 'true') {
|
||||
flags.push('selected');
|
||||
}
|
||||
|
||||
// Pressed state
|
||||
if (el.getAttribute('aria-pressed') === 'true') {
|
||||
flags.push('pressed');
|
||||
}
|
||||
|
||||
// Required state
|
||||
if (el.required || el.getAttribute('aria-required') === 'true') {
|
||||
flags.push('required');
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function isVisible(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
}
|
||||
|
||||
function isInteractiveOrLandmark(role) {
|
||||
return INTERACTIVE_ROLES.has(role) || LANDMARK_ROLES.has(role);
|
||||
}
|
||||
|
||||
function shouldInclude(el) {
|
||||
if (!isVisible(el)) return false;
|
||||
const role = getRole(el);
|
||||
if (!role) return false;
|
||||
// Include interactive, landmarks, and structural elements
|
||||
return isInteractiveOrLandmark(role) ||
|
||||
role === 'heading' ||
|
||||
role === 'img' ||
|
||||
role === 'list' ||
|
||||
role === 'listitem' ||
|
||||
role === 'table' ||
|
||||
role === 'row' ||
|
||||
role === 'cell' ||
|
||||
role === 'columnheader';
|
||||
}
|
||||
|
||||
function assignRef(el, role) {
|
||||
// Only assign refs to interactive elements
|
||||
if (!INTERACTIVE_ROLES.has(role)) return null;
|
||||
|
||||
const ref = 'e' + refCounter++;
|
||||
window.__chromeDevToolsRefs.set(ref, el);
|
||||
return ref;
|
||||
}
|
||||
|
||||
function buildYaml(el, indent = 0) {
|
||||
const role = getRole(el);
|
||||
if (!role) return '';
|
||||
|
||||
const prefix = ' '.repeat(indent) + '- ';
|
||||
const lines = [];
|
||||
|
||||
// Build the line: role "name" [flags] [ref=eN]
|
||||
let line = prefix + role;
|
||||
|
||||
const name = getAccessibleName(el);
|
||||
if (name) {
|
||||
line += ' "' + name.replace(/"/g, '\\\\"') + '"';
|
||||
}
|
||||
|
||||
// Add heading level
|
||||
if (role === 'heading') {
|
||||
const level = el.tagName.match(/H(\\d)/)?.[1] || el.getAttribute('aria-level');
|
||||
if (level) line += ' [level=' + level + ']';
|
||||
}
|
||||
|
||||
// Add state flags
|
||||
const flags = getStateFlags(el);
|
||||
flags.forEach(flag => {
|
||||
line += ' [' + flag + ']';
|
||||
});
|
||||
|
||||
// Add ref for interactive elements
|
||||
const ref = assignRef(el, role);
|
||||
if (ref) {
|
||||
line += ' [ref=' + ref + ']';
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
|
||||
// Add metadata on subsequent lines
|
||||
if (el.tagName === 'A' && el.href) {
|
||||
lines.push(' '.repeat(indent + 1) + '/url: ' + el.href);
|
||||
}
|
||||
if (el.placeholder) {
|
||||
lines.push(' '.repeat(indent + 1) + '/placeholder: "' + el.placeholder + '"');
|
||||
}
|
||||
if (el.tagName === 'INPUT' && el.value && el.type !== 'password') {
|
||||
lines.push(' '.repeat(indent + 1) + '/value: "' + el.value.substring(0, 50) + '"');
|
||||
}
|
||||
|
||||
// Process children
|
||||
const children = Array.from(el.children);
|
||||
children.forEach(child => {
|
||||
const childYaml = buildYaml(child, indent + 1);
|
||||
if (childYaml) lines.push(childYaml);
|
||||
});
|
||||
|
||||
return lines.join('\\n');
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
const lines = [];
|
||||
|
||||
// Start from body
|
||||
const children = Array.from(document.body.children);
|
||||
children.forEach(child => {
|
||||
const yaml = buildYaml(child, 0);
|
||||
if (yaml) lines.push(yaml);
|
||||
});
|
||||
|
||||
// Save ref counter for next snapshot
|
||||
window.__chromeDevToolsRefCounter = refCounter;
|
||||
|
||||
return lines.join('\\n');
|
||||
}
|
||||
|
||||
return getSnapshot();
|
||||
})();
|
||||
`;
|
||||
}
|
||||
|
||||
async function ariaSnapshot() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
// Get ARIA snapshot
|
||||
const snapshot = await page.evaluate(getAriaSnapshotScript());
|
||||
|
||||
// Build result
|
||||
const result = {
|
||||
success: true,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
format: 'yaml',
|
||||
snapshot: snapshot
|
||||
};
|
||||
|
||||
// Output to file or stdout
|
||||
if (args.output) {
|
||||
const outputPath = args.output;
|
||||
|
||||
// Ensure snapshots directory exists
|
||||
const outputDir = path.dirname(outputPath);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Write YAML snapshot
|
||||
await fs.writeFile(outputPath, snapshot, 'utf8');
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
output: path.resolve(outputPath),
|
||||
url: page.url()
|
||||
});
|
||||
} else {
|
||||
// Output to stdout
|
||||
outputJSON(result);
|
||||
}
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
ariaSnapshot();
|
||||
84
.opencode/skills/chrome-devtools/scripts/click.js
Executable file
84
.opencode/skills/chrome-devtools/scripts/click.js
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Click an element
|
||||
* Usage: node click.js --selector ".button" [--url https://example.com] [--wait-for ".result"]
|
||||
* Supports both CSS and XPath selectors:
|
||||
* - CSS: node click.js --selector "button.submit"
|
||||
* - XPath: node click.js --selector "//button[contains(text(),'Submit')]"
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import { parseSelector, waitForElement, clickElement, enhanceError } from './lib/selector.js';
|
||||
|
||||
async function click() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.selector) {
|
||||
outputError(new Error('--selector is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate selector
|
||||
const parsed = parseSelector(args.selector);
|
||||
|
||||
// Wait for element based on selector type
|
||||
await waitForElement(page, parsed, {
|
||||
visible: true,
|
||||
timeout: parseInt(args.timeout || '5000')
|
||||
});
|
||||
|
||||
// Set up navigation promise BEFORE clicking (in case click triggers immediate navigation)
|
||||
const navigationPromise = page.waitForNavigation({
|
||||
waitUntil: 'load',
|
||||
timeout: 5000
|
||||
}).catch(() => null); // Catch timeout - navigation may not occur
|
||||
|
||||
// Click element
|
||||
await clickElement(page, parsed);
|
||||
|
||||
// Wait for optional selector after click
|
||||
if (args['wait-for']) {
|
||||
await page.waitForSelector(args['wait-for'], {
|
||||
timeout: parseInt(args.timeout || '5000')
|
||||
});
|
||||
} else {
|
||||
// Wait for navigation to complete (or timeout if no navigation)
|
||||
await navigationPromise;
|
||||
}
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
url: page.url(),
|
||||
title: await page.title()
|
||||
});
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// Enhance error message with troubleshooting tips
|
||||
const enhanced = enhanceError(error, args.selector);
|
||||
outputError(enhanced);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
click();
|
||||
146
.opencode/skills/chrome-devtools/scripts/connect-chrome.js
Normal file
146
.opencode/skills/chrome-devtools/scripts/connect-chrome.js
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Connect to an existing Chrome browser launched with remote debugging
|
||||
*
|
||||
* Two-step workflow:
|
||||
* 1. User launches Chrome with: chrome --remote-debugging-port=9222
|
||||
* 2. Connect with this script: node connect-chrome.js --browser-url http://localhost:9222
|
||||
*
|
||||
* Or launch Chrome automatically:
|
||||
* node connect-chrome.js --launch --port 9222
|
||||
*
|
||||
* This is useful for:
|
||||
* - Debugging (can see browser window while scripts run)
|
||||
* - Using existing Chrome session with all logged-in accounts
|
||||
* - Avoiding Puppeteer's bundled Chromium
|
||||
*/
|
||||
import { spawn } from 'child_process';
|
||||
import { getBrowser, getPage, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
|
||||
/**
|
||||
* Get Chrome executable path based on OS
|
||||
* @returns {string} - Path to Chrome executable
|
||||
*/
|
||||
function getChromeExecutablePath() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
case 'win32':
|
||||
// Try common installation paths
|
||||
const paths = [
|
||||
`${process.env['PROGRAMFILES']}/Google/Chrome/Application/chrome.exe`,
|
||||
`${process.env['PROGRAMFILES(X86)']}/Google/Chrome/Application/chrome.exe`,
|
||||
`${process.env.LOCALAPPDATA}/Google/Chrome/Application/chrome.exe`
|
||||
];
|
||||
// Return first path (user should have Chrome installed in standard location)
|
||||
return paths[0];
|
||||
default: // Linux
|
||||
return 'google-chrome';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Chrome with remote debugging enabled
|
||||
* @param {number} port - Debug port (default 9222)
|
||||
* @returns {Promise<ChildProcess>}
|
||||
*/
|
||||
function launchChromeWithDebugging(port = 9222) {
|
||||
const chromePath = getChromeExecutablePath();
|
||||
const args = [
|
||||
`--remote-debugging-port=${port}`,
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check'
|
||||
];
|
||||
|
||||
const chrome = spawn(chromePath, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore'
|
||||
});
|
||||
|
||||
chrome.unref();
|
||||
return chrome;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for Chrome debug endpoint to be ready
|
||||
* @param {string} browserUrl - Browser debug URL
|
||||
* @param {number} timeout - Max wait time in ms
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function waitForDebugEndpoint(browserUrl, timeout = 10000) {
|
||||
const start = Date.now();
|
||||
const checkUrl = `${browserUrl}/json/version`;
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
const response = await fetch(checkUrl);
|
||||
if (response.ok) return true;
|
||||
} catch {
|
||||
// Not ready yet
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function connectChrome() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const port = parseInt(args.port || '9222');
|
||||
const browserUrl = args['browser-url'] || `http://localhost:${port}`;
|
||||
|
||||
try {
|
||||
// Launch Chrome if requested
|
||||
if (args.launch) {
|
||||
launchChromeWithDebugging(port);
|
||||
|
||||
// Wait for debug endpoint
|
||||
const ready = await waitForDebugEndpoint(browserUrl);
|
||||
if (!ready) {
|
||||
outputError(new Error(`Chrome did not start within timeout. Check if port ${port} is available.`));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Connect to Chrome via browserUrl
|
||||
const browser = await getBrowser({ browserUrl });
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2',
|
||||
timeout: parseInt(args.timeout || '30000')
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
browserUrl,
|
||||
connected: true,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
hint: args.launch
|
||||
? 'Chrome launched with debugging. Browser window is visible.'
|
||||
: 'Connected to existing Chrome instance.'
|
||||
};
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running
|
||||
await disconnectBrowser();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// Provide helpful error message
|
||||
if (error.message.includes('ECONNREFUSED')) {
|
||||
outputError(new Error(
|
||||
`Could not connect to Chrome at ${browserUrl}. ` +
|
||||
`Make sure Chrome is running with: ` +
|
||||
`chrome --remote-debugging-port=${port}`
|
||||
));
|
||||
} else {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectChrome();
|
||||
81
.opencode/skills/chrome-devtools/scripts/console.js
Executable file
81
.opencode/skills/chrome-devtools/scripts/console.js
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Monitor console messages
|
||||
* Usage: node console.js --url https://example.com [--types error,warn] [--duration 5000]
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
|
||||
async function monitorConsole() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
const messages = [];
|
||||
const filterTypes = args.types ? args.types.split(',') : null;
|
||||
|
||||
// Listen for console messages
|
||||
page.on('console', (msg) => {
|
||||
const type = msg.type();
|
||||
|
||||
if (!filterTypes || filterTypes.includes(type)) {
|
||||
messages.push({
|
||||
type: type,
|
||||
text: msg.text(),
|
||||
location: msg.location(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for page errors
|
||||
page.on('pageerror', (error) => {
|
||||
messages.push({
|
||||
type: 'pageerror',
|
||||
text: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Navigate
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
|
||||
// Wait for additional time if specified
|
||||
if (args.duration) {
|
||||
await new Promise(resolve => setTimeout(resolve, parseInt(args.duration)));
|
||||
}
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
url: page.url(),
|
||||
messageCount: messages.length,
|
||||
messages: messages
|
||||
});
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
monitorConsole();
|
||||
56
.opencode/skills/chrome-devtools/scripts/evaluate.js
Executable file
56
.opencode/skills/chrome-devtools/scripts/evaluate.js
Executable file
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Execute JavaScript in page context
|
||||
* Usage: node evaluate.js --script "document.title" [--url https://example.com]
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
|
||||
async function evaluate() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.script) {
|
||||
outputError(new Error('--script is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await page.evaluate(async (script) => {
|
||||
// Wrap in async IIFE so user scripts can use await
|
||||
// eslint-disable-next-line no-eval
|
||||
return await eval(`(async () => { return ${script}; })()`);
|
||||
}, args.script);
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
result: result,
|
||||
url: page.url()
|
||||
});
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
evaluate();
|
||||
77
.opencode/skills/chrome-devtools/scripts/fill.js
Executable file
77
.opencode/skills/chrome-devtools/scripts/fill.js
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fill form fields
|
||||
* Usage: node fill.js --selector "#input" --value "text" [--url https://example.com]
|
||||
* Supports both CSS and XPath selectors:
|
||||
* - CSS: node fill.js --selector "#email" --value "user@example.com"
|
||||
* - XPath: node fill.js --selector "//input[@type='email']" --value "user@example.com"
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import { parseSelector, waitForElement, typeIntoElement, enhanceError } from './lib/selector.js';
|
||||
|
||||
async function fill() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.selector) {
|
||||
outputError(new Error('--selector is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.value) {
|
||||
outputError(new Error('--value is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
// Parse and validate selector
|
||||
const parsed = parseSelector(args.selector);
|
||||
|
||||
// Wait for element based on selector type
|
||||
await waitForElement(page, parsed, {
|
||||
visible: true,
|
||||
timeout: parseInt(args.timeout || '5000')
|
||||
});
|
||||
|
||||
// Type into element
|
||||
await typeIntoElement(page, parsed, args.value, {
|
||||
clear: args.clear === 'true',
|
||||
delay: parseInt(args.delay || '0')
|
||||
});
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
selector: args.selector,
|
||||
value: args.value,
|
||||
url: page.url()
|
||||
});
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// Enhance error message with troubleshooting tips
|
||||
const enhanced = enhanceError(error, args.selector);
|
||||
outputError(enhanced);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fill();
|
||||
205
.opencode/skills/chrome-devtools/scripts/import-cookies.js
Normal file
205
.opencode/skills/chrome-devtools/scripts/import-cookies.js
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Import cookies from JSON file exported by browser extensions
|
||||
* Supports: EditThisCookie, Cookie-Editor, Netscape (txt) formats
|
||||
*
|
||||
* Usage:
|
||||
* node import-cookies.js --file ./cookies.json --url https://example.com
|
||||
* node import-cookies.js --file ./cookies.txt --format netscape --url https://example.com
|
||||
*
|
||||
* Workflow:
|
||||
* 1. Install "Cookie-Editor" or "EditThisCookie" Chrome extension
|
||||
* 2. Navigate to target site and log in manually
|
||||
* 3. Export cookies as JSON via extension
|
||||
* 4. Run this script to import into puppeteer session
|
||||
* 5. Use other scripts (screenshot, navigate) with authenticated session
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError, saveAuthSession } from './lib/browser.js';
|
||||
|
||||
/**
|
||||
* Parse cookies from EditThisCookie/Cookie-Editor JSON format
|
||||
* @param {Array} cookies - Array of cookie objects
|
||||
* @returns {Array} - Normalized cookie array for Puppeteer
|
||||
*/
|
||||
function parseJsonCookies(cookies) {
|
||||
return cookies.map(cookie => {
|
||||
// Handle different property names from various extensions
|
||||
const normalized = {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path || '/',
|
||||
httpOnly: cookie.httpOnly ?? false,
|
||||
secure: cookie.secure ?? false,
|
||||
sameSite: cookie.sameSite || 'Lax'
|
||||
};
|
||||
|
||||
// Handle expiration (different extensions use different names)
|
||||
if (cookie.expirationDate) {
|
||||
normalized.expires = cookie.expirationDate;
|
||||
} else if (cookie.expires) {
|
||||
normalized.expires = typeof cookie.expires === 'number'
|
||||
? cookie.expires
|
||||
: new Date(cookie.expires).getTime() / 1000;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Netscape cookie file format (used by curl, wget, etc.)
|
||||
* Format: domain\tflags\tpath\tsecure\texpiration\tname\tvalue
|
||||
* @param {string} content - Netscape format cookie file content
|
||||
* @returns {Array} - Normalized cookie array for Puppeteer
|
||||
*/
|
||||
function parseNetscapeCookies(content) {
|
||||
const cookies = [];
|
||||
const lines = content.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
// Skip comments and empty lines
|
||||
if (line.startsWith('#') || line.trim() === '') continue;
|
||||
|
||||
const parts = line.split('\t');
|
||||
if (parts.length < 7) continue;
|
||||
|
||||
const [domain, , path, secure, expires, name, value] = parts;
|
||||
|
||||
cookies.push({
|
||||
name: name.trim(),
|
||||
value: value.trim(),
|
||||
domain: domain.trim(),
|
||||
path: path.trim() || '/',
|
||||
secure: secure.toUpperCase() === 'TRUE',
|
||||
httpOnly: false, // Netscape format doesn't include httpOnly
|
||||
expires: parseInt(expires, 10) || undefined,
|
||||
sameSite: 'Lax'
|
||||
});
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect cookie file format from content
|
||||
* @param {string} content - File content
|
||||
* @returns {string} - 'json' or 'netscape'
|
||||
*/
|
||||
function detectFormat(content) {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
|
||||
return 'json';
|
||||
}
|
||||
return 'netscape';
|
||||
}
|
||||
|
||||
async function importCookies() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.file) {
|
||||
outputError(new Error('--file is required (path to cookies file)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required (target URL to apply cookies)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Read cookie file
|
||||
let fileContent;
|
||||
try {
|
||||
fileContent = fs.readFileSync(args.file, 'utf8');
|
||||
} catch (e) {
|
||||
outputError(new Error(`Failed to read cookie file: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse cookies based on format
|
||||
const format = args.format || detectFormat(fileContent);
|
||||
let cookies;
|
||||
|
||||
try {
|
||||
if (format === 'json') {
|
||||
const parsed = JSON.parse(fileContent);
|
||||
// Handle both array and object with cookies property
|
||||
const cookieArray = Array.isArray(parsed) ? parsed : (parsed.cookies || []);
|
||||
cookies = parseJsonCookies(cookieArray);
|
||||
} else {
|
||||
cookies = parseNetscapeCookies(fileContent);
|
||||
}
|
||||
} catch (e) {
|
||||
outputError(new Error(`Failed to parse cookies (${format}): ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (cookies.length === 0) {
|
||||
outputError(new Error('No valid cookies found in file'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate to URL first to establish domain context
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2',
|
||||
timeout: parseInt(args.timeout || '30000')
|
||||
});
|
||||
|
||||
// Filter cookies by domain if --strict-domain is set
|
||||
let cookiesToApply = cookies;
|
||||
if (args['strict-domain']) {
|
||||
const urlDomain = new URL(args.url).hostname;
|
||||
cookiesToApply = cookies.filter(c => {
|
||||
const cookieDomain = c.domain.startsWith('.') ? c.domain.slice(1) : c.domain;
|
||||
return urlDomain.endsWith(cookieDomain);
|
||||
});
|
||||
}
|
||||
|
||||
// Apply cookies
|
||||
await page.setCookie(...cookiesToApply);
|
||||
|
||||
// Save to auth session for persistence
|
||||
saveAuthSession({ cookies: cookiesToApply });
|
||||
|
||||
// Reload to apply cookies if --reload is set
|
||||
if (args.reload === 'true') {
|
||||
await page.reload({ waitUntil: 'networkidle2' });
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
file: args.file,
|
||||
format,
|
||||
url: args.url,
|
||||
imported: {
|
||||
total: cookiesToApply.length,
|
||||
names: cookiesToApply.map(c => c.name)
|
||||
},
|
||||
persisted: true,
|
||||
finalUrl: page.url(),
|
||||
title: await page.title()
|
||||
};
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
importCookies();
|
||||
230
.opencode/skills/chrome-devtools/scripts/inject-auth.js
Executable file
230
.opencode/skills/chrome-devtools/scripts/inject-auth.js
Executable file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Inject authentication cookies/tokens into browser session
|
||||
* Usage: node inject-auth.js --url https://example.com --cookies '[{"name":"token","value":"xxx","domain":".example.com"}]'
|
||||
* node inject-auth.js --url https://example.com --token "Bearer xxx" [--header Authorization]
|
||||
* node inject-auth.js --url https://example.com --local-storage '{"key":"value"}'
|
||||
* node inject-auth.js --url https://example.com --session-storage '{"key":"value"}'
|
||||
*
|
||||
* This script injects authentication data into browser session for testing protected routes.
|
||||
* The session persists across script executions until --close true is used.
|
||||
*
|
||||
* Workflow for testing protected routes:
|
||||
* 1. User manually logs into the site in their browser
|
||||
* 2. User extracts cookies/tokens from browser DevTools
|
||||
* 3. Run this script to inject auth into puppeteer session
|
||||
* 4. Run other scripts (screenshot, navigate, etc.) which will use authenticated session
|
||||
*
|
||||
* Session behavior:
|
||||
* --close false : Keep browser running (default for chaining)
|
||||
* --close true : Close browser completely and clear session
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError, saveAuthSession, clearAuthSession } from './lib/browser.js';
|
||||
|
||||
/**
|
||||
* Parse cookies from JSON string or file
|
||||
* @param {string} cookiesInput - JSON string or file path
|
||||
* @returns {Array} - Array of cookie objects
|
||||
*/
|
||||
function parseCookies(cookiesInput) {
|
||||
try {
|
||||
// Try parsing as JSON string
|
||||
return JSON.parse(cookiesInput);
|
||||
} catch {
|
||||
throw new Error(`Invalid cookies format. Expected JSON array: [{"name":"cookie_name","value":"cookie_value","domain":".example.com"}]`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage data from JSON string
|
||||
* @param {string} storageInput - JSON string
|
||||
* @returns {Object} - Storage key-value pairs
|
||||
*/
|
||||
function parseStorage(storageInput) {
|
||||
try {
|
||||
return JSON.parse(storageInput);
|
||||
} catch {
|
||||
throw new Error(`Invalid storage format. Expected JSON object: {"key":"value"}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function injectAuth() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required (base URL for the protected site)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate at least one auth method provided
|
||||
if (!args.cookies && !args.token && !args['local-storage'] && !args['session-storage']) {
|
||||
outputError(new Error('At least one auth method required: --cookies, --token, --local-storage, or --session-storage'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate to the URL first to set the domain context
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2',
|
||||
timeout: parseInt(args.timeout || '30000')
|
||||
});
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url: args.url,
|
||||
injected: []
|
||||
};
|
||||
|
||||
// Inject cookies
|
||||
if (args.cookies) {
|
||||
const cookies = parseCookies(args.cookies);
|
||||
|
||||
// Validate and normalize cookies
|
||||
const normalizedCookies = cookies.map(cookie => {
|
||||
if (!cookie.name || !cookie.value) {
|
||||
throw new Error(`Cookie must have 'name' and 'value' properties`);
|
||||
}
|
||||
|
||||
// Extract domain from URL if not provided
|
||||
if (!cookie.domain) {
|
||||
const urlObj = new URL(args.url);
|
||||
cookie.domain = urlObj.hostname;
|
||||
}
|
||||
|
||||
return {
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path || '/',
|
||||
httpOnly: cookie.httpOnly !== undefined ? cookie.httpOnly : false,
|
||||
secure: cookie.secure !== undefined ? cookie.secure : args.url.startsWith('https'),
|
||||
sameSite: cookie.sameSite || 'Lax',
|
||||
...(cookie.expires && { expires: cookie.expires })
|
||||
};
|
||||
});
|
||||
|
||||
await page.setCookie(...normalizedCookies);
|
||||
result.injected.push({
|
||||
type: 'cookies',
|
||||
count: normalizedCookies.length,
|
||||
names: normalizedCookies.map(c => c.name)
|
||||
});
|
||||
}
|
||||
|
||||
// Inject Bearer token via localStorage (common pattern)
|
||||
if (args.token) {
|
||||
const tokenKey = args['token-key'] || 'access_token';
|
||||
const token = args.token.startsWith('Bearer ') ? args.token.slice(7) : args.token;
|
||||
|
||||
await page.evaluate((key, value) => {
|
||||
localStorage.setItem(key, value);
|
||||
}, tokenKey, token);
|
||||
|
||||
result.injected.push({
|
||||
type: 'token',
|
||||
key: tokenKey,
|
||||
storage: 'localStorage'
|
||||
});
|
||||
|
||||
// Also set Authorization header for future requests if header option provided
|
||||
if (args.header) {
|
||||
await page.setExtraHTTPHeaders({
|
||||
[args.header]: args.token.startsWith('Bearer ') ? args.token : `Bearer ${args.token}`
|
||||
});
|
||||
result.injected.push({
|
||||
type: 'header',
|
||||
name: args.header
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inject localStorage items
|
||||
if (args['local-storage']) {
|
||||
const storageData = parseStorage(args['local-storage']);
|
||||
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, storageData);
|
||||
|
||||
result.injected.push({
|
||||
type: 'localStorage',
|
||||
keys: Object.keys(storageData)
|
||||
});
|
||||
}
|
||||
|
||||
// Inject sessionStorage items
|
||||
if (args['session-storage']) {
|
||||
const storageData = parseStorage(args['session-storage']);
|
||||
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, storageData);
|
||||
|
||||
result.injected.push({
|
||||
type: 'sessionStorage',
|
||||
keys: Object.keys(storageData)
|
||||
});
|
||||
}
|
||||
|
||||
// Reload page to apply auth (optional, use --reload true)
|
||||
if (args.reload === 'true') {
|
||||
await page.reload({ waitUntil: 'networkidle2' });
|
||||
result.reloaded = true;
|
||||
}
|
||||
|
||||
// Save auth session to file for persistence across script executions
|
||||
const authSessionData = {};
|
||||
|
||||
if (args.cookies) {
|
||||
authSessionData.cookies = parseCookies(args.cookies);
|
||||
}
|
||||
if (args['local-storage']) {
|
||||
authSessionData.localStorage = parseStorage(args['local-storage']);
|
||||
}
|
||||
if (args['session-storage']) {
|
||||
authSessionData.sessionStorage = parseStorage(args['session-storage']);
|
||||
}
|
||||
if (args.token && args.header) {
|
||||
authSessionData.headers = {
|
||||
[args.header]: args.token.startsWith('Bearer ') ? args.token : `Bearer ${args.token}`
|
||||
};
|
||||
}
|
||||
|
||||
// Clear existing auth if --clear flag used
|
||||
if (args.clear === 'true') {
|
||||
clearAuthSession();
|
||||
result.cleared = true;
|
||||
} else if (Object.keys(authSessionData).length > 0) {
|
||||
saveAuthSession(authSessionData);
|
||||
result.persisted = true;
|
||||
}
|
||||
|
||||
// Verify auth by checking page title and URL after injection
|
||||
result.finalUrl = page.url();
|
||||
result.title = await page.title();
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
injectAuth();
|
||||
181
.opencode/skills/chrome-devtools/scripts/install-deps.sh
Executable file
181
.opencode/skills/chrome-devtools/scripts/install-deps.sh
Executable file
@@ -0,0 +1,181 @@
|
||||
#!/bin/bash
|
||||
# System dependencies installation script for Chrome DevTools Agent Skill
|
||||
# This script installs required system libraries for running Chrome/Chromium
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Installing system dependencies for Chrome/Chromium..."
|
||||
echo ""
|
||||
|
||||
# Detect OS
|
||||
if [ -f /etc/os-release ]; then
|
||||
. /etc/os-release
|
||||
OS=$ID
|
||||
else
|
||||
echo "❌ Cannot detect OS. This script supports Debian/Ubuntu-based systems."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if running as root
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
SUDO="sudo"
|
||||
echo "⚠️ This script requires root privileges to install system packages."
|
||||
echo " You may be prompted for your password."
|
||||
echo ""
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
# Install dependencies based on OS
|
||||
case $OS in
|
||||
ubuntu|debian|pop)
|
||||
echo "Detected: $PRETTY_NAME"
|
||||
echo "Installing dependencies with apt..."
|
||||
echo ""
|
||||
|
||||
$SUDO apt-get update
|
||||
|
||||
# Install Chrome dependencies
|
||||
$SUDO apt-get install -y \
|
||||
ca-certificates \
|
||||
fonts-liberation \
|
||||
libasound2t64 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libc6 \
|
||||
libcairo2 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgbm1 \
|
||||
libgcc1 \
|
||||
libglib2.0-0 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libstdc++6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcb1 \
|
||||
libxcomposite1 \
|
||||
libxcursor1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
lsb-release \
|
||||
wget \
|
||||
xdg-utils
|
||||
|
||||
echo ""
|
||||
echo "✅ System dependencies installed successfully!"
|
||||
;;
|
||||
|
||||
fedora|rhel|centos)
|
||||
echo "Detected: $PRETTY_NAME"
|
||||
echo "Installing dependencies with dnf/yum..."
|
||||
echo ""
|
||||
|
||||
# Try dnf first, fallback to yum
|
||||
if command -v dnf &> /dev/null; then
|
||||
PKG_MGR="dnf"
|
||||
else
|
||||
PKG_MGR="yum"
|
||||
fi
|
||||
|
||||
$SUDO $PKG_MGR install -y \
|
||||
alsa-lib \
|
||||
atk \
|
||||
at-spi2-atk \
|
||||
cairo \
|
||||
cups-libs \
|
||||
dbus-libs \
|
||||
expat \
|
||||
fontconfig \
|
||||
glib2 \
|
||||
gtk3 \
|
||||
libdrm \
|
||||
libgbm \
|
||||
libX11 \
|
||||
libxcb \
|
||||
libXcomposite \
|
||||
libXcursor \
|
||||
libXdamage \
|
||||
libXext \
|
||||
libXfixes \
|
||||
libXi \
|
||||
libxkbcommon \
|
||||
libXrandr \
|
||||
libXrender \
|
||||
libXScrnSaver \
|
||||
libXtst \
|
||||
mesa-libgbm \
|
||||
nspr \
|
||||
nss \
|
||||
pango
|
||||
|
||||
echo ""
|
||||
echo "✅ System dependencies installed successfully!"
|
||||
;;
|
||||
|
||||
arch|manjaro)
|
||||
echo "Detected: $PRETTY_NAME"
|
||||
echo "Installing dependencies with pacman..."
|
||||
echo ""
|
||||
|
||||
$SUDO pacman -Sy --noconfirm \
|
||||
alsa-lib \
|
||||
at-spi2-core \
|
||||
cairo \
|
||||
cups \
|
||||
dbus \
|
||||
expat \
|
||||
glib2 \
|
||||
gtk3 \
|
||||
libdrm \
|
||||
libx11 \
|
||||
libxcb \
|
||||
libxcomposite \
|
||||
libxcursor \
|
||||
libxdamage \
|
||||
libxext \
|
||||
libxfixes \
|
||||
libxi \
|
||||
libxkbcommon \
|
||||
libxrandr \
|
||||
libxrender \
|
||||
libxshmfence \
|
||||
libxss \
|
||||
libxtst \
|
||||
mesa \
|
||||
nspr \
|
||||
nss \
|
||||
pango
|
||||
|
||||
echo ""
|
||||
echo "✅ System dependencies installed successfully!"
|
||||
;;
|
||||
|
||||
*)
|
||||
echo "❌ Unsupported OS: $OS"
|
||||
echo " This script supports: Ubuntu, Debian, Fedora, RHEL, CentOS, Arch, Manjaro"
|
||||
echo ""
|
||||
echo " Please install Chrome/Chromium dependencies manually for your OS."
|
||||
echo " See: https://pptr.dev/troubleshooting"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo ""
|
||||
echo "📝 Next steps:"
|
||||
echo " 1. Run: cd $(dirname "$0")"
|
||||
echo " 2. Run: npm install"
|
||||
echo " 3. Test: node navigate.js --url https://example.com"
|
||||
echo ""
|
||||
83
.opencode/skills/chrome-devtools/scripts/install.sh
Executable file
83
.opencode/skills/chrome-devtools/scripts/install.sh
Executable file
@@ -0,0 +1,83 @@
|
||||
#!/bin/bash
|
||||
# Installation script for Chrome DevTools Agent Skill
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Installing Chrome DevTools Agent Skill..."
|
||||
echo ""
|
||||
|
||||
# Check Node.js version
|
||||
echo "Checking Node.js version..."
|
||||
NODE_VERSION=$(node --version | cut -d'v' -f2 | cut -d'.' -f1)
|
||||
|
||||
if [ "$NODE_VERSION" -lt 18 ]; then
|
||||
echo "❌ Error: Node.js 18+ is required. Current version: $(node --version)"
|
||||
echo " Please upgrade Node.js: https://nodejs.org/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ Node.js version: $(node --version)"
|
||||
echo ""
|
||||
|
||||
# Check for system dependencies (Linux only)
|
||||
if [[ "$OSTYPE" == "linux-gnu"* ]]; then
|
||||
echo "Checking system dependencies (Linux)..."
|
||||
|
||||
# Check for critical Chrome dependencies
|
||||
MISSING_DEPS=()
|
||||
|
||||
if ! ldconfig -p | grep -q libnss3.so; then
|
||||
MISSING_DEPS+=("libnss3")
|
||||
fi
|
||||
|
||||
if ! ldconfig -p | grep -q libnspr4.so; then
|
||||
MISSING_DEPS+=("libnspr4")
|
||||
fi
|
||||
|
||||
if ! ldconfig -p | grep -q libgbm.so; then
|
||||
MISSING_DEPS+=("libgbm1")
|
||||
fi
|
||||
|
||||
if [ ${#MISSING_DEPS[@]} -gt 0 ]; then
|
||||
echo "⚠️ Missing system dependencies: ${MISSING_DEPS[*]}"
|
||||
echo ""
|
||||
echo " Chrome/Chromium requires system libraries to run."
|
||||
echo " Install them with:"
|
||||
echo ""
|
||||
echo " ./install-deps.sh"
|
||||
echo ""
|
||||
echo " Or manually:"
|
||||
echo " sudo apt-get install -y libnss3 libnspr4 libgbm1 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2"
|
||||
echo ""
|
||||
|
||||
read -p " Continue anyway? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Installation cancelled."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "✓ System dependencies found"
|
||||
fi
|
||||
echo ""
|
||||
elif [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "Platform: macOS (no system dependencies needed)"
|
||||
echo ""
|
||||
elif [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "cygwin" ]]; then
|
||||
echo "Platform: Windows (no system dependencies needed)"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Install Node.js dependencies
|
||||
echo "Installing Node.js dependencies..."
|
||||
npm install
|
||||
|
||||
echo ""
|
||||
echo "✅ Installation complete!"
|
||||
echo ""
|
||||
echo "Test the installation:"
|
||||
echo " node navigate.js --url https://example.com"
|
||||
echo ""
|
||||
echo "For more information:"
|
||||
echo " cat README.md"
|
||||
echo ""
|
||||
374
.opencode/skills/chrome-devtools/scripts/lib/browser.js
Normal file
374
.opencode/skills/chrome-devtools/scripts/lib/browser.js
Normal file
@@ -0,0 +1,374 @@
|
||||
/**
|
||||
* Shared browser utilities for Chrome DevTools scripts
|
||||
* Supports persistent browser sessions via WebSocket endpoint file
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import debug from 'debug';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const log = debug('chrome-devtools:browser');
|
||||
|
||||
// Session file stores WebSocket endpoint for browser reuse across processes
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SESSION_FILE = path.join(__dirname, '..', '.browser-session.json');
|
||||
const AUTH_SESSION_FILE = path.join(__dirname, '..', '.auth-session.json');
|
||||
|
||||
let browserInstance = null;
|
||||
let pageInstance = null;
|
||||
|
||||
/**
|
||||
* Resolve headless mode based on explicit value or OS auto-detection.
|
||||
* - Explicit 'true'/'false' or boolean always wins
|
||||
* - CI environments (CI, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL) → headless
|
||||
* - Linux → headless (servers/WSL typically have no display)
|
||||
* - macOS/Windows → headed for better debugging
|
||||
* @param {string|boolean|undefined} value - CLI arg value or boolean
|
||||
* @returns {boolean} - true for headless, false for headed
|
||||
*/
|
||||
export function resolveHeadless(value) {
|
||||
if (value === false || value === 'false') return false;
|
||||
if (value === true || value === 'true') return true;
|
||||
|
||||
// Auto-detect: CI → headless
|
||||
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI || process.env.JENKINS_URL) {
|
||||
log('Auto-detected CI environment → headless');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux → headless (includes WSL, remote servers)
|
||||
if (process.platform === 'linux') {
|
||||
log('Auto-detected Linux → headless');
|
||||
return true;
|
||||
}
|
||||
|
||||
// macOS/Windows → headed for debugging
|
||||
log(`Auto-detected ${process.platform} → headed`);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default Chrome profile path based on OS
|
||||
* @returns {string} - Path to Chrome's default user data directory
|
||||
*/
|
||||
function getDefaultChromeProfilePath() {
|
||||
switch (process.platform) {
|
||||
case 'darwin':
|
||||
return `${process.env.HOME}/Library/Application Support/Google/Chrome`;
|
||||
case 'win32':
|
||||
return `${process.env.LOCALAPPDATA}/Google/Chrome/User Data`;
|
||||
default: // Linux and others
|
||||
return `${process.env.HOME}/.config/google-chrome`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read session info from file
|
||||
*/
|
||||
function readSession() {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
|
||||
// Check if session is not too old (max 1 hour)
|
||||
if (Date.now() - data.timestamp < 3600000) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to read session:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write session info to file
|
||||
*/
|
||||
function writeSession(wsEndpoint) {
|
||||
try {
|
||||
fs.writeFileSync(SESSION_FILE, JSON.stringify({
|
||||
wsEndpoint,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
} catch (e) {
|
||||
log('Failed to write session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear session file
|
||||
*/
|
||||
function clearSession() {
|
||||
try {
|
||||
if (fs.existsSync(SESSION_FILE)) {
|
||||
fs.unlinkSync(SESSION_FILE);
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to clear session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save auth session (cookies, storage) for persistence
|
||||
* @param {Object} authData - Auth data to save
|
||||
*/
|
||||
export function saveAuthSession(authData) {
|
||||
try {
|
||||
const existing = readAuthSession() || {};
|
||||
const merged = { ...existing, ...authData, timestamp: Date.now() };
|
||||
fs.writeFileSync(AUTH_SESSION_FILE, JSON.stringify(merged, null, 2));
|
||||
log('Auth session saved');
|
||||
} catch (e) {
|
||||
log('Failed to save auth session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read auth session from file
|
||||
* @returns {Object|null} - Auth session data or null
|
||||
*/
|
||||
export function readAuthSession() {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_SESSION_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(AUTH_SESSION_FILE, 'utf8'));
|
||||
// Auth sessions valid for 24 hours
|
||||
if (Date.now() - data.timestamp < 86400000) {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to read auth session:', e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear auth session file
|
||||
*/
|
||||
export function clearAuthSession() {
|
||||
try {
|
||||
if (fs.existsSync(AUTH_SESSION_FILE)) {
|
||||
fs.unlinkSync(AUTH_SESSION_FILE);
|
||||
log('Auth session cleared');
|
||||
}
|
||||
} catch (e) {
|
||||
log('Failed to clear auth session:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply saved auth session to page
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {string} url - Target URL for domain context
|
||||
*/
|
||||
export async function applyAuthSession(page, url) {
|
||||
const authData = readAuthSession();
|
||||
if (!authData) {
|
||||
log('No auth session found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Apply cookies
|
||||
if (authData.cookies && authData.cookies.length > 0) {
|
||||
await page.setCookie(...authData.cookies);
|
||||
log(`Applied ${authData.cookies.length} cookies`);
|
||||
}
|
||||
|
||||
// Apply localStorage (requires navigation first)
|
||||
if (authData.localStorage && Object.keys(authData.localStorage).length > 0) {
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, authData.localStorage);
|
||||
log('Applied localStorage data');
|
||||
}
|
||||
|
||||
// Apply sessionStorage
|
||||
if (authData.sessionStorage && Object.keys(authData.sessionStorage).length > 0) {
|
||||
await page.evaluate((data) => {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
});
|
||||
}, authData.sessionStorage);
|
||||
log('Applied sessionStorage data');
|
||||
}
|
||||
|
||||
// Apply extra headers
|
||||
if (authData.headers) {
|
||||
await page.setExtraHTTPHeaders(authData.headers);
|
||||
log('Applied HTTP headers');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
log('Failed to apply auth session:', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch or connect to browser
|
||||
* If a session file exists with valid wsEndpoint, connects to existing browser
|
||||
* Otherwise launches new browser and saves wsEndpoint for future connections
|
||||
*/
|
||||
export async function getBrowser(options = {}) {
|
||||
// If we already have a connected browser in this process, reuse it
|
||||
if (browserInstance && browserInstance.isConnected()) {
|
||||
log('Reusing existing browser instance from process');
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
// Try to connect to existing browser from session file
|
||||
const session = readSession();
|
||||
if (session && session.wsEndpoint) {
|
||||
try {
|
||||
log('Attempting to connect to existing browser session');
|
||||
browserInstance = await puppeteer.connect({
|
||||
browserWSEndpoint: session.wsEndpoint
|
||||
});
|
||||
log('Connected to existing browser');
|
||||
return browserInstance;
|
||||
} catch (e) {
|
||||
log('Failed to connect to existing browser:', e.message);
|
||||
clearSession();
|
||||
}
|
||||
}
|
||||
|
||||
// Connect via provided wsEndpoint or browserUrl
|
||||
if (options.wsEndpoint || options.browserUrl) {
|
||||
log('Connecting to browser via provided endpoint');
|
||||
browserInstance = await puppeteer.connect({
|
||||
browserWSEndpoint: options.wsEndpoint,
|
||||
browserURL: options.browserUrl
|
||||
});
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
// Resolve Chrome profile path
|
||||
let userDataDir = options.userDataDir || options.profile;
|
||||
if (options.useDefaultProfile) {
|
||||
userDataDir = getDefaultChromeProfilePath();
|
||||
log(`Using default Chrome profile: ${userDataDir}`);
|
||||
}
|
||||
|
||||
// Destructure known properties — only pass Puppeteer-valid options to launch()
|
||||
const { headless, args: extraArgs, viewport, useDefaultProfile, profile, browserUrl, wsEndpoint: _ws, userDataDir: _udd, ...restOptions } = options;
|
||||
|
||||
// Launch new browser
|
||||
const launchOptions = {
|
||||
headless: resolveHeadless(headless),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
...(extraArgs || [])
|
||||
],
|
||||
defaultViewport: viewport || {
|
||||
width: 1920,
|
||||
height: 1080
|
||||
},
|
||||
...(userDataDir && { userDataDir }),
|
||||
...restOptions
|
||||
};
|
||||
|
||||
log('Launching new browser');
|
||||
browserInstance = await puppeteer.launch(launchOptions);
|
||||
|
||||
// Save wsEndpoint for future connections
|
||||
const wsEndpoint = browserInstance.wsEndpoint();
|
||||
writeSession(wsEndpoint);
|
||||
log('Browser launched, session saved');
|
||||
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page or create new one
|
||||
*/
|
||||
export async function getPage(browser) {
|
||||
if (pageInstance && !pageInstance.isClosed()) {
|
||||
log('Reusing existing page');
|
||||
return pageInstance;
|
||||
}
|
||||
|
||||
const pages = await browser.pages();
|
||||
if (pages.length > 0) {
|
||||
pageInstance = pages[0];
|
||||
} else {
|
||||
pageInstance = await browser.newPage();
|
||||
}
|
||||
|
||||
return pageInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close browser and clear session
|
||||
*/
|
||||
export async function closeBrowser() {
|
||||
if (browserInstance) {
|
||||
await browserInstance.close();
|
||||
browserInstance = null;
|
||||
pageInstance = null;
|
||||
clearSession();
|
||||
log('Browser closed, session cleared');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from browser without closing it
|
||||
* Use this to keep browser running for future script executions
|
||||
*/
|
||||
export async function disconnectBrowser() {
|
||||
if (browserInstance) {
|
||||
browserInstance.disconnect();
|
||||
browserInstance = null;
|
||||
pageInstance = null;
|
||||
log('Disconnected from browser (browser still running)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse command line arguments
|
||||
*/
|
||||
export function parseArgs(argv, options = {}) {
|
||||
const args = {};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
if (arg.startsWith('--')) {
|
||||
const key = arg.slice(2);
|
||||
const nextArg = argv[i + 1];
|
||||
|
||||
if (nextArg && !nextArg.startsWith('--')) {
|
||||
args[key] = nextArg;
|
||||
i++;
|
||||
} else {
|
||||
args[key] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output JSON result
|
||||
*/
|
||||
export function outputJSON(data) {
|
||||
console.log(JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Output error
|
||||
*/
|
||||
export function outputError(error) {
|
||||
console.error(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
}, null, 2));
|
||||
process.exit(1);
|
||||
}
|
||||
178
.opencode/skills/chrome-devtools/scripts/lib/selector.js
Normal file
178
.opencode/skills/chrome-devtools/scripts/lib/selector.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared selector parsing and validation library
|
||||
* Supports CSS and XPath selectors with security validation
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse and validate selector
|
||||
* @param {string} selector - CSS or XPath selector
|
||||
* @returns {{type: 'css'|'xpath', selector: string}}
|
||||
* @throws {Error} If XPath contains injection patterns
|
||||
*/
|
||||
export function parseSelector(selector) {
|
||||
if (!selector || typeof selector !== 'string') {
|
||||
throw new Error('Selector must be a non-empty string');
|
||||
}
|
||||
|
||||
// Detect XPath selectors
|
||||
if (selector.startsWith('/') || selector.startsWith('(//')) {
|
||||
// XPath injection prevention
|
||||
validateXPath(selector);
|
||||
return { type: 'xpath', selector };
|
||||
}
|
||||
|
||||
// CSS selector
|
||||
return { type: 'css', selector };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate XPath selector for security
|
||||
* @param {string} xpath - XPath expression to validate
|
||||
* @throws {Error} If XPath contains dangerous patterns
|
||||
*/
|
||||
function validateXPath(xpath) {
|
||||
const dangerous = [
|
||||
'javascript:',
|
||||
'<script',
|
||||
'onerror=',
|
||||
'onload=',
|
||||
'onclick=',
|
||||
'onmouseover=',
|
||||
'eval(',
|
||||
'Function(',
|
||||
'constructor(',
|
||||
];
|
||||
|
||||
const lower = xpath.toLowerCase();
|
||||
for (const pattern of dangerous) {
|
||||
if (lower.includes(pattern.toLowerCase())) {
|
||||
throw new Error(`Potential XPath injection detected: ${pattern}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation: check for extremely long selectors (potential DoS)
|
||||
if (xpath.length > 1000) {
|
||||
throw new Error('XPath selector too long (max 1000 characters)');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for element based on selector type
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {{type: string, selector: string}} parsed - Parsed selector
|
||||
* @param {Object} options - Wait options (visible, timeout)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function waitForElement(page, parsed, options = {}) {
|
||||
const defaultOptions = {
|
||||
visible: true,
|
||||
timeout: 5000,
|
||||
...options
|
||||
};
|
||||
|
||||
if (parsed.type === 'xpath') {
|
||||
// Use locator API for XPath (Puppeteer v24+)
|
||||
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
|
||||
// setVisibility and setTimeout are the locator options
|
||||
await locator
|
||||
.setVisibility(defaultOptions.visible ? 'visible' : null)
|
||||
.setTimeout(defaultOptions.timeout)
|
||||
.wait();
|
||||
} else {
|
||||
await page.waitForSelector(parsed.selector, defaultOptions);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click element based on selector type
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {{type: string, selector: string}} parsed - Parsed selector
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function clickElement(page, parsed) {
|
||||
if (parsed.type === 'xpath') {
|
||||
// Use locator API for XPath (Puppeteer v24+)
|
||||
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
|
||||
await locator.click();
|
||||
} else {
|
||||
await page.click(parsed.selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type into element based on selector type
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {{type: string, selector: string}} parsed - Parsed selector
|
||||
* @param {string} value - Text to type
|
||||
* @param {Object} options - Type options (delay, clear)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function typeIntoElement(page, parsed, value, options = {}) {
|
||||
if (parsed.type === 'xpath') {
|
||||
// Use locator API for XPath (Puppeteer v24+)
|
||||
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
|
||||
|
||||
// Clear if requested
|
||||
if (options.clear) {
|
||||
await locator.fill('');
|
||||
}
|
||||
|
||||
await locator.fill(value);
|
||||
} else {
|
||||
// CSS selector
|
||||
if (options.clear) {
|
||||
await page.$eval(parsed.selector, el => el.value = '');
|
||||
}
|
||||
|
||||
await page.type(parsed.selector, value, { delay: options.delay || 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get element handle based on selector type
|
||||
* @param {Object} page - Puppeteer page instance
|
||||
* @param {{type: string, selector: string}} parsed - Parsed selector
|
||||
* @returns {Promise<ElementHandle|null>}
|
||||
*/
|
||||
export async function getElement(page, parsed) {
|
||||
if (parsed.type === 'xpath') {
|
||||
// For XPath, use page.evaluate with XPath evaluation
|
||||
// This returns the first matching element
|
||||
const element = await page.evaluateHandle((xpath) => {
|
||||
const result = document.evaluate(
|
||||
xpath,
|
||||
document,
|
||||
null,
|
||||
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
||||
null
|
||||
);
|
||||
return result.singleNodeValue;
|
||||
}, parsed.selector);
|
||||
|
||||
// Convert JSHandle to ElementHandle
|
||||
const elementHandle = element.asElement();
|
||||
return elementHandle;
|
||||
} else {
|
||||
return await page.$(parsed.selector);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get enhanced error message for selector failures
|
||||
* @param {Error} error - Original error
|
||||
* @param {string} selector - Selector that failed
|
||||
* @returns {Error} Enhanced error with troubleshooting tips
|
||||
*/
|
||||
export function enhanceError(error, selector) {
|
||||
if (error.message.includes('waiting for selector') ||
|
||||
error.message.includes('waiting for XPath') ||
|
||||
error.message.includes('No node found')) {
|
||||
error.message += '\n\nTroubleshooting:\n' +
|
||||
'1. Use snapshot.js to find correct selector: node snapshot.js --url <url>\n' +
|
||||
'2. Try XPath selector: //button[text()="Click"] or //button[contains(text(),"Click")]\n' +
|
||||
'3. Check element is visible on page (not display:none or hidden)\n' +
|
||||
'4. Increase --timeout value: --timeout 10000\n' +
|
||||
'5. Change wait strategy: --wait-until load or --wait-until domcontentloaded';
|
||||
}
|
||||
return error;
|
||||
}
|
||||
138
.opencode/skills/chrome-devtools/scripts/navigate.js
Executable file
138
.opencode/skills/chrome-devtools/scripts/navigate.js
Executable file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Navigate to a URL
|
||||
* Usage: node navigate.js --url https://example.com [--wait-until networkidle2] [--timeout 30000]
|
||||
* node navigate.js --url https://example.com --use-default-profile true
|
||||
* node navigate.js --url https://example.com --profile "/path/to/chrome/profile"
|
||||
* node navigate.js --url https://example.com/login --wait-for-login "/dashboard"
|
||||
*
|
||||
* Session behavior:
|
||||
* --close false : Keep browser running, disconnect from it (default for chaining)
|
||||
* --close true : Close browser completely and clear session
|
||||
*
|
||||
* Profile options (Chrome must be closed first):
|
||||
* --use-default-profile true : Use Chrome's default profile with all cookies
|
||||
* --profile <path> : Use specific Chrome profile directory
|
||||
* --browser-url <url> : Connect to Chrome with remote debugging
|
||||
*
|
||||
* Interactive login (OAuth/SSO):
|
||||
* --wait-for-login <pattern> : Open headed browser, wait for URL to match regex pattern
|
||||
* --login-timeout <ms> : Max wait time for login (default: 300000 = 5 min)
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, saveAuthSession, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
|
||||
async function navigate() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Force headed mode when waiting for interactive login
|
||||
const headless = args['wait-for-login'] ? false : args.headless;
|
||||
|
||||
const browser = await getBrowser({
|
||||
headless,
|
||||
useDefaultProfile: args['use-default-profile'] === 'true',
|
||||
profile: args.profile,
|
||||
browserUrl: args['browser-url']
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
const options = {
|
||||
waitUntil: args['wait-until'] || 'networkidle2',
|
||||
timeout: parseInt(args.timeout || '30000')
|
||||
};
|
||||
|
||||
await page.goto(args.url, options);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url: page.url(),
|
||||
title: await page.title()
|
||||
};
|
||||
|
||||
// Interactive login: wait for user to complete OAuth/SSO flow
|
||||
if (args['wait-for-login']) {
|
||||
const pattern = args['wait-for-login'];
|
||||
const loginTimeout = parseInt(args['login-timeout'] || '300000');
|
||||
|
||||
// Validate timeout value
|
||||
if (!Number.isFinite(loginTimeout) || loginTimeout <= 0) {
|
||||
outputError(new Error('--login-timeout must be a positive integer (ms)'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate regex pattern before use
|
||||
let regex;
|
||||
try {
|
||||
regex = new RegExp(pattern);
|
||||
} catch (e) {
|
||||
outputError(new Error(`Invalid regex pattern for --wait-for-login: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Log to stderr so JSON output stays clean
|
||||
process.stderr.write(`[i] Browser opened for manual login. Complete the login flow.\n`);
|
||||
process.stderr.write(`[i] Waiting for URL to match: ${pattern} (timeout: ${loginTimeout / 1000}s)\n`);
|
||||
|
||||
// Poll URL from Node side — survives page navigations during OAuth redirects
|
||||
const deadline = Date.now() + loginTimeout;
|
||||
let loginDetected = false;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const currentUrl = page.url();
|
||||
if (regex.test(currentUrl)) {
|
||||
loginDetected = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Page may be mid-navigation, retry
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
if (loginDetected) {
|
||||
// Save session cookies after successful login
|
||||
const cookies = await page.cookies();
|
||||
if (cookies.length > 0) {
|
||||
saveAuthSession({ cookies });
|
||||
result.cookiesSaved = cookies.length;
|
||||
} else {
|
||||
process.stderr.write('[!] No cookies captured. Previous session preserved.\n');
|
||||
result.cookiesSaved = 0;
|
||||
}
|
||||
|
||||
result.loginCompleted = true;
|
||||
result.url = page.url();
|
||||
result.title = await page.title();
|
||||
|
||||
process.stderr.write(`[OK] Login detected. ${result.cookiesSaved} cookies saved for session reuse.\n`);
|
||||
} else {
|
||||
result.loginCompleted = false;
|
||||
result.loginError = `Login timeout after ${loginTimeout / 1000}s. URL did not match: ${pattern}`;
|
||||
process.stderr.write(`[X] Login timeout. URL never matched: ${pattern}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
navigate();
|
||||
108
.opencode/skills/chrome-devtools/scripts/network.js
Executable file
108
.opencode/skills/chrome-devtools/scripts/network.js
Executable file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Monitor network requests
|
||||
* Usage: node network.js --url https://example.com [--types xhr,fetch] [--output requests.json]
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
async function monitorNetwork() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
const requests = [];
|
||||
const filterTypes = args.types ? args.types.split(',').map(t => t.toLowerCase()) : null;
|
||||
|
||||
// Monitor requests
|
||||
page.on('request', (request) => {
|
||||
const resourceType = request.resourceType().toLowerCase();
|
||||
|
||||
if (!filterTypes || filterTypes.includes(resourceType)) {
|
||||
requests.push({
|
||||
id: request._requestId || requests.length,
|
||||
url: request.url(),
|
||||
method: request.method(),
|
||||
resourceType: resourceType,
|
||||
headers: request.headers(),
|
||||
postData: request.postData(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor responses
|
||||
const responses = new Map();
|
||||
page.on('response', async (response) => {
|
||||
const request = response.request();
|
||||
const resourceType = request.resourceType().toLowerCase();
|
||||
|
||||
if (!filterTypes || filterTypes.includes(resourceType)) {
|
||||
try {
|
||||
responses.set(request._requestId || request.url(), {
|
||||
status: response.status(),
|
||||
statusText: response.statusText(),
|
||||
headers: response.headers(),
|
||||
fromCache: response.fromCache(),
|
||||
timing: response.timing()
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors for some response types
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
|
||||
// Merge requests with responses
|
||||
const combined = requests.map(req => ({
|
||||
...req,
|
||||
response: responses.get(req.id) || responses.get(req.url) || null
|
||||
}));
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url: page.url(),
|
||||
requestCount: combined.length,
|
||||
requests: combined
|
||||
};
|
||||
|
||||
if (args.output) {
|
||||
await fs.writeFile(args.output, JSON.stringify(result, null, 2));
|
||||
outputJSON({
|
||||
success: true,
|
||||
output: args.output,
|
||||
requestCount: combined.length
|
||||
});
|
||||
} else {
|
||||
outputJSON(result);
|
||||
}
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
monitorNetwork();
|
||||
16
.opencode/skills/chrome-devtools/scripts/package.json
Normal file
16
.opencode/skills/chrome-devtools/scripts/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "chrome-devtools-scripts",
|
||||
"version": "1.1.0",
|
||||
"description": "Browser automation scripts for Chrome DevTools Agent Skill",
|
||||
"type": "module",
|
||||
"scripts": {},
|
||||
"dependencies": {
|
||||
"debug": "^4.4.0",
|
||||
"puppeteer": "^24.15.0",
|
||||
"sharp": "^0.33.5",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
151
.opencode/skills/chrome-devtools/scripts/performance.js
Executable file
151
.opencode/skills/chrome-devtools/scripts/performance.js
Executable file
@@ -0,0 +1,151 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Measure performance metrics and record trace
|
||||
* Usage: node performance.js --url https://example.com [--trace trace.json] [--metrics]
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
async function measurePerformance() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.url) {
|
||||
outputError(new Error('--url is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Start tracing if requested
|
||||
if (args.trace) {
|
||||
await page.tracing.start({
|
||||
path: args.trace,
|
||||
categories: [
|
||||
'devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline',
|
||||
'disabled-by-default-devtools.timeline.frame'
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate
|
||||
await page.goto(args.url, {
|
||||
waitUntil: 'networkidle2'
|
||||
});
|
||||
|
||||
// Stop tracing
|
||||
if (args.trace) {
|
||||
await page.tracing.stop();
|
||||
}
|
||||
|
||||
// Get performance metrics
|
||||
const metrics = await page.metrics();
|
||||
|
||||
// Get Core Web Vitals
|
||||
const vitals = await page.evaluate(() => {
|
||||
return new Promise((resolve) => {
|
||||
const vitals = {
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: 0,
|
||||
FCP: null,
|
||||
TTFB: null
|
||||
};
|
||||
|
||||
// LCP
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
const entries = list.getEntries();
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1];
|
||||
vitals.LCP = lastEntry.renderTime || lastEntry.loadTime;
|
||||
}
|
||||
}).observe({ entryTypes: ['largest-contentful-paint'], buffered: true });
|
||||
} catch (e) {}
|
||||
|
||||
// CLS
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
list.getEntries().forEach((entry) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
vitals.CLS += entry.value;
|
||||
}
|
||||
});
|
||||
}).observe({ entryTypes: ['layout-shift'], buffered: true });
|
||||
} catch (e) {}
|
||||
|
||||
// FCP
|
||||
try {
|
||||
const paintEntries = performance.getEntriesByType('paint');
|
||||
const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint');
|
||||
if (fcpEntry) {
|
||||
vitals.FCP = fcpEntry.startTime;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// TTFB
|
||||
try {
|
||||
const [navigationEntry] = performance.getEntriesByType('navigation');
|
||||
if (navigationEntry) {
|
||||
vitals.TTFB = navigationEntry.responseStart - navigationEntry.requestStart;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Wait a bit for metrics to stabilize
|
||||
setTimeout(() => resolve(vitals), 1000);
|
||||
});
|
||||
});
|
||||
|
||||
// Get resource timing
|
||||
const resources = await page.evaluate(() => {
|
||||
return performance.getEntriesByType('resource').map(r => ({
|
||||
name: r.name,
|
||||
type: r.initiatorType,
|
||||
duration: r.duration,
|
||||
size: r.transferSize,
|
||||
startTime: r.startTime
|
||||
}));
|
||||
});
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url: page.url(),
|
||||
metrics: {
|
||||
...metrics,
|
||||
JSHeapUsedSizeMB: (metrics.JSHeapUsedSize / 1024 / 1024).toFixed(2),
|
||||
JSHeapTotalSizeMB: (metrics.JSHeapTotalSize / 1024 / 1024).toFixed(2)
|
||||
},
|
||||
vitals: vitals,
|
||||
resources: {
|
||||
count: resources.length,
|
||||
totalDuration: resources.reduce((sum, r) => sum + r.duration, 0),
|
||||
items: args.resources === 'true' ? resources : undefined
|
||||
}
|
||||
};
|
||||
|
||||
if (args.trace) {
|
||||
result.trace = args.trace;
|
||||
}
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
measurePerformance();
|
||||
199
.opencode/skills/chrome-devtools/scripts/screenshot.js
Executable file
199
.opencode/skills/chrome-devtools/scripts/screenshot.js
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Take a screenshot
|
||||
* Usage: node screenshot.js --output screenshot.png [--url https://example.com] [--full-page true] [--selector .element] [--max-size 5] [--no-compress]
|
||||
* Supports both CSS and XPath selectors:
|
||||
* - CSS: node screenshot.js --selector ".main-content" --output page.png
|
||||
* - XPath: node screenshot.js --selector "//div[@class='main-content']" --output page.png
|
||||
*
|
||||
* Session behavior:
|
||||
* By default, browser stays running for session persistence
|
||||
* Use --close true to fully close browser
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import { parseSelector, getElement, enhanceError } from './lib/selector.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* Check if Sharp is available
|
||||
*/
|
||||
let sharp = null;
|
||||
try {
|
||||
sharp = (await import('sharp')).default;
|
||||
} catch {
|
||||
// Sharp not installed, compression disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress image using Sharp if it exceeds max size
|
||||
* Sharp is 4-5x faster than ImageMagick with lower memory usage
|
||||
* Falls back to no compression if Sharp is not installed
|
||||
* @param {string} filePath - Path to the image file
|
||||
* @param {number} maxSizeMB - Maximum file size in MB (default: 5)
|
||||
* @returns {Promise<{compressed: boolean, originalSize: number, finalSize: number}>}
|
||||
*/
|
||||
async function compressImageIfNeeded(filePath, maxSizeMB = 5) {
|
||||
const stats = await fs.stat(filePath);
|
||||
const originalSize = stats.size;
|
||||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
||||
|
||||
if (originalSize <= maxSizeBytes) {
|
||||
return { compressed: false, originalSize, finalSize: originalSize };
|
||||
}
|
||||
|
||||
if (!sharp) {
|
||||
console.error('Warning: Sharp not installed. Run npm install to enable automatic compression.');
|
||||
return { compressed: false, originalSize, finalSize: originalSize };
|
||||
}
|
||||
|
||||
try {
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const imageBuffer = await fs.readFile(filePath);
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
|
||||
// First pass: moderate compression
|
||||
let outputBuffer;
|
||||
if (ext === '.png') {
|
||||
// PNG: resize to 90% and compress
|
||||
const newWidth = Math.round(metadata.width * 0.9);
|
||||
outputBuffer = await sharp(imageBuffer)
|
||||
.resize(newWidth)
|
||||
.png({ quality: 85, compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
} else if (ext === '.jpg' || ext === '.jpeg') {
|
||||
// JPEG: quality 80 with progressive encoding
|
||||
outputBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality: 80, progressive: true, mozjpeg: true })
|
||||
.toBuffer();
|
||||
} else if (ext === '.webp') {
|
||||
// WebP: quality 80
|
||||
outputBuffer = await sharp(imageBuffer)
|
||||
.webp({ quality: 80 })
|
||||
.toBuffer();
|
||||
} else {
|
||||
// Other formats: convert to JPEG
|
||||
outputBuffer = await sharp(imageBuffer)
|
||||
.jpeg({ quality: 80, progressive: true, mozjpeg: true })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
// Second pass: aggressive compression if still too large
|
||||
if (outputBuffer.length > maxSizeBytes) {
|
||||
if (ext === '.png') {
|
||||
const newWidth = Math.round(metadata.width * 0.75);
|
||||
outputBuffer = await sharp(outputBuffer)
|
||||
.resize(newWidth)
|
||||
.png({ quality: 70, compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
} else {
|
||||
outputBuffer = await sharp(outputBuffer)
|
||||
.jpeg({ quality: 60, progressive: true, mozjpeg: true })
|
||||
.toBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
// Write compressed image back to file
|
||||
await fs.writeFile(filePath, outputBuffer);
|
||||
|
||||
return { compressed: true, originalSize, finalSize: outputBuffer.length };
|
||||
} catch (error) {
|
||||
console.error('Compression error:', error.message);
|
||||
return { compressed: false, originalSize, finalSize: originalSize };
|
||||
}
|
||||
}
|
||||
|
||||
async function screenshot() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.output) {
|
||||
outputError(new Error('--output is required'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure output directory exists
|
||||
const outputDir = path.dirname(path.resolve(args.output));
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
|
||||
const screenshotOptions = {
|
||||
path: args.output,
|
||||
type: args.format || 'png',
|
||||
fullPage: args['full-page'] === 'true'
|
||||
};
|
||||
|
||||
if (args.quality) {
|
||||
screenshotOptions.quality = parseInt(args.quality);
|
||||
}
|
||||
|
||||
let buffer;
|
||||
if (args.selector) {
|
||||
// Parse and validate selector
|
||||
const parsed = parseSelector(args.selector);
|
||||
|
||||
// Get element based on selector type
|
||||
const element = await getElement(page, parsed);
|
||||
if (!element) {
|
||||
throw new Error(`Element not found: ${args.selector}`);
|
||||
}
|
||||
buffer = await element.screenshot(screenshotOptions);
|
||||
} else {
|
||||
buffer = await page.screenshot(screenshotOptions);
|
||||
}
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
output: path.resolve(args.output),
|
||||
size: buffer.length,
|
||||
url: page.url()
|
||||
};
|
||||
|
||||
// Compress image if needed (unless --no-compress flag is set)
|
||||
if (args['no-compress'] !== 'true') {
|
||||
const maxSize = args['max-size'] ? parseFloat(args['max-size']) : 5;
|
||||
const compressionResult = await compressImageIfNeeded(args.output, maxSize);
|
||||
|
||||
if (compressionResult.compressed) {
|
||||
result.compressed = true;
|
||||
result.originalSize = compressionResult.originalSize;
|
||||
result.size = compressionResult.finalSize;
|
||||
result.compressionRatio = ((1 - compressionResult.finalSize / compressionResult.originalSize) * 100).toFixed(2) + '%';
|
||||
}
|
||||
}
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
// Enhance error message if selector-related
|
||||
if (args.selector) {
|
||||
const enhanced = enhanceError(error, args.selector);
|
||||
outputError(enhanced);
|
||||
} else {
|
||||
outputError(error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
screenshot();
|
||||
132
.opencode/skills/chrome-devtools/scripts/select-ref.js
Executable file
132
.opencode/skills/chrome-devtools/scripts/select-ref.js
Executable file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Select and interact with elements by ref from ARIA snapshot
|
||||
* Usage: node select-ref.js --ref e5 --action click
|
||||
* node select-ref.js --ref e10 --action fill --value "text"
|
||||
* node select-ref.js --ref e3 --action screenshot --output element.png
|
||||
*
|
||||
* Actions:
|
||||
* click - Click the element
|
||||
* fill - Fill input with --value
|
||||
* screenshot - Take screenshot of element
|
||||
* text - Get text content
|
||||
* focus - Focus the element
|
||||
* hover - Hover over element
|
||||
*
|
||||
* Refs are obtained from aria-snapshot.js output (e.g., [ref=e5])
|
||||
*
|
||||
* Session behavior:
|
||||
* By default, browser stays running for session persistence
|
||||
* Use --close true to fully close browser
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
async function selectRef() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.ref) {
|
||||
outputError(new Error('--ref is required (e.g., --ref e5)'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.action) {
|
||||
outputError(new Error('--action is required (click, fill, screenshot, text, focus, hover)'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Get element by ref from window.__chromeDevToolsRefs
|
||||
const element = await page.evaluateHandle((ref) => {
|
||||
const refs = window.__chromeDevToolsRefs;
|
||||
if (!refs) {
|
||||
throw new Error('No refs available. Run aria-snapshot.js first to generate refs.');
|
||||
}
|
||||
const el = refs.get(ref);
|
||||
if (!el) {
|
||||
throw new Error(`Ref "${ref}" not found. Available refs: ${Array.from(refs.keys()).join(', ')}`);
|
||||
}
|
||||
return el;
|
||||
}, args.ref);
|
||||
|
||||
const elementHandle = element.asElement();
|
||||
if (!elementHandle) {
|
||||
throw new Error(`Could not get element handle for ref "${args.ref}"`);
|
||||
}
|
||||
|
||||
let result = {
|
||||
success: true,
|
||||
ref: args.ref,
|
||||
action: args.action
|
||||
};
|
||||
|
||||
// Perform action
|
||||
switch (args.action) {
|
||||
case 'click':
|
||||
await elementHandle.click();
|
||||
result.message = 'Element clicked';
|
||||
break;
|
||||
|
||||
case 'fill':
|
||||
if (!args.value && args.value !== '') {
|
||||
throw new Error('--value is required for fill action');
|
||||
}
|
||||
await elementHandle.click({ clickCount: 3 }); // Select all
|
||||
await elementHandle.type(args.value);
|
||||
result.message = 'Element filled';
|
||||
result.value = args.value;
|
||||
break;
|
||||
|
||||
case 'screenshot':
|
||||
if (!args.output) {
|
||||
throw new Error('--output is required for screenshot action');
|
||||
}
|
||||
const outputDir = path.dirname(args.output);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
await elementHandle.screenshot({ path: args.output });
|
||||
result.output = path.resolve(args.output);
|
||||
result.message = 'Screenshot saved';
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
const text = await page.evaluate(el => el.textContent?.trim(), elementHandle);
|
||||
result.text = text;
|
||||
break;
|
||||
|
||||
case 'focus':
|
||||
await elementHandle.focus();
|
||||
result.message = 'Element focused';
|
||||
break;
|
||||
|
||||
case 'hover':
|
||||
await elementHandle.hover();
|
||||
result.message = 'Hovering over element';
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${args.action}. Valid actions: click, fill, screenshot, text, focus, hover`);
|
||||
}
|
||||
|
||||
outputJSON(result);
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
selectRef();
|
||||
136
.opencode/skills/chrome-devtools/scripts/snapshot.js
Executable file
136
.opencode/skills/chrome-devtools/scripts/snapshot.js
Executable file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Get DOM snapshot with selectors
|
||||
* Usage: node snapshot.js [--url https://example.com] [--output snapshot.json]
|
||||
*/
|
||||
import { getBrowser, getPage, closeBrowser, disconnectBrowser, parseArgs, outputJSON, outputError } from './lib/browser.js';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
async function snapshot() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
try {
|
||||
const browser = await getBrowser({
|
||||
headless: args.headless
|
||||
});
|
||||
|
||||
const page = await getPage(browser);
|
||||
|
||||
// Navigate if URL provided
|
||||
if (args.url) {
|
||||
await page.goto(args.url, {
|
||||
waitUntil: args['wait-until'] || 'networkidle2'
|
||||
});
|
||||
}
|
||||
|
||||
// Get interactive elements with metadata
|
||||
const elements = await page.evaluate(() => {
|
||||
const interactiveSelectors = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
'[onclick]',
|
||||
'[role="button"]',
|
||||
'[role="link"]',
|
||||
'[contenteditable]'
|
||||
];
|
||||
|
||||
const elements = [];
|
||||
const selector = interactiveSelectors.join(', ');
|
||||
const nodes = document.querySelectorAll(selector);
|
||||
|
||||
nodes.forEach((el, index) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Generate unique selector
|
||||
let uniqueSelector = '';
|
||||
if (el.id) {
|
||||
uniqueSelector = `#${el.id}`;
|
||||
} else if (el.className) {
|
||||
const classes = Array.from(el.classList).join('.');
|
||||
uniqueSelector = `${el.tagName.toLowerCase()}.${classes}`;
|
||||
} else {
|
||||
uniqueSelector = el.tagName.toLowerCase();
|
||||
}
|
||||
|
||||
elements.push({
|
||||
index: index,
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
type: el.type || null,
|
||||
id: el.id || null,
|
||||
className: el.className || null,
|
||||
name: el.name || null,
|
||||
value: el.value || null,
|
||||
text: el.textContent?.trim().substring(0, 100) || null,
|
||||
href: el.href || null,
|
||||
selector: uniqueSelector,
|
||||
xpath: getXPath(el),
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
position: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function getXPath(element) {
|
||||
if (element.id) {
|
||||
return `//*[@id="${element.id}"]`;
|
||||
}
|
||||
if (element === document.body) {
|
||||
return '/html/body';
|
||||
}
|
||||
let ix = 0;
|
||||
const siblings = element.parentNode?.childNodes || [];
|
||||
for (let i = 0; i < siblings.length; i++) {
|
||||
const sibling = siblings[i];
|
||||
if (sibling === element) {
|
||||
return getXPath(element.parentNode) + '/' + element.tagName.toLowerCase() + '[' + (ix + 1) + ']';
|
||||
}
|
||||
if (sibling.nodeType === 1 && sibling.tagName === element.tagName) {
|
||||
ix++;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
return elements;
|
||||
});
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
url: page.url(),
|
||||
title: await page.title(),
|
||||
elementCount: elements.length,
|
||||
elements: elements
|
||||
};
|
||||
|
||||
if (args.output) {
|
||||
await fs.writeFile(args.output, JSON.stringify(result, null, 2));
|
||||
outputJSON({
|
||||
success: true,
|
||||
output: args.output,
|
||||
elementCount: elements.length
|
||||
});
|
||||
} else {
|
||||
outputJSON(result);
|
||||
}
|
||||
|
||||
// Default: disconnect to keep browser running for session persistence
|
||||
// Use --close true to fully close browser
|
||||
if (args.close === 'true') {
|
||||
await closeBrowser();
|
||||
} else {
|
||||
await disconnectBrowser();
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
outputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot();
|
||||
44
.opencode/skills/chrome-devtools/scripts/ws-debug.js
Normal file
44
.opencode/skills/chrome-devtools/scripts/ws-debug.js
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getBrowser, getPage, disconnectBrowser, outputJSON } from './lib/browser.js';
|
||||
|
||||
async function debugWs() {
|
||||
const browser = await getBrowser();
|
||||
const page = await getPage(browser);
|
||||
|
||||
const logs = [];
|
||||
const wsEvents = [];
|
||||
|
||||
// Capture console
|
||||
page.on('console', msg => {
|
||||
logs.push({ type: msg.type(), text: msg.text() });
|
||||
});
|
||||
|
||||
// Monitor WebSocket via CDP
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketCreated', e => wsEvents.push({ event: 'created', ...e }));
|
||||
client.on('Network.webSocketWillSendHandshakeRequest', e => wsEvents.push({ event: 'handshake', ...e }));
|
||||
client.on('Network.webSocketHandshakeResponseReceived', e => wsEvents.push({ event: 'response', ...e }));
|
||||
client.on('Network.webSocketClosed', e => wsEvents.push({ event: 'closed', ...e }));
|
||||
client.on('Network.webSocketFrameError', e => wsEvents.push({ event: 'error', ...e }));
|
||||
|
||||
await page.goto('http://localhost:5173', { waitUntil: 'networkidle0', timeout: 15000 });
|
||||
|
||||
// Wait for WS connections
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
url: page.url(),
|
||||
logs: logs.filter(l => l.text.toLowerCase().includes('websocket') || l.text.toLowerCase().includes('ws') || l.type === 'error'),
|
||||
wsEvents
|
||||
});
|
||||
|
||||
await disconnectBrowser();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
debugWs().catch(e => {
|
||||
console.error(JSON.stringify({ success: false, error: e.message }));
|
||||
process.exit(1);
|
||||
});
|
||||
107
.opencode/skills/chrome-devtools/scripts/ws-full-debug.js
Normal file
107
.opencode/skills/chrome-devtools/scripts/ws-full-debug.js
Normal file
@@ -0,0 +1,107 @@
|
||||
import { getBrowser, getPage, disconnectBrowser, outputJSON } from './lib/browser.js';
|
||||
|
||||
async function debugWsFull() {
|
||||
const browser = await getBrowser({ headless: false });
|
||||
const page = await getPage(browser);
|
||||
|
||||
const logs = [];
|
||||
const wsEvents = [];
|
||||
const networkErrors = [];
|
||||
|
||||
// Capture ALL console messages
|
||||
page.on('console', msg => {
|
||||
logs.push({
|
||||
type: msg.type(),
|
||||
text: msg.text(),
|
||||
location: msg.location()
|
||||
});
|
||||
});
|
||||
|
||||
// Capture page errors
|
||||
page.on('pageerror', err => {
|
||||
logs.push({ type: 'pageerror', text: err.message });
|
||||
});
|
||||
|
||||
// Monitor WebSocket via CDP
|
||||
const client = await page.createCDPSession();
|
||||
await client.send('Network.enable');
|
||||
|
||||
client.on('Network.webSocketCreated', e => {
|
||||
console.log('WS Created:', e.url);
|
||||
wsEvents.push({ event: 'created', url: e.url, requestId: e.requestId });
|
||||
});
|
||||
|
||||
client.on('Network.webSocketWillSendHandshakeRequest', e => {
|
||||
console.log('WS Handshake Request:', e.requestId);
|
||||
wsEvents.push({ event: 'handshake_request', requestId: e.requestId, request: e.request });
|
||||
});
|
||||
|
||||
client.on('Network.webSocketHandshakeResponseReceived', e => {
|
||||
console.log('WS Handshake Response:', e.response?.status);
|
||||
wsEvents.push({
|
||||
event: 'handshake_response',
|
||||
requestId: e.requestId,
|
||||
status: e.response?.status,
|
||||
headers: e.response?.headers
|
||||
});
|
||||
});
|
||||
|
||||
client.on('Network.webSocketClosed', e => {
|
||||
console.log('WS Closed:', e.requestId);
|
||||
wsEvents.push({ event: 'closed', requestId: e.requestId });
|
||||
});
|
||||
|
||||
client.on('Network.webSocketFrameError', e => {
|
||||
console.log('WS Frame Error:', e.errorMessage);
|
||||
wsEvents.push({ event: 'frame_error', requestId: e.requestId, error: e.errorMessage });
|
||||
});
|
||||
|
||||
client.on('Network.webSocketFrameReceived', e => {
|
||||
wsEvents.push({ event: 'frame_received', requestId: e.requestId, data: e.response?.payloadData?.substring(0, 200) });
|
||||
});
|
||||
|
||||
client.on('Network.webSocketFrameSent', e => {
|
||||
wsEvents.push({ event: 'frame_sent', requestId: e.requestId, data: e.response?.payloadData?.substring(0, 200) });
|
||||
});
|
||||
|
||||
// Track failed requests
|
||||
client.on('Network.loadingFailed', e => {
|
||||
if (e.type === 'WebSocket') {
|
||||
networkErrors.push({ requestId: e.requestId, error: e.errorText, canceled: e.canceled });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Navigating to app...');
|
||||
await page.goto('http://localhost:5173', { waitUntil: 'networkidle0', timeout: 20000 });
|
||||
|
||||
console.log('Current URL:', page.url());
|
||||
|
||||
// Wait longer and collect events
|
||||
console.log('Waiting 10s to collect WebSocket events...');
|
||||
await new Promise(r => setTimeout(r, 10000));
|
||||
|
||||
// Filter for /ws connections only (not vite-hmr)
|
||||
const appWsEvents = wsEvents.filter(e => e.url?.includes('/ws') && !e.url?.includes('token='));
|
||||
|
||||
outputJSON({
|
||||
success: true,
|
||||
url: page.url(),
|
||||
appWsEvents,
|
||||
allWsEvents: wsEvents,
|
||||
networkErrors,
|
||||
logs: logs.filter(l =>
|
||||
l.text?.toLowerCase().includes('websocket') ||
|
||||
l.text?.toLowerCase().includes('unauthorized') ||
|
||||
l.text?.toLowerCase().includes('error') ||
|
||||
l.type === 'error'
|
||||
).slice(0, 20)
|
||||
});
|
||||
|
||||
await disconnectBrowser();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
debugWsFull().catch(e => {
|
||||
console.error(JSON.stringify({ success: false, error: e.message, stack: e.stack }));
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user