visual-regression-compare 0.0.0 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1397 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/run.ts
4
+ import fs6 from "fs";
5
+ import os from "os";
6
+ import path7 from "path";
7
+
8
+ // src/approve.ts
9
+ import { createHash } from "crypto";
10
+ import fs from "fs";
11
+ import path from "path";
12
+ import {
13
+ isSafeRelativePath,
14
+ mergeManifests,
15
+ projectDirInRepo,
16
+ readManifestFile,
17
+ selectEntries,
18
+ summarize,
19
+ toRepoPath,
20
+ writeManifestFile
21
+ } from "visual-regression-manifest";
22
+ var sha256 = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
23
+ var locateProject = (manifest, repoRoot) => {
24
+ if (manifest.ci?.workspace) {
25
+ return { projectDir: projectDirInRepo(manifest), base: repoRoot };
26
+ }
27
+ const relative = path.relative(repoRoot, manifest.projectRoot).split(path.sep).join("/");
28
+ if (relative === "") return { projectDir: "", base: repoRoot };
29
+ return isSafeRelativePath(relative) ? { projectDir: relative, base: repoRoot } : { projectDir: null, base: repoRoot };
30
+ };
31
+ var approve = ({
32
+ manifests,
33
+ repoRoot = process.cwd(),
34
+ names,
35
+ testFile,
36
+ force = false,
37
+ dryRun = false
38
+ }) => {
39
+ const root = path.resolve(repoRoot);
40
+ const sources = manifests.map((file) => ({
41
+ file,
42
+ label: file,
43
+ manifest: readManifestFile(file)
44
+ }));
45
+ const run = mergeManifests(sources);
46
+ const picked = selectEntries(run, names ? { names } : "all");
47
+ const explicit = names !== void 0;
48
+ const chosen = testFile ? picked.entries.filter((m) => m.entry.test.filePath === testFile) : picked.entries;
49
+ const result = {
50
+ approved: [],
51
+ skipped: [],
52
+ files: [],
53
+ warnings: run.warnings,
54
+ unknown: picked.unknown
55
+ };
56
+ const changed = /* @__PURE__ */ new Set();
57
+ const skip = (m, reason) => result.skipped.push({ entry: m.entry, reason });
58
+ for (const m of chosen) {
59
+ const { entry, source } = m;
60
+ if (m.unapprovableReason) {
61
+ skip(m, `cannot be approved: ${m.unapprovableReason}`);
62
+ continue;
63
+ }
64
+ if (!explicit && m.collidesWith.length > 0) {
65
+ skip(m, "writes the same baseline as another entry; name one to approve");
66
+ continue;
67
+ }
68
+ const { projectDir, base } = locateProject(source.manifest, root);
69
+ if (projectDir === null) {
70
+ skip(
71
+ m,
72
+ `the project root ${source.manifest.projectRoot} is outside the repository root ${root}; run from the repository root or pass --repo-root`
73
+ );
74
+ continue;
75
+ }
76
+ if (!entry.images.actual.path) {
77
+ skip(m, "the run kept no actual image for it");
78
+ continue;
79
+ }
80
+ const actualRepo = toRepoPath(projectDir, entry.images.actual.path);
81
+ const baselineRepo = toRepoPath(projectDir, entry.images.baseline.path);
82
+ if (!actualRepo || !baselineRepo) {
83
+ skip(m, "its paths are outside the repository");
84
+ continue;
85
+ }
86
+ const actual = path.join(base, actualRepo);
87
+ const baseline = path.join(base, baselineRepo);
88
+ const diffRepo = toRepoPath(projectDir, entry.images.diff.path);
89
+ if (!fs.existsSync(actual)) {
90
+ skip(m, "the actual image is gone");
91
+ continue;
92
+ }
93
+ if (!force) {
94
+ if (entry.hashes?.actual && entry.hashes.actual !== sha256(actual)) {
95
+ skip(m, "the actual image changed since the run (stale)");
96
+ continue;
97
+ }
98
+ const baselineNow = fs.existsSync(baseline) ? sha256(baseline) : void 0;
99
+ const expected = entry.hashes?.baseline;
100
+ const stale = entry.status === "missing-baseline" ? baselineNow !== void 0 : expected !== void 0 && baselineNow !== expected;
101
+ if (stale) {
102
+ skip(m, "the baseline changed since the run (stale)");
103
+ continue;
104
+ }
105
+ }
106
+ if (!dryRun) {
107
+ fs.mkdirSync(path.dirname(baseline), { recursive: true });
108
+ fs.copyFileSync(actual, baseline);
109
+ fs.rmSync(actual, { force: true });
110
+ if (diffRepo) fs.rmSync(path.join(base, diffRepo), { force: true });
111
+ Object.assign(entry, {
112
+ status: "approved",
113
+ images: {
114
+ // the approved screenshot is the baseline now, with its size
115
+ baseline: {
116
+ ...entry.images.baseline,
117
+ width: entry.images.actual.width ?? entry.images.baseline.width,
118
+ height: entry.images.actual.height ?? entry.images.baseline.height
119
+ },
120
+ actual: { path: null },
121
+ diff: { path: null }
122
+ },
123
+ baselineWritten: true,
124
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
125
+ hashes: { baseline: sha256(baseline) },
126
+ message: "Baseline image was replaced with the approved screenshot."
127
+ });
128
+ changed.add(source);
129
+ }
130
+ result.approved.push(entry);
131
+ result.files.push(baselineRepo);
132
+ }
133
+ for (const { file, manifest } of changed) {
134
+ manifest.summary = summarize(manifest.entries);
135
+ manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
136
+ writeManifestFile(file, manifest);
137
+ }
138
+ return result;
139
+ };
140
+
141
+ // src/comparer.ts
142
+ import fs3 from "fs";
143
+ import path4 from "path";
144
+ import {
145
+ ManifestWriter,
146
+ IMAGE_SUFFIX as IMAGE_SUFFIX2,
147
+ getManifestFileName,
148
+ maxRatioFrom
149
+ } from "visual-regression-manifest";
150
+
151
+ // src/engines/index.ts
152
+ import path2 from "path";
153
+ import { pathToFileURL } from "url";
154
+
155
+ // src/engines/pixelmatch.ts
156
+ import pixelmatch from "pixelmatch";
157
+ var pixelmatchEngine = ({
158
+ threshold = 0.1,
159
+ includeAA = false
160
+ } = {}) => ({
161
+ name: "pixelmatch",
162
+ colorMetric: "yiq",
163
+ colorThreshold: threshold,
164
+ compare(baseline, actual) {
165
+ const { width, height } = baseline;
166
+ const diff = new Uint8ClampedArray(width * height * 4);
167
+ const diffPixels = pixelmatch(
168
+ baseline.data,
169
+ actual.data,
170
+ diff,
171
+ width,
172
+ height,
173
+ {
174
+ threshold,
175
+ includeAA
176
+ }
177
+ );
178
+ const area = width * height;
179
+ return {
180
+ diffPixels,
181
+ diffRatio: area === 0 ? 0 : diffPixels / area,
182
+ diff: { width, height, data: diff }
183
+ };
184
+ }
185
+ });
186
+ var pixelmatchFrom = (options) => pixelmatchEngine(options);
187
+
188
+ // src/engines/index.ts
189
+ var BUILT_IN = {
190
+ pixelmatch: pixelmatchFrom
191
+ };
192
+ var BUILT_IN_ENGINES = Object.keys(BUILT_IN);
193
+ var isDiffEngine = (value) => typeof value === "object" && value !== null && typeof value.compare === "function" && typeof value.name === "string";
194
+ var loadEngine = async (id, options = {}, cwd = process.cwd()) => {
195
+ const builtIn = BUILT_IN[id];
196
+ if (builtIn) return builtIn(options);
197
+ const isPath = /^\.{0,2}[\\/]/.test(id) || path2.isAbsolute(id);
198
+ const specifier = isPath ? pathToFileURL(path2.resolve(cwd, id)).href : id;
199
+ let loaded;
200
+ try {
201
+ loaded = await import(specifier);
202
+ } catch (error) {
203
+ throw new Error(
204
+ `Cannot load engine "${id}" (built in: ${BUILT_IN_ENGINES.join(", ")}): ${error.message}`,
205
+ { cause: error }
206
+ );
207
+ }
208
+ const exported = loaded.default;
209
+ const engine = typeof exported === "function" ? exported(options) : exported;
210
+ if (!isDiffEngine(engine)) {
211
+ throw new Error(
212
+ `Engine "${id}" must default-export a { name, colorMetric, compare } object or a function returning one`
213
+ );
214
+ }
215
+ return engine;
216
+ };
217
+
218
+ // src/image.ts
219
+ import { PNG } from "pngjs";
220
+ var LFS_POINTER = "version https://git-lfs.github.com/spec/";
221
+ var decodePng = (bytes) => {
222
+ const head = Buffer.from(bytes.subarray(0, LFS_POINTER.length)).toString(
223
+ "latin1"
224
+ );
225
+ if (head === LFS_POINTER) {
226
+ throw new Error(
227
+ "the file is a Git LFS pointer, not an image; run `git lfs pull`"
228
+ );
229
+ }
230
+ const { width, height, data } = PNG.sync.read(Buffer.from(bytes));
231
+ return { width, height, data: new Uint8ClampedArray(data) };
232
+ };
233
+ var encodePng = ({ width, height, data }) => {
234
+ const png = new PNG({ width, height });
235
+ png.data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
236
+ return PNG.sync.write(png);
237
+ };
238
+ var resize = (image, width, height) => {
239
+ const data = new Uint8ClampedArray(width * height * 4);
240
+ const rows = Math.min(height, image.height);
241
+ const rowBytes = Math.min(width, image.width) * 4;
242
+ for (let y = 0; y < rows; y++) {
243
+ const from = y * image.width * 4;
244
+ data.set(image.data.subarray(from, from + rowBytes), y * width * 4);
245
+ }
246
+ return { width, height, data };
247
+ };
248
+ var padToSame = (a, b) => {
249
+ const width = Math.max(a.width, b.width);
250
+ const height = Math.max(a.height, b.height);
251
+ return [resize(a, width, height), resize(b, width, height)];
252
+ };
253
+ var cropToSame = (a, b) => {
254
+ const width = Math.min(a.width, b.width);
255
+ const height = Math.min(a.height, b.height);
256
+ return [resize(a, width, height), resize(b, width, height)];
257
+ };
258
+
259
+ // src/job.ts
260
+ var decodeNamed = (which, bytes) => {
261
+ try {
262
+ return decodePng(bytes);
263
+ } catch (error) {
264
+ throw new Error(`the ${which} image: ${error.message}`, {
265
+ cause: error
266
+ });
267
+ }
268
+ };
269
+ var sizeOf = ({ width, height }) => ({ width, height });
270
+ var runCompare = async (job, engine) => {
271
+ let baseline = decodeNamed("baseline", job.baseline);
272
+ let actual = decodeNamed("actual", job.actual);
273
+ const baselineSize = sizeOf(baseline);
274
+ const actualSize = sizeOf(actual);
275
+ let resized;
276
+ if (baseline.width !== actual.width || baseline.height !== actual.height) {
277
+ if (job.onSizeMismatch === "fail") {
278
+ return { kind: "size-mismatch", baselineSize, actualSize };
279
+ }
280
+ resized = job.onSizeMismatch;
281
+ [baseline, actual] = (resized === "pad" ? padToSame : cropToSame)(
282
+ baseline,
283
+ actual
284
+ );
285
+ }
286
+ const { diff, ...result } = await engine.compare(baseline, actual);
287
+ const compared = (part, image) => job.include?.includes(part) ? {
288
+ [`${part}Png`]: resized ? encodePng(image) : job[part]
289
+ } : {};
290
+ return {
291
+ kind: "compared",
292
+ baselineSize,
293
+ actualSize,
294
+ ...resized && { resized },
295
+ ...result,
296
+ ...diff && { diffPng: encodePng(diff) },
297
+ ...compared("baseline", baseline),
298
+ ...compared("actual", actual)
299
+ };
300
+ };
301
+ var runJob = async (job) => runCompare(
302
+ job,
303
+ await loadEngine(job.engine.id, job.engine.options, job.engine.cwd)
304
+ );
305
+
306
+ // src/pool.ts
307
+ import { Worker } from "worker_threads";
308
+ var WorkerPool = class {
309
+ constructor(size, workerFile) {
310
+ this.size = size;
311
+ this.workerFile = workerFile;
312
+ }
313
+ size;
314
+ workerFile;
315
+ idle = [];
316
+ workers = [];
317
+ queue = [];
318
+ running = /* @__PURE__ */ new Map();
319
+ nextId = 0;
320
+ current = /* @__PURE__ */ new WeakMap();
321
+ get threaded() {
322
+ return this.size > 1 && this.workerFile !== void 0;
323
+ }
324
+ run(job) {
325
+ if (!this.threaded) return runJob(job);
326
+ return new Promise((resolve, reject) => {
327
+ this.queue.push({ job, resolve, reject });
328
+ this.pump();
329
+ });
330
+ }
331
+ pump() {
332
+ while (this.queue.length > 0) {
333
+ const worker = this.idle.pop() ?? this.spawn();
334
+ if (!worker) return;
335
+ const pending = this.queue.shift();
336
+ const id = this.nextId++;
337
+ this.running.set(id, pending);
338
+ this.current.set(worker, id);
339
+ worker.postMessage({ id, job: pending.job });
340
+ }
341
+ }
342
+ spawn() {
343
+ if (this.workers.length >= this.size) return void 0;
344
+ const worker = new Worker(this.workerFile);
345
+ this.workers.push(worker);
346
+ worker.on("message", ({ id, result, error }) => {
347
+ const pending = this.running.get(id);
348
+ this.running.delete(id);
349
+ this.idle.push(worker);
350
+ if (error !== void 0) pending?.reject(new Error(error));
351
+ else pending?.resolve(result);
352
+ this.pump();
353
+ });
354
+ const lose = (error) => {
355
+ for (const list of [this.workers, this.idle]) {
356
+ const at = list.indexOf(worker);
357
+ if (at !== -1) list.splice(at, 1);
358
+ }
359
+ const id = this.current.get(worker);
360
+ const pending = id === void 0 ? void 0 : this.running.get(id);
361
+ if (id !== void 0) this.running.delete(id);
362
+ pending?.reject(error);
363
+ this.pump();
364
+ };
365
+ worker.on(
366
+ "exit",
367
+ (code) => lose(new Error(`A compare worker exited with code ${code}`))
368
+ );
369
+ worker.on("error", lose);
370
+ return worker;
371
+ }
372
+ async close() {
373
+ for (const pending of this.running.values()) {
374
+ pending.reject(new Error("The compare worker pool was closed"));
375
+ }
376
+ this.running.clear();
377
+ this.queue.length = 0;
378
+ await Promise.all(this.workers.map((worker) => worker.terminate()));
379
+ this.workers.length = 0;
380
+ this.idle.length = 0;
381
+ }
382
+ };
383
+
384
+ // src/layout.ts
385
+ import { IMAGE_SUFFIX } from "visual-regression-manifest";
386
+ var sanitizeName = (name) => name.replace(/[\\:*?"<>|\u0000-\u001f]/g, "-").split("/").filter((segment) => segment !== "" && segment !== "." && segment !== "..").map(
387
+ (segment, _, all) => segment === all[all.length - 1] ? segment.replace(/\.(actual|diff)$/, "-$1") : segment
388
+ ).join("/") || "screenshot";
389
+ var manifestLayout = () => ({
390
+ paths: (name) => ({
391
+ baseline: `${name}.png`,
392
+ incoming: `${name}.png`,
393
+ actual: `${name}${IMAGE_SUFFIX.actual}.png`,
394
+ diff: `${name}${IMAGE_SUFFIX.diff}.png`
395
+ }),
396
+ nameOf(kind, file) {
397
+ if (!file.endsWith(".png")) return null;
398
+ const stem = file.slice(0, -".png".length);
399
+ const suffix = kind === "actual" ? IMAGE_SUFFIX.actual : kind === "diff" ? IMAGE_SUFFIX.diff : "";
400
+ if (suffix)
401
+ return stem.endsWith(suffix) ? stem.slice(0, -suffix.length) : null;
402
+ return stem.endsWith(IMAGE_SUFFIX.actual) || stem.endsWith(IMAGE_SUFFIX.diff) ? null : stem;
403
+ }
404
+ });
405
+ var samePathMatcher = () => ({
406
+ baselineFor: (incoming) => ({ kind: "baseline", path: incoming.path })
407
+ });
408
+ var templateMatcher = (template) => ({
409
+ baselineFor({ path: incoming }) {
410
+ const slash = incoming.lastIndexOf("/");
411
+ const dir = slash === -1 ? "" : incoming.slice(0, slash + 1);
412
+ const name = incoming.slice(slash + 1).replace(/\.png$/, "");
413
+ return {
414
+ kind: "baseline",
415
+ path: template.replaceAll("{dir}", dir).replaceAll("{name}", name).replaceAll("{path}", `${dir}${name}`)
416
+ };
417
+ }
418
+ });
419
+
420
+ // src/store.ts
421
+ import fs2 from "fs";
422
+ import path3 from "path";
423
+ import { isSafeRelativePath as isSafeRelativePath2 } from "visual-regression-manifest";
424
+ var GENERATED = /\.(actual|diff)\.png$/;
425
+ var fsStore = ({
426
+ baselineDir,
427
+ actualDir,
428
+ diffDir
429
+ }) => {
430
+ const roots = {
431
+ baseline: baselineDir,
432
+ incoming: actualDir,
433
+ actual: diffDir ?? baselineDir,
434
+ diff: diffDir ?? baselineDir
435
+ };
436
+ const rootOf = (kind) => {
437
+ const root = roots[kind];
438
+ if (!root) throw new Error(`The store has no directory for ${kind} images`);
439
+ return path3.resolve(root);
440
+ };
441
+ const locate = ({ kind, path: relative }) => {
442
+ if (relative !== "" && !isSafeRelativePath2(relative)) {
443
+ throw new Error(`Unsafe image path ${JSON.stringify(relative)}`);
444
+ }
445
+ return path3.join(rootOf(kind), relative);
446
+ };
447
+ return {
448
+ locate,
449
+ async read(ref) {
450
+ try {
451
+ return await fs2.promises.readFile(locate(ref));
452
+ } catch (error) {
453
+ if (error.code === "ENOENT") return null;
454
+ throw error;
455
+ }
456
+ },
457
+ async write(ref, bytes) {
458
+ const file = locate(ref);
459
+ await fs2.promises.mkdir(path3.dirname(file), { recursive: true });
460
+ await fs2.promises.writeFile(file, bytes);
461
+ },
462
+ async remove(ref) {
463
+ await fs2.promises.rm(locate(ref), { force: true });
464
+ },
465
+ async *list(kind) {
466
+ const root = rootOf(kind);
467
+ const found = [];
468
+ const walk = async (dir, prefix) => {
469
+ for (const dirent of await fs2.promises.readdir(dir, {
470
+ withFileTypes: true
471
+ })) {
472
+ const relative = prefix ? `${prefix}/${dirent.name}` : dirent.name;
473
+ if (dirent.isDirectory()) {
474
+ if (dirent.name !== "node_modules" && dirent.name !== ".git") {
475
+ await walk(path3.join(dir, dirent.name), relative);
476
+ }
477
+ } else if (dirent.isFile() && dirent.name.endsWith(".png") && !GENERATED.test(dirent.name)) {
478
+ found.push(relative);
479
+ }
480
+ }
481
+ };
482
+ await walk(root, "");
483
+ for (const relative of found.sort()) yield { kind, path: relative };
484
+ }
485
+ };
486
+ };
487
+ var fileStore = (files) => {
488
+ const locate = ({ kind }) => {
489
+ if (kind === "incoming") {
490
+ throw new Error("A file store has no incoming images");
491
+ }
492
+ return files[kind];
493
+ };
494
+ return {
495
+ locate,
496
+ async read(ref) {
497
+ try {
498
+ return await fs2.promises.readFile(locate(ref));
499
+ } catch (error) {
500
+ if (error.code === "ENOENT") return null;
501
+ throw error;
502
+ }
503
+ },
504
+ async write(ref, bytes) {
505
+ const file = locate(ref);
506
+ await fs2.promises.mkdir(path3.dirname(file), { recursive: true });
507
+ await fs2.promises.writeFile(file, bytes);
508
+ },
509
+ async remove(ref) {
510
+ await fs2.promises.rm(locate(ref), { force: true });
511
+ },
512
+ async *list() {
513
+ }
514
+ };
515
+ };
516
+
517
+ // src/plugins.ts
518
+ var createRegistry = (initial) => {
519
+ const registry = {
520
+ ...initial,
521
+ reporters: [...initial.reporters],
522
+ engines: /* @__PURE__ */ new Map(),
523
+ hooks: {
524
+ beforeCompare: [],
525
+ afterCompare: [],
526
+ decide: [],
527
+ beforeWrite: [],
528
+ end: []
529
+ },
530
+ async setup(plugins) {
531
+ const api = {
532
+ addEngine: (name, engine) => registry.engines.set(name, engine),
533
+ addReporter: (reporter) => registry.reporters.push(reporter),
534
+ setStore: (store) => registry.store = store,
535
+ setLayout: (layout) => registry.layout = layout,
536
+ setMatcher: (matcher) => registry.matcher = matcher,
537
+ on: ((event, fn) => {
538
+ registry.hooks[event].push(fn);
539
+ })
540
+ };
541
+ for (const plugin of plugins) await plugin.setup(api);
542
+ }
543
+ };
544
+ return registry;
545
+ };
546
+
547
+ // src/comparer.ts
548
+ var TOOL_NAME = "visual-regression-compare";
549
+ var packageVersion = () => {
550
+ try {
551
+ const text = fs3.readFileSync(
552
+ new URL("../package.json", import.meta.url),
553
+ "utf8"
554
+ );
555
+ return JSON.parse(text).version;
556
+ } catch {
557
+ return void 0;
558
+ }
559
+ };
560
+ var withDefaults = ({ baseline, actual, diff }) => {
561
+ const stem = baseline.replace(/\.png$/, "");
562
+ return {
563
+ baseline,
564
+ actual: actual ?? `${stem}${IMAGE_SUFFIX2.actual}.png`,
565
+ diff: diff ?? `${stem}${IMAGE_SUFFIX2.diff}.png`
566
+ };
567
+ };
568
+ var DEFAULT_WORKER = new URL("./worker.js", import.meta.url);
569
+ var percent = (ratio2) => `${Number((ratio2 * 100).toPrecision(3))}%`;
570
+ var sizeText = ({ width, height }) => `${width}x${height}`;
571
+ var Comparer = class {
572
+ constructor(options) {
573
+ this.options = options;
574
+ this.projectRoot = options.manifest.writer ? options.manifest.writer.projectRoot : path4.resolve(options.manifest.projectRoot ?? process.cwd());
575
+ this.registry = createRegistry({
576
+ store: options.store,
577
+ layout: options.layout ?? manifestLayout(),
578
+ matcher: options.matcher ?? samePathMatcher(),
579
+ reporters: options.reporters ?? []
580
+ });
581
+ this.store = () => this.registry.store;
582
+ this.layout = () => this.registry.layout;
583
+ this.matcher = () => this.registry.matcher;
584
+ this.ready = this.registry.setup(options.plugins ?? []);
585
+ this.writer = options.manifest.writer ?? this.createWriter(options);
586
+ this.pool = new WorkerPool(
587
+ options.concurrency ?? 1,
588
+ options.workerFile ?? DEFAULT_WORKER
589
+ );
590
+ }
591
+ options;
592
+ writer;
593
+ projectRoot;
594
+ store;
595
+ layout;
596
+ matcher;
597
+ pool;
598
+ registry;
599
+ ready;
600
+ engines = /* @__PURE__ */ new Map();
601
+ closed = false;
602
+ createWriter(options) {
603
+ const manifestPath = options.manifest.path ?? path4.join(this.projectRoot, getManifestFileName("compare"));
604
+ return new ManifestWriter(
605
+ path4.resolve(this.projectRoot, manifestPath),
606
+ {
607
+ projectRoot: this.projectRoot,
608
+ tool: options.manifest.tool ?? {
609
+ name: TOOL_NAME,
610
+ version: packageVersion()
611
+ },
612
+ runner: options.manifest.runner ?? { name: TOOL_NAME },
613
+ options: {
614
+ engine: typeof options.engine === "object" ? options.engine.name : options.engine ?? "pixelmatch",
615
+ engineOptions: options.engineOptions ?? {},
616
+ limits: options.limits ?? {},
617
+ onMissingBaseline: options.onMissingBaseline ?? "fail",
618
+ onSizeMismatch: options.onSizeMismatch ?? "fail",
619
+ update: options.update ?? "none"
620
+ }
621
+ },
622
+ { writeDelayMs: 200 }
623
+ );
624
+ }
625
+ /** The comparer's settings with the overrides of one call applied. */
626
+ settings(call = {}) {
627
+ const o = this.options;
628
+ return {
629
+ limits: call.limits ?? o.limits ?? {},
630
+ engineOptions: { ...o.engineOptions, ...call.engineOptions },
631
+ update: call.update ?? o.update ?? "none",
632
+ onMissingBaseline: call.onMissingBaseline ?? o.onMissingBaseline ?? "fail",
633
+ onSizeMismatch: call.onSizeMismatch ?? o.onSizeMismatch ?? "fail"
634
+ };
635
+ }
636
+ /** The engine as the main thread knows it: for its name and metric, and to run it when it is an object or came from a plugin. */
637
+ resolveEngine(engineOptions) {
638
+ const key = JSON.stringify(engineOptions);
639
+ let engine = this.engines.get(key);
640
+ if (!engine) {
641
+ engine = (async () => {
642
+ await this.ready;
643
+ const { engine: spec = "pixelmatch" } = this.options;
644
+ if (typeof spec === "object") return spec;
645
+ const registered = this.registry.engines.get(spec);
646
+ if (typeof registered === "object") return registered;
647
+ if (typeof registered === "function") return registered(engineOptions);
648
+ return loadEngine(spec, engineOptions);
649
+ })();
650
+ this.engines.set(key, engine);
651
+ }
652
+ return engine;
653
+ }
654
+ async measure(baseline, actual, engine, cfg, include) {
655
+ const { engine: spec = "pixelmatch" } = this.options;
656
+ const inMainThread = typeof spec === "object" || this.registry.engines.has(spec);
657
+ const job = {
658
+ baseline,
659
+ actual,
660
+ onSizeMismatch: cfg.onSizeMismatch,
661
+ include
662
+ };
663
+ if (inMainThread) return runCompare(job, engine);
664
+ return this.pool.run({
665
+ ...job,
666
+ engine: { id: spec, options: cfg.engineOptions, cwd: process.cwd() }
667
+ });
668
+ }
669
+ /** Compares one screenshot, decides, writes the files and records the manifest entry. */
670
+ async compareImage(input) {
671
+ return (await this.compare(input)).entry;
672
+ }
673
+ /**
674
+ * `compareImage` that can also hand back the compared images as PNG bytes,
675
+ * for a runner that shows them (a review UI). `include` names the parts;
676
+ * the diff is encoded anyway, the other two cost nothing when the sizes
677
+ * match.
678
+ */
679
+ async compare(input, { include = [] } = {}) {
680
+ await this.ready;
681
+ const name = sanitizeName(input.name);
682
+ if (input.files) {
683
+ return this.compareAt(
684
+ input,
685
+ name,
686
+ { kind: "baseline", path: "baseline" },
687
+ include
688
+ );
689
+ }
690
+ const baseline = {
691
+ kind: "baseline",
692
+ path: this.layout().paths(name).baseline
693
+ };
694
+ return this.compareAt(input, name, baseline, include);
695
+ }
696
+ compareAt(input, name, baselineRef, include) {
697
+ const store = input.files ? fileStore(withDefaults(input.files)) : this.store();
698
+ const names = input.files ? { actual: "actual", diff: "diff" } : this.layout().paths(name);
699
+ return this.compareWith(input, name, baselineRef, include, store, names);
700
+ }
701
+ async compareWith(input, name, baselineRef, include, store, names) {
702
+ const cfg = this.settings(input.options);
703
+ const started = Date.now();
704
+ const ctx = { name, input };
705
+ for (const fn of this.registry.hooks.beforeCompare) await fn(ctx);
706
+ const engine = await this.resolveEngine(cfg.engineOptions);
707
+ const baselineBytes = await store.read(baselineRef);
708
+ let measurement;
709
+ let diffPng;
710
+ const images = {};
711
+ if (baselineBytes === null) {
712
+ measurement = { kind: "missing-baseline" };
713
+ if (include.includes("actual")) images.actual = input.actual;
714
+ } else {
715
+ let job;
716
+ try {
717
+ job = await this.measure(
718
+ baselineBytes,
719
+ input.actual,
720
+ engine,
721
+ cfg,
722
+ include
723
+ );
724
+ } catch (error) {
725
+ throw new Error(`${name}: ${error.message}`, {
726
+ cause: error
727
+ });
728
+ }
729
+ measurement = job.kind === "size-mismatch" ? job : {
730
+ kind: "compared",
731
+ baselineSize: job.baselineSize,
732
+ actualSize: job.actualSize,
733
+ resized: job.resized,
734
+ result: {
735
+ diffPixels: job.diffPixels,
736
+ diffRatio: job.diffRatio,
737
+ score: job.score
738
+ }
739
+ };
740
+ diffPng = job.kind === "compared" ? job.diffPng : void 0;
741
+ if (job.kind === "compared") {
742
+ if (include.includes("diff") && diffPng) images.diff = diffPng;
743
+ if (job.baselinePng) images.baseline = job.baselinePng;
744
+ if (job.actualPng) images.actual = job.actualPng;
745
+ }
746
+ }
747
+ for (const fn of this.registry.hooks.afterCompare)
748
+ await fn(ctx, measurement);
749
+ const limit = this.limitFor(measurement, cfg);
750
+ let decision = this.decide(measurement, limit, cfg);
751
+ for (const fn of this.registry.hooks.decide) {
752
+ decision = await fn(ctx, decision);
753
+ }
754
+ const refs = {
755
+ actual: { kind: "actual", path: names.actual },
756
+ diff: { kind: "diff", path: names.diff }
757
+ };
758
+ await store.remove(refs.actual);
759
+ await store.remove(refs.diff);
760
+ let plan = { write: [] };
761
+ const status = decision.status;
762
+ if (status === "failed" || status === "missing-baseline") {
763
+ plan.write.push({ ref: refs.actual, bytes: input.actual });
764
+ if (diffPng) plan.write.push({ ref: refs.diff, bytes: diffPng });
765
+ } else if (status === "created" || status === "updated" || status === "approved") {
766
+ plan.write.push({ ref: baselineRef, bytes: input.actual });
767
+ }
768
+ for (const fn of this.registry.hooks.beforeWrite)
769
+ plan = await fn(ctx, plan);
770
+ for (const { ref, bytes } of plan.write) await store.write(ref, bytes);
771
+ const entry = this.record(
772
+ store,
773
+ cfg,
774
+ input,
775
+ name,
776
+ baselineRef,
777
+ refs,
778
+ measurement,
779
+ decision,
780
+ limit,
781
+ engine,
782
+ Date.now() - started
783
+ );
784
+ for (const reporter of this.registry.reporters)
785
+ await reporter.onEntry?.(entry);
786
+ return { entry, images };
787
+ }
788
+ /** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
789
+ limitFor(measurement, cfg) {
790
+ if (measurement.kind !== "compared") return void 0;
791
+ const { baselineSize: b, actualSize: a, resized } = measurement;
792
+ const size = resized === "pad" ? {
793
+ width: Math.max(a.width, b.width),
794
+ height: Math.max(a.height, b.height)
795
+ } : resized === "crop" ? {
796
+ width: Math.min(a.width, b.width),
797
+ height: Math.min(a.height, b.height)
798
+ } : a;
799
+ const { maxDiffRatio, maxDiffPixels } = cfg.limits;
800
+ return maxRatioFrom({ ratio: maxDiffRatio, pixels: maxDiffPixels }, size) ?? 0;
801
+ }
802
+ decide(measurement, limit, { update, onMissingBaseline }) {
803
+ if (measurement.kind === "missing-baseline") {
804
+ return onMissingBaseline === "create" ? {
805
+ status: "created",
806
+ message: "Baseline image was created from the actual screenshot."
807
+ } : {
808
+ status: "missing-baseline",
809
+ message: "There is no baseline image."
810
+ };
811
+ }
812
+ if (measurement.kind === "size-mismatch") {
813
+ const message = `The size changed: baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}.`;
814
+ return update === "none" ? { status: "failed", message } : {
815
+ status: "updated",
816
+ message: `${message} Baseline image was replaced with the actual screenshot.`
817
+ };
818
+ }
819
+ const { diffPixels, diffRatio } = measurement.result;
820
+ const passed = diffRatio <= (limit ?? 0);
821
+ const resized = measurement.resized ? ` Both images were ${measurement.resized === "pad" ? "padded" : "cropped"} to the same size (baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}).` : "";
822
+ const counts = `${diffPixels} pixels (${percent(diffRatio)}) differ`;
823
+ if (passed && !(update === "all" && diffPixels > 0)) {
824
+ return {
825
+ status: "passed",
826
+ message: diffPixels === 0 ? `Identical to the baseline.${resized}` : `${counts}, within the limit of ${percent(limit ?? 0)}.${resized}`
827
+ };
828
+ }
829
+ if (update !== "none") {
830
+ return {
831
+ status: "updated",
832
+ message: `${counts}. Baseline image was replaced with the actual screenshot.${resized}`
833
+ };
834
+ }
835
+ return {
836
+ status: "failed",
837
+ message: `${counts}; the limit is ${percent(limit ?? 0)}.${resized}`
838
+ };
839
+ }
840
+ record(store, cfg, input, name, baselineRef, refs, measurement, decision, limit, engine, duration) {
841
+ const where = (ref) => store.locate(ref) ?? path4.join(this.projectRoot, ref.path);
842
+ const status = decision.status;
843
+ const compared = measurement.kind === "compared" ? measurement : void 0;
844
+ const sized = measurement.kind !== "missing-baseline" ? measurement : void 0;
845
+ const { maxDiffRatio = 0 } = cfg.limits;
846
+ const baselineRoot = path4.relative(
847
+ this.projectRoot,
848
+ input.files ? path4.dirname(input.files.baseline) : where({ kind: "baseline", path: "" })
849
+ );
850
+ const meta = {
851
+ engine: engine.name,
852
+ ...compared && { diffPixels: compared.result.diffPixels },
853
+ ...compared?.result.score && { score: compared.result.score },
854
+ ...compared?.resized && { sizeMismatch: compared.resized },
855
+ ...measurement.kind === "size-mismatch" && {
856
+ sizeMismatch: "fail",
857
+ baselineSize: measurement.baselineSize,
858
+ actualSize: measurement.actualSize
859
+ },
860
+ ...decision.extra
861
+ };
862
+ return this.writer.record({
863
+ actualPath: where(refs.actual),
864
+ baselinePath: where(baselineRef),
865
+ diffPath: where(refs.diff),
866
+ name,
867
+ testFile: input.test?.filePath,
868
+ titlePath: input.test?.titlePath,
869
+ retry: input.test?.retry,
870
+ status,
871
+ diffRatio: measurement.kind === "size-mismatch" ? 1 : compared?.result.diffRatio,
872
+ maxDiffRatio: limit ?? maxDiffRatio,
873
+ colorThreshold: engine.colorThreshold,
874
+ colorMetric: engine.colorMetric,
875
+ baselineWritten: status === "created" || status === "updated" || status === "approved",
876
+ message: decision.message,
877
+ baselineSize: sized?.baselineSize,
878
+ actualSize: sized?.actualSize,
879
+ platform: input.platform ?? this.options.manifest.platform,
880
+ viewport: input.viewport,
881
+ variant: input.variant ?? this.options.manifest.variant,
882
+ extra: { ...input.extra, visualRegressionCompare: meta },
883
+ ...input.entry,
884
+ options: input.entry?.options ?? {
885
+ imagesPath: `${baselineRoot || "."}/{name}.png`,
886
+ maxDiffRatio,
887
+ diffConfig: { engine: engine.name, ...cfg.engineOptions },
888
+ createMissingImages: cfg.onMissingBaseline === "create",
889
+ updateImages: cfg.update === "failures-only" ? "failures-only" : cfg.update === "all"
890
+ },
891
+ duration: input.duration ?? duration
892
+ });
893
+ }
894
+ /** Compares every incoming image of the store against its baseline. */
895
+ async compareAll() {
896
+ await this.ready;
897
+ const refs = [];
898
+ for await (const ref of this.store().list("incoming")) refs.push(ref);
899
+ const queue = [...refs];
900
+ const workers = Math.max(1, this.options.concurrency ?? 1);
901
+ const failures = [];
902
+ await Promise.all(
903
+ Array.from({ length: Math.min(workers, queue.length) }, async () => {
904
+ for (let ref = queue.shift(); ref; ref = queue.shift()) {
905
+ try {
906
+ await this.compareIncoming(ref);
907
+ } catch (error) {
908
+ failures.push(error);
909
+ }
910
+ }
911
+ })
912
+ );
913
+ if (failures.length > 0) {
914
+ throw new AggregateError(
915
+ failures,
916
+ `${failures.length} image${failures.length === 1 ? "" : "s"} could not be compared:
917
+ ${failures.map((f) => ` ${f.message}`).join("\n")}`
918
+ );
919
+ }
920
+ return this.writer.toJSON();
921
+ }
922
+ async compareIncoming(incoming) {
923
+ const baselineRef = this.matcher().baselineFor(incoming);
924
+ if (!baselineRef) return;
925
+ const name = this.layout().nameOf("baseline", baselineRef.path) ?? baselineRef.path.replace(/\.png$/, "");
926
+ const actual = await this.store().read(incoming);
927
+ if (actual === null) return;
928
+ await this.compareAt({ name, actual }, name, baselineRef, []);
929
+ }
930
+ /** Flushes the manifest and ends the reporters. Call once, after the last comparison. */
931
+ async close() {
932
+ await this.ready;
933
+ if (!this.closed) {
934
+ this.closed = true;
935
+ this.writer.write();
936
+ this.writer.flush();
937
+ await this.pool.close();
938
+ for (const fn of this.registry.hooks.end) await fn(this.writer.toJSON());
939
+ for (const reporter of this.registry.reporters) {
940
+ await reporter.onEnd?.(this.writer.toJSON());
941
+ }
942
+ }
943
+ return this.writer.toJSON();
944
+ }
945
+ };
946
+ var createComparer = (options) => new Comparer(options);
947
+
948
+ // src/config.ts
949
+ import fs4 from "fs";
950
+ import path5 from "path";
951
+ import { pathToFileURL as pathToFileURL2 } from "url";
952
+ var CANDIDATES = [
953
+ "visual-regression.config.json",
954
+ "visual-regression.config.mjs",
955
+ "visual-regression.config.js"
956
+ ];
957
+ var loadConfig = async (explicit, cwd = process.cwd()) => {
958
+ const file = explicit ? path5.resolve(cwd, explicit) : CANDIDATES.map((name) => path5.join(cwd, name)).find(
959
+ (f) => fs4.existsSync(f)
960
+ );
961
+ if (!file) return {};
962
+ if (!fs4.existsSync(file))
963
+ throw new Error(`Config file ${file} does not exist`);
964
+ if (file.endsWith(".json")) return JSON.parse(fs4.readFileSync(file, "utf8"));
965
+ const loaded = await import(pathToFileURL2(file).href);
966
+ return loaded.default ?? {};
967
+ };
968
+ var configArg = (argv) => {
969
+ const at = argv.findIndex(
970
+ (arg2) => arg2 === "--config" || arg2.startsWith("--config=")
971
+ );
972
+ if (at === -1) return void 0;
973
+ const arg = argv[at];
974
+ return arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[at + 1];
975
+ };
976
+
977
+ // src/options.ts
978
+ import { parseArgs } from "util";
979
+ import {
980
+ MANIFEST_STATUSES
981
+ } from "visual-regression-manifest";
982
+ var UsageError = class extends Error {
983
+ };
984
+ var helpText = `Compare folders of screenshots against their baselines and write a visual-regression-manifest.
985
+
986
+ Usage:
987
+ visual-regression-compare [compare] <actual-dir> --baseline <dir> [options]
988
+ visual-regression-compare record <actual-dir> --baseline <dir> [options]
989
+ visual-regression-compare approve --manifest <path>... [<name>...|--all] [options]
990
+
991
+ compare options:
992
+ --baseline <dir> directory of baseline images (required)
993
+ --diff <dir> where .actual and .diff files go (default: next to the baselines)
994
+ --engine <id> pixelmatch, or a module path or package name (default: pixelmatch)
995
+ --max-diff-ratio <n> largest accepted share of differing pixels, 0..1 (default: 0)
996
+ --max-diff-pixels <n> largest accepted number of differing pixels
997
+ --color-threshold <n> per-pixel color tolerance of the engine, 0..1 (default: 0.1)
998
+ --include-aa count anti-aliased pixels as differences
999
+ --on-missing fail|create a screenshot without baseline (default: fail)
1000
+ --on-size-mismatch fail|pad|crop
1001
+ fail records the size change and compares nothing; pad grows both
1002
+ images to the larger size, crop cuts both to the smaller (default: fail)
1003
+ --update none|failures-only|all
1004
+ overwrite baselines (default: none)
1005
+ --match <template> baseline path from the screenshot path: {dir}, {name}, {path}
1006
+ --layout manifest file naming (default: manifest)
1007
+ --manifest <path> default: <actual-dir>/visual-regression-manifest.compare.json
1008
+ --runner <name> --variant <id> --platform <os>[/<browser>]
1009
+ metadata recorded on every entry
1010
+ --report <list> console, ctrf[:<file>], github-summary[:<file>] (default: console)
1011
+ --concurrency <n> worker threads for decoding and comparing (default: CPU count)
1012
+ --config <file> visual-regression.config.{json,mjs,js}, found in the working directory
1013
+ --fail-on <list> statuses that make the exit code 1 (default: failed,missing-baseline)
1014
+
1015
+ approve options:
1016
+ --manifest <path> a manifest to apply (repeatable; required)
1017
+ --all every entry that needs a human (the default without names)
1018
+ --test <file> only entries of this test file
1019
+ --repo-root <dir> the repository the manifest's paths are applied to (default: the working directory)
1020
+ --force approve although the baseline changed since the run
1021
+ --dry-run report what would be approved
1022
+
1023
+ -h, --help, -v, --version
1024
+ `;
1025
+ var number = (name, value) => {
1026
+ if (value === void 0) return void 0;
1027
+ const parsed = Number(value);
1028
+ if (value.trim() === "" || Number.isNaN(parsed)) {
1029
+ throw new UsageError(`${name} must be a number, got "${value}"`);
1030
+ }
1031
+ return parsed;
1032
+ };
1033
+ var ratio = (value) => {
1034
+ if (value !== void 0 && (value < 0 || value > 1)) {
1035
+ throw new UsageError(
1036
+ `--max-diff-ratio must be between 0 and 1, got ${value}`
1037
+ );
1038
+ }
1039
+ return value;
1040
+ };
1041
+ var oneOf = (name, value, allowed, fallback) => {
1042
+ if (value === void 0) return fallback;
1043
+ if (!allowed.includes(value)) {
1044
+ throw new UsageError(
1045
+ `${name} must be one of ${allowed.join(", ")}, got "${value}"`
1046
+ );
1047
+ }
1048
+ return value;
1049
+ };
1050
+ var parseCommand = (argv, config = {}) => {
1051
+ let parsed;
1052
+ try {
1053
+ parsed = parseArgs({
1054
+ args: argv,
1055
+ allowPositionals: true,
1056
+ options: {
1057
+ help: { type: "boolean", short: "h" },
1058
+ version: { type: "boolean", short: "v" },
1059
+ baseline: { type: "string" },
1060
+ diff: { type: "string" },
1061
+ engine: { type: "string" },
1062
+ "max-diff-ratio": { type: "string" },
1063
+ "max-diff-pixels": { type: "string" },
1064
+ "color-threshold": { type: "string" },
1065
+ "include-aa": { type: "boolean" },
1066
+ "on-missing": { type: "string" },
1067
+ "on-size-mismatch": { type: "string" },
1068
+ update: { type: "string" },
1069
+ match: { type: "string" },
1070
+ layout: { type: "string" },
1071
+ manifest: { type: "string", multiple: true },
1072
+ runner: { type: "string" },
1073
+ variant: { type: "string" },
1074
+ platform: { type: "string" },
1075
+ report: { type: "string" },
1076
+ concurrency: { type: "string" },
1077
+ config: { type: "string" },
1078
+ "fail-on": { type: "string" },
1079
+ all: { type: "boolean" },
1080
+ test: { type: "string" },
1081
+ "repo-root": { type: "string" },
1082
+ force: { type: "boolean" },
1083
+ "dry-run": { type: "boolean" }
1084
+ }
1085
+ });
1086
+ } catch (error) {
1087
+ throw new UsageError(error.message);
1088
+ }
1089
+ const { values } = parsed;
1090
+ const verbAt = parsed.positionals.findIndex(
1091
+ (arg) => ["compare", "record", "approve"].includes(arg)
1092
+ );
1093
+ const verb = verbAt === -1 ? void 0 : parsed.positionals[verbAt];
1094
+ const positionals = parsed.positionals.filter((_, i) => i !== verbAt);
1095
+ if (values.help) return { kind: "help" };
1096
+ if (values.version) return { kind: "version" };
1097
+ const manifests = values.manifest ?? [];
1098
+ if (verb === "approve") {
1099
+ if (manifests.length === 0) {
1100
+ throw new UsageError("approve needs at least one --manifest <path>");
1101
+ }
1102
+ return {
1103
+ kind: "approve",
1104
+ options: {
1105
+ manifests,
1106
+ names: positionals,
1107
+ all: Boolean(values.all) || positionals.length === 0,
1108
+ test: values.test,
1109
+ repoRoot: values["repo-root"],
1110
+ force: Boolean(values.force),
1111
+ dryRun: Boolean(values["dry-run"])
1112
+ }
1113
+ };
1114
+ }
1115
+ if (manifests.length > 1) {
1116
+ throw new UsageError("compare takes one --manifest <path>");
1117
+ }
1118
+ const actualDir = positionals[0] ?? config.actualDir;
1119
+ if (!actualDir) throw new UsageError("Missing <actual-dir>");
1120
+ if (positionals.length > 1) {
1121
+ throw new UsageError(`Unexpected argument "${positionals[1]}"`);
1122
+ }
1123
+ const baselineDir = values.baseline ?? config.baselineDir;
1124
+ if (!baselineDir) throw new UsageError("Missing --baseline <dir>");
1125
+ const record = verb === "record";
1126
+ const failOn = (values["fail-on"]?.split(",").map((s) => s.trim()).filter(Boolean) ?? config.failOn ?? ["failed", "missing-baseline"]).map(
1127
+ (status) => oneOf("--fail-on", status, MANIFEST_STATUSES, "failed")
1128
+ );
1129
+ if (failOn.length === 0) {
1130
+ throw new UsageError("--fail-on needs at least one status");
1131
+ }
1132
+ const colorThreshold = number(
1133
+ "--color-threshold",
1134
+ values["color-threshold"]
1135
+ );
1136
+ return {
1137
+ kind: "compare",
1138
+ options: {
1139
+ actualDir,
1140
+ baselineDir,
1141
+ diffDir: values.diff ?? config.diffDir,
1142
+ engine: values.engine ?? config.engine ?? "pixelmatch",
1143
+ engineOptions: {
1144
+ ...config.engineOptions,
1145
+ ...colorThreshold !== void 0 && { threshold: colorThreshold },
1146
+ ...values["include-aa"] && { includeAA: true }
1147
+ },
1148
+ maxDiffRatio: ratio(
1149
+ number(
1150
+ "--max-diff-ratio",
1151
+ values["max-diff-ratio"]
1152
+ ) ?? config.maxDiffRatio
1153
+ ),
1154
+ maxDiffPixels: number(
1155
+ "--max-diff-pixels",
1156
+ values["max-diff-pixels"]
1157
+ ) ?? config.maxDiffPixels,
1158
+ onMissingBaseline: record ? "create" : oneOf(
1159
+ "--on-missing",
1160
+ values["on-missing"] ?? config.onMissingBaseline,
1161
+ ["fail", "create"],
1162
+ "fail"
1163
+ ),
1164
+ onSizeMismatch: oneOf(
1165
+ "--on-size-mismatch",
1166
+ values["on-size-mismatch"] ?? config.onSizeMismatch,
1167
+ ["fail", "pad", "crop"],
1168
+ "fail"
1169
+ ),
1170
+ update: record ? "all" : oneOf(
1171
+ "--update",
1172
+ values.update ?? config.update,
1173
+ ["none", "failures-only", "all"],
1174
+ "none"
1175
+ ),
1176
+ match: values.match ?? config.match,
1177
+ layout: oneOf(
1178
+ "--layout",
1179
+ values.layout,
1180
+ ["manifest"],
1181
+ "manifest"
1182
+ ),
1183
+ manifest: manifests[0] ?? config.manifest,
1184
+ runner: values.runner ?? config.runner,
1185
+ variant: values.variant ?? config.variant,
1186
+ platform: values.platform ?? config.platform,
1187
+ report: values.report ?? config.report ?? "console",
1188
+ concurrency: number("--concurrency", values.concurrency) ?? config.concurrency,
1189
+ failOn,
1190
+ record
1191
+ }
1192
+ };
1193
+ };
1194
+
1195
+ // src/reporters.ts
1196
+ import fs5 from "fs";
1197
+ import path6 from "path";
1198
+ import {
1199
+ needsHuman,
1200
+ toCtrf
1201
+ } from "visual-regression-manifest";
1202
+ var MARK = {
1203
+ passed: "\u2713",
1204
+ failed: "\u2717",
1205
+ "missing-baseline": "?",
1206
+ created: "+",
1207
+ updated: "~",
1208
+ approved: "\u2713"
1209
+ };
1210
+ var consoleReporter = (log = (line) => process.stdout.write(`${line}
1211
+ `)) => ({
1212
+ onEntry(entry) {
1213
+ const note = entry.status === "passed" ? "" : ` ${entry.status}: ${entry.message}`;
1214
+ log(`${MARK[entry.status]} ${entry.name}${note}`);
1215
+ },
1216
+ onEnd({ summary }) {
1217
+ const parts = [
1218
+ `${summary.passed} passed`,
1219
+ `${summary.failed} failed`,
1220
+ `${summary.missingBaseline} without baseline`,
1221
+ `${summary.created} created`,
1222
+ `${summary.updated} updated`
1223
+ ];
1224
+ log(`
1225
+ ${summary.entries} compared: ${parts.join(", ")}`);
1226
+ }
1227
+ });
1228
+ var ctrfReporter = (file) => ({
1229
+ onEnd(manifest) {
1230
+ fs5.mkdirSync(path6.dirname(path6.resolve(file)), { recursive: true });
1231
+ fs5.writeFileSync(file, JSON.stringify(toCtrf(manifest), null, 2));
1232
+ }
1233
+ });
1234
+ var cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
1235
+ var summaryMarkdown = ({ summary, entries }) => {
1236
+ const lines = [
1237
+ "### Visual regression",
1238
+ "",
1239
+ `${summary.entries} screenshots: ${summary.passed} passed, ${summary.failed} failed, ${summary.missingBaseline} without baseline, ${summary.created} created, ${summary.updated} updated.`
1240
+ ];
1241
+ const open = entries.filter((entry) => needsHuman(entry.status));
1242
+ if (open.length > 0) {
1243
+ lines.push("", "| Screenshot | Status | Details |", "| --- | --- | --- |");
1244
+ for (const entry of open) {
1245
+ lines.push(
1246
+ `| ${cell(entry.name)} | ${entry.status} | ${cell(entry.message)} |`
1247
+ );
1248
+ }
1249
+ }
1250
+ return `${lines.join("\n")}
1251
+ `;
1252
+ };
1253
+ var githubSummaryReporter = (file = process.env.GITHUB_STEP_SUMMARY) => ({
1254
+ onEnd(manifest) {
1255
+ if (!file) return;
1256
+ fs5.appendFileSync(file, summaryMarkdown(manifest));
1257
+ }
1258
+ });
1259
+ var REPORTER_NAMES = ["console", "ctrf", "github-summary"];
1260
+ var parseReporters = (spec) => spec.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
1261
+ const colon = part.indexOf(":");
1262
+ const name = colon === -1 ? part : part.slice(0, colon);
1263
+ const arg = colon === -1 ? void 0 : part.slice(colon + 1);
1264
+ switch (name) {
1265
+ case "console":
1266
+ return consoleReporter();
1267
+ case "ctrf":
1268
+ return ctrfReporter(arg ?? "ctrf-report.json");
1269
+ case "github-summary":
1270
+ return githubSummaryReporter(arg);
1271
+ default:
1272
+ throw new Error(
1273
+ `Unknown reporter "${name}" (known: ${REPORTER_NAMES.join(", ")})`
1274
+ );
1275
+ }
1276
+ });
1277
+
1278
+ // src/run.ts
1279
+ var platformOf = (spec) => {
1280
+ if (!spec) return void 0;
1281
+ const [osPlatform, browser] = spec.split("/");
1282
+ return { osPlatform, ...browser && { browser: { name: browser } } };
1283
+ };
1284
+ var compare = async (options, version, out) => {
1285
+ const actualDir = path7.resolve(options.actualDir);
1286
+ const comparer = createComparer({
1287
+ engine: options.engine,
1288
+ engineOptions: options.engineOptions,
1289
+ limits: {
1290
+ maxDiffRatio: options.maxDiffRatio,
1291
+ maxDiffPixels: options.maxDiffPixels
1292
+ },
1293
+ onMissingBaseline: options.onMissingBaseline,
1294
+ onSizeMismatch: options.onSizeMismatch,
1295
+ update: options.update,
1296
+ store: fsStore({
1297
+ baselineDir: options.baselineDir,
1298
+ actualDir,
1299
+ diffDir: options.diffDir
1300
+ }),
1301
+ layout: manifestLayout(),
1302
+ matcher: options.match ? templateMatcher(options.match) : samePathMatcher(),
1303
+ manifest: {
1304
+ path: options.manifest ?? path7.join(actualDir, "visual-regression-manifest.compare.json"),
1305
+ projectRoot: commonRoot([
1306
+ actualDir,
1307
+ path7.resolve(options.baselineDir),
1308
+ ...options.diffDir ? [path7.resolve(options.diffDir)] : []
1309
+ ]),
1310
+ tool: { name: "visual-regression-compare", version },
1311
+ runner: { name: options.runner ?? "visual-regression-compare" },
1312
+ platform: platformOf(options.platform),
1313
+ variant: options.variant
1314
+ },
1315
+ reporters: parseReporters(options.report),
1316
+ concurrency: options.concurrency ?? os.cpus().length
1317
+ });
1318
+ try {
1319
+ await comparer.compareAll();
1320
+ } finally {
1321
+ await comparer.close();
1322
+ }
1323
+ const manifest = comparer.writer.toJSON();
1324
+ const failing = manifest.entries.filter(
1325
+ (entry) => options.failOn.includes(entry.status)
1326
+ );
1327
+ if (manifest.entries.length === 0) {
1328
+ out.error(`No images found in ${options.actualDir}`);
1329
+ return 1;
1330
+ }
1331
+ out.log(`Manifest: ${comparer.writer.filePath}`);
1332
+ return failing.length > 0 ? 1 : 0;
1333
+ };
1334
+ var commonRoot = (dirs) => {
1335
+ const split = dirs.map((dir) => dir.split(path7.sep));
1336
+ const shared = [];
1337
+ for (let i = 0; i < split[0].length; i++) {
1338
+ if (split.every((parts) => parts[i] === split[0][i]))
1339
+ shared.push(split[0][i]);
1340
+ else break;
1341
+ }
1342
+ return shared.join(path7.sep) || path7.sep;
1343
+ };
1344
+ var approveCommand = (options, out) => {
1345
+ const result = approve({
1346
+ manifests: options.manifests,
1347
+ names: options.all ? void 0 : options.names,
1348
+ testFile: options.test,
1349
+ repoRoot: options.repoRoot,
1350
+ force: options.force,
1351
+ dryRun: options.dryRun
1352
+ });
1353
+ for (const warning of result.warnings) out.error(`warning: ${warning}`);
1354
+ for (const name of result.unknown) out.error(`No entry named "${name}"`);
1355
+ for (const { entry, reason } of result.skipped) {
1356
+ out.error(`skipped ${entry.name}: ${reason}`);
1357
+ }
1358
+ const verb = options.dryRun ? "Would approve" : "Approved";
1359
+ out.log(
1360
+ `${verb} ${result.approved.length} screenshot${result.approved.length === 1 ? "" : "s"}`
1361
+ );
1362
+ for (const file of result.files) out.log(` ${file}`);
1363
+ return result.skipped.length > 0 || result.unknown.length > 0 ? 1 : 0;
1364
+ };
1365
+ var runCli = async (argv, out) => {
1366
+ try {
1367
+ const config = await loadConfig(configArg(argv));
1368
+ const command = parseCommand(argv, config);
1369
+ if (command.kind === "help") {
1370
+ out.log(helpText);
1371
+ return 0;
1372
+ }
1373
+ const { version } = JSON.parse(
1374
+ fs6.readFileSync(new URL("../package.json", import.meta.url), "utf8")
1375
+ );
1376
+ if (command.kind === "version") {
1377
+ out.log(version);
1378
+ return 0;
1379
+ }
1380
+ return command.kind === "approve" ? approveCommand(command.options, out) : await compare(command.options, version, out);
1381
+ } catch (error) {
1382
+ const message = error instanceof Error ? error.message : String(error);
1383
+ out.error(
1384
+ error instanceof UsageError ? `${message}
1385
+ Run with --help for the options.` : `Error: ${message}`
1386
+ );
1387
+ return 2;
1388
+ }
1389
+ };
1390
+
1391
+ // src/cli.ts
1392
+ process.exitCode = await runCli(process.argv.slice(2), {
1393
+ log: (line) => process.stdout.write(`${line}
1394
+ `),
1395
+ error: (line) => process.stderr.write(`${line}
1396
+ `)
1397
+ });