@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,321 @@
|
|
|
1
|
+
// @browserflow-ai/exploration - Explorer tests
|
|
2
|
+
import { describe, it, expect, beforeEach, spyOn, afterEach } from 'bun:test';
|
|
3
|
+
import { Explorer } from './explorer';
|
|
4
|
+
import type { BrowserSession } from './explorer';
|
|
5
|
+
import type { AIAdapter, Spec } from './adapters/types';
|
|
6
|
+
import { promises as fs } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
|
|
10
|
+
// Mock browser session
|
|
11
|
+
function createMockBrowserSession(options: Partial<BrowserSession> = {}): BrowserSession {
|
|
12
|
+
return {
|
|
13
|
+
isLaunched: () => true,
|
|
14
|
+
launch: async () => {},
|
|
15
|
+
navigate: async () => {},
|
|
16
|
+
screenshot: async () => Buffer.from('fake-image'),
|
|
17
|
+
close: async () => {},
|
|
18
|
+
getSnapshot: async () => ({
|
|
19
|
+
tree: '<button ref="e1">Submit</button>',
|
|
20
|
+
refs: { e1: { tag: 'button', text: 'Submit' } },
|
|
21
|
+
}),
|
|
22
|
+
...options,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Mock AI adapter
|
|
27
|
+
function createMockAdapter(options: Partial<AIAdapter> = {}): AIAdapter {
|
|
28
|
+
return {
|
|
29
|
+
name: 'mock',
|
|
30
|
+
explore: async () => ({
|
|
31
|
+
spec: 'test',
|
|
32
|
+
specPath: 'test.yaml',
|
|
33
|
+
explorationId: 'exp-test',
|
|
34
|
+
timestamp: new Date().toISOString(),
|
|
35
|
+
durationMs: 100,
|
|
36
|
+
browser: 'chromium',
|
|
37
|
+
viewport: { width: 1280, height: 720 },
|
|
38
|
+
baseUrl: 'http://localhost:3000',
|
|
39
|
+
steps: [],
|
|
40
|
+
outcomeChecks: [],
|
|
41
|
+
overallStatus: 'completed',
|
|
42
|
+
errors: [],
|
|
43
|
+
}),
|
|
44
|
+
findElement: async () => ({
|
|
45
|
+
ref: 'e1',
|
|
46
|
+
reasoning: 'Found submit button',
|
|
47
|
+
confidence: 0.95,
|
|
48
|
+
}),
|
|
49
|
+
...options,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Sample spec for testing
|
|
54
|
+
const sampleSpec: Spec = {
|
|
55
|
+
name: 'login-flow',
|
|
56
|
+
description: 'Test user login',
|
|
57
|
+
steps: [
|
|
58
|
+
{ action: 'navigate', to: '/login' },
|
|
59
|
+
{ action: 'fill', query: 'email input field', value: 'test@example.com' },
|
|
60
|
+
{ action: 'fill', query: 'password input field', value: 'password123' },
|
|
61
|
+
{ action: 'click', query: 'submit button' },
|
|
62
|
+
{ action: 'wait', for: 'url', contains: '/dashboard' },
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
describe('Explorer', () => {
|
|
67
|
+
let explorer: Explorer;
|
|
68
|
+
let mockBrowser: BrowserSession;
|
|
69
|
+
let mockAdapter: AIAdapter;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
mockBrowser = createMockBrowserSession();
|
|
73
|
+
mockAdapter = createMockAdapter();
|
|
74
|
+
explorer = new Explorer({
|
|
75
|
+
adapter: mockAdapter,
|
|
76
|
+
browser: mockBrowser,
|
|
77
|
+
outputDir: './test-output',
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('constructor', () => {
|
|
82
|
+
it('should initialize with required config', () => {
|
|
83
|
+
expect(explorer.getAdapter()).toBe(mockAdapter);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should use default outputDir when not provided', () => {
|
|
87
|
+
const exp = new Explorer({ adapter: mockAdapter });
|
|
88
|
+
expect(exp).toBeDefined();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('runExploration', () => {
|
|
93
|
+
it('should launch browser at start', async () => {
|
|
94
|
+
const launchSpy = spyOn(mockBrowser, 'launch');
|
|
95
|
+
|
|
96
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
97
|
+
|
|
98
|
+
expect(launchSpy).toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('should navigate to starting page', async () => {
|
|
102
|
+
const navigateSpy = spyOn(mockBrowser, 'navigate');
|
|
103
|
+
|
|
104
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
105
|
+
|
|
106
|
+
expect(navigateSpy).toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should execute all steps in spec', async () => {
|
|
110
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
111
|
+
|
|
112
|
+
expect(result.steps.length).toBe(sampleSpec.steps.length);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should return completed status when all steps succeed', async () => {
|
|
116
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
117
|
+
|
|
118
|
+
expect(result.overallStatus).toBe('completed');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('should return failed status when any step fails', async () => {
|
|
122
|
+
// Make findElement fail for one step
|
|
123
|
+
mockAdapter.findElement = async (query: string) => {
|
|
124
|
+
if (query.includes('submit')) {
|
|
125
|
+
throw new Error('Element not found');
|
|
126
|
+
}
|
|
127
|
+
return { ref: 'e1', reasoning: 'Found', confidence: 0.9 };
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
131
|
+
|
|
132
|
+
expect(result.overallStatus).toBe('failed');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('should continue exploration after step failure', async () => {
|
|
136
|
+
let stepCount = 0;
|
|
137
|
+
mockAdapter.findElement = async () => {
|
|
138
|
+
stepCount++;
|
|
139
|
+
if (stepCount === 2) {
|
|
140
|
+
throw new Error('Element not found');
|
|
141
|
+
}
|
|
142
|
+
return { ref: 'e1', reasoning: 'Found', confidence: 0.9 };
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
146
|
+
|
|
147
|
+
// Should have attempted all steps
|
|
148
|
+
expect(result.steps.length).toBe(sampleSpec.steps.length);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should capture timing information', async () => {
|
|
152
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
153
|
+
|
|
154
|
+
// Duration should be >= 0 (might be 0 for very fast tests)
|
|
155
|
+
expect(result.durationMs).toBeGreaterThanOrEqual(0);
|
|
156
|
+
expect(typeof result.durationMs).toBe('number');
|
|
157
|
+
for (const step of result.steps) {
|
|
158
|
+
expect(step.execution.durationMs).toBeGreaterThanOrEqual(0);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should close browser on completion', async () => {
|
|
163
|
+
const closeSpy = spyOn(mockBrowser, 'close');
|
|
164
|
+
|
|
165
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
166
|
+
|
|
167
|
+
expect(closeSpy).toHaveBeenCalled();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should close browser even on error', async () => {
|
|
171
|
+
const closeSpy = spyOn(mockBrowser, 'close');
|
|
172
|
+
mockBrowser.navigate = async () => {
|
|
173
|
+
throw new Error('Navigation failed');
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
178
|
+
} catch {
|
|
179
|
+
// Expected to throw
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
expect(closeSpy).toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should generate unique exploration ID', async () => {
|
|
186
|
+
const result1 = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
187
|
+
const result2 = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
188
|
+
|
|
189
|
+
expect(result1.explorationId).not.toBe(result2.explorationId);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should include spec metadata in output', async () => {
|
|
193
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
194
|
+
|
|
195
|
+
expect(result.spec).toBe(sampleSpec.name);
|
|
196
|
+
expect(result.baseUrl).toBe('http://localhost:3000');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should use preconditions viewport when specified', async () => {
|
|
200
|
+
const specWithViewport: Spec = {
|
|
201
|
+
...sampleSpec,
|
|
202
|
+
preconditions: {
|
|
203
|
+
viewport: { width: 800, height: 600 },
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const launchSpy = spyOn(mockBrowser, 'launch');
|
|
208
|
+
await explorer.runExploration(specWithViewport, 'http://localhost:3000');
|
|
209
|
+
|
|
210
|
+
expect(launchSpy).toHaveBeenCalledWith(
|
|
211
|
+
expect.objectContaining({
|
|
212
|
+
viewport: { width: 800, height: 600 },
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('step execution', () => {
|
|
219
|
+
it('should capture screenshot before each action', async () => {
|
|
220
|
+
const screenshotSpy = spyOn(mockBrowser, 'screenshot');
|
|
221
|
+
|
|
222
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
223
|
+
|
|
224
|
+
// At least 2 screenshots per step (before + after)
|
|
225
|
+
expect(screenshotSpy.mock.calls.length).toBeGreaterThanOrEqual(sampleSpec.steps.length * 2);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('should use AI adapter to find elements for query-based steps', async () => {
|
|
229
|
+
const findSpy = spyOn(mockAdapter, 'findElement');
|
|
230
|
+
|
|
231
|
+
await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
232
|
+
|
|
233
|
+
// Should be called for steps with query field
|
|
234
|
+
expect(findSpy).toHaveBeenCalled();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should include locator info in step result', async () => {
|
|
238
|
+
const result = await explorer.runExploration(sampleSpec, 'http://localhost:3000');
|
|
239
|
+
|
|
240
|
+
const clickStep = result.steps.find((s) => s.specAction.action === 'click');
|
|
241
|
+
expect(clickStep?.execution.elementRef).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe('screenshot file persistence', () => {
|
|
246
|
+
let testDir: string;
|
|
247
|
+
|
|
248
|
+
beforeEach(async () => {
|
|
249
|
+
// Create temporary test directory
|
|
250
|
+
testDir = join(tmpdir(), `bf-test-${Date.now()}`);
|
|
251
|
+
await fs.mkdir(testDir, { recursive: true });
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
afterEach(async () => {
|
|
255
|
+
// Clean up test directory
|
|
256
|
+
try {
|
|
257
|
+
await fs.rm(testDir, { recursive: true, force: true });
|
|
258
|
+
} catch {
|
|
259
|
+
// Ignore cleanup errors
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('should write screenshot files to disk for each step', async () => {
|
|
264
|
+
const explorerWithTempDir = new Explorer({
|
|
265
|
+
adapter: mockAdapter,
|
|
266
|
+
browser: mockBrowser,
|
|
267
|
+
outputDir: testDir,
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const result = await explorerWithTempDir.runExploration(sampleSpec, 'http://localhost:3000');
|
|
271
|
+
|
|
272
|
+
// Check that screenshot directory was created (inside exploration-specific directory)
|
|
273
|
+
const explorationDir = join(testDir, result.explorationId);
|
|
274
|
+
const screenshotDir = join(explorationDir, 'screenshots');
|
|
275
|
+
const dirExists = await fs.stat(screenshotDir).then(() => true).catch(() => false);
|
|
276
|
+
expect(dirExists).toBe(true);
|
|
277
|
+
|
|
278
|
+
// Check that screenshot files exist for each step
|
|
279
|
+
for (let i = 0; i < result.steps.length; i++) {
|
|
280
|
+
const step = result.steps[i];
|
|
281
|
+
|
|
282
|
+
// Verify screenshot paths are recorded
|
|
283
|
+
expect(step.screenshots.before).toBeDefined();
|
|
284
|
+
expect(step.screenshots.after).toBeDefined();
|
|
285
|
+
|
|
286
|
+
// Verify actual files exist (paths are relative to exploration directory)
|
|
287
|
+
const beforePath = join(explorationDir, step.screenshots.before);
|
|
288
|
+
const afterPath = join(explorationDir, step.screenshots.after);
|
|
289
|
+
|
|
290
|
+
const beforeExists = await fs.stat(beforePath).then(() => true).catch(() => false);
|
|
291
|
+
const afterExists = await fs.stat(afterPath).then(() => true).catch(() => false);
|
|
292
|
+
|
|
293
|
+
expect(beforeExists).toBe(true);
|
|
294
|
+
expect(afterExists).toBe(true);
|
|
295
|
+
|
|
296
|
+
// Verify files are not empty
|
|
297
|
+
const beforeSize = (await fs.stat(beforePath)).size;
|
|
298
|
+
const afterSize = (await fs.stat(afterPath)).size;
|
|
299
|
+
|
|
300
|
+
expect(beforeSize).toBeGreaterThan(0);
|
|
301
|
+
expect(afterSize).toBeGreaterThan(0);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should store relative paths in exploration output', async () => {
|
|
306
|
+
const explorerWithTempDir = new Explorer({
|
|
307
|
+
adapter: mockAdapter,
|
|
308
|
+
browser: mockBrowser,
|
|
309
|
+
outputDir: testDir,
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const result = await explorerWithTempDir.runExploration(sampleSpec, 'http://localhost:3000');
|
|
313
|
+
|
|
314
|
+
// Paths should be relative (start with "screenshots/")
|
|
315
|
+
for (const step of result.steps) {
|
|
316
|
+
expect(step.screenshots.before).toMatch(/^screenshots\//);
|
|
317
|
+
expect(step.screenshots.after).toMatch(/^screenshots\//);
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
});
|