cdp-toolkit 1.9.0 → 1.9.1
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/CHANGELOG.md +118 -0
- package/CONTRACT.md +3 -3
- package/README.md +21 -7
- package/dist/cli.js +73 -14
- package/dist/index.js +60 -12
- package/dist/mcp.js +92 -23
- package/package.json +1 -1
- package/src/bidi-tools.ts +21 -0
- package/src/cli.ts +4 -2
- package/src/manifest.ts +22 -10
- package/src/mcp.ts +1 -1
- package/src/tools/console.ts +24 -7
- package/src/tools/network.ts +21 -7
- package/src/tools/recorder.ts +110 -10
- package/src/types.ts +8 -6
- package/src/workers.ts +69 -7
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,123 @@ All notable changes to cdp-toolkit are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.9.1] - 2026-08-10
|
|
9
|
+
|
|
10
|
+
`list_network_requests`, `get_network_request`, and `list_console_messages` now
|
|
11
|
+
observe MV3 background service workers, not just pages: the outbound fetch a
|
|
12
|
+
worker makes to a real backend is a first-class capture target, not something
|
|
13
|
+
you have to fake with an `evaluate_script` monkeypatch. Tool count stays 45 —
|
|
14
|
+
the three tools grow a `worker:` arm, no new tool. One seat (N1) landed it,
|
|
15
|
+
every protocol claim below probed against a live Chrome 151 before being built
|
|
16
|
+
on.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **The three console/network reader tools accept `target: "worker:<substring>"`
|
|
21
|
+
or a bare worker target id**, alongside the page selectors they already took.
|
|
22
|
+
Chrome only (capability `worker.targets`) — Firefox refuses the arm from its
|
|
23
|
+
real capability set before launching a browser that could never have served
|
|
24
|
+
the call. The allowlist that grants the arm, `WORKER_CAPABLE_TOOLS` in
|
|
25
|
+
`src/workers.ts`, now names four tools (`evaluate_script` plus these three);
|
|
26
|
+
it is the single source of truth for both the refusal messages and
|
|
27
|
+
`test/manifest-grammar-drift.test.ts`'s exclusion property, so a fifth tool
|
|
28
|
+
cannot silently advertise `worker:` without a deliberate edit here.
|
|
29
|
+
|
|
30
|
+
- **A worker capture LISTENS instead of reloading.** Measured on Chrome
|
|
31
|
+
151.0.7922.109: `Network.enable` and `Runtime.consoleAPICalled` work
|
|
32
|
+
identically on a direct worker session to how they work on a page — all four
|
|
33
|
+
events the recorder persists (`requestWillBeSent`, `responseReceived`,
|
|
34
|
+
`loadingFinished`, `loadingFailed`) arrive, and `Network.getResponseBody`
|
|
35
|
+
serves the body — but `Page.enable`/`Page.reload` **do not exist** on a
|
|
36
|
+
worker session (`-32601 'Page.enable' wasn't found`). A page capture is
|
|
37
|
+
reload-driven; a worker capture cannot be, so it opens the worker's own CDP
|
|
38
|
+
session and listens for `durationMs` instead. `captureWindow()`'s branch is
|
|
39
|
+
keyed off the resolved target's type, not the selector's shape, so a bare
|
|
40
|
+
worker id takes the listen path too.
|
|
41
|
+
|
|
42
|
+
- **Recording a worker keeps it alive for the length of the capture — a
|
|
43
|
+
deliberate side effect, not a bug.** A held CDP session suppresses MV3 idle
|
|
44
|
+
eviction; measured in both directions with the same polling loop so
|
|
45
|
+
"stayed alive" isn't an artifact of how the poll works: with the recorder's
|
|
46
|
+
session held, the worker is still present in `/json/list` and still emits a
|
|
47
|
+
fresh `Network.requestWillBeSent` for a triggered fetch at t+60s; once the
|
|
48
|
+
session detaches, the same worker idle-evicts within ~30s. Documented in the
|
|
49
|
+
three tools' manifest descriptions and asserted in both directions by the
|
|
50
|
+
live smoke, not left implicit.
|
|
51
|
+
|
|
52
|
+
- **`wake` (default `true` on a capture) is REFUSED on a read-only call and on
|
|
53
|
+
a bare target id, rather than accepted and ignored.** Measured: a worker
|
|
54
|
+
that idle-evicts and restarts comes back under a brand-new target id (probed
|
|
55
|
+
restart: `C43F0A18...` → `1F7D3C59...` for the same extension). The recorder
|
|
56
|
+
keys its buffer file by target id, so waking a worker on a *read* would hand
|
|
57
|
+
back a live worker with an empty buffer — "0 requests" for a capture that
|
|
58
|
+
really did record some, a zero shaped like an answer. A wake therefore only
|
|
59
|
+
makes sense when a capture is being started, and only from a
|
|
60
|
+
`worker:<substring>` selector, since an evicted worker's old id no longer
|
|
61
|
+
exists to name. The refusal messages (`workerWakeMisuseMessage`,
|
|
62
|
+
`workerBufferReadMissMessage` in `src/workers.ts`) teach the id-re-mint fact
|
|
63
|
+
rather than just rejecting the argument.
|
|
64
|
+
|
|
65
|
+
- **Known limitation, stated rather than silently absent: a worker woken BY a
|
|
66
|
+
capture has already run its top-level code by the time the recorder
|
|
67
|
+
attaches**, so a fetch the worker makes at startup can be missed. Trigger
|
|
68
|
+
the request you want to observe (a message, an alarm) after the capture
|
|
69
|
+
starts rather than relying on the worker's own boot-time fetch. Fixing this
|
|
70
|
+
needs `Target.setAutoAttach{waitForDebuggerOnStart}` on a held browser
|
|
71
|
+
session, a different feature; the limitation is documented in
|
|
72
|
+
`recorder.ts`'s header and in the three manifest descriptions instead.
|
|
73
|
+
|
|
74
|
+
- **`get_console_message` reads the same worker-keyed buffer (so its miss
|
|
75
|
+
message matches what it reads) but is deliberately NOT in
|
|
76
|
+
`WORKER_CAPABLE_TOOLS`** — it already resolved worker targets through the
|
|
77
|
+
1.9.0 arm, routing it through the read-path resolver only sharpens its miss
|
|
78
|
+
message; advertising it as worker-capable was out of scope, and the drift
|
|
79
|
+
test pins the advertised list at four.
|
|
80
|
+
|
|
81
|
+
### Measured, not assumed — the protocol facts this release ships on
|
|
82
|
+
|
|
83
|
+
- **`Page.enable`/`Page.reload` are `-32601` on a worker session** — the
|
|
84
|
+
single measurement that shapes the implementation: a worker window cannot
|
|
85
|
+
be reload-driven, so it listens.
|
|
86
|
+
- **A held session suppresses MV3 idle eviction; detach restores it** —
|
|
87
|
+
confirmed in both directions with the same polling loop, including a
|
|
88
|
+
post-hold evaluate and a fresh `Network.requestWillBeSent` at t+60s, so
|
|
89
|
+
"alive" means genuinely live, not a stale listing entry.
|
|
90
|
+
- **A worker's target id is re-minted on every restart, never stable** — this
|
|
91
|
+
is why `wake` is refused on reads and on bare ids rather than silently doing
|
|
92
|
+
the wrong thing.
|
|
93
|
+
- **Two things a caller might reach for instead don't work, for ordinary
|
|
94
|
+
JavaScript reasons, not a service-worker-specific one.** A value bound at
|
|
95
|
+
module top level is not visible from `evaluate_script`'s global scope
|
|
96
|
+
(plain lexical scoping, identical in a page); assigning `self.fetch`
|
|
97
|
+
afterward cannot rebind a `fetch` reference the module already captured
|
|
98
|
+
(closure semantics, also identical in a page). `Network`-domain recording
|
|
99
|
+
on the worker's own session observes what the code ACTUALLY SENT and needs
|
|
100
|
+
neither. (An earlier field report attributing this to service-worker eval
|
|
101
|
+
semantics was a misdiagnosis of an outdated loaded extension build, not a
|
|
102
|
+
real capability gap — see the stale-build tip below.)
|
|
103
|
+
|
|
104
|
+
### Notes
|
|
105
|
+
|
|
106
|
+
- Tool count is unchanged at **45** — this release grew the `target` grammar
|
|
107
|
+
on three existing tools, not the tool list.
|
|
108
|
+
- `docs/social-preview.html` and `docs/demo.tape` carry no version string and
|
|
109
|
+
needed no re-render for this release.
|
|
110
|
+
- Before deep-debugging an extension that "the toolkit can't see": verify the
|
|
111
|
+
loaded build is current with
|
|
112
|
+
`evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`.
|
|
113
|
+
An outdated unpacked build left over from a prior load mimics every symptom
|
|
114
|
+
in this release's "measured facts" section above.
|
|
115
|
+
- New tests: 20 in `test/worker-capture.test.ts`, 3 more in
|
|
116
|
+
`test/manifest-grammar-drift.test.ts` (allowlist rescoped from an
|
|
117
|
+
evaluate_script-only exclusion to the four-tool `WORKER_CAPABLE_TOOLS`
|
|
118
|
+
allowlist), and new live scenarios in `test/extension-smoke.ts` covering the
|
|
119
|
+
worker fetch/console capture, the eviction pair in both directions, and the
|
|
120
|
+
capture-path wake (proven by the fixture's own start counter, `starts: 2 ->
|
|
121
|
+
3`, never by `ServiceWorker.startWorker`'s result, which reports empty
|
|
122
|
+
success even for a scope that doesn't exist) — 31/31 assertions, up from the
|
|
123
|
+
1.9.0 baseline of 20.
|
|
124
|
+
|
|
8
125
|
## [1.9.0] - 2026-08-09
|
|
9
126
|
|
|
10
127
|
Four complaints from a field agent driving an MV3 extension, closed without adding
|
|
@@ -870,6 +987,7 @@ First public release.
|
|
|
870
987
|
- Zero runtime dependencies in the CDP/CLI layer (Node's global `WebSocket` + `fetch`). The MCP server adds only `@modelcontextprotocol/sdk`; `lighthouse_audit` is the sole non-CDP tool and shells out to `npx lighthouse`.
|
|
871
988
|
- Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
|
|
872
989
|
|
|
990
|
+
[1.9.1]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.1
|
|
873
991
|
[1.9.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.0
|
|
874
992
|
[1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
|
|
875
993
|
[1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
|
package/CONTRACT.md
CHANGED
|
@@ -18,9 +18,9 @@ writing a module.
|
|
|
18
18
|
|
|
19
19
|
**`claim_page{target}` is a deliberate, reviewed exception to this rule, not a violation of it.** Its target resolution goes through `pickPage` (`src/shared-tools.ts`), which is exported *specifically* for this one caller and does **not** call `assertLeaseOk`. Routing a claim through `resolvePage` instead would, under strict mode, auto-acquire a lease on the way in and then collide with the explicit claim right behind it, one call claiming the same tab against itself twice. The protection is not dropped, only relocated one layer down: `claimLease`'s own conflict check still refuses a tab any live process holds, auto-acquired or explicit, so a stranger's tab is still refused, only later and by a different function. `pickPage` must never gain a second caller without re-deriving this argument for that caller too; if it does not need gate-free resolution, it needs `resolvePage`, not `pickPage`.
|
|
20
20
|
|
|
21
|
-
**A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets
|
|
21
|
+
**A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets`; one tool in 1.9.0, `evaluate_script` — four as of 1.9.1, see `WORKER_CAPABLE_TOOLS` in `src/workers.ts`) resolves to a service/shared worker target, and `resolveTarget` returns it before reaching `assertLeaseOk` — see the comment at the top of that branch in `src/client.ts`. A lease answers "who is driving this tab"; a worker is not a tab, has no reap-meaningful close, and strict-mode auto-acquire on it would mint a lease file keyed on an id that vanishes the moment Chrome idles the worker out, garbage the page-only reap set (`ReapInput.livePageIds`) can never collect. This also cannot be used to bypass a lease on an actual tab: there is no tab a worker resolution could stand in for. **The three console/network reader tools take a parallel path, not this one** — `resolveRecorderTarget`/`resolveWorkerSelector` in `src/tools/recorder.ts`/`src/cdp/workers.ts` — but the same reasoning holds: neither touches `assertLeaseOk`, tested by asserting zero lease files on a worker read with a page control in the same strict-mode window.
|
|
22
22
|
|
|
23
|
-
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (
|
|
23
|
+
**The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (only a tool in the `WORKER_CAPABLE_TOOLS` allowlist — four as of 1.9.1 — may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
|
|
24
24
|
|
|
25
25
|
7. **Never dispatch `Input.*` outside `sendInput`.** As of 1.8.0 there is exactly one function that writes a synthesized input command to a Chrome connection: `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`. `CdpPageDriver.dispatchInput` (the per-driver choke point every interaction method already had to go through) delegates to it, and any tool built outside the Driver abstraction (`dispatch_mouse`, in `src/tools/dispatch-mouse.ts`, is the precedent) must call it directly rather than opening its own `conn.send("Input....", ...)`. This is what keeps the activity beacon's human/agent discrimination (`src/activity.ts`'s dispatch log) correct: a new input path that bypasses `sendInput` will typecheck, pass its own unit tests, and pass `bun test` in full, while silently making the toolkit's own input read as a human using the tab — exactly the 1.8.0 merge bug `test/input-dispatch-wiring.test.ts` now exists to catch (it walks every `.ts` under `src/`, strips comment-only lines, and fails on any line that both sends and names `Input.`). On the Firefox side, `BidiPageDriver.performActions` is the twin choke point and already calls `recordDispatch`; a new BiDi input path goes through it the same way `CdpPageDriver.dispatchInput` is used on Chrome. Mode-toggle commands that dispatch no page input themselves (`Input.setInterceptDrags`'s arm/disarm around HTML5 drag) are still sent through `sendInput` but are on a denylist that skips recording them — classify a new such command explicitly; the default for anything unclassified is to be logged, since over-warning a human is recoverable and silently misattributing their input to "agent" is not.
|
|
26
26
|
|
|
@@ -117,7 +117,7 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
|
|
|
117
117
|
| `src/tools/screenshot.ts` | `take_screenshot`→`takeScreenshot` | `Page.captureScreenshot`. Support `{ format:'png'|'jpeg', quality, fullPage, uid?/selector? (element clip via getBoxModel), savePath? }`. Write to ARTIFACT_DIR, return `{ path, bytes, format }` and optionally base64 when `{ returnBase64:true }`. |
|
|
118
118
|
| `src/tools/emulation.ts` | `emulate`→`emulate`, `resize_page`→`resizePage` | `Emulation.setDeviceMetricsOverride` (width,height,deviceScaleFactor,mobile), `Emulation.setUserAgentOverride`, `Emulation.setCPUThrottlingRate`, `Emulation.setEmulatedMedia`, `Network`-based throttling via `Network.emulateNetworkConditions`. `resize_page` = device metrics width/height. |
|
|
119
119
|
| `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
|
|
120
|
-
| `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. |
|
|
120
|
+
| `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. **As of 1.9.1, the same three tools also accept a `worker:<substring>` target** (Chrome only, capability `worker.targets`; MV3 service/shared workers), resolved via `resolveRecorderTarget`/`resolveWorkerSelector` in `src/workers.ts` rather than `resolveTarget`. Because `Page.enable`/`Page.reload` do not exist on a worker session (measured: `-32601`), `captureWindow()` skips the reload for a worker-typed resolution and LISTENS on the held connection for `durationMs` instead — keyed off the resolved target's type, not the selector's shape, so a bare worker id takes the same branch as a `worker:` selector. Holding that session is a deliberate side effect, the same pattern as the beacon sessions below: it SUPPRESSES the worker's MV3 idle eviction for as long as the capture runs (measured: alive and still emitting events at 60s held, evicted ~30s after detach). `wake` (default `true` on a capture) restarts an evicted worker first, but is refused on a read-only call and on a bare target id, because a restart mints a NEW target id and the buffer is keyed by the old one — see `workerWakeMisuseMessage` in `src/workers.ts`. |
|
|
121
121
|
| `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
|
|
122
122
|
| `src/tools/screencast.ts` | `start_screen_recording`→`startScreenRecording`, `stop_screen_recording`→`stopScreenRecording` | **Toolkit addition (not MCP parity): a tab captured to an H.265 MP4.** Persistent `openPage` conn parked in a module-level `Map` keyed by targetId (recorder.ts's statefulness, performance.ts's registry), `Page.startScreencast` → `Page.screencastFrame` → **ack from the handler, before the disk write** (an unacked frame stalls the stream) → spool file + ledger row `{file,timestamp,width,height}`. Frames arrive on repaint only, so playback timing MUST come from per-frame ffconcat `duration`s off the ledger, never a fixed rate. Encoder ladder probed once from `ffmpeg -encoders`: `hevc_videotoolbox`→`h264_videotoolbox`→`libx265`→`libx264`, `-tag:v hvc1` on HEVC, always `+faststart`/`yuv420p`/even dims. ffmpeg is probed at START (fail before capturing, not after). Pure parts (`frameDurationsMs`/`buildConcatManifest`/`selectEncoder`/`buildFfmpegArgs`/`imageSize`) are unit-tested; live capture via `test/screencast-smoke.ts`. |
|
|
123
123
|
| `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
|
package/README.md
CHANGED
|
@@ -253,7 +253,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
253
253
|
| `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
|
|
254
254
|
| `navigate_page` | `Page.navigate` / `Page.reload` + load events, or `history:'back'\|'forward'` (Chrome: `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`; Firefox: `browsingContext.traverseHistory`) | Returns `{url,frameId,waitedFor}` (no auto-snapshot; a history move also returns `traversed:'back'\|'forward'`). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). `url`/`reload`/`history` are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
|
|
255
255
|
| `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
|
|
256
|
-
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`.
|
|
256
|
+
| `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`. One of four tools that also accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) to reach an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
|
|
257
257
|
| `list_cookies` *(superset)* | `Network.getCookies` | Reads the target page's cookie store, httpOnly cookies included, which `document.cookie` and therefore `evaluate_script` cannot see. Page-scoped on purpose, not the browser-wide jar (`Storage.getCookies`), so it answers for the tab you named. Optional `domain`/`name` filters; optional `savePath` writes the array to a JSON file and returns `{path,bytes,count,target}` with no cookie value in the response. |
|
|
258
258
|
| `set_cookie` *(superset)* | `Network.setCookie` | Writes one cookie, httpOnly and secure ones included, which `document.cookie` cannot create. Either `url` or `domain` is required and the call is refused with an error when neither is given. Chrome's `success:false` refusal is raised as an error rather than reported as a write. Answers `{set:true,target}` and never echoes the value back. `path` is passed through as given, never defaulted. |
|
|
259
259
|
| `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
|
|
@@ -274,10 +274,10 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
274
274
|
| `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
|
|
275
275
|
| `resize_page` | `Emulation.setDeviceMetricsOverride` | Verifies via `window.innerWidth/innerHeight`. Override persists on the target. |
|
|
276
276
|
| `handle_dialog` | `Page.javascriptDialogOpening` / `handleJavaScriptDialog` | Caller arms first and triggers out-of-band (or `handleDialogForExpression` to trigger-and-handle atomically). Supports wait-for-next and auto-handle-for-N-ms. |
|
|
277
|
-
| `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. |
|
|
278
|
-
| `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. |
|
|
279
|
-
| `list_network_requests` | `Network.*` events (+ `Page.reload`) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. |
|
|
280
|
-
| `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh
|
|
277
|
+
| `list_console_messages` | `Runtime`/`Log` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | `reload:true` records console+network into a unique per-capture file; default read returns the latest. Args flattened best-effort. Accepts `target: "worker:<substring>"` (Chrome only) to capture an MV3 service worker's `console.log` — see "Driving MV3 extensions" below. |
|
|
278
|
+
| `get_console_message` | reads the shared "latest" buffer | Index into the latest capture; throws if out of range. Resolves a worker-keyed buffer too, but is deliberately not advertised as worker-capable (see CHANGELOG 1.9.1). |
|
|
279
|
+
| `list_network_requests` | `Network.*` events (+ `Page.reload`, or LISTEN for `durationMs` on a worker target) | Correlated rows from the per-capture buffer; redirect chains collapse to the first row. No timing breakdown / POST data. Accepts `target: "worker:<substring>"` (Chrome only) to capture the requests an MV3 service worker itself makes — see "Driving MV3 extensions" below. |
|
|
280
|
+
| `get_network_request` | above + `Network.getResponseBody` | Bodies only via a fresh capture (CDP serves bodies from the live session); `includeBody` matches by **url**. Worker-capable, same as `list_network_requests`. |
|
|
281
281
|
| `performance_start_trace` | `Tracing.start` / `dataCollected` | Works ONLY within one process; a live trace buffer is bound to its connection. Use `performance_trace` for robustness. |
|
|
282
282
|
| `performance_stop_trace` | `Tracing.end` / `tracingComplete` | Must run in the SAME process as `performance_start_trace`; throws a clear error otherwise. |
|
|
283
283
|
| `performance_analyze_insight` | parses a trace JSON file | A **CDP-native approximation** of the MCP insight analyzer (FCP/LCP/CLS/TBT/long-tasks); close but not byte-identical. Requires an explicit `tracePath`. |
|
|
@@ -295,7 +295,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
|
|
|
295
295
|
|
|
296
296
|
## Driving MV3 extensions
|
|
297
297
|
|
|
298
|
-
An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector.
|
|
298
|
+
An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector. Four tools accept `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) — `evaluate_script`, and, as of 1.9.1, `list_network_requests`, `get_network_request`, and `list_console_messages` — any substring of the worker's url, so `worker:<extension-id>` and `worker:background.js` both work:
|
|
299
299
|
|
|
300
300
|
```json
|
|
301
301
|
{"tool": "evaluate_script", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "expression": "chrome.storage.local.get()", "args": []}}
|
|
@@ -306,9 +306,23 @@ A few facts worth knowing before you reach for it, all measured against a real b
|
|
|
306
306
|
- **An idle MV3 worker is evicted within seconds of its last activity, and once evicted it is invisible to every target listing** — not "stopped and still visible," genuinely absent from `/json/list` and `Target.getTargets` under any filter. So a `worker:` miss almost always means "asleep," not "wrong id," and the error says so.
|
|
307
307
|
- **`wake` defaults to `true`:** a selector that matches nothing, or a known-but-stopped worker, is started for you first, then re-resolved. Pass `wake:false` to fail fast instead — the error then teaches the eviction fact rather than reading like a typo.
|
|
308
308
|
- **A wake is verified by re-reading the target list, never by the start command's own result** — Chrome's start command reports success even for a scope that doesn't exist, so nothing here trusts it.
|
|
309
|
-
- **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker
|
|
309
|
+
- **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker-targeted call, on any of the four tools; a worker id is Chrome's to create and destroy, not this toolkit's to fence.
|
|
310
310
|
- **If you're loading your own unpacked extension for testing, `--load-extension` does nothing on recent Chrome**, even with the documented unblock flag. Use `Extensions.loadUnpacked` over CDP instead (what this toolkit's own extension smoke does).
|
|
311
311
|
- **The extension id is a hash of the unpacked extension's absolute path**, so it's deterministic across loads from the same directory but differs from the id a packed/store install would get — don't hardcode an id copied from a different machine's path.
|
|
312
|
+
- **Before deep-debugging something that looks like "the toolkit can't see it," check you're driving the build you think you are:** `evaluate_script {target:"worker:<id>", expression:"chrome.runtime.getManifest().version"}`. Chrome keeps serving a stale unpacked build's worker until you explicitly reload the extension, and an outdated loaded build mimics every symptom below.
|
|
313
|
+
|
|
314
|
+
### Watching what a worker sends and logs (1.9.1)
|
|
315
|
+
|
|
316
|
+
`list_network_requests`, `get_network_request`, and `list_console_messages` record a worker the same way they record a page — over the worker's own CDP session, the same `Network`/`Runtime` domains, the same `get_*` follow-up for bodies — with one difference: a worker capture can't be driven by reload. Measured against Chrome 151.0.7922.109: `Page.enable`/`Page.reload` don't exist on a worker session (`-32601 'Page.enable' wasn't found`), so a worker capture LISTENS for `durationMs` instead of reloading; `Network.requestWillBeSent`/`responseReceived`/`loadingFinished` and `Runtime.consoleAPICalled` all arrive on that same session, and `Network.getResponseBody` serves the body, exactly like a page capture.
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{"tool": "list_network_requests", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "reload": true, "durationMs": 5000}}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
- **Recording a worker keeps it alive for the length of the capture — a documented side effect, not a bug.** A held CDP session suppresses MV3 idle eviction (measured: alive and still emitting events at 60s held; evicted ~30s after the recorder detaches). Watch a worker and Chrome won't put it to sleep out from under you until you stop.
|
|
323
|
+
- **`wake` (default `true` on a capture) is refused on a read-only call and on a bare target id**, rather than accepted and quietly doing nothing useful. A worker that idle-evicts and restarts comes back under a brand-new target id (measured: re-minted on every restart), and the buffer is keyed by id, so waking on a *read* would hand back a live worker with an empty buffer — "0 requests" for a capture that really did record some, a zero shaped like an answer. Re-run with `reload:true` to record a fresh capture instead.
|
|
324
|
+
- **A worker woken by the capture has already run its top-level code by the time the recorder attaches**, so a fetch it makes at startup can be missed. Trigger the request you want to observe (a message, an alarm) after the capture starts rather than relying on the worker's own boot-time fetch.
|
|
325
|
+
- **A `self.fetch` monkeypatch via `evaluate_script` won't reliably intercept a worker's requests, and reading a module-scoped value from `evaluate_script` won't reliably work either — both for ordinary JavaScript reasons, not a service-worker-specific one.** A value bound at module top level is not visible from `evaluate_script`'s global scope (plain lexical scoping, identical in a page); assigning `self.fetch` cannot rebind a `fetch` reference the module already captured (closure semantics, also identical in a page). `list_network_requests`/`get_network_request` observe the `Network` domain on the worker's own session directly and need neither.
|
|
312
326
|
|
|
313
327
|
`worker:` is deliberately absent everywhere else — `close_page`/`select_page`/`release_page` and `claim_page`'s `target` stay page-only, refusing the arm by name rather than silently missing, and Firefox refuses it with a capability error (`worker.targets`), since WebDriver BiDi has no extension-service-worker target to map onto.
|
|
314
328
|
|
package/dist/cli.js
CHANGED
|
@@ -406,6 +406,12 @@ async function resolveLiveLabel(backend, label, liveIds) {
|
|
|
406
406
|
|
|
407
407
|
// src/workers.ts
|
|
408
408
|
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
409
|
+
var WORKER_CAPABLE_TOOLS = [
|
|
410
|
+
"evaluate_script",
|
|
411
|
+
"list_network_requests",
|
|
412
|
+
"get_network_request",
|
|
413
|
+
"list_console_messages"
|
|
414
|
+
];
|
|
409
415
|
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
410
416
|
function isWorkerSelector(selector) {
|
|
411
417
|
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
@@ -424,8 +430,18 @@ function resolveWorkerTargets(targets, needle) {
|
|
|
424
430
|
};
|
|
425
431
|
}
|
|
426
432
|
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
427
|
-
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
|
|
428
|
-
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless).
|
|
433
|
+
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
|
|
434
|
+
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
435
|
+
function workerWakeMisuseMessage(kind) {
|
|
436
|
+
if (kind === "not-a-worker-selector") {
|
|
437
|
+
return "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " + "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " + "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running.";
|
|
438
|
+
}
|
|
439
|
+
return "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " + "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " + "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one.";
|
|
440
|
+
}
|
|
441
|
+
function workerBufferReadMissMessage(needle, liveWorkers) {
|
|
442
|
+
const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
|
|
443
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " + "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " + "re-run with reload:true, which starts the worker and records a fresh capture window.";
|
|
444
|
+
}
|
|
429
445
|
function urlList(workers) {
|
|
430
446
|
return workers.map((w) => w.url).join(", ");
|
|
431
447
|
}
|
|
@@ -2840,6 +2856,31 @@ function captureFile(targetId, captureId) {
|
|
|
2840
2856
|
function delay2(ms) {
|
|
2841
2857
|
return new Promise((r) => setTimeout(r, ms));
|
|
2842
2858
|
}
|
|
2859
|
+
async function resolveRecorderTarget(target, opts) {
|
|
2860
|
+
if (!isWorkerSelector(target))
|
|
2861
|
+
return resolveTarget(target);
|
|
2862
|
+
if (opts.capture)
|
|
2863
|
+
return resolveWorkerSelector(target, { wake: opts.wake ?? true });
|
|
2864
|
+
const needle = workerNeedle(target);
|
|
2865
|
+
if (needle === "")
|
|
2866
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
2867
|
+
const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
|
|
2868
|
+
if (matches.length > 1)
|
|
2869
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
2870
|
+
if (matches.length === 1)
|
|
2871
|
+
return matches[0];
|
|
2872
|
+
throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
|
|
2873
|
+
}
|
|
2874
|
+
function assertWakeApplies(target, wake, capture) {
|
|
2875
|
+
if (wake === undefined)
|
|
2876
|
+
return;
|
|
2877
|
+
if (typeof wake !== "boolean")
|
|
2878
|
+
throw new CdpError("'wake' must be a boolean");
|
|
2879
|
+
if (!isWorkerSelector(target))
|
|
2880
|
+
throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
|
|
2881
|
+
if (!capture)
|
|
2882
|
+
throw new CdpError(workerWakeMisuseMessage("read-only-call"));
|
|
2883
|
+
}
|
|
2843
2884
|
var NETWORK_EVENTS = [
|
|
2844
2885
|
"Network.requestWillBeSent",
|
|
2845
2886
|
"Network.responseReceived",
|
|
@@ -2905,7 +2946,8 @@ async function startRecorder(target, opts = {}) {
|
|
|
2905
2946
|
};
|
|
2906
2947
|
}
|
|
2907
2948
|
async function captureWindow(target, durationMs, opts = {}) {
|
|
2908
|
-
const resolved = await
|
|
2949
|
+
const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
|
|
2950
|
+
const isWorker = isWorkerTargetType(resolved.type);
|
|
2909
2951
|
const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2910
2952
|
const file = captureFile(resolved.id, captureId);
|
|
2911
2953
|
const rec = await startRecorder(resolved.id, {
|
|
@@ -2915,13 +2957,16 @@ async function captureWindow(target, durationMs, opts = {}) {
|
|
|
2915
2957
|
file,
|
|
2916
2958
|
timeoutMs: opts.timeoutMs
|
|
2917
2959
|
});
|
|
2918
|
-
|
|
2919
|
-
|
|
2960
|
+
if (!isWorker) {
|
|
2961
|
+
await rec.conn.send("Page.enable");
|
|
2962
|
+
await rec.conn.send("Page.reload", { ignoreCache: false });
|
|
2963
|
+
}
|
|
2920
2964
|
await delay2(durationMs);
|
|
2921
2965
|
return {
|
|
2922
2966
|
file: rec.file,
|
|
2923
2967
|
conn: rec.conn,
|
|
2924
2968
|
resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
|
|
2969
|
+
mode: isWorker ? "listen" : "reload",
|
|
2925
2970
|
droppedWrites: rec.droppedWrites,
|
|
2926
2971
|
flush: () => rec.flush(),
|
|
2927
2972
|
async stop() {
|
|
@@ -3021,8 +3066,9 @@ async function readConsoleEntries(file) {
|
|
|
3021
3066
|
return out;
|
|
3022
3067
|
}
|
|
3023
3068
|
async function listConsoleMessages(args = {}) {
|
|
3069
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3024
3070
|
if (args.reload) {
|
|
3025
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
3071
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
|
|
3026
3072
|
await cap.stop();
|
|
3027
3073
|
const messages2 = await readConsoleEntries(cap.file);
|
|
3028
3074
|
return {
|
|
@@ -3032,7 +3078,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3032
3078
|
droppedWrites: cap.droppedWrites()
|
|
3033
3079
|
};
|
|
3034
3080
|
}
|
|
3035
|
-
const target = await
|
|
3081
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3036
3082
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3037
3083
|
return {
|
|
3038
3084
|
target: { id: target.id, url: target.url, title: target.title },
|
|
@@ -3041,7 +3087,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3041
3087
|
};
|
|
3042
3088
|
}
|
|
3043
3089
|
async function getConsoleMessage(args = {}) {
|
|
3044
|
-
const target = await
|
|
3090
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3045
3091
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3046
3092
|
const index = args.index ?? 0;
|
|
3047
3093
|
const entry = messages[index];
|
|
@@ -3139,14 +3185,15 @@ async function listNetworkRequests(args = {}) {
|
|
|
3139
3185
|
let file;
|
|
3140
3186
|
let resolved;
|
|
3141
3187
|
let droppedWrites;
|
|
3188
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3142
3189
|
if (args.reload) {
|
|
3143
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3190
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3144
3191
|
await cap.stop();
|
|
3145
3192
|
file = cap.file;
|
|
3146
3193
|
resolved = cap.resolved;
|
|
3147
3194
|
droppedWrites = cap.droppedWrites();
|
|
3148
3195
|
} else {
|
|
3149
|
-
const target = await
|
|
3196
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3150
3197
|
file = recFile(target.id);
|
|
3151
3198
|
resolved = { id: target.id, url: target.url, title: target.title };
|
|
3152
3199
|
}
|
|
@@ -3167,8 +3214,9 @@ async function getNetworkRequest(args = {}) {
|
|
|
3167
3214
|
return requests.find((r) => r.url.includes(args.url));
|
|
3168
3215
|
};
|
|
3169
3216
|
const describeSelector = () => args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
3217
|
+
assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
|
|
3170
3218
|
if (args.includeBody && args.url) {
|
|
3171
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3219
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3172
3220
|
try {
|
|
3173
3221
|
await cap.flush();
|
|
3174
3222
|
const match2 = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url));
|
|
@@ -3185,7 +3233,7 @@ async function getNetworkRequest(args = {}) {
|
|
|
3185
3233
|
await cap.stop();
|
|
3186
3234
|
}
|
|
3187
3235
|
}
|
|
3188
|
-
const target = await
|
|
3236
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3189
3237
|
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
3190
3238
|
if (!match) {
|
|
3191
3239
|
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|
|
@@ -5993,6 +6041,11 @@ async function withPage3(driver, target, fn) {
|
|
|
5993
6041
|
}
|
|
5994
6042
|
var consoleBuffers = new Map;
|
|
5995
6043
|
var networkBuffers = new Map;
|
|
6044
|
+
function refuseWorkerSelector(driver, target) {
|
|
6045
|
+
if (isWorkerSelector(target) && !driver.capabilities.has("worker.targets")) {
|
|
6046
|
+
throw new BidiToolError(WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
6047
|
+
}
|
|
6048
|
+
}
|
|
5996
6049
|
function bufferFor(map, id) {
|
|
5997
6050
|
let b = map.get(id);
|
|
5998
6051
|
if (!b) {
|
|
@@ -6021,6 +6074,7 @@ async function captureFresh(page, durationMs) {
|
|
|
6021
6074
|
}
|
|
6022
6075
|
}
|
|
6023
6076
|
async function listConsoleMessages2(driver, args = {}) {
|
|
6077
|
+
refuseWorkerSelector(driver, args.target);
|
|
6024
6078
|
return withPage3(driver, args.target, async (page) => {
|
|
6025
6079
|
if (args.reload)
|
|
6026
6080
|
await captureFresh(page, args.durationMs ?? 2500);
|
|
@@ -6029,6 +6083,7 @@ async function listConsoleMessages2(driver, args = {}) {
|
|
|
6029
6083
|
});
|
|
6030
6084
|
}
|
|
6031
6085
|
async function getConsoleMessage2(driver, args = {}) {
|
|
6086
|
+
refuseWorkerSelector(driver, args.target);
|
|
6032
6087
|
return withPage3(driver, args.target, async (page) => {
|
|
6033
6088
|
const buf = bufferFor(consoleBuffers, page.info.id);
|
|
6034
6089
|
const idx = args.index ?? 0;
|
|
@@ -6039,6 +6094,7 @@ async function getConsoleMessage2(driver, args = {}) {
|
|
|
6039
6094
|
});
|
|
6040
6095
|
}
|
|
6041
6096
|
async function listNetworkRequests2(driver, args = {}) {
|
|
6097
|
+
refuseWorkerSelector(driver, args.target);
|
|
6042
6098
|
return withPage3(driver, args.target, async (page) => {
|
|
6043
6099
|
if (args.reload)
|
|
6044
6100
|
await captureFresh(page, args.durationMs ?? 2500);
|
|
@@ -6049,6 +6105,7 @@ async function listNetworkRequests2(driver, args = {}) {
|
|
|
6049
6105
|
});
|
|
6050
6106
|
}
|
|
6051
6107
|
async function getNetworkRequest2(driver, args = {}) {
|
|
6108
|
+
refuseWorkerSelector(driver, args.target);
|
|
6052
6109
|
if (!args.requestId && !args.url)
|
|
6053
6110
|
throw new BidiToolError("get_network_request requires requestId or url");
|
|
6054
6111
|
return withPage3(driver, args.target, async (page) => {
|
|
@@ -6161,8 +6218,10 @@ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it
|
|
|
6161
6218
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
6162
6219
|
|
|
6163
6220
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
|
|
6164
|
-
plus worker:<substr>
|
|
6165
|
-
|
|
6221
|
+
plus worker:<substr> (Chrome only) for evaluate_script, list_network_requests, get_network_request
|
|
6222
|
+
and list_console_messages ONLY: an MV3 extension's background service worker, e.g.
|
|
6223
|
+
worker:<extension-id>. An idle-evicted worker is started first unless wake:false. Recording a
|
|
6224
|
+
worker keeps it alive for the length of the capture.
|
|
6166
6225
|
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
6167
6226
|
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
6168
6227
|
Run with --list to print every tool name available under the selected backend.
|
package/dist/index.js
CHANGED
|
@@ -404,6 +404,12 @@ async function resolveLiveLabel(backend, label, liveIds) {
|
|
|
404
404
|
|
|
405
405
|
// src/workers.ts
|
|
406
406
|
var WORKER_SELECTOR_PREFIX = "worker:";
|
|
407
|
+
var WORKER_CAPABLE_TOOLS = [
|
|
408
|
+
"evaluate_script",
|
|
409
|
+
"list_network_requests",
|
|
410
|
+
"get_network_request",
|
|
411
|
+
"list_console_messages"
|
|
412
|
+
];
|
|
407
413
|
var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
|
|
408
414
|
function isWorkerSelector(selector) {
|
|
409
415
|
return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
|
|
@@ -422,8 +428,18 @@ function resolveWorkerTargets(targets, needle) {
|
|
|
422
428
|
};
|
|
423
429
|
}
|
|
424
430
|
var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
|
|
425
|
-
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
|
|
426
|
-
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless).
|
|
431
|
+
var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
|
|
432
|
+
var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " + `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
|
|
433
|
+
function workerWakeMisuseMessage(kind) {
|
|
434
|
+
if (kind === "not-a-worker-selector") {
|
|
435
|
+
return "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " + "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " + "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running.";
|
|
436
|
+
}
|
|
437
|
+
return "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " + "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " + "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one.";
|
|
438
|
+
}
|
|
439
|
+
function workerBufferReadMissMessage(needle, liveWorkers) {
|
|
440
|
+
const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
|
|
441
|
+
return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " + "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " + "re-run with reload:true, which starts the worker and records a fresh capture window.";
|
|
442
|
+
}
|
|
427
443
|
function urlList(workers) {
|
|
428
444
|
return workers.map((w) => w.url).join(", ");
|
|
429
445
|
}
|
|
@@ -2838,6 +2854,31 @@ function captureFile(targetId, captureId) {
|
|
|
2838
2854
|
function delay2(ms) {
|
|
2839
2855
|
return new Promise((r) => setTimeout(r, ms));
|
|
2840
2856
|
}
|
|
2857
|
+
async function resolveRecorderTarget(target, opts) {
|
|
2858
|
+
if (!isWorkerSelector(target))
|
|
2859
|
+
return resolveTarget(target);
|
|
2860
|
+
if (opts.capture)
|
|
2861
|
+
return resolveWorkerSelector(target, { wake: opts.wake ?? true });
|
|
2862
|
+
const needle = workerNeedle(target);
|
|
2863
|
+
if (needle === "")
|
|
2864
|
+
throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
2865
|
+
const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
|
|
2866
|
+
if (matches.length > 1)
|
|
2867
|
+
throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
2868
|
+
if (matches.length === 1)
|
|
2869
|
+
return matches[0];
|
|
2870
|
+
throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
|
|
2871
|
+
}
|
|
2872
|
+
function assertWakeApplies(target, wake, capture) {
|
|
2873
|
+
if (wake === undefined)
|
|
2874
|
+
return;
|
|
2875
|
+
if (typeof wake !== "boolean")
|
|
2876
|
+
throw new CdpError("'wake' must be a boolean");
|
|
2877
|
+
if (!isWorkerSelector(target))
|
|
2878
|
+
throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
|
|
2879
|
+
if (!capture)
|
|
2880
|
+
throw new CdpError(workerWakeMisuseMessage("read-only-call"));
|
|
2881
|
+
}
|
|
2841
2882
|
var NETWORK_EVENTS = [
|
|
2842
2883
|
"Network.requestWillBeSent",
|
|
2843
2884
|
"Network.responseReceived",
|
|
@@ -2903,7 +2944,8 @@ async function startRecorder(target, opts = {}) {
|
|
|
2903
2944
|
};
|
|
2904
2945
|
}
|
|
2905
2946
|
async function captureWindow(target, durationMs, opts = {}) {
|
|
2906
|
-
const resolved = await
|
|
2947
|
+
const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
|
|
2948
|
+
const isWorker = isWorkerTargetType(resolved.type);
|
|
2907
2949
|
const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2908
2950
|
const file = captureFile(resolved.id, captureId);
|
|
2909
2951
|
const rec = await startRecorder(resolved.id, {
|
|
@@ -2913,13 +2955,16 @@ async function captureWindow(target, durationMs, opts = {}) {
|
|
|
2913
2955
|
file,
|
|
2914
2956
|
timeoutMs: opts.timeoutMs
|
|
2915
2957
|
});
|
|
2916
|
-
|
|
2917
|
-
|
|
2958
|
+
if (!isWorker) {
|
|
2959
|
+
await rec.conn.send("Page.enable");
|
|
2960
|
+
await rec.conn.send("Page.reload", { ignoreCache: false });
|
|
2961
|
+
}
|
|
2918
2962
|
await delay2(durationMs);
|
|
2919
2963
|
return {
|
|
2920
2964
|
file: rec.file,
|
|
2921
2965
|
conn: rec.conn,
|
|
2922
2966
|
resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
|
|
2967
|
+
mode: isWorker ? "listen" : "reload",
|
|
2923
2968
|
droppedWrites: rec.droppedWrites,
|
|
2924
2969
|
flush: () => rec.flush(),
|
|
2925
2970
|
async stop() {
|
|
@@ -3019,8 +3064,9 @@ async function readConsoleEntries(file) {
|
|
|
3019
3064
|
return out;
|
|
3020
3065
|
}
|
|
3021
3066
|
async function listConsoleMessages(args = {}) {
|
|
3067
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3022
3068
|
if (args.reload) {
|
|
3023
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
|
|
3069
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
|
|
3024
3070
|
await cap.stop();
|
|
3025
3071
|
const messages2 = await readConsoleEntries(cap.file);
|
|
3026
3072
|
return {
|
|
@@ -3030,7 +3076,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3030
3076
|
droppedWrites: cap.droppedWrites()
|
|
3031
3077
|
};
|
|
3032
3078
|
}
|
|
3033
|
-
const target = await
|
|
3079
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3034
3080
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3035
3081
|
return {
|
|
3036
3082
|
target: { id: target.id, url: target.url, title: target.title },
|
|
@@ -3039,7 +3085,7 @@ async function listConsoleMessages(args = {}) {
|
|
|
3039
3085
|
};
|
|
3040
3086
|
}
|
|
3041
3087
|
async function getConsoleMessage(args = {}) {
|
|
3042
|
-
const target = await
|
|
3088
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3043
3089
|
const messages = await readConsoleEntries(recFile(target.id));
|
|
3044
3090
|
const index = args.index ?? 0;
|
|
3045
3091
|
const entry = messages[index];
|
|
@@ -3137,14 +3183,15 @@ async function listNetworkRequests(args = {}) {
|
|
|
3137
3183
|
let file;
|
|
3138
3184
|
let resolved;
|
|
3139
3185
|
let droppedWrites;
|
|
3186
|
+
assertWakeApplies(args.target, args.wake, args.reload === true);
|
|
3140
3187
|
if (args.reload) {
|
|
3141
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3188
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3142
3189
|
await cap.stop();
|
|
3143
3190
|
file = cap.file;
|
|
3144
3191
|
resolved = cap.resolved;
|
|
3145
3192
|
droppedWrites = cap.droppedWrites();
|
|
3146
3193
|
} else {
|
|
3147
|
-
const target = await
|
|
3194
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3148
3195
|
file = recFile(target.id);
|
|
3149
3196
|
resolved = { id: target.id, url: target.url, title: target.title };
|
|
3150
3197
|
}
|
|
@@ -3165,8 +3212,9 @@ async function getNetworkRequest(args = {}) {
|
|
|
3165
3212
|
return requests.find((r) => r.url.includes(args.url));
|
|
3166
3213
|
};
|
|
3167
3214
|
const describeSelector = () => args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
|
|
3215
|
+
assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
|
|
3168
3216
|
if (args.includeBody && args.url) {
|
|
3169
|
-
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2);
|
|
3217
|
+
const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS2, { wake: args.wake });
|
|
3170
3218
|
try {
|
|
3171
3219
|
await cap.flush();
|
|
3172
3220
|
const match2 = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url));
|
|
@@ -3183,7 +3231,7 @@ async function getNetworkRequest(args = {}) {
|
|
|
3183
3231
|
await cap.stop();
|
|
3184
3232
|
}
|
|
3185
3233
|
}
|
|
3186
|
-
const target = await
|
|
3234
|
+
const target = await resolveRecorderTarget(args.target, { capture: false });
|
|
3187
3235
|
const match = pick(await readNetworkRequests(recFile(target.id)));
|
|
3188
3236
|
if (!match) {
|
|
3189
3237
|
throw new CdpError(`no network request matched ${describeSelector()}; run list_network_requests with reload:true first` + (args.includeBody ? `, then fetch the body with the url selector (reload re-mints requestIds)` : ``));
|