vitest-image-snapshot 0.6.39 → 0.6.40

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/src/DiffReport.ts CHANGED
@@ -1,8 +1,14 @@
1
1
  import { exec } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import type { ComparisonResult } from "./ImageComparison.ts";
5
6
 
7
+ const templatesDir = path.join(
8
+ path.dirname(fileURLToPath(import.meta.url)),
9
+ "templates",
10
+ );
11
+
6
12
  /** Failed image snapshot with comparison results and file paths. */
7
13
  export interface ImageSnapshotFailure {
8
14
  testName: string;
@@ -23,38 +29,61 @@ export interface DiffReportConfig {
23
29
  reportDir: string;
24
30
  /** Vitest config root for calculating relative paths when copying images */
25
31
  configRoot: string;
32
+ /** Enable live reload script in HTML. Default: false */
33
+ liveReload?: boolean;
26
34
  }
27
35
 
28
- /** Generate HTML diff report for all failed image snapshots. */
36
+ /** Clear the diff report directory if it exists. */
37
+ export async function clearDiffReport(reportDir: string): Promise<void> {
38
+ if (fs.existsSync(reportDir)) {
39
+ await fs.promises.rm(reportDir, { recursive: true });
40
+ }
41
+ }
42
+
43
+ /** Generate HTML diff report for image snapshot results. */
29
44
  export async function generateDiffReport(
30
45
  failures: ImageSnapshotFailure[],
31
46
  config: DiffReportConfig,
32
47
  ): Promise<void> {
33
- const { autoOpen = false, reportDir, configRoot } = config;
34
-
35
- if (failures.length === 0) return;
48
+ const {
49
+ autoOpen = false,
50
+ reportDir,
51
+ configRoot,
52
+ liveReload = false,
53
+ } = config;
36
54
 
55
+ // Clear old report before generating new one to remove stale images
56
+ await clearDiffReport(reportDir);
37
57
  await fs.promises.mkdir(reportDir, { recursive: true });
38
58
 
39
- const withCopiedImages = await copyImagesToReport(
40
- failures,
41
- reportDir,
42
- configRoot,
43
- );
44
- const html = createReportHTML(withCopiedImages);
59
+ const cssSource = path.join(templatesDir, "report.css");
60
+ await fs.promises.copyFile(cssSource, path.join(reportDir, "report.css"));
61
+
62
+ if (liveReload) {
63
+ const jsSource = path.join(templatesDir, "live-reload.js");
64
+ await fs.promises.copyFile(
65
+ jsSource,
66
+ path.join(reportDir, "live-reload.js"),
67
+ );
68
+ }
69
+
70
+ const withCopiedImages =
71
+ failures.length > 0
72
+ ? await copyImagesToReport(failures, reportDir, configRoot)
73
+ : [];
74
+ const html = createReportHTML(withCopiedImages, liveReload);
45
75
  const outputPath = path.join(reportDir, "index.html");
46
76
 
47
77
  await fs.promises.writeFile(outputPath, html, "utf-8");
48
78
 
49
- console.log(`\nšŸ“Š Image diff report: ${outputPath}`);
79
+ // Only log file path if not using live reload server (which logs its own URL)
80
+ if (failures.length > 0 && !liveReload) {
81
+ console.log(`\n Image diff report: ${outputPath}`);
82
+ }
50
83
 
51
84
  if (autoOpen) {
52
- const cmd =
53
- process.platform === "darwin"
54
- ? "open"
55
- : process.platform === "win32"
56
- ? "start"
57
- : "xdg-open";
85
+ const commands: Record<string, string> = { darwin: "open", win32: "start" };
86
+ const cmd = commands[process.platform] ?? "xdg-open";
58
87
  exec(`${cmd} "${outputPath}"`);
59
88
  }
60
89
  }
@@ -67,28 +96,17 @@ async function copyImagesToReport(
67
96
  ): Promise<ImageSnapshotFailure[]> {
68
97
  return Promise.all(
69
98
  failures.map(async failure => {
70
- const copiedPaths = await copyImageSet(
71
- failure.paths,
72
- reportDir,
73
- configRoot,
74
- );
99
+ const { paths } = failure;
100
+ const copiedPaths = {
101
+ reference: await copyImage(paths.reference, reportDir, configRoot),
102
+ actual: await copyImage(paths.actual, reportDir, configRoot),
103
+ diff: await copyImage(paths.diff, reportDir, configRoot),
104
+ };
75
105
  return { ...failure, paths: copiedPaths };
76
106
  }),
77
107
  );
78
108
  }
79
109
 
80
- async function copyImageSet(
81
- paths: { reference: string; actual: string; diff: string },
82
- reportDir: string,
83
- configRoot: string,
84
- ): Promise<{ reference: string; actual: string; diff: string }> {
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
-
92
110
  /** Copy single image to report dir, preserving directory structure relative to configRoot */
93
111
  async function copyImage(
94
112
  sourcePath: string,
@@ -106,16 +124,61 @@ async function copyImage(
106
124
  return relativePath;
107
125
  }
108
126
 
109
- function createReportHTML(failures: ImageSnapshotFailure[]): string {
127
+ function loadTemplate(name: string): string {
128
+ return fs.readFileSync(path.join(templatesDir, name), "utf-8");
129
+ }
130
+
131
+ /** Replace all {{key}} placeholders in template with values from data object. */
132
+ function renderTemplate(
133
+ template: string,
134
+ data: Record<string, string>,
135
+ ): string {
136
+ let result = template;
137
+ for (const [key, value] of Object.entries(data)) {
138
+ result = result.replaceAll(`{{${key}}}`, value);
139
+ }
140
+
141
+ // Check for any unreplaced placeholders
142
+ const unreplaced = result.match(/\{\{(\w+)\}\}/g);
143
+ if (unreplaced) {
144
+ const keys = unreplaced.map(m => m.slice(2, -2)).join(", ");
145
+ throw new Error(`Template has unreplaced placeholders: ${keys}`);
146
+ }
147
+
148
+ return result;
149
+ }
150
+
151
+ function createReportHTML(
152
+ failures: ImageSnapshotFailure[],
153
+ liveReload: boolean,
154
+ ): string {
110
155
  const timestamp = new Date().toLocaleString();
111
156
  const totalFailures = failures.length;
157
+ const script = liveReload ? `<script src="live-reload.js"></script>` : "";
112
158
 
113
- const rows = failures
114
- .map(failure => {
115
- const { testName, snapshotName, comparison, paths } = failure;
116
- const { mismatchedPixels, mismatchedPixelRatio } = comparison;
159
+ if (totalFailures === 0) {
160
+ return renderTemplate(loadTemplate("report-success.hbs"), {
161
+ timestamp: escapeHtml(timestamp),
162
+ script,
163
+ });
164
+ }
165
+
166
+ const rows = failures.map(failure => createRowHTML(failure)).join("\n");
167
+
168
+ return renderTemplate(loadTemplate("report-failure.hbs"), {
169
+ totalFailures: String(totalFailures),
170
+ testPlural: totalFailures === 1 ? "test" : "tests",
171
+ timestamp: escapeHtml(timestamp),
172
+ rows,
173
+ script,
174
+ });
175
+ }
176
+
177
+ function createRowHTML(failure: ImageSnapshotFailure): string {
178
+ const { testName, snapshotName, comparison, paths } = failure;
179
+ const { mismatchedPixels, mismatchedPixelRatio } = comparison;
117
180
 
118
- return `
181
+ return `
119
182
  <tr>
120
183
  <td class="test-name">
121
184
  <strong>${escapeHtml(testName)}</strong><br>
@@ -140,176 +203,7 @@ function createReportHTML(failures: ImageSnapshotFailure[]): string {
140
203
  <div><strong>${mismatchedPixels}</strong> pixels</div>
141
204
  <div><strong>${(mismatchedPixelRatio * 100).toFixed(2)}%</strong></div>
142
205
  </td>
143
- </tr>
144
- `;
145
- })
146
- .join("\n");
147
-
148
- return `<!DOCTYPE html>
149
- <html lang="en">
150
- <head>
151
- <meta charset="UTF-8">
152
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
153
- <title>Image Snapshot Failures - ${totalFailures} failed</title>
154
- <style>
155
- * { margin: 0; padding: 0; box-sizing: border-box; }
156
- body {
157
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
158
- padding: 20px;
159
- background: #f5f5f5;
160
- }
161
- header {
162
- background: white;
163
- padding: 20px;
164
- border-radius: 8px;
165
- margin-bottom: 20px;
166
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
167
- }
168
- h1 {
169
- color: #d73027;
170
- margin-bottom: 10px;
171
- }
172
- .meta {
173
- color: #666;
174
- font-size: 14px;
175
- }
176
- .update-hint {
177
- background: #fff3cd;
178
- border: 1px solid #ffc107;
179
- border-radius: 4px;
180
- padding: 12px;
181
- margin: 15px 0;
182
- }
183
- .update-hint code {
184
- background: #f8f9fa;
185
- padding: 2px 6px;
186
- border-radius: 3px;
187
- font-family: 'Monaco', 'Courier New', monospace;
188
- }
189
- table {
190
- width: 100%;
191
- border-collapse: collapse;
192
- background: white;
193
- border-radius: 8px;
194
- overflow: hidden;
195
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
196
- }
197
- th, td {
198
- padding: 15px;
199
- text-align: left;
200
- border-bottom: 1px solid #e0e0e0;
201
- }
202
- th {
203
- background: #f8f9fa;
204
- font-weight: 600;
205
- color: #333;
206
- text-align: center;
207
- }
208
- tr:last-child td {
209
- border-bottom: none;
210
- }
211
- tr:hover {
212
- background: #fafafa;
213
- }
214
- .test-name {
215
- min-width: 200px;
216
- vertical-align: top;
217
- }
218
- .test-name strong {
219
- color: #333;
220
- }
221
- .test-name code {
222
- color: #666;
223
- font-size: 12px;
224
- background: #f5f5f5;
225
- padding: 2px 6px;
226
- border-radius: 3px;
227
- }
228
- .image-cell {
229
- text-align: center;
230
- vertical-align: top;
231
- width: 25%;
232
- }
233
- .image-cell a {
234
- display: block;
235
- text-decoration: none;
236
- }
237
- .image-cell img {
238
- max-width: 100%;
239
- height: auto;
240
- max-height: 300px;
241
- border: 1px solid #ddd;
242
- border-radius: 4px;
243
- cursor: pointer;
244
- transition: transform 0.2s;
245
- }
246
- .image-cell img:hover {
247
- transform: scale(1.02);
248
- box-shadow: 0 4px 12px rgba(0,0,0,0.15);
249
- }
250
- .label {
251
- margin-top: 8px;
252
- font-size: 12px;
253
- color: #666;
254
- font-weight: 500;
255
- }
256
- .no-diff {
257
- color: #999;
258
- font-size: 13px;
259
- font-style: italic;
260
- padding: 20px;
261
- text-align: center;
262
- }
263
- .stats {
264
- text-align: center;
265
- color: #d73027;
266
- font-size: 14px;
267
- vertical-align: top;
268
- }
269
- .stats div {
270
- margin: 5px 0;
271
- }
272
- footer {
273
- margin-top: 20px;
274
- text-align: center;
275
- color: #999;
276
- font-size: 12px;
277
- }
278
- </style>
279
- </head>
280
- <body>
281
- <header>
282
- <h1>šŸ”“ Image Snapshot Failures</h1>
283
- <div class="meta">
284
- <strong>${totalFailures}</strong> test${totalFailures === 1 ? "" : "s"} failed •
285
- Generated: ${escapeHtml(timestamp)}
286
- </div>
287
- <div class="update-hint">
288
- šŸ’” <strong>To update snapshots:</strong> Run <code>pnpm test -- -u</code> or <code>vitest -u</code>
289
- </div>
290
- </header>
291
-
292
- <table>
293
- <thead>
294
- <tr>
295
- <th>Test</th>
296
- <th>Expected</th>
297
- <th>Actual</th>
298
- <th>Diff</th>
299
- <th>Mismatch</th>
300
- </tr>
301
- </thead>
302
- <tbody>
303
- ${rows}
304
- </tbody>
305
- </table>
306
-
307
- <footer>
308
- Click images to view full size •
309
- Diff images highlight mismatched pixels in yellow/red
310
- </footer>
311
- </body>
312
- </html>`;
206
+ </tr>`;
313
207
  }
314
208
 
315
209
  function escapeHtml(text: string): string {
@@ -1,8 +1,15 @@
1
+ import * as fs from "node:fs";
2
+ import * as http from "node:http";
1
3
  import * as path from "node:path";
2
- import type { Reporter, TestCase, Vitest } from "vitest/node";
4
+ import type {
5
+ Reporter,
6
+ TestCase,
7
+ TestSpecification,
8
+ Vitest,
9
+ } from "vitest/node";
3
10
  import { generateDiffReport, type ImageSnapshotFailure } from "./DiffReport.ts";
4
11
 
5
- /** metadata saved at failure for future report */
12
+ /** Metadata captured when image snapshot test fails, used to generate HTML report. */
6
13
  interface ImageSnapshotFailureData {
7
14
  actualPath: string;
8
15
  expectedPath: string;
@@ -15,23 +22,92 @@ export interface ImageSnapshotReporterOptions {
15
22
  /** Report directory (relative to config.root or absolute) */
16
23
  reportPath?: string;
17
24
  autoOpen?: boolean;
25
+ /** Port for live-reload server. Set to 0 to disable. Default: 4343 */
26
+ port?: number;
18
27
  }
19
28
 
20
29
  /** Vitest reporter that generates HTML diff reports for image snapshot failures */
21
30
  export class ImageSnapshotReporter implements Reporter {
22
- private failures: ImageSnapshotFailure[] = [];
31
+ private failuresByFile = new Map<string, ImageSnapshotFailure[]>();
23
32
  private vitest!: Vitest;
24
33
  private reportPath?: string;
25
34
  private autoOpen: boolean;
35
+ private port: number;
36
+ private serverStarted = false;
26
37
 
27
38
  constructor(options: ImageSnapshotReporterOptions = {}) {
28
39
  this.reportPath = options.reportPath;
29
40
  this.autoOpen =
30
41
  options.autoOpen ?? process.env.IMAGE_DIFF_AUTO_OPEN === "true";
42
+ this.port = options.port ?? 4343;
31
43
  }
32
44
 
33
45
  onInit(vitest: Vitest) {
34
46
  this.vitest = vitest;
47
+ if (this.port > 0) {
48
+ this.startServer();
49
+ }
50
+ }
51
+
52
+ private startServer() {
53
+ const reportDir = this.resolveReportDir();
54
+ const server = http.createServer((req, res) => {
55
+ const url = req.url === "/" ? "/index.html" : req.url || "/index.html";
56
+ const filePath = path.join(reportDir, url);
57
+
58
+ fs.stat(filePath, (statErr, stats) => {
59
+ if (statErr) {
60
+ res.writeHead(404);
61
+ res.end("Not found");
62
+ return;
63
+ }
64
+
65
+ const ext = path.extname(filePath);
66
+ const contentType =
67
+ ext === ".html"
68
+ ? "text/html"
69
+ : ext === ".css"
70
+ ? "text/css"
71
+ : ext === ".png"
72
+ ? "image/png"
73
+ : "application/octet-stream";
74
+ const headers = {
75
+ "Content-Type": contentType,
76
+ "Last-Modified": stats.mtime.toUTCString(),
77
+ };
78
+
79
+ // For HEAD requests (used by live reload), just send headers
80
+ if (req.method === "HEAD") {
81
+ res.writeHead(200, headers);
82
+ res.end();
83
+ return;
84
+ }
85
+
86
+ fs.readFile(filePath, (err, data) => {
87
+ if (err) {
88
+ res.writeHead(500);
89
+ res.end("Error reading file");
90
+ return;
91
+ }
92
+ res.writeHead(200, headers);
93
+ res.end(data);
94
+ });
95
+ });
96
+ });
97
+
98
+ server.listen(this.port, () => {
99
+ this.serverStarted = true;
100
+ console.log(`\n Image diff report: http://localhost:${this.port}`);
101
+ });
102
+
103
+ server.unref(); // Don't keep process alive just for this server
104
+ }
105
+
106
+ onTestRunStart(specifications: ReadonlyArray<TestSpecification>) {
107
+ // Clear failures only for files that are about to run
108
+ for (const spec of specifications) {
109
+ this.failuresByFile.delete(spec.moduleId);
110
+ }
35
111
  }
36
112
 
37
113
  onTestCaseResult(testCase: TestCase) {
@@ -49,17 +125,18 @@ export class ImageSnapshotReporter implements Reporter {
49
125
  meta.imageSnapshotFailure,
50
126
  error?.message || "",
51
127
  );
52
- this.failures.push(failure);
128
+ const moduleId = testCase.module.moduleId;
129
+ const existing = this.failuresByFile.get(moduleId) || [];
130
+ this.failuresByFile.set(moduleId, [...existing, failure]);
53
131
  }
54
132
 
55
133
  async onTestRunEnd() {
56
- if (this.failures.length === 0) return;
57
-
58
- const reportDir = this.resolveReportDir();
59
- await generateDiffReport(this.failures, {
134
+ const allFailures = [...this.failuresByFile.values()].flat();
135
+ await generateDiffReport(allFailures, {
60
136
  autoOpen: this.autoOpen,
61
- reportDir,
137
+ reportDir: this.resolveReportDir(),
62
138
  configRoot: this.vitest.config.root,
139
+ liveReload: this.serverStarted,
63
140
  });
64
141
  }
65
142
 
@@ -0,0 +1,9 @@
1
+ let lastModified;
2
+ setInterval(async () => {
3
+ try {
4
+ const res = await fetch(location.href, { method: "HEAD" });
5
+ const modified = res.headers.get("last-modified");
6
+ if (lastModified && modified !== lastModified) location.reload();
7
+ lastModified = modified;
8
+ } catch {}
9
+ }, 500);
@@ -0,0 +1,42 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Image Snapshots - {{totalFailures}} failed</title>
7
+ <link rel="stylesheet" href="report.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>Image Snapshot Failures</h1>
12
+ <div class="meta">
13
+ <strong>{{totalFailures}}</strong> {{testPlural}} failed |
14
+ Generated: {{timestamp}}
15
+ </div>
16
+ <div class="update-hint">
17
+ <strong>To update snapshots:</strong> Run <code>vitest -u</code>
18
+ </div>
19
+ </header>
20
+
21
+ <table>
22
+ <thead>
23
+ <tr>
24
+ <th>Test</th>
25
+ <th>Expected</th>
26
+ <th>Actual</th>
27
+ <th>Diff</th>
28
+ <th>Mismatch</th>
29
+ </tr>
30
+ </thead>
31
+ <tbody>
32
+ {{rows}}
33
+ </tbody>
34
+ </table>
35
+
36
+ <footer>
37
+ Click images to view full size |
38
+ Diff images highlight mismatched pixels in yellow/red
39
+ </footer>
40
+ {{script}}
41
+ </body>
42
+ </html>
@@ -0,0 +1,19 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Image Snapshots - All Passing</title>
8
+ <link rel="stylesheet" href="report.css">
9
+ </head>
10
+
11
+ <body>
12
+ <header class="success">
13
+ <h1>All Image Snapshots Passing</h1>
14
+ <div class="meta">Generated: {{timestamp}}</div>
15
+ </header>
16
+ {{script}}
17
+ </body>
18
+
19
+ </html>