bopbop 0.0.0-stage → 0.1.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.
@@ -0,0 +1,894 @@
1
+ // One controlled tab over CDP: frames (incl. cross-origin OOPIFs), isolated-world agent,
2
+ // trusted input, dialogs, console + network buffers, settle-after-action.
3
+ import { readFileSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { pressKey } from './keys.js';
6
+ const AGENT = readFileSync(fileURLToPath(new URL('../page/agent.js', import.meta.url)), 'utf8');
7
+ const WORLD = '__bbm';
8
+ // Frames of other extensions (password managers, ad blockers): the debugger may not touch them.
9
+ // Chrome reports them as chrome-extension:// or with a masked URL (":").
10
+ const foreignFrame = (url) => url === ':' || (url || '').startsWith('chrome-extension:');
11
+ const RING = 300;
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ export class Tab {
14
+ allow;
15
+ deny;
16
+ agentSrc;
17
+ browser;
18
+ targetId;
19
+ root;
20
+ sessions;
21
+ frames;
22
+ prefixes;
23
+ nextFrame;
24
+ mainFrameId;
25
+ foreign;
26
+ console;
27
+ network;
28
+ inflight;
29
+ events;
30
+ dialogPolicy;
31
+ lastSnapshot;
32
+ closed;
33
+ loads;
34
+ ctxWaiters;
35
+ emulation = null;
36
+ constructor(browser, targetId, session, { allow = null, deny = null, shield = true } = {}) {
37
+ this.allow = allow;
38
+ this.deny = deny;
39
+ this.agentSrc = AGENT + (deny ? `\n;globalThis.__bbm.guard(${JSON.stringify(deny.spec)});` : '') + (shield ? '\n;globalThis.__bbm.shield();' : '');
40
+ this.browser = browser;
41
+ this.targetId = targetId;
42
+ this.root = session;
43
+ this.sessions = new Map([[session.id, session]]); // sessionId -> session
44
+ this.frames = new Map(); // frameId -> {id, parentId, session, ctx, prefix, url}
45
+ this.prefixes = new Map(); // prefix -> frameId
46
+ this.nextFrame = 1;
47
+ this.mainFrameId = null;
48
+ this.foreign = new Set(); // frame ids of other extensions' frames
49
+ this.console = [];
50
+ this.network = new Map(); // requestId -> entry
51
+ this.inflight = new Set();
52
+ this.events = []; // pending events for the next act result
53
+ this.dialogPolicy = null; // {accept, text, once}
54
+ this.lastSnapshot = null; // {lines: string[], url}
55
+ this.closed = false;
56
+ this.loads = 0; // DOMContentLoaded counter of the main frame
57
+ this.ctxWaiters = [];
58
+ }
59
+ async init() {
60
+ await this.#setupSession(this.root, true);
61
+ }
62
+ async #setupSession(s, isRoot) {
63
+ s.on('Runtime.executionContextCreated', ({ context }) => {
64
+ const fid = context.auxData?.frameId;
65
+ if (!fid || this.foreign.has(fid) || foreignFrame(context.origin))
66
+ return;
67
+ if (context.auxData.isDefault) {
68
+ this.#frame(fid, s).mainCtx = context.id;
69
+ return;
70
+ }
71
+ if (context.name !== WORLD)
72
+ return;
73
+ const f = this.#frame(fid, s);
74
+ f.ctx = context.id;
75
+ f.session = s;
76
+ for (const w of this.ctxWaiters.splice(0))
77
+ w();
78
+ });
79
+ s.on('Runtime.executionContextDestroyed', ({ executionContextId }) => {
80
+ for (const f of this.frames.values()) {
81
+ if (f.session !== s)
82
+ continue;
83
+ if (f.ctx === executionContextId)
84
+ f.ctx = null;
85
+ if (f.mainCtx === executionContextId)
86
+ f.mainCtx = null;
87
+ }
88
+ });
89
+ s.on('Runtime.executionContextsCleared', () => {
90
+ for (const f of this.frames.values())
91
+ if (f.session === s) {
92
+ f.ctx = null;
93
+ f.mainCtx = null;
94
+ }
95
+ });
96
+ s.on('Page.frameAttached', ({ frameId, parentFrameId }) => {
97
+ const f = this.#frame(frameId, s);
98
+ f.parentId = parentFrameId;
99
+ });
100
+ s.on('Page.frameNavigated', ({ frame }) => {
101
+ if (foreignFrame(frame.url)) {
102
+ this.foreign.add(frame.id);
103
+ return;
104
+ }
105
+ const f = this.#frame(frame.id, s);
106
+ f.url = frame.url;
107
+ if (!frame.parentId) {
108
+ if (isRoot) {
109
+ this.#setMain(frame.id);
110
+ this.events.push(`navigated ${frame.url}`);
111
+ }
112
+ }
113
+ else
114
+ f.parentId = frame.parentId;
115
+ });
116
+ s.on('Page.frameDetached', ({ frameId, reason }) => {
117
+ if (reason === 'swap')
118
+ return; // moved to an OOPIF session
119
+ const f = this.frames.get(frameId);
120
+ if (f && f.session === s) {
121
+ this.frames.delete(frameId);
122
+ this.prefixes.delete(f.prefix);
123
+ }
124
+ });
125
+ s.on('Page.javascriptDialogOpening', (p) => this.#onDialog(s, p));
126
+ if (isRoot)
127
+ s.on('Page.domContentEventFired', () => {
128
+ this.loads++;
129
+ });
130
+ s.on('Runtime.consoleAPICalled', (p) => this.#pushConsole({
131
+ level: p.type === 'warning' ? 'warn' : p.type,
132
+ text: p.args
133
+ .map((a) => a.value ?? a.unserializableValue ?? a.description ?? a.type)
134
+ .join(' ')
135
+ .slice(0, 2000),
136
+ url: p.stackTrace?.callFrames?.[0]?.url,
137
+ line: p.stackTrace?.callFrames?.[0]?.lineNumber,
138
+ }));
139
+ s.on('Runtime.exceptionThrown', ({ exceptionDetails: d }) => this.#pushConsole({
140
+ level: 'error',
141
+ text: (d.exception?.description || d.text || 'exception').slice(0, 2000),
142
+ url: d.url,
143
+ line: d.lineNumber,
144
+ }));
145
+ s.on('Log.entryAdded', ({ entry }) => this.#pushConsole({ level: entry.level === 'warning' ? 'warn' : entry.level, text: entry.text, url: entry.url, src: entry.source }));
146
+ s.on('Network.requestWillBeSent', (p) => {
147
+ if (p.type === 'WebSocket' || p.type === 'EventSource')
148
+ return;
149
+ if (p.request.url.startsWith('chrome-extension:'))
150
+ return; // other extensions' scripts in the page: not the site's traffic
151
+ this.network.set(p.requestId, {
152
+ id: p.requestId,
153
+ method: p.request.method,
154
+ url: p.request.url,
155
+ type: p.type,
156
+ ts: p.timestamp,
157
+ wall: p.wallTime,
158
+ postData: p.request.postData?.slice(0, 2000),
159
+ session: s,
160
+ status: null,
161
+ reqHeaders: p.request.headers,
162
+ });
163
+ this.inflight.add(p.requestId);
164
+ if (this.network.size > RING)
165
+ this.network.delete(this.network.keys().next().value);
166
+ });
167
+ s.on('Network.responseReceived', ({ requestId, response }) => {
168
+ const e = this.network.get(requestId);
169
+ if (e)
170
+ Object.assign(e, {
171
+ status: response.status,
172
+ statusText: response.statusText,
173
+ mime: response.mimeType,
174
+ timing: response.timing,
175
+ resHeaders: { ...response.headers, ...e.resHeaders },
176
+ protocol: response.protocol,
177
+ remote: response.remoteIPAddress,
178
+ });
179
+ });
180
+ // Raw headers as sent and received, including Cookie and Set-Cookie (the plain events leave them out).
181
+ s.on('Network.requestWillBeSentExtraInfo', ({ requestId, headers }) => {
182
+ const e = this.network.get(requestId);
183
+ if (e)
184
+ e.reqHeaders = { ...e.reqHeaders, ...headers };
185
+ });
186
+ s.on('Network.responseReceivedExtraInfo', ({ requestId, headers }) => {
187
+ const e = this.network.get(requestId);
188
+ if (e)
189
+ e.resHeaders = { ...e.resHeaders, ...headers };
190
+ });
191
+ s.on('Network.loadingFinished', ({ requestId, encodedDataLength, timestamp }) => {
192
+ this.inflight.delete(requestId);
193
+ const e = this.network.get(requestId);
194
+ if (e) {
195
+ e.size = encodedDataLength;
196
+ e.end = timestamp;
197
+ }
198
+ });
199
+ s.on('Network.loadingFailed', ({ requestId, errorText, canceled, timestamp }) => {
200
+ this.inflight.delete(requestId);
201
+ const e = this.network.get(requestId);
202
+ if (e) {
203
+ e.failed = canceled ? 'canceled' : errorText;
204
+ e.end = timestamp;
205
+ }
206
+ });
207
+ // Child targets: cross-origin iframes (OOPIF) and workers.
208
+ s.on('Target.attachedToTarget', async ({ sessionId, targetInfo, waitingForDebugger }) => {
209
+ const child = s.conn.session(sessionId);
210
+ if (foreignFrame(targetInfo.url)) {
211
+ if (waitingForDebugger)
212
+ child.send('Runtime.runIfWaitingForDebugger').catch(() => { });
213
+ s.send('Target.detachFromTarget', { sessionId }).catch(() => { });
214
+ return;
215
+ }
216
+ if (targetInfo.type === 'iframe') {
217
+ this.sessions.set(sessionId, child);
218
+ try {
219
+ await this.#setupSession(child, false);
220
+ }
221
+ catch { }
222
+ }
223
+ if (waitingForDebugger)
224
+ child.send('Runtime.runIfWaitingForDebugger').catch(() => { });
225
+ });
226
+ s.on('Target.detachedFromTarget', ({ sessionId }) => {
227
+ this.sessions.delete(sessionId);
228
+ for (const [id, f] of this.frames)
229
+ if (f.session?.id === sessionId) {
230
+ this.frames.delete(id);
231
+ this.prefixes.delete(f.prefix);
232
+ }
233
+ });
234
+ if (this.allow || this.deny) {
235
+ // Enforce allowlist and deny policy inside the browser: page-initiated navigations, redirects, frames, popups.
236
+ s.on('Fetch.requestPaused', ({ requestId, request }) => {
237
+ const why = this.allow && !this.allow(request.url) ? 'not in allowlist' : this.deny?.url(request.url) ? 'blocked_by_policy' : null;
238
+ if (!why) {
239
+ s.send('Fetch.continueRequest', { requestId }).catch(() => { });
240
+ return;
241
+ }
242
+ this.events.push(`blocked navigation to ${request.url.slice(0, 100)} (${why})`);
243
+ s.send('Fetch.failRequest', { requestId, errorReason: 'BlockedByClient' }).catch(() => { });
244
+ });
245
+ await s.send('Fetch.enable', { patterns: [{ urlPattern: '*', resourceType: 'Document', requestStage: 'Request' }] });
246
+ }
247
+ await Promise.all([
248
+ s.send('Page.enable'),
249
+ s.send('Runtime.enable'),
250
+ s.send('Network.enable', { maxPostDataSize: 2000 }),
251
+ isRoot ? s.send('Performance.enable').catch(() => { }) : null, // perf counters (layouts, script time) count from here
252
+ s.send('Log.enable').catch(() => { }),
253
+ // page keeps "focused" behaviour when the window is in the background (real-profile modes)
254
+ isRoot ? s.send('Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => { }) : null,
255
+ s.send('Page.addScriptToEvaluateOnNewDocument', { source: this.agentSrc, worldName: WORLD }),
256
+ s.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true }).catch(() => { }),
257
+ ]);
258
+ // Install the agent into the frames that already exist.
259
+ const { frameTree } = await s.send('Page.getFrameTree');
260
+ const visit = async (node, parentId) => {
261
+ if (foreignFrame(node.frame.url)) {
262
+ this.foreign.add(node.frame.id);
263
+ return;
264
+ }
265
+ const f = this.#frame(node.frame.id, s);
266
+ f.url = node.frame.url;
267
+ if (parentId)
268
+ f.parentId = parentId;
269
+ if (!parentId && isRoot)
270
+ this.#setMain(node.frame.id);
271
+ try {
272
+ const { executionContextId } = await s.send('Page.createIsolatedWorld', { frameId: node.frame.id, worldName: WORLD });
273
+ await s.send('Runtime.evaluate', { expression: this.agentSrc, contextId: executionContextId });
274
+ f.ctx = f.ctx || executionContextId;
275
+ }
276
+ catch { }
277
+ for (const c of node.childFrames || [])
278
+ await visit(c, node.frame.id);
279
+ };
280
+ await visit(frameTree, isRoot ? null : undefined);
281
+ if (!isRoot)
282
+ s.send('Runtime.runIfWaitingForDebugger').catch(() => { });
283
+ }
284
+ // The main frame owns the empty ref prefix (e12, not f1e12). Another frame's context can be reported
285
+ // first (e.g. an extension iframe after a re-attach) and take it; give it back here.
286
+ #setMain(fid) {
287
+ this.mainFrameId = fid;
288
+ const f = this.frames.get(fid);
289
+ if (!f || f.prefix === '')
290
+ return;
291
+ const other = this.frames.get(this.prefixes.get('') ?? '');
292
+ if (other) {
293
+ other.prefix = `f${this.nextFrame++}`;
294
+ this.prefixes.set(other.prefix, other.id);
295
+ }
296
+ this.prefixes.delete(f.prefix);
297
+ f.prefix = '';
298
+ this.prefixes.set('', fid);
299
+ }
300
+ #frame(id, session) {
301
+ let f = this.frames.get(id);
302
+ if (!f) {
303
+ const prefix = this.mainFrameId === null && session === this.root && this.frames.size === 0 ? '' : `f${this.nextFrame++}`;
304
+ f = { id, parentId: null, session, ctx: null, prefix, url: '' };
305
+ this.frames.set(id, f);
306
+ this.prefixes.set(prefix, id);
307
+ }
308
+ else if (session !== this.root && f.session === this.root) {
309
+ f.session = session; // swapped into an OOPIF session
310
+ }
311
+ return f;
312
+ }
313
+ #pushConsole(e) {
314
+ e.ts = Date.now();
315
+ this.console.push(e);
316
+ if (this.console.length > RING)
317
+ this.console.shift();
318
+ }
319
+ async #onDialog(s, { type, message, defaultPrompt }) {
320
+ const pol = this.dialogPolicy || { accept: true };
321
+ if (this.dialogPolicy?.once)
322
+ this.dialogPolicy = null;
323
+ const accept = type === 'beforeunload' ? true : pol.accept;
324
+ const promptText = type === 'prompt' ? (pol.text ?? defaultPrompt ?? '') : undefined;
325
+ try {
326
+ await s.send('Page.handleJavaScriptDialog', { accept, promptText });
327
+ }
328
+ catch { }
329
+ this.events.push(`dialog ${type} ${JSON.stringify(message.slice(0, 120))} -> ${accept ? 'accepted' : 'dismissed'}${type === 'prompt' && accept ? ` with ${JSON.stringify(promptText)}` : ''}`);
330
+ }
331
+ // ---- agent calls ----
332
+ frameForRef(ref) {
333
+ const m = /^(f\d+)?e\d+$/.exec(ref || '');
334
+ if (!m)
335
+ throw new Error(`bad ref "${ref}" (expected e12 or f2e7)`);
336
+ const fid = this.prefixes.get(m[1] || '');
337
+ if (!fid)
338
+ throw new Error(`frame of ref ${ref} is gone; take a new snapshot`);
339
+ return fid;
340
+ }
341
+ get mainFrame() {
342
+ return this.mainFrameId === null ? undefined : this.frames.get(this.mainFrameId);
343
+ }
344
+ /** The main frame id; a tab always has one once init() has run. */
345
+ get mainId() {
346
+ if (this.mainFrameId === null)
347
+ throw new Error('page has no main frame yet');
348
+ return this.mainFrameId;
349
+ }
350
+ async #ctx(fid, timeout = 10000) {
351
+ const t0 = Date.now();
352
+ for (;;) {
353
+ const f = this.frames.get(fid);
354
+ if (!f)
355
+ throw new Error('frame is gone');
356
+ if (f.ctx)
357
+ return f;
358
+ if (Date.now() - t0 > timeout) {
359
+ // last resort: create the world now
360
+ const { executionContextId } = await f.session.send('Page.createIsolatedWorld', { frameId: fid, worldName: WORLD });
361
+ await f.session.send('Runtime.evaluate', { expression: this.agentSrc, contextId: executionContextId });
362
+ f.ctx = executionContextId;
363
+ return f;
364
+ }
365
+ await new Promise((r) => {
366
+ this.ctxWaiters.push(r);
367
+ setTimeout(r, 200);
368
+ });
369
+ }
370
+ }
371
+ async call(fid, fn, args = [], { byValue = true } = {}) {
372
+ for (let attempt = 0; attempt < 3; attempt++) {
373
+ const f = await this.#ctx(fid);
374
+ try {
375
+ const res = await f.session.send('Runtime.evaluate', {
376
+ expression: `globalThis.__bbm.${fn}(${args.map((a) => JSON.stringify(a)).join(',')})`,
377
+ contextId: f.ctx,
378
+ returnByValue: byValue,
379
+ awaitPromise: true,
380
+ });
381
+ if (res.exceptionDetails) {
382
+ const msg = res.exceptionDetails.exception?.description?.split('\n')[0] || res.exceptionDetails.text;
383
+ // world without agent (a fresh world after a re-attach reports "reading '<fn>'" without naming __bbm)
384
+ if ((/__bbm/.test(msg) || msg.includes(`reading '${fn}'`)) && attempt < 2) {
385
+ await f.session.send('Runtime.evaluate', { expression: this.agentSrc, contextId: f.ctx });
386
+ continue;
387
+ }
388
+ throw new Error(msg.replace(/^Error: /, ''));
389
+ }
390
+ return byValue ? res.result.value : res.result;
391
+ }
392
+ catch (e) {
393
+ if (/Cannot find context|Execution context was destroyed|Inspected target navigated/.test(e.message) && attempt < 2) {
394
+ f.ctx = null;
395
+ await sleep(100);
396
+ continue;
397
+ }
398
+ throw e;
399
+ }
400
+ }
401
+ throw new Error(`call ${fn}: no page context after 3 attempts`); // not reached: the last attempt returns or throws
402
+ }
403
+ // ---- snapshot ----
404
+ async snapshot(opts = {}) {
405
+ const lines = [];
406
+ let injections = 0;
407
+ const visit = async (fid, depth, ref) => {
408
+ const f = this.frames.get(fid);
409
+ let snap;
410
+ try {
411
+ snap = await this.call(fid, 'snapshot', [{ prefix: f.prefix, mode: opts.mode, viewport: opts.viewport, ref }]);
412
+ }
413
+ catch (e) {
414
+ lines.push(' '.repeat(depth) + `(frame unavailable: ${e.message})`);
415
+ return null;
416
+ }
417
+ if (snap.error)
418
+ throw new Error(snap.error);
419
+ injections += snap.injections;
420
+ const childAt = new Map();
421
+ for (const fr of snap.frames)
422
+ childAt.set(fr.at, fr);
423
+ for (let i = 0; i < snap.lines.length; i++) {
424
+ const l = snap.lines[i];
425
+ lines.push(' '.repeat(depth + l.d) + l.t);
426
+ const fr = childAt.get(i);
427
+ if (fr && (opts.frames ?? true)) {
428
+ const cfid = await this.#childFrameId(fid, fr.ref).catch(() => null);
429
+ if (cfid)
430
+ await visit(cfid, depth + fr.depth + 1);
431
+ else
432
+ lines.push(' '.repeat(depth + fr.depth + 1) + '(frame not loaded)');
433
+ }
434
+ }
435
+ return snap;
436
+ };
437
+ let ref = opts.ref, modal = null;
438
+ const scope = opts.scope || 'auto';
439
+ if (!ref && scope !== 'auto' && scope !== 'page')
440
+ ref = scope;
441
+ if (!ref && scope === 'auto') {
442
+ modal = await this.call(this.mainId, 'modal', [this.mainFrame?.prefix || '']).catch(() => null);
443
+ if (modal)
444
+ ref = modal.ref;
445
+ }
446
+ let fid = this.mainId;
447
+ if (ref)
448
+ fid = this.frameForRef(ref);
449
+ const top = await visit(fid, 0, ref);
450
+ return { lines, url: top?.url, title: top?.title, injections, scroll: top?.scroll, modal };
451
+ }
452
+ async #childFrameId(fid, ref) {
453
+ const f = this.frames.get(fid);
454
+ const obj = await this.call(fid, 'el', [ref], { byValue: false });
455
+ const { node } = await f.session.send('DOM.describeNode', { objectId: obj.objectId });
456
+ f.session.send('Runtime.releaseObject', { objectId: obj.objectId }).catch(() => { });
457
+ const id = node.frameId || node.contentDocument?.frameId;
458
+ return id && this.frames.has(id) ? id : null;
459
+ }
460
+ // Offset of a frame's viewport inside the top-level viewport.
461
+ async frameOffset(fid) {
462
+ const f = this.frames.get(fid);
463
+ if (!f.parentId || fid === this.mainFrameId)
464
+ return { x: 0, y: 0 };
465
+ const parent = this.frames.get(f.parentId);
466
+ if (!parent)
467
+ return { x: 0, y: 0 };
468
+ const s = parent.session;
469
+ const { backendNodeId } = await s.send('DOM.getFrameOwner', { frameId: fid });
470
+ const { model } = await s.send('DOM.getBoxModel', { backendNodeId });
471
+ // In-process frames: quads are relative to the session's root frame viewport.
472
+ let rootFid = f.parentId;
473
+ for (let r = this.frames.get(rootFid); r?.parentId && this.frames.get(r.parentId)?.session === s; r = this.frames.get(rootFid))
474
+ rootFid = r.parentId;
475
+ const base = s === this.root ? { x: 0, y: 0 } : await this.frameOffset(rootFid);
476
+ return { x: base.x + model.content[0], y: base.y + model.content[1] };
477
+ }
478
+ async #scrollOwners(fid) {
479
+ let f = this.frames.get(fid);
480
+ while (f && f.parentId && f.id !== this.mainFrameId) {
481
+ const parent = this.frames.get(f.parentId);
482
+ if (!parent)
483
+ break;
484
+ try {
485
+ const { backendNodeId } = await parent.session.send('DOM.getFrameOwner', { frameId: f.id });
486
+ await parent.session.send('DOM.scrollIntoViewIfNeeded', { backendNodeId });
487
+ }
488
+ catch { }
489
+ f = parent;
490
+ }
491
+ }
492
+ async point(ref, opts = {}) {
493
+ const fid = this.frameForRef(ref);
494
+ await this.#scrollOwners(fid);
495
+ const p = await this.call(fid, 'point', [ref, opts]);
496
+ if (p.error)
497
+ throw new Error(p.error);
498
+ const off = await this.frameOffset(fid);
499
+ return { ...p, x: p.x + off.x, y: p.y + off.y, fid };
500
+ }
501
+ // ---- input ----
502
+ async mouseClick(x, y, { button = 'left', count = 1, modifiers = 0 } = {}) {
503
+ const s = this.root;
504
+ await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, modifiers });
505
+ for (let i = 1; i <= count; i++) {
506
+ await s.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount: i, modifiers });
507
+ await s.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount: i, modifiers });
508
+ }
509
+ }
510
+ async click(ref, opts = {}) {
511
+ const p = await this.point(ref);
512
+ await this.mouseClick(p.x, p.y, opts);
513
+ return p;
514
+ }
515
+ async hover(ref) {
516
+ const p = await this.point(ref);
517
+ await this.root.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: p.x, y: p.y });
518
+ return p;
519
+ }
520
+ press(combo) {
521
+ return pressKey(this.root, combo);
522
+ }
523
+ async type(ref, text, { clear = true, submit = false, noFocus = false } = {}) {
524
+ const fid = this.frameForRef(ref);
525
+ const info = await this.call(fid, 'info', [ref]);
526
+ // noFocus: fill without focusing (a password manager that re-injects its menu on every focus)
527
+ const direct = noFocus || info.tag === 'select' || ['date', 'time', 'datetime-local', 'month', 'week', 'color', 'range'].includes(info.type);
528
+ const notes = [];
529
+ if (direct) {
530
+ const res = await this.setValue(ref, text);
531
+ notes.push(`set value ${JSON.stringify(res.value)}`);
532
+ }
533
+ else {
534
+ try {
535
+ const p = await this.click(ref);
536
+ if (p.covered)
537
+ notes.push(`covered by ${p.covered}`);
538
+ }
539
+ catch (e) {
540
+ notes.push(e.message);
541
+ }
542
+ await this.call(fid, 'focus', [ref, { clear }]);
543
+ await this.root.send('Input.insertText', { text });
544
+ }
545
+ if (submit)
546
+ await this.press('Enter');
547
+ return notes;
548
+ }
549
+ setValue(ref, value) {
550
+ return this.call(this.frameForRef(ref), 'setValue', [ref, value]);
551
+ }
552
+ // select: native <select> -> set value; custom combobox -> behave like a user and verify the commit.
553
+ async select(ref, value) {
554
+ const fid = this.frameForRef(ref);
555
+ const info = await this.call(fid, 'info', [ref]);
556
+ if (info.tag === 'select' || Array.isArray(value))
557
+ return `= ${JSON.stringify((await this.setValue(ref, value)).value)}`;
558
+ const want = String(value).toLowerCase().trim();
559
+ const f = this.frames.get(fid);
560
+ const st0 = await this.call(fid, 'comboState', [ref]);
561
+ if (st0.editable)
562
+ await this.type(ref, String(value), { clear: true });
563
+ else
564
+ await this.click(ref);
565
+ await this.settle({ max: 1500 });
566
+ const committed = async () => {
567
+ const st = await this.call(fid, 'comboState', [ref]);
568
+ return !st.expanded && st.value.toLowerCase().includes(want);
569
+ };
570
+ const opt = await this.call(fid, 'optionRef', [String(value), f.prefix]);
571
+ if (opt) {
572
+ await this.click(opt);
573
+ await this.settle({ max: 1500 });
574
+ if (await committed())
575
+ return `clicked option ${opt}`;
576
+ }
577
+ // keyboard fallback (some widgets ignore mouse on options)
578
+ if (st0.editable || opt) {
579
+ if (!st0.editable)
580
+ await this.call(fid, 'focus', [ref]);
581
+ for (let i = 1; i <= 60; i++) {
582
+ await this.press('ArrowDown');
583
+ const st = await this.call(fid, 'comboState', [ref]);
584
+ if (st.active.toLowerCase() === want || (st.active && st.active.toLowerCase().includes(want))) {
585
+ await this.press('Enter');
586
+ await this.settle({ max: 1500 });
587
+ if (await committed())
588
+ return `keyboard ArrowDown×${i} + Enter`;
589
+ break;
590
+ }
591
+ if (!st.active && i > 3)
592
+ break;
593
+ }
594
+ }
595
+ const st = await this.call(fid, 'comboState', [ref]);
596
+ throw new Error(`could not commit ${JSON.stringify(String(value))} (value now ${JSON.stringify(st.value)}${st.expanded ? ', list still open' : ''}); try snapshot + click option or keys`);
597
+ }
598
+ async upload(ref, files) {
599
+ const fid = this.frameForRef(ref);
600
+ const f = this.frames.get(fid);
601
+ const info = await this.call(fid, 'info', [ref]);
602
+ if (info.tag === 'input' && info.type === 'file') {
603
+ const obj = await this.call(fid, 'el', [ref], { byValue: false });
604
+ await f.session.send('DOM.setFileInputFiles', { files, objectId: obj.objectId });
605
+ return 'files set on input';
606
+ }
607
+ // drop zone: synthesize a native file drop
608
+ const p = await this.point(ref);
609
+ const data = { items: [], files, dragOperationsMask: 1 };
610
+ for (const type of ['dragEnter', 'dragOver', 'drop']) {
611
+ await this.root.send('Input.dispatchDragEvent', { type, x: p.x, y: p.y, data });
612
+ }
613
+ return 'files dropped';
614
+ }
615
+ async drag(fromRef, toRef, { position } = {}) {
616
+ // Scroll both into view first; measure without scrolling so nothing moves while the button is down.
617
+ await this.point(toRef, { block: 'nearest' });
618
+ await this.point(fromRef, { block: 'nearest' });
619
+ const a = await this.point(fromRef, { noScroll: true });
620
+ const b = await this.point(toRef, { position, noScroll: true });
621
+ const s = this.root;
622
+ let intercepted = null;
623
+ const off = s.on('Input.dragIntercepted', ({ data }) => {
624
+ intercepted = data;
625
+ });
626
+ await s.send('Input.setInterceptDrags', { enabled: true });
627
+ try {
628
+ await s.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: a.x, y: a.y });
629
+ await s.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: a.x, y: a.y, button: 'left', clickCount: 1 });
630
+ for (let i = 1; i <= 5; i++) {
631
+ await s.send('Input.dispatchMouseEvent', {
632
+ type: 'mouseMoved',
633
+ x: a.x + ((b.x - a.x) * i) / 5,
634
+ y: a.y + ((b.y - a.y) * i) / 5,
635
+ button: 'left',
636
+ buttons: 1,
637
+ });
638
+ }
639
+ await sleep(30);
640
+ if (intercepted) {
641
+ for (const type of ['dragEnter', 'dragOver', 'drop'])
642
+ await s.send('Input.dispatchDragEvent', { type, x: b.x, y: b.y, data: intercepted });
643
+ }
644
+ await s.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: b.x, y: b.y, button: 'left', clickCount: 1 });
645
+ return intercepted ? 'html5 drag' : 'mouse drag';
646
+ }
647
+ finally {
648
+ off();
649
+ await s.send('Input.setInterceptDrags', { enabled: false }).catch(() => { });
650
+ }
651
+ }
652
+ // ---- waiting ----
653
+ async settle({ max = 3000 } = {}) {
654
+ const t0 = Date.now();
655
+ // 1. a navigation may be in flight: wait for the agent world of the new document
656
+ await sleep(30);
657
+ // 2. network quiet for 300ms (ignore long-lived requests older than 2s)
658
+ let quietSince = Date.now();
659
+ while (Date.now() - t0 < max) {
660
+ const now = Date.now();
661
+ let busy = 0;
662
+ for (const id of this.inflight) {
663
+ const e = this.network.get(id);
664
+ if (!e) {
665
+ this.inflight.delete(id);
666
+ continue;
667
+ }
668
+ if (e.started === undefined)
669
+ e.started = now;
670
+ if (now - e.started < 2000)
671
+ busy++;
672
+ }
673
+ if (busy)
674
+ quietSince = now;
675
+ if (now - quietSince >= 150 && this.mainFrame?.ctx)
676
+ break;
677
+ await sleep(40);
678
+ }
679
+ if (process.env.BBM_TRACE)
680
+ console.error(`[settle] network ${Date.now() - t0}ms inflight=${[...this.inflight].map((id) => this.network.get(id)?.url).join(',')}`);
681
+ // 3. DOM quiet
682
+ try {
683
+ await this.call(this.mainId, 'settle', [{ quiet: 100, max: Math.max(1500, max - (Date.now() - t0)) }]);
684
+ }
685
+ catch { }
686
+ return Date.now() - t0;
687
+ }
688
+ async waitFor(opts) {
689
+ const timeout = Math.min(opts.timeout ?? 10000, 60000);
690
+ const t0 = Date.now();
691
+ if (opts.url) {
692
+ const re = new RegExp(opts.url.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*'));
693
+ while (Date.now() - t0 < timeout) {
694
+ if (re.test(this.mainFrame?.url || ''))
695
+ break;
696
+ await sleep(100);
697
+ }
698
+ if (!re.test(this.mainFrame?.url || ''))
699
+ return { ok: false, ms: Date.now() - t0 };
700
+ }
701
+ if (opts.text || opts.gone || opts.ref) {
702
+ while (Date.now() - t0 < timeout) {
703
+ try {
704
+ const fid = opts.ref ? this.frameForRef(opts.ref) : this.mainId;
705
+ const r = await this.call(fid, 'waitFor', [{ text: opts.text, gone: opts.gone, ref: opts.ref, timeout: timeout - (Date.now() - t0) }]);
706
+ if (r.ok)
707
+ return { ok: true, ms: Date.now() - t0 };
708
+ // text may live in a child frame
709
+ if (opts.text && !opts.ref) {
710
+ for (const f of this.frames.values()) {
711
+ if (f.id === this.mainFrameId)
712
+ continue;
713
+ if (await this.call(f.id, 'hasText', [opts.text]).catch(() => false))
714
+ return { ok: true, ms: Date.now() - t0, frame: f.prefix };
715
+ }
716
+ }
717
+ break;
718
+ }
719
+ catch (e) {
720
+ if (/navigat|context|gone/i.test(e.message)) {
721
+ await sleep(150);
722
+ continue;
723
+ }
724
+ throw e;
725
+ }
726
+ }
727
+ return { ok: false, ms: Date.now() - t0 };
728
+ }
729
+ return { ok: true, ms: Date.now() - t0 };
730
+ }
731
+ async navigate(url) {
732
+ const loads = this.loads;
733
+ let newDoc = true;
734
+ if (url === 'back' || url === 'forward') {
735
+ const { currentIndex, entries } = await this.root.send('Page.getNavigationHistory');
736
+ const e = entries[currentIndex + (url === 'back' ? -1 : 1)];
737
+ if (!e)
738
+ throw new Error(`no ${url} history entry`);
739
+ await this.root.send('Page.navigateToHistoryEntry', { entryId: e.id });
740
+ }
741
+ else if (url === 'reload') {
742
+ await this.root.send('Page.reload');
743
+ }
744
+ else {
745
+ const res = await this.root.send('Page.navigate', { url });
746
+ if (res.errorText)
747
+ throw new Error(`navigation failed: ${res.errorText}`);
748
+ newDoc = !!res.loaderId;
749
+ }
750
+ const tl = Date.now();
751
+ if (newDoc)
752
+ await this.waitLoad(loads);
753
+ if (process.env.BBM_TRACE)
754
+ console.error(`[navigate] load ${Date.now() - tl}ms loads=${this.loads}`);
755
+ await this.settle({ max: 3000 });
756
+ }
757
+ // Wait until DOMContentLoaded fired after the given load counter.
758
+ async waitLoad(since, timeout = 15000) {
759
+ const t0 = Date.now();
760
+ while (this.loads <= since && Date.now() - t0 < timeout)
761
+ await sleep(30);
762
+ }
763
+ // area = what to capture in CSS px: a ref, clip {x, y, width, height} (viewport coordinates, as getBoundingClientRect
764
+ // gives), the full page, or the viewport. padding grows ref/clip areas. scale 'fit' keeps the image at most
765
+ // maxWidth device px wide; 'full' keeps every device pixel. Returned scale = image px per CSS px.
766
+ async screenshot({ ref, clip: box, padding = 0, fullPage = false, marks = false, quality = 60, scale: mode = 'fit', maxWidth = 1280 } = {}) {
767
+ if (marks)
768
+ for (const f of this.frames.values())
769
+ await this.call(f.id, 'marks', [true]).catch(() => { });
770
+ try {
771
+ const params = { format: 'jpeg', quality };
772
+ const { cssLayoutViewport: vp, cssContentSize } = await this.root.send('Page.getLayoutMetrics');
773
+ let area; // viewport coordinates, except fullPage (page coordinates)
774
+ if (ref) {
775
+ const fid = this.frameForRef(ref);
776
+ await this.call(fid, 'point', [ref]);
777
+ const r = await this.call(fid, 'rect', [ref]);
778
+ const off = await this.frameOffset(fid);
779
+ area = { x: r.x + off.x, y: r.y + off.y, width: r.w, height: r.h };
780
+ }
781
+ else if (box) {
782
+ area = { x: box.x, y: box.y, width: box.width, height: box.height };
783
+ }
784
+ let clip;
785
+ if (area) {
786
+ const x = Math.max(0, area.x - padding), y = Math.max(0, area.y - padding);
787
+ area = { x, y, width: Math.max(1, area.x + area.width + padding - x), height: Math.max(1, area.y + area.height + padding - y) };
788
+ clip = { ...area, x: area.x + vp.pageX, y: area.y + vp.pageY };
789
+ if (box)
790
+ params.captureBeyondViewport = true; // a clip may reach below the fold
791
+ }
792
+ else if (fullPage) {
793
+ clip = area = { x: 0, y: 0, width: cssContentSize.width, height: Math.min(cssContentSize.height, 8000) };
794
+ params.captureBeyondViewport = true;
795
+ }
796
+ else {
797
+ clip = { x: vp.pageX, y: vp.pageY, width: vp.clientWidth, height: vp.clientHeight };
798
+ area = { x: 0, y: 0, width: vp.clientWidth, height: vp.clientHeight };
799
+ }
800
+ // maxWidth is in device pixels: on a 2x display a 1000-CSS-px viewport is 2000 px.
801
+ const dpr = (await this.evaluate('devicePixelRatio')
802
+ .then(Number)
803
+ .catch(() => 1)) || 1;
804
+ const s = mode === 'full' ? 1 : Math.min(1, maxWidth / (clip.width * dpr));
805
+ params.clip = { ...clip, scale: s };
806
+ const { data } = await this.root.send('Page.captureScreenshot', params);
807
+ const scale = s * dpr;
808
+ return {
809
+ data,
810
+ scale,
811
+ width: Math.round(clip.width * scale),
812
+ height: Math.round(clip.height * scale),
813
+ area: { x: Math.round(area.x), y: Math.round(area.y), width: Math.round(area.width), height: Math.round(area.height), page: fullPage && !ref && !box },
814
+ viewport: { w: vp.clientWidth, h: vp.clientHeight },
815
+ };
816
+ }
817
+ finally {
818
+ if (marks)
819
+ for (const f of this.frames.values())
820
+ await this.call(f.id, 'marks', [false]).catch(() => { });
821
+ }
822
+ }
823
+ // Evaluate JS in the page's main world (page globals visible). With ref: function receives the element.
824
+ // A returned Promise is awaited (up to timeout ms); values JSON cannot carry are made readable (see NORM).
825
+ async evaluate(expression, { ref, frame, timeout = 30000 } = {}) {
826
+ let timer;
827
+ const late = new Promise((_, reject) => {
828
+ timer = setTimeout(() => reject(new Error(`eval timed out after ${timeout}ms (the page may still run it)`)), timeout);
829
+ });
830
+ try {
831
+ return await Promise.race([this.#evaluate(expression, { ref, frame }), late]);
832
+ }
833
+ finally {
834
+ clearTimeout(timer);
835
+ }
836
+ }
837
+ async #evaluate(expression, { ref, frame }) {
838
+ const fid = ref ? this.frameForRef(ref) : ((frame ? this.prefixes.get(frame) : null) ?? this.mainFrameId);
839
+ const f = fid == null ? undefined : this.frames.get(fid);
840
+ if (!f)
841
+ throw new Error(`unknown frame ${frame}`);
842
+ const isFn = /^\s*(async\s+)?(\([^)]*\)\s*=>|function\b|[a-zA-Z_$][\w$]*\s*=>)/.test(expression);
843
+ const byValue = (objectId, body) => f.session.send('Runtime.callFunctionOn', {
844
+ objectId,
845
+ functionDeclaration: `async function(){ ${NORM} return norm(${body}); }`,
846
+ returnByValue: true,
847
+ awaitPromise: true,
848
+ userGesture: true,
849
+ });
850
+ if (ref) {
851
+ const obj = await this.call(fid, 'el', [ref], { byValue: false });
852
+ const fn = isFn ? expression : `(el) => (${expression})`;
853
+ return fmtEval(await byValue(obj.objectId, `await (${fn})(this)`));
854
+ }
855
+ // Evaluate as written; if the value is a function, call it (lets callers pass "() => ..."). Objects (a
856
+ // Promise from fetch().then(), an async IIFE) are awaited and read back by value.
857
+ let res = await f.session.send('Runtime.evaluate', {
858
+ expression,
859
+ contextId: f.mainCtx || undefined,
860
+ returnByValue: false,
861
+ awaitPromise: true,
862
+ replMode: true,
863
+ userGesture: true,
864
+ });
865
+ if (!res.exceptionDetails && res.result.type === 'function')
866
+ res = await byValue(res.result.objectId, 'await this()');
867
+ else if (!res.exceptionDetails && res.result.objectId)
868
+ res = await byValue(res.result.objectId, 'await this').catch(() => res);
869
+ return fmtEval(res);
870
+ }
871
+ }
872
+ // In-page helper: Headers/Map/URLSearchParams -> object, Set -> array, DOM node -> HTML excerpt,
873
+ // Response -> a hint to read its body, other class instances that JSON shows as {} -> [ClassName].
874
+ // A cross-origin Window throws on any property read: it shows as [Window].
875
+ const NORM = `const norm = (v) => { try { return norm1(v); } catch { return '[Window]'; } };
876
+ const norm1 = (v) => {
877
+ if (!v || typeof v !== 'object' || Array.isArray(v) || Object.getPrototypeOf(v) === Object.prototype) return v;
878
+ if (typeof Node !== 'undefined' && v instanceof Node) return (v.outerHTML || v.textContent || '').slice(0, 2000);
879
+ if (typeof Response !== 'undefined' && v instanceof Response) return '[Response ' + v.status + ' ' + v.url + '] read the body in the same eval: (await r.json()) or (await r.text())';
880
+ let json; try { json = JSON.stringify(v); } catch { return '[' + (v.constructor?.name || 'object') + ']'; }
881
+ if (json !== '{}') return v;
882
+ if (typeof v[Symbol.iterator] === 'function') { const a = [...v]; return a.every((e) => Array.isArray(e) && e.length === 2) ? Object.fromEntries(a) : a; }
883
+ return '[' + (v.constructor?.name || 'object') + ']';
884
+ };`;
885
+ function fmtEval(res) {
886
+ if (res.exceptionDetails)
887
+ throw new Error((res.exceptionDetails.exception?.description || res.exceptionDetails.text).split('\n').slice(0, 3).join('\n'));
888
+ const r = res.result;
889
+ if (r.type === 'undefined')
890
+ return 'undefined';
891
+ if ('value' in r)
892
+ return typeof r.value === 'string' ? r.value : JSON.stringify(r.value, null, 1);
893
+ return r.description || r.type;
894
+ }