visual-regression-compare 1.0.0-beta.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/README.md CHANGED
@@ -141,7 +141,74 @@ await comparer.compareAll(); // every image under actualDir
141
141
  await comparer.close(); // flushes the manifest, ends the reporters
142
142
  ```
143
143
 
144
- Decoding and comparing run in worker threads when the engine is named by id (`engine: 'pixelmatch'`, `engine: './my-engine.mjs'`). An engine passed as an object runs in the main thread, and `concurrency` then only affects `compareAll`'s parallel reads. Engine options must be structured-cloneable.
144
+ Decoding and comparing run in worker threads when the engine is named by id (`engine: 'pixelmatch'`, `engine: './my-engine.mjs'`) and `concurrency` is above 1. The library's default is `1`, which runs everything in the calling thread, because a test runner's own workers already use the CPUs; the command line defaults to the CPU count. An engine passed as an object runs in the main thread, and `concurrency` then only affects `compareAll`'s parallel reads. Engine options must be structured-cloneable.
145
+
146
+ ### Per-call settings
147
+
148
+ A test runner changes its options from one assertion to the next. `compareImage` and `compare` take them on the input, for that screenshot only:
149
+
150
+ ```ts
151
+ await comparer.compareImage({
152
+ name: 'checkout',
153
+ actual: pngBuffer,
154
+ options: {
155
+ limits: { maxDiffRatio: 0.02 }, // replaces the comparer's limits
156
+ engineOptions: { threshold: 0.2, includeAA: true }, // merged over its engine options
157
+ update: 'failures-only',
158
+ onMissingBaseline: 'create',
159
+ onSizeMismatch: 'pad',
160
+ },
161
+ });
162
+ ```
163
+
164
+ The entry records the limit and color threshold the call used.
165
+
166
+ ### Files at known paths
167
+
168
+ A runner that already knows where the baseline, the `.actual` and the `.diff` file live (or that compares against a baseline kept under another name) passes them as absolute paths. They replace the store, layout and matcher for that call; `actual` and `diff` default to `<baseline>.actual.png` and `<baseline>.diff.png`.
169
+
170
+ ```ts
171
+ await comparer.compareImage({
172
+ name: 'checkout',
173
+ actual: pngBuffer,
174
+ files: {
175
+ baseline: '/repo/e2e/__image_snapshots__/checkout.png',
176
+ actual: '/repo/e2e/__image_snapshots__/checkout.actual.png',
177
+ },
178
+ });
179
+ ```
180
+
181
+ ### Getting the images back
182
+
183
+ `compare` returns the entry and, when asked, the PNG bytes of the baseline, the actual and the diff as they were compared (padded or cropped to the same size when the sizes differed). That is what a review UI needs, including for a screenshot that passed.
184
+
185
+ ```ts
186
+ const { entry, images } = await comparer.compare(
187
+ { name: 'checkout', actual: pngBuffer },
188
+ { include: ['baseline', 'actual', 'diff'] },
189
+ );
190
+ // images.diff, images.actual, images.baseline: Uint8Array
191
+ ```
192
+
193
+ ### Your own manifest writer
194
+
195
+ By default the comparer writes its own manifest. A runner that builds the header itself (CI and run metadata, one file per worker) passes its `ManifestWriter`, and states per-entry fields the comparer cannot know (`options`, `renderer`, `attachments`, `baselineExtra`, `target`, `subject`) as `entry` on the input. They are recorded as given.
196
+
197
+ ```ts
198
+ const writer = new ManifestWriter(
199
+ 'out/visual-regression-manifest.worker-0.json',
200
+ {
201
+ projectRoot: process.cwd(),
202
+ runner: { name: 'my-runner' },
203
+ },
204
+ );
205
+ const comparer = createComparer({ store, manifest: { writer } });
206
+ await comparer.compareImage({
207
+ name: 'checkout',
208
+ actual: pngBuffer,
209
+ entry: { renderer: { backend: 'docker', name: 'chromium' } },
210
+ });
211
+ ```
145
212
 
146
213
  ## Extending
147
214
 
package/dist/cli.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  // src/run.ts
4
4
  import fs6 from "fs";
5
+ import os from "os";
5
6
  import path7 from "path";
6
7
 
7
8
  // src/approve.ts
@@ -138,11 +139,11 @@ var approve = ({
138
139
  };
139
140
 
140
141
  // src/comparer.ts
141
- import fs2 from "fs";
142
- import os from "os";
143
- import path3 from "path";
142
+ import fs3 from "fs";
143
+ import path4 from "path";
144
144
  import {
145
145
  ManifestWriter,
146
+ IMAGE_SUFFIX as IMAGE_SUFFIX2,
146
147
  getManifestFileName,
147
148
  maxRatioFrom
148
149
  } from "visual-regression-manifest";
@@ -283,13 +284,18 @@ var runCompare = async (job, engine) => {
283
284
  );
284
285
  }
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
+ } : {};
286
290
  return {
287
291
  kind: "compared",
288
292
  baselineSize,
289
293
  actualSize,
290
294
  ...resized && { resized },
291
295
  ...result,
292
- ...diff && { diffPng: encodePng(diff) }
296
+ ...diff && { diffPng: encodePng(diff) },
297
+ ...compared("baseline", baseline),
298
+ ...compared("actual", actual)
293
299
  };
294
300
  };
295
301
  var runJob = async (job) => runCompare(
@@ -411,6 +417,103 @@ var templateMatcher = (template) => ({
411
417
  }
412
418
  });
413
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
+
414
517
  // src/plugins.ts
415
518
  var createRegistry = (initial) => {
416
519
  const registry = {
@@ -445,7 +548,7 @@ var createRegistry = (initial) => {
445
548
  var TOOL_NAME = "visual-regression-compare";
446
549
  var packageVersion = () => {
447
550
  try {
448
- const text = fs2.readFileSync(
551
+ const text = fs3.readFileSync(
449
552
  new URL("../package.json", import.meta.url),
450
553
  "utf8"
451
554
  );
@@ -454,15 +557,21 @@ var packageVersion = () => {
454
557
  return void 0;
455
558
  }
456
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
+ };
457
568
  var DEFAULT_WORKER = new URL("./worker.js", import.meta.url);
458
569
  var percent = (ratio2) => `${Number((ratio2 * 100).toPrecision(3))}%`;
459
570
  var sizeText = ({ width, height }) => `${width}x${height}`;
460
571
  var Comparer = class {
461
572
  constructor(options) {
462
573
  this.options = options;
463
- this.projectRoot = path3.resolve(
464
- options.manifest.projectRoot ?? process.cwd()
465
- );
574
+ this.projectRoot = options.manifest.writer ? options.manifest.writer.projectRoot : path4.resolve(options.manifest.projectRoot ?? process.cwd());
466
575
  this.registry = createRegistry({
467
576
  store: options.store,
468
577
  layout: options.layout ?? manifestLayout(),
@@ -473,9 +582,27 @@ var Comparer = class {
473
582
  this.layout = () => this.registry.layout;
474
583
  this.matcher = () => this.registry.matcher;
475
584
  this.ready = this.registry.setup(options.plugins ?? []);
476
- const manifestPath = options.manifest.path ?? path3.join(this.projectRoot, getManifestFileName("compare"));
477
- this.writer = new ManifestWriter(
478
- path3.resolve(this.projectRoot, manifestPath),
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),
479
606
  {
480
607
  projectRoot: this.projectRoot,
481
608
  tool: options.manifest.tool ?? {
@@ -494,73 +621,106 @@ var Comparer = class {
494
621
  },
495
622
  { writeDelayMs: 200 }
496
623
  );
497
- this.pool = new WorkerPool(
498
- options.concurrency ?? os.cpus().length,
499
- options.workerFile ?? DEFAULT_WORKER
500
- );
501
624
  }
502
- options;
503
- writer;
504
- projectRoot;
505
- store;
506
- layout;
507
- matcher;
508
- pool;
509
- registry;
510
- ready;
511
- engine;
512
- closed = false;
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
+ }
513
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. */
514
- resolveEngine() {
515
- this.engine ??= (async () => {
516
- await this.ready;
517
- const { engine = "pixelmatch", engineOptions = {} } = this.options;
518
- if (typeof engine === "object") return engine;
519
- const registered = this.registry.engines.get(engine);
520
- if (typeof registered === "object") return registered;
521
- if (typeof registered === "function") return registered(engineOptions);
522
- return loadEngine(engine, engineOptions);
523
- })();
524
- return this.engine;
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;
525
653
  }
526
- async measure(baseline, actual, engine) {
527
- const { engine: spec = "pixelmatch", engineOptions } = this.options;
654
+ async measure(baseline, actual, engine, cfg, include) {
655
+ const { engine: spec = "pixelmatch" } = this.options;
528
656
  const inMainThread = typeof spec === "object" || this.registry.engines.has(spec);
529
- const onSizeMismatch = this.options.onSizeMismatch ?? "fail";
530
- if (inMainThread) {
531
- return runCompare({ baseline, actual, onSizeMismatch }, engine);
532
- }
533
- return this.pool.run({
657
+ const job = {
534
658
  baseline,
535
659
  actual,
536
- onSizeMismatch,
537
- engine: { id: spec, options: engineOptions, cwd: process.cwd() }
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() }
538
667
  });
539
668
  }
540
669
  /** Compares one screenshot, decides, writes the files and records the manifest entry. */
541
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 = [] } = {}) {
542
680
  await this.ready;
543
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
+ }
544
690
  const baseline = {
545
691
  kind: "baseline",
546
692
  path: this.layout().paths(name).baseline
547
693
  };
548
- return this.compareAt(input, name, baseline);
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);
549
700
  }
550
- async compareAt(input, name, baselineRef) {
701
+ async compareWith(input, name, baselineRef, include, store, names) {
702
+ const cfg = this.settings(input.options);
551
703
  const started = Date.now();
552
704
  const ctx = { name, input };
553
705
  for (const fn of this.registry.hooks.beforeCompare) await fn(ctx);
554
- const engine = await this.resolveEngine();
555
- const baselineBytes = await this.store().read(baselineRef);
706
+ const engine = await this.resolveEngine(cfg.engineOptions);
707
+ const baselineBytes = await store.read(baselineRef);
556
708
  let measurement;
557
709
  let diffPng;
710
+ const images = {};
558
711
  if (baselineBytes === null) {
559
712
  measurement = { kind: "missing-baseline" };
713
+ if (include.includes("actual")) images.actual = input.actual;
560
714
  } else {
561
715
  let job;
562
716
  try {
563
- job = await this.measure(baselineBytes, input.actual, engine);
717
+ job = await this.measure(
718
+ baselineBytes,
719
+ input.actual,
720
+ engine,
721
+ cfg,
722
+ include
723
+ );
564
724
  } catch (error) {
565
725
  throw new Error(`${name}: ${error.message}`, {
566
726
  cause: error
@@ -578,21 +738,25 @@ var Comparer = class {
578
738
  }
579
739
  };
580
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
+ }
581
746
  }
582
747
  for (const fn of this.registry.hooks.afterCompare)
583
748
  await fn(ctx, measurement);
584
- const limit = this.limitFor(measurement);
585
- let decision = this.decide(measurement, limit);
749
+ const limit = this.limitFor(measurement, cfg);
750
+ let decision = this.decide(measurement, limit, cfg);
586
751
  for (const fn of this.registry.hooks.decide) {
587
752
  decision = await fn(ctx, decision);
588
753
  }
589
- const names = this.layout().paths(name);
590
754
  const refs = {
591
755
  actual: { kind: "actual", path: names.actual },
592
756
  diff: { kind: "diff", path: names.diff }
593
757
  };
594
- await this.store().remove(refs.actual);
595
- await this.store().remove(refs.diff);
758
+ await store.remove(refs.actual);
759
+ await store.remove(refs.diff);
596
760
  let plan = { write: [] };
597
761
  const status = decision.status;
598
762
  if (status === "failed" || status === "missing-baseline") {
@@ -603,9 +767,10 @@ var Comparer = class {
603
767
  }
604
768
  for (const fn of this.registry.hooks.beforeWrite)
605
769
  plan = await fn(ctx, plan);
606
- for (const { ref, bytes } of plan.write)
607
- await this.store().write(ref, bytes);
770
+ for (const { ref, bytes } of plan.write) await store.write(ref, bytes);
608
771
  const entry = this.record(
772
+ store,
773
+ cfg,
609
774
  input,
610
775
  name,
611
776
  baselineRef,
@@ -618,10 +783,10 @@ var Comparer = class {
618
783
  );
619
784
  for (const reporter of this.registry.reporters)
620
785
  await reporter.onEntry?.(entry);
621
- return entry;
786
+ return { entry, images };
622
787
  }
623
788
  /** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
624
- limitFor(measurement) {
789
+ limitFor(measurement, cfg) {
625
790
  if (measurement.kind !== "compared") return void 0;
626
791
  const { baselineSize: b, actualSize: a, resized } = measurement;
627
792
  const size = resized === "pad" ? {
@@ -631,11 +796,10 @@ var Comparer = class {
631
796
  width: Math.min(a.width, b.width),
632
797
  height: Math.min(a.height, b.height)
633
798
  } : a;
634
- const { maxDiffRatio, maxDiffPixels } = this.options.limits ?? {};
799
+ const { maxDiffRatio, maxDiffPixels } = cfg.limits;
635
800
  return maxRatioFrom({ ratio: maxDiffRatio, pixels: maxDiffPixels }, size) ?? 0;
636
801
  }
637
- decide(measurement, limit) {
638
- const { update = "none", onMissingBaseline = "fail" } = this.options;
802
+ decide(measurement, limit, { update, onMissingBaseline }) {
639
803
  if (measurement.kind === "missing-baseline") {
640
804
  return onMissingBaseline === "create" ? {
641
805
  status: "created",
@@ -673,16 +837,15 @@ var Comparer = class {
673
837
  message: `${counts}; the limit is ${percent(limit ?? 0)}.${resized}`
674
838
  };
675
839
  }
676
- record(input, name, baselineRef, refs, measurement, decision, limit, engine, duration) {
677
- const store = this.store();
678
- const where = (ref) => store.locate(ref) ?? path3.join(this.projectRoot, ref.path);
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);
679
842
  const status = decision.status;
680
843
  const compared = measurement.kind === "compared" ? measurement : void 0;
681
844
  const sized = measurement.kind !== "missing-baseline" ? measurement : void 0;
682
- const { maxDiffRatio = 0 } = this.options.limits ?? {};
683
- const baselineRoot = path3.relative(
845
+ const { maxDiffRatio = 0 } = cfg.limits;
846
+ const baselineRoot = path4.relative(
684
847
  this.projectRoot,
685
- where({ kind: "baseline", path: "" })
848
+ input.files ? path4.dirname(input.files.baseline) : where({ kind: "baseline", path: "" })
686
849
  );
687
850
  const meta = {
688
851
  engine: engine.name,
@@ -717,12 +880,13 @@ var Comparer = class {
717
880
  viewport: input.viewport,
718
881
  variant: input.variant ?? this.options.manifest.variant,
719
882
  extra: { ...input.extra, visualRegressionCompare: meta },
720
- options: {
883
+ ...input.entry,
884
+ options: input.entry?.options ?? {
721
885
  imagesPath: `${baselineRoot || "."}/{name}.png`,
722
886
  maxDiffRatio,
723
- diffConfig: { engine: engine.name, ...this.options.engineOptions },
724
- createMissingImages: this.options.onMissingBaseline === "create",
725
- updateImages: this.options.update === "failures-only" ? "failures-only" : this.options.update === "all"
887
+ diffConfig: { engine: engine.name, ...cfg.engineOptions },
888
+ createMissingImages: cfg.onMissingBaseline === "create",
889
+ updateImages: cfg.update === "failures-only" ? "failures-only" : cfg.update === "all"
726
890
  },
727
891
  duration: input.duration ?? duration
728
892
  });
@@ -733,7 +897,7 @@ var Comparer = class {
733
897
  const refs = [];
734
898
  for await (const ref of this.store().list("incoming")) refs.push(ref);
735
899
  const queue = [...refs];
736
- const workers = Math.max(1, this.options.concurrency ?? os.cpus().length);
900
+ const workers = Math.max(1, this.options.concurrency ?? 1);
737
901
  const failures = [];
738
902
  await Promise.all(
739
903
  Array.from({ length: Math.min(workers, queue.length) }, async () => {
@@ -761,7 +925,7 @@ ${failures.map((f) => ` ${f.message}`).join("\n")}`
761
925
  const name = this.layout().nameOf("baseline", baselineRef.path) ?? baselineRef.path.replace(/\.png$/, "");
762
926
  const actual = await this.store().read(incoming);
763
927
  if (actual === null) return;
764
- await this.compareAt({ name, actual }, name, baselineRef);
928
+ await this.compareAt({ name, actual }, name, baselineRef, []);
765
929
  }
766
930
  /** Flushes the manifest and ends the reporters. Call once, after the last comparison. */
767
931
  async close() {
@@ -782,8 +946,8 @@ ${failures.map((f) => ` ${f.message}`).join("\n")}`
782
946
  var createComparer = (options) => new Comparer(options);
783
947
 
784
948
  // src/config.ts
785
- import fs3 from "fs";
786
- import path4 from "path";
949
+ import fs4 from "fs";
950
+ import path5 from "path";
787
951
  import { pathToFileURL as pathToFileURL2 } from "url";
788
952
  var CANDIDATES = [
789
953
  "visual-regression.config.json",
@@ -791,13 +955,13 @@ var CANDIDATES = [
791
955
  "visual-regression.config.js"
792
956
  ];
793
957
  var loadConfig = async (explicit, cwd = process.cwd()) => {
794
- const file = explicit ? path4.resolve(cwd, explicit) : CANDIDATES.map((name) => path4.join(cwd, name)).find(
795
- (f) => fs3.existsSync(f)
958
+ const file = explicit ? path5.resolve(cwd, explicit) : CANDIDATES.map((name) => path5.join(cwd, name)).find(
959
+ (f) => fs4.existsSync(f)
796
960
  );
797
961
  if (!file) return {};
798
- if (!fs3.existsSync(file))
962
+ if (!fs4.existsSync(file))
799
963
  throw new Error(`Config file ${file} does not exist`);
800
- if (file.endsWith(".json")) return JSON.parse(fs3.readFileSync(file, "utf8"));
964
+ if (file.endsWith(".json")) return JSON.parse(fs4.readFileSync(file, "utf8"));
801
965
  const loaded = await import(pathToFileURL2(file).href);
802
966
  return loaded.default ?? {};
803
967
  };
@@ -810,74 +974,6 @@ var configArg = (argv) => {
810
974
  return arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[at + 1];
811
975
  };
812
976
 
813
- // src/store.ts
814
- import fs4 from "fs";
815
- import path5 from "path";
816
- import { isSafeRelativePath as isSafeRelativePath2 } from "visual-regression-manifest";
817
- var GENERATED = /\.(actual|diff)\.png$/;
818
- var fsStore = ({
819
- baselineDir,
820
- actualDir,
821
- diffDir
822
- }) => {
823
- const roots = {
824
- baseline: baselineDir,
825
- incoming: actualDir,
826
- actual: diffDir ?? baselineDir,
827
- diff: diffDir ?? baselineDir
828
- };
829
- const rootOf = (kind) => {
830
- const root = roots[kind];
831
- if (!root) throw new Error(`The store has no directory for ${kind} images`);
832
- return path5.resolve(root);
833
- };
834
- const locate = ({ kind, path: relative }) => {
835
- if (relative !== "" && !isSafeRelativePath2(relative)) {
836
- throw new Error(`Unsafe image path ${JSON.stringify(relative)}`);
837
- }
838
- return path5.join(rootOf(kind), relative);
839
- };
840
- return {
841
- locate,
842
- async read(ref) {
843
- try {
844
- return await fs4.promises.readFile(locate(ref));
845
- } catch (error) {
846
- if (error.code === "ENOENT") return null;
847
- throw error;
848
- }
849
- },
850
- async write(ref, bytes) {
851
- const file = locate(ref);
852
- await fs4.promises.mkdir(path5.dirname(file), { recursive: true });
853
- await fs4.promises.writeFile(file, bytes);
854
- },
855
- async remove(ref) {
856
- await fs4.promises.rm(locate(ref), { force: true });
857
- },
858
- async *list(kind) {
859
- const root = rootOf(kind);
860
- const found = [];
861
- const walk = async (dir, prefix) => {
862
- for (const dirent of await fs4.promises.readdir(dir, {
863
- withFileTypes: true
864
- })) {
865
- const relative = prefix ? `${prefix}/${dirent.name}` : dirent.name;
866
- if (dirent.isDirectory()) {
867
- if (dirent.name !== "node_modules" && dirent.name !== ".git") {
868
- await walk(path5.join(dir, dirent.name), relative);
869
- }
870
- } else if (dirent.isFile() && dirent.name.endsWith(".png") && !GENERATED.test(dirent.name)) {
871
- found.push(relative);
872
- }
873
- }
874
- };
875
- await walk(root, "");
876
- for (const relative of found.sort()) yield { kind, path: relative };
877
- }
878
- };
879
- };
880
-
881
977
  // src/options.ts
882
978
  import { parseArgs } from "util";
883
979
  import {
@@ -1217,7 +1313,7 @@ var compare = async (options, version, out) => {
1217
1313
  variant: options.variant
1218
1314
  },
1219
1315
  reporters: parseReporters(options.report),
1220
- concurrency: options.concurrency
1316
+ concurrency: options.concurrency ?? os.cpus().length
1221
1317
  });
1222
1318
  try {
1223
1319
  await comparer.compareAll();