cdp-toolkit 1.8.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 +237 -0
- package/CONTRACT.md +5 -1
- package/README.md +41 -8
- package/dist/cli.js +407 -121
- package/dist/index.js +377 -120
- package/dist/mcp.js +464 -166
- package/package.json +3 -2
- package/src/bidi/driver.ts +28 -0
- package/src/bidi-tools.ts +21 -0
- package/src/cdp/driver.ts +16 -1
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +5 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +27 -1
- package/src/leases-tools.ts +2 -2
- package/src/manifest.ts +61 -45
- package/src/mcp.ts +1 -1
- package/src/origins.ts +41 -1
- package/src/shared-tools.ts +104 -3
- 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 +22 -0
- package/src/workers.ts +221 -0
package/src/shared-tools.ts
CHANGED
|
@@ -44,7 +44,8 @@ import {
|
|
|
44
44
|
type LeaseBackend, type LeaseStaleReason, type LeaseToken,
|
|
45
45
|
} from "./leases.ts";
|
|
46
46
|
import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
|
|
47
|
-
import { newTrackedPage, originIndex, type PageOrigin } from "./origins.ts";
|
|
47
|
+
import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
|
|
48
|
+
import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
|
|
48
49
|
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
49
50
|
|
|
50
51
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -132,6 +133,21 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
132
133
|
if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
|
|
133
134
|
return p;
|
|
134
135
|
}
|
|
136
|
+
if (selector.startsWith("label:")) return pickByLabel(driver, pages, selector.slice(6));
|
|
137
|
+
if (isWorkerSelector(selector)) {
|
|
138
|
+
// THE PAGE-ONLY CONTRACT, STATED OUT LOUD. Every caller of pickPage
|
|
139
|
+
// (resolvePage -> close_page / select_page / release_page, claim_page's
|
|
140
|
+
// takeover) acts on a TAB, and the 1.9.0 "worker:" arm is deliberately NOT
|
|
141
|
+
// implemented here: it resolves against the page-only listing these callers
|
|
142
|
+
// pass, which by construction never contains a worker, so the arm could
|
|
143
|
+
// only ever miss. Refusing with the reason beats falling through to the
|
|
144
|
+
// bare-id lookup, whose "no target with id 'worker:foo'" reads as a typo.
|
|
145
|
+
// The arm lives in client.ts's pickTarget (Chrome's all-targets resolver),
|
|
146
|
+
// and only evaluate_script routes to it.
|
|
147
|
+
throw new SharedToolError(
|
|
148
|
+
backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
135
151
|
const exactPage = pages.find((x) => x.id === selector);
|
|
136
152
|
if (exactPage) return exactPage;
|
|
137
153
|
const all = await driver.listPages({ all: true });
|
|
@@ -140,6 +156,35 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
140
156
|
throw new SharedToolError(`no target with id '${selector}'`);
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
/**
|
|
160
|
+
* "label:<name>" resolution for pickPage. The lookup itself (both stores,
|
|
161
|
+
* live-scoped) is shared with client.ts's pickTarget and bidi/driver.ts's
|
|
162
|
+
* pickContext via origins.ts's resolveLiveLabel, so the three copies of the
|
|
163
|
+
* selector grammar cannot drift on what counts as a hit, a miss or an
|
|
164
|
+
* ambiguity. Only the error wording is local, matching this file's
|
|
165
|
+
* SharedToolError idiom.
|
|
166
|
+
*
|
|
167
|
+
* Nothing is reaped here: this is a read, not a maintenance pass, and pickPage
|
|
168
|
+
* stays gate-free and side-effect-free over its inputs.
|
|
169
|
+
*/
|
|
170
|
+
async function pickByLabel(driver: BrowserDriver, pages: PageInfo[], label: string): Promise<PageInfo> {
|
|
171
|
+
const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
|
|
172
|
+
if (matchIds.length > 1) {
|
|
173
|
+
throw new SharedToolError(
|
|
174
|
+
`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (matchIds.length === 1) {
|
|
178
|
+
const p = pages.find((x) => x.id === matchIds[0]);
|
|
179
|
+
if (p) return p;
|
|
180
|
+
}
|
|
181
|
+
throw new SharedToolError(
|
|
182
|
+
knownLabels.length
|
|
183
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
184
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
143
188
|
/* -------------------------------- element locators -------------------------------- */
|
|
144
189
|
|
|
145
190
|
function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
|
|
@@ -476,14 +521,70 @@ function sinkRequested(savePath?: string): savePath is string {
|
|
|
476
521
|
return savePath !== undefined && savePath !== "";
|
|
477
522
|
}
|
|
478
523
|
|
|
524
|
+
/** Keys a caller reaches for out of habit from other automation APIs, none of
|
|
525
|
+
* which this tool reads: the code always goes in 'expression'. Checked in
|
|
526
|
+
* this order so the error names one wrong key, deterministically, when a
|
|
527
|
+
* caller happens to pass more than one. */
|
|
528
|
+
const EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"] as const;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Turn a `worker:<substring>` target into a concrete target id, waking an
|
|
532
|
+
* idle-evicted MV3 service worker on the way when `wake` is not false.
|
|
533
|
+
*
|
|
534
|
+
* WHY THIS RETURNS AN ID RATHER THAN DRIVING THE WORKER ITSELF. Once resolved,
|
|
535
|
+
* a worker is just a target with its own webSocketDebuggerUrl, and a DIRECT
|
|
536
|
+
* connection to it evaluates exactly like a page does — `Runtime.evaluate` with
|
|
537
|
+
* `awaitPromise` returns real awaited values there (measured on Chrome 151;
|
|
538
|
+
* `chrome.storage.local.get(null)` comes back as an object, not undefined). So
|
|
539
|
+
* the worker rides the ordinary page() acquisition path from here, and every
|
|
540
|
+
* behavior around it — the args/function form, exception reporting, and above
|
|
541
|
+
* all savePath's no-value-in-the-response secret hygiene — is the SAME CODE,
|
|
542
|
+
* not a parallel copy that could quietly diverge on the leak-shaped edge.
|
|
543
|
+
*
|
|
544
|
+
* Exported for the unit tests, which need to reach the capability refusal
|
|
545
|
+
* without a browser.
|
|
546
|
+
*/
|
|
547
|
+
export async function resolveWorkerSelectorFor(
|
|
548
|
+
driver: BrowserDriver,
|
|
549
|
+
selector: string,
|
|
550
|
+
wake: boolean,
|
|
551
|
+
): Promise<PageInfo> {
|
|
552
|
+
if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
|
|
553
|
+
throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
|
|
554
|
+
}
|
|
555
|
+
return driver.resolveWorkerTarget(selector, { wake });
|
|
556
|
+
}
|
|
557
|
+
|
|
479
558
|
export async function evaluateScript(
|
|
480
559
|
driver: BrowserDriver,
|
|
481
|
-
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string },
|
|
560
|
+
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string; wake?: boolean },
|
|
482
561
|
): Promise<unknown> {
|
|
483
562
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
563
|
+
const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => (args as Record<string, unknown>)[k] !== undefined);
|
|
564
|
+
if (wrongKey !== undefined) {
|
|
565
|
+
throw new SharedToolError(
|
|
566
|
+
`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
484
569
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
485
570
|
}
|
|
486
|
-
|
|
571
|
+
if (args.wake !== undefined && typeof args.wake !== "boolean") {
|
|
572
|
+
throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
|
|
573
|
+
}
|
|
574
|
+
// `wake` is REFUSED on a page selector rather than ignored. Accepting it there
|
|
575
|
+
// would read as "this call will wake something", and the caller would never
|
|
576
|
+
// learn that the argument did nothing at all.
|
|
577
|
+
if (args.wake !== undefined && !isWorkerSelector(args.target)) {
|
|
578
|
+
throw new SharedToolError(
|
|
579
|
+
"evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
// Resolved to a bare target id BEFORE acquisition, so the page() call below is
|
|
583
|
+
// the same one every other selector makes.
|
|
584
|
+
const target = isWorkerSelector(args.target)
|
|
585
|
+
? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id
|
|
586
|
+
: args.target;
|
|
587
|
+
return withPage(driver, target, async (page) => {
|
|
487
588
|
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
488
589
|
// No savePath: byte-identical to the pre-sink behavior, the value itself.
|
|
489
590
|
// A thrown page-side exception never reaches here, so it still surfaces as
|
package/src/tools/console.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
*
|
package/src/tools/network.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
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
|
@@ -41,6 +41,28 @@ export interface CdpEvent<P = Record<string, unknown>> {
|
|
|
41
41
|
* - "index:N" -> Nth page-type target (0-based)
|
|
42
42
|
* - "url:<substring>" -> first page whose url contains substring
|
|
43
43
|
* - "title:<substring>" -> first page whose title contains substring
|
|
44
|
+
* - "label:<name>" -> the page-type target whose label EXACTLY
|
|
45
|
+
* matches `name`, checked against both the origin ledger (agent-created
|
|
46
|
+
* tabs, see src/origins.ts) and live lease records (covers a taken-over
|
|
47
|
+
* tab claimed with a label, which has no origin record — see
|
|
48
|
+
* src/leases-tools.ts's claimPage). The resolved id must be present in
|
|
49
|
+
* the listing this call is resolving against; a label whose tab is gone
|
|
50
|
+
* falls through to the normal no-match error. A miss enumerates the
|
|
51
|
+
* labels that DO exist; two live targets sharing a label is an ambiguity
|
|
52
|
+
* error naming both ids, never a silent first-match. See pickPage in
|
|
53
|
+
* src/shared-tools.ts.
|
|
54
|
+
* - "worker:<substring>" -> a service_worker/shared_worker target whose
|
|
55
|
+
* URL contains the substring (an MV3 extension's background worker is
|
|
56
|
+
* `chrome-extension://<id>/<script>`, so both the extension id and the
|
|
57
|
+
* script name match). NOT UNIVERSAL, unlike every arm above it: resolved
|
|
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.
|
|
44
66
|
*/
|
|
45
67
|
export type TargetSelector = string | undefined;
|
|
46
68
|
|