@rettangoli/vt 0.0.12 → 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 +5 -4
- package/src/cli/accept.js +39 -49
- package/src/cli/generate.js +4 -1
- package/src/cli/report.js +125 -19
- package/src/common.js +136 -58
- package/src/createSteps.js +10 -0
|
@@ -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.
|
|
3
|
+
"version": "0.0.14",
|
|
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/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
|
@@ -3,6 +3,8 @@ import path from "path";
|
|
|
3
3
|
import crypto from "crypto";
|
|
4
4
|
import { Liquid } from "liquidjs";
|
|
5
5
|
import { cp } from "node:fs/promises";
|
|
6
|
+
import pixelmatch from "pixelmatch";
|
|
7
|
+
import sharp from "sharp";
|
|
6
8
|
|
|
7
9
|
const libraryTemplatesPath = new URL('./templates', import.meta.url).pathname;
|
|
8
10
|
|
|
@@ -70,35 +72,80 @@ async function calculateImageHash(imagePath) {
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
async function
|
|
75
|
+
async function compareImagesMd5(artifactPath, goldPath) {
|
|
74
76
|
try {
|
|
75
77
|
const artifactHash = await calculateImageHash(artifactPath);
|
|
76
78
|
const goldHash = await calculateImageHash(goldPath);
|
|
77
79
|
|
|
78
80
|
if (artifactHash === null || goldHash === null) {
|
|
79
|
-
return {
|
|
80
|
-
equal: false,
|
|
81
|
-
error: true,
|
|
82
|
-
};
|
|
81
|
+
return { equal: false, error: true };
|
|
83
82
|
}
|
|
84
83
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return {
|
|
88
|
-
equal,
|
|
89
|
-
error: false,
|
|
90
|
-
};
|
|
84
|
+
return { equal: artifactHash === goldHash, error: false };
|
|
91
85
|
} catch (error) {
|
|
92
86
|
console.error("Error comparing images:", error);
|
|
93
|
-
return {
|
|
94
|
-
equal: false,
|
|
95
|
-
error: true,
|
|
96
|
-
diffBounds: null,
|
|
97
|
-
diffClusters: null
|
|
98
|
-
};
|
|
87
|
+
return { equal: false, error: true };
|
|
99
88
|
}
|
|
100
89
|
}
|
|
101
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
|
+
|
|
102
149
|
async function generateReport({ results, templatePath, outputPath }) {
|
|
103
150
|
try {
|
|
104
151
|
// Read the template file
|
|
@@ -119,20 +166,37 @@ async function generateReport({ results, templatePath, outputPath }) {
|
|
|
119
166
|
}
|
|
120
167
|
|
|
121
168
|
async function main(options = {}) {
|
|
122
|
-
const {
|
|
169
|
+
const {
|
|
170
|
+
vtPath = "./vt",
|
|
171
|
+
compareMethod = 'pixelmatch',
|
|
172
|
+
colorThreshold = 0.1,
|
|
173
|
+
diffThreshold = 0.3,
|
|
174
|
+
} = options;
|
|
123
175
|
|
|
124
176
|
const siteOutputPath = path.join(".rettangoli", "vt", "_site");
|
|
125
177
|
const candidateDir = path.join(siteOutputPath, "candidate");
|
|
178
|
+
const diffDir = path.join(siteOutputPath, "diff");
|
|
126
179
|
const originalReferenceDir = path.join(vtPath, "reference");
|
|
127
180
|
const siteReferenceDir = path.join(siteOutputPath, "reference");
|
|
128
181
|
const templatePath = path.join(libraryTemplatesPath, "report.html");
|
|
129
182
|
const outputPath = path.join(siteOutputPath, "report.html");
|
|
183
|
+
const jsonReportPath = path.join(".rettangoli", "vt", "report.json");
|
|
184
|
+
|
|
185
|
+
console.log(`Comparison method: ${compareMethod}`);
|
|
186
|
+
if (compareMethod === 'pixelmatch') {
|
|
187
|
+
console.log(` color threshold: ${colorThreshold}, diff threshold: ${diffThreshold}%`);
|
|
188
|
+
}
|
|
130
189
|
|
|
131
190
|
if (!fs.existsSync(originalReferenceDir)) {
|
|
132
191
|
console.log("Reference directory does not exist, creating it...");
|
|
133
192
|
fs.mkdirSync(originalReferenceDir, { recursive: true });
|
|
134
193
|
}
|
|
135
194
|
|
|
195
|
+
// Create diff directory for diffs
|
|
196
|
+
if (compareMethod === 'pixelmatch' && !fs.existsSync(diffDir)) {
|
|
197
|
+
fs.mkdirSync(diffDir, { recursive: true });
|
|
198
|
+
}
|
|
199
|
+
|
|
136
200
|
// Copy reference directory to _site for web access
|
|
137
201
|
if (fs.existsSync(originalReferenceDir)) {
|
|
138
202
|
console.log("Copying reference directory to _site...");
|
|
@@ -168,6 +232,7 @@ async function main(options = {}) {
|
|
|
168
232
|
const candidatePath = path.join(candidateDir, relativePath);
|
|
169
233
|
const referencePath = path.join(originalReferenceDir, relativePath);
|
|
170
234
|
const siteReferencePath = path.join(siteReferenceDir, relativePath);
|
|
235
|
+
const diffPath = path.join(diffDir, relativePath.replace('.webp', '-diff.png'));
|
|
171
236
|
|
|
172
237
|
const candidateExists = fs.existsSync(candidatePath);
|
|
173
238
|
const referenceExists = fs.existsSync(referencePath);
|
|
@@ -177,12 +242,28 @@ async function main(options = {}) {
|
|
|
177
242
|
|
|
178
243
|
let equal = true;
|
|
179
244
|
let error = false;
|
|
245
|
+
let similarity = null;
|
|
246
|
+
let diffPixels = null;
|
|
180
247
|
|
|
181
248
|
// Compare images if both exist
|
|
182
249
|
if (candidateExists && referenceExists) {
|
|
183
|
-
|
|
250
|
+
// Ensure diff directory exists
|
|
251
|
+
const diffDirPath = path.dirname(diffPath);
|
|
252
|
+
if (!fs.existsSync(diffDirPath)) {
|
|
253
|
+
fs.mkdirSync(diffDirPath, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const comparison = await compareImages(
|
|
257
|
+
candidatePath,
|
|
258
|
+
referencePath,
|
|
259
|
+
compareMethod,
|
|
260
|
+
diffPath,
|
|
261
|
+
{ colorThreshold, diffThreshold }
|
|
262
|
+
);
|
|
184
263
|
equal = comparison.equal;
|
|
185
264
|
error = comparison.error;
|
|
265
|
+
similarity = comparison.similarity;
|
|
266
|
+
diffPixels = comparison.diffPixels;
|
|
186
267
|
} else {
|
|
187
268
|
equal = false; // If one file is missing, they're not equal
|
|
188
269
|
}
|
|
@@ -193,6 +274,8 @@ async function main(options = {}) {
|
|
|
193
274
|
referencePath: referenceExists ? siteReferencePath : null, // Use site reference path for HTML report
|
|
194
275
|
path: relativePath,
|
|
195
276
|
equal: candidateExists && referenceExists ? equal : false,
|
|
277
|
+
similarity,
|
|
278
|
+
diffPixels,
|
|
196
279
|
onlyInCandidate: candidateExists && !referenceExists,
|
|
197
280
|
onlyInReference: !candidateExists && referenceExists,
|
|
198
281
|
});
|
|
@@ -213,6 +296,8 @@ async function main(options = {}) {
|
|
|
213
296
|
? path.relative(siteOutputPath, result.referencePath)
|
|
214
297
|
: null,
|
|
215
298
|
equal: result.equal,
|
|
299
|
+
similarity: result.similarity,
|
|
300
|
+
diffPixels: result.diffPixels,
|
|
216
301
|
onlyInCandidate: result.onlyInCandidate,
|
|
217
302
|
onlyInReference: result.onlyInReference,
|
|
218
303
|
};
|
|
@@ -223,6 +308,8 @@ async function main(options = {}) {
|
|
|
223
308
|
candidatePath: item.candidatePath,
|
|
224
309
|
referencePath: item.referencePath,
|
|
225
310
|
equal: item.equal,
|
|
311
|
+
similarity: item.similarity ? `${item.similarity}%` : null,
|
|
312
|
+
diffPixels: item.diffPixels,
|
|
226
313
|
};
|
|
227
314
|
console.log(JSON.stringify(logData, null, 2));
|
|
228
315
|
});
|
|
@@ -238,6 +325,25 @@ async function main(options = {}) {
|
|
|
238
325
|
templatePath,
|
|
239
326
|
outputPath,
|
|
240
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
|
+
|
|
241
347
|
if(mismatchingItems.length > 0){
|
|
242
348
|
console.error("Error: there are more than 0 mismatching item.")
|
|
243
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,78 +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
|
+
}
|
|
301
318
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|
|
306
335
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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;
|
|
311
343
|
}
|
|
312
|
-
|
|
344
|
+
`
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
if (waitTime > 0) {
|
|
348
|
+
await page.waitForTimeout(waitTime);
|
|
349
|
+
}
|
|
350
|
+
const baseName = removeExtension(file.path);
|
|
313
351
|
|
|
314
|
-
|
|
315
|
-
|
|
352
|
+
const initialScreenshotPath = await takeAndSaveScreenshot(page, baseName);
|
|
353
|
+
console.log(`Screenshot saved: ${initialScreenshotPath}`);
|
|
316
354
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
355
|
+
const stepContext = {
|
|
356
|
+
baseName,
|
|
357
|
+
takeAndSaveScreenshot,
|
|
358
|
+
};
|
|
359
|
+
const stepsExecutor = createSteps(page, stepContext);
|
|
322
360
|
|
|
323
|
-
|
|
324
|
-
|
|
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);
|
|
325
406
|
}
|
|
326
|
-
completed++;
|
|
327
|
-
console.log(`Finished processing ${file.path} (${completed}/${total})`);
|
|
328
|
-
} catch (error) {
|
|
329
|
-
console.error(`Error processing instructions for ${file.path}:`, error);
|
|
330
|
-
} finally {
|
|
331
|
-
// Close the context when done
|
|
332
|
-
await context.close();
|
|
333
407
|
}
|
|
334
|
-
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
filesToProcess = failedFiles;
|
|
411
|
+
}
|
|
335
412
|
|
|
336
|
-
|
|
337
|
-
|
|
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}`));
|
|
338
416
|
}
|
|
339
417
|
} catch (error) {
|
|
340
418
|
console.error("Error taking screenshots:", error);
|
|
@@ -437,4 +515,4 @@ export {
|
|
|
437
515
|
takeScreenshots,
|
|
438
516
|
generateOverview,
|
|
439
517
|
readYaml,
|
|
440
|
-
};
|
|
518
|
+
};
|
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) {
|