@browserflow-ai/exploration 0.0.6

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.
Files changed (58) hide show
  1. package/dist/adapters/claude-cli.d.ts +57 -0
  2. package/dist/adapters/claude-cli.d.ts.map +1 -0
  3. package/dist/adapters/claude-cli.js +195 -0
  4. package/dist/adapters/claude-cli.js.map +1 -0
  5. package/dist/adapters/claude.d.ts +54 -0
  6. package/dist/adapters/claude.d.ts.map +1 -0
  7. package/dist/adapters/claude.js +160 -0
  8. package/dist/adapters/claude.js.map +1 -0
  9. package/dist/adapters/index.d.ts +6 -0
  10. package/dist/adapters/index.d.ts.map +1 -0
  11. package/dist/adapters/index.js +4 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/types.d.ts +196 -0
  14. package/dist/adapters/types.d.ts.map +1 -0
  15. package/dist/adapters/types.js +3 -0
  16. package/dist/adapters/types.js.map +1 -0
  17. package/dist/agent-browser-session.d.ts +62 -0
  18. package/dist/agent-browser-session.d.ts.map +1 -0
  19. package/dist/agent-browser-session.js +272 -0
  20. package/dist/agent-browser-session.js.map +1 -0
  21. package/dist/evidence.d.ts +111 -0
  22. package/dist/evidence.d.ts.map +1 -0
  23. package/dist/evidence.js +144 -0
  24. package/dist/evidence.js.map +1 -0
  25. package/dist/explorer.d.ts +180 -0
  26. package/dist/explorer.d.ts.map +1 -0
  27. package/dist/explorer.js +393 -0
  28. package/dist/explorer.js.map +1 -0
  29. package/dist/index.d.ts +15 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +15 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/locator-candidates.d.ts +127 -0
  34. package/dist/locator-candidates.d.ts.map +1 -0
  35. package/dist/locator-candidates.js +358 -0
  36. package/dist/locator-candidates.js.map +1 -0
  37. package/dist/step-executor.d.ts +99 -0
  38. package/dist/step-executor.d.ts.map +1 -0
  39. package/dist/step-executor.js +646 -0
  40. package/dist/step-executor.js.map +1 -0
  41. package/package.json +34 -0
  42. package/src/adapters/claude-cli.test.ts +134 -0
  43. package/src/adapters/claude-cli.ts +240 -0
  44. package/src/adapters/claude.test.ts +195 -0
  45. package/src/adapters/claude.ts +190 -0
  46. package/src/adapters/index.ts +21 -0
  47. package/src/adapters/types.ts +207 -0
  48. package/src/agent-browser-session.test.ts +369 -0
  49. package/src/agent-browser-session.ts +349 -0
  50. package/src/evidence.test.ts +239 -0
  51. package/src/evidence.ts +203 -0
  52. package/src/explorer.test.ts +321 -0
  53. package/src/explorer.ts +565 -0
  54. package/src/index.ts +51 -0
  55. package/src/locator-candidates.test.ts +602 -0
  56. package/src/locator-candidates.ts +441 -0
  57. package/src/step-executor.test.ts +696 -0
  58. package/src/step-executor.ts +783 -0
@@ -0,0 +1,783 @@
1
+ // @browserflow-ai/exploration - Step executor
2
+
3
+ import { parseDuration } from '@browserflow-ai/core';
4
+ import type { BrowserSession } from './explorer';
5
+ import type {
6
+ SpecStep,
7
+ StepResult,
8
+ StepExecution,
9
+ AIAdapter,
10
+ EnhancedSnapshot,
11
+ VerifyCheck,
12
+ } from './adapters/types';
13
+
14
+ /**
15
+ * Parse a timeout/duration value that can be string or number
16
+ * Supports duration strings like "500ms", "5s", "1m" as well as plain numbers
17
+ */
18
+ function parseTimeout(value: string | number | undefined, defaultValue: number): number {
19
+ if (value === undefined) return defaultValue;
20
+ try {
21
+ return parseDuration(value);
22
+ } catch {
23
+ return defaultValue;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Configuration for step execution
29
+ */
30
+ export interface StepExecutorConfig {
31
+ browser?: BrowserSession;
32
+ adapter?: AIAdapter;
33
+ baseUrl?: string;
34
+ defaultTimeout?: number;
35
+ screenshotDir?: string;
36
+ captureBeforeScreenshots?: boolean;
37
+ captureAfterScreenshots?: boolean;
38
+ customActionHandlers?: Record<
39
+ string,
40
+ (step: SpecStep, browser: BrowserSession) => Promise<StepExecution>
41
+ >;
42
+ }
43
+
44
+ /**
45
+ * Target resolution result
46
+ */
47
+ interface ResolvedTarget {
48
+ ref: string;
49
+ selector?: string;
50
+ }
51
+
52
+ /**
53
+ * StepExecutor - Executes individual spec steps against a browser session
54
+ *
55
+ * Handles:
56
+ * - Navigation: navigate, back, forward, refresh
57
+ * - Interaction: click, fill, type, select, check, press
58
+ * - Waiting: wait for element, text, url, time
59
+ * - Assertions: verify_state with various checks
60
+ * - Capture: screenshot, scroll
61
+ */
62
+ export class StepExecutor {
63
+ private browser?: BrowserSession;
64
+ private adapter?: AIAdapter;
65
+ private baseUrl: string;
66
+ private defaultTimeout: number;
67
+ private screenshotDir: string;
68
+ private captureBeforeScreenshots: boolean;
69
+ private captureAfterScreenshots: boolean;
70
+ private customActionHandlers: Record<
71
+ string,
72
+ (step: SpecStep, browser: BrowserSession) => Promise<StepExecution>
73
+ >;
74
+
75
+ constructor(config: StepExecutorConfig = {}) {
76
+ this.browser = config.browser;
77
+ this.adapter = config.adapter;
78
+ this.baseUrl = config.baseUrl ?? '';
79
+ this.defaultTimeout = config.defaultTimeout ?? 30000;
80
+ this.screenshotDir = config.screenshotDir ?? './screenshots';
81
+ this.captureBeforeScreenshots = config.captureBeforeScreenshots ?? false;
82
+ this.captureAfterScreenshots = config.captureAfterScreenshots ?? true;
83
+ this.customActionHandlers = config.customActionHandlers ?? {};
84
+ }
85
+
86
+ /**
87
+ * Configure the executor with browser and adapter (for lazy initialization)
88
+ */
89
+ configure(config: { browser?: BrowserSession; adapter?: AIAdapter; baseUrl?: string }): void {
90
+ if (config.browser) this.browser = config.browser;
91
+ if (config.adapter) this.adapter = config.adapter;
92
+ if (config.baseUrl) this.baseUrl = config.baseUrl;
93
+ }
94
+
95
+ /**
96
+ * Check if executor is properly configured
97
+ */
98
+ isConfigured(): boolean {
99
+ return !!this.browser && !!this.adapter;
100
+ }
101
+
102
+ /**
103
+ * Execute a single step
104
+ *
105
+ * @param step - The step definition from the spec
106
+ * @param stepIndex - Index of this step in the spec
107
+ * @returns Promise resolving to step result
108
+ */
109
+ async execute(step: SpecStep, stepIndex: number = 0): Promise<StepResult> {
110
+ const startTime = Date.now();
111
+ const screenshotPrefix = `step-${String(stepIndex).padStart(2, '0')}`;
112
+ let beforeScreenshot: string | undefined;
113
+ let afterScreenshot: string | undefined;
114
+
115
+ // Check if executor is configured
116
+ if (!this.browser || !this.adapter) {
117
+ return {
118
+ stepIndex,
119
+ specAction: step as Record<string, unknown>,
120
+ execution: {
121
+ status: 'failed',
122
+ method: step.action,
123
+ durationMs: Date.now() - startTime,
124
+ error: 'StepExecutor not configured - browser and adapter required',
125
+ },
126
+ screenshots: {
127
+ before: `${this.screenshotDir}/${screenshotPrefix}-before.png`,
128
+ after: `${this.screenshotDir}/${screenshotPrefix}-after.png`,
129
+ },
130
+ };
131
+ }
132
+
133
+ try {
134
+ // Capture before screenshot if configured
135
+ if (this.captureBeforeScreenshots) {
136
+ beforeScreenshot = await this.captureScreenshot(`${screenshotPrefix}-before`);
137
+ }
138
+
139
+ // Execute the action
140
+ const execution = await this.executeAction(step);
141
+
142
+ // Capture after screenshot (always for screenshot action, or if configured)
143
+ if (step.action === 'screenshot') {
144
+ const name = step.name || screenshotPrefix;
145
+ afterScreenshot = await this.captureScreenshot(name);
146
+ } else if (this.captureAfterScreenshots) {
147
+ afterScreenshot = await this.captureScreenshot(`${screenshotPrefix}-after`);
148
+ }
149
+
150
+ return {
151
+ stepIndex,
152
+ specAction: step as Record<string, unknown>,
153
+ execution: {
154
+ ...execution,
155
+ durationMs: Date.now() - startTime,
156
+ },
157
+ screenshots: {
158
+ before: beforeScreenshot ?? `${this.screenshotDir}/${screenshotPrefix}-before.png`,
159
+ after: afterScreenshot ?? `${this.screenshotDir}/${screenshotPrefix}-after.png`,
160
+ },
161
+ };
162
+ } catch (error) {
163
+ const errorMessage = error instanceof Error ? error.message : String(error);
164
+ return {
165
+ stepIndex,
166
+ specAction: step as Record<string, unknown>,
167
+ execution: {
168
+ status: 'failed',
169
+ method: step.action,
170
+ durationMs: Date.now() - startTime,
171
+ error: errorMessage,
172
+ },
173
+ screenshots: {
174
+ before: beforeScreenshot ?? `${this.screenshotDir}/${screenshotPrefix}-before.png`,
175
+ after: `${this.screenshotDir}/${screenshotPrefix}-after.png`,
176
+ },
177
+ };
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Execute a single action based on step type
183
+ */
184
+ private async executeAction(step: SpecStep): Promise<StepExecution> {
185
+ switch (step.action) {
186
+ // Navigation actions
187
+ case 'navigate':
188
+ return this.executeNavigate(step);
189
+ case 'back':
190
+ return this.executeBack();
191
+ case 'forward':
192
+ return this.executeForward();
193
+ case 'reload': // Alias for refresh
194
+ case 'refresh':
195
+ return this.executeRefresh();
196
+
197
+ // Interaction actions
198
+ case 'click':
199
+ return this.executeClick(step);
200
+ case 'fill':
201
+ return this.executeFill(step);
202
+ case 'type':
203
+ return this.executeType(step);
204
+ case 'select':
205
+ return this.executeSelect(step);
206
+ case 'check':
207
+ return this.executeCheck(step);
208
+ case 'press':
209
+ return this.executePress(step);
210
+
211
+ // Wait actions
212
+ case 'wait':
213
+ return this.executeWait(step);
214
+
215
+ // Verification actions
216
+ case 'verify_state':
217
+ return this.executeVerifyState(step);
218
+
219
+ // Capture actions
220
+ case 'screenshot':
221
+ return this.executeScreenshot(step);
222
+ case 'scroll':
223
+ return this.executeScroll(step);
224
+ case 'scroll_into_view':
225
+ return this.executeScrollIntoView(step);
226
+
227
+ // AI-powered actions
228
+ case 'identify_element':
229
+ return this.executeIdentifyElement(step);
230
+ case 'ai_verify':
231
+ return this.executeAIVerify(step);
232
+
233
+ // Custom actions
234
+ case 'custom':
235
+ return this.executeCustom(step);
236
+
237
+ default:
238
+ return {
239
+ status: 'failed',
240
+ method: step.action,
241
+ durationMs: 0,
242
+ error: `Unknown action type: ${step.action}`,
243
+ };
244
+ }
245
+ }
246
+
247
+ // ============ Navigation Actions ============
248
+
249
+ private async executeNavigate(step: SpecStep): Promise<StepExecution> {
250
+ // Support both url (canonical) and to (legacy)
251
+ const targetUrl = step.url ?? step.to;
252
+ if (!targetUrl) {
253
+ return {
254
+ status: 'failed',
255
+ method: 'navigate',
256
+ durationMs: 0,
257
+ error: 'Navigate action requires "url" field',
258
+ };
259
+ }
260
+
261
+ const url = targetUrl.startsWith('http') ? targetUrl : `${this.baseUrl}${targetUrl}`;
262
+ await this.browser!.navigate(url);
263
+
264
+ return {
265
+ status: 'completed',
266
+ method: 'navigate',
267
+ durationMs: 0,
268
+ };
269
+ }
270
+
271
+ private async executeBack(): Promise<StepExecution> {
272
+ if (this.browser!.back) {
273
+ await this.browser!.back();
274
+ }
275
+ return {
276
+ status: 'completed',
277
+ method: 'back',
278
+ durationMs: 0,
279
+ };
280
+ }
281
+
282
+ private async executeForward(): Promise<StepExecution> {
283
+ if (this.browser!.forward) {
284
+ await this.browser!.forward();
285
+ }
286
+ return {
287
+ status: 'completed',
288
+ method: 'forward',
289
+ durationMs: 0,
290
+ };
291
+ }
292
+
293
+ private async executeRefresh(): Promise<StepExecution> {
294
+ if (this.browser!.refresh) {
295
+ await this.browser!.refresh();
296
+ }
297
+ return {
298
+ status: 'completed',
299
+ method: 'refresh',
300
+ durationMs: 0,
301
+ };
302
+ }
303
+
304
+ // ============ Interaction Actions ============
305
+
306
+ private async executeClick(step: SpecStep): Promise<StepExecution> {
307
+ const target = await this.resolveTarget(step);
308
+
309
+ if (!target) {
310
+ return {
311
+ status: 'failed',
312
+ method: 'click',
313
+ durationMs: 0,
314
+ error: 'Click action requires ref, selector, or query',
315
+ };
316
+ }
317
+
318
+ if (this.browser!.click) {
319
+ await this.browser!.click(target.ref);
320
+ }
321
+
322
+ return {
323
+ status: 'completed',
324
+ method: 'click',
325
+ elementRef: target.ref,
326
+ selectorUsed: target.selector,
327
+ durationMs: 0,
328
+ };
329
+ }
330
+
331
+ private async executeFill(step: SpecStep): Promise<StepExecution> {
332
+ const target = await this.resolveTarget(step);
333
+
334
+ if (!target) {
335
+ return {
336
+ status: 'failed',
337
+ method: 'fill',
338
+ durationMs: 0,
339
+ error: 'Fill action requires ref, selector, or query',
340
+ };
341
+ }
342
+
343
+ const value = step.value ?? '';
344
+ if (this.browser!.fill) {
345
+ await this.browser!.fill(target.ref, value);
346
+ }
347
+
348
+ return {
349
+ status: 'completed',
350
+ method: 'fill',
351
+ elementRef: target.ref,
352
+ selectorUsed: target.selector,
353
+ durationMs: 0,
354
+ };
355
+ }
356
+
357
+ private async executeType(step: SpecStep): Promise<StepExecution> {
358
+ const target = await this.resolveTarget(step);
359
+
360
+ if (!target) {
361
+ return {
362
+ status: 'failed',
363
+ method: 'type',
364
+ durationMs: 0,
365
+ error: 'Type action requires ref, selector, or query',
366
+ };
367
+ }
368
+
369
+ const text = step.value ?? '';
370
+ if (this.browser!.type) {
371
+ await this.browser!.type(target.ref, text);
372
+ }
373
+
374
+ return {
375
+ status: 'completed',
376
+ method: 'type',
377
+ elementRef: target.ref,
378
+ selectorUsed: target.selector,
379
+ durationMs: 0,
380
+ };
381
+ }
382
+
383
+ private async executeSelect(step: SpecStep): Promise<StepExecution> {
384
+ const target = await this.resolveTarget(step);
385
+
386
+ if (!target) {
387
+ return {
388
+ status: 'failed',
389
+ method: 'select',
390
+ durationMs: 0,
391
+ error: 'Select action requires ref, selector, or query',
392
+ };
393
+ }
394
+
395
+ const option = step.option ?? '';
396
+ if (this.browser!.select) {
397
+ await this.browser!.select(target.ref, option);
398
+ }
399
+
400
+ return {
401
+ status: 'completed',
402
+ method: 'select',
403
+ elementRef: target.ref,
404
+ selectorUsed: target.selector,
405
+ durationMs: 0,
406
+ };
407
+ }
408
+
409
+ private async executeCheck(step: SpecStep): Promise<StepExecution> {
410
+ const target = await this.resolveTarget(step);
411
+
412
+ if (!target) {
413
+ return {
414
+ status: 'failed',
415
+ method: 'check',
416
+ durationMs: 0,
417
+ error: 'Check action requires ref, selector, or query',
418
+ };
419
+ }
420
+
421
+ const checked = step.checked !== false; // Default to true
422
+ if (this.browser!.check) {
423
+ await this.browser!.check(target.ref, checked);
424
+ }
425
+
426
+ return {
427
+ status: 'completed',
428
+ method: 'check',
429
+ elementRef: target.ref,
430
+ selectorUsed: target.selector,
431
+ durationMs: 0,
432
+ };
433
+ }
434
+
435
+ private async executePress(step: SpecStep): Promise<StepExecution> {
436
+ const key = step.value ?? '';
437
+ if (!key) {
438
+ return {
439
+ status: 'failed',
440
+ method: 'press',
441
+ durationMs: 0,
442
+ error: 'Press action requires "value" field with key name',
443
+ };
444
+ }
445
+
446
+ if (this.browser!.press) {
447
+ await this.browser!.press(key);
448
+ }
449
+
450
+ return {
451
+ status: 'completed',
452
+ method: 'press',
453
+ durationMs: 0,
454
+ };
455
+ }
456
+
457
+ // ============ Wait Actions ============
458
+
459
+ private async executeWait(step: SpecStep): Promise<StepExecution> {
460
+ const waitFor = step.for;
461
+ const timeout = parseTimeout(step.timeout, this.defaultTimeout);
462
+
463
+ switch (waitFor) {
464
+ case 'element':
465
+ if (step.selector && this.browser!.waitForSelector) {
466
+ await this.browser!.waitForSelector(step.selector, timeout);
467
+ }
468
+ break;
469
+
470
+ case 'url':
471
+ if (step.contains && this.browser!.waitForURL) {
472
+ await this.browser!.waitForURL(step.contains, timeout);
473
+ }
474
+ break;
475
+
476
+ case 'text':
477
+ if (step.text && this.browser!.waitForText) {
478
+ await this.browser!.waitForText(step.text, timeout);
479
+ }
480
+ break;
481
+
482
+ case 'time':
483
+ if (step.duration && this.browser!.waitForTimeout) {
484
+ await this.browser!.waitForTimeout(parseTimeout(step.duration, 1000));
485
+ }
486
+ break;
487
+
488
+ case 'load_state':
489
+ if (this.browser!.waitForLoadState) {
490
+ await this.browser!.waitForLoadState('load');
491
+ }
492
+ break;
493
+
494
+ default:
495
+ // No specific wait type - just mark as completed
496
+ break;
497
+ }
498
+
499
+ return {
500
+ status: 'completed',
501
+ method: 'wait',
502
+ durationMs: 0,
503
+ };
504
+ }
505
+
506
+ // ============ Verification Actions ============
507
+
508
+ private async executeVerifyState(step: SpecStep): Promise<StepExecution> {
509
+ const checks = (step.checks || []) as VerifyCheck[];
510
+ const snapshot = await this.browser!.getSnapshot({ interactive: true });
511
+ const currentURL = this.browser!.getCurrentURL?.() ?? '';
512
+
513
+ for (const check of checks) {
514
+ // Check element_visible
515
+ if (check.element_visible) {
516
+ const found = this.findElementInSnapshot(check.element_visible, snapshot);
517
+ if (!found) {
518
+ return {
519
+ status: 'failed',
520
+ method: 'verify_state',
521
+ durationMs: 0,
522
+ error: `Element not visible: ${check.element_visible}`,
523
+ };
524
+ }
525
+ }
526
+
527
+ // Check element_not_visible
528
+ if (check.element_not_visible) {
529
+ const found = this.findElementInSnapshot(check.element_not_visible, snapshot);
530
+ if (found) {
531
+ return {
532
+ status: 'failed',
533
+ method: 'verify_state',
534
+ durationMs: 0,
535
+ error: `Element should not be visible: ${check.element_not_visible}`,
536
+ };
537
+ }
538
+ }
539
+
540
+ // Check url_contains
541
+ if (check.url_contains) {
542
+ if (!currentURL.includes(check.url_contains)) {
543
+ return {
544
+ status: 'failed',
545
+ method: 'verify_state',
546
+ durationMs: 0,
547
+ error: `URL does not contain: ${check.url_contains}`,
548
+ };
549
+ }
550
+ }
551
+
552
+ // Check text_contains
553
+ if (check.text_contains) {
554
+ if (!snapshot.tree.includes(check.text_contains)) {
555
+ return {
556
+ status: 'failed',
557
+ method: 'verify_state',
558
+ durationMs: 0,
559
+ error: `Text not found: ${check.text_contains}`,
560
+ };
561
+ }
562
+ }
563
+
564
+ // Check text_not_contains
565
+ if (check.text_not_contains) {
566
+ if (snapshot.tree.includes(check.text_not_contains)) {
567
+ return {
568
+ status: 'failed',
569
+ method: 'verify_state',
570
+ durationMs: 0,
571
+ error: `Text should not be present: ${check.text_not_contains}`,
572
+ };
573
+ }
574
+ }
575
+ }
576
+
577
+ return {
578
+ status: 'completed',
579
+ method: 'verify_state',
580
+ durationMs: 0,
581
+ };
582
+ }
583
+
584
+ // ============ Capture Actions ============
585
+
586
+ private async executeScreenshot(step: SpecStep): Promise<StepExecution> {
587
+ // Screenshot is captured in the main execute method
588
+ return {
589
+ status: 'completed',
590
+ method: 'screenshot',
591
+ durationMs: 0,
592
+ };
593
+ }
594
+
595
+ private async executeScroll(step: SpecStep): Promise<StepExecution> {
596
+ const x = (step.scrollX as number) ?? 0;
597
+ const y = (step.scrollY as number) ?? 0;
598
+
599
+ if (this.browser!.scroll) {
600
+ await this.browser!.scroll(x, y);
601
+ }
602
+
603
+ return {
604
+ status: 'completed',
605
+ method: 'scroll',
606
+ durationMs: 0,
607
+ };
608
+ }
609
+
610
+ private async executeScrollIntoView(step: SpecStep): Promise<StepExecution> {
611
+ const target = await this.resolveTarget(step);
612
+
613
+ if (!target) {
614
+ return {
615
+ status: 'failed',
616
+ method: 'scroll_into_view',
617
+ durationMs: 0,
618
+ error: 'scroll_into_view action requires ref, selector, or query',
619
+ };
620
+ }
621
+
622
+ if (this.browser!.scrollIntoView) {
623
+ await this.browser!.scrollIntoView(target.ref);
624
+ }
625
+
626
+ return {
627
+ status: 'completed',
628
+ method: 'scroll_into_view',
629
+ elementRef: target.ref,
630
+ durationMs: 0,
631
+ };
632
+ }
633
+
634
+ // ============ AI-Powered Actions ============
635
+
636
+ private async executeIdentifyElement(step: SpecStep): Promise<StepExecution> {
637
+ if (!step.query) {
638
+ return {
639
+ status: 'failed',
640
+ method: 'identify_element',
641
+ durationMs: 0,
642
+ error: 'identify_element action requires "query" field',
643
+ };
644
+ }
645
+
646
+ const snapshot = await this.browser!.getSnapshot({ interactive: true });
647
+ const result = await this.adapter!.findElement(step.query, snapshot);
648
+
649
+ if (result.ref === 'NOT_FOUND') {
650
+ return {
651
+ status: 'failed',
652
+ method: 'identify_element',
653
+ durationMs: 0,
654
+ error: `Element not found for query: ${step.query}`,
655
+ };
656
+ }
657
+
658
+ return {
659
+ status: 'completed',
660
+ method: 'identify_element',
661
+ elementRef: result.ref,
662
+ durationMs: 0,
663
+ };
664
+ }
665
+
666
+ private async executeAIVerify(step: SpecStep): Promise<StepExecution> {
667
+ // AI verification is more complex - for now just mark as completed
668
+ // Full implementation would use adapter to verify visual/semantic state
669
+ return {
670
+ status: 'completed',
671
+ method: 'ai_verify',
672
+ durationMs: 0,
673
+ };
674
+ }
675
+
676
+ // ============ Custom Actions ============
677
+
678
+ private async executeCustom(step: SpecStep): Promise<StepExecution> {
679
+ const handlerName = step.name;
680
+ if (!handlerName || !this.customActionHandlers[handlerName]) {
681
+ return {
682
+ status: 'failed',
683
+ method: 'custom',
684
+ durationMs: 0,
685
+ error: `Custom action handler not found: ${handlerName}`,
686
+ };
687
+ }
688
+
689
+ return this.customActionHandlers[handlerName](step, this.browser!);
690
+ }
691
+
692
+ // ============ Helper Methods ============
693
+
694
+ /**
695
+ * Resolve target to element ref
696
+ */
697
+ private async resolveTarget(step: SpecStep): Promise<ResolvedTarget | null> {
698
+ // If step has a direct ref, use it
699
+ if (step.ref) {
700
+ return { ref: step.ref };
701
+ }
702
+
703
+ // If step has a selector, use it as ref
704
+ if (step.selector) {
705
+ return { ref: step.selector, selector: step.selector };
706
+ }
707
+
708
+ // Use AI adapter to find element from query
709
+ if (step.query) {
710
+ const snapshot = await this.browser!.getSnapshot({ interactive: true });
711
+ const result = await this.adapter!.findElement(step.query, snapshot);
712
+
713
+ if (result.ref === 'NOT_FOUND') {
714
+ throw new Error(`Element not found for query: ${step.query}`);
715
+ }
716
+
717
+ return { ref: result.ref };
718
+ }
719
+
720
+ return null;
721
+ }
722
+
723
+ /**
724
+ * Find element in snapshot by selector/query
725
+ */
726
+ private findElementInSnapshot(
727
+ selector: string,
728
+ snapshot: EnhancedSnapshot
729
+ ): boolean {
730
+ // Check if element exists in tree or refs
731
+ if (snapshot.tree.includes(selector)) {
732
+ return true;
733
+ }
734
+
735
+ // Check refs for matching tag or attribute
736
+ for (const ref of Object.values(snapshot.refs)) {
737
+ const element = ref as Record<string, unknown>;
738
+ if (
739
+ element.tag === selector ||
740
+ element.text?.toString().includes(selector) ||
741
+ element.className?.toString().includes(selector) ||
742
+ element.id === selector
743
+ ) {
744
+ return true;
745
+ }
746
+ }
747
+
748
+ return false;
749
+ }
750
+
751
+ /**
752
+ * Capture a screenshot
753
+ */
754
+ private async captureScreenshot(name: string): Promise<string> {
755
+ const { promises: fs } = await import('fs');
756
+ const path = await import('path');
757
+
758
+ const filepath = path.join(this.screenshotDir, `${name}.png`);
759
+
760
+ // Ensure directory exists
761
+ await fs.mkdir(path.dirname(filepath), { recursive: true });
762
+
763
+ // Capture and write screenshot
764
+ const buffer = await this.browser!.screenshot();
765
+ await fs.writeFile(filepath, buffer);
766
+
767
+ return filepath;
768
+ }
769
+
770
+ /**
771
+ * Get the default timeout value
772
+ */
773
+ getDefaultTimeout(): number {
774
+ return this.defaultTimeout;
775
+ }
776
+
777
+ /**
778
+ * Get the screenshot directory
779
+ */
780
+ getScreenshotDir(): string {
781
+ return this.screenshotDir;
782
+ }
783
+ }