visual-regression-compare 0.0.0 → 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 +154 -0
- 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 +65 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1301 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/run.ts
|
|
4
|
+
import fs6 from "fs";
|
|
5
|
+
import path7 from "path";
|
|
6
|
+
|
|
7
|
+
// src/approve.ts
|
|
8
|
+
import { createHash } from "crypto";
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import {
|
|
12
|
+
isSafeRelativePath,
|
|
13
|
+
mergeManifests,
|
|
14
|
+
projectDirInRepo,
|
|
15
|
+
readManifestFile,
|
|
16
|
+
selectEntries,
|
|
17
|
+
summarize,
|
|
18
|
+
toRepoPath,
|
|
19
|
+
writeManifestFile
|
|
20
|
+
} from "visual-regression-manifest";
|
|
21
|
+
var sha256 = (file) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
22
|
+
var locateProject = (manifest, repoRoot) => {
|
|
23
|
+
if (manifest.ci?.workspace) {
|
|
24
|
+
return { projectDir: projectDirInRepo(manifest), base: repoRoot };
|
|
25
|
+
}
|
|
26
|
+
const relative = path.relative(repoRoot, manifest.projectRoot).split(path.sep).join("/");
|
|
27
|
+
if (relative === "") return { projectDir: "", base: repoRoot };
|
|
28
|
+
return isSafeRelativePath(relative) ? { projectDir: relative, base: repoRoot } : { projectDir: null, base: repoRoot };
|
|
29
|
+
};
|
|
30
|
+
var approve = ({
|
|
31
|
+
manifests,
|
|
32
|
+
repoRoot = process.cwd(),
|
|
33
|
+
names,
|
|
34
|
+
testFile,
|
|
35
|
+
force = false,
|
|
36
|
+
dryRun = false
|
|
37
|
+
}) => {
|
|
38
|
+
const root = path.resolve(repoRoot);
|
|
39
|
+
const sources = manifests.map((file) => ({
|
|
40
|
+
file,
|
|
41
|
+
label: file,
|
|
42
|
+
manifest: readManifestFile(file)
|
|
43
|
+
}));
|
|
44
|
+
const run = mergeManifests(sources);
|
|
45
|
+
const picked = selectEntries(run, names ? { names } : "all");
|
|
46
|
+
const explicit = names !== void 0;
|
|
47
|
+
const chosen = testFile ? picked.entries.filter((m) => m.entry.test.filePath === testFile) : picked.entries;
|
|
48
|
+
const result = {
|
|
49
|
+
approved: [],
|
|
50
|
+
skipped: [],
|
|
51
|
+
files: [],
|
|
52
|
+
warnings: run.warnings,
|
|
53
|
+
unknown: picked.unknown
|
|
54
|
+
};
|
|
55
|
+
const changed = /* @__PURE__ */ new Set();
|
|
56
|
+
const skip = (m, reason) => result.skipped.push({ entry: m.entry, reason });
|
|
57
|
+
for (const m of chosen) {
|
|
58
|
+
const { entry, source } = m;
|
|
59
|
+
if (m.unapprovableReason) {
|
|
60
|
+
skip(m, `cannot be approved: ${m.unapprovableReason}`);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (!explicit && m.collidesWith.length > 0) {
|
|
64
|
+
skip(m, "writes the same baseline as another entry; name one to approve");
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const { projectDir, base } = locateProject(source.manifest, root);
|
|
68
|
+
if (projectDir === null) {
|
|
69
|
+
skip(
|
|
70
|
+
m,
|
|
71
|
+
`the project root ${source.manifest.projectRoot} is outside the repository root ${root}; run from the repository root or pass --repo-root`
|
|
72
|
+
);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (!entry.images.actual.path) {
|
|
76
|
+
skip(m, "the run kept no actual image for it");
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const actualRepo = toRepoPath(projectDir, entry.images.actual.path);
|
|
80
|
+
const baselineRepo = toRepoPath(projectDir, entry.images.baseline.path);
|
|
81
|
+
if (!actualRepo || !baselineRepo) {
|
|
82
|
+
skip(m, "its paths are outside the repository");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const actual = path.join(base, actualRepo);
|
|
86
|
+
const baseline = path.join(base, baselineRepo);
|
|
87
|
+
const diffRepo = toRepoPath(projectDir, entry.images.diff.path);
|
|
88
|
+
if (!fs.existsSync(actual)) {
|
|
89
|
+
skip(m, "the actual image is gone");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!force) {
|
|
93
|
+
if (entry.hashes?.actual && entry.hashes.actual !== sha256(actual)) {
|
|
94
|
+
skip(m, "the actual image changed since the run (stale)");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const baselineNow = fs.existsSync(baseline) ? sha256(baseline) : void 0;
|
|
98
|
+
const expected = entry.hashes?.baseline;
|
|
99
|
+
const stale = entry.status === "missing-baseline" ? baselineNow !== void 0 : expected !== void 0 && baselineNow !== expected;
|
|
100
|
+
if (stale) {
|
|
101
|
+
skip(m, "the baseline changed since the run (stale)");
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!dryRun) {
|
|
106
|
+
fs.mkdirSync(path.dirname(baseline), { recursive: true });
|
|
107
|
+
fs.copyFileSync(actual, baseline);
|
|
108
|
+
fs.rmSync(actual, { force: true });
|
|
109
|
+
if (diffRepo) fs.rmSync(path.join(base, diffRepo), { force: true });
|
|
110
|
+
Object.assign(entry, {
|
|
111
|
+
status: "approved",
|
|
112
|
+
images: {
|
|
113
|
+
// the approved screenshot is the baseline now, with its size
|
|
114
|
+
baseline: {
|
|
115
|
+
...entry.images.baseline,
|
|
116
|
+
width: entry.images.actual.width ?? entry.images.baseline.width,
|
|
117
|
+
height: entry.images.actual.height ?? entry.images.baseline.height
|
|
118
|
+
},
|
|
119
|
+
actual: { path: null },
|
|
120
|
+
diff: { path: null }
|
|
121
|
+
},
|
|
122
|
+
baselineWritten: true,
|
|
123
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
124
|
+
hashes: { baseline: sha256(baseline) },
|
|
125
|
+
message: "Baseline image was replaced with the approved screenshot."
|
|
126
|
+
});
|
|
127
|
+
changed.add(source);
|
|
128
|
+
}
|
|
129
|
+
result.approved.push(entry);
|
|
130
|
+
result.files.push(baselineRepo);
|
|
131
|
+
}
|
|
132
|
+
for (const { file, manifest } of changed) {
|
|
133
|
+
manifest.summary = summarize(manifest.entries);
|
|
134
|
+
manifest.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
135
|
+
writeManifestFile(file, manifest);
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/comparer.ts
|
|
141
|
+
import fs2 from "fs";
|
|
142
|
+
import os from "os";
|
|
143
|
+
import path3 from "path";
|
|
144
|
+
import {
|
|
145
|
+
ManifestWriter,
|
|
146
|
+
getManifestFileName,
|
|
147
|
+
maxRatioFrom
|
|
148
|
+
} from "visual-regression-manifest";
|
|
149
|
+
|
|
150
|
+
// src/engines/index.ts
|
|
151
|
+
import path2 from "path";
|
|
152
|
+
import { pathToFileURL } from "url";
|
|
153
|
+
|
|
154
|
+
// src/engines/pixelmatch.ts
|
|
155
|
+
import pixelmatch from "pixelmatch";
|
|
156
|
+
var pixelmatchEngine = ({
|
|
157
|
+
threshold = 0.1,
|
|
158
|
+
includeAA = false
|
|
159
|
+
} = {}) => ({
|
|
160
|
+
name: "pixelmatch",
|
|
161
|
+
colorMetric: "yiq",
|
|
162
|
+
colorThreshold: threshold,
|
|
163
|
+
compare(baseline, actual) {
|
|
164
|
+
const { width, height } = baseline;
|
|
165
|
+
const diff = new Uint8ClampedArray(width * height * 4);
|
|
166
|
+
const diffPixels = pixelmatch(
|
|
167
|
+
baseline.data,
|
|
168
|
+
actual.data,
|
|
169
|
+
diff,
|
|
170
|
+
width,
|
|
171
|
+
height,
|
|
172
|
+
{
|
|
173
|
+
threshold,
|
|
174
|
+
includeAA
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
const area = width * height;
|
|
178
|
+
return {
|
|
179
|
+
diffPixels,
|
|
180
|
+
diffRatio: area === 0 ? 0 : diffPixels / area,
|
|
181
|
+
diff: { width, height, data: diff }
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
var pixelmatchFrom = (options) => pixelmatchEngine(options);
|
|
186
|
+
|
|
187
|
+
// src/engines/index.ts
|
|
188
|
+
var BUILT_IN = {
|
|
189
|
+
pixelmatch: pixelmatchFrom
|
|
190
|
+
};
|
|
191
|
+
var BUILT_IN_ENGINES = Object.keys(BUILT_IN);
|
|
192
|
+
var isDiffEngine = (value) => typeof value === "object" && value !== null && typeof value.compare === "function" && typeof value.name === "string";
|
|
193
|
+
var loadEngine = async (id, options = {}, cwd = process.cwd()) => {
|
|
194
|
+
const builtIn = BUILT_IN[id];
|
|
195
|
+
if (builtIn) return builtIn(options);
|
|
196
|
+
const isPath = /^\.{0,2}[\\/]/.test(id) || path2.isAbsolute(id);
|
|
197
|
+
const specifier = isPath ? pathToFileURL(path2.resolve(cwd, id)).href : id;
|
|
198
|
+
let loaded;
|
|
199
|
+
try {
|
|
200
|
+
loaded = await import(specifier);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
throw new Error(
|
|
203
|
+
`Cannot load engine "${id}" (built in: ${BUILT_IN_ENGINES.join(", ")}): ${error.message}`,
|
|
204
|
+
{ cause: error }
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
const exported = loaded.default;
|
|
208
|
+
const engine = typeof exported === "function" ? exported(options) : exported;
|
|
209
|
+
if (!isDiffEngine(engine)) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Engine "${id}" must default-export a { name, colorMetric, compare } object or a function returning one`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
return engine;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// src/image.ts
|
|
218
|
+
import { PNG } from "pngjs";
|
|
219
|
+
var LFS_POINTER = "version https://git-lfs.github.com/spec/";
|
|
220
|
+
var decodePng = (bytes) => {
|
|
221
|
+
const head = Buffer.from(bytes.subarray(0, LFS_POINTER.length)).toString(
|
|
222
|
+
"latin1"
|
|
223
|
+
);
|
|
224
|
+
if (head === LFS_POINTER) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"the file is a Git LFS pointer, not an image; run `git lfs pull`"
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
const { width, height, data } = PNG.sync.read(Buffer.from(bytes));
|
|
230
|
+
return { width, height, data: new Uint8ClampedArray(data) };
|
|
231
|
+
};
|
|
232
|
+
var encodePng = ({ width, height, data }) => {
|
|
233
|
+
const png = new PNG({ width, height });
|
|
234
|
+
png.data = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
235
|
+
return PNG.sync.write(png);
|
|
236
|
+
};
|
|
237
|
+
var resize = (image, width, height) => {
|
|
238
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
239
|
+
const rows = Math.min(height, image.height);
|
|
240
|
+
const rowBytes = Math.min(width, image.width) * 4;
|
|
241
|
+
for (let y = 0; y < rows; y++) {
|
|
242
|
+
const from = y * image.width * 4;
|
|
243
|
+
data.set(image.data.subarray(from, from + rowBytes), y * width * 4);
|
|
244
|
+
}
|
|
245
|
+
return { width, height, data };
|
|
246
|
+
};
|
|
247
|
+
var padToSame = (a, b) => {
|
|
248
|
+
const width = Math.max(a.width, b.width);
|
|
249
|
+
const height = Math.max(a.height, b.height);
|
|
250
|
+
return [resize(a, width, height), resize(b, width, height)];
|
|
251
|
+
};
|
|
252
|
+
var cropToSame = (a, b) => {
|
|
253
|
+
const width = Math.min(a.width, b.width);
|
|
254
|
+
const height = Math.min(a.height, b.height);
|
|
255
|
+
return [resize(a, width, height), resize(b, width, height)];
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
// src/job.ts
|
|
259
|
+
var decodeNamed = (which, bytes) => {
|
|
260
|
+
try {
|
|
261
|
+
return decodePng(bytes);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
throw new Error(`the ${which} image: ${error.message}`, {
|
|
264
|
+
cause: error
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
var sizeOf = ({ width, height }) => ({ width, height });
|
|
269
|
+
var runCompare = async (job, engine) => {
|
|
270
|
+
let baseline = decodeNamed("baseline", job.baseline);
|
|
271
|
+
let actual = decodeNamed("actual", job.actual);
|
|
272
|
+
const baselineSize = sizeOf(baseline);
|
|
273
|
+
const actualSize = sizeOf(actual);
|
|
274
|
+
let resized;
|
|
275
|
+
if (baseline.width !== actual.width || baseline.height !== actual.height) {
|
|
276
|
+
if (job.onSizeMismatch === "fail") {
|
|
277
|
+
return { kind: "size-mismatch", baselineSize, actualSize };
|
|
278
|
+
}
|
|
279
|
+
resized = job.onSizeMismatch;
|
|
280
|
+
[baseline, actual] = (resized === "pad" ? padToSame : cropToSame)(
|
|
281
|
+
baseline,
|
|
282
|
+
actual
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
const { diff, ...result } = await engine.compare(baseline, actual);
|
|
286
|
+
return {
|
|
287
|
+
kind: "compared",
|
|
288
|
+
baselineSize,
|
|
289
|
+
actualSize,
|
|
290
|
+
...resized && { resized },
|
|
291
|
+
...result,
|
|
292
|
+
...diff && { diffPng: encodePng(diff) }
|
|
293
|
+
};
|
|
294
|
+
};
|
|
295
|
+
var runJob = async (job) => runCompare(
|
|
296
|
+
job,
|
|
297
|
+
await loadEngine(job.engine.id, job.engine.options, job.engine.cwd)
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// src/pool.ts
|
|
301
|
+
import { Worker } from "worker_threads";
|
|
302
|
+
var WorkerPool = class {
|
|
303
|
+
constructor(size, workerFile) {
|
|
304
|
+
this.size = size;
|
|
305
|
+
this.workerFile = workerFile;
|
|
306
|
+
}
|
|
307
|
+
size;
|
|
308
|
+
workerFile;
|
|
309
|
+
idle = [];
|
|
310
|
+
workers = [];
|
|
311
|
+
queue = [];
|
|
312
|
+
running = /* @__PURE__ */ new Map();
|
|
313
|
+
nextId = 0;
|
|
314
|
+
current = /* @__PURE__ */ new WeakMap();
|
|
315
|
+
get threaded() {
|
|
316
|
+
return this.size > 1 && this.workerFile !== void 0;
|
|
317
|
+
}
|
|
318
|
+
run(job) {
|
|
319
|
+
if (!this.threaded) return runJob(job);
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
this.queue.push({ job, resolve, reject });
|
|
322
|
+
this.pump();
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
pump() {
|
|
326
|
+
while (this.queue.length > 0) {
|
|
327
|
+
const worker = this.idle.pop() ?? this.spawn();
|
|
328
|
+
if (!worker) return;
|
|
329
|
+
const pending = this.queue.shift();
|
|
330
|
+
const id = this.nextId++;
|
|
331
|
+
this.running.set(id, pending);
|
|
332
|
+
this.current.set(worker, id);
|
|
333
|
+
worker.postMessage({ id, job: pending.job });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
spawn() {
|
|
337
|
+
if (this.workers.length >= this.size) return void 0;
|
|
338
|
+
const worker = new Worker(this.workerFile);
|
|
339
|
+
this.workers.push(worker);
|
|
340
|
+
worker.on("message", ({ id, result, error }) => {
|
|
341
|
+
const pending = this.running.get(id);
|
|
342
|
+
this.running.delete(id);
|
|
343
|
+
this.idle.push(worker);
|
|
344
|
+
if (error !== void 0) pending?.reject(new Error(error));
|
|
345
|
+
else pending?.resolve(result);
|
|
346
|
+
this.pump();
|
|
347
|
+
});
|
|
348
|
+
const lose = (error) => {
|
|
349
|
+
for (const list of [this.workers, this.idle]) {
|
|
350
|
+
const at = list.indexOf(worker);
|
|
351
|
+
if (at !== -1) list.splice(at, 1);
|
|
352
|
+
}
|
|
353
|
+
const id = this.current.get(worker);
|
|
354
|
+
const pending = id === void 0 ? void 0 : this.running.get(id);
|
|
355
|
+
if (id !== void 0) this.running.delete(id);
|
|
356
|
+
pending?.reject(error);
|
|
357
|
+
this.pump();
|
|
358
|
+
};
|
|
359
|
+
worker.on(
|
|
360
|
+
"exit",
|
|
361
|
+
(code) => lose(new Error(`A compare worker exited with code ${code}`))
|
|
362
|
+
);
|
|
363
|
+
worker.on("error", lose);
|
|
364
|
+
return worker;
|
|
365
|
+
}
|
|
366
|
+
async close() {
|
|
367
|
+
for (const pending of this.running.values()) {
|
|
368
|
+
pending.reject(new Error("The compare worker pool was closed"));
|
|
369
|
+
}
|
|
370
|
+
this.running.clear();
|
|
371
|
+
this.queue.length = 0;
|
|
372
|
+
await Promise.all(this.workers.map((worker) => worker.terminate()));
|
|
373
|
+
this.workers.length = 0;
|
|
374
|
+
this.idle.length = 0;
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// src/layout.ts
|
|
379
|
+
import { IMAGE_SUFFIX } from "visual-regression-manifest";
|
|
380
|
+
var sanitizeName = (name) => name.replace(/[\\:*?"<>|\u0000-\u001f]/g, "-").split("/").filter((segment) => segment !== "" && segment !== "." && segment !== "..").map(
|
|
381
|
+
(segment, _, all) => segment === all[all.length - 1] ? segment.replace(/\.(actual|diff)$/, "-$1") : segment
|
|
382
|
+
).join("/") || "screenshot";
|
|
383
|
+
var manifestLayout = () => ({
|
|
384
|
+
paths: (name) => ({
|
|
385
|
+
baseline: `${name}.png`,
|
|
386
|
+
incoming: `${name}.png`,
|
|
387
|
+
actual: `${name}${IMAGE_SUFFIX.actual}.png`,
|
|
388
|
+
diff: `${name}${IMAGE_SUFFIX.diff}.png`
|
|
389
|
+
}),
|
|
390
|
+
nameOf(kind, file) {
|
|
391
|
+
if (!file.endsWith(".png")) return null;
|
|
392
|
+
const stem = file.slice(0, -".png".length);
|
|
393
|
+
const suffix = kind === "actual" ? IMAGE_SUFFIX.actual : kind === "diff" ? IMAGE_SUFFIX.diff : "";
|
|
394
|
+
if (suffix)
|
|
395
|
+
return stem.endsWith(suffix) ? stem.slice(0, -suffix.length) : null;
|
|
396
|
+
return stem.endsWith(IMAGE_SUFFIX.actual) || stem.endsWith(IMAGE_SUFFIX.diff) ? null : stem;
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
var samePathMatcher = () => ({
|
|
400
|
+
baselineFor: (incoming) => ({ kind: "baseline", path: incoming.path })
|
|
401
|
+
});
|
|
402
|
+
var templateMatcher = (template) => ({
|
|
403
|
+
baselineFor({ path: incoming }) {
|
|
404
|
+
const slash = incoming.lastIndexOf("/");
|
|
405
|
+
const dir = slash === -1 ? "" : incoming.slice(0, slash + 1);
|
|
406
|
+
const name = incoming.slice(slash + 1).replace(/\.png$/, "");
|
|
407
|
+
return {
|
|
408
|
+
kind: "baseline",
|
|
409
|
+
path: template.replaceAll("{dir}", dir).replaceAll("{name}", name).replaceAll("{path}", `${dir}${name}`)
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// src/plugins.ts
|
|
415
|
+
var createRegistry = (initial) => {
|
|
416
|
+
const registry = {
|
|
417
|
+
...initial,
|
|
418
|
+
reporters: [...initial.reporters],
|
|
419
|
+
engines: /* @__PURE__ */ new Map(),
|
|
420
|
+
hooks: {
|
|
421
|
+
beforeCompare: [],
|
|
422
|
+
afterCompare: [],
|
|
423
|
+
decide: [],
|
|
424
|
+
beforeWrite: [],
|
|
425
|
+
end: []
|
|
426
|
+
},
|
|
427
|
+
async setup(plugins) {
|
|
428
|
+
const api = {
|
|
429
|
+
addEngine: (name, engine) => registry.engines.set(name, engine),
|
|
430
|
+
addReporter: (reporter) => registry.reporters.push(reporter),
|
|
431
|
+
setStore: (store) => registry.store = store,
|
|
432
|
+
setLayout: (layout) => registry.layout = layout,
|
|
433
|
+
setMatcher: (matcher) => registry.matcher = matcher,
|
|
434
|
+
on: ((event, fn) => {
|
|
435
|
+
registry.hooks[event].push(fn);
|
|
436
|
+
})
|
|
437
|
+
};
|
|
438
|
+
for (const plugin of plugins) await plugin.setup(api);
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
return registry;
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// src/comparer.ts
|
|
445
|
+
var TOOL_NAME = "visual-regression-compare";
|
|
446
|
+
var packageVersion = () => {
|
|
447
|
+
try {
|
|
448
|
+
const text = fs2.readFileSync(
|
|
449
|
+
new URL("../package.json", import.meta.url),
|
|
450
|
+
"utf8"
|
|
451
|
+
);
|
|
452
|
+
return JSON.parse(text).version;
|
|
453
|
+
} catch {
|
|
454
|
+
return void 0;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var DEFAULT_WORKER = new URL("./worker.js", import.meta.url);
|
|
458
|
+
var percent = (ratio2) => `${Number((ratio2 * 100).toPrecision(3))}%`;
|
|
459
|
+
var sizeText = ({ width, height }) => `${width}x${height}`;
|
|
460
|
+
var Comparer = class {
|
|
461
|
+
constructor(options) {
|
|
462
|
+
this.options = options;
|
|
463
|
+
this.projectRoot = path3.resolve(
|
|
464
|
+
options.manifest.projectRoot ?? process.cwd()
|
|
465
|
+
);
|
|
466
|
+
this.registry = createRegistry({
|
|
467
|
+
store: options.store,
|
|
468
|
+
layout: options.layout ?? manifestLayout(),
|
|
469
|
+
matcher: options.matcher ?? samePathMatcher(),
|
|
470
|
+
reporters: options.reporters ?? []
|
|
471
|
+
});
|
|
472
|
+
this.store = () => this.registry.store;
|
|
473
|
+
this.layout = () => this.registry.layout;
|
|
474
|
+
this.matcher = () => this.registry.matcher;
|
|
475
|
+
this.ready = this.registry.setup(options.plugins ?? []);
|
|
476
|
+
const manifestPath = options.manifest.path ?? path3.join(this.projectRoot, getManifestFileName("compare"));
|
|
477
|
+
this.writer = new ManifestWriter(
|
|
478
|
+
path3.resolve(this.projectRoot, manifestPath),
|
|
479
|
+
{
|
|
480
|
+
projectRoot: this.projectRoot,
|
|
481
|
+
tool: options.manifest.tool ?? {
|
|
482
|
+
name: TOOL_NAME,
|
|
483
|
+
version: packageVersion()
|
|
484
|
+
},
|
|
485
|
+
runner: options.manifest.runner ?? { name: TOOL_NAME },
|
|
486
|
+
options: {
|
|
487
|
+
engine: typeof options.engine === "object" ? options.engine.name : options.engine ?? "pixelmatch",
|
|
488
|
+
engineOptions: options.engineOptions ?? {},
|
|
489
|
+
limits: options.limits ?? {},
|
|
490
|
+
onMissingBaseline: options.onMissingBaseline ?? "fail",
|
|
491
|
+
onSizeMismatch: options.onSizeMismatch ?? "fail",
|
|
492
|
+
update: options.update ?? "none"
|
|
493
|
+
}
|
|
494
|
+
},
|
|
495
|
+
{ writeDelayMs: 200 }
|
|
496
|
+
);
|
|
497
|
+
this.pool = new WorkerPool(
|
|
498
|
+
options.concurrency ?? os.cpus().length,
|
|
499
|
+
options.workerFile ?? DEFAULT_WORKER
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
options;
|
|
503
|
+
writer;
|
|
504
|
+
projectRoot;
|
|
505
|
+
store;
|
|
506
|
+
layout;
|
|
507
|
+
matcher;
|
|
508
|
+
pool;
|
|
509
|
+
registry;
|
|
510
|
+
ready;
|
|
511
|
+
engine;
|
|
512
|
+
closed = false;
|
|
513
|
+
/** The engine as the main thread knows it: for its name and metric, and to run it when it is an object or came from a plugin. */
|
|
514
|
+
resolveEngine() {
|
|
515
|
+
this.engine ??= (async () => {
|
|
516
|
+
await this.ready;
|
|
517
|
+
const { engine = "pixelmatch", engineOptions = {} } = this.options;
|
|
518
|
+
if (typeof engine === "object") return engine;
|
|
519
|
+
const registered = this.registry.engines.get(engine);
|
|
520
|
+
if (typeof registered === "object") return registered;
|
|
521
|
+
if (typeof registered === "function") return registered(engineOptions);
|
|
522
|
+
return loadEngine(engine, engineOptions);
|
|
523
|
+
})();
|
|
524
|
+
return this.engine;
|
|
525
|
+
}
|
|
526
|
+
async measure(baseline, actual, engine) {
|
|
527
|
+
const { engine: spec = "pixelmatch", engineOptions } = this.options;
|
|
528
|
+
const inMainThread = typeof spec === "object" || this.registry.engines.has(spec);
|
|
529
|
+
const onSizeMismatch = this.options.onSizeMismatch ?? "fail";
|
|
530
|
+
if (inMainThread) {
|
|
531
|
+
return runCompare({ baseline, actual, onSizeMismatch }, engine);
|
|
532
|
+
}
|
|
533
|
+
return this.pool.run({
|
|
534
|
+
baseline,
|
|
535
|
+
actual,
|
|
536
|
+
onSizeMismatch,
|
|
537
|
+
engine: { id: spec, options: engineOptions, cwd: process.cwd() }
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
/** Compares one screenshot, decides, writes the files and records the manifest entry. */
|
|
541
|
+
async compareImage(input) {
|
|
542
|
+
await this.ready;
|
|
543
|
+
const name = sanitizeName(input.name);
|
|
544
|
+
const baseline = {
|
|
545
|
+
kind: "baseline",
|
|
546
|
+
path: this.layout().paths(name).baseline
|
|
547
|
+
};
|
|
548
|
+
return this.compareAt(input, name, baseline);
|
|
549
|
+
}
|
|
550
|
+
async compareAt(input, name, baselineRef) {
|
|
551
|
+
const started = Date.now();
|
|
552
|
+
const ctx = { name, input };
|
|
553
|
+
for (const fn of this.registry.hooks.beforeCompare) await fn(ctx);
|
|
554
|
+
const engine = await this.resolveEngine();
|
|
555
|
+
const baselineBytes = await this.store().read(baselineRef);
|
|
556
|
+
let measurement;
|
|
557
|
+
let diffPng;
|
|
558
|
+
if (baselineBytes === null) {
|
|
559
|
+
measurement = { kind: "missing-baseline" };
|
|
560
|
+
} else {
|
|
561
|
+
let job;
|
|
562
|
+
try {
|
|
563
|
+
job = await this.measure(baselineBytes, input.actual, engine);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
throw new Error(`${name}: ${error.message}`, {
|
|
566
|
+
cause: error
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
measurement = job.kind === "size-mismatch" ? job : {
|
|
570
|
+
kind: "compared",
|
|
571
|
+
baselineSize: job.baselineSize,
|
|
572
|
+
actualSize: job.actualSize,
|
|
573
|
+
resized: job.resized,
|
|
574
|
+
result: {
|
|
575
|
+
diffPixels: job.diffPixels,
|
|
576
|
+
diffRatio: job.diffRatio,
|
|
577
|
+
score: job.score
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
diffPng = job.kind === "compared" ? job.diffPng : void 0;
|
|
581
|
+
}
|
|
582
|
+
for (const fn of this.registry.hooks.afterCompare)
|
|
583
|
+
await fn(ctx, measurement);
|
|
584
|
+
const limit = this.limitFor(measurement);
|
|
585
|
+
let decision = this.decide(measurement, limit);
|
|
586
|
+
for (const fn of this.registry.hooks.decide) {
|
|
587
|
+
decision = await fn(ctx, decision);
|
|
588
|
+
}
|
|
589
|
+
const names = this.layout().paths(name);
|
|
590
|
+
const refs = {
|
|
591
|
+
actual: { kind: "actual", path: names.actual },
|
|
592
|
+
diff: { kind: "diff", path: names.diff }
|
|
593
|
+
};
|
|
594
|
+
await this.store().remove(refs.actual);
|
|
595
|
+
await this.store().remove(refs.diff);
|
|
596
|
+
let plan = { write: [] };
|
|
597
|
+
const status = decision.status;
|
|
598
|
+
if (status === "failed" || status === "missing-baseline") {
|
|
599
|
+
plan.write.push({ ref: refs.actual, bytes: input.actual });
|
|
600
|
+
if (diffPng) plan.write.push({ ref: refs.diff, bytes: diffPng });
|
|
601
|
+
} else if (status === "created" || status === "updated" || status === "approved") {
|
|
602
|
+
plan.write.push({ ref: baselineRef, bytes: input.actual });
|
|
603
|
+
}
|
|
604
|
+
for (const fn of this.registry.hooks.beforeWrite)
|
|
605
|
+
plan = await fn(ctx, plan);
|
|
606
|
+
for (const { ref, bytes } of plan.write)
|
|
607
|
+
await this.store().write(ref, bytes);
|
|
608
|
+
const entry = this.record(
|
|
609
|
+
input,
|
|
610
|
+
name,
|
|
611
|
+
baselineRef,
|
|
612
|
+
refs,
|
|
613
|
+
measurement,
|
|
614
|
+
decision,
|
|
615
|
+
limit,
|
|
616
|
+
engine,
|
|
617
|
+
Date.now() - started
|
|
618
|
+
);
|
|
619
|
+
for (const reporter of this.registry.reporters)
|
|
620
|
+
await reporter.onEntry?.(entry);
|
|
621
|
+
return entry;
|
|
622
|
+
}
|
|
623
|
+
/** `maxDiffRatio` of the entry: the stricter of the ratio and the pixel count, for the size actually compared. */
|
|
624
|
+
limitFor(measurement) {
|
|
625
|
+
if (measurement.kind !== "compared") return void 0;
|
|
626
|
+
const { baselineSize: b, actualSize: a, resized } = measurement;
|
|
627
|
+
const size = resized === "pad" ? {
|
|
628
|
+
width: Math.max(a.width, b.width),
|
|
629
|
+
height: Math.max(a.height, b.height)
|
|
630
|
+
} : resized === "crop" ? {
|
|
631
|
+
width: Math.min(a.width, b.width),
|
|
632
|
+
height: Math.min(a.height, b.height)
|
|
633
|
+
} : a;
|
|
634
|
+
const { maxDiffRatio, maxDiffPixels } = this.options.limits ?? {};
|
|
635
|
+
return maxRatioFrom({ ratio: maxDiffRatio, pixels: maxDiffPixels }, size) ?? 0;
|
|
636
|
+
}
|
|
637
|
+
decide(measurement, limit) {
|
|
638
|
+
const { update = "none", onMissingBaseline = "fail" } = this.options;
|
|
639
|
+
if (measurement.kind === "missing-baseline") {
|
|
640
|
+
return onMissingBaseline === "create" ? {
|
|
641
|
+
status: "created",
|
|
642
|
+
message: "Baseline image was created from the actual screenshot."
|
|
643
|
+
} : {
|
|
644
|
+
status: "missing-baseline",
|
|
645
|
+
message: "There is no baseline image."
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
if (measurement.kind === "size-mismatch") {
|
|
649
|
+
const message = `The size changed: baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}.`;
|
|
650
|
+
return update === "none" ? { status: "failed", message } : {
|
|
651
|
+
status: "updated",
|
|
652
|
+
message: `${message} Baseline image was replaced with the actual screenshot.`
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const { diffPixels, diffRatio } = measurement.result;
|
|
656
|
+
const passed = diffRatio <= (limit ?? 0);
|
|
657
|
+
const resized = measurement.resized ? ` Both images were ${measurement.resized === "pad" ? "padded" : "cropped"} to the same size (baseline ${sizeText(measurement.baselineSize)}, actual ${sizeText(measurement.actualSize)}).` : "";
|
|
658
|
+
const counts = `${diffPixels} pixels (${percent(diffRatio)}) differ`;
|
|
659
|
+
if (passed && !(update === "all" && diffPixels > 0)) {
|
|
660
|
+
return {
|
|
661
|
+
status: "passed",
|
|
662
|
+
message: diffPixels === 0 ? `Identical to the baseline.${resized}` : `${counts}, within the limit of ${percent(limit ?? 0)}.${resized}`
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
if (update !== "none") {
|
|
666
|
+
return {
|
|
667
|
+
status: "updated",
|
|
668
|
+
message: `${counts}. Baseline image was replaced with the actual screenshot.${resized}`
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return {
|
|
672
|
+
status: "failed",
|
|
673
|
+
message: `${counts}; the limit is ${percent(limit ?? 0)}.${resized}`
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
record(input, name, baselineRef, refs, measurement, decision, limit, engine, duration) {
|
|
677
|
+
const store = this.store();
|
|
678
|
+
const where = (ref) => store.locate(ref) ?? path3.join(this.projectRoot, ref.path);
|
|
679
|
+
const status = decision.status;
|
|
680
|
+
const compared = measurement.kind === "compared" ? measurement : void 0;
|
|
681
|
+
const sized = measurement.kind !== "missing-baseline" ? measurement : void 0;
|
|
682
|
+
const { maxDiffRatio = 0 } = this.options.limits ?? {};
|
|
683
|
+
const baselineRoot = path3.relative(
|
|
684
|
+
this.projectRoot,
|
|
685
|
+
where({ kind: "baseline", path: "" })
|
|
686
|
+
);
|
|
687
|
+
const meta = {
|
|
688
|
+
engine: engine.name,
|
|
689
|
+
...compared && { diffPixels: compared.result.diffPixels },
|
|
690
|
+
...compared?.result.score && { score: compared.result.score },
|
|
691
|
+
...compared?.resized && { sizeMismatch: compared.resized },
|
|
692
|
+
...measurement.kind === "size-mismatch" && {
|
|
693
|
+
sizeMismatch: "fail",
|
|
694
|
+
baselineSize: measurement.baselineSize,
|
|
695
|
+
actualSize: measurement.actualSize
|
|
696
|
+
},
|
|
697
|
+
...decision.extra
|
|
698
|
+
};
|
|
699
|
+
return this.writer.record({
|
|
700
|
+
actualPath: where(refs.actual),
|
|
701
|
+
baselinePath: where(baselineRef),
|
|
702
|
+
diffPath: where(refs.diff),
|
|
703
|
+
name,
|
|
704
|
+
testFile: input.test?.filePath,
|
|
705
|
+
titlePath: input.test?.titlePath,
|
|
706
|
+
retry: input.test?.retry,
|
|
707
|
+
status,
|
|
708
|
+
diffRatio: measurement.kind === "size-mismatch" ? 1 : compared?.result.diffRatio,
|
|
709
|
+
maxDiffRatio: limit ?? maxDiffRatio,
|
|
710
|
+
colorThreshold: engine.colorThreshold,
|
|
711
|
+
colorMetric: engine.colorMetric,
|
|
712
|
+
baselineWritten: status === "created" || status === "updated" || status === "approved",
|
|
713
|
+
message: decision.message,
|
|
714
|
+
baselineSize: sized?.baselineSize,
|
|
715
|
+
actualSize: sized?.actualSize,
|
|
716
|
+
platform: input.platform ?? this.options.manifest.platform,
|
|
717
|
+
viewport: input.viewport,
|
|
718
|
+
variant: input.variant ?? this.options.manifest.variant,
|
|
719
|
+
extra: { ...input.extra, visualRegressionCompare: meta },
|
|
720
|
+
options: {
|
|
721
|
+
imagesPath: `${baselineRoot || "."}/{name}.png`,
|
|
722
|
+
maxDiffRatio,
|
|
723
|
+
diffConfig: { engine: engine.name, ...this.options.engineOptions },
|
|
724
|
+
createMissingImages: this.options.onMissingBaseline === "create",
|
|
725
|
+
updateImages: this.options.update === "failures-only" ? "failures-only" : this.options.update === "all"
|
|
726
|
+
},
|
|
727
|
+
duration: input.duration ?? duration
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
/** Compares every incoming image of the store against its baseline. */
|
|
731
|
+
async compareAll() {
|
|
732
|
+
await this.ready;
|
|
733
|
+
const refs = [];
|
|
734
|
+
for await (const ref of this.store().list("incoming")) refs.push(ref);
|
|
735
|
+
const queue = [...refs];
|
|
736
|
+
const workers = Math.max(1, this.options.concurrency ?? os.cpus().length);
|
|
737
|
+
const failures = [];
|
|
738
|
+
await Promise.all(
|
|
739
|
+
Array.from({ length: Math.min(workers, queue.length) }, async () => {
|
|
740
|
+
for (let ref = queue.shift(); ref; ref = queue.shift()) {
|
|
741
|
+
try {
|
|
742
|
+
await this.compareIncoming(ref);
|
|
743
|
+
} catch (error) {
|
|
744
|
+
failures.push(error);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
})
|
|
748
|
+
);
|
|
749
|
+
if (failures.length > 0) {
|
|
750
|
+
throw new AggregateError(
|
|
751
|
+
failures,
|
|
752
|
+
`${failures.length} image${failures.length === 1 ? "" : "s"} could not be compared:
|
|
753
|
+
${failures.map((f) => ` ${f.message}`).join("\n")}`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
return this.writer.toJSON();
|
|
757
|
+
}
|
|
758
|
+
async compareIncoming(incoming) {
|
|
759
|
+
const baselineRef = this.matcher().baselineFor(incoming);
|
|
760
|
+
if (!baselineRef) return;
|
|
761
|
+
const name = this.layout().nameOf("baseline", baselineRef.path) ?? baselineRef.path.replace(/\.png$/, "");
|
|
762
|
+
const actual = await this.store().read(incoming);
|
|
763
|
+
if (actual === null) return;
|
|
764
|
+
await this.compareAt({ name, actual }, name, baselineRef);
|
|
765
|
+
}
|
|
766
|
+
/** Flushes the manifest and ends the reporters. Call once, after the last comparison. */
|
|
767
|
+
async close() {
|
|
768
|
+
await this.ready;
|
|
769
|
+
if (!this.closed) {
|
|
770
|
+
this.closed = true;
|
|
771
|
+
this.writer.write();
|
|
772
|
+
this.writer.flush();
|
|
773
|
+
await this.pool.close();
|
|
774
|
+
for (const fn of this.registry.hooks.end) await fn(this.writer.toJSON());
|
|
775
|
+
for (const reporter of this.registry.reporters) {
|
|
776
|
+
await reporter.onEnd?.(this.writer.toJSON());
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
return this.writer.toJSON();
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
var createComparer = (options) => new Comparer(options);
|
|
783
|
+
|
|
784
|
+
// src/config.ts
|
|
785
|
+
import fs3 from "fs";
|
|
786
|
+
import path4 from "path";
|
|
787
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
788
|
+
var CANDIDATES = [
|
|
789
|
+
"visual-regression.config.json",
|
|
790
|
+
"visual-regression.config.mjs",
|
|
791
|
+
"visual-regression.config.js"
|
|
792
|
+
];
|
|
793
|
+
var loadConfig = async (explicit, cwd = process.cwd()) => {
|
|
794
|
+
const file = explicit ? path4.resolve(cwd, explicit) : CANDIDATES.map((name) => path4.join(cwd, name)).find(
|
|
795
|
+
(f) => fs3.existsSync(f)
|
|
796
|
+
);
|
|
797
|
+
if (!file) return {};
|
|
798
|
+
if (!fs3.existsSync(file))
|
|
799
|
+
throw new Error(`Config file ${file} does not exist`);
|
|
800
|
+
if (file.endsWith(".json")) return JSON.parse(fs3.readFileSync(file, "utf8"));
|
|
801
|
+
const loaded = await import(pathToFileURL2(file).href);
|
|
802
|
+
return loaded.default ?? {};
|
|
803
|
+
};
|
|
804
|
+
var configArg = (argv) => {
|
|
805
|
+
const at = argv.findIndex(
|
|
806
|
+
(arg2) => arg2 === "--config" || arg2.startsWith("--config=")
|
|
807
|
+
);
|
|
808
|
+
if (at === -1) return void 0;
|
|
809
|
+
const arg = argv[at];
|
|
810
|
+
return arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : argv[at + 1];
|
|
811
|
+
};
|
|
812
|
+
|
|
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
|
+
// src/options.ts
|
|
882
|
+
import { parseArgs } from "util";
|
|
883
|
+
import {
|
|
884
|
+
MANIFEST_STATUSES
|
|
885
|
+
} from "visual-regression-manifest";
|
|
886
|
+
var UsageError = class extends Error {
|
|
887
|
+
};
|
|
888
|
+
var helpText = `Compare folders of screenshots against their baselines and write a visual-regression-manifest.
|
|
889
|
+
|
|
890
|
+
Usage:
|
|
891
|
+
visual-regression-compare [compare] <actual-dir> --baseline <dir> [options]
|
|
892
|
+
visual-regression-compare record <actual-dir> --baseline <dir> [options]
|
|
893
|
+
visual-regression-compare approve --manifest <path>... [<name>...|--all] [options]
|
|
894
|
+
|
|
895
|
+
compare options:
|
|
896
|
+
--baseline <dir> directory of baseline images (required)
|
|
897
|
+
--diff <dir> where .actual and .diff files go (default: next to the baselines)
|
|
898
|
+
--engine <id> pixelmatch, or a module path or package name (default: pixelmatch)
|
|
899
|
+
--max-diff-ratio <n> largest accepted share of differing pixels, 0..1 (default: 0)
|
|
900
|
+
--max-diff-pixels <n> largest accepted number of differing pixels
|
|
901
|
+
--color-threshold <n> per-pixel color tolerance of the engine, 0..1 (default: 0.1)
|
|
902
|
+
--include-aa count anti-aliased pixels as differences
|
|
903
|
+
--on-missing fail|create a screenshot without baseline (default: fail)
|
|
904
|
+
--on-size-mismatch fail|pad|crop
|
|
905
|
+
fail records the size change and compares nothing; pad grows both
|
|
906
|
+
images to the larger size, crop cuts both to the smaller (default: fail)
|
|
907
|
+
--update none|failures-only|all
|
|
908
|
+
overwrite baselines (default: none)
|
|
909
|
+
--match <template> baseline path from the screenshot path: {dir}, {name}, {path}
|
|
910
|
+
--layout manifest file naming (default: manifest)
|
|
911
|
+
--manifest <path> default: <actual-dir>/visual-regression-manifest.compare.json
|
|
912
|
+
--runner <name> --variant <id> --platform <os>[/<browser>]
|
|
913
|
+
metadata recorded on every entry
|
|
914
|
+
--report <list> console, ctrf[:<file>], github-summary[:<file>] (default: console)
|
|
915
|
+
--concurrency <n> worker threads for decoding and comparing (default: CPU count)
|
|
916
|
+
--config <file> visual-regression.config.{json,mjs,js}, found in the working directory
|
|
917
|
+
--fail-on <list> statuses that make the exit code 1 (default: failed,missing-baseline)
|
|
918
|
+
|
|
919
|
+
approve options:
|
|
920
|
+
--manifest <path> a manifest to apply (repeatable; required)
|
|
921
|
+
--all every entry that needs a human (the default without names)
|
|
922
|
+
--test <file> only entries of this test file
|
|
923
|
+
--repo-root <dir> the repository the manifest's paths are applied to (default: the working directory)
|
|
924
|
+
--force approve although the baseline changed since the run
|
|
925
|
+
--dry-run report what would be approved
|
|
926
|
+
|
|
927
|
+
-h, --help, -v, --version
|
|
928
|
+
`;
|
|
929
|
+
var number = (name, value) => {
|
|
930
|
+
if (value === void 0) return void 0;
|
|
931
|
+
const parsed = Number(value);
|
|
932
|
+
if (value.trim() === "" || Number.isNaN(parsed)) {
|
|
933
|
+
throw new UsageError(`${name} must be a number, got "${value}"`);
|
|
934
|
+
}
|
|
935
|
+
return parsed;
|
|
936
|
+
};
|
|
937
|
+
var ratio = (value) => {
|
|
938
|
+
if (value !== void 0 && (value < 0 || value > 1)) {
|
|
939
|
+
throw new UsageError(
|
|
940
|
+
`--max-diff-ratio must be between 0 and 1, got ${value}`
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
return value;
|
|
944
|
+
};
|
|
945
|
+
var oneOf = (name, value, allowed, fallback) => {
|
|
946
|
+
if (value === void 0) return fallback;
|
|
947
|
+
if (!allowed.includes(value)) {
|
|
948
|
+
throw new UsageError(
|
|
949
|
+
`${name} must be one of ${allowed.join(", ")}, got "${value}"`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
return value;
|
|
953
|
+
};
|
|
954
|
+
var parseCommand = (argv, config = {}) => {
|
|
955
|
+
let parsed;
|
|
956
|
+
try {
|
|
957
|
+
parsed = parseArgs({
|
|
958
|
+
args: argv,
|
|
959
|
+
allowPositionals: true,
|
|
960
|
+
options: {
|
|
961
|
+
help: { type: "boolean", short: "h" },
|
|
962
|
+
version: { type: "boolean", short: "v" },
|
|
963
|
+
baseline: { type: "string" },
|
|
964
|
+
diff: { type: "string" },
|
|
965
|
+
engine: { type: "string" },
|
|
966
|
+
"max-diff-ratio": { type: "string" },
|
|
967
|
+
"max-diff-pixels": { type: "string" },
|
|
968
|
+
"color-threshold": { type: "string" },
|
|
969
|
+
"include-aa": { type: "boolean" },
|
|
970
|
+
"on-missing": { type: "string" },
|
|
971
|
+
"on-size-mismatch": { type: "string" },
|
|
972
|
+
update: { type: "string" },
|
|
973
|
+
match: { type: "string" },
|
|
974
|
+
layout: { type: "string" },
|
|
975
|
+
manifest: { type: "string", multiple: true },
|
|
976
|
+
runner: { type: "string" },
|
|
977
|
+
variant: { type: "string" },
|
|
978
|
+
platform: { type: "string" },
|
|
979
|
+
report: { type: "string" },
|
|
980
|
+
concurrency: { type: "string" },
|
|
981
|
+
config: { type: "string" },
|
|
982
|
+
"fail-on": { type: "string" },
|
|
983
|
+
all: { type: "boolean" },
|
|
984
|
+
test: { type: "string" },
|
|
985
|
+
"repo-root": { type: "string" },
|
|
986
|
+
force: { type: "boolean" },
|
|
987
|
+
"dry-run": { type: "boolean" }
|
|
988
|
+
}
|
|
989
|
+
});
|
|
990
|
+
} catch (error) {
|
|
991
|
+
throw new UsageError(error.message);
|
|
992
|
+
}
|
|
993
|
+
const { values } = parsed;
|
|
994
|
+
const verbAt = parsed.positionals.findIndex(
|
|
995
|
+
(arg) => ["compare", "record", "approve"].includes(arg)
|
|
996
|
+
);
|
|
997
|
+
const verb = verbAt === -1 ? void 0 : parsed.positionals[verbAt];
|
|
998
|
+
const positionals = parsed.positionals.filter((_, i) => i !== verbAt);
|
|
999
|
+
if (values.help) return { kind: "help" };
|
|
1000
|
+
if (values.version) return { kind: "version" };
|
|
1001
|
+
const manifests = values.manifest ?? [];
|
|
1002
|
+
if (verb === "approve") {
|
|
1003
|
+
if (manifests.length === 0) {
|
|
1004
|
+
throw new UsageError("approve needs at least one --manifest <path>");
|
|
1005
|
+
}
|
|
1006
|
+
return {
|
|
1007
|
+
kind: "approve",
|
|
1008
|
+
options: {
|
|
1009
|
+
manifests,
|
|
1010
|
+
names: positionals,
|
|
1011
|
+
all: Boolean(values.all) || positionals.length === 0,
|
|
1012
|
+
test: values.test,
|
|
1013
|
+
repoRoot: values["repo-root"],
|
|
1014
|
+
force: Boolean(values.force),
|
|
1015
|
+
dryRun: Boolean(values["dry-run"])
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
if (manifests.length > 1) {
|
|
1020
|
+
throw new UsageError("compare takes one --manifest <path>");
|
|
1021
|
+
}
|
|
1022
|
+
const actualDir = positionals[0] ?? config.actualDir;
|
|
1023
|
+
if (!actualDir) throw new UsageError("Missing <actual-dir>");
|
|
1024
|
+
if (positionals.length > 1) {
|
|
1025
|
+
throw new UsageError(`Unexpected argument "${positionals[1]}"`);
|
|
1026
|
+
}
|
|
1027
|
+
const baselineDir = values.baseline ?? config.baselineDir;
|
|
1028
|
+
if (!baselineDir) throw new UsageError("Missing --baseline <dir>");
|
|
1029
|
+
const record = verb === "record";
|
|
1030
|
+
const failOn = (values["fail-on"]?.split(",").map((s) => s.trim()).filter(Boolean) ?? config.failOn ?? ["failed", "missing-baseline"]).map(
|
|
1031
|
+
(status) => oneOf("--fail-on", status, MANIFEST_STATUSES, "failed")
|
|
1032
|
+
);
|
|
1033
|
+
if (failOn.length === 0) {
|
|
1034
|
+
throw new UsageError("--fail-on needs at least one status");
|
|
1035
|
+
}
|
|
1036
|
+
const colorThreshold = number(
|
|
1037
|
+
"--color-threshold",
|
|
1038
|
+
values["color-threshold"]
|
|
1039
|
+
);
|
|
1040
|
+
return {
|
|
1041
|
+
kind: "compare",
|
|
1042
|
+
options: {
|
|
1043
|
+
actualDir,
|
|
1044
|
+
baselineDir,
|
|
1045
|
+
diffDir: values.diff ?? config.diffDir,
|
|
1046
|
+
engine: values.engine ?? config.engine ?? "pixelmatch",
|
|
1047
|
+
engineOptions: {
|
|
1048
|
+
...config.engineOptions,
|
|
1049
|
+
...colorThreshold !== void 0 && { threshold: colorThreshold },
|
|
1050
|
+
...values["include-aa"] && { includeAA: true }
|
|
1051
|
+
},
|
|
1052
|
+
maxDiffRatio: ratio(
|
|
1053
|
+
number(
|
|
1054
|
+
"--max-diff-ratio",
|
|
1055
|
+
values["max-diff-ratio"]
|
|
1056
|
+
) ?? config.maxDiffRatio
|
|
1057
|
+
),
|
|
1058
|
+
maxDiffPixels: number(
|
|
1059
|
+
"--max-diff-pixels",
|
|
1060
|
+
values["max-diff-pixels"]
|
|
1061
|
+
) ?? config.maxDiffPixels,
|
|
1062
|
+
onMissingBaseline: record ? "create" : oneOf(
|
|
1063
|
+
"--on-missing",
|
|
1064
|
+
values["on-missing"] ?? config.onMissingBaseline,
|
|
1065
|
+
["fail", "create"],
|
|
1066
|
+
"fail"
|
|
1067
|
+
),
|
|
1068
|
+
onSizeMismatch: oneOf(
|
|
1069
|
+
"--on-size-mismatch",
|
|
1070
|
+
values["on-size-mismatch"] ?? config.onSizeMismatch,
|
|
1071
|
+
["fail", "pad", "crop"],
|
|
1072
|
+
"fail"
|
|
1073
|
+
),
|
|
1074
|
+
update: record ? "all" : oneOf(
|
|
1075
|
+
"--update",
|
|
1076
|
+
values.update ?? config.update,
|
|
1077
|
+
["none", "failures-only", "all"],
|
|
1078
|
+
"none"
|
|
1079
|
+
),
|
|
1080
|
+
match: values.match ?? config.match,
|
|
1081
|
+
layout: oneOf(
|
|
1082
|
+
"--layout",
|
|
1083
|
+
values.layout,
|
|
1084
|
+
["manifest"],
|
|
1085
|
+
"manifest"
|
|
1086
|
+
),
|
|
1087
|
+
manifest: manifests[0] ?? config.manifest,
|
|
1088
|
+
runner: values.runner ?? config.runner,
|
|
1089
|
+
variant: values.variant ?? config.variant,
|
|
1090
|
+
platform: values.platform ?? config.platform,
|
|
1091
|
+
report: values.report ?? config.report ?? "console",
|
|
1092
|
+
concurrency: number("--concurrency", values.concurrency) ?? config.concurrency,
|
|
1093
|
+
failOn,
|
|
1094
|
+
record
|
|
1095
|
+
}
|
|
1096
|
+
};
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1099
|
+
// src/reporters.ts
|
|
1100
|
+
import fs5 from "fs";
|
|
1101
|
+
import path6 from "path";
|
|
1102
|
+
import {
|
|
1103
|
+
needsHuman,
|
|
1104
|
+
toCtrf
|
|
1105
|
+
} from "visual-regression-manifest";
|
|
1106
|
+
var MARK = {
|
|
1107
|
+
passed: "\u2713",
|
|
1108
|
+
failed: "\u2717",
|
|
1109
|
+
"missing-baseline": "?",
|
|
1110
|
+
created: "+",
|
|
1111
|
+
updated: "~",
|
|
1112
|
+
approved: "\u2713"
|
|
1113
|
+
};
|
|
1114
|
+
var consoleReporter = (log = (line) => process.stdout.write(`${line}
|
|
1115
|
+
`)) => ({
|
|
1116
|
+
onEntry(entry) {
|
|
1117
|
+
const note = entry.status === "passed" ? "" : ` ${entry.status}: ${entry.message}`;
|
|
1118
|
+
log(`${MARK[entry.status]} ${entry.name}${note}`);
|
|
1119
|
+
},
|
|
1120
|
+
onEnd({ summary }) {
|
|
1121
|
+
const parts = [
|
|
1122
|
+
`${summary.passed} passed`,
|
|
1123
|
+
`${summary.failed} failed`,
|
|
1124
|
+
`${summary.missingBaseline} without baseline`,
|
|
1125
|
+
`${summary.created} created`,
|
|
1126
|
+
`${summary.updated} updated`
|
|
1127
|
+
];
|
|
1128
|
+
log(`
|
|
1129
|
+
${summary.entries} compared: ${parts.join(", ")}`);
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
var ctrfReporter = (file) => ({
|
|
1133
|
+
onEnd(manifest) {
|
|
1134
|
+
fs5.mkdirSync(path6.dirname(path6.resolve(file)), { recursive: true });
|
|
1135
|
+
fs5.writeFileSync(file, JSON.stringify(toCtrf(manifest), null, 2));
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
var cell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
1139
|
+
var summaryMarkdown = ({ summary, entries }) => {
|
|
1140
|
+
const lines = [
|
|
1141
|
+
"### Visual regression",
|
|
1142
|
+
"",
|
|
1143
|
+
`${summary.entries} screenshots: ${summary.passed} passed, ${summary.failed} failed, ${summary.missingBaseline} without baseline, ${summary.created} created, ${summary.updated} updated.`
|
|
1144
|
+
];
|
|
1145
|
+
const open = entries.filter((entry) => needsHuman(entry.status));
|
|
1146
|
+
if (open.length > 0) {
|
|
1147
|
+
lines.push("", "| Screenshot | Status | Details |", "| --- | --- | --- |");
|
|
1148
|
+
for (const entry of open) {
|
|
1149
|
+
lines.push(
|
|
1150
|
+
`| ${cell(entry.name)} | ${entry.status} | ${cell(entry.message)} |`
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
return `${lines.join("\n")}
|
|
1155
|
+
`;
|
|
1156
|
+
};
|
|
1157
|
+
var githubSummaryReporter = (file = process.env.GITHUB_STEP_SUMMARY) => ({
|
|
1158
|
+
onEnd(manifest) {
|
|
1159
|
+
if (!file) return;
|
|
1160
|
+
fs5.appendFileSync(file, summaryMarkdown(manifest));
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
var REPORTER_NAMES = ["console", "ctrf", "github-summary"];
|
|
1164
|
+
var parseReporters = (spec) => spec.split(",").map((part) => part.trim()).filter(Boolean).map((part) => {
|
|
1165
|
+
const colon = part.indexOf(":");
|
|
1166
|
+
const name = colon === -1 ? part : part.slice(0, colon);
|
|
1167
|
+
const arg = colon === -1 ? void 0 : part.slice(colon + 1);
|
|
1168
|
+
switch (name) {
|
|
1169
|
+
case "console":
|
|
1170
|
+
return consoleReporter();
|
|
1171
|
+
case "ctrf":
|
|
1172
|
+
return ctrfReporter(arg ?? "ctrf-report.json");
|
|
1173
|
+
case "github-summary":
|
|
1174
|
+
return githubSummaryReporter(arg);
|
|
1175
|
+
default:
|
|
1176
|
+
throw new Error(
|
|
1177
|
+
`Unknown reporter "${name}" (known: ${REPORTER_NAMES.join(", ")})`
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
// src/run.ts
|
|
1183
|
+
var platformOf = (spec) => {
|
|
1184
|
+
if (!spec) return void 0;
|
|
1185
|
+
const [osPlatform, browser] = spec.split("/");
|
|
1186
|
+
return { osPlatform, ...browser && { browser: { name: browser } } };
|
|
1187
|
+
};
|
|
1188
|
+
var compare = async (options, version, out) => {
|
|
1189
|
+
const actualDir = path7.resolve(options.actualDir);
|
|
1190
|
+
const comparer = createComparer({
|
|
1191
|
+
engine: options.engine,
|
|
1192
|
+
engineOptions: options.engineOptions,
|
|
1193
|
+
limits: {
|
|
1194
|
+
maxDiffRatio: options.maxDiffRatio,
|
|
1195
|
+
maxDiffPixels: options.maxDiffPixels
|
|
1196
|
+
},
|
|
1197
|
+
onMissingBaseline: options.onMissingBaseline,
|
|
1198
|
+
onSizeMismatch: options.onSizeMismatch,
|
|
1199
|
+
update: options.update,
|
|
1200
|
+
store: fsStore({
|
|
1201
|
+
baselineDir: options.baselineDir,
|
|
1202
|
+
actualDir,
|
|
1203
|
+
diffDir: options.diffDir
|
|
1204
|
+
}),
|
|
1205
|
+
layout: manifestLayout(),
|
|
1206
|
+
matcher: options.match ? templateMatcher(options.match) : samePathMatcher(),
|
|
1207
|
+
manifest: {
|
|
1208
|
+
path: options.manifest ?? path7.join(actualDir, "visual-regression-manifest.compare.json"),
|
|
1209
|
+
projectRoot: commonRoot([
|
|
1210
|
+
actualDir,
|
|
1211
|
+
path7.resolve(options.baselineDir),
|
|
1212
|
+
...options.diffDir ? [path7.resolve(options.diffDir)] : []
|
|
1213
|
+
]),
|
|
1214
|
+
tool: { name: "visual-regression-compare", version },
|
|
1215
|
+
runner: { name: options.runner ?? "visual-regression-compare" },
|
|
1216
|
+
platform: platformOf(options.platform),
|
|
1217
|
+
variant: options.variant
|
|
1218
|
+
},
|
|
1219
|
+
reporters: parseReporters(options.report),
|
|
1220
|
+
concurrency: options.concurrency
|
|
1221
|
+
});
|
|
1222
|
+
try {
|
|
1223
|
+
await comparer.compareAll();
|
|
1224
|
+
} finally {
|
|
1225
|
+
await comparer.close();
|
|
1226
|
+
}
|
|
1227
|
+
const manifest = comparer.writer.toJSON();
|
|
1228
|
+
const failing = manifest.entries.filter(
|
|
1229
|
+
(entry) => options.failOn.includes(entry.status)
|
|
1230
|
+
);
|
|
1231
|
+
if (manifest.entries.length === 0) {
|
|
1232
|
+
out.error(`No images found in ${options.actualDir}`);
|
|
1233
|
+
return 1;
|
|
1234
|
+
}
|
|
1235
|
+
out.log(`Manifest: ${comparer.writer.filePath}`);
|
|
1236
|
+
return failing.length > 0 ? 1 : 0;
|
|
1237
|
+
};
|
|
1238
|
+
var commonRoot = (dirs) => {
|
|
1239
|
+
const split = dirs.map((dir) => dir.split(path7.sep));
|
|
1240
|
+
const shared = [];
|
|
1241
|
+
for (let i = 0; i < split[0].length; i++) {
|
|
1242
|
+
if (split.every((parts) => parts[i] === split[0][i]))
|
|
1243
|
+
shared.push(split[0][i]);
|
|
1244
|
+
else break;
|
|
1245
|
+
}
|
|
1246
|
+
return shared.join(path7.sep) || path7.sep;
|
|
1247
|
+
};
|
|
1248
|
+
var approveCommand = (options, out) => {
|
|
1249
|
+
const result = approve({
|
|
1250
|
+
manifests: options.manifests,
|
|
1251
|
+
names: options.all ? void 0 : options.names,
|
|
1252
|
+
testFile: options.test,
|
|
1253
|
+
repoRoot: options.repoRoot,
|
|
1254
|
+
force: options.force,
|
|
1255
|
+
dryRun: options.dryRun
|
|
1256
|
+
});
|
|
1257
|
+
for (const warning of result.warnings) out.error(`warning: ${warning}`);
|
|
1258
|
+
for (const name of result.unknown) out.error(`No entry named "${name}"`);
|
|
1259
|
+
for (const { entry, reason } of result.skipped) {
|
|
1260
|
+
out.error(`skipped ${entry.name}: ${reason}`);
|
|
1261
|
+
}
|
|
1262
|
+
const verb = options.dryRun ? "Would approve" : "Approved";
|
|
1263
|
+
out.log(
|
|
1264
|
+
`${verb} ${result.approved.length} screenshot${result.approved.length === 1 ? "" : "s"}`
|
|
1265
|
+
);
|
|
1266
|
+
for (const file of result.files) out.log(` ${file}`);
|
|
1267
|
+
return result.skipped.length > 0 || result.unknown.length > 0 ? 1 : 0;
|
|
1268
|
+
};
|
|
1269
|
+
var runCli = async (argv, out) => {
|
|
1270
|
+
try {
|
|
1271
|
+
const config = await loadConfig(configArg(argv));
|
|
1272
|
+
const command = parseCommand(argv, config);
|
|
1273
|
+
if (command.kind === "help") {
|
|
1274
|
+
out.log(helpText);
|
|
1275
|
+
return 0;
|
|
1276
|
+
}
|
|
1277
|
+
const { version } = JSON.parse(
|
|
1278
|
+
fs6.readFileSync(new URL("../package.json", import.meta.url), "utf8")
|
|
1279
|
+
);
|
|
1280
|
+
if (command.kind === "version") {
|
|
1281
|
+
out.log(version);
|
|
1282
|
+
return 0;
|
|
1283
|
+
}
|
|
1284
|
+
return command.kind === "approve" ? approveCommand(command.options, out) : await compare(command.options, version, out);
|
|
1285
|
+
} catch (error) {
|
|
1286
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1287
|
+
out.error(
|
|
1288
|
+
error instanceof UsageError ? `${message}
|
|
1289
|
+
Run with --help for the options.` : `Error: ${message}`
|
|
1290
|
+
);
|
|
1291
|
+
return 2;
|
|
1292
|
+
}
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
// src/cli.ts
|
|
1296
|
+
process.exitCode = await runCli(process.argv.slice(2), {
|
|
1297
|
+
log: (line) => process.stdout.write(`${line}
|
|
1298
|
+
`),
|
|
1299
|
+
error: (line) => process.stderr.write(`${line}
|
|
1300
|
+
`)
|
|
1301
|
+
});
|