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/dist/comparer.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
3
|
-
import
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
338
|
-
this.
|
|
339
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
456
|
-
await
|
|
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 } =
|
|
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
|
|
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 } =
|
|
544
|
-
const baselineRoot =
|
|
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
|
-
|
|
743
|
+
...input.entry,
|
|
744
|
+
options: input.entry?.options ?? {
|
|
582
745
|
imagesPath: `${baselineRoot || "."}/{name}.png`,
|
|
583
746
|
maxDiffRatio,
|
|
584
|
-
diffConfig: { engine: engine.name, ...
|
|
585
|
-
createMissingImages:
|
|
586
|
-
updateImages:
|
|
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 ??
|
|
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;
|