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