@reportforge/playwright-pdf 0.15.1 → 0.16.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/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.1] - 2026-07-09
7
+
8
+ ### Changed
9
+
10
+ - **Relicensed from MIT to the Elastic License 2.0** (source-available). The source stays readable, copyable, and modifiable; the license does not allow offering the software as a hosted service, bypassing or removing the license-key check, or stripping license/copyright notices. Surfaces updated together: `LICENSE`, `package.json` license metadata, README badge + License section, docs badge, marketing footer, pricing FAQ, and legal terms (§2 License grant, §7 IP, `lastUpdated`). Versions 0.16.0 and earlier were published under MIT and remain MIT.
11
+
12
+ ---
13
+
14
+ ## [0.16.0] - 2026-07-07
15
+
16
+ ### Added
17
+
18
+ - **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.
19
+ - **Per-suite totals on the bar chart**: each bar ends with its muted test count, so counts read directly instead of via gridlines.
20
+
21
+ ### Changed
22
+
23
+ - **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.
24
+
25
+ ### Fixed
26
+
27
+ - **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.
28
+ - **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).
29
+ - `timedOut` counts are now carried through the suite-chart data type and the demo fixture's suite entries.
30
+
31
+ ---
32
+
6
33
  ## [0.15.1] - 2026-07-07
7
34
 
8
35
  ### Changed
package/LICENSE CHANGED
@@ -1,21 +1,95 @@
1
- MIT License
2
-
3
1
  Copyright (c) 2026 ReportForge
4
2
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
3
+ Elastic License 2.0
4
+
5
+ URL: https://www.elastic.co/licensing/elastic-license
6
+
7
+ ## Acceptance
8
+
9
+ By using the software, you agree to all of the terms and conditions below.
10
+
11
+ ## Copyright License
12
+
13
+ The licensor grants you a non-exclusive, royalty-free, worldwide,
14
+ non-sublicensable, non-transferable license to use, copy, distribute, make
15
+ available, and prepare derivative works of the software, in each case subject to
16
+ the limitations and conditions below.
17
+
18
+ ## Limitations
19
+
20
+ You may not provide the software to third parties as a hosted or managed
21
+ service, where the service provides users with access to any substantial set of
22
+ the features or functionality of the software.
23
+
24
+ You may not move, change, disable, or circumvent the license key functionality
25
+ in the software, and you may not remove or obscure any functionality in the
26
+ software that is protected by the license key.
27
+
28
+ You may not alter, remove, or obscure any licensing, copyright, or other notices
29
+ of the licensor in the software. Any use of the licensor’s trademarks is subject
30
+ to applicable law.
31
+
32
+ ## Patents
33
+
34
+ The licensor grants you a license, under any patent claims the licensor can
35
+ license, or becomes able to license, to make, have made, use, sell, offer for
36
+ sale, import and have imported the software, in each case subject to the
37
+ limitations and conditions in this license. This license does not cover any
38
+ patent claims that you cause to be infringed by modifications or additions to
39
+ the software. If you or your company make any written claim that the software
40
+ infringes or contributes to infringement of any patent, your patent license for
41
+ the software granted under these terms ends immediately. If your company makes
42
+ such a claim, your patent license ends immediately for work on behalf of your
43
+ company.
44
+
45
+ ## Notices
46
+
47
+ You must ensure that anyone who gets a copy of any part of the software from you
48
+ also gets a copy of these terms.
49
+
50
+ If you modify the software, you must include in any modified copies of the
51
+ software prominent notices stating that you have modified the software.
52
+
53
+ ## No Other Rights
54
+
55
+ These terms do not imply any licenses other than those expressly granted in
56
+ these terms.
57
+
58
+ ## Termination
59
+
60
+ If you use the software in violation of these terms, such use is not licensed,
61
+ and your licenses will automatically terminate. If the licensor provides you
62
+ with a notice of your violation, and you cease all violation of this license no
63
+ later than 30 days after you receive that notice, your licenses will be
64
+ reinstated retroactively. However, if you violate these terms after such
65
+ reinstatement, any additional violation of these terms will cause your licenses
66
+ to terminate automatically and permanently.
67
+
68
+ ## No Liability
69
+
70
+ *As far as the law allows, the software comes as is, without any warranty or
71
+ condition, and the licensor will not be liable to you for any damages arising
72
+ out of these terms or the use or nature of the software, under any kind of
73
+ legal claim.*
74
+
75
+ ## Definitions
76
+
77
+ The **licensor** is the entity offering these terms, and the **software** is the
78
+ software the licensor makes available under these terms, including any portion
79
+ of it.
80
+
81
+ **you** refers to the individual or entity agreeing to these terms.
82
+
83
+ **your company** is any legal entity, sole proprietorship, or other kind of
84
+ organization that you work for, plus all organizations that have control over,
85
+ are under the control of, or are under common control with that
86
+ organization. **control** means ownership of substantially all the assets of an
87
+ entity, or the power to direct its management and policies by vote, contract, or
88
+ otherwise. Control can be direct or indirect.
89
+
90
+ **your licenses** are all the licenses granted to you for the software under
91
+ these terms.
92
+
93
+ **use** means anything you do with the software requiring one of your licenses.
94
+
95
+ **trademark** means trademarks, service marks, and similar rights.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Drop it into any Playwright project and get a PDF report on every CI run. No cha
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@reportforge/playwright-pdf.svg)](https://www.npmjs.com/package/@reportforge/playwright-pdf)
8
8
  [![Node](https://img.shields.io/node/v/@reportforge/playwright-pdf.svg)](https://nodejs.org/)
9
- [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
9
+ [![License](https://img.shields.io/badge/license-Elastic--2.0-blue.svg)](./LICENSE)
10
10
 
11
11
  <p align="center">
12
12
  <a href="https://reportforge.org/pricing">
@@ -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
@@ -678,4 +678,6 @@ Questions and bug reports: email [support@reportforge.org](mailto:support@report
678
678
 
679
679
  ## License
680
680
 
681
- MIT. See [LICENSE](./LICENSE).
681
+ [Elastic License 2.0](./LICENSE), source-available. Read, copy, and modify the source freely. Three things the license does not allow: offering the software as a hosted service, bypassing or removing the license-key check, and stripping license or copyright notices. Generating PDF reports requires an active subscription.
682
+
683
+ Versions 0.16.0 and earlier shipped under MIT and stay MIT.
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 [
@@ -6587,6 +6588,18 @@ function jsonForInlineScript(value) {
6587
6588
  function isUnknownValue(v) {
6588
6589
  return !v || v === "unknown";
6589
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
+ }
6590
6603
  function deriveOptions(s) {
6591
6604
  return {
6592
6605
  showCharts: s.charts,
@@ -6598,7 +6611,7 @@ function deriveOptions(s) {
6598
6611
  showFullFailures: s.fullFailures
6599
6612
  };
6600
6613
  }
6601
- 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;
6602
6615
  var init_engine = __esm({
6603
6616
  "src/templates/engine.ts"() {
6604
6617
  "use strict";
@@ -6613,6 +6626,10 @@ var init_engine = __esm({
6613
6626
  A4_HEIGHT_MM = 297;
6614
6627
  PAGE_MARGIN_Y_MM = 16;
6615
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;
6616
6633
  PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
6617
6634
  SLOW_MEDIAN_FACTOR = 2;
6618
6635
  TemplateEngine = class {
@@ -6675,7 +6692,9 @@ var init_engine = __esm({
6675
6692
  templateCSS: "",
6676
6693
  chartjsScript: this.buildChartjsScript(),
6677
6694
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
6678
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
6679
6698
  };
6680
6699
  }
6681
6700
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -6700,7 +6719,9 @@ var init_engine = __esm({
6700
6719
  templateCSS,
6701
6720
  chartjsScript: this.buildChartjsScript(),
6702
6721
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
6703
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
6704
6725
  };
6705
6726
  }
6706
6727
  buildChartjsScript() {
package/dist/index.js CHANGED
@@ -11656,7 +11656,7 @@ init_cjs_shims();
11656
11656
 
11657
11657
  // src/collector/stats-utils.ts
11658
11658
  init_cjs_shims();
11659
- var TOP_SUITES_FOR_CHART = 10;
11659
+ var SUITE_CHART_MAX_ROWS = 150;
11660
11660
  function resolveTestStatus(test, result) {
11661
11661
  if (!result) return "skipped";
11662
11662
  const expected = test.expectedStatus ?? "passed";
@@ -11700,15 +11700,40 @@ function toRow(s) {
11700
11700
  skipped: s.stats.skipped
11701
11701
  };
11702
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
+ }
11703
11728
  function buildSuiteResults(projects, limit) {
11704
11729
  const top = projects.flatMap((p) => p.suites);
11705
11730
  if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11706
11731
  const file = top[0];
11707
11732
  const fs14 = statsFromTests(file.tests);
11708
11733
  const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11709
- return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11734
+ return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11710
11735
  }
11711
- return top.map(toRow).slice(0, limit);
11736
+ return capRows(top.map(toRow), limit);
11712
11737
  }
11713
11738
 
11714
11739
  // src/collector/SuiteWalker.ts
@@ -12053,7 +12078,7 @@ var DataCollector = class {
12053
12078
  };
12054
12079
  }
12055
12080
  buildChartData(stats, projects) {
12056
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12081
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12057
12082
  return {
12058
12083
  passRate: {
12059
12084
  passed: stats.passed,
@@ -12062,7 +12087,8 @@ var DataCollector = class {
12062
12087
  skipped: stats.skipped,
12063
12088
  flaky: stats.flaky
12064
12089
  },
12065
- suiteResults
12090
+ suiteResults,
12091
+ suiteResultsOverflowNote
12066
12092
  };
12067
12093
  }
12068
12094
  };
@@ -12332,7 +12358,7 @@ var ShardMerger = class {
12332
12358
  };
12333
12359
  }
12334
12360
  buildCharts(stats, projects) {
12335
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12361
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12336
12362
  return {
12337
12363
  passRate: {
12338
12364
  passed: stats.passed,
@@ -12342,7 +12368,8 @@ var ShardMerger = class {
12342
12368
  skipped: stats.skipped,
12343
12369
  flaky: stats.flaky
12344
12370
  },
12345
- suiteResults
12371
+ suiteResults,
12372
+ suiteResultsOverflowNote
12346
12373
  };
12347
12374
  }
12348
12375
  };
@@ -12376,11 +12403,27 @@ function jsonForInlineScript(value) {
12376
12403
  var A4_HEIGHT_MM = 297;
12377
12404
  var PAGE_MARGIN_Y_MM = 16;
12378
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;
12379
12410
  var PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
12380
12411
  function isUnknownValue(v) {
12381
12412
  return !v || v === "unknown";
12382
12413
  }
12383
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
+ }
12384
12427
  function deriveOptions(s) {
12385
12428
  return {
12386
12429
  showCharts: s.charts,
@@ -12452,7 +12495,9 @@ var TemplateEngine = class {
12452
12495
  templateCSS: "",
12453
12496
  chartjsScript: this.buildChartjsScript(),
12454
12497
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12455
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
12456
12501
  };
12457
12502
  }
12458
12503
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -12477,7 +12522,9 @@ var TemplateEngine = class {
12477
12522
  templateCSS,
12478
12523
  chartjsScript: this.buildChartjsScript(),
12479
12524
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12480
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
12481
12528
  };
12482
12529
  }
12483
12530
  buildChartjsScript() {
@@ -14072,7 +14119,7 @@ var PdfReporter = class {
14072
14119
  this.liveConsole = false;
14073
14120
  this.options = parseOptions(rawOptions);
14074
14121
  this.dataCollector = new DataCollector(this.options.capture);
14075
- const version = true ? "0.15.1" : "0.x";
14122
+ const version = true ? "0.16.1" : "0.x";
14076
14123
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14077
14124
  this.licenseClient = new LicenseClient({
14078
14125
  licenseKey: this.options.licenseKey,
package/dist/index.mjs CHANGED
@@ -11657,7 +11657,7 @@ init_esm_shims();
11657
11657
 
11658
11658
  // src/collector/stats-utils.ts
11659
11659
  init_esm_shims();
11660
- var TOP_SUITES_FOR_CHART = 10;
11660
+ var SUITE_CHART_MAX_ROWS = 150;
11661
11661
  function resolveTestStatus(test, result) {
11662
11662
  if (!result) return "skipped";
11663
11663
  const expected = test.expectedStatus ?? "passed";
@@ -11701,15 +11701,40 @@ function toRow(s) {
11701
11701
  skipped: s.stats.skipped
11702
11702
  };
11703
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
+ }
11704
11729
  function buildSuiteResults(projects, limit) {
11705
11730
  const top = projects.flatMap((p) => p.suites);
11706
11731
  if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11707
11732
  const file = top[0];
11708
11733
  const fs14 = statsFromTests(file.tests);
11709
11734
  const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11710
- return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11735
+ return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11711
11736
  }
11712
- return top.map(toRow).slice(0, limit);
11737
+ return capRows(top.map(toRow), limit);
11713
11738
  }
11714
11739
 
11715
11740
  // src/collector/SuiteWalker.ts
@@ -12054,7 +12079,7 @@ var DataCollector = class {
12054
12079
  };
12055
12080
  }
12056
12081
  buildChartData(stats, projects) {
12057
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12082
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12058
12083
  return {
12059
12084
  passRate: {
12060
12085
  passed: stats.passed,
@@ -12063,7 +12088,8 @@ var DataCollector = class {
12063
12088
  skipped: stats.skipped,
12064
12089
  flaky: stats.flaky
12065
12090
  },
12066
- suiteResults
12091
+ suiteResults,
12092
+ suiteResultsOverflowNote
12067
12093
  };
12068
12094
  }
12069
12095
  };
@@ -12333,7 +12359,7 @@ var ShardMerger = class {
12333
12359
  };
12334
12360
  }
12335
12361
  buildCharts(stats, projects) {
12336
- const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
12362
+ const { rows: suiteResults, overflowNote: suiteResultsOverflowNote } = buildSuiteResults(projects, SUITE_CHART_MAX_ROWS);
12337
12363
  return {
12338
12364
  passRate: {
12339
12365
  passed: stats.passed,
@@ -12343,7 +12369,8 @@ var ShardMerger = class {
12343
12369
  skipped: stats.skipped,
12344
12370
  flaky: stats.flaky
12345
12371
  },
12346
- suiteResults
12372
+ suiteResults,
12373
+ suiteResultsOverflowNote
12347
12374
  };
12348
12375
  }
12349
12376
  };
@@ -12377,11 +12404,27 @@ function jsonForInlineScript(value) {
12377
12404
  var A4_HEIGHT_MM = 297;
12378
12405
  var PAGE_MARGIN_Y_MM = 16;
12379
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;
12380
12411
  var PAGE_CONTENT_HEIGHT_PX = (A4_HEIGHT_MM - 2 * PAGE_MARGIN_Y_MM) * PX_PER_MM;
12381
12412
  function isUnknownValue(v) {
12382
12413
  return !v || v === "unknown";
12383
12414
  }
12384
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
+ }
12385
12428
  function deriveOptions(s) {
12386
12429
  return {
12387
12430
  showCharts: s.charts,
@@ -12453,7 +12496,9 @@ var TemplateEngine = class {
12453
12496
  templateCSS: "",
12454
12497
  chartjsScript: this.buildChartjsScript(),
12455
12498
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12456
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
12457
12502
  };
12458
12503
  }
12459
12504
  buildContext(data, template, userSections, slowTestThreshold) {
@@ -12478,7 +12523,9 @@ var TemplateEngine = class {
12478
12523
  templateCSS,
12479
12524
  chartjsScript: this.buildChartjsScript(),
12480
12525
  watermarkPageHeightPx: PAGE_CONTENT_HEIGHT_PX,
12481
- releaseGateTally: buildFailureTally(data.analysis)
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) : []
12482
12529
  };
12483
12530
  }
12484
12531
  buildChartjsScript() {
@@ -14073,7 +14120,7 @@ var PdfReporter = class {
14073
14120
  this.liveConsole = false;
14074
14121
  this.options = parseOptions(rawOptions);
14075
14122
  this.dataCollector = new DataCollector(this.options.capture);
14076
- const version = true ? "0.15.1" : "0.x";
14123
+ const version = true ? "0.16.1" : "0.x";
14077
14124
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14078
14125
  this.licenseClient = new LicenseClient({
14079
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}}
@@ -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,8 +1,8 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
- "license": "MIT",
5
+ "license": "Elastic-2.0",
6
6
  "author": "ReportForge",
7
7
  "homepage": "https://reportforge.org",
8
8
  "repository": {