@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/drag.js
CHANGED
|
@@ -1,195 +1,195 @@
|
|
|
1
|
-
import { deadline, delivery } from './actionability.js';
|
|
2
|
-
import { targetOptions } from './locator.js';
|
|
3
|
-
import { offsetOption, timingOption, pause, trajectoryOption, dragPath } from './motion.js';
|
|
4
|
-
|
|
5
|
-
export function dragOptions(args) {
|
|
6
|
-
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
7
|
-
for (const target of [args.source, args.destination]) {
|
|
8
|
-
targetOptions(target, { coordinates: true });
|
|
9
|
-
offsetOption(target);
|
|
10
|
-
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
11
|
-
}
|
|
12
|
-
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
13
|
-
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
14
|
-
timingOption(args.settle_ms ?? 0, 'settle_ms');
|
|
15
|
-
trajectoryOption(args.trajectory);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function createDrag(cdp, value,
|
|
19
|
-
const pending = new Map();
|
|
20
|
-
function event(tabId, method, params, sessionId) {
|
|
21
|
-
const operation = pending.get(tabId);
|
|
22
|
-
if (!operation) return;
|
|
23
|
-
if (['DOM.documentUpdated', 'Page.frameNavigated', 'Page.frameDetached', 'Page.navigatedWithinDocument'].includes(method)) {
|
|
24
|
-
const frameId = params.frame?.id || params.frameId;
|
|
25
|
-
if ([...operation.frames].some(key => { const [session, frame] = JSON.parse(key); return session === (sessionId ?? null) && (!frameId || frame === frameId); })) {
|
|
26
|
-
operation.error = new Error('Drag document/frame changed; outcome is unknown. Observe before continuing.');
|
|
27
|
-
operation.reject(operation.error);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
if (method === 'Page.javascriptDialogOpening') { operation.error = new Error('Drag opened a JavaScript dialog; outcome is unknown. Observe and handle the dialog.'); operation.reject(operation.error); }
|
|
31
|
-
if (method === 'Input.dragIntercepted' && !sessionId) operation.intercept(params.data);
|
|
32
|
-
if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
|
|
33
|
-
try {
|
|
34
|
-
const { type, rejected } = JSON.parse(params.payload);
|
|
35
|
-
if (rejected) { operation.error = new Error('Drag target changed during dispatch; outcome is unknown. Observe before continuing.'); operation.reject(operation.error); }
|
|
36
|
-
operation.seen.add(type);
|
|
37
|
-
const required = operation.html5 ? ['mousedown', 'dragstart', 'drop', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'];
|
|
38
|
-
if (required.every(type => operation.seen.has(type))) operation.resolve();
|
|
39
|
-
} catch (error) { operation.reject(error); }
|
|
40
|
-
}
|
|
41
|
-
function cancel(tabId) { const operation = pending.get(tabId); if (operation) { operation.error = new Error('Tab detached during drag; outcome is unknown.'); operation.reject(operation.error); } }
|
|
42
|
-
async function drag(tabId, args, resolve, assertCurrent = () => {}) {
|
|
43
|
-
dragOptions(args);
|
|
44
|
-
const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
|
|
45
|
-
await
|
|
46
|
-
if (args.source.coordinate_space === 'document' && args.destination.coordinate_space === 'document') {
|
|
47
|
-
const view = await budget.run(() => viewport(tabId)), a = args.source, b = args.destination;
|
|
48
|
-
// Keep both endpoints in one viewport when possible. Centering them
|
|
49
|
-
// independently can erase the requested delta before interpolation.
|
|
50
|
-
if (Math.abs(a.x - b.x) < view.width && Math.abs(a.y - b.y) < view.height) {
|
|
51
|
-
const left = Math.min(a.x, b.x) < view.scroll_x || Math.max(a.x, b.x) >= view.scroll_x + view.width
|
|
52
|
-
? Math.max(0, (a.x + b.x - view.width) / 2) : view.scroll_x;
|
|
53
|
-
const top = Math.min(a.y, b.y) < view.scroll_y || Math.max(a.y, b.y) >= view.scroll_y + view.height
|
|
54
|
-
? Math.max(0, (a.y + b.y - view.height) / 2) : view.scroll_y;
|
|
55
|
-
if (left !== view.scroll_x || top !== view.scroll_y) value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
|
|
56
|
-
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})`, returnByValue: true,
|
|
57
|
-
})));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
const source = await
|
|
61
|
-
const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
|
|
62
|
-
let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
|
|
63
|
-
const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, frames, html5, seen: new Set(), resolve, reject, intercept }));
|
|
64
|
-
receipt.catch(() => {});
|
|
65
|
-
let pressed = false, releaseQueued = false, interceptEnabled = false, point = source.point;
|
|
66
|
-
const check = () => { assertCurrent(); const error = pending.get(tabId)?.error; if (error) throw error; };
|
|
67
|
-
const send = (method, params) => budget.run(() => {
|
|
68
|
-
check();
|
|
69
|
-
if (method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed') pressed = true;
|
|
70
|
-
if (method === 'Input.dispatchMouseEvent' && params.type === 'mouseReleased') releaseQueued = true;
|
|
71
|
-
return delivery(cdp(tabId, method, params));
|
|
72
|
-
});
|
|
73
|
-
const move = p => send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...p, button: pressed ? 'left' : 'none', buttons: pressed ? 1 : 0 });
|
|
74
|
-
async function guard(prepared, stage) {
|
|
75
|
-
for (const item of [prepared, ...prepared.boundaries]) {
|
|
76
|
-
const { target, frameId, backendNodeId } = item;
|
|
77
|
-
frames.add(JSON.stringify([target.sessionId ?? null, frameId]));
|
|
78
|
-
check();
|
|
79
|
-
const call = (method, params) => budget.run(() => cdp(target, method, params));
|
|
80
|
-
const { executionContextId } = await call('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' });
|
|
81
|
-
contexts.add(JSON.stringify([target.sessionId, executionContextId]));
|
|
82
|
-
await call('Runtime.addBinding', { name, executionContextId });
|
|
83
|
-
const isolated = await call('DOM.resolveNode', { backendNodeId, executionContextId, objectGroup: 'browser-attach' });
|
|
84
|
-
const installed = await call('Runtime.callFunctionOn', { objectId: isolated.object.objectId, functionDeclaration: installDragReceipt.toString(),
|
|
85
|
-
arguments: [{ value: name }, { value: item === prepared ? stage : `${stage}_boundary` }, { value: html5 }, { value: budget.remaining() + 500 }], objectGroup: 'browser-attach' });
|
|
86
|
-
value(installed);
|
|
87
|
-
if (!installed.result?.objectId) throw new Error('Could not install drag receipt; observe before continuing');
|
|
88
|
-
guards.push({ target, objectId: installed.result.objectId });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
await guard(source, 'source');
|
|
93
|
-
if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
|
|
94
|
-
await pause(budget, args.settle_ms);
|
|
95
|
-
await
|
|
96
|
-
budget.dispatch();
|
|
97
|
-
await move(point);
|
|
98
|
-
await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', buttons: 1, clickCount: 1 });
|
|
99
|
-
// Cross Chromium's drag threshold before scrolling to an offscreen destination.
|
|
100
|
-
const initial = { x: point.x + (point.x > 20 ? -12 : 12), y: point.y };
|
|
101
|
-
await move(initial); point = initial;
|
|
102
|
-
const data = html5 ? await budget.run(() => delivery(intercepted)) : null;
|
|
103
|
-
// Preparation scrolls the destination into view while the button remains held,
|
|
104
|
-
// including nested containers, then rechecks frame boundaries and occlusion.
|
|
105
|
-
const destination = await
|
|
106
|
-
await guard(destination, 'destination');
|
|
107
|
-
const from = point;
|
|
108
|
-
const path = dragPath(from, destination.point, steps, args.trajectory);
|
|
109
|
-
if (args.trajectory) {
|
|
110
|
-
const view = await budget.run(() => viewport(tabId));
|
|
111
|
-
if (path.some(({ point }) => point.x < 0 || point.y < 0 || point.x >= view.width || point.y >= view.height)) {
|
|
112
|
-
throw new Error('Drag trajectory leaves the viewport. Outcome is unknown; observe before continuing.');
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
|
|
116
|
-
for (const sample of path) {
|
|
117
|
-
point = sample.point;
|
|
118
|
-
if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
|
|
119
|
-
else await move(point);
|
|
120
|
-
await budget.run(() => new Promise(resolve => setTimeout(resolve, sample.dt)));
|
|
121
|
-
}
|
|
122
|
-
if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
|
|
123
|
-
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
|
|
124
|
-
pressed = false;
|
|
125
|
-
await budget.run(() => delivery(receipt));
|
|
126
|
-
return { dragged: true, method: html5 ? 'native_html5' : 'native_pointer', events_trusted: true, delivery_verified: true,
|
|
127
|
-
steps, from: source.point, to: point, application_result: 'not_checked', observe_again: true };
|
|
128
|
-
} finally {
|
|
129
|
-
pending.delete(tabId);
|
|
130
|
-
// Cancel/release only this gesture; cleanup is never a replay of the action.
|
|
131
|
-
const cleanup = [];
|
|
132
|
-
if (pressed && !releaseQueued) {
|
|
133
|
-
if (html5) cleanup.push(cdp(tabId, 'Input.cancelDragging'));
|
|
134
|
-
cleanup.push(cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 }));
|
|
135
|
-
}
|
|
136
|
-
if (interceptEnabled) cleanup.push(cdp(tabId, 'Input.setInterceptDrags', { enabled: false }));
|
|
137
|
-
for (const { target, objectId } of guards) {
|
|
138
|
-
cleanup.push(cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' }));
|
|
139
|
-
cleanup.push(cdp(target, 'Runtime.removeBinding', { name }));
|
|
140
|
-
}
|
|
141
|
-
await delivery(Promise.allSettled(cleanup), 250).catch(() => {});
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return { drag, event, cancel };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function installDragReceipt(name, stage, html5, expiryMs) {
|
|
148
|
-
const target = this, win = target.ownerDocument.defaultView, report = win[name];
|
|
149
|
-
// Cancelling pointerdown suppresses compatibility
|
|
150
|
-
// not prevent a pointer-based control from receiving the drag.
|
|
151
|
-
const down = html5 ? 'mousedown' : 'pointerdown';
|
|
152
|
-
const types = stage === 'source' ? (html5 ? ['mousedown', 'dragstart', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'])
|
|
153
|
-
: stage === 'source_boundary' ? [down] : (html5 ? ['drop'] : ['pointerup']);
|
|
154
|
-
const listeners = [];
|
|
155
|
-
function listener(event, expected) {
|
|
156
|
-
if (!event.isTrusted) return;
|
|
157
|
-
const hit = event.composedPath().includes(expected);
|
|
158
|
-
if ((event.type === down || event.type === 'drop') && (!hit || !target.isConnected)) {
|
|
159
|
-
event.preventDefault(); event.stopImmediatePropagation(); report(JSON.stringify({ rejected: true })); return;
|
|
160
|
-
}
|
|
161
|
-
if (stage.endsWith('_boundary') || expected !== target) return;
|
|
162
|
-
if (['dragstart', 'dragend'].includes(event.type) && !hit) return;
|
|
163
|
-
if (event.type === 'pointermove' && event.buttons !== 1) return;
|
|
164
|
-
if (html5 && !['mousedown', 'dragstart', 'drop', 'dragend'].includes(event.type)) return;
|
|
165
|
-
report(JSON.stringify({ type: event.type }));
|
|
166
|
-
}
|
|
167
|
-
let expected = target;
|
|
168
|
-
while (true) {
|
|
169
|
-
const root = expected.getRootNode(), receiver = root.host ? root : win;
|
|
170
|
-
const local = expected, callback = event => listener(event, local);
|
|
171
|
-
listeners.push({ receiver, callback, types });
|
|
172
|
-
for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
|
|
173
|
-
if (!root.host) break;
|
|
174
|
-
expected = root.host;
|
|
175
|
-
}
|
|
176
|
-
if (stage === 'source' && html5) {
|
|
177
|
-
const callback = event => listener(event, target), types = ['dragend'];
|
|
178
|
-
listeners.push({ receiver: target, callback, types });
|
|
179
|
-
target.addEventListener('dragend', callback, true);
|
|
180
|
-
}
|
|
181
|
-
// Pointer capture and movement can leave the source's closed shadow root.
|
|
182
|
-
if (stage === 'source' && target.getRootNode().host && !html5) {
|
|
183
|
-
const callback = event => listener(event, target), types = ['pointermove', 'pointerup'];
|
|
184
|
-
listeners.push({ receiver: win, callback, types });
|
|
185
|
-
for (const type of types) win.addEventListener(type, callback, { capture: true, passive: false });
|
|
186
|
-
}
|
|
187
|
-
let timer;
|
|
188
|
-
function stop() {
|
|
189
|
-
clearTimeout(timer);
|
|
190
|
-
for (const { receiver, callback, types } of listeners) for (const type of types) receiver.removeEventListener(type, callback, true);
|
|
191
|
-
delete win[name];
|
|
192
|
-
}
|
|
193
|
-
timer = win.setTimeout(stop, expiryMs);
|
|
194
|
-
return { stop };
|
|
195
|
-
}
|
|
1
|
+
import { deadline, delivery } from './actionability.js';
|
|
2
|
+
import { targetOptions } from './locator.js';
|
|
3
|
+
import { offsetOption, timingOption, pause, trajectoryOption, dragPath } from './motion.js';
|
|
4
|
+
|
|
5
|
+
export function dragOptions(args) {
|
|
6
|
+
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
7
|
+
for (const target of [args.source, args.destination]) {
|
|
8
|
+
targetOptions(target, { coordinates: true });
|
|
9
|
+
offsetOption(target);
|
|
10
|
+
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
11
|
+
}
|
|
12
|
+
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
13
|
+
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
14
|
+
timingOption(args.settle_ms ?? 0, 'settle_ms');
|
|
15
|
+
trajectoryOption(args.trajectory);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createDrag(cdp, value, pointer, viewport) {
|
|
19
|
+
const pending = new Map();
|
|
20
|
+
function event(tabId, method, params, sessionId) {
|
|
21
|
+
const operation = pending.get(tabId);
|
|
22
|
+
if (!operation) return;
|
|
23
|
+
if (['DOM.documentUpdated', 'Page.frameNavigated', 'Page.frameDetached', 'Page.navigatedWithinDocument'].includes(method)) {
|
|
24
|
+
const frameId = params.frame?.id || params.frameId;
|
|
25
|
+
if ([...operation.frames].some(key => { const [session, frame] = JSON.parse(key); return session === (sessionId ?? null) && (!frameId || frame === frameId); })) {
|
|
26
|
+
operation.error = Object.assign(new Error('Drag document/frame changed; outcome is unknown. Observe before continuing.'), { outcome_unknown: true });
|
|
27
|
+
operation.reject(operation.error);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (method === 'Page.javascriptDialogOpening') { operation.error = Object.assign(new Error('Drag opened a JavaScript dialog; outcome is unknown. Observe and handle the dialog.'), { outcome_unknown: true }); operation.reject(operation.error); }
|
|
31
|
+
if (method === 'Input.dragIntercepted' && !sessionId) operation.intercept(params.data);
|
|
32
|
+
if (method !== 'Runtime.bindingCalled' || params.name !== operation.name || !operation.contexts.has(JSON.stringify([sessionId, params.executionContextId]))) return;
|
|
33
|
+
try {
|
|
34
|
+
const { type, rejected } = JSON.parse(params.payload);
|
|
35
|
+
if (rejected) { operation.error = Object.assign(new Error('Drag target changed during dispatch; outcome is unknown. Observe before continuing.'), { outcome_unknown: true }); operation.reject(operation.error); }
|
|
36
|
+
operation.seen.add(type);
|
|
37
|
+
const required = operation.html5 ? ['mousedown', 'dragstart', 'drop', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'];
|
|
38
|
+
if (required.every(type => operation.seen.has(type))) operation.resolve();
|
|
39
|
+
} catch (error) { operation.reject(error); }
|
|
40
|
+
}
|
|
41
|
+
function cancel(tabId) { const operation = pending.get(tabId); if (operation) { operation.error = Object.assign(new Error('Tab detached during drag; outcome is unknown.'), { outcome_unknown: true }); operation.reject(operation.error); } }
|
|
42
|
+
async function drag(tabId, args, resolve, assertCurrent = () => {}) {
|
|
43
|
+
dragOptions(args);
|
|
44
|
+
const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
|
|
45
|
+
await pointer.assertReady(tabId, budget, 'drag');
|
|
46
|
+
if (args.source.coordinate_space === 'document' && args.destination.coordinate_space === 'document') {
|
|
47
|
+
const view = await budget.run(() => viewport(tabId)), a = args.source, b = args.destination;
|
|
48
|
+
// Keep both endpoints in one viewport when possible. Centering them
|
|
49
|
+
// independently can erase the requested delta before interpolation.
|
|
50
|
+
if (Math.abs(a.x - b.x) < view.width && Math.abs(a.y - b.y) < view.height) {
|
|
51
|
+
const left = Math.min(a.x, b.x) < view.scroll_x || Math.max(a.x, b.x) >= view.scroll_x + view.width
|
|
52
|
+
? Math.max(0, (a.x + b.x - view.width) / 2) : view.scroll_x;
|
|
53
|
+
const top = Math.min(a.y, b.y) < view.scroll_y || Math.max(a.y, b.y) >= view.scroll_y + view.height
|
|
54
|
+
? Math.max(0, (a.y + b.y - view.height) / 2) : view.scroll_y;
|
|
55
|
+
if (left !== view.scroll_x || top !== view.scroll_y) value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
|
|
56
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})`, returnByValue: true,
|
|
57
|
+
})));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const source = await pointer.prepare(tabId, await resolve(args.source, budget), { ...args.source, budget });
|
|
61
|
+
const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
|
|
62
|
+
let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
|
|
63
|
+
const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, frames, html5, seen: new Set(), resolve, reject, intercept }));
|
|
64
|
+
receipt.catch(() => {});
|
|
65
|
+
let pressed = false, releaseQueued = false, interceptEnabled = false, point = source.point;
|
|
66
|
+
const check = () => { assertCurrent(); const error = pending.get(tabId)?.error; if (error) throw error; };
|
|
67
|
+
const send = (method, params) => budget.run(() => {
|
|
68
|
+
check();
|
|
69
|
+
if (method === 'Input.dispatchMouseEvent' && params.type === 'mousePressed') pressed = true;
|
|
70
|
+
if (method === 'Input.dispatchMouseEvent' && params.type === 'mouseReleased') releaseQueued = true;
|
|
71
|
+
return delivery(cdp(tabId, method, params));
|
|
72
|
+
});
|
|
73
|
+
const move = p => send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...p, button: pressed ? 'left' : 'none', buttons: pressed ? 1 : 0 });
|
|
74
|
+
async function guard(prepared, stage) {
|
|
75
|
+
for (const item of [prepared, ...prepared.boundaries]) {
|
|
76
|
+
const { target, frameId, backendNodeId } = item;
|
|
77
|
+
frames.add(JSON.stringify([target.sessionId ?? null, frameId]));
|
|
78
|
+
check();
|
|
79
|
+
const call = (method, params) => budget.run(() => cdp(target, method, params));
|
|
80
|
+
const { executionContextId } = await call('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-input' });
|
|
81
|
+
contexts.add(JSON.stringify([target.sessionId, executionContextId]));
|
|
82
|
+
await call('Runtime.addBinding', { name, executionContextId });
|
|
83
|
+
const isolated = await call('DOM.resolveNode', { backendNodeId, executionContextId, objectGroup: 'browser-attach' });
|
|
84
|
+
const installed = await call('Runtime.callFunctionOn', { objectId: isolated.object.objectId, functionDeclaration: installDragReceipt.toString(),
|
|
85
|
+
arguments: [{ value: name }, { value: item === prepared ? stage : `${stage}_boundary` }, { value: html5 }, { value: budget.remaining() + 500 }], objectGroup: 'browser-attach' });
|
|
86
|
+
value(installed);
|
|
87
|
+
if (!installed.result?.objectId) throw new Error('Could not install drag receipt; observe before continuing');
|
|
88
|
+
guards.push({ target, objectId: installed.result.objectId });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
await guard(source, 'source');
|
|
93
|
+
if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
|
|
94
|
+
await pause(budget, args.settle_ms);
|
|
95
|
+
await pointer.assertReady(tabId, budget, 'drag');
|
|
96
|
+
budget.dispatch();
|
|
97
|
+
await move(point);
|
|
98
|
+
await send('Input.dispatchMouseEvent', { type: 'mousePressed', ...point, button: 'left', buttons: 1, clickCount: 1 });
|
|
99
|
+
// Cross Chromium's drag threshold before scrolling to an offscreen destination.
|
|
100
|
+
const initial = { x: point.x + (point.x > 20 ? -12 : 12), y: point.y };
|
|
101
|
+
await move(initial); point = initial;
|
|
102
|
+
const data = html5 ? await budget.run(() => delivery(intercepted)) : null;
|
|
103
|
+
// Preparation scrolls the destination into view while the button remains held,
|
|
104
|
+
// including nested containers, then rechecks frame boundaries and occlusion.
|
|
105
|
+
const destination = await pointer.prepare(tabId, await resolve(args.destination, budget), { ...args.destination, budget });
|
|
106
|
+
await guard(destination, 'destination');
|
|
107
|
+
const from = point;
|
|
108
|
+
const path = dragPath(from, destination.point, steps, args.trajectory);
|
|
109
|
+
if (args.trajectory) {
|
|
110
|
+
const view = await budget.run(() => viewport(tabId));
|
|
111
|
+
if (path.some(({ point }) => point.x < 0 || point.y < 0 || point.x >= view.width || point.y >= view.height)) {
|
|
112
|
+
throw Object.assign(new Error('Drag trajectory leaves the viewport. Outcome is unknown; observe before continuing.'), { outcome_unknown: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
|
|
116
|
+
for (const sample of path) {
|
|
117
|
+
point = sample.point;
|
|
118
|
+
if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
|
|
119
|
+
else await move(point);
|
|
120
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, sample.dt)));
|
|
121
|
+
}
|
|
122
|
+
if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
|
|
123
|
+
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
|
|
124
|
+
pressed = false;
|
|
125
|
+
await budget.run(() => delivery(receipt));
|
|
126
|
+
return { dragged: true, method: html5 ? 'native_html5' : 'native_pointer', events_trusted: true, delivery_verified: true,
|
|
127
|
+
steps, from: source.point, to: point, application_result: 'not_checked', observe_again: true };
|
|
128
|
+
} finally {
|
|
129
|
+
pending.delete(tabId);
|
|
130
|
+
// Cancel/release only this gesture; cleanup is never a replay of the action.
|
|
131
|
+
const cleanup = [];
|
|
132
|
+
if (pressed && !releaseQueued) {
|
|
133
|
+
if (html5) cleanup.push(cdp(tabId, 'Input.cancelDragging'));
|
|
134
|
+
cleanup.push(cdp(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 }));
|
|
135
|
+
}
|
|
136
|
+
if (interceptEnabled) cleanup.push(cdp(tabId, 'Input.setInterceptDrags', { enabled: false }));
|
|
137
|
+
for (const { target, objectId } of guards) {
|
|
138
|
+
cleanup.push(cdp(target, 'Runtime.callFunctionOn', { objectId, functionDeclaration: 'function() { this.stop(); }' }));
|
|
139
|
+
cleanup.push(cdp(target, 'Runtime.removeBinding', { name }));
|
|
140
|
+
}
|
|
141
|
+
await delivery(Promise.allSettled(cleanup), 250).catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { drag, event, cancel };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function installDragReceipt(name, stage, html5, expiryMs) {
|
|
148
|
+
const target = this, win = target.ownerDocument.defaultView, report = win[name];
|
|
149
|
+
// Cancelling pointerdown suppresses compatibility pointer down/up, but does
|
|
150
|
+
// not prevent a pointer-based control from receiving the drag.
|
|
151
|
+
const down = html5 ? 'mousedown' : 'pointerdown';
|
|
152
|
+
const types = stage === 'source' ? (html5 ? ['mousedown', 'dragstart', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'])
|
|
153
|
+
: stage === 'source_boundary' ? [down] : (html5 ? ['drop'] : ['pointerup']);
|
|
154
|
+
const listeners = [];
|
|
155
|
+
function listener(event, expected) {
|
|
156
|
+
if (!event.isTrusted) return;
|
|
157
|
+
const hit = event.composedPath().includes(expected);
|
|
158
|
+
if ((event.type === down || event.type === 'drop') && (!hit || !target.isConnected)) {
|
|
159
|
+
event.preventDefault(); event.stopImmediatePropagation(); report(JSON.stringify({ rejected: true })); return;
|
|
160
|
+
}
|
|
161
|
+
if (stage.endsWith('_boundary') || expected !== target) return;
|
|
162
|
+
if (['dragstart', 'dragend'].includes(event.type) && !hit) return;
|
|
163
|
+
if (event.type === 'pointermove' && event.buttons !== 1) return;
|
|
164
|
+
if (html5 && !['mousedown', 'dragstart', 'drop', 'dragend'].includes(event.type)) return;
|
|
165
|
+
report(JSON.stringify({ type: event.type }));
|
|
166
|
+
}
|
|
167
|
+
let expected = target;
|
|
168
|
+
while (true) {
|
|
169
|
+
const root = expected.getRootNode(), receiver = root.host ? root : win;
|
|
170
|
+
const local = expected, callback = event => listener(event, local);
|
|
171
|
+
listeners.push({ receiver, callback, types });
|
|
172
|
+
for (const type of types) receiver.addEventListener(type, callback, { capture: true, passive: false });
|
|
173
|
+
if (!root.host) break;
|
|
174
|
+
expected = root.host;
|
|
175
|
+
}
|
|
176
|
+
if (stage === 'source' && html5) {
|
|
177
|
+
const callback = event => listener(event, target), types = ['dragend'];
|
|
178
|
+
listeners.push({ receiver: target, callback, types });
|
|
179
|
+
target.addEventListener('dragend', callback, true);
|
|
180
|
+
}
|
|
181
|
+
// Pointer capture and movement can leave the source's closed shadow root.
|
|
182
|
+
if (stage === 'source' && target.getRootNode().host && !html5) {
|
|
183
|
+
const callback = event => listener(event, target), types = ['pointermove', 'pointerup'];
|
|
184
|
+
listeners.push({ receiver: win, callback, types });
|
|
185
|
+
for (const type of types) win.addEventListener(type, callback, { capture: true, passive: false });
|
|
186
|
+
}
|
|
187
|
+
let timer;
|
|
188
|
+
function stop() {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
for (const { receiver, callback, types } of listeners) for (const type of types) receiver.removeEventListener(type, callback, true);
|
|
191
|
+
delete win[name];
|
|
192
|
+
}
|
|
193
|
+
timer = win.setTimeout(stop, expiryMs);
|
|
194
|
+
return { stop };
|
|
195
|
+
}
|