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