@reportforge/playwright-pdf 0.12.0 → 0.13.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
@@ -55,7 +55,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
55
55
  - **CI/CD snippets** — GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps
56
56
  - **Live runs** — opt-in `live` streams per-test progress to an unguessable watch link printed at run start; all shards of one CI run converge on a single live dashboard while tests execute. The PDF is still produced at the end, unchanged
57
57
  - **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
58
- - **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`
58
+ - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email and Discord support optional PDF attachment via `attachPdf`
59
59
  - **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)
60
60
  - **Offline failure analysis** — sorts each failure into one of 7 root-cause buckets and renders a per-test root-cause chip inline under the failed test in the `detailed` PDF (dominant-cause one-liner in `executive`). No network, no AI service — a small embedded classifier that updates itself safely over your existing license refresh; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
61
61
  - `npx @reportforge/playwright-pdf export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
@@ -194,7 +194,7 @@ All options are the second element of the reporter tuple.
194
194
  | `maxInlineFailures` | `number` | derived from `compressionLevel` | Cap on failure entries rendered inline in the PDF. Overflow is written to a sibling `{basename}-failures.json` sidecar file. |
195
195
  | `maxFileSizeMb` | `number` | `8` | Soft cap on the final PDF size in MB. If exceeded, the report is re-rendered once with the `'max'` compression preset. |
196
196
  | `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard report files to merge into one PDF. See the Shard Merging docs. |
197
- | `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
197
+ | `notify` | `object` | — | Notification channels: `slack`, `teams` (each `{ url, enabled, on }`), `discord` (`{ url, enabled, on, attachPdf }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
198
198
  | `failureAnalysis` | `object` | `{ enabled: true }` | Offline failure root-cause analysis (embedded Naive Bayes; no runtime network). Fields: `enabled` (default `true`), `maxClusters` (default `10`), `minStrength` (`weak`\|`moderate`\|`strong`, default `weak`), `maxFailuresToAnalyse` (default `500`), `collectUnclassified` (default `true`), `autoUpdateModel` (default `true`). See the Failure Analysis docs. |
199
199
  | `capture` | `object` | `{}` (off) | Opt-in rich execution capture for the defect log (reporter-side; no fixtures, no test-code changes). Fields: `steps` (a copy-pasteable "Steps to Reproduce" outline built from the Playwright step tree — each row a Markdown list line indented under its parent `test.step`, default `false`), `apiSteps` (include every top-level `pw:api` action (click/fill/check/goto) in the outline for a full action trail; off by default the outline shows only `test.step` intent + `expect` assertions, default `false`), `console` (Node stdout/stderr tail, default `false`), `evidence` (trace/video file links, default `false`), `maxSteps` (default `50`), `maxConsoleLines` (default `50`). Renders in the Defect Log section of the `detailed` template. |
200
200
  | `live` | `object` | `{}` (off) | Opt-in live test-execution streaming. When `enabled`, the reporter prints an unguessable watch link to the CI logs at run start and streams per-test progress to the hosted dashboard while tests run — all shards of one CI run converge on a single live view. Fields: `enabled` (default `false`), `runId` (override the auto-derived run id), `serverUrl` (override the streaming server), `steps` (`none`\|`failed`\|`intent`\|`all`, default `failed`; `intent` shows only your `test.step` names plus any failing step), `console` (stream stdout/stderr tails, default `false`), `flushMs` (batch debounce 500–10000, default `2000`). The PDF at the end of the run is unaffected. Requires an active subscription with the `live` entitlement. See the Live Runs docs. |
@@ -282,6 +282,7 @@ notify: {
282
282
  url: process.env.DISCORD_WEBHOOK_URL,
283
283
  enabled: true,
284
284
  on: 'failure',
285
+ attachPdf: true, // upload the PDF with the message (default: false)
285
286
  },
286
287
  email: {
287
288
  to: ['team@company.com', 'qa@company.com'],
@@ -304,6 +305,8 @@ The `on` trigger controls when the message fires:
304
305
 
305
306
  **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 secrets — never commit them.
306
307
 
308
+ **PDF attachment** (`attachPdf: true`) is available on `email` and `discord` — Slack and Teams webhooks cannot carry file uploads. On Discord the PDF is uploaded with the message; if the upload fails or the PDF exceeds Discord's file-size cap, the summary still posts without the attachment. With multiple templates, the first PDF is attached.
309
+
307
310
  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).
308
311
 
309
312
  ### Test History Trending
package/dist/cli/index.js CHANGED
@@ -11921,7 +11921,7 @@ ${issues}`);
11921
11921
  }
11922
11922
  return result.data;
11923
11923
  }
11924
- var hexColor, channelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11924
+ var hexColor, channelConfigSchema, discordChannelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11925
11925
  var init_schema = __esm({
11926
11926
  "src/config/schema.ts"() {
11927
11927
  "use strict";
@@ -11938,6 +11938,9 @@ var init_schema = __esm({
11938
11938
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
11939
11939
  enabled: external_exports.boolean().default(false)
11940
11940
  });
11941
+ discordChannelConfigSchema = channelConfigSchema.extend({
11942
+ attachPdf: external_exports.boolean().default(false)
11943
+ });
11941
11944
  emailChannelConfigSchema = external_exports.object({
11942
11945
  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'),
11943
11946
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -11947,7 +11950,7 @@ var init_schema = __esm({
11947
11950
  notifyConfigSchema = external_exports.object({
11948
11951
  slack: channelConfigSchema.optional(),
11949
11952
  teams: channelConfigSchema.optional(),
11950
- discord: channelConfigSchema.optional(),
11953
+ discord: discordChannelConfigSchema.optional(),
11951
11954
  email: emailChannelConfigSchema.optional()
11952
11955
  }).optional();
11953
11956
  failureAnalysisConfigSchema = external_exports.object({
package/dist/index.d.mts CHANGED
@@ -158,7 +158,12 @@ interface ReporterOptions {
158
158
  * `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
159
159
  *
160
160
  * - `slack` / `teams` / `discord`: webhook URL channels
161
+ * - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
162
+ * to the summary alone if the upload fails or the PDF exceeds Discord's cap)
161
163
  * - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
164
+ *
165
+ * Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
166
+ * exist on those channels.
162
167
  */
163
168
  notify?: {
164
169
  slack?: {
@@ -175,6 +180,7 @@ interface ReporterOptions {
175
180
  url: string;
176
181
  on?: 'always' | 'failure' | 'success';
177
182
  enabled?: boolean;
183
+ attachPdf?: boolean;
178
184
  };
179
185
  email?: {
180
186
  to: string[];
package/dist/index.d.ts CHANGED
@@ -158,7 +158,12 @@ interface ReporterOptions {
158
158
  * `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
159
159
  *
160
160
  * - `slack` / `teams` / `discord`: webhook URL channels
161
+ * - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
162
+ * to the summary alone if the upload fails or the PDF exceeds Discord's cap)
161
163
  * - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
164
+ *
165
+ * Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
166
+ * exist on those channels.
162
167
  */
163
168
  notify?: {
164
169
  slack?: {
@@ -175,6 +180,7 @@ interface ReporterOptions {
175
180
  url: string;
176
181
  on?: 'always' | 'failure' | 'success';
177
182
  enabled?: boolean;
183
+ attachPdf?: boolean;
178
184
  };
179
185
  email?: {
180
186
  to: string[];
package/dist/index.js CHANGED
@@ -10454,6 +10454,9 @@ var channelConfigSchema = external_exports.object({
10454
10454
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10455
10455
  enabled: external_exports.boolean().default(false)
10456
10456
  });
10457
+ var discordChannelConfigSchema = channelConfigSchema.extend({
10458
+ attachPdf: external_exports.boolean().default(false)
10459
+ });
10457
10460
  var emailChannelConfigSchema = external_exports.object({
10458
10461
  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'),
10459
10462
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -10463,7 +10466,7 @@ var emailChannelConfigSchema = external_exports.object({
10463
10466
  var notifyConfigSchema = external_exports.object({
10464
10467
  slack: channelConfigSchema.optional(),
10465
10468
  teams: channelConfigSchema.optional(),
10466
- discord: channelConfigSchema.optional(),
10469
+ discord: discordChannelConfigSchema.optional(),
10467
10470
  email: emailChannelConfigSchema.optional()
10468
10471
  }).optional();
10469
10472
  var failureAnalysisConfigSchema = external_exports.object({
@@ -13199,7 +13202,7 @@ function fmtMb(bytes) {
13199
13202
  }
13200
13203
 
13201
13204
  // src/reporter.ts
13202
- var import_path12 = __toESM(require("path"));
13205
+ var import_path13 = __toESM(require("path"));
13203
13206
 
13204
13207
  // src/history/HistoryManager.ts
13205
13208
  init_cjs_shims();
@@ -13280,6 +13283,8 @@ init_cjs_shims();
13280
13283
 
13281
13284
  // src/notify/NotificationSender.ts
13282
13285
  init_cjs_shims();
13286
+ var import_promises2 = require("fs/promises");
13287
+ var import_path12 = require("path");
13283
13288
 
13284
13289
  // src/notify/formatters/slack.ts
13285
13290
  init_cjs_shims();
@@ -13529,6 +13534,7 @@ var FORMATTERS = {
13529
13534
  teams: buildTeamsPayload,
13530
13535
  discord: buildDiscordPayload
13531
13536
  };
13537
+ var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
13532
13538
  var NotificationSender = class {
13533
13539
  async sendAll(reportData, notifyConfig, pdfPaths) {
13534
13540
  const result = { sent: [], skipped: [], failed: [] };
@@ -13541,6 +13547,9 @@ var NotificationSender = class {
13541
13547
  return Promise.resolve();
13542
13548
  }
13543
13549
  const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
13550
+ if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
13551
+ return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
13552
+ }
13544
13553
  return this.post(name, channel.url, payload, result);
13545
13554
  })
13546
13555
  );
@@ -13582,6 +13591,47 @@ var NotificationSender = class {
13582
13591
  })
13583
13592
  );
13584
13593
  }
13594
+ /**
13595
+ * Posts the summary to a Discord webhook with the PDF as a multipart file
13596
+ * upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
13597
+ * over Discord's upload cap, or a failed upload — falls back to the plain
13598
+ * JSON summary post so the notification still lands; only the fallback's
13599
+ * outcome is recorded in the result. Attaches the first PDF only (matches
13600
+ * the email channel's multi-template behaviour).
13601
+ */
13602
+ async postDiscordWithPdf(url, payload, pdfPath, result) {
13603
+ let file;
13604
+ try {
13605
+ file = await (0, import_promises2.readFile)(pdfPath);
13606
+ if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
13607
+ const mb = (file.byteLength / 1024 / 1024).toFixed(1);
13608
+ throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
13609
+ }
13610
+ } catch (err) {
13611
+ logger.warn(
13612
+ `[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
13613
+ );
13614
+ return this.post("discord", url, payload, result);
13615
+ }
13616
+ const form = new FormData();
13617
+ form.append("payload_json", JSON.stringify(payload));
13618
+ form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), (0, import_path12.basename)(pdfPath));
13619
+ const controller = new AbortController();
13620
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
13621
+ try {
13622
+ const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
13623
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
13624
+ logger.info("[notify] discord: sent (PDF attached)");
13625
+ result.sent.push("discord");
13626
+ } catch (err) {
13627
+ logger.warn(
13628
+ `[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
13629
+ );
13630
+ return this.post("discord", url, payload, result);
13631
+ } finally {
13632
+ clearTimeout(timeoutId);
13633
+ }
13634
+ }
13585
13635
  async post(name, url, payload, result) {
13586
13636
  const controller = new AbortController();
13587
13637
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13951,7 +14001,7 @@ var PdfReporter = class {
13951
14001
  this.liveConsole = false;
13952
14002
  this.options = parseOptions(rawOptions);
13953
14003
  this.dataCollector = new DataCollector(this.options.capture);
13954
- const version = true ? "0.12.0" : "0.x";
14004
+ const version = true ? "0.13.0" : "0.x";
13955
14005
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13956
14006
  this.licenseClient = new LicenseClient({
13957
14007
  licenseKey: this.options.licenseKey,
@@ -14219,7 +14269,7 @@ var PdfReporter = class {
14219
14269
  }
14220
14270
  resolveProjectName() {
14221
14271
  try {
14222
- const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
14272
+ const pkgPath = import_path13.default.resolve(process.cwd(), "package.json");
14223
14273
  const pkg = require(pkgPath);
14224
14274
  return pkg.name ?? "Playwright Tests";
14225
14275
  } catch {
@@ -14269,9 +14319,9 @@ function deriveVerdict(stats) {
14269
14319
  }
14270
14320
  function resolveHistoryPath(options, cwd) {
14271
14321
  if (options.historyFile) {
14272
- return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
14322
+ return import_path13.default.isAbsolute(options.historyFile) ? options.historyFile : import_path13.default.resolve(cwd, options.historyFile);
14273
14323
  }
14274
- return import_path12.default.join(resolveProjectDir(options, cwd), "history.json");
14324
+ return import_path13.default.join(resolveProjectDir(options, cwd), "history.json");
14275
14325
  }
14276
14326
  function flattenSuiteNodes(suites) {
14277
14327
  return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
package/dist/index.mjs CHANGED
@@ -10455,6 +10455,9 @@ var channelConfigSchema = external_exports.object({
10455
10455
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10456
10456
  enabled: external_exports.boolean().default(false)
10457
10457
  });
10458
+ var discordChannelConfigSchema = channelConfigSchema.extend({
10459
+ attachPdf: external_exports.boolean().default(false)
10460
+ });
10458
10461
  var emailChannelConfigSchema = external_exports.object({
10459
10462
  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'),
10460
10463
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -10464,7 +10467,7 @@ var emailChannelConfigSchema = external_exports.object({
10464
10467
  var notifyConfigSchema = external_exports.object({
10465
10468
  slack: channelConfigSchema.optional(),
10466
10469
  teams: channelConfigSchema.optional(),
10467
- discord: channelConfigSchema.optional(),
10470
+ discord: discordChannelConfigSchema.optional(),
10468
10471
  email: emailChannelConfigSchema.optional()
10469
10472
  }).optional();
10470
10473
  var failureAnalysisConfigSchema = external_exports.object({
@@ -13281,6 +13284,8 @@ init_esm_shims();
13281
13284
 
13282
13285
  // src/notify/NotificationSender.ts
13283
13286
  init_esm_shims();
13287
+ import { readFile as readFile2 } from "fs/promises";
13288
+ import { basename as basename3 } from "path";
13284
13289
 
13285
13290
  // src/notify/formatters/slack.ts
13286
13291
  init_esm_shims();
@@ -13530,6 +13535,7 @@ var FORMATTERS = {
13530
13535
  teams: buildTeamsPayload,
13531
13536
  discord: buildDiscordPayload
13532
13537
  };
13538
+ var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
13533
13539
  var NotificationSender = class {
13534
13540
  async sendAll(reportData, notifyConfig, pdfPaths) {
13535
13541
  const result = { sent: [], skipped: [], failed: [] };
@@ -13542,6 +13548,9 @@ var NotificationSender = class {
13542
13548
  return Promise.resolve();
13543
13549
  }
13544
13550
  const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
13551
+ if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
13552
+ return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
13553
+ }
13545
13554
  return this.post(name, channel.url, payload, result);
13546
13555
  })
13547
13556
  );
@@ -13583,6 +13592,47 @@ var NotificationSender = class {
13583
13592
  })
13584
13593
  );
13585
13594
  }
13595
+ /**
13596
+ * Posts the summary to a Discord webhook with the PDF as a multipart file
13597
+ * upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
13598
+ * over Discord's upload cap, or a failed upload — falls back to the plain
13599
+ * JSON summary post so the notification still lands; only the fallback's
13600
+ * outcome is recorded in the result. Attaches the first PDF only (matches
13601
+ * the email channel's multi-template behaviour).
13602
+ */
13603
+ async postDiscordWithPdf(url, payload, pdfPath, result) {
13604
+ let file;
13605
+ try {
13606
+ file = await readFile2(pdfPath);
13607
+ if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
13608
+ const mb = (file.byteLength / 1024 / 1024).toFixed(1);
13609
+ throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
13610
+ }
13611
+ } catch (err) {
13612
+ logger.warn(
13613
+ `[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
13614
+ );
13615
+ return this.post("discord", url, payload, result);
13616
+ }
13617
+ const form = new FormData();
13618
+ form.append("payload_json", JSON.stringify(payload));
13619
+ form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), basename3(pdfPath));
13620
+ const controller = new AbortController();
13621
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
13622
+ try {
13623
+ const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
13624
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
13625
+ logger.info("[notify] discord: sent (PDF attached)");
13626
+ result.sent.push("discord");
13627
+ } catch (err) {
13628
+ logger.warn(
13629
+ `[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
13630
+ );
13631
+ return this.post("discord", url, payload, result);
13632
+ } finally {
13633
+ clearTimeout(timeoutId);
13634
+ }
13635
+ }
13586
13636
  async post(name, url, payload, result) {
13587
13637
  const controller = new AbortController();
13588
13638
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13952,7 +14002,7 @@ var PdfReporter = class {
13952
14002
  this.liveConsole = false;
13953
14003
  this.options = parseOptions(rawOptions);
13954
14004
  this.dataCollector = new DataCollector(this.options.capture);
13955
- const version = true ? "0.12.0" : "0.x";
14005
+ const version = true ? "0.13.0" : "0.x";
13956
14006
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13957
14007
  this.licenseClient = new LicenseClient({
13958
14008
  licenseKey: this.options.licenseKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.12.0",
3
+ "version": "0.13.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",