@kintsugi-ai/core 0.1.0

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 (86) hide show
  1. package/dist/auth.d.ts +57 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +151 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/baseline.d.ts +8 -0
  6. package/dist/baseline.d.ts.map +1 -0
  7. package/dist/baseline.js +114 -0
  8. package/dist/baseline.js.map +1 -0
  9. package/dist/benchmark.d.ts +65 -0
  10. package/dist/benchmark.d.ts.map +1 -0
  11. package/dist/benchmark.js +232 -0
  12. package/dist/benchmark.js.map +1 -0
  13. package/dist/capturer.d.ts +14 -0
  14. package/dist/capturer.d.ts.map +1 -0
  15. package/dist/capturer.js +92 -0
  16. package/dist/capturer.js.map +1 -0
  17. package/dist/classifier.d.ts +62 -0
  18. package/dist/classifier.d.ts.map +1 -0
  19. package/dist/classifier.js +204 -0
  20. package/dist/classifier.js.map +1 -0
  21. package/dist/comparator.d.ts +28 -0
  22. package/dist/comparator.d.ts.map +1 -0
  23. package/dist/comparator.js +184 -0
  24. package/dist/comparator.js.map +1 -0
  25. package/dist/comparator.test.d.ts +2 -0
  26. package/dist/comparator.test.d.ts.map +1 -0
  27. package/dist/comparator.test.js +73 -0
  28. package/dist/comparator.test.js.map +1 -0
  29. package/dist/config.d.ts +6 -0
  30. package/dist/config.d.ts.map +1 -0
  31. package/dist/config.js +79 -0
  32. package/dist/config.js.map +1 -0
  33. package/dist/config.test.d.ts +2 -0
  34. package/dist/config.test.d.ts.map +1 -0
  35. package/dist/config.test.js +67 -0
  36. package/dist/config.test.js.map +1 -0
  37. package/dist/domdiff.d.ts +6 -0
  38. package/dist/domdiff.d.ts.map +1 -0
  39. package/dist/domdiff.js +71 -0
  40. package/dist/domdiff.js.map +1 -0
  41. package/dist/feedback.d.ts +9 -0
  42. package/dist/feedback.d.ts.map +1 -0
  43. package/dist/feedback.js +57 -0
  44. package/dist/feedback.js.map +1 -0
  45. package/dist/flowid.d.ts +3 -0
  46. package/dist/flowid.d.ts.map +1 -0
  47. package/dist/flowid.js +11 -0
  48. package/dist/flowid.js.map +1 -0
  49. package/dist/index.d.ts +14 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +14 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/recorder.d.ts +12 -0
  54. package/dist/recorder.d.ts.map +1 -0
  55. package/dist/recorder.js +86 -0
  56. package/dist/recorder.js.map +1 -0
  57. package/dist/setupPrompt.d.ts +10 -0
  58. package/dist/setupPrompt.d.ts.map +1 -0
  59. package/dist/setupPrompt.js +27 -0
  60. package/dist/setupPrompt.js.map +1 -0
  61. package/dist/types.d.ts +144 -0
  62. package/dist/types.d.ts.map +1 -0
  63. package/dist/types.js +12 -0
  64. package/dist/types.js.map +1 -0
  65. package/dist/vision.d.ts +34 -0
  66. package/dist/vision.d.ts.map +1 -0
  67. package/dist/vision.js +139 -0
  68. package/dist/vision.js.map +1 -0
  69. package/package.json +37 -0
  70. package/src/auth.ts +195 -0
  71. package/src/baseline.ts +125 -0
  72. package/src/benchmark.ts +307 -0
  73. package/src/capturer.ts +105 -0
  74. package/src/classifier.ts +258 -0
  75. package/src/comparator.test.ts +97 -0
  76. package/src/comparator.ts +217 -0
  77. package/src/config.test.ts +80 -0
  78. package/src/config.ts +83 -0
  79. package/src/domdiff.ts +62 -0
  80. package/src/feedback.ts +64 -0
  81. package/src/flowid.ts +14 -0
  82. package/src/index.ts +13 -0
  83. package/src/recorder.ts +96 -0
  84. package/src/setupPrompt.ts +26 -0
  85. package/src/types.ts +144 -0
  86. package/tsconfig.json +9 -0
@@ -0,0 +1,125 @@
1
+ import { promises as fs } from 'fs';
2
+ import path from 'path';
3
+ import type { FlowMetadata, FlowRecording, CaptureResult } from './types.js';
4
+
5
+ export function getFlowDir(projectDir: string, name: string): string {
6
+ return path.join(projectDir, '.kintsugi', 'flows', name);
7
+ }
8
+
9
+ export async function listFlows(projectDir: string): Promise<FlowMetadata[]> {
10
+ const flowsDir = path.join(projectDir, '.kintsugi', 'flows');
11
+ try {
12
+ const entries = await fs.readdir(flowsDir, { withFileTypes: true });
13
+ const flows: FlowMetadata[] = [];
14
+ for (const entry of entries) {
15
+ if (entry.isDirectory()) {
16
+ const metaPath = path.join(flowsDir, entry.name, 'metadata.json');
17
+ try {
18
+ const data = await fs.readFile(metaPath, 'utf-8');
19
+ flows.push(JSON.parse(data) as FlowMetadata);
20
+ } catch (e) {
21
+ // Ignore invalid directories
22
+ }
23
+ }
24
+ }
25
+ return flows;
26
+ } catch (e) {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ export async function loadFlow(projectDir: string, name: string): Promise<FlowRecording> {
32
+ const flowDir = getFlowDir(projectDir, name);
33
+ const metaPath = path.join(flowDir, 'metadata.json');
34
+ const stepsPath = path.join(flowDir, 'steps.json');
35
+
36
+ const [metaData, stepsData] = await Promise.all([
37
+ fs.readFile(metaPath, 'utf-8'),
38
+ fs.readFile(stepsPath, 'utf-8'),
39
+ ]);
40
+
41
+ const metadata = JSON.parse(metaData) as FlowMetadata;
42
+ const steps = JSON.parse(stepsData) as FlowRecording['steps'];
43
+
44
+ const screenshotsDir = path.join(flowDir, 'screenshots');
45
+ let screenshotPaths: string[] = [];
46
+ try {
47
+ const files = await fs.readdir(screenshotsDir);
48
+ // Explicitly match step screenshots for each step in order
49
+ const stepFiles: string[] = [];
50
+ let allStepsFound = true;
51
+ for (let i = 0; i < steps.length; i++) {
52
+ const candidates = [`step_${i}.png`, `step-${String(i).padStart(3, '0')}.png`, `step-${i}.png`];
53
+ const match = candidates.find(c => files.includes(c));
54
+ if (match) {
55
+ stepFiles.push(path.join(screenshotsDir, match));
56
+ } else {
57
+ allStepsFound = false;
58
+ break;
59
+ }
60
+ }
61
+
62
+ if (allStepsFound && stepFiles.length > 0) {
63
+ screenshotPaths = stepFiles;
64
+ } else {
65
+ screenshotPaths = files
66
+ .filter((f) => f.endsWith('.png'))
67
+ .sort()
68
+ .map((f) => path.join(screenshotsDir, f));
69
+ }
70
+ } catch (e) {
71
+ // No screenshots directory
72
+ }
73
+
74
+ const videoPath = path.join(flowDir, 'video.webm');
75
+ const videoExists = await fs.stat(videoPath).then(() => true).catch(() => false);
76
+
77
+ // Baseline aria snapshots (semantic DOM state per step)
78
+ const ariaDir = path.join(flowDir, 'aria');
79
+ const ariaSnapshotPaths: (string | undefined)[] = [];
80
+ for (let i = 0; i < steps.length; i++) {
81
+ const ariaPath = path.join(ariaDir, `step_${i}.yml`);
82
+ const exists = await fs.stat(ariaPath).then(() => true).catch(() => false);
83
+ ariaSnapshotPaths.push(exists ? ariaPath : undefined);
84
+ }
85
+
86
+ return {
87
+ metadata,
88
+ steps,
89
+ screenshotPaths,
90
+ ariaSnapshotPaths: ariaSnapshotPaths.some(p => p !== undefined) ? ariaSnapshotPaths as string[] : undefined,
91
+ videoPath: videoExists ? videoPath : undefined,
92
+ };
93
+ }
94
+
95
+ export async function saveFlow(projectDir: string, recording: FlowRecording): Promise<void> {
96
+ const flowDir = getFlowDir(projectDir, recording.metadata.name);
97
+ await fs.mkdir(flowDir, { recursive: true });
98
+ await fs.mkdir(path.join(flowDir, 'screenshots'), { recursive: true });
99
+
100
+ await fs.writeFile(path.join(flowDir, 'metadata.json'), JSON.stringify(recording.metadata, null, 2));
101
+ await fs.writeFile(path.join(flowDir, 'steps.json'), JSON.stringify(recording.steps, null, 2));
102
+ }
103
+
104
+ export async function deleteFlow(projectDir: string, name: string): Promise<void> {
105
+ const flowDir = getFlowDir(projectDir, name);
106
+ await fs.rm(flowDir, { recursive: true, force: true });
107
+ }
108
+
109
+ export async function updateBaseline(projectDir: string, name: string, capture: CaptureResult): Promise<void> {
110
+ const flowDir = getFlowDir(projectDir, name);
111
+ const screenshotsDir = path.join(flowDir, 'screenshots');
112
+ const ariaDir = path.join(flowDir, 'aria');
113
+
114
+ await fs.mkdir(screenshotsDir, { recursive: true });
115
+ await fs.mkdir(ariaDir, { recursive: true });
116
+
117
+ for (let i = 0; i < capture.screenshots.length; i++) {
118
+ const screenshot = capture.screenshots[i];
119
+ await fs.writeFile(path.join(screenshotsDir, `step_${i}.png`), screenshot);
120
+ const aria = capture.ariaSnapshots?.[i];
121
+ if (aria !== undefined) {
122
+ await fs.writeFile(path.join(ariaDir, `step_${i}.yml`), aria, 'utf-8');
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,307 @@
1
+ import { PNG } from 'pngjs';
2
+ import { computePixelDiff, compareScreenshots } from './comparator.js';
3
+ import { DiffResult } from './types.js';
4
+
5
+ export interface LatencyMetric {
6
+ name: string;
7
+ targetMs: number;
8
+ actualMeanMs: number;
9
+ p50Ms: number;
10
+ p95Ms: number;
11
+ passed: boolean;
12
+ }
13
+
14
+ export interface AccuracyMetric {
15
+ name: string;
16
+ targetPercent: number;
17
+ actualPercent: number;
18
+ description: string;
19
+ passed: boolean;
20
+ }
21
+
22
+ export interface BenchmarkReport {
23
+ timestamp: string;
24
+ performance: {
25
+ metrics: LatencyMetric[];
26
+ allPassed: boolean;
27
+ };
28
+ accuracy: {
29
+ metrics: AccuracyMetric[];
30
+ truePositiveRate: number;
31
+ falsePositiveRate: number;
32
+ allPassed: boolean;
33
+ };
34
+ summary: {
35
+ totalBenchmarks: number;
36
+ passedBenchmarks: number;
37
+ launchReady: boolean;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Creates a synthetic UI PNG buffer of the given width and height.
43
+ */
44
+ export function createSyntheticUiPng(options?: {
45
+ width?: number;
46
+ height?: number;
47
+ removeButton?: boolean;
48
+ shiftLayout?: boolean;
49
+ addNoise?: boolean;
50
+ noiseAmount?: number;
51
+ }): Buffer {
52
+ const width = options?.width || 1280;
53
+ const height = options?.height || 720;
54
+ const png = new PNG({ width, height });
55
+
56
+ // Background - light gray #F8FAFC
57
+ for (let y = 0; y < height; y++) {
58
+ for (let x = 0; x < width; x++) {
59
+ const idx = (y * width + x) * 4;
60
+ png.data[idx] = 248;
61
+ png.data[idx + 1] = 250;
62
+ png.data[idx + 2] = 252;
63
+ png.data[idx + 3] = 255;
64
+ }
65
+ }
66
+
67
+ // Draw Navbar (0 to 60px height) - #1E293B
68
+ for (let y = 0; y < 60; y++) {
69
+ for (let x = 0; x < width; x++) {
70
+ const idx = (y * width + x) * 4;
71
+ png.data[idx] = 30;
72
+ png.data[idx + 1] = 41;
73
+ png.data[idx + 2] = 59;
74
+ }
75
+ }
76
+
77
+ // Draw Card Container
78
+ const cardTop = options?.shiftLayout ? 120 : 100;
79
+ const cardLeft = 100;
80
+ const cardWidth = width - 200;
81
+ const cardHeight = 400;
82
+
83
+ for (let y = cardTop; y < cardTop + cardHeight; y++) {
84
+ for (let x = cardLeft; x < cardLeft + cardWidth; x++) {
85
+ const idx = (y * width + x) * 4;
86
+ png.data[idx] = 255;
87
+ png.data[idx + 1] = 255;
88
+ png.data[idx + 2] = 255;
89
+ }
90
+ }
91
+
92
+ // Draw Button (e.g. checkout button) inside card if not removed
93
+ if (!options?.removeButton) {
94
+ const btnTop = cardTop + 300;
95
+ const btnLeft = cardLeft + 50;
96
+ const btnWidth = 200;
97
+ const btnHeight = 48;
98
+
99
+ for (let y = btnTop; y < btnTop + btnHeight; y++) {
100
+ for (let x = btnLeft; x < btnLeft + btnWidth; x++) {
101
+ const idx = (y * width + x) * 4;
102
+ png.data[idx] = 37;
103
+ png.data[idx + 1] = 99;
104
+ png.data[idx + 2] = 235; // Blue #2563EB
105
+ }
106
+ }
107
+ }
108
+
109
+ // Add subtle subpixel/anti-aliasing noise if requested
110
+ if (options?.addNoise) {
111
+ const amount = options.noiseAmount || 0.0005; // 0.05% of pixels
112
+ const totalPixels = width * height;
113
+ const noisePixels = Math.floor(totalPixels * amount);
114
+
115
+ for (let i = 0; i < noisePixels; i++) {
116
+ const px = Math.floor(Math.random() * totalPixels);
117
+ const idx = px * 4;
118
+ // Slight luminance jitter (+/- 3 in color value)
119
+ png.data[idx] = Math.max(0, Math.min(255, png.data[idx] + (Math.random() > 0.5 ? 2 : -2)));
120
+ png.data[idx + 1] = Math.max(0, Math.min(255, png.data[idx + 1] + (Math.random() > 0.5 ? 2 : -2)));
121
+ png.data[idx + 2] = Math.max(0, Math.min(255, png.data[idx + 2] + (Math.random() > 0.5 ? 2 : -2)));
122
+ }
123
+ }
124
+
125
+ return PNG.sync.write(png);
126
+ }
127
+
128
+ function calculatePercentiles(values: number[]): { p50: number; p95: number; mean: number } {
129
+ const sorted = [...values].sort((a, b) => a - b);
130
+ const p50 = sorted[Math.floor(sorted.length * 0.5)] || 0;
131
+ const p95 = sorted[Math.floor(sorted.length * 0.95)] || sorted[sorted.length - 1] || 0;
132
+ const mean = values.reduce((sum, v) => sum + v, 0) / (values.length || 1);
133
+ return { p50, p95, mean };
134
+ }
135
+
136
+ /**
137
+ * Runs the performance latency benchmark suite.
138
+ */
139
+ export async function runPerformanceBenchmark(iterations = 10): Promise<{ metrics: LatencyMetric[]; allPassed: boolean }> {
140
+ const baseImg = createSyntheticUiPng();
141
+ const minorImg = createSyntheticUiPng({ addNoise: true, noiseAmount: 0.001 });
142
+
143
+ // 1. Benchmark pixelmatch on 1280x720
144
+ const pixelDiffTimes: number[] = [];
145
+ for (let i = 0; i < iterations; i++) {
146
+ const t0 = performance.now();
147
+ await computePixelDiff(baseImg, baseImg);
148
+ pixelDiffTimes.push(performance.now() - t0);
149
+ }
150
+ const pixelStats = calculatePercentiles(pixelDiffTimes);
151
+
152
+ // 2. Fast-path comparison latency (identical)
153
+ const fastPathTimes: number[] = [];
154
+ for (let i = 0; i < iterations; i++) {
155
+ const t0 = performance.now();
156
+ await compareScreenshots(baseImg, baseImg);
157
+ fastPathTimes.push(performance.now() - t0);
158
+ }
159
+ const fastPathStats = calculatePercentiles(fastPathTimes);
160
+
161
+ // 3. Minor-band comparison latency (sub-threshold noise)
162
+ const tier2Times: number[] = [];
163
+ for (let i = 0; i < iterations; i++) {
164
+ const t0 = performance.now();
165
+ await compareScreenshots(baseImg, minorImg);
166
+ tier2Times.push(performance.now() - t0);
167
+ }
168
+ const tier2Stats = calculatePercentiles(tier2Times);
169
+
170
+ const metrics: LatencyMetric[] = [
171
+ {
172
+ name: 'Pixel Diff Latency (1280x720)',
173
+ targetMs: 50,
174
+ actualMeanMs: pixelStats.mean,
175
+ p50Ms: pixelStats.p50,
176
+ p95Ms: pixelStats.p95,
177
+ passed: pixelStats.p95 <= 75,
178
+ },
179
+ {
180
+ name: 'Fast-Path Hook Latency (Identical)',
181
+ targetMs: 50,
182
+ actualMeanMs: fastPathStats.mean,
183
+ p50Ms: fastPathStats.p50,
184
+ p95Ms: fastPathStats.p95,
185
+ passed: fastPathStats.p95 <= 75,
186
+ },
187
+ {
188
+ name: 'Minor-Band Hook Latency (Sub-threshold Noise)',
189
+ targetMs: 250,
190
+ actualMeanMs: tier2Stats.mean,
191
+ p50Ms: tier2Stats.p50,
192
+ p95Ms: tier2Stats.p95,
193
+ passed: tier2Stats.p95 <= 300,
194
+ },
195
+ ];
196
+
197
+ return {
198
+ metrics,
199
+ allPassed: metrics.every(m => m.passed),
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Runs the accuracy benchmark suite checking True Positive Rate and False Positive Rate.
205
+ */
206
+ export async function runAccuracyBenchmark(sampleSize = 20): Promise<{
207
+ metrics: AccuracyMetric[];
208
+ truePositiveRate: number;
209
+ falsePositiveRate: number;
210
+ allPassed: boolean;
211
+ }> {
212
+ const baseImg = createSyntheticUiPng();
213
+
214
+ let truePositives = 0;
215
+ let falseNegatives = 0;
216
+ let trueNegatives = 0;
217
+ let falsePositives = 0;
218
+
219
+ // Test True Positive: Actual Regressions (missing button, layout shift)
220
+ for (let i = 0; i < sampleSize; i++) {
221
+ const isShift = i % 2 === 0;
222
+ const brokenImg = createSyntheticUiPng({
223
+ removeButton: !isShift,
224
+ shiftLayout: isShift,
225
+ });
226
+
227
+ const result = await compareScreenshots(baseImg, brokenImg);
228
+ if (result.result === DiffResult.CHANGED || result.result === DiffResult.BROKEN) {
229
+ truePositives++;
230
+ } else {
231
+ falseNegatives++;
232
+ }
233
+ }
234
+
235
+ // Test False Positive: Benign changes (identical or sub-pixel noise)
236
+ for (let i = 0; i < sampleSize; i++) {
237
+ const isNoise = i % 2 === 0;
238
+ const benignImg = isNoise
239
+ ? createSyntheticUiPng({ addNoise: true, noiseAmount: 0.0003 })
240
+ : createSyntheticUiPng();
241
+
242
+ const result = await compareScreenshots(baseImg, benignImg);
243
+ if (result.result === DiffResult.IDENTICAL || result.result === DiffResult.MINOR) {
244
+ trueNegatives++;
245
+ } else {
246
+ falsePositives++;
247
+ }
248
+ }
249
+
250
+ const tpr = (truePositives / (truePositives + falseNegatives)) * 100;
251
+ const fpr = (falsePositives / (falsePositives + trueNegatives)) * 100;
252
+
253
+ const metrics: AccuracyMetric[] = [
254
+ {
255
+ name: 'True Positive Rate (Catches Real Regressions)',
256
+ targetPercent: 95.0,
257
+ actualPercent: tpr,
258
+ description: 'Percentage of genuine visual UI defects successfully caught',
259
+ passed: tpr >= 95.0,
260
+ },
261
+ {
262
+ name: 'False Positive Rate (Filters Benign Shifts)',
263
+ targetPercent: 5.0,
264
+ actualPercent: fpr,
265
+ description: 'Percentage of benign subpixel/anti-aliasing changes wrongly flagged',
266
+ passed: fpr <= 5.0,
267
+ },
268
+ {
269
+ name: 'Identical Match Precision',
270
+ targetPercent: 100.0,
271
+ actualPercent: ((trueNegatives / sampleSize) * 100),
272
+ description: 'Accuracy when comparing completely unchanged views',
273
+ passed: ((trueNegatives / sampleSize) * 100) >= 95.0,
274
+ },
275
+ ];
276
+
277
+ return {
278
+ metrics,
279
+ truePositiveRate: tpr,
280
+ falsePositiveRate: fpr,
281
+ allPassed: metrics.every(m => m.passed),
282
+ };
283
+ }
284
+
285
+ /**
286
+ * Runs the complete benchmark suite (performance and accuracy).
287
+ */
288
+ export async function runAllBenchmarks(): Promise<BenchmarkReport> {
289
+ const perf = await runPerformanceBenchmark(10);
290
+ const acc = await runAccuracyBenchmark(20);
291
+
292
+ const totalBenchmarks = perf.metrics.length + acc.metrics.length;
293
+ const passedBenchmarks =
294
+ perf.metrics.filter(m => m.passed).length +
295
+ acc.metrics.filter(m => m.passed).length;
296
+
297
+ return {
298
+ timestamp: new Date().toISOString(),
299
+ performance: perf,
300
+ accuracy: acc,
301
+ summary: {
302
+ totalBenchmarks,
303
+ passedBenchmarks,
304
+ launchReady: perf.allPassed && acc.allPassed,
305
+ },
306
+ };
307
+ }
@@ -0,0 +1,105 @@
1
+ import { chromium, type Browser } from 'playwright';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import type { CaptureResult, FlowRecording } from './types.js';
5
+
6
+ let browserInstance: Browser | null = null;
7
+
8
+ export async function getBrowser(): Promise<Browser> {
9
+ if (!browserInstance) {
10
+ browserInstance = await chromium.launch({ headless: true });
11
+ }
12
+ return browserInstance;
13
+ }
14
+
15
+ export async function closeBrowser(): Promise<void> {
16
+ if (browserInstance) {
17
+ await browserInstance.close();
18
+ browserInstance = null;
19
+ }
20
+ }
21
+
22
+ export async function captureCurrentState(options: {
23
+ url: string;
24
+ flow: FlowRecording;
25
+ outputDir: string;
26
+ viewport?: { width: number; height: number };
27
+ }): Promise<CaptureResult> {
28
+ const { url, flow, outputDir, viewport = { width: 1280, height: 720 } } = options;
29
+ const browser = await getBrowser();
30
+
31
+ const videoDir = path.join(outputDir, 'video');
32
+ await fs.mkdir(videoDir, { recursive: true });
33
+
34
+ const context = await browser.newContext({
35
+ viewport,
36
+ recordVideo: { dir: videoDir }
37
+ });
38
+
39
+ const page = await context.newPage();
40
+ const result: CaptureResult = {
41
+ screenshots: [],
42
+ screenshotPaths: [],
43
+ ariaSnapshots: [],
44
+ completedSteps: 0,
45
+ totalSteps: flow.steps.length,
46
+ errors: []
47
+ };
48
+
49
+ await fs.mkdir(outputDir, { recursive: true });
50
+
51
+ try {
52
+ let navigated = false;
53
+ for (let i = 0; i < flow.steps.length; i++) {
54
+ const step = flow.steps[i];
55
+ try {
56
+ if (!navigated && page.url() === 'about:blank' && step.action !== 'navigate') {
57
+ await page.goto(url, { waitUntil: 'networkidle' });
58
+ navigated = true;
59
+ }
60
+
61
+ if (step.action === 'navigate') {
62
+ await page.goto(step.url || url, { waitUntil: 'networkidle' });
63
+ navigated = true;
64
+ } else if (step.action === 'click' && step.selector) {
65
+ await page.click(step.selector, { timeout: step.timeout || 5000 });
66
+ } else if (step.action === 'type' && step.selector && step.value !== undefined) {
67
+ await page.fill(step.selector, step.value, { timeout: step.timeout || 5000 });
68
+ } else if (step.action === 'scroll' && step.position) {
69
+ await page.mouse.wheel(step.position.x, step.position.y);
70
+ } else if (step.action === 'wait') {
71
+ await page.waitForTimeout(step.timeout || 1000);
72
+ }
73
+
74
+ await page.waitForTimeout(500);
75
+
76
+ const screenshotPath = path.join(outputDir, `step_${i}.png`);
77
+ const buffer = await page.screenshot({ path: screenshotPath });
78
+
79
+ // Semantic DOM state for the classifier tier (diffable as text)
80
+ let aria = '';
81
+ try {
82
+ aria = await page.locator('body').ariaSnapshot();
83
+ } catch {
84
+ aria = '';
85
+ }
86
+ await fs.writeFile(path.join(outputDir, `aria_step_${i}.yml`), aria, 'utf-8');
87
+
88
+ result.screenshots.push(buffer);
89
+ result.screenshotPaths.push(screenshotPath);
90
+ result.ariaSnapshots.push(aria);
91
+ result.completedSteps++;
92
+ } catch (err: any) {
93
+ result.errors.push(`Step ${i} (${step.action}) failed: ${err.message}`);
94
+ break;
95
+ }
96
+ }
97
+ } finally {
98
+ const videoObj = await page.video();
99
+ const videoPath = videoObj ? await videoObj.path() : undefined;
100
+ result.videoPath = videoPath;
101
+ await context.close();
102
+ }
103
+
104
+ return result;
105
+ }