@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 +5 -4
- package/src/cli/generate.js +13 -3
- package/src/cli/report.js +108 -23
- package/src/common.js +26 -4
- package/src/createSteps.js +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rettangoli/vt",
|
|
3
|
-
"version": "0.0.
|
|
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
|
-
"
|
|
19
|
-
"
|
|
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
|
+
}
|
package/src/cli/generate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/createSteps.js
CHANGED
|
@@ -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) {
|