init
This commit is contained in:
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