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.
@@ -12,11 +12,18 @@
12
12
  * unique per-capture file), Page.reload, capture for `durationMs` (default
13
13
  * 2500ms), stop, then read+return the console entries from that capture. The
14
14
  * both-domains capture means a network reload never wipes console history.
15
+ *
16
+ * SERVICE WORKERS (1.9.1): list_console_messages accepts `worker:<substring>`
17
+ * and a bare worker target id, Chrome only (Capability "worker.targets"). A
18
+ * worker's console.log arrives as Runtime.consoleAPICalled on its own session
19
+ * (measured; `Log.entryAdded` never fires there). There is no reload to drive a
20
+ * worker capture, so reload:true means "listen for durationMs" — see
21
+ * recorder.ts's header.
15
22
  */
16
23
  import { readFile } from "node:fs/promises";
17
- import { CdpError, resolveTarget } from "../client.ts";
24
+ import { CdpError } from "../client.ts";
18
25
  import type { TargetSelector } from "../types.ts";
19
- import { recFile, captureWindow } from "./recorder.ts";
26
+ import { assertWakeApplies, recFile, captureWindow, resolveRecorderTarget } from "./recorder.ts";
20
27
  import type { RecLine } from "./recorder.ts";
21
28
 
22
29
  const DEFAULT_CAPTURE_MS = 2500;
@@ -44,6 +51,8 @@ export interface ListConsoleMessagesArgs {
44
51
  reload?: boolean;
45
52
  /** Capture window for reload mode (ms). Default 2500. */
46
53
  durationMs?: number;
54
+ /** Worker targets only, and only with reload:true — start an idle-evicted MV3 worker first (default true). */
55
+ wake?: boolean;
47
56
  }
48
57
 
49
58
  export interface GetConsoleMessageArgs {
@@ -144,8 +153,9 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
144
153
  messages: ConsoleEntry[];
145
154
  droppedWrites?: number;
146
155
  }> {
156
+ assertWakeApplies(args.target, args.wake, args.reload === true);
147
157
  if (args.reload) {
148
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
158
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
149
159
  await cap.stop();
150
160
  const messages = await readConsoleEntries(cap.file);
151
161
  return {
@@ -156,7 +166,7 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
156
166
  };
157
167
  }
158
168
 
159
- const target = await resolveTarget(args.target);
169
+ const target = await resolveRecorderTarget(args.target, { capture: false });
160
170
  const messages = await readConsoleEntries(recFile(target.id));
161
171
  return {
162
172
  target: { id: target.id, url: target.url, title: target.title },
@@ -170,7 +180,14 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
170
180
  * existing buffer for the target. Throws if the index is out of range.
171
181
  */
172
182
  export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
173
- const target = await resolveTarget(args.target);
183
+ // Reads the SAME buffer list_console_messages just returned, so it resolves
184
+ // through the same read-path resolver: a worker target already reached this
185
+ // code through the 1.9.0 bare-id/`worker:` arm, and routing it here is what
186
+ // makes the miss message match the buffer it is actually reading. It is
187
+ // deliberately NOT advertised as worker-capable in the manifest (see
188
+ // WORKER_CAPABLE_TOOLS): index-into-a-buffer is not the worker story, and the
189
+ // drift test keeps the advertised list to the four tools that are.
190
+ const target = await resolveRecorderTarget(args.target, { capture: false });
174
191
  const messages = await readConsoleEntries(recFile(target.id));
175
192
  const index = args.index ?? 0;
176
193
  const entry = messages[index];
@@ -184,8 +201,8 @@ export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promi
184
201
 
185
202
  /*
186
203
  * CDP methods / domains used (via recorder.ts + this module):
187
- * Page.enable (reload mode)
188
- * Page.reload (reload mode)
204
+ * Page.enable (reload mode, PAGE targets only)
205
+ * Page.reload (reload mode, PAGE targets only)
189
206
  * Runtime.enable / Runtime.consoleAPICalled / Runtime.exceptionThrown (recorder)
190
207
  * Log.enable / Log.entryAdded (recorder)
191
208
  *
@@ -16,11 +16,19 @@
16
16
  * and calls Network.getResponseBody before closing. Because a reload re-mints
17
17
  * requestIds, body fetch is matched by `url` (stable), not by a carried-over
18
18
  * requestId; see getNetworkRequest for the exact contract.
19
+ *
20
+ * SERVICE WORKERS (1.9.1): both tools accept `worker:<substring>` and a bare
21
+ * worker target id, Chrome only (Capability "worker.targets"). The capture is
22
+ * the same recorder, over the worker's own connection; what differs is that
23
+ * there is no reload to drive it (see recorder.ts's header for the measurements)
24
+ * — the window listens while the worker works, so the traffic has to be
25
+ * triggered during it. `wake` starts an idle-evicted worker first, and is
26
+ * accepted only where it can actually do something.
19
27
  */
20
28
  import { readFile } from "node:fs/promises";
21
- import { CdpError, resolveTarget } from "../client.ts";
29
+ import { CdpError } from "../client.ts";
22
30
  import type { TargetSelector } from "../types.ts";
23
- import { recFile, captureWindow } from "./recorder.ts";
31
+ import { assertWakeApplies, recFile, captureWindow, resolveRecorderTarget } from "./recorder.ts";
24
32
  import type { RecLine } from "./recorder.ts";
25
33
 
26
34
  const DEFAULT_CAPTURE_MS = 2500;
@@ -55,6 +63,8 @@ export interface ListNetworkRequestsArgs {
55
63
  durationMs?: number;
56
64
  /** Only return requests whose URL contains this substring. */
57
65
  filterUrl?: string;
66
+ /** Worker targets only, and only with reload:true — start an idle-evicted MV3 worker first (default true). */
67
+ wake?: boolean;
58
68
  }
59
69
 
60
70
  export interface GetNetworkRequestArgs {
@@ -66,6 +76,8 @@ export interface GetNetworkRequestArgs {
66
76
  /** Fetch the response body too (drives a fresh reload capture; use with `url`). */
67
77
  includeBody?: boolean;
68
78
  durationMs?: number;
79
+ /** Worker targets only, and only with includeBody+url — start an idle-evicted MV3 worker first (default true). */
80
+ wake?: boolean;
69
81
  }
70
82
 
71
83
  /** Coerce a CDP headers object to a string-keyed/string-valued record. */
@@ -166,14 +178,15 @@ export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): P
166
178
  let resolved: { id: string; url: string; title: string };
167
179
  let droppedWrites: number | undefined;
168
180
 
181
+ assertWakeApplies(args.target, args.wake, args.reload === true);
169
182
  if (args.reload) {
170
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
183
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
171
184
  await cap.stop();
172
185
  file = cap.file;
173
186
  resolved = cap.resolved;
174
187
  droppedWrites = cap.droppedWrites();
175
188
  } else {
176
- const target = await resolveTarget(args.target);
189
+ const target = await resolveRecorderTarget(args.target, { capture: false });
177
190
  file = recFile(target.id);
178
191
  resolved = { id: target.id, url: target.url, title: target.title };
179
192
  }
@@ -214,9 +227,10 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
214
227
  const describeSelector = (): string =>
215
228
  args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
216
229
 
230
+ assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
217
231
  // Body fetch by URL: a fresh reload capture (url is stable across reload).
218
232
  if (args.includeBody && args.url) {
219
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
233
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
220
234
  try {
221
235
  await cap.flush(); // ensure the buffer is complete; conn stays open for the body fetch
222
236
  const match = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url!));
@@ -239,7 +253,7 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
239
253
  }
240
254
 
241
255
  // Metadata path: read the target's existing buffer (also the requestId+includeBody case).
242
- const target = await resolveTarget(args.target);
256
+ const target = await resolveRecorderTarget(args.target, { capture: false });
243
257
  const match = pick(await readNetworkRequests(recFile(target.id)));
244
258
  if (!match) {
245
259
  throw new CdpError(
@@ -264,7 +278,7 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
264
278
  * Network.requestWillBeSent / .responseReceived / .loadingFinished /
265
279
  * .loadingFailed (events) (recorder)
266
280
  * Network.getResponseBody (includeBody mode)
267
- * Page.enable / Page.reload (reload mode)
281
+ * Page.enable / Page.reload (reload mode, PAGE targets only)
268
282
  *
269
283
  * Parity gaps vs chrome-devtools-mcp:
270
284
  * - Response bodies require a fresh reload capture (CDP serves bodies only from
@@ -38,11 +38,47 @@
38
38
  * reload-driven via `captureWindow`. `startRecorder` resolves only AFTER the CDP
39
39
  * domains are enabled, so a caller can trigger navigation/reload immediately and
40
40
  * be sure events are caught.
41
+ *
42
+ * SERVICE WORKERS (1.9.1)
43
+ * -----------------------
44
+ * A `worker:<substring>` (or bare worker-id) target records the same two domains
45
+ * over the worker's OWN connection, which is the whole point of the feature: the
46
+ * outbound fetch an MV3 background worker makes is a Network event on that
47
+ * session and nothing else can see it reliably. Measured on Chrome 151.0.7922.109
48
+ * before any of this was written:
49
+ *
50
+ * - `Network.enable` succeeds on a direct worker connection and
51
+ * requestWillBeSent / responseReceived / loadingFinished all arrive for a
52
+ * fetch the worker performs; `Network.getResponseBody` serves its body.
53
+ * - `Runtime.consoleAPICalled` delivers the worker's console.log on the same
54
+ * session. (`Log.enable` also succeeds but emits nothing there.)
55
+ * - `Page.enable` and `Page.reload` DO NOT EXIST on a worker session: both
56
+ * answer `-32601 ... wasn't found`. So a worker capture window cannot be
57
+ * reload-driven, and this module skips both instead of catching a failure —
58
+ * the window simply LISTENS for durationMs while the worker runs.
59
+ * - A held session SUPPRESSES MV3 idle-eviction (measured: alive and still
60
+ * emitting Network events after 60s held; evicted ~30s after the detach). A
61
+ * recording therefore keeps the worker alive for exactly as long as it runs,
62
+ * which is a documented side effect of these tools, not an accident.
63
+ * - An evicted worker that is woken has ALREADY run its top-level code by the
64
+ * time the recorder attaches, so a request the worker makes at startup can be
65
+ * missed. Trigger the work you want to observe during the window.
41
66
  */
42
67
  import { mkdir, appendFile, writeFile, copyFile } from "node:fs/promises";
43
- import { openPage, resolveTarget } from "../client.ts";
68
+ import { CdpError, listTargets, openPage, resolveTarget } from "../client.ts";
44
69
  import type { CdpConnection } from "../client.ts";
70
+ import { resolveWorkerSelector } from "../cdp/workers.ts";
45
71
  import type { Target, TargetSelector } from "../types.ts";
72
+ import {
73
+ isWorkerSelector,
74
+ isWorkerTargetType,
75
+ resolveWorkerTargets,
76
+ workerAmbiguityMessage,
77
+ workerBufferReadMissMessage,
78
+ workerNeedle,
79
+ workerWakeMisuseMessage,
80
+ WORKER_EMPTY_NEEDLE_MESSAGE,
81
+ } from "../workers.ts";
46
82
 
47
83
  /** Artifact / buffer directory (shared with the rest of the toolkit). */
48
84
  export const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
@@ -62,6 +98,52 @@ function delay(ms: number): Promise<void> {
62
98
  return new Promise((r) => setTimeout(r, ms));
63
99
  }
64
100
 
101
+ /* ------------------------- worker targets (1.9.1) ------------------------- */
102
+
103
+ /**
104
+ * Resolve the target for a console/network call, which is the ONE place the
105
+ * three reader tools differ from every other tool: a capture may start a
106
+ * stopped worker, a read never may.
107
+ *
108
+ * `capture: true` — a recording is about to be made, so a `worker:` selector
109
+ * goes through the waking resolver (Chrome-only, `cdp/workers.ts`), default
110
+ * wake:true, exactly like evaluate_script.
111
+ * `capture: false` — an existing buffer is about to be read, so the same
112
+ * selector resolves against the RUNNING workers only, and a miss gets the
113
+ * read-path message rather than a promise that waking would help (it would
114
+ * not: the buffer is keyed by a target id the restart re-mints).
115
+ *
116
+ * Non-worker selectors are untouched: they go through client.ts's resolveTarget,
117
+ * lease gate and all.
118
+ */
119
+ export async function resolveRecorderTarget(
120
+ target: TargetSelector,
121
+ opts: { capture: boolean; wake?: boolean },
122
+ ): Promise<Target> {
123
+ if (!isWorkerSelector(target)) return resolveTarget(target);
124
+ if (opts.capture) return resolveWorkerSelector(target, { wake: opts.wake ?? true });
125
+
126
+ const needle = workerNeedle(target);
127
+ if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
128
+ const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
129
+ if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
130
+ if (matches.length === 1) return matches[0]!;
131
+ throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
132
+ }
133
+
134
+ /**
135
+ * Validate `wake` for the three reader tools. It is REFUSED where it would do
136
+ * nothing rather than accepted and ignored — the evaluate_script precedent: an
137
+ * accepted argument reads as "this call will wake something", and a caller who
138
+ * is wrong about that never finds out.
139
+ */
140
+ export function assertWakeApplies(target: TargetSelector, wake: boolean | undefined, capture: boolean): void {
141
+ if (wake === undefined) return;
142
+ if (typeof wake !== "boolean") throw new CdpError("'wake' must be a boolean");
143
+ if (!isWorkerSelector(target)) throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
144
+ if (!capture) throw new CdpError(workerWakeMisuseMessage("read-only-call"));
145
+ }
146
+
65
147
  /** Network CDP events we persist. */
66
148
  const NETWORK_EVENTS = [
67
149
  "Network.requestWillBeSent",
@@ -186,11 +268,16 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
186
268
  }
187
269
 
188
270
  /**
189
- * One-shot reload capture used by the console/network tools. Records BOTH
190
- * domains into a unique per-capture file, reloads the page, waits `durationMs`,
191
- * and (on stop) publishes the capture as the target's shared "latest" buffer.
192
- * The returned handle keeps the connection open until `stop()`, so a caller can
193
- * `flush()` then fetch response bodies before closing.
271
+ * One-shot capture used by the console/network tools. Records BOTH domains into
272
+ * a unique per-capture file, waits `durationMs`, and (on stop) publishes the
273
+ * capture as the target's shared "latest" buffer. The returned handle keeps the
274
+ * connection open until `stop()`, so a caller can `flush()` then fetch response
275
+ * bodies before closing.
276
+ *
277
+ * A PAGE window reloads to generate the traffic. A WORKER window does not (and
278
+ * cannot: `Page.*` is absent on a worker session) — it listens while the worker
279
+ * runs, and the session it holds keeps that worker from being idle-evicted for
280
+ * the length of the window.
194
281
  */
195
282
  export interface CaptureWindow {
196
283
  /** The unique per-capture buffer file (read this for isolated results). */
@@ -199,6 +286,8 @@ export interface CaptureWindow {
199
286
  conn: CdpConnection;
200
287
  /** Resolved target identity. */
201
288
  resolved: { id: string; url: string; title: string };
289
+ /** How the window was driven: "reload" for a page, "listen" for a worker. */
290
+ mode: "reload" | "listen";
202
291
  /** Buffer-write failure count. */
203
292
  droppedWrites(): number;
204
293
  /** Await in-flight appends without closing (bodies stay fetchable). */
@@ -210,9 +299,15 @@ export interface CaptureWindow {
210
299
  export async function captureWindow(
211
300
  target: TargetSelector,
212
301
  durationMs: number,
213
- opts: { timeoutMs?: number } = {},
302
+ opts: { timeoutMs?: number; wake?: boolean } = {},
214
303
  ): Promise<CaptureWindow> {
215
- const resolved = await resolveTarget(target);
304
+ const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
305
+ // A WORKER window is "listen", not "reload": Page.enable/Page.reload answer
306
+ // -32601 on a worker session (measured), and there is nothing to reload
307
+ // anyway — the caller triggers the worker's work during the window. Keyed off
308
+ // the RESOLVED TYPE rather than the selector shape, so a bare worker target id
309
+ // takes the same branch as `worker:<substring>`.
310
+ const isWorker = isWorkerTargetType(resolved.type);
216
311
  const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
217
312
  const file = captureFile(resolved.id, captureId);
218
313
  // Record both domains so neither tool clobbers the other's history.
@@ -223,13 +318,16 @@ export async function captureWindow(
223
318
  file,
224
319
  timeoutMs: opts.timeoutMs,
225
320
  });
226
- await rec.conn.send("Page.enable");
227
- await rec.conn.send("Page.reload", { ignoreCache: false });
321
+ if (!isWorker) {
322
+ await rec.conn.send("Page.enable");
323
+ await rec.conn.send("Page.reload", { ignoreCache: false });
324
+ }
228
325
  await delay(durationMs);
229
326
  return {
230
327
  file: rec.file,
231
328
  conn: rec.conn,
232
329
  resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
330
+ mode: isWorker ? "listen" : "reload",
233
331
  droppedWrites: rec.droppedWrites,
234
332
  flush: () => rec.flush(),
235
333
  async stop(): Promise<void> {
@@ -257,6 +355,8 @@ export async function captureWindow(
257
355
  * Runtime.exceptionThrown (event)
258
356
  * Log.enable
259
357
  * Log.entryAdded (event)
358
+ * Page.enable / Page.reload (PAGE capture windows only: absent on a worker)
359
+ * ServiceWorker.enable / .startWorker (via cdp/workers.ts, worker wake only)
260
360
  *
261
361
  * Parity gaps vs chrome-devtools-mcp:
262
362
  * - Buffering is to a per-target JSONL file on disk, not an in-memory ring; the
package/src/types.ts CHANGED
@@ -55,12 +55,14 @@ export interface CdpEvent<P = Record<string, unknown>> {
55
55
  * URL contains the substring (an MV3 extension's background worker is
56
56
  * `chrome-extension://<id>/<script>`, so both the extension id and the
57
57
  * script name match). NOT UNIVERSAL, unlike every arm above it: resolved
58
- * only by Chrome's pickTarget (this file's resolveTarget), accepted only by
59
- * evaluate_script, refused with a capability error on Firefox and by the
60
- * page-only resolvers. It reads the UNFILTERED target listing, because a
61
- * worker is not a page target — and a worker that is idle-evicted is in no
62
- * listing at all, which is what evaluate_script's `wake` exists for. See
63
- * src/workers.ts and src/cdp/workers.ts.
58
+ * only by Chrome's pickTarget (client.ts's resolveTarget), accepted by the
59
+ * four tools in WORKER_CAPABLE_TOOLS (evaluate_script, which evaluates in a
60
+ * worker, plus list_network_requests / get_network_request /
61
+ * list_console_messages, which record one), refused with a capability error
62
+ * on Firefox and by the page-only resolvers. It reads the UNFILTERED target
63
+ * listing, because a worker is not a page target — and a worker that is
64
+ * idle-evicted is in no listing at all, which is what `wake` exists for.
65
+ * See src/workers.ts and src/cdp/workers.ts.
64
66
  */
65
67
  export type TargetSelector = string | undefined;
66
68
 
package/src/workers.ts CHANGED
@@ -36,6 +36,25 @@
36
36
  /** Selector prefix owned by this arm. */
37
37
  export const WORKER_SELECTOR_PREFIX = "worker:";
38
38
 
39
+ /**
40
+ * THE ALLOWLIST. Every tool that accepts a `worker:<substring>` target, and the
41
+ * single source of truth for it: the refusal messages below are built from this
42
+ * list, and test/manifest-grammar-drift.test.ts asserts that no OTHER tool's
43
+ * manifest description advertises the arm. Adding a fifth tool is therefore one
44
+ * deliberate edit here, not a description that quietly grew the grammar.
45
+ *
46
+ * 1.9.0 shipped one entry (evaluate_script). 1.9.1 adds the three console/network
47
+ * readers, because the request an MV3 background worker makes to a real backend
48
+ * is exactly what a caller needs to see and `Runtime.evaluate`-based
49
+ * interception cannot reliably show them (see the tools' descriptions).
50
+ */
51
+ export const WORKER_CAPABLE_TOOLS = [
52
+ "evaluate_script",
53
+ "list_network_requests",
54
+ "get_network_request",
55
+ "list_console_messages",
56
+ ] as const;
57
+
39
58
  /**
40
59
  * CDP target types this arm resolves. `service_worker` is the MV3 case that
41
60
  * motivated the feature; `shared_worker` is included because it presents the
@@ -94,21 +113,64 @@ export const WORKER_EMPTY_NEEDLE_MESSAGE =
94
113
 
95
114
  /**
96
115
  * The Firefox refusal. A PARAM-level gap, exactly like drag's mode:'html5'
97
- * (see resolveDragMode): evaluate_script stays in tools/list on Firefox because
98
- * every page selector still works there, and only this one arm is refused.
99
- * Naming the reason matters — WebDriver BiDi has no extension-worker target
100
- * concept to map onto, so this is a protocol gap, not an unimplemented to-do.
116
+ * (see resolveDragMode): the worker-capable tools stay in tools/list on Firefox
117
+ * because every page selector still works there, and only this one arm is
118
+ * refused. Naming the reason matters — WebDriver BiDi has no extension-worker
119
+ * target concept to map onto, so this is a protocol gap, not an unimplemented
120
+ * to-do.
101
121
  */
102
122
  export const WORKER_SELECTOR_UNSUPPORTED_MESSAGE =
103
123
  "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " +
104
124
  "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " +
105
- "Use --browser chrome to evaluate in an extension's background worker.";
125
+ "Use --browser chrome to evaluate in, or record console/network from, an extension's background worker.";
106
126
 
107
127
  /** Refusal from the page-only resolvers (close_page / select_page / release_page). */
108
128
  export const WORKER_SELECTOR_PAGE_ONLY_MESSAGE =
109
129
  "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " +
110
- "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " +
111
- "accepts a worker: target.";
130
+ "(closing, selecting or releasing a worker is meaningless). The tools that accept a worker: target are " +
131
+ `${WORKER_CAPABLE_TOOLS.join(", ")}.`;
132
+
133
+ /**
134
+ * WHY `wake` IS REFUSED RATHER THAN IGNORED ON THE READ PATH, and why a bare id
135
+ * can never be woken. Measured for 1.9.1 on Chrome 151.0.7922.109: a worker that
136
+ * idle-evicts and is then restarted comes back under a DIFFERENT target id
137
+ * (probed: C43F0A18... -> 1F7D3C59... for the same extension). The recorder keys
138
+ * its buffer file by target id, so waking a worker on a read gives you a live
139
+ * worker whose buffer is empty — "0 requests" for a capture that really did
140
+ * record some, which is a lie shaped like an answer. A wake therefore only makes
141
+ * sense when a capture is being STARTED, and only from a `worker:<substring>`
142
+ * selector, since the id of an evicted worker no longer exists to name.
143
+ */
144
+ export function workerWakeMisuseMessage(kind: "not-a-worker-selector" | "read-only-call"): string {
145
+ if (kind === "not-a-worker-selector") {
146
+ return (
147
+ "'wake' only applies to a target of the form 'worker:<substring>'. A page is never asleep, and a bare " +
148
+ "worker target id cannot be woken either: Chrome destroys the target when an MV3 worker is evicted and " +
149
+ "mints a NEW id when it restarts, so only the url-substring form can name a worker that is not running."
150
+ );
151
+ }
152
+ return (
153
+ "'wake' only applies when a capture is being started (reload:true, or includeBody with url). Reading an " +
154
+ "existing buffer cannot use a woken worker: a restarted MV3 worker gets a NEW target id, and the buffer is " +
155
+ "keyed by id, so the wake would hand back an empty capture. Re-run with reload:true to record a fresh one."
156
+ );
157
+ }
158
+
159
+ /**
160
+ * The read-path miss. Deliberately NOT workerMissMessage's wake:false text,
161
+ * which promises that "wake:true (the default)" would fix it — true for
162
+ * evaluate_script and for a capture, false here (see workerWakeMisuseMessage).
163
+ */
164
+ export function workerBufferReadMissMessage(needle: string, liveWorkers: readonly WorkerTargetLike[]): string {
165
+ const running = liveWorkers.length ? ` (running workers: ${urlList(liveWorkers)})` : " (no workers are running)";
166
+ return (
167
+ `no running worker whose url contains '${needle}'${running}. ` +
168
+ "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " +
169
+ "listing, so this is usually a sleeping worker rather than a wrong substring. This call only READS a buffer " +
170
+ "recorded earlier, and that buffer is keyed by the worker's target id, which Chrome re-mints on restart — so " +
171
+ "re-run with reload:true, which starts the worker and records a fresh capture window."
172
+ );
173
+ }
112
174
 
113
175
  function urlList(workers: readonly WorkerTargetLike[]): string {
114
176
  return workers.map((w) => w.url).join(", ");