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/dist/index.js CHANGED
@@ -1,6 +1,254 @@
1
- // src/tools/pages.ts
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { dirname } from "node:path";
1
+ // src/leases.ts
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { randomBytes } from "node:crypto";
4
+ import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ var DEFAULT_LEASE_TTL_MS = 900000;
7
+ function leaseDir() {
8
+ return process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
9
+ }
10
+ function leaseTtlMs() {
11
+ const raw = Number(process.env.CDP_LEASE_TTL_MS);
12
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
13
+ }
14
+ function safeId(id) {
15
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
16
+ }
17
+ function leaseFile(backend, targetId) {
18
+ return join(leaseDir(), `lease-${backend}-${safeId(targetId)}.json`);
19
+ }
20
+ function mintToken(backend, targetId, nonce) {
21
+ return `${backend}:${targetId}:${nonce}`;
22
+ }
23
+ function tokenParts(token) {
24
+ if (typeof token !== "string")
25
+ return;
26
+ const first = token.indexOf(":");
27
+ const last = token.lastIndexOf(":");
28
+ if (first <= 0 || last <= first)
29
+ return;
30
+ const backend = token.slice(0, first);
31
+ if (backend !== "chrome" && backend !== "firefox")
32
+ return;
33
+ const targetId = token.slice(first + 1, last);
34
+ const nonce = token.slice(last + 1);
35
+ if (!targetId.length || !/^[0-9a-f]{24}$/.test(nonce))
36
+ return;
37
+ return { backend, targetId, nonce };
38
+ }
39
+
40
+ class LeaseConflictError extends Error {
41
+ targetId;
42
+ holder;
43
+ constructor(message, targetId, holder) {
44
+ super(message);
45
+ this.targetId = targetId;
46
+ this.holder = holder;
47
+ this.name = "LeaseConflictError";
48
+ }
49
+ }
50
+ async function readLease(backend, targetId) {
51
+ let raw;
52
+ try {
53
+ raw = await readFile(leaseFile(backend, targetId), "utf8");
54
+ } catch (err) {
55
+ if (err.code === "ENOENT")
56
+ return;
57
+ throw err;
58
+ }
59
+ try {
60
+ const rec = JSON.parse(raw);
61
+ if (typeof rec?.nonce === "string" && typeof rec?.pid === "number")
62
+ return rec;
63
+ } catch {}
64
+ return;
65
+ }
66
+ function isPidAlive(pid) {
67
+ if (!Number.isInteger(pid) || pid <= 0)
68
+ return false;
69
+ try {
70
+ process.kill(pid, 0);
71
+ return true;
72
+ } catch (err) {
73
+ return err.code === "EPERM";
74
+ }
75
+ }
76
+ function staleReason(rec, opts) {
77
+ if (!isPidAlive(rec.pid))
78
+ return "dead-pid";
79
+ if (opts.now - rec.lastUsedAt > rec.ttlMs)
80
+ return "expired";
81
+ if (opts.liveIds && !opts.liveIds.includes(rec.targetId))
82
+ return "target-gone";
83
+ return false;
84
+ }
85
+ async function claimLease(backend, targetId, opts) {
86
+ const now = opts.now ?? Date.now();
87
+ await mkdir(leaseDir(), { recursive: true });
88
+ for (let attempt = 0;attempt < 3; attempt++) {
89
+ const record = {
90
+ backend,
91
+ targetId,
92
+ nonce: randomBytes(12).toString("hex"),
93
+ pid: process.pid,
94
+ label: opts.label,
95
+ createdAt: now,
96
+ lastUsedAt: now,
97
+ ttlMs: opts.ttlMs ?? leaseTtlMs()
98
+ };
99
+ try {
100
+ await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
101
+ return { token: mintToken(backend, targetId, record.nonce), record };
102
+ } catch (err) {
103
+ if (err.code !== "EEXIST")
104
+ throw err;
105
+ }
106
+ const existing = await readLease(backend, targetId);
107
+ if (!existing)
108
+ continue;
109
+ const stale = staleReason(existing, { now, liveIds: opts.liveIds });
110
+ if (!stale) {
111
+ throw new LeaseConflictError(`target '${targetId}' is already leased by '${existing.label}' (pid ${existing.pid}); call release_page with that lease's token, or list_leases to inspect it`, targetId, existing.label);
112
+ }
113
+ await unlink(leaseFile(backend, targetId)).catch(() => {
114
+ return;
115
+ });
116
+ }
117
+ throw new LeaseConflictError(`target '${targetId}' could not be claimed after 3 attempts: another process is claiming and releasing it concurrently`, targetId);
118
+ }
119
+ async function releaseLease(token) {
120
+ const parts = tokenParts(token);
121
+ if (!parts)
122
+ return { released: false };
123
+ const rec = await readLease(parts.backend, parts.targetId);
124
+ if (!rec || rec.nonce !== parts.nonce)
125
+ return { released: false };
126
+ await unlink(leaseFile(parts.backend, parts.targetId)).catch(() => {
127
+ return;
128
+ });
129
+ return { released: true };
130
+ }
131
+ async function releaseLeaseFor(backend, targetId) {
132
+ if (!await readLease(backend, targetId))
133
+ return { released: false };
134
+ await unlink(leaseFile(backend, targetId)).catch(() => {
135
+ return;
136
+ });
137
+ return { released: true };
138
+ }
139
+ var leaseScope = new AsyncLocalStorage;
140
+ function withLeaseScope(lease, fn) {
141
+ return leaseScope.run(lease, fn);
142
+ }
143
+ function currentLease() {
144
+ return leaseScope.getStore();
145
+ }
146
+ function leaseFromArgs(args) {
147
+ if (!args || typeof args !== "object")
148
+ return;
149
+ const lease = args.lease;
150
+ return typeof lease === "string" && lease.length > 0 ? lease : undefined;
151
+ }
152
+ async function touchLease(rec, now = Date.now()) {
153
+ const current = await readLease(rec.backend, rec.targetId).catch(() => {
154
+ return;
155
+ });
156
+ if (!current || current.nonce !== rec.nonce)
157
+ return;
158
+ await writeFile(leaseFile(current.backend, current.targetId), JSON.stringify({ ...current, lastUsedAt: now }), "utf8").catch(() => {
159
+ return;
160
+ });
161
+ }
162
+ function describeTarget(targetId, ctx) {
163
+ const url = ctx.url ? ` (url ${ctx.url})` : ctx.title ? ` (title ${JSON.stringify(ctx.title)})` : "";
164
+ return `target '${targetId}'${url}`;
165
+ }
166
+ async function assertLeaseOk(backend, targetId, ctx = {}) {
167
+ const token = ctx.lease ?? currentLease();
168
+ const now = ctx.now ?? Date.now();
169
+ const rec = await readLease(backend, targetId);
170
+ const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
171
+ const where = describeTarget(targetId, ctx);
172
+ if (token === undefined) {
173
+ if (!held)
174
+ return;
175
+ throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
176
+ }
177
+ const parts = tokenParts(token);
178
+ if (!parts) {
179
+ throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
180
+ }
181
+ if (parts.backend !== backend || parts.targetId !== targetId) {
182
+ if (!held)
183
+ return;
184
+ throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
185
+ }
186
+ if (!held) {
187
+ throw new LeaseConflictError(`${where} is not leased any more: the token you passed belongs to a lease that was released or reclaimed. Call claim_page again to take the tab.`, targetId);
188
+ }
189
+ if (parts.nonce !== held.nonce) {
190
+ throw new LeaseConflictError(`${where} was reclaimed and is now leased by '${held.label}' (pid ${held.pid}). Your token was invalidated by that reclamation. Call claim_page again if you still need this tab.`, targetId, held.label);
191
+ }
192
+ await touchLease(held, now);
193
+ }
194
+ var LEASE_FILE_RE = /^lease-(chrome|firefox)-(.+)\.json$/;
195
+ async function listLeases(opts = {}) {
196
+ const now = opts.now ?? Date.now();
197
+ let names = [];
198
+ try {
199
+ names = await readdir(leaseDir());
200
+ } catch (err) {
201
+ if (err.code === "ENOENT")
202
+ return [];
203
+ throw err;
204
+ }
205
+ const out = [];
206
+ for (const name of names) {
207
+ const parts = LEASE_FILE_RE.exec(name);
208
+ if (!parts)
209
+ continue;
210
+ const fromName = {
211
+ backend: parts[1],
212
+ targetId: parts[2],
213
+ label: "",
214
+ pid: 0,
215
+ createdAt: 0,
216
+ lastUsedAt: 0,
217
+ ttlMs: 0,
218
+ pidAlive: false,
219
+ stale: false
220
+ };
221
+ let rec;
222
+ try {
223
+ rec = JSON.parse(await readFile(join(leaseDir(), name), "utf8"));
224
+ } catch (err) {
225
+ const code = err.code;
226
+ if (code === "ENOENT")
227
+ continue;
228
+ out.push({ ...fromName, unreadable: code ?? "unparseable" });
229
+ continue;
230
+ }
231
+ if (typeof rec?.nonce !== "string" || typeof rec?.pid !== "number") {
232
+ out.push({ ...fromName, unreadable: "unparseable" });
233
+ continue;
234
+ }
235
+ out.push({
236
+ backend: rec.backend,
237
+ targetId: rec.targetId,
238
+ label: rec.label,
239
+ pid: rec.pid,
240
+ createdAt: rec.createdAt,
241
+ lastUsedAt: rec.lastUsedAt,
242
+ ttlMs: rec.ttlMs,
243
+ pidAlive: isPidAlive(rec.pid),
244
+ stale: staleReason(rec, {
245
+ now,
246
+ liveIds: opts.liveBackend === undefined || opts.liveBackend === rec.backend ? opts.liveIds : undefined
247
+ })
248
+ });
249
+ }
250
+ return out;
251
+ }
4
252
 
5
253
  // src/client.ts
6
254
  var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -155,8 +403,13 @@ async function browserWsUrl() {
155
403
  const v = await httpJson("/json/version");
156
404
  return v.webSocketDebuggerUrl;
157
405
  }
158
- async function resolveTarget(selector) {
406
+ async function resolveTarget(selector, opts = {}) {
159
407
  const targets = await listTargets();
408
+ const hit = pickTarget(targets, selector);
409
+ await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
410
+ return hit;
411
+ }
412
+ function pickTarget(targets, selector) {
160
413
  const pages = targets.filter((t) => t.type === "page");
161
414
  if (!selector || selector === "active") {
162
415
  if (!pages.length)
@@ -192,7 +445,7 @@ async function openBrowser(opts = {}) {
192
445
  return new CdpConnection(await browserWsUrl(), opts).connect();
193
446
  }
194
447
  async function openPage(selector, opts = {}) {
195
- const target = await resolveTarget(selector);
448
+ const target = await resolveTarget(selector, { lease: opts.lease });
196
449
  const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
197
450
  return { conn, target };
198
451
  }
@@ -205,191 +458,6 @@ async function withPage(selector, fn, opts = {}) {
205
458
  }
206
459
  }
207
460
 
208
- // src/tools/pages.ts
209
- var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
210
- var SELECTED_FILE = `${STATE_DIR}/selected`;
211
- function toPageInfo(t) {
212
- return { id: t.id, url: t.url, title: t.title, type: t.type };
213
- }
214
- async function listPages(args = {}) {
215
- const targets = await listTargets();
216
- const filtered = args.all ? targets : targets.filter((t) => t.type === "page");
217
- const pages = filtered.map(toPageInfo);
218
- return { pages, count: pages.length };
219
- }
220
- async function newPage(args = {}) {
221
- const url = args.url ?? "about:blank";
222
- const conn = await openBrowser();
223
- try {
224
- const { targetId } = await conn.send("Target.createTarget", { url });
225
- if (!targetId)
226
- throw new CdpError("Target.createTarget returned no targetId");
227
- return { targetId, url };
228
- } finally {
229
- conn.close();
230
- }
231
- }
232
- async function closePage(args) {
233
- if (args.target === undefined || args.target === "") {
234
- throw new CdpError("close_page requires an explicit target; refusing to guess which page to close");
235
- }
236
- const target = await resolveTarget(args.target);
237
- const conn = await openBrowser();
238
- try {
239
- const res = await conn.send("Target.closeTarget", { targetId: target.id });
240
- return { closed: target.id, success: res.success ?? true };
241
- } finally {
242
- conn.close();
243
- }
244
- }
245
- async function selectPage(args) {
246
- if (args.target === undefined || args.target === "") {
247
- throw new CdpError("select_page requires an explicit target");
248
- }
249
- const target = await resolveTarget(args.target);
250
- const conn = await openBrowser();
251
- try {
252
- await conn.send("Target.activateTarget", { targetId: target.id });
253
- } finally {
254
- conn.close();
255
- }
256
- await mkdir(dirname(SELECTED_FILE), { recursive: true });
257
- await writeFile(SELECTED_FILE, target.id, "utf8");
258
- return { selected: target.id };
259
- }
260
-
261
- // src/tools/navigation.ts
262
- async function navigatePage(args) {
263
- const reload = args.reload === true;
264
- if (!reload && (!args.url || typeof args.url !== "string")) {
265
- throw new CdpError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
266
- }
267
- const waitUntil = args.waitUntil ?? "load";
268
- return withPage(args.target, async (conn) => {
269
- await conn.send("Page.enable");
270
- const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
271
- const milestone = conn.waitFor(milestoneMethod, undefined, args.timeoutMs).then(() => waitUntil).catch(() => {
272
- return;
273
- });
274
- const stopped = conn.waitFor("Page.frameStoppedLoading", undefined, args.timeoutMs).then(() => "frameStoppedLoading").catch(() => {
275
- return;
276
- });
277
- let resolvedUrl;
278
- let frameId;
279
- if (reload) {
280
- await conn.send("Page.reload", { ignoreCache: args.ignoreCache === true }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
281
- const tree = await conn.send("Page.getFrameTree").catch(() => {
282
- return;
283
- });
284
- frameId = tree?.frameTree?.frame?.id ?? "";
285
- resolvedUrl = tree?.frameTree?.frame?.url ?? args.url ?? "";
286
- } else {
287
- const nav = await conn.send("Page.navigate", { url: args.url }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
288
- if (nav.errorText) {
289
- throw new CdpError(`navigate_page: ${nav.errorText} (${args.url})`);
290
- }
291
- frameId = nav.frameId;
292
- resolvedUrl = args.url;
293
- }
294
- const settled = await Promise.race([milestone, stopped]);
295
- const waitedFor = settled ?? "navigate-only";
296
- return reload ? { url: resolvedUrl, frameId, reloaded: true, waitedFor } : { url: resolvedUrl, frameId, waitedFor };
297
- }, args.timeoutMs ? { timeoutMs: args.timeoutMs } : {});
298
- }
299
- var DEFAULT_WAIT_TIMEOUT_MS = 15000;
300
- var DEFAULT_POLL_MS = 250;
301
- async function waitForText(args) {
302
- if (typeof args.text !== "string" || args.text.length === 0) {
303
- throw new CdpError("wait_for: 'text' is required");
304
- }
305
- const timeoutMs = args.timeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS;
306
- const pollMs = args.pollMs ?? DEFAULT_POLL_MS;
307
- const start = Date.now();
308
- return withPage(args.target, async (conn, _target) => {
309
- await conn.send("Runtime.enable");
310
- const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(args.text)}); })()`;
311
- for (;; ) {
312
- const { result } = await conn.send("Runtime.evaluate", {
313
- expression: expr,
314
- returnByValue: true
315
- });
316
- if (result?.value === true) {
317
- return { found: true, waitedMs: Date.now() - start };
318
- }
319
- if (Date.now() - start >= timeoutMs) {
320
- throw new CdpError(`wait_for: text not found within ${timeoutMs}ms: ${JSON.stringify(args.text)}`);
321
- }
322
- await delay(Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start))));
323
- }
324
- }, { timeoutMs: Math.max(timeoutMs, DEFAULT_WAIT_TIMEOUT_MS) });
325
- }
326
- function delay(ms) {
327
- return new Promise((resolve) => setTimeout(resolve, ms));
328
- }
329
-
330
- // src/tools/evaluate.ts
331
- function exceptionMessage(details) {
332
- const ex = details.exception;
333
- const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
334
- return fromObject ?? details.text ?? "evaluation threw";
335
- }
336
- function unwrap(obj) {
337
- if (Object.prototype.hasOwnProperty.call(obj, "value"))
338
- return obj.value;
339
- if (obj.unserializableValue !== undefined)
340
- return obj.unserializableValue;
341
- if (obj.type === "undefined")
342
- return;
343
- return obj.description ?? null;
344
- }
345
- async function evaluateExpression(conn, expression, awaitPromise) {
346
- const res = await conn.send("Runtime.evaluate", {
347
- expression,
348
- returnByValue: true,
349
- awaitPromise,
350
- replMode: false,
351
- silent: false
352
- });
353
- if (res.exceptionDetails)
354
- throw new CdpError(exceptionMessage(res.exceptionDetails));
355
- return unwrap(res.result);
356
- }
357
- async function evaluateFunction(conn, expression, awaitPromise, args) {
358
- const globalObj = await conn.send("Runtime.evaluate", {
359
- expression: "globalThis",
360
- returnByValue: false
361
- });
362
- const objectId = globalObj.result.objectId;
363
- if (!objectId)
364
- throw new CdpError("could not resolve global object for function call");
365
- const res = await conn.send("Runtime.callFunctionOn", {
366
- objectId,
367
- functionDeclaration: expression,
368
- arguments: args.map((value) => ({ value })),
369
- returnByValue: true,
370
- awaitPromise,
371
- silent: false
372
- });
373
- if (res.exceptionDetails)
374
- throw new CdpError(exceptionMessage(res.exceptionDetails));
375
- return unwrap(res.result);
376
- }
377
- async function evaluateScript(args) {
378
- const { expression } = args;
379
- if (typeof expression !== "string" || expression.length === 0) {
380
- throw new CdpError("evaluateScript: 'expression' must be a non-empty string");
381
- }
382
- const awaitPromise = args.awaitPromise ?? true;
383
- const callArgs = args.args;
384
- return withPage(args.target, async (conn) => {
385
- await conn.send("Runtime.enable");
386
- if (callArgs && callArgs.length > 0) {
387
- return evaluateFunction(conn, expression, awaitPromise, callArgs);
388
- }
389
- return evaluateExpression(conn, expression, awaitPromise);
390
- });
391
- }
392
-
393
461
  // src/tools/snapshot.ts
394
462
  var INTERACTIVE_ROLES = new Set([
395
463
  "button",
@@ -412,92 +480,6 @@ var INTERACTIVE_ROLES = new Set([
412
480
  "MenuItem",
413
481
  "Button"
414
482
  ]);
415
- function axString(v) {
416
- if (!v)
417
- return;
418
- const raw = v.value;
419
- if (raw === undefined || raw === null)
420
- return;
421
- const s = String(raw).trim();
422
- return s.length ? s : undefined;
423
- }
424
- function propValue(node, name) {
425
- const p = node.properties?.find((x) => x.name === name);
426
- return axString(p?.value);
427
- }
428
- function isInteractive(role) {
429
- if (!role)
430
- return false;
431
- return INTERACTIVE_ROLES.has(role) || INTERACTIVE_ROLES.has(role.toLowerCase());
432
- }
433
- function formatNode(node, interactiveOnly) {
434
- const role = axString(node.role);
435
- const name = axString(node.name);
436
- const uid = node.backendDOMNodeId;
437
- if (uid === undefined)
438
- return;
439
- if (interactiveOnly && !isInteractive(role))
440
- return;
441
- if (!interactiveOnly && !role && !name)
442
- return;
443
- const extras = [];
444
- const value = axString(node.value);
445
- if (value)
446
- extras.push(`value=${JSON.stringify(value)}`);
447
- const checked = propValue(node, "checked");
448
- if (checked && checked !== "false")
449
- extras.push(`checked=${checked}`);
450
- const expanded = propValue(node, "expanded");
451
- if (expanded)
452
- extras.push(`expanded=${expanded}`);
453
- const disabled = propValue(node, "disabled");
454
- if (disabled === "true")
455
- extras.push("disabled");
456
- const url = propValue(node, "url");
457
- if (url)
458
- extras.push(`url=${url}`);
459
- const focused = propValue(node, "focused");
460
- if (focused === "true")
461
- extras.push("focused");
462
- const label = name ? ` ${JSON.stringify(name)}` : "";
463
- const extra = extras.length ? ` [${extras.join(" ")}]` : "";
464
- return `[${uid}] ${role ?? "generic"}${label}${extra}`;
465
- }
466
- async function takeSnapshot(args = {}) {
467
- const interactiveOnly = args.interactiveOnly ?? false;
468
- return withPage(args.target, async (conn, target) => {
469
- await conn.send("Accessibility.enable");
470
- const { nodes } = await conn.send("Accessibility.getFullAXTree");
471
- const byId = new Map;
472
- for (const n of nodes)
473
- byId.set(n.nodeId, n);
474
- const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
475
- const lines = [];
476
- let nodeCount = 0;
477
- const walk = (node, depth) => {
478
- const line = node.ignored ? undefined : formatNode(node, interactiveOnly);
479
- if (line !== undefined) {
480
- const indent = interactiveOnly ? "" : " ".repeat(depth);
481
- lines.push(`${indent}${line}`);
482
- nodeCount++;
483
- }
484
- const childDepth = line !== undefined && !interactiveOnly ? depth + 1 : depth;
485
- for (const childId of node.childIds ?? []) {
486
- const child = byId.get(childId);
487
- if (child)
488
- walk(child, childDepth);
489
- }
490
- };
491
- for (const root of roots)
492
- walk(root, 0);
493
- return {
494
- snapshot: lines.join(`
495
- `),
496
- target: { id: target.id, url: target.url, title: target.title },
497
- nodeCount
498
- };
499
- });
500
- }
501
483
  async function resolveUid(conn, uid) {
502
484
  const { object } = await conn.send("DOM.resolveNode", {
503
485
  backendNodeId: uid
@@ -508,149 +490,236 @@ async function resolveUid(conn, uid) {
508
490
  return { objectId: object.objectId };
509
491
  }
510
492
 
511
- // src/tools/input.ts
512
- async function resolveElement(conn, t) {
513
- const hasUid = t.uid !== undefined && t.uid !== null;
514
- const hasSelector = typeof t.selector === "string" && t.selector.length > 0;
515
- if (hasUid === hasSelector) {
516
- throw new CdpError("provide exactly one of { uid } or { selector }");
517
- }
518
- if (hasUid) {
519
- return resolveUid(conn, t.uid);
520
- }
521
- const { result, exceptionDetails } = await conn.send("Runtime.evaluate", {
522
- expression: `document.querySelector(${JSON.stringify(t.selector)})`,
523
- returnByValue: false
524
- });
525
- if (exceptionDetails) {
526
- throw new CdpError(`selector '${t.selector}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
527
- }
528
- if (!result.objectId || result.subtype === "null") {
529
- throw new CdpError(`selector '${t.selector}' matched no element`);
530
- }
531
- return { objectId: result.objectId };
493
+ // src/tools/network_mock.ts
494
+ function escapeRegExpChar(ch) {
495
+ return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
532
496
  }
533
- async function centerOf(conn, objectId) {
534
- const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", {
535
- objectId,
536
- functionDeclaration: "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}",
537
- returnByValue: true
538
- });
539
- if (exceptionDetails) {
540
- throw new CdpError(`could not measure element: ${exceptionDetails.text ?? "error"}`);
541
- }
542
- if (!result.value) {
543
- throw new CdpError("element has zero size / is not visible; cannot compute a click point");
497
+ function globToRegExp(pattern) {
498
+ let re = "";
499
+ for (let i = 0;i < pattern.length; i++) {
500
+ const ch = pattern[i];
501
+ if (ch === "\\") {
502
+ const next = pattern[i + 1];
503
+ if (next !== undefined) {
504
+ re += escapeRegExpChar(next);
505
+ i++;
506
+ } else {
507
+ re += "\\\\";
508
+ }
509
+ } else if (ch === "*") {
510
+ re += ".*";
511
+ } else if (ch === "?") {
512
+ re += ".";
513
+ } else {
514
+ re += escapeRegExpChar(ch);
515
+ }
544
516
  }
545
- return result.value;
546
- }
547
- async function focusElement(conn, objectId) {
548
- await conn.send("Runtime.callFunctionOn", {
549
- objectId,
550
- functionDeclaration: "function(){this.focus&&this.focus();}",
551
- returnByValue: true
552
- });
553
- }
554
- async function click(args) {
555
- const button = args.button ?? "left";
556
- const clickCount = args.clickCount ?? 1;
557
- return withPage(args.target, async (conn) => {
558
- const { objectId } = await resolveElement(conn, args);
559
- const { x, y } = await centerOf(conn, objectId);
560
- await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
561
- await conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
562
- await conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
563
- return { clicked: true, x, y };
564
- });
517
+ return new RegExp(`^${re}$`);
565
518
  }
566
- async function hover(args) {
567
- return withPage(args.target, async (conn) => {
568
- const { objectId } = await resolveElement(conn, args);
569
- const { x, y } = await centerOf(conn, objectId);
570
- await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
571
- return { hovered: true, x, y };
572
- });
519
+ function urlMatches(pattern, url) {
520
+ return globToRegExp(pattern).test(url);
573
521
  }
574
- async function drag(args) {
575
- if (!args.from || !args.to)
576
- throw new CdpError("drag requires { from } and { to }");
577
- return withPage(args.target, async (conn) => {
578
- const { objectId: srcId } = await resolveElement(conn, args.from);
579
- const from = await centerOf(conn, srcId);
580
- const { objectId: dstId } = await resolveElement(conn, args.to);
581
- const to = await centerOf(conn, dstId);
582
- await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: from.x, y: from.y });
583
- await conn.send("Input.dispatchMouseEvent", {
584
- type: "mousePressed",
585
- x: from.x,
586
- y: from.y,
587
- button: "left",
588
- clickCount: 1
589
- });
590
- await conn.send("Input.dispatchMouseEvent", {
591
- type: "mouseMoved",
592
- x: (from.x + to.x) / 2,
593
- y: (from.y + to.y) / 2,
594
- button: "left"
595
- });
596
- await conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: to.x, y: to.y, button: "left" });
597
- await conn.send("Input.dispatchMouseEvent", {
598
- type: "mouseReleased",
599
- x: to.x,
600
- y: to.y,
601
- button: "left",
602
- clickCount: 1
603
- });
604
- return { dragged: true, from, to };
605
- });
522
+ function selectRule(rules, url, method) {
523
+ return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
606
524
  }
607
- async function setValue(conn, objectId, value) {
608
- await focusElement(conn, objectId);
609
- await conn.send("Runtime.callFunctionOn", {
610
- objectId,
611
- functionDeclaration: "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}",
612
- returnByValue: true
613
- });
614
- if (value.length) {
615
- await conn.send("Input.insertText", { text: value });
525
+ function buildFulfillParams(requestId, rule) {
526
+ const merged = new Map;
527
+ merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
528
+ for (const [name, value] of Object.entries(rule.headers ?? {})) {
529
+ merged.set(name.toLowerCase(), { name, value });
616
530
  }
531
+ return {
532
+ requestId,
533
+ responseCode: rule.status ?? 200,
534
+ responseHeaders: [...merged.values()],
535
+ body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
536
+ };
617
537
  }
618
- async function fill(args) {
619
- if (typeof args.value !== "string")
620
- throw new CdpError("fill requires a string { value }");
621
- return withPage(args.target, async (conn) => {
622
- const { objectId } = await resolveElement(conn, args);
623
- await setValue(conn, objectId, args.value);
624
- return { filled: true, value: args.value };
625
- });
626
- }
627
- async function typeText(args) {
628
- if (typeof args.text !== "string")
629
- throw new CdpError("type_text requires a string { text }");
630
- return withPage(args.target, async (conn) => {
631
- const { objectId } = await resolveElement(conn, args);
632
- await focusElement(conn, objectId);
633
- if (args.text.length)
634
- await conn.send("Input.insertText", { text: args.text });
635
- return { typed: true, text: args.text };
538
+ function effectiveAction(rule, rnd) {
539
+ if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
540
+ return "fail";
541
+ return rule.action;
542
+ }
543
+ var sessions = new Map;
544
+ function delay(ms) {
545
+ return new Promise((r) => setTimeout(r, ms));
546
+ }
547
+ function registerHandler(session) {
548
+ session.conn.on("Fetch.requestPaused", (params) => {
549
+ const p = params;
550
+ session.chain = session.chain.then(async () => {
551
+ const rule = selectRule(session.rules, p.request.url, p.request.method);
552
+ if (!rule) {
553
+ await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
554
+ return;
555
+ }
556
+ if (rule.delayMs)
557
+ await delay(rule.delayMs);
558
+ const act = effectiveAction(rule, Math.random());
559
+ if (act === "fulfill") {
560
+ await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
561
+ } else if (act === "fail") {
562
+ await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
563
+ } else {
564
+ await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
565
+ }
566
+ rule.hits++;
567
+ session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
568
+ }).catch((e) => {
569
+ if (/closed|connection|Target|Inspected/i.test(String(e)))
570
+ session.dead = true;
571
+ });
636
572
  });
637
573
  }
638
- async function fillForm(args) {
639
- if (!Array.isArray(args.fields) || args.fields.length === 0) {
640
- throw new CdpError("fill_form requires a non-empty { fields } array");
574
+ async function mockRequest(args) {
575
+ if (!args.urlPattern)
576
+ throw new CdpError("mock_request requires a { urlPattern }");
577
+ const rule = {
578
+ urlPattern: args.urlPattern,
579
+ action: args.action ?? "fulfill",
580
+ status: args.status,
581
+ body: args.body,
582
+ contentType: args.contentType,
583
+ headers: args.headers,
584
+ errorReason: args.errorReason,
585
+ method: args.method,
586
+ delayMs: args.delayMs,
587
+ failRate: args.failRate,
588
+ hits: 0
589
+ };
590
+ const resolved = await resolveTarget(args.target);
591
+ let session = sessions.get(resolved.id);
592
+ if (session && !session.dead) {
593
+ const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
594
+ if (idx >= 0)
595
+ session.rules[idx] = rule;
596
+ else
597
+ session.rules.push(rule);
598
+ await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
599
+ } else {
600
+ const { conn, target } = await openPage(resolved.id);
601
+ session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
602
+ registerHandler(session);
603
+ await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
604
+ sessions.set(target.id, session);
641
605
  }
642
- return withPage(args.target, async (conn) => {
643
- let filled = 0;
644
- for (const field of args.fields) {
645
- if (typeof field.value !== "string") {
646
- throw new CdpError("each fill_form field requires a string value");
647
- }
648
- const { objectId } = await resolveElement(conn, field);
649
- await setValue(conn, objectId, field.value);
650
- filled++;
606
+ let reloaded = false;
607
+ if (args.reload) {
608
+ await session.conn.send("Page.enable");
609
+ await session.conn.send("Page.reload", { ignoreCache: true });
610
+ await delay(800);
611
+ reloaded = true;
612
+ }
613
+ return {
614
+ target: { id: session.target.id, url: session.target.url, title: session.target.title },
615
+ pattern: rule.urlPattern,
616
+ ruleCount: session.rules.length,
617
+ reloaded
618
+ };
619
+ }
620
+ async function listMocks(_args = {}) {
621
+ for (const [id, s] of sessions) {
622
+ if (s.dead) {
623
+ try {
624
+ s.conn.close();
625
+ } catch {}
626
+ sessions.delete(id);
627
+ continue;
651
628
  }
652
- return { filled, fields: args.fields.length };
653
- });
629
+ try {
630
+ await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
631
+ } catch {
632
+ try {
633
+ s.conn.close();
634
+ } catch {}
635
+ sessions.delete(id);
636
+ }
637
+ }
638
+ const mocks = [...sessions.values()].map((s) => ({
639
+ target: { id: s.target.id, url: s.target.url, title: s.target.title },
640
+ rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
641
+ hits: s.intercepted.length
642
+ }));
643
+ return { count: mocks.length, mocks };
644
+ }
645
+ async function clearMocks(args = {}) {
646
+ let ids;
647
+ if (args.all) {
648
+ ids = [...sessions.keys()];
649
+ } else {
650
+ const resolved = await resolveTarget(args.target);
651
+ ids = sessions.has(resolved.id) ? [resolved.id] : [];
652
+ }
653
+ let cleared = 0;
654
+ for (const id of ids) {
655
+ const s = sessions.get(id);
656
+ if (!s)
657
+ continue;
658
+ try {
659
+ await s.conn.send("Fetch.disable");
660
+ } catch {}
661
+ try {
662
+ s.conn.close();
663
+ } catch {}
664
+ sessions.delete(id);
665
+ cleared++;
666
+ }
667
+ return { cleared };
668
+ }
669
+
670
+ // src/driver.ts
671
+ var UID_STAMP_ATTR = "data-cdp-uid";
672
+ var LEGACY_NUMERIC_UID = /^[0-9]+$/;
673
+ var REQUIRED_CAPABILITIES = {
674
+ performance_start_trace: ["trace.performance"],
675
+ performance_stop_trace: ["trace.performance"],
676
+ performance_analyze_insight: ["trace.performance"],
677
+ performance_trace: ["trace.performance"],
678
+ take_heapsnapshot: ["heap.snapshot"],
679
+ lighthouse_audit: ["audit.lighthouse"],
680
+ mock_request: ["network.intercept"],
681
+ list_mocks: ["network.intercept"],
682
+ clear_mocks: ["network.intercept"]
683
+ };
684
+ function isDriverError(e) {
685
+ return e instanceof Error && typeof e.code === "string";
686
+ }
687
+
688
+ // src/cdp/driver.ts
689
+ function driverError(code, message, data) {
690
+ return Object.assign(new Error(message), { code, ...data !== undefined ? { data } : {} });
691
+ }
692
+ function encodeUid(backendNodeId) {
693
+ return `cdp:${backendNodeId}`;
694
+ }
695
+ function decodeUid(uid) {
696
+ if (LEGACY_NUMERIC_UID.test(uid))
697
+ return Number(uid);
698
+ if (!uid.startsWith("cdp:"))
699
+ throw driverError("foreign-uid", `uid is not owned by the cdp driver: ${uid}`);
700
+ const n = Number(uid.slice(4));
701
+ if (!Number.isSafeInteger(n) || n <= 0)
702
+ throw driverError("foreign-uid", `malformed cdp uid payload: ${uid}`);
703
+ return n;
704
+ }
705
+ async function centerOf(conn, objectId) {
706
+ const fn = "function(){this.scrollIntoView({block:'center',inline:'center'});const r=this.getBoundingClientRect();if(r.width===0&&r.height===0)return null;return {x:r.left+r.width/2,y:r.top+r.height/2};}";
707
+ const { result, exceptionDetails } = await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
708
+ if (exceptionDetails)
709
+ throw driverError("page-error", `could not measure element: ${exceptionDetails.text ?? "error"}`);
710
+ if (!result.value)
711
+ throw driverError("page-error", "element has zero size / is not visible; cannot compute a click point");
712
+ return result.value;
713
+ }
714
+ async function focusElement(conn, objectId) {
715
+ await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
716
+ }
717
+ async function setValueOnObject(conn, objectId, value) {
718
+ await focusElement(conn, objectId);
719
+ const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
720
+ await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
721
+ if (value.length)
722
+ await conn.send("Input.insertText", { text: value });
654
723
  }
655
724
  var NAMED_KEYS = {
656
725
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -674,15 +743,7 @@ var NAMED_KEYS = {
674
743
  pageup: { key: "PageUp", code: "PageUp", keyCode: 33 },
675
744
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
676
745
  };
677
- var MODIFIER_BITS = {
678
- alt: 1,
679
- control: 2,
680
- ctrl: 2,
681
- meta: 4,
682
- cmd: 4,
683
- command: 4,
684
- shift: 8
685
- };
746
+ var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
686
747
  function resolveKey(key) {
687
748
  const named = NAMED_KEYS[key.toLowerCase()];
688
749
  if (named)
@@ -692,225 +753,407 @@ function resolveKey(key) {
692
753
  const isLetter = upper >= "A" && upper <= "Z";
693
754
  const isDigit = key >= "0" && key <= "9";
694
755
  const code = isLetter ? `Key${upper}` : isDigit ? `Digit${key}` : `Key${upper}`;
695
- const keyCode = upper.charCodeAt(0);
696
- return { key, code, keyCode, text: key };
697
- }
698
- throw new CdpError(`unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
699
- }
700
- async function pressKey(args) {
701
- if (typeof args.key !== "string" || !args.key.length) {
702
- throw new CdpError("press_key requires a non-empty { key }");
703
- }
704
- const spec = resolveKey(args.key);
705
- let modBits = 0;
706
- const mods = args.modifiers ?? [];
707
- for (const m of mods) {
708
- const bit = MODIFIER_BITS[m.toLowerCase()];
709
- if (bit === undefined)
710
- throw new CdpError(`unknown modifier '${m}'`);
711
- modBits |= bit;
712
- }
713
- const suppressText = (modBits & ~MODIFIER_BITS.shift) !== 0;
714
- return withPage(args.target, async (conn) => {
715
- const down = {
716
- type: "keyDown",
717
- key: spec.key,
718
- code: spec.code,
719
- windowsVirtualKeyCode: spec.keyCode,
720
- nativeVirtualKeyCode: spec.keyCode,
721
- modifiers: modBits
722
- };
723
- if (spec.text && !suppressText) {
724
- down.text = spec.text;
725
- }
726
- await conn.send("Input.dispatchKeyEvent", down);
727
- await conn.send("Input.dispatchKeyEvent", {
728
- type: "keyUp",
729
- key: spec.key,
730
- code: spec.code,
731
- windowsVirtualKeyCode: spec.keyCode,
732
- nativeVirtualKeyCode: spec.keyCode,
733
- modifiers: modBits
734
- });
735
- return { pressed: spec.key, modifiers: mods };
736
- });
756
+ return { key, code, keyCode: upper.charCodeAt(0), text: key };
757
+ }
758
+ throw driverError("page-error", `unknown key '${key}' (use a named key like Enter/Tab/ArrowDown or a single character)`);
737
759
  }
738
- async function uploadFile(args) {
739
- const files = Array.isArray(args.files) ? args.files : [args.files];
740
- if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
741
- throw new CdpError("upload_file requires { files } as a non-empty path or array of paths");
760
+ function unwrap(obj) {
761
+ if (Object.prototype.hasOwnProperty.call(obj, "value"))
762
+ return obj.value;
763
+ if (obj.unserializableValue !== undefined)
764
+ return obj.unserializableValue;
765
+ return obj.type === "undefined" ? undefined : obj.description ?? null;
766
+ }
767
+ function exceptionMessage(details) {
768
+ const ex = details.exception;
769
+ const fromObject = ex?.description ?? (typeof ex?.value === "string" ? ex.value : ex?.value !== undefined ? JSON.stringify(ex.value) : undefined);
770
+ return fromObject ?? details.text ?? "evaluation threw";
771
+ }
772
+ var INTERACTIVE_ROLES2 = new Set([
773
+ "button",
774
+ "link",
775
+ "textbox",
776
+ "searchbox",
777
+ "checkbox",
778
+ "radio",
779
+ "combobox",
780
+ "listbox",
781
+ "option",
782
+ "menuitem",
783
+ "menuitemcheckbox",
784
+ "menuitemradio",
785
+ "tab",
786
+ "switch",
787
+ "slider",
788
+ "spinbutton",
789
+ "textfield"
790
+ ]);
791
+ function axString(v) {
792
+ if (!v || v.value === undefined || v.value === null)
793
+ return;
794
+ const s = String(v.value).trim();
795
+ return s.length ? s : undefined;
796
+ }
797
+ function propValue(node, name) {
798
+ return axString(node.properties?.find((p) => p.name === name)?.value);
799
+ }
800
+ function isInteractive(role) {
801
+ return !!role && (INTERACTIVE_ROLES2.has(role) || INTERACTIVE_ROLES2.has(role.toLowerCase()));
802
+ }
803
+ function axExtras(node) {
804
+ const extras = {};
805
+ const value = axString(node.value);
806
+ if (value)
807
+ extras.value = value;
808
+ const checked = propValue(node, "checked");
809
+ if (checked && checked !== "false")
810
+ extras.checked = checked;
811
+ const expanded = propValue(node, "expanded");
812
+ if (expanded)
813
+ extras.expanded = expanded;
814
+ if (propValue(node, "disabled") === "true")
815
+ extras.disabled = "true";
816
+ const url = propValue(node, "url");
817
+ if (url)
818
+ extras.url = url;
819
+ if (propValue(node, "focused") === "true")
820
+ extras.focused = "true";
821
+ return extras;
822
+ }
823
+ async function resolveElementLocator(conn, loc) {
824
+ if ("uid" in loc) {
825
+ return resolveUid(conn, decodeUid(loc.uid)).catch(() => {
826
+ throw driverError("stale-uid", `uid does not resolve: ${loc.uid}`);
827
+ });
742
828
  }
743
- return withPage(args.target, async (conn) => {
744
- const { objectId } = await resolveElement(conn, args);
745
- await conn.send("DOM.setFileInputFiles", { files, objectId });
746
- return { uploaded: files };
829
+ if ("css" in loc) {
830
+ const { result, exceptionDetails } = await conn.send("Runtime.evaluate", { expression: `document.querySelector(${JSON.stringify(loc.css)})`, returnByValue: false });
831
+ if (exceptionDetails)
832
+ throw driverError("page-error", `css '${loc.css}' evaluation failed: ${exceptionDetails.text ?? "error"}`);
833
+ if (!result.objectId || result.subtype === "null")
834
+ throw driverError("no-such-element", `css '${loc.css}' matched no element`);
835
+ return { objectId: result.objectId };
836
+ }
837
+ const backendNodeId = await searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
838
+ return resolveUid(conn, backendNodeId).catch(() => {
839
+ throw driverError("stale-uid", "text/xpath match no longer resolves");
747
840
  });
748
841
  }
749
-
750
- // src/tools/screenshot.ts
751
- import { writeFile as writeFile2, mkdir as mkdir2 } from "node:fs/promises";
752
- import { join } from "node:path";
753
- var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
754
- function shortId(id) {
755
- return id.slice(0, 8);
842
+ async function searchLocate(conn, query) {
843
+ await conn.send("DOM.getDocument", { depth: 0 }).catch(() => {
844
+ return;
845
+ });
846
+ const { searchId, resultCount } = await conn.send("DOM.performSearch", { query });
847
+ try {
848
+ if (!resultCount)
849
+ throw driverError("no-such-element", `locator matched nothing: ${query}`);
850
+ const { nodeIds } = await conn.send("DOM.getSearchResults", { searchId, fromIndex: 0, toIndex: 1 });
851
+ const nodeId = nodeIds[0];
852
+ if (nodeId === undefined)
853
+ throw driverError("no-such-element", `locator matched nothing: ${query}`);
854
+ const { node } = await conn.send("DOM.describeNode", { nodeId });
855
+ return node.backendNodeId;
856
+ } finally {
857
+ await conn.send("DOM.discardSearchResults", { searchId }).catch(() => {
858
+ return;
859
+ });
860
+ }
756
861
  }
757
- function stamp() {
758
- return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
862
+ async function backendNodeIdOf(conn, loc) {
863
+ if ("uid" in loc)
864
+ return decodeUid(loc.uid);
865
+ if ("css" in loc) {
866
+ const { objectId } = await resolveElementLocator(conn, loc);
867
+ const { node } = await conn.send("DOM.describeNode", { objectId });
868
+ return node.backendNodeId;
869
+ }
870
+ return searchLocate(conn, "xpath" in loc ? loc.xpath : loc.text);
759
871
  }
760
872
  async function fullPageClip(conn) {
761
873
  const m = await conn.send("Page.getLayoutMetrics");
762
874
  const size = m.cssContentSize ?? m.contentSize;
763
875
  if (!size)
764
- throw new CdpError("Page.getLayoutMetrics returned no content size");
765
- return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height), scale: 1 };
766
- }
767
- async function elementClip(conn, uid, selector) {
768
- let backendNodeId;
769
- if (uid != null) {
770
- backendNodeId = uid;
771
- } else if (selector) {
772
- const { root } = await conn.send("DOM.getDocument", { depth: 0 });
773
- const { nodeId } = await conn.send("DOM.querySelector", {
774
- nodeId: root.nodeId,
775
- selector
776
- });
777
- if (!nodeId)
778
- throw new CdpError(`selector matched no element: ${selector}`);
779
- const desc = await conn.send("DOM.describeNode", { nodeId });
780
- backendNodeId = desc.node.backendNodeId;
781
- } else {
782
- throw new CdpError("elementClip requires uid or selector");
783
- }
784
- try {
785
- await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId });
786
- } catch {}
876
+ throw driverError("page-error", "Page.getLayoutMetrics returned no content size");
877
+ return { x: 0, y: 0, width: Math.ceil(size.width), height: Math.ceil(size.height) };
878
+ }
879
+ async function elementClip(conn, loc) {
880
+ const backendNodeId = await backendNodeIdOf(conn, loc);
881
+ await conn.send("DOM.scrollIntoViewIfNeeded", { backendNodeId }).catch(() => {
882
+ return;
883
+ });
787
884
  const box = await conn.send("DOM.getBoxModel", { backendNodeId });
788
885
  const q = box.model.content;
789
- const q0 = q[0];
790
- const q1 = q[1];
791
- if (q0 == null || q1 == null)
792
- throw new CdpError("DOM.getBoxModel returned an empty content quad");
793
886
  const xs = [q[0], q[2], q[4], q[6]].filter((n) => n != null);
794
887
  const ys = [q[1], q[3], q[5], q[7]].filter((n) => n != null);
795
- const minX = Math.min(...xs);
796
- const minY = Math.min(...ys);
797
- const maxX = Math.max(...xs);
798
- const maxY = Math.max(...ys);
799
- const width = Math.ceil(maxX - minX);
800
- const height = Math.ceil(maxY - minY);
888
+ if (!xs.length || !ys.length)
889
+ throw driverError("page-error", "DOM.getBoxModel returned an empty content quad");
890
+ const minX = Math.min(...xs), minY = Math.min(...ys);
891
+ const width = Math.ceil(Math.max(...xs) - minX), height = Math.ceil(Math.max(...ys) - minY);
801
892
  if (width <= 0 || height <= 0)
802
- throw new CdpError("resolved element has zero area");
803
- return { x: minX, y: minY, width, height, scale: 1 };
804
- }
805
- async function takeScreenshot(args = {}) {
806
- const { target, fullPage, uid, selector, savePath, returnBase64 } = args;
807
- if (uid != null && selector)
808
- throw new CdpError("provide exactly one of uid or selector, not both");
809
- const format = args.format ?? "png";
810
- const quality = format === "jpeg" ? args.quality ?? 80 : undefined;
811
- return withPage(target, async (conn, t) => {
812
- await conn.send("Page.enable");
893
+ throw driverError("page-error", "resolved element has zero area");
894
+ return { x: minX, y: minY, width, height };
895
+ }
896
+ var CDP_CAPABILITIES = new Set([
897
+ "trace.performance",
898
+ "heap.snapshot",
899
+ "audit.lighthouse",
900
+ "emulate.cpuThrottling",
901
+ "emulate.mediaFeatures",
902
+ "emulate.deviceMetrics",
903
+ "emulate.networkConditions",
904
+ "screenshot.fullPage",
905
+ "screenshot.element",
906
+ "network.intercept",
907
+ "snapshot.accessibilityTree",
908
+ "input.insertTextAtomic",
909
+ "locate.text",
910
+ "locate.xpath"
911
+ ]);
912
+
913
+ class CdpPageDriver {
914
+ conn;
915
+ browser;
916
+ info;
917
+ enabledDomains = new Set;
918
+ released = false;
919
+ constructor(conn, target, browser) {
920
+ this.conn = conn;
921
+ this.browser = browser;
922
+ this.info = { id: target.id, url: target.url, title: target.title, type: target.type };
923
+ }
924
+ async ensureDomain(name) {
925
+ if (this.enabledDomains.has(name))
926
+ return;
927
+ this.enabledDomains.add(name);
928
+ await this.conn.send(`${name}.enable`);
929
+ }
930
+ async ensureDomainSoft(name) {
931
+ if (this.enabledDomains.has(name))
932
+ return;
933
+ this.enabledDomains.add(name);
934
+ await this.conn.send(`${name}.enable`).catch(() => {
935
+ return;
936
+ });
937
+ }
938
+ async navigate(opts) {
939
+ await this.ensureDomain("Page");
940
+ const reload = opts.reload === true;
941
+ if (!reload && !opts.url)
942
+ throw driverError("page-error", "navigate: 'url' is required unless reload:true");
943
+ const waitUntil = opts.waitUntil ?? "load";
944
+ const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
945
+ const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
946
+ return;
947
+ });
948
+ const stopped = this.conn.waitFor("Page.frameStoppedLoading", undefined, opts.timeoutMs).then(() => "committed").catch(() => {
949
+ return;
950
+ });
951
+ let url, contextId, reloaded;
952
+ if (reload) {
953
+ await this.conn.send("Page.reload", { ignoreCache: opts.ignoreCache === true }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
954
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
955
+ return;
956
+ });
957
+ contextId = tree?.frameTree?.frame?.id ?? "";
958
+ url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
959
+ reloaded = true;
960
+ } else {
961
+ const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
962
+ if (nav.errorText)
963
+ throw driverError("page-error", `navigate: ${nav.errorText} (${opts.url})`);
964
+ contextId = nav.frameId;
965
+ url = opts.url;
966
+ }
967
+ const settled = await Promise.race([milestone, stopped]);
968
+ return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
969
+ }
970
+ async waitForText(text, timeoutMs = 15000, pollMs = 250) {
971
+ await this.ensureDomain("Runtime");
972
+ const start = Date.now();
973
+ const expr = `(() => { const b = document.body; return !!b && typeof b.innerText === 'string' && b.innerText.includes(${JSON.stringify(text)}); })()`;
974
+ for (;; ) {
975
+ const { result } = await this.conn.send("Runtime.evaluate", { expression: expr, returnByValue: true });
976
+ if (result?.value === true)
977
+ return { found: true, elapsedMs: Date.now() - start };
978
+ if (Date.now() - start >= timeoutMs)
979
+ throw driverError("timeout", `waitForText: '${text}' not found within ${timeoutMs}ms`);
980
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, Math.max(0, timeoutMs - (Date.now() - start)))));
981
+ }
982
+ }
983
+ async callFn(objectId, functionDeclaration, args, awaitPromise) {
984
+ const res = await this.conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration, arguments: args.map((value) => ({ value })), returnByValue: true, awaitPromise });
985
+ if (res.exceptionDetails)
986
+ throw driverError("page-error", exceptionMessage(res.exceptionDetails));
987
+ return unwrap(res.result);
988
+ }
989
+ async evaluate(expression, opts) {
990
+ await this.ensureDomain("Runtime");
991
+ const awaitPromise = opts?.awaitPromise ?? true;
992
+ if (opts?.args && opts.args.length > 0) {
993
+ const globalObj = await this.conn.send("Runtime.evaluate", { expression: "globalThis", returnByValue: false });
994
+ const objectId = globalObj.result.objectId;
995
+ if (!objectId)
996
+ throw driverError("page-error", "could not resolve global object for function call");
997
+ return this.callFn(objectId, expression, opts.args, awaitPromise);
998
+ }
999
+ const res = await this.conn.send("Runtime.evaluate", { expression, returnByValue: true, awaitPromise });
1000
+ if (res.exceptionDetails)
1001
+ throw driverError("page-error", exceptionMessage(res.exceptionDetails));
1002
+ return unwrap(res.result);
1003
+ }
1004
+ async callOnElement(loc, functionDeclaration, args = []) {
1005
+ const { objectId } = await resolveElementLocator(this.conn, loc);
1006
+ return this.callFn(objectId, functionDeclaration, args, true);
1007
+ }
1008
+ async snapshot(opts) {
1009
+ const interactiveOnly = opts?.interactiveOnly ?? false;
1010
+ await this.ensureDomain("Accessibility");
1011
+ const { nodes } = await this.conn.send("Accessibility.getFullAXTree");
1012
+ const byId = new Map;
1013
+ for (const n of nodes)
1014
+ byId.set(n.nodeId, n);
1015
+ const roots = nodes.filter((n) => !n.parentId || !byId.has(n.parentId));
1016
+ const out = [];
1017
+ const walk = (node, depth) => {
1018
+ const role = axString(node.role);
1019
+ const name = axString(node.name);
1020
+ const backendNodeId = node.backendDOMNodeId;
1021
+ const emitted = !node.ignored && backendNodeId !== undefined && (interactiveOnly ? isInteractive(role) : !!(role || name));
1022
+ if (emitted) {
1023
+ const extras = axExtras(node);
1024
+ out.push({ uid: encodeUid(backendNodeId), role: role ?? "generic", depth, ...name ? { name } : {}, ...Object.keys(extras).length ? { extras } : {} });
1025
+ }
1026
+ const childDepth = emitted && !interactiveOnly ? depth + 1 : depth;
1027
+ for (const childId of node.childIds ?? []) {
1028
+ const child = byId.get(childId);
1029
+ if (child)
1030
+ walk(child, childDepth);
1031
+ }
1032
+ };
1033
+ for (const root of roots)
1034
+ walk(root, 0);
1035
+ return out;
1036
+ }
1037
+ async locate(loc) {
1038
+ return encodeUid(await backendNodeIdOf(this.conn, loc));
1039
+ }
1040
+ async resolve(uid) {
1041
+ await resolveUid(this.conn, decodeUid(uid)).catch(() => {
1042
+ throw driverError("stale-uid", `uid does not resolve: ${uid}`);
1043
+ });
1044
+ }
1045
+ async hover(loc) {
1046
+ const { objectId } = await resolveElementLocator(this.conn, loc);
1047
+ const { x, y } = await centerOf(this.conn, objectId);
1048
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1049
+ return { x, y };
1050
+ }
1051
+ async click(loc, opts) {
1052
+ const button = opts?.button ?? "left";
1053
+ const clickCount = opts?.clickCount ?? 1;
1054
+ const { x, y } = await this.hover(loc);
1055
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1056
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1057
+ return { x, y };
1058
+ }
1059
+ async drag(from, to) {
1060
+ const fromPt = await this.hover(from);
1061
+ const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1062
+ const toPt = await centerOf(this.conn, dstId);
1063
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1064
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1065
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1066
+ await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1067
+ return { from: fromPt, to: toPt };
1068
+ }
1069
+ async setValue(loc, value) {
1070
+ const { objectId } = await resolveElementLocator(this.conn, loc);
1071
+ await setValueOnObject(this.conn, objectId, value);
1072
+ }
1073
+ async typeText(loc, text) {
1074
+ const { objectId } = await resolveElementLocator(this.conn, loc);
1075
+ await focusElement(this.conn, objectId);
1076
+ if (text.length)
1077
+ await this.conn.send("Input.insertText", { text });
1078
+ }
1079
+ async pressKey(press) {
1080
+ const spec = resolveKey(press.key);
1081
+ let modBits = 0;
1082
+ for (const m of press.modifiers ?? []) {
1083
+ const bit = MODIFIER_BITS[m.toLowerCase()];
1084
+ if (bit === undefined)
1085
+ throw driverError("page-error", `unknown modifier '${m}'`);
1086
+ modBits |= bit;
1087
+ }
1088
+ const suppressText = (modBits & ~MODIFIER_BITS.shift) !== 0;
1089
+ const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1090
+ if (spec.text && !suppressText)
1091
+ down.text = spec.text;
1092
+ await this.conn.send("Input.dispatchKeyEvent", down);
1093
+ await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1094
+ }
1095
+ async setFiles(loc, files) {
1096
+ const { objectId } = await resolveElementLocator(this.conn, loc);
1097
+ await this.conn.send("DOM.setFileInputFiles", { files: [...files], objectId });
1098
+ }
1099
+ async screenshot(opts) {
1100
+ await this.ensureDomain("Page");
1101
+ const format = opts?.format ?? "png";
1102
+ const quality = format === "jpeg" ? opts?.quality ?? 80 : undefined;
813
1103
  const params = { format, captureBeyondViewport: true };
814
1104
  if (quality != null)
815
1105
  params.quality = quality;
816
- if (uid != null || selector) {
817
- params.clip = await elementClip(conn, uid, selector);
818
- } else if (fullPage) {
819
- params.clip = await fullPageClip(conn);
820
- }
821
- const { data } = await conn.send("Page.captureScreenshot", params);
822
- const buf = Buffer.from(data, "base64");
823
- if (buf.byteLength === 0)
824
- throw new CdpError("Page.captureScreenshot returned empty data");
825
- await mkdir2(ARTIFACT_DIR, { recursive: true });
826
- const ext = format === "jpeg" ? "jpg" : "png";
827
- const path = savePath ?? join(ARTIFACT_DIR, `screenshot-${shortId(t.id)}-${stamp()}.${ext}`);
828
- await writeFile2(path, buf);
829
- const result = {
830
- path,
831
- bytes: buf.byteLength,
832
- format,
833
- target: { id: t.id, url: t.url, title: t.title }
834
- };
835
- if (returnBase64)
836
- result.base64 = data;
837
- return result;
838
- });
839
- }
840
-
841
- // src/tools/emulation.ts
842
- async function clearAll(conn) {
843
- await conn.send("Emulation.clearDeviceMetricsOverride").catch(() => {
844
- return;
845
- });
846
- await conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(() => {
847
- return;
848
- });
849
- await conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(() => {
850
- return;
851
- });
852
- await conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(() => {
853
- return;
854
- });
855
- await conn.send("Network.enable").catch(() => {
856
- return;
857
- });
858
- await conn.send("Network.emulateNetworkConditions", {
859
- offline: false,
860
- latency: 0,
861
- downloadThroughput: -1,
862
- uploadThroughput: -1
863
- }).catch(() => {
864
- return;
865
- });
866
- }
867
- async function emulate(args = {}) {
868
- const { target } = args;
869
- return withPage(target, async (conn, t) => {
870
- if (args.clearOverrides) {
871
- await clearAll(conn);
872
- return {
873
- target: { id: t.id, url: t.url, title: t.title },
874
- applied: [],
875
- cleared: true
1106
+ if (opts?.clip)
1107
+ params.clip = { ...await elementClip(this.conn, opts.clip), scale: 1 };
1108
+ else if (opts?.fullPage)
1109
+ params.clip = { ...await fullPageClip(this.conn), scale: 1 };
1110
+ const { data } = await this.conn.send("Page.captureScreenshot", params);
1111
+ return { data: new Uint8Array(Buffer.from(data, "base64")), format };
1112
+ }
1113
+ async emulate(opts) {
1114
+ if (opts.clearOverrides) {
1115
+ const c = () => {
1116
+ return;
876
1117
  };
1118
+ await this.conn.send("Emulation.clearDeviceMetricsOverride").catch(c);
1119
+ await this.conn.send("Emulation.setUserAgentOverride", { userAgent: "" }).catch(c);
1120
+ await this.conn.send("Emulation.setCPUThrottlingRate", { rate: 1 }).catch(c);
1121
+ await this.conn.send("Emulation.setEmulatedMedia", { media: "", features: [] }).catch(c);
1122
+ await this.ensureDomainSoft("Network");
1123
+ await this.conn.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }).catch(c);
1124
+ return { applied: [] };
877
1125
  }
878
1126
  const applied = [];
879
- if (args.width != null || args.height != null) {
880
- if (args.width == null || args.height == null) {
881
- throw new CdpError("emulate device metrics require both width and height");
1127
+ if (opts.width != null || opts.height != null) {
1128
+ if (opts.width == null || opts.height == null) {
1129
+ throw driverError("page-error", "emulate device metrics require both width and height");
882
1130
  }
883
- await conn.send("Emulation.setDeviceMetricsOverride", {
884
- width: args.width,
885
- height: args.height,
886
- deviceScaleFactor: args.deviceScaleFactor ?? 0,
887
- mobile: args.mobile ?? false
1131
+ await this.conn.send("Emulation.setDeviceMetricsOverride", {
1132
+ width: opts.width,
1133
+ height: opts.height,
1134
+ deviceScaleFactor: opts.deviceScaleFactor ?? 0,
1135
+ mobile: opts.mobile ?? false
888
1136
  });
889
1137
  applied.push("deviceMetrics");
890
1138
  }
891
- if (args.userAgent != null) {
892
- await conn.send("Emulation.setUserAgentOverride", { userAgent: args.userAgent });
1139
+ if (opts.userAgent != null) {
1140
+ await this.conn.send("Emulation.setUserAgentOverride", { userAgent: opts.userAgent });
893
1141
  applied.push("userAgent");
894
1142
  }
895
- if (args.cpuThrottlingRate != null) {
896
- if (args.cpuThrottlingRate < 1)
897
- throw new CdpError("cpuThrottlingRate must be >= 1");
898
- await conn.send("Emulation.setCPUThrottlingRate", { rate: args.cpuThrottlingRate });
1143
+ if (opts.cpuThrottlingRate != null) {
1144
+ if (opts.cpuThrottlingRate < 1)
1145
+ throw driverError("page-error", "cpuThrottlingRate must be >= 1");
1146
+ await this.conn.send("Emulation.setCPUThrottlingRate", { rate: opts.cpuThrottlingRate });
899
1147
  applied.push("cpuThrottlingRate");
900
1148
  }
901
- if (args.media != null || args.mediaFeatures != null) {
902
- await conn.send("Emulation.setEmulatedMedia", {
903
- media: args.media ?? "",
904
- features: args.mediaFeatures ?? []
905
- });
1149
+ if (opts.media != null || opts.mediaFeatures != null) {
1150
+ await this.conn.send("Emulation.setEmulatedMedia", { media: opts.media ?? "", features: opts.mediaFeatures ?? [] });
906
1151
  applied.push("emulatedMedia");
907
1152
  }
908
- if (args.networkConditions != null) {
909
- const n = args.networkConditions;
910
- await conn.send("Network.enable").catch(() => {
911
- return;
912
- });
913
- await conn.send("Network.emulateNetworkConditions", {
1153
+ if (opts.networkConditions != null) {
1154
+ const n = opts.networkConditions;
1155
+ await this.ensureDomainSoft("Network");
1156
+ await this.conn.send("Network.emulateNetworkConditions", {
914
1157
  offline: n.offline ?? false,
915
1158
  latency: n.latency ?? 0,
916
1159
  downloadThroughput: n.downloadThroughput ?? -1,
@@ -919,95 +1162,542 @@ async function emulate(args = {}) {
919
1162
  });
920
1163
  applied.push("networkConditions");
921
1164
  }
922
- if (applied.length === 0) {
923
- throw new CdpError("emulate: no overrides specified (pass clearOverrides:true to reset)");
1165
+ return { applied };
1166
+ }
1167
+ static EVENT_MAP = {
1168
+ navigated: { method: "Page.frameNavigated" },
1169
+ console: { method: "Runtime.consoleAPICalled" },
1170
+ "network.request": { method: "Network.requestWillBeSent", domain: "Network" },
1171
+ "network.response": { method: "Network.responseReceived", domain: "Network" },
1172
+ dialog: { method: "Page.javascriptDialogOpening" },
1173
+ "page.closed": { method: "Inspector.detached", domain: "Inspector" }
1174
+ };
1175
+ on(event, handler) {
1176
+ const spec = CdpPageDriver.EVENT_MAP[event];
1177
+ if (spec.domain && !this.enabledDomains.has(spec.domain)) {
1178
+ this.enabledDomains.add(spec.domain);
1179
+ this.conn.send(`${spec.domain}.enable`);
924
1180
  }
925
- return { target: { id: t.id, url: t.url, title: t.title }, applied };
1181
+ return this.conn.on(spec.method, (params) => handler(params));
1182
+ }
1183
+ async waitForEvent(event, predicate, timeoutMs) {
1184
+ const spec = CdpPageDriver.EVENT_MAP[event];
1185
+ if (spec.domain)
1186
+ await this.ensureDomain(spec.domain);
1187
+ return this.conn.waitFor(spec.method, predicate, timeoutMs);
1188
+ }
1189
+ armDialog(accept, promptText, onHandled) {
1190
+ return this.conn.on("Page.javascriptDialogOpening", (params) => {
1191
+ const p = params;
1192
+ this.conn.send("Page.handleJavaScriptDialog", { accept, ...promptText !== undefined ? { promptText } : {} }).then(() => onHandled({
1193
+ type: p.type,
1194
+ message: p.message,
1195
+ url: p.url,
1196
+ ...p.defaultPrompt !== undefined ? { defaultPrompt: p.defaultPrompt } : {},
1197
+ accept,
1198
+ ...promptText !== undefined ? { promptText } : {},
1199
+ handled: true
1200
+ })).catch(() => {
1201
+ return;
1202
+ });
1203
+ });
1204
+ }
1205
+ async handleDialog(accept, promptText, opts) {
1206
+ await this.ensureDomain("Page");
1207
+ if (opts?.autoMs !== undefined) {
1208
+ const handled = [];
1209
+ const off = this.armDialog(accept, promptText, (d) => handled.push(d));
1210
+ await new Promise((resolve) => setTimeout(resolve, opts.autoMs));
1211
+ off();
1212
+ return { handled, count: handled.length };
1213
+ }
1214
+ const timeoutMs = opts?.timeoutMs ?? 15000;
1215
+ return new Promise((resolve, reject) => {
1216
+ const timer = setTimeout(() => {
1217
+ off();
1218
+ reject(driverError("timeout", `handleDialog: no dialog opened within ${timeoutMs}ms`));
1219
+ }, timeoutMs);
1220
+ const off = this.armDialog(accept, promptText, (d) => {
1221
+ clearTimeout(timer);
1222
+ off();
1223
+ resolve(d);
1224
+ });
1225
+ });
1226
+ }
1227
+ async startBodyCapture() {
1228
+ await this.ensureDomain("Network");
1229
+ return async () => {
1230
+ await this.conn.send("Network.disable").catch(() => {
1231
+ return;
1232
+ });
1233
+ this.enabledDomains.delete("Network");
1234
+ };
1235
+ }
1236
+ async getResponseBody(requestId) {
1237
+ return this.conn.send("Network.getResponseBody", { requestId });
1238
+ }
1239
+ async intercept(rules) {
1240
+ await this.conn.send("Fetch.enable", { patterns: rules.map((r) => ({ urlPattern: r.urlPattern })) });
1241
+ let chain = Promise.resolve();
1242
+ const off = this.conn.on("Fetch.requestPaused", (params) => {
1243
+ const p = params;
1244
+ chain = chain.then(async () => {
1245
+ const rule = selectRule(rules, p.request.url, p.request.method);
1246
+ if (!rule) {
1247
+ await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
1248
+ return;
1249
+ }
1250
+ if (rule.delayMs)
1251
+ await new Promise((r) => setTimeout(r, rule.delayMs));
1252
+ const act = effectiveAction(rule, Math.random());
1253
+ if (act === "fulfill")
1254
+ await this.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
1255
+ else if (act === "fail")
1256
+ await this.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
1257
+ else
1258
+ await this.conn.send("Fetch.continueRequest", { requestId: p.requestId });
1259
+ }).catch(() => {
1260
+ return;
1261
+ });
1262
+ });
1263
+ return async () => {
1264
+ off();
1265
+ await this.conn.send("Fetch.disable").catch(() => {
1266
+ return;
1267
+ });
1268
+ };
1269
+ }
1270
+ async release() {
1271
+ if (this.released)
1272
+ return;
1273
+ this.released = true;
1274
+ this.conn.close();
1275
+ }
1276
+ }
1277
+ async function withBrowserConn(fn) {
1278
+ const conn = await openBrowser();
1279
+ try {
1280
+ return await fn(conn);
1281
+ } finally {
1282
+ conn.close();
1283
+ }
1284
+ }
1285
+ function noSuchTarget(e) {
1286
+ throw driverError("no-such-target", e instanceof CdpError ? e.message : String(e));
1287
+ }
1288
+
1289
+ class CdpBrowserDriver {
1290
+ scheme = "cdp";
1291
+ lifetime = "per-call";
1292
+ capabilities = CDP_CAPABILITIES;
1293
+ uidStability = "browser-node";
1294
+ snapshotFidelity = "accessibility-tree";
1295
+ async listPages(opts) {
1296
+ const targets = await listTargets();
1297
+ const filtered = opts?.all ? targets : targets.filter((t) => t.type === "page");
1298
+ return filtered.map((t) => ({ id: t.id, url: t.url, title: t.title, type: t.type }));
1299
+ }
1300
+ async newPage(url) {
1301
+ return withBrowserConn(async (conn) => {
1302
+ const { targetId } = await conn.send("Target.createTarget", { url: url ?? "about:blank" });
1303
+ if (!targetId)
1304
+ throw driverError("page-error", "Target.createTarget returned no targetId");
1305
+ return { id: targetId, url: url ?? "about:blank", title: "" };
1306
+ });
1307
+ }
1308
+ async closePage(id) {
1309
+ return withBrowserConn(async (conn) => {
1310
+ const res = await conn.send("Target.closeTarget", { targetId: id });
1311
+ return { success: res.success ?? true };
1312
+ });
1313
+ }
1314
+ async activatePage(id) {
1315
+ const target = await resolveTarget(id).catch(noSuchTarget);
1316
+ await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
1317
+ return { id: target.id, url: target.url, title: target.title };
1318
+ }
1319
+ async page(selector) {
1320
+ const { conn, target } = await openPage(selector).catch(noSuchTarget);
1321
+ return new CdpPageDriver(conn, target, this);
1322
+ }
1323
+ async dispose() {}
1324
+ }
1325
+ function createCdpDriver() {
1326
+ return new CdpBrowserDriver;
1327
+ }
1328
+
1329
+ // src/leases-tools.ts
1330
+ class LeaseToolError extends Error {
1331
+ }
1332
+ function backendOf(driver) {
1333
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
1334
+ }
1335
+ async function claimPage(driver, args = {}) {
1336
+ const backend = backendOf(driver);
1337
+ const label = typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`;
1338
+ const pages = await driver.listPages();
1339
+ let targetId = args.targetId;
1340
+ let url = args.url ?? "about:blank";
1341
+ if (targetId === undefined || targetId === "") {
1342
+ const page = await driver.newPage(args.url);
1343
+ targetId = page.id;
1344
+ url = page.url;
1345
+ } else {
1346
+ const existing = pages.find((p) => p.id === targetId);
1347
+ if (!existing)
1348
+ throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
1349
+ url = existing.url;
1350
+ }
1351
+ const liveIds = [...pages.map((p) => p.id), targetId];
1352
+ const { record, token } = await claimLease(backend, targetId, {
1353
+ label,
1354
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1355
+ liveIds
1356
+ });
1357
+ return {
1358
+ lease: token,
1359
+ targetId,
1360
+ url,
1361
+ label: record.label,
1362
+ ttlMs: record.ttlMs,
1363
+ expiresAt: record.lastUsedAt + record.ttlMs
1364
+ };
1365
+ }
1366
+ async function releasePage(_driver, args) {
1367
+ if (typeof args.lease !== "string" || !args.lease.length) {
1368
+ throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
1369
+ }
1370
+ return releaseLease(args.lease);
1371
+ }
1372
+ async function listLeasesTool(driver, _args = {}) {
1373
+ const liveIds = (await driver.listPages()).map((p) => p.id);
1374
+ const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
1375
+ return { leases, count: leases.length };
1376
+ }
1377
+ var LEASE_TOOLS = {
1378
+ claim_page: claimPage,
1379
+ release_page: releasePage,
1380
+ list_leases: listLeasesTool
1381
+ };
1382
+
1383
+ // src/shared-tools.ts
1384
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
1385
+ import { join as join2 } from "node:path";
1386
+ var ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1387
+ var STATE_DIR = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
1388
+
1389
+ class SharedToolError extends Error {
1390
+ }
1391
+ function target3(p) {
1392
+ return { id: p.id, url: p.url, title: p.title };
1393
+ }
1394
+ function backendOf2(driver) {
1395
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
1396
+ }
1397
+ async function withPage2(driver, target, fn) {
1398
+ const page = await driver.page(target);
1399
+ try {
1400
+ return await fn(page);
1401
+ } finally {
1402
+ await page.release();
1403
+ }
1404
+ }
1405
+ async function resolvePage(driver, selector) {
1406
+ const pages = await driver.listPages();
1407
+ const hit = await pickPage(driver, pages, selector);
1408
+ await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
1409
+ return hit;
1410
+ }
1411
+ async function pickPage(driver, pages, selector) {
1412
+ if (selector === undefined || selector === "active") {
1413
+ if (!pages.length)
1414
+ throw new SharedToolError("no page targets open");
1415
+ return pages[0];
1416
+ }
1417
+ if (selector.startsWith("index:")) {
1418
+ const p = pages[Number(selector.slice(6))];
1419
+ if (!p)
1420
+ throw new SharedToolError(`no page target at index ${selector.slice(6)} (have ${pages.length})`);
1421
+ return p;
1422
+ }
1423
+ if (selector.startsWith("url:")) {
1424
+ const needle = selector.slice(4);
1425
+ const p = pages.find((x) => x.url.includes(needle));
1426
+ if (!p)
1427
+ throw new SharedToolError(`no page url containing '${needle}'`);
1428
+ return p;
1429
+ }
1430
+ if (selector.startsWith("title:")) {
1431
+ const needle = selector.slice(6);
1432
+ const p = pages.find((x) => x.title.includes(needle));
1433
+ if (!p)
1434
+ throw new SharedToolError(`no page title containing '${needle}'`);
1435
+ return p;
1436
+ }
1437
+ const exactPage = pages.find((x) => x.id === selector);
1438
+ if (exactPage)
1439
+ return exactPage;
1440
+ const all = await driver.listPages({ all: true });
1441
+ const exact = all.find((x) => x.id === selector);
1442
+ if (exact)
1443
+ return exact;
1444
+ throw new SharedToolError(`no target with id '${selector}'`);
1445
+ }
1446
+ function locatorOf(args) {
1447
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
1448
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
1449
+ if (hasUid === hasSelector)
1450
+ throw new SharedToolError("provide exactly one of { uid } or { selector }");
1451
+ return hasUid ? { uid: args.uid } : { css: args.selector };
1452
+ }
1453
+ async function listPages(driver, args = {}) {
1454
+ const pages = await driver.listPages({ all: args.all });
1455
+ return { pages, count: pages.length };
1456
+ }
1457
+ async function newPage(driver, args = {}) {
1458
+ const p = await driver.newPage(args.url);
1459
+ if (args.claim !== true)
1460
+ return { targetId: p.id, url: p.url };
1461
+ const { record, token } = await claimLease(backendOf2(driver), p.id, {
1462
+ label: typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`,
1463
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
1464
+ });
1465
+ return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1466
+ }
1467
+ async function closePage(driver, args) {
1468
+ if (args.target === undefined || args.target === "")
1469
+ throw new SharedToolError("close_page requires an explicit target; refusing to guess which page to close");
1470
+ const p = await resolvePage(driver, args.target);
1471
+ const res = await driver.closePage(p.id);
1472
+ if (!res.success)
1473
+ return { closed: p.id, success: false, leaseReleased: false };
1474
+ const released = await releaseLeaseFor(backendOf2(driver), p.id);
1475
+ return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
1476
+ }
1477
+ async function writeSelectedFile(id) {
1478
+ await mkdir2(STATE_DIR, { recursive: true });
1479
+ await writeFile2(join2(STATE_DIR, "selected"), id, "utf8");
1480
+ }
1481
+ async function selectPage(driver, args) {
1482
+ if (args.target === undefined || args.target === "")
1483
+ throw new SharedToolError("select_page requires an explicit target");
1484
+ const p = await resolvePage(driver, args.target);
1485
+ const activated = await driver.activatePage(p.id);
1486
+ await writeSelectedFile(activated.id);
1487
+ return { selected: activated.id };
1488
+ }
1489
+ function toLegacyWaitedFor(w) {
1490
+ if (w === "committed")
1491
+ return "frameStoppedLoading";
1492
+ if (w === "timeout")
1493
+ return "navigate-only";
1494
+ return w;
1495
+ }
1496
+ async function navigatePage(driver, args) {
1497
+ const reload = args.reload === true;
1498
+ if (!reload && (!args.url || typeof args.url !== "string")) {
1499
+ throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
1500
+ }
1501
+ return withPage2(driver, args.target, async (page) => {
1502
+ const r = await page.navigate(args);
1503
+ return {
1504
+ url: r.url,
1505
+ frameId: r.contextId,
1506
+ ...r.reloaded ? { reloaded: r.reloaded } : {},
1507
+ waitedFor: toLegacyWaitedFor(r.waitedFor)
1508
+ };
1509
+ });
1510
+ }
1511
+ async function waitForText(driver, args) {
1512
+ if (typeof args.text !== "string" || args.text.length === 0)
1513
+ throw new SharedToolError("wait_for: 'text' is required");
1514
+ return withPage2(driver, args.target, async (page) => {
1515
+ const r = await page.waitForText(args.text, args.timeoutMs ?? 15000, args.pollMs ?? 250);
1516
+ return { found: true, waitedMs: r.elapsedMs };
1517
+ });
1518
+ }
1519
+ async function evaluateScript(driver, args) {
1520
+ if (typeof args.expression !== "string" || args.expression.length === 0) {
1521
+ throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
1522
+ }
1523
+ return withPage2(driver, args.target, (page) => page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true }));
1524
+ }
1525
+ var BARE_TOKEN_EXTRAS = new Set(["disabled", "focused"]);
1526
+ var UNQUOTED_EXTRAS = new Set(["checked", "expanded", "url"]);
1527
+ function renderSnapshotLine(n, scheme) {
1528
+ const indent = " ".repeat(n.depth);
1529
+ const uidText = scheme === "cdp" && n.uid.startsWith("cdp:") ? n.uid.slice(4) : n.uid;
1530
+ const name = n.name !== undefined ? ` ${JSON.stringify(n.name)}` : "";
1531
+ const extrasToks = [];
1532
+ if (n.extras) {
1533
+ for (const [k, v] of Object.entries(n.extras)) {
1534
+ if (BARE_TOKEN_EXTRAS.has(k))
1535
+ extrasToks.push(k);
1536
+ else if (UNQUOTED_EXTRAS.has(k))
1537
+ extrasToks.push(`${k}=${v}`);
1538
+ else
1539
+ extrasToks.push(`${k}=${JSON.stringify(v)}`);
1540
+ }
1541
+ }
1542
+ const extra = extrasToks.length ? ` [${extrasToks.join(" ")}]` : "";
1543
+ return `${indent}[${uidText}] ${n.role}${name}${extra}`;
1544
+ }
1545
+ async function takeSnapshot(driver, args = {}) {
1546
+ return withPage2(driver, args.target, async (page) => {
1547
+ const nodes = await page.snapshot({ interactiveOnly: args.interactiveOnly ?? false });
1548
+ const scheme = page.browser.scheme;
1549
+ return { snapshot: nodes.map((n) => renderSnapshotLine(n, scheme)).join(`
1550
+ `), target: target3(page.info), nodeCount: nodes.length };
1551
+ });
1552
+ }
1553
+ async function click(driver, args) {
1554
+ return withPage2(driver, args.target, async (page) => {
1555
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
1556
+ return { clicked: true, x, y };
1557
+ });
1558
+ }
1559
+ async function hover(driver, args) {
1560
+ return withPage2(driver, args.target, async (page) => {
1561
+ const { x, y } = await page.hover(locatorOf(args));
1562
+ return { hovered: true, x, y };
1563
+ });
1564
+ }
1565
+ async function drag(driver, args) {
1566
+ if (!args.from || !args.to)
1567
+ throw new SharedToolError("drag requires { from } and { to }");
1568
+ return withPage2(driver, args.target, async (page) => {
1569
+ const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1570
+ return { dragged: true, from, to };
1571
+ });
1572
+ }
1573
+ async function fill(driver, args) {
1574
+ if (typeof args.value !== "string")
1575
+ throw new SharedToolError("fill requires a string { value }");
1576
+ return withPage2(driver, args.target, async (page) => {
1577
+ await page.setValue(locatorOf(args), args.value);
1578
+ return { filled: true, value: args.value };
1579
+ });
1580
+ }
1581
+ async function fillForm(driver, args) {
1582
+ if (!Array.isArray(args.fields) || args.fields.length === 0)
1583
+ throw new SharedToolError("fill_form requires a non-empty { fields } array");
1584
+ return withPage2(driver, args.target, async (page) => {
1585
+ let filled = 0;
1586
+ for (const f of args.fields) {
1587
+ if (typeof f.value !== "string")
1588
+ throw new SharedToolError("each fill_form field requires a string value");
1589
+ await page.setValue(locatorOf(f), f.value);
1590
+ filled++;
1591
+ }
1592
+ return { filled, fields: args.fields.length };
1593
+ });
1594
+ }
1595
+ async function typeText(driver, args) {
1596
+ if (typeof args.text !== "string")
1597
+ throw new SharedToolError("type_text requires a string { text }");
1598
+ return withPage2(driver, args.target, async (page) => {
1599
+ await page.typeText(locatorOf(args), args.text);
1600
+ return { typed: true, text: args.text };
1601
+ });
1602
+ }
1603
+ async function pressKey(driver, args) {
1604
+ if (typeof args.key !== "string" || !args.key.length)
1605
+ throw new SharedToolError("press_key requires a non-empty { key }");
1606
+ const modifiers = args.modifiers ?? [];
1607
+ return withPage2(driver, args.target, async (page) => {
1608
+ await page.pressKey({ key: args.key, modifiers });
1609
+ return { pressed: args.key, modifiers };
1610
+ });
1611
+ }
1612
+ async function uploadFile(driver, args) {
1613
+ const files = Array.isArray(args.files) ? args.files : [args.files];
1614
+ if (!files.length || files.some((f) => typeof f !== "string" || !f.length)) {
1615
+ throw new SharedToolError("upload_file requires { files } as a non-empty path or array of paths");
1616
+ }
1617
+ return withPage2(driver, args.target, async (page) => {
1618
+ await page.setFiles(locatorOf(args), files);
1619
+ return { uploaded: files };
926
1620
  });
927
1621
  }
928
- async function resizePage(args) {
929
- const { target, width, height } = args;
930
- if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
931
- throw new CdpError("resize_page requires positive numeric width and height");
932
- }
933
- return withPage(target, async (conn, t) => {
934
- await conn.send("Emulation.setDeviceMetricsOverride", {
935
- width,
936
- height,
937
- deviceScaleFactor: args.deviceScaleFactor ?? 0,
938
- mobile: args.mobile ?? false
939
- });
940
- await conn.send("Runtime.enable").catch(() => {
941
- return;
942
- });
943
- const { result } = await conn.send("Runtime.evaluate", {
944
- expression: "({ w: window.innerWidth, h: window.innerHeight })",
945
- returnByValue: true
946
- });
947
- const v = result.value;
948
- return {
949
- target: { id: t.id, url: t.url, title: t.title },
950
- width,
951
- height,
952
- innerWidth: v?.w ?? width,
953
- innerHeight: v?.h ?? height
1622
+ function stamp() {
1623
+ return new Date(Date.now()).toISOString().replace(/[:.]/g, "-");
1624
+ }
1625
+ async function takeScreenshot(driver, args = {}) {
1626
+ if (args.uid !== undefined && args.uid !== "" && args.selector)
1627
+ throw new SharedToolError("provide exactly one of uid or selector, not both");
1628
+ const format = args.format ?? "png";
1629
+ return withPage2(driver, args.target, async (page) => {
1630
+ const clip = args.uid !== undefined || args.selector ? locatorOf(args) : undefined;
1631
+ const { data, format: outFormat } = await page.screenshot({ format, quality: args.quality, fullPage: args.fullPage, ...clip ? { clip } : {} });
1632
+ if (data.byteLength === 0)
1633
+ throw new SharedToolError("captureScreenshot returned empty data");
1634
+ await mkdir2(ARTIFACT_DIR, { recursive: true });
1635
+ const ext = outFormat === "jpeg" ? "jpg" : "png";
1636
+ const path = args.savePath ?? join2(ARTIFACT_DIR, `screenshot-${page.info.id.slice(0, 8)}-${stamp()}.${ext}`);
1637
+ await writeFile2(path, Buffer.from(data));
1638
+ const result = {
1639
+ path,
1640
+ bytes: data.byteLength,
1641
+ format: outFormat,
1642
+ target: target3(page.info)
954
1643
  };
1644
+ if (args.returnBase64)
1645
+ result.base64 = Buffer.from(data).toString("base64");
1646
+ return result;
955
1647
  });
956
1648
  }
957
-
958
- // src/tools/dialogs.ts
959
- function armDialogHandler(conn, accept, promptText, onHandled) {
960
- return conn.on("Page.javascriptDialogOpening", (params) => {
961
- const ev = params;
962
- (async () => {
963
- try {
964
- await conn.send("Page.handleJavaScriptDialog", {
965
- accept,
966
- ...promptText !== undefined ? { promptText } : {}
967
- });
968
- onHandled({
969
- type: ev.type,
970
- message: ev.message,
971
- url: ev.url,
972
- ...ev.defaultPrompt !== undefined ? { defaultPrompt: ev.defaultPrompt } : {},
973
- accept,
974
- ...promptText !== undefined ? { promptText } : {},
975
- handled: true
976
- });
977
- } catch {}
978
- })();
1649
+ async function emulate(driver, args = {}) {
1650
+ return withPage2(driver, args.target, async (page) => {
1651
+ if (args.clearOverrides) {
1652
+ await page.emulate({ clearOverrides: true });
1653
+ return { target: target3(page.info), applied: [], cleared: true };
1654
+ }
1655
+ const { applied } = await page.emulate(args);
1656
+ if (applied.length === 0)
1657
+ throw new SharedToolError("emulate: no overrides specified (pass clearOverrides:true to reset)");
1658
+ return { target: target3(page.info), applied };
979
1659
  });
980
1660
  }
981
- async function handleDialog(args) {
982
- const { target, accept, promptText, timeoutMs = 15000, autoMs } = args;
983
- const { conn } = await openPage(target);
984
- try {
985
- await conn.send("Page.enable");
986
- if (autoMs !== undefined) {
987
- const handled = [];
988
- const disarm = armDialogHandler(conn, accept, promptText, (d) => handled.push(d));
989
- await new Promise((resolve) => setTimeout(resolve, autoMs));
990
- disarm();
991
- return { handled, count: handled.length };
992
- }
993
- return await new Promise((resolve, reject) => {
994
- const timer = setTimeout(() => {
995
- disarm();
996
- reject(new CdpError(`handle_dialog: no dialog opened within ${timeoutMs}ms`));
997
- }, timeoutMs);
998
- const disarm = armDialogHandler(conn, accept, promptText, (d) => {
999
- clearTimeout(timer);
1000
- disarm();
1001
- resolve(d);
1002
- });
1003
- });
1004
- } finally {
1005
- conn.close();
1661
+ async function resizePage(driver, args) {
1662
+ if (!Number.isFinite(args.width) || !Number.isFinite(args.height) || args.width <= 0 || args.height <= 0) {
1663
+ throw new SharedToolError("resize_page requires positive numeric width and height");
1006
1664
  }
1665
+ return withPage2(driver, args.target, async (page) => {
1666
+ await page.emulate({ width: args.width, height: args.height, deviceScaleFactor: args.deviceScaleFactor, mobile: args.mobile });
1667
+ const v = await page.evaluate("({ w: window.innerWidth, h: window.innerHeight })");
1668
+ return { target: target3(page.info), width: args.width, height: args.height, innerWidth: v?.w ?? args.width, innerHeight: v?.h ?? args.height };
1669
+ });
1670
+ }
1671
+ async function handleDialog(driver, args) {
1672
+ if (args.accept === undefined)
1673
+ throw new SharedToolError("handle_dialog requires { accept }");
1674
+ return withPage2(driver, args.target, (page) => page.handleDialog(args.accept, args.promptText, { timeoutMs: args.timeoutMs, autoMs: args.autoMs }));
1007
1675
  }
1676
+ var SHARED_TOOLS = {
1677
+ list_pages: listPages,
1678
+ new_page: newPage,
1679
+ close_page: closePage,
1680
+ select_page: selectPage,
1681
+ navigate_page: navigatePage,
1682
+ wait_for: waitForText,
1683
+ evaluate_script: evaluateScript,
1684
+ take_snapshot: takeSnapshot,
1685
+ click,
1686
+ hover,
1687
+ drag,
1688
+ fill,
1689
+ fill_form: fillForm,
1690
+ type_text: typeText,
1691
+ press_key: pressKey,
1692
+ upload_file: uploadFile,
1693
+ take_screenshot: takeScreenshot,
1694
+ emulate,
1695
+ resize_page: resizePage,
1696
+ handle_dialog: handleDialog
1697
+ };
1008
1698
 
1009
1699
  // src/tools/console.ts
1010
- import { readFile } from "node:fs/promises";
1700
+ import { readFile as readFile2 } from "node:fs/promises";
1011
1701
 
1012
1702
  // src/tools/recorder.ts
1013
1703
  import { mkdir as mkdir3, appendFile, writeFile as writeFile3, copyFile } from "node:fs/promises";
@@ -1177,7 +1867,7 @@ function lineToEntry(line, index) {
1177
1867
  async function readConsoleEntries(file) {
1178
1868
  let raw;
1179
1869
  try {
1180
- raw = await readFile(file, "utf8");
1870
+ raw = await readFile2(file, "utf8");
1181
1871
  } catch {
1182
1872
  return [];
1183
1873
  }
@@ -1233,7 +1923,7 @@ async function getConsoleMessage(args = {}) {
1233
1923
  }
1234
1924
 
1235
1925
  // src/tools/network.ts
1236
- import { readFile as readFile2 } from "node:fs/promises";
1926
+ import { readFile as readFile3 } from "node:fs/promises";
1237
1927
  var DEFAULT_CAPTURE_MS2 = 2500;
1238
1928
  function asHeaders(v) {
1239
1929
  if (!v || typeof v !== "object")
@@ -1300,7 +1990,7 @@ function correlate(lines) {
1300
1990
  async function readNetworkRequests(file) {
1301
1991
  let raw;
1302
1992
  try {
1303
- raw = await readFile2(file, "utf8");
1993
+ raw = await readFile3(file, "utf8");
1304
1994
  } catch {
1305
1995
  return [];
1306
1996
  }
@@ -1382,8 +2072,8 @@ async function getNetworkRequest(args = {}) {
1382
2072
  }
1383
2073
 
1384
2074
  // src/tools/performance.ts
1385
- import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile4, stat, unlink } from "node:fs/promises";
1386
- import { join as join2 } from "node:path";
2075
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4, stat, unlink as unlink2 } from "node:fs/promises";
2076
+ import { join as join3 } from "node:path";
1387
2077
  var ARTIFACT_DIR3 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1388
2078
  var STATE_DIR2 = process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit";
1389
2079
  var DEFAULT_CATEGORIES = [
@@ -1397,11 +2087,11 @@ var liveTraces = new Map;
1397
2087
  function stamp2() {
1398
2088
  return new Date().toISOString().replace(/[:.]/g, "-");
1399
2089
  }
1400
- function shortId2(id) {
2090
+ function shortId(id) {
1401
2091
  return id.slice(0, 8);
1402
2092
  }
1403
2093
  function stateFilePath() {
1404
- return join2(STATE_DIR2, "trace-state.json");
2094
+ return join3(STATE_DIR2, "trace-state.json");
1405
2095
  }
1406
2096
  function bufferTraceData(conn, sink) {
1407
2097
  return conn.on("Tracing.dataCollected", (params) => {
@@ -1424,7 +2114,7 @@ async function endTrace(conn, sink, timeoutMs = 30000) {
1424
2114
  }
1425
2115
  async function writeTrace(events, targetId) {
1426
2116
  await mkdir4(ARTIFACT_DIR3, { recursive: true });
1427
- const path = join2(ARTIFACT_DIR3, `trace-${shortId2(targetId)}-${stamp2()}.json`);
2117
+ const path = join3(ARTIFACT_DIR3, `trace-${shortId(targetId)}-${stamp2()}.json`);
1428
2118
  const body = JSON.stringify({ traceEvents: events, metadata: { source: "cdp-toolkit" } });
1429
2119
  await writeFile4(path, body, "utf8");
1430
2120
  const { size } = await stat(path);
@@ -1435,7 +2125,7 @@ async function performanceStartTrace(args = {}) {
1435
2125
  const { conn, target } = await openPage(args.target);
1436
2126
  if (liveTraces.has(target.id)) {
1437
2127
  conn.close();
1438
- throw new CdpError(`a trace is already in progress for target ${shortId2(target.id)}; call performanceStopTrace first`);
2128
+ throw new CdpError(`a trace is already in progress for target ${shortId(target.id)}; call performanceStopTrace first`);
1439
2129
  }
1440
2130
  const events = [];
1441
2131
  bufferTraceData(conn, events);
@@ -1467,9 +2157,9 @@ async function performanceStopTrace(args = {}) {
1467
2157
  if (!entry) {
1468
2158
  let hint = "";
1469
2159
  try {
1470
- const raw = await readFile3(stateFilePath(), "utf8");
2160
+ const raw = await readFile4(stateFilePath(), "utf8");
1471
2161
  const state = JSON.parse(raw);
1472
- hint = state.pid === process.pid ? ` (state file lists target ${shortId2(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
2162
+ hint = state.pid === process.pid ? ` (state file lists target ${shortId(state.targetId)} but its live connection is gone)` : ` (trace was started by a DIFFERENT process pid=${state.pid}; live recordings cannot cross process boundaries)`;
1473
2163
  } catch {}
1474
2164
  throw new CdpError(`no in-process trace to stop${hint}. Use performanceTrace for a robust single-call trace.`);
1475
2165
  }
@@ -1479,7 +2169,7 @@ async function performanceStopTrace(args = {}) {
1479
2169
  } finally {
1480
2170
  conn.close();
1481
2171
  liveTraces.delete(target.id);
1482
- await unlink(stateFilePath()).catch(() => {});
2172
+ await unlink2(stateFilePath()).catch(() => {});
1483
2173
  }
1484
2174
  const written = await writeTrace(events, target.id);
1485
2175
  const metrics = analyzeEvents(events);
@@ -1597,7 +2287,7 @@ async function performanceAnalyzeInsight(args = {}) {
1597
2287
  }
1598
2288
  let raw;
1599
2289
  try {
1600
- raw = await readFile3(args.tracePath, "utf8");
2290
+ raw = await readFile4(args.tracePath, "utf8");
1601
2291
  } catch (err) {
1602
2292
  throw new CdpError(`cannot read trace file '${args.tracePath}': ${err.message}`);
1603
2293
  }
@@ -1615,7 +2305,7 @@ async function performanceAnalyzeInsight(args = {}) {
1615
2305
 
1616
2306
  // src/tools/heap.ts
1617
2307
  import { mkdir as mkdir5, writeFile as writeFile5 } from "node:fs/promises";
1618
- import { join as join3 } from "node:path";
2308
+ import { join as join4 } from "node:path";
1619
2309
  var ARTIFACT_DIR4 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1620
2310
  function stamp3(targetId) {
1621
2311
  const short = targetId.slice(0, 8);
@@ -1641,7 +2331,7 @@ async function takeHeapsnapshot(args = {}) {
1641
2331
  throw new Error("HeapProfiler.takeHeapSnapshot produced no chunks");
1642
2332
  }
1643
2333
  await mkdir5(ARTIFACT_DIR4, { recursive: true });
1644
- const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join3(ARTIFACT_DIR4, args.savePath) : join3(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
2334
+ const path = args.savePath ? args.savePath.startsWith("/") ? args.savePath : join4(ARTIFACT_DIR4, args.savePath) : join4(ARTIFACT_DIR4, `take_heapsnapshot-${stamp3(target.id)}.heapsnapshot`);
1645
2335
  await writeFile5(path, snapshot, "utf8");
1646
2336
  const bytes = Buffer.byteLength(snapshot, "utf8");
1647
2337
  return {
@@ -1655,7 +2345,7 @@ async function takeHeapsnapshot(args = {}) {
1655
2345
 
1656
2346
  // src/tools/lighthouse.ts
1657
2347
  import { spawn } from "node:child_process";
1658
- import { mkdir as mkdir6, readFile as readFile4, stat as stat2 } from "node:fs/promises";
2348
+ import { mkdir as mkdir6, readFile as readFile5, stat as stat2 } from "node:fs/promises";
1659
2349
  var ARTIFACT_DIR5 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
1660
2350
  function cdpPort() {
1661
2351
  const base = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -1745,7 +2435,7 @@ async function lighthouseAudit(args) {
1745
2435
  }
1746
2436
  let raw;
1747
2437
  try {
1748
- raw = await readFile4(outPath, "utf8");
2438
+ raw = await readFile5(outPath, "utf8");
1749
2439
  } catch {
1750
2440
  raw = undefined;
1751
2441
  }
@@ -1753,7 +2443,7 @@ async function lighthouseAudit(args) {
1753
2443
  if (res.signal === "SIGKILL") {
1754
2444
  throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
1755
2445
  }
1756
- const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx — ensure network access and that 'lighthouse' is installable." : "";
2446
+ const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
1757
2447
  throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
1758
2448
  }
1759
2449
  let report;
@@ -1789,205 +2479,32 @@ function truncate(s, max = 600) {
1789
2479
  return t.length > max ? `${t.slice(0, max)}…` : t;
1790
2480
  }
1791
2481
 
1792
- // src/tools/network_mock.ts
1793
- function escapeRegExpChar(ch) {
1794
- return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
1795
- }
1796
- function globToRegExp(pattern) {
1797
- let re = "";
1798
- for (let i = 0;i < pattern.length; i++) {
1799
- const ch = pattern[i];
1800
- if (ch === "\\") {
1801
- const next = pattern[i + 1];
1802
- if (next !== undefined) {
1803
- re += escapeRegExpChar(next);
1804
- i++;
1805
- } else {
1806
- re += "\\\\";
1807
- }
1808
- } else if (ch === "*") {
1809
- re += ".*";
1810
- } else if (ch === "?") {
1811
- re += ".";
1812
- } else {
1813
- re += escapeRegExpChar(ch);
1814
- }
1815
- }
1816
- return new RegExp(`^${re}$`);
1817
- }
1818
- function urlMatches(pattern, url) {
1819
- return globToRegExp(pattern).test(url);
1820
- }
1821
- function selectRule(rules, url, method) {
1822
- return rules.find((r) => urlMatches(r.urlPattern, url) && (!r.method || r.method.toUpperCase() === method.toUpperCase()));
1823
- }
1824
- function buildFulfillParams(requestId, rule) {
1825
- const merged = new Map;
1826
- merged.set("content-type", { name: "Content-Type", value: rule.contentType ?? "application/json" });
1827
- for (const [name, value] of Object.entries(rule.headers ?? {})) {
1828
- merged.set(name.toLowerCase(), { name, value });
1829
- }
1830
- return {
1831
- requestId,
1832
- responseCode: rule.status ?? 200,
1833
- responseHeaders: [...merged.values()],
1834
- body: Buffer.from(rule.body ?? "", "utf8").toString("base64")
1835
- };
1836
- }
1837
- function effectiveAction(rule, rnd) {
1838
- if (rule.failRate !== undefined && rule.failRate > 0 && rnd < rule.failRate)
1839
- return "fail";
1840
- return rule.action;
1841
- }
1842
- var sessions = new Map;
1843
- function delay3(ms) {
1844
- return new Promise((r) => setTimeout(r, ms));
1845
- }
1846
- function registerHandler(session) {
1847
- session.conn.on("Fetch.requestPaused", (params) => {
1848
- const p = params;
1849
- session.chain = session.chain.then(async () => {
1850
- const rule = selectRule(session.rules, p.request.url, p.request.method);
1851
- if (!rule) {
1852
- await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
1853
- return;
1854
- }
1855
- if (rule.delayMs)
1856
- await delay3(rule.delayMs);
1857
- const act = effectiveAction(rule, Math.random());
1858
- if (act === "fulfill") {
1859
- await session.conn.send("Fetch.fulfillRequest", { ...buildFulfillParams(p.requestId, rule) });
1860
- } else if (act === "fail") {
1861
- await session.conn.send("Fetch.failRequest", { requestId: p.requestId, errorReason: rule.errorReason ?? "Failed" });
1862
- } else {
1863
- await session.conn.send("Fetch.continueRequest", { requestId: p.requestId });
1864
- }
1865
- rule.hits++;
1866
- session.intercepted.push({ url: p.request.url, method: p.request.method, action: act });
1867
- }).catch((e) => {
1868
- if (/closed|connection|Target|Inspected/i.test(String(e)))
1869
- session.dead = true;
1870
- });
1871
- });
1872
- }
1873
- async function mockRequest(args) {
1874
- if (!args.urlPattern)
1875
- throw new CdpError("mock_request requires a { urlPattern }");
1876
- const rule = {
1877
- urlPattern: args.urlPattern,
1878
- action: args.action ?? "fulfill",
1879
- status: args.status,
1880
- body: args.body,
1881
- contentType: args.contentType,
1882
- headers: args.headers,
1883
- errorReason: args.errorReason,
1884
- method: args.method,
1885
- delayMs: args.delayMs,
1886
- failRate: args.failRate,
1887
- hits: 0
1888
- };
1889
- const resolved = await resolveTarget(args.target);
1890
- let session = sessions.get(resolved.id);
1891
- if (session && !session.dead) {
1892
- const idx = session.rules.findIndex((r) => r.urlPattern === rule.urlPattern && (r.method ?? "") === (rule.method ?? ""));
1893
- if (idx >= 0)
1894
- session.rules[idx] = rule;
1895
- else
1896
- session.rules.push(rule);
1897
- await session.conn.send("Fetch.enable", { patterns: session.rules.map((r) => ({ urlPattern: r.urlPattern })) });
1898
- } else {
1899
- const { conn, target } = await openPage(resolved.id);
1900
- session = { conn, target, rules: [rule], intercepted: [], chain: Promise.resolve(), dead: false };
1901
- registerHandler(session);
1902
- await conn.send("Fetch.enable", { patterns: [{ urlPattern: rule.urlPattern }] });
1903
- sessions.set(target.id, session);
1904
- }
1905
- let reloaded = false;
1906
- if (args.reload) {
1907
- await session.conn.send("Page.enable");
1908
- await session.conn.send("Page.reload", { ignoreCache: true });
1909
- await delay3(800);
1910
- reloaded = true;
1911
- }
1912
- return {
1913
- target: { id: session.target.id, url: session.target.url, title: session.target.title },
1914
- pattern: rule.urlPattern,
1915
- ruleCount: session.rules.length,
1916
- reloaded
1917
- };
1918
- }
1919
- async function listMocks(_args = {}) {
1920
- for (const [id, s] of sessions) {
1921
- if (s.dead) {
1922
- try {
1923
- s.conn.close();
1924
- } catch {}
1925
- sessions.delete(id);
1926
- continue;
1927
- }
1928
- try {
1929
- await s.conn.send("Runtime.evaluate", { expression: "1", returnByValue: true }, { timeoutMs: 3000 });
1930
- } catch {
1931
- try {
1932
- s.conn.close();
1933
- } catch {}
1934
- sessions.delete(id);
1935
- }
1936
- }
1937
- const mocks = [...sessions.values()].map((s) => ({
1938
- target: { id: s.target.id, url: s.target.url, title: s.target.title },
1939
- rules: s.rules.map((r) => ({ urlPattern: r.urlPattern, action: r.action, method: r.method, hits: r.hits })),
1940
- hits: s.intercepted.length
1941
- }));
1942
- return { count: mocks.length, mocks };
1943
- }
1944
- async function clearMocks(args = {}) {
1945
- let ids;
1946
- if (args.all) {
1947
- ids = [...sessions.keys()];
1948
- } else {
1949
- const resolved = await resolveTarget(args.target);
1950
- ids = sessions.has(resolved.id) ? [resolved.id] : [];
1951
- }
1952
- let cleared = 0;
1953
- for (const id of ids) {
1954
- const s = sessions.get(id);
1955
- if (!s)
1956
- continue;
1957
- try {
1958
- await s.conn.send("Fetch.disable");
1959
- } catch {}
1960
- try {
1961
- s.conn.close();
1962
- } catch {}
1963
- sessions.delete(id);
1964
- cleared++;
1965
- }
1966
- return { cleared };
1967
- }
1968
-
1969
2482
  // src/index.ts
2483
+ var cdpDriver = createCdpDriver();
2484
+ function onCdp(fn) {
2485
+ return (args) => fn(cdpDriver, args);
2486
+ }
1970
2487
  var TOOLS = {
1971
- list_pages: listPages,
1972
- new_page: newPage,
1973
- close_page: closePage,
1974
- select_page: selectPage,
1975
- navigate_page: navigatePage,
1976
- wait_for: waitForText,
1977
- evaluate_script: evaluateScript,
1978
- take_snapshot: takeSnapshot,
1979
- click,
1980
- hover,
1981
- drag,
1982
- fill,
1983
- fill_form: fillForm,
1984
- type_text: typeText,
1985
- press_key: pressKey,
1986
- upload_file: uploadFile,
1987
- take_screenshot: takeScreenshot,
1988
- emulate,
1989
- resize_page: resizePage,
1990
- handle_dialog: handleDialog,
2488
+ list_pages: onCdp(SHARED_TOOLS.list_pages),
2489
+ new_page: onCdp(SHARED_TOOLS.new_page),
2490
+ close_page: onCdp(SHARED_TOOLS.close_page),
2491
+ select_page: onCdp(SHARED_TOOLS.select_page),
2492
+ navigate_page: onCdp(SHARED_TOOLS.navigate_page),
2493
+ wait_for: onCdp(SHARED_TOOLS.wait_for),
2494
+ evaluate_script: onCdp(SHARED_TOOLS.evaluate_script),
2495
+ take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
2496
+ click: onCdp(SHARED_TOOLS.click),
2497
+ hover: onCdp(SHARED_TOOLS.hover),
2498
+ drag: onCdp(SHARED_TOOLS.drag),
2499
+ fill: onCdp(SHARED_TOOLS.fill),
2500
+ fill_form: onCdp(SHARED_TOOLS.fill_form),
2501
+ type_text: onCdp(SHARED_TOOLS.type_text),
2502
+ press_key: onCdp(SHARED_TOOLS.press_key),
2503
+ upload_file: onCdp(SHARED_TOOLS.upload_file),
2504
+ take_screenshot: onCdp(SHARED_TOOLS.take_screenshot),
2505
+ emulate: onCdp(SHARED_TOOLS.emulate),
2506
+ resize_page: onCdp(SHARED_TOOLS.resize_page),
2507
+ handle_dialog: onCdp(SHARED_TOOLS.handle_dialog),
1991
2508
  list_console_messages: listConsoleMessages,
1992
2509
  get_console_message: getConsoleMessage,
1993
2510
  list_network_requests: listNetworkRequests,
@@ -2000,7 +2517,10 @@ var TOOLS = {
2000
2517
  lighthouse_audit: lighthouseAudit,
2001
2518
  mock_request: mockRequest,
2002
2519
  list_mocks: listMocks,
2003
- clear_mocks: clearMocks
2520
+ clear_mocks: clearMocks,
2521
+ claim_page: onCdp(LEASE_TOOLS.claim_page),
2522
+ release_page: onCdp(LEASE_TOOLS.release_page),
2523
+ list_leases: onCdp(LEASE_TOOLS.list_leases)
2004
2524
  };
2005
2525
  var TOOL_NAMES = Object.keys(TOOLS);
2006
2526
  export {