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 +68 -1
- package/dist/cli.js +246 -150
- package/dist/comparer.d.ts +27 -3
- package/dist/index.d.ts +1 -1
- package/dist/index.js +240 -144
- package/dist/job.d.ts +7 -2
- package/dist/store.d.ts +6 -1
- package/dist/types.d.ts +40 -1
- package/dist/worker.js +6 -1
- package/package.json +1 -1
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
|
|
142
|
-
import
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
477
|
-
this.
|
|
478
|
-
|
|
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
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
595
|
-
await
|
|
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 } =
|
|
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
|
|
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 } =
|
|
683
|
-
const baselineRoot =
|
|
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
|
-
|
|
883
|
+
...input.entry,
|
|
884
|
+
options: input.entry?.options ?? {
|
|
721
885
|
imagesPath: `${baselineRoot || "."}/{name}.png`,
|
|
722
886
|
maxDiffRatio,
|
|
723
|
-
diffConfig: { engine: engine.name, ...
|
|
724
|
-
createMissingImages:
|
|
725
|
-
updateImages:
|
|
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 ??
|
|
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
|
|
786
|
-
import
|
|
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 ?
|
|
795
|
-
(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 (!
|
|
962
|
+
if (!fs4.existsSync(file))
|
|
799
963
|
throw new Error(`Config file ${file} does not exist`);
|
|
800
|
-
if (file.endsWith(".json")) return JSON.parse(
|
|
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();
|