chromex-mcp 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -5
- package/package.json +2 -2
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +27 -7
- package/plugins/chromex/skills/chromex/scripts/lib/client.mjs +1 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/audit.mjs +182 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/console.mjs +32 -2
- package/plugins/chromex/skills/chromex/scripts/lib/commands/emulate.mjs +15 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/interact.mjs +15 -5
- package/plugins/chromex/skills/chromex/scripts/lib/commands/keyboard.mjs +123 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/navigate.mjs +26 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/network.mjs +67 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/refs.mjs +11 -4
- package/plugins/chromex/skills/chromex/scripts/lib/commands/screenshot.mjs +32 -5
- package/plugins/chromex/skills/chromex/scripts/lib/commands/stats.mjs +80 -0
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +134 -16
- package/plugins/chromex/skills/chromex/scripts/lib/launcher.mjs +8 -0
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +85 -17
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# Chromex
|
|
2
2
|
|
|
3
|
-
Zero-dependency Chrome DevTools Protocol toolkit for AI agents.
|
|
3
|
+
Zero-dependency Chrome DevTools Protocol toolkit for AI agents. 56 typed MCP tools + CLI. Connects directly to Chrome, Brave, Edge, or Chromium via WebSocket. No Puppeteer, no bloat.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
7
|
-
- **
|
|
7
|
+
- **56 MCP tools** -- typed JSON Schema, annotations (`readOnlyHint`, `destructiveHint`), inline screenshots (base64)
|
|
8
8
|
- **Zero dependencies** -- uses only Node.js 22+ built-in modules (WebSocket, fs, net, crypto)
|
|
9
9
|
- **Ref-based selection** -- `snap --refs` assigns `@e1`, `@e2`... to interactive elements, then `click @e5` or `fill @e3 "value"`. No fragile CSS selectors
|
|
10
10
|
- **Incremental snapshots** -- second snapshot returns only changed nodes (diff), reducing output from thousands of lines to just what changed
|
|
@@ -45,7 +45,7 @@ Add to `~/.claude/settings.json`:
|
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
This approves all
|
|
48
|
+
This approves all 56 MCP tools at once. For granular control, approve individual tools:
|
|
49
49
|
|
|
50
50
|
```json
|
|
51
51
|
{
|
|
@@ -141,6 +141,10 @@ chromex close <target> # Close tab
|
|
|
141
141
|
chromex focus <target> # Activate/focus tab
|
|
142
142
|
chromex launch # Launch browser with debugging
|
|
143
143
|
chromex launch --incognito --browser brave # Launch Brave in incognito
|
|
144
|
+
chromex launch --headless --url https://example.com # Headless mode for CI/CD
|
|
145
|
+
chromex launch --proxy socks5://localhost:1080 # Launch with proxy
|
|
146
|
+
chromex launch --insecure # Ignore certificate errors
|
|
147
|
+
chromex launch --chrome-arg --disable-web-security # Pass custom Chrome flag
|
|
144
148
|
chromex launch --profile testing --url https://... # Isolated profile + URL
|
|
145
149
|
chromex incognito https://example.com # Isolated context (no relaunch)
|
|
146
150
|
chromex stop # Stop all daemons
|
|
@@ -156,9 +160,14 @@ chromex snap <target> --full # Force full snapshot (skip diff)
|
|
|
156
160
|
chromex html <target> "#main" # Element HTML by selector
|
|
157
161
|
chromex shot <target> /tmp/page.png # Viewport screenshot
|
|
158
162
|
chromex shot <target> /tmp/full.png --full # Full page screenshot
|
|
159
|
-
chromex
|
|
163
|
+
chromex shot <target> --format=jpeg --quality=80 # JPEG/WebP with quality control
|
|
164
|
+
chromex shot <target> @e5 # Screenshot of specific element by ref
|
|
165
|
+
chromex net <target> # List network requests (CDP tracked)
|
|
166
|
+
chromex net <target> <requestId> # Request detail: headers, timing, body
|
|
160
167
|
chromex perf <target> # Core Web Vitals + memory + DOM stats
|
|
161
168
|
chromex console <target> 5000 # Capture console.log/error for 5s
|
|
169
|
+
chromex console <target> list # Show stored messages since daemon start
|
|
170
|
+
chromex console <target> detail <id> # Message detail with stack trace
|
|
162
171
|
chromex domsnapshot <target> # Structured DOM with bounding rects
|
|
163
172
|
chromex domsnapshot <target> --styles # Include computed styles
|
|
164
173
|
chromex highlight <target> "h1" # Highlight element with overlay
|
|
@@ -178,6 +187,10 @@ chromex evalraw <target> "Page.getLayoutMetrics" # Layout info
|
|
|
178
187
|
|
|
179
188
|
```bash
|
|
180
189
|
chromex nav <target> "https://example.com" # Navigate + wait for load
|
|
190
|
+
chromex nav <target> back # Go back in history
|
|
191
|
+
chromex nav <target> forward # Go forward in history
|
|
192
|
+
chromex nav <target> reload # Reload page
|
|
193
|
+
chromex nav <target> reload-hard # Reload ignoring cache
|
|
181
194
|
chromex waitfor <target> ".results" 10000 # Wait for CSS selector (10s)
|
|
182
195
|
chromex wait <target> networkidle # Wait for network idle
|
|
183
196
|
chromex wait <target> load # Wait for page load
|
|
@@ -195,7 +208,12 @@ chromex scroll <target> to "#footer" # Scroll to element
|
|
|
195
208
|
```bash
|
|
196
209
|
chromex click <target> "button.submit" # Click by CSS selector
|
|
197
210
|
chromex click <target> @e5 # Click by ref (from snap --refs)
|
|
211
|
+
chromex click <target> @e5 --dbl # Double-click
|
|
198
212
|
chromex clickxy <target> 100 200 # Click at CSS pixel coords
|
|
213
|
+
chromex clickxy <target> 100 200 --dbl # Double-click at coords
|
|
214
|
+
chromex key <target> Enter # Press key
|
|
215
|
+
chromex key <target> "Control+A" # Key combination
|
|
216
|
+
chromex key <target> "Control+Shift+R" # Multi-modifier combo
|
|
199
217
|
chromex type <target> "hello world" # Type text (works cross-origin)
|
|
200
218
|
chromex hover <target> @e12 # Hover element by ref
|
|
201
219
|
chromex drag <target> "#source" "#dest" # Drag & drop by selector
|
|
@@ -268,6 +286,8 @@ chromex emulate <target> macbook-air # 1440x900 @2x laptop
|
|
|
268
286
|
chromex emulate <target> desktop-1080p # 1920x1080 @1x
|
|
269
287
|
chromex emulate <target> desktop-4k # 3840x2160 @1x
|
|
270
288
|
chromex emulate <target> reset # Reset to default
|
|
289
|
+
chromex resize <target> 1280 720 # Custom viewport dimensions
|
|
290
|
+
chromex resize <target> 1440 900 2 # Custom with DPR (retina)
|
|
271
291
|
chromex geo <target> -23.55 -46.63 # Set geolocation (Sao Paulo)
|
|
272
292
|
chromex geo <target> reset # Clear geolocation
|
|
273
293
|
chromex timezone <target> "America/Sao_Paulo" # Set timezone
|
|
@@ -295,6 +315,18 @@ chromex webauthn <target> creds # List stored credentials
|
|
|
295
315
|
chromex webauthn <target> disable # Remove authenticator
|
|
296
316
|
```
|
|
297
317
|
|
|
318
|
+
### Audit & Analytics
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
chromex audit <target> # Full Lighthouse audit (all categories)
|
|
322
|
+
chromex audit <target> performance,seo # Specific categories
|
|
323
|
+
chromex audit <target> accessibility desktop # Accessibility on desktop
|
|
324
|
+
chromex stats <target> # Session analytics (command counts, timing)
|
|
325
|
+
chromex stats <target> --full # Full action timeline
|
|
326
|
+
chromex stats <target> --reset # Reset counters
|
|
327
|
+
chromex stats <target> --export=/tmp/stats.json # Export as JSON
|
|
328
|
+
```
|
|
329
|
+
|
|
298
330
|
## Ref-Based Selection
|
|
299
331
|
|
|
300
332
|
The killer feature for AI agents. Instead of fragile CSS selectors, use numbered refs:
|
|
@@ -379,7 +411,7 @@ chromex click <target> @e3
|
|
|
379
411
|
# ...
|
|
380
412
|
```
|
|
381
413
|
|
|
382
|
-
Commands that trigger auto-snapshot: `click`, `clickxy`, `type`, `fill`, `clear`, `select`, `check`, `form`, `nav`, `dialog`, `loadall`, `drag`, `touch`, `upload`.
|
|
414
|
+
Commands that trigger auto-snapshot: `click`, `clickxy`, `type`, `key`, `fill`, `clear`, `select`, `check`, `form`, `nav`, `dialog`, `loadall`, `drag`, `touch`, `upload`.
|
|
383
415
|
|
|
384
416
|
Suppress with `--no-snap` for scripts doing rapid sequential actions:
|
|
385
417
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chromex-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents.
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 56 typed tools, per-tab daemons, security hardened.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"chromex": "./bin/chromex.mjs",
|
|
@@ -27,7 +27,7 @@ const NEEDS_TARGET = new Set([
|
|
|
27
27
|
'intercept', 'har', 'coverage',
|
|
28
28
|
// Tier 3
|
|
29
29
|
'trace', 'heap', 'webauthn', 'drag', 'touch', 'domsnapshot', 'highlight',
|
|
30
|
-
'hover',
|
|
30
|
+
'hover', 'key', 'resize', 'audit', 'stats',
|
|
31
31
|
]);
|
|
32
32
|
|
|
33
33
|
const USAGE = `chromex - Chrome DevTools Protocol CLI for AI agents
|
|
@@ -44,15 +44,25 @@ Usage: chromex <command> [args]
|
|
|
44
44
|
--browser chrome|brave|edge Choose browser
|
|
45
45
|
--profile NAME Use named profile
|
|
46
46
|
--url URL Open URL on launch
|
|
47
|
+
--headless Launch in headless mode (no UI)
|
|
48
|
+
--proxy PROXY Proxy server (e.g. socks5://localhost:1080)
|
|
49
|
+
--insecure Ignore certificate errors
|
|
50
|
+
--chrome-arg FLAG Pass custom Chrome flag (e.g. --chrome-arg --disable-web-security)
|
|
47
51
|
incognito [url] Create isolated browser context (no relaunch)
|
|
48
52
|
|
|
49
53
|
INSPECT
|
|
50
54
|
snap <target> Accessibility tree snapshot (compact)
|
|
51
55
|
html <target> [selector] Get HTML (full page or CSS selector)
|
|
52
|
-
shot <target> [file] [
|
|
53
|
-
|
|
56
|
+
shot <target> [file] [options] Screenshot (viewport, full page, or element)
|
|
57
|
+
--full Full page capture
|
|
58
|
+
--format=jpeg|webp|png Image format (default: png)
|
|
59
|
+
--quality=N Compression quality 0-100 (JPEG/WebP)
|
|
60
|
+
@eN Capture specific element by ref
|
|
61
|
+
net <target> [requestId] Network requests list, or detail by request ID
|
|
54
62
|
perf <target> Core Web Vitals + performance metrics
|
|
55
63
|
console <target> [duration_ms] Capture console output (default 5000ms)
|
|
64
|
+
console <target> list Show stored messages since daemon start
|
|
65
|
+
console <target> detail <id> Message detail with stack trace
|
|
56
66
|
domsnapshot <target> [--styles] Structured DOM snapshot with bounding rects
|
|
57
67
|
highlight <target> <sel|clear> Highlight element with overlay
|
|
58
68
|
|
|
@@ -61,14 +71,15 @@ Usage: chromex <command> [args]
|
|
|
61
71
|
evalraw <target> <method> [json] Raw CDP command (some methods blocked)
|
|
62
72
|
|
|
63
73
|
NAVIGATE
|
|
64
|
-
nav <target> <url>
|
|
74
|
+
nav <target> <url|action> Navigate: URL, back, forward, reload, reload-hard
|
|
65
75
|
waitfor <target> <selector> [ms] Wait for CSS selector to appear
|
|
66
76
|
wait <target> <event> [ms] Wait for: networkidle, load, domready, fcp
|
|
67
77
|
scroll <target> <dir> [amount] Scroll: up, down, top, bottom, to <selector>
|
|
68
78
|
|
|
69
79
|
INTERACT
|
|
70
|
-
click <target> <selector>
|
|
71
|
-
clickxy <target> <x> <y>
|
|
80
|
+
click <target> <selector> [--dbl] Click element (supports double-click)
|
|
81
|
+
clickxy <target> <x> <y> [--dbl] Click at coordinates (supports double-click)
|
|
82
|
+
key <target> <combo> Press key: Enter, Tab, Escape, Control+A, Meta+C
|
|
72
83
|
type <target> <text> Type text at current focus
|
|
73
84
|
drag <target> <from> <to> Drag & drop (selectors or x1,y1 x2,y2)
|
|
74
85
|
touch <target> <gesture> [args] Touch: tap, swipe, pinch, longpress
|
|
@@ -99,6 +110,7 @@ Usage: chromex <command> [args]
|
|
|
99
110
|
timezone <target> <tz|reset> Set timezone (e.g. America/Sao_Paulo)
|
|
100
111
|
locale <target> <locale|reset> Set locale (e.g. pt-BR)
|
|
101
112
|
cpu <target> <rate|reset> CPU throttle (1=normal, 4=4x slower, 6=mobile)
|
|
113
|
+
resize <target> <w> <h> [dpr] Resize viewport to custom dimensions
|
|
102
114
|
|
|
103
115
|
ADVANCED
|
|
104
116
|
inject <target> <script|flags> Inject JS on every page load (--file, --remove, --list)
|
|
@@ -108,6 +120,13 @@ Usage: chromex <command> [args]
|
|
|
108
120
|
heap <target> snapshot [file] Heap snapshot for memory analysis
|
|
109
121
|
webauthn <target> enable|creds|dis Virtual authenticator for passkey testing
|
|
110
122
|
|
|
123
|
+
AUDIT
|
|
124
|
+
audit <target> [categories] [device] Lighthouse audit (performance, accessibility, SEO)
|
|
125
|
+
categories: performance,accessibility,seo,best-practices
|
|
126
|
+
device: mobile (default) or desktop
|
|
127
|
+
stats <target> [--full] [--reset] Session analytics (command counts, timing, errors)
|
|
128
|
+
--export=/path/to/stats.json Export as JSON
|
|
129
|
+
|
|
111
130
|
DAEMON
|
|
112
131
|
stop [target] Stop daemon(s)
|
|
113
132
|
|
|
@@ -166,7 +185,8 @@ async function main() {
|
|
|
166
185
|
|
|
167
186
|
// Launch
|
|
168
187
|
if (cmd === 'launch') {
|
|
169
|
-
const options = parseFlags(args, ['incognito'], ['browser', 'profile', 'url']);
|
|
188
|
+
const options = parseFlags(args, ['incognito', 'headless', 'insecure'], ['browser', 'profile', 'url', 'proxy', 'chrome-arg']);
|
|
189
|
+
if (options['chrome-arg']) { options.chromeArgs = [options['chrome-arg']]; delete options['chrome-arg']; }
|
|
170
190
|
const result = await launchBrowser(options);
|
|
171
191
|
console.log(result);
|
|
172
192
|
return;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Lighthouse audit via subprocess (zero deps -- invokes npx lighthouse externally)
|
|
2
|
+
// Chrome: connects to existing browser via --port (reuses session)
|
|
3
|
+
// Other browsers (Brave, Edge, etc.): Lighthouse launches its own headless Chrome
|
|
4
|
+
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { evalStr } from './evaluate.mjs';
|
|
8
|
+
|
|
9
|
+
const VALID_CATEGORIES = ['performance', 'accessibility', 'seo', 'best-practices'];
|
|
10
|
+
|
|
11
|
+
// Find any Chromium-based browser for CHROME_PATH env var
|
|
12
|
+
function findChromiumPath() {
|
|
13
|
+
const paths = process.platform === 'darwin' ? [
|
|
14
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
15
|
+
'/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
|
|
16
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
17
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
18
|
+
] : [
|
|
19
|
+
'/usr/bin/google-chrome', '/usr/bin/google-chrome-stable',
|
|
20
|
+
'/usr/bin/brave-browser', '/usr/bin/chromium-browser', '/usr/bin/chromium',
|
|
21
|
+
'/usr/bin/microsoft-edge',
|
|
22
|
+
];
|
|
23
|
+
for (const p of paths) {
|
|
24
|
+
if (existsSync(p)) return p;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check if Chrome's HTTP debug endpoint is available (Brave/Edge don't expose it)
|
|
30
|
+
function isHttpDebugAvailable(port) {
|
|
31
|
+
try {
|
|
32
|
+
const result = execSync(`curl -sf http://127.0.0.1:${port}/json/version`, {
|
|
33
|
+
encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'],
|
|
34
|
+
});
|
|
35
|
+
return result.length > 0;
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function auditStr(cdp, sid, categories, device, reportPath) {
|
|
42
|
+
// Get current page URL
|
|
43
|
+
const url = await evalStr(cdp, sid, 'window.location.href');
|
|
44
|
+
if (!url || url === 'about:blank') {
|
|
45
|
+
throw new Error('Navigate to a page first before running audit.');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Validate categories
|
|
49
|
+
const cats = categories
|
|
50
|
+
? categories.split(',').map(c => c.trim().toLowerCase()).filter(c => VALID_CATEGORIES.includes(c))
|
|
51
|
+
: VALID_CATEGORIES;
|
|
52
|
+
|
|
53
|
+
if (cats.length === 0) {
|
|
54
|
+
throw new Error(`Invalid categories. Valid: ${VALID_CATEGORIES.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build base args
|
|
58
|
+
const args = [
|
|
59
|
+
'--output=json',
|
|
60
|
+
`--only-categories=${cats.join(',')}`,
|
|
61
|
+
'--quiet',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
if (device === 'desktop') args.push('--preset=desktop');
|
|
65
|
+
|
|
66
|
+
if (reportPath) {
|
|
67
|
+
args.push(`--output-path=${reportPath}`);
|
|
68
|
+
args.push('--output=html');
|
|
69
|
+
args.push('--output=json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Detect: Chrome (has /json/version) vs other browsers (Brave, Edge, etc.)
|
|
73
|
+
let port;
|
|
74
|
+
if (cdp.wsUrl) {
|
|
75
|
+
const m = cdp.wsUrl.match(/:(\d+)\//);
|
|
76
|
+
if (m) port = m[1];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let mode;
|
|
80
|
+
if (port && isHttpDebugAvailable(port)) {
|
|
81
|
+
// Chrome: reuse existing browser session
|
|
82
|
+
args.push(`--port=${port}`);
|
|
83
|
+
mode = 'connected (existing browser)';
|
|
84
|
+
} else {
|
|
85
|
+
// Brave/Edge/other: Lighthouse launches its own headless Chrome
|
|
86
|
+
args.push('--chrome-flags=--headless=new');
|
|
87
|
+
mode = 'standalone (headless Chrome)';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const cmd = `npx --yes lighthouse ${JSON.stringify(url)} ${args.join(' ')}`;
|
|
91
|
+
|
|
92
|
+
// Set CHROME_PATH for standalone mode (Lighthouse uses chrome-launcher which reads it)
|
|
93
|
+
const env = { ...process.env };
|
|
94
|
+
if (mode.startsWith('standalone')) {
|
|
95
|
+
const chromePath = findChromiumPath();
|
|
96
|
+
if (chromePath) env.CHROME_PATH = chromePath;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let jsonOutput;
|
|
100
|
+
try {
|
|
101
|
+
jsonOutput = execSync(cmd, {
|
|
102
|
+
encoding: 'utf8',
|
|
103
|
+
timeout: 120000,
|
|
104
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
105
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
106
|
+
env,
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
const stderr = e.stderr?.toString().trim() || '';
|
|
110
|
+
if (stderr.includes('not found') || stderr.includes('ENOENT')) {
|
|
111
|
+
throw new Error('lighthouse not found. Install: npm i -g lighthouse');
|
|
112
|
+
}
|
|
113
|
+
if (stderr.includes('No Chrome installations found')) {
|
|
114
|
+
throw new Error('Lighthouse needs Chrome installed to run in standalone mode. Install Google Chrome or run against a Chrome instance with debug port.');
|
|
115
|
+
}
|
|
116
|
+
throw new Error(`Lighthouse failed: ${stderr || e.message}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Parse JSON output
|
|
120
|
+
let report;
|
|
121
|
+
try {
|
|
122
|
+
report = JSON.parse(jsonOutput);
|
|
123
|
+
} catch {
|
|
124
|
+
const jsonStart = jsonOutput.lastIndexOf('{"');
|
|
125
|
+
if (jsonStart > 0) {
|
|
126
|
+
report = JSON.parse(jsonOutput.slice(jsonStart));
|
|
127
|
+
} else {
|
|
128
|
+
throw new Error('Failed to parse Lighthouse output.');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Format results
|
|
133
|
+
const lines = [];
|
|
134
|
+
|
|
135
|
+
// Scores
|
|
136
|
+
const scores = {};
|
|
137
|
+
for (const cat of cats) {
|
|
138
|
+
const c = report.categories?.[cat];
|
|
139
|
+
if (c) scores[c.title] = Math.round((c.score || 0) * 100);
|
|
140
|
+
}
|
|
141
|
+
const scoreStr = Object.entries(scores).map(([k, v]) => `${k}: ${v}`).join(' | ');
|
|
142
|
+
lines.push(`Lighthouse Audit: ${scoreStr}`);
|
|
143
|
+
lines.push(`URL: ${url}`);
|
|
144
|
+
lines.push(`Device: ${device || 'mobile'} | Mode: ${mode}`);
|
|
145
|
+
lines.push('');
|
|
146
|
+
|
|
147
|
+
// Top opportunities
|
|
148
|
+
const audits = report.audits || {};
|
|
149
|
+
const opportunities = Object.values(audits)
|
|
150
|
+
.filter(a => a.details?.type === 'opportunity' && a.details?.overallSavingsMs > 0)
|
|
151
|
+
.sort((a, b) => (b.details.overallSavingsMs || 0) - (a.details.overallSavingsMs || 0))
|
|
152
|
+
.slice(0, 5);
|
|
153
|
+
|
|
154
|
+
if (opportunities.length > 0) {
|
|
155
|
+
lines.push('Top Opportunities:');
|
|
156
|
+
for (const opp of opportunities) {
|
|
157
|
+
const savings = (opp.details.overallSavingsMs / 1000).toFixed(1);
|
|
158
|
+
lines.push(` - ${opp.title} (savings: ${savings}s)`);
|
|
159
|
+
}
|
|
160
|
+
lines.push('');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Critical diagnostics
|
|
164
|
+
const diagnostics = Object.values(audits)
|
|
165
|
+
.filter(a => a.score !== null && a.score < 0.5 && a.details?.type !== 'opportunity')
|
|
166
|
+
.sort((a, b) => (a.score || 0) - (b.score || 0))
|
|
167
|
+
.slice(0, 5);
|
|
168
|
+
|
|
169
|
+
if (diagnostics.length > 0) {
|
|
170
|
+
lines.push('Critical Issues:');
|
|
171
|
+
for (const diag of diagnostics) {
|
|
172
|
+
lines.push(` - ${diag.title}: ${diag.displayValue || 'needs improvement'}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (reportPath) {
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push(`Full report saved to: ${reportPath}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return lines.join('\n');
|
|
182
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Console: live capture, stored message list, and detail with stack traces
|
|
2
2
|
|
|
3
3
|
import { sleep } from '../utils.mjs';
|
|
4
4
|
|
|
@@ -9,7 +9,7 @@ export async function consoleStr(cdp, sid, durationMs = 5000) {
|
|
|
9
9
|
await cdp.send('Runtime.enable', {}, sid);
|
|
10
10
|
|
|
11
11
|
const off = cdp.onEvent('Runtime.consoleAPICalled', (params) => {
|
|
12
|
-
const type = params.type;
|
|
12
|
+
const type = params.type;
|
|
13
13
|
const args = (params.args || []).map(a => {
|
|
14
14
|
if (a.type === 'string') return a.value;
|
|
15
15
|
if (a.type === 'number') return String(a.value);
|
|
@@ -35,3 +35,33 @@ export async function consoleStr(cdp, sid, durationMs = 5000) {
|
|
|
35
35
|
return `[${e.ts}] ${prefix.padEnd(3)} ${e.msg.substring(0, 200)}`;
|
|
36
36
|
}).join('\n');
|
|
37
37
|
}
|
|
38
|
+
|
|
39
|
+
export function consoleListStr(consoleMessages) {
|
|
40
|
+
if (consoleMessages.length === 0) return 'No console messages captured since daemon started.';
|
|
41
|
+
const msgs = consoleMessages.slice(-50);
|
|
42
|
+
return msgs.map(e => {
|
|
43
|
+
const ts = new Date(e.ts).toISOString().slice(11, 23);
|
|
44
|
+
const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
|
|
45
|
+
return `[${e.id}] ${ts} ${prefix.padEnd(3)} ${e.args.join(' ').substring(0, 200)}`;
|
|
46
|
+
}).join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function consoleDetailStr(consoleMessages, msgId) {
|
|
50
|
+
const id = parseInt(msgId);
|
|
51
|
+
const msg = consoleMessages.find(m => m.id === id);
|
|
52
|
+
if (!msg) return `Message #${msgId} not found. Use "console list" to see stored messages.`;
|
|
53
|
+
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`${msg.type.toUpperCase()} #${msg.id} at ${new Date(msg.ts).toISOString()}`);
|
|
56
|
+
lines.push(msg.args.join(' '));
|
|
57
|
+
|
|
58
|
+
if (msg.stackTrace?.callFrames?.length) {
|
|
59
|
+
lines.push('\nStack Trace:');
|
|
60
|
+
for (const f of msg.stackTrace.callFrames) {
|
|
61
|
+
const loc = f.url ? `${f.url}:${f.lineNumber + 1}:${f.columnNumber + 1}` : '(native)';
|
|
62
|
+
lines.push(` at ${f.functionName || '(anonymous)'} (${loc})`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
@@ -42,3 +42,18 @@ export async function emulateStr(cdp, sid, device) {
|
|
|
42
42
|
|
|
43
43
|
return `Emulating ${device}: ${preset.width}x${preset.height} @${preset.deviceScaleFactor}x${preset.mobile ? ' (mobile)' : ''}`;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
export async function resizeStr(cdp, sid, widthStr, heightStr, dprStr) {
|
|
47
|
+
const width = parseInt(widthStr);
|
|
48
|
+
const height = parseInt(heightStr);
|
|
49
|
+
if (!width || !height || width < 1 || height < 1) {
|
|
50
|
+
throw new Error('Width and height required (e.g. resize 1280 720)');
|
|
51
|
+
}
|
|
52
|
+
const dpr = dprStr ? parseFloat(dprStr) : 1;
|
|
53
|
+
|
|
54
|
+
await cdp.send('Emulation.setDeviceMetricsOverride', {
|
|
55
|
+
width, height, deviceScaleFactor: dpr, mobile: false,
|
|
56
|
+
}, sid);
|
|
57
|
+
|
|
58
|
+
return `Viewport resized to ${width}x${height} @${dpr}x`;
|
|
59
|
+
}
|
|
@@ -3,33 +3,43 @@
|
|
|
3
3
|
import { sleep } from '../utils.mjs';
|
|
4
4
|
import { evalStr } from './evaluate.mjs';
|
|
5
5
|
|
|
6
|
-
export async function clickStr(cdp, sid, selector) {
|
|
6
|
+
export async function clickStr(cdp, sid, selector, dbl = false) {
|
|
7
7
|
if (!selector) throw new Error('CSS selector required');
|
|
8
|
+
const dblStr = dbl ? 'true' : 'false';
|
|
8
9
|
const expr = `
|
|
9
10
|
(function() {
|
|
10
11
|
const el = document.querySelector(${JSON.stringify(selector)});
|
|
11
12
|
if (!el) return { ok: false, error: 'Element not found: ' + ${JSON.stringify(selector)} };
|
|
12
13
|
el.scrollIntoView({ block: 'center' });
|
|
13
14
|
el.click();
|
|
15
|
+
if (${dblStr}) el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true }));
|
|
14
16
|
return { ok: true, tag: el.tagName, text: el.textContent.trim().substring(0, 80) };
|
|
15
17
|
})()
|
|
16
18
|
`;
|
|
17
19
|
const result = await evalStr(cdp, sid, expr);
|
|
18
20
|
const r = JSON.parse(result);
|
|
19
21
|
if (!r.ok) throw new Error(r.error);
|
|
20
|
-
|
|
22
|
+
const verb = dbl ? 'Double-clicked' : 'Clicked';
|
|
23
|
+
return `${verb} <${r.tag}> "${r.text}"`;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
export async function clickXyStr(cdp, sid, x, y) {
|
|
26
|
+
export async function clickXyStr(cdp, sid, x, y, dbl = false) {
|
|
24
27
|
const cx = parseFloat(x);
|
|
25
28
|
const cy = parseFloat(y);
|
|
26
29
|
if (isNaN(cx) || isNaN(cy)) throw new Error('x and y must be numbers (CSS pixels)');
|
|
27
|
-
const
|
|
30
|
+
const clickCount = dbl ? 2 : 1;
|
|
31
|
+
const base = { x: cx, y: cy, button: 'left', clickCount, modifiers: 0 };
|
|
28
32
|
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseMoved' }, sid);
|
|
29
33
|
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);
|
|
30
34
|
await sleep(50);
|
|
31
35
|
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);
|
|
32
|
-
|
|
36
|
+
if (dbl) {
|
|
37
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mousePressed' }, sid);
|
|
38
|
+
await sleep(50);
|
|
39
|
+
await cdp.send('Input.dispatchMouseEvent', { ...base, type: 'mouseReleased' }, sid);
|
|
40
|
+
}
|
|
41
|
+
const verb = dbl ? 'Double-clicked' : 'Clicked';
|
|
42
|
+
return `${verb} at CSS (${cx}, ${cy})`;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export async function typeStr(cdp, sid, text) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Keyboard input: press keys and key combinations via CDP Input.dispatchKeyEvent
|
|
2
|
+
|
|
3
|
+
// Key name -> { key, code, keyCode } mapping for CDP
|
|
4
|
+
const KEY_MAP = {
|
|
5
|
+
enter: { key: 'Enter', code: 'Enter', keyCode: 13 },
|
|
6
|
+
tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
|
|
7
|
+
escape: { key: 'Escape', code: 'Escape', keyCode: 27 },
|
|
8
|
+
backspace: { key: 'Backspace', code: 'Backspace', keyCode: 8 },
|
|
9
|
+
delete: { key: 'Delete', code: 'Delete', keyCode: 46 },
|
|
10
|
+
space: { key: ' ', code: 'Space', keyCode: 32 },
|
|
11
|
+
arrowup: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
|
|
12
|
+
arrowdown: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
|
|
13
|
+
arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
|
|
14
|
+
arrowright: { key: 'ArrowRight',code: 'ArrowRight', keyCode: 39 },
|
|
15
|
+
home: { key: 'Home', code: 'Home', keyCode: 36 },
|
|
16
|
+
end: { key: 'End', code: 'End', keyCode: 35 },
|
|
17
|
+
pageup: { key: 'PageUp', code: 'PageUp', keyCode: 33 },
|
|
18
|
+
pagedown: { key: 'PageDown', code: 'PageDown', keyCode: 34 },
|
|
19
|
+
insert: { key: 'Insert', code: 'Insert', keyCode: 45 },
|
|
20
|
+
// Modifier keys (needed for keyDown/keyUp dispatch of modifiers themselves)
|
|
21
|
+
control: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
22
|
+
ctrl: { key: 'Control', code: 'ControlLeft', keyCode: 17 },
|
|
23
|
+
shift: { key: 'Shift', code: 'ShiftLeft', keyCode: 16 },
|
|
24
|
+
alt: { key: 'Alt', code: 'AltLeft', keyCode: 18 },
|
|
25
|
+
meta: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
|
|
26
|
+
cmd: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
|
|
27
|
+
command: { key: 'Meta', code: 'MetaLeft', keyCode: 91 },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// F1-F12
|
|
31
|
+
for (let i = 1; i <= 12; i++) {
|
|
32
|
+
KEY_MAP[`f${i}`] = { key: `F${i}`, code: `F${i}`, keyCode: 111 + i };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Modifier name -> CDP modifier bitfield
|
|
36
|
+
const MODIFIERS = {
|
|
37
|
+
alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
function resolveKey(name) {
|
|
41
|
+
const lower = name.toLowerCase();
|
|
42
|
+
const mapped = KEY_MAP[lower];
|
|
43
|
+
if (mapped) return mapped;
|
|
44
|
+
|
|
45
|
+
// Single letter a-z
|
|
46
|
+
if (lower.length === 1 && lower >= 'a' && lower <= 'z') {
|
|
47
|
+
return { key: lower, code: `Key${lower.toUpperCase()}`, keyCode: lower.charCodeAt(0) - 32 };
|
|
48
|
+
}
|
|
49
|
+
// Single digit 0-9
|
|
50
|
+
if (lower.length === 1 && lower >= '0' && lower <= '9') {
|
|
51
|
+
return { key: lower, code: `Digit${lower}`, keyCode: lower.charCodeAt(0) };
|
|
52
|
+
}
|
|
53
|
+
// Single special char (pass through)
|
|
54
|
+
if (name.length === 1) {
|
|
55
|
+
return { key: name, code: '', keyCode: name.charCodeAt(0) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
throw new Error(`Unknown key: "${name}". Common keys: Enter, Tab, Escape, Backspace, Delete, Space, ArrowUp/Down/Left/Right, Home, End, PageUp, PageDown, F1-F12, a-z, 0-9`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse "Control+Shift+A" -> { modifiers: 10, modifierNames: [...], key: {...} }
|
|
62
|
+
function parseKeyCombo(combo) {
|
|
63
|
+
if (!combo) throw new Error('Key combination required (e.g. "Enter", "Control+A", "Control+Shift+R")');
|
|
64
|
+
|
|
65
|
+
// Split on + but handle edge case "Control++" (last + is the key)
|
|
66
|
+
const parts = [];
|
|
67
|
+
let buf = '';
|
|
68
|
+
for (const ch of combo) {
|
|
69
|
+
if (ch === '+' && buf) {
|
|
70
|
+
parts.push(buf);
|
|
71
|
+
buf = '';
|
|
72
|
+
} else {
|
|
73
|
+
buf += ch;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (buf) parts.push(buf);
|
|
77
|
+
|
|
78
|
+
let modBits = 0;
|
|
79
|
+
const modNames = [];
|
|
80
|
+
const keyParts = [];
|
|
81
|
+
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
const mod = MODIFIERS[part.toLowerCase()];
|
|
84
|
+
if (mod !== undefined) {
|
|
85
|
+
modBits |= mod;
|
|
86
|
+
modNames.push(part);
|
|
87
|
+
} else {
|
|
88
|
+
keyParts.push(part);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (keyParts.length === 0) throw new Error(`No key found in combination: "${combo}". Modifiers alone are not valid.`);
|
|
93
|
+
if (keyParts.length > 1) throw new Error(`Multiple non-modifier keys in "${combo}": ${keyParts.join(', ')}. Use only one primary key.`);
|
|
94
|
+
|
|
95
|
+
return { modifiers: modBits, modifierNames: modNames, key: resolveKey(keyParts[0]) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Exported for testing
|
|
99
|
+
export { parseKeyCombo };
|
|
100
|
+
|
|
101
|
+
export async function pressKeyStr(cdp, sid, combo) {
|
|
102
|
+
const { modifiers, modifierNames, key } = parseKeyCombo(combo);
|
|
103
|
+
|
|
104
|
+
const base = { modifiers, key: key.key, code: key.code, windowsVirtualKeyCode: key.keyCode };
|
|
105
|
+
|
|
106
|
+
// Press modifier keys down
|
|
107
|
+
for (const name of modifierNames) {
|
|
108
|
+
const modKey = resolveKey(name);
|
|
109
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', key: modKey.key, code: modKey.code, modifiers }, sid);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Press and release the primary key
|
|
113
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', ...base }, sid);
|
|
114
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }, sid);
|
|
115
|
+
|
|
116
|
+
// Release modifier keys in reverse order
|
|
117
|
+
for (const name of modifierNames.toReversed()) {
|
|
118
|
+
const modKey = resolveKey(name);
|
|
119
|
+
await cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: modKey.key, code: modKey.code, modifiers: 0 }, sid);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return `Pressed ${combo}`;
|
|
123
|
+
}
|