@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/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 { 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";
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("Error generating HTML:", 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
- server.listen(port, () => {
216
- console.log(`Server started at http://localhost:${port}`);
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
- 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);
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
- if (existsSync(tempPngPath)) {
285
- unlinkSync(tempPngPath);
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
- 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();
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
- 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
- }
321
+ ensureDirectoryExists(screenshotsDir);
380
322
 
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
- }
323
+ const tasks = createCaptureTasks(generatedFiles, {
324
+ serverUrl,
325
+ configUrl,
326
+ waitEvent,
327
+ waitSelector,
328
+ waitStrategy,
329
+ viewport,
330
+ });
409
331
 
410
- filesToProcess = failedFiles;
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
- 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");
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.title.toLowerCase().replace(/\s+/g, "-"),
397
+ id: toSectionPageKey(item),
459
398
  title: item.title,
460
- href: `/${item.title.toLowerCase().replace(/\s+/g, "-")}.html`,
399
+ href: `/${toSectionPageKey(item)}.html`,
461
400
  })),
462
401
  };
463
402
  } else {
464
- // Flat item (backwards compatibility)
403
+ // Flat item
465
404
  return {
466
- id: section.title.toLowerCase().replace(/\s+/g, "-"),
405
+ id: toSectionPageKey(section),
467
406
  title: section.title,
468
- href: `/${section.title.toLowerCase().replace(/\s+/g, "-")}.html`,
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.title.toLowerCase().replace(/\s+/g, "-")}.html`
436
+ `${toSectionPageKey(section)}.html`
498
437
  );
499
438
  // Save file
500
439
  writeFileSync(finalOutputPath, renderedContent, "utf8");