cdp-toolkit 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
- package/src/types.ts +20 -0
- package/src/workers.ts +159 -0
package/src/shared-tools.ts
CHANGED
|
@@ -36,11 +36,16 @@
|
|
|
36
36
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
37
37
|
import { dirname, join } from "node:path";
|
|
38
38
|
import type {
|
|
39
|
-
BrowserCookie, BrowserDriver, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
39
|
+
BrowserCookie, BrowserDriver, Capability, DragDestination, DriverUid, ElementLocator, NavigateResult, PageDriver, PageInfo, SnapshotNode,
|
|
40
40
|
} from "./driver.ts";
|
|
41
41
|
import type { TargetSelector } from "./types.ts";
|
|
42
|
-
import {
|
|
43
|
-
|
|
42
|
+
import {
|
|
43
|
+
assertLeaseOk, claimLease, defaultLabel, leaseTtlMs, listLeases, releaseLeaseFor, requireLease,
|
|
44
|
+
type LeaseBackend, type LeaseStaleReason, type LeaseToken,
|
|
45
|
+
} from "./leases.ts";
|
|
46
|
+
import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
|
|
47
|
+
import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
|
|
48
|
+
import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
|
|
44
49
|
import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
|
|
45
50
|
|
|
46
51
|
const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
|
|
@@ -128,6 +133,21 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
128
133
|
if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
|
|
129
134
|
return p;
|
|
130
135
|
}
|
|
136
|
+
if (selector.startsWith("label:")) return pickByLabel(driver, pages, selector.slice(6));
|
|
137
|
+
if (isWorkerSelector(selector)) {
|
|
138
|
+
// THE PAGE-ONLY CONTRACT, STATED OUT LOUD. Every caller of pickPage
|
|
139
|
+
// (resolvePage -> close_page / select_page / release_page, claim_page's
|
|
140
|
+
// takeover) acts on a TAB, and the 1.9.0 "worker:" arm is deliberately NOT
|
|
141
|
+
// implemented here: it resolves against the page-only listing these callers
|
|
142
|
+
// pass, which by construction never contains a worker, so the arm could
|
|
143
|
+
// only ever miss. Refusing with the reason beats falling through to the
|
|
144
|
+
// bare-id lookup, whose "no target with id 'worker:foo'" reads as a typo.
|
|
145
|
+
// The arm lives in client.ts's pickTarget (Chrome's all-targets resolver),
|
|
146
|
+
// and only evaluate_script routes to it.
|
|
147
|
+
throw new SharedToolError(
|
|
148
|
+
backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
131
151
|
const exactPage = pages.find((x) => x.id === selector);
|
|
132
152
|
if (exactPage) return exactPage;
|
|
133
153
|
const all = await driver.listPages({ all: true });
|
|
@@ -136,6 +156,35 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
|
|
|
136
156
|
throw new SharedToolError(`no target with id '${selector}'`);
|
|
137
157
|
}
|
|
138
158
|
|
|
159
|
+
/**
|
|
160
|
+
* "label:<name>" resolution for pickPage. The lookup itself (both stores,
|
|
161
|
+
* live-scoped) is shared with client.ts's pickTarget and bidi/driver.ts's
|
|
162
|
+
* pickContext via origins.ts's resolveLiveLabel, so the three copies of the
|
|
163
|
+
* selector grammar cannot drift on what counts as a hit, a miss or an
|
|
164
|
+
* ambiguity. Only the error wording is local, matching this file's
|
|
165
|
+
* SharedToolError idiom.
|
|
166
|
+
*
|
|
167
|
+
* Nothing is reaped here: this is a read, not a maintenance pass, and pickPage
|
|
168
|
+
* stays gate-free and side-effect-free over its inputs.
|
|
169
|
+
*/
|
|
170
|
+
async function pickByLabel(driver: BrowserDriver, pages: PageInfo[], label: string): Promise<PageInfo> {
|
|
171
|
+
const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
|
|
172
|
+
if (matchIds.length > 1) {
|
|
173
|
+
throw new SharedToolError(
|
|
174
|
+
`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (matchIds.length === 1) {
|
|
178
|
+
const p = pages.find((x) => x.id === matchIds[0]);
|
|
179
|
+
if (p) return p;
|
|
180
|
+
}
|
|
181
|
+
throw new SharedToolError(
|
|
182
|
+
knownLabels.length
|
|
183
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
184
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
/* -------------------------------- element locators -------------------------------- */
|
|
140
189
|
|
|
141
190
|
function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
|
|
@@ -166,6 +215,31 @@ export interface ListedPage extends PageInfo {
|
|
|
166
215
|
* genuinely unknown, but this field is what keeps an unreadable record from
|
|
167
216
|
* being reported as a clean "nothing was ever recorded". */
|
|
168
217
|
originUnreadable?: string;
|
|
218
|
+
/**
|
|
219
|
+
* Present only for a tab under an active, READABLE lease of THIS backend.
|
|
220
|
+
* idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and
|
|
221
|
+
* lastUsedAt+ttlMs), never read off disk — same rule as list_leases' own
|
|
222
|
+
* LeaseRow, and unconditional on `probe`: a lease can exist whether or not a
|
|
223
|
+
* caller ever passes probe:true, and this costs nothing beyond a lease-dir
|
|
224
|
+
* read this call already pays for under strict mode.
|
|
225
|
+
*/
|
|
226
|
+
lease?: { label: string; pid: number; idleMs: number; expiresAt: number; stale: LeaseStaleReason | false };
|
|
227
|
+
/**
|
|
228
|
+
* Present only when { probe: true } was passed AND this backend can answer
|
|
229
|
+
* it (see rendererProbeSupported in src/activity.ts) AND this entry is a
|
|
230
|
+
* page-type target — a worker or iframe has no `window` to carry the beacon
|
|
231
|
+
* global, so probing one would be a guaranteed false negative rather than a
|
|
232
|
+
* genuine liveness answer. true when a bounded Runtime.evaluate answered
|
|
233
|
+
* within budget; false on a timeout, a thrown page-side exception, or a
|
|
234
|
+
* target the driver could not reach at all — the wedged-tab case this field
|
|
235
|
+
* exists to surface, reported as data rather than failing the whole call.
|
|
236
|
+
*/
|
|
237
|
+
responsive?: boolean;
|
|
238
|
+
/** Ms since a human last touched this tab, present only where probe:true
|
|
239
|
+
* answered AND the same round trip found human-attributed beacon data.
|
|
240
|
+
* Same rule as claim_page/list_leases: absence is "no answer", never "no
|
|
241
|
+
* human". */
|
|
242
|
+
humanActiveMs?: number;
|
|
169
243
|
}
|
|
170
244
|
|
|
171
245
|
/**
|
|
@@ -190,23 +264,52 @@ async function reapSet(driver: BrowserDriver, pages: PageInfo[], all?: boolean):
|
|
|
190
264
|
|
|
191
265
|
export async function listPages(
|
|
192
266
|
driver: BrowserDriver,
|
|
193
|
-
args: { all?: boolean } = {},
|
|
267
|
+
args: { all?: boolean; probe?: boolean } = {},
|
|
194
268
|
): Promise<{ pages: ListedPage[]; count: number; reaped?: ReapedTab[] }> {
|
|
195
269
|
// Reap FIRST, so a tab this call is about to close never appears in the
|
|
196
|
-
// listing it returns
|
|
197
|
-
|
|
270
|
+
// listing it returns, and so the lease read just below never reports a lease
|
|
271
|
+
// this same call is in the middle of deleting. Under strict mode only: see
|
|
272
|
+
// requireLease.
|
|
273
|
+
const backend = backendOf(driver);
|
|
274
|
+
const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
|
|
198
275
|
const reapedIds = new Set(reaped.map((r) => r.targetId));
|
|
199
276
|
const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
|
|
200
277
|
// Never let provenance break the listing: originIndex does not throw, and a
|
|
201
278
|
// missing or unreadable ledger yields an empty map, so every page falls back
|
|
202
279
|
// to origin "unknown" exactly as a pre-ledger consumer always saw.
|
|
203
|
-
const ledger = await originIndex(
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
280
|
+
const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
|
|
281
|
+
const now = Date.now();
|
|
282
|
+
// Observability (1.8.0): every READABLE lease of this backend, keyed by
|
|
283
|
+
// target. Read unconditionally, not gated on `probe`, because a lease can
|
|
284
|
+
// exist whether or not a caller ever asks for a renderer ping.
|
|
285
|
+
const leaseByTarget = new Map(
|
|
286
|
+
(await listLeases({ now, liveIds: pages.map((p) => p.id), liveBackend: backend }))
|
|
287
|
+
.filter((l) => l.backend === backend && l.unreadable === undefined)
|
|
288
|
+
.map((l) => [l.targetId, l] as const),
|
|
289
|
+
);
|
|
290
|
+
const doProbe = args.probe === true && rendererProbeSupported(driver);
|
|
291
|
+
const annotated: ListedPage[] = await Promise.all(
|
|
292
|
+
pages.map(async (p) => {
|
|
293
|
+
const rec = ledger.get(p.id);
|
|
294
|
+
const base: ListedPage =
|
|
295
|
+
!rec ? { ...p, origin: "unknown" }
|
|
296
|
+
: rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable }
|
|
297
|
+
: { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
|
|
298
|
+
const leaseRow = leaseByTarget.get(p.id);
|
|
299
|
+
if (leaseRow) {
|
|
300
|
+
base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
|
|
301
|
+
}
|
|
302
|
+
// Page-type only: see ListedPage.responsive. Concurrent across pages
|
|
303
|
+
// (this whole map runs inside Promise.all), so one wedged tab's 500ms
|
|
304
|
+
// budget never serializes onto the next tab's.
|
|
305
|
+
if (doProbe && (p.type === undefined || p.type === "page")) {
|
|
306
|
+
const probe = await probeRendererActivity(driver, backend, p.id, now);
|
|
307
|
+
base.responsive = probe.responsive;
|
|
308
|
+
if (probe.humanActiveMs !== undefined) base.humanActiveMs = probe.humanActiveMs;
|
|
309
|
+
}
|
|
310
|
+
return base;
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
210
313
|
// `reaped` is present only when it has something to say, so the common-case
|
|
211
314
|
// shape is byte-identical to 1.4.0. Silently closing a tab is not acceptable:
|
|
212
315
|
// if a read closed something, the read says so.
|
|
@@ -238,6 +341,12 @@ export async function newPage(
|
|
|
238
341
|
// silently WEAKEN every existing claim:true call site to pid-only.
|
|
239
342
|
auto: !explicit,
|
|
240
343
|
});
|
|
344
|
+
// Install the activity beacon on the same terms claim_page does: at claim
|
|
345
|
+
// time, after the claim, best effort (installBeacon never throws). A tab
|
|
346
|
+
// beaconed now is a tab that can ANSWER later — when this agent has released
|
|
347
|
+
// it and a person has picked it up, which is the case the beacon exists for.
|
|
348
|
+
// Nothing here is read back: a tab created a moment ago has no human history.
|
|
349
|
+
await installBeacon(driver, p.id);
|
|
241
350
|
return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
|
|
242
351
|
}
|
|
243
352
|
|
|
@@ -286,23 +395,65 @@ export interface LegacyNavigateResult {
|
|
|
286
395
|
url: string;
|
|
287
396
|
frameId: string;
|
|
288
397
|
reloaded?: boolean;
|
|
398
|
+
/** Present only on a history traversal, mirroring how `reloaded` is present only on a reload. */
|
|
399
|
+
traversed?: "back" | "forward";
|
|
289
400
|
waitedFor: "load" | "domcontentloaded" | "frameStoppedLoading" | "navigate-only";
|
|
290
401
|
}
|
|
291
402
|
|
|
403
|
+
/** What a navigate_page call is actually asking for. Exactly one of the three. */
|
|
404
|
+
export type NavigateMode =
|
|
405
|
+
| { mode: "url"; url: string }
|
|
406
|
+
| { mode: "reload" }
|
|
407
|
+
| { mode: "history"; history: "back" | "forward" };
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The three-way exclusivity check for navigate_page's url / reload / history, split out and
|
|
411
|
+
* exported so every refusal is pinned by a unit test without a browser (the same reason
|
|
412
|
+
* resolveScrollAnchor and resolveDragDestination live at this layer).
|
|
413
|
+
*
|
|
414
|
+
* EXCLUSIVE, NOT PRECEDENCE-ORDERED. `{url, history:"back"}` is a caller who does not know what
|
|
415
|
+
* they want; silently honoring one of them would navigate somewhere the caller did not ask for and
|
|
416
|
+
* report success. Refusing names both, so the mistake is visible in the error rather than in the
|
|
417
|
+
* page that comes back.
|
|
418
|
+
*/
|
|
419
|
+
export function resolveNavigateMode(args: { url?: string; reload?: boolean; history?: unknown }): NavigateMode {
|
|
420
|
+
const reload = args.reload === true;
|
|
421
|
+
const hasUrl = typeof args.url === "string" && args.url.length > 0;
|
|
422
|
+
const hasHistory = args.history !== undefined && args.history !== null;
|
|
423
|
+
const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
|
|
424
|
+
if (given.length > 1) {
|
|
425
|
+
throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
|
|
426
|
+
}
|
|
427
|
+
if (hasHistory) {
|
|
428
|
+
if (args.history !== "back" && args.history !== "forward") {
|
|
429
|
+
throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
|
|
430
|
+
}
|
|
431
|
+
return { mode: "history", history: args.history };
|
|
432
|
+
}
|
|
433
|
+
if (reload) return { mode: "reload" };
|
|
434
|
+
if (!hasUrl) {
|
|
435
|
+
throw new SharedToolError(
|
|
436
|
+
"navigate_page: 'url' is required (or pass reload:true to reload the current page, or history:'back'|'forward' to go back/forward in this tab's session history)",
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
return { mode: "url", url: args.url as string };
|
|
440
|
+
}
|
|
441
|
+
|
|
292
442
|
export async function navigatePage(
|
|
293
443
|
driver: BrowserDriver,
|
|
294
|
-
args: {
|
|
444
|
+
args: {
|
|
445
|
+
target?: TargetSelector; url?: string; reload?: boolean; ignoreCache?: boolean;
|
|
446
|
+
history?: "back" | "forward"; waitUntil?: "load" | "domcontentloaded"; timeoutMs?: number;
|
|
447
|
+
},
|
|
295
448
|
): Promise<LegacyNavigateResult> {
|
|
296
|
-
const
|
|
297
|
-
if (!reload && (!args.url || typeof args.url !== "string")) {
|
|
298
|
-
throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
|
|
299
|
-
}
|
|
449
|
+
const mode = resolveNavigateMode(args);
|
|
300
450
|
return withPage(driver, args.target, async (page) => {
|
|
301
|
-
const r = await page.navigate(args);
|
|
451
|
+
const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
|
|
302
452
|
return {
|
|
303
453
|
url: r.url,
|
|
304
454
|
frameId: r.contextId,
|
|
305
455
|
...(r.reloaded ? { reloaded: r.reloaded } : {}),
|
|
456
|
+
...(r.traversed ? { traversed: r.traversed } : {}),
|
|
306
457
|
waitedFor: toLegacyWaitedFor(r.waitedFor),
|
|
307
458
|
};
|
|
308
459
|
});
|
|
@@ -370,14 +521,70 @@ function sinkRequested(savePath?: string): savePath is string {
|
|
|
370
521
|
return savePath !== undefined && savePath !== "";
|
|
371
522
|
}
|
|
372
523
|
|
|
524
|
+
/** Keys a caller reaches for out of habit from other automation APIs, none of
|
|
525
|
+
* which this tool reads: the code always goes in 'expression'. Checked in
|
|
526
|
+
* this order so the error names one wrong key, deterministically, when a
|
|
527
|
+
* caller happens to pass more than one. */
|
|
528
|
+
const EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"] as const;
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* Turn a `worker:<substring>` target into a concrete target id, waking an
|
|
532
|
+
* idle-evicted MV3 service worker on the way when `wake` is not false.
|
|
533
|
+
*
|
|
534
|
+
* WHY THIS RETURNS AN ID RATHER THAN DRIVING THE WORKER ITSELF. Once resolved,
|
|
535
|
+
* a worker is just a target with its own webSocketDebuggerUrl, and a DIRECT
|
|
536
|
+
* connection to it evaluates exactly like a page does — `Runtime.evaluate` with
|
|
537
|
+
* `awaitPromise` returns real awaited values there (measured on Chrome 151;
|
|
538
|
+
* `chrome.storage.local.get(null)` comes back as an object, not undefined). So
|
|
539
|
+
* the worker rides the ordinary page() acquisition path from here, and every
|
|
540
|
+
* behavior around it — the args/function form, exception reporting, and above
|
|
541
|
+
* all savePath's no-value-in-the-response secret hygiene — is the SAME CODE,
|
|
542
|
+
* not a parallel copy that could quietly diverge on the leak-shaped edge.
|
|
543
|
+
*
|
|
544
|
+
* Exported for the unit tests, which need to reach the capability refusal
|
|
545
|
+
* without a browser.
|
|
546
|
+
*/
|
|
547
|
+
export async function resolveWorkerSelectorFor(
|
|
548
|
+
driver: BrowserDriver,
|
|
549
|
+
selector: string,
|
|
550
|
+
wake: boolean,
|
|
551
|
+
): Promise<PageInfo> {
|
|
552
|
+
if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
|
|
553
|
+
throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
|
|
554
|
+
}
|
|
555
|
+
return driver.resolveWorkerTarget(selector, { wake });
|
|
556
|
+
}
|
|
557
|
+
|
|
373
558
|
export async function evaluateScript(
|
|
374
559
|
driver: BrowserDriver,
|
|
375
|
-
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string },
|
|
560
|
+
args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string; wake?: boolean },
|
|
376
561
|
): Promise<unknown> {
|
|
377
562
|
if (typeof args.expression !== "string" || args.expression.length === 0) {
|
|
563
|
+
const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => (args as Record<string, unknown>)[k] !== undefined);
|
|
564
|
+
if (wrongKey !== undefined) {
|
|
565
|
+
throw new SharedToolError(
|
|
566
|
+
`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
378
569
|
throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
|
|
379
570
|
}
|
|
380
|
-
|
|
571
|
+
if (args.wake !== undefined && typeof args.wake !== "boolean") {
|
|
572
|
+
throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
|
|
573
|
+
}
|
|
574
|
+
// `wake` is REFUSED on a page selector rather than ignored. Accepting it there
|
|
575
|
+
// would read as "this call will wake something", and the caller would never
|
|
576
|
+
// learn that the argument did nothing at all.
|
|
577
|
+
if (args.wake !== undefined && !isWorkerSelector(args.target)) {
|
|
578
|
+
throw new SharedToolError(
|
|
579
|
+
"evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.",
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
// Resolved to a bare target id BEFORE acquisition, so the page() call below is
|
|
583
|
+
// the same one every other selector makes.
|
|
584
|
+
const target = isWorkerSelector(args.target)
|
|
585
|
+
? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id
|
|
586
|
+
: args.target;
|
|
587
|
+
return withPage(driver, target, async (page) => {
|
|
381
588
|
const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
|
|
382
589
|
// No savePath: byte-identical to the pre-sink behavior, the value itself.
|
|
383
590
|
// A thrown page-side exception never reaches here, so it still surfaces as
|
|
@@ -620,14 +827,55 @@ export async function takeSnapshot(
|
|
|
620
827
|
|
|
621
828
|
export async function click(
|
|
622
829
|
driver: BrowserDriver,
|
|
623
|
-
args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number },
|
|
830
|
+
args: { target?: TargetSelector; uid?: DriverUid; selector?: string; button?: "left" | "right" | "middle"; clickCount?: number; modifiers?: string[] },
|
|
624
831
|
): Promise<{ clicked: true; x: number; y: number }> {
|
|
625
832
|
return withPage(driver, args.target, async (page) => {
|
|
626
|
-
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
|
|
833
|
+
const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
|
|
627
834
|
return { clicked: true, x, y };
|
|
628
835
|
});
|
|
629
836
|
}
|
|
630
837
|
|
|
838
|
+
/**
|
|
839
|
+
* scroll's anchor rule: exactly one of `{uid}`, `{selector}`, or `{x,y}` — or none of the three,
|
|
840
|
+
* which means the viewport center. `x`/`y` must arrive together; one without the other is refused
|
|
841
|
+
* rather than silently treated as "half an anchor". Exported for the unit tests, which pin the
|
|
842
|
+
* rule without a browser.
|
|
843
|
+
*/
|
|
844
|
+
export function resolveScrollAnchor(args: { uid?: DriverUid; selector?: string; x?: number; y?: number }): ElementLocator | { x: number; y: number } | undefined {
|
|
845
|
+
const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== ("" as unknown);
|
|
846
|
+
const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
|
|
847
|
+
const hasX = args.x !== undefined;
|
|
848
|
+
const hasY = args.y !== undefined;
|
|
849
|
+
if (hasX !== hasY) throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
|
|
850
|
+
const hasXY = hasX && hasY;
|
|
851
|
+
const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
852
|
+
if (anchorCount > 1) throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
|
|
853
|
+
if (hasUid || hasSelector) return locatorOf({ uid: args.uid, selector: args.selector });
|
|
854
|
+
if (hasXY) return { x: args.x as number, y: args.y as number };
|
|
855
|
+
return undefined;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** scroll's delta rule: at least one of deltaX/deltaY is required (a no-op scroll is refused
|
|
859
|
+
* rather than silently dispatched). Exported for the unit tests. */
|
|
860
|
+
export function resolveScrollDelta(args: { deltaX?: number; deltaY?: number }): { deltaX: number; deltaY: number } {
|
|
861
|
+
const deltaX = args.deltaX ?? 0;
|
|
862
|
+
const deltaY = args.deltaY ?? 0;
|
|
863
|
+
if (deltaX === 0 && deltaY === 0) throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
|
|
864
|
+
return { deltaX, deltaY };
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
export async function scroll(
|
|
868
|
+
driver: BrowserDriver,
|
|
869
|
+
args: { target?: TargetSelector; uid?: DriverUid; selector?: string; x?: number; y?: number; deltaX?: number; deltaY?: number },
|
|
870
|
+
): Promise<{ x: number; y: number; deltaX: number; deltaY: number; target: { id: string; url: string; title: string } }> {
|
|
871
|
+
const anchor = resolveScrollAnchor(args);
|
|
872
|
+
const { deltaX, deltaY } = resolveScrollDelta(args);
|
|
873
|
+
return withPage(driver, args.target, async (page) => {
|
|
874
|
+
const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
|
|
875
|
+
return { x, y, deltaX, deltaY, target: target3(page.info) };
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
|
|
631
879
|
export async function hover(driver: BrowserDriver, args: { target?: TargetSelector; uid?: DriverUid; selector?: string }): Promise<{ hovered: true; x: number; y: number }> {
|
|
632
880
|
return withPage(driver, args.target, async (page) => {
|
|
633
881
|
const { x, y } = await page.hover(locatorOf(args));
|
|
@@ -635,14 +883,106 @@ export async function hover(driver: BrowserDriver, args: { target?: TargetSelect
|
|
|
635
883
|
});
|
|
636
884
|
}
|
|
637
885
|
|
|
886
|
+
/** drag's `to`: exactly one of { uid } | { selector } | { x, y }, x and y together. Exported for
|
|
887
|
+
* the unit tests, which pin the rule without a browser. */
|
|
888
|
+
export function resolveDragTo(to: { uid?: DriverUid; selector?: string; x?: number; y?: number }): DragDestination {
|
|
889
|
+
const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== ("" as unknown);
|
|
890
|
+
const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
|
|
891
|
+
const hasX = to.x !== undefined;
|
|
892
|
+
const hasY = to.y !== undefined;
|
|
893
|
+
if (hasX !== hasY) throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
|
|
894
|
+
const hasXY = hasX && hasY;
|
|
895
|
+
const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
|
|
896
|
+
if (count !== 1) throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
|
|
897
|
+
if (hasXY) {
|
|
898
|
+
if (!Number.isFinite(to.x) || !Number.isFinite(to.y)) throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
|
|
899
|
+
return { x: to.x as number, y: to.y as number };
|
|
900
|
+
}
|
|
901
|
+
return locatorOf({ uid: to.uid, selector: to.selector });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* drag's destination rule: EXACTLY one of `to` (where the drag ends) or `by` (how far it moves
|
|
906
|
+
* from the source point). Both is a contradiction and neither leaves nowhere to drag to, so both
|
|
907
|
+
* are refused rather than resolved by precedence — a caller who passed both meant one of them and
|
|
908
|
+
* silently picking is how the wrong widget gets dragged. `by` needs at least one of dx/dy; the
|
|
909
|
+
* other defaults to 0, so `by:{dx:40}` is a pure horizontal slider nudge. Exported for the tests.
|
|
910
|
+
*/
|
|
911
|
+
export function resolveDragDestination(args: {
|
|
912
|
+
to?: { uid?: DriverUid; selector?: string; x?: number; y?: number };
|
|
913
|
+
by?: { dx?: number; dy?: number };
|
|
914
|
+
}): DragDestination {
|
|
915
|
+
const hasTo = args.to !== undefined && args.to !== null;
|
|
916
|
+
const hasBy = args.by !== undefined && args.by !== null;
|
|
917
|
+
if (hasTo === hasBy) throw new SharedToolError("drag requires exactly one of { to } or { by }");
|
|
918
|
+
if (hasBy) {
|
|
919
|
+
const by = args.by as { dx?: number; dy?: number };
|
|
920
|
+
const hasDx = by.dx !== undefined;
|
|
921
|
+
const hasDy = by.dy !== undefined;
|
|
922
|
+
if (!hasDx && !hasDy) throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
|
|
923
|
+
const dx = by.dx ?? 0;
|
|
924
|
+
const dy = by.dy ?? 0;
|
|
925
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy)) throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
|
|
926
|
+
return { dx, dy };
|
|
927
|
+
}
|
|
928
|
+
return resolveDragTo(args.to as { uid?: DriverUid; selector?: string; x?: number; y?: number });
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** Upper bound on drag's interpolated moves. 500 already exceeds what any per-frame-sampling DnD
|
|
932
|
+
* library needs (a 60fps drag lasting 8s), and each step is a round-tripped CDP command, so an
|
|
933
|
+
* accidental `steps: 100000` would otherwise wedge the call for minutes. Refused, not clamped. */
|
|
934
|
+
const MAX_DRAG_STEPS = 500;
|
|
935
|
+
|
|
936
|
+
/** drag's `steps`: an integer in [1, MAX_DRAG_STEPS], default 2 (the pre-1.8.0 midpoint +
|
|
937
|
+
* destination sequence). Exported for the unit tests. */
|
|
938
|
+
export function resolveDragSteps(steps?: number): number {
|
|
939
|
+
if (steps === undefined) return 2;
|
|
940
|
+
if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
|
|
941
|
+
throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
|
|
942
|
+
}
|
|
943
|
+
return steps;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/**
|
|
947
|
+
* drag's `mode`, plus the ONE backend gate in this file. mode:"html5" needs Capability
|
|
948
|
+
* "input.html5Drag", which only the Chrome driver declares (Chrome's Input.setInterceptDrags /
|
|
949
|
+
* dragIntercepted / dispatchDragEvent have no WebDriver BiDi equivalent). Per ADR-001 this is a
|
|
950
|
+
* PARAM-level gap, not a whole-tool one: `drag` stays in tools/list on Firefox because mouse mode
|
|
951
|
+
* works there, and asking for html5 on Firefox is a clear validation error — never a silent
|
|
952
|
+
* downgrade to mouse mode, which would report success for a drop that never happened.
|
|
953
|
+
* Exported for the unit tests.
|
|
954
|
+
*/
|
|
955
|
+
export function resolveDragMode(mode: string | undefined, capabilities: ReadonlySet<Capability>): "mouse" | "html5" {
|
|
956
|
+
if (mode === undefined || mode === "mouse") return "mouse";
|
|
957
|
+
if (mode !== "html5") throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
|
|
958
|
+
if (!capabilities.has("input.html5Drag")) {
|
|
959
|
+
throw new SharedToolError(
|
|
960
|
+
"drag: mode:'html5' is not supported by this backend (WebDriver BiDi has no drag-interception primitive). " +
|
|
961
|
+
"Omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
|
|
962
|
+
);
|
|
963
|
+
}
|
|
964
|
+
return "html5";
|
|
965
|
+
}
|
|
966
|
+
|
|
638
967
|
export async function drag(
|
|
639
968
|
driver: BrowserDriver,
|
|
640
|
-
args: {
|
|
641
|
-
|
|
642
|
-
|
|
969
|
+
args: {
|
|
970
|
+
target?: TargetSelector;
|
|
971
|
+
from: { uid?: DriverUid; selector?: string };
|
|
972
|
+
to?: { uid?: DriverUid; selector?: string; x?: number; y?: number };
|
|
973
|
+
by?: { dx?: number; dy?: number };
|
|
974
|
+
mode?: "mouse" | "html5";
|
|
975
|
+
steps?: number;
|
|
976
|
+
},
|
|
977
|
+
): Promise<{ dragged: true; mode: "mouse" | "html5"; steps: number; from: { x: number; y: number }; to: { x: number; y: number } }> {
|
|
978
|
+
if (!args.from) throw new SharedToolError("drag requires { from }");
|
|
979
|
+
const destination = resolveDragDestination(args);
|
|
980
|
+
const steps = resolveDragSteps(args.steps);
|
|
981
|
+
// Capability check BEFORE withPage: an unsupported mode must not claim a lease or open a socket.
|
|
982
|
+
const mode = resolveDragMode(args.mode, driver.capabilities);
|
|
643
983
|
return withPage(driver, args.target, async (page) => {
|
|
644
|
-
const { from, to } = await page.drag(locatorOf(args.from),
|
|
645
|
-
return { dragged: true, from, to };
|
|
984
|
+
const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
|
|
985
|
+
return { dragged: true, mode, steps, from, to };
|
|
646
986
|
});
|
|
647
987
|
}
|
|
648
988
|
|
|
@@ -775,8 +1115,9 @@ export async function handleDialog(
|
|
|
775
1115
|
|
|
776
1116
|
/* ------------------------------------- registry ------------------------------------- */
|
|
777
1117
|
|
|
778
|
-
/** The
|
|
779
|
-
* src/firefox-tools.ts (Firefox). Each function is
|
|
1118
|
+
/** The 24 tools this file unifies (23 pre-1.8.0, plus `scroll`, Track P1), importable by both
|
|
1119
|
+
* src/index.ts (Chrome) and src/firefox-tools.ts (Firefox). Each function is
|
|
1120
|
+
* (driver, args) => Promise<result>. */
|
|
780
1121
|
export const SHARED_TOOLS = {
|
|
781
1122
|
list_pages: listPages,
|
|
782
1123
|
new_page: newPage,
|
|
@@ -792,6 +1133,7 @@ export const SHARED_TOOLS = {
|
|
|
792
1133
|
click,
|
|
793
1134
|
hover,
|
|
794
1135
|
drag,
|
|
1136
|
+
scroll,
|
|
795
1137
|
fill,
|
|
796
1138
|
fill_form: fillForm,
|
|
797
1139
|
type_text: typeText,
|