@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/cli.js
ADDED
|
@@ -0,0 +1,3205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var commander = require('commander');
|
|
5
|
+
var playwright = require('playwright');
|
|
6
|
+
var child_process = require('child_process');
|
|
7
|
+
var fs = require('fs');
|
|
8
|
+
var module$1 = require('module');
|
|
9
|
+
var path2 = require('path');
|
|
10
|
+
var promises = require('fs/promises');
|
|
11
|
+
var os = require('os');
|
|
12
|
+
var crypto$1 = require('crypto');
|
|
13
|
+
var coreNative = require('@blazediff/core-native');
|
|
14
|
+
var sharp = require('sharp');
|
|
15
|
+
var promises$1 = require('readline/promises');
|
|
16
|
+
var url = require('url');
|
|
17
|
+
var net = require('net');
|
|
18
|
+
var util = require('util');
|
|
19
|
+
var treeKill = require('tree-kill');
|
|
20
|
+
var langgraph = require('@langchain/langgraph');
|
|
21
|
+
|
|
22
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
23
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
24
|
+
|
|
25
|
+
var path2__default = /*#__PURE__*/_interopDefault(path2);
|
|
26
|
+
var sharp__default = /*#__PURE__*/_interopDefault(sharp);
|
|
27
|
+
var treeKill__default = /*#__PURE__*/_interopDefault(treeKill);
|
|
28
|
+
|
|
29
|
+
var FROZEN_NOW = Date.UTC(2025, 0, 1, 0, 0, 0);
|
|
30
|
+
var STABILITY_CSS = `
|
|
31
|
+
*,*::before,*::after{
|
|
32
|
+
animation-delay:-0.0001s !important;
|
|
33
|
+
animation-duration:0s !important;
|
|
34
|
+
animation-iteration-count:1 !important;
|
|
35
|
+
transition-delay:0s !important;
|
|
36
|
+
transition-duration:0s !important;
|
|
37
|
+
caret-color:transparent !important;
|
|
38
|
+
}
|
|
39
|
+
html{scroll-behavior:auto !important}
|
|
40
|
+
`;
|
|
41
|
+
var CHROMIUM_FLAGS = [
|
|
42
|
+
"--font-render-hinting=none",
|
|
43
|
+
"--disable-skia-runtime-opts",
|
|
44
|
+
"--force-color-profile=srgb",
|
|
45
|
+
"--disable-lcd-text",
|
|
46
|
+
"--disable-background-timer-throttling",
|
|
47
|
+
"--disable-renderer-backgrounding",
|
|
48
|
+
"--disable-backgrounding-occluded-windows",
|
|
49
|
+
"--hide-scrollbars"
|
|
50
|
+
];
|
|
51
|
+
var cachedBrowser = null;
|
|
52
|
+
var launchInFlight = null;
|
|
53
|
+
async function getBrowser() {
|
|
54
|
+
if (cachedBrowser?.isConnected()) return cachedBrowser;
|
|
55
|
+
if (launchInFlight) return launchInFlight;
|
|
56
|
+
launchInFlight = playwright.chromium.launch({ headless: true, args: CHROMIUM_FLAGS }).then((b) => {
|
|
57
|
+
cachedBrowser = b;
|
|
58
|
+
return b;
|
|
59
|
+
}).finally(() => {
|
|
60
|
+
launchInFlight = null;
|
|
61
|
+
});
|
|
62
|
+
return launchInFlight;
|
|
63
|
+
}
|
|
64
|
+
async function closeBrowser() {
|
|
65
|
+
if (launchInFlight) {
|
|
66
|
+
await launchInFlight.catch(() => {
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (!cachedBrowser) return;
|
|
70
|
+
await cachedBrowser.close().catch(() => {
|
|
71
|
+
});
|
|
72
|
+
cachedBrowser = null;
|
|
73
|
+
}
|
|
74
|
+
async function openStableContext(opts) {
|
|
75
|
+
const browser = await getBrowser();
|
|
76
|
+
const context = await browser.newContext({
|
|
77
|
+
viewport: opts.viewport,
|
|
78
|
+
deviceScaleFactor: 1,
|
|
79
|
+
reducedMotion: "reduce",
|
|
80
|
+
forcedColors: "none",
|
|
81
|
+
colorScheme: "light",
|
|
82
|
+
bypassCSP: true
|
|
83
|
+
});
|
|
84
|
+
await context.addInitScript(
|
|
85
|
+
({ frozenNow }) => {
|
|
86
|
+
Object.defineProperty(Date, "now", {
|
|
87
|
+
value: () => frozenNow,
|
|
88
|
+
writable: true,
|
|
89
|
+
configurable: true
|
|
90
|
+
});
|
|
91
|
+
let perfTick = 0;
|
|
92
|
+
Object.defineProperty(performance, "now", {
|
|
93
|
+
value: () => {
|
|
94
|
+
perfTick += 16.6667;
|
|
95
|
+
return perfTick;
|
|
96
|
+
},
|
|
97
|
+
writable: true,
|
|
98
|
+
configurable: true
|
|
99
|
+
});
|
|
100
|
+
let seed = 2654435769 | 0;
|
|
101
|
+
Math.random = () => {
|
|
102
|
+
seed = seed + 1831565813 | 0;
|
|
103
|
+
let t = seed;
|
|
104
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
105
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
106
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
107
|
+
};
|
|
108
|
+
if (typeof crypto !== "undefined") {
|
|
109
|
+
let uuidCounter = 0;
|
|
110
|
+
Object.defineProperty(crypto, "randomUUID", {
|
|
111
|
+
value: () => {
|
|
112
|
+
uuidCounter += 1;
|
|
113
|
+
return `00000000-0000-4000-8000-${uuidCounter.toString(16).padStart(12, "0")}`;
|
|
114
|
+
},
|
|
115
|
+
writable: true,
|
|
116
|
+
configurable: true
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{ frozenNow: FROZEN_NOW }
|
|
121
|
+
);
|
|
122
|
+
const page = await context.newPage();
|
|
123
|
+
const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
|
|
124
|
+
});
|
|
125
|
+
await injectStability();
|
|
126
|
+
page.on("load", injectStability);
|
|
127
|
+
return { context, page };
|
|
128
|
+
}
|
|
129
|
+
async function waitForStability(page, waitFor) {
|
|
130
|
+
for (const w of waitFor) {
|
|
131
|
+
if (w === "networkidle") {
|
|
132
|
+
await page.waitForLoadState("networkidle").catch(() => {
|
|
133
|
+
});
|
|
134
|
+
} else if (w === "fonts") {
|
|
135
|
+
await page.evaluate(
|
|
136
|
+
() => document.fonts && "ready" in document.fonts ? document.fonts.ready.then(() => void 0) : void 0
|
|
137
|
+
).catch(() => {
|
|
138
|
+
});
|
|
139
|
+
} else {
|
|
140
|
+
await page.waitForSelector(w.selector, { timeout: w.timeoutMs ?? 5e3 }).catch(() => {
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
var DEFAULT_MASK_ATTR = "data-blazediff-agent-mask";
|
|
146
|
+
var DEFAULT_MASK_SELECTOR = `[${DEFAULT_MASK_ATTR}]`;
|
|
147
|
+
async function applyMaskOverlays(page, masks) {
|
|
148
|
+
const selectors = [DEFAULT_MASK_SELECTOR, ...masks];
|
|
149
|
+
await page.evaluate((selectors2) => {
|
|
150
|
+
for (const sel of selectors2) {
|
|
151
|
+
for (const el of Array.from(
|
|
152
|
+
document.querySelectorAll(sel)
|
|
153
|
+
)) {
|
|
154
|
+
const rect = el.getBoundingClientRect();
|
|
155
|
+
const overlay = document.createElement("div");
|
|
156
|
+
overlay.style.position = "absolute";
|
|
157
|
+
overlay.style.left = `${rect.left + window.scrollX}px`;
|
|
158
|
+
overlay.style.top = `${rect.top + window.scrollY}px`;
|
|
159
|
+
overlay.style.width = `${rect.width}px`;
|
|
160
|
+
overlay.style.height = `${rect.height}px`;
|
|
161
|
+
overlay.style.background = "#ff00ff";
|
|
162
|
+
overlay.style.zIndex = "2147483647";
|
|
163
|
+
overlay.style.pointerEvents = "none";
|
|
164
|
+
overlay.setAttribute("data-blazediff-mask", "1");
|
|
165
|
+
document.body.appendChild(overlay);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}, selectors);
|
|
169
|
+
}
|
|
170
|
+
function resolvePlaywrightCli() {
|
|
171
|
+
const require_ = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href)));
|
|
172
|
+
const pkgJson = require_.resolve("playwright/package.json");
|
|
173
|
+
const dir = path2__default.default.dirname(pkgJson);
|
|
174
|
+
const candidates = [
|
|
175
|
+
path2__default.default.join(dir, "cli.js"),
|
|
176
|
+
path2__default.default.join(dir, "lib", "cli.js")
|
|
177
|
+
];
|
|
178
|
+
const found = candidates.find((p) => fs.existsSync(p));
|
|
179
|
+
if (!found) {
|
|
180
|
+
throw new Error(`could not locate playwright CLI under ${dir}`);
|
|
181
|
+
}
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
async function readExecutablePath() {
|
|
185
|
+
try {
|
|
186
|
+
const require_ = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href)));
|
|
187
|
+
const pw = require_("playwright");
|
|
188
|
+
const p = pw.chromium.executablePath();
|
|
189
|
+
return p && fs.existsSync(p) ? p : null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function installBrowsers(opts = {}) {
|
|
195
|
+
const cliPath = resolvePlaywrightCli();
|
|
196
|
+
if (opts.check) {
|
|
197
|
+
const executablePath2 = await readExecutablePath();
|
|
198
|
+
return { installed: Boolean(executablePath2), executablePath: executablePath2, cliPath };
|
|
199
|
+
}
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
const child = child_process.spawn(process.execPath, [cliPath, "install", "chromium"], {
|
|
202
|
+
stdio: ["ignore", "inherit", "inherit"],
|
|
203
|
+
env: process.env
|
|
204
|
+
});
|
|
205
|
+
child.on("exit", (code) => {
|
|
206
|
+
if (code === 0) resolve();
|
|
207
|
+
else
|
|
208
|
+
reject(
|
|
209
|
+
new Error(`playwright install chromium exited with code ${code}`)
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
child.on("error", reject);
|
|
213
|
+
});
|
|
214
|
+
const executablePath = await readExecutablePath();
|
|
215
|
+
return { installed: Boolean(executablePath), executablePath, cliPath };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// src/cli/commands/browsers.ts
|
|
219
|
+
function registerBrowsers(program, out) {
|
|
220
|
+
const cmd = program.command("browsers").description("manage browser binaries");
|
|
221
|
+
cmd.addCommand(
|
|
222
|
+
new commander.Command("install").description(
|
|
223
|
+
"install Playwright Chromium using the bundled playwright (no sudo, no --with-deps)"
|
|
224
|
+
).option("--check", "only check whether chromium is already installed").action(async (opts) => {
|
|
225
|
+
const result = await installBrowsers({ check: Boolean(opts.check) });
|
|
226
|
+
const human = result.installed ? `chromium ready at ${result.executablePath}` : opts.check ? "chromium not installed (run `blazediff-agent browsers install`)" : "chromium installed";
|
|
227
|
+
out.emit({ ok: true, ...result }, human);
|
|
228
|
+
})
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
var DEFAULT_VIEWPORT = { width: 1280, height: 800 };
|
|
232
|
+
var DEFAULT_WAIT_FOR = ["networkidle", "fonts"];
|
|
233
|
+
var DEFAULT_FULL_PAGE = true;
|
|
234
|
+
var DEFAULT_THRESHOLD = 0.1;
|
|
235
|
+
var DEFAULT_PORT = 3e3;
|
|
236
|
+
var DEFAULT_READY_TIMEOUT_MS = 6e4;
|
|
237
|
+
var MIN_AUTO_CONCURRENCY = 2;
|
|
238
|
+
var MAX_AUTO_CONCURRENCY = 8;
|
|
239
|
+
function defaultConcurrency() {
|
|
240
|
+
const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
241
|
+
if (!cores || !Number.isFinite(cores)) return MIN_AUTO_CONCURRENCY;
|
|
242
|
+
return Math.max(
|
|
243
|
+
MIN_AUTO_CONCURRENCY,
|
|
244
|
+
Math.min(MAX_AUTO_CONCURRENCY, cores - 1)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
var BLAZEDIFF_DIR = ".blazediff";
|
|
248
|
+
var paths = (cwd = process.cwd()) => {
|
|
249
|
+
const root = path2__default.default.join(cwd, BLAZEDIFF_DIR);
|
|
250
|
+
return {
|
|
251
|
+
root,
|
|
252
|
+
config: path2__default.default.join(root, "config.json"),
|
|
253
|
+
manifest: path2__default.default.join(root, "manifest.json"),
|
|
254
|
+
baselines: path2__default.default.join(root, "baselines"),
|
|
255
|
+
actual: path2__default.default.join(root, "actual"),
|
|
256
|
+
judgments: path2__default.default.join(root, "judgments"),
|
|
257
|
+
summary: path2__default.default.join(root, "summary.md"),
|
|
258
|
+
gitignore: path2__default.default.join(root, ".gitignore"),
|
|
259
|
+
serverLog: path2__default.default.join(root, "dev-server.log"),
|
|
260
|
+
serverPid: path2__default.default.join(root, "dev-server.pid")
|
|
261
|
+
};
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// src/browser/capture.ts
|
|
265
|
+
async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
266
|
+
const viewport = opts.viewport ?? DEFAULT_VIEWPORT;
|
|
267
|
+
const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
|
|
268
|
+
const masks = opts.mask ?? [];
|
|
269
|
+
const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
|
|
270
|
+
const { context, page } = await openStableContext({ viewport});
|
|
271
|
+
try {
|
|
272
|
+
const url = new URL(opts.url, baseUrl).toString();
|
|
273
|
+
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
274
|
+
await waitForStability(page, waitFor);
|
|
275
|
+
await applyMaskOverlays(page, masks);
|
|
276
|
+
await page.evaluate(
|
|
277
|
+
() => new Promise((r) => requestAnimationFrame(() => r()))
|
|
278
|
+
);
|
|
279
|
+
const outputDir = opts.mode === "baseline" ? paths(cwd).baselines : paths(cwd).actual;
|
|
280
|
+
await promises.mkdir(outputDir, { recursive: true });
|
|
281
|
+
const outputPath = path2__default.default.join(outputDir, `${opts.id}.png`);
|
|
282
|
+
const buffer = await page.screenshot({
|
|
283
|
+
path: outputPath,
|
|
284
|
+
type: "png",
|
|
285
|
+
fullPage,
|
|
286
|
+
animations: "disabled",
|
|
287
|
+
caret: "hide",
|
|
288
|
+
scale: "device"
|
|
289
|
+
});
|
|
290
|
+
return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
|
|
291
|
+
} finally {
|
|
292
|
+
await context.close().catch(() => {
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function loadConfig(cwd = process.cwd()) {
|
|
297
|
+
const file = paths(cwd).config;
|
|
298
|
+
if (!fs.existsSync(file)) return null;
|
|
299
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
300
|
+
}
|
|
301
|
+
async function saveConfig(config, cwd = process.cwd()) {
|
|
302
|
+
const file = paths(cwd).config;
|
|
303
|
+
await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
|
|
304
|
+
await promises.writeFile(file, `${JSON.stringify(config, null, 2)}
|
|
305
|
+
`, "utf8");
|
|
306
|
+
}
|
|
307
|
+
function configHash(config) {
|
|
308
|
+
return `sha256:${crypto$1.createHash("sha256").update(JSON.stringify(config)).digest("hex")}`;
|
|
309
|
+
}
|
|
310
|
+
function resolveBaseUrl(config, override) {
|
|
311
|
+
if (override) return override;
|
|
312
|
+
if (config?.baseUrl) return config.baseUrl;
|
|
313
|
+
if (config?.devServer) return `http://127.0.0.1:${config.devServer.port}`;
|
|
314
|
+
throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// src/types.ts
|
|
318
|
+
var STABILITY_HOOKS_VERSION = 1;
|
|
319
|
+
|
|
320
|
+
// src/manifest.ts
|
|
321
|
+
async function loadManifest(cwd = process.cwd()) {
|
|
322
|
+
const file = paths(cwd).manifest;
|
|
323
|
+
if (!fs.existsSync(file)) return null;
|
|
324
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
325
|
+
}
|
|
326
|
+
async function saveManifest(manifest, cwd = process.cwd()) {
|
|
327
|
+
const file = paths(cwd).manifest;
|
|
328
|
+
await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
|
|
329
|
+
await promises.writeFile(file, `${JSON.stringify(manifest, null, 2)}
|
|
330
|
+
`, "utf8");
|
|
331
|
+
}
|
|
332
|
+
function emptyManifest(configHashValue) {
|
|
333
|
+
return {
|
|
334
|
+
version: 1,
|
|
335
|
+
configHash: configHashValue,
|
|
336
|
+
stabilityHooksVersion: STABILITY_HOOKS_VERSION,
|
|
337
|
+
entries: []
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function hashMaterial(input) {
|
|
341
|
+
const material = {
|
|
342
|
+
url: input.url,
|
|
343
|
+
viewport: input.viewport,
|
|
344
|
+
mask: [...input.mask].sort(),
|
|
345
|
+
waitFor: input.waitFor,
|
|
346
|
+
auth: input.auth,
|
|
347
|
+
fullPage: input.fullPage,
|
|
348
|
+
hooks: STABILITY_HOOKS_VERSION
|
|
349
|
+
};
|
|
350
|
+
return `sha256:${crypto$1.createHash("sha256").update(JSON.stringify(material)).digest("hex")}`;
|
|
351
|
+
}
|
|
352
|
+
function makeEntry(input) {
|
|
353
|
+
const viewport = input.viewport ?? DEFAULT_VIEWPORT;
|
|
354
|
+
const waitFor = input.waitFor ?? DEFAULT_WAIT_FOR;
|
|
355
|
+
const mask = input.mask ?? [];
|
|
356
|
+
const auth = input.auth ?? null;
|
|
357
|
+
const fullPage = input.fullPage ?? DEFAULT_FULL_PAGE;
|
|
358
|
+
return {
|
|
359
|
+
id: input.id,
|
|
360
|
+
url: input.url,
|
|
361
|
+
viewport,
|
|
362
|
+
auth,
|
|
363
|
+
waitFor,
|
|
364
|
+
mask,
|
|
365
|
+
fullPage,
|
|
366
|
+
baselinePath: path2__default.default.posix.join(".blazediff", "baselines", `${input.id}.png`),
|
|
367
|
+
captureHash: hashMaterial({
|
|
368
|
+
url: input.url,
|
|
369
|
+
viewport,
|
|
370
|
+
mask,
|
|
371
|
+
waitFor,
|
|
372
|
+
auth,
|
|
373
|
+
fullPage
|
|
374
|
+
}),
|
|
375
|
+
createdBy: input.createdBy ?? "agent",
|
|
376
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function addOrReplaceEntry(manifest, entry) {
|
|
380
|
+
const entries = [
|
|
381
|
+
...manifest.entries.filter((e) => e.id !== entry.id),
|
|
382
|
+
entry
|
|
383
|
+
].sort((a, b) => a.id.localeCompare(b.id));
|
|
384
|
+
return { ...manifest, entries };
|
|
385
|
+
}
|
|
386
|
+
function removeEntry(manifest, id) {
|
|
387
|
+
return { ...manifest, entries: manifest.entries.filter((e) => e.id !== id) };
|
|
388
|
+
}
|
|
389
|
+
function findEntry(manifest, id) {
|
|
390
|
+
return manifest.entries.find((e) => e.id === id);
|
|
391
|
+
}
|
|
392
|
+
function isEntryStale(entry) {
|
|
393
|
+
const recomputed = hashMaterial({
|
|
394
|
+
url: entry.url,
|
|
395
|
+
viewport: entry.viewport,
|
|
396
|
+
mask: entry.mask,
|
|
397
|
+
waitFor: entry.waitFor,
|
|
398
|
+
auth: entry.auth,
|
|
399
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE
|
|
400
|
+
});
|
|
401
|
+
return recomputed !== entry.captureHash;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// src/captures.ts
|
|
405
|
+
function validateRoute(route, i) {
|
|
406
|
+
if (!route || typeof route !== "object")
|
|
407
|
+
return `route[${i}] must be an object`;
|
|
408
|
+
if (!route.id || typeof route.id !== "string")
|
|
409
|
+
return `route[${i}] missing id`;
|
|
410
|
+
if (!route.url || typeof route.url !== "string")
|
|
411
|
+
return `route[${i}] missing url`;
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
async function loadOrCreateManifest(cwd) {
|
|
415
|
+
const existing = await loadManifest(cwd);
|
|
416
|
+
if (existing) return existing;
|
|
417
|
+
const config = await loadConfig(cwd);
|
|
418
|
+
return emptyManifest(config ? configHash(config) : "sha256:none");
|
|
419
|
+
}
|
|
420
|
+
async function runCaptures(opts) {
|
|
421
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
422
|
+
const defaultMode = opts.mode ?? "baseline";
|
|
423
|
+
const writeManifest = opts.writeManifest ?? true;
|
|
424
|
+
const results = [];
|
|
425
|
+
const valid = [];
|
|
426
|
+
opts.routes.forEach((r, i) => {
|
|
427
|
+
const err = validateRoute(r, i);
|
|
428
|
+
if (err) {
|
|
429
|
+
results.push({
|
|
430
|
+
id: r?.id ?? `route[${i}]`,
|
|
431
|
+
url: r?.url ?? "",
|
|
432
|
+
mode: r?.mode ?? defaultMode,
|
|
433
|
+
ok: false,
|
|
434
|
+
error: err
|
|
435
|
+
});
|
|
436
|
+
} else {
|
|
437
|
+
valid.push(r);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
|
|
441
|
+
let manifestUpdates = 0;
|
|
442
|
+
try {
|
|
443
|
+
for (const r of valid) {
|
|
444
|
+
const mode = r.mode ?? defaultMode;
|
|
445
|
+
try {
|
|
446
|
+
const shot = await captureScreenshot(
|
|
447
|
+
opts.baseUrl,
|
|
448
|
+
{
|
|
449
|
+
id: r.id,
|
|
450
|
+
url: r.url,
|
|
451
|
+
viewport: r.viewport,
|
|
452
|
+
mask: r.mask,
|
|
453
|
+
waitFor: r.waitFor,
|
|
454
|
+
fullPage: r.fullPage,
|
|
455
|
+
mode
|
|
456
|
+
},
|
|
457
|
+
cwd
|
|
458
|
+
);
|
|
459
|
+
results.push({
|
|
460
|
+
id: r.id,
|
|
461
|
+
url: r.url,
|
|
462
|
+
mode,
|
|
463
|
+
ok: true,
|
|
464
|
+
outputPath: shot.outputPath,
|
|
465
|
+
bytes: shot.bytes
|
|
466
|
+
});
|
|
467
|
+
if (manifest && mode === "baseline") {
|
|
468
|
+
manifest = addOrReplaceEntry(
|
|
469
|
+
manifest,
|
|
470
|
+
makeEntry({
|
|
471
|
+
id: r.id,
|
|
472
|
+
url: r.url,
|
|
473
|
+
viewport: r.viewport,
|
|
474
|
+
mask: r.mask,
|
|
475
|
+
waitFor: r.waitFor,
|
|
476
|
+
fullPage: r.fullPage
|
|
477
|
+
})
|
|
478
|
+
);
|
|
479
|
+
manifestUpdates += 1;
|
|
480
|
+
}
|
|
481
|
+
} catch (err) {
|
|
482
|
+
results.push({
|
|
483
|
+
id: r.id,
|
|
484
|
+
url: r.url,
|
|
485
|
+
mode,
|
|
486
|
+
ok: false,
|
|
487
|
+
error: err.message
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
|
|
492
|
+
} finally {
|
|
493
|
+
await closeBrowser();
|
|
494
|
+
}
|
|
495
|
+
const succeeded = results.filter((r) => r.ok).length;
|
|
496
|
+
return {
|
|
497
|
+
total: results.length,
|
|
498
|
+
succeeded,
|
|
499
|
+
failed: results.length - succeeded,
|
|
500
|
+
manifestUpdates,
|
|
501
|
+
results
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// src/cli/parsers.ts
|
|
506
|
+
function parseViewport(value) {
|
|
507
|
+
const [w, h] = value.split("x").map((n) => Number(n));
|
|
508
|
+
if (!w || !h) throw new Error(`invalid viewport: ${value} (expected WxH)`);
|
|
509
|
+
return { width: w, height: h };
|
|
510
|
+
}
|
|
511
|
+
function parseMaskList(value) {
|
|
512
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
513
|
+
}
|
|
514
|
+
function parseWaitFor(value) {
|
|
515
|
+
if (!value) return [];
|
|
516
|
+
return value.split(",").map((s) => s.trim()).filter(Boolean).map(
|
|
517
|
+
(part) => part === "networkidle" || part === "fonts" ? part : { selector: part }
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
function parseRoutesPayload(raw) {
|
|
521
|
+
const trimmed = raw.trim();
|
|
522
|
+
if (!trimmed) throw new Error("routes payload is empty");
|
|
523
|
+
const parsed = JSON.parse(trimmed);
|
|
524
|
+
if (!Array.isArray(parsed))
|
|
525
|
+
throw new Error("routes payload must be a JSON array");
|
|
526
|
+
return parsed;
|
|
527
|
+
}
|
|
528
|
+
function readStdin() {
|
|
529
|
+
return new Promise((resolve, reject) => {
|
|
530
|
+
const chunks = [];
|
|
531
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
532
|
+
process.stdin.on(
|
|
533
|
+
"end",
|
|
534
|
+
() => resolve(Buffer.concat(chunks).toString("utf8"))
|
|
535
|
+
);
|
|
536
|
+
process.stdin.on("error", reject);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/cli/commands/capture.ts
|
|
541
|
+
async function resolveRoutes(opts) {
|
|
542
|
+
const sources = [opts.routes, opts.stdin, opts.id || opts.url].filter(
|
|
543
|
+
Boolean
|
|
544
|
+
).length;
|
|
545
|
+
if (sources === 0) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
"provide one of: --routes <file>, --stdin, or --id <id> --url <url>"
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
if (opts.routes && opts.stdin) {
|
|
551
|
+
throw new Error("--routes and --stdin are mutually exclusive");
|
|
552
|
+
}
|
|
553
|
+
if (opts.routes) {
|
|
554
|
+
return parseRoutesPayload(
|
|
555
|
+
await promises.readFile(path2__default.default.resolve(opts.routes), "utf8")
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (opts.stdin) {
|
|
559
|
+
return parseRoutesPayload(await readStdin());
|
|
560
|
+
}
|
|
561
|
+
if (!opts.id || !opts.url) {
|
|
562
|
+
throw new Error(
|
|
563
|
+
"--id and --url must both be provided for single-route capture"
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
return [
|
|
567
|
+
{
|
|
568
|
+
id: opts.id,
|
|
569
|
+
url: opts.url,
|
|
570
|
+
viewport: parseViewport(opts.viewport),
|
|
571
|
+
mask: parseMaskList(opts.mask),
|
|
572
|
+
waitFor: parseWaitFor(opts.waitFor),
|
|
573
|
+
fullPage: opts.fullPage
|
|
574
|
+
}
|
|
575
|
+
];
|
|
576
|
+
}
|
|
577
|
+
function registerCapture(program, out) {
|
|
578
|
+
program.command("capture").description(
|
|
579
|
+
"capture one or more deterministic screenshots. Reads routes from --routes <file>, --stdin, or --id/--url for a single route."
|
|
580
|
+
).option("--routes <file>", "JSON file with an array of route entries").option("--stdin", "read JSON array of route entries from stdin").option("--id <id>", "single-route id (used with --url)").option("--url <url>", "single-route URL (used with --id)").option(
|
|
581
|
+
"--viewport <WxH>",
|
|
582
|
+
"default viewport for inline single route",
|
|
583
|
+
"1280x800"
|
|
584
|
+
).option("--mask <selectors>", "default mask for inline single route", "").option(
|
|
585
|
+
"--wait-for <list>",
|
|
586
|
+
"default wait list for inline single route",
|
|
587
|
+
"networkidle,fonts"
|
|
588
|
+
).option("--no-full-page", "default: viewport-only (default is full page)").option(
|
|
589
|
+
"--mode <baseline|actual>",
|
|
590
|
+
"default mode (entries can override)",
|
|
591
|
+
"baseline"
|
|
592
|
+
).option("--base-url <url>", "override base URL").option(
|
|
593
|
+
"--no-manifest",
|
|
594
|
+
"do not write manifest entries (baseline mode only)"
|
|
595
|
+
).action(async (opts) => {
|
|
596
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
597
|
+
const routes = await resolveRoutes(opts);
|
|
598
|
+
const report = await runCaptures({
|
|
599
|
+
baseUrl,
|
|
600
|
+
routes,
|
|
601
|
+
mode: opts.mode,
|
|
602
|
+
writeManifest: opts.manifest
|
|
603
|
+
});
|
|
604
|
+
const human = out.isTTY() ? `captured ${report.succeeded}/${report.total} (manifest: +${report.manifestUpdates})${report.failed ? `, ${report.failed} failed` : ""}` : ".";
|
|
605
|
+
out.emit(report, human);
|
|
606
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
var ENTRIES = [
|
|
610
|
+
"actual/",
|
|
611
|
+
"judgments/",
|
|
612
|
+
"summary.md",
|
|
613
|
+
"dev-server.log",
|
|
614
|
+
"dev-server.pid",
|
|
615
|
+
"*.tmp"
|
|
616
|
+
];
|
|
617
|
+
var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
|
|
618
|
+
var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
|
|
619
|
+
async function ensureGitignore(cwd) {
|
|
620
|
+
const file = paths(cwd).gitignore;
|
|
621
|
+
await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
|
|
622
|
+
const existing = fs.existsSync(file) ? await promises.readFile(file, "utf8") : "";
|
|
623
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
624
|
+
const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
|
|
625
|
+
const missing = ENTRIES.filter((e) => !lines.includes(e));
|
|
626
|
+
if (!missing.length && !hasStale && existing) return;
|
|
627
|
+
if (hasStale) {
|
|
628
|
+
const kept = lines.filter(
|
|
629
|
+
(l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
|
|
630
|
+
);
|
|
631
|
+
const body2 = `${kept.length ? `${kept.join("\n")}
|
|
632
|
+
` : HEADER}${ENTRIES.join("\n")}
|
|
633
|
+
`;
|
|
634
|
+
await promises.writeFile(file, body2, "utf8");
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
const body = existing ? `${existing.replace(/\n+$/, "")}
|
|
638
|
+
${missing.join("\n")}
|
|
639
|
+
` : `${HEADER}${ENTRIES.join("\n")}
|
|
640
|
+
`;
|
|
641
|
+
await promises.writeFile(file, body, "utf8");
|
|
642
|
+
}
|
|
643
|
+
async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
|
|
644
|
+
if (!fs.existsSync(baselinePath) || !fs.existsSync(actualPath)) {
|
|
645
|
+
return {
|
|
646
|
+
id,
|
|
647
|
+
baselinePath,
|
|
648
|
+
actualPath,
|
|
649
|
+
match: false,
|
|
650
|
+
reason: "file-not-exists"
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
let diffPath;
|
|
654
|
+
if (opts.emitDiffPng) {
|
|
655
|
+
const actualDir = paths(cwd).actual;
|
|
656
|
+
await promises.mkdir(actualDir, { recursive: true });
|
|
657
|
+
diffPath = path2__default.default.join(actualDir, `${id}.diff.png`);
|
|
658
|
+
}
|
|
659
|
+
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
660
|
+
const antialiasing = opts.antialiasing ?? true;
|
|
661
|
+
const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
|
|
662
|
+
threshold,
|
|
663
|
+
antialiasing
|
|
664
|
+
});
|
|
665
|
+
if (result.match) return { id, baselinePath, actualPath, match: true };
|
|
666
|
+
if (result.reason === "file-not-exists") {
|
|
667
|
+
return {
|
|
668
|
+
id,
|
|
669
|
+
baselinePath,
|
|
670
|
+
actualPath,
|
|
671
|
+
match: false,
|
|
672
|
+
reason: "file-not-exists"
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
if (result.reason === "layout-diff") {
|
|
676
|
+
return {
|
|
677
|
+
id,
|
|
678
|
+
baselinePath,
|
|
679
|
+
actualPath,
|
|
680
|
+
diffPath,
|
|
681
|
+
match: false,
|
|
682
|
+
reason: "layout-diff"
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const interpretation = await coreNative.interpret(baselinePath, actualPath, {
|
|
686
|
+
threshold,
|
|
687
|
+
antialiasing
|
|
688
|
+
}).catch(() => void 0);
|
|
689
|
+
return {
|
|
690
|
+
id,
|
|
691
|
+
baselinePath,
|
|
692
|
+
actualPath,
|
|
693
|
+
diffPath,
|
|
694
|
+
match: false,
|
|
695
|
+
reason: "pixel-diff",
|
|
696
|
+
diffCount: result.diffCount,
|
|
697
|
+
diffPercentage: result.diffPercentage,
|
|
698
|
+
interpretation
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// src/diff/verdict.ts
|
|
703
|
+
var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
|
|
704
|
+
var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
|
|
705
|
+
var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
|
|
706
|
+
var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
|
|
707
|
+
var SUB_PERCEPTUAL_PCT = 0.01;
|
|
708
|
+
function pctText(pct) {
|
|
709
|
+
if (typeof pct !== "number") return "?%";
|
|
710
|
+
return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
|
|
711
|
+
}
|
|
712
|
+
function countByType(regions) {
|
|
713
|
+
const counts = /* @__PURE__ */ new Map();
|
|
714
|
+
for (const r of regions)
|
|
715
|
+
counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
|
|
716
|
+
return counts;
|
|
717
|
+
}
|
|
718
|
+
function dominantType(counts) {
|
|
719
|
+
let best = "";
|
|
720
|
+
let bestN = 0;
|
|
721
|
+
for (const [type, n] of counts) {
|
|
722
|
+
if (n > bestN) {
|
|
723
|
+
best = type;
|
|
724
|
+
bestN = n;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return best;
|
|
728
|
+
}
|
|
729
|
+
function topPosition(regions) {
|
|
730
|
+
let best;
|
|
731
|
+
for (const r of regions)
|
|
732
|
+
if (!best || r.pixelCount > best.pixelCount) best = r;
|
|
733
|
+
return best?.position;
|
|
734
|
+
}
|
|
735
|
+
function meanConfidence(regions) {
|
|
736
|
+
if (regions.length === 0) return 0;
|
|
737
|
+
return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
|
|
738
|
+
}
|
|
739
|
+
function formatBreakdown(counts) {
|
|
740
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
|
|
741
|
+
}
|
|
742
|
+
function buildHeadline(input) {
|
|
743
|
+
const { reason, interpretation, diffCount, diffPercentage } = input;
|
|
744
|
+
if (reason === "layout-diff") return "image dimensions changed";
|
|
745
|
+
if (reason === "file-not-exists") return "baseline or actual capture missing";
|
|
746
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
747
|
+
const px = diffCount?.toLocaleString() ?? "?";
|
|
748
|
+
return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
|
|
749
|
+
}
|
|
750
|
+
const regions = interpretation.regions;
|
|
751
|
+
const counts = countByType(regions);
|
|
752
|
+
const pos = topPosition(regions);
|
|
753
|
+
const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
|
|
754
|
+
const sev = interpretation.severity ?? "?";
|
|
755
|
+
if (regions.length === 1) {
|
|
756
|
+
return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
757
|
+
}
|
|
758
|
+
return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
759
|
+
}
|
|
760
|
+
function deriveVerdict(input) {
|
|
761
|
+
const { reason, interpretation, diffPercentage } = input;
|
|
762
|
+
const headline = buildHeadline(input);
|
|
763
|
+
if (reason === "layout-diff") {
|
|
764
|
+
return {
|
|
765
|
+
label: "ambiguous",
|
|
766
|
+
headline,
|
|
767
|
+
rationale: [
|
|
768
|
+
"baseline and actual image dimensions differ \u2014 page height likely shifted",
|
|
769
|
+
"could be intentional (content added/removed) or regression (broken layout)"
|
|
770
|
+
],
|
|
771
|
+
action: "investigate"
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (reason === "file-not-exists") {
|
|
775
|
+
return {
|
|
776
|
+
label: "regression-likely",
|
|
777
|
+
headline,
|
|
778
|
+
rationale: ["baseline or actual capture is missing from disk"],
|
|
779
|
+
action: "investigate"
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
783
|
+
return {
|
|
784
|
+
label: "ambiguous",
|
|
785
|
+
headline,
|
|
786
|
+
rationale: ["pixels differ but interpret returned no regions"],
|
|
787
|
+
action: "investigate"
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
const regions = interpretation.regions;
|
|
791
|
+
const severity = interpretation.severity;
|
|
792
|
+
const counts = countByType(regions);
|
|
793
|
+
const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
|
|
794
|
+
const allColor = regions.every((r) => r.changeType === "color-change");
|
|
795
|
+
const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
|
|
796
|
+
const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
|
|
797
|
+
const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
|
|
798
|
+
if (allNoise) {
|
|
799
|
+
return {
|
|
800
|
+
label: "noise-likely",
|
|
801
|
+
headline,
|
|
802
|
+
rationale: ["all regions classified as rendering-noise"],
|
|
803
|
+
action: "ignore-or-rewrite"
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
|
|
807
|
+
return {
|
|
808
|
+
label: "noise-likely",
|
|
809
|
+
headline,
|
|
810
|
+
rationale: [
|
|
811
|
+
`delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
|
|
812
|
+
"sub-perceptual change - review optional"
|
|
813
|
+
],
|
|
814
|
+
action: "ignore-or-rewrite"
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
|
|
818
|
+
const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
819
|
+
return {
|
|
820
|
+
label: "regression-likely",
|
|
821
|
+
headline,
|
|
822
|
+
rationale: [
|
|
823
|
+
`severity ${severity} with structural changes (${types})`,
|
|
824
|
+
"likely affects content or layout, not just styling"
|
|
825
|
+
],
|
|
826
|
+
action: "investigate"
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
if (allColor && meanConfidence(regions) > 0.7) {
|
|
830
|
+
return {
|
|
831
|
+
label: "intentional-likely",
|
|
832
|
+
headline,
|
|
833
|
+
rationale: [
|
|
834
|
+
`${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
|
|
835
|
+
"edge structure preserved - looks like a theming / palette change"
|
|
836
|
+
],
|
|
837
|
+
action: "rewrite-if-intended"
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
if (allMoved && !allColor) {
|
|
841
|
+
return {
|
|
842
|
+
label: "intentional-likely",
|
|
843
|
+
headline,
|
|
844
|
+
rationale: [
|
|
845
|
+
"all regions are shift/color-change - content moved or restyled, structure preserved"
|
|
846
|
+
],
|
|
847
|
+
action: "rewrite-if-intended"
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
return {
|
|
851
|
+
label: "ambiguous",
|
|
852
|
+
headline,
|
|
853
|
+
rationale: [
|
|
854
|
+
`mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
|
|
855
|
+
`${pctText(pct)} of image differs`
|
|
856
|
+
],
|
|
857
|
+
action: "investigate"
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
var DEFAULT_TOP_N = 5;
|
|
861
|
+
var DEFAULT_PADDING = 16;
|
|
862
|
+
var DEFAULT_LOCATOR_MAX_WIDTH = 400;
|
|
863
|
+
var DEFAULT_GUTTER = 2;
|
|
864
|
+
var DEFAULT_ROW_GUTTER = 8;
|
|
865
|
+
var BG_WHITE = { r: 255, g: 255, b: 255 };
|
|
866
|
+
function padAndClamp(bbox, padding, imgWidth, imgHeight) {
|
|
867
|
+
const left = Math.max(0, Math.floor(bbox.x - padding));
|
|
868
|
+
const top = Math.max(0, Math.floor(bbox.y - padding));
|
|
869
|
+
const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
|
|
870
|
+
const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
|
|
871
|
+
return {
|
|
872
|
+
x: left,
|
|
873
|
+
y: top,
|
|
874
|
+
width: Math.max(1, right - left),
|
|
875
|
+
height: Math.max(1, bottom - top)
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
async function prepareTiles(opts) {
|
|
879
|
+
const topN = opts.topN ?? DEFAULT_TOP_N;
|
|
880
|
+
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
881
|
+
const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
|
|
882
|
+
const gutter = opts.gutter ?? DEFAULT_GUTTER;
|
|
883
|
+
const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
|
|
884
|
+
const diffMeta = await sharp__default.default(opts.diffPath).metadata();
|
|
885
|
+
const imgWidth = diffMeta.width ?? 0;
|
|
886
|
+
const imgHeight = diffMeta.height ?? 0;
|
|
887
|
+
if (!imgWidth || !imgHeight) {
|
|
888
|
+
throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
|
|
889
|
+
}
|
|
890
|
+
const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
|
|
891
|
+
const regionData = await Promise.all(
|
|
892
|
+
ranked.map(async (region) => {
|
|
893
|
+
const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
|
|
894
|
+
const extract = {
|
|
895
|
+
left: padded.x,
|
|
896
|
+
top: padded.y,
|
|
897
|
+
width: padded.width,
|
|
898
|
+
height: padded.height
|
|
899
|
+
};
|
|
900
|
+
const [base, actual] = await Promise.all([
|
|
901
|
+
sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
|
|
902
|
+
sharp__default.default(opts.actualPath).extract(extract).toBuffer()
|
|
903
|
+
]);
|
|
904
|
+
return { region, padded, base, actual };
|
|
905
|
+
})
|
|
906
|
+
);
|
|
907
|
+
let tilesName;
|
|
908
|
+
if (regionData.length > 0) {
|
|
909
|
+
const canvasWidth = Math.max(
|
|
910
|
+
...regionData.map((r) => r.padded.width * 2 + gutter)
|
|
911
|
+
);
|
|
912
|
+
const totalHeight = regionData.reduce(
|
|
913
|
+
(sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
|
|
914
|
+
0
|
|
915
|
+
);
|
|
916
|
+
const composites = [];
|
|
917
|
+
let y = 0;
|
|
918
|
+
for (let i = 0; i < regionData.length; i++) {
|
|
919
|
+
const r = regionData[i];
|
|
920
|
+
const w = r.padded.width;
|
|
921
|
+
composites.push(
|
|
922
|
+
{ input: r.base, left: 0, top: y },
|
|
923
|
+
{ input: r.actual, left: w + gutter, top: y }
|
|
924
|
+
);
|
|
925
|
+
y += r.padded.height;
|
|
926
|
+
if (i < regionData.length - 1) y += rowGutter;
|
|
927
|
+
}
|
|
928
|
+
tilesName = "regions.png";
|
|
929
|
+
await sharp__default.default({
|
|
930
|
+
create: {
|
|
931
|
+
width: canvasWidth,
|
|
932
|
+
height: totalHeight,
|
|
933
|
+
channels: 3,
|
|
934
|
+
background: BG_WHITE
|
|
935
|
+
}
|
|
936
|
+
}).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
|
|
937
|
+
}
|
|
938
|
+
const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
|
|
939
|
+
const locW = Math.max(1, Math.round(imgWidth * scale));
|
|
940
|
+
const locH = Math.max(1, Math.round(imgHeight * scale));
|
|
941
|
+
const rects = opts.regions.map((r) => {
|
|
942
|
+
const x = Math.round(r.bbox.x * scale);
|
|
943
|
+
const y = Math.round(r.bbox.y * scale);
|
|
944
|
+
const w = Math.max(1, Math.round(r.bbox.width * scale));
|
|
945
|
+
const h = Math.max(1, Math.round(r.bbox.height * scale));
|
|
946
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
|
|
947
|
+
}).join("");
|
|
948
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
|
|
949
|
+
const locatorName = "locator.png";
|
|
950
|
+
await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
|
|
951
|
+
return {
|
|
952
|
+
locatorPath: locatorName,
|
|
953
|
+
tilesPath: tilesName,
|
|
954
|
+
regions: regionData.map((r) => ({
|
|
955
|
+
bbox: r.region.bbox,
|
|
956
|
+
pixelCount: r.region.pixelCount
|
|
957
|
+
}))
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// src/judge/host-harness.ts
|
|
962
|
+
async function tryPrepareTiles(input, entryDir) {
|
|
963
|
+
if (!input.regions || input.regions.length === 0 || !input.diffPath) {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
try {
|
|
967
|
+
return await prepareTiles({
|
|
968
|
+
regions: input.regions,
|
|
969
|
+
baselinePath: input.baselinePath,
|
|
970
|
+
actualPath: input.actualPath,
|
|
971
|
+
diffPath: input.diffPath,
|
|
972
|
+
outputDir: entryDir
|
|
973
|
+
});
|
|
974
|
+
} catch (err) {
|
|
975
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
976
|
+
console.warn(
|
|
977
|
+
`[blazediff] tile generation failed for ${input.entry.id}: ${message}`
|
|
978
|
+
);
|
|
979
|
+
return null;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
var hostHarnessJudge = {
|
|
983
|
+
name: "host",
|
|
984
|
+
async judge(input, cwd) {
|
|
985
|
+
const p = paths(cwd);
|
|
986
|
+
const entryDir = path2__default.default.join(p.judgments, input.entry.id);
|
|
987
|
+
await promises.mkdir(entryDir, { recursive: true });
|
|
988
|
+
await tryPrepareTiles(input, entryDir);
|
|
989
|
+
return { kind: "deferred", requestPath: entryDir };
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
// src/judge/none.ts
|
|
994
|
+
var noneJudge = {
|
|
995
|
+
name: "none",
|
|
996
|
+
async judge(input) {
|
|
997
|
+
return { kind: "judged", verdict: input.heuristicVerdict };
|
|
998
|
+
}
|
|
999
|
+
};
|
|
1000
|
+
var PREVIEW_WIDTH = 320;
|
|
1001
|
+
function escapeCell(s) {
|
|
1002
|
+
return s.replace(/\n/g, " ");
|
|
1003
|
+
}
|
|
1004
|
+
function toBlazediffRel(cwd, abs) {
|
|
1005
|
+
if (!abs) return void 0;
|
|
1006
|
+
const root = paths(cwd).root;
|
|
1007
|
+
const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
|
|
1008
|
+
return rel.split(path2__default.default.sep).join("/");
|
|
1009
|
+
}
|
|
1010
|
+
function img(src, alt) {
|
|
1011
|
+
return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
|
|
1012
|
+
}
|
|
1013
|
+
function baselineCell(r, cwd) {
|
|
1014
|
+
const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
|
|
1015
|
+
return img(rel, `${r.id} baseline`);
|
|
1016
|
+
}
|
|
1017
|
+
function actualCell(r, cwd) {
|
|
1018
|
+
const actual = toBlazediffRel(cwd, r.actualPath);
|
|
1019
|
+
return actual ? img(actual, `${r.id} actual`) : "-";
|
|
1020
|
+
}
|
|
1021
|
+
function diffCell(r, cwd) {
|
|
1022
|
+
const diff = toBlazediffRel(cwd, r.diffPath);
|
|
1023
|
+
return diff ? img(diff, `${r.id} diff`) : "-";
|
|
1024
|
+
}
|
|
1025
|
+
function verdictCell(r) {
|
|
1026
|
+
if (r.status === "missing-baseline") {
|
|
1027
|
+
return `missing-baseline - ${r.message ?? "baseline missing"}`;
|
|
1028
|
+
}
|
|
1029
|
+
if (r.status === "stale-baseline") {
|
|
1030
|
+
return `stale-baseline - ${r.message ?? "manifest entry edited without re-capturing"}`;
|
|
1031
|
+
}
|
|
1032
|
+
if (r.status === "needs-judgment") {
|
|
1033
|
+
return `needs-judgment - see judgments/${r.id}/`;
|
|
1034
|
+
}
|
|
1035
|
+
if (!r.verdict) {
|
|
1036
|
+
return r.message ?? r.status;
|
|
1037
|
+
}
|
|
1038
|
+
return `${r.verdict.label} - ${r.verdict.headline} -> ${r.verdict.action}`;
|
|
1039
|
+
}
|
|
1040
|
+
function renderRow(r, cwd) {
|
|
1041
|
+
return `| ${r.id} | ${escapeCell(baselineCell(r, cwd))} | ${escapeCell(actualCell(r, cwd))} | ${escapeCell(diffCell(r, cwd))} | ${escapeCell(verdictCell(r))} |`;
|
|
1042
|
+
}
|
|
1043
|
+
function headerLine(report) {
|
|
1044
|
+
const { passed, failed, pendingJudgments, totalEntries } = report;
|
|
1045
|
+
const parts = [`${passed}/${totalEntries} passed`];
|
|
1046
|
+
if (failed > 0) parts.push(`${failed} failed`);
|
|
1047
|
+
if (pendingJudgments > 0) parts.push(`${pendingJudgments} pending judgment`);
|
|
1048
|
+
return parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(", ")})` : parts[0];
|
|
1049
|
+
}
|
|
1050
|
+
function renderSummary(report, cwd = process.cwd()) {
|
|
1051
|
+
const nonPass = report.results.filter((r) => r.status !== "pass");
|
|
1052
|
+
const lines = [
|
|
1053
|
+
`# blazediff check - ${report.createdAt}`,
|
|
1054
|
+
"",
|
|
1055
|
+
headerLine(report),
|
|
1056
|
+
""
|
|
1057
|
+
];
|
|
1058
|
+
if (nonPass.length === 0) {
|
|
1059
|
+
lines.push("All entries passed.");
|
|
1060
|
+
return `${lines.join("\n")}
|
|
1061
|
+
`;
|
|
1062
|
+
}
|
|
1063
|
+
lines.push("| id | baseline | actual | diff | verdict |");
|
|
1064
|
+
lines.push("| --- | --- | --- | --- | --- |");
|
|
1065
|
+
for (const r of nonPass) lines.push(renderRow(r, cwd));
|
|
1066
|
+
return `${lines.join("\n")}
|
|
1067
|
+
`;
|
|
1068
|
+
}
|
|
1069
|
+
async function writeSummaryMarkdown(report, cwd = process.cwd()) {
|
|
1070
|
+
const file = paths(cwd).summary;
|
|
1071
|
+
await promises.mkdir(path2__default.default.dirname(file), { recursive: true });
|
|
1072
|
+
await promises.writeFile(file, renderSummary(report, cwd), "utf8");
|
|
1073
|
+
return file;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// src/judge/apply.ts
|
|
1077
|
+
var VALID_LABELS = [
|
|
1078
|
+
"regression-likely",
|
|
1079
|
+
"intentional-likely",
|
|
1080
|
+
"noise-likely",
|
|
1081
|
+
"ambiguous"
|
|
1082
|
+
];
|
|
1083
|
+
var VALID_ACTIONS = [
|
|
1084
|
+
"investigate",
|
|
1085
|
+
"rewrite-if-intended",
|
|
1086
|
+
"ignore-or-rewrite"
|
|
1087
|
+
];
|
|
1088
|
+
function parseVerdict(raw) {
|
|
1089
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1090
|
+
const r = raw;
|
|
1091
|
+
if (typeof r.id !== "string") return null;
|
|
1092
|
+
const v = r.verdict;
|
|
1093
|
+
if (!v || typeof v !== "object") return null;
|
|
1094
|
+
if (typeof v.label !== "string" || !VALID_LABELS.includes(v.label))
|
|
1095
|
+
return null;
|
|
1096
|
+
if (typeof v.headline !== "string") return null;
|
|
1097
|
+
if (typeof v.action !== "string" || !VALID_ACTIONS.includes(v.action))
|
|
1098
|
+
return null;
|
|
1099
|
+
const rationale = Array.isArray(v.rationale) ? v.rationale.filter((x) => typeof x === "string") : [];
|
|
1100
|
+
return {
|
|
1101
|
+
id: r.id,
|
|
1102
|
+
verdict: {
|
|
1103
|
+
label: v.label,
|
|
1104
|
+
headline: v.headline,
|
|
1105
|
+
rationale,
|
|
1106
|
+
action: v.action
|
|
1107
|
+
},
|
|
1108
|
+
rationale: typeof r.rationale === "string" ? r.rationale : void 0,
|
|
1109
|
+
confidence: typeof r.confidence === "number" ? r.confidence : void 0
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
async function readJsonOrNull(file) {
|
|
1113
|
+
try {
|
|
1114
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
1115
|
+
} catch {
|
|
1116
|
+
return null;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
async function readJudgmentDirs(root) {
|
|
1120
|
+
let names;
|
|
1121
|
+
try {
|
|
1122
|
+
names = await promises.readdir(root);
|
|
1123
|
+
} catch {
|
|
1124
|
+
return [];
|
|
1125
|
+
}
|
|
1126
|
+
const out = [];
|
|
1127
|
+
for (const name of names) {
|
|
1128
|
+
const dir = path2__default.default.join(root, name);
|
|
1129
|
+
let isDir = false;
|
|
1130
|
+
try {
|
|
1131
|
+
isDir = (await promises.stat(dir)).isDirectory();
|
|
1132
|
+
} catch {
|
|
1133
|
+
isDir = false;
|
|
1134
|
+
}
|
|
1135
|
+
if (!isDir) continue;
|
|
1136
|
+
const request = await readJsonOrNull(
|
|
1137
|
+
path2__default.default.join(dir, "request.json")
|
|
1138
|
+
);
|
|
1139
|
+
const verdictFile = path2__default.default.join(dir, "verdict.json");
|
|
1140
|
+
let verdict = null;
|
|
1141
|
+
let verdictInvalid = false;
|
|
1142
|
+
if (fs.existsSync(verdictFile)) {
|
|
1143
|
+
const raw = await readJsonOrNull(verdictFile);
|
|
1144
|
+
verdict = raw ? parseVerdict(raw) : null;
|
|
1145
|
+
if (raw && !verdict) verdictInvalid = true;
|
|
1146
|
+
}
|
|
1147
|
+
out.push({ id: name, request, verdict, verdictInvalid });
|
|
1148
|
+
}
|
|
1149
|
+
return out;
|
|
1150
|
+
}
|
|
1151
|
+
function toAbs(cwd, rel) {
|
|
1152
|
+
if (!rel) return void 0;
|
|
1153
|
+
return path2__default.default.isAbsolute(rel) ? rel : path2__default.default.join(cwd, rel);
|
|
1154
|
+
}
|
|
1155
|
+
function buildResult(cwd, dir, entry) {
|
|
1156
|
+
const req = dir.request;
|
|
1157
|
+
const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
|
|
1158
|
+
const status = req ? dir.verdict ? "fail" : req.status : "fail";
|
|
1159
|
+
const message = dir.verdict?.rationale ?? (dir.verdict?.confidence !== void 0 ? `judged (confidence ${dir.verdict.confidence.toFixed(2)})` : req?.message);
|
|
1160
|
+
return {
|
|
1161
|
+
id: dir.id,
|
|
1162
|
+
url: req?.url ?? entry?.url ?? "",
|
|
1163
|
+
status,
|
|
1164
|
+
diffPercentage: req?.diffPercentage,
|
|
1165
|
+
severity: req?.severity,
|
|
1166
|
+
regions: req?.regions,
|
|
1167
|
+
verdict: finalVerdict,
|
|
1168
|
+
diffPath: toAbs(cwd, req?.paths.diff),
|
|
1169
|
+
actualPath: toAbs(cwd, req?.paths.actual),
|
|
1170
|
+
baselinePath: toAbs(cwd, req?.paths.baseline),
|
|
1171
|
+
message
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
function passResult(entry, cwd) {
|
|
1175
|
+
const baselineAbs = path2__default.default.join(paths(cwd).baselines, `${entry.id}.png`);
|
|
1176
|
+
const actualAbs = path2__default.default.join(paths(cwd).actual, `${entry.id}.png`);
|
|
1177
|
+
return {
|
|
1178
|
+
id: entry.id,
|
|
1179
|
+
url: entry.url,
|
|
1180
|
+
status: "pass",
|
|
1181
|
+
baselinePath: baselineAbs,
|
|
1182
|
+
actualPath: fs.existsSync(actualAbs) ? actualAbs : void 0
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
async function applyJudgments(cwd = process.cwd()) {
|
|
1186
|
+
const p = paths(cwd);
|
|
1187
|
+
const manifest = await loadManifest(cwd);
|
|
1188
|
+
if (!manifest) {
|
|
1189
|
+
throw new Error(
|
|
1190
|
+
`no manifest at ${p.manifest}. Run \`blazediff-agent init\` first.`
|
|
1191
|
+
);
|
|
1192
|
+
}
|
|
1193
|
+
const dirs = await readJudgmentDirs(p.judgments);
|
|
1194
|
+
const dirById = new Map(dirs.map((d) => [d.id, d]));
|
|
1195
|
+
const applied = [];
|
|
1196
|
+
const missing = [];
|
|
1197
|
+
const invalid = [];
|
|
1198
|
+
for (const d of dirs) {
|
|
1199
|
+
if (d.verdictInvalid) invalid.push(path2__default.default.join(p.judgments, d.id));
|
|
1200
|
+
else if (d.verdict) applied.push(d.id);
|
|
1201
|
+
else missing.push(d.id);
|
|
1202
|
+
}
|
|
1203
|
+
const nonPassResults = [];
|
|
1204
|
+
for (const d of dirs) {
|
|
1205
|
+
const entry = manifest.entries.find((e) => e.id === d.id);
|
|
1206
|
+
nonPassResults.push(buildResult(cwd, d, entry));
|
|
1207
|
+
}
|
|
1208
|
+
const passResults = [];
|
|
1209
|
+
for (const entry of manifest.entries) {
|
|
1210
|
+
if (dirById.has(entry.id)) continue;
|
|
1211
|
+
passResults.push(passResult(entry, cwd));
|
|
1212
|
+
}
|
|
1213
|
+
const results = [...passResults, ...nonPassResults];
|
|
1214
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1215
|
+
const pendingJudgments = results.filter(
|
|
1216
|
+
(r) => r.status === "needs-judgment"
|
|
1217
|
+
).length;
|
|
1218
|
+
const report = {
|
|
1219
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1220
|
+
totalEntries: results.length,
|
|
1221
|
+
passed,
|
|
1222
|
+
failed: results.length - passed - pendingJudgments,
|
|
1223
|
+
pendingJudgments,
|
|
1224
|
+
results
|
|
1225
|
+
};
|
|
1226
|
+
await writeSummaryMarkdown(report, cwd);
|
|
1227
|
+
return { report, applied, missing, invalid };
|
|
1228
|
+
}
|
|
1229
|
+
var HOST_INSTRUCTIONS = [
|
|
1230
|
+
"The visual-regression heuristic could not classify this diff confidently.",
|
|
1231
|
+
"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).",
|
|
1232
|
+
"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).",
|
|
1233
|
+
"Decide whether the change is a regression, an intentional UI change, or rendering noise.",
|
|
1234
|
+
"Write your decision to `verdict.json` (next to this `request.json`) with shape:",
|
|
1235
|
+
' { "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 }',
|
|
1236
|
+
"Then re-run `blazediff-agent check --apply-judgments --json` to regenerate summary.md."
|
|
1237
|
+
].join("\n");
|
|
1238
|
+
function relTo(cwd, abs) {
|
|
1239
|
+
if (!abs) return void 0;
|
|
1240
|
+
return path2__default.default.relative(cwd, abs).split(path2__default.default.sep).join("/");
|
|
1241
|
+
}
|
|
1242
|
+
function signatureOf(r) {
|
|
1243
|
+
const pct = typeof r.diffPercentage === "number" ? r.diffPercentage.toFixed(4) : "?";
|
|
1244
|
+
const regions = r.regions?.length ?? 0;
|
|
1245
|
+
const severity = r.severity ?? "?";
|
|
1246
|
+
return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
|
|
1247
|
+
}
|
|
1248
|
+
async function readJsonOrNull2(file) {
|
|
1249
|
+
try {
|
|
1250
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
1251
|
+
} catch {
|
|
1252
|
+
return null;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
function entryById(manifest, id) {
|
|
1256
|
+
return manifest.entries.find((e) => e.id === id);
|
|
1257
|
+
}
|
|
1258
|
+
function buildRequest(result, entry, cwd, tiles) {
|
|
1259
|
+
const isAmbiguous = result.status === "needs-judgment" || result.verdict?.label === "ambiguous" && result.status === "fail";
|
|
1260
|
+
return {
|
|
1261
|
+
id: result.id,
|
|
1262
|
+
url: result.url,
|
|
1263
|
+
status: result.status,
|
|
1264
|
+
diffPercentage: result.diffPercentage,
|
|
1265
|
+
severity: result.severity,
|
|
1266
|
+
regions: result.regions,
|
|
1267
|
+
paths: {
|
|
1268
|
+
baseline: relTo(cwd, result.baselinePath),
|
|
1269
|
+
actual: relTo(cwd, result.actualPath),
|
|
1270
|
+
diff: relTo(cwd, result.diffPath),
|
|
1271
|
+
locator: tiles.locatorPath,
|
|
1272
|
+
tiles: tiles.tilesPath
|
|
1273
|
+
},
|
|
1274
|
+
heuristicVerdict: result.verdict,
|
|
1275
|
+
manifestEntry: {
|
|
1276
|
+
viewport: entry.viewport,
|
|
1277
|
+
mask: entry.mask,
|
|
1278
|
+
waitFor: entry.waitFor,
|
|
1279
|
+
fullPage: entry.fullPage
|
|
1280
|
+
},
|
|
1281
|
+
signature: signatureOf(result),
|
|
1282
|
+
message: result.message,
|
|
1283
|
+
instructions: isAmbiguous ? HOST_INSTRUCTIONS : void 0,
|
|
1284
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
function autoVerdict(result) {
|
|
1288
|
+
if (!result.verdict) return null;
|
|
1289
|
+
if (result.status === "needs-judgment") return null;
|
|
1290
|
+
if (result.verdict.label === "ambiguous") return null;
|
|
1291
|
+
return {
|
|
1292
|
+
id: result.id,
|
|
1293
|
+
verdict: result.verdict,
|
|
1294
|
+
rationale: result.verdict.rationale.join(" "),
|
|
1295
|
+
confidence: 1
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
async function discoverTiles(dir) {
|
|
1299
|
+
const locatorAbs = path2__default.default.join(dir, "locator.png");
|
|
1300
|
+
const tilesAbs = path2__default.default.join(dir, "regions.png");
|
|
1301
|
+
return {
|
|
1302
|
+
locatorPath: fs.existsSync(locatorAbs) ? "locator.png" : void 0,
|
|
1303
|
+
tilesPath: fs.existsSync(tilesAbs) ? "regions.png" : void 0
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
async function writeJudgments(opts) {
|
|
1307
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1308
|
+
const root = paths(cwd).judgments;
|
|
1309
|
+
await promises.mkdir(root, { recursive: true });
|
|
1310
|
+
const knownIds = /* @__PURE__ */ new Set();
|
|
1311
|
+
for (const r of opts.report.results) knownIds.add(r.id);
|
|
1312
|
+
for (const result of opts.report.results) {
|
|
1313
|
+
const dir = path2__default.default.join(root, result.id);
|
|
1314
|
+
if (result.status === "pass") {
|
|
1315
|
+
if (fs.existsSync(dir)) await promises.rm(dir, { recursive: true, force: true });
|
|
1316
|
+
continue;
|
|
1317
|
+
}
|
|
1318
|
+
const entry = entryById(opts.manifest, result.id);
|
|
1319
|
+
if (!entry) continue;
|
|
1320
|
+
await promises.mkdir(dir, { recursive: true });
|
|
1321
|
+
const tiles = await discoverTiles(dir);
|
|
1322
|
+
const request = buildRequest(result, entry, cwd, tiles);
|
|
1323
|
+
const requestFile = path2__default.default.join(dir, "request.json");
|
|
1324
|
+
const prior = await readJsonOrNull2(requestFile);
|
|
1325
|
+
const verdictFile = path2__default.default.join(dir, "verdict.json");
|
|
1326
|
+
const priorVerdict = fs.existsSync(verdictFile) ? await readJsonOrNull2(verdictFile) : null;
|
|
1327
|
+
const signatureMatches = prior !== null && prior.signature === request.signature;
|
|
1328
|
+
await promises.writeFile(
|
|
1329
|
+
requestFile,
|
|
1330
|
+
`${JSON.stringify(request, null, 2)}
|
|
1331
|
+
`,
|
|
1332
|
+
"utf8"
|
|
1333
|
+
);
|
|
1334
|
+
if (priorVerdict && signatureMatches) {
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
const auto = autoVerdict(result);
|
|
1338
|
+
if (auto) {
|
|
1339
|
+
await promises.writeFile(
|
|
1340
|
+
verdictFile,
|
|
1341
|
+
`${JSON.stringify(auto, null, 2)}
|
|
1342
|
+
`,
|
|
1343
|
+
"utf8"
|
|
1344
|
+
);
|
|
1345
|
+
} else if (priorVerdict && !signatureMatches) {
|
|
1346
|
+
await promises.rm(verdictFile, { force: true });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
let entries;
|
|
1350
|
+
try {
|
|
1351
|
+
entries = await promises.readdir(root);
|
|
1352
|
+
} catch {
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
for (const name of entries) {
|
|
1356
|
+
if (knownIds.has(name)) continue;
|
|
1357
|
+
await promises.rm(path2__default.default.join(root, name), { recursive: true, force: true });
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// src/judge/index.ts
|
|
1362
|
+
function resolveJudge(backend) {
|
|
1363
|
+
switch (backend) {
|
|
1364
|
+
case "none":
|
|
1365
|
+
return noneJudge;
|
|
1366
|
+
case "host":
|
|
1367
|
+
return hostHarnessJudge;
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
function escapeXml(value) {
|
|
1371
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1372
|
+
}
|
|
1373
|
+
async function writeJunit(report, destPath) {
|
|
1374
|
+
await promises.mkdir(path2__default.default.dirname(destPath), { recursive: true });
|
|
1375
|
+
const cases = report.results.map((r) => {
|
|
1376
|
+
if (r.status === "pass") {
|
|
1377
|
+
return ` <testcase classname="blazediff" name="${escapeXml(r.id)}"/>`;
|
|
1378
|
+
}
|
|
1379
|
+
const message = r.message ?? r.status;
|
|
1380
|
+
return ` <testcase classname="blazediff" name="${escapeXml(r.id)}">
|
|
1381
|
+
<failure message="${escapeXml(message)}" type="${escapeXml(r.status)}">${escapeXml(message)}</failure>
|
|
1382
|
+
</testcase>`;
|
|
1383
|
+
});
|
|
1384
|
+
const failures = report.totalEntries - report.passed;
|
|
1385
|
+
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1386
|
+
<testsuites>
|
|
1387
|
+
<testsuite name="blazediff" tests="${report.totalEntries}" failures="${failures}">
|
|
1388
|
+
${cases.join("\n")}
|
|
1389
|
+
</testsuite>
|
|
1390
|
+
</testsuites>
|
|
1391
|
+
`;
|
|
1392
|
+
await promises.writeFile(destPath, xml, "utf8");
|
|
1393
|
+
return destPath;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// src/check.ts
|
|
1397
|
+
function narrowRegion(r) {
|
|
1398
|
+
return {
|
|
1399
|
+
bbox: r.bbox,
|
|
1400
|
+
pixelCount: r.pixelCount,
|
|
1401
|
+
percentage: r.percentage,
|
|
1402
|
+
changeType: r.changeType,
|
|
1403
|
+
confidence: r.confidence
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
async function pool(items, limit, fn) {
|
|
1407
|
+
const results = new Array(items.length);
|
|
1408
|
+
let next = 0;
|
|
1409
|
+
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
1410
|
+
const workers = Array.from({ length: workerCount }, async () => {
|
|
1411
|
+
while (true) {
|
|
1412
|
+
const i = next++;
|
|
1413
|
+
if (i >= items.length) return;
|
|
1414
|
+
results[i] = await fn(items[i], i);
|
|
1415
|
+
}
|
|
1416
|
+
});
|
|
1417
|
+
await Promise.all(workers);
|
|
1418
|
+
return results;
|
|
1419
|
+
}
|
|
1420
|
+
function passResult2(entry, baselinePath, actualPath) {
|
|
1421
|
+
return {
|
|
1422
|
+
id: entry.id,
|
|
1423
|
+
url: entry.url,
|
|
1424
|
+
status: "pass",
|
|
1425
|
+
baselinePath,
|
|
1426
|
+
actualPath
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
function skipResult(entry, message) {
|
|
1430
|
+
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1431
|
+
}
|
|
1432
|
+
function staleResult(entry) {
|
|
1433
|
+
return {
|
|
1434
|
+
id: entry.id,
|
|
1435
|
+
url: entry.url,
|
|
1436
|
+
status: "stale-baseline",
|
|
1437
|
+
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function missingBaselineResult(entry, baselinePath) {
|
|
1441
|
+
return {
|
|
1442
|
+
id: entry.id,
|
|
1443
|
+
url: entry.url,
|
|
1444
|
+
status: "missing-baseline",
|
|
1445
|
+
message: `baseline missing at ${baselinePath}`
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
function failResult(entry, outcome, actualPath, baselinePath, verdict) {
|
|
1449
|
+
return {
|
|
1450
|
+
id: entry.id,
|
|
1451
|
+
url: entry.url,
|
|
1452
|
+
status: "fail",
|
|
1453
|
+
diffCount: outcome.diffCount,
|
|
1454
|
+
diffPercentage: outcome.diffPercentage,
|
|
1455
|
+
severity: outcome.interpretation?.severity,
|
|
1456
|
+
regions: outcome.interpretation?.regions?.map(narrowRegion),
|
|
1457
|
+
verdict,
|
|
1458
|
+
diffPath: outcome.diffPath,
|
|
1459
|
+
baselinePath,
|
|
1460
|
+
actualPath,
|
|
1461
|
+
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
async function judgeAmbiguous(result, entry, judge, cwd) {
|
|
1465
|
+
if (result.status !== "fail" || !result.verdict || result.verdict.label !== "ambiguous" || !result.baselinePath || !result.actualPath) {
|
|
1466
|
+
return result;
|
|
1467
|
+
}
|
|
1468
|
+
const output = await judge.judge(
|
|
1469
|
+
{
|
|
1470
|
+
entry,
|
|
1471
|
+
baselinePath: result.baselinePath,
|
|
1472
|
+
actualPath: result.actualPath,
|
|
1473
|
+
diffPath: result.diffPath,
|
|
1474
|
+
regions: result.regions,
|
|
1475
|
+
diffPercentage: result.diffPercentage,
|
|
1476
|
+
severity: result.severity,
|
|
1477
|
+
heuristicVerdict: result.verdict
|
|
1478
|
+
},
|
|
1479
|
+
cwd
|
|
1480
|
+
);
|
|
1481
|
+
if (output.kind === "judged") {
|
|
1482
|
+
return { ...result, verdict: output.verdict };
|
|
1483
|
+
}
|
|
1484
|
+
return {
|
|
1485
|
+
...result,
|
|
1486
|
+
status: "needs-judgment",
|
|
1487
|
+
message: `awaiting judgment in ${output.requestPath}`
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
async function checkEntry(entry, opts, cwd, baselinesDir, judge) {
|
|
1491
|
+
if (entry.auth === "required") {
|
|
1492
|
+
return skipResult(entry, "skipped: auth required (deferred to v0.2)");
|
|
1493
|
+
}
|
|
1494
|
+
if (isEntryStale(entry)) {
|
|
1495
|
+
return staleResult(entry);
|
|
1496
|
+
}
|
|
1497
|
+
const baselinePath = path2__default.default.join(baselinesDir, `${entry.id}.png`);
|
|
1498
|
+
const capture = await captureScreenshot(
|
|
1499
|
+
opts.baseUrl,
|
|
1500
|
+
{
|
|
1501
|
+
id: entry.id,
|
|
1502
|
+
url: entry.url,
|
|
1503
|
+
viewport: entry.viewport,
|
|
1504
|
+
mask: entry.mask,
|
|
1505
|
+
waitFor: entry.waitFor,
|
|
1506
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1507
|
+
mode: "actual"
|
|
1508
|
+
},
|
|
1509
|
+
cwd
|
|
1510
|
+
);
|
|
1511
|
+
const outcome = await diffEntry(
|
|
1512
|
+
entry.id,
|
|
1513
|
+
baselinePath,
|
|
1514
|
+
capture.outputPath,
|
|
1515
|
+
{ threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
|
|
1516
|
+
cwd
|
|
1517
|
+
);
|
|
1518
|
+
if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
|
|
1519
|
+
if (outcome.reason === "file-not-exists")
|
|
1520
|
+
return missingBaselineResult(entry, baselinePath);
|
|
1521
|
+
const verdict = deriveVerdict({
|
|
1522
|
+
reason: outcome.reason,
|
|
1523
|
+
interpretation: outcome.interpretation,
|
|
1524
|
+
diffCount: outcome.diffCount,
|
|
1525
|
+
diffPercentage: outcome.diffPercentage
|
|
1526
|
+
});
|
|
1527
|
+
const failed = failResult(
|
|
1528
|
+
entry,
|
|
1529
|
+
outcome,
|
|
1530
|
+
capture.outputPath,
|
|
1531
|
+
baselinePath,
|
|
1532
|
+
verdict
|
|
1533
|
+
);
|
|
1534
|
+
return judgeAmbiguous(failed, entry, judge, cwd);
|
|
1535
|
+
}
|
|
1536
|
+
async function runCheck(opts) {
|
|
1537
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1538
|
+
const manifest = await loadManifest(cwd);
|
|
1539
|
+
if (!manifest) {
|
|
1540
|
+
throw new Error(
|
|
1541
|
+
`no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
const baselinesDir = paths(cwd).baselines;
|
|
1545
|
+
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
1546
|
+
const judge = resolveJudge(opts.judge ?? "none");
|
|
1547
|
+
let results;
|
|
1548
|
+
try {
|
|
1549
|
+
results = await pool(
|
|
1550
|
+
manifest.entries,
|
|
1551
|
+
concurrency,
|
|
1552
|
+
(entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
|
|
1553
|
+
);
|
|
1554
|
+
} finally {
|
|
1555
|
+
await closeBrowser();
|
|
1556
|
+
}
|
|
1557
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
1558
|
+
const pendingJudgments = results.filter(
|
|
1559
|
+
(r) => r.status === "needs-judgment"
|
|
1560
|
+
).length;
|
|
1561
|
+
const report = {
|
|
1562
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1563
|
+
totalEntries: results.length,
|
|
1564
|
+
passed,
|
|
1565
|
+
failed: results.length - passed - pendingJudgments,
|
|
1566
|
+
pendingJudgments,
|
|
1567
|
+
results
|
|
1568
|
+
};
|
|
1569
|
+
await writeJudgments({ report, manifest, cwd });
|
|
1570
|
+
await writeSummaryMarkdown(report, cwd);
|
|
1571
|
+
await ensureGitignore(cwd);
|
|
1572
|
+
if (opts.junitPath) {
|
|
1573
|
+
const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
|
|
1574
|
+
await writeJunit(report, target);
|
|
1575
|
+
}
|
|
1576
|
+
return report;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// src/cli/commands/check.ts
|
|
1580
|
+
function slimResult(r) {
|
|
1581
|
+
return {
|
|
1582
|
+
id: r.id,
|
|
1583
|
+
url: r.url,
|
|
1584
|
+
status: r.status,
|
|
1585
|
+
verdict: r.verdict ? {
|
|
1586
|
+
label: r.verdict.label,
|
|
1587
|
+
headline: r.verdict.headline,
|
|
1588
|
+
action: r.verdict.action
|
|
1589
|
+
} : void 0
|
|
1590
|
+
};
|
|
1591
|
+
}
|
|
1592
|
+
function slimReport(report, summaryPath) {
|
|
1593
|
+
return {
|
|
1594
|
+
summaryPath,
|
|
1595
|
+
createdAt: report.createdAt,
|
|
1596
|
+
totalEntries: report.totalEntries,
|
|
1597
|
+
passed: report.passed,
|
|
1598
|
+
failed: report.failed,
|
|
1599
|
+
pendingJudgments: report.pendingJudgments,
|
|
1600
|
+
results: report.results.filter((r) => r.status !== "pass").map(slimResult)
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
function failureLines(results) {
|
|
1604
|
+
return results.filter((r) => r.status !== "pass").flatMap((r) => {
|
|
1605
|
+
const lines = [];
|
|
1606
|
+
const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
|
|
1607
|
+
if (r.verdict) {
|
|
1608
|
+
lines.push(
|
|
1609
|
+
` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
|
|
1610
|
+
);
|
|
1611
|
+
lines.push(` \u2192 ${r.verdict.action}`);
|
|
1612
|
+
} else {
|
|
1613
|
+
const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
|
|
1614
|
+
lines.push(` ${prefix} ${r.id}: ${detail}`);
|
|
1615
|
+
}
|
|
1616
|
+
if (r.status === "needs-judgment" && r.message) {
|
|
1617
|
+
lines.push(` ${r.message}`);
|
|
1618
|
+
}
|
|
1619
|
+
if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
|
|
1620
|
+
return lines;
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
function parseJudge(input) {
|
|
1624
|
+
if (input === "host" || input === "none") return input;
|
|
1625
|
+
throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
|
|
1626
|
+
}
|
|
1627
|
+
function registerCheck(program, out) {
|
|
1628
|
+
program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
|
|
1629
|
+
"--threshold <n>",
|
|
1630
|
+
"color threshold (0-1)",
|
|
1631
|
+
String(DEFAULT_THRESHOLD)
|
|
1632
|
+
).option(
|
|
1633
|
+
"--concurrency <n>",
|
|
1634
|
+
"max entries checked in parallel (default: auto based on CPU cores, capped at 8)"
|
|
1635
|
+
).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
|
|
1636
|
+
"--judge <backend>",
|
|
1637
|
+
"judge backend for ambiguous diffs (host | none)",
|
|
1638
|
+
"none"
|
|
1639
|
+
).option(
|
|
1640
|
+
"--apply-judgments",
|
|
1641
|
+
"regenerate summary.md from .blazediff/judgments/<id>/verdict.json files (no re-check)"
|
|
1642
|
+
).action(async (opts) => {
|
|
1643
|
+
if (opts.applyJudgments) {
|
|
1644
|
+
const { report: report2, applied, missing, invalid } = await applyJudgments();
|
|
1645
|
+
const summaryPath2 = paths().summary;
|
|
1646
|
+
const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
|
|
1647
|
+
summary: ${summaryPath2}` : [
|
|
1648
|
+
`applied ${applied.length} judgment(s)`,
|
|
1649
|
+
missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
|
|
1650
|
+
invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
|
|
1651
|
+
` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
|
|
1652
|
+
` summary: ${summaryPath2}`
|
|
1653
|
+
].filter(Boolean).join("\n");
|
|
1654
|
+
out.emit(
|
|
1655
|
+
{
|
|
1656
|
+
ok: true,
|
|
1657
|
+
applied,
|
|
1658
|
+
missing,
|
|
1659
|
+
invalid,
|
|
1660
|
+
...slimReport(report2, summaryPath2)
|
|
1661
|
+
},
|
|
1662
|
+
human2
|
|
1663
|
+
);
|
|
1664
|
+
if (report2.failed > 0) process.exitCode = 1;
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
1668
|
+
const report = await runCheck({
|
|
1669
|
+
baseUrl,
|
|
1670
|
+
threshold: Number(opts.threshold),
|
|
1671
|
+
concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
|
|
1672
|
+
emitDiffPng: opts.diffPng,
|
|
1673
|
+
junitPath: opts.junit,
|
|
1674
|
+
judge: parseJudge(opts.judge)
|
|
1675
|
+
});
|
|
1676
|
+
const summaryPath = paths().summary;
|
|
1677
|
+
const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
|
|
1678
|
+
const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
|
|
1679
|
+
summary: ${summaryPath}` : [
|
|
1680
|
+
`${summary}:`,
|
|
1681
|
+
...failureLines(report.results),
|
|
1682
|
+
` summary: ${summaryPath}`,
|
|
1683
|
+
report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
|
|
1684
|
+
].filter(Boolean).join("\n");
|
|
1685
|
+
out.emit(slimReport(report, summaryPath), human);
|
|
1686
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
1687
|
+
});
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
// src/cli/commands/diff.ts
|
|
1691
|
+
function registerDiff(program, out) {
|
|
1692
|
+
program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
|
|
1693
|
+
"--threshold <n>",
|
|
1694
|
+
"color threshold (0-1)",
|
|
1695
|
+
String(DEFAULT_THRESHOLD)
|
|
1696
|
+
).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
|
|
1697
|
+
const manifest = await loadManifest();
|
|
1698
|
+
if (!manifest) throw new Error("no manifest");
|
|
1699
|
+
const entry = findEntry(manifest, id);
|
|
1700
|
+
if (!entry) throw new Error(`no entry with id ${id}`);
|
|
1701
|
+
const baselinePath = `${paths().baselines}/${id}.png`;
|
|
1702
|
+
const actualPath = `${paths().actual}/${id}.png`;
|
|
1703
|
+
const outcome = await diffEntry(id, baselinePath, actualPath, {
|
|
1704
|
+
threshold: Number(opts.threshold),
|
|
1705
|
+
emitDiffPng: Boolean(opts.emitDiffPng)
|
|
1706
|
+
});
|
|
1707
|
+
out.emit(
|
|
1708
|
+
outcome,
|
|
1709
|
+
outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
|
|
1710
|
+
);
|
|
1711
|
+
if (!outcome.match) process.exitCode = 1;
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// src/discover/crawl.ts
|
|
1716
|
+
function extractInternalLinks(base, target, hrefs) {
|
|
1717
|
+
const out = [];
|
|
1718
|
+
for (const href of hrefs) {
|
|
1719
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
try {
|
|
1723
|
+
const u = new URL(href, target);
|
|
1724
|
+
if (u.origin !== base.origin) continue;
|
|
1725
|
+
const path23 = u.pathname + u.search;
|
|
1726
|
+
if (path23.startsWith("/api/")) continue;
|
|
1727
|
+
out.push(path23);
|
|
1728
|
+
} catch {
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
return out;
|
|
1732
|
+
}
|
|
1733
|
+
async function crawlRoutes(opts) {
|
|
1734
|
+
const maxRoutes = opts.maxRoutes ?? 50;
|
|
1735
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
1736
|
+
const base = new URL(opts.baseUrl);
|
|
1737
|
+
const visited = /* @__PURE__ */ new Set();
|
|
1738
|
+
const queue = [{ url: "/", depth: 0 }];
|
|
1739
|
+
visited.add("/");
|
|
1740
|
+
const discovered = [];
|
|
1741
|
+
const browser = await getBrowser();
|
|
1742
|
+
const context = await browser.newContext({
|
|
1743
|
+
viewport: { width: 1024, height: 768 },
|
|
1744
|
+
deviceScaleFactor: 1
|
|
1745
|
+
});
|
|
1746
|
+
try {
|
|
1747
|
+
while (queue.length && discovered.length < maxRoutes) {
|
|
1748
|
+
const { url, depth } = queue.shift();
|
|
1749
|
+
const page = await context.newPage();
|
|
1750
|
+
try {
|
|
1751
|
+
const target = new URL(url, base).toString();
|
|
1752
|
+
await page.goto(target, {
|
|
1753
|
+
waitUntil: "domcontentloaded",
|
|
1754
|
+
timeout: 15e3
|
|
1755
|
+
});
|
|
1756
|
+
discovered.push({ url, source: "crawl" });
|
|
1757
|
+
if (depth >= maxDepth) continue;
|
|
1758
|
+
const hrefs = await page.evaluate(
|
|
1759
|
+
() => Array.from(
|
|
1760
|
+
document.querySelectorAll("a[href]")
|
|
1761
|
+
).map((a) => a.getAttribute("href") ?? "")
|
|
1762
|
+
);
|
|
1763
|
+
for (const path23 of extractInternalLinks(base, target, hrefs)) {
|
|
1764
|
+
if (visited.has(path23)) continue;
|
|
1765
|
+
visited.add(path23);
|
|
1766
|
+
queue.push({ url: path23, depth: depth + 1 });
|
|
1767
|
+
}
|
|
1768
|
+
} catch {
|
|
1769
|
+
} finally {
|
|
1770
|
+
await page.close().catch(() => {
|
|
1771
|
+
});
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
} finally {
|
|
1775
|
+
await context.close().catch(() => {
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
return discovered;
|
|
1779
|
+
}
|
|
1780
|
+
var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
|
|
1781
|
+
async function readJson(file) {
|
|
1782
|
+
if (!fs.existsSync(file)) return null;
|
|
1783
|
+
try {
|
|
1784
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
1785
|
+
} catch {
|
|
1786
|
+
return null;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
function isPublicRoute(route) {
|
|
1790
|
+
if (DYNAMIC_SEGMENT.test(route)) return false;
|
|
1791
|
+
if (route === "/api" || route.startsWith("/api/")) return false;
|
|
1792
|
+
return true;
|
|
1793
|
+
}
|
|
1794
|
+
async function discoverFromNextManifest(cwd = process.cwd()) {
|
|
1795
|
+
const nextDir = path2__default.default.join(cwd, ".next");
|
|
1796
|
+
if (!fs.existsSync(nextDir)) return [];
|
|
1797
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1798
|
+
const out = [];
|
|
1799
|
+
const add = (url) => {
|
|
1800
|
+
if (seen.has(url)) return;
|
|
1801
|
+
seen.add(url);
|
|
1802
|
+
out.push({ url, source: "next-manifest" });
|
|
1803
|
+
};
|
|
1804
|
+
const routes = await readJson(
|
|
1805
|
+
path2__default.default.join(nextDir, "routes-manifest.json")
|
|
1806
|
+
);
|
|
1807
|
+
for (const r of routes?.staticRoutes ?? []) {
|
|
1808
|
+
if (isPublicRoute(r.page)) add(r.page);
|
|
1809
|
+
}
|
|
1810
|
+
const appPaths = await readJson(
|
|
1811
|
+
path2__default.default.join(nextDir, "server", "app-paths-manifest.json")
|
|
1812
|
+
);
|
|
1813
|
+
for (const route of Object.keys(appPaths ?? {})) {
|
|
1814
|
+
const normalized = route.replace(/\/page$/, "") || "/";
|
|
1815
|
+
if (isPublicRoute(normalized)) add(normalized);
|
|
1816
|
+
}
|
|
1817
|
+
return out;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// src/discover/sitemap.ts
|
|
1821
|
+
var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
|
|
1822
|
+
var LOC_RE = /<loc>([^<]+)<\/loc>/g;
|
|
1823
|
+
async function discoverFromSitemap(baseUrl) {
|
|
1824
|
+
for (const candidate of CANDIDATES) {
|
|
1825
|
+
try {
|
|
1826
|
+
const res = await fetch(new URL(candidate, baseUrl));
|
|
1827
|
+
if (!res.ok) continue;
|
|
1828
|
+
const text = await res.text();
|
|
1829
|
+
const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
|
|
1830
|
+
if (!urls.length) continue;
|
|
1831
|
+
return urls.map((u) => {
|
|
1832
|
+
const url = new URL(u);
|
|
1833
|
+
return { url: url.pathname + url.search, source: "sitemap" };
|
|
1834
|
+
});
|
|
1835
|
+
} catch {
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
return [];
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/discover/index.ts
|
|
1842
|
+
function normalizePath(url) {
|
|
1843
|
+
const [pathPart, query = ""] = url.split("?", 2);
|
|
1844
|
+
const trimmed = pathPart.replace(/\/+$/, "");
|
|
1845
|
+
const normalizedPath = trimmed === "" ? "/" : trimmed;
|
|
1846
|
+
return query ? `${normalizedPath}?${query}` : normalizedPath;
|
|
1847
|
+
}
|
|
1848
|
+
function mergeBy(routes, into) {
|
|
1849
|
+
for (const r of routes) {
|
|
1850
|
+
const key = normalizePath(r.url);
|
|
1851
|
+
if (!into.has(key)) into.set(key, { ...r, url: key });
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
async function discover(opts) {
|
|
1855
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
1856
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1857
|
+
mergeBy(await discoverFromNextManifest(cwd), merged);
|
|
1858
|
+
mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
|
|
1859
|
+
if (!opts.skipCrawl) {
|
|
1860
|
+
const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
|
|
1861
|
+
if (crawlMax > 0) {
|
|
1862
|
+
mergeBy(
|
|
1863
|
+
await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
|
|
1864
|
+
merged
|
|
1865
|
+
);
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// src/cli/commands/discover.ts
|
|
1872
|
+
function registerDiscover(program, out) {
|
|
1873
|
+
program.command("discover").description(
|
|
1874
|
+
"enumerate candidate routes via BFS crawl + Next manifest + sitemap"
|
|
1875
|
+
).option("--base-url <url>", "override base URL").option("--max-routes <n>", "cap on routes returned", "50").option("--no-crawl", "skip BFS crawl fallback").action(async (opts) => {
|
|
1876
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
1877
|
+
const routes = await discover({
|
|
1878
|
+
baseUrl,
|
|
1879
|
+
maxRoutes: Number(opts.maxRoutes),
|
|
1880
|
+
skipCrawl: !opts.crawl
|
|
1881
|
+
});
|
|
1882
|
+
await closeBrowser();
|
|
1883
|
+
out.emit(
|
|
1884
|
+
{ ok: true, baseUrl, routes },
|
|
1885
|
+
routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
|
|
1886
|
+
);
|
|
1887
|
+
});
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// src/introspect/framework.ts
|
|
1891
|
+
var SIGNALS = [
|
|
1892
|
+
["next", ["next"]],
|
|
1893
|
+
["remix", ["@remix-run/dev", "@remix-run/serve"]],
|
|
1894
|
+
["sveltekit", ["@sveltejs/kit"]],
|
|
1895
|
+
["nuxt", ["nuxt", "nuxt3"]],
|
|
1896
|
+
["astro", ["astro"]],
|
|
1897
|
+
["gatsby", ["gatsby"]],
|
|
1898
|
+
["vite-react", ["vite", "react"]]
|
|
1899
|
+
];
|
|
1900
|
+
function detectFramework(pkg) {
|
|
1901
|
+
const deps = pkg.allDependencies;
|
|
1902
|
+
for (const [framework, required] of SIGNALS) {
|
|
1903
|
+
if (required.every((d) => d in deps)) return framework;
|
|
1904
|
+
}
|
|
1905
|
+
return "unknown";
|
|
1906
|
+
}
|
|
1907
|
+
async function readPackageJson(cwd = process.cwd()) {
|
|
1908
|
+
const file = path2__default.default.join(cwd, "package.json");
|
|
1909
|
+
if (!fs.existsSync(file)) return null;
|
|
1910
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
1911
|
+
}
|
|
1912
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1913
|
+
let dir = cwd;
|
|
1914
|
+
const { root } = path2__default.default.parse(dir);
|
|
1915
|
+
while (true) {
|
|
1916
|
+
if (fs.existsSync(path2__default.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
1917
|
+
if (fs.existsSync(path2__default.default.join(dir, "bun.lockb")) || fs.existsSync(path2__default.default.join(dir, "bun.lock")))
|
|
1918
|
+
return "bun";
|
|
1919
|
+
if (fs.existsSync(path2__default.default.join(dir, "yarn.lock"))) return "yarn";
|
|
1920
|
+
if (fs.existsSync(path2__default.default.join(dir, "package-lock.json"))) return "npm";
|
|
1921
|
+
if (dir === root) return "npm";
|
|
1922
|
+
dir = path2__default.default.dirname(dir);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
var DEFAULT_PORTS = {
|
|
1926
|
+
next: 3e3,
|
|
1927
|
+
"react-scripts": 3e3,
|
|
1928
|
+
vite: 5173,
|
|
1929
|
+
remix: 3e3,
|
|
1930
|
+
"@remix-run/dev": 3e3,
|
|
1931
|
+
astro: 4321,
|
|
1932
|
+
svelte: 5173,
|
|
1933
|
+
vue: 5173,
|
|
1934
|
+
nuxt: 3e3,
|
|
1935
|
+
gatsby: 8e3,
|
|
1936
|
+
parcel: 1234
|
|
1937
|
+
};
|
|
1938
|
+
var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
|
|
1939
|
+
function inferPort(script, deps) {
|
|
1940
|
+
const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
|
|
1941
|
+
if (portArg) return Number(portArg[1]);
|
|
1942
|
+
const portEnv = script.match(/PORT[\s=]+(\d+)/);
|
|
1943
|
+
if (portEnv) return Number(portEnv[1]);
|
|
1944
|
+
const depNames = Object.keys(deps);
|
|
1945
|
+
for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
|
|
1946
|
+
if (depNames.some((d) => d.startsWith(pkg))) return port;
|
|
1947
|
+
}
|
|
1948
|
+
return DEFAULT_PORT;
|
|
1949
|
+
}
|
|
1950
|
+
function runnerFor(pm, scriptName) {
|
|
1951
|
+
if (pm === "npm") return `npm run ${scriptName}`;
|
|
1952
|
+
if (pm === "yarn") return `yarn ${scriptName}`;
|
|
1953
|
+
if (pm === "bun") return `bun run ${scriptName}`;
|
|
1954
|
+
return `pnpm ${scriptName}`;
|
|
1955
|
+
}
|
|
1956
|
+
function collectCandidates(scripts, deps, pm) {
|
|
1957
|
+
const out = [];
|
|
1958
|
+
for (const name of DEV_SCRIPT_CANDIDATES) {
|
|
1959
|
+
if (scripts[name]) {
|
|
1960
|
+
out.push({
|
|
1961
|
+
name,
|
|
1962
|
+
body: scripts[name],
|
|
1963
|
+
command: runnerFor(pm, name),
|
|
1964
|
+
port: inferPort(scripts[name], deps)
|
|
1965
|
+
});
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
return out;
|
|
1969
|
+
}
|
|
1970
|
+
async function introspectPackage(cwd = process.cwd()) {
|
|
1971
|
+
const pkg = await readPackageJson(cwd);
|
|
1972
|
+
if (!pkg) return null;
|
|
1973
|
+
const packageManager = detectPackageManager(cwd);
|
|
1974
|
+
const scripts = pkg.scripts ?? {};
|
|
1975
|
+
const devDependencies = pkg.devDependencies ?? {};
|
|
1976
|
+
const dependencies = pkg.dependencies ?? {};
|
|
1977
|
+
const allDependencies = { ...devDependencies, ...dependencies };
|
|
1978
|
+
const candidates = collectCandidates(
|
|
1979
|
+
scripts,
|
|
1980
|
+
allDependencies,
|
|
1981
|
+
packageManager
|
|
1982
|
+
);
|
|
1983
|
+
if (!candidates.length) return null;
|
|
1984
|
+
const chosen = candidates[0];
|
|
1985
|
+
return {
|
|
1986
|
+
packageManager,
|
|
1987
|
+
devScript: chosen.name,
|
|
1988
|
+
devCommand: chosen.command,
|
|
1989
|
+
port: chosen.port,
|
|
1990
|
+
candidates,
|
|
1991
|
+
devDependencies,
|
|
1992
|
+
dependencies,
|
|
1993
|
+
allDependencies
|
|
1994
|
+
};
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// src/cli/commands/init.ts
|
|
1998
|
+
async function buildConfig(opts) {
|
|
1999
|
+
if (opts.url) {
|
|
2000
|
+
if (opts.devCommand || opts.port || opts.devScript) {
|
|
2001
|
+
throw new Error(
|
|
2002
|
+
"--url is mutually exclusive with --dev-command/--port/--dev-script"
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
const baseUrl = new URL(opts.url).toString().replace(/\/$/, "");
|
|
2006
|
+
return { devServer: null, baseUrl };
|
|
2007
|
+
}
|
|
2008
|
+
if (opts.devCommand) {
|
|
2009
|
+
const port2 = opts.port ? Number(opts.port) : DEFAULT_PORT;
|
|
2010
|
+
return {
|
|
2011
|
+
devServer: {
|
|
2012
|
+
command: opts.devCommand,
|
|
2013
|
+
port: port2,
|
|
2014
|
+
readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
|
|
2015
|
+
},
|
|
2016
|
+
baseUrl: `http://127.0.0.1:${port2}`
|
|
2017
|
+
};
|
|
2018
|
+
}
|
|
2019
|
+
const pkg = await introspectPackage();
|
|
2020
|
+
if (!pkg) {
|
|
2021
|
+
throw new Error(
|
|
2022
|
+
"no package.json with a dev/start script in cwd. Pass --url <baseUrl> or --dev-command <cmd>."
|
|
2023
|
+
);
|
|
2024
|
+
}
|
|
2025
|
+
let chosen = pkg.candidates[0];
|
|
2026
|
+
if (pkg.candidates.length > 1) {
|
|
2027
|
+
if (!opts.devScript) {
|
|
2028
|
+
const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
|
|
2029
|
+
throw new Error(
|
|
2030
|
+
`multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
const match = pkg.candidates.find((c) => c.name === opts.devScript);
|
|
2034
|
+
if (!match) {
|
|
2035
|
+
const names = pkg.candidates.map((c) => c.name).join(", ");
|
|
2036
|
+
throw new Error(
|
|
2037
|
+
`--dev-script "${opts.devScript}" not found among candidates: ${names}`
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
chosen = match;
|
|
2041
|
+
}
|
|
2042
|
+
const port = opts.port ? Number(opts.port) : chosen.port;
|
|
2043
|
+
return {
|
|
2044
|
+
devServer: {
|
|
2045
|
+
command: chosen.command,
|
|
2046
|
+
port,
|
|
2047
|
+
readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
|
|
2048
|
+
},
|
|
2049
|
+
framework: detectFramework(pkg),
|
|
2050
|
+
packageManager: pkg.packageManager,
|
|
2051
|
+
baseUrl: `http://127.0.0.1:${port}`
|
|
2052
|
+
};
|
|
2053
|
+
}
|
|
2054
|
+
function registerInit(program, out) {
|
|
2055
|
+
program.command("init").description("write .blazediff/config.json and .gitignore").option("--force", "overwrite existing config").option(
|
|
2056
|
+
"--url <baseUrl>",
|
|
2057
|
+
"point at an already-running server / external URL"
|
|
2058
|
+
).option("--dev-command <cmd>", "override detected dev-server command").option("--port <n>", "override detected port").option(
|
|
2059
|
+
"--dev-script <name>",
|
|
2060
|
+
"select a dev script by name when multiple candidates exist"
|
|
2061
|
+
).action(async (opts) => {
|
|
2062
|
+
const existing = await loadConfig();
|
|
2063
|
+
if (existing && !opts.force) {
|
|
2064
|
+
await ensureGitignore(process.cwd());
|
|
2065
|
+
out.emit(
|
|
2066
|
+
{
|
|
2067
|
+
ok: true,
|
|
2068
|
+
created: false,
|
|
2069
|
+
config: existing,
|
|
2070
|
+
configHash: configHash(existing)
|
|
2071
|
+
},
|
|
2072
|
+
`config exists at ${paths().config} (use --force to overwrite)`
|
|
2073
|
+
);
|
|
2074
|
+
return;
|
|
2075
|
+
}
|
|
2076
|
+
const config = await buildConfig(opts);
|
|
2077
|
+
await saveConfig(config);
|
|
2078
|
+
await ensureGitignore(process.cwd());
|
|
2079
|
+
const human = config.devServer ? `wrote ${paths().config}
|
|
2080
|
+
baseUrl: ${config.baseUrl}
|
|
2081
|
+
dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
|
|
2082
|
+
baseUrl: ${config.baseUrl}
|
|
2083
|
+
external server (no devServer managed)`;
|
|
2084
|
+
out.emit(
|
|
2085
|
+
{ ok: true, created: true, config, configHash: configHash(config) },
|
|
2086
|
+
human
|
|
2087
|
+
);
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
// src/cli/commands/manifest.ts
|
|
2092
|
+
function registerManifest(program, out) {
|
|
2093
|
+
const cmd = program.command("manifest").description("manage .blazediff/manifest.json");
|
|
2094
|
+
cmd.command("add <id>").requiredOption("--url <url>").option("--viewport <WxH>", "viewport", "1280x800").option("--mask <selectors>", "selectors", "").option("--wait-for <list>", "wait list", "networkidle,fonts").option("--no-full-page", "viewport-only (default: full page)").option("--auth <required|none>", "mark auth-gated", "none").option("--created-by <agent|human>", "provenance", "agent").action(async (id, opts) => {
|
|
2095
|
+
const config = await loadConfig();
|
|
2096
|
+
if (!config)
|
|
2097
|
+
throw new Error("no config. Run `blazediff-agent init` first.");
|
|
2098
|
+
const manifest = await loadManifest() ?? emptyManifest(configHash(config));
|
|
2099
|
+
const entry = makeEntry({
|
|
2100
|
+
id,
|
|
2101
|
+
url: opts.url,
|
|
2102
|
+
viewport: parseViewport(opts.viewport),
|
|
2103
|
+
mask: parseMaskList(opts.mask),
|
|
2104
|
+
waitFor: parseWaitFor(opts.waitFor),
|
|
2105
|
+
fullPage: opts.fullPage,
|
|
2106
|
+
auth: opts.auth === "required" ? "required" : null,
|
|
2107
|
+
createdBy: opts.createdBy
|
|
2108
|
+
});
|
|
2109
|
+
await saveManifest(addOrReplaceEntry(manifest, entry));
|
|
2110
|
+
out.emit(
|
|
2111
|
+
{ ok: true, entry },
|
|
2112
|
+
out.isTTY() ? `manifest: added ${id} (${entry.url})` : "."
|
|
2113
|
+
);
|
|
2114
|
+
});
|
|
2115
|
+
cmd.command("remove <id>").action(async (id) => {
|
|
2116
|
+
const manifest = await loadManifest();
|
|
2117
|
+
if (!manifest) throw new Error("no manifest");
|
|
2118
|
+
await saveManifest(removeEntry(manifest, id));
|
|
2119
|
+
out.emit({ ok: true, removed: id }, `manifest: removed ${id}`);
|
|
2120
|
+
});
|
|
2121
|
+
cmd.command("list").action(async () => {
|
|
2122
|
+
const manifest = await loadManifest();
|
|
2123
|
+
if (!manifest) {
|
|
2124
|
+
out.emit({ entries: [] }, "no manifest");
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
out.emit(
|
|
2128
|
+
{ entries: manifest.entries },
|
|
2129
|
+
manifest.entries.map((e) => `${e.id.padEnd(30)} ${e.url}`).join("\n") || "no entries"
|
|
2130
|
+
);
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
var someExists = (paths2) => paths2.some((p) => fs.existsSync(p));
|
|
2134
|
+
var HARNESSES = {
|
|
2135
|
+
claude: {
|
|
2136
|
+
id: "claude",
|
|
2137
|
+
label: "Claude Code",
|
|
2138
|
+
detect: (cwd) => someExists([
|
|
2139
|
+
path2.join(cwd, ".claude"),
|
|
2140
|
+
path2.join(cwd, "CLAUDE.md"),
|
|
2141
|
+
path2.join(cwd, "AGENTS.md")
|
|
2142
|
+
]),
|
|
2143
|
+
target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
|
|
2144
|
+
format: "skill-file",
|
|
2145
|
+
scope: "project"
|
|
2146
|
+
},
|
|
2147
|
+
codex: {
|
|
2148
|
+
id: "codex",
|
|
2149
|
+
label: "Codex",
|
|
2150
|
+
detect: (cwd) => someExists([
|
|
2151
|
+
path2.join(cwd, "AGENTS.md"),
|
|
2152
|
+
path2.join(cwd, ".codex"),
|
|
2153
|
+
path2.join(os.homedir(), ".codex")
|
|
2154
|
+
]),
|
|
2155
|
+
target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
|
|
2156
|
+
format: "skill-file",
|
|
2157
|
+
scope: "user"
|
|
2158
|
+
},
|
|
2159
|
+
cursor: {
|
|
2160
|
+
id: "cursor",
|
|
2161
|
+
label: "Cursor",
|
|
2162
|
+
detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
|
|
2163
|
+
target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
|
|
2164
|
+
format: "cursor-rule",
|
|
2165
|
+
scope: "project"
|
|
2166
|
+
}
|
|
2167
|
+
};
|
|
2168
|
+
var ALL_HARNESSES = ["claude", "codex", "cursor"];
|
|
2169
|
+
function detectHarnesses(cwd) {
|
|
2170
|
+
return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
|
|
2171
|
+
}
|
|
2172
|
+
function parseHarnessList(input) {
|
|
2173
|
+
const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2174
|
+
if (tokens.includes("all")) return [...ALL_HARNESSES];
|
|
2175
|
+
const out = [];
|
|
2176
|
+
for (const t of tokens) {
|
|
2177
|
+
if (!(t in HARNESSES)) {
|
|
2178
|
+
throw new Error(
|
|
2179
|
+
`unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
|
|
2180
|
+
);
|
|
2181
|
+
}
|
|
2182
|
+
if (!out.includes(t)) out.push(t);
|
|
2183
|
+
}
|
|
2184
|
+
return out;
|
|
2185
|
+
}
|
|
2186
|
+
var SKILL_FILES = ["SKILL.md", "JUDGING.md", "MASKING.md"];
|
|
2187
|
+
var cachedDir = null;
|
|
2188
|
+
var cachedFiles = null;
|
|
2189
|
+
function moduleDir() {
|
|
2190
|
+
return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
|
|
2191
|
+
}
|
|
2192
|
+
function resolveSkillDir() {
|
|
2193
|
+
if (cachedDir !== null) return cachedDir;
|
|
2194
|
+
const here = moduleDir();
|
|
2195
|
+
const candidates = [
|
|
2196
|
+
path2.join(here, ".."),
|
|
2197
|
+
path2.join(here, "..", ".."),
|
|
2198
|
+
path2.join(here, "..", "..", "..", "skill", "blazediff"),
|
|
2199
|
+
path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
|
|
2200
|
+
];
|
|
2201
|
+
for (const dir of candidates) {
|
|
2202
|
+
if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
|
|
2203
|
+
cachedDir = dir;
|
|
2204
|
+
return cachedDir;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
throw new Error(
|
|
2208
|
+
`could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
function loadSkillFiles() {
|
|
2212
|
+
if (cachedFiles !== null) return cachedFiles;
|
|
2213
|
+
const dir = resolveSkillDir();
|
|
2214
|
+
cachedFiles = SKILL_FILES.filter((name) => fs.existsSync(path2.join(dir, name))).map(
|
|
2215
|
+
(name) => ({ name, content: fs.readFileSync(path2.join(dir, name), "utf8") })
|
|
2216
|
+
);
|
|
2217
|
+
return cachedFiles;
|
|
2218
|
+
}
|
|
2219
|
+
function skillBodyOnly(content) {
|
|
2220
|
+
const lines = content.split("\n");
|
|
2221
|
+
if (lines[0]?.startsWith("---")) {
|
|
2222
|
+
let end = -1;
|
|
2223
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2224
|
+
if (lines[i]?.startsWith("---")) {
|
|
2225
|
+
end = i;
|
|
2226
|
+
break;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
if (end > 0)
|
|
2230
|
+
return lines.slice(end + 1).join("\n").trimStart();
|
|
2231
|
+
}
|
|
2232
|
+
return content;
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
// src/onboard/install.ts
|
|
2236
|
+
function ensureTrailingNewline(s) {
|
|
2237
|
+
return s.endsWith("\n") ? s : `${s}
|
|
2238
|
+
`;
|
|
2239
|
+
}
|
|
2240
|
+
function renderCursorRule(files) {
|
|
2241
|
+
const skill = files.find((f) => f.name === "SKILL.md")?.content ?? "";
|
|
2242
|
+
const sidecars = files.filter((f) => f.name !== "SKILL.md");
|
|
2243
|
+
const body = skillBodyOnly(skill).trim();
|
|
2244
|
+
const frontmatter = [
|
|
2245
|
+
"---",
|
|
2246
|
+
'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
|
|
2247
|
+
"alwaysApply: false",
|
|
2248
|
+
"---",
|
|
2249
|
+
""
|
|
2250
|
+
].join("\n");
|
|
2251
|
+
const sidecarBlocks = sidecars.map((f) => `
|
|
2252
|
+
|
|
2253
|
+
---
|
|
2254
|
+
|
|
2255
|
+
<!-- ${f.name} -->
|
|
2256
|
+
|
|
2257
|
+
${f.content.trim()}`).join("");
|
|
2258
|
+
return `${frontmatter}${body}${sidecarBlocks}
|
|
2259
|
+
`;
|
|
2260
|
+
}
|
|
2261
|
+
async function writeIfChanged(target, content, force) {
|
|
2262
|
+
const stat2 = await promises.lstat(target).catch(() => null);
|
|
2263
|
+
const isSymlink = stat2?.isSymbolicLink() ?? false;
|
|
2264
|
+
const exists = stat2 !== null;
|
|
2265
|
+
if (isSymlink) {
|
|
2266
|
+
await promises.unlink(target);
|
|
2267
|
+
await promises.mkdir(path2.dirname(target), { recursive: true });
|
|
2268
|
+
await promises.writeFile(target, content, "utf8");
|
|
2269
|
+
return "updated";
|
|
2270
|
+
}
|
|
2271
|
+
if (exists) {
|
|
2272
|
+
const current = fs.readFileSync(target, "utf8");
|
|
2273
|
+
if (current === content) return "unchanged";
|
|
2274
|
+
if (!force) return "skipped-exists";
|
|
2275
|
+
}
|
|
2276
|
+
await promises.mkdir(path2.dirname(target), { recursive: true });
|
|
2277
|
+
await promises.writeFile(target, content, "utf8");
|
|
2278
|
+
return exists ? "updated" : "created";
|
|
2279
|
+
}
|
|
2280
|
+
function combineStatuses(statuses) {
|
|
2281
|
+
if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
|
|
2282
|
+
if (statuses.some((s) => s === "created")) return "created";
|
|
2283
|
+
if (statuses.some((s) => s === "updated")) return "updated";
|
|
2284
|
+
return "unchanged";
|
|
2285
|
+
}
|
|
2286
|
+
async function installHarness(harness, cwd, opts = {}) {
|
|
2287
|
+
const info = HARNESSES[harness];
|
|
2288
|
+
const target = info.target(cwd);
|
|
2289
|
+
const files = loadSkillFiles();
|
|
2290
|
+
if (info.format === "cursor-rule") {
|
|
2291
|
+
const content = renderCursorRule(files);
|
|
2292
|
+
const status = await writeIfChanged(target, content, opts.force);
|
|
2293
|
+
return { harness, path: target, status };
|
|
2294
|
+
}
|
|
2295
|
+
const targetDir = path2.dirname(target);
|
|
2296
|
+
const statuses = [];
|
|
2297
|
+
for (const file of files) {
|
|
2298
|
+
const filePath = path2.join(targetDir, file.name);
|
|
2299
|
+
const status = await writeIfChanged(
|
|
2300
|
+
filePath,
|
|
2301
|
+
ensureTrailingNewline(file.content),
|
|
2302
|
+
opts.force
|
|
2303
|
+
);
|
|
2304
|
+
statuses.push(status);
|
|
2305
|
+
}
|
|
2306
|
+
return { harness, path: target, status: combineStatuses(statuses) };
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2309
|
+
// src/cli/commands/onboard.ts
|
|
2310
|
+
function suggestOtherHarnesses(installed) {
|
|
2311
|
+
const missing = ALL_HARNESSES.filter((h) => !installed.includes(h));
|
|
2312
|
+
if (missing.length === 0) return "";
|
|
2313
|
+
const labels = missing.map((h) => HARNESSES[h].label).join(" / ");
|
|
2314
|
+
const ids = missing.join(",");
|
|
2315
|
+
return `
|
|
2316
|
+
Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
|
|
2317
|
+
}
|
|
2318
|
+
async function promptForHarnesses() {
|
|
2319
|
+
const rl = promises$1.createInterface({
|
|
2320
|
+
input: process.stdin,
|
|
2321
|
+
output: process.stderr
|
|
2322
|
+
});
|
|
2323
|
+
try {
|
|
2324
|
+
const lines = [
|
|
2325
|
+
"No coding-agent harness detected. Which one(s) do you use?",
|
|
2326
|
+
...ALL_HARNESSES.map(
|
|
2327
|
+
(id, i) => ` [${i + 1}] ${HARNESSES[id].label.padEnd(12)} ${HARNESSES[id].target(process.cwd())}`
|
|
2328
|
+
),
|
|
2329
|
+
" [a] all three",
|
|
2330
|
+
""
|
|
2331
|
+
];
|
|
2332
|
+
process.stderr.write(`${lines.join("\n")}`);
|
|
2333
|
+
const answer = (await rl.question("Choice (1/2/3/a): ")).trim().toLowerCase();
|
|
2334
|
+
if (!answer) throw new Error("no harness selected; aborting");
|
|
2335
|
+
if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
|
|
2336
|
+
const idx = Number(answer);
|
|
2337
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
|
|
2338
|
+
throw new Error(
|
|
2339
|
+
`invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
return [ALL_HARNESSES[idx - 1]];
|
|
2343
|
+
} finally {
|
|
2344
|
+
rl.close();
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
function humanizeResults(results) {
|
|
2348
|
+
const lines = results.map((r) => {
|
|
2349
|
+
const verb = r.status === "created" ? "wrote" : r.status === "updated" ? "updated" : r.status === "unchanged" ? "unchanged" : "skipped (exists; pass --force to overwrite)";
|
|
2350
|
+
const info = HARNESSES[r.harness];
|
|
2351
|
+
const scopeTag = info.scope === "user" ? " [user-global]" : "";
|
|
2352
|
+
return ` ${info.label.padEnd(12)} ${verb}: ${r.path}${scopeTag}`;
|
|
2353
|
+
});
|
|
2354
|
+
const installed = results.map((r) => r.harness);
|
|
2355
|
+
const hint = suggestOtherHarnesses(installed);
|
|
2356
|
+
return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
|
|
2357
|
+
}
|
|
2358
|
+
function registerOnboard(program, out) {
|
|
2359
|
+
program.command("onboard").description(
|
|
2360
|
+
"install the BlazeDiff playbook into the coding-agent harness in cwd. Auto-detects Claude Code (.claude/), Codex (AGENTS.md), and Cursor (.cursor/). Prompts on TTY when none detected."
|
|
2361
|
+
).option(
|
|
2362
|
+
"--harness <list>",
|
|
2363
|
+
'comma-separated harness ids, or "all". valid: claude,codex,cursor,all'
|
|
2364
|
+
).option(
|
|
2365
|
+
"--force",
|
|
2366
|
+
"overwrite existing playbook files (idempotent without --force only when content matches)"
|
|
2367
|
+
).action(async (opts) => {
|
|
2368
|
+
const cwd = process.cwd();
|
|
2369
|
+
let targets;
|
|
2370
|
+
if (opts.harness) {
|
|
2371
|
+
targets = parseHarnessList(opts.harness);
|
|
2372
|
+
} else {
|
|
2373
|
+
const detected = detectHarnesses(cwd);
|
|
2374
|
+
if (detected.length > 0) {
|
|
2375
|
+
targets = detected;
|
|
2376
|
+
} else if (out.isTTY() && !out.isJson()) {
|
|
2377
|
+
targets = await promptForHarnesses();
|
|
2378
|
+
} else {
|
|
2379
|
+
throw new Error(
|
|
2380
|
+
"no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
const results = [];
|
|
2385
|
+
for (const t of targets) {
|
|
2386
|
+
results.push(await installHarness(t, cwd, { force: opts.force }));
|
|
2387
|
+
}
|
|
2388
|
+
out.emit(
|
|
2389
|
+
{
|
|
2390
|
+
ok: true,
|
|
2391
|
+
detected: detectHarnesses(cwd),
|
|
2392
|
+
installed: results
|
|
2393
|
+
},
|
|
2394
|
+
humanizeResults(results)
|
|
2395
|
+
);
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
var execFileP = util.promisify(child_process.execFile);
|
|
2399
|
+
async function isPortOpen(port, host = "127.0.0.1") {
|
|
2400
|
+
return new Promise((resolve) => {
|
|
2401
|
+
const socket = net.createConnection({ port, host });
|
|
2402
|
+
socket.setTimeout(500);
|
|
2403
|
+
socket.once("connect", () => {
|
|
2404
|
+
socket.destroy();
|
|
2405
|
+
resolve(true);
|
|
2406
|
+
});
|
|
2407
|
+
socket.once("error", () => resolve(false));
|
|
2408
|
+
socket.once("timeout", () => {
|
|
2409
|
+
socket.destroy();
|
|
2410
|
+
resolve(false);
|
|
2411
|
+
});
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
async function waitForPort(port, timeoutMs = 6e4) {
|
|
2415
|
+
const deadline = Date.now() + timeoutMs;
|
|
2416
|
+
while (Date.now() < deadline) {
|
|
2417
|
+
if (await isPortOpen(port)) return;
|
|
2418
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2419
|
+
}
|
|
2420
|
+
throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
|
|
2421
|
+
}
|
|
2422
|
+
async function findPidByPort(port) {
|
|
2423
|
+
const platform = process.platform;
|
|
2424
|
+
try {
|
|
2425
|
+
if (platform === "darwin" || platform === "linux") {
|
|
2426
|
+
const { stdout } = await execFileP("lsof", [
|
|
2427
|
+
"-ti",
|
|
2428
|
+
`tcp:${port}`,
|
|
2429
|
+
"-sTCP:LISTEN"
|
|
2430
|
+
]);
|
|
2431
|
+
const pid = Number(stdout.trim().split("\n")[0]);
|
|
2432
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2433
|
+
}
|
|
2434
|
+
if (platform === "win32") {
|
|
2435
|
+
const { stdout } = await execFileP("netstat", ["-ano"]);
|
|
2436
|
+
const line = stdout.split(/\r?\n/).find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
|
|
2437
|
+
if (!line) return null;
|
|
2438
|
+
const parts = line.trim().split(/\s+/);
|
|
2439
|
+
const pid = Number(parts[parts.length - 1]);
|
|
2440
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2441
|
+
}
|
|
2442
|
+
} catch {
|
|
2443
|
+
return null;
|
|
2444
|
+
}
|
|
2445
|
+
return null;
|
|
2446
|
+
}
|
|
2447
|
+
async function startServer(opts) {
|
|
2448
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2449
|
+
const logPath = opts.logPath ?? paths(cwd).serverLog;
|
|
2450
|
+
const pidPath = opts.pidPath ?? paths(cwd).serverPid;
|
|
2451
|
+
await promises.mkdir(path2__default.default.dirname(logPath), { recursive: true });
|
|
2452
|
+
if (await isPortOpen(opts.port)) {
|
|
2453
|
+
const discoveredPid = await findPidByPort(opts.port);
|
|
2454
|
+
if (discoveredPid) {
|
|
2455
|
+
await promises.writeFile(pidPath, String(discoveredPid), "utf8").catch(() => {
|
|
2456
|
+
});
|
|
2457
|
+
}
|
|
2458
|
+
return {
|
|
2459
|
+
pid: discoveredPid ?? 0,
|
|
2460
|
+
port: opts.port,
|
|
2461
|
+
url: `http://127.0.0.1:${opts.port}`,
|
|
2462
|
+
attached: true
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
const [bin, ...args] = parseCommand(opts.command);
|
|
2466
|
+
const child = child_process.spawn(bin, args, {
|
|
2467
|
+
cwd,
|
|
2468
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2469
|
+
detached: true,
|
|
2470
|
+
env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
|
|
2471
|
+
});
|
|
2472
|
+
const logStream = await import('fs').then(
|
|
2473
|
+
(m) => m.createWriteStream(logPath, { flags: "a" })
|
|
2474
|
+
);
|
|
2475
|
+
child.stdout?.pipe(logStream);
|
|
2476
|
+
child.stderr?.pipe(logStream);
|
|
2477
|
+
if (!child.pid) throw new Error("failed to spawn dev server");
|
|
2478
|
+
await promises.writeFile(pidPath, String(child.pid), "utf8");
|
|
2479
|
+
installSignalHandlers(child);
|
|
2480
|
+
try {
|
|
2481
|
+
await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
|
|
2482
|
+
} catch (err) {
|
|
2483
|
+
await stopProcess(child.pid);
|
|
2484
|
+
throw err;
|
|
2485
|
+
}
|
|
2486
|
+
return {
|
|
2487
|
+
pid: child.pid,
|
|
2488
|
+
port: opts.port,
|
|
2489
|
+
url: `http://127.0.0.1:${opts.port}`
|
|
2490
|
+
};
|
|
2491
|
+
}
|
|
2492
|
+
async function stopServer(cwd = process.cwd(), portFallback) {
|
|
2493
|
+
const pidPath = paths(cwd).serverPid;
|
|
2494
|
+
let pid = null;
|
|
2495
|
+
let via = "none";
|
|
2496
|
+
if (fs.existsSync(pidPath)) {
|
|
2497
|
+
const raw = (await promises.readFile(pidPath, "utf8")).trim();
|
|
2498
|
+
const parsed = Number(raw);
|
|
2499
|
+
if (Number.isFinite(parsed) && parsed > 0 && processExists(parsed)) {
|
|
2500
|
+
pid = parsed;
|
|
2501
|
+
via = "pidfile";
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
if (!pid && portFallback) {
|
|
2505
|
+
pid = await findPidByPort(portFallback);
|
|
2506
|
+
if (pid) via = "port";
|
|
2507
|
+
}
|
|
2508
|
+
if (!pid) {
|
|
2509
|
+
await promises.writeFile(pidPath, "", "utf8").catch(() => {
|
|
2510
|
+
});
|
|
2511
|
+
return { killed: false, pid: null, via: "none" };
|
|
2512
|
+
}
|
|
2513
|
+
await stopProcess(pid);
|
|
2514
|
+
await promises.writeFile(pidPath, "", "utf8").catch(() => {
|
|
2515
|
+
});
|
|
2516
|
+
return { killed: true, pid, via };
|
|
2517
|
+
}
|
|
2518
|
+
function processExists(pid) {
|
|
2519
|
+
try {
|
|
2520
|
+
process.kill(pid, 0);
|
|
2521
|
+
return true;
|
|
2522
|
+
} catch {
|
|
2523
|
+
return false;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
async function stopProcess(pid) {
|
|
2527
|
+
if (!pid) return;
|
|
2528
|
+
await new Promise((resolve) => {
|
|
2529
|
+
treeKill__default.default(pid, "SIGTERM", (err) => {
|
|
2530
|
+
if (err) {
|
|
2531
|
+
treeKill__default.default(pid, "SIGKILL", () => resolve());
|
|
2532
|
+
return;
|
|
2533
|
+
}
|
|
2534
|
+
resolve();
|
|
2535
|
+
});
|
|
2536
|
+
});
|
|
2537
|
+
}
|
|
2538
|
+
function parseCommand(command) {
|
|
2539
|
+
const out = [];
|
|
2540
|
+
let current = "";
|
|
2541
|
+
let inQuote = false;
|
|
2542
|
+
for (const ch of command) {
|
|
2543
|
+
if (ch === '"') {
|
|
2544
|
+
inQuote = !inQuote;
|
|
2545
|
+
continue;
|
|
2546
|
+
}
|
|
2547
|
+
if (ch === " " && !inQuote) {
|
|
2548
|
+
if (current) {
|
|
2549
|
+
out.push(current);
|
|
2550
|
+
current = "";
|
|
2551
|
+
}
|
|
2552
|
+
continue;
|
|
2553
|
+
}
|
|
2554
|
+
current += ch;
|
|
2555
|
+
}
|
|
2556
|
+
if (current) out.push(current);
|
|
2557
|
+
return out;
|
|
2558
|
+
}
|
|
2559
|
+
var signalsInstalled = false;
|
|
2560
|
+
function installSignalHandlers(child) {
|
|
2561
|
+
if (signalsInstalled) return;
|
|
2562
|
+
signalsInstalled = true;
|
|
2563
|
+
const cleanup = () => {
|
|
2564
|
+
if (child.pid) {
|
|
2565
|
+
try {
|
|
2566
|
+
process.kill(-child.pid, "SIGTERM");
|
|
2567
|
+
} catch {
|
|
2568
|
+
}
|
|
2569
|
+
treeKill__default.default(child.pid, "SIGKILL", () => {
|
|
2570
|
+
});
|
|
2571
|
+
}
|
|
2572
|
+
};
|
|
2573
|
+
process.on("SIGINT", () => {
|
|
2574
|
+
cleanup();
|
|
2575
|
+
process.exit(130);
|
|
2576
|
+
});
|
|
2577
|
+
process.on("SIGTERM", () => {
|
|
2578
|
+
cleanup();
|
|
2579
|
+
process.exit(143);
|
|
2580
|
+
});
|
|
2581
|
+
process.on("exit", cleanup);
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
// src/cli/commands/reset.ts
|
|
2585
|
+
async function stopTrackedServer() {
|
|
2586
|
+
const config = await loadConfig();
|
|
2587
|
+
if (!config?.devServer) return { stopped: false };
|
|
2588
|
+
try {
|
|
2589
|
+
const result = await stopServer(process.cwd(), config.devServer.port);
|
|
2590
|
+
return { stopped: result.killed, via: result.via, pid: result.pid };
|
|
2591
|
+
} catch {
|
|
2592
|
+
return { stopped: false };
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
function registerReset(program, out) {
|
|
2596
|
+
program.command("reset").description(
|
|
2597
|
+
"wipe .blazediff/ entirely - config, manifest, baselines, actual, judgments, summary, pid/log (stops the dev server first if one is tracked). Re-run /blazediff or `init` afterward to start from scratch."
|
|
2598
|
+
).option("--yes", "do not prompt; required when stdin is a TTY").action(async (opts) => {
|
|
2599
|
+
const root = paths().root;
|
|
2600
|
+
if (!fs.existsSync(root)) {
|
|
2601
|
+
out.emit(
|
|
2602
|
+
{ ok: true, removed: false, root },
|
|
2603
|
+
`nothing to reset (no ${root})`
|
|
2604
|
+
);
|
|
2605
|
+
return;
|
|
2606
|
+
}
|
|
2607
|
+
if (out.isTTY() && !opts.yes && !out.isJson()) {
|
|
2608
|
+
throw new Error(
|
|
2609
|
+
`refusing to wipe ${root} without --yes (interactive run)`
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
const stopOutcome = await stopTrackedServer();
|
|
2613
|
+
await promises.rm(root, { recursive: true, force: true });
|
|
2614
|
+
out.emit(
|
|
2615
|
+
{ ok: true, removed: true, root, devServer: stopOutcome },
|
|
2616
|
+
stopOutcome.stopped ? `stopped dev server (pid ${stopOutcome.pid} via ${stopOutcome.via}) and removed ${root}` : `removed ${root}`
|
|
2617
|
+
);
|
|
2618
|
+
});
|
|
2619
|
+
}
|
|
2620
|
+
async function resolveTargets(manifest, ids, opts) {
|
|
2621
|
+
const exclusive = [
|
|
2622
|
+
ids.length > 0,
|
|
2623
|
+
Boolean(opts.failed),
|
|
2624
|
+
Boolean(opts.all)
|
|
2625
|
+
].filter(Boolean).length;
|
|
2626
|
+
if (exclusive === 0) throw new Error("provide ids, --failed, or --all");
|
|
2627
|
+
if (exclusive > 1)
|
|
2628
|
+
throw new Error("ids / --failed / --all are mutually exclusive");
|
|
2629
|
+
if (opts.all) return new Set(manifest.entries.map((e) => e.id));
|
|
2630
|
+
if (opts.failed) {
|
|
2631
|
+
const judgmentsDir = paths().judgments;
|
|
2632
|
+
if (!fs.existsSync(judgmentsDir)) {
|
|
2633
|
+
throw new Error(
|
|
2634
|
+
`no judgments at ${judgmentsDir}. Run \`blazediff-agent check\` first.`
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
const names = await promises.readdir(judgmentsDir);
|
|
2638
|
+
const failed = /* @__PURE__ */ new Set();
|
|
2639
|
+
for (const name of names) {
|
|
2640
|
+
const full = path2__default.default.join(judgmentsDir, name);
|
|
2641
|
+
if (fs.statSync(full).isDirectory()) failed.add(name);
|
|
2642
|
+
}
|
|
2643
|
+
return failed;
|
|
2644
|
+
}
|
|
2645
|
+
const targets = new Set(ids);
|
|
2646
|
+
const missing = ids.filter(
|
|
2647
|
+
(id) => !manifest.entries.some((e) => e.id === id)
|
|
2648
|
+
);
|
|
2649
|
+
if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
|
|
2650
|
+
return targets;
|
|
2651
|
+
}
|
|
2652
|
+
function registerRewrite(program, out) {
|
|
2653
|
+
program.command("rewrite [ids...]").description(
|
|
2654
|
+
"rewrite baselines for existing manifest entries, preserving mask/viewport/etc. Pick targets via positional ids, --failed (uses .blazediff/judgments/ from last check), or --all."
|
|
2655
|
+
).option("--failed", "rewrite entries that failed the most recent check").option("--all", "rewrite every manifest entry").option("--base-url <url>", "override base URL").action(async (ids, opts) => {
|
|
2656
|
+
const manifest = await loadManifest();
|
|
2657
|
+
if (!manifest) throw new Error("no manifest. Run authoring first.");
|
|
2658
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
2659
|
+
const targets = await resolveTargets(manifest, ids, opts);
|
|
2660
|
+
if (targets.size === 0 && opts.failed) {
|
|
2661
|
+
out.emit({ ok: true, rewritten: 0 }, "no failed entries to rewrite");
|
|
2662
|
+
return;
|
|
2663
|
+
}
|
|
2664
|
+
const routes = manifest.entries.filter((e) => targets.has(e.id)).map((e) => ({
|
|
2665
|
+
id: e.id,
|
|
2666
|
+
url: e.url,
|
|
2667
|
+
mask: e.mask,
|
|
2668
|
+
viewport: e.viewport,
|
|
2669
|
+
waitFor: e.waitFor,
|
|
2670
|
+
fullPage: e.fullPage
|
|
2671
|
+
}));
|
|
2672
|
+
const report = await runCaptures({
|
|
2673
|
+
baseUrl,
|
|
2674
|
+
routes,
|
|
2675
|
+
mode: "baseline",
|
|
2676
|
+
writeManifest: true
|
|
2677
|
+
});
|
|
2678
|
+
const failureLines3 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
|
|
2679
|
+
const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
|
|
2680
|
+
`rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
|
|
2681
|
+
...failureLines3
|
|
2682
|
+
].join("\n");
|
|
2683
|
+
out.emit(report, human);
|
|
2684
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
2685
|
+
});
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// src/graph/nodes/aggregate.ts
|
|
2689
|
+
async function aggregateNode(state) {
|
|
2690
|
+
const options = state.options;
|
|
2691
|
+
if (!options) throw new Error("aggregateNode: options missing");
|
|
2692
|
+
const manifest = state.manifest;
|
|
2693
|
+
if (!manifest) throw new Error("aggregateNode: manifest missing");
|
|
2694
|
+
const results = state.results;
|
|
2695
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
2696
|
+
const pendingJudgments = results.filter(
|
|
2697
|
+
(r) => r.status === "needs-judgment"
|
|
2698
|
+
).length;
|
|
2699
|
+
const report = {
|
|
2700
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2701
|
+
totalEntries: results.length,
|
|
2702
|
+
passed,
|
|
2703
|
+
failed: results.length - passed - pendingJudgments,
|
|
2704
|
+
pendingJudgments,
|
|
2705
|
+
results
|
|
2706
|
+
};
|
|
2707
|
+
await writeJudgments({ report, manifest, cwd: options.cwd });
|
|
2708
|
+
await writeSummaryMarkdown(report, options.cwd);
|
|
2709
|
+
await ensureGitignore(options.cwd);
|
|
2710
|
+
return { report };
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// src/graph/nodes/load.ts
|
|
2714
|
+
async function loadNode(state) {
|
|
2715
|
+
if (!state.options) {
|
|
2716
|
+
throw new Error("loadNode: graph options missing");
|
|
2717
|
+
}
|
|
2718
|
+
const manifest = await loadManifest(state.options.cwd);
|
|
2719
|
+
if (!manifest) {
|
|
2720
|
+
throw new Error(
|
|
2721
|
+
`no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
2722
|
+
);
|
|
2723
|
+
}
|
|
2724
|
+
return { entries: manifest.entries, manifest };
|
|
2725
|
+
}
|
|
2726
|
+
function narrowRegion2(r) {
|
|
2727
|
+
return {
|
|
2728
|
+
bbox: r.bbox,
|
|
2729
|
+
pixelCount: r.pixelCount,
|
|
2730
|
+
percentage: r.percentage,
|
|
2731
|
+
changeType: r.changeType,
|
|
2732
|
+
confidence: r.confidence
|
|
2733
|
+
};
|
|
2734
|
+
}
|
|
2735
|
+
function skipResult2(entry, message) {
|
|
2736
|
+
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
2737
|
+
}
|
|
2738
|
+
function staleResult2(entry) {
|
|
2739
|
+
return {
|
|
2740
|
+
id: entry.id,
|
|
2741
|
+
url: entry.url,
|
|
2742
|
+
status: "stale-baseline",
|
|
2743
|
+
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
2744
|
+
};
|
|
2745
|
+
}
|
|
2746
|
+
function passResult3(entry, baselinePath, actualPath) {
|
|
2747
|
+
return {
|
|
2748
|
+
id: entry.id,
|
|
2749
|
+
url: entry.url,
|
|
2750
|
+
status: "pass",
|
|
2751
|
+
baselinePath,
|
|
2752
|
+
actualPath
|
|
2753
|
+
};
|
|
2754
|
+
}
|
|
2755
|
+
function missingBaselineResult2(entry, baselinePath) {
|
|
2756
|
+
return {
|
|
2757
|
+
id: entry.id,
|
|
2758
|
+
url: entry.url,
|
|
2759
|
+
status: "missing-baseline",
|
|
2760
|
+
message: `baseline missing at ${baselinePath}`
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
|
|
2764
|
+
return {
|
|
2765
|
+
id: entry.id,
|
|
2766
|
+
url: entry.url,
|
|
2767
|
+
status: "fail",
|
|
2768
|
+
diffCount: outcome.diffCount,
|
|
2769
|
+
diffPercentage: outcome.diffPercentage,
|
|
2770
|
+
severity: outcome.interpretation?.severity,
|
|
2771
|
+
regions: outcome.interpretation?.regions?.map(narrowRegion2),
|
|
2772
|
+
verdict,
|
|
2773
|
+
diffPath: outcome.diffPath,
|
|
2774
|
+
baselinePath,
|
|
2775
|
+
actualPath,
|
|
2776
|
+
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
function makeProcessNode(semaphore) {
|
|
2780
|
+
return async function processNode(state) {
|
|
2781
|
+
const entry = state.entry;
|
|
2782
|
+
const options = state.options;
|
|
2783
|
+
if (!entry || !options) {
|
|
2784
|
+
throw new Error("processNode: entry or options missing");
|
|
2785
|
+
}
|
|
2786
|
+
if (entry.auth === "required") {
|
|
2787
|
+
return {
|
|
2788
|
+
results: [
|
|
2789
|
+
skipResult2(entry, "skipped: auth required (deferred to v0.2)")
|
|
2790
|
+
]
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
if (isEntryStale(entry)) {
|
|
2794
|
+
return { results: [staleResult2(entry)] };
|
|
2795
|
+
}
|
|
2796
|
+
const capture = await semaphore.run(
|
|
2797
|
+
() => captureScreenshot(
|
|
2798
|
+
options.baseUrl,
|
|
2799
|
+
{
|
|
2800
|
+
id: entry.id,
|
|
2801
|
+
url: entry.url,
|
|
2802
|
+
viewport: entry.viewport,
|
|
2803
|
+
mask: entry.mask,
|
|
2804
|
+
waitFor: entry.waitFor,
|
|
2805
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
2806
|
+
mode: "actual"
|
|
2807
|
+
},
|
|
2808
|
+
options.cwd
|
|
2809
|
+
)
|
|
2810
|
+
);
|
|
2811
|
+
const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
|
|
2812
|
+
const outcome = await diffEntry(
|
|
2813
|
+
entry.id,
|
|
2814
|
+
baselinePath,
|
|
2815
|
+
capture.outputPath,
|
|
2816
|
+
{ threshold: options.threshold, emitDiffPng: options.emitDiffPng },
|
|
2817
|
+
options.cwd
|
|
2818
|
+
);
|
|
2819
|
+
if (outcome.match) {
|
|
2820
|
+
return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
|
|
2821
|
+
}
|
|
2822
|
+
if (outcome.reason === "file-not-exists") {
|
|
2823
|
+
return { results: [missingBaselineResult2(entry, baselinePath)] };
|
|
2824
|
+
}
|
|
2825
|
+
const verdict = deriveVerdict({
|
|
2826
|
+
reason: outcome.reason,
|
|
2827
|
+
interpretation: outcome.interpretation,
|
|
2828
|
+
diffCount: outcome.diffCount,
|
|
2829
|
+
diffPercentage: outcome.diffPercentage
|
|
2830
|
+
});
|
|
2831
|
+
let result = failResult2(
|
|
2832
|
+
entry,
|
|
2833
|
+
outcome,
|
|
2834
|
+
capture.outputPath,
|
|
2835
|
+
baselinePath,
|
|
2836
|
+
verdict
|
|
2837
|
+
);
|
|
2838
|
+
if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
|
|
2839
|
+
const judge = resolveJudge(options.judge);
|
|
2840
|
+
const output = await judge.judge(
|
|
2841
|
+
{
|
|
2842
|
+
entry,
|
|
2843
|
+
baselinePath: result.baselinePath,
|
|
2844
|
+
actualPath: result.actualPath,
|
|
2845
|
+
diffPath: result.diffPath,
|
|
2846
|
+
regions: result.regions,
|
|
2847
|
+
diffPercentage: result.diffPercentage,
|
|
2848
|
+
severity: result.severity,
|
|
2849
|
+
heuristicVerdict: result.verdict
|
|
2850
|
+
},
|
|
2851
|
+
options.cwd
|
|
2852
|
+
);
|
|
2853
|
+
if (output.kind === "judged") {
|
|
2854
|
+
result = { ...result, verdict: output.verdict };
|
|
2855
|
+
} else {
|
|
2856
|
+
result = {
|
|
2857
|
+
...result,
|
|
2858
|
+
status: "needs-judgment",
|
|
2859
|
+
message: `awaiting judgment in ${output.requestPath}`
|
|
2860
|
+
};
|
|
2861
|
+
}
|
|
2862
|
+
}
|
|
2863
|
+
return { results: [result] };
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
// src/graph/semaphore.ts
|
|
2868
|
+
var Semaphore = class {
|
|
2869
|
+
constructor(limit) {
|
|
2870
|
+
this.limit = limit;
|
|
2871
|
+
this.current = 0;
|
|
2872
|
+
this.queue = [];
|
|
2873
|
+
if (limit < 1)
|
|
2874
|
+
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
2875
|
+
}
|
|
2876
|
+
async run(fn) {
|
|
2877
|
+
if (this.current >= this.limit) {
|
|
2878
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
2879
|
+
}
|
|
2880
|
+
this.current++;
|
|
2881
|
+
try {
|
|
2882
|
+
return await fn();
|
|
2883
|
+
} finally {
|
|
2884
|
+
this.current--;
|
|
2885
|
+
const next = this.queue.shift();
|
|
2886
|
+
if (next) next();
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
};
|
|
2890
|
+
var GraphState = langgraph.Annotation.Root({
|
|
2891
|
+
options: langgraph.Annotation({
|
|
2892
|
+
reducer: (acc, next) => next ?? acc,
|
|
2893
|
+
default: () => void 0
|
|
2894
|
+
}),
|
|
2895
|
+
entries: langgraph.Annotation({
|
|
2896
|
+
reducer: (acc, next) => next ?? acc,
|
|
2897
|
+
default: () => []
|
|
2898
|
+
}),
|
|
2899
|
+
entry: langgraph.Annotation({
|
|
2900
|
+
reducer: (_, next) => next,
|
|
2901
|
+
default: () => void 0
|
|
2902
|
+
}),
|
|
2903
|
+
results: langgraph.Annotation({
|
|
2904
|
+
reducer: (acc, next) => [...acc, ...next],
|
|
2905
|
+
default: () => []
|
|
2906
|
+
}),
|
|
2907
|
+
manifest: langgraph.Annotation({
|
|
2908
|
+
reducer: (acc, next) => next ?? acc,
|
|
2909
|
+
default: () => void 0
|
|
2910
|
+
}),
|
|
2911
|
+
report: langgraph.Annotation({
|
|
2912
|
+
reducer: (acc, next) => next ?? acc,
|
|
2913
|
+
default: () => void 0
|
|
2914
|
+
})
|
|
2915
|
+
});
|
|
2916
|
+
|
|
2917
|
+
// src/graph/index.ts
|
|
2918
|
+
function buildGraph(semaphore) {
|
|
2919
|
+
return new langgraph.StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(langgraph.START, "load").addConditionalEdges(
|
|
2920
|
+
"load",
|
|
2921
|
+
(state) => state.entries.map(
|
|
2922
|
+
(entry) => new langgraph.Send("process", { entry, options: state.options })
|
|
2923
|
+
),
|
|
2924
|
+
["process"]
|
|
2925
|
+
).addEdge("process", "aggregate").addEdge("aggregate", langgraph.END).compile();
|
|
2926
|
+
}
|
|
2927
|
+
async function runGraph(opts) {
|
|
2928
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2929
|
+
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
2930
|
+
const semaphore = new Semaphore(concurrency);
|
|
2931
|
+
const baselinesDir = paths(cwd).baselines;
|
|
2932
|
+
const graph = buildGraph(semaphore);
|
|
2933
|
+
let finalState;
|
|
2934
|
+
try {
|
|
2935
|
+
finalState = await graph.invoke({
|
|
2936
|
+
options: {
|
|
2937
|
+
baseUrl: opts.baseUrl,
|
|
2938
|
+
cwd,
|
|
2939
|
+
threshold: opts.threshold,
|
|
2940
|
+
concurrency,
|
|
2941
|
+
emitDiffPng: opts.emitDiffPng ?? true,
|
|
2942
|
+
judge: opts.judge ?? "none",
|
|
2943
|
+
baselinesDir
|
|
2944
|
+
}
|
|
2945
|
+
});
|
|
2946
|
+
} finally {
|
|
2947
|
+
await closeBrowser();
|
|
2948
|
+
}
|
|
2949
|
+
const report = finalState.report;
|
|
2950
|
+
if (!report) {
|
|
2951
|
+
throw new Error("runGraph: graph completed without producing a report");
|
|
2952
|
+
}
|
|
2953
|
+
if (opts.junitPath) {
|
|
2954
|
+
const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
|
|
2955
|
+
await writeJunit(report, target);
|
|
2956
|
+
}
|
|
2957
|
+
return report;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// src/cli/commands/run.ts
|
|
2961
|
+
function slimResult2(r) {
|
|
2962
|
+
return {
|
|
2963
|
+
id: r.id,
|
|
2964
|
+
url: r.url,
|
|
2965
|
+
status: r.status,
|
|
2966
|
+
verdict: r.verdict ? {
|
|
2967
|
+
label: r.verdict.label,
|
|
2968
|
+
headline: r.verdict.headline,
|
|
2969
|
+
action: r.verdict.action
|
|
2970
|
+
} : void 0
|
|
2971
|
+
};
|
|
2972
|
+
}
|
|
2973
|
+
function slimReport2(report, summaryPath) {
|
|
2974
|
+
return {
|
|
2975
|
+
summaryPath,
|
|
2976
|
+
createdAt: report.createdAt,
|
|
2977
|
+
totalEntries: report.totalEntries,
|
|
2978
|
+
passed: report.passed,
|
|
2979
|
+
failed: report.failed,
|
|
2980
|
+
pendingJudgments: report.pendingJudgments,
|
|
2981
|
+
results: report.results.filter((r) => r.status !== "pass").map(slimResult2)
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
function failureLines2(results) {
|
|
2985
|
+
return results.filter((r) => r.status !== "pass").flatMap((r) => {
|
|
2986
|
+
const lines = [];
|
|
2987
|
+
const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
|
|
2988
|
+
if (r.verdict) {
|
|
2989
|
+
lines.push(
|
|
2990
|
+
` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
|
|
2991
|
+
);
|
|
2992
|
+
lines.push(` \u2192 ${r.verdict.action}`);
|
|
2993
|
+
} else {
|
|
2994
|
+
const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
|
|
2995
|
+
lines.push(` ${prefix} ${r.id}: ${detail}`);
|
|
2996
|
+
}
|
|
2997
|
+
if (r.status === "needs-judgment" && r.message) {
|
|
2998
|
+
lines.push(` ${r.message}`);
|
|
2999
|
+
}
|
|
3000
|
+
if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
|
|
3001
|
+
return lines;
|
|
3002
|
+
});
|
|
3003
|
+
}
|
|
3004
|
+
function parseJudge2(input) {
|
|
3005
|
+
if (input === "host" || input === "none") return input;
|
|
3006
|
+
throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
|
|
3007
|
+
}
|
|
3008
|
+
function parseMode(input) {
|
|
3009
|
+
if (input === "actual") return input;
|
|
3010
|
+
if (input === "baseline") {
|
|
3011
|
+
throw new Error(
|
|
3012
|
+
"`run --mode baseline` is not yet implemented. Use `init` + `capture` for authoring."
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
throw new Error(`unknown --mode: ${input} (expected: actual)`);
|
|
3016
|
+
}
|
|
3017
|
+
function registerRun(program, out) {
|
|
3018
|
+
program.command("run").description(
|
|
3019
|
+
"streaming check pipeline via LangGraph (alternative to `check`)"
|
|
3020
|
+
).option("--mode <mode>", "pipeline mode (actual)", "actual").option("--base-url <url>", "override base URL").option(
|
|
3021
|
+
"--threshold <n>",
|
|
3022
|
+
"color threshold (0-1)",
|
|
3023
|
+
String(DEFAULT_THRESHOLD)
|
|
3024
|
+
).option(
|
|
3025
|
+
"--concurrency <n>",
|
|
3026
|
+
"max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
|
|
3027
|
+
).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
|
|
3028
|
+
"--judge <backend>",
|
|
3029
|
+
"judge backend for ambiguous diffs (host | none)",
|
|
3030
|
+
"none"
|
|
3031
|
+
).action(async (opts) => {
|
|
3032
|
+
parseMode(opts.mode);
|
|
3033
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
3034
|
+
const report = await runGraph({
|
|
3035
|
+
baseUrl,
|
|
3036
|
+
threshold: Number(opts.threshold),
|
|
3037
|
+
concurrency: opts.concurrency ? Number(opts.concurrency) : void 0,
|
|
3038
|
+
emitDiffPng: opts.diffPng,
|
|
3039
|
+
junitPath: opts.junit,
|
|
3040
|
+
judge: parseJudge2(opts.judge)
|
|
3041
|
+
});
|
|
3042
|
+
const summaryPath = paths().summary;
|
|
3043
|
+
const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
|
|
3044
|
+
const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
|
|
3045
|
+
summary: ${summaryPath}` : [
|
|
3046
|
+
`${summary}:`,
|
|
3047
|
+
...failureLines2(report.results),
|
|
3048
|
+
` summary: ${summaryPath}`,
|
|
3049
|
+
report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
|
|
3050
|
+
].filter(Boolean).join("\n");
|
|
3051
|
+
out.emit(slimReport2(report, summaryPath), human);
|
|
3052
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
|
|
3056
|
+
// src/cli/commands/serve-status.ts
|
|
3057
|
+
function registerServeStatus(program, out) {
|
|
3058
|
+
program.command("serve-status").description("start/stop the configured dev server").option("--start", "start (default)").option(
|
|
3059
|
+
"--detach",
|
|
3060
|
+
"alias for --start; waits up to readyTimeoutMs for the port"
|
|
3061
|
+
).option("--kill", "stop the dev server").option("--port <n>", "override port from config").action(async (opts) => {
|
|
3062
|
+
const config = await loadConfig();
|
|
3063
|
+
if (!config) {
|
|
3064
|
+
throw new Error(
|
|
3065
|
+
"no .blazediff/config.json. Run `blazediff-agent init` first."
|
|
3066
|
+
);
|
|
3067
|
+
}
|
|
3068
|
+
if (!config.devServer) {
|
|
3069
|
+
out.emit(
|
|
3070
|
+
{ ok: true, external: true, baseUrl: config.baseUrl },
|
|
3071
|
+
`external base URL (${config.baseUrl}); no dev server managed`
|
|
3072
|
+
);
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
const port = opts.port ? Number(opts.port) : config.devServer.port;
|
|
3076
|
+
if (opts.kill) {
|
|
3077
|
+
const result = await stopServer(process.cwd(), port);
|
|
3078
|
+
const human = result.killed ? `dev server stopped (pid ${result.pid} via ${result.via})` : `no dev server found to stop on :${port}`;
|
|
3079
|
+
out.emit({ ok: true, stopped: result.killed, ...result }, human);
|
|
3080
|
+
return;
|
|
3081
|
+
}
|
|
3082
|
+
if (await isPortOpen(port)) {
|
|
3083
|
+
out.emit(
|
|
3084
|
+
{ ok: true, attached: true, port },
|
|
3085
|
+
`dev server already up on :${port}`
|
|
3086
|
+
);
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
const handle = await startServer({
|
|
3090
|
+
command: config.devServer.command,
|
|
3091
|
+
port,
|
|
3092
|
+
readyTimeoutMs: config.devServer.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS
|
|
3093
|
+
});
|
|
3094
|
+
out.emit(
|
|
3095
|
+
{ ok: true, ...handle },
|
|
3096
|
+
`dev server up on :${handle.port} (pid ${handle.pid})`
|
|
3097
|
+
);
|
|
3098
|
+
});
|
|
3099
|
+
}
|
|
3100
|
+
function extractCwdArg(argv) {
|
|
3101
|
+
for (let i = 0; i < argv.length; i++) {
|
|
3102
|
+
const a = argv[i];
|
|
3103
|
+
if (a === "-C" || a === "--cwd") return argv[i + 1] ?? null;
|
|
3104
|
+
if (a.startsWith("--cwd=")) return a.slice("--cwd=".length);
|
|
3105
|
+
if (a.startsWith("-C=")) return a.slice("-C=".length);
|
|
3106
|
+
}
|
|
3107
|
+
return null;
|
|
3108
|
+
}
|
|
3109
|
+
function applyCwdFromArgv() {
|
|
3110
|
+
const value = extractCwdArg(process.argv.slice(2));
|
|
3111
|
+
if (!value) return;
|
|
3112
|
+
const resolved = path2__default.default.resolve(value);
|
|
3113
|
+
if (fs.existsSync(resolved)) {
|
|
3114
|
+
process.chdir(resolved);
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
const cwdBase = path2__default.default.basename(process.cwd());
|
|
3118
|
+
const inputBase = path2__default.default.basename(value);
|
|
3119
|
+
const doubled = cwdBase && inputBase && cwdBase === inputBase && resolved.endsWith(path2__default.default.join(cwdBase, cwdBase));
|
|
3120
|
+
if (doubled) {
|
|
3121
|
+
throw new Error(
|
|
3122
|
+
`--cwd "${value}" resolves to ${resolved} which does not exist. Looks like you may already be inside that directory - re-run with --cwd as an absolute path, or from the parent.`
|
|
3123
|
+
);
|
|
3124
|
+
}
|
|
3125
|
+
throw new Error(`--cwd path does not exist: ${resolved}`);
|
|
3126
|
+
}
|
|
3127
|
+
function maybeDefaultToCheck() {
|
|
3128
|
+
const positional = process.argv.slice(2).filter((a) => !a.startsWith("-"));
|
|
3129
|
+
if (positional.length) return;
|
|
3130
|
+
if (!fs.existsSync(paths().manifest)) return;
|
|
3131
|
+
process.argv = [
|
|
3132
|
+
process.argv[0],
|
|
3133
|
+
process.argv[1],
|
|
3134
|
+
"check",
|
|
3135
|
+
...process.argv.slice(2)
|
|
3136
|
+
];
|
|
3137
|
+
}
|
|
3138
|
+
|
|
3139
|
+
// src/cli/output.ts
|
|
3140
|
+
function makeOutput(getRootOpts) {
|
|
3141
|
+
const isJson = () => Boolean(getRootOpts().json);
|
|
3142
|
+
const isQuiet = () => Boolean(getRootOpts().quiet);
|
|
3143
|
+
const isTTY = () => Boolean(process.stdout.isTTY);
|
|
3144
|
+
const emit = (payload, human) => {
|
|
3145
|
+
if (isQuiet()) return;
|
|
3146
|
+
if (isJson()) {
|
|
3147
|
+
process.stdout.write(`${JSON.stringify(payload)}
|
|
3148
|
+
`);
|
|
3149
|
+
return;
|
|
3150
|
+
}
|
|
3151
|
+
if (human) process.stdout.write(`${human}
|
|
3152
|
+
`);
|
|
3153
|
+
};
|
|
3154
|
+
return { isJson, isQuiet, isTTY, emit };
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
// src/cli.ts
|
|
3158
|
+
function buildProgram() {
|
|
3159
|
+
const program = new commander.Command().name("blazediff-agent").description("Agentic visual regression for BlazeDiff").version("0.1.2").option("-C, --cwd <path>", "operate on a different directory").option("--json", "emit JSON to stdout where applicable").option("--quiet", "suppress non-error output");
|
|
3160
|
+
const out = makeOutput(() => program.opts());
|
|
3161
|
+
registerOnboard(program, out);
|
|
3162
|
+
registerInit(program, out);
|
|
3163
|
+
registerDiscover(program, out);
|
|
3164
|
+
registerServeStatus(program, out);
|
|
3165
|
+
registerCapture(program, out);
|
|
3166
|
+
registerBrowsers(program, out);
|
|
3167
|
+
registerDiff(program, out);
|
|
3168
|
+
registerManifest(program, out);
|
|
3169
|
+
registerCheck(program, out);
|
|
3170
|
+
registerRun(program, out);
|
|
3171
|
+
registerRewrite(program, out);
|
|
3172
|
+
registerReset(program, out);
|
|
3173
|
+
return program;
|
|
3174
|
+
}
|
|
3175
|
+
async function main() {
|
|
3176
|
+
try {
|
|
3177
|
+
applyCwdFromArgv();
|
|
3178
|
+
} catch (err) {
|
|
3179
|
+
process.stderr.write(`${err.message}
|
|
3180
|
+
`);
|
|
3181
|
+
process.exitCode = 1;
|
|
3182
|
+
return;
|
|
3183
|
+
}
|
|
3184
|
+
maybeDefaultToCheck();
|
|
3185
|
+
const program = buildProgram();
|
|
3186
|
+
try {
|
|
3187
|
+
await program.parseAsync();
|
|
3188
|
+
} catch (err) {
|
|
3189
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3190
|
+
const json = Boolean(program.opts().json);
|
|
3191
|
+
if (json) {
|
|
3192
|
+
process.stdout.write(
|
|
3193
|
+
`${JSON.stringify({ ok: false, error: message })}
|
|
3194
|
+
`
|
|
3195
|
+
);
|
|
3196
|
+
} else {
|
|
3197
|
+
process.stderr.write(`error: ${message}
|
|
3198
|
+
`);
|
|
3199
|
+
}
|
|
3200
|
+
process.exitCode = 1;
|
|
3201
|
+
} finally {
|
|
3202
|
+
await closeBrowser();
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
void main();
|