@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.
- package/dist/config-schema.d.ts +354 -0
- package/dist/config-schema.d.ts.map +1 -0
- package/dist/config-schema.js +83 -0
- package/dist/config-schema.js.map +1 -0
- package/dist/config.d.ts +107 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +5 -0
- package/dist/config.js.map +1 -0
- package/dist/duration.d.ts +39 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +111 -0
- package/dist/duration.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/locator-object.d.ts +556 -0
- package/dist/locator-object.d.ts.map +1 -0
- package/dist/locator-object.js +114 -0
- package/dist/locator-object.js.map +1 -0
- package/dist/lockfile.d.ts +1501 -0
- package/dist/lockfile.d.ts.map +1 -0
- package/dist/lockfile.js +86 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/run-store.d.ts +81 -0
- package/dist/run-store.d.ts.map +1 -0
- package/dist/run-store.js +181 -0
- package/dist/run-store.js.map +1 -0
- package/dist/spec-loader.d.ts +17 -0
- package/dist/spec-loader.d.ts.map +1 -0
- package/dist/spec-loader.js +37 -0
- package/dist/spec-loader.js.map +1 -0
- package/dist/spec-schema.d.ts +1411 -0
- package/dist/spec-schema.d.ts.map +1 -0
- package/dist/spec-schema.js +239 -0
- package/dist/spec-schema.js.map +1 -0
- package/package.json +45 -0
- package/schemas/browserflow.schema.json +220 -0
- package/schemas/lockfile.schema.json +568 -0
- package/schemas/spec-v2.schema.json +413 -0
- package/src/config-schema.test.ts +190 -0
- package/src/config-schema.ts +111 -0
- package/src/config.ts +112 -0
- package/src/duration.test.ts +175 -0
- package/src/duration.ts +128 -0
- package/src/index.ts +136 -0
- package/src/json-schemas.test.ts +374 -0
- package/src/locator-object.test.ts +323 -0
- package/src/locator-object.ts +250 -0
- package/src/lockfile.test.ts +345 -0
- package/src/lockfile.ts +209 -0
- package/src/run-store.test.ts +232 -0
- package/src/run-store.ts +240 -0
- package/src/spec-loader.test.ts +181 -0
- package/src/spec-loader.ts +47 -0
- package/src/spec-schema.test.ts +360 -0
- 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
|
+
});
|