@blazediff/agent 0.0.1
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/JUDGING.md +60 -0
- package/LICENSE.md +21 -0
- package/MASKING.md +86 -0
- package/README.md +136 -0
- package/SKILL.md +93 -0
- package/dist/cli.js +3205 -0
- package/dist/index.d.mts +281 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.js +1889 -0
- package/dist/index.mjs +1865 -0
- package/package.json +65 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1865 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, readdir, stat, rm } from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { availableParallelism, cpus } from 'os';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { existsSync } from 'fs';
|
|
7
|
+
import { createRequire } from 'module';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { compare, interpret } from '@blazediff/core-native';
|
|
10
|
+
import sharp from 'sharp';
|
|
11
|
+
import { Annotation, StateGraph, START, Send, END } from '@langchain/langgraph';
|
|
12
|
+
|
|
13
|
+
// src/browser/capture.ts
|
|
14
|
+
var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
15
|
+
var DEFAULT_WAIT_FOR = ["networkidle", "fonts"];
|
|
16
|
+
var DEFAULT_FULL_PAGE = true;
|
|
17
|
+
var DEFAULT_THRESHOLD = 0.1;
|
|
18
|
+
var MIN_AUTO_CONCURRENCY = 2;
|
|
19
|
+
var MAX_AUTO_CONCURRENCY = 8;
|
|
20
|
+
function defaultConcurrency() {
|
|
21
|
+
const cores = typeof availableParallelism === "function" ? availableParallelism() : cpus().length;
|
|
22
|
+
if (!cores || !Number.isFinite(cores)) return MIN_AUTO_CONCURRENCY;
|
|
23
|
+
return Math.max(
|
|
24
|
+
MIN_AUTO_CONCURRENCY,
|
|
25
|
+
Math.min(MAX_AUTO_CONCURRENCY, cores - 1)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
var BLAZEDIFF_DIR = ".blazediff";
|
|
29
|
+
var paths = (cwd = process.cwd()) => {
|
|
30
|
+
const root = path.join(cwd, BLAZEDIFF_DIR);
|
|
31
|
+
return {
|
|
32
|
+
root,
|
|
33
|
+
config: path.join(root, "config.json"),
|
|
34
|
+
manifest: path.join(root, "manifest.json"),
|
|
35
|
+
baselines: path.join(root, "baselines"),
|
|
36
|
+
actual: path.join(root, "actual"),
|
|
37
|
+
judgments: path.join(root, "judgments"),
|
|
38
|
+
summary: path.join(root, "summary.md"),
|
|
39
|
+
gitignore: path.join(root, ".gitignore"),
|
|
40
|
+
serverLog: path.join(root, "dev-server.log"),
|
|
41
|
+
serverPid: path.join(root, "dev-server.pid")
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
var FROZEN_NOW = Date.UTC(2025, 0, 1, 0, 0, 0);
|
|
45
|
+
var STABILITY_CSS = `
|
|
46
|
+
*,*::before,*::after{
|
|
47
|
+
animation-delay:-0.0001s !important;
|
|
48
|
+
animation-duration:0s !important;
|
|
49
|
+
animation-iteration-count:1 !important;
|
|
50
|
+
transition-delay:0s !important;
|
|
51
|
+
transition-duration:0s !important;
|
|
52
|
+
caret-color:transparent !important;
|
|
53
|
+
}
|
|
54
|
+
html{scroll-behavior:auto !important}
|
|
55
|
+
`;
|
|
56
|
+
var CHROMIUM_FLAGS = [
|
|
57
|
+
"--font-render-hinting=none",
|
|
58
|
+
"--disable-skia-runtime-opts",
|
|
59
|
+
"--force-color-profile=srgb",
|
|
60
|
+
"--disable-lcd-text",
|
|
61
|
+
"--disable-background-timer-throttling",
|
|
62
|
+
"--disable-renderer-backgrounding",
|
|
63
|
+
"--disable-backgrounding-occluded-windows",
|
|
64
|
+
"--hide-scrollbars"
|
|
65
|
+
];
|
|
66
|
+
var cachedBrowser = null;
|
|
67
|
+
var launchInFlight = null;
|
|
68
|
+
async function getBrowser() {
|
|
69
|
+
if (cachedBrowser?.isConnected()) return cachedBrowser;
|
|
70
|
+
if (launchInFlight) return launchInFlight;
|
|
71
|
+
launchInFlight = chromium.launch({ headless: true, args: CHROMIUM_FLAGS }).then((b) => {
|
|
72
|
+
cachedBrowser = b;
|
|
73
|
+
return b;
|
|
74
|
+
}).finally(() => {
|
|
75
|
+
launchInFlight = null;
|
|
76
|
+
});
|
|
77
|
+
return launchInFlight;
|
|
78
|
+
}
|
|
79
|
+
async function closeBrowser() {
|
|
80
|
+
if (launchInFlight) {
|
|
81
|
+
await launchInFlight.catch(() => {
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!cachedBrowser) return;
|
|
85
|
+
await cachedBrowser.close().catch(() => {
|
|
86
|
+
});
|
|
87
|
+
cachedBrowser = null;
|
|
88
|
+
}
|
|
89
|
+
async function openStableContext(opts) {
|
|
90
|
+
const browser = await getBrowser();
|
|
91
|
+
const context = await browser.newContext({
|
|
92
|
+
viewport: opts.viewport,
|
|
93
|
+
deviceScaleFactor: 1,
|
|
94
|
+
reducedMotion: "reduce",
|
|
95
|
+
forcedColors: "none",
|
|
96
|
+
colorScheme: "light",
|
|
97
|
+
bypassCSP: true
|
|
98
|
+
});
|
|
99
|
+
await context.addInitScript(
|
|
100
|
+
({ frozenNow }) => {
|
|
101
|
+
Object.defineProperty(Date, "now", {
|
|
102
|
+
value: () => frozenNow,
|
|
103
|
+
writable: true,
|
|
104
|
+
configurable: true
|
|
105
|
+
});
|
|
106
|
+
let perfTick = 0;
|
|
107
|
+
Object.defineProperty(performance, "now", {
|
|
108
|
+
value: () => {
|
|
109
|
+
perfTick += 16.6667;
|
|
110
|
+
return perfTick;
|
|
111
|
+
},
|
|
112
|
+
writable: true,
|
|
113
|
+
configurable: true
|
|
114
|
+
});
|
|
115
|
+
let seed = 2654435769 | 0;
|
|
116
|
+
Math.random = () => {
|
|
117
|
+
seed = seed + 1831565813 | 0;
|
|
118
|
+
let t = seed;
|
|
119
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
120
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
121
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
122
|
+
};
|
|
123
|
+
if (typeof crypto !== "undefined") {
|
|
124
|
+
let uuidCounter = 0;
|
|
125
|
+
Object.defineProperty(crypto, "randomUUID", {
|
|
126
|
+
value: () => {
|
|
127
|
+
uuidCounter += 1;
|
|
128
|
+
return `00000000-0000-4000-8000-${uuidCounter.toString(16).padStart(12, "0")}`;
|
|
129
|
+
},
|
|
130
|
+
writable: true,
|
|
131
|
+
configurable: true
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
{ frozenNow: FROZEN_NOW }
|
|
136
|
+
);
|
|
137
|
+
const page = await context.newPage();
|
|
138
|
+
const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
|
|
139
|
+
});
|
|
140
|
+
await injectStability();
|
|
141
|
+
page.on("load", injectStability);
|
|
142
|
+
return { context, page };
|
|
143
|
+
}
|
|
144
|
+
async function waitForStability(page, waitFor) {
|
|
145
|
+
for (const w of waitFor) {
|
|
146
|
+
if (w === "networkidle") {
|
|
147
|
+
await page.waitForLoadState("networkidle").catch(() => {
|
|
148
|
+
});
|
|
149
|
+
} else if (w === "fonts") {
|
|
150
|
+
await page.evaluate(
|
|
151
|
+
() => document.fonts && "ready" in document.fonts ? document.fonts.ready.then(() => void 0) : void 0
|
|
152
|
+
).catch(() => {
|
|
153
|
+
});
|
|
154
|
+
} else {
|
|
155
|
+
await page.waitForSelector(w.selector, { timeout: w.timeoutMs ?? 5e3 }).catch(() => {
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
var DEFAULT_MASK_ATTR = "data-blazediff-agent-mask";
|
|
161
|
+
var DEFAULT_MASK_SELECTOR = `[${DEFAULT_MASK_ATTR}]`;
|
|
162
|
+
async function applyMaskOverlays(page, masks) {
|
|
163
|
+
const selectors = [DEFAULT_MASK_SELECTOR, ...masks];
|
|
164
|
+
await page.evaluate((selectors2) => {
|
|
165
|
+
for (const sel of selectors2) {
|
|
166
|
+
for (const el of Array.from(
|
|
167
|
+
document.querySelectorAll(sel)
|
|
168
|
+
)) {
|
|
169
|
+
const rect = el.getBoundingClientRect();
|
|
170
|
+
const overlay = document.createElement("div");
|
|
171
|
+
overlay.style.position = "absolute";
|
|
172
|
+
overlay.style.left = `${rect.left + window.scrollX}px`;
|
|
173
|
+
overlay.style.top = `${rect.top + window.scrollY}px`;
|
|
174
|
+
overlay.style.width = `${rect.width}px`;
|
|
175
|
+
overlay.style.height = `${rect.height}px`;
|
|
176
|
+
overlay.style.background = "#ff00ff";
|
|
177
|
+
overlay.style.zIndex = "2147483647";
|
|
178
|
+
overlay.style.pointerEvents = "none";
|
|
179
|
+
overlay.setAttribute("data-blazediff-mask", "1");
|
|
180
|
+
document.body.appendChild(overlay);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}, selectors);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// src/browser/capture.ts
|
|
187
|
+
async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
188
|
+
const viewport = opts.viewport ?? DEFAULT_VIEWPORT;
|
|
189
|
+
const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
|
|
190
|
+
const masks = opts.mask ?? [];
|
|
191
|
+
const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
|
|
192
|
+
const { context, page } = await openStableContext({ viewport});
|
|
193
|
+
try {
|
|
194
|
+
const url = new URL(opts.url, baseUrl).toString();
|
|
195
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
196
|
+
await waitForStability(page, waitFor);
|
|
197
|
+
await applyMaskOverlays(page, masks);
|
|
198
|
+
await page.evaluate(
|
|
199
|
+
() => new Promise((r) => requestAnimationFrame(() => r()))
|
|
200
|
+
);
|
|
201
|
+
const outputDir = opts.mode === "baseline" ? paths(cwd).baselines : paths(cwd).actual;
|
|
202
|
+
await mkdir(outputDir, { recursive: true });
|
|
203
|
+
const outputPath = path.join(outputDir, `${opts.id}.png`);
|
|
204
|
+
const buffer = await page.screenshot({
|
|
205
|
+
path: outputPath,
|
|
206
|
+
type: "png",
|
|
207
|
+
fullPage,
|
|
208
|
+
animations: "disabled",
|
|
209
|
+
caret: "hide",
|
|
210
|
+
scale: "device"
|
|
211
|
+
});
|
|
212
|
+
return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
|
|
213
|
+
} finally {
|
|
214
|
+
await context.close().catch(() => {
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function resolvePlaywrightCli() {
|
|
219
|
+
const require_ = createRequire(import.meta.url);
|
|
220
|
+
const pkgJson = require_.resolve("playwright/package.json");
|
|
221
|
+
const dir = path.dirname(pkgJson);
|
|
222
|
+
const candidates = [
|
|
223
|
+
path.join(dir, "cli.js"),
|
|
224
|
+
path.join(dir, "lib", "cli.js")
|
|
225
|
+
];
|
|
226
|
+
const found = candidates.find((p) => existsSync(p));
|
|
227
|
+
if (!found) {
|
|
228
|
+
throw new Error(`could not locate playwright CLI under ${dir}`);
|
|
229
|
+
}
|
|
230
|
+
return found;
|
|
231
|
+
}
|
|
232
|
+
async function readExecutablePath() {
|
|
233
|
+
try {
|
|
234
|
+
const require_ = createRequire(import.meta.url);
|
|
235
|
+
const pw = require_("playwright");
|
|
236
|
+
const p = pw.chromium.executablePath();
|
|
237
|
+
return p && existsSync(p) ? p : null;
|
|
238
|
+
} catch {
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async function installBrowsers(opts = {}) {
|
|
243
|
+
const cliPath = resolvePlaywrightCli();
|
|
244
|
+
if (opts.check) {
|
|
245
|
+
const executablePath2 = await readExecutablePath();
|
|
246
|
+
return { installed: Boolean(executablePath2), executablePath: executablePath2, cliPath };
|
|
247
|
+
}
|
|
248
|
+
await new Promise((resolve, reject) => {
|
|
249
|
+
const child = spawn(process.execPath, [cliPath, "install", "chromium"], {
|
|
250
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
251
|
+
env: process.env
|
|
252
|
+
});
|
|
253
|
+
child.on("exit", (code) => {
|
|
254
|
+
if (code === 0) resolve();
|
|
255
|
+
else
|
|
256
|
+
reject(
|
|
257
|
+
new Error(`playwright install chromium exited with code ${code}`)
|
|
258
|
+
);
|
|
259
|
+
});
|
|
260
|
+
child.on("error", reject);
|
|
261
|
+
});
|
|
262
|
+
const executablePath = await readExecutablePath();
|
|
263
|
+
return { installed: Boolean(executablePath), executablePath, cliPath };
|
|
264
|
+
}
|
|
265
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
266
|
+
const file = paths(cwd).config;
|
|
267
|
+
if (!existsSync(file)) return null;
|
|
268
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
269
|
+
}
|
|
270
|
+
async function saveConfig(config, cwd = process.cwd()) {
|
|
271
|
+
const file = paths(cwd).config;
|
|
272
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
273
|
+
await writeFile(file, `${JSON.stringify(config, null, 2)}
|
|
274
|
+
`, "utf8");
|
|
275
|
+
}
|
|
276
|
+
function configHash(config) {
|
|
277
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(config)).digest("hex")}`;
|
|
278
|
+
}
|
|
279
|
+
function resolveBaseUrl(config, override) {
|
|
280
|
+
if (override) return override;
|
|
281
|
+
if (config?.baseUrl) return config.baseUrl;
|
|
282
|
+
if (config?.devServer) return `http://127.0.0.1:${config.devServer.port}`;
|
|
283
|
+
throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/types.ts
|
|
287
|
+
var STABILITY_HOOKS_VERSION = 1;
|
|
288
|
+
|
|
289
|
+
// src/manifest.ts
|
|
290
|
+
async function loadManifest(cwd = process.cwd()) {
|
|
291
|
+
const file = paths(cwd).manifest;
|
|
292
|
+
if (!existsSync(file)) return null;
|
|
293
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
294
|
+
}
|
|
295
|
+
async function saveManifest(manifest, cwd = process.cwd()) {
|
|
296
|
+
const file = paths(cwd).manifest;
|
|
297
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
298
|
+
await writeFile(file, `${JSON.stringify(manifest, null, 2)}
|
|
299
|
+
`, "utf8");
|
|
300
|
+
}
|
|
301
|
+
function emptyManifest(configHashValue) {
|
|
302
|
+
return {
|
|
303
|
+
version: 1,
|
|
304
|
+
configHash: configHashValue,
|
|
305
|
+
stabilityHooksVersion: STABILITY_HOOKS_VERSION,
|
|
306
|
+
entries: []
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function hashMaterial(input) {
|
|
310
|
+
const material = {
|
|
311
|
+
url: input.url,
|
|
312
|
+
viewport: input.viewport,
|
|
313
|
+
mask: [...input.mask].sort(),
|
|
314
|
+
waitFor: input.waitFor,
|
|
315
|
+
auth: input.auth,
|
|
316
|
+
fullPage: input.fullPage,
|
|
317
|
+
hooks: STABILITY_HOOKS_VERSION
|
|
318
|
+
};
|
|
319
|
+
return `sha256:${createHash("sha256").update(JSON.stringify(material)).digest("hex")}`;
|
|
320
|
+
}
|
|
321
|
+
function makeEntry(input) {
|
|
322
|
+
const viewport = input.viewport ?? DEFAULT_VIEWPORT;
|
|
323
|
+
const waitFor = input.waitFor ?? DEFAULT_WAIT_FOR;
|
|
324
|
+
const mask = input.mask ?? [];
|
|
325
|
+
const auth = input.auth ?? null;
|
|
326
|
+
const fullPage = input.fullPage ?? DEFAULT_FULL_PAGE;
|
|
327
|
+
return {
|
|
328
|
+
id: input.id,
|
|
329
|
+
url: input.url,
|
|
330
|
+
viewport,
|
|
331
|
+
auth,
|
|
332
|
+
waitFor,
|
|
333
|
+
mask,
|
|
334
|
+
fullPage,
|
|
335
|
+
baselinePath: path.posix.join(".blazediff", "baselines", `${input.id}.png`),
|
|
336
|
+
captureHash: hashMaterial({
|
|
337
|
+
url: input.url,
|
|
338
|
+
viewport,
|
|
339
|
+
mask,
|
|
340
|
+
waitFor,
|
|
341
|
+
auth,
|
|
342
|
+
fullPage
|
|
343
|
+
}),
|
|
344
|
+
createdBy: input.createdBy ?? "agent",
|
|
345
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
function addOrReplaceEntry(manifest, entry) {
|
|
349
|
+
const entries = [
|
|
350
|
+
...manifest.entries.filter((e) => e.id !== entry.id),
|
|
351
|
+
entry
|
|
352
|
+
].sort((a, b) => a.id.localeCompare(b.id));
|
|
353
|
+
return { ...manifest, entries };
|
|
354
|
+
}
|
|
355
|
+
function isEntryStale(entry) {
|
|
356
|
+
const recomputed = hashMaterial({
|
|
357
|
+
url: entry.url,
|
|
358
|
+
viewport: entry.viewport,
|
|
359
|
+
mask: entry.mask,
|
|
360
|
+
waitFor: entry.waitFor,
|
|
361
|
+
auth: entry.auth,
|
|
362
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE
|
|
363
|
+
});
|
|
364
|
+
return recomputed !== entry.captureHash;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// src/captures.ts
|
|
368
|
+
function validateRoute(route, i) {
|
|
369
|
+
if (!route || typeof route !== "object")
|
|
370
|
+
return `route[${i}] must be an object`;
|
|
371
|
+
if (!route.id || typeof route.id !== "string")
|
|
372
|
+
return `route[${i}] missing id`;
|
|
373
|
+
if (!route.url || typeof route.url !== "string")
|
|
374
|
+
return `route[${i}] missing url`;
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
async function loadOrCreateManifest(cwd) {
|
|
378
|
+
const existing = await loadManifest(cwd);
|
|
379
|
+
if (existing) return existing;
|
|
380
|
+
const config = await loadConfig(cwd);
|
|
381
|
+
return emptyManifest(config ? configHash(config) : "sha256:none");
|
|
382
|
+
}
|
|
383
|
+
async function runCaptures(opts) {
|
|
384
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
385
|
+
const defaultMode = opts.mode ?? "baseline";
|
|
386
|
+
const writeManifest = opts.writeManifest ?? true;
|
|
387
|
+
const results = [];
|
|
388
|
+
const valid = [];
|
|
389
|
+
opts.routes.forEach((r, i) => {
|
|
390
|
+
const err = validateRoute(r, i);
|
|
391
|
+
if (err) {
|
|
392
|
+
results.push({
|
|
393
|
+
id: r?.id ?? `route[${i}]`,
|
|
394
|
+
url: r?.url ?? "",
|
|
395
|
+
mode: r?.mode ?? defaultMode,
|
|
396
|
+
ok: false,
|
|
397
|
+
error: err
|
|
398
|
+
});
|
|
399
|
+
} else {
|
|
400
|
+
valid.push(r);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
|
|
404
|
+
let manifestUpdates = 0;
|
|
405
|
+
try {
|
|
406
|
+
for (const r of valid) {
|
|
407
|
+
const mode = r.mode ?? defaultMode;
|
|
408
|
+
try {
|
|
409
|
+
const shot = await captureScreenshot(
|
|
410
|
+
opts.baseUrl,
|
|
411
|
+
{
|
|
412
|
+
id: r.id,
|
|
413
|
+
url: r.url,
|
|
414
|
+
viewport: r.viewport,
|
|
415
|
+
mask: r.mask,
|
|
416
|
+
waitFor: r.waitFor,
|
|
417
|
+
fullPage: r.fullPage,
|
|
418
|
+
mode
|
|
419
|
+
},
|
|
420
|
+
cwd
|
|
421
|
+
);
|
|
422
|
+
results.push({
|
|
423
|
+
id: r.id,
|
|
424
|
+
url: r.url,
|
|
425
|
+
mode,
|
|
426
|
+
ok: true,
|
|
427
|
+
outputPath: shot.outputPath,
|
|
428
|
+
bytes: shot.bytes
|
|
429
|
+
});
|
|
430
|
+
if (manifest && mode === "baseline") {
|
|
431
|
+
manifest = addOrReplaceEntry(
|
|
432
|
+
manifest,
|
|
433
|
+
makeEntry({
|
|
434
|
+
id: r.id,
|
|
435
|
+
url: r.url,
|
|
436
|
+
viewport: r.viewport,
|
|
437
|
+
mask: r.mask,
|
|
438
|
+
waitFor: r.waitFor,
|
|
439
|
+
fullPage: r.fullPage
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
manifestUpdates += 1;
|
|
443
|
+
}
|
|
444
|
+
} catch (err) {
|
|
445
|
+
results.push({
|
|
446
|
+
id: r.id,
|
|
447
|
+
url: r.url,
|
|
448
|
+
mode,
|
|
449
|
+
ok: false,
|
|
450
|
+
error: err.message
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
|
|
455
|
+
} finally {
|
|
456
|
+
await closeBrowser();
|
|
457
|
+
}
|
|
458
|
+
const succeeded = results.filter((r) => r.ok).length;
|
|
459
|
+
return {
|
|
460
|
+
total: results.length,
|
|
461
|
+
succeeded,
|
|
462
|
+
failed: results.length - succeeded,
|
|
463
|
+
manifestUpdates,
|
|
464
|
+
results
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
var ENTRIES = [
|
|
468
|
+
"actual/",
|
|
469
|
+
"judgments/",
|
|
470
|
+
"summary.md",
|
|
471
|
+
"dev-server.log",
|
|
472
|
+
"dev-server.pid",
|
|
473
|
+
"*.tmp"
|
|
474
|
+
];
|
|
475
|
+
var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
|
|
476
|
+
var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
|
|
477
|
+
async function ensureGitignore(cwd) {
|
|
478
|
+
const file = paths(cwd).gitignore;
|
|
479
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
480
|
+
const existing = existsSync(file) ? await readFile(file, "utf8") : "";
|
|
481
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
482
|
+
const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
|
|
483
|
+
const missing = ENTRIES.filter((e) => !lines.includes(e));
|
|
484
|
+
if (!missing.length && !hasStale && existing) return;
|
|
485
|
+
if (hasStale) {
|
|
486
|
+
const kept = lines.filter(
|
|
487
|
+
(l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
|
|
488
|
+
);
|
|
489
|
+
const body2 = `${kept.length ? `${kept.join("\n")}
|
|
490
|
+
` : HEADER}${ENTRIES.join("\n")}
|
|
491
|
+
`;
|
|
492
|
+
await writeFile(file, body2, "utf8");
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const body = existing ? `${existing.replace(/\n+$/, "")}
|
|
496
|
+
${missing.join("\n")}
|
|
497
|
+
` : `${HEADER}${ENTRIES.join("\n")}
|
|
498
|
+
`;
|
|
499
|
+
await writeFile(file, body, "utf8");
|
|
500
|
+
}
|
|
501
|
+
async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
|
|
502
|
+
if (!existsSync(baselinePath) || !existsSync(actualPath)) {
|
|
503
|
+
return {
|
|
504
|
+
id,
|
|
505
|
+
baselinePath,
|
|
506
|
+
actualPath,
|
|
507
|
+
match: false,
|
|
508
|
+
reason: "file-not-exists"
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
let diffPath;
|
|
512
|
+
if (opts.emitDiffPng) {
|
|
513
|
+
const actualDir = paths(cwd).actual;
|
|
514
|
+
await mkdir(actualDir, { recursive: true });
|
|
515
|
+
diffPath = path.join(actualDir, `${id}.diff.png`);
|
|
516
|
+
}
|
|
517
|
+
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
518
|
+
const antialiasing = opts.antialiasing ?? true;
|
|
519
|
+
const result = await compare(baselinePath, actualPath, diffPath, {
|
|
520
|
+
threshold,
|
|
521
|
+
antialiasing
|
|
522
|
+
});
|
|
523
|
+
if (result.match) return { id, baselinePath, actualPath, match: true };
|
|
524
|
+
if (result.reason === "file-not-exists") {
|
|
525
|
+
return {
|
|
526
|
+
id,
|
|
527
|
+
baselinePath,
|
|
528
|
+
actualPath,
|
|
529
|
+
match: false,
|
|
530
|
+
reason: "file-not-exists"
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
if (result.reason === "layout-diff") {
|
|
534
|
+
return {
|
|
535
|
+
id,
|
|
536
|
+
baselinePath,
|
|
537
|
+
actualPath,
|
|
538
|
+
diffPath,
|
|
539
|
+
match: false,
|
|
540
|
+
reason: "layout-diff"
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const interpretation = await interpret(baselinePath, actualPath, {
|
|
544
|
+
threshold,
|
|
545
|
+
antialiasing
|
|
546
|
+
}).catch(() => void 0);
|
|
547
|
+
return {
|
|
548
|
+
id,
|
|
549
|
+
baselinePath,
|
|
550
|
+
actualPath,
|
|
551
|
+
diffPath,
|
|
552
|
+
match: false,
|
|
553
|
+
reason: "pixel-diff",
|
|
554
|
+
diffCount: result.diffCount,
|
|
555
|
+
diffPercentage: result.diffPercentage,
|
|
556
|
+
interpretation
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// src/diff/verdict.ts
|
|
561
|
+
var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
|
|
562
|
+
var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
|
|
563
|
+
var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
|
|
564
|
+
var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
|
|
565
|
+
var SUB_PERCEPTUAL_PCT = 0.01;
|
|
566
|
+
function pctText(pct) {
|
|
567
|
+
if (typeof pct !== "number") return "?%";
|
|
568
|
+
return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
|
|
569
|
+
}
|
|
570
|
+
function countByType(regions) {
|
|
571
|
+
const counts = /* @__PURE__ */ new Map();
|
|
572
|
+
for (const r of regions)
|
|
573
|
+
counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
|
|
574
|
+
return counts;
|
|
575
|
+
}
|
|
576
|
+
function dominantType(counts) {
|
|
577
|
+
let best = "";
|
|
578
|
+
let bestN = 0;
|
|
579
|
+
for (const [type, n] of counts) {
|
|
580
|
+
if (n > bestN) {
|
|
581
|
+
best = type;
|
|
582
|
+
bestN = n;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return best;
|
|
586
|
+
}
|
|
587
|
+
function topPosition(regions) {
|
|
588
|
+
let best;
|
|
589
|
+
for (const r of regions)
|
|
590
|
+
if (!best || r.pixelCount > best.pixelCount) best = r;
|
|
591
|
+
return best?.position;
|
|
592
|
+
}
|
|
593
|
+
function meanConfidence(regions) {
|
|
594
|
+
if (regions.length === 0) return 0;
|
|
595
|
+
return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
|
|
596
|
+
}
|
|
597
|
+
function formatBreakdown(counts) {
|
|
598
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
|
|
599
|
+
}
|
|
600
|
+
function buildHeadline(input) {
|
|
601
|
+
const { reason, interpretation, diffCount, diffPercentage } = input;
|
|
602
|
+
if (reason === "layout-diff") return "image dimensions changed";
|
|
603
|
+
if (reason === "file-not-exists") return "baseline or actual capture missing";
|
|
604
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
605
|
+
const px = diffCount?.toLocaleString() ?? "?";
|
|
606
|
+
return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
|
|
607
|
+
}
|
|
608
|
+
const regions = interpretation.regions;
|
|
609
|
+
const counts = countByType(regions);
|
|
610
|
+
const pos = topPosition(regions);
|
|
611
|
+
const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
|
|
612
|
+
const sev = interpretation.severity ?? "?";
|
|
613
|
+
if (regions.length === 1) {
|
|
614
|
+
return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
615
|
+
}
|
|
616
|
+
return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
617
|
+
}
|
|
618
|
+
function deriveVerdict(input) {
|
|
619
|
+
const { reason, interpretation, diffPercentage } = input;
|
|
620
|
+
const headline = buildHeadline(input);
|
|
621
|
+
if (reason === "layout-diff") {
|
|
622
|
+
return {
|
|
623
|
+
label: "ambiguous",
|
|
624
|
+
headline,
|
|
625
|
+
rationale: [
|
|
626
|
+
"baseline and actual image dimensions differ \u2014 page height likely shifted",
|
|
627
|
+
"could be intentional (content added/removed) or regression (broken layout)"
|
|
628
|
+
],
|
|
629
|
+
action: "investigate"
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (reason === "file-not-exists") {
|
|
633
|
+
return {
|
|
634
|
+
label: "regression-likely",
|
|
635
|
+
headline,
|
|
636
|
+
rationale: ["baseline or actual capture is missing from disk"],
|
|
637
|
+
action: "investigate"
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
641
|
+
return {
|
|
642
|
+
label: "ambiguous",
|
|
643
|
+
headline,
|
|
644
|
+
rationale: ["pixels differ but interpret returned no regions"],
|
|
645
|
+
action: "investigate"
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
const regions = interpretation.regions;
|
|
649
|
+
const severity = interpretation.severity;
|
|
650
|
+
const counts = countByType(regions);
|
|
651
|
+
const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
|
|
652
|
+
const allColor = regions.every((r) => r.changeType === "color-change");
|
|
653
|
+
const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
|
|
654
|
+
const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
|
|
655
|
+
const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
|
|
656
|
+
if (allNoise) {
|
|
657
|
+
return {
|
|
658
|
+
label: "noise-likely",
|
|
659
|
+
headline,
|
|
660
|
+
rationale: ["all regions classified as rendering-noise"],
|
|
661
|
+
action: "ignore-or-rewrite"
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
|
|
665
|
+
return {
|
|
666
|
+
label: "noise-likely",
|
|
667
|
+
headline,
|
|
668
|
+
rationale: [
|
|
669
|
+
`delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
|
|
670
|
+
"sub-perceptual change - review optional"
|
|
671
|
+
],
|
|
672
|
+
action: "ignore-or-rewrite"
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
|
|
676
|
+
const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
677
|
+
return {
|
|
678
|
+
label: "regression-likely",
|
|
679
|
+
headline,
|
|
680
|
+
rationale: [
|
|
681
|
+
`severity ${severity} with structural changes (${types})`,
|
|
682
|
+
"likely affects content or layout, not just styling"
|
|
683
|
+
],
|
|
684
|
+
action: "investigate"
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
if (allColor && meanConfidence(regions) > 0.7) {
|
|
688
|
+
return {
|
|
689
|
+
label: "intentional-likely",
|
|
690
|
+
headline,
|
|
691
|
+
rationale: [
|
|
692
|
+
`${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
|
|
693
|
+
"edge structure preserved - looks like a theming / palette change"
|
|
694
|
+
],
|
|
695
|
+
action: "rewrite-if-intended"
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
if (allMoved && !allColor) {
|
|
699
|
+
return {
|
|
700
|
+
label: "intentional-likely",
|
|
701
|
+
headline,
|
|
702
|
+
rationale: [
|
|
703
|
+
"all regions are shift/color-change - content moved or restyled, structure preserved"
|
|
704
|
+
],
|
|
705
|
+
action: "rewrite-if-intended"
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
return {
|
|
709
|
+
label: "ambiguous",
|
|
710
|
+
headline,
|
|
711
|
+
rationale: [
|
|
712
|
+
`mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
|
|
713
|
+
`${pctText(pct)} of image differs`
|
|
714
|
+
],
|
|
715
|
+
action: "investigate"
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
var DEFAULT_TOP_N = 5;
|
|
719
|
+
var DEFAULT_PADDING = 16;
|
|
720
|
+
var DEFAULT_LOCATOR_MAX_WIDTH = 400;
|
|
721
|
+
var DEFAULT_GUTTER = 2;
|
|
722
|
+
var DEFAULT_ROW_GUTTER = 8;
|
|
723
|
+
var BG_WHITE = { r: 255, g: 255, b: 255 };
|
|
724
|
+
function padAndClamp(bbox, padding, imgWidth, imgHeight) {
|
|
725
|
+
const left = Math.max(0, Math.floor(bbox.x - padding));
|
|
726
|
+
const top = Math.max(0, Math.floor(bbox.y - padding));
|
|
727
|
+
const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
|
|
728
|
+
const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
|
|
729
|
+
return {
|
|
730
|
+
x: left,
|
|
731
|
+
y: top,
|
|
732
|
+
width: Math.max(1, right - left),
|
|
733
|
+
height: Math.max(1, bottom - top)
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
async function prepareTiles(opts) {
|
|
737
|
+
const topN = opts.topN ?? DEFAULT_TOP_N;
|
|
738
|
+
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
739
|
+
const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
|
|
740
|
+
const gutter = opts.gutter ?? DEFAULT_GUTTER;
|
|
741
|
+
const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
|
|
742
|
+
const diffMeta = await sharp(opts.diffPath).metadata();
|
|
743
|
+
const imgWidth = diffMeta.width ?? 0;
|
|
744
|
+
const imgHeight = diffMeta.height ?? 0;
|
|
745
|
+
if (!imgWidth || !imgHeight) {
|
|
746
|
+
throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
|
|
747
|
+
}
|
|
748
|
+
const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
|
|
749
|
+
const regionData = await Promise.all(
|
|
750
|
+
ranked.map(async (region) => {
|
|
751
|
+
const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
|
|
752
|
+
const extract = {
|
|
753
|
+
left: padded.x,
|
|
754
|
+
top: padded.y,
|
|
755
|
+
width: padded.width,
|
|
756
|
+
height: padded.height
|
|
757
|
+
};
|
|
758
|
+
const [base, actual] = await Promise.all([
|
|
759
|
+
sharp(opts.baselinePath).extract(extract).toBuffer(),
|
|
760
|
+
sharp(opts.actualPath).extract(extract).toBuffer()
|
|
761
|
+
]);
|
|
762
|
+
return { region, padded, base, actual };
|
|
763
|
+
})
|
|
764
|
+
);
|
|
765
|
+
let tilesName;
|
|
766
|
+
if (regionData.length > 0) {
|
|
767
|
+
const canvasWidth = Math.max(
|
|
768
|
+
...regionData.map((r) => r.padded.width * 2 + gutter)
|
|
769
|
+
);
|
|
770
|
+
const totalHeight = regionData.reduce(
|
|
771
|
+
(sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
|
|
772
|
+
0
|
|
773
|
+
);
|
|
774
|
+
const composites = [];
|
|
775
|
+
let y = 0;
|
|
776
|
+
for (let i = 0; i < regionData.length; i++) {
|
|
777
|
+
const r = regionData[i];
|
|
778
|
+
const w = r.padded.width;
|
|
779
|
+
composites.push(
|
|
780
|
+
{ input: r.base, left: 0, top: y },
|
|
781
|
+
{ input: r.actual, left: w + gutter, top: y }
|
|
782
|
+
);
|
|
783
|
+
y += r.padded.height;
|
|
784
|
+
if (i < regionData.length - 1) y += rowGutter;
|
|
785
|
+
}
|
|
786
|
+
tilesName = "regions.png";
|
|
787
|
+
await sharp({
|
|
788
|
+
create: {
|
|
789
|
+
width: canvasWidth,
|
|
790
|
+
height: totalHeight,
|
|
791
|
+
channels: 3,
|
|
792
|
+
background: BG_WHITE
|
|
793
|
+
}
|
|
794
|
+
}).composite(composites).png().toFile(path.join(opts.outputDir, tilesName));
|
|
795
|
+
}
|
|
796
|
+
const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
|
|
797
|
+
const locW = Math.max(1, Math.round(imgWidth * scale));
|
|
798
|
+
const locH = Math.max(1, Math.round(imgHeight * scale));
|
|
799
|
+
const rects = opts.regions.map((r) => {
|
|
800
|
+
const x = Math.round(r.bbox.x * scale);
|
|
801
|
+
const y = Math.round(r.bbox.y * scale);
|
|
802
|
+
const w = Math.max(1, Math.round(r.bbox.width * scale));
|
|
803
|
+
const h = Math.max(1, Math.round(r.bbox.height * scale));
|
|
804
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
|
|
805
|
+
}).join("");
|
|
806
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
|
|
807
|
+
const locatorName = "locator.png";
|
|
808
|
+
await sharp(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path.join(opts.outputDir, locatorName));
|
|
809
|
+
return {
|
|
810
|
+
locatorPath: locatorName,
|
|
811
|
+
tilesPath: tilesName,
|
|
812
|
+
regions: regionData.map((r) => ({
|
|
813
|
+
bbox: r.region.bbox,
|
|
814
|
+
pixelCount: r.region.pixelCount
|
|
815
|
+
}))
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/judge/host-harness.ts
|
|
820
|
+
async function tryPrepareTiles(input, entryDir) {
|
|
821
|
+
if (!input.regions || input.regions.length === 0 || !input.diffPath) {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
try {
|
|
825
|
+
return await prepareTiles({
|
|
826
|
+
regions: input.regions,
|
|
827
|
+
baselinePath: input.baselinePath,
|
|
828
|
+
actualPath: input.actualPath,
|
|
829
|
+
diffPath: input.diffPath,
|
|
830
|
+
outputDir: entryDir
|
|
831
|
+
});
|
|
832
|
+
} catch (err) {
|
|
833
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
834
|
+
console.warn(
|
|
835
|
+
`[blazediff] tile generation failed for ${input.entry.id}: ${message}`
|
|
836
|
+
);
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
var hostHarnessJudge = {
|
|
841
|
+
name: "host",
|
|
842
|
+
async judge(input, cwd) {
|
|
843
|
+
const p = paths(cwd);
|
|
844
|
+
const entryDir = path.join(p.judgments, input.entry.id);
|
|
845
|
+
await mkdir(entryDir, { recursive: true });
|
|
846
|
+
await tryPrepareTiles(input, entryDir);
|
|
847
|
+
return { kind: "deferred", requestPath: entryDir };
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
// src/judge/none.ts
|
|
852
|
+
var noneJudge = {
|
|
853
|
+
name: "none",
|
|
854
|
+
async judge(input) {
|
|
855
|
+
return { kind: "judged", verdict: input.heuristicVerdict };
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
var PREVIEW_WIDTH = 320;
|
|
859
|
+
function escapeCell(s) {
|
|
860
|
+
return s.replace(/\n/g, " ");
|
|
861
|
+
}
|
|
862
|
+
function toBlazediffRel(cwd, abs) {
|
|
863
|
+
if (!abs) return void 0;
|
|
864
|
+
const root = paths(cwd).root;
|
|
865
|
+
const rel = path.isAbsolute(abs) ? path.relative(root, abs) : path.relative(paths(cwd).root, path.join(cwd, abs));
|
|
866
|
+
return rel.split(path.sep).join("/");
|
|
867
|
+
}
|
|
868
|
+
function img(src, alt) {
|
|
869
|
+
return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
|
|
870
|
+
}
|
|
871
|
+
function baselineCell(r, cwd) {
|
|
872
|
+
const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
|
|
873
|
+
return img(rel, `${r.id} baseline`);
|
|
874
|
+
}
|
|
875
|
+
function actualCell(r, cwd) {
|
|
876
|
+
const actual = toBlazediffRel(cwd, r.actualPath);
|
|
877
|
+
return actual ? img(actual, `${r.id} actual`) : "-";
|
|
878
|
+
}
|
|
879
|
+
function diffCell(r, cwd) {
|
|
880
|
+
const diff = toBlazediffRel(cwd, r.diffPath);
|
|
881
|
+
return diff ? img(diff, `${r.id} diff`) : "-";
|
|
882
|
+
}
|
|
883
|
+
function verdictCell(r) {
|
|
884
|
+
if (r.status === "missing-baseline") {
|
|
885
|
+
return `missing-baseline - ${r.message ?? "baseline missing"}`;
|
|
886
|
+
}
|
|
887
|
+
if (r.status === "stale-baseline") {
|
|
888
|
+
return `stale-baseline - ${r.message ?? "manifest entry edited without re-capturing"}`;
|
|
889
|
+
}
|
|
890
|
+
if (r.status === "needs-judgment") {
|
|
891
|
+
return `needs-judgment - see judgments/${r.id}/`;
|
|
892
|
+
}
|
|
893
|
+
if (!r.verdict) {
|
|
894
|
+
return r.message ?? r.status;
|
|
895
|
+
}
|
|
896
|
+
return `${r.verdict.label} - ${r.verdict.headline} -> ${r.verdict.action}`;
|
|
897
|
+
}
|
|
898
|
+
function renderRow(r, cwd) {
|
|
899
|
+
return `| ${r.id} | ${escapeCell(baselineCell(r, cwd))} | ${escapeCell(actualCell(r, cwd))} | ${escapeCell(diffCell(r, cwd))} | ${escapeCell(verdictCell(r))} |`;
|
|
900
|
+
}
|
|
901
|
+
function headerLine(report) {
|
|
902
|
+
const { passed, failed, pendingJudgments, totalEntries } = report;
|
|
903
|
+
const parts = [`${passed}/${totalEntries} passed`];
|
|
904
|
+
if (failed > 0) parts.push(`${failed} failed`);
|
|
905
|
+
if (pendingJudgments > 0) parts.push(`${pendingJudgments} pending judgment`);
|
|
906
|
+
return parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(", ")})` : parts[0];
|
|
907
|
+
}
|
|
908
|
+
function renderSummary(report, cwd = process.cwd()) {
|
|
909
|
+
const nonPass = report.results.filter((r) => r.status !== "pass");
|
|
910
|
+
const lines = [
|
|
911
|
+
`# blazediff check - ${report.createdAt}`,
|
|
912
|
+
"",
|
|
913
|
+
headerLine(report),
|
|
914
|
+
""
|
|
915
|
+
];
|
|
916
|
+
if (nonPass.length === 0) {
|
|
917
|
+
lines.push("All entries passed.");
|
|
918
|
+
return `${lines.join("\n")}
|
|
919
|
+
`;
|
|
920
|
+
}
|
|
921
|
+
lines.push("| id | baseline | actual | diff | verdict |");
|
|
922
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
923
|
+
for (const r of nonPass) lines.push(renderRow(r, cwd));
|
|
924
|
+
return `${lines.join("\n")}
|
|
925
|
+
`;
|
|
926
|
+
}
|
|
927
|
+
async function writeSummaryMarkdown(report, cwd = process.cwd()) {
|
|
928
|
+
const file = paths(cwd).summary;
|
|
929
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
930
|
+
await writeFile(file, renderSummary(report, cwd), "utf8");
|
|
931
|
+
return file;
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// src/judge/apply.ts
|
|
935
|
+
var VALID_LABELS = [
|
|
936
|
+
"regression-likely",
|
|
937
|
+
"intentional-likely",
|
|
938
|
+
"noise-likely",
|
|
939
|
+
"ambiguous"
|
|
940
|
+
];
|
|
941
|
+
var VALID_ACTIONS = [
|
|
942
|
+
"investigate",
|
|
943
|
+
"rewrite-if-intended",
|
|
944
|
+
"ignore-or-rewrite"
|
|
945
|
+
];
|
|
946
|
+
function parseVerdict(raw) {
|
|
947
|
+
if (!raw || typeof raw !== "object") return null;
|
|
948
|
+
const r = raw;
|
|
949
|
+
if (typeof r.id !== "string") return null;
|
|
950
|
+
const v = r.verdict;
|
|
951
|
+
if (!v || typeof v !== "object") return null;
|
|
952
|
+
if (typeof v.label !== "string" || !VALID_LABELS.includes(v.label))
|
|
953
|
+
return null;
|
|
954
|
+
if (typeof v.headline !== "string") return null;
|
|
955
|
+
if (typeof v.action !== "string" || !VALID_ACTIONS.includes(v.action))
|
|
956
|
+
return null;
|
|
957
|
+
const rationale = Array.isArray(v.rationale) ? v.rationale.filter((x) => typeof x === "string") : [];
|
|
958
|
+
return {
|
|
959
|
+
id: r.id,
|
|
960
|
+
verdict: {
|
|
961
|
+
label: v.label,
|
|
962
|
+
headline: v.headline,
|
|
963
|
+
rationale,
|
|
964
|
+
action: v.action
|
|
965
|
+
},
|
|
966
|
+
rationale: typeof r.rationale === "string" ? r.rationale : void 0,
|
|
967
|
+
confidence: typeof r.confidence === "number" ? r.confidence : void 0
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
async function readJsonOrNull(file) {
|
|
971
|
+
try {
|
|
972
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
973
|
+
} catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
async function readJudgmentDirs(root) {
|
|
978
|
+
let names;
|
|
979
|
+
try {
|
|
980
|
+
names = await readdir(root);
|
|
981
|
+
} catch {
|
|
982
|
+
return [];
|
|
983
|
+
}
|
|
984
|
+
const out = [];
|
|
985
|
+
for (const name of names) {
|
|
986
|
+
const dir = path.join(root, name);
|
|
987
|
+
let isDir = false;
|
|
988
|
+
try {
|
|
989
|
+
isDir = (await stat(dir)).isDirectory();
|
|
990
|
+
} catch {
|
|
991
|
+
isDir = false;
|
|
992
|
+
}
|
|
993
|
+
if (!isDir) continue;
|
|
994
|
+
const request = await readJsonOrNull(
|
|
995
|
+
path.join(dir, "request.json")
|
|
996
|
+
);
|
|
997
|
+
const verdictFile = path.join(dir, "verdict.json");
|
|
998
|
+
let verdict = null;
|
|
999
|
+
let verdictInvalid = false;
|
|
1000
|
+
if (existsSync(verdictFile)) {
|
|
1001
|
+
const raw = await readJsonOrNull(verdictFile);
|
|
1002
|
+
verdict = raw ? parseVerdict(raw) : null;
|
|
1003
|
+
if (raw && !verdict) verdictInvalid = true;
|
|
1004
|
+
}
|
|
1005
|
+
out.push({ id: name, request, verdict, verdictInvalid });
|
|
1006
|
+
}
|
|
1007
|
+
return out;
|
|
1008
|
+
}
|
|
1009
|
+
function toAbs(cwd, rel) {
|
|
1010
|
+
if (!rel) return void 0;
|
|
1011
|
+
return path.isAbsolute(rel) ? rel : path.join(cwd, rel);
|
|
1012
|
+
}
|
|
1013
|
+
function buildResult(cwd, dir, entry) {
|
|
1014
|
+
const req = dir.request;
|
|
1015
|
+
const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
|
|
1016
|
+
const status = req ? dir.verdict ? "fail" : req.status : "fail";
|
|
1017
|
+
const message = dir.verdict?.rationale ?? (dir.verdict?.confidence !== void 0 ? `judged (confidence ${dir.verdict.confidence.toFixed(2)})` : req?.message);
|
|
1018
|
+
return {
|
|
1019
|
+
id: dir.id,
|
|
1020
|
+
url: req?.url ?? entry?.url ?? "",
|
|
1021
|
+
status,
|
|
1022
|
+
diffPercentage: req?.diffPercentage,
|
|
1023
|
+
severity: req?.severity,
|
|
1024
|
+
regions: req?.regions,
|
|
1025
|
+
verdict: finalVerdict,
|
|
1026
|
+
diffPath: toAbs(cwd, req?.paths.diff),
|
|
1027
|
+
actualPath: toAbs(cwd, req?.paths.actual),
|
|
1028
|
+
baselinePath: toAbs(cwd, req?.paths.baseline),
|
|
1029
|
+
message
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
function passResult(entry, cwd) {
|
|
1033
|
+
const baselineAbs = path.join(paths(cwd).baselines, `${entry.id}.png`);
|
|
1034
|
+
const actualAbs = path.join(paths(cwd).actual, `${entry.id}.png`);
|
|
1035
|
+
return {
|
|
1036
|
+
id: entry.id,
|
|
1037
|
+
url: entry.url,
|
|
1038
|
+
status: "pass",
|
|
1039
|
+
baselinePath: baselineAbs,
|
|
1040
|
+
actualPath: existsSync(actualAbs) ? actualAbs : void 0
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
async function applyJudgments(cwd = process.cwd()) {
|
|
1044
|
+
const p = paths(cwd);
|
|
1045
|
+
const manifest = await loadManifest(cwd);
|
|
1046
|
+
if (!manifest) {
|
|
1047
|
+
throw new Error(
|
|
1048
|
+
`no manifest at ${p.manifest}. Run \`blazediff-agent init\` first.`
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const dirs = await readJudgmentDirs(p.judgments);
|
|
1052
|
+
const dirById = new Map(dirs.map((d) => [d.id, d]));
|
|
1053
|
+
const applied = [];
|
|
1054
|
+
const missing = [];
|
|
1055
|
+
const invalid = [];
|
|
1056
|
+
for (const d of dirs) {
|
|
1057
|
+
if (d.verdictInvalid) invalid.push(path.join(p.judgments, d.id));
|
|
1058
|
+
else if (d.verdict) applied.push(d.id);
|
|
1059
|
+
else missing.push(d.id);
|
|
1060
|
+
}
|
|
1061
|
+
const nonPassResults = [];
|
|
1062
|
+
for (const d of dirs) {
|
|
1063
|
+
const entry = manifest.entries.find((e) => e.id === d.id);
|
|
1064
|
+
nonPassResults.push(buildResult(cwd, d, entry));
|
|
1065
|
+
}
|
|
1066
|
+
const passResults = [];
|
|
1067
|
+
for (const entry of manifest.entries) {
|
|
1068
|
+
if (dirById.has(entry.id)) continue;
|
|
1069
|
+
passResults.push(passResult(entry, cwd));
|
|
1070
|
+
}
|
|
1071
|
+
const results = [...passResults, ...nonPassResults];
|
|
1072
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1073
|
+
const pendingJudgments = results.filter(
|
|
1074
|
+
(r) => r.status === "needs-judgment"
|
|
1075
|
+
).length;
|
|
1076
|
+
const report = {
|
|
1077
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1078
|
+
totalEntries: results.length,
|
|
1079
|
+
passed,
|
|
1080
|
+
failed: results.length - passed - pendingJudgments,
|
|
1081
|
+
pendingJudgments,
|
|
1082
|
+
results
|
|
1083
|
+
};
|
|
1084
|
+
await writeSummaryMarkdown(report, cwd);
|
|
1085
|
+
return { report, applied, missing, invalid };
|
|
1086
|
+
}
|
|
1087
|
+
var HOST_INSTRUCTIONS = [
|
|
1088
|
+
"The visual-regression heuristic could not classify this diff confidently.",
|
|
1089
|
+
"Read `locator.png` AND `regions.png` in parallel - issue both Read calls in a single tool batch. locator.png is a small thumbnail of the diff with every change region outlined in red; regions.png is a vertical stack of [baseline | actual] pairs, one row per change region at native resolution. Row order matches the `regions[]` array (top = largest by pixelCount).",
|
|
1090
|
+
"Decide from the tile pairs. Only open the full diff / baseline / actual PNGs if the composite is itself ambiguous (e.g., a change clearly continues outside the cropped region).",
|
|
1091
|
+
"Decide whether the change is a regression, an intentional UI change, or rendering noise.",
|
|
1092
|
+
"Write your decision to `verdict.json` (next to this `request.json`) with shape:",
|
|
1093
|
+
' { "id": string, "verdict": { "label": "regression-likely" | "intentional-likely" | "noise-likely", "headline": string, "rationale": string[], "action": "investigate" | "rewrite-if-intended" | "ignore-or-rewrite" }, "rationale": string, "confidence": number }',
|
|
1094
|
+
"Then re-run `blazediff-agent check --apply-judgments --json` to regenerate summary.md."
|
|
1095
|
+
].join("\n");
|
|
1096
|
+
function relTo(cwd, abs) {
|
|
1097
|
+
if (!abs) return void 0;
|
|
1098
|
+
return path.relative(cwd, abs).split(path.sep).join("/");
|
|
1099
|
+
}
|
|
1100
|
+
function signatureOf(r) {
|
|
1101
|
+
const pct = typeof r.diffPercentage === "number" ? r.diffPercentage.toFixed(4) : "?";
|
|
1102
|
+
const regions = r.regions?.length ?? 0;
|
|
1103
|
+
const severity = r.severity ?? "?";
|
|
1104
|
+
return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
|
|
1105
|
+
}
|
|
1106
|
+
async function readJsonOrNull2(file) {
|
|
1107
|
+
try {
|
|
1108
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
1109
|
+
} catch {
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
function entryById(manifest, id) {
|
|
1114
|
+
return manifest.entries.find((e) => e.id === id);
|
|
1115
|
+
}
|
|
1116
|
+
function buildRequest(result, entry, cwd, tiles) {
|
|
1117
|
+
const isAmbiguous = result.status === "needs-judgment" || result.verdict?.label === "ambiguous" && result.status === "fail";
|
|
1118
|
+
return {
|
|
1119
|
+
id: result.id,
|
|
1120
|
+
url: result.url,
|
|
1121
|
+
status: result.status,
|
|
1122
|
+
diffPercentage: result.diffPercentage,
|
|
1123
|
+
severity: result.severity,
|
|
1124
|
+
regions: result.regions,
|
|
1125
|
+
paths: {
|
|
1126
|
+
baseline: relTo(cwd, result.baselinePath),
|
|
1127
|
+
actual: relTo(cwd, result.actualPath),
|
|
1128
|
+
diff: relTo(cwd, result.diffPath),
|
|
1129
|
+
locator: tiles.locatorPath,
|
|
1130
|
+
tiles: tiles.tilesPath
|
|
1131
|
+
},
|
|
1132
|
+
heuristicVerdict: result.verdict,
|
|
1133
|
+
manifestEntry: {
|
|
1134
|
+
viewport: entry.viewport,
|
|
1135
|
+
mask: entry.mask,
|
|
1136
|
+
waitFor: entry.waitFor,
|
|
1137
|
+
fullPage: entry.fullPage
|
|
1138
|
+
},
|
|
1139
|
+
signature: signatureOf(result),
|
|
1140
|
+
message: result.message,
|
|
1141
|
+
instructions: isAmbiguous ? HOST_INSTRUCTIONS : void 0,
|
|
1142
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
function autoVerdict(result) {
|
|
1146
|
+
if (!result.verdict) return null;
|
|
1147
|
+
if (result.status === "needs-judgment") return null;
|
|
1148
|
+
if (result.verdict.label === "ambiguous") return null;
|
|
1149
|
+
return {
|
|
1150
|
+
id: result.id,
|
|
1151
|
+
verdict: result.verdict,
|
|
1152
|
+
rationale: result.verdict.rationale.join(" "),
|
|
1153
|
+
confidence: 1
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
async function discoverTiles(dir) {
|
|
1157
|
+
const locatorAbs = path.join(dir, "locator.png");
|
|
1158
|
+
const tilesAbs = path.join(dir, "regions.png");
|
|
1159
|
+
return {
|
|
1160
|
+
locatorPath: existsSync(locatorAbs) ? "locator.png" : void 0,
|
|
1161
|
+
tilesPath: existsSync(tilesAbs) ? "regions.png" : void 0
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
async function writeJudgments(opts) {
|
|
1165
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1166
|
+
const root = paths(cwd).judgments;
|
|
1167
|
+
await mkdir(root, { recursive: true });
|
|
1168
|
+
const knownIds = /* @__PURE__ */ new Set();
|
|
1169
|
+
for (const r of opts.report.results) knownIds.add(r.id);
|
|
1170
|
+
for (const result of opts.report.results) {
|
|
1171
|
+
const dir = path.join(root, result.id);
|
|
1172
|
+
if (result.status === "pass") {
|
|
1173
|
+
if (existsSync(dir)) await rm(dir, { recursive: true, force: true });
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
const entry = entryById(opts.manifest, result.id);
|
|
1177
|
+
if (!entry) continue;
|
|
1178
|
+
await mkdir(dir, { recursive: true });
|
|
1179
|
+
const tiles = await discoverTiles(dir);
|
|
1180
|
+
const request = buildRequest(result, entry, cwd, tiles);
|
|
1181
|
+
const requestFile = path.join(dir, "request.json");
|
|
1182
|
+
const prior = await readJsonOrNull2(requestFile);
|
|
1183
|
+
const verdictFile = path.join(dir, "verdict.json");
|
|
1184
|
+
const priorVerdict = existsSync(verdictFile) ? await readJsonOrNull2(verdictFile) : null;
|
|
1185
|
+
const signatureMatches = prior !== null && prior.signature === request.signature;
|
|
1186
|
+
await writeFile(
|
|
1187
|
+
requestFile,
|
|
1188
|
+
`${JSON.stringify(request, null, 2)}
|
|
1189
|
+
`,
|
|
1190
|
+
"utf8"
|
|
1191
|
+
);
|
|
1192
|
+
if (priorVerdict && signatureMatches) {
|
|
1193
|
+
continue;
|
|
1194
|
+
}
|
|
1195
|
+
const auto = autoVerdict(result);
|
|
1196
|
+
if (auto) {
|
|
1197
|
+
await writeFile(
|
|
1198
|
+
verdictFile,
|
|
1199
|
+
`${JSON.stringify(auto, null, 2)}
|
|
1200
|
+
`,
|
|
1201
|
+
"utf8"
|
|
1202
|
+
);
|
|
1203
|
+
} else if (priorVerdict && !signatureMatches) {
|
|
1204
|
+
await rm(verdictFile, { force: true });
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
let entries;
|
|
1208
|
+
try {
|
|
1209
|
+
entries = await readdir(root);
|
|
1210
|
+
} catch {
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
for (const name of entries) {
|
|
1214
|
+
if (knownIds.has(name)) continue;
|
|
1215
|
+
await rm(path.join(root, name), { recursive: true, force: true });
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// src/judge/index.ts
|
|
1220
|
+
function resolveJudge(backend) {
|
|
1221
|
+
switch (backend) {
|
|
1222
|
+
case "none":
|
|
1223
|
+
return noneJudge;
|
|
1224
|
+
case "host":
|
|
1225
|
+
return hostHarnessJudge;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
function escapeXml(value) {
|
|
1229
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1230
|
+
}
|
|
1231
|
+
async function writeJunit(report, destPath) {
|
|
1232
|
+
await mkdir(path.dirname(destPath), { recursive: true });
|
|
1233
|
+
const cases = report.results.map((r) => {
|
|
1234
|
+
if (r.status === "pass") {
|
|
1235
|
+
return ` <testcase classname="blazediff" name="${escapeXml(r.id)}"/>`;
|
|
1236
|
+
}
|
|
1237
|
+
const message = r.message ?? r.status;
|
|
1238
|
+
return ` <testcase classname="blazediff" name="${escapeXml(r.id)}">
|
|
1239
|
+
<failure message="${escapeXml(message)}" type="${escapeXml(r.status)}">${escapeXml(message)}</failure>
|
|
1240
|
+
</testcase>`;
|
|
1241
|
+
});
|
|
1242
|
+
const failures = report.totalEntries - report.passed;
|
|
1243
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1244
|
+
<testsuites>
|
|
1245
|
+
<testsuite name="blazediff" tests="${report.totalEntries}" failures="${failures}">
|
|
1246
|
+
${cases.join("\n")}
|
|
1247
|
+
</testsuite>
|
|
1248
|
+
</testsuites>
|
|
1249
|
+
`;
|
|
1250
|
+
await writeFile(destPath, xml, "utf8");
|
|
1251
|
+
return destPath;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// src/check.ts
|
|
1255
|
+
function narrowRegion(r) {
|
|
1256
|
+
return {
|
|
1257
|
+
bbox: r.bbox,
|
|
1258
|
+
pixelCount: r.pixelCount,
|
|
1259
|
+
percentage: r.percentage,
|
|
1260
|
+
changeType: r.changeType,
|
|
1261
|
+
confidence: r.confidence
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
async function pool(items, limit, fn) {
|
|
1265
|
+
const results = new Array(items.length);
|
|
1266
|
+
let next = 0;
|
|
1267
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
1268
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
1269
|
+
while (true) {
|
|
1270
|
+
const i = next++;
|
|
1271
|
+
if (i >= items.length) return;
|
|
1272
|
+
results[i] = await fn(items[i], i);
|
|
1273
|
+
}
|
|
1274
|
+
});
|
|
1275
|
+
await Promise.all(workers);
|
|
1276
|
+
return results;
|
|
1277
|
+
}
|
|
1278
|
+
function passResult2(entry, baselinePath, actualPath) {
|
|
1279
|
+
return {
|
|
1280
|
+
id: entry.id,
|
|
1281
|
+
url: entry.url,
|
|
1282
|
+
status: "pass",
|
|
1283
|
+
baselinePath,
|
|
1284
|
+
actualPath
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function skipResult(entry, message) {
|
|
1288
|
+
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1289
|
+
}
|
|
1290
|
+
function staleResult(entry) {
|
|
1291
|
+
return {
|
|
1292
|
+
id: entry.id,
|
|
1293
|
+
url: entry.url,
|
|
1294
|
+
status: "stale-baseline",
|
|
1295
|
+
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function missingBaselineResult(entry, baselinePath) {
|
|
1299
|
+
return {
|
|
1300
|
+
id: entry.id,
|
|
1301
|
+
url: entry.url,
|
|
1302
|
+
status: "missing-baseline",
|
|
1303
|
+
message: `baseline missing at ${baselinePath}`
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
function failResult(entry, outcome, actualPath, baselinePath, verdict) {
|
|
1307
|
+
return {
|
|
1308
|
+
id: entry.id,
|
|
1309
|
+
url: entry.url,
|
|
1310
|
+
status: "fail",
|
|
1311
|
+
diffCount: outcome.diffCount,
|
|
1312
|
+
diffPercentage: outcome.diffPercentage,
|
|
1313
|
+
severity: outcome.interpretation?.severity,
|
|
1314
|
+
regions: outcome.interpretation?.regions?.map(narrowRegion),
|
|
1315
|
+
verdict,
|
|
1316
|
+
diffPath: outcome.diffPath,
|
|
1317
|
+
baselinePath,
|
|
1318
|
+
actualPath,
|
|
1319
|
+
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
async function judgeAmbiguous(result, entry, judge, cwd) {
|
|
1323
|
+
if (result.status !== "fail" || !result.verdict || result.verdict.label !== "ambiguous" || !result.baselinePath || !result.actualPath) {
|
|
1324
|
+
return result;
|
|
1325
|
+
}
|
|
1326
|
+
const output = await judge.judge(
|
|
1327
|
+
{
|
|
1328
|
+
entry,
|
|
1329
|
+
baselinePath: result.baselinePath,
|
|
1330
|
+
actualPath: result.actualPath,
|
|
1331
|
+
diffPath: result.diffPath,
|
|
1332
|
+
regions: result.regions,
|
|
1333
|
+
diffPercentage: result.diffPercentage,
|
|
1334
|
+
severity: result.severity,
|
|
1335
|
+
heuristicVerdict: result.verdict
|
|
1336
|
+
},
|
|
1337
|
+
cwd
|
|
1338
|
+
);
|
|
1339
|
+
if (output.kind === "judged") {
|
|
1340
|
+
return { ...result, verdict: output.verdict };
|
|
1341
|
+
}
|
|
1342
|
+
return {
|
|
1343
|
+
...result,
|
|
1344
|
+
status: "needs-judgment",
|
|
1345
|
+
message: `awaiting judgment in ${output.requestPath}`
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
async function checkEntry(entry, opts, cwd, baselinesDir, judge) {
|
|
1349
|
+
if (entry.auth === "required") {
|
|
1350
|
+
return skipResult(entry, "skipped: auth required (deferred to v0.2)");
|
|
1351
|
+
}
|
|
1352
|
+
if (isEntryStale(entry)) {
|
|
1353
|
+
return staleResult(entry);
|
|
1354
|
+
}
|
|
1355
|
+
const baselinePath = path.join(baselinesDir, `${entry.id}.png`);
|
|
1356
|
+
const capture = await captureScreenshot(
|
|
1357
|
+
opts.baseUrl,
|
|
1358
|
+
{
|
|
1359
|
+
id: entry.id,
|
|
1360
|
+
url: entry.url,
|
|
1361
|
+
viewport: entry.viewport,
|
|
1362
|
+
mask: entry.mask,
|
|
1363
|
+
waitFor: entry.waitFor,
|
|
1364
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1365
|
+
mode: "actual"
|
|
1366
|
+
},
|
|
1367
|
+
cwd
|
|
1368
|
+
);
|
|
1369
|
+
const outcome = await diffEntry(
|
|
1370
|
+
entry.id,
|
|
1371
|
+
baselinePath,
|
|
1372
|
+
capture.outputPath,
|
|
1373
|
+
{ threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
|
|
1374
|
+
cwd
|
|
1375
|
+
);
|
|
1376
|
+
if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
|
|
1377
|
+
if (outcome.reason === "file-not-exists")
|
|
1378
|
+
return missingBaselineResult(entry, baselinePath);
|
|
1379
|
+
const verdict = deriveVerdict({
|
|
1380
|
+
reason: outcome.reason,
|
|
1381
|
+
interpretation: outcome.interpretation,
|
|
1382
|
+
diffCount: outcome.diffCount,
|
|
1383
|
+
diffPercentage: outcome.diffPercentage
|
|
1384
|
+
});
|
|
1385
|
+
const failed = failResult(
|
|
1386
|
+
entry,
|
|
1387
|
+
outcome,
|
|
1388
|
+
capture.outputPath,
|
|
1389
|
+
baselinePath,
|
|
1390
|
+
verdict
|
|
1391
|
+
);
|
|
1392
|
+
return judgeAmbiguous(failed, entry, judge, cwd);
|
|
1393
|
+
}
|
|
1394
|
+
async function runCheck(opts) {
|
|
1395
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1396
|
+
const manifest = await loadManifest(cwd);
|
|
1397
|
+
if (!manifest) {
|
|
1398
|
+
throw new Error(
|
|
1399
|
+
`no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
const baselinesDir = paths(cwd).baselines;
|
|
1403
|
+
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
1404
|
+
const judge = resolveJudge(opts.judge ?? "none");
|
|
1405
|
+
let results;
|
|
1406
|
+
try {
|
|
1407
|
+
results = await pool(
|
|
1408
|
+
manifest.entries,
|
|
1409
|
+
concurrency,
|
|
1410
|
+
(entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
|
|
1411
|
+
);
|
|
1412
|
+
} finally {
|
|
1413
|
+
await closeBrowser();
|
|
1414
|
+
}
|
|
1415
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1416
|
+
const pendingJudgments = results.filter(
|
|
1417
|
+
(r) => r.status === "needs-judgment"
|
|
1418
|
+
).length;
|
|
1419
|
+
const report = {
|
|
1420
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1421
|
+
totalEntries: results.length,
|
|
1422
|
+
passed,
|
|
1423
|
+
failed: results.length - passed - pendingJudgments,
|
|
1424
|
+
pendingJudgments,
|
|
1425
|
+
results
|
|
1426
|
+
};
|
|
1427
|
+
await writeJudgments({ report, manifest, cwd });
|
|
1428
|
+
await writeSummaryMarkdown(report, cwd);
|
|
1429
|
+
await ensureGitignore(cwd);
|
|
1430
|
+
if (opts.junitPath) {
|
|
1431
|
+
const target = path.isAbsolute(opts.junitPath) ? opts.junitPath : path.join(cwd, opts.junitPath);
|
|
1432
|
+
await writeJunit(report, target);
|
|
1433
|
+
}
|
|
1434
|
+
return report;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// src/discover/crawl.ts
|
|
1438
|
+
function extractInternalLinks(base, target, hrefs) {
|
|
1439
|
+
const out = [];
|
|
1440
|
+
for (const href of hrefs) {
|
|
1441
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
try {
|
|
1445
|
+
const u = new URL(href, target);
|
|
1446
|
+
if (u.origin !== base.origin) continue;
|
|
1447
|
+
const path18 = u.pathname + u.search;
|
|
1448
|
+
if (path18.startsWith("/api/")) continue;
|
|
1449
|
+
out.push(path18);
|
|
1450
|
+
} catch {
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
return out;
|
|
1454
|
+
}
|
|
1455
|
+
async function crawlRoutes(opts) {
|
|
1456
|
+
const maxRoutes = opts.maxRoutes ?? 50;
|
|
1457
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
1458
|
+
const base = new URL(opts.baseUrl);
|
|
1459
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1460
|
+
const queue = [{ url: "/", depth: 0 }];
|
|
1461
|
+
visited.add("/");
|
|
1462
|
+
const discovered = [];
|
|
1463
|
+
const browser = await getBrowser();
|
|
1464
|
+
const context = await browser.newContext({
|
|
1465
|
+
viewport: { width: 1024, height: 768 },
|
|
1466
|
+
deviceScaleFactor: 1
|
|
1467
|
+
});
|
|
1468
|
+
try {
|
|
1469
|
+
while (queue.length && discovered.length < maxRoutes) {
|
|
1470
|
+
const { url, depth } = queue.shift();
|
|
1471
|
+
const page = await context.newPage();
|
|
1472
|
+
try {
|
|
1473
|
+
const target = new URL(url, base).toString();
|
|
1474
|
+
await page.goto(target, {
|
|
1475
|
+
waitUntil: "domcontentloaded",
|
|
1476
|
+
timeout: 15e3
|
|
1477
|
+
});
|
|
1478
|
+
discovered.push({ url, source: "crawl" });
|
|
1479
|
+
if (depth >= maxDepth) continue;
|
|
1480
|
+
const hrefs = await page.evaluate(
|
|
1481
|
+
() => Array.from(
|
|
1482
|
+
document.querySelectorAll("a[href]")
|
|
1483
|
+
).map((a) => a.getAttribute("href") ?? "")
|
|
1484
|
+
);
|
|
1485
|
+
for (const path18 of extractInternalLinks(base, target, hrefs)) {
|
|
1486
|
+
if (visited.has(path18)) continue;
|
|
1487
|
+
visited.add(path18);
|
|
1488
|
+
queue.push({ url: path18, depth: depth + 1 });
|
|
1489
|
+
}
|
|
1490
|
+
} catch {
|
|
1491
|
+
} finally {
|
|
1492
|
+
await page.close().catch(() => {
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
} finally {
|
|
1497
|
+
await context.close().catch(() => {
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
return discovered;
|
|
1501
|
+
}
|
|
1502
|
+
var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
|
|
1503
|
+
async function readJson(file) {
|
|
1504
|
+
if (!existsSync(file)) return null;
|
|
1505
|
+
try {
|
|
1506
|
+
return JSON.parse(await readFile(file, "utf8"));
|
|
1507
|
+
} catch {
|
|
1508
|
+
return null;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
function isPublicRoute(route) {
|
|
1512
|
+
if (DYNAMIC_SEGMENT.test(route)) return false;
|
|
1513
|
+
if (route === "/api" || route.startsWith("/api/")) return false;
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
async function discoverFromNextManifest(cwd = process.cwd()) {
|
|
1517
|
+
const nextDir = path.join(cwd, ".next");
|
|
1518
|
+
if (!existsSync(nextDir)) return [];
|
|
1519
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1520
|
+
const out = [];
|
|
1521
|
+
const add = (url) => {
|
|
1522
|
+
if (seen.has(url)) return;
|
|
1523
|
+
seen.add(url);
|
|
1524
|
+
out.push({ url, source: "next-manifest" });
|
|
1525
|
+
};
|
|
1526
|
+
const routes = await readJson(
|
|
1527
|
+
path.join(nextDir, "routes-manifest.json")
|
|
1528
|
+
);
|
|
1529
|
+
for (const r of routes?.staticRoutes ?? []) {
|
|
1530
|
+
if (isPublicRoute(r.page)) add(r.page);
|
|
1531
|
+
}
|
|
1532
|
+
const appPaths = await readJson(
|
|
1533
|
+
path.join(nextDir, "server", "app-paths-manifest.json")
|
|
1534
|
+
);
|
|
1535
|
+
for (const route of Object.keys(appPaths ?? {})) {
|
|
1536
|
+
const normalized = route.replace(/\/page$/, "") || "/";
|
|
1537
|
+
if (isPublicRoute(normalized)) add(normalized);
|
|
1538
|
+
}
|
|
1539
|
+
return out;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
// src/discover/sitemap.ts
|
|
1543
|
+
var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
|
|
1544
|
+
var LOC_RE = /<loc>([^<]+)<\/loc>/g;
|
|
1545
|
+
async function discoverFromSitemap(baseUrl) {
|
|
1546
|
+
for (const candidate of CANDIDATES) {
|
|
1547
|
+
try {
|
|
1548
|
+
const res = await fetch(new URL(candidate, baseUrl));
|
|
1549
|
+
if (!res.ok) continue;
|
|
1550
|
+
const text = await res.text();
|
|
1551
|
+
const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
|
|
1552
|
+
if (!urls.length) continue;
|
|
1553
|
+
return urls.map((u) => {
|
|
1554
|
+
const url = new URL(u);
|
|
1555
|
+
return { url: url.pathname + url.search, source: "sitemap" };
|
|
1556
|
+
});
|
|
1557
|
+
} catch {
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
return [];
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/discover/index.ts
|
|
1564
|
+
function normalizePath(url) {
|
|
1565
|
+
const [pathPart, query = ""] = url.split("?", 2);
|
|
1566
|
+
const trimmed = pathPart.replace(/\/+$/, "");
|
|
1567
|
+
const normalizedPath = trimmed === "" ? "/" : trimmed;
|
|
1568
|
+
return query ? `${normalizedPath}?${query}` : normalizedPath;
|
|
1569
|
+
}
|
|
1570
|
+
function mergeBy(routes, into) {
|
|
1571
|
+
for (const r of routes) {
|
|
1572
|
+
const key = normalizePath(r.url);
|
|
1573
|
+
if (!into.has(key)) into.set(key, { ...r, url: key });
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
async function discover(opts) {
|
|
1577
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1578
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1579
|
+
mergeBy(await discoverFromNextManifest(cwd), merged);
|
|
1580
|
+
mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
|
|
1581
|
+
if (!opts.skipCrawl) {
|
|
1582
|
+
const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
|
|
1583
|
+
if (crawlMax > 0) {
|
|
1584
|
+
mergeBy(
|
|
1585
|
+
await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
|
|
1586
|
+
merged
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// src/graph/nodes/aggregate.ts
|
|
1594
|
+
async function aggregateNode(state) {
|
|
1595
|
+
const options = state.options;
|
|
1596
|
+
if (!options) throw new Error("aggregateNode: options missing");
|
|
1597
|
+
const manifest = state.manifest;
|
|
1598
|
+
if (!manifest) throw new Error("aggregateNode: manifest missing");
|
|
1599
|
+
const results = state.results;
|
|
1600
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1601
|
+
const pendingJudgments = results.filter(
|
|
1602
|
+
(r) => r.status === "needs-judgment"
|
|
1603
|
+
).length;
|
|
1604
|
+
const report = {
|
|
1605
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1606
|
+
totalEntries: results.length,
|
|
1607
|
+
passed,
|
|
1608
|
+
failed: results.length - passed - pendingJudgments,
|
|
1609
|
+
pendingJudgments,
|
|
1610
|
+
results
|
|
1611
|
+
};
|
|
1612
|
+
await writeJudgments({ report, manifest, cwd: options.cwd });
|
|
1613
|
+
await writeSummaryMarkdown(report, options.cwd);
|
|
1614
|
+
await ensureGitignore(options.cwd);
|
|
1615
|
+
return { report };
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/graph/nodes/load.ts
|
|
1619
|
+
async function loadNode(state) {
|
|
1620
|
+
if (!state.options) {
|
|
1621
|
+
throw new Error("loadNode: graph options missing");
|
|
1622
|
+
}
|
|
1623
|
+
const manifest = await loadManifest(state.options.cwd);
|
|
1624
|
+
if (!manifest) {
|
|
1625
|
+
throw new Error(
|
|
1626
|
+
`no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
return { entries: manifest.entries, manifest };
|
|
1630
|
+
}
|
|
1631
|
+
function narrowRegion2(r) {
|
|
1632
|
+
return {
|
|
1633
|
+
bbox: r.bbox,
|
|
1634
|
+
pixelCount: r.pixelCount,
|
|
1635
|
+
percentage: r.percentage,
|
|
1636
|
+
changeType: r.changeType,
|
|
1637
|
+
confidence: r.confidence
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
function skipResult2(entry, message) {
|
|
1641
|
+
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1642
|
+
}
|
|
1643
|
+
function staleResult2(entry) {
|
|
1644
|
+
return {
|
|
1645
|
+
id: entry.id,
|
|
1646
|
+
url: entry.url,
|
|
1647
|
+
status: "stale-baseline",
|
|
1648
|
+
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1649
|
+
};
|
|
1650
|
+
}
|
|
1651
|
+
function passResult3(entry, baselinePath, actualPath) {
|
|
1652
|
+
return {
|
|
1653
|
+
id: entry.id,
|
|
1654
|
+
url: entry.url,
|
|
1655
|
+
status: "pass",
|
|
1656
|
+
baselinePath,
|
|
1657
|
+
actualPath
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
function missingBaselineResult2(entry, baselinePath) {
|
|
1661
|
+
return {
|
|
1662
|
+
id: entry.id,
|
|
1663
|
+
url: entry.url,
|
|
1664
|
+
status: "missing-baseline",
|
|
1665
|
+
message: `baseline missing at ${baselinePath}`
|
|
1666
|
+
};
|
|
1667
|
+
}
|
|
1668
|
+
function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
|
|
1669
|
+
return {
|
|
1670
|
+
id: entry.id,
|
|
1671
|
+
url: entry.url,
|
|
1672
|
+
status: "fail",
|
|
1673
|
+
diffCount: outcome.diffCount,
|
|
1674
|
+
diffPercentage: outcome.diffPercentage,
|
|
1675
|
+
severity: outcome.interpretation?.severity,
|
|
1676
|
+
regions: outcome.interpretation?.regions?.map(narrowRegion2),
|
|
1677
|
+
verdict,
|
|
1678
|
+
diffPath: outcome.diffPath,
|
|
1679
|
+
baselinePath,
|
|
1680
|
+
actualPath,
|
|
1681
|
+
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
function makeProcessNode(semaphore) {
|
|
1685
|
+
return async function processNode(state) {
|
|
1686
|
+
const entry = state.entry;
|
|
1687
|
+
const options = state.options;
|
|
1688
|
+
if (!entry || !options) {
|
|
1689
|
+
throw new Error("processNode: entry or options missing");
|
|
1690
|
+
}
|
|
1691
|
+
if (entry.auth === "required") {
|
|
1692
|
+
return {
|
|
1693
|
+
results: [
|
|
1694
|
+
skipResult2(entry, "skipped: auth required (deferred to v0.2)")
|
|
1695
|
+
]
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
if (isEntryStale(entry)) {
|
|
1699
|
+
return { results: [staleResult2(entry)] };
|
|
1700
|
+
}
|
|
1701
|
+
const capture = await semaphore.run(
|
|
1702
|
+
() => captureScreenshot(
|
|
1703
|
+
options.baseUrl,
|
|
1704
|
+
{
|
|
1705
|
+
id: entry.id,
|
|
1706
|
+
url: entry.url,
|
|
1707
|
+
viewport: entry.viewport,
|
|
1708
|
+
mask: entry.mask,
|
|
1709
|
+
waitFor: entry.waitFor,
|
|
1710
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1711
|
+
mode: "actual"
|
|
1712
|
+
},
|
|
1713
|
+
options.cwd
|
|
1714
|
+
)
|
|
1715
|
+
);
|
|
1716
|
+
const baselinePath = path.join(options.baselinesDir, `${entry.id}.png`);
|
|
1717
|
+
const outcome = await diffEntry(
|
|
1718
|
+
entry.id,
|
|
1719
|
+
baselinePath,
|
|
1720
|
+
capture.outputPath,
|
|
1721
|
+
{ threshold: options.threshold, emitDiffPng: options.emitDiffPng },
|
|
1722
|
+
options.cwd
|
|
1723
|
+
);
|
|
1724
|
+
if (outcome.match) {
|
|
1725
|
+
return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
|
|
1726
|
+
}
|
|
1727
|
+
if (outcome.reason === "file-not-exists") {
|
|
1728
|
+
return { results: [missingBaselineResult2(entry, baselinePath)] };
|
|
1729
|
+
}
|
|
1730
|
+
const verdict = deriveVerdict({
|
|
1731
|
+
reason: outcome.reason,
|
|
1732
|
+
interpretation: outcome.interpretation,
|
|
1733
|
+
diffCount: outcome.diffCount,
|
|
1734
|
+
diffPercentage: outcome.diffPercentage
|
|
1735
|
+
});
|
|
1736
|
+
let result = failResult2(
|
|
1737
|
+
entry,
|
|
1738
|
+
outcome,
|
|
1739
|
+
capture.outputPath,
|
|
1740
|
+
baselinePath,
|
|
1741
|
+
verdict
|
|
1742
|
+
);
|
|
1743
|
+
if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
|
|
1744
|
+
const judge = resolveJudge(options.judge);
|
|
1745
|
+
const output = await judge.judge(
|
|
1746
|
+
{
|
|
1747
|
+
entry,
|
|
1748
|
+
baselinePath: result.baselinePath,
|
|
1749
|
+
actualPath: result.actualPath,
|
|
1750
|
+
diffPath: result.diffPath,
|
|
1751
|
+
regions: result.regions,
|
|
1752
|
+
diffPercentage: result.diffPercentage,
|
|
1753
|
+
severity: result.severity,
|
|
1754
|
+
heuristicVerdict: result.verdict
|
|
1755
|
+
},
|
|
1756
|
+
options.cwd
|
|
1757
|
+
);
|
|
1758
|
+
if (output.kind === "judged") {
|
|
1759
|
+
result = { ...result, verdict: output.verdict };
|
|
1760
|
+
} else {
|
|
1761
|
+
result = {
|
|
1762
|
+
...result,
|
|
1763
|
+
status: "needs-judgment",
|
|
1764
|
+
message: `awaiting judgment in ${output.requestPath}`
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
return { results: [result] };
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// src/graph/semaphore.ts
|
|
1773
|
+
var Semaphore = class {
|
|
1774
|
+
constructor(limit) {
|
|
1775
|
+
this.limit = limit;
|
|
1776
|
+
this.current = 0;
|
|
1777
|
+
this.queue = [];
|
|
1778
|
+
if (limit < 1)
|
|
1779
|
+
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
1780
|
+
}
|
|
1781
|
+
async run(fn) {
|
|
1782
|
+
if (this.current >= this.limit) {
|
|
1783
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
1784
|
+
}
|
|
1785
|
+
this.current++;
|
|
1786
|
+
try {
|
|
1787
|
+
return await fn();
|
|
1788
|
+
} finally {
|
|
1789
|
+
this.current--;
|
|
1790
|
+
const next = this.queue.shift();
|
|
1791
|
+
if (next) next();
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
};
|
|
1795
|
+
var GraphState = Annotation.Root({
|
|
1796
|
+
options: Annotation({
|
|
1797
|
+
reducer: (acc, next) => next ?? acc,
|
|
1798
|
+
default: () => void 0
|
|
1799
|
+
}),
|
|
1800
|
+
entries: Annotation({
|
|
1801
|
+
reducer: (acc, next) => next ?? acc,
|
|
1802
|
+
default: () => []
|
|
1803
|
+
}),
|
|
1804
|
+
entry: Annotation({
|
|
1805
|
+
reducer: (_, next) => next,
|
|
1806
|
+
default: () => void 0
|
|
1807
|
+
}),
|
|
1808
|
+
results: Annotation({
|
|
1809
|
+
reducer: (acc, next) => [...acc, ...next],
|
|
1810
|
+
default: () => []
|
|
1811
|
+
}),
|
|
1812
|
+
manifest: Annotation({
|
|
1813
|
+
reducer: (acc, next) => next ?? acc,
|
|
1814
|
+
default: () => void 0
|
|
1815
|
+
}),
|
|
1816
|
+
report: Annotation({
|
|
1817
|
+
reducer: (acc, next) => next ?? acc,
|
|
1818
|
+
default: () => void 0
|
|
1819
|
+
})
|
|
1820
|
+
});
|
|
1821
|
+
|
|
1822
|
+
// src/graph/index.ts
|
|
1823
|
+
function buildGraph(semaphore) {
|
|
1824
|
+
return new StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(START, "load").addConditionalEdges(
|
|
1825
|
+
"load",
|
|
1826
|
+
(state) => state.entries.map(
|
|
1827
|
+
(entry) => new Send("process", { entry, options: state.options })
|
|
1828
|
+
),
|
|
1829
|
+
["process"]
|
|
1830
|
+
).addEdge("process", "aggregate").addEdge("aggregate", END).compile();
|
|
1831
|
+
}
|
|
1832
|
+
async function runGraph(opts) {
|
|
1833
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1834
|
+
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
1835
|
+
const semaphore = new Semaphore(concurrency);
|
|
1836
|
+
const baselinesDir = paths(cwd).baselines;
|
|
1837
|
+
const graph = buildGraph(semaphore);
|
|
1838
|
+
let finalState;
|
|
1839
|
+
try {
|
|
1840
|
+
finalState = await graph.invoke({
|
|
1841
|
+
options: {
|
|
1842
|
+
baseUrl: opts.baseUrl,
|
|
1843
|
+
cwd,
|
|
1844
|
+
threshold: opts.threshold,
|
|
1845
|
+
concurrency,
|
|
1846
|
+
emitDiffPng: opts.emitDiffPng ?? true,
|
|
1847
|
+
judge: opts.judge ?? "none",
|
|
1848
|
+
baselinesDir
|
|
1849
|
+
}
|
|
1850
|
+
});
|
|
1851
|
+
} finally {
|
|
1852
|
+
await closeBrowser();
|
|
1853
|
+
}
|
|
1854
|
+
const report = finalState.report;
|
|
1855
|
+
if (!report) {
|
|
1856
|
+
throw new Error("runGraph: graph completed without producing a report");
|
|
1857
|
+
}
|
|
1858
|
+
if (opts.junitPath) {
|
|
1859
|
+
const target = path.isAbsolute(opts.junitPath) ? opts.junitPath : path.join(cwd, opts.junitPath);
|
|
1860
|
+
await writeJunit(report, target);
|
|
1861
|
+
}
|
|
1862
|
+
return report;
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
export { STABILITY_HOOKS_VERSION, applyJudgments, captureScreenshot, configHash, diffEntry, discover, installBrowsers, loadConfig, loadManifest, paths, resolveBaseUrl, resolveJudge, runCaptures, runCheck, runGraph, saveConfig, saveManifest };
|