@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.
- package/docker/Dockerfile +21 -0
- package/docker/build-and-push.sh +16 -0
- package/package.json +1 -1
- package/src/cli/accept.js +39 -49
- package/src/cli/generate.js +4 -1
- package/src/cli/report.js +20 -0
- package/src/common.js +137 -71
|
@@ -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
package/src/cli/accept.js
CHANGED
|
@@ -1,36 +1,10 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import {
|
|
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
|
-
*
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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!
|
|
58
|
+
console.log('Done! Changes accepted.');
|
|
69
59
|
} catch (error) {
|
|
70
60
|
console.error('Error:', error.message);
|
|
71
61
|
process.exit(1);
|
package/src/cli/generate.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
267
|
-
await
|
|
267
|
+
// Check if custom screenshot function is available
|
|
268
|
+
const hasCustomScreenshot = await page.evaluate(() => typeof window.takeVtScreenshotBase64 === 'function');
|
|
268
269
|
|
|
269
|
-
if (
|
|
270
|
-
|
|
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
|
-
|
|
276
|
-
const
|
|
277
|
-
const
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
344
|
+
`
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (waitTime > 0) {
|
|
348
|
+
await page.waitForTimeout(waitTime);
|
|
349
|
+
}
|
|
350
|
+
const baseName = removeExtension(file.path);
|
|
325
351
|
|
|
326
|
-
|
|
327
|
-
|
|
352
|
+
const initialScreenshotPath = await takeAndSaveScreenshot(page, baseName);
|
|
353
|
+
console.log(`Screenshot saved: ${initialScreenshotPath}`);
|
|
328
354
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
355
|
+
const stepContext = {
|
|
356
|
+
baseName,
|
|
357
|
+
takeAndSaveScreenshot,
|
|
358
|
+
};
|
|
359
|
+
const stepsExecutor = createSteps(page, stepContext);
|
|
334
360
|
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
349
|
-
|
|
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
|
+
};
|