@evan_zhong/browser-attach 0.21.0 → 0.22.1

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.
@@ -1,1018 +1,430 @@
1
- // Browser operations live here; the WebSocket transport never exposes raw CDP.
2
- import { createVisuals } from './visuals.js';
3
- import { createClickVisuals } from './click-visuals.js';
4
- import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
5
- import { createNavigation } from './navigation.js';
6
- import { createClick, clickOptions, framePoint } from './click.js';
7
- import { createInput, inputMode, selectOptions, typeOptions } from './input.js';
8
- import { createEvaluations } from './evaluate.js';
9
- import { targetOptions, resolveSelector, resolveFrame } from './locator.js';
10
- import { readText } from './text.js';
11
- import { createDiagnostics } from './diagnostics.js';
12
- import { createSessions } from './sessions.js';
13
- import { NAVIGATION_OBSERVATION_TIMEOUT_MS } from './limits.js';
14
- import { deadline } from './actionability.js';
15
- import { scrollOptions, scrollElement, findScrollText, scrollState } from './scroll.js';
16
- import { validateSteps } from './batch.js';
17
- import { waitFor } from './wait.js';
18
- import { boundedRead, installOutcomeObserver } from './outcomes.js';
19
- import { createDrag, dragOptions } from './drag.js';
20
- import { createFetches, capturePage } from './fetch.js';
21
- import { errorData } from './errors.js';
22
- import { pageUrl } from './page-url.js';
23
-
24
- export function createController(api, notify = () => {}) {
25
- const claims = new Map();
26
- const temporaryTabs = new Map();
27
- const fetches = createFetches(fetchPage);
28
- const snapshots = new Map();
29
- const retiredRefs = new Map();
30
- const documents = new Map();
31
- const pageContexts = new Map();
32
- const frameStates = new Map();
33
- const queues = new Map();
34
- const dialogs = new Map();
35
- const dialogPolicies = new Map();
36
- const fileChoosers = new Map();
37
- const dialogWaiters = new Map();
38
- const visuals = createVisuals(api);
39
- const clickVisuals = createClickVisuals(api);
40
- const navigation = createNavigation(cdp, controlUrl);
41
- const diagnostics = createDiagnostics(cdp);
42
- const sessions = createSessions(cdp, invalidateFrame, (tabId, sessionId) => {
43
- diagnostics.detach(tabId, sessionId);
44
- for (const key of pageContexts.keys()) { const [tab, session] = JSON.parse(key); if (tab === tabId && session === sessionId) pageContexts.delete(key); }
45
- }, tabId => diagnostics.networkOptions(tabId));
46
- const mouse = createClick(cdp, value, viewport, position, sessions, tabId => api.tabs.get(tabId), windowId => api.windows.get(windowId));
47
- const gestures = createDrag(cdp, value, mouse, viewport);
48
- const input = createInput(cdp, value, sessions);
49
- const evaluations = createEvaluations(cdp);
50
-
51
- function queued(tabId, operation) {
52
- const result = (queues.get(tabId) || Promise.resolve()).catch(() => {}).then(operation);
53
- queues.set(tabId, result);
54
- const done = () => { if (queues.get(tabId) === result) queues.delete(tabId); };
55
- result.then(done, done);
56
- return result;
57
- }
58
-
59
- function httpUrl(url) {
60
- const parsed = new URL(url);
61
- if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
62
- throw new Error('Only http(s) pages without URL credentials can be controlled');
63
- }
64
- return parsed.href;
65
- }
66
-
67
- async function controlUrl(url) {
68
- const normalized = pageUrl(url);
69
- if (normalized.startsWith('file:') && !await api.extension.isAllowedFileSchemeAccess()) {
70
- throw new Error('Local file access is disabled. Enable "Allow access to file URLs" in the BrowserAttach extension settings in Chrome/Edge.');
71
- }
72
- return normalized;
73
- }
74
-
75
- async function cdp(target, method, params = {}) {
76
- if (typeof target === 'number') target = { tabId: target };
77
- const { tabId } = target;
78
- if (!method.startsWith('Input.') && method !== 'Runtime.callFunctionOn' && method !== 'Runtime.evaluate' && method !== 'Page.navigate' && method !== 'DOM.setFileInputFiles') {
79
- return await api.debugger.sendCommand(target, method, params);
80
- }
81
- // Input handlers may open a modal and prevent the CDP response from returning.
82
- // Free the tab queue so the next tool can observe and resolve that modal.
83
- let rejectDialog;
84
- const dialog = new Promise((resolve, reject) => { rejectDialog = reject; });
85
- const waiters = dialogWaiters.get(tabId) || new Set();
86
- dialogWaiters.set(tabId, waiters);
87
- waiters.add(rejectDialog);
88
- try {
89
- return await Promise.race([api.debugger.sendCommand(target, method, params), dialog]);
90
- } finally {
91
- waiters.delete(rejectDialog);
92
- if (waiters.size === 0) dialogWaiters.delete(tabId);
93
- }
94
- }
95
-
96
- function value(result) {
97
- if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
98
- return result.result?.value;
99
- }
100
-
101
- function retireRef(tabId, ref, cause, frameId) {
102
- let history = retiredRefs.get(tabId);
103
- if (!history) retiredRefs.set(tabId, history = new Map());
104
- history.set(ref, { cause, frame_id: frameId });
105
- if (history.size > 1000) history.delete(history.keys().next().value);
106
- }
107
-
108
- function invalidateSnapshot(tabId, cause) {
109
- const state = snapshots.get(tabId);
110
- if (state) for (const [ref, node] of state.refs) retireRef(tabId, ref, cause, node.frameId);
111
- snapshots.delete(tabId);
112
- }
113
-
114
- async function getObject(tabId, ref, budget) {
115
- const refError = (cause, frameId, message) => {
116
- const dispatched = budget.has_dispatched();
117
- return Object.assign(new Error(`${message}. Take a new browser_snapshot. ${dispatched ? 'Input was already dispatched; outcome is unknown. Do not replay automatically.' : 'No input was dispatched.'}`), {
118
- input_dispatched: dispatched, diagnostics: { reason: 'stale_ref', cause, ref, ...(frameId ? { frame_id: frameId } : {}) },
119
- });
120
- };
121
- const node = snapshots.get(tabId)?.refs.get(ref);
122
- if (!node) {
123
- const failure = retiredRefs.get(tabId)?.get(ref);
124
- throw refError(failure?.cause || 'unknown_ref', failure?.frame_id, 'Element ref is stale or unknown');
125
- }
126
- const target = sessions.target(tabId, node.frameId);
127
- if (target.sessionId !== node.sessionId) throw refError('frame_session_changed', node.frameId, 'Frame session changed');
128
- let resolved, nodes;
129
- try {
130
- resolved = await budget.run(() => cdp(target, 'DOM.resolveNode', { backendNodeId: node.backendNodeId, objectGroup: 'browser-attach' }));
131
- if (!resolved.object?.objectId) throw refError('element_detached', node.frameId, 'Element no longer exists');
132
- ({ nodes } = await budget.run(() => cdp(target, 'Accessibility.getPartialAXTree', { backendNodeId: node.backendNodeId, fetchRelatives: false })));
133
- }
134
- catch (error) {
135
- const retired = retiredRefs.get(tabId)?.get(ref);
136
- if (retired) throw refError(retired.cause, retired.frame_id, 'Element ref became stale during resolution');
137
- if (/no node|could not find node|node.*(detached|not found)/i.test(error.message)) throw refError('element_detached', node.frameId, 'Element is detached or no longer exists');
138
- throw error;
139
- }
140
- if (snapshots.get(tabId)?.refs.get(ref) !== node) {
141
- const retired = retiredRefs.get(tabId)?.get(ref);
142
- throw refError(retired?.cause || 'unknown_ref', node.frameId, 'Element ref became stale during resolution');
143
- }
144
- const current = nodes?.find(item => item.backendDOMNodeId === node.backendNodeId);
145
- if (!current || !!current.ignored !== !!node.ignored || current.role?.value !== node.role || String(current.name?.value || '') !== node.name) {
146
- throw refError('identity_changed', node.frameId, 'Element ref identity changed (role/name or accessibility)');
147
- }
148
- return { ...node, target, objectId: resolved.object.objectId };
149
- }
150
-
151
- async function rememberElement(tabId, target, objectId, frameId, budget) {
152
- const version = documents.get(tabId), claim = claims.get(tabId), frameVersion = frameStates.get(tabId)?.versions.get(frameId);
153
- const send = (method, params) => budget.run(() => cdp(target, method, params));
154
- const { node } = await send('DOM.describeNode', { objectId, depth: 0 });
155
- if (node.nodeType !== 1) throw new Error('Result must be a DOM element');
156
- const { nodes } = await send('Accessibility.getPartialAXTree', { backendNodeId: node.backendNodeId, fetchRelatives: false });
157
- const ax = nodes?.find(item => item.backendDOMNodeId === node.backendNodeId);
158
- if (!ax) throw new Error('Element accessibility identity is unavailable; observe again');
159
- const connected = value(await send('Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { return this.isConnected; }', returnByValue: true }));
160
- if (!connected) throw new Error('Element is detached; no ref was created');
161
- if (documents.get(tabId) !== version || claims.get(tabId) !== claim || frameStates.get(tabId)?.versions.get(frameId) !== frameVersion) throw new Error('Element document/frame/control changed; no ref was created');
162
- let state = snapshots.get(tabId);
163
- if (!state) {
164
- state = { id: crypto.randomUUID().slice(0, 8), refs: new Map(), entries: [], invalidFrames: new Set(), totalNodes: 0, nextCursor: null, metadata: { omitted_frames: [] } };
165
- snapshots.set(tabId, state);
166
- }
167
- if ((state.extraRefs || 0) >= 500) throw new Error('Element ref budget exhausted; take a new snapshot');
168
- state.extraRefs = (state.extraRefs || 0) + 1;
169
- const ref = `${state.id}:element:${state.extraRefs}`;
170
- state.refs.set(ref, { backendNodeId: node.backendNodeId, frameId, sessionId: target.sessionId, role: ax.role?.value, name: String(ax.name?.value || ''), ignored: !!ax.ignored });
171
- return { ref, frame_id: frameId, role: ax.role?.value, name: String(ax.name?.value || '') };
172
- }
173
-
174
- async function viewport(tabId) {
175
- return value(await cdp(tabId, 'Runtime.evaluate', {
176
- expression: '({width: innerWidth, height: innerHeight, scroll_x: scrollX, scroll_y: scrollY, device_pixel_ratio: devicePixelRatio})', returnByValue: true,
177
- }));
178
- }
179
-
180
- function position(view, x, y) {
181
- if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= view.width || y >= view.height) {
182
- throw new Error(`Position must be inside the viewport (${view.width} x ${view.height} CSS pixels)`);
183
- }
184
- return { x, y };
185
- }
186
-
187
- async function release(tabId) {
188
- if (!claims.has(tabId)) return;
189
- // Forget only after detach; a failed detach must not silently grant another owner.
190
- try { await clickVisuals.remove(tabId, dialogs.has(tabId)); await visuals.remove(tabId); }
191
- finally { await api.debugger.detach({ tabId }); }
192
- claims.delete(tabId);
193
- evaluations.forget(tabId);
194
- snapshots.delete(tabId);
195
- retiredRefs.delete(tabId);
196
- documents.delete(tabId);
197
- for (const key of pageContexts.keys()) if (JSON.parse(key)[0] === tabId) pageContexts.delete(key);
198
- frameStates.delete(tabId);
199
- dialogs.delete(tabId);
200
- dialogPolicies.delete(tabId);
201
- fileChoosers.delete(tabId);
202
- diagnostics.forget(tabId);
203
- sessions.forget(tabId);
204
- notify({ type: 'detached', tab_id: tabId });
205
- }
206
-
207
- async function releaseMatching(predicate) {
208
- const targets = new Map([...claims, ...temporaryTabs].filter(([, claim]) => predicate(claim)));
209
- const outcomes = await Promise.allSettled([...targets].map(([tabId]) => queued(tabId, () => temporaryTabs.has(tabId) ? closeTemporary(tabId) : release(tabId))));
210
- return { released: outcomes.filter(item => item.status === 'fulfilled').length,
211
- errors: outcomes.filter(item => item.status === 'rejected').map(item => String(item.reason)) };
212
- }
213
-
214
- function forget(tabId) {
215
- evaluations.forget(tabId);
216
- navigation.cancel(tabId);
217
- mouse.cancel(tabId);
218
- gestures.cancel(tabId);
219
- input.cancel(tabId);
220
- visuals.forget(tabId);
221
- clickVisuals.forget(tabId);
222
- claims.delete(tabId);
223
- snapshots.delete(tabId);
224
- retiredRefs.delete(tabId);
225
- documents.delete(tabId);
226
- for (const key of pageContexts.keys()) if (JSON.parse(key)[0] === tabId) pageContexts.delete(key);
227
- frameStates.delete(tabId);
228
- dialogs.delete(tabId);
229
- dialogPolicies.delete(tabId);
230
- fileChoosers.delete(tabId);
231
- diagnostics.forget(tabId);
232
- sessions.forget(tabId);
233
- notify({ type: 'detached', tab_id: tabId });
234
- }
235
- api.debugger.onDetach.addListener(source => { if (!source.sessionId) forget(source.tabId); });
236
- api.debugger.onEvent.addListener((source, method, params = {}) => {
237
- sessions.event(source, method, params);
238
- const contextKey = JSON.stringify([source.tabId, source.sessionId]);
239
- if (method === 'Runtime.executionContextCreated' && params.context.auxData?.isDefault) pageContexts.set(JSON.stringify([source.tabId, source.sessionId, params.context.auxData.frameId]), params.context.id);
240
- if (method === 'Runtime.executionContextDestroyed' || method === 'Runtime.executionContextsCleared') {
241
- for (const [key, id] of pageContexts) if (key.startsWith(contextKey.slice(0, -1) + ',') && (method.endsWith('Cleared') || id === params.executionContextId)) pageContexts.delete(key);
242
- }
243
- if (!sessions.has(source)) return;
244
- diagnostics.event(source, method, params);
245
- if (!source.sessionId) navigation.event(source.tabId, method, params);
246
- const policy = dialogPolicies.get(source.tabId);
247
- const autoDialog = method === 'Page.javascriptDialogOpening' && policy && policy.policy !== 'ask' && policy.messages.length < 10;
248
- if (!autoDialog) {
249
- mouse.event(source.tabId, method, params, source.sessionId);
250
- gestures.event(source.tabId, method, params, source.sessionId);
251
- input.event(source.tabId, method, params, source.sessionId);
252
- }
253
- const frames = frameStates.get(source.tabId);
254
- if (method === 'Page.frameAttached') frames?.parents.set(params.frameId, params.parentFrameId);
255
- const mainNavigation = !source.sessionId && method === 'Page.frameNavigated' && params.frame && !params.frame.parentId;
256
- const temporary = temporaryTabs.get(source.tabId);
257
- if (mainNavigation && temporary && params.frame.loaderId) {
258
- const document = JSON.stringify([params.frame.id, params.frame.loaderId]);
259
- if (!temporary.firstDocument && /^https?:/.test(params.frame.url || '') && !params.frame.unreachableUrl) temporary.firstDocument = document;
260
- if (temporary.firstDocument) {
261
- temporary.document = document;
262
- temporary.document_changed ||= document !== temporary.firstDocument;
263
- }
264
- }
265
- if (mainNavigation && frames) {
266
- frames.mainId = params.frame.id;
267
- frames.parents.clear(); frames.versions.clear(); frames.sessions.clear();
268
- }
269
- if (method === 'Page.frameNavigated' && params.frame?.parentId) frames?.parents.set(params.frame.id, params.frame.parentId);
270
- const sameDocumentMain = method === 'Page.navigatedWithinDocument' && params.frameId === frames?.mainId;
271
- if ((!source.sessionId && method === 'DOM.documentUpdated') || mainNavigation || sameDocumentMain) {
272
- evaluations.forget(source.tabId);
273
- documents.set(source.tabId, (documents.get(source.tabId) || 0) + 1);
274
- invalidateSnapshot(source.tabId, sameDocumentMain ? 'same_document_navigation' : mainNavigation ? 'document_navigation' : 'document_updated');
275
- fileChoosers.delete(source.tabId);
276
- } else if (source.sessionId && method === 'DOM.documentUpdated') {
277
- for (const [frame, session] of frameStates.get(source.tabId)?.sessions || []) if (session === source.sessionId) invalidateFrame(source.tabId, frame);
278
- } else if (method === 'Page.frameNavigated' || method === 'Page.navigatedWithinDocument' || method === 'Page.frameDetached') {
279
- invalidateFrame(source.tabId, params.frame?.id || params.frameId);
280
- }
281
- if (method === 'Page.fileChooserOpened' && claims.has(source.tabId)) {
282
- if (params.backendNodeId) fileChoosers.set(source.tabId, {
283
- sessionId: source.sessionId, backendNodeId: params.backendNodeId, mode: params.mode, frame_id: params.frameId,
284
- });
285
- }
286
- if (mainNavigation) clickVisuals.navigated(source.tabId);
287
- if (method === 'Page.javascriptDialogOpening') {
288
- dialogs.set(source.tabId, { sessionId: source.sessionId, type: params.type, message: params.message, default_prompt: params.defaultPrompt, default_button: null });
289
- invalidateSnapshot(source.tabId, 'dialog_opened');
290
- const openedDialog = dialogs.get(source.tabId);
291
- const message = { ...openedDialog, policy: policy?.policy || 'ask', accepted: autoDialog ? policy.policy === 'accept' : null };
292
- for (let item = policy; item; item = item.parent) item.messages.push(message);
293
- if (autoDialog) {
294
- const handling = cdp(source, 'Page.handleJavaScriptDialog', { accept: policy.policy === 'accept', ...(params.type === 'prompt' ? { promptText: params.defaultPrompt || '' } : {}) });
295
- policy.handling = handling;
296
- handling.then(() => { message.handled = true; if (dialogs.get(source.tabId) === openedDialog) dialogs.delete(source.tabId); if (params.type === 'beforeunload' && policy.policy === 'dismiss') navigation.cancel(source.tabId, new Error('Navigation cancelled by dismissed beforeunload')); }, error => { message.handled = false; message.error = error.message; for (const reject of dialogWaiters.get(source.tabId) || []) reject(new Error(`Automatic dialog handling failed: ${error.message}. Outcome is unknown.`)); });
297
- }
298
- if (!autoDialog) for (const reject of dialogWaiters.get(source.tabId) || []) {
299
- reject(new Error('The action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'));
300
- }
301
- }
302
- if (method === 'Page.javascriptDialogClosed') dialogs.delete(source.tabId);
303
- });
304
- api.tabs.onRemoved.addListener(tabId => { temporaryTabs.delete(tabId); forget(tabId); });
305
- api.tabs.onUpdated.addListener((tabId, change) => {
306
- // A subframe reload can also set the TAB status to loading. CDP main-frame
307
- // events identify document changes; a status-only update carries no identity.
308
- if (change.url) {
309
- evaluations.forget(tabId);
310
- documents.set(tabId, (documents.get(tabId) || 0) + 1);
311
- invalidateSnapshot(tabId, 'tab_url_changed'); fileChoosers.delete(tabId);
312
- }
313
- });
314
-
315
- function invalidateFrame(tabId, frameId) {
316
- const frames = frameStates.get(tabId);
317
- if (!frames || !frameId) return;
318
- const affected = new Set([frameId]);
319
- // Descendant documents disappear with their parent, even without individual events.
320
- let size;
321
- do {
322
- size = affected.size;
323
- for (const [id, parent] of frames.parents) if (affected.has(parent)) affected.add(id);
324
- } while (size !== affected.size);
325
- const state = snapshots.get(tabId);
326
- for (const id of affected) {
327
- evaluations.forgetFrame(tabId, id);
328
- frames.versions.set(id, (frames.versions.get(id) || 0) + 1);
329
- if (state) omitFrame(tabId, state, id, 'Frame navigated or detached after observation');
330
- }
331
- if (affected.has(fileChoosers.get(tabId)?.frame_id)) fileChoosers.delete(tabId);
332
- }
333
-
334
- function omitFrame(tabId, state, frameId, reason) {
335
- if (state.invalidFrames.has(frameId)) return;
336
- state.invalidFrames.add(frameId);
337
- for (const [ref, node] of state.refs) if (node.frameId === frameId) {
338
- retireRef(tabId, ref, 'frame_changed', frameId);
339
- state.refs.delete(ref);
340
- }
341
- state.totalNodes -= state.entries.filter(entry => entry.frameId === frameId && entry.node).length;
342
- state.metadata.omitted_frames.push({ frame_id: frameId, reason });
343
- }
344
-
345
- function expireCursor(tabId) {
346
- const state = snapshots.get(tabId);
347
- if (state) state.nextCursor = null;
348
- }
349
-
350
- async function snapshot(tabId, args = {}, budget) {
351
- const send = (...params) => budget ? budget.run(() => cdp(...params)) : cdp(...params);
352
- if (args.cursor != null) {
353
- const state = snapshots.get(tabId);
354
- if (!state) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
355
- return snapshotPage(state, args);
356
- }
357
- const options = snapshotOptions(args);
358
- invalidateSnapshot(tabId, 'snapshot_replaced');
359
- const documentVersion = documents.get(tabId);
360
- const versions = new Map(frameStates.get(tabId)?.versions);
361
- await (budget ? budget.run(() => sessions.settle(tabId)) : sessions.settle(tabId));
362
- for (const target of sessions.targets(tabId)) await send(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' });
363
- const frames = await (budget ? budget.run(() => sessions.frames(tabId, send)) : sessions.frames(tabId, send));
364
- const mainId = frameStates.get(tabId).mainId;
365
- for (const frame of frames) if (frame.parentId) frameStates.get(tabId).parents.set(frame.id, frame.parentId);
366
- const knownFrames = new Set(frames.map(frame => frame.id));
367
- const token = crypto.randomUUID().slice(0, 8);
368
- const refs = new Map(), entries = [], omittedFrames = [];
369
- for (const frame of frames) {
370
- let nodes;
371
- let target;
372
- try {
373
- target = sessions.target(tabId, frame.id);
374
- frameStates.get(tabId).sessions.set(frame.id, target.sessionId);
375
- ({ nodes } = await send(target, 'Accessibility.getFullAXTree', { frameId: frame.id }));
376
- }
377
- catch (error) {
378
- if (frame.id === mainId) throw error;
379
- omittedFrames.push({ frame_id: frame.id, url: frame.url, reason: error.message });
380
- continue;
381
- }
382
- // Report cross-process frames that are missing from Page.getFrameTree.
383
- for (const node of nodes) {
384
- if (!/^iframe$/i.test(node.role?.value || '') || !node.backendDOMNodeId) continue;
385
- const { node: owner } = await send(target, 'DOM.describeNode', { backendNodeId: node.backendDOMNodeId, depth: 0 });
386
- const childId = owner.frameId || owner.contentDocument?.frameId;
387
- if (!childId) continue;
388
- frameStates.get(tabId)?.parents.set(childId, frame.id);
389
- sessions.rememberParent(tabId, childId, frame.id);
390
- if (knownFrames.has(childId)) continue;
391
- const attributes = owner.attributes || [], srcIndex = attributes.indexOf('src');
392
- const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
393
- knownFrames.add(childId); frames.push({ id: childId, url });
394
- }
395
- const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
396
- if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
397
- for (const entry of rendered) entries.push(entry);
398
- }
399
- const metadata = value(await send(tabId, 'Runtime.evaluate', {
400
- expression: '({title: document.title, url: location.href})', returnByValue: true,
401
- }));
402
- const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
403
- metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
404
- if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
405
- for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
406
- omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
407
- }
408
- const page = snapshotPage(state, args);
409
- snapshots.set(tabId, state);
410
- return page;
411
- }
412
-
413
- async function action(tabId, command, args) {
414
- // Reading buffered diagnostics does not require a responsive renderer.
415
- if (command === 'logs') return await diagnostics.readDetails(tabId, args, sessions.targets(tabId));
416
- if (command === 'handle_dialog') {
417
- if (typeof args.accept !== 'boolean') throw new Error('accept must be a boolean');
418
- const dialog = dialogs.get(tabId);
419
- await cdp({ tabId, ...(dialogs.get(tabId)?.sessionId ? { sessionId: dialogs.get(tabId).sessionId } : {}) }, 'Page.handleJavaScriptDialog', { accept: args.accept,
420
- ...(args.prompt_text != null ? { promptText: String(args.prompt_text) } : {}) });
421
- dialogs.delete(tabId);
422
- invalidateSnapshot(tabId, 'dialog_handled');
423
- return { handled: true, dialog: dialog ?? null, accepted: args.accept, observe_again: true };
424
- }
425
- if (dialogs.has(tabId)) {
426
- if (command === 'snapshot') return { dialog: dialogs.get(tabId), note: 'Use browser_handle_dialog to accept or dismiss this dialog.' };
427
- if (command !== 'release_tab') throw new Error('A JavaScript dialog is open. Observe browser_snapshot and use browser_handle_dialog.');
428
- }
429
- if (command === 'snapshot') return await snapshot(tabId, args);
430
- if (command === 'batch') {
431
- validateSteps(args.steps, args.start_at ?? 0);
432
- const budget = deadline(args.timeout_ms ?? 15000), results = [];
433
- const claim = claims.get(tabId), documentVersion = documents.get(tabId);
434
- for (let index = args.start_at ?? 0; index < args.steps.length; index++) {
435
- const step = args.steps[index];
436
- try {
437
- if (claims.get(tabId) !== claim) throw new Error('Tab control ended during batch; observe before continuing');
438
- if (documents.get(tabId) !== documentVersion) throw new Error('Document changed during batch; observe before continuing');
439
- await budget.run(async () => controlUrl((await api.tabs.get(tabId)).url));
440
- const { action: command, ...inputArgs } = step;
441
- const stepBudget = budget.child(step.timeout_ms ?? 30000, command === 'wait' ? 'wait' : 'input');
442
- results.push(await perform(tabId, command, { ...inputArgs, dialog_policy: step.dialog_policy ?? args.dialog_policy ?? 'ask', budget: stepBudget }));
443
- } catch (error) {
444
- return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false, ...errorData(error),
445
- application_result: 'not_checked', observe_again: true };
446
- }
447
- }
448
- return { completed: true, results, application_result: 'not_checked', observe_again: true };
449
- }
450
- if (command === 'evaluate') {
451
- await args.observe_start?.();
452
- if (args.result_type != null && !['json', 'element'].includes(args.result_type)) throw new Error('result_type must be json or element');
453
- if (args.cursor == null) expireCursor(tabId);
454
- if (args.cursor != null) return await evaluations.read(tabId, args);
455
- const budget = args.budget || deadline(args.timeout_ms ?? 10000, 'evaluation'), version = documents.get(tabId), claim = claims.get(tabId);
456
- const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
457
- const contextId = args.frame || args.frame_id ? pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId])) : undefined;
458
- if ((args.frame || args.frame_id) && contextId == null) throw new Error('Page frame context is unavailable; observe again');
459
- let result;
460
- try { result = await budget.run(() => { budget.dispatch(); return evaluations.read(tabId, { ...args, evaluation_frame: frameId, target, context_id: contextId, timeout_ms: Math.max(100, Math.min(args.timeout_ms ?? 10000, budget.remaining())) }); }); }
461
- catch (error) { evaluations.forget(tabId); if (args.result_type === 'element') await boundedRead(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-evaluation' }), 100).catch(() => {}); throw error; }
462
- if (!result.element) return { ...result, frame_id: frameId, scope: args.frame || args.frame_id ? 'frame' : 'main_frame' };
463
- try {
464
- if (documents.get(tabId) !== version || claims.get(tabId) !== claim) throw new Error('Document or claim changed during evaluation; no element ref was created');
465
- const owner = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', { objectId: result.element.objectId,
466
- functionDeclaration: 'function() { return this.nodeType === 1 && this.isConnected && this.ownerDocument === document; }', returnByValue: true })));
467
- if (!owner) throw new Error('Element must belong to the selected frame; use a frame locator');
468
- return { result_type: 'element', ...await rememberElement(tabId, target, result.element.objectId, frameId, budget), observe_again: true };
469
- } finally { await boundedRead(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-evaluation' }), 100).catch(() => {}); }
470
- }
471
- if (command === 'wait') return await waitFor(cdp, value, sessions, diagnostics, mouse, tabId, args);
472
- if (command === 'drag') {
473
- dragOptions(args);
474
- args = { ...args, budget: deadline(args.timeout_ms ?? 5000) };
475
- await mouse.assertReady(tabId, args.budget);
476
- await args.observe_start?.();
477
- const claim = claims.get(tabId), documentVersion = documents.get(tabId);
478
- for (const target of sessions.targets(tabId)) await args.budget.run(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
479
- expireCursor(tabId);
480
- return await gestures.drag(tabId, args, (target, budget) => target.selector ? resolveSelector(cdp, value, sessions, tabId, target, budget) : target.ref ? getObject(tabId, target.ref, budget) : null, () => {
481
- if (claims.get(tabId) !== claim || documents.get(tabId) !== documentVersion) throw new Error('Tab control or document changed during drag; outcome is unknown. Observe before continuing.');
482
- });
483
- }
484
- if (command === 'read_text') {
485
- if (args.selector_type === 'role') {
486
- const budget = deadline(5000), node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
487
- try { return { ...await budget.run(() => readText(cdp, value, node.target, { ...args, node })), scope: args.frame || args.frame_id ? 'frame' : 'main_frame', frame_id: node.frameId }; }
488
- finally { await boundedRead(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }), 100).catch(() => {}); }
489
- }
490
- if (!args.frame && !args.frame_id) return await readText(cdp, value, tabId, args);
491
- const budget = deadline(5000), frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
492
- const contextId = pageContexts.get(JSON.stringify([tabId, target.sessionId, frameId]));
493
- if (contextId == null) throw new Error('Page frame context is unavailable; observe again');
494
- return { ...await readText(cdp, value, target, { ...args, context_id: contextId }), scope: 'frame', frame_id: frameId };
495
- }
496
- if (command === 'screenshot') {
497
- const view = await viewport(tabId);
498
- const hasTarget = args.ref != null || args.selector != null;
499
- if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type || args.name != null)) throw new Error('Element screenshot frame/nth/type/name requires an element selector');
500
- if (hasTarget && args.full_page) throw new Error('Element target and full_page are mutually exclusive');
501
- const clip = hasTarget ? await elementClip(tabId, args, view) : args.full_page ? await fullPageClip(tabId) : null;
502
- const includeIndicators = args.include_indicators === true;
503
- const hiddenError = includeIndicators ? null : await clickVisuals.visible(tabId, false);
504
- if (hiddenError) throw new Error(`Could not hide click feedback for a clean screenshot: ${hiddenError}`);
505
- try {
506
- // Render an explicit target surface without activating its tab or window.
507
- const captureClip = clip || { x: view.scroll_x, y: view.scroll_y, width: view.width, height: view.height, scale: 1 };
508
- let timer;
509
- let result;
510
- try {
511
- result = await Promise.race([
512
- cdp(tabId, 'Page.captureScreenshot', { format: 'png', fromSurface: true,
513
- captureBeyondViewport: true, optimizeForSpeed: true, clip: captureClip }),
514
- new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(
515
- 'Screenshot did not finish within 5 seconds. An inactive or occluded Chromium page may not render. Use browser_tab_control action=activate before requesting another screenshot, or use DOM/AX.'
516
- )), 5000); }),
517
- ]);
518
- } finally { clearTimeout(timer); }
519
- return { ...result, capture: { scope: hasTarget ? 'element' : clip ? 'full_page' : 'viewport', viewport: view,
520
- css_width: clip?.width ?? view.width, css_height: clip?.height ?? view.height,
521
- image_origin_css: { x: clip?.x ?? view.scroll_x, y: clip?.y ?? view.scroll_y },
522
- click_coordinate_space: 'viewport_css_pixels', agent_indicators_included: includeIndicators } };
523
- } finally { await clickVisuals.visible(tabId, true); }
524
- }
525
- if (command === 'release_tab') { await release(tabId); return { released: true }; }
526
- if (command === 'navigate') {
527
- await args.observe_start?.();
528
- const url = args.direction != null ? undefined : args.url;
529
- if (args.direction != null && args.url != null) throw new Error('Provide url or direction, never both');
530
- evaluations.forget(tabId);
531
- invalidateSnapshot(tabId, 'navigation_requested');
532
- fileChoosers.delete(tabId);
533
- const { document: navigationDocument, ...result } = await navigation.navigate(tabId, url, args);
534
- const budget = deadline(NAVIGATION_OBSERVATION_TIMEOUT_MS, 'observation');
535
- for (let attempt = 0; attempt < 2; attempt++) try {
536
- if (args.include_snapshot === false) {
537
- const metadata = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
538
- expression: '({title: document.title, url: location.href})', returnByValue: true,
539
- })));
540
- return { ...result, ...metadata };
541
- }
542
- return { ...result, ...await snapshot(tabId, {}, budget) };
543
- } catch (error) {
544
- invalidateSnapshot(tabId, 'observation_failed');
545
- if (attempt === 0 && budget.remaining() > 100 && /context.*(destroyed|not found)|cannot find context|document (changed|updated)|inspected target navigated|no frame with given id/i.test(error.message)) {
546
- await new Promise(resolve => setTimeout(resolve, 50));
547
- continue;
548
- }
549
- return { ...result, observation_error: error.message, observe_again: true };
550
- }
551
- }
552
- // Re-resolve backend identity for every action; document/frame events expire refs.
553
- if (command === 'click' || command === 'fill') inputMode(args);
554
- if (command === 'select_option') selectOptions(args);
555
- if (command === 'type') typeOptions(args);
556
- if (command === 'scroll') scrollOptions(args);
557
- if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'name', 'nth'].some(key => args[key] != null)) targetOptions(args);
558
- if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom', focused: command === 'type' });
559
- if (command === 'click') clickOptions(args);
560
- if (['click', 'fill', 'type', 'press_key', 'scroll', 'hover', 'select_option', 'upload_file'].includes(command)) args = { ...args, budget: args.budget || deadline(args.timeout_ms) };
561
- // Check before any renderer preparation or ref invalidation. A rejected
562
- // inactive native click is known not to have dispatched, unlike a lost
563
- // receipt after input. DOM mode remains an explicit separate operation.
564
- if (command === 'hover' || command === 'click' && inputMode(args) === 'native') await mouse.assertReady(tabId, args.budget);
565
- await args.observe_start?.();
566
- // Reusable selectors permit many actions without a snapshot. Release the
567
- // previous operation's remote wrappers before resolving this one. Refs store
568
- // backend ids, not object handles; this cannot change their identity.
569
- if (args.budget) for (const target of sessions.targets(tabId)) {
570
- await args.budget.run(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
571
- }
572
- const node = args.selector ? await resolveSelector(cdp, value, sessions, tabId, args, args.budget || deadline(30000)) :
573
- args.ref ? await getObject(tabId, args.ref, args.budget || deadline(30000)) : null;
574
- expireCursor(tabId);
575
- if (command === 'upload_file') {
576
- const send = (target, method, params) => args.budget.run(() => cdp(target, method, params));
577
- if (!Array.isArray(args.files) || args.files.length > 100 || args.files.some(file => typeof file !== 'string' || !file || file.includes('\0'))) {
578
- throw new Error('files must contain at most 100 valid absolute file paths; [] clears selection');
579
- }
580
- const chooser = fileChoosers.get(tabId);
581
- let target = node;
582
- if (!target) {
583
- if (!chooser) throw new Error('No pending file chooser. Supply a fresh file-input/label ref, or click the upload button first.');
584
- const resolved = await send({ tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) }, 'DOM.resolveNode', { backendNodeId: chooser.backendNodeId, objectGroup: 'browser-attach' });
585
- if (!resolved.object?.objectId) throw new Error('File chooser input no longer exists. Observe again.');
586
- target = { objectId: resolved.object.objectId, target: { tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) } };
587
- }
588
- const resolved = await send(target.target, 'Runtime.callFunctionOn', {
589
- objectId: target.objectId, arguments: [{ value: args.files.length }],
590
- functionDeclaration: `function(count) {
591
- const input = this.tagName === 'LABEL' ? this.control : this;
592
- if (!input || input.tagName !== 'INPUT' || input.type !== 'file') throw new Error('Ref is not a file input or associated label');
593
- if (!input.isConnected) throw new Error('File input is detached');
594
- if (input.matches(':disabled')) throw new Error('File input is disabled');
595
- if (input.webkitdirectory) throw new Error('Directory upload is unsupported; use a regular file input');
596
- if (count > 1 && !input.multiple) throw new Error('Single-file input cannot accept multiple files');
597
- return input;
598
- }`, objectGroup: 'browser-attach',
599
- });
600
- if (resolved.exceptionDetails) value(resolved);
601
- if (!resolved.result?.objectId) throw new Error('Could not resolve the file input');
602
- args.budget.dispatch();
603
- if (args.files.length === 0) {
604
- // Chromium's native empty-path assignment does not emit input/change.
605
- // Like Playwright's empty payload path, explicitly clear and notify the app.
606
- value(await send(target.target, 'Runtime.callFunctionOn', { objectId: resolved.result.objectId, returnByValue: true,
607
- functionDeclaration: `function() {
608
- this.value = '';
609
- const Event = this.ownerDocument.defaultView.Event;
610
- this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
611
- this.dispatchEvent(new Event('change', { bubbles: true }));
612
- return true;
613
- }` }));
614
- } else {
615
- // Native CDP assignment emits input/change and preserves actual file metadata.
616
- await send(target.target, 'DOM.setFileInputFiles', { objectId: resolved.result.objectId, files: args.files });
617
- }
618
- fileChoosers.delete(tabId);
619
- return { files_set: args.files.length, cleared: args.files.length === 0, events_trusted: args.files.length > 0, observe_again: true };
620
- }
621
- if (command === 'click') {
622
- if (inputMode(args) === 'dom') return await input.domClick(tabId, node, args);
623
- const result = await mouse.click(tabId, node, args);
624
- // Keep click feedback out of the input dispatch/verification path.
625
- void clickVisuals.click(tabId, result.clicked.x, result.clicked.y).catch(() => {});
626
- return result;
627
- }
628
- if (command === 'hover') return await mouse.click(tabId, node, { ...args, hover: true });
629
- if (command === 'select_option') return await input.selectOption(tabId, node, args);
630
- if (command === 'type') return await input.typeText(tabId, node, args);
631
- if (command === 'fill') return await input.fill(tabId, node, args);
632
- if (command === 'press_key') return await input.pressKey(tabId, args.key, args);
633
- if (command === 'scroll' && args.until != null) {
634
- const { text, selector } = args.until;
635
- const maxSteps = args.max_steps ?? 100;
636
- const root = node || await resolveSelector(cdp, value, sessions, tabId, { selector: 'html' }, args.budget);
637
- const claim = claims.get(tabId), version = documents.get(tabId), frameVersion = frameStates.get(tabId)?.versions.get(root.frameId);
638
- let last;
639
- for (let step = 0; step <= maxSteps;) {
640
- if (claims.get(tabId) !== claim || documents.get(tabId) !== version || frameStates.get(tabId)?.versions.get(root.frameId) !== frameVersion) throw new Error('Document/frame/control changed during scroll search; observe before continuing');
641
- const found = await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
642
- functionDeclaration: findScrollText.toString(), arguments: [{ value: text }, { value: selector }], objectGroup: 'browser-attach-search' }));
643
- value(found);
644
- if (found.result?.objectId) {
645
- const result = await rememberElement(tabId, root.target, found.result.objectId, root.frameId, args.budget);
646
- await args.budget.run(() => cdp(root.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-search' }));
647
- return { found: true, ...result, scroll_steps: step, application_result: 'not_checked', observe_again: true };
648
- }
649
- if (step === maxSteps) break;
650
- if (last?.scrolled === false) {
651
- // A boundary may precede lazy loading. Observe without repeating a
652
- // no-op scroll until content grows, a match appears, or time runs out.
653
- const current = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
654
- functionDeclaration: scrollState.toString(), returnByValue: true })));
655
- if (JSON.stringify(current) === JSON.stringify(last.after)) { await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50))); continue; }
656
- }
657
- args.budget.dispatch();
658
- last = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
659
- functionDeclaration: scrollElement.toString(), arguments: [{ value: args.delta_x ?? 0 }, { value: args.delta_y ?? null }], returnByValue: true })));
660
- step++;
661
- await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
662
- }
663
- throw new Error(`Scroll search did not find ${JSON.stringify(text)} within ${maxSteps} steps; current=${JSON.stringify(last)}. Content may still load later. Scrolling may already have occurred.`);
664
- }
665
- if (command === 'scroll') {
666
- const mode = args.mode ?? 'dom';
667
- const budget = args.budget;
668
- const view = await budget.run(() => viewport(tabId));
669
- if (node && mode === 'dom') {
670
- budget.dispatch();
671
- return { ...value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
672
- functionDeclaration: scrollElement.toString(), arguments: [{ value: args.delta_x ?? 0 }, { value: args.delta_y }], returnByValue: true }))), observe_again: true };
673
- }
674
- const point = node ? (await mouse.prepare(tabId, node, { budget })).point : position(view, args.x ?? view.width / 2, args.y ?? view.height / 2);
675
- budget.dispatch();
676
- if (mode === 'wheel') {
677
- // Root-widget routing respects ancestor overlays and OOPIF boundaries.
678
- // A protocol acknowledgement does not prove a page handler ran or scrolled.
679
- await budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', {
680
- type: 'mouseWheel', ...point, deltaX: args.delta_x ?? 0, deltaY: args.delta_y,
681
- button: 'none', buttons: 0, modifiers: 0,
682
- }));
683
- return { dispatched: true, method: 'native_wheel', at: point, coordinate_space: 'viewport_css_pixels',
684
- application_result: 'not_checked', observe_again: true };
685
- }
686
- const result = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', { returnByValue: true,
687
- expression: `(${scrollAtPoint.toString()})(${point.x},${point.y},${args.delta_x ?? 0},${args.delta_y})` })));
688
- return { ...result, observe_again: true };
689
- }
690
- throw new Error(`Unknown operation: ${command}`);
691
- }
692
-
693
- async function perform(tabId, command, args) {
694
- if (args.dialog_policy != null && !['ask', 'accept', 'dismiss'].includes(args.dialog_policy)) throw new Error('dialog_policy must be ask, accept or dismiss');
695
- if (!args.budget && (['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file'].includes(command) || command === 'evaluate' && args.cursor == null)) {
696
- args = { ...args, budget: deadline(args.timeout_ms ?? (command === 'evaluate' ? 10000 : 5000), command === 'evaluate' ? 'evaluation' : 'input') };
697
- }
698
- const parent = dialogPolicies.get(tabId), state = { policy: args.dialog_policy ?? parent?.policy ?? 'ask', messages: [], parent };
699
- dialogPolicies.set(tabId, state);
700
- const version = documents.get(tabId), startTab = await api.tabs.get(tabId);
701
- const watch = ['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file', 'evaluate', 'navigate'].includes(command) && !(command === 'evaluate' && args.cursor != null);
702
- let observer, observationError, observationFrame = frameStates.get(tabId)?.mainId, observationTarget = { tabId }, observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
703
- const startObservation = async () => { if (!watch || observer || dialogs.has(tabId)) return; try {
704
- observer = await boundedRead(async () => {
705
- if (args.ref) observationFrame = snapshots.get(tabId)?.refs.get(args.ref)?.frameId || observationFrame;
706
- else if (args.frame || args.frame_id) observationFrame = await resolveFrame(cdp, value, sessions, tabId, args, deadline(Math.max(100, Math.min(200, args.budget?.remaining() ?? 200))), { wait: false });
707
- observationTarget = sessions.target(tabId, observationFrame);
708
- observedVersion = frameStates.get(tabId)?.versions.get(observationFrame);
709
- const { executionContextId } = await cdp(observationTarget, 'Page.createIsolatedWorld', { frameId: observationFrame, worldName: 'browser-attach-outcome' });
710
- const result = await cdp(observationTarget, 'Runtime.evaluate', { contextId: executionContextId, expression: `(${installOutcomeObserver.toString()})()`, objectGroup: 'browser-attach-outcome' });
711
- if (!result.result?.objectId) throw new Error('DOM outcome observer could not be installed');
712
- return result.result.objectId;
713
- }, Math.min(200, args.budget?.remaining() ?? 200));
714
- } catch (error) { observationError = error.message; } };
715
- try {
716
- const result = await action(tabId, command, { ...args, observe_start: startObservation });
717
- if (state.handling) await boundedRead(() => state.handling, 500);
718
- return { ...result, ...(state.messages.length ? { dialogs: state.messages } : {}) };
719
- } catch (error) {
720
- if (error.input_dispatched == null && args.budget?.has_dispatched) error.input_dispatched = args.budget.has_dispatched();
721
- if (state.handling) await boundedRead(() => state.handling, 500).catch(() => {});
722
- if (state.messages.some(d => d.type === 'beforeunload' && d.accepted === false && d.handled) && /ERR_ABORTED|cancel/i.test(error.message)) return { navigated: false, cancelled_by_dialog: true, dialogs: state.messages, observe_again: true };
723
- if (watch && error.input_dispatched !== false && /outcome is unknown|may already|effects may|may have been|timed out|did not reach|may still/i.test(error.message)) {
724
- let current, dom, tabError, domError = observationError;
725
- await Promise.allSettled([
726
- boundedRead(() => api.tabs.get(tabId)).then(result => { if (result) current = result; else tabError = 'Tab is unavailable'; }, error => { tabError = error.message; }),
727
- observer ? boundedRead(async () => {
728
- const result = value(await cdp(observationTarget, 'Runtime.callFunctionOn', { objectId: observer, functionDeclaration: 'function() { return this.read(); }', returnByValue: true }));
729
- if (typeof result?.dom_changed !== 'boolean') throw new Error('DOM outcome observation is unavailable');
730
- return result;
731
- }).then(result => { dom = result; }, error => { domError = error.message; }) : Promise.resolve(),
732
- ]);
733
- const frameChanged = frameStates.get(tabId)?.versions.get(observationFrame) !== observedVersion;
734
- const navigationObserved = documents.get(tabId) !== version || !!current && current.url !== startTab.url;
735
- error.post_action = { delivery: 'unknown', frame_id: observationFrame, frame_navigation_observed: frameChanged, dialog: dialogs.get(tabId) || null, dialogs: state.messages,
736
- navigation_observed: navigationObserved,
737
- current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
738
- observations: { tab: { available: !!current, ...(tabError ? { error: tabError } : {}) },
739
- dom: { available: !!dom, ...(!dom ? { error: domError || 'DOM outcome observer is unavailable' } : {}) } },
740
- assessment: dialogs.has(tabId) || state.messages.length || navigationObserved || frameChanged || dom?.dom_changed ? 'observable_effects'
741
- : dom && current ? 'no_observable_effects' : 'observation_unavailable',
742
- note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
743
- } else if (state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
744
- throw error;
745
- } finally {
746
- if (parent && claims.has(tabId)) dialogPolicies.set(tabId, parent); else dialogPolicies.delete(tabId);
747
- if (observer) await boundedRead(async () => {
748
- await cdp(observationTarget, 'Runtime.callFunctionOn', { objectId: observer, functionDeclaration: 'function() { this.stop(); }' });
749
- await cdp(observationTarget, 'Runtime.releaseObject', { objectId: observer });
750
- }, 100).catch(() => {});
751
- }
752
- }
753
-
754
- async function elementClip(tabId, args, view) {
755
- targetOptions(args);
756
- const budget = deadline(5000);
757
- const node = args.selector ? await resolveSelector(cdp, value, sessions, tabId, args, budget) : await getObject(tabId, args.ref, budget);
758
- const { model } = await budget.run(() => cdp(node.target, 'DOM.getBoxModel', { backendNodeId: node.backendNodeId }));
759
- if (!model?.border) throw new Error('Element has no rendered box');
760
- let points = Array.from({ length: 4 }, (_, i) => ({ x: model.border[i * 2], y: model.border[i * 2 + 1] })), target = node.target;
761
- while (target.sessionId) {
762
- const parent = sessions.parent(tabId, target.sessionId);
763
- const owner = await budget.run(() => cdp(parent.target, 'DOM.getFrameOwner', { frameId: parent.frameId }));
764
- const box = await budget.run(() => cdp(parent.target, 'DOM.getBoxModel', { backendNodeId: owner.backendNodeId }));
765
- const childView = await budget.run(() => viewport(target));
766
- points = points.map(p => framePoint(p, box.model.content, childView));
767
- target = parent.target;
768
- }
769
- const x = Math.min(...points.map(p => p.x)) + view.scroll_x, y = Math.min(...points.map(p => p.y)) + view.scroll_y;
770
- const width = Math.max(...points.map(p => p.x)) + view.scroll_x - x, height = Math.max(...points.map(p => p.y)) + view.scroll_y - y;
771
- if (width <= 0 || height <= 0 || width * height > 40000000 || x < 0 || y < 0) throw new Error('Element capture requires a positive box within the document and at most 40 million pixels');
772
- return { x, y, width, height, scale: 1 };
773
- }
774
-
775
- async function fullPageClip(tabId) {
776
- const { cssContentSize } = await cdp(tabId, 'Page.getLayoutMetrics');
777
- if (cssContentSize.width * cssContentSize.height > 40000000) throw new Error('Full-page capture exceeds 40 million pixels; use viewport capture');
778
- return { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 };
779
- }
780
-
781
- async function closeTemporary(tabId) {
782
- try { await api.tabs.remove(tabId); }
783
- catch (error) {
784
- // A closed tab is already clean; otherwise retain ownership for later cleanup.
785
- let tab;
786
- try { tab = await api.tabs.get(tabId); }
787
- catch (lookup) { if (!/No tab with id|Invalid tab ID/i.test(lookup.message)) throw error; }
788
- if (tab) throw error;
789
- }
790
- temporaryTabs.delete(tabId);
791
- if (claims.has(tabId)) forget(tabId);
792
- }
793
-
794
- async function fetchPage(owner, args, cap, checkConnection) {
795
- const url = httpUrl(args.url), timeout = args.timeout_ms ?? 15000, waitUntil = args.wait_until ?? 'load';
796
- const end = Date.now() + timeout, cleanupReserve = Math.min(250, Math.floor(timeout / 10));
797
- const budget = deadline(timeout, 'fetch', end - cleanupReserve);
798
- const handle = crypto.randomUUID(), temporary = { owner, handle };
799
- const creation = api.tabs.create({ url: 'about:blank', active: false }).then(tab => {
800
- temporaryTabs.set(tab.id, temporary);
801
- return tab;
802
- });
803
- let tab;
804
- try { tab = await budget.run(() => creation); }
805
- catch (error) {
806
- // A late tabs.create response must not start navigation or leak its tab.
807
- creation.then(created => queued(created.id, () => closeTemporary(created.id))).catch(() => {});
808
- throw error;
809
- }
810
- return await queued(tab.id, async () => {
811
- let failure;
812
- try {
813
- checkConnection();
814
- await attach(tab.id, owner, { tab_handle: handle }, tab, budget);
815
- const claim = claims.get(tab.id);
816
- const checkControl = () => {
817
- checkConnection();
818
- if (claims.get(tab.id) !== claim) throw new Error('Tab control ended during fetch; capture discarded');
819
- };
820
- const metadata = (satisfied, reason, readyState) => ({ wait_until: waitUntil, wait_satisfied: satisfied,
821
- degraded: !satisfied, degraded_reason: reason, ready_state: readyState, document_changed: !!temporary.document_changed });
822
- const httpFailure = response => {
823
- if ((args.format ?? 'text') === 'json') throw new Error(`HTTP ${response.status}: navigation produced an error page without a readable JSON document`);
824
- return { url: httpUrl(response.url), contentType: response.contentType, format: args.format ?? 'text', body: '',
825
- body_source: response.empty_body ? 'http_response' : 'unavailable', ...(!response.empty_body ? { body_unavailable: true } : {}),
826
- capture_truncated: false, status: response.status, ok: response.status >= 200 && response.status < 300,
827
- ...metadata(false, 'http_error_response', null), navigation_error: response.error };
828
- };
829
- checkControl();
830
- if (budget.remaining() < 100) throw new Error('Fetch preparation timed out; no navigation was dispatched');
831
- const readReserve = Math.min(2000, Math.floor(budget.remaining() / 3));
832
- const waitEnd = Date.now() + budget.remaining() - readReserve;
833
- const waitBudget = deadline(timeout, 'wait', waitEnd);
834
- let satisfied = false, reason = null, waitedDocument;
835
- try {
836
- const navigationResult = await navigation.navigate(tab.id, url, { wait_until: waitUntil === 'network_idle' ? 'load' : waitUntil,
837
- timeout_ms: Math.max(100, waitBudget.remaining()) });
838
- waitedDocument = JSON.stringify([navigationResult.document.frameId, navigationResult.document.loaderId]);
839
- checkControl();
840
- if (waitUntil === 'network_idle') await waitFor(cdp, value, sessions, diagnostics, mouse, tab.id, { condition: 'network_idle', budget: waitBudget });
841
- satisfied = true;
842
- } catch (error) {
843
- checkControl();
844
- if (error.navigation_error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') {
845
- const response = diagnostics.failedDocument(tab.id, frameStates.get(tab.id)?.mainId);
846
- if (response) return httpFailure(response);
847
- throw error;
848
- }
849
- if (error.code === 'NAVIGATION_TIMEOUT' || (waitUntil === 'network_idle' && waitBudget.remaining() === 0)) reason = 'wait_timeout';
850
- else if (error.navigation_error === 'net::ERR_ABORTED' && temporary.document) reason = 'navigation_interrupted';
851
- else throw error;
852
- }
853
- for (let attempt = 0; attempt < 3; attempt++) {
854
- checkControl();
855
- const version = documents.get(tab.id);
856
- try {
857
- const { frameTree } = await budget.run(() => cdp(tab.id, 'Page.getFrameTree'));
858
- const response = frameTree.frame.unreachableUrl && diagnostics.failedDocument(tab.id, frameTree.frame.id);
859
- if (response && response.url === frameTree.frame.unreachableUrl) return httpFailure(response);
860
- httpUrl(frameTree.frame.url);
861
- const { executionContextId } = await budget.run(() => cdp(tab.id, 'Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'browser-attach-fetch' }));
862
- const result = value(await budget.run(() => cdp(tab.id, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true, userGesture: false,
863
- expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
864
- checkControl();
865
- if (documents.get(tab.id) !== version) throw Object.assign(new Error('Document changed during fetch capture'), { code: 'FETCH_DOCUMENT_CHANGED' });
866
- httpUrl(result.url);
867
- const status = diagnostics.documentStatus(tab.id, frameTree.frame);
868
- if (satisfied && waitedDocument !== JSON.stringify([frameTree.frame.id, frameTree.frame.loaderId])) { satisfied = false; reason = 'document_changed'; }
869
- return { ...result, status, ok: status == null ? null : status >= 200 && status < 300,
870
- ...metadata(satisfied, reason, result.ready_state) };
871
- } catch (error) {
872
- checkControl();
873
- const changing = documents.get(tab.id) !== version || error.code === 'FETCH_DOCUMENT_CHANGED' || /context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated/i.test(error.message);
874
- if (!changing || attempt === 2) throw error;
875
- await budget.run(() => new Promise(resolve => setTimeout(resolve, 25)));
876
- }
877
- }
878
- } catch (error) { failure = error; throw error; }
879
- finally {
880
- try { await boundedRead(() => closeTemporary(tab.id), Math.max(1, end - Date.now())); }
881
- catch (error) { throw new Error(`${failure ? failure.message + '; ' : ''}Fetch temporary tab ${tab.id} could not be closed: ${error.message}. Cleanup will be retried on disconnect.`); }
882
- }
883
- });
884
- }
885
-
886
- async function attach(tabId, owner, args, tab, budget) {
887
- const run = operation => budget ? budget.run(operation) : operation();
888
- await run(() => api.debugger.attach({ tabId }, '1.3'));
889
- try {
890
- diagnostics.start(tabId);
891
- await run(() => sessions.start(tabId));
892
- const { frameTree } = await run(() => cdp(tabId, 'Page.getFrameTree'));
893
- const state = { mainId: frameTree.frame.id, parents: new Map(), versions: new Map(), sessions: new Map() };
894
- const remember = tree => {
895
- if (tree.frame.parentId) state.parents.set(tree.frame.id, tree.frame.parentId);
896
- (tree.childFrames || []).forEach(remember);
897
- };
898
- remember(frameTree); frameStates.set(tabId, state);
899
- claims.set(tabId, { owner, handle: args.tab_handle });
900
- let visualWarning;
901
- try { if (!temporaryTabs.has(tabId)) await visuals.install(tabId); }
902
- catch (error) { visualWarning = error.message; }
903
- return { claimed: true, activation_requested: false, title: tab.title, url: tab.url,
904
- ...(visualWarning ? { visual_warning: visualWarning } : {}) };
905
- } catch (error) {
906
- diagnostics.forget(tabId);
907
- sessions.forget(tabId);
908
- if (!temporaryTabs.has(tabId)) await api.debugger.detach({ tabId });
909
- throw error;
910
- }
911
- }
912
-
913
- async function execute(owner, command, args) {
914
- if (command === 'fetch') return await fetches.fetch(owner, args);
915
- if (command === 'open_tab') {
916
- const url = httpUrl(args.url);
917
- if (args.active != null && typeof args.active !== 'boolean') throw new Error('active must be a boolean');
918
- const tab = await api.tabs.create({ url, active: args.active ?? false });
919
- return { opened: true, tab_id: tab.id, window_id: tab.windowId, active: tab.active,
920
- note: 'List tabs after loading, then claim tab_id with optional exact URL/title guards before controlling this tab.' };
921
- }
922
- if (command === 'list_tabs') {
923
- const tabs = (await api.tabs.query({})).filter(tab => !temporaryTabs.has(tab.id));
924
- const fileAccess = tabs.some(tab => tab.url?.startsWith('file:')) && await api.extension.isAllowedFileSchemeAccess();
925
- const controllable = url => {
926
- try { return !pageUrl(url).startsWith('file:') || fileAccess; }
927
- catch { return false; }
928
- };
929
- return { tabs: tabs.map(tab => ({ tab_id: tab.id, window_id: tab.windowId, title: tab.title || '', url: tab.url || '',
930
- active: tab.active, group_id: tab.groupId, controllable: controllable(tab.url) })) };
931
- }
932
- if (command === 'release_owner') { fetches.forget(owner); return await releaseMatching(claim => claim.owner === owner); }
933
- const tabId = args.tab_id;
934
- if (!Number.isInteger(tabId)) throw new Error('Invalid tab ID');
935
- return await queued(tabId, async () => {
936
- if (command === 'claim_tab') {
937
- if (temporaryTabs.has(tabId)) throw new Error('Tab is already controlled by a fetch operation');
938
- const tab = await api.tabs.get(tabId);
939
- if ((args.expected_url != null && tab.url !== args.expected_url) || (args.expected_title != null && tab.title !== args.expected_title)) throw new Error('Tab title/URL changed. List tabs again or omit optional identity guards to claim by tab_id.');
940
- await controlUrl(tab.url);
941
- const existing = claims.get(tabId);
942
- if (existing) {
943
- if (existing.owner === owner && existing.handle === args.tab_handle) return { claimed: true };
944
- throw new Error('Tab is already controlled by another session');
945
- }
946
- return await attach(tabId, owner, args, tab);
947
- }
948
- const claim = claims.get(tabId);
949
- if (!claim || claim.owner !== owner || claim.handle !== args.tab_handle) throw new Error('Tab is not claimed by this connection');
950
- if (command === 'activate_tab') {
951
- if (args.focus_window != null && typeof args.focus_window !== 'boolean') throw new Error('focus_window must be a boolean');
952
- const tab = await api.tabs.update(tabId, { active: true });
953
- if (args.focus_window ?? true) {
954
- await api.windows.update(tab.windowId, { focused: true });
955
- // Explicit activation also restores the renderer focus, which can be
956
- // lost when a focused iframe navigates even if its tab stays active.
957
- await cdp(tabId, 'Page.bringToFront');
958
- }
959
- return { activated: true, window_focus_requested: args.focus_window ?? true, tab_id: tabId, window_id: tab.windowId };
960
- }
961
- if (command === 'close_tab') {
962
- await api.tabs.remove(tabId);
963
- if (claims.has(tabId)) forget(tabId);
964
- return { closed: true, tab_id: tabId };
965
- }
966
- if (command === 'release_tab') return await action(tabId, command, args);
967
- await controlUrl((await api.tabs.get(tabId)).url);
968
- const result = await perform(tabId, command, args);
969
- // A batch can stop because its tab closed. Preserve the partial results
970
- // instead of losing them to a metadata lookup on the vanished tab.
971
- if (command === 'batch') return { ...result, tab_id: tabId };
972
- const tab = await api.tabs.get(tabId);
973
- const chooser = fileChoosers.get(tabId);
974
- return { ...result, ...(chooser ? { file_chooser: { pending: true, mode: chooser.mode, frame_id: chooser.frame_id,
975
- note: 'Use browser_upload_file without ref; files: [] cancels the selection.' } } : {}),
976
- tab_id: tabId, title: result.title ?? tab.title, url: result.url ?? tab.url, active: tab.active };
977
- });
978
- }
979
-
980
- return { execute, releaseAll: () => { fetches.clear(); return releaseMatching(() => true); }, claimedCount: () => claims.size };
981
- }
982
-
983
- function scrollAtPoint(x, y, dx, dy) {
984
- let doc = document;
985
- let element = doc.elementFromPoint(x, y);
986
- const enteredShadowHosts = new Set();
987
- while (element) {
988
- if (element.tagName === 'IFRAME') {
989
- const frame = element;
990
- const rect = frame.getBoundingClientRect();
991
- try { doc = frame.contentDocument; } catch { doc = null; }
992
- if (!doc) throw new Error('Cross-origin iframe scrolling is unavailable; scroll the outer page or use the website controls.');
993
- x -= rect.left + frame.clientLeft; y -= rect.top + frame.clientTop;
994
- element = doc.elementFromPoint(x, y);
995
- continue;
996
- }
997
- if (element.shadowRoot && !enteredShadowHosts.has(element)) {
998
- enteredShadowHosts.add(element);
999
- const inner = element.shadowRoot.elementFromPoint(x, y);
1000
- if (inner && inner !== element) { element = inner; continue; }
1001
- }
1002
- const style = doc.defaultView.getComputedStyle(element);
1003
- const canX = /auto|scroll|overlay/.test(style.overflowX) && element.scrollWidth > element.clientWidth &&
1004
- ((dx > 0 && element.scrollLeft < element.scrollWidth - element.clientWidth) || (dx < 0 && element.scrollLeft > 0));
1005
- const canY = /auto|scroll|overlay/.test(style.overflowY) && element.scrollHeight > element.clientHeight &&
1006
- ((dy > 0 && element.scrollTop < element.scrollHeight - element.clientHeight) || (dy < 0 && element.scrollTop > 0));
1007
- if (canX || canY) break;
1008
- const parent = element.parentElement || element.getRootNode().host;
1009
- if (!parent) { element = doc.scrollingElement; break; }
1010
- element = parent;
1011
- }
1012
- if (!element) element = doc.scrollingElement;
1013
- const before = { x: element.scrollLeft, y: element.scrollTop };
1014
- element.scrollBy({ left: dx, top: dy, behavior: 'instant' });
1015
- const after = { x: element.scrollLeft, y: element.scrollTop };
1016
- return { scrolled: before.x !== after.x || before.y !== after.y, before, after,
1017
- method: 'dom_scroll', scope: element === doc.scrollingElement ? 'document' : 'element' };
1018
- }
1
+ // Browser operations live here; the WebSocket transport never exposes raw CDP.
2
+ import { createVisuals } from './visuals.js';
3
+ import { createClickVisuals } from './click-visuals.js';
4
+ import { createObservations } from './observation.js';
5
+ import { createNavigation } from './navigation.js';
6
+ import { createClick, clickOptions } from './click.js';
7
+ import { createPointer } from './pointer.js';
8
+ import { createInput, inputMode, selectOptions, typeOptions } from './input.js';
9
+ import { createEvaluations } from './evaluate.js';
10
+ import { targetOptions, resolveSelector, resolveFrame } from './locator.js';
11
+ import { readText } from './text.js';
12
+ import { createDiagnostics } from './diagnostics.js';
13
+ import { createNetworkTracker } from './network.js';
14
+ import { createSessions } from './sessions.js';
15
+ import { NAVIGATION_OBSERVATION_TIMEOUT_MS } from './limits.js';
16
+ import { deadline } from './actionability.js';
17
+ import { scrollOptions, createScrolling } from './scroll.js';
18
+ import { validateSteps } from './batch.js';
19
+ import { waitFor } from './wait.js';
20
+ import { boundedRead, createOutcomes } from './outcomes.js';
21
+ import { createDialogs } from './dialogs.js';
22
+ import { createDrag, dragOptions } from './drag.js';
23
+ import { createFetches } from './fetch.js';
24
+ import { createFetchPages } from './fetch-page.js';
25
+ import { errorData } from './errors.js';
26
+ import { pageUrl, httpUrl } from './page-url.js';
27
+ import { createScreenshots } from './screenshot.js';
28
+ import { createUploads } from './upload.js';
29
+
30
+ export function createController(api, notify = () => {}) {
31
+ const claims = new Map();
32
+ const queues = new Map();
33
+ const dialogs = createDialogs((...args) => api.debugger.sendCommand(...args),
34
+ (tabId, error) => navigation.cancel(tabId, error), (tabId, cause) => observations.invalidate(tabId, cause), tabId => claims.has(tabId));
35
+ const visuals = createVisuals(api);
36
+ const clickVisuals = createClickVisuals(api);
37
+ const navigation = createNavigation(cdp, controlUrl);
38
+ const network = createNetworkTracker();
39
+ const diagnostics = createDiagnostics(cdp);
40
+ const sessions = createSessions(cdp, (tabId, frameId) => observations.invalidateFrame(tabId, frameId), (tabId, sessionId) => {
41
+ diagnostics.detach(tabId, sessionId);
42
+ network.detach(tabId, sessionId);
43
+ observations.detachSession(tabId, sessionId);
44
+ }, tabId => diagnostics.networkOptions(tabId));
45
+ const pointer = createPointer(cdp, value, viewport, position, sessions, tabId => api.tabs.get(tabId), windowId => api.windows.get(windowId));
46
+ const mouse = createClick(cdp, value, pointer);
47
+ const gestures = createDrag(cdp, value, pointer, viewport);
48
+ const input = createInput(cdp, value, sessions);
49
+ const evaluations = createEvaluations(cdp);
50
+ const uploads = createUploads(cdp, value);
51
+ const observations = createObservations(cdp, value, sessions, viewport, tabId => claims.get(tabId), (tabId, affected) => {
52
+ if (affected) {
53
+ for (const frameId of affected) evaluations.forgetFrame(tabId, frameId);
54
+ uploads.forgetFrames(tabId, affected);
55
+ } else { evaluations.forget(tabId); uploads.forget(tabId); }
56
+ });
57
+ const outcomes = createOutcomes(cdp, value, sessions, observations, tabId => api.tabs.get(tabId), tabId => dialogs.get(tabId));
58
+ const fetchPages = createFetchPages(api, cdp, value, navigation, network, sessions, pointer, observations, { queued, attach, currentClaim: tabId => claims.get(tabId), forget });
59
+ const fetches = createFetches(fetchPages.read);
60
+ const scrolling = createScrolling(cdp, value, sessions, pointer, viewport, position, observations, tabId => claims.get(tabId));
61
+ const screenshots = createScreenshots(cdp, value, viewport, sessions, clickVisuals, resolveNode);
62
+
63
+ function resolveNode(tabId, args, budget) {
64
+ return args.selector ? resolveSelector(cdp, value, sessions, tabId, args, budget) : observations.resolve(tabId, args.ref, budget);
65
+ }
66
+
67
+ function queued(tabId, operation) {
68
+ const result = (queues.get(tabId) || Promise.resolve()).catch(() => {}).then(operation);
69
+ queues.set(tabId, result);
70
+ const done = () => { if (queues.get(tabId) === result) queues.delete(tabId); };
71
+ result.then(done, done);
72
+ return result;
73
+ }
74
+
75
+ async function controlUrl(url) {
76
+ const normalized = pageUrl(url);
77
+ if (normalized.startsWith('file:') && !await api.extension.isAllowedFileSchemeAccess()) {
78
+ throw new Error('Local file access is disabled. Enable "Allow access to file URLs" in the BrowserAttach extension settings in Chrome/Edge.');
79
+ }
80
+ return normalized;
81
+ }
82
+
83
+ function cdp(target, method, params = {}) { return dialogs.send(target, method, params); }
84
+
85
+ function value(result) {
86
+ if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
87
+ return result.result?.value;
88
+ }
89
+
90
+ async function viewport(tabId) {
91
+ return value(await cdp(tabId, 'Runtime.evaluate', {
92
+ expression: '({width: innerWidth, height: innerHeight, scroll_x: scrollX, scroll_y: scrollY, device_pixel_ratio: devicePixelRatio})', returnByValue: true,
93
+ }));
94
+ }
95
+
96
+ function position(view, x, y) {
97
+ if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || y < 0 || x >= view.width || y >= view.height) {
98
+ throw new Error(`Position must be inside the viewport (${view.width} x ${view.height} CSS pixels)`);
99
+ }
100
+ return { x, y };
101
+ }
102
+
103
+ async function release(tabId) {
104
+ if (!claims.has(tabId)) return;
105
+ // Forget only after detach; a failed detach must not silently grant another owner.
106
+ try { await clickVisuals.remove(tabId, dialogs.has(tabId)); await visuals.remove(tabId); }
107
+ finally { await api.debugger.detach({ tabId }); }
108
+ claims.delete(tabId);
109
+ evaluations.forget(tabId);
110
+ observations.forget(tabId);
111
+ dialogs.forget(tabId);
112
+ uploads.forget(tabId);
113
+ diagnostics.forget(tabId);
114
+ network.forget(tabId);
115
+ sessions.forget(tabId);
116
+ notify({ type: 'detached', tab_id: tabId });
117
+ }
118
+
119
+ async function releaseMatching(predicate) {
120
+ const targets = new Map([...claims, ...fetchPages.entries()].filter(([, claim]) => predicate(claim)));
121
+ const outcomes = await Promise.allSettled([...targets].map(([tabId]) => queued(tabId, () => fetchPages.has(tabId) ? fetchPages.close(tabId) : release(tabId))));
122
+ return { released: outcomes.filter(item => item.status === 'fulfilled').length,
123
+ errors: outcomes.filter(item => item.status === 'rejected').map(item => String(item.reason)) };
124
+ }
125
+
126
+ function forget(tabId) {
127
+ evaluations.forget(tabId);
128
+ navigation.cancel(tabId);
129
+ mouse.cancel(tabId);
130
+ gestures.cancel(tabId);
131
+ input.cancel(tabId);
132
+ visuals.forget(tabId);
133
+ clickVisuals.forget(tabId);
134
+ claims.delete(tabId);
135
+ observations.forget(tabId);
136
+ dialogs.forget(tabId);
137
+ uploads.forget(tabId);
138
+ diagnostics.forget(tabId);
139
+ network.forget(tabId);
140
+ sessions.forget(tabId);
141
+ notify({ type: 'detached', tab_id: tabId });
142
+ }
143
+ api.debugger.onDetach.addListener(source => { if (!source.sessionId) forget(source.tabId); });
144
+ api.debugger.onEvent.addListener((source, method, params = {}) => {
145
+ sessions.event(source, method, params);
146
+ observations.contextEvent(source, method, params);
147
+ if (!sessions.has(source)) return;
148
+ const tracked = network.event(source, method, params);
149
+ diagnostics.event(source, method, params, tracked);
150
+ if (!source.sessionId) navigation.event(source.tabId, method, params);
151
+ const autoDialog = method === 'Page.javascriptDialogOpening' && dialogs.willHandle(source.tabId);
152
+ if (!autoDialog) {
153
+ mouse.event(source.tabId, method, params, source.sessionId);
154
+ gestures.event(source.tabId, method, params, source.sessionId);
155
+ input.event(source.tabId, method, params, source.sessionId);
156
+ }
157
+ const mainNavigation = !source.sessionId && method === 'Page.frameNavigated' && params.frame && !params.frame.parentId;
158
+ fetchPages.event(source, method, params);
159
+ observations.event(source, method, params);
160
+ if (method === 'Page.fileChooserOpened' && claims.has(source.tabId)) uploads.opened(source.tabId, params, source.sessionId);
161
+ if (mainNavigation) clickVisuals.navigated(source.tabId);
162
+ dialogs.event(source, method, params);
163
+ });
164
+ api.tabs.onRemoved.addListener(tabId => { fetchPages.removed(tabId); forget(tabId); });
165
+ api.tabs.onUpdated.addListener((tabId, change) => {
166
+ // A subframe reload can also set the TAB status to loading. CDP main-frame
167
+ // events identify document changes; a status-only update carries no identity.
168
+ if (change.url) {
169
+ observations.documentChanged(tabId, 'tab_url_changed');
170
+ }
171
+ });
172
+
173
+ async function action(tabId, command, args) {
174
+ // Reading buffered diagnostics does not require a responsive renderer.
175
+ if (command === 'logs') return await diagnostics.readDetails(tabId, args, sessions.targets(tabId));
176
+ if (command === 'handle_dialog') return await dialogs.handle(tabId, args);
177
+ if (dialogs.has(tabId)) {
178
+ if (command === 'snapshot') return { dialog: dialogs.get(tabId), note: 'Use browser_handle_dialog to accept or dismiss this dialog.' };
179
+ if (command !== 'release_tab') throw new Error('A JavaScript dialog is open. Observe browser_snapshot and use browser_handle_dialog.');
180
+ }
181
+ if (command === 'snapshot') return await observations.snapshot(tabId, args);
182
+ if (command === 'batch') {
183
+ validateSteps(args.steps, args.start_at ?? 0);
184
+ const budget = deadline(args.timeout_ms ?? 15000), results = [];
185
+ const claim = claims.get(tabId), documentVersion = observations.version(tabId);
186
+ for (let index = args.start_at ?? 0; index < args.steps.length; index++) {
187
+ const step = args.steps[index];
188
+ try {
189
+ if (claims.get(tabId) !== claim) throw new Error('Tab control ended during batch; observe before continuing');
190
+ if (observations.version(tabId) !== documentVersion) throw new Error('Document changed during batch; observe before continuing');
191
+ await budget.run(async () => controlUrl((await api.tabs.get(tabId)).url));
192
+ const { action: command, ...inputArgs } = step;
193
+ const stepBudget = budget.child(step.timeout_ms ?? 30000, command === 'wait' ? 'wait' : 'input');
194
+ results.push(await perform(tabId, command, { ...inputArgs, dialog_policy: step.dialog_policy ?? args.dialog_policy ?? 'ask', budget: stepBudget }));
195
+ } catch (error) {
196
+ return { completed: false, results, started_at: args.start_at ?? 0, failed_step: index, next_unattempted_step: index + 1 < args.steps.length ? index + 1 : null, retry_failed_step_safe: error.input_dispatched === false, ...errorData(error),
197
+ application_result: 'not_checked', observe_again: true };
198
+ }
199
+ }
200
+ return { completed: true, results, application_result: 'not_checked', observe_again: true };
201
+ }
202
+ if (command === 'evaluate') {
203
+ await args.observe_start?.();
204
+ if (args.result_type != null && !['json', 'element'].includes(args.result_type)) throw new Error('result_type must be json or element');
205
+ if (args.cursor == null) observations.expireCursor(tabId);
206
+ if (args.cursor != null) return await evaluations.read(tabId, args);
207
+ const budget = args.budget || deadline(args.timeout_ms ?? 10000, 'evaluation'), version = observations.version(tabId), claim = claims.get(tabId);
208
+ const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
209
+ const contextId = args.frame || args.frame_id ? observations.context(tabId, target.sessionId, frameId) : undefined;
210
+ if ((args.frame || args.frame_id) && contextId == null) throw new Error('Page frame context is unavailable; observe again');
211
+ let result;
212
+ try { result = await budget.run(() => { budget.dispatch(); return evaluations.read(tabId, { ...args, evaluation_frame: frameId, target, context_id: contextId, timeout_ms: Math.max(100, Math.min(args.timeout_ms ?? 10000, budget.remaining())) }); }); }
213
+ catch (error) { evaluations.forget(tabId); if (args.result_type === 'element') await boundedRead(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-evaluation' }), 100).catch(() => {}); throw error; }
214
+ if (!result.element) return { ...result, frame_id: frameId, scope: args.frame || args.frame_id ? 'frame' : 'main_frame' };
215
+ try {
216
+ if (observations.version(tabId) !== version || claims.get(tabId) !== claim) throw new Error('Document or claim changed during evaluation; no element ref was created');
217
+ const owner = value(await budget.run(() => cdp(target, 'Runtime.callFunctionOn', { objectId: result.element.objectId,
218
+ functionDeclaration: 'function() { return this.nodeType === 1 && this.isConnected && this.ownerDocument === document; }', returnByValue: true })));
219
+ if (!owner) throw new Error('Element must belong to the selected frame; use a frame locator');
220
+ return { result_type: 'element', ...await observations.rememberElement(tabId, target, result.element.objectId, frameId, budget), observe_again: true };
221
+ } finally { await boundedRead(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-evaluation' }), 100).catch(() => {}); }
222
+ }
223
+ if (command === 'wait') return await waitFor(cdp, value, sessions, network, pointer, tabId, args);
224
+ if (command === 'drag') {
225
+ dragOptions(args);
226
+ args = { ...args, budget: deadline(args.timeout_ms ?? 5000) };
227
+ await pointer.assertReady(tabId, args.budget, 'drag');
228
+ await args.observe_start?.();
229
+ const claim = claims.get(tabId), documentVersion = observations.version(tabId);
230
+ for (const target of sessions.targets(tabId)) await args.budget.run(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
231
+ observations.expireCursor(tabId);
232
+ return await gestures.drag(tabId, args, (target, budget) => target.selector ? resolveSelector(cdp, value, sessions, tabId, target, budget) : target.ref ? observations.resolve(tabId, target.ref, budget) : null, () => {
233
+ if (claims.get(tabId) !== claim || observations.version(tabId) !== documentVersion) throw Object.assign(new Error('Tab control or document changed during drag; outcome is unknown. Observe before continuing.'), { outcome_unknown: true });
234
+ });
235
+ }
236
+ if (command === 'read_text') {
237
+ if (args.selector_type === 'role') {
238
+ const budget = deadline(5000), node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
239
+ try { return { ...await budget.run(() => readText(cdp, value, node.target, { ...args, node })), scope: args.frame || args.frame_id ? 'frame' : 'main_frame', frame_id: node.frameId }; }
240
+ finally { await boundedRead(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }), 100).catch(() => {}); }
241
+ }
242
+ if (!args.frame && !args.frame_id) return await readText(cdp, value, tabId, args);
243
+ const budget = deadline(5000), frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget), target = sessions.target(tabId, frameId);
244
+ const contextId = observations.context(tabId, target.sessionId, frameId);
245
+ if (contextId == null) throw new Error('Page frame context is unavailable; observe again');
246
+ return { ...await readText(cdp, value, target, { ...args, context_id: contextId }), scope: 'frame', frame_id: frameId };
247
+ }
248
+ if (command === 'screenshot') return await screenshots.capture(tabId, args);
249
+ if (command === 'release_tab') { await release(tabId); return { released: true }; }
250
+ if (command === 'navigate') {
251
+ await args.observe_start?.();
252
+ const url = args.direction != null ? undefined : args.url;
253
+ if (args.direction != null && args.url != null) throw new Error('Provide url or direction, never both');
254
+ evaluations.forget(tabId);
255
+ observations.invalidate(tabId, 'navigation_requested');
256
+ uploads.forget(tabId);
257
+ const { document: navigationDocument, ...result } = await navigation.navigate(tabId, url, args);
258
+ const budget = deadline(NAVIGATION_OBSERVATION_TIMEOUT_MS, 'observation');
259
+ for (let attempt = 0; attempt < 2; attempt++) try {
260
+ if (args.include_snapshot === false) {
261
+ const metadata = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
262
+ expression: '({title: document.title, url: location.href})', returnByValue: true,
263
+ })));
264
+ return { ...result, ...metadata };
265
+ }
266
+ return { ...result, ...await observations.snapshot(tabId, {}, budget) };
267
+ } catch (error) {
268
+ observations.invalidate(tabId, 'observation_failed');
269
+ if (attempt === 0 && budget.remaining() > 100 && /context.*(destroyed|not found)|cannot find context|document (changed|updated)|inspected target navigated|no frame with given id/i.test(error.message)) {
270
+ await new Promise(resolve => setTimeout(resolve, 50));
271
+ continue;
272
+ }
273
+ return { ...result, observation_error: error.message, observe_again: true };
274
+ }
275
+ }
276
+ // Re-resolve backend identity for every action; document/frame events expire refs.
277
+ if (command === 'click' || command === 'fill') inputMode(args);
278
+ if (command === 'select_option') selectOptions(args);
279
+ if (command === 'type') typeOptions(args);
280
+ if (command === 'scroll') scrollOptions(args);
281
+ if (['scroll', 'upload_file'].includes(command) && ['ref', 'selector', 'frame', 'frame_id', 'selector_type', 'name', 'nth'].some(key => args[key] != null)) targetOptions(args);
282
+ if (['click', 'fill', 'type', 'hover', 'select_option'].includes(command)) targetOptions(args, { coordinates: command === 'hover' || command === 'click' && inputMode(args) !== 'dom', focused: command === 'type' });
283
+ if (command === 'click') clickOptions(args);
284
+ if (['click', 'fill', 'type', 'press_key', 'scroll', 'hover', 'select_option', 'upload_file'].includes(command)) args = { ...args, budget: args.budget || deadline(args.timeout_ms) };
285
+ // Reject unavailable input before renderer preparation or ref invalidation;
286
+ // a lost receipt after dispatch must instead report an unknown outcome.
287
+ if (command === 'hover' || command === 'click' && inputMode(args) === 'native') await pointer.assertReady(tabId, args.budget, command, args);
288
+ await args.observe_start?.();
289
+ // Reusable selectors permit many actions without a snapshot. Release the
290
+ // previous operation's remote wrappers before resolving this one. Refs store
291
+ // backend ids, not object handles; this cannot change their identity.
292
+ if (args.budget) for (const target of sessions.targets(tabId)) {
293
+ await args.budget.run(() => cdp(target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
294
+ }
295
+ const node = args.selector ? await resolveSelector(cdp, value, sessions, tabId, args, args.budget || deadline(30000)) :
296
+ args.ref ? await observations.resolve(tabId, args.ref, args.budget || deadline(30000)) : null;
297
+ observations.expireCursor(tabId);
298
+ if (command === 'upload_file') return await uploads.upload(tabId, node, args);
299
+ if (command === 'click') {
300
+ if (inputMode(args) === 'dom') return await input.domClick(tabId, node, args);
301
+ const result = await mouse.click(tabId, node, args);
302
+ // Keep click feedback out of the input dispatch/verification path.
303
+ void clickVisuals.click(tabId, result.clicked.x, result.clicked.y).catch(() => {});
304
+ return result;
305
+ }
306
+ if (command === 'hover') return await mouse.click(tabId, node, { ...args, hover: true });
307
+ if (command === 'select_option') return await input.selectOption(tabId, node, args);
308
+ if (command === 'type') return await input.typeText(tabId, node, args);
309
+ if (command === 'fill') return await input.fill(tabId, node, args);
310
+ if (command === 'press_key') return await input.pressKey(tabId, args.key, args);
311
+ if (command === 'scroll') return await scrolling.scroll(tabId, node, args);
312
+ throw new Error(`Unknown operation: ${command}`);
313
+ }
314
+
315
+ async function perform(tabId, command, args) {
316
+ if (!args.budget && (['click', 'fill', 'type', 'press_key', 'hover', 'scroll', 'select_option', 'upload_file'].includes(command) || command === 'evaluate' && args.cursor == null)) {
317
+ args = { ...args, budget: deadline(args.timeout_ms ?? (command === 'evaluate' ? 10000 : 5000), command === 'evaluate' ? 'evaluation' : 'input') };
318
+ }
319
+ const state = dialogs.enter(tabId, args.dialog_policy);
320
+ let outcome;
321
+ try {
322
+ outcome = await outcomes.begin(tabId, command, args);
323
+ const result = await action(tabId, command, { ...args, observe_start: outcome.start });
324
+ await dialogs.settle(state);
325
+ return { ...result, ...(state.messages.length ? { dialogs: state.messages } : {}) };
326
+ } catch (error) {
327
+ if (error.input_dispatched == null && args.budget?.has_dispatched) error.input_dispatched = args.budget.has_dispatched();
328
+ await dialogs.settle(state).catch(() => {});
329
+ if (state.messages.some(d => d.type === 'beforeunload' && d.accepted === false && d.handled) && /ERR_ABORTED|cancel/i.test(error.message)) return { navigated: false, cancelled_by_dialog: true, dialogs: state.messages, observe_again: true };
330
+ if (!await outcome?.enrich(error, state.messages) && state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
331
+ throw error;
332
+ } finally {
333
+ dialogs.exit(tabId, state);
334
+ await outcome?.stop();
335
+ }
336
+ }
337
+
338
+ async function attach(tabId, owner, args, tab, budget) {
339
+ const run = operation => budget ? budget.run(operation) : operation();
340
+ await run(() => api.debugger.attach({ tabId }, '1.3'));
341
+ try {
342
+ network.start(tabId);
343
+ diagnostics.start(tabId);
344
+ await run(() => sessions.start(tabId));
345
+ const { frameTree } = await run(() => cdp(tabId, 'Page.getFrameTree'));
346
+ observations.initialize(tabId, frameTree);
347
+ claims.set(tabId, { owner, handle: args.tab_handle });
348
+ let visualWarning;
349
+ try { if (!fetchPages.has(tabId)) await visuals.install(tabId); }
350
+ catch (error) { visualWarning = error.message; }
351
+ return { claimed: true, activation_requested: false, title: tab.title, url: tab.url,
352
+ ...(visualWarning ? { visual_warning: visualWarning } : {}) };
353
+ } catch (error) {
354
+ diagnostics.forget(tabId);
355
+ network.forget(tabId);
356
+ sessions.forget(tabId);
357
+ if (!fetchPages.has(tabId)) await api.debugger.detach({ tabId });
358
+ throw error;
359
+ }
360
+ }
361
+
362
+ async function execute(owner, command, args) {
363
+ if (command === 'fetch') return await fetches.fetch(owner, args);
364
+ if (command === 'open_tab') {
365
+ const url = httpUrl(args.url);
366
+ if (args.active != null && typeof args.active !== 'boolean') throw new Error('active must be a boolean');
367
+ const tab = await api.tabs.create({ url, active: args.active ?? false });
368
+ return { opened: true, tab_id: tab.id, window_id: tab.windowId, active: tab.active,
369
+ note: 'List tabs after loading, then claim tab_id with optional exact URL/title guards before controlling this tab.' };
370
+ }
371
+ if (command === 'list_tabs') {
372
+ const tabs = (await api.tabs.query({})).filter(tab => !fetchPages.has(tab.id));
373
+ const fileAccess = tabs.some(tab => tab.url?.startsWith('file:')) && await api.extension.isAllowedFileSchemeAccess();
374
+ const controllable = url => {
375
+ try { return !pageUrl(url).startsWith('file:') || fileAccess; }
376
+ catch { return false; }
377
+ };
378
+ return { tabs: tabs.map(tab => ({ tab_id: tab.id, window_id: tab.windowId, title: tab.title || '', url: tab.url || '',
379
+ active: tab.active, group_id: tab.groupId, controllable: controllable(tab.url) })) };
380
+ }
381
+ if (command === 'release_owner') { fetches.forget(owner); return await releaseMatching(claim => claim.owner === owner); }
382
+ const tabId = args.tab_id;
383
+ if (!Number.isInteger(tabId)) throw new Error('Invalid tab ID');
384
+ return await queued(tabId, async () => {
385
+ if (command === 'claim_tab') {
386
+ if (fetchPages.has(tabId)) throw new Error('Tab is already controlled by a fetch operation');
387
+ const tab = await api.tabs.get(tabId);
388
+ if ((args.expected_url != null && tab.url !== args.expected_url) || (args.expected_title != null && tab.title !== args.expected_title)) throw new Error('Tab title/URL changed. List tabs again or omit optional identity guards to claim by tab_id.');
389
+ await controlUrl(tab.url);
390
+ const existing = claims.get(tabId);
391
+ if (existing) {
392
+ if (existing.owner === owner && existing.handle === args.tab_handle) return { claimed: true };
393
+ throw new Error('Tab is already controlled by another session');
394
+ }
395
+ return await attach(tabId, owner, args, tab);
396
+ }
397
+ const claim = claims.get(tabId);
398
+ if (!claim || claim.owner !== owner || claim.handle !== args.tab_handle) throw new Error('Tab is not claimed by this connection');
399
+ if (command === 'activate_tab') {
400
+ if (args.focus_window != null && typeof args.focus_window !== 'boolean') throw new Error('focus_window must be a boolean');
401
+ const tab = await api.tabs.update(tabId, { active: true });
402
+ if (args.focus_window ?? true) {
403
+ await api.windows.update(tab.windowId, { focused: true });
404
+ // Explicit activation also restores the renderer focus, which can be
405
+ // lost when a focused iframe navigates even if its tab stays active.
406
+ await cdp(tabId, 'Page.bringToFront');
407
+ }
408
+ return { activated: true, window_focus_requested: args.focus_window ?? true, tab_id: tabId, window_id: tab.windowId };
409
+ }
410
+ if (command === 'close_tab') {
411
+ await api.tabs.remove(tabId);
412
+ if (claims.has(tabId)) forget(tabId);
413
+ return { closed: true, tab_id: tabId };
414
+ }
415
+ if (command === 'release_tab') return await action(tabId, command, args);
416
+ await controlUrl((await api.tabs.get(tabId)).url);
417
+ const result = await perform(tabId, command, args);
418
+ // A batch can stop because its tab closed. Preserve the partial results
419
+ // instead of losing them to a metadata lookup on the vanished tab.
420
+ if (command === 'batch') return { ...result, tab_id: tabId };
421
+ const tab = await api.tabs.get(tabId);
422
+ const chooser = uploads.pending(tabId);
423
+ return { ...result, ...(chooser ? { file_chooser: { pending: true, mode: chooser.mode, frame_id: chooser.frame_id,
424
+ note: 'Use browser_upload_file without ref; files: [] cancels the selection.' } } : {}),
425
+ tab_id: tabId, title: result.title ?? tab.title, url: result.url ?? tab.url, active: tab.active };
426
+ });
427
+ }
428
+
429
+ return { execute, releaseAll: () => { fetches.clear(); return releaseMatching(() => true); }, claimedCount: () => claims.size };
430
+ }