@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
package/src/lockfile.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lockfile types for exploration results
|
|
3
|
+
*
|
|
4
|
+
* @see bf-aak for implementation task
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { createHash } from 'crypto';
|
|
10
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
11
|
+
import { locatorObjectSchema, type LocatorObject, type LegacyLocatorObject } from './locator-object.js';
|
|
12
|
+
import type { SpecStep, LegacySpecStep } from './spec-schema.js';
|
|
13
|
+
|
|
14
|
+
// Assertion types
|
|
15
|
+
export type AssertionType =
|
|
16
|
+
| 'visible'
|
|
17
|
+
| 'hidden'
|
|
18
|
+
| 'text_contains'
|
|
19
|
+
| 'text_equals'
|
|
20
|
+
| 'url_contains'
|
|
21
|
+
| 'url_matches'
|
|
22
|
+
| 'count'
|
|
23
|
+
| 'attribute'
|
|
24
|
+
| 'checked'
|
|
25
|
+
| 'screenshot';
|
|
26
|
+
|
|
27
|
+
export const assertionTypeSchema = z.enum([
|
|
28
|
+
'visible',
|
|
29
|
+
'hidden',
|
|
30
|
+
'text_contains',
|
|
31
|
+
'text_equals',
|
|
32
|
+
'url_contains',
|
|
33
|
+
'url_matches',
|
|
34
|
+
'count',
|
|
35
|
+
'attribute',
|
|
36
|
+
'checked',
|
|
37
|
+
'screenshot',
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
// Mask interface
|
|
41
|
+
export interface Mask {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
reason: string;
|
|
47
|
+
locator?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const maskSchema = z.object({
|
|
51
|
+
x: z.number(),
|
|
52
|
+
y: z.number(),
|
|
53
|
+
width: z.number(),
|
|
54
|
+
height: z.number(),
|
|
55
|
+
reason: z.string(),
|
|
56
|
+
locator: z.string().optional(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Assertion interface
|
|
60
|
+
export interface Assertion {
|
|
61
|
+
id: string;
|
|
62
|
+
type: AssertionType;
|
|
63
|
+
target?: LocatorObject;
|
|
64
|
+
expected?: string | number | boolean;
|
|
65
|
+
step_id?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const assertionSchema = z.object({
|
|
69
|
+
id: z.string(),
|
|
70
|
+
type: assertionTypeSchema,
|
|
71
|
+
target: locatorObjectSchema.optional(),
|
|
72
|
+
expected: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
|
73
|
+
step_id: z.string().optional(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Generation metadata
|
|
77
|
+
export interface GenerationMetadata {
|
|
78
|
+
format: 'playwright-ts';
|
|
79
|
+
output_path: string;
|
|
80
|
+
generated_at?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const generationMetadataSchema = z.object({
|
|
84
|
+
format: z.literal('playwright-ts'),
|
|
85
|
+
output_path: z.string(),
|
|
86
|
+
generated_at: z.string().optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Main Lockfile interface
|
|
90
|
+
export interface Lockfile {
|
|
91
|
+
run_id: string;
|
|
92
|
+
spec_name: string;
|
|
93
|
+
spec_hash: string;
|
|
94
|
+
created_at: string;
|
|
95
|
+
locators: Record<string, LocatorObject>;
|
|
96
|
+
masks: Record<string, Mask[]>;
|
|
97
|
+
assertions: Assertion[];
|
|
98
|
+
generation: GenerationMetadata;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const lockfileSchema = z.object({
|
|
102
|
+
run_id: z.string(),
|
|
103
|
+
spec_name: z.string(),
|
|
104
|
+
spec_hash: z.string(),
|
|
105
|
+
created_at: z.string(),
|
|
106
|
+
locators: z.record(locatorObjectSchema),
|
|
107
|
+
masks: z.record(z.array(maskSchema)),
|
|
108
|
+
assertions: z.array(assertionSchema),
|
|
109
|
+
generation: generationMetadataSchema,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validates if an object is a valid Lockfile
|
|
114
|
+
*/
|
|
115
|
+
export function validateLockfile(lockfile: unknown): lockfile is Lockfile {
|
|
116
|
+
return lockfileSchema.safeParse(lockfile).success;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Computes SHA256 hash of spec file content
|
|
121
|
+
*/
|
|
122
|
+
export function computeSpecHash(content: string): string {
|
|
123
|
+
return createHash('sha256').update(content).digest('hex');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Reads a lockfile from a run directory
|
|
128
|
+
*/
|
|
129
|
+
export async function readLockfile(runDir: string): Promise<Lockfile> {
|
|
130
|
+
const lockfilePath = join(runDir, 'lockfile.json');
|
|
131
|
+
const content = await readFile(lockfilePath, 'utf-8');
|
|
132
|
+
const parsed = JSON.parse(content);
|
|
133
|
+
|
|
134
|
+
const result = lockfileSchema.safeParse(parsed);
|
|
135
|
+
if (!result.success) {
|
|
136
|
+
throw new Error(`Invalid lockfile: ${result.error.message}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return result.data;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Writes a lockfile to a run directory
|
|
144
|
+
*/
|
|
145
|
+
export async function writeLockfile(runDir: string, lockfile: Lockfile): Promise<void> {
|
|
146
|
+
const lockfilePath = join(runDir, 'lockfile.json');
|
|
147
|
+
const content = JSON.stringify(lockfile, null, 2);
|
|
148
|
+
await writeFile(lockfilePath, content, 'utf-8');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Exploration output - NOT the deterministic lockfile
|
|
152
|
+
// This represents the raw output from an exploration run
|
|
153
|
+
export interface ExplorationReport {
|
|
154
|
+
spec: string;
|
|
155
|
+
spec_path: string;
|
|
156
|
+
spec_description?: string; // Spec-level description for UI display
|
|
157
|
+
exploration_id: string;
|
|
158
|
+
timestamp: string;
|
|
159
|
+
duration_ms: number;
|
|
160
|
+
browser: 'chromium' | 'firefox' | 'webkit';
|
|
161
|
+
viewport: { width: number; height: number };
|
|
162
|
+
base_url: string;
|
|
163
|
+
steps: ExplorationStep[];
|
|
164
|
+
outcome_checks: OutcomeCheck[];
|
|
165
|
+
overall_status: 'completed' | 'failed' | 'timeout';
|
|
166
|
+
errors: ExplorationError[];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* @deprecated Use ExplorationReport instead - this is exploration output, not the deterministic lockfile
|
|
171
|
+
*/
|
|
172
|
+
export type ExplorationLockfile = ExplorationReport;
|
|
173
|
+
|
|
174
|
+
export interface ExplorationStep {
|
|
175
|
+
step_index: number;
|
|
176
|
+
spec_action: LegacySpecStep;
|
|
177
|
+
execution: StepExecution;
|
|
178
|
+
screenshots: {
|
|
179
|
+
before?: string;
|
|
180
|
+
after?: string;
|
|
181
|
+
};
|
|
182
|
+
snapshot_before?: Record<string, unknown>;
|
|
183
|
+
snapshot_after?: Record<string, unknown>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface StepExecution {
|
|
187
|
+
status: 'completed' | 'failed' | 'skipped';
|
|
188
|
+
method?: string;
|
|
189
|
+
element_ref?: string;
|
|
190
|
+
selector_used?: string;
|
|
191
|
+
locator?: LegacyLocatorObject;
|
|
192
|
+
duration_ms: number;
|
|
193
|
+
error?: string;
|
|
194
|
+
value_used?: string;
|
|
195
|
+
url_used?: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface OutcomeCheck {
|
|
199
|
+
check: string;
|
|
200
|
+
expected: unknown;
|
|
201
|
+
actual: unknown;
|
|
202
|
+
passed: boolean;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export interface ExplorationError {
|
|
206
|
+
step_index?: number;
|
|
207
|
+
message: string;
|
|
208
|
+
stack?: string;
|
|
209
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for run store (immutable directories)
|
|
3
|
+
* @see bf-92j
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { mkdtemp, rm, readlink, stat, readdir, mkdir, writeFile } from 'fs/promises';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
import {
|
|
11
|
+
createRunStore,
|
|
12
|
+
createRunId,
|
|
13
|
+
type RunStore,
|
|
14
|
+
} from './run-store.js';
|
|
15
|
+
|
|
16
|
+
describe('createRunId', () => {
|
|
17
|
+
test('generates run ID with correct format', () => {
|
|
18
|
+
const id = createRunId();
|
|
19
|
+
expect(id).toMatch(/^run-\d{14}-[a-f0-9]{6}$/);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('generates unique IDs', () => {
|
|
23
|
+
const ids = new Set<string>();
|
|
24
|
+
for (let i = 0; i < 100; i++) {
|
|
25
|
+
ids.add(createRunId());
|
|
26
|
+
}
|
|
27
|
+
expect(ids.size).toBe(100);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('includes timestamp in ID', () => {
|
|
31
|
+
const id = createRunId();
|
|
32
|
+
const timestamp = id.slice(4, 18);
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const year = timestamp.slice(0, 4);
|
|
35
|
+
expect(parseInt(year)).toBe(now.getFullYear());
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('RunStore', () => {
|
|
40
|
+
let tempDir: string;
|
|
41
|
+
let store: RunStore;
|
|
42
|
+
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
tempDir = await mkdtemp(join(tmpdir(), 'browserflow-test-'));
|
|
45
|
+
store = createRunStore(tempDir);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(async () => {
|
|
49
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('createRun', () => {
|
|
53
|
+
test('creates run directory with correct structure', async () => {
|
|
54
|
+
const runPath = await store.createRun('checkout-cart');
|
|
55
|
+
|
|
56
|
+
// Check directory exists
|
|
57
|
+
const stats = await stat(runPath);
|
|
58
|
+
expect(stats.isDirectory()).toBe(true);
|
|
59
|
+
|
|
60
|
+
// Check subdirectories created
|
|
61
|
+
const artifactsDir = join(runPath, 'artifacts');
|
|
62
|
+
const screenshotsDir = join(artifactsDir, 'screenshots');
|
|
63
|
+
const logsDir = join(artifactsDir, 'logs');
|
|
64
|
+
|
|
65
|
+
expect((await stat(artifactsDir)).isDirectory()).toBe(true);
|
|
66
|
+
expect((await stat(screenshotsDir)).isDirectory()).toBe(true);
|
|
67
|
+
expect((await stat(logsDir)).isDirectory()).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('creates unique run directories', async () => {
|
|
71
|
+
const run1 = await store.createRun('spec-a');
|
|
72
|
+
const run2 = await store.createRun('spec-a');
|
|
73
|
+
|
|
74
|
+
expect(run1).not.toBe(run2);
|
|
75
|
+
expect((await stat(run1)).isDirectory()).toBe(true);
|
|
76
|
+
expect((await stat(run2)).isDirectory()).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('creates run under spec-specific directory', async () => {
|
|
80
|
+
const runPath = await store.createRun('checkout-cart');
|
|
81
|
+
|
|
82
|
+
expect(runPath).toContain('checkout-cart');
|
|
83
|
+
expect(runPath).toContain('runs');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('updates latest symlink', async () => {
|
|
87
|
+
const runPath = await store.createRun('checkout-cart');
|
|
88
|
+
|
|
89
|
+
const latestPath = join(tempDir, '.browserflow', 'runs', 'checkout-cart', 'latest');
|
|
90
|
+
const linkTarget = await readlink(latestPath);
|
|
91
|
+
|
|
92
|
+
expect(runPath).toContain(linkTarget);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('creates multiple runs for same spec', async () => {
|
|
96
|
+
const run1 = await store.createRun('checkout-cart');
|
|
97
|
+
const run2 = await store.createRun('checkout-cart');
|
|
98
|
+
const run3 = await store.createRun('checkout-cart');
|
|
99
|
+
|
|
100
|
+
const runs = await store.listRuns('checkout-cart');
|
|
101
|
+
expect(runs.length).toBe(3);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('getLatestRun', () => {
|
|
106
|
+
test('returns null for spec with no runs', () => {
|
|
107
|
+
const latest = store.getLatestRun('nonexistent-spec');
|
|
108
|
+
expect(latest).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('returns latest run path', async () => {
|
|
112
|
+
await store.createRun('checkout-cart');
|
|
113
|
+
await new Promise((r) => setTimeout(r, 10)); // Small delay
|
|
114
|
+
const run2 = await store.createRun('checkout-cart');
|
|
115
|
+
|
|
116
|
+
const latest = store.getLatestRun('checkout-cart');
|
|
117
|
+
expect(latest).toBe(run2);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('listRuns', () => {
|
|
122
|
+
test('returns empty array for spec with no runs', () => {
|
|
123
|
+
const runs = store.listRuns('nonexistent-spec');
|
|
124
|
+
expect(runs).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('lists all runs newest first', async () => {
|
|
128
|
+
const run1 = await store.createRun('checkout-cart');
|
|
129
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
130
|
+
const run2 = await store.createRun('checkout-cart');
|
|
131
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
132
|
+
const run3 = await store.createRun('checkout-cart');
|
|
133
|
+
|
|
134
|
+
const runs = store.listRuns('checkout-cart');
|
|
135
|
+
|
|
136
|
+
expect(runs.length).toBe(3);
|
|
137
|
+
expect(runs[0]).toBe(run3);
|
|
138
|
+
expect(runs[1]).toBe(run2);
|
|
139
|
+
expect(runs[2]).toBe(run1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('only lists run directories, not latest symlink', async () => {
|
|
143
|
+
await store.createRun('checkout-cart');
|
|
144
|
+
await store.createRun('checkout-cart');
|
|
145
|
+
|
|
146
|
+
const runs = store.listRuns('checkout-cart');
|
|
147
|
+
|
|
148
|
+
expect(runs.every((r) => r.includes('run-'))).toBe(true);
|
|
149
|
+
expect(runs.some((r) => r.includes('latest'))).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('getRunDir', () => {
|
|
154
|
+
test('returns full path for run', async () => {
|
|
155
|
+
const runPath = await store.createRun('checkout-cart');
|
|
156
|
+
const runId = runPath.split('/').pop()!;
|
|
157
|
+
|
|
158
|
+
const dir = store.getRunDir('checkout-cart', runId);
|
|
159
|
+
expect(dir).toBe(runPath);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('runExists', () => {
|
|
164
|
+
test('returns false for nonexistent run', () => {
|
|
165
|
+
expect(store.runExists('checkout-cart', 'run-nonexistent')).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('returns true for existing run', async () => {
|
|
169
|
+
const runPath = await store.createRun('checkout-cart');
|
|
170
|
+
const runId = runPath.split('/').pop()!;
|
|
171
|
+
|
|
172
|
+
expect(store.runExists('checkout-cart', runId)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('immutability', () => {
|
|
177
|
+
test('never overwrites existing runs', async () => {
|
|
178
|
+
const run1 = await store.createRun('checkout-cart');
|
|
179
|
+
|
|
180
|
+
// Write a file to the run
|
|
181
|
+
await writeFile(join(run1, 'test.txt'), 'original');
|
|
182
|
+
|
|
183
|
+
// Create another run
|
|
184
|
+
const run2 = await store.createRun('checkout-cart');
|
|
185
|
+
|
|
186
|
+
// Original run should be unchanged
|
|
187
|
+
const content = await Bun.file(join(run1, 'test.txt')).text();
|
|
188
|
+
expect(content).toBe('original');
|
|
189
|
+
|
|
190
|
+
// New run should be different
|
|
191
|
+
expect(run1).not.toBe(run2);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('directory structure', () => {
|
|
197
|
+
let tempDir: string;
|
|
198
|
+
let store: RunStore;
|
|
199
|
+
|
|
200
|
+
beforeEach(async () => {
|
|
201
|
+
tempDir = await mkdtemp(join(tmpdir(), 'browserflow-test-'));
|
|
202
|
+
store = createRunStore(tempDir);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
afterEach(async () => {
|
|
206
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('creates .browserflow root directory', async () => {
|
|
210
|
+
await store.createRun('test-spec');
|
|
211
|
+
|
|
212
|
+
const browserflowDir = join(tempDir, '.browserflow');
|
|
213
|
+
const stats = await stat(browserflowDir);
|
|
214
|
+
expect(stats.isDirectory()).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('creates runs directory', async () => {
|
|
218
|
+
await store.createRun('test-spec');
|
|
219
|
+
|
|
220
|
+
const runsDir = join(tempDir, '.browserflow', 'runs');
|
|
221
|
+
const stats = await stat(runsDir);
|
|
222
|
+
expect(stats.isDirectory()).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('creates spec-specific directory', async () => {
|
|
226
|
+
await store.createRun('checkout-cart');
|
|
227
|
+
|
|
228
|
+
const specDir = join(tempDir, '.browserflow', 'runs', 'checkout-cart');
|
|
229
|
+
const stats = await stat(specDir);
|
|
230
|
+
expect(stats.isDirectory()).toBe(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
package/src/run-store.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable run directory management
|
|
3
|
+
*
|
|
4
|
+
* Manages .browserflow/ directories for storing exploration runs,
|
|
5
|
+
* screenshots, and generated artifacts.
|
|
6
|
+
*
|
|
7
|
+
* @see bf-92j for implementation task
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { randomBytes } from 'crypto';
|
|
12
|
+
import { mkdir, symlink, unlink } from 'fs/promises';
|
|
13
|
+
import { existsSync, statSync, readdirSync } from 'fs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run directory structure:
|
|
17
|
+
* .browserflow/
|
|
18
|
+
* runs/
|
|
19
|
+
* <spec-name>/
|
|
20
|
+
* run-20260115-031000-abc123/
|
|
21
|
+
* exploration.json
|
|
22
|
+
* review.json
|
|
23
|
+
* lockfile.json
|
|
24
|
+
* artifacts/
|
|
25
|
+
* screenshots/
|
|
26
|
+
* trace.zip
|
|
27
|
+
* logs/
|
|
28
|
+
* run-20260115-041500-def456/
|
|
29
|
+
* ...
|
|
30
|
+
* latest -> run-20260115-041500-def456
|
|
31
|
+
* cache/
|
|
32
|
+
* tmp/
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
export interface RunDirectoryPaths {
|
|
36
|
+
root: string;
|
|
37
|
+
runsDir: string;
|
|
38
|
+
runDir: string;
|
|
39
|
+
lockfile: string;
|
|
40
|
+
screenshotsDir: string;
|
|
41
|
+
reviewFile: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RunStore {
|
|
45
|
+
createRun(specName: string): Promise<string>;
|
|
46
|
+
getLatestRun(specName: string): string | null;
|
|
47
|
+
listRuns(specName: string): string[];
|
|
48
|
+
getRunDir(specName: string, runId: string): string;
|
|
49
|
+
runExists(specName: string, runId: string): boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generates a unique run ID.
|
|
54
|
+
*
|
|
55
|
+
* Format: run-<YYYYMMDDHHMMSS>-<random>
|
|
56
|
+
* Example: run-20260115031000-a3f2dd
|
|
57
|
+
*/
|
|
58
|
+
export function createRunId(): string {
|
|
59
|
+
const now = new Date();
|
|
60
|
+
const ts = now
|
|
61
|
+
.toISOString()
|
|
62
|
+
.replace(/[-:T]/g, '')
|
|
63
|
+
.slice(0, 14); // YYYYMMDDHHMMSS
|
|
64
|
+
const rand = randomBytes(3).toString('hex');
|
|
65
|
+
return `run-${ts}-${rand}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a RunStore instance for a project root.
|
|
70
|
+
*/
|
|
71
|
+
export function createRunStore(projectRoot: string): RunStore {
|
|
72
|
+
const browserflowDir = join(projectRoot, '.browserflow');
|
|
73
|
+
const runsDir = join(browserflowDir, 'runs');
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
async createRun(specName: string): Promise<string> {
|
|
77
|
+
const specDir = join(runsDir, specName);
|
|
78
|
+
const runId = createRunId();
|
|
79
|
+
const runDir = join(specDir, runId);
|
|
80
|
+
|
|
81
|
+
// Create directory structure
|
|
82
|
+
await mkdir(runDir, { recursive: true });
|
|
83
|
+
await mkdir(join(runDir, 'artifacts', 'screenshots'), { recursive: true });
|
|
84
|
+
await mkdir(join(runDir, 'artifacts', 'logs'), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// Update "latest" symlink atomically
|
|
87
|
+
const latestPath = join(specDir, 'latest');
|
|
88
|
+
const tempLatestPath = join(specDir, `.latest-${Date.now()}`);
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
// Create symlink to new location
|
|
92
|
+
await symlink(runId, tempLatestPath);
|
|
93
|
+
|
|
94
|
+
// Atomic rename (replaces old symlink)
|
|
95
|
+
try {
|
|
96
|
+
await unlink(latestPath);
|
|
97
|
+
} catch {
|
|
98
|
+
// Ignore if doesn't exist
|
|
99
|
+
}
|
|
100
|
+
await symlink(runId, latestPath);
|
|
101
|
+
await unlink(tempLatestPath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// Cleanup temp symlink on error
|
|
104
|
+
try {
|
|
105
|
+
await unlink(tempLatestPath);
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return runDir;
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
getLatestRun(specName: string): string | null {
|
|
116
|
+
const specDir = join(runsDir, specName);
|
|
117
|
+
const latestPath = join(specDir, 'latest');
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
// Check if latest symlink exists
|
|
121
|
+
if (!existsSync(latestPath)) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Read symlink target synchronously
|
|
126
|
+
const target = readdirSync(specDir)
|
|
127
|
+
.filter((name) => name.startsWith('run-'))
|
|
128
|
+
.map((name) => ({
|
|
129
|
+
name,
|
|
130
|
+
path: join(specDir, name),
|
|
131
|
+
mtime: statSync(join(specDir, name)).mtime.getTime(),
|
|
132
|
+
}))
|
|
133
|
+
.sort((a, b) => b.mtime - a.mtime)[0];
|
|
134
|
+
|
|
135
|
+
return target ? target.path : null;
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
listRuns(specName: string): string[] {
|
|
142
|
+
const specDir = join(runsDir, specName);
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
if (!existsSync(specDir)) {
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return readdirSync(specDir)
|
|
150
|
+
.filter((name) => name.startsWith('run-'))
|
|
151
|
+
.map((name) => ({
|
|
152
|
+
name,
|
|
153
|
+
path: join(specDir, name),
|
|
154
|
+
mtime: statSync(join(specDir, name)).mtime.getTime(),
|
|
155
|
+
}))
|
|
156
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
157
|
+
.map((item) => item.path);
|
|
158
|
+
} catch {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
getRunDir(specName: string, runId: string): string {
|
|
164
|
+
return join(runsDir, specName, runId);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
runExists(specName: string, runId: string): boolean {
|
|
168
|
+
const runDir = join(runsDir, specName, runId);
|
|
169
|
+
try {
|
|
170
|
+
return existsSync(runDir) && statSync(runDir).isDirectory();
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets paths for a run directory.
|
|
180
|
+
*
|
|
181
|
+
* @param projectRoot - Root of the project (where .browserflow lives)
|
|
182
|
+
* @param explorationId - Unique exploration identifier
|
|
183
|
+
* @returns Paths object
|
|
184
|
+
*/
|
|
185
|
+
export function getRunPaths(projectRoot: string, explorationId: string): RunDirectoryPaths {
|
|
186
|
+
const root = join(projectRoot, '.browserflow');
|
|
187
|
+
const runsDir = join(root, 'runs');
|
|
188
|
+
const runDir = join(runsDir, explorationId);
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
root,
|
|
192
|
+
runsDir,
|
|
193
|
+
runDir,
|
|
194
|
+
lockfile: join(runDir, 'lockfile.json'),
|
|
195
|
+
screenshotsDir: join(runDir, 'screenshots'),
|
|
196
|
+
reviewFile: join(runDir, 'review.json'),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Generates a unique exploration ID.
|
|
202
|
+
*
|
|
203
|
+
* Format: <spec-slug>-<timestamp>-<random>
|
|
204
|
+
* Example: login-flow-20240115-143022-abc123
|
|
205
|
+
*
|
|
206
|
+
* @param specName - Name of the spec
|
|
207
|
+
* @returns Unique exploration ID
|
|
208
|
+
*/
|
|
209
|
+
export function generateExplorationId(specName: string): string {
|
|
210
|
+
const slug = specName
|
|
211
|
+
.toLowerCase()
|
|
212
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
213
|
+
.replace(/^-|-$/g, '')
|
|
214
|
+
.slice(0, 30);
|
|
215
|
+
|
|
216
|
+
const timestamp = new Date()
|
|
217
|
+
.toISOString()
|
|
218
|
+
.replace(/[-:T]/g, '')
|
|
219
|
+
.slice(0, 14);
|
|
220
|
+
|
|
221
|
+
const random = Math.random().toString(36).slice(2, 8);
|
|
222
|
+
|
|
223
|
+
return `${slug}-${timestamp}-${random}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Gets the screenshot path for a step.
|
|
228
|
+
*
|
|
229
|
+
* @param screenshotsDir - Screenshots directory path
|
|
230
|
+
* @param stepIndex - Step index (0-based)
|
|
231
|
+
* @param type - "before" or "after"
|
|
232
|
+
* @returns Screenshot file path
|
|
233
|
+
*/
|
|
234
|
+
export function getScreenshotPath(
|
|
235
|
+
screenshotsDir: string,
|
|
236
|
+
stepIndex: number,
|
|
237
|
+
type: 'before' | 'after'
|
|
238
|
+
): string {
|
|
239
|
+
return join(screenshotsDir, `step-${stepIndex}-${type}.png`);
|
|
240
|
+
}
|