@reportforge/playwright-pdf 0.15.0 → 0.16.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,33 @@
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.16.0] - 2026-07-07
7
+
8
+ ### Added
9
+
10
+ - **Every suite now gets its own bar in the Suite Results chart.** Runs with more than 10 suites switch from the compact side-by-side canvas to full-width charts (about 45 suites per page-sized canvas, "continued" across pages, declaration order preserved) — a run-wide per-suite coverage map instead of a silently truncated top-10. The Pass Rate doughnut takes the freed slot at 230px. A 150-suite safety ceiling (failures kept first, remainder summarised in a "+ N more suites" note under the chart) guards pathological runs only. Applies to every template that renders the charts section.
11
+ - **Per-suite totals on the bar chart**: each bar ends with its muted test count, so counts read directly instead of via gridlines.
12
+
13
+ ### Changed
14
+
15
+ - **Chart polish**: the pass-rate doughnut renders as a gauge (thicker ring, rounded and gapped segments), legends use colour dots, bars have rounded caps, and suite labels went from 9px to 10px with taller rows.
16
+
17
+ ### Fixed
18
+
19
+ - **Chart axis labels no longer clip their first character.** Chart.js measured tick labels before the report's embedded Inter font finished loading and painted them after, so wide-glyph labels overflowed their measured gutter. Charts now initialise after `document.fonts.ready`, making measurement match paint exactly.
20
+ - **X-axis no longer draws one tick per test** on suites with large test counts (`stepSize: 1` replaced with auto steps capped at 12, integers only).
21
+ - `timedOut` counts are now carried through the suite-chart data type and the demo fixture's suite entries.
22
+
23
+ ---
24
+
25
+ ## [0.15.1] - 2026-07-07
26
+
27
+ ### Changed
28
+
29
+ - **Release Recommendation copy is dash-free and data-driven.** The banner verdict reads `HOLD: N FAILURES` and the detail line now carries the per-run root-cause tally from failure analysis, e.g. "3 of 21 tests failed (2 assertion, 1 timeout). Each failure may be a product defect or an automation script issue. Review and verify manually before shipping." Falls back to the plain count when analysis is off; `flaky-by-retry` is excluded from the tally (flaky tests keep their own suffix). Timed-out variant matches.
30
+
31
+ ---
32
+
6
33
  ## [0.15.0] - 2026-07-07
7
34
 
8
35
  ### Changed
package/README.md CHANGED
@@ -47,7 +47,7 @@ Three templates, one reporter. Every PDF is fully offline: fonts, charts, and sc
47
47
 
48
48
  - **3 templates**: `minimal` (developer), `detailed` (QA team), `executive` (stakeholders)
49
49
  - **Detailed reports**: KPI dashboard, suite breakdown with inline failure detail (error, screenshot, root-cause chip) under each failed test, CI/CD environment
50
- - **Embedded Chart.js**: pass-rate doughnut + per-suite bar chart, bundled inline (no CDN)
50
+ - **Embedded Chart.js**: pass-rate doughnut + per-suite bar chart, bundled inline (no CDN); every suite gets its own bar — large runs switch to full-width suite charts that continue across pages, so per-suite coverage and failures stay visible run-wide
51
51
  - **Self-contained PDFs**: screenshots, fonts, logos all embedded; share or archive with confidence
52
52
  - **Custom branding**: logo, primary/accent colours, watermark, PDF password encryption
53
53
  - **Dynamic filenames**: `{date}`, `{branch}`, `{status}` tokens in the output path
package/dist/cli/index.js CHANGED
@@ -120,8 +120,9 @@ function makeProject(name, suites) {
120
120
  function suiteEntry(label, tests) {
121
121
  const passed = tests.filter((t) => t.status === "passed" || t.status === "flaky").length;
122
122
  const failed = tests.filter((t) => t.status === "failed").length;
123
+ const timedOut = tests.filter((t) => t.status === "timedOut").length;
123
124
  const skipped = tests.filter((t) => t.status === "skipped").length;
124
- return { label, passed, failed, skipped };
125
+ return { label, passed, failed, timedOut, skipped };
125
126
  }
126
127
  function buildDemoFailures() {
127
128
  return [
@@ -6375,16 +6376,24 @@ var init_duration = __esm({
6375
6376
  });
6376
6377
 
6377
6378
  // src/analysis/SummaryWriter.ts
6378
- function buildOneLiner(clusters) {
6379
- if (clusters.length === 0) return "";
6379
+ function tallyEntries(clusters) {
6380
6380
  const byCategory = /* @__PURE__ */ new Map();
6381
6381
  for (const c of clusters) {
6382
6382
  byCategory.set(c.category, (byCategory.get(c.category) ?? 0) + c.count);
6383
6383
  }
6384
+ return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
6385
+ }
6386
+ function buildOneLiner(clusters) {
6387
+ if (clusters.length === 0) return "";
6384
6388
  const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
6385
- const tally = [...byCategory.entries()].sort((a, b) => b[1] - a[1]).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
6389
+ const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
6386
6390
  return [head, ...tally].join(" \xB7 ");
6387
6391
  }
6392
+ function buildFailureTally(analysis) {
6393
+ if (!analysis) return "";
6394
+ const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
6395
+ return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
6396
+ }
6388
6397
  function dominantCategory(clusters) {
6389
6398
  return clusters[0]?.category ?? "unknown";
6390
6399
  }
@@ -6579,6 +6588,18 @@ function jsonForInlineScript(value) {
6579
6588
  function isUnknownValue(v) {
6580
6589
  return !v || v === "unknown";
6581
6590
  }
6591
+ function buildSuiteChartChunks(rows) {
6592
+ const chunks = [];
6593
+ for (let i = 0; i < rows.length; i += SUITE_CHART_ROWS_PER_CANVAS) {
6594
+ const slice = rows.slice(i, i + SUITE_CHART_ROWS_PER_CANVAS);
6595
+ chunks.push({
6596
+ canvasId: `suitesBarChart${chunks.length}`,
6597
+ heightPx: slice.length * SUITE_CHART_ROW_PX + SUITE_CHART_PAD_PX,
6598
+ rows: slice
6599
+ });
6600
+ }
6601
+ return chunks;
6602
+ }
6582
6603
  function deriveOptions(s) {
6583
6604
  return {
6584
6605
  showCharts: s.charts,
@@ -6590,7 +6611,7 @@ function deriveOptions(s) {
6590
6611
  showFullFailures: s.fullFailures
6591
6612
  };
6592
6613
  }
6593
- var import_handlebars, import_fs, import_path, A4_HEIGHT_MM, PAGE_MARGIN_Y_MM, PX_PER_MM, PAGE_CONTENT_HEIGHT_PX, SLOW_MEDIAN_FACTOR, TemplateEngine;
6614
+ var import_handlebars, import_fs, import_path, A4_HEIGHT_MM, PAGE_MARGIN_Y_MM, PX_PER_MM, SUITE_CHART_SMALL_MAX, SUITE_CHART_ROWS_PER_CANVAS, SUITE_CHART_ROW_PX, SUITE_CHART_PAD_PX, PAGE_CONTENT_HEIGHT_PX, SLOW_MEDIAN_FACTOR, TemplateEngine;
6594
6615
  var init_engine = __esm({
6595
6616
  "src/templates/engine.ts"() {
6596
6617
  "use strict";
@@ -6605,6 +6626,10 @@ var init_engine = __esm({
6605
6626
  A4_HEIGHT_MM = 297;
6606
6627
  PAGE_MARGIN_Y_MM = 16;
6607
6628
  PX_PER_MM = 96 / 25.4;
6629
+ SUITE_CHART_SMALL_MAX = 10;
6630
+ SUITE_CHART_ROWS_PER_CANVAS = 45;
6631
+ SUITE_CHART_ROW_PX = 18;
6632
+ SUITE_CHART_PAD_PX = 56;
6608
6633
  PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
6609
6634
  SLOW_MEDIAN_FACTOR = 2;
6610
6635
  TemplateEngine = class {
@@ -6666,7 +6691,10 @@ var init_engine = __esm({
6666
6691
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
6667
6692
  templateCSS: "",
6668
6693
  chartjsScript: this.buildChartjsScript(),
6669
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
6694
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
6695
+ releaseGateTally: buildFailureTally(data.analysis),
6696
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
6697
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
6670
6698
  };
6671
6699
  }
6672
6700
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -6690,7 +6718,10 @@ var init_engine = __esm({
6690
6718
  baseCSS,
6691
6719
  templateCSS,
6692
6720
  chartjsScript: this.buildChartjsScript(),
6693
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
6721
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
6722
+ releaseGateTally: buildFailureTally(data.analysis),
6723
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
6724
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
6694
6725
  };
6695
6726
  }
6696
6727
  buildChartjsScript() {
package/dist/index.js CHANGED
@@ -11291,16 +11291,24 @@ var SHORT_LABEL = {
11291
11291
  "flaky-by-retry": "flaky",
11292
11292
  unknown: "unclassified"
11293
11293
  };
11294
- function buildOneLiner(clusters) {
11295
- if (clusters.length === 0) return "";
11294
+ function tallyEntries(clusters) {
11296
11295
  const byCategory = /* @__PURE__ */ new Map();
11297
11296
  for (const c of clusters) {
11298
11297
  byCategory.set(c.category, (byCategory.get(c.category) ?? 0) + c.count);
11299
11298
  }
11299
+ return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
11300
+ }
11301
+ function buildOneLiner(clusters) {
11302
+ if (clusters.length === 0) return "";
11300
11303
  const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
11301
- const tally = [...byCategory.entries()].sort((a, b) => b[1] - a[1]).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11304
+ const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11302
11305
  return [head, ...tally].join(" \xB7 ");
11303
11306
  }
11307
+ function buildFailureTally(analysis) {
11308
+ if (!analysis) return "";
11309
+ const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
11310
+ return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
11311
+ }
11304
11312
  function dominantCategory(clusters) {
11305
11313
  return clusters[0]?.category ?? "unknown";
11306
11314
  }
@@ -11648,7 +11656,7 @@ init_cjs_shims();
11648
11656
 
11649
11657
  // src/collector/stats-utils.ts
11650
11658
  init_cjs_shims();
11651
- var TOP_SUITES_FOR_CHART = 10;
11659
+ var SUITE_CHART_MAX_ROWS = 150;
11652
11660
  function resolveTestStatus(test, result) {
11653
11661
  if (!result) return "skipped";
11654
11662
  const expected = test.expectedStatus ?? "passed";
@@ -11692,15 +11700,40 @@ function toRow(s) {
11692
11700
  skipped: s.stats.skipped
11693
11701
  };
11694
11702
  }
11703
+ function capRows(rows, limit) {
11704
+ if (rows.length <= limit) return { rows };
11705
+ const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11706
+ const scored = [...rows].sort(
11707
+ (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11708
+ );
11709
+ const rest = scored.slice(limit);
11710
+ const sums = rest.reduce(
11711
+ (acc, r) => ({
11712
+ passed: acc.passed + r.passed,
11713
+ failed: acc.failed + r.failed,
11714
+ timedOut: acc.timedOut + r.timedOut,
11715
+ skipped: acc.skipped + r.skipped
11716
+ }),
11717
+ { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11718
+ );
11719
+ const parts = [
11720
+ sums.failed > 0 ? `${sums.failed} failed` : "",
11721
+ sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11722
+ sums.passed > 0 ? `${sums.passed} passed` : "",
11723
+ sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11724
+ ].filter(Boolean);
11725
+ const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11726
+ return { rows: scored.slice(0, limit), overflowNote };
11727
+ }
11695
11728
  function buildSuiteResults(projects, limit) {
11696
11729
  const top = projects.flatMap((p) => p.suites);
11697
11730
  if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11698
11731
  const file = top[0];
11699
11732
  const fs14 = statsFromTests(file.tests);
11700
11733
  const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11701
- return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11734
+ return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11702
11735
  }
11703
- return top.map(toRow).slice(0, limit);
11736
+ return capRows(top.map(toRow), limit);
11704
11737
  }
11705
11738
 
11706
11739
  // src/collector/SuiteWalker.ts
@@ -12045,7 +12078,7 @@ var DataCollector = class {
12045
12078
  };
12046
12079
  }
12047
12080
  buildChartData(stats, projects) {
12048
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12081
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12049
12082
  return {
12050
12083
  passRate: {
12051
12084
  passed: stats.passed,
@@ -12054,7 +12087,8 @@ var DataCollector = class {
12054
12087
  skipped: stats.skipped,
12055
12088
  flaky: stats.flaky
12056
12089
  },
12057
- suiteResults
12090
+ suiteResults,
12091
+ suiteResultsOverflowNote
12058
12092
  };
12059
12093
  }
12060
12094
  };
@@ -12324,7 +12358,7 @@ var ShardMerger = class {
12324
12358
  };
12325
12359
  }
12326
12360
  buildCharts(stats, projects) {
12327
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12361
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12328
12362
  return {
12329
12363
  passRate: {
12330
12364
  passed: stats.passed,
@@ -12334,7 +12368,8 @@ var ShardMerger = class {
12334
12368
  skipped: stats.skipped,
12335
12369
  flaky: stats.flaky
12336
12370
  },
12337
- suiteResults
12371
+ suiteResults,
12372
+ suiteResultsOverflowNote
12338
12373
  };
12339
12374
  }
12340
12375
  };
@@ -12368,11 +12403,27 @@ function jsonForInlineScript(value) {
12368
12403
  var A4_HEIGHT_MM = 297;
12369
12404
  var PAGE_MARGIN_Y_MM = 16;
12370
12405
  var PX_PER_MM = 96 / 25.4;
12406
+ var SUITE_CHART_SMALL_MAX = 10;
12407
+ var SUITE_CHART_ROWS_PER_CANVAS = 45;
12408
+ var SUITE_CHART_ROW_PX = 18;
12409
+ var SUITE_CHART_PAD_PX = 56;
12371
12410
  var PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
12372
12411
  function isUnknownValue(v) {
12373
12412
  return !v || v === "unknown";
12374
12413
  }
12375
12414
  var SLOW_MEDIAN_FACTOR = 2;
12415
+ function buildSuiteChartChunks(rows) {
12416
+ const chunks = [];
12417
+ for (let i = 0; i < rows.length; i += SUITE_CHART_ROWS_PER_CANVAS) {
12418
+ const slice = rows.slice(i, i + SUITE_CHART_ROWS_PER_CANVAS);
12419
+ chunks.push({
12420
+ canvasId: `suitesBarChart${chunks.length}`,
12421
+ heightPx: slice.length * SUITE_CHART_ROW_PX + SUITE_CHART_PAD_PX,
12422
+ rows: slice
12423
+ });
12424
+ }
12425
+ return chunks;
12426
+ }
12376
12427
  function deriveOptions(s) {
12377
12428
  return {
12378
12429
  showCharts: s.charts,
@@ -12443,7 +12494,10 @@ var TemplateEngine = class {
12443
12494
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12444
12495
  templateCSS: "",
12445
12496
  chartjsScript: this.buildChartjsScript(),
12446
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
12497
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12498
+ releaseGateTally: buildFailureTally(data.analysis),
12499
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
12500
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
12447
12501
  };
12448
12502
  }
12449
12503
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -12467,7 +12521,10 @@ var TemplateEngine = class {
12467
12521
  baseCSS,
12468
12522
  templateCSS,
12469
12523
  chartjsScript: this.buildChartjsScript(),
12470
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
12524
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12525
+ releaseGateTally: buildFailureTally(data.analysis),
12526
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
12527
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
12471
12528
  };
12472
12529
  }
12473
12530
  buildChartjsScript() {
@@ -14062,7 +14119,7 @@ var PdfReporter = class {
14062
14119
  this.liveConsole = false;
14063
14120
  this.options = parseOptions(rawOptions);
14064
14121
  this.dataCollector = new DataCollector(this.options.capture);
14065
- const version = true ? "0.15.0" : "0.x";
14122
+ const version = true ? "0.16.0" : "0.x";
14066
14123
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14067
14124
  this.licenseClient = new LicenseClient({
14068
14125
  licenseKey: this.options.licenseKey,
package/dist/index.mjs CHANGED
@@ -11292,16 +11292,24 @@ var SHORT_LABEL = {
11292
11292
  "flaky-by-retry": "flaky",
11293
11293
  unknown: "unclassified"
11294
11294
  };
11295
- function buildOneLiner(clusters) {
11296
- if (clusters.length === 0) return "";
11295
+ function tallyEntries(clusters) {
11297
11296
  const byCategory = /* @__PURE__ */ new Map();
11298
11297
  for (const c of clusters) {
11299
11298
  byCategory.set(c.category, (byCategory.get(c.category) ?? 0) + c.count);
11300
11299
  }
11300
+ return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
11301
+ }
11302
+ function buildOneLiner(clusters) {
11303
+ if (clusters.length === 0) return "";
11301
11304
  const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
11302
- const tally = [...byCategory.entries()].sort((a, b) => b[1] - a[1]).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11305
+ const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11303
11306
  return [head, ...tally].join(" \xB7 ");
11304
11307
  }
11308
+ function buildFailureTally(analysis) {
11309
+ if (!analysis) return "";
11310
+ const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
11311
+ return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
11312
+ }
11305
11313
  function dominantCategory(clusters) {
11306
11314
  return clusters[0]?.category ?? "unknown";
11307
11315
  }
@@ -11649,7 +11657,7 @@ init_esm_shims();
11649
11657
 
11650
11658
  // src/collector/stats-utils.ts
11651
11659
  init_esm_shims();
11652
- var TOP_SUITES_FOR_CHART = 10;
11660
+ var SUITE_CHART_MAX_ROWS = 150;
11653
11661
  function resolveTestStatus(test, result) {
11654
11662
  if (!result) return "skipped";
11655
11663
  const expected = test.expectedStatus ?? "passed";
@@ -11693,15 +11701,40 @@ function toRow(s) {
11693
11701
  skipped: s.stats.skipped
11694
11702
  };
11695
11703
  }
11704
+ function capRows(rows, limit) {
11705
+ if (rows.length <= limit) return { rows };
11706
+ const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11707
+ const scored = [...rows].sort(
11708
+ (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11709
+ );
11710
+ const rest = scored.slice(limit);
11711
+ const sums = rest.reduce(
11712
+ (acc, r) => ({
11713
+ passed: acc.passed + r.passed,
11714
+ failed: acc.failed + r.failed,
11715
+ timedOut: acc.timedOut + r.timedOut,
11716
+ skipped: acc.skipped + r.skipped
11717
+ }),
11718
+ { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11719
+ );
11720
+ const parts = [
11721
+ sums.failed > 0 ? `${sums.failed} failed` : "",
11722
+ sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11723
+ sums.passed > 0 ? `${sums.passed} passed` : "",
11724
+ sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11725
+ ].filter(Boolean);
11726
+ const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11727
+ return { rows: scored.slice(0, limit), overflowNote };
11728
+ }
11696
11729
  function buildSuiteResults(projects, limit) {
11697
11730
  const top = projects.flatMap((p) => p.suites);
11698
11731
  if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11699
11732
  const file = top[0];
11700
11733
  const fs14 = statsFromTests(file.tests);
11701
11734
  const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11702
- return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11735
+ return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11703
11736
  }
11704
- return top.map(toRow).slice(0, limit);
11737
+ return capRows(top.map(toRow), limit);
11705
11738
  }
11706
11739
 
11707
11740
  // src/collector/SuiteWalker.ts
@@ -12046,7 +12079,7 @@ var DataCollector = class {
12046
12079
  };
12047
12080
  }
12048
12081
  buildChartData(stats, projects) {
12049
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12082
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12050
12083
  return {
12051
12084
  passRate: {
12052
12085
  passed: stats.passed,
@@ -12055,7 +12088,8 @@ var DataCollector = class {
12055
12088
  skipped: stats.skipped,
12056
12089
  flaky: stats.flaky
12057
12090
  },
12058
- suiteResults
12091
+ suiteResults,
12092
+ suiteResultsOverflowNote
12059
12093
  };
12060
12094
  }
12061
12095
  };
@@ -12325,7 +12359,7 @@ var ShardMerger = class {
12325
12359
  };
12326
12360
  }
12327
12361
  buildCharts(stats, projects) {
12328
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12362
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12329
12363
  return {
12330
12364
  passRate: {
12331
12365
  passed: stats.passed,
@@ -12335,7 +12369,8 @@ var ShardMerger = class {
12335
12369
  skipped: stats.skipped,
12336
12370
  flaky: stats.flaky
12337
12371
  },
12338
- suiteResults
12372
+ suiteResults,
12373
+ suiteResultsOverflowNote
12339
12374
  };
12340
12375
  }
12341
12376
  };
@@ -12369,11 +12404,27 @@ function jsonForInlineScript(value) {
12369
12404
  var A4_HEIGHT_MM = 297;
12370
12405
  var PAGE_MARGIN_Y_MM = 16;
12371
12406
  var PX_PER_MM = 96 / 25.4;
12407
+ var SUITE_CHART_SMALL_MAX = 10;
12408
+ var SUITE_CHART_ROWS_PER_CANVAS = 45;
12409
+ var SUITE_CHART_ROW_PX = 18;
12410
+ var SUITE_CHART_PAD_PX = 56;
12372
12411
  var PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
12373
12412
  function isUnknownValue(v) {
12374
12413
  return !v || v === "unknown";
12375
12414
  }
12376
12415
  var SLOW_MEDIAN_FACTOR = 2;
12416
+ function buildSuiteChartChunks(rows) {
12417
+ const chunks = [];
12418
+ for (let i = 0; i < rows.length; i += SUITE_CHART_ROWS_PER_CANVAS) {
12419
+ const slice = rows.slice(i, i + SUITE_CHART_ROWS_PER_CANVAS);
12420
+ chunks.push({
12421
+ canvasId: `suitesBarChart${chunks.length}`,
12422
+ heightPx: slice.length * SUITE_CHART_ROW_PX + SUITE_CHART_PAD_PX,
12423
+ rows: slice
12424
+ });
12425
+ }
12426
+ return chunks;
12427
+ }
12377
12428
  function deriveOptions(s) {
12378
12429
  return {
12379
12430
  showCharts: s.charts,
@@ -12444,7 +12495,10 @@ var TemplateEngine = class {
12444
12495
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12445
12496
  templateCSS: "",
12446
12497
  chartjsScript: this.buildChartjsScript(),
12447
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
12498
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12499
+ releaseGateTally: buildFailureTally(data.analysis),
12500
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
12501
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
12448
12502
  };
12449
12503
  }
12450
12504
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -12468,7 +12522,10 @@ var TemplateEngine = class {
12468
12522
  baseCSS,
12469
12523
  templateCSS,
12470
12524
  chartjsScript: this.buildChartjsScript(),
12471
- watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX
12525
+ watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12526
+ releaseGateTally: buildFailureTally(data.analysis),
12527
+ suiteChartLarge: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX,
12528
+ suiteChartChunks: data.charts.suiteResults.length > SUITE_CHART_SMALL_MAX ? buildSuiteChartChunks(data.charts.suiteResults) : []
12472
12529
  };
12473
12530
  }
12474
12531
  buildChartjsScript() {
@@ -14063,7 +14120,7 @@ var PdfReporter = class {
14063
14120
  this.liveConsole = false;
14064
14121
  this.options = parseOptions(rawOptions);
14065
14122
  this.dataCollector = new DataCollector(this.options.capture);
14066
- const version = true ? "0.15.0" : "0.x";
14123
+ const version = true ? "0.16.0" : "0.x";
14067
14124
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14068
14125
  this.licenseClient = new LicenseClient({
14069
14126
  licenseKey: this.options.licenseKey,
@@ -1,12 +1,18 @@
1
- <div class="section charts-grid{{#if options.heroTrend}} charts-grid--secondary{{/if}}">
1
+ <div class="section charts-grid{{#if options.heroTrend}} charts-grid--secondary{{/if}}{{#if suiteChartLarge}} charts-grid--solo{{/if}}">
2
2
  <div class="chart-card">
3
3
  <h3>Pass Rate</h3>
4
+ {{#if suiteChartLarge}}
5
+ {{! Solo card (suite chart moved below) — use the freed space: bigger doughnut. }}
6
+ <canvas id="passRateChart" width="230" height="230"></canvas>
7
+ {{else}}
4
8
  {{#if options.heroTrend}}
5
9
  <canvas id="passRateChart" width="150" height="150"></canvas>
6
10
  {{else}}
7
11
  <canvas id="passRateChart" width="186" height="186"></canvas>
8
12
  {{/if}}
13
+ {{/if}}
9
14
  </div>
15
+ {{#unless suiteChartLarge}}
10
16
  <div class="chart-card">
11
17
  <h3>Suite Results</h3>
12
18
  {{#if options.heroTrend}}
@@ -15,4 +21,20 @@
15
21
  <canvas id="suitesBarChart" width="390" height="186"></canvas>
16
22
  {{/if}}
17
23
  </div>
24
+ {{/unless}}
25
+ </div>
26
+
27
+ {{#if suiteChartLarge}}
28
+ {{! Large runs: every suite keeps its own bar. The chart leaves the side-by-side
29
+ grid, goes full width, and splits into page-sized canvases that flow across
30
+ pages — a continuous per-suite coverage map instead of a squashed canvas. }}
31
+ {{#each suiteChartChunks}}
32
+ <div class="section chart-card chart-card--wide">
33
+ <h3>Suite Results{{#unless @first}} &middot; continued{{/unless}}</h3>
34
+ <canvas id="{{this.canvasId}}" width="640" height="{{this.heightPx}}"></canvas>
35
+ {{#if @last}}{{#if ../charts.suiteResultsOverflowNote}}
36
+ <div class="chart-note">{{../charts.suiteResultsOverflowNote}}</div>
37
+ {{/if}}{{/if}}
18
38
  </div>
39
+ {{/each}}
40
+ {{/if}}
@@ -45,6 +45,17 @@
45
45
 
46
46
  <script>
47
47
  (function() {
48
+ // Chart.js measures axis labels with whatever font is available at chart
49
+ // construction. The embedded Inter loads asynchronously (even from a data:
50
+ // URI), so charts built immediately measure ticks with narrower fallback
51
+ // metrics and then PAINT with Inter — wide-glyph labels overflowed their
52
+ // measured gutter by a character. Build every chart only after the document
53
+ // fonts settle; the timeout fallback guards a hung fonts.ready so
54
+ // __chartsReady can never be starved.
55
+ var inited = false;
56
+ function initCharts() {
57
+ if (inited) return;
58
+ inited = true;
48
59
  var remaining = 0;
49
60
  function chartDone() { if (--remaining === 0) window.__chartsReady = true; }
50
61
 
@@ -90,6 +101,9 @@
90
101
  Chart.register(centerTextPlugin);
91
102
 
92
103
  /* ── Pass rate doughnut ──────────────────────────────────────────────── */
104
+ // Rounded, slightly separated segments + a thicker ring read as a designed
105
+ // gauge rather than a default pie; the centre % (drawn by centerTextPlugin)
106
+ // scales off the inner radius automatically.
93
107
  remaining += 1;
94
108
  new Chart(document.getElementById('passRateChart'), {
95
109
  type: 'doughnut',
@@ -99,19 +113,22 @@
99
113
  data: [passed, failed, timedOut, skipped, flaky],
100
114
  backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#CA8A04'],
101
115
  borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
102
- borderWidth: 2,
116
+ borderWidth: 1,
117
+ borderRadius: 5,
118
+ spacing: 2,
103
119
  hoverOffset: 4
104
120
  }]
105
121
  },
106
122
  options: {
107
123
  responsive: false,
108
- cutout: '68%',
124
+ cutout: '64%',
109
125
  plugins: {
110
126
  legend: {
111
127
  position: 'bottom',
112
128
  labels: {
113
129
  font: { size: 9, family: 'Inter, sans-serif' },
114
- color: '#5A5A52', padding: 10, boxWidth: 10, boxHeight: 10
130
+ color: '#5A5A52', padding: 10,
131
+ usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
115
132
  }
116
133
  }
117
134
  },
@@ -119,64 +136,126 @@
119
136
  }
120
137
  });
121
138
 
122
- /* ── Horizontal suite results bar ────────────────────────────────────── */
123
- var suiteLabels = [{{{jsonArray charts.suiteResults 'label'}}}];
124
- var suitePassed = [{{{jsonArray charts.suiteResults 'passed'}}}];
125
- var suiteFailed = [{{{jsonArray charts.suiteResults 'failed'}}}];
126
- var suiteTimedOut = [{{{jsonArray charts.suiteResults 'timedOut'}}}];
127
- var suiteSkipped = [{{{jsonArray charts.suiteResults 'skipped'}}}];
139
+ /* ── Horizontal suite results bar(s) ─────────────────────────────────── */
140
+ // Muted per-suite total at each bar's end — the count is scannable without
141
+ // tracing gridlines. Guarded by canvas id prefix so it fires on every suite
142
+ // chunk canvas and nothing else.
143
+ var suiteTotalsPlugin = {
144
+ id: 'suiteBarTotals',
145
+ afterDatasetsDraw: function(chart) {
146
+ if (chart.canvas.id.indexOf('suitesBarChart') !== 0) return;
147
+ var ctx = chart.ctx;
148
+ for (var i = 0; i < chart.data.labels.length; i++) {
149
+ var total = 0;
150
+ chart.data.datasets.forEach(function(ds) { total += ds.data[i] || 0; });
151
+ if (!total) continue;
152
+ var y = chart.scales.y.getPixelForValue(i);
153
+ var x = chart.scales.x.getPixelForValue(total);
154
+ ctx.save();
155
+ ctx.font = '600 8.5px Inter, sans-serif';
156
+ ctx.fillStyle = '#8C8B82';
157
+ ctx.textAlign = 'left';
158
+ ctx.textBaseline = 'middle';
159
+ ctx.fillText(String(total), x + 5, y);
160
+ ctx.restore();
161
+ }
162
+ }
163
+ };
164
+ Chart.register(suiteTotalsPlugin);
128
165
 
129
- remaining += 1;
130
- new Chart(document.getElementById('suitesBarChart'), {
131
- type: 'bar',
132
- data: {
133
- labels: suiteLabels,
134
- datasets: [
135
- { label: 'Passed', data: suitePassed, backgroundColor: '#3F7D58', borderRadius: 3 },
136
- { label: 'Failed', data: suiteFailed, backgroundColor: '#C3553C', borderRadius: 3 },
137
- { label: 'Timed Out', data: suiteTimedOut, backgroundColor: '#B5803A', borderRadius: 3 },
138
- { label: 'Skipped', data: suiteSkipped, backgroundColor: '#7A786E', borderRadius: 3 }
139
- ]
140
- },
141
- options: {
142
- indexAxis: 'y',
143
- responsive: false,
144
- scales: {
145
- x: {
146
- stacked: true,
147
- ticks: { font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52', stepSize: 1 },
148
- grid: { color: 'rgba(31,31,28,0.06)' },
149
- border: { color: '#D6D4C8' }
166
+ // Shared config so the compact small-run card and the chunked large-run
167
+ // canvases render identically bar-for-bar.
168
+ function suiteChartConfig(labels, passed, failed, timedOut, skipped) {
169
+ return {
170
+ type: 'bar',
171
+ data: {
172
+ labels: labels,
173
+ datasets: [
174
+ { label: 'Passed', data: passed, backgroundColor: '#3F7D58', borderRadius: 4 },
175
+ { label: 'Failed', data: failed, backgroundColor: '#C3553C', borderRadius: 4 },
176
+ { label: 'Timed Out', data: timedOut, backgroundColor: '#B5803A', borderRadius: 4 },
177
+ { label: 'Skipped', data: skipped, backgroundColor: '#7A786E', borderRadius: 4 }
178
+ ]
179
+ },
180
+ options: {
181
+ indexAxis: 'y',
182
+ responsive: false,
183
+ // Left: small gutter margin for the y labels (enlarging scale.width in
184
+ // afterFit is not honoured by the layout pass); exact fit comes from
185
+ // building charts after fonts.ready so tick measurement uses Inter.
186
+ // Right: room for the per-suite total drawn past the bar end.
187
+ layout: { padding: { left: 16, right: 22 } },
188
+ scales: {
189
+ x: {
190
+ stacked: true,
191
+ // maxTicksLimit instead of stepSize:1 — a suite with 40+ tests would
192
+ // otherwise spray 40+ ticks across the axis. precision:0 keeps the
193
+ // auto-chosen steps integral.
194
+ ticks: {
195
+ font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
196
+ maxTicksLimit: 12, precision: 0
197
+ },
198
+ grid: { color: 'rgba(31,31,28,0.06)' },
199
+ border: { color: '#D6D4C8' }
200
+ },
201
+ y: {
202
+ stacked: true,
203
+ ticks: {
204
+ font: { size: 10, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
205
+ // Truncate only very long suite / describe labels; measurement runs
206
+ // post-fonts.ready so the gutter fits exactly what gets painted.
207
+ callback: function(value) {
208
+ var l = this.getLabelForValue(value);
209
+ return l.length > 24 ? l.slice(0, 23) + '…' : l;
210
+ }
211
+ },
212
+ grid: { display: false },
213
+ border: { color: '#D6D4C8' }
214
+ }
150
215
  },
151
- y: {
152
- stacked: true,
153
- // Widen the label gutter so the longest label isn't clipped at the canvas edge.
154
- afterFit: function(scale) { if (!scale.$rfPadded) { scale.$rfPadded = true; scale.width += 30; } },
155
- ticks: {
156
- font: { size: 9, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
157
- // Truncate only very long suite / describe labels; the wider gutter above
158
- // lets most labels show in full instead of clipping at the canvas edge.
159
- callback: function(value) {
160
- var l = this.getLabelForValue(value);
161
- return l.length > 24 ? l.slice(0, 23) + '…' : l;
216
+ plugins: {
217
+ legend: {
218
+ position: 'bottom',
219
+ labels: {
220
+ font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
221
+ padding: 12,
222
+ usePointStyle: true, pointStyle: 'circle', boxWidth: 7, boxHeight: 7
162
223
  }
163
- },
164
- grid: { display: false },
165
- border: { color: '#D6D4C8' }
166
- }
167
- },
168
- plugins: {
169
- legend: {
170
- position: 'bottom',
171
- labels: {
172
- font: { size: 9, family: 'Inter, sans-serif' }, color: '#5A5A52',
173
- padding: 12, boxWidth: 10, boxHeight: 10
174
224
  }
175
- }
176
- },
177
- animation: { onComplete: chartDone }
178
- }
225
+ },
226
+ animation: { onComplete: chartDone }
227
+ }
228
+ };
229
+ }
230
+
231
+ {{#if suiteChartLarge}}
232
+ // Large run: one canvas per page-sized chunk — every suite keeps its own bar.
233
+ var suiteChunks = [
234
+ {{#each suiteChartChunks}}
235
+ {
236
+ id: '{{this.canvasId}}',
237
+ labels: [{{{jsonArray this.rows 'label'}}}],
238
+ passed: [{{{jsonArray this.rows 'passed'}}}],
239
+ failed: [{{{jsonArray this.rows 'failed'}}}],
240
+ timedOut: [{{{jsonArray this.rows 'timedOut'}}}],
241
+ skipped: [{{{jsonArray this.rows 'skipped'}}}]
242
+ }{{#unless @last}},{{/unless}}
243
+ {{/each}}
244
+ ];
245
+ remaining += suiteChunks.length;
246
+ suiteChunks.forEach(function(c) {
247
+ new Chart(document.getElementById(c.id), suiteChartConfig(c.labels, c.passed, c.failed, c.timedOut, c.skipped));
179
248
  });
249
+ {{else}}
250
+ remaining += 1;
251
+ new Chart(document.getElementById('suitesBarChart'), suiteChartConfig(
252
+ [{{{jsonArray charts.suiteResults 'label'}}}],
253
+ [{{{jsonArray charts.suiteResults 'passed'}}}],
254
+ [{{{jsonArray charts.suiteResults 'failed'}}}],
255
+ [{{{jsonArray charts.suiteResults 'timedOut'}}}],
256
+ [{{{jsonArray charts.suiteResults 'skipped'}}}]
257
+ ));
258
+ {{/if}}
180
259
 
181
260
  {{#if options.showTrend}}{{#if charts.trend}}
182
261
  // entries are newest-first in storage; reverse for oldest-left (chronological) display
@@ -339,6 +418,14 @@
339
418
  });
340
419
  }
341
420
  {{/if}}{{/if}}
421
+ }
422
+
423
+ if (document.fonts && document.fonts.ready && typeof document.fonts.ready.then === 'function') {
424
+ document.fonts.ready.then(initCharts, initCharts);
425
+ setTimeout(initCharts, 3000);
426
+ } else {
427
+ initCharts();
428
+ }
342
429
  })();
343
430
  </script>
344
431
  {{else}}
@@ -4,11 +4,12 @@
4
4
  <div class="release-gate__body">
5
5
  <div class="release-gate__label">Release Recommendation</div>
6
6
  <div class="release-gate__verdict release-gate__verdict--fail">
7
- HOLD &mdash; {{stats.failed}} FAILURE{{#if (gt stats.failed 1)}}S{{/if}}
7
+ HOLD: {{stats.failed}} FAILURE{{#if (gt stats.failed 1)}}S{{/if}}
8
8
  </div>
9
9
  <div class="release-gate__detail">
10
- {{stats.failed}} of {{stats.total}} tests failed. Review each failure &mdash; it may be a
11
- product defect or an automation script issue &mdash; and verify manually before shipping.
10
+ {{stats.failed}} of {{stats.total}} tests failed{{#if releaseGateTally}} ({{releaseGateTally}}){{/if}}.
11
+ Each failure may be a product defect or an automation script issue.
12
+ Review and verify manually before shipping.
12
13
  {{#if stats.flaky}} &nbsp;&middot;&nbsp; {{stats.flaky}} flaky (additional risk).{{/if}}
13
14
  </div>
14
15
  </div>
@@ -24,11 +25,12 @@
24
25
  <div class="release-gate__body">
25
26
  <div class="release-gate__label">Release Recommendation</div>
26
27
  <div class="release-gate__verdict release-gate__verdict--fail">
27
- HOLD &mdash; {{stats.timedOut}} TIMED OUT
28
+ HOLD: {{stats.timedOut}} TIMED OUT
28
29
  </div>
29
30
  <div class="release-gate__detail">
30
- {{stats.timedOut}} tests exceeded timeout &mdash; often an environment or automation
31
- issue. Review and verify manually before shipping.
31
+ {{stats.timedOut}} test{{#if (gt stats.timedOut 1)}}s{{/if}} exceeded timeout.
32
+ Timeouts are often environment or automation issues.
33
+ Review and verify manually before shipping.
32
34
  </div>
33
35
  </div>
34
36
  <div class="release-gate__meta">
@@ -688,9 +688,26 @@ code {
688
688
  border: 1px solid var(--border-2);
689
689
  border-radius: 6px; padding: 16px 14px; text-align: center;
690
690
  }
691
+ /* Large runs: the doughnut sits alone in the grid (the suite chart moved to
692
+ full-width chunk cards below) — wider card + bigger canvas so it owns the
693
+ space instead of floating in it. Extra specificity so a template
694
+ stylesheet's --secondary column override can't re-widen it. */
695
+ .charts-grid.charts-grid--solo { grid-template-columns: 320px; justify-content: center; }
696
+ /* Full-width Suite Results cards, one per ~45 suites — each page-break-atomic
697
+ so the chart continues cleanly across pages. */
698
+ .chart-card--wide {
699
+ width: 100%; margin-bottom: 16px;
700
+ page-break-inside: avoid; break-inside: avoid;
701
+ }
691
702
  /* canvas computes to display:block in Chromium, so text-align:center above
692
703
  never centers it (that only affects inline-level boxes) — margin:auto does. */
693
704
  .chart-card canvas { display: block; margin: 0 auto; }
705
+ /* Overflow note under the Suite Results chart ("+ N more suites: …") — rendered
706
+ when the run has more suites than the chart's bar cap. */
707
+ .chart-note {
708
+ margin-top: 6px; text-align: center;
709
+ font-size: 9px; color: var(--text-3); letter-spacing: 0.2px;
710
+ }
694
711
  .chart-card h3 {
695
712
  margin: 0 0 12px;
696
713
  font-family: 'Inter', system-ui, sans-serif; font-size: 9px;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "MIT",
6
6
  "author": "ReportForge",