cdp-toolkit 1.6.0 → 1.8.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.
@@ -24,14 +24,16 @@
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 { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
35
37
  import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
36
38
  import { takeStampedSnapshot } from "./snapshot.ts";
37
39
  import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
@@ -39,7 +41,7 @@ import type {
39
41
  BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
40
42
  BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
41
43
  ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
42
- NetworkCookie,
44
+ InputWheelSourceAction, NetworkCookie,
43
45
  } from "./protocol.ts";
44
46
 
45
47
  /* ---------------------------------- cookies ---------------------------------- */
@@ -256,6 +258,14 @@ const KEY_VALUES: Record<string, string> = {
256
258
  const MODIFIER_VALUES: Record<string, string> = { shift: "\uE008", control: "\uE009", ctrl: "\uE009", alt: "\uE00A", meta: "\uE03D", cmd: "\uE03D", command: "\uE03D" };
257
259
  const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, middle: 1, right: 2 };
258
260
 
261
+ /** Same scroll-settle technique as cdp/driver.ts's ARM_SCROLL_SETTLE_WATCH / awaitScrollSettle,
262
+ * see that file's doc comment for the full rationale: a capture-phase 'scroll' listener on
263
+ * window, debounced 60ms with a 500ms cap, so scroll()'s caller observes the settled position. */
264
+ const ARM_SCROLL_SETTLE_WATCH_SOURCE =
265
+ "function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" +
266
+ "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
267
+ const AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
268
+
259
269
  /**
260
270
  * Firefox 153 capabilities. Only what was empirically verified is declared: emulate.deviceMetrics
261
271
  * (browsingContext.setViewport) and the userAgent path (universal, no capability token) both work.
@@ -301,6 +311,22 @@ async function getConnection(port: number, timeoutMs?: number): Promise<BidiConn
301
311
  return conn;
302
312
  }
303
313
 
314
+ /**
315
+ * Contexts that already carry a beacon preload script, keyed port:context ->
316
+ * the script id BiDi handed back.
317
+ *
318
+ * WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
319
+ * LifetimeModel is "session": ONE BidiConnection is memoized per port above and
320
+ * lives until dispose(), so a preload script registered on it stays registered
321
+ * across navigations for free. Chrome's equivalent registration is cleared when
322
+ * the registering client disconnects, and this toolkit's CDP lifetime is
323
+ * "per-call", which is why cdp/driver.ts has to hold a socket per beaconed tab
324
+ * and this file does not. The map exists only to avoid stacking a second
325
+ * registration on a context that already has one; the in-page script is
326
+ * idempotent, so a duplicate would be harmless but pointless.
327
+ */
328
+ const bidiBeaconScripts = new Map<string, string>();
329
+
304
330
  /* ----------------------------------- context/page lookup ----------------------------------- */
305
331
  async function safeTitle(conn: BidiConnection, contextId: BrowsingContextId): Promise<string> {
306
332
  try {
@@ -369,9 +395,51 @@ class BidiPageDriver implements PageDriver {
369
395
  private bodyCollector?: string;
370
396
  constructor(private readonly conn: BidiConnection, private readonly contextId: BrowsingContextId, readonly info: PageInfo, readonly browser: BrowserDriver) {}
371
397
 
398
+ /** `location.href` and `document.readyState` in one round trip, NUL-joined so the result comes
399
+ * back as one plain string (deserializeRemote's simplest shape). Empty strings on any failure —
400
+ * a transient evaluate error during a navigation is normal and must not abort a poll. */
401
+ private async urlAndReadyState(): Promise<[string, string]> {
402
+ const raw = await this.conn.send("script.evaluate", {
403
+ expression: "location.href + '\\u0000' + document.readyState",
404
+ target: { context: this.contextId }, awaitPromise: false,
405
+ }).then((r) => (r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "")).catch(() => "");
406
+ const [url = "", ready = ""] = raw.split("\u0000");
407
+ return [url, ready];
408
+ }
409
+
410
+ /**
411
+ * Bounded readiness wait after a history traversal.
412
+ *
413
+ * WHY THIS EXISTS AT ALL. browsingContext.navigate and .reload both take a `wait` readiness state
414
+ * and block server-side until it is reached, which is why the two branches below simply return
415
+ * `waitedFor: waitUntil`. browsingContext.traverseHistory takes NO `wait` parameter and returns
416
+ * as soon as the traversal is dispatched, so this driver owns the wait for that one path — the
417
+ * same position cdp/driver.ts is in for every navigation.
418
+ *
419
+ * Polling rather than a `browsingContext.load` subscription, matching waitForText's in-file
420
+ * pattern: a bfcache restore does not necessarily re-fire a load event, so an event wait could
421
+ * hang on a perfectly successful traversal while a poll observes the settled document directly.
422
+ *
423
+ * KNOWN LIMIT, stated rather than hidden: "we moved" is inferred from the url changing. A
424
+ * traversal between two history entries with the IDENTICAL url therefore cannot be confirmed and
425
+ * burns the whole budget before returning "timeout" — with the correct url, and after the
426
+ * traversal really did happen. That is the honest reading of the only signal available here.
427
+ */
428
+ private async settleAfterTraversal(beforeUrl: string, waitUntil: "load" | "domcontentloaded", timeoutMs: number): Promise<{ waitedFor: NavigateResult["waitedFor"]; url: string }> {
429
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
430
+ const deadline = Date.now() + timeoutMs;
431
+ for (;;) {
432
+ const [url, state] = await this.urlAndReadyState();
433
+ if (url && url !== beforeUrl && ready.includes(state)) return { waitedFor: waitUntil, url };
434
+ if (Date.now() >= deadline) return { waitedFor: "timeout", url: url || beforeUrl };
435
+ await new Promise((r) => setTimeout(r, 50));
436
+ }
437
+ }
438
+
372
439
  async navigate(opts: NavigateOptions): Promise<NavigateResult> {
373
440
  const reload = opts.reload === true;
374
- if (!reload && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
441
+ const history = opts.history;
442
+ if (!reload && !history && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
375
443
  const waitUntil = opts.waitUntil ?? "load";
376
444
  const wait: BrowsingContextReadinessState = waitUntil === "domcontentloaded" ? "interactive" : "complete";
377
445
  const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
@@ -380,6 +448,21 @@ class BidiPageDriver implements PageDriver {
380
448
  const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
381
449
  return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
382
450
  }
451
+ if (history) {
452
+ const [beforeUrl] = await this.urlAndReadyState();
453
+ // NavigateOptions.history forbids a silent no-op. Firefox answers a traversal past either
454
+ // end of the session history with a "no such history entry" error, which mapBidiError would
455
+ // relay verbatim — a message that never names the direction the caller asked for. Rewrapped
456
+ // here so back-at-the-start and forward-at-the-end read the same on both backends.
457
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts)
458
+ .catch((e) => {
459
+ const msg = e instanceof Error ? e.message : String(e);
460
+ if (/no such history entry|history/i.test(msg)) throw driverError("page-error", `navigate: no history entry to go ${history} to (${msg})`);
461
+ throw e;
462
+ });
463
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15_000);
464
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
465
+ }
383
466
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url as string, wait }, sendOpts);
384
467
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
385
468
  } catch (e) {
@@ -454,7 +537,25 @@ class BidiPageDriver implements PageDriver {
454
537
  if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${uid}`);
455
538
  }
456
539
 
540
+ /**
541
+ * THE INPUT DISPATCH CHOKE POINT for Firefox, and the direct counterpart of
542
+ * cdp/driver.ts's dispatchInput. Every synthesized input this driver produces
543
+ * is already funnelled through this one method (hover, click, scroll, drag,
544
+ * setValue, typeText, pressKey all build actions and call it), so the
545
+ * dispatch-log write that keeps the toolkit's own input from reading as a
546
+ * human's needs exactly one line, here. See ../activity.ts.
547
+ *
548
+ * scroll and the generalised drag arrived on the OTHER 1.8.0 branch (Track P)
549
+ * and inherited the log for free by building on this method — which is the
550
+ * whole argument for a choke point, and the reason this list is worth keeping
551
+ * accurate rather than deleting.
552
+ *
553
+ * NOT routed through here: input.setFiles (upload_file). Same reasoning as the
554
+ * CDP side — it fires none of the beacon's events, so logging it could only
555
+ * suppress a real human input, never prevent a false one.
556
+ */
457
557
  private async performActions(actions: InputSourceActions[]): Promise<void> {
558
+ recordDispatch("firefox", this.contextId);
458
559
  try {
459
560
  await this.conn.send("input.performActions", { context: this.contextId, actions });
460
561
  } catch (e) {
@@ -474,7 +575,18 @@ class BidiPageDriver implements PageDriver {
474
575
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
475
576
  return { x, y };
476
577
  }
578
+ // modifiers is new in 1.8.0 (Track P1). Chrome expresses it as a bitmask on the same
579
+ // mousePressed/mouseReleased events (cdp/driver.ts's inputModifierBits); WebDriver BiDi has no
580
+ // equivalent field on a pointerDown/pointerUp action; a modifier chord instead requires a SECOND
581
+ // "key" input source ticked in lockstep with the pointer source across the same performActions
582
+ // call, which the spec explicitly permits skipping ("throw a clear error when modifiers are
583
+ // passed on firefox" — see cdp-toolkit's 1.8.0 spec, Track P1 §3) and this driver was not
584
+ // verified against real Firefox for this pass, so it throws "unsupported" rather than guess at
585
+ // untested tick-synchronization semantics.
477
586
  async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
587
+ if (opts?.modifiers && opts.modifiers.length > 0) {
588
+ throw driverError("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
589
+ }
478
590
  const ref = await resolveElementLocator(this.conn, this.contextId, loc);
479
591
  const { x, y } = await this.centerOf(ref);
480
592
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -483,17 +595,63 @@ class BidiPageDriver implements PageDriver {
483
595
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
484
596
  return { x, y };
485
597
  }
486
- async drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
598
+ // 1.8.0 Track P1: BiDi's "wheel" input source (a distinct source type from "pointer"/"key")
599
+ // carries x/y/deltaX/deltaY directly on its one "scroll" action — no tick synchronization with
600
+ // another source needed, unlike click's modifiers above, which is what makes this tractable.
601
+ // Same settle-wait rationale as cdp/driver.ts's armScrollSettleWatch: a wheel-triggered scroll
602
+ // is a browser-animated effect, not a synchronous DOM mutation, so a caller reading scroll
603
+ // position immediately after performActions resolves risks the pre-scroll value. Not empirically
604
+ // verified against real Firefox this pass (no Firefox available in this environment), but applied
605
+ // for consistency with the verified Chrome behavior rather than left as a known, unfixed gap.
606
+ async scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }> {
607
+ const { x, y } = await this.resolveScrollPoint(anchor);
608
+ const deltaX = Math.round(opts.deltaX ?? 0);
609
+ const deltaY = Math.round(opts.deltaY ?? 0);
610
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
611
+ const actions: InputWheelSourceAction[] = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
612
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
613
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => undefined);
614
+ return { x, y };
615
+ }
616
+ private async resolveScrollPoint(anchor: ElementLocator | { x: number; y: number } | undefined): Promise<{ x: number; y: number }> {
617
+ if (anchor === undefined) {
618
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
619
+ if (!v || typeof v !== "object") throw driverError("page-error", "could not measure viewport for scroll center");
620
+ return v as { x: number; y: number };
621
+ }
622
+ if ("x" in anchor) return anchor;
623
+ return this.centerOf(await resolveElementLocator(this.conn, this.contextId, anchor));
624
+ }
625
+ // 1.8.0 Track P2. mode:"html5" is REFUSED here, never silently downgraded to mouse mode: WebDriver
626
+ // BiDi has no drag-interception primitive at all (no setInterceptDrags equivalent, no drag event
627
+ // to subscribe to, no dispatchDragEvent), so there is nothing to port. The param-level refusal
628
+ // mirrors click's modifiers-on-Firefox above — `drag` itself stays in tools/list because mouse
629
+ // mode is fully supported here. `to` accepting a point or an offset, and `steps`, are both
630
+ // backend-neutral and DO work on this driver.
631
+ async drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
632
+ if (opts?.mode === "html5") {
633
+ throw driverError(
634
+ "unsupported",
635
+ "drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " +
636
+ "omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
637
+ );
638
+ }
487
639
  const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
488
- const toPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
640
+ const toPt = await this.resolveDragDestination(to, fromPt);
641
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
489
642
  const actions: InputPointerSourceAction[] = [
490
643
  { 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
644
  ];
645
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
646
+ actions.push({ type: "pointerUp", button: 0 });
494
647
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
495
648
  return { from: fromPt, to: toPt };
496
649
  }
650
+ private async resolveDragDestination(to: DragDestination, fromPt: { x: number; y: number }): Promise<{ x: number; y: number }> {
651
+ if ("dx" in to) return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
652
+ if ("x" in to) return to;
653
+ return this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
654
+ }
497
655
  // No atomic value-commit primitive over BiDi (established fact): every key goes through
498
656
  // input.performActions, batched into ONE call regardless of string length, so setValue/typeText
499
657
  // cost exactly 2 round trips (one evaluate to focus/clear, one performActions for the keys).
@@ -921,7 +1079,72 @@ class BidiBrowserDriver implements BrowserDriver {
921
1079
  const info = await pageInfoOf(conn, ctx);
922
1080
  return new BidiPageDriver(conn, ctx.context, info, this);
923
1081
  }
1082
+
1083
+ /**
1084
+ * Install the activity beacon (../activity.ts) into a context and every
1085
+ * document it navigates to afterwards.
1086
+ *
1087
+ * Both legs matter: script.addPreloadScript arms every FUTURE document (and
1088
+ * survives navigation for free here, see bidiBeaconScripts above), while the
1089
+ * script.evaluate arms the document already loaded, which a preload script
1090
+ * never retroactively reaches.
1091
+ *
1092
+ * DELIBERATELY GATE-FREE (no resolveContext), like its CDP twin and for the
1093
+ * same reason: the claim_page{target} path reads and installs around a claim
1094
+ * that the lease gate would otherwise take on its own behalf. Returning false
1095
+ * rather than throwing is the contract — installBeacon's caller treats this as
1096
+ * an annotation that must never fail real work.
1097
+ */
1098
+ async installActivityBeacon(contextId: string): Promise<boolean> {
1099
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
1100
+ if (!conn) return false;
1101
+ try {
1102
+ const key = `${this.port}:${contextId}`;
1103
+ if (!bidiBeaconScripts.has(key)) {
1104
+ const registered = await conn.send("script.addPreloadScript", {
1105
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
1106
+ contexts: [contextId],
1107
+ });
1108
+ bidiBeaconScripts.set(key, registered.script);
1109
+ }
1110
+ await conn.send("script.evaluate", {
1111
+ expression: BEACON_SOURCE,
1112
+ target: { context: contextId },
1113
+ awaitPromise: false,
1114
+ });
1115
+ return true;
1116
+ } catch {
1117
+ return false;
1118
+ } finally {
1119
+ conn.release();
1120
+ }
1121
+ }
1122
+
1123
+ /** Read the beacon timestamp for a context, or null when it has none. Gate-free;
1124
+ * see the declaration in ../driver.ts for why that is required here. */
1125
+ async readActivityBeacon(contextId: string): Promise<number | null> {
1126
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
1127
+ if (!conn) return null;
1128
+ try {
1129
+ const res = await conn.send("script.evaluate", {
1130
+ expression: BEACON_READ_EXPRESSION,
1131
+ target: { context: contextId },
1132
+ awaitPromise: false,
1133
+ });
1134
+ if (res.type !== "success") return null;
1135
+ const value = deserializeRemote(res.result);
1136
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1137
+ } catch {
1138
+ return null;
1139
+ } finally {
1140
+ conn.release();
1141
+ }
1142
+ }
1143
+
924
1144
  async dispose(): Promise<void> {
1145
+ for (const key of [...bidiBeaconScripts.keys()]) {
1146
+ if (key.startsWith(`${this.port}:`)) bidiBeaconScripts.delete(key);
1147
+ }
925
1148
  const conn = connections.get(this.port);
926
1149
  if (!conn) return;
927
1150
  connections.delete(this.port);
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Per-backend tool availability: filters the 41-tool registry down to what a
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