@rettangoli/vt 0.0.13 → 0.0.14

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.
@@ -0,0 +1,21 @@
1
+ FROM mcr.microsoft.com/playwright:v1.57.0-noble
2
+
3
+ # Install dependencies for Bun
4
+ RUN apt-get update && apt-get install -y unzip curl && rm -rf /var/lib/apt/lists/*
5
+
6
+ # Install Bun to system location directly (latest version)
7
+ RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash
8
+
9
+ # Install rtgl globally
10
+ RUN bun install -g rtgl@0.0.36
11
+
12
+ # Copy bun global packages to /usr/local/lib/rtgl (accessible to all users)
13
+ RUN mkdir -p /usr/local/lib/rtgl && \
14
+ cp -r /root/.bun/install/global /usr/local/lib/rtgl/ && \
15
+ chmod -R a+rx /usr/local/lib/rtgl/
16
+
17
+ # Create wrapper script that uses copied packages
18
+ RUN echo "#!/bin/sh" > /usr/local/bin/rtgl && \
19
+ echo "BUN_INSTALL_LOCKFILE=/usr/local/lib/rtgl/bun/install/cache/bun-install.lock" >> /usr/local/bin/rtgl && \
20
+ echo "exec bun /usr/local/lib/rtgl/global/node_modules/rtgl/cli.js \"\$@\"" >> /usr/local/bin/rtgl && \
21
+ chmod +x /usr/local/bin/rtgl
@@ -0,0 +1,16 @@
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ IMAGE_NAME="playwright-v1.57.0-rtgl-v0.0.36"
6
+ REGISTRY="${REGISTRY:-docker.io}"
7
+ REPO="${REPO:-han4wluc/rtgl}"
8
+
9
+ FULL_TAG="$REGISTRY/$REPO:$IMAGE_NAME"
10
+
11
+ docker build -t "$IMAGE_NAME" "$SCRIPT_DIR"
12
+ echo "Built image: $IMAGE_NAME"
13
+
14
+ docker tag "$IMAGE_NAME" "$FULL_TAG"
15
+ docker push "$FULL_TAG"
16
+ echo "Pushed: $FULL_TAG"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rettangoli/vt",
3
- "version": "0.0.13",
3
+ "version": "0.0.14",
4
4
  "description": "Rettangoli Visual Testing",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/cli/accept.js CHANGED
@@ -1,36 +1,10 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { rm, cp, mkdir } from 'node:fs/promises';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { cp, mkdir, rm } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
 
5
5
  /**
6
- * Recursively copy only WebP files from source to destination
7
- */
8
- async function copyWebpFiles(sourceDir, destDir) {
9
- if (!existsSync(sourceDir)) {
10
- return;
11
- }
12
-
13
- const items = readdirSync(sourceDir);
14
-
15
- for (const item of items) {
16
- const sourcePath = join(sourceDir, item);
17
- const destPath = join(destDir, item);
18
-
19
- if (statSync(sourcePath).isDirectory()) {
20
- // Recursively copy subdirectories
21
- await copyWebpFiles(sourcePath, destPath);
22
- } else if (item.endsWith('.webp')) {
23
- // Copy WebP files only
24
- await mkdir(dirname(destPath), { recursive: true });
25
- await cp(sourcePath, destPath);
26
- console.log(`Copied: ${sourcePath} -> ${destPath}`);
27
- }
28
- }
29
- }
30
-
31
- /**
32
- * Accepts candidate screenshots as the new reference by removing the existing reference
33
- * directory and copying the candidate directory to reference.
6
+ * Accepts candidate screenshots as the new reference by copying only files
7
+ * that have diffs according to report.json.
34
8
  */
35
9
  async function acceptReference(options = {}) {
36
10
  const {
@@ -40,32 +14,48 @@ async function acceptReference(options = {}) {
40
14
  const referenceDir = join(vtPath, "reference");
41
15
  const siteOutputPath = join(".rettangoli", "vt", "_site");
42
16
  const candidateDir = join(siteOutputPath, "candidate");
17
+ const jsonReportPath = join(".rettangoli", "vt", "report.json");
43
18
 
44
- console.log('Accepting candidate as new reference...');
45
-
46
- // Check if candidate directory exists
47
- if (!existsSync(candidateDir)) {
48
- console.error('Error: Candidate directory does not exist!');
19
+ // Check if report.json exists
20
+ if (!existsSync(jsonReportPath)) {
21
+ console.error('Error: report.json not found. Run "rtgl vt report" first.');
49
22
  process.exit(1);
50
23
  }
51
24
 
25
+ // Read report.json
26
+ const report = JSON.parse(readFileSync(jsonReportPath, 'utf8'));
27
+
28
+ if (report.items.length === 0) {
29
+ console.log('No differences found in report. Nothing to accept.');
30
+ return;
31
+ }
32
+
33
+ console.log(`Accepting ${report.items.length} changed files as new reference...`);
34
+
52
35
  try {
53
- // Remove reference directory if it exists
54
- if (existsSync(referenceDir)) {
55
- console.log('Removing existing reference directory...');
56
- await rm(referenceDir, { recursive: true, force: true });
57
- }
36
+ for (const item of report.items) {
37
+ // Skip items that only exist in reference (they should be deleted)
38
+ if (item.onlyInReference) {
39
+ const refPath = join(referenceDir, item.referencePath.replace('reference/', ''));
40
+ if (existsSync(refPath)) {
41
+ await rm(refPath);
42
+ console.log(`Removed: ${refPath}`);
43
+ }
44
+ continue;
45
+ }
58
46
 
59
- // Wait for 100ms to ensure the directory is removed
60
- await new Promise((resolve) => {
61
- setTimeout(resolve, 100);
62
- })
63
-
64
- // Copy only WebP files from candidate to reference
65
- console.log('Copying WebP files from candidate to reference...');
66
- await copyWebpFiles(candidateDir, referenceDir);
47
+ // Copy candidate to reference
48
+ const candidatePath = join(siteOutputPath, item.candidatePath);
49
+ const refPath = join(referenceDir, item.candidatePath.replace('candidate/', ''));
50
+
51
+ if (existsSync(candidatePath)) {
52
+ await mkdir(dirname(refPath), { recursive: true });
53
+ await cp(candidatePath, refPath);
54
+ console.log(`Copied: ${candidatePath} -> ${refPath}`);
55
+ }
56
+ }
67
57
 
68
- console.log('Done! New reference accepted.');
58
+ console.log('Done! Changes accepted.');
69
59
  } catch (error) {
70
60
  console.error('Error:', error.message);
71
61
  process.exit(1);
@@ -21,6 +21,8 @@ async function main(options) {
21
21
  vtPath = "./vt",
22
22
  screenshotWaitTime = 0,
23
23
  port = 3001,
24
+ waitEvent,
25
+ concurrency = 12,
24
26
  } = options;
25
27
 
26
28
  const specsPath = join(vtPath, "specs");
@@ -100,9 +102,10 @@ async function main(options) {
100
102
  filesToScreenshot,
101
103
  `http://localhost:${port}`,
102
104
  candidatePath,
103
- 24,
105
+ concurrency,
104
106
  screenshotWaitTime,
105
107
  configUrl,
108
+ waitEvent,
106
109
  );
107
110
  } finally {
108
111
  if (server) {
package/src/cli/report.js CHANGED
@@ -180,6 +180,7 @@ async function main(options = {}) {
180
180
  const siteReferenceDir = path.join(siteOutputPath, "reference");
181
181
  const templatePath = path.join(libraryTemplatesPath, "report.html");
182
182
  const outputPath = path.join(siteOutputPath, "report.html");
183
+ const jsonReportPath = path.join(".rettangoli", "vt", "report.json");
183
184
 
184
185
  console.log(`Comparison method: ${compareMethod}`);
185
186
  if (compareMethod === 'pixelmatch') {
@@ -324,6 +325,25 @@ async function main(options = {}) {
324
325
  templatePath,
325
326
  outputPath,
326
327
  });
328
+
329
+ // Write JSON report
330
+ const jsonReport = {
331
+ timestamp: new Date().toISOString(),
332
+ 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
+ };
344
+ fs.writeFileSync(jsonReportPath, JSON.stringify(jsonReport, null, 2));
345
+ console.log(`JSON report written to ${jsonReportPath}`);
346
+
327
347
  if(mismatchingItems.length > 0){
328
348
  console.error("Error: there are more than 0 mismatching item.")
329
349
  process.exit(1);
package/src/common.js CHANGED
@@ -247,8 +247,9 @@ async function takeScreenshots(
247
247
  serverUrl,
248
248
  screenshotsDir,
249
249
  concurrency = 8,
250
- waitTime = 0,
250
+ waitTime = 10,
251
251
  configUrl = undefined,
252
+ waitEvent = undefined,
252
253
  ) {
253
254
  // Ensure screenshots directory exists
254
255
  ensureDirectoryExists(screenshotsDir);
@@ -263,90 +264,155 @@ async function takeScreenshots(
263
264
  const screenshotPath = join(screenshotsDir, `${finalPath}.webp`);
264
265
  ensureDirectoryExists(dirname(screenshotPath));
265
266
 
266
- await page.screenshot({ path: tempPngPath, fullPage: true });
267
- await sharp(tempPngPath).webp({ quality: 85 }).toFile(screenshotPath);
267
+ // Check if custom screenshot function is available
268
+ const hasCustomScreenshot = await page.evaluate(() => typeof window.takeVtScreenshotBase64 === 'function');
268
269
 
269
- if (existsSync(tempPngPath)) {
270
- unlinkSync(tempPngPath);
270
+ if (hasCustomScreenshot) {
271
+ // Use custom screenshot function (useful for canvas-based apps)
272
+ let base64Data = await page.evaluate(() => window.takeVtScreenshotBase64());
273
+ // Strip data URL prefix if present (e.g., "data:image/png;base64,")
274
+ if (base64Data.includes(',')) {
275
+ base64Data = base64Data.split(',')[1];
276
+ }
277
+ const pngBuffer = Buffer.from(base64Data, 'base64');
278
+ await sharp(pngBuffer).webp({ quality: 85 }).toFile(screenshotPath);
279
+ } else {
280
+ // Use Playwright's built-in screenshot
281
+ await page.screenshot({ path: tempPngPath, fullPage: true });
282
+ await sharp(tempPngPath).webp({ quality: 85 }).toFile(screenshotPath);
283
+
284
+ if (existsSync(tempPngPath)) {
285
+ unlinkSync(tempPngPath);
286
+ }
271
287
  }
272
288
  return screenshotPath;
273
289
  };
274
290
 
275
- try {
276
- const files = [...generatedFiles];
277
- const total = files.length;
278
- let completed = 0;
291
+ const processFile = async (file, browser, serverUrl, screenshotsDir, waitTime, configUrl, waitEvent, takeAndSaveScreenshot) => {
292
+ const context = await browser.newContext();
293
+ const page = await context.newPage();
279
294
 
280
- // Process files in batches based on concurrency
281
- while (files.length > 0) {
282
- const batch = files.splice(0, concurrency);
283
- const batchPromises = batch.map(async (file) => {
284
- // Create a new context and page for each file (for parallelism)
285
- const context = await browser.newContext();
286
- const page = await context.newPage();
287
-
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
+ try {
296
+ const envVars = {};
297
+ for (const [key, value] of Object.entries(process.env)) {
298
+ if (key.startsWith('RTGL_VT_')) {
299
+ envVars[key] = value;
300
+ }
301
+ }
295
302
 
296
- if (Object.keys(envVars).length > 0) {
297
- await page.addInitScript((vars) => {
298
- Object.assign(window, vars);
299
- }, envVars);
300
- }
303
+ if (Object.keys(envVars).length > 0) {
304
+ await page.addInitScript((vars) => {
305
+ Object.assign(window, vars);
306
+ }, envVars);
307
+ }
308
+
309
+ // If using custom wait event, set up listener before navigation
310
+ if (waitEvent) {
311
+ await page.addInitScript((eventName) => {
312
+ window.__vtReadyFired = false;
313
+ window.addEventListener(eventName, () => {
314
+ window.__vtReadyFired = true;
315
+ }, { once: true });
316
+ }, waitEvent);
317
+ }
318
+
319
+ const frontMatterUrl = file.frontMatter?.url;
320
+ const constructedUrl = convertToHtmlExtension(`${serverUrl}/candidate/${file.path.replace(/\\/g, "/")}`);
321
+ const url = frontMatterUrl ?? configUrl ?? constructedUrl;
322
+ const fileUrl = url.startsWith("http") ? url : new URL(url, serverUrl).href;
323
+
324
+ console.log(`Navigating to ${fileUrl}`);
325
+
326
+ if (waitEvent) {
327
+ // Navigate and wait for custom event
328
+ await page.goto(fileUrl, { waitUntil: "load" });
329
+ console.log(`Waiting for custom event: ${waitEvent}`);
330
+ await page.waitForFunction(() => window.__vtReadyFired === true, { timeout: 30000 });
331
+ } else {
332
+ // Default: wait for network idle
333
+ await page.goto(fileUrl, { waitUntil: "networkidle" });
334
+ }
301
335
 
302
- const frontMatterUrl = file.frontMatter?.url;
303
- const constructedUrl = convertToHtmlExtension(`${serverUrl}/candidate/${file.path.replace(/\\/g, "/")}`);
304
- const url = frontMatterUrl ?? configUrl ?? constructedUrl;
305
- const fileUrl = url.startsWith("http") ? url : new URL(url, serverUrl).href;
306
-
307
- console.log(`Navigating to ${fileUrl}`);
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
-
321
- if (waitTime > 0) {
322
- await page.waitForTimeout(waitTime);
336
+ // Normalize font rendering for consistent screenshots
337
+ await page.addStyleTag({
338
+ content: `
339
+ * {
340
+ -webkit-font-smoothing: antialiased !important;
341
+ -moz-osx-font-smoothing: grayscale !important;
342
+ text-rendering: geometricPrecision !important;
323
343
  }
324
- const baseName = removeExtension(file.path);
344
+ `
345
+ });
346
+
347
+ if (waitTime > 0) {
348
+ await page.waitForTimeout(waitTime);
349
+ }
350
+ const baseName = removeExtension(file.path);
325
351
 
326
- const initialScreenshotPath = await takeAndSaveScreenshot(page, baseName);
327
- console.log(`Screenshot saved: ${initialScreenshotPath}`);
352
+ const initialScreenshotPath = await takeAndSaveScreenshot(page, baseName);
353
+ console.log(`Screenshot saved: ${initialScreenshotPath}`);
328
354
 
329
- const stepContext = {
330
- baseName,
331
- takeAndSaveScreenshot,
332
- };
333
- const stepsExecutor = createSteps(page, stepContext);
355
+ const stepContext = {
356
+ baseName,
357
+ takeAndSaveScreenshot,
358
+ };
359
+ const stepsExecutor = createSteps(page, stepContext);
334
360
 
335
- for (const step of file.frontMatter?.steps || []) {
336
- await stepsExecutor.executeStep(step);
361
+ for (const step of file.frontMatter?.steps || []) {
362
+ await stepsExecutor.executeStep(step);
363
+ }
364
+ return { success: true, file };
365
+ } finally {
366
+ await context.close();
367
+ }
368
+ };
369
+
370
+ try {
371
+ const total = generatedFiles.length;
372
+ let completed = 0;
373
+ let filesToProcess = [...generatedFiles];
374
+ const maxRetries = 3;
375
+
376
+ for (let attempt = 1; attempt <= maxRetries && filesToProcess.length > 0; attempt++) {
377
+ if (attempt > 1) {
378
+ console.log(`\nRetry attempt ${attempt}/${maxRetries} for ${filesToProcess.length} failed files...`);
379
+ }
380
+
381
+ const failedFiles = [];
382
+
383
+ // Process files in batches based on concurrency
384
+ while (filesToProcess.length > 0) {
385
+ const batch = filesToProcess.splice(0, concurrency);
386
+ const batchPromises = batch.map((file) =>
387
+ processFile(file, browser, serverUrl, screenshotsDir, waitTime, configUrl, waitEvent, takeAndSaveScreenshot)
388
+ .then(() => {
389
+ completed++;
390
+ console.log(`Finished processing ${file.path} (${completed}/${total})`);
391
+ return { success: true, file };
392
+ })
393
+ .catch((error) => {
394
+ console.error(`Error processing ${file.path}:`, error.message);
395
+ return { success: false, file };
396
+ })
397
+ );
398
+
399
+ const results = await Promise.allSettled(batchPromises);
400
+ for (const result of results) {
401
+ if (result.status === 'fulfilled' && !result.value.success) {
402
+ failedFiles.push(result.value.file);
403
+ } else if (result.status === 'rejected') {
404
+ // This shouldn't happen since we catch errors above, but just in case
405
+ console.error('Unexpected rejection:', result.reason);
337
406
  }
338
- completed++;
339
- console.log(`Finished processing ${file.path} (${completed}/${total})`);
340
- } catch (error) {
341
- console.error(`Error processing instructions for ${file.path}:`, error);
342
- } finally {
343
- // Close the context when done
344
- await context.close();
345
407
  }
346
- });
408
+ }
409
+
410
+ filesToProcess = failedFiles;
411
+ }
347
412
 
348
- // Wait for current batch to complete before processing next batch
349
- await Promise.all(batchPromises);
413
+ if (filesToProcess.length > 0) {
414
+ console.error(`\nFailed to process ${filesToProcess.length} files after ${maxRetries} attempts:`);
415
+ filesToProcess.forEach((file) => console.error(` - ${file.path}`));
350
416
  }
351
417
  } catch (error) {
352
418
  console.error("Error taking screenshots:", error);
@@ -449,4 +515,4 @@ export {
449
515
  takeScreenshots,
450
516
  generateOverview,
451
517
  readYaml,
452
- };
518
+ };