@evan_zhong/browser-attach 0.21.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.
@@ -0,0 +1,243 @@
1
+ import { deadline, waitForTarget } from './actionability.js';
2
+ import { offsetOption } from './motion.js';
3
+
4
+ export function createPointer(cdp, value, viewport, position, sessions, getTab, getWindow) {
5
+ async function assertReady(tabId, budget, action, args = {}) {
6
+ const inactiveClick = action === 'click' && (args.button ?? 'left') === 'left' &&
7
+ (args.click_count ?? 1) === 1 && (args.modifiers ?? []).length === 0;
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 && !inactiveClick) || 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
+ // A hidden renderer can still receive a complete click, while an
24
+ // isolated move or drag may stall. Delivery still needs a trusted receipt.
25
+ return { ...state, tab_active: tab.active, 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 Object.assign(new Error(`TAB_NOT_READY: Native pointer input not dispatched: ${error.message}. ${guidance}`), { input_dispatched: false });
31
+ } finally { clearTimeout(timer); }
32
+ }
33
+ async function prepare(tabId, node, args) {
34
+ offsetOption(args);
35
+ if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
36
+ if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
37
+ if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
38
+ const budget = args.budget || deadline(args.timeout_ms);
39
+ const obstruction = (detail, diagnostics) => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
40
+ ? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
41
+ : 'No input was dispatched.'}`), { outcome_unknown: budget.has_dispatched(), input_dispatched: budget.has_dispatched(), diagnostics });
42
+ let target = node?.target || { tabId };
43
+ const send = (method, params) => budget.run(() => cdp(target, method, params));
44
+ let point, view;
45
+ if (node) {
46
+ await waitForTarget(cdp, value, target, node, budget);
47
+ if (args.scroll !== false) await send('DOM.scrollIntoViewIfNeeded', { backendNodeId: node.backendNodeId });
48
+ await waitForTarget(cdp, value, target, node, budget);
49
+ const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
50
+ view = await budget.run(() => viewport(target));
51
+ for (const quad of quads || []) {
52
+ point = clippedPoint(quad, view, args.offset);
53
+ if (point) break;
54
+ }
55
+ if (!point) throw obstruction('Element has no visible click point. Inspect a screenshot', {
56
+ reason: 'no_visible_click_point', frame_id: node.frameId, viewport: view, quads: quads || [],
57
+ });
58
+ } else {
59
+ view = await budget.run(() => viewport(target));
60
+ if (args.coordinate_space === 'document') {
61
+ 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');
62
+ const x = args.x - view.scroll_x, y = args.y - view.scroll_y;
63
+ if (args.scroll !== false && (x < 0 || y < 0 || x >= view.width || y >= view.height)) {
64
+ const left = x < 0 || x >= view.width ? Math.max(0, args.x - view.width / 2) : view.scroll_x;
65
+ const top = y < 0 || y >= view.height ? Math.max(0, args.y - view.height / 2) : view.scroll_y;
66
+ value(await send('Runtime.evaluate', { returnByValue: true,
67
+ expression: `scrollTo({left:${left},top:${top},behavior:'instant'})` }));
68
+ view = await budget.run(() => viewport(target));
69
+ }
70
+ point = position(view, args.x - view.scroll_x, args.y - view.scroll_y);
71
+ } else point = position(view, args.x, args.y);
72
+ }
73
+ // DOM hit testing uses document CSS coordinates; Input uses viewport CSS coordinates.
74
+ let hitNode = await send('DOM.getNodeForLocation', {
75
+ x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true,
76
+ });
77
+ // Coordinate input starts in the root viewport; descend into a remote frame
78
+ // before resolving backend ids or installing isolated-world verification.
79
+ const visited = new Set();
80
+ while (!node) {
81
+ const description = await send('DOM.describeNode', { backendNodeId: hitNode.backendNodeId, depth: 0 });
82
+ const childId = description.node.frameId, child = childId && sessions.target(tabId, childId);
83
+ if (!child?.sessionId || child.sessionId === target.sessionId) break;
84
+ if (visited.has(child.sessionId)) throw new Error('Frame hit-test cycle; no input was dispatched.');
85
+ visited.add(child.sessionId);
86
+ const { model } = await send('DOM.getBoxModel', { backendNodeId: hitNode.backendNodeId });
87
+ const childView = await budget.run(() => viewport(child));
88
+ point = framePoint(point, model.content, childView, true);
89
+ target = child; view = childView;
90
+ hitNode = await send('DOM.getNodeForLocation', { x: Math.floor(point.x + view.scroll_x), y: Math.floor(point.y + view.scroll_y), includeUserAgentShadowDOM: true });
91
+ }
92
+ let { backendNodeId } = hitNode;
93
+ const { frameId } = hitNode;
94
+ let hit = await send('DOM.resolveNode', { backendNodeId, objectGroup: 'browser-attach' });
95
+ const pseudo = hit.object.className === 'CSSPseudoElement';
96
+ if (pseudo || hit.object.className === 'ShadowRoot') {
97
+ let localPoint = point;
98
+ if (!pseudo && frameId && (await send('Page.getFrameTree')).frameTree.frame.id !== frameId) {
99
+ // Same-process frame quads are session-relative, while ShadowRoot hit
100
+ // testing expects the owning document's viewport coordinates.
101
+ const owner = await send('DOM.getFrameOwner', { frameId });
102
+ const { model } = await send('DOM.getBoxModel', { backendNodeId: owner.backendNodeId });
103
+ const { executionContextId } = await send('Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' });
104
+ const frameView = value(await send('Runtime.evaluate', { contextId: executionContextId,
105
+ expression: '({width: innerWidth, height: innerHeight})', returnByValue: true }));
106
+ localPoint = framePoint(point, model.content, frameView, true);
107
+ }
108
+ const normalized = await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
109
+ functionDeclaration: normalizeHit.toString(), arguments: [{ value: localPoint.x }, { value: localPoint.y }], objectGroup: 'browser-attach' });
110
+ value(normalized);
111
+ if (!normalized.result?.objectId) throw new Error('Hit element could not be resolved; no input was dispatched.');
112
+ hit = { object: normalized.result };
113
+ const { node: origin } = await send('DOM.describeNode', { objectId: hit.object.objectId });
114
+ backendNodeId = origin.backendNodeId;
115
+ }
116
+ const hitTarget = value(await send('Runtime.callFunctionOn', { objectId: hit.object.objectId,
117
+ functionDeclaration: describeHit.toString(), returnByValue: true }));
118
+ if (pseudo) hitTarget.pseudo_element = true;
119
+ let relation = null;
120
+ if (node) {
121
+ relation = value(await send('Runtime.callFunctionOn', {
122
+ objectId: node.objectId, functionDeclaration: hitRelation.toString(),
123
+ arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
124
+ }));
125
+ if (!relation) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`,
126
+ { reason: 'obstructed', hit_target: hitTarget, point, coordinate_space: 'session_viewport_css_pixels', frame_id: node.frameId, hit_relation: null });
127
+ }
128
+ // Dispatch through the root widget so overlays in ancestor documents cannot
129
+ // be bypassed by sending directly to an OOPIF widget. Guard every boundary.
130
+ const boundaries = [];
131
+ let rootPoint = point, current = target, currentView = view;
132
+ while (current.sessionId) {
133
+ const parent = sessions.parent(tabId, current.sessionId);
134
+ const owner = await budget.run(() => cdp(parent.target, 'DOM.getFrameOwner', { frameId: parent.frameId }));
135
+ const { model } = await budget.run(() => cdp(parent.target, 'DOM.getBoxModel', { backendNodeId: owner.backendNodeId }));
136
+ rootPoint = framePoint(rootPoint, model.content, currentView);
137
+ const parentView = await budget.run(() => viewport(parent.target));
138
+ position(parentView, rootPoint.x, rootPoint.y);
139
+ const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
140
+ x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
141
+ }));
142
+ if (parentHit.backendNodeId !== owner.backendNodeId) {
143
+ let blocker;
144
+ try {
145
+ const diagnostic = budget.child(200);
146
+ const resolved = await diagnostic.run(() => cdp(parent.target, 'DOM.resolveNode', { backendNodeId: parentHit.backendNodeId, objectGroup: 'browser-attach' }));
147
+ blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
148
+ functionDeclaration: describeHit.toString(), returnByValue: true })));
149
+ } catch { /* Preserve the obstruction error even if its node disappears. */ }
150
+ 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)}`,
151
+ { reason: 'frame_obstructed', hit_target: blocker ?? null, point: rootPoint, coordinate_space: 'session_viewport_css_pixels', blocked_frame_id: parent.frameId });
152
+ }
153
+ const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
154
+ boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
155
+ current = parent.target; currentView = parentView;
156
+ }
157
+ return { target, point: rootPoint, hitTarget, relation, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, boundaries };
158
+ }
159
+ return { assertReady, prepare };
160
+ }
161
+
162
+ export function describeHit() {
163
+ const element = this.nodeType === 3 ? this.parentElement : this;
164
+ if (element?.nodeType !== 1) throw new Error('Hit target is not an Element; no input was dispatched.');
165
+ let clickable, disabled = false, nativeSelect = false;
166
+ for (let parent = element; parent; parent = parent.assignedSlot || parent.parentElement || parent.getRootNode().host) {
167
+ if (parent.tagName === 'SELECT' || parent.tagName === 'LABEL' && parent.control?.tagName === 'SELECT') nativeSelect = true;
168
+ if (!clickable && parent.matches('button,a[href],input,select,textarea,summary,[role="button"],[role="link"],[onclick]')) clickable = parent;
169
+ if (parent.matches(':disabled,[inert],[aria-disabled="true"]')) disabled = true;
170
+ }
171
+ const rect = element.getBoundingClientRect(), style = element.ownerDocument.defaultView.getComputedStyle(element);
172
+ return { tag: element.tagName?.toLowerCase(), id: element.id || null, text: (element.innerText || element.textContent || '').slice(0, 300),
173
+ rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, coordinate_space: 'frame_viewport_css_pixels',
174
+ position: style.position, z_index: style.zIndex, visibility: style.visibility,
175
+ role: element.getAttribute('role'), disabled, clickable_hint: !!clickable,
176
+ native_select: nativeSelect,
177
+ clickable_target: clickable ? { tag: clickable.tagName.toLowerCase(), role: clickable.getAttribute('role'),
178
+ text: (clickable.innerText || clickable.textContent || '').slice(0, 300) } : null };
179
+ }
180
+
181
+ export function normalizeHit(x, y) {
182
+ let element = this.element || (this.nodeType === 3 ? this.parentElement : this);
183
+ if (element?.nodeType === 11 && element.host) element = element.elementFromPoint(x, y);
184
+ if (element?.nodeType !== 1) throw new Error('Hit test did not resolve an Element; no input was dispatched.');
185
+ return element;
186
+ }
187
+
188
+ export function hitRelation(hit) {
189
+ if (hit === this) return 'same_node';
190
+ for (let node = hit; node; node = node.assignedSlot || node.parentNode || node.host) {
191
+ if (node === this) return 'composed_descendant';
192
+ }
193
+ return null;
194
+ }
195
+
196
+ export function clippedPoint(quad, view, offset) {
197
+ if (offset) {
198
+ const { x_ratio: x, y_ratio: y } = offset;
199
+ const point = { x: (1 - y) * ((1 - x) * quad[0] + x * quad[2]) + y * ((1 - x) * quad[6] + x * quad[4]),
200
+ y: (1 - y) * ((1 - x) * quad[1] + x * quad[3]) + y * ((1 - x) * quad[7] + x * quad[5]) };
201
+ return point.x >= 0 && point.y >= 0 && point.x < view.width && point.y < view.height ? point : null;
202
+ }
203
+ let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
204
+ for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
205
+ const input = polygon; polygon = [];
206
+ for (let i = 0; i < input.length; i++) {
207
+ const a = input[i], b = input[(i + 1) % input.length];
208
+ const insideA = lower ? a[axis] >= bound : a[axis] <= bound;
209
+ const insideB = lower ? b[axis] >= bound : b[axis] <= bound;
210
+ if (insideA) polygon.push(a);
211
+ if (insideA !== insideB) {
212
+ const t = (bound - a[axis]) / (b[axis] - a[axis]);
213
+ polygon.push({ x: a.x + t * (b.x - a.x), y: a.y + t * (b.y - a.y) });
214
+ }
215
+ }
216
+ }
217
+ const twiceArea = polygon.reduce((sum, a, i) => {
218
+ const b = polygon[(i + 1) % polygon.length]; return sum + a.x * b.y - b.x * a.y;
219
+ }, 0);
220
+ if (Math.abs(twiceArea) < 2) return null;
221
+ const point = { x: 0, y: 0 };
222
+ for (let i = 0; i < polygon.length; i++) {
223
+ const a = polygon[i], b = polygon[(i + 1) % polygon.length], cross = a.x * b.y - b.x * a.y;
224
+ point.x += (a.x + b.x) * cross; point.y += (a.y + b.y) * cross;
225
+ }
226
+ return { x: point.x / (3 * twiceArea), y: point.y / (3 * twiceArea) };
227
+ }
228
+
229
+ // Affine transforms cover translation, scale, rotation and skew. Reject
230
+ // perspective rather than silently clicking a wrongly transformed position.
231
+ export function framePoint(point, quad, view, inverse = false) {
232
+ const [x, y, x1, y1, x2, y2, x3, y3] = quad;
233
+ const ax = x1 - x, ay = y1 - y, bx = x3 - x, by = y3 - y;
234
+ 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.');
235
+ const determinant = ax * by - ay * bx;
236
+ if (Math.abs(determinant) < 0.001) throw new Error('Frame has no visible content area; no input was dispatched.');
237
+ if (inverse) {
238
+ const dx = point.x - x, dy = point.y - y;
239
+ return { x: (dx * by - dy * bx) / determinant * view.width, y: (dy * ax - dx * ay) / determinant * view.height };
240
+ }
241
+ return { x: x + point.x / view.width * ax + point.y / view.height * bx,
242
+ y: y + point.x / view.width * ay + point.y / view.height * by };
243
+ }
@@ -0,0 +1,64 @@
1
+ import { targetOptions } from './locator.js';
2
+ import { deadline } from './actionability.js';
3
+ import { framePoint } from './pointer.js';
4
+
5
+ export function createScreenshots(cdp, value, viewport, sessions, clickVisuals, resolveNode) {
6
+ async function capture(tabId, args) {
7
+ const view = await viewport(tabId);
8
+ const hasTarget = args.ref != null || args.selector != null;
9
+ if (!hasTarget && (args.frame || args.frame_id || args.nth != null || args.selector_type || args.name != null)) throw new Error('Element screenshot frame/nth/type/name requires an element selector');
10
+ if (hasTarget && args.full_page) throw new Error('Element target and full_page are mutually exclusive');
11
+ const clip = hasTarget ? await elementClip(tabId, args, view) : args.full_page ? await fullPageClip(tabId) : null;
12
+ const includeIndicators = args.include_indicators === true;
13
+ const hiddenError = includeIndicators ? null : await clickVisuals.visible(tabId, false);
14
+ if (hiddenError) throw new Error(`Could not hide click feedback for a clean screenshot: ${hiddenError}`);
15
+ try {
16
+ // Render an explicit target surface without activating its tab or window.
17
+ const captureClip = clip || { x: view.scroll_x, y: view.scroll_y, width: view.width, height: view.height, scale: 1 };
18
+ let timer;
19
+ let result;
20
+ try {
21
+ result = await Promise.race([
22
+ cdp(tabId, 'Page.captureScreenshot', { format: 'png', fromSurface: true,
23
+ captureBeyondViewport: true, optimizeForSpeed: true, clip: captureClip }),
24
+ new Promise((resolve, reject) => { timer = setTimeout(() => reject(new Error(
25
+ 'Screenshot did not finish within 5 seconds. An inactive or occluded Chromium page may not render. Use browser_tab_control action=activate before requesting another screenshot, or use DOM/AX.'
26
+ )), 5000); }),
27
+ ]);
28
+ } finally { clearTimeout(timer); }
29
+ return { ...result, capture: { scope: hasTarget ? 'element' : clip ? 'full_page' : 'viewport', viewport: view,
30
+ css_width: clip?.width ?? view.width, css_height: clip?.height ?? view.height,
31
+ image_origin_css: { x: clip?.x ?? view.scroll_x, y: clip?.y ?? view.scroll_y },
32
+ click_coordinate_space: 'viewport_css_pixels', agent_indicators_included: includeIndicators } };
33
+ } finally { await clickVisuals.visible(tabId, true); }
34
+ }
35
+
36
+ async function elementClip(tabId, args, view) {
37
+ targetOptions(args);
38
+ const budget = deadline(5000);
39
+ const node = await resolveNode(tabId, args, budget);
40
+ const { model } = await budget.run(() => cdp(node.target, 'DOM.getBoxModel', { backendNodeId: node.backendNodeId }));
41
+ if (!model?.border) throw new Error('Element has no rendered box');
42
+ let points = Array.from({ length: 4 }, (_, i) => ({ x: model.border[i * 2], y: model.border[i * 2 + 1] })), target = node.target;
43
+ while (target.sessionId) {
44
+ const parent = sessions.parent(tabId, target.sessionId);
45
+ const owner = await budget.run(() => cdp(parent.target, 'DOM.getFrameOwner', { frameId: parent.frameId }));
46
+ const box = await budget.run(() => cdp(parent.target, 'DOM.getBoxModel', { backendNodeId: owner.backendNodeId }));
47
+ const childView = await budget.run(() => viewport(target));
48
+ points = points.map(p => framePoint(p, box.model.content, childView));
49
+ target = parent.target;
50
+ }
51
+ const x = Math.min(...points.map(p => p.x)) + view.scroll_x, y = Math.min(...points.map(p => p.y)) + view.scroll_y;
52
+ const width = Math.max(...points.map(p => p.x)) + view.scroll_x - x, height = Math.max(...points.map(p => p.y)) + view.scroll_y - y;
53
+ if (width <= 0 || height <= 0 || width * height > 40000000 || x < 0 || y < 0) throw new Error('Element capture requires a positive box within the document and at most 40 million pixels');
54
+ return { x, y, width, height, scale: 1 };
55
+ }
56
+
57
+ async function fullPageClip(tabId) {
58
+ const { cssContentSize } = await cdp(tabId, 'Page.getLayoutMetrics');
59
+ if (cssContentSize.width * cssContentSize.height > 40000000) throw new Error('Full-page capture exceeds 40 million pixels; use viewport capture');
60
+ return { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 };
61
+ }
62
+
63
+ return { capture };
64
+ }
@@ -1,42 +1,142 @@
1
- export function scrollOptions(args) {
2
- if (!['dom', 'wheel'].includes(args.mode ?? 'dom')) throw new Error('Scroll mode must be dom or wheel');
3
- if (!Number.isFinite(args.delta_x ?? 0) || args.delta_y != null && !Number.isFinite(args.delta_y)) throw new Error('Scroll deltas must be finite');
4
- if (!args.until && !Number.isFinite(args.delta_y)) throw new Error('delta_y is required without until');
5
- if (args.until) {
6
- if (args.mode === 'wheel') throw new Error('until search requires explicit DOM scroll mode');
7
- const { text, selector } = args.until;
8
- if (typeof text !== 'string' || !text || text.length > 2000 || selector != null && (typeof selector !== 'string' || !selector)) throw new Error('until requires nonempty text and optional CSS selector');
9
- if (!Number.isInteger(args.max_steps ?? 100) || (args.max_steps ?? 100) < 1 || (args.max_steps ?? 100) > 500) throw new Error('max_steps must be 1..500');
10
- }
11
- }
12
-
13
- export function scrollElement(dx, dy) {
14
- if (!this.isConnected) throw new Error('Scroll container is detached');
15
- const before = { x: this.scrollLeft, y: this.scrollTop };
16
- this.scrollBy({ left: dx, top: dy ?? Math.max(1, this.clientHeight * 0.8), behavior: 'instant' });
17
- const after = { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
18
- client_width: this.clientWidth, client_height: this.clientHeight };
19
- return { scrolled: before.x !== after.x || before.y !== after.y, before, after, method: 'dom_scroll', scope: 'element' };
20
- }
21
-
22
- export function scrollState() {
23
- if (!this.isConnected) throw new Error('Scroll container is detached');
24
- return { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
25
- client_width: this.clientWidth, client_height: this.clientHeight };
26
- }
27
-
28
- export function findScrollText(text, selector) {
29
- if (!this.isConnected) throw new Error('Scroll container detached');
30
- const elements = this.querySelectorAll(selector || '*');
31
- if (elements.length > 10000) throw new Error('Scroll search exceeds 10000 candidates; scope until.selector');
32
- const matches = [];
33
- for (const element of elements) {
34
- if (!(element.textContent || '').includes(text)) continue;
35
- const rect = element.getBoundingClientRect();
36
- if (rect.width > 0 && rect.height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible') matches.push(element);
37
- }
38
- // Document order keeps each subtree contiguous, even after filtering.
39
- const leaves = matches.filter((element, index) => index === matches.length - 1 || !element.contains(matches[index + 1]));
40
- if (leaves.length > 1) throw new Error(`Scroll text must match one element; matched ${leaves.length}. Scope until.selector`);
41
- return leaves[0] || null;
42
- }
1
+ import { resolveSelector } from './locator.js';
2
+
3
+ export function scrollOptions(args) {
4
+ if (!['dom', 'wheel'].includes(args.mode ?? 'dom')) throw new Error('Scroll mode must be dom or wheel');
5
+ if (!Number.isFinite(args.delta_x ?? 0) || args.delta_y != null && !Number.isFinite(args.delta_y)) throw new Error('Scroll deltas must be finite');
6
+ if (!args.until && !Number.isFinite(args.delta_y)) throw new Error('delta_y is required without until');
7
+ if (args.until) {
8
+ if (args.mode === 'wheel') throw new Error('until search requires explicit DOM scroll mode');
9
+ const { text, selector } = args.until;
10
+ if (typeof text !== 'string' || !text || text.length > 2000 || selector != null && (typeof selector !== 'string' || !selector)) throw new Error('until requires nonempty text and optional CSS selector');
11
+ if (!Number.isInteger(args.max_steps ?? 100) || (args.max_steps ?? 100) < 1 || (args.max_steps ?? 100) > 500) throw new Error('max_steps must be 1..500');
12
+ }
13
+ }
14
+
15
+ export function scrollElement(dx, dy) {
16
+ if (!this.isConnected) throw new Error('Scroll container is detached');
17
+ const before = { x: this.scrollLeft, y: this.scrollTop };
18
+ this.scrollBy({ left: dx, top: dy ?? Math.max(1, this.clientHeight * 0.8), behavior: 'instant' });
19
+ const after = { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
20
+ client_width: this.clientWidth, client_height: this.clientHeight };
21
+ return { scrolled: before.x !== after.x || before.y !== after.y, before, after, method: 'dom_scroll', scope: 'element' };
22
+ }
23
+
24
+ export function scrollState() {
25
+ if (!this.isConnected) throw new Error('Scroll container is detached');
26
+ return { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
27
+ client_width: this.clientWidth, client_height: this.clientHeight };
28
+ }
29
+
30
+ export function findScrollText(text, selector) {
31
+ if (!this.isConnected) throw new Error('Scroll container detached');
32
+ const elements = this.querySelectorAll(selector || '*');
33
+ if (elements.length > 10000) throw new Error('Scroll search exceeds 10000 candidates; scope until.selector');
34
+ const matches = [];
35
+ for (const element of elements) {
36
+ if (!(element.textContent || '').includes(text)) continue;
37
+ const rect = element.getBoundingClientRect();
38
+ if (rect.width > 0 && rect.height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible') matches.push(element);
39
+ }
40
+ // Document order keeps each subtree contiguous, even after filtering.
41
+ const leaves = matches.filter((element, index) => index === matches.length - 1 || !element.contains(matches[index + 1]));
42
+ if (leaves.length > 1) throw new Error(`Scroll text must match one element; matched ${leaves.length}. Scope until.selector`);
43
+ return leaves[0] || null;
44
+ }
45
+
46
+ export function createScrolling(cdp, value, sessions, pointer, viewport, position, observations, currentClaim) {
47
+ async function scroll(tabId, node, args) {
48
+ if (args.until != null) {
49
+ const { text, selector } = args.until;
50
+ const maxSteps = args.max_steps ?? 100;
51
+ const root = node || await resolveSelector(cdp, value, sessions, tabId, { selector: 'html' }, args.budget);
52
+ const claim = currentClaim(tabId), version = observations.version(tabId), frameVersion = observations.frameVersion(tabId, root.frameId);
53
+ let last;
54
+ for (let step = 0; step <= maxSteps;) {
55
+ if (currentClaim(tabId) !== claim || observations.version(tabId) !== version || observations.frameVersion(tabId, root.frameId) !== frameVersion) throw new Error('Document/frame/control changed during scroll search; observe before continuing');
56
+ const found = await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
57
+ functionDeclaration: findScrollText.toString(), arguments: [{ value: text }, { value: selector }], objectGroup: 'browser-attach-search' }));
58
+ value(found);
59
+ if (found.result?.objectId) {
60
+ const result = await observations.rememberElement(tabId, root.target, found.result.objectId, root.frameId, args.budget);
61
+ await args.budget.run(() => cdp(root.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-search' }));
62
+ return { found: true, ...result, scroll_steps: step, application_result: 'not_checked', observe_again: true };
63
+ }
64
+ if (step === maxSteps) break;
65
+ if (last?.scrolled === false) {
66
+ // A boundary may precede lazy loading. Observe without repeating a
67
+ // no-op scroll until content grows, a match appears, or time runs out.
68
+ const current = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
69
+ functionDeclaration: scrollState.toString(), returnByValue: true })));
70
+ if (JSON.stringify(current) === JSON.stringify(last.after)) { await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50))); continue; }
71
+ }
72
+ args.budget.dispatch();
73
+ last = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
74
+ functionDeclaration: scrollElement.toString(), arguments: [{ value: args.delta_x ?? 0 }, { value: args.delta_y ?? null }], returnByValue: true })));
75
+ step++;
76
+ await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
77
+ }
78
+ throw Object.assign(new Error(`Scroll search did not find ${JSON.stringify(text)} within ${maxSteps} steps; current=${JSON.stringify(last)}. Content may still load later. Scrolling may already have occurred.`), { outcome_unknown: true });
79
+ }
80
+ const mode = args.mode ?? 'dom';
81
+ const budget = args.budget;
82
+ const view = await budget.run(() => viewport(tabId));
83
+ if (node && mode === 'dom') {
84
+ budget.dispatch();
85
+ return { ...value(await budget.run(() => cdp(node.target, 'Runtime.callFunctionOn', { objectId: node.objectId,
86
+ functionDeclaration: scrollElement.toString(), arguments: [{ value: args.delta_x ?? 0 }, { value: args.delta_y }], returnByValue: true }))), observe_again: true };
87
+ }
88
+ const point = node ? (await pointer.prepare(tabId, node, { budget })).point : position(view, args.x ?? view.width / 2, args.y ?? view.height / 2);
89
+ budget.dispatch();
90
+ if (mode === 'wheel') {
91
+ // Root-widget routing respects ancestor overlays and OOPIF boundaries.
92
+ // A protocol acknowledgement does not prove a page handler ran or scrolled.
93
+ await budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', {
94
+ type: 'mouseWheel', ...point, deltaX: args.delta_x ?? 0, deltaY: args.delta_y,
95
+ button: 'none', buttons: 0, modifiers: 0,
96
+ }));
97
+ return { dispatched: true, method: 'native_wheel', at: point, coordinate_space: 'viewport_css_pixels',
98
+ application_result: 'not_checked', observe_again: true };
99
+ }
100
+ const result = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', { returnByValue: true,
101
+ expression: `(${scrollAtPoint.toString()})(${point.x},${point.y},${args.delta_x ?? 0},${args.delta_y})` })));
102
+ return { ...result, observe_again: true };
103
+ }
104
+ return { scroll };
105
+ }
106
+
107
+ function scrollAtPoint(x, y, dx, dy) {
108
+ let doc = document;
109
+ let element = doc.elementFromPoint(x, y);
110
+ const enteredShadowHosts = new Set();
111
+ while (element) {
112
+ if (element.tagName === 'IFRAME') {
113
+ const frame = element;
114
+ const rect = frame.getBoundingClientRect();
115
+ try { doc = frame.contentDocument; } catch { doc = null; }
116
+ if (!doc) throw new Error('Cross-origin iframe scrolling is unavailable; scroll the outer page or use the website controls.');
117
+ x -= rect.left + frame.clientLeft; y -= rect.top + frame.clientTop;
118
+ element = doc.elementFromPoint(x, y);
119
+ continue;
120
+ }
121
+ if (element.shadowRoot && !enteredShadowHosts.has(element)) {
122
+ enteredShadowHosts.add(element);
123
+ const inner = element.shadowRoot.elementFromPoint(x, y);
124
+ if (inner && inner !== element) { element = inner; continue; }
125
+ }
126
+ const style = doc.defaultView.getComputedStyle(element);
127
+ const canX = /auto|scroll|overlay/.test(style.overflowX) && element.scrollWidth > element.clientWidth &&
128
+ ((dx > 0 && element.scrollLeft < element.scrollWidth - element.clientWidth) || (dx < 0 && element.scrollLeft > 0));
129
+ const canY = /auto|scroll|overlay/.test(style.overflowY) && element.scrollHeight > element.clientHeight &&
130
+ ((dy > 0 && element.scrollTop < element.scrollHeight - element.clientHeight) || (dy < 0 && element.scrollTop > 0));
131
+ if (canX || canY) break;
132
+ const parent = element.parentElement || element.getRootNode().host;
133
+ if (!parent) { element = doc.scrollingElement; break; }
134
+ element = parent;
135
+ }
136
+ if (!element) element = doc.scrollingElement;
137
+ const before = { x: element.scrollLeft, y: element.scrollTop };
138
+ element.scrollBy({ left: dx, top: dy, behavior: 'instant' });
139
+ const after = { x: element.scrollLeft, y: element.scrollTop };
140
+ return { scrolled: before.x !== after.x || before.y !== after.y, before, after,
141
+ method: 'dom_scroll', scope: element === doc.scrollingElement ? 'document' : 'element' };
142
+ }
@@ -0,0 +1,57 @@
1
+ export function createUploads(cdp, value) {
2
+ const fileChoosers = new Map();
3
+ async function upload(tabId, node, args) {
4
+ const send = (target, method, params) => args.budget.run(() => cdp(target, method, params));
5
+ if (!Array.isArray(args.files) || args.files.length > 100 || args.files.some(file => typeof file !== 'string' || !file || file.includes('\0'))) {
6
+ throw new Error('files must contain at most 100 valid absolute file paths; [] clears selection');
7
+ }
8
+ const chooser = fileChoosers.get(tabId);
9
+ let target = node;
10
+ if (!target) {
11
+ if (!chooser) throw new Error('No pending file chooser. Supply a fresh file-input/label ref, or click the upload button first.');
12
+ const resolved = await send({ tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) }, 'DOM.resolveNode', { backendNodeId: chooser.backendNodeId, objectGroup: 'browser-attach' });
13
+ if (!resolved.object?.objectId) throw new Error('File chooser input no longer exists. Observe again.');
14
+ target = { objectId: resolved.object.objectId, target: { tabId, ...(chooser.sessionId ? { sessionId: chooser.sessionId } : {}) } };
15
+ }
16
+ const resolved = await send(target.target, 'Runtime.callFunctionOn', {
17
+ objectId: target.objectId, arguments: [{ value: args.files.length }],
18
+ functionDeclaration: `function(count) {
19
+ const input = this.tagName === 'LABEL' ? this.control : this;
20
+ if (!input || input.tagName !== 'INPUT' || input.type !== 'file') throw new Error('Ref is not a file input or associated label');
21
+ if (!input.isConnected) throw new Error('File input is detached');
22
+ if (input.matches(':disabled')) throw new Error('File input is disabled');
23
+ if (input.webkitdirectory) throw new Error('Directory upload is unsupported; use a regular file input');
24
+ if (count > 1 && !input.multiple) throw new Error('Single-file input cannot accept multiple files');
25
+ return input;
26
+ }`, objectGroup: 'browser-attach',
27
+ });
28
+ if (resolved.exceptionDetails) value(resolved);
29
+ if (!resolved.result?.objectId) throw new Error('Could not resolve the file input');
30
+ args.budget.dispatch();
31
+ if (args.files.length === 0) {
32
+ // Chromium's native empty-path assignment does not emit input/change.
33
+ // Like Playwright's empty payload path, explicitly clear and notify the app.
34
+ value(await send(target.target, 'Runtime.callFunctionOn', { objectId: resolved.result.objectId, returnByValue: true,
35
+ functionDeclaration: `function() {
36
+ this.value = '';
37
+ const Event = this.ownerDocument.defaultView.Event;
38
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
39
+ this.dispatchEvent(new Event('change', { bubbles: true }));
40
+ return true;
41
+ }` }));
42
+ } else {
43
+ // Native CDP assignment emits input/change and preserves actual file metadata.
44
+ await send(target.target, 'DOM.setFileInputFiles', { objectId: resolved.result.objectId, files: args.files });
45
+ }
46
+ fileChoosers.delete(tabId);
47
+ return { files_set: args.files.length, cleared: args.files.length === 0, events_trusted: args.files.length > 0, observe_again: true };
48
+ }
49
+ return { upload,
50
+ opened(tabId, params, sessionId) {
51
+ if (params.backendNodeId) fileChoosers.set(tabId, { sessionId, backendNodeId: params.backendNodeId, mode: params.mode, frame_id: params.frameId });
52
+ },
53
+ pending: tabId => fileChoosers.get(tabId),
54
+ forget: tabId => fileChoosers.delete(tabId),
55
+ forgetFrames(tabId, affected) { if (affected.has(fileChoosers.get(tabId)?.frame_id)) fileChoosers.delete(tabId); },
56
+ };
57
+ }