@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/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 { createSteps } from "./createSteps.js";
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("Error generating HTML:", 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
- server.listen(port, () => {
216
- console.log(`Server started at http://localhost:${port}`);
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
- generatedFiles,
247
- serverUrl,
248
- screenshotsDir,
249
- concurrency = 8,
250
- waitTime = 10,
251
- configUrl = undefined,
252
- waitEvent = undefined,
253
- ) {
254
- // Ensure screenshots directory exists
255
- ensureDirectoryExists(screenshotsDir);
256
-
257
- // Launch browser
258
- console.log("Launching browser to take screenshots...");
259
- const browser = await chromium.launch();
260
-
261
- const takeAndSaveScreenshot = async (page, basePath, suffix = "") => {
262
- const finalPath = suffix ? `${basePath}-${suffix}` : basePath;
263
- const tempPngPath = join(screenshotsDir, `${finalPath}.png`);
264
- const screenshotPath = join(screenshotsDir, `${finalPath}.webp`);
265
- ensureDirectoryExists(dirname(screenshotPath));
266
-
267
- // Check if custom screenshot function is available
268
- const hasCustomScreenshot = await page.evaluate(() => typeof window.takeVtScreenshotBase64 === 'function');
269
-
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);
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
- if (existsSync(tempPngPath)) {
285
- unlinkSync(tempPngPath);
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
- return screenshotPath;
289
- };
290
-
291
- const processFile = async (file, browser, serverUrl, screenshotsDir, waitTime, configUrl, waitEvent, takeAndSaveScreenshot) => {
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
- 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
- }
320
+ ensureDirectoryExists(screenshotsDir);
380
321
 
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);
406
- }
407
- }
408
- }
322
+ const tasks = createCaptureTasks(generatedFiles, {
323
+ serverUrl,
324
+ configUrl,
325
+ waitEvent,
326
+ waitSelector,
327
+ waitStrategy,
328
+ viewport,
329
+ });
409
330
 
410
- filesToProcess = failedFiles;
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
- if (filesToProcess.length > 0) {
414
- console.error(`\nFailed to process ${filesToProcess.length} files after ${maxRetries} attempts:`);
415
- filesToProcess.forEach((file) => console.error(` - ${file.path}`));
416
- }
417
- } catch (error) {
418
- console.error("Error taking screenshots:", error);
419
- } finally {
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.title.toLowerCase().replace(/\s+/g, "-"),
396
+ id: toSectionPageKey(item),
459
397
  title: item.title,
460
- href: `/${item.title.toLowerCase().replace(/\s+/g, "-")}.html`,
398
+ href: `/${toSectionPageKey(item)}.html`,
461
399
  })),
462
400
  };
463
401
  } else {
464
- // Flat item (backwards compatibility)
402
+ // Flat item
465
403
  return {
466
- id: section.title.toLowerCase().replace(/\s+/g, "-"),
404
+ id: toSectionPageKey(section),
467
405
  title: section.title,
468
- href: `/${section.title.toLowerCase().replace(/\s+/g, "-")}.html`,
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.title.toLowerCase().replace(/\s+/g, "-")}.html`
435
+ `${toSectionPageKey(section)}.html`
498
436
  );
499
437
  // Save file
500
438
  writeFileSync(finalOutputPath, renderedContent, "utf8");