@reportforge/playwright-pdf 0.1.0 → 0.2.1

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(
@@ -10962,6 +10971,18 @@ function detectCiEnv() {
10962
10971
  ciProvider: "CircleCI"
10963
10972
  };
10964
10973
  }
10974
+ if (env.BITBUCKET_BUILD_NUMBER) {
10975
+ const workspace = env.BITBUCKET_WORKSPACE;
10976
+ const repoSlug = env.BITBUCKET_REPO_SLUG;
10977
+ const buildNumber = env.BITBUCKET_BUILD_NUMBER;
10978
+ const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
10979
+ return {
10980
+ branch: env.BITBUCKET_BRANCH || "unknown",
10981
+ commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
10982
+ buildUrl,
10983
+ ciProvider: "Bitbucket Pipelines"
10984
+ };
10985
+ }
10965
10986
  return {
10966
10987
  branch: gitBranch(),
10967
10988
  commit: gitCommit(),
@@ -12233,7 +12254,7 @@ function fmtMb(bytes) {
12233
12254
  }
12234
12255
 
12235
12256
  // src/reporter.ts
12236
- var import_path11 = __toESM(require("path"));
12257
+ var import_path12 = __toESM(require("path"));
12237
12258
  var import_crypto7 = __toESM(require("crypto"));
12238
12259
  var import_os6 = __toESM(require("os"));
12239
12260
 
@@ -12275,6 +12296,42 @@ var HistoryManager = class {
12275
12296
  }
12276
12297
  };
12277
12298
 
12299
+ // src/history/FlakinessAnalyser.ts
12300
+ init_cjs_shims();
12301
+ function computeTopN(entries, topN) {
12302
+ if (topN === 0) return [];
12303
+ const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
12304
+ if (qualifying.length === 0) return [];
12305
+ const map = /* @__PURE__ */ new Map();
12306
+ qualifying.forEach((entry, idx) => {
12307
+ const seen = /* @__PURE__ */ new Set();
12308
+ for (const t of entry.flakyTests) {
12309
+ if (seen.has(t.id)) continue;
12310
+ seen.add(t.id);
12311
+ if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
12312
+ map.get(t.id).hits.add(idx);
12313
+ }
12314
+ });
12315
+ const totalRuns = qualifying.length;
12316
+ const rows = [...map.entries()].map(([, { title, hits }]) => {
12317
+ const flakeCount = hits.size;
12318
+ const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
12319
+ const originalIdx = qualifying.length - 1 - reversedIdx;
12320
+ return hits.has(originalIdx);
12321
+ });
12322
+ return {
12323
+ title,
12324
+ flakeRate: Math.round(flakeCount / totalRuns * 100),
12325
+ flakeCount,
12326
+ totalRuns,
12327
+ sparkline
12328
+ };
12329
+ });
12330
+ return rows.sort(
12331
+ (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
12332
+ ).slice(0, topN);
12333
+ }
12334
+
12278
12335
  // src/notify/index.ts
12279
12336
  init_cjs_shims();
12280
12337
 
@@ -12391,6 +12448,115 @@ ${name}`,
12391
12448
  };
12392
12449
  }
12393
12450
 
12451
+ // src/notify/EmailSender.ts
12452
+ init_cjs_shims();
12453
+ var import_promises = require("fs/promises");
12454
+ var import_path11 = require("path");
12455
+
12456
+ // src/notify/formatters/email.ts
12457
+ init_cjs_shims();
12458
+ function buildEmailContent(stats, pdfPaths, reportTitle) {
12459
+ const label = statusLabel(stats.verdict);
12460
+ const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
12461
+ const name = reportName(pdfPaths, reportTitle);
12462
+ const html = `<!DOCTYPE html>
12463
+ <html lang="en">
12464
+ <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
12465
+ <body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
12466
+ <h2 style="margin:0 0 8px">${escHtml(label)}</h2>
12467
+ <p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
12468
+ <table style="border-collapse:collapse;width:100%">
12469
+ <tr style="background:#f5f5f5">
12470
+ <th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
12471
+ <th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
12472
+ </tr>
12473
+ <tr>
12474
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
12475
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
12476
+ </tr>
12477
+ <tr style="background:#f9f9f9">
12478
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
12479
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
12480
+ </tr>
12481
+ <tr>
12482
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
12483
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
12484
+ </tr>
12485
+ <tr style="background:#f9f9f9">
12486
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
12487
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
12488
+ </tr>
12489
+ <tr>
12490
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
12491
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
12492
+ </tr>
12493
+ <tr style="background:#f9f9f9">
12494
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
12495
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
12496
+ </tr>
12497
+ </table>
12498
+ <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>
12499
+ </body>
12500
+ </html>`;
12501
+ return { subject, html };
12502
+ }
12503
+ function escHtml(str) {
12504
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12505
+ }
12506
+
12507
+ // src/notify/EmailSender.ts
12508
+ var RESEND_ENDPOINT = "https://api.resend.com/emails";
12509
+ var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
12510
+ var EmailSender = class {
12511
+ async send(reportData, config, pdfPaths) {
12512
+ const apiKey = process.env["RESEND_API_KEY"];
12513
+ if (!apiKey) {
12514
+ logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
12515
+ return "skipped";
12516
+ }
12517
+ const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
12518
+ const { subject, html } = buildEmailContent(
12519
+ reportData.stats,
12520
+ pdfPaths,
12521
+ reportData.meta.title ?? ""
12522
+ );
12523
+ const body = { from, to: config.to, subject, html };
12524
+ if (config.attachPdf && pdfPaths.length > 0) {
12525
+ const pdfPath = pdfPaths[0];
12526
+ try {
12527
+ const content = await (0, import_promises.readFile)(pdfPath);
12528
+ body["attachments"] = [{ filename: (0, import_path11.basename)(pdfPath), content: content.toString("base64") }];
12529
+ } catch (err) {
12530
+ logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
12531
+ }
12532
+ }
12533
+ const controller = new AbortController();
12534
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
12535
+ try {
12536
+ const res = await fetch(RESEND_ENDPOINT, {
12537
+ method: "POST",
12538
+ headers: {
12539
+ "Authorization": `Bearer ${apiKey}`,
12540
+ "Content-Type": "application/json"
12541
+ },
12542
+ body: JSON.stringify(body),
12543
+ signal: controller.signal
12544
+ });
12545
+ if (!res.ok) {
12546
+ const detail = await res.json().catch(() => ({}));
12547
+ throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
12548
+ }
12549
+ logger.info("[notify] email: sent");
12550
+ return "sent";
12551
+ } catch (err) {
12552
+ logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
12553
+ return "failed";
12554
+ } finally {
12555
+ clearTimeout(timeoutId);
12556
+ }
12557
+ }
12558
+ };
12559
+
12394
12560
  // src/notify/NotificationSender.ts
12395
12561
  function shouldNotify(channel, stats) {
12396
12562
  if (!channel.enabled) return false;
@@ -12419,6 +12585,18 @@ var NotificationSender = class {
12419
12585
  return this.post(name, channel.url, payload, result);
12420
12586
  })
12421
12587
  );
12588
+ if (notifyConfig.email) {
12589
+ if (!shouldNotify(notifyConfig.email, reportData.stats)) {
12590
+ result.skipped.push("email");
12591
+ } else {
12592
+ const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
12593
+ if (outcome === "sent") result.sent.push("email");
12594
+ else if (outcome === "failed") result.failed.push("email");
12595
+ else result.skipped.push("email");
12596
+ }
12597
+ } else {
12598
+ result.skipped.push("email");
12599
+ }
12422
12600
  return result;
12423
12601
  }
12424
12602
  async post(name, url, payload, result) {
@@ -12450,7 +12628,7 @@ var PdfReporter = class {
12450
12628
  this.dataCollector = new DataCollector();
12451
12629
  this.pdfGenerator = new PdfGenerator();
12452
12630
  this.options = parseOptions(rawOptions);
12453
- const version = true ? "0.1.0" : "0.x";
12631
+ const version = true ? "0.2.1" : "0.x";
12454
12632
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12455
12633
  this.licenseClient = new LicenseClient({
12456
12634
  licenseKey: this.options.licenseKey,
@@ -12506,6 +12684,7 @@ var PdfReporter = class {
12506
12684
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12507
12685
  const hm = new HistoryManager(historyPath);
12508
12686
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12687
+ const flakyTests = collectFlakyTests(collected.projects);
12509
12688
  const entry = {
12510
12689
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12511
12690
  timestamp: Date.now(),
@@ -12515,9 +12694,13 @@ var PdfReporter = class {
12515
12694
  failed: collected.stats.failed,
12516
12695
  flaky: collected.stats.flaky,
12517
12696
  duration: collected.stats.duration,
12518
- verdict: deriveVerdict(collected.stats)
12697
+ verdict: deriveVerdict(collected.stats),
12698
+ flakyTests
12519
12699
  };
12520
12700
  const entries = hm.append(entry, this.options.historySize);
12701
+ this._populateHistoryCharts(entries, collected);
12702
+ }
12703
+ _populateHistoryCharts(entries, collected) {
12521
12704
  if (entries.length >= 2) {
12522
12705
  collected.charts.trend = {
12523
12706
  entries: entries.map((e) => ({
@@ -12531,6 +12714,12 @@ var PdfReporter = class {
12531
12714
  delta: entries[0].passRate - entries[1].passRate
12532
12715
  };
12533
12716
  }
12717
+ if (this.options.flakinessTopN > 0) {
12718
+ const table = computeTopN(entries, this.options.flakinessTopN);
12719
+ if (table.length > 0) {
12720
+ collected.charts.flakinessTable = table;
12721
+ }
12722
+ }
12534
12723
  }
12535
12724
  _buildReportData(collected, licenseInfo) {
12536
12725
  const { projects, stats, failures, environment, charts } = collected;
@@ -12571,7 +12760,7 @@ var PdfReporter = class {
12571
12760
  }
12572
12761
  resolveProjectName() {
12573
12762
  try {
12574
- const pkgPath = import_path11.default.resolve(process.cwd(), "package.json");
12763
+ const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
12575
12764
  const pkg = require(pkgPath);
12576
12765
  return pkg.name ?? "Playwright Tests";
12577
12766
  } catch {
@@ -12621,10 +12810,28 @@ function deriveVerdict(stats) {
12621
12810
  }
12622
12811
  function resolveHistoryPath(options, cwd) {
12623
12812
  if (options.historyFile) {
12624
- return import_path11.default.isAbsolute(options.historyFile) ? options.historyFile : import_path11.default.resolve(cwd, options.historyFile);
12813
+ return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
12625
12814
  }
12626
12815
  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");
12816
+ return import_path12.default.join(import_os6.default.homedir(), ".reportforge", projectKey, "history.json");
12817
+ }
12818
+ function flattenSuiteNodes(suites) {
12819
+ return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
12820
+ }
12821
+ function collectFlakyTests(projects) {
12822
+ const seen = /* @__PURE__ */ new Set();
12823
+ const result = [];
12824
+ for (const project of projects) {
12825
+ for (const suite of flattenSuiteNodes(project.suites)) {
12826
+ for (const test of suite.tests) {
12827
+ if (test.status === "flaky" && !seen.has(test.id)) {
12828
+ seen.add(test.id);
12829
+ result.push({ id: test.id, title: test.fullTitle });
12830
+ }
12831
+ }
12832
+ }
12833
+ }
12834
+ return result;
12628
12835
  }
12629
12836
 
12630
12837
  // 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(
@@ -10963,6 +10972,18 @@ function detectCiEnv() {
10963
10972
  ciProvider: "CircleCI"
10964
10973
  };
10965
10974
  }
10975
+ if (env.BITBUCKET_BUILD_NUMBER) {
10976
+ const workspace = env.BITBUCKET_WORKSPACE;
10977
+ const repoSlug = env.BITBUCKET_REPO_SLUG;
10978
+ const buildNumber = env.BITBUCKET_BUILD_NUMBER;
10979
+ const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
10980
+ return {
10981
+ branch: env.BITBUCKET_BRANCH || "unknown",
10982
+ commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
10983
+ buildUrl,
10984
+ ciProvider: "Bitbucket Pipelines"
10985
+ };
10986
+ }
10966
10987
  return {
10967
10988
  branch: gitBranch(),
10968
10989
  commit: gitCommit(),
@@ -12276,6 +12297,42 @@ var HistoryManager = class {
12276
12297
  }
12277
12298
  };
12278
12299
 
12300
+ // src/history/FlakinessAnalyser.ts
12301
+ init_esm_shims();
12302
+ function computeTopN(entries, topN) {
12303
+ if (topN === 0) return [];
12304
+ const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
12305
+ if (qualifying.length === 0) return [];
12306
+ const map = /* @__PURE__ */ new Map();
12307
+ qualifying.forEach((entry, idx) => {
12308
+ const seen = /* @__PURE__ */ new Set();
12309
+ for (const t of entry.flakyTests) {
12310
+ if (seen.has(t.id)) continue;
12311
+ seen.add(t.id);
12312
+ if (!map.has(t.id)) map.set(t.id, { title: t.title, hits: /* @__PURE__ */ new Set() });
12313
+ map.get(t.id).hits.add(idx);
12314
+ }
12315
+ });
12316
+ const totalRuns = qualifying.length;
12317
+ const rows = [...map.entries()].map(([, { title, hits }]) => {
12318
+ const flakeCount = hits.size;
12319
+ const sparkline = qualifying.slice().reverse().map((_, reversedIdx) => {
12320
+ const originalIdx = qualifying.length - 1 - reversedIdx;
12321
+ return hits.has(originalIdx);
12322
+ });
12323
+ return {
12324
+ title,
12325
+ flakeRate: Math.round(flakeCount / totalRuns * 100),
12326
+ flakeCount,
12327
+ totalRuns,
12328
+ sparkline
12329
+ };
12330
+ });
12331
+ return rows.sort(
12332
+ (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
12333
+ ).slice(0, topN);
12334
+ }
12335
+
12279
12336
  // src/notify/index.ts
12280
12337
  init_esm_shims();
12281
12338
 
@@ -12392,6 +12449,115 @@ ${name}`,
12392
12449
  };
12393
12450
  }
12394
12451
 
12452
+ // src/notify/EmailSender.ts
12453
+ init_esm_shims();
12454
+ import { readFile } from "fs/promises";
12455
+ import { basename as basename2 } from "path";
12456
+
12457
+ // src/notify/formatters/email.ts
12458
+ init_esm_shims();
12459
+ function buildEmailContent(stats, pdfPaths, reportTitle) {
12460
+ const label = statusLabel(stats.verdict);
12461
+ const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
12462
+ const name = reportName(pdfPaths, reportTitle);
12463
+ const html = `<!DOCTYPE html>
12464
+ <html lang="en">
12465
+ <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
12466
+ <body style="font-family:sans-serif;max-width:600px;margin:auto;padding:24px;color:#1a1a1a">
12467
+ <h2 style="margin:0 0 8px">${escHtml(label)}</h2>
12468
+ <p style="margin:0 0 16px;color:#555">${escHtml(name)}</p>
12469
+ <table style="border-collapse:collapse;width:100%">
12470
+ <tr style="background:#f5f5f5">
12471
+ <th style="text-align:left;padding:8px 12px;font-weight:600">Metric</th>
12472
+ <th style="text-align:right;padding:8px 12px;font-weight:600">Value</th>
12473
+ </tr>
12474
+ <tr>
12475
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Total</td>
12476
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.total}</td>
12477
+ </tr>
12478
+ <tr style="background:#f9f9f9">
12479
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Passed</td>
12480
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:#1a7f3c">${stats.passed}</td>
12481
+ </tr>
12482
+ <tr>
12483
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Failed</td>
12484
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right;color:${stats.failed > 0 ? "#b91c1c" : "#1a1a1a"}">${stats.failed}</td>
12485
+ </tr>
12486
+ <tr style="background:#f9f9f9">
12487
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Flaky</td>
12488
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.flaky}</td>
12489
+ </tr>
12490
+ <tr>
12491
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Pass Rate</td>
12492
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${stats.passRate}%</td>
12493
+ </tr>
12494
+ <tr style="background:#f9f9f9">
12495
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
12496
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
12497
+ </tr>
12498
+ </table>
12499
+ <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>
12500
+ </body>
12501
+ </html>`;
12502
+ return { subject, html };
12503
+ }
12504
+ function escHtml(str) {
12505
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
12506
+ }
12507
+
12508
+ // src/notify/EmailSender.ts
12509
+ var RESEND_ENDPOINT = "https://api.resend.com/emails";
12510
+ var DEFAULT_FROM = "ReportForge <noreply@reportforge.org>";
12511
+ var EmailSender = class {
12512
+ async send(reportData, config, pdfPaths) {
12513
+ const apiKey = process.env["RESEND_API_KEY"];
12514
+ if (!apiKey) {
12515
+ logger.warn("[notify] email: RESEND_API_KEY not set \u2014 skipping");
12516
+ return "skipped";
12517
+ }
12518
+ const from = process.env["RESEND_FROM"] ?? DEFAULT_FROM;
12519
+ const { subject, html } = buildEmailContent(
12520
+ reportData.stats,
12521
+ pdfPaths,
12522
+ reportData.meta.title ?? ""
12523
+ );
12524
+ const body = { from, to: config.to, subject, html };
12525
+ if (config.attachPdf && pdfPaths.length > 0) {
12526
+ const pdfPath = pdfPaths[0];
12527
+ try {
12528
+ const content = await readFile(pdfPath);
12529
+ body["attachments"] = [{ filename: basename2(pdfPath), content: content.toString("base64") }];
12530
+ } catch (err) {
12531
+ logger.warn(`[notify] email: could not read PDF for attachment: ${err instanceof Error ? err.message : String(err)}`);
12532
+ }
12533
+ }
12534
+ const controller = new AbortController();
12535
+ const timeoutId = setTimeout(() => controller.abort(), 15e3);
12536
+ try {
12537
+ const res = await fetch(RESEND_ENDPOINT, {
12538
+ method: "POST",
12539
+ headers: {
12540
+ "Authorization": `Bearer ${apiKey}`,
12541
+ "Content-Type": "application/json"
12542
+ },
12543
+ body: JSON.stringify(body),
12544
+ signal: controller.signal
12545
+ });
12546
+ if (!res.ok) {
12547
+ const detail = await res.json().catch(() => ({}));
12548
+ throw new Error(`HTTP ${res.status}: ${detail.message ?? "unknown"}`);
12549
+ }
12550
+ logger.info("[notify] email: sent");
12551
+ return "sent";
12552
+ } catch (err) {
12553
+ logger.warn(`[notify] email: ${err instanceof Error ? err.message : String(err)}`);
12554
+ return "failed";
12555
+ } finally {
12556
+ clearTimeout(timeoutId);
12557
+ }
12558
+ }
12559
+ };
12560
+
12395
12561
  // src/notify/NotificationSender.ts
12396
12562
  function shouldNotify(channel, stats) {
12397
12563
  if (!channel.enabled) return false;
@@ -12420,6 +12586,18 @@ var NotificationSender = class {
12420
12586
  return this.post(name, channel.url, payload, result);
12421
12587
  })
12422
12588
  );
12589
+ if (notifyConfig.email) {
12590
+ if (!shouldNotify(notifyConfig.email, reportData.stats)) {
12591
+ result.skipped.push("email");
12592
+ } else {
12593
+ const outcome = await new EmailSender().send(reportData, notifyConfig.email, pdfPaths);
12594
+ if (outcome === "sent") result.sent.push("email");
12595
+ else if (outcome === "failed") result.failed.push("email");
12596
+ else result.skipped.push("email");
12597
+ }
12598
+ } else {
12599
+ result.skipped.push("email");
12600
+ }
12423
12601
  return result;
12424
12602
  }
12425
12603
  async post(name, url, payload, result) {
@@ -12451,7 +12629,7 @@ var PdfReporter = class {
12451
12629
  this.dataCollector = new DataCollector();
12452
12630
  this.pdfGenerator = new PdfGenerator();
12453
12631
  this.options = parseOptions(rawOptions);
12454
- const version = true ? "0.1.0" : "0.x";
12632
+ const version = true ? "0.2.1" : "0.x";
12455
12633
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
12456
12634
  this.licenseClient = new LicenseClient({
12457
12635
  licenseKey: this.options.licenseKey,
@@ -12507,6 +12685,7 @@ var PdfReporter = class {
12507
12685
  const historyPath = resolveHistoryPath(this.options, this.cwd);
12508
12686
  const hm = new HistoryManager(historyPath);
12509
12687
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
12688
+ const flakyTests = collectFlakyTests(collected.projects);
12510
12689
  const entry = {
12511
12690
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
12512
12691
  timestamp: Date.now(),
@@ -12516,9 +12695,13 @@ var PdfReporter = class {
12516
12695
  failed: collected.stats.failed,
12517
12696
  flaky: collected.stats.flaky,
12518
12697
  duration: collected.stats.duration,
12519
- verdict: deriveVerdict(collected.stats)
12698
+ verdict: deriveVerdict(collected.stats),
12699
+ flakyTests
12520
12700
  };
12521
12701
  const entries = hm.append(entry, this.options.historySize);
12702
+ this._populateHistoryCharts(entries, collected);
12703
+ }
12704
+ _populateHistoryCharts(entries, collected) {
12522
12705
  if (entries.length >= 2) {
12523
12706
  collected.charts.trend = {
12524
12707
  entries: entries.map((e) => ({
@@ -12532,6 +12715,12 @@ var PdfReporter = class {
12532
12715
  delta: entries[0].passRate - entries[1].passRate
12533
12716
  };
12534
12717
  }
12718
+ if (this.options.flakinessTopN > 0) {
12719
+ const table = computeTopN(entries, this.options.flakinessTopN);
12720
+ if (table.length > 0) {
12721
+ collected.charts.flakinessTable = table;
12722
+ }
12723
+ }
12535
12724
  }
12536
12725
  _buildReportData(collected, licenseInfo) {
12537
12726
  const { projects, stats, failures, environment, charts } = collected;
@@ -12627,6 +12816,24 @@ function resolveHistoryPath(options, cwd) {
12627
12816
  const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
12628
12817
  return path11.join(os6.homedir(), ".reportforge", projectKey, "history.json");
12629
12818
  }
12819
+ function flattenSuiteNodes(suites) {
12820
+ return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
12821
+ }
12822
+ function collectFlakyTests(projects) {
12823
+ const seen = /* @__PURE__ */ new Set();
12824
+ const result = [];
12825
+ for (const project of projects) {
12826
+ for (const suite of flattenSuiteNodes(project.suites)) {
12827
+ for (const test of suite.tests) {
12828
+ if (test.status === "flaky" && !seen.has(test.id)) {
12829
+ seen.add(test.id);
12830
+ result.push({ id: test.id, title: test.fullTitle });
12831
+ }
12832
+ }
12833
+ }
12834
+ }
12835
+ return result;
12836
+ }
12630
12837
 
12631
12838
  // src/index.ts
12632
12839
  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.1",
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",