@reportforge/playwright-pdf 0.13.1 → 0.14.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,34 @@
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.14.0] - 2026-07-06
7
+
8
+ ### Added
9
+
10
+ - **Executive report leads with a plain-language brief and a hero pass-rate stat**: a new tinted brief band composes the trend delta and root-cause tally into one sentence (e.g. "Pass rate climbed 4 points to 92%... 3 root causes remain: 2 assertion, 1 timeout"), replacing the 6-card KPI grid with a single hero pass-rate number and a compact secondary stat row. The trend chart is promoted above the smaller pass-rate/suite-results charts. `detailed` and `minimal` templates are unchanged.
11
+ - **New `sections.briefBand` option** to toggle the brief band on any template (on by default for `executive`).
12
+
13
+ ### Fixed
14
+
15
+ - **Flaky doughnut slice now matches the `--flaky` design token** instead of a stray hardcoded color.
16
+ - **`minimal` template's header no longer uses a side-stripe border**, now consistent with the design system's top-accent-bar convention used everywhere else.
17
+
18
+ ---
19
+
20
+ ## [0.13.2] - 2026-07-04
21
+
22
+ ### Fixed
23
+
24
+ - **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.
25
+ - **`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.
26
+ - **`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.
27
+ - **Sharded runs: describe blocks split across shards now merge correctly** instead of rendering as duplicate, half-populated nodes.
28
+ - **Sharded runs: failure severity now reflects tags/annotations** instead of always showing "medium".
29
+ - **Custom templates sharing a basename no longer overwrite each other's output.**
30
+ - **PDF size-cap retuning now actually reduces the inline failure count** when the report has fewer real failures than the configured cap.
31
+
32
+ ---
33
+
6
34
  ## [0.13.1] - 2026-07-03
7
35
 
8
36
  ### Changed
package/README.md CHANGED
@@ -61,6 +61,7 @@ Three templates, one reporter. Every PDF is fully offline: fonts, charts, and sc
61
61
  - `npx @reportforge/playwright-pdf export-feedback`: exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
62
62
  - **Rich execution capture**: opt-in "Steps to Reproduce" outline (a nested, copy-pasteable Markdown list of your `test.step`s and assertions; add `apiSteps` for the full click/fill/check trail), Node console tail, and trace/video links under each failed test in the defect log. Read straight from the Playwright step tree: no fixtures, no test-code changes. Enable via `capture` (`steps` / `apiSteps` / `console` / `evidence`); off by default.
63
63
  - **Configurable sections**: add or remove any report section per template via `sections`.
64
+ - **Executive report brief band**: leads with a plain-language brief (trend + root causes) and one hero pass-rate number instead of a flat KPI grid
64
65
  - **Hybrid licensing**: one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
65
66
 
66
67
  ---
@@ -204,7 +205,7 @@ All options are the second element of the reporter tuple.
204
205
  | `flakinessTopN` | `number` | `5` | Maximum flaky tests to show in the flakiness table (`detailed` template). Set to `0` to disable the table entirely. |
205
206
  | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown: below this, every test is trivially the "slowest" so badges stay hidden. On larger runs only genuine outliers (at least 2× the median test duration) are badged, so the badge stays rare. Set to `0` to drop the run-size gate (outliers still required). |
206
207
  | `templatePath` | `string \| string[]` | n/a | Path to a custom Handlebars (`.hbs`) template file. Takes precedence over `template`. Pass an array to generate one PDF per custom template. See the Custom Templates docs. |
207
- | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. See the Report Sections docs. |
208
+ | `sections` | `object` | per-template | Override which report sections appear, on top of the chosen template's defaults. Flat keys are the baseline for every chosen template; per-template keys (`minimal`\|`detailed`\|`executive`) override per template. Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`, `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`, `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. See the Report Sections docs. |
208
209
  <!-- AUTOGEN:options-table END -->
209
210
 
210
211
  Full reference: [reportforge.org/docs/configuration](https://reportforge.org/docs/configuration).
package/dist/cli/index.js CHANGED
@@ -6388,6 +6388,25 @@ function buildOneLiner(clusters) {
6388
6388
  function dominantCategory(clusters) {
6389
6389
  return clusters[0]?.category ?? "unknown";
6390
6390
  }
6391
+ function trendClause(passRate, delta) {
6392
+ if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
6393
+ const verb = delta > 0 ? "climbed" : "dropped";
6394
+ const abs = Math.abs(delta);
6395
+ const points = abs === 1 ? "point" : "points";
6396
+ return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
6397
+ }
6398
+ function causesClause(analysis) {
6399
+ const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
6400
+ const verb = analysis.clusters.length === 1 ? "remains" : "remain";
6401
+ return `${head} ${verb}: ${tallyParts.join(", ")}.`;
6402
+ }
6403
+ function buildBriefSentence(stats, analysis, trend) {
6404
+ if (trend && trend.delta !== null) {
6405
+ const trendSentence = trendClause(stats.passRate, trend.delta);
6406
+ return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
6407
+ }
6408
+ return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
6409
+ }
6391
6410
  var CATEGORY_LABEL, SHORT_LABEL;
6392
6411
  var init_SummaryWriter = __esm({
6393
6412
  "src/analysis/SummaryWriter.ts"() {
@@ -6457,6 +6476,7 @@ var init_sections = __esm({
6457
6476
  "failureAnalysis",
6458
6477
  "slowTests",
6459
6478
  "defectLog",
6479
+ "briefBand",
6460
6480
  // display modifiers
6461
6481
  "passRate",
6462
6482
  "fullEnvironment",
@@ -6479,6 +6499,7 @@ var init_sections = __esm({
6479
6499
  failureAnalysis: false,
6480
6500
  slowTests: false,
6481
6501
  defectLog: false,
6502
+ briefBand: false,
6482
6503
  passRate: true,
6483
6504
  fullEnvironment: false,
6484
6505
  retries: true,
@@ -6499,6 +6520,7 @@ var init_sections = __esm({
6499
6520
  failureAnalysis: true,
6500
6521
  slowTests: true,
6501
6522
  defectLog: true,
6523
+ briefBand: false,
6502
6524
  passRate: true,
6503
6525
  fullEnvironment: true,
6504
6526
  retries: true,
@@ -6507,7 +6529,9 @@ var init_sections = __esm({
6507
6529
  },
6508
6530
  executive: {
6509
6531
  coverPage: true,
6510
- analysisOneliner: true,
6532
+ // Now off: its content (root-cause tally) is subsumed by briefBand below,
6533
+ // which also adds the trend delta. Showing both would say it twice.
6534
+ analysisOneliner: false,
6511
6535
  releaseGate: true,
6512
6536
  summary: true,
6513
6537
  charts: true,
@@ -6522,6 +6546,7 @@ var init_sections = __esm({
6522
6546
  failureAnalysis: false,
6523
6547
  slowTests: true,
6524
6548
  defectLog: false,
6549
+ briefBand: true,
6525
6550
  passRate: true,
6526
6551
  fullEnvironment: false,
6527
6552
  retries: false,
@@ -7325,25 +7350,18 @@ var init_PdfEncryptor = __esm({
7325
7350
  return;
7326
7351
  }
7327
7352
  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")}`);
7353
+ const argFile = import_path3.default.join(import_os2.default.tmpdir(), `rf-qpdf-args-${import_crypto2.default.randomBytes(8).toString("hex")}`);
7329
7354
  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" });
7355
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
7356
+ import_fs5.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
7357
+ (0, import_child_process2.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
7340
7358
  import_fs5.default.renameSync(tmpPath, pdfPath);
7341
7359
  logger.info(`PDF encrypted with password.`);
7342
7360
  } catch (e) {
7343
- logger.warn(`qpdf encryption failed: ${e.message}`);
7361
+ throw new Error(`qpdf encryption failed: ${e.message}`);
7344
7362
  } finally {
7345
7363
  if (import_fs5.default.existsSync(tmpPath)) import_fs5.default.unlinkSync(tmpPath);
7346
- import_fs5.default.rmSync(pwFile, { force: true });
7364
+ import_fs5.default.rmSync(argFile, { force: true });
7347
7365
  }
7348
7366
  }
7349
7367
  isQpdfAvailable() {
@@ -7668,7 +7686,8 @@ var init_PdfGenerator = __esm({
7668
7686
  if (capBytes > 0) {
7669
7687
  const sizeBytes = (await import_fs8.default.promises.stat(outputPath)).size;
7670
7688
  if (sizeBytes > capBytes) {
7671
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
7689
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
7690
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
7672
7691
  logger.warn(
7673
7692
  `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
7693
  );
@@ -7697,11 +7716,30 @@ var init_PdfGenerator = __esm({
7697
7716
  }
7698
7717
  resolveTemplateSources(options) {
7699
7718
  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
- }));
7719
+ const byAbsolutePath = /* @__PURE__ */ new Map();
7720
+ for (const p of options.templatePath) {
7721
+ const absolutePath = import_path6.default.isAbsolute(p) ? p : import_path6.default.resolve(process.cwd(), p);
7722
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
7723
+ }
7724
+ const labelCounts = /* @__PURE__ */ new Map();
7725
+ for (const rawPath of byAbsolutePath.values()) {
7726
+ const label = import_path6.default.basename(rawPath, ".hbs");
7727
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
7728
+ }
7729
+ const seenPerLabel = /* @__PURE__ */ new Map();
7730
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
7731
+ const baseLabel = import_path6.default.basename(rawPath, ".hbs");
7732
+ let label = baseLabel;
7733
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
7734
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
7735
+ seenPerLabel.set(baseLabel, n);
7736
+ label = `${baseLabel}-${n}`;
7737
+ logger.warn(
7738
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
7739
+ );
7740
+ }
7741
+ return { kind: "custom", absolutePath, label };
7742
+ });
7705
7743
  }
7706
7744
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
7707
7745
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -7723,8 +7761,8 @@ var init_PdfGenerator = __esm({
7723
7761
  * Also tightens JPEG quality and width to claw back bytes from each
7724
7762
  * remaining image.
7725
7763
  */
7726
- tightenForCap(prev, actualBytes, capBytes) {
7727
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
7764
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
7765
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
7728
7766
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
7729
7767
  return {
7730
7768
  effective: "max",
@@ -12489,6 +12527,7 @@ async function run() {
12489
12527
  const options = parseOptions({ template, outputFile, failureAnalysis: { collectUnclassified: false } });
12490
12528
  const generator = new PdfGenerator();
12491
12529
  runFailureAnalysis(reportData, options, process.cwd());
12530
+ reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
12492
12531
  const paths = await generator.generateAll(reportData, options, DEMO_LICENSE_INFO2);
12493
12532
  if (paths.length > 0) {
12494
12533
  console.log(`
@@ -12529,6 +12568,7 @@ var init_cli = __esm({
12529
12568
  init_schema();
12530
12569
  init_types2();
12531
12570
  init_analysis();
12571
+ init_SummaryWriter();
12532
12572
  DEMO_LICENSE_INFO2 = {
12533
12573
  plan: "subscription",
12534
12574
  source: "demo",
package/dist/index.d.mts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 13 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 14 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -332,7 +332,7 @@ interface ReporterOptions {
332
332
  *
333
333
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
334
334
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
335
- * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`.
335
+ * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
336
336
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
337
337
  * `stackTraces`.
338
338
  *
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
2
2
 
3
3
  /**
4
- * Canonical section catalog. 13 block toggles (section present/absent) followed
4
+ * Canonical section catalog. 14 block toggles (section present/absent) followed
5
5
  * by 5 display modifiers (tweak a section that is on). Single source of truth —
6
6
  * the Zod schema in src/config/schema.ts builds its flag shape from this array.
7
7
  */
8
- declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
8
+ declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
9
9
  type SectionKey = (typeof SECTION_KEYS)[number];
10
10
  /** A partial map of section flags — used both as the flat baseline and per-template. */
11
11
  type SectionFlags = Partial<Record<SectionKey, boolean>>;
@@ -332,7 +332,7 @@ interface ReporterOptions {
332
332
  *
333
333
  * Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
334
334
  * `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
335
- * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`.
335
+ * `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
336
336
  * Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
337
337
  * `stackTraces`.
338
338
  *
package/dist/index.js CHANGED
@@ -10350,6 +10350,7 @@ var SECTION_KEYS = [
10350
10350
  "failureAnalysis",
10351
10351
  "slowTests",
10352
10352
  "defectLog",
10353
+ "briefBand",
10353
10354
  // display modifiers
10354
10355
  "passRate",
10355
10356
  "fullEnvironment",
@@ -10372,6 +10373,7 @@ var SECTION_DEFAULTS = {
10372
10373
  failureAnalysis: false,
10373
10374
  slowTests: false,
10374
10375
  defectLog: false,
10376
+ briefBand: false,
10375
10377
  passRate: true,
10376
10378
  fullEnvironment: false,
10377
10379
  retries: true,
@@ -10392,6 +10394,7 @@ var SECTION_DEFAULTS = {
10392
10394
  failureAnalysis: true,
10393
10395
  slowTests: true,
10394
10396
  defectLog: true,
10397
+ briefBand: false,
10395
10398
  passRate: true,
10396
10399
  fullEnvironment: true,
10397
10400
  retries: true,
@@ -10400,7 +10403,9 @@ var SECTION_DEFAULTS = {
10400
10403
  },
10401
10404
  executive: {
10402
10405
  coverPage: true,
10403
- analysisOneliner: true,
10406
+ // Now off: its content (root-cause tally) is subsumed by briefBand below,
10407
+ // which also adds the trend delta. Showing both would say it twice.
10408
+ analysisOneliner: false,
10404
10409
  releaseGate: true,
10405
10410
  summary: true,
10406
10411
  charts: true,
@@ -10415,6 +10420,7 @@ var SECTION_DEFAULTS = {
10415
10420
  failureAnalysis: false,
10416
10421
  slowTests: true,
10417
10422
  defectLog: false,
10423
+ briefBand: true,
10418
10424
  passRate: true,
10419
10425
  fullEnvironment: false,
10420
10426
  retries: false,
@@ -11061,6 +11067,11 @@ var LicenseClient = class {
11061
11067
  body: JSON.stringify(body),
11062
11068
  signal: controller.signal
11063
11069
  });
11070
+ if (res.status === 429 || res.status === 408) {
11071
+ await res.body?.cancel().catch(() => {
11072
+ });
11073
+ throw new Error(`HTTP ${res.status}`);
11074
+ }
11064
11075
  if (res.status >= 400 && res.status < 500) {
11065
11076
  const text = await res.text().catch(() => "");
11066
11077
  throw new Error(`license-denied: ${text || `HTTP ${res.status}`}`);
@@ -11293,6 +11304,25 @@ function buildOneLiner(clusters) {
11293
11304
  function dominantCategory(clusters) {
11294
11305
  return clusters[0]?.category ?? "unknown";
11295
11306
  }
11307
+ function trendClause(passRate, delta) {
11308
+ if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
11309
+ const verb = delta > 0 ? "climbed" : "dropped";
11310
+ const abs = Math.abs(delta);
11311
+ const points = abs === 1 ? "point" : "points";
11312
+ return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
11313
+ }
11314
+ function causesClause(analysis) {
11315
+ const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
11316
+ const verb = analysis.clusters.length === 1 ? "remains" : "remain";
11317
+ return `${head} ${verb}: ${tallyParts.join(", ")}.`;
11318
+ }
11319
+ function buildBriefSentence(stats, analysis, trend) {
11320
+ if (trend && trend.delta !== null) {
11321
+ const trendSentence = trendClause(stats.passRate, trend.delta);
11322
+ return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
11323
+ }
11324
+ return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
11325
+ }
11296
11326
 
11297
11327
  // src/analysis/UnclassifiedCollector.ts
11298
11328
  init_cjs_shims();
@@ -11619,6 +11649,17 @@ init_cjs_shims();
11619
11649
  // src/collector/stats-utils.ts
11620
11650
  init_cjs_shims();
11621
11651
  var TOP_SUITES_FOR_CHART = 10;
11652
+ function resolveTestStatus(test, result) {
11653
+ if (!result) return "skipped";
11654
+ const expected = test.expectedStatus ?? "passed";
11655
+ const actual = result.status;
11656
+ if (actual === "skipped") return "skipped";
11657
+ if (actual === "timedOut") return "timedOut";
11658
+ if (actual === "passed" && result.retry > 0) return "flaky";
11659
+ if (actual === "passed" && expected === "passed") return "passed";
11660
+ if (actual === "failed" && expected === "failed") return "passed";
11661
+ return "failed";
11662
+ }
11622
11663
  function statsFromTests(tests) {
11623
11664
  return {
11624
11665
  total: tests.length,
@@ -11694,7 +11735,7 @@ var SuiteWalker = class {
11694
11735
  }
11695
11736
  buildTest(test, resultMap) {
11696
11737
  const result = resultMap.get(test.id);
11697
- const status = this.resolveStatus(test, result);
11738
+ const status = resolveTestStatus(test, result);
11698
11739
  return {
11699
11740
  id: test.id,
11700
11741
  title: test.title,
@@ -11707,17 +11748,6 @@ var SuiteWalker = class {
11707
11748
  location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11708
11749
  };
11709
11750
  }
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
11751
  };
11722
11752
 
11723
11753
  // src/collector/severity.ts
@@ -11731,6 +11761,16 @@ var SEVERITY_RANK = {
11731
11761
  function sortBySeverity(failures) {
11732
11762
  return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11733
11763
  }
11764
+ function inferSeverity(annotations, tags) {
11765
+ const allLabels = [
11766
+ ...annotations.map((a) => a.type.toLowerCase()),
11767
+ ...tags.map((t) => t.toLowerCase())
11768
+ ];
11769
+ if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11770
+ if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11771
+ if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11772
+ return "medium";
11773
+ }
11734
11774
 
11735
11775
  // src/utils/env.ts
11736
11776
  init_cjs_shims();
@@ -11855,6 +11895,10 @@ var DataCollector = class {
11855
11895
  constructor(captureOpts = {}) {
11856
11896
  this.captureOpts = captureOpts;
11857
11897
  this.resultMap = /* @__PURE__ */ new Map();
11898
+ // Parallel to resultMap — needed so computeStats() (which only sees
11899
+ // TestResult, not TestCase) can reconcile against expectedStatus the same
11900
+ // way resolveTestStatus does for the suite tree and the failure list.
11901
+ this.expectedStatusMap = /* @__PURE__ */ new Map();
11858
11902
  this.failureRecords = [];
11859
11903
  this.startTime = Date.now();
11860
11904
  }
@@ -11865,7 +11909,10 @@ var DataCollector = class {
11865
11909
  }
11866
11910
  onTestEnd(test, result) {
11867
11911
  this.resultMap.set(test.id, result);
11868
- if (result.status === "failed" || result.status === "timedOut") {
11912
+ this.expectedStatusMap.set(test.id, test.expectedStatus);
11913
+ const resolved = resolveTestStatus(test, result);
11914
+ const isRealFailure = resolved === "failed" || resolved === "timedOut";
11915
+ if (isRealFailure) {
11869
11916
  const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11870
11917
  const record = this.buildFailureRecord(test, result);
11871
11918
  if (existing >= 0) {
@@ -11905,7 +11952,7 @@ var DataCollector = class {
11905
11952
  (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
11906
11953
  );
11907
11954
  const suitePath = test.titlePath().slice(0, -1);
11908
- const severity = this.inferSeverity(test);
11955
+ const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
11909
11956
  return {
11910
11957
  testId: test.id,
11911
11958
  testTitle: test.title,
@@ -11928,28 +11975,18 @@ var DataCollector = class {
11928
11975
  execution: executionFromResult(result, this.captureOpts)
11929
11976
  };
11930
11977
  }
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
11978
  computeStats(fullResult) {
11944
11979
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11945
- for (const [, result] of this.resultMap) {
11980
+ for (const [testId, result] of this.resultMap) {
11946
11981
  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%.
11982
+ const expectedStatus = this.expectedStatusMap.get(testId);
11983
+ const resolved = resolveTestStatus({ expectedStatus }, result);
11984
+ switch (resolved) {
11951
11985
  case "passed":
11952
- result.retry > 0 ? flaky++ : passed++;
11986
+ passed++;
11987
+ break;
11988
+ case "flaky":
11989
+ flaky++;
11953
11990
  break;
11954
11991
  case "failed":
11955
11992
  failed++;
@@ -11961,9 +11998,6 @@ var DataCollector = class {
11961
11998
  case "skipped":
11962
11999
  skipped++;
11963
12000
  break;
11964
- case "interrupted":
11965
- failed++;
11966
- break;
11967
12001
  }
11968
12002
  }
11969
12003
  const duration = fullResult.duration ?? Date.now() - this.startTime;
@@ -12123,18 +12157,26 @@ var ShardMerger = class {
12123
12157
  });
12124
12158
  }
12125
12159
  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);
12160
+ const groups = /* @__PURE__ */ new Map();
12161
+ for (const suite of jsonSuites) {
12162
+ if (!groups.has(suite.title)) groups.set(suite.title, []);
12163
+ groups.get(suite.title).push(suite);
12164
+ }
12165
+ return Array.from(groups.values()).map((group) => {
12166
+ const ref = group[0];
12167
+ const suitePath = [...parentPath, ref.title];
12168
+ const allSpecs = group.flatMap((s) => s.specs);
12169
+ const allChildSuites = group.flatMap((s) => s.suites ?? []);
12170
+ const tests = allSpecs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12171
+ const childSuites = this.buildDescribeSuites(allChildSuites, projectName, suitePath);
12130
12172
  const stats = aggregateStats([
12131
12173
  ...childSuites.map((s) => s.stats),
12132
12174
  statsFromTests(tests)
12133
12175
  ]);
12134
12176
  return {
12135
- title: suite.title,
12177
+ title: ref.title,
12136
12178
  type: "describe",
12137
- location: suite.file ? `${suite.file}:${suite.line ?? 0}` : void 0,
12179
+ location: ref.file ? `${ref.file}:${ref.line ?? 0}` : void 0,
12138
12180
  suites: childSuites,
12139
12181
  tests,
12140
12182
  stats
@@ -12240,7 +12282,7 @@ var ShardMerger = class {
12240
12282
  duration: r.duration
12241
12283
  })),
12242
12284
  durationMs: lastResult?.duration ?? 0,
12243
- severity: "medium",
12285
+ severity: inferSeverity(test.annotations ?? [], spec.tags ?? []),
12244
12286
  execution: lastResult ? buildExecution(
12245
12287
  {
12246
12288
  // Guard each field — a partial / non-conformant / older shard JSON can
@@ -12816,25 +12858,18 @@ var PdfEncryptor = class {
12816
12858
  return;
12817
12859
  }
12818
12860
  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")}`);
12861
+ const argFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-qpdf-args-${import_crypto7.default.randomBytes(8).toString("hex")}`);
12820
12862
  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" });
12863
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
12864
+ import_fs8.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
12865
+ (0, import_child_process3.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
12831
12866
  import_fs8.default.renameSync(tmpPath, pdfPath);
12832
12867
  logger.info(`PDF encrypted with password.`);
12833
12868
  } catch (e) {
12834
- logger.warn(`qpdf encryption failed: ${e.message}`);
12869
+ throw new Error(`qpdf encryption failed: ${e.message}`);
12835
12870
  } finally {
12836
12871
  if (import_fs8.default.existsSync(tmpPath)) import_fs8.default.unlinkSync(tmpPath);
12837
- import_fs8.default.rmSync(pwFile, { force: true });
12872
+ import_fs8.default.rmSync(argFile, { force: true });
12838
12873
  }
12839
12874
  }
12840
12875
  isQpdfAvailable() {
@@ -13083,7 +13118,8 @@ var PdfGenerator = class {
13083
13118
  if (capBytes > 0) {
13084
13119
  const sizeBytes = (await import_fs11.default.promises.stat(outputPath)).size;
13085
13120
  if (sizeBytes > capBytes) {
13086
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
13121
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13122
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
13087
13123
  logger.warn(
13088
13124
  `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
13125
  );
@@ -13112,11 +13148,30 @@ var PdfGenerator = class {
13112
13148
  }
13113
13149
  resolveTemplateSources(options) {
13114
13150
  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
- }));
13151
+ const byAbsolutePath = /* @__PURE__ */ new Map();
13152
+ for (const p of options.templatePath) {
13153
+ const absolutePath = import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p);
13154
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13155
+ }
13156
+ const labelCounts = /* @__PURE__ */ new Map();
13157
+ for (const rawPath of byAbsolutePath.values()) {
13158
+ const label = import_path8.default.basename(rawPath, ".hbs");
13159
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13160
+ }
13161
+ const seenPerLabel = /* @__PURE__ */ new Map();
13162
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13163
+ const baseLabel = import_path8.default.basename(rawPath, ".hbs");
13164
+ let label = baseLabel;
13165
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13166
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13167
+ seenPerLabel.set(baseLabel, n);
13168
+ label = `${baseLabel}-${n}`;
13169
+ logger.warn(
13170
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13171
+ );
13172
+ }
13173
+ return { kind: "custom", absolutePath, label };
13174
+ });
13120
13175
  }
13121
13176
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13122
13177
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -13138,8 +13193,8 @@ var PdfGenerator = class {
13138
13193
  * Also tightens JPEG quality and width to claw back bytes from each
13139
13194
  * remaining image.
13140
13195
  */
13141
- tightenForCap(prev, actualBytes, capBytes) {
13142
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
13196
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13197
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13143
13198
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13144
13199
  return {
13145
13200
  effective: "max",
@@ -14001,7 +14056,7 @@ var PdfReporter = class {
14001
14056
  this.liveConsole = false;
14002
14057
  this.options = parseOptions(rawOptions);
14003
14058
  this.dataCollector = new DataCollector(this.options.capture);
14004
- const version = true ? "0.13.1" : "0.x";
14059
+ const version = true ? "0.14.0" : "0.x";
14005
14060
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14006
14061
  this.licenseClient = new LicenseClient({
14007
14062
  licenseKey: this.options.licenseKey,
@@ -14085,6 +14140,7 @@ var PdfReporter = class {
14085
14140
  }
14086
14141
  const reportData = this._buildReportData(collected, licenseInfo);
14087
14142
  runFailureAnalysis(reportData, this.options, this.cwd);
14143
+ reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
14088
14144
  let pdfPaths = [];
14089
14145
  try {
14090
14146
  pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);