@reportforge/playwright-pdf 0.13.1 → 0.13.2

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,20 @@
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.13.2] - 2026-07-04
7
+
8
+ ### Fixed
9
+
10
+ - **License refresh no longer wipes a valid cache on rate-limit**: an HTTP 429/408 from the license server was misclassified as a denial, clearing a still-valid cached license and skipping PDF generation. Now treated as transient — falls back to the cache.
11
+ - **`pdfPassword` encryption now actually works**: a malformed qpdf command meant every password-protected PDF shipped unencrypted while the run reported success. qpdf failures are now fatal instead of silent, and the password is no longer passed via process arguments.
12
+ - **`test.fail()` expected failures no longer miscounted**: an expected failure was counted as a failure in the KPI stats and failure list even though the suite tree correctly showed it as passed.
13
+ - **Sharded runs: describe blocks split across shards now merge correctly** instead of rendering as duplicate, half-populated nodes.
14
+ - **Sharded runs: failure severity now reflects tags/annotations** instead of always showing "medium".
15
+ - **Custom templates sharing a basename no longer overwrite each other's output.**
16
+ - **PDF size-cap retuning now actually reduces the inline failure count** when the report has fewer real failures than the configured cap.
17
+
18
+ ---
19
+
6
20
  ## [0.13.1] - 2026-07-03
7
21
 
8
22
  ### Changed
package/dist/cli/index.js CHANGED
@@ -7325,25 +7325,18 @@ var init_PdfEncryptor = __esm({
7325
7325
  return;
7326
7326
  }
7327
7327
  const tmpPath = import_path3.default.join(import_os2.default.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
7328
- const pwFile = import_path3.default.join(import_os2.default.tmpdir(), `rf-pw-${import_crypto2.default.randomBytes(8).toString("hex")}`);
7328
+ const argFile = import_path3.default.join(import_os2.default.tmpdir(), `rf-qpdf-args-${import_crypto2.default.randomBytes(8).toString("hex")}`);
7329
7329
  try {
7330
- import_fs5.default.writeFileSync(pwFile, `${password}
7331
- ${password}`, { mode: 384 });
7332
- (0, import_child_process2.execFileSync)("qpdf", [
7333
- `--password-file=${pwFile}`,
7334
- "--encrypt",
7335
- "256",
7336
- "--",
7337
- pdfPath,
7338
- tmpPath
7339
- ], { stdio: "pipe" });
7330
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
7331
+ import_fs5.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
7332
+ (0, import_child_process2.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
7340
7333
  import_fs5.default.renameSync(tmpPath, pdfPath);
7341
7334
  logger.info(`PDF encrypted with password.`);
7342
7335
  } catch (e) {
7343
- logger.warn(`qpdf encryption failed: ${e.message}`);
7336
+ throw new Error(`qpdf encryption failed: ${e.message}`);
7344
7337
  } finally {
7345
7338
  if (import_fs5.default.existsSync(tmpPath)) import_fs5.default.unlinkSync(tmpPath);
7346
- import_fs5.default.rmSync(pwFile, { force: true });
7339
+ import_fs5.default.rmSync(argFile, { force: true });
7347
7340
  }
7348
7341
  }
7349
7342
  isQpdfAvailable() {
@@ -7668,7 +7661,8 @@ var init_PdfGenerator = __esm({
7668
7661
  if (capBytes > 0) {
7669
7662
  const sizeBytes = (await import_fs8.default.promises.stat(outputPath)).size;
7670
7663
  if (sizeBytes > capBytes) {
7671
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
7664
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
7665
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
7672
7666
  logger.warn(
7673
7667
  `PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
7674
7668
  );
@@ -7697,11 +7691,30 @@ var init_PdfGenerator = __esm({
7697
7691
  }
7698
7692
  resolveTemplateSources(options) {
7699
7693
  if (options.templatePath && options.templatePath.length > 0) {
7700
- return [...new Set(options.templatePath)].map((p) => ({
7701
- kind: "custom",
7702
- absolutePath: import_path6.default.isAbsolute(p) ? p : import_path6.default.resolve(process.cwd(), p),
7703
- label: import_path6.default.basename(p, ".hbs")
7704
- }));
7694
+ const byAbsolutePath = /* @__PURE__ */ new Map();
7695
+ for (const p of options.templatePath) {
7696
+ const absolutePath = import_path6.default.isAbsolute(p) ? p : import_path6.default.resolve(process.cwd(), p);
7697
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
7698
+ }
7699
+ const labelCounts = /* @__PURE__ */ new Map();
7700
+ for (const rawPath of byAbsolutePath.values()) {
7701
+ const label = import_path6.default.basename(rawPath, ".hbs");
7702
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
7703
+ }
7704
+ const seenPerLabel = /* @__PURE__ */ new Map();
7705
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
7706
+ const baseLabel = import_path6.default.basename(rawPath, ".hbs");
7707
+ let label = baseLabel;
7708
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
7709
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
7710
+ seenPerLabel.set(baseLabel, n);
7711
+ label = `${baseLabel}-${n}`;
7712
+ logger.warn(
7713
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
7714
+ );
7715
+ }
7716
+ return { kind: "custom", absolutePath, label };
7717
+ });
7705
7718
  }
7706
7719
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
7707
7720
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -7723,8 +7736,8 @@ var init_PdfGenerator = __esm({
7723
7736
  * Also tightens JPEG quality and width to claw back bytes from each
7724
7737
  * remaining image.
7725
7738
  */
7726
- tightenForCap(prev, actualBytes, capBytes) {
7727
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
7739
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
7740
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
7728
7741
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
7729
7742
  return {
7730
7743
  effective: "max",
package/dist/index.js CHANGED
@@ -11061,6 +11061,11 @@ var LicenseClient = class {
11061
11061
  body: JSON.stringify(body),
11062
11062
  signal: controller.signal
11063
11063
  });
11064
+ if (res.status === 429 || res.status === 408) {
11065
+ await res.body?.cancel().catch(() => {
11066
+ });
11067
+ throw new Error(`HTTP ${res.status}`);
11068
+ }
11064
11069
  if (res.status >= 400 && res.status < 500) {
11065
11070
  const text = await res.text().catch(() => "");
11066
11071
  throw new Error(`license-denied: ${text || `HTTP ${res.status}`}`);
@@ -11619,6 +11624,17 @@ init_cjs_shims();
11619
11624
  // src/collector/stats-utils.ts
11620
11625
  init_cjs_shims();
11621
11626
  var TOP_SUITES_FOR_CHART = 10;
11627
+ function resolveTestStatus(test, result) {
11628
+ if (!result) return "skipped";
11629
+ const expected = test.expectedStatus ?? "passed";
11630
+ const actual = result.status;
11631
+ if (actual === "skipped") return "skipped";
11632
+ if (actual === "timedOut") return "timedOut";
11633
+ if (actual === "passed" && result.retry > 0) return "flaky";
11634
+ if (actual === "passed" && expected === "passed") return "passed";
11635
+ if (actual === "failed" && expected === "failed") return "passed";
11636
+ return "failed";
11637
+ }
11622
11638
  function statsFromTests(tests) {
11623
11639
  return {
11624
11640
  total: tests.length,
@@ -11694,7 +11710,7 @@ var SuiteWalker = class {
11694
11710
  }
11695
11711
  buildTest(test, resultMap) {
11696
11712
  const result = resultMap.get(test.id);
11697
- const status = this.resolveStatus(test, result);
11713
+ const status = resolveTestStatus(test, result);
11698
11714
  return {
11699
11715
  id: test.id,
11700
11716
  title: test.title,
@@ -11707,17 +11723,6 @@ var SuiteWalker = class {
11707
11723
  location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11708
11724
  };
11709
11725
  }
11710
- resolveStatus(test, result) {
11711
- if (!result) return "skipped";
11712
- const expected = test.expectedStatus ?? "passed";
11713
- const actual = result.status;
11714
- if (actual === "skipped") return "skipped";
11715
- if (actual === "timedOut") return "timedOut";
11716
- if (actual === "passed" && result.retry > 0) return "flaky";
11717
- if (actual === "passed" && expected === "passed") return "passed";
11718
- if (actual === "failed" && expected === "failed") return "passed";
11719
- return "failed";
11720
- }
11721
11726
  };
11722
11727
 
11723
11728
  // src/collector/severity.ts
@@ -11731,6 +11736,16 @@ var SEVERITY_RANK = {
11731
11736
  function sortBySeverity(failures) {
11732
11737
  return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11733
11738
  }
11739
+ function inferSeverity(annotations, tags) {
11740
+ const allLabels = [
11741
+ ...annotations.map((a) => a.type.toLowerCase()),
11742
+ ...tags.map((t) => t.toLowerCase())
11743
+ ];
11744
+ if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11745
+ if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11746
+ if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11747
+ return "medium";
11748
+ }
11734
11749
 
11735
11750
  // src/utils/env.ts
11736
11751
  init_cjs_shims();
@@ -11855,6 +11870,10 @@ var DataCollector = class {
11855
11870
  constructor(captureOpts = {}) {
11856
11871
  this.captureOpts = captureOpts;
11857
11872
  this.resultMap = /* @__PURE__ */ new Map();
11873
+ // Parallel to resultMap — needed so computeStats() (which only sees
11874
+ // TestResult, not TestCase) can reconcile against expectedStatus the same
11875
+ // way resolveTestStatus does for the suite tree and the failure list.
11876
+ this.expectedStatusMap = /* @__PURE__ */ new Map();
11858
11877
  this.failureRecords = [];
11859
11878
  this.startTime = Date.now();
11860
11879
  }
@@ -11865,7 +11884,10 @@ var DataCollector = class {
11865
11884
  }
11866
11885
  onTestEnd(test, result) {
11867
11886
  this.resultMap.set(test.id, result);
11868
- if (result.status === "failed" || result.status === "timedOut") {
11887
+ this.expectedStatusMap.set(test.id, test.expectedStatus);
11888
+ const resolved = resolveTestStatus(test, result);
11889
+ const isRealFailure = resolved === "failed" || resolved === "timedOut";
11890
+ if (isRealFailure) {
11869
11891
  const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11870
11892
  const record = this.buildFailureRecord(test, result);
11871
11893
  if (existing >= 0) {
@@ -11905,7 +11927,7 @@ var DataCollector = class {
11905
11927
  (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
11906
11928
  );
11907
11929
  const suitePath = test.titlePath().slice(0, -1);
11908
- const severity = this.inferSeverity(test);
11930
+ const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
11909
11931
  return {
11910
11932
  testId: test.id,
11911
11933
  testTitle: test.title,
@@ -11928,28 +11950,18 @@ var DataCollector = class {
11928
11950
  execution: executionFromResult(result, this.captureOpts)
11929
11951
  };
11930
11952
  }
11931
- inferSeverity(test) {
11932
- const annotations = test.annotations ?? [];
11933
- const tags = test.tags ?? [];
11934
- const allLabels = [
11935
- ...annotations.map((a) => a.type.toLowerCase()),
11936
- ...tags.map((t) => t.toLowerCase())
11937
- ];
11938
- if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11939
- if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11940
- if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11941
- return "medium";
11942
- }
11943
11953
  computeStats(fullResult) {
11944
11954
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11945
- for (const [, result] of this.resultMap) {
11955
+ for (const [testId, result] of this.resultMap) {
11946
11956
  total++;
11947
- switch (result.status) {
11948
- // A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
11949
- // Counting it in `passed` too would double-count it against `flaky` and
11950
- // push passRate over 100%.
11957
+ const expectedStatus = this.expectedStatusMap.get(testId);
11958
+ const resolved = resolveTestStatus({ expectedStatus }, result);
11959
+ switch (resolved) {
11951
11960
  case "passed":
11952
- result.retry > 0 ? flaky++ : passed++;
11961
+ passed++;
11962
+ break;
11963
+ case "flaky":
11964
+ flaky++;
11953
11965
  break;
11954
11966
  case "failed":
11955
11967
  failed++;
@@ -11961,9 +11973,6 @@ var DataCollector = class {
11961
11973
  case "skipped":
11962
11974
  skipped++;
11963
11975
  break;
11964
- case "interrupted":
11965
- failed++;
11966
- break;
11967
11976
  }
11968
11977
  }
11969
11978
  const duration = fullResult.duration ?? Date.now() - this.startTime;
@@ -12123,18 +12132,26 @@ var ShardMerger = class {
12123
12132
  });
12124
12133
  }
12125
12134
  buildDescribeSuites(jsonSuites, projectName, parentPath) {
12126
- return jsonSuites.map((suite) => {
12127
- const suitePath = [...parentPath, suite.title];
12128
- const tests = suite.specs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12129
- const childSuites = this.buildDescribeSuites(suite.suites ?? [], projectName, suitePath);
12135
+ const groups = /* @__PURE__ */ new Map();
12136
+ for (const suite of jsonSuites) {
12137
+ if (!groups.has(suite.title)) groups.set(suite.title, []);
12138
+ groups.get(suite.title).push(suite);
12139
+ }
12140
+ return Array.from(groups.values()).map((group) => {
12141
+ const ref = group[0];
12142
+ const suitePath = [...parentPath, ref.title];
12143
+ const allSpecs = group.flatMap((s) => s.specs);
12144
+ const allChildSuites = group.flatMap((s) => s.suites ?? []);
12145
+ const tests = allSpecs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12146
+ const childSuites = this.buildDescribeSuites(allChildSuites, projectName, suitePath);
12130
12147
  const stats = aggregateStats([
12131
12148
  ...childSuites.map((s) => s.stats),
12132
12149
  statsFromTests(tests)
12133
12150
  ]);
12134
12151
  return {
12135
- title: suite.title,
12152
+ title: ref.title,
12136
12153
  type: "describe",
12137
- location: suite.file ? `${suite.file}:${suite.line ?? 0}` : void 0,
12154
+ location: ref.file ? `${ref.file}:${ref.line ?? 0}` : void 0,
12138
12155
  suites: childSuites,
12139
12156
  tests,
12140
12157
  stats
@@ -12240,7 +12257,7 @@ var ShardMerger = class {
12240
12257
  duration: r.duration
12241
12258
  })),
12242
12259
  durationMs: lastResult?.duration ?? 0,
12243
- severity: "medium",
12260
+ severity: inferSeverity(test.annotations ?? [], spec.tags ?? []),
12244
12261
  execution: lastResult ? buildExecution(
12245
12262
  {
12246
12263
  // Guard each field — a partial / non-conformant / older shard JSON can
@@ -12816,25 +12833,18 @@ var PdfEncryptor = class {
12816
12833
  return;
12817
12834
  }
12818
12835
  const tmpPath = import_path5.default.join(import_os5.default.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
12819
- const pwFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-pw-${import_crypto7.default.randomBytes(8).toString("hex")}`);
12836
+ const argFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-qpdf-args-${import_crypto7.default.randomBytes(8).toString("hex")}`);
12820
12837
  try {
12821
- import_fs8.default.writeFileSync(pwFile, `${password}
12822
- ${password}`, { mode: 384 });
12823
- (0, import_child_process3.execFileSync)("qpdf", [
12824
- `--password-file=${pwFile}`,
12825
- "--encrypt",
12826
- "256",
12827
- "--",
12828
- pdfPath,
12829
- tmpPath
12830
- ], { stdio: "pipe" });
12838
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
12839
+ import_fs8.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
12840
+ (0, import_child_process3.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
12831
12841
  import_fs8.default.renameSync(tmpPath, pdfPath);
12832
12842
  logger.info(`PDF encrypted with password.`);
12833
12843
  } catch (e) {
12834
- logger.warn(`qpdf encryption failed: ${e.message}`);
12844
+ throw new Error(`qpdf encryption failed: ${e.message}`);
12835
12845
  } finally {
12836
12846
  if (import_fs8.default.existsSync(tmpPath)) import_fs8.default.unlinkSync(tmpPath);
12837
- import_fs8.default.rmSync(pwFile, { force: true });
12847
+ import_fs8.default.rmSync(argFile, { force: true });
12838
12848
  }
12839
12849
  }
12840
12850
  isQpdfAvailable() {
@@ -13083,7 +13093,8 @@ var PdfGenerator = class {
13083
13093
  if (capBytes > 0) {
13084
13094
  const sizeBytes = (await import_fs11.default.promises.stat(outputPath)).size;
13085
13095
  if (sizeBytes > capBytes) {
13086
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
13096
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13097
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
13087
13098
  logger.warn(
13088
13099
  `PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
13089
13100
  );
@@ -13112,11 +13123,30 @@ var PdfGenerator = class {
13112
13123
  }
13113
13124
  resolveTemplateSources(options) {
13114
13125
  if (options.templatePath && options.templatePath.length > 0) {
13115
- return [...new Set(options.templatePath)].map((p) => ({
13116
- kind: "custom",
13117
- absolutePath: import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p),
13118
- label: import_path8.default.basename(p, ".hbs")
13119
- }));
13126
+ const byAbsolutePath = /* @__PURE__ */ new Map();
13127
+ for (const p of options.templatePath) {
13128
+ const absolutePath = import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p);
13129
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13130
+ }
13131
+ const labelCounts = /* @__PURE__ */ new Map();
13132
+ for (const rawPath of byAbsolutePath.values()) {
13133
+ const label = import_path8.default.basename(rawPath, ".hbs");
13134
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13135
+ }
13136
+ const seenPerLabel = /* @__PURE__ */ new Map();
13137
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13138
+ const baseLabel = import_path8.default.basename(rawPath, ".hbs");
13139
+ let label = baseLabel;
13140
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13141
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13142
+ seenPerLabel.set(baseLabel, n);
13143
+ label = `${baseLabel}-${n}`;
13144
+ logger.warn(
13145
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13146
+ );
13147
+ }
13148
+ return { kind: "custom", absolutePath, label };
13149
+ });
13120
13150
  }
13121
13151
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13122
13152
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -13138,8 +13168,8 @@ var PdfGenerator = class {
13138
13168
  * Also tightens JPEG quality and width to claw back bytes from each
13139
13169
  * remaining image.
13140
13170
  */
13141
- tightenForCap(prev, actualBytes, capBytes) {
13142
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
13171
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13172
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13143
13173
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13144
13174
  return {
13145
13175
  effective: "max",
@@ -14001,7 +14031,7 @@ var PdfReporter = class {
14001
14031
  this.liveConsole = false;
14002
14032
  this.options = parseOptions(rawOptions);
14003
14033
  this.dataCollector = new DataCollector(this.options.capture);
14004
- const version = true ? "0.13.1" : "0.x";
14034
+ const version = true ? "0.13.2" : "0.x";
14005
14035
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14006
14036
  this.licenseClient = new LicenseClient({
14007
14037
  licenseKey: this.options.licenseKey,
package/dist/index.mjs CHANGED
@@ -11062,6 +11062,11 @@ var LicenseClient = class {
11062
11062
  body: JSON.stringify(body),
11063
11063
  signal: controller.signal
11064
11064
  });
11065
+ if (res.status === 429 || res.status === 408) {
11066
+ await res.body?.cancel().catch(() => {
11067
+ });
11068
+ throw new Error(`HTTP ${res.status}`);
11069
+ }
11065
11070
  if (res.status >= 400 && res.status < 500) {
11066
11071
  const text = await res.text().catch(() => "");
11067
11072
  throw new Error(`license-denied: ${text || `HTTP ${res.status}`}`);
@@ -11620,6 +11625,17 @@ init_esm_shims();
11620
11625
  // src/collector/stats-utils.ts
11621
11626
  init_esm_shims();
11622
11627
  var TOP_SUITES_FOR_CHART = 10;
11628
+ function resolveTestStatus(test, result) {
11629
+ if (!result) return "skipped";
11630
+ const expected = test.expectedStatus ?? "passed";
11631
+ const actual = result.status;
11632
+ if (actual === "skipped") return "skipped";
11633
+ if (actual === "timedOut") return "timedOut";
11634
+ if (actual === "passed" && result.retry > 0) return "flaky";
11635
+ if (actual === "passed" && expected === "passed") return "passed";
11636
+ if (actual === "failed" && expected === "failed") return "passed";
11637
+ return "failed";
11638
+ }
11623
11639
  function statsFromTests(tests) {
11624
11640
  return {
11625
11641
  total: tests.length,
@@ -11695,7 +11711,7 @@ var SuiteWalker = class {
11695
11711
  }
11696
11712
  buildTest(test, resultMap) {
11697
11713
  const result = resultMap.get(test.id);
11698
- const status = this.resolveStatus(test, result);
11714
+ const status = resolveTestStatus(test, result);
11699
11715
  return {
11700
11716
  id: test.id,
11701
11717
  title: test.title,
@@ -11708,17 +11724,6 @@ var SuiteWalker = class {
11708
11724
  location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11709
11725
  };
11710
11726
  }
11711
- resolveStatus(test, result) {
11712
- if (!result) return "skipped";
11713
- const expected = test.expectedStatus ?? "passed";
11714
- const actual = result.status;
11715
- if (actual === "skipped") return "skipped";
11716
- if (actual === "timedOut") return "timedOut";
11717
- if (actual === "passed" && result.retry > 0) return "flaky";
11718
- if (actual === "passed" && expected === "passed") return "passed";
11719
- if (actual === "failed" && expected === "failed") return "passed";
11720
- return "failed";
11721
- }
11722
11727
  };
11723
11728
 
11724
11729
  // src/collector/severity.ts
@@ -11732,6 +11737,16 @@ var SEVERITY_RANK = {
11732
11737
  function sortBySeverity(failures) {
11733
11738
  return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11734
11739
  }
11740
+ function inferSeverity(annotations, tags) {
11741
+ const allLabels = [
11742
+ ...annotations.map((a) => a.type.toLowerCase()),
11743
+ ...tags.map((t) => t.toLowerCase())
11744
+ ];
11745
+ if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11746
+ if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11747
+ if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11748
+ return "medium";
11749
+ }
11735
11750
 
11736
11751
  // src/utils/env.ts
11737
11752
  init_esm_shims();
@@ -11856,6 +11871,10 @@ var DataCollector = class {
11856
11871
  constructor(captureOpts = {}) {
11857
11872
  this.captureOpts = captureOpts;
11858
11873
  this.resultMap = /* @__PURE__ */ new Map();
11874
+ // Parallel to resultMap — needed so computeStats() (which only sees
11875
+ // TestResult, not TestCase) can reconcile against expectedStatus the same
11876
+ // way resolveTestStatus does for the suite tree and the failure list.
11877
+ this.expectedStatusMap = /* @__PURE__ */ new Map();
11859
11878
  this.failureRecords = [];
11860
11879
  this.startTime = Date.now();
11861
11880
  }
@@ -11866,7 +11885,10 @@ var DataCollector = class {
11866
11885
  }
11867
11886
  onTestEnd(test, result) {
11868
11887
  this.resultMap.set(test.id, result);
11869
- if (result.status === "failed" || result.status === "timedOut") {
11888
+ this.expectedStatusMap.set(test.id, test.expectedStatus);
11889
+ const resolved = resolveTestStatus(test, result);
11890
+ const isRealFailure = resolved === "failed" || resolved === "timedOut";
11891
+ if (isRealFailure) {
11870
11892
  const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11871
11893
  const record = this.buildFailureRecord(test, result);
11872
11894
  if (existing >= 0) {
@@ -11906,7 +11928,7 @@ var DataCollector = class {
11906
11928
  (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
11907
11929
  );
11908
11930
  const suitePath = test.titlePath().slice(0, -1);
11909
- const severity = this.inferSeverity(test);
11931
+ const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
11910
11932
  return {
11911
11933
  testId: test.id,
11912
11934
  testTitle: test.title,
@@ -11929,28 +11951,18 @@ var DataCollector = class {
11929
11951
  execution: executionFromResult(result, this.captureOpts)
11930
11952
  };
11931
11953
  }
11932
- inferSeverity(test) {
11933
- const annotations = test.annotations ?? [];
11934
- const tags = test.tags ?? [];
11935
- const allLabels = [
11936
- ...annotations.map((a) => a.type.toLowerCase()),
11937
- ...tags.map((t) => t.toLowerCase())
11938
- ];
11939
- if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11940
- if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11941
- if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11942
- return "medium";
11943
- }
11944
11954
  computeStats(fullResult) {
11945
11955
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11946
- for (const [, result] of this.resultMap) {
11956
+ for (const [testId, result] of this.resultMap) {
11947
11957
  total++;
11948
- switch (result.status) {
11949
- // A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
11950
- // Counting it in `passed` too would double-count it against `flaky` and
11951
- // push passRate over 100%.
11958
+ const expectedStatus = this.expectedStatusMap.get(testId);
11959
+ const resolved = resolveTestStatus({ expectedStatus }, result);
11960
+ switch (resolved) {
11952
11961
  case "passed":
11953
- result.retry > 0 ? flaky++ : passed++;
11962
+ passed++;
11963
+ break;
11964
+ case "flaky":
11965
+ flaky++;
11954
11966
  break;
11955
11967
  case "failed":
11956
11968
  failed++;
@@ -11962,9 +11974,6 @@ var DataCollector = class {
11962
11974
  case "skipped":
11963
11975
  skipped++;
11964
11976
  break;
11965
- case "interrupted":
11966
- failed++;
11967
- break;
11968
11977
  }
11969
11978
  }
11970
11979
  const duration = fullResult.duration ?? Date.now() - this.startTime;
@@ -12124,18 +12133,26 @@ var ShardMerger = class {
12124
12133
  });
12125
12134
  }
12126
12135
  buildDescribeSuites(jsonSuites, projectName, parentPath) {
12127
- return jsonSuites.map((suite) => {
12128
- const suitePath = [...parentPath, suite.title];
12129
- const tests = suite.specs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12130
- const childSuites = this.buildDescribeSuites(suite.suites ?? [], projectName, suitePath);
12136
+ const groups = /* @__PURE__ */ new Map();
12137
+ for (const suite of jsonSuites) {
12138
+ if (!groups.has(suite.title)) groups.set(suite.title, []);
12139
+ groups.get(suite.title).push(suite);
12140
+ }
12141
+ return Array.from(groups.values()).map((group) => {
12142
+ const ref = group[0];
12143
+ const suitePath = [...parentPath, ref.title];
12144
+ const allSpecs = group.flatMap((s) => s.specs);
12145
+ const allChildSuites = group.flatMap((s) => s.suites ?? []);
12146
+ const tests = allSpecs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12147
+ const childSuites = this.buildDescribeSuites(allChildSuites, projectName, suitePath);
12131
12148
  const stats = aggregateStats([
12132
12149
  ...childSuites.map((s) => s.stats),
12133
12150
  statsFromTests(tests)
12134
12151
  ]);
12135
12152
  return {
12136
- title: suite.title,
12153
+ title: ref.title,
12137
12154
  type: "describe",
12138
- location: suite.file ? `${suite.file}:${suite.line ?? 0}` : void 0,
12155
+ location: ref.file ? `${ref.file}:${ref.line ?? 0}` : void 0,
12139
12156
  suites: childSuites,
12140
12157
  tests,
12141
12158
  stats
@@ -12241,7 +12258,7 @@ var ShardMerger = class {
12241
12258
  duration: r.duration
12242
12259
  })),
12243
12260
  durationMs: lastResult?.duration ?? 0,
12244
- severity: "medium",
12261
+ severity: inferSeverity(test.annotations ?? [], spec.tags ?? []),
12245
12262
  execution: lastResult ? buildExecution(
12246
12263
  {
12247
12264
  // Guard each field — a partial / non-conformant / older shard JSON can
@@ -12817,25 +12834,18 @@ var PdfEncryptor = class {
12817
12834
  return;
12818
12835
  }
12819
12836
  const tmpPath = path9.join(os8.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
12820
- const pwFile = path9.join(os8.tmpdir(), `rf-pw-${crypto3.randomBytes(8).toString("hex")}`);
12837
+ const argFile = path9.join(os8.tmpdir(), `rf-qpdf-args-${crypto3.randomBytes(8).toString("hex")}`);
12821
12838
  try {
12822
- fs9.writeFileSync(pwFile, `${password}
12823
- ${password}`, { mode: 384 });
12824
- execFileSync("qpdf", [
12825
- `--password-file=${pwFile}`,
12826
- "--encrypt",
12827
- "256",
12828
- "--",
12829
- pdfPath,
12830
- tmpPath
12831
- ], { stdio: "pipe" });
12839
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
12840
+ fs9.writeFileSync(argFile, args.join("\n"), { mode: 384 });
12841
+ execFileSync("qpdf", [`@${argFile}`], { stdio: "pipe" });
12832
12842
  fs9.renameSync(tmpPath, pdfPath);
12833
12843
  logger.info(`PDF encrypted with password.`);
12834
12844
  } catch (e) {
12835
- logger.warn(`qpdf encryption failed: ${e.message}`);
12845
+ throw new Error(`qpdf encryption failed: ${e.message}`);
12836
12846
  } finally {
12837
12847
  if (fs9.existsSync(tmpPath)) fs9.unlinkSync(tmpPath);
12838
- fs9.rmSync(pwFile, { force: true });
12848
+ fs9.rmSync(argFile, { force: true });
12839
12849
  }
12840
12850
  }
12841
12851
  isQpdfAvailable() {
@@ -13084,7 +13094,8 @@ var PdfGenerator = class {
13084
13094
  if (capBytes > 0) {
13085
13095
  const sizeBytes = (await fs12.promises.stat(outputPath)).size;
13086
13096
  if (sizeBytes > capBytes) {
13087
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
13097
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13098
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
13088
13099
  logger.warn(
13089
13100
  `PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
13090
13101
  );
@@ -13113,11 +13124,30 @@ var PdfGenerator = class {
13113
13124
  }
13114
13125
  resolveTemplateSources(options) {
13115
13126
  if (options.templatePath && options.templatePath.length > 0) {
13116
- return [...new Set(options.templatePath)].map((p) => ({
13117
- kind: "custom",
13118
- absolutePath: path12.isAbsolute(p) ? p : path12.resolve(process.cwd(), p),
13119
- label: path12.basename(p, ".hbs")
13120
- }));
13127
+ const byAbsolutePath = /* @__PURE__ */ new Map();
13128
+ for (const p of options.templatePath) {
13129
+ const absolutePath = path12.isAbsolute(p) ? p : path12.resolve(process.cwd(), p);
13130
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13131
+ }
13132
+ const labelCounts = /* @__PURE__ */ new Map();
13133
+ for (const rawPath of byAbsolutePath.values()) {
13134
+ const label = path12.basename(rawPath, ".hbs");
13135
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13136
+ }
13137
+ const seenPerLabel = /* @__PURE__ */ new Map();
13138
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13139
+ const baseLabel = path12.basename(rawPath, ".hbs");
13140
+ let label = baseLabel;
13141
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13142
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13143
+ seenPerLabel.set(baseLabel, n);
13144
+ label = `${baseLabel}-${n}`;
13145
+ logger.warn(
13146
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13147
+ );
13148
+ }
13149
+ return { kind: "custom", absolutePath, label };
13150
+ });
13121
13151
  }
13122
13152
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13123
13153
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -13139,8 +13169,8 @@ var PdfGenerator = class {
13139
13169
  * Also tightens JPEG quality and width to claw back bytes from each
13140
13170
  * remaining image.
13141
13171
  */
13142
- tightenForCap(prev, actualBytes, capBytes) {
13143
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
13172
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13173
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13144
13174
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13145
13175
  return {
13146
13176
  effective: "max",
@@ -14002,7 +14032,7 @@ var PdfReporter = class {
14002
14032
  this.liveConsole = false;
14003
14033
  this.options = parseOptions(rawOptions);
14004
14034
  this.dataCollector = new DataCollector(this.options.capture);
14005
- const version = true ? "0.13.1" : "0.x";
14035
+ const version = true ? "0.13.2" : "0.x";
14006
14036
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14007
14037
  this.licenseClient = new LicenseClient({
14008
14038
  licenseKey: this.options.licenseKey,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
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",