@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.
- package/README.md +2 -2
- package/bin/browser-attach.mjs +44 -43
- package/docs/ARCHITECTURE.md +22 -11
- package/docs/TEST-REPORT.md +30 -1
- package/docs/USAGE.md +24 -4
- package/extension/actionability.js +96 -72
- package/extension/click.js +141 -375
- package/extension/controller.js +430 -968
- package/extension/diagnostics.js +167 -241
- package/extension/dialogs.js +73 -0
- package/extension/drag.js +195 -195
- package/extension/evaluate.js +133 -133
- package/extension/fetch-page.js +131 -0
- package/extension/input.js +342 -339
- package/extension/log-data.js +29 -0
- package/extension/manifest.json +1 -1
- package/extension/navigation.js +74 -74
- package/extension/network.js +79 -0
- package/extension/observation.js +232 -0
- package/extension/outcomes.js +71 -15
- package/extension/page-url.js +8 -0
- package/extension/pointer.js +243 -0
- package/extension/screenshot.js +64 -0
- package/extension/scroll.js +142 -42
- package/extension/upload.js +57 -0
- package/extension/wait.js +117 -93
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -0
- package/src/client.mjs +50 -135
- package/src/config.mjs +1 -1
- package/src/hub-runtime.mjs +87 -0
- package/src/mcp.mjs +216 -208
- package/docs/DESIGN-REVIEW-0.15.1.md +0 -27
package/extension/click.js
CHANGED
|
@@ -1,375 +1,141 @@
|
|
|
1
|
-
import { deadline,
|
|
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,
|
|
6
|
-
const pending = new Map();
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
await
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
}
|