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