cdp-skill 1.0.2 → 1.0.4
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 +3 -0
- package/SKILL.md +34 -5
- package/package.json +2 -1
- package/src/capture/console-capture.js +241 -0
- package/src/capture/debug-capture.js +144 -0
- package/src/capture/error-aggregator.js +151 -0
- package/src/capture/eval-serializer.js +320 -0
- package/src/capture/index.js +40 -0
- package/src/capture/network-capture.js +211 -0
- package/src/capture/pdf-capture.js +256 -0
- package/src/capture/screenshot-capture.js +325 -0
- package/src/cdp/browser.js +569 -0
- package/src/cdp/connection.js +369 -0
- package/src/cdp/discovery.js +138 -0
- package/src/cdp/index.js +29 -0
- package/src/cdp/target-and-session.js +439 -0
- package/src/cdp-skill.js +25 -11
- package/src/constants.js +79 -0
- package/src/dom/actionability.js +638 -0
- package/src/dom/click-executor.js +923 -0
- package/src/dom/element-handle.js +496 -0
- package/src/dom/element-locator.js +475 -0
- package/src/dom/element-validator.js +120 -0
- package/src/dom/fill-executor.js +489 -0
- package/src/dom/index.js +248 -0
- package/src/dom/input-emulator.js +406 -0
- package/src/dom/keyboard-executor.js +202 -0
- package/src/dom/quad-helpers.js +89 -0
- package/src/dom/react-filler.js +94 -0
- package/src/dom/wait-executor.js +423 -0
- package/src/index.js +6 -6
- package/src/page/cookie-manager.js +202 -0
- package/src/page/dom-stability.js +181 -0
- package/src/page/index.js +36 -0
- package/src/{page.js → page/page-controller.js} +109 -839
- package/src/page/wait-utilities.js +302 -0
- package/src/page/web-storage-manager.js +108 -0
- package/src/runner/context-helpers.js +224 -0
- package/src/runner/execute-browser.js +518 -0
- package/src/runner/execute-form.js +315 -0
- package/src/runner/execute-input.js +308 -0
- package/src/runner/execute-interaction.js +672 -0
- package/src/runner/execute-navigation.js +180 -0
- package/src/runner/execute-query.js +771 -0
- package/src/runner/index.js +51 -0
- package/src/runner/step-executors.js +421 -0
- package/src/runner/step-validator.js +641 -0
- package/src/tests/Actionability.test.js +613 -0
- package/src/tests/BrowserClient.test.js +1 -1
- package/src/tests/ChromeDiscovery.test.js +1 -1
- package/src/tests/ClickExecutor.test.js +554 -0
- package/src/tests/ConsoleCapture.test.js +1 -1
- package/src/tests/ContextHelpers.test.js +453 -0
- package/src/tests/CookieManager.test.js +450 -0
- package/src/tests/DebugCapture.test.js +307 -0
- package/src/tests/ElementHandle.test.js +1 -1
- package/src/tests/ElementLocator.test.js +1 -1
- package/src/tests/ErrorAggregator.test.js +1 -1
- package/src/tests/EvalSerializer.test.js +391 -0
- package/src/tests/FillExecutor.test.js +611 -0
- package/src/tests/InputEmulator.test.js +1 -1
- package/src/tests/KeyboardExecutor.test.js +430 -0
- package/src/tests/NetworkErrorCapture.test.js +1 -1
- package/src/tests/PageController.test.js +1 -1
- package/src/tests/PdfCapture.test.js +333 -0
- package/src/tests/ScreenshotCapture.test.js +1 -1
- package/src/tests/SessionRegistry.test.js +1 -1
- package/src/tests/StepValidator.test.js +527 -0
- package/src/tests/TargetManager.test.js +1 -1
- package/src/tests/TestRunner.test.js +1 -1
- package/src/tests/WaitStrategy.test.js +1 -1
- package/src/tests/WaitUtilities.test.js +508 -0
- package/src/tests/WebStorageManager.test.js +333 -0
- package/src/types.js +309 -0
- package/src/capture.js +0 -1400
- package/src/cdp.js +0 -1286
- package/src/dom.js +0 -4379
- package/src/runner.js +0 -3676
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actionability Checker
|
|
3
|
+
* Playwright-style auto-waiting for element actionability
|
|
4
|
+
*
|
|
5
|
+
* EXPORTS:
|
|
6
|
+
* - createActionabilityChecker(session) → ActionabilityChecker
|
|
7
|
+
* Methods: waitForActionable, getClickablePoint, checkHitTarget, checkPointerEvents,
|
|
8
|
+
* checkCovered, checkVisible, checkEnabled, checkEditable, checkStable,
|
|
9
|
+
* getRequiredStates, scrollUntilVisible
|
|
10
|
+
*
|
|
11
|
+
* DEPENDENCIES:
|
|
12
|
+
* - ../constants.js: TIMEOUTS
|
|
13
|
+
* - ../utils.js: sleep, releaseObject
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { TIMEOUTS } from '../constants.js';
|
|
17
|
+
import { sleep, releaseObject } from '../utils.js';
|
|
18
|
+
|
|
19
|
+
// Configurable stability check frame count
|
|
20
|
+
const stableFrameCount = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Create an actionability checker for Playwright-style auto-waiting
|
|
24
|
+
* @param {Object} session - CDP session
|
|
25
|
+
* @returns {Object} Actionability checker interface
|
|
26
|
+
*/
|
|
27
|
+
export function createActionabilityChecker(session) {
|
|
28
|
+
// Simplified: removed stability check, shorter retry delays
|
|
29
|
+
const retryDelays = [0, 50, 100, 200];
|
|
30
|
+
|
|
31
|
+
function getRequiredStates(actionType) {
|
|
32
|
+
// Removed 'stable' requirement - it caused timeouts on elements with CSS transitions
|
|
33
|
+
// Zero-size elements are handled separately with JS click fallback
|
|
34
|
+
switch (actionType) {
|
|
35
|
+
case 'click':
|
|
36
|
+
return ['attached']; // Just check element exists and is connected
|
|
37
|
+
case 'hover':
|
|
38
|
+
return ['attached'];
|
|
39
|
+
case 'fill':
|
|
40
|
+
case 'type':
|
|
41
|
+
return ['attached', 'editable'];
|
|
42
|
+
case 'select':
|
|
43
|
+
return ['attached'];
|
|
44
|
+
default:
|
|
45
|
+
return ['attached'];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function findElementInternal(selector) {
|
|
50
|
+
try {
|
|
51
|
+
const result = await session.send('Runtime.evaluate', {
|
|
52
|
+
expression: `document.querySelector(${JSON.stringify(selector)})`,
|
|
53
|
+
returnByValue: false
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (result.result.subtype === 'null' || !result.result.objectId) {
|
|
57
|
+
return { success: false, error: `Element not found: ${selector}` };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { success: true, objectId: result.result.objectId };
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return { success: false, error: error.message };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function checkVisible(objectId) {
|
|
67
|
+
try {
|
|
68
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
69
|
+
objectId,
|
|
70
|
+
functionDeclaration: `function() {
|
|
71
|
+
const el = this;
|
|
72
|
+
if (!el.isConnected) {
|
|
73
|
+
return { matches: false, received: 'detached' };
|
|
74
|
+
}
|
|
75
|
+
const style = window.getComputedStyle(el);
|
|
76
|
+
if (style.visibility === 'hidden') {
|
|
77
|
+
return { matches: false, received: 'visibility:hidden' };
|
|
78
|
+
}
|
|
79
|
+
if (style.display === 'none') {
|
|
80
|
+
return { matches: false, received: 'display:none' };
|
|
81
|
+
}
|
|
82
|
+
const rect = el.getBoundingClientRect();
|
|
83
|
+
if (rect.width === 0 || rect.height === 0) {
|
|
84
|
+
return { matches: false, received: 'zero-size' };
|
|
85
|
+
}
|
|
86
|
+
if (parseFloat(style.opacity) === 0) {
|
|
87
|
+
return { matches: false, received: 'opacity:0' };
|
|
88
|
+
}
|
|
89
|
+
return { matches: true, received: 'visible' };
|
|
90
|
+
}`,
|
|
91
|
+
returnByValue: true
|
|
92
|
+
});
|
|
93
|
+
return result.result.value;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return { matches: false, received: 'error', error: error.message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function checkEnabled(objectId) {
|
|
100
|
+
try {
|
|
101
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
102
|
+
objectId,
|
|
103
|
+
functionDeclaration: `function() {
|
|
104
|
+
const el = this;
|
|
105
|
+
if (el.disabled === true) {
|
|
106
|
+
return { matches: false, received: 'disabled' };
|
|
107
|
+
}
|
|
108
|
+
if (el.getAttribute('aria-disabled') === 'true') {
|
|
109
|
+
return { matches: false, received: 'aria-disabled' };
|
|
110
|
+
}
|
|
111
|
+
const fieldset = el.closest('fieldset');
|
|
112
|
+
if (fieldset && fieldset.disabled) {
|
|
113
|
+
const legend = fieldset.querySelector('legend');
|
|
114
|
+
if (!legend || !legend.contains(el)) {
|
|
115
|
+
return { matches: false, received: 'fieldset-disabled' };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { matches: true, received: 'enabled' };
|
|
119
|
+
}`,
|
|
120
|
+
returnByValue: true
|
|
121
|
+
});
|
|
122
|
+
return result.result.value;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
return { matches: false, received: 'error', error: error.message };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function checkEditable(objectId) {
|
|
129
|
+
const enabledCheck = await checkEnabled(objectId);
|
|
130
|
+
if (!enabledCheck.matches) {
|
|
131
|
+
return enabledCheck;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
136
|
+
objectId,
|
|
137
|
+
functionDeclaration: `function() {
|
|
138
|
+
const el = this;
|
|
139
|
+
const tagName = el.tagName.toLowerCase();
|
|
140
|
+
if (el.readOnly === true) {
|
|
141
|
+
return { matches: false, received: 'readonly' };
|
|
142
|
+
}
|
|
143
|
+
if (el.getAttribute('aria-readonly') === 'true') {
|
|
144
|
+
return { matches: false, received: 'aria-readonly' };
|
|
145
|
+
}
|
|
146
|
+
const isFormElement = ['input', 'textarea', 'select'].includes(tagName);
|
|
147
|
+
const isContentEditable = el.isContentEditable;
|
|
148
|
+
if (!isFormElement && !isContentEditable) {
|
|
149
|
+
return { matches: false, received: 'not-editable-element' };
|
|
150
|
+
}
|
|
151
|
+
if (tagName === 'input') {
|
|
152
|
+
const type = el.type.toLowerCase();
|
|
153
|
+
const textInputTypes = ['text', 'password', 'email', 'number', 'search', 'tel', 'url', 'date', 'datetime-local', 'month', 'time', 'week'];
|
|
154
|
+
if (!textInputTypes.includes(type)) {
|
|
155
|
+
return { matches: false, received: 'non-text-input' };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { matches: true, received: 'editable' };
|
|
159
|
+
}`,
|
|
160
|
+
returnByValue: true
|
|
161
|
+
});
|
|
162
|
+
return result.result.value;
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { matches: false, received: 'error', error: error.message };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function checkStable(objectId) {
|
|
169
|
+
try {
|
|
170
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
171
|
+
objectId,
|
|
172
|
+
functionDeclaration: `async function() {
|
|
173
|
+
const el = this;
|
|
174
|
+
const frameCount = ${stableFrameCount};
|
|
175
|
+
if (!el.isConnected) {
|
|
176
|
+
return { matches: false, received: 'detached' };
|
|
177
|
+
}
|
|
178
|
+
let lastRect = null;
|
|
179
|
+
let stableCount = 0;
|
|
180
|
+
const getRect = () => {
|
|
181
|
+
const r = el.getBoundingClientRect();
|
|
182
|
+
return { x: r.x, y: r.y, width: r.width, height: r.height };
|
|
183
|
+
};
|
|
184
|
+
const checkFrame = () => new Promise(resolve => {
|
|
185
|
+
requestAnimationFrame(() => {
|
|
186
|
+
if (!el.isConnected) {
|
|
187
|
+
resolve({ matches: false, received: 'detached' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const rect = getRect();
|
|
191
|
+
if (lastRect) {
|
|
192
|
+
const same = rect.x === lastRect.x &&
|
|
193
|
+
rect.y === lastRect.y &&
|
|
194
|
+
rect.width === lastRect.width &&
|
|
195
|
+
rect.height === lastRect.height;
|
|
196
|
+
if (same) {
|
|
197
|
+
stableCount++;
|
|
198
|
+
if (stableCount >= frameCount) {
|
|
199
|
+
resolve({ matches: true, received: 'stable' });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
} else {
|
|
203
|
+
stableCount = 0;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
lastRect = rect;
|
|
207
|
+
resolve(null);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
for (let i = 0; i < 10; i++) {
|
|
211
|
+
const result = await checkFrame();
|
|
212
|
+
if (result !== null) {
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return { matches: false, received: 'unstable' };
|
|
217
|
+
}`,
|
|
218
|
+
returnByValue: true,
|
|
219
|
+
awaitPromise: true
|
|
220
|
+
});
|
|
221
|
+
return result.result.value;
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { matches: false, received: 'error', error: error.message };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function checkAttached(objectId) {
|
|
228
|
+
try {
|
|
229
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
230
|
+
objectId,
|
|
231
|
+
functionDeclaration: `function() {
|
|
232
|
+
return { matches: this.isConnected, received: this.isConnected ? 'attached' : 'detached' };
|
|
233
|
+
}`,
|
|
234
|
+
returnByValue: true
|
|
235
|
+
});
|
|
236
|
+
return result.result.value;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return { matches: false, received: 'error', error: error.message };
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function checkState(objectId, state) {
|
|
243
|
+
switch (state) {
|
|
244
|
+
case 'attached':
|
|
245
|
+
return checkAttached(objectId);
|
|
246
|
+
case 'visible':
|
|
247
|
+
return checkVisible(objectId);
|
|
248
|
+
case 'enabled':
|
|
249
|
+
return checkEnabled(objectId);
|
|
250
|
+
case 'editable':
|
|
251
|
+
return checkEditable(objectId);
|
|
252
|
+
case 'stable':
|
|
253
|
+
return checkStable(objectId);
|
|
254
|
+
default:
|
|
255
|
+
return { matches: true };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function checkStates(objectId, states) {
|
|
260
|
+
for (const state of states) {
|
|
261
|
+
const check = await checkState(objectId, state);
|
|
262
|
+
if (!check.matches) {
|
|
263
|
+
return { success: false, missingState: state, received: check.received };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { success: true };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function waitForActionable(selector, actionType, opts = {}) {
|
|
270
|
+
// Simplified: shorter default timeout (5s), simpler retry logic
|
|
271
|
+
const { timeout = 5000, force = false } = opts;
|
|
272
|
+
const startTime = Date.now();
|
|
273
|
+
|
|
274
|
+
const requiredStates = getRequiredStates(actionType);
|
|
275
|
+
|
|
276
|
+
// Force mode: just find the element, skip all checks
|
|
277
|
+
if (force) {
|
|
278
|
+
const element = await findElementInternal(selector);
|
|
279
|
+
if (!element.success) {
|
|
280
|
+
return element;
|
|
281
|
+
}
|
|
282
|
+
return { success: true, objectId: element.objectId, forced: true };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
let retry = 0;
|
|
286
|
+
let lastError = null;
|
|
287
|
+
let lastObjectId = null;
|
|
288
|
+
|
|
289
|
+
while (Date.now() - startTime < timeout) {
|
|
290
|
+
if (retry > 0) {
|
|
291
|
+
const delay = retryDelays[Math.min(retry - 1, retryDelays.length - 1)];
|
|
292
|
+
if (delay > 0) {
|
|
293
|
+
await sleep(delay);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (lastObjectId) {
|
|
298
|
+
await releaseObject(session, lastObjectId);
|
|
299
|
+
lastObjectId = null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const element = await findElementInternal(selector);
|
|
303
|
+
if (!element.success) {
|
|
304
|
+
lastError = element.error;
|
|
305
|
+
retry++;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
lastObjectId = element.objectId;
|
|
310
|
+
|
|
311
|
+
const stateCheck = await checkStates(element.objectId, requiredStates);
|
|
312
|
+
|
|
313
|
+
if (stateCheck.success) {
|
|
314
|
+
return { success: true, objectId: element.objectId };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
lastError = `Element is not ${stateCheck.missingState}: ${stateCheck.received}`;
|
|
318
|
+
retry++;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (lastObjectId) {
|
|
322
|
+
await releaseObject(session, lastObjectId);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
success: false,
|
|
327
|
+
error: lastError || `Element not found: ${selector} (timeout: ${timeout}ms)`
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function getClickablePoint(objectId) {
|
|
332
|
+
try {
|
|
333
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
334
|
+
objectId,
|
|
335
|
+
functionDeclaration: `function() {
|
|
336
|
+
const el = this;
|
|
337
|
+
const rect = el.getBoundingClientRect();
|
|
338
|
+
return {
|
|
339
|
+
x: rect.x + rect.width / 2,
|
|
340
|
+
y: rect.y + rect.height / 2,
|
|
341
|
+
rect: {
|
|
342
|
+
x: rect.x,
|
|
343
|
+
y: rect.y,
|
|
344
|
+
width: rect.width,
|
|
345
|
+
height: rect.height
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}`,
|
|
349
|
+
returnByValue: true
|
|
350
|
+
});
|
|
351
|
+
return result.result.value;
|
|
352
|
+
} catch {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function checkHitTarget(objectId, point) {
|
|
358
|
+
try {
|
|
359
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
360
|
+
objectId,
|
|
361
|
+
functionDeclaration: `function(point) {
|
|
362
|
+
const el = this;
|
|
363
|
+
const hitEl = document.elementFromPoint(point.x, point.y);
|
|
364
|
+
if (!hitEl) {
|
|
365
|
+
return { matches: false, received: 'no-element-at-point' };
|
|
366
|
+
}
|
|
367
|
+
if (hitEl === el || el.contains(hitEl)) {
|
|
368
|
+
return { matches: true, received: 'hit' };
|
|
369
|
+
}
|
|
370
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
371
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
372
|
+
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
373
|
+
desc += '.' + hitEl.className.split(' ').filter(c => c).join('.');
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
matches: false,
|
|
377
|
+
received: 'blocked',
|
|
378
|
+
blockedBy: desc
|
|
379
|
+
};
|
|
380
|
+
}`,
|
|
381
|
+
arguments: [{ value: point }],
|
|
382
|
+
returnByValue: true
|
|
383
|
+
});
|
|
384
|
+
return result.result.value;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
return { matches: false, received: 'error', error: error.message };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Check if pointer-events CSS allows clicking
|
|
392
|
+
* Elements with pointer-events: none cannot receive click events
|
|
393
|
+
* @param {string} objectId - Element object ID
|
|
394
|
+
* @returns {Promise<{clickable: boolean, pointerEvents: string}>}
|
|
395
|
+
*/
|
|
396
|
+
async function checkPointerEvents(objectId) {
|
|
397
|
+
try {
|
|
398
|
+
const result = await session.send('Runtime.callFunctionOn', {
|
|
399
|
+
objectId,
|
|
400
|
+
functionDeclaration: `function() {
|
|
401
|
+
const el = this;
|
|
402
|
+
const style = window.getComputedStyle(el);
|
|
403
|
+
const pointerEvents = style.pointerEvents;
|
|
404
|
+
|
|
405
|
+
// Check if element or any ancestor has pointer-events: none
|
|
406
|
+
let current = el;
|
|
407
|
+
while (current) {
|
|
408
|
+
const currentStyle = window.getComputedStyle(current);
|
|
409
|
+
if (currentStyle.pointerEvents === 'none') {
|
|
410
|
+
return {
|
|
411
|
+
clickable: false,
|
|
412
|
+
pointerEvents: 'none',
|
|
413
|
+
blockedBy: current === el ? 'self' : current.tagName.toLowerCase()
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
current = current.parentElement;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { clickable: true, pointerEvents: pointerEvents || 'auto' };
|
|
420
|
+
}`,
|
|
421
|
+
returnByValue: true
|
|
422
|
+
});
|
|
423
|
+
return result.result.value;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
return { clickable: true, pointerEvents: 'unknown', error: error.message };
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Detect covered elements using CDP DOM.getNodeForLocation
|
|
431
|
+
* Inspired by Rod's Interactable() method
|
|
432
|
+
* @param {string} objectId - Element object ID
|
|
433
|
+
* @param {{x: number, y: number}} point - Click coordinates
|
|
434
|
+
* @returns {Promise<{covered: boolean, coveringElement?: string}>}
|
|
435
|
+
*/
|
|
436
|
+
async function checkCovered(objectId, point) {
|
|
437
|
+
try {
|
|
438
|
+
// Get the backend node ID for the target element
|
|
439
|
+
const nodeResult = await session.send('DOM.describeNode', { objectId });
|
|
440
|
+
const targetBackendNodeId = nodeResult.node.backendNodeId;
|
|
441
|
+
|
|
442
|
+
// Use DOM.getNodeForLocation to see what element is actually at the click point
|
|
443
|
+
const locationResult = await session.send('DOM.getNodeForLocation', {
|
|
444
|
+
x: Math.floor(point.x),
|
|
445
|
+
y: Math.floor(point.y),
|
|
446
|
+
includeUserAgentShadowDOM: false
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const hitBackendNodeId = locationResult.backendNodeId;
|
|
450
|
+
|
|
451
|
+
// If the hit element matches our target, it's not covered
|
|
452
|
+
if (hitBackendNodeId === targetBackendNodeId) {
|
|
453
|
+
return { covered: false };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Check if the hit element is a child of our target (also valid)
|
|
457
|
+
const isChild = await session.send('Runtime.callFunctionOn', {
|
|
458
|
+
objectId,
|
|
459
|
+
functionDeclaration: `function(hitNodeId) {
|
|
460
|
+
// We need to find if the hit element is inside this element
|
|
461
|
+
// This is tricky because we only have backend node IDs
|
|
462
|
+
// Use elementFromPoint as a fallback check
|
|
463
|
+
const rect = this.getBoundingClientRect();
|
|
464
|
+
const centerX = rect.left + rect.width / 2;
|
|
465
|
+
const centerY = rect.top + rect.height / 2;
|
|
466
|
+
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
467
|
+
|
|
468
|
+
if (!hitEl) return { isChild: false, coverInfo: 'no-element' };
|
|
469
|
+
|
|
470
|
+
if (hitEl === this || this.contains(hitEl)) {
|
|
471
|
+
return { isChild: true };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Get info about the covering element
|
|
475
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
476
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
477
|
+
if (hitEl.className && typeof hitEl.className === 'string') {
|
|
478
|
+
const classes = hitEl.className.split(' ').filter(c => c).slice(0, 3);
|
|
479
|
+
if (classes.length > 0) desc += '.' + classes.join('.');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return { isChild: false, coverInfo: desc };
|
|
483
|
+
}`,
|
|
484
|
+
returnByValue: true
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const childResult = isChild.result.value;
|
|
488
|
+
|
|
489
|
+
if (childResult.isChild) {
|
|
490
|
+
return { covered: false };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
covered: true,
|
|
495
|
+
coveringElement: childResult.coverInfo || 'unknown'
|
|
496
|
+
};
|
|
497
|
+
} catch (error) {
|
|
498
|
+
// If DOM methods fail, fall back to elementFromPoint check
|
|
499
|
+
try {
|
|
500
|
+
const fallbackResult = await session.send('Runtime.callFunctionOn', {
|
|
501
|
+
objectId,
|
|
502
|
+
functionDeclaration: `function() {
|
|
503
|
+
const rect = this.getBoundingClientRect();
|
|
504
|
+
const centerX = rect.left + rect.width / 2;
|
|
505
|
+
const centerY = rect.top + rect.height / 2;
|
|
506
|
+
const hitEl = document.elementFromPoint(centerX, centerY);
|
|
507
|
+
|
|
508
|
+
if (!hitEl) return { covered: true, coverInfo: 'no-element-at-center' };
|
|
509
|
+
if (hitEl === this || this.contains(hitEl)) return { covered: false };
|
|
510
|
+
|
|
511
|
+
let desc = hitEl.tagName.toLowerCase();
|
|
512
|
+
if (hitEl.id) desc += '#' + hitEl.id;
|
|
513
|
+
return { covered: true, coverInfo: desc };
|
|
514
|
+
}`,
|
|
515
|
+
returnByValue: true
|
|
516
|
+
});
|
|
517
|
+
return {
|
|
518
|
+
covered: fallbackResult.result.value.covered,
|
|
519
|
+
coveringElement: fallbackResult.result.value.coverInfo
|
|
520
|
+
};
|
|
521
|
+
} catch {
|
|
522
|
+
return { covered: false, error: error.message };
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Scroll incrementally until an element becomes visible
|
|
529
|
+
* Useful for lazy-loaded content or infinite scroll pages
|
|
530
|
+
* @param {string} selector - CSS selector for the element
|
|
531
|
+
* @param {Object} [options] - Scroll options
|
|
532
|
+
* @param {number} [options.maxScrolls=10] - Maximum number of scroll attempts
|
|
533
|
+
* @param {number} [options.scrollAmount=500] - Pixels to scroll each attempt
|
|
534
|
+
* @param {number} [options.timeout=30000] - Total timeout in ms
|
|
535
|
+
* @param {string} [options.direction='down'] - Scroll direction ('down' or 'up')
|
|
536
|
+
* @returns {Promise<{found: boolean, objectId?: string, scrollCount: number}>}
|
|
537
|
+
*/
|
|
538
|
+
async function scrollUntilVisible(selector, options = {}) {
|
|
539
|
+
const {
|
|
540
|
+
maxScrolls = 10,
|
|
541
|
+
scrollAmount = 500,
|
|
542
|
+
timeout = 30000,
|
|
543
|
+
direction = 'down'
|
|
544
|
+
} = options;
|
|
545
|
+
|
|
546
|
+
const startTime = Date.now();
|
|
547
|
+
let scrollCount = 0;
|
|
548
|
+
|
|
549
|
+
while (scrollCount < maxScrolls && (Date.now() - startTime) < timeout) {
|
|
550
|
+
// Try to find the element
|
|
551
|
+
const findResult = await findElementInternal(selector);
|
|
552
|
+
|
|
553
|
+
if (findResult.success) {
|
|
554
|
+
// Check if visible
|
|
555
|
+
const visibleResult = await checkVisible(findResult.objectId);
|
|
556
|
+
if (visibleResult.matches) {
|
|
557
|
+
return {
|
|
558
|
+
found: true,
|
|
559
|
+
objectId: findResult.objectId,
|
|
560
|
+
scrollCount,
|
|
561
|
+
visibleAfterScrolls: scrollCount
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// Element exists but not visible, try scrolling it into view
|
|
566
|
+
try {
|
|
567
|
+
await session.send('Runtime.callFunctionOn', {
|
|
568
|
+
objectId: findResult.objectId,
|
|
569
|
+
functionDeclaration: `function() {
|
|
570
|
+
this.scrollIntoView({ block: 'center', behavior: 'instant' });
|
|
571
|
+
}`
|
|
572
|
+
});
|
|
573
|
+
await sleep(100);
|
|
574
|
+
|
|
575
|
+
// Check visibility again
|
|
576
|
+
const visibleAfterScroll = await checkVisible(findResult.objectId);
|
|
577
|
+
if (visibleAfterScroll.matches) {
|
|
578
|
+
return {
|
|
579
|
+
found: true,
|
|
580
|
+
objectId: findResult.objectId,
|
|
581
|
+
scrollCount,
|
|
582
|
+
scrolledIntoView: true
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
} catch {
|
|
586
|
+
// Failed to scroll into view, continue with page scrolling
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Release the object as we'll search again
|
|
590
|
+
await releaseObject(session, findResult.objectId);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// Scroll the page
|
|
594
|
+
const scrollDir = direction === 'up' ? -scrollAmount : scrollAmount;
|
|
595
|
+
await session.send('Runtime.evaluate', {
|
|
596
|
+
expression: `window.scrollBy(0, ${scrollDir})`
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
scrollCount++;
|
|
600
|
+
await sleep(200); // Wait for content to load/render
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Final attempt to find the element
|
|
604
|
+
const finalResult = await findElementInternal(selector);
|
|
605
|
+
if (finalResult.success) {
|
|
606
|
+
const visibleResult = await checkVisible(finalResult.objectId);
|
|
607
|
+
if (visibleResult.matches) {
|
|
608
|
+
return {
|
|
609
|
+
found: true,
|
|
610
|
+
objectId: finalResult.objectId,
|
|
611
|
+
scrollCount,
|
|
612
|
+
foundOnFinalCheck: true
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
await releaseObject(session, finalResult.objectId);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
found: false,
|
|
620
|
+
scrollCount,
|
|
621
|
+
reason: scrollCount >= maxScrolls ? 'maxScrollsReached' : 'timeout'
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return {
|
|
626
|
+
waitForActionable,
|
|
627
|
+
getClickablePoint,
|
|
628
|
+
checkHitTarget,
|
|
629
|
+
checkPointerEvents,
|
|
630
|
+
checkCovered,
|
|
631
|
+
checkVisible,
|
|
632
|
+
checkEnabled,
|
|
633
|
+
checkEditable,
|
|
634
|
+
checkStable,
|
|
635
|
+
getRequiredStates,
|
|
636
|
+
scrollUntilVisible
|
|
637
|
+
};
|
|
638
|
+
}
|