@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.mjs CHANGED
@@ -10329,7 +10329,8 @@ var DEFAULT_OPTIONS = {
10329
10329
  compressionLevel: "auto",
10330
10330
  maxFileSizeMb: 8,
10331
10331
  historySize: 10,
10332
- showTrend: true
10332
+ showTrend: true,
10333
+ flakinessTopN: 5
10333
10334
  };
10334
10335
 
10335
10336
  // src/config/schema.ts
@@ -10342,10 +10343,17 @@ var channelConfigSchema = external_exports.object({
10342
10343
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10343
10344
  enabled: external_exports.boolean().default(false)
10344
10345
  });
10346
+ var emailChannelConfigSchema = external_exports.object({
10347
+ 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'),
10348
+ on: external_exports.enum(["always", "failure", "success"]).default("always"),
10349
+ enabled: external_exports.boolean().default(false),
10350
+ attachPdf: external_exports.boolean().default(false)
10351
+ });
10345
10352
  var notifyConfigSchema = external_exports.object({
10346
10353
  slack: channelConfigSchema.optional(),
10347
10354
  teams: channelConfigSchema.optional(),
10348
- discord: channelConfigSchema.optional()
10355
+ discord: channelConfigSchema.optional(),
10356
+ email: emailChannelConfigSchema.optional()
10349
10357
  }).optional();
10350
10358
  var ReporterOptionsSchema = external_exports.object({
10351
10359
  outputFile: external_exports.string().optional().default(DEFAULT_OPTIONS.outputFile),
@@ -10374,7 +10382,16 @@ var ReporterOptionsSchema = external_exports.object({
10374
10382
  notify: notifyConfigSchema,
10375
10383
  historyFile: external_exports.string().optional(),
10376
10384
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10377
- showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend)
10385
+ showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10386
+ flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10387
+ templatePath: external_exports.union([
10388
+ 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"),
10389
+ external_exports.array(
10390
+ 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")
10391
+ ).min(1, "templatePath array must not be empty")
10392
+ ]).optional().transform(
10393
+ (v) => v === void 0 ? void 0 : Array.isArray(v) ? v : [v]
10394
+ )
10378
10395
  });
10379
10396
  function parseOptions(raw) {
10380
10397
  const result = ReporterOptionsSchema.safeParse(raw ?? {});
@@ -11462,6 +11479,7 @@ var TemplateEngine = class {
11462
11479
  constructor() {
11463
11480
  this.chartjsInline = "";
11464
11481
  this.fontsBundleCss = "";
11482
+ this.customTemplateCache = /* @__PURE__ */ new Map();
11465
11483
  this.handlebars = import_handlebars.default.create();
11466
11484
  this.templatesDir = path4.resolve(__dirname, "templates");
11467
11485
  if (!fs3.existsSync(this.templatesDir)) {
@@ -11491,12 +11509,43 @@ var TemplateEngine = class {
11491
11509
  const ctx = this.buildContext(data, template);
11492
11510
  return compiledLayout(ctx);
11493
11511
  }
11512
+ renderFromPath(data, filePath) {
11513
+ if (!this.customTemplateCache.has(filePath)) {
11514
+ if (!fs3.existsSync(filePath)) {
11515
+ throw new Error(`[reportforge] Custom template not found: ${filePath}`);
11516
+ }
11517
+ const source = fs3.readFileSync(filePath, "utf8");
11518
+ this.customTemplateCache.set(filePath, this.handlebars.compile(source));
11519
+ }
11520
+ const compiledFn = this.customTemplateCache.get(filePath);
11521
+ return compiledFn(this.buildCustomContext(data));
11522
+ }
11523
+ buildCustomContext(data) {
11524
+ return {
11525
+ ...data,
11526
+ options: {
11527
+ showCharts: true,
11528
+ showTrend: true,
11529
+ showStackTraces: true,
11530
+ showFullEnvironment: true,
11531
+ showPassRate: true,
11532
+ showRetries: true,
11533
+ showFullFailures: true
11534
+ },
11535
+ tagGroups: this.buildTagGroups(data),
11536
+ slowTests: this.buildSlowTests(data),
11537
+ baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
11538
+ templateCSS: "",
11539
+ chartjsScript: this.buildChartjsScript()
11540
+ };
11541
+ }
11494
11542
  buildContext(data, template) {
11495
11543
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
11496
11544
  const templateCSS = this.readStyle(`${template}.css`);
11497
11545
  const optionsMap = {
11498
11546
  minimal: {
11499
11547
  showCharts: false,
11548
+ showTrend: false,
11500
11549
  showStackTraces: true,
11501
11550
  showFullEnvironment: false,
11502
11551
  showPassRate: true,
@@ -11505,6 +11554,7 @@ var TemplateEngine = class {
11505
11554
  },
11506
11555
  detailed: {
11507
11556
  showCharts: true,
11557
+ showTrend: true,
11508
11558
  showStackTraces: true,
11509
11559
  showFullEnvironment: true,
11510
11560
  showPassRate: true,
@@ -11513,6 +11563,7 @@ var TemplateEngine = class {
11513
11563
  },
11514
11564
  executive: {
11515
11565
  showCharts: true,
11566
+ showTrend: true,
11516
11567
  showStackTraces: false,
11517
11568
  showFullEnvironment: false,
11518
11569
  showPassRate: true,
@@ -11520,7 +11571,7 @@ var TemplateEngine = class {
11520
11571
  showFullFailures: false
11521
11572
  }
11522
11573
  };
11523
- const chartjsScript = this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
11574
+ const chartjsScript = this.buildChartjsScript();
11524
11575
  return {
11525
11576
  ...data,
11526
11577
  options: optionsMap[template],
@@ -11531,6 +11582,9 @@ var TemplateEngine = class {
11531
11582
  chartjsScript
11532
11583
  };
11533
11584
  }
11585
+ buildChartjsScript() {
11586
+ return this.chartjsInline ? `<script>${this.chartjsInline}</script>` : "<!-- Chart.js not bundled; charts disabled --><script>window.__chartsReady=true;</script>";
11587
+ }
11534
11588
  buildTagGroups(data) {
11535
11589
  const tagMap = /* @__PURE__ */ new Map();
11536
11590
  for (const project of data.projects) {
@@ -11911,10 +11965,10 @@ var FilenameResolver = class {
11911
11965
  fs8.mkdirSync(path7.dirname(absolute), { recursive: true });
11912
11966
  return absolute;
11913
11967
  }
11914
- injectTemplateSuffix(outputPath, templateId) {
11968
+ injectTemplateSuffix(outputPath, suffix) {
11915
11969
  const ext = path7.extname(outputPath);
11916
11970
  const base = outputPath.slice(0, outputPath.length - ext.length);
11917
- return `${base}-${templateId}${ext}`;
11971
+ return `${base}-${suffix}${ext}`;
11918
11972
  }
11919
11973
  formatDate(d) {
11920
11974
  const yyyy = d.getFullYear();
@@ -12034,9 +12088,15 @@ var PdfGenerator = class {
12034
12088
  }
12035
12089
  }
12036
12090
  async generateAll(data, options, _licenseInfo) {
12037
- const rawTemplates = Array.isArray(options.template) ? options.template : [options.template];
12038
- const templates = [...new Set(rawTemplates)];
12039
- const isMulti = templates.length > 1;
12091
+ const sources = this.resolveTemplateSources(options);
12092
+ const isMulti = sources.length > 1;
12093
+ for (const source of sources) {
12094
+ if (source.kind === "custom" && !fs10.existsSync(source.absolutePath)) {
12095
+ throw new Error(
12096
+ `[reportforge] Custom template not found: ${source.absolutePath}`
12097
+ );
12098
+ }
12099
+ }
12040
12100
  if (options.logo) {
12041
12101
  const logoBase64 = await this.screenshotEmbedder.embedLogoFile(options.logo);
12042
12102
  data.meta.branding.logoBase64 = logoBase64;
@@ -12053,11 +12113,13 @@ var PdfGenerator = class {
12053
12113
  const outputPaths = [];
12054
12114
  const { page } = await this.browserManager.launch(options.puppeteerExecutablePath);
12055
12115
  try {
12056
- for (const templateId of templates) {
12057
- const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, templateId) : baseOutputPath;
12116
+ for (const source of sources) {
12117
+ const suffix = source.kind === "builtin" ? source.id : source.label;
12118
+ const outputPath = isMulti ? this.filenameResolver.injectTemplateSuffix(baseOutputPath, suffix) : baseOutputPath;
12119
+ const templateId = source.kind === "builtin" ? source.id : "detailed";
12058
12120
  const templateData = { ...data, meta: { ...data.meta, template: templateId } };
12059
12121
  const templateOptions = { ...options, template: templateId };
12060
- await this.renderPdf(templateData, templateOptions, compression, outputPath, page);
12122
+ await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
12061
12123
  const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
12062
12124
  if (capBytes > 0) {
12063
12125
  const sizeBytes = (await fs10.promises.stat(outputPath)).size;
@@ -12066,7 +12128,7 @@ var PdfGenerator = class {
12066
12128
  logger.warn(
12067
12129
  `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}).`
12068
12130
  );
12069
- await this.renderPdf(templateData, templateOptions, tighter, outputPath, page);
12131
+ await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
12070
12132
  const finalSize = (await fs10.promises.stat(outputPath)).size;
12071
12133
  if (finalSize > capBytes) {
12072
12134
  logger.warn(
@@ -12089,6 +12151,17 @@ var PdfGenerator = class {
12089
12151
  }
12090
12152
  return outputPaths;
12091
12153
  }
12154
+ resolveTemplateSources(options) {
12155
+ if (options.templatePath && options.templatePath.length > 0) {
12156
+ return [...new Set(options.templatePath)].map((p) => ({
12157
+ kind: "custom",
12158
+ absolutePath: path9.isAbsolute(p) ? p : path9.resolve(process.cwd(), p),
12159
+ label: path9.basename(p, ".hbs")
12160
+ }));
12161
+ }
12162
+ const rawIds = Array.isArray(options.template) ? options.template : [options.template];
12163
+ return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
12164
+ }
12092
12165
  resolveSettings(options, data) {
12093
12166
  return resolveCompression({
12094
12167
  level: options.compressionLevel ?? "auto",
@@ -12123,7 +12196,7 @@ var PdfGenerator = class {
12123
12196
  * Separated from generateAll() so the size-cap retune can reuse it with
12124
12197
  * stricter settings without reshaping the rest of the flow.
12125
12198
  */
12126
- async renderPdf(data, options, compression, outputPath, page) {
12199
+ async renderPdf(data, options, source, compression, outputPath, page) {
12127
12200
  const paginated = await this.failurePaginator.paginate(
12128
12201
  data.failures,
12129
12202
  compression.maxInlineFailures,
@@ -12143,15 +12216,15 @@ var PdfGenerator = class {
12143
12216
  maxWidth: compression.maxWidth
12144
12217
  });
12145
12218
  }
12146
- const templateId = options.template;
12147
- logger.info(`Rendering template: ${templateId}`);
12148
- const html = this.templateEngine.render(renderData, templateId);
12219
+ const templateLabel = source.kind === "builtin" ? source.id : source.label;
12220
+ logger.info(`Rendering template: ${templateLabel}`);
12221
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
12149
12222
  const tempHtmlPath = await this.htmlWriter.write(html);
12150
12223
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12151
12224
  try {
12152
12225
  logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
12153
12226
  await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
12154
- const showCharts = templateId !== "minimal";
12227
+ const showCharts = source.kind === "builtin" ? source.id !== "minimal" : true;
12155
12228
  await this.chartRenderer.waitForCharts(page, showCharts);
12156
12229
  logger.info(`Generating PDF \u2192 ${path9.relative(process.cwd(), outputPath)}`);
12157
12230
  await page.pdf({
@@ -12212,6 +12285,42 @@ var HistoryManager = class {
12212
12285
  }
12213
12286
  };
12214
12287
 
12288
+ // src/history/FlakinessAnalyser.ts
12289
+ init_esm_shims();
12290
+ function computeTopN(entries, topN) {
12291
+ if (topN === 0) return [];
12292
+ const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
12293
+ if (qualifying.length === 0) return [];
12294
+ const map = /* @__PURE__ */ new Map();
12295
+ qualifying.forEach((entry, idx) => {
12296
+ const seen = /* @__PURE__ */ new Set();
12297
+ for (const t of entry.flakyTests) {
12298
+ if (seen.has(t.id)) continue;
12299
+ seen.add(t.id);
12300
+ if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
12301
+ map.get(t.id).hits.add(idx);
12302
+ }
12303
+ });
12304
+ const totalRuns = qualifying.length;
12305
+ const rows = [...map.entries()].map(([, { title, hits }]) => {
12306
+ const flakeCount = hits.size;
12307
+ const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
12308
+ const originalIdx = qualifying.length - 1 - reversedIdx;
12309
+ return hits.has(originalIdx);
12310
+ });
12311
+ return {
12312
+ title,
12313
+ flakeRate: Math.round(flakeCount / totalRuns * 100),
12314
+ flakeCount,
12315
+ totalRuns,
12316
+ sparkline
12317
+ };
12318
+ });
12319
+ return rows.sort(
12320
+ (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
12321
+ ).slice(0, topN);
12322
+ }
12323
+
12215
12324
  // src/notify/index.ts
12216
12325
  init_esm_shims();
12217
12326
 
@@ -12328,6 +12437,115 @@ ${name}`,
12328
12437
  };
12329
12438
  }
12330
12439
 
12440
+ // src/notify/EmailSender.ts
12441
+ init_esm_shims();
12442
+ import { readFile } from "fs/promises";
12443
+ import { basename as basename2 } from "path";
12444
+
12445
+ // src/notify/formatters/email.ts
12446
+ init_esm_shims();
12447
+ function buildEmailContent(stats, pdfPaths, reportTitle) {
12448
+ const label = statusLabel(stats.verdict);
12449
+ const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
12450
+ const name = reportName(pdfPaths, reportTitle);
12451
+ const html = `<!DOCTYPE html>
12452
+ <html lang="en">
12453
+ <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
12454
+ <body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
12455
+ <h2 style="margin:0 0 8px">${escHtml(label)}</h2>
12456
+ <p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
12457
+ <table style="border-collapse:collapse;width:100%">
12458
+ <tr style="background:#f5f5f5">
12459
+ <th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
12460
+ <th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
12461
+ </tr>
12462
+ <tr>
12463
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
12464
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
12465
+ </tr>
12466
+ <tr style="background:#f9f9f9">
12467
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
12468
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
12469
+ </tr>
12470
+ <tr>
12471
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
12472
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
12473
+ </tr>
12474
+ <tr style="background:#f9f9f9">
12475
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
12476
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
12477
+ </tr>
12478
+ <tr>
12479
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
12480
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
12481
+ </tr>
12482
+ <tr style="background:#f9f9f9">
12483
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
12484
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
12485
+ </tr>
12486
+ </table>
12487
+ <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>
12488
+ </body>
12489
+ </html>`;
12490
+ return { subject, html };
12491
+ }
12492
+ function escHtml(str) {
12493
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12494
+ }
12495
+
12496
+ // src/notify/EmailSender.ts
12497
+ var RESEND_ENDPOINT = "https://api.resend.com/emails";
12498
+ var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
12499
+ var EmailSender = class {
12500
+ async send(reportData, config, pdfPaths) {
12501
+ const apiKey = process.env["RESEND_API_KEY"];
12502
+ if (!apiKey) {
12503
+ logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
12504
+ return "skipped";
12505
+ }
12506
+ const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
12507
+ const { subject, html } = buildEmailContent(
12508
+ reportData.stats,
12509
+ pdfPaths,
12510
+ reportData.meta.title ?? ""
12511
+ );
12512
+ const body = { from, to: config.to, subject, html };
12513
+ if (config.attachPdf && pdfPaths.length > 0) {
12514
+ const pdfPath = pdfPaths[0];
12515
+ try {
12516
+ const content = await readFile(pdfPath);
12517
+ body["attachments"] = [{ filename: basename2(pdfPath), content: content.toString("base64") }];
12518
+ } catch (err) {
12519
+ logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
12520
+ }
12521
+ }
12522
+ const controller = new AbortController();
12523
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
12524
+ try {
12525
+ const res = await fetch(RESEND_ENDPOINT, {
12526
+ method: "POST",
12527
+ headers: {
12528
+ "Authorization": `Bearer ${apiKey}`,
12529
+ "Content-Type": "application/json"
12530
+ },
12531
+ body: JSON.stringify(body),
12532
+ signal: controller.signal
12533
+ });
12534
+ if (!res.ok) {
12535
+ const detail = await res.json().catch(() => ({}));
12536
+ throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
12537
+ }
12538
+ logger.info("[notify] email: sent");
12539
+ return "sent";
12540
+ } catch (err) {
12541
+ logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
12542
+ return "failed";
12543
+ } finally {
12544
+ clearTimeout(timeoutId);
12545
+ }
12546
+ }
12547
+ };
12548
+
12331
12549
  // src/notify/NotificationSender.ts
12332
12550
  function shouldNotify(channel, stats) {
12333
12551
  if (!channel.enabled) return false;
@@ -12356,6 +12574,18 @@ var NotificationSender = class {
12356
12574
  return this.post(name, channel.url, payload, result);
12357
12575
  })
12358
12576
  );
12577
+ if (notifyConfig.email) {
12578
+ if (!shouldNotify(notifyConfig.email, reportData.stats)) {
12579
+ result.skipped.push("email");
12580
+ } else {
12581
+ const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
12582
+ if (outcome === "sent") result.sent.push("email");
12583
+ else if (outcome === "failed") result.failed.push("email");
12584
+ else result.skipped.push("email");
12585
+ }
12586
+ } else {
12587
+ result.skipped.push("email");
12588
+ }
12359
12589
  return result;
12360
12590
  }
12361
12591
  async post(name, url, payload, result) {
@@ -12387,7 +12617,7 @@ var PdfReporter = class {
12387
12617
  this.dataCollector = new DataCollector();
12388
12618
  this.pdfGenerator = new PdfGenerator();
12389
12619
  this.options = parseOptions(rawOptions);
12390
- const version = true ? "0.0.1" : "0.x";
12620
+ const version = true ? "0.2.0" : "0.x";
12391
12621
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12392
12622
  this.licenseClient = new LicenseClient({
12393
12623
  licenseKey: this.options.licenseKey,
@@ -12443,6 +12673,7 @@ var PdfReporter = class {
12443
12673
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12444
12674
  const hm = new HistoryManager(historyPath);
12445
12675
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12676
+ const flakyTests = collectFlakyTests(collected.projects);
12446
12677
  const entry = {
12447
12678
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12448
12679
  timestamp: Date.now(),
@@ -12452,9 +12683,13 @@ var PdfReporter = class {
12452
12683
  failed: collected.stats.failed,
12453
12684
  flaky: collected.stats.flaky,
12454
12685
  duration: collected.stats.duration,
12455
- verdict: deriveVerdict(collected.stats)
12686
+ verdict: deriveVerdict(collected.stats),
12687
+ flakyTests
12456
12688
  };
12457
12689
  const entries = hm.append(entry, this.options.historySize);
12690
+ this._populateHistoryCharts(entries, collected);
12691
+ }
12692
+ _populateHistoryCharts(entries, collected) {
12458
12693
  if (entries.length >= 2) {
12459
12694
  collected.charts.trend = {
12460
12695
  entries: entries.map((e) => ({
@@ -12468,11 +12703,17 @@ var PdfReporter = class {
12468
12703
  delta: entries[0].passRate - entries[1].passRate
12469
12704
  };
12470
12705
  }
12706
+ if (this.options.flakinessTopN > 0) {
12707
+ const table = computeTopN(entries, this.options.flakinessTopN);
12708
+ if (table.length > 0) {
12709
+ collected.charts.flakinessTable = table;
12710
+ }
12711
+ }
12471
12712
  }
12472
12713
  _buildReportData(collected, licenseInfo) {
12473
12714
  const { projects, stats, failures, environment, charts } = collected;
12474
12715
  const projectName = this.options.projectName ?? this.resolveProjectName();
12475
- const firstTemplate = Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
12716
+ const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
12476
12717
  return {
12477
12718
  meta: {
12478
12719
  title: this.options.reportTitle,
@@ -12530,6 +12771,15 @@ var PdfReporter = class {
12530
12771
  logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
12531
12772
  });
12532
12773
  child.unref();
12774
+ if (platform === "win32") {
12775
+ child.on("close", (code) => {
12776
+ if (code !== 0 && code !== null) {
12777
+ logger.warn(
12778
+ `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}`
12779
+ );
12780
+ }
12781
+ });
12782
+ }
12533
12783
  } catch (err) {
12534
12784
  logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
12535
12785
  }
@@ -12554,6 +12804,24 @@ function resolveHistoryPath(options, cwd) {
12554
12804
  const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
12555
12805
  return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
12556
12806
  }
12807
+ function flattenSuiteNodes(suites) {
12808
+ return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
12809
+ }
12810
+ function collectFlakyTests(projects) {
12811
+ const seen = /* @__PURE__ */ new Set();
12812
+ const result = [];
12813
+ for (const project of projects) {
12814
+ for (const suite of flattenSuiteNodes(project.suites)) {
12815
+ for (const test of suite.tests) {
12816
+ if (test.status === "flaky" && !seen.has(test.id)) {
12817
+ seen.add(test.id);
12818
+ result.push({ id: test.id, title: test.fullTitle });
12819
+ }
12820
+ }
12821
+ }
12822
+ }
12823
+ return result;
12824
+ }
12557
12825
 
12558
12826
  // src/index.ts
12559
12827
  function defineReporterConfig(options) {
@@ -14,7 +14,7 @@
14
14
 
15
15
  {{> executive-summary options=(obj showPassRate=true) }}
16
16
 
17
- {{> charts options=(obj showCharts=true) }}
17
+ {{> charts options=(obj showCharts=true showTrend=options.showTrend) }}
18
18
 
19
19
  <div class="page-group">
20
20
  {{> requirements-matrix }}
@@ -18,7 +18,7 @@
18
18
 
19
19
  {{> executive-summary options=(obj showPassRate=true) }}
20
20
 
21
- {{> charts options=(obj showCharts=true) }}
21
+ {{> charts options=(obj showCharts=true showTrend=options.showTrend) }}
22
22
 
23
23
  {{> slow-tests limit=5 showProject=false}}
24
24
 
@@ -10,9 +10,8 @@
10
10
  </div>
11
11
  </div>
12
12
 
13
- {{!-- Trend card: detailed + executive. Minimal skipped (no charts at all).
14
- In multi-template runs generateAll overrides meta.template per render pass. --}}
15
- {{#unless (eq meta.template 'minimal')}}
13
+ {{!-- Trend card: detailed + executive + custom templates. Minimal has showTrend=false. --}}
14
+ {{#if options.showTrend}}
16
15
  {{#if charts.trend}}
17
16
  <div class="trend-card">
18
17
  <div class="trend-card__header">
@@ -29,7 +28,43 @@
29
28
  <div class="trend-run-history" id="run-history-table"></div>
30
29
  </div>
31
30
  {{/if}}
32
- {{/unless}}
31
+ {{/if}}
32
+
33
+ {{#if options.showTrend}}
34
+ {{#if charts.flakinessTable}}
35
+ <div class="flakiness-card">
36
+ <div class="flakiness-card__header">
37
+ <span class="flakiness-card__title">Top flaky tests &mdash; last {{charts.flakinessTable.[0].totalRuns}} runs</span>
38
+ </div>
39
+ <table class="flakiness-table">
40
+ <thead>
41
+ <tr>
42
+ <th class="flakiness-table__test">Test</th>
43
+ <th class="flakiness-table__rate">Flake rate</th>
44
+ <th class="flakiness-table__count">Runs flaky</th>
45
+ <th class="flakiness-table__spark">History</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ {{#each charts.flakinessTable}}
50
+ <tr>
51
+ <td class="flakiness-table__test" title="{{this.title}}">{{this.title}}</td>
52
+ <td class="flakiness-table__rate">{{this.flakeRate}}%</td>
53
+ <td class="flakiness-table__count">{{this.flakeCount}} / {{this.totalRuns}}</td>
54
+ <td class="flakiness-table__spark">
55
+ <span class="flaky-spark">
56
+ {{#each this.sparkline}}
57
+ <span class="flaky-dot{{#if this}} flaky-dot--hit{{/if}}"></span>
58
+ {{/each}}
59
+ </span>
60
+ </td>
61
+ </tr>
62
+ {{/each}}
63
+ </tbody>
64
+ </table>
65
+ </div>
66
+ {{/if}}
67
+ {{/if}}
33
68
 
34
69
  <script>
35
70
  (function() {
@@ -144,7 +179,7 @@
144
179
  }
145
180
  });
146
181
 
147
- {{#unless (eq meta.template 'minimal')}}{{#if charts.trend}}
182
+ {{#if options.showTrend}}{{#if charts.trend}}
148
183
  // entries are newest-first in storage; reverse for oldest-left (chronological) display
149
184
  var trendRaw = {{{safeJsonData charts.trend.entries}}};
150
185
  var trendData = trendRaw.slice().reverse();
@@ -298,7 +333,7 @@
298
333
  histEl.appendChild(row);
299
334
  });
300
335
  }
301
- {{/if}}{{/unless}}
336
+ {{/if}}{{/if}}
302
337
  })();
303
338
  </script>
304
339
  {{else}}