@blazediff/agent 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -86
- package/SKILL.md +2 -3
- package/dist/cli.js +2281 -1912
- package/dist/index.d.mts +73 -43
- package/dist/index.d.ts +73 -43
- package/dist/index.js +1186 -791
- package/dist/index.mjs +1187 -794
- package/package.json +2 -1
package/dist/index.js
CHANGED
|
@@ -9,8 +9,9 @@ var fs = require('fs');
|
|
|
9
9
|
var module$1 = require('module');
|
|
10
10
|
var crypto$1 = require('crypto');
|
|
11
11
|
var coreNative = require('@blazediff/core-native');
|
|
12
|
-
var sharp = require('sharp');
|
|
13
12
|
var langgraph = require('@langchain/langgraph');
|
|
13
|
+
var sharp = require('sharp');
|
|
14
|
+
var langgraphCheckpoint = require('@langchain/langgraph-checkpoint');
|
|
14
15
|
|
|
15
16
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
16
17
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -43,6 +44,7 @@ var paths = (cwd = process.cwd()) => {
|
|
|
43
44
|
baselines: path__default.default.join(root, "baselines"),
|
|
44
45
|
actual: path__default.default.join(root, "actual"),
|
|
45
46
|
judgments: path__default.default.join(root, "judgments"),
|
|
47
|
+
checkpoints: path__default.default.join(root, "checkpoints"),
|
|
46
48
|
summary: path__default.default.join(root, "summary.md"),
|
|
47
49
|
gitignore: path__default.default.join(root, ".gitignore"),
|
|
48
50
|
serverLog: path__default.default.join(root, "dev-server.log"),
|
|
@@ -73,6 +75,7 @@ var CHROMIUM_FLAGS = [
|
|
|
73
75
|
];
|
|
74
76
|
var cachedBrowser = null;
|
|
75
77
|
var launchInFlight = null;
|
|
78
|
+
var contextPool = /* @__PURE__ */ new Map();
|
|
76
79
|
async function getBrowser() {
|
|
77
80
|
if (cachedBrowser?.isConnected()) return cachedBrowser;
|
|
78
81
|
if (launchInFlight) return launchInFlight;
|
|
@@ -89,15 +92,22 @@ async function closeBrowser() {
|
|
|
89
92
|
await launchInFlight.catch(() => {
|
|
90
93
|
});
|
|
91
94
|
}
|
|
95
|
+
const ctxs = Array.from(contextPool.values()).flat();
|
|
96
|
+
contextPool.clear();
|
|
97
|
+
await Promise.all(ctxs.map((c) => c.close().catch(() => {
|
|
98
|
+
})));
|
|
92
99
|
if (!cachedBrowser) return;
|
|
93
100
|
await cachedBrowser.close().catch(() => {
|
|
94
101
|
});
|
|
95
102
|
cachedBrowser = null;
|
|
96
103
|
}
|
|
97
|
-
|
|
104
|
+
function viewportKey(v) {
|
|
105
|
+
return `${v.width}x${v.height}`;
|
|
106
|
+
}
|
|
107
|
+
async function createStableContext(viewport) {
|
|
98
108
|
const browser = await getBrowser();
|
|
99
109
|
const context = await browser.newContext({
|
|
100
|
-
viewport
|
|
110
|
+
viewport,
|
|
101
111
|
deviceScaleFactor: 1,
|
|
102
112
|
reducedMotion: "reduce",
|
|
103
113
|
forcedColors: "none",
|
|
@@ -142,12 +152,39 @@ async function openStableContext(opts) {
|
|
|
142
152
|
},
|
|
143
153
|
{ frozenNow: FROZEN_NOW }
|
|
144
154
|
);
|
|
145
|
-
|
|
155
|
+
return context;
|
|
156
|
+
}
|
|
157
|
+
async function acquireStableContext(viewport) {
|
|
158
|
+
const key = viewportKey(viewport);
|
|
159
|
+
const pool = contextPool.get(key);
|
|
160
|
+
if (pool && pool.length > 0) {
|
|
161
|
+
const context2 = pool.pop();
|
|
162
|
+
return { context: context2, viewport };
|
|
163
|
+
}
|
|
164
|
+
const context = await createStableContext(viewport);
|
|
165
|
+
return { context, viewport };
|
|
166
|
+
}
|
|
167
|
+
async function releaseStableContext(handle) {
|
|
168
|
+
const key = viewportKey(handle.viewport);
|
|
169
|
+
if (!cachedBrowser?.isConnected()) {
|
|
170
|
+
await handle.context.close().catch(() => {
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
const pool = contextPool.get(key);
|
|
175
|
+
if (pool) {
|
|
176
|
+
pool.push(handle.context);
|
|
177
|
+
} else {
|
|
178
|
+
contextPool.set(key, [handle.context]);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function openStablePage(handle) {
|
|
182
|
+
const page = await handle.context.newPage();
|
|
146
183
|
const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
|
|
147
184
|
});
|
|
148
185
|
await injectStability();
|
|
149
186
|
page.on("load", injectStability);
|
|
150
|
-
return
|
|
187
|
+
return page;
|
|
151
188
|
}
|
|
152
189
|
async function waitForStability(page, waitFor) {
|
|
153
190
|
for (const w of waitFor) {
|
|
@@ -197,7 +234,8 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
|
197
234
|
const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
|
|
198
235
|
const masks = opts.mask ?? [];
|
|
199
236
|
const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
|
|
200
|
-
const
|
|
237
|
+
const handle = await acquireStableContext(viewport);
|
|
238
|
+
const page = await openStablePage(handle);
|
|
201
239
|
try {
|
|
202
240
|
const url = new URL(opts.url, baseUrl).toString();
|
|
203
241
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
@@ -219,8 +257,9 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
|
219
257
|
});
|
|
220
258
|
return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
|
|
221
259
|
} finally {
|
|
222
|
-
await
|
|
260
|
+
await page.close().catch(() => {
|
|
223
261
|
});
|
|
262
|
+
await releaseStableContext(handle);
|
|
224
263
|
}
|
|
225
264
|
}
|
|
226
265
|
function resolvePlaywrightCli() {
|
|
@@ -291,6 +330,30 @@ function resolveBaseUrl(config, override) {
|
|
|
291
330
|
throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
|
|
292
331
|
}
|
|
293
332
|
|
|
333
|
+
// src/graph/semaphore.ts
|
|
334
|
+
var Semaphore = class {
|
|
335
|
+
constructor(limit) {
|
|
336
|
+
this.limit = limit;
|
|
337
|
+
this.current = 0;
|
|
338
|
+
this.queue = [];
|
|
339
|
+
if (limit < 1)
|
|
340
|
+
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
341
|
+
}
|
|
342
|
+
async run(fn) {
|
|
343
|
+
if (this.current >= this.limit) {
|
|
344
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
345
|
+
}
|
|
346
|
+
this.current++;
|
|
347
|
+
try {
|
|
348
|
+
return await fn();
|
|
349
|
+
} finally {
|
|
350
|
+
this.current--;
|
|
351
|
+
const next = this.queue.shift();
|
|
352
|
+
if (next) next();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
294
357
|
// src/types.ts
|
|
295
358
|
var STABILITY_HOOKS_VERSION = 1;
|
|
296
359
|
|
|
@@ -410,55 +473,62 @@ async function runCaptures(opts) {
|
|
|
410
473
|
});
|
|
411
474
|
let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
|
|
412
475
|
let manifestUpdates = 0;
|
|
476
|
+
const slot = new Array(valid.length);
|
|
477
|
+
const semaphore = new Semaphore(opts.concurrency ?? defaultConcurrency());
|
|
413
478
|
try {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
mode,
|
|
434
|
-
ok: true,
|
|
435
|
-
outputPath: shot.outputPath,
|
|
436
|
-
bytes: shot.bytes
|
|
437
|
-
});
|
|
438
|
-
if (manifest && mode === "baseline") {
|
|
439
|
-
manifest = addOrReplaceEntry(
|
|
440
|
-
manifest,
|
|
441
|
-
makeEntry({
|
|
479
|
+
await Promise.all(
|
|
480
|
+
valid.map(
|
|
481
|
+
(r, i) => semaphore.run(async () => {
|
|
482
|
+
const mode = r.mode ?? defaultMode;
|
|
483
|
+
try {
|
|
484
|
+
const shot = await captureScreenshot(
|
|
485
|
+
opts.baseUrl,
|
|
486
|
+
{
|
|
487
|
+
id: r.id,
|
|
488
|
+
url: r.url,
|
|
489
|
+
viewport: r.viewport,
|
|
490
|
+
mask: r.mask,
|
|
491
|
+
waitFor: r.waitFor,
|
|
492
|
+
fullPage: r.fullPage,
|
|
493
|
+
mode
|
|
494
|
+
},
|
|
495
|
+
cwd
|
|
496
|
+
);
|
|
497
|
+
slot[i] = {
|
|
442
498
|
id: r.id,
|
|
443
499
|
url: r.url,
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
500
|
+
mode,
|
|
501
|
+
ok: true,
|
|
502
|
+
outputPath: shot.outputPath,
|
|
503
|
+
bytes: shot.bytes
|
|
504
|
+
};
|
|
505
|
+
if (mode === "baseline" && manifest) {
|
|
506
|
+
manifest = addOrReplaceEntry(
|
|
507
|
+
manifest,
|
|
508
|
+
makeEntry({
|
|
509
|
+
id: r.id,
|
|
510
|
+
url: r.url,
|
|
511
|
+
viewport: r.viewport,
|
|
512
|
+
mask: r.mask,
|
|
513
|
+
waitFor: r.waitFor,
|
|
514
|
+
fullPage: r.fullPage
|
|
515
|
+
})
|
|
516
|
+
);
|
|
517
|
+
manifestUpdates += 1;
|
|
518
|
+
}
|
|
519
|
+
} catch (err) {
|
|
520
|
+
slot[i] = {
|
|
521
|
+
id: r.id,
|
|
522
|
+
url: r.url,
|
|
523
|
+
mode,
|
|
524
|
+
ok: false,
|
|
525
|
+
error: err.message
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
})
|
|
529
|
+
)
|
|
530
|
+
);
|
|
531
|
+
for (const r of slot) results.push(r);
|
|
462
532
|
if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
|
|
463
533
|
} finally {
|
|
464
534
|
await closeBrowser();
|
|
@@ -472,42 +542,20 @@ async function runCaptures(opts) {
|
|
|
472
542
|
results
|
|
473
543
|
};
|
|
474
544
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
"*.tmp"
|
|
482
|
-
];
|
|
483
|
-
var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
|
|
484
|
-
var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
|
|
485
|
-
async function ensureGitignore(cwd) {
|
|
486
|
-
const file = paths(cwd).gitignore;
|
|
487
|
-
await promises.mkdir(path__default.default.dirname(file), { recursive: true });
|
|
488
|
-
const existing = fs.existsSync(file) ? await promises.readFile(file, "utf8") : "";
|
|
489
|
-
const lines = existing.split("\n").map((l) => l.trim());
|
|
490
|
-
const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
|
|
491
|
-
const missing = ENTRIES.filter((e) => !lines.includes(e));
|
|
492
|
-
if (!missing.length && !hasStale && existing) return;
|
|
493
|
-
if (hasStale) {
|
|
494
|
-
const kept = lines.filter(
|
|
495
|
-
(l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
|
|
496
|
-
);
|
|
497
|
-
const body2 = `${kept.length ? `${kept.join("\n")}
|
|
498
|
-
` : HEADER}${ENTRIES.join("\n")}
|
|
499
|
-
`;
|
|
500
|
-
await promises.writeFile(file, body2, "utf8");
|
|
501
|
-
return;
|
|
545
|
+
async function fileExists(p) {
|
|
546
|
+
try {
|
|
547
|
+
await promises.access(p);
|
|
548
|
+
return true;
|
|
549
|
+
} catch {
|
|
550
|
+
return false;
|
|
502
551
|
}
|
|
503
|
-
const body = existing ? `${existing.replace(/\n+$/, "")}
|
|
504
|
-
${missing.join("\n")}
|
|
505
|
-
` : `${HEADER}${ENTRIES.join("\n")}
|
|
506
|
-
`;
|
|
507
|
-
await promises.writeFile(file, body, "utf8");
|
|
508
552
|
}
|
|
509
553
|
async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
|
|
510
|
-
|
|
554
|
+
const [hasBaseline, hasActual] = await Promise.all([
|
|
555
|
+
fileExists(baselinePath),
|
|
556
|
+
fileExists(actualPath)
|
|
557
|
+
]);
|
|
558
|
+
if (!hasBaseline || !hasActual) {
|
|
511
559
|
return {
|
|
512
560
|
id,
|
|
513
561
|
baselinePath,
|
|
@@ -526,7 +574,8 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
|
|
|
526
574
|
const antialiasing = opts.antialiasing ?? true;
|
|
527
575
|
const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
|
|
528
576
|
threshold,
|
|
529
|
-
antialiasing
|
|
577
|
+
antialiasing,
|
|
578
|
+
interpret: true
|
|
530
579
|
});
|
|
531
580
|
if (result.match) return { id, baselinePath, actualPath, match: true };
|
|
532
581
|
if (result.reason === "file-not-exists") {
|
|
@@ -548,10 +597,6 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
|
|
|
548
597
|
reason: "layout-diff"
|
|
549
598
|
};
|
|
550
599
|
}
|
|
551
|
-
const interpretation = await coreNative.interpret(baselinePath, actualPath, {
|
|
552
|
-
threshold,
|
|
553
|
-
antialiasing
|
|
554
|
-
}).catch(() => void 0);
|
|
555
600
|
return {
|
|
556
601
|
id,
|
|
557
602
|
baselinePath,
|
|
@@ -561,167 +606,200 @@ async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.
|
|
|
561
606
|
reason: "pixel-diff",
|
|
562
607
|
diffCount: result.diffCount,
|
|
563
608
|
diffPercentage: result.diffPercentage,
|
|
564
|
-
interpretation
|
|
609
|
+
interpretation: result.interpretation
|
|
565
610
|
};
|
|
566
611
|
}
|
|
567
612
|
|
|
568
|
-
// src/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
return counts;
|
|
583
|
-
}
|
|
584
|
-
function dominantType(counts) {
|
|
585
|
-
let best = "";
|
|
586
|
-
let bestN = 0;
|
|
587
|
-
for (const [type, n] of counts) {
|
|
588
|
-
if (n > bestN) {
|
|
589
|
-
best = type;
|
|
590
|
-
bestN = n;
|
|
613
|
+
// src/discover/crawl.ts
|
|
614
|
+
function extractInternalLinks(base, target, hrefs) {
|
|
615
|
+
const out = [];
|
|
616
|
+
for (const href of hrefs) {
|
|
617
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const u = new URL(href, target);
|
|
622
|
+
if (u.origin !== base.origin) continue;
|
|
623
|
+
const path18 = u.pathname + u.search;
|
|
624
|
+
if (path18.startsWith("/api/")) continue;
|
|
625
|
+
out.push(path18);
|
|
626
|
+
} catch {
|
|
591
627
|
}
|
|
592
628
|
}
|
|
593
|
-
return
|
|
629
|
+
return out;
|
|
594
630
|
}
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
631
|
+
var CRAWL_VIEWPORT = { width: 1024, height: 768 };
|
|
632
|
+
var CRAWL_WORKERS = 4;
|
|
633
|
+
async function crawlRoutes(opts) {
|
|
634
|
+
const maxRoutes = opts.maxRoutes ?? 50;
|
|
635
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
636
|
+
const base = new URL(opts.baseUrl);
|
|
637
|
+
const visited = /* @__PURE__ */ new Set(["/"]);
|
|
638
|
+
const queue = [{ url: "/", depth: 0 }];
|
|
639
|
+
const discovered = [];
|
|
640
|
+
const handle = await acquireStableContext(CRAWL_VIEWPORT);
|
|
641
|
+
const fetchOne = async () => {
|
|
642
|
+
while (queue.length && discovered.length < maxRoutes) {
|
|
643
|
+
const item = queue.shift();
|
|
644
|
+
if (!item) return;
|
|
645
|
+
if (discovered.length >= maxRoutes) return;
|
|
646
|
+
discovered.push({ url: item.url, source: "crawl" });
|
|
647
|
+
if (item.depth >= maxDepth) continue;
|
|
648
|
+
const page = await handle.context.newPage();
|
|
649
|
+
try {
|
|
650
|
+
const target = new URL(item.url, base).toString();
|
|
651
|
+
await page.goto(target, {
|
|
652
|
+
waitUntil: "domcontentloaded",
|
|
653
|
+
timeout: 15e3
|
|
654
|
+
});
|
|
655
|
+
const hrefs = await page.evaluate(
|
|
656
|
+
() => Array.from(
|
|
657
|
+
document.querySelectorAll("a[href]")
|
|
658
|
+
).map((a) => a.getAttribute("href") ?? "")
|
|
659
|
+
);
|
|
660
|
+
for (const p of extractInternalLinks(base, target, hrefs)) {
|
|
661
|
+
if (visited.has(p)) continue;
|
|
662
|
+
visited.add(p);
|
|
663
|
+
queue.push({ url: p, depth: item.depth + 1 });
|
|
664
|
+
}
|
|
665
|
+
} catch {
|
|
666
|
+
} finally {
|
|
667
|
+
await page.close().catch(() => {
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
try {
|
|
673
|
+
await Promise.all(Array.from({ length: CRAWL_WORKERS }, () => fetchOne()));
|
|
674
|
+
} finally {
|
|
675
|
+
await releaseStableContext(handle);
|
|
676
|
+
}
|
|
677
|
+
return discovered.slice(0, maxRoutes);
|
|
600
678
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
679
|
+
var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
|
|
680
|
+
async function readJson(file) {
|
|
681
|
+
if (!fs.existsSync(file)) return null;
|
|
682
|
+
try {
|
|
683
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
684
|
+
} catch {
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
604
687
|
}
|
|
605
|
-
function
|
|
606
|
-
|
|
688
|
+
function isPublicRoute(route) {
|
|
689
|
+
if (DYNAMIC_SEGMENT.test(route)) return false;
|
|
690
|
+
if (route === "/api" || route.startsWith("/api/")) return false;
|
|
691
|
+
return true;
|
|
607
692
|
}
|
|
608
|
-
function
|
|
609
|
-
const
|
|
610
|
-
if (
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
693
|
+
async function discoverFromNextManifest(cwd = process.cwd()) {
|
|
694
|
+
const nextDir = path__default.default.join(cwd, ".next");
|
|
695
|
+
if (!fs.existsSync(nextDir)) return [];
|
|
696
|
+
const seen = /* @__PURE__ */ new Set();
|
|
697
|
+
const out = [];
|
|
698
|
+
const add = (url) => {
|
|
699
|
+
if (seen.has(url)) return;
|
|
700
|
+
seen.add(url);
|
|
701
|
+
out.push({ url, source: "next-manifest" });
|
|
702
|
+
};
|
|
703
|
+
const routes = await readJson(
|
|
704
|
+
path__default.default.join(nextDir, "routes-manifest.json")
|
|
705
|
+
);
|
|
706
|
+
for (const r of routes?.staticRoutes ?? []) {
|
|
707
|
+
if (isPublicRoute(r.page)) add(r.page);
|
|
615
708
|
}
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
const
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
709
|
+
const appPaths = await readJson(
|
|
710
|
+
path__default.default.join(nextDir, "server", "app-paths-manifest.json")
|
|
711
|
+
);
|
|
712
|
+
for (const route of Object.keys(appPaths ?? {})) {
|
|
713
|
+
const normalized = route.replace(/\/page$/, "") || "/";
|
|
714
|
+
if (isPublicRoute(normalized)) add(normalized);
|
|
623
715
|
}
|
|
624
|
-
return
|
|
716
|
+
return out;
|
|
625
717
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
]
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
rationale: ["baseline or actual capture is missing from disk"],
|
|
645
|
-
action: "investigate"
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
if (!interpretation || interpretation.regions.length === 0) {
|
|
649
|
-
return {
|
|
650
|
-
label: "ambiguous",
|
|
651
|
-
headline,
|
|
652
|
-
rationale: ["pixels differ but interpret returned no regions"],
|
|
653
|
-
action: "investigate"
|
|
654
|
-
};
|
|
718
|
+
|
|
719
|
+
// src/discover/sitemap.ts
|
|
720
|
+
var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
|
|
721
|
+
var LOC_RE = /<loc>([^<]+)<\/loc>/g;
|
|
722
|
+
async function discoverFromSitemap(baseUrl) {
|
|
723
|
+
for (const candidate of CANDIDATES) {
|
|
724
|
+
try {
|
|
725
|
+
const res = await fetch(new URL(candidate, baseUrl));
|
|
726
|
+
if (!res.ok) continue;
|
|
727
|
+
const text = await res.text();
|
|
728
|
+
const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
|
|
729
|
+
if (!urls.length) continue;
|
|
730
|
+
return urls.map((u) => {
|
|
731
|
+
const url = new URL(u);
|
|
732
|
+
return { url: url.pathname + url.search, source: "sitemap" };
|
|
733
|
+
});
|
|
734
|
+
} catch {
|
|
735
|
+
}
|
|
655
736
|
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
const
|
|
662
|
-
const
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
};
|
|
671
|
-
}
|
|
672
|
-
if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
|
|
673
|
-
return {
|
|
674
|
-
label: "noise-likely",
|
|
675
|
-
headline,
|
|
676
|
-
rationale: [
|
|
677
|
-
`delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
|
|
678
|
-
"sub-perceptual change - review optional"
|
|
679
|
-
],
|
|
680
|
-
action: "ignore-or-rewrite"
|
|
681
|
-
};
|
|
682
|
-
}
|
|
683
|
-
if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
|
|
684
|
-
const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
685
|
-
return {
|
|
686
|
-
label: "regression-likely",
|
|
687
|
-
headline,
|
|
688
|
-
rationale: [
|
|
689
|
-
`severity ${severity} with structural changes (${types})`,
|
|
690
|
-
"likely affects content or layout, not just styling"
|
|
691
|
-
],
|
|
692
|
-
action: "investigate"
|
|
693
|
-
};
|
|
737
|
+
return [];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// src/discover/index.ts
|
|
741
|
+
function normalizePath(url) {
|
|
742
|
+
const [pathPart, query = ""] = url.split("?", 2);
|
|
743
|
+
const trimmed = pathPart.replace(/\/+$/, "");
|
|
744
|
+
const normalizedPath = trimmed === "" ? "/" : trimmed;
|
|
745
|
+
return query ? `${normalizedPath}?${query}` : normalizedPath;
|
|
746
|
+
}
|
|
747
|
+
function mergeBy(routes, into) {
|
|
748
|
+
for (const r of routes) {
|
|
749
|
+
const key = normalizePath(r.url);
|
|
750
|
+
if (!into.has(key)) into.set(key, { ...r, url: key });
|
|
694
751
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
752
|
+
}
|
|
753
|
+
async function discover(opts) {
|
|
754
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
755
|
+
const merged = /* @__PURE__ */ new Map();
|
|
756
|
+
mergeBy(await discoverFromNextManifest(cwd), merged);
|
|
757
|
+
mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
|
|
758
|
+
if (!opts.skipCrawl) {
|
|
759
|
+
const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
|
|
760
|
+
if (crawlMax > 0) {
|
|
761
|
+
mergeBy(
|
|
762
|
+
await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
|
|
763
|
+
merged
|
|
764
|
+
);
|
|
765
|
+
}
|
|
705
766
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
767
|
+
return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
|
|
768
|
+
}
|
|
769
|
+
var ENTRIES = [
|
|
770
|
+
"actual/",
|
|
771
|
+
"judgments/",
|
|
772
|
+
"checkpoints/",
|
|
773
|
+
"summary.md",
|
|
774
|
+
"dev-server.log",
|
|
775
|
+
"dev-server.pid",
|
|
776
|
+
"*.tmp"
|
|
777
|
+
];
|
|
778
|
+
var STALE_ENTRIES = /* @__PURE__ */ new Set(["diffs/", "pending-judgments/", "report.json"]);
|
|
779
|
+
var HEADER = "# blazediff: generated artifacts (committed: config.json, manifest.json, baselines/)\n";
|
|
780
|
+
async function ensureGitignore(cwd) {
|
|
781
|
+
const file = paths(cwd).gitignore;
|
|
782
|
+
await promises.mkdir(path__default.default.dirname(file), { recursive: true });
|
|
783
|
+
const existing = fs.existsSync(file) ? await promises.readFile(file, "utf8") : "";
|
|
784
|
+
const lines = existing.split("\n").map((l) => l.trim());
|
|
785
|
+
const hasStale = lines.some((l) => STALE_ENTRIES.has(l));
|
|
786
|
+
const missing = ENTRIES.filter((e) => !lines.includes(e));
|
|
787
|
+
if (!missing.length && !hasStale && existing) return;
|
|
788
|
+
if (hasStale) {
|
|
789
|
+
const kept = lines.filter(
|
|
790
|
+
(l) => !STALE_ENTRIES.has(l) && !ENTRIES.includes(l) && l !== ""
|
|
791
|
+
);
|
|
792
|
+
const body2 = `${kept.length ? `${kept.join("\n")}
|
|
793
|
+
` : HEADER}${ENTRIES.join("\n")}
|
|
794
|
+
`;
|
|
795
|
+
await promises.writeFile(file, body2, "utf8");
|
|
796
|
+
return;
|
|
715
797
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
`${pctText(pct)} of image differs`
|
|
722
|
-
],
|
|
723
|
-
action: "investigate"
|
|
724
|
-
};
|
|
798
|
+
const body = existing ? `${existing.replace(/\n+$/, "")}
|
|
799
|
+
${missing.join("\n")}
|
|
800
|
+
` : `${HEADER}${ENTRIES.join("\n")}
|
|
801
|
+
`;
|
|
802
|
+
await promises.writeFile(file, body, "utf8");
|
|
725
803
|
}
|
|
726
804
|
var DEFAULT_TOP_N = 5;
|
|
727
805
|
var DEFAULT_PADDING = 16;
|
|
@@ -863,6 +941,283 @@ var noneJudge = {
|
|
|
863
941
|
return { kind: "judged", verdict: input.heuristicVerdict };
|
|
864
942
|
}
|
|
865
943
|
};
|
|
944
|
+
var ROOT_NS_SENTINEL = "_root";
|
|
945
|
+
function nsDir(ns) {
|
|
946
|
+
return ns === "" ? ROOT_NS_SENTINEL : ns;
|
|
947
|
+
}
|
|
948
|
+
function encode(buf) {
|
|
949
|
+
return Buffer.from(buf).toString("base64");
|
|
950
|
+
}
|
|
951
|
+
function decode(s) {
|
|
952
|
+
return Buffer.from(s, "base64");
|
|
953
|
+
}
|
|
954
|
+
async function readJson2(file) {
|
|
955
|
+
try {
|
|
956
|
+
const raw = await promises.readFile(file, "utf8");
|
|
957
|
+
return JSON.parse(raw);
|
|
958
|
+
} catch (err) {
|
|
959
|
+
if (err.code === "ENOENT") return void 0;
|
|
960
|
+
throw err;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
async function writeJsonAtomic(file, value) {
|
|
964
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
965
|
+
await promises.writeFile(tmp, JSON.stringify(value), "utf8");
|
|
966
|
+
await promises.rename(tmp, file);
|
|
967
|
+
}
|
|
968
|
+
var FsCheckpointSaver = class extends langgraphCheckpoint.BaseCheckpointSaver {
|
|
969
|
+
constructor(root, serde) {
|
|
970
|
+
super(serde);
|
|
971
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
972
|
+
this.root = root;
|
|
973
|
+
}
|
|
974
|
+
threadDir(thread) {
|
|
975
|
+
return path__default.default.join(this.root, thread);
|
|
976
|
+
}
|
|
977
|
+
nsPath(thread, ns) {
|
|
978
|
+
return path__default.default.join(this.threadDir(thread), nsDir(ns));
|
|
979
|
+
}
|
|
980
|
+
ckptFile(thread, ns, id) {
|
|
981
|
+
return path__default.default.join(this.nsPath(thread, ns), `${id}.ckpt.json`);
|
|
982
|
+
}
|
|
983
|
+
writesFile(thread, ns, id) {
|
|
984
|
+
return path__default.default.join(this.nsPath(thread, ns), `${id}.writes.json`);
|
|
985
|
+
}
|
|
986
|
+
async withLock(key, fn) {
|
|
987
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
988
|
+
const next = prev.then(fn, fn);
|
|
989
|
+
this.locks.set(key, next);
|
|
990
|
+
try {
|
|
991
|
+
return await next;
|
|
992
|
+
} finally {
|
|
993
|
+
if (this.locks.get(key) === next) this.locks.delete(key);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async listCheckpointIds(thread, ns) {
|
|
997
|
+
try {
|
|
998
|
+
const names = await promises.readdir(this.nsPath(thread, ns));
|
|
999
|
+
const suffix = ".ckpt.json";
|
|
1000
|
+
return names.filter((n) => n.endsWith(suffix)).map((n) => n.slice(0, -suffix.length));
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
if (err.code === "ENOENT") return [];
|
|
1003
|
+
throw err;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
async listNamespaces(thread) {
|
|
1007
|
+
try {
|
|
1008
|
+
return await promises.readdir(this.threadDir(thread));
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
if (err.code === "ENOENT") return [];
|
|
1011
|
+
throw err;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
async listThreads() {
|
|
1015
|
+
try {
|
|
1016
|
+
return await promises.readdir(this.root);
|
|
1017
|
+
} catch (err) {
|
|
1018
|
+
if (err.code === "ENOENT") return [];
|
|
1019
|
+
throw err;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
decodeNs(nsDirName) {
|
|
1023
|
+
return nsDirName === ROOT_NS_SENTINEL ? "" : nsDirName;
|
|
1024
|
+
}
|
|
1025
|
+
async loadPendingWrites(thread, ns, ckptId) {
|
|
1026
|
+
const data = await readJson2(
|
|
1027
|
+
this.writesFile(thread, ns, ckptId)
|
|
1028
|
+
);
|
|
1029
|
+
if (!data) return [];
|
|
1030
|
+
const out = [];
|
|
1031
|
+
for (const [taskId, channel, serialized] of Object.values(data)) {
|
|
1032
|
+
const value = await this.serde.loadsTyped("json", decode(serialized));
|
|
1033
|
+
out.push([taskId, channel, value]);
|
|
1034
|
+
}
|
|
1035
|
+
return out;
|
|
1036
|
+
}
|
|
1037
|
+
async migratePendingSends(checkpoint, thread, ns, parentCheckpointId) {
|
|
1038
|
+
const data = await readJson2(
|
|
1039
|
+
this.writesFile(thread, ns, parentCheckpointId)
|
|
1040
|
+
);
|
|
1041
|
+
const pendingSends = data ? await Promise.all(
|
|
1042
|
+
Object.values(data).filter(([, channel]) => channel === langgraphCheckpoint.TASKS).map(
|
|
1043
|
+
([, , serialized]) => this.serde.loadsTyped("json", decode(serialized))
|
|
1044
|
+
)
|
|
1045
|
+
) : [];
|
|
1046
|
+
const m = checkpoint;
|
|
1047
|
+
m.channel_values ?? (m.channel_values = {});
|
|
1048
|
+
m.channel_values[langgraphCheckpoint.TASKS] = pendingSends;
|
|
1049
|
+
m.channel_versions ?? (m.channel_versions = {});
|
|
1050
|
+
const versions = Object.values(m.channel_versions);
|
|
1051
|
+
m.channel_versions[langgraphCheckpoint.TASKS] = versions.length > 0 ? langgraphCheckpoint.maxChannelVersion(...versions) : this.getNextVersion(void 0);
|
|
1052
|
+
}
|
|
1053
|
+
async readTuple(thread, ns, ckptId, config) {
|
|
1054
|
+
const data = await readJson2(this.ckptFile(thread, ns, ckptId));
|
|
1055
|
+
if (!data) return void 0;
|
|
1056
|
+
const checkpoint = await this.serde.loadsTyped(
|
|
1057
|
+
"json",
|
|
1058
|
+
decode(data.checkpoint)
|
|
1059
|
+
);
|
|
1060
|
+
const metadata = await this.serde.loadsTyped(
|
|
1061
|
+
"json",
|
|
1062
|
+
decode(data.metadata)
|
|
1063
|
+
);
|
|
1064
|
+
if (checkpoint.v < 4 && data.parentCheckpointId !== void 0) {
|
|
1065
|
+
await this.migratePendingSends(
|
|
1066
|
+
checkpoint,
|
|
1067
|
+
thread,
|
|
1068
|
+
ns,
|
|
1069
|
+
data.parentCheckpointId
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
const pendingWrites = await this.loadPendingWrites(thread, ns, ckptId);
|
|
1073
|
+
const tuple = {
|
|
1074
|
+
config,
|
|
1075
|
+
checkpoint,
|
|
1076
|
+
metadata,
|
|
1077
|
+
pendingWrites
|
|
1078
|
+
};
|
|
1079
|
+
if (data.parentCheckpointId !== void 0) {
|
|
1080
|
+
tuple.parentConfig = {
|
|
1081
|
+
configurable: {
|
|
1082
|
+
thread_id: thread,
|
|
1083
|
+
checkpoint_ns: ns,
|
|
1084
|
+
checkpoint_id: data.parentCheckpointId
|
|
1085
|
+
}
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
return tuple;
|
|
1089
|
+
}
|
|
1090
|
+
async getTuple(config) {
|
|
1091
|
+
const thread = config.configurable?.thread_id;
|
|
1092
|
+
if (!thread) return void 0;
|
|
1093
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1094
|
+
const explicitId = langgraphCheckpoint.getCheckpointId(config);
|
|
1095
|
+
if (explicitId) {
|
|
1096
|
+
return this.readTuple(thread, ns, explicitId, config);
|
|
1097
|
+
}
|
|
1098
|
+
const ids = (await this.listCheckpointIds(thread, ns)).sort(
|
|
1099
|
+
(a, b) => b.localeCompare(a)
|
|
1100
|
+
);
|
|
1101
|
+
if (ids.length === 0) return void 0;
|
|
1102
|
+
const ckptId = ids[0];
|
|
1103
|
+
return this.readTuple(thread, ns, ckptId, {
|
|
1104
|
+
configurable: {
|
|
1105
|
+
thread_id: thread,
|
|
1106
|
+
checkpoint_ns: ns,
|
|
1107
|
+
checkpoint_id: ckptId
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
async *list(config, options) {
|
|
1112
|
+
const filter = options?.filter;
|
|
1113
|
+
const before = options?.before;
|
|
1114
|
+
let limit = options?.limit;
|
|
1115
|
+
const configThread = config.configurable?.thread_id;
|
|
1116
|
+
const configNs = config.configurable?.checkpoint_ns;
|
|
1117
|
+
const configCkptId = config.configurable?.checkpoint_id;
|
|
1118
|
+
const threads = configThread ? [configThread] : await this.listThreads();
|
|
1119
|
+
for (const thread of threads) {
|
|
1120
|
+
const nsNames = await this.listNamespaces(thread);
|
|
1121
|
+
for (const nsName of nsNames) {
|
|
1122
|
+
const ns = this.decodeNs(nsName);
|
|
1123
|
+
if (configNs !== void 0 && ns !== configNs) continue;
|
|
1124
|
+
const ids = (await this.listCheckpointIds(thread, ns)).sort(
|
|
1125
|
+
(a, b) => b.localeCompare(a)
|
|
1126
|
+
);
|
|
1127
|
+
for (const ckptId of ids) {
|
|
1128
|
+
if (configCkptId && ckptId !== configCkptId) continue;
|
|
1129
|
+
if (before?.configurable?.checkpoint_id && ckptId >= before.configurable.checkpoint_id)
|
|
1130
|
+
continue;
|
|
1131
|
+
const tuple = await this.readTuple(thread, ns, ckptId, {
|
|
1132
|
+
configurable: {
|
|
1133
|
+
thread_id: thread,
|
|
1134
|
+
checkpoint_ns: ns,
|
|
1135
|
+
checkpoint_id: ckptId
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
if (!tuple) continue;
|
|
1139
|
+
if (filter) {
|
|
1140
|
+
const md = tuple.metadata;
|
|
1141
|
+
const matches = Object.entries(filter).every(
|
|
1142
|
+
([k, v]) => md?.[k] === v
|
|
1143
|
+
);
|
|
1144
|
+
if (!matches) continue;
|
|
1145
|
+
}
|
|
1146
|
+
if (limit !== void 0) {
|
|
1147
|
+
if (limit <= 0) return;
|
|
1148
|
+
limit -= 1;
|
|
1149
|
+
}
|
|
1150
|
+
yield tuple;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
async put(config, checkpoint, metadata) {
|
|
1156
|
+
const thread = config.configurable?.thread_id;
|
|
1157
|
+
if (!thread) {
|
|
1158
|
+
throw new Error(
|
|
1159
|
+
'FsCheckpointSaver: missing "thread_id" in configurable. Pass `{ configurable: { thread_id } }` when streaming.'
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1163
|
+
const parentCheckpointId = config.configurable?.checkpoint_id;
|
|
1164
|
+
const prepared = langgraphCheckpoint.copyCheckpoint(checkpoint);
|
|
1165
|
+
const [[, serializedCheckpoint], [, serializedMetadata]] = await Promise.all([
|
|
1166
|
+
this.serde.dumpsTyped(prepared),
|
|
1167
|
+
this.serde.dumpsTyped(metadata)
|
|
1168
|
+
]);
|
|
1169
|
+
await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
|
|
1170
|
+
const file = this.ckptFile(thread, ns, checkpoint.id);
|
|
1171
|
+
const body = {
|
|
1172
|
+
checkpoint: encode(serializedCheckpoint),
|
|
1173
|
+
metadata: encode(serializedMetadata),
|
|
1174
|
+
parentCheckpointId
|
|
1175
|
+
};
|
|
1176
|
+
await writeJsonAtomic(file, body);
|
|
1177
|
+
return {
|
|
1178
|
+
configurable: {
|
|
1179
|
+
thread_id: thread,
|
|
1180
|
+
checkpoint_ns: ns,
|
|
1181
|
+
checkpoint_id: checkpoint.id
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
async putWrites(config, writes, taskId) {
|
|
1186
|
+
const thread = config.configurable?.thread_id;
|
|
1187
|
+
if (!thread) {
|
|
1188
|
+
throw new Error(
|
|
1189
|
+
'FsCheckpointSaver: missing "thread_id" in configurable for putWrites.'
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1193
|
+
const ckptId = config.configurable?.checkpoint_id;
|
|
1194
|
+
if (!ckptId) {
|
|
1195
|
+
throw new Error(
|
|
1196
|
+
'FsCheckpointSaver: missing "checkpoint_id" in configurable for putWrites.'
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
const key = `${thread}|${ns}|${ckptId}`;
|
|
1200
|
+
await this.withLock(key, async () => {
|
|
1201
|
+
const existing = await readJson2(this.writesFile(thread, ns, ckptId)) ?? {};
|
|
1202
|
+
let mutated = false;
|
|
1203
|
+
for (let idx = 0; idx < writes.length; idx++) {
|
|
1204
|
+
const [channel, value] = writes[idx];
|
|
1205
|
+
const writeIdx = langgraphCheckpoint.WRITES_IDX_MAP[channel] ?? idx;
|
|
1206
|
+
const innerKey = `${taskId},${writeIdx}`;
|
|
1207
|
+
if (writeIdx >= 0 && innerKey in existing) continue;
|
|
1208
|
+
const [, serialized] = await this.serde.dumpsTyped(value);
|
|
1209
|
+
existing[innerKey] = [taskId, channel, encode(serialized)];
|
|
1210
|
+
mutated = true;
|
|
1211
|
+
}
|
|
1212
|
+
if (!mutated) return;
|
|
1213
|
+
await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
|
|
1214
|
+
await writeJsonAtomic(this.writesFile(thread, ns, ckptId), existing);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
async deleteThread(threadId) {
|
|
1218
|
+
await promises.rm(this.threadDir(threadId), { recursive: true, force: true });
|
|
1219
|
+
}
|
|
1220
|
+
};
|
|
866
1221
|
var PREVIEW_WIDTH = 320;
|
|
867
1222
|
function escapeCell(s) {
|
|
868
1223
|
return s.replace(/\n/g, " ");
|
|
@@ -975,6 +1330,14 @@ function parseVerdict(raw) {
|
|
|
975
1330
|
confidence: typeof r.confidence === "number" ? r.confidence : void 0
|
|
976
1331
|
};
|
|
977
1332
|
}
|
|
1333
|
+
async function fileExists2(p) {
|
|
1334
|
+
try {
|
|
1335
|
+
await promises.access(p);
|
|
1336
|
+
return true;
|
|
1337
|
+
} catch {
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
978
1341
|
async function readJsonOrNull(file) {
|
|
979
1342
|
try {
|
|
980
1343
|
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
@@ -1005,7 +1368,7 @@ async function readJudgmentDirs(root) {
|
|
|
1005
1368
|
const verdictFile = path__default.default.join(dir, "verdict.json");
|
|
1006
1369
|
let verdict = null;
|
|
1007
1370
|
let verdictInvalid = false;
|
|
1008
|
-
if (
|
|
1371
|
+
if (await fileExists2(verdictFile)) {
|
|
1009
1372
|
const raw = await readJsonOrNull(verdictFile);
|
|
1010
1373
|
verdict = raw ? parseVerdict(raw) : null;
|
|
1011
1374
|
if (raw && !verdict) verdictInvalid = true;
|
|
@@ -1018,7 +1381,7 @@ function toAbs(cwd, rel) {
|
|
|
1018
1381
|
if (!rel) return void 0;
|
|
1019
1382
|
return path__default.default.isAbsolute(rel) ? rel : path__default.default.join(cwd, rel);
|
|
1020
1383
|
}
|
|
1021
|
-
function
|
|
1384
|
+
function fromDiskResult(cwd, dir, entry) {
|
|
1022
1385
|
const req = dir.request;
|
|
1023
1386
|
const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
|
|
1024
1387
|
const status = req ? dir.verdict ? "fail" : req.status : "fail";
|
|
@@ -1037,7 +1400,7 @@ function buildResult(cwd, dir, entry) {
|
|
|
1037
1400
|
message
|
|
1038
1401
|
};
|
|
1039
1402
|
}
|
|
1040
|
-
function
|
|
1403
|
+
async function passResultFromDisk(entry, cwd) {
|
|
1041
1404
|
const baselineAbs = path__default.default.join(paths(cwd).baselines, `${entry.id}.png`);
|
|
1042
1405
|
const actualAbs = path__default.default.join(paths(cwd).actual, `${entry.id}.png`);
|
|
1043
1406
|
return {
|
|
@@ -1045,37 +1408,27 @@ function passResult(entry, cwd) {
|
|
|
1045
1408
|
url: entry.url,
|
|
1046
1409
|
status: "pass",
|
|
1047
1410
|
baselinePath: baselineAbs,
|
|
1048
|
-
actualPath:
|
|
1411
|
+
actualPath: await fileExists2(actualAbs) ? actualAbs : void 0
|
|
1049
1412
|
};
|
|
1050
1413
|
}
|
|
1051
|
-
async function
|
|
1052
|
-
const p = paths(cwd);
|
|
1414
|
+
async function reconstructFromDisk(cwd, dirs) {
|
|
1053
1415
|
const manifest = await loadManifest(cwd);
|
|
1054
1416
|
if (!manifest) {
|
|
1055
1417
|
throw new Error(
|
|
1056
|
-
`no manifest at ${
|
|
1418
|
+
`no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
|
|
1057
1419
|
);
|
|
1058
1420
|
}
|
|
1059
|
-
const dirs = await readJudgmentDirs(p.judgments);
|
|
1060
1421
|
const dirById = new Map(dirs.map((d) => [d.id, d]));
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
const entry = manifest.entries.find((e) => e.id === d.id);
|
|
1072
|
-
nonPassResults.push(buildResult(cwd, d, entry));
|
|
1073
|
-
}
|
|
1074
|
-
const passResults = [];
|
|
1075
|
-
for (const entry of manifest.entries) {
|
|
1076
|
-
if (dirById.has(entry.id)) continue;
|
|
1077
|
-
passResults.push(passResult(entry, cwd));
|
|
1078
|
-
}
|
|
1422
|
+
const nonPassResults = dirs.map(
|
|
1423
|
+
(d) => fromDiskResult(
|
|
1424
|
+
cwd,
|
|
1425
|
+
d,
|
|
1426
|
+
manifest.entries.find((e) => e.id === d.id)
|
|
1427
|
+
)
|
|
1428
|
+
);
|
|
1429
|
+
const passResults = await Promise.all(
|
|
1430
|
+
manifest.entries.filter((entry) => !dirById.has(entry.id)).map((entry) => passResultFromDisk(entry, cwd))
|
|
1431
|
+
);
|
|
1079
1432
|
const results = [...passResults, ...nonPassResults];
|
|
1080
1433
|
const passed = results.filter((r) => r.status === "pass").length;
|
|
1081
1434
|
const pendingJudgments = results.filter(
|
|
@@ -1090,6 +1443,57 @@ async function applyJudgments(cwd = process.cwd()) {
|
|
|
1090
1443
|
results
|
|
1091
1444
|
};
|
|
1092
1445
|
await writeSummaryMarkdown(report, cwd);
|
|
1446
|
+
return report;
|
|
1447
|
+
}
|
|
1448
|
+
async function hasCheckpoint(cwd, threadId) {
|
|
1449
|
+
const dir = path__default.default.join(paths(cwd).checkpoints, threadId);
|
|
1450
|
+
try {
|
|
1451
|
+
const names = await promises.readdir(dir);
|
|
1452
|
+
return names.length > 0;
|
|
1453
|
+
} catch {
|
|
1454
|
+
return false;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
async function applyJudgments(opts = process.cwd()) {
|
|
1458
|
+
const options = typeof opts === "string" ? { cwd: opts } : opts;
|
|
1459
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1460
|
+
const manifest = await loadManifest(cwd);
|
|
1461
|
+
if (!manifest) {
|
|
1462
|
+
throw new Error(
|
|
1463
|
+
`no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
const dirs = await readJudgmentDirs(paths(cwd).judgments);
|
|
1467
|
+
const applied = [];
|
|
1468
|
+
const missing = [];
|
|
1469
|
+
const invalid = [];
|
|
1470
|
+
const verdicts = {};
|
|
1471
|
+
for (const d of dirs) {
|
|
1472
|
+
if (d.verdictInvalid) {
|
|
1473
|
+
invalid.push(path__default.default.join(paths(cwd).judgments, d.id));
|
|
1474
|
+
continue;
|
|
1475
|
+
}
|
|
1476
|
+
if (d.verdict) {
|
|
1477
|
+
verdicts[d.id] = d.verdict.verdict;
|
|
1478
|
+
applied.push(d.id);
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
missing.push(d.id);
|
|
1482
|
+
}
|
|
1483
|
+
const threadId = threadIdFor(cwd);
|
|
1484
|
+
const checkpointExists = await hasCheckpoint(cwd, threadId);
|
|
1485
|
+
if (!checkpointExists) {
|
|
1486
|
+
const report2 = await reconstructFromDisk(cwd, dirs);
|
|
1487
|
+
return { report: report2, applied, missing, invalid };
|
|
1488
|
+
}
|
|
1489
|
+
const report = await resumeGraph({
|
|
1490
|
+
cwd,
|
|
1491
|
+
verdicts,
|
|
1492
|
+
threadId,
|
|
1493
|
+
onEvent: options.onEvent,
|
|
1494
|
+
junitPath: options.junitPath
|
|
1495
|
+
});
|
|
1496
|
+
await new FsCheckpointSaver(paths(cwd).checkpoints).deleteThread(threadId).catch(() => void 0);
|
|
1093
1497
|
return { report, applied, missing, invalid };
|
|
1094
1498
|
}
|
|
1095
1499
|
var HOST_INSTRUCTIONS = [
|
|
@@ -1111,6 +1515,14 @@ function signatureOf(r) {
|
|
|
1111
1515
|
const severity = r.severity ?? "?";
|
|
1112
1516
|
return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
|
|
1113
1517
|
}
|
|
1518
|
+
async function fileExists3(p) {
|
|
1519
|
+
try {
|
|
1520
|
+
await promises.access(p);
|
|
1521
|
+
return true;
|
|
1522
|
+
} catch {
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1114
1526
|
async function readJsonOrNull2(file) {
|
|
1115
1527
|
try {
|
|
1116
1528
|
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
@@ -1164,9 +1576,13 @@ function autoVerdict(result) {
|
|
|
1164
1576
|
async function discoverTiles(dir) {
|
|
1165
1577
|
const locatorAbs = path__default.default.join(dir, "locator.png");
|
|
1166
1578
|
const tilesAbs = path__default.default.join(dir, "regions.png");
|
|
1579
|
+
const [locator, tiles] = await Promise.all([
|
|
1580
|
+
fileExists3(locatorAbs),
|
|
1581
|
+
fileExists3(tilesAbs)
|
|
1582
|
+
]);
|
|
1167
1583
|
return {
|
|
1168
|
-
locatorPath:
|
|
1169
|
-
tilesPath:
|
|
1584
|
+
locatorPath: locator ? "locator.png" : void 0,
|
|
1585
|
+
tilesPath: tiles ? "regions.png" : void 0
|
|
1170
1586
|
};
|
|
1171
1587
|
}
|
|
1172
1588
|
async function writeJudgments(opts) {
|
|
@@ -1175,53 +1591,61 @@ async function writeJudgments(opts) {
|
|
|
1175
1591
|
await promises.mkdir(root, { recursive: true });
|
|
1176
1592
|
const knownIds = /* @__PURE__ */ new Set();
|
|
1177
1593
|
for (const r of opts.report.results) knownIds.add(r.id);
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
if (
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
if (priorVerdict && signatureMatches) {
|
|
1201
|
-
continue;
|
|
1202
|
-
}
|
|
1203
|
-
const auto = autoVerdict(result);
|
|
1204
|
-
if (auto) {
|
|
1594
|
+
await Promise.all(
|
|
1595
|
+
opts.report.results.map(async (result) => {
|
|
1596
|
+
const dir = path__default.default.join(root, result.id);
|
|
1597
|
+
if (result.status === "pass") {
|
|
1598
|
+
if (await fileExists3(dir))
|
|
1599
|
+
await promises.rm(dir, { recursive: true, force: true });
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const entry = entryById(opts.manifest, result.id);
|
|
1603
|
+
if (!entry) return;
|
|
1604
|
+
await promises.mkdir(dir, { recursive: true });
|
|
1605
|
+
const tiles = await discoverTiles(dir);
|
|
1606
|
+
const request = buildRequest(result, entry, cwd, tiles);
|
|
1607
|
+
const requestFile = path__default.default.join(dir, "request.json");
|
|
1608
|
+
const verdictFile = path__default.default.join(dir, "verdict.json");
|
|
1609
|
+
const [prior, priorVerdict] = await Promise.all([
|
|
1610
|
+
readJsonOrNull2(requestFile),
|
|
1611
|
+
fileExists3(verdictFile).then(
|
|
1612
|
+
(exists) => exists ? readJsonOrNull2(verdictFile) : null
|
|
1613
|
+
)
|
|
1614
|
+
]);
|
|
1615
|
+
const signatureMatches = prior !== null && prior.signature === request.signature;
|
|
1205
1616
|
await promises.writeFile(
|
|
1206
|
-
|
|
1207
|
-
`${JSON.stringify(
|
|
1617
|
+
requestFile,
|
|
1618
|
+
`${JSON.stringify(request, null, 2)}
|
|
1208
1619
|
`,
|
|
1209
1620
|
"utf8"
|
|
1210
1621
|
);
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1622
|
+
if (priorVerdict && signatureMatches) {
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const auto = autoVerdict(result);
|
|
1626
|
+
if (auto) {
|
|
1627
|
+
await promises.writeFile(
|
|
1628
|
+
verdictFile,
|
|
1629
|
+
`${JSON.stringify(auto, null, 2)}
|
|
1630
|
+
`,
|
|
1631
|
+
"utf8"
|
|
1632
|
+
);
|
|
1633
|
+
} else if (priorVerdict && !signatureMatches) {
|
|
1634
|
+
await promises.rm(verdictFile, { force: true });
|
|
1635
|
+
}
|
|
1636
|
+
})
|
|
1637
|
+
);
|
|
1215
1638
|
let entries;
|
|
1216
1639
|
try {
|
|
1217
1640
|
entries = await promises.readdir(root);
|
|
1218
1641
|
} catch {
|
|
1219
1642
|
return;
|
|
1220
1643
|
}
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1644
|
+
await Promise.all(
|
|
1645
|
+
entries.filter((name) => !knownIds.has(name)).map(
|
|
1646
|
+
(name) => promises.rm(path__default.default.join(root, name), { recursive: true, force: true })
|
|
1647
|
+
)
|
|
1648
|
+
);
|
|
1225
1649
|
}
|
|
1226
1650
|
|
|
1227
1651
|
// src/judge/index.ts
|
|
@@ -1259,7 +1683,7 @@ ${cases.join("\n")}
|
|
|
1259
1683
|
return destPath;
|
|
1260
1684
|
}
|
|
1261
1685
|
|
|
1262
|
-
// src/
|
|
1686
|
+
// src/graph/nodes/results.ts
|
|
1263
1687
|
function narrowRegion(r) {
|
|
1264
1688
|
return {
|
|
1265
1689
|
bbox: r.bbox,
|
|
@@ -1269,38 +1693,24 @@ function narrowRegion(r) {
|
|
|
1269
1693
|
confidence: r.confidence
|
|
1270
1694
|
};
|
|
1271
1695
|
}
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
let next = 0;
|
|
1275
|
-
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
1276
|
-
const workers = Array.from({ length: workerCount }, async () => {
|
|
1277
|
-
while (true) {
|
|
1278
|
-
const i = next++;
|
|
1279
|
-
if (i >= items.length) return;
|
|
1280
|
-
results[i] = await fn(items[i], i);
|
|
1281
|
-
}
|
|
1282
|
-
});
|
|
1283
|
-
await Promise.all(workers);
|
|
1284
|
-
return results;
|
|
1696
|
+
function skipResult(entry, message) {
|
|
1697
|
+
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1285
1698
|
}
|
|
1286
|
-
function
|
|
1699
|
+
function staleResult(entry) {
|
|
1287
1700
|
return {
|
|
1288
1701
|
id: entry.id,
|
|
1289
1702
|
url: entry.url,
|
|
1290
|
-
status: "
|
|
1291
|
-
|
|
1292
|
-
actualPath
|
|
1703
|
+
status: "stale-baseline",
|
|
1704
|
+
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1293
1705
|
};
|
|
1294
1706
|
}
|
|
1295
|
-
function
|
|
1296
|
-
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1297
|
-
}
|
|
1298
|
-
function staleResult(entry) {
|
|
1707
|
+
function passResult(entry, baselinePath, actualPath) {
|
|
1299
1708
|
return {
|
|
1300
1709
|
id: entry.id,
|
|
1301
1710
|
url: entry.url,
|
|
1302
|
-
status: "
|
|
1303
|
-
|
|
1711
|
+
status: "pass",
|
|
1712
|
+
baselinePath,
|
|
1713
|
+
actualPath
|
|
1304
1714
|
};
|
|
1305
1715
|
}
|
|
1306
1716
|
function missingBaselineResult(entry, baselinePath) {
|
|
@@ -1327,300 +1737,334 @@ function failResult(entry, outcome, actualPath, baselinePath, verdict) {
|
|
|
1327
1737
|
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1328
1738
|
};
|
|
1329
1739
|
}
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
mask: entry.mask,
|
|
1371
|
-
waitFor: entry.waitFor,
|
|
1372
|
-
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1373
|
-
mode: "actual"
|
|
1374
|
-
},
|
|
1375
|
-
cwd
|
|
1376
|
-
);
|
|
1377
|
-
const outcome = await diffEntry(
|
|
1378
|
-
entry.id,
|
|
1379
|
-
baselinePath,
|
|
1380
|
-
capture.outputPath,
|
|
1381
|
-
{ threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
|
|
1382
|
-
cwd
|
|
1383
|
-
);
|
|
1384
|
-
if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
|
|
1385
|
-
if (outcome.reason === "file-not-exists")
|
|
1386
|
-
return missingBaselineResult(entry, baselinePath);
|
|
1387
|
-
const verdict = deriveVerdict({
|
|
1388
|
-
reason: outcome.reason,
|
|
1389
|
-
interpretation: outcome.interpretation,
|
|
1390
|
-
diffCount: outcome.diffCount,
|
|
1391
|
-
diffPercentage: outcome.diffPercentage
|
|
1392
|
-
});
|
|
1393
|
-
const failed = failResult(
|
|
1394
|
-
entry,
|
|
1395
|
-
outcome,
|
|
1396
|
-
capture.outputPath,
|
|
1397
|
-
baselinePath,
|
|
1398
|
-
verdict
|
|
1399
|
-
);
|
|
1400
|
-
return judgeAmbiguous(failed, entry, judge, cwd);
|
|
1401
|
-
}
|
|
1402
|
-
async function runCheck(opts) {
|
|
1403
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
1404
|
-
const manifest = await loadManifest(cwd);
|
|
1405
|
-
if (!manifest) {
|
|
1406
|
-
throw new Error(
|
|
1407
|
-
`no manifest found at ${paths(cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
1408
|
-
);
|
|
1409
|
-
}
|
|
1410
|
-
const baselinesDir = paths(cwd).baselines;
|
|
1411
|
-
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
1412
|
-
const judge = resolveJudge(opts.judge ?? "none");
|
|
1413
|
-
let results;
|
|
1414
|
-
try {
|
|
1415
|
-
results = await pool(
|
|
1416
|
-
manifest.entries,
|
|
1417
|
-
concurrency,
|
|
1418
|
-
(entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
|
|
1740
|
+
|
|
1741
|
+
// src/graph/nodes/capture.ts
|
|
1742
|
+
function makeCaptureNode(semaphore) {
|
|
1743
|
+
return async function captureNode(state) {
|
|
1744
|
+
const entry = state.entry;
|
|
1745
|
+
const options = state.options;
|
|
1746
|
+
if (!entry || !options) {
|
|
1747
|
+
throw new Error("captureNode: entry or options missing");
|
|
1748
|
+
}
|
|
1749
|
+
if (entry.auth === "required") {
|
|
1750
|
+
return {
|
|
1751
|
+
captureOutput: {
|
|
1752
|
+
id: entry.id,
|
|
1753
|
+
skipResult: skipResult(
|
|
1754
|
+
entry,
|
|
1755
|
+
"skipped: auth required (deferred to v0.2)"
|
|
1756
|
+
)
|
|
1757
|
+
}
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
if (isEntryStale(entry)) {
|
|
1761
|
+
return {
|
|
1762
|
+
captureOutput: { id: entry.id, skipResult: staleResult(entry) }
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
const baselinePath = path__default.default.join(options.baselinesDir, `${entry.id}.png`);
|
|
1766
|
+
const capture = await semaphore.run(
|
|
1767
|
+
() => captureScreenshot(
|
|
1768
|
+
options.baseUrl,
|
|
1769
|
+
{
|
|
1770
|
+
id: entry.id,
|
|
1771
|
+
url: entry.url,
|
|
1772
|
+
viewport: entry.viewport,
|
|
1773
|
+
mask: entry.mask,
|
|
1774
|
+
waitFor: entry.waitFor,
|
|
1775
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1776
|
+
mode: "actual"
|
|
1777
|
+
},
|
|
1778
|
+
options.cwd
|
|
1779
|
+
)
|
|
1419
1780
|
);
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
const report = {
|
|
1428
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1429
|
-
totalEntries: results.length,
|
|
1430
|
-
passed,
|
|
1431
|
-
failed: results.length - passed - pendingJudgments,
|
|
1432
|
-
pendingJudgments,
|
|
1433
|
-
results
|
|
1781
|
+
return {
|
|
1782
|
+
captureOutput: {
|
|
1783
|
+
id: entry.id,
|
|
1784
|
+
captureOutputPath: capture.outputPath,
|
|
1785
|
+
baselinePath
|
|
1786
|
+
}
|
|
1787
|
+
};
|
|
1434
1788
|
};
|
|
1435
|
-
await writeJudgments({ report, manifest, cwd });
|
|
1436
|
-
await writeSummaryMarkdown(report, cwd);
|
|
1437
|
-
await ensureGitignore(cwd);
|
|
1438
|
-
if (opts.junitPath) {
|
|
1439
|
-
const target = path__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path__default.default.join(cwd, opts.junitPath);
|
|
1440
|
-
await writeJunit(report, target);
|
|
1441
|
-
}
|
|
1442
|
-
return report;
|
|
1443
1789
|
}
|
|
1444
1790
|
|
|
1445
|
-
// src/
|
|
1446
|
-
function
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1791
|
+
// src/graph/nodes/diff.ts
|
|
1792
|
+
function makeDiffNode(semaphore) {
|
|
1793
|
+
return async function diffNode(state) {
|
|
1794
|
+
const entry = state.entry;
|
|
1795
|
+
const options = state.options;
|
|
1796
|
+
const capture = state.captureOutput;
|
|
1797
|
+
if (!entry || !options || !capture) {
|
|
1798
|
+
throw new Error("diffNode: entry, options, or capture missing");
|
|
1451
1799
|
}
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (path18.startsWith("/api/")) continue;
|
|
1457
|
-
out.push(path18);
|
|
1458
|
-
} catch {
|
|
1800
|
+
if (capture.skipResult) {
|
|
1801
|
+
return {
|
|
1802
|
+
diffOutput: { id: capture.id, skipResult: capture.skipResult }
|
|
1803
|
+
};
|
|
1459
1804
|
}
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
while (queue.length && discovered.length < maxRoutes) {
|
|
1478
|
-
const { url, depth } = queue.shift();
|
|
1479
|
-
const page = await context.newPage();
|
|
1480
|
-
try {
|
|
1481
|
-
const target = new URL(url, base).toString();
|
|
1482
|
-
await page.goto(target, {
|
|
1483
|
-
waitUntil: "domcontentloaded",
|
|
1484
|
-
timeout: 15e3
|
|
1485
|
-
});
|
|
1486
|
-
discovered.push({ url, source: "crawl" });
|
|
1487
|
-
if (depth >= maxDepth) continue;
|
|
1488
|
-
const hrefs = await page.evaluate(
|
|
1489
|
-
() => Array.from(
|
|
1490
|
-
document.querySelectorAll("a[href]")
|
|
1491
|
-
).map((a) => a.getAttribute("href") ?? "")
|
|
1492
|
-
);
|
|
1493
|
-
for (const path18 of extractInternalLinks(base, target, hrefs)) {
|
|
1494
|
-
if (visited.has(path18)) continue;
|
|
1495
|
-
visited.add(path18);
|
|
1496
|
-
queue.push({ url: path18, depth: depth + 1 });
|
|
1805
|
+
if (!capture.captureOutputPath || !capture.baselinePath) {
|
|
1806
|
+
throw new Error("diffNode: capture output paths missing");
|
|
1807
|
+
}
|
|
1808
|
+
const outcome = await semaphore.run(
|
|
1809
|
+
() => diffEntry(
|
|
1810
|
+
entry.id,
|
|
1811
|
+
capture.baselinePath,
|
|
1812
|
+
capture.captureOutputPath,
|
|
1813
|
+
{ threshold: options.threshold, emitDiffPng: options.emitDiffPng },
|
|
1814
|
+
options.cwd
|
|
1815
|
+
)
|
|
1816
|
+
);
|
|
1817
|
+
if (outcome.reason === "file-not-exists") {
|
|
1818
|
+
return {
|
|
1819
|
+
diffOutput: {
|
|
1820
|
+
id: entry.id,
|
|
1821
|
+
skipResult: missingBaselineResult(entry, capture.baselinePath)
|
|
1497
1822
|
}
|
|
1498
|
-
}
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
return {
|
|
1826
|
+
diffOutput: {
|
|
1827
|
+
id: entry.id,
|
|
1828
|
+
outcome,
|
|
1829
|
+
baselinePath: capture.baselinePath,
|
|
1830
|
+
captureOutputPath: capture.captureOutputPath
|
|
1502
1831
|
}
|
|
1832
|
+
};
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
// src/diff/verdict.ts
|
|
1837
|
+
var REGRESSIVE_TYPES = /* @__PURE__ */ new Set(["content-change", "addition", "deletion"]);
|
|
1838
|
+
var INTENTIONAL_TYPES = /* @__PURE__ */ new Set(["color-change", "shift"]);
|
|
1839
|
+
var NOISE_TYPES = /* @__PURE__ */ new Set(["rendering-noise"]);
|
|
1840
|
+
var ELEVATED_SEVERITY = /* @__PURE__ */ new Set(["medium", "high"]);
|
|
1841
|
+
var SUB_PERCEPTUAL_PCT = 0.01;
|
|
1842
|
+
function pctText(pct) {
|
|
1843
|
+
if (typeof pct !== "number") return "?%";
|
|
1844
|
+
return pct >= 0.01 ? `${pct.toFixed(2)}%` : `${pct.toFixed(3)}%`;
|
|
1845
|
+
}
|
|
1846
|
+
function countByType(regions) {
|
|
1847
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1848
|
+
for (const r of regions)
|
|
1849
|
+
counts.set(r.changeType, (counts.get(r.changeType) ?? 0) + 1);
|
|
1850
|
+
return counts;
|
|
1851
|
+
}
|
|
1852
|
+
function dominantType(counts) {
|
|
1853
|
+
let best = "";
|
|
1854
|
+
let bestN = 0;
|
|
1855
|
+
for (const [type, n] of counts) {
|
|
1856
|
+
if (n > bestN) {
|
|
1857
|
+
best = type;
|
|
1858
|
+
bestN = n;
|
|
1503
1859
|
}
|
|
1504
|
-
} finally {
|
|
1505
|
-
await context.close().catch(() => {
|
|
1506
|
-
});
|
|
1507
1860
|
}
|
|
1508
|
-
return
|
|
1861
|
+
return best;
|
|
1509
1862
|
}
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
} catch {
|
|
1516
|
-
return null;
|
|
1517
|
-
}
|
|
1863
|
+
function topPosition(regions) {
|
|
1864
|
+
let best;
|
|
1865
|
+
for (const r of regions)
|
|
1866
|
+
if (!best || r.pixelCount > best.pixelCount) best = r;
|
|
1867
|
+
return best?.position;
|
|
1518
1868
|
}
|
|
1519
|
-
function
|
|
1520
|
-
if (
|
|
1521
|
-
|
|
1522
|
-
return true;
|
|
1869
|
+
function meanConfidence(regions) {
|
|
1870
|
+
if (regions.length === 0) return 0;
|
|
1871
|
+
return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
|
|
1523
1872
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
const
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
const routes = await readJson(
|
|
1535
|
-
path__default.default.join(nextDir, "routes-manifest.json")
|
|
1536
|
-
);
|
|
1537
|
-
for (const r of routes?.staticRoutes ?? []) {
|
|
1538
|
-
if (isPublicRoute(r.page)) add(r.page);
|
|
1873
|
+
function formatBreakdown(counts) {
|
|
1874
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
|
|
1875
|
+
}
|
|
1876
|
+
function buildHeadline(input) {
|
|
1877
|
+
const { reason, interpretation, diffCount, diffPercentage } = input;
|
|
1878
|
+
if (reason === "layout-diff") return "image dimensions changed";
|
|
1879
|
+
if (reason === "file-not-exists") return "baseline or actual capture missing";
|
|
1880
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
1881
|
+
const px = diffCount?.toLocaleString() ?? "?";
|
|
1882
|
+
return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
|
|
1539
1883
|
}
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
);
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1884
|
+
const regions = interpretation.regions;
|
|
1885
|
+
const counts = countByType(regions);
|
|
1886
|
+
const pos = topPosition(regions);
|
|
1887
|
+
const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
|
|
1888
|
+
const sev = interpretation.severity ?? "?";
|
|
1889
|
+
if (regions.length === 1) {
|
|
1890
|
+
return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
1546
1891
|
}
|
|
1547
|
-
return
|
|
1892
|
+
return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
1548
1893
|
}
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1894
|
+
function deriveVerdict(input) {
|
|
1895
|
+
const { reason, interpretation, diffPercentage } = input;
|
|
1896
|
+
const headline = buildHeadline(input);
|
|
1897
|
+
if (reason === "layout-diff") {
|
|
1898
|
+
return {
|
|
1899
|
+
label: "ambiguous",
|
|
1900
|
+
headline,
|
|
1901
|
+
rationale: [
|
|
1902
|
+
"baseline and actual image dimensions differ \u2014 page height likely shifted",
|
|
1903
|
+
"could be intentional (content added/removed) or regression (broken layout)"
|
|
1904
|
+
],
|
|
1905
|
+
action: "investigate"
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
if (reason === "file-not-exists") {
|
|
1909
|
+
return {
|
|
1910
|
+
label: "regression-likely",
|
|
1911
|
+
headline,
|
|
1912
|
+
rationale: ["baseline or actual capture is missing from disk"],
|
|
1913
|
+
action: "investigate"
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
if (!interpretation || interpretation.regions.length === 0) {
|
|
1917
|
+
return {
|
|
1918
|
+
label: "ambiguous",
|
|
1919
|
+
headline,
|
|
1920
|
+
rationale: ["pixels differ but interpret returned no regions"],
|
|
1921
|
+
action: "investigate"
|
|
1922
|
+
};
|
|
1923
|
+
}
|
|
1924
|
+
const regions = interpretation.regions;
|
|
1925
|
+
const severity = interpretation.severity;
|
|
1926
|
+
const counts = countByType(regions);
|
|
1927
|
+
const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
|
|
1928
|
+
const allColor = regions.every((r) => r.changeType === "color-change");
|
|
1929
|
+
const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
|
|
1930
|
+
const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
|
|
1931
|
+
const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
|
|
1932
|
+
if (allNoise) {
|
|
1933
|
+
return {
|
|
1934
|
+
label: "noise-likely",
|
|
1935
|
+
headline,
|
|
1936
|
+
rationale: ["all regions classified as rendering-noise"],
|
|
1937
|
+
action: "ignore-or-rewrite"
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
|
|
1941
|
+
return {
|
|
1942
|
+
label: "noise-likely",
|
|
1943
|
+
headline,
|
|
1944
|
+
rationale: [
|
|
1945
|
+
`delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
|
|
1946
|
+
"sub-perceptual change - review optional"
|
|
1947
|
+
],
|
|
1948
|
+
action: "ignore-or-rewrite"
|
|
1949
|
+
};
|
|
1950
|
+
}
|
|
1951
|
+
if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
|
|
1952
|
+
const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
1953
|
+
return {
|
|
1954
|
+
label: "regression-likely",
|
|
1955
|
+
headline,
|
|
1956
|
+
rationale: [
|
|
1957
|
+
`severity ${severity} with structural changes (${types})`,
|
|
1958
|
+
"likely affects content or layout, not just styling"
|
|
1959
|
+
],
|
|
1960
|
+
action: "investigate"
|
|
1961
|
+
};
|
|
1962
|
+
}
|
|
1963
|
+
if (allColor && meanConfidence(regions) > 0.7) {
|
|
1964
|
+
return {
|
|
1965
|
+
label: "intentional-likely",
|
|
1966
|
+
headline,
|
|
1967
|
+
rationale: [
|
|
1968
|
+
`${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
|
|
1969
|
+
"edge structure preserved - looks like a theming / palette change"
|
|
1970
|
+
],
|
|
1971
|
+
action: "rewrite-if-intended"
|
|
1972
|
+
};
|
|
1567
1973
|
}
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
}
|
|
1578
|
-
function mergeBy(routes, into) {
|
|
1579
|
-
for (const r of routes) {
|
|
1580
|
-
const key = normalizePath(r.url);
|
|
1581
|
-
if (!into.has(key)) into.set(key, { ...r, url: key });
|
|
1974
|
+
if (allMoved && !allColor) {
|
|
1975
|
+
return {
|
|
1976
|
+
label: "intentional-likely",
|
|
1977
|
+
headline,
|
|
1978
|
+
rationale: [
|
|
1979
|
+
"all regions are shift/color-change - content moved or restyled, structure preserved"
|
|
1980
|
+
],
|
|
1981
|
+
action: "rewrite-if-intended"
|
|
1982
|
+
};
|
|
1582
1983
|
}
|
|
1984
|
+
return {
|
|
1985
|
+
label: "ambiguous",
|
|
1986
|
+
headline,
|
|
1987
|
+
rationale: [
|
|
1988
|
+
`mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
|
|
1989
|
+
`${pctText(pct)} of image differs`
|
|
1990
|
+
],
|
|
1991
|
+
action: "investigate"
|
|
1992
|
+
};
|
|
1583
1993
|
}
|
|
1584
|
-
|
|
1585
|
-
const
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
|
|
1589
|
-
if (!opts.skipCrawl) {
|
|
1590
|
-
const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
|
|
1591
|
-
if (crawlMax > 0) {
|
|
1592
|
-
mergeBy(
|
|
1593
|
-
await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
|
|
1594
|
-
merged
|
|
1595
|
-
);
|
|
1596
|
-
}
|
|
1597
|
-
}
|
|
1598
|
-
return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
|
|
1994
|
+
function interruptForJudgment(payload) {
|
|
1995
|
+
const resume = langgraph.interrupt(payload);
|
|
1996
|
+
if (!resume || typeof resume !== "object") return void 0;
|
|
1997
|
+
return resume[payload.entryId];
|
|
1599
1998
|
}
|
|
1600
1999
|
|
|
1601
|
-
// src/graph/nodes/
|
|
1602
|
-
async function
|
|
2000
|
+
// src/graph/nodes/judge.ts
|
|
2001
|
+
async function judgeNode(state) {
|
|
2002
|
+
const entry = state.entry;
|
|
1603
2003
|
const options = state.options;
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
const
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
results
|
|
1619
|
-
}
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
2004
|
+
const diff = state.diffOutput;
|
|
2005
|
+
if (!entry || !options || !diff) {
|
|
2006
|
+
throw new Error("judgeNode: entry, options, or diff missing");
|
|
2007
|
+
}
|
|
2008
|
+
if (diff.skipResult) {
|
|
2009
|
+
return { results: [diff.skipResult] };
|
|
2010
|
+
}
|
|
2011
|
+
const outcome = diff.outcome;
|
|
2012
|
+
const baselinePath = diff.baselinePath;
|
|
2013
|
+
const captureOutputPath = diff.captureOutputPath;
|
|
2014
|
+
if (!outcome || !baselinePath || !captureOutputPath) {
|
|
2015
|
+
throw new Error("judgeNode: diff outputs missing");
|
|
2016
|
+
}
|
|
2017
|
+
if (outcome.match) {
|
|
2018
|
+
return { results: [passResult(entry, baselinePath, captureOutputPath)] };
|
|
2019
|
+
}
|
|
2020
|
+
const verdict = deriveVerdict({
|
|
2021
|
+
reason: outcome.reason,
|
|
2022
|
+
interpretation: outcome.interpretation,
|
|
2023
|
+
diffCount: outcome.diffCount,
|
|
2024
|
+
diffPercentage: outcome.diffPercentage
|
|
2025
|
+
});
|
|
2026
|
+
let result = failResult(
|
|
2027
|
+
entry,
|
|
2028
|
+
outcome,
|
|
2029
|
+
captureOutputPath,
|
|
2030
|
+
baselinePath,
|
|
2031
|
+
verdict
|
|
2032
|
+
);
|
|
2033
|
+
if (result.baselinePath && result.actualPath) {
|
|
2034
|
+
const judge = resolveJudge(options.judge);
|
|
2035
|
+
const output = await judge.judge(
|
|
2036
|
+
{
|
|
2037
|
+
entry,
|
|
2038
|
+
baselinePath: result.baselinePath,
|
|
2039
|
+
actualPath: result.actualPath,
|
|
2040
|
+
diffPath: result.diffPath,
|
|
2041
|
+
regions: result.regions,
|
|
2042
|
+
diffPercentage: result.diffPercentage,
|
|
2043
|
+
severity: result.severity,
|
|
2044
|
+
heuristicVerdict: result.verdict ?? verdict
|
|
2045
|
+
},
|
|
2046
|
+
options.cwd
|
|
2047
|
+
);
|
|
2048
|
+
if (output.kind === "judged") {
|
|
2049
|
+
result = { ...result, verdict: output.verdict };
|
|
2050
|
+
} else {
|
|
2051
|
+
const pending = {
|
|
2052
|
+
...result,
|
|
2053
|
+
status: "needs-judgment",
|
|
2054
|
+
message: `awaiting judgment in ${output.requestPath}`
|
|
2055
|
+
};
|
|
2056
|
+
const resumed = interruptForJudgment({
|
|
2057
|
+
kind: "host-judgment-required",
|
|
2058
|
+
entryId: entry.id,
|
|
2059
|
+
url: entry.url,
|
|
2060
|
+
requestPath: output.requestPath,
|
|
2061
|
+
signature: signatureOf(result),
|
|
2062
|
+
pendingResult: pending
|
|
2063
|
+
});
|
|
2064
|
+
result = resumed ? { ...result, verdict: resumed } : pending;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
return { results: [result] };
|
|
1624
2068
|
}
|
|
1625
2069
|
|
|
1626
2070
|
// src/graph/nodes/load.ts
|
|
@@ -1636,187 +2080,39 @@ async function loadNode(state) {
|
|
|
1636
2080
|
}
|
|
1637
2081
|
return { entries: manifest.entries, manifest };
|
|
1638
2082
|
}
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
}
|
|
1648
|
-
function skipResult2(entry, message) {
|
|
1649
|
-
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1650
|
-
}
|
|
1651
|
-
function staleResult2(entry) {
|
|
1652
|
-
return {
|
|
1653
|
-
id: entry.id,
|
|
1654
|
-
url: entry.url,
|
|
1655
|
-
status: "stale-baseline",
|
|
1656
|
-
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1657
|
-
};
|
|
1658
|
-
}
|
|
1659
|
-
function passResult3(entry, baselinePath, actualPath) {
|
|
1660
|
-
return {
|
|
1661
|
-
id: entry.id,
|
|
1662
|
-
url: entry.url,
|
|
1663
|
-
status: "pass",
|
|
1664
|
-
baselinePath,
|
|
1665
|
-
actualPath
|
|
1666
|
-
};
|
|
1667
|
-
}
|
|
1668
|
-
function missingBaselineResult2(entry, baselinePath) {
|
|
1669
|
-
return {
|
|
1670
|
-
id: entry.id,
|
|
1671
|
-
url: entry.url,
|
|
1672
|
-
status: "missing-baseline",
|
|
1673
|
-
message: `baseline missing at ${baselinePath}`
|
|
1674
|
-
};
|
|
1675
|
-
}
|
|
1676
|
-
function failResult2(entry, outcome, actualPath, baselinePath, verdict) {
|
|
1677
|
-
return {
|
|
1678
|
-
id: entry.id,
|
|
1679
|
-
url: entry.url,
|
|
1680
|
-
status: "fail",
|
|
1681
|
-
diffCount: outcome.diffCount,
|
|
1682
|
-
diffPercentage: outcome.diffPercentage,
|
|
1683
|
-
severity: outcome.interpretation?.severity,
|
|
1684
|
-
regions: outcome.interpretation?.regions?.map(narrowRegion2),
|
|
1685
|
-
verdict,
|
|
1686
|
-
diffPath: outcome.diffPath,
|
|
1687
|
-
baselinePath,
|
|
1688
|
-
actualPath,
|
|
1689
|
-
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1690
|
-
};
|
|
1691
|
-
}
|
|
1692
|
-
function makeProcessNode(semaphore) {
|
|
1693
|
-
return async function processNode(state) {
|
|
1694
|
-
const entry = state.entry;
|
|
1695
|
-
const options = state.options;
|
|
1696
|
-
if (!entry || !options) {
|
|
1697
|
-
throw new Error("processNode: entry or options missing");
|
|
1698
|
-
}
|
|
1699
|
-
if (entry.auth === "required") {
|
|
1700
|
-
return {
|
|
1701
|
-
results: [
|
|
1702
|
-
skipResult2(entry, "skipped: auth required (deferred to v0.2)")
|
|
1703
|
-
]
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1706
|
-
if (isEntryStale(entry)) {
|
|
1707
|
-
return { results: [staleResult2(entry)] };
|
|
1708
|
-
}
|
|
1709
|
-
const capture = await semaphore.run(
|
|
1710
|
-
() => captureScreenshot(
|
|
1711
|
-
options.baseUrl,
|
|
1712
|
-
{
|
|
1713
|
-
id: entry.id,
|
|
1714
|
-
url: entry.url,
|
|
1715
|
-
viewport: entry.viewport,
|
|
1716
|
-
mask: entry.mask,
|
|
1717
|
-
waitFor: entry.waitFor,
|
|
1718
|
-
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1719
|
-
mode: "actual"
|
|
1720
|
-
},
|
|
1721
|
-
options.cwd
|
|
1722
|
-
)
|
|
1723
|
-
);
|
|
1724
|
-
const baselinePath = path__default.default.join(options.baselinesDir, `${entry.id}.png`);
|
|
1725
|
-
const outcome = await diffEntry(
|
|
1726
|
-
entry.id,
|
|
1727
|
-
baselinePath,
|
|
1728
|
-
capture.outputPath,
|
|
1729
|
-
{ threshold: options.threshold, emitDiffPng: options.emitDiffPng },
|
|
1730
|
-
options.cwd
|
|
1731
|
-
);
|
|
1732
|
-
if (outcome.match) {
|
|
1733
|
-
return { results: [passResult3(entry, baselinePath, capture.outputPath)] };
|
|
1734
|
-
}
|
|
1735
|
-
if (outcome.reason === "file-not-exists") {
|
|
1736
|
-
return { results: [missingBaselineResult2(entry, baselinePath)] };
|
|
1737
|
-
}
|
|
1738
|
-
const verdict = deriveVerdict({
|
|
1739
|
-
reason: outcome.reason,
|
|
1740
|
-
interpretation: outcome.interpretation,
|
|
1741
|
-
diffCount: outcome.diffCount,
|
|
1742
|
-
diffPercentage: outcome.diffPercentage
|
|
1743
|
-
});
|
|
1744
|
-
let result = failResult2(
|
|
1745
|
-
entry,
|
|
1746
|
-
outcome,
|
|
1747
|
-
capture.outputPath,
|
|
1748
|
-
baselinePath,
|
|
1749
|
-
verdict
|
|
1750
|
-
);
|
|
1751
|
-
if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
|
|
1752
|
-
const judge = resolveJudge(options.judge);
|
|
1753
|
-
const output = await judge.judge(
|
|
1754
|
-
{
|
|
1755
|
-
entry,
|
|
1756
|
-
baselinePath: result.baselinePath,
|
|
1757
|
-
actualPath: result.actualPath,
|
|
1758
|
-
diffPath: result.diffPath,
|
|
1759
|
-
regions: result.regions,
|
|
1760
|
-
diffPercentage: result.diffPercentage,
|
|
1761
|
-
severity: result.severity,
|
|
1762
|
-
heuristicVerdict: result.verdict
|
|
1763
|
-
},
|
|
1764
|
-
options.cwd
|
|
1765
|
-
);
|
|
1766
|
-
if (output.kind === "judged") {
|
|
1767
|
-
result = { ...result, verdict: output.verdict };
|
|
1768
|
-
} else {
|
|
1769
|
-
result = {
|
|
1770
|
-
...result,
|
|
1771
|
-
status: "needs-judgment",
|
|
1772
|
-
message: `awaiting judgment in ${output.requestPath}`
|
|
1773
|
-
};
|
|
1774
|
-
}
|
|
1775
|
-
}
|
|
1776
|
-
return { results: [result] };
|
|
1777
|
-
};
|
|
1778
|
-
}
|
|
1779
|
-
|
|
1780
|
-
// src/graph/semaphore.ts
|
|
1781
|
-
var Semaphore = class {
|
|
1782
|
-
constructor(limit) {
|
|
1783
|
-
this.limit = limit;
|
|
1784
|
-
this.current = 0;
|
|
1785
|
-
this.queue = [];
|
|
1786
|
-
if (limit < 1)
|
|
1787
|
-
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
1788
|
-
}
|
|
1789
|
-
async run(fn) {
|
|
1790
|
-
if (this.current >= this.limit) {
|
|
1791
|
-
await new Promise((resolve) => this.queue.push(resolve));
|
|
1792
|
-
}
|
|
1793
|
-
this.current++;
|
|
1794
|
-
try {
|
|
1795
|
-
return await fn();
|
|
1796
|
-
} finally {
|
|
1797
|
-
this.current--;
|
|
1798
|
-
const next = this.queue.shift();
|
|
1799
|
-
if (next) next();
|
|
1800
|
-
}
|
|
1801
|
-
}
|
|
1802
|
-
};
|
|
1803
|
-
var GraphState = langgraph.Annotation.Root({
|
|
2083
|
+
var resultsChannel = langgraph.Annotation({
|
|
2084
|
+
reducer: (acc, next) => [...acc, ...next],
|
|
2085
|
+
default: () => []
|
|
2086
|
+
});
|
|
2087
|
+
var BranchState = langgraph.Annotation.Root({
|
|
2088
|
+
entry: langgraph.Annotation({
|
|
2089
|
+
reducer: (_, next) => next,
|
|
2090
|
+
default: () => void 0
|
|
2091
|
+
}),
|
|
1804
2092
|
options: langgraph.Annotation({
|
|
1805
2093
|
reducer: (acc, next) => next ?? acc,
|
|
1806
2094
|
default: () => void 0
|
|
1807
2095
|
}),
|
|
1808
|
-
|
|
1809
|
-
reducer: (
|
|
1810
|
-
default: () =>
|
|
2096
|
+
captureOutput: langgraph.Annotation({
|
|
2097
|
+
reducer: (_, next) => next,
|
|
2098
|
+
default: () => void 0
|
|
1811
2099
|
}),
|
|
1812
|
-
|
|
2100
|
+
diffOutput: langgraph.Annotation({
|
|
1813
2101
|
reducer: (_, next) => next,
|
|
1814
2102
|
default: () => void 0
|
|
1815
2103
|
}),
|
|
1816
|
-
results:
|
|
1817
|
-
|
|
2104
|
+
results: resultsChannel
|
|
2105
|
+
});
|
|
2106
|
+
var GraphState = langgraph.Annotation.Root({
|
|
2107
|
+
options: langgraph.Annotation({
|
|
2108
|
+
reducer: (acc, next) => next ?? acc,
|
|
2109
|
+
default: () => void 0
|
|
2110
|
+
}),
|
|
2111
|
+
entries: langgraph.Annotation({
|
|
2112
|
+
reducer: (acc, next) => next ?? acc,
|
|
1818
2113
|
default: () => []
|
|
1819
2114
|
}),
|
|
2115
|
+
results: resultsChannel,
|
|
1820
2116
|
manifest: langgraph.Annotation({
|
|
1821
2117
|
reducer: (acc, next) => next ?? acc,
|
|
1822
2118
|
default: () => void 0
|
|
@@ -1828,47 +2124,144 @@ var GraphState = langgraph.Annotation.Root({
|
|
|
1828
2124
|
});
|
|
1829
2125
|
|
|
1830
2126
|
// src/graph/index.ts
|
|
1831
|
-
function
|
|
1832
|
-
|
|
2127
|
+
function cpuParallelism() {
|
|
2128
|
+
const cores = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
2129
|
+
if (!cores || !Number.isFinite(cores)) return 2;
|
|
2130
|
+
return Math.max(2, cores);
|
|
2131
|
+
}
|
|
2132
|
+
function threadIdFor(cwd) {
|
|
2133
|
+
return crypto$1.createHash("sha1").update(paths(cwd).manifest).digest("hex").slice(0, 16);
|
|
2134
|
+
}
|
|
2135
|
+
function buildBranchGraph(captureSemaphore, diffSemaphore) {
|
|
2136
|
+
return new langgraph.StateGraph(BranchState).addNode("capture", makeCaptureNode(captureSemaphore)).addNode("diff", makeDiffNode(diffSemaphore)).addNode("judge", judgeNode).addEdge(langgraph.START, "capture").addEdge("capture", "diff").addEdge("diff", "judge").addEdge("judge", langgraph.END).compile();
|
|
2137
|
+
}
|
|
2138
|
+
function buildGraph(captureSemaphore, diffSemaphore, checkpointer) {
|
|
2139
|
+
const branch = buildBranchGraph(captureSemaphore, diffSemaphore);
|
|
2140
|
+
return new langgraph.StateGraph(GraphState).addNode("load", loadNode).addNode("branch", branch).addEdge(langgraph.START, "load").addConditionalEdges(
|
|
1833
2141
|
"load",
|
|
1834
2142
|
(state) => state.entries.map(
|
|
1835
|
-
(entry) => new langgraph.Send("
|
|
2143
|
+
(entry) => new langgraph.Send("branch", { entry, options: state.options })
|
|
1836
2144
|
),
|
|
1837
|
-
["
|
|
1838
|
-
).addEdge("
|
|
2145
|
+
["branch"]
|
|
2146
|
+
).addEdge("branch", langgraph.END).compile({ checkpointer });
|
|
2147
|
+
}
|
|
2148
|
+
async function streamGraph(graph, input, threadId, onEvent) {
|
|
2149
|
+
const collect = { results: [], interrupts: [] };
|
|
2150
|
+
const stream = await graph.stream(input, {
|
|
2151
|
+
streamMode: "updates",
|
|
2152
|
+
configurable: { thread_id: threadId }
|
|
2153
|
+
});
|
|
2154
|
+
for await (const chunk of stream) {
|
|
2155
|
+
if (!chunk || typeof chunk !== "object") continue;
|
|
2156
|
+
for (const [node, partial] of Object.entries(
|
|
2157
|
+
chunk
|
|
2158
|
+
)) {
|
|
2159
|
+
if (node === "__interrupt__") {
|
|
2160
|
+
const arr = partial;
|
|
2161
|
+
if (!arr) continue;
|
|
2162
|
+
for (const i of arr) {
|
|
2163
|
+
const v = i?.value;
|
|
2164
|
+
if (!v || v.kind !== "host-judgment-required") continue;
|
|
2165
|
+
collect.interrupts.push(v);
|
|
2166
|
+
onEvent?.({ type: "interrupt", interrupt: v });
|
|
2167
|
+
}
|
|
2168
|
+
continue;
|
|
2169
|
+
}
|
|
2170
|
+
const part = partial;
|
|
2171
|
+
if (!part) continue;
|
|
2172
|
+
if (part.results) {
|
|
2173
|
+
for (const r of part.results) {
|
|
2174
|
+
collect.results.push(r);
|
|
2175
|
+
onEvent?.({ type: "result", result: r });
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (part.manifest) collect.manifest = part.manifest;
|
|
2179
|
+
if (part.report) {
|
|
2180
|
+
collect.report = part.report;
|
|
2181
|
+
onEvent?.({ type: "report", report: part.report });
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return collect;
|
|
2186
|
+
}
|
|
2187
|
+
async function buildPartialReport(collect, cwd) {
|
|
2188
|
+
const manifest = collect.manifest ?? await loadManifest(cwd);
|
|
2189
|
+
const synthesized = collect.interrupts.map((i) => ({
|
|
2190
|
+
...i.pendingResult,
|
|
2191
|
+
status: "needs-judgment",
|
|
2192
|
+
message: i.pendingResult.message ?? `awaiting judgment in ${i.requestPath}`
|
|
2193
|
+
}));
|
|
2194
|
+
const results = [...collect.results, ...synthesized];
|
|
2195
|
+
const passed = results.filter((r) => r.status === "pass").length;
|
|
2196
|
+
const pendingJudgments = results.filter(
|
|
2197
|
+
(r) => r.status === "needs-judgment"
|
|
2198
|
+
).length;
|
|
2199
|
+
const report = {
|
|
2200
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2201
|
+
totalEntries: results.length,
|
|
2202
|
+
passed,
|
|
2203
|
+
failed: results.length - passed - pendingJudgments,
|
|
2204
|
+
pendingJudgments,
|
|
2205
|
+
results
|
|
2206
|
+
};
|
|
2207
|
+
if (manifest) {
|
|
2208
|
+
await writeJudgments({ report, manifest, cwd });
|
|
2209
|
+
}
|
|
2210
|
+
await writeSummaryMarkdown(report, cwd);
|
|
2211
|
+
await ensureGitignore(cwd);
|
|
2212
|
+
return report;
|
|
1839
2213
|
}
|
|
1840
2214
|
async function runGraph(opts) {
|
|
1841
2215
|
const cwd = opts.cwd ?? process.cwd();
|
|
1842
2216
|
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
1843
|
-
const
|
|
2217
|
+
const captureSemaphore = new Semaphore(concurrency);
|
|
2218
|
+
const diffSemaphore = new Semaphore(cpuParallelism());
|
|
1844
2219
|
const baselinesDir = paths(cwd).baselines;
|
|
1845
|
-
const
|
|
1846
|
-
|
|
2220
|
+
const checkpointer = new FsCheckpointSaver(paths(cwd).checkpoints);
|
|
2221
|
+
const graph = buildGraph(captureSemaphore, diffSemaphore, checkpointer);
|
|
2222
|
+
const threadId = opts.threadId ?? threadIdFor(cwd);
|
|
2223
|
+
if (!opts.resume) {
|
|
2224
|
+
await checkpointer.deleteThread(threadId);
|
|
2225
|
+
}
|
|
2226
|
+
const input = opts.resume ? new langgraph.Command({ resume: opts.resume }) : {
|
|
2227
|
+
options: {
|
|
2228
|
+
baseUrl: opts.baseUrl ?? "",
|
|
2229
|
+
cwd,
|
|
2230
|
+
threshold: opts.threshold,
|
|
2231
|
+
concurrency,
|
|
2232
|
+
emitDiffPng: opts.emitDiffPng ?? true,
|
|
2233
|
+
judge: opts.judge ?? "none",
|
|
2234
|
+
baselinesDir
|
|
2235
|
+
}
|
|
2236
|
+
};
|
|
2237
|
+
let collect;
|
|
1847
2238
|
try {
|
|
1848
|
-
|
|
1849
|
-
options: {
|
|
1850
|
-
baseUrl: opts.baseUrl,
|
|
1851
|
-
cwd,
|
|
1852
|
-
threshold: opts.threshold,
|
|
1853
|
-
concurrency,
|
|
1854
|
-
emitDiffPng: opts.emitDiffPng ?? true,
|
|
1855
|
-
judge: opts.judge ?? "none",
|
|
1856
|
-
baselinesDir
|
|
1857
|
-
}
|
|
1858
|
-
});
|
|
2239
|
+
collect = await streamGraph(graph, input, threadId, opts.onEvent);
|
|
1859
2240
|
} finally {
|
|
1860
2241
|
await closeBrowser();
|
|
1861
2242
|
}
|
|
1862
|
-
const report =
|
|
1863
|
-
if (!report) {
|
|
1864
|
-
throw new Error("runGraph: graph completed without producing a report");
|
|
1865
|
-
}
|
|
2243
|
+
const report = collect.report ?? await buildPartialReport(collect, cwd);
|
|
1866
2244
|
if (opts.junitPath) {
|
|
1867
2245
|
const target = path__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path__default.default.join(cwd, opts.junitPath);
|
|
1868
2246
|
await writeJunit(report, target);
|
|
1869
2247
|
}
|
|
2248
|
+
if (collect.interrupts.length === 0) {
|
|
2249
|
+
await checkpointer.deleteThread(threadId).catch(() => void 0);
|
|
2250
|
+
}
|
|
1870
2251
|
return report;
|
|
1871
2252
|
}
|
|
2253
|
+
async function resumeGraph(opts) {
|
|
2254
|
+
return runGraph({
|
|
2255
|
+
cwd: opts.cwd,
|
|
2256
|
+
threadId: opts.threadId,
|
|
2257
|
+
resume: opts.verdicts,
|
|
2258
|
+
onEvent: opts.onEvent,
|
|
2259
|
+
junitPath: opts.junitPath
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
function runCheck(opts) {
|
|
2263
|
+
return runGraph(opts);
|
|
2264
|
+
}
|
|
1872
2265
|
|
|
1873
2266
|
exports.STABILITY_HOOKS_VERSION = STABILITY_HOOKS_VERSION;
|
|
1874
2267
|
exports.applyJudgments = applyJudgments;
|
|
@@ -1882,8 +2275,10 @@ exports.loadManifest = loadManifest;
|
|
|
1882
2275
|
exports.paths = paths;
|
|
1883
2276
|
exports.resolveBaseUrl = resolveBaseUrl;
|
|
1884
2277
|
exports.resolveJudge = resolveJudge;
|
|
2278
|
+
exports.resumeGraph = resumeGraph;
|
|
1885
2279
|
exports.runCaptures = runCaptures;
|
|
1886
2280
|
exports.runCheck = runCheck;
|
|
1887
2281
|
exports.runGraph = runGraph;
|
|
1888
2282
|
exports.saveConfig = saveConfig;
|
|
1889
2283
|
exports.saveManifest = saveManifest;
|
|
2284
|
+
exports.threadIdFor = threadIdFor;
|