@evan_zhong/browser-attach 0.0.0-stage → 0.10.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.
- package/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +169 -3
- package/bin/browser-attach.mjs +43 -0
- package/docs/ARCHITECTURE.md +129 -0
- package/docs/NOSIS-INTEGRATION.md +53 -0
- package/docs/TEST-REPORT.md +39 -0
- package/docs/USAGE.md +370 -0
- package/extension/actionability.js +66 -0
- package/extension/background.js +98 -0
- package/extension/batch.js +32 -0
- package/extension/click-visuals.js +82 -0
- package/extension/click.js +308 -0
- package/extension/controller.js +812 -0
- package/extension/diagnostics.js +205 -0
- package/extension/drag.js +163 -0
- package/extension/evaluate.js +131 -0
- package/extension/icons/BrowserAttach.png +0 -0
- package/extension/input.js +318 -0
- package/extension/limits.js +6 -0
- package/extension/locator.js +115 -0
- package/extension/manifest.json +34 -0
- package/extension/navigation.js +65 -0
- package/extension/options.html +6 -0
- package/extension/options.js +15 -0
- package/extension/outcomes.js +15 -0
- package/extension/popup.html +5 -0
- package/extension/popup.js +10 -0
- package/extension/scroll.js +29 -0
- package/extension/sessions.js +100 -0
- package/extension/snapshot.js +75 -0
- package/extension/text.js +37 -0
- package/extension/ui.css +12 -0
- package/extension/visuals.js +93 -0
- package/extension/wait.js +81 -0
- package/mcp-config.json +8 -0
- package/package.json +54 -6
- package/scripts/check-package.mjs +20 -0
- package/src/client.mjs +91 -0
- package/src/config.mjs +36 -0
- package/src/hub.mjs +197 -0
- package/src/mcp.mjs +167 -0
- package/src/setup.mjs +59 -0
- package/src/upload.mjs +15 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Only transient click feedback is drawn in the page. Control status stays in
|
|
2
|
+
// the native tab strip; no badge or document-start script is installed.
|
|
3
|
+
function installClickVisual() {
|
|
4
|
+
if (globalThis.__localBrowserAttachClickVisual) return;
|
|
5
|
+
let host, ring, pointer, expiry;
|
|
6
|
+
let hidden = false;
|
|
7
|
+
const style = (node, css) => { node.style.cssText = css; return node; };
|
|
8
|
+
function remove() { clearTimeout(expiry); host?.remove(); host = null; }
|
|
9
|
+
function click(x, y) {
|
|
10
|
+
remove();
|
|
11
|
+
host = style(document.createElement('div'), 'position:fixed!important;inset:0!important;z-index:2147483647!important;pointer-events:none!important;contain:layout style!important;');
|
|
12
|
+
host.setAttribute('data-local-browser-attach-click', '');
|
|
13
|
+
host.setAttribute('aria-hidden', 'true'); host.inert = true;
|
|
14
|
+
const shadow = host.attachShadow({ mode: 'closed' });
|
|
15
|
+
ring = style(document.createElement('div'), 'position:absolute;width:26px;height:26px;border:2px solid #e95c3b;border-radius:50%;background:#e95c3b22;box-sizing:border-box;transform:translate(-50%,-50%);pointer-events:none;');
|
|
16
|
+
pointer = style(document.createElement('div'), 'position:absolute;width:28px;height:32px;filter:drop-shadow(0 1px 2px #0008);pointer-events:none;');
|
|
17
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
18
|
+
svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '28'); svg.setAttribute('height', '28');
|
|
19
|
+
const path = document.createElementNS(svg.namespaceURI, 'path');
|
|
20
|
+
path.setAttribute('d', 'M4 2v17l4-4 4 7 4-2-4-7h6L4 2Z');
|
|
21
|
+
path.setAttribute('fill', '#e95c3b'); path.setAttribute('stroke', '#fff'); path.setAttribute('stroke-width', '1.3');
|
|
22
|
+
svg.append(path); pointer.append(svg); shadow.append(ring, pointer);
|
|
23
|
+
document.documentElement?.append(host);
|
|
24
|
+
for (const node of [ring, pointer]) { node.style.left = `${x}px`; node.style.top = `${y}px`; }
|
|
25
|
+
host.style.visibility = hidden ? 'hidden' : 'visible';
|
|
26
|
+
expiry = setTimeout(remove, 1200);
|
|
27
|
+
}
|
|
28
|
+
function visible(show) { hidden = !show; if (host) host.style.visibility = show ? 'visible' : 'hidden'; }
|
|
29
|
+
globalThis.__localBrowserAttachClickVisual = { click, visible, remove };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function createClickVisuals(api) {
|
|
33
|
+
const contexts = new Map();
|
|
34
|
+
const pending = new Map();
|
|
35
|
+
const versions = new Map();
|
|
36
|
+
const send = (tabId, method, params = {}) => api.debugger.sendCommand({ tabId }, method, params);
|
|
37
|
+
async function evaluate(tabId, expression) {
|
|
38
|
+
const version = versions.get(tabId);
|
|
39
|
+
let contextId = contexts.get(tabId);
|
|
40
|
+
if (!contextId) {
|
|
41
|
+
const { frameTree } = await send(tabId, 'Page.getFrameTree');
|
|
42
|
+
({ executionContextId: contextId } = await send(tabId, 'Page.createIsolatedWorld', {
|
|
43
|
+
frameId: frameTree.frame.id, worldName: 'local-browser-attach-click-visual',
|
|
44
|
+
}));
|
|
45
|
+
if (version !== versions.get(tabId)) throw new Error('Click visual was cancelled');
|
|
46
|
+
contexts.set(tabId, contextId);
|
|
47
|
+
}
|
|
48
|
+
const result = await send(tabId, 'Runtime.evaluate', { expression, contextId, returnByValue: true });
|
|
49
|
+
if (result.exceptionDetails) throw new Error(result.exceptionDetails.text || 'Click visual update failed');
|
|
50
|
+
}
|
|
51
|
+
async function visible(tabId, show) {
|
|
52
|
+
await pending.get(tabId)?.catch(() => {});
|
|
53
|
+
if (!contexts.has(tabId)) return null;
|
|
54
|
+
try { await evaluate(tabId, `globalThis.__localBrowserAttachClickVisual?.visible(${!!show})`); return null; }
|
|
55
|
+
catch (error) { return error.message; }
|
|
56
|
+
}
|
|
57
|
+
function forget(tabId) { versions.set(tabId, (versions.get(tabId) || 0) + 1); contexts.delete(tabId); pending.delete(tabId); }
|
|
58
|
+
async function remove(tabId, skipDOM = false) {
|
|
59
|
+
if (skipDOM) { forget(tabId); return; }
|
|
60
|
+
if (!contexts.has(tabId) && !pending.has(tabId)) return;
|
|
61
|
+
let timer;
|
|
62
|
+
try {
|
|
63
|
+
await Promise.race([
|
|
64
|
+
(async () => {
|
|
65
|
+
await pending.get(tabId)?.catch(() => {});
|
|
66
|
+
if (contexts.has(tabId)) await evaluate(tabId, 'globalThis.__localBrowserAttachClickVisual?.remove()');
|
|
67
|
+
})().catch(() => {}),
|
|
68
|
+
new Promise(resolve => { timer = setTimeout(resolve, 250); }),
|
|
69
|
+
]);
|
|
70
|
+
} finally { clearTimeout(timer); forget(tabId); }
|
|
71
|
+
}
|
|
72
|
+
function click(tabId, x, y) {
|
|
73
|
+
const operation = evaluate(tabId, `(${installClickVisual.toString()})();globalThis.__localBrowserAttachClickVisual.click(${JSON.stringify(x)},${JSON.stringify(y)})`);
|
|
74
|
+
pending.set(tabId, operation);
|
|
75
|
+
const done = () => { if (pending.get(tabId) === operation) pending.delete(tabId); };
|
|
76
|
+
operation.then(done, done);
|
|
77
|
+
return operation;
|
|
78
|
+
}
|
|
79
|
+
return { visible, remove, forget, navigated: forget,
|
|
80
|
+
click,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { deadline, waitForTarget, delivery } from './actionability.js';
|
|
2
|
+
|
|
3
|
+
// Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
|
|
4
|
+
export function createClick(cdp, value, viewport, position, sessions, getTab, getWindow) {
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
async function assertReady(tabId, budget) {
|
|
7
|
+
const timeout = Math.min(500, budget.remaining());
|
|
8
|
+
const guidance = 'Call browser_activate_tab 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.';
|
|
9
|
+
let timer;
|
|
10
|
+
try {
|
|
11
|
+
if (timeout <= 0) throw new Error('input deadline expired before the readiness check');
|
|
12
|
+
return await Promise.race([
|
|
13
|
+
(async () => {
|
|
14
|
+
const tab = await getTab(tabId);
|
|
15
|
+
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})`);
|
|
16
|
+
const window = await getWindow(tab.windowId);
|
|
17
|
+
if (window.state === 'minimized') throw new Error('browser window is minimized');
|
|
18
|
+
const state = value(await cdp(tabId, 'Runtime.evaluate', {
|
|
19
|
+
expression: '({visibility_state: document.visibilityState, has_focus: document.hasFocus()})', returnByValue: true,
|
|
20
|
+
}));
|
|
21
|
+
if (typeof state?.visibility_state !== 'string' || typeof state.has_focus !== 'boolean') throw new Error('page readiness unavailable');
|
|
22
|
+
// Occlusion can make an active renderer report hidden without
|
|
23
|
+
// preventing CDP input. Focus/visibility are diagnostics; the isolated
|
|
24
|
+
// event guard below verifies delivery to the intended element.
|
|
25
|
+
return { ...state, window_focused: window.focused };
|
|
26
|
+
})(),
|
|
27
|
+
new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(`readiness check did not respond within ${timeout} ms`)), timeout); }),
|
|
28
|
+
]);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
throw new Error(`TAB_NOT_READY: Native pointer input not dispatched: ${error.message}. ${guidance}`);
|
|
31
|
+
} finally { clearTimeout(timer); }
|
|
32
|
+
}
|
|
33
|
+
function event(tabId, method, params, sessionId) {
|
|
34
|
+
const operation = pending.get(tabId);
|
|
35
|
+
if (operation && method === 'Page.javascriptDialogOpening') {
|
|
36
|
+
operation.reject(new Error('The action opened a JavaScript dialog. It may already have executed; use browser_snapshot and browser_handle_dialog before continuing.'));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (method !== 'Runtime.bindingCalled' || !operation || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
|
|
40
|
+
try {
|
|
41
|
+
const receipt = JSON.parse(params.payload);
|
|
42
|
+
if (receipt.matched === true) operation.resolve();
|
|
43
|
+
else operation.reject(new Error('Click target changed or became covered during mouse dispatch. Incorrect target input was blocked; observe again before retrying.'));
|
|
44
|
+
} catch (error) { operation.reject(error); }
|
|
45
|
+
}
|
|
46
|
+
function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
|
|
47
|
+
async function prepare(tabId, node, args) {
|
|
48
|
+
if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
|
|
49
|
+
if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
|
|
50
|
+
if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
|
|
51
|
+
const budget = args.budget || deadline(args.timeout_ms);
|
|
52
|
+
let target = node?.target || { tabId };
|
|
53
|
+
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
54
|
+
let point, view;
|
|
55
|
+
if (node) {
|
|
56
|
+
await waitForTarget(cdp, value, target, node, budget);
|
|
57
|
+
if (args.scroll !== false) await send('DOM.scrollIntoViewIfNeeded', { backendNodeId: node.backendNodeId });
|
|
58
|
+
await waitForTarget(cdp, value, target, node, budget);
|
|
59
|
+
const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
|
|
60
|
+
view = await budget.run(() => viewport(target));
|
|
61
|
+
for (const quad of quads || []) {
|
|
62
|
+
point = clippedPoint(quad, view);
|
|
63
|
+
if (point) break;
|
|
64
|
+
}
|
|
65
|
+
if (!point) throw new Error('Element has no visible click point. Inspect a screenshot.');
|
|
66
|
+
} else {
|
|
67
|
+
view = await budget.run(() => viewport(target));
|
|
68
|
+
if (args.coordinate_space === 'document') {
|
|
69
|
+
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');
|
|
70
|
+
value(await send('Runtime.evaluate', { returnByValue: true,
|
|
71
|
+
expression: `scrollTo({left:${Math.max(0, args.x - view.width / 2)},top:${Math.max(0, args.y - view.height / 2)},behavior:'instant'})` }));
|
|
72
|
+
view = await budget.run(() => viewport(target));
|
|
73
|
+
point = position(view, args.x - view.scroll_x, args.y - view.scroll_y);
|
|
74
|
+
} else point = position(view, args.x, args.y);
|
|
75
|
+
}
|
|
76
|
+
// DOM hit testing uses document CSS coordinates; Input uses viewport CSS coordinates.
|
|
77
|
+
let hitNode = await send('DOM.getNodeForLocation', {
|
|
78
|
+
x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true,
|
|
79
|
+
});
|
|
80
|
+
// Coordinate input starts in the root viewport; descend into a remote frame
|
|
81
|
+
// before resolving backend ids or installing isolated-world verification.
|
|
82
|
+
const visited = new Set();
|
|
83
|
+
while (!node) {
|
|
84
|
+
const description = await send('DOM.describeNode', { backendNodeId: hitNode.backendNodeId, depth: 0 });
|
|
85
|
+
const childId = description.node.frameId, child = childId && sessions.target(tabId, childId);
|
|
86
|
+
if (!child?.sessionId || child.sessionId === target.sessionId) break;
|
|
87
|
+
if (visited.has(child.sessionId)) throw new Error('Frame hit-test cycle; no input was dispatched.');
|
|
88
|
+
visited.add(child.sessionId);
|
|
89
|
+
const { model } = await send('DOM.getBoxModel', { backendNodeId: hitNode.backendNodeId });
|
|
90
|
+
const childView = await budget.run(() => viewport(child));
|
|
91
|
+
point = framePoint(point, model.content, childView, true);
|
|
92
|
+
target = child; view = childView;
|
|
93
|
+
hitNode = await send('DOM.getNodeForLocation', { x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true });
|
|
94
|
+
}
|
|
95
|
+
let { backendNodeId } = hitNode;
|
|
96
|
+
const { frameId } = hitNode;
|
|
97
|
+
let hit = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
|
|
98
|
+
const pseudo = hit.object.className === 'CSSPseudoElement';
|
|
99
|
+
if (pseudo) {
|
|
100
|
+
// Pseudo-elements are event targets through their originating element,
|
|
101
|
+
// but their Runtime wrappers are not Elements and have no matches().
|
|
102
|
+
const normalized = await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
|
|
103
|
+
functionDeclaration: 'function() { if (this.element?.nodeType !== 1) throw new Error("Pseudo-element has no originating element; no input was dispatched."); return this.element; }', objectGroup: 'browser-attach' });
|
|
104
|
+
value(normalized);
|
|
105
|
+
if (!normalized.result?.objectId) throw new Error('Pseudo-element could not be resolved; no input was dispatched.');
|
|
106
|
+
hit = { object: normalized.result };
|
|
107
|
+
const { node: origin } = await send('DOM.describeNode', { objectId: hit.object.objectId });
|
|
108
|
+
backendNodeId = origin.backendNodeId;
|
|
109
|
+
}
|
|
110
|
+
const hitTarget = value(await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
|
|
111
|
+
functionDeclaration: describeHit.toString(), returnByValue: true }));
|
|
112
|
+
if (pseudo) hitTarget.pseudo_element = true;
|
|
113
|
+
if (node) {
|
|
114
|
+
const unblocked = value(await send('Runtime.callFunctionOn', {
|
|
115
|
+
objectId: node.objectId, functionDeclaration: 'function(hit) { for (let e = hit; e; e = e.parentNode || e.host) if (e === this) return true; return false; }',
|
|
116
|
+
arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
|
|
117
|
+
}));
|
|
118
|
+
if (!unblocked) throw new Error('Another element covers this ref. Inspect a new snapshot or screenshot.');
|
|
119
|
+
}
|
|
120
|
+
// Dispatch through the root widget so overlays in ancestor documents cannot
|
|
121
|
+
// be bypassed by sending directly to an OOPIF widget. Guard every boundary.
|
|
122
|
+
const boundaries = [];
|
|
123
|
+
let rootPoint = point, current = target, currentView = view;
|
|
124
|
+
while (current.sessionId) {
|
|
125
|
+
const parent = sessions.parent(tabId, current.sessionId);
|
|
126
|
+
const owner = await budget.run(() => cdp(parent.target, 'DOM.getFrameOwner', { frameId: parent.frameId }));
|
|
127
|
+
const { model } = await budget.run(() => cdp(parent.target, 'DOM.getBoxModel', { backendNodeId: owner.backendNodeId }));
|
|
128
|
+
rootPoint = framePoint(rootPoint, model.content, currentView);
|
|
129
|
+
const parentView = await budget.run(() => viewport(parent.target));
|
|
130
|
+
position(parentView, rootPoint.x, rootPoint.y);
|
|
131
|
+
const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
|
|
132
|
+
x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
|
|
133
|
+
}));
|
|
134
|
+
if (parentHit.backendNodeId !== owner.backendNodeId) throw new Error('Another element covers this frame. Inspect a new snapshot or screenshot.');
|
|
135
|
+
const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
|
|
136
|
+
boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
|
|
137
|
+
current = parent.target; currentView = parentView;
|
|
138
|
+
}
|
|
139
|
+
return { target, point: rootPoint, hitTarget, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, boundaries };
|
|
140
|
+
}
|
|
141
|
+
async function click(tabId, node, args) {
|
|
142
|
+
const options = { ...clickOptions(args), hover: args.hover === true };
|
|
143
|
+
const budget = args.budget || deadline(args.timeout_ms);
|
|
144
|
+
const prepared = await prepare(tabId, node, { ...args, budget });
|
|
145
|
+
const { target, point: rootPoint, hitTarget, frameId, backendNodeId, boundaries } = prepared;
|
|
146
|
+
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.");
|
|
147
|
+
const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
|
|
148
|
+
const guards = [], contexts = new Set();
|
|
149
|
+
const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
|
|
150
|
+
receipt.catch(() => {});
|
|
151
|
+
try {
|
|
152
|
+
const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
|
|
153
|
+
for (const item of targets) {
|
|
154
|
+
const sendTo = (method, params) => budget.run(() => cdp(item.target, method, params));
|
|
155
|
+
const frame = item.frameId;
|
|
156
|
+
const { executionContextId } = await sendTo('Page.createIsolatedWorld', { frameId: frame, worldName: 'browser-attach-input' });
|
|
157
|
+
if (!executionContextId) throw new Error('Could not create an isolated click verification context; no input was dispatched.');
|
|
158
|
+
contexts.add(JSON.stringify([item.target.sessionId, executionContextId]));
|
|
159
|
+
await sendTo('Runtime.addBinding', { name, executionContextId });
|
|
160
|
+
const isolated = await sendTo('DOM.resolveNode', { backendNodeId: item.backendNodeId, executionContextId, objectGroup: 'browser-attach' });
|
|
161
|
+
const installed = await sendTo('Runtime.callFunctionOn', {
|
|
162
|
+
objectId: isolated.object.objectId, functionDeclaration: installGuard.toString(),
|
|
163
|
+
arguments: [{ value: name }, { value: options }, { value: budget.remaining() + 500 }, { value: item.leaf === true }], objectGroup: 'browser-attach',
|
|
164
|
+
});
|
|
165
|
+
if (installed.exceptionDetails) value(installed);
|
|
166
|
+
if (!installed.result?.objectId) throw new Error('Could not install click target verification; no input was dispatched.');
|
|
167
|
+
guards.push({ target: item.target, objectId: installed.result.objectId });
|
|
168
|
+
}
|
|
169
|
+
// Recheck the root page after target preparation: a user may have changed
|
|
170
|
+
// tabs/focus while we resolved a frame or installed the event guards.
|
|
171
|
+
const pageState = await assertReady(tabId, budget);
|
|
172
|
+
budget.dispatch();
|
|
173
|
+
const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
|
|
174
|
+
const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
|
|
175
|
+
for (let count = 1; !options.hover && count <= options.click_count; count++) {
|
|
176
|
+
commands.push(dispatch({ type: 'mousePressed', button: options.button, buttons: options.buttons, clickCount: count, modifiers: options.modifiers }));
|
|
177
|
+
commands.push(dispatch({ type: 'mouseReleased', button: options.button, buttons: 0, clickCount: count, modifiers: options.modifiers }));
|
|
178
|
+
}
|
|
179
|
+
await budget.run(() => delivery(Promise.all([Promise.all(commands), receipt])));
|
|
180
|
+
if (options.hover) return { hovered: rootPoint, hit_target: hitTarget, page_state: pageState, target_verified: true, events_trusted: true, application_result: 'not_checked', observe_again: true };
|
|
181
|
+
return { clicked: rootPoint, hit_target: hitTarget, page_state: pageState, target_verified: true, 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 };
|
|
182
|
+
} finally {
|
|
183
|
+
pending.delete(tabId);
|
|
184
|
+
// A page handler may have opened a modal. Renderer cleanup can finish after
|
|
185
|
+
// it is dismissed, but must not keep the tab queue locked until then.
|
|
186
|
+
const cleanup = guards.flatMap(({ target, objectId }) => [cdp(target, 'Runtime.removeBinding', { name }),
|
|
187
|
+
cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' })]);
|
|
188
|
+
let cleanupTimer;
|
|
189
|
+
try { await Promise.race([Promise.allSettled(cleanup),
|
|
190
|
+
new Promise(resolve => { cleanupTimer = setTimeout(resolve, 250); })]); }
|
|
191
|
+
finally { clearTimeout(cleanupTimer); }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { assertReady, prepare, click, event, cancel };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function describeHit() {
|
|
198
|
+
const element = this.nodeType === 3 ? this.parentElement : this;
|
|
199
|
+
let clickable, disabled = false, nativeSelect = false;
|
|
200
|
+
for (let parent = element; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
|
|
201
|
+
if (parent.tagName === 'SELECT' || parent.tagName === 'LABEL' && parent.control?.tagName === 'SELECT') nativeSelect = true;
|
|
202
|
+
if (!clickable && parent.matches('button,a[href],input,select,textarea,summary,[role="button"],[role="link"],[onclick]')) clickable = parent;
|
|
203
|
+
if (parent.matches(':disabled,[inert],[aria-disabled="true"]')) disabled = true;
|
|
204
|
+
}
|
|
205
|
+
return { tag: element.tagName?.toLowerCase(), text: (element.innerText || element.textContent || '').slice(0, 300),
|
|
206
|
+
role: element.getAttribute('role'), disabled, clickable_hint: !!clickable,
|
|
207
|
+
native_select: nativeSelect,
|
|
208
|
+
clickable_target: clickable ? { tag: clickable.tagName.toLowerCase(), role: clickable.getAttribute('role'),
|
|
209
|
+
text: (clickable.innerText || clickable.textContent || '').slice(0, 300) } : null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function installGuard(name, options, expiryMs, verifyDelivery) {
|
|
213
|
+
const target = this.nodeType === 3 ? this.parentElement : this;
|
|
214
|
+
const win = target.ownerDocument.defaultView, report = win[name];
|
|
215
|
+
let rejected = false, completed = 0;
|
|
216
|
+
const types = options.hover ? ['mousemove'] : ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', 'auxclick', 'contextmenu', 'dblclick'];
|
|
217
|
+
const listeners = [];
|
|
218
|
+
function listener(event, expected) {
|
|
219
|
+
if (!event.isTrusted) return;
|
|
220
|
+
const element = target.nodeType === 3 ? target.parentElement : target;
|
|
221
|
+
const modifiers = (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0);
|
|
222
|
+
const matched = (options.hover || event.button === options.domButton) && modifiers === options.modifiers && !rejected && target.isConnected && !element.matches(':disabled') && !element.closest('[inert]') &&
|
|
223
|
+
event.composedPath().some(node => node === expected);
|
|
224
|
+
if (!matched) {
|
|
225
|
+
rejected = true; event.preventDefault(); event.stopImmediatePropagation();
|
|
226
|
+
report(JSON.stringify({ matched: false }));
|
|
227
|
+
} else {
|
|
228
|
+
const terminal = options.hover ? 'mousemove' : options.button === 'right' ? 'contextmenu' : options.button === 'middle' ? 'auxclick' : options.click_count === 2 ? 'dblclick' : 'click';
|
|
229
|
+
if (verifyDelivery && event.type === terminal && expected === target) {
|
|
230
|
+
completed++;
|
|
231
|
+
if (options.button === 'left' || completed === options.click_count) report(JSON.stringify({ matched: true }));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
}
|
|
236
|
+
let expiry;
|
|
237
|
+
function stop() {
|
|
238
|
+
clearTimeout(expiry);
|
|
239
|
+
for (const [root, callback] of listeners) for (const type of types) root.removeEventListener(type, callback, true);
|
|
240
|
+
delete win[name];
|
|
241
|
+
}
|
|
242
|
+
// Window listeners see a closed shadow host, not its internal target. Check
|
|
243
|
+
// each boundary in its own root so closed components remain controllable.
|
|
244
|
+
let expected = target;
|
|
245
|
+
while (true) {
|
|
246
|
+
const root = expected.getRootNode(), receiver = root.host ? root : win;
|
|
247
|
+
const localTarget = expected, callback = event => listener(event, localTarget);
|
|
248
|
+
listeners.push([receiver, callback]);
|
|
249
|
+
for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
|
|
250
|
+
if (!root.host) break;
|
|
251
|
+
expected = root.host;
|
|
252
|
+
}
|
|
253
|
+
expiry = win.setTimeout(stop, expiryMs);
|
|
254
|
+
return { stop };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function clippedPoint(quad, view) {
|
|
258
|
+
let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
|
|
259
|
+
for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
|
|
260
|
+
const input = polygon; polygon = [];
|
|
261
|
+
for (let i = 0; i < input.length; i++) {
|
|
262
|
+
const a = input[i], b = input[(i + 1) % input.length];
|
|
263
|
+
const insideA = lower ? a[axis] >= bound : a[axis] <= bound;
|
|
264
|
+
const insideB = lower ? b[axis] >= bound : b[axis] <= bound;
|
|
265
|
+
if (insideA) polygon.push(a);
|
|
266
|
+
if (insideA !== insideB) {
|
|
267
|
+
const t = (bound - a[axis]) / (b[axis] - a[axis]);
|
|
268
|
+
polygon.push({ x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const twiceArea = polygon.reduce((sum, a, i) => {
|
|
273
|
+
const b = polygon[(i + 1) % polygon.length]; return sum + a.x * b.y - b.x * a.y;
|
|
274
|
+
}, 0);
|
|
275
|
+
if (Math.abs(twiceArea) < 2) return null;
|
|
276
|
+
const point = { x: 0, y: 0 };
|
|
277
|
+
for (let i = 0; i < polygon.length; i++) {
|
|
278
|
+
const a = polygon[i], b = polygon[(i + 1) % polygon.length], cross = a.x * b.y - b.x * a.y;
|
|
279
|
+
point.x += (a.x + b.x) * cross; point.y += (a.y + b.y) * cross;
|
|
280
|
+
}
|
|
281
|
+
return { x: point.x / (3 * twiceArea), y: point.y / (3 * twiceArea) };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function clickOptions(args) {
|
|
285
|
+
const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
|
|
286
|
+
const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
287
|
+
if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
|
|
288
|
+
if (![1, 2].includes(click_count)) throw new Error('click_count must be 1 or 2');
|
|
289
|
+
if (!Array.isArray(modifiers) || modifiers.some(key => !Object.hasOwn(masks, key))) throw new Error('modifiers must contain Alt, Control, Meta or Shift');
|
|
290
|
+
if (args.mode === 'dom' && (button !== 'left' || click_count !== 1 || modifiers.length)) throw new Error('DOM click supports only left single click without modifiers');
|
|
291
|
+
return { button, click_count, modifiers: modifiers.reduce((mask, key) => mask | masks[key], 0), buttons: buttons[button][0], domButton: buttons[button][1] };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Affine transforms cover translation, scale, rotation and skew. Reject
|
|
295
|
+
// perspective rather than silently clicking a wrongly transformed position.
|
|
296
|
+
export function framePoint(point, quad, view, inverse = false) {
|
|
297
|
+
const [x, y, x1, y1, x2, y2, x3, y3] = quad;
|
|
298
|
+
const ax = x1 - x, ay = y1 - y, bx = x3 - x, by = y3 - y;
|
|
299
|
+
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.');
|
|
300
|
+
const determinant = ax * by - ay * bx;
|
|
301
|
+
if (Math.abs(determinant) < 0.001) throw new Error('Frame has no visible content area; no input was dispatched.');
|
|
302
|
+
if (inverse) {
|
|
303
|
+
const dx = point.x - x, dy = point.y - y;
|
|
304
|
+
return { x: (dx * by - dy * bx) / determinant * view.width, y: (dy * ax - dx * ay) / determinant * view.height };
|
|
305
|
+
}
|
|
306
|
+
return { x: x + point.x / view.width * ax + point.y / view.height * bx,
|
|
307
|
+
y: y + point.x / view.width * ay + point.y / view.height * by };
|
|
308
|
+
}
|