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