This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
node_modules
.browser-session.json
.auth-session.json

View 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": "..."
}
```

View File

@@ -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`);
});
}
});
});

View File

@@ -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');
});
});
});

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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();

View 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 ""

View 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 ""

View 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);
}

View 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;
}

View 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();

View 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();

View 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"
}
}

View 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();

View 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();

View 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();

View 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();

View 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);
});

View 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);
});