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