@reportforge/playwright-pdf 0.24.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.
Files changed (31) hide show
  1. package/README.md +9 -6
  2. package/ci-templates/azure-devops/azure-pipelines.yml +58 -58
  3. package/ci-templates/github-actions/basic-workflow.yml +44 -44
  4. package/ci-templates/gitlab-ci/.gitlab-ci.yml +42 -42
  5. package/ci-templates/jenkins/Jenkinsfile +54 -54
  6. package/dist/cli/index.js +24 -1
  7. package/dist/index.d.mts +9 -3
  8. package/dist/index.d.ts +9 -3
  9. package/dist/index.js +407 -44
  10. package/dist/index.mjs +407 -44
  11. package/dist/templates/layouts/detailed.hbs +43 -41
  12. package/dist/templates/layouts/executive.hbs +43 -41
  13. package/dist/templates/layouts/minimal.hbs +43 -41
  14. package/dist/templates/partials/analysis-oneliner.hbs +3 -3
  15. package/dist/templates/partials/chart-summary-grid.hbs +40 -40
  16. package/dist/templates/partials/chart-trend-card.hbs +16 -16
  17. package/dist/templates/partials/charts.hbs +433 -433
  18. package/dist/templates/partials/ci-environment.hbs +56 -56
  19. package/dist/templates/partials/cover-page.hbs +48 -48
  20. package/dist/templates/partials/defect-log.hbs +79 -79
  21. package/dist/templates/partials/executive-hero-summary.hbs +66 -66
  22. package/dist/templates/partials/executive-summary.hbs +78 -78
  23. package/dist/templates/partials/failure-card.hbs +35 -35
  24. package/dist/templates/partials/head.hbs +17 -17
  25. package/dist/templates/partials/release-gate.hbs +84 -84
  26. package/dist/templates/partials/requirements-matrix.hbs +51 -51
  27. package/dist/templates/partials/run-diff.hbs +41 -0
  28. package/dist/templates/partials/suite-breakdown.hbs +89 -89
  29. package/dist/templates/partials/watermark.hbs +42 -42
  30. package/dist/templates/styles/base.css +19 -1
  31. package/package.json +1 -2
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
  }
@@ -14231,11 +14327,35 @@ var NETWORK_TIMEOUT_MS3 = 8e3;
14231
14327
  var LIVE_DRAIN_DEADLINE_MS = 1e4;
14232
14328
  var MAX_FLUSH_MS = 3e4;
14233
14329
  var WARN_THROTTLE_MS = 3e4;
14330
+ var TREE_UPDATE_WEIGHT = 25;
14331
+ function redactTestUpdate(update, redact) {
14332
+ if (update.title !== void 0) update.title = redact(update.title);
14333
+ if (update.currentStep !== void 0) update.currentStep = redact(update.currentStep);
14334
+ if (update.file !== void 0) update.file = redact(update.file);
14335
+ if (update.suitePath) update.suitePath = update.suitePath.map(redact);
14336
+ if (update.projectName !== void 0) update.projectName = redact(update.projectName);
14337
+ if (update.steps) redactStepNodes(update.steps, redact);
14338
+ if (update.errors) update.errors.forEach((e) => redactError(e, redact));
14339
+ }
14340
+ function redactStepNodes(nodes, redact) {
14341
+ for (const node of nodes) {
14342
+ node.t = redact(node.t);
14343
+ if (node.err) redactError(node.err, redact);
14344
+ if (node.ch) redactStepNodes(node.ch, redact);
14345
+ }
14346
+ }
14347
+ function redactError(err, redact) {
14348
+ err.message = redact(err.message);
14349
+ if (err.stack !== void 0) err.stack = redact(err.stack);
14350
+ if (err.snippet !== void 0) err.snippet = redact(err.snippet);
14351
+ }
14234
14352
  var LiveStreamer = class {
14235
14353
  constructor(config) {
14236
14354
  this.config = config;
14237
14355
  this.testBuffer = [];
14238
14356
  this.eventBuffer = [];
14357
+ /** Weighted size of the buffers (tree-bearing updates count TREE_UPDATE_WEIGHT). */
14358
+ this.bufferWeight = 0;
14239
14359
  /** Monotonic per-shard event counter → stable `{shardKey}:{seq}` event ids. */
14240
14360
  this.eventSeq = 0;
14241
14361
  /**
@@ -14261,13 +14381,11 @@ var LiveStreamer = class {
14261
14381
  }
14262
14382
  enqueueTest(update) {
14263
14383
  if (!this.enabled) return;
14264
- if (this.config.redact) {
14265
- if (update.title !== void 0) update.title = this.config.redact(update.title);
14266
- if (update.currentStep !== void 0) update.currentStep = this.config.redact(update.currentStep);
14267
- }
14384
+ if (this.config.redact) redactTestUpdate(update, this.config.redact);
14268
14385
  const prevRetry = this.testStates.get(update.id)?.retry;
14269
14386
  this.testStates.set(update.id, { status: update.status, retry: update.retry ?? prevRetry });
14270
14387
  this.testBuffer.push(update);
14388
+ this.bufferWeight += update.steps ? TREE_UPDATE_WEIGHT : 1;
14271
14389
  this.capPreLicenseBuffer();
14272
14390
  this.scheduleFlush();
14273
14391
  }
@@ -14279,6 +14397,7 @@ var LiveStreamer = class {
14279
14397
  }
14280
14398
  event.id = `${this.config.shardKey}:${this.eventSeq++}`;
14281
14399
  this.eventBuffer.push(event);
14400
+ this.bufferWeight += 1;
14282
14401
  this.capPreLicenseBuffer();
14283
14402
  this.scheduleFlush();
14284
14403
  }
@@ -14396,9 +14515,13 @@ var LiveStreamer = class {
14396
14515
  if (dropEvents > 0) this.eventBuffer.splice(0, dropEvents);
14397
14516
  overflow -= dropEvents;
14398
14517
  if (overflow > 0) this.testBuffer.splice(0, overflow);
14518
+ this.bufferWeight = this.currentBufferWeight();
14519
+ }
14520
+ currentBufferWeight() {
14521
+ return this.eventBuffer.length + this.testBuffer.reduce((w, t) => w + (t.steps ? TREE_UPDATE_WEIGHT : 1), 0);
14399
14522
  }
14400
14523
  scheduleFlush() {
14401
- if (this.testBuffer.length + this.eventBuffer.length >= this.flushMaxBatch) {
14524
+ if (this.bufferWeight >= this.flushMaxBatch) {
14402
14525
  void this.flush("running");
14403
14526
  return;
14404
14527
  }
@@ -14419,6 +14542,7 @@ var LiveStreamer = class {
14419
14542
  if (status !== "finished" && tests.length === 0 && events.length === 0) return;
14420
14543
  this.testBuffer = [];
14421
14544
  this.eventBuffer = [];
14545
+ this.bufferWeight = 0;
14422
14546
  const body = {
14423
14547
  runId: this.config.runId,
14424
14548
  shardKey: this.config.shardKey,
@@ -14453,6 +14577,7 @@ var LiveStreamer = class {
14453
14577
  this.enabled = false;
14454
14578
  this.testBuffer = [];
14455
14579
  this.eventBuffer = [];
14580
+ this.bufferWeight = 0;
14456
14581
  logger.debug("Live streaming disabled \u2014 no active license or live entitlement.");
14457
14582
  return false;
14458
14583
  }
@@ -14539,25 +14664,213 @@ function shardKeyOf(shard) {
14539
14664
 
14540
14665
  // src/live/event-map.ts
14541
14666
  init_esm_shims();
14667
+
14668
+ // src/live/step-tree.ts
14669
+ init_esm_shims();
14670
+ var MAX_TREE_DEPTH = 10;
14671
+ var MAX_TREE_NODES = 150;
14672
+ var MAX_TREE_JSON = 24e3;
14673
+ var MAX_NODE_TITLE = 200;
14674
+ var MAX_ERRORS = 3;
14675
+ var MAX_ERR_MESSAGE = 2e3;
14676
+ var MAX_ERR_STACK = 8192;
14677
+ var MAX_ERR_SNIPPET = 4096;
14678
+ var isHook = (c) => c === "hook" || c === "fixture";
14679
+ function flatten2(steps) {
14680
+ const out = [];
14681
+ const walk = (nodes, depth, ancestors) => {
14682
+ for (const s of nodes ?? []) {
14683
+ const index = out.length;
14684
+ out.push({
14685
+ raw: s,
14686
+ category: s.category ?? "",
14687
+ depth,
14688
+ hasError: s.error != null,
14689
+ ancestors
14690
+ });
14691
+ if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
14692
+ }
14693
+ };
14694
+ walk(steps ?? [], 0, []);
14695
+ return out;
14696
+ }
14697
+ var inHookSubtree2 = (flat, f) => isHook(f.category) || f.ancestors.some((a) => isHook(flat[a].category));
14698
+ function pickFailingIdx2(flat) {
14699
+ const pick = (allowTeardown) => {
14700
+ let idx = -1;
14701
+ for (let i = 0; i < flat.length; i++) {
14702
+ const f = flat[i];
14703
+ if (!f.hasError || !allowTeardown && inHookSubtree2(flat, f)) continue;
14704
+ if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
14705
+ }
14706
+ return idx;
14707
+ };
14708
+ const body = pick(false);
14709
+ return body >= 0 ? body : pick(true);
14710
+ }
14711
+ function mapCategory(category) {
14712
+ if (category === "test.step") return "step";
14713
+ if (category === "expect") return "expect";
14714
+ if (isHook(category)) return "hook";
14715
+ return "api";
14716
+ }
14717
+ function buildErrorInfo(err) {
14718
+ if (!err?.message) return void 0;
14719
+ return {
14720
+ message: clampWithEllipsis(stripAnsi(err.message), MAX_ERR_MESSAGE),
14721
+ ...err.stack ? { stack: clampWithEllipsis(stripAnsi(err.stack), MAX_ERR_STACK) } : {},
14722
+ ...err.snippet ? { snippet: clampWithEllipsis(stripAnsi(err.snippet), MAX_ERR_SNIPPET) } : {}
14723
+ };
14724
+ }
14725
+ function mapResultErrors(errors) {
14726
+ if (!errors?.length) return void 0;
14727
+ const out = errors.slice(0, MAX_ERRORS).map((e) => buildErrorInfo(e)).filter((e) => e !== void 0);
14728
+ return out.length > 0 ? out : void 0;
14729
+ }
14730
+ function buildLiveStepTree(steps) {
14731
+ if (!steps?.length) return void 0;
14732
+ const flat = flatten2(steps);
14733
+ if (flat.length === 0) return void 0;
14734
+ const failingIdx = pickFailingIdx2(flat);
14735
+ const failingPath = /* @__PURE__ */ new Set();
14736
+ if (failingIdx >= 0) {
14737
+ failingPath.add(flat[failingIdx].raw);
14738
+ for (const a of flat[failingIdx].ancestors) failingPath.add(flat[a].raw);
14739
+ }
14740
+ const failingLeaf = failingIdx >= 0 ? flat[failingIdx].raw : void 0;
14741
+ for (const level of [0, 1, 2, 3]) {
14742
+ const budget = { left: MAX_TREE_NODES };
14743
+ const tree = buildChildren(steps, {
14744
+ level,
14745
+ depth: 0,
14746
+ underHook: false,
14747
+ underApiOrExpect: false,
14748
+ failingPath,
14749
+ failingLeaf,
14750
+ budget
14751
+ });
14752
+ if (tree.length === 0) return void 0;
14753
+ if (budget.left >= 0 && JSON.stringify(tree).length <= MAX_TREE_JSON) return tree;
14754
+ }
14755
+ return void 0;
14756
+ }
14757
+ function keepNode(s, ctx) {
14758
+ if (ctx.failingPath.has(s)) return true;
14759
+ if (!s.title) return false;
14760
+ const c = s.category ?? "";
14761
+ if (isHook(c) || ctx.underHook) return false;
14762
+ if (c === "test.step") return true;
14763
+ if (ctx.underApiOrExpect) return false;
14764
+ if (c === "expect") return ctx.level < 2;
14765
+ if (c === "pw:api") return ctx.level < 1;
14766
+ return false;
14767
+ }
14768
+ function containsFailing(s, leaf) {
14769
+ if (!leaf) return false;
14770
+ if (s === leaf) return true;
14771
+ return (s.steps ?? []).some((child) => containsFailing(child, leaf));
14772
+ }
14773
+ function buildChildren(nodes, ctx) {
14774
+ const out = [];
14775
+ for (const s of nodes ?? []) {
14776
+ if (ctx.budget.left <= 0) {
14777
+ ctx.budget.left = -1;
14778
+ break;
14779
+ }
14780
+ const c = s.category ?? "";
14781
+ if (!keepNode(s, ctx)) {
14782
+ if (containsFailing(s, ctx.failingLeaf)) {
14783
+ out.push(
14784
+ ...buildChildren(s.steps, {
14785
+ ...ctx,
14786
+ underHook: ctx.underHook || isHook(c),
14787
+ underApiOrExpect: ctx.underApiOrExpect || c === "pw:api" || c === "expect"
14788
+ })
14789
+ );
14790
+ }
14791
+ continue;
14792
+ }
14793
+ ctx.budget.left -= 1;
14794
+ const node = {
14795
+ t: clampWithEllipsis(stripAnsi(s.title ?? ""), MAX_NODE_TITLE),
14796
+ c: mapCategory(c),
14797
+ ...Number.isFinite(s.duration) && s.duration >= 0 ? { d: Math.round(s.duration) } : {},
14798
+ ...s.error != null ? { s: "failed" } : {},
14799
+ ...s === ctx.failingLeaf ? errField(s) : {}
14800
+ };
14801
+ if (s.steps?.length) {
14802
+ if (ctx.depth + 1 >= MAX_TREE_DEPTH) {
14803
+ if (!node.err && containsFailing(s, ctx.failingLeaf) && s !== ctx.failingLeaf) {
14804
+ const hoisted = errField(ctx.failingLeaf);
14805
+ if (hoisted.err) {
14806
+ node.err = hoisted.err;
14807
+ node.s = "failed";
14808
+ }
14809
+ }
14810
+ } else {
14811
+ const ch = buildChildren(s.steps, {
14812
+ ...ctx,
14813
+ depth: ctx.depth + 1,
14814
+ underHook: ctx.underHook || isHook(c),
14815
+ underApiOrExpect: ctx.underApiOrExpect || c === "pw:api" || c === "expect"
14816
+ });
14817
+ if (ch.length > 0) node.ch = ch;
14818
+ }
14819
+ }
14820
+ out.push(node);
14821
+ }
14822
+ return out;
14823
+ }
14824
+ function errField(s) {
14825
+ const err = buildErrorInfo(s.error);
14826
+ return err ? { err } : {};
14827
+ }
14828
+
14829
+ // src/live/event-map.ts
14542
14830
  var MAX_TITLE_CHARS2 = 200;
14543
14831
  var MAX_STEP_TITLE_CHARS = 200;
14544
14832
  var MAX_CONSOLE_CHARS = 2048;
14833
+ var MAX_FILE_CHARS = 300;
14834
+ var MAX_PROJECT_CHARS = 100;
14835
+ var MAX_SUITE_SEGMENTS = 10;
14836
+ function suiteIdentity(test) {
14837
+ const path16 = typeof test.titlePath === "function" ? test.titlePath() : [];
14838
+ if (!Array.isArray(path16) || path16.length < 4) return {};
14839
+ const [, project, file, ...rest] = path16;
14840
+ const describes = rest.slice(0, -1);
14841
+ const out = {};
14842
+ if (typeof file === "string" && file) {
14843
+ out.file = clampWithEllipsis(file.replace(/\\/g, "/"), MAX_FILE_CHARS);
14844
+ }
14845
+ const segs = describes.filter((s) => typeof s === "string" && s.length > 0).slice(0, MAX_SUITE_SEGMENTS).map((s) => clampWithEllipsis(s, MAX_STEP_TITLE_CHARS));
14846
+ if (segs.length > 0) out.suitePath = segs;
14847
+ if (typeof project === "string" && project) {
14848
+ out.projectName = clampWithEllipsis(project, MAX_PROJECT_CHARS);
14849
+ }
14850
+ return out;
14851
+ }
14545
14852
  function mapTestBegin(test, shardKey) {
14546
14853
  return {
14547
14854
  id: test.id,
14548
14855
  title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
14549
14856
  status: "running",
14550
- shardKey
14857
+ shardKey,
14858
+ ...suiteIdentity(test)
14551
14859
  };
14552
14860
  }
14553
14861
  function mapTestEnd(test, result, shardKey) {
14862
+ const steps = buildLiveStepTree(result.steps);
14863
+ const errors = mapResultErrors(result.errors);
14554
14864
  return {
14555
14865
  id: test.id,
14556
14866
  title: clampWithEllipsis(test.title ?? "", MAX_TITLE_CHARS2),
14557
14867
  status: result.status,
14558
14868
  durationMs: result.duration,
14559
14869
  retry: result.retry,
14560
- shardKey
14870
+ shardKey,
14871
+ ...suiteIdentity(test),
14872
+ ...steps ? { steps } : {},
14873
+ ...errors ? { errors } : {}
14561
14874
  };
14562
14875
  }
14563
14876
  function isLifecycleStep(step) {
@@ -14607,7 +14920,11 @@ function mapStep(testId, step, phase, shardKey) {
14607
14920
  startedAt: step.startTime?.getTime(),
14608
14921
  // Nesting depth (test.step ancestors) so the page indents children under their
14609
14922
  // parent step. 0 → flat, so a test without test.steps renders as a flat trail.
14610
- depth: testStepDepth2(step)
14923
+ depth: testStepDepth2(step),
14924
+ // Category so the trail styles pw:api actions and expects distinctly. Hooks
14925
+ // and fixtures never reach here non-errored (shouldEmitStep drops them); an
14926
+ // errored one maps to 'api' — the trail has no dedicated hook style.
14927
+ category: step.category === "test.step" ? "step" : step.category === "expect" ? "expect" : "api"
14611
14928
  };
14612
14929
  }
14613
14930
  function mapConsole(kind, chunk, testId, shardKey) {
@@ -14639,7 +14956,7 @@ var PdfReporter = class {
14639
14956
  if (this.options.redact.enabled) {
14640
14957
  this.redactor = new Redactor(this.options.redact);
14641
14958
  }
14642
- this.version = true ? "0.24.0" : "0.x";
14959
+ this.version = true ? "0.26.1" : "0.x";
14643
14960
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14644
14961
  this.licenseClient = new LicenseClient({
14645
14962
  licenseKey: this.options.licenseKey,
@@ -14768,6 +15085,7 @@ var PdfReporter = class {
14768
15085
  const flakyTests = collectFlakyTests(collected.projects).map(
14769
15086
  (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14770
15087
  );
15088
+ const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
14771
15089
  const entry = {
14772
15090
  runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
14773
15091
  timestamp: Date.now(),
@@ -14778,9 +15096,12 @@ var PdfReporter = class {
14778
15096
  flaky: collected.stats.flaky,
14779
15097
  duration: collected.stats.duration,
14780
15098
  verdict: deriveVerdict(collected.stats),
14781
- flakyTests
15099
+ flakyTests,
15100
+ ...failedTestsFields,
15101
+ branch: collected.environment.branch
14782
15102
  };
14783
15103
  const localEntries = hm.append(entry, this.options.historySize);
15104
+ collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
14784
15105
  let trendEntries = localEntries;
14785
15106
  if (this.options.remoteHistory && licenseInfo.jwt) {
14786
15107
  const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
@@ -14820,7 +15141,7 @@ var PdfReporter = class {
14820
15141
  }
14821
15142
  }
14822
15143
  _buildReportData(collected, licenseInfo) {
14823
- const { projects, stats, failures, environment, charts } = collected;
15144
+ const { projects, stats, failures, environment, charts, runDiff } = collected;
14824
15145
  const projectName = this.options.projectName ?? this.resolveProjectName();
14825
15146
  const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
14826
15147
  return {
@@ -14840,7 +15161,8 @@ var PdfReporter = class {
14840
15161
  projects,
14841
15162
  failures,
14842
15163
  environment,
14843
- charts
15164
+ charts,
15165
+ runDiff
14844
15166
  };
14845
15167
  }
14846
15168
  printsToStdio() {
@@ -15044,6 +15366,47 @@ function collectFlakyTests(projects) {
15044
15366
  }
15045
15367
  return result;
15046
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
+ }
15047
15410
 
15048
15411
  // src/index.ts
15049
15412
  function defineReporterConfig(options) {