vitest-image-snapshot 0.6.20 → 0.6.23
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/dist/ImageSnapshotReporter-C3C7yfn1.d.ts +22 -0
- package/dist/ImageSnapshotReporter-D1-9tyxx.js +314 -0
- package/dist/index.d.ts +1 -20
- package/dist/index.js +1 -297
- package/dist/reporter.d.ts +2 -0
- package/dist/reporter.js +7 -0
- package/package.json +1 -1
- package/src/DiffReport.ts +38 -22
- package/src/test/fixtures/failing-snapshot/__image_diff_report__/index.html +14 -7
- /package/src/test/fixtures/failing-snapshot/__image_diff_report__/{packages/vitest-image-snapshot/src → src}/test/fixtures/failing-snapshot/__image_actual__/red-square.png +0 -0
- /package/src/test/fixtures/failing-snapshot/__image_diff_report__/{packages/vitest-image-snapshot/src → src}/test/fixtures/failing-snapshot/__image_diffs__/red-square.png +0 -0
- /package/src/test/fixtures/failing-snapshot/__image_diff_report__/{packages/vitest-image-snapshot/src → src}/test/fixtures/failing-snapshot/__image_snapshots__/red-square.png +0 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Reporter, TestCase, Vitest } from "vitest/node";
|
|
2
|
+
|
|
3
|
+
//#region src/ImageSnapshotReporter.d.ts
|
|
4
|
+
interface ImageSnapshotReporterOptions {
|
|
5
|
+
/** Report directory (relative to config.root or absolute) */
|
|
6
|
+
reportPath?: string;
|
|
7
|
+
autoOpen?: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** Vitest reporter that generates HTML diff reports for image snapshot failures */
|
|
10
|
+
declare class ImageSnapshotReporter implements Reporter {
|
|
11
|
+
private failures;
|
|
12
|
+
private vitest;
|
|
13
|
+
private reportPath?;
|
|
14
|
+
private autoOpen;
|
|
15
|
+
constructor(options?: ImageSnapshotReporterOptions);
|
|
16
|
+
onInit(vitest: Vitest): void;
|
|
17
|
+
onTestCaseResult(testCase: TestCase): void;
|
|
18
|
+
onTestRunEnd(): Promise<void>;
|
|
19
|
+
private resolveReportDir;
|
|
20
|
+
}
|
|
21
|
+
//#endregion
|
|
22
|
+
export { ImageSnapshotReporterOptions as n, ImageSnapshotReporter as t };
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { exec } from "node:child_process";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
|
|
5
|
+
//#region src/DiffReport.ts
|
|
6
|
+
/** Generate HTML diff report for all failed image snapshots. */
|
|
7
|
+
async function generateDiffReport(failures, config) {
|
|
8
|
+
const { autoOpen = false, reportDir, configRoot } = config;
|
|
9
|
+
if (failures.length === 0) return;
|
|
10
|
+
await fs.promises.mkdir(reportDir, { recursive: true });
|
|
11
|
+
const html = createReportHTML(await copyImagesToReport(failures, reportDir, configRoot));
|
|
12
|
+
const outputPath = path.join(reportDir, "index.html");
|
|
13
|
+
await fs.promises.writeFile(outputPath, html, "utf-8");
|
|
14
|
+
console.log(`\n📊 Image diff report: ${outputPath}`);
|
|
15
|
+
if (autoOpen) exec(`${process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"} "${outputPath}"`);
|
|
16
|
+
}
|
|
17
|
+
/** Copy images to report dir, preserving directory structure relative to configRoot */
|
|
18
|
+
async function copyImagesToReport(failures, reportDir, configRoot) {
|
|
19
|
+
return Promise.all(failures.map(async (failure) => {
|
|
20
|
+
const copiedPaths = await copyImageSet(failure.paths, reportDir, configRoot);
|
|
21
|
+
return {
|
|
22
|
+
...failure,
|
|
23
|
+
paths: copiedPaths
|
|
24
|
+
};
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
async function copyImageSet(paths, reportDir, configRoot) {
|
|
28
|
+
return {
|
|
29
|
+
reference: await copyImage(paths.reference, reportDir, configRoot),
|
|
30
|
+
actual: await copyImage(paths.actual, reportDir, configRoot),
|
|
31
|
+
diff: await copyImage(paths.diff, reportDir, configRoot)
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Copy single image to report dir, preserving directory structure relative to configRoot */
|
|
35
|
+
async function copyImage(sourcePath, reportDir, configRoot) {
|
|
36
|
+
if (!fs.existsSync(sourcePath)) return "";
|
|
37
|
+
const relativePath = path.relative(configRoot, sourcePath);
|
|
38
|
+
const destPath = path.join(reportDir, relativePath);
|
|
39
|
+
const destDir = path.dirname(destPath);
|
|
40
|
+
await fs.promises.mkdir(destDir, { recursive: true });
|
|
41
|
+
await fs.promises.copyFile(sourcePath, destPath);
|
|
42
|
+
return relativePath;
|
|
43
|
+
}
|
|
44
|
+
function createReportHTML(failures) {
|
|
45
|
+
const timestamp = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
46
|
+
const totalFailures = failures.length;
|
|
47
|
+
const rows = failures.map((failure) => {
|
|
48
|
+
const { testName, snapshotName, comparison, paths } = failure;
|
|
49
|
+
const { mismatchedPixels, mismatchedPixelRatio } = comparison;
|
|
50
|
+
return `
|
|
51
|
+
<tr>
|
|
52
|
+
<td class="test-name">
|
|
53
|
+
<strong>${escapeHtml(testName)}</strong><br>
|
|
54
|
+
<code>${escapeHtml(snapshotName)}</code>
|
|
55
|
+
</td>
|
|
56
|
+
<td class="image-cell">
|
|
57
|
+
<a href="${paths.reference}" target="_blank">
|
|
58
|
+
<img src="${paths.reference}" alt="Expected" />
|
|
59
|
+
</a>
|
|
60
|
+
<div class="label">Expected</div>
|
|
61
|
+
</td>
|
|
62
|
+
<td class="image-cell">
|
|
63
|
+
<a href="${paths.actual}" target="_blank">
|
|
64
|
+
<img src="${paths.actual}" alt="Actual" />
|
|
65
|
+
</a>
|
|
66
|
+
<div class="label">Actual</div>
|
|
67
|
+
</td>
|
|
68
|
+
<td class="image-cell">
|
|
69
|
+
${diffCellHTML(paths.diff)}
|
|
70
|
+
</td>
|
|
71
|
+
<td class="stats">
|
|
72
|
+
<div><strong>${mismatchedPixels}</strong> pixels</div>
|
|
73
|
+
<div><strong>${(mismatchedPixelRatio * 100).toFixed(2)}%</strong></div>
|
|
74
|
+
</td>
|
|
75
|
+
</tr>
|
|
76
|
+
`;
|
|
77
|
+
}).join("\n");
|
|
78
|
+
return `<!DOCTYPE html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="UTF-8">
|
|
82
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
83
|
+
<title>Image Snapshot Failures - ${totalFailures} failed</title>
|
|
84
|
+
<style>
|
|
85
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
86
|
+
body {
|
|
87
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
88
|
+
padding: 20px;
|
|
89
|
+
background: #f5f5f5;
|
|
90
|
+
}
|
|
91
|
+
header {
|
|
92
|
+
background: white;
|
|
93
|
+
padding: 20px;
|
|
94
|
+
border-radius: 8px;
|
|
95
|
+
margin-bottom: 20px;
|
|
96
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
97
|
+
}
|
|
98
|
+
h1 {
|
|
99
|
+
color: #d73027;
|
|
100
|
+
margin-bottom: 10px;
|
|
101
|
+
}
|
|
102
|
+
.meta {
|
|
103
|
+
color: #666;
|
|
104
|
+
font-size: 14px;
|
|
105
|
+
}
|
|
106
|
+
.update-hint {
|
|
107
|
+
background: #fff3cd;
|
|
108
|
+
border: 1px solid #ffc107;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
padding: 12px;
|
|
111
|
+
margin: 15px 0;
|
|
112
|
+
}
|
|
113
|
+
.update-hint code {
|
|
114
|
+
background: #f8f9fa;
|
|
115
|
+
padding: 2px 6px;
|
|
116
|
+
border-radius: 3px;
|
|
117
|
+
font-family: 'Monaco', 'Courier New', monospace;
|
|
118
|
+
}
|
|
119
|
+
table {
|
|
120
|
+
width: 100%;
|
|
121
|
+
border-collapse: collapse;
|
|
122
|
+
background: white;
|
|
123
|
+
border-radius: 8px;
|
|
124
|
+
overflow: hidden;
|
|
125
|
+
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
126
|
+
}
|
|
127
|
+
th, td {
|
|
128
|
+
padding: 15px;
|
|
129
|
+
text-align: left;
|
|
130
|
+
border-bottom: 1px solid #e0e0e0;
|
|
131
|
+
}
|
|
132
|
+
th {
|
|
133
|
+
background: #f8f9fa;
|
|
134
|
+
font-weight: 600;
|
|
135
|
+
color: #333;
|
|
136
|
+
text-align: center;
|
|
137
|
+
}
|
|
138
|
+
tr:last-child td {
|
|
139
|
+
border-bottom: none;
|
|
140
|
+
}
|
|
141
|
+
tr:hover {
|
|
142
|
+
background: #fafafa;
|
|
143
|
+
}
|
|
144
|
+
.test-name {
|
|
145
|
+
min-width: 200px;
|
|
146
|
+
vertical-align: top;
|
|
147
|
+
}
|
|
148
|
+
.test-name strong {
|
|
149
|
+
color: #333;
|
|
150
|
+
}
|
|
151
|
+
.test-name code {
|
|
152
|
+
color: #666;
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
background: #f5f5f5;
|
|
155
|
+
padding: 2px 6px;
|
|
156
|
+
border-radius: 3px;
|
|
157
|
+
}
|
|
158
|
+
.image-cell {
|
|
159
|
+
text-align: center;
|
|
160
|
+
vertical-align: top;
|
|
161
|
+
width: 25%;
|
|
162
|
+
}
|
|
163
|
+
.image-cell a {
|
|
164
|
+
display: block;
|
|
165
|
+
text-decoration: none;
|
|
166
|
+
}
|
|
167
|
+
.image-cell img {
|
|
168
|
+
max-width: 100%;
|
|
169
|
+
height: auto;
|
|
170
|
+
max-height: 300px;
|
|
171
|
+
border: 1px solid #ddd;
|
|
172
|
+
border-radius: 4px;
|
|
173
|
+
cursor: pointer;
|
|
174
|
+
transition: transform 0.2s;
|
|
175
|
+
}
|
|
176
|
+
.image-cell img:hover {
|
|
177
|
+
transform: scale(1.02);
|
|
178
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
179
|
+
}
|
|
180
|
+
.label {
|
|
181
|
+
margin-top: 8px;
|
|
182
|
+
font-size: 12px;
|
|
183
|
+
color: #666;
|
|
184
|
+
font-weight: 500;
|
|
185
|
+
}
|
|
186
|
+
.no-diff {
|
|
187
|
+
color: #999;
|
|
188
|
+
font-size: 13px;
|
|
189
|
+
font-style: italic;
|
|
190
|
+
padding: 20px;
|
|
191
|
+
text-align: center;
|
|
192
|
+
}
|
|
193
|
+
.stats {
|
|
194
|
+
text-align: center;
|
|
195
|
+
color: #d73027;
|
|
196
|
+
font-size: 14px;
|
|
197
|
+
vertical-align: top;
|
|
198
|
+
}
|
|
199
|
+
.stats div {
|
|
200
|
+
margin: 5px 0;
|
|
201
|
+
}
|
|
202
|
+
footer {
|
|
203
|
+
margin-top: 20px;
|
|
204
|
+
text-align: center;
|
|
205
|
+
color: #999;
|
|
206
|
+
font-size: 12px;
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
209
|
+
</head>
|
|
210
|
+
<body>
|
|
211
|
+
<header>
|
|
212
|
+
<h1>🔴 Image Snapshot Failures</h1>
|
|
213
|
+
<div class="meta">
|
|
214
|
+
<strong>${totalFailures}</strong> test${totalFailures === 1 ? "" : "s"} failed •
|
|
215
|
+
Generated: ${escapeHtml(timestamp)}
|
|
216
|
+
</div>
|
|
217
|
+
<div class="update-hint">
|
|
218
|
+
💡 <strong>To update snapshots:</strong> Run <code>pnpm test -- -u</code> or <code>vitest -u</code>
|
|
219
|
+
</div>
|
|
220
|
+
</header>
|
|
221
|
+
|
|
222
|
+
<table>
|
|
223
|
+
<thead>
|
|
224
|
+
<tr>
|
|
225
|
+
<th>Test</th>
|
|
226
|
+
<th>Expected</th>
|
|
227
|
+
<th>Actual</th>
|
|
228
|
+
<th>Diff</th>
|
|
229
|
+
<th>Mismatch</th>
|
|
230
|
+
</tr>
|
|
231
|
+
</thead>
|
|
232
|
+
<tbody>
|
|
233
|
+
${rows}
|
|
234
|
+
</tbody>
|
|
235
|
+
</table>
|
|
236
|
+
|
|
237
|
+
<footer>
|
|
238
|
+
Click images to view full size •
|
|
239
|
+
Diff images highlight mismatched pixels in yellow/red
|
|
240
|
+
</footer>
|
|
241
|
+
</body>
|
|
242
|
+
</html>`;
|
|
243
|
+
}
|
|
244
|
+
function escapeHtml(text) {
|
|
245
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
246
|
+
}
|
|
247
|
+
function diffCellHTML(diffPath) {
|
|
248
|
+
if (!diffPath) return `<div class="no-diff">No diff image<br/>(dimension mismatch)</div>`;
|
|
249
|
+
return `<a href="${diffPath}" target="_blank">
|
|
250
|
+
<img src="${diffPath}" alt="Diff" />
|
|
251
|
+
</a>
|
|
252
|
+
<div class="label">Diff</div>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/ImageSnapshotReporter.ts
|
|
257
|
+
/** Vitest reporter that generates HTML diff reports for image snapshot failures */
|
|
258
|
+
var ImageSnapshotReporter = class {
|
|
259
|
+
failures = [];
|
|
260
|
+
vitest;
|
|
261
|
+
reportPath;
|
|
262
|
+
autoOpen;
|
|
263
|
+
constructor(options = {}) {
|
|
264
|
+
this.reportPath = options.reportPath;
|
|
265
|
+
this.autoOpen = options.autoOpen ?? process.env.IMAGE_DIFF_AUTO_OPEN === "true";
|
|
266
|
+
}
|
|
267
|
+
onInit(vitest) {
|
|
268
|
+
this.vitest = vitest;
|
|
269
|
+
}
|
|
270
|
+
onTestCaseResult(testCase) {
|
|
271
|
+
const result = testCase.result();
|
|
272
|
+
if (result?.state !== "failed") return;
|
|
273
|
+
const meta = testCase.meta();
|
|
274
|
+
if (!meta.imageSnapshotFailure) return;
|
|
275
|
+
const error = result.errors?.[0];
|
|
276
|
+
const failure = captureFailure(testCase, meta.imageSnapshotFailure, error?.message || "");
|
|
277
|
+
this.failures.push(failure);
|
|
278
|
+
}
|
|
279
|
+
async onTestRunEnd() {
|
|
280
|
+
if (this.failures.length === 0) return;
|
|
281
|
+
const reportDir = this.resolveReportDir();
|
|
282
|
+
await generateDiffReport(this.failures, {
|
|
283
|
+
autoOpen: this.autoOpen,
|
|
284
|
+
reportDir,
|
|
285
|
+
configRoot: this.vitest.config.root
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
resolveReportDir() {
|
|
289
|
+
const configRoot = this.vitest.config.root;
|
|
290
|
+
if (!this.reportPath) return path.join(configRoot, "__image_diff_report__");
|
|
291
|
+
return path.isAbsolute(this.reportPath) ? this.reportPath : path.join(configRoot, this.reportPath);
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
function captureFailure(testCase, data, message) {
|
|
295
|
+
const snapshotName = data.actualPath.match(/([^/]+)\.png$/)?.[1] || "unknown";
|
|
296
|
+
return {
|
|
297
|
+
testName: testCase.fullName || testCase.name,
|
|
298
|
+
snapshotName,
|
|
299
|
+
comparison: {
|
|
300
|
+
pass: false,
|
|
301
|
+
message,
|
|
302
|
+
mismatchedPixels: data.mismatchedPixels,
|
|
303
|
+
mismatchedPixelRatio: data.mismatchedPixelRatio
|
|
304
|
+
},
|
|
305
|
+
paths: {
|
|
306
|
+
reference: data.expectedPath,
|
|
307
|
+
actual: data.actualPath,
|
|
308
|
+
diff: data.diffPath
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
//#endregion
|
|
314
|
+
export { generateDiffReport as n, ImageSnapshotReporter as t };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as ImageSnapshotReporterOptions, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-C3C7yfn1.js";
|
|
2
2
|
|
|
3
3
|
//#region src/ImageComparison.d.ts
|
|
4
4
|
/** nodejs compatible interface to DOM ImageData */
|
|
@@ -59,25 +59,6 @@ interface MatchImageOptions extends ComparisonOptions {
|
|
|
59
59
|
/** Register toMatchImage() matcher with Vitest */
|
|
60
60
|
declare function imageMatcher(): void;
|
|
61
61
|
//#endregion
|
|
62
|
-
//#region src/ImageSnapshotReporter.d.ts
|
|
63
|
-
interface ImageSnapshotReporterOptions {
|
|
64
|
-
/** Report directory (relative to config.root or absolute) */
|
|
65
|
-
reportPath?: string;
|
|
66
|
-
autoOpen?: boolean;
|
|
67
|
-
}
|
|
68
|
-
/** Vitest reporter that generates HTML diff reports for image snapshot failures */
|
|
69
|
-
declare class ImageSnapshotReporter implements Reporter {
|
|
70
|
-
private failures;
|
|
71
|
-
private vitest;
|
|
72
|
-
private reportPath?;
|
|
73
|
-
private autoOpen;
|
|
74
|
-
constructor(options?: ImageSnapshotReporterOptions);
|
|
75
|
-
onInit(vitest: Vitest): void;
|
|
76
|
-
onTestCaseResult(testCase: TestCase): void;
|
|
77
|
-
onTestRunEnd(): Promise<void>;
|
|
78
|
-
private resolveReportDir;
|
|
79
|
-
}
|
|
80
|
-
//#endregion
|
|
81
62
|
//#region src/PNGUtil.d.ts
|
|
82
63
|
/** Convert standard browser ImageData to PNG Buffer for comparison. */
|
|
83
64
|
declare function pngBuffer(imageData: ImageData): Buffer;
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { n as generateDiffReport, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-D1-9tyxx.js";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import pixelmatch from "pixelmatch";
|
|
@@ -6,244 +6,6 @@ import { PNG } from "pngjs";
|
|
|
6
6
|
import { expect } from "vitest";
|
|
7
7
|
import { getCurrentTest } from "vitest/suite";
|
|
8
8
|
|
|
9
|
-
//#region src/DiffReport.ts
|
|
10
|
-
/** Generate HTML diff report for all failed image snapshots. */
|
|
11
|
-
async function generateDiffReport(failures, config) {
|
|
12
|
-
const { autoOpen = false, reportDir, configRoot } = config;
|
|
13
|
-
if (failures.length === 0) return;
|
|
14
|
-
if (!fs.existsSync(reportDir)) fs.mkdirSync(reportDir, { recursive: true });
|
|
15
|
-
const html = createReportHTML(await copyImagesToReport(failures, reportDir, configRoot));
|
|
16
|
-
const outputPath = path.join(reportDir, "index.html");
|
|
17
|
-
await fs.promises.writeFile(outputPath, html, "utf-8");
|
|
18
|
-
console.log(`\n📊 Image diff report: ${outputPath}`);
|
|
19
|
-
if (autoOpen) exec(`${process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"} "${outputPath}"`);
|
|
20
|
-
}
|
|
21
|
-
/** Copy images to report dir, preserving directory structure relative to configRoot */
|
|
22
|
-
async function copyImagesToReport(failures, reportDir, configRoot) {
|
|
23
|
-
return Promise.all(failures.map(async (failure) => {
|
|
24
|
-
const copiedPaths = await copyImageSet(failure.paths, reportDir, configRoot);
|
|
25
|
-
return {
|
|
26
|
-
...failure,
|
|
27
|
-
paths: copiedPaths
|
|
28
|
-
};
|
|
29
|
-
}));
|
|
30
|
-
}
|
|
31
|
-
async function copyImageSet(paths, reportDir, configRoot) {
|
|
32
|
-
const copy = async (sourcePath) => {
|
|
33
|
-
const relativePath = path.relative(configRoot, sourcePath);
|
|
34
|
-
const destPath = path.join(reportDir, relativePath);
|
|
35
|
-
const destDir = path.dirname(destPath);
|
|
36
|
-
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
37
|
-
await fs.promises.copyFile(sourcePath, destPath);
|
|
38
|
-
return relativePath;
|
|
39
|
-
};
|
|
40
|
-
return {
|
|
41
|
-
reference: await copy(paths.reference),
|
|
42
|
-
actual: await copy(paths.actual),
|
|
43
|
-
diff: await copy(paths.diff)
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
function createReportHTML(failures) {
|
|
47
|
-
const timestamp = (/* @__PURE__ */ new Date()).toLocaleString();
|
|
48
|
-
const totalFailures = failures.length;
|
|
49
|
-
const rows = failures.map((failure) => {
|
|
50
|
-
const { testName, snapshotName, comparison, paths } = failure;
|
|
51
|
-
const { mismatchedPixels, mismatchedPixelRatio } = comparison;
|
|
52
|
-
return `
|
|
53
|
-
<tr>
|
|
54
|
-
<td class="test-name">
|
|
55
|
-
<strong>${escapeHtml(testName)}</strong><br>
|
|
56
|
-
<code>${escapeHtml(snapshotName)}</code>
|
|
57
|
-
</td>
|
|
58
|
-
<td class="image-cell">
|
|
59
|
-
<a href="${paths.reference}" target="_blank">
|
|
60
|
-
<img src="${paths.reference}" alt="Expected" />
|
|
61
|
-
</a>
|
|
62
|
-
<div class="label">Expected</div>
|
|
63
|
-
</td>
|
|
64
|
-
<td class="image-cell">
|
|
65
|
-
<a href="${paths.actual}" target="_blank">
|
|
66
|
-
<img src="${paths.actual}" alt="Actual" />
|
|
67
|
-
</a>
|
|
68
|
-
<div class="label">Actual</div>
|
|
69
|
-
</td>
|
|
70
|
-
<td class="image-cell">
|
|
71
|
-
<a href="${paths.diff}" target="_blank">
|
|
72
|
-
<img src="${paths.diff}" alt="Diff" />
|
|
73
|
-
</a>
|
|
74
|
-
<div class="label">Diff</div>
|
|
75
|
-
</td>
|
|
76
|
-
<td class="stats">
|
|
77
|
-
<div><strong>${mismatchedPixels}</strong> pixels</div>
|
|
78
|
-
<div><strong>${(mismatchedPixelRatio * 100).toFixed(2)}%</strong></div>
|
|
79
|
-
</td>
|
|
80
|
-
</tr>
|
|
81
|
-
`;
|
|
82
|
-
}).join("\n");
|
|
83
|
-
return `<!DOCTYPE html>
|
|
84
|
-
<html lang="en">
|
|
85
|
-
<head>
|
|
86
|
-
<meta charset="UTF-8">
|
|
87
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
88
|
-
<title>Image Snapshot Failures - ${totalFailures} failed</title>
|
|
89
|
-
<style>
|
|
90
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
91
|
-
body {
|
|
92
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
93
|
-
padding: 20px;
|
|
94
|
-
background: #f5f5f5;
|
|
95
|
-
}
|
|
96
|
-
header {
|
|
97
|
-
background: white;
|
|
98
|
-
padding: 20px;
|
|
99
|
-
border-radius: 8px;
|
|
100
|
-
margin-bottom: 20px;
|
|
101
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
102
|
-
}
|
|
103
|
-
h1 {
|
|
104
|
-
color: #d73027;
|
|
105
|
-
margin-bottom: 10px;
|
|
106
|
-
}
|
|
107
|
-
.meta {
|
|
108
|
-
color: #666;
|
|
109
|
-
font-size: 14px;
|
|
110
|
-
}
|
|
111
|
-
.update-hint {
|
|
112
|
-
background: #fff3cd;
|
|
113
|
-
border: 1px solid #ffc107;
|
|
114
|
-
border-radius: 4px;
|
|
115
|
-
padding: 12px;
|
|
116
|
-
margin: 15px 0;
|
|
117
|
-
}
|
|
118
|
-
.update-hint code {
|
|
119
|
-
background: #f8f9fa;
|
|
120
|
-
padding: 2px 6px;
|
|
121
|
-
border-radius: 3px;
|
|
122
|
-
font-family: 'Monaco', 'Courier New', monospace;
|
|
123
|
-
}
|
|
124
|
-
table {
|
|
125
|
-
width: 100%;
|
|
126
|
-
border-collapse: collapse;
|
|
127
|
-
background: white;
|
|
128
|
-
border-radius: 8px;
|
|
129
|
-
overflow: hidden;
|
|
130
|
-
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
131
|
-
}
|
|
132
|
-
th, td {
|
|
133
|
-
padding: 15px;
|
|
134
|
-
text-align: left;
|
|
135
|
-
border-bottom: 1px solid #e0e0e0;
|
|
136
|
-
}
|
|
137
|
-
th {
|
|
138
|
-
background: #f8f9fa;
|
|
139
|
-
font-weight: 600;
|
|
140
|
-
color: #333;
|
|
141
|
-
text-align: center;
|
|
142
|
-
}
|
|
143
|
-
tr:last-child td {
|
|
144
|
-
border-bottom: none;
|
|
145
|
-
}
|
|
146
|
-
tr:hover {
|
|
147
|
-
background: #fafafa;
|
|
148
|
-
}
|
|
149
|
-
.test-name {
|
|
150
|
-
min-width: 200px;
|
|
151
|
-
vertical-align: top;
|
|
152
|
-
}
|
|
153
|
-
.test-name strong {
|
|
154
|
-
color: #333;
|
|
155
|
-
}
|
|
156
|
-
.test-name code {
|
|
157
|
-
color: #666;
|
|
158
|
-
font-size: 12px;
|
|
159
|
-
background: #f5f5f5;
|
|
160
|
-
padding: 2px 6px;
|
|
161
|
-
border-radius: 3px;
|
|
162
|
-
}
|
|
163
|
-
.image-cell {
|
|
164
|
-
text-align: center;
|
|
165
|
-
vertical-align: top;
|
|
166
|
-
width: 25%;
|
|
167
|
-
}
|
|
168
|
-
.image-cell a {
|
|
169
|
-
display: block;
|
|
170
|
-
text-decoration: none;
|
|
171
|
-
}
|
|
172
|
-
.image-cell img {
|
|
173
|
-
max-width: 100%;
|
|
174
|
-
height: auto;
|
|
175
|
-
max-height: 300px;
|
|
176
|
-
border: 1px solid #ddd;
|
|
177
|
-
border-radius: 4px;
|
|
178
|
-
cursor: pointer;
|
|
179
|
-
transition: transform 0.2s;
|
|
180
|
-
}
|
|
181
|
-
.image-cell img:hover {
|
|
182
|
-
transform: scale(1.02);
|
|
183
|
-
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
184
|
-
}
|
|
185
|
-
.label {
|
|
186
|
-
margin-top: 8px;
|
|
187
|
-
font-size: 12px;
|
|
188
|
-
color: #666;
|
|
189
|
-
font-weight: 500;
|
|
190
|
-
}
|
|
191
|
-
.stats {
|
|
192
|
-
text-align: center;
|
|
193
|
-
color: #d73027;
|
|
194
|
-
font-size: 14px;
|
|
195
|
-
vertical-align: top;
|
|
196
|
-
}
|
|
197
|
-
.stats div {
|
|
198
|
-
margin: 5px 0;
|
|
199
|
-
}
|
|
200
|
-
footer {
|
|
201
|
-
margin-top: 20px;
|
|
202
|
-
text-align: center;
|
|
203
|
-
color: #999;
|
|
204
|
-
font-size: 12px;
|
|
205
|
-
}
|
|
206
|
-
</style>
|
|
207
|
-
</head>
|
|
208
|
-
<body>
|
|
209
|
-
<header>
|
|
210
|
-
<h1>🔴 Image Snapshot Failures</h1>
|
|
211
|
-
<div class="meta">
|
|
212
|
-
<strong>${totalFailures}</strong> test${totalFailures === 1 ? "" : "s"} failed •
|
|
213
|
-
Generated: ${escapeHtml(timestamp)}
|
|
214
|
-
</div>
|
|
215
|
-
<div class="update-hint">
|
|
216
|
-
💡 <strong>To update snapshots:</strong> Run <code>pnpm test -- -u</code> or <code>vitest -u</code>
|
|
217
|
-
</div>
|
|
218
|
-
</header>
|
|
219
|
-
|
|
220
|
-
<table>
|
|
221
|
-
<thead>
|
|
222
|
-
<tr>
|
|
223
|
-
<th>Test</th>
|
|
224
|
-
<th>Expected</th>
|
|
225
|
-
<th>Actual</th>
|
|
226
|
-
<th>Diff</th>
|
|
227
|
-
<th>Mismatch</th>
|
|
228
|
-
</tr>
|
|
229
|
-
</thead>
|
|
230
|
-
<tbody>
|
|
231
|
-
${rows}
|
|
232
|
-
</tbody>
|
|
233
|
-
</table>
|
|
234
|
-
|
|
235
|
-
<footer>
|
|
236
|
-
Click images to view full size •
|
|
237
|
-
Diff images highlight mismatched pixels in yellow/red
|
|
238
|
-
</footer>
|
|
239
|
-
</body>
|
|
240
|
-
</html>`;
|
|
241
|
-
}
|
|
242
|
-
function escapeHtml(text) {
|
|
243
|
-
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
//#endregion
|
|
247
9
|
//#region src/ImageComparison.ts
|
|
248
10
|
async function compareImages(reference, actual, options = {}) {
|
|
249
11
|
const { threshold = .1, allowedPixelRatio = 0, allowedPixels = 0, includeAA = true } = options;
|
|
@@ -436,63 +198,5 @@ async function missingSnapshot(manager, actualBuffer, snapshotName) {
|
|
|
436
198
|
};
|
|
437
199
|
}
|
|
438
200
|
|
|
439
|
-
//#endregion
|
|
440
|
-
//#region src/ImageSnapshotReporter.ts
|
|
441
|
-
/** Vitest reporter that generates HTML diff reports for image snapshot failures */
|
|
442
|
-
var ImageSnapshotReporter = class {
|
|
443
|
-
failures = [];
|
|
444
|
-
vitest;
|
|
445
|
-
reportPath;
|
|
446
|
-
autoOpen;
|
|
447
|
-
constructor(options = {}) {
|
|
448
|
-
this.reportPath = options.reportPath;
|
|
449
|
-
this.autoOpen = options.autoOpen ?? process.env.IMAGE_DIFF_AUTO_OPEN === "true";
|
|
450
|
-
}
|
|
451
|
-
onInit(vitest) {
|
|
452
|
-
this.vitest = vitest;
|
|
453
|
-
}
|
|
454
|
-
onTestCaseResult(testCase) {
|
|
455
|
-
const result = testCase.result();
|
|
456
|
-
if (result?.state !== "failed") return;
|
|
457
|
-
const meta = testCase.meta();
|
|
458
|
-
if (!meta.imageSnapshotFailure) return;
|
|
459
|
-
const error = result.errors?.[0];
|
|
460
|
-
const failure = captureFailure(testCase, meta.imageSnapshotFailure, error?.message || "");
|
|
461
|
-
this.failures.push(failure);
|
|
462
|
-
}
|
|
463
|
-
async onTestRunEnd() {
|
|
464
|
-
if (this.failures.length === 0) return;
|
|
465
|
-
const reportDir = this.resolveReportDir();
|
|
466
|
-
await generateDiffReport(this.failures, {
|
|
467
|
-
autoOpen: this.autoOpen,
|
|
468
|
-
reportDir,
|
|
469
|
-
configRoot: this.vitest.config.root
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
resolveReportDir() {
|
|
473
|
-
const configRoot = this.vitest.config.root;
|
|
474
|
-
if (!this.reportPath) return path.join(configRoot, "__image_diff_report__");
|
|
475
|
-
return path.isAbsolute(this.reportPath) ? this.reportPath : path.join(configRoot, this.reportPath);
|
|
476
|
-
}
|
|
477
|
-
};
|
|
478
|
-
function captureFailure(testCase, data, message) {
|
|
479
|
-
const snapshotName = data.actualPath.match(/([^/]+)\.png$/)?.[1] || "unknown";
|
|
480
|
-
return {
|
|
481
|
-
testName: testCase.fullName || testCase.name,
|
|
482
|
-
snapshotName,
|
|
483
|
-
comparison: {
|
|
484
|
-
pass: false,
|
|
485
|
-
message,
|
|
486
|
-
mismatchedPixels: data.mismatchedPixels,
|
|
487
|
-
mismatchedPixelRatio: data.mismatchedPixelRatio
|
|
488
|
-
},
|
|
489
|
-
paths: {
|
|
490
|
-
reference: data.expectedPath,
|
|
491
|
-
actual: data.actualPath,
|
|
492
|
-
diff: data.diffPath
|
|
493
|
-
}
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
|
|
497
201
|
//#endregion
|
|
498
202
|
export { ImageSnapshotManager, ImageSnapshotReporter, compareImages, generateDiffReport, imageMatcher, pngBuffer };
|
package/dist/reporter.js
ADDED
package/package.json
CHANGED
package/src/DiffReport.ts
CHANGED
|
@@ -34,9 +34,7 @@ export async function generateDiffReport(
|
|
|
34
34
|
|
|
35
35
|
if (failures.length === 0) return;
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
fs.mkdirSync(reportDir, { recursive: true });
|
|
39
|
-
}
|
|
37
|
+
await fs.promises.mkdir(reportDir, { recursive: true });
|
|
40
38
|
|
|
41
39
|
const withCopiedImages = await copyImagesToReport(
|
|
42
40
|
failures,
|
|
@@ -84,24 +82,28 @@ async function copyImageSet(
|
|
|
84
82
|
reportDir: string,
|
|
85
83
|
configRoot: string,
|
|
86
84
|
): Promise<{ reference: string; actual: string; diff: string }> {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
return {
|
|
86
|
+
reference: await copyImage(paths.reference, reportDir, configRoot),
|
|
87
|
+
actual: await copyImage(paths.actual, reportDir, configRoot),
|
|
88
|
+
diff: await copyImage(paths.diff, reportDir, configRoot),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
/** Copy single image to report dir, preserving directory structure relative to configRoot */
|
|
93
|
+
async function copyImage(
|
|
94
|
+
sourcePath: string,
|
|
95
|
+
reportDir: string,
|
|
96
|
+
configRoot: string,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
if (!fs.existsSync(sourcePath)) return "";
|
|
95
99
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const relativePath = path.relative(configRoot, sourcePath);
|
|
101
|
+
const destPath = path.join(reportDir, relativePath);
|
|
102
|
+
const destDir = path.dirname(destPath);
|
|
99
103
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
diff: await copy(paths.diff),
|
|
104
|
-
};
|
|
104
|
+
await fs.promises.mkdir(destDir, { recursive: true });
|
|
105
|
+
await fs.promises.copyFile(sourcePath, destPath);
|
|
106
|
+
return relativePath;
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
function createReportHTML(failures: ImageSnapshotFailure[]): string {
|
|
@@ -132,10 +134,7 @@ function createReportHTML(failures: ImageSnapshotFailure[]): string {
|
|
|
132
134
|
<div class="label">Actual</div>
|
|
133
135
|
</td>
|
|
134
136
|
<td class="image-cell">
|
|
135
|
-
|
|
136
|
-
<img src="${paths.diff}" alt="Diff" />
|
|
137
|
-
</a>
|
|
138
|
-
<div class="label">Diff</div>
|
|
137
|
+
${diffCellHTML(paths.diff)}
|
|
139
138
|
</td>
|
|
140
139
|
<td class="stats">
|
|
141
140
|
<div><strong>${mismatchedPixels}</strong> pixels</div>
|
|
@@ -254,6 +253,13 @@ function createReportHTML(failures: ImageSnapshotFailure[]): string {
|
|
|
254
253
|
color: #666;
|
|
255
254
|
font-weight: 500;
|
|
256
255
|
}
|
|
256
|
+
.no-diff {
|
|
257
|
+
color: #999;
|
|
258
|
+
font-size: 13px;
|
|
259
|
+
font-style: italic;
|
|
260
|
+
padding: 20px;
|
|
261
|
+
text-align: center;
|
|
262
|
+
}
|
|
257
263
|
.stats {
|
|
258
264
|
text-align: center;
|
|
259
265
|
color: #d73027;
|
|
@@ -314,3 +320,13 @@ function escapeHtml(text: string): string {
|
|
|
314
320
|
.replace(/"/g, """)
|
|
315
321
|
.replace(/'/g, "'");
|
|
316
322
|
}
|
|
323
|
+
|
|
324
|
+
function diffCellHTML(diffPath: string): string {
|
|
325
|
+
if (!diffPath) {
|
|
326
|
+
return `<div class="no-diff">No diff image<br/>(dimension mismatch)</div>`;
|
|
327
|
+
}
|
|
328
|
+
return `<a href="${diffPath}" target="_blank">
|
|
329
|
+
<img src="${diffPath}" alt="Diff" />
|
|
330
|
+
</a>
|
|
331
|
+
<div class="label">Diff</div>`;
|
|
332
|
+
}
|
|
@@ -106,6 +106,13 @@
|
|
|
106
106
|
color: #666;
|
|
107
107
|
font-weight: 500;
|
|
108
108
|
}
|
|
109
|
+
.no-diff {
|
|
110
|
+
color: #999;
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
font-style: italic;
|
|
113
|
+
padding: 20px;
|
|
114
|
+
text-align: center;
|
|
115
|
+
}
|
|
109
116
|
.stats {
|
|
110
117
|
text-align: center;
|
|
111
118
|
color: #d73027;
|
|
@@ -128,7 +135,7 @@
|
|
|
128
135
|
<h1>🔴 Image Snapshot Failures</h1>
|
|
129
136
|
<div class="meta">
|
|
130
137
|
<strong>1</strong> test failed •
|
|
131
|
-
Generated:
|
|
138
|
+
Generated: 11/4/2025, 4:23:56 PM
|
|
132
139
|
</div>
|
|
133
140
|
<div class="update-hint">
|
|
134
141
|
💡 <strong>To update snapshots:</strong> Run <code>pnpm test -- -u</code> or <code>vitest -u</code>
|
|
@@ -153,20 +160,20 @@
|
|
|
153
160
|
<code>red-square</code>
|
|
154
161
|
</td>
|
|
155
162
|
<td class="image-cell">
|
|
156
|
-
<a href="
|
|
157
|
-
<img src="
|
|
163
|
+
<a href="src/test/fixtures/failing-snapshot/__image_snapshots__/red-square.png" target="_blank">
|
|
164
|
+
<img src="src/test/fixtures/failing-snapshot/__image_snapshots__/red-square.png" alt="Expected" />
|
|
158
165
|
</a>
|
|
159
166
|
<div class="label">Expected</div>
|
|
160
167
|
</td>
|
|
161
168
|
<td class="image-cell">
|
|
162
|
-
<a href="
|
|
163
|
-
<img src="
|
|
169
|
+
<a href="src/test/fixtures/failing-snapshot/__image_actual__/red-square.png" target="_blank">
|
|
170
|
+
<img src="src/test/fixtures/failing-snapshot/__image_actual__/red-square.png" alt="Actual" />
|
|
164
171
|
</a>
|
|
165
172
|
<div class="label">Actual</div>
|
|
166
173
|
</td>
|
|
167
174
|
<td class="image-cell">
|
|
168
|
-
<a href="
|
|
169
|
-
<img src="
|
|
175
|
+
<a href="src/test/fixtures/failing-snapshot/__image_diffs__/red-square.png" target="_blank">
|
|
176
|
+
<img src="src/test/fixtures/failing-snapshot/__image_diffs__/red-square.png" alt="Diff" />
|
|
170
177
|
</a>
|
|
171
178
|
<div class="label">Diff</div>
|
|
172
179
|
</td>
|
|
File without changes
|
|
File without changes
|