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