@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.
- package/dist/adapters/claude-cli.d.ts +57 -0
- package/dist/adapters/claude-cli.d.ts.map +1 -0
- package/dist/adapters/claude-cli.js +195 -0
- package/dist/adapters/claude-cli.js.map +1 -0
- package/dist/adapters/claude.d.ts +54 -0
- package/dist/adapters/claude.d.ts.map +1 -0
- package/dist/adapters/claude.js +160 -0
- package/dist/adapters/claude.js.map +1 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +4 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/types.d.ts +196 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +3 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/agent-browser-session.d.ts +62 -0
- package/dist/agent-browser-session.d.ts.map +1 -0
- package/dist/agent-browser-session.js +272 -0
- package/dist/agent-browser-session.js.map +1 -0
- package/dist/evidence.d.ts +111 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +144 -0
- package/dist/evidence.js.map +1 -0
- package/dist/explorer.d.ts +180 -0
- package/dist/explorer.d.ts.map +1 -0
- package/dist/explorer.js +393 -0
- package/dist/explorer.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/locator-candidates.d.ts +127 -0
- package/dist/locator-candidates.d.ts.map +1 -0
- package/dist/locator-candidates.js +358 -0
- package/dist/locator-candidates.js.map +1 -0
- package/dist/step-executor.d.ts +99 -0
- package/dist/step-executor.d.ts.map +1 -0
- package/dist/step-executor.js +646 -0
- package/dist/step-executor.js.map +1 -0
- package/package.json +34 -0
- package/src/adapters/claude-cli.test.ts +134 -0
- package/src/adapters/claude-cli.ts +240 -0
- package/src/adapters/claude.test.ts +195 -0
- package/src/adapters/claude.ts +190 -0
- package/src/adapters/index.ts +21 -0
- package/src/adapters/types.ts +207 -0
- package/src/agent-browser-session.test.ts +369 -0
- package/src/agent-browser-session.ts +349 -0
- package/src/evidence.test.ts +239 -0
- package/src/evidence.ts +203 -0
- package/src/explorer.test.ts +321 -0
- package/src/explorer.ts +565 -0
- package/src/index.ts +51 -0
- package/src/locator-candidates.test.ts +602 -0
- package/src/locator-candidates.ts +441 -0
- package/src/step-executor.test.ts +696 -0
- 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
|
+
}
|