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