@reportforge/playwright-pdf 0.25.0 → 0.26.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
@@ -58,6 +58,7 @@ Three templates, one reporter. Every PDF is fully offline: fonts, charts, and sc
58
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
  - **Pass-rate trend**: cross-run trend chart from stored history; opt-in `remoteHistory` keeps the trend alive on ephemeral CI runners by storing aggregate numbers only (pass rate, counts, duration, verdict; no test titles or error text, and the branch name never travels in plaintext), with no extra network calls
61
+ - **Run-over-run diff**: every report shows what changed since the previous run on the same branch: new failures, fixed tests, and still-failing tests, and the same summary rides along in Slack, Teams, Discord, and email notifications
61
62
  - **Offline failure analysis**: sorts each failure into one of 7 root-cause buckets and renders a root-cause chip under each failed test in the `detailed` PDF (dominant-cause one-liner in `executive`). No network, no AI service: a small embedded classifier with automatic updates; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
62
63
  - **On-device training**: teach the classifier your team's failures without any data leaving your machine. Label locally-collected samples (`npx @reportforge/playwright-pdf label-feedback`), then `train-model` trains and cross-validates a personal layer on-device; it activates only when it measurably beats the base model on your own labeled set (`evaluate-model` shows the comparison any time). Commit the model file to your repo via `failureAnalysis.localModelPath` to share it with CI. Training data, the trained model, and evaluation are all local files; the analysis code cannot even name a network primitive (enforced by an automated source audit).
63
64
  - `npx @reportforge/playwright-pdf export-feedback`: merges your locally-collected feedback (tokenized + redacted) into one CSV for review or moving between machines. Local file only; there is no upload.
@@ -213,7 +214,7 @@ All options are the second element of the reporter tuple.
213
214
  | `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). |
214
215
  | `requirementTagPattern` | `string` | `'^@?[A-Z][A-Z0-9]*-\\d+$'` | Regex deciding which tags count as requirement IDs in the Requirements Traceability section (`detailed` template). Matching tags (ticket shapes like `@ODP-5328`, `REQ-001`) get the traceability matrix; everything else (`@regression`, `@sanity`) collapses into a compact tag summary instead of repeating identical rows. Set to `''` to disable the split and list every tag in the matrix. |
215
216
  | `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. |
216
- | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. See the Report Sections docs. |
217
+ | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. 20 keys total. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`, `runDiff`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. `runDiff` (block toggle, on by default in all three templates) shows new failures, fixed tests, and still-failing tests compared with the previous run on the same branch. See the Report Sections docs. |
217
218
  <!-- AUTOGEN:options-table END -->
218
219
 
219
220
  Full reference: [reportforge.org/docs/configuration](https://reportforge.org/docs/configuration).
@@ -316,7 +317,7 @@ The `on` trigger controls when the message fires:
316
317
 
317
318
  **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.
318
319
 
319
- 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).
320
+ The summary includes pass rate, test counts, duration, and the report filename. When a previous run on the same branch exists, the message also carries a run-over-run diff line: new failures, fixed tests, and tests still failing. Notifications require a valid license and fire after PDF generation (or after a PDF failure; you still get the ping).
320
321
 
321
322
  ### Data Protection
322
323
 
@@ -364,6 +365,8 @@ Full details at [reportforge.org/docs/advanced/security](https://reportforge.org
364
365
 
365
366
  After each run the reporter appends a summary entry to a local JSON file and renders a pass-rate sparkline + verdict row in the `detailed` template.
366
367
 
368
+ **Since Last Run**: every report also gets a section (`runDiff`, on by default in all three templates) comparing the current run against the previous run on the same branch: new failures, fixed tests, and tests still failing, with the same summary line carried into Slack, Teams, Discord, and email notifications. The first run after upgrading has no comparable prior run to diff against, so the section starts appearing from the second run onward. It reads only the local history file, so test names never leave your machine; see the ephemeral-CI note below for keeping that file around between runs. Like the flakiness table, it rides history tracking (`showTrend`, on by default), and the notification line follows history availability rather than the `runDiff` section toggle.
369
+
367
370
  **Local dev (zero config)**: history is written automatically to `~/.reportforge/{projectKey}/history.json`. The sparkline appears once two or more runs have been recorded.
368
371
 
369
372
  **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:
@@ -375,7 +378,7 @@ reporter: [['@reportforge/playwright-pdf', {
375
378
  }]]
376
379
  ```
377
380
 
378
- One small authenticated request per run, carrying aggregate numbers only (pass rate, counts, duration, verdict; no test titles, no error text, and the branch name never travels in plaintext). The server keeps roughly the last 100 runs per project + branch, expiring after 180 days, 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.
381
+ One small authenticated request per run, carrying aggregate numbers only (pass rate, counts, duration, verdict; no test titles, no error text, and the branch name never travels in plaintext). The server keeps roughly the last 100 runs per project + branch, expiring after 180 days, and returns them for the chart; any failure falls back to the local file. This feeds the trend chart only: the flakiness table and the Since Last Run diff both stay local-only by design (they need test titles, which never leave your machine); to keep either alive on ephemeral runners, use the CI caching alternative below to persist `historyFile` instead. Bitbucket Pipelines users especially: Bitbucket caches are immutable once written, so `remoteHistory` is the practical path for the trend chart there.
379
382
 
380
383
  **CI caching alternative** (keeps even aggregate numbers off the server): set `historyFile` to a project-relative path and cache it between runs:
381
384
 
package/dist/cli/index.js CHANGED
@@ -311,6 +311,24 @@ function buildDemoCharts(passed, failed, skipped, flaky, total) {
311
311
  ]
312
312
  };
313
313
  }
314
+ function buildDemoRunDiff() {
315
+ return {
316
+ newFailures: [
317
+ { id: "auth-5", title: "should block brute-force attempts after 5 tries" },
318
+ { id: "cat-7", title: "should lazy-load images on scroll" }
319
+ ],
320
+ stillFailing: [
321
+ { id: "co-3", title: "should reject expired promo code" }
322
+ ],
323
+ fixed: [
324
+ { id: "co-5", title: "should complete Stripe checkout" }
325
+ ],
326
+ noLongerRun: [],
327
+ baselineRunId: "demo-1d",
328
+ baselineTimestamp: DEMO_BASE_TS - 864e5,
329
+ baselineTruncated: false
330
+ };
331
+ }
314
332
  function buildDemoReportData(template = "detailed") {
315
333
  const projects = [
316
334
  makeProject("chromium", [
@@ -362,7 +380,8 @@ function buildDemoReportData(template = "detailed") {
362
380
  projectCount: 2,
363
381
  workerCount: 4
364
382
  },
365
- charts
383
+ charts,
384
+ runDiff: buildDemoRunDiff()
366
385
  };
367
386
  }
368
387
  var DEMO_BASE_TS, DEMO_LICENSE_INFO, DEMO_AUTH_TESTS, DEMO_CHECKOUT_TESTS, DEMO_CATALOG_TESTS;
@@ -6498,6 +6517,7 @@ var init_sections = __esm({
6498
6517
  "slowTests",
6499
6518
  "defectLog",
6500
6519
  "briefBand",
6520
+ "runDiff",
6501
6521
  // display modifiers
6502
6522
  "passRate",
6503
6523
  "fullEnvironment",
@@ -6521,6 +6541,7 @@ var init_sections = __esm({
6521
6541
  slowTests: false,
6522
6542
  defectLog: false,
6523
6543
  briefBand: false,
6544
+ runDiff: true,
6524
6545
  passRate: true,
6525
6546
  fullEnvironment: false,
6526
6547
  retries: true,
@@ -6542,6 +6563,7 @@ var init_sections = __esm({
6542
6563
  slowTests: true,
6543
6564
  defectLog: true,
6544
6565
  briefBand: false,
6566
+ runDiff: true,
6545
6567
  passRate: true,
6546
6568
  fullEnvironment: true,
6547
6569
  retries: true,
@@ -6568,6 +6590,7 @@ var init_sections = __esm({
6568
6590
  slowTests: true,
6569
6591
  defectLog: false,
6570
6592
  briefBand: true,
6593
+ runDiff: true,
6571
6594
  passRate: true,
6572
6595
  fullEnvironment: false,
6573
6596
  retries: false,
package/dist/index.d.mts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 14 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 15 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -406,12 +406,18 @@ interface ReporterOptions {
406
406
  * every chosen template; per-template keys (`minimal` / `detailed` /
407
407
  * `executive`) override the baseline for that template only.
408
408
  *
409
+ * 20 keys total: 15 block toggles, 5 display modifiers.
410
+ *
409
411
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
410
412
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
411
- * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
413
+ * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`,
414
+ * `runDiff`.
412
415
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
413
416
  * `stackTraces`.
414
417
  *
418
+ * `runDiff` shows new failures, fixed tests, and still-failing tests against
419
+ * the previous run on the same branch; on by default in all three templates.
420
+ *
415
421
  * `trend` renders only when `charts` is also on and history data exists (`showTrend` enabled). Sections gated on data
416
422
  * (`failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog`) appear
417
423
  * only when matching data exists — this option never changes data collection.
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 14 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 15 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "runDiff", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -406,12 +406,18 @@ interface ReporterOptions {
406
406
  * every chosen template; per-template keys (`minimal` / `detailed` /
407
407
  * `executive`) override the baseline for that template only.
408
408
  *
409
+ * 20 keys total: 15 block toggles, 5 display modifiers.
410
+ *
409
411
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
410
412
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
411
- * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
413
+ * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`,
414
+ * `runDiff`.
412
415
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
413
416
  * `stackTraces`.
414
417
  *
418
+ * `runDiff` shows new failures, fixed tests, and still-failing tests against
419
+ * the previous run on the same branch; on by default in all three templates.
420
+ *
415
421
  * `trend` renders only when `charts` is also on and history data exists (`showTrend` enabled). Sections gated on data
416
422
  * (`failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog`) appear
417
423
  * only when matching data exists — this option never changes data collection.
package/dist/index.js CHANGED
@@ -10354,6 +10354,7 @@ var SECTION_KEYS = [
10354
10354
  "slowTests",
10355
10355
  "defectLog",
10356
10356
  "briefBand",
10357
+ "runDiff",
10357
10358
  // display modifiers
10358
10359
  "passRate",
10359
10360
  "fullEnvironment",
@@ -10377,6 +10378,7 @@ var SECTION_DEFAULTS = {
10377
10378
  slowTests: false,
10378
10379
  defectLog: false,
10379
10380
  briefBand: false,
10381
+ runDiff: true,
10380
10382
  passRate: true,
10381
10383
  fullEnvironment: false,
10382
10384
  retries: true,
@@ -10398,6 +10400,7 @@ var SECTION_DEFAULTS = {
10398
10400
  slowTests: true,
10399
10401
  defectLog: true,
10400
10402
  briefBand: false,
10403
+ runDiff: true,
10401
10404
  passRate: true,
10402
10405
  fullEnvironment: true,
10403
10406
  retries: true,
@@ -10424,6 +10427,7 @@ var SECTION_DEFAULTS = {
10424
10427
  slowTests: true,
10425
10428
  defectLog: false,
10426
10429
  briefBand: true,
10430
+ runDiff: true,
10427
10431
  passRate: true,
10428
10432
  fullEnvironment: false,
10429
10433
  retries: false,
@@ -13783,6 +13787,9 @@ function buildBranchHash(branch) {
13783
13787
  async function appendRemoteHistory(opts) {
13784
13788
  const numericEntry = { ...opts.entry };
13785
13789
  delete numericEntry.flakyTests;
13790
+ delete numericEntry.failedTests;
13791
+ delete numericEntry.failedTestsTruncated;
13792
+ delete numericEntry.branch;
13786
13793
  const controller = new AbortController();
13787
13794
  const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13788
13795
  try {
@@ -13812,6 +13819,58 @@ async function appendRemoteHistory(opts) {
13812
13819
  }
13813
13820
  }
13814
13821
 
13822
+ // src/history/types.ts
13823
+ init_cjs_shims();
13824
+ var FAILED_TESTS_CAP = 300;
13825
+
13826
+ // src/history/RunDiff.ts
13827
+ init_cjs_shims();
13828
+ function splitCurrentFailing(currentFailing, baselineFailed) {
13829
+ const currentFailedIds = /* @__PURE__ */ new Set();
13830
+ const newFailures = [];
13831
+ const stillFailing = [];
13832
+ for (const item of currentFailing) {
13833
+ if (currentFailedIds.has(item.id)) continue;
13834
+ currentFailedIds.add(item.id);
13835
+ (baselineFailed.has(item.id) ? stillFailing : newFailures).push(item);
13836
+ }
13837
+ return { newFailures, stillFailing, currentFailedIds };
13838
+ }
13839
+ function splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses) {
13840
+ const fixed = [];
13841
+ const noLongerRun = [];
13842
+ for (const [id, title] of baselineFailed) {
13843
+ if (currentFailedIds.has(id)) continue;
13844
+ const status = currentStatuses.get(id);
13845
+ const bucket = status === "passed" || status === "flaky" ? fixed : noLongerRun;
13846
+ bucket.push({ id, title });
13847
+ }
13848
+ return { fixed, noLongerRun };
13849
+ }
13850
+ function computeRunDiff(args) {
13851
+ const { currentFailing, currentStatuses, priorEntries, branch } = args;
13852
+ const baseline = priorEntries.find((e) => e.branch === branch && Array.isArray(e.failedTests));
13853
+ if (!baseline) return null;
13854
+ const baselineFailed = /* @__PURE__ */ new Map();
13855
+ for (const item of baseline.failedTests) {
13856
+ if (!baselineFailed.has(item.id)) baselineFailed.set(item.id, item.title);
13857
+ }
13858
+ const { newFailures, stillFailing, currentFailedIds } = splitCurrentFailing(currentFailing, baselineFailed);
13859
+ const { fixed, noLongerRun } = splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses);
13860
+ for (const bucket of [newFailures, stillFailing, fixed, noLongerRun]) {
13861
+ bucket.sort((a, b) => a.title.localeCompare(b.title));
13862
+ }
13863
+ return {
13864
+ newFailures,
13865
+ stillFailing,
13866
+ fixed,
13867
+ noLongerRun,
13868
+ baselineRunId: baseline.runId,
13869
+ baselineTimestamp: baseline.timestamp,
13870
+ baselineTruncated: baseline.failedTestsTruncated === true
13871
+ };
13872
+ }
13873
+
13815
13874
  // src/history/FlakinessAnalyser.ts
13816
13875
  init_cjs_shims();
13817
13876
  function computeTopN(entries, topN) {
@@ -13888,11 +13947,32 @@ function reportName(pdfPaths, reportTitle) {
13888
13947
  if (pdfPaths.length > 0) return (0, import_path9.basename)(pdfPaths[0]);
13889
13948
  return reportTitle || "ReportForge";
13890
13949
  }
13950
+ function formatDiffLine(diff) {
13951
+ if (!diff) return null;
13952
+ const newFailures = diff.newFailures.length;
13953
+ const fixed = diff.fixed.length;
13954
+ const stillFailing = diff.stillFailing.length;
13955
+ const noLongerRun = diff.noLongerRun.length;
13956
+ const parts = [
13957
+ `${newFailures} new ${newFailures === 1 ? "failure" : "failures"}`,
13958
+ `${fixed} fixed`,
13959
+ `${stillFailing} still failing`
13960
+ ];
13961
+ if (noLongerRun > 0) parts.push(`${noLongerRun} no longer run`);
13962
+ return parts.join(" \xB7 ");
13963
+ }
13891
13964
 
13892
13965
  // src/notify/formatters/slack.ts
13893
- function buildSlackPayload(stats, pdfPaths, reportTitle) {
13966
+ function buildSlackPayload(stats, pdfPaths, reportTitle, runDiff) {
13894
13967
  const label = statusLabel(stats.verdict);
13895
13968
  const name = reportName(pdfPaths, reportTitle);
13969
+ const diffLine = formatDiffLine(runDiff);
13970
+ const lines = [
13971
+ `*${label}* \xB7 ${stats.passRate}% passed`,
13972
+ countsLine(stats),
13973
+ ...diffLine ? [diffLine] : [],
13974
+ `_${name}_`
13975
+ ];
13896
13976
  return {
13897
13977
  text: `${label} \xB7 ${stats.passRate}% passed`,
13898
13978
  blocks: [
@@ -13900,9 +13980,7 @@ function buildSlackPayload(stats, pdfPaths, reportTitle) {
13900
13980
  type: "section",
13901
13981
  text: {
13902
13982
  type: "mrkdwn",
13903
- text: `*${label}* \xB7 ${stats.passRate}% passed
13904
- ${countsLine(stats)}
13905
- _${name}_`
13983
+ text: lines.join("\n")
13906
13984
  }
13907
13985
  }
13908
13986
  ]
@@ -13927,9 +14005,37 @@ function buildTeamsSimpleMessage(text) {
13927
14005
  ]
13928
14006
  };
13929
14007
  }
13930
- function buildTeamsPayload(stats, pdfPaths, reportTitle) {
14008
+ function buildTeamsPayload(stats, pdfPaths, reportTitle, runDiff) {
13931
14009
  const label = statusLabel(stats.verdict);
13932
14010
  const name = reportName(pdfPaths, reportTitle);
14011
+ const diffLine = formatDiffLine(runDiff);
14012
+ const body = [
14013
+ {
14014
+ type: "TextBlock",
14015
+ text: `${label} \xB7 ${stats.passRate}% passed`,
14016
+ weight: "bolder",
14017
+ size: "medium"
14018
+ },
14019
+ {
14020
+ type: "TextBlock",
14021
+ text: countsLine(stats),
14022
+ isSubtle: true
14023
+ },
14024
+ {
14025
+ type: "TextBlock",
14026
+ text: name,
14027
+ isSubtle: true,
14028
+ spacing: "none"
14029
+ }
14030
+ ];
14031
+ if (diffLine) {
14032
+ body.push({
14033
+ type: "TextBlock",
14034
+ text: diffLine,
14035
+ isSubtle: true,
14036
+ spacing: "none"
14037
+ });
14038
+ }
13933
14039
  return {
13934
14040
  type: "message",
13935
14041
  attachments: [
@@ -13939,25 +14045,7 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13939
14045
  $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
13940
14046
  type: "AdaptiveCard",
13941
14047
  version: "1.4",
13942
- body: [
13943
- {
13944
- type: "TextBlock",
13945
- text: `${label} \xB7 ${stats.passRate}% passed`,
13946
- weight: "bolder",
13947
- size: "medium"
13948
- },
13949
- {
13950
- type: "TextBlock",
13951
- text: countsLine(stats),
13952
- isSubtle: true
13953
- },
13954
- {
13955
- type: "TextBlock",
13956
- text: name,
13957
- isSubtle: true,
13958
- spacing: "none"
13959
- }
13960
- ]
14048
+ body
13961
14049
  }
13962
14050
  }
13963
14051
  ]
@@ -13966,16 +14054,17 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13966
14054
 
13967
14055
  // src/notify/formatters/discord.ts
13968
14056
  init_cjs_shims();
13969
- function buildDiscordPayload(stats, pdfPaths, reportTitle) {
14057
+ function buildDiscordPayload(stats, pdfPaths, reportTitle, runDiff) {
13970
14058
  const label = statusLabel(stats.verdict);
13971
14059
  const name = reportName(pdfPaths, reportTitle);
13972
14060
  const color = stats.verdict === "passed" ? 3066993 : 15158332;
14061
+ const diffLine = formatDiffLine(runDiff);
14062
+ const descriptionLines = [countsLine(stats), ...diffLine ? [diffLine] : [], name];
13973
14063
  return {
13974
14064
  embeds: [
13975
14065
  {
13976
14066
  title: `${label} \xB7 ${stats.passRate}% passed`,
13977
- description: `${countsLine(stats)}
13978
- ${name}`,
14067
+ description: descriptionLines.join("\n"),
13979
14068
  color
13980
14069
  }
13981
14070
  ]
@@ -13989,10 +14078,16 @@ var import_path10 = require("path");
13989
14078
 
13990
14079
  // src/notify/formatters/email.ts
13991
14080
  init_cjs_shims();
13992
- function buildEmailContent(stats, pdfPaths, reportTitle) {
14081
+ function buildEmailContent(stats, pdfPaths, reportTitle, runDiff) {
13993
14082
  const label = statusLabel(stats.verdict);
13994
- const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
14083
+ const subject = `[ReportForge] ${label} \xB7 ${stats.passRate}% passed`;
13995
14084
  const name = reportName(pdfPaths, reportTitle);
14085
+ const diffLine = formatDiffLine(runDiff);
14086
+ const diffRow = diffLine ? `
14087
+ <tr>
14088
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Since last run</td>
14089
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(diffLine)}</td>
14090
+ </tr>` : "";
13996
14091
  const html = `<!DOCTYPE html>
13997
14092
  <html lang="en">
13998
14093
  <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
@@ -14027,7 +14122,7 @@ function buildEmailContent(stats, pdfPaths, reportTitle) {
14027
14122
  <tr style="background:#f9f9f9">
14028
14123
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
14029
14124
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
14030
- </tr>
14125
+ </tr>${diffRow}
14031
14126
  </table>
14032
14127
  <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>
14033
14128
  </body>
@@ -14052,7 +14147,8 @@ var EmailSender = class {
14052
14147
  const { subject, html } = buildEmailContent(
14053
14148
  reportData.stats,
14054
14149
  pdfPaths,
14055
- reportData.meta.title ?? ""
14150
+ reportData.meta.title ?? "",
14151
+ reportData.runDiff
14056
14152
  );
14057
14153
  const body = { from, to: config.to, subject, html };
14058
14154
  if (config.attachPdf && pdfPaths.length > 0) {
@@ -14116,7 +14212,7 @@ var NotificationSender = class {
14116
14212
  result.skipped.push(name);
14117
14213
  return Promise.resolve();
14118
14214
  }
14119
- const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
14215
+ const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle, reportData.runDiff);
14120
14216
  if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
14121
14217
  return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
14122
14218
  }
@@ -14859,7 +14955,7 @@ var PdfReporter = class {
14859
14955
  if (this.options.redact.enabled) {
14860
14956
  this.redactor = new Redactor(this.options.redact);
14861
14957
  }
14862
- this.version = true ? "0.25.0" : "0.x";
14958
+ this.version = true ? "0.26.1" : "0.x";
14863
14959
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14864
14960
  this.licenseClient = new LicenseClient({
14865
14961
  licenseKey: this.options.licenseKey,
@@ -14988,6 +15084,7 @@ var PdfReporter = class {
14988
15084
  const flakyTests = collectFlakyTests(collected.projects).map(
14989
15085
  (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14990
15086
  );
15087
+ const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
14991
15088
  const entry = {
14992
15089
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
14993
15090
  timestamp: Date.now(),
@@ -14998,9 +15095,12 @@ var PdfReporter = class {
14998
15095
  flaky: collected.stats.flaky,
14999
15096
  duration: collected.stats.duration,
15000
15097
  verdict: deriveVerdict(collected.stats),
15001
- flakyTests
15098
+ flakyTests,
15099
+ ...failedTestsFields,
15100
+ branch: collected.environment.branch
15002
15101
  };
15003
15102
  const localEntries = hm.append(entry, this.options.historySize);
15103
+ collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
15004
15104
  let trendEntries = localEntries;
15005
15105
  if (this.options.remoteHistory && licenseInfo.jwt) {
15006
15106
  const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
@@ -15040,7 +15140,7 @@ var PdfReporter = class {
15040
15140
  }
15041
15141
  }
15042
15142
  _buildReportData(collected, licenseInfo) {
15043
- const { projects, stats, failures, environment, charts } = collected;
15143
+ const { projects, stats, failures, environment, charts, runDiff } = collected;
15044
15144
  const projectName = this.options.projectName ?? this.resolveProjectName();
15045
15145
  const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
15046
15146
  return {
@@ -15060,7 +15160,8 @@ var PdfReporter = class {
15060
15160
  projects,
15061
15161
  failures,
15062
15162
  environment,
15063
- charts
15163
+ charts,
15164
+ runDiff
15064
15165
  };
15065
15166
  }
15066
15167
  printsToStdio() {
@@ -15264,6 +15365,47 @@ function collectFlakyTests(projects) {
15264
15365
  }
15265
15366
  return result;
15266
15367
  }
15368
+ function collectFailedTests(projects) {
15369
+ const seen = /* @__PURE__ */ new Set();
15370
+ const result = [];
15371
+ for (const project of projects) {
15372
+ for (const suite of flattenSuiteNodes(project.suites)) {
15373
+ for (const test of suite.tests) {
15374
+ if ((test.status === "failed" || test.status === "timedOut") && !seen.has(test.id)) {
15375
+ seen.add(test.id);
15376
+ result.push({ id: test.id, title: test.fullTitle });
15377
+ }
15378
+ }
15379
+ }
15380
+ }
15381
+ return result;
15382
+ }
15383
+ function collectCurrentStatuses(projects) {
15384
+ const statuses = /* @__PURE__ */ new Map();
15385
+ for (const project of projects) {
15386
+ for (const suite of flattenSuiteNodes(project.suites)) {
15387
+ for (const test of suite.tests) {
15388
+ statuses.set(test.id, test.status);
15389
+ }
15390
+ }
15391
+ }
15392
+ return statuses;
15393
+ }
15394
+ function buildFailedTestsFields(projects, redactor) {
15395
+ const redactedFailing = collectFailedTests(projects).map(
15396
+ (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15397
+ );
15398
+ const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
15399
+ return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
15400
+ }
15401
+ function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
15402
+ return computeRunDiff({
15403
+ currentFailing,
15404
+ currentStatuses: collectCurrentStatuses(projects),
15405
+ priorEntries,
15406
+ branch
15407
+ }) ?? void 0;
15408
+ }
15267
15409
 
15268
15410
  // src/index.ts
15269
15411
  function defineReporterConfig(options) {
package/dist/index.mjs CHANGED
@@ -10355,6 +10355,7 @@ var SECTION_KEYS = [
10355
10355
  "slowTests",
10356
10356
  "defectLog",
10357
10357
  "briefBand",
10358
+ "runDiff",
10358
10359
  // display modifiers
10359
10360
  "passRate",
10360
10361
  "fullEnvironment",
@@ -10378,6 +10379,7 @@ var SECTION_DEFAULTS = {
10378
10379
  slowTests: false,
10379
10380
  defectLog: false,
10380
10381
  briefBand: false,
10382
+ runDiff: true,
10381
10383
  passRate: true,
10382
10384
  fullEnvironment: false,
10383
10385
  retries: true,
@@ -10399,6 +10401,7 @@ var SECTION_DEFAULTS = {
10399
10401
  slowTests: true,
10400
10402
  defectLog: true,
10401
10403
  briefBand: false,
10404
+ runDiff: true,
10402
10405
  passRate: true,
10403
10406
  fullEnvironment: true,
10404
10407
  retries: true,
@@ -10425,6 +10428,7 @@ var SECTION_DEFAULTS = {
10425
10428
  slowTests: true,
10426
10429
  defectLog: false,
10427
10430
  briefBand: true,
10431
+ runDiff: true,
10428
10432
  passRate: true,
10429
10433
  fullEnvironment: false,
10430
10434
  retries: false,
@@ -13784,6 +13788,9 @@ function buildBranchHash(branch) {
13784
13788
  async function appendRemoteHistory(opts) {
13785
13789
  const numericEntry = { ...opts.entry };
13786
13790
  delete numericEntry.flakyTests;
13791
+ delete numericEntry.failedTests;
13792
+ delete numericEntry.failedTestsTruncated;
13793
+ delete numericEntry.branch;
13787
13794
  const controller = new AbortController();
13788
13795
  const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13789
13796
  try {
@@ -13813,6 +13820,58 @@ async function appendRemoteHistory(opts) {
13813
13820
  }
13814
13821
  }
13815
13822
 
13823
+ // src/history/types.ts
13824
+ init_esm_shims();
13825
+ var FAILED_TESTS_CAP = 300;
13826
+
13827
+ // src/history/RunDiff.ts
13828
+ init_esm_shims();
13829
+ function splitCurrentFailing(currentFailing, baselineFailed) {
13830
+ const currentFailedIds = /* @__PURE__ */ new Set();
13831
+ const newFailures = [];
13832
+ const stillFailing = [];
13833
+ for (const item of currentFailing) {
13834
+ if (currentFailedIds.has(item.id)) continue;
13835
+ currentFailedIds.add(item.id);
13836
+ (baselineFailed.has(item.id) ? stillFailing : newFailures).push(item);
13837
+ }
13838
+ return { newFailures, stillFailing, currentFailedIds };
13839
+ }
13840
+ function splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses) {
13841
+ const fixed = [];
13842
+ const noLongerRun = [];
13843
+ for (const [id, title] of baselineFailed) {
13844
+ if (currentFailedIds.has(id)) continue;
13845
+ const status = currentStatuses.get(id);
13846
+ const bucket = status === "passed" || status === "flaky" ? fixed : noLongerRun;
13847
+ bucket.push({ id, title });
13848
+ }
13849
+ return { fixed, noLongerRun };
13850
+ }
13851
+ function computeRunDiff(args) {
13852
+ const { currentFailing, currentStatuses, priorEntries, branch } = args;
13853
+ const baseline = priorEntries.find((e) => e.branch === branch && Array.isArray(e.failedTests));
13854
+ if (!baseline) return null;
13855
+ const baselineFailed = /* @__PURE__ */ new Map();
13856
+ for (const item of baseline.failedTests) {
13857
+ if (!baselineFailed.has(item.id)) baselineFailed.set(item.id, item.title);
13858
+ }
13859
+ const { newFailures, stillFailing, currentFailedIds } = splitCurrentFailing(currentFailing, baselineFailed);
13860
+ const { fixed, noLongerRun } = splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses);
13861
+ for (const bucket of [newFailures, stillFailing, fixed, noLongerRun]) {
13862
+ bucket.sort((a, b) => a.title.localeCompare(b.title));
13863
+ }
13864
+ return {
13865
+ newFailures,
13866
+ stillFailing,
13867
+ fixed,
13868
+ noLongerRun,
13869
+ baselineRunId: baseline.runId,
13870
+ baselineTimestamp: baseline.timestamp,
13871
+ baselineTruncated: baseline.failedTestsTruncated === true
13872
+ };
13873
+ }
13874
+
13816
13875
  // src/history/FlakinessAnalyser.ts
13817
13876
  init_esm_shims();
13818
13877
  function computeTopN(entries, topN) {
@@ -13889,11 +13948,32 @@ function reportName(pdfPaths, reportTitle) {
13889
13948
  if (pdfPaths.length > 0) return basename(pdfPaths[0]);
13890
13949
  return reportTitle || "ReportForge";
13891
13950
  }
13951
+ function formatDiffLine(diff) {
13952
+ if (!diff) return null;
13953
+ const newFailures = diff.newFailures.length;
13954
+ const fixed = diff.fixed.length;
13955
+ const stillFailing = diff.stillFailing.length;
13956
+ const noLongerRun = diff.noLongerRun.length;
13957
+ const parts = [
13958
+ `${newFailures} new ${newFailures === 1 ? "failure" : "failures"}`,
13959
+ `${fixed} fixed`,
13960
+ `${stillFailing} still failing`
13961
+ ];
13962
+ if (noLongerRun > 0) parts.push(`${noLongerRun} no longer run`);
13963
+ return parts.join(" \xB7 ");
13964
+ }
13892
13965
 
13893
13966
  // src/notify/formatters/slack.ts
13894
- function buildSlackPayload(stats, pdfPaths, reportTitle) {
13967
+ function buildSlackPayload(stats, pdfPaths, reportTitle, runDiff) {
13895
13968
  const label = statusLabel(stats.verdict);
13896
13969
  const name = reportName(pdfPaths, reportTitle);
13970
+ const diffLine = formatDiffLine(runDiff);
13971
+ const lines = [
13972
+ `*${label}* \xB7 ${stats.passRate}% passed`,
13973
+ countsLine(stats),
13974
+ ...diffLine ? [diffLine] : [],
13975
+ `_${name}_`
13976
+ ];
13897
13977
  return {
13898
13978
  text: `${label} \xB7 ${stats.passRate}% passed`,
13899
13979
  blocks: [
@@ -13901,9 +13981,7 @@ function buildSlackPayload(stats, pdfPaths, reportTitle) {
13901
13981
  type: "section",
13902
13982
  text: {
13903
13983
  type: "mrkdwn",
13904
- text: `*${label}* \xB7 ${stats.passRate}% passed
13905
- ${countsLine(stats)}
13906
- _${name}_`
13984
+ text: lines.join("\n")
13907
13985
  }
13908
13986
  }
13909
13987
  ]
@@ -13928,9 +14006,37 @@ function buildTeamsSimpleMessage(text) {
13928
14006
  ]
13929
14007
  };
13930
14008
  }
13931
- function buildTeamsPayload(stats, pdfPaths, reportTitle) {
14009
+ function buildTeamsPayload(stats, pdfPaths, reportTitle, runDiff) {
13932
14010
  const label = statusLabel(stats.verdict);
13933
14011
  const name = reportName(pdfPaths, reportTitle);
14012
+ const diffLine = formatDiffLine(runDiff);
14013
+ const body = [
14014
+ {
14015
+ type: "TextBlock",
14016
+ text: `${label} \xB7 ${stats.passRate}% passed`,
14017
+ weight: "bolder",
14018
+ size: "medium"
14019
+ },
14020
+ {
14021
+ type: "TextBlock",
14022
+ text: countsLine(stats),
14023
+ isSubtle: true
14024
+ },
14025
+ {
14026
+ type: "TextBlock",
14027
+ text: name,
14028
+ isSubtle: true,
14029
+ spacing: "none"
14030
+ }
14031
+ ];
14032
+ if (diffLine) {
14033
+ body.push({
14034
+ type: "TextBlock",
14035
+ text: diffLine,
14036
+ isSubtle: true,
14037
+ spacing: "none"
14038
+ });
14039
+ }
13934
14040
  return {
13935
14041
  type: "message",
13936
14042
  attachments: [
@@ -13940,25 +14046,7 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13940
14046
  $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
13941
14047
  type: "AdaptiveCard",
13942
14048
  version: "1.4",
13943
- body: [
13944
- {
13945
- type: "TextBlock",
13946
- text: `${label} \xB7 ${stats.passRate}% passed`,
13947
- weight: "bolder",
13948
- size: "medium"
13949
- },
13950
- {
13951
- type: "TextBlock",
13952
- text: countsLine(stats),
13953
- isSubtle: true
13954
- },
13955
- {
13956
- type: "TextBlock",
13957
- text: name,
13958
- isSubtle: true,
13959
- spacing: "none"
13960
- }
13961
- ]
14049
+ body
13962
14050
  }
13963
14051
  }
13964
14052
  ]
@@ -13967,16 +14055,17 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13967
14055
 
13968
14056
  // src/notify/formatters/discord.ts
13969
14057
  init_esm_shims();
13970
- function buildDiscordPayload(stats, pdfPaths, reportTitle) {
14058
+ function buildDiscordPayload(stats, pdfPaths, reportTitle, runDiff) {
13971
14059
  const label = statusLabel(stats.verdict);
13972
14060
  const name = reportName(pdfPaths, reportTitle);
13973
14061
  const color = stats.verdict === "passed" ? 3066993 : 15158332;
14062
+ const diffLine = formatDiffLine(runDiff);
14063
+ const descriptionLines = [countsLine(stats), ...diffLine ? [diffLine] : [], name];
13974
14064
  return {
13975
14065
  embeds: [
13976
14066
  {
13977
14067
  title: `${label} \xB7 ${stats.passRate}% passed`,
13978
- description: `${countsLine(stats)}
13979
- ${name}`,
14068
+ description: descriptionLines.join("\n"),
13980
14069
  color
13981
14070
  }
13982
14071
  ]
@@ -13990,10 +14079,16 @@ import { basename as basename2 } from "path";
13990
14079
 
13991
14080
  // src/notify/formatters/email.ts
13992
14081
  init_esm_shims();
13993
- function buildEmailContent(stats, pdfPaths, reportTitle) {
14082
+ function buildEmailContent(stats, pdfPaths, reportTitle, runDiff) {
13994
14083
  const label = statusLabel(stats.verdict);
13995
- const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
14084
+ const subject = `[ReportForge] ${label} \xB7 ${stats.passRate}% passed`;
13996
14085
  const name = reportName(pdfPaths, reportTitle);
14086
+ const diffLine = formatDiffLine(runDiff);
14087
+ const diffRow = diffLine ? `
14088
+ <tr>
14089
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Since last run</td>
14090
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(diffLine)}</td>
14091
+ </tr>` : "";
13997
14092
  const html = `<!DOCTYPE html>
13998
14093
  <html lang="en">
13999
14094
  <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
@@ -14028,7 +14123,7 @@ function buildEmailContent(stats, pdfPaths, reportTitle) {
14028
14123
  <tr style="background:#f9f9f9">
14029
14124
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
14030
14125
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
14031
- </tr>
14126
+ </tr>${diffRow}
14032
14127
  </table>
14033
14128
  <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>
14034
14129
  </body>
@@ -14053,7 +14148,8 @@ var EmailSender = class {
14053
14148
  const { subject, html } = buildEmailContent(
14054
14149
  reportData.stats,
14055
14150
  pdfPaths,
14056
- reportData.meta.title ?? ""
14151
+ reportData.meta.title ?? "",
14152
+ reportData.runDiff
14057
14153
  );
14058
14154
  const body = { from, to: config.to, subject, html };
14059
14155
  if (config.attachPdf && pdfPaths.length > 0) {
@@ -14117,7 +14213,7 @@ var NotificationSender = class {
14117
14213
  result.skipped.push(name);
14118
14214
  return Promise.resolve();
14119
14215
  }
14120
- const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
14216
+ const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle, reportData.runDiff);
14121
14217
  if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
14122
14218
  return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
14123
14219
  }
@@ -14860,7 +14956,7 @@ var PdfReporter = class {
14860
14956
  if (this.options.redact.enabled) {
14861
14957
  this.redactor = new Redactor(this.options.redact);
14862
14958
  }
14863
- this.version = true ? "0.25.0" : "0.x";
14959
+ this.version = true ? "0.26.1" : "0.x";
14864
14960
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14865
14961
  this.licenseClient = new LicenseClient({
14866
14962
  licenseKey: this.options.licenseKey,
@@ -14989,6 +15085,7 @@ var PdfReporter = class {
14989
15085
  const flakyTests = collectFlakyTests(collected.projects).map(
14990
15086
  (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14991
15087
  );
15088
+ const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
14992
15089
  const entry = {
14993
15090
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
14994
15091
  timestamp: Date.now(),
@@ -14999,9 +15096,12 @@ var PdfReporter = class {
14999
15096
  flaky: collected.stats.flaky,
15000
15097
  duration: collected.stats.duration,
15001
15098
  verdict: deriveVerdict(collected.stats),
15002
- flakyTests
15099
+ flakyTests,
15100
+ ...failedTestsFields,
15101
+ branch: collected.environment.branch
15003
15102
  };
15004
15103
  const localEntries = hm.append(entry, this.options.historySize);
15104
+ collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
15005
15105
  let trendEntries = localEntries;
15006
15106
  if (this.options.remoteHistory && licenseInfo.jwt) {
15007
15107
  const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
@@ -15041,7 +15141,7 @@ var PdfReporter = class {
15041
15141
  }
15042
15142
  }
15043
15143
  _buildReportData(collected, licenseInfo) {
15044
- const { projects, stats, failures, environment, charts } = collected;
15144
+ const { projects, stats, failures, environment, charts, runDiff } = collected;
15045
15145
  const projectName = this.options.projectName ?? this.resolveProjectName();
15046
15146
  const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
15047
15147
  return {
@@ -15061,7 +15161,8 @@ var PdfReporter = class {
15061
15161
  projects,
15062
15162
  failures,
15063
15163
  environment,
15064
- charts
15164
+ charts,
15165
+ runDiff
15065
15166
  };
15066
15167
  }
15067
15168
  printsToStdio() {
@@ -15265,6 +15366,47 @@ function collectFlakyTests(projects) {
15265
15366
  }
15266
15367
  return result;
15267
15368
  }
15369
+ function collectFailedTests(projects) {
15370
+ const seen = /* @__PURE__ */ new Set();
15371
+ const result = [];
15372
+ for (const project of projects) {
15373
+ for (const suite of flattenSuiteNodes(project.suites)) {
15374
+ for (const test of suite.tests) {
15375
+ if ((test.status === "failed" || test.status === "timedOut") && !seen.has(test.id)) {
15376
+ seen.add(test.id);
15377
+ result.push({ id: test.id, title: test.fullTitle });
15378
+ }
15379
+ }
15380
+ }
15381
+ }
15382
+ return result;
15383
+ }
15384
+ function collectCurrentStatuses(projects) {
15385
+ const statuses = /* @__PURE__ */ new Map();
15386
+ for (const project of projects) {
15387
+ for (const suite of flattenSuiteNodes(project.suites)) {
15388
+ for (const test of suite.tests) {
15389
+ statuses.set(test.id, test.status);
15390
+ }
15391
+ }
15392
+ }
15393
+ return statuses;
15394
+ }
15395
+ function buildFailedTestsFields(projects, redactor) {
15396
+ const redactedFailing = collectFailedTests(projects).map(
15397
+ (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15398
+ );
15399
+ const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
15400
+ return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
15401
+ }
15402
+ function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
15403
+ return computeRunDiff({
15404
+ currentFailing,
15405
+ currentStatuses: collectCurrentStatuses(projects),
15406
+ priorEntries,
15407
+ branch
15408
+ }) ?? void 0;
15409
+ }
15268
15410
 
15269
15411
  // src/index.ts
15270
15412
  function defineReporterConfig(options) {
@@ -24,6 +24,8 @@
24
24
 
25
25
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
26
26
 
27
+ {{#if sections.runDiff}}{{> run-diff variant='full'}}{{/if}}
28
+
27
29
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
28
30
 
29
31
  {{#if sections.ciEnvironment}}{{> ci-environment options=(obj showFullEnvironment=sections.fullEnvironment) }}{{/if}}
@@ -20,6 +20,8 @@
20
20
 
21
21
  {{#if sections.briefBand}}{{> brief-band}}{{/if}}
22
22
 
23
+ {{#if sections.runDiff}}{{> run-diff variant='compact'}}{{/if}}
24
+
23
25
  {{#if sections.summary}}{{> executive-hero-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
24
26
 
25
27
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend heroTrend=true) }}{{/if}}
@@ -22,6 +22,8 @@
22
22
 
23
23
  {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
24
24
 
25
+ {{#if sections.runDiff}}{{> run-diff variant='strip'}}{{/if}}
26
+
25
27
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
26
28
 
27
29
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
@@ -0,0 +1,41 @@
1
+ {{#if runDiff}}
2
+ <div class="section section--run-diff">
3
+ <h2>Since Last Run</h2>
4
+ <div class="run-diff__head">
5
+ <div class="run-diff__strip">
6
+ <span class="run-diff__count run-diff__count--new"><strong>{{runDiff.newFailures.length}}</strong> {{pluralise runDiff.newFailures.length "new failure" "new failures"}}</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--fixed"><strong>{{runDiff.fixed.length}}</strong> fixed</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--still"><strong>{{runDiff.stillFailing.length}}</strong> still failing</span>{{#if runDiff.noLongerRun.length}}&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--neutral"><strong>{{runDiff.noLongerRun.length}}</strong> no longer run</span>{{/if}}
7
+ </div>
8
+ <div class="run-diff__baseline">vs. previous run on {{formatDate runDiff.baselineTimestamp}}</div>
9
+ {{#if runDiff.baselineTruncated}}
10
+ <div class="overflow-banner run-diff__caveat">Baseline list was capped; fixed and no-longer-run may be incomplete.</div>
11
+ {{/if}}
12
+ </div>
13
+
14
+ {{#if (eq variant 'full')}}
15
+ {{> run-diff-list items=runDiff.newFailures label="New Failures" }}
16
+ {{> run-diff-list items=runDiff.fixed label="Fixed" }}
17
+ {{> run-diff-list items=runDiff.stillFailing label="Still Failing" }}
18
+ {{#if runDiff.noLongerRun.length}}
19
+ <div class="run-diff__footnote">No longer run: {{#each (take runDiff.noLongerRun 5)}}{{title}}{{#unless @last}} &middot; {{/unless}}{{/each}}{{#if (gt runDiff.noLongerRun.length 5)}} &middot; + {{subtract runDiff.noLongerRun.length 5}} more{{/if}}</div>
20
+ {{/if}}
21
+ {{/if}}
22
+
23
+ {{#if (eq variant 'compact')}}
24
+ {{> run-diff-list items=runDiff.newFailures label="New Failures" }}
25
+ {{/if}}
26
+ </div>
27
+ {{/if}}
28
+
29
+ {{#*inline "run-diff-list"}}
30
+ {{#if items.length}}
31
+ <div class="run-diff__list">
32
+ <div class="run-diff__list-label">{{label}}</div>
33
+ {{#each (take items 20)}}
34
+ <div class="run-diff__list-item">- {{title}}</div>
35
+ {{/each}}
36
+ {{#if (gt items.length 20)}}
37
+ <div class="run-diff__list-more">+ {{subtract items.length 20}} more</div>
38
+ {{/if}}
39
+ </div>
40
+ {{/if}}
41
+ {{/inline}}
@@ -188,7 +188,7 @@ tr:last-child td { border-bottom: none; }
188
188
  blocks below stay glued together, and headings stick to the content beneath. */
189
189
  .section { margin-bottom: 24px; }
190
190
  .kpi-strip, .summary-row, .charts-grid, .release-gate, .flaky-callout,
191
- .trend-card, .flakiness-card, .env-grid {
191
+ .trend-card, .flakiness-card, .env-grid, .run-diff__head {
192
192
  page-break-inside: avoid; break-inside: avoid;
193
193
  }
194
194
  h2, h3 { page-break-after: avoid; break-after: avoid; }
@@ -684,6 +684,24 @@ code {
684
684
  color: var(--text-1); line-height: 1.55;
685
685
  }
686
686
 
687
+ /* ── Run diff — since-last-run counts + optional lists (sections.runDiff) ── */
688
+ .run-diff__strip { font-size: 10.5px; }
689
+ .run-diff__count strong { font-weight: 700; }
690
+ .run-diff__count--new strong { color: var(--fail); }
691
+ .run-diff__count--fixed strong { color: var(--pass); }
692
+ .run-diff__count--still strong,
693
+ .run-diff__count--neutral strong { color: var(--text-3); }
694
+ .run-diff__baseline { font-size: 9.5px; color: var(--text-3); margin-top: 4px; }
695
+ .run-diff__caveat { margin-top: 8px; }
696
+ .run-diff__list { margin-top: 10px; }
697
+ .run-diff__list-label {
698
+ font-size: 9px; font-weight: 600; letter-spacing: 0.6px; text-transform: uppercase;
699
+ color: var(--text-2); margin-bottom: 4px;
700
+ }
701
+ .run-diff__list-item { font-size: 10px; color: var(--text-1); padding: 1px 0; }
702
+ .run-diff__list-more { font-size: 9.5px; color: var(--text-3); padding: 1px 0; }
703
+ .run-diff__footnote { font-size: 9.5px; color: var(--text-3); margin-top: 8px; }
704
+
687
705
  /* Charts grid + cards (available to any template via sections.charts) */
688
706
  .charts-grid {
689
707
  display: grid; grid-template-columns: 210px 1fr;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.25.0",
3
+ "version": "0.26.1",
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",