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 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`, `evaluate_script`'s alone) 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.
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:` (no tool other than `evaluate_script` may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
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`. The one tool that also accepts `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) to evaluate inside an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
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 reload capture (CDP serves bodies from the live session); `includeBody` matches by **url**. |
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. `evaluate_script` is the one tool that also accepts `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) — any substring of the worker's url, so `worker:<extension-id>` and `worker:background.js` both work:
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 evaluate; a worker id is Chrome's to create and destroy, not this toolkit's to fence.
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). evaluate_script is the one tool that " + "accepts a worker: target.";
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 resolveTarget(target);
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
- await rec.conn.send("Page.enable");
2919
- await rec.conn.send("Page.reload", { ignoreCache: false });
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
6165
- worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
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). evaluate_script is the one tool that " + "accepts a worker: target.";
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 resolveTarget(target);
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
- await rec.conn.send("Page.enable");
2917
- await rec.conn.send("Page.reload", { ignoreCache: false });
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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 resolveTarget(args.target);
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)` : ``));