@reportforge/playwright-pdf 0.20.2 → 0.21.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/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.21.0] - 2026-07-12
7
+
8
+ ### Added
9
+
10
+ - **`remoteHistory` option — the pass-rate trend now survives ephemeral CI runners.** The local history file is wiped with the CI workspace, so the trend chart never accumulated past one data point. With `remoteHistory: true`, the reporter sends one request per run over the existing license connection carrying **aggregate numbers only** (pass rate, counts, duration, verdict, an opaque run id — no test titles, no error text; the branch name is sent as a hash). The server keeps the last 100 runs per project + branch and returns them for the chart. Any failure falls back to the local file. The flakiness table stays local-only by design (it needs test titles, which never leave your machine).
11
+
12
+ ---
13
+
6
14
  ## [0.20.2] - 2026-07-12
7
15
 
8
16
  ### Fixed
package/README.md CHANGED
@@ -205,6 +205,7 @@ All options are the second element of the reporter tuple.
205
205
  | `historyFile` | `string` | `~/.reportforge/{key}/history.json` | Path to the history JSON file. Relative paths resolve from `cwd`. Enables pass-rate trending charts in the `detailed` template. |
206
206
  | `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
207
207
  | `showTrend` | `boolean` | `true` | Show the pass-rate sparkline and delta badge in the `detailed` template. Set to `false` to disable history tracking entirely. |
208
+ | `remoteHistory` | `boolean` | `false` | Opt-in server-side trend store so the pass-rate trend survives ephemeral CI runners (the local history file is wiped with the workspace, leaving the chart stuck at one data point). One request per run over the existing license connection carrying aggregate numbers only — no test titles, no error text; the branch name is sent as a hash. Falls back to the local history file on any failure. See the History docs. |
208
209
  | `flakinessTopN` | `number` | `5` | Maximum flaky tests to show in the flakiness table (`detailed` template). Set to `0` to disable the table entirely. |
209
210
  | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown: below this, every test is trivially the "slowest" so badges stay hidden. On larger runs only genuine outliers (at least 2× the median test duration) are badged, so the badge stays rare. Set to `0` to drop the run-size gate (outliers still required). |
210
211
  | `templatePath` | `string \| string[]` | n/a | Path to a custom Handlebars (`.hbs`) template file. Takes precedence over `template`. Pass an array to generate one PDF per custom template. See the Custom Templates docs. |
@@ -319,7 +320,18 @@ After each run the reporter appends a summary entry to a local JSON file and ren
319
320
 
320
321
  **Local dev (zero config)**: history is written automatically to `~/.reportforge/{projectKey}/history.json`. The sparkline appears once two or more runs have been recorded.
321
322
 
322
- **CI with ephemeral runners**: set `historyFile` to a project-relative path and cache it between runs:
323
+ **CI with ephemeral runners (recommended)**: enable the server-side trend store; the local file is wiped with the workspace, leaving the chart stuck at one data point:
324
+
325
+ ```typescript
326
+ reporter: [['@reportforge/playwright-pdf', {
327
+ template: 'detailed',
328
+ remoteHistory: true,
329
+ }]]
330
+ ```
331
+
332
+ One request per run over the existing license connection, carrying aggregate numbers only (pass rate, counts, duration, verdict; no test titles, no error text — the branch name is sent as a hash). The server keeps the last 100 runs per project + branch and returns them for the chart; any failure falls back to the local file. The flakiness table stays local-only by design (it needs test titles, which never leave your machine). Bitbucket Pipelines users especially: Bitbucket caches are immutable once written, so `remoteHistory` is the practical path there.
333
+
334
+ **CI caching alternative** (keeps even aggregate numbers off the server): set `historyFile` to a project-relative path and cache it between runs:
323
335
 
324
336
  ```typescript
325
337
  // playwright.config.ts
package/dist/cli/index.js CHANGED
@@ -12128,6 +12128,9 @@ var init_schema = __esm({
12128
12128
  historyFile: external_exports.string().optional(),
12129
12129
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
12130
12130
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
12131
+ // Opt-in server-side trend store: survives ephemeral CI runners where the
12132
+ // local history file is wiped every run. Aggregate numbers only.
12133
+ remoteHistory: external_exports.boolean().default(false),
12131
12134
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
12132
12135
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
12133
12136
  failureAnalysis: failureAnalysisConfigSchema,
package/dist/index.d.mts CHANGED
@@ -332,6 +332,16 @@ interface ReporterOptions {
332
332
  * @default true
333
333
  */
334
334
  showTrend?: boolean;
335
+ /**
336
+ * Store the pass-rate trend on the ReportForge server so it survives
337
+ * ephemeral CI runners (where the local history file is wiped every run).
338
+ * One request per run over the existing license connection, carrying
339
+ * AGGREGATE NUMBERS ONLY — pass rate, counts, duration, verdict. No test
340
+ * titles, no error text; even the branch name is sent as a hash. Falls back
341
+ * to the local history file on any failure.
342
+ * @default false
343
+ */
344
+ remoteHistory?: boolean;
335
345
  /**
336
346
  * Number of flaky tests to display in the flakiness table (detailed template).
337
347
  * Set to 0 to disable. Must be a non-negative integer.
package/dist/index.d.ts CHANGED
@@ -332,6 +332,16 @@ interface ReporterOptions {
332
332
  * @default true
333
333
  */
334
334
  showTrend?: boolean;
335
+ /**
336
+ * Store the pass-rate trend on the ReportForge server so it survives
337
+ * ephemeral CI runners (where the local history file is wiped every run).
338
+ * One request per run over the existing license connection, carrying
339
+ * AGGREGATE NUMBERS ONLY — pass rate, counts, duration, verdict. No test
340
+ * titles, no error text; even the branch name is sent as a hash. Falls back
341
+ * to the local history file on any failure.
342
+ * @default false
343
+ */
344
+ remoteHistory?: boolean;
335
345
  /**
336
346
  * Number of flaky tests to display in the flakiness table (detailed template).
337
347
  * Set to 0 to disable. Must be a non-negative integer.
package/dist/index.js CHANGED
@@ -10554,6 +10554,9 @@ var ReporterOptionsSchema = external_exports.object({
10554
10554
  historyFile: external_exports.string().optional(),
10555
10555
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10556
10556
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10557
+ // Opt-in server-side trend store: survives ephemeral CI runners where the
10558
+ // local history file is wiped every run. Aggregate numbers only.
10559
+ remoteHistory: external_exports.boolean().default(false),
10557
10560
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10558
10561
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10559
10562
  failureAnalysis: failureAnalysisConfigSchema,
@@ -13595,6 +13598,48 @@ var HistoryManager = class {
13595
13598
  }
13596
13599
  };
13597
13600
 
13601
+ // src/history/RemoteHistory.ts
13602
+ init_cjs_shims();
13603
+ var import_node_crypto3 = __toESM(require("crypto"));
13604
+ var NETWORK_TIMEOUT_MS2 = 5e3;
13605
+ function buildProjectId(projectName, outputFile) {
13606
+ return import_node_crypto3.default.createHash("sha256").update(`${projectName ?? ""}:${outputFile}`).digest("hex").slice(0, 16);
13607
+ }
13608
+ function buildBranchHash(branch) {
13609
+ return import_node_crypto3.default.createHash("sha256").update(branch || "unknown").digest("hex").slice(0, 12);
13610
+ }
13611
+ async function appendRemoteHistory(opts) {
13612
+ const numericEntry = { ...opts.entry };
13613
+ delete numericEntry.flakyTests;
13614
+ const controller = new AbortController();
13615
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13616
+ try {
13617
+ const res = await fetch(`${opts.serverUrl}/api/history/append`, {
13618
+ method: "POST",
13619
+ headers: {
13620
+ "content-type": "application/json",
13621
+ authorization: `Bearer ${opts.jwt}`
13622
+ },
13623
+ body: JSON.stringify({ projectId: opts.projectId, branchHash: opts.branchHash, entry: numericEntry }),
13624
+ signal: controller.signal
13625
+ });
13626
+ if (!res.ok) {
13627
+ await res.body?.cancel().catch(() => {
13628
+ });
13629
+ logger.debug(`remote history append failed: HTTP ${res.status} \u2014 using local history`);
13630
+ return null;
13631
+ }
13632
+ const data = await res.json();
13633
+ if (!Array.isArray(data.entries) || data.entries.length === 0) return null;
13634
+ return data.entries;
13635
+ } catch (err) {
13636
+ logger.debug(`remote history append failed: ${err.message} \u2014 using local history`);
13637
+ return null;
13638
+ } finally {
13639
+ clearTimeout(timeout);
13640
+ }
13641
+ }
13642
+
13598
13643
  // src/history/FlakinessAnalyser.ts
13599
13644
  init_cjs_shims();
13600
13645
  function computeTopN(entries, topN) {
@@ -14009,7 +14054,7 @@ var NotificationSender = class {
14009
14054
 
14010
14055
  // src/live/LiveStreamer.ts
14011
14056
  init_cjs_shims();
14012
- var NETWORK_TIMEOUT_MS2 = 8e3;
14057
+ var NETWORK_TIMEOUT_MS3 = 8e3;
14013
14058
  var LIVE_DRAIN_DEADLINE_MS = 1e4;
14014
14059
  var MAX_FLUSH_MS = 3e4;
14015
14060
  var WARN_THROTTLE_MS = 3e4;
@@ -14182,7 +14227,7 @@ var LiveStreamer = class {
14182
14227
  async send(body) {
14183
14228
  if (!this.jwt) return;
14184
14229
  const controller = new AbortController();
14185
- const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
14230
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
14186
14231
  try {
14187
14232
  const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
14188
14233
  method: "POST",
@@ -14356,7 +14401,7 @@ var PdfReporter = class {
14356
14401
  this.options = parseOptions(rawOptions);
14357
14402
  setLogLevel(this.options.logLevel);
14358
14403
  this.dataCollector = new DataCollector(this.options.capture);
14359
- this.version = true ? "0.20.2" : "0.x";
14404
+ this.version = true ? "0.21.0" : "0.x";
14360
14405
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14361
14406
  this.licenseClient = new LicenseClient({
14362
14407
  licenseKey: this.options.licenseKey,
@@ -14439,7 +14484,7 @@ var PdfReporter = class {
14439
14484
  }
14440
14485
  if (this.options.showTrend && collected.stats.total > 0) {
14441
14486
  try {
14442
- this._appendTrend(collected);
14487
+ await this._appendTrend(collected, licenseInfo);
14443
14488
  } catch (err) {
14444
14489
  logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
14445
14490
  }
@@ -14474,7 +14519,7 @@ var PdfReporter = class {
14474
14519
  }
14475
14520
  return this.dataCollector.finalize(result);
14476
14521
  }
14477
- _appendTrend(collected) {
14522
+ async _appendTrend(collected, licenseInfo) {
14478
14523
  const historyPath = resolveHistoryPath(this.options, this.cwd);
14479
14524
  const hm = new HistoryManager(historyPath);
14480
14525
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
@@ -14491,13 +14536,28 @@ var PdfReporter = class {
14491
14536
  verdict: deriveVerdict(collected.stats),
14492
14537
  flakyTests
14493
14538
  };
14494
- const entries = hm.append(entry, this.options.historySize);
14495
- this._populateHistoryCharts(entries, collected);
14539
+ const localEntries = hm.append(entry, this.options.historySize);
14540
+ let trendEntries = localEntries;
14541
+ if (this.options.remoteHistory && licenseInfo.jwt) {
14542
+ const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
14543
+ const remote = await appendRemoteHistory({
14544
+ serverUrl,
14545
+ jwt: licenseInfo.jwt,
14546
+ projectId: buildProjectId(this.options.projectName, this.options.outputFile),
14547
+ branchHash: buildBranchHash(collected.environment.branch),
14548
+ entry
14549
+ });
14550
+ if (remote) {
14551
+ trendEntries = remote;
14552
+ logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
14553
+ }
14554
+ }
14555
+ this._populateHistoryCharts(trendEntries, localEntries, collected);
14496
14556
  }
14497
- _populateHistoryCharts(entries, collected) {
14498
- if (entries.length >= 2) {
14557
+ _populateHistoryCharts(trendEntries, localEntries, collected) {
14558
+ if (trendEntries.length >= 2) {
14499
14559
  collected.charts.trend = {
14500
- entries: entries.map((e) => ({
14560
+ entries: trendEntries.map((e) => ({
14501
14561
  runId: e.runId,
14502
14562
  timestamp: e.timestamp,
14503
14563
  passRate: e.passRate,
@@ -14505,11 +14565,11 @@ var PdfReporter = class {
14505
14565
  duration: e.duration,
14506
14566
  verdict: e.verdict
14507
14567
  })),
14508
- delta: entries[0].passRate - entries[1].passRate
14568
+ delta: trendEntries[0].passRate - trendEntries[1].passRate
14509
14569
  };
14510
14570
  }
14511
14571
  if (this.options.flakinessTopN > 0) {
14512
- const table = computeTopN(entries, this.options.flakinessTopN);
14572
+ const table = computeTopN(localEntries, this.options.flakinessTopN);
14513
14573
  if (table.length > 0) {
14514
14574
  collected.charts.flakinessTable = table;
14515
14575
  }
package/dist/index.mjs CHANGED
@@ -10555,6 +10555,9 @@ var ReporterOptionsSchema = external_exports.object({
10555
10555
  historyFile: external_exports.string().optional(),
10556
10556
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10557
10557
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10558
+ // Opt-in server-side trend store: survives ephemeral CI runners where the
10559
+ // local history file is wiped every run. Aggregate numbers only.
10560
+ remoteHistory: external_exports.boolean().default(false),
10558
10561
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10559
10562
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10560
10563
  failureAnalysis: failureAnalysisConfigSchema,
@@ -13596,6 +13599,48 @@ var HistoryManager = class {
13596
13599
  }
13597
13600
  };
13598
13601
 
13602
+ // src/history/RemoteHistory.ts
13603
+ init_esm_shims();
13604
+ import crypto4 from "crypto";
13605
+ var NETWORK_TIMEOUT_MS2 = 5e3;
13606
+ function buildProjectId(projectName, outputFile) {
13607
+ return crypto4.createHash("sha256").update(`${projectName ?? ""}:${outputFile}`).digest("hex").slice(0, 16);
13608
+ }
13609
+ function buildBranchHash(branch) {
13610
+ return crypto4.createHash("sha256").update(branch || "unknown").digest("hex").slice(0, 12);
13611
+ }
13612
+ async function appendRemoteHistory(opts) {
13613
+ const numericEntry = { ...opts.entry };
13614
+ delete numericEntry.flakyTests;
13615
+ const controller = new AbortController();
13616
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13617
+ try {
13618
+ const res = await fetch(`${opts.serverUrl}/api/history/append`, {
13619
+ method: "POST",
13620
+ headers: {
13621
+ "content-type": "application/json",
13622
+ authorization: `Bearer ${opts.jwt}`
13623
+ },
13624
+ body: JSON.stringify({ projectId: opts.projectId, branchHash: opts.branchHash, entry: numericEntry }),
13625
+ signal: controller.signal
13626
+ });
13627
+ if (!res.ok) {
13628
+ await res.body?.cancel().catch(() => {
13629
+ });
13630
+ logger.debug(`remote history append failed: HTTP ${res.status} \u2014 using local history`);
13631
+ return null;
13632
+ }
13633
+ const data = await res.json();
13634
+ if (!Array.isArray(data.entries) || data.entries.length === 0) return null;
13635
+ return data.entries;
13636
+ } catch (err) {
13637
+ logger.debug(`remote history append failed: ${err.message} \u2014 using local history`);
13638
+ return null;
13639
+ } finally {
13640
+ clearTimeout(timeout);
13641
+ }
13642
+ }
13643
+
13599
13644
  // src/history/FlakinessAnalyser.ts
13600
13645
  init_esm_shims();
13601
13646
  function computeTopN(entries, topN) {
@@ -14010,7 +14055,7 @@ var NotificationSender = class {
14010
14055
 
14011
14056
  // src/live/LiveStreamer.ts
14012
14057
  init_esm_shims();
14013
- var NETWORK_TIMEOUT_MS2 = 8e3;
14058
+ var NETWORK_TIMEOUT_MS3 = 8e3;
14014
14059
  var LIVE_DRAIN_DEADLINE_MS = 1e4;
14015
14060
  var MAX_FLUSH_MS = 3e4;
14016
14061
  var WARN_THROTTLE_MS = 3e4;
@@ -14183,7 +14228,7 @@ var LiveStreamer = class {
14183
14228
  async send(body) {
14184
14229
  if (!this.jwt) return;
14185
14230
  const controller = new AbortController();
14186
- const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
14231
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS3);
14187
14232
  try {
14188
14233
  const res = await fetch(`${this.serverUrl}/api/live/ingest`, {
14189
14234
  method: "POST",
@@ -14357,7 +14402,7 @@ var PdfReporter = class {
14357
14402
  this.options = parseOptions(rawOptions);
14358
14403
  setLogLevel(this.options.logLevel);
14359
14404
  this.dataCollector = new DataCollector(this.options.capture);
14360
- this.version = true ? "0.20.2" : "0.x";
14405
+ this.version = true ? "0.21.0" : "0.x";
14361
14406
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14362
14407
  this.licenseClient = new LicenseClient({
14363
14408
  licenseKey: this.options.licenseKey,
@@ -14440,7 +14485,7 @@ var PdfReporter = class {
14440
14485
  }
14441
14486
  if (this.options.showTrend && collected.stats.total > 0) {
14442
14487
  try {
14443
- this._appendTrend(collected);
14488
+ await this._appendTrend(collected, licenseInfo);
14444
14489
  } catch (err) {
14445
14490
  logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
14446
14491
  }
@@ -14475,7 +14520,7 @@ var PdfReporter = class {
14475
14520
  }
14476
14521
  return this.dataCollector.finalize(result);
14477
14522
  }
14478
- _appendTrend(collected) {
14523
+ async _appendTrend(collected, licenseInfo) {
14479
14524
  const historyPath = resolveHistoryPath(this.options, this.cwd);
14480
14525
  const hm = new HistoryManager(historyPath);
14481
14526
  const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
@@ -14492,13 +14537,28 @@ var PdfReporter = class {
14492
14537
  verdict: deriveVerdict(collected.stats),
14493
14538
  flakyTests
14494
14539
  };
14495
- const entries = hm.append(entry, this.options.historySize);
14496
- this._populateHistoryCharts(entries, collected);
14540
+ const localEntries = hm.append(entry, this.options.historySize);
14541
+ let trendEntries = localEntries;
14542
+ if (this.options.remoteHistory && licenseInfo.jwt) {
14543
+ const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
14544
+ const remote = await appendRemoteHistory({
14545
+ serverUrl,
14546
+ jwt: licenseInfo.jwt,
14547
+ projectId: buildProjectId(this.options.projectName, this.options.outputFile),
14548
+ branchHash: buildBranchHash(collected.environment.branch),
14549
+ entry
14550
+ });
14551
+ if (remote) {
14552
+ trendEntries = remote;
14553
+ logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
14554
+ }
14555
+ }
14556
+ this._populateHistoryCharts(trendEntries, localEntries, collected);
14497
14557
  }
14498
- _populateHistoryCharts(entries, collected) {
14499
- if (entries.length >= 2) {
14558
+ _populateHistoryCharts(trendEntries, localEntries, collected) {
14559
+ if (trendEntries.length >= 2) {
14500
14560
  collected.charts.trend = {
14501
- entries: entries.map((e) => ({
14561
+ entries: trendEntries.map((e) => ({
14502
14562
  runId: e.runId,
14503
14563
  timestamp: e.timestamp,
14504
14564
  passRate: e.passRate,
@@ -14506,11 +14566,11 @@ var PdfReporter = class {
14506
14566
  duration: e.duration,
14507
14567
  verdict: e.verdict
14508
14568
  })),
14509
- delta: entries[0].passRate - entries[1].passRate
14569
+ delta: trendEntries[0].passRate - trendEntries[1].passRate
14510
14570
  };
14511
14571
  }
14512
14572
  if (this.options.flakinessTopN > 0) {
14513
- const table = computeTopN(entries, this.options.flakinessTopN);
14573
+ const table = computeTopN(localEntries, this.options.flakinessTopN);
14514
14574
  if (table.length > 0) {
14515
14575
  collected.charts.flakinessTable = table;
14516
14576
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.20.2",
3
+ "version": "0.21.0",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "Elastic-2.0",
6
6
  "author": "ReportForge",