@kintsugi-ai/core 0.1.0 → 0.2.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.
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/dist/agentConfig.d.ts +17 -0
- package/dist/agentConfig.js +91 -0
- package/dist/agentInstructions.d.ts +11 -0
- package/dist/agentInstructions.js +58 -0
- package/dist/auth.d.ts +35 -0
- package/dist/auth.js +121 -1
- package/dist/baseline.d.ts +9 -0
- package/dist/baseline.js +47 -0
- package/dist/capturer.js +6 -0
- package/dist/classifier-state.d.ts +13 -0
- package/dist/classifier-state.js +39 -0
- package/dist/comparator.d.ts +7 -0
- package/dist/comparator.js +6 -0
- package/dist/config.js +0 -6
- package/dist/escalation.d.ts +55 -0
- package/dist/escalation.js +99 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/net.d.ts +7 -0
- package/dist/net.js +20 -0
- package/dist/setupPrompt.js +4 -2
- package/dist/types.d.ts +9 -4
- package/package.json +16 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/baseline.d.ts.map +0 -1
- package/dist/baseline.js.map +0 -1
- package/dist/benchmark.d.ts.map +0 -1
- package/dist/benchmark.js.map +0 -1
- package/dist/capturer.d.ts.map +0 -1
- package/dist/capturer.js.map +0 -1
- package/dist/classifier.d.ts.map +0 -1
- package/dist/classifier.js.map +0 -1
- package/dist/comparator.d.ts.map +0 -1
- package/dist/comparator.js.map +0 -1
- package/dist/comparator.test.d.ts +0 -2
- package/dist/comparator.test.d.ts.map +0 -1
- package/dist/comparator.test.js +0 -73
- package/dist/comparator.test.js.map +0 -1
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js.map +0 -1
- package/dist/config.test.d.ts +0 -2
- package/dist/config.test.d.ts.map +0 -1
- package/dist/config.test.js +0 -67
- package/dist/config.test.js.map +0 -1
- package/dist/domdiff.d.ts.map +0 -1
- package/dist/domdiff.js.map +0 -1
- package/dist/feedback.d.ts.map +0 -1
- package/dist/feedback.js.map +0 -1
- package/dist/flowid.d.ts.map +0 -1
- package/dist/flowid.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/recorder.d.ts.map +0 -1
- package/dist/recorder.js.map +0 -1
- package/dist/setupPrompt.d.ts.map +0 -1
- package/dist/setupPrompt.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/vision.d.ts +0 -34
- package/dist/vision.d.ts.map +0 -1
- package/dist/vision.js +0 -139
- package/dist/vision.js.map +0 -1
- package/src/auth.ts +0 -195
- package/src/baseline.ts +0 -125
- package/src/benchmark.ts +0 -307
- package/src/capturer.ts +0 -105
- package/src/classifier.ts +0 -258
- package/src/comparator.test.ts +0 -97
- package/src/comparator.ts +0 -217
- package/src/config.test.ts +0 -80
- package/src/config.ts +0 -83
- package/src/domdiff.ts +0 -62
- package/src/feedback.ts +0 -64
- package/src/flowid.ts +0 -14
- package/src/index.ts +0 -13
- package/src/recorder.ts +0 -96
- package/src/setupPrompt.ts +0 -26
- package/src/types.ts +0 -144
- package/tsconfig.json +0 -9
package/src/benchmark.ts
DELETED
|
@@ -1,307 +0,0 @@
|
|
|
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
|
-
}
|
package/src/capturer.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
|
-
}
|
package/src/classifier.ts
DELETED
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import { diffAriaSnapshots } from './domdiff.js';
|
|
5
|
-
import type { ClassifierConfig } from './types.js';
|
|
6
|
-
|
|
7
|
-
export interface ClassifierVerdict {
|
|
8
|
-
intentional: boolean;
|
|
9
|
-
/** 0..1 certainty reported by the classifier */
|
|
10
|
-
confidence: number;
|
|
11
|
-
/** One sentence: what changed and — if not intentional — how to fix it */
|
|
12
|
-
reasoning: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** Metadata about the flow being classified (the hosted API uses it for the flow gate + usage log) */
|
|
16
|
-
export interface ClassifyMeta {
|
|
17
|
-
flowName?: string;
|
|
18
|
-
flowHash?: string;
|
|
19
|
-
step?: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Thrown when the hosted API returns 429 — either the free-plan flow limit
|
|
24
|
-
* (distinct flows) or the monthly abuse cap. Carries the payload surfaced
|
|
25
|
-
* in agent feedback and UI ("10 of 10 flows guarded — upgrade for unlimited").
|
|
26
|
-
*/
|
|
27
|
-
export class FlowLimitError extends Error {
|
|
28
|
-
readonly kind: 'flow_limit_reached' | 'monthly_cap_reached';
|
|
29
|
-
readonly flowsUsed?: number;
|
|
30
|
-
readonly flowLimit?: number;
|
|
31
|
-
readonly upgradeUrl?: string;
|
|
32
|
-
|
|
33
|
-
constructor(body: { error?: string; flowsUsed?: number; flowLimit?: number; upgradeUrl?: string }) {
|
|
34
|
-
super(body.error ?? 'limit_reached');
|
|
35
|
-
this.name = 'FlowLimitError';
|
|
36
|
-
this.kind = body.error === 'monthly_cap_reached' ? 'monthly_cap_reached' : 'flow_limit_reached';
|
|
37
|
-
this.flowsUsed = body.flowsUsed;
|
|
38
|
-
this.flowLimit = body.flowLimit;
|
|
39
|
-
this.upgradeUrl = body.upgradeUrl;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const SYSTEM_PROMPT = [
|
|
44
|
-
'You are a strict UI regression classifier for an automated QA tool that guards AI coding agents.',
|
|
45
|
-
'You will be given: (1) a structural diff of the page accessibility tree between the accepted baseline and the current state after a code change, and (2) optional context about what the developer/agent was asked to do in the latest turn.',
|
|
46
|
-
'The baseline is the ACCEPTED, WORKING state. Removed or broken elements (buttons, forms, prices, text, navigation) are regressions even if the remaining page looks coherent.',
|
|
47
|
-
'If no context is provided, be conservative: significant structural changes should be classified as not intentional.',
|
|
48
|
-
'If context IS provided and the diff implements exactly what the context asked for, classify it as intentional with high confidence — a requested change is not a regression.',
|
|
49
|
-
'Benign changes (reordered identical items, minor text edits, added requested features) are intentional.',
|
|
50
|
-
'Answer with ONLY a JSON object, no other text:',
|
|
51
|
-
'{"intentional": <true|false>, "confidence": <number between 0 and 1>, "reasoning": "<exactly one sentence: what changed, and if not intentional, the specific fix>"}',
|
|
52
|
-
].join('\n');
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Classifies a visual change as intentional or a regression. Provider
|
|
56
|
-
* 'kintsugi' POSTs to the hosted classifier API (which owns the model and
|
|
57
|
-
* the flow gate); 'openai-compatible' sends the chat-completions prompt
|
|
58
|
-
* below to any OpenAI-compatible endpoint.
|
|
59
|
-
*/
|
|
60
|
-
export async function classifyChange(options: {
|
|
61
|
-
baselineAria: string;
|
|
62
|
-
currentAria: string;
|
|
63
|
-
context?: string;
|
|
64
|
-
config: ClassifierConfig;
|
|
65
|
-
apiToken: string;
|
|
66
|
-
meta?: ClassifyMeta;
|
|
67
|
-
logger?: { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
|
|
68
|
-
}): Promise<ClassifierVerdict> {
|
|
69
|
-
if (options.config.provider === 'kintsugi') {
|
|
70
|
-
return classifyViaKintsugi(options);
|
|
71
|
-
}
|
|
72
|
-
return classifyViaOpenAiCompatible(options);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Hosted kintsugi API path — the server computes the diff, picks the model, and enforces the flow gate. */
|
|
76
|
-
async function classifyViaKintsugi(options: {
|
|
77
|
-
baselineAria: string;
|
|
78
|
-
currentAria: string;
|
|
79
|
-
context?: string;
|
|
80
|
-
config: ClassifierConfig;
|
|
81
|
-
apiToken: string;
|
|
82
|
-
meta?: ClassifyMeta;
|
|
83
|
-
}): Promise<ClassifierVerdict> {
|
|
84
|
-
const { baselineAria, currentAria, context, config, apiToken, meta } = options;
|
|
85
|
-
const url = `${config.endpoint.replace(/\/$/, '')}/classify`;
|
|
86
|
-
const controller = new AbortController();
|
|
87
|
-
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
88
|
-
let response: Response;
|
|
89
|
-
try {
|
|
90
|
-
response = await fetch(url, {
|
|
91
|
-
method: 'POST',
|
|
92
|
-
headers: {
|
|
93
|
-
'Content-Type': 'application/json',
|
|
94
|
-
Authorization: `Bearer ${apiToken}`,
|
|
95
|
-
},
|
|
96
|
-
body: JSON.stringify({ baselineAria, currentAria, context: context ?? null, meta: meta ?? null }),
|
|
97
|
-
signal: controller.signal,
|
|
98
|
-
});
|
|
99
|
-
} finally {
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
if (response.status === 429) {
|
|
104
|
-
const body = await response.json().catch(() => ({}));
|
|
105
|
-
throw new FlowLimitError(body);
|
|
106
|
-
}
|
|
107
|
-
if (!response.ok) {
|
|
108
|
-
const detail = await response.text().catch(() => '');
|
|
109
|
-
throw new Error(`kintsugi api ${response.status}: ${detail.slice(0, 300)}`);
|
|
110
|
-
}
|
|
111
|
-
const payload = (await response.json()) as Partial<ClassifierVerdict>;
|
|
112
|
-
if (typeof payload.intentional !== 'boolean' || typeof payload.confidence !== 'number') {
|
|
113
|
-
throw new Error(`unexpected kintsugi api response: ${JSON.stringify(payload).slice(0, 200)}`);
|
|
114
|
-
}
|
|
115
|
-
return {
|
|
116
|
-
intentional: payload.intentional,
|
|
117
|
-
confidence: payload.confidence,
|
|
118
|
-
reasoning: payload.reasoning ?? '',
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async function classifyViaOpenAiCompatible(options: {
|
|
123
|
-
baselineAria: string;
|
|
124
|
-
currentAria: string;
|
|
125
|
-
context?: string;
|
|
126
|
-
config: ClassifierConfig;
|
|
127
|
-
apiToken: string;
|
|
128
|
-
logger?: { info: (msg: string, data?: unknown) => void; warn: (msg: string, data?: unknown) => void };
|
|
129
|
-
}): Promise<ClassifierVerdict> {
|
|
130
|
-
const { baselineAria, currentAria, context, config, apiToken, logger } = options;
|
|
131
|
-
|
|
132
|
-
const diff = diffAriaSnapshots(baselineAria, currentAria);
|
|
133
|
-
const userText = [
|
|
134
|
-
'Accessibility-tree diff (baseline → current). Lines prefixed "REMOVED:" were removed from the page, "ADDED:" were added:',
|
|
135
|
-
diff,
|
|
136
|
-
'',
|
|
137
|
-
context
|
|
138
|
-
? `Context from the latest turn (what the agent was asked to do):\n"${context}"`
|
|
139
|
-
: 'No context available about the intended change.',
|
|
140
|
-
'',
|
|
141
|
-
'Classify this change as intentional or not. Reply with ONLY the JSON object.',
|
|
142
|
-
].join('\n');
|
|
143
|
-
|
|
144
|
-
const body = {
|
|
145
|
-
model: config.model,
|
|
146
|
-
temperature: 0,
|
|
147
|
-
max_tokens: 120,
|
|
148
|
-
messages: [
|
|
149
|
-
{ role: 'system', content: SYSTEM_PROMPT },
|
|
150
|
-
{ role: 'user', content: userText },
|
|
151
|
-
],
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const url = `${config.endpoint.replace(/\/$/, '')}/chat/completions`;
|
|
155
|
-
const controller = new AbortController();
|
|
156
|
-
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
157
|
-
let response: Response;
|
|
158
|
-
try {
|
|
159
|
-
response = await fetch(url, {
|
|
160
|
-
method: 'POST',
|
|
161
|
-
headers: {
|
|
162
|
-
'Content-Type': 'application/json',
|
|
163
|
-
Authorization: `Bearer ${apiToken}`,
|
|
164
|
-
},
|
|
165
|
-
body: JSON.stringify(body),
|
|
166
|
-
signal: controller.signal,
|
|
167
|
-
});
|
|
168
|
-
} finally {
|
|
169
|
-
clearTimeout(timer);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (!response.ok) {
|
|
173
|
-
const detail = await response.text().catch(() => '');
|
|
174
|
-
throw new Error(`classifier endpoint ${response.status}: ${detail.slice(0, 300)}`);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const payload = JSON.parse(await response.text()) as {
|
|
178
|
-
choices?: { message?: { content?: string } }[];
|
|
179
|
-
};
|
|
180
|
-
const content = payload.choices?.[0]?.message?.content ?? '';
|
|
181
|
-
logger?.info('classifier responded', { content: content.slice(0, 300) });
|
|
182
|
-
return parseVerdict(content);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/** Robustly extracts {intentional, confidence, reasoning} from the model output. Fails safe to not-intentional. */
|
|
186
|
-
export function parseVerdict(content: string): ClassifierVerdict {
|
|
187
|
-
const text = content.trim();
|
|
188
|
-
|
|
189
|
-
// Preferred: the JSON object we asked for
|
|
190
|
-
const jsonMatch = text.match(/\{[\s\S]*?\}/);
|
|
191
|
-
if (jsonMatch) {
|
|
192
|
-
try {
|
|
193
|
-
const parsed = JSON.parse(jsonMatch[0]) as {
|
|
194
|
-
intentional?: boolean;
|
|
195
|
-
confidence?: number;
|
|
196
|
-
reasoning?: string;
|
|
197
|
-
};
|
|
198
|
-
if (typeof parsed.intentional === 'boolean') {
|
|
199
|
-
return {
|
|
200
|
-
intentional: parsed.intentional,
|
|
201
|
-
confidence: typeof parsed.confidence === 'number'
|
|
202
|
-
? Math.min(1, Math.max(0, parsed.confidence))
|
|
203
|
-
: 0.5,
|
|
204
|
-
reasoning: typeof parsed.reasoning === 'string' && parsed.reasoning.trim()
|
|
205
|
-
? parsed.reasoning.trim()
|
|
206
|
-
: 'No reasoning provided by classifier.',
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
} catch {
|
|
210
|
-
// fall through to fail-safe
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Unparseable — fail safe: treat as an unresolved regression
|
|
215
|
-
return {
|
|
216
|
-
intentional: false,
|
|
217
|
-
confidence: 0.5,
|
|
218
|
-
reasoning: 'Classifier output was unparseable; treat this change as a regression and inspect the aria diff manually.',
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Resolves the API token: process env first, then the project's
|
|
224
|
-
* `<projectDir>/.kintsugi/.env`, then the machine-global `~/.kintsugi/.env`
|
|
225
|
-
* (so one key works across all projects).
|
|
226
|
-
*/
|
|
227
|
-
export function resolveApiToken(tokenEnvVar: string, projectDir?: string): string | undefined {
|
|
228
|
-
if (process.env[tokenEnvVar]) return process.env[tokenEnvVar];
|
|
229
|
-
if (projectDir) loadKintsugiEnv(projectDir);
|
|
230
|
-
loadEnvFile(globalKintsugiEnvPath());
|
|
231
|
-
return process.env[tokenEnvVar];
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/** Path of the machine-global kintsugi env file — where the hosted API key lives. */
|
|
235
|
-
export function globalKintsugiEnvPath(): string {
|
|
236
|
-
return path.join(os.homedir(), '.kintsugi', '.env');
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/** Loads KEY=VALUE pairs from <projectDir>/.kintsugi/.env into process.env (never overrides). */
|
|
240
|
-
export function loadKintsugiEnv(projectDir: string): void {
|
|
241
|
-
loadEnvFile(path.join(projectDir, '.kintsugi', '.env'));
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function loadEnvFile(envPath: string): void {
|
|
245
|
-
let content: string;
|
|
246
|
-
try {
|
|
247
|
-
content = fs.readFileSync(envPath, 'utf-8');
|
|
248
|
-
} catch {
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
for (const line of content.split('\n')) {
|
|
252
|
-
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
|
|
253
|
-
if (!match) continue;
|
|
254
|
-
const key = match[1];
|
|
255
|
-
const value = match[2].replace(/^["']|["']$/g, '');
|
|
256
|
-
if (!(key in process.env)) process.env[key] = value;
|
|
257
|
-
}
|
|
258
|
-
}
|