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/src/cdp/driver.ts CHANGED
@@ -5,16 +5,19 @@
5
5
  * one connection via openPage and holds it for the PageDriver's life; release() closes it. Uid
6
6
  * codec (scheme "cdp"): `cdp:<backendDOMNodeId>`, or a bare legacy numeric uid, per THE UID CODEC block in ../driver.ts.
7
7
  */
8
- import { type CdpConnection, CdpError, listTargets, openBrowser, openPage, resolveTarget } from "../client.ts";
8
+ import { CdpConnection, CdpError, listTargets, openBrowser, openPage, resolveTarget } from "../client.ts";
9
9
  import type { Target, TargetSelector } from "../types.ts";
10
10
  import { LeaseConflictError } from "../leases.ts";
11
+ import { BEACON_READ_EXPRESSION, BEACON_SOURCE, BeaconSessions, RENDERER_PROBE_EXPRESSION, RENDERER_PROBE_TIMEOUT_MS, sendInput } from "../activity.ts";
11
12
  import { resolveUid } from "../tools/snapshot.ts";
13
+ import { resolveWorkerSelector } from "./workers.ts";
12
14
  import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
13
15
  import {
14
- LEGACY_NUMERIC_UID, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
16
+ LEGACY_NUMERIC_UID, interpolatePoints, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
15
17
  type SetCookieParams,
18
+ type DragDestination, type DragOptions,
16
19
  type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel, type MouseButtonOptions,
17
- type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type SnapshotNode, type UidStability,
20
+ type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo, type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
18
21
  } from "../driver.ts";
19
22
  /* ------------------------------ error + uid codec ------------------------------ */
20
23
  function driverError(code: DriverErrorCode, message: string, data?: unknown): DriverError {
@@ -88,13 +91,51 @@ async function centerOf(conn: CdpConnection, objectId: string): Promise<{ x: num
88
91
  async function focusElement(conn: CdpConnection, objectId: string): Promise<void> {
89
92
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
90
93
  }
94
+ /**
95
+ * Chrome smooth-scrolls wheel-triggered scrolling by default (a genuine UX feature, not something
96
+ * a plain `--headless=new` launch turns off): Input.dispatchMouseEvent's mouseWheel command
97
+ * resolves once the event is QUEUED, not once the resulting scroll animation has settled, so a
98
+ * caller reading scrollTop immediately after dispatch can observe the PRE-scroll value. Verified
99
+ * empirically against real headless Chrome 151 (not inferred): reading scrollTop in the same tick
100
+ * after the command resolved showed 0; a fixed 300ms wait showed the expected value.
101
+ *
102
+ * Rather than a blind sleep (wrong for a large delta's longer animation, wasteful for a small
103
+ * one), arm a CAPTURE-phase 'scroll' listener on window BEFORE dispatching the wheel event.
104
+ * Capture-phase listeners on window see scroll events targeted at ANY descendant scrollable
105
+ * element, even though 'scroll' itself does not bubble — the standard scroll-event-delegation
106
+ * technique. The promise resolves once 60ms pass with no further scroll event (debounced, so a
107
+ * multi-frame animation is waited out rather than caught mid-flight), capped at 500ms so a wheel
108
+ * event that scrolls nothing (delta too small, target already at its scroll limit) cannot hang.
109
+ */
110
+ const ARM_SCROLL_SETTLE_WATCH =
111
+ "(function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" +
112
+ "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});})()";
113
+ async function armScrollSettleWatch(conn: CdpConnection): Promise<void> {
114
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
115
+ }
116
+ async function awaitScrollSettle(conn: CdpConnection): Promise<void> {
117
+ // Best-effort: a navigation racing the wheel event could tear down window.__cdpScrollSettle
118
+ // before this reads it, and that is not a scroll failure worth surfacing as one.
119
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => undefined);
120
+ }
91
121
  // Copied verbatim (behaviorally) from src/tools/input.ts `setValue`. CRITICAL: must stay
92
122
  // byte-identical to input.ts; see the side-by-side quote in the driver report.
93
- async function setValueOnObject(conn: CdpConnection, objectId: string, value: string): Promise<void> {
123
+ //
124
+ // `sendInput` is the ONE addition, and it changes no command, no parameter and no ordering:
125
+ // the Input.* leg is routed through the caller's input choke point instead of the raw
126
+ // connection, so this function's dispatch is logged like every other (see ../activity.ts).
127
+ // It is a parameter rather than a `conn.send` here because the choke point is per-PAGE
128
+ // (it knows the target id) and this helper only has a connection.
129
+ async function setValueOnObject(
130
+ conn: CdpConnection,
131
+ objectId: string,
132
+ value: string,
133
+ sendInput: (method: string, params: Record<string, unknown>) => Promise<unknown>,
134
+ ): Promise<void> {
94
135
  await focusElement(conn, objectId);
95
136
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
96
137
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
97
- if (value.length) await conn.send("Input.insertText", { text: value });
138
+ if (value.length) await sendInput("Input.insertText", { text: value });
98
139
  }
99
140
  interface KeySpec { key: string; code: string; keyCode: number; text?: string }
100
141
  // Copied verbatim from src/tools/input.ts `NAMED_KEYS`.
@@ -111,6 +152,24 @@ const NAMED_KEYS: Record<string, KeySpec> = {
111
152
  pageup: { key: "PageUp", code: "PageUp", keyCode: 33 }, pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 },
112
153
  };
113
154
  const MODIFIER_BITS: Record<string, number> = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
155
+ // The closed click/dispatch_mouse modifiers enum (exact casing, no aliases): CDP bits Alt=1,
156
+ // Control=2, Meta=4, Shift=8, per driver.ts's MouseButtonOptions doc. Exported for the unit tests
157
+ // and for src/tools/dispatch-mouse.ts, which needs the identical bit math for its raw primitive.
158
+ const CLICK_MODIFIER_BITS: Record<string, number> = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
159
+ export function inputModifierBits(mods?: readonly string[]): number {
160
+ let bits = 0;
161
+ for (const m of mods ?? []) {
162
+ const bit = CLICK_MODIFIER_BITS[m];
163
+ if (bit === undefined) throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
164
+ bits |= bit;
165
+ }
166
+ return bits;
167
+ }
168
+ /** How long drag mode:"html5" waits for Chrome's Input.dragIntercepted before declaring the source
169
+ * element undraggable. Deliberately well under the 15s default command timeout: a page that is
170
+ * going to start a drag does so on the first move, so a longer wait only delays a clear error. */
171
+ const HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5_000;
172
+
114
173
  // Copied verbatim from src/tools/input.ts `resolveKey`.
115
174
  function resolveKey(key: string): KeySpec {
116
175
  const named = NAMED_KEYS[key.toLowerCase()];
@@ -261,13 +320,36 @@ async function elementClip(conn: CdpConnection, loc: ElementLocator): Promise<{
261
320
  * which is a real Chrome capability, not a line-budget artifact, so both ARE declared.
262
321
  * capture.screencast rides Page.startScreencast / Page.screencastFrame / Page.screencastFrameAck,
263
322
  * the streamed-repaint primitive behind start_screen_recording and stop_screen_recording.
323
+ * input.raw rides Input.dispatchMouseEvent dispatched one event at a time with no scroll-into-view
324
+ * or hover prefix (src/tools/dispatch-mouse.ts), the primitive behind dispatch_mouse; declared
325
+ * chrome-only per ADR-001 rather than attempted over BiDi's per-call performActions device-state
326
+ * model (see dispatch-mouse.ts's header for why that combination was not attempted this pass).
327
+ * input.html5Drag rides Input.setInterceptDrags + Input.dragIntercepted + Input.dispatchDragEvent
328
+ * (drag's mode:"html5", see CdpPageDriver.html5Drag). It is a PARAM-level capability, not a
329
+ * whole-tool one: `drag` itself stays universal because mouse mode works on both backends, so
330
+ * input.html5Drag is deliberately absent from driver.ts's REQUIRED_CAPABILITIES and is instead
331
+ * checked by shared-tools.ts's drag() when mode:"html5" is asked for. ADR-001 governs whole tools;
332
+ * a param gap surfaces as a clear validation error named in the manifest description, exactly like
333
+ * click's modifiers-on-Firefox (Track P1). WebDriver BiDi has no drag-interception primitive at
334
+ * all — no equivalent of setInterceptDrags, no drag event to subscribe to — so this is a genuine
335
+ * protocol gap, not an unattempted port.
336
+ * browser.downloads (wait_for_download) rides Browser.setDownloadBehavior + Browser.downloadWillBegin
337
+ * + Browser.downloadProgress, and browser.permissions (grant_permissions) rides
338
+ * Browser.grantPermissions + Browser.resetPermissions. Both are BROWSER-endpoint commands whose
339
+ * effect is bound to the CDP client that issued them and is REVERTED when that client disconnects
340
+ * (measured, both of them — see src/tools/browser-session.ts's header for the experiment), so both
341
+ * tools run on the standing connection that module holds rather than on a per-call socket. Neither
342
+ * is declared for BiDi: Firefox's browsingContext.downloadWillBegin/downloadEnd exist but there is
343
+ * no BiDi command to redirect a download to a chosen directory, and BiDi has no permission-granting
344
+ * command at all outside the optional `permissions` module this driver does not negotiate.
264
345
  */
265
346
  const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
266
347
  "trace.performance", "heap.snapshot", "audit.lighthouse", "emulate.cpuThrottling",
267
348
  "emulate.mediaFeatures", "emulate.deviceMetrics", "emulate.networkConditions",
268
349
  "screenshot.fullPage", "screenshot.element", "network.intercept",
269
350
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
270
- "capture.screencast",
351
+ "capture.screencast", "input.raw", "input.html5Drag",
352
+ "browser.downloads", "browser.permissions", "worker.targets",
271
353
  ]);
272
354
  /* ---------------------------------- PageDriver ---------------------------------- */
273
355
  class CdpPageDriver implements PageDriver {
@@ -286,6 +368,40 @@ class CdpPageDriver implements PageDriver {
286
368
  this.enabledDomains.add(name);
287
369
  await this.conn.send(`${name}.enable`);
288
370
  }
371
+ /**
372
+ * THE INPUT DISPATCH CHOKE POINT for Chrome. Every synthesized-input command
373
+ * this driver sends goes through here, and nothing else does.
374
+ *
375
+ * WHY IT IS A WRAPPER AND NOT A CALL AT THE TOP OF EACH METHOD. The
376
+ * bookkeeping (../activity.ts's dispatch log) is what keeps the toolkit's own
377
+ * clicks from reading as a human's, and a version of it that each new input
378
+ * method had to REMEMBER to call would be silently wrong for the first tool
379
+ * whose author did not know it existed — and the whole point of the log is
380
+ * that it is complete. Wrapping the send makes the coupling structural: an
381
+ * `Input.*` command cannot be sent from this class without being logged,
382
+ * because `this.conn.send` is not what sends it.
383
+ *
384
+ * IF YOU ARE ADDING AN INPUT TOOL (scroll, dispatch_mouse, a drag mode, ...):
385
+ * send through `this.dispatchInput(...)`, never `this.conn.send(...)`. That is
386
+ * the entire integration. 1.8.0's scroll and html5 drag arrived on a branch
387
+ * written before this method existed and did exactly that at merge time.
388
+ *
389
+ * The actual write lives in ../activity.ts's `sendInput`, which this delegates
390
+ * to, because one chrome-only tool (tools/dispatch-mouse.ts) dispatches input
391
+ * with a bare connection and no PageDriver to route through. Both callers share
392
+ * that one writer, so `conn.send("Input.` appears nowhere in src/ and the
393
+ * completeness of the log is a grep, not a convention.
394
+ *
395
+ * Deliberately NOT routed through here: DOM.setFileInputFiles (upload_file).
396
+ * It attaches files to an input element and fires the page's change handler;
397
+ * it synthesizes none of the beacon's events (pointerdown/mousedown/keydown/
398
+ * wheel/touchstart), so logging it could only suppress a genuine human input
399
+ * that happened to land within the attribution window, never prevent a false
400
+ * one.
401
+ */
402
+ private async dispatchInput<T = Record<string, unknown>>(method: string, params: Record<string, unknown> = {}): Promise<T> {
403
+ return sendInput<T>(this.conn, "chrome", this.info.id, method, params);
404
+ }
289
405
  /** Same as ensureDomain but tolerates the enable call itself failing (matches a legacy
290
406
  * `.catch(() => undefined)` site); a failed enable is NOT re-attempted on the next call. */
291
407
  private async ensureDomainSoft(name: string): Promise<void> {
@@ -293,10 +409,32 @@ class CdpPageDriver implements PageDriver {
293
409
  this.enabledDomains.add(name);
294
410
  await this.conn.send(`${name}.enable`).catch(() => undefined);
295
411
  }
412
+ /**
413
+ * The history entry exactly one step `direction` from the current one. Throws when there is
414
+ * none, naming the direction: NavigateOptions.history's contract forbids a silent no-op, and
415
+ * Page.navigateToHistoryEntry with a bad id would fail with a protocol message that never
416
+ * mentions "back" or "forward", which is not an answer a caller can act on.
417
+ */
418
+ private async historyEntry(direction: "back" | "forward"): Promise<{ id: number; url: string }> {
419
+ const h = await this.conn.send<{ currentIndex: number; entries: Array<{ id: number; url: string }> }>("Page.getNavigationHistory");
420
+ const entries = h.entries ?? [];
421
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
422
+ if (!entry) {
423
+ throw driverError(
424
+ "page-error",
425
+ `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`,
426
+ );
427
+ }
428
+ return entry;
429
+ }
296
430
  async navigate(opts: NavigateOptions): Promise<NavigateResult> {
297
431
  await this.ensureDomain("Page");
298
432
  const reload = opts.reload === true;
299
- if (!reload && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
433
+ const history = opts.history;
434
+ if (!reload && !history && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
435
+ // Resolved BEFORE the milestone waiters are armed: a refused traversal must throw without
436
+ // leaving two armed waitFor timers behind holding the event loop open for timeoutMs.
437
+ const entry = history ? await this.historyEntry(history) : undefined;
300
438
  const waitUntil = opts.waitUntil ?? "load";
301
439
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
302
440
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil as NavigateResult["waitedFor"]).catch(() => undefined);
@@ -308,6 +446,15 @@ class CdpPageDriver implements PageDriver {
308
446
  contextId = tree?.frameTree?.frame?.id ?? "";
309
447
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
310
448
  reloaded = true;
449
+ } else if (entry) {
450
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
451
+ // Provisional: the entry we asked for. The authoritative post-traversal url is read from the
452
+ // frame tree AFTER the milestone race below — navigateToHistoryEntry resolves the moment the
453
+ // traversal is dispatched, so reading the frame tree here would still report the OUTGOING
454
+ // document (measured: the reload branch gets away with an immediate read only because a
455
+ // reload's url cannot change).
456
+ contextId = "";
457
+ url = entry.url;
311
458
  } else {
312
459
  const nav = await this.conn.send<{ frameId: string; errorText?: string }>("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
313
460
  if (nav.errorText) throw driverError("page-error", `navigate: ${nav.errorText} (${opts.url})`);
@@ -315,12 +462,22 @@ class CdpPageDriver implements PageDriver {
315
462
  url = opts.url as string;
316
463
  }
317
464
  const settled = await Promise.race([milestone, stopped]);
465
+ if (entry) {
466
+ const tree = await this.conn.send<{ frameTree?: { frame?: { id?: string; url?: string } } }>("Page.getFrameTree").catch(() => undefined);
467
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
468
+ url = tree?.frameTree?.frame?.url ?? url;
469
+ }
318
470
  // "timeout" here means the navigation already committed (Page.navigate/reload resolved) but
319
471
  // no load/domcontentloaded milestone arrived before opts.timeoutMs; it is NOT the navigate
320
472
  // call itself timing out. NavigateResult.waitedFor's vocabulary is BiDi-shaped (BiDi's
321
473
  // navigate takes an explicit readiness state) rather than CDP-native, so this is the
322
474
  // closest honest mapping onto CDP's milestone events.
323
- return { url, contextId, ...(reloaded ? { reloaded } : {}), waitedFor: settled ?? "timeout" };
475
+ return {
476
+ url, contextId,
477
+ ...(reloaded ? { reloaded } : {}),
478
+ ...(history ? { traversed: history } : {}),
479
+ waitedFor: settled ?? "timeout",
480
+ };
324
481
  }
325
482
  async waitForText(text: string, timeoutMs = 15_000, pollMs = 250): Promise<{ found: true; elapsedMs: number }> {
326
483
  await this.ensureDomain("Runtime");
@@ -404,37 +561,143 @@ class CdpPageDriver implements PageDriver {
404
561
  async hover(loc: ElementLocator): Promise<{ x: number; y: number }> {
405
562
  const { objectId } = await resolveElementLocator(this.conn, loc);
406
563
  const { x, y } = await centerOf(this.conn, objectId);
407
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
564
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
408
565
  return { x, y };
409
566
  }
410
567
  // Copied dispatch sequence verbatim from src/tools/input.ts `click` (its resolve+centerOf+mouseMoved prefix IS hover, reused here).
568
+ // modifiers is new in 1.8.0 (Track P1): held for both press and release, same as a real chord.
411
569
  async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
412
570
  const button = opts?.button ?? "left";
413
571
  const clickCount = opts?.clickCount ?? 1;
572
+ const modifiers = inputModifierBits(opts?.modifiers);
414
573
  const { x, y } = await this.hover(loc);
415
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
416
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
574
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
575
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
417
576
  return { x, y };
418
577
  }
419
- // Copied dispatch sequence verbatim from src/tools/input.ts `drag` (its "from" prefix IS hover, reused here).
420
- async drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
578
+ // 1.8.0 Track P1: scroll into view (same as click/hover) when an element anchor is given; an
579
+ // {x,y} anchor is already a viewport point, and no anchor scrolls at the viewport center.
580
+ async scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }> {
581
+ await this.ensureDomain("Runtime");
582
+ const { x, y } = await this.resolveScrollPoint(anchor);
583
+ // Arm the settle watch BEFORE dispatching: the wheel event's resulting scroll can start (and,
584
+ // for a small delta, finish) before this command's own response comes back, so arming after
585
+ // dispatch could miss it and wait out the full 500ms cap needlessly. See armScrollSettleWatch's
586
+ // doc comment for why a listener is used instead of a fixed sleep.
587
+ await armScrollSettleWatch(this.conn);
588
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
589
+ await awaitScrollSettle(this.conn);
590
+ return { x, y };
591
+ }
592
+ private async resolveScrollPoint(anchor: ElementLocator | { x: number; y: number } | undefined): Promise<{ x: number; y: number }> {
593
+ if (anchor === undefined) {
594
+ await this.ensureDomain("Runtime");
595
+ const { result, exceptionDetails } = await this.conn.send<{ result: { value?: { x: number; y: number } }; exceptionDetails?: { text?: string } }>(
596
+ "Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true },
597
+ );
598
+ if (exceptionDetails || !result.value) throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
599
+ return result.value;
600
+ }
601
+ if ("x" in anchor) return anchor;
602
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
603
+ return centerOf(this.conn, objectId);
604
+ }
605
+ // Dispatch sequence originally copied verbatim from src/tools/input.ts `drag` (its "from" prefix
606
+ // IS hover, reused here). 1.8.0 Track P2 generalised it: `to` may be an element, a viewport
607
+ // point, or an offset; `steps` sets the interpolation count; `mode:"html5"` swaps the tail of
608
+ // the sequence for real drag events. steps:2 reproduces the original midpoint+destination moves
609
+ // exactly, so the default path is byte-for-byte the pre-1.8.0 dispatch.
610
+ async drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
421
611
  const fromPt = await this.hover(from);
422
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
423
- const toPt = await centerOf(this.conn, dstId);
424
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
425
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
426
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
427
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
612
+ const toPt = await this.resolveDragDestination(to, fromPt);
613
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
614
+ if ((opts?.mode ?? "mouse") === "html5") {
615
+ await this.html5Drag(fromPt, toPt, steps);
616
+ return { from: fromPt, to: toPt };
617
+ }
618
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
619
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
620
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
621
+ }
622
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
428
623
  return { from: fromPt, to: toPt };
429
624
  }
625
+ private async resolveDragDestination(to: DragDestination, fromPt: { x: number; y: number }): Promise<{ x: number; y: number }> {
626
+ if ("dx" in to) return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
627
+ if ("x" in to) return to;
628
+ const { objectId } = await resolveElementLocator(this.conn, to);
629
+ return centerOf(this.conn, objectId);
630
+ }
631
+ /**
632
+ * The real HTML5 drag (capability "input.html5Drag").
633
+ *
634
+ * WHY THIS EXISTS — MEASURED, NOT ASSUMED (Chrome 151.0.7922.109, macOS, --headless=new and
635
+ * headed alike; see test/input-smoke.ts's P2 section). The common claim that synthetic mouse
636
+ * events cannot drive HTML5 drag-and-drop is FALSE on modern Chrome: a plain mouse-mode drag
637
+ * does start a real drag and did fire dragstart and drop on a draggable source. What mouse mode
638
+ * cannot control is WHICH drag events the page gets, because they follow the interpolated
639
+ * pointer path. On the same page, the default steps:2 produced dragstart + drop but ZERO
640
+ * dragover, while steps:1 and steps:8 produced dragover — and a drop zone written the standard
641
+ * way (preventDefault inside dragover, the HTML spec's way to mark a valid drop target) refuses
642
+ * the drop when no dragover arrives. So mouse mode is a coin flip decided by geometry, and
643
+ * html5 mode is the deterministic path: dragEnter -> dragOver -> drop, dispatched explicitly at
644
+ * the destination, every time.
645
+ *
646
+ * Input.setInterceptDrags tells Chrome to hand the gesture BACK to the client instead of running
647
+ * it natively: the moment the drag would start, Chrome emits Input.dragIntercepted carrying the
648
+ * real DragData (items, mimeTypes, dragOperationsMask) it built from the page's own dragstart,
649
+ * and the client plays that data at the destination with Input.dispatchDragEvent. The data must
650
+ * be Chrome's — a hand-rolled DragData would drop whatever the page's dragstart handler put in
651
+ * the dataTransfer, which is exactly what a real DnD implementation reads in its drop handler.
652
+ *
653
+ * The event subscription is armed BEFORE the press for the same reason armScrollSettleWatch is
654
+ * armed before the wheel dispatch: dragIntercepted can arrive before the mouseMoved command's
655
+ * own ack, so subscribing afterwards is a race that loses the event and then waits out the full
656
+ * timeout. Sequence and the trailing mouseReleased mirror Puppeteer's Mouse.dragAndDrop.
657
+ *
658
+ * Interception is disabled in a `finally` unconditionally. Leaving it enabled would break real
659
+ * user dragging in that tab for as long as the browser lives — the tab would look fine and
660
+ * simply refuse to drag anything — and the tab may well be a human's (claim_page takeover), so
661
+ * this is not a tidiness nicety.
662
+ */
663
+ private async html5Drag(fromPt: { x: number; y: number }, toPt: { x: number; y: number }, steps: number): Promise<void> {
664
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
665
+ try {
666
+ const intercepted = this.conn
667
+ .waitFor<{ data: Record<string, unknown> }>("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS)
668
+ .catch(() => undefined);
669
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
670
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
671
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
672
+ }
673
+ const event = await intercepted;
674
+ if (!event?.data) {
675
+ throw driverError(
676
+ "page-error",
677
+ `drag mode:"html5": the page never started a drag (no Input.dragIntercepted within ${HTML5_DRAG_INTERCEPT_TIMEOUT_MS}ms). ` +
678
+ "The source element is probably not draggable — HTML5 drag-and-drop needs draggable=\"true\" (or a native draggable element) " +
679
+ "plus a dragstart handler. Use the default mode:\"mouse\" for widgets built on raw pointer events.",
680
+ );
681
+ }
682
+ const data = event.data;
683
+ for (const type of ["dragEnter", "dragOver", "drop"] as const) {
684
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
685
+ }
686
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
687
+ } finally {
688
+ // Never let a throw above leave this tab unable to drag. Swallowed on failure: the original
689
+ // error is the one worth reporting, and a dead connection cannot be un-intercepted anyway.
690
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => undefined);
691
+ }
692
+ }
430
693
  async setValue(loc: ElementLocator, value: string): Promise<void> {
431
694
  const { objectId } = await resolveElementLocator(this.conn, loc);
432
- await setValueOnObject(this.conn, objectId, value);
695
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
433
696
  }
434
697
  async typeText(loc: ElementLocator, text: string): Promise<void> {
435
698
  const { objectId } = await resolveElementLocator(this.conn, loc);
436
699
  await focusElement(this.conn, objectId);
437
- if (text.length) await this.conn.send("Input.insertText", { text });
700
+ if (text.length) await this.dispatchInput("Input.insertText", { text });
438
701
  }
439
702
  // Copied dispatch logic verbatim from src/tools/input.ts `pressKey`.
440
703
  async pressKey(press: KeyPress): Promise<void> {
@@ -448,8 +711,8 @@ class CdpPageDriver implements PageDriver {
448
711
  const suppressText = (modBits & ~(MODIFIER_BITS.shift as number)) !== 0;
449
712
  const down: Record<string, unknown> = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
450
713
  if (spec.text && !suppressText) down.text = spec.text;
451
- await this.conn.send("Input.dispatchKeyEvent", down);
452
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
714
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
715
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
453
716
  }
454
717
  async setFiles(loc: ElementLocator, files: readonly string[]): Promise<void> {
455
718
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -698,6 +961,75 @@ class CdpPageDriver implements PageDriver {
698
961
  this.conn.close();
699
962
  }
700
963
  }
964
+ /* ------------------------------- activity beacon ------------------------------- */
965
+
966
+ /**
967
+ * The held connections that keep each beaconed tab's init script armed.
968
+ *
969
+ * MODULE SCOPE, matching recorder.ts / network_mock.ts, which hold long-lived
970
+ * page connections the same way and for the same reason: the state belongs to
971
+ * the PROCESS, not to a driver instance, and index.ts constructs exactly one
972
+ * driver anyway. Under the CLI (one process per invocation) these die with the
973
+ * process, which degrades the beacon to current-document-only — correct, since
974
+ * a CLI process cannot hold anything across calls by construction.
975
+ */
976
+ const beaconSessions = new BeaconSessions<CdpConnection>((conn) => conn.close());
977
+
978
+ /** The page endpoint for a target id, or undefined if the browser no longer has it. */
979
+ async function targetWsUrl(targetId: string): Promise<string | undefined> {
980
+ const hit = (await listTargets()).find((t) => t.id === targetId);
981
+ return hit?.webSocketDebuggerUrl;
982
+ }
983
+
984
+ /**
985
+ * Read the beacon over an existing connection.
986
+ *
987
+ * THREE-VALUED ON PURPOSE, because the caller has to tell two failures apart:
988
+ * `null` is "the connection worked and the page has no beacon", `undefined` is
989
+ * "this connection is dead". Collapsing them would make a socket that died with
990
+ * its tab look exactly like a live tab nobody has touched, and the held session
991
+ * would never be dropped or retried.
992
+ */
993
+ async function readBeaconOver(conn: CdpConnection): Promise<number | null | undefined> {
994
+ try {
995
+ const res = await conn.send<{ result: { value?: unknown } }>("Runtime.evaluate", {
996
+ expression: BEACON_READ_EXPRESSION,
997
+ returnByValue: true,
998
+ });
999
+ const value = res.result?.value;
1000
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1001
+ } catch {
1002
+ return undefined;
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Evaluate the renderer probe over an existing connection, bounded to
1008
+ * `timeoutMs`.
1009
+ *
1010
+ * NOT three-valued like readBeaconOver: a probe's whole job is "did it answer
1011
+ * in time", so a dead socket and a genuinely wedged renderer both collapse to
1012
+ * the same {responsive:false} — that IS the correct answer for both, since the
1013
+ * caller cannot drive either tab any faster than this already told it.
1014
+ */
1015
+ async function probeOver(conn: CdpConnection, timeoutMs: number): Promise<{ responsive: boolean; beaconTs: number | null }> {
1016
+ try {
1017
+ const res = await conn.send<{ result: { value?: unknown } }>(
1018
+ "Runtime.evaluate",
1019
+ { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true },
1020
+ { timeoutMs },
1021
+ );
1022
+ const value = res.result?.value;
1023
+ if (Array.isArray(value) && value[0] === 1) {
1024
+ const ts = value[1];
1025
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1026
+ }
1027
+ return { responsive: false, beaconTs: null };
1028
+ } catch {
1029
+ return { responsive: false, beaconTs: null };
1030
+ }
1031
+ }
1032
+
701
1033
  /* --------------------------------- BrowserDriver --------------------------------- */
702
1034
  // Runs fn on a throwaway browser-endpoint connection, always closing it.
703
1035
  async function withBrowserConn<T>(fn: (conn: CdpConnection) => Promise<T>): Promise<T> {
@@ -763,6 +1095,10 @@ class CdpBrowserDriver implements BrowserDriver {
763
1095
  async closePage(id: string): Promise<{ success: boolean }> {
764
1096
  return withBrowserConn(async (conn) => {
765
1097
  const res = await conn.send<{ success?: boolean }>("Target.closeTarget", { targetId: id });
1098
+ // Opportunistic, not load-bearing: the beacon socket dies with the tab
1099
+ // anyway, and readBeaconOver's dead-connection branch would drop it on the
1100
+ // next read. Dropping it here just avoids holding a corpse until then.
1101
+ if (res.success !== false) beaconSessions.drop(id);
766
1102
  return { success: res.success ?? true };
767
1103
  });
768
1104
  }
@@ -771,6 +1107,20 @@ class CdpBrowserDriver implements BrowserDriver {
771
1107
  await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
772
1108
  return { id: target.id, url: target.url, title: target.title };
773
1109
  }
1110
+ /**
1111
+ * Capability "worker.targets". Resolution and the wake live in ./workers.ts;
1112
+ * this is the driver-shaped door onto them.
1113
+ *
1114
+ * The catch is noSuchTarget for the same reason page() uses it: a genuine
1115
+ * "nothing matched" is a CdpError (numeric code) and must surface as a
1116
+ * `no-such-target` DriverError, while a typed error from underneath — a lease
1117
+ * conflict, an fs errno — passes through with its class intact. The teaching
1118
+ * text in the miss message is the CdpError's message and survives relabeling.
1119
+ */
1120
+ async resolveWorkerTarget(selector: string, opts: { wake: boolean }): Promise<PageInfo> {
1121
+ const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
1122
+ return { id: target.id, url: target.url, title: target.title, type: target.type };
1123
+ }
774
1124
  async page(selector: TargetSelector): Promise<PageDriver> {
775
1125
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
776
1126
  // Deliberately NOT enabling ANY domain here (previously eagerly enabled Page+Runtime on every
@@ -780,7 +1130,118 @@ class CdpBrowserDriver implements BrowserDriver {
780
1130
  // the PageDriver method that actually needs it, matching each legacy tool's own timing.
781
1131
  return new CdpPageDriver(conn, target, this);
782
1132
  }
783
- async dispose(): Promise<void> {}
1133
+
1134
+ /**
1135
+ * Install the activity beacon, and HOLD the connection that keeps it armed.
1136
+ *
1137
+ * The held socket is the whole mechanism, not an optimization. Chrome clears
1138
+ * Page.addScriptToEvaluateOnNewDocument registrations when the client that
1139
+ * made them disconnects (verified against Chrome 151.0.7922.109: install,
1140
+ * disconnect, reconnect, navigate, and the script does not run). This driver's
1141
+ * lifetime is "per-call", so without a held connection the beacon would cover
1142
+ * the current document and vanish on the person's next navigation — the
1143
+ * single most common thing they do. See BeaconSessions in ../activity.ts for
1144
+ * the bound on how many are held and what is lost when one is evicted.
1145
+ *
1146
+ * Both legs are needed and neither is redundant:
1147
+ * addScriptToEvaluateOnNewDocument arms every FUTURE document, and the
1148
+ * Runtime.evaluate arms the one already loaded, which the former never
1149
+ * retroactively touches. The script itself is idempotent, so re-installing
1150
+ * into a document that already has it adds no second set of listeners.
1151
+ */
1152
+ async installActivityBeacon(targetId: string): Promise<boolean> {
1153
+ const held = beaconSessions.get(targetId);
1154
+ if (held) {
1155
+ const rearmed = await held
1156
+ .send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true })
1157
+ .then(() => true)
1158
+ .catch(() => false);
1159
+ if (rearmed) return true;
1160
+ // The socket is gone (tab closed, browser restarted): fall through and
1161
+ // build a fresh one rather than reporting a beacon that is not there.
1162
+ beaconSessions.drop(targetId);
1163
+ }
1164
+ const wsUrl = await targetWsUrl(targetId).catch(() => undefined);
1165
+ if (!wsUrl) return false;
1166
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => undefined);
1167
+ if (!conn) return false;
1168
+ try {
1169
+ await conn.send("Page.enable");
1170
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
1171
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
1172
+ } catch {
1173
+ conn.close();
1174
+ return false;
1175
+ }
1176
+ beaconSessions.set(targetId, conn);
1177
+ return true;
1178
+ }
1179
+
1180
+ /**
1181
+ * Read the beacon, gate-free (see the declaration in ../driver.ts for why that
1182
+ * is required rather than convenient).
1183
+ *
1184
+ * Prefers the held session, which costs no new socket, and falls back to a
1185
+ * throwaway connection so a tab whose beacon session was EVICTED — or one
1186
+ * beaconed by a previous server process and never re-claimed by this one —
1187
+ * still answers. That fallback is what makes the read work at all under the
1188
+ * CLI, which holds nothing.
1189
+ */
1190
+ async readActivityBeacon(targetId: string): Promise<number | null> {
1191
+ const held = beaconSessions.get(targetId);
1192
+ if (held) {
1193
+ const value = await readBeaconOver(held);
1194
+ if (value !== undefined) return value;
1195
+ beaconSessions.drop(targetId);
1196
+ }
1197
+ const wsUrl = await targetWsUrl(targetId).catch(() => undefined);
1198
+ if (!wsUrl) return null;
1199
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => undefined);
1200
+ if (!conn) return null;
1201
+ try {
1202
+ return (await readBeaconOver(conn)) ?? null;
1203
+ } finally {
1204
+ conn.close();
1205
+ }
1206
+ }
1207
+
1208
+ /**
1209
+ * Bounded liveness probe, gate-free like readActivityBeacon (see ../driver.ts).
1210
+ *
1211
+ * PREFERS THE HELD BEACON SESSION when one exists — no new socket, and it is
1212
+ * exactly the connection the beacon's init script is already registered on.
1213
+ * Falls back to a FRESH, throwaway connection, itself capped at `timeoutMs`,
1214
+ * for a tab this process never beaconed.
1215
+ *
1216
+ * Deliberately ONE attempt, never held-then-retry the way
1217
+ * installActivityBeacon/readActivityBeacon are: those retry because losing a
1218
+ * held socket is a correctness problem worth routing around, but a probe
1219
+ * that failed already IS the answer this call exists to produce — retrying
1220
+ * would let one wedged tab cost the listing two timeouts instead of one.
1221
+ */
1222
+ async probeRenderer(targetId: string, timeoutMs: number = RENDERER_PROBE_TIMEOUT_MS): Promise<{ responsive: boolean; beaconTs: number | null }> {
1223
+ const held = beaconSessions.get(targetId);
1224
+ if (held) return probeOver(held, timeoutMs);
1225
+ const wsUrl = await targetWsUrl(targetId).catch(() => undefined);
1226
+ if (!wsUrl) return { responsive: false, beaconTs: null };
1227
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
1228
+ try {
1229
+ await conn.connect();
1230
+ } catch {
1231
+ return { responsive: false, beaconTs: null };
1232
+ }
1233
+ try {
1234
+ return await probeOver(conn, timeoutMs);
1235
+ } finally {
1236
+ conn.close();
1237
+ }
1238
+ }
1239
+
1240
+ /** Closes every held beacon connection. dispose() is the MCP server's shutdown
1241
+ * path and the CLI's exit path; a tool must never call it (see ../driver.ts). */
1242
+ async dispose(): Promise<void> {
1243
+ beaconSessions.clear();
1244
+ }
784
1245
  }
785
1246
  /** Construct the CDP implementation of the frozen Driver contract. */
786
1247
  export function createCdpDriver(): BrowserDriver { return new CdpBrowserDriver(); }
@@ -788,7 +1249,8 @@ export { CdpBrowserDriver, CdpPageDriver };
788
1249
  /* ------------------------------------------------------------------------------
789
1250
  * CDP methods/domains used: Target.createTarget/closeTarget/activateTarget; Page.enable/
790
1251
  * navigate/reload/getFrameTree/loadEventFired/domContentEventFired/frameStoppedLoading/
791
- * captureScreenshot/getLayoutMetrics/javascriptDialogOpening/handleJavaScriptDialog;
1252
+ * captureScreenshot/getLayoutMetrics/javascriptDialogOpening/handleJavaScriptDialog/
1253
+ * addScriptToEvaluateOnNewDocument (the activity beacon, see ../activity.ts);
792
1254
  * Runtime.enable/evaluate/callFunctionOn/consoleAPICalled; Accessibility.enable/getFullAXTree;
793
1255
  * DOM.resolveNode (via resolveUid)/describeNode/performSearch/getSearchResults/
794
1256
  * discardSearchResults/scrollIntoViewIfNeeded/getBoxModel/setFileInputFiles/getDocument;