cdp-toolkit 1.0.0 → 1.2.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 +76 -9
- package/CONTRACT.md +7 -5
- package/README.md +99 -28
- package/dist/cli.js +3248 -1033
- package/dist/index.js +1418 -898
- package/dist/mcp.js +3447 -1064
- package/package.json +12 -6
- package/src/backend.ts +86 -0
- package/src/bidi/client.ts +319 -0
- package/src/bidi/driver.ts +851 -0
- package/src/bidi/launch.ts +192 -0
- package/src/bidi/protocol.ts +782 -0
- package/src/bidi/snapshot.ts +419 -0
- package/src/bidi-tools.ts +193 -0
- package/src/capabilities.ts +43 -0
- package/src/cdp/driver.ts +660 -0
- package/src/cli.ts +97 -14
- package/src/client.ts +25 -7
- package/src/driver.ts +424 -0
- package/src/firefox-tools.ts +17 -0
- package/src/index.ts +55 -42
- package/src/leases-tools.ts +101 -0
- package/src/leases.ts +533 -0
- package/src/manifest.ts +211 -13
- package/src/mcp.ts +73 -15
- package/src/shared-tools.ts +466 -0
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
- package/src/tools/dialogs.ts +0 -236
- package/src/tools/emulation.ts +0 -212
- package/src/tools/input.ts +0 -399
- package/src/tools/pages.ts +0 -159
- package/src/tools/screenshot.ts +0 -180
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE implementation per tool, built only on the browser-neutral Driver contract in driver.ts
|
|
3
|
+
* (ADR-001), used by BOTH Chrome (via cdp/driver.ts's createCdpDriver()) and Firefox (via
|
|
4
|
+
* bidi/driver.ts's createFirefoxDriver()). This file replaces the split that used to exist
|
|
5
|
+
* between src/tools/*.ts (raw CDP, Chrome only) and src/neutral.ts (Driver-based, Firefox only)
|
|
6
|
+
* for the 20 tools below.
|
|
7
|
+
*
|
|
8
|
+
* THE HARD REQUIREMENT this file exists to satisfy: Chrome's return shape, timing, and quirks
|
|
9
|
+
* stay byte-identical to what src/tools/*.ts produced before this migration: that already
|
|
10
|
+
* shipped to npm. Where the clean Driver abstraction and that requirement conflicted, the
|
|
11
|
+
* requirement won; every such place is called out in a comment at the call site. The most
|
|
12
|
+
* significant ones:
|
|
13
|
+
* - take_snapshot's uid is rendered as a bare digit for the "cdp" scheme (matching the legacy
|
|
14
|
+
* backendDOMNodeId-as-uid contract) rather than the driver's own tagged "cdp:N" form, and its
|
|
15
|
+
* extras (checked/expanded/disabled/url/focused) keep the legacy per-key quoting AND the
|
|
16
|
+
* "expanded" quirk (included even when literally "false") rather than a clean uniform rule.
|
|
17
|
+
* - navigate_page's field names (frameId, not contextId) and waitedFor vocabulary (load /
|
|
18
|
+
* domcontentloaded / frameStoppedLoading / navigate-only) are the legacy CDP tool's, not the
|
|
19
|
+
* Driver's own (committed / timeout).
|
|
20
|
+
* - Every nested `target` object in a result is the legacy 3-field {id,url,title}: PageInfo's
|
|
21
|
+
* additive `type` field is deliberately dropped there (list_pages is the one place `type` is
|
|
22
|
+
* part of the contract).
|
|
23
|
+
*
|
|
24
|
+
* NOT here: list_console_messages / get_console_message / list_network_requests /
|
|
25
|
+
* get_network_request / mock_request / list_mocks / clear_mocks. Chrome's versions of those 7
|
|
26
|
+
* tools are built on src/tools/recorder.ts, a disk-persisted, cross-process capture keyed by
|
|
27
|
+
* target id (rec-<targetId>.jsonl) with a materially richer shape (ConsoleEntry/NetworkRequest
|
|
28
|
+
* correlation, per-rule hit counts, dedup-by-pattern+method). Firefox's existing implementation
|
|
29
|
+
* (now in src/bidi-tools.ts) is a same-process, in-memory event buffer with a different shape.
|
|
30
|
+
* Unifying them safely would mean either dropping Chrome's cross-process persistence (a real
|
|
31
|
+
* behavior change to something that ships today) or teaching Firefox to write the same JSONL
|
|
32
|
+
* format (new feature work, not a safety-preserving migration). Per the explicit instruction that
|
|
33
|
+
* behavior outranks the abstraction, these 7 stay two implementations; see the migration report.
|
|
34
|
+
*/
|
|
35
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
36
|
+
import { join } from "node:path";
|
|
37
|
+
import type {
|
|
38
|
+
BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
|
+
} from "./driver.ts";
|
|
40
|
+
import type { TargetSelector } from "./types.ts";
|
|
41
|
+
import { assertLeaseOk, claimLease, leaseTtlMs, releaseLeaseFor, type LeaseBackend, type LeaseToken } from "./leases.ts";
|
|
42
|
+
|
|
43
|
+
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
44
|
+
const STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
|
|
45
|
+
|
|
46
|
+
class SharedToolError extends Error {}
|
|
47
|
+
|
|
48
|
+
/** Trim a PageInfo down to the legacy 3-field {id,url,title} target shape used everywhere except list_pages. */
|
|
49
|
+
function target3(p: PageInfo): { id: string; url: string; title: string } {
|
|
50
|
+
return { id: p.id, url: p.url, title: p.title };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Same discriminator leases-tools.ts uses: the driver's own uid scheme tag
|
|
54
|
+
* ("cdp" for Chrome, "bidi" for Firefox). */
|
|
55
|
+
function backendOf(driver: BrowserDriver): LeaseBackend {
|
|
56
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* -------------------------------- page acquisition -------------------------------- */
|
|
60
|
+
|
|
61
|
+
/** Acquire, run, always release: the Driver counterpart of client.ts's withPage. */
|
|
62
|
+
async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undefined, fn: (page: PageDriver) => Promise<T>): Promise<T> {
|
|
63
|
+
const page = await driver.page(target);
|
|
64
|
+
try {
|
|
65
|
+
return await fn(page);
|
|
66
|
+
} finally {
|
|
67
|
+
await page.release();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function resolvePage(driver: BrowserDriver, selector?: TargetSelector): Promise<PageInfo> {
|
|
72
|
+
const pages = await driver.listPages();
|
|
73
|
+
const hit = await pickPage(driver, pages, selector);
|
|
74
|
+
// THE third choke point. close_page and select_page resolve here and nowhere
|
|
75
|
+
// else, so the lease check has to be here too, not only in resolveTarget /
|
|
76
|
+
// resolveContext. Closing someone else's leased tab is the worst version of
|
|
77
|
+
// the collision this feature exists to stop.
|
|
78
|
+
//
|
|
79
|
+
// Deliberately NOT passing liveIds, matching resolveTarget and resolveContext.
|
|
80
|
+
// For every branch but one it would be inert (hit is an element of `pages`),
|
|
81
|
+
// and for the bare-id branch it would be actively wrong: that branch can
|
|
82
|
+
// return a non-page target off the `all:true` listing, whose id is absent
|
|
83
|
+
// from `pages`, so passing pages' ids would report a perfectly healthy lease
|
|
84
|
+
// as target-gone and wave the caller straight through it.
|
|
85
|
+
await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
|
|
86
|
+
return hit;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Same TargetSelector grammar as client.ts's resolveTarget. Matches its one asymmetry exactly:
|
|
91
|
+
* every branch resolves against page-type targets only EXCEPT a bare-id lookup, which (like
|
|
92
|
+
* legacy resolveTarget's `targets.find`) searches the FULL unfiltered listing, so passing an
|
|
93
|
+
* exact non-page targetId (a worker, an iframe) still resolves.
|
|
94
|
+
*/
|
|
95
|
+
async function pickPage(driver: BrowserDriver, pages: PageInfo[], selector?: TargetSelector): Promise<PageInfo> {
|
|
96
|
+
if (selector === undefined || selector === "active") {
|
|
97
|
+
if (!pages.length) throw new SharedToolError("no page targets open");
|
|
98
|
+
return pages[0]!;
|
|
99
|
+
}
|
|
100
|
+
if (selector.startsWith("index:")) {
|
|
101
|
+
const p = pages[Number(selector.slice(6))];
|
|
102
|
+
if (!p) throw new SharedToolError(`no page target at index ${selector.slice(6)} (have ${pages.length})`);
|
|
103
|
+
return p;
|
|
104
|
+
}
|
|
105
|
+
if (selector.startsWith("url:")) {
|
|
106
|
+
const needle = selector.slice(4);
|
|
107
|
+
const p = pages.find((x) => x.url.includes(needle));
|
|
108
|
+
if (!p) throw new SharedToolError(`no page url containing '${needle}'`);
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
if (selector.startsWith("title:")) {
|
|
112
|
+
const needle = selector.slice(6);
|
|
113
|
+
const p = pages.find((x) => x.title.includes(needle));
|
|
114
|
+
if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
|
|
115
|
+
return p;
|
|
116
|
+
}
|
|
117
|
+
const exactPage = pages.find((x) => x.id === selector);
|
|
118
|
+
if (exactPage) return exactPage;
|
|
119
|
+
const all = await driver.listPages({ all: true });
|
|
120
|
+
const exact = all.find((x) => x.id === selector);
|
|
121
|
+
if (exact) return exact;
|
|
122
|
+
throw new SharedToolError(`no target with id '${selector}'`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/* -------------------------------- element locators -------------------------------- */
|
|
126
|
+
|
|
127
|
+
function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
|
|
128
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== ("" as unknown);
|
|
129
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
130
|
+
if (hasUid === hasSelector) throw new SharedToolError("provide exactly one of { uid } or { selector }");
|
|
131
|
+
return hasUid ? { uid: args.uid as DriverUid } : { css: args.selector as string };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* -------------------------------- pages (4) -------------------------------- */
|
|
135
|
+
|
|
136
|
+
export async function listPages(driver: BrowserDriver, args: { all?: boolean } = {}): Promise<{ pages: PageInfo[]; count: number }> {
|
|
137
|
+
const pages = await driver.listPages({ all: args.all });
|
|
138
|
+
return { pages, count: pages.length };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function newPage(
|
|
142
|
+
driver: BrowserDriver,
|
|
143
|
+
args: { url?: string; claim?: boolean; label?: string; ttlMs?: number } = {},
|
|
144
|
+
): Promise<{ targetId: string; url: string; lease?: LeaseToken; label?: string; expiresAt?: number }> {
|
|
145
|
+
const p = await driver.newPage(args.url);
|
|
146
|
+
if (args.claim !== true) return { targetId: p.id, url: p.url };
|
|
147
|
+
// Atomic in the sense that matters: the tab is claimed before this call
|
|
148
|
+
// returns, so no other agent can see it unclaimed and take it first.
|
|
149
|
+
const { record, token } = await claimLease(backendOf(driver), p.id, {
|
|
150
|
+
label: typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`,
|
|
151
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
152
|
+
});
|
|
153
|
+
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export async function closePage(
|
|
157
|
+
driver: BrowserDriver,
|
|
158
|
+
args: { target?: TargetSelector },
|
|
159
|
+
): Promise<{ closed: string; success: boolean; leaseReleased?: boolean }> {
|
|
160
|
+
if (args.target === undefined || args.target === "") throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
|
|
161
|
+
const p = await resolvePage(driver, args.target);
|
|
162
|
+
const res = await driver.closePage(p.id);
|
|
163
|
+
// A failed close (target refused to close, or was already gone) must leave
|
|
164
|
+
// the lease intact: the caller still owns a tab that never actually closed,
|
|
165
|
+
// and releasing it here would let another agent claim a tab this one still
|
|
166
|
+
// thinks it holds.
|
|
167
|
+
if (!res.success) return { closed: p.id, success: false, leaseReleased: false };
|
|
168
|
+
// Closing a leased tab must not leave its lease file behind waiting on the
|
|
169
|
+
// TTL. resolvePage above already refused this call unless the caller holds
|
|
170
|
+
// the lease, so no token is needed here.
|
|
171
|
+
const released = await releaseLeaseFor(backendOf(driver), p.id);
|
|
172
|
+
return { closed: p.id, success: true, ...(released.released ? { leaseReleased: true } : {}) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeSelectedFile(id: string): Promise<void> {
|
|
176
|
+
await mkdir(STATE_DIR, { recursive: true });
|
|
177
|
+
await writeFile(join(STATE_DIR, "selected"), id, "utf8");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function selectPage(driver: BrowserDriver, args: { target?: TargetSelector }): Promise<{ selected: string }> {
|
|
181
|
+
if (args.target === undefined || args.target === "") throw new SharedToolError("select_page requires an explicit target");
|
|
182
|
+
const p = await resolvePage(driver, args.target);
|
|
183
|
+
const activated = await driver.activatePage(p.id);
|
|
184
|
+
await writeSelectedFile(activated.id);
|
|
185
|
+
return { selected: activated.id };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ------------------------------ navigation (2) ------------------------------ */
|
|
189
|
+
|
|
190
|
+
/** Legacy tools/navigation.ts vocabulary, not the Driver's own (see file header). */
|
|
191
|
+
function toLegacyWaitedFor(w: NavigateResult["waitedFor"]): "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only" {
|
|
192
|
+
if (w === "committed") return "frameStoppedLoading";
|
|
193
|
+
if (w === "timeout") return "navigate-only";
|
|
194
|
+
return w;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface LegacyNavigateResult {
|
|
198
|
+
url: string;
|
|
199
|
+
frameId: string;
|
|
200
|
+
reloaded?: boolean;
|
|
201
|
+
waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function navigatePage(
|
|
205
|
+
driver: BrowserDriver,
|
|
206
|
+
args: { target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number },
|
|
207
|
+
): Promise<LegacyNavigateResult> {
|
|
208
|
+
const reload = args.reload === true;
|
|
209
|
+
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
210
|
+
throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
211
|
+
}
|
|
212
|
+
return withPage(driver, args.target, async (page) => {
|
|
213
|
+
const r = await page.navigate(args);
|
|
214
|
+
return {
|
|
215
|
+
url: r.url,
|
|
216
|
+
frameId: r.contextId,
|
|
217
|
+
...(r.reloaded ? { reloaded: r.reloaded } : {}),
|
|
218
|
+
waitedFor: toLegacyWaitedFor(r.waitedFor),
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function waitForText(driver: BrowserDriver, args: { target?: TargetSelector; text: string; timeoutMs?: number; pollMs?: number }): Promise<{ found: true; waitedMs: number }> {
|
|
224
|
+
if (typeof args.text !== "string" || args.text.length === 0) throw new SharedToolError("wait_for: 'text' is required");
|
|
225
|
+
return withPage(driver, args.target, async (page) => {
|
|
226
|
+
const r = await page.waitForText(args.text, args.timeoutMs ?? 15_000, args.pollMs ?? 250);
|
|
227
|
+
return { found: true, waitedMs: r.elapsedMs };
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
/** Contract-mandated alias, matching tools/navigation.ts's exported `waitFor`. */
|
|
231
|
+
export const waitFor = waitForText;
|
|
232
|
+
|
|
233
|
+
/* ------------------------------- evaluate (1) ------------------------------- */
|
|
234
|
+
|
|
235
|
+
export async function evaluateScript(
|
|
236
|
+
driver: BrowserDriver,
|
|
237
|
+
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[] },
|
|
238
|
+
): Promise<unknown> {
|
|
239
|
+
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
240
|
+
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
241
|
+
}
|
|
242
|
+
return withPage(driver, args.target, (page) => page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true }));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* -------------------------------- snapshot (1) -------------------------------- */
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Legacy-faithful render, keyed by field name (see the file header's uid/extras note). The
|
|
249
|
+
* "cdp" scheme strips its "cdp:" tag to reproduce the historical bare-backendDOMNodeId uid;
|
|
250
|
+
* every other scheme keeps its tag (a tool must never guess at a foreign driver's uid grammar,
|
|
251
|
+
* per driver.ts's THE UID CODEC block: this is a display choice made by the tool that already
|
|
252
|
+
* knows its own driver's scheme, not a parse of a uid it didn't mint).
|
|
253
|
+
*/
|
|
254
|
+
const BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
|
|
255
|
+
const UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
|
|
256
|
+
|
|
257
|
+
function renderSnapshotLine(n: SnapshotNode, scheme: string): string {
|
|
258
|
+
const indent = " ".repeat(n.depth);
|
|
259
|
+
const uidText = scheme === "cdp" && n.uid.startsWith("cdp:") ? n.uid.slice(4) : n.uid;
|
|
260
|
+
const name = n.name !== undefined ? ` ${JSON.stringify(n.name)}` : "";
|
|
261
|
+
const extrasToks: string[] = [];
|
|
262
|
+
if (n.extras) {
|
|
263
|
+
for (const [k, v] of Object.entries(n.extras)) {
|
|
264
|
+
if (BARE_TOKEN_EXTRAS.has(k)) extrasToks.push(k);
|
|
265
|
+
else if (UNQUOTED_EXTRAS.has(k)) extrasToks.push(`${k}=${v}`);
|
|
266
|
+
else extrasToks.push(`${k}=${JSON.stringify(v)}`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const extra = extrasToks.length ? ` [${extrasToks.join(" ")}]` : "";
|
|
270
|
+
return `${indent}[${uidText}] ${n.role}${name}${extra}`;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export async function takeSnapshot(
|
|
274
|
+
driver: BrowserDriver,
|
|
275
|
+
args: { target?: TargetSelector; interactiveOnly?: boolean } = {},
|
|
276
|
+
): Promise<{ snapshot: string; target: { id: string; url: string; title: string }; nodeCount: number }> {
|
|
277
|
+
return withPage(driver, args.target, async (page) => {
|
|
278
|
+
const nodes = await page.snapshot({ interactiveOnly: args.interactiveOnly ?? false });
|
|
279
|
+
const scheme = page.browser.scheme;
|
|
280
|
+
return { snapshot: nodes.map((n) => renderSnapshotLine(n, scheme)).join("\n"), target: target3(page.info), nodeCount: nodes.length };
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ------------------------------ interaction (8) ------------------------------ */
|
|
285
|
+
|
|
286
|
+
export async function click(
|
|
287
|
+
driver: BrowserDriver,
|
|
288
|
+
args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number },
|
|
289
|
+
): Promise<{ clicked: true; x: number; y: number }> {
|
|
290
|
+
return withPage(driver, args.target, async (page) => {
|
|
291
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
292
|
+
return { clicked: true, x, y };
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
|
|
297
|
+
return withPage(driver, args.target, async (page) => {
|
|
298
|
+
const { x, y } = await page.hover(locatorOf(args));
|
|
299
|
+
return { hovered: true, x, y };
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function drag(
|
|
304
|
+
driver: BrowserDriver,
|
|
305
|
+
args: { target?: TargetSelector; from: { uid?: DriverUid; selector?: string }; to: { uid?: DriverUid; selector?: string } },
|
|
306
|
+
): Promise<{ dragged: true; from: { x: number; y: number }; to: { x: number; y: number } }> {
|
|
307
|
+
if (!args.from || !args.to) throw new SharedToolError("drag requires { from } and { to }");
|
|
308
|
+
return withPage(driver, args.target, async (page) => {
|
|
309
|
+
const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
|
|
310
|
+
return { dragged: true, from, to };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export async function fill(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; value: string }): Promise<{ filled: true; value: string }> {
|
|
315
|
+
if (typeof args.value !== "string") throw new SharedToolError("fill requires a string { value }");
|
|
316
|
+
return withPage(driver, args.target, async (page) => {
|
|
317
|
+
await page.setValue(locatorOf(args), args.value);
|
|
318
|
+
return { filled: true, value: args.value };
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export async function fillForm(
|
|
323
|
+
driver: BrowserDriver,
|
|
324
|
+
args: { target?: TargetSelector; fields: Array<{ uid?: DriverUid; selector?: string; value: string }> },
|
|
325
|
+
): Promise<{ filled: number; fields: number }> {
|
|
326
|
+
if (!Array.isArray(args.fields) || args.fields.length === 0) throw new SharedToolError("fill_form requires a non-empty { fields } array");
|
|
327
|
+
return withPage(driver, args.target, async (page) => {
|
|
328
|
+
let filled = 0;
|
|
329
|
+
for (const f of args.fields) {
|
|
330
|
+
if (typeof f.value !== "string") throw new SharedToolError("each fill_form field requires a string value");
|
|
331
|
+
await page.setValue(locatorOf(f), f.value);
|
|
332
|
+
filled++;
|
|
333
|
+
}
|
|
334
|
+
return { filled, fields: args.fields.length };
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export async function typeText(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; text: string }): Promise<{ typed: true; text: string }> {
|
|
339
|
+
if (typeof args.text !== "string") throw new SharedToolError("type_text requires a string { text }");
|
|
340
|
+
return withPage(driver, args.target, async (page) => {
|
|
341
|
+
await page.typeText(locatorOf(args), args.text);
|
|
342
|
+
return { typed: true, text: args.text };
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function pressKey(driver: BrowserDriver, args: { target?: TargetSelector; key: string; modifiers?: string[] }): Promise<{ pressed: string; modifiers: string[] }> {
|
|
347
|
+
if (typeof args.key !== "string" || !args.key.length) throw new SharedToolError("press_key requires a non-empty { key }");
|
|
348
|
+
const modifiers = args.modifiers ?? [];
|
|
349
|
+
return withPage(driver, args.target, async (page) => {
|
|
350
|
+
await page.pressKey({ key: args.key, modifiers });
|
|
351
|
+
return { pressed: args.key, modifiers };
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function uploadFile(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string; files: string | string[] }): Promise<{ uploaded: string[] }> {
|
|
356
|
+
const files = Array.isArray(args.files) ? args.files : [args.files];
|
|
357
|
+
if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
|
|
358
|
+
throw new SharedToolError("upload_file requires { files } as a non-empty path or array of paths");
|
|
359
|
+
}
|
|
360
|
+
return withPage(driver, args.target, async (page) => {
|
|
361
|
+
await page.setFiles(locatorOf(args), files);
|
|
362
|
+
return { uploaded: files };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* -------------------------- screenshot + emulation (3) -------------------------- */
|
|
367
|
+
|
|
368
|
+
function stamp(): string {
|
|
369
|
+
return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function takeScreenshot(
|
|
373
|
+
driver: BrowserDriver,
|
|
374
|
+
args: {
|
|
375
|
+
target?: TargetSelector; format?: "png" | "jpeg"; quality?: number; fullPage?: boolean;
|
|
376
|
+
uid?: DriverUid; selector?: string; savePath?: string; returnBase64?: boolean;
|
|
377
|
+
} = {},
|
|
378
|
+
): Promise<{ path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string }> {
|
|
379
|
+
if ((args.uid !== undefined && args.uid !== "") && args.selector) throw new SharedToolError("provide exactly one of uid or selector, not both");
|
|
380
|
+
const format = args.format ?? "png";
|
|
381
|
+
return withPage(driver, args.target, async (page) => {
|
|
382
|
+
const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
|
|
383
|
+
const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...(clip ? { clip } : {}) });
|
|
384
|
+
if (data.byteLength === 0) throw new SharedToolError("captureScreenshot returned empty data");
|
|
385
|
+
await mkdir(ARTIFACT_DIR, { recursive: true });
|
|
386
|
+
const ext = outFormat === "jpeg" ? "jpg" : "png";
|
|
387
|
+
const path = args.savePath ?? join(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
|
|
388
|
+
await writeFile(path, Buffer.from(data));
|
|
389
|
+
const result: { path: string; bytes: number; format: "png" | "jpeg"; target: { id: string; url: string; title: string }; base64?: string } = {
|
|
390
|
+
path, bytes: data.byteLength, format: outFormat, target: target3(page.info),
|
|
391
|
+
};
|
|
392
|
+
if (args.returnBase64) result.base64 = Buffer.from(data).toString("base64");
|
|
393
|
+
return result;
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export async function emulate(
|
|
398
|
+
driver: BrowserDriver,
|
|
399
|
+
args: {
|
|
400
|
+
target?: TargetSelector; width?: number; height?: number; deviceScaleFactor?: number; mobile?: boolean;
|
|
401
|
+
userAgent?: string; cpuThrottlingRate?: number; media?: string; mediaFeatures?: Array<{ name: string; value: string }>;
|
|
402
|
+
networkConditions?: { offline?: boolean; latency?: number; downloadThroughput?: number; uploadThroughput?: number; connectionType?: string };
|
|
403
|
+
clearOverrides?: boolean;
|
|
404
|
+
} = {},
|
|
405
|
+
): Promise<{ target: { id: string; url: string; title: string }; applied: string[]; cleared?: boolean }> {
|
|
406
|
+
return withPage(driver, args.target, async (page) => {
|
|
407
|
+
if (args.clearOverrides) {
|
|
408
|
+
await page.emulate({ clearOverrides: true });
|
|
409
|
+
return { target: target3(page.info), applied: [], cleared: true };
|
|
410
|
+
}
|
|
411
|
+
const { applied } = await page.emulate(args);
|
|
412
|
+
if (applied.length === 0) throw new SharedToolError("emulate: no overrides specified (pass clearOverrides:true to reset)");
|
|
413
|
+
return { target: target3(page.info), applied };
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export async function resizePage(
|
|
418
|
+
driver: BrowserDriver,
|
|
419
|
+
args: { target?: TargetSelector; width: number; height: number; deviceScaleFactor?: number; mobile?: boolean },
|
|
420
|
+
): Promise<{ target: { id: string; url: string; title: string }; width: number; height: number; innerWidth: number; innerHeight: number }> {
|
|
421
|
+
if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
|
|
422
|
+
throw new SharedToolError("resize_page requires positive numeric width and height");
|
|
423
|
+
}
|
|
424
|
+
return withPage(driver, args.target, async (page) => {
|
|
425
|
+
await page.emulate({ width: args.width, height: args.height, deviceScaleFactor: args.deviceScaleFactor, mobile: args.mobile });
|
|
426
|
+
const v = (await page.evaluate("({ w: window.innerWidth, h: window.innerHeight })")) as { w?: number; h?: number } | undefined;
|
|
427
|
+
return { target: target3(page.info), width: args.width, height: args.height, innerWidth: v?.w ?? args.width, innerHeight: v?.h ?? args.height };
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/* ---------------------------------- dialogs (1) ---------------------------------- */
|
|
432
|
+
|
|
433
|
+
export async function handleDialog(
|
|
434
|
+
driver: BrowserDriver,
|
|
435
|
+
args: { target?: TargetSelector; accept: boolean; promptText?: string; timeoutMs?: number; autoMs?: number },
|
|
436
|
+
): Promise<unknown> {
|
|
437
|
+
if (args.accept === undefined) throw new SharedToolError("handle_dialog requires { accept }");
|
|
438
|
+
return withPage(driver, args.target, (page) => page.handleDialog(args.accept, args.promptText, { timeoutMs: args.timeoutMs, autoMs: args.autoMs }));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/* ------------------------------------- registry ------------------------------------- */
|
|
442
|
+
|
|
443
|
+
/** The 20 tools this file unifies, importable by both src/index.ts (Chrome) and
|
|
444
|
+
* src/firefox-tools.ts (Firefox). Each function is (driver, args) => Promise<result>. */
|
|
445
|
+
export const SHARED_TOOLS = {
|
|
446
|
+
list_pages: listPages,
|
|
447
|
+
new_page: newPage,
|
|
448
|
+
close_page: closePage,
|
|
449
|
+
select_page: selectPage,
|
|
450
|
+
navigate_page: navigatePage,
|
|
451
|
+
wait_for: waitForText,
|
|
452
|
+
evaluate_script: evaluateScript,
|
|
453
|
+
take_snapshot: takeSnapshot,
|
|
454
|
+
click,
|
|
455
|
+
hover,
|
|
456
|
+
drag,
|
|
457
|
+
fill,
|
|
458
|
+
fill_form: fillForm,
|
|
459
|
+
type_text: typeText,
|
|
460
|
+
press_key: pressKey,
|
|
461
|
+
upload_file: uploadFile,
|
|
462
|
+
take_screenshot: takeScreenshot,
|
|
463
|
+
emulate,
|
|
464
|
+
resize_page: resizePage,
|
|
465
|
+
handle_dialog: handleDialog,
|
|
466
|
+
} satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;
|
package/src/tools/console.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* console.ts
|
|
2
|
+
* console.ts: read console output captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_console_messages -> listConsoleMessages
|
|
5
5
|
* get_console_message -> getConsoleMessage
|
|
@@ -135,7 +135,7 @@ async function readConsoleEntries(file: string): Promise<ConsoleEntry[]> {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
* list_console_messages
|
|
138
|
+
* list_console_messages: return console output for the target. With
|
|
139
139
|
* `reload:true`, records a fresh capture window by reloading the page.
|
|
140
140
|
*/
|
|
141
141
|
export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): Promise<{
|
|
@@ -166,7 +166,7 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
/**
|
|
169
|
-
* get_console_message
|
|
169
|
+
* get_console_message: return a single console entry by index from the
|
|
170
170
|
* existing buffer for the target. Throws if the index is out of range.
|
|
171
171
|
*/
|
|
172
172
|
export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
|
package/src/tools/evaluate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* evaluate_script
|
|
2
|
+
* evaluate_script: run arbitrary JavaScript in a page's main context over raw
|
|
3
3
|
* CDP and return the evaluated value.
|
|
4
4
|
*
|
|
5
5
|
* Two modes, selected by whether `args` is supplied:
|
|
@@ -148,7 +148,7 @@ export async function evaluateScript(args: EvaluateScriptArgs): Promise<unknown>
|
|
|
148
148
|
*
|
|
149
149
|
* Parity gaps vs chrome-devtools-mcp evaluate_script:
|
|
150
150
|
* - MCP injects a typed `page`/element handle into the evaluated function; here `args` are plain
|
|
151
|
-
* JSON-serializable values passed positionally
|
|
151
|
+
* JSON-serializable values passed positionally; no live element/page handle is bound.
|
|
152
152
|
* - Non-serializable return values (DOM nodes, functions, circular objects) come back as their CDP
|
|
153
153
|
* `description` string rather than a structured handle, since returnByValue is used.
|
|
154
154
|
* - No isolated-world/contextId selection: evaluation runs in the page's default main-world context.
|
package/src/tools/heap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* heap.ts
|
|
2
|
+
* heap.ts: `take_heapsnapshot` over raw CDP.
|
|
3
3
|
*
|
|
4
4
|
* Captures a V8 heap snapshot of a page target and writes it as a
|
|
5
5
|
* `.heapsnapshot` file (the JSON format DevTools' Memory panel loads).
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* CDP semantics this module relies on:
|
|
8
8
|
* - `HeapProfiler.takeHeapSnapshot` streams the snapshot back as a sequence
|
|
9
9
|
* of `HeapProfiler.addHeapSnapshotChunk` events. The command's own result
|
|
10
|
-
* does NOT carry the data
|
|
10
|
+
* does NOT carry the data; it resolves only after the final chunk has been
|
|
11
11
|
* emitted. So we subscribe to the chunk event first, accumulate every
|
|
12
12
|
* chunk's string, then `await` the command; once it resolves we have the
|
|
13
13
|
* complete snapshot and join the chunks into one JSON document.
|
|
@@ -103,7 +103,7 @@ export async function takeHeapsnapshot(
|
|
|
103
103
|
*
|
|
104
104
|
* Parity gaps vs chrome-devtools-mcp `take_heapsnapshot`:
|
|
105
105
|
* - reportProgress is forced false; no incremental progress callbacks surfaced.
|
|
106
|
-
* - Returns {path,bytes,chunks,target} only
|
|
106
|
+
* - Returns {path,bytes,chunks,target} only; does not parse/summarize the
|
|
107
107
|
* snapshot (node counts, retained sizes); the .heapsnapshot file is for the
|
|
108
108
|
* DevTools Memory panel to load, matching the MCP tool's artifact behavior.
|
|
109
109
|
* --------------------------------------------------------------------------*/
|
package/src/tools/lighthouse.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lighthouse_audit
|
|
2
|
+
* lighthouse_audit: THE ONLY non-CDP tool in this toolkit.
|
|
3
3
|
*
|
|
4
4
|
* Unlike every other module, this one does NOT speak the Chrome DevTools
|
|
5
5
|
* Protocol over a WebSocket. Instead it shells out to the external Lighthouse
|
|
@@ -105,7 +105,7 @@ interface LighthouseReport {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export interface LighthouseAuditArgs {
|
|
108
|
-
/** The URL to audit. Required
|
|
108
|
+
/** The URL to audit. Required, and never points at a user tab implicitly. */
|
|
109
109
|
url: string;
|
|
110
110
|
/**
|
|
111
111
|
* Lighthouse categories to run. Defaults to the full set.
|
|
@@ -206,7 +206,7 @@ export async function lighthouseAudit(
|
|
|
206
206
|
const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(
|
|
207
207
|
res.stderr,
|
|
208
208
|
)
|
|
209
|
-
? " The Lighthouse CLI could not be fetched/installed via npx
|
|
209
|
+
? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable."
|
|
210
210
|
: "";
|
|
211
211
|
throw new CdpError(
|
|
212
212
|
`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`,
|
|
@@ -264,7 +264,7 @@ function truncate(s: string, max = 600): string {
|
|
|
264
264
|
* ---------------------------------------------------------------------------
|
|
265
265
|
* NON-CDP MODULE. This is the toolkit's sole tool that does not speak raw CDP.
|
|
266
266
|
*
|
|
267
|
-
* External dependency (subprocess, fetched on demand by npx
|
|
267
|
+
* External dependency (subprocess, fetched on demand by npx, not an npm dep):
|
|
268
268
|
* - `npx --yes lighthouse <url> --port=<cdpPort> --output=json
|
|
269
269
|
* --output-path=<artifact> --quiet --preset=desktop
|
|
270
270
|
* [--only-categories=...] --chrome-flags=--headless=new`
|
package/src/tools/navigation.ts
CHANGED
|
@@ -48,7 +48,7 @@ export interface NavigatePageResult {
|
|
|
48
48
|
* finish loading.
|
|
49
49
|
*
|
|
50
50
|
* `Page.navigate`/`Page.reload` resolve once the action is *committed*. We
|
|
51
|
-
* separately race the load milestone event
|
|
51
|
+
* separately race the load milestone event, subscribed BEFORE issuing the
|
|
52
52
|
* command so a fast load can't fire before we're listening. Pass
|
|
53
53
|
* `reload:true, ignoreCache:true` for a hard reload that refetches every
|
|
54
54
|
* subresource (the only way to pick up a freshly-deployed bundle the HTTP
|
package/src/tools/network.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* network.ts
|
|
2
|
+
* network.ts: read network activity captured by the recorder.
|
|
3
3
|
*
|
|
4
4
|
* list_network_requests -> listNetworkRequests
|
|
5
5
|
* get_network_request -> getNetworkRequest
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
* - default ({ reload: false }): read the target's shared "latest" buffer
|
|
9
9
|
* (rec-<targetId>.jsonl), correlate the four Network.* events by requestId,
|
|
10
10
|
* and return one row per request.
|
|
11
|
-
* - { reload: true }: run a captureWindow (both domains)
|
|
11
|
+
* - { reload: true }: run a captureWindow (both domains): Page.reload, capture
|
|
12
12
|
* for `durationMs` (default 2500ms), stop, then read+return the requests.
|
|
13
13
|
*
|
|
14
14
|
* Response bodies: CDP only serves a body from the LIVE renderer session, so
|
|
15
15
|
* get_network_request with { includeBody: true } drives a fresh reload capture
|
|
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
|
-
* requestId
|
|
18
|
+
* requestId; see getNetworkRequest for the exact contract.
|
|
19
19
|
*/
|
|
20
20
|
import { readFile } from "node:fs/promises";
|
|
21
21
|
import { CdpError, resolveTarget } from "../client.ts";
|
|
@@ -59,7 +59,7 @@ export interface ListNetworkRequestsArgs {
|
|
|
59
59
|
|
|
60
60
|
export interface GetNetworkRequestArgs {
|
|
61
61
|
target?: TargetSelector;
|
|
62
|
-
/** Match by exact requestId (metadata only
|
|
62
|
+
/** Match by exact requestId (metadata only, see includeBody). */
|
|
63
63
|
requestId?: string;
|
|
64
64
|
/** Match by URL substring (first match). Required for body fetch (includeBody). */
|
|
65
65
|
url?: string;
|
|
@@ -153,7 +153,7 @@ async function readNetworkRequests(file: string): Promise<NetworkRequest[]> {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
/**
|
|
156
|
-
* list_network_requests
|
|
156
|
+
* list_network_requests: correlated request rows for the target. With
|
|
157
157
|
* `reload:true`, records a fresh capture window (both domains) by reloading.
|
|
158
158
|
*/
|
|
159
159
|
export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): Promise<{
|
|
@@ -187,13 +187,13 @@ export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): P
|
|
|
187
187
|
}
|
|
188
188
|
|
|
189
189
|
/**
|
|
190
|
-
* get_network_request
|
|
190
|
+
* get_network_request: return one request (matched by requestId, else by URL
|
|
191
191
|
* substring) including response status/headers.
|
|
192
192
|
*
|
|
193
193
|
* Body retrieval: CDP only serves a response body from the LIVE renderer
|
|
194
194
|
* session, so `includeBody:true` must drive a fresh reload capture and fetch the
|
|
195
195
|
* body before the connection closes. A reload re-mints requestIds, so a body
|
|
196
|
-
* fetch can only be matched by `url` (stable across reload)
|
|
196
|
+
* fetch can only be matched by `url` (stable across reload), NOT by a
|
|
197
197
|
* `requestId` carried over from a prior `list_network_requests`. When a
|
|
198
198
|
* requestId is supplied with includeBody we therefore return metadata from the
|
|
199
199
|
* existing buffer and explain why the body is unavailable, rather than reloading
|