@reportforge/playwright-pdf 0.4.1 → 0.5.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,41 @@
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.5.1] — 2026-06-19
7
+
8
+ ### Security
9
+
10
+ - **Chart labels escaped for inline-script safety** — a suite / describe / file title containing `</script>` can no longer break out of the inline Chart.js data block in the rendered report (relevant when the reporter runs over untrusted test code, e.g. external-contributor CI).
11
+
12
+ ### Changed
13
+
14
+ - **Sharper failure-analysis classification** — high-precision deterministic rules now label the error shapes the offline model struggles with: timed-out web-first assertions (`toHaveClass`/`toHaveText`/… with the element resolved) as **assertions**, connection/DNS/SSL errors as **network**, aborted/redirected/interrupted navigations as **navigation**, and resolved-to-0 / strict-mode locators as **locator-not-found**. Rules match the error header only, so call-log context and asserted values no longer cause mislabels (a navigation-wait timeout stays a timeout, a refused `page.goto` is network).
15
+
16
+ ---
17
+
18
+ ## [0.5.0] — 2026-06-19
19
+
20
+ ### Added
21
+
22
+ - **Timed-out tests are first-class** — surfaced as their own KPI card, pass-rate doughnut slice, and Suite Results bar segment instead of being folded into "Failed".
23
+ - **Release gate on every template** — the ship/hold verdict banner now appears in `minimal` and `detailed`, not just `executive`.
24
+ - **Severity-ranked failures** — failure cards are colour-coded and ordered critical-first (also drives defect-log numbering and sidecar-overflow priority).
25
+ - **Describe-block Suite Results** — a single-file run breaks down by describe block instead of rendering one bar.
26
+ - **Threshold-coloured requirements coverage bars** — red `<50%`, amber `<80%`, green `≥80%`.
27
+
28
+ ### Changed
29
+
30
+ - **Compact page flow** — large sections flow and break between rows instead of each starting on a fresh page; a typical `detailed` run drops ~2 pages.
31
+ - The pass-rate verdict renders "TIMED OUT" instead of "TIMEDOUT".
32
+
33
+ ### Fixed
34
+
35
+ - **Pass rate could exceed 100%** on runs with flaky tests — a passed-on-retry test was double-counted in both `passed` and `flaky`. It is now counted as flaky only, so the rate is correct and the KPI cards reconcile to the total.
36
+ - The pass-rate doughnut centre now matches the KPI strip (single source of truth) rather than computing a second, divergent figure.
37
+ - Timed-out status badges render with the correct styling, and timed-out tests are recovered in sharded (`shardResults`) runs instead of being reported as failures.
38
+
39
+ ---
40
+
6
41
  ## [0.4.0] — 2026-06-12
7
42
 
8
43
  ### Added
package/README.md CHANGED
@@ -334,6 +334,10 @@ Everything in `minimal`, **plus** Chart.js pass-rate + suite-results charts, a n
334
334
 
335
335
  Full-page cover with verdict + KPI strip, followed by a compact dashboard with charts and a failures summary (titles only — **no raw stack traces**). Best for sprint reports and management dashboards.
336
336
 
337
+ ### Shared across all three
338
+
339
+ Every report leads with a **release-gate** ship/hold banner derived from the run verdict, surfaces **timed-out** tests as their own KPI card and pass-rate chart slice, and lists failures **most-severe first** (with severity-coloured card borders in `minimal` and `detailed`). Pages **pack compactly** — large sections flow and break between rows instead of each starting on a fresh page. The Suite Results chart breaks a single-file run down by **describe block** so it never collapses to one bar. Requirements coverage bars are coloured by threshold (red &lt;50%, amber &lt;80%, green ≥80%).
340
+
337
341
  ### Generating multiple templates in one run
338
342
 
339
343
  Pass an array to `template` to generate one PDF per template from a single test run. The template name is appended to the output filename automatically:
package/dist/demo/cli.js CHANGED
@@ -6251,7 +6251,8 @@ function statsFromTests(tests) {
6251
6251
  return {
6252
6252
  total: tests.length,
6253
6253
  passed: tests.filter((t) => t.status === "passed").length,
6254
- failed: tests.filter((t) => t.status === "failed" || t.status === "timedOut").length,
6254
+ failed: tests.filter((t) => t.status === "failed").length,
6255
+ timedOut: tests.filter((t) => t.status === "timedOut").length,
6255
6256
  skipped: tests.filter((t) => t.status === "skipped").length,
6256
6257
  flaky: tests.filter((t) => t.status === "flaky").length
6257
6258
  };
@@ -6381,7 +6382,7 @@ function buildDemoFailures() {
6381
6382
  }
6382
6383
  function buildDemoCharts(passed, failed, skipped, flaky, total) {
6383
6384
  return {
6384
- passRate: { passed, failed, skipped, flaky },
6385
+ passRate: { passed, failed, timedOut: 0, skipped, flaky },
6385
6386
  suiteResults: [
6386
6387
  suiteEntry("auth", DEMO_AUTH_TESTS),
6387
6388
  suiteEntry("checkout", DEMO_CHECKOUT_TESTS),
@@ -6531,6 +6532,9 @@ function dominantCategory(clusters) {
6531
6532
  }
6532
6533
 
6533
6534
  // src/templates/engine.ts
6535
+ function jsonForInlineScript(value) {
6536
+ return JSON.stringify(value).replace(/<\//g, "<\\/");
6537
+ }
6534
6538
  var TemplateEngine = class {
6535
6539
  constructor() {
6536
6540
  this.chartjsInline = "";
@@ -6714,6 +6718,19 @@ var TemplateEngine = class {
6714
6718
  "upperCase",
6715
6719
  (s) => typeof s === "string" ? s.toUpperCase() : s
6716
6720
  );
6721
+ this.handlebars.registerHelper("verdictLabel", (v) => {
6722
+ const map = {
6723
+ passed: "PASSED",
6724
+ failed: "FAILED",
6725
+ timedout: "TIMED OUT",
6726
+ interrupted: "INTERRUPTED"
6727
+ };
6728
+ return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
6729
+ });
6730
+ this.handlebars.registerHelper("coverageBand", (pct) => {
6731
+ const n = Number(pct);
6732
+ return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
6733
+ });
6717
6734
  this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
6718
6735
  this.handlebars.registerHelper(
6719
6736
  "join",
@@ -6733,7 +6750,7 @@ var TemplateEngine = class {
6733
6750
  if (!Array.isArray(arr)) return "";
6734
6751
  const values = arr.map((item) => {
6735
6752
  const v = item[key];
6736
- return typeof v === "string" ? JSON.stringify(v) : String(v ?? 0);
6753
+ return typeof v === "string" ? jsonForInlineScript(v) : String(v ?? 0);
6737
6754
  });
6738
6755
  return new import_handlebars.default.SafeString(values.join(","));
6739
6756
  }
@@ -6744,7 +6761,7 @@ var TemplateEngine = class {
6744
6761
  this.handlebars.registerHelper("absFixed", (n) => Math.abs(n).toFixed(0));
6745
6762
  this.handlebars.registerHelper(
6746
6763
  "safeJsonData",
6747
- (value) => new import_handlebars.default.SafeString(JSON.stringify(value).replace(/<\//g, "<\\/"))
6764
+ (value) => new import_handlebars.default.SafeString(jsonForInlineScript(value))
6748
6765
  );
6749
6766
  this.handlebars.registerHelper(
6750
6767
  "reverse",
@@ -11670,11 +11687,33 @@ var STRENGTH = {
11670
11687
  strongMinMargin: 0.25
11671
11688
  };
11672
11689
  var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11673
- var FLAKY_MARGIN = 999;
11690
+ var OVERRIDE_MARGIN = 999;
11674
11691
 
11675
11692
  // src/analysis/FailureClusterer.ts
11676
11693
  var MAX_TESTS_PER_CLUSTER = 20;
11677
11694
  var MAX_MESSAGE_CHARS = 200;
11695
+ function errorHeader(message) {
11696
+ const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11697
+ return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11698
+ }
11699
+ function isAssertionTimeout(message) {
11700
+ return /Expect "[^"]+" with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11701
+ }
11702
+ function isNetwork(message) {
11703
+ return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
11704
+ }
11705
+ function isNavigation(message) {
11706
+ return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
11707
+ }
11708
+ function isLocatorNotFound(message) {
11709
+ return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
11710
+ }
11711
+ var MESSAGE_RULES = [
11712
+ [isAssertionTimeout, "assertion"],
11713
+ [isNavigation, "navigation"],
11714
+ [isNetwork, "network"],
11715
+ [isLocatorNotFound, "locator-not-found"]
11716
+ ];
11678
11717
  function strengthOf(margin) {
11679
11718
  if (margin >= STRENGTH.strongMinMargin) return "strong";
11680
11719
  if (margin >= STRENGTH.moderateMinMargin) return "moderate";
@@ -11697,11 +11736,15 @@ function representativeMessage(message) {
11697
11736
  return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
11698
11737
  }
11699
11738
  function classifyOne(f, model) {
11739
+ const frame = topFrame(f.error.stack);
11700
11740
  if (f.retryHistory.some((r) => r.status === "passed")) {
11701
- return { category: "flaky-by-retry", margin: FLAKY_MARGIN, failure: f, frame: topFrame(f.error.stack) };
11741
+ return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, failure: f, frame };
11742
+ }
11743
+ for (const [match, category] of MESSAGE_RULES) {
11744
+ if (match(f.error.message)) return { category, margin: OVERRIDE_MARGIN, failure: f, frame };
11702
11745
  }
11703
11746
  const { label, margin } = classify(model, classifyInput(f));
11704
- return { category: label, margin, failure: f, frame: topFrame(f.error.stack) };
11747
+ return { category: label, margin, failure: f, frame };
11705
11748
  }
11706
11749
  function classifyAll(failures, model) {
11707
11750
  return failures.map((f) => classifyOne(f, model));
package/dist/index.js CHANGED
@@ -10993,11 +10993,33 @@ var STRENGTH = {
10993
10993
  strongMinMargin: 0.25
10994
10994
  };
10995
10995
  var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
10996
- var FLAKY_MARGIN = 999;
10996
+ var OVERRIDE_MARGIN = 999;
10997
10997
 
10998
10998
  // src/analysis/FailureClusterer.ts
10999
10999
  var MAX_TESTS_PER_CLUSTER = 20;
11000
11000
  var MAX_MESSAGE_CHARS = 200;
11001
+ function errorHeader(message) {
11002
+ const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11003
+ return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11004
+ }
11005
+ function isAssertionTimeout(message) {
11006
+ return /Expect "[^"]+" with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11007
+ }
11008
+ function isNetwork(message) {
11009
+ return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
11010
+ }
11011
+ function isNavigation(message) {
11012
+ return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
11013
+ }
11014
+ function isLocatorNotFound(message) {
11015
+ return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
11016
+ }
11017
+ var MESSAGE_RULES = [
11018
+ [isAssertionTimeout, "assertion"],
11019
+ [isNavigation, "navigation"],
11020
+ [isNetwork, "network"],
11021
+ [isLocatorNotFound, "locator-not-found"]
11022
+ ];
11001
11023
  function strengthOf(margin) {
11002
11024
  if (margin >= STRENGTH.strongMinMargin) return "strong";
11003
11025
  if (margin >= STRENGTH.moderateMinMargin) return "moderate";
@@ -11020,11 +11042,15 @@ function representativeMessage(message) {
11020
11042
  return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
11021
11043
  }
11022
11044
  function classifyOne(f, model) {
11045
+ const frame = topFrame(f.error.stack);
11023
11046
  if (f.retryHistory.some((r) => r.status === "passed")) {
11024
- return { category: "flaky-by-retry", margin: FLAKY_MARGIN, failure: f, frame: topFrame(f.error.stack) };
11047
+ return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, failure: f, frame };
11048
+ }
11049
+ for (const [match, category] of MESSAGE_RULES) {
11050
+ if (match(f.error.message)) return { category, margin: OVERRIDE_MARGIN, failure: f, frame };
11025
11051
  }
11026
11052
  const { label, margin } = classify(model, classifyInput(f));
11027
- return { category: label, margin, failure: f, frame: topFrame(f.error.stack) };
11053
+ return { category: label, margin, failure: f, frame };
11028
11054
  }
11029
11055
  function classifyAll(failures, model) {
11030
11056
  return failures.map((f) => classifyOne(f, model));
@@ -11233,11 +11259,13 @@ init_cjs_shims();
11233
11259
 
11234
11260
  // src/collector/stats-utils.ts
11235
11261
  init_cjs_shims();
11262
+ var TOP_SUITES_FOR_CHART = 10;
11236
11263
  function statsFromTests(tests) {
11237
11264
  return {
11238
11265
  total: tests.length,
11239
11266
  passed: tests.filter((t) => t.status === "passed").length,
11240
- failed: tests.filter((t) => t.status === "failed" || t.status === "timedOut").length,
11267
+ failed: tests.filter((t) => t.status === "failed").length,
11268
+ timedOut: tests.filter((t) => t.status === "timedOut").length,
11241
11269
  skipped: tests.filter((t) => t.status === "skipped").length,
11242
11270
  flaky: tests.filter((t) => t.status === "flaky").length
11243
11271
  };
@@ -11248,12 +11276,32 @@ function aggregateStats(parts) {
11248
11276
  total: acc.total + s.total,
11249
11277
  passed: acc.passed + s.passed,
11250
11278
  failed: acc.failed + s.failed,
11279
+ timedOut: acc.timedOut + s.timedOut,
11251
11280
  skipped: acc.skipped + s.skipped,
11252
11281
  flaky: acc.flaky + s.flaky
11253
11282
  }),
11254
- { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 }
11283
+ { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11255
11284
  );
11256
11285
  }
11286
+ function toRow(s) {
11287
+ return {
11288
+ label: s.title,
11289
+ passed: s.stats.passed,
11290
+ failed: s.stats.failed,
11291
+ timedOut: s.stats.timedOut,
11292
+ skipped: s.stats.skipped
11293
+ };
11294
+ }
11295
+ function buildSuiteResults(projects, limit) {
11296
+ const top = projects.flatMap((p) => p.suites);
11297
+ if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11298
+ const file = top[0];
11299
+ const fs14 = statsFromTests(file.tests);
11300
+ const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11301
+ return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11302
+ }
11303
+ return top.map(toRow).slice(0, limit);
11304
+ }
11257
11305
 
11258
11306
  // src/collector/SuiteWalker.ts
11259
11307
  var SuiteWalker = class {
@@ -11313,6 +11361,18 @@ var SuiteWalker = class {
11313
11361
  }
11314
11362
  };
11315
11363
 
11364
+ // src/collector/severity.ts
11365
+ init_cjs_shims();
11366
+ var SEVERITY_RANK = {
11367
+ critical: 0,
11368
+ high: 1,
11369
+ medium: 2,
11370
+ low: 3
11371
+ };
11372
+ function sortBySeverity(failures) {
11373
+ return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11374
+ }
11375
+
11316
11376
  // src/utils/env.ts
11317
11377
  init_cjs_shims();
11318
11378
  var import_child_process = require("child_process");
@@ -11402,7 +11462,6 @@ function gitCommit() {
11402
11462
  }
11403
11463
 
11404
11464
  // src/collector/DataCollector.ts
11405
- var TOP_SUITES_FOR_CHART = 10;
11406
11465
  var DataCollector = class {
11407
11466
  constructor() {
11408
11467
  this.resultMap = /* @__PURE__ */ new Map();
@@ -11439,13 +11498,13 @@ var DataCollector = class {
11439
11498
  finalize(fullResult) {
11440
11499
  const walker = new SuiteWalker();
11441
11500
  const projects = walker.build(this.rootSuite, this.resultMap);
11442
- const stats = this.computeStats(projects, fullResult);
11501
+ const stats = this.computeStats(fullResult);
11443
11502
  const environment = this.buildEnvironment();
11444
11503
  const charts = this.buildChartData(stats, projects);
11445
11504
  return {
11446
11505
  projects,
11447
11506
  stats,
11448
- failures: [...this.failureRecords],
11507
+ failures: sortBySeverity(this.failureRecords),
11449
11508
  environment,
11450
11509
  charts
11451
11510
  };
@@ -11489,13 +11548,16 @@ var DataCollector = class {
11489
11548
  if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11490
11549
  return "medium";
11491
11550
  }
11492
- computeStats(projects, fullResult) {
11551
+ computeStats(fullResult) {
11493
11552
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11494
11553
  for (const [, result] of this.resultMap) {
11495
11554
  total++;
11496
11555
  switch (result.status) {
11556
+ // A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
11557
+ // Counting it in `passed` too would double-count it against `flaky` and
11558
+ // push passRate over 100%.
11497
11559
  case "passed":
11498
- passed++;
11560
+ result.retry > 0 ? flaky++ : passed++;
11499
11561
  break;
11500
11562
  case "failed":
11501
11563
  failed++;
@@ -11512,13 +11574,6 @@ var DataCollector = class {
11512
11574
  break;
11513
11575
  }
11514
11576
  }
11515
- for (const project of projects) {
11516
- for (const suite of this.flattenSuites(project.suites)) {
11517
- for (const test of suite.tests) {
11518
- if (test.status === "flaky") flaky++;
11519
- }
11520
- }
11521
- }
11522
11577
  const duration = fullResult.duration ?? Date.now() - this.startTime;
11523
11578
  const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
11524
11579
  return {
@@ -11564,27 +11619,18 @@ var DataCollector = class {
11564
11619
  };
11565
11620
  }
11566
11621
  buildChartData(stats, projects) {
11567
- const suiteResults = projects.flatMap(
11568
- (p) => p.suites.map((s) => ({
11569
- label: s.title,
11570
- passed: s.stats.passed,
11571
- failed: s.stats.failed,
11572
- skipped: s.stats.skipped
11573
- }))
11574
- ).slice(0, TOP_SUITES_FOR_CHART);
11622
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11575
11623
  return {
11576
11624
  passRate: {
11577
11625
  passed: stats.passed,
11578
11626
  failed: stats.failed,
11627
+ timedOut: stats.timedOut,
11579
11628
  skipped: stats.skipped,
11580
11629
  flaky: stats.flaky
11581
11630
  },
11582
11631
  suiteResults
11583
11632
  };
11584
11633
  }
11585
- flattenSuites(suites) {
11586
- return suites.flatMap((s) => [s, ...this.flattenSuites(s.suites)]);
11587
- }
11588
11634
  };
11589
11635
 
11590
11636
  // src/collector/ShardMerger.ts
@@ -11592,15 +11638,14 @@ init_cjs_shims();
11592
11638
  var import_fs3 = require("fs");
11593
11639
  var import_glob = require("glob");
11594
11640
  var import_node_module = require("module");
11595
- var TOP_SUITES_FOR_CHART2 = 10;
11596
11641
  var ShardMerger = class {
11597
11642
  merge(filePaths) {
11598
11643
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11599
11644
  const resolved = this.resolveShardPaths(filePaths);
11600
11645
  const reports = this.loadReports(resolved);
11601
11646
  const projects = this.mergeProjects(reports);
11602
- const stats = this.mergeStats(reports);
11603
- const failures = this.extractFailures(reports);
11647
+ const stats = this.mergeStats(reports, projects);
11648
+ const failures = sortBySeverity(this.extractFailures(reports));
11604
11649
  const environment = this.buildEnvironment(reports[0]);
11605
11650
  const charts = this.buildCharts(stats, projects);
11606
11651
  return { projects, stats, failures, environment, charts };
@@ -11707,7 +11752,7 @@ var ShardMerger = class {
11707
11752
  id: spec.id,
11708
11753
  title: spec.title,
11709
11754
  fullTitle: [...suitePath, spec.title].join(" > "),
11710
- status: this.resolveStatus(test),
11755
+ status: this.resolveStatus(test, lastResult),
11711
11756
  duration: lastResult?.duration ?? 0,
11712
11757
  retryCount: test ? test.results.length - 1 : 0,
11713
11758
  tags: spec.tags ?? [],
@@ -11715,8 +11760,9 @@ var ShardMerger = class {
11715
11760
  location: `${spec.file}:${spec.line}`
11716
11761
  };
11717
11762
  }
11718
- resolveStatus(test) {
11763
+ resolveStatus(test, lastResult) {
11719
11764
  if (!test) return "skipped";
11765
+ if (lastResult?.status === "timedOut") return "timedOut";
11720
11766
  switch (test.status) {
11721
11767
  case "expected":
11722
11768
  return "passed";
@@ -11730,7 +11776,7 @@ var ShardMerger = class {
11730
11776
  return "failed";
11731
11777
  }
11732
11778
  }
11733
- mergeStats(reports) {
11779
+ mergeStats(reports, projects) {
11734
11780
  let expected = 0, unexpected = 0, flaky = 0, skipped = 0, maxDuration = 0;
11735
11781
  for (const r of reports) {
11736
11782
  expected += r.stats.expected;
@@ -11741,13 +11787,14 @@ var ShardMerger = class {
11741
11787
  }
11742
11788
  const total = expected + unexpected + flaky + skipped;
11743
11789
  const passRate = total > 0 ? Math.round((expected + flaky) / total * 100) : 0;
11790
+ const timedOut = projects.reduce((n, p) => n + p.stats.timedOut, 0);
11744
11791
  return {
11745
11792
  total,
11746
11793
  passed: expected,
11747
- failed: unexpected,
11794
+ failed: Math.max(0, unexpected - timedOut),
11748
11795
  skipped,
11749
11796
  flaky,
11750
- timedOut: 0,
11797
+ timedOut,
11751
11798
  duration: maxDuration,
11752
11799
  passRate,
11753
11800
  verdict: unexpected > 0 ? "failed" : "passed"
@@ -11825,18 +11872,13 @@ var ShardMerger = class {
11825
11872
  };
11826
11873
  }
11827
11874
  buildCharts(stats, projects) {
11828
- const suiteResults = projects.flatMap(
11829
- (p) => p.suites.map((s) => ({
11830
- label: s.title,
11831
- passed: s.stats.passed,
11832
- failed: s.stats.failed,
11833
- skipped: s.stats.skipped
11834
- }))
11835
- ).slice(0, TOP_SUITES_FOR_CHART2);
11875
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11836
11876
  return {
11837
11877
  passRate: {
11838
11878
  passed: stats.passed,
11839
11879
  failed: stats.failed,
11880
+ timedOut: stats.timedOut,
11881
+ // always 0 in shard JSON (no separate timedOut count)
11840
11882
  skipped: stats.skipped,
11841
11883
  flaky: stats.flaky
11842
11884
  },
@@ -11868,6 +11910,9 @@ function formatDuration(ms) {
11868
11910
  }
11869
11911
 
11870
11912
  // src/templates/engine.ts
11913
+ function jsonForInlineScript(value) {
11914
+ return JSON.stringify(value).replace(/<\//g, "<\\/");
11915
+ }
11871
11916
  var TemplateEngine = class {
11872
11917
  constructor() {
11873
11918
  this.chartjsInline = "";
@@ -12051,6 +12096,19 @@ var TemplateEngine = class {
12051
12096
  "upperCase",
12052
12097
  (s) => typeof s === "string" ? s.toUpperCase() : s
12053
12098
  );
12099
+ this.handlebars.registerHelper("verdictLabel", (v) => {
12100
+ const map = {
12101
+ passed: "PASSED",
12102
+ failed: "FAILED",
12103
+ timedout: "TIMED OUT",
12104
+ interrupted: "INTERRUPTED"
12105
+ };
12106
+ return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
12107
+ });
12108
+ this.handlebars.registerHelper("coverageBand", (pct) => {
12109
+ const n = Number(pct);
12110
+ return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
12111
+ });
12054
12112
  this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
12055
12113
  this.handlebars.registerHelper(
12056
12114
  "join",
@@ -12070,7 +12128,7 @@ var TemplateEngine = class {
12070
12128
  if (!Array.isArray(arr)) return "";
12071
12129
  const values = arr.map((item) => {
12072
12130
  const v = item[key];
12073
- return typeof v === "string" ? JSON.stringify(v) : String(v ?? 0);
12131
+ return typeof v === "string" ? jsonForInlineScript(v) : String(v ?? 0);
12074
12132
  });
12075
12133
  return new import_handlebars.default.SafeString(values.join(","));
12076
12134
  }
@@ -12081,7 +12139,7 @@ var TemplateEngine = class {
12081
12139
  this.handlebars.registerHelper("absFixed", (n) => Math.abs(n).toFixed(0));
12082
12140
  this.handlebars.registerHelper(
12083
12141
  "safeJsonData",
12084
- (value) => new import_handlebars.default.SafeString(JSON.stringify(value).replace(/<\//g, "<\\/"))
12142
+ (value) => new import_handlebars.default.SafeString(jsonForInlineScript(value))
12085
12143
  );
12086
12144
  this.handlebars.registerHelper(
12087
12145
  "reverse",
@@ -13068,7 +13126,7 @@ var PdfReporter = class {
13068
13126
  this.dataCollector = new DataCollector();
13069
13127
  this.pdfGenerator = new PdfGenerator();
13070
13128
  this.options = parseOptions(rawOptions);
13071
- const version = true ? "0.4.1" : "0.x";
13129
+ const version = true ? "0.5.1" : "0.x";
13072
13130
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13073
13131
  this.licenseClient = new LicenseClient({
13074
13132
  licenseKey: this.options.licenseKey,