chrometools-mcp 1.8.2 → 2.2.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.
@@ -1,946 +1,970 @@
1
- /**
2
- * recorder/scenario-executor.js
3
- *
4
- * Executes recorded scenarios with:
5
- * 1. Action playback with error handling
6
- * 2. Parameter substitution
7
- * 3. Secret injection
8
- * 4. Dependency resolution and chaining
9
- * 5. Retry logic with fallback selectors
10
- */
11
-
12
- import { resolveDependencies, checkDependencyCondition } from './dependency-resolver.js';
13
- import { loadScenario, loadSecrets, loadIndex } from './scenario-storage.js';
14
-
15
- /**
16
- * Execute scenario with dependencies
17
- * @param {string} scenarioName - Scenario to execute
18
- * @param {Object} page - Puppeteer page instance
19
- * @param {Object} params - Parameters for scenario
20
- * @param {Object} options - Execution options { executeDependencies, skipConditions, maxRetries, timeout, baseDir }
21
- * @returns {Object} - Execution result
22
- */
23
- export async function executeScenario(scenarioName, page, params = {}, options = {}) {
24
- const {
25
- executeDependencies = true, // NEW: Execute dependencies by default
26
- skipConditions = false,
27
- maxRetries = 3,
28
- timeout = 30000,
29
- baseDir // Base directory for scenarios
30
- } = options;
31
-
32
- const result = {
33
- success: false,
34
- scenarioName,
35
- executedScenarios: [],
36
- errors: [],
37
- outputs: {},
38
- duration: 0
39
- };
40
-
41
- const startTime = Date.now();
42
-
43
- try {
44
- // Load scenario index
45
- const path = await import('path');
46
- const scenariosDir = path.join(baseDir, 'scenarios');
47
- const scenarioIndex = await loadIndex(scenariosDir);
48
-
49
- let chain = [scenarioName]; // Default: execute only the requested scenario
50
-
51
- // Resolve and execute dependencies if enabled
52
- if (executeDependencies) {
53
- const resolution = resolveDependencies(scenarioName, scenarioIndex, { skipConditions });
54
-
55
- if (resolution.errors.length > 0) {
56
- result.errors.push(...resolution.errors);
57
- return result;
58
- }
59
-
60
- chain = resolution.chain; // Use full dependency chain
61
- }
62
-
63
- // Execute chain in order
64
- for (const name of chain) {
65
- const scenario = await loadScenario(name, false, baseDir);
66
- if (!scenario) {
67
- result.errors.push(`Scenario "${name}" not found`);
68
- return result;
69
- }
70
-
71
- // Check dependency conditions
72
- if (scenario.metadata?.dependencies) {
73
- for (const dep of scenario.metadata.dependencies) {
74
- if (dep.condition) {
75
- const context = { page, variables: params };
76
- const shouldExecute = await checkDependencyCondition(dep.condition, context);
77
-
78
- if (!shouldExecute) {
79
- console.log(`Skipping scenario "${name}" due to condition`);
80
- continue;
81
- }
82
- }
83
- }
84
- }
85
-
86
- // Load secrets
87
- const secretsDir = path.join(baseDir, 'secrets');
88
- const secrets = await loadSecrets(name, secretsDir);
89
-
90
- // Merge secrets with params
91
- const executionParams = { ...params, ...secrets };
92
-
93
- // Execute scenario
94
- const scenarioResult = await executeSingleScenario(scenario, page, executionParams, {
95
- maxRetries,
96
- timeout
97
- });
98
-
99
- result.executedScenarios.push(name);
100
-
101
- if (!scenarioResult.success) {
102
- result.errors.push(...scenarioResult.errors);
103
- return result;
104
- }
105
-
106
- // Collect outputs for next scenarios
107
- if (scenarioResult.outputs) {
108
- Object.assign(result.outputs, scenarioResult.outputs);
109
- Object.assign(params, scenarioResult.outputs);
110
- }
111
- }
112
-
113
- result.success = true;
114
- } catch (error) {
115
- result.errors.push(`Execution failed: ${error.message}`);
116
- } finally {
117
- result.duration = Date.now() - startTime;
118
- }
119
-
120
- return result;
121
- }
122
-
123
- /**
124
- * Execute single scenario (without dependencies)
125
- * @param {Object} scenario - Scenario data
126
- * @param {Object} page - Puppeteer page
127
- * @param {Object} params - Parameters
128
- * @param {Object} options - Options
129
- * @returns {Object} - Execution result
130
- */
131
- async function executeSingleScenario(scenario, page, params = {}, options = {}) {
132
- const { maxRetries = 3, timeout = 30000 } = options;
133
-
134
- const result = {
135
- success: false,
136
- errors: [],
137
- outputs: {},
138
- actionResults: []
139
- };
140
-
141
- try {
142
- for (const action of scenario.chain) {
143
- // Substitute parameters in action
144
- const resolvedAction = substituteParameters(action, params);
145
-
146
- // Execute action with retry
147
- const actionResult = await executeActionWithRetry(
148
- resolvedAction,
149
- page,
150
- maxRetries,
151
- timeout
152
- );
153
-
154
- result.actionResults.push(actionResult);
155
-
156
- if (!actionResult.success) {
157
- result.errors.push(`Action failed: ${actionResult.error}`);
158
- return result;
159
- }
160
-
161
- // Store outputs if action produces any
162
- if (actionResult.output) {
163
- Object.assign(result.outputs, actionResult.output);
164
- }
165
- }
166
-
167
- // Validate final URL if exitUrl is specified in metadata
168
- if (scenario.metadata?.exitUrl) {
169
- const currentUrl = page.url();
170
- const expectedUrl = scenario.metadata.exitUrl;
171
-
172
- // Normalize URLs for comparison (remove trailing slashes, fragments)
173
- const normalizeUrl = (url) => {
174
- try {
175
- const parsed = new URL(url);
176
- // Remove fragment and trailing slash
177
- return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`;
178
- } catch {
179
- return url.replace(/\/$/, '');
180
- }
181
- };
182
-
183
- const normalizedCurrent = normalizeUrl(currentUrl);
184
- const normalizedExpected = normalizeUrl(expectedUrl);
185
-
186
- if (normalizedCurrent !== normalizedExpected) {
187
- result.errors.push(
188
- `❌ URL Validation Failed\n\n` +
189
- `Expected final URL: ${expectedUrl}\n` +
190
- `Actual final URL: ${currentUrl}\n\n` +
191
- `The scenario ended on a different page than expected.\n` +
192
- `This may indicate:\n` +
193
- ` - Navigation flow has changed\n` +
194
- ` - An action failed silently\n` +
195
- ` - Page redirected unexpectedly\n\n` +
196
- `💡 Suggestion: Check if the page flow or redirects have changed since recording.`
197
- );
198
- return result;
199
- }
200
-
201
- // URL validation passed
202
- result.urlValidation = {
203
- success: true,
204
- expectedUrl,
205
- actualUrl: currentUrl
206
- };
207
- }
208
-
209
- result.success = true;
210
- } catch (error) {
211
- result.errors.push(`Scenario execution error: ${error.message}`);
212
- }
213
-
214
- return result;
215
- }
216
-
217
- /**
218
- * Execute action with retry and fallback selectors
219
- */
220
- async function executeActionWithRetry(action, page, maxRetries, timeout) {
221
- const result = {
222
- success: false,
223
- action: action.type,
224
- error: null,
225
- errorDetails: {
226
- attempts: [],
227
- selector: action.selector?.value || action.selector?.primary,
228
- context: null
229
- },
230
- output: null,
231
- attempts: 0
232
- };
233
-
234
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
235
- result.attempts = attempt;
236
- const attemptInfo = {
237
- number: attempt,
238
- selector: action.selector?.value || action.selector?.primary,
239
- error: null,
240
- timestamp: new Date().toISOString()
241
- };
242
-
243
- try {
244
- // Execute action based on type
245
- const actionResult = await executeAction(action, page, timeout);
246
-
247
- result.success = true;
248
- result.output = actionResult.output;
249
- attemptInfo.success = true;
250
- result.errorDetails.attempts.push(attemptInfo);
251
- return result;
252
- } catch (error) {
253
- attemptInfo.error = error.message;
254
- attemptInfo.success = false;
255
-
256
- // Capture page context for error reporting
257
- if (attempt === maxRetries) {
258
- try {
259
- result.errorDetails.context = await capturePageContext(page, action);
260
- } catch (contextError) {
261
- console.error('Failed to capture page context:', contextError);
262
- }
263
- }
264
-
265
- result.errorDetails.attempts.push(attemptInfo);
266
- result.error = error.message;
267
-
268
- // If this is a selector error and we have fallbacks, try them
269
- if (action.selector?.fallbacks && action.selector.fallbacks.length > 0) {
270
- const fallback = action.selector.fallbacks[0];
271
- console.log(`[Retry ${attempt}] Trying fallback selector: ${fallback}`);
272
-
273
- action.selector.value = fallback;
274
- action.selector.fallbacks = action.selector.fallbacks.slice(1);
275
- continue;
276
- }
277
-
278
- // If we have element description, try smartFindElement
279
- if (action.selector?.elementInfo?.text && attempt < maxRetries) {
280
- console.log(`[Retry ${attempt}] Selector failed, trying smartFindElement with description: ${action.selector.elementInfo.text}`);
281
-
282
- try {
283
- // Inject element finder utilities if not already done
284
- await page.evaluate(elementFinderUtilsCode);
285
-
286
- const smartResult = await page.evaluate((description) => {
287
- return window.smartFindElement({ description, maxResults: 3 });
288
- }, action.selector.elementInfo.text);
289
-
290
- if (smartResult.candidates && smartResult.candidates.length > 0) {
291
- action.selector.value = smartResult.candidates[0].selector;
292
- action.selector.fallbacks = smartResult.candidates.slice(1).map(c => c.selector);
293
- console.log(`[Retry ${attempt}] Found alternative selector: ${action.selector.value}`);
294
- continue;
295
- }
296
- } catch (smartError) {
297
- console.error('[Retry] smartFindElement failed:', smartError.message);
298
- }
299
- }
300
-
301
- // Last attempt failed
302
- if (attempt === maxRetries) {
303
- // Create comprehensive error message
304
- result.error = formatDetailedError(action, result.errorDetails);
305
- return result;
306
- }
307
-
308
- // Wait before retry
309
- await new Promise(resolve => setTimeout(resolve, 1000));
310
- }
311
- }
312
-
313
- return result;
314
- }
315
-
316
- /**
317
- * Capture page context for error reporting
318
- */
319
- async function capturePageContext(page, action) {
320
- try {
321
- const context = {
322
- url: page.url(),
323
- title: await page.title(),
324
- elementExists: false,
325
- elementVisible: false,
326
- elementInfo: null,
327
- pageState: null
328
- };
329
-
330
- const selector = action.selector?.value || action.selector?.primary;
331
-
332
- if (selector) {
333
- // Check if element exists
334
- context.elementExists = await page.evaluate((sel) => {
335
- return document.querySelector(sel) !== null;
336
- }, selector);
337
-
338
- // If exists, check visibility and get info
339
- if (context.elementExists) {
340
- context.elementInfo = await page.evaluate((sel) => {
341
- const el = document.querySelector(sel);
342
- const rect = el.getBoundingClientRect();
343
- const styles = window.getComputedStyle(el);
344
-
345
- return {
346
- tagName: el.tagName,
347
- id: el.id,
348
- className: el.className,
349
- visible: rect.width > 0 && rect.height > 0 && styles.display !== 'none' && styles.visibility !== 'hidden',
350
- disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
351
- readonly: el.readOnly || el.getAttribute('aria-readonly') === 'true',
352
- position: {
353
- top: rect.top,
354
- left: rect.left,
355
- width: rect.width,
356
- height: rect.height
357
- },
358
- styles: {
359
- display: styles.display,
360
- visibility: styles.visibility,
361
- opacity: styles.opacity,
362
- pointerEvents: styles.pointerEvents
363
- }
364
- };
365
- }, selector);
366
-
367
- context.elementVisible = context.elementInfo.visible;
368
- }
369
- }
370
-
371
- // Get page state
372
- context.pageState = await page.evaluate(() => {
373
- return {
374
- readyState: document.readyState,
375
- hasModals: document.querySelector('[role="dialog"], .modal, .popup') !== null,
376
- hasOverlays: document.querySelector('.overlay, .backdrop') !== null,
377
- activeElement: document.activeElement ? {
378
- tagName: document.activeElement.tagName,
379
- id: document.activeElement.id,
380
- className: document.activeElement.className
381
- } : null
382
- };
383
- });
384
-
385
- return context;
386
- } catch (error) {
387
- return { error: error.message };
388
- }
389
- }
390
-
391
- /**
392
- * Format detailed error message for AI agent
393
- */
394
- function formatDetailedError(action, errorDetails) {
395
- const parts = [
396
- `❌ Action "${action.type}" failed after ${errorDetails.attempts.length} attempts`,
397
- ``,
398
- `📍 Selector: ${errorDetails.selector}`,
399
- ];
400
-
401
- if (errorDetails.context) {
402
- parts.push(``, `📄 Page Context:`);
403
- parts.push(` URL: ${errorDetails.context.url}`);
404
- parts.push(` Title: ${errorDetails.context.title}`);
405
-
406
- if (errorDetails.context.elementExists) {
407
- parts.push(``, `🔍 Element Found But:`);
408
- const info = errorDetails.context.elementInfo;
409
-
410
- if (!info.visible) {
411
- parts.push(` ⚠️ Element is NOT VISIBLE`);
412
- parts.push(` - Display: ${info.styles.display}`);
413
- parts.push(` - Visibility: ${info.styles.visibility}`);
414
- parts.push(` - Opacity: ${info.styles.opacity}`);
415
- parts.push(` - Size: ${info.position.width}x${info.position.height}`);
416
- }
417
-
418
- if (info.disabled) {
419
- parts.push(` ⚠️ Element is DISABLED`);
420
- }
421
-
422
- if (info.readonly && action.type === 'type') {
423
- parts.push(` ⚠️ Element is READONLY`);
424
- }
425
-
426
- if (info.styles.pointerEvents === 'none') {
427
- parts.push(` ⚠️ Element has pointer-events: none`);
428
- }
429
- } else {
430
- parts.push(``, `❌ Element NOT FOUND in DOM`);
431
- }
432
-
433
- if (errorDetails.context.pageState) {
434
- const state = errorDetails.context.pageState;
435
- if (state.hasModals) {
436
- parts.push(``, `⚠️ Page has open modal/dialog`);
437
- }
438
- if (state.hasOverlays) {
439
- parts.push(`⚠️ Page has overlay/backdrop`);
440
- }
441
- }
442
- }
443
-
444
- parts.push(``, `🔄 Retry History:`);
445
- errorDetails.attempts.forEach(attempt => {
446
- parts.push(` Attempt ${attempt.number}: ${attempt.error || 'Unknown error'}`);
447
- if (attempt.selector !== errorDetails.selector) {
448
- parts.push(` (tried selector: ${attempt.selector})`);
449
- }
450
- });
451
-
452
- parts.push(``, `💡 Suggestions:`);
453
- if (!errorDetails.context?.elementExists) {
454
- parts.push(` - Check if page has fully loaded`);
455
- parts.push(` - Verify the selector is correct`);
456
- parts.push(` - Element might be dynamically added - add wait condition`);
457
- } else if (!errorDetails.context?.elementVisible) {
458
- parts.push(` - Element exists but is hidden - check CSS/JS conditions`);
459
- parts.push(` - Wait for element to become visible`);
460
- parts.push(` - Check if element is covered by modal/overlay`);
461
- }
462
-
463
- return parts.join('\n');
464
- }
465
-
466
- /**
467
- * Execute single action
468
- */
469
- async function executeAction(action, page, timeout) {
470
- const result = { output: null };
471
-
472
- switch (action.type) {
473
- case 'click':
474
- await executeClick(action, page, timeout);
475
- break;
476
-
477
- case 'type':
478
- await executeType(action, page, timeout);
479
- break;
480
-
481
- case 'select':
482
- await executeSelect(action, page, timeout);
483
- break;
484
-
485
- case 'scroll':
486
- await executeScroll(action, page);
487
- break;
488
-
489
- case 'hover':
490
- await executeHover(action, page);
491
- break;
492
-
493
- case 'keypress':
494
- await executeKeypress(action, page);
495
- break;
496
-
497
- case 'wait':
498
- await executeWait(action, page);
499
- break;
500
-
501
- case 'upload':
502
- await executeUpload(action, page, timeout);
503
- break;
504
-
505
- case 'drag':
506
- await executeDrag(action, page);
507
- break;
508
-
509
- case 'navigate':
510
- await executeNavigate(action, page, timeout);
511
- break;
512
-
513
- case 'extract':
514
- result.output = await executeExtract(action, page);
515
- break;
516
-
517
- default:
518
- throw new Error(`Unknown action type: ${action.type}`);
519
- }
520
-
521
- return result;
522
- }
523
-
524
- /**
525
- * Action executors
526
- */
527
-
528
- async function executeClick(action, page, timeout) {
529
- const selector = action.selector.value || action.selector.primary || action.selector;
530
-
531
- try {
532
- await page.waitForSelector(selector, { timeout, visible: true });
533
- await page.click(selector);
534
-
535
- // Smart waiting after click
536
- if (action.data.requiresWait !== false) {
537
- await smartWaitAfterClick(page, action, timeout);
538
- }
539
-
540
- // Additional wait if specified
541
- if (action.data.waitAfter) {
542
- await new Promise(resolve => setTimeout(resolve, action.data.waitAfter));
543
- }
544
- } catch (error) {
545
- throw new Error(`Failed to click "${selector}": ${error.message}`);
546
- }
547
- }
548
-
549
- /**
550
- * Smart waiting after click - waits for animations and network requests
551
- */
552
- async function smartWaitAfterClick(page, action, timeout) {
553
- const startTime = Date.now();
554
- const maxWaitTime = timeout || 30000;
555
-
556
- try {
557
- // Initial wait 500ms to let page respond
558
- await new Promise(resolve => setTimeout(resolve, 500));
559
-
560
- // Check if there's any activity (animations, network, DOM changes)
561
- const hasActivity = await checkPageActivity(page);
562
-
563
- if (!hasActivity) {
564
- // No activity detected - we're done, fast exit
565
- console.log('[Smart Wait] No activity detected, skipping extended wait');
566
- return;
567
- }
568
-
569
- // Activity detected - wait minimum 2 seconds
570
- console.log('[Smart Wait] Activity detected, waiting for completion');
571
- const remainingMinWait = 2000 - 500; // Already waited 500ms
572
- if (remainingMinWait > 0) {
573
- await new Promise(resolve => setTimeout(resolve, remainingMinWait));
574
- }
575
-
576
- // Wait for animations to complete
577
- await page.evaluate(() => {
578
- return new Promise((resolve) => {
579
- const checkAnimations = () => {
580
- // Check for CSS animations/transitions
581
- const elements = document.querySelectorAll('*');
582
- let hasAnimations = false;
583
-
584
- for (const el of elements) {
585
- const computedStyle = window.getComputedStyle(el);
586
- const animations = computedStyle.getPropertyValue('animation-name');
587
- const transitions = computedStyle.getPropertyValue('transition-property');
588
-
589
- if ((animations && animations !== 'none') ||
590
- (transitions && transitions !== 'none' && transitions !== 'all')) {
591
- hasAnimations = true;
592
- break;
593
- }
594
- }
595
-
596
- if (!hasAnimations) {
597
- resolve();
598
- } else {
599
- setTimeout(checkAnimations, 100);
600
- }
601
- };
602
-
603
- // Start checking immediately
604
- checkAnimations();
605
- // Timeout after 3 seconds
606
- setTimeout(resolve, 3000);
607
- });
608
- });
609
-
610
- // Wait for network to be idle (no pending requests for 500ms)
611
- await Promise.race([
612
- page.waitForNetworkIdle({ idleTime: 500, timeout: 5000 }),
613
- new Promise(resolve => setTimeout(resolve, 5000)) // Max 5 seconds for network
614
- ]);
615
-
616
- // Wait for any DOM changes to settle
617
- await page.evaluate(() => {
618
- return new Promise((resolve) => {
619
- let timeoutId;
620
- const observer = new MutationObserver(() => {
621
- clearTimeout(timeoutId);
622
- timeoutId = setTimeout(() => {
623
- observer.disconnect();
624
- resolve();
625
- }, 300); // 300ms of no DOM changes
626
- });
627
-
628
- observer.observe(document.body, {
629
- childList: true,
630
- subtree: true,
631
- attributes: true,
632
- attributeFilter: ['class', 'style', 'hidden', 'disabled']
633
- });
634
-
635
- // Start the timeout
636
- timeoutId = setTimeout(() => {
637
- observer.disconnect();
638
- resolve();
639
- }, 300);
640
-
641
- // Max wait 3 seconds
642
- setTimeout(() => {
643
- observer.disconnect();
644
- resolve();
645
- }, 3000);
646
- });
647
- });
648
-
649
- } catch (error) {
650
- // If smart wait fails, just log and continue
651
- console.error('[Smart Wait] Error during smart wait:', error.message);
652
- }
653
-
654
- // Ensure we don't exceed max wait time
655
- const elapsed = Date.now() - startTime;
656
- if (elapsed > maxWaitTime) {
657
- console.warn(`[Smart Wait] Exceeded max wait time (${maxWaitTime}ms)`);
658
- }
659
- }
660
-
661
- /**
662
- * Check if page has any ongoing activity (animations, network, DOM changes)
663
- * Returns true if activity detected, false otherwise
664
- */
665
- async function checkPageActivity(page) {
666
- try {
667
- const activity = await page.evaluate(() => {
668
- // Check for animations
669
- const elements = document.querySelectorAll('*');
670
- for (const el of elements) {
671
- const computedStyle = window.getComputedStyle(el);
672
- const animations = computedStyle.getPropertyValue('animation-name');
673
- const transitions = computedStyle.getPropertyValue('transition-property');
674
-
675
- if ((animations && animations !== 'none') ||
676
- (transitions && transitions !== 'none' && transitions !== 'all')) {
677
- return { hasActivity: true, reason: 'animations' };
678
- }
679
- }
680
-
681
- // Check for recent DOM changes (using performance API)
682
- if (window.performance && window.performance.getEntriesByType) {
683
- const entries = window.performance.getEntriesByType('measure');
684
- if (entries.length > 0) {
685
- const recentEntries = entries.filter(e =>
686
- performance.now() - e.startTime < 500
687
- );
688
- if (recentEntries.length > 0) {
689
- return { hasActivity: true, reason: 'performance_measures' };
690
- }
691
- }
692
- }
693
-
694
- return { hasActivity: false, reason: 'none' };
695
- });
696
-
697
- if (activity.hasActivity) {
698
- console.log(`[Smart Wait] Activity detected: ${activity.reason}`);
699
- return true;
700
- }
701
-
702
- // Check for pending network requests using CDP
703
- try {
704
- const client = await page.target().createCDPSession();
705
- await client.send('Network.enable');
706
-
707
- // Give a moment for network requests to start
708
- await new Promise(resolve => setTimeout(resolve, 100));
709
-
710
- const hasNetworkActivity = await new Promise((resolve) => {
711
- let requestCount = 0;
712
-
713
- const requestListener = () => {
714
- requestCount++;
715
- };
716
-
717
- client.on('Network.requestWillBeSent', requestListener);
718
-
719
- setTimeout(() => {
720
- client.off('Network.requestWillBeSent', requestListener);
721
- client.detach().catch(() => {});
722
- resolve(requestCount > 0);
723
- }, 200);
724
- });
725
-
726
- if (hasNetworkActivity) {
727
- console.log('[Smart Wait] Network activity detected');
728
- return true;
729
- }
730
- } catch (netError) {
731
- // Network check failed, assume no activity
732
- console.log('[Smart Wait] Network check failed, assuming no activity');
733
- }
734
-
735
- return false;
736
- } catch (error) {
737
- // If check fails, assume there's activity to be safe
738
- console.error('[Smart Wait] Activity check failed, assuming activity exists:', error.message);
739
- return true;
740
- }
741
- }
742
-
743
- async function executeType(action, page, timeout) {
744
- const selector = action.selector.value || action.selector.primary || action.selector;
745
-
746
- try {
747
- await page.waitForSelector(selector, { timeout, visible: true });
748
-
749
- // Check if element is editable
750
- const isEditable = await page.evaluate((sel) => {
751
- const el = document.querySelector(sel);
752
- if (!el) return false;
753
-
754
- const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
755
- const isContentEditable = el.isContentEditable;
756
-
757
- return isInput || isContentEditable;
758
- }, selector);
759
-
760
- if (!isEditable) {
761
- throw new Error(`Element "${selector}" is not editable (not an input, textarea, or contenteditable)`);
762
- }
763
-
764
- // Clear field if specified
765
- if (action.data.clearFirst !== false) {
766
- await page.click(selector, { clickCount: 3 });
767
- await page.keyboard.press('Backspace');
768
- }
769
-
770
- // Type text with optional delay
771
- await page.type(selector, action.data.text, {
772
- delay: action.data.delay || 0
773
- });
774
- } catch (error) {
775
- throw new Error(`Failed to type into "${selector}": ${error.message}`);
776
- }
777
- }
778
-
779
- async function executeSelect(action, page, timeout) {
780
- const selector = action.selector.value || action.selector.primary || action.selector;
781
-
782
- try {
783
- if (action.data.selectType === 'custom') {
784
- // Custom select (multi-step)
785
- for (const step of action.data.steps) {
786
- if (step.action === 'click') {
787
- await page.waitForSelector(step.selector, { timeout });
788
- await page.click(step.selector);
789
- } else if (step.action === 'wait') {
790
- await new Promise(resolve => setTimeout(resolve, step.duration));
791
- }
792
- }
793
- } else {
794
- // Native select
795
- await page.waitForSelector(selector, { timeout, visible: true });
796
-
797
- // Verify it's a select element
798
- const isSelect = await page.evaluate((sel) => {
799
- const el = document.querySelector(sel);
800
- return el && el.tagName === 'SELECT';
801
- }, selector);
802
-
803
- if (!isSelect) {
804
- throw new Error(`Element "${selector}" is not a <select> element`);
805
- }
806
-
807
- await page.select(selector, action.data.value);
808
- }
809
- } catch (error) {
810
- throw new Error(`Failed to select option in "${selector}": ${error.message}`);
811
- }
812
- }
813
-
814
- async function executeScroll(action, page) {
815
- const selector = action.selector.value || action.selector.primary || action.selector;
816
- await page.evaluate((selector, behavior) => {
817
- const element = document.querySelector(selector);
818
- if (element) {
819
- element.scrollIntoView({ behavior: behavior || 'auto', block: 'center' });
820
- }
821
- }, selector, action.data.behavior);
822
- }
823
-
824
- async function executeHover(action, page) {
825
- const selector = action.selector.value || action.selector.primary || action.selector;
826
- await page.hover(selector);
827
- }
828
-
829
- async function executeKeypress(action, page) {
830
- const key = action.data.key;
831
- const modifiers = action.data.modifiers || [];
832
-
833
- // Press modifiers
834
- for (const mod of modifiers) {
835
- await page.keyboard.down(mod);
836
- }
837
-
838
- // Press key
839
- await page.keyboard.press(key);
840
-
841
- // Release modifiers
842
- for (const mod of modifiers.reverse()) {
843
- await page.keyboard.up(mod);
844
- }
845
- }
846
-
847
- async function executeWait(action, page) {
848
- if (action.data.waitType === 'selector') {
849
- await page.waitForSelector(action.data.selector, {
850
- timeout: action.data.duration
851
- });
852
- } else {
853
- await new Promise(resolve => setTimeout(resolve, action.data.duration));
854
- }
855
- }
856
-
857
- async function executeUpload(action, page, timeout) {
858
- const selector = action.selector.value || action.selector.primary || action.selector;
859
- const fileInput = await page.waitForSelector(selector, { timeout });
860
- await fileInput.uploadFile(action.data.filePath);
861
- }
862
-
863
- async function executeDrag(action, page) {
864
- const { fromSelector, toSelector, fromX, fromY, toX, toY } = action.data;
865
-
866
- if (fromSelector && toSelector) {
867
- // Drag from element to element
868
- const from = await page.$(fromSelector);
869
- const to = await page.$(toSelector);
870
-
871
- const fromBox = await from.boundingBox();
872
- const toBox = await to.boundingBox();
873
-
874
- await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
875
- await page.mouse.down();
876
- await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2);
877
- await page.mouse.up();
878
- } else {
879
- // Drag by coordinates
880
- await page.mouse.move(fromX, fromY);
881
- await page.mouse.down();
882
- await page.mouse.move(toX, toY);
883
- await page.mouse.up();
884
- }
885
- }
886
-
887
- async function executeNavigate(action, page, timeout) {
888
- await page.goto(action.data.url, {
889
- waitUntil: action.data.waitUntil || 'networkidle2',
890
- timeout
891
- });
892
- }
893
-
894
- async function executeExtract(action, page) {
895
- const { selector, attribute, multiple } = action.data;
896
-
897
- if (multiple) {
898
- return await page.$$eval(selector, (elements, attr) => {
899
- return elements.map(el => attr ? el.getAttribute(attr) : el.textContent.trim());
900
- }, attribute);
901
- } else {
902
- return await page.$eval(selector, (el, attr) => {
903
- return attr ? el.getAttribute(attr) : el.textContent.trim();
904
- }, attribute);
905
- }
906
- }
907
-
908
- /**
909
- * Substitute parameters in action
910
- * Replaces {{paramName}} with actual values
911
- */
912
- function substituteParameters(action, params) {
913
- const resolved = JSON.parse(JSON.stringify(action));
914
-
915
- // Substitute in action data
916
- if (resolved.data) {
917
- for (const [key, value] of Object.entries(resolved.data)) {
918
- if (typeof value === 'string') {
919
- resolved.data[key] = substituteString(value, params);
920
- }
921
- }
922
- }
923
-
924
- return resolved;
925
- }
926
-
927
- /**
928
- * Substitute {{param}} in string
929
- */
930
- function substituteString(str, params) {
931
- return str.replace(/\{\{(\w+)\}\}/g, (match, paramName) => {
932
- if (params[paramName] !== undefined) {
933
- return params[paramName];
934
- }
935
- return match; // Keep original if param not found
936
- });
937
- }
938
-
939
- /**
940
- * Element finder utils code (to be injected into page)
941
- * Will be loaded from utils/element-finder-utils.js
942
- */
943
- const elementFinderUtilsCode = `
944
- // This will be populated from element-finder-utils.js browser-side code
945
- // For now, placeholder
946
- `;
1
+ /**
2
+ * recorder/scenario-executor.js
3
+ *
4
+ * Executes recorded scenarios with:
5
+ * 1. Action playback with error handling
6
+ * 2. Parameter substitution
7
+ * 3. Secret injection
8
+ * 4. Dependency resolution and chaining
9
+ * 5. Retry logic with fallback selectors
10
+ */
11
+
12
+ import { resolveDependencies, checkDependencyCondition } from './dependency-resolver.js';
13
+ import { loadScenario, loadSecrets, loadIndex } from './scenario-storage.js';
14
+
15
+ // Debug mode - avoid polluting STDIO with logs (breaks MCP JSON-RPC)
16
+ const DEBUG_MODE = process.env.CHROMETOOLS_DEBUG === 'true';
17
+ const debugLog = DEBUG_MODE ? console.error : () => {};
18
+
19
+ /**
20
+ * Execute scenario with dependencies
21
+ * @param {string} scenarioName - Scenario to execute
22
+ * @param {Object} page - Puppeteer page instance
23
+ * @param {Object} params - Parameters for scenario
24
+ * @param {Object} options - Execution options { executeDependencies, skipConditions, maxRetries, timeout }
25
+ * @returns {Object} - Execution result
26
+ */
27
+ export async function executeScenario(scenarioName, page, params = {}, options = {}) {
28
+ const {
29
+ executeDependencies = true, // Execute dependencies by default
30
+ skipConditions = false,
31
+ maxRetries = 3,
32
+ timeout = 30000,
33
+ projectId = null // Optional projectId for disambiguation
34
+ } = options;
35
+
36
+ const result = {
37
+ success: false,
38
+ scenarioName,
39
+ executedScenarios: [],
40
+ errors: [],
41
+ outputs: {},
42
+ duration: 0
43
+ };
44
+
45
+ const startTime = Date.now();
46
+
47
+ try {
48
+ // Load scenario to get its metadata (needed for dependency resolution)
49
+ const initialScenario = await loadScenario(scenarioName, false, projectId);
50
+
51
+ if (!initialScenario) {
52
+ result.errors.push(`Scenario "${scenarioName}" not found`);
53
+ return result;
54
+ }
55
+
56
+ // Check for name collision
57
+ if (initialScenario.collision) {
58
+ result.errors.push(initialScenario.message);
59
+ result.availableProjectIds = initialScenario.availableProjectIds;
60
+ return result;
61
+ }
62
+
63
+ let chain = [{ name: scenarioName, projectId }]; // Default: execute only the requested scenario
64
+
65
+ // Resolve and execute dependencies if enabled
66
+ if (executeDependencies && initialScenario.metadata?.dependencies) {
67
+ // Build a simplified index from metadata for dependency resolution
68
+ // In the new system, we need to resolve cross-project dependencies
69
+ // Dependencies inherit parent's projectId unless they specify their own
70
+ chain = [
71
+ ...initialScenario.metadata.dependencies.map(dep => ({
72
+ name: dep.scenario,
73
+ projectId: dep.projectId || projectId // Use explicit projectId or inherit from parent
74
+ })),
75
+ { name: scenarioName, projectId }
76
+ ];
77
+ }
78
+
79
+ // Execute chain in order
80
+ for (const item of chain) {
81
+ const scenario = await loadScenario(item.name, true, item.projectId); // Load with secrets, using item's projectId
82
+
83
+ if (!scenario) {
84
+ result.errors.push(`Scenario "${item.name}" not found`);
85
+ return result;
86
+ }
87
+
88
+ // Check for name collision in dependencies
89
+ if (scenario.collision) {
90
+ result.errors.push(`Dependency "${item.name}": ${scenario.message}`);
91
+ result.availableProjectIds = scenario.availableProjectIds;
92
+ return result;
93
+ }
94
+
95
+ // Check dependency conditions
96
+ if (scenario.metadata?.dependencies) {
97
+ for (const dep of scenario.metadata.dependencies) {
98
+ if (dep.condition) {
99
+ const context = { page, variables: params };
100
+ const shouldExecute = await checkDependencyCondition(dep.condition, context);
101
+
102
+ if (!shouldExecute) {
103
+ debugLog(`Skipping scenario "${item.name}" due to condition`);
104
+ continue;
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ // Merge secrets with params
111
+ const executionParams = { ...params, ...(scenario.secrets || {}) };
112
+
113
+ // Execute scenario
114
+ const scenarioResult = await executeSingleScenario(scenario, page, executionParams, {
115
+ maxRetries,
116
+ timeout
117
+ });
118
+
119
+ result.executedScenarios.push(item.name);
120
+
121
+ if (!scenarioResult.success) {
122
+ result.errors.push(...scenarioResult.errors);
123
+ return result;
124
+ }
125
+
126
+ // Collect outputs for next scenarios
127
+ if (scenarioResult.outputs) {
128
+ Object.assign(result.outputs, scenarioResult.outputs);
129
+ Object.assign(params, scenarioResult.outputs);
130
+ }
131
+ }
132
+
133
+ result.success = true;
134
+ } catch (error) {
135
+ result.errors.push(`Execution failed: ${error.message}`);
136
+ } finally {
137
+ result.duration = Date.now() - startTime;
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ /**
144
+ * Execute single scenario (without dependencies)
145
+ * @param {Object} scenario - Scenario data
146
+ * @param {Object} page - Puppeteer page
147
+ * @param {Object} params - Parameters
148
+ * @param {Object} options - Options
149
+ * @returns {Object} - Execution result
150
+ */
151
+ async function executeSingleScenario(scenario, page, params = {}, options = {}) {
152
+ const { maxRetries = 3, timeout = 30000 } = options;
153
+
154
+ const result = {
155
+ success: false,
156
+ errors: [],
157
+ outputs: {},
158
+ actionResults: []
159
+ };
160
+
161
+ try {
162
+ for (const action of scenario.chain) {
163
+ // Substitute parameters in action
164
+ const resolvedAction = substituteParameters(action, params);
165
+
166
+ // Execute action with retry
167
+ const actionResult = await executeActionWithRetry(
168
+ resolvedAction,
169
+ page,
170
+ maxRetries,
171
+ timeout
172
+ );
173
+
174
+ result.actionResults.push(actionResult);
175
+
176
+ if (!actionResult.success) {
177
+ result.errors.push(`Action failed: ${actionResult.error}`);
178
+ return result;
179
+ }
180
+
181
+ // Store outputs if action produces any
182
+ if (actionResult.output) {
183
+ Object.assign(result.outputs, actionResult.output);
184
+ }
185
+ }
186
+
187
+ // Validate final URL if exitUrl is specified in metadata
188
+ if (scenario.metadata?.exitUrl) {
189
+ // Wait a bit for any pending navigation/redirects to complete
190
+ await new Promise(resolve => setTimeout(resolve, 500));
191
+
192
+ // Get current URL from the page (more reliable than page.url() for recent navigation)
193
+ const currentUrl = await page.evaluate(() => window.location.href);
194
+ const expectedUrl = scenario.metadata.exitUrl;
195
+
196
+ // Normalize URLs for comparison (remove trailing slashes, fragments)
197
+ const normalizeUrl = (url) => {
198
+ try {
199
+ const parsed = new URL(url);
200
+ // Remove fragment and trailing slash
201
+ return `${parsed.origin}${parsed.pathname.replace(/\/$/, '')}${parsed.search}`;
202
+ } catch {
203
+ return url.replace(/\/$/, '');
204
+ }
205
+ };
206
+
207
+ const normalizedCurrent = normalizeUrl(currentUrl);
208
+ const normalizedExpected = normalizeUrl(expectedUrl);
209
+
210
+ if (normalizedCurrent !== normalizedExpected) {
211
+ result.errors.push(
212
+ `❌ URL Validation Failed\n\n` +
213
+ `Expected final URL: ${expectedUrl}\n` +
214
+ `Actual final URL: ${currentUrl}\n\n` +
215
+ `The scenario ended on a different page than expected.\n` +
216
+ `This may indicate:\n` +
217
+ ` - Navigation flow has changed\n` +
218
+ ` - An action failed silently\n` +
219
+ ` - Page redirected unexpectedly\n\n` +
220
+ `💡 Suggestion: Check if the page flow or redirects have changed since recording.`
221
+ );
222
+ return result;
223
+ }
224
+
225
+ // URL validation passed
226
+ result.urlValidation = {
227
+ success: true,
228
+ expectedUrl,
229
+ actualUrl: currentUrl
230
+ };
231
+ }
232
+
233
+ result.success = true;
234
+ } catch (error) {
235
+ result.errors.push(`Scenario execution error: ${error.message}`);
236
+ }
237
+
238
+ return result;
239
+ }
240
+
241
+ /**
242
+ * Execute action with retry and fallback selectors
243
+ */
244
+ async function executeActionWithRetry(action, page, maxRetries, timeout) {
245
+ const result = {
246
+ success: false,
247
+ action: action.type,
248
+ error: null,
249
+ errorDetails: {
250
+ attempts: [],
251
+ selector: action.selector?.value || action.selector?.primary,
252
+ context: null
253
+ },
254
+ output: null,
255
+ attempts: 0
256
+ };
257
+
258
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
259
+ result.attempts = attempt;
260
+ const attemptInfo = {
261
+ number: attempt,
262
+ selector: action.selector?.value || action.selector?.primary,
263
+ error: null,
264
+ timestamp: new Date().toISOString()
265
+ };
266
+
267
+ try {
268
+ // Execute action based on type
269
+ const actionResult = await executeAction(action, page, timeout);
270
+
271
+ result.success = true;
272
+ result.output = actionResult.output;
273
+ attemptInfo.success = true;
274
+ result.errorDetails.attempts.push(attemptInfo);
275
+ return result;
276
+ } catch (error) {
277
+ attemptInfo.error = error.message;
278
+ attemptInfo.success = false;
279
+
280
+ // Capture page context for error reporting
281
+ if (attempt === maxRetries) {
282
+ try {
283
+ result.errorDetails.context = await capturePageContext(page, action);
284
+ } catch (contextError) {
285
+ debugLog('Failed to capture page context:', contextError);
286
+ }
287
+ }
288
+
289
+ result.errorDetails.attempts.push(attemptInfo);
290
+ result.error = error.message;
291
+
292
+ // If this is a selector error and we have fallbacks, try them
293
+ if (action.selector?.fallbacks && action.selector.fallbacks.length > 0) {
294
+ const fallback = action.selector.fallbacks[0];
295
+ debugLog(`[Retry ${attempt}] Trying fallback selector: ${fallback}`);
296
+
297
+ action.selector.value = fallback;
298
+ action.selector.fallbacks = action.selector.fallbacks.slice(1);
299
+ continue;
300
+ }
301
+
302
+ // If we have element description, try smartFindElement
303
+ if (action.selector?.elementInfo?.text && attempt < maxRetries) {
304
+ debugLog(`[Retry ${attempt}] Selector failed, trying smartFindElement with description: ${action.selector.elementInfo.text}`);
305
+
306
+ try {
307
+ // Inject element finder utilities if not already done
308
+ await page.evaluate(elementFinderUtilsCode);
309
+
310
+ const smartResult = await page.evaluate((description) => {
311
+ return window.smartFindElement({ description, maxResults: 3 });
312
+ }, action.selector.elementInfo.text);
313
+
314
+ if (smartResult.candidates && smartResult.candidates.length > 0) {
315
+ action.selector.value = smartResult.candidates[0].selector;
316
+ action.selector.fallbacks = smartResult.candidates.slice(1).map(c => c.selector);
317
+ debugLog(`[Retry ${attempt}] Found alternative selector: ${action.selector.value}`);
318
+ continue;
319
+ }
320
+ } catch (smartError) {
321
+ debugLog('[Retry] smartFindElement failed:', smartError.message);
322
+ }
323
+ }
324
+
325
+ // Last attempt failed
326
+ if (attempt === maxRetries) {
327
+ // Create comprehensive error message
328
+ result.error = formatDetailedError(action, result.errorDetails);
329
+ return result;
330
+ }
331
+
332
+ // Wait before retry
333
+ await new Promise(resolve => setTimeout(resolve, 1000));
334
+ }
335
+ }
336
+
337
+ return result;
338
+ }
339
+
340
+ /**
341
+ * Capture page context for error reporting
342
+ */
343
+ async function capturePageContext(page, action) {
344
+ try {
345
+ const context = {
346
+ url: page.url(),
347
+ title: await page.title(),
348
+ elementExists: false,
349
+ elementVisible: false,
350
+ elementInfo: null,
351
+ pageState: null
352
+ };
353
+
354
+ const selector = action.selector?.value || action.selector?.primary;
355
+
356
+ if (selector) {
357
+ // Check if element exists
358
+ context.elementExists = await page.evaluate((sel) => {
359
+ return document.querySelector(sel) !== null;
360
+ }, selector);
361
+
362
+ // If exists, check visibility and get info
363
+ if (context.elementExists) {
364
+ context.elementInfo = await page.evaluate((sel) => {
365
+ const el = document.querySelector(sel);
366
+ const rect = el.getBoundingClientRect();
367
+ const styles = window.getComputedStyle(el);
368
+
369
+ return {
370
+ tagName: el.tagName,
371
+ id: el.id,
372
+ className: el.className,
373
+ visible: rect.width > 0 && rect.height > 0 && styles.display !== 'none' && styles.visibility !== 'hidden',
374
+ disabled: el.disabled || el.getAttribute('aria-disabled') === 'true',
375
+ readonly: el.readOnly || el.getAttribute('aria-readonly') === 'true',
376
+ position: {
377
+ top: rect.top,
378
+ left: rect.left,
379
+ width: rect.width,
380
+ height: rect.height
381
+ },
382
+ styles: {
383
+ display: styles.display,
384
+ visibility: styles.visibility,
385
+ opacity: styles.opacity,
386
+ pointerEvents: styles.pointerEvents
387
+ }
388
+ };
389
+ }, selector);
390
+
391
+ context.elementVisible = context.elementInfo.visible;
392
+ }
393
+ }
394
+
395
+ // Get page state
396
+ context.pageState = await page.evaluate(() => {
397
+ return {
398
+ readyState: document.readyState,
399
+ hasModals: document.querySelector('[role="dialog"], .modal, .popup') !== null,
400
+ hasOverlays: document.querySelector('.overlay, .backdrop') !== null,
401
+ activeElement: document.activeElement ? {
402
+ tagName: document.activeElement.tagName,
403
+ id: document.activeElement.id,
404
+ className: document.activeElement.className
405
+ } : null
406
+ };
407
+ });
408
+
409
+ return context;
410
+ } catch (error) {
411
+ return { error: error.message };
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Format detailed error message for AI agent
417
+ */
418
+ function formatDetailedError(action, errorDetails) {
419
+ const parts = [
420
+ `❌ Action "${action.type}" failed after ${errorDetails.attempts.length} attempts`,
421
+ ``,
422
+ `📍 Selector: ${errorDetails.selector}`,
423
+ ];
424
+
425
+ if (errorDetails.context) {
426
+ parts.push(``, `📄 Page Context:`);
427
+ parts.push(` URL: ${errorDetails.context.url}`);
428
+ parts.push(` Title: ${errorDetails.context.title}`);
429
+
430
+ if (errorDetails.context.elementExists) {
431
+ parts.push(``, `🔍 Element Found But:`);
432
+ const info = errorDetails.context.elementInfo;
433
+
434
+ if (!info.visible) {
435
+ parts.push(` ⚠️ Element is NOT VISIBLE`);
436
+ parts.push(` - Display: ${info.styles.display}`);
437
+ parts.push(` - Visibility: ${info.styles.visibility}`);
438
+ parts.push(` - Opacity: ${info.styles.opacity}`);
439
+ parts.push(` - Size: ${info.position.width}x${info.position.height}`);
440
+ }
441
+
442
+ if (info.disabled) {
443
+ parts.push(` ⚠️ Element is DISABLED`);
444
+ }
445
+
446
+ if (info.readonly && action.type === 'type') {
447
+ parts.push(` ⚠️ Element is READONLY`);
448
+ }
449
+
450
+ if (info.styles.pointerEvents === 'none') {
451
+ parts.push(` ⚠️ Element has pointer-events: none`);
452
+ }
453
+ } else {
454
+ parts.push(``, `❌ Element NOT FOUND in DOM`);
455
+ }
456
+
457
+ if (errorDetails.context.pageState) {
458
+ const state = errorDetails.context.pageState;
459
+ if (state.hasModals) {
460
+ parts.push(``, `⚠️ Page has open modal/dialog`);
461
+ }
462
+ if (state.hasOverlays) {
463
+ parts.push(`⚠️ Page has overlay/backdrop`);
464
+ }
465
+ }
466
+ }
467
+
468
+ parts.push(``, `🔄 Retry History:`);
469
+ errorDetails.attempts.forEach(attempt => {
470
+ parts.push(` Attempt ${attempt.number}: ${attempt.error || 'Unknown error'}`);
471
+ if (attempt.selector !== errorDetails.selector) {
472
+ parts.push(` (tried selector: ${attempt.selector})`);
473
+ }
474
+ });
475
+
476
+ parts.push(``, `💡 Suggestions:`);
477
+ if (!errorDetails.context?.elementExists) {
478
+ parts.push(` - Check if page has fully loaded`);
479
+ parts.push(` - Verify the selector is correct`);
480
+ parts.push(` - Element might be dynamically added - add wait condition`);
481
+ } else if (!errorDetails.context?.elementVisible) {
482
+ parts.push(` - Element exists but is hidden - check CSS/JS conditions`);
483
+ parts.push(` - Wait for element to become visible`);
484
+ parts.push(` - Check if element is covered by modal/overlay`);
485
+ }
486
+
487
+ return parts.join('\n');
488
+ }
489
+
490
+ /**
491
+ * Execute single action
492
+ */
493
+ async function executeAction(action, page, timeout) {
494
+ const result = { output: null };
495
+
496
+ switch (action.type) {
497
+ case 'click':
498
+ await executeClick(action, page, timeout);
499
+ break;
500
+
501
+ case 'type':
502
+ await executeType(action, page, timeout);
503
+ break;
504
+
505
+ case 'select':
506
+ await executeSelect(action, page, timeout);
507
+ break;
508
+
509
+ case 'scroll':
510
+ await executeScroll(action, page);
511
+ break;
512
+
513
+ case 'hover':
514
+ await executeHover(action, page);
515
+ break;
516
+
517
+ case 'keypress':
518
+ await executeKeypress(action, page);
519
+ break;
520
+
521
+ case 'wait':
522
+ await executeWait(action, page);
523
+ break;
524
+
525
+ case 'upload':
526
+ await executeUpload(action, page, timeout);
527
+ break;
528
+
529
+ case 'drag':
530
+ await executeDrag(action, page);
531
+ break;
532
+
533
+ case 'navigate':
534
+ await executeNavigate(action, page, timeout);
535
+ break;
536
+
537
+ case 'extract':
538
+ result.output = await executeExtract(action, page);
539
+ break;
540
+
541
+ default:
542
+ throw new Error(`Unknown action type: ${action.type}`);
543
+ }
544
+
545
+ return result;
546
+ }
547
+
548
+ /**
549
+ * Action executors
550
+ */
551
+
552
+ async function executeClick(action, page, timeout) {
553
+ const selector = action.selector.value || action.selector.primary || action.selector;
554
+
555
+ try {
556
+ await page.waitForSelector(selector, { timeout, visible: true });
557
+ await page.click(selector);
558
+
559
+ // Smart waiting after click
560
+ if (action.data.requiresWait !== false) {
561
+ await smartWaitAfterClick(page, action, timeout);
562
+ }
563
+
564
+ // Additional wait if specified
565
+ if (action.data.waitAfter) {
566
+ await new Promise(resolve => setTimeout(resolve, action.data.waitAfter));
567
+ }
568
+ } catch (error) {
569
+ throw new Error(`Failed to click "${selector}": ${error.message}`);
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Smart waiting after click - waits for animations and network requests
575
+ */
576
+ async function smartWaitAfterClick(page, action, timeout) {
577
+ const startTime = Date.now();
578
+ const maxWaitTime = timeout || 30000;
579
+
580
+ try {
581
+ // Initial wait 500ms to let page respond
582
+ await new Promise(resolve => setTimeout(resolve, 500));
583
+
584
+ // Check if there's any activity (animations, network, DOM changes)
585
+ const hasActivity = await checkPageActivity(page);
586
+
587
+ if (!hasActivity) {
588
+ // No activity detected - we're done, fast exit
589
+ debugLog('[Smart Wait] No activity detected, skipping extended wait');
590
+ return;
591
+ }
592
+
593
+ // Activity detected - wait minimum 2 seconds
594
+ debugLog('[Smart Wait] Activity detected, waiting for completion');
595
+ const remainingMinWait = 2000 - 500; // Already waited 500ms
596
+ if (remainingMinWait > 0) {
597
+ await new Promise(resolve => setTimeout(resolve, remainingMinWait));
598
+ }
599
+
600
+ // Wait for animations to complete
601
+ await page.evaluate(() => {
602
+ return new Promise((resolve) => {
603
+ const checkAnimations = () => {
604
+ // Check for CSS animations/transitions
605
+ const elements = document.querySelectorAll('*');
606
+ let hasAnimations = false;
607
+
608
+ for (const el of elements) {
609
+ const computedStyle = window.getComputedStyle(el);
610
+ const animations = computedStyle.getPropertyValue('animation-name');
611
+ const transitions = computedStyle.getPropertyValue('transition-property');
612
+
613
+ if ((animations && animations !== 'none') ||
614
+ (transitions && transitions !== 'none' && transitions !== 'all')) {
615
+ hasAnimations = true;
616
+ break;
617
+ }
618
+ }
619
+
620
+ if (!hasAnimations) {
621
+ resolve();
622
+ } else {
623
+ setTimeout(checkAnimations, 100);
624
+ }
625
+ };
626
+
627
+ // Start checking immediately
628
+ checkAnimations();
629
+ // Timeout after 3 seconds
630
+ setTimeout(resolve, 3000);
631
+ });
632
+ });
633
+
634
+ // Wait for network to be idle (no pending requests for 500ms)
635
+ await Promise.race([
636
+ page.waitForNetworkIdle({ idleTime: 500, timeout: 5000 }),
637
+ new Promise(resolve => setTimeout(resolve, 5000)) // Max 5 seconds for network
638
+ ]);
639
+
640
+ // Wait for any DOM changes to settle
641
+ await page.evaluate(() => {
642
+ return new Promise((resolve) => {
643
+ let timeoutId;
644
+ const observer = new MutationObserver(() => {
645
+ clearTimeout(timeoutId);
646
+ timeoutId = setTimeout(() => {
647
+ observer.disconnect();
648
+ resolve();
649
+ }, 300); // 300ms of no DOM changes
650
+ });
651
+
652
+ observer.observe(document.body, {
653
+ childList: true,
654
+ subtree: true,
655
+ attributes: true,
656
+ attributeFilter: ['class', 'style', 'hidden', 'disabled']
657
+ });
658
+
659
+ // Start the timeout
660
+ timeoutId = setTimeout(() => {
661
+ observer.disconnect();
662
+ resolve();
663
+ }, 300);
664
+
665
+ // Max wait 3 seconds
666
+ setTimeout(() => {
667
+ observer.disconnect();
668
+ resolve();
669
+ }, 3000);
670
+ });
671
+ });
672
+
673
+ } catch (error) {
674
+ // If smart wait fails, just log and continue
675
+ debugLog('[Smart Wait] Error during smart wait:', error.message);
676
+ }
677
+
678
+ // Ensure we don't exceed max wait time
679
+ const elapsed = Date.now() - startTime;
680
+ if (elapsed > maxWaitTime) {
681
+ debugLog(`[Smart Wait] Exceeded max wait time (${maxWaitTime}ms)`);
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Check if page has any ongoing activity (animations, network, DOM changes)
687
+ * Returns true if activity detected, false otherwise
688
+ */
689
+ async function checkPageActivity(page) {
690
+ try {
691
+ const activity = await page.evaluate(() => {
692
+ // Check for animations
693
+ const elements = document.querySelectorAll('*');
694
+ for (const el of elements) {
695
+ const computedStyle = window.getComputedStyle(el);
696
+ const animations = computedStyle.getPropertyValue('animation-name');
697
+ const transitions = computedStyle.getPropertyValue('transition-property');
698
+
699
+ if ((animations && animations !== 'none') ||
700
+ (transitions && transitions !== 'none' && transitions !== 'all')) {
701
+ return { hasActivity: true, reason: 'animations' };
702
+ }
703
+ }
704
+
705
+ // Check for recent DOM changes (using performance API)
706
+ if (window.performance && window.performance.getEntriesByType) {
707
+ const entries = window.performance.getEntriesByType('measure');
708
+ if (entries.length > 0) {
709
+ const recentEntries = entries.filter(e =>
710
+ performance.now() - e.startTime < 500
711
+ );
712
+ if (recentEntries.length > 0) {
713
+ return { hasActivity: true, reason: 'performance_measures' };
714
+ }
715
+ }
716
+ }
717
+
718
+ return { hasActivity: false, reason: 'none' };
719
+ });
720
+
721
+ if (activity.hasActivity) {
722
+ debugLog(`[Smart Wait] Activity detected: ${activity.reason}`);
723
+ return true;
724
+ }
725
+
726
+ // Check for pending network requests using CDP
727
+ try {
728
+ const client = await page.target().createCDPSession();
729
+ await client.send('Network.enable');
730
+
731
+ // Give a moment for network requests to start
732
+ await new Promise(resolve => setTimeout(resolve, 100));
733
+
734
+ const hasNetworkActivity = await new Promise((resolve) => {
735
+ let requestCount = 0;
736
+
737
+ const requestListener = () => {
738
+ requestCount++;
739
+ };
740
+
741
+ client.on('Network.requestWillBeSent', requestListener);
742
+
743
+ setTimeout(() => {
744
+ client.off('Network.requestWillBeSent', requestListener);
745
+ client.detach().catch(() => {});
746
+ resolve(requestCount > 0);
747
+ }, 200);
748
+ });
749
+
750
+ if (hasNetworkActivity) {
751
+ debugLog('[Smart Wait] Network activity detected');
752
+ return true;
753
+ }
754
+ } catch (netError) {
755
+ // Network check failed, assume no activity
756
+ debugLog('[Smart Wait] Network check failed, assuming no activity');
757
+ }
758
+
759
+ return false;
760
+ } catch (error) {
761
+ // If check fails, assume there's activity to be safe
762
+ debugLog('[Smart Wait] Activity check failed, assuming activity exists:', error.message);
763
+ return true;
764
+ }
765
+ }
766
+
767
+ async function executeType(action, page, timeout) {
768
+ const selector = action.selector.value || action.selector.primary || action.selector;
769
+
770
+ try {
771
+ await page.waitForSelector(selector, { timeout, visible: true });
772
+
773
+ // Check if element is editable
774
+ const isEditable = await page.evaluate((sel) => {
775
+ const el = document.querySelector(sel);
776
+ if (!el) return false;
777
+
778
+ const isInput = el.tagName === 'INPUT' || el.tagName === 'TEXTAREA';
779
+ const isContentEditable = el.isContentEditable;
780
+
781
+ return isInput || isContentEditable;
782
+ }, selector);
783
+
784
+ if (!isEditable) {
785
+ throw new Error(`Element "${selector}" is not editable (not an input, textarea, or contenteditable)`);
786
+ }
787
+
788
+ // Clear field if specified
789
+ if (action.data.clearFirst !== false) {
790
+ await page.click(selector, { clickCount: 3 });
791
+ await page.keyboard.press('Backspace');
792
+ }
793
+
794
+ // Type text with optional delay
795
+ await page.type(selector, action.data.text, {
796
+ delay: action.data.delay || 0
797
+ });
798
+ } catch (error) {
799
+ throw new Error(`Failed to type into "${selector}": ${error.message}`);
800
+ }
801
+ }
802
+
803
+ async function executeSelect(action, page, timeout) {
804
+ const selector = action.selector.value || action.selector.primary || action.selector;
805
+
806
+ try {
807
+ if (action.data.selectType === 'custom') {
808
+ // Custom select (multi-step)
809
+ for (const step of action.data.steps) {
810
+ if (step.action === 'click') {
811
+ await page.waitForSelector(step.selector, { timeout });
812
+ await page.click(step.selector);
813
+ } else if (step.action === 'wait') {
814
+ await new Promise(resolve => setTimeout(resolve, step.duration));
815
+ }
816
+ }
817
+ } else {
818
+ // Native select
819
+ await page.waitForSelector(selector, { timeout, visible: true });
820
+
821
+ // Verify it's a select element
822
+ const isSelect = await page.evaluate((sel) => {
823
+ const el = document.querySelector(sel);
824
+ return el && el.tagName === 'SELECT';
825
+ }, selector);
826
+
827
+ if (!isSelect) {
828
+ throw new Error(`Element "${selector}" is not a <select> element`);
829
+ }
830
+
831
+ await page.select(selector, action.data.value);
832
+ }
833
+ } catch (error) {
834
+ throw new Error(`Failed to select option in "${selector}": ${error.message}`);
835
+ }
836
+ }
837
+
838
+ async function executeScroll(action, page) {
839
+ const selector = action.selector.value || action.selector.primary || action.selector;
840
+ await page.evaluate((selector, behavior) => {
841
+ const element = document.querySelector(selector);
842
+ if (element) {
843
+ element.scrollIntoView({ behavior: behavior || 'auto', block: 'center' });
844
+ }
845
+ }, selector, action.data.behavior);
846
+ }
847
+
848
+ async function executeHover(action, page) {
849
+ const selector = action.selector.value || action.selector.primary || action.selector;
850
+ await page.hover(selector);
851
+ }
852
+
853
+ async function executeKeypress(action, page) {
854
+ const key = action.data.key;
855
+ const modifiers = action.data.modifiers || [];
856
+
857
+ // Press modifiers
858
+ for (const mod of modifiers) {
859
+ await page.keyboard.down(mod);
860
+ }
861
+
862
+ // Press key
863
+ await page.keyboard.press(key);
864
+
865
+ // Release modifiers
866
+ for (const mod of modifiers.reverse()) {
867
+ await page.keyboard.up(mod);
868
+ }
869
+ }
870
+
871
+ async function executeWait(action, page) {
872
+ if (action.data.waitType === 'selector') {
873
+ await page.waitForSelector(action.data.selector, {
874
+ timeout: action.data.duration
875
+ });
876
+ } else {
877
+ await new Promise(resolve => setTimeout(resolve, action.data.duration));
878
+ }
879
+ }
880
+
881
+ async function executeUpload(action, page, timeout) {
882
+ const selector = action.selector.value || action.selector.primary || action.selector;
883
+ const fileInput = await page.waitForSelector(selector, { timeout });
884
+ await fileInput.uploadFile(action.data.filePath);
885
+ }
886
+
887
+ async function executeDrag(action, page) {
888
+ const { fromSelector, toSelector, fromX, fromY, toX, toY } = action.data;
889
+
890
+ if (fromSelector && toSelector) {
891
+ // Drag from element to element
892
+ const from = await page.$(fromSelector);
893
+ const to = await page.$(toSelector);
894
+
895
+ const fromBox = await from.boundingBox();
896
+ const toBox = await to.boundingBox();
897
+
898
+ await page.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
899
+ await page.mouse.down();
900
+ await page.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2);
901
+ await page.mouse.up();
902
+ } else {
903
+ // Drag by coordinates
904
+ await page.mouse.move(fromX, fromY);
905
+ await page.mouse.down();
906
+ await page.mouse.move(toX, toY);
907
+ await page.mouse.up();
908
+ }
909
+ }
910
+
911
+ async function executeNavigate(action, page, timeout) {
912
+ await page.goto(action.data.url, {
913
+ waitUntil: action.data.waitUntil || 'networkidle2',
914
+ timeout
915
+ });
916
+ }
917
+
918
+ async function executeExtract(action, page) {
919
+ const { selector, attribute, multiple } = action.data;
920
+
921
+ if (multiple) {
922
+ return await page.$$eval(selector, (elements, attr) => {
923
+ return elements.map(el => attr ? el.getAttribute(attr) : el.textContent.trim());
924
+ }, attribute);
925
+ } else {
926
+ return await page.$eval(selector, (el, attr) => {
927
+ return attr ? el.getAttribute(attr) : el.textContent.trim();
928
+ }, attribute);
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Substitute parameters in action
934
+ * Replaces {{paramName}} with actual values
935
+ */
936
+ function substituteParameters(action, params) {
937
+ const resolved = JSON.parse(JSON.stringify(action));
938
+
939
+ // Substitute in action data
940
+ if (resolved.data) {
941
+ for (const [key, value] of Object.entries(resolved.data)) {
942
+ if (typeof value === 'string') {
943
+ resolved.data[key] = substituteString(value, params);
944
+ }
945
+ }
946
+ }
947
+
948
+ return resolved;
949
+ }
950
+
951
+ /**
952
+ * Substitute {{param}} in string
953
+ */
954
+ function substituteString(str, params) {
955
+ return str.replace(/\{\{(\w+)\}\}/g, (match, paramName) => {
956
+ if (params[paramName] !== undefined) {
957
+ return params[paramName];
958
+ }
959
+ return match; // Keep original if param not found
960
+ });
961
+ }
962
+
963
+ /**
964
+ * Element finder utils code (to be injected into page)
965
+ * Will be loaded from utils/element-finder-utils.js
966
+ */
967
+ const elementFinderUtilsCode = `
968
+ // This will be populated from element-finder-utils.js browser-side code
969
+ // For now, placeholder
970
+ `;