@browserflow-ai/core 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 (57) hide show
  1. package/dist/config-schema.d.ts +354 -0
  2. package/dist/config-schema.d.ts.map +1 -0
  3. package/dist/config-schema.js +83 -0
  4. package/dist/config-schema.js.map +1 -0
  5. package/dist/config.d.ts +107 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +5 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/duration.d.ts +39 -0
  10. package/dist/duration.d.ts.map +1 -0
  11. package/dist/duration.js +111 -0
  12. package/dist/duration.js.map +1 -0
  13. package/dist/index.d.ts +12 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +20 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/locator-object.d.ts +556 -0
  18. package/dist/locator-object.d.ts.map +1 -0
  19. package/dist/locator-object.js +114 -0
  20. package/dist/locator-object.js.map +1 -0
  21. package/dist/lockfile.d.ts +1501 -0
  22. package/dist/lockfile.d.ts.map +1 -0
  23. package/dist/lockfile.js +86 -0
  24. package/dist/lockfile.js.map +1 -0
  25. package/dist/run-store.d.ts +81 -0
  26. package/dist/run-store.d.ts.map +1 -0
  27. package/dist/run-store.js +181 -0
  28. package/dist/run-store.js.map +1 -0
  29. package/dist/spec-loader.d.ts +17 -0
  30. package/dist/spec-loader.d.ts.map +1 -0
  31. package/dist/spec-loader.js +37 -0
  32. package/dist/spec-loader.js.map +1 -0
  33. package/dist/spec-schema.d.ts +1411 -0
  34. package/dist/spec-schema.d.ts.map +1 -0
  35. package/dist/spec-schema.js +239 -0
  36. package/dist/spec-schema.js.map +1 -0
  37. package/package.json +45 -0
  38. package/schemas/browserflow.schema.json +220 -0
  39. package/schemas/lockfile.schema.json +568 -0
  40. package/schemas/spec-v2.schema.json +413 -0
  41. package/src/config-schema.test.ts +190 -0
  42. package/src/config-schema.ts +111 -0
  43. package/src/config.ts +112 -0
  44. package/src/duration.test.ts +175 -0
  45. package/src/duration.ts +128 -0
  46. package/src/index.ts +136 -0
  47. package/src/json-schemas.test.ts +374 -0
  48. package/src/locator-object.test.ts +323 -0
  49. package/src/locator-object.ts +250 -0
  50. package/src/lockfile.test.ts +345 -0
  51. package/src/lockfile.ts +209 -0
  52. package/src/run-store.test.ts +232 -0
  53. package/src/run-store.ts +240 -0
  54. package/src/spec-loader.test.ts +181 -0
  55. package/src/spec-loader.ts +47 -0
  56. package/src/spec-schema.test.ts +360 -0
  57. package/src/spec-schema.ts +347 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Tests for spec loading and normalization
3
+ * @see bf-7ne - preconditions.page string coercion
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { normalizePreconditions, loadSpec } from './spec-loader.js';
8
+
9
+ describe('normalizePreconditions', () => {
10
+ test('coerces string page to object format', () => {
11
+ const input = {
12
+ page: '/',
13
+ viewport: { width: 1920, height: 1080 },
14
+ };
15
+
16
+ const result = normalizePreconditions(input);
17
+
18
+ expect(result.page).toEqual({ url: '/' });
19
+ expect(result.viewport).toEqual({ width: 1920, height: 1080 });
20
+ });
21
+
22
+ test('coerces string page with full URL', () => {
23
+ const input = {
24
+ page: 'https://example.com/login',
25
+ };
26
+
27
+ const result = normalizePreconditions(input);
28
+
29
+ expect(result.page).toEqual({ url: 'https://example.com/login' });
30
+ });
31
+
32
+ test('passes through object page format unchanged', () => {
33
+ const input = {
34
+ page: { url: '/dashboard' },
35
+ viewport: { width: 1280, height: 720 },
36
+ };
37
+
38
+ const result = normalizePreconditions(input);
39
+
40
+ expect(result.page).toEqual({ url: '/dashboard' });
41
+ expect(result.viewport).toEqual({ width: 1280, height: 720 });
42
+ });
43
+
44
+ test('handles missing page field', () => {
45
+ const input = {
46
+ viewport: { width: 1920, height: 1080 },
47
+ };
48
+
49
+ const result = normalizePreconditions(input);
50
+
51
+ expect(result.page).toBeUndefined();
52
+ expect(result.viewport).toEqual({ width: 1920, height: 1080 });
53
+ });
54
+
55
+ test('handles empty preconditions', () => {
56
+ const result = normalizePreconditions({});
57
+
58
+ expect(result).toEqual({});
59
+ });
60
+
61
+ test('handles null/undefined preconditions', () => {
62
+ expect(normalizePreconditions(null)).toEqual({});
63
+ expect(normalizePreconditions(undefined)).toEqual({});
64
+ });
65
+
66
+ test('preserves auth field', () => {
67
+ const input = {
68
+ page: '/',
69
+ auth: { user: 'testuser', state: 'logged-in' },
70
+ };
71
+
72
+ const result = normalizePreconditions(input);
73
+
74
+ expect(result.page).toEqual({ url: '/' });
75
+ expect(result.auth).toEqual({ user: 'testuser', state: 'logged-in' });
76
+ });
77
+
78
+ test('preserves mocks field', () => {
79
+ const input = {
80
+ page: '/',
81
+ mocks: [{ url: '/api/user', response: { id: 123 } }],
82
+ };
83
+
84
+ const result = normalizePreconditions(input);
85
+
86
+ expect(result.page).toEqual({ url: '/' });
87
+ expect(result.mocks).toEqual([{ url: '/api/user', response: { id: 123 } }]);
88
+ });
89
+ });
90
+
91
+ describe('loadSpec', () => {
92
+ test('loads and validates spec with string preconditions.page', () => {
93
+ const rawSpec = {
94
+ version: 2,
95
+ name: 'login-test',
96
+ steps: [
97
+ {
98
+ id: 'step-1',
99
+ action: 'navigate',
100
+ url: '/login',
101
+ },
102
+ ],
103
+ preconditions: {
104
+ page: '/',
105
+ },
106
+ };
107
+
108
+ const result = loadSpec(rawSpec);
109
+
110
+ expect(result.success).toBe(true);
111
+ if (result.success) {
112
+ expect(result.data.preconditions?.page).toEqual({ url: '/' });
113
+ }
114
+ });
115
+
116
+ test('loads and validates spec with object preconditions.page', () => {
117
+ const rawSpec = {
118
+ version: 2,
119
+ name: 'login-test',
120
+ steps: [
121
+ {
122
+ id: 'step-1',
123
+ action: 'navigate',
124
+ url: '/login',
125
+ },
126
+ ],
127
+ preconditions: {
128
+ page: { url: '/dashboard' },
129
+ },
130
+ };
131
+
132
+ const result = loadSpec(rawSpec);
133
+
134
+ expect(result.success).toBe(true);
135
+ if (result.success) {
136
+ expect(result.data.preconditions?.page).toEqual({ url: '/dashboard' });
137
+ }
138
+ });
139
+
140
+ test('validates spec after normalization', () => {
141
+ const rawSpec = {
142
+ version: 2,
143
+ name: 'invalid-name!', // Invalid: not kebab-case
144
+ steps: [
145
+ {
146
+ id: 'step-1',
147
+ action: 'navigate',
148
+ url: '/login',
149
+ },
150
+ ],
151
+ preconditions: {
152
+ page: '/',
153
+ },
154
+ };
155
+
156
+ const result = loadSpec(rawSpec);
157
+
158
+ expect(result.success).toBe(false);
159
+ });
160
+
161
+ test('handles spec with no preconditions', () => {
162
+ const rawSpec = {
163
+ version: 2,
164
+ name: 'simple-test',
165
+ steps: [
166
+ {
167
+ id: 'step-1',
168
+ action: 'navigate',
169
+ url: '/login',
170
+ },
171
+ ],
172
+ };
173
+
174
+ const result = loadSpec(rawSpec);
175
+
176
+ expect(result.success).toBe(true);
177
+ if (result.success) {
178
+ expect(result.data.preconditions).toBeUndefined();
179
+ }
180
+ });
181
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Spec loading and normalization utilities
3
+ * @see bf-7ne - preconditions.page string coercion
4
+ */
5
+
6
+ import type { Preconditions, BrowserFlowSpec } from './spec-schema.js';
7
+ import { specSchema } from './spec-schema.js';
8
+ import type { z } from 'zod';
9
+
10
+ /**
11
+ * Normalize preconditions to handle backward-compatible formats
12
+ * Coerces string page values to object format
13
+ */
14
+ export function normalizePreconditions(preconditions: unknown): Preconditions {
15
+ if (!preconditions || typeof preconditions !== 'object') {
16
+ return {};
17
+ }
18
+
19
+ const pre = { ...preconditions } as Record<string, unknown>;
20
+
21
+ // Coerce string page to object format
22
+ if (typeof pre.page === 'string') {
23
+ pre.page = { url: pre.page };
24
+ }
25
+
26
+ return pre as Preconditions;
27
+ }
28
+
29
+ /**
30
+ * Load and validate a spec with normalization
31
+ * Returns a Zod SafeParseReturnType for detailed error handling
32
+ */
33
+ export function loadSpec(rawSpec: unknown): z.SafeParseReturnType<unknown, BrowserFlowSpec> {
34
+ if (!rawSpec || typeof rawSpec !== 'object') {
35
+ return specSchema.safeParse(rawSpec);
36
+ }
37
+
38
+ const spec = { ...rawSpec } as Record<string, unknown>;
39
+
40
+ // Normalize preconditions if present
41
+ if (spec.preconditions) {
42
+ spec.preconditions = normalizePreconditions(spec.preconditions);
43
+ }
44
+
45
+ // Validate with Zod schema
46
+ return specSchema.safeParse(spec);
47
+ }
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Tests for spec v2 Zod schema
3
+ * @see bf-dgs
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import {
8
+ specSchema,
9
+ targetSchema,
10
+ durationSchema,
11
+ stepSchema,
12
+ preconditionsSchema,
13
+ } from './spec-schema.js';
14
+
15
+ describe('durationSchema', () => {
16
+ test('validates valid duration strings', () => {
17
+ expect(durationSchema.safeParse('3s').success).toBe(true);
18
+ expect(durationSchema.safeParse('2m').success).toBe(true);
19
+ expect(durationSchema.safeParse('500ms').success).toBe(true);
20
+ expect(durationSchema.safeParse('1m30s').success).toBe(true);
21
+ expect(durationSchema.safeParse('1h').success).toBe(true);
22
+ });
23
+
24
+ test('rejects invalid duration strings', () => {
25
+ expect(durationSchema.safeParse('3 seconds').success).toBe(false);
26
+ expect(durationSchema.safeParse('abc').success).toBe(false);
27
+ expect(durationSchema.safeParse('').success).toBe(false);
28
+ });
29
+
30
+ test('provides actionable error message', () => {
31
+ const result = durationSchema.safeParse('invalid');
32
+ expect(result.success).toBe(false);
33
+ if (!result.success) {
34
+ expect(result.error.issues[0].message).toContain('duration');
35
+ }
36
+ });
37
+ });
38
+
39
+ describe('targetSchema', () => {
40
+ test('validates targets with testid', () => {
41
+ expect(targetSchema.safeParse({ testid: 'submit-button' }).success).toBe(true);
42
+ });
43
+
44
+ test('validates targets with role', () => {
45
+ expect(targetSchema.safeParse({ role: 'button', name: 'Submit' }).success).toBe(true);
46
+ });
47
+
48
+ test('validates targets with label', () => {
49
+ expect(targetSchema.safeParse({ label: 'Email' }).success).toBe(true);
50
+ });
51
+
52
+ test('validates targets with placeholder', () => {
53
+ expect(targetSchema.safeParse({ placeholder: 'Enter email' }).success).toBe(true);
54
+ });
55
+
56
+ test('validates targets with text', () => {
57
+ expect(targetSchema.safeParse({ text: 'Click here' }).success).toBe(true);
58
+ });
59
+
60
+ test('validates targets with css', () => {
61
+ expect(targetSchema.safeParse({ css: '.submit-btn' }).success).toBe(true);
62
+ });
63
+
64
+ test('validates targets with query (natural language)', () => {
65
+ expect(targetSchema.safeParse({ query: 'the blue submit button' }).success).toBe(true);
66
+ });
67
+
68
+ test('validates nested within targeting', () => {
69
+ const result = targetSchema.safeParse({
70
+ role: 'button',
71
+ name: 'Delete',
72
+ within: { testid: 'user-list' },
73
+ });
74
+ expect(result.success).toBe(true);
75
+ });
76
+
77
+ test('validates nth targeting', () => {
78
+ expect(targetSchema.safeParse({ role: 'listitem', nth: 2 }).success).toBe(true);
79
+ });
80
+
81
+ test('rejects targets with no locator strategy', () => {
82
+ const result = targetSchema.safeParse({});
83
+ expect(result.success).toBe(false);
84
+ });
85
+
86
+ test('rejects targets with only nth (no strategy)', () => {
87
+ const result = targetSchema.safeParse({ nth: 1 });
88
+ expect(result.success).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('stepSchema', () => {
93
+ test('validates step with required id', () => {
94
+ const result = stepSchema.safeParse({
95
+ id: 'step-1',
96
+ action: 'click',
97
+ target: { testid: 'submit-btn' },
98
+ });
99
+ expect(result.success).toBe(true);
100
+ });
101
+
102
+ test('rejects step without id', () => {
103
+ const result = stepSchema.safeParse({
104
+ action: 'click',
105
+ target: { testid: 'submit-btn' },
106
+ });
107
+ expect(result.success).toBe(false);
108
+ if (!result.success) {
109
+ // The error should be about the missing 'id' field (path: ['id'])
110
+ const idError = result.error.issues.find((i) => i.path.includes('id'));
111
+ expect(idError).toBeDefined();
112
+ }
113
+ });
114
+
115
+ test('rejects step with empty id', () => {
116
+ const result = stepSchema.safeParse({
117
+ id: '',
118
+ action: 'click',
119
+ });
120
+ expect(result.success).toBe(false);
121
+ });
122
+
123
+ test('validates navigate action', () => {
124
+ const result = stepSchema.safeParse({
125
+ id: 'nav-1',
126
+ action: 'navigate',
127
+ url: 'https://example.com',
128
+ });
129
+ expect(result.success).toBe(true);
130
+ });
131
+
132
+ test('validates fill action', () => {
133
+ const result = stepSchema.safeParse({
134
+ id: 'fill-1',
135
+ action: 'fill',
136
+ target: { label: 'Email' },
137
+ value: 'test@example.com',
138
+ });
139
+ expect(result.success).toBe(true);
140
+ });
141
+
142
+ test('validates wait action', () => {
143
+ const result = stepSchema.safeParse({
144
+ id: 'wait-1',
145
+ action: 'wait',
146
+ duration: '2s',
147
+ });
148
+ expect(result.success).toBe(true);
149
+ });
150
+
151
+ test('validates expect action', () => {
152
+ const result = stepSchema.safeParse({
153
+ id: 'expect-1',
154
+ action: 'expect',
155
+ target: { text: 'Success' },
156
+ state: 'visible',
157
+ });
158
+ expect(result.success).toBe(true);
159
+ });
160
+
161
+ test('validates screenshot action', () => {
162
+ const result = stepSchema.safeParse({
163
+ id: 'screenshot-1',
164
+ action: 'screenshot',
165
+ name: 'checkout-complete',
166
+ });
167
+ expect(result.success).toBe(true);
168
+ });
169
+ });
170
+
171
+ describe('preconditionsSchema', () => {
172
+ test('validates page precondition', () => {
173
+ const result = preconditionsSchema.safeParse({
174
+ page: { url: 'https://example.com/login' },
175
+ });
176
+ expect(result.success).toBe(true);
177
+ });
178
+
179
+ test('validates auth precondition', () => {
180
+ const result = preconditionsSchema.safeParse({
181
+ auth: { user: 'admin', state: 'logged_in' },
182
+ });
183
+ expect(result.success).toBe(true);
184
+ });
185
+
186
+ test('validates viewport precondition', () => {
187
+ const result = preconditionsSchema.safeParse({
188
+ viewport: { width: 1280, height: 720 },
189
+ });
190
+ expect(result.success).toBe(true);
191
+ });
192
+
193
+ test('validates mocks precondition', () => {
194
+ const result = preconditionsSchema.safeParse({
195
+ mocks: [{ url: '/api/users', response: { users: [] } }],
196
+ });
197
+ expect(result.success).toBe(true);
198
+ });
199
+ });
200
+
201
+ describe('specSchema', () => {
202
+ test('validates complete spec', () => {
203
+ const spec = {
204
+ version: 2,
205
+ name: 'checkout-cart',
206
+ description: 'Test the checkout flow',
207
+ steps: [
208
+ { id: 'step-1', action: 'navigate', url: 'https://example.com' },
209
+ { id: 'step-2', action: 'click', target: { testid: 'add-to-cart' } },
210
+ ],
211
+ timeout: '30s',
212
+ priority: 'high',
213
+ tags: ['e2e', 'checkout'],
214
+ };
215
+ const result = specSchema.safeParse(spec);
216
+ expect(result.success).toBe(true);
217
+ });
218
+
219
+ test('requires version 2', () => {
220
+ const spec = {
221
+ version: 1,
222
+ name: 'test-spec',
223
+ steps: [{ id: 'step-1', action: 'click' }],
224
+ };
225
+ const result = specSchema.safeParse(spec);
226
+ expect(result.success).toBe(false);
227
+ });
228
+
229
+ test('requires kebab-case name', () => {
230
+ const specWithInvalidName = {
231
+ version: 2,
232
+ name: 'Invalid Name With Spaces',
233
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
234
+ };
235
+ const result = specSchema.safeParse(specWithInvalidName);
236
+ expect(result.success).toBe(false);
237
+ if (!result.success) {
238
+ expect(result.error.issues[0].message).toContain('kebab-case');
239
+ }
240
+ });
241
+
242
+ test('requires at least one step', () => {
243
+ const spec = {
244
+ version: 2,
245
+ name: 'empty-spec',
246
+ steps: [],
247
+ };
248
+ const result = specSchema.safeParse(spec);
249
+ expect(result.success).toBe(false);
250
+ if (!result.success) {
251
+ expect(result.error.issues[0].message).toContain('step');
252
+ }
253
+ });
254
+
255
+ test('requires unique step IDs', () => {
256
+ const spec = {
257
+ version: 2,
258
+ name: 'duplicate-ids',
259
+ steps: [
260
+ { id: 'step-1', action: 'click', target: { testid: 'btn1' } },
261
+ { id: 'step-1', action: 'click', target: { testid: 'btn2' } }, // Duplicate!
262
+ ],
263
+ };
264
+ const result = specSchema.safeParse(spec);
265
+ expect(result.success).toBe(false);
266
+ if (!result.success) {
267
+ expect(result.error.issues[0].message).toContain('unique');
268
+ }
269
+ });
270
+
271
+ test('validates priority values', () => {
272
+ const validPriorities = ['critical', 'high', 'normal', 'low'];
273
+ for (const priority of validPriorities) {
274
+ const spec = {
275
+ version: 2,
276
+ name: 'priority-test',
277
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
278
+ priority,
279
+ };
280
+ expect(specSchema.safeParse(spec).success).toBe(true);
281
+ }
282
+
283
+ const invalidSpec = {
284
+ version: 2,
285
+ name: 'priority-test',
286
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
287
+ priority: 'urgent',
288
+ };
289
+ expect(specSchema.safeParse(invalidSpec).success).toBe(false);
290
+ });
291
+
292
+ test('validates timeout as duration string', () => {
293
+ const validSpec = {
294
+ version: 2,
295
+ name: 'timeout-test',
296
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
297
+ timeout: '30s',
298
+ };
299
+ expect(specSchema.safeParse(validSpec).success).toBe(true);
300
+
301
+ const invalidSpec = {
302
+ version: 2,
303
+ name: 'timeout-test',
304
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
305
+ timeout: 'invalid',
306
+ };
307
+ expect(specSchema.safeParse(invalidSpec).success).toBe(false);
308
+ });
309
+
310
+ test('validates expected outcomes', () => {
311
+ const spec = {
312
+ version: 2,
313
+ name: 'outcomes-test',
314
+ steps: [{ id: 'step-1', action: 'navigate', url: 'https://example.com' }],
315
+ expected_outcomes: [
316
+ { description: 'Cart should be empty', check: 'cart_count', expected: 0 },
317
+ { description: 'User is logged in', check: 'logged_in', expected: true },
318
+ ],
319
+ };
320
+ expect(specSchema.safeParse(spec).success).toBe(true);
321
+ });
322
+
323
+ test('validates preconditions', () => {
324
+ const spec = {
325
+ version: 2,
326
+ name: 'preconditions-test',
327
+ steps: [{ id: 'step-1', action: 'click', target: { testid: 'btn' } }],
328
+ preconditions: {
329
+ page: { url: 'https://example.com/dashboard' },
330
+ viewport: { width: 1920, height: 1080 },
331
+ },
332
+ };
333
+ expect(specSchema.safeParse(spec).success).toBe(true);
334
+ });
335
+ });
336
+
337
+ describe('action types', () => {
338
+ const actionTestCases = [
339
+ { action: 'click', target: { testid: 'btn' } },
340
+ { action: 'navigate', url: 'https://example.com' },
341
+ { action: 'back' },
342
+ { action: 'forward' },
343
+ { action: 'refresh' },
344
+ { action: 'fill', target: { label: 'Email' }, value: 'test@test.com' },
345
+ { action: 'type', target: { css: 'input' }, text: 'hello' },
346
+ { action: 'select', target: { label: 'Country' }, option: 'USA' },
347
+ { action: 'check', target: { label: 'I agree' }, checked: true },
348
+ { action: 'wait', duration: '2s' },
349
+ { action: 'expect', target: { text: 'Success' }, state: 'visible' },
350
+ { action: 'screenshot', name: 'final-state' },
351
+ ];
352
+
353
+ for (const testCase of actionTestCases) {
354
+ test(`validates ${testCase.action} action`, () => {
355
+ const step = { id: `test-${testCase.action}`, ...testCase };
356
+ const result = stepSchema.safeParse(step);
357
+ expect(result.success).toBe(true);
358
+ });
359
+ }
360
+ });