@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,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
+ });