@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,250 @@
1
+ /**
2
+ * LocatorObject types and resolution utilities
3
+ *
4
+ * @see bf-6ig for implementation task
5
+ */
6
+
7
+ import { z } from 'zod';
8
+ import type { Page, Locator } from 'playwright-core';
9
+
10
+ /**
11
+ * Supported locator strategy types
12
+ */
13
+ export type LocatorStrategyType = 'testid' | 'role' | 'label' | 'placeholder' | 'text' | 'css';
14
+
15
+ /**
16
+ * Locator strategy definition
17
+ */
18
+ export interface LocatorStrategy {
19
+ type: LocatorStrategyType;
20
+ // For testid
21
+ value?: string;
22
+ attribute?: string; // Default: data-testid
23
+ // For role
24
+ role?: string;
25
+ name?: string;
26
+ exact?: boolean; // Default: true
27
+ // For label/placeholder/text
28
+ text?: string;
29
+ // For css
30
+ selector?: string;
31
+ }
32
+
33
+ /**
34
+ * DOM fingerprint for element verification
35
+ */
36
+ export interface DOMFingerprint {
37
+ tag: string;
38
+ classes: string[];
39
+ attributes?: Record<string, string>;
40
+ }
41
+
42
+ /**
43
+ * Bounding box for element position
44
+ */
45
+ export interface BoundingBox {
46
+ x: number;
47
+ y: number;
48
+ width: number;
49
+ height: number;
50
+ }
51
+
52
+ /**
53
+ * Proof data for element verification
54
+ */
55
+ export interface LocatorProof {
56
+ a11y_role?: string;
57
+ a11y_name?: string;
58
+ dom_fingerprint?: DOMFingerprint;
59
+ bounding_box?: BoundingBox;
60
+ }
61
+
62
+ /**
63
+ * Scoping constraints for locator
64
+ */
65
+ export interface LocatorScoping {
66
+ within?: LocatorStrategy[];
67
+ nth?: number;
68
+ }
69
+
70
+ /**
71
+ * Complete LocatorObject - core primitive for deterministic element selection
72
+ */
73
+ export interface LocatorObject {
74
+ locator_id: string;
75
+ preferred: LocatorStrategy;
76
+ fallbacks: LocatorStrategy[];
77
+ scoping?: LocatorScoping;
78
+ proof: LocatorProof;
79
+ }
80
+
81
+ /**
82
+ * Options for resolving locators
83
+ */
84
+ export interface ResolveOptions {
85
+ useFallbacks: boolean; // false in CI, true in dev
86
+ timeout?: number;
87
+ }
88
+
89
+ // Zod schemas
90
+ export const locatorStrategyTypeSchema = z.enum(['testid', 'role', 'label', 'placeholder', 'text', 'css']);
91
+
92
+ export const locatorStrategySchema = z.object({
93
+ type: locatorStrategyTypeSchema,
94
+ // For testid
95
+ value: z.string().optional(),
96
+ attribute: z.string().optional(),
97
+ // For role
98
+ role: z.string().optional(),
99
+ name: z.string().optional(),
100
+ exact: z.boolean().optional(),
101
+ // For label/placeholder/text
102
+ text: z.string().optional(),
103
+ // For css
104
+ selector: z.string().optional(),
105
+ });
106
+
107
+ export const domFingerprintSchema = z.object({
108
+ tag: z.string(),
109
+ classes: z.array(z.string()),
110
+ attributes: z.record(z.string()).optional(),
111
+ });
112
+
113
+ export const boundingBoxSchema = z.object({
114
+ x: z.number(),
115
+ y: z.number(),
116
+ width: z.number(),
117
+ height: z.number(),
118
+ });
119
+
120
+ export const locatorProofSchema = z.object({
121
+ a11y_role: z.string().optional(),
122
+ a11y_name: z.string().optional(),
123
+ dom_fingerprint: domFingerprintSchema.optional(),
124
+ bounding_box: boundingBoxSchema.optional(),
125
+ });
126
+
127
+ export const locatorScopingSchema = z.object({
128
+ within: z.array(locatorStrategySchema).optional(),
129
+ nth: z.number().int().optional(),
130
+ });
131
+
132
+ export const locatorObjectSchema = z.object({
133
+ locator_id: z.string().min(1),
134
+ preferred: locatorStrategySchema,
135
+ fallbacks: z.array(locatorStrategySchema),
136
+ scoping: locatorScopingSchema.optional(),
137
+ proof: locatorProofSchema,
138
+ });
139
+
140
+ /**
141
+ * Converts a LocatorStrategy to a Playwright Locator
142
+ */
143
+ export function strategyToLocator(strategy: LocatorStrategy, page: Page): Locator {
144
+ switch (strategy.type) {
145
+ case 'testid':
146
+ if (strategy.attribute && strategy.attribute !== 'data-testid') {
147
+ // Custom testid attribute
148
+ return page.locator(`[${strategy.attribute}="${strategy.value}"]`);
149
+ }
150
+ return page.getByTestId(strategy.value!);
151
+
152
+ case 'role':
153
+ const roleOptions: { name?: string; exact?: boolean } = {};
154
+ if (strategy.name) {
155
+ roleOptions.name = strategy.name;
156
+ roleOptions.exact = strategy.exact ?? true;
157
+ }
158
+ return page.getByRole(strategy.role as any, roleOptions);
159
+
160
+ case 'label':
161
+ return page.getByLabel(strategy.text!);
162
+
163
+ case 'placeholder':
164
+ return page.getByPlaceholder(strategy.text!);
165
+
166
+ case 'text':
167
+ return page.getByText(strategy.text!);
168
+
169
+ case 'css':
170
+ return page.locator(strategy.selector!);
171
+
172
+ default:
173
+ throw new Error(`Unknown locator strategy type: ${(strategy as any).type}`);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Resolves a LocatorObject to a Playwright Locator
179
+ */
180
+ export function resolveLocator(
181
+ locatorObj: LocatorObject,
182
+ page: Page,
183
+ options: ResolveOptions
184
+ ): Locator {
185
+ // Start with preferred strategy
186
+ let locator = strategyToLocator(locatorObj.preferred, page);
187
+
188
+ // Apply scoping constraints
189
+ if (locatorObj.scoping?.within) {
190
+ for (const scope of locatorObj.scoping.within) {
191
+ const scopeLocator = strategyToLocator(scope, page);
192
+ locator = scopeLocator.locator(locator);
193
+ }
194
+ }
195
+
196
+ if (locatorObj.scoping?.nth !== undefined) {
197
+ locator = locator.nth(locatorObj.scoping.nth);
198
+ }
199
+
200
+ // TODO: If useFallbacks is true and locator doesn't find element,
201
+ // iterate through fallbacks. This requires async operation.
202
+
203
+ return locator;
204
+ }
205
+
206
+ // Legacy exports for backwards compatibility
207
+ export type LocatorMethod =
208
+ | 'getByRole'
209
+ | 'getByText'
210
+ | 'getByLabel'
211
+ | 'getByPlaceholder'
212
+ | 'getByTestId'
213
+ | 'getByAltText'
214
+ | 'getByTitle'
215
+ | 'locator';
216
+
217
+ export interface LocatorArgs {
218
+ role?: string;
219
+ name?: string | RegExp;
220
+ text?: string | RegExp;
221
+ testId?: string;
222
+ exact?: boolean;
223
+ selector?: string;
224
+ [key: string]: unknown;
225
+ }
226
+
227
+ /**
228
+ * Legacy LocatorObject interface (for backwards compatibility)
229
+ */
230
+ export interface LegacyLocatorObject {
231
+ ref?: string;
232
+ selector?: string;
233
+ method?: LocatorMethod;
234
+ args?: LocatorArgs;
235
+ description?: string;
236
+ }
237
+
238
+ /**
239
+ * Legacy function - resolves a LegacyLocatorObject to a string representation
240
+ */
241
+ export function resolveLegacyLocator(locator: LegacyLocatorObject): string {
242
+ if (locator.selector) {
243
+ return `locator(${JSON.stringify(locator.selector)})`;
244
+ }
245
+ if (locator.method && locator.args) {
246
+ const argsStr = JSON.stringify(locator.args);
247
+ return `${locator.method}(${argsStr})`;
248
+ }
249
+ return 'locator("body")';
250
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Tests for lockfile types
3
+ * @see bf-aak
4
+ */
5
+
6
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
7
+ import { join } from 'path';
8
+ import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises';
9
+ import { tmpdir } from 'os';
10
+ import {
11
+ lockfileSchema,
12
+ maskSchema,
13
+ assertionSchema,
14
+ readLockfile,
15
+ writeLockfile,
16
+ validateLockfile,
17
+ computeSpecHash,
18
+ type Lockfile,
19
+ type Mask,
20
+ type Assertion,
21
+ type AssertionType,
22
+ } from './lockfile.js';
23
+
24
+ describe('maskSchema', () => {
25
+ test('validates mask with coordinates', () => {
26
+ const mask: Mask = {
27
+ x: 100,
28
+ y: 200,
29
+ width: 50,
30
+ height: 30,
31
+ reason: 'Dynamic content',
32
+ };
33
+ expect(maskSchema.safeParse(mask).success).toBe(true);
34
+ });
35
+
36
+ test('validates mask with locator', () => {
37
+ const mask: Mask = {
38
+ x: 0,
39
+ y: 0,
40
+ width: 100,
41
+ height: 100,
42
+ reason: 'User avatar',
43
+ locator: '[data-testid="avatar"]',
44
+ };
45
+ expect(maskSchema.safeParse(mask).success).toBe(true);
46
+ });
47
+
48
+ test('requires all coordinate fields', () => {
49
+ const incompleteMask = {
50
+ x: 100,
51
+ y: 200,
52
+ reason: 'Missing dimensions',
53
+ };
54
+ expect(maskSchema.safeParse(incompleteMask).success).toBe(false);
55
+ });
56
+ });
57
+
58
+ describe('assertionSchema', () => {
59
+ const assertionTypes: AssertionType[] = [
60
+ 'visible',
61
+ 'hidden',
62
+ 'text_contains',
63
+ 'text_equals',
64
+ 'url_contains',
65
+ 'url_matches',
66
+ 'count',
67
+ 'attribute',
68
+ 'checked',
69
+ 'screenshot',
70
+ ];
71
+
72
+ for (const type of assertionTypes) {
73
+ test(`validates ${type} assertion type`, () => {
74
+ const assertion: Assertion = {
75
+ id: `assertion-${type}`,
76
+ type,
77
+ };
78
+ expect(assertionSchema.safeParse(assertion).success).toBe(true);
79
+ });
80
+ }
81
+
82
+ test('validates assertion with target', () => {
83
+ const assertion: Assertion = {
84
+ id: 'assert-1',
85
+ type: 'visible',
86
+ target: {
87
+ locator_id: 'loc-1',
88
+ preferred: { type: 'testid', value: 'submit-btn' },
89
+ fallbacks: [],
90
+ proof: {},
91
+ },
92
+ };
93
+ expect(assertionSchema.safeParse(assertion).success).toBe(true);
94
+ });
95
+
96
+ test('validates assertion with expected value', () => {
97
+ const assertion: Assertion = {
98
+ id: 'assert-2',
99
+ type: 'text_equals',
100
+ expected: 'Welcome back!',
101
+ };
102
+ expect(assertionSchema.safeParse(assertion).success).toBe(true);
103
+ });
104
+
105
+ test('validates assertion attached to step', () => {
106
+ const assertion: Assertion = {
107
+ id: 'assert-3',
108
+ type: 'screenshot',
109
+ step_id: 'step-5',
110
+ };
111
+ expect(assertionSchema.safeParse(assertion).success).toBe(true);
112
+ });
113
+ });
114
+
115
+ describe('lockfileSchema', () => {
116
+ test('validates complete lockfile', () => {
117
+ const lockfile: Lockfile = {
118
+ run_id: 'run-20260115031000-abc123',
119
+ spec_name: 'checkout-cart',
120
+ spec_hash: 'abc123def456',
121
+ created_at: '2026-01-15T03:10:00Z',
122
+ locators: {
123
+ 'submit-btn': {
124
+ locator_id: 'submit-btn',
125
+ preferred: { type: 'testid', value: 'submit-btn' },
126
+ fallbacks: [],
127
+ proof: { a11y_role: 'button' },
128
+ },
129
+ },
130
+ masks: {
131
+ 'checkout-complete': [
132
+ { x: 10, y: 20, width: 100, height: 50, reason: 'Dynamic timestamp' },
133
+ ],
134
+ },
135
+ assertions: [
136
+ { id: 'assert-1', type: 'visible' },
137
+ ],
138
+ generation: {
139
+ format: 'playwright-ts',
140
+ output_path: 'tests/checkout-cart.spec.ts',
141
+ },
142
+ };
143
+ expect(lockfileSchema.safeParse(lockfile).success).toBe(true);
144
+ });
145
+
146
+ test('validates lockfile with generated_at', () => {
147
+ const lockfile: Lockfile = {
148
+ run_id: 'run-20260115031000-abc123',
149
+ spec_name: 'checkout-cart',
150
+ spec_hash: 'abc123def456',
151
+ created_at: '2026-01-15T03:10:00Z',
152
+ locators: {},
153
+ masks: {},
154
+ assertions: [],
155
+ generation: {
156
+ format: 'playwright-ts',
157
+ output_path: 'tests/checkout-cart.spec.ts',
158
+ generated_at: '2026-01-15T03:15:00Z',
159
+ },
160
+ };
161
+ expect(lockfileSchema.safeParse(lockfile).success).toBe(true);
162
+ });
163
+
164
+ test('requires run_id', () => {
165
+ const lockfile = {
166
+ spec_name: 'checkout-cart',
167
+ spec_hash: 'abc123',
168
+ created_at: '2026-01-15T03:10:00Z',
169
+ locators: {},
170
+ masks: {},
171
+ assertions: [],
172
+ generation: {
173
+ format: 'playwright-ts',
174
+ output_path: 'tests/test.spec.ts',
175
+ },
176
+ };
177
+ expect(lockfileSchema.safeParse(lockfile).success).toBe(false);
178
+ });
179
+
180
+ test('requires spec_name', () => {
181
+ const lockfile = {
182
+ run_id: 'run-123',
183
+ spec_hash: 'abc123',
184
+ created_at: '2026-01-15T03:10:00Z',
185
+ locators: {},
186
+ masks: {},
187
+ assertions: [],
188
+ generation: {
189
+ format: 'playwright-ts',
190
+ output_path: 'tests/test.spec.ts',
191
+ },
192
+ };
193
+ expect(lockfileSchema.safeParse(lockfile).success).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe('validateLockfile', () => {
198
+ test('returns true for valid lockfile', () => {
199
+ const lockfile: Lockfile = {
200
+ run_id: 'run-123',
201
+ spec_name: 'test',
202
+ spec_hash: 'abc',
203
+ created_at: '2026-01-15T03:10:00Z',
204
+ locators: {},
205
+ masks: {},
206
+ assertions: [],
207
+ generation: {
208
+ format: 'playwright-ts',
209
+ output_path: 'tests/test.spec.ts',
210
+ },
211
+ };
212
+ expect(validateLockfile(lockfile)).toBe(true);
213
+ });
214
+
215
+ test('returns false for invalid lockfile', () => {
216
+ const invalid = {
217
+ run_id: 'run-123',
218
+ // Missing required fields
219
+ };
220
+ expect(validateLockfile(invalid)).toBe(false);
221
+ });
222
+
223
+ test('returns false for non-object', () => {
224
+ expect(validateLockfile('string')).toBe(false);
225
+ expect(validateLockfile(123)).toBe(false);
226
+ expect(validateLockfile(null)).toBe(false);
227
+ });
228
+ });
229
+
230
+ describe('computeSpecHash', () => {
231
+ test('computes consistent hash for same content', () => {
232
+ const content = 'version: 2\nname: test';
233
+ const hash1 = computeSpecHash(content);
234
+ const hash2 = computeSpecHash(content);
235
+ expect(hash1).toBe(hash2);
236
+ });
237
+
238
+ test('computes different hash for different content', () => {
239
+ const hash1 = computeSpecHash('content1');
240
+ const hash2 = computeSpecHash('content2');
241
+ expect(hash1).not.toBe(hash2);
242
+ });
243
+
244
+ test('returns SHA256 hex string', () => {
245
+ const hash = computeSpecHash('test');
246
+ expect(hash).toMatch(/^[a-f0-9]{64}$/);
247
+ });
248
+ });
249
+
250
+ describe('file operations', () => {
251
+ let tempDir: string;
252
+
253
+ beforeEach(async () => {
254
+ tempDir = await mkdtemp(join(tmpdir(), 'lockfile-test-'));
255
+ await mkdir(join(tempDir, 'runs', 'test-spec', 'run-123'), { recursive: true });
256
+ });
257
+
258
+ afterEach(async () => {
259
+ await rm(tempDir, { recursive: true, force: true });
260
+ });
261
+
262
+ describe('writeLockfile', () => {
263
+ test('writes lockfile to correct path', async () => {
264
+ const runDir = join(tempDir, 'runs', 'test-spec', 'run-123');
265
+ const lockfile: Lockfile = {
266
+ run_id: 'run-123',
267
+ spec_name: 'test-spec',
268
+ spec_hash: 'abc123',
269
+ created_at: '2026-01-15T03:10:00Z',
270
+ locators: {},
271
+ masks: {},
272
+ assertions: [],
273
+ generation: {
274
+ format: 'playwright-ts',
275
+ output_path: 'tests/test.spec.ts',
276
+ },
277
+ };
278
+
279
+ await writeLockfile(runDir, lockfile);
280
+
281
+ const file = Bun.file(join(runDir, 'lockfile.json'));
282
+ const content = await file.json();
283
+ expect(content.run_id).toBe('run-123');
284
+ expect(content.spec_name).toBe('test-spec');
285
+ });
286
+
287
+ test('writes formatted JSON', async () => {
288
+ const runDir = join(tempDir, 'runs', 'test-spec', 'run-123');
289
+ const lockfile: Lockfile = {
290
+ run_id: 'run-123',
291
+ spec_name: 'test-spec',
292
+ spec_hash: 'abc123',
293
+ created_at: '2026-01-15T03:10:00Z',
294
+ locators: {},
295
+ masks: {},
296
+ assertions: [],
297
+ generation: {
298
+ format: 'playwright-ts',
299
+ output_path: 'tests/test.spec.ts',
300
+ },
301
+ };
302
+
303
+ await writeLockfile(runDir, lockfile);
304
+
305
+ const text = await Bun.file(join(runDir, 'lockfile.json')).text();
306
+ expect(text).toContain('\n'); // Should be pretty-printed
307
+ });
308
+ });
309
+
310
+ describe('readLockfile', () => {
311
+ test('reads lockfile from correct path', async () => {
312
+ const runDir = join(tempDir, 'runs', 'test-spec', 'run-123');
313
+ const lockfile: Lockfile = {
314
+ run_id: 'run-123',
315
+ spec_name: 'test-spec',
316
+ spec_hash: 'abc123',
317
+ created_at: '2026-01-15T03:10:00Z',
318
+ locators: {},
319
+ masks: {},
320
+ assertions: [],
321
+ generation: {
322
+ format: 'playwright-ts',
323
+ output_path: 'tests/test.spec.ts',
324
+ },
325
+ };
326
+
327
+ await writeFile(join(runDir, 'lockfile.json'), JSON.stringify(lockfile));
328
+
329
+ const result = await readLockfile(runDir);
330
+ expect(result.run_id).toBe('run-123');
331
+ expect(result.spec_name).toBe('test-spec');
332
+ });
333
+
334
+ test('throws for missing lockfile', async () => {
335
+ const runDir = join(tempDir, 'runs', 'nonexistent');
336
+ await expect(readLockfile(runDir)).rejects.toThrow();
337
+ });
338
+
339
+ test('throws for invalid JSON', async () => {
340
+ const runDir = join(tempDir, 'runs', 'test-spec', 'run-123');
341
+ await writeFile(join(runDir, 'lockfile.json'), 'not json');
342
+ await expect(readLockfile(runDir)).rejects.toThrow();
343
+ });
344
+ });
345
+ });