@rettangoli/vt 0.0.14 → 1.0.0-rc2

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/cli/report.js CHANGED
@@ -1,23 +1,25 @@
1
1
  import fs from "fs";
2
2
  import path from "path";
3
3
  import crypto from "crypto";
4
- import { Liquid } from "liquidjs";
5
4
  import { cp } from "node:fs/promises";
6
5
  import pixelmatch from "pixelmatch";
7
6
  import sharp from "sharp";
7
+ import { readYaml } from "../common.js";
8
+ import { validateVtConfig } from "../validation.js";
9
+ import { resolveReportOptions } from "./report-options.js";
10
+ import {
11
+ buildAllRelativePaths,
12
+ toMismatchingItems,
13
+ buildJsonReport,
14
+ } from "../report/report-model.js";
15
+ import { renderHtmlReport } from "../report/report-render.js";
16
+ import {
17
+ filterRelativeScreenshotPathsBySelectors,
18
+ hasSelectors,
19
+ } from "../selector-filter.js";
20
+
21
+ const libraryTemplatesPath = new URL("./templates", import.meta.url).pathname;
8
22
 
9
- const libraryTemplatesPath = new URL('./templates', import.meta.url).pathname;
10
-
11
- // Initialize Liquid engine
12
- const engine = new Liquid();
13
-
14
- // Add custom filter to convert string to lowercase and replace spaces with hyphens
15
- engine.registerFilter("slug", (value) => {
16
- if (typeof value !== "string") return "";
17
- return value.toLowerCase().replace(/\s+/g, "-");
18
- });
19
-
20
- // Recursively get all files in a directory
21
23
  function getAllFiles(dir, fileList = []) {
22
24
  const files = fs.readdirSync(dir);
23
25
 
@@ -33,58 +35,20 @@ function getAllFiles(dir, fileList = []) {
33
35
  return fileList;
34
36
  }
35
37
 
36
- function extractParts(p) {
37
- const dir = path.dirname(p);
38
- const filename = path.basename(p, '.webp');
39
- const lastHyphenIndex = filename.lastIndexOf('-');
40
-
41
- if (lastHyphenIndex > -1) {
42
- const suffix = filename.substring(lastHyphenIndex + 1);
43
- const number = parseInt(suffix);
44
-
45
- if (!isNaN(number) && String(number) === suffix) {
46
- const name = path.join(dir, filename.substring(0, lastHyphenIndex));
47
- return { name, number };
48
- }
49
- }
50
- // -1 is for the first file (as it will result in the first index when sorting)
51
- return { name: path.join(dir, filename), number: -1 };
52
- }
53
-
54
- function sortPaths(a, b) {
55
- const partsA = extractParts(a);
56
- const partsB = extractParts(b);
57
-
58
- if (partsA.name < partsB.name) return -1;
59
- if (partsA.name > partsB.name) return 1;
60
-
61
- return partsA.number - partsB.number;
62
- }
63
-
64
38
  async function calculateImageHash(imagePath) {
65
- try {
66
- const imageBuffer = fs.readFileSync(imagePath);
67
- const hash = crypto.createHash('md5').update(imageBuffer).digest('hex');
68
- return hash;
69
- } catch (error) {
70
- console.error(`Error calculating hash for ${imagePath}:`, error);
71
- return null;
72
- }
39
+ const imageBuffer = fs.readFileSync(imagePath);
40
+ const hash = crypto.createHash("md5").update(imageBuffer).digest("hex");
41
+ return hash;
73
42
  }
74
43
 
75
44
  async function compareImagesMd5(artifactPath, goldPath) {
76
45
  try {
77
46
  const artifactHash = await calculateImageHash(artifactPath);
78
47
  const goldHash = await calculateImageHash(goldPath);
79
-
80
- if (artifactHash === null || goldHash === null) {
81
- return { equal: false, error: true };
82
- }
83
-
84
48
  return { equal: artifactHash === goldHash, error: false };
85
49
  } catch (error) {
86
50
  console.error("Error comparing images:", error);
87
- return { equal: false, error: true };
51
+ return { equal: false, error: true, message: error.message };
88
52
  }
89
53
  }
90
54
 
@@ -92,7 +56,6 @@ async function compareImagesPixelmatch(artifactPath, goldPath, diffPath, options
92
56
  const { colorThreshold = 0.1, diffThreshold = 0.3 } = options;
93
57
 
94
58
  try {
95
- // Load images and convert to raw RGBA using sharp
96
59
  const [artifactData, goldData] = await Promise.all([
97
60
  sharp(artifactPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
98
61
  sharp(goldPath).ensureAlpha().raw().toBuffer({ resolveWithObject: true }),
@@ -103,12 +66,10 @@ async function compareImagesPixelmatch(artifactPath, goldPath, diffPath, options
103
66
  const goldHeight = goldData.info.height;
104
67
  const totalPixels = width * height;
105
68
 
106
- // If dimensions don't match, images are different
107
69
  if (width !== goldWidth || height !== goldHeight) {
108
70
  return { equal: false, error: false, diffPixels: -1, totalPixels, similarity: 0 };
109
71
  }
110
72
 
111
- // Create diff image buffer
112
73
  const diffBuffer = Buffer.alloc(totalPixels * 4);
113
74
 
114
75
  const diffPixels = pixelmatch(
@@ -117,14 +78,13 @@ async function compareImagesPixelmatch(artifactPath, goldPath, diffPath, options
117
78
  diffBuffer,
118
79
  width,
119
80
  height,
120
- { threshold: colorThreshold }
81
+ { threshold: colorThreshold },
121
82
  );
122
83
 
123
84
  const diffPercent = (diffPixels / totalPixels) * 100;
124
85
  const similarity = (100 - diffPercent).toFixed(2);
125
86
  const equal = diffPercent < diffThreshold;
126
87
 
127
- // Save diff image if not equal
128
88
  if (!equal && diffPath) {
129
89
  fs.mkdirSync(path.dirname(diffPath), { recursive: true });
130
90
  await sharp(diffBuffer, { raw: { width, height, channels: 4 } })
@@ -135,43 +95,39 @@ async function compareImagesPixelmatch(artifactPath, goldPath, diffPath, options
135
95
  return { equal, error: false, diffPixels, totalPixels, similarity };
136
96
  } catch (error) {
137
97
  console.error("Error comparing images with pixelmatch:", error);
138
- return { equal: false, error: true };
98
+ return { equal: false, error: true, message: error.message };
139
99
  }
140
100
  }
141
101
 
142
- async function compareImages(artifactPath, goldPath, method = 'pixelmatch', diffPath = null, options = {}) {
143
- if (method === 'md5') {
102
+ async function compareImages(artifactPath, goldPath, method = "pixelmatch", diffPath = null, options = {}) {
103
+ if (method === "md5") {
144
104
  return compareImagesMd5(artifactPath, goldPath);
145
105
  }
146
106
  return compareImagesPixelmatch(artifactPath, goldPath, diffPath, options);
147
107
  }
148
108
 
149
- async function generateReport({ results, templatePath, outputPath }) {
109
+ async function main(options = {}) {
110
+ const mainConfigPath = "rettangoli.config.yaml";
111
+ let mainConfig;
150
112
  try {
151
- // Read the template file
152
- const templateContent = fs.readFileSync(templatePath, "utf8");
153
-
154
- // Render the template with the results data
155
- const renderedHtml = await engine.parseAndRender(templateContent, {
156
- files: results,
157
- });
158
-
159
- // Write the rendered HTML to the output file
160
- fs.writeFileSync(outputPath, renderedHtml);
161
-
162
- console.log(`Report generated successfully at ${outputPath}`);
113
+ mainConfig = await readYaml(mainConfigPath);
163
114
  } catch (error) {
164
- console.error("Error generating report:", error);
115
+ throw new Error(`Unable to read "${mainConfigPath}": ${error.message}`, { cause: error });
165
116
  }
166
- }
167
117
 
168
- async function main(options = {}) {
118
+ const vtConfig = mainConfig?.vt;
119
+ if (!vtConfig) {
120
+ throw new Error(`Invalid "${mainConfigPath}": missing required "vt" section.`);
121
+ }
122
+
123
+ const configData = validateVtConfig(vtConfig, mainConfigPath);
169
124
  const {
170
- vtPath = "./vt",
171
- compareMethod = 'pixelmatch',
172
- colorThreshold = 0.1,
173
- diffThreshold = 0.3,
174
- } = options;
125
+ vtPath,
126
+ compareMethod,
127
+ colorThreshold,
128
+ diffThreshold,
129
+ selectors,
130
+ } = resolveReportOptions(options, configData);
175
131
 
176
132
  const siteOutputPath = path.join(".rettangoli", "vt", "_site");
177
133
  const candidateDir = path.join(siteOutputPath, "candidate");
@@ -183,7 +139,7 @@ async function main(options = {}) {
183
139
  const jsonReportPath = path.join(".rettangoli", "vt", "report.json");
184
140
 
185
141
  console.log(`Comparison method: ${compareMethod}`);
186
- if (compareMethod === 'pixelmatch') {
142
+ if (compareMethod === "pixelmatch") {
187
143
  console.log(` color threshold: ${colorThreshold}, diff threshold: ${diffThreshold}%`);
188
144
  }
189
145
 
@@ -192,52 +148,56 @@ async function main(options = {}) {
192
148
  fs.mkdirSync(originalReferenceDir, { recursive: true });
193
149
  }
194
150
 
195
- // Create diff directory for diffs
196
- if (compareMethod === 'pixelmatch' && !fs.existsSync(diffDir)) {
151
+ if (compareMethod === "pixelmatch" && !fs.existsSync(diffDir)) {
197
152
  fs.mkdirSync(diffDir, { recursive: true });
198
153
  }
199
154
 
200
- // Copy reference directory to _site for web access
201
155
  if (fs.existsSync(originalReferenceDir)) {
202
156
  console.log("Copying reference directory to _site...");
203
157
  await cp(originalReferenceDir, siteReferenceDir, { recursive: true });
204
158
  }
205
159
 
206
160
  try {
207
- // Get all WebP files recursively (only compare screenshots, not HTML)
208
- const candidateFiles = getAllFiles(candidateDir).filter(file => file.endsWith('.webp'));
209
- const referenceFiles = getAllFiles(originalReferenceDir).filter(file => file.endsWith('.webp'));
161
+ if (!fs.existsSync(candidateDir)) {
162
+ throw new Error(`Candidate screenshots directory not found: "${candidateDir}". Run "rtgl vt generate" first.`);
163
+ }
164
+
165
+ const candidateFiles = getAllFiles(candidateDir).filter((file) => file.endsWith(".webp"));
166
+ const referenceFiles = getAllFiles(originalReferenceDir).filter((file) => file.endsWith(".webp"));
210
167
 
211
168
  console.log("Candidate Screenshots:", candidateFiles.length);
212
169
  console.log("Reference Screenshots:", referenceFiles.length);
213
170
 
214
171
  const results = [];
172
+ const comparisonErrors = [];
215
173
 
216
- // Get relative paths for comparison
217
174
  const candidateRelativePaths = candidateFiles.map((file) =>
218
- path.relative(candidateDir, file)
175
+ path.relative(candidateDir, file),
219
176
  );
220
177
  const referenceRelativePaths = referenceFiles.map((file) =>
221
- path.relative(originalReferenceDir, file)
178
+ path.relative(originalReferenceDir, file),
222
179
  );
223
180
 
224
- // Get all unique paths from both directories
225
- const allPaths = [
226
- ...new Set([...candidateRelativePaths, ...referenceRelativePaths]),
227
- ];
228
-
229
- allPaths.sort(sortPaths);
181
+ const allPaths = buildAllRelativePaths(candidateRelativePaths, referenceRelativePaths);
182
+ const scopedPaths = filterRelativeScreenshotPathsBySelectors(
183
+ allPaths,
184
+ selectors,
185
+ configData.sections,
186
+ );
187
+ if (hasSelectors(selectors)) {
188
+ const excludedCount = allPaths.length - scopedPaths.length;
189
+ console.log(`Selector scope: ${scopedPaths.length} image(s) selected, ${excludedCount} excluded.`);
190
+ }
230
191
 
231
- for (const relativePath of allPaths) {
192
+ for (const relativePath of scopedPaths) {
232
193
  const candidatePath = path.join(candidateDir, relativePath);
233
194
  const referencePath = path.join(originalReferenceDir, relativePath);
234
195
  const siteReferencePath = path.join(siteReferenceDir, relativePath);
235
- const diffPath = path.join(diffDir, relativePath.replace('.webp', '-diff.png'));
196
+ const diffPath = path.join(diffDir, relativePath.replace(".webp", "-diff.png"));
236
197
 
237
198
  const candidateExists = fs.existsSync(candidatePath);
238
199
  const referenceExists = fs.existsSync(referencePath);
239
200
 
240
- // Skip if neither file exists (shouldn't happen, but just in case)
241
201
  if (!candidateExists && !referenceExists) continue;
242
202
 
243
203
  let equal = true;
@@ -245,9 +205,7 @@ async function main(options = {}) {
245
205
  let similarity = null;
246
206
  let diffPixels = null;
247
207
 
248
- // Compare images if both exist
249
208
  if (candidateExists && referenceExists) {
250
- // Ensure diff directory exists
251
209
  const diffDirPath = path.dirname(diffPath);
252
210
  if (!fs.existsSync(diffDirPath)) {
253
211
  fs.mkdirSync(diffDirPath, { recursive: true });
@@ -258,20 +216,26 @@ async function main(options = {}) {
258
216
  referencePath,
259
217
  compareMethod,
260
218
  diffPath,
261
- { colorThreshold, diffThreshold }
219
+ { colorThreshold, diffThreshold },
262
220
  );
221
+ if (comparison.error) {
222
+ comparisonErrors.push(
223
+ `${relativePath}: ${comparison.message || "unknown comparison error"}`,
224
+ );
225
+ continue;
226
+ }
263
227
  equal = comparison.equal;
264
228
  error = comparison.error;
265
229
  similarity = comparison.similarity;
266
230
  diffPixels = comparison.diffPixels;
267
231
  } else {
268
- equal = false; // If one file is missing, they're not equal
232
+ equal = false;
269
233
  }
270
234
 
271
235
  if (!error) {
272
236
  results.push({
273
237
  candidatePath: candidateExists ? candidatePath : null,
274
- referencePath: referenceExists ? siteReferencePath : null, // Use site reference path for HTML report
238
+ referencePath: referenceExists ? siteReferencePath : null,
275
239
  path: relativePath,
276
240
  equal: candidateExists && referenceExists ? equal : false,
277
241
  similarity,
@@ -282,28 +246,15 @@ async function main(options = {}) {
282
246
  }
283
247
  }
284
248
 
285
- const mismatchingItems = results
286
- .filter(
287
- (result) =>
288
- !result.equal || result.onlyInCandidate || result.onlyInReference
289
- )
290
- .map((result) => {
291
- return {
292
- candidatePath: result.candidatePath
293
- ? path.relative(siteOutputPath, result.candidatePath)
294
- : null,
295
- referencePath: result.referencePath
296
- ? path.relative(siteOutputPath, result.referencePath)
297
- : null,
298
- equal: result.equal,
299
- similarity: result.similarity,
300
- diffPixels: result.diffPixels,
301
- onlyInCandidate: result.onlyInCandidate,
302
- onlyInReference: result.onlyInReference,
303
- };
304
- });
249
+ if (comparisonErrors.length > 0) {
250
+ throw new Error(
251
+ `Image comparison failed for ${comparisonErrors.length} file(s):\n- ${comparisonErrors.join("\n- ")}`,
252
+ );
253
+ }
254
+
255
+ const mismatchingItems = toMismatchingItems(results, siteOutputPath);
305
256
  console.log("Mismatching Items (JSON):");
306
- mismatchingItems.forEach(item => {
257
+ mismatchingItems.forEach((item) => {
307
258
  const logData = {
308
259
  candidatePath: item.candidatePath,
309
260
  referencePath: item.referencePath,
@@ -314,42 +265,28 @@ async function main(options = {}) {
314
265
  console.log(JSON.stringify(logData, null, 2));
315
266
  });
316
267
 
317
- // Summary at the end
318
- console.log(`\nSummary:`);
268
+ console.log("\nSummary:");
319
269
  console.log(`Total images: ${results.length}`);
320
270
  console.log(`Mismatched images: ${mismatchingItems.length}`);
321
271
 
322
- // Generate HTML report
323
- await generateReport({
272
+ await renderHtmlReport({
324
273
  results: mismatchingItems,
325
274
  templatePath,
326
275
  outputPath,
327
276
  });
328
277
 
329
- // Write JSON report
330
- const jsonReport = {
331
- timestamp: new Date().toISOString(),
278
+ const jsonReport = buildJsonReport({
332
279
  total: results.length,
333
- mismatched: mismatchingItems.length,
334
- items: mismatchingItems.map(item => ({
335
- path: item.candidatePath || item.referencePath,
336
- candidatePath: item.candidatePath,
337
- referencePath: item.referencePath,
338
- equal: item.equal,
339
- similarity: item.similarity,
340
- onlyInCandidate: item.onlyInCandidate,
341
- onlyInReference: item.onlyInReference,
342
- })),
343
- };
280
+ mismatchingItems,
281
+ });
344
282
  fs.writeFileSync(jsonReportPath, JSON.stringify(jsonReport, null, 2));
345
283
  console.log(`JSON report written to ${jsonReportPath}`);
346
284
 
347
- if(mismatchingItems.length > 0){
348
- console.error("Error: there are more than 0 mismatching item.")
349
- process.exit(1);
285
+ if (mismatchingItems.length > 0) {
286
+ throw new Error(`Visual differences found in ${mismatchingItems.length} file(s).`);
350
287
  }
351
288
  } catch (error) {
352
- console.error("Error reading directories:", error);
289
+ throw new Error(`Error generating VT report: ${error.message}`, { cause: error });
353
290
  }
354
291
  }
355
292
 
@@ -52,7 +52,7 @@
52
52
  </rtgl-sidebar>
53
53
  </rtgl-view>
54
54
 
55
- <rtgl-view id="content" h="100vh" w="f" flex="1" p="lg" g="lg" style="flex-wrap: nowrap;" sv ah="c">
55
+ <rtgl-view id="content" h="100vh" w="1fg" p="lg" g="lg" style="flex-wrap: nowrap;" sv ah="c">
56
56
  <rtgl-view w="f" g="xl">
57
57
  <rtgl-text s="h2">{{ currentSection.title }} </rtgl-text>
58
58
  {% for file in files %}
@@ -106,4 +106,4 @@
106
106
  </rtgl-view>
107
107
  </body>
108
108
 
109
- </html>
109
+ </html>
@@ -30,7 +30,7 @@
30
30
  <rtgl-view p="sm">
31
31
  </rtgl-view>
32
32
  </rtgl-view>
33
- <rtgl-view h="100vh" w="f" flex="1" bgc="su" p="lg" g="lg" style="flex-wrap: nowrap;" sv>
33
+ <rtgl-view h="100vh" w="1fg" bgc="su" p="lg" g="lg" style="flex-wrap: nowrap;" sv>
34
34
  <rtgl-view>
35
35
  <rtgl-text s="tl">Rettangoli Components</rtgl-text>
36
36
  <rtgl-text s="sm">{{ files.length }} components failed</rtgl-text>
@@ -38,11 +38,11 @@
38
38
  {% for file in files %}
39
39
  <rtgl-view w="f">
40
40
  <rtgl-view w="f" d="h" g="lg">
41
- <rtgl-view flex="1">
41
+ <rtgl-view w="1fg">
42
42
  <rtgl-text>{{ file.referencePath }}</rtgl-text>
43
43
  <img width="100%" src="/{{ file.referencePath }}">
44
44
  </rtgl-view>
45
- <rtgl-view flex="1">
45
+ <rtgl-view w="1fg">
46
46
  <rtgl-text>{{ file.candidatePath }}</rtgl-text>
47
47
  <img width="100%" src="/{{ file.candidatePath }}">
48
48
  </rtgl-view>
@@ -53,4 +53,4 @@
53
53
  </rtgl-view>
54
54
  </body>
55
55
 
56
- </html>
56
+ </html>