@reportforge/playwright-pdf 0.0.1 → 0.2.0

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/dist/index.js CHANGED
@@ -10328,7 +10328,8 @@ var DEFAULT_OPTIONS = {
10328
10328
  compressionLevel: "auto",
10329
10329
  maxFileSizeMb: 8,
10330
10330
  historySize: 10,
10331
- showTrend: true
10331
+ showTrend: true,
10332
+ flakinessTopN: 5
10332
10333
  };
10333
10334
 
10334
10335
  // src/config/schema.ts
@@ -10341,10 +10342,17 @@ var channelConfigSchema = external_exports.object({
10341
10342
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10342
10343
  enabled: external_exports.boolean().default(false)
10343
10344
  });
10345
+ var emailChannelConfigSchema = external_exports.object({
10346
+ to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
10347
+ on: external_exports.enum(["always", "failure", "success"]).default("always"),
10348
+ enabled: external_exports.boolean().default(false),
10349
+ attachPdf: external_exports.boolean().default(false)
10350
+ });
10344
10351
  var notifyConfigSchema = external_exports.object({
10345
10352
  slack: channelConfigSchema.optional(),
10346
10353
  teams: channelConfigSchema.optional(),
10347
- discord: channelConfigSchema.optional()
10354
+ discord: channelConfigSchema.optional(),
10355
+ email: emailChannelConfigSchema.optional()
10348
10356
  }).optional();
10349
10357
  var ReporterOptionsSchema = external_exports.object({
10350
10358
  outputFile: external_exports.string().optional().default(DEFAULT_OPTIONS.outputFile),
@@ -10373,7 +10381,16 @@ var ReporterOptionsSchema = external_exports.object({
10373
10381
  notify: notifyConfigSchema,
10374
10382
  historyFile: external_exports.string().optional(),
10375
10383
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10376
- showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend)
10384
+ showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10385
+ flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10386
+ templatePath: external_exports.union([
10387
+ external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
10388
+ external_exports.array(
10389
+ external_exports.string().regex(/\.hbs$/, "Each templatePath entry must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "Each templatePath basename must not be empty")
10390
+ ).min(1, "templatePath array must not be empty")
10391
+ ]).optional().transform(
10392
+ (v) => v === void 0 ? void 0 : Array.isArray(v) ? v : [v]
10393
+ )
10377
10394
  });
10378
10395
  function parseOptions(raw) {
10379
10396
  const result = ReporterOptionsSchema.safeParse(raw ?? {});
@@ -11461,6 +11478,7 @@ var TemplateEngine = class {
11461
11478
  constructor() {
11462
11479
  this.chartjsInline = "";
11463
11480
  this.fontsBundleCss = "";
11481
+ this.customTemplateCache = /* @__PURE__ */ new Map();
11464
11482
  this.handlebars = import_handlebars.default.create();
11465
11483
  this.templatesDir = import_path3.default.resolve(__dirname, "templates");
11466
11484
  if (!import_fs4.default.existsSync(this.templatesDir)) {
@@ -11490,12 +11508,43 @@ var TemplateEngine = class {
11490
11508
  const ctx = this.buildContext(data, template);
11491
11509
  return compiledLayout(ctx);
11492
11510
  }
11511
+ renderFromPath(data, filePath) {
11512
+ if (!this.customTemplateCache.has(filePath)) {
11513
+ if (!import_fs4.default.existsSync(filePath)) {
11514
+ throw new Error(`[reportforge] Custom template not found: ${filePath}`);
11515
+ }
11516
+ const source = import_fs4.default.readFileSync(filePath, "utf8");
11517
+ this.customTemplateCache.set(filePath, this.handlebars.compile(source));
11518
+ }
11519
+ const compiledFn = this.customTemplateCache.get(filePath);
11520
+ return compiledFn(this.buildCustomContext(data));
11521
+ }
11522
+ buildCustomContext(data) {
11523
+ return {
11524
+ ...data,
11525
+ options: {
11526
+ showCharts: true,
11527
+ showTrend: true,
11528
+ showStackTraces: true,
11529
+ showFullEnvironment: true,
11530
+ showPassRate: true,
11531
+ showRetries: true,
11532
+ showFullFailures: true
11533
+ },
11534
+ tagGroups: this.buildTagGroups(data),
11535
+ slowTests: this.buildSlowTests(data),
11536
+ baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
11537
+ templateCSS: "",
11538
+ chartjsScript: this.buildChartjsScript()
11539
+ };
11540
+ }
11493
11541
  buildContext(data, template) {
11494
11542
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
11495
11543
  const templateCSS = this.readStyle(`${template}.css`);
11496
11544
  const optionsMap = {
11497
11545
  minimal: {
11498
11546
  showCharts: false,
11547
+ showTrend: false,
11499
11548
  showStackTraces: true,
11500
11549
  showFullEnvironment: false,
11501
11550
  showPassRate: true,
@@ -11504,6 +11553,7 @@ var TemplateEngine = class {
11504
11553
  },
11505
11554
  detailed: {
11506
11555
  showCharts: true,
11556
+ showTrend: true,
11507
11557
  showStackTraces: true,
11508
11558
  showFullEnvironment: true,
11509
11559
  showPassRate: true,
@@ -11512,6 +11562,7 @@ var TemplateEngine = class {
11512
11562
  },
11513
11563
  executive: {
11514
11564
  showCharts: true,
11565
+ showTrend: true,
11515
11566
  showStackTraces: false,
11516
11567
  showFullEnvironment: false,
11517
11568
  showPassRate: true,
@@ -11519,7 +11570,7 @@ var TemplateEngine = class {
11519
11570
  showFullFailures: false
11520
11571
  }
11521
11572
  };
11522
- const chartjsScript = this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
11573
+ const chartjsScript = this.buildChartjsScript();
11523
11574
  return {
11524
11575
  ...data,
11525
11576
  options: optionsMap[template],
@@ -11530,6 +11581,9 @@ var TemplateEngine = class {
11530
11581
  chartjsScript
11531
11582
  };
11532
11583
  }
11584
+ buildChartjsScript() {
11585
+ return this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
11586
+ }
11533
11587
  buildTagGroups(data) {
11534
11588
  const tagMap = /* @__PURE__ */ new Map();
11535
11589
  for (const project of data.projects) {
@@ -11910,10 +11964,10 @@ var FilenameResolver = class {
11910
11964
  import_fs9.default.mkdirSync(import_path6.default.dirname(absolute), { recursive: true });
11911
11965
  return absolute;
11912
11966
  }
11913
- injectTemplateSuffix(outputPath, templateId) {
11967
+ injectTemplateSuffix(outputPath, suffix) {
11914
11968
  const ext = import_path6.default.extname(outputPath);
11915
11969
  const base = outputPath.slice(0, outputPath.length - ext.length);
11916
- return `${base}-${templateId}${ext}`;
11970
+ return `${base}-${suffix}${ext}`;
11917
11971
  }
11918
11972
  formatDate(d) {
11919
11973
  const yyyy = d.getFullYear();
@@ -12033,9 +12087,15 @@ var PdfGenerator = class {
12033
12087
  }
12034
12088
  }
12035
12089
  async generateAll(data, options, _licenseInfo) {
12036
- const rawTemplates = Array.isArray(options.template) ? options.template : [options.template];
12037
- const templates = [...new Set(rawTemplates)];
12038
- const isMulti = templates.length > 1;
12090
+ const sources = this.resolveTemplateSources(options);
12091
+ const isMulti = sources.length > 1;
12092
+ for (const source of sources) {
12093
+ if (source.kind === "custom" && !import_fs11.default.existsSync(source.absolutePath)) {
12094
+ throw new Error(
12095
+ `[reportforge] Custom template not found: ${source.absolutePath}`
12096
+ );
12097
+ }
12098
+ }
12039
12099
  if (options.logo) {
12040
12100
  const logoBase64 = await this.screenshotEmbedder.embedLogoFile(options.logo);
12041
12101
  data.meta.branding.logoBase64 = logoBase64;
@@ -12052,11 +12112,13 @@ var PdfGenerator = class {
12052
12112
  const outputPaths = [];
12053
12113
  const { page } = await this.browserManager.launch(options.puppeteerExecutablePath);
12054
12114
  try {
12055
- for (const templateId of templates) {
12056
- const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, templateId) : baseOutputPath;
12115
+ for (const source of sources) {
12116
+ const suffix = source.kind === "builtin" ? source.id : source.label;
12117
+ const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, suffix) : baseOutputPath;
12118
+ const templateId = source.kind === "builtin" ? source.id : "detailed";
12057
12119
  const templateData = { ...data, meta: { ...data.meta, template: templateId } };
12058
12120
  const templateOptions = { ...options, template: templateId };
12059
- await this.renderPdf(templateData, templateOptions, compression, outputPath, page);
12121
+ await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
12060
12122
  const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
12061
12123
  if (capBytes > 0) {
12062
12124
  const sizeBytes = (await import_fs11.default.promises.stat(outputPath)).size;
@@ -12065,7 +12127,7 @@ var PdfGenerator = class {
12065
12127
  logger.warn(
12066
12128
  `PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
12067
12129
  );
12068
- await this.renderPdf(templateData, templateOptions, tighter, outputPath, page);
12130
+ await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
12069
12131
  const finalSize = (await import_fs11.default.promises.stat(outputPath)).size;
12070
12132
  if (finalSize > capBytes) {
12071
12133
  logger.warn(
@@ -12088,6 +12150,17 @@ var PdfGenerator = class {
12088
12150
  }
12089
12151
  return outputPaths;
12090
12152
  }
12153
+ resolveTemplateSources(options) {
12154
+ if (options.templatePath && options.templatePath.length > 0) {
12155
+ return [...new Set(options.templatePath)].map((p) => ({
12156
+ kind: "custom",
12157
+ absolutePath: import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p),
12158
+ label: import_path8.default.basename(p, ".hbs")
12159
+ }));
12160
+ }
12161
+ const rawIds = Array.isArray(options.template) ? options.template : [options.template];
12162
+ return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
12163
+ }
12091
12164
  resolveSettings(options, data) {
12092
12165
  return resolveCompression({
12093
12166
  level: options.compressionLevel ?? "auto",
@@ -12122,7 +12195,7 @@ var PdfGenerator = class {
12122
12195
  * Separated from generateAll() so the size-cap retune can reuse it with
12123
12196
  * stricter settings without reshaping the rest of the flow.
12124
12197
  */
12125
- async renderPdf(data, options, compression, outputPath, page) {
12198
+ async renderPdf(data, options, source, compression, outputPath, page) {
12126
12199
  const paginated = await this.failurePaginator.paginate(
12127
12200
  data.failures,
12128
12201
  compression.maxInlineFailures,
@@ -12142,15 +12215,15 @@ var PdfGenerator = class {
12142
12215
  maxWidth: compression.maxWidth
12143
12216
  });
12144
12217
  }
12145
- const templateId = options.template;
12146
- logger.info(`Rendering template: ${templateId}`);
12147
- const html = this.templateEngine.render(renderData, templateId);
12218
+ const templateLabel = source.kind === "builtin" ? source.id : source.label;
12219
+ logger.info(`Rendering template: ${templateLabel}`);
12220
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
12148
12221
  const tempHtmlPath = await this.htmlWriter.write(html);
12149
12222
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12150
12223
  try {
12151
12224
  logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
12152
12225
  await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
12153
- const showCharts = templateId !== "minimal";
12226
+ const showCharts = source.kind === "builtin" ? source.id !== "minimal" : true;
12154
12227
  await this.chartRenderer.waitForCharts(page, showCharts);
12155
12228
  logger.info(`Generating PDF \u2192 ${import_path8.default.relative(process.cwd(), outputPath)}`);
12156
12229
  await page.pdf({
@@ -12169,7 +12242,7 @@ function fmtMb(bytes) {
12169
12242
  }
12170
12243
 
12171
12244
  // src/reporter.ts
12172
- var import_path11 = __toESM(require("path"));
12245
+ var import_path12 = __toESM(require("path"));
12173
12246
  var import_crypto7 = __toESM(require("crypto"));
12174
12247
  var import_os6 = __toESM(require("os"));
12175
12248
 
@@ -12211,6 +12284,42 @@ var HistoryManager = class {
12211
12284
  }
12212
12285
  };
12213
12286
 
12287
+ // src/history/FlakinessAnalyser.ts
12288
+ init_cjs_shims();
12289
+ function computeTopN(entries, topN) {
12290
+ if (topN === 0) return [];
12291
+ const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
12292
+ if (qualifying.length === 0) return [];
12293
+ const map = /* @__PURE__ */ new Map();
12294
+ qualifying.forEach((entry, idx) => {
12295
+ const seen = /* @__PURE__ */ new Set();
12296
+ for (const t of entry.flakyTests) {
12297
+ if (seen.has(t.id)) continue;
12298
+ seen.add(t.id);
12299
+ if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
12300
+ map.get(t.id).hits.add(idx);
12301
+ }
12302
+ });
12303
+ const totalRuns = qualifying.length;
12304
+ const rows = [...map.entries()].map(([, { title, hits }]) => {
12305
+ const flakeCount = hits.size;
12306
+ const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
12307
+ const originalIdx = qualifying.length - 1 - reversedIdx;
12308
+ return hits.has(originalIdx);
12309
+ });
12310
+ return {
12311
+ title,
12312
+ flakeRate: Math.round(flakeCount / totalRuns * 100),
12313
+ flakeCount,
12314
+ totalRuns,
12315
+ sparkline
12316
+ };
12317
+ });
12318
+ return rows.sort(
12319
+ (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
12320
+ ).slice(0, topN);
12321
+ }
12322
+
12214
12323
  // src/notify/index.ts
12215
12324
  init_cjs_shims();
12216
12325
 
@@ -12327,6 +12436,115 @@ ${name}`,
12327
12436
  };
12328
12437
  }
12329
12438
 
12439
+ // src/notify/EmailSender.ts
12440
+ init_cjs_shims();
12441
+ var import_promises = require("fs/promises");
12442
+ var import_path11 = require("path");
12443
+
12444
+ // src/notify/formatters/email.ts
12445
+ init_cjs_shims();
12446
+ function buildEmailContent(stats, pdfPaths, reportTitle) {
12447
+ const label = statusLabel(stats.verdict);
12448
+ const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
12449
+ const name = reportName(pdfPaths, reportTitle);
12450
+ const html = `<!DOCTYPE html>
12451
+ <html lang="en">
12452
+ <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
12453
+ <body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
12454
+ <h2 style="margin:0 0 8px">${escHtml(label)}</h2>
12455
+ <p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
12456
+ <table style="border-collapse:collapse;width:100%">
12457
+ <tr style="background:#f5f5f5">
12458
+ <th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
12459
+ <th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
12460
+ </tr>
12461
+ <tr>
12462
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
12463
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
12464
+ </tr>
12465
+ <tr style="background:#f9f9f9">
12466
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
12467
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
12468
+ </tr>
12469
+ <tr>
12470
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
12471
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
12472
+ </tr>
12473
+ <tr style="background:#f9f9f9">
12474
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
12475
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
12476
+ </tr>
12477
+ <tr>
12478
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
12479
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
12480
+ </tr>
12481
+ <tr style="background:#f9f9f9">
12482
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
12483
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
12484
+ </tr>
12485
+ </table>
12486
+ <p style="margin:24px 0 0;color:#888;font-size:12px">Sent by ReportForge \xB7 <a href="https://reportforge.org" style="color:#888">reportforge.org</a></p>
12487
+ </body>
12488
+ </html>`;
12489
+ return { subject, html };
12490
+ }
12491
+ function escHtml(str) {
12492
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12493
+ }
12494
+
12495
+ // src/notify/EmailSender.ts
12496
+ var RESEND_ENDPOINT = "https://api.resend.com/emails";
12497
+ var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
12498
+ var EmailSender = class {
12499
+ async send(reportData, config, pdfPaths) {
12500
+ const apiKey = process.env["RESEND_API_KEY"];
12501
+ if (!apiKey) {
12502
+ logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
12503
+ return "skipped";
12504
+ }
12505
+ const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
12506
+ const { subject, html } = buildEmailContent(
12507
+ reportData.stats,
12508
+ pdfPaths,
12509
+ reportData.meta.title ?? ""
12510
+ );
12511
+ const body = { from, to: config.to, subject, html };
12512
+ if (config.attachPdf && pdfPaths.length > 0) {
12513
+ const pdfPath = pdfPaths[0];
12514
+ try {
12515
+ const content = await (0, import_promises.readFile)(pdfPath);
12516
+ body["attachments"] = [{ filename: (0, import_path11.basename)(pdfPath), content: content.toString("base64") }];
12517
+ } catch (err) {
12518
+ logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
12519
+ }
12520
+ }
12521
+ const controller = new AbortController();
12522
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
12523
+ try {
12524
+ const res = await fetch(RESEND_ENDPOINT, {
12525
+ method: "POST",
12526
+ headers: {
12527
+ "Authorization": `Bearer ${apiKey}`,
12528
+ "Content-Type": "application/json"
12529
+ },
12530
+ body: JSON.stringify(body),
12531
+ signal: controller.signal
12532
+ });
12533
+ if (!res.ok) {
12534
+ const detail = await res.json().catch(() => ({}));
12535
+ throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
12536
+ }
12537
+ logger.info("[notify] email: sent");
12538
+ return "sent";
12539
+ } catch (err) {
12540
+ logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
12541
+ return "failed";
12542
+ } finally {
12543
+ clearTimeout(timeoutId);
12544
+ }
12545
+ }
12546
+ };
12547
+
12330
12548
  // src/notify/NotificationSender.ts
12331
12549
  function shouldNotify(channel, stats) {
12332
12550
  if (!channel.enabled) return false;
@@ -12355,6 +12573,18 @@ var NotificationSender = class {
12355
12573
  return this.post(name, channel.url, payload, result);
12356
12574
  })
12357
12575
  );
12576
+ if (notifyConfig.email) {
12577
+ if (!shouldNotify(notifyConfig.email, reportData.stats)) {
12578
+ result.skipped.push("email");
12579
+ } else {
12580
+ const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
12581
+ if (outcome === "sent") result.sent.push("email");
12582
+ else if (outcome === "failed") result.failed.push("email");
12583
+ else result.skipped.push("email");
12584
+ }
12585
+ } else {
12586
+ result.skipped.push("email");
12587
+ }
12358
12588
  return result;
12359
12589
  }
12360
12590
  async post(name, url, payload, result) {
@@ -12386,7 +12616,7 @@ var PdfReporter = class {
12386
12616
  this.dataCollector = new DataCollector();
12387
12617
  this.pdfGenerator = new PdfGenerator();
12388
12618
  this.options = parseOptions(rawOptions);
12389
- const version = true ? "0.0.1" : "0.x";
12619
+ const version = true ? "0.2.0" : "0.x";
12390
12620
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12391
12621
  this.licenseClient = new LicenseClient({
12392
12622
  licenseKey: this.options.licenseKey,
@@ -12442,6 +12672,7 @@ var PdfReporter = class {
12442
12672
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12443
12673
  const hm = new HistoryManager(historyPath);
12444
12674
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12675
+ const flakyTests = collectFlakyTests(collected.projects);
12445
12676
  const entry = {
12446
12677
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12447
12678
  timestamp: Date.now(),
@@ -12451,9 +12682,13 @@ var PdfReporter = class {
12451
12682
  failed: collected.stats.failed,
12452
12683
  flaky: collected.stats.flaky,
12453
12684
  duration: collected.stats.duration,
12454
- verdict: deriveVerdict(collected.stats)
12685
+ verdict: deriveVerdict(collected.stats),
12686
+ flakyTests
12455
12687
  };
12456
12688
  const entries = hm.append(entry, this.options.historySize);
12689
+ this._populateHistoryCharts(entries, collected);
12690
+ }
12691
+ _populateHistoryCharts(entries, collected) {
12457
12692
  if (entries.length >= 2) {
12458
12693
  collected.charts.trend = {
12459
12694
  entries: entries.map((e) => ({
@@ -12467,11 +12702,17 @@ var PdfReporter = class {
12467
12702
  delta: entries[0].passRate - entries[1].passRate
12468
12703
  };
12469
12704
  }
12705
+ if (this.options.flakinessTopN > 0) {
12706
+ const table = computeTopN(entries, this.options.flakinessTopN);
12707
+ if (table.length > 0) {
12708
+ collected.charts.flakinessTable = table;
12709
+ }
12710
+ }
12470
12711
  }
12471
12712
  _buildReportData(collected, licenseInfo) {
12472
12713
  const { projects, stats, failures, environment, charts } = collected;
12473
12714
  const projectName = this.options.projectName ?? this.resolveProjectName();
12474
- const firstTemplate = Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
12715
+ const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
12475
12716
  return {
12476
12717
  meta: {
12477
12718
  title: this.options.reportTitle,
@@ -12507,7 +12748,7 @@ var PdfReporter = class {
12507
12748
  }
12508
12749
  resolveProjectName() {
12509
12750
  try {
12510
- const pkgPath = import_path11.default.resolve(process.cwd(), "package.json");
12751
+ const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
12511
12752
  const pkg = require(pkgPath);
12512
12753
  return pkg.name ?? "Playwright Tests";
12513
12754
  } catch {
@@ -12529,6 +12770,15 @@ var PdfReporter = class {
12529
12770
  logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
12530
12771
  });
12531
12772
  child.unref();
12773
+ if (platform === "win32") {
12774
+ child.on("close", (code) => {
12775
+ if (code !== 0 && code !== null) {
12776
+ logger.warn(
12777
+ `open: true \u2014 shell-open exited with code ${code}. No PDF viewer may be registered for .pdf files on this machine. File is at: ${pdfPath}`
12778
+ );
12779
+ }
12780
+ });
12781
+ }
12532
12782
  } catch (err) {
12533
12783
  logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
12534
12784
  }
@@ -12548,10 +12798,28 @@ function deriveVerdict(stats) {
12548
12798
  }
12549
12799
  function resolveHistoryPath(options, cwd) {
12550
12800
  if (options.historyFile) {
12551
- return import_path11.default.isAbsolute(options.historyFile) ? options.historyFile : import_path11.default.resolve(cwd, options.historyFile);
12801
+ return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
12552
12802
  }
12553
12803
  const projectKey = import_crypto7.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
12554
- return import_path11.default.join(import_os6.default.homedir(), ".reportforge", projectKey, "history.json");
12804
+ return import_path12.default.join(import_os6.default.homedir(), ".reportforge", projectKey, "history.json");
12805
+ }
12806
+ function flattenSuiteNodes(suites) {
12807
+ return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
12808
+ }
12809
+ function collectFlakyTests(projects) {
12810
+ const seen = /* @__PURE__ */ new Set();
12811
+ const result = [];
12812
+ for (const project of projects) {
12813
+ for (const suite of flattenSuiteNodes(project.suites)) {
12814
+ for (const test of suite.tests) {
12815
+ if (test.status === "flaky" && !seen.has(test.id)) {
12816
+ seen.add(test.id);
12817
+ result.push({ id: test.id, title: test.fullTitle });
12818
+ }
12819
+ }
12820
+ }
12821
+ }
12822
+ return result;
12555
12823
  }
12556
12824
 
12557
12825
  // src/index.ts