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/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
+ }