@evan_zhong/browser-attach 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,375 +1,141 @@
1
- import { deadline, waitForTarget, delivery } from './actionability.js';
2
- import { offsetOption, timingOption, pause } from './motion.js';
3
-
4
- // Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
5
- export function createClick(cdp, value, viewport, position, sessions, getTab, getWindow) {
6
- const pending = new Map();
7
- async function assertReady(tabId, budget) {
8
- const timeout = Math.min(500, budget.remaining());
9
- const guidance = 'Call browser_tab_control action=activate with this tab_handle, then browser_snapshot and retry the requested pointer action. If focus is still unavailable, restore/focus the browser window. No input was dispatched.';
10
- let timer;
11
- try {
12
- if (timeout <= 0) throw new Error('input deadline expired before the readiness check');
13
- return await Promise.race([
14
- (async () => {
15
- const tab = await getTab(tabId);
16
- if (!tab.active || tab.discarded || tab.frozen) throw new Error(`tab not active or renderer suspended (active=${tab.active}, discarded=${!!tab.discarded}, frozen=${!!tab.frozen})`);
17
- const window = await getWindow(tab.windowId);
18
- if (window.state === 'minimized') throw new Error('browser window is minimized');
19
- const state = value(await cdp(tabId, 'Runtime.evaluate', {
20
- expression: '({visibility_state: document.visibilityState, has_focus: document.hasFocus()})', returnByValue: true,
21
- }));
22
- if (typeof state?.visibility_state !== 'string' || typeof state.has_focus !== 'boolean') throw new Error('page readiness unavailable');
23
- // Occlusion can make an active renderer report hidden without
24
- // preventing CDP input. Focus/visibility are diagnostics; the isolated
25
- // event guard below verifies delivery to the intended element.
26
- return { ...state, window_focused: window.focused };
27
- })(),
28
- new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(`readiness check did not respond within ${timeout} ms`)), timeout); }),
29
- ]);
30
- } catch (error) {
31
- throw new Error(`TAB_NOT_READY: Native pointer input not dispatched: ${error.message}. ${guidance}`);
32
- } finally { clearTimeout(timer); }
33
- }
34
- function event(tabId, method, params, sessionId) {
35
- const operation = pending.get(tabId);
36
- if (operation && method === 'Page.javascriptDialogOpening') {
37
- operation.reject(new Error('The action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'));
38
- return;
39
- }
40
- if (method !== 'Runtime.bindingCalled' || !operation || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
41
- try {
42
- const receipt = JSON.parse(params.payload);
43
- if (receipt.matched === true) operation.resolve();
44
- else operation.reject(new Error('Click target changed or became covered during mouse dispatch. Further incorrect target input was blocked; outcome is unknown. Observe again before retrying.'));
45
- } catch (error) { operation.reject(error); }
46
- }
47
- function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
48
- async function prepare(tabId, node, args) {
49
- offsetOption(args);
50
- if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
51
- if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
52
- if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
53
- const budget = args.budget || deadline(args.timeout_ms);
54
- const obstruction = (detail, diagnostics) => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
55
- ? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
56
- : 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched(), diagnostics });
57
- let target = node?.target || { tabId };
58
- const send = (method, params) => budget.run(() => cdp(target, method, params));
59
- let point, view;
60
- if (node) {
61
- await waitForTarget(cdp, value, target, node, budget);
62
- if (args.scroll !== false) await send('DOM.scrollIntoViewIfNeeded', { backendNodeId: node.backendNodeId });
63
- await waitForTarget(cdp, value, target, node, budget);
64
- const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
65
- view = await budget.run(() => viewport(target));
66
- for (const quad of quads || []) {
67
- point = clippedPoint(quad, view, args.offset);
68
- if (point) break;
69
- }
70
- if (!point) throw new Error('Element has no visible click point. Inspect a screenshot.');
71
- } else {
72
- view = await budget.run(() => viewport(target));
73
- if (args.coordinate_space === 'document') {
74
- if (!Number.isFinite(args.x) || !Number.isFinite(args.y) || args.x < 0 || args.y < 0) throw new Error('Document coordinates must be finite and nonnegative');
75
- const x = args.x - view.scroll_x, y = args.y - view.scroll_y;
76
- if (args.scroll !== false && (x < 0 || y < 0 || x >= view.width || y >= view.height)) {
77
- const left = x < 0 || x >= view.width ? Math.max(0, args.x - view.width / 2) : view.scroll_x;
78
- const top = y < 0 || y >= view.height ? Math.max(0, args.y - view.height / 2) : view.scroll_y;
79
- value(await send('Runtime.evaluate', { returnByValue: true,
80
- expression: `scrollTo({left:${left},top:${top},behavior:'instant'})` }));
81
- view = await budget.run(() => viewport(target));
82
- }
83
- point = position(view, args.x - view.scroll_x, args.y - view.scroll_y);
84
- } else point = position(view, args.x, args.y);
85
- }
86
- // DOM hit testing uses document CSS coordinates; Input uses viewport CSS coordinates.
87
- let hitNode = await send('DOM.getNodeForLocation', {
88
- x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true,
89
- });
90
- // Coordinate input starts in the root viewport; descend into a remote frame
91
- // before resolving backend ids or installing isolated-world verification.
92
- const visited = new Set();
93
- while (!node) {
94
- const description = await send('DOM.describeNode', { backendNodeId: hitNode.backendNodeId, depth: 0 });
95
- const childId = description.node.frameId, child = childId && sessions.target(tabId, childId);
96
- if (!child?.sessionId || child.sessionId === target.sessionId) break;
97
- if (visited.has(child.sessionId)) throw new Error('Frame hit-test cycle; no input was dispatched.');
98
- visited.add(child.sessionId);
99
- const { model } = await send('DOM.getBoxModel', { backendNodeId: hitNode.backendNodeId });
100
- const childView = await budget.run(() => viewport(child));
101
- point = framePoint(point, model.content, childView, true);
102
- target = child; view = childView;
103
- hitNode = await send('DOM.getNodeForLocation', { x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true });
104
- }
105
- let { backendNodeId } = hitNode;
106
- const { frameId } = hitNode;
107
- let hit = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
108
- const pseudo = hit.object.className === 'CSSPseudoElement';
109
- if (pseudo || hit.object.className === 'ShadowRoot') {
110
- let localPoint = point;
111
- if (!pseudo && frameId && (await send('Page.getFrameTree')).frameTree.frame.id !== frameId) {
112
- // Same-process frame quads are session-relative, while ShadowRoot hit
113
- // testing expects the owning document's viewport coordinates.
114
- const owner = await send('DOM.getFrameOwner', { frameId });
115
- const { model } = await send('DOM.getBoxModel', { backendNodeId: owner.backendNodeId });
116
- const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
117
- const frameView = value(await send('Runtime.evaluate', { contextId: executionContextId,
118
- expression: '({width: innerWidth, height: innerHeight})', returnByValue: true }));
119
- localPoint = framePoint(point, model.content, frameView, true);
120
- }
121
- const normalized = await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
122
- functionDeclaration: normalizeHit.toString(), arguments: [{ value: localPoint.x }, { value: localPoint.y }], objectGroup: 'browser-attach' });
123
- value(normalized);
124
- if (!normalized.result?.objectId) throw new Error('Hit element could not be resolved; no input was dispatched.');
125
- hit = { object: normalized.result };
126
- const { node: origin } = await send('DOM.describeNode', { objectId: hit.object.objectId });
127
- backendNodeId = origin.backendNodeId;
128
- }
129
- const hitTarget = value(await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
130
- functionDeclaration: describeHit.toString(), returnByValue: true }));
131
- if (pseudo) hitTarget.pseudo_element = true;
132
- let relation = null;
133
- if (node) {
134
- relation = value(await send('Runtime.callFunctionOn', {
135
- objectId: node.objectId, functionDeclaration: hitRelation.toString(),
136
- arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
137
- }));
138
- if (!relation) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`,
139
- { reason: 'obstructed', hit_target: hitTarget, point, coordinate_space: 'session_viewport_css_pixels', frame_id: node.frameId, hit_relation: null });
140
- }
141
- // Dispatch through the root widget so overlays in ancestor documents cannot
142
- // be bypassed by sending directly to an OOPIF widget. Guard every boundary.
143
- const boundaries = [];
144
- let rootPoint = point, current = target, currentView = view;
145
- while (current.sessionId) {
146
- const parent = sessions.parent(tabId, current.sessionId);
147
- const owner = await budget.run(() => cdp(parent.target, 'DOM.getFrameOwner', { frameId: parent.frameId }));
148
- const { model } = await budget.run(() => cdp(parent.target, 'DOM.getBoxModel', { backendNodeId: owner.backendNodeId }));
149
- rootPoint = framePoint(rootPoint, model.content, currentView);
150
- const parentView = await budget.run(() => viewport(parent.target));
151
- position(parentView, rootPoint.x, rootPoint.y);
152
- const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
153
- x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
154
- }));
155
- if (parentHit.backendNodeId !== owner.backendNodeId) {
156
- let blocker;
157
- try {
158
- const diagnostic = budget.child(200);
159
- const resolved = await diagnostic.run(() => cdp(parent.target, 'DOM.resolveNode', { backendNodeId: parentHit.backendNodeId, objectGroup: 'browser-attach' }));
160
- blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
161
- functionDeclaration: describeHit.toString(), returnByValue: true })));
162
- } catch { /* Preserve the obstruction error even if its node disappears. */ }
163
- throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`,
164
- { reason: 'frame_obstructed', hit_target: blocker ?? null, point: rootPoint, coordinate_space: 'session_viewport_css_pixels', blocked_frame_id: parent.frameId });
165
- }
166
- const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
167
- boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
168
- current = parent.target; currentView = parentView;
169
- }
170
- return { target, point: rootPoint, hitTarget, relation, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, boundaries };
171
- }
172
- async function click(tabId, node, args) {
173
- const options = { ...clickOptions(args), hover: args.hover === true };
174
- const budget = args.budget || deadline(args.timeout_ms);
175
- const prepared = await prepare(tabId, node, { ...args, budget });
176
- const { target, point: rootPoint, hitTarget, relation, frameId, backendNodeId, boundaries } = prepared;
177
- if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
178
- const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
179
- const guards = [], contexts = new Set();
180
- let failure;
181
- const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject(error) { failure = error; reject(error); } }));
182
- receipt.catch(() => {});
183
- try {
184
- const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
185
- for (const item of targets) {
186
- const sendTo = (method, params) => budget.run(() => cdp(item.target, method, params));
187
- const frame = item.frameId;
188
- const { executionContextId } = await sendTo('Page.createIsolatedWorld', { frameId: frame, worldName: 'browser-attach-input' });
189
- if (!executionContextId) throw new Error('Could not create an isolated click verification context; no input was dispatched.');
190
- contexts.add(JSON.stringify([item.target.sessionId, executionContextId]));
191
- await sendTo('Runtime.addBinding', { name, executionContextId });
192
- const isolated = await sendTo('DOM.resolveNode', { backendNodeId: item.backendNodeId, executionContextId, objectGroup: 'browser-attach' });
193
- const installed = await sendTo('Runtime.callFunctionOn', {
194
- objectId: isolated.object.objectId, functionDeclaration: installGuard.toString(),
195
- arguments: [{ value: name }, { value: options }, { value: budget.remaining() + 500 }, { value: item.leaf === true }], objectGroup: 'browser-attach',
196
- });
197
- if (installed.exceptionDetails) value(installed);
198
- if (!installed.result?.objectId) throw new Error('Could not install click target verification; no input was dispatched.');
199
- guards.push({ target: item.target, objectId: installed.result.objectId });
200
- }
201
- // Recheck the root page after target preparation: a user may have changed
202
- // tabs/focus while we resolved a frame or installed the event guards.
203
- await pause(budget, args.settle_ms);
204
- if (failure) throw failure;
205
- const pageState = await assertReady(tabId, budget);
206
- if (failure) throw failure;
207
- budget.dispatch();
208
- const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
209
- const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
210
- for (let count = 1; !options.hover && count <= options.click_count; count++) {
211
- commands.push(dispatch({ type: 'mousePressed', button: options.button, buttons: options.buttons, clickCount: count, modifiers: options.modifiers }));
212
- commands.push(dispatch({ type: 'mouseReleased', button: options.button, buttons: 0, clickCount: count, modifiers: options.modifiers }));
213
- }
214
- await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
215
- const verification = { target_verified: node ? true : null, delivery_verified: true, events_trusted: true,
216
- target_verification: { basis: node ? 'element' : 'coordinate_hit', hit_relation: relation } };
217
- if (options.hover) return { hovered: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', observe_again: true };
218
- return { clicked: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', coordinate_space: 'viewport_css_pixels', frame_id: node?.frameId || frameId, button: options.button, click_count: options.click_count, observe_again: true };
219
- } finally {
220
- pending.delete(tabId);
221
- // A page handler may have opened a modal. Renderer cleanup can finish after
222
- // it is dismissed, but must not keep the tab queue locked until then.
223
- const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
224
- cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
225
- let cleanupTimer;
226
- try { await Promise.race([Promise.allSettled(cleanup),
227
- new Promise(resolve => { cleanupTimer = setTimeout(resolve, 250); })]); }
228
- finally { clearTimeout(cleanupTimer); }
229
- }
230
- }
231
- return { assertReady, prepare, click, event, cancel };
232
- }
233
-
234
- export function describeHit() {
235
- const element = this.nodeType === 3 ? this.parentElement : this;
236
- if (element?.nodeType !== 1) throw new Error('Hit target is not an Element; no input was dispatched.');
237
- let clickable, disabled = false, nativeSelect = false;
238
- for (let parent = element; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
239
- if (parent.tagName === 'SELECT' || parent.tagName === 'LABEL' && parent.control?.tagName === 'SELECT') nativeSelect = true;
240
- if (!clickable && parent.matches('button,a[href],input,select,textarea,summary,[role="button"],[role="link"],[onclick]')) clickable = parent;
241
- if (parent.matches(':disabled,[inert],[aria-disabled="true"]')) disabled = true;
242
- }
243
- const rect = element.getBoundingClientRect(), style = element.ownerDocument.defaultView.getComputedStyle(element);
244
- return { tag: element.tagName?.toLowerCase(), id: element.id || null, text: (element.innerText || element.textContent || '').slice(0, 300),
245
- rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, coordinate_space: 'frame_viewport_css_pixels',
246
- position: style.position, z_index: style.zIndex, visibility: style.visibility,
247
- role: element.getAttribute('role'), disabled, clickable_hint: !!clickable,
248
- native_select: nativeSelect,
249
- clickable_target: clickable ? { tag: clickable.tagName.toLowerCase(), role: clickable.getAttribute('role'),
250
- text: (clickable.innerText || clickable.textContent || '').slice(0, 300) } : null };
251
- }
252
-
253
- export function normalizeHit(x, y) {
254
- let element = this.element || (this.nodeType === 3 ? this.parentElement : this);
255
- if (element?.nodeType === 11 && element.host) element = element.elementFromPoint(x, y);
256
- if (element?.nodeType !== 1) throw new Error('Hit test did not resolve an Element; no input was dispatched.');
257
- return element;
258
- }
259
-
260
- export function hitRelation(hit) {
261
- if (hit === this) return 'same_node';
262
- for (let node = hit; node; node = node.assignedSlot || node.parentNode || node.host) {
263
- if (node === this) return 'composed_descendant';
264
- }
265
- return null;
266
- }
267
-
268
- function installGuard(name, options, expiryMs, verifyDelivery) {
269
- const target = this.nodeType === 3 ? this.parentElement : this;
270
- const win = target.ownerDocument.defaultView, report = win[name];
271
- let rejected = false, completed = 0;
272
- const types = options.hover ? ['mousemove'] : ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', 'auxclick', 'contextmenu', 'dblclick'];
273
- const listeners = [];
274
- function listener(event, expected) {
275
- if (!event.isTrusted) return;
276
- const element = target.nodeType === 3 ? target.parentElement : target;
277
- const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
278
- const matched = (options.hover || event.button === options.domButton) && modifiers === options.modifiers && !rejected && target.isConnected && !element.matches(':disabled') && !element.closest('[inert]') &&
279
- event.composedPath().some(node => node === expected);
280
- if (!matched) {
281
- rejected = true; event.preventDefault(); event.stopImmediatePropagation();
282
- report(JSON.stringify({ matched: false }));
283
- } else {
284
- const terminal = options.hover ? 'mousemove' : options.button === 'right' ? 'contextmenu' : options.button === 'middle' ? 'auxclick' : options.click_count === 2 ? 'dblclick' : 'click';
285
- if (verifyDelivery && event.type === terminal && expected === target) {
286
- completed++;
287
- if (options.button === 'left' || completed === options.click_count) report(JSON.stringify({ matched: true }));
288
- }
289
- }
290
-
291
- }
292
- let expiry;
293
- function stop() {
294
- clearTimeout(expiry);
295
- for (const [root, callback] of listeners) for (const type of types) root.removeEventListener(type, callback, true);
296
- delete win[name];
297
- }
298
- // Window listeners see a closed shadow host, not its internal target. Check
299
- // each boundary in its own root so closed components remain controllable.
300
- let expected = target;
301
- while (true) {
302
- const root = expected.getRootNode(), receiver = root.host ? root : win;
303
- const localTarget = expected, callback = event => listener(event, localTarget);
304
- listeners.push([receiver, callback]);
305
- for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
306
- if (!root.host) break;
307
- expected = root.host;
308
- }
309
- expiry = win.setTimeout(stop, expiryMs);
310
- return { stop };
311
- }
312
-
313
- export function clippedPoint(quad, view, offset) {
314
- if (offset) {
315
- const { x_ratio: x, y_ratio: y } = offset;
316
- const point = { x: (1 - y) * ((1 - x) * quad[0] + x * quad[2]) + y * ((1 - x) * quad[6] + x * quad[4]),
317
- y: (1 - y) * ((1 - x) * quad[1] + x * quad[3]) + y * ((1 - x) * quad[7] + x * quad[5]) };
318
- return point.x >= 0 && point.y >= 0 && point.x < view.width && point.y < view.height ? point : null;
319
- }
320
- let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
321
- for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
322
- const input = polygon; polygon = [];
323
- for (let i = 0; i < input.length; i++) {
324
- const a = input[i], b = input[(i + 1) % input.length];
325
- const insideA = lower ? a[axis] >= bound : a[axis] <= bound;
326
- const insideB = lower ? b[axis] >= bound : b[axis] <= bound;
327
- if (insideA) polygon.push(a);
328
- if (insideA !== insideB) {
329
- const t = (bound - a[axis]) / (b[axis] - a[axis]);
330
- polygon.push({ x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) });
331
- }
332
- }
333
- }
334
- const twiceArea = polygon.reduce((sum, a, i) => {
335
- const b = polygon[(i + 1) % polygon.length]; return sum + a.x * b.y - b.x * a.y;
336
- }, 0);
337
- if (Math.abs(twiceArea) < 2) return null;
338
- const point = { x: 0, y: 0 };
339
- for (let i = 0; i < polygon.length; i++) {
340
- const a = polygon[i], b = polygon[(i + 1) % polygon.length], cross = a.x * b.y - b.x * a.y;
341
- point.x += (a.x + b.x) * cross; point.y += (a.y + b.y) * cross;
342
- }
343
- return { x: point.x / (3 * twiceArea), y: point.y / (3 * twiceArea) };
344
- }
345
-
346
- export function clickOptions(args) {
347
- offsetOption(args);
348
- if (args.settle_ms != null) {
349
- timingOption(args.settle_ms, 'settle_ms');
350
- if (args.mode === 'dom') throw new Error('settle_ms requires native pointer input');
351
- }
352
- const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
353
- const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
354
- if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
355
- if (![1, 2].includes(click_count)) throw new Error('click_count must be 1 or 2');
356
- if (!Array.isArray(modifiers) || modifiers.some(key => !Object.hasOwn(masks, key))) throw new Error('modifiers must contain Alt, Control, Meta or Shift');
357
- if (args.mode === 'dom' && (button !== 'left' || click_count !== 1 || modifiers.length)) throw new Error('DOM click supports only left single click without modifiers');
358
- return { button, click_count, modifiers: modifiers.reduce((mask, key) => mask | masks[key], 0), buttons: buttons[button][0], domButton: buttons[button][1] };
359
- }
360
-
361
- // Affine transforms cover translation, scale, rotation and skew. Reject
362
- // perspective rather than silently clicking a wrongly transformed position.
363
- export function framePoint(point, quad, view, inverse = false) {
364
- const [x, y, x1, y1, x2, y2, x3, y3] = quad;
365
- const ax = x1 - x, ay = y1 - y, bx = x3 - x, by = y3 - y;
366
- if (Math.abs(x2 - x1 - x3 + x) > 0.5 || Math.abs(y2 - y1 - y3 + y) > 0.5) throw new Error('Perspective iframe transforms are unsupported; no input was dispatched.');
367
- const determinant = ax * by - ay * bx;
368
- if (Math.abs(determinant) < 0.001) throw new Error('Frame has no visible content area; no input was dispatched.');
369
- if (inverse) {
370
- const dx = point.x - x, dy = point.y - y;
371
- return { x: (dx * by - dy * bx) / determinant * view.width, y: (dy * ax - dx * ay) / determinant * view.height };
372
- }
373
- return { x: x + point.x / view.width * ax + point.y / view.height * bx,
374
- y: y + point.x / view.width * ay + point.y / view.height * by };
375
- }
1
+ import { deadline, delivery } from './actionability.js';
2
+ import { offsetOption, timingOption, pause } from './motion.js';
3
+
4
+ // Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
5
+ export function createClick(cdp, value, { prepare, assertReady }) {
6
+ const pending = new Map();
7
+ function event(tabId, method, params, sessionId) {
8
+ const operation = pending.get(tabId);
9
+ if (operation && method === 'Page.javascriptDialogOpening') {
10
+ operation.reject(Object.assign(new Error('The action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'), { outcome_unknown: true }));
11
+ return;
12
+ }
13
+ if (method !== 'Runtime.bindingCalled' || !operation || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
14
+ try {
15
+ const receipt = JSON.parse(params.payload);
16
+ if (receipt.matched === true) operation.resolve();
17
+ else operation.reject(Object.assign(new Error('Click target changed or became covered during mouse dispatch. Further incorrect target input was blocked; outcome is unknown. Observe again before retrying.'), { outcome_unknown: true }));
18
+ } catch (error) { operation.reject(error); }
19
+ }
20
+ function cancel(tabId) { pending.get(tabId)?.reject(Object.assign(new Error('Tab detached during click; outcome is unknown. Observe before retrying.'), { outcome_unknown: true })); }
21
+ async function click(tabId, node, args) {
22
+ const options = { ...clickOptions(args), hover: args.hover === true };
23
+ const budget = args.budget || deadline(args.timeout_ms);
24
+ const prepared = await prepare(tabId, node, { ...args, budget });
25
+ const { target, point: rootPoint, hitTarget, relation, frameId, backendNodeId, boundaries } = prepared;
26
+ if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
27
+ const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
28
+ const guards = [], contexts = new Set();
29
+ let failure;
30
+ const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject(error) { failure = error; reject(error); } }));
31
+ receipt.catch(() => {});
32
+ try {
33
+ const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
34
+ for (const item of targets) {
35
+ const sendTo = (method, params) => budget.run(() => cdp(item.target, method, params));
36
+ const frame = item.frameId;
37
+ const { executionContextId } = await sendTo('Page.createIsolatedWorld', { frameId: frame, worldName: 'browser-attach-input' });
38
+ if (!executionContextId) throw new Error('Could not create an isolated click verification context; no input was dispatched.');
39
+ contexts.add(JSON.stringify([item.target.sessionId, executionContextId]));
40
+ await sendTo('Runtime.addBinding', { name, executionContextId });
41
+ const isolated = await sendTo('DOM.resolveNode', { backendNodeId: item.backendNodeId, executionContextId, objectGroup: 'browser-attach' });
42
+ const installed = await sendTo('Runtime.callFunctionOn', {
43
+ objectId: isolated.object.objectId, functionDeclaration: installGuard.toString(),
44
+ arguments: [{ value: name }, { value: options }, { value: budget.remaining() + 500 }, { value: item.leaf === true }], objectGroup: 'browser-attach',
45
+ });
46
+ if (installed.exceptionDetails) value(installed);
47
+ if (!installed.result?.objectId) throw new Error('Could not install click target verification; no input was dispatched.');
48
+ guards.push({ target: item.target, objectId: installed.result.objectId });
49
+ }
50
+ // Recheck the root page after target preparation: a user may have changed
51
+ // tabs/focus while we resolved a frame or installed the event guards.
52
+ await pause(budget, args.settle_ms);
53
+ if (failure) throw failure;
54
+ const pageState = await assertReady(tabId, budget, options.hover ? 'hover' : 'click', args);
55
+ if (failure) throw failure;
56
+ budget.dispatch();
57
+ const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
58
+ const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
59
+ for (let count = 1; !options.hover && count <= options.click_count; count++) {
60
+ commands.push(dispatch({ type: 'mousePressed', button: options.button, buttons: options.buttons, clickCount: count, modifiers: options.modifiers }));
61
+ commands.push(dispatch({ type: 'mouseReleased', button: options.button, buttons: 0, clickCount: count, modifiers: options.modifiers }));
62
+ }
63
+ await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
64
+ const verification = { target_verified: node ? true : null, delivery_verified: true, events_trusted: true,
65
+ target_verification: { basis: node ? 'element' : 'coordinate_hit', hit_relation: relation } };
66
+ if (options.hover) return { hovered: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', observe_again: true };
67
+ return { clicked: rootPoint, hit_target: hitTarget, page_state: pageState, ...verification, application_result: 'not_checked', coordinate_space: 'viewport_css_pixels', frame_id: node?.frameId || frameId, button: options.button, click_count: options.click_count, observe_again: true };
68
+ } finally {
69
+ pending.delete(tabId);
70
+ // A page handler may have opened a modal. Renderer cleanup can finish after
71
+ // it is dismissed, but must not keep the tab queue locked until then.
72
+ const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
73
+ cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
74
+ let cleanupTimer;
75
+ try { await Promise.race([Promise.allSettled(cleanup),
76
+ new Promise(resolve => { cleanupTimer = setTimeout(resolve, 250); })]); }
77
+ finally { clearTimeout(cleanupTimer); }
78
+ }
79
+ }
80
+ return { click, event, cancel };
81
+ }
82
+
83
+ function installGuard(name, options, expiryMs, verifyDelivery) {
84
+ const target = this.nodeType === 3 ? this.parentElement : this;
85
+ const win = target.ownerDocument.defaultView, report = win[name];
86
+ let rejected = false, completed = 0;
87
+ const types = options.hover ? ['mousemove'] : ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', 'auxclick', 'contextmenu', 'dblclick'];
88
+ const listeners = [];
89
+ function listener(event, expected) {
90
+ if (!event.isTrusted) return;
91
+ const element = target.nodeType === 3 ? target.parentElement : target;
92
+ const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
93
+ const matched = (options.hover || event.button === options.domButton) && modifiers === options.modifiers && !rejected && target.isConnected && !element.matches(':disabled') && !element.closest('[inert]') &&
94
+ event.composedPath().some(node => node === expected);
95
+ if (!matched) {
96
+ rejected = true; event.preventDefault(); event.stopImmediatePropagation();
97
+ report(JSON.stringify({ matched: false }));
98
+ } else {
99
+ const terminal = options.hover ? 'mousemove' : options.button === 'right' ? 'contextmenu' : options.button === 'middle' ? 'auxclick' : options.click_count === 2 ? 'dblclick' : 'click';
100
+ if (verifyDelivery && event.type === terminal && expected === target) {
101
+ completed++;
102
+ if (options.button === 'left' || completed === options.click_count) report(JSON.stringify({ matched: true }));
103
+ }
104
+ }
105
+
106
+ }
107
+ let expiry;
108
+ function stop() {
109
+ clearTimeout(expiry);
110
+ for (const [root, callback] of listeners) for (const type of types) root.removeEventListener(type, callback, true);
111
+ delete win[name];
112
+ }
113
+ // Window listeners see a closed shadow host, not its internal target. Check
114
+ // each boundary in its own root so closed components remain controllable.
115
+ let expected = target;
116
+ while (true) {
117
+ const root = expected.getRootNode(), receiver = root.host ? root : win;
118
+ const localTarget = expected, callback = event => listener(event, localTarget);
119
+ listeners.push([receiver, callback]);
120
+ for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
121
+ if (!root.host) break;
122
+ expected = root.host;
123
+ }
124
+ expiry = win.setTimeout(stop, expiryMs);
125
+ return { stop };
126
+ }
127
+
128
+ export function clickOptions(args) {
129
+ offsetOption(args);
130
+ if (args.settle_ms != null) {
131
+ timingOption(args.settle_ms, 'settle_ms');
132
+ if (args.mode === 'dom') throw new Error('settle_ms requires native pointer input');
133
+ }
134
+ const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
135
+ const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
136
+ if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
137
+ if (![1, 2].includes(click_count)) throw new Error('click_count must be 1 or 2');
138
+ if (!Array.isArray(modifiers) || modifiers.some(key => !Object.hasOwn(masks, key))) throw new Error('modifiers must contain Alt, Control, Meta or Shift');
139
+ if (args.mode === 'dom' && (button !== 'left' || click_count !== 1 || modifiers.length)) throw new Error('DOM click supports only left single click without modifiers');
140
+ return { button, click_count, modifiers: modifiers.reduce((mask, key) => mask | masks[key], 0), buttons: buttons[button][0], domButton: buttons[button][1] };
141
+ }