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/bidi/driver.ts
CHANGED
|
@@ -24,14 +24,18 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import type { TargetSelector } from "../types.ts";
|
|
26
26
|
import {
|
|
27
|
-
UID_STAMP_ATTR, isDriverError,
|
|
27
|
+
UID_STAMP_ATTR, isDriverError, interpolatePoints,
|
|
28
28
|
type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
29
|
+
type DragDestination, type DragOptions,
|
|
29
30
|
type SetCookieParams,
|
|
30
31
|
type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
|
|
31
32
|
type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
|
|
32
|
-
type ScreenshotOptions, type SnapshotNode, type UidStability,
|
|
33
|
+
type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
33
34
|
} from "../driver.ts";
|
|
34
35
|
import { assertLeaseOk } from "../leases.ts";
|
|
36
|
+
import { resolveLiveLabel } from "../origins.ts";
|
|
37
|
+
import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
|
|
38
|
+
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
35
39
|
import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
|
|
36
40
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
37
41
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
@@ -39,7 +43,7 @@ import type {
|
|
|
39
43
|
BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
|
|
40
44
|
BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
|
|
41
45
|
ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
|
|
42
|
-
NetworkCookie,
|
|
46
|
+
InputWheelSourceAction, NetworkCookie,
|
|
43
47
|
} from "./protocol.ts";
|
|
44
48
|
|
|
45
49
|
/* ---------------------------------- cookies ---------------------------------- */
|
|
@@ -256,6 +260,14 @@ const KEY_VALUES: Record<string, string> = {
|
|
|
256
260
|
const MODIFIER_VALUES: Record<string, string> = { shift: "\uE008", control: "\uE009", ctrl: "\uE009", alt: "\uE00A", meta: "\uE03D", cmd: "\uE03D", command: "\uE03D" };
|
|
257
261
|
const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, middle: 1, right: 2 };
|
|
258
262
|
|
|
263
|
+
/** Same scroll-settle technique as cdp/driver.ts's ARM_SCROLL_SETTLE_WATCH / awaitScrollSettle,
|
|
264
|
+
* see that file's doc comment for the full rationale: a capture-phase 'scroll' listener on
|
|
265
|
+
* window, debounced 60ms with a 500ms cap, so scroll()'s caller observes the settled position. */
|
|
266
|
+
const ARM_SCROLL_SETTLE_WATCH_SOURCE =
|
|
267
|
+
"function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" +
|
|
268
|
+
"const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
|
|
269
|
+
const AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
|
|
270
|
+
|
|
259
271
|
/**
|
|
260
272
|
* Firefox 153 capabilities. Only what was empirically verified is declared: emulate.deviceMetrics
|
|
261
273
|
* (browsingContext.setViewport) and the userAgent path (universal, no capability token) both work.
|
|
@@ -301,6 +313,22 @@ async function getConnection(port: number, timeoutMs?: number): Promise<BidiConn
|
|
|
301
313
|
return conn;
|
|
302
314
|
}
|
|
303
315
|
|
|
316
|
+
/**
|
|
317
|
+
* Contexts that already carry a beacon preload script, keyed port:context ->
|
|
318
|
+
* the script id BiDi handed back.
|
|
319
|
+
*
|
|
320
|
+
* WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
|
|
321
|
+
* LifetimeModel is "session": ONE BidiConnection is memoized per port above and
|
|
322
|
+
* lives until dispose(), so a preload script registered on it stays registered
|
|
323
|
+
* across navigations for free. Chrome's equivalent registration is cleared when
|
|
324
|
+
* the registering client disconnects, and this toolkit's CDP lifetime is
|
|
325
|
+
* "per-call", which is why cdp/driver.ts has to hold a socket per beaconed tab
|
|
326
|
+
* and this file does not. The map exists only to avoid stacking a second
|
|
327
|
+
* registration on a context that already has one; the in-page script is
|
|
328
|
+
* idempotent, so a duplicate would be harmless but pointless.
|
|
329
|
+
*/
|
|
330
|
+
const bidiBeaconScripts = new Map<string, string>();
|
|
331
|
+
|
|
304
332
|
/* ----------------------------------- context/page lookup ----------------------------------- */
|
|
305
333
|
async function safeTitle(conn: BidiConnection, contextId: BrowsingContextId): Promise<string> {
|
|
306
334
|
try {
|
|
@@ -358,6 +386,32 @@ async function pickContext(
|
|
|
358
386
|
for (const c of contexts) if ((await safeTitle(conn, c.context)).includes(needle)) return c;
|
|
359
387
|
throw driverError("no-such-target", `no context with title containing '${needle}'`);
|
|
360
388
|
}
|
|
389
|
+
if (selector.startsWith("label:")) {
|
|
390
|
+
const label = selector.slice(6);
|
|
391
|
+
const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
|
|
392
|
+
if (matchIds.length > 1) {
|
|
393
|
+
throw driverError("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
|
|
394
|
+
}
|
|
395
|
+
if (matchIds.length === 1) {
|
|
396
|
+
const hit = contexts.find((c) => c.context === matchIds[0]);
|
|
397
|
+
if (hit) return hit;
|
|
398
|
+
}
|
|
399
|
+
throw driverError(
|
|
400
|
+
"no-such-target",
|
|
401
|
+
knownLabels.length
|
|
402
|
+
? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
|
|
403
|
+
: `no live target with label '${label}' (no labels are currently assigned to any open target)`,
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
if (isWorkerSelector(selector)) {
|
|
407
|
+
// Capability-gated, not unimplemented: "unsupported" is the DriverErrorCode
|
|
408
|
+
// that tells a caller to check the capability rather than retry. The tool
|
|
409
|
+
// itself stays available on Firefox — only this arm of the grammar is
|
|
410
|
+
// refused, exactly as drag's mode:'html5' is. Refusing loudly beats the
|
|
411
|
+
// alternative the bare-id fall-through would produce, which is a confusing
|
|
412
|
+
// "no context matching 'worker:abc'" that reads like a typo.
|
|
413
|
+
throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
|
|
414
|
+
}
|
|
361
415
|
const exact = contexts.find((c) => c.context === selector);
|
|
362
416
|
if (exact) return exact;
|
|
363
417
|
throw driverError("no-such-target", `no context matching '${selector}'`);
|
|
@@ -369,9 +423,51 @@ class BidiPageDriver implements PageDriver {
|
|
|
369
423
|
private bodyCollector?: string;
|
|
370
424
|
constructor(private readonly conn: BidiConnection, private readonly contextId: BrowsingContextId, readonly info: PageInfo, readonly browser: BrowserDriver) {}
|
|
371
425
|
|
|
426
|
+
/** `location.href` and `document.readyState` in one round trip, NUL-joined so the result comes
|
|
427
|
+
* back as one plain string (deserializeRemote's simplest shape). Empty strings on any failure —
|
|
428
|
+
* a transient evaluate error during a navigation is normal and must not abort a poll. */
|
|
429
|
+
private async urlAndReadyState(): Promise<[string, string]> {
|
|
430
|
+
const raw = await this.conn.send("script.evaluate", {
|
|
431
|
+
expression: "location.href + '\\u0000' + document.readyState",
|
|
432
|
+
target: { context: this.contextId }, awaitPromise: false,
|
|
433
|
+
}).then((r) => (r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "")).catch(() => "");
|
|
434
|
+
const [url = "", ready = ""] = raw.split("\u0000");
|
|
435
|
+
return [url, ready];
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Bounded readiness wait after a history traversal.
|
|
440
|
+
*
|
|
441
|
+
* WHY THIS EXISTS AT ALL. browsingContext.navigate and .reload both take a `wait` readiness state
|
|
442
|
+
* and block server-side until it is reached, which is why the two branches below simply return
|
|
443
|
+
* `waitedFor: waitUntil`. browsingContext.traverseHistory takes NO `wait` parameter and returns
|
|
444
|
+
* as soon as the traversal is dispatched, so this driver owns the wait for that one path — the
|
|
445
|
+
* same position cdp/driver.ts is in for every navigation.
|
|
446
|
+
*
|
|
447
|
+
* Polling rather than a `browsingContext.load` subscription, matching waitForText's in-file
|
|
448
|
+
* pattern: a bfcache restore does not necessarily re-fire a load event, so an event wait could
|
|
449
|
+
* hang on a perfectly successful traversal while a poll observes the settled document directly.
|
|
450
|
+
*
|
|
451
|
+
* KNOWN LIMIT, stated rather than hidden: "we moved" is inferred from the url changing. A
|
|
452
|
+
* traversal between two history entries with the IDENTICAL url therefore cannot be confirmed and
|
|
453
|
+
* burns the whole budget before returning "timeout" — with the correct url, and after the
|
|
454
|
+
* traversal really did happen. That is the honest reading of the only signal available here.
|
|
455
|
+
*/
|
|
456
|
+
private async settleAfterTraversal(beforeUrl: string, waitUntil: "load" | "domcontentloaded", timeoutMs: number): Promise<{ waitedFor: NavigateResult["waitedFor"]; url: string }> {
|
|
457
|
+
const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
|
|
458
|
+
const deadline = Date.now() + timeoutMs;
|
|
459
|
+
for (;;) {
|
|
460
|
+
const [url, state] = await this.urlAndReadyState();
|
|
461
|
+
if (url && url !== beforeUrl && ready.includes(state)) return { waitedFor: waitUntil, url };
|
|
462
|
+
if (Date.now() >= deadline) return { waitedFor: "timeout", url: url || beforeUrl };
|
|
463
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
372
467
|
async navigate(opts: NavigateOptions): Promise<NavigateResult> {
|
|
373
468
|
const reload = opts.reload === true;
|
|
374
|
-
|
|
469
|
+
const history = opts.history;
|
|
470
|
+
if (!reload && !history && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
375
471
|
const waitUntil = opts.waitUntil ?? "load";
|
|
376
472
|
const wait: BrowsingContextReadinessState = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
377
473
|
const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
@@ -380,6 +476,21 @@ class BidiPageDriver implements PageDriver {
|
|
|
380
476
|
const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
|
|
381
477
|
return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
382
478
|
}
|
|
479
|
+
if (history) {
|
|
480
|
+
const [beforeUrl] = await this.urlAndReadyState();
|
|
481
|
+
// NavigateOptions.history forbids a silent no-op. Firefox answers a traversal past either
|
|
482
|
+
// end of the session history with a "no such history entry" error, which mapBidiError would
|
|
483
|
+
// relay verbatim — a message that never names the direction the caller asked for. Rewrapped
|
|
484
|
+
// here so back-at-the-start and forward-at-the-end read the same on both backends.
|
|
485
|
+
await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts)
|
|
486
|
+
.catch((e) => {
|
|
487
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
488
|
+
if (/no such history entry|history/i.test(msg)) throw driverError("page-error", `navigate: no history entry to go ${history} to (${msg})`);
|
|
489
|
+
throw e;
|
|
490
|
+
});
|
|
491
|
+
const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15_000);
|
|
492
|
+
return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
|
|
493
|
+
}
|
|
383
494
|
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url as string, wait }, sendOpts);
|
|
384
495
|
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
385
496
|
} catch (e) {
|
|
@@ -454,7 +565,25 @@ class BidiPageDriver implements PageDriver {
|
|
|
454
565
|
if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${uid}`);
|
|
455
566
|
}
|
|
456
567
|
|
|
568
|
+
/**
|
|
569
|
+
* THE INPUT DISPATCH CHOKE POINT for Firefox, and the direct counterpart of
|
|
570
|
+
* cdp/driver.ts's dispatchInput. Every synthesized input this driver produces
|
|
571
|
+
* is already funnelled through this one method (hover, click, scroll, drag,
|
|
572
|
+
* setValue, typeText, pressKey all build actions and call it), so the
|
|
573
|
+
* dispatch-log write that keeps the toolkit's own input from reading as a
|
|
574
|
+
* human's needs exactly one line, here. See ../activity.ts.
|
|
575
|
+
*
|
|
576
|
+
* scroll and the generalised drag arrived on the OTHER 1.8.0 branch (Track P)
|
|
577
|
+
* and inherited the log for free by building on this method — which is the
|
|
578
|
+
* whole argument for a choke point, and the reason this list is worth keeping
|
|
579
|
+
* accurate rather than deleting.
|
|
580
|
+
*
|
|
581
|
+
* NOT routed through here: input.setFiles (upload_file). Same reasoning as the
|
|
582
|
+
* CDP side — it fires none of the beacon's events, so logging it could only
|
|
583
|
+
* suppress a real human input, never prevent a false one.
|
|
584
|
+
*/
|
|
457
585
|
private async performActions(actions: InputSourceActions[]): Promise<void> {
|
|
586
|
+
recordDispatch("firefox", this.contextId);
|
|
458
587
|
try {
|
|
459
588
|
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
460
589
|
} catch (e) {
|
|
@@ -474,7 +603,18 @@ class BidiPageDriver implements PageDriver {
|
|
|
474
603
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
|
|
475
604
|
return { x, y };
|
|
476
605
|
}
|
|
606
|
+
// modifiers is new in 1.8.0 (Track P1). Chrome expresses it as a bitmask on the same
|
|
607
|
+
// mousePressed/mouseReleased events (cdp/driver.ts's inputModifierBits); WebDriver BiDi has no
|
|
608
|
+
// equivalent field on a pointerDown/pointerUp action; a modifier chord instead requires a SECOND
|
|
609
|
+
// "key" input source ticked in lockstep with the pointer source across the same performActions
|
|
610
|
+
// call, which the spec explicitly permits skipping ("throw a clear error when modifiers are
|
|
611
|
+
// passed on firefox" — see cdp-toolkit's 1.8.0 spec, Track P1 §3) and this driver was not
|
|
612
|
+
// verified against real Firefox for this pass, so it throws "unsupported" rather than guess at
|
|
613
|
+
// untested tick-synchronization semantics.
|
|
477
614
|
async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
|
|
615
|
+
if (opts?.modifiers && opts.modifiers.length > 0) {
|
|
616
|
+
throw driverError("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
|
|
617
|
+
}
|
|
478
618
|
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
479
619
|
const { x, y } = await this.centerOf(ref);
|
|
480
620
|
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
@@ -483,17 +623,63 @@ class BidiPageDriver implements PageDriver {
|
|
|
483
623
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
484
624
|
return { x, y };
|
|
485
625
|
}
|
|
486
|
-
|
|
626
|
+
// 1.8.0 Track P1: BiDi's "wheel" input source (a distinct source type from "pointer"/"key")
|
|
627
|
+
// carries x/y/deltaX/deltaY directly on its one "scroll" action — no tick synchronization with
|
|
628
|
+
// another source needed, unlike click's modifiers above, which is what makes this tractable.
|
|
629
|
+
// Same settle-wait rationale as cdp/driver.ts's armScrollSettleWatch: a wheel-triggered scroll
|
|
630
|
+
// is a browser-animated effect, not a synchronous DOM mutation, so a caller reading scroll
|
|
631
|
+
// position immediately after performActions resolves risks the pre-scroll value. Not empirically
|
|
632
|
+
// verified against real Firefox this pass (no Firefox available in this environment), but applied
|
|
633
|
+
// for consistency with the verified Chrome behavior rather than left as a known, unfixed gap.
|
|
634
|
+
async scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }> {
|
|
635
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
636
|
+
const deltaX = Math.round(opts.deltaX ?? 0);
|
|
637
|
+
const deltaY = Math.round(opts.deltaY ?? 0);
|
|
638
|
+
await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
|
|
639
|
+
const actions: InputWheelSourceAction[] = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
|
|
640
|
+
await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
|
|
641
|
+
await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => undefined);
|
|
642
|
+
return { x, y };
|
|
643
|
+
}
|
|
644
|
+
private async resolveScrollPoint(anchor: ElementLocator | { x: number; y: number } | undefined): Promise<{ x: number; y: number }> {
|
|
645
|
+
if (anchor === undefined) {
|
|
646
|
+
const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
|
|
647
|
+
if (!v || typeof v !== "object") throw driverError("page-error", "could not measure viewport for scroll center");
|
|
648
|
+
return v as { x: number; y: number };
|
|
649
|
+
}
|
|
650
|
+
if ("x" in anchor) return anchor;
|
|
651
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, anchor));
|
|
652
|
+
}
|
|
653
|
+
// 1.8.0 Track P2. mode:"html5" is REFUSED here, never silently downgraded to mouse mode: WebDriver
|
|
654
|
+
// BiDi has no drag-interception primitive at all (no setInterceptDrags equivalent, no drag event
|
|
655
|
+
// to subscribe to, no dispatchDragEvent), so there is nothing to port. The param-level refusal
|
|
656
|
+
// mirrors click's modifiers-on-Firefox above — `drag` itself stays in tools/list because mouse
|
|
657
|
+
// mode is fully supported here. `to` accepting a point or an offset, and `steps`, are both
|
|
658
|
+
// backend-neutral and DO work on this driver.
|
|
659
|
+
async drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
|
|
660
|
+
if (opts?.mode === "html5") {
|
|
661
|
+
throw driverError(
|
|
662
|
+
"unsupported",
|
|
663
|
+
"drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " +
|
|
664
|
+
"omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
|
|
665
|
+
);
|
|
666
|
+
}
|
|
487
667
|
const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
|
|
488
|
-
const toPt = await this.
|
|
668
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
669
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
489
670
|
const actions: InputPointerSourceAction[] = [
|
|
490
671
|
{ type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 }, { type: "pointerDown", button: 0 },
|
|
491
|
-
{ type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
|
|
492
|
-
{ type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 }, { type: "pointerUp", button: 0 },
|
|
493
672
|
];
|
|
673
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
|
|
674
|
+
actions.push({ type: "pointerUp", button: 0 });
|
|
494
675
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
495
676
|
return { from: fromPt, to: toPt };
|
|
496
677
|
}
|
|
678
|
+
private async resolveDragDestination(to: DragDestination, fromPt: { x: number; y: number }): Promise<{ x: number; y: number }> {
|
|
679
|
+
if ("dx" in to) return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
680
|
+
if ("x" in to) return to;
|
|
681
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
|
|
682
|
+
}
|
|
497
683
|
// No atomic value-commit primitive over BiDi (established fact): every key goes through
|
|
498
684
|
// input.performActions, batched into ONE call regardless of string length, so setValue/typeText
|
|
499
685
|
// cost exactly 2 round trips (one evaluate to focus/clear, one performActions for the keys).
|
|
@@ -921,7 +1107,72 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
921
1107
|
const info = await pageInfoOf(conn, ctx);
|
|
922
1108
|
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
923
1109
|
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Install the activity beacon (../activity.ts) into a context and every
|
|
1113
|
+
* document it navigates to afterwards.
|
|
1114
|
+
*
|
|
1115
|
+
* Both legs matter: script.addPreloadScript arms every FUTURE document (and
|
|
1116
|
+
* survives navigation for free here, see bidiBeaconScripts above), while the
|
|
1117
|
+
* script.evaluate arms the document already loaded, which a preload script
|
|
1118
|
+
* never retroactively reaches.
|
|
1119
|
+
*
|
|
1120
|
+
* DELIBERATELY GATE-FREE (no resolveContext), like its CDP twin and for the
|
|
1121
|
+
* same reason: the claim_page{target} path reads and installs around a claim
|
|
1122
|
+
* that the lease gate would otherwise take on its own behalf. Returning false
|
|
1123
|
+
* rather than throwing is the contract — installBeacon's caller treats this as
|
|
1124
|
+
* an annotation that must never fail real work.
|
|
1125
|
+
*/
|
|
1126
|
+
async installActivityBeacon(contextId: string): Promise<boolean> {
|
|
1127
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
|
|
1128
|
+
if (!conn) return false;
|
|
1129
|
+
try {
|
|
1130
|
+
const key = `${this.port}:${contextId}`;
|
|
1131
|
+
if (!bidiBeaconScripts.has(key)) {
|
|
1132
|
+
const registered = await conn.send("script.addPreloadScript", {
|
|
1133
|
+
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
1134
|
+
contexts: [contextId],
|
|
1135
|
+
});
|
|
1136
|
+
bidiBeaconScripts.set(key, registered.script);
|
|
1137
|
+
}
|
|
1138
|
+
await conn.send("script.evaluate", {
|
|
1139
|
+
expression: BEACON_SOURCE,
|
|
1140
|
+
target: { context: contextId },
|
|
1141
|
+
awaitPromise: false,
|
|
1142
|
+
});
|
|
1143
|
+
return true;
|
|
1144
|
+
} catch {
|
|
1145
|
+
return false;
|
|
1146
|
+
} finally {
|
|
1147
|
+
conn.release();
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Read the beacon timestamp for a context, or null when it has none. Gate-free;
|
|
1152
|
+
* see the declaration in ../driver.ts for why that is required here. */
|
|
1153
|
+
async readActivityBeacon(contextId: string): Promise<number | null> {
|
|
1154
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
|
|
1155
|
+
if (!conn) return null;
|
|
1156
|
+
try {
|
|
1157
|
+
const res = await conn.send("script.evaluate", {
|
|
1158
|
+
expression: BEACON_READ_EXPRESSION,
|
|
1159
|
+
target: { context: contextId },
|
|
1160
|
+
awaitPromise: false,
|
|
1161
|
+
});
|
|
1162
|
+
if (res.type !== "success") return null;
|
|
1163
|
+
const value = deserializeRemote(res.result);
|
|
1164
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
} finally {
|
|
1168
|
+
conn.release();
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
924
1172
|
async dispose(): Promise<void> {
|
|
1173
|
+
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
1174
|
+
if (key.startsWith(`${this.port}:`)) bidiBeaconScripts.delete(key);
|
|
1175
|
+
}
|
|
925
1176
|
const conn = connections.get(this.port);
|
|
926
1177
|
if (!conn) return;
|
|
927
1178
|
connections.delete(this.port);
|
package/src/capabilities.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-backend tool availability: filters the
|
|
2
|
+
* Per-backend tool availability: filters the 45-tool registry down to what a
|
|
3
3
|
* given DriverKind can actually run, per ADR-001's rule that tools/list
|
|
4
4
|
* discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
|
|
5
5
|
* construction reads a static readonly `capabilities` set, it does not dial a
|