cdp-toolkit 1.7.0 → 1.9.0
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 +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
- package/src/types.ts +20 -0
- package/src/workers.ts +159 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome-only: resolving a "worker:<substring>" selector, including WAKING an
|
|
3
|
+
* idle-evicted MV3 service worker so there is something to resolve.
|
|
4
|
+
*
|
|
5
|
+
* EVERY PROTOCOL CHOICE BELOW WAS MEASURED against Chrome 151.0.7922.109 for
|
|
6
|
+
* 1.9.0, because the obvious ones are wrong:
|
|
7
|
+
*
|
|
8
|
+
* 1. `ServiceWorker.enable` and `ServiceWorker.startWorker` DO NOT EXIST on the
|
|
9
|
+
* browser endpoint — both answer `-32601 wasn't found` there. The domain is
|
|
10
|
+
* reachable only over a session attached to a PAGE target. Any page works;
|
|
11
|
+
* a plain `about:blank` tab hosted the wake in the probe, so no page on the
|
|
12
|
+
* extension's own origin is needed. That is why this opens a browser
|
|
13
|
+
* connection and then attaches a flattened session to some page, rather
|
|
14
|
+
* than talking to the browser endpoint directly.
|
|
15
|
+
*
|
|
16
|
+
* 2. A STOPPED worker is not a target. It is absent from `/json/list` and from
|
|
17
|
+
* `Target.getTargets` under every filter tried (including an explicit
|
|
18
|
+
* all-types filter, and after `Target.setDiscoverTargets`). The ONLY place a
|
|
19
|
+
* stopped worker is visible is the registration inventory that
|
|
20
|
+
* `ServiceWorker.enable` pushes as `workerRegistrationUpdated` /
|
|
21
|
+
* `workerVersionUpdated`. So matching a substring against a sleeping worker
|
|
22
|
+
* means matching against those events, not against a target list.
|
|
23
|
+
*
|
|
24
|
+
* 3. `ServiceWorker.startWorker` RETURNS EMPTY SUCCESS FOR A SCOPE THAT DOES
|
|
25
|
+
* NOT EXIST — a completely invented `chrome-extension://aaaa.../` scope
|
|
26
|
+
* answers `{}` exactly like a real one. Its result is therefore worthless as
|
|
27
|
+
* evidence, and nothing here reports a wake on the strength of it: the
|
|
28
|
+
* caller re-reads the target list and only a worker that actually shows up
|
|
29
|
+
* counts. This is the one dishonesty the feature could most easily ship.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE CONNECTION IS ALWAYS CLOSED IN A `finally`. Two reasons, and the
|
|
32
|
+
* second is not obvious: a leaked socket keeps the process alive (the standing
|
|
33
|
+
* harness rule), AND an attached CDP session KEEPS A SERVICE WORKER FROM BEING
|
|
34
|
+
* EVICTED. A connection left open here would suppress the very eviction this
|
|
35
|
+
* module exists to recover from, so the leak would hide itself.
|
|
36
|
+
*/
|
|
37
|
+
import { CdpError, listTargets, openBrowser } from "../client.ts";
|
|
38
|
+
import type { Target } from "../types.ts";
|
|
39
|
+
import {
|
|
40
|
+
resolveWorkerTargets,
|
|
41
|
+
workerAmbiguityMessage,
|
|
42
|
+
workerMissMessage,
|
|
43
|
+
workerNeedle,
|
|
44
|
+
WORKER_EMPTY_NEEDLE_MESSAGE,
|
|
45
|
+
} from "../workers.ts";
|
|
46
|
+
|
|
47
|
+
/** Bounded wait for the first inventory event after ServiceWorker.enable. A
|
|
48
|
+
* browser with no registrations at all never sends one, so this must time out
|
|
49
|
+
* rather than hang; the settle that follows collects the rest of the batch. */
|
|
50
|
+
const INVENTORY_FIRST_EVENT_MS = 1_500;
|
|
51
|
+
const INVENTORY_SETTLE_MS = 250;
|
|
52
|
+
/** Wake poll budget: 30 x 300ms = 9s. The probe saw a worker reappear in well
|
|
53
|
+
* under 2s; the headroom is for a cold worker whose top-level code does work. */
|
|
54
|
+
const WAKE_POLL_MS = 300;
|
|
55
|
+
const WAKE_POLL_ATTEMPTS = 30;
|
|
56
|
+
|
|
57
|
+
const NO_HOST_PAGE_MESSAGE =
|
|
58
|
+
"cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " +
|
|
59
|
+
"and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " +
|
|
60
|
+
"to belong to the extension.";
|
|
61
|
+
|
|
62
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
63
|
+
|
|
64
|
+
interface RegistrationEvent {
|
|
65
|
+
registrationId?: unknown;
|
|
66
|
+
scopeURL?: unknown;
|
|
67
|
+
isDeleted?: unknown;
|
|
68
|
+
}
|
|
69
|
+
interface VersionEvent {
|
|
70
|
+
registrationId?: unknown;
|
|
71
|
+
scriptURL?: unknown;
|
|
72
|
+
runningStatus?: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WorkerStartAttempt {
|
|
76
|
+
/** True only when a matching registration was found AND a start was issued.
|
|
77
|
+
* Says nothing about whether the worker actually came up: see fact 3. */
|
|
78
|
+
startRequested: boolean;
|
|
79
|
+
/** Every registered scope this browser reported, for the miss message. */
|
|
80
|
+
scopes: string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read the service-worker registration inventory and, when exactly one
|
|
85
|
+
* registration matches `needle`, ask Chrome to start it.
|
|
86
|
+
*
|
|
87
|
+
* Matching is against the version's scriptURL OR the registration's scopeURL,
|
|
88
|
+
* so both `worker:<extension-id>` (which appears in both) and
|
|
89
|
+
* `worker:background.js` (which appears only in the scriptURL) resolve.
|
|
90
|
+
*/
|
|
91
|
+
export async function requestWorkerStart(needle: string): Promise<WorkerStartAttempt> {
|
|
92
|
+
const conn = await openBrowser();
|
|
93
|
+
try {
|
|
94
|
+
const host = (await listTargets()).find((t) => t.type === "page");
|
|
95
|
+
if (!host) throw new CdpError(NO_HOST_PAGE_MESSAGE);
|
|
96
|
+
const { sessionId } = await conn.send<{ sessionId?: string }>("Target.attachToTarget", {
|
|
97
|
+
targetId: host.id,
|
|
98
|
+
flatten: true,
|
|
99
|
+
});
|
|
100
|
+
if (!sessionId) throw new CdpError("could not attach a session to host the ServiceWorker domain");
|
|
101
|
+
|
|
102
|
+
const scopeById = new Map<string, string>();
|
|
103
|
+
const versions: Array<{ registrationId: string; scriptURL: string }> = [];
|
|
104
|
+
conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
|
|
105
|
+
for (const r of (params.registrations as RegistrationEvent[] | undefined) ?? []) {
|
|
106
|
+
if (r.isDeleted === true) continue;
|
|
107
|
+
scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
conn.on("ServiceWorker.workerVersionUpdated", (params) => {
|
|
111
|
+
for (const v of (params.versions as VersionEvent[] | undefined) ?? []) {
|
|
112
|
+
versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await conn.send("ServiceWorker.enable", {}, { sessionId });
|
|
117
|
+
// The inventory arrives as events AFTER the enable response, so waiting on
|
|
118
|
+
// the response alone would read an empty map every time.
|
|
119
|
+
await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => undefined);
|
|
120
|
+
await sleep(INVENTORY_SETTLE_MS);
|
|
121
|
+
|
|
122
|
+
const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
|
|
123
|
+
const matched = new Set<string>();
|
|
124
|
+
for (const v of versions) {
|
|
125
|
+
const scope = scopeById.get(v.registrationId);
|
|
126
|
+
if (scope === undefined) continue;
|
|
127
|
+
if (v.scriptURL.includes(needle) || scope.includes(needle)) matched.add(scope);
|
|
128
|
+
}
|
|
129
|
+
if (matched.size === 0) return { startRequested: false, scopes };
|
|
130
|
+
if (matched.size > 1) {
|
|
131
|
+
throw new CdpError(
|
|
132
|
+
`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` +
|
|
133
|
+
"Narrow the substring.",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0]! }, { sessionId });
|
|
137
|
+
return { startRequested: true, scopes };
|
|
138
|
+
} finally {
|
|
139
|
+
// See the header: a held session also suppresses eviction.
|
|
140
|
+
conn.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve a `worker:<substring>` selector to a live worker target, waking an
|
|
146
|
+
* evicted worker first when `wake` is true (the default at the tool surface).
|
|
147
|
+
*
|
|
148
|
+
* The wake path is: match running targets -> nothing -> ask for a start ->
|
|
149
|
+
* RE-READ THE TARGET LIST until the worker appears or the budget runs out.
|
|
150
|
+
* The re-read is the only evidence accepted that a wake happened.
|
|
151
|
+
*/
|
|
152
|
+
export async function resolveWorkerSelector(selector: string, opts: { wake: boolean }): Promise<Target> {
|
|
153
|
+
const needle = workerNeedle(selector);
|
|
154
|
+
if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
155
|
+
|
|
156
|
+
const first = resolveWorkerTargets(await listTargets(), needle);
|
|
157
|
+
if (first.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, first.matches));
|
|
158
|
+
if (first.matches.length === 1) return first.matches[0]!;
|
|
159
|
+
|
|
160
|
+
if (!opts.wake) {
|
|
161
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { startRequested, scopes } = await requestWorkerStart(needle);
|
|
165
|
+
if (startRequested) {
|
|
166
|
+
for (let i = 0; i < WAKE_POLL_ATTEMPTS; i++) {
|
|
167
|
+
await sleep(WAKE_POLL_MS);
|
|
168
|
+
const again = resolveWorkerTargets(await listTargets(), needle);
|
|
169
|
+
if (again.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, again.matches));
|
|
170
|
+
if (again.matches.length === 1) return again.matches[0]!;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const final = resolveWorkerTargets(await listTargets(), needle);
|
|
174
|
+
throw new CdpError(
|
|
175
|
+
workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }),
|
|
176
|
+
);
|
|
177
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -48,7 +48,9 @@ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else
|
|
|
48
48
|
zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
|
|
49
49
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
50
50
|
|
|
51
|
-
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
51
|
+
Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
|
|
52
|
+
plus worker:<substr> for evaluate_script ONLY (Chrome only): an MV3 extension's background service
|
|
53
|
+
worker, e.g. worker:<extension-id>. An idle-evicted worker is started first unless wake:false.
|
|
52
54
|
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
53
55
|
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
54
56
|
Run with --list to print every tool name available under the selected backend.
|
package/src/client.ts
CHANGED
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { CdpResponse, Target, TargetSelector } from "./types.ts";
|
|
7
7
|
import { assertLeaseOk } from "./leases.ts";
|
|
8
|
+
import { resolveLiveLabel } from "./origins.ts";
|
|
9
|
+
import {
|
|
10
|
+
isWorkerSelector,
|
|
11
|
+
isWorkerTargetType,
|
|
12
|
+
resolveWorkerTargets,
|
|
13
|
+
workerAmbiguityMessage,
|
|
14
|
+
workerMissMessage,
|
|
15
|
+
workerNeedle,
|
|
16
|
+
WORKER_EMPTY_NEEDLE_MESSAGE,
|
|
17
|
+
} from "./workers.ts";
|
|
8
18
|
|
|
9
19
|
/** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
|
|
10
20
|
export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -197,7 +207,19 @@ export async function browserWsUrl(): Promise<string> {
|
|
|
197
207
|
* the ambient lease scope instead (see leases.ts). */
|
|
198
208
|
export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
|
|
199
209
|
const targets = await listTargets();
|
|
200
|
-
const hit = pickTarget(targets, selector);
|
|
210
|
+
const hit = await pickTarget(targets, selector);
|
|
211
|
+
// WORKER TARGETS BYPASS THE LEASE GATE ENTIRELY, and that is a decision, not
|
|
212
|
+
// an omission. A lease answers "who is driving this TAB", and every
|
|
213
|
+
// consequence of holding one is a tab consequence: reap CLOSES an abandoned
|
|
214
|
+
// leased tab, and "close the stale tab" is meaningless for a service worker
|
|
215
|
+
// that Chrome starts and stops on its own schedule (see ReapInput.livePageIds,
|
|
216
|
+
// which is deliberately fed the page-only listing for exactly this reason).
|
|
217
|
+
// Letting strict mode auto-acquire here would mint a lease file per worker
|
|
218
|
+
// evaluate, keyed on an id that vanishes the moment the worker idles out —
|
|
219
|
+
// garbage that the page-only reap set can never collect. It also cannot be
|
|
220
|
+
// used to sneak past a lease: a worker is not a tab, so there is no tab
|
|
221
|
+
// someone else holds that this could reach.
|
|
222
|
+
if (isWorkerTargetType(hit.type)) return hit;
|
|
201
223
|
// THE Chrome choke point. Every tool reaches a page through here, so a tool
|
|
202
224
|
// added after this shipped is protected with no action from its author.
|
|
203
225
|
//
|
|
@@ -211,7 +233,7 @@ export async function resolveTarget(selector: TargetSelector, opts: { lease?: st
|
|
|
211
233
|
return hit;
|
|
212
234
|
}
|
|
213
235
|
|
|
214
|
-
function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
236
|
+
async function pickTarget(targets: Target[], selector: TargetSelector): Promise<Target> {
|
|
215
237
|
const pages = targets.filter((t) => t.type === "page");
|
|
216
238
|
if (!selector || selector === "active") {
|
|
217
239
|
if (!pages.length) throw new CdpError("no page targets open");
|
|
@@ -234,6 +256,34 @@ function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
|
234
256
|
if (!hit) throw new CdpError(`no page title containing '${needle}'`);
|
|
235
257
|
return hit;
|
|
236
258
|
}
|
|
259
|
+
if (selector.startsWith("label:")) {
|
|
260
|
+
const label = selector.slice(6);
|
|
261
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
|
|
262
|
+
if (matchIds.length > 1) {
|
|
263
|
+
throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
264
|
+
}
|
|
265
|
+
if (matchIds.length === 1) {
|
|
266
|
+
const hit = pages.find((t) => t.id === matchIds[0]);
|
|
267
|
+
if (hit) return hit;
|
|
268
|
+
}
|
|
269
|
+
throw new CdpError(
|
|
270
|
+
knownLabels.length
|
|
271
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
272
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (isWorkerSelector(selector)) {
|
|
276
|
+
// Resolved against the UNFILTERED listing, never `pages`: a worker is not a
|
|
277
|
+
// page target and would not be in it. Waking an evicted worker is NOT done
|
|
278
|
+
// here — this stays a pure pick over what the browser already reports, and
|
|
279
|
+
// the wake path lives in cdp/workers.ts, reached only from evaluate_script.
|
|
280
|
+
const needle = workerNeedle(selector);
|
|
281
|
+
if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
|
|
282
|
+
const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
|
|
283
|
+
if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
|
|
284
|
+
if (matches.length === 1) return matches[0]!;
|
|
285
|
+
throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
|
|
286
|
+
}
|
|
237
287
|
// bare id
|
|
238
288
|
const byId = targets.find((t) => t.id === selector);
|
|
239
289
|
if (!byId) throw new CdpError(`no target with id '${selector}'`);
|
package/src/driver.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ADR-001: one browser-neutral Driver interface behind the
|
|
2
|
+
* ADR-001: one browser-neutral Driver interface behind the 45 tools.
|
|
3
3
|
*
|
|
4
4
|
* Context. Every tool in src/tools/ is a stateless one-shot on withPage():
|
|
5
5
|
* open a socket, act, always close. That is only correct because a CDP `Uid`
|
|
@@ -138,7 +138,12 @@ export type Capability =
|
|
|
138
138
|
| "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
|
|
139
139
|
| "input.insertTextAtomic" // atomic value commit, not synthesized keystrokes
|
|
140
140
|
| "locate.text" // find a node by visible text substring
|
|
141
|
-
| "locate.xpath"
|
|
141
|
+
| "locate.xpath" // find a node by xpath
|
|
142
|
+
| "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
|
|
143
|
+
| "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
|
|
144
|
+
| "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
|
|
145
|
+
| "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
|
|
146
|
+
| "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
|
|
142
147
|
|
|
143
148
|
/**
|
|
144
149
|
* Tools whose availability depends on a capability. A tool absent from this
|
|
@@ -157,6 +162,9 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
|
|
|
157
162
|
mock_request: ["network.intercept"],
|
|
158
163
|
list_mocks: ["network.intercept"],
|
|
159
164
|
clear_mocks: ["network.intercept"],
|
|
165
|
+
dispatch_mouse: ["input.raw"],
|
|
166
|
+
wait_for_download: ["browser.downloads"],
|
|
167
|
+
grant_permissions: ["browser.permissions"],
|
|
160
168
|
} as const;
|
|
161
169
|
|
|
162
170
|
/* --------------------------------- errors --------------------------------- */
|
|
@@ -213,6 +221,19 @@ export interface NavigateOptions {
|
|
|
213
221
|
url?: string;
|
|
214
222
|
reload?: boolean;
|
|
215
223
|
ignoreCache?: boolean;
|
|
224
|
+
/**
|
|
225
|
+
* Traverse the tab's session history instead of loading a URL: the browser Back / Forward
|
|
226
|
+
* buttons. Mutually exclusive with `url` and `reload` (the tool layer enforces that; a driver
|
|
227
|
+
* trusts its caller). Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry. Firefox:
|
|
228
|
+
* browsingContext.traverseHistory{delta:-1|+1}. Universal — both drivers implement it, so this is
|
|
229
|
+
* deliberately NOT a Capability.
|
|
230
|
+
*
|
|
231
|
+
* NO SILENT NO-OP. Asking to go back from the first history entry (or forward from the last) is a
|
|
232
|
+
* caller mistake, not a legal outcome: both drivers MUST throw an error naming the direction
|
|
233
|
+
* rather than return a success that navigated nowhere, because a no-op success reads as "you are
|
|
234
|
+
* on the previous page" and the caller acts on that.
|
|
235
|
+
*/
|
|
236
|
+
history?: "back" | "forward";
|
|
216
237
|
waitUntil?: "load" | "domcontentloaded";
|
|
217
238
|
timeoutMs?: number;
|
|
218
239
|
}
|
|
@@ -222,12 +243,93 @@ export interface NavigateResult {
|
|
|
222
243
|
/** CDP frameId or BiDi context id. Opaque. */
|
|
223
244
|
contextId: string;
|
|
224
245
|
reloaded?: boolean;
|
|
246
|
+
/** Which direction a `history` traversal actually moved. Present only for a history navigation,
|
|
247
|
+
* the same way `reloaded` is present only for a reload. */
|
|
248
|
+
traversed?: "back" | "forward";
|
|
225
249
|
waitedFor: "load" | "domcontentloaded" | "committed" | "timeout";
|
|
226
250
|
}
|
|
227
251
|
|
|
228
252
|
export interface MouseButtonOptions {
|
|
229
253
|
button?: "left" | "right" | "middle";
|
|
230
254
|
clickCount?: number;
|
|
255
|
+
/** "Alt" | "Control" | "Meta" | "Shift", held for the press/release. CDP bits: Alt=1,
|
|
256
|
+
* Control=2, Meta=4, Shift=8 (cdp/driver.ts's inputModifierBits). A non-empty array is
|
|
257
|
+
* "unsupported" on the Firefox/BiDi driver today: see click()'s implementation note. */
|
|
258
|
+
modifiers?: readonly string[];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Where a drag ends. 1.8.0 Track P2 widened this past "an element": an absolute viewport point
|
|
263
|
+
* ({x,y}) and an offset from the source point ({dx,dy}) are both real destinations for widgets
|
|
264
|
+
* that have no droppable element to name — a range slider's track, a map canvas, a resize gutter.
|
|
265
|
+
* The three branches are discriminated structurally ("dx" in / "x" in), the same way
|
|
266
|
+
* PageDriver.scroll's anchor already discriminates an ElementLocator from a viewport point; no
|
|
267
|
+
* ElementLocator variant carries an `x` or `dx` field, so the check is exact and not a heuristic.
|
|
268
|
+
*/
|
|
269
|
+
export type DragDestination = ElementLocator | { x: number; y: number } | { dx: number; dy: number };
|
|
270
|
+
|
|
271
|
+
export interface DragOptions {
|
|
272
|
+
/**
|
|
273
|
+
* "mouse" (default): synthetic mousePressed/mouseMoved…/mouseReleased. Fine for widgets built on
|
|
274
|
+
* raw pointer events, and it is what `drag` has always done.
|
|
275
|
+
*
|
|
276
|
+
* "html5": a REAL HTML5 drag — Input.setInterceptDrags(true), the same press/move sequence, then
|
|
277
|
+
* the intercepted DragData replayed as Input.dispatchDragEvent dragEnter/dragOver/drop at the
|
|
278
|
+
* destination.
|
|
279
|
+
*
|
|
280
|
+
* WHAT THE DIFFERENCE ACTUALLY IS (measured on Chrome 151.0.7922.109, macOS, both --headless=new
|
|
281
|
+
* and headed — NOT assumed). Synthetic mouse events DO start a real drag in modern Chrome, so
|
|
282
|
+
* mouse mode is not inert on HTML5 drop zones the way the folklore says. What mouse mode does
|
|
283
|
+
* not control is WHICH drag events the page receives: that follows the interpolated pointer
|
|
284
|
+
* path, and at the default steps:2 a source-to-zone drag delivered dragstart and drop but ZERO
|
|
285
|
+
* dragover. A drop zone written the standard way — preventDefault inside dragover, which is how
|
|
286
|
+
* the HTML spec says you mark a valid drop target — therefore refused the drop outright, while
|
|
287
|
+
* steps:1 and steps:8 on the very same page happened to work. html5 mode removes the luck: the
|
|
288
|
+
* dragEnter/dragOver/drop triple is dispatched explicitly at the destination every time.
|
|
289
|
+
*
|
|
290
|
+
* Requires Capability "input.html5Drag" — Chrome only. A driver without that capability MUST
|
|
291
|
+
* throw "unsupported"; it must never silently downgrade to mouse mode, because a caller who
|
|
292
|
+
* asked for html5 asked precisely because mouse mode was not delivering the drop.
|
|
293
|
+
*/
|
|
294
|
+
mode?: "mouse" | "html5";
|
|
295
|
+
/** Interpolated mouseMoved events dispatched between the source and destination points, evenly
|
|
296
|
+
* spaced, the last one landing exactly on the destination. Default 2 (the pre-1.8.0 hardcoded
|
|
297
|
+
* behavior: midpoint, then destination). DnD libraries with a movement threshold or per-frame
|
|
298
|
+
* sampling need more. The tool layer validates the range; a driver trusts its caller. */
|
|
299
|
+
steps?: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The `steps` evenly-spaced points between `from` (exclusive) and `to` (inclusive), for a drag's
|
|
304
|
+
* interpolated move sequence. The last point is EXACTLY `to`, emitted as `to` itself rather than
|
|
305
|
+
* computed, so float error can never leave the pointer a fraction of a pixel short of the
|
|
306
|
+
* destination element. steps:1 is a single jump to the destination; steps:2 is
|
|
307
|
+
* midpoint-then-destination, the pre-1.8.0 hardcoded behavior.
|
|
308
|
+
*
|
|
309
|
+
* Lives here, in the backend-neutral module, because BOTH drivers need the identical geometry:
|
|
310
|
+
* the CDP driver turns each point into an Input.dispatchMouseEvent{mouseMoved} and the BiDi driver
|
|
311
|
+
* into a performActions pointerMove, but "where the pointer goes" must not differ by backend.
|
|
312
|
+
* Callers pass an already-validated integer >= 1 (the tool layer validates; a driver trusts it).
|
|
313
|
+
*/
|
|
314
|
+
export function interpolatePoints(
|
|
315
|
+
from: { x: number; y: number },
|
|
316
|
+
to: { x: number; y: number },
|
|
317
|
+
steps: number,
|
|
318
|
+
): Array<{ x: number; y: number }> {
|
|
319
|
+
const out: Array<{ x: number; y: number }> = [];
|
|
320
|
+
for (let i = 1; i < steps; i++) {
|
|
321
|
+
out.push({ x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps });
|
|
322
|
+
}
|
|
323
|
+
out.push({ x: to.x, y: to.y });
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export interface ScrollOptions {
|
|
328
|
+
/** Positive scrolls DOWN, positive scrolls RIGHT: wheel-event convention. The tool layer
|
|
329
|
+
* (shared-tools.ts) requires at least one of deltaX/deltaY; a driver trusts its caller and
|
|
330
|
+
* does not re-validate. */
|
|
331
|
+
deltaX?: number;
|
|
332
|
+
deltaY?: number;
|
|
231
333
|
}
|
|
232
334
|
|
|
233
335
|
export interface KeyPress {
|
|
@@ -414,6 +516,79 @@ export interface BrowserDriver {
|
|
|
414
516
|
/** Acquire a page handle. The caller MUST release() it. Prefer withDriver. */
|
|
415
517
|
page(selector: TargetSelector): Promise<PageDriver>;
|
|
416
518
|
|
|
519
|
+
/**
|
|
520
|
+
* Install the activity beacon into a target and, where the backend can, into
|
|
521
|
+
* every document it navigates to afterwards. Resolves true when the beacon is
|
|
522
|
+
* in place. See src/activity.ts.
|
|
523
|
+
*
|
|
524
|
+
* OPTIONAL, per ADR-001's "absent rather than present-and-throwing" rule, but
|
|
525
|
+
* NOT a `Capability`: that union gates whole tools out of tools/list, and no
|
|
526
|
+
* tool is gated here. The beacon is an additive FIELD on claim_page and
|
|
527
|
+
* list_leases, both of which work unchanged without it, so the honest
|
|
528
|
+
* expression of a backend that cannot do this is an absent method producing an
|
|
529
|
+
* absent field — not a missing tool and not a throw.
|
|
530
|
+
*/
|
|
531
|
+
installActivityBeacon?(targetId: string): Promise<boolean>;
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Read the beacon's timestamp for a target, or null when the target has no
|
|
535
|
+
* beacon. WITHOUT THE LEASE GATE, which is required rather than convenient:
|
|
536
|
+
* claim_page{target} must read this BEFORE claiming, and under
|
|
537
|
+
* CDP_REQUIRE_LEASE a gated read would auto-acquire the lease and then collide
|
|
538
|
+
* with the explicit claim behind it (the same reason pickPage is gate-free;
|
|
539
|
+
* see leases-tools.ts's header).
|
|
540
|
+
*
|
|
541
|
+
* This is not a general gate bypass and must never become one: it returns one
|
|
542
|
+
* number and takes no expression, so it cannot be made to read or change
|
|
543
|
+
* anything else about the page.
|
|
544
|
+
*/
|
|
545
|
+
readActivityBeacon?(targetId: string): Promise<number | null>;
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Bounded liveness probe behind list_pages{probe:true}: run ONE Runtime.evaluate
|
|
549
|
+
* (or backend equivalent) capped at `timeoutMs`, and report whether it
|
|
550
|
+
* answered in time plus whatever the activity beacon said in that same round
|
|
551
|
+
* trip. See src/activity.ts's RENDERER_PROBE_EXPRESSION for exactly what is
|
|
552
|
+
* evaluated and why.
|
|
553
|
+
*
|
|
554
|
+
* MUST NEVER REJECT. A timeout, a thrown page-side exception, a dead
|
|
555
|
+
* connection, or a target that no longer exists are all
|
|
556
|
+
* `{responsive:false, beaconTs:null}` — indistinguishable from each other and
|
|
557
|
+
* from a genuinely wedged renderer, which is the honest answer: none of them
|
|
558
|
+
* let the caller drive that tab any faster than this call already told it.
|
|
559
|
+
*
|
|
560
|
+
* OPTIONAL and NOT a `Capability`, for the same reason installActivityBeacon
|
|
561
|
+
* and readActivityBeacon are not: no whole tool is gated, only an additive
|
|
562
|
+
* field on list_pages, so the honest expression of a backend that cannot do
|
|
563
|
+
* this is an absent method producing an absent field.
|
|
564
|
+
*/
|
|
565
|
+
probeRenderer?(targetId: string, timeoutMs?: number): Promise<{ responsive: boolean; beaconTs: number | null }>;
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Resolve a "worker:<substring>" selector to a concrete service/shared-worker
|
|
569
|
+
* target, STARTING an idle-evicted MV3 service worker first when `wake` is
|
|
570
|
+
* true. The returned PageInfo's id is a plain target id, so the caller then
|
|
571
|
+
* acquires it through the ordinary page() path.
|
|
572
|
+
*
|
|
573
|
+
* OPTIONAL **and** gated by Capability "worker.targets", unlike
|
|
574
|
+
* installActivityBeacon / readActivityBeacon / probeRenderer, which are
|
|
575
|
+
* optional but deliberately NOT capabilities. The difference is what a caller
|
|
576
|
+
* can observe: those three produce an absent FIELD on a result that is
|
|
577
|
+
* otherwise complete, so silence is the honest answer. This one decides
|
|
578
|
+
* whether an argument the caller explicitly wrote is honoured, and silently
|
|
579
|
+
* resolving `worker:...` to something else — or to nothing — would be a wrong
|
|
580
|
+
* answer rather than a missing annotation. So it refuses out loud, exactly as
|
|
581
|
+
* drag's mode:'html5' does behind "input.html5Drag" (see resolveDragMode).
|
|
582
|
+
* No tool is gated out of tools/list: evaluate_script is universal, only this
|
|
583
|
+
* one arm of its `target` grammar is Chrome-only.
|
|
584
|
+
*
|
|
585
|
+
* MUST NOT report a wake it cannot see: an implementation is required to
|
|
586
|
+
* confirm the worker is back in the target listing rather than trusting the
|
|
587
|
+
* start command's own result. See cdp/workers.ts for why that is not
|
|
588
|
+
* paranoia.
|
|
589
|
+
*/
|
|
590
|
+
resolveWorkerTarget?(selector: string, opts: { wake: boolean }): Promise<PageInfo>;
|
|
591
|
+
|
|
417
592
|
/** Tear down the transport: process exit (CLI) or shutdown (MCP server).
|
|
418
593
|
* Idempotent. MUST NOT be called by a tool. */
|
|
419
594
|
dispose(): Promise<void>;
|
|
@@ -446,7 +621,16 @@ export interface PageDriver {
|
|
|
446
621
|
/* input */
|
|
447
622
|
click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }>;
|
|
448
623
|
hover(loc: ElementLocator): Promise<{ x: number; y: number }>;
|
|
449
|
-
|
|
624
|
+
/** Press at `from`, move to `to`, release. `to` is an element, an absolute viewport point, or an
|
|
625
|
+
* offset from the resolved source point (DragDestination). opts.mode picks synthetic-mouse vs.
|
|
626
|
+
* real HTML5 drag events; opts.steps sets how many interpolated moves are dispatched. */
|
|
627
|
+
drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }>;
|
|
628
|
+
/** Dispatch a wheel/scroll event at an anchor point. `anchor` is an ElementLocator (scrolled
|
|
629
|
+
* into view first, exactly like click/hover), an absolute viewport point, or `undefined` for
|
|
630
|
+
* the viewport center. Chrome: Input.dispatchMouseEvent{type:"mouseWheel"}. Firefox: BiDi's
|
|
631
|
+
* "wheel" input source via input.performActions. Universal: both drivers must implement it
|
|
632
|
+
* (absent from driver.ts's REQUIRED_CAPABILITIES, unlike dispatch_mouse). */
|
|
633
|
+
scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }>;
|
|
450
634
|
/** Overwrite an element's value. One atomic commit under
|
|
451
635
|
* "input.insertTextAtomic", else synthesized keystrokes, so per-key handlers
|
|
452
636
|
* on the page WILL fire. Callers must not depend on either behavior. */
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Two exports:
|
|
5
5
|
* 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
|
|
6
6
|
* (snake_case string, all 29 of them) plus the toolkit's own superset
|
|
7
|
-
* tools,
|
|
7
|
+
* tools, 45 entries in all, to its raw-CDP implementation function. The
|
|
8
8
|
* CLI and any embedder dispatch through this single table.
|
|
9
9
|
* 2. The client primitives, re-exported so consumers can build their own
|
|
10
10
|
* flows on the same connection/timeout machinery the tools use.
|
|
@@ -41,6 +41,11 @@ import { takeHeapsnapshot } from "./tools/heap.ts";
|
|
|
41
41
|
import { lighthouseAudit } from "./tools/lighthouse.ts";
|
|
42
42
|
// --- network mocking (Fetch domain) : toolkit addition beyond the 29 parity tools ---
|
|
43
43
|
import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
|
|
44
|
+
// --- raw mouse dispatch (Input.dispatchMouseEvent, one event per call) : chrome-only, capability "input.raw" ---
|
|
45
|
+
import { dispatchMouse } from "./tools/dispatch-mouse.ts";
|
|
46
|
+
// --- downloads + permissions (browser-endpoint state on a standing connection) : chrome-only, 1.8.0 Track P3 ---
|
|
47
|
+
import { waitForDownload } from "./tools/downloads.ts";
|
|
48
|
+
import { grantPermissions } from "./tools/permissions.ts";
|
|
44
49
|
|
|
45
50
|
/** A toolkit tool: a single typed-args function returning JSON-serializable data. */
|
|
46
51
|
export type ToolFn = (args: never) => Promise<unknown>;
|
|
@@ -67,8 +72,16 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
|
|
|
67
72
|
* opt-in tab ownership so many agents can share one browser), plus a 2-tool
|
|
68
73
|
* screen-recording pair (start_screen_recording/stop_screen_recording : a tab
|
|
69
74
|
* captured to an H.265 MP4 with per-frame durations, so a variable-rate
|
|
70
|
-
* repaint stream plays back at wallclock speed)
|
|
71
|
-
*
|
|
75
|
+
* repaint stream plays back at wallclock speed), plus a 2-tool input-parity
|
|
76
|
+
* addition (1.8.0 Track P1: `scroll`, a wheel-event dispatch at an anchor
|
|
77
|
+
* point sharing click/hover's element-locator and lease-gate contract; and
|
|
78
|
+
* `dispatch_mouse`, the raw move/down/up mouse-event primitive composable
|
|
79
|
+
* into anything a physical mouse can do, chrome-only per capability "input.raw"), plus a 2-tool
|
|
80
|
+
* browser-state addition (1.8.0 Track P3: `wait_for_download`, which captures a file download to a
|
|
81
|
+
* named path, and `grant_permissions`, which answers permission prompts for an origin; both
|
|
82
|
+
* chrome-only and both driven from a standing browser-endpoint connection, see
|
|
83
|
+
* tools/browser-session.ts).
|
|
84
|
+
* 29 + 1 + 3 + 3 + 3 + 2 + 2 + 2 = 45 entries total. Listed explicitly so the mapping is auditable at a glance and
|
|
72
85
|
* the CLI can `--list` it.
|
|
73
86
|
*
|
|
74
87
|
* 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
|
|
@@ -93,10 +106,11 @@ export const TOOLS = {
|
|
|
93
106
|
delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
|
|
94
107
|
// snapshot (1)
|
|
95
108
|
take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
|
|
96
|
-
// interaction (8
|
|
109
|
+
// interaction (9) : scroll is 1.8.0 Track P1, a toolkit addition beyond the 29 parity tools
|
|
97
110
|
click: onCdp(SHARED_TOOLS.click),
|
|
98
111
|
hover: onCdp(SHARED_TOOLS.hover),
|
|
99
112
|
drag: onCdp(SHARED_TOOLS.drag),
|
|
113
|
+
scroll: onCdp(SHARED_TOOLS.scroll),
|
|
100
114
|
fill: onCdp(SHARED_TOOLS.fill),
|
|
101
115
|
fill_form: onCdp(SHARED_TOOLS.fill_form),
|
|
102
116
|
type_text: onCdp(SHARED_TOOLS.type_text),
|
|
@@ -129,6 +143,14 @@ export const TOOLS = {
|
|
|
129
143
|
mock_request: mockRequest,
|
|
130
144
|
list_mocks: listMocks,
|
|
131
145
|
clear_mocks: clearMocks,
|
|
146
|
+
// raw mouse dispatch (1) : 1.8.0 Track P1 toolkit addition, chrome-only (capability "input.raw")
|
|
147
|
+
dispatch_mouse: dispatchMouse,
|
|
148
|
+
// downloads + permissions (2) : 1.8.0 Track P3 toolkit additions, chrome-only (capabilities
|
|
149
|
+
// "browser.downloads" / "browser.permissions"). Both drive browser-endpoint state that Chrome
|
|
150
|
+
// reverts when the issuing client disconnects, so both run on tools/browser-session.ts's standing
|
|
151
|
+
// connection rather than a per-call socket : see that module's header for the measurements.
|
|
152
|
+
wait_for_download: waitForDownload,
|
|
153
|
+
grant_permissions: grantPermissions,
|
|
132
154
|
// tab leases (3) : opt-in ownership so many agents can share one browser
|
|
133
155
|
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
134
156
|
release_page: onCdp(LEASE_TOOLS.release_page),
|