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/README.md CHANGED
@@ -169,6 +169,14 @@ interface MatchImageOptions {
169
169
  }
170
170
  ```
171
171
 
172
+ ### How Comparison Works
173
+
174
+ Image comparison uses [pixelmatch](https://github.com/mapbox/pixelmatch), which converts RGB to **YIQ color space** for perceptually-weighted comparison. YIQ separates luminance (Y) from chrominance (I, Q), making the comparison more aligned with human perception than raw RGB byte comparison.
175
+
176
+ The `threshold` option (0-1) controls sensitivity to color differences in YIQ space.
177
+
178
+ **Color space note**: Images are compared as raw RGBA bytes. If using `display-p3` (supported in WebGPU via `GPUCanvasConfiguration.colorSpace`), ensure both reference and actual images use the same color space.
179
+
172
180
  ## Examples
173
181
 
174
182
  ### WebGPU Shaders
@@ -0,0 +1,236 @@
1
+ import { exec } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import * as http from "node:http";
6
+
7
+ //#region src/DiffReport.ts
8
+ const templatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "templates");
9
+ /** Clear the diff report directory if it exists. */
10
+ async function clearDiffReport(reportDir) {
11
+ if (fs.existsSync(reportDir)) await fs.promises.rm(reportDir, { recursive: true });
12
+ }
13
+ /** Generate HTML diff report for image snapshot results. */
14
+ async function generateDiffReport(failures, config) {
15
+ const { autoOpen = false, reportDir, configRoot, liveReload = false } = config;
16
+ await clearDiffReport(reportDir);
17
+ await fs.promises.mkdir(reportDir, { recursive: true });
18
+ const cssSource = path.join(templatesDir, "report.css");
19
+ await fs.promises.copyFile(cssSource, path.join(reportDir, "report.css"));
20
+ if (liveReload) {
21
+ const jsSource = path.join(templatesDir, "live-reload.js");
22
+ await fs.promises.copyFile(jsSource, path.join(reportDir, "live-reload.js"));
23
+ }
24
+ const html = createReportHTML(failures.length > 0 ? await copyImagesToReport(failures, reportDir, configRoot) : [], liveReload);
25
+ const outputPath = path.join(reportDir, "index.html");
26
+ await fs.promises.writeFile(outputPath, html, "utf-8");
27
+ if (failures.length > 0 && !liveReload) console.log(`\n Image diff report: ${outputPath}`);
28
+ if (autoOpen) exec(`${{
29
+ darwin: "open",
30
+ win32: "start"
31
+ }[process.platform] ?? "xdg-open"} "${outputPath}"`);
32
+ }
33
+ /** Copy images to report dir, preserving directory structure relative to configRoot */
34
+ async function copyImagesToReport(failures, reportDir, configRoot) {
35
+ return Promise.all(failures.map(async (failure) => {
36
+ const { paths } = failure;
37
+ const copiedPaths = {
38
+ reference: await copyImage(paths.reference, reportDir, configRoot),
39
+ actual: await copyImage(paths.actual, reportDir, configRoot),
40
+ diff: await copyImage(paths.diff, reportDir, configRoot)
41
+ };
42
+ return {
43
+ ...failure,
44
+ paths: copiedPaths
45
+ };
46
+ }));
47
+ }
48
+ /** Copy single image to report dir, preserving directory structure relative to configRoot */
49
+ async function copyImage(sourcePath, reportDir, configRoot) {
50
+ if (!fs.existsSync(sourcePath)) return "";
51
+ const relativePath = path.relative(configRoot, sourcePath);
52
+ const destPath = path.join(reportDir, relativePath);
53
+ const destDir = path.dirname(destPath);
54
+ await fs.promises.mkdir(destDir, { recursive: true });
55
+ await fs.promises.copyFile(sourcePath, destPath);
56
+ return relativePath;
57
+ }
58
+ function loadTemplate(name) {
59
+ return fs.readFileSync(path.join(templatesDir, name), "utf-8");
60
+ }
61
+ /** Replace all {{key}} placeholders in template with values from data object. */
62
+ function renderTemplate(template, data) {
63
+ let result = template;
64
+ for (const [key, value] of Object.entries(data)) result = result.replaceAll(`{{${key}}}`, value);
65
+ const unreplaced = result.match(/\{\{(\w+)\}\}/g);
66
+ if (unreplaced) {
67
+ const keys = unreplaced.map((m) => m.slice(2, -2)).join(", ");
68
+ throw new Error(`Template has unreplaced placeholders: ${keys}`);
69
+ }
70
+ return result;
71
+ }
72
+ function createReportHTML(failures, liveReload) {
73
+ const timestamp = (/* @__PURE__ */ new Date()).toLocaleString();
74
+ const totalFailures = failures.length;
75
+ const script = liveReload ? `<script src="live-reload.js"><\/script>` : "";
76
+ if (totalFailures === 0) return renderTemplate(loadTemplate("report-success.hbs"), {
77
+ timestamp: escapeHtml(timestamp),
78
+ script
79
+ });
80
+ const rows = failures.map((failure) => createRowHTML(failure)).join("\n");
81
+ return renderTemplate(loadTemplate("report-failure.hbs"), {
82
+ totalFailures: String(totalFailures),
83
+ testPlural: totalFailures === 1 ? "test" : "tests",
84
+ timestamp: escapeHtml(timestamp),
85
+ rows,
86
+ script
87
+ });
88
+ }
89
+ function createRowHTML(failure) {
90
+ const { testName, snapshotName, comparison, paths } = failure;
91
+ const { mismatchedPixels, mismatchedPixelRatio } = comparison;
92
+ return `
93
+ <tr>
94
+ <td class="test-name">
95
+ <strong>${escapeHtml(testName)}</strong><br>
96
+ <code>${escapeHtml(snapshotName)}</code>
97
+ </td>
98
+ <td class="image-cell">
99
+ <a href="${paths.reference}" target="_blank">
100
+ <img src="${paths.reference}" alt="Expected" />
101
+ </a>
102
+ <div class="label">Expected</div>
103
+ </td>
104
+ <td class="image-cell">
105
+ <a href="${paths.actual}" target="_blank">
106
+ <img src="${paths.actual}" alt="Actual" />
107
+ </a>
108
+ <div class="label">Actual</div>
109
+ </td>
110
+ <td class="image-cell">
111
+ ${diffCellHTML(paths.diff)}
112
+ </td>
113
+ <td class="stats">
114
+ <div><strong>${mismatchedPixels}</strong> pixels</div>
115
+ <div><strong>${(mismatchedPixelRatio * 100).toFixed(2)}%</strong></div>
116
+ </td>
117
+ </tr>`;
118
+ }
119
+ function escapeHtml(text) {
120
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
121
+ }
122
+ function diffCellHTML(diffPath) {
123
+ if (!diffPath) return `<div class="no-diff">No diff image<br/>(dimension mismatch)</div>`;
124
+ return `<a href="${diffPath}" target="_blank">
125
+ <img src="${diffPath}" alt="Diff" />
126
+ </a>
127
+ <div class="label">Diff</div>`;
128
+ }
129
+
130
+ //#endregion
131
+ //#region src/ImageSnapshotReporter.ts
132
+ /** Vitest reporter that generates HTML diff reports for image snapshot failures */
133
+ var ImageSnapshotReporter = class {
134
+ failuresByFile = /* @__PURE__ */ new Map();
135
+ vitest;
136
+ reportPath;
137
+ autoOpen;
138
+ port;
139
+ serverStarted = false;
140
+ constructor(options = {}) {
141
+ this.reportPath = options.reportPath;
142
+ this.autoOpen = options.autoOpen ?? process.env.IMAGE_DIFF_AUTO_OPEN === "true";
143
+ this.port = options.port ?? 4343;
144
+ }
145
+ onInit(vitest) {
146
+ this.vitest = vitest;
147
+ if (this.port > 0) this.startServer();
148
+ }
149
+ startServer() {
150
+ const reportDir = this.resolveReportDir();
151
+ const server = http.createServer((req, res) => {
152
+ const url = req.url === "/" ? "/index.html" : req.url || "/index.html";
153
+ const filePath = path.join(reportDir, url);
154
+ fs.stat(filePath, (statErr, stats) => {
155
+ if (statErr) {
156
+ res.writeHead(404);
157
+ res.end("Not found");
158
+ return;
159
+ }
160
+ const ext = path.extname(filePath);
161
+ const contentType = ext === ".html" ? "text/html" : ext === ".css" ? "text/css" : ext === ".png" ? "image/png" : "application/octet-stream";
162
+ const headers = {
163
+ "Content-Type": contentType,
164
+ "Last-Modified": stats.mtime.toUTCString()
165
+ };
166
+ if (req.method === "HEAD") {
167
+ res.writeHead(200, headers);
168
+ res.end();
169
+ return;
170
+ }
171
+ fs.readFile(filePath, (err, data) => {
172
+ if (err) {
173
+ res.writeHead(500);
174
+ res.end("Error reading file");
175
+ return;
176
+ }
177
+ res.writeHead(200, headers);
178
+ res.end(data);
179
+ });
180
+ });
181
+ });
182
+ server.listen(this.port, () => {
183
+ this.serverStarted = true;
184
+ console.log(`\n Image diff report: http://localhost:${this.port}`);
185
+ });
186
+ server.unref();
187
+ }
188
+ onTestRunStart(specifications) {
189
+ for (const spec of specifications) this.failuresByFile.delete(spec.moduleId);
190
+ }
191
+ onTestCaseResult(testCase) {
192
+ const result = testCase.result();
193
+ if (result?.state !== "failed") return;
194
+ const meta = testCase.meta();
195
+ if (!meta.imageSnapshotFailure) return;
196
+ const error = result.errors?.[0];
197
+ const failure = captureFailure(testCase, meta.imageSnapshotFailure, error?.message || "");
198
+ const moduleId = testCase.module.moduleId;
199
+ const existing = this.failuresByFile.get(moduleId) || [];
200
+ this.failuresByFile.set(moduleId, [...existing, failure]);
201
+ }
202
+ async onTestRunEnd() {
203
+ await generateDiffReport([...this.failuresByFile.values()].flat(), {
204
+ autoOpen: this.autoOpen,
205
+ reportDir: this.resolveReportDir(),
206
+ configRoot: this.vitest.config.root,
207
+ liveReload: this.serverStarted
208
+ });
209
+ }
210
+ resolveReportDir() {
211
+ const configRoot = this.vitest.config.root;
212
+ if (!this.reportPath) return path.join(configRoot, "__image_diff_report__");
213
+ return path.isAbsolute(this.reportPath) ? this.reportPath : path.join(configRoot, this.reportPath);
214
+ }
215
+ };
216
+ function captureFailure(testCase, data, message) {
217
+ const snapshotName = data.actualPath.match(/([^/]+)\.png$/)?.[1] || "unknown";
218
+ return {
219
+ testName: testCase.fullName || testCase.name,
220
+ snapshotName,
221
+ comparison: {
222
+ pass: false,
223
+ message,
224
+ mismatchedPixels: data.mismatchedPixels,
225
+ mismatchedPixelRatio: data.mismatchedPixelRatio
226
+ },
227
+ paths: {
228
+ reference: data.expectedPath,
229
+ actual: data.actualPath,
230
+ diff: data.diffPath
231
+ }
232
+ };
233
+ }
234
+
235
+ //#endregion
236
+ export { clearDiffReport as n, generateDiffReport as r, ImageSnapshotReporter as t };
@@ -1,19 +1,25 @@
1
- import { Reporter, TestCase, Vitest } from "vitest/node";
1
+ import { Reporter, TestCase, TestSpecification, Vitest } from "vitest/node";
2
2
 
3
3
  //#region src/ImageSnapshotReporter.d.ts
4
4
  interface ImageSnapshotReporterOptions {
5
5
  /** Report directory (relative to config.root or absolute) */
6
6
  reportPath?: string;
7
7
  autoOpen?: boolean;
8
+ /** Port for live-reload server. Set to 0 to disable. Default: 4343 */
9
+ port?: number;
8
10
  }
9
11
  /** Vitest reporter that generates HTML diff reports for image snapshot failures */
10
12
  declare class ImageSnapshotReporter implements Reporter {
11
- private failures;
13
+ private failuresByFile;
12
14
  private vitest;
13
15
  private reportPath?;
14
16
  private autoOpen;
17
+ private port;
18
+ private serverStarted;
15
19
  constructor(options?: ImageSnapshotReporterOptions);
16
20
  onInit(vitest: Vitest): void;
21
+ private startServer;
22
+ onTestRunStart(specifications: ReadonlyArray<TestSpecification>): void;
17
23
  onTestCaseResult(testCase: TestCase): void;
18
24
  onTestRunEnd(): Promise<void>;
19
25
  private resolveReportDir;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { n as ImageSnapshotReporterOptions, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-BthcwBqW.js";
1
+ import { n as ImageSnapshotReporterOptions, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-CRXQpaQz.js";
2
2
 
3
3
  //#region src/ImageComparison.d.ts
4
4
  /** nodejs compatible interface to DOM ImageData */
@@ -48,8 +48,12 @@ interface DiffReportConfig {
48
48
  reportDir: string;
49
49
  /** Vitest config root for calculating relative paths when copying images */
50
50
  configRoot: string;
51
+ /** Enable live reload script in HTML. Default: false */
52
+ liveReload?: boolean;
51
53
  }
52
- /** Generate HTML diff report for all failed image snapshots. */
54
+ /** Clear the diff report directory if it exists. */
55
+ declare function clearDiffReport(reportDir: string): Promise<void>;
56
+ /** Generate HTML diff report for image snapshot results. */
53
57
  declare function generateDiffReport(failures: ImageSnapshotFailure[], config: DiffReportConfig): Promise<void>;
54
58
  //#endregion
55
59
  //#region src/ImageSnapshotMatcher.d.ts
@@ -97,4 +101,4 @@ declare module "vitest" {
97
101
  }
98
102
  }
99
103
  //#endregion
100
- export { ComparisonOptions, ComparisonResult, DiffReportConfig, ImageData, ImageSnapshotFailure, ImageSnapshotManager, ImageSnapshotReporter, ImageSnapshotReporterOptions, MatchImageOptions, SnapshotConfig, compareImages, generateDiffReport, imageMatcher, pngBuffer };
104
+ export { ComparisonOptions, ComparisonResult, DiffReportConfig, ImageData, ImageSnapshotFailure, ImageSnapshotManager, ImageSnapshotReporter, ImageSnapshotReporterOptions, MatchImageOptions, SnapshotConfig, clearDiffReport, compareImages, generateDiffReport, imageMatcher, pngBuffer };
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { n as generateDiffReport, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-CEBGVm2_.js";
1
+ import { n as clearDiffReport, r as generateDiffReport, t as ImageSnapshotReporter } from "./ImageSnapshotReporter-BdJsioWe.js";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import pixelmatch from "pixelmatch";
@@ -199,4 +199,4 @@ async function missingSnapshot(manager, actualBuffer, snapshotName) {
199
199
  }
200
200
 
201
201
  //#endregion
202
- export { ImageSnapshotManager, ImageSnapshotReporter, compareImages, generateDiffReport, imageMatcher, pngBuffer };
202
+ export { ImageSnapshotManager, ImageSnapshotReporter, clearDiffReport, compareImages, generateDiffReport, imageMatcher, pngBuffer };
@@ -1,2 +1,2 @@
1
- import { t as ImageSnapshotReporter } from "./ImageSnapshotReporter-BthcwBqW.js";
1
+ import { t as ImageSnapshotReporter } from "./ImageSnapshotReporter-CRXQpaQz.js";
2
2
  export { ImageSnapshotReporter as default };
package/dist/reporter.js CHANGED
@@ -1,4 +1,4 @@
1
- import { t as ImageSnapshotReporter } from "./ImageSnapshotReporter-CEBGVm2_.js";
1
+ import { t as ImageSnapshotReporter } from "./ImageSnapshotReporter-BdJsioWe.js";
2
2
 
3
3
  //#region src/reporter.ts
4
4
  var reporter_default = ImageSnapshotReporter;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vitest-image-snapshot",
3
- "version": "0.6.39",
3
+ "version": "0.6.40",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",