@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,696 @@
|
|
|
1
|
+
// @browserflow-ai/exploration - Step executor tests
|
|
2
|
+
import { describe, it, expect, beforeEach, spyOn } from 'bun:test';
|
|
3
|
+
import { StepExecutor } from './step-executor';
|
|
4
|
+
import type { BrowserSession } from './explorer';
|
|
5
|
+
import type { AIAdapter, SpecStep } from './adapters/types';
|
|
6
|
+
|
|
7
|
+
// Mock browser session
|
|
8
|
+
function createMockBrowserSession(options: Partial<BrowserSession> = {}): BrowserSession {
|
|
9
|
+
return {
|
|
10
|
+
isLaunched: () => true,
|
|
11
|
+
launch: async () => {},
|
|
12
|
+
navigate: async () => {},
|
|
13
|
+
screenshot: async () => Buffer.from('fake-image'),
|
|
14
|
+
close: async () => {},
|
|
15
|
+
getSnapshot: async () => ({
|
|
16
|
+
tree: '<button ref="e1">Submit</button><input ref="e2" type="text">',
|
|
17
|
+
refs: {
|
|
18
|
+
e1: { tag: 'button', text: 'Submit' },
|
|
19
|
+
e2: { tag: 'input', type: 'text' },
|
|
20
|
+
},
|
|
21
|
+
}),
|
|
22
|
+
click: async () => {},
|
|
23
|
+
fill: async () => {},
|
|
24
|
+
type: async () => {},
|
|
25
|
+
select: async () => {},
|
|
26
|
+
check: async () => {},
|
|
27
|
+
press: async () => {},
|
|
28
|
+
back: async () => {},
|
|
29
|
+
forward: async () => {},
|
|
30
|
+
refresh: async () => {},
|
|
31
|
+
scrollIntoView: async () => {},
|
|
32
|
+
scroll: async () => {},
|
|
33
|
+
waitForSelector: async () => {},
|
|
34
|
+
waitForURL: async () => {},
|
|
35
|
+
waitForText: async () => {},
|
|
36
|
+
waitForLoadState: async () => {},
|
|
37
|
+
waitForTimeout: async () => {},
|
|
38
|
+
...options,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Mock AI adapter
|
|
43
|
+
function createMockAdapter(options: Partial<AIAdapter> = {}): AIAdapter {
|
|
44
|
+
return {
|
|
45
|
+
name: 'mock',
|
|
46
|
+
explore: async () => ({
|
|
47
|
+
spec: 'test',
|
|
48
|
+
specPath: 'test.yaml',
|
|
49
|
+
explorationId: 'exp-test',
|
|
50
|
+
timestamp: new Date().toISOString(),
|
|
51
|
+
durationMs: 100,
|
|
52
|
+
browser: 'chromium',
|
|
53
|
+
viewport: { width: 1280, height: 720 },
|
|
54
|
+
baseUrl: 'http://localhost:3000',
|
|
55
|
+
steps: [],
|
|
56
|
+
outcomeChecks: [],
|
|
57
|
+
overallStatus: 'completed',
|
|
58
|
+
errors: [],
|
|
59
|
+
}),
|
|
60
|
+
findElement: async () => ({
|
|
61
|
+
ref: 'e1',
|
|
62
|
+
reasoning: 'Found element',
|
|
63
|
+
confidence: 0.95,
|
|
64
|
+
}),
|
|
65
|
+
...options,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('StepExecutor', () => {
|
|
70
|
+
let executor: StepExecutor;
|
|
71
|
+
let mockBrowser: BrowserSession;
|
|
72
|
+
let mockAdapter: AIAdapter;
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
mockBrowser = createMockBrowserSession();
|
|
76
|
+
mockAdapter = createMockAdapter();
|
|
77
|
+
executor = new StepExecutor({
|
|
78
|
+
browser: mockBrowser,
|
|
79
|
+
adapter: mockAdapter,
|
|
80
|
+
baseUrl: 'http://localhost:3000',
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('constructor', () => {
|
|
85
|
+
it('should initialize with default config', () => {
|
|
86
|
+
const exec = new StepExecutor({
|
|
87
|
+
browser: mockBrowser,
|
|
88
|
+
adapter: mockAdapter,
|
|
89
|
+
baseUrl: 'http://localhost:3000',
|
|
90
|
+
});
|
|
91
|
+
expect(exec.getDefaultTimeout()).toBe(30000);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should use custom timeout when provided', () => {
|
|
95
|
+
const exec = new StepExecutor({
|
|
96
|
+
browser: mockBrowser,
|
|
97
|
+
adapter: mockAdapter,
|
|
98
|
+
baseUrl: 'http://localhost:3000',
|
|
99
|
+
defaultTimeout: 5000,
|
|
100
|
+
});
|
|
101
|
+
expect(exec.getDefaultTimeout()).toBe(5000);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should use custom screenshot directory when provided', () => {
|
|
105
|
+
const exec = new StepExecutor({
|
|
106
|
+
browser: mockBrowser,
|
|
107
|
+
adapter: mockAdapter,
|
|
108
|
+
baseUrl: 'http://localhost:3000',
|
|
109
|
+
screenshotDir: './custom-screenshots',
|
|
110
|
+
});
|
|
111
|
+
expect(exec.getScreenshotDir()).toBe('./custom-screenshots');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('navigate action', () => {
|
|
116
|
+
it('should execute navigate to absolute URL with canonical "url" field', async () => {
|
|
117
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
118
|
+
const step: SpecStep = { action: 'navigate', url: 'https://example.com' };
|
|
119
|
+
|
|
120
|
+
const result = await executor.execute(step, 0);
|
|
121
|
+
|
|
122
|
+
expect(navigateSpy).toHaveBeenCalledWith('https://example.com');
|
|
123
|
+
expect(result.execution.status).toBe('completed');
|
|
124
|
+
expect(result.execution.method).toBe('navigate');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should execute navigate with relative URL using baseUrl (url field)', async () => {
|
|
128
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
129
|
+
const step: SpecStep = { action: 'navigate', url: '/login' };
|
|
130
|
+
|
|
131
|
+
await executor.execute(step, 0);
|
|
132
|
+
|
|
133
|
+
expect(navigateSpy).toHaveBeenCalledWith('http://localhost:3000/login');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should execute navigate to absolute URL (legacy "to" field for backward compat)', async () => {
|
|
137
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
138
|
+
const step: SpecStep = { action: 'navigate', to: 'https://example.com' };
|
|
139
|
+
|
|
140
|
+
const result = await executor.execute(step, 0);
|
|
141
|
+
|
|
142
|
+
expect(navigateSpy).toHaveBeenCalledWith('https://example.com');
|
|
143
|
+
expect(result.execution.status).toBe('completed');
|
|
144
|
+
expect(result.execution.method).toBe('navigate');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should execute navigate with relative URL using baseUrl (legacy "to" field)', async () => {
|
|
148
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
149
|
+
const step: SpecStep = { action: 'navigate', to: '/login' };
|
|
150
|
+
|
|
151
|
+
await executor.execute(step, 0);
|
|
152
|
+
|
|
153
|
+
expect(navigateSpy).toHaveBeenCalledWith('http://localhost:3000/login');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should prefer "url" field when both "url" and "to" are present', async () => {
|
|
157
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
158
|
+
const step: SpecStep = { action: 'navigate', url: 'https://canonical.com', to: 'https://legacy.com' };
|
|
159
|
+
|
|
160
|
+
const result = await executor.execute(step, 0);
|
|
161
|
+
|
|
162
|
+
expect(navigateSpy).toHaveBeenCalledWith('https://canonical.com');
|
|
163
|
+
expect(result.execution.status).toBe('completed');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should fail when navigate missing both "url" and "to" fields', async () => {
|
|
167
|
+
const step: SpecStep = { action: 'navigate' };
|
|
168
|
+
|
|
169
|
+
const result = await executor.execute(step, 0);
|
|
170
|
+
|
|
171
|
+
expect(result.execution.status).toBe('failed');
|
|
172
|
+
expect(result.execution.error).toContain('url');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('back/forward/refresh actions', () => {
|
|
177
|
+
it('should execute back action', async () => {
|
|
178
|
+
const backSpy = spyOn(mockBrowser, 'back' as keyof BrowserSession);
|
|
179
|
+
const step: SpecStep = { action: 'back' };
|
|
180
|
+
|
|
181
|
+
const result = await executor.execute(step, 0);
|
|
182
|
+
|
|
183
|
+
expect(backSpy).toHaveBeenCalled();
|
|
184
|
+
expect(result.execution.status).toBe('completed');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should execute forward action', async () => {
|
|
188
|
+
const forwardSpy = spyOn(mockBrowser, 'forward' as keyof BrowserSession);
|
|
189
|
+
const step: SpecStep = { action: 'forward' };
|
|
190
|
+
|
|
191
|
+
const result = await executor.execute(step, 0);
|
|
192
|
+
|
|
193
|
+
expect(forwardSpy).toHaveBeenCalled();
|
|
194
|
+
expect(result.execution.status).toBe('completed');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should execute refresh action', async () => {
|
|
198
|
+
const refreshSpy = spyOn(mockBrowser, 'refresh' as keyof BrowserSession);
|
|
199
|
+
const step: SpecStep = { action: 'refresh' };
|
|
200
|
+
|
|
201
|
+
const result = await executor.execute(step, 0);
|
|
202
|
+
|
|
203
|
+
expect(refreshSpy).toHaveBeenCalled();
|
|
204
|
+
expect(result.execution.status).toBe('completed');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should execute reload action (alias for refresh)', async () => {
|
|
208
|
+
const refreshSpy = spyOn(mockBrowser, 'refresh' as keyof BrowserSession);
|
|
209
|
+
const step: SpecStep = { action: 'reload' };
|
|
210
|
+
|
|
211
|
+
const result = await executor.execute(step, 0);
|
|
212
|
+
|
|
213
|
+
expect(refreshSpy).toHaveBeenCalled();
|
|
214
|
+
expect(result.execution.status).toBe('completed');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('click action', () => {
|
|
219
|
+
it('should click element by ref', async () => {
|
|
220
|
+
const clickSpy = spyOn(mockBrowser, 'click' as keyof BrowserSession);
|
|
221
|
+
const step: SpecStep = { action: 'click', ref: 'e1' };
|
|
222
|
+
|
|
223
|
+
const result = await executor.execute(step, 0);
|
|
224
|
+
|
|
225
|
+
expect(clickSpy).toHaveBeenCalledWith('e1');
|
|
226
|
+
expect(result.execution.status).toBe('completed');
|
|
227
|
+
expect(result.execution.elementRef).toBe('e1');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('should click element by selector', async () => {
|
|
231
|
+
const clickSpy = spyOn(mockBrowser, 'click' as keyof BrowserSession);
|
|
232
|
+
const step: SpecStep = { action: 'click', selector: 'button.submit' };
|
|
233
|
+
|
|
234
|
+
const result = await executor.execute(step, 0);
|
|
235
|
+
|
|
236
|
+
expect(clickSpy).toHaveBeenCalledWith('button.submit');
|
|
237
|
+
expect(result.execution.selectorUsed).toBe('button.submit');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should click element by query using AI adapter', async () => {
|
|
241
|
+
const findSpy = spyOn(mockAdapter, 'findElement');
|
|
242
|
+
const clickSpy = spyOn(mockBrowser, 'click' as keyof BrowserSession);
|
|
243
|
+
const step: SpecStep = { action: 'click', query: 'submit button' };
|
|
244
|
+
|
|
245
|
+
const result = await executor.execute(step, 0);
|
|
246
|
+
|
|
247
|
+
expect(findSpy).toHaveBeenCalled();
|
|
248
|
+
expect(clickSpy).toHaveBeenCalledWith('e1');
|
|
249
|
+
expect(result.execution.elementRef).toBe('e1');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should click element with custom timeout (string format)', async () => {
|
|
253
|
+
const clickSpy = spyOn(mockBrowser, 'click' as keyof BrowserSession);
|
|
254
|
+
const step: SpecStep = { action: 'click', ref: 'e1', timeout: '10s' };
|
|
255
|
+
|
|
256
|
+
const result = await executor.execute(step, 0);
|
|
257
|
+
|
|
258
|
+
expect(clickSpy).toHaveBeenCalledWith('e1');
|
|
259
|
+
expect(result.execution.status).toBe('completed');
|
|
260
|
+
expect(result.execution.elementRef).toBe('e1');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should fail when element not found', async () => {
|
|
264
|
+
mockAdapter.findElement = async () => ({
|
|
265
|
+
ref: 'NOT_FOUND',
|
|
266
|
+
reasoning: 'Could not find element',
|
|
267
|
+
confidence: 0,
|
|
268
|
+
});
|
|
269
|
+
const step: SpecStep = { action: 'click', query: 'nonexistent button' };
|
|
270
|
+
|
|
271
|
+
const result = await executor.execute(step, 0);
|
|
272
|
+
|
|
273
|
+
expect(result.execution.status).toBe('failed');
|
|
274
|
+
expect(result.execution.error).toContain('not found');
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should fail when no target specified', async () => {
|
|
278
|
+
const step: SpecStep = { action: 'click' };
|
|
279
|
+
|
|
280
|
+
const result = await executor.execute(step, 0);
|
|
281
|
+
|
|
282
|
+
expect(result.execution.status).toBe('failed');
|
|
283
|
+
expect(result.execution.error).toContain('ref, selector, or query');
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe('fill action', () => {
|
|
288
|
+
it('should fill element with value', async () => {
|
|
289
|
+
const fillSpy = spyOn(mockBrowser, 'fill' as keyof BrowserSession);
|
|
290
|
+
const step: SpecStep = { action: 'fill', ref: 'e2', value: 'test@example.com' };
|
|
291
|
+
|
|
292
|
+
const result = await executor.execute(step, 0);
|
|
293
|
+
|
|
294
|
+
expect(fillSpy).toHaveBeenCalledWith('e2', 'test@example.com');
|
|
295
|
+
expect(result.execution.status).toBe('completed');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should fill element found by query', async () => {
|
|
299
|
+
const fillSpy = spyOn(mockBrowser, 'fill' as keyof BrowserSession);
|
|
300
|
+
const step: SpecStep = { action: 'fill', query: 'email input', value: 'user@test.com' };
|
|
301
|
+
|
|
302
|
+
const result = await executor.execute(step, 0);
|
|
303
|
+
|
|
304
|
+
expect(fillSpy).toHaveBeenCalledWith('e1', 'user@test.com');
|
|
305
|
+
expect(result.execution.status).toBe('completed');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('should handle empty value', async () => {
|
|
309
|
+
const fillSpy = spyOn(mockBrowser, 'fill' as keyof BrowserSession);
|
|
310
|
+
const step: SpecStep = { action: 'fill', ref: 'e2', value: '' };
|
|
311
|
+
|
|
312
|
+
const result = await executor.execute(step, 0);
|
|
313
|
+
|
|
314
|
+
expect(fillSpy).toHaveBeenCalledWith('e2', '');
|
|
315
|
+
expect(result.execution.status).toBe('completed');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('type action', () => {
|
|
320
|
+
it('should type text character by character', async () => {
|
|
321
|
+
const typeSpy = spyOn(mockBrowser, 'type' as keyof BrowserSession);
|
|
322
|
+
const step: SpecStep = { action: 'type', ref: 'e2', value: 'hello' };
|
|
323
|
+
|
|
324
|
+
const result = await executor.execute(step, 0);
|
|
325
|
+
|
|
326
|
+
expect(typeSpy).toHaveBeenCalledWith('e2', 'hello');
|
|
327
|
+
expect(result.execution.status).toBe('completed');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('select action', () => {
|
|
332
|
+
it('should select option by value', async () => {
|
|
333
|
+
const selectSpy = spyOn(mockBrowser, 'select' as keyof BrowserSession);
|
|
334
|
+
const step: SpecStep = { action: 'select', ref: 'e1', option: 'option1' };
|
|
335
|
+
|
|
336
|
+
const result = await executor.execute(step, 0);
|
|
337
|
+
|
|
338
|
+
expect(selectSpy).toHaveBeenCalledWith('e1', 'option1');
|
|
339
|
+
expect(result.execution.status).toBe('completed');
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('check action', () => {
|
|
344
|
+
it('should check a checkbox', async () => {
|
|
345
|
+
const checkSpy = spyOn(mockBrowser, 'check' as keyof BrowserSession);
|
|
346
|
+
const step: SpecStep = { action: 'check', ref: 'e1', checked: true };
|
|
347
|
+
|
|
348
|
+
const result = await executor.execute(step, 0);
|
|
349
|
+
|
|
350
|
+
expect(checkSpy).toHaveBeenCalledWith('e1', true);
|
|
351
|
+
expect(result.execution.status).toBe('completed');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should uncheck a checkbox when checked=false', async () => {
|
|
355
|
+
const checkSpy = spyOn(mockBrowser, 'check' as keyof BrowserSession);
|
|
356
|
+
const step: SpecStep = { action: 'check', ref: 'e1', checked: false };
|
|
357
|
+
|
|
358
|
+
await executor.execute(step, 0);
|
|
359
|
+
|
|
360
|
+
expect(checkSpy).toHaveBeenCalledWith('e1', false);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('should default to checked=true when not specified', async () => {
|
|
364
|
+
const checkSpy = spyOn(mockBrowser, 'check' as keyof BrowserSession);
|
|
365
|
+
const step: SpecStep = { action: 'check', ref: 'e1' };
|
|
366
|
+
|
|
367
|
+
await executor.execute(step, 0);
|
|
368
|
+
|
|
369
|
+
expect(checkSpy).toHaveBeenCalledWith('e1', true);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
describe('press action', () => {
|
|
374
|
+
it('should press a key', async () => {
|
|
375
|
+
const pressSpy = spyOn(mockBrowser, 'press' as keyof BrowserSession);
|
|
376
|
+
const step: SpecStep = { action: 'press', value: 'Enter' };
|
|
377
|
+
|
|
378
|
+
const result = await executor.execute(step, 0);
|
|
379
|
+
|
|
380
|
+
expect(pressSpy).toHaveBeenCalledWith('Enter');
|
|
381
|
+
expect(result.execution.status).toBe('completed');
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe('wait action', () => {
|
|
386
|
+
it('should wait for element', async () => {
|
|
387
|
+
const waitSpy = spyOn(mockBrowser, 'waitForSelector' as keyof BrowserSession);
|
|
388
|
+
const step: SpecStep = { action: 'wait', for: 'element', selector: '.loaded' };
|
|
389
|
+
|
|
390
|
+
const result = await executor.execute(step, 0);
|
|
391
|
+
|
|
392
|
+
expect(waitSpy).toHaveBeenCalledWith('.loaded', expect.any(Number));
|
|
393
|
+
expect(result.execution.status).toBe('completed');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should wait for URL to contain string', async () => {
|
|
397
|
+
const waitSpy = spyOn(mockBrowser, 'waitForURL' as keyof BrowserSession);
|
|
398
|
+
const step: SpecStep = { action: 'wait', for: 'url', contains: '/dashboard' };
|
|
399
|
+
|
|
400
|
+
const result = await executor.execute(step, 0);
|
|
401
|
+
|
|
402
|
+
expect(waitSpy).toHaveBeenCalledWith('/dashboard', expect.any(Number));
|
|
403
|
+
expect(result.execution.status).toBe('completed');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should wait for text to appear', async () => {
|
|
407
|
+
const waitSpy = spyOn(mockBrowser, 'waitForText' as keyof BrowserSession);
|
|
408
|
+
const step: SpecStep = { action: 'wait', for: 'text', text: 'Welcome' };
|
|
409
|
+
|
|
410
|
+
const result = await executor.execute(step, 0);
|
|
411
|
+
|
|
412
|
+
expect(waitSpy).toHaveBeenCalledWith('Welcome', expect.any(Number));
|
|
413
|
+
expect(result.execution.status).toBe('completed');
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should wait for time duration', async () => {
|
|
417
|
+
const waitSpy = spyOn(mockBrowser, 'waitForTimeout' as keyof BrowserSession);
|
|
418
|
+
const step: SpecStep = { action: 'wait', for: 'time', duration: 1000 };
|
|
419
|
+
|
|
420
|
+
const result = await executor.execute(step, 0);
|
|
421
|
+
|
|
422
|
+
expect(waitSpy).toHaveBeenCalledWith(1000);
|
|
423
|
+
expect(result.execution.status).toBe('completed');
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('should wait for time duration with string format (500ms)', async () => {
|
|
427
|
+
const waitSpy = spyOn(mockBrowser, 'waitForTimeout' as keyof BrowserSession);
|
|
428
|
+
const step: SpecStep = { action: 'wait', for: 'time', duration: '500ms' };
|
|
429
|
+
|
|
430
|
+
const result = await executor.execute(step, 0);
|
|
431
|
+
|
|
432
|
+
expect(waitSpy).toHaveBeenCalledWith(500);
|
|
433
|
+
expect(result.execution.status).toBe('completed');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should wait for time duration with string format (2s)', async () => {
|
|
437
|
+
const waitSpy = spyOn(mockBrowser, 'waitForTimeout' as keyof BrowserSession);
|
|
438
|
+
const step: SpecStep = { action: 'wait', for: 'time', duration: '2s' };
|
|
439
|
+
|
|
440
|
+
const result = await executor.execute(step, 0);
|
|
441
|
+
|
|
442
|
+
expect(waitSpy).toHaveBeenCalledWith(2000);
|
|
443
|
+
expect(result.execution.status).toBe('completed');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should use custom timeout when specified', async () => {
|
|
447
|
+
const waitSpy = spyOn(mockBrowser, 'waitForSelector' as keyof BrowserSession);
|
|
448
|
+
const step: SpecStep = { action: 'wait', for: 'element', selector: '.slow', timeout: 60000 };
|
|
449
|
+
|
|
450
|
+
await executor.execute(step, 0);
|
|
451
|
+
|
|
452
|
+
expect(waitSpy).toHaveBeenCalledWith('.slow', 60000);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('should use custom timeout with string format (5s)', async () => {
|
|
456
|
+
const waitSpy = spyOn(mockBrowser, 'waitForSelector' as keyof BrowserSession);
|
|
457
|
+
const step: SpecStep = { action: 'wait', for: 'element', selector: '.slow', timeout: '5s' };
|
|
458
|
+
|
|
459
|
+
await executor.execute(step, 0);
|
|
460
|
+
|
|
461
|
+
expect(waitSpy).toHaveBeenCalledWith('.slow', 5000);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('should use custom timeout with string format (3000ms)', async () => {
|
|
465
|
+
const waitSpy = spyOn(mockBrowser, 'waitForSelector' as keyof BrowserSession);
|
|
466
|
+
const step: SpecStep = { action: 'wait', for: 'element', selector: '.slow', timeout: '3000ms' };
|
|
467
|
+
|
|
468
|
+
await executor.execute(step, 0);
|
|
469
|
+
|
|
470
|
+
expect(waitSpy).toHaveBeenCalledWith('.slow', 3000);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
describe('verify_state action', () => {
|
|
475
|
+
it('should verify element is visible', async () => {
|
|
476
|
+
const step: SpecStep = {
|
|
477
|
+
action: 'verify_state',
|
|
478
|
+
checks: [{ element_visible: 'button' }],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
// Mock snapshot to include the element
|
|
482
|
+
mockBrowser.getSnapshot = async () => ({
|
|
483
|
+
tree: '<button ref="e1">Submit</button>',
|
|
484
|
+
refs: { e1: { tag: 'button', text: 'Submit', visible: true } },
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const result = await executor.execute(step, 0);
|
|
488
|
+
|
|
489
|
+
expect(result.execution.status).toBe('completed');
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('should fail when element not visible', async () => {
|
|
493
|
+
const step: SpecStep = {
|
|
494
|
+
action: 'verify_state',
|
|
495
|
+
checks: [{ element_visible: 'button.hidden' }],
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// Mock snapshot without the element
|
|
499
|
+
mockBrowser.getSnapshot = async () => ({
|
|
500
|
+
tree: '',
|
|
501
|
+
refs: {},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const result = await executor.execute(step, 0);
|
|
505
|
+
|
|
506
|
+
expect(result.execution.status).toBe('failed');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('should verify URL contains string', async () => {
|
|
510
|
+
const step: SpecStep = {
|
|
511
|
+
action: 'verify_state',
|
|
512
|
+
checks: [{ url_contains: '/login' }],
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Mock browser to return current URL
|
|
516
|
+
(mockBrowser as any).getCurrentURL = () => 'http://localhost:3000/login';
|
|
517
|
+
|
|
518
|
+
const result = await executor.execute(step, 0);
|
|
519
|
+
|
|
520
|
+
expect(result.execution.status).toBe('completed');
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it('should verify text content', async () => {
|
|
524
|
+
const step: SpecStep = {
|
|
525
|
+
action: 'verify_state',
|
|
526
|
+
checks: [{ text_contains: 'Welcome' }],
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
mockBrowser.getSnapshot = async () => ({
|
|
530
|
+
tree: '<h1>Welcome to our site</h1>',
|
|
531
|
+
refs: {},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
const result = await executor.execute(step, 0);
|
|
535
|
+
|
|
536
|
+
expect(result.execution.status).toBe('completed');
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should handle multiple checks', async () => {
|
|
540
|
+
const step: SpecStep = {
|
|
541
|
+
action: 'verify_state',
|
|
542
|
+
checks: [
|
|
543
|
+
{ element_visible: 'button' },
|
|
544
|
+
{ text_contains: 'Submit' },
|
|
545
|
+
],
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
mockBrowser.getSnapshot = async () => ({
|
|
549
|
+
tree: '<button ref="e1">Submit</button>',
|
|
550
|
+
refs: { e1: { tag: 'button', text: 'Submit' } },
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const result = await executor.execute(step, 0);
|
|
554
|
+
|
|
555
|
+
expect(result.execution.status).toBe('completed');
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe('screenshot action', () => {
|
|
560
|
+
it('should capture screenshot with name', async () => {
|
|
561
|
+
const screenshotSpy = spyOn(mockBrowser, 'screenshot');
|
|
562
|
+
const step: SpecStep = { action: 'screenshot', name: 'login-page' };
|
|
563
|
+
|
|
564
|
+
const result = await executor.execute(step, 0);
|
|
565
|
+
|
|
566
|
+
expect(screenshotSpy).toHaveBeenCalled();
|
|
567
|
+
expect(result.execution.status).toBe('completed');
|
|
568
|
+
expect(result.screenshots.after).toContain('login-page');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('should use step index for screenshot name when not provided', async () => {
|
|
572
|
+
const step: SpecStep = { action: 'screenshot' };
|
|
573
|
+
|
|
574
|
+
const result = await executor.execute(step, 5);
|
|
575
|
+
|
|
576
|
+
expect(result.screenshots.after).toContain('05');
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
describe('scroll actions', () => {
|
|
581
|
+
it('should scroll element into view', async () => {
|
|
582
|
+
const scrollSpy = spyOn(mockBrowser, 'scrollIntoView' as keyof BrowserSession);
|
|
583
|
+
const step: SpecStep = { action: 'scroll_into_view', ref: 'e1' };
|
|
584
|
+
|
|
585
|
+
const result = await executor.execute(step, 0);
|
|
586
|
+
|
|
587
|
+
expect(scrollSpy).toHaveBeenCalledWith('e1');
|
|
588
|
+
expect(result.execution.status).toBe('completed');
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('should scroll by offset', async () => {
|
|
592
|
+
const scrollSpy = spyOn(mockBrowser, 'scroll' as keyof BrowserSession);
|
|
593
|
+
const step: SpecStep = { action: 'scroll', scrollX: 0, scrollY: 500 };
|
|
594
|
+
|
|
595
|
+
const result = await executor.execute(step, 0);
|
|
596
|
+
|
|
597
|
+
expect(scrollSpy).toHaveBeenCalledWith(0, 500);
|
|
598
|
+
expect(result.execution.status).toBe('completed');
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
describe('identify_element action', () => {
|
|
603
|
+
it('should find element using AI and return ref', async () => {
|
|
604
|
+
const findSpy = spyOn(mockAdapter, 'findElement');
|
|
605
|
+
const step: SpecStep = { action: 'identify_element', query: 'login button' };
|
|
606
|
+
|
|
607
|
+
const result = await executor.execute(step, 0);
|
|
608
|
+
|
|
609
|
+
expect(findSpy).toHaveBeenCalled();
|
|
610
|
+
expect(result.execution.status).toBe('completed');
|
|
611
|
+
expect(result.execution.elementRef).toBe('e1');
|
|
612
|
+
});
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
describe('screenshot capture around actions', () => {
|
|
616
|
+
it('should capture before screenshot when configured', async () => {
|
|
617
|
+
const exec = new StepExecutor({
|
|
618
|
+
browser: mockBrowser,
|
|
619
|
+
adapter: mockAdapter,
|
|
620
|
+
baseUrl: 'http://localhost:3000',
|
|
621
|
+
captureBeforeScreenshots: true,
|
|
622
|
+
});
|
|
623
|
+
const screenshotSpy = spyOn(mockBrowser, 'screenshot');
|
|
624
|
+
const step: SpecStep = { action: 'click', ref: 'e1' };
|
|
625
|
+
|
|
626
|
+
const result = await exec.execute(step, 0);
|
|
627
|
+
|
|
628
|
+
expect(result.screenshots.before).toBeDefined();
|
|
629
|
+
// Screenshot should be called at least once for before
|
|
630
|
+
expect(screenshotSpy).toHaveBeenCalled();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('should capture after screenshot by default', async () => {
|
|
634
|
+
const screenshotSpy = spyOn(mockBrowser, 'screenshot');
|
|
635
|
+
const step: SpecStep = { action: 'click', ref: 'e1' };
|
|
636
|
+
|
|
637
|
+
const result = await executor.execute(step, 0);
|
|
638
|
+
|
|
639
|
+
expect(result.screenshots.after).toBeDefined();
|
|
640
|
+
});
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
describe('error handling', () => {
|
|
644
|
+
it('should return failed status on browser error', async () => {
|
|
645
|
+
mockBrowser.click = async () => {
|
|
646
|
+
throw new Error('Element not interactable');
|
|
647
|
+
};
|
|
648
|
+
const step: SpecStep = { action: 'click', ref: 'e1' };
|
|
649
|
+
|
|
650
|
+
const result = await executor.execute(step, 0);
|
|
651
|
+
|
|
652
|
+
expect(result.execution.status).toBe('failed');
|
|
653
|
+
expect(result.execution.error).toContain('Element not interactable');
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('should include duration even on failure', async () => {
|
|
657
|
+
mockBrowser.click = async () => {
|
|
658
|
+
throw new Error('Failed');
|
|
659
|
+
};
|
|
660
|
+
const step: SpecStep = { action: 'click', ref: 'e1' };
|
|
661
|
+
|
|
662
|
+
const result = await executor.execute(step, 0);
|
|
663
|
+
|
|
664
|
+
expect(result.execution.durationMs).toBeGreaterThanOrEqual(0);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should return failed for unknown action type', async () => {
|
|
668
|
+
const step: SpecStep = { action: 'unknown_action' as any };
|
|
669
|
+
|
|
670
|
+
const result = await executor.execute(step, 0);
|
|
671
|
+
|
|
672
|
+
expect(result.execution.status).toBe('failed');
|
|
673
|
+
expect(result.execution.error).toContain('Unknown action');
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
describe('custom action', () => {
|
|
678
|
+
it('should handle custom action with handler', async () => {
|
|
679
|
+
const exec = new StepExecutor({
|
|
680
|
+
browser: mockBrowser,
|
|
681
|
+
adapter: mockAdapter,
|
|
682
|
+
baseUrl: 'http://localhost:3000',
|
|
683
|
+
customActionHandlers: {
|
|
684
|
+
'my_custom_action': async (step, browser) => {
|
|
685
|
+
return { status: 'completed', method: 'custom', durationMs: 0 };
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
const step: SpecStep = { action: 'custom', name: 'my_custom_action' };
|
|
690
|
+
|
|
691
|
+
const result = await exec.execute(step, 0);
|
|
692
|
+
|
|
693
|
+
expect(result.execution.status).toBe('completed');
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
});
|