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/CHANGELOG.md +284 -0
- package/CONTRACT.md +5 -1
- package/README.md +35 -13
- package/dist/cli.js +973 -56
- package/dist/index.js +834 -44
- package/dist/mcp.js +1213 -66
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +125 -4
- package/src/leases.ts +1 -1
- package/src/manifest.ts +239 -10
- package/src/mcp.ts +6 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +268 -27
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/src/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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
416
|
-
await this.
|
|
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
|
-
|
|
420
|
-
|
|
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
|
|
423
|
-
const
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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.
|
|
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.
|
|
452
|
-
await this.
|
|
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
|
-
|
|
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;
|