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.
@@ -1,5 +1,5 @@
1
1
  import { ManifestWriter, type Manifest, type ManifestEntry, type ManifestHeaderInput } from 'visual-regression-manifest';
2
- import type { CompareInput, DiffEngine, EngineOptions, Layout, Limits, Matcher, OnMissingBaseline, Plugin, Reporter, SizeMismatch, Store, UpdatePolicy } from './types';
2
+ import type { CompareInput, CompareResult, DiffEngine, EngineOptions, ImagePart, Layout, Limits, Matcher, OnMissingBaseline, Plugin, Reporter, SizeMismatch, Store, UpdatePolicy } from './types';
3
3
  export declare const TOOL_NAME = "visual-regression-compare";
4
4
  export type ComparerOptions = {
5
5
  /**
@@ -21,6 +21,12 @@ export type ComparerOptions = {
21
21
  layout?: Layout;
22
22
  matcher?: Matcher;
23
23
  manifest: {
24
+ /**
25
+ * A manifest writer the caller owns, for a runner that builds the header
26
+ * (CI, run and reproduction metadata, one file per worker) itself. The
27
+ * other keys of this object are then ignored.
28
+ */
29
+ writer?: ManifestWriter;
24
30
  /** Where the manifest is written; default `visual-regression-manifest.compare.json` in the project root. */
25
31
  path?: string;
26
32
  /** Absolute path every manifest path is relative to; default the working directory. */
@@ -33,7 +39,12 @@ export type ComparerOptions = {
33
39
  };
34
40
  plugins?: Plugin[];
35
41
  reporters?: Reporter[];
36
- /** Worker threads for decoding and comparing; default the CPU count. */
42
+ /**
43
+ * Worker threads for decoding and comparing, and parallel reads in
44
+ * `compareAll`. Default `1`: everything runs in the calling thread, which is
45
+ * what a runner adapter wants (its own workers already use the CPUs). The
46
+ * command line passes the CPU count.
47
+ */
37
48
  concurrency?: number;
38
49
  /** The built worker module; found next to this file in the published package. */
39
50
  workerFile?: URL | string;
@@ -48,15 +59,28 @@ export declare class Comparer {
48
59
  private readonly pool;
49
60
  private readonly registry;
50
61
  private readonly ready;
51
- private engine;
62
+ private readonly engines;
52
63
  private closed;
53
64
  constructor(options: ComparerOptions);
65
+ private createWriter;
66
+ /** The comparer's settings with the overrides of one call applied. */
67
+ private settings;
54
68
  /** 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. */
55
69
  private resolveEngine;
56
70
  private measure;
57
71
  /** Compares one screenshot, decides, writes the files and records the manifest entry. */
58
72
  compareImage(input: CompareInput): Promise<ManifestEntry>;
73
+ /**
74
+ * `compareImage` that can also hand back the compared images as PNG bytes,
75
+ * for a runner that shows them (a review UI). `include` names the parts;
76
+ * the diff is encoded anyway, the other two cost nothing when the sizes
77
+ * match.
78
+ */
79
+ compare(input: CompareInput, { include }?: {
80
+ include?: ImagePart[];
81
+ }): Promise<CompareResult>;
59
82
  private compareAt;
83
+ private compareWith;
60
84
  /** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
61
85
  private limitFor;
62
86
  private decide;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { Comparer, TOOL_NAME, createComparer, type ComparerOptions, } from './comparer';
2
2
  export { loadEngine, pixelmatchEngine, BUILT_IN_ENGINES, type PixelmatchOptions, } from './engines';
3
- export { fsStore, type FsStoreOptions } from './store';
3
+ export { fileStore, fsStore, type FsStoreOptions } from './store';
4
4
  export { manifestLayout, samePathMatcher, sanitizeName, templateMatcher, } from './layout';
5
5
  export { approve, type ApproveOptions, type ApproveResult, type Skipped, } from './approve';
6
6
  export { consoleReporter, ctrfReporter, githubSummaryReporter, parseReporters, summaryMarkdown, } from './reporters';
package/dist/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  // src/comparer.ts
2
- import fs from "fs";
3
- import os from "os";
4
- import path2 from "path";
2
+ import fs2 from "fs";
3
+ import path3 from "path";
5
4
  import {
6
5
  ManifestWriter,
6
+ IMAGE_SUFFIX as IMAGE_SUFFIX2,
7
7
  getManifestFileName,
8
8
  maxRatioFrom
9
9
  } from "visual-regression-manifest";
@@ -144,13 +144,18 @@ var runCompare = async (job, engine) => {
144
144
  );
145
145
  }
146
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
+ } : {};
147
150
  return {
148
151
  kind: "compared",
149
152
  baselineSize,
150
153
  actualSize,
151
154
  ...resized && { resized },
152
155
  ...result,
153
- ...diff && { diffPng: encodePng(diff) }
156
+ ...diff && { diffPng: encodePng(diff) },
157
+ ...compared("baseline", baseline),
158
+ ...compared("actual", actual)
154
159
  };
155
160
  };
156
161
  var runJob = async (job) => runCompare(
@@ -272,6 +277,103 @@ var templateMatcher = (template) => ({
272
277
  }
273
278
  });
274
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
+
275
377
  // src/plugins.ts
276
378
  var createRegistry = (initial) => {
277
379
  const registry = {
@@ -306,7 +408,7 @@ var createRegistry = (initial) => {
306
408
  var TOOL_NAME = "visual-regression-compare";
307
409
  var packageVersion = () => {
308
410
  try {
309
- const text = fs.readFileSync(
411
+ const text = fs2.readFileSync(
310
412
  new URL("../package.json", import.meta.url),
311
413
  "utf8"
312
414
  );
@@ -315,15 +417,21 @@ var packageVersion = () => {
315
417
  return void 0;
316
418
  }
317
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
+ };
318
428
  var DEFAULT_WORKER = new URL("./worker.js", import.meta.url);
319
429
  var percent = (ratio) => `${Number((ratio * 100).toPrecision(3))}%`;
320
430
  var sizeText = ({ width, height }) => `${width}x${height}`;
321
431
  var Comparer = class {
322
432
  constructor(options) {
323
433
  this.options = options;
324
- this.projectRoot = path2.resolve(
325
- options.manifest.projectRoot ?? process.cwd()
326
- );
434
+ this.projectRoot = options.manifest.writer ? options.manifest.writer.projectRoot : path3.resolve(options.manifest.projectRoot ?? process.cwd());
327
435
  this.registry = createRegistry({
328
436
  store: options.store,
329
437
  layout: options.layout ?? manifestLayout(),
@@ -334,9 +442,27 @@ var Comparer = class {
334
442
  this.layout = () => this.registry.layout;
335
443
  this.matcher = () => this.registry.matcher;
336
444
  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),
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),
340
466
  {
341
467
  projectRoot: this.projectRoot,
342
468
  tool: options.manifest.tool ?? {
@@ -355,73 +481,106 @@ var Comparer = class {
355
481
  },
356
482
  { writeDelayMs: 200 }
357
483
  );
358
- this.pool = new WorkerPool(
359
- options.concurrency ?? os.cpus().length,
360
- options.workerFile ?? DEFAULT_WORKER
361
- );
362
484
  }
363
- options;
364
- writer;
365
- projectRoot;
366
- store;
367
- layout;
368
- matcher;
369
- pool;
370
- registry;
371
- ready;
372
- engine;
373
- closed = false;
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
+ }
374
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. */
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);
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);
393
511
  }
394
- return this.pool.run({
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 = {
395
518
  baseline,
396
519
  actual,
397
- onSizeMismatch,
398
- engine: { id: spec, options: engineOptions, cwd: process.cwd() }
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() }
399
527
  });
400
528
  }
401
529
  /** Compares one screenshot, decides, writes the files and records the manifest entry. */
402
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 = [] } = {}) {
403
540
  await this.ready;
404
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
+ }
405
550
  const baseline = {
406
551
  kind: "baseline",
407
552
  path: this.layout().paths(name).baseline
408
553
  };
409
- return this.compareAt(input, name, baseline);
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);
410
560
  }
411
- async compareAt(input, name, baselineRef) {
561
+ async compareWith(input, name, baselineRef, include, store, names) {
562
+ const cfg = this.settings(input.options);
412
563
  const started = Date.now();
413
564
  const ctx = { name, input };
414
565
  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);
566
+ const engine = await this.resolveEngine(cfg.engineOptions);
567
+ const baselineBytes = await store.read(baselineRef);
417
568
  let measurement;
418
569
  let diffPng;
570
+ const images = {};
419
571
  if (baselineBytes === null) {
420
572
  measurement = { kind: "missing-baseline" };
573
+ if (include.includes("actual")) images.actual = input.actual;
421
574
  } else {
422
575
  let job;
423
576
  try {
424
- job = await this.measure(baselineBytes, input.actual, engine);
577
+ job = await this.measure(
578
+ baselineBytes,
579
+ input.actual,
580
+ engine,
581
+ cfg,
582
+ include
583
+ );
425
584
  } catch (error) {
426
585
  throw new Error(`${name}: ${error.message}`, {
427
586
  cause: error
@@ -439,21 +598,25 @@ var Comparer = class {
439
598
  }
440
599
  };
441
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
+ }
442
606
  }
443
607
  for (const fn of this.registry.hooks.afterCompare)
444
608
  await fn(ctx, measurement);
445
- const limit = this.limitFor(measurement);
446
- let decision = this.decide(measurement, limit);
609
+ const limit = this.limitFor(measurement, cfg);
610
+ let decision = this.decide(measurement, limit, cfg);
447
611
  for (const fn of this.registry.hooks.decide) {
448
612
  decision = await fn(ctx, decision);
449
613
  }
450
- const names = this.layout().paths(name);
451
614
  const refs = {
452
615
  actual: { kind: "actual", path: names.actual },
453
616
  diff: { kind: "diff", path: names.diff }
454
617
  };
455
- await this.store().remove(refs.actual);
456
- await this.store().remove(refs.diff);
618
+ await store.remove(refs.actual);
619
+ await store.remove(refs.diff);
457
620
  let plan = { write: [] };
458
621
  const status = decision.status;
459
622
  if (status === "failed" || status === "missing-baseline") {
@@ -464,9 +627,10 @@ var Comparer = class {
464
627
  }
465
628
  for (const fn of this.registry.hooks.beforeWrite)
466
629
  plan = await fn(ctx, plan);
467
- for (const { ref, bytes } of plan.write)
468
- await this.store().write(ref, bytes);
630
+ for (const { ref, bytes } of plan.write) await store.write(ref, bytes);
469
631
  const entry = this.record(
632
+ store,
633
+ cfg,
470
634
  input,
471
635
  name,
472
636
  baselineRef,
@@ -479,10 +643,10 @@ var Comparer = class {
479
643
  );
480
644
  for (const reporter of this.registry.reporters)
481
645
  await reporter.onEntry?.(entry);
482
- return entry;
646
+ return { entry, images };
483
647
  }
484
648
  /** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
485
- limitFor(measurement) {
649
+ limitFor(measurement, cfg) {
486
650
  if (measurement.kind !== "compared") return void 0;
487
651
  const { baselineSize: b, actualSize: a, resized } = measurement;
488
652
  const size = resized === "pad" ? {
@@ -492,11 +656,10 @@ var Comparer = class {
492
656
  width: Math.min(a.width, b.width),
493
657
  height: Math.min(a.height, b.height)
494
658
  } : a;
495
- const { maxDiffRatio, maxDiffPixels } = this.options.limits ?? {};
659
+ const { maxDiffRatio, maxDiffPixels } = cfg.limits;
496
660
  return maxRatioFrom({ ratio: maxDiffRatio, pixels: maxDiffPixels }, size) ?? 0;
497
661
  }
498
- decide(measurement, limit) {
499
- const { update = "none", onMissingBaseline = "fail" } = this.options;
662
+ decide(measurement, limit, { update, onMissingBaseline }) {
500
663
  if (measurement.kind === "missing-baseline") {
501
664
  return onMissingBaseline === "create" ? {
502
665
  status: "created",
@@ -534,16 +697,15 @@ var Comparer = class {
534
697
  message: `${counts}; the limit is ${percent(limit ?? 0)}.${resized}`
535
698
  };
536
699
  }
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);
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);
540
702
  const status = decision.status;
541
703
  const compared = measurement.kind === "compared" ? measurement : void 0;
542
704
  const sized = measurement.kind !== "missing-baseline" ? measurement : void 0;
543
- const { maxDiffRatio = 0 } = this.options.limits ?? {};
544
- const baselineRoot = path2.relative(
705
+ const { maxDiffRatio = 0 } = cfg.limits;
706
+ const baselineRoot = path3.relative(
545
707
  this.projectRoot,
546
- where({ kind: "baseline", path: "" })
708
+ input.files ? path3.dirname(input.files.baseline) : where({ kind: "baseline", path: "" })
547
709
  );
548
710
  const meta = {
549
711
  engine: engine.name,
@@ -578,12 +740,13 @@ var Comparer = class {
578
740
  viewport: input.viewport,
579
741
  variant: input.variant ?? this.options.manifest.variant,
580
742
  extra: { ...input.extra, visualRegressionCompare: meta },
581
- options: {
743
+ ...input.entry,
744
+ options: input.entry?.options ?? {
582
745
  imagesPath: `${baselineRoot || "."}/{name}.png`,
583
746
  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"
747
+ diffConfig: { engine: engine.name, ...cfg.engineOptions },
748
+ createMissingImages: cfg.onMissingBaseline === "create",
749
+ updateImages: cfg.update === "failures-only" ? "failures-only" : cfg.update === "all"
587
750
  },
588
751
  duration: input.duration ?? duration
589
752
  });
@@ -594,7 +757,7 @@ var Comparer = class {
594
757
  const refs = [];
595
758
  for await (const ref of this.store().list("incoming")) refs.push(ref);
596
759
  const queue = [...refs];
597
- const workers = Math.max(1, this.options.concurrency ?? os.cpus().length);
760
+ const workers = Math.max(1, this.options.concurrency ?? 1);
598
761
  const failures = [];
599
762
  await Promise.all(
600
763
  Array.from({ length: Math.min(workers, queue.length) }, async () => {
@@ -622,7 +785,7 @@ ${failures.map((f) => ` ${f.message}`).join("\n")}`
622
785
  const name = this.layout().nameOf("baseline", baselineRef.path) ?? baselineRef.path.replace(/\.png$/, "");
623
786
  const actual = await this.store().read(incoming);
624
787
  if (actual === null) return;
625
- await this.compareAt({ name, actual }, name, baselineRef);
788
+ await this.compareAt({ name, actual }, name, baselineRef, []);
626
789
  }
627
790
  /** Flushes the manifest and ends the reporters. Call once, after the last comparison. */
628
791
  async close() {
@@ -642,74 +805,6 @@ ${failures.map((f) => ` ${f.message}`).join("\n")}`
642
805
  };
643
806
  var createComparer = (options) => new Comparer(options);
644
807
 
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
808
  // src/approve.ts
714
809
  import { createHash } from "crypto";
715
810
  import fs3 from "fs";
@@ -935,6 +1030,7 @@ export {
935
1030
  ctrfReporter,
936
1031
  decodePng,
937
1032
  encodePng,
1033
+ fileStore,
938
1034
  fsStore,
939
1035
  githubSummaryReporter,
940
1036
  loadEngine,
package/dist/job.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { DiffEngine, EngineOptions, EngineResult, SizeMismatch } from './types';
1
+ import type { DiffEngine, EngineOptions, EngineResult, ImagePart, SizeMismatch } from './types';
2
2
  /** The CPU-bound part of one comparison: plain data in, plain data out, so it can run in a worker. */
3
3
  export type CompareJob = {
4
4
  baseline: Uint8Array;
5
5
  actual: Uint8Array;
6
6
  onSizeMismatch: SizeMismatch;
7
+ /** Images to hand back besides the measurement (`diff` is always encoded; this decides whether it is sent). */
8
+ include?: ImagePart[];
7
9
  /** An engine id (see `loadEngine`), so a worker can load it itself. */
8
10
  engine: {
9
11
  id: string;
@@ -24,13 +26,16 @@ export type JobResult = {
24
26
  diffRatio: number;
25
27
  /** The diff image, already encoded. */
26
28
  diffPng?: Uint8Array;
29
+ /** The compared baseline and actual, encoded, when the job asked for them. */
30
+ baselinePng?: Uint8Array;
31
+ actualPng?: Uint8Array;
27
32
  score?: EngineResult['score'];
28
33
  } | {
29
34
  kind: 'size-mismatch';
30
35
  baselineSize: Size;
31
36
  actualSize: Size;
32
37
  };
33
- export declare const runCompare: (job: Pick<CompareJob, "baseline" | "actual" | "onSizeMismatch">, engine: DiffEngine) => Promise<JobResult>;
38
+ export declare const runCompare: (job: Pick<CompareJob, "baseline" | "actual" | "onSizeMismatch" | "include">, engine: DiffEngine) => Promise<JobResult>;
34
39
  /** Runs a job in this thread (what a worker does for each message). */
35
40
  export declare const runJob: (job: CompareJob) => Promise<JobResult>;
36
41
  export {};
package/dist/store.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Store } from './types';
1
+ import type { CompareFiles, Store } from './types';
2
2
  export type FsStoreOptions = {
3
3
  baselineDir: string;
4
4
  /** Where the screenshots to compare are; only the directory call needs it. */
@@ -7,3 +7,8 @@ export type FsStoreOptions = {
7
7
  diffDir?: string;
8
8
  };
9
9
  export declare const fsStore: ({ baselineDir, actualDir, diffDir, }: FsStoreOptions) => Store;
10
+ /**
11
+ * A store for one screenshot whose files are given as absolute paths (see
12
+ * `CompareFiles`). Every ref of a kind is that kind's file, whatever its path.
13
+ */
14
+ export declare const fileStore: (files: Required<CompareFiles>) => Store;