chromex-mcp 1.4.0 → 1.5.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 +119 -0
- package/package.json +7 -6
- package/plugins/chromex/skills/chromex/scripts/chromex.mjs +12 -2
- package/plugins/chromex/skills/chromex/scripts/lib/commands/console.mjs +34 -6
- package/plugins/chromex/skills/chromex/scripts/lib/commands/cookies.mjs +2 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/network.mjs +34 -5
- package/plugins/chromex/skills/chromex/scripts/lib/commands/perf.mjs +10 -0
- package/plugins/chromex/skills/chromex/scripts/lib/commands/snapshot.mjs +101 -52
- package/plugins/chromex/skills/chromex/scripts/lib/commands/stats.mjs +4 -1
- package/plugins/chromex/skills/chromex/scripts/lib/commands/storage.mjs +3 -2
- package/plugins/chromex/skills/chromex/scripts/lib/daemon.mjs +50 -13
- package/plugins/chromex/skills/chromex/scripts/lib/hints.mjs +203 -0
- package/plugins/chromex/skills/chromex/scripts/lib/output.mjs +74 -0
- package/plugins/chromex/skills/chromex/scripts/mcp-server.mjs +22 -2
package/README.md
CHANGED
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
|
|
3
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
|
+
Designed from the ground up for token efficiency: incremental diffs, query-filtered snapshots, ref-based selection, and a plain-text output format that consistently beats JSON and YAML-style structured alternatives by 25% to 126% in head-to-head token measurements (see [Token Efficiency](#token-efficiency) below).
|
|
6
|
+
|
|
5
7
|
## Features
|
|
6
8
|
|
|
7
9
|
- **56 MCP tools** -- typed JSON Schema, annotations (`readOnlyHint`, `destructiveHint`), inline screenshots (base64)
|
|
8
10
|
- **Zero dependencies** -- uses only Node.js 22+ built-in modules (WebSocket, fs, net, crypto)
|
|
9
11
|
- **Ref-based selection** -- `snap --refs` assigns `@e1`, `@e2`... to interactive elements, then `click @e5` or `fill @e3 "value"`. No fragile CSS selectors
|
|
10
12
|
- **Incremental snapshots** -- second snapshot returns only changed nodes (diff), reducing output from thousands of lines to just what changed
|
|
13
|
+
- **Query-filtered snapshots** -- `snap --query=login` returns only matching nodes and their ancestors, cutting output by 95% to 99% on large pages like GitHub, Jira, or Gmail
|
|
11
14
|
- **Auto-snapshot** -- interactive commands (click, fill, nav, etc.) automatically append an incremental snapshot with refs, so the agent sees the page state in a single round-trip
|
|
15
|
+
- **Contextual hints** -- after each action, chromex appends up to 3 `help[]` next-step suggestions picked from the current ref map, eliminating the "what do I do next" turn. Opt-out with `--no-hints`
|
|
16
|
+
- **Pre-computed aggregates** -- `net` and `console` outputs embed counters (`network[47] errors:3 pending:0 ok:44`, `console[12] errors:2 warnings:4 info:6`) so the agent never needs a follow-up count
|
|
12
17
|
- **Scroll detection** -- snapshots report scrollable containers with remaining distance (`[scroll: page: down:1200px | sidebar: up:300px]`)
|
|
13
18
|
- **Per-tab persistent daemons** -- each tab gets a background process connected via Unix socket. Chrome's "Allow debugging" modal fires once, not on every command
|
|
14
19
|
- **Security hardened** -- domain filtering (allow/blocklist), CDP method blocklist, token-authenticated sockets, full audit log
|
|
@@ -157,6 +162,7 @@ chromex snap <target> # Accessibility tree snapshot (compa
|
|
|
157
162
|
chromex snap <target> --refs # With interactive refs (@e1, @e2...)
|
|
158
163
|
chromex snap <target> --depth=3 # Limit tree depth
|
|
159
164
|
chromex snap <target> --full # Force full snapshot (skip diff)
|
|
165
|
+
chromex snap <target> --query=login # Filter to matching nodes + ancestors (hierarchy preserved)
|
|
160
166
|
chromex html <target> "#main" # Element HTML by selector
|
|
161
167
|
chromex shot <target> /tmp/page.png # Viewport screenshot
|
|
162
168
|
chromex shot <target> /tmp/full.png --full # Full page screenshot
|
|
@@ -381,6 +387,30 @@ chromex snap <target> --depth=3 # Only 3 levels deep
|
|
|
381
387
|
|
|
382
388
|
Nodes at the depth limit render as leaves (children are not expanded).
|
|
383
389
|
|
|
390
|
+
### Query Filter
|
|
391
|
+
|
|
392
|
+
On large pages, a full accessibility tree can be tens of kilobytes. Use `--query` to keep only the nodes you care about, with their ancestors preserved so the hierarchy stays intact:
|
|
393
|
+
|
|
394
|
+
```bash
|
|
395
|
+
chromex snap <target> --query=login # Substring match (case-insensitive)
|
|
396
|
+
chromex snap <target> --query=issues # role/name/value all searched
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Matched nodes are prefixed with `>` in the output, so the agent can spot them at a glance. Ancestor chains are included from the match up to the root, so the agent still understands the surrounding structure.
|
|
400
|
+
|
|
401
|
+
`@eN` refs stay stable across filtered and unfiltered calls because the ref map is always computed against the full tree. A filtered `snap --query=login` followed by a plain `snap --refs` returns the same ref numbering. Fingerprints for incremental diff are also computed on the full tree, so the next non-query snapshot still produces a correct diff against the previous state.
|
|
402
|
+
|
|
403
|
+
Measured reduction on a real 65 KB GitHub repo page snapshot:
|
|
404
|
+
|
|
405
|
+
| Query | Output bytes | Reduction |
|
|
406
|
+
|-------|-------------:|----------:|
|
|
407
|
+
| `snap --full` (baseline) | 65,455 | -- |
|
|
408
|
+
| `snap --query=issues` | 317 | **-99.5%** |
|
|
409
|
+
| `snap --query=star` | 1,490 | **-97.7%** |
|
|
410
|
+
| `snap --query=readme` | 936 | **-98.6%** |
|
|
411
|
+
|
|
412
|
+
When no node matches, chromex returns the explicit empty state `snap: no matches for query "X"` so the agent never confuses an empty filter with a silent failure.
|
|
413
|
+
|
|
384
414
|
### Scroll Detection
|
|
385
415
|
|
|
386
416
|
Snapshots automatically detect scrollable containers and report remaining scroll distance:
|
|
@@ -421,6 +451,95 @@ chromex fill <target> @e2 "secret123" --no-snap
|
|
|
421
451
|
chromex click <target> @e3 # Only this one triggers snapshot
|
|
422
452
|
```
|
|
423
453
|
|
|
454
|
+
## Contextual Hints
|
|
455
|
+
|
|
456
|
+
After any action that produces a fresh ref map (auto-snap on interactive commands, or an explicit `snap --refs`), chromex appends a `help[N]:` block with up to 3 next-step suggestions picked heuristically from the current elements and the last command:
|
|
457
|
+
|
|
458
|
+
```
|
|
459
|
+
Navigated to https://github.com/login
|
|
460
|
+
|
|
461
|
+
RootWebArea "Sign in to GitHub"
|
|
462
|
+
@e1 [textbox] Username or email address
|
|
463
|
+
@e2 [textbox] Password
|
|
464
|
+
@e3 [button] Sign in
|
|
465
|
+
@e4 [link] Forgot password?
|
|
466
|
+
|
|
467
|
+
help[3]:
|
|
468
|
+
chromex fill <t> @e1 "<value>" # textbox "Username or email address"
|
|
469
|
+
chromex click <t> @e3 # button "Sign in"
|
|
470
|
+
chromex click <t> @e4 # link "Forgot password?"
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
The agent gets the most probable next commands inline, eliminating the "decide what to click" turn. Heuristic rules:
|
|
474
|
+
|
|
475
|
+
- **After `fill`** -- priority is a matching submit button (label matches `login`, `submit`, `send`, `search`, `go`, `continue`, ...) or `key Enter` fallback, then the next unfilled input.
|
|
476
|
+
- **After `nav`** -- first input (highest priority), then first submit button, then first link.
|
|
477
|
+
- **After `snap --refs`** (or any default) -- top interactive elements with non-empty names.
|
|
478
|
+
- **Maximum 3 hints per response.**
|
|
479
|
+
|
|
480
|
+
### Staleness Guard
|
|
481
|
+
|
|
482
|
+
Hints are only emitted when chromex can guarantee the ref map matches the DOM that was just rendered. This prevents the agent from clicking `@eN` coordinates that no longer exist on screen:
|
|
483
|
+
|
|
484
|
+
| Command | Hints? | Why |
|
|
485
|
+
|---------|--------|-----|
|
|
486
|
+
| `click`, `fill`, `nav`, `type`, ... (default) | Yes | auto-snap just ran with refs |
|
|
487
|
+
| `click @e1 --no-snap`, `fill ... --no-snap` | **No** | ref map may be stale |
|
|
488
|
+
| `snap --refs` | Yes | ref map populated by this call |
|
|
489
|
+
| `snap --refs` on a page with zero interactive elements | **No** | nothing to suggest, avoids a `snap --refs` loop |
|
|
490
|
+
| Bare `snap` (no `--refs`) | **No** | ref map was not refreshed |
|
|
491
|
+
|
|
492
|
+
Navigation (URL, back, forward, reload) always clears the ref map before dispatching, so post-nav hints always reflect the new page.
|
|
493
|
+
|
|
494
|
+
Opt out explicitly with `--no-hints` (CLI) or `noHints: true` (MCP) for scripts that parse output strictly.
|
|
495
|
+
|
|
496
|
+
## Token Efficiency
|
|
497
|
+
|
|
498
|
+
Chromex outputs are designed to be read by LLM agents, not humans. Every format decision -- plain text, refs over CSS selectors, incremental diffs, query filters -- was made to minimize tokens while preserving the information the agent actually needs to act.
|
|
499
|
+
|
|
500
|
+
### Measured against common alternatives
|
|
501
|
+
|
|
502
|
+
We measured the current plain-text output against three structured format candidates often proposed for agent interfaces: minified JSON, pretty-printed JSON, and a TOON-style compact encoder (a zero-dependency 60-line implementation of the YAML-inline compact style). Five representative chromex outputs were encoded in each format and tokenized with `tiktoken` (`cl100k_base`). Lower token counts are better.
|
|
503
|
+
|
|
504
|
+
#### Case by case
|
|
505
|
+
|
|
506
|
+
| Case | text-free | json-min | json-pretty | toon-compact |
|
|
507
|
+
|--------------------------------|-----------:|---------------:|----------------:|----------------:|
|
|
508
|
+
| `snap-login-small` | **130** | 178 (+37%) | 301 (+132%) | 212 (+63%) |
|
|
509
|
+
| `snap-repo-large` (65 KB real) | **19,702** | 34,315 (+74%) | 63,232 (+221%) | 44,613 (+126%) |
|
|
510
|
+
| `net-list-50` | **454** | 574 (+26%) | 921 (+103%) | 706 (+56%) |
|
|
511
|
+
| `console-list-12` | **273** | 363 (+33%) | 576 (+111%) | 453 (+66%) |
|
|
512
|
+
| `fill-action-small` | **99** | 127 (+28%) | 221 (+123%) | 146 (+48%) |
|
|
513
|
+
|
|
514
|
+
The five cases cover the most common outputs an agent sees during a real session: a small accessibility snapshot with refs (a login form), a large accessibility snapshot of a real GitHub repository page, a network list with 50 tracked requests, a console list with a mix of `log`, `warn` and `error` entries, and a post-action result (fill + incremental diff + hints).
|
|
515
|
+
|
|
516
|
+
#### Aggregate (5 cases combined)
|
|
517
|
+
|
|
518
|
+
| Format | tokens | vs text-free |
|
|
519
|
+
|------------------------|----------:|-------------:|
|
|
520
|
+
| **text-free (current)**| **20,658**| -- |
|
|
521
|
+
| json-min | 35,557 | +72.1% |
|
|
522
|
+
| json-pretty | 65,251 | +215.9% |
|
|
523
|
+
| toon-compact (custom) | 46,130 | +123.3% |
|
|
524
|
+
|
|
525
|
+
The current plain-text output is the most token-efficient in every single case. Every structured alternative costs more tokens, not less, because JSON and YAML-style encodings add syntactic overhead (`{`, `}`, `"`, `:`, `,`, indentation) that the chromex plain-text format omits entirely. The chromex format is already dense: short refs (`@e5`), unquoted labels, no wrapping, no redundancy. Structured formats have nothing to optimize away.
|
|
526
|
+
|
|
527
|
+
### Why this matters
|
|
528
|
+
|
|
529
|
+
- **No structured-output refactor is planned.** Chromex keeps the current plain-text format because the data shows it wins.
|
|
530
|
+
- **`@eN` refs beat CSS selectors** not just for reliability (accessibility tree is stable across SPA re-renders) but also for token count -- `@e5` is one token, `document.querySelector('#login-form > div.field input[name="email"]')` is around fifteen.
|
|
531
|
+
- **`--query` and incremental diff** are where the real token savings live. A filtered snapshot on a large page drops output by 95% to 99%, and an incremental diff after an action drops it by 90% or more. These are multiplicative with the already-dense base format.
|
|
532
|
+
|
|
533
|
+
### Reproducing the benchmark
|
|
534
|
+
|
|
535
|
+
The comparison script is checked into the repo and runs standalone:
|
|
536
|
+
|
|
537
|
+
```bash
|
|
538
|
+
node tests/benchmarks/token-format-comparison.mjs
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
It uses `js-tiktoken` (devDependency) with `cl100k_base`, the GPT-4 tokenizer. Claude uses its own BPE tokenizer with a different vocabulary, so absolute numbers would shift by a few percent -- but both are byte-pair encoders over similar corpora, and the direction of the comparison (which format wins) is consistent across BPE tokenizers. Nothing in the results is a rounding error: the gaps are 25% to 220%.
|
|
542
|
+
|
|
424
543
|
## MCP vs CLI
|
|
425
544
|
|
|
426
545
|
Both interfaces call the same core, same daemons, same commands. The difference is how they integrate with Claude Code.
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chromex-mcp",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 56 typed tools, per-tab daemons, security hardened.",
|
|
3
|
+
"version": "1.5.0",
|
|
4
|
+
"description": "Zero-dependency Chrome DevTools Protocol MCP server for AI agents. 56 typed tools, per-tab daemons, security hardened, contextual hints, query-filtered snapshots.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"chromex": "
|
|
8
|
-
"chromex-mcp": "
|
|
9
|
-
"chromex-cli": "
|
|
7
|
+
"chromex": "bin/chromex.mjs",
|
|
8
|
+
"chromex-mcp": "bin/chromex-mcp.mjs",
|
|
9
|
+
"chromex-cli": "bin/chromex-cli.mjs"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"bin/",
|
|
@@ -40,13 +40,14 @@
|
|
|
40
40
|
"license": "MIT",
|
|
41
41
|
"repository": {
|
|
42
42
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/whallysson/chromex"
|
|
43
|
+
"url": "git+https://github.com/whallysson/chromex.git"
|
|
44
44
|
},
|
|
45
45
|
"homepage": "https://github.com/whallysson/chromex#mcp-server-recommended-for-claude-code",
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22.0.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
+
"js-tiktoken": "^1.0.21",
|
|
50
51
|
"vitest": "^3.0.0"
|
|
51
52
|
}
|
|
52
53
|
}
|
|
@@ -51,7 +51,11 @@ Usage: chromex <command> [args]
|
|
|
51
51
|
incognito [url] Create isolated browser context (no relaunch)
|
|
52
52
|
|
|
53
53
|
INSPECT
|
|
54
|
-
snap <target>
|
|
54
|
+
snap <target> [options] Accessibility tree snapshot (compact)
|
|
55
|
+
--refs Assign @eN refs to interactive elements
|
|
56
|
+
--full Force full snapshot (skip incremental diff)
|
|
57
|
+
--depth=N Limit tree depth (nodes at limit render as leaves)
|
|
58
|
+
--query=TEXT Filter to nodes matching substring + ancestors
|
|
55
59
|
html <target> [selector] Get HTML (full page or CSS selector)
|
|
56
60
|
shot <target> [file] [options] Screenshot (viewport, full page, or element)
|
|
57
61
|
--full Full page capture
|
|
@@ -132,6 +136,10 @@ Usage: chromex <command> [args]
|
|
|
132
136
|
|
|
133
137
|
<target> is a unique targetId prefix from "chromex list". Ambiguous prefixes are rejected.
|
|
134
138
|
|
|
139
|
+
OUTPUT FLAGS
|
|
140
|
+
--no-snap Skip auto-snapshot after interactive commands
|
|
141
|
+
--no-hints Suppress contextual help[] suggestions
|
|
142
|
+
|
|
135
143
|
SECURITY
|
|
136
144
|
Config: ~/.chromex/config.json
|
|
137
145
|
- blockedDomains / allowedDomains: domain filtering
|
|
@@ -265,7 +273,8 @@ async function main() {
|
|
|
265
273
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
266
274
|
|
|
267
275
|
const noSnap = args.includes('--no-snap');
|
|
268
|
-
const
|
|
276
|
+
const noHints = args.includes('--no-hints');
|
|
277
|
+
const cmdArgs = args.slice(1).filter(a => a !== '--no-snap' && a !== '--no-hints');
|
|
269
278
|
|
|
270
279
|
// Juntar argumentos para comandos que aceitam texto livre
|
|
271
280
|
if (cmd === 'eval') {
|
|
@@ -300,6 +309,7 @@ async function main() {
|
|
|
300
309
|
}
|
|
301
310
|
|
|
302
311
|
if (noSnap) cmdArgs.push('--no-snap');
|
|
312
|
+
if (noHints) cmdArgs.push('--no-hints');
|
|
303
313
|
const response = await sendCommand(conn, { cmd, args: cmdArgs });
|
|
304
314
|
|
|
305
315
|
if (response.ok) {
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
// Console: live capture, stored message list, and detail with stack traces
|
|
2
2
|
|
|
3
3
|
import { sleep } from '../utils.mjs';
|
|
4
|
+
import { emptyState, aggregate } from '../output.mjs';
|
|
5
|
+
|
|
6
|
+
// Pre-computed aggregates for a list of console entries.
|
|
7
|
+
// Returns { errors, warnings, info } counts; zero keys omitted by callers.
|
|
8
|
+
function bucketConsoleByType(entries) {
|
|
9
|
+
let errors = 0;
|
|
10
|
+
let warnings = 0;
|
|
11
|
+
let info = 0;
|
|
12
|
+
for (const e of entries) {
|
|
13
|
+
const type = e.type;
|
|
14
|
+
if (type === 'error') errors++;
|
|
15
|
+
else if (type === 'warning' || type === 'warn') warnings++;
|
|
16
|
+
else info++;
|
|
17
|
+
}
|
|
18
|
+
const meta = {};
|
|
19
|
+
if (errors) meta.errors = errors;
|
|
20
|
+
if (warnings) meta.warnings = warnings;
|
|
21
|
+
if (info) meta.info = info;
|
|
22
|
+
return meta;
|
|
23
|
+
}
|
|
4
24
|
|
|
5
25
|
export async function consoleStr(cdp, sid, durationMs = 5000) {
|
|
6
26
|
const duration = parseInt(durationMs) || 5000;
|
|
@@ -28,22 +48,30 @@ export async function consoleStr(cdp, sid, durationMs = 5000) {
|
|
|
28
48
|
await sleep(duration);
|
|
29
49
|
off();
|
|
30
50
|
|
|
31
|
-
if (entries.length === 0) return `
|
|
51
|
+
if (entries.length === 0) return emptyState('console', `0 messages captured in ${duration}ms`);
|
|
32
52
|
|
|
33
|
-
|
|
53
|
+
const header = aggregate('console', entries.length, bucketConsoleByType(entries));
|
|
54
|
+
const rows = entries.map(e => {
|
|
34
55
|
const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
|
|
35
56
|
return `[${e.ts}] ${prefix.padEnd(3)} ${e.msg.substring(0, 200)}`;
|
|
36
|
-
})
|
|
57
|
+
});
|
|
58
|
+
return `${header}\n${rows.join('\n')}`;
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
export function consoleListStr(consoleMessages) {
|
|
40
|
-
if (consoleMessages.length === 0) return '
|
|
62
|
+
if (consoleMessages.length === 0) return emptyState('console', '0 messages captured since daemon started');
|
|
63
|
+
|
|
64
|
+
const header = aggregate('console', consoleMessages.length, bucketConsoleByType(consoleMessages));
|
|
41
65
|
const msgs = consoleMessages.slice(-50);
|
|
42
|
-
|
|
66
|
+
const rows = msgs.map(e => {
|
|
43
67
|
const ts = new Date(e.ts).toISOString().slice(11, 23);
|
|
44
68
|
const prefix = e.type === 'error' ? 'ERR' : e.type === 'warn' ? 'WRN' : e.type.toUpperCase().slice(0, 3);
|
|
45
69
|
return `[${e.id}] ${ts} ${prefix.padEnd(3)} ${e.args.join(' ').substring(0, 200)}`;
|
|
46
|
-
})
|
|
70
|
+
});
|
|
71
|
+
const truncNote = consoleMessages.length > 50
|
|
72
|
+
? `\n(showing last 50 of ${consoleMessages.length})`
|
|
73
|
+
: '';
|
|
74
|
+
return `${header}\n${rows.join('\n')}${truncNote}`;
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
export function consoleDetailStr(consoleMessages, msgId) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Cookie management via CDP Network domain
|
|
2
2
|
|
|
3
3
|
import { evalStr } from './evaluate.mjs';
|
|
4
|
+
import { emptyState } from '../output.mjs';
|
|
4
5
|
|
|
5
6
|
export async function cookiesStr(cdp, sid, action, arg) {
|
|
6
7
|
switch (action) {
|
|
@@ -12,7 +13,7 @@ export async function cookiesStr(cdp, sid, action, arg) {
|
|
|
12
13
|
const { cookies } = await cdp.send('Network.getCookies', { urls: [url] }, sid);
|
|
13
14
|
await cdp.send('Network.disable', {}, sid);
|
|
14
15
|
|
|
15
|
-
if (cookies.length === 0) return '
|
|
16
|
+
if (cookies.length === 0) return emptyState('cookies', '0 cookies for this page');
|
|
16
17
|
|
|
17
18
|
return cookies.map(c => {
|
|
18
19
|
const flags = [
|
|
@@ -1,27 +1,56 @@
|
|
|
1
1
|
// Network: resource timing + CDP request detail
|
|
2
2
|
|
|
3
3
|
import { evalStr } from './evaluate.mjs';
|
|
4
|
+
import { emptyState, aggregate, formatBytes } from '../output.mjs';
|
|
4
5
|
|
|
5
6
|
export async function netStr(cdp, sid) {
|
|
6
7
|
const raw = await evalStr(cdp, sid, `JSON.stringify(performance.getEntriesByType('resource').map(e => ({
|
|
7
8
|
name: e.name.substring(0, 120), type: e.initiatorType,
|
|
8
9
|
duration: Math.round(e.duration), size: e.transferSize
|
|
9
10
|
})))`);
|
|
10
|
-
|
|
11
|
+
const resources = JSON.parse(raw);
|
|
12
|
+
if (resources.length === 0) return emptyState('network', '0 resources timed (page not loaded or resources cached)');
|
|
13
|
+
|
|
14
|
+
// Pre-computed aggregates: total transfer size so agent doesn't need a follow-up sum.
|
|
15
|
+
const totalSize = resources.reduce((s, e) => s + (e.size || 0), 0);
|
|
16
|
+
const header = aggregate('network', resources.length, { size: formatBytes(totalSize) });
|
|
17
|
+
|
|
18
|
+
const rows = resources.map(e =>
|
|
11
19
|
`${String(e.duration).padStart(5)}ms ${String(e.size || '?').padStart(8)}B ${e.type.padEnd(8)} ${e.name}`
|
|
12
|
-
)
|
|
20
|
+
);
|
|
21
|
+
return `${header}\n${rows.join('\n')}`;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export function netListStr(networkRequests) {
|
|
16
|
-
if (networkRequests.size === 0) return '
|
|
25
|
+
if (networkRequests.size === 0) return emptyState('network', '0 requests captured since daemon started');
|
|
26
|
+
|
|
27
|
+
// Pre-computed aggregates: breakdown by status class.
|
|
28
|
+
// Agents commonly ask "are there any errors?" -- embedding the count eliminates a round-trip.
|
|
29
|
+
let errors = 0;
|
|
30
|
+
let pending = 0;
|
|
31
|
+
let ok = 0;
|
|
32
|
+
for (const [, r] of networkRequests.entries()) {
|
|
33
|
+
if (r.status == null) pending++;
|
|
34
|
+
else if (r.status >= 400) errors++;
|
|
35
|
+
else ok++;
|
|
36
|
+
}
|
|
37
|
+
const meta = {};
|
|
38
|
+
if (errors) meta.errors = errors;
|
|
39
|
+
if (pending) meta.pending = pending;
|
|
40
|
+
if (ok) meta.ok = ok;
|
|
41
|
+
const header = aggregate('network', networkRequests.size, meta);
|
|
42
|
+
|
|
17
43
|
const entries = [...networkRequests.entries()].slice(-50);
|
|
18
|
-
const
|
|
44
|
+
const tableHeader = `${'STATUS'.padStart(3)} ${'METHOD'.padEnd(6)} ${'ID'.padEnd(14)} URL`;
|
|
19
45
|
const rows = entries.map(([id, r]) => {
|
|
20
46
|
const status = r.status != null ? String(r.status).padStart(3) : '...';
|
|
21
47
|
const method = (r.method || 'GET').padEnd(6);
|
|
22
48
|
return ` ${status} ${method} ${id.substring(0, 14).padEnd(14)} ${r.url?.substring(0, 100) || '?'}`;
|
|
23
49
|
});
|
|
24
|
-
|
|
50
|
+
const truncNote = networkRequests.size > 50
|
|
51
|
+
? `\n(showing last 50 of ${networkRequests.size})`
|
|
52
|
+
: '';
|
|
53
|
+
return `${header}\n${tableHeader}\n${rows.join('\n')}${truncNote}\n\nUse "net <target> <requestId>" for detail.`;
|
|
25
54
|
}
|
|
26
55
|
|
|
27
56
|
export async function netDetailStr(cdp, sid, requestId, networkRequests) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Core Web Vitals + performance metrics
|
|
2
2
|
|
|
3
3
|
import { evalStr } from './evaluate.mjs';
|
|
4
|
+
import { emptyState } from '../output.mjs';
|
|
4
5
|
|
|
5
6
|
export async function perfStr(cdp, sid) {
|
|
6
7
|
// Métricas do CDP
|
|
@@ -53,6 +54,15 @@ export async function perfStr(cdp, sid) {
|
|
|
53
54
|
const cdpMetrics = {};
|
|
54
55
|
for (const m of metrics) cdpMetrics[m.name] = m.value;
|
|
55
56
|
|
|
57
|
+
// Empty state: no vitals, no nav timing, no resources -- page not loaded or blank
|
|
58
|
+
if (
|
|
59
|
+
v.lcp == null && v.fcp == null && v.ttfb == null &&
|
|
60
|
+
v.domInteractive == null && v.load == null &&
|
|
61
|
+
(!v.resources || v.resources === 0)
|
|
62
|
+
) {
|
|
63
|
+
return emptyState('perf', 'no metrics available (page not loaded?)');
|
|
64
|
+
}
|
|
65
|
+
|
|
56
66
|
const lines = ['## Core Web Vitals'];
|
|
57
67
|
|
|
58
68
|
if (v.lcp != null) lines.push(`LCP: ${v.lcp}ms (${v.lcpElement})${v.lcp <= 2500 ? ' [GOOD]' : v.lcp <= 4000 ? ' [NEEDS IMPROVEMENT]' : ' [POOR]'}`);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Accessibility tree snapshot with incremental diff and interactive refs (@e1, @e2...)
|
|
2
2
|
|
|
3
|
+
import { emptyState } from '../output.mjs';
|
|
4
|
+
|
|
3
5
|
const INTERACTIVE_ROLES = new Set([
|
|
4
6
|
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
|
5
7
|
'menuitem', 'tab', 'switch', 'searchbox', 'slider', 'spinbutton',
|
|
@@ -38,25 +40,35 @@ function truncate(str, max = MAX_NAME_LENGTH) {
|
|
|
38
40
|
return str.slice(0, max) + '...';
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
|
|
43
|
+
// Render a single AX node line. Ref assignment is done OUT of this function
|
|
44
|
+
// (via the refMap pre-pass) so that query-filtered renders still produce
|
|
45
|
+
// the same @eN numbering as unfiltered renders.
|
|
46
|
+
function formatNodeLine(node, depth, refNum, isNew = false, isMatch = false) {
|
|
42
47
|
const role = node.role?.value || '';
|
|
43
48
|
const name = truncate(node.name?.value ?? '');
|
|
44
49
|
const value = node.value?.value;
|
|
45
50
|
const indent = ' '.repeat(Math.min(depth, 10));
|
|
46
51
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
refTag = `@e${refIndex.value} `;
|
|
50
|
-
refIndex.value++;
|
|
51
|
-
}
|
|
52
|
-
|
|
52
|
+
const refTag = refNum != null ? `@e${refNum} ` : '';
|
|
53
|
+
const matchTag = isMatch ? '> ' : '';
|
|
53
54
|
const newTag = isNew ? '*' : '';
|
|
54
|
-
let line = `${indent}${newTag}${refTag}[${role}]`;
|
|
55
|
+
let line = `${indent}${matchTag}${newTag}${refTag}[${role}]`;
|
|
55
56
|
if (name !== '') line += ` ${name}`;
|
|
56
57
|
if (!(value === '' || value == null)) line += ` = ${JSON.stringify(truncate(String(value)))}`;
|
|
57
58
|
return line;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
// Case-insensitive substring match against role, name and value.
|
|
62
|
+
function nodeMatchesQuery(node, query) {
|
|
63
|
+
if (!query) return false;
|
|
64
|
+
const q = query.toLowerCase();
|
|
65
|
+
const role = (node.role?.value || '').toLowerCase();
|
|
66
|
+
const name = (node.name?.value || '').toLowerCase();
|
|
67
|
+
const value = node.value?.value;
|
|
68
|
+
const valueStr = value == null ? '' : String(value).toLowerCase();
|
|
69
|
+
return role.includes(q) || name.includes(q) || valueStr.includes(q);
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
function orderedAxChildren(node, nodesById, childrenByParent) {
|
|
61
73
|
const children = [];
|
|
62
74
|
const seen = new Set();
|
|
@@ -144,8 +156,11 @@ async function detectScrollables(cdp, sid) {
|
|
|
144
156
|
// The caller (daemon) stores this map for later ref resolution.
|
|
145
157
|
// previousFingerprints: Map from prior snapshot for incremental diff.
|
|
146
158
|
// maxDepth: limit tree depth (0 = unlimited). Nodes at the limit render as leaves.
|
|
159
|
+
// query: when set, filter the rendered output to nodes matching (substring) the
|
|
160
|
+
// query plus their ancestors. Fingerprints and refMap are still computed
|
|
161
|
+
// against the full tree so incremental diff and @eN stability survive.
|
|
147
162
|
// Returns { text, refMap, fingerprints } -- caller stores fingerprints for next diff.
|
|
148
|
-
export async function snapshotStr(cdp, sid, compact = true, refs = false, previousFingerprints = null, maxDepth = 0) {
|
|
163
|
+
export async function snapshotStr(cdp, sid, compact = true, refs = false, previousFingerprints = null, maxDepth = 0, query = null) {
|
|
149
164
|
const { nodes } = await cdp.send('Accessibility.getFullAXTree', {}, sid);
|
|
150
165
|
const nodesById = new Map(nodes.map(node => [node.nodeId, node]));
|
|
151
166
|
const childrenByParent = new Map();
|
|
@@ -156,11 +171,73 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
156
171
|
}
|
|
157
172
|
|
|
158
173
|
const currentFingerprints = buildFingerprints(nodes, nodesById, childrenByParent, compact);
|
|
159
|
-
const isDiff = previousFingerprints !== null && previousFingerprints.size > 0;
|
|
174
|
+
const isDiff = previousFingerprints !== null && previousFingerprints.size > 0 && !query;
|
|
160
175
|
const scrollables = await detectScrollables(cdp, sid);
|
|
161
176
|
|
|
162
|
-
const
|
|
177
|
+
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
|
|
178
|
+
|
|
179
|
+
// ---- Pre-pass: populate refMap on the FULL tree so @eN stays stable
|
|
180
|
+
// regardless of query filtering.
|
|
163
181
|
const refMap = new Map();
|
|
182
|
+
const nodeIdToRef = new Map();
|
|
183
|
+
if (refs) {
|
|
184
|
+
let refCounter = 1;
|
|
185
|
+
const prepassSeen = new Set();
|
|
186
|
+
const prepass = (node) => {
|
|
187
|
+
if (!node || prepassSeen.has(node.nodeId)) return;
|
|
188
|
+
prepassSeen.add(node.nodeId);
|
|
189
|
+
if (shouldShowAxNode(node, compact)) {
|
|
190
|
+
const role = node.role?.value || '';
|
|
191
|
+
if (INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
|
|
192
|
+
refMap.set(refCounter, {
|
|
193
|
+
backendNodeId: node.backendDOMNodeId,
|
|
194
|
+
nodeId: node.nodeId,
|
|
195
|
+
role,
|
|
196
|
+
name: node.name?.value ?? '',
|
|
197
|
+
});
|
|
198
|
+
nodeIdToRef.set(node.nodeId, refCounter);
|
|
199
|
+
refCounter++;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
203
|
+
prepass(child);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
for (const root of roots) prepass(root);
|
|
207
|
+
if (!maxDepth) {
|
|
208
|
+
for (const node of nodes) prepass(node);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- Query filter: compute matchedIds + their ancestors (keepIds)
|
|
213
|
+
let matchedIds = null;
|
|
214
|
+
let keepIds = null;
|
|
215
|
+
if (query) {
|
|
216
|
+
matchedIds = new Set();
|
|
217
|
+
keepIds = new Set();
|
|
218
|
+
for (const node of nodes) {
|
|
219
|
+
if (!shouldShowAxNode(node, compact)) continue;
|
|
220
|
+
if (nodeMatchesQuery(node, query)) {
|
|
221
|
+
matchedIds.add(node.nodeId);
|
|
222
|
+
// Walk ancestors via parentId; include non-visible parents too so the
|
|
223
|
+
// visit() pass can reach the match by descending through them.
|
|
224
|
+
let pid = node.parentId;
|
|
225
|
+
while (pid && !keepIds.has(pid)) {
|
|
226
|
+
keepIds.add(pid);
|
|
227
|
+
pid = nodesById.get(pid)?.parentId;
|
|
228
|
+
}
|
|
229
|
+
keepIds.add(node.nodeId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (matchedIds.size === 0) {
|
|
233
|
+
return {
|
|
234
|
+
text: `snap: no matches for query "${query}"`,
|
|
235
|
+
refMap,
|
|
236
|
+
fingerprints: currentFingerprints,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
164
241
|
const lines = [];
|
|
165
242
|
const visited = new Set();
|
|
166
243
|
|
|
@@ -173,7 +250,6 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
173
250
|
const curr = currentFingerprints.get(nodeId);
|
|
174
251
|
const prev = previousFingerprints.get(nodeId);
|
|
175
252
|
if (!curr || !prev || curr !== prev) return false;
|
|
176
|
-
// Node itself matches -- check all visible children recursively
|
|
177
253
|
const children = orderedAxChildren(node, nodesById, childrenByParent);
|
|
178
254
|
for (const child of children) {
|
|
179
255
|
if (!shouldShowAxNode(child, compact)) continue;
|
|
@@ -186,6 +262,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
186
262
|
if (!node || visited.has(node.nodeId)) return;
|
|
187
263
|
visited.add(node.nodeId);
|
|
188
264
|
|
|
265
|
+
// Query filter: if this node isn't on the keep path, skip the whole subtree.
|
|
266
|
+
// All nodes with matches have their ancestors in keepIds, so pruning here is safe.
|
|
267
|
+
if (query && !keepIds.has(node.nodeId)) return;
|
|
268
|
+
|
|
189
269
|
const show = shouldShowAxNode(node, compact);
|
|
190
270
|
|
|
191
271
|
if (!show) {
|
|
@@ -196,29 +276,18 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
196
276
|
return;
|
|
197
277
|
}
|
|
198
278
|
|
|
199
|
-
// Incremental diff: if this subtree is unchanged, collapse it
|
|
279
|
+
// Incremental diff: if this subtree is unchanged, collapse it.
|
|
280
|
+
// Disabled when a query filter is active (user wants the matched content, not a diff).
|
|
200
281
|
if (isDiff && isSubtreeUnchanged(node)) {
|
|
201
282
|
unchangedCount++;
|
|
202
|
-
if (refs) {
|
|
203
|
-
advanceRefsForSubtree(node);
|
|
204
|
-
}
|
|
205
283
|
return;
|
|
206
284
|
}
|
|
207
285
|
|
|
208
|
-
const role = node.role?.value || '';
|
|
209
|
-
const currentRef = refIndex.value;
|
|
210
286
|
const isNew = isDiff && !previousFingerprints.has(node.nodeId);
|
|
287
|
+
const isMatch = !!(matchedIds && matchedIds.has(node.nodeId));
|
|
288
|
+
const refNum = refs ? nodeIdToRef.get(node.nodeId) ?? null : null;
|
|
211
289
|
|
|
212
|
-
lines.push(
|
|
213
|
-
|
|
214
|
-
if (refs && refIndex.value > currentRef) {
|
|
215
|
-
refMap.set(currentRef, {
|
|
216
|
-
backendNodeId: node.backendDOMNodeId,
|
|
217
|
-
nodeId: node.nodeId,
|
|
218
|
-
role,
|
|
219
|
-
name: node.name?.value ?? '',
|
|
220
|
-
});
|
|
221
|
-
}
|
|
290
|
+
lines.push(formatNodeLine(node, depth, refNum, isNew, isMatch));
|
|
222
291
|
|
|
223
292
|
// Depth limiting: at the limit, render as leaf (no children)
|
|
224
293
|
if (maxDepth > 0 && depth >= maxDepth) return;
|
|
@@ -228,30 +297,6 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
228
297
|
}
|
|
229
298
|
}
|
|
230
299
|
|
|
231
|
-
// Advance ref counter for unchanged subtrees to keep ref numbers stable
|
|
232
|
-
// Uses its own visited set because visit() already marked nodes before calling this
|
|
233
|
-
const refAdvanced = new Set();
|
|
234
|
-
function advanceRefsForSubtree(node) {
|
|
235
|
-
if (!node || refAdvanced.has(node.nodeId)) return;
|
|
236
|
-
refAdvanced.add(node.nodeId);
|
|
237
|
-
if (shouldShowAxNode(node, compact)) {
|
|
238
|
-
const role = node.role?.value || '';
|
|
239
|
-
if (INTERACTIVE_ROLES.has(role.toLowerCase()) && isAxNodeInteractable(node)) {
|
|
240
|
-
refMap.set(refIndex.value, {
|
|
241
|
-
backendNodeId: node.backendDOMNodeId,
|
|
242
|
-
nodeId: node.nodeId,
|
|
243
|
-
role,
|
|
244
|
-
name: node.name?.value ?? '',
|
|
245
|
-
});
|
|
246
|
-
refIndex.value++;
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
for (const child of orderedAxChildren(node, nodesById, childrenByParent)) {
|
|
250
|
-
advanceRefsForSubtree(child);
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const roots = nodes.filter(node => !node.parentId || !nodesById.has(node.parentId));
|
|
255
300
|
for (const root of roots) visit(root, 0);
|
|
256
301
|
// Second pass: catch disconnected nodes (skip when depth-limited to avoid false depth=0)
|
|
257
302
|
if (!maxDepth) {
|
|
@@ -265,6 +310,10 @@ export async function snapshotStr(cdp, sid, compact = true, refs = false, previo
|
|
|
265
310
|
const changedCount = totalVisible - unchangedCount;
|
|
266
311
|
text = `[incremental: ${changedCount} changed, ${unchangedCount} unchanged]\n${text}`;
|
|
267
312
|
}
|
|
313
|
+
// Empty state: no visible nodes and not an incremental diff -> page is blank/not ready
|
|
314
|
+
if (lines.length === 0 && !isDiff) {
|
|
315
|
+
text = emptyState('snap', 'empty accessibility tree');
|
|
316
|
+
}
|
|
268
317
|
// Append scroll info footer when scrollable containers exist
|
|
269
318
|
if (scrollables.length > 0) {
|
|
270
319
|
text += `\n[scroll: ${scrollables.join(' | ')}]`;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Tracks command counts, timing, errors, and session timeline
|
|
3
3
|
|
|
4
4
|
import { writeFileSync } from 'fs';
|
|
5
|
+
import { emptyState } from '../output.mjs';
|
|
5
6
|
|
|
6
7
|
export class SessionStats {
|
|
7
8
|
constructor() {
|
|
@@ -26,13 +27,15 @@ export class SessionStats {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function statsStr(stats, full = false, exportPath = null) {
|
|
29
|
-
if (!stats) return '
|
|
30
|
+
if (!stats) return emptyState('stats', 'no stats available');
|
|
30
31
|
|
|
31
32
|
const lines = [];
|
|
32
33
|
const uptime = ((Date.now() - stats.startTime) / 1000).toFixed(0);
|
|
33
34
|
const totalCmds = [...stats.commands.values()].reduce((s, e) => s + e.count, 0);
|
|
34
35
|
const totalErrors = [...stats.commands.values()].reduce((s, e) => s + e.errors, 0);
|
|
35
36
|
|
|
37
|
+
if (totalCmds === 0) return emptyState('stats', `no commands executed yet (uptime: ${uptime}s)`);
|
|
38
|
+
|
|
36
39
|
lines.push(`Session Stats (uptime: ${uptime}s, commands: ${totalCmds}, errors: ${totalErrors})`);
|
|
37
40
|
lines.push('');
|
|
38
41
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// LocalStorage / SessionStorage management
|
|
2
2
|
|
|
3
3
|
import { evalStr } from './evaluate.mjs';
|
|
4
|
+
import { emptyState } from '../output.mjs';
|
|
4
5
|
|
|
5
6
|
export async function storageStr(cdp, sid, action) {
|
|
6
7
|
switch (action) {
|
|
@@ -12,7 +13,7 @@ export async function storageStr(cdp, sid, action) {
|
|
|
12
13
|
`);
|
|
13
14
|
const data = JSON.parse(raw);
|
|
14
15
|
const keys = Object.keys(data);
|
|
15
|
-
if (keys.length === 0) return 'localStorage is empty
|
|
16
|
+
if (keys.length === 0) return emptyState('storage', 'localStorage is empty');
|
|
16
17
|
return keys.map(k => {
|
|
17
18
|
const v = data[k];
|
|
18
19
|
const val = v && v.length > 80 ? v.slice(0, 80) + '...' : v;
|
|
@@ -28,7 +29,7 @@ export async function storageStr(cdp, sid, action) {
|
|
|
28
29
|
`);
|
|
29
30
|
const data = JSON.parse(raw);
|
|
30
31
|
const keys = Object.keys(data);
|
|
31
|
-
if (keys.length === 0) return 'sessionStorage is empty
|
|
32
|
+
if (keys.length === 0) return emptyState('storage', 'sessionStorage is empty');
|
|
32
33
|
return keys.map(k => {
|
|
33
34
|
const v = data[k];
|
|
34
35
|
const val = v && v.length > 80 ? v.slice(0, 80) + '...' : v;
|
|
@@ -49,6 +49,7 @@ import { parseRef, clickRefStr, hoverRefStr, fillRefStr } from './commands/refs.
|
|
|
49
49
|
import { highlightStr } from './commands/highlight.mjs';
|
|
50
50
|
import { auditStr } from './commands/audit.mjs';
|
|
51
51
|
import { SessionStats, statsStr } from './commands/stats.mjs';
|
|
52
|
+
import { generateHints, renderHints, isRefMapFresh } from './hints.mjs';
|
|
52
53
|
import { sleep } from './utils.mjs';
|
|
53
54
|
|
|
54
55
|
// Commands that modify visible DOM and should trigger automatic post-action snapshot.
|
|
@@ -121,6 +122,9 @@ export async function runDaemon(targetId, config) {
|
|
|
121
122
|
// Per-tab state: ref map for @eN resolution + fingerprints for incremental diff
|
|
122
123
|
let currentRefMap = new Map();
|
|
123
124
|
let previousFingerprints = null;
|
|
125
|
+
// Track the last @eN that was filled so hints can prioritize the NEXT input
|
|
126
|
+
// in a multi-field form instead of re-suggesting the one we just touched.
|
|
127
|
+
let lastFilledRef = null;
|
|
124
128
|
const sessionStats = new SessionStats();
|
|
125
129
|
|
|
126
130
|
// Network request tracking (CDP Network domain) for detail drill-down
|
|
@@ -177,10 +181,11 @@ export async function runDaemon(targetId, config) {
|
|
|
177
181
|
const startMs = Date.now();
|
|
178
182
|
const auditResult = { ok: true };
|
|
179
183
|
try {
|
|
180
|
-
// Strip --no-snap before dispatch so
|
|
181
|
-
// (e.g. fill would type "--no-snap" into the input field).
|
|
184
|
+
// Strip --no-snap / --no-hints before dispatch so they don't contaminate
|
|
185
|
+
// command args (e.g. fill would type "--no-snap" into the input field).
|
|
182
186
|
const noSnap = args.includes('--no-snap');
|
|
183
|
-
|
|
187
|
+
const noHints = args.includes('--no-hints');
|
|
188
|
+
if (noSnap || noHints) args = args.filter(a => a !== '--no-snap' && a !== '--no-hints');
|
|
184
189
|
|
|
185
190
|
let result;
|
|
186
191
|
let isRefCmd = false;
|
|
@@ -194,6 +199,7 @@ export async function runDaemon(targetId, config) {
|
|
|
194
199
|
result = await clickRefStr(cdp, sessionId, currentRefMap, refNum, dbl);
|
|
195
200
|
} else if (cmd === 'fill') {
|
|
196
201
|
result = await fillRefStr(cdp, sessionId, currentRefMap, refNum, args.slice(1).join(' '));
|
|
202
|
+
lastFilledRef = refNum;
|
|
197
203
|
} else if (cmd === 'hover') {
|
|
198
204
|
result = await hoverRefStr(cdp, sessionId, currentRefMap, refNum);
|
|
199
205
|
} else {
|
|
@@ -218,11 +224,17 @@ export async function runDaemon(targetId, config) {
|
|
|
218
224
|
const forceFull = args.includes('--full');
|
|
219
225
|
const depthArg = args.find(a => a.startsWith('--depth='));
|
|
220
226
|
const maxDepth = depthArg ? parseInt(depthArg.split('=')[1]) || 0 : 0;
|
|
227
|
+
const queryArg = args.find(a => a.startsWith('--query='));
|
|
228
|
+
const query = queryArg ? queryArg.slice('--query='.length) : null;
|
|
221
229
|
const prevFp = forceFull ? null : previousFingerprints;
|
|
222
|
-
const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth);
|
|
230
|
+
const snapResult = await snapshotStr(cdp, sessionId, true, useRefs, prevFp, maxDepth, query);
|
|
223
231
|
result = snapResult.text;
|
|
232
|
+
// Always track fingerprints on the full tree -- this survives query filtering
|
|
233
|
+
// because snapshotStr computes them before applying the filter.
|
|
224
234
|
previousFingerprints = snapResult.fingerprints;
|
|
225
|
-
|
|
235
|
+
// Replace the ref map even when it is empty: a fresh snapshot that found
|
|
236
|
+
// zero interactive refs must clear any stale @eN left from an older page.
|
|
237
|
+
if (useRefs) {
|
|
226
238
|
currentRefMap = snapResult.refMap;
|
|
227
239
|
}
|
|
228
240
|
break;
|
|
@@ -253,12 +265,17 @@ export async function runDaemon(targetId, config) {
|
|
|
253
265
|
result = await htmlStr(cdp, sessionId, args[0]);
|
|
254
266
|
break;
|
|
255
267
|
case 'nav': case 'navigate': {
|
|
256
|
-
const navAction = args[0]?.toLowerCase();
|
|
257
268
|
result = await navStr(cdp, sessionId, args[0], config);
|
|
258
|
-
//
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
269
|
+
// Any navigation changes page identity and can restore DOM via
|
|
270
|
+
// BFCache/hydration. Reset the diff baseline unconditionally so the
|
|
271
|
+
// first post-navigation snapshot is always a trustworthy full view.
|
|
272
|
+
previousFingerprints = null;
|
|
273
|
+
// ANY navigation (including back/forward) invalidates the ref map:
|
|
274
|
+
// the @eN numbers were assigned against a specific DOM render,
|
|
275
|
+
// and even back/forward can restore the page with different hydration.
|
|
276
|
+
// Auto-snap (if not --no-snap) will repopulate the refMap immediately below.
|
|
277
|
+
currentRefMap = new Map();
|
|
278
|
+
lastFilledRef = null;
|
|
262
279
|
break;
|
|
263
280
|
}
|
|
264
281
|
case 'net': case 'network':
|
|
@@ -469,13 +486,33 @@ export async function runDaemon(targetId, config) {
|
|
|
469
486
|
await sleep(settleMs);
|
|
470
487
|
const snapResult = await snapshotStr(cdp, sessionId, true, true, previousFingerprints);
|
|
471
488
|
previousFingerprints = snapResult.fingerprints;
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
489
|
+
// Same rule as explicit snap --refs: fresh empty ref maps must replace
|
|
490
|
+
// stale state, otherwise hints can suggest dead @eN from a prior screen.
|
|
491
|
+
currentRefMap = snapResult.refMap;
|
|
475
492
|
result = (result ?? '') + '\n\n' + snapResult.text;
|
|
476
493
|
} catch (e) { process.stderr.write(`[auto-snap] ${e.message}\n`); }
|
|
477
494
|
}
|
|
478
495
|
|
|
496
|
+
// Contextual hints: append next-step suggestions to help the agent
|
|
497
|
+
// pick the next action without guessing. Opt-out via --no-hints.
|
|
498
|
+
// Only emit hints when the refMap is guaranteed FRESH for this command
|
|
499
|
+
// (either auto-snap ran, or the user explicitly asked for `snap --refs`).
|
|
500
|
+
// Without this guard, --no-snap or bare `snap` would emit hints pointing
|
|
501
|
+
// to @eN from a prior page -- stale and dangerous.
|
|
502
|
+
const shouldHint = !noHints && isRefMapFresh({ cmd, shouldSnap, noSnap, args });
|
|
503
|
+
if (shouldHint) {
|
|
504
|
+
try {
|
|
505
|
+
const hints = generateHints({
|
|
506
|
+
cmd,
|
|
507
|
+
refMap: currentRefMap,
|
|
508
|
+
lastFilledRef,
|
|
509
|
+
hasPage: true,
|
|
510
|
+
});
|
|
511
|
+
const hintsText = renderHints(hints);
|
|
512
|
+
if (hintsText) result = (result ?? '') + '\n\n' + hintsText;
|
|
513
|
+
} catch (e) { process.stderr.write(`[hints] ${e.message}\n`); }
|
|
514
|
+
}
|
|
515
|
+
|
|
479
516
|
sessionStats.record(cmd, args, startMs, Date.now(), true, null);
|
|
480
517
|
return { ok: true, result: result ?? '' };
|
|
481
518
|
} catch (e) {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Contextual next-step hints generator.
|
|
2
|
+
// Pure function, zero deps. Consumes refMap + last action context
|
|
3
|
+
// and produces up to MAX_HINTS suggestions of "what to do next".
|
|
4
|
+
//
|
|
5
|
+
// Design notes:
|
|
6
|
+
// - Hints always use a <t> placeholder for target because the daemon
|
|
7
|
+
// does not know the CLI prefix the agent is using.
|
|
8
|
+
// - Heuristic is deliberately simple: bucket refs by role, then pick
|
|
9
|
+
// based on the last command. More sophisticated ranking can come
|
|
10
|
+
// later if measurement shows it matters.
|
|
11
|
+
// - After `fill`, a submit button is the most likely next step, so
|
|
12
|
+
// it gets priority. We fall back to "key Enter" when no submit is
|
|
13
|
+
// visible (common on search boxes).
|
|
14
|
+
|
|
15
|
+
const SUBMIT_RX = /submit|login|log\s*in|sign\s*in|signin|send|search|go\b|continue|confirm|\bok\b|apply|save|create|next/i;
|
|
16
|
+
|
|
17
|
+
export const MAX_HINTS = 3;
|
|
18
|
+
|
|
19
|
+
const INPUT_ROLES = new Set(['textbox', 'searchbox', 'spinbutton', 'slider']);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} Hint
|
|
23
|
+
* @property {string} cmd - CLI command with <t> placeholder
|
|
24
|
+
* @property {string} comment - Short human-readable context annotation
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Classify refMap entries by interaction type.
|
|
29
|
+
* Returns buckets preserving original ref order (stable across calls).
|
|
30
|
+
*
|
|
31
|
+
* @param {Map<number, {role: string, name: string}>} refMap
|
|
32
|
+
*/
|
|
33
|
+
function bucket(refMap) {
|
|
34
|
+
const submitButtons = [];
|
|
35
|
+
const buttons = [];
|
|
36
|
+
const inputs = [];
|
|
37
|
+
const selects = [];
|
|
38
|
+
const links = [];
|
|
39
|
+
|
|
40
|
+
for (const [num, ref] of refMap.entries()) {
|
|
41
|
+
const role = (ref.role || '').toLowerCase();
|
|
42
|
+
const name = ref.name || '';
|
|
43
|
+
const entry = { num, name, role };
|
|
44
|
+
|
|
45
|
+
if (role === 'button') {
|
|
46
|
+
if (SUBMIT_RX.test(name)) submitButtons.push(entry);
|
|
47
|
+
else buttons.push(entry);
|
|
48
|
+
} else if (INPUT_ROLES.has(role)) {
|
|
49
|
+
inputs.push(entry);
|
|
50
|
+
} else if (role === 'combobox') {
|
|
51
|
+
selects.push(entry);
|
|
52
|
+
} else if (role === 'link') {
|
|
53
|
+
links.push(entry);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { submitButtons, buttons, inputs, selects, links };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function truncate(s, max = 40) {
|
|
61
|
+
if (!s) return '';
|
|
62
|
+
if (s.length <= max) return s;
|
|
63
|
+
return s.slice(0, max) + '...';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function commentFor(entry) {
|
|
67
|
+
if (!entry.name) return entry.role;
|
|
68
|
+
return `${entry.role} "${truncate(entry.name)}"`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function clickHint(entry) {
|
|
72
|
+
return { cmd: `chromex click <t> @e${entry.num}`, comment: commentFor(entry) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function fillHint(entry) {
|
|
76
|
+
return { cmd: `chromex fill <t> @e${entry.num} "<value>"`, comment: commentFor(entry) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Generate up to MAX_HINTS next-step suggestions based on command context and refMap.
|
|
81
|
+
*
|
|
82
|
+
* @param {Object} ctx
|
|
83
|
+
* @param {string} ctx.cmd - Command just executed
|
|
84
|
+
* @param {Map<number, {role,name,backendNodeId}>} ctx.refMap
|
|
85
|
+
* @param {number|null} [ctx.lastFilledRef] - Ref number just filled (flow awareness)
|
|
86
|
+
* @param {boolean} [ctx.hasPage] - Whether a page/tab is active
|
|
87
|
+
* @returns {Hint[]}
|
|
88
|
+
*/
|
|
89
|
+
export function generateHints({ cmd, refMap, lastFilledRef = null, hasPage = true }) {
|
|
90
|
+
// No page/tab -> bootstrap hints
|
|
91
|
+
if (!hasPage) {
|
|
92
|
+
return [
|
|
93
|
+
{ cmd: 'chromex list', comment: 'see open tabs' },
|
|
94
|
+
{ cmd: 'chromex launch', comment: 'start a browser with remote debugging' },
|
|
95
|
+
];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Missing refMap usually means the caller never captured refs for this page.
|
|
99
|
+
// In that case, teach the agent how to populate them.
|
|
100
|
+
if (!refMap) {
|
|
101
|
+
return [
|
|
102
|
+
{ cmd: 'chromex snap <t> --refs', comment: 'assign @eN refs to interactive elements' },
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Fresh snapshot with an EMPTY refMap means there are no interactive refs on
|
|
107
|
+
// the page we just rendered. Returning "snap --refs" here would create a
|
|
108
|
+
// pointless loop, because the agent already did that work and learned "none".
|
|
109
|
+
if (refMap.size === 0) return [];
|
|
110
|
+
|
|
111
|
+
const buckets = bucket(refMap);
|
|
112
|
+
const { submitButtons, buttons, inputs, links } = buckets;
|
|
113
|
+
|
|
114
|
+
// After fill: submit is most likely next step
|
|
115
|
+
if (cmd === 'fill') {
|
|
116
|
+
const hints = [];
|
|
117
|
+
if (submitButtons.length > 0) {
|
|
118
|
+
hints.push(clickHint(submitButtons[0]));
|
|
119
|
+
} else {
|
|
120
|
+
hints.push({ cmd: 'chromex key <t> Enter', comment: 'submit via Enter key' });
|
|
121
|
+
}
|
|
122
|
+
// Next unfilled input keeps the flow going
|
|
123
|
+
const unfilled = inputs.filter((i) => i.num !== lastFilledRef);
|
|
124
|
+
if (unfilled.length > 0 && hints.length < MAX_HINTS) {
|
|
125
|
+
hints.push(fillHint(unfilled[0]));
|
|
126
|
+
}
|
|
127
|
+
return hints.slice(0, MAX_HINTS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// After nav: first input is usually the entry point (search, login, etc)
|
|
131
|
+
if (cmd === 'nav' || cmd === 'navigate') {
|
|
132
|
+
const hints = [];
|
|
133
|
+
if (inputs.length > 0) hints.push(fillHint(inputs[0]));
|
|
134
|
+
if (hints.length < MAX_HINTS && submitButtons.length > 0) hints.push(clickHint(submitButtons[0]));
|
|
135
|
+
else if (hints.length < MAX_HINTS && buttons.length > 0) hints.push(clickHint(buttons[0]));
|
|
136
|
+
if (hints.length < MAX_HINTS && links.length > 0) hints.push(clickHint(links[0]));
|
|
137
|
+
return hints.slice(0, MAX_HINTS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Default (snap, click, hover, others): top interactives with non-empty labels
|
|
141
|
+
return topInteractives(buckets, MAX_HINTS);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function topInteractives({ submitButtons, buttons, inputs, selects, links }, max) {
|
|
145
|
+
const ordered = [...inputs, ...submitButtons, ...buttons, ...selects, ...links];
|
|
146
|
+
const hints = [];
|
|
147
|
+
for (const entry of ordered) {
|
|
148
|
+
if (hints.length >= max) break;
|
|
149
|
+
if (!entry.name) continue; // skip nameless nodes -- they rarely help the agent
|
|
150
|
+
hints.push(INPUT_ROLES.has(entry.role) ? fillHint(entry) : clickHint(entry));
|
|
151
|
+
}
|
|
152
|
+
return hints;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Decide whether the daemon's currentRefMap is "fresh" for this command,
|
|
157
|
+
* i.e. safe to feed into generateHints without producing stale @eN pointers.
|
|
158
|
+
*
|
|
159
|
+
* Fresh means a snapshot with refs=true actually ran in THIS command:
|
|
160
|
+
* - auto-snap fired (shouldSnap && !noSnap) -- daemon always passes refs=true there
|
|
161
|
+
* - OR explicit `snap` with --refs/-i flag
|
|
162
|
+
*
|
|
163
|
+
* If neither happened, currentRefMap may still hold refs from a prior page
|
|
164
|
+
* (for example after nav --no-snap, or after a bare `snap` without --refs),
|
|
165
|
+
* so hints would point to coordinates that no longer exist in the DOM.
|
|
166
|
+
*
|
|
167
|
+
* This is a pure function so the daemon logic stays testable.
|
|
168
|
+
*
|
|
169
|
+
* @param {Object} ctx
|
|
170
|
+
* @param {string} ctx.cmd
|
|
171
|
+
* @param {boolean} ctx.shouldSnap - Whether this cmd is in the AUTO_SNAP_CMDS set
|
|
172
|
+
* @param {boolean} ctx.noSnap - Whether the caller passed --no-snap
|
|
173
|
+
* @param {string[]} ctx.args - Command args (to detect --refs / -i on snap)
|
|
174
|
+
* @returns {boolean}
|
|
175
|
+
*/
|
|
176
|
+
export function isRefMapFresh({ cmd, shouldSnap, noSnap, args }) {
|
|
177
|
+
// Auto-snap always uses refs=true in the daemon -- if it ran, refs are fresh.
|
|
178
|
+
if (shouldSnap && !noSnap) return true;
|
|
179
|
+
// Explicit `snap --refs` (or `snap -i` alias) also populates refMap.
|
|
180
|
+
if ((cmd === 'snap' || cmd === 'snapshot') && Array.isArray(args)) {
|
|
181
|
+
if (args.includes('--refs') || args.includes('-i')) return true;
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Render an array of hints into the chromex help[] block format.
|
|
188
|
+
* Format mirrors what Claude can parse unambiguously:
|
|
189
|
+
* help[N]:
|
|
190
|
+
* chromex <cmd> # comment
|
|
191
|
+
*
|
|
192
|
+
* @param {Hint[]} hints
|
|
193
|
+
* @returns {string} Empty string when hints is empty/missing.
|
|
194
|
+
*/
|
|
195
|
+
export function renderHints(hints) {
|
|
196
|
+
if (!hints || hints.length === 0) return '';
|
|
197
|
+
const header = `help[${hints.length}]:`;
|
|
198
|
+
const lines = hints.map((h) => {
|
|
199
|
+
if (h.comment) return ` ${h.cmd} # ${h.comment}`;
|
|
200
|
+
return ` ${h.cmd}`;
|
|
201
|
+
});
|
|
202
|
+
return `${header}\n${lines.join('\n')}`;
|
|
203
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Standardized output helpers: empty states, aggregates, indentation.
|
|
2
|
+
// Pure functions, zero deps. Shared by commands and hints.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Render a standardized empty state for a given domain.
|
|
6
|
+
* Used to distinguish "no results" from "silent failure" so agents
|
|
7
|
+
* do not retry commands hoping for different output.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} domain - command or data domain (e.g. 'network', 'console')
|
|
10
|
+
* @param {string} [msg='empty'] - human-readable reason
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* emptyState('network', '0 requests captured')
|
|
15
|
+
* // => 'network: 0 requests captured'
|
|
16
|
+
*/
|
|
17
|
+
export function emptyState(domain, msg = 'empty') {
|
|
18
|
+
return `${domain}: ${msg}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Render an aggregate header with count and optional key:value metadata.
|
|
23
|
+
* Embeds totals/counters into the first line of output so agents do not
|
|
24
|
+
* need a follow-up command just to ask "how many?".
|
|
25
|
+
*
|
|
26
|
+
* @param {string} domain
|
|
27
|
+
* @param {number} count
|
|
28
|
+
* @param {Object<string, string|number>} [meta]
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* aggregate('network', 47, { errors: 3, pending: 0 })
|
|
33
|
+
* // => 'network[47] errors:3 pending:0'
|
|
34
|
+
*/
|
|
35
|
+
export function aggregate(domain, count, meta) {
|
|
36
|
+
const header = `${domain}[${count}]`;
|
|
37
|
+
if (!meta) return header;
|
|
38
|
+
const parts = Object.entries(meta).map(([k, v]) => `${k}:${v}`);
|
|
39
|
+
if (parts.length === 0) return header;
|
|
40
|
+
return `${header} ${parts.join(' ')}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Format a byte count into a human-readable string (B / KB / MB / GB).
|
|
45
|
+
* Keeps one decimal place above 1KB. Returns '?' for null/undefined.
|
|
46
|
+
*
|
|
47
|
+
* @param {number|null|undefined} bytes
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatBytes(bytes) {
|
|
51
|
+
if (bytes == null) return '?';
|
|
52
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
53
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
54
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
55
|
+
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)}GB`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Indent every non-empty line of a block by `level` (2 spaces per level).
|
|
60
|
+
* Empty lines are preserved as empty (no trailing whitespace).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} text
|
|
63
|
+
* @param {number} [level=1]
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function indent(text, level = 1) {
|
|
67
|
+
if (!text) return '';
|
|
68
|
+
if (level <= 0) return text;
|
|
69
|
+
const pad = ' '.repeat(level);
|
|
70
|
+
return text
|
|
71
|
+
.split('\n')
|
|
72
|
+
.map((line) => (line === '' ? '' : pad + line))
|
|
73
|
+
.join('\n');
|
|
74
|
+
}
|
|
@@ -14,7 +14,7 @@ import { launchBrowser, incognitoContext } from './lib/launcher.mjs';
|
|
|
14
14
|
import { openTabStr, closeTabStr, focusTabStr } from './lib/commands/tab.mjs';
|
|
15
15
|
|
|
16
16
|
const config = loadConfig();
|
|
17
|
-
const SERVER_INFO = { name: 'chromex', version: '1.
|
|
17
|
+
const SERVER_INFO = { name: 'chromex', version: '1.5.0' };
|
|
18
18
|
|
|
19
19
|
// ---- JSON-RPC helpers ----
|
|
20
20
|
|
|
@@ -55,6 +55,7 @@ function tool(name, description, properties, required, annotations) {
|
|
|
55
55
|
|
|
56
56
|
const P_TARGET = { type: 'string', description: 'Target ID prefix from chromex_list' };
|
|
57
57
|
const P_NO_SNAP = { type: 'boolean', description: 'Skip auto-snapshot after action' };
|
|
58
|
+
const P_NO_HINTS = { type: 'boolean', description: 'Skip contextual help[] suggestions in output' };
|
|
58
59
|
|
|
59
60
|
// ---- Tool definitions (56 tools) ----
|
|
60
61
|
|
|
@@ -102,12 +103,14 @@ const TOOLS = [
|
|
|
102
103
|
|
|
103
104
|
// == INSPECT (readOnly) ==
|
|
104
105
|
tool('chromex_snapshot',
|
|
105
|
-
'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover.',
|
|
106
|
+
'Accessibility tree snapshot. Returns incremental diff after first call (only changed nodes). Use refs=true to get @eN references for click/fill/hover. Use query to filter to matching nodes + ancestors (preserves hierarchy, slashes page output for large sites). Hints (help[]) are only emitted when refs=true because they rely on a fresh refMap.',
|
|
106
107
|
{
|
|
107
108
|
target: P_TARGET,
|
|
108
109
|
refs: { type: 'boolean', description: 'Assign @eN refs to interactive elements', default: false },
|
|
109
110
|
full: { type: 'boolean', description: 'Force full snapshot (skip incremental diff)', default: false },
|
|
110
111
|
depth: { type: 'number', description: 'Max tree depth (0 = unlimited)' },
|
|
112
|
+
query: { type: 'string', description: 'Filter tree to nodes matching substring (case-insensitive) in role/name/value. Ancestors preserved so hierarchy is intact. @eN refs stay stable.' },
|
|
113
|
+
noHints: P_NO_HINTS,
|
|
111
114
|
}, ['target'], RO),
|
|
112
115
|
|
|
113
116
|
tool('chromex_html',
|
|
@@ -185,6 +188,7 @@ const TOOLS = [
|
|
|
185
188
|
target: P_TARGET,
|
|
186
189
|
url: { type: 'string', description: 'URL, or action: "back", "forward", "reload", "reload-hard"' },
|
|
187
190
|
noSnap: P_NO_SNAP,
|
|
191
|
+
noHints: P_NO_HINTS,
|
|
188
192
|
}, ['target', 'url'], RW),
|
|
189
193
|
|
|
190
194
|
tool('chromex_waitfor',
|
|
@@ -219,6 +223,7 @@ const TOOLS = [
|
|
|
219
223
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
220
224
|
dblClick: { type: 'boolean', description: 'Double-click instead of single click', default: false },
|
|
221
225
|
noSnap: P_NO_SNAP,
|
|
226
|
+
noHints: P_NO_HINTS,
|
|
222
227
|
}, ['target', 'selector'], RW),
|
|
223
228
|
|
|
224
229
|
tool('chromex_clickxy',
|
|
@@ -229,6 +234,7 @@ const TOOLS = [
|
|
|
229
234
|
y: { type: 'number', description: 'Y in CSS pixels' },
|
|
230
235
|
dblClick: { type: 'boolean', description: 'Double-click instead of single click', default: false },
|
|
231
236
|
noSnap: P_NO_SNAP,
|
|
237
|
+
noHints: P_NO_HINTS,
|
|
232
238
|
}, ['target', 'x', 'y'], RW),
|
|
233
239
|
|
|
234
240
|
tool('chromex_type',
|
|
@@ -237,6 +243,7 @@ const TOOLS = [
|
|
|
237
243
|
target: P_TARGET,
|
|
238
244
|
text: { type: 'string', description: 'Text to type' },
|
|
239
245
|
noSnap: P_NO_SNAP,
|
|
246
|
+
noHints: P_NO_HINTS,
|
|
240
247
|
}, ['target', 'text'], RW),
|
|
241
248
|
|
|
242
249
|
tool('chromex_hover',
|
|
@@ -253,6 +260,7 @@ const TOOLS = [
|
|
|
253
260
|
from: { type: 'string', description: 'Source selector or x,y' },
|
|
254
261
|
to: { type: 'string', description: 'Destination selector or x,y' },
|
|
255
262
|
noSnap: P_NO_SNAP,
|
|
263
|
+
noHints: P_NO_HINTS,
|
|
256
264
|
}, ['target', 'from', 'to'], RW),
|
|
257
265
|
|
|
258
266
|
tool('chromex_touch',
|
|
@@ -262,6 +270,7 @@ const TOOLS = [
|
|
|
262
270
|
gesture: { type: 'string', enum: ['tap', 'swipe', 'pinch', 'longpress'], description: 'Gesture type' },
|
|
263
271
|
args: { type: 'array', items: { type: 'string' }, description: 'Gesture args: tap(x,y), swipe(x1,y1,x2,y2), pinch(x,y,scale), longpress(x,y,[ms])' },
|
|
264
272
|
noSnap: P_NO_SNAP,
|
|
273
|
+
noHints: P_NO_HINTS,
|
|
265
274
|
}, ['target', 'gesture'], RW),
|
|
266
275
|
|
|
267
276
|
tool('chromex_dialog',
|
|
@@ -271,6 +280,7 @@ const TOOLS = [
|
|
|
271
280
|
action: { type: 'string', enum: ['accept', 'dismiss', 'auto'], description: 'Dialog action' },
|
|
272
281
|
text: { type: 'string', description: 'Text for prompt (only with accept)' },
|
|
273
282
|
noSnap: P_NO_SNAP,
|
|
283
|
+
noHints: P_NO_HINTS,
|
|
274
284
|
}, ['target', 'action'], RW),
|
|
275
285
|
|
|
276
286
|
tool('chromex_press_key',
|
|
@@ -279,6 +289,7 @@ const TOOLS = [
|
|
|
279
289
|
target: P_TARGET,
|
|
280
290
|
key: { type: 'string', description: 'Key or combination: "Enter", "Tab", "Escape", "Control+A", "Control+Shift+R", "Meta+C"' },
|
|
281
291
|
noSnap: P_NO_SNAP,
|
|
292
|
+
noHints: P_NO_HINTS,
|
|
282
293
|
}, ['target', 'key'], RW),
|
|
283
294
|
|
|
284
295
|
tool('chromex_loadall',
|
|
@@ -288,6 +299,7 @@ const TOOLS = [
|
|
|
288
299
|
selector: { type: 'string', description: 'CSS selector of load-more button' },
|
|
289
300
|
interval: { type: 'number', description: 'Interval between clicks in ms (default: 1500)' },
|
|
290
301
|
noSnap: P_NO_SNAP,
|
|
302
|
+
noHints: P_NO_HINTS,
|
|
291
303
|
}, ['target', 'selector'], RW),
|
|
292
304
|
|
|
293
305
|
// == FORMS ==
|
|
@@ -298,6 +310,7 @@ const TOOLS = [
|
|
|
298
310
|
selector: { type: 'string', description: 'CSS selector or @eN ref' },
|
|
299
311
|
value: { type: 'string', description: 'Value to fill' },
|
|
300
312
|
noSnap: P_NO_SNAP,
|
|
313
|
+
noHints: P_NO_HINTS,
|
|
301
314
|
}, ['target', 'selector', 'value'], RW),
|
|
302
315
|
|
|
303
316
|
tool('chromex_clear',
|
|
@@ -306,6 +319,7 @@ const TOOLS = [
|
|
|
306
319
|
target: P_TARGET,
|
|
307
320
|
selector: { type: 'string', description: 'CSS selector' },
|
|
308
321
|
noSnap: P_NO_SNAP,
|
|
322
|
+
noHints: P_NO_HINTS,
|
|
309
323
|
}, ['target', 'selector'], RW),
|
|
310
324
|
|
|
311
325
|
tool('chromex_select',
|
|
@@ -315,6 +329,7 @@ const TOOLS = [
|
|
|
315
329
|
selector: { type: 'string', description: 'CSS selector of select element' },
|
|
316
330
|
value: { type: 'string', description: 'Option value or visible text' },
|
|
317
331
|
noSnap: P_NO_SNAP,
|
|
332
|
+
noHints: P_NO_HINTS,
|
|
318
333
|
}, ['target', 'selector', 'value'], RW),
|
|
319
334
|
|
|
320
335
|
tool('chromex_check',
|
|
@@ -324,6 +339,7 @@ const TOOLS = [
|
|
|
324
339
|
selector: { type: 'string', description: 'CSS selector' },
|
|
325
340
|
checked: { type: 'boolean', description: 'Desired state (default: true)', default: true },
|
|
326
341
|
noSnap: P_NO_SNAP,
|
|
342
|
+
noHints: P_NO_HINTS,
|
|
327
343
|
}, ['target', 'selector'], RW),
|
|
328
344
|
|
|
329
345
|
tool('chromex_form',
|
|
@@ -332,6 +348,7 @@ const TOOLS = [
|
|
|
332
348
|
target: P_TARGET,
|
|
333
349
|
fields: { type: 'string', description: 'JSON: {"#email":"user@test.com","#terms":true}' },
|
|
334
350
|
noSnap: P_NO_SNAP,
|
|
351
|
+
noHints: P_NO_HINTS,
|
|
335
352
|
}, ['target', 'fields'], RW),
|
|
336
353
|
|
|
337
354
|
tool('chromex_upload',
|
|
@@ -341,6 +358,7 @@ const TOOLS = [
|
|
|
341
358
|
selector: { type: 'string', description: 'CSS selector of file input' },
|
|
342
359
|
files: { type: 'array', items: { type: 'string' }, description: 'File path(s)' },
|
|
343
360
|
noSnap: P_NO_SNAP,
|
|
361
|
+
noHints: P_NO_HINTS,
|
|
344
362
|
}, ['target', 'selector', 'files'], RW),
|
|
345
363
|
|
|
346
364
|
// == DATA ==
|
|
@@ -516,6 +534,7 @@ function toolToCmd(name, p) {
|
|
|
516
534
|
if (p.refs) a.push('--refs');
|
|
517
535
|
if (p.full) a.push('--full');
|
|
518
536
|
if (p.depth) a.push(`--depth=${p.depth}`);
|
|
537
|
+
if (p.query) a.push(`--query=${p.query}`);
|
|
519
538
|
return { cmd: 'snap', args: a };
|
|
520
539
|
}
|
|
521
540
|
case 'chromex_html': return { cmd: 'html', args: p.selector ? [p.selector] : [] };
|
|
@@ -713,6 +732,7 @@ async function executeTool(name, params) {
|
|
|
713
732
|
if (!mapped) return fail(`Unknown tool: ${name}`);
|
|
714
733
|
|
|
715
734
|
if (params.noSnap) mapped.args.push('--no-snap');
|
|
735
|
+
if (params.noHints) mapped.args.push('--no-hints');
|
|
716
736
|
|
|
717
737
|
const conn = await getOrStartTabDaemon(targetId, config);
|
|
718
738
|
const response = await sendCommand(conn, { cmd: mapped.cmd, args: mapped.args });
|