claude4arc 0.5.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/lib/page.js ADDED
@@ -0,0 +1,1530 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { LIBRARY_KEY, LIBRARY_PRELUDE } from "./inpage.js";
4
+ import { EDITOR_PRELUDE } from "./editors.js";
5
+ import { html5Drag } from "./dnd.js";
6
+ import { STATE_DIR } from "./paths.js";
7
+ import { SCREENSHOT_DIR, pruneScreenshots } from "./housekeeping.js";
8
+ import { blockedBy, blockedError, readBlocklist } from "./blocklist.js";
9
+ import { run, sleep, TRANSIENT, withTimeout, poll, fatal, normalizeUrl, urlMatches } from "./util.js";
10
+ import { AGENT_SHIM } from "./shim.js";
11
+ import { ROOT_INPUT, ROOT_CONTEXT, scopeOf, CLOSED_AWARE, REGISTER_CLOSED_ROOT, splitFrameSelector, pathOf, prefixRefs } from "./frames.js";
12
+ import { Mouse, Keyboard } from "./input.js";
13
+
14
+
15
+ export class Page {
16
+ #task;
17
+ #attached = false;
18
+ #stoppedByUser = false;
19
+ #dialog = null;
20
+ #dialogWaiters = new Set();
21
+ #eventWaiters = new Set();
22
+
23
+ constructor(task, label, tabId, openedBy) {
24
+ this.#task = task;
25
+ this.label = label;
26
+ this.tabId = tabId;
27
+ this.openedBy = openedBy;
28
+ this.mouse = new Mouse(this);
29
+ this.keyboard = new Keyboard(this);
30
+ }
31
+
32
+ get spaceId() {
33
+ return this.#task.spaceId;
34
+ }
35
+
36
+ get #bridge() {
37
+ return this.#task.bridge;
38
+ }
39
+
40
+ get attached() {
41
+ return this.#attached;
42
+ }
43
+
44
+ get dialog() {
45
+ return this.#dialog;
46
+ }
47
+
48
+ _onEvent(message) {
49
+ if (message.type === "detached") {
50
+ this.#attached = false;
51
+ if (message.reason === "canceled_by_user") this.#stoppedByUser = true;
52
+ return;
53
+ }
54
+ if (message.type !== "event" || message.sessionId) return;
55
+ if (message.method === "Page.javascriptDialogOpening") {
56
+ this.#dialog = {
57
+ type: message.params.type,
58
+ message: message.params.message,
59
+ defaultPrompt: message.params.defaultPrompt,
60
+ url: message.params.url,
61
+ };
62
+ for (const waiter of this.#dialogWaiters) waiter(this.#dialog);
63
+ }
64
+ if (message.method === "Page.javascriptDialogClosed") this.#dialog = null;
65
+ for (const waiter of this.#eventWaiters) waiter(message);
66
+ }
67
+
68
+ _waitForCdpEvent(method, options) {
69
+ return this.#waitForCdpEvent(method, options);
70
+ }
71
+
72
+ #waitForCdpEvent(method, { timeout, filter = () => true }) {
73
+ let listener;
74
+ const promise = new Promise((resolve) => {
75
+ listener = (message) => {
76
+ if (message.method === method && filter(message.params)) resolve(message.params);
77
+ };
78
+ this.#eventWaiters.add(listener);
79
+ });
80
+ return withTimeout(promise, timeout, `Timed out after ${timeout}ms waiting for ${method}`).finally(() =>
81
+ this.#eventWaiters.delete(listener),
82
+ );
83
+ }
84
+
85
+ async #attach() {
86
+ if (this.#stoppedByUser) {
87
+ throw fatal(new Error(`The user stopped Claude's control of page ${this.label}. Ask the user before continuing.`));
88
+ }
89
+ if (this.#attached) return;
90
+ let result;
91
+ try {
92
+ result = await this.#bridge.call("debugger.attach", this.tabId).catch(async (error) => {
93
+ if (!/chrome-extension:\/\/ URL of different extension/i.test(error.message) || this.openedBy === "user") throw error;
94
+ await this.#bridge.call("guard.sweep", this.tabId).catch(() => {});
95
+ await sleep(50);
96
+ return this.#bridge.call("debugger.attach", this.tabId);
97
+ });
98
+ } catch (error) {
99
+ if (/No tab with id/i.test(error.message)) {
100
+ throw fatal(new Error(`Page ${this.label} (tab ${this.tabId}) is closed.`));
101
+ } else {
102
+ const tab = await this.#bridge.call("tabs.get", this.tabId).catch(() => null);
103
+ throw fatal(
104
+ new Error(
105
+ `Cannot control page ${this.label} at ${tab?.url ?? "unknown url"}: ${error.message}. Arc blocks extensions on internal pages such as arc://, chrome://, and the Chrome Web Store.`,
106
+ ),
107
+ );
108
+ }
109
+ }
110
+ this.#attached = true;
111
+ const managed = this.openedBy !== "user";
112
+ if (!result?.already) {
113
+ await this.#bridge.call("debugger.send", this.tabId, "Page.enable", {});
114
+ if (managed) await this.#ensureViewport();
115
+ }
116
+ if (managed) await this.#bridge.call("host.autoDialogs", this.tabId, true, AGENT_SHIM).catch(() => {});
117
+ const dialog = await this.#bridge.call("host.dialog", this.tabId).catch(() => null);
118
+ if (dialog) this.#dialog = { type: dialog.type, message: dialog.message, defaultPrompt: dialog.defaultPrompt, url: dialog.url };
119
+ }
120
+
121
+ async #ensureViewport() {
122
+ const probe = await this.#bridge
123
+ .call("debugger.send", this.tabId, "Runtime.evaluate", { expression: "[innerWidth, innerHeight]", returnByValue: true })
124
+ .catch(() => null);
125
+ const [width, height] = probe?.result?.value ?? [0, 0];
126
+ if (width > 1 && height > 1) return;
127
+ const window = await this.#bridge.call("windows.getLastFocused").catch(() => null);
128
+ await this.#bridge
129
+ .call("debugger.send", this.tabId, "Emulation.setDeviceMetricsOverride", {
130
+ width: Math.max(800, (window?.width ?? 1440) - 240),
131
+ height: Math.max(600, (window?.height ?? 900) - 90),
132
+ deviceScaleFactor: 0,
133
+ mobile: false,
134
+ })
135
+ .catch(() => {});
136
+ }
137
+
138
+ async _detach() {
139
+ this.#attached = false;
140
+ await this.#bridge.call("host.autoDialogs", this.tabId, false).catch(() => {});
141
+ await this.#bridge.call("debugger.detach", this.tabId).catch(() => {});
142
+ }
143
+
144
+ _frame() {
145
+ const capture = this.#bridge
146
+ .call("debugger.send", this.tabId, "Page.captureScreenshot", {
147
+ format: "jpeg",
148
+ quality: 1,
149
+ clip: { x: 0, y: 0, width: 1, height: 1, scale: 1 },
150
+ optimizeForSpeed: true,
151
+ })
152
+ .catch(() => {});
153
+ return Promise.race([capture, sleep(250)]);
154
+ }
155
+
156
+ #hidden = null;
157
+ #lastTick = 0;
158
+
159
+ async #isHidden() {
160
+ if (this.#hidden === null) this.#hidden = await this.#bridge.call("tabs.get", this.tabId).then((tab) => !tab.active, () => false);
161
+ return this.#hidden;
162
+ }
163
+
164
+ async _tick() {
165
+ if (!this.#attached || Date.now() - this.#lastTick < 30 || !(await this.#isHidden())) return;
166
+ this.#lastTick = Date.now();
167
+ await Promise.race([this._frame(), sleep(60)]);
168
+ }
169
+
170
+ async _dispatchBatch(method, list, { sessionId = null, timeout = 30_000 } = {}) {
171
+ await this.#attach();
172
+ const target = sessionId ? { tabId: this.tabId, sessionId } : this.tabId;
173
+ let pending = list.length;
174
+ const calls = list.map((params) =>
175
+ this.#bridge.call("debugger.send", target, method, params).finally(() => {
176
+ pending--;
177
+ }),
178
+ );
179
+ for (const call of calls) call.catch(() => {});
180
+ if (await this.#isHidden()) {
181
+ const deadline = Date.now() + timeout;
182
+ while (pending > 0 && !this.#dialog && Date.now() < deadline) await this._frame();
183
+ }
184
+ await withTimeout(Promise.all(calls), timeout, `${method} batch timed out after ${timeout}ms`);
185
+ }
186
+
187
+ async #pumped(send, timeout, method) {
188
+ await this.#isHidden();
189
+ let done = false;
190
+ const pending = send().finally(() => {
191
+ done = true;
192
+ });
193
+ pending.catch(() => {});
194
+ if (!this.#hidden) await Promise.race([pending.catch(() => {}), sleep(20)]);
195
+ const deadline = Date.now() + timeout;
196
+ while (!done && !this.#dialog && Date.now() < deadline) await this._frame();
197
+ return withTimeout(pending, Math.max(0, deadline - Date.now()) + 50, `CDP ${method} timed out after ${timeout}ms`);
198
+ }
199
+
200
+ async cdp(method, params = {}, { timeout = 30_000, sessionId = null } = {}) {
201
+ await this.#attach();
202
+ const target = sessionId ? { tabId: this.tabId, sessionId } : this.tabId;
203
+ const send = () => this.#bridge.call("debugger.send", target, method, params);
204
+ try {
205
+ if (method.startsWith("Input.")) return await this.#pumped(send, timeout, method);
206
+ return await withTimeout(send(), timeout, `CDP ${method} timed out after ${timeout}ms`);
207
+ } catch (error) {
208
+ const detached = /not attached|Detached while handling command/i.test(error.message);
209
+ if (detached && !method.startsWith("Input.") && !sessionId) {
210
+ this.#attached = false;
211
+ await this.#attach();
212
+ return await withTimeout(send(), timeout, `CDP ${method} timed out after ${timeout}ms`);
213
+ }
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ #dialogError() {
219
+ const dialog = this.#dialog;
220
+ return fatal(
221
+ new Error(
222
+ `A JavaScript ${dialog.type} dialog is open ("${dialog.message}"). Call page.acceptDialog() or page.dismissDialog() first.`,
223
+ ),
224
+ );
225
+ }
226
+
227
+ #raceDialog(promise) {
228
+ if (this.#dialog) {
229
+ promise.catch(() => {});
230
+ return Promise.reject(this.#dialogError());
231
+ }
232
+ let waiter;
233
+ const dialogPromise = new Promise((_, reject) => {
234
+ waiter = () => reject(this.#dialogError());
235
+ this.#dialogWaiters.add(waiter);
236
+ });
237
+ promise.catch(() => {});
238
+ return Promise.race([promise, dialogPromise]).finally(() => this.#dialogWaiters.delete(waiter));
239
+ }
240
+
241
+ async #evaluateExpression(expression, { timeout = 60_000, sessionId = null, contextId = null } = {}) {
242
+ const response = await this.#raceDialog(
243
+ this.cdp(
244
+ "Runtime.evaluate",
245
+ { expression, contextId: contextId ?? undefined, awaitPromise: true, returnByValue: true, userGesture: true, allowUnsafeEvalBlockedByCSP: true },
246
+ { timeout, sessionId },
247
+ ),
248
+ );
249
+ if (response.exceptionDetails) {
250
+ const details = response.exceptionDetails;
251
+ const description = details.exception?.description ?? details.exception?.value ?? details.text;
252
+ const error = new Error(String(description).replace(/^Error: /, "").split("\n at ")[0]);
253
+ error.pageError = true;
254
+ throw error;
255
+ }
256
+ return response.result.value;
257
+ }
258
+
259
+ async evaluate(fn, argument, { frame } = {}) {
260
+ const expression =
261
+ typeof fn === "function"
262
+ ? `(${fn.toString()})(${argument === undefined ? "" : JSON.stringify(argument)})`
263
+ : String(fn);
264
+ const context = frame ? await this.#frameOf(frame) : ROOT_CONTEXT;
265
+ return this.#evaluateExpression(expression, scopeOf(context));
266
+ }
267
+
268
+ async _lib(method, ...args) {
269
+ return this.#libAt(ROOT_CONTEXT, method, ...args);
270
+ }
271
+
272
+ async #libAt(context, method, ...args) {
273
+ try {
274
+ return await this.#libOnce(context, method, args);
275
+ } catch (error) {
276
+ if (!context.depth || !/Session with given id not found|Detached while handling command|Cannot find context with specified id/i.test(error.message)) throw error;
277
+ this.#scopes.clear();
278
+ await sleep(150);
279
+ const fresh = await this.#frameContext(pathOf(context));
280
+ Object.assign(context, fresh);
281
+ return this.#libOnce(context, method, args);
282
+ }
283
+ }
284
+
285
+ async #libOnce(context, method, args) {
286
+ if (CLOSED_AWARE.has(method)) return this.#libGuarded(context, method, args);
287
+ const call = `${LIBRARY_PRELUDE}.${method}(${args.map((arg) => JSON.stringify(arg ?? null)).join(", ")})`;
288
+ return this.#evaluateExpression(call, scopeOf(context));
289
+ }
290
+
291
+ async #withElement(context, ref, body) {
292
+ const call = `((element, lib, editors) => (${body}))(${LIBRARY_PRELUDE}.element(${ref}), ${LIBRARY_PRELUDE}, ${EDITOR_PRELUDE})`;
293
+ return this.#evaluateExpression(call, scopeOf(context));
294
+ }
295
+
296
+ async #libGuarded(context, method, args) {
297
+ const list = args.map((arg) => JSON.stringify(arg ?? null)).join(", ");
298
+ for (let round = 0; ; round++) {
299
+ const call = `${LIBRARY_PRELUDE}.guarded(${JSON.stringify(method)}, [${list}], ${round < 4})`;
300
+ const value = await this.#evaluateExpression(call, scopeOf(context));
301
+ if (!value?.__arcClosedHosts) return value;
302
+ await this.#exposeClosedRoots(context);
303
+ }
304
+ }
305
+
306
+ async #exposeClosedRoots(context) {
307
+ const options = { sessionId: context.sessionId };
308
+ const objectGroup = "arc-closed-roots";
309
+ try {
310
+ const { result } = await this.cdp(
311
+ "Runtime.evaluate",
312
+ { expression: `${LIBRARY_PRELUDE}.takeClosedHostCandidates()`, objectGroup, returnByValue: false, contextId: context.contextId ?? undefined },
313
+ options,
314
+ );
315
+ if (!result?.objectId) return;
316
+ const { result: properties } = await this.cdp("Runtime.getProperties", { objectId: result.objectId, ownProperties: true }, options);
317
+ const hosts = properties.filter((property) => /^\d+$/.test(property.name) && property.value?.objectId);
318
+ await Promise.all(
319
+ hosts.map(async ({ value }) => {
320
+ const { node } = await this.cdp("DOM.describeNode", { objectId: value.objectId, depth: 0, pierce: true }, options);
321
+ for (const root of node.shadowRoots ?? []) {
322
+ if (root.shadowRootType !== "closed") continue;
323
+ const { object } = await this.cdp("DOM.resolveNode", { backendNodeId: root.backendNodeId, objectGroup }, options);
324
+ await this.cdp(
325
+ "Runtime.callFunctionOn",
326
+ { objectId: object.objectId, functionDeclaration: REGISTER_CLOSED_ROOT, arguments: [{ value: LIBRARY_KEY }], returnByValue: true },
327
+ options,
328
+ );
329
+ }
330
+ }).map((work) => work.catch(() => {})),
331
+ );
332
+ } finally {
333
+ await this.cdp("Runtime.releaseObjectGroup", { objectGroup }, options).catch(() => {});
334
+ }
335
+ }
336
+
337
+ async #objectIdAt(context, ref) {
338
+ const response = await this.cdp(
339
+ "Runtime.evaluate",
340
+ { expression: `${LIBRARY_PRELUDE}.element(${ref})`, contextId: context.contextId ?? undefined, returnByValue: false },
341
+ { sessionId: context.sessionId },
342
+ );
343
+ if (response.exceptionDetails) throw new Error(response.exceptionDetails.exception?.description ?? "Cannot resolve element");
344
+ return response.result.objectId;
345
+ }
346
+
347
+ #scopes = new Map();
348
+
349
+ _resetScopes() {
350
+ this.#scopes.clear();
351
+ }
352
+
353
+ async #childContext(context, ref) {
354
+ const box = await this.#libAt(context, "frameBox", ref);
355
+ const name = `@${context.prefix}${ref}`;
356
+ if (!box.cross) throw fatal(new Error(`${name} is a same-origin frame; its elements already have plain refs in the snapshot.`));
357
+ const key = `${context.sessionId}:${context.contextId}:${name}`;
358
+ let scope = this.#scopes.get(key);
359
+ if (!scope) {
360
+ const objectId = await this.#objectIdAt(context, ref);
361
+ const { node } = await this.cdp("DOM.describeNode", { objectId }, { sessionId: context.sessionId });
362
+ this.cdp("Runtime.releaseObject", { objectId }, { sessionId: context.sessionId }).catch(() => {});
363
+ scope = node.contentDocument ? await this.#inProcessScope(context.sessionId, node) : await this.#outOfProcessScope(node);
364
+ if (!scope) throw new Error(`Frame ${name} is still loading or not reachable.`);
365
+ this.#scopes.set(key, scope);
366
+ }
367
+ const offset = { x: context.offset.x + box.x, y: context.offset.y + box.y };
368
+ return {
369
+ ...scope,
370
+ input: scope.sessionId === context.sessionId ? context.input : { sessionId: scope.sessionId, ...offset },
371
+ offset,
372
+ prefix: `${context.prefix}${ref}.`,
373
+ depth: context.depth + 1,
374
+ };
375
+ }
376
+
377
+ async #outOfProcessScope(node) {
378
+ const frameId = node.frameId ?? node.contentDocument?.frameId;
379
+ const sessions = await this.#bridge.call("host.sessions", this.tabId);
380
+ const child = sessions.filter((session) => session.targetId === frameId).at(-1);
381
+ return child ? { sessionId: child.sessionId, contextId: null } : null;
382
+ }
383
+
384
+ async #inProcessScope(sessionId, node) {
385
+ const { object } = await this.cdp("DOM.resolveNode", { backendNodeId: node.contentDocument.backendNodeId }, { sessionId });
386
+ await this.cdp("Runtime.releaseObject", { objectId: object.objectId }, { sessionId }).catch(() => {});
387
+ const mainWorld = Number(/^-?\d+\.(\d+)\.\d+$/.exec(object.objectId ?? "")?.[1]);
388
+ if (mainWorld) return { sessionId, contextId: mainWorld };
389
+ const { executionContextId } = await this.cdp(
390
+ "Page.createIsolatedWorld",
391
+ { frameId: node.frameId, worldName: "claude4arc", grantUniveralAccess: true },
392
+ { sessionId },
393
+ );
394
+ return { sessionId, contextId: executionContextId };
395
+ }
396
+
397
+ async #frameContext(path) {
398
+ let context = ROOT_CONTEXT;
399
+ for (const ref of path) context = await this.#childContext(context, ref);
400
+ return context;
401
+ }
402
+
403
+ async #frameOf(frame) {
404
+ const path = String(frame).replace(/^@/, "").split(".").map(Number);
405
+ return this.#frameContext(path);
406
+ }
407
+
408
+ #describeIn(context, description) {
409
+ return prefixRefs(description, context.prefix);
410
+ }
411
+
412
+ async _focusForKeys() {
413
+ if (this.openedBy !== "user") return;
414
+ if ((await this.#tab()).active) return;
415
+ const roundTrip = () => this.#evaluateExpression("0", { timeout: 2_000 }).catch(() => {});
416
+ await this.cdp("Emulation.setFocusEmulationEnabled", { enabled: true }).catch(() => {});
417
+ await roundTrip();
418
+ await this.cdp("Emulation.setFocusEmulationEnabled", { enabled: false }).catch(() => {});
419
+ await roundTrip();
420
+ }
421
+
422
+ _showCursor(x, y, label) {
423
+ if (this.#attached) this._lib("showCursor", x, y, label ?? "").catch(() => {});
424
+ }
425
+
426
+ async #tab() {
427
+ try {
428
+ return await this.#bridge.call("tabs.get", this.tabId);
429
+ } catch {
430
+ throw fatal(new Error(`Page ${this.label} (tab ${this.tabId}) is closed.`));
431
+ }
432
+ }
433
+
434
+ async _waitForTabLoad(timeout = 30_000) {
435
+ await poll(
436
+ async () => {
437
+ const tab = await this.#tab();
438
+ return tab.status === "complete" && tab.url && tab.url !== "about:blank";
439
+ },
440
+ { timeout, message: `Page ${this.label} did not finish loading within ${timeout}ms` },
441
+ ).catch(async (error) => {
442
+ const tab = await this.#tab();
443
+ if (!tab.url || tab.url === "about:blank") throw error;
444
+ });
445
+ }
446
+
447
+ async url() {
448
+ return (await this.#tab()).url;
449
+ }
450
+
451
+ async title() {
452
+ return (await this.#tab()).title;
453
+ }
454
+
455
+ async info() {
456
+ const tab = await this.#tab();
457
+ return {
458
+ label: this.label,
459
+ spaceId: this.spaceId,
460
+ tabId: this.tabId,
461
+ openedBy: this.openedBy,
462
+ url: tab.url,
463
+ title: tab.title,
464
+ active: tab.active,
465
+ loading: tab.status === "loading",
466
+ dialog: this.#dialog,
467
+ };
468
+ }
469
+
470
+ #rootFrameId = null;
471
+
472
+ async #loadRootFrameId() {
473
+ if (this.#rootFrameId) return;
474
+ const { frameTree } = await this.cdp("Page.getFrameTree").catch(() => ({ frameTree: null }));
475
+ this.#rootFrameId = frameTree?.frame?.id ?? null;
476
+ }
477
+
478
+ #navigation(waitUntil, timeout) {
479
+ const wanted = waitUntil === "domcontentloaded" ? "Page.domContentEventFired" : "Page.loadEventFired";
480
+ let listener;
481
+ let timer;
482
+ let done = false;
483
+ let committed = false;
484
+ let sawContent = false;
485
+ let loading = false;
486
+ let withinDocument = false;
487
+ const rootFrame = this.#rootFrameId;
488
+ let contentTimer = null;
489
+ const promise = new Promise((resolve, reject) => {
490
+ listener = (message) => {
491
+ if (message.method === "Page.frameStartedLoading" && message.params.frameId === rootFrame) loading = true;
492
+ if (message.method === "Page.frameNavigated" && !message.params.frame?.parentId) committed = true;
493
+ if (message.method === "Page.domContentEventFired") {
494
+ sawContent = true;
495
+ if (waitUntil === "load" && !contentTimer) contentTimer = setTimeout(resolve, 3_000);
496
+ }
497
+ if (message.method === wanted) resolve();
498
+ if (message.method === "Page.navigatedWithinDocument" && message.params.frameId === rootFrame) {
499
+ withinDocument = true;
500
+ if (!loading) {
501
+ setTimeout(() => {
502
+ if (!loading) resolve();
503
+ }, 50);
504
+ }
505
+ }
506
+ if (message.method === "Page.frameStoppedLoading" && message.params.frameId === rootFrame && withinDocument && !committed) resolve();
507
+ };
508
+ this.#eventWaiters.add(listener);
509
+ timer = setTimeout(() => {
510
+ if (sawContent) resolve();
511
+ else reject(new Error(`Navigation did not reach "${waitUntil}" within ${timeout}ms`));
512
+ }, timeout);
513
+ (async () => {
514
+ while (!done) {
515
+ await sleep(250);
516
+ if (done || !committed) continue;
517
+ const state = await this.#evaluateExpression("document.readyState", { timeout: 2_000 }).catch(() => "loading");
518
+ if (state === "complete" || (waitUntil === "domcontentloaded" && state !== "loading")) resolve();
519
+ }
520
+ })();
521
+ });
522
+ const cancel = () => {
523
+ done = true;
524
+ clearTimeout(timer);
525
+ clearTimeout(contentTimer);
526
+ this.#eventWaiters.delete(listener);
527
+ };
528
+ return {
529
+ wait: async () => {
530
+ try {
531
+ await promise;
532
+ } finally {
533
+ cancel();
534
+ }
535
+ if (waitUntil === "networkidle") await this.#waitForNetworkIdle(timeout);
536
+ },
537
+ cancel,
538
+ };
539
+ }
540
+
541
+ async #waitForNetworkIdle(timeout) {
542
+ let previous = -1;
543
+ let stableSince = Date.now();
544
+ await poll(
545
+ async () => {
546
+ const count = await this.#evaluateExpression("performance.getEntriesByType('resource').length", { timeout: 5_000 });
547
+ if (count !== previous) {
548
+ previous = count;
549
+ stableSince = Date.now();
550
+ return false;
551
+ }
552
+ return Date.now() - stableSince >= 500;
553
+ },
554
+ { timeout, interval: 100, message: `Network did not become idle within ${timeout}ms` },
555
+ );
556
+ }
557
+
558
+ async #navigate(start, { waitUntil, timeout }) {
559
+ await this.#attach();
560
+ await this.#loadRootFrameId();
561
+ const skip = waitUntil === "none" || waitUntil === "commit";
562
+ const navigation = this.#navigation(waitUntil, timeout);
563
+ try {
564
+ const result = await start();
565
+ if (skip || result?.sameDocument) navigation.cancel();
566
+ else await navigation.wait();
567
+ } catch (error) {
568
+ navigation.cancel();
569
+ throw error;
570
+ }
571
+ if (!skip) await this.settle({ quiet: 100, max: 1_000 });
572
+ }
573
+
574
+ async _enforceBlocklist() {
575
+ const patterns = readBlocklist();
576
+ if (!patterns.length) return;
577
+ const url = await this.url().catch(() => "");
578
+ const pattern = blockedBy(url, patterns);
579
+ if (!pattern) return;
580
+ if (this.openedBy !== "user") await this.cdp("Page.navigate", { url: "about:blank" }).catch(() => {});
581
+ throw blockedError(url, pattern);
582
+ }
583
+
584
+ async goto(url, { waitUntil = "load", timeout = 30_000 } = {}) {
585
+ const target = normalizeUrl(url);
586
+ const pattern = blockedBy(target, readBlocklist());
587
+ if (pattern) throw blockedError(target, pattern);
588
+ await this.#navigate(
589
+ async () => {
590
+ const result = await this.cdp("Page.navigate", { url: target }, { timeout });
591
+ if (result.errorText) throw new Error(`Navigation to ${target} failed: ${result.errorText}`);
592
+ return { sameDocument: !result.loaderId };
593
+ },
594
+ { waitUntil, timeout },
595
+ );
596
+ const tab = await this.#tab();
597
+ return { url: tab.url, title: tab.title };
598
+ }
599
+
600
+ async reload({ waitUntil = "load", timeout = 30_000 } = {}) {
601
+ await this.#navigate(() => this.cdp("Page.reload", {}), { waitUntil, timeout });
602
+ }
603
+
604
+ async goBack({ waitUntil = "load", timeout = 30_000 } = {}) {
605
+ await this.#navigate(() => this.#bridge.call("tabs.goBack", this.tabId), { waitUntil, timeout });
606
+ return this.url();
607
+ }
608
+
609
+ async goForward({ waitUntil = "load", timeout = 30_000 } = {}) {
610
+ await this.#navigate(() => this.#bridge.call("tabs.goForward", this.tabId), { waitUntil, timeout });
611
+ return this.url();
612
+ }
613
+
614
+ async waitForLoadState(state = "load", { timeout = 30_000 } = {}) {
615
+ await poll(
616
+ async () => {
617
+ const readyState = await this.#evaluateExpression("document.readyState", { timeout: 5_000 });
618
+ return state === "domcontentloaded" ? readyState !== "loading" : readyState === "complete";
619
+ },
620
+ { timeout, message: `Page did not reach "${state}" within ${timeout}ms` },
621
+ );
622
+ if (state === "networkidle") await this.#waitForNetworkIdle(timeout);
623
+ }
624
+
625
+ async waitForURL(matcher, { timeout = 30_000, waitUntil = "load" } = {}) {
626
+ const url = await poll(
627
+ async () => {
628
+ await this._tick();
629
+ const current = await this.url();
630
+ return urlMatches(current, matcher) ? current : false;
631
+ },
632
+ { timeout, message: `URL did not match ${matcher} within ${timeout}ms` },
633
+ );
634
+ if (waitUntil !== "none" && waitUntil !== "commit") await this.waitForLoadState(waitUntil, { timeout });
635
+ await this.settle({ quiet: 120, max: 1_500 });
636
+ return url;
637
+ }
638
+
639
+ async waitForSelector(selector, { state = "visible", timeout = 30_000 } = {}) {
640
+ const { path, local } = splitFrameSelector(selector);
641
+ return poll(
642
+ async () => {
643
+ await this._tick();
644
+ const context = await this.#frameContext(path);
645
+ const count = (visibleOnly) => this.#libAt(context, "count", local, { visibleOnly });
646
+ if (state === "visible") return (await count(true)) > 0;
647
+ if (state === "attached") return (await count(false)) > 0;
648
+ if (state === "hidden") return (await count(true)) === 0;
649
+ if (state === "detached") return (await count(false)) === 0;
650
+ throw fatal(new Error(`Unknown state: ${state}`));
651
+ },
652
+ { timeout, message: `Selector ${selector} did not become ${state} within ${timeout}ms` },
653
+ );
654
+ }
655
+
656
+ async waitForFunction(fn, argument, { timeout = 30_000, polling = 100 } = {}) {
657
+ return poll(async () => {
658
+ await this._tick();
659
+ return this.evaluate(fn, argument);
660
+ }, {
661
+ timeout,
662
+ interval: polling,
663
+ message: `Function did not return a truthy value within ${timeout}ms`,
664
+ });
665
+ }
666
+
667
+ async waitForDownload({ timeout = 60_000, since = Date.now() - 15_000, forget = false } = {}) {
668
+ const startedAfter = new Date(since).toISOString();
669
+ const origin = await this.url().then((url) => new URL(url).origin, () => null);
670
+ const deadline = Date.now() + timeout;
671
+ let last = null;
672
+ while (Date.now() < deadline) {
673
+ const items = await this.#bridge.call("downloads.search", { startedAfter, orderBy: ["-startTime"], limit: 10 });
674
+ const mine = items.find((item) => origin && [item.referrer, item.url, item.finalUrl].some((value) => value?.startsWith(origin))) ?? items[0];
675
+ if (mine) {
676
+ last = mine;
677
+ if (mine.state === "complete") {
678
+ if (forget) await this.#bridge.call("downloads.erase", { id: mine.id }).catch(() => {});
679
+ return { path: mine.filename, url: mine.finalUrl || mine.url, bytes: mine.fileSize ?? mine.totalBytes, mime: mine.mime };
680
+ }
681
+ if (mine.state === "interrupted") throw new Error(`Download of ${mine.finalUrl || mine.url} failed: ${mine.error}`);
682
+ }
683
+ await sleep(150);
684
+ }
685
+ throw new Error(last ? `Download ${last.filename || last.url} is still in progress after ${timeout}ms` : `No download started within ${timeout}ms`);
686
+ }
687
+
688
+ async waitForTimeout(ms) {
689
+ const deadline = Date.now() + ms;
690
+ while (Date.now() < deadline) {
691
+ await this._tick();
692
+ await sleep(Math.min(50, Math.max(0, deadline - Date.now())));
693
+ }
694
+ }
695
+
696
+ async waitForEvent(name, { timeout = 30_000 } = {}) {
697
+ if (name === "popup") return this.#task._waitForPopup(this, timeout);
698
+ if (name === "dialog") {
699
+ if (this.#dialog) return this.#dialog;
700
+ let waiter;
701
+ return withTimeout(
702
+ new Promise((resolve) => {
703
+ waiter = resolve;
704
+ this.#dialogWaiters.add(waiter);
705
+ }),
706
+ timeout,
707
+ `No dialog within ${timeout}ms`,
708
+ ).finally(() => this.#dialogWaiters.delete(waiter));
709
+ }
710
+ if (name === "load") return this.#waitForCdpEvent("Page.loadEventFired", { timeout });
711
+ if (name === "filechooser") return this.waitForFileChooser({ timeout });
712
+ if (name === "download") return this.waitForDownload({ timeout });
713
+ throw new Error(`Unknown event: ${name}`);
714
+ }
715
+
716
+ async #snapshotIn(context, options) {
717
+ let text;
718
+ try {
719
+ text = await this.#libAt(context, "snapshot", options);
720
+ } catch (error) {
721
+ if (!TRANSIENT.test(error.message) || context.depth) throw error;
722
+ await this.waitForLoadState("domcontentloaded", { timeout: 5_000 });
723
+ text = await this.#libAt(context, "snapshot", options);
724
+ }
725
+ const lines = text.split("\n");
726
+ const frameLine = /^(\s*)(?:[+-] )?@(\d+) iframe .*\(cross-origin\)$/;
727
+ const inline = [];
728
+ for (const [index, line] of lines.entries()) {
729
+ const match = index > 0 && line.match(frameLine);
730
+ if (match && !line.startsWith("- ") && context.depth < 3) inline.push({ index, ref: Number(match[2]), indent: match[1] + " " });
731
+ }
732
+ const expanded = new Set(inline.map((frame) => frame.ref));
733
+ const extra = options.diff && context.depth < 3
734
+ ? (await this.#libAt(context, "crossFrames", { visibleOnly: true }).catch(() => [])).filter((frame) => !expanded.has(frame.ref))
735
+ : [];
736
+ const [inlineLines, extraLines] = await Promise.all([
737
+ Promise.all(inline.map((frame) => this.#frameSnapshotLines(context, frame.ref, options, frame.indent))),
738
+ Promise.all(extra.map((frame) => this.#frameSnapshotLines(context, frame.ref, options, ""))),
739
+ ]);
740
+ const byIndex = new Map(inline.map((frame, position) => [frame.index, inlineLines[position]]));
741
+ const output = [];
742
+ for (const [index, line] of lines.entries()) {
743
+ output.push(prefixRefs(line, context.prefix));
744
+ output.push(...(byIndex.get(index) ?? []));
745
+ }
746
+ extra.forEach((frame, position) => {
747
+ if (extraLines[position].length) output.push(`frame @${context.prefix}${frame.ref} "${frame.name}":`, ...extraLines[position]);
748
+ });
749
+ if (output.length > 2 && /^\(no change since last snapshot\)$/.test(output[1])) output.splice(1, 1);
750
+ return output.join("\n");
751
+ }
752
+
753
+ async #frameSnapshotLines(context, ref, options, indent) {
754
+ try {
755
+ const child = await this.#childContext(context, ref);
756
+ const inner = await this.#snapshotIn(child, { ...options, root: undefined, scope: "viewport" });
757
+ const body = inner.split("\n").slice(1).filter((line) => !/^\((no change|nothing visible)/.test(line));
758
+ return body.map((line) => indent + line);
759
+ } catch (error) {
760
+ return [`${indent}(frame not readable: ${error.message})`];
761
+ }
762
+ }
763
+
764
+ async snapshot(options = {}) {
765
+ await this.#attach();
766
+ await this._tick();
767
+ if ((await this.#evaluateExpression("document.contentType").catch(() => "")) === "application/pdf") {
768
+ return `${await this.title()} | ${await this.url()}\n(PDF document. Use \`text\` to read it; use \`shot\` to see a page.)`;
769
+ }
770
+ if (!options.root) return this.#snapshotIn(ROOT_CONTEXT, options);
771
+ options = { ...options, scope: "full_page" };
772
+ const { path, local: rawLocal } = splitFrameSelector(options.root);
773
+ let context = await this.#frameContext(path);
774
+ let local = rawLocal;
775
+ if (!/^(?:@|ref=)\d+$/.test(local)) {
776
+ const target = await this.#target(options.root, { timeout: 5_000 });
777
+ context = target.context;
778
+ local = `@${target.ref}`;
779
+ }
780
+ const refMatch = local.match(/^(?:@|ref=)(\d+)$/);
781
+ if (refMatch && (await this.#libAt(context, "refIsCrossFrame", Number(refMatch[1])))) {
782
+ context = await this.#childContext(context, Number(refMatch[1]));
783
+ return this.#snapshotIn(context, { ...options, root: undefined, scope: "viewport" });
784
+ }
785
+ return this.#snapshotIn(context, { ...options, root: local });
786
+ }
787
+
788
+ async #findIn(context, query, options) {
789
+ const results = [];
790
+ const own = await this.#libAt(context, "find", query, { limit: options.limit ?? 15 });
791
+ if (!own.startsWith("no match")) results.push(...own.split("\n").map((line) => prefixRefs(line, context.prefix)));
792
+ if (context.depth >= 3) return results;
793
+ const frames = await this.#libAt(context, "crossFrames").catch(() => []);
794
+ const nested = await Promise.all(
795
+ frames.map((frame) =>
796
+ this.#childContext(context, frame.ref)
797
+ .then((child) => this.#findIn(child, query, options))
798
+ .catch(() => []),
799
+ ),
800
+ );
801
+ return [...results, ...nested.flat()];
802
+ }
803
+
804
+ async settle({ quiet = 120, max = 1_000 } = {}) {
805
+ const deadline = Date.now() + max;
806
+ let last = await this._lib("mutationCount").catch(() => null);
807
+ let quietSince = Date.now();
808
+ while (Date.now() < deadline) {
809
+ await this._tick();
810
+ await sleep(25);
811
+ const count = await this._lib("mutationCount").catch(() => null);
812
+ if (count !== last) {
813
+ last = count;
814
+ quietSince = Date.now();
815
+ } else if (Date.now() - quietSince >= quiet) {
816
+ return true;
817
+ }
818
+ }
819
+ return false;
820
+ }
821
+
822
+ async table(selector, options = {}) {
823
+ await this.#attach();
824
+ await this._tick();
825
+ if (!selector) return this._lib("tableText", options);
826
+ const target = await this.#target(selector, { timeout: 5_000, visibleOnly: false });
827
+ return this.#describeIn(target.context, await this.#libAt(target.context, "tableText", { ...options, ref: target.ref }));
828
+ }
829
+
830
+ async links(query = "", options = {}) {
831
+ await this.#attach();
832
+ await this._tick();
833
+ return this._lib("linkList", { ...options, query });
834
+ }
835
+
836
+ async section(query, options = {}) {
837
+ await this.#attach();
838
+ await this._tick();
839
+ return this._lib("sectionText", query, options);
840
+ }
841
+
842
+ async find(query, options = {}) {
843
+ await this.#attach();
844
+ await this._tick();
845
+ const results = await this.#findIn(ROOT_CONTEXT, query, options);
846
+ return results.length ? results.join("\n") : `no match for "${query}"`;
847
+ }
848
+
849
+ async #pdfText(maxChars) {
850
+ const url = await this.url();
851
+ const file = path.join(STATE_DIR, "pdf", `${Date.now()}-${this.label}.pdf`);
852
+ await this.fetch(url, { saveAs: file });
853
+ try {
854
+ const { stdout } = await run(
855
+ "osascript",
856
+ [
857
+ "-l",
858
+ "JavaScript",
859
+ "-e",
860
+ 'ObjC.import("PDFKit"); function run(argv) { const doc = $.PDFDocument.alloc.initWithURL($.NSURL.fileURLWithPath(argv[0])); return doc.isNil() ? "" : ObjC.unwrap(doc.string); }',
861
+ file,
862
+ ],
863
+ { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 },
864
+ );
865
+ const value = stdout.replace(/\n{3,}/g, "\n\n").trim();
866
+ return value.length > maxChars ? `${value.slice(0, maxChars)}\n… truncated (${value.length} chars total)` : value;
867
+ } finally {
868
+ await fs.rm(file, { force: true });
869
+ }
870
+ }
871
+
872
+ async #googleExport(maxChars) {
873
+ const url = new URL(await this.url());
874
+ if (url.hostname !== "docs.google.com") return null;
875
+ const match = url.pathname.match(/^\/(document|spreadsheets|presentation)\/d\/([\w-]+)/);
876
+ if (!match) return null;
877
+ const [, kind, id] = match;
878
+ const gid = url.searchParams.get("gid") ?? url.hash.match(/gid=(\d+)/)?.[1];
879
+ const exportUrl =
880
+ kind === "spreadsheets"
881
+ ? `/spreadsheets/d/${id}/export?format=csv${gid ? `&gid=${gid}` : ""}`
882
+ : `/${kind}/d/${id}/export?format=txt`;
883
+ const response = await this.fetch(exportUrl).catch(() => null);
884
+ if (!response?.ok) return null;
885
+ const value = response.body.replace(/\r\n/g, "\n").trim();
886
+ return value.length > maxChars ? `${value.slice(0, maxChars)}\n… truncated (${value.length} chars total)` : value;
887
+ }
888
+
889
+ async text(options = {}) {
890
+ await this.#attach();
891
+ await this._tick();
892
+ if (!options.frame && !options.selector && !options.all) {
893
+ const exported = await this.#googleExport(options.maxChars ?? 8_000).catch(() => null);
894
+ if (exported !== null) return exported;
895
+ }
896
+ if (!options.frame && !options.selector && (await this.#evaluateExpression("document.contentType").catch(() => "")) === "application/pdf") {
897
+ return this.#pdfText(options.maxChars ?? 8_000);
898
+ }
899
+ const { frame, selector, ...rest } = options;
900
+ let context = frame ? await this.#frameOf(frame) : ROOT_CONTEXT;
901
+ if (selector) {
902
+ const { path, local } = splitFrameSelector(selector);
903
+ context = await this.#frameContext(path);
904
+ const refMatch = local.match(/^(?:@|ref=)(\d+)$/);
905
+ if (refMatch && (await this.#libAt(context, "refIsCrossFrame", Number(refMatch[1])))) {
906
+ context = await this.#childContext(context, Number(refMatch[1]));
907
+ } else {
908
+ const target = await this.#target(selector, { timeout: 1_000, visibleOnly: false });
909
+ context = target.context;
910
+ rest.ref = target.ref;
911
+ }
912
+ }
913
+ const expression = `(() => {
914
+ const body = ${LIBRARY_PRELUDE}.text(${JSON.stringify(rest)});
915
+ try {
916
+ return { body, editors: ${EDITOR_PRELUDE}.codeEditors() };
917
+ } catch {
918
+ return { body, editors: [] };
919
+ }
920
+ })()`;
921
+ const { body, editors } = await this.#evaluateExpression(expression, scopeOf(context));
922
+ if (!editors.length || rest.ref !== undefined) return body;
923
+ const maxChars = rest.maxChars ?? 8_000;
924
+ const sections = editors.map(({ kind, value }, index) => {
925
+ const lines = value.split("\n").length;
926
+ const shown = value.length > maxChars ? `${value.slice(0, maxChars)}\n… truncated (${value.length} chars total)` : value;
927
+ return `--- ${kind} editor ${index + 1}/${editors.length}, ${lines} lines ---\n${shown}`;
928
+ });
929
+ return [body, ...sections].join("\n\n");
930
+ }
931
+
932
+ async #target(selector, { timeout, visibleOnly = true }) {
933
+ if (!String(selector ?? "").trim()) throw fatal(new Error("Empty selector. Pass a ref like @12 or a selector."));
934
+ await this.#attach();
935
+ await this._tick();
936
+ const { path, local } = splitFrameSelector(selector);
937
+ return poll(
938
+ async () => {
939
+ const context = await this.#frameContext(path);
940
+ try {
941
+ const { ref, description } = await this.#libAt(context, "resolve", local, { visibleOnly });
942
+ return { context, ref, description: this.#describeIn(context, description) };
943
+ } catch (error) {
944
+ if (/matches \d+ elements|stale or unknown|Cannot parse/.test(error.message)) throw fatal(new Error(this.#describeIn(context, error.message)));
945
+ if (path.length || !/matches no element|and no visible one/.test(error.message) || /^(?:@|ref=)/.test(local.trim())) throw error;
946
+ const found = await this.#resolveInFrames(context, local, visibleOnly, 2);
947
+ if (found.length === 1) return found[0];
948
+ if (found.length > 1) {
949
+ throw fatal(new Error(`Selector ${selector} matches elements in ${found.length} frames:\n${found.map((item) => ` ${item.description}`).join("\n")}`));
950
+ }
951
+ throw error;
952
+ }
953
+ },
954
+ { timeout, message: `Could not find ${selector} within ${timeout}ms` },
955
+ );
956
+ }
957
+
958
+ async #resolveInFrames(context, local, visibleOnly, depth) {
959
+ if (!depth) return [];
960
+ const frames = await this.#libAt(context, "crossFrames", { visibleOnly: true }).catch(() => []);
961
+ const results = await Promise.all(
962
+ frames.map(async (frame) => {
963
+ const child = await this.#childContext(context, frame.ref).catch(() => null);
964
+ if (!child) return [];
965
+ try {
966
+ const { ref, description } = await this.#libAt(child, "resolve", local, { visibleOnly });
967
+ return [{ context: child, ref, description: this.#describeIn(child, description) }];
968
+ } catch (error) {
969
+ if (/matches \d+ elements/.test(error.message)) throw fatal(new Error(this.#describeIn(child, error.message)));
970
+ return this.#resolveInFrames(child, local, visibleOnly, depth - 1);
971
+ }
972
+ }),
973
+ );
974
+ return results.flat();
975
+ }
976
+
977
+ async #pointOf(target, { force = false, timeout = 5_000 } = {}) {
978
+ const deadline = Date.now() + Math.min(timeout, 2_000);
979
+ const { context, ref } = target;
980
+ let point;
981
+ while (true) {
982
+ point = await this.#libAt(context, "actionPoint", ref);
983
+ if (!point.covered || force) break;
984
+ if (Date.now() >= deadline) {
985
+ throw new Error(
986
+ `${this.#describeIn(context, point.description)} is covered by ${this.#describeIn(context, point.covered)}. Close the overlay first, or pass { force: true }.`,
987
+ );
988
+ }
989
+ await sleep(150);
990
+ }
991
+ const fresh = context.depth ? await this.#frameContext(pathOf(context)) : context;
992
+ return {
993
+ ...point,
994
+ x: point.x + fresh.offset.x,
995
+ y: point.y + fresh.offset.y,
996
+ description: this.#describeIn(context, point.description),
997
+ ref,
998
+ context,
999
+ input: fresh.input,
1000
+ };
1001
+ }
1002
+
1003
+ async #actionPoint(selector, options = {}) {
1004
+ const target = await this.#target(selector, { timeout: options.timeout ?? 5_000 });
1005
+ return this.#pointOf(target, options);
1006
+ }
1007
+
1008
+ async #receipt(action, description, work) {
1009
+ const downloads = [];
1010
+ const stopDownloads = this.#bridge.on((message) => {
1011
+ if (message.type === "download" && message.event === "created") downloads.push(message.item.finalUrl || message.item.url);
1012
+ });
1013
+ const popups = [];
1014
+ const stopWatching = this.#task._watchPopups(this, (page) => popups.push({ label: page.label, tabId: page.tabId }));
1015
+ let dialog = null;
1016
+ let waiter;
1017
+ const dialogOpened = new Promise((resolve) => {
1018
+ waiter = resolve;
1019
+ this.#dialogWaiters.add(waiter);
1020
+ });
1021
+ try {
1022
+ const outcome = work();
1023
+ outcome.catch(() => {});
1024
+ const first = await Promise.race([outcome.then(() => "done"), dialogOpened.then(() => "dialog")]);
1025
+ if (first === "dialog") dialog = this.#dialog;
1026
+ else {
1027
+ await outcome;
1028
+ if (this.openedBy === "user") await sleep(30);
1029
+ }
1030
+ if (!dialog && this.#dialog) dialog = this.#dialog;
1031
+ } finally {
1032
+ this.#dialogWaiters.delete(waiter);
1033
+ stopWatching();
1034
+ }
1035
+ stopDownloads();
1036
+ const { dialogs, opens, events } = await this._takeShim();
1037
+ let navigated = null;
1038
+ for (const open of opens) {
1039
+ if (open.kind === "form") {
1040
+ navigated = open.url;
1041
+ continue;
1042
+ }
1043
+ if (!open.url || open.url === "about:blank") continue;
1044
+ if (!/^https?:/i.test(open.url)) {
1045
+ events.push({ kind: "external link", detail: open.url });
1046
+ continue;
1047
+ }
1048
+ const opener = open.kind === "window.open" ? { tabId: this.tabId, stubId: open.id } : null;
1049
+ const page = await this.#task.newPage({ url: open.url, opener });
1050
+ popups.push({ label: page.label, tabId: page.tabId, via: open.kind });
1051
+ }
1052
+ if (navigated) await this.waitForLoadState("load", { timeout: 15_000 }).catch(() => {});
1053
+ return { action, target: description, popups, dialog, dialogs, navigated, downloads, blocked: events };
1054
+ }
1055
+
1056
+ async #clickTarget(target, { button = "left", clickCount = 1, delay = 0, force = false, modifiers = [], timeout = 5_000, label } = {}) {
1057
+ const point = await this.#pointOf(target, { force, timeout });
1058
+ const receipt = await this.#receipt("click", point.description, () =>
1059
+ this.mouse.click(point.x, point.y, { button, clickCount, delay, modifiers, label: label ?? `click ${point.description}`, input: point.input }),
1060
+ );
1061
+ if (point.disabled) receipt.disabled = true;
1062
+ return receipt;
1063
+ }
1064
+
1065
+ async click(selector, options = {}) {
1066
+ const target = await this.#target(selector, { timeout: options.timeout ?? 5_000 });
1067
+ return this.#clickTarget(target, options);
1068
+ }
1069
+
1070
+ async dblclick(selector, options = {}) {
1071
+ return this.click(selector, { ...options, clickCount: 2 });
1072
+ }
1073
+
1074
+ async hover(selector, { force = false, timeout = 5_000, label } = {}) {
1075
+ const point = await this.#actionPoint(selector, { force, timeout });
1076
+ await this.mouse.move(point.x, point.y, { steps: 3, label: label ?? `hover ${point.description}`, input: point.input });
1077
+ return { action: "hover", target: point.description };
1078
+ }
1079
+
1080
+ async #focusTarget(target) {
1081
+ if (target.context.depth) {
1082
+ const point = await this.#pointOf(target, { force: true });
1083
+ await this.mouse.click(point.x, point.y, { label: false, input: point.input });
1084
+ }
1085
+ return this.#describeIn(target.context, await this.#libAt(target.context, "focus", target.ref));
1086
+ }
1087
+
1088
+ async focus(selector, { timeout = 5_000 } = {}) {
1089
+ return this.#focusTarget(await this.#target(selector, { timeout }));
1090
+ }
1091
+
1092
+ async fill(selector, value, options = {}) {
1093
+ try {
1094
+ return await this.#fillOnce(selector, value, options);
1095
+ } catch (error) {
1096
+ const words = String(selector).trim().match(/^text=([^"'\]]+)$/)?.[1];
1097
+ if (!words || !/is not editable|is not a select/.test(error.message)) throw error;
1098
+ return this.#fillOnce(`role=textbox[name*="${words.trim()}"]`, value, options).catch(() => {
1099
+ throw error;
1100
+ });
1101
+ }
1102
+ }
1103
+
1104
+ async #fillOnce(selector, value, { timeout = 5_000 } = {}) {
1105
+ const text = String(value);
1106
+ const target = await this.#target(selector, { timeout });
1107
+ const { context } = target;
1108
+ const valueInput = await this.#withElement(
1109
+ context,
1110
+ target.ref,
1111
+ '(element.tagName === "INPUT" && ["range", "color", "date", "time", "month", "week", "datetime-local"].includes(element.type))',
1112
+ );
1113
+ if (context.depth && !valueInput) await this.#focusTarget(target);
1114
+ const editor = await this.#withElement(
1115
+ context,
1116
+ target.ref,
1117
+ "(() => { const found = editors.prepare(element); return found && { kind: found.kind, api: found.api, x: found.x, y: found.y, description: lib.describe(found.root) }; })()",
1118
+ );
1119
+ if (editor) return this.#fillEditor(context, editor, text);
1120
+ const { kind, ref, x, y } = await this.#libAt(context, "prepareFill", target.ref);
1121
+ this._showCursor(x + context.offset.x, y + context.offset.y, `fill ${target.description}`);
1122
+ if (kind === "value") return this.#receipt("fill", target.description, () => this.#libAt(context, "fillValue", ref, text));
1123
+ const receipt = await this.#receipt("fill", target.description, async () => {
1124
+ if (text === "") {
1125
+ await this.keyboard.press("Backspace");
1126
+ } else {
1127
+ await this.cdp("Input.insertText", { text });
1128
+ }
1129
+ const actual = await this.#libAt(context, "valueOf", ref);
1130
+ if (kind === "input" && actual !== text) await this.#libAt(context, "forceValue", ref, text);
1131
+ });
1132
+ if (kind === "contenteditable" && text) {
1133
+ const normalize = (value) => String(value ?? "").replace(/\s+/g, " ").trim();
1134
+ const actual = normalize(await this.#libAt(context, "valueOf", ref));
1135
+ if (!actual.includes(normalize(text))) receipt.warning = `${target.description} holds ${JSON.stringify(actual.slice(0, 120))} after fill`;
1136
+ }
1137
+ return receipt;
1138
+ }
1139
+
1140
+ async #fillEditor(context, editor, text) {
1141
+ const ref = Number(/^@(\d+)/.exec(editor.description)[1]);
1142
+ const description = this.#describeIn(context, editor.description);
1143
+ const label = `${editor.kind} editor ${description}`;
1144
+ this._showCursor(editor.x + context.offset.x, editor.y + context.offset.y, `fill ${label}`);
1145
+ const receipt = await this.#receipt("fill", label, async () => {
1146
+ if (editor.api) {
1147
+ const result = await this.#withElement(context, ref, `editors.setValue(element, ${JSON.stringify(text)})`);
1148
+ if (result.ok) return;
1149
+ }
1150
+ await this.keyboard.press("Meta+a");
1151
+ if (text === "") await this.keyboard.press("Backspace");
1152
+ else await this.cdp("Input.insertText", { text });
1153
+ });
1154
+ const actual = await this.#withElement(context, ref, "editors.getValue(element)");
1155
+ const expected = text.replace(/\r\n?/g, "\n").replace(/ /g, " ");
1156
+ if (actual !== expected) {
1157
+ const shown = actual === null ? "nothing readable" : JSON.stringify(actual.length > 120 ? `${actual.slice(0, 119)}…` : actual);
1158
+ receipt.warning = `${label} holds ${shown} after fill`;
1159
+ }
1160
+ return { ...receipt, editor: editor.kind };
1161
+ }
1162
+
1163
+ async press(selector, key, options = {}) {
1164
+ if (key === undefined) {
1165
+ await this.keyboard.press(selector, options);
1166
+ return { action: "press", target: null };
1167
+ }
1168
+ const target = await this.#target(selector, { timeout: options.timeout ?? 5_000 });
1169
+ const description = await this.#focusTarget(target);
1170
+ return this.#receipt("press", description, () => this.keyboard.press(key, options));
1171
+ }
1172
+
1173
+ async check(selector, checked = true, options = {}) {
1174
+ const target = await this.#target(selector, { timeout: options.timeout ?? 5_000 });
1175
+ const state = () => this.#libAt(target.context, "checkedState", target.ref);
1176
+ const before = await state();
1177
+ if (before.checked === checked) return { action: "check", target: this.#describeIn(target.context, before.control), changed: false };
1178
+ const receipt = await this.#clickTarget(target, options);
1179
+ const after = await state();
1180
+ const control = this.#describeIn(target.context, after.control);
1181
+ if (after.checked !== checked) throw new Error(`${control} did not become ${checked ? "checked" : "unchecked"} after clicking ${target.description}.`);
1182
+ return { ...receipt, action: "check", target: control, changed: true };
1183
+ }
1184
+
1185
+ async uncheck(selector, options = {}) {
1186
+ return this.check(selector, false, options);
1187
+ }
1188
+
1189
+ async selectOption(selector, values, { timeout = 5_000 } = {}) {
1190
+ const target = await this.#target(selector, { timeout });
1191
+ return this.#libAt(target.context, "selectOption", target.ref, values);
1192
+ }
1193
+
1194
+ async setInputFiles(selector, files, { timeout = 5_000 } = {}) {
1195
+ const list = (Array.isArray(files) ? files : [files]).map((file) => path.resolve(file));
1196
+ for (const file of list) await fs.access(file);
1197
+ const target = await this.#target(selector, { timeout, visibleOnly: false });
1198
+ const inputRef = await this.#libAt(target.context, "fileInput", target.ref);
1199
+ const objectId = await this.#objectIdAt(target.context, inputRef);
1200
+ await this.cdp("DOM.setFileInputFiles", { files: list, objectId }, { sessionId: target.context.sessionId });
1201
+ return { action: "setInputFiles", target: target.description, files: list };
1202
+ }
1203
+
1204
+ async waitForFileChooser({ timeout = 30_000 } = {}) {
1205
+ await this.cdp("Page.setInterceptFileChooserDialog", { enabled: true });
1206
+ try {
1207
+ const event = await this.#waitForCdpEvent("Page.fileChooserOpened", { timeout });
1208
+ return {
1209
+ mode: event.mode,
1210
+ setFiles: async (files) => {
1211
+ const list = (Array.isArray(files) ? files : [files]).map((file) => path.resolve(file));
1212
+ for (const file of list) await fs.access(file);
1213
+ await this.cdp("DOM.setFileInputFiles", { files: list, backendNodeId: event.backendNodeId });
1214
+ if (this.openedBy === "user") await this.cdp("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {});
1215
+ return { files: list, dialog: this.#dialog };
1216
+ },
1217
+ };
1218
+ } catch (error) {
1219
+ if (this.openedBy === "user") await this.cdp("Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => {});
1220
+ throw error;
1221
+ }
1222
+ }
1223
+
1224
+ async dragAndDrop(source, target, { steps = 12, force = false, timeout = 5_000 } = {}) {
1225
+ const sourceTarget = await this.#target(source, { timeout });
1226
+ await this.#pointOf(sourceTarget, { force, timeout });
1227
+ const to = await this.#actionPoint(target, { force: true, timeout });
1228
+ const from = await this.#pointOf(sourceTarget, { force: true, timeout });
1229
+ if (from.input !== ROOT_INPUT || to.input !== ROOT_INPUT) {
1230
+ await this.mouse.move(from.x, from.y, { label: `drag ${from.description}`, input: from.input });
1231
+ await this.mouse.down();
1232
+ await this.mouse.move(to.x, to.y, { steps, label: `drop on ${to.description}`, input: to.input });
1233
+ await this.mouse.up();
1234
+ return { action: "dragAndDrop", source: from.description, target: to.description, mode: "pointer" };
1235
+ }
1236
+ const draggable = await this.#withElement(
1237
+ sourceTarget.context,
1238
+ sourceTarget.ref,
1239
+ "Boolean(element.closest(\"[draggable=true]\") || (element.closest(\"a[href], img\") && !element.closest(\"[draggable=false]\")))",
1240
+ );
1241
+ const drag = await html5Drag(this, from, to, {
1242
+ steps,
1243
+ draggable,
1244
+ labels: { from: `drag ${from.description}`, to: `drop on ${to.description}` },
1245
+ });
1246
+ return { action: "dragAndDrop", source: from.description, target: to.description, mode: drag.intercepted ? "html5" : "pointer" };
1247
+ }
1248
+
1249
+ async scroll(deltaY, { deltaX = 0, x, y, selector, label } = {}) {
1250
+ let context = ROOT_CONTEXT;
1251
+ let ref = null;
1252
+ if (selector) {
1253
+ const target = await this.#target(selector, { timeout: 5_000 });
1254
+ const point = await this.#pointOf(target, { force: true });
1255
+ ({ context, ref } = target);
1256
+ x = point.x;
1257
+ y = point.y;
1258
+ } else if (x === undefined || y === undefined) {
1259
+ const size = await this.evaluate("[innerWidth, innerHeight]");
1260
+ x = Math.round(size[0] / 2);
1261
+ y = Math.round(size[1] / 2);
1262
+ }
1263
+ const localX = x - context.offset.x;
1264
+ const localY = y - context.offset.y;
1265
+ const before = await this.#libAt(context, "scrollState", localX, localY, ref);
1266
+ await this.mouse.move(x, y, { label: false });
1267
+ await this.mouse.wheel(deltaX, deltaY, { label: label ?? "scroll" });
1268
+ let after = await this.#libAt(context, "scrollState", localX, localY, ref);
1269
+ if (after.top === before.top && after.left === before.left && (deltaY || deltaX)) {
1270
+ after = await this.#libAt(context, "scrollByProgram", localX, localY, ref, deltaX, deltaY);
1271
+ }
1272
+ return { scroller: this.#describeIn(context, after.name), scrollY: after.top, scrollX: after.left, height: after.height, view: after.view };
1273
+ }
1274
+
1275
+ async seek(target, options = {}) {
1276
+ try {
1277
+ return await this.#seekScroll(target, options);
1278
+ } catch (error) {
1279
+ if (options.container || !/Found no scrollable|reached the end/.test(error.message)) throw error;
1280
+ const found = await this.#seekPages(target, options);
1281
+ if (found) return found;
1282
+ throw error;
1283
+ }
1284
+ }
1285
+
1286
+ async #seekPages(target, { maxPages = 30 } = {}) {
1287
+ for (let page = 1; page <= maxPages; page++) {
1288
+ const found = await this._lib("seekFind", target);
1289
+ if (found) return page === 1 ? found : `${found} (page ${page})`;
1290
+ const next = await this._lib("nextPageControl");
1291
+ if (!next) return null;
1292
+ await this.click(`@${next}`);
1293
+ await this.settle();
1294
+ }
1295
+ return null;
1296
+ }
1297
+
1298
+ async #seekScroll(target, { container, max = 400, timeout = 30_000, idle = 2_000, direction = "down" } = {}) {
1299
+ const sign = direction === "up" ? -1 : 1;
1300
+ const deadline = Date.now() + timeout;
1301
+ const probe = (scroller = null, advance = 0) => this._lib("seekProbe", target, { container, scroller, advance });
1302
+ const atEdge = (state) => (sign > 0 ? state.top >= state.max - 2 : state.top <= 1);
1303
+ let state = await probe();
1304
+ let wheel = false;
1305
+ let scrolled = false;
1306
+ let steps = 0;
1307
+ while (!state.found || scrolled) {
1308
+ if (state.found) {
1309
+ await this._frame();
1310
+ scrolled = false;
1311
+ state = await probe(state.scroller);
1312
+ continue;
1313
+ }
1314
+ if (atEdge(state)) {
1315
+ state = await this.#seekMore(state, probe, Math.min(idle, deadline - Date.now()));
1316
+ if (state.found) break;
1317
+ if (state.more) continue;
1318
+ throw new Error(`No match for ${target} in ${state.name} after ${steps} scroll steps: reached the ${sign > 0 ? "end" : "top"}.`);
1319
+ }
1320
+ if (steps >= max || Date.now() >= deadline) {
1321
+ throw new Error(`No match for ${target} in ${state.name} after ${steps} scroll steps (at y=${state.top}/${state.max}).`);
1322
+ }
1323
+ steps++;
1324
+ if (!wheel) {
1325
+ const next = await probe(state.scroller ?? null, sign);
1326
+ if (next.found) {
1327
+ state = next;
1328
+ continue;
1329
+ }
1330
+ if (next.after !== next.top) {
1331
+ scrolled = steps % 4 !== 0;
1332
+ if (!scrolled) await this._frame();
1333
+ state = { ...next, top: next.after };
1334
+ continue;
1335
+ }
1336
+ wheel = true;
1337
+ }
1338
+ if (this.mouse.x !== state.x || this.mouse.y !== state.y) {
1339
+ await this.mouse.move(state.x, state.y, { label: `seek ${target}` });
1340
+ }
1341
+ await this.mouse.wheel(0, sign * state.step);
1342
+ const next = await probe(state.scroller);
1343
+ if (!next.found && next.top === state.top) throw new Error(`Cannot scroll ${state.name} (stuck at y=${next.top}/${next.max}). Pass a container selector.`);
1344
+ state = next;
1345
+ }
1346
+ return state.found;
1347
+ }
1348
+
1349
+ async #seekMore(state, probe, wait) {
1350
+ const until = Date.now() + Math.max(0, wait);
1351
+ do {
1352
+ await this._frame();
1353
+ const next = await probe(state.scroller);
1354
+ if (next.found || next.height > state.height + 2 || next.max > state.max + 2) return { ...next, more: true };
1355
+ await sleep(80);
1356
+ } while (Date.now() < until);
1357
+ return state;
1358
+ }
1359
+
1360
+ async screenshot({ path: filePath, fullPage = false, selector, scale = "css", timeout = 15_000 } = {}) {
1361
+ const [, metrics, [ratio, innerWidth]] = await Promise.all([
1362
+ this._lib("hideCursor").catch(() => {}),
1363
+ this.cdp("Page.getLayoutMetrics"),
1364
+ this.evaluate("[devicePixelRatio, innerWidth]"),
1365
+ ]);
1366
+ let clip = null;
1367
+ if (selector) {
1368
+ const target = await this.#target(selector, { timeout: 5_000 });
1369
+ const rect = await this.#libAt(target.context, "rectOf", target.ref);
1370
+ const fresh = target.context.depth ? await this.#frameContext(pathOf(target.context)) : target.context;
1371
+ const { cssVisualViewport: current } = await this.cdp("Page.getLayoutMetrics");
1372
+ clip = { x: current.pageX + rect.x + fresh.offset.x, y: current.pageY + rect.y + fresh.offset.y, width: rect.width, height: rect.height };
1373
+ } else if (fullPage) {
1374
+ clip = { x: 0, y: 0, width: metrics.cssContentSize.width, height: metrics.cssContentSize.height };
1375
+ }
1376
+ if (clip) {
1377
+ clip = {
1378
+ x: Math.floor(clip.x),
1379
+ y: Math.floor(clip.y),
1380
+ width: Math.max(1, Math.floor(clip.width)),
1381
+ height: Math.max(1, Math.floor(clip.height)),
1382
+ scale: 1,
1383
+ };
1384
+ }
1385
+ const width = clip ? clip.width : innerWidth;
1386
+ const png = Boolean(filePath && /\.png$/i.test(filePath));
1387
+ const format = png ? { format: "png" } : { format: "jpeg", quality: 80 };
1388
+ let data;
1389
+ try {
1390
+ ({ data } = await this.cdp(
1391
+ "Page.captureScreenshot",
1392
+ { ...format, ...(clip ? { clip, captureBeyondViewport: true } : {}) },
1393
+ { timeout },
1394
+ ));
1395
+ } catch (error) {
1396
+ if (/timed out/.test(error.message)) {
1397
+ throw new Error(
1398
+ `Screenshot of page ${this.label} timed out. Call await page.bringToFront() and retry.`,
1399
+ );
1400
+ }
1401
+ throw error;
1402
+ }
1403
+ const target = filePath ?? path.join(SCREENSHOT_DIR, `${new Date().toISOString().replace(/[:.]/g, "-")}-${this.label}.jpg`);
1404
+ await fs.mkdir(path.dirname(target), { recursive: true });
1405
+ if (!data) throw new Error(`Screenshot of page ${this.label} came back empty.`);
1406
+ await fs.writeFile(target, Buffer.from(data, "base64"));
1407
+ if (scale === "css" && ratio !== 1) {
1408
+ await run("sips", ["--resampleWidth", String(width), target], { encoding: "utf8" }).catch(() => {});
1409
+ }
1410
+ if (!filePath) await pruneScreenshots().catch(() => {});
1411
+ return target;
1412
+ }
1413
+
1414
+ async bringToFront() {
1415
+ await this.#bridge.call("tabs.update", this.tabId, { active: true });
1416
+ await sleep(200);
1417
+ }
1418
+
1419
+ async #frameSessions() {
1420
+ const sessions = await this.#bridge.call("host.sessions", this.tabId).catch(() => []);
1421
+ return sessions.filter((session) => session.type === "iframe").map((session) => session.sessionId);
1422
+ }
1423
+
1424
+ async #inProcessFrames(sessionId) {
1425
+ const { frameTree } = await this.cdp("Page.getFrameTree", {}, { sessionId, timeout: 2_000 });
1426
+ const frameIds = [];
1427
+ const visit = (tree) => {
1428
+ for (const child of tree.childFrames ?? []) {
1429
+ frameIds.push(child.frame.id);
1430
+ visit(child);
1431
+ }
1432
+ };
1433
+ visit(frameTree);
1434
+ const scopes = await Promise.all(
1435
+ frameIds.map(async (frameId) => {
1436
+ const { backendNodeId } = await this.cdp("DOM.getFrameOwner", { frameId }, { sessionId, timeout: 2_000 });
1437
+ const { node } = await this.cdp("DOM.describeNode", { backendNodeId }, { sessionId, timeout: 2_000 });
1438
+ return node.contentDocument ? this.#inProcessScope(sessionId, node) : null;
1439
+ }).map((scope) => scope.catch(() => null)),
1440
+ );
1441
+ return scopes.filter(Boolean);
1442
+ }
1443
+
1444
+ async #shimScopes() {
1445
+ const sessions = [null, ...(await this.#frameSessions())];
1446
+ const nested = await Promise.all(sessions.map((sessionId) => this.#inProcessFrames(sessionId).catch(() => [])));
1447
+ return [...sessions.map((sessionId) => ({ sessionId, contextId: null })), ...nested.flat()];
1448
+ }
1449
+
1450
+ async _takeShim() {
1451
+ if (this.openedBy === "user" || !this.#attached) return { dialogs: [], opens: [], events: [] };
1452
+ const expression =
1453
+ "(() => { const s = globalThis.__arcShim; if (!s) return { dialogs: [], opens: [], events: [] }; s.dialogs.policy = null; return { dialogs: s.dialogs.log.splice(0), opens: s.opens.splice(0), events: (s.events ?? []).splice(0) }; })()";
1454
+ const scopes = await this.#shimScopes();
1455
+ const results = await Promise.all(
1456
+ scopes.map((scope) => this.#evaluateExpression(expression, { timeout: 2_000, ...scope }).catch(() => ({ dialogs: [], opens: [], events: [] }))),
1457
+ );
1458
+ const native = await this.#bridge.call("host.handledDialogs", this.tabId).catch(() => []);
1459
+ const fileChoosers = native.filter((entry) => entry.type === "filechooser").map(() => ({ kind: "file chooser", detail: "use `upload <sel> <path>` instead" }));
1460
+ return {
1461
+ dialogs: [...results.flatMap((result) => result.dialogs), ...native.filter((entry) => entry.type !== "filechooser")],
1462
+ opens: results.flatMap((result) => result.opens),
1463
+ events: [...results.flatMap((result) => result.events ?? []), ...fileChoosers],
1464
+ };
1465
+ }
1466
+
1467
+ async #handleDialog(params) {
1468
+ await this.#attach();
1469
+ if (this.#dialog) {
1470
+ await this.#bridge.call("debugger.send", this.tabId, "Page.handleJavaScriptDialog", params);
1471
+ this.#dialog = null;
1472
+ return "answered";
1473
+ }
1474
+ if (this.openedBy === "user") throw new Error(`No dialog is open on page ${this.label}.`);
1475
+ const policy = JSON.stringify({ accept: params.accept, text: params.promptText });
1476
+ const expression = `(() => { if (!globalThis.__arcShim) return false; globalThis.__arcShim.dialogs.policy = ${policy}; return true; })()`;
1477
+ const scopes = await this.#shimScopes();
1478
+ await Promise.all(scopes.map((scope) => this.#evaluateExpression(expression, { timeout: 2_000, ...scope }).catch(() => false)));
1479
+ return "armed";
1480
+ }
1481
+
1482
+ async acceptDialog(promptText) {
1483
+ return this.#handleDialog({ accept: true, promptText });
1484
+ }
1485
+
1486
+ async dismissDialog() {
1487
+ return this.#handleDialog({ accept: false });
1488
+ }
1489
+
1490
+ async fetch(url, { method = "GET", headers, body, credentials = "same-origin", timeout = 30_000, saveAs } = {}) {
1491
+ const response = await this.evaluate(
1492
+ async ({ url, init, binary, timeout }) => {
1493
+ const result = await fetch(url, { ...init, signal: AbortSignal.timeout(timeout) });
1494
+ let payload;
1495
+ if (binary) {
1496
+ const bytes = new Uint8Array(await result.arrayBuffer());
1497
+ let text = "";
1498
+ for (let index = 0; index < bytes.length; index += 0x8000) {
1499
+ text += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
1500
+ }
1501
+ payload = btoa(text);
1502
+ } else {
1503
+ payload = await result.text();
1504
+ }
1505
+ return {
1506
+ ok: result.ok,
1507
+ status: result.status,
1508
+ statusText: result.statusText,
1509
+ url: result.url,
1510
+ headers: Object.fromEntries(result.headers),
1511
+ body: payload,
1512
+ };
1513
+ },
1514
+ { url, init: { method, headers, body, credentials }, binary: Boolean(saveAs), timeout },
1515
+ );
1516
+ if (saveAs) {
1517
+ const target = path.resolve(saveAs);
1518
+ await fs.mkdir(path.dirname(target), { recursive: true });
1519
+ await fs.writeFile(target, Buffer.from(response.body, "base64"));
1520
+ return { ...response, body: undefined, savedTo: target };
1521
+ }
1522
+ return response;
1523
+ }
1524
+
1525
+ async close() {
1526
+ if (this.openedBy === "user") await this._detach();
1527
+ await this.#bridge.call("tabs.remove", this.tabId).catch(() => {});
1528
+ this.#task._forget(this.label);
1529
+ }
1530
+ }