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/index.js ADDED
@@ -0,0 +1,1044 @@
1
+ // src/comparer.ts
2
+ import fs2 from "fs";
3
+ import path3 from "path";
4
+ import {
5
+ ManifestWriter,
6
+ IMAGE_SUFFIX as IMAGE_SUFFIX2,
7
+ getManifestFileName,
8
+ maxRatioFrom
9
+ } from "visual-regression-manifest";
10
+
11
+ // src/engines/index.ts
12
+ import path from "path";
13
+ import { pathToFileURL } from "url";
14
+
15
+ // src/engines/pixelmatch.ts
16
+ import pixelmatch from "pixelmatch";
17
+ var pixelmatchEngine = ({
18
+ threshold = 0.1,
19
+ includeAA = false
20
+ } = {}) => ({
21
+ name: "pixelmatch",
22
+ colorMetric: "yiq",
23
+ colorThreshold: threshold,
24
+ compare(baseline, actual) {
25
+ const { width, height } = baseline;
26
+ const diff = new Uint8ClampedArray(width * height * 4);
27
+ const diffPixels = pixelmatch(
28
+ baseline.data,
29
+ actual.data,
30
+ diff,
31
+ width,
32
+ height,
33
+ {
34
+ threshold,
35
+ includeAA
36
+ }
37
+ );
38
+ const area = width * height;
39
+ return {
40
+ diffPixels,
41
+ diffRatio: area === 0 ? 0 : diffPixels / area,
42
+ diff: { width, height, data: diff }
43
+ };
44
+ }
45
+ });
46
+ var pixelmatchFrom = (options) => pixelmatchEngine(options);
47
+
48
+ // src/engines/index.ts
49
+ var BUILT_IN = {
50
+ pixelmatch: pixelmatchFrom
51
+ };
52
+ var BUILT_IN_ENGINES = Object.keys(BUILT_IN);
53
+ var isDiffEngine = (value) => typeof value === "object" && value !== null && typeof value.compare === "function" && typeof value.name === "string";
54
+ var loadEngine = async (id, options = {}, cwd = process.cwd()) => {
55
+ const builtIn = BUILT_IN[id];
56
+ if (builtIn) return builtIn(options);
57
+ const isPath = /^\.{0,2}[\\/]/.test(id) || path.isAbsolute(id);
58
+ const specifier = isPath ? pathToFileURL(path.resolve(cwd, id)).href : id;
59
+ let loaded;
60
+ try {
61
+ loaded = await import(specifier);
62
+ } catch (error) {
63
+ throw new Error(
64
+ `Cannot load engine "${id}" (built in: ${BUILT_IN_ENGINES.join(", ")}): ${error.message}`,
65
+ { cause: error }
66
+ );
67
+ }
68
+ const exported = loaded.default;
69
+ const engine = typeof exported === "function" ? exported(options) : exported;
70
+ if (!isDiffEngine(engine)) {
71
+ throw new Error(
72
+ `Engine "${id}" must default-export a { name, colorMetric, compare } object or a function returning one`
73
+ );
74
+ }
75
+ return engine;
76
+ };
77
+
78
+ // src/image.ts
79
+ import { PNG } from "pngjs";
80
+ var LFS_POINTER = "version https://git-lfs.github.com/spec/";
81
+ var decodePng = (bytes) => {
82
+ const head = Buffer.from(bytes.subarray(0, LFS_POINTER.length)).toString(
83
+ "latin1"
84
+ );
85
+ if (head === LFS_POINTER) {
86
+ throw new Error(
87
+ "the file is a Git LFS pointer, not an image; run `git lfs pull`"
88
+ );
89
+ }
90
+ const { width, height, data } = PNG.sync.read(Buffer.from(bytes));
91
+ return { width, height, data: new Uint8ClampedArray(data) };
92
+ };
93
+ var encodePng = ({ width, height, data }) => {
94
+ const png = new PNG({ width, height });
95
+ png.data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
96
+ return PNG.sync.write(png);
97
+ };
98
+ var resize = (image, width, height) => {
99
+ const data = new Uint8ClampedArray(width * height * 4);
100
+ const rows = Math.min(height, image.height);
101
+ const rowBytes = Math.min(width, image.width) * 4;
102
+ for (let y = 0; y < rows; y++) {
103
+ const from = y * image.width * 4;
104
+ data.set(image.data.subarray(from, from + rowBytes), y * width * 4);
105
+ }
106
+ return { width, height, data };
107
+ };
108
+ var padToSame = (a, b) => {
109
+ const width = Math.max(a.width, b.width);
110
+ const height = Math.max(a.height, b.height);
111
+ return [resize(a, width, height), resize(b, width, height)];
112
+ };
113
+ var cropToSame = (a, b) => {
114
+ const width = Math.min(a.width, b.width);
115
+ const height = Math.min(a.height, b.height);
116
+ return [resize(a, width, height), resize(b, width, height)];
117
+ };
118
+
119
+ // src/job.ts
120
+ var decodeNamed = (which, bytes) => {
121
+ try {
122
+ return decodePng(bytes);
123
+ } catch (error) {
124
+ throw new Error(`the ${which} image: ${error.message}`, {
125
+ cause: error
126
+ });
127
+ }
128
+ };
129
+ var sizeOf = ({ width, height }) => ({ width, height });
130
+ var runCompare = async (job, engine) => {
131
+ let baseline = decodeNamed("baseline", job.baseline);
132
+ let actual = decodeNamed("actual", job.actual);
133
+ const baselineSize = sizeOf(baseline);
134
+ const actualSize = sizeOf(actual);
135
+ let resized;
136
+ if (baseline.width !== actual.width || baseline.height !== actual.height) {
137
+ if (job.onSizeMismatch === "fail") {
138
+ return { kind: "size-mismatch", baselineSize, actualSize };
139
+ }
140
+ resized = job.onSizeMismatch;
141
+ [baseline, actual] = (resized === "pad" ? padToSame : cropToSame)(
142
+ baseline,
143
+ actual
144
+ );
145
+ }
146
+ const { diff, ...result } = await engine.compare(baseline, actual);
147
+ const compared = (part, image) => job.include?.includes(part) ? {
148
+ [`${part}Png`]: resized ? encodePng(image) : job[part]
149
+ } : {};
150
+ return {
151
+ kind: "compared",
152
+ baselineSize,
153
+ actualSize,
154
+ ...resized && { resized },
155
+ ...result,
156
+ ...diff && { diffPng: encodePng(diff) },
157
+ ...compared("baseline", baseline),
158
+ ...compared("actual", actual)
159
+ };
160
+ };
161
+ var runJob = async (job) => runCompare(
162
+ job,
163
+ await loadEngine(job.engine.id, job.engine.options, job.engine.cwd)
164
+ );
165
+
166
+ // src/pool.ts
167
+ import { Worker } from "worker_threads";
168
+ var WorkerPool = class {
169
+ constructor(size, workerFile) {
170
+ this.size = size;
171
+ this.workerFile = workerFile;
172
+ }
173
+ size;
174
+ workerFile;
175
+ idle = [];
176
+ workers = [];
177
+ queue = [];
178
+ running = /* @__PURE__ */ new Map();
179
+ nextId = 0;
180
+ current = /* @__PURE__ */ new WeakMap();
181
+ get threaded() {
182
+ return this.size > 1 && this.workerFile !== void 0;
183
+ }
184
+ run(job) {
185
+ if (!this.threaded) return runJob(job);
186
+ return new Promise((resolve, reject) => {
187
+ this.queue.push({ job, resolve, reject });
188
+ this.pump();
189
+ });
190
+ }
191
+ pump() {
192
+ while (this.queue.length > 0) {
193
+ const worker = this.idle.pop() ?? this.spawn();
194
+ if (!worker) return;
195
+ const pending = this.queue.shift();
196
+ const id = this.nextId++;
197
+ this.running.set(id, pending);
198
+ this.current.set(worker, id);
199
+ worker.postMessage({ id, job: pending.job });
200
+ }
201
+ }
202
+ spawn() {
203
+ if (this.workers.length >= this.size) return void 0;
204
+ const worker = new Worker(this.workerFile);
205
+ this.workers.push(worker);
206
+ worker.on("message", ({ id, result, error }) => {
207
+ const pending = this.running.get(id);
208
+ this.running.delete(id);
209
+ this.idle.push(worker);
210
+ if (error !== void 0) pending?.reject(new Error(error));
211
+ else pending?.resolve(result);
212
+ this.pump();
213
+ });
214
+ const lose = (error) => {
215
+ for (const list of [this.workers, this.idle]) {
216
+ const at = list.indexOf(worker);
217
+ if (at !== -1) list.splice(at, 1);
218
+ }
219
+ const id = this.current.get(worker);
220
+ const pending = id === void 0 ? void 0 : this.running.get(id);
221
+ if (id !== void 0) this.running.delete(id);
222
+ pending?.reject(error);
223
+ this.pump();
224
+ };
225
+ worker.on(
226
+ "exit",
227
+ (code) => lose(new Error(`A compare worker exited with code ${code}`))
228
+ );
229
+ worker.on("error", lose);
230
+ return worker;
231
+ }
232
+ async close() {
233
+ for (const pending of this.running.values()) {
234
+ pending.reject(new Error("The compare worker pool was closed"));
235
+ }
236
+ this.running.clear();
237
+ this.queue.length = 0;
238
+ await Promise.all(this.workers.map((worker) => worker.terminate()));
239
+ this.workers.length = 0;
240
+ this.idle.length = 0;
241
+ }
242
+ };
243
+
244
+ // src/layout.ts
245
+ import { IMAGE_SUFFIX } from "visual-regression-manifest";
246
+ var sanitizeName = (name) => name.replace(/[\\:*?"<>|\u0000-\u001f]/g, "-").split("/").filter((segment) => segment !== "" && segment !== "." && segment !== "..").map(
247
+ (segment, _, all) => segment === all[all.length - 1] ? segment.replace(/\.(actual|diff)$/, "-$1") : segment
248
+ ).join("/") || "screenshot";
249
+ var manifestLayout = () => ({
250
+ paths: (name) => ({
251
+ baseline: `${name}.png`,
252
+ incoming: `${name}.png`,
253
+ actual: `${name}${IMAGE_SUFFIX.actual}.png`,
254
+ diff: `${name}${IMAGE_SUFFIX.diff}.png`
255
+ }),
256
+ nameOf(kind, file) {
257
+ if (!file.endsWith(".png")) return null;
258
+ const stem = file.slice(0, -".png".length);
259
+ const suffix = kind === "actual" ? IMAGE_SUFFIX.actual : kind === "diff" ? IMAGE_SUFFIX.diff : "";
260
+ if (suffix)
261
+ return stem.endsWith(suffix) ? stem.slice(0, -suffix.length) : null;
262
+ return stem.endsWith(IMAGE_SUFFIX.actual) || stem.endsWith(IMAGE_SUFFIX.diff) ? null : stem;
263
+ }
264
+ });
265
+ var samePathMatcher = () => ({
266
+ baselineFor: (incoming) => ({ kind: "baseline", path: incoming.path })
267
+ });
268
+ var templateMatcher = (template) => ({
269
+ baselineFor({ path: incoming }) {
270
+ const slash = incoming.lastIndexOf("/");
271
+ const dir = slash === -1 ? "" : incoming.slice(0, slash + 1);
272
+ const name = incoming.slice(slash + 1).replace(/\.png$/, "");
273
+ return {
274
+ kind: "baseline",
275
+ path: template.replaceAll("{dir}", dir).replaceAll("{name}", name).replaceAll("{path}", `${dir}${name}`)
276
+ };
277
+ }
278
+ });
279
+
280
+ // src/store.ts
281
+ import fs from "fs";
282
+ import path2 from "path";
283
+ import { isSafeRelativePath } from "visual-regression-manifest";
284
+ var GENERATED = /\.(actual|diff)\.png$/;
285
+ var fsStore = ({
286
+ baselineDir,
287
+ actualDir,
288
+ diffDir
289
+ }) => {
290
+ const roots = {
291
+ baseline: baselineDir,
292
+ incoming: actualDir,
293
+ actual: diffDir ?? baselineDir,
294
+ diff: diffDir ?? baselineDir
295
+ };
296
+ const rootOf = (kind) => {
297
+ const root = roots[kind];
298
+ if (!root) throw new Error(`The store has no directory for ${kind} images`);
299
+ return path2.resolve(root);
300
+ };
301
+ const locate = ({ kind, path: relative }) => {
302
+ if (relative !== "" && !isSafeRelativePath(relative)) {
303
+ throw new Error(`Unsafe image path ${JSON.stringify(relative)}`);
304
+ }
305
+ return path2.join(rootOf(kind), relative);
306
+ };
307
+ return {
308
+ locate,
309
+ async read(ref) {
310
+ try {
311
+ return await fs.promises.readFile(locate(ref));
312
+ } catch (error) {
313
+ if (error.code === "ENOENT") return null;
314
+ throw error;
315
+ }
316
+ },
317
+ async write(ref, bytes) {
318
+ const file = locate(ref);
319
+ await fs.promises.mkdir(path2.dirname(file), { recursive: true });
320
+ await fs.promises.writeFile(file, bytes);
321
+ },
322
+ async remove(ref) {
323
+ await fs.promises.rm(locate(ref), { force: true });
324
+ },
325
+ async *list(kind) {
326
+ const root = rootOf(kind);
327
+ const found = [];
328
+ const walk = async (dir, prefix) => {
329
+ for (const dirent of await fs.promises.readdir(dir, {
330
+ withFileTypes: true
331
+ })) {
332
+ const relative = prefix ? `${prefix}/${dirent.name}` : dirent.name;
333
+ if (dirent.isDirectory()) {
334
+ if (dirent.name !== "node_modules" && dirent.name !== ".git") {
335
+ await walk(path2.join(dir, dirent.name), relative);
336
+ }
337
+ } else if (dirent.isFile() && dirent.name.endsWith(".png") && !GENERATED.test(dirent.name)) {
338
+ found.push(relative);
339
+ }
340
+ }
341
+ };
342
+ await walk(root, "");
343
+ for (const relative of found.sort()) yield { kind, path: relative };
344
+ }
345
+ };
346
+ };
347
+ var fileStore = (files) => {
348
+ const locate = ({ kind }) => {
349
+ if (kind === "incoming") {
350
+ throw new Error("A file store has no incoming images");
351
+ }
352
+ return files[kind];
353
+ };
354
+ return {
355
+ locate,
356
+ async read(ref) {
357
+ try {
358
+ return await fs.promises.readFile(locate(ref));
359
+ } catch (error) {
360
+ if (error.code === "ENOENT") return null;
361
+ throw error;
362
+ }
363
+ },
364
+ async write(ref, bytes) {
365
+ const file = locate(ref);
366
+ await fs.promises.mkdir(path2.dirname(file), { recursive: true });
367
+ await fs.promises.writeFile(file, bytes);
368
+ },
369
+ async remove(ref) {
370
+ await fs.promises.rm(locate(ref), { force: true });
371
+ },
372
+ async *list() {
373
+ }
374
+ };
375
+ };
376
+
377
+ // src/plugins.ts
378
+ var createRegistry = (initial) => {
379
+ const registry = {
380
+ ...initial,
381
+ reporters: [...initial.reporters],
382
+ engines: /* @__PURE__ */ new Map(),
383
+ hooks: {
384
+ beforeCompare: [],
385
+ afterCompare: [],
386
+ decide: [],
387
+ beforeWrite: [],
388
+ end: []
389
+ },
390
+ async setup(plugins) {
391
+ const api = {
392
+ addEngine: (name, engine) => registry.engines.set(name, engine),
393
+ addReporter: (reporter) => registry.reporters.push(reporter),
394
+ setStore: (store) => registry.store = store,
395
+ setLayout: (layout) => registry.layout = layout,
396
+ setMatcher: (matcher) => registry.matcher = matcher,
397
+ on: ((event, fn) => {
398
+ registry.hooks[event].push(fn);
399
+ })
400
+ };
401
+ for (const plugin of plugins) await plugin.setup(api);
402
+ }
403
+ };
404
+ return registry;
405
+ };
406
+
407
+ // src/comparer.ts
408
+ var TOOL_NAME = "visual-regression-compare";
409
+ var packageVersion = () => {
410
+ try {
411
+ const text = fs2.readFileSync(
412
+ new URL("../package.json", import.meta.url),
413
+ "utf8"
414
+ );
415
+ return JSON.parse(text).version;
416
+ } catch {
417
+ return void 0;
418
+ }
419
+ };
420
+ var withDefaults = ({ baseline, actual, diff }) => {
421
+ const stem = baseline.replace(/\.png$/, "");
422
+ return {
423
+ baseline,
424
+ actual: actual ?? `${stem}${IMAGE_SUFFIX2.actual}.png`,
425
+ diff: diff ?? `${stem}${IMAGE_SUFFIX2.diff}.png`
426
+ };
427
+ };
428
+ var DEFAULT_WORKER = new URL("./worker.js", import.meta.url);
429
+ var percent = (ratio) => `${Number((ratio * 100).toPrecision(3))}%`;
430
+ var sizeText = ({ width, height }) => `${width}x${height}`;
431
+ var Comparer = class {
432
+ constructor(options) {
433
+ this.options = options;
434
+ this.projectRoot = options.manifest.writer ? options.manifest.writer.projectRoot : path3.resolve(options.manifest.projectRoot ?? process.cwd());
435
+ this.registry = createRegistry({
436
+ store: options.store,
437
+ layout: options.layout ?? manifestLayout(),
438
+ matcher: options.matcher ?? samePathMatcher(),
439
+ reporters: options.reporters ?? []
440
+ });
441
+ this.store = () => this.registry.store;
442
+ this.layout = () => this.registry.layout;
443
+ this.matcher = () => this.registry.matcher;
444
+ this.ready = this.registry.setup(options.plugins ?? []);
445
+ this.writer = options.manifest.writer ?? this.createWriter(options);
446
+ this.pool = new WorkerPool(
447
+ options.concurrency ?? 1,
448
+ options.workerFile ?? DEFAULT_WORKER
449
+ );
450
+ }
451
+ options;
452
+ writer;
453
+ projectRoot;
454
+ store;
455
+ layout;
456
+ matcher;
457
+ pool;
458
+ registry;
459
+ ready;
460
+ engines = /* @__PURE__ */ new Map();
461
+ closed = false;
462
+ createWriter(options) {
463
+ const manifestPath = options.manifest.path ?? path3.join(this.projectRoot, getManifestFileName("compare"));
464
+ return new ManifestWriter(
465
+ path3.resolve(this.projectRoot, manifestPath),
466
+ {
467
+ projectRoot: this.projectRoot,
468
+ tool: options.manifest.tool ?? {
469
+ name: TOOL_NAME,
470
+ version: packageVersion()
471
+ },
472
+ runner: options.manifest.runner ?? { name: TOOL_NAME },
473
+ options: {
474
+ engine: typeof options.engine === "object" ? options.engine.name : options.engine ?? "pixelmatch",
475
+ engineOptions: options.engineOptions ?? {},
476
+ limits: options.limits ?? {},
477
+ onMissingBaseline: options.onMissingBaseline ?? "fail",
478
+ onSizeMismatch: options.onSizeMismatch ?? "fail",
479
+ update: options.update ?? "none"
480
+ }
481
+ },
482
+ { writeDelayMs: 200 }
483
+ );
484
+ }
485
+ /** The comparer's settings with the overrides of one call applied. */
486
+ settings(call = {}) {
487
+ const o = this.options;
488
+ return {
489
+ limits: call.limits ?? o.limits ?? {},
490
+ engineOptions: { ...o.engineOptions, ...call.engineOptions },
491
+ update: call.update ?? o.update ?? "none",
492
+ onMissingBaseline: call.onMissingBaseline ?? o.onMissingBaseline ?? "fail",
493
+ onSizeMismatch: call.onSizeMismatch ?? o.onSizeMismatch ?? "fail"
494
+ };
495
+ }
496
+ /** 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. */
497
+ resolveEngine(engineOptions) {
498
+ const key = JSON.stringify(engineOptions);
499
+ let engine = this.engines.get(key);
500
+ if (!engine) {
501
+ engine = (async () => {
502
+ await this.ready;
503
+ const { engine: spec = "pixelmatch" } = this.options;
504
+ if (typeof spec === "object") return spec;
505
+ const registered = this.registry.engines.get(spec);
506
+ if (typeof registered === "object") return registered;
507
+ if (typeof registered === "function") return registered(engineOptions);
508
+ return loadEngine(spec, engineOptions);
509
+ })();
510
+ this.engines.set(key, engine);
511
+ }
512
+ return engine;
513
+ }
514
+ async measure(baseline, actual, engine, cfg, include) {
515
+ const { engine: spec = "pixelmatch" } = this.options;
516
+ const inMainThread = typeof spec === "object" || this.registry.engines.has(spec);
517
+ const job = {
518
+ baseline,
519
+ actual,
520
+ onSizeMismatch: cfg.onSizeMismatch,
521
+ include
522
+ };
523
+ if (inMainThread) return runCompare(job, engine);
524
+ return this.pool.run({
525
+ ...job,
526
+ engine: { id: spec, options: cfg.engineOptions, cwd: process.cwd() }
527
+ });
528
+ }
529
+ /** Compares one screenshot, decides, writes the files and records the manifest entry. */
530
+ async compareImage(input) {
531
+ return (await this.compare(input)).entry;
532
+ }
533
+ /**
534
+ * `compareImage` that can also hand back the compared images as PNG bytes,
535
+ * for a runner that shows them (a review UI). `include` names the parts;
536
+ * the diff is encoded anyway, the other two cost nothing when the sizes
537
+ * match.
538
+ */
539
+ async compare(input, { include = [] } = {}) {
540
+ await this.ready;
541
+ const name = sanitizeName(input.name);
542
+ if (input.files) {
543
+ return this.compareAt(
544
+ input,
545
+ name,
546
+ { kind: "baseline", path: "baseline" },
547
+ include
548
+ );
549
+ }
550
+ const baseline = {
551
+ kind: "baseline",
552
+ path: this.layout().paths(name).baseline
553
+ };
554
+ return this.compareAt(input, name, baseline, include);
555
+ }
556
+ compareAt(input, name, baselineRef, include) {
557
+ const store = input.files ? fileStore(withDefaults(input.files)) : this.store();
558
+ const names = input.files ? { actual: "actual", diff: "diff" } : this.layout().paths(name);
559
+ return this.compareWith(input, name, baselineRef, include, store, names);
560
+ }
561
+ async compareWith(input, name, baselineRef, include, store, names) {
562
+ const cfg = this.settings(input.options);
563
+ const started = Date.now();
564
+ const ctx = { name, input };
565
+ for (const fn of this.registry.hooks.beforeCompare) await fn(ctx);
566
+ const engine = await this.resolveEngine(cfg.engineOptions);
567
+ const baselineBytes = await store.read(baselineRef);
568
+ let measurement;
569
+ let diffPng;
570
+ const images = {};
571
+ if (baselineBytes === null) {
572
+ measurement = { kind: "missing-baseline" };
573
+ if (include.includes("actual")) images.actual = input.actual;
574
+ } else {
575
+ let job;
576
+ try {
577
+ job = await this.measure(
578
+ baselineBytes,
579
+ input.actual,
580
+ engine,
581
+ cfg,
582
+ include
583
+ );
584
+ } catch (error) {
585
+ throw new Error(`${name}: ${error.message}`, {
586
+ cause: error
587
+ });
588
+ }
589
+ measurement = job.kind === "size-mismatch" ? job : {
590
+ kind: "compared",
591
+ baselineSize: job.baselineSize,
592
+ actualSize: job.actualSize,
593
+ resized: job.resized,
594
+ result: {
595
+ diffPixels: job.diffPixels,
596
+ diffRatio: job.diffRatio,
597
+ score: job.score
598
+ }
599
+ };
600
+ diffPng = job.kind === "compared" ? job.diffPng : void 0;
601
+ if (job.kind === "compared") {
602
+ if (include.includes("diff") && diffPng) images.diff = diffPng;
603
+ if (job.baselinePng) images.baseline = job.baselinePng;
604
+ if (job.actualPng) images.actual = job.actualPng;
605
+ }
606
+ }
607
+ for (const fn of this.registry.hooks.afterCompare)
608
+ await fn(ctx, measurement);
609
+ const limit = this.limitFor(measurement, cfg);
610
+ let decision = this.decide(measurement, limit, cfg);
611
+ for (const fn of this.registry.hooks.decide) {
612
+ decision = await fn(ctx, decision);
613
+ }
614
+ const refs = {
615
+ actual: { kind: "actual", path: names.actual },
616
+ diff: { kind: "diff", path: names.diff }
617
+ };
618
+ await store.remove(refs.actual);
619
+ await store.remove(refs.diff);
620
+ let plan = { write: [] };
621
+ const status = decision.status;
622
+ if (status === "failed" || status === "missing-baseline") {
623
+ plan.write.push({ ref: refs.actual, bytes: input.actual });
624
+ if (diffPng) plan.write.push({ ref: refs.diff, bytes: diffPng });
625
+ } else if (status === "created" || status === "updated" || status === "approved") {
626
+ plan.write.push({ ref: baselineRef, bytes: input.actual });
627
+ }
628
+ for (const fn of this.registry.hooks.beforeWrite)
629
+ plan = await fn(ctx, plan);
630
+ for (const { ref, bytes } of plan.write) await store.write(ref, bytes);
631
+ const entry = this.record(
632
+ store,
633
+ cfg,
634
+ input,
635
+ name,
636
+ baselineRef,
637
+ refs,
638
+ measurement,
639
+ decision,
640
+ limit,
641
+ engine,
642
+ Date.now() - started
643
+ );
644
+ for (const reporter of this.registry.reporters)
645
+ await reporter.onEntry?.(entry);
646
+ return { entry, images };
647
+ }
648
+ /** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
649
+ limitFor(measurement, cfg) {
650
+ if (measurement.kind !== "compared") return void 0;
651
+ const { baselineSize: b, actualSize: a, resized } = measurement;
652
+ const size = resized === "pad" ? {
653
+ width: Math.max(a.width, b.width),
654
+ height: Math.max(a.height, b.height)
655
+ } : resized === "crop" ? {
656
+ width: Math.min(a.width, b.width),
657
+ height: Math.min(a.height, b.height)
658
+ } : a;
659
+ const { maxDiffRatio, maxDiffPixels } = cfg.limits;
660
+ return maxRatioFrom({ ratio: maxDiffRatio, pixels: maxDiffPixels }, size) ?? 0;
661
+ }
662
+ decide(measurement, limit, { update, onMissingBaseline }) {
663
+ if (measurement.kind === "missing-baseline") {
664
+ return onMissingBaseline === "create" ? {
665
+ status: "created",
666
+ message: "Baseline image was created from the actual screenshot."
667
+ } : {
668
+ status: "missing-baseline",
669
+ message: "There is no baseline image."
670
+ };
671
+ }
672
+ if (measurement.kind === "size-mismatch") {
673
+ const message = `The size changed: baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}.`;
674
+ return update === "none" ? { status: "failed", message } : {
675
+ status: "updated",
676
+ message: `${message} Baseline image was replaced with the actual screenshot.`
677
+ };
678
+ }
679
+ const { diffPixels, diffRatio } = measurement.result;
680
+ const passed = diffRatio <= (limit ?? 0);
681
+ const resized = measurement.resized ? ` Both images were ${measurement.resized === "pad" ? "padded" : "cropped"} to the same size (baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}).` : "";
682
+ const counts = `${diffPixels} pixels (${percent(diffRatio)}) differ`;
683
+ if (passed && !(update === "all" && diffPixels > 0)) {
684
+ return {
685
+ status: "passed",
686
+ message: diffPixels === 0 ? `Identical to the baseline.${resized}` : `${counts}, within the limit of ${percent(limit ?? 0)}.${resized}`
687
+ };
688
+ }
689
+ if (update !== "none") {
690
+ return {
691
+ status: "updated",
692
+ message: `${counts}. Baseline image was replaced with the actual screenshot.${resized}`
693
+ };
694
+ }
695
+ return {
696
+ status: "failed",
697
+ message: `${counts}; the limit is ${percent(limit ?? 0)}.${resized}`
698
+ };
699
+ }
700
+ record(store, cfg, input, name, baselineRef, refs, measurement, decision, limit, engine, duration) {
701
+ const where = (ref) => store.locate(ref) ?? path3.join(this.projectRoot, ref.path);
702
+ const status = decision.status;
703
+ const compared = measurement.kind === "compared" ? measurement : void 0;
704
+ const sized = measurement.kind !== "missing-baseline" ? measurement : void 0;
705
+ const { maxDiffRatio = 0 } = cfg.limits;
706
+ const baselineRoot = path3.relative(
707
+ this.projectRoot,
708
+ input.files ? path3.dirname(input.files.baseline) : where({ kind: "baseline", path: "" })
709
+ );
710
+ const meta = {
711
+ engine: engine.name,
712
+ ...compared && { diffPixels: compared.result.diffPixels },
713
+ ...compared?.result.score && { score: compared.result.score },
714
+ ...compared?.resized && { sizeMismatch: compared.resized },
715
+ ...measurement.kind === "size-mismatch" && {
716
+ sizeMismatch: "fail",
717
+ baselineSize: measurement.baselineSize,
718
+ actualSize: measurement.actualSize
719
+ },
720
+ ...decision.extra
721
+ };
722
+ return this.writer.record({
723
+ actualPath: where(refs.actual),
724
+ baselinePath: where(baselineRef),
725
+ diffPath: where(refs.diff),
726
+ name,
727
+ testFile: input.test?.filePath,
728
+ titlePath: input.test?.titlePath,
729
+ retry: input.test?.retry,
730
+ status,
731
+ diffRatio: measurement.kind === "size-mismatch" ? 1 : compared?.result.diffRatio,
732
+ maxDiffRatio: limit ?? maxDiffRatio,
733
+ colorThreshold: engine.colorThreshold,
734
+ colorMetric: engine.colorMetric,
735
+ baselineWritten: status === "created" || status === "updated" || status === "approved",
736
+ message: decision.message,
737
+ baselineSize: sized?.baselineSize,
738
+ actualSize: sized?.actualSize,
739
+ platform: input.platform ?? this.options.manifest.platform,
740
+ viewport: input.viewport,
741
+ variant: input.variant ?? this.options.manifest.variant,
742
+ extra: { ...input.extra, visualRegressionCompare: meta },
743
+ ...input.entry,
744
+ options: input.entry?.options ?? {
745
+ imagesPath: `${baselineRoot || "."}/{name}.png`,
746
+ maxDiffRatio,
747
+ diffConfig: { engine: engine.name, ...cfg.engineOptions },
748
+ createMissingImages: cfg.onMissingBaseline === "create",
749
+ updateImages: cfg.update === "failures-only" ? "failures-only" : cfg.update === "all"
750
+ },
751
+ duration: input.duration ?? duration
752
+ });
753
+ }
754
+ /** Compares every incoming image of the store against its baseline. */
755
+ async compareAll() {
756
+ await this.ready;
757
+ const refs = [];
758
+ for await (const ref of this.store().list("incoming")) refs.push(ref);
759
+ const queue = [...refs];
760
+ const workers = Math.max(1, this.options.concurrency ?? 1);
761
+ const failures = [];
762
+ await Promise.all(
763
+ Array.from({ length: Math.min(workers, queue.length) }, async () => {
764
+ for (let ref = queue.shift(); ref; ref = queue.shift()) {
765
+ try {
766
+ await this.compareIncoming(ref);
767
+ } catch (error) {
768
+ failures.push(error);
769
+ }
770
+ }
771
+ })
772
+ );
773
+ if (failures.length > 0) {
774
+ throw new AggregateError(
775
+ failures,
776
+ `${failures.length} image${failures.length === 1 ? "" : "s"} could not be compared:
777
+ ${failures.map((f) => ` ${f.message}`).join("\n")}`
778
+ );
779
+ }
780
+ return this.writer.toJSON();
781
+ }
782
+ async compareIncoming(incoming) {
783
+ const baselineRef = this.matcher().baselineFor(incoming);
784
+ if (!baselineRef) return;
785
+ const name = this.layout().nameOf("baseline", baselineRef.path) ?? baselineRef.path.replace(/\.png$/, "");
786
+ const actual = await this.store().read(incoming);
787
+ if (actual === null) return;
788
+ await this.compareAt({ name, actual }, name, baselineRef, []);
789
+ }
790
+ /** Flushes the manifest and ends the reporters. Call once, after the last comparison. */
791
+ async close() {
792
+ await this.ready;
793
+ if (!this.closed) {
794
+ this.closed = true;
795
+ this.writer.write();
796
+ this.writer.flush();
797
+ await this.pool.close();
798
+ for (const fn of this.registry.hooks.end) await fn(this.writer.toJSON());
799
+ for (const reporter of this.registry.reporters) {
800
+ await reporter.onEnd?.(this.writer.toJSON());
801
+ }
802
+ }
803
+ return this.writer.toJSON();
804
+ }
805
+ };
806
+ var createComparer = (options) => new Comparer(options);
807
+
808
+ // src/approve.ts
809
+ import { createHash } from "crypto";
810
+ import fs3 from "fs";
811
+ import path4 from "path";
812
+ import {
813
+ isSafeRelativePath as isSafeRelativePath2,
814
+ mergeManifests,
815
+ projectDirInRepo,
816
+ readManifestFile,
817
+ selectEntries,
818
+ summarize,
819
+ toRepoPath,
820
+ writeManifestFile
821
+ } from "visual-regression-manifest";
822
+ var sha256 = (file) => createHash("sha256").update(fs3.readFileSync(file)).digest("hex");
823
+ var locateProject = (manifest, repoRoot) => {
824
+ if (manifest.ci?.workspace) {
825
+ return { projectDir: projectDirInRepo(manifest), base: repoRoot };
826
+ }
827
+ const relative = path4.relative(repoRoot, manifest.projectRoot).split(path4.sep).join("/");
828
+ if (relative === "") return { projectDir: "", base: repoRoot };
829
+ return isSafeRelativePath2(relative) ? { projectDir: relative, base: repoRoot } : { projectDir: null, base: repoRoot };
830
+ };
831
+ var approve = ({
832
+ manifests,
833
+ repoRoot = process.cwd(),
834
+ names,
835
+ testFile,
836
+ force = false,
837
+ dryRun = false
838
+ }) => {
839
+ const root = path4.resolve(repoRoot);
840
+ const sources = manifests.map((file) => ({
841
+ file,
842
+ label: file,
843
+ manifest: readManifestFile(file)
844
+ }));
845
+ const run = mergeManifests(sources);
846
+ const picked = selectEntries(run, names ? { names } : "all");
847
+ const explicit = names !== void 0;
848
+ const chosen = testFile ? picked.entries.filter((m) => m.entry.test.filePath === testFile) : picked.entries;
849
+ const result = {
850
+ approved: [],
851
+ skipped: [],
852
+ files: [],
853
+ warnings: run.warnings,
854
+ unknown: picked.unknown
855
+ };
856
+ const changed = /* @__PURE__ */ new Set();
857
+ const skip = (m, reason) => result.skipped.push({ entry: m.entry, reason });
858
+ for (const m of chosen) {
859
+ const { entry, source } = m;
860
+ if (m.unapprovableReason) {
861
+ skip(m, `cannot be approved: ${m.unapprovableReason}`);
862
+ continue;
863
+ }
864
+ if (!explicit && m.collidesWith.length > 0) {
865
+ skip(m, "writes the same baseline as another entry; name one to approve");
866
+ continue;
867
+ }
868
+ const { projectDir, base } = locateProject(source.manifest, root);
869
+ if (projectDir === null) {
870
+ skip(
871
+ m,
872
+ `the project root ${source.manifest.projectRoot} is outside the repository root ${root}; run from the repository root or pass --repo-root`
873
+ );
874
+ continue;
875
+ }
876
+ if (!entry.images.actual.path) {
877
+ skip(m, "the run kept no actual image for it");
878
+ continue;
879
+ }
880
+ const actualRepo = toRepoPath(projectDir, entry.images.actual.path);
881
+ const baselineRepo = toRepoPath(projectDir, entry.images.baseline.path);
882
+ if (!actualRepo || !baselineRepo) {
883
+ skip(m, "its paths are outside the repository");
884
+ continue;
885
+ }
886
+ const actual = path4.join(base, actualRepo);
887
+ const baseline = path4.join(base, baselineRepo);
888
+ const diffRepo = toRepoPath(projectDir, entry.images.diff.path);
889
+ if (!fs3.existsSync(actual)) {
890
+ skip(m, "the actual image is gone");
891
+ continue;
892
+ }
893
+ if (!force) {
894
+ if (entry.hashes?.actual && entry.hashes.actual !== sha256(actual)) {
895
+ skip(m, "the actual image changed since the run (stale)");
896
+ continue;
897
+ }
898
+ const baselineNow = fs3.existsSync(baseline) ? sha256(baseline) : void 0;
899
+ const expected = entry.hashes?.baseline;
900
+ const stale = entry.status === "missing-baseline" ? baselineNow !== void 0 : expected !== void 0 && baselineNow !== expected;
901
+ if (stale) {
902
+ skip(m, "the baseline changed since the run (stale)");
903
+ continue;
904
+ }
905
+ }
906
+ if (!dryRun) {
907
+ fs3.mkdirSync(path4.dirname(baseline), { recursive: true });
908
+ fs3.copyFileSync(actual, baseline);
909
+ fs3.rmSync(actual, { force: true });
910
+ if (diffRepo) fs3.rmSync(path4.join(base, diffRepo), { force: true });
911
+ Object.assign(entry, {
912
+ status: "approved",
913
+ images: {
914
+ // the approved screenshot is the baseline now, with its size
915
+ baseline: {
916
+ ...entry.images.baseline,
917
+ width: entry.images.actual.width ?? entry.images.baseline.width,
918
+ height: entry.images.actual.height ?? entry.images.baseline.height
919
+ },
920
+ actual: { path: null },
921
+ diff: { path: null }
922
+ },
923
+ baselineWritten: true,
924
+ recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
925
+ hashes: { baseline: sha256(baseline) },
926
+ message: "Baseline image was replaced with the approved screenshot."
927
+ });
928
+ changed.add(source);
929
+ }
930
+ result.approved.push(entry);
931
+ result.files.push(baselineRepo);
932
+ }
933
+ for (const { file, manifest } of changed) {
934
+ manifest.summary = summarize(manifest.entries);
935
+ manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
936
+ writeManifestFile(file, manifest);
937
+ }
938
+ return result;
939
+ };
940
+
941
+ // src/reporters.ts
942
+ import fs4 from "fs";
943
+ import path5 from "path";
944
+ import {
945
+ needsHuman,
946
+ toCtrf
947
+ } from "visual-regression-manifest";
948
+ var MARK = {
949
+ passed: "\u2713",
950
+ failed: "\u2717",
951
+ "missing-baseline": "?",
952
+ created: "+",
953
+ updated: "~",
954
+ approved: "\u2713"
955
+ };
956
+ var consoleReporter = (log = (line) => process.stdout.write(`${line}
957
+ `)) => ({
958
+ onEntry(entry) {
959
+ const note = entry.status === "passed" ? "" : ` ${entry.status}: ${entry.message}`;
960
+ log(`${MARK[entry.status]} ${entry.name}${note}`);
961
+ },
962
+ onEnd({ summary }) {
963
+ const parts = [
964
+ `${summary.passed} passed`,
965
+ `${summary.failed} failed`,
966
+ `${summary.missingBaseline} without baseline`,
967
+ `${summary.created} created`,
968
+ `${summary.updated} updated`
969
+ ];
970
+ log(`
971
+ ${summary.entries} compared: ${parts.join(", ")}`);
972
+ }
973
+ });
974
+ var ctrfReporter = (file) => ({
975
+ onEnd(manifest) {
976
+ fs4.mkdirSync(path5.dirname(path5.resolve(file)), { recursive: true });
977
+ fs4.writeFileSync(file, JSON.stringify(toCtrf(manifest), null, 2));
978
+ }
979
+ });
980
+ var cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
981
+ var summaryMarkdown = ({ summary, entries }) => {
982
+ const lines = [
983
+ "### Visual regression",
984
+ "",
985
+ `${summary.entries} screenshots: ${summary.passed} passed, ${summary.failed} failed, ${summary.missingBaseline} without baseline, ${summary.created} created, ${summary.updated} updated.`
986
+ ];
987
+ const open = entries.filter((entry) => needsHuman(entry.status));
988
+ if (open.length > 0) {
989
+ lines.push("", "| Screenshot | Status | Details |", "| --- | --- | --- |");
990
+ for (const entry of open) {
991
+ lines.push(
992
+ `| ${cell(entry.name)} | ${entry.status} | ${cell(entry.message)} |`
993
+ );
994
+ }
995
+ }
996
+ return `${lines.join("\n")}
997
+ `;
998
+ };
999
+ var githubSummaryReporter = (file = process.env.GITHUB_STEP_SUMMARY) => ({
1000
+ onEnd(manifest) {
1001
+ if (!file) return;
1002
+ fs4.appendFileSync(file, summaryMarkdown(manifest));
1003
+ }
1004
+ });
1005
+ var REPORTER_NAMES = ["console", "ctrf", "github-summary"];
1006
+ var parseReporters = (spec) => spec.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
1007
+ const colon = part.indexOf(":");
1008
+ const name = colon === -1 ? part : part.slice(0, colon);
1009
+ const arg = colon === -1 ? void 0 : part.slice(colon + 1);
1010
+ switch (name) {
1011
+ case "console":
1012
+ return consoleReporter();
1013
+ case "ctrf":
1014
+ return ctrfReporter(arg ?? "ctrf-report.json");
1015
+ case "github-summary":
1016
+ return githubSummaryReporter(arg);
1017
+ default:
1018
+ throw new Error(
1019
+ `Unknown reporter "${name}" (known: ${REPORTER_NAMES.join(", ")})`
1020
+ );
1021
+ }
1022
+ });
1023
+ export {
1024
+ BUILT_IN_ENGINES,
1025
+ Comparer,
1026
+ TOOL_NAME,
1027
+ approve,
1028
+ consoleReporter,
1029
+ createComparer,
1030
+ ctrfReporter,
1031
+ decodePng,
1032
+ encodePng,
1033
+ fileStore,
1034
+ fsStore,
1035
+ githubSummaryReporter,
1036
+ loadEngine,
1037
+ manifestLayout,
1038
+ parseReporters,
1039
+ pixelmatchEngine,
1040
+ samePathMatcher,
1041
+ sanitizeName,
1042
+ summaryMarkdown,
1043
+ templateMatcher
1044
+ };