@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/cli.js
CHANGED
|
@@ -10,14 +10,15 @@ var path2 = require('path');
|
|
|
10
10
|
var promises = require('fs/promises');
|
|
11
11
|
var os = require('os');
|
|
12
12
|
var crypto$1 = require('crypto');
|
|
13
|
-
var
|
|
13
|
+
var langgraph = require('@langchain/langgraph');
|
|
14
14
|
var sharp = require('sharp');
|
|
15
|
+
var langgraphCheckpoint = require('@langchain/langgraph-checkpoint');
|
|
16
|
+
var coreNative = require('@blazediff/core-native');
|
|
15
17
|
var promises$1 = require('readline/promises');
|
|
16
18
|
var url = require('url');
|
|
17
19
|
var net = require('net');
|
|
18
20
|
var util = require('util');
|
|
19
21
|
var treeKill = require('tree-kill');
|
|
20
|
-
var langgraph = require('@langchain/langgraph');
|
|
21
22
|
|
|
22
23
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
23
24
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
@@ -50,6 +51,7 @@ var CHROMIUM_FLAGS = [
|
|
|
50
51
|
];
|
|
51
52
|
var cachedBrowser = null;
|
|
52
53
|
var launchInFlight = null;
|
|
54
|
+
var contextPool = /* @__PURE__ */ new Map();
|
|
53
55
|
async function getBrowser() {
|
|
54
56
|
if (cachedBrowser?.isConnected()) return cachedBrowser;
|
|
55
57
|
if (launchInFlight) return launchInFlight;
|
|
@@ -66,15 +68,22 @@ async function closeBrowser() {
|
|
|
66
68
|
await launchInFlight.catch(() => {
|
|
67
69
|
});
|
|
68
70
|
}
|
|
71
|
+
const ctxs = Array.from(contextPool.values()).flat();
|
|
72
|
+
contextPool.clear();
|
|
73
|
+
await Promise.all(ctxs.map((c) => c.close().catch(() => {
|
|
74
|
+
})));
|
|
69
75
|
if (!cachedBrowser) return;
|
|
70
76
|
await cachedBrowser.close().catch(() => {
|
|
71
77
|
});
|
|
72
78
|
cachedBrowser = null;
|
|
73
79
|
}
|
|
74
|
-
|
|
80
|
+
function viewportKey(v) {
|
|
81
|
+
return `${v.width}x${v.height}`;
|
|
82
|
+
}
|
|
83
|
+
async function createStableContext(viewport) {
|
|
75
84
|
const browser = await getBrowser();
|
|
76
85
|
const context = await browser.newContext({
|
|
77
|
-
viewport
|
|
86
|
+
viewport,
|
|
78
87
|
deviceScaleFactor: 1,
|
|
79
88
|
reducedMotion: "reduce",
|
|
80
89
|
forcedColors: "none",
|
|
@@ -119,12 +128,39 @@ async function openStableContext(opts) {
|
|
|
119
128
|
},
|
|
120
129
|
{ frozenNow: FROZEN_NOW }
|
|
121
130
|
);
|
|
122
|
-
|
|
131
|
+
return context;
|
|
132
|
+
}
|
|
133
|
+
async function acquireStableContext(viewport) {
|
|
134
|
+
const key = viewportKey(viewport);
|
|
135
|
+
const pool = contextPool.get(key);
|
|
136
|
+
if (pool && pool.length > 0) {
|
|
137
|
+
const context2 = pool.pop();
|
|
138
|
+
return { context: context2, viewport };
|
|
139
|
+
}
|
|
140
|
+
const context = await createStableContext(viewport);
|
|
141
|
+
return { context, viewport };
|
|
142
|
+
}
|
|
143
|
+
async function releaseStableContext(handle) {
|
|
144
|
+
const key = viewportKey(handle.viewport);
|
|
145
|
+
if (!cachedBrowser?.isConnected()) {
|
|
146
|
+
await handle.context.close().catch(() => {
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const pool = contextPool.get(key);
|
|
151
|
+
if (pool) {
|
|
152
|
+
pool.push(handle.context);
|
|
153
|
+
} else {
|
|
154
|
+
contextPool.set(key, [handle.context]);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async function openStablePage(handle) {
|
|
158
|
+
const page = await handle.context.newPage();
|
|
123
159
|
const injectStability = () => page.addStyleTag({ content: STABILITY_CSS }).catch(() => {
|
|
124
160
|
});
|
|
125
161
|
await injectStability();
|
|
126
162
|
page.on("load", injectStability);
|
|
127
|
-
return
|
|
163
|
+
return page;
|
|
128
164
|
}
|
|
129
165
|
async function waitForStability(page, waitFor) {
|
|
130
166
|
for (const w of waitFor) {
|
|
@@ -254,6 +290,7 @@ var paths = (cwd = process.cwd()) => {
|
|
|
254
290
|
baselines: path2__default.default.join(root, "baselines"),
|
|
255
291
|
actual: path2__default.default.join(root, "actual"),
|
|
256
292
|
judgments: path2__default.default.join(root, "judgments"),
|
|
293
|
+
checkpoints: path2__default.default.join(root, "checkpoints"),
|
|
257
294
|
summary: path2__default.default.join(root, "summary.md"),
|
|
258
295
|
gitignore: path2__default.default.join(root, ".gitignore"),
|
|
259
296
|
serverLog: path2__default.default.join(root, "dev-server.log"),
|
|
@@ -267,7 +304,8 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
|
267
304
|
const waitFor = opts.waitFor ?? DEFAULT_WAIT_FOR;
|
|
268
305
|
const masks = opts.mask ?? [];
|
|
269
306
|
const fullPage = opts.fullPage ?? DEFAULT_FULL_PAGE;
|
|
270
|
-
const
|
|
307
|
+
const handle = await acquireStableContext(viewport);
|
|
308
|
+
const page = await openStablePage(handle);
|
|
271
309
|
try {
|
|
272
310
|
const url = new URL(opts.url, baseUrl).toString();
|
|
273
311
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 3e4 });
|
|
@@ -289,8 +327,9 @@ async function captureScreenshot(baseUrl, opts, cwd = process.cwd()) {
|
|
|
289
327
|
});
|
|
290
328
|
return { id: opts.id, outputPath, mode: opts.mode, bytes: buffer.length };
|
|
291
329
|
} finally {
|
|
292
|
-
await
|
|
330
|
+
await page.close().catch(() => {
|
|
293
331
|
});
|
|
332
|
+
await releaseStableContext(handle);
|
|
294
333
|
}
|
|
295
334
|
}
|
|
296
335
|
async function loadConfig(cwd = process.cwd()) {
|
|
@@ -314,6 +353,30 @@ function resolveBaseUrl(config, override) {
|
|
|
314
353
|
throw new Error("no baseUrl: pass --base-url or run `blazediff-agent init`");
|
|
315
354
|
}
|
|
316
355
|
|
|
356
|
+
// src/graph/semaphore.ts
|
|
357
|
+
var Semaphore = class {
|
|
358
|
+
constructor(limit) {
|
|
359
|
+
this.limit = limit;
|
|
360
|
+
this.current = 0;
|
|
361
|
+
this.queue = [];
|
|
362
|
+
if (limit < 1)
|
|
363
|
+
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
364
|
+
}
|
|
365
|
+
async run(fn) {
|
|
366
|
+
if (this.current >= this.limit) {
|
|
367
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
368
|
+
}
|
|
369
|
+
this.current++;
|
|
370
|
+
try {
|
|
371
|
+
return await fn();
|
|
372
|
+
} finally {
|
|
373
|
+
this.current--;
|
|
374
|
+
const next = this.queue.shift();
|
|
375
|
+
if (next) next();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
|
|
317
380
|
// src/types.ts
|
|
318
381
|
var STABILITY_HOOKS_VERSION = 1;
|
|
319
382
|
|
|
@@ -439,55 +502,62 @@ async function runCaptures(opts) {
|
|
|
439
502
|
});
|
|
440
503
|
let manifest = writeManifest ? await loadOrCreateManifest(cwd) : null;
|
|
441
504
|
let manifestUpdates = 0;
|
|
505
|
+
const slot = new Array(valid.length);
|
|
506
|
+
const semaphore = new Semaphore(opts.concurrency ?? defaultConcurrency());
|
|
442
507
|
try {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
mode,
|
|
463
|
-
ok: true,
|
|
464
|
-
outputPath: shot.outputPath,
|
|
465
|
-
bytes: shot.bytes
|
|
466
|
-
});
|
|
467
|
-
if (manifest && mode === "baseline") {
|
|
468
|
-
manifest = addOrReplaceEntry(
|
|
469
|
-
manifest,
|
|
470
|
-
makeEntry({
|
|
508
|
+
await Promise.all(
|
|
509
|
+
valid.map(
|
|
510
|
+
(r, i) => semaphore.run(async () => {
|
|
511
|
+
const mode = r.mode ?? defaultMode;
|
|
512
|
+
try {
|
|
513
|
+
const shot = await captureScreenshot(
|
|
514
|
+
opts.baseUrl,
|
|
515
|
+
{
|
|
516
|
+
id: r.id,
|
|
517
|
+
url: r.url,
|
|
518
|
+
viewport: r.viewport,
|
|
519
|
+
mask: r.mask,
|
|
520
|
+
waitFor: r.waitFor,
|
|
521
|
+
fullPage: r.fullPage,
|
|
522
|
+
mode
|
|
523
|
+
},
|
|
524
|
+
cwd
|
|
525
|
+
);
|
|
526
|
+
slot[i] = {
|
|
471
527
|
id: r.id,
|
|
472
528
|
url: r.url,
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
529
|
+
mode,
|
|
530
|
+
ok: true,
|
|
531
|
+
outputPath: shot.outputPath,
|
|
532
|
+
bytes: shot.bytes
|
|
533
|
+
};
|
|
534
|
+
if (mode === "baseline" && manifest) {
|
|
535
|
+
manifest = addOrReplaceEntry(
|
|
536
|
+
manifest,
|
|
537
|
+
makeEntry({
|
|
538
|
+
id: r.id,
|
|
539
|
+
url: r.url,
|
|
540
|
+
viewport: r.viewport,
|
|
541
|
+
mask: r.mask,
|
|
542
|
+
waitFor: r.waitFor,
|
|
543
|
+
fullPage: r.fullPage
|
|
544
|
+
})
|
|
545
|
+
);
|
|
546
|
+
manifestUpdates += 1;
|
|
547
|
+
}
|
|
548
|
+
} catch (err) {
|
|
549
|
+
slot[i] = {
|
|
550
|
+
id: r.id,
|
|
551
|
+
url: r.url,
|
|
552
|
+
mode,
|
|
553
|
+
ok: false,
|
|
554
|
+
error: err.message
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
})
|
|
558
|
+
)
|
|
559
|
+
);
|
|
560
|
+
for (const r of slot) results.push(r);
|
|
491
561
|
if (manifest && manifestUpdates > 0) await saveManifest(manifest, cwd);
|
|
492
562
|
} finally {
|
|
493
563
|
await closeBrowser();
|
|
@@ -508,6 +578,29 @@ function parseViewport(value) {
|
|
|
508
578
|
if (!w || !h) throw new Error(`invalid viewport: ${value} (expected WxH)`);
|
|
509
579
|
return { width: w, height: h };
|
|
510
580
|
}
|
|
581
|
+
function parsePositiveInteger(value, flagName) {
|
|
582
|
+
const parsed = Number(value);
|
|
583
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
584
|
+
throw new Error(`invalid ${flagName}: ${value} (expected integer >= 1)`);
|
|
585
|
+
}
|
|
586
|
+
return parsed;
|
|
587
|
+
}
|
|
588
|
+
function parsePort(value, flagName = "--port") {
|
|
589
|
+
const parsed = parsePositiveInteger(value, flagName);
|
|
590
|
+
if (parsed > 65535) {
|
|
591
|
+
throw new Error(
|
|
592
|
+
`invalid ${flagName}: ${value} (expected integer <= 65535)`
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
return parsed;
|
|
596
|
+
}
|
|
597
|
+
function parseThreshold(value, flagName = "--threshold") {
|
|
598
|
+
const parsed = Number(value);
|
|
599
|
+
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
|
|
600
|
+
throw new Error(`invalid ${flagName}: ${value} (expected number 0-1)`);
|
|
601
|
+
}
|
|
602
|
+
return parsed;
|
|
603
|
+
}
|
|
511
604
|
function parseMaskList(value) {
|
|
512
605
|
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
|
513
606
|
}
|
|
@@ -609,6 +702,7 @@ function registerCapture(program, out) {
|
|
|
609
702
|
var ENTRIES = [
|
|
610
703
|
"actual/",
|
|
611
704
|
"judgments/",
|
|
705
|
+
"checkpoints/",
|
|
612
706
|
"summary.md",
|
|
613
707
|
"dev-server.log",
|
|
614
708
|
"dev-server.pid",
|
|
@@ -640,343 +734,126 @@ ${missing.join("\n")}
|
|
|
640
734
|
`;
|
|
641
735
|
await promises.writeFile(file, body, "utf8");
|
|
642
736
|
}
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
reason: "file-not-exists"
|
|
673
|
-
};
|
|
737
|
+
var DEFAULT_TOP_N = 5;
|
|
738
|
+
var DEFAULT_PADDING = 16;
|
|
739
|
+
var DEFAULT_LOCATOR_MAX_WIDTH = 400;
|
|
740
|
+
var DEFAULT_GUTTER = 2;
|
|
741
|
+
var DEFAULT_ROW_GUTTER = 8;
|
|
742
|
+
var BG_WHITE = { r: 255, g: 255, b: 255 };
|
|
743
|
+
function padAndClamp(bbox, padding, imgWidth, imgHeight) {
|
|
744
|
+
const left = Math.max(0, Math.floor(bbox.x - padding));
|
|
745
|
+
const top = Math.max(0, Math.floor(bbox.y - padding));
|
|
746
|
+
const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
|
|
747
|
+
const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
|
|
748
|
+
return {
|
|
749
|
+
x: left,
|
|
750
|
+
y: top,
|
|
751
|
+
width: Math.max(1, right - left),
|
|
752
|
+
height: Math.max(1, bottom - top)
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
async function prepareTiles(opts) {
|
|
756
|
+
const topN = opts.topN ?? DEFAULT_TOP_N;
|
|
757
|
+
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
758
|
+
const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
|
|
759
|
+
const gutter = opts.gutter ?? DEFAULT_GUTTER;
|
|
760
|
+
const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
|
|
761
|
+
const diffMeta = await sharp__default.default(opts.diffPath).metadata();
|
|
762
|
+
const imgWidth = diffMeta.width ?? 0;
|
|
763
|
+
const imgHeight = diffMeta.height ?? 0;
|
|
764
|
+
if (!imgWidth || !imgHeight) {
|
|
765
|
+
throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
|
|
674
766
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
767
|
+
const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
|
|
768
|
+
const regionData = await Promise.all(
|
|
769
|
+
ranked.map(async (region) => {
|
|
770
|
+
const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
|
|
771
|
+
const extract = {
|
|
772
|
+
left: padded.x,
|
|
773
|
+
top: padded.y,
|
|
774
|
+
width: padded.width,
|
|
775
|
+
height: padded.height
|
|
776
|
+
};
|
|
777
|
+
const [base, actual] = await Promise.all([
|
|
778
|
+
sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
|
|
779
|
+
sharp__default.default(opts.actualPath).extract(extract).toBuffer()
|
|
780
|
+
]);
|
|
781
|
+
return { region, padded, base, actual };
|
|
782
|
+
})
|
|
783
|
+
);
|
|
784
|
+
let tilesName;
|
|
785
|
+
if (regionData.length > 0) {
|
|
786
|
+
const canvasWidth = Math.max(
|
|
787
|
+
...regionData.map((r) => r.padded.width * 2 + gutter)
|
|
788
|
+
);
|
|
789
|
+
const totalHeight = regionData.reduce(
|
|
790
|
+
(sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
|
|
791
|
+
0
|
|
792
|
+
);
|
|
793
|
+
const composites = [];
|
|
794
|
+
let y = 0;
|
|
795
|
+
for (let i = 0; i < regionData.length; i++) {
|
|
796
|
+
const r = regionData[i];
|
|
797
|
+
const w = r.padded.width;
|
|
798
|
+
composites.push(
|
|
799
|
+
{ input: r.base, left: 0, top: y },
|
|
800
|
+
{ input: r.actual, left: w + gutter, top: y }
|
|
801
|
+
);
|
|
802
|
+
y += r.padded.height;
|
|
803
|
+
if (i < regionData.length - 1) y += rowGutter;
|
|
804
|
+
}
|
|
805
|
+
tilesName = "regions.png";
|
|
806
|
+
await sharp__default.default({
|
|
807
|
+
create: {
|
|
808
|
+
width: canvasWidth,
|
|
809
|
+
height: totalHeight,
|
|
810
|
+
channels: 3,
|
|
811
|
+
background: BG_WHITE
|
|
812
|
+
}
|
|
813
|
+
}).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
|
|
684
814
|
}
|
|
685
|
-
const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
815
|
+
const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
|
|
816
|
+
const locW = Math.max(1, Math.round(imgWidth * scale));
|
|
817
|
+
const locH = Math.max(1, Math.round(imgHeight * scale));
|
|
818
|
+
const rects = opts.regions.map((r) => {
|
|
819
|
+
const x = Math.round(r.bbox.x * scale);
|
|
820
|
+
const y = Math.round(r.bbox.y * scale);
|
|
821
|
+
const w = Math.max(1, Math.round(r.bbox.width * scale));
|
|
822
|
+
const h = Math.max(1, Math.round(r.bbox.height * scale));
|
|
823
|
+
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
|
|
824
|
+
}).join("");
|
|
825
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
|
|
826
|
+
const locatorName = "locator.png";
|
|
827
|
+
await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
|
|
689
828
|
return {
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
diffCount: result.diffCount,
|
|
697
|
-
diffPercentage: result.diffPercentage,
|
|
698
|
-
interpretation
|
|
829
|
+
locatorPath: locatorName,
|
|
830
|
+
tilesPath: tilesName,
|
|
831
|
+
regions: regionData.map((r) => ({
|
|
832
|
+
bbox: r.region.bbox,
|
|
833
|
+
pixelCount: r.region.pixelCount
|
|
834
|
+
}))
|
|
699
835
|
};
|
|
700
836
|
}
|
|
701
837
|
|
|
702
|
-
// src/
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
for (const [type, n] of counts) {
|
|
722
|
-
if (n > bestN) {
|
|
723
|
-
best = type;
|
|
724
|
-
bestN = n;
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
return best;
|
|
728
|
-
}
|
|
729
|
-
function topPosition(regions) {
|
|
730
|
-
let best;
|
|
731
|
-
for (const r of regions)
|
|
732
|
-
if (!best || r.pixelCount > best.pixelCount) best = r;
|
|
733
|
-
return best?.position;
|
|
734
|
-
}
|
|
735
|
-
function meanConfidence(regions) {
|
|
736
|
-
if (regions.length === 0) return 0;
|
|
737
|
-
return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
|
|
738
|
-
}
|
|
739
|
-
function formatBreakdown(counts) {
|
|
740
|
-
return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
|
|
741
|
-
}
|
|
742
|
-
function buildHeadline(input) {
|
|
743
|
-
const { reason, interpretation, diffCount, diffPercentage } = input;
|
|
744
|
-
if (reason === "layout-diff") return "image dimensions changed";
|
|
745
|
-
if (reason === "file-not-exists") return "baseline or actual capture missing";
|
|
746
|
-
if (!interpretation || interpretation.regions.length === 0) {
|
|
747
|
-
const px = diffCount?.toLocaleString() ?? "?";
|
|
748
|
-
return `${px} px (${pctText(diffPercentage)}) - no region analysis`;
|
|
749
|
-
}
|
|
750
|
-
const regions = interpretation.regions;
|
|
751
|
-
const counts = countByType(regions);
|
|
752
|
-
const pos = topPosition(regions);
|
|
753
|
-
const pct = pctText(diffPercentage ?? interpretation.diffPercentage);
|
|
754
|
-
const sev = interpretation.severity ?? "?";
|
|
755
|
-
if (regions.length === 1) {
|
|
756
|
-
return `1 ${dominantType(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
757
|
-
}
|
|
758
|
-
return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
759
|
-
}
|
|
760
|
-
function deriveVerdict(input) {
|
|
761
|
-
const { reason, interpretation, diffPercentage } = input;
|
|
762
|
-
const headline = buildHeadline(input);
|
|
763
|
-
if (reason === "layout-diff") {
|
|
764
|
-
return {
|
|
765
|
-
label: "ambiguous",
|
|
766
|
-
headline,
|
|
767
|
-
rationale: [
|
|
768
|
-
"baseline and actual image dimensions differ \u2014 page height likely shifted",
|
|
769
|
-
"could be intentional (content added/removed) or regression (broken layout)"
|
|
770
|
-
],
|
|
771
|
-
action: "investigate"
|
|
772
|
-
};
|
|
773
|
-
}
|
|
774
|
-
if (reason === "file-not-exists") {
|
|
775
|
-
return {
|
|
776
|
-
label: "regression-likely",
|
|
777
|
-
headline,
|
|
778
|
-
rationale: ["baseline or actual capture is missing from disk"],
|
|
779
|
-
action: "investigate"
|
|
780
|
-
};
|
|
781
|
-
}
|
|
782
|
-
if (!interpretation || interpretation.regions.length === 0) {
|
|
783
|
-
return {
|
|
784
|
-
label: "ambiguous",
|
|
785
|
-
headline,
|
|
786
|
-
rationale: ["pixels differ but interpret returned no regions"],
|
|
787
|
-
action: "investigate"
|
|
788
|
-
};
|
|
789
|
-
}
|
|
790
|
-
const regions = interpretation.regions;
|
|
791
|
-
const severity = interpretation.severity;
|
|
792
|
-
const counts = countByType(regions);
|
|
793
|
-
const allNoise = regions.every((r) => NOISE_TYPES.has(r.changeType));
|
|
794
|
-
const allColor = regions.every((r) => r.changeType === "color-change");
|
|
795
|
-
const allMoved = regions.every((r) => INTENTIONAL_TYPES.has(r.changeType));
|
|
796
|
-
const hasRegressive = regions.some((r) => REGRESSIVE_TYPES.has(r.changeType));
|
|
797
|
-
const pct = typeof diffPercentage === "number" ? diffPercentage : interpretation.diffPercentage;
|
|
798
|
-
if (allNoise) {
|
|
799
|
-
return {
|
|
800
|
-
label: "noise-likely",
|
|
801
|
-
headline,
|
|
802
|
-
rationale: ["all regions classified as rendering-noise"],
|
|
803
|
-
action: "ignore-or-rewrite"
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
if (typeof pct === "number" && pct < SUB_PERCEPTUAL_PCT && severity === "low") {
|
|
807
|
-
return {
|
|
808
|
-
label: "noise-likely",
|
|
809
|
-
headline,
|
|
810
|
-
rationale: [
|
|
811
|
-
`delta < ${SUB_PERCEPTUAL_PCT}% (got ${pctText(pct)}) at "low" severity`,
|
|
812
|
-
"sub-perceptual change - review optional"
|
|
813
|
-
],
|
|
814
|
-
action: "ignore-or-rewrite"
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
if (hasRegressive && ELEVATED_SEVERITY.has(severity ?? "")) {
|
|
818
|
-
const types = [...counts].filter(([t]) => REGRESSIVE_TYPES.has(t)).map(([t, n]) => `${n} ${t}`).join(", ");
|
|
819
|
-
return {
|
|
820
|
-
label: "regression-likely",
|
|
821
|
-
headline,
|
|
822
|
-
rationale: [
|
|
823
|
-
`severity ${severity} with structural changes (${types})`,
|
|
824
|
-
"likely affects content or layout, not just styling"
|
|
825
|
-
],
|
|
826
|
-
action: "investigate"
|
|
827
|
-
};
|
|
828
|
-
}
|
|
829
|
-
if (allColor && meanConfidence(regions) > 0.7) {
|
|
830
|
-
return {
|
|
831
|
-
label: "intentional-likely",
|
|
832
|
-
headline,
|
|
833
|
-
rationale: [
|
|
834
|
-
`${regions.length} color-change region${regions.length === 1 ? "" : "s"} with mean confidence > 0.7`,
|
|
835
|
-
"edge structure preserved - looks like a theming / palette change"
|
|
836
|
-
],
|
|
837
|
-
action: "rewrite-if-intended"
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
if (allMoved && !allColor) {
|
|
841
|
-
return {
|
|
842
|
-
label: "intentional-likely",
|
|
843
|
-
headline,
|
|
844
|
-
rationale: [
|
|
845
|
-
"all regions are shift/color-change - content moved or restyled, structure preserved"
|
|
846
|
-
],
|
|
847
|
-
action: "rewrite-if-intended"
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
return {
|
|
851
|
-
label: "ambiguous",
|
|
852
|
-
headline,
|
|
853
|
-
rationale: [
|
|
854
|
-
`mix of change types (${formatBreakdown(counts)}) at "${severity ?? "?"}" severity`,
|
|
855
|
-
`${pctText(pct)} of image differs`
|
|
856
|
-
],
|
|
857
|
-
action: "investigate"
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
var DEFAULT_TOP_N = 5;
|
|
861
|
-
var DEFAULT_PADDING = 16;
|
|
862
|
-
var DEFAULT_LOCATOR_MAX_WIDTH = 400;
|
|
863
|
-
var DEFAULT_GUTTER = 2;
|
|
864
|
-
var DEFAULT_ROW_GUTTER = 8;
|
|
865
|
-
var BG_WHITE = { r: 255, g: 255, b: 255 };
|
|
866
|
-
function padAndClamp(bbox, padding, imgWidth, imgHeight) {
|
|
867
|
-
const left = Math.max(0, Math.floor(bbox.x - padding));
|
|
868
|
-
const top = Math.max(0, Math.floor(bbox.y - padding));
|
|
869
|
-
const right = Math.min(imgWidth, Math.ceil(bbox.x + bbox.width + padding));
|
|
870
|
-
const bottom = Math.min(imgHeight, Math.ceil(bbox.y + bbox.height + padding));
|
|
871
|
-
return {
|
|
872
|
-
x: left,
|
|
873
|
-
y: top,
|
|
874
|
-
width: Math.max(1, right - left),
|
|
875
|
-
height: Math.max(1, bottom - top)
|
|
876
|
-
};
|
|
877
|
-
}
|
|
878
|
-
async function prepareTiles(opts) {
|
|
879
|
-
const topN = opts.topN ?? DEFAULT_TOP_N;
|
|
880
|
-
const padding = opts.padding ?? DEFAULT_PADDING;
|
|
881
|
-
const locatorMaxWidth = opts.locatorMaxWidth ?? DEFAULT_LOCATOR_MAX_WIDTH;
|
|
882
|
-
const gutter = opts.gutter ?? DEFAULT_GUTTER;
|
|
883
|
-
const rowGutter = opts.rowGutter ?? DEFAULT_ROW_GUTTER;
|
|
884
|
-
const diffMeta = await sharp__default.default(opts.diffPath).metadata();
|
|
885
|
-
const imgWidth = diffMeta.width ?? 0;
|
|
886
|
-
const imgHeight = diffMeta.height ?? 0;
|
|
887
|
-
if (!imgWidth || !imgHeight) {
|
|
888
|
-
throw new Error(`unable to read diff image dimensions: ${opts.diffPath}`);
|
|
889
|
-
}
|
|
890
|
-
const ranked = [...opts.regions].sort((a, b) => b.pixelCount - a.pixelCount).slice(0, topN);
|
|
891
|
-
const regionData = await Promise.all(
|
|
892
|
-
ranked.map(async (region) => {
|
|
893
|
-
const padded = padAndClamp(region.bbox, padding, imgWidth, imgHeight);
|
|
894
|
-
const extract = {
|
|
895
|
-
left: padded.x,
|
|
896
|
-
top: padded.y,
|
|
897
|
-
width: padded.width,
|
|
898
|
-
height: padded.height
|
|
899
|
-
};
|
|
900
|
-
const [base, actual] = await Promise.all([
|
|
901
|
-
sharp__default.default(opts.baselinePath).extract(extract).toBuffer(),
|
|
902
|
-
sharp__default.default(opts.actualPath).extract(extract).toBuffer()
|
|
903
|
-
]);
|
|
904
|
-
return { region, padded, base, actual };
|
|
905
|
-
})
|
|
906
|
-
);
|
|
907
|
-
let tilesName;
|
|
908
|
-
if (regionData.length > 0) {
|
|
909
|
-
const canvasWidth = Math.max(
|
|
910
|
-
...regionData.map((r) => r.padded.width * 2 + gutter)
|
|
911
|
-
);
|
|
912
|
-
const totalHeight = regionData.reduce(
|
|
913
|
-
(sum, r, i) => sum + r.padded.height + (i > 0 ? rowGutter : 0),
|
|
914
|
-
0
|
|
915
|
-
);
|
|
916
|
-
const composites = [];
|
|
917
|
-
let y = 0;
|
|
918
|
-
for (let i = 0; i < regionData.length; i++) {
|
|
919
|
-
const r = regionData[i];
|
|
920
|
-
const w = r.padded.width;
|
|
921
|
-
composites.push(
|
|
922
|
-
{ input: r.base, left: 0, top: y },
|
|
923
|
-
{ input: r.actual, left: w + gutter, top: y }
|
|
924
|
-
);
|
|
925
|
-
y += r.padded.height;
|
|
926
|
-
if (i < regionData.length - 1) y += rowGutter;
|
|
927
|
-
}
|
|
928
|
-
tilesName = "regions.png";
|
|
929
|
-
await sharp__default.default({
|
|
930
|
-
create: {
|
|
931
|
-
width: canvasWidth,
|
|
932
|
-
height: totalHeight,
|
|
933
|
-
channels: 3,
|
|
934
|
-
background: BG_WHITE
|
|
935
|
-
}
|
|
936
|
-
}).composite(composites).png().toFile(path2__default.default.join(opts.outputDir, tilesName));
|
|
937
|
-
}
|
|
938
|
-
const scale = locatorMaxWidth / Math.max(imgWidth, imgHeight);
|
|
939
|
-
const locW = Math.max(1, Math.round(imgWidth * scale));
|
|
940
|
-
const locH = Math.max(1, Math.round(imgHeight * scale));
|
|
941
|
-
const rects = opts.regions.map((r) => {
|
|
942
|
-
const x = Math.round(r.bbox.x * scale);
|
|
943
|
-
const y = Math.round(r.bbox.y * scale);
|
|
944
|
-
const w = Math.max(1, Math.round(r.bbox.width * scale));
|
|
945
|
-
const h = Math.max(1, Math.round(r.bbox.height * scale));
|
|
946
|
-
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="none" stroke="red" stroke-width="2" />`;
|
|
947
|
-
}).join("");
|
|
948
|
-
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${locW}" height="${locH}">${rects}</svg>`;
|
|
949
|
-
const locatorName = "locator.png";
|
|
950
|
-
await sharp__default.default(opts.diffPath).resize(locW, locH, { fit: "fill" }).composite([{ input: Buffer.from(svg), left: 0, top: 0 }]).png().toFile(path2__default.default.join(opts.outputDir, locatorName));
|
|
951
|
-
return {
|
|
952
|
-
locatorPath: locatorName,
|
|
953
|
-
tilesPath: tilesName,
|
|
954
|
-
regions: regionData.map((r) => ({
|
|
955
|
-
bbox: r.region.bbox,
|
|
956
|
-
pixelCount: r.region.pixelCount
|
|
957
|
-
}))
|
|
958
|
-
};
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
// src/judge/host-harness.ts
|
|
962
|
-
async function tryPrepareTiles(input, entryDir) {
|
|
963
|
-
if (!input.regions || input.regions.length === 0 || !input.diffPath) {
|
|
964
|
-
return null;
|
|
965
|
-
}
|
|
966
|
-
try {
|
|
967
|
-
return await prepareTiles({
|
|
968
|
-
regions: input.regions,
|
|
969
|
-
baselinePath: input.baselinePath,
|
|
970
|
-
actualPath: input.actualPath,
|
|
971
|
-
diffPath: input.diffPath,
|
|
972
|
-
outputDir: entryDir
|
|
973
|
-
});
|
|
974
|
-
} catch (err) {
|
|
975
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
976
|
-
console.warn(
|
|
977
|
-
`[blazediff] tile generation failed for ${input.entry.id}: ${message}`
|
|
978
|
-
);
|
|
979
|
-
return null;
|
|
838
|
+
// src/judge/host-harness.ts
|
|
839
|
+
async function tryPrepareTiles(input, entryDir) {
|
|
840
|
+
if (!input.regions || input.regions.length === 0 || !input.diffPath) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
try {
|
|
844
|
+
return await prepareTiles({
|
|
845
|
+
regions: input.regions,
|
|
846
|
+
baselinePath: input.baselinePath,
|
|
847
|
+
actualPath: input.actualPath,
|
|
848
|
+
diffPath: input.diffPath,
|
|
849
|
+
outputDir: entryDir
|
|
850
|
+
});
|
|
851
|
+
} catch (err) {
|
|
852
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
853
|
+
console.warn(
|
|
854
|
+
`[blazediff] tile generation failed for ${input.entry.id}: ${message}`
|
|
855
|
+
);
|
|
856
|
+
return null;
|
|
980
857
|
}
|
|
981
858
|
}
|
|
982
859
|
var hostHarnessJudge = {
|
|
@@ -997,22 +874,299 @@ var noneJudge = {
|
|
|
997
874
|
return { kind: "judged", verdict: input.heuristicVerdict };
|
|
998
875
|
}
|
|
999
876
|
};
|
|
1000
|
-
var
|
|
1001
|
-
function
|
|
1002
|
-
return
|
|
1003
|
-
}
|
|
1004
|
-
function toBlazediffRel(cwd, abs) {
|
|
1005
|
-
if (!abs) return void 0;
|
|
1006
|
-
const root = paths(cwd).root;
|
|
1007
|
-
const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
|
|
1008
|
-
return rel.split(path2__default.default.sep).join("/");
|
|
877
|
+
var ROOT_NS_SENTINEL = "_root";
|
|
878
|
+
function nsDir(ns) {
|
|
879
|
+
return ns === "" ? ROOT_NS_SENTINEL : ns;
|
|
1009
880
|
}
|
|
1010
|
-
function
|
|
1011
|
-
return
|
|
881
|
+
function encode(buf) {
|
|
882
|
+
return Buffer.from(buf).toString("base64");
|
|
1012
883
|
}
|
|
1013
|
-
function
|
|
1014
|
-
|
|
1015
|
-
|
|
884
|
+
function decode(s) {
|
|
885
|
+
return Buffer.from(s, "base64");
|
|
886
|
+
}
|
|
887
|
+
async function readJson(file) {
|
|
888
|
+
try {
|
|
889
|
+
const raw = await promises.readFile(file, "utf8");
|
|
890
|
+
return JSON.parse(raw);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
if (err.code === "ENOENT") return void 0;
|
|
893
|
+
throw err;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function writeJsonAtomic(file, value) {
|
|
897
|
+
const tmp = `${file}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
898
|
+
await promises.writeFile(tmp, JSON.stringify(value), "utf8");
|
|
899
|
+
await promises.rename(tmp, file);
|
|
900
|
+
}
|
|
901
|
+
var FsCheckpointSaver = class extends langgraphCheckpoint.BaseCheckpointSaver {
|
|
902
|
+
constructor(root, serde) {
|
|
903
|
+
super(serde);
|
|
904
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
905
|
+
this.root = root;
|
|
906
|
+
}
|
|
907
|
+
threadDir(thread) {
|
|
908
|
+
return path2__default.default.join(this.root, thread);
|
|
909
|
+
}
|
|
910
|
+
nsPath(thread, ns) {
|
|
911
|
+
return path2__default.default.join(this.threadDir(thread), nsDir(ns));
|
|
912
|
+
}
|
|
913
|
+
ckptFile(thread, ns, id) {
|
|
914
|
+
return path2__default.default.join(this.nsPath(thread, ns), `${id}.ckpt.json`);
|
|
915
|
+
}
|
|
916
|
+
writesFile(thread, ns, id) {
|
|
917
|
+
return path2__default.default.join(this.nsPath(thread, ns), `${id}.writes.json`);
|
|
918
|
+
}
|
|
919
|
+
async withLock(key, fn) {
|
|
920
|
+
const prev = this.locks.get(key) ?? Promise.resolve();
|
|
921
|
+
const next = prev.then(fn, fn);
|
|
922
|
+
this.locks.set(key, next);
|
|
923
|
+
try {
|
|
924
|
+
return await next;
|
|
925
|
+
} finally {
|
|
926
|
+
if (this.locks.get(key) === next) this.locks.delete(key);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async listCheckpointIds(thread, ns) {
|
|
930
|
+
try {
|
|
931
|
+
const names = await promises.readdir(this.nsPath(thread, ns));
|
|
932
|
+
const suffix = ".ckpt.json";
|
|
933
|
+
return names.filter((n) => n.endsWith(suffix)).map((n) => n.slice(0, -suffix.length));
|
|
934
|
+
} catch (err) {
|
|
935
|
+
if (err.code === "ENOENT") return [];
|
|
936
|
+
throw err;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
async listNamespaces(thread) {
|
|
940
|
+
try {
|
|
941
|
+
return await promises.readdir(this.threadDir(thread));
|
|
942
|
+
} catch (err) {
|
|
943
|
+
if (err.code === "ENOENT") return [];
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async listThreads() {
|
|
948
|
+
try {
|
|
949
|
+
return await promises.readdir(this.root);
|
|
950
|
+
} catch (err) {
|
|
951
|
+
if (err.code === "ENOENT") return [];
|
|
952
|
+
throw err;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
decodeNs(nsDirName) {
|
|
956
|
+
return nsDirName === ROOT_NS_SENTINEL ? "" : nsDirName;
|
|
957
|
+
}
|
|
958
|
+
async loadPendingWrites(thread, ns, ckptId) {
|
|
959
|
+
const data = await readJson(
|
|
960
|
+
this.writesFile(thread, ns, ckptId)
|
|
961
|
+
);
|
|
962
|
+
if (!data) return [];
|
|
963
|
+
const out = [];
|
|
964
|
+
for (const [taskId, channel, serialized] of Object.values(data)) {
|
|
965
|
+
const value = await this.serde.loadsTyped("json", decode(serialized));
|
|
966
|
+
out.push([taskId, channel, value]);
|
|
967
|
+
}
|
|
968
|
+
return out;
|
|
969
|
+
}
|
|
970
|
+
async migratePendingSends(checkpoint, thread, ns, parentCheckpointId) {
|
|
971
|
+
const data = await readJson(
|
|
972
|
+
this.writesFile(thread, ns, parentCheckpointId)
|
|
973
|
+
);
|
|
974
|
+
const pendingSends = data ? await Promise.all(
|
|
975
|
+
Object.values(data).filter(([, channel]) => channel === langgraphCheckpoint.TASKS).map(
|
|
976
|
+
([, , serialized]) => this.serde.loadsTyped("json", decode(serialized))
|
|
977
|
+
)
|
|
978
|
+
) : [];
|
|
979
|
+
const m = checkpoint;
|
|
980
|
+
m.channel_values ?? (m.channel_values = {});
|
|
981
|
+
m.channel_values[langgraphCheckpoint.TASKS] = pendingSends;
|
|
982
|
+
m.channel_versions ?? (m.channel_versions = {});
|
|
983
|
+
const versions = Object.values(m.channel_versions);
|
|
984
|
+
m.channel_versions[langgraphCheckpoint.TASKS] = versions.length > 0 ? langgraphCheckpoint.maxChannelVersion(...versions) : this.getNextVersion(void 0);
|
|
985
|
+
}
|
|
986
|
+
async readTuple(thread, ns, ckptId, config) {
|
|
987
|
+
const data = await readJson(this.ckptFile(thread, ns, ckptId));
|
|
988
|
+
if (!data) return void 0;
|
|
989
|
+
const checkpoint = await this.serde.loadsTyped(
|
|
990
|
+
"json",
|
|
991
|
+
decode(data.checkpoint)
|
|
992
|
+
);
|
|
993
|
+
const metadata = await this.serde.loadsTyped(
|
|
994
|
+
"json",
|
|
995
|
+
decode(data.metadata)
|
|
996
|
+
);
|
|
997
|
+
if (checkpoint.v < 4 && data.parentCheckpointId !== void 0) {
|
|
998
|
+
await this.migratePendingSends(
|
|
999
|
+
checkpoint,
|
|
1000
|
+
thread,
|
|
1001
|
+
ns,
|
|
1002
|
+
data.parentCheckpointId
|
|
1003
|
+
);
|
|
1004
|
+
}
|
|
1005
|
+
const pendingWrites = await this.loadPendingWrites(thread, ns, ckptId);
|
|
1006
|
+
const tuple = {
|
|
1007
|
+
config,
|
|
1008
|
+
checkpoint,
|
|
1009
|
+
metadata,
|
|
1010
|
+
pendingWrites
|
|
1011
|
+
};
|
|
1012
|
+
if (data.parentCheckpointId !== void 0) {
|
|
1013
|
+
tuple.parentConfig = {
|
|
1014
|
+
configurable: {
|
|
1015
|
+
thread_id: thread,
|
|
1016
|
+
checkpoint_ns: ns,
|
|
1017
|
+
checkpoint_id: data.parentCheckpointId
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return tuple;
|
|
1022
|
+
}
|
|
1023
|
+
async getTuple(config) {
|
|
1024
|
+
const thread = config.configurable?.thread_id;
|
|
1025
|
+
if (!thread) return void 0;
|
|
1026
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1027
|
+
const explicitId = langgraphCheckpoint.getCheckpointId(config);
|
|
1028
|
+
if (explicitId) {
|
|
1029
|
+
return this.readTuple(thread, ns, explicitId, config);
|
|
1030
|
+
}
|
|
1031
|
+
const ids = (await this.listCheckpointIds(thread, ns)).sort(
|
|
1032
|
+
(a, b) => b.localeCompare(a)
|
|
1033
|
+
);
|
|
1034
|
+
if (ids.length === 0) return void 0;
|
|
1035
|
+
const ckptId = ids[0];
|
|
1036
|
+
return this.readTuple(thread, ns, ckptId, {
|
|
1037
|
+
configurable: {
|
|
1038
|
+
thread_id: thread,
|
|
1039
|
+
checkpoint_ns: ns,
|
|
1040
|
+
checkpoint_id: ckptId
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
async *list(config, options) {
|
|
1045
|
+
const filter = options?.filter;
|
|
1046
|
+
const before = options?.before;
|
|
1047
|
+
let limit = options?.limit;
|
|
1048
|
+
const configThread = config.configurable?.thread_id;
|
|
1049
|
+
const configNs = config.configurable?.checkpoint_ns;
|
|
1050
|
+
const configCkptId = config.configurable?.checkpoint_id;
|
|
1051
|
+
const threads = configThread ? [configThread] : await this.listThreads();
|
|
1052
|
+
for (const thread of threads) {
|
|
1053
|
+
const nsNames = await this.listNamespaces(thread);
|
|
1054
|
+
for (const nsName of nsNames) {
|
|
1055
|
+
const ns = this.decodeNs(nsName);
|
|
1056
|
+
if (configNs !== void 0 && ns !== configNs) continue;
|
|
1057
|
+
const ids = (await this.listCheckpointIds(thread, ns)).sort(
|
|
1058
|
+
(a, b) => b.localeCompare(a)
|
|
1059
|
+
);
|
|
1060
|
+
for (const ckptId of ids) {
|
|
1061
|
+
if (configCkptId && ckptId !== configCkptId) continue;
|
|
1062
|
+
if (before?.configurable?.checkpoint_id && ckptId >= before.configurable.checkpoint_id)
|
|
1063
|
+
continue;
|
|
1064
|
+
const tuple = await this.readTuple(thread, ns, ckptId, {
|
|
1065
|
+
configurable: {
|
|
1066
|
+
thread_id: thread,
|
|
1067
|
+
checkpoint_ns: ns,
|
|
1068
|
+
checkpoint_id: ckptId
|
|
1069
|
+
}
|
|
1070
|
+
});
|
|
1071
|
+
if (!tuple) continue;
|
|
1072
|
+
if (filter) {
|
|
1073
|
+
const md = tuple.metadata;
|
|
1074
|
+
const matches = Object.entries(filter).every(
|
|
1075
|
+
([k, v]) => md?.[k] === v
|
|
1076
|
+
);
|
|
1077
|
+
if (!matches) continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (limit !== void 0) {
|
|
1080
|
+
if (limit <= 0) return;
|
|
1081
|
+
limit -= 1;
|
|
1082
|
+
}
|
|
1083
|
+
yield tuple;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
async put(config, checkpoint, metadata) {
|
|
1089
|
+
const thread = config.configurable?.thread_id;
|
|
1090
|
+
if (!thread) {
|
|
1091
|
+
throw new Error(
|
|
1092
|
+
'FsCheckpointSaver: missing "thread_id" in configurable. Pass `{ configurable: { thread_id } }` when streaming.'
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1096
|
+
const parentCheckpointId = config.configurable?.checkpoint_id;
|
|
1097
|
+
const prepared = langgraphCheckpoint.copyCheckpoint(checkpoint);
|
|
1098
|
+
const [[, serializedCheckpoint], [, serializedMetadata]] = await Promise.all([
|
|
1099
|
+
this.serde.dumpsTyped(prepared),
|
|
1100
|
+
this.serde.dumpsTyped(metadata)
|
|
1101
|
+
]);
|
|
1102
|
+
await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
|
|
1103
|
+
const file = this.ckptFile(thread, ns, checkpoint.id);
|
|
1104
|
+
const body = {
|
|
1105
|
+
checkpoint: encode(serializedCheckpoint),
|
|
1106
|
+
metadata: encode(serializedMetadata),
|
|
1107
|
+
parentCheckpointId
|
|
1108
|
+
};
|
|
1109
|
+
await writeJsonAtomic(file, body);
|
|
1110
|
+
return {
|
|
1111
|
+
configurable: {
|
|
1112
|
+
thread_id: thread,
|
|
1113
|
+
checkpoint_ns: ns,
|
|
1114
|
+
checkpoint_id: checkpoint.id
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async putWrites(config, writes, taskId) {
|
|
1119
|
+
const thread = config.configurable?.thread_id;
|
|
1120
|
+
if (!thread) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
'FsCheckpointSaver: missing "thread_id" in configurable for putWrites.'
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
const ns = config.configurable?.checkpoint_ns ?? "";
|
|
1126
|
+
const ckptId = config.configurable?.checkpoint_id;
|
|
1127
|
+
if (!ckptId) {
|
|
1128
|
+
throw new Error(
|
|
1129
|
+
'FsCheckpointSaver: missing "checkpoint_id" in configurable for putWrites.'
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
const key = `${thread}|${ns}|${ckptId}`;
|
|
1133
|
+
await this.withLock(key, async () => {
|
|
1134
|
+
const existing = await readJson(this.writesFile(thread, ns, ckptId)) ?? {};
|
|
1135
|
+
let mutated = false;
|
|
1136
|
+
for (let idx = 0; idx < writes.length; idx++) {
|
|
1137
|
+
const [channel, value] = writes[idx];
|
|
1138
|
+
const writeIdx = langgraphCheckpoint.WRITES_IDX_MAP[channel] ?? idx;
|
|
1139
|
+
const innerKey = `${taskId},${writeIdx}`;
|
|
1140
|
+
if (writeIdx >= 0 && innerKey in existing) continue;
|
|
1141
|
+
const [, serialized] = await this.serde.dumpsTyped(value);
|
|
1142
|
+
existing[innerKey] = [taskId, channel, encode(serialized)];
|
|
1143
|
+
mutated = true;
|
|
1144
|
+
}
|
|
1145
|
+
if (!mutated) return;
|
|
1146
|
+
await promises.mkdir(this.nsPath(thread, ns), { recursive: true });
|
|
1147
|
+
await writeJsonAtomic(this.writesFile(thread, ns, ckptId), existing);
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
async deleteThread(threadId) {
|
|
1151
|
+
await promises.rm(this.threadDir(threadId), { recursive: true, force: true });
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
var PREVIEW_WIDTH = 320;
|
|
1155
|
+
function escapeCell(s) {
|
|
1156
|
+
return s.replace(/\n/g, " ");
|
|
1157
|
+
}
|
|
1158
|
+
function toBlazediffRel(cwd, abs) {
|
|
1159
|
+
if (!abs) return void 0;
|
|
1160
|
+
const root = paths(cwd).root;
|
|
1161
|
+
const rel = path2__default.default.isAbsolute(abs) ? path2__default.default.relative(root, abs) : path2__default.default.relative(paths(cwd).root, path2__default.default.join(cwd, abs));
|
|
1162
|
+
return rel.split(path2__default.default.sep).join("/");
|
|
1163
|
+
}
|
|
1164
|
+
function img(src, alt) {
|
|
1165
|
+
return `<img src="${src}" width="${PREVIEW_WIDTH}" alt="${alt}">`;
|
|
1166
|
+
}
|
|
1167
|
+
function baselineCell(r, cwd) {
|
|
1168
|
+
const rel = toBlazediffRel(cwd, r.baselinePath) ?? `baselines/${r.id}.png`;
|
|
1169
|
+
return img(rel, `${r.id} baseline`);
|
|
1016
1170
|
}
|
|
1017
1171
|
function actualCell(r, cwd) {
|
|
1018
1172
|
const actual = toBlazediffRel(cwd, r.actualPath);
|
|
@@ -1109,6 +1263,14 @@ function parseVerdict(raw) {
|
|
|
1109
1263
|
confidence: typeof r.confidence === "number" ? r.confidence : void 0
|
|
1110
1264
|
};
|
|
1111
1265
|
}
|
|
1266
|
+
async function fileExists(p) {
|
|
1267
|
+
try {
|
|
1268
|
+
await promises.access(p);
|
|
1269
|
+
return true;
|
|
1270
|
+
} catch {
|
|
1271
|
+
return false;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1112
1274
|
async function readJsonOrNull(file) {
|
|
1113
1275
|
try {
|
|
1114
1276
|
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
@@ -1139,7 +1301,7 @@ async function readJudgmentDirs(root) {
|
|
|
1139
1301
|
const verdictFile = path2__default.default.join(dir, "verdict.json");
|
|
1140
1302
|
let verdict = null;
|
|
1141
1303
|
let verdictInvalid = false;
|
|
1142
|
-
if (
|
|
1304
|
+
if (await fileExists(verdictFile)) {
|
|
1143
1305
|
const raw = await readJsonOrNull(verdictFile);
|
|
1144
1306
|
verdict = raw ? parseVerdict(raw) : null;
|
|
1145
1307
|
if (raw && !verdict) verdictInvalid = true;
|
|
@@ -1152,7 +1314,7 @@ function toAbs(cwd, rel) {
|
|
|
1152
1314
|
if (!rel) return void 0;
|
|
1153
1315
|
return path2__default.default.isAbsolute(rel) ? rel : path2__default.default.join(cwd, rel);
|
|
1154
1316
|
}
|
|
1155
|
-
function
|
|
1317
|
+
function fromDiskResult(cwd, dir, entry) {
|
|
1156
1318
|
const req = dir.request;
|
|
1157
1319
|
const finalVerdict = dir.verdict?.verdict ?? req?.heuristicVerdict;
|
|
1158
1320
|
const status = req ? dir.verdict ? "fail" : req.status : "fail";
|
|
@@ -1171,7 +1333,7 @@ function buildResult(cwd, dir, entry) {
|
|
|
1171
1333
|
message
|
|
1172
1334
|
};
|
|
1173
1335
|
}
|
|
1174
|
-
function
|
|
1336
|
+
async function passResultFromDisk(entry, cwd) {
|
|
1175
1337
|
const baselineAbs = path2__default.default.join(paths(cwd).baselines, `${entry.id}.png`);
|
|
1176
1338
|
const actualAbs = path2__default.default.join(paths(cwd).actual, `${entry.id}.png`);
|
|
1177
1339
|
return {
|
|
@@ -1179,37 +1341,27 @@ function passResult(entry, cwd) {
|
|
|
1179
1341
|
url: entry.url,
|
|
1180
1342
|
status: "pass",
|
|
1181
1343
|
baselinePath: baselineAbs,
|
|
1182
|
-
actualPath:
|
|
1344
|
+
actualPath: await fileExists(actualAbs) ? actualAbs : void 0
|
|
1183
1345
|
};
|
|
1184
1346
|
}
|
|
1185
|
-
async function
|
|
1186
|
-
const p = paths(cwd);
|
|
1347
|
+
async function reconstructFromDisk(cwd, dirs) {
|
|
1187
1348
|
const manifest = await loadManifest(cwd);
|
|
1188
1349
|
if (!manifest) {
|
|
1189
1350
|
throw new Error(
|
|
1190
|
-
`no manifest at ${
|
|
1351
|
+
`no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
|
|
1191
1352
|
);
|
|
1192
1353
|
}
|
|
1193
|
-
const dirs = await readJudgmentDirs(p.judgments);
|
|
1194
1354
|
const dirById = new Map(dirs.map((d) => [d.id, d]));
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
const entry = manifest.entries.find((e) => e.id === d.id);
|
|
1206
|
-
nonPassResults.push(buildResult(cwd, d, entry));
|
|
1207
|
-
}
|
|
1208
|
-
const passResults = [];
|
|
1209
|
-
for (const entry of manifest.entries) {
|
|
1210
|
-
if (dirById.has(entry.id)) continue;
|
|
1211
|
-
passResults.push(passResult(entry, cwd));
|
|
1212
|
-
}
|
|
1355
|
+
const nonPassResults = dirs.map(
|
|
1356
|
+
(d) => fromDiskResult(
|
|
1357
|
+
cwd,
|
|
1358
|
+
d,
|
|
1359
|
+
manifest.entries.find((e) => e.id === d.id)
|
|
1360
|
+
)
|
|
1361
|
+
);
|
|
1362
|
+
const passResults = await Promise.all(
|
|
1363
|
+
manifest.entries.filter((entry) => !dirById.has(entry.id)).map((entry) => passResultFromDisk(entry, cwd))
|
|
1364
|
+
);
|
|
1213
1365
|
const results = [...passResults, ...nonPassResults];
|
|
1214
1366
|
const passed = results.filter((r) => r.status === "pass").length;
|
|
1215
1367
|
const pendingJudgments = results.filter(
|
|
@@ -1224,6 +1376,57 @@ async function applyJudgments(cwd = process.cwd()) {
|
|
|
1224
1376
|
results
|
|
1225
1377
|
};
|
|
1226
1378
|
await writeSummaryMarkdown(report, cwd);
|
|
1379
|
+
return report;
|
|
1380
|
+
}
|
|
1381
|
+
async function hasCheckpoint(cwd, threadId) {
|
|
1382
|
+
const dir = path2__default.default.join(paths(cwd).checkpoints, threadId);
|
|
1383
|
+
try {
|
|
1384
|
+
const names = await promises.readdir(dir);
|
|
1385
|
+
return names.length > 0;
|
|
1386
|
+
} catch {
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
async function applyJudgments(opts = process.cwd()) {
|
|
1391
|
+
const options = typeof opts === "string" ? { cwd: opts } : opts;
|
|
1392
|
+
const cwd = options.cwd ?? process.cwd();
|
|
1393
|
+
const manifest = await loadManifest(cwd);
|
|
1394
|
+
if (!manifest) {
|
|
1395
|
+
throw new Error(
|
|
1396
|
+
`no manifest at ${paths(cwd).manifest}. Run \`blazediff-agent init\` first.`
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
const dirs = await readJudgmentDirs(paths(cwd).judgments);
|
|
1400
|
+
const applied = [];
|
|
1401
|
+
const missing = [];
|
|
1402
|
+
const invalid = [];
|
|
1403
|
+
const verdicts = {};
|
|
1404
|
+
for (const d of dirs) {
|
|
1405
|
+
if (d.verdictInvalid) {
|
|
1406
|
+
invalid.push(path2__default.default.join(paths(cwd).judgments, d.id));
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
if (d.verdict) {
|
|
1410
|
+
verdicts[d.id] = d.verdict.verdict;
|
|
1411
|
+
applied.push(d.id);
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
missing.push(d.id);
|
|
1415
|
+
}
|
|
1416
|
+
const threadId = threadIdFor(cwd);
|
|
1417
|
+
const checkpointExists = await hasCheckpoint(cwd, threadId);
|
|
1418
|
+
if (!checkpointExists) {
|
|
1419
|
+
const report2 = await reconstructFromDisk(cwd, dirs);
|
|
1420
|
+
return { report: report2, applied, missing, invalid };
|
|
1421
|
+
}
|
|
1422
|
+
const report = await resumeGraph({
|
|
1423
|
+
cwd,
|
|
1424
|
+
verdicts,
|
|
1425
|
+
threadId,
|
|
1426
|
+
onEvent: options.onEvent,
|
|
1427
|
+
junitPath: options.junitPath
|
|
1428
|
+
});
|
|
1429
|
+
await new FsCheckpointSaver(paths(cwd).checkpoints).deleteThread(threadId).catch(() => void 0);
|
|
1227
1430
|
return { report, applied, missing, invalid };
|
|
1228
1431
|
}
|
|
1229
1432
|
var HOST_INSTRUCTIONS = [
|
|
@@ -1245,6 +1448,14 @@ function signatureOf(r) {
|
|
|
1245
1448
|
const severity = r.severity ?? "?";
|
|
1246
1449
|
return `${r.status}|diff:${pct}|regions:${regions}|severity:${severity}`;
|
|
1247
1450
|
}
|
|
1451
|
+
async function fileExists2(p) {
|
|
1452
|
+
try {
|
|
1453
|
+
await promises.access(p);
|
|
1454
|
+
return true;
|
|
1455
|
+
} catch {
|
|
1456
|
+
return false;
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1248
1459
|
async function readJsonOrNull2(file) {
|
|
1249
1460
|
try {
|
|
1250
1461
|
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
@@ -1298,9 +1509,13 @@ function autoVerdict(result) {
|
|
|
1298
1509
|
async function discoverTiles(dir) {
|
|
1299
1510
|
const locatorAbs = path2__default.default.join(dir, "locator.png");
|
|
1300
1511
|
const tilesAbs = path2__default.default.join(dir, "regions.png");
|
|
1512
|
+
const [locator, tiles] = await Promise.all([
|
|
1513
|
+
fileExists2(locatorAbs),
|
|
1514
|
+
fileExists2(tilesAbs)
|
|
1515
|
+
]);
|
|
1301
1516
|
return {
|
|
1302
|
-
locatorPath:
|
|
1303
|
-
tilesPath:
|
|
1517
|
+
locatorPath: locator ? "locator.png" : void 0,
|
|
1518
|
+
tilesPath: tiles ? "regions.png" : void 0
|
|
1304
1519
|
};
|
|
1305
1520
|
}
|
|
1306
1521
|
async function writeJudgments(opts) {
|
|
@@ -1309,53 +1524,61 @@ async function writeJudgments(opts) {
|
|
|
1309
1524
|
await promises.mkdir(root, { recursive: true });
|
|
1310
1525
|
const knownIds = /* @__PURE__ */ new Set();
|
|
1311
1526
|
for (const r of opts.report.results) knownIds.add(r.id);
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
if (
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
if (priorVerdict && signatureMatches) {
|
|
1335
|
-
continue;
|
|
1336
|
-
}
|
|
1337
|
-
const auto = autoVerdict(result);
|
|
1338
|
-
if (auto) {
|
|
1527
|
+
await Promise.all(
|
|
1528
|
+
opts.report.results.map(async (result) => {
|
|
1529
|
+
const dir = path2__default.default.join(root, result.id);
|
|
1530
|
+
if (result.status === "pass") {
|
|
1531
|
+
if (await fileExists2(dir))
|
|
1532
|
+
await promises.rm(dir, { recursive: true, force: true });
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
const entry = entryById(opts.manifest, result.id);
|
|
1536
|
+
if (!entry) return;
|
|
1537
|
+
await promises.mkdir(dir, { recursive: true });
|
|
1538
|
+
const tiles = await discoverTiles(dir);
|
|
1539
|
+
const request = buildRequest(result, entry, cwd, tiles);
|
|
1540
|
+
const requestFile = path2__default.default.join(dir, "request.json");
|
|
1541
|
+
const verdictFile = path2__default.default.join(dir, "verdict.json");
|
|
1542
|
+
const [prior, priorVerdict] = await Promise.all([
|
|
1543
|
+
readJsonOrNull2(requestFile),
|
|
1544
|
+
fileExists2(verdictFile).then(
|
|
1545
|
+
(exists) => exists ? readJsonOrNull2(verdictFile) : null
|
|
1546
|
+
)
|
|
1547
|
+
]);
|
|
1548
|
+
const signatureMatches = prior !== null && prior.signature === request.signature;
|
|
1339
1549
|
await promises.writeFile(
|
|
1340
|
-
|
|
1341
|
-
`${JSON.stringify(
|
|
1550
|
+
requestFile,
|
|
1551
|
+
`${JSON.stringify(request, null, 2)}
|
|
1342
1552
|
`,
|
|
1343
1553
|
"utf8"
|
|
1344
1554
|
);
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1555
|
+
if (priorVerdict && signatureMatches) {
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
const auto = autoVerdict(result);
|
|
1559
|
+
if (auto) {
|
|
1560
|
+
await promises.writeFile(
|
|
1561
|
+
verdictFile,
|
|
1562
|
+
`${JSON.stringify(auto, null, 2)}
|
|
1563
|
+
`,
|
|
1564
|
+
"utf8"
|
|
1565
|
+
);
|
|
1566
|
+
} else if (priorVerdict && !signatureMatches) {
|
|
1567
|
+
await promises.rm(verdictFile, { force: true });
|
|
1568
|
+
}
|
|
1569
|
+
})
|
|
1570
|
+
);
|
|
1349
1571
|
let entries;
|
|
1350
1572
|
try {
|
|
1351
1573
|
entries = await promises.readdir(root);
|
|
1352
1574
|
} catch {
|
|
1353
1575
|
return;
|
|
1354
1576
|
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1577
|
+
await Promise.all(
|
|
1578
|
+
entries.filter((name) => !knownIds.has(name)).map(
|
|
1579
|
+
(name) => promises.rm(path2__default.default.join(root, name), { recursive: true, force: true })
|
|
1580
|
+
)
|
|
1581
|
+
);
|
|
1359
1582
|
}
|
|
1360
1583
|
|
|
1361
1584
|
// src/judge/index.ts
|
|
@@ -1393,7 +1616,7 @@ ${cases.join("\n")}
|
|
|
1393
1616
|
return destPath;
|
|
1394
1617
|
}
|
|
1395
1618
|
|
|
1396
|
-
// src/
|
|
1619
|
+
// src/graph/nodes/results.ts
|
|
1397
1620
|
function narrowRegion(r) {
|
|
1398
1621
|
return {
|
|
1399
1622
|
bbox: r.bbox,
|
|
@@ -1403,29 +1626,6 @@ function narrowRegion(r) {
|
|
|
1403
1626
|
confidence: r.confidence
|
|
1404
1627
|
};
|
|
1405
1628
|
}
|
|
1406
|
-
async function pool(items, limit, fn) {
|
|
1407
|
-
const results = new Array(items.length);
|
|
1408
|
-
let next = 0;
|
|
1409
|
-
const workerCount = Math.max(1, Math.min(limit, items.length));
|
|
1410
|
-
const workers = Array.from({ length: workerCount }, async () => {
|
|
1411
|
-
while (true) {
|
|
1412
|
-
const i = next++;
|
|
1413
|
-
if (i >= items.length) return;
|
|
1414
|
-
results[i] = await fn(items[i], i);
|
|
1415
|
-
}
|
|
1416
|
-
});
|
|
1417
|
-
await Promise.all(workers);
|
|
1418
|
-
return results;
|
|
1419
|
-
}
|
|
1420
|
-
function passResult2(entry, baselinePath, actualPath) {
|
|
1421
|
-
return {
|
|
1422
|
-
id: entry.id,
|
|
1423
|
-
url: entry.url,
|
|
1424
|
-
status: "pass",
|
|
1425
|
-
baselinePath,
|
|
1426
|
-
actualPath
|
|
1427
|
-
};
|
|
1428
|
-
}
|
|
1429
1629
|
function skipResult(entry, message) {
|
|
1430
1630
|
return { id: entry.id, url: entry.url, status: "pass", message };
|
|
1431
1631
|
}
|
|
@@ -1437,6 +1637,15 @@ function staleResult(entry) {
|
|
|
1437
1637
|
message: "captureHash mismatch: entry was edited without re-capturing"
|
|
1438
1638
|
};
|
|
1439
1639
|
}
|
|
1640
|
+
function passResult(entry, baselinePath, actualPath) {
|
|
1641
|
+
return {
|
|
1642
|
+
id: entry.id,
|
|
1643
|
+
url: entry.url,
|
|
1644
|
+
status: "pass",
|
|
1645
|
+
baselinePath,
|
|
1646
|
+
actualPath
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1440
1649
|
function missingBaselineResult(entry, baselinePath) {
|
|
1441
1650
|
return {
|
|
1442
1651
|
id: entry.id,
|
|
@@ -1461,1594 +1670,1755 @@ function failResult(entry, outcome, actualPath, baselinePath, verdict) {
|
|
|
1461
1670
|
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
1462
1671
|
};
|
|
1463
1672
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1673
|
+
|
|
1674
|
+
// src/graph/nodes/capture.ts
|
|
1675
|
+
function makeCaptureNode(semaphore) {
|
|
1676
|
+
return async function captureNode(state) {
|
|
1677
|
+
const entry = state.entry;
|
|
1678
|
+
const options = state.options;
|
|
1679
|
+
if (!entry || !options) {
|
|
1680
|
+
throw new Error("captureNode: entry or options missing");
|
|
1681
|
+
}
|
|
1682
|
+
if (entry.auth === "required") {
|
|
1683
|
+
return {
|
|
1684
|
+
captureOutput: {
|
|
1685
|
+
id: entry.id,
|
|
1686
|
+
skipResult: skipResult(
|
|
1687
|
+
entry,
|
|
1688
|
+
"skipped: auth required (deferred to v0.2)"
|
|
1689
|
+
)
|
|
1690
|
+
}
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
if (isEntryStale(entry)) {
|
|
1694
|
+
return {
|
|
1695
|
+
captureOutput: { id: entry.id, skipResult: staleResult(entry) }
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
|
|
1699
|
+
const capture = await semaphore.run(
|
|
1700
|
+
() => captureScreenshot(
|
|
1701
|
+
options.baseUrl,
|
|
1702
|
+
{
|
|
1703
|
+
id: entry.id,
|
|
1704
|
+
url: entry.url,
|
|
1705
|
+
viewport: entry.viewport,
|
|
1706
|
+
mask: entry.mask,
|
|
1707
|
+
waitFor: entry.waitFor,
|
|
1708
|
+
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1709
|
+
mode: "actual"
|
|
1710
|
+
},
|
|
1711
|
+
options.cwd
|
|
1712
|
+
)
|
|
1713
|
+
);
|
|
1714
|
+
return {
|
|
1715
|
+
captureOutput: {
|
|
1716
|
+
id: entry.id,
|
|
1717
|
+
captureOutputPath: capture.outputPath,
|
|
1718
|
+
baselinePath
|
|
1719
|
+
}
|
|
1720
|
+
};
|
|
1488
1721
|
};
|
|
1489
1722
|
}
|
|
1490
|
-
async function
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
return
|
|
1723
|
+
async function fileExists3(p) {
|
|
1724
|
+
try {
|
|
1725
|
+
await promises.access(p);
|
|
1726
|
+
return true;
|
|
1727
|
+
} catch {
|
|
1728
|
+
return false;
|
|
1496
1729
|
}
|
|
1497
|
-
const baselinePath = path2__default.default.join(baselinesDir, `${entry.id}.png`);
|
|
1498
|
-
const capture = await captureScreenshot(
|
|
1499
|
-
opts.baseUrl,
|
|
1500
|
-
{
|
|
1501
|
-
id: entry.id,
|
|
1502
|
-
url: entry.url,
|
|
1503
|
-
viewport: entry.viewport,
|
|
1504
|
-
mask: entry.mask,
|
|
1505
|
-
waitFor: entry.waitFor,
|
|
1506
|
-
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
1507
|
-
mode: "actual"
|
|
1508
|
-
},
|
|
1509
|
-
cwd
|
|
1510
|
-
);
|
|
1511
|
-
const outcome = await diffEntry(
|
|
1512
|
-
entry.id,
|
|
1513
|
-
baselinePath,
|
|
1514
|
-
capture.outputPath,
|
|
1515
|
-
{ threshold: opts.threshold, emitDiffPng: opts.emitDiffPng ?? true },
|
|
1516
|
-
cwd
|
|
1517
|
-
);
|
|
1518
|
-
if (outcome.match) return passResult2(entry, baselinePath, capture.outputPath);
|
|
1519
|
-
if (outcome.reason === "file-not-exists")
|
|
1520
|
-
return missingBaselineResult(entry, baselinePath);
|
|
1521
|
-
const verdict = deriveVerdict({
|
|
1522
|
-
reason: outcome.reason,
|
|
1523
|
-
interpretation: outcome.interpretation,
|
|
1524
|
-
diffCount: outcome.diffCount,
|
|
1525
|
-
diffPercentage: outcome.diffPercentage
|
|
1526
|
-
});
|
|
1527
|
-
const failed = failResult(
|
|
1528
|
-
entry,
|
|
1529
|
-
outcome,
|
|
1530
|
-
capture.outputPath,
|
|
1531
|
-
baselinePath,
|
|
1532
|
-
verdict
|
|
1533
|
-
);
|
|
1534
|
-
return judgeAmbiguous(failed, entry, judge, cwd);
|
|
1535
1730
|
}
|
|
1536
|
-
async function
|
|
1537
|
-
const
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1731
|
+
async function diffEntry(id, baselinePath, actualPath, opts = {}, cwd = process.cwd()) {
|
|
1732
|
+
const [hasBaseline, hasActual] = await Promise.all([
|
|
1733
|
+
fileExists3(baselinePath),
|
|
1734
|
+
fileExists3(actualPath)
|
|
1735
|
+
]);
|
|
1736
|
+
if (!hasBaseline || !hasActual) {
|
|
1737
|
+
return {
|
|
1738
|
+
id,
|
|
1739
|
+
baselinePath,
|
|
1740
|
+
actualPath,
|
|
1741
|
+
match: false,
|
|
1742
|
+
reason: "file-not-exists"
|
|
1743
|
+
};
|
|
1543
1744
|
}
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
results = await pool(
|
|
1550
|
-
manifest.entries,
|
|
1551
|
-
concurrency,
|
|
1552
|
-
(entry) => checkEntry(entry, opts, cwd, baselinesDir, judge)
|
|
1553
|
-
);
|
|
1554
|
-
} finally {
|
|
1555
|
-
await closeBrowser();
|
|
1745
|
+
let diffPath;
|
|
1746
|
+
if (opts.emitDiffPng) {
|
|
1747
|
+
const actualDir = paths(cwd).actual;
|
|
1748
|
+
await promises.mkdir(actualDir, { recursive: true });
|
|
1749
|
+
diffPath = path2__default.default.join(actualDir, `${id}.diff.png`);
|
|
1556
1750
|
}
|
|
1557
|
-
const
|
|
1558
|
-
const
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1751
|
+
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
1752
|
+
const antialiasing = opts.antialiasing ?? true;
|
|
1753
|
+
const result = await coreNative.compare(baselinePath, actualPath, diffPath, {
|
|
1754
|
+
threshold,
|
|
1755
|
+
antialiasing,
|
|
1756
|
+
interpret: true
|
|
1757
|
+
});
|
|
1758
|
+
if (result.match) return { id, baselinePath, actualPath, match: true };
|
|
1759
|
+
if (result.reason === "file-not-exists") {
|
|
1760
|
+
return {
|
|
1761
|
+
id,
|
|
1762
|
+
baselinePath,
|
|
1763
|
+
actualPath,
|
|
1764
|
+
match: false,
|
|
1765
|
+
reason: "file-not-exists"
|
|
1766
|
+
};
|
|
1767
|
+
}
|
|
1768
|
+
if (result.reason === "layout-diff") {
|
|
1769
|
+
return {
|
|
1770
|
+
id,
|
|
1771
|
+
baselinePath,
|
|
1772
|
+
actualPath,
|
|
1773
|
+
diffPath,
|
|
1774
|
+
match: false,
|
|
1775
|
+
reason: "layout-diff"
|
|
1776
|
+
};
|
|
1575
1777
|
}
|
|
1576
|
-
return report;
|
|
1577
|
-
}
|
|
1578
|
-
|
|
1579
|
-
// src/cli/commands/check.ts
|
|
1580
|
-
function slimResult(r) {
|
|
1581
|
-
return {
|
|
1582
|
-
id: r.id,
|
|
1583
|
-
url: r.url,
|
|
1584
|
-
status: r.status,
|
|
1585
|
-
verdict: r.verdict ? {
|
|
1586
|
-
label: r.verdict.label,
|
|
1587
|
-
headline: r.verdict.headline,
|
|
1588
|
-
action: r.verdict.action
|
|
1589
|
-
} : void 0
|
|
1590
|
-
};
|
|
1591
|
-
}
|
|
1592
|
-
function slimReport(report, summaryPath) {
|
|
1593
1778
|
return {
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1779
|
+
id,
|
|
1780
|
+
baselinePath,
|
|
1781
|
+
actualPath,
|
|
1782
|
+
diffPath,
|
|
1783
|
+
match: false,
|
|
1784
|
+
reason: "pixel-diff",
|
|
1785
|
+
diffCount: result.diffCount,
|
|
1786
|
+
diffPercentage: result.diffPercentage,
|
|
1787
|
+
interpretation: result.interpretation
|
|
1601
1788
|
};
|
|
1602
1789
|
}
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
} else {
|
|
1613
|
-
const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
|
|
1614
|
-
lines.push(` ${prefix} ${r.id}: ${detail}`);
|
|
1790
|
+
|
|
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");
|
|
1615
1799
|
}
|
|
1616
|
-
if (
|
|
1617
|
-
|
|
1800
|
+
if (capture.skipResult) {
|
|
1801
|
+
return {
|
|
1802
|
+
diffOutput: { id: capture.id, skipResult: capture.skipResult }
|
|
1803
|
+
};
|
|
1618
1804
|
}
|
|
1619
|
-
if (
|
|
1620
|
-
|
|
1621
|
-
});
|
|
1622
|
-
}
|
|
1623
|
-
function parseJudge(input) {
|
|
1624
|
-
if (input === "host" || input === "none") return input;
|
|
1625
|
-
throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
|
|
1626
|
-
}
|
|
1627
|
-
function registerCheck(program, out) {
|
|
1628
|
-
program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
|
|
1629
|
-
"--threshold <n>",
|
|
1630
|
-
"color threshold (0-1)",
|
|
1631
|
-
String(DEFAULT_THRESHOLD)
|
|
1632
|
-
).option(
|
|
1633
|
-
"--concurrency <n>",
|
|
1634
|
-
"max entries checked in parallel (default: auto based on CPU cores, capped at 8)"
|
|
1635
|
-
).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
|
|
1636
|
-
"--judge <backend>",
|
|
1637
|
-
"judge backend for ambiguous diffs (host | none)",
|
|
1638
|
-
"none"
|
|
1639
|
-
).option(
|
|
1640
|
-
"--apply-judgments",
|
|
1641
|
-
"regenerate summary.md from .blazediff/judgments/<id>/verdict.json files (no re-check)"
|
|
1642
|
-
).action(async (opts) => {
|
|
1643
|
-
if (opts.applyJudgments) {
|
|
1644
|
-
const { report: report2, applied, missing, invalid } = await applyJudgments();
|
|
1645
|
-
const summaryPath2 = paths().summary;
|
|
1646
|
-
const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
|
|
1647
|
-
summary: ${summaryPath2}` : [
|
|
1648
|
-
`applied ${applied.length} judgment(s)`,
|
|
1649
|
-
missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
|
|
1650
|
-
invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
|
|
1651
|
-
` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
|
|
1652
|
-
` summary: ${summaryPath2}`
|
|
1653
|
-
].filter(Boolean).join("\n");
|
|
1654
|
-
out.emit(
|
|
1655
|
-
{
|
|
1656
|
-
ok: true,
|
|
1657
|
-
applied,
|
|
1658
|
-
missing,
|
|
1659
|
-
invalid,
|
|
1660
|
-
...slimReport(report2, summaryPath2)
|
|
1661
|
-
},
|
|
1662
|
-
human2
|
|
1663
|
-
);
|
|
1664
|
-
if (report2.failed > 0) process.exitCode = 1;
|
|
1665
|
-
return;
|
|
1805
|
+
if (!capture.captureOutputPath || !capture.baselinePath) {
|
|
1806
|
+
throw new Error("diffNode: capture output paths missing");
|
|
1666
1807
|
}
|
|
1667
|
-
const
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
});
|
|
1676
|
-
const summaryPath = paths().summary;
|
|
1677
|
-
const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
|
|
1678
|
-
const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
|
|
1679
|
-
summary: ${summaryPath}` : [
|
|
1680
|
-
`${summary}:`,
|
|
1681
|
-
...failureLines(report.results),
|
|
1682
|
-
` summary: ${summaryPath}`,
|
|
1683
|
-
report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
|
|
1684
|
-
].filter(Boolean).join("\n");
|
|
1685
|
-
out.emit(slimReport(report, summaryPath), human);
|
|
1686
|
-
if (report.failed > 0) process.exitCode = 1;
|
|
1687
|
-
});
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
|
-
// src/cli/commands/diff.ts
|
|
1691
|
-
function registerDiff(program, out) {
|
|
1692
|
-
program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
|
|
1693
|
-
"--threshold <n>",
|
|
1694
|
-
"color threshold (0-1)",
|
|
1695
|
-
String(DEFAULT_THRESHOLD)
|
|
1696
|
-
).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
|
|
1697
|
-
const manifest = await loadManifest();
|
|
1698
|
-
if (!manifest) throw new Error("no manifest");
|
|
1699
|
-
const entry = findEntry(manifest, id);
|
|
1700
|
-
if (!entry) throw new Error(`no entry with id ${id}`);
|
|
1701
|
-
const baselinePath = `${paths().baselines}/${id}.png`;
|
|
1702
|
-
const actualPath = `${paths().actual}/${id}.png`;
|
|
1703
|
-
const outcome = await diffEntry(id, baselinePath, actualPath, {
|
|
1704
|
-
threshold: Number(opts.threshold),
|
|
1705
|
-
emitDiffPng: Boolean(opts.emitDiffPng)
|
|
1706
|
-
});
|
|
1707
|
-
out.emit(
|
|
1708
|
-
outcome,
|
|
1709
|
-
outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
|
|
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
|
+
)
|
|
1710
1816
|
);
|
|
1711
|
-
if (
|
|
1712
|
-
|
|
1817
|
+
if (outcome.reason === "file-not-exists") {
|
|
1818
|
+
return {
|
|
1819
|
+
diffOutput: {
|
|
1820
|
+
id: entry.id,
|
|
1821
|
+
skipResult: missingBaselineResult(entry, capture.baselinePath)
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
return {
|
|
1826
|
+
diffOutput: {
|
|
1827
|
+
id: entry.id,
|
|
1828
|
+
outcome,
|
|
1829
|
+
baselinePath: capture.baselinePath,
|
|
1830
|
+
captureOutputPath: capture.captureOutputPath
|
|
1831
|
+
}
|
|
1832
|
+
};
|
|
1833
|
+
};
|
|
1713
1834
|
}
|
|
1714
1835
|
|
|
1715
|
-
// src/
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
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;
|
|
1729
1859
|
}
|
|
1730
1860
|
}
|
|
1731
|
-
return
|
|
1861
|
+
return best;
|
|
1732
1862
|
}
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
const
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
const queue = [{ url: "/", depth: 0 }];
|
|
1739
|
-
visited.add("/");
|
|
1740
|
-
const discovered = [];
|
|
1741
|
-
const browser = await getBrowser();
|
|
1742
|
-
const context = await browser.newContext({
|
|
1743
|
-
viewport: { width: 1024, height: 768 },
|
|
1744
|
-
deviceScaleFactor: 1
|
|
1745
|
-
});
|
|
1746
|
-
try {
|
|
1747
|
-
while (queue.length && discovered.length < maxRoutes) {
|
|
1748
|
-
const { url, depth } = queue.shift();
|
|
1749
|
-
const page = await context.newPage();
|
|
1750
|
-
try {
|
|
1751
|
-
const target = new URL(url, base).toString();
|
|
1752
|
-
await page.goto(target, {
|
|
1753
|
-
waitUntil: "domcontentloaded",
|
|
1754
|
-
timeout: 15e3
|
|
1755
|
-
});
|
|
1756
|
-
discovered.push({ url, source: "crawl" });
|
|
1757
|
-
if (depth >= maxDepth) continue;
|
|
1758
|
-
const hrefs = await page.evaluate(
|
|
1759
|
-
() => Array.from(
|
|
1760
|
-
document.querySelectorAll("a[href]")
|
|
1761
|
-
).map((a) => a.getAttribute("href") ?? "")
|
|
1762
|
-
);
|
|
1763
|
-
for (const path23 of extractInternalLinks(base, target, hrefs)) {
|
|
1764
|
-
if (visited.has(path23)) continue;
|
|
1765
|
-
visited.add(path23);
|
|
1766
|
-
queue.push({ url: path23, depth: depth + 1 });
|
|
1767
|
-
}
|
|
1768
|
-
} catch {
|
|
1769
|
-
} finally {
|
|
1770
|
-
await page.close().catch(() => {
|
|
1771
|
-
});
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
} finally {
|
|
1775
|
-
await context.close().catch(() => {
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
return discovered;
|
|
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;
|
|
1779
1868
|
}
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
try {
|
|
1784
|
-
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
1785
|
-
} catch {
|
|
1786
|
-
return null;
|
|
1787
|
-
}
|
|
1869
|
+
function meanConfidence(regions) {
|
|
1870
|
+
if (regions.length === 0) return 0;
|
|
1871
|
+
return regions.reduce((a, r) => a + (r.confidence ?? 0), 0) / regions.length;
|
|
1788
1872
|
}
|
|
1789
|
-
function
|
|
1790
|
-
|
|
1791
|
-
if (route === "/api" || route.startsWith("/api/")) return false;
|
|
1792
|
-
return true;
|
|
1873
|
+
function formatBreakdown(counts) {
|
|
1874
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([type, n]) => `${n} ${type}`).join(", ");
|
|
1793
1875
|
}
|
|
1794
|
-
|
|
1795
|
-
const
|
|
1796
|
-
if (
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
seen.add(url);
|
|
1802
|
-
out.push({ url, source: "next-manifest" });
|
|
1803
|
-
};
|
|
1804
|
-
const routes = await readJson(
|
|
1805
|
-
path2__default.default.join(nextDir, "routes-manifest.json")
|
|
1806
|
-
);
|
|
1807
|
-
for (const r of routes?.staticRoutes ?? []) {
|
|
1808
|
-
if (isPublicRoute(r.page)) add(r.page);
|
|
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`;
|
|
1809
1883
|
}
|
|
1810
|
-
const
|
|
1811
|
-
|
|
1812
|
-
);
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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})`;
|
|
1816
1891
|
}
|
|
1817
|
-
return
|
|
1892
|
+
return `${regions.length} regions: ${formatBreakdown(counts)}${pos ? ` @ ${pos}` : ""} (${pct}, ${sev})`;
|
|
1818
1893
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
const url = new URL(u);
|
|
1833
|
-
return { url: url.pathname + url.search, source: "sitemap" };
|
|
1834
|
-
});
|
|
1835
|
-
} catch {
|
|
1836
|
-
}
|
|
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
|
+
};
|
|
1837
1907
|
}
|
|
1838
|
-
|
|
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
|
+
};
|
|
1973
|
+
}
|
|
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
|
+
};
|
|
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
|
+
};
|
|
1839
1993
|
}
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
const trimmed = pathPart.replace(/\/+$/, "");
|
|
1845
|
-
const normalizedPath = trimmed === "" ? "/" : trimmed;
|
|
1846
|
-
return query ? `${normalizedPath}?${query}` : normalizedPath;
|
|
1994
|
+
function interruptForJudgment(payload) {
|
|
1995
|
+
const resume = langgraph.interrupt(payload);
|
|
1996
|
+
if (!resume || typeof resume !== "object") return void 0;
|
|
1997
|
+
return resume[payload.entryId];
|
|
1847
1998
|
}
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1999
|
+
|
|
2000
|
+
// src/graph/nodes/judge.ts
|
|
2001
|
+
async function judgeNode(state) {
|
|
2002
|
+
const entry = state.entry;
|
|
2003
|
+
const options = state.options;
|
|
2004
|
+
const diff = state.diffOutput;
|
|
2005
|
+
if (!entry || !options || !diff) {
|
|
2006
|
+
throw new Error("judgeNode: entry, options, or diff missing");
|
|
1852
2007
|
}
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
const
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
if (!
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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;
|
|
1866
2065
|
}
|
|
1867
2066
|
}
|
|
1868
|
-
return
|
|
2067
|
+
return { results: [result] };
|
|
1869
2068
|
}
|
|
1870
2069
|
|
|
1871
|
-
// src/
|
|
1872
|
-
function
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
skipCrawl: !opts.crawl
|
|
1881
|
-
});
|
|
1882
|
-
await closeBrowser();
|
|
1883
|
-
out.emit(
|
|
1884
|
-
{ ok: true, baseUrl, routes },
|
|
1885
|
-
routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
|
|
2070
|
+
// src/graph/nodes/load.ts
|
|
2071
|
+
async function loadNode(state) {
|
|
2072
|
+
if (!state.options) {
|
|
2073
|
+
throw new Error("loadNode: graph options missing");
|
|
2074
|
+
}
|
|
2075
|
+
const manifest = await loadManifest(state.options.cwd);
|
|
2076
|
+
if (!manifest) {
|
|
2077
|
+
throw new Error(
|
|
2078
|
+
`no manifest found at ${paths(state.options.cwd).manifest}. Run \`blazediff init\` then \`/blazediff\` (or capture manually) first.`
|
|
1886
2079
|
);
|
|
1887
|
-
});
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
// src/introspect/framework.ts
|
|
1891
|
-
var SIGNALS = [
|
|
1892
|
-
["next", ["next"]],
|
|
1893
|
-
["remix", ["@remix-run/dev", "@remix-run/serve"]],
|
|
1894
|
-
["sveltekit", ["@sveltejs/kit"]],
|
|
1895
|
-
["nuxt", ["nuxt", "nuxt3"]],
|
|
1896
|
-
["astro", ["astro"]],
|
|
1897
|
-
["gatsby", ["gatsby"]],
|
|
1898
|
-
["vite-react", ["vite", "react"]]
|
|
1899
|
-
];
|
|
1900
|
-
function detectFramework(pkg) {
|
|
1901
|
-
const deps = pkg.allDependencies;
|
|
1902
|
-
for (const [framework, required] of SIGNALS) {
|
|
1903
|
-
if (required.every((d) => d in deps)) return framework;
|
|
1904
2080
|
}
|
|
1905
|
-
return
|
|
1906
|
-
}
|
|
1907
|
-
async function readPackageJson(cwd = process.cwd()) {
|
|
1908
|
-
const file = path2__default.default.join(cwd, "package.json");
|
|
1909
|
-
if (!fs.existsSync(file)) return null;
|
|
1910
|
-
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
2081
|
+
return { entries: manifest.entries, manifest };
|
|
1911
2082
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
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
|
+
}),
|
|
2092
|
+
options: langgraph.Annotation({
|
|
2093
|
+
reducer: (acc, next) => next ?? acc,
|
|
2094
|
+
default: () => void 0
|
|
2095
|
+
}),
|
|
2096
|
+
captureOutput: langgraph.Annotation({
|
|
2097
|
+
reducer: (_, next) => next,
|
|
2098
|
+
default: () => void 0
|
|
2099
|
+
}),
|
|
2100
|
+
diffOutput: langgraph.Annotation({
|
|
2101
|
+
reducer: (_, next) => next,
|
|
2102
|
+
default: () => void 0
|
|
2103
|
+
}),
|
|
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,
|
|
2113
|
+
default: () => []
|
|
2114
|
+
}),
|
|
2115
|
+
results: resultsChannel,
|
|
2116
|
+
manifest: langgraph.Annotation({
|
|
2117
|
+
reducer: (acc, next) => next ?? acc,
|
|
2118
|
+
default: () => void 0
|
|
2119
|
+
}),
|
|
2120
|
+
report: langgraph.Annotation({
|
|
2121
|
+
reducer: (acc, next) => next ?? acc,
|
|
2122
|
+
default: () => void 0
|
|
2123
|
+
})
|
|
2124
|
+
});
|
|
2125
|
+
|
|
2126
|
+
// src/graph/index.ts
|
|
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);
|
|
1924
2131
|
}
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
"react-scripts": 3e3,
|
|
1928
|
-
vite: 5173,
|
|
1929
|
-
remix: 3e3,
|
|
1930
|
-
"@remix-run/dev": 3e3,
|
|
1931
|
-
astro: 4321,
|
|
1932
|
-
svelte: 5173,
|
|
1933
|
-
vue: 5173,
|
|
1934
|
-
nuxt: 3e3,
|
|
1935
|
-
gatsby: 8e3,
|
|
1936
|
-
parcel: 1234
|
|
1937
|
-
};
|
|
1938
|
-
var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
|
|
1939
|
-
function inferPort(script, deps) {
|
|
1940
|
-
const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
|
|
1941
|
-
if (portArg) return Number(portArg[1]);
|
|
1942
|
-
const portEnv = script.match(/PORT[\s=]+(\d+)/);
|
|
1943
|
-
if (portEnv) return Number(portEnv[1]);
|
|
1944
|
-
const depNames = Object.keys(deps);
|
|
1945
|
-
for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
|
|
1946
|
-
if (depNames.some((d) => d.startsWith(pkg))) return port;
|
|
1947
|
-
}
|
|
1948
|
-
return DEFAULT_PORT;
|
|
2132
|
+
function threadIdFor(cwd) {
|
|
2133
|
+
return crypto$1.createHash("sha1").update(paths(cwd).manifest).digest("hex").slice(0, 16);
|
|
1949
2134
|
}
|
|
1950
|
-
function
|
|
1951
|
-
|
|
1952
|
-
if (pm === "yarn") return `yarn ${scriptName}`;
|
|
1953
|
-
if (pm === "bun") return `bun run ${scriptName}`;
|
|
1954
|
-
return `pnpm ${scriptName}`;
|
|
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();
|
|
1955
2137
|
}
|
|
1956
|
-
function
|
|
1957
|
-
const
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
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(
|
|
2141
|
+
"load",
|
|
2142
|
+
(state) => state.entries.map(
|
|
2143
|
+
(entry) => new langgraph.Send("branch", { entry, options: state.options })
|
|
2144
|
+
),
|
|
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
|
+
}
|
|
1966
2183
|
}
|
|
1967
2184
|
}
|
|
1968
|
-
return
|
|
2185
|
+
return collect;
|
|
1969
2186
|
}
|
|
1970
|
-
async function
|
|
1971
|
-
const
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
const
|
|
1978
|
-
const
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
port: chosen.port,
|
|
1990
|
-
candidates,
|
|
1991
|
-
devDependencies,
|
|
1992
|
-
dependencies,
|
|
1993
|
-
allDependencies
|
|
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
|
|
1994
2206
|
};
|
|
2207
|
+
if (manifest) {
|
|
2208
|
+
await writeJudgments({ report, manifest, cwd });
|
|
2209
|
+
}
|
|
2210
|
+
await writeSummaryMarkdown(report, cwd);
|
|
2211
|
+
await ensureGitignore(cwd);
|
|
2212
|
+
return report;
|
|
1995
2213
|
}
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2214
|
+
async function runGraph(opts) {
|
|
2215
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2216
|
+
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
2217
|
+
const captureSemaphore = new Semaphore(concurrency);
|
|
2218
|
+
const diffSemaphore = new Semaphore(cpuParallelism());
|
|
2219
|
+
const baselinesDir = paths(cwd).baselines;
|
|
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
|
|
2004
2235
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
devServer: {
|
|
2012
|
-
command: opts.devCommand,
|
|
2013
|
-
port: port2,
|
|
2014
|
-
readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
|
|
2015
|
-
},
|
|
2016
|
-
baseUrl: `http://127.0.0.1:${port2}`
|
|
2017
|
-
};
|
|
2236
|
+
};
|
|
2237
|
+
let collect;
|
|
2238
|
+
try {
|
|
2239
|
+
collect = await streamGraph(graph, input, threadId, opts.onEvent);
|
|
2240
|
+
} finally {
|
|
2241
|
+
await closeBrowser();
|
|
2018
2242
|
}
|
|
2019
|
-
const
|
|
2020
|
-
if (
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
);
|
|
2243
|
+
const report = collect.report ?? await buildPartialReport(collect, cwd);
|
|
2244
|
+
if (opts.junitPath) {
|
|
2245
|
+
const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
|
|
2246
|
+
await writeJunit(report, target);
|
|
2024
2247
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
if (!opts.devScript) {
|
|
2028
|
-
const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
|
|
2029
|
-
throw new Error(
|
|
2030
|
-
`multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
|
|
2031
|
-
);
|
|
2032
|
-
}
|
|
2033
|
-
const match = pkg.candidates.find((c) => c.name === opts.devScript);
|
|
2034
|
-
if (!match) {
|
|
2035
|
-
const names = pkg.candidates.map((c) => c.name).join(", ");
|
|
2036
|
-
throw new Error(
|
|
2037
|
-
`--dev-script "${opts.devScript}" not found among candidates: ${names}`
|
|
2038
|
-
);
|
|
2039
|
-
}
|
|
2040
|
-
chosen = match;
|
|
2248
|
+
if (collect.interrupts.length === 0) {
|
|
2249
|
+
await checkpointer.deleteThread(threadId).catch(() => void 0);
|
|
2041
2250
|
}
|
|
2042
|
-
|
|
2251
|
+
return report;
|
|
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
|
+
|
|
2263
|
+
// src/cli/check-output.ts
|
|
2264
|
+
function slimResult(r) {
|
|
2043
2265
|
return {
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2266
|
+
id: r.id,
|
|
2267
|
+
url: r.url,
|
|
2268
|
+
status: r.status,
|
|
2269
|
+
verdict: r.verdict ? {
|
|
2270
|
+
label: r.verdict.label,
|
|
2271
|
+
headline: r.verdict.headline,
|
|
2272
|
+
action: r.verdict.action
|
|
2273
|
+
} : void 0
|
|
2052
2274
|
};
|
|
2053
2275
|
}
|
|
2054
|
-
function
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
}
|
|
2072
|
-
|
|
2073
|
-
);
|
|
2074
|
-
|
|
2276
|
+
function slimReport(report, summaryPath) {
|
|
2277
|
+
return {
|
|
2278
|
+
summaryPath,
|
|
2279
|
+
createdAt: report.createdAt,
|
|
2280
|
+
totalEntries: report.totalEntries,
|
|
2281
|
+
passed: report.passed,
|
|
2282
|
+
failed: report.failed,
|
|
2283
|
+
pendingJudgments: report.pendingJudgments,
|
|
2284
|
+
results: report.results.filter((r) => r.status !== "pass").map(slimResult)
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function failureLines(results) {
|
|
2288
|
+
return results.filter((r) => r.status !== "pass").flatMap((r) => {
|
|
2289
|
+
const lines = [];
|
|
2290
|
+
const prefix = r.status === "needs-judgment" ? "?" : "\u2717";
|
|
2291
|
+
if (r.verdict) {
|
|
2292
|
+
lines.push(
|
|
2293
|
+
` ${prefix} ${r.id} [${r.verdict.label}] ${r.verdict.headline}`
|
|
2294
|
+
);
|
|
2295
|
+
lines.push(` \u2192 ${r.verdict.action}`);
|
|
2296
|
+
} else {
|
|
2297
|
+
const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
|
|
2298
|
+
lines.push(` ${prefix} ${r.id}: ${detail}`);
|
|
2075
2299
|
}
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
|
|
2082
|
-
baseUrl: ${config.baseUrl}
|
|
2083
|
-
external server (no devServer managed)`;
|
|
2084
|
-
out.emit(
|
|
2085
|
-
{ ok: true, created: true, config, configHash: configHash(config) },
|
|
2086
|
-
human
|
|
2087
|
-
);
|
|
2300
|
+
if (r.status === "needs-judgment" && r.message) {
|
|
2301
|
+
lines.push(` ${r.message}`);
|
|
2302
|
+
}
|
|
2303
|
+
if (r.diffPath) lines.push(` diff: ${r.diffPath}`);
|
|
2304
|
+
return lines;
|
|
2088
2305
|
});
|
|
2089
2306
|
}
|
|
2307
|
+
function parseJudge(input) {
|
|
2308
|
+
if (input === "host" || input === "none") return input;
|
|
2309
|
+
throw new Error(`unknown --judge backend: ${input} (expected: host | none)`);
|
|
2310
|
+
}
|
|
2090
2311
|
|
|
2091
|
-
// src/cli/commands/
|
|
2092
|
-
function
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2312
|
+
// src/cli/commands/check.ts
|
|
2313
|
+
function glyphFor(status) {
|
|
2314
|
+
switch (status) {
|
|
2315
|
+
case "pass":
|
|
2316
|
+
return "\u2713";
|
|
2317
|
+
case "needs-judgment":
|
|
2318
|
+
return "?";
|
|
2319
|
+
case "stale-baseline":
|
|
2320
|
+
case "missing-baseline":
|
|
2321
|
+
return "!";
|
|
2322
|
+
default:
|
|
2323
|
+
return "\u2717";
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
function makeProgressReporter(out) {
|
|
2327
|
+
if (out.isJson() || out.isQuiet()) return void 0;
|
|
2328
|
+
let done = 0;
|
|
2329
|
+
let total = 0;
|
|
2330
|
+
const counter = () => total > 0 ? `[${done}/${total}]` : `[${done}]`;
|
|
2331
|
+
return (event) => {
|
|
2332
|
+
if (event.type === "report") {
|
|
2333
|
+
total = event.report.totalEntries;
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
if (event.type === "result") {
|
|
2337
|
+
done += 1;
|
|
2338
|
+
const r = event.result;
|
|
2339
|
+
const detail = r.status === "fail" && typeof r.diffPercentage === "number" ? ` (${r.diffPercentage.toFixed(3)}%)` : r.status !== "pass" && r.message ? ` (${r.message})` : "";
|
|
2340
|
+
process.stderr.write(
|
|
2341
|
+
`${counter()} ${glyphFor(r.status)} ${r.id}${detail}
|
|
2342
|
+
`
|
|
2343
|
+
);
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
if (event.type === "interrupt") {
|
|
2347
|
+
done += 1;
|
|
2348
|
+
process.stderr.write(
|
|
2349
|
+
`${counter()} ? ${event.interrupt.entryId} (awaiting judgment)
|
|
2350
|
+
`
|
|
2351
|
+
);
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
}
|
|
2355
|
+
function registerCheck(program, out) {
|
|
2356
|
+
program.command("check").description("run the visual regression check (CI verb)").option("--base-url <url>", "override base URL").option(
|
|
2357
|
+
"--threshold <n>",
|
|
2358
|
+
"color threshold (0-1)",
|
|
2359
|
+
String(DEFAULT_THRESHOLD)
|
|
2360
|
+
).option(
|
|
2361
|
+
"--concurrency <n>",
|
|
2362
|
+
"max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
|
|
2363
|
+
).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
|
|
2364
|
+
"--judge <backend>",
|
|
2365
|
+
"judge backend for ambiguous diffs (host | none)",
|
|
2366
|
+
"none"
|
|
2367
|
+
).option(
|
|
2368
|
+
"--apply-judgments",
|
|
2369
|
+
"resume the suspended graph using .blazediff/judgments/<id>/verdict.json files"
|
|
2370
|
+
).action(async (opts) => {
|
|
2371
|
+
if (opts.applyJudgments) {
|
|
2372
|
+
const { report: report2, applied, missing, invalid } = await applyJudgments({
|
|
2373
|
+
onEvent: makeProgressReporter(out),
|
|
2374
|
+
junitPath: opts.junit
|
|
2375
|
+
});
|
|
2376
|
+
const summaryPath2 = paths().summary;
|
|
2377
|
+
const human2 = applied.length === 0 && missing.length === 0 && invalid.length === 0 ? `no judgments to apply
|
|
2378
|
+
summary: ${summaryPath2}` : [
|
|
2379
|
+
`applied ${applied.length} judgment(s)`,
|
|
2380
|
+
missing.length ? ` ${missing.length} pending without judgment: ${missing.join(", ")}` : void 0,
|
|
2381
|
+
invalid.length ? ` ${invalid.length} invalid judgment file(s): ${invalid.join(", ")}` : void 0,
|
|
2382
|
+
` ${report2.passed}/${report2.totalEntries} passed (${report2.failed} failed, ${report2.pendingJudgments} pending)`,
|
|
2383
|
+
` summary: ${summaryPath2}`
|
|
2384
|
+
].filter(Boolean).join("\n");
|
|
2385
|
+
out.emit(
|
|
2386
|
+
{
|
|
2387
|
+
ok: true,
|
|
2388
|
+
applied,
|
|
2389
|
+
missing,
|
|
2390
|
+
invalid,
|
|
2391
|
+
...slimReport(report2, summaryPath2)
|
|
2392
|
+
},
|
|
2393
|
+
human2
|
|
2394
|
+
);
|
|
2395
|
+
if (report2.failed > 0) process.exitCode = 1;
|
|
2396
|
+
return;
|
|
2397
|
+
}
|
|
2398
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
2399
|
+
const report = await runGraph({
|
|
2400
|
+
baseUrl,
|
|
2401
|
+
threshold: parseThreshold(opts.threshold),
|
|
2402
|
+
concurrency: opts.concurrency ? parsePositiveInteger(opts.concurrency, "--concurrency") : void 0,
|
|
2403
|
+
emitDiffPng: opts.diffPng,
|
|
2404
|
+
junitPath: opts.junit,
|
|
2405
|
+
judge: parseJudge(opts.judge),
|
|
2406
|
+
onEvent: makeProgressReporter(out)
|
|
2108
2407
|
});
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2408
|
+
const summaryPath = paths().summary;
|
|
2409
|
+
const summary = report.pendingJudgments > 0 ? `${report.passed}/${report.totalEntries} passed (${report.failed} failed, ${report.pendingJudgments} pending judgment)` : report.failed === 0 ? `${report.passed}/${report.totalEntries} passed` : `${report.passed}/${report.totalEntries} passed (${report.failed} failed)`;
|
|
2410
|
+
const human = report.failed === 0 && report.pendingJudgments === 0 ? `${summary}
|
|
2411
|
+
summary: ${summaryPath}` : [
|
|
2412
|
+
`${summary}:`,
|
|
2413
|
+
...failureLines(report.results),
|
|
2414
|
+
` summary: ${summaryPath}`,
|
|
2415
|
+
report.pendingJudgments > 0 ? ` pending: ${paths().judgments}/ - host writes <id>/verdict.json, then re-run check --apply-judgments` : void 0
|
|
2416
|
+
].filter(Boolean).join("\n");
|
|
2417
|
+
out.emit(slimReport(report, summaryPath), human);
|
|
2418
|
+
if (report.failed > 0) process.exitCode = 1;
|
|
2114
2419
|
});
|
|
2115
|
-
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
// src/cli/commands/diff.ts
|
|
2423
|
+
function registerDiff(program, out) {
|
|
2424
|
+
program.command("diff <id>").description("diff a route's baseline against its actual capture").option(
|
|
2425
|
+
"--threshold <n>",
|
|
2426
|
+
"color threshold (0-1)",
|
|
2427
|
+
String(DEFAULT_THRESHOLD)
|
|
2428
|
+
).option("--emit-diff-png", "write diff PNG to .blazediff/diffs/").action(async (id, opts) => {
|
|
2116
2429
|
const manifest = await loadManifest();
|
|
2117
2430
|
if (!manifest) throw new Error("no manifest");
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
const
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
}
|
|
2431
|
+
const entry = findEntry(manifest, id);
|
|
2432
|
+
if (!entry) throw new Error(`no entry with id ${id}`);
|
|
2433
|
+
const baselinePath = `${paths().baselines}/${id}.png`;
|
|
2434
|
+
const actualPath = `${paths().actual}/${id}.png`;
|
|
2435
|
+
const outcome = await diffEntry(id, baselinePath, actualPath, {
|
|
2436
|
+
threshold: Number(opts.threshold),
|
|
2437
|
+
emitDiffPng: Boolean(opts.emitDiffPng)
|
|
2438
|
+
});
|
|
2127
2439
|
out.emit(
|
|
2128
|
-
|
|
2129
|
-
|
|
2440
|
+
outcome,
|
|
2441
|
+
outcome.match ? `${id}: match` : `${id}: ${outcome.reason ?? "diff"}`
|
|
2130
2442
|
);
|
|
2443
|
+
if (!outcome.match) process.exitCode = 1;
|
|
2131
2444
|
});
|
|
2132
2445
|
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
id: "claude",
|
|
2137
|
-
label: "Claude Code",
|
|
2138
|
-
detect: (cwd) => someExists([
|
|
2139
|
-
path2.join(cwd, ".claude"),
|
|
2140
|
-
path2.join(cwd, "CLAUDE.md"),
|
|
2141
|
-
path2.join(cwd, "AGENTS.md")
|
|
2142
|
-
]),
|
|
2143
|
-
target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
|
|
2144
|
-
format: "skill-file",
|
|
2145
|
-
scope: "project"
|
|
2146
|
-
},
|
|
2147
|
-
codex: {
|
|
2148
|
-
id: "codex",
|
|
2149
|
-
label: "Codex",
|
|
2150
|
-
detect: (cwd) => someExists([
|
|
2151
|
-
path2.join(cwd, "AGENTS.md"),
|
|
2152
|
-
path2.join(cwd, ".codex"),
|
|
2153
|
-
path2.join(os.homedir(), ".codex")
|
|
2154
|
-
]),
|
|
2155
|
-
target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
|
|
2156
|
-
format: "skill-file",
|
|
2157
|
-
scope: "user"
|
|
2158
|
-
},
|
|
2159
|
-
cursor: {
|
|
2160
|
-
id: "cursor",
|
|
2161
|
-
label: "Cursor",
|
|
2162
|
-
detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
|
|
2163
|
-
target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
|
|
2164
|
-
format: "cursor-rule",
|
|
2165
|
-
scope: "project"
|
|
2166
|
-
}
|
|
2167
|
-
};
|
|
2168
|
-
var ALL_HARNESSES = ["claude", "codex", "cursor"];
|
|
2169
|
-
function detectHarnesses(cwd) {
|
|
2170
|
-
return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
|
|
2171
|
-
}
|
|
2172
|
-
function parseHarnessList(input) {
|
|
2173
|
-
const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2174
|
-
if (tokens.includes("all")) return [...ALL_HARNESSES];
|
|
2446
|
+
|
|
2447
|
+
// src/discover/crawl.ts
|
|
2448
|
+
function extractInternalLinks(base, target, hrefs) {
|
|
2175
2449
|
const out = [];
|
|
2176
|
-
for (const
|
|
2177
|
-
if (!(
|
|
2178
|
-
|
|
2179
|
-
`unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
|
|
2180
|
-
);
|
|
2450
|
+
for (const href of hrefs) {
|
|
2451
|
+
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) {
|
|
2452
|
+
continue;
|
|
2181
2453
|
}
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
function moduleDir() {
|
|
2190
|
-
return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
|
|
2191
|
-
}
|
|
2192
|
-
function resolveSkillDir() {
|
|
2193
|
-
if (cachedDir !== null) return cachedDir;
|
|
2194
|
-
const here = moduleDir();
|
|
2195
|
-
const candidates = [
|
|
2196
|
-
path2.join(here, ".."),
|
|
2197
|
-
path2.join(here, "..", ".."),
|
|
2198
|
-
path2.join(here, "..", "..", "..", "skill", "blazediff"),
|
|
2199
|
-
path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
|
|
2200
|
-
];
|
|
2201
|
-
for (const dir of candidates) {
|
|
2202
|
-
if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
|
|
2203
|
-
cachedDir = dir;
|
|
2204
|
-
return cachedDir;
|
|
2454
|
+
try {
|
|
2455
|
+
const u = new URL(href, target);
|
|
2456
|
+
if (u.origin !== base.origin) continue;
|
|
2457
|
+
const path23 = u.pathname + u.search;
|
|
2458
|
+
if (path23.startsWith("/api/")) continue;
|
|
2459
|
+
out.push(path23);
|
|
2460
|
+
} catch {
|
|
2205
2461
|
}
|
|
2206
2462
|
}
|
|
2207
|
-
|
|
2208
|
-
`could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
|
|
2209
|
-
);
|
|
2463
|
+
return out;
|
|
2210
2464
|
}
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
);
|
|
2217
|
-
|
|
2218
|
-
}
|
|
2219
|
-
|
|
2220
|
-
const
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
if (
|
|
2225
|
-
|
|
2226
|
-
|
|
2465
|
+
var CRAWL_VIEWPORT = { width: 1024, height: 768 };
|
|
2466
|
+
var CRAWL_WORKERS = 4;
|
|
2467
|
+
async function crawlRoutes(opts) {
|
|
2468
|
+
const maxRoutes = opts.maxRoutes ?? 50;
|
|
2469
|
+
const maxDepth = opts.maxDepth ?? 2;
|
|
2470
|
+
const base = new URL(opts.baseUrl);
|
|
2471
|
+
const visited = /* @__PURE__ */ new Set(["/"]);
|
|
2472
|
+
const queue = [{ url: "/", depth: 0 }];
|
|
2473
|
+
const discovered = [];
|
|
2474
|
+
const handle = await acquireStableContext(CRAWL_VIEWPORT);
|
|
2475
|
+
const fetchOne = async () => {
|
|
2476
|
+
while (queue.length && discovered.length < maxRoutes) {
|
|
2477
|
+
const item = queue.shift();
|
|
2478
|
+
if (!item) return;
|
|
2479
|
+
if (discovered.length >= maxRoutes) return;
|
|
2480
|
+
discovered.push({ url: item.url, source: "crawl" });
|
|
2481
|
+
if (item.depth >= maxDepth) continue;
|
|
2482
|
+
const page = await handle.context.newPage();
|
|
2483
|
+
try {
|
|
2484
|
+
const target = new URL(item.url, base).toString();
|
|
2485
|
+
await page.goto(target, {
|
|
2486
|
+
waitUntil: "domcontentloaded",
|
|
2487
|
+
timeout: 15e3
|
|
2488
|
+
});
|
|
2489
|
+
const hrefs = await page.evaluate(
|
|
2490
|
+
() => Array.from(
|
|
2491
|
+
document.querySelectorAll("a[href]")
|
|
2492
|
+
).map((a) => a.getAttribute("href") ?? "")
|
|
2493
|
+
);
|
|
2494
|
+
for (const p of extractInternalLinks(base, target, hrefs)) {
|
|
2495
|
+
if (visited.has(p)) continue;
|
|
2496
|
+
visited.add(p);
|
|
2497
|
+
queue.push({ url: p, depth: item.depth + 1 });
|
|
2498
|
+
}
|
|
2499
|
+
} catch {
|
|
2500
|
+
} finally {
|
|
2501
|
+
await page.close().catch(() => {
|
|
2502
|
+
});
|
|
2227
2503
|
}
|
|
2228
2504
|
}
|
|
2229
|
-
|
|
2230
|
-
|
|
2505
|
+
};
|
|
2506
|
+
try {
|
|
2507
|
+
await Promise.all(Array.from({ length: CRAWL_WORKERS }, () => fetchOne()));
|
|
2508
|
+
} finally {
|
|
2509
|
+
await releaseStableContext(handle);
|
|
2231
2510
|
}
|
|
2232
|
-
return
|
|
2511
|
+
return discovered.slice(0, maxRoutes);
|
|
2233
2512
|
}
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2513
|
+
var DYNAMIC_SEGMENT = /\[[^\]]+\]/;
|
|
2514
|
+
async function readJson2(file) {
|
|
2515
|
+
if (!fs.existsSync(file)) return null;
|
|
2516
|
+
try {
|
|
2517
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
2518
|
+
} catch {
|
|
2519
|
+
return null;
|
|
2520
|
+
}
|
|
2239
2521
|
}
|
|
2240
|
-
function
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
const frontmatter = [
|
|
2245
|
-
"---",
|
|
2246
|
-
'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
|
|
2247
|
-
"alwaysApply: false",
|
|
2248
|
-
"---",
|
|
2249
|
-
""
|
|
2250
|
-
].join("\n");
|
|
2251
|
-
const sidecarBlocks = sidecars.map((f) => `
|
|
2252
|
-
|
|
2253
|
-
---
|
|
2254
|
-
|
|
2255
|
-
<!-- ${f.name} -->
|
|
2256
|
-
|
|
2257
|
-
${f.content.trim()}`).join("");
|
|
2258
|
-
return `${frontmatter}${body}${sidecarBlocks}
|
|
2259
|
-
`;
|
|
2522
|
+
function isPublicRoute(route) {
|
|
2523
|
+
if (DYNAMIC_SEGMENT.test(route)) return false;
|
|
2524
|
+
if (route === "/api" || route.startsWith("/api/")) return false;
|
|
2525
|
+
return true;
|
|
2260
2526
|
}
|
|
2261
|
-
async function
|
|
2262
|
-
const
|
|
2263
|
-
|
|
2264
|
-
const
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2527
|
+
async function discoverFromNextManifest(cwd = process.cwd()) {
|
|
2528
|
+
const nextDir = path2__default.default.join(cwd, ".next");
|
|
2529
|
+
if (!fs.existsSync(nextDir)) return [];
|
|
2530
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2531
|
+
const out = [];
|
|
2532
|
+
const add = (url) => {
|
|
2533
|
+
if (seen.has(url)) return;
|
|
2534
|
+
seen.add(url);
|
|
2535
|
+
out.push({ url, source: "next-manifest" });
|
|
2536
|
+
};
|
|
2537
|
+
const routes = await readJson2(
|
|
2538
|
+
path2__default.default.join(nextDir, "routes-manifest.json")
|
|
2539
|
+
);
|
|
2540
|
+
for (const r of routes?.staticRoutes ?? []) {
|
|
2541
|
+
if (isPublicRoute(r.page)) add(r.page);
|
|
2270
2542
|
}
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2543
|
+
const appPaths = await readJson2(
|
|
2544
|
+
path2__default.default.join(nextDir, "server", "app-paths-manifest.json")
|
|
2545
|
+
);
|
|
2546
|
+
for (const route of Object.keys(appPaths ?? {})) {
|
|
2547
|
+
const normalized = route.replace(/\/page$/, "") || "/";
|
|
2548
|
+
if (isPublicRoute(normalized)) add(normalized);
|
|
2275
2549
|
}
|
|
2276
|
-
|
|
2277
|
-
await promises.writeFile(target, content, "utf8");
|
|
2278
|
-
return exists ? "updated" : "created";
|
|
2279
|
-
}
|
|
2280
|
-
function combineStatuses(statuses) {
|
|
2281
|
-
if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
|
|
2282
|
-
if (statuses.some((s) => s === "created")) return "created";
|
|
2283
|
-
if (statuses.some((s) => s === "updated")) return "updated";
|
|
2284
|
-
return "unchanged";
|
|
2550
|
+
return out;
|
|
2285
2551
|
}
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
statuses.push(status);
|
|
2552
|
+
|
|
2553
|
+
// src/discover/sitemap.ts
|
|
2554
|
+
var CANDIDATES = ["/sitemap.xml", "/sitemap_index.xml"];
|
|
2555
|
+
var LOC_RE = /<loc>([^<]+)<\/loc>/g;
|
|
2556
|
+
async function discoverFromSitemap(baseUrl) {
|
|
2557
|
+
for (const candidate of CANDIDATES) {
|
|
2558
|
+
try {
|
|
2559
|
+
const res = await fetch(new URL(candidate, baseUrl));
|
|
2560
|
+
if (!res.ok) continue;
|
|
2561
|
+
const text = await res.text();
|
|
2562
|
+
const urls = Array.from(text.matchAll(LOC_RE)).map((m) => m[1]);
|
|
2563
|
+
if (!urls.length) continue;
|
|
2564
|
+
return urls.map((u) => {
|
|
2565
|
+
const url = new URL(u);
|
|
2566
|
+
return { url: url.pathname + url.search, source: "sitemap" };
|
|
2567
|
+
});
|
|
2568
|
+
} catch {
|
|
2569
|
+
}
|
|
2305
2570
|
}
|
|
2306
|
-
return
|
|
2571
|
+
return [];
|
|
2307
2572
|
}
|
|
2308
2573
|
|
|
2309
|
-
// src/
|
|
2310
|
-
function
|
|
2311
|
-
const
|
|
2312
|
-
|
|
2313
|
-
const
|
|
2314
|
-
|
|
2315
|
-
return `
|
|
2316
|
-
Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
|
|
2574
|
+
// src/discover/index.ts
|
|
2575
|
+
function normalizePath(url) {
|
|
2576
|
+
const [pathPart, query = ""] = url.split("?", 2);
|
|
2577
|
+
const trimmed = pathPart.replace(/\/+$/, "");
|
|
2578
|
+
const normalizedPath = trimmed === "" ? "/" : trimmed;
|
|
2579
|
+
return query ? `${normalizedPath}?${query}` : normalizedPath;
|
|
2317
2580
|
}
|
|
2318
|
-
|
|
2319
|
-
const
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
|
|
2336
|
-
const idx = Number(answer);
|
|
2337
|
-
if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
|
|
2338
|
-
throw new Error(
|
|
2339
|
-
`invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
|
|
2581
|
+
function mergeBy(routes, into) {
|
|
2582
|
+
for (const r of routes) {
|
|
2583
|
+
const key = normalizePath(r.url);
|
|
2584
|
+
if (!into.has(key)) into.set(key, { ...r, url: key });
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
async function discover(opts) {
|
|
2588
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
2589
|
+
const merged = /* @__PURE__ */ new Map();
|
|
2590
|
+
mergeBy(await discoverFromNextManifest(cwd), merged);
|
|
2591
|
+
mergeBy(await discoverFromSitemap(opts.baseUrl), merged);
|
|
2592
|
+
if (!opts.skipCrawl) {
|
|
2593
|
+
const crawlMax = Math.max(0, (opts.maxRoutes ?? 50) - merged.size);
|
|
2594
|
+
if (crawlMax > 0) {
|
|
2595
|
+
mergeBy(
|
|
2596
|
+
await crawlRoutes({ baseUrl: opts.baseUrl, maxRoutes: crawlMax }),
|
|
2597
|
+
merged
|
|
2340
2598
|
);
|
|
2341
2599
|
}
|
|
2342
|
-
return [ALL_HARNESSES[idx - 1]];
|
|
2343
|
-
} finally {
|
|
2344
|
-
rl.close();
|
|
2345
2600
|
}
|
|
2601
|
+
return Array.from(merged.values()).sort((a, b) => a.url.localeCompare(b.url));
|
|
2346
2602
|
}
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2603
|
+
|
|
2604
|
+
// src/cli/commands/discover.ts
|
|
2605
|
+
function registerDiscover(program, out) {
|
|
2606
|
+
program.command("discover").description(
|
|
2607
|
+
"enumerate candidate routes via BFS crawl + Next manifest + sitemap"
|
|
2608
|
+
).option("--base-url <url>", "override base URL").option("--max-routes <n>", "cap on routes returned", "50").option("--no-crawl", "skip BFS crawl fallback").action(async (opts) => {
|
|
2609
|
+
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
2610
|
+
const routes = await discover({
|
|
2611
|
+
baseUrl,
|
|
2612
|
+
maxRoutes: Number(opts.maxRoutes),
|
|
2613
|
+
skipCrawl: !opts.crawl
|
|
2614
|
+
});
|
|
2615
|
+
await closeBrowser();
|
|
2616
|
+
out.emit(
|
|
2617
|
+
{ ok: true, baseUrl, routes },
|
|
2618
|
+
routes.length ? routes.map((r) => `${r.source.padEnd(14)} ${r.url}`).join("\n") : "no routes discovered"
|
|
2619
|
+
);
|
|
2353
2620
|
});
|
|
2354
|
-
const installed = results.map((r) => r.harness);
|
|
2355
|
-
const hint = suggestOtherHarnesses(installed);
|
|
2356
|
-
return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
|
|
2357
2621
|
}
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
targets = detected;
|
|
2376
|
-
} else if (out.isTTY() && !out.isJson()) {
|
|
2377
|
-
targets = await promptForHarnesses();
|
|
2378
|
-
} else {
|
|
2379
|
-
throw new Error(
|
|
2380
|
-
"no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
|
|
2381
|
-
);
|
|
2382
|
-
}
|
|
2383
|
-
}
|
|
2384
|
-
const results = [];
|
|
2385
|
-
for (const t of targets) {
|
|
2386
|
-
results.push(await installHarness(t, cwd, { force: opts.force }));
|
|
2387
|
-
}
|
|
2388
|
-
out.emit(
|
|
2389
|
-
{
|
|
2390
|
-
ok: true,
|
|
2391
|
-
detected: detectHarnesses(cwd),
|
|
2392
|
-
installed: results
|
|
2393
|
-
},
|
|
2394
|
-
humanizeResults(results)
|
|
2395
|
-
);
|
|
2396
|
-
});
|
|
2622
|
+
|
|
2623
|
+
// src/introspect/framework.ts
|
|
2624
|
+
var SIGNALS = [
|
|
2625
|
+
["next", ["next"]],
|
|
2626
|
+
["remix", ["@remix-run/dev", "@remix-run/serve"]],
|
|
2627
|
+
["sveltekit", ["@sveltejs/kit"]],
|
|
2628
|
+
["nuxt", ["nuxt", "nuxt3"]],
|
|
2629
|
+
["astro", ["astro"]],
|
|
2630
|
+
["gatsby", ["gatsby"]],
|
|
2631
|
+
["vite-react", ["vite", "react"]]
|
|
2632
|
+
];
|
|
2633
|
+
function detectFramework(pkg) {
|
|
2634
|
+
const deps = pkg.allDependencies;
|
|
2635
|
+
for (const [framework, required] of SIGNALS) {
|
|
2636
|
+
if (required.every((d) => d in deps)) return framework;
|
|
2637
|
+
}
|
|
2638
|
+
return "unknown";
|
|
2397
2639
|
}
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
socket.setTimeout(500);
|
|
2403
|
-
socket.once("connect", () => {
|
|
2404
|
-
socket.destroy();
|
|
2405
|
-
resolve(true);
|
|
2406
|
-
});
|
|
2407
|
-
socket.once("error", () => resolve(false));
|
|
2408
|
-
socket.once("timeout", () => {
|
|
2409
|
-
socket.destroy();
|
|
2410
|
-
resolve(false);
|
|
2411
|
-
});
|
|
2412
|
-
});
|
|
2640
|
+
async function readPackageJson(cwd = process.cwd()) {
|
|
2641
|
+
const file = path2__default.default.join(cwd, "package.json");
|
|
2642
|
+
if (!fs.existsSync(file)) return null;
|
|
2643
|
+
return JSON.parse(await promises.readFile(file, "utf8"));
|
|
2413
2644
|
}
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2645
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
2646
|
+
let dir = cwd;
|
|
2647
|
+
const { root } = path2__default.default.parse(dir);
|
|
2648
|
+
while (true) {
|
|
2649
|
+
if (fs.existsSync(path2__default.default.join(dir, "pnpm-lock.yaml"))) return "pnpm";
|
|
2650
|
+
if (fs.existsSync(path2__default.default.join(dir, "bun.lockb")) || fs.existsSync(path2__default.default.join(dir, "bun.lock")))
|
|
2651
|
+
return "bun";
|
|
2652
|
+
if (fs.existsSync(path2__default.default.join(dir, "yarn.lock"))) return "yarn";
|
|
2653
|
+
if (fs.existsSync(path2__default.default.join(dir, "package-lock.json"))) return "npm";
|
|
2654
|
+
if (dir === root) return "npm";
|
|
2655
|
+
dir = path2__default.default.dirname(dir);
|
|
2419
2656
|
}
|
|
2420
|
-
throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
|
|
2421
2657
|
}
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
return
|
|
2658
|
+
var DEFAULT_PORTS = {
|
|
2659
|
+
next: 3e3,
|
|
2660
|
+
"react-scripts": 3e3,
|
|
2661
|
+
vite: 5173,
|
|
2662
|
+
remix: 3e3,
|
|
2663
|
+
"@remix-run/dev": 3e3,
|
|
2664
|
+
astro: 4321,
|
|
2665
|
+
svelte: 5173,
|
|
2666
|
+
vue: 5173,
|
|
2667
|
+
nuxt: 3e3,
|
|
2668
|
+
gatsby: 8e3,
|
|
2669
|
+
parcel: 1234
|
|
2670
|
+
};
|
|
2671
|
+
var DEV_SCRIPT_CANDIDATES = ["dev", "start", "serve", "develop"];
|
|
2672
|
+
function inferPort(script, deps) {
|
|
2673
|
+
const portArg = script.match(/(?:--port[\s=]|-p\s+)(\d+)/);
|
|
2674
|
+
if (portArg) return Number(portArg[1]);
|
|
2675
|
+
const portEnv = script.match(/PORT[\s=]+(\d+)/);
|
|
2676
|
+
if (portEnv) return Number(portEnv[1]);
|
|
2677
|
+
const depNames = Object.keys(deps);
|
|
2678
|
+
for (const [pkg, port] of Object.entries(DEFAULT_PORTS)) {
|
|
2679
|
+
if (depNames.some((d) => d.startsWith(pkg))) return port;
|
|
2444
2680
|
}
|
|
2445
|
-
return
|
|
2681
|
+
return DEFAULT_PORT;
|
|
2446
2682
|
}
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2683
|
+
function runnerFor(pm, scriptName) {
|
|
2684
|
+
if (pm === "npm") return `npm run ${scriptName}`;
|
|
2685
|
+
if (pm === "yarn") return `yarn ${scriptName}`;
|
|
2686
|
+
if (pm === "bun") return `bun run ${scriptName}`;
|
|
2687
|
+
return `pnpm ${scriptName}`;
|
|
2688
|
+
}
|
|
2689
|
+
function collectCandidates(scripts, deps, pm) {
|
|
2690
|
+
const out = [];
|
|
2691
|
+
for (const name of DEV_SCRIPT_CANDIDATES) {
|
|
2692
|
+
if (scripts[name]) {
|
|
2693
|
+
out.push({
|
|
2694
|
+
name,
|
|
2695
|
+
body: scripts[name],
|
|
2696
|
+
command: runnerFor(pm, name),
|
|
2697
|
+
port: inferPort(scripts[name], deps)
|
|
2456
2698
|
});
|
|
2457
2699
|
}
|
|
2458
|
-
return {
|
|
2459
|
-
pid: discoveredPid ?? 0,
|
|
2460
|
-
port: opts.port,
|
|
2461
|
-
url: `http://127.0.0.1:${opts.port}`,
|
|
2462
|
-
attached: true
|
|
2463
|
-
};
|
|
2464
2700
|
}
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
}
|
|
2472
|
-
const
|
|
2473
|
-
|
|
2701
|
+
return out;
|
|
2702
|
+
}
|
|
2703
|
+
async function introspectPackage(cwd = process.cwd()) {
|
|
2704
|
+
const pkg = await readPackageJson(cwd);
|
|
2705
|
+
if (!pkg) return null;
|
|
2706
|
+
const packageManager = detectPackageManager(cwd);
|
|
2707
|
+
const scripts = pkg.scripts ?? {};
|
|
2708
|
+
const devDependencies = pkg.devDependencies ?? {};
|
|
2709
|
+
const dependencies = pkg.dependencies ?? {};
|
|
2710
|
+
const allDependencies = { ...devDependencies, ...dependencies };
|
|
2711
|
+
const candidates = collectCandidates(
|
|
2712
|
+
scripts,
|
|
2713
|
+
allDependencies,
|
|
2714
|
+
packageManager
|
|
2474
2715
|
);
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
if (!child.pid) throw new Error("failed to spawn dev server");
|
|
2478
|
-
await promises.writeFile(pidPath, String(child.pid), "utf8");
|
|
2479
|
-
installSignalHandlers(child);
|
|
2480
|
-
try {
|
|
2481
|
-
await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
|
|
2482
|
-
} catch (err) {
|
|
2483
|
-
await stopProcess(child.pid);
|
|
2484
|
-
throw err;
|
|
2485
|
-
}
|
|
2716
|
+
if (!candidates.length) return null;
|
|
2717
|
+
const chosen = candidates[0];
|
|
2486
2718
|
return {
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2719
|
+
packageManager,
|
|
2720
|
+
devScript: chosen.name,
|
|
2721
|
+
devCommand: chosen.command,
|
|
2722
|
+
port: chosen.port,
|
|
2723
|
+
candidates,
|
|
2724
|
+
devDependencies,
|
|
2725
|
+
dependencies,
|
|
2726
|
+
allDependencies
|
|
2490
2727
|
};
|
|
2491
2728
|
}
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
pid = parsed;
|
|
2501
|
-
via = "pidfile";
|
|
2729
|
+
|
|
2730
|
+
// src/cli/commands/init.ts
|
|
2731
|
+
async function buildConfig(opts) {
|
|
2732
|
+
if (opts.url) {
|
|
2733
|
+
if (opts.devCommand || opts.port || opts.devScript) {
|
|
2734
|
+
throw new Error(
|
|
2735
|
+
"--url is mutually exclusive with --dev-command/--port/--dev-script"
|
|
2736
|
+
);
|
|
2502
2737
|
}
|
|
2738
|
+
const baseUrl = new URL(opts.url).toString().replace(/\/$/, "");
|
|
2739
|
+
return { devServer: null, baseUrl };
|
|
2503
2740
|
}
|
|
2504
|
-
if (
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2741
|
+
if (opts.devCommand) {
|
|
2742
|
+
const port2 = opts.port ? parsePort(opts.port) : DEFAULT_PORT;
|
|
2743
|
+
return {
|
|
2744
|
+
devServer: {
|
|
2745
|
+
command: opts.devCommand,
|
|
2746
|
+
port: port2,
|
|
2747
|
+
readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
|
|
2748
|
+
},
|
|
2749
|
+
baseUrl: `http://127.0.0.1:${port2}`
|
|
2750
|
+
};
|
|
2512
2751
|
}
|
|
2513
|
-
await
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
function processExists(pid) {
|
|
2519
|
-
try {
|
|
2520
|
-
process.kill(pid, 0);
|
|
2521
|
-
return true;
|
|
2522
|
-
} catch {
|
|
2523
|
-
return false;
|
|
2752
|
+
const pkg = await introspectPackage();
|
|
2753
|
+
if (!pkg) {
|
|
2754
|
+
throw new Error(
|
|
2755
|
+
"no package.json with a dev/start script in cwd. Pass --url <baseUrl> or --dev-command <cmd>."
|
|
2756
|
+
);
|
|
2524
2757
|
}
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
return;
|
|
2533
|
-
}
|
|
2534
|
-
resolve();
|
|
2535
|
-
});
|
|
2536
|
-
});
|
|
2537
|
-
}
|
|
2538
|
-
function parseCommand(command) {
|
|
2539
|
-
const out = [];
|
|
2540
|
-
let current = "";
|
|
2541
|
-
let inQuote = false;
|
|
2542
|
-
for (const ch of command) {
|
|
2543
|
-
if (ch === '"') {
|
|
2544
|
-
inQuote = !inQuote;
|
|
2545
|
-
continue;
|
|
2758
|
+
let chosen = pkg.candidates[0];
|
|
2759
|
+
if (pkg.candidates.length > 1) {
|
|
2760
|
+
if (!opts.devScript) {
|
|
2761
|
+
const names = pkg.candidates.map((c) => `${c.name} (${c.command})`).join(", ");
|
|
2762
|
+
throw new Error(
|
|
2763
|
+
`multiple dev-script candidates: ${names}. Pass --dev-script <name> or --dev-command <cmd>.`
|
|
2764
|
+
);
|
|
2546
2765
|
}
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2766
|
+
const match = pkg.candidates.find((c) => c.name === opts.devScript);
|
|
2767
|
+
if (!match) {
|
|
2768
|
+
const names = pkg.candidates.map((c) => c.name).join(", ");
|
|
2769
|
+
throw new Error(
|
|
2770
|
+
`--dev-script "${opts.devScript}" not found among candidates: ${names}`
|
|
2771
|
+
);
|
|
2553
2772
|
}
|
|
2554
|
-
|
|
2773
|
+
chosen = match;
|
|
2555
2774
|
}
|
|
2556
|
-
|
|
2557
|
-
return
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
process.kill(-child.pid, "SIGTERM");
|
|
2567
|
-
} catch {
|
|
2568
|
-
}
|
|
2569
|
-
treeKill__default.default(child.pid, "SIGKILL", () => {
|
|
2570
|
-
});
|
|
2571
|
-
}
|
|
2775
|
+
const port = opts.port ? parsePort(opts.port) : chosen.port;
|
|
2776
|
+
return {
|
|
2777
|
+
devServer: {
|
|
2778
|
+
command: chosen.command,
|
|
2779
|
+
port,
|
|
2780
|
+
readyTimeoutMs: DEFAULT_READY_TIMEOUT_MS
|
|
2781
|
+
},
|
|
2782
|
+
framework: detectFramework(pkg),
|
|
2783
|
+
packageManager: pkg.packageManager,
|
|
2784
|
+
baseUrl: `http://127.0.0.1:${port}`
|
|
2572
2785
|
};
|
|
2573
|
-
process.on("SIGINT", () => {
|
|
2574
|
-
cleanup();
|
|
2575
|
-
process.exit(130);
|
|
2576
|
-
});
|
|
2577
|
-
process.on("SIGTERM", () => {
|
|
2578
|
-
cleanup();
|
|
2579
|
-
process.exit(143);
|
|
2580
|
-
});
|
|
2581
|
-
process.on("exit", cleanup);
|
|
2582
|
-
}
|
|
2583
|
-
|
|
2584
|
-
// src/cli/commands/reset.ts
|
|
2585
|
-
async function stopTrackedServer() {
|
|
2586
|
-
const config = await loadConfig();
|
|
2587
|
-
if (!config?.devServer) return { stopped: false };
|
|
2588
|
-
try {
|
|
2589
|
-
const result = await stopServer(process.cwd(), config.devServer.port);
|
|
2590
|
-
return { stopped: result.killed, via: result.via, pid: result.pid };
|
|
2591
|
-
} catch {
|
|
2592
|
-
return { stopped: false };
|
|
2593
|
-
}
|
|
2594
2786
|
}
|
|
2595
|
-
function
|
|
2596
|
-
program.command("
|
|
2597
|
-
"
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2787
|
+
function registerInit(program, out) {
|
|
2788
|
+
program.command("init").description("write .blazediff/config.json and .gitignore").option("--force", "overwrite existing config").option(
|
|
2789
|
+
"--url <baseUrl>",
|
|
2790
|
+
"point at an already-running server / external URL"
|
|
2791
|
+
).option("--dev-command <cmd>", "override detected dev-server command").option("--port <n>", "override detected port").option(
|
|
2792
|
+
"--dev-script <name>",
|
|
2793
|
+
"select a dev script by name when multiple candidates exist"
|
|
2794
|
+
).action(async (opts) => {
|
|
2795
|
+
const existing = await loadConfig();
|
|
2796
|
+
if (existing && !opts.force) {
|
|
2797
|
+
await ensureGitignore(process.cwd());
|
|
2601
2798
|
out.emit(
|
|
2602
|
-
{
|
|
2603
|
-
|
|
2799
|
+
{
|
|
2800
|
+
ok: true,
|
|
2801
|
+
created: false,
|
|
2802
|
+
config: existing,
|
|
2803
|
+
configHash: configHash(existing)
|
|
2804
|
+
},
|
|
2805
|
+
`config exists at ${paths().config} (use --force to overwrite)`
|
|
2604
2806
|
);
|
|
2605
2807
|
return;
|
|
2606
2808
|
}
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2809
|
+
const config = await buildConfig(opts);
|
|
2810
|
+
await saveConfig(config);
|
|
2811
|
+
await ensureGitignore(process.cwd());
|
|
2812
|
+
const human = config.devServer ? `wrote ${paths().config}
|
|
2813
|
+
baseUrl: ${config.baseUrl}
|
|
2814
|
+
dev: ${config.devServer.command} (port ${config.devServer.port})` : `wrote ${paths().config}
|
|
2815
|
+
baseUrl: ${config.baseUrl}
|
|
2816
|
+
external server (no devServer managed)`;
|
|
2817
|
+
out.emit(
|
|
2818
|
+
{ ok: true, created: true, config, configHash: configHash(config) },
|
|
2819
|
+
human
|
|
2820
|
+
);
|
|
2821
|
+
});
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
// src/cli/commands/manifest.ts
|
|
2825
|
+
function registerManifest(program, out) {
|
|
2826
|
+
const cmd = program.command("manifest").description("manage .blazediff/manifest.json");
|
|
2827
|
+
cmd.command("add <id>").requiredOption("--url <url>").option("--viewport <WxH>", "viewport", "1280x800").option("--mask <selectors>", "selectors", "").option("--wait-for <list>", "wait list", "networkidle,fonts").option("--no-full-page", "viewport-only (default: full page)").option("--auth <required|none>", "mark auth-gated", "none").option("--created-by <agent|human>", "provenance", "agent").action(async (id, opts) => {
|
|
2828
|
+
const config = await loadConfig();
|
|
2829
|
+
if (!config)
|
|
2830
|
+
throw new Error("no config. Run `blazediff-agent init` first.");
|
|
2831
|
+
const manifest = await loadManifest() ?? emptyManifest(configHash(config));
|
|
2832
|
+
const entry = makeEntry({
|
|
2833
|
+
id,
|
|
2834
|
+
url: opts.url,
|
|
2835
|
+
viewport: parseViewport(opts.viewport),
|
|
2836
|
+
mask: parseMaskList(opts.mask),
|
|
2837
|
+
waitFor: parseWaitFor(opts.waitFor),
|
|
2838
|
+
fullPage: opts.fullPage,
|
|
2839
|
+
auth: opts.auth === "required" ? "required" : null,
|
|
2840
|
+
createdBy: opts.createdBy
|
|
2841
|
+
});
|
|
2842
|
+
await saveManifest(addOrReplaceEntry(manifest, entry));
|
|
2843
|
+
out.emit(
|
|
2844
|
+
{ ok: true, entry },
|
|
2845
|
+
out.isTTY() ? `manifest: added ${id} (${entry.url})` : "."
|
|
2846
|
+
);
|
|
2847
|
+
});
|
|
2848
|
+
cmd.command("remove <id>").action(async (id) => {
|
|
2849
|
+
const manifest = await loadManifest();
|
|
2850
|
+
if (!manifest) throw new Error("no manifest");
|
|
2851
|
+
await saveManifest(removeEntry(manifest, id));
|
|
2852
|
+
out.emit({ ok: true, removed: id }, `manifest: removed ${id}`);
|
|
2853
|
+
});
|
|
2854
|
+
cmd.command("list").action(async () => {
|
|
2855
|
+
const manifest = await loadManifest();
|
|
2856
|
+
if (!manifest) {
|
|
2857
|
+
out.emit({ entries: [] }, "no manifest");
|
|
2858
|
+
return;
|
|
2611
2859
|
}
|
|
2612
|
-
const stopOutcome = await stopTrackedServer();
|
|
2613
|
-
await promises.rm(root, { recursive: true, force: true });
|
|
2614
2860
|
out.emit(
|
|
2615
|
-
{
|
|
2616
|
-
|
|
2861
|
+
{ entries: manifest.entries },
|
|
2862
|
+
manifest.entries.map((e) => `${e.id.padEnd(30)} ${e.url}`).join("\n") || "no entries"
|
|
2617
2863
|
);
|
|
2618
2864
|
});
|
|
2619
2865
|
}
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2866
|
+
var someExists = (paths2) => paths2.some((p) => fs.existsSync(p));
|
|
2867
|
+
var HARNESSES = {
|
|
2868
|
+
claude: {
|
|
2869
|
+
id: "claude",
|
|
2870
|
+
label: "Claude Code",
|
|
2871
|
+
detect: (cwd) => someExists([
|
|
2872
|
+
path2.join(cwd, ".claude"),
|
|
2873
|
+
path2.join(cwd, "CLAUDE.md"),
|
|
2874
|
+
path2.join(cwd, "AGENTS.md")
|
|
2875
|
+
]),
|
|
2876
|
+
target: (cwd) => path2.join(cwd, ".claude", "skills", "blazediff", "SKILL.md"),
|
|
2877
|
+
format: "skill-file",
|
|
2878
|
+
scope: "project"
|
|
2879
|
+
},
|
|
2880
|
+
codex: {
|
|
2881
|
+
id: "codex",
|
|
2882
|
+
label: "Codex",
|
|
2883
|
+
detect: (cwd) => someExists([
|
|
2884
|
+
path2.join(cwd, "AGENTS.md"),
|
|
2885
|
+
path2.join(cwd, ".codex"),
|
|
2886
|
+
path2.join(os.homedir(), ".codex")
|
|
2887
|
+
]),
|
|
2888
|
+
target: () => path2.join(os.homedir(), ".codex", "skills", "blazediff", "SKILL.md"),
|
|
2889
|
+
format: "skill-file",
|
|
2890
|
+
scope: "user"
|
|
2891
|
+
},
|
|
2892
|
+
cursor: {
|
|
2893
|
+
id: "cursor",
|
|
2894
|
+
label: "Cursor",
|
|
2895
|
+
detect: (cwd) => someExists([path2.join(cwd, ".cursor"), path2.join(cwd, ".cursorrules")]),
|
|
2896
|
+
target: (cwd) => path2.join(cwd, ".cursor", "rules", "blazediff.mdc"),
|
|
2897
|
+
format: "cursor-rule",
|
|
2898
|
+
scope: "project"
|
|
2899
|
+
}
|
|
2900
|
+
};
|
|
2901
|
+
var ALL_HARNESSES = ["claude", "codex", "cursor"];
|
|
2902
|
+
function detectHarnesses(cwd) {
|
|
2903
|
+
return ALL_HARNESSES.filter((id) => HARNESSES[id].detect(cwd));
|
|
2904
|
+
}
|
|
2905
|
+
function parseHarnessList(input) {
|
|
2906
|
+
const tokens = input.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
|
|
2907
|
+
if (tokens.includes("all")) return [...ALL_HARNESSES];
|
|
2908
|
+
const out = [];
|
|
2909
|
+
for (const t of tokens) {
|
|
2910
|
+
if (!(t in HARNESSES)) {
|
|
2633
2911
|
throw new Error(
|
|
2634
|
-
`
|
|
2912
|
+
`unknown harness "${t}". valid: ${[...ALL_HARNESSES, "all"].join(", ")}`
|
|
2635
2913
|
);
|
|
2636
2914
|
}
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2915
|
+
if (!out.includes(t)) out.push(t);
|
|
2916
|
+
}
|
|
2917
|
+
return out;
|
|
2918
|
+
}
|
|
2919
|
+
var SKILL_FILES = ["SKILL.md", "JUDGING.md", "MASKING.md"];
|
|
2920
|
+
var cachedDir = null;
|
|
2921
|
+
var cachedFiles = null;
|
|
2922
|
+
function moduleDir() {
|
|
2923
|
+
return path2.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.js', document.baseURI).href))));
|
|
2924
|
+
}
|
|
2925
|
+
function resolveSkillDir() {
|
|
2926
|
+
if (cachedDir !== null) return cachedDir;
|
|
2927
|
+
const here = moduleDir();
|
|
2928
|
+
const candidates = [
|
|
2929
|
+
path2.join(here, ".."),
|
|
2930
|
+
path2.join(here, "..", ".."),
|
|
2931
|
+
path2.join(here, "..", "..", "..", "skill", "blazediff"),
|
|
2932
|
+
path2.join(here, "..", "..", "..", "..", "skill", "blazediff")
|
|
2933
|
+
];
|
|
2934
|
+
for (const dir of candidates) {
|
|
2935
|
+
if (fs.existsSync(path2.join(dir, "SKILL.md"))) {
|
|
2936
|
+
cachedDir = dir;
|
|
2937
|
+
return cachedDir;
|
|
2642
2938
|
}
|
|
2643
|
-
return failed;
|
|
2644
2939
|
}
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
(id) => !manifest.entries.some((e) => e.id === id)
|
|
2940
|
+
throw new Error(
|
|
2941
|
+
`could not locate bundled SKILL.md (looked in: ${candidates.join(", ")}). reinstall @blazediff/agent.`
|
|
2648
2942
|
);
|
|
2649
|
-
if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
|
|
2650
|
-
return targets;
|
|
2651
2943
|
}
|
|
2652
|
-
function
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2944
|
+
function loadSkillFiles() {
|
|
2945
|
+
if (cachedFiles !== null) return cachedFiles;
|
|
2946
|
+
const dir = resolveSkillDir();
|
|
2947
|
+
cachedFiles = SKILL_FILES.filter((name) => fs.existsSync(path2.join(dir, name))).map(
|
|
2948
|
+
(name) => ({ name, content: fs.readFileSync(path2.join(dir, name), "utf8") })
|
|
2949
|
+
);
|
|
2950
|
+
return cachedFiles;
|
|
2951
|
+
}
|
|
2952
|
+
function skillBodyOnly(content) {
|
|
2953
|
+
const lines = content.split("\n");
|
|
2954
|
+
if (lines[0]?.startsWith("---")) {
|
|
2955
|
+
let end = -1;
|
|
2956
|
+
for (let i = 1; i < lines.length; i++) {
|
|
2957
|
+
if (lines[i]?.startsWith("---")) {
|
|
2958
|
+
end = i;
|
|
2959
|
+
break;
|
|
2960
|
+
}
|
|
2663
2961
|
}
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
viewport: e.viewport,
|
|
2669
|
-
waitFor: e.waitFor,
|
|
2670
|
-
fullPage: e.fullPage
|
|
2671
|
-
}));
|
|
2672
|
-
const report = await runCaptures({
|
|
2673
|
-
baseUrl,
|
|
2674
|
-
routes,
|
|
2675
|
-
mode: "baseline",
|
|
2676
|
-
writeManifest: true
|
|
2677
|
-
});
|
|
2678
|
-
const failureLines3 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
|
|
2679
|
-
const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
|
|
2680
|
-
`rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
|
|
2681
|
-
...failureLines3
|
|
2682
|
-
].join("\n");
|
|
2683
|
-
out.emit(report, human);
|
|
2684
|
-
if (report.failed > 0) process.exitCode = 1;
|
|
2685
|
-
});
|
|
2962
|
+
if (end > 0)
|
|
2963
|
+
return lines.slice(end + 1).join("\n").trimStart();
|
|
2964
|
+
}
|
|
2965
|
+
return content;
|
|
2686
2966
|
}
|
|
2687
2967
|
|
|
2688
|
-
// src/
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
const manifest = state.manifest;
|
|
2693
|
-
if (!manifest) throw new Error("aggregateNode: manifest missing");
|
|
2694
|
-
const results = state.results;
|
|
2695
|
-
const passed = results.filter((r) => r.status === "pass").length;
|
|
2696
|
-
const pendingJudgments = results.filter(
|
|
2697
|
-
(r) => r.status === "needs-judgment"
|
|
2698
|
-
).length;
|
|
2699
|
-
const report = {
|
|
2700
|
-
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2701
|
-
totalEntries: results.length,
|
|
2702
|
-
passed,
|
|
2703
|
-
failed: results.length - passed - pendingJudgments,
|
|
2704
|
-
pendingJudgments,
|
|
2705
|
-
results
|
|
2706
|
-
};
|
|
2707
|
-
await writeJudgments({ report, manifest, cwd: options.cwd });
|
|
2708
|
-
await writeSummaryMarkdown(report, options.cwd);
|
|
2709
|
-
await ensureGitignore(options.cwd);
|
|
2710
|
-
return { report };
|
|
2968
|
+
// src/onboard/install.ts
|
|
2969
|
+
function ensureTrailingNewline(s) {
|
|
2970
|
+
return s.endsWith("\n") ? s : `${s}
|
|
2971
|
+
`;
|
|
2711
2972
|
}
|
|
2973
|
+
function renderCursorRule(files) {
|
|
2974
|
+
const skill = files.find((f) => f.name === "SKILL.md")?.content ?? "";
|
|
2975
|
+
const sidecars = files.filter((f) => f.name !== "SKILL.md");
|
|
2976
|
+
const body = skillBodyOnly(skill).trim();
|
|
2977
|
+
const frontmatter = [
|
|
2978
|
+
"---",
|
|
2979
|
+
'description: "Run, author, or update BlazeDiff visual regression tests. Trigger on visual test, screenshot regression, blazediff, /blazediff."',
|
|
2980
|
+
"alwaysApply: false",
|
|
2981
|
+
"---",
|
|
2982
|
+
""
|
|
2983
|
+
].join("\n");
|
|
2984
|
+
const sidecarBlocks = sidecars.map((f) => `
|
|
2712
2985
|
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2986
|
+
---
|
|
2987
|
+
|
|
2988
|
+
<!-- ${f.name} -->
|
|
2989
|
+
|
|
2990
|
+
${f.content.trim()}`).join("");
|
|
2991
|
+
return `${frontmatter}${body}${sidecarBlocks}
|
|
2992
|
+
`;
|
|
2993
|
+
}
|
|
2994
|
+
async function writeIfChanged(target, content, force) {
|
|
2995
|
+
const stat2 = await promises.lstat(target).catch(() => null);
|
|
2996
|
+
const isSymlink = stat2?.isSymbolicLink() ?? false;
|
|
2997
|
+
const exists = stat2 !== null;
|
|
2998
|
+
if (isSymlink) {
|
|
2999
|
+
await promises.unlink(target);
|
|
3000
|
+
await promises.mkdir(path2.dirname(target), { recursive: true });
|
|
3001
|
+
await promises.writeFile(target, content, "utf8");
|
|
3002
|
+
return "updated";
|
|
2717
3003
|
}
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
);
|
|
3004
|
+
if (exists) {
|
|
3005
|
+
const current = fs.readFileSync(target, "utf8");
|
|
3006
|
+
if (current === content) return "unchanged";
|
|
3007
|
+
if (!force) return "skipped-exists";
|
|
2723
3008
|
}
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
return {
|
|
2728
|
-
bbox: r.bbox,
|
|
2729
|
-
pixelCount: r.pixelCount,
|
|
2730
|
-
percentage: r.percentage,
|
|
2731
|
-
changeType: r.changeType,
|
|
2732
|
-
confidence: r.confidence
|
|
2733
|
-
};
|
|
3009
|
+
await promises.mkdir(path2.dirname(target), { recursive: true });
|
|
3010
|
+
await promises.writeFile(target, content, "utf8");
|
|
3011
|
+
return exists ? "updated" : "created";
|
|
2734
3012
|
}
|
|
2735
|
-
function
|
|
2736
|
-
|
|
3013
|
+
function combineStatuses(statuses) {
|
|
3014
|
+
if (statuses.some((s) => s === "skipped-exists")) return "skipped-exists";
|
|
3015
|
+
if (statuses.some((s) => s === "created")) return "created";
|
|
3016
|
+
if (statuses.some((s) => s === "updated")) return "updated";
|
|
3017
|
+
return "unchanged";
|
|
2737
3018
|
}
|
|
2738
|
-
function
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
3019
|
+
async function installHarness(harness, cwd, opts = {}) {
|
|
3020
|
+
const info = HARNESSES[harness];
|
|
3021
|
+
const target = info.target(cwd);
|
|
3022
|
+
const files = loadSkillFiles();
|
|
3023
|
+
if (info.format === "cursor-rule") {
|
|
3024
|
+
const content = renderCursorRule(files);
|
|
3025
|
+
const status = await writeIfChanged(target, content, opts.force);
|
|
3026
|
+
return { harness, path: target, status };
|
|
3027
|
+
}
|
|
3028
|
+
const targetDir = path2.dirname(target);
|
|
3029
|
+
const statuses = [];
|
|
3030
|
+
for (const file of files) {
|
|
3031
|
+
const filePath = path2.join(targetDir, file.name);
|
|
3032
|
+
const status = await writeIfChanged(
|
|
3033
|
+
filePath,
|
|
3034
|
+
ensureTrailingNewline(file.content),
|
|
3035
|
+
opts.force
|
|
3036
|
+
);
|
|
3037
|
+
statuses.push(status);
|
|
3038
|
+
}
|
|
3039
|
+
return { harness, path: target, status: combineStatuses(statuses) };
|
|
2745
3040
|
}
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
3041
|
+
|
|
3042
|
+
// src/cli/commands/onboard.ts
|
|
3043
|
+
function suggestOtherHarnesses(installed) {
|
|
3044
|
+
const missing = ALL_HARNESSES.filter((h) => !installed.includes(h));
|
|
3045
|
+
if (missing.length === 0) return "";
|
|
3046
|
+
const labels = missing.map((h) => HARNESSES[h].label).join(" / ");
|
|
3047
|
+
const ids = missing.join(",");
|
|
3048
|
+
return `
|
|
3049
|
+
Also use ${labels}? Run: blazediff-agent onboard --harness ${ids}`;
|
|
2754
3050
|
}
|
|
2755
|
-
function
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
3051
|
+
async function promptForHarnesses() {
|
|
3052
|
+
const rl = promises$1.createInterface({
|
|
3053
|
+
input: process.stdin,
|
|
3054
|
+
output: process.stderr
|
|
3055
|
+
});
|
|
3056
|
+
try {
|
|
3057
|
+
const lines = [
|
|
3058
|
+
"No coding-agent harness detected. Which one(s) do you use?",
|
|
3059
|
+
...ALL_HARNESSES.map(
|
|
3060
|
+
(id, i) => ` [${i + 1}] ${HARNESSES[id].label.padEnd(12)} ${HARNESSES[id].target(process.cwd())}`
|
|
3061
|
+
),
|
|
3062
|
+
" [a] all three",
|
|
3063
|
+
""
|
|
3064
|
+
];
|
|
3065
|
+
process.stderr.write(`${lines.join("\n")}`);
|
|
3066
|
+
const answer = (await rl.question("Choice (1/2/3/a): ")).trim().toLowerCase();
|
|
3067
|
+
if (!answer) throw new Error("no harness selected; aborting");
|
|
3068
|
+
if (answer === "a" || answer === "all") return [...ALL_HARNESSES];
|
|
3069
|
+
const idx = Number(answer);
|
|
3070
|
+
if (!Number.isInteger(idx) || idx < 1 || idx > ALL_HARNESSES.length) {
|
|
3071
|
+
throw new Error(
|
|
3072
|
+
`invalid choice "${answer}"; expected 1-${ALL_HARNESSES.length} or "a"`
|
|
3073
|
+
);
|
|
3074
|
+
}
|
|
3075
|
+
return [ALL_HARNESSES[idx - 1]];
|
|
3076
|
+
} finally {
|
|
3077
|
+
rl.close();
|
|
3078
|
+
}
|
|
2762
3079
|
}
|
|
2763
|
-
function
|
|
2764
|
-
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
diffPath: outcome.diffPath,
|
|
2774
|
-
baselinePath,
|
|
2775
|
-
actualPath,
|
|
2776
|
-
message: outcome.reason === "layout-diff" ? "layout differs (dimensions changed)" : `${outcome.diffCount ?? 0} pixels differ (${(outcome.diffPercentage ?? 0).toFixed(3)}%)`
|
|
2777
|
-
};
|
|
3080
|
+
function humanizeResults(results) {
|
|
3081
|
+
const lines = results.map((r) => {
|
|
3082
|
+
const verb = r.status === "created" ? "wrote" : r.status === "updated" ? "updated" : r.status === "unchanged" ? "unchanged" : "skipped (exists; pass --force to overwrite)";
|
|
3083
|
+
const info = HARNESSES[r.harness];
|
|
3084
|
+
const scopeTag = info.scope === "user" ? " [user-global]" : "";
|
|
3085
|
+
return ` ${info.label.padEnd(12)} ${verb}: ${r.path}${scopeTag}`;
|
|
3086
|
+
});
|
|
3087
|
+
const installed = results.map((r) => r.harness);
|
|
3088
|
+
const hint = suggestOtherHarnesses(installed);
|
|
3089
|
+
return ["BlazeDiff playbook installed:", ...lines].join("\n") + hint;
|
|
2778
3090
|
}
|
|
2779
|
-
function
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
3091
|
+
function registerOnboard(program, out) {
|
|
3092
|
+
program.command("onboard").description(
|
|
3093
|
+
"install the BlazeDiff playbook into the coding-agent harness in cwd. Auto-detects Claude Code (.claude/), Codex (AGENTS.md), and Cursor (.cursor/). Prompts on TTY when none detected."
|
|
3094
|
+
).option(
|
|
3095
|
+
"--harness <list>",
|
|
3096
|
+
'comma-separated harness ids, or "all". valid: claude,codex,cursor,all'
|
|
3097
|
+
).option(
|
|
3098
|
+
"--force",
|
|
3099
|
+
"overwrite existing playbook files (idempotent without --force only when content matches)"
|
|
3100
|
+
).action(async (opts) => {
|
|
3101
|
+
const cwd = process.cwd();
|
|
3102
|
+
let targets;
|
|
3103
|
+
if (opts.harness) {
|
|
3104
|
+
targets = parseHarnessList(opts.harness);
|
|
3105
|
+
} else {
|
|
3106
|
+
const detected = detectHarnesses(cwd);
|
|
3107
|
+
if (detected.length > 0) {
|
|
3108
|
+
targets = detected;
|
|
3109
|
+
} else if (out.isTTY() && !out.isJson()) {
|
|
3110
|
+
targets = await promptForHarnesses();
|
|
3111
|
+
} else {
|
|
3112
|
+
throw new Error(
|
|
3113
|
+
"no coding-agent harness detected in cwd. pass --harness <claude|codex|cursor|all> explicitly."
|
|
3114
|
+
);
|
|
3115
|
+
}
|
|
2792
3116
|
}
|
|
2793
|
-
|
|
2794
|
-
|
|
3117
|
+
const results = [];
|
|
3118
|
+
for (const t of targets) {
|
|
3119
|
+
results.push(await installHarness(t, cwd, { force: opts.force }));
|
|
2795
3120
|
}
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
mask: entry.mask,
|
|
2804
|
-
waitFor: entry.waitFor,
|
|
2805
|
-
fullPage: entry.fullPage ?? DEFAULT_FULL_PAGE,
|
|
2806
|
-
mode: "actual"
|
|
2807
|
-
},
|
|
2808
|
-
options.cwd
|
|
2809
|
-
)
|
|
2810
|
-
);
|
|
2811
|
-
const baselinePath = path2__default.default.join(options.baselinesDir, `${entry.id}.png`);
|
|
2812
|
-
const outcome = await diffEntry(
|
|
2813
|
-
entry.id,
|
|
2814
|
-
baselinePath,
|
|
2815
|
-
capture.outputPath,
|
|
2816
|
-
{ threshold: options.threshold, emitDiffPng: options.emitDiffPng },
|
|
2817
|
-
options.cwd
|
|
3121
|
+
out.emit(
|
|
3122
|
+
{
|
|
3123
|
+
ok: true,
|
|
3124
|
+
detected: detectHarnesses(cwd),
|
|
3125
|
+
installed: results
|
|
3126
|
+
},
|
|
3127
|
+
humanizeResults(results)
|
|
2818
3128
|
);
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
}
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
diffPercentage: outcome.diffPercentage
|
|
3129
|
+
});
|
|
3130
|
+
}
|
|
3131
|
+
var execFileP = util.promisify(child_process.execFile);
|
|
3132
|
+
async function isPortOpen(port, host = "127.0.0.1") {
|
|
3133
|
+
return new Promise((resolve) => {
|
|
3134
|
+
const socket = net.createConnection({ port, host });
|
|
3135
|
+
socket.setTimeout(500);
|
|
3136
|
+
socket.once("connect", () => {
|
|
3137
|
+
socket.destroy();
|
|
3138
|
+
resolve(true);
|
|
2830
3139
|
});
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
);
|
|
2838
|
-
if (result.verdict?.label === "ambiguous" && result.baselinePath && result.actualPath) {
|
|
2839
|
-
const judge = resolveJudge(options.judge);
|
|
2840
|
-
const output = await judge.judge(
|
|
2841
|
-
{
|
|
2842
|
-
entry,
|
|
2843
|
-
baselinePath: result.baselinePath,
|
|
2844
|
-
actualPath: result.actualPath,
|
|
2845
|
-
diffPath: result.diffPath,
|
|
2846
|
-
regions: result.regions,
|
|
2847
|
-
diffPercentage: result.diffPercentage,
|
|
2848
|
-
severity: result.severity,
|
|
2849
|
-
heuristicVerdict: result.verdict
|
|
2850
|
-
},
|
|
2851
|
-
options.cwd
|
|
2852
|
-
);
|
|
2853
|
-
if (output.kind === "judged") {
|
|
2854
|
-
result = { ...result, verdict: output.verdict };
|
|
2855
|
-
} else {
|
|
2856
|
-
result = {
|
|
2857
|
-
...result,
|
|
2858
|
-
status: "needs-judgment",
|
|
2859
|
-
message: `awaiting judgment in ${output.requestPath}`
|
|
2860
|
-
};
|
|
2861
|
-
}
|
|
2862
|
-
}
|
|
2863
|
-
return { results: [result] };
|
|
2864
|
-
};
|
|
3140
|
+
socket.once("error", () => resolve(false));
|
|
3141
|
+
socket.once("timeout", () => {
|
|
3142
|
+
socket.destroy();
|
|
3143
|
+
resolve(false);
|
|
3144
|
+
});
|
|
3145
|
+
});
|
|
2865
3146
|
}
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
this.current = 0;
|
|
2872
|
-
this.queue = [];
|
|
2873
|
-
if (limit < 1)
|
|
2874
|
-
throw new Error(`semaphore limit must be >= 1 (got ${limit})`);
|
|
3147
|
+
async function waitForPort(port, timeoutMs = 6e4) {
|
|
3148
|
+
const deadline = Date.now() + timeoutMs;
|
|
3149
|
+
while (Date.now() < deadline) {
|
|
3150
|
+
if (await isPortOpen(port)) return;
|
|
3151
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
2875
3152
|
}
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
3153
|
+
throw new Error(`dev server did not open port ${port} within ${timeoutMs}ms`);
|
|
3154
|
+
}
|
|
3155
|
+
async function findPidByPort(port) {
|
|
3156
|
+
const platform = process.platform;
|
|
3157
|
+
try {
|
|
3158
|
+
if (platform === "darwin" || platform === "linux") {
|
|
3159
|
+
const { stdout } = await execFileP("lsof", [
|
|
3160
|
+
"-ti",
|
|
3161
|
+
`tcp:${port}`,
|
|
3162
|
+
"-sTCP:LISTEN"
|
|
3163
|
+
]);
|
|
3164
|
+
const pid = Number(stdout.trim().split("\n")[0]);
|
|
3165
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
2879
3166
|
}
|
|
2880
|
-
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
const
|
|
2886
|
-
|
|
3167
|
+
if (platform === "win32") {
|
|
3168
|
+
const { stdout } = await execFileP("netstat", ["-ano"]);
|
|
3169
|
+
const line = stdout.split(/\r?\n/).find((l) => l.includes(`:${port} `) && l.includes("LISTENING"));
|
|
3170
|
+
if (!line) return null;
|
|
3171
|
+
const parts = line.trim().split(/\s+/);
|
|
3172
|
+
const pid = Number(parts[parts.length - 1]);
|
|
3173
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
3174
|
+
}
|
|
3175
|
+
} catch {
|
|
3176
|
+
return null;
|
|
3177
|
+
}
|
|
3178
|
+
return null;
|
|
3179
|
+
}
|
|
3180
|
+
async function startServer(opts) {
|
|
3181
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
3182
|
+
const logPath = opts.logPath ?? paths(cwd).serverLog;
|
|
3183
|
+
const pidPath = opts.pidPath ?? paths(cwd).serverPid;
|
|
3184
|
+
await promises.mkdir(path2__default.default.dirname(logPath), { recursive: true });
|
|
3185
|
+
if (await isPortOpen(opts.port)) {
|
|
3186
|
+
const discoveredPid = await findPidByPort(opts.port);
|
|
3187
|
+
if (discoveredPid) {
|
|
3188
|
+
await promises.writeFile(pidPath, String(discoveredPid), "utf8").catch(() => {
|
|
3189
|
+
});
|
|
3190
|
+
}
|
|
3191
|
+
return {
|
|
3192
|
+
pid: discoveredPid ?? 0,
|
|
3193
|
+
port: opts.port,
|
|
3194
|
+
url: `http://127.0.0.1:${opts.port}`,
|
|
3195
|
+
attached: true
|
|
3196
|
+
};
|
|
3197
|
+
}
|
|
3198
|
+
const child = child_process.spawn(opts.command, {
|
|
3199
|
+
cwd,
|
|
3200
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3201
|
+
detached: true,
|
|
3202
|
+
shell: true,
|
|
3203
|
+
env: { ...process.env, FORCE_COLOR: "0", CI: "1" }
|
|
3204
|
+
});
|
|
3205
|
+
const logStream = await import('fs').then(
|
|
3206
|
+
(m) => m.createWriteStream(logPath, { flags: "a" })
|
|
3207
|
+
);
|
|
3208
|
+
child.stdout?.pipe(logStream);
|
|
3209
|
+
child.stderr?.pipe(logStream);
|
|
3210
|
+
if (!child.pid) throw new Error("failed to spawn dev server");
|
|
3211
|
+
await promises.writeFile(pidPath, String(child.pid), "utf8");
|
|
3212
|
+
installSignalHandlers(child);
|
|
3213
|
+
try {
|
|
3214
|
+
await waitForPort(opts.port, opts.readyTimeoutMs ?? 6e4);
|
|
3215
|
+
} catch (err) {
|
|
3216
|
+
await stopProcess(child.pid);
|
|
3217
|
+
throw err;
|
|
3218
|
+
}
|
|
3219
|
+
return {
|
|
3220
|
+
pid: child.pid,
|
|
3221
|
+
port: opts.port,
|
|
3222
|
+
url: `http://127.0.0.1:${opts.port}`
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
async function stopServer(cwd = process.cwd(), portFallback) {
|
|
3226
|
+
const pidPath = paths(cwd).serverPid;
|
|
3227
|
+
let pid = null;
|
|
3228
|
+
let via = "none";
|
|
3229
|
+
if (fs.existsSync(pidPath)) {
|
|
3230
|
+
const raw = (await promises.readFile(pidPath, "utf8")).trim();
|
|
3231
|
+
const parsed = Number(raw);
|
|
3232
|
+
if (Number.isFinite(parsed) && parsed > 0 && processExists(parsed)) {
|
|
3233
|
+
pid = parsed;
|
|
3234
|
+
via = "pidfile";
|
|
2887
3235
|
}
|
|
2888
3236
|
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
}),
|
|
2903
|
-
results: langgraph.Annotation({
|
|
2904
|
-
reducer: (acc, next) => [...acc, ...next],
|
|
2905
|
-
default: () => []
|
|
2906
|
-
}),
|
|
2907
|
-
manifest: langgraph.Annotation({
|
|
2908
|
-
reducer: (acc, next) => next ?? acc,
|
|
2909
|
-
default: () => void 0
|
|
2910
|
-
}),
|
|
2911
|
-
report: langgraph.Annotation({
|
|
2912
|
-
reducer: (acc, next) => next ?? acc,
|
|
2913
|
-
default: () => void 0
|
|
2914
|
-
})
|
|
2915
|
-
});
|
|
2916
|
-
|
|
2917
|
-
// src/graph/index.ts
|
|
2918
|
-
function buildGraph(semaphore) {
|
|
2919
|
-
return new langgraph.StateGraph(GraphState).addNode("load", loadNode).addNode("process", makeProcessNode(semaphore)).addNode("aggregate", aggregateNode).addEdge(langgraph.START, "load").addConditionalEdges(
|
|
2920
|
-
"load",
|
|
2921
|
-
(state) => state.entries.map(
|
|
2922
|
-
(entry) => new langgraph.Send("process", { entry, options: state.options })
|
|
2923
|
-
),
|
|
2924
|
-
["process"]
|
|
2925
|
-
).addEdge("process", "aggregate").addEdge("aggregate", langgraph.END).compile();
|
|
3237
|
+
if (!pid && portFallback) {
|
|
3238
|
+
pid = await findPidByPort(portFallback);
|
|
3239
|
+
if (pid) via = "port";
|
|
3240
|
+
}
|
|
3241
|
+
if (!pid) {
|
|
3242
|
+
await promises.writeFile(pidPath, "", "utf8").catch(() => {
|
|
3243
|
+
});
|
|
3244
|
+
return { killed: false, pid: null, via: "none" };
|
|
3245
|
+
}
|
|
3246
|
+
await stopProcess(pid);
|
|
3247
|
+
await promises.writeFile(pidPath, "", "utf8").catch(() => {
|
|
3248
|
+
});
|
|
3249
|
+
return { killed: true, pid, via };
|
|
2926
3250
|
}
|
|
2927
|
-
|
|
2928
|
-
const cwd = opts.cwd ?? process.cwd();
|
|
2929
|
-
const concurrency = opts.concurrency ?? defaultConcurrency();
|
|
2930
|
-
const semaphore = new Semaphore(concurrency);
|
|
2931
|
-
const baselinesDir = paths(cwd).baselines;
|
|
2932
|
-
const graph = buildGraph(semaphore);
|
|
2933
|
-
let finalState;
|
|
3251
|
+
function processExists(pid) {
|
|
2934
3252
|
try {
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
3253
|
+
process.kill(pid, 0);
|
|
3254
|
+
return true;
|
|
3255
|
+
} catch {
|
|
3256
|
+
return false;
|
|
3257
|
+
}
|
|
3258
|
+
}
|
|
3259
|
+
async function stopProcess(pid) {
|
|
3260
|
+
if (!pid) return;
|
|
3261
|
+
await new Promise((resolve) => {
|
|
3262
|
+
treeKill__default.default(pid, "SIGTERM", (err) => {
|
|
3263
|
+
if (err) {
|
|
3264
|
+
treeKill__default.default(pid, "SIGKILL", () => resolve());
|
|
3265
|
+
return;
|
|
2944
3266
|
}
|
|
3267
|
+
resolve();
|
|
2945
3268
|
});
|
|
2946
|
-
}
|
|
2947
|
-
await closeBrowser();
|
|
2948
|
-
}
|
|
2949
|
-
const report = finalState.report;
|
|
2950
|
-
if (!report) {
|
|
2951
|
-
throw new Error("runGraph: graph completed without producing a report");
|
|
2952
|
-
}
|
|
2953
|
-
if (opts.junitPath) {
|
|
2954
|
-
const target = path2__default.default.isAbsolute(opts.junitPath) ? opts.junitPath : path2__default.default.join(cwd, opts.junitPath);
|
|
2955
|
-
await writeJunit(report, target);
|
|
2956
|
-
}
|
|
2957
|
-
return report;
|
|
3269
|
+
});
|
|
2958
3270
|
}
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
3271
|
+
var signalsInstalled = false;
|
|
3272
|
+
function installSignalHandlers(child) {
|
|
3273
|
+
if (signalsInstalled) return;
|
|
3274
|
+
signalsInstalled = true;
|
|
3275
|
+
const cleanup = () => {
|
|
3276
|
+
if (child.pid) {
|
|
3277
|
+
try {
|
|
3278
|
+
process.kill(-child.pid, "SIGTERM");
|
|
3279
|
+
} catch {
|
|
3280
|
+
}
|
|
3281
|
+
treeKill__default.default(child.pid, "SIGKILL", () => {
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
2971
3284
|
};
|
|
3285
|
+
process.on("SIGINT", () => {
|
|
3286
|
+
cleanup();
|
|
3287
|
+
process.exit(130);
|
|
3288
|
+
});
|
|
3289
|
+
process.on("SIGTERM", () => {
|
|
3290
|
+
cleanup();
|
|
3291
|
+
process.exit(143);
|
|
3292
|
+
});
|
|
3293
|
+
process.on("exit", cleanup);
|
|
2972
3294
|
}
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
3295
|
+
|
|
3296
|
+
// src/cli/commands/reset.ts
|
|
3297
|
+
async function stopTrackedServer() {
|
|
3298
|
+
const config = await loadConfig();
|
|
3299
|
+
if (!config?.devServer) return { stopped: false };
|
|
3300
|
+
try {
|
|
3301
|
+
const result = await stopServer(process.cwd(), config.devServer.port);
|
|
3302
|
+
return { stopped: result.killed, via: result.via, pid: result.pid };
|
|
3303
|
+
} catch {
|
|
3304
|
+
return { stopped: false };
|
|
3305
|
+
}
|
|
2983
3306
|
}
|
|
2984
|
-
function
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
3307
|
+
function registerReset(program, out) {
|
|
3308
|
+
program.command("reset").description(
|
|
3309
|
+
"wipe .blazediff/ entirely - config, manifest, baselines, actual, judgments, summary, pid/log (stops the dev server first if one is tracked). Re-run /blazediff or `init` afterward to start from scratch."
|
|
3310
|
+
).option("--yes", "do not prompt; required when stdin is a TTY").action(async (opts) => {
|
|
3311
|
+
const root = paths().root;
|
|
3312
|
+
if (!fs.existsSync(root)) {
|
|
3313
|
+
out.emit(
|
|
3314
|
+
{ ok: true, removed: false, root },
|
|
3315
|
+
`nothing to reset (no ${root})`
|
|
2991
3316
|
);
|
|
2992
|
-
|
|
2993
|
-
} else {
|
|
2994
|
-
const detail = typeof r.diffPercentage === "number" ? `${r.status} (${r.diffPercentage.toFixed(3)}%)` : r.status;
|
|
2995
|
-
lines.push(` ${prefix} ${r.id}: ${detail}`);
|
|
3317
|
+
return;
|
|
2996
3318
|
}
|
|
2997
|
-
if (
|
|
2998
|
-
|
|
3319
|
+
if (out.isTTY() && !opts.yes && !out.isJson()) {
|
|
3320
|
+
throw new Error(
|
|
3321
|
+
`refusing to wipe ${root} without --yes (interactive run)`
|
|
3322
|
+
);
|
|
2999
3323
|
}
|
|
3000
|
-
|
|
3001
|
-
|
|
3324
|
+
const stopOutcome = await stopTrackedServer();
|
|
3325
|
+
await promises.rm(root, { recursive: true, force: true });
|
|
3326
|
+
out.emit(
|
|
3327
|
+
{ ok: true, removed: true, root, devServer: stopOutcome },
|
|
3328
|
+
stopOutcome.stopped ? `stopped dev server (pid ${stopOutcome.pid} via ${stopOutcome.via}) and removed ${root}` : `removed ${root}`
|
|
3329
|
+
);
|
|
3002
3330
|
});
|
|
3003
3331
|
}
|
|
3004
|
-
function
|
|
3005
|
-
|
|
3006
|
-
|
|
3332
|
+
async function cleanupAfterRewrite(rewrittenIds, scopeAll) {
|
|
3333
|
+
const p = paths();
|
|
3334
|
+
if (scopeAll) {
|
|
3335
|
+
await Promise.all([
|
|
3336
|
+
promises.rm(p.actual, { recursive: true, force: true }),
|
|
3337
|
+
promises.rm(p.judgments, { recursive: true, force: true }),
|
|
3338
|
+
promises.rm(p.checkpoints, { recursive: true, force: true }),
|
|
3339
|
+
promises.rm(p.summary, { force: true })
|
|
3340
|
+
]);
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
const perId = rewrittenIds.flatMap((id) => [
|
|
3344
|
+
promises.rm(path2__default.default.join(p.actual, `${id}.png`), { force: true }),
|
|
3345
|
+
promises.rm(path2__default.default.join(p.actual, `${id}.diff.png`), { force: true }),
|
|
3346
|
+
promises.rm(path2__default.default.join(p.judgments, id), { recursive: true, force: true })
|
|
3347
|
+
]);
|
|
3348
|
+
await Promise.all([
|
|
3349
|
+
...perId,
|
|
3350
|
+
promises.rm(p.summary, { force: true }),
|
|
3351
|
+
promises.rm(p.checkpoints, { recursive: true, force: true })
|
|
3352
|
+
]);
|
|
3007
3353
|
}
|
|
3008
|
-
function
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3354
|
+
async function resolveTargets(manifest, ids, opts) {
|
|
3355
|
+
const exclusive = [
|
|
3356
|
+
ids.length > 0,
|
|
3357
|
+
Boolean(opts.failed),
|
|
3358
|
+
Boolean(opts.all)
|
|
3359
|
+
].filter(Boolean).length;
|
|
3360
|
+
if (exclusive === 0) throw new Error("provide ids, --failed, or --all");
|
|
3361
|
+
if (exclusive > 1)
|
|
3362
|
+
throw new Error("ids / --failed / --all are mutually exclusive");
|
|
3363
|
+
if (opts.all) return new Set(manifest.entries.map((e) => e.id));
|
|
3364
|
+
if (opts.failed) {
|
|
3365
|
+
const judgmentsDir = paths().judgments;
|
|
3366
|
+
if (!fs.existsSync(judgmentsDir)) {
|
|
3367
|
+
throw new Error(
|
|
3368
|
+
`no judgments at ${judgmentsDir}. Run \`blazediff-agent check\` first.`
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
const names = await promises.readdir(judgmentsDir);
|
|
3372
|
+
const failed = /* @__PURE__ */ new Set();
|
|
3373
|
+
for (const name of names) {
|
|
3374
|
+
const full = path2__default.default.join(judgmentsDir, name);
|
|
3375
|
+
if (fs.statSync(full).isDirectory()) failed.add(name);
|
|
3376
|
+
}
|
|
3377
|
+
return failed;
|
|
3014
3378
|
}
|
|
3015
|
-
|
|
3379
|
+
const targets = new Set(ids);
|
|
3380
|
+
const missing = ids.filter(
|
|
3381
|
+
(id) => !manifest.entries.some((e) => e.id === id)
|
|
3382
|
+
);
|
|
3383
|
+
if (missing.length) throw new Error(`unknown ids: ${missing.join(", ")}`);
|
|
3384
|
+
return targets;
|
|
3016
3385
|
}
|
|
3017
|
-
function
|
|
3018
|
-
program.command("
|
|
3019
|
-
"
|
|
3020
|
-
).option("--
|
|
3021
|
-
|
|
3022
|
-
"
|
|
3023
|
-
String(DEFAULT_THRESHOLD)
|
|
3024
|
-
).option(
|
|
3025
|
-
"--concurrency <n>",
|
|
3026
|
-
"max entries captured in parallel (default: auto based on CPU cores, capped at 8)"
|
|
3027
|
-
).option("--no-diff-png", "skip writing diff PNGs").option("--junit <path>", "write JUnit XML to this path (default: skipped)").option(
|
|
3028
|
-
"--judge <backend>",
|
|
3029
|
-
"judge backend for ambiguous diffs (host | none)",
|
|
3030
|
-
"none"
|
|
3031
|
-
).action(async (opts) => {
|
|
3032
|
-
parseMode(opts.mode);
|
|
3386
|
+
function registerRewrite(program, out) {
|
|
3387
|
+
program.command("rewrite [ids...]").description(
|
|
3388
|
+
"rewrite baselines for existing manifest entries, preserving mask/viewport/etc. Pick targets via positional ids, --failed (uses .blazediff/judgments/ from last check), or --all."
|
|
3389
|
+
).option("--failed", "rewrite entries that failed the most recent check").option("--all", "rewrite every manifest entry").option("--base-url <url>", "override base URL").action(async (ids, opts) => {
|
|
3390
|
+
const manifest = await loadManifest();
|
|
3391
|
+
if (!manifest) throw new Error("no manifest. Run authoring first.");
|
|
3033
3392
|
const baseUrl = resolveBaseUrl(await loadConfig(), opts.baseUrl);
|
|
3034
|
-
const
|
|
3393
|
+
const targets = await resolveTargets(manifest, ids, opts);
|
|
3394
|
+
if (targets.size === 0 && opts.failed) {
|
|
3395
|
+
out.emit({ ok: true, rewritten: 0 }, "no failed entries to rewrite");
|
|
3396
|
+
return;
|
|
3397
|
+
}
|
|
3398
|
+
const routes = manifest.entries.filter((e) => targets.has(e.id)).map((e) => ({
|
|
3399
|
+
id: e.id,
|
|
3400
|
+
url: e.url,
|
|
3401
|
+
mask: e.mask,
|
|
3402
|
+
viewport: e.viewport,
|
|
3403
|
+
waitFor: e.waitFor,
|
|
3404
|
+
fullPage: e.fullPage
|
|
3405
|
+
}));
|
|
3406
|
+
const report = await runCaptures({
|
|
3035
3407
|
baseUrl,
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
junitPath: opts.junit,
|
|
3040
|
-
judge: parseJudge2(opts.judge)
|
|
3408
|
+
routes,
|
|
3409
|
+
mode: "baseline",
|
|
3410
|
+
writeManifest: true
|
|
3041
3411
|
});
|
|
3042
|
-
const
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
`
|
|
3049
|
-
|
|
3050
|
-
].
|
|
3051
|
-
out.emit(
|
|
3412
|
+
const succeededIds = report.results.filter((r) => r.ok).map((r) => r.id);
|
|
3413
|
+
if (succeededIds.length > 0) {
|
|
3414
|
+
await cleanupAfterRewrite(succeededIds, Boolean(opts.all));
|
|
3415
|
+
}
|
|
3416
|
+
const failureLines2 = report.results.filter((r) => !r.ok).map((r) => ` \u2717 ${r.id}: ${r.error ?? "failed"}`);
|
|
3417
|
+
const human = report.failed === 0 ? `rewrote ${report.succeeded}/${report.total} baseline${report.total === 1 ? "" : "s"}` : [
|
|
3418
|
+
`rewrote ${report.succeeded}/${report.total} (${report.failed} failed):`,
|
|
3419
|
+
...failureLines2
|
|
3420
|
+
].join("\n");
|
|
3421
|
+
out.emit(report, human);
|
|
3052
3422
|
if (report.failed > 0) process.exitCode = 1;
|
|
3053
3423
|
});
|
|
3054
3424
|
}
|
|
@@ -3072,7 +3442,7 @@ function registerServeStatus(program, out) {
|
|
|
3072
3442
|
);
|
|
3073
3443
|
return;
|
|
3074
3444
|
}
|
|
3075
|
-
const port = opts.port ?
|
|
3445
|
+
const port = opts.port ? parsePort(opts.port) : config.devServer.port;
|
|
3076
3446
|
if (opts.kill) {
|
|
3077
3447
|
const result = await stopServer(process.cwd(), port);
|
|
3078
3448
|
const human = result.killed ? `dev server stopped (pid ${result.pid} via ${result.via})` : `no dev server found to stop on :${port}`;
|
|
@@ -3167,7 +3537,6 @@ function buildProgram() {
|
|
|
3167
3537
|
registerDiff(program, out);
|
|
3168
3538
|
registerManifest(program, out);
|
|
3169
3539
|
registerCheck(program, out);
|
|
3170
|
-
registerRun(program, out);
|
|
3171
3540
|
registerRewrite(program, out);
|
|
3172
3541
|
registerReset(program, out);
|
|
3173
3542
|
return program;
|