@reportforge/playwright-pdf 0.1.0 → 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/README.md CHANGED
@@ -53,7 +53,8 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
53
53
  - **Dynamic filenames** — `{date}`, `{branch}`, `{status}` tokens in the output path
54
54
  - **CI/CD snippets** — GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps
55
55
  - **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
56
- - **Webhook notifications** — post pass/fail summaries to Slack, Teams, or Discord on every run; configurable per channel with `on: 'always' | 'failure' | 'success'`
56
+ - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email supports optional PDF attachment via `attachPdf`
57
+ - **Flakiness trend** — top-N flakiest tests table with per-test dot sparkline across stored history runs in the `detailed` template; `flakinessTopN` option (default 5, `0` disables)
57
58
  - **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
58
59
 
59
60
  ---
@@ -183,10 +184,11 @@ All options are the second element of the reporter tuple.
183
184
  | `serverUrl` | `string` | `'https://reportforge.org'` | Licensing server base URL (override only for self-hosting) |
184
185
  | `open` | `boolean` | `false` | Open PDF after generation (local only) |
185
186
  | `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard files to merge into one PDF. |
186
- | `notify` | `object` | — | Webhook notification channels. See [Webhook Notifications](#webhook-notifications). |
187
+ | `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See [Notifications](#notifications). |
187
188
  | `historyFile` | `string` | `~/.reportforge/{key}/history.json` | Path to history JSON. Relative paths resolved from cwd at startup. |
188
189
  | `historySize` | `number` | `10` | Max run entries to keep (integer ≥ 2). |
189
- | `showTrend` | `boolean` | `true` | Set to `false` to disable history write and sparkline entirely. |
190
+ | `showTrend` | `boolean` | `true` | Set to `false` to disable history write and trend charts entirely. |
191
+ | `flakinessTopN` | `number` | `5` | Max flaky tests to show in the `detailed` template flakiness table. `0` disables the table. |
190
192
  | `puppeteerExecutablePath` | `string` | auto-detect | Path to Chrome / Chromium |
191
193
 
192
194
  Full reference: [reportforge.org/docs#configuration](https://reportforge.org/docs#configuration).
@@ -215,30 +217,37 @@ The merge step can run against a dummy test file with zero tests — `shardResul
215
217
 
216
218
  > **Note:** Timed-out tests are counted as failures in shard mode (Playwright JSON stats do not distinguish `timedOut` from `failed`).
217
219
 
218
- ### Webhook Notifications
220
+ ### Notifications
219
221
 
220
- Post a summary message to Slack, Teams, or Discord after each run.
222
+ Post a summary to Slack, Teams, Discord, or email after each run.
221
223
 
222
224
  ```typescript
223
225
  notify: {
224
226
  slack: {
225
- url: 'https://hooks.slack.com/services/...',
226
- enabled: true, // required — defaults to false
227
- on: 'always', // 'always' | 'failure' | 'success' (default: 'always')
227
+ url: process.env.SLACK_WEBHOOK_URL,
228
+ enabled: true,
229
+ on: 'failure', // 'always' | 'failure' | 'success' (default: 'always')
228
230
  },
229
231
  teams: {
230
- url: 'https://outlook.office.com/webhook/...',
232
+ url: process.env.TEAMS_WEBHOOK_URL,
231
233
  enabled: true,
232
- on: 'failure',
234
+ on: 'always',
233
235
  },
234
236
  discord: {
235
- url: 'https://discord.com/api/webhooks/...',
237
+ url: process.env.DISCORD_WEBHOOK_URL,
238
+ enabled: true,
239
+ on: 'failure',
240
+ },
241
+ email: {
242
+ to: ['team@company.com', 'qa@company.com'],
236
243
  enabled: true,
244
+ on: 'failure',
245
+ attachPdf: false, // set true to attach the generated PDF
237
246
  },
238
247
  },
239
248
  ```
240
249
 
241
- Each channel is independent. `enabled: false` (the default) lets you store a URL without activating it — useful for staging configs. Set `enabled: true` to activate.
250
+ Each channel is independent. `enabled: false` (the default) lets you store a URL without activating it — useful for staging configs.
242
251
 
243
252
  The `on` trigger controls when the message fires:
244
253
 
@@ -248,7 +257,9 @@ The `on` trigger controls when the message fires:
248
257
  | `'failure'` | `stats.failed > 0` or run timed out / interrupted |
249
258
  | `'success'` | All tests passed |
250
259
 
251
- The summary includes pass rate, test counts, duration, and the report filename. Notifications require a valid license and fire after PDF generation (or after a PDF generation failureyou still get the ping).
260
+ **Email** requires `RESEND_API_KEY` in the environment (get one at [resend.com](https://resend.com)). Sender defaults to `noreply@reportforge.org`; override with `RESEND_FROM`. Store webhook URLs and API keys as CI secretsnever commit them.
261
+
262
+ The summary includes pass rate, test counts, duration, and the report filename. Notifications require a valid license and fire after PDF generation (or after a PDF failure — you still get the ping).
252
263
 
253
264
  ### Test History Trending
254
265
 
@@ -277,6 +288,19 @@ reporter: [['@reportforge/playwright-pdf', {
277
288
 
278
289
  **Monorepo** — each package needs a distinct `historyFile` (e.g. `.reportforge/api-history.json`, `.reportforge/web-history.json`) so runs do not overwrite each other.
279
290
 
291
+ ### Flakiness Trend Table
292
+
293
+ The `detailed` template includes a **Top flaky tests** table showing which tests flake most often across your stored history. Each row shows the test name, flake rate (%), run count, and a dot sparkline (amber dot = flaky in that run).
294
+
295
+ ```typescript
296
+ reporter: [['@reportforge/playwright-pdf', {
297
+ template: 'detailed',
298
+ flakinessTopN: 5, // default — show top 5; set 0 to disable
299
+ }]]
300
+ ```
301
+
302
+ The table is gated behind `showTrend: true` (the default) and appears automatically once history entries are present. Runs written before the flakiness feature was added are excluded from the denominator — the table fills in correctly as newer runs accumulate. Set `flakinessTopN: 0` to hide the table entirely.
303
+
280
304
  ### Filename tokens
281
305
 
282
306
  | Token | Replaced with | Example |
package/dist/index.d.mts CHANGED
@@ -176,6 +176,12 @@ interface ReporterOptions {
176
176
  * @default true
177
177
  */
178
178
  showTrend?: boolean;
179
+ /**
180
+ * Number of flaky tests to display in the flakiness table (detailed template).
181
+ * Set to 0 to disable. Must be a non-negative integer.
182
+ * @default 5
183
+ */
184
+ flakinessTopN?: number;
179
185
  /**
180
186
  * Path to a custom Handlebars (.hbs) template file. When set, takes
181
187
  * precedence over `template`. Relative paths resolve from `process.cwd()`.
@@ -256,6 +262,7 @@ declare class PdfReporter implements Reporter {
256
262
  onEnd(result: FullResult): Promise<void>;
257
263
  private _collectData;
258
264
  private _appendTrend;
265
+ private _populateHistoryCharts;
259
266
  private _buildReportData;
260
267
  printsToStdio(): boolean;
261
268
  private resolveLicense;
package/dist/index.d.ts CHANGED
@@ -176,6 +176,12 @@ interface ReporterOptions {
176
176
  * @default true
177
177
  */
178
178
  showTrend?: boolean;
179
+ /**
180
+ * Number of flaky tests to display in the flakiness table (detailed template).
181
+ * Set to 0 to disable. Must be a non-negative integer.
182
+ * @default 5
183
+ */
184
+ flakinessTopN?: number;
179
185
  /**
180
186
  * Path to a custom Handlebars (.hbs) template file. When set, takes
181
187
  * precedence over `template`. Relative paths resolve from `process.cwd()`.
@@ -256,6 +262,7 @@ declare class PdfReporter implements Reporter {
256
262
  onEnd(result: FullResult): Promise<void>;
257
263
  private _collectData;
258
264
  private _appendTrend;
265
+ private _populateHistoryCharts;
259
266
  private _buildReportData;
260
267
  printsToStdio(): boolean;
261
268
  private resolveLicense;
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),
@@ -10374,6 +10382,7 @@ var ReporterOptionsSchema = external_exports.object({
10374
10382
  historyFile: external_exports.string().optional(),
10375
10383
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10376
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),
10377
10386
  templatePath: external_exports.union([
10378
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"),
10379
10388
  external_exports.array(
@@ -12233,7 +12242,7 @@ function fmtMb(bytes) {
12233
12242
  }
12234
12243
 
12235
12244
  // src/reporter.ts
12236
- var import_path11 = __toESM(require("path"));
12245
+ var import_path12 = __toESM(require("path"));
12237
12246
  var import_crypto7 = __toESM(require("crypto"));
12238
12247
  var import_os6 = __toESM(require("os"));
12239
12248
 
@@ -12275,6 +12284,42 @@ var HistoryManager = class {
12275
12284
  }
12276
12285
  };
12277
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
+
12278
12323
  // src/notify/index.ts
12279
12324
  init_cjs_shims();
12280
12325
 
@@ -12391,6 +12436,115 @@ ${name}`,
12391
12436
  };
12392
12437
  }
12393
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
+
12394
12548
  // src/notify/NotificationSender.ts
12395
12549
  function shouldNotify(channel, stats) {
12396
12550
  if (!channel.enabled) return false;
@@ -12419,6 +12573,18 @@ var NotificationSender = class {
12419
12573
  return this.post(name, channel.url, payload, result);
12420
12574
  })
12421
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
+ }
12422
12588
  return result;
12423
12589
  }
12424
12590
  async post(name, url, payload, result) {
@@ -12450,7 +12616,7 @@ var PdfReporter = class {
12450
12616
  this.dataCollector = new DataCollector();
12451
12617
  this.pdfGenerator = new PdfGenerator();
12452
12618
  this.options = parseOptions(rawOptions);
12453
- const version = true ? "0.1.0" : "0.x";
12619
+ const version = true ? "0.2.0" : "0.x";
12454
12620
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12455
12621
  this.licenseClient = new LicenseClient({
12456
12622
  licenseKey: this.options.licenseKey,
@@ -12506,6 +12672,7 @@ var PdfReporter = class {
12506
12672
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12507
12673
  const hm = new HistoryManager(historyPath);
12508
12674
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12675
+ const flakyTests = collectFlakyTests(collected.projects);
12509
12676
  const entry = {
12510
12677
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12511
12678
  timestamp: Date.now(),
@@ -12515,9 +12682,13 @@ var PdfReporter = class {
12515
12682
  failed: collected.stats.failed,
12516
12683
  flaky: collected.stats.flaky,
12517
12684
  duration: collected.stats.duration,
12518
- verdict: deriveVerdict(collected.stats)
12685
+ verdict: deriveVerdict(collected.stats),
12686
+ flakyTests
12519
12687
  };
12520
12688
  const entries = hm.append(entry, this.options.historySize);
12689
+ this._populateHistoryCharts(entries, collected);
12690
+ }
12691
+ _populateHistoryCharts(entries, collected) {
12521
12692
  if (entries.length >= 2) {
12522
12693
  collected.charts.trend = {
12523
12694
  entries: entries.map((e) => ({
@@ -12531,6 +12702,12 @@ var PdfReporter = class {
12531
12702
  delta: entries[0].passRate - entries[1].passRate
12532
12703
  };
12533
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
+ }
12534
12711
  }
12535
12712
  _buildReportData(collected, licenseInfo) {
12536
12713
  const { projects, stats, failures, environment, charts } = collected;
@@ -12571,7 +12748,7 @@ var PdfReporter = class {
12571
12748
  }
12572
12749
  resolveProjectName() {
12573
12750
  try {
12574
- const pkgPath = import_path11.default.resolve(process.cwd(), "package.json");
12751
+ const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
12575
12752
  const pkg = require(pkgPath);
12576
12753
  return pkg.name ?? "Playwright Tests";
12577
12754
  } catch {
@@ -12621,10 +12798,28 @@ function deriveVerdict(stats) {
12621
12798
  }
12622
12799
  function resolveHistoryPath(options, cwd) {
12623
12800
  if (options.historyFile) {
12624
- 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);
12625
12802
  }
12626
12803
  const projectKey = import_crypto7.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
12627
- 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;
12628
12823
  }
12629
12824
 
12630
12825
  // src/index.ts
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),
@@ -10375,6 +10383,7 @@ var ReporterOptionsSchema = external_exports.object({
10375
10383
  historyFile: external_exports.string().optional(),
10376
10384
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10377
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),
10378
10387
  templatePath: external_exports.union([
10379
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"),
10380
10389
  external_exports.array(
@@ -12276,6 +12285,42 @@ var HistoryManager = class {
12276
12285
  }
12277
12286
  };
12278
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
+
12279
12324
  // src/notify/index.ts
12280
12325
  init_esm_shims();
12281
12326
 
@@ -12392,6 +12437,115 @@ ${name}`,
12392
12437
  };
12393
12438
  }
12394
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
+
12395
12549
  // src/notify/NotificationSender.ts
12396
12550
  function shouldNotify(channel, stats) {
12397
12551
  if (!channel.enabled) return false;
@@ -12420,6 +12574,18 @@ var NotificationSender = class {
12420
12574
  return this.post(name, channel.url, payload, result);
12421
12575
  })
12422
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
+ }
12423
12589
  return result;
12424
12590
  }
12425
12591
  async post(name, url, payload, result) {
@@ -12451,7 +12617,7 @@ var PdfReporter = class {
12451
12617
  this.dataCollector = new DataCollector();
12452
12618
  this.pdfGenerator = new PdfGenerator();
12453
12619
  this.options = parseOptions(rawOptions);
12454
- const version = true ? "0.1.0" : "0.x";
12620
+ const version = true ? "0.2.0" : "0.x";
12455
12621
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12456
12622
  this.licenseClient = new LicenseClient({
12457
12623
  licenseKey: this.options.licenseKey,
@@ -12507,6 +12673,7 @@ var PdfReporter = class {
12507
12673
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12508
12674
  const hm = new HistoryManager(historyPath);
12509
12675
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12676
+ const flakyTests = collectFlakyTests(collected.projects);
12510
12677
  const entry = {
12511
12678
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12512
12679
  timestamp: Date.now(),
@@ -12516,9 +12683,13 @@ var PdfReporter = class {
12516
12683
  failed: collected.stats.failed,
12517
12684
  flaky: collected.stats.flaky,
12518
12685
  duration: collected.stats.duration,
12519
- verdict: deriveVerdict(collected.stats)
12686
+ verdict: deriveVerdict(collected.stats),
12687
+ flakyTests
12520
12688
  };
12521
12689
  const entries = hm.append(entry, this.options.historySize);
12690
+ this._populateHistoryCharts(entries, collected);
12691
+ }
12692
+ _populateHistoryCharts(entries, collected) {
12522
12693
  if (entries.length >= 2) {
12523
12694
  collected.charts.trend = {
12524
12695
  entries: entries.map((e) => ({
@@ -12532,6 +12703,12 @@ var PdfReporter = class {
12532
12703
  delta: entries[0].passRate - entries[1].passRate
12533
12704
  };
12534
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
+ }
12535
12712
  }
12536
12713
  _buildReportData(collected, licenseInfo) {
12537
12714
  const { projects, stats, failures, environment, charts } = collected;
@@ -12627,6 +12804,24 @@ function resolveHistoryPath(options, cwd) {
12627
12804
  const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
12628
12805
  return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
12629
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
+ }
12630
12825
 
12631
12826
  // src/index.ts
12632
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
 
@@ -30,6 +30,42 @@
30
30
  {{/if}}
31
31
  {{/if}}
32
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}}
68
+
33
69
  <script>
34
70
  (function() {
35
71
  var remaining = 0;
@@ -307,5 +307,106 @@
307
307
  .verdict-partial { background: var(--flaky); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
308
308
  .verdict-failed { background: var(--fail); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
309
309
  .verdict-unknown { background: var(--skip); -webkit-print-color-adjust: exact; print-color-adjust: exact; }
310
+
311
+ /* ── Flakiness trend table (R8) ─────────────────────────────────────────── */
312
+ .flakiness-card {
313
+ margin: 24px 0 0;
314
+ border: 1px solid var(--border);
315
+ border-radius: 8px;
316
+ overflow: hidden;
317
+ }
318
+
319
+ .flakiness-card__header {
320
+ display: flex;
321
+ align-items: center;
322
+ padding: 10px 16px;
323
+ background: var(--surface-2);
324
+ border-bottom: 1px solid var(--border);
325
+ }
326
+
327
+ .flakiness-card__title {
328
+ font-size: 11px;
329
+ font-weight: 600;
330
+ text-transform: uppercase;
331
+ letter-spacing: 0.04em;
332
+ color: var(--text-3);
333
+ }
334
+
335
+ .flakiness-table {
336
+ width: 100%;
337
+ border-collapse: collapse;
338
+ font-size: 11px;
339
+ }
340
+
341
+ .flakiness-table thead th {
342
+ padding: 8px 12px;
343
+ text-align: left;
344
+ font-weight: 600;
345
+ font-size: 10px;
346
+ text-transform: uppercase;
347
+ letter-spacing: 0.04em;
348
+ color: var(--text-3);
349
+ border-bottom: 1px solid var(--border);
350
+ background: var(--surface-2);
351
+ }
352
+
353
+ .flakiness-table tbody tr {
354
+ border-bottom: 1px solid var(--border);
355
+ }
356
+
357
+ .flakiness-table tbody tr:last-child {
358
+ border-bottom: none;
359
+ }
360
+
361
+ .flakiness-table tbody td {
362
+ padding: 8px 12px;
363
+ vertical-align: middle;
364
+ color: var(--text-1);
365
+ }
366
+
367
+ .flakiness-table__test {
368
+ max-width: 300px;
369
+ overflow: hidden;
370
+ text-overflow: ellipsis;
371
+ white-space: nowrap;
372
+ }
373
+
374
+ .flakiness-table__rate {
375
+ width: 72px;
376
+ font-weight: 600;
377
+ color: var(--flaky);
378
+ }
379
+
380
+ .flakiness-table__count {
381
+ width: 72px;
382
+ color: var(--text-3);
383
+ }
384
+
385
+ .flakiness-table__spark {
386
+ width: 140px;
387
+ }
388
+
389
+ .flaky-spark {
390
+ display: flex;
391
+ gap: 3px;
392
+ align-items: center;
393
+ }
394
+
395
+ .flaky-dot {
396
+ flex-shrink: 0;
397
+ width: 7px;
398
+ height: 7px;
399
+ border-radius: 50%;
400
+ background: var(--border, #e5e3d8);
401
+ print-color-adjust: exact;
402
+ -webkit-print-color-adjust: exact;
403
+ }
404
+
405
+ .flaky-dot--hit {
406
+ background: #f59e0b;
407
+ print-color-adjust: exact;
408
+ -webkit-print-color-adjust: exact;
409
+ }
410
+
310
411
  </content>
311
412
  </invoke>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "MIT",
6
6
  "author": "ReportForge",