@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/README.md +135 -64
- package/package.json +9 -2
- package/src/capture/capture-scheduler.js +206 -0
- package/src/capture/playwright-runner.js +403 -0
- package/src/capture/result-collector.js +234 -0
- package/src/capture/screenshot-naming.js +13 -0
- package/src/capture/spec-loader.js +117 -0
- package/src/capture/worker-plan.js +66 -0
- package/src/cli/generate-options.js +81 -0
- package/src/cli/generate.js +95 -28
- package/src/cli/report-options.js +43 -0
- package/src/cli/report.js +88 -151
- package/src/cli/templates/index.html +2 -2
- package/src/cli/templates/report.html +4 -4
- package/src/common.js +123 -185
- package/src/createSteps.js +358 -28
- package/src/report/report-model.js +76 -0
- package/src/report/report-render.js +22 -0
- package/src/selector-filter.js +139 -0
- package/src/step-commands.js +33 -0
- package/src/validation.js +304 -0
- package/src/viewport.js +99 -0
- package/docker/Dockerfile +0 -21
- package/docker/build-and-push.sh +0 -16
package/src/common.js
CHANGED
|
@@ -5,17 +5,16 @@ import {
|
|
|
5
5
|
writeFileSync,
|
|
6
6
|
mkdirSync,
|
|
7
7
|
existsSync,
|
|
8
|
-
unlinkSync,
|
|
9
8
|
} from "fs";
|
|
10
9
|
import { join, dirname, resolve, extname } from "path";
|
|
11
10
|
import http from "http";
|
|
12
11
|
import { load as loadYaml } from "js-yaml";
|
|
13
12
|
import { Liquid } from "liquidjs";
|
|
14
|
-
import { chromium } from "playwright";
|
|
15
13
|
import { codeToHtml } from "shiki";
|
|
16
|
-
import sharp from "sharp";
|
|
17
14
|
import path from "path";
|
|
18
|
-
import {
|
|
15
|
+
import { validateFiniteNumber, validateFrontMatter } from "./validation.js";
|
|
16
|
+
import { createCaptureTasks } from "./capture/spec-loader.js";
|
|
17
|
+
import { runCaptureScheduler } from "./capture/capture-scheduler.js";
|
|
19
18
|
|
|
20
19
|
const removeExtension = (filePath) => filePath.replace(/\.[^/.]+$/, "");
|
|
21
20
|
|
|
@@ -136,11 +135,15 @@ async function generateHtml(specsDir, templatePath, outputDir, templateConfig) {
|
|
|
136
135
|
}
|
|
137
136
|
|
|
138
137
|
const relativePath = path.relative(specsDir, filePath);
|
|
138
|
+
validateFrontMatter(frontMatterObj, relativePath);
|
|
139
139
|
|
|
140
140
|
let templateToUse = defaultTemplateContent;
|
|
141
141
|
const resolvedTemplatePath = frontMatterObj?.template ?
|
|
142
142
|
join(templateConfig.vtPath, "templates", frontMatterObj.template) :
|
|
143
143
|
templateConfig.defaultTemplate;
|
|
144
|
+
if (!existsSync(resolvedTemplatePath)) {
|
|
145
|
+
throw new Error(`Template not found for "${relativePath}": ${resolvedTemplatePath}`);
|
|
146
|
+
}
|
|
144
147
|
templateToUse = readFileSync(resolvedTemplatePath, "utf8");
|
|
145
148
|
|
|
146
149
|
// Render template
|
|
@@ -168,14 +171,14 @@ async function generateHtml(specsDir, templatePath, outputDir, templateConfig) {
|
|
|
168
171
|
console.log(`Successfully generated ${processedFiles.length} files`);
|
|
169
172
|
return processedFiles;
|
|
170
173
|
} catch (error) {
|
|
171
|
-
throw new Error(
|
|
174
|
+
throw new Error(`Error generating HTML: ${error.message}`, { cause: error });
|
|
172
175
|
}
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
/**
|
|
176
179
|
* Start a web server to serve static files
|
|
177
180
|
*/
|
|
178
|
-
function startWebServer(artifactsDir, staticDir, port) {
|
|
181
|
+
async function startWebServer(artifactsDir, staticDir, port) {
|
|
179
182
|
const server = http.createServer((req, res) => {
|
|
180
183
|
const url = new URL(req.url, `http://localhost:${port}`);
|
|
181
184
|
let path = url.pathname;
|
|
@@ -212,10 +215,15 @@ function startWebServer(artifactsDir, staticDir, port) {
|
|
|
212
215
|
res.end("Not Found");
|
|
213
216
|
});
|
|
214
217
|
|
|
215
|
-
|
|
216
|
-
|
|
218
|
+
await new Promise((resolveServer, rejectServer) => {
|
|
219
|
+
server.once("error", rejectServer);
|
|
220
|
+
server.listen(port, () => {
|
|
221
|
+
server.off("error", rejectServer);
|
|
222
|
+
resolveServer();
|
|
223
|
+
});
|
|
217
224
|
});
|
|
218
225
|
|
|
226
|
+
console.log(`Server started at http://localhost:${port}`);
|
|
219
227
|
return server;
|
|
220
228
|
}
|
|
221
229
|
|
|
@@ -239,188 +247,118 @@ function getContentType(filePath) {
|
|
|
239
247
|
return contentTypes[ext] || "application/octet-stream";
|
|
240
248
|
}
|
|
241
249
|
|
|
250
|
+
function toSectionPageKey(sectionLike) {
|
|
251
|
+
return sectionLike.title;
|
|
252
|
+
}
|
|
253
|
+
|
|
242
254
|
/**
|
|
243
255
|
* Take screenshots of all generated HTML files
|
|
244
256
|
*/
|
|
245
|
-
async function takeScreenshots(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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);
|
|
257
|
+
async function takeScreenshots(options) {
|
|
258
|
+
const {
|
|
259
|
+
generatedFiles,
|
|
260
|
+
serverUrl,
|
|
261
|
+
screenshotsDir,
|
|
262
|
+
workerCount,
|
|
263
|
+
screenshotWaitTime = 10,
|
|
264
|
+
configUrl = undefined,
|
|
265
|
+
waitEvent = undefined,
|
|
266
|
+
waitSelector = undefined,
|
|
267
|
+
waitStrategy = undefined,
|
|
268
|
+
navigationTimeout = 30000,
|
|
269
|
+
readyTimeout = 30000,
|
|
270
|
+
screenshotTimeout = 30000,
|
|
271
|
+
maxRetries = 2,
|
|
272
|
+
recycleEvery = 0,
|
|
273
|
+
isolationMode = "fast",
|
|
274
|
+
metricsPath = join(".rettangoli", "vt", "metrics.json"),
|
|
275
|
+
headless = true,
|
|
276
|
+
viewport = undefined,
|
|
277
|
+
} = options;
|
|
278
|
+
|
|
279
|
+
if (!Array.isArray(generatedFiles)) {
|
|
280
|
+
throw new Error("takeScreenshots: generatedFiles must be an array.");
|
|
281
|
+
}
|
|
282
|
+
if (typeof serverUrl !== "string" || serverUrl.trim().length === 0) {
|
|
283
|
+
throw new Error("takeScreenshots: serverUrl must be a non-empty string.");
|
|
284
|
+
}
|
|
285
|
+
if (typeof screenshotsDir !== "string" || screenshotsDir.trim().length === 0) {
|
|
286
|
+
throw new Error("takeScreenshots: screenshotsDir must be a non-empty string.");
|
|
287
|
+
}
|
|
283
288
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
289
|
+
validateFiniteNumber(workerCount, "workerCount", { integer: true, min: 1 });
|
|
290
|
+
validateFiniteNumber(screenshotWaitTime, "screenshotWaitTime", { integer: true, min: 0 });
|
|
291
|
+
validateFiniteNumber(navigationTimeout, "navigationTimeout", { integer: true, min: 1 });
|
|
292
|
+
validateFiniteNumber(readyTimeout, "readyTimeout", { integer: true, min: 1 });
|
|
293
|
+
validateFiniteNumber(screenshotTimeout, "screenshotTimeout", { integer: true, min: 1 });
|
|
294
|
+
validateFiniteNumber(maxRetries, "maxRetries", { integer: true, min: 0 });
|
|
295
|
+
validateFiniteNumber(recycleEvery, "recycleEvery", { integer: true, min: 0 });
|
|
296
|
+
|
|
297
|
+
if (!["strict", "fast"].includes(isolationMode)) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
`Invalid isolationMode "${isolationMode}". Expected "strict" or "fast".`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
if (waitStrategy !== undefined && waitStrategy !== null) {
|
|
303
|
+
if (!["networkidle", "load", "event", "selector"].includes(waitStrategy)) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`Invalid waitStrategy "${waitStrategy}". Expected "networkidle", "load", "event", or "selector".`,
|
|
306
|
+
);
|
|
287
307
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const context = await browser.newContext();
|
|
293
|
-
const page = await context.newPage();
|
|
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
|
-
}
|
|
302
|
-
|
|
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
|
-
}
|
|
335
|
-
|
|
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;
|
|
343
|
-
}
|
|
344
|
-
`
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
if (waitTime > 0) {
|
|
348
|
-
await page.waitForTimeout(waitTime);
|
|
349
|
-
}
|
|
350
|
-
const baseName = removeExtension(file.path);
|
|
351
|
-
|
|
352
|
-
const initialScreenshotPath = await takeAndSaveScreenshot(page, baseName);
|
|
353
|
-
console.log(`Screenshot saved: ${initialScreenshotPath}`);
|
|
354
|
-
|
|
355
|
-
const stepContext = {
|
|
356
|
-
baseName,
|
|
357
|
-
takeAndSaveScreenshot,
|
|
358
|
-
};
|
|
359
|
-
const stepsExecutor = createSteps(page, stepContext);
|
|
360
|
-
|
|
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();
|
|
308
|
+
}
|
|
309
|
+
if (waitEvent !== undefined && waitEvent !== null) {
|
|
310
|
+
if (typeof waitEvent !== "string" || waitEvent.trim().length === 0) {
|
|
311
|
+
throw new Error("waitEvent must be a non-empty string when provided.");
|
|
367
312
|
}
|
|
368
|
-
}
|
|
313
|
+
}
|
|
314
|
+
if (waitSelector !== undefined && waitSelector !== null) {
|
|
315
|
+
if (typeof waitSelector !== "string" || waitSelector.trim().length === 0) {
|
|
316
|
+
throw new Error("waitSelector must be a non-empty string when provided.");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
369
319
|
|
|
370
|
-
|
|
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
|
-
}
|
|
320
|
+
ensureDirectoryExists(screenshotsDir);
|
|
380
321
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
}
|
|
322
|
+
const tasks = createCaptureTasks(generatedFiles, {
|
|
323
|
+
serverUrl,
|
|
324
|
+
configUrl,
|
|
325
|
+
waitEvent,
|
|
326
|
+
waitSelector,
|
|
327
|
+
waitStrategy,
|
|
328
|
+
viewport,
|
|
329
|
+
});
|
|
409
330
|
|
|
410
|
-
|
|
411
|
-
|
|
331
|
+
const { summary, failures } = await runCaptureScheduler({
|
|
332
|
+
tasks,
|
|
333
|
+
screenshotsDir,
|
|
334
|
+
workerCount,
|
|
335
|
+
isolationMode,
|
|
336
|
+
screenshotWaitTime,
|
|
337
|
+
waitEvent,
|
|
338
|
+
waitSelector,
|
|
339
|
+
waitStrategy,
|
|
340
|
+
navigationTimeout,
|
|
341
|
+
readyTimeout,
|
|
342
|
+
screenshotTimeout,
|
|
343
|
+
maxRetries,
|
|
344
|
+
recycleEvery,
|
|
345
|
+
metricsPath,
|
|
346
|
+
headless,
|
|
347
|
+
});
|
|
412
348
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
// Close browser
|
|
421
|
-
await browser.close();
|
|
422
|
-
console.log("Browser closed");
|
|
349
|
+
if (failures.length > 0) {
|
|
350
|
+
const failedDetails = failures
|
|
351
|
+
.map((failure) => `- ${failure.path}: ${failure.error}`)
|
|
352
|
+
.join("\n");
|
|
353
|
+
throw new Error(
|
|
354
|
+
`Failed to process ${failures.length} screenshot file(s):\n${failedDetails}`,
|
|
355
|
+
);
|
|
423
356
|
}
|
|
357
|
+
|
|
358
|
+
console.log(
|
|
359
|
+
`Capture completed: ${summary.successful}/${summary.totalTasks} succeeded in ${summary.durationMs}ms`,
|
|
360
|
+
);
|
|
361
|
+
return summary;
|
|
424
362
|
}
|
|
425
363
|
|
|
426
364
|
/**
|
|
@@ -455,17 +393,17 @@ function generateOverview(data, templatePath, outputPath, configData) {
|
|
|
455
393
|
title: section.title,
|
|
456
394
|
type: "groupLabel",
|
|
457
395
|
items: section.items.map((item) => ({
|
|
458
|
-
id: item
|
|
396
|
+
id: toSectionPageKey(item),
|
|
459
397
|
title: item.title,
|
|
460
|
-
href: `/${item
|
|
398
|
+
href: `/${toSectionPageKey(item)}.html`,
|
|
461
399
|
})),
|
|
462
400
|
};
|
|
463
401
|
} else {
|
|
464
|
-
// Flat item
|
|
402
|
+
// Flat item
|
|
465
403
|
return {
|
|
466
|
-
id: section
|
|
404
|
+
id: toSectionPageKey(section),
|
|
467
405
|
title: section.title,
|
|
468
|
-
href: `/${section
|
|
406
|
+
href: `/${toSectionPageKey(section)}.html`,
|
|
469
407
|
};
|
|
470
408
|
}
|
|
471
409
|
});
|
|
@@ -494,7 +432,7 @@ function generateOverview(data, templatePath, outputPath, configData) {
|
|
|
494
432
|
|
|
495
433
|
const finalOutputPath = outputPath.replace(
|
|
496
434
|
"index.html",
|
|
497
|
-
`${section
|
|
435
|
+
`${toSectionPageKey(section)}.html`
|
|
498
436
|
);
|
|
499
437
|
// Save file
|
|
500
438
|
writeFileSync(finalOutputPath, renderedContent, "utf8");
|