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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # Chromex
2
2
 
3
- Zero-dependency Chrome DevTools Protocol toolkit for AI agents. 52 typed MCP tools + CLI. Connects directly to Chrome, Brave, Edge, or Chromium via WebSocket. No Puppeteer, no bloat.
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
- - **52 MCP tools** -- typed JSON Schema, annotations (`readOnlyHint`, `destructiveHint`), inline screenshots (base64)
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 52 MCP tools at once. For granular control, approve individual tools:
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 net <target> # Network resource timing
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.3.0",
4
- "description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 52 typed tools, per-tab daemons, security hardened.",
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] [--full] Screenshot (viewport or full page)
53
- net <target> Network performance entries
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> Navigate to URL and wait for load
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> Click element by CSS selector
71
- clickxy <target> <x> <y> Click at CSS pixel coordinates
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;
@@ -16,6 +16,7 @@ export class CDP {
16
16
  }
17
17
 
18
18
  async connect(wsUrl) {
19
+ this.wsUrl = wsUrl;
19
20
  return new Promise((res, rej) => {
20
21
  this.#ws = new WebSocket(wsUrl);
21
22
  this.#ws.onopen = () => res();
@@ -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
- // Captura de console.log/error/warn
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; // log, error, warn, info, debug
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
- return `Clicked <${r.tag}> "${r.text}"`;
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 base = { x: cx, y: cy, button: 'left', clickCount: 1, modifiers: 0 };
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
- return `Clicked at CSS (${cx}, ${cy})`;
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
+ }