cdp-toolkit 1.9.0 → 1.9.4
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 +507 -0
- package/CONTRACT.md +62 -5
- package/README.md +74 -10
- package/dist/cli.js +2940 -138
- package/dist/index.js +994 -90
- package/dist/mcp.js +1221 -137
- package/package.json +2 -1
- package/src/backend.ts +156 -21
- package/src/bidi/client.ts +48 -10
- package/src/bidi/driver.ts +228 -33
- package/src/bidi/launch.ts +23 -13
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +594 -9
- package/src/cli.ts +111 -12
- package/src/driver.ts +254 -3
- package/src/manifest.ts +42 -14
- package/src/mcp.ts +21 -12
- package/src/png.ts +809 -0
- package/src/shared-tools.ts +313 -11
- 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/src/tools/recorder.ts
CHANGED
|
@@ -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
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
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 (
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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):
|
|
98
|
-
* every page selector still works there, and only this one arm is
|
|
99
|
-
* Naming the reason matters — WebDriver BiDi has no extension-worker
|
|
100
|
-
* concept to map onto, so this is a protocol gap, not an unimplemented
|
|
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).
|
|
111
|
-
"
|
|
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(", ");
|