@rettangoli/vt 0.0.11 → 0.0.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -15,8 +15,9 @@
15
15
  "commander": "^13.1.0",
16
16
  "js-yaml": "^4.1.0",
17
17
  "liquidjs": "^10.21.0",
18
- "playwright": "^1.52.0",
19
- "sharp": "^0.33.0",
18
+ "pixelmatch": "^7.1.0",
19
+ "playwright": "1.57.0",
20
+ "sharp": "^0.34.5",
20
21
  "shiki": "^3.3.0"
21
22
  }
22
- }
23
+ }
@@ -74,7 +74,7 @@ async function main(options) {
74
74
  templateConfig,
75
75
  );
76
76
 
77
- // Generate overview page
77
+ // Generate overview page (includes all files, skipped or not)
78
78
  generateOverview(
79
79
  generatedFiles,
80
80
  indexTemplatePath,
@@ -82,12 +82,22 @@ async function main(options) {
82
82
  configData,
83
83
  );
84
84
 
85
- // Take screenshots
85
+ // Take screenshots (only for non-skipped files)
86
86
  if (!skipScreenshots) {
87
+ // Filter out files with skipScreenshot: true in frontmatter
88
+ const filesToScreenshot = generatedFiles.filter(
89
+ (file) => !file.frontMatter?.skipScreenshot
90
+ );
91
+
92
+ const skippedCount = generatedFiles.length - filesToScreenshot.length;
93
+ if (skippedCount > 0) {
94
+ console.log(`Skipping screenshots for ${skippedCount} files`);
95
+ }
96
+
87
97
  const server = configUrl ? null : startWebServer(siteOutputPath, vtPath, port);
88
98
  try {
89
99
  await takeScreenshots(
90
- generatedFiles,
100
+ filesToScreenshot,
91
101
  `http://localhost:${port}`,
92
102
  candidatePath,
93
103
  24,
package/src/cli/report.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
- import sharp from "sharp";
5
4
  import { Liquid } from "liquidjs";
6
5
  import { cp } from "node:fs/promises";
6
+ import pixelmatch from "pixelmatch";
7
+ import sharp from "sharp";
7
8
 
8
9
  const libraryTemplatesPath = new URL('./templates', import.meta.url).pathname;
9
10
 
@@ -71,35 +72,80 @@ async function calculateImageHash(imagePath) {
71
72
  }
72
73
  }
73
74
 
74
- async function compareImages(artifactPath, goldPath) {
75
+ async function compareImagesMd5(artifactPath, goldPath) {
75
76
  try {
76
77
  const artifactHash = await calculateImageHash(artifactPath);
77
78
  const goldHash = await calculateImageHash(goldPath);
78
79
 
79
80
  if (artifactHash === null || goldHash === null) {
80
- return {
81
- equal: false,
82
- error: true,
83
- };
81
+ return { equal: false, error: true };
84
82
  }
85
83
 
86
- const equal = artifactHash === goldHash;
87
-
88
- return {
89
- equal,
90
- error: false,
91
- };
84
+ return { equal: artifactHash === goldHash, error: false };
92
85
  } catch (error) {
93
86
  console.error("Error comparing images:", error);
94
- return {
95
- equal: false,
96
- error: true,
97
- diffBounds: null,
98
- diffClusters: null
99
- };
87
+ return { equal: false, error: true };
100
88
  }
101
89
  }
102
90
 
91
+ async function compareImagesPixelmatch(artifactPath, goldPath, diffPath, options = {}) {
92
+ const { colorThreshold = 0.1, diffThreshold = 0.3 } = options;
93
+
94
+ try {
95
+ // Load images and convert to raw RGBA using sharp
96
+ const [artifactData, goldData] = await Promise.all([
97
+ sharp(artifactPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
98
+ sharp(goldPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
99
+ ]);
100
+
101
+ const { width, height } = artifactData.info;
102
+ const goldWidth = goldData.info.width;
103
+ const goldHeight = goldData.info.height;
104
+ const totalPixels = width * height;
105
+
106
+ // If dimensions don't match, images are different
107
+ if (width !== goldWidth || height !== goldHeight) {
108
+ return { equal: false, error: false, diffPixels: -1, totalPixels, similarity: 0 };
109
+ }
110
+
111
+ // Create diff image buffer
112
+ const diffBuffer = Buffer.alloc(totalPixels * 4);
113
+
114
+ const diffPixels = pixelmatch(
115
+ artifactData.data,
116
+ goldData.data,
117
+ diffBuffer,
118
+ width,
119
+ height,
120
+ { threshold: colorThreshold }
121
+ );
122
+
123
+ const diffPercent = (diffPixels / totalPixels) * 100;
124
+ const similarity = (100 - diffPercent).toFixed(2);
125
+ const equal = diffPercent < diffThreshold;
126
+
127
+ // Save diff image if not equal
128
+ if (!equal && diffPath) {
129
+ fs.mkdirSync(path.dirname(diffPath), { recursive: true });
130
+ await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
131
+ .png()
132
+ .toFile(diffPath);
133
+ }
134
+
135
+ return { equal, error: false, diffPixels, totalPixels, similarity };
136
+ } catch (error) {
137
+ console.error("Error comparing images with pixelmatch:", error);
138
+ return { equal: false, error: true };
139
+ }
140
+ }
141
+
142
+ async function compareImages(artifactPath, goldPath, method = 'pixelmatch', diffPath = null, options = {}) {
143
+ if (method === 'md5') {
144
+ return compareImagesMd5(artifactPath, goldPath);
145
+ }
146
+ return compareImagesPixelmatch(artifactPath, goldPath, diffPath, options);
147
+ }
148
+
103
149
  async function generateReport({ results, templatePath, outputPath }) {
104
150
  try {
105
151
  // Read the template file
@@ -120,20 +166,36 @@ async function generateReport({ results, templatePath, outputPath }) {
120
166
  }
121
167
 
122
168
  async function main(options = {}) {
123
- const { vtPath = "./vt" } = options;
169
+ const {
170
+ vtPath = "./vt",
171
+ compareMethod = 'pixelmatch',
172
+ colorThreshold = 0.1,
173
+ diffThreshold = 0.3,
174
+ } = options;
124
175
 
125
176
  const siteOutputPath = path.join(".rettangoli", "vt", "_site");
126
177
  const candidateDir = path.join(siteOutputPath, "candidate");
178
+ const diffDir = path.join(siteOutputPath, "diff");
127
179
  const originalReferenceDir = path.join(vtPath, "reference");
128
180
  const siteReferenceDir = path.join(siteOutputPath, "reference");
129
181
  const templatePath = path.join(libraryTemplatesPath, "report.html");
130
182
  const outputPath = path.join(siteOutputPath, "report.html");
131
183
 
184
+ console.log(`Comparison method: ${compareMethod}`);
185
+ if (compareMethod === 'pixelmatch') {
186
+ console.log(` color threshold: ${colorThreshold}, diff threshold: ${diffThreshold}%`);
187
+ }
188
+
132
189
  if (!fs.existsSync(originalReferenceDir)) {
133
190
  console.log("Reference directory does not exist, creating it...");
134
191
  fs.mkdirSync(originalReferenceDir, { recursive: true });
135
192
  }
136
193
 
194
+ // Create diff directory for diffs
195
+ if (compareMethod === 'pixelmatch' && !fs.existsSync(diffDir)) {
196
+ fs.mkdirSync(diffDir, { recursive: true });
197
+ }
198
+
137
199
  // Copy reference directory to _site for web access
138
200
  if (fs.existsSync(originalReferenceDir)) {
139
201
  console.log("Copying reference directory to _site...");
@@ -169,6 +231,7 @@ async function main(options = {}) {
169
231
  const candidatePath = path.join(candidateDir, relativePath);
170
232
  const referencePath = path.join(originalReferenceDir, relativePath);
171
233
  const siteReferencePath = path.join(siteReferenceDir, relativePath);
234
+ const diffPath = path.join(diffDir, relativePath.replace('.webp', '-diff.png'));
172
235
 
173
236
  const candidateExists = fs.existsSync(candidatePath);
174
237
  const referenceExists = fs.existsSync(referencePath);
@@ -178,12 +241,28 @@ async function main(options = {}) {
178
241
 
179
242
  let equal = true;
180
243
  let error = false;
244
+ let similarity = null;
245
+ let diffPixels = null;
181
246
 
182
247
  // Compare images if both exist
183
248
  if (candidateExists && referenceExists) {
184
- const comparison = await compareImages(candidatePath, referencePath);
249
+ // Ensure diff directory exists
250
+ const diffDirPath = path.dirname(diffPath);
251
+ if (!fs.existsSync(diffDirPath)) {
252
+ fs.mkdirSync(diffDirPath, { recursive: true });
253
+ }
254
+
255
+ const comparison = await compareImages(
256
+ candidatePath,
257
+ referencePath,
258
+ compareMethod,
259
+ diffPath,
260
+ { colorThreshold, diffThreshold }
261
+ );
185
262
  equal = comparison.equal;
186
263
  error = comparison.error;
264
+ similarity = comparison.similarity;
265
+ diffPixels = comparison.diffPixels;
187
266
  } else {
188
267
  equal = false; // If one file is missing, they're not equal
189
268
  }
@@ -194,6 +273,8 @@ async function main(options = {}) {
194
273
  referencePath: referenceExists ? siteReferencePath : null, // Use site reference path for HTML report
195
274
  path: relativePath,
196
275
  equal: candidateExists && referenceExists ? equal : false,
276
+ similarity,
277
+ diffPixels,
197
278
  onlyInCandidate: candidateExists && !referenceExists,
198
279
  onlyInReference: !candidateExists && referenceExists,
199
280
  });
@@ -214,6 +295,8 @@ async function main(options = {}) {
214
295
  ? path.relative(siteOutputPath, result.referencePath)
215
296
  : null,
216
297
  equal: result.equal,
298
+ similarity: result.similarity,
299
+ diffPixels: result.diffPixels,
217
300
  onlyInCandidate: result.onlyInCandidate,
218
301
  onlyInReference: result.onlyInReference,
219
302
  };
@@ -224,22 +307,24 @@ async function main(options = {}) {
224
307
  candidatePath: item.candidatePath,
225
308
  referencePath: item.referencePath,
226
309
  equal: item.equal,
310
+ similarity: item.similarity ? `${item.similarity}%` : null,
311
+ diffPixels: item.diffPixels,
227
312
  };
228
313
  console.log(JSON.stringify(logData, null, 2));
229
314
  });
230
-
315
+
231
316
  // Summary at the end
232
317
  console.log(`\nSummary:`);
233
318
  console.log(`Total images: ${results.length}`);
234
319
  console.log(`Mismatched images: ${mismatchingItems.length}`);
235
-
320
+
236
321
  // Generate HTML report
237
322
  await generateReport({
238
323
  results: mismatchingItems,
239
324
  templatePath,
240
325
  outputPath,
241
326
  });
242
- if(mismatchingItems.length > 0){
327
+ if(mismatchingItems.length > 0){
243
328
  console.error("Error: there are more than 0 mismatching item.")
244
329
  process.exit(1);
245
330
  }
package/src/common.js CHANGED
@@ -284,18 +284,40 @@ async function takeScreenshots(
284
284
  // Create a new context and page for each file (for parallelism)
285
285
  const context = await browser.newContext();
286
286
  const page = await context.newPage();
287
- let screenshotIndex = 0;
288
287
 
289
288
  try {
289
+ const envVars = {};
290
+ for (const [key, value] of Object.entries(process.env)) {
291
+ if (key.startsWith('RTGL_VT_')) {
292
+ envVars[key] = value;
293
+ }
294
+ }
295
+
296
+ if (Object.keys(envVars).length > 0) {
297
+ await page.addInitScript((vars) => {
298
+ Object.assign(window, vars);
299
+ }, envVars);
300
+ }
301
+
290
302
  const frontMatterUrl = file.frontMatter?.url;
291
- const constructedUrl = convertToHtmlExtension(
292
- `${serverUrl}/candidate/${file.path.replace(/\\/g, "/")}`,
293
- );
303
+ const constructedUrl = convertToHtmlExtension(`${serverUrl}/candidate/${file.path.replace(/\\/g, "/")}`);
294
304
  const url = frontMatterUrl ?? configUrl ?? constructedUrl;
295
305
  const fileUrl = url.startsWith("http") ? url : new URL(url, serverUrl).href;
296
306
 
297
307
  console.log(`Navigating to ${fileUrl}`);
298
308
  await page.goto(fileUrl, { waitUntil: "networkidle" });
309
+
310
+ // Normalize font rendering for consistent screenshots
311
+ await page.addStyleTag({
312
+ content: `
313
+ * {
314
+ -webkit-font-smoothing: antialiased !important;
315
+ -moz-osx-font-smoothing: grayscale !important;
316
+ text-rendering: geometricPrecision !important;
317
+ }
318
+ `
319
+ });
320
+
299
321
  if (waitTime > 0) {
300
322
  await page.waitForTimeout(waitTime);
301
323
  }
@@ -22,6 +22,16 @@ async function customEvent(page, args) {
22
22
 
23
23
  async function goto(page, args) {
24
24
  await page.goto(args[0], { waitUntil: "networkidle" });
25
+ // Normalize font rendering for consistent screenshots
26
+ await page.addStyleTag({
27
+ content: `
28
+ * {
29
+ -webkit-font-smoothing: antialiased !important;
30
+ -moz-osx-font-smoothing: grayscale !important;
31
+ text-rendering: geometricPrecision !important;
32
+ }
33
+ `
34
+ });
25
35
  }
26
36
 
27
37
  async function keypress(page, args) {