cdp-skill 1.0.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/SKILL.md +543 -0
- package/install.js +92 -0
- package/package.json +47 -0
- package/src/aria.js +1302 -0
- package/src/capture.js +1359 -0
- package/src/cdp.js +905 -0
- package/src/cli.js +244 -0
- package/src/dom.js +3525 -0
- package/src/index.js +155 -0
- package/src/page.js +1720 -0
- package/src/runner.js +2111 -0
- package/src/tests/BrowserClient.test.js +588 -0
- package/src/tests/CDPConnection.test.js +598 -0
- package/src/tests/ChromeDiscovery.test.js +181 -0
- package/src/tests/ConsoleCapture.test.js +302 -0
- package/src/tests/ElementHandle.test.js +586 -0
- package/src/tests/ElementLocator.test.js +586 -0
- package/src/tests/ErrorAggregator.test.js +327 -0
- package/src/tests/InputEmulator.test.js +641 -0
- package/src/tests/NetworkErrorCapture.test.js +458 -0
- package/src/tests/PageController.test.js +822 -0
- package/src/tests/ScreenshotCapture.test.js +356 -0
- package/src/tests/SessionRegistry.test.js +257 -0
- package/src/tests/TargetManager.test.js +274 -0
- package/src/tests/TestRunner.test.js +1529 -0
- package/src/tests/WaitStrategy.test.js +406 -0
- package/src/tests/integration.test.js +431 -0
- package/src/utils.js +1034 -0
- package/uninstall.js +44 -0
package/src/runner.js
ADDED
|
@@ -0,0 +1,2111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test Step Execution
|
|
3
|
+
* Validates and executes YAML/JSON test step sequences
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
elementNotFoundError,
|
|
8
|
+
elementNotEditableError,
|
|
9
|
+
timeoutError,
|
|
10
|
+
stepValidationError,
|
|
11
|
+
createKeyValidator,
|
|
12
|
+
createFormValidator
|
|
13
|
+
} from './utils.js';
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
createClickExecutor,
|
|
17
|
+
createFillExecutor,
|
|
18
|
+
createWaitExecutor,
|
|
19
|
+
createKeyboardExecutor,
|
|
20
|
+
createElementValidator,
|
|
21
|
+
createReactInputFiller,
|
|
22
|
+
createActionabilityChecker
|
|
23
|
+
} from './dom.js';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
createQueryOutputProcessor,
|
|
27
|
+
createRoleQueryExecutor
|
|
28
|
+
} from './aria.js';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
createEvalSerializer,
|
|
32
|
+
createDebugCapture
|
|
33
|
+
} from './capture.js';
|
|
34
|
+
|
|
35
|
+
import { sleep, resetInputState, releaseObject, resolveTempPath, generateTempPath } from './utils.js';
|
|
36
|
+
|
|
37
|
+
const keyValidator = createKeyValidator();
|
|
38
|
+
|
|
39
|
+
const STEP_TYPES = ['goto', 'wait', 'delay', 'click', 'fill', 'fillForm', 'press', 'screenshot', 'query', 'queryAll', 'inspect', 'scroll', 'console', 'pdf', 'eval', 'snapshot', 'hover', 'viewport', 'cookies', 'back', 'forward', 'waitForNavigation', 'listTabs', 'closeTab', 'type', 'select', 'validate', 'submit', 'assert', 'switchToFrame', 'switchToMainFrame', 'listFrames', 'drag'];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Validate a single step definition
|
|
43
|
+
* @param {Object} step - Step definition
|
|
44
|
+
* @returns {string[]} Array of validation errors
|
|
45
|
+
*/
|
|
46
|
+
function validateStepInternal(step) {
|
|
47
|
+
const errors = [];
|
|
48
|
+
|
|
49
|
+
if (!step || typeof step !== 'object') {
|
|
50
|
+
errors.push('step must be an object');
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const definedActions = STEP_TYPES.filter(type => step[type] !== undefined);
|
|
55
|
+
|
|
56
|
+
if (definedActions.length === 0) {
|
|
57
|
+
errors.push(`unknown step type, expected one of: ${STEP_TYPES.join(', ')}`);
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (definedActions.length > 1) {
|
|
62
|
+
errors.push(`ambiguous step: multiple actions defined (${definedActions.join(', ')})`);
|
|
63
|
+
return errors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const action = definedActions[0];
|
|
67
|
+
const params = step[action];
|
|
68
|
+
|
|
69
|
+
switch (action) {
|
|
70
|
+
case 'goto':
|
|
71
|
+
if (typeof params !== 'string' || params.length === 0) {
|
|
72
|
+
errors.push('goto requires a non-empty URL string');
|
|
73
|
+
}
|
|
74
|
+
break;
|
|
75
|
+
|
|
76
|
+
case 'wait':
|
|
77
|
+
// Support numeric value for simple delay: { "wait": 2000 }
|
|
78
|
+
if (typeof params === 'number') {
|
|
79
|
+
if (params < 0) {
|
|
80
|
+
errors.push('wait time must be a non-negative number');
|
|
81
|
+
}
|
|
82
|
+
} else if (typeof params === 'string') {
|
|
83
|
+
if (params.length === 0) {
|
|
84
|
+
errors.push('wait selector cannot be empty');
|
|
85
|
+
}
|
|
86
|
+
} else if (params && typeof params === 'object') {
|
|
87
|
+
const hasSelector = params.selector !== undefined;
|
|
88
|
+
const hasText = params.text !== undefined;
|
|
89
|
+
const hasTextRegex = params.textRegex !== undefined;
|
|
90
|
+
const hasTime = params.time !== undefined;
|
|
91
|
+
const hasUrlContains = params.urlContains !== undefined;
|
|
92
|
+
if (!hasSelector && !hasText && !hasTextRegex && !hasTime && !hasUrlContains) {
|
|
93
|
+
errors.push('wait requires selector, text, textRegex, time, or urlContains');
|
|
94
|
+
}
|
|
95
|
+
if (hasSelector && typeof params.selector !== 'string') {
|
|
96
|
+
errors.push('wait selector must be a string');
|
|
97
|
+
}
|
|
98
|
+
if (hasText && typeof params.text !== 'string') {
|
|
99
|
+
errors.push('wait text must be a string');
|
|
100
|
+
}
|
|
101
|
+
if (hasTextRegex && typeof params.textRegex !== 'string') {
|
|
102
|
+
errors.push('wait textRegex must be a string');
|
|
103
|
+
}
|
|
104
|
+
if (hasTime && (typeof params.time !== 'number' || params.time < 0)) {
|
|
105
|
+
errors.push('wait time must be a non-negative number');
|
|
106
|
+
}
|
|
107
|
+
if (hasUrlContains && typeof params.urlContains !== 'string') {
|
|
108
|
+
errors.push('wait urlContains must be a string');
|
|
109
|
+
}
|
|
110
|
+
if (params.minCount !== undefined && (typeof params.minCount !== 'number' || params.minCount < 0)) {
|
|
111
|
+
errors.push('wait minCount must be a non-negative number');
|
|
112
|
+
}
|
|
113
|
+
if (params.caseSensitive !== undefined && typeof params.caseSensitive !== 'boolean') {
|
|
114
|
+
errors.push('wait caseSensitive must be a boolean');
|
|
115
|
+
}
|
|
116
|
+
if (params.hidden !== undefined && typeof params.hidden !== 'boolean') {
|
|
117
|
+
errors.push('wait hidden must be a boolean');
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
errors.push('wait requires a number (ms), selector string, or params object');
|
|
121
|
+
}
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case 'delay':
|
|
125
|
+
// Simple delay step: { "delay": 2000 }
|
|
126
|
+
if (typeof params !== 'number' || params < 0) {
|
|
127
|
+
errors.push('delay requires a non-negative number (milliseconds)');
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
|
|
131
|
+
case 'click':
|
|
132
|
+
if (typeof params === 'string') {
|
|
133
|
+
if (params.length === 0) {
|
|
134
|
+
errors.push('click selector cannot be empty');
|
|
135
|
+
}
|
|
136
|
+
} else if (params && typeof params === 'object') {
|
|
137
|
+
// Check for coordinate-based click (FR-064)
|
|
138
|
+
const hasCoordinates = typeof params.x === 'number' && typeof params.y === 'number';
|
|
139
|
+
if (!params.selector && !params.ref && !hasCoordinates) {
|
|
140
|
+
errors.push('click requires selector, ref, or x/y coordinates');
|
|
141
|
+
} else if (params.selector && typeof params.selector !== 'string') {
|
|
142
|
+
errors.push('click selector must be a string');
|
|
143
|
+
} else if (params.ref && typeof params.ref !== 'string') {
|
|
144
|
+
errors.push('click ref must be a string');
|
|
145
|
+
} else if (hasCoordinates) {
|
|
146
|
+
if (params.x < 0 || params.y < 0) {
|
|
147
|
+
errors.push('click coordinates must be non-negative');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
errors.push('click requires a selector string or params object');
|
|
152
|
+
}
|
|
153
|
+
break;
|
|
154
|
+
|
|
155
|
+
case 'fill':
|
|
156
|
+
if (!params || typeof params !== 'object') {
|
|
157
|
+
errors.push('fill requires an object with selector/ref and value');
|
|
158
|
+
} else {
|
|
159
|
+
if (!params.selector && !params.ref) {
|
|
160
|
+
errors.push('fill requires selector or ref');
|
|
161
|
+
} else if (params.selector && typeof params.selector !== 'string') {
|
|
162
|
+
errors.push('fill selector must be a string');
|
|
163
|
+
} else if (params.ref && typeof params.ref !== 'string') {
|
|
164
|
+
errors.push('fill ref must be a string');
|
|
165
|
+
}
|
|
166
|
+
if (params.value === undefined) {
|
|
167
|
+
errors.push('fill requires value');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
|
|
172
|
+
case 'fillForm':
|
|
173
|
+
if (!params || typeof params !== 'object') {
|
|
174
|
+
errors.push('fillForm requires an object mapping selectors/refs to values');
|
|
175
|
+
} else {
|
|
176
|
+
// Support both formats:
|
|
177
|
+
// Simple: {"#firstName": "John", "#lastName": "Doe"}
|
|
178
|
+
// Extended: {"fields": {"#firstName": "John"}, "react": true}
|
|
179
|
+
let fields;
|
|
180
|
+
if (params.fields && typeof params.fields === 'object') {
|
|
181
|
+
fields = params.fields;
|
|
182
|
+
// Validate react option if present
|
|
183
|
+
if (params.react !== undefined && typeof params.react !== 'boolean') {
|
|
184
|
+
errors.push('fillForm react option must be a boolean');
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
fields = params;
|
|
188
|
+
}
|
|
189
|
+
const entries = Object.entries(fields);
|
|
190
|
+
if (entries.length === 0) {
|
|
191
|
+
errors.push('fillForm requires at least one field');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
|
|
196
|
+
case 'press':
|
|
197
|
+
if (typeof params !== 'string' || params.length === 0) {
|
|
198
|
+
errors.push('press requires a non-empty key string');
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
|
|
202
|
+
case 'screenshot':
|
|
203
|
+
if (typeof params === 'string') {
|
|
204
|
+
if (params.length === 0) {
|
|
205
|
+
errors.push('screenshot path cannot be empty');
|
|
206
|
+
}
|
|
207
|
+
} else if (params && typeof params === 'object') {
|
|
208
|
+
if (!params.path) {
|
|
209
|
+
errors.push('screenshot requires path');
|
|
210
|
+
} else if (typeof params.path !== 'string') {
|
|
211
|
+
errors.push('screenshot path must be a string');
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
errors.push('screenshot requires a path string or params object');
|
|
215
|
+
}
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case 'query':
|
|
219
|
+
if (typeof params === 'string') {
|
|
220
|
+
if (params.length === 0) {
|
|
221
|
+
errors.push('query selector cannot be empty');
|
|
222
|
+
}
|
|
223
|
+
} else if (params && typeof params === 'object') {
|
|
224
|
+
// Support both CSS selector and role-based queries
|
|
225
|
+
if (!params.selector && !params.role) {
|
|
226
|
+
errors.push('query requires selector or role');
|
|
227
|
+
}
|
|
228
|
+
// Role can be string or array of strings (FR-021 compound roles)
|
|
229
|
+
if (params.role) {
|
|
230
|
+
if (typeof params.role !== 'string' && !Array.isArray(params.role)) {
|
|
231
|
+
errors.push('query role must be a string or array of strings');
|
|
232
|
+
}
|
|
233
|
+
if (Array.isArray(params.role) && !params.role.every(r => typeof r === 'string')) {
|
|
234
|
+
errors.push('query role array must contain only strings');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Validate nameExact and nameRegex are not both set
|
|
238
|
+
if (params.nameExact && params.nameRegex) {
|
|
239
|
+
errors.push('query cannot have both nameExact and nameRegex');
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
errors.push('query requires a selector string or params object');
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
|
|
246
|
+
case 'inspect':
|
|
247
|
+
// inspect can be boolean or object with options
|
|
248
|
+
break;
|
|
249
|
+
|
|
250
|
+
case 'scroll':
|
|
251
|
+
if (typeof params === 'string') {
|
|
252
|
+
if (!['top', 'bottom', 'up', 'down'].includes(params) && params.length === 0) {
|
|
253
|
+
errors.push('scroll requires direction (top/bottom/up/down) or selector');
|
|
254
|
+
}
|
|
255
|
+
} else if (params && typeof params === 'object') {
|
|
256
|
+
// selector, x, y, deltaX, deltaY are all valid
|
|
257
|
+
} else if (typeof params !== 'string') {
|
|
258
|
+
errors.push('scroll requires direction string or params object');
|
|
259
|
+
}
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case 'console':
|
|
263
|
+
// console can be boolean or object with filter options
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case 'pdf':
|
|
267
|
+
if (typeof params === 'string') {
|
|
268
|
+
if (params.length === 0) {
|
|
269
|
+
errors.push('pdf path cannot be empty');
|
|
270
|
+
}
|
|
271
|
+
} else if (params && typeof params === 'object') {
|
|
272
|
+
if (!params.path) {
|
|
273
|
+
errors.push('pdf requires path');
|
|
274
|
+
} else if (typeof params.path !== 'string') {
|
|
275
|
+
errors.push('pdf path must be a string');
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
errors.push('pdf requires a path string or params object');
|
|
279
|
+
}
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'eval':
|
|
283
|
+
if (typeof params === 'string') {
|
|
284
|
+
if (params.length === 0) {
|
|
285
|
+
errors.push('eval expression cannot be empty');
|
|
286
|
+
}
|
|
287
|
+
} else if (params && typeof params === 'object') {
|
|
288
|
+
if (!params.expression) {
|
|
289
|
+
errors.push('eval requires expression');
|
|
290
|
+
} else if (typeof params.expression !== 'string') {
|
|
291
|
+
errors.push('eval expression must be a string');
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
errors.push('eval requires an expression string or params object');
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case 'snapshot':
|
|
299
|
+
// snapshot can be boolean or object with options
|
|
300
|
+
if (params !== true && params !== false && typeof params !== 'object') {
|
|
301
|
+
errors.push('snapshot requires true or params object');
|
|
302
|
+
}
|
|
303
|
+
if (typeof params === 'object' && params.mode && !['ai', 'full'].includes(params.mode)) {
|
|
304
|
+
errors.push('snapshot mode must be "ai" or "full"');
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
|
|
308
|
+
case 'hover':
|
|
309
|
+
if (typeof params === 'string') {
|
|
310
|
+
if (params.length === 0) {
|
|
311
|
+
errors.push('hover selector cannot be empty');
|
|
312
|
+
}
|
|
313
|
+
} else if (params && typeof params === 'object') {
|
|
314
|
+
if (!params.selector && !params.ref) {
|
|
315
|
+
errors.push('hover requires selector or ref');
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
errors.push('hover requires a selector string or params object');
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
|
|
322
|
+
case 'viewport':
|
|
323
|
+
// Support both device preset strings and explicit config objects
|
|
324
|
+
if (typeof params === 'string') {
|
|
325
|
+
// Device preset name - validation happens at execution time
|
|
326
|
+
if (params.length === 0) {
|
|
327
|
+
errors.push('viewport preset name cannot be empty');
|
|
328
|
+
}
|
|
329
|
+
} else if (params && typeof params === 'object') {
|
|
330
|
+
if (!params.width || typeof params.width !== 'number') {
|
|
331
|
+
errors.push('viewport requires numeric width');
|
|
332
|
+
}
|
|
333
|
+
if (!params.height || typeof params.height !== 'number') {
|
|
334
|
+
errors.push('viewport requires numeric height');
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
errors.push('viewport requires a device preset string or object with width and height');
|
|
338
|
+
}
|
|
339
|
+
break;
|
|
340
|
+
|
|
341
|
+
case 'cookies':
|
|
342
|
+
if (!params || typeof params !== 'object') {
|
|
343
|
+
errors.push('cookies requires an object with action (get, set, or clear)');
|
|
344
|
+
} else {
|
|
345
|
+
const action = params.action || params.get || params.set || params.clear;
|
|
346
|
+
if (params.set && !Array.isArray(params.set)) {
|
|
347
|
+
errors.push('cookies set requires an array of cookie objects');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
|
|
352
|
+
case 'back':
|
|
353
|
+
if (params !== true && typeof params !== 'object') {
|
|
354
|
+
errors.push('back requires true or params object');
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
|
|
358
|
+
case 'forward':
|
|
359
|
+
if (params !== true && typeof params !== 'object') {
|
|
360
|
+
errors.push('forward requires true or params object');
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
|
|
364
|
+
case 'waitForNavigation':
|
|
365
|
+
if (params !== true && typeof params !== 'object') {
|
|
366
|
+
errors.push('waitForNavigation requires true or params object');
|
|
367
|
+
}
|
|
368
|
+
if (typeof params === 'object' && params.timeout !== undefined) {
|
|
369
|
+
if (typeof params.timeout !== 'number' || params.timeout < 0) {
|
|
370
|
+
errors.push('waitForNavigation timeout must be a non-negative number');
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
break;
|
|
374
|
+
|
|
375
|
+
case 'listTabs':
|
|
376
|
+
// listTabs can be boolean true
|
|
377
|
+
if (params !== true) {
|
|
378
|
+
errors.push('listTabs requires true');
|
|
379
|
+
}
|
|
380
|
+
break;
|
|
381
|
+
|
|
382
|
+
case 'closeTab':
|
|
383
|
+
if (typeof params !== 'string' || params.length === 0) {
|
|
384
|
+
errors.push('closeTab requires a non-empty targetId string');
|
|
385
|
+
}
|
|
386
|
+
break;
|
|
387
|
+
|
|
388
|
+
case 'type':
|
|
389
|
+
if (!params || typeof params !== 'object') {
|
|
390
|
+
errors.push('type requires an object with selector and text');
|
|
391
|
+
} else {
|
|
392
|
+
if (!params.selector) {
|
|
393
|
+
errors.push('type requires selector');
|
|
394
|
+
} else if (typeof params.selector !== 'string') {
|
|
395
|
+
errors.push('type selector must be a string');
|
|
396
|
+
}
|
|
397
|
+
if (params.text === undefined) {
|
|
398
|
+
errors.push('type requires text');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
break;
|
|
402
|
+
|
|
403
|
+
case 'select':
|
|
404
|
+
if (typeof params === 'string') {
|
|
405
|
+
if (params.length === 0) {
|
|
406
|
+
errors.push('select selector cannot be empty');
|
|
407
|
+
}
|
|
408
|
+
} else if (params && typeof params === 'object') {
|
|
409
|
+
if (!params.selector) {
|
|
410
|
+
errors.push('select requires selector');
|
|
411
|
+
} else if (typeof params.selector !== 'string') {
|
|
412
|
+
errors.push('select selector must be a string');
|
|
413
|
+
}
|
|
414
|
+
if (params.start !== undefined && typeof params.start !== 'number') {
|
|
415
|
+
errors.push('select start must be a number');
|
|
416
|
+
}
|
|
417
|
+
if (params.end !== undefined && typeof params.end !== 'number') {
|
|
418
|
+
errors.push('select end must be a number');
|
|
419
|
+
}
|
|
420
|
+
} else {
|
|
421
|
+
errors.push('select requires a selector string or params object');
|
|
422
|
+
}
|
|
423
|
+
break;
|
|
424
|
+
|
|
425
|
+
case 'validate':
|
|
426
|
+
if (typeof params !== 'string' || params.length === 0) {
|
|
427
|
+
errors.push('validate requires a non-empty selector string');
|
|
428
|
+
}
|
|
429
|
+
break;
|
|
430
|
+
|
|
431
|
+
case 'submit':
|
|
432
|
+
if (typeof params === 'string') {
|
|
433
|
+
if (params.length === 0) {
|
|
434
|
+
errors.push('submit requires a non-empty form selector');
|
|
435
|
+
}
|
|
436
|
+
} else if (params && typeof params === 'object') {
|
|
437
|
+
if (!params.selector) {
|
|
438
|
+
errors.push('submit requires selector');
|
|
439
|
+
} else if (typeof params.selector !== 'string') {
|
|
440
|
+
errors.push('submit selector must be a string');
|
|
441
|
+
}
|
|
442
|
+
} else {
|
|
443
|
+
errors.push('submit requires a selector string or params object');
|
|
444
|
+
}
|
|
445
|
+
break;
|
|
446
|
+
|
|
447
|
+
case 'assert':
|
|
448
|
+
if (!params || typeof params !== 'object') {
|
|
449
|
+
errors.push('assert requires an object with url, text, or selector');
|
|
450
|
+
} else {
|
|
451
|
+
const hasUrl = params.url !== undefined;
|
|
452
|
+
const hasText = params.text !== undefined;
|
|
453
|
+
const hasSelector = params.selector !== undefined;
|
|
454
|
+
if (!hasUrl && !hasText) {
|
|
455
|
+
errors.push('assert requires url or text');
|
|
456
|
+
}
|
|
457
|
+
if (hasUrl && typeof params.url !== 'object') {
|
|
458
|
+
errors.push('assert url must be an object (e.g., { contains: "..." })');
|
|
459
|
+
}
|
|
460
|
+
if (hasUrl && params.url && !params.url.contains && !params.url.equals && !params.url.startsWith && !params.url.endsWith && !params.url.matches) {
|
|
461
|
+
errors.push('assert url requires contains, equals, startsWith, endsWith, or matches');
|
|
462
|
+
}
|
|
463
|
+
if (hasText && typeof params.text !== 'string') {
|
|
464
|
+
errors.push('assert text must be a string');
|
|
465
|
+
}
|
|
466
|
+
if (hasSelector && typeof params.selector !== 'string') {
|
|
467
|
+
errors.push('assert selector must be a string');
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
break;
|
|
471
|
+
|
|
472
|
+
case 'queryAll':
|
|
473
|
+
if (!params || typeof params !== 'object') {
|
|
474
|
+
errors.push('queryAll requires an object mapping names to selectors');
|
|
475
|
+
} else {
|
|
476
|
+
const entries = Object.entries(params);
|
|
477
|
+
if (entries.length === 0) {
|
|
478
|
+
errors.push('queryAll requires at least one query');
|
|
479
|
+
}
|
|
480
|
+
for (const [name, selector] of entries) {
|
|
481
|
+
if (typeof selector !== 'string' && typeof selector !== 'object') {
|
|
482
|
+
errors.push(`queryAll "${name}" must be a selector string or query object`);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
break;
|
|
487
|
+
|
|
488
|
+
case 'switchToFrame':
|
|
489
|
+
// Can be string (selector/name), number (index), or object
|
|
490
|
+
if (params === null || params === undefined) {
|
|
491
|
+
errors.push('switchToFrame requires a selector, index, or options object');
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
|
|
495
|
+
case 'switchToMainFrame':
|
|
496
|
+
// No validation needed, params can be true or anything
|
|
497
|
+
break;
|
|
498
|
+
|
|
499
|
+
case 'listFrames':
|
|
500
|
+
// No validation needed
|
|
501
|
+
break;
|
|
502
|
+
|
|
503
|
+
case 'drag':
|
|
504
|
+
if (!params || typeof params !== 'object') {
|
|
505
|
+
errors.push('drag requires an object with source and target');
|
|
506
|
+
} else {
|
|
507
|
+
if (!params.source) {
|
|
508
|
+
errors.push('drag requires a source selector or coordinates');
|
|
509
|
+
}
|
|
510
|
+
if (!params.target) {
|
|
511
|
+
errors.push('drag requires a target selector or coordinates');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return errors;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Validate an array of step definitions
|
|
522
|
+
* @param {Array<Object>} steps - Array of step definitions
|
|
523
|
+
* @returns {{valid: boolean, errors: Array}}
|
|
524
|
+
*/
|
|
525
|
+
export function validateSteps(steps) {
|
|
526
|
+
const invalidSteps = [];
|
|
527
|
+
|
|
528
|
+
for (let i = 0; i < steps.length; i++) {
|
|
529
|
+
const step = steps[i];
|
|
530
|
+
const errors = validateStepInternal(step);
|
|
531
|
+
if (errors.length > 0) {
|
|
532
|
+
invalidSteps.push({ index: i, step, errors });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (invalidSteps.length > 0) {
|
|
537
|
+
return { valid: false, errors: invalidSteps };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
return { valid: true, errors: [] };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Execute a single test step
|
|
545
|
+
* @param {Object} deps - Dependencies
|
|
546
|
+
* @param {Object} step - Step definition
|
|
547
|
+
* @param {Object} [options] - Execution options
|
|
548
|
+
* @returns {Promise<Object>}
|
|
549
|
+
*/
|
|
550
|
+
export async function executeStep(deps, step, options = {}) {
|
|
551
|
+
const { pageController, elementLocator, inputEmulator, screenshotCapture } = deps;
|
|
552
|
+
const startTime = Date.now();
|
|
553
|
+
const stepTimeout = options.stepTimeout || 30000;
|
|
554
|
+
const isOptional = step.optional === true;
|
|
555
|
+
const debugMode = options.debug || false;
|
|
556
|
+
const debugCapture = debugMode && screenshotCapture
|
|
557
|
+
? createDebugCapture(pageController.session, screenshotCapture, options.debugOptions || {})
|
|
558
|
+
: null;
|
|
559
|
+
|
|
560
|
+
const stepResult = {
|
|
561
|
+
action: null,
|
|
562
|
+
params: null,
|
|
563
|
+
status: 'passed',
|
|
564
|
+
duration: 0,
|
|
565
|
+
error: null,
|
|
566
|
+
warning: null,
|
|
567
|
+
screenshot: null,
|
|
568
|
+
output: null,
|
|
569
|
+
debug: null
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
async function executeStepInternal() {
|
|
573
|
+
// Check for ambiguous steps (multiple actions defined)
|
|
574
|
+
const definedActions = STEP_TYPES.filter(type => step[type] !== undefined);
|
|
575
|
+
if (definedActions.length === 0) {
|
|
576
|
+
throw new Error(`Unknown step type: ${JSON.stringify(step)}`);
|
|
577
|
+
}
|
|
578
|
+
if (definedActions.length > 1) {
|
|
579
|
+
throw new Error(`Ambiguous step: multiple actions defined (${definedActions.join(', ')}). Each step must have exactly one action.`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (step.goto !== undefined) {
|
|
583
|
+
stepResult.action = 'goto';
|
|
584
|
+
stepResult.params = { url: step.goto };
|
|
585
|
+
await pageController.navigate(step.goto);
|
|
586
|
+
} else if (step.delay !== undefined) {
|
|
587
|
+
// Simple delay step: { "delay": 2000 }
|
|
588
|
+
stepResult.action = 'delay';
|
|
589
|
+
stepResult.params = { ms: step.delay };
|
|
590
|
+
await sleep(step.delay);
|
|
591
|
+
} else if (step.wait !== undefined) {
|
|
592
|
+
stepResult.action = 'wait';
|
|
593
|
+
stepResult.params = step.wait;
|
|
594
|
+
// Support numeric value for simple delay: { "wait": 2000 }
|
|
595
|
+
if (typeof step.wait === 'number') {
|
|
596
|
+
await sleep(step.wait);
|
|
597
|
+
} else {
|
|
598
|
+
await executeWait(elementLocator, step.wait);
|
|
599
|
+
}
|
|
600
|
+
} else if (step.click !== undefined) {
|
|
601
|
+
stepResult.action = 'click';
|
|
602
|
+
stepResult.params = step.click;
|
|
603
|
+
const clickResult = await executeClick(elementLocator, inputEmulator, deps.ariaSnapshot, step.click);
|
|
604
|
+
if (clickResult) {
|
|
605
|
+
// Build output object with all relevant info
|
|
606
|
+
const output = { clicked: clickResult.clicked };
|
|
607
|
+
|
|
608
|
+
// Handle stale ref warning
|
|
609
|
+
if (clickResult.stale || clickResult.warning) {
|
|
610
|
+
stepResult.warning = clickResult.warning;
|
|
611
|
+
output.stale = clickResult.stale;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Handle verify mode
|
|
615
|
+
if (typeof step.click === 'object' && step.click.verify) {
|
|
616
|
+
output.targetReceived = clickResult.targetReceived;
|
|
617
|
+
if (!clickResult.targetReceived) {
|
|
618
|
+
stepResult.warning = 'Click may have hit a different element';
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// Add navigation info (FR-008)
|
|
623
|
+
if (clickResult.navigated !== undefined) {
|
|
624
|
+
output.navigated = clickResult.navigated;
|
|
625
|
+
if (clickResult.newUrl) {
|
|
626
|
+
output.newUrl = clickResult.newUrl;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Add debug info (FR-005)
|
|
631
|
+
if (clickResult.debug) {
|
|
632
|
+
output.debug = clickResult.debug;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// Add coordinates for coordinate-based clicks (FR-064)
|
|
636
|
+
if (clickResult.coordinates) {
|
|
637
|
+
output.coordinates = clickResult.coordinates;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
stepResult.output = output;
|
|
641
|
+
}
|
|
642
|
+
} else if (step.fill !== undefined) {
|
|
643
|
+
stepResult.action = 'fill';
|
|
644
|
+
stepResult.params = step.fill;
|
|
645
|
+
const fillExecutor = createFillExecutor(
|
|
646
|
+
elementLocator.session,
|
|
647
|
+
elementLocator,
|
|
648
|
+
inputEmulator,
|
|
649
|
+
deps.ariaSnapshot
|
|
650
|
+
);
|
|
651
|
+
await fillExecutor.execute(step.fill);
|
|
652
|
+
} else if (step.fillForm !== undefined) {
|
|
653
|
+
stepResult.action = 'fillForm';
|
|
654
|
+
stepResult.params = step.fillForm;
|
|
655
|
+
const fillExecutor = createFillExecutor(
|
|
656
|
+
elementLocator.session,
|
|
657
|
+
elementLocator,
|
|
658
|
+
inputEmulator,
|
|
659
|
+
deps.ariaSnapshot
|
|
660
|
+
);
|
|
661
|
+
stepResult.output = await fillExecutor.executeBatch(step.fillForm);
|
|
662
|
+
} else if (step.press !== undefined) {
|
|
663
|
+
stepResult.action = 'press';
|
|
664
|
+
stepResult.params = { key: step.press };
|
|
665
|
+
// Validate key name and set warning if unknown
|
|
666
|
+
const keyValidation = keyValidator.validate(step.press);
|
|
667
|
+
if (keyValidation.warning) {
|
|
668
|
+
stepResult.warning = keyValidation.warning;
|
|
669
|
+
}
|
|
670
|
+
// Support keyboard combos like "Control+a" or "Meta+Shift+Enter"
|
|
671
|
+
if (typeof step.press === 'string' && step.press.includes('+')) {
|
|
672
|
+
await inputEmulator.pressCombo(step.press);
|
|
673
|
+
} else {
|
|
674
|
+
await inputEmulator.press(step.press);
|
|
675
|
+
}
|
|
676
|
+
} else if (step.screenshot !== undefined) {
|
|
677
|
+
stepResult.action = 'screenshot';
|
|
678
|
+
stepResult.params = step.screenshot;
|
|
679
|
+
const screenshotResult = await executeScreenshot(screenshotCapture, elementLocator, step.screenshot);
|
|
680
|
+
stepResult.screenshot = screenshotResult.path;
|
|
681
|
+
stepResult.output = screenshotResult;
|
|
682
|
+
} else if (step.query !== undefined) {
|
|
683
|
+
stepResult.action = 'query';
|
|
684
|
+
stepResult.params = step.query;
|
|
685
|
+
stepResult.output = await executeQuery(elementLocator, step.query);
|
|
686
|
+
} else if (step.inspect !== undefined) {
|
|
687
|
+
stepResult.action = 'inspect';
|
|
688
|
+
stepResult.params = step.inspect;
|
|
689
|
+
stepResult.output = await executeInspect(pageController, elementLocator, step.inspect);
|
|
690
|
+
} else if (step.scroll !== undefined) {
|
|
691
|
+
stepResult.action = 'scroll';
|
|
692
|
+
stepResult.params = step.scroll;
|
|
693
|
+
stepResult.output = await executeScroll(elementLocator, inputEmulator, pageController, step.scroll);
|
|
694
|
+
} else if (step.console !== undefined) {
|
|
695
|
+
stepResult.action = 'console';
|
|
696
|
+
stepResult.params = step.console;
|
|
697
|
+
stepResult.output = await executeConsole(deps.consoleCapture, step.console);
|
|
698
|
+
} else if (step.pdf !== undefined) {
|
|
699
|
+
stepResult.action = 'pdf';
|
|
700
|
+
stepResult.params = step.pdf;
|
|
701
|
+
const pdfResult = await executePdf(deps.pdfCapture, elementLocator, step.pdf);
|
|
702
|
+
stepResult.output = pdfResult;
|
|
703
|
+
} else if (step.eval !== undefined) {
|
|
704
|
+
stepResult.action = 'eval';
|
|
705
|
+
stepResult.params = step.eval;
|
|
706
|
+
stepResult.output = await executeEval(pageController, step.eval);
|
|
707
|
+
} else if (step.snapshot !== undefined) {
|
|
708
|
+
stepResult.action = 'snapshot';
|
|
709
|
+
stepResult.params = step.snapshot;
|
|
710
|
+
stepResult.output = await executeSnapshot(deps.ariaSnapshot, step.snapshot);
|
|
711
|
+
} else if (step.hover !== undefined) {
|
|
712
|
+
stepResult.action = 'hover';
|
|
713
|
+
stepResult.params = step.hover;
|
|
714
|
+
await executeHover(elementLocator, inputEmulator, deps.ariaSnapshot, step.hover);
|
|
715
|
+
} else if (step.viewport !== undefined) {
|
|
716
|
+
stepResult.action = 'viewport';
|
|
717
|
+
stepResult.params = step.viewport;
|
|
718
|
+
const viewportResult = await pageController.setViewport(step.viewport);
|
|
719
|
+
stepResult.output = viewportResult;
|
|
720
|
+
} else if (step.cookies !== undefined) {
|
|
721
|
+
stepResult.action = 'cookies';
|
|
722
|
+
stepResult.params = step.cookies;
|
|
723
|
+
stepResult.output = await executeCookies(deps.cookieManager, step.cookies);
|
|
724
|
+
} else if (step.back !== undefined) {
|
|
725
|
+
stepResult.action = 'back';
|
|
726
|
+
stepResult.params = step.back;
|
|
727
|
+
const backOptions = step.back === true ? {} : step.back;
|
|
728
|
+
const entry = await pageController.goBack(backOptions);
|
|
729
|
+
stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
|
|
730
|
+
} else if (step.forward !== undefined) {
|
|
731
|
+
stepResult.action = 'forward';
|
|
732
|
+
stepResult.params = step.forward;
|
|
733
|
+
const forwardOptions = step.forward === true ? {} : step.forward;
|
|
734
|
+
const entry = await pageController.goForward(forwardOptions);
|
|
735
|
+
stepResult.output = entry ? { url: entry.url, title: entry.title } : { noHistory: true };
|
|
736
|
+
} else if (step.waitForNavigation !== undefined) {
|
|
737
|
+
stepResult.action = 'waitForNavigation';
|
|
738
|
+
stepResult.params = step.waitForNavigation;
|
|
739
|
+
await executeWaitForNavigation(pageController, step.waitForNavigation);
|
|
740
|
+
} else if (step.listTabs !== undefined) {
|
|
741
|
+
stepResult.action = 'listTabs';
|
|
742
|
+
stepResult.params = step.listTabs;
|
|
743
|
+
stepResult.output = await executeListTabs(deps.browser);
|
|
744
|
+
} else if (step.closeTab !== undefined) {
|
|
745
|
+
stepResult.action = 'closeTab';
|
|
746
|
+
stepResult.params = { targetId: step.closeTab };
|
|
747
|
+
stepResult.output = await executeCloseTab(deps.browser, step.closeTab);
|
|
748
|
+
} else if (step.type !== undefined) {
|
|
749
|
+
stepResult.action = 'type';
|
|
750
|
+
stepResult.params = step.type;
|
|
751
|
+
const keyboardExecutor = createKeyboardExecutor(
|
|
752
|
+
elementLocator.session,
|
|
753
|
+
elementLocator,
|
|
754
|
+
inputEmulator
|
|
755
|
+
);
|
|
756
|
+
stepResult.output = await keyboardExecutor.executeType(step.type);
|
|
757
|
+
} else if (step.select !== undefined) {
|
|
758
|
+
stepResult.action = 'select';
|
|
759
|
+
stepResult.params = step.select;
|
|
760
|
+
const keyboardExecutor = createKeyboardExecutor(
|
|
761
|
+
elementLocator.session,
|
|
762
|
+
elementLocator,
|
|
763
|
+
inputEmulator
|
|
764
|
+
);
|
|
765
|
+
stepResult.output = await keyboardExecutor.executeSelect(step.select);
|
|
766
|
+
} else if (step.validate !== undefined) {
|
|
767
|
+
stepResult.action = 'validate';
|
|
768
|
+
stepResult.params = step.validate;
|
|
769
|
+
stepResult.output = await executeValidate(elementLocator, step.validate);
|
|
770
|
+
} else if (step.submit !== undefined) {
|
|
771
|
+
stepResult.action = 'submit';
|
|
772
|
+
stepResult.params = step.submit;
|
|
773
|
+
stepResult.output = await executeSubmit(elementLocator, step.submit);
|
|
774
|
+
} else if (step.assert !== undefined) {
|
|
775
|
+
stepResult.action = 'assert';
|
|
776
|
+
stepResult.params = step.assert;
|
|
777
|
+
stepResult.output = await executeAssert(pageController, elementLocator, step.assert);
|
|
778
|
+
} else if (step.queryAll !== undefined) {
|
|
779
|
+
stepResult.action = 'queryAll';
|
|
780
|
+
stepResult.params = step.queryAll;
|
|
781
|
+
stepResult.output = await executeQueryAll(elementLocator, step.queryAll);
|
|
782
|
+
} else if (step.switchToFrame !== undefined) {
|
|
783
|
+
stepResult.action = 'switchToFrame';
|
|
784
|
+
stepResult.params = step.switchToFrame;
|
|
785
|
+
stepResult.output = await pageController.switchToFrame(step.switchToFrame);
|
|
786
|
+
} else if (step.switchToMainFrame !== undefined) {
|
|
787
|
+
stepResult.action = 'switchToMainFrame';
|
|
788
|
+
stepResult.params = step.switchToMainFrame;
|
|
789
|
+
stepResult.output = await pageController.switchToMainFrame();
|
|
790
|
+
} else if (step.listFrames !== undefined) {
|
|
791
|
+
stepResult.action = 'listFrames';
|
|
792
|
+
stepResult.params = step.listFrames;
|
|
793
|
+
stepResult.output = await pageController.getFrameTree();
|
|
794
|
+
} else if (step.drag !== undefined) {
|
|
795
|
+
stepResult.action = 'drag';
|
|
796
|
+
stepResult.params = step.drag;
|
|
797
|
+
stepResult.output = await executeDrag(elementLocator, inputEmulator, pageController, step.drag);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
try {
|
|
802
|
+
const stepPromise = executeStepInternal();
|
|
803
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
804
|
+
setTimeout(() => {
|
|
805
|
+
reject(timeoutError(`Step timed out after ${stepTimeout}ms`, stepTimeout));
|
|
806
|
+
}, stepTimeout);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
// Debug: capture before state
|
|
810
|
+
if (debugCapture && stepResult.action) {
|
|
811
|
+
try {
|
|
812
|
+
stepResult.debug = { before: await debugCapture.captureBefore(stepResult.action, stepResult.params) };
|
|
813
|
+
} catch (e) {
|
|
814
|
+
stepResult.debug = { beforeError: e.message };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
await Promise.race([stepPromise, timeoutPromise]);
|
|
819
|
+
|
|
820
|
+
// Debug: capture after state on success
|
|
821
|
+
if (debugCapture && stepResult.action) {
|
|
822
|
+
try {
|
|
823
|
+
stepResult.debug = stepResult.debug || {};
|
|
824
|
+
stepResult.debug.after = await debugCapture.captureAfter(stepResult.action, stepResult.params, 'passed');
|
|
825
|
+
} catch (e) {
|
|
826
|
+
stepResult.debug = stepResult.debug || {};
|
|
827
|
+
stepResult.debug.afterError = e.message;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
} catch (error) {
|
|
831
|
+
// Debug: capture after state on failure
|
|
832
|
+
if (debugCapture && stepResult.action) {
|
|
833
|
+
try {
|
|
834
|
+
stepResult.debug = stepResult.debug || {};
|
|
835
|
+
stepResult.debug.after = await debugCapture.captureAfter(stepResult.action, stepResult.params, 'failed');
|
|
836
|
+
} catch (e) {
|
|
837
|
+
stepResult.debug = stepResult.debug || {};
|
|
838
|
+
stepResult.debug.afterError = e.message;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (isOptional) {
|
|
843
|
+
stepResult.status = 'skipped';
|
|
844
|
+
stepResult.error = `${error.message} (timeout: ${stepTimeout}ms)`;
|
|
845
|
+
} else {
|
|
846
|
+
stepResult.status = 'failed';
|
|
847
|
+
stepResult.error = error.message;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
stepResult.duration = Date.now() - startTime;
|
|
852
|
+
return stepResult;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async function executeWait(elementLocator, params) {
|
|
856
|
+
const waitExecutor = createWaitExecutor(elementLocator.session, elementLocator);
|
|
857
|
+
await waitExecutor.execute(params);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Execute a waitForNavigation step (FR-003)
|
|
862
|
+
* Waits for page navigation to complete
|
|
863
|
+
* @param {Object} pageController - Page controller
|
|
864
|
+
* @param {boolean|Object} params - Wait parameters
|
|
865
|
+
* @returns {Promise<void>}
|
|
866
|
+
*/
|
|
867
|
+
async function executeWaitForNavigation(pageController, params) {
|
|
868
|
+
const options = params === true ? {} : params;
|
|
869
|
+
const timeout = options.timeout || 30000;
|
|
870
|
+
const waitUntil = options.waitUntil || 'load';
|
|
871
|
+
|
|
872
|
+
const session = pageController.session;
|
|
873
|
+
const startTime = Date.now();
|
|
874
|
+
|
|
875
|
+
// Poll for page ready state
|
|
876
|
+
await new Promise((resolve, reject) => {
|
|
877
|
+
const checkNavigation = async () => {
|
|
878
|
+
if (Date.now() - startTime >= timeout) {
|
|
879
|
+
reject(new Error(`Navigation timeout after ${timeout}ms`));
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
try {
|
|
884
|
+
const result = await session.send('Runtime.evaluate', {
|
|
885
|
+
expression: 'document.readyState',
|
|
886
|
+
returnByValue: true
|
|
887
|
+
});
|
|
888
|
+
const readyState = result.result.value;
|
|
889
|
+
|
|
890
|
+
if (waitUntil === 'commit') {
|
|
891
|
+
resolve();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
if (waitUntil === 'domcontentloaded' && (readyState === 'interactive' || readyState === 'complete')) {
|
|
896
|
+
resolve();
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if ((waitUntil === 'load' || waitUntil === 'networkidle') && readyState === 'complete') {
|
|
901
|
+
resolve();
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
} catch {
|
|
905
|
+
// Page might be navigating, continue polling
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
setTimeout(checkNavigation, 100);
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
checkNavigation();
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
async function executeClick(elementLocator, inputEmulator, ariaSnapshot, params) {
|
|
916
|
+
// Delegate to ClickExecutor for improved click handling with JS fallback
|
|
917
|
+
const clickExecutor = createClickExecutor(
|
|
918
|
+
elementLocator.session,
|
|
919
|
+
elementLocator,
|
|
920
|
+
inputEmulator,
|
|
921
|
+
ariaSnapshot
|
|
922
|
+
);
|
|
923
|
+
return clickExecutor.execute(params);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Legacy implementation kept for reference
|
|
927
|
+
async function _legacyExecuteClick(elementLocator, inputEmulator, ariaSnapshot, params) {
|
|
928
|
+
const selector = typeof params === 'string' ? params : params.selector;
|
|
929
|
+
const ref = typeof params === 'object' ? params.ref : null;
|
|
930
|
+
const verify = typeof params === 'object' && params.verify === true;
|
|
931
|
+
let lastError = null;
|
|
932
|
+
|
|
933
|
+
// Handle click by ref
|
|
934
|
+
if (ref && ariaSnapshot) {
|
|
935
|
+
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
936
|
+
if (!refInfo) {
|
|
937
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
938
|
+
}
|
|
939
|
+
// Check if element is stale (no longer in DOM)
|
|
940
|
+
if (refInfo.stale) {
|
|
941
|
+
return {
|
|
942
|
+
clicked: false,
|
|
943
|
+
stale: true,
|
|
944
|
+
warning: `Element ref:${ref} is no longer attached to the DOM. Page content may have changed.`
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
// Check if element is visible
|
|
948
|
+
if (!refInfo.isVisible) {
|
|
949
|
+
return {
|
|
950
|
+
clicked: false,
|
|
951
|
+
warning: `Element ref:${ref} exists but is not visible. It may be hidden or have zero dimensions.`
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
// Click at center of element
|
|
955
|
+
const x = refInfo.box.x + refInfo.box.width / 2;
|
|
956
|
+
const y = refInfo.box.y + refInfo.box.height / 2;
|
|
957
|
+
await inputEmulator.click(x, y);
|
|
958
|
+
|
|
959
|
+
if (verify) {
|
|
960
|
+
// For ref-based clicks with verify, return verification result
|
|
961
|
+
return { clicked: true, targetReceived: true };
|
|
962
|
+
}
|
|
963
|
+
return { clicked: true };
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
for (const strategy of SCROLL_STRATEGIES) {
|
|
967
|
+
const element = await elementLocator.findElement(selector);
|
|
968
|
+
|
|
969
|
+
if (!element) {
|
|
970
|
+
throw elementNotFoundError(selector, 0);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
try {
|
|
974
|
+
await element._handle.scrollIntoView({ block: strategy });
|
|
975
|
+
await element._handle.waitForStability({ frames: 2, timeout: 2000 });
|
|
976
|
+
|
|
977
|
+
const actionable = await element._handle.isActionable();
|
|
978
|
+
if (!actionable.actionable) {
|
|
979
|
+
await element._handle.dispose();
|
|
980
|
+
lastError = new Error(`Element not actionable: ${actionable.reason}`);
|
|
981
|
+
continue; // Try next scroll strategy
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const box = await element._handle.getBoundingBox();
|
|
985
|
+
const x = box.x + box.width / 2;
|
|
986
|
+
const y = box.y + box.height / 2;
|
|
987
|
+
|
|
988
|
+
if (verify) {
|
|
989
|
+
const result = await clickWithVerification(elementLocator, inputEmulator, x, y, element._handle.objectId);
|
|
990
|
+
await element._handle.dispose();
|
|
991
|
+
return result;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
await inputEmulator.click(x, y);
|
|
995
|
+
await element._handle.dispose();
|
|
996
|
+
return; // Success
|
|
997
|
+
} catch (e) {
|
|
998
|
+
await element._handle.dispose();
|
|
999
|
+
lastError = e;
|
|
1000
|
+
if (strategy === SCROLL_STRATEGIES[SCROLL_STRATEGIES.length - 1]) {
|
|
1001
|
+
// Reset input state before throwing to prevent subsequent operation timeouts
|
|
1002
|
+
await resetInputState(elementLocator.session);
|
|
1003
|
+
throw e; // Last strategy failed
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
if (lastError) {
|
|
1009
|
+
// Reset input state before throwing to prevent subsequent operation timeouts
|
|
1010
|
+
await resetInputState(elementLocator.session);
|
|
1011
|
+
throw lastError;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
async function clickWithVerification(elementLocator, inputEmulator, x, y, targetObjectId) {
|
|
1016
|
+
const session = elementLocator.session;
|
|
1017
|
+
|
|
1018
|
+
// Setup event listener on target before clicking
|
|
1019
|
+
await session.send('Runtime.callFunctionOn', {
|
|
1020
|
+
objectId: targetObjectId,
|
|
1021
|
+
functionDeclaration: `function() {
|
|
1022
|
+
this.__clickReceived = false;
|
|
1023
|
+
this.__clickHandler = () => { this.__clickReceived = true; };
|
|
1024
|
+
this.addEventListener('click', this.__clickHandler, { once: true });
|
|
1025
|
+
}`
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
// Perform click
|
|
1029
|
+
await inputEmulator.click(x, y);
|
|
1030
|
+
await sleep(50);
|
|
1031
|
+
|
|
1032
|
+
// Check if target received the click
|
|
1033
|
+
const verifyResult = await session.send('Runtime.callFunctionOn', {
|
|
1034
|
+
objectId: targetObjectId,
|
|
1035
|
+
functionDeclaration: `function() {
|
|
1036
|
+
this.removeEventListener('click', this.__clickHandler);
|
|
1037
|
+
const received = this.__clickReceived;
|
|
1038
|
+
delete this.__clickReceived;
|
|
1039
|
+
delete this.__clickHandler;
|
|
1040
|
+
return received;
|
|
1041
|
+
}`,
|
|
1042
|
+
returnByValue: true
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
return {
|
|
1046
|
+
clicked: true,
|
|
1047
|
+
targetReceived: verifyResult.result.value === true
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
async function executeFill(elementLocator, inputEmulator, params) {
|
|
1052
|
+
const { selector, value, react } = params;
|
|
1053
|
+
|
|
1054
|
+
if (!selector || value === undefined) {
|
|
1055
|
+
throw new Error('Fill requires selector and value');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const element = await elementLocator.findElement(selector);
|
|
1059
|
+
if (!element) {
|
|
1060
|
+
throw elementNotFoundError(selector, 0);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
// Validate element is editable before attempting fill
|
|
1064
|
+
const validator = createElementValidator(elementLocator.session);
|
|
1065
|
+
const editableCheck = await validator.isEditable(element._handle.objectId);
|
|
1066
|
+
if (!editableCheck.editable) {
|
|
1067
|
+
await element._handle.dispose();
|
|
1068
|
+
throw elementNotEditableError(selector, editableCheck.reason);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// Try fast path first - scroll to center with short stability check
|
|
1072
|
+
let actionable;
|
|
1073
|
+
try {
|
|
1074
|
+
await element._handle.scrollIntoView({ block: 'center' });
|
|
1075
|
+
// Use short stability timeout - most elements stabilize quickly
|
|
1076
|
+
await element._handle.waitForStability({ frames: 2, timeout: 300 });
|
|
1077
|
+
actionable = await element._handle.isActionable();
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
// Stability check failed, check actionability anyway
|
|
1080
|
+
actionable = await element._handle.isActionable();
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// If not actionable, try alternative scroll strategies
|
|
1084
|
+
if (!actionable.actionable) {
|
|
1085
|
+
let lastError = new Error(`Element not actionable: ${actionable.reason}`);
|
|
1086
|
+
|
|
1087
|
+
for (const strategy of ['end', 'start', 'nearest']) {
|
|
1088
|
+
try {
|
|
1089
|
+
await element._handle.scrollIntoView({ block: strategy });
|
|
1090
|
+
await element._handle.waitForStability({ frames: 2, timeout: 500 });
|
|
1091
|
+
actionable = await element._handle.isActionable();
|
|
1092
|
+
|
|
1093
|
+
if (actionable.actionable) break;
|
|
1094
|
+
lastError = new Error(`Element not actionable: ${actionable.reason}`);
|
|
1095
|
+
} catch (e) {
|
|
1096
|
+
lastError = e;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
if (!actionable.actionable) {
|
|
1101
|
+
await element._handle.dispose();
|
|
1102
|
+
await resetInputState(elementLocator.session);
|
|
1103
|
+
throw lastError;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
try {
|
|
1108
|
+
// Use React-specific fill approach if react option is set
|
|
1109
|
+
if (react) {
|
|
1110
|
+
const reactFiller = createReactInputFiller(elementLocator.session);
|
|
1111
|
+
await reactFiller.fillByObjectId(element._handle.objectId, value);
|
|
1112
|
+
return; // Success
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Standard fill approach using keyboard events
|
|
1116
|
+
const box = await element._handle.getBoundingBox();
|
|
1117
|
+
const x = box.x + box.width / 2;
|
|
1118
|
+
const y = box.y + box.height / 2;
|
|
1119
|
+
|
|
1120
|
+
// Click to focus
|
|
1121
|
+
await inputEmulator.click(x, y);
|
|
1122
|
+
|
|
1123
|
+
// Focus element directly - more reliable than relying on click
|
|
1124
|
+
await element._handle.focus();
|
|
1125
|
+
|
|
1126
|
+
if (params.clear !== false) {
|
|
1127
|
+
await inputEmulator.selectAll();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
await inputEmulator.type(String(value));
|
|
1131
|
+
} catch (e) {
|
|
1132
|
+
await resetInputState(elementLocator.session);
|
|
1133
|
+
throw e;
|
|
1134
|
+
} finally {
|
|
1135
|
+
await element._handle.dispose();
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
async function executeScreenshot(screenshotCapture, elementLocator, params) {
|
|
1140
|
+
const rawPath = typeof params === 'string' ? params : params.path;
|
|
1141
|
+
const options = typeof params === 'object' ? params : {};
|
|
1142
|
+
|
|
1143
|
+
// Resolve path - relative paths go to platform temp directory
|
|
1144
|
+
const format = options.format || 'png';
|
|
1145
|
+
const resolvedPath = await resolveTempPath(rawPath, `.${format}`);
|
|
1146
|
+
|
|
1147
|
+
// Get viewport dimensions before capturing
|
|
1148
|
+
const viewport = await screenshotCapture.getViewportDimensions();
|
|
1149
|
+
|
|
1150
|
+
// Pass elementLocator for element screenshots
|
|
1151
|
+
const savedPath = await screenshotCapture.captureToFile(resolvedPath, options, elementLocator);
|
|
1152
|
+
|
|
1153
|
+
// Return metadata including viewport dimensions
|
|
1154
|
+
return {
|
|
1155
|
+
path: savedPath,
|
|
1156
|
+
viewport,
|
|
1157
|
+
format,
|
|
1158
|
+
fullPage: options.fullPage || false,
|
|
1159
|
+
selector: options.selector || null
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/**
|
|
1164
|
+
* Execute a PDF generation step
|
|
1165
|
+
* Supports element PDF via selector option (FR-060)
|
|
1166
|
+
* Returns metadata including file size, page count, dimensions (FR-059)
|
|
1167
|
+
* Supports validation option (FR-061)
|
|
1168
|
+
*/
|
|
1169
|
+
async function executePdf(pdfCapture, elementLocator, params) {
|
|
1170
|
+
if (!pdfCapture) {
|
|
1171
|
+
throw new Error('PDF capture not available');
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
const rawPath = typeof params === 'string' ? params : params.path;
|
|
1175
|
+
const options = typeof params === 'object' ? params : {};
|
|
1176
|
+
|
|
1177
|
+
// Resolve path - relative paths go to platform temp directory
|
|
1178
|
+
const resolvedPath = await resolveTempPath(rawPath, '.pdf');
|
|
1179
|
+
|
|
1180
|
+
// Pass elementLocator for element PDFs
|
|
1181
|
+
return pdfCapture.saveToFile(resolvedPath, options, elementLocator);
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Execute an eval step - executes JavaScript in the page context
|
|
1186
|
+
* Enhanced with serialization for non-JSON values (FR-039, FR-040, FR-041)
|
|
1187
|
+
* and optional timeout for async operations (FR-042)
|
|
1188
|
+
*/
|
|
1189
|
+
async function executeEval(pageController, params) {
|
|
1190
|
+
const expression = typeof params === 'string' ? params : params.expression;
|
|
1191
|
+
const awaitPromise = typeof params === 'object' && params.await === true;
|
|
1192
|
+
const serialize = typeof params === 'object' && params.serialize !== false;
|
|
1193
|
+
const evalTimeout = typeof params === 'object' && typeof params.timeout === 'number' ? params.timeout : null;
|
|
1194
|
+
|
|
1195
|
+
// Validate the expression
|
|
1196
|
+
if (!expression || typeof expression !== 'string') {
|
|
1197
|
+
throw new Error('Eval requires a non-empty expression string');
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Check for common shell escaping issues
|
|
1201
|
+
const hasUnbalancedQuotes = (expression.match(/"/g) || []).length % 2 !== 0 ||
|
|
1202
|
+
(expression.match(/'/g) || []).length % 2 !== 0;
|
|
1203
|
+
const hasUnbalancedBraces = (expression.match(/\{/g) || []).length !== (expression.match(/\}/g) || []).length;
|
|
1204
|
+
const hasUnbalancedParens = (expression.match(/\(/g) || []).length !== (expression.match(/\)/g) || []).length;
|
|
1205
|
+
|
|
1206
|
+
if (hasUnbalancedQuotes || hasUnbalancedBraces || hasUnbalancedParens) {
|
|
1207
|
+
const issues = [];
|
|
1208
|
+
if (hasUnbalancedQuotes) issues.push('unbalanced quotes');
|
|
1209
|
+
if (hasUnbalancedBraces) issues.push('unbalanced braces {}');
|
|
1210
|
+
if (hasUnbalancedParens) issues.push('unbalanced parentheses ()');
|
|
1211
|
+
|
|
1212
|
+
throw new Error(
|
|
1213
|
+
`Eval expression appears malformed (${issues.join(', ')}). ` +
|
|
1214
|
+
`This often happens due to shell escaping. Expression preview: "${expression.substring(0, 100)}${expression.length > 100 ? '...' : ''}". ` +
|
|
1215
|
+
`Tip: Use heredoc syntax or a JSON file to pass complex expressions.`
|
|
1216
|
+
);
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// Build the wrapped expression for serialization
|
|
1220
|
+
let wrappedExpression;
|
|
1221
|
+
if (serialize) {
|
|
1222
|
+
// Use EvalSerializer for enhanced value handling
|
|
1223
|
+
const evalSerializer = createEvalSerializer();
|
|
1224
|
+
const serializerFn = evalSerializer.getSerializationFunction();
|
|
1225
|
+
wrappedExpression = `(${serializerFn})(${expression})`;
|
|
1226
|
+
} else {
|
|
1227
|
+
wrappedExpression = expression;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Create the eval promise
|
|
1231
|
+
const evalPromise = pageController.session.send('Runtime.evaluate', {
|
|
1232
|
+
expression: wrappedExpression,
|
|
1233
|
+
returnByValue: true,
|
|
1234
|
+
awaitPromise
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
// Apply timeout if specified (FR-042)
|
|
1238
|
+
let result;
|
|
1239
|
+
if (evalTimeout !== null && evalTimeout > 0) {
|
|
1240
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
1241
|
+
setTimeout(() => {
|
|
1242
|
+
reject(new Error(`Eval timed out after ${evalTimeout}ms`));
|
|
1243
|
+
}, evalTimeout);
|
|
1244
|
+
});
|
|
1245
|
+
result = await Promise.race([evalPromise, timeoutPromise]);
|
|
1246
|
+
} else {
|
|
1247
|
+
result = await evalPromise;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (result.exceptionDetails) {
|
|
1251
|
+
const errorText = result.exceptionDetails.exception?.description ||
|
|
1252
|
+
result.exceptionDetails.text ||
|
|
1253
|
+
'Unknown eval error';
|
|
1254
|
+
|
|
1255
|
+
// Provide more context for syntax errors
|
|
1256
|
+
if (errorText.includes('SyntaxError')) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`Eval syntax error: ${errorText}. ` +
|
|
1259
|
+
`Expression was: "${expression.substring(0, 150)}${expression.length > 150 ? '...' : ''}". ` +
|
|
1260
|
+
`Tip: Check for shell escaping issues or use a JSON file for complex expressions.`
|
|
1261
|
+
);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
throw new Error(`Eval error: ${errorText}`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Process serialized result if serialization was used
|
|
1268
|
+
if (serialize && result.result.value && typeof result.result.value === 'object') {
|
|
1269
|
+
const evalSerializer = createEvalSerializer();
|
|
1270
|
+
return evalSerializer.processResult(result.result.value);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return {
|
|
1274
|
+
value: result.result.value,
|
|
1275
|
+
type: result.result.type
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Execute a snapshot step - generates accessibility tree snapshot
|
|
1281
|
+
*/
|
|
1282
|
+
async function executeSnapshot(ariaSnapshot, params) {
|
|
1283
|
+
if (!ariaSnapshot) {
|
|
1284
|
+
throw new Error('Aria snapshot not available');
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const options = params === true ? {} : params;
|
|
1288
|
+
const result = await ariaSnapshot.generate(options);
|
|
1289
|
+
|
|
1290
|
+
if (result.error) {
|
|
1291
|
+
throw new Error(result.error);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
return {
|
|
1295
|
+
yaml: result.yaml,
|
|
1296
|
+
refs: result.refs,
|
|
1297
|
+
stats: result.stats
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
/**
|
|
1302
|
+
* Execute a hover step - moves mouse over an element to trigger hover events
|
|
1303
|
+
* Uses Playwright-style auto-waiting for element to be visible and stable
|
|
1304
|
+
*/
|
|
1305
|
+
async function executeHover(elementLocator, inputEmulator, ariaSnapshot, params) {
|
|
1306
|
+
const selector = typeof params === 'string' ? params : params.selector;
|
|
1307
|
+
const ref = typeof params === 'object' ? params.ref : null;
|
|
1308
|
+
const duration = typeof params === 'object' ? (params.duration || 0) : 0;
|
|
1309
|
+
const force = typeof params === 'object' && params.force === true;
|
|
1310
|
+
const timeout = typeof params === 'object' ? (params.timeout || 30000) : 30000;
|
|
1311
|
+
|
|
1312
|
+
// Handle hover by ref
|
|
1313
|
+
if (ref && ariaSnapshot) {
|
|
1314
|
+
const refInfo = await ariaSnapshot.getElementByRef(ref);
|
|
1315
|
+
if (!refInfo) {
|
|
1316
|
+
throw elementNotFoundError(`ref:${ref}`, 0);
|
|
1317
|
+
}
|
|
1318
|
+
const x = refInfo.box.x + refInfo.box.width / 2;
|
|
1319
|
+
const y = refInfo.box.y + refInfo.box.height / 2;
|
|
1320
|
+
await inputEmulator.hover(x, y, { duration });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Use Playwright-style auto-waiting for element to be actionable
|
|
1325
|
+
// Hover requires: visible, stable
|
|
1326
|
+
const actionabilityChecker = createActionabilityChecker(elementLocator.session);
|
|
1327
|
+
const waitResult = await actionabilityChecker.waitForActionable(selector, 'hover', {
|
|
1328
|
+
timeout,
|
|
1329
|
+
force
|
|
1330
|
+
});
|
|
1331
|
+
|
|
1332
|
+
if (!waitResult.success) {
|
|
1333
|
+
throw new Error(`Element not actionable: ${waitResult.error}`);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// Get clickable point for hovering
|
|
1337
|
+
const point = await actionabilityChecker.getClickablePoint(waitResult.objectId);
|
|
1338
|
+
if (!point) {
|
|
1339
|
+
throw new Error('Could not determine hover point for element');
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
await inputEmulator.hover(point.x, point.y, { duration });
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/**
|
|
1346
|
+
* Execute a drag operation from source to target
|
|
1347
|
+
* @param {Object} elementLocator - Element locator instance
|
|
1348
|
+
* @param {Object} inputEmulator - Input emulator instance
|
|
1349
|
+
* @param {Object} pageController - Page controller instance
|
|
1350
|
+
* @param {Object} params - Drag parameters
|
|
1351
|
+
* @returns {Promise<Object>} Drag result
|
|
1352
|
+
*/
|
|
1353
|
+
async function executeDrag(elementLocator, inputEmulator, pageController, params) {
|
|
1354
|
+
const { source, target, steps = 10, delay = 0 } = params;
|
|
1355
|
+
|
|
1356
|
+
// Helper to get element bounding box in current frame context
|
|
1357
|
+
async function getElementBox(selector) {
|
|
1358
|
+
// Use page controller's frame context if available
|
|
1359
|
+
const contextId = pageController.currentExecutionContextId;
|
|
1360
|
+
const evalParams = {
|
|
1361
|
+
expression: `
|
|
1362
|
+
(function() {
|
|
1363
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
1364
|
+
if (!el) return null;
|
|
1365
|
+
const rect = el.getBoundingClientRect();
|
|
1366
|
+
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
1367
|
+
})()
|
|
1368
|
+
`,
|
|
1369
|
+
returnByValue: true
|
|
1370
|
+
};
|
|
1371
|
+
|
|
1372
|
+
// Add context ID if we're in a non-main frame
|
|
1373
|
+
if (contextId && pageController.currentFrameId !== pageController.mainFrameId) {
|
|
1374
|
+
evalParams.contextId = contextId;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const result = await elementLocator.session.send('Runtime.evaluate', evalParams);
|
|
1378
|
+
if (result.exceptionDetails) {
|
|
1379
|
+
throw new Error(`Selector error: ${result.exceptionDetails.text}`);
|
|
1380
|
+
}
|
|
1381
|
+
return result.result.value;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Get source coordinates
|
|
1385
|
+
let sourceX, sourceY;
|
|
1386
|
+
if (typeof source === 'object' && typeof source.x === 'number' && typeof source.y === 'number') {
|
|
1387
|
+
sourceX = source.x;
|
|
1388
|
+
sourceY = source.y;
|
|
1389
|
+
} else {
|
|
1390
|
+
const sourceSelector = typeof source === 'string' ? source : source.selector;
|
|
1391
|
+
const box = await getElementBox(sourceSelector);
|
|
1392
|
+
if (!box) {
|
|
1393
|
+
throw elementNotFoundError(sourceSelector, 0);
|
|
1394
|
+
}
|
|
1395
|
+
sourceX = box.x + box.width / 2;
|
|
1396
|
+
sourceY = box.y + box.height / 2;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Get target coordinates
|
|
1400
|
+
let targetX, targetY;
|
|
1401
|
+
if (typeof target === 'object' && typeof target.x === 'number' && typeof target.y === 'number') {
|
|
1402
|
+
targetX = target.x;
|
|
1403
|
+
targetY = target.y;
|
|
1404
|
+
} else {
|
|
1405
|
+
const targetSelector = typeof target === 'string' ? target : target.selector;
|
|
1406
|
+
const box = await getElementBox(targetSelector);
|
|
1407
|
+
if (!box) {
|
|
1408
|
+
throw elementNotFoundError(targetSelector, 0);
|
|
1409
|
+
}
|
|
1410
|
+
targetX = box.x + box.width / 2;
|
|
1411
|
+
targetY = box.y + box.height / 2;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// Perform the drag operation using CDP mouse events
|
|
1415
|
+
// Move to source
|
|
1416
|
+
await elementLocator.session.send('Input.dispatchMouseEvent', {
|
|
1417
|
+
type: 'mouseMoved',
|
|
1418
|
+
x: sourceX,
|
|
1419
|
+
y: sourceY
|
|
1420
|
+
});
|
|
1421
|
+
|
|
1422
|
+
// Press mouse button
|
|
1423
|
+
await elementLocator.session.send('Input.dispatchMouseEvent', {
|
|
1424
|
+
type: 'mousePressed',
|
|
1425
|
+
x: sourceX,
|
|
1426
|
+
y: sourceY,
|
|
1427
|
+
button: 'left',
|
|
1428
|
+
clickCount: 1,
|
|
1429
|
+
buttons: 1
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
if (delay > 0) {
|
|
1433
|
+
await sleep(delay);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// Move in steps for smoother drag
|
|
1437
|
+
const deltaX = (targetX - sourceX) / steps;
|
|
1438
|
+
const deltaY = (targetY - sourceY) / steps;
|
|
1439
|
+
|
|
1440
|
+
for (let i = 1; i <= steps; i++) {
|
|
1441
|
+
const currentX = sourceX + deltaX * i;
|
|
1442
|
+
const currentY = sourceY + deltaY * i;
|
|
1443
|
+
|
|
1444
|
+
await elementLocator.session.send('Input.dispatchMouseEvent', {
|
|
1445
|
+
type: 'mouseMoved',
|
|
1446
|
+
x: currentX,
|
|
1447
|
+
y: currentY,
|
|
1448
|
+
buttons: 1
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
if (delay > 0) {
|
|
1452
|
+
await sleep(delay / steps);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
// Release mouse button
|
|
1457
|
+
await elementLocator.session.send('Input.dispatchMouseEvent', {
|
|
1458
|
+
type: 'mouseReleased',
|
|
1459
|
+
x: targetX,
|
|
1460
|
+
y: targetY,
|
|
1461
|
+
button: 'left',
|
|
1462
|
+
clickCount: 1,
|
|
1463
|
+
buttons: 0
|
|
1464
|
+
});
|
|
1465
|
+
|
|
1466
|
+
return {
|
|
1467
|
+
dragged: true,
|
|
1468
|
+
source: { x: sourceX, y: sourceY },
|
|
1469
|
+
target: { x: targetX, y: targetY },
|
|
1470
|
+
steps
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Parse human-readable expiration string to Unix timestamp
|
|
1476
|
+
* Supports: "1h" (hours), "7d" (days), "30m" (minutes), "1w" (weeks), "1y" (years)
|
|
1477
|
+
* @param {string|number} expires - Expiration value
|
|
1478
|
+
* @returns {number} Unix timestamp in seconds
|
|
1479
|
+
*/
|
|
1480
|
+
function parseExpiration(expires) {
|
|
1481
|
+
if (typeof expires === 'number') {
|
|
1482
|
+
return expires;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (typeof expires !== 'string') {
|
|
1486
|
+
return undefined;
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const match = expires.match(/^(\d+)([mhdwy])$/i);
|
|
1490
|
+
if (!match) {
|
|
1491
|
+
// Try parsing as number string
|
|
1492
|
+
const num = parseInt(expires, 10);
|
|
1493
|
+
if (!isNaN(num)) return num;
|
|
1494
|
+
return undefined;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
const value = parseInt(match[1], 10);
|
|
1498
|
+
const unit = match[2].toLowerCase();
|
|
1499
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1500
|
+
|
|
1501
|
+
switch (unit) {
|
|
1502
|
+
case 'm': return now + value * 60; // minutes
|
|
1503
|
+
case 'h': return now + value * 60 * 60; // hours
|
|
1504
|
+
case 'd': return now + value * 60 * 60 * 24; // days
|
|
1505
|
+
case 'w': return now + value * 60 * 60 * 24 * 7; // weeks
|
|
1506
|
+
case 'y': return now + value * 60 * 60 * 24 * 365; // years
|
|
1507
|
+
default: return undefined;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Execute a cookies step - get, set, or clear cookies
|
|
1513
|
+
*/
|
|
1514
|
+
async function executeCookies(cookieManager, params) {
|
|
1515
|
+
if (!cookieManager) {
|
|
1516
|
+
throw new Error('Cookie manager not available');
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Determine the action
|
|
1520
|
+
if (params.get !== undefined || params.action === 'get') {
|
|
1521
|
+
const urls = Array.isArray(params.get) ? params.get : (params.urls || []);
|
|
1522
|
+
let cookies = await cookieManager.getCookies(urls);
|
|
1523
|
+
|
|
1524
|
+
// Filter by name if specified
|
|
1525
|
+
if (params.name) {
|
|
1526
|
+
const names = Array.isArray(params.name) ? params.name : [params.name];
|
|
1527
|
+
cookies = cookies.filter(c => names.includes(c.name));
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
return { action: 'get', cookies };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
if (params.set !== undefined || params.action === 'set') {
|
|
1534
|
+
const cookies = params.set || params.cookies || [];
|
|
1535
|
+
if (!Array.isArray(cookies)) {
|
|
1536
|
+
throw new Error('cookies set requires an array of cookie objects');
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Process cookies to convert human-readable expires values
|
|
1540
|
+
const processedCookies = cookies.map(cookie => {
|
|
1541
|
+
const processed = { ...cookie };
|
|
1542
|
+
if (processed.expires !== undefined) {
|
|
1543
|
+
processed.expires = parseExpiration(processed.expires);
|
|
1544
|
+
}
|
|
1545
|
+
return processed;
|
|
1546
|
+
});
|
|
1547
|
+
|
|
1548
|
+
await cookieManager.setCookies(processedCookies);
|
|
1549
|
+
return { action: 'set', count: processedCookies.length };
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (params.clear !== undefined || params.action === 'clear') {
|
|
1553
|
+
const urls = Array.isArray(params.clear) ? params.clear : [];
|
|
1554
|
+
const result = await cookieManager.clearCookies(urls);
|
|
1555
|
+
return { action: 'clear', count: result.count };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (params.delete !== undefined || params.action === 'delete') {
|
|
1559
|
+
const names = params.delete || params.names;
|
|
1560
|
+
if (!names) {
|
|
1561
|
+
throw new Error('cookies delete requires cookie name(s)');
|
|
1562
|
+
}
|
|
1563
|
+
const options = {};
|
|
1564
|
+
if (params.domain) options.domain = params.domain;
|
|
1565
|
+
if (params.path) options.path = params.path;
|
|
1566
|
+
const result = await cookieManager.deleteCookies(names, options);
|
|
1567
|
+
return { action: 'delete', count: result.count };
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
throw new Error('cookies requires action: get, set, clear, or delete');
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
/**
|
|
1574
|
+
|
|
1575
|
+
/**
|
|
1576
|
+
* Execute a query step - finds elements and returns info about them
|
|
1577
|
+
* Supports both CSS selectors and role-based queries
|
|
1578
|
+
*
|
|
1579
|
+
* Features:
|
|
1580
|
+
* - FR-016: Text cleanup with clean option
|
|
1581
|
+
* - FR-017: Multiple output modes via array
|
|
1582
|
+
* - FR-018: Attribute output via object
|
|
1583
|
+
* - FR-019: Element metadata in results
|
|
1584
|
+
*/
|
|
1585
|
+
async function executeQuery(elementLocator, params) {
|
|
1586
|
+
// Check if this is a role-based query
|
|
1587
|
+
if (typeof params === 'object' && params.role) {
|
|
1588
|
+
return executeRoleQuery(elementLocator, params);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
const selector = typeof params === 'string' ? params : params.selector;
|
|
1592
|
+
const limit = (typeof params === 'object' && params.limit) || 10;
|
|
1593
|
+
const output = (typeof params === 'object' && params.output) || 'text';
|
|
1594
|
+
const clean = typeof params === 'object' && params.clean === true;
|
|
1595
|
+
const metadata = typeof params === 'object' && params.metadata === true;
|
|
1596
|
+
|
|
1597
|
+
const elements = await elementLocator.querySelectorAll(selector);
|
|
1598
|
+
const outputProcessor = createQueryOutputProcessor(elementLocator.session);
|
|
1599
|
+
const results = [];
|
|
1600
|
+
|
|
1601
|
+
const count = Math.min(elements.length, limit);
|
|
1602
|
+
for (let i = 0; i < count; i++) {
|
|
1603
|
+
const el = elements[i];
|
|
1604
|
+
try {
|
|
1605
|
+
const resultItem = {
|
|
1606
|
+
index: i + 1,
|
|
1607
|
+
value: await outputProcessor.processOutput(el, output, { clean })
|
|
1608
|
+
};
|
|
1609
|
+
|
|
1610
|
+
// Add element metadata if requested (FR-019)
|
|
1611
|
+
if (metadata) {
|
|
1612
|
+
resultItem.metadata = await outputProcessor.getElementMetadata(el);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
results.push(resultItem);
|
|
1616
|
+
} catch (e) {
|
|
1617
|
+
results.push({ index: i + 1, value: null, error: e.message });
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// Dispose all elements
|
|
1622
|
+
for (const el of elements) {
|
|
1623
|
+
try { await el.dispose(); } catch { /* ignore */ }
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
return {
|
|
1627
|
+
selector,
|
|
1628
|
+
total: elements.length,
|
|
1629
|
+
showing: count,
|
|
1630
|
+
results
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* Execute a role-based query - finds elements by ARIA role
|
|
1636
|
+
* Supported roles: button, textbox, checkbox, link, heading, listitem, option, combobox
|
|
1637
|
+
*
|
|
1638
|
+
* Features:
|
|
1639
|
+
* - FR-020: Role level filter for headings
|
|
1640
|
+
* - FR-021: Compound role queries (array of roles)
|
|
1641
|
+
* - FR-055: Exact match option (nameExact)
|
|
1642
|
+
* - FR-056: Regex support (nameRegex)
|
|
1643
|
+
* - FR-057: Element refs in results
|
|
1644
|
+
* - FR-058: Count-only mode
|
|
1645
|
+
*/
|
|
1646
|
+
async function executeRoleQuery(elementLocator, params) {
|
|
1647
|
+
const roleQueryExecutor = createRoleQueryExecutor(elementLocator.session, elementLocator);
|
|
1648
|
+
return roleQueryExecutor.execute(params);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
async function executeInspect(pageController, elementLocator, params) {
|
|
1652
|
+
const info = {
|
|
1653
|
+
title: await pageController.getTitle(),
|
|
1654
|
+
url: await pageController.getUrl()
|
|
1655
|
+
};
|
|
1656
|
+
|
|
1657
|
+
// Count common element types
|
|
1658
|
+
const counts = {};
|
|
1659
|
+
const selectors = ['a', 'button', 'input', 'textarea', 'select', 'h1', 'h2', 'h3', 'img', 'form'];
|
|
1660
|
+
|
|
1661
|
+
for (const sel of selectors) {
|
|
1662
|
+
try {
|
|
1663
|
+
const els = await elementLocator.querySelectorAll(sel);
|
|
1664
|
+
counts[sel] = els.length;
|
|
1665
|
+
for (const el of els) {
|
|
1666
|
+
try { await el.dispose(); } catch (e) { /* ignore */ }
|
|
1667
|
+
}
|
|
1668
|
+
} catch (e) {
|
|
1669
|
+
counts[sel] = 0;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
info.elements = counts;
|
|
1674
|
+
|
|
1675
|
+
// If specific selectors requested with optional limit for showing values
|
|
1676
|
+
if (typeof params === 'object' && params.selectors) {
|
|
1677
|
+
info.custom = {};
|
|
1678
|
+
const limit = params.limit || 0;
|
|
1679
|
+
|
|
1680
|
+
for (const sel of params.selectors) {
|
|
1681
|
+
try {
|
|
1682
|
+
const els = await elementLocator.querySelectorAll(sel);
|
|
1683
|
+
const count = els.length;
|
|
1684
|
+
|
|
1685
|
+
if (limit > 0 && count > 0) {
|
|
1686
|
+
const values = [];
|
|
1687
|
+
const showCount = Math.min(count, limit);
|
|
1688
|
+
for (let i = 0; i < showCount; i++) {
|
|
1689
|
+
try {
|
|
1690
|
+
const text = await els[i].evaluate(
|
|
1691
|
+
`function() { return this.textContent ? this.textContent.trim().substring(0, 100) : ''; }`
|
|
1692
|
+
);
|
|
1693
|
+
values.push(text);
|
|
1694
|
+
} catch (e) {
|
|
1695
|
+
values.push(null);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
info.custom[sel] = { count, values };
|
|
1699
|
+
} else {
|
|
1700
|
+
info.custom[sel] = count;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
for (const el of els) {
|
|
1704
|
+
try { await el.dispose(); } catch (e) { /* ignore */ }
|
|
1705
|
+
}
|
|
1706
|
+
} catch (e) {
|
|
1707
|
+
info.custom[sel] = 0;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
return info;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/**
|
|
1716
|
+
* Execute a listTabs step - returns all open browser tabs
|
|
1717
|
+
*/
|
|
1718
|
+
async function executeListTabs(browser) {
|
|
1719
|
+
if (!browser) {
|
|
1720
|
+
throw new Error('Browser not available for listTabs');
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const pages = await browser.getPages();
|
|
1724
|
+
const tabs = pages.map(page => ({
|
|
1725
|
+
targetId: page.targetId,
|
|
1726
|
+
url: page.url,
|
|
1727
|
+
title: page.title
|
|
1728
|
+
}));
|
|
1729
|
+
|
|
1730
|
+
return {
|
|
1731
|
+
count: tabs.length,
|
|
1732
|
+
tabs
|
|
1733
|
+
};
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Execute a closeTab step - closes a browser tab by targetId
|
|
1738
|
+
*/
|
|
1739
|
+
async function executeCloseTab(browser, targetId) {
|
|
1740
|
+
if (!browser) {
|
|
1741
|
+
throw new Error('Browser not available for closeTab');
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
await browser.closePage(targetId);
|
|
1745
|
+
return { closed: targetId };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* Format a stack trace for output
|
|
1750
|
+
* @param {Object} stackTrace - CDP stack trace object
|
|
1751
|
+
* @returns {Array|null} Formatted stack frames or null
|
|
1752
|
+
*/
|
|
1753
|
+
function formatStackTrace(stackTrace) {
|
|
1754
|
+
if (!stackTrace || !stackTrace.callFrames) {
|
|
1755
|
+
return null;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
return stackTrace.callFrames.map(frame => ({
|
|
1759
|
+
functionName: frame.functionName || '(anonymous)',
|
|
1760
|
+
url: frame.url || null,
|
|
1761
|
+
lineNumber: frame.lineNumber,
|
|
1762
|
+
columnNumber: frame.columnNumber
|
|
1763
|
+
}));
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Execute a console step - retrieves browser console logs
|
|
1768
|
+
*
|
|
1769
|
+
* Note: Console logs are captured from the moment startCapture() is called
|
|
1770
|
+
* (typically at session start). Logs do NOT persist across separate CLI invocations.
|
|
1771
|
+
* Each invocation starts with an empty log buffer.
|
|
1772
|
+
*/
|
|
1773
|
+
async function executeConsole(consoleCapture, params) {
|
|
1774
|
+
if (!consoleCapture) {
|
|
1775
|
+
return { error: 'Console capture not available', messages: [] };
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const limit = (typeof params === 'object' && params.limit) || 50;
|
|
1779
|
+
const level = typeof params === 'object' ? params.level : null;
|
|
1780
|
+
const type = typeof params === 'object' ? params.type : null;
|
|
1781
|
+
const since = typeof params === 'object' ? params.since : null;
|
|
1782
|
+
const clear = typeof params === 'object' && params.clear === true;
|
|
1783
|
+
const includeStackTrace = typeof params === 'object' && params.stackTrace === true;
|
|
1784
|
+
|
|
1785
|
+
let messages;
|
|
1786
|
+
// FR-036: Filter by type (console vs exception)
|
|
1787
|
+
if (type) {
|
|
1788
|
+
messages = consoleCapture.getMessagesByType(type);
|
|
1789
|
+
} else if (level) {
|
|
1790
|
+
messages = consoleCapture.getMessagesByLevel(level);
|
|
1791
|
+
} else {
|
|
1792
|
+
messages = consoleCapture.getMessages();
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
// FR-038: Filter by "since" timestamp
|
|
1796
|
+
if (since) {
|
|
1797
|
+
messages = messages.filter(m => m.timestamp >= since);
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// Get the most recent messages up to limit
|
|
1801
|
+
const recentMessages = messages.slice(-limit);
|
|
1802
|
+
|
|
1803
|
+
// Format messages for output
|
|
1804
|
+
const formatted = recentMessages.map(m => {
|
|
1805
|
+
const formatted = {
|
|
1806
|
+
level: m.level,
|
|
1807
|
+
text: m.text ? m.text.substring(0, 500) : '',
|
|
1808
|
+
type: m.type,
|
|
1809
|
+
url: m.url || null,
|
|
1810
|
+
line: m.line || null,
|
|
1811
|
+
timestamp: m.timestamp || null
|
|
1812
|
+
};
|
|
1813
|
+
|
|
1814
|
+
// Include stack trace if requested
|
|
1815
|
+
if (includeStackTrace && m.stackTrace) {
|
|
1816
|
+
formatted.stackTrace = formatStackTrace(m.stackTrace);
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
return formatted;
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
if (clear) {
|
|
1823
|
+
consoleCapture.clear();
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
return {
|
|
1827
|
+
total: messages.length,
|
|
1828
|
+
showing: formatted.length,
|
|
1829
|
+
messages: formatted
|
|
1830
|
+
};
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
/**
|
|
1834
|
+
* Execute a scroll step
|
|
1835
|
+
*/
|
|
1836
|
+
async function executeScroll(elementLocator, inputEmulator, pageController, params) {
|
|
1837
|
+
if (typeof params === 'string') {
|
|
1838
|
+
// Direction-based scroll
|
|
1839
|
+
switch (params) {
|
|
1840
|
+
case 'top':
|
|
1841
|
+
await pageController.session.send('Runtime.evaluate', {
|
|
1842
|
+
expression: 'window.scrollTo(0, 0)'
|
|
1843
|
+
});
|
|
1844
|
+
break;
|
|
1845
|
+
case 'bottom':
|
|
1846
|
+
await pageController.session.send('Runtime.evaluate', {
|
|
1847
|
+
expression: 'window.scrollTo(0, document.body.scrollHeight)'
|
|
1848
|
+
});
|
|
1849
|
+
break;
|
|
1850
|
+
case 'up':
|
|
1851
|
+
await inputEmulator.scroll(0, -300, 400, 300);
|
|
1852
|
+
break;
|
|
1853
|
+
case 'down':
|
|
1854
|
+
await inputEmulator.scroll(0, 300, 400, 300);
|
|
1855
|
+
break;
|
|
1856
|
+
default:
|
|
1857
|
+
// Treat as selector - scroll element into view
|
|
1858
|
+
const el = await elementLocator.querySelector(params);
|
|
1859
|
+
if (!el) {
|
|
1860
|
+
throw elementNotFoundError(params, 0);
|
|
1861
|
+
}
|
|
1862
|
+
await el.scrollIntoView();
|
|
1863
|
+
await el.dispose();
|
|
1864
|
+
}
|
|
1865
|
+
} else if (params && typeof params === 'object') {
|
|
1866
|
+
if (params.selector) {
|
|
1867
|
+
// Scroll to element
|
|
1868
|
+
const el = await elementLocator.querySelector(params.selector);
|
|
1869
|
+
if (!el) {
|
|
1870
|
+
throw elementNotFoundError(params.selector, 0);
|
|
1871
|
+
}
|
|
1872
|
+
await el.scrollIntoView();
|
|
1873
|
+
await el.dispose();
|
|
1874
|
+
} else if (params.deltaY !== undefined || params.deltaX !== undefined) {
|
|
1875
|
+
// Scroll by delta
|
|
1876
|
+
const x = params.x || 400;
|
|
1877
|
+
const y = params.y || 300;
|
|
1878
|
+
await inputEmulator.scroll(params.deltaX || 0, params.deltaY || 0, x, y);
|
|
1879
|
+
} else if (params.y !== undefined) {
|
|
1880
|
+
// Scroll to position
|
|
1881
|
+
await pageController.session.send('Runtime.evaluate', {
|
|
1882
|
+
expression: `window.scrollTo(${params.x || 0}, ${params.y})`
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// Return current scroll position
|
|
1888
|
+
const posResult = await pageController.session.send('Runtime.evaluate', {
|
|
1889
|
+
expression: '({ scrollX: window.scrollX, scrollY: window.scrollY })',
|
|
1890
|
+
returnByValue: true
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
return posResult.result.value;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
/**
|
|
1897
|
+
* Run an array of test steps
|
|
1898
|
+
* @param {Object} deps - Dependencies
|
|
1899
|
+
* @param {Array<Object>} steps - Array of step definitions
|
|
1900
|
+
* @param {Object} [options] - Execution options
|
|
1901
|
+
* @param {boolean} [options.stopOnError=true] - Stop on first error
|
|
1902
|
+
* @param {number} [options.stepTimeout=30000] - Timeout per step
|
|
1903
|
+
* @returns {Promise<{status: string, steps: Array, errors: Array, screenshots: Array}>}
|
|
1904
|
+
*/
|
|
1905
|
+
export async function runSteps(deps, steps, options = {}) {
|
|
1906
|
+
const validation = validateSteps(steps);
|
|
1907
|
+
if (!validation.valid) {
|
|
1908
|
+
throw stepValidationError(validation.errors);
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
const stopOnError = options.stopOnError !== false;
|
|
1912
|
+
const result = {
|
|
1913
|
+
status: 'passed',
|
|
1914
|
+
steps: [],
|
|
1915
|
+
errors: [],
|
|
1916
|
+
screenshots: [],
|
|
1917
|
+
outputs: []
|
|
1918
|
+
};
|
|
1919
|
+
|
|
1920
|
+
for (const step of steps) {
|
|
1921
|
+
const stepResult = await executeStep(deps, step, options);
|
|
1922
|
+
result.steps.push(stepResult);
|
|
1923
|
+
|
|
1924
|
+
if (stepResult.screenshot) {
|
|
1925
|
+
result.screenshots.push(stepResult.screenshot);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
if (stepResult.output) {
|
|
1929
|
+
result.outputs.push({
|
|
1930
|
+
step: result.steps.length,
|
|
1931
|
+
action: stepResult.action,
|
|
1932
|
+
output: stepResult.output
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
if (stepResult.status === 'failed') {
|
|
1937
|
+
result.status = 'failed';
|
|
1938
|
+
result.errors.push({
|
|
1939
|
+
step: result.steps.length,
|
|
1940
|
+
action: stepResult.action,
|
|
1941
|
+
error: stepResult.error
|
|
1942
|
+
});
|
|
1943
|
+
|
|
1944
|
+
if (stopOnError) {
|
|
1945
|
+
break;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
// 'skipped' (optional) steps don't fail the run
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return result;
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
/**
|
|
1955
|
+
* Create a test runner with bound dependencies
|
|
1956
|
+
* @param {Object} deps - Dependencies
|
|
1957
|
+
* @returns {Object} Test runner interface
|
|
1958
|
+
*/
|
|
1959
|
+
export function createTestRunner(deps) {
|
|
1960
|
+
const { pageController, elementLocator, inputEmulator, screenshotCapture } = deps;
|
|
1961
|
+
|
|
1962
|
+
return {
|
|
1963
|
+
validateSteps,
|
|
1964
|
+
executeStep: (step, options) => executeStep(deps, step, options),
|
|
1965
|
+
run: (steps, options) => runSteps(deps, steps, options)
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* Execute a validate step - query validation state of an element
|
|
1971
|
+
*/
|
|
1972
|
+
async function executeValidate(elementLocator, selector) {
|
|
1973
|
+
const formValidator = createFormValidator(elementLocator.session, elementLocator);
|
|
1974
|
+
return formValidator.validateElement(selector);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Execute a submit step - submit a form with validation error reporting
|
|
1979
|
+
*/
|
|
1980
|
+
async function executeSubmit(elementLocator, params) {
|
|
1981
|
+
const selector = typeof params === 'string' ? params : params.selector;
|
|
1982
|
+
const options = typeof params === 'object' ? params : {};
|
|
1983
|
+
|
|
1984
|
+
const formValidator = createFormValidator(elementLocator.session, elementLocator);
|
|
1985
|
+
return formValidator.submitForm(selector, options);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* Execute an assert step - validates conditions about the page
|
|
1990
|
+
* Supports URL assertions and text assertions
|
|
1991
|
+
*/
|
|
1992
|
+
async function executeAssert(pageController, elementLocator, params) {
|
|
1993
|
+
const result = {
|
|
1994
|
+
passed: true,
|
|
1995
|
+
assertions: []
|
|
1996
|
+
};
|
|
1997
|
+
|
|
1998
|
+
// URL assertion
|
|
1999
|
+
if (params.url) {
|
|
2000
|
+
const currentUrl = await pageController.getUrl();
|
|
2001
|
+
const urlAssertion = { type: 'url', actual: currentUrl };
|
|
2002
|
+
|
|
2003
|
+
if (params.url.contains) {
|
|
2004
|
+
urlAssertion.expected = { contains: params.url.contains };
|
|
2005
|
+
urlAssertion.passed = currentUrl.includes(params.url.contains);
|
|
2006
|
+
} else if (params.url.equals) {
|
|
2007
|
+
urlAssertion.expected = { equals: params.url.equals };
|
|
2008
|
+
urlAssertion.passed = currentUrl === params.url.equals;
|
|
2009
|
+
} else if (params.url.startsWith) {
|
|
2010
|
+
urlAssertion.expected = { startsWith: params.url.startsWith };
|
|
2011
|
+
urlAssertion.passed = currentUrl.startsWith(params.url.startsWith);
|
|
2012
|
+
} else if (params.url.endsWith) {
|
|
2013
|
+
urlAssertion.expected = { endsWith: params.url.endsWith };
|
|
2014
|
+
urlAssertion.passed = currentUrl.endsWith(params.url.endsWith);
|
|
2015
|
+
} else if (params.url.matches) {
|
|
2016
|
+
urlAssertion.expected = { matches: params.url.matches };
|
|
2017
|
+
const regex = new RegExp(params.url.matches);
|
|
2018
|
+
urlAssertion.passed = regex.test(currentUrl);
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
result.assertions.push(urlAssertion);
|
|
2022
|
+
if (!urlAssertion.passed) {
|
|
2023
|
+
result.passed = false;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// Text assertion
|
|
2028
|
+
if (params.text) {
|
|
2029
|
+
const selector = params.selector || 'body';
|
|
2030
|
+
const caseSensitive = params.caseSensitive !== false;
|
|
2031
|
+
const textAssertion = { type: 'text', expected: params.text, selector };
|
|
2032
|
+
|
|
2033
|
+
try {
|
|
2034
|
+
// Get the text content of the target element
|
|
2035
|
+
const textResult = await pageController.session.send('Runtime.evaluate', {
|
|
2036
|
+
expression: `
|
|
2037
|
+
(function() {
|
|
2038
|
+
const el = document.querySelector(${JSON.stringify(selector)});
|
|
2039
|
+
return el ? el.textContent : null;
|
|
2040
|
+
})()
|
|
2041
|
+
`,
|
|
2042
|
+
returnByValue: true
|
|
2043
|
+
});
|
|
2044
|
+
|
|
2045
|
+
const actualText = textResult.result.value;
|
|
2046
|
+
textAssertion.found = actualText !== null;
|
|
2047
|
+
|
|
2048
|
+
if (actualText === null) {
|
|
2049
|
+
textAssertion.passed = false;
|
|
2050
|
+
textAssertion.error = `Element not found: ${selector}`;
|
|
2051
|
+
} else {
|
|
2052
|
+
if (caseSensitive) {
|
|
2053
|
+
textAssertion.passed = actualText.includes(params.text);
|
|
2054
|
+
} else {
|
|
2055
|
+
textAssertion.passed = actualText.toLowerCase().includes(params.text.toLowerCase());
|
|
2056
|
+
}
|
|
2057
|
+
textAssertion.actualLength = actualText.length;
|
|
2058
|
+
}
|
|
2059
|
+
} catch (e) {
|
|
2060
|
+
textAssertion.passed = false;
|
|
2061
|
+
textAssertion.error = e.message;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
result.assertions.push(textAssertion);
|
|
2065
|
+
if (!textAssertion.passed) {
|
|
2066
|
+
result.passed = false;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Throw error if assertion failed (makes the step fail)
|
|
2071
|
+
if (!result.passed) {
|
|
2072
|
+
const failedAssertions = result.assertions.filter(a => !a.passed);
|
|
2073
|
+
const messages = failedAssertions.map(a => {
|
|
2074
|
+
if (a.type === 'url') {
|
|
2075
|
+
return `URL assertion failed: expected ${JSON.stringify(a.expected)}, actual "${a.actual}"`;
|
|
2076
|
+
} else if (a.type === 'text') {
|
|
2077
|
+
if (a.error) return `Text assertion failed: ${a.error}`;
|
|
2078
|
+
return `Text assertion failed: "${a.expected}" not found in ${a.selector}`;
|
|
2079
|
+
}
|
|
2080
|
+
return 'Assertion failed';
|
|
2081
|
+
});
|
|
2082
|
+
throw new Error(messages.join('; '));
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
return result;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* Execute a queryAll step - runs multiple queries and returns results
|
|
2090
|
+
* @param {Object} elementLocator - Element locator
|
|
2091
|
+
* @param {Object} params - Object mapping names to selectors
|
|
2092
|
+
* @returns {Promise<Object>} Results keyed by query name
|
|
2093
|
+
*/
|
|
2094
|
+
async function executeQueryAll(elementLocator, params) {
|
|
2095
|
+
const results = {};
|
|
2096
|
+
|
|
2097
|
+
for (const [name, selectorOrConfig] of Object.entries(params)) {
|
|
2098
|
+
// Support both string selectors and query config objects
|
|
2099
|
+
const queryParams = typeof selectorOrConfig === 'string'
|
|
2100
|
+
? selectorOrConfig
|
|
2101
|
+
: selectorOrConfig;
|
|
2102
|
+
|
|
2103
|
+
try {
|
|
2104
|
+
results[name] = await executeQuery(elementLocator, queryParams);
|
|
2105
|
+
} catch (e) {
|
|
2106
|
+
results[name] = { error: e.message };
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
return results;
|
|
2111
|
+
}
|