@reportforge/playwright-pdf 0.4.0 → 0.5.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,37 @@
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.0] — 2026-06-19
7
+
8
+ ### Added
9
+
10
+ - **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".
11
+ - **Release gate on every template** — the ship/hold verdict banner now appears in `minimal` and `detailed`, not just `executive`.
12
+ - **Severity-ranked failures** — failure cards are colour-coded and ordered critical-first (also drives defect-log numbering and sidecar-overflow priority).
13
+ - **Describe-block Suite Results** — a single-file run breaks down by describe block instead of rendering one bar.
14
+ - **Threshold-coloured requirements coverage bars** — red `<50%`, amber `<80%`, green `≥80%`.
15
+
16
+ ### Changed
17
+
18
+ - **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.
19
+ - The pass-rate verdict renders "TIMED OUT" instead of "TIMEDOUT".
20
+
21
+ ### Fixed
22
+
23
+ - **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.
24
+ - The pass-rate doughnut centre now matches the KPI strip (single source of truth) rather than computing a second, divergent figure.
25
+ - 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.
26
+
27
+ ---
28
+
29
+ ## [0.4.0] — 2026-06-12
30
+
31
+ ### Added
32
+
33
+ - **Failure analysis** — every failed test is classified into one of seven root-cause categories (assertion, timeout, selector/locator, network, and more) and failures that share a cause are grouped into clusters, rendered as a ranked breakdown in the `detailed` template. Classification runs fully offline — no network call, no AI service. The classifier model refreshes from the licensing server in the background (offline-tolerant; the bundled model is always the floor), with optional privacy-preserving local feedback collection. See `docs/advanced/failure-analysis`.
34
+
35
+ ---
36
+
6
37
  ## [0.3.1] — 2026-06-09
7
38
 
8
39
  ### Security
package/README.md CHANGED
@@ -56,7 +56,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
56
56
  - **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
57
57
  - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email supports optional PDF attachment via `attachPdf`
58
58
  - **Flakiness trend** — top-N flakiest tests table with per-test dot sparkline across stored history runs in the `detailed` template; `flakinessTopN` option (default 5, `0` disables)
59
- - **Offline failure analysis** — classifies failures into 7 root-cause categories, clusters them, and renders a breakdown in the `detailed` PDF (one-liner in `executive`). No network, no AI service. The classifier model auto-updates offline over the existing license-refresh channel each delivered model is Ed25519-signed and verified against the bundled public key, with the bundled model always the floor; set `failureAnalysis.autoUpdateModel: false` to pin the bundled model.
59
+ - **Offline failure analysis** — sorts failures into 7 root-cause buckets and clusters them, right inside the `detailed` PDF (one-liner in `executive`). No network, no AI service a small embedded classifier that updates itself safely over your existing license refresh; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
60
60
  - `reportforge-export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
61
61
  - **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
62
62
 
@@ -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),
@@ -6714,6 +6715,19 @@ var TemplateEngine = class {
6714
6715
  "upperCase",
6715
6716
  (s) => typeof s === "string" ? s.toUpperCase() : s
6716
6717
  );
6718
+ this.handlebars.registerHelper("verdictLabel", (v) => {
6719
+ const map = {
6720
+ passed: "PASSED",
6721
+ failed: "FAILED",
6722
+ timedout: "TIMED OUT",
6723
+ interrupted: "INTERRUPTED"
6724
+ };
6725
+ return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
6726
+ });
6727
+ this.handlebars.registerHelper("coverageBand", (pct) => {
6728
+ const n = Number(pct);
6729
+ return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
6730
+ });
6717
6731
  this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
6718
6732
  this.handlebars.registerHelper(
6719
6733
  "join",
package/dist/index.js CHANGED
@@ -11215,16 +11215,31 @@ function runFailureAnalysis(reportData, options, cwd) {
11215
11215
  // src/collector/DataCollector.ts
11216
11216
  init_cjs_shims();
11217
11217
 
11218
+ // src/utils/strip-ansi.ts
11219
+ init_cjs_shims();
11220
+ var ANSI_PATTERN = new RegExp(
11221
+ [
11222
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
11223
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
11224
+ ].join("|"),
11225
+ "g"
11226
+ );
11227
+ function stripAnsi(input) {
11228
+ return input.replace(ANSI_PATTERN, "");
11229
+ }
11230
+
11218
11231
  // src/collector/SuiteWalker.ts
11219
11232
  init_cjs_shims();
11220
11233
 
11221
11234
  // src/collector/stats-utils.ts
11222
11235
  init_cjs_shims();
11236
+ var TOP_SUITES_FOR_CHART = 10;
11223
11237
  function statsFromTests(tests) {
11224
11238
  return {
11225
11239
  total: tests.length,
11226
11240
  passed: tests.filter((t) => t.status === "passed").length,
11227
- failed: tests.filter((t) => t.status === "failed" || t.status === "timedOut").length,
11241
+ failed: tests.filter((t) => t.status === "failed").length,
11242
+ timedOut: tests.filter((t) => t.status === "timedOut").length,
11228
11243
  skipped: tests.filter((t) => t.status === "skipped").length,
11229
11244
  flaky: tests.filter((t) => t.status === "flaky").length
11230
11245
  };
@@ -11235,12 +11250,32 @@ function aggregateStats(parts) {
11235
11250
  total: acc.total + s.total,
11236
11251
  passed: acc.passed + s.passed,
11237
11252
  failed: acc.failed + s.failed,
11253
+ timedOut: acc.timedOut + s.timedOut,
11238
11254
  skipped: acc.skipped + s.skipped,
11239
11255
  flaky: acc.flaky + s.flaky
11240
11256
  }),
11241
- { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 }
11257
+ { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11242
11258
  );
11243
11259
  }
11260
+ function toRow(s) {
11261
+ return {
11262
+ label: s.title,
11263
+ passed: s.stats.passed,
11264
+ failed: s.stats.failed,
11265
+ timedOut: s.stats.timedOut,
11266
+ skipped: s.stats.skipped
11267
+ };
11268
+ }
11269
+ function buildSuiteResults(projects, limit) {
11270
+ const top = projects.flatMap((p) => p.suites);
11271
+ if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11272
+ const file = top[0];
11273
+ const fs14 = statsFromTests(file.tests);
11274
+ const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11275
+ return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11276
+ }
11277
+ return top.map(toRow).slice(0, limit);
11278
+ }
11244
11279
 
11245
11280
  // src/collector/SuiteWalker.ts
11246
11281
  var SuiteWalker = class {
@@ -11300,6 +11335,18 @@ var SuiteWalker = class {
11300
11335
  }
11301
11336
  };
11302
11337
 
11338
+ // src/collector/severity.ts
11339
+ init_cjs_shims();
11340
+ var SEVERITY_RANK = {
11341
+ critical: 0,
11342
+ high: 1,
11343
+ medium: 2,
11344
+ low: 3
11345
+ };
11346
+ function sortBySeverity(failures) {
11347
+ return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11348
+ }
11349
+
11303
11350
  // src/utils/env.ts
11304
11351
  init_cjs_shims();
11305
11352
  var import_child_process = require("child_process");
@@ -11389,7 +11436,6 @@ function gitCommit() {
11389
11436
  }
11390
11437
 
11391
11438
  // src/collector/DataCollector.ts
11392
- var TOP_SUITES_FOR_CHART = 10;
11393
11439
  var DataCollector = class {
11394
11440
  constructor() {
11395
11441
  this.resultMap = /* @__PURE__ */ new Map();
@@ -11426,13 +11472,13 @@ var DataCollector = class {
11426
11472
  finalize(fullResult) {
11427
11473
  const walker = new SuiteWalker();
11428
11474
  const projects = walker.build(this.rootSuite, this.resultMap);
11429
- const stats = this.computeStats(projects, fullResult);
11475
+ const stats = this.computeStats(fullResult);
11430
11476
  const environment = this.buildEnvironment();
11431
11477
  const charts = this.buildChartData(stats, projects);
11432
11478
  return {
11433
11479
  projects,
11434
11480
  stats,
11435
- failures: [...this.failureRecords],
11481
+ failures: sortBySeverity(this.failureRecords),
11436
11482
  environment,
11437
11483
  charts
11438
11484
  };
@@ -11449,8 +11495,8 @@ var DataCollector = class {
11449
11495
  testTitle: test.title,
11450
11496
  suitePath,
11451
11497
  error: {
11452
- message: error.message ?? String(error),
11453
- stack: error.stack
11498
+ message: stripAnsi(error.message ?? String(error)),
11499
+ stack: error.stack ? stripAnsi(error.stack) : void 0
11454
11500
  },
11455
11501
  screenshotPath: screenshotAttachment?.path,
11456
11502
  screenshotBuffer: screenshotAttachment?.body,
@@ -11476,13 +11522,16 @@ var DataCollector = class {
11476
11522
  if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11477
11523
  return "medium";
11478
11524
  }
11479
- computeStats(projects, fullResult) {
11525
+ computeStats(fullResult) {
11480
11526
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11481
11527
  for (const [, result] of this.resultMap) {
11482
11528
  total++;
11483
11529
  switch (result.status) {
11530
+ // A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
11531
+ // Counting it in `passed` too would double-count it against `flaky` and
11532
+ // push passRate over 100%.
11484
11533
  case "passed":
11485
- passed++;
11534
+ result.retry > 0 ? flaky++ : passed++;
11486
11535
  break;
11487
11536
  case "failed":
11488
11537
  failed++;
@@ -11499,13 +11548,6 @@ var DataCollector = class {
11499
11548
  break;
11500
11549
  }
11501
11550
  }
11502
- for (const project of projects) {
11503
- for (const suite of this.flattenSuites(project.suites)) {
11504
- for (const test of suite.tests) {
11505
- if (test.status === "flaky") flaky++;
11506
- }
11507
- }
11508
- }
11509
11551
  const duration = fullResult.duration ?? Date.now() - this.startTime;
11510
11552
  const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
11511
11553
  return {
@@ -11551,27 +11593,18 @@ var DataCollector = class {
11551
11593
  };
11552
11594
  }
11553
11595
  buildChartData(stats, projects) {
11554
- const suiteResults = projects.flatMap(
11555
- (p) => p.suites.map((s) => ({
11556
- label: s.title,
11557
- passed: s.stats.passed,
11558
- failed: s.stats.failed,
11559
- skipped: s.stats.skipped
11560
- }))
11561
- ).slice(0, TOP_SUITES_FOR_CHART);
11596
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11562
11597
  return {
11563
11598
  passRate: {
11564
11599
  passed: stats.passed,
11565
11600
  failed: stats.failed,
11601
+ timedOut: stats.timedOut,
11566
11602
  skipped: stats.skipped,
11567
11603
  flaky: stats.flaky
11568
11604
  },
11569
11605
  suiteResults
11570
11606
  };
11571
11607
  }
11572
- flattenSuites(suites) {
11573
- return suites.flatMap((s) => [s, ...this.flattenSuites(s.suites)]);
11574
- }
11575
11608
  };
11576
11609
 
11577
11610
  // src/collector/ShardMerger.ts
@@ -11579,15 +11612,14 @@ init_cjs_shims();
11579
11612
  var import_fs3 = require("fs");
11580
11613
  var import_glob = require("glob");
11581
11614
  var import_node_module = require("module");
11582
- var TOP_SUITES_FOR_CHART2 = 10;
11583
11615
  var ShardMerger = class {
11584
11616
  merge(filePaths) {
11585
11617
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11586
11618
  const resolved = this.resolveShardPaths(filePaths);
11587
11619
  const reports = this.loadReports(resolved);
11588
11620
  const projects = this.mergeProjects(reports);
11589
- const stats = this.mergeStats(reports);
11590
- const failures = this.extractFailures(reports);
11621
+ const stats = this.mergeStats(reports, projects);
11622
+ const failures = sortBySeverity(this.extractFailures(reports));
11591
11623
  const environment = this.buildEnvironment(reports[0]);
11592
11624
  const charts = this.buildCharts(stats, projects);
11593
11625
  return { projects, stats, failures, environment, charts };
@@ -11694,7 +11726,7 @@ var ShardMerger = class {
11694
11726
  id: spec.id,
11695
11727
  title: spec.title,
11696
11728
  fullTitle: [...suitePath, spec.title].join(" > "),
11697
- status: this.resolveStatus(test),
11729
+ status: this.resolveStatus(test, lastResult),
11698
11730
  duration: lastResult?.duration ?? 0,
11699
11731
  retryCount: test ? test.results.length - 1 : 0,
11700
11732
  tags: spec.tags ?? [],
@@ -11702,8 +11734,9 @@ var ShardMerger = class {
11702
11734
  location: `${spec.file}:${spec.line}`
11703
11735
  };
11704
11736
  }
11705
- resolveStatus(test) {
11737
+ resolveStatus(test, lastResult) {
11706
11738
  if (!test) return "skipped";
11739
+ if (lastResult?.status === "timedOut") return "timedOut";
11707
11740
  switch (test.status) {
11708
11741
  case "expected":
11709
11742
  return "passed";
@@ -11717,7 +11750,7 @@ var ShardMerger = class {
11717
11750
  return "failed";
11718
11751
  }
11719
11752
  }
11720
- mergeStats(reports) {
11753
+ mergeStats(reports, projects) {
11721
11754
  let expected = 0, unexpected = 0, flaky = 0, skipped = 0, maxDuration = 0;
11722
11755
  for (const r of reports) {
11723
11756
  expected += r.stats.expected;
@@ -11728,13 +11761,14 @@ var ShardMerger = class {
11728
11761
  }
11729
11762
  const total = expected + unexpected + flaky + skipped;
11730
11763
  const passRate = total > 0 ? Math.round((expected + flaky) / total * 100) : 0;
11764
+ const timedOut = projects.reduce((n, p) => n + p.stats.timedOut, 0);
11731
11765
  return {
11732
11766
  total,
11733
11767
  passed: expected,
11734
- failed: unexpected,
11768
+ failed: Math.max(0, unexpected - timedOut),
11735
11769
  skipped,
11736
11770
  flaky,
11737
- timedOut: 0,
11771
+ timedOut,
11738
11772
  duration: maxDuration,
11739
11773
  passRate,
11740
11774
  verdict: unexpected > 0 ? "failed" : "passed"
@@ -11772,8 +11806,8 @@ var ShardMerger = class {
11772
11806
  testTitle: spec.title,
11773
11807
  suitePath,
11774
11808
  error: {
11775
- message: error?.message ?? "Test failed",
11776
- stack: error?.stack
11809
+ message: stripAnsi(error?.message ?? "Test failed"),
11810
+ stack: error?.stack ? stripAnsi(error.stack) : void 0
11777
11811
  },
11778
11812
  screenshotPath: lastResult?.attachments.find(
11779
11813
  (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
@@ -11812,18 +11846,13 @@ var ShardMerger = class {
11812
11846
  };
11813
11847
  }
11814
11848
  buildCharts(stats, projects) {
11815
- const suiteResults = projects.flatMap(
11816
- (p) => p.suites.map((s) => ({
11817
- label: s.title,
11818
- passed: s.stats.passed,
11819
- failed: s.stats.failed,
11820
- skipped: s.stats.skipped
11821
- }))
11822
- ).slice(0, TOP_SUITES_FOR_CHART2);
11849
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11823
11850
  return {
11824
11851
  passRate: {
11825
11852
  passed: stats.passed,
11826
11853
  failed: stats.failed,
11854
+ timedOut: stats.timedOut,
11855
+ // always 0 in shard JSON (no separate timedOut count)
11827
11856
  skipped: stats.skipped,
11828
11857
  flaky: stats.flaky
11829
11858
  },
@@ -12038,6 +12067,19 @@ var TemplateEngine = class {
12038
12067
  "upperCase",
12039
12068
  (s) => typeof s === "string" ? s.toUpperCase() : s
12040
12069
  );
12070
+ this.handlebars.registerHelper("verdictLabel", (v) => {
12071
+ const map = {
12072
+ passed: "PASSED",
12073
+ failed: "FAILED",
12074
+ timedout: "TIMED OUT",
12075
+ interrupted: "INTERRUPTED"
12076
+ };
12077
+ return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
12078
+ });
12079
+ this.handlebars.registerHelper("coverageBand", (pct) => {
12080
+ const n = Number(pct);
12081
+ return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
12082
+ });
12041
12083
  this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
12042
12084
  this.handlebars.registerHelper(
12043
12085
  "join",
@@ -13055,7 +13097,7 @@ var PdfReporter = class {
13055
13097
  this.dataCollector = new DataCollector();
13056
13098
  this.pdfGenerator = new PdfGenerator();
13057
13099
  this.options = parseOptions(rawOptions);
13058
- const version = true ? "0.4.0" : "0.x";
13100
+ const version = true ? "0.5.0" : "0.x";
13059
13101
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13060
13102
  this.licenseClient = new LicenseClient({
13061
13103
  licenseKey: this.options.licenseKey,