@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/dist/index.mjs CHANGED
@@ -10351,6 +10351,7 @@ var SECTION_KEYS = [
10351
10351
  "failureAnalysis",
10352
10352
  "slowTests",
10353
10353
  "defectLog",
10354
+ "briefBand",
10354
10355
  // display modifiers
10355
10356
  "passRate",
10356
10357
  "fullEnvironment",
@@ -10373,6 +10374,7 @@ var SECTION_DEFAULTS = {
10373
10374
  failureAnalysis: false,
10374
10375
  slowTests: false,
10375
10376
  defectLog: false,
10377
+ briefBand: false,
10376
10378
  passRate: true,
10377
10379
  fullEnvironment: false,
10378
10380
  retries: true,
@@ -10393,6 +10395,7 @@ var SECTION_DEFAULTS = {
10393
10395
  failureAnalysis: true,
10394
10396
  slowTests: true,
10395
10397
  defectLog: true,
10398
+ briefBand: false,
10396
10399
  passRate: true,
10397
10400
  fullEnvironment: true,
10398
10401
  retries: true,
@@ -10401,7 +10404,9 @@ var SECTION_DEFAULTS = {
10401
10404
  },
10402
10405
  executive: {
10403
10406
  coverPage: true,
10404
- analysisOneliner: true,
10407
+ // Now off: its content (root-cause tally) is subsumed by briefBand below,
10408
+ // which also adds the trend delta. Showing both would say it twice.
10409
+ analysisOneliner: false,
10405
10410
  releaseGate: true,
10406
10411
  summary: true,
10407
10412
  charts: true,
@@ -10416,6 +10421,7 @@ var SECTION_DEFAULTS = {
10416
10421
  failureAnalysis: false,
10417
10422
  slowTests: true,
10418
10423
  defectLog: false,
10424
+ briefBand: true,
10419
10425
  passRate: true,
10420
10426
  fullEnvironment: false,
10421
10427
  retries: false,
@@ -11062,6 +11068,11 @@ var LicenseClient = class {
11062
11068
  body: JSON.stringify(body),
11063
11069
  signal: controller.signal
11064
11070
  });
11071
+ if (res.status === 429 || res.status === 408) {
11072
+ await res.body?.cancel().catch(() => {
11073
+ });
11074
+ throw new Error(`HTTP ${res.status}`);
11075
+ }
11065
11076
  if (res.status >= 400 && res.status < 500) {
11066
11077
  const text = await res.text().catch(() => "");
11067
11078
  throw new Error(`license-denied: ${text || `HTTP ${res.status}`}`);
@@ -11294,6 +11305,25 @@ function buildOneLiner(clusters) {
11294
11305
  function dominantCategory(clusters) {
11295
11306
  return clusters[0]?.category ?? "unknown";
11296
11307
  }
11308
+ function trendClause(passRate, delta) {
11309
+ if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
11310
+ const verb = delta > 0 ? "climbed" : "dropped";
11311
+ const abs = Math.abs(delta);
11312
+ const points = abs === 1 ? "point" : "points";
11313
+ return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
11314
+ }
11315
+ function causesClause(analysis) {
11316
+ const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
11317
+ const verb = analysis.clusters.length === 1 ? "remains" : "remain";
11318
+ return `${head} ${verb}: ${tallyParts.join(", ")}.`;
11319
+ }
11320
+ function buildBriefSentence(stats, analysis, trend) {
11321
+ if (trend && trend.delta !== null) {
11322
+ const trendSentence = trendClause(stats.passRate, trend.delta);
11323
+ return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
11324
+ }
11325
+ return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
11326
+ }
11297
11327
 
11298
11328
  // src/analysis/UnclassifiedCollector.ts
11299
11329
  init_esm_shims();
@@ -11620,6 +11650,17 @@ init_esm_shims();
11620
11650
  // src/collector/stats-utils.ts
11621
11651
  init_esm_shims();
11622
11652
  var TOP_SUITES_FOR_CHART = 10;
11653
+ function resolveTestStatus(test, result) {
11654
+ if (!result) return "skipped";
11655
+ const expected = test.expectedStatus ?? "passed";
11656
+ const actual = result.status;
11657
+ if (actual === "skipped") return "skipped";
11658
+ if (actual === "timedOut") return "timedOut";
11659
+ if (actual === "passed" && result.retry > 0) return "flaky";
11660
+ if (actual === "passed" && expected === "passed") return "passed";
11661
+ if (actual === "failed" && expected === "failed") return "passed";
11662
+ return "failed";
11663
+ }
11623
11664
  function statsFromTests(tests) {
11624
11665
  return {
11625
11666
  total: tests.length,
@@ -11695,7 +11736,7 @@ var SuiteWalker = class {
11695
11736
  }
11696
11737
  buildTest(test, resultMap) {
11697
11738
  const result = resultMap.get(test.id);
11698
- const status = this.resolveStatus(test, result);
11739
+ const status = resolveTestStatus(test, result);
11699
11740
  return {
11700
11741
  id: test.id,
11701
11742
  title: test.title,
@@ -11708,17 +11749,6 @@ var SuiteWalker = class {
11708
11749
  location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11709
11750
  };
11710
11751
  }
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
11752
  };
11723
11753
 
11724
11754
  // src/collector/severity.ts
@@ -11732,6 +11762,16 @@ var SEVERITY_RANK = {
11732
11762
  function sortBySeverity(failures) {
11733
11763
  return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11734
11764
  }
11765
+ function inferSeverity(annotations, tags) {
11766
+ const allLabels = [
11767
+ ...annotations.map((a) => a.type.toLowerCase()),
11768
+ ...tags.map((t) => t.toLowerCase())
11769
+ ];
11770
+ if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11771
+ if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11772
+ if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11773
+ return "medium";
11774
+ }
11735
11775
 
11736
11776
  // src/utils/env.ts
11737
11777
  init_esm_shims();
@@ -11856,6 +11896,10 @@ var DataCollector = class {
11856
11896
  constructor(captureOpts = {}) {
11857
11897
  this.captureOpts = captureOpts;
11858
11898
  this.resultMap = /* @__PURE__ */ new Map();
11899
+ // Parallel to resultMap — needed so computeStats() (which only sees
11900
+ // TestResult, not TestCase) can reconcile against expectedStatus the same
11901
+ // way resolveTestStatus does for the suite tree and the failure list.
11902
+ this.expectedStatusMap = /* @__PURE__ */ new Map();
11859
11903
  this.failureRecords = [];
11860
11904
  this.startTime = Date.now();
11861
11905
  }
@@ -11866,7 +11910,10 @@ var DataCollector = class {
11866
11910
  }
11867
11911
  onTestEnd(test, result) {
11868
11912
  this.resultMap.set(test.id, result);
11869
- if (result.status === "failed" || result.status === "timedOut") {
11913
+ this.expectedStatusMap.set(test.id, test.expectedStatus);
11914
+ const resolved = resolveTestStatus(test, result);
11915
+ const isRealFailure = resolved === "failed" || resolved === "timedOut";
11916
+ if (isRealFailure) {
11870
11917
  const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11871
11918
  const record = this.buildFailureRecord(test, result);
11872
11919
  if (existing >= 0) {
@@ -11906,7 +11953,7 @@ var DataCollector = class {
11906
11953
  (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
11907
11954
  );
11908
11955
  const suitePath = test.titlePath().slice(0, -1);
11909
- const severity = this.inferSeverity(test);
11956
+ const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
11910
11957
  return {
11911
11958
  testId: test.id,
11912
11959
  testTitle: test.title,
@@ -11929,28 +11976,18 @@ var DataCollector = class {
11929
11976
  execution: executionFromResult(result, this.captureOpts)
11930
11977
  };
11931
11978
  }
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
11979
  computeStats(fullResult) {
11945
11980
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11946
- for (const [, result] of this.resultMap) {
11981
+ for (const [testId, result] of this.resultMap) {
11947
11982
  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%.
11983
+ const expectedStatus = this.expectedStatusMap.get(testId);
11984
+ const resolved = resolveTestStatus({ expectedStatus }, result);
11985
+ switch (resolved) {
11952
11986
  case "passed":
11953
- result.retry > 0 ? flaky++ : passed++;
11987
+ passed++;
11988
+ break;
11989
+ case "flaky":
11990
+ flaky++;
11954
11991
  break;
11955
11992
  case "failed":
11956
11993
  failed++;
@@ -11962,9 +11999,6 @@ var DataCollector = class {
11962
11999
  case "skipped":
11963
12000
  skipped++;
11964
12001
  break;
11965
- case "interrupted":
11966
- failed++;
11967
- break;
11968
12002
  }
11969
12003
  }
11970
12004
  const duration = fullResult.duration ?? Date.now() - this.startTime;
@@ -12124,18 +12158,26 @@ var ShardMerger = class {
12124
12158
  });
12125
12159
  }
12126
12160
  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);
12161
+ const groups = /* @__PURE__ */ new Map();
12162
+ for (const suite of jsonSuites) {
12163
+ if (!groups.has(suite.title)) groups.set(suite.title, []);
12164
+ groups.get(suite.title).push(suite);
12165
+ }
12166
+ return Array.from(groups.values()).map((group) => {
12167
+ const ref = group[0];
12168
+ const suitePath = [...parentPath, ref.title];
12169
+ const allSpecs = group.flatMap((s) => s.specs);
12170
+ const allChildSuites = group.flatMap((s) => s.suites ?? []);
12171
+ const tests = allSpecs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
12172
+ const childSuites = this.buildDescribeSuites(allChildSuites, projectName, suitePath);
12131
12173
  const stats = aggregateStats([
12132
12174
  ...childSuites.map((s) => s.stats),
12133
12175
  statsFromTests(tests)
12134
12176
  ]);
12135
12177
  return {
12136
- title: suite.title,
12178
+ title: ref.title,
12137
12179
  type: "describe",
12138
- location: suite.file ? `${suite.file}:${suite.line ?? 0}` : void 0,
12180
+ location: ref.file ? `${ref.file}:${ref.line ?? 0}` : void 0,
12139
12181
  suites: childSuites,
12140
12182
  tests,
12141
12183
  stats
@@ -12241,7 +12283,7 @@ var ShardMerger = class {
12241
12283
  duration: r.duration
12242
12284
  })),
12243
12285
  durationMs: lastResult?.duration ?? 0,
12244
- severity: "medium",
12286
+ severity: inferSeverity(test.annotations ?? [], spec.tags ?? []),
12245
12287
  execution: lastResult ? buildExecution(
12246
12288
  {
12247
12289
  // Guard each field — a partial / non-conformant / older shard JSON can
@@ -12817,25 +12859,18 @@ var PdfEncryptor = class {
12817
12859
  return;
12818
12860
  }
12819
12861
  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")}`);
12862
+ const argFile = path9.join(os8.tmpdir(), `rf-qpdf-args-${crypto3.randomBytes(8).toString("hex")}`);
12821
12863
  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" });
12864
+ const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
12865
+ fs9.writeFileSync(argFile, args.join("\n"), { mode: 384 });
12866
+ execFileSync("qpdf", [`@${argFile}`], { stdio: "pipe" });
12832
12867
  fs9.renameSync(tmpPath, pdfPath);
12833
12868
  logger.info(`PDF encrypted with password.`);
12834
12869
  } catch (e) {
12835
- logger.warn(`qpdf encryption failed: ${e.message}`);
12870
+ throw new Error(`qpdf encryption failed: ${e.message}`);
12836
12871
  } finally {
12837
12872
  if (fs9.existsSync(tmpPath)) fs9.unlinkSync(tmpPath);
12838
- fs9.rmSync(pwFile, { force: true });
12873
+ fs9.rmSync(argFile, { force: true });
12839
12874
  }
12840
12875
  }
12841
12876
  isQpdfAvailable() {
@@ -13084,7 +13119,8 @@ var PdfGenerator = class {
13084
13119
  if (capBytes > 0) {
13085
13120
  const sizeBytes = (await fs12.promises.stat(outputPath)).size;
13086
13121
  if (sizeBytes > capBytes) {
13087
- const tighter = this.tightenForCap(compression, sizeBytes, capBytes);
13122
+ const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13123
+ const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
13088
13124
  logger.warn(
13089
13125
  `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
13126
  );
@@ -13113,11 +13149,30 @@ var PdfGenerator = class {
13113
13149
  }
13114
13150
  resolveTemplateSources(options) {
13115
13151
  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
- }));
13152
+ const byAbsolutePath = /* @__PURE__ */ new Map();
13153
+ for (const p of options.templatePath) {
13154
+ const absolutePath = path12.isAbsolute(p) ? p : path12.resolve(process.cwd(), p);
13155
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13156
+ }
13157
+ const labelCounts = /* @__PURE__ */ new Map();
13158
+ for (const rawPath of byAbsolutePath.values()) {
13159
+ const label = path12.basename(rawPath, ".hbs");
13160
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13161
+ }
13162
+ const seenPerLabel = /* @__PURE__ */ new Map();
13163
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13164
+ const baseLabel = path12.basename(rawPath, ".hbs");
13165
+ let label = baseLabel;
13166
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13167
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13168
+ seenPerLabel.set(baseLabel, n);
13169
+ label = `${baseLabel}-${n}`;
13170
+ logger.warn(
13171
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13172
+ );
13173
+ }
13174
+ return { kind: "custom", absolutePath, label };
13175
+ });
13121
13176
  }
13122
13177
  const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13123
13178
  return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
@@ -13139,8 +13194,8 @@ var PdfGenerator = class {
13139
13194
  * Also tightens JPEG quality and width to claw back bytes from each
13140
13195
  * remaining image.
13141
13196
  */
13142
- tightenForCap(prev, actualBytes, capBytes) {
13143
- const observedPerFailure = actualBytes / Math.max(1, prev.maxInlineFailures);
13197
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13198
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13144
13199
  const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13145
13200
  return {
13146
13201
  effective: "max",
@@ -14002,7 +14057,7 @@ var PdfReporter = class {
14002
14057
  this.liveConsole = false;
14003
14058
  this.options = parseOptions(rawOptions);
14004
14059
  this.dataCollector = new DataCollector(this.options.capture);
14005
- const version = true ? "0.13.1" : "0.x";
14060
+ const version = true ? "0.14.0" : "0.x";
14006
14061
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
14007
14062
  this.licenseClient = new LicenseClient({
14008
14063
  licenseKey: this.options.licenseKey,
@@ -14086,6 +14141,7 @@ var PdfReporter = class {
14086
14141
  }
14087
14142
  const reportData = this._buildReportData(collected, licenseInfo);
14088
14143
  runFailureAnalysis(reportData, this.options, this.cwd);
14144
+ reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
14089
14145
  let pdfPaths = [];
14090
14146
  try {
14091
14147
  pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);
@@ -18,6 +18,8 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
+ {{#if sections.briefBand}}{{> brief-band}}{{/if}}
22
+
21
23
  {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
24
 
23
25
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
@@ -18,9 +18,11 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
21
+ {{#if sections.briefBand}}{{> brief-band}}{{/if}}
22
22
 
23
- {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
23
+ {{#if sections.summary}}{{> executive-hero-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
24
+
25
+ {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend heroTrend=true) }}{{/if}}
24
26
 
25
27
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
26
28
 
@@ -18,6 +18,8 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
+ {{#if sections.briefBand}}{{> brief-band}}{{/if}}
22
+
21
23
  {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
24
 
23
25
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
@@ -0,0 +1,6 @@
1
+ {{#if briefSentence}}
2
+ <div class="brief-band">
3
+ <span class="brief-band__quote">&#8220;</span>
4
+ <p class="brief-band__text">{{briefSentence}}</p>
5
+ </div>
6
+ {{/if}}
@@ -0,0 +1,18 @@
1
+ <div class="section charts-grid{{#if options.heroTrend}} charts-grid--secondary{{/if}}">
2
+ <div class="chart-card">
3
+ <h3>Pass Rate</h3>
4
+ {{#if options.heroTrend}}
5
+ <canvas id="passRateChart" width="150" height="150"></canvas>
6
+ {{else}}
7
+ <canvas id="passRateChart" width="186" height="186"></canvas>
8
+ {{/if}}
9
+ </div>
10
+ <div class="chart-card">
11
+ <h3>Suite Results</h3>
12
+ {{#if options.heroTrend}}
13
+ <canvas id="suitesBarChart" width="300" height="150"></canvas>
14
+ {{else}}
15
+ <canvas id="suitesBarChart" width="390" height="186"></canvas>
16
+ {{/if}}
17
+ </div>
18
+ </div>
@@ -0,0 +1,16 @@
1
+ {{#if options.showTrend}}
2
+ {{#if charts.trend}}
3
+ <div class="trend-card">
4
+ <div class="trend-card__header">
5
+ <span class="trend-card__title">Pass rate trend &mdash; last {{charts.trend.entries.length}} runs</span>
6
+ {{#if charts.trend.delta}}
7
+ <span class="delta {{#if (gt charts.trend.delta 0)}}delta-up{{else}}delta-down{{/if}}">
8
+ {{#if (gt charts.trend.delta 0)}}&#8593;{{else}}&#8595;{{/if}} {{absFixed charts.trend.delta}}% vs previous run
9
+ </span>
10
+ {{/if}}
11
+ </div>
12
+ <canvas id="trendChart" width="680" height="160"></canvas>
13
+ <div class="trend-run-history" id="run-history-table"></div>
14
+ </div>
15
+ {{/if}}
16
+ {{/if}}
@@ -1,33 +1,10 @@
1
1
  {{#if options.showCharts}}
2
- <div class="section charts-grid">
3
- <div class="chart-card">
4
- <h3>Pass Rate</h3>
5
- <canvas id="passRateChart" width="186" height="186"></canvas>
6
- </div>
7
- <div class="chart-card">
8
- <h3>Suite Results</h3>
9
- <canvas id="suitesBarChart" width="390" height="186"></canvas>
10
- </div>
11
- </div>
12
-
13
- {{!-- Trend card: detailed + executive + custom templates. Minimal has showTrend=false. --}}
14
- {{#if options.showTrend}}
15
- {{#if charts.trend}}
16
- <div class="trend-card">
17
- <div class="trend-card__header">
18
- <span class="trend-card__title">Pass rate trend &mdash; last {{charts.trend.entries.length}} runs</span>
19
- {{!-- Delta badge: falsy guard handles null (< 2 entries) and 0 (identical runs). --}}
20
- {{#if charts.trend.delta}}
21
- <span class="delta {{#if (gt charts.trend.delta 0)}}delta-up{{else}}delta-down{{/if}}">
22
- {{#if (gt charts.trend.delta 0)}}&#8593;{{else}}&#8595;{{/if}} {{absFixed charts.trend.delta}}% vs previous run
23
- </span>
24
- {{/if}}
25
- </div>
26
- <canvas id="trendChart" width="680" height="160"></canvas>
27
- {{!-- Run history table is populated by JS below; placeholder must exist before script runs. --}}
28
- <div class="trend-run-history" id="run-history-table"></div>
29
- </div>
30
- {{/if}}
2
+ {{#if options.heroTrend}}
3
+ {{> chart-trend-card options=options}}
4
+ {{> chart-summary-grid options=options}}
5
+ {{else}}
6
+ {{> chart-summary-grid options=options}}
7
+ {{> chart-trend-card options=options}}
31
8
  {{/if}}
32
9
 
33
10
  {{#if options.showTrend}}
@@ -112,7 +89,7 @@
112
89
  labels: ['Passed', 'Failed', 'Timed Out', 'Skipped', 'Flaky'],
113
90
  datasets: [{
114
91
  data: [passed, failed, timedOut, skipped, flaky],
115
- backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#D49452'],
92
+ backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#CA8A04'],
116
93
  borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
117
94
  borderWidth: 2,
118
95
  hoverOffset: 4
@@ -0,0 +1,66 @@
1
+ <div class="section">
2
+ {{#unless options.releaseGateShown}}
3
+ <span class="verdict verdict--{{stats.verdict}}">
4
+ {{verdictLabel stats.verdict}}
5
+ </span>
6
+ {{/unless}}
7
+
8
+ <div class="hero-summary">
9
+ <div class="hero-summary__value">{{stats.passRate}}<span class="hero-summary__pct">%</span></div>
10
+ <div class="hero-summary__label">Pass Rate</div>
11
+ </div>
12
+
13
+ <div class="hero-stat-row">
14
+ <div class="hero-stat">
15
+ <span class="hero-stat__value">{{stats.total}}</span>
16
+ <span class="hero-stat__label">Total</span>
17
+ </div>
18
+ <div class="hero-stat{{#unless stats.failed}} hero-stat--zero{{/unless}}">
19
+ <span class="hero-stat__value" style="color:var(--fail);">{{stats.failed}}</span>
20
+ <span class="hero-stat__label">Failed</span>
21
+ </div>
22
+ <div class="hero-stat{{#unless stats.timedOut}} hero-stat--zero{{/unless}}">
23
+ <span class="hero-stat__value" style="color:var(--timeout);">{{stats.timedOut}}</span>
24
+ <span class="hero-stat__label">Timed Out</span>
25
+ </div>
26
+ <div class="hero-stat{{#unless stats.skipped}} hero-stat--zero{{/unless}}">
27
+ <span class="hero-stat__value" style="color:var(--skip);">{{stats.skipped}}</span>
28
+ <span class="hero-stat__label">Skipped</span>
29
+ </div>
30
+ <div class="hero-stat{{#unless stats.flaky}} hero-stat--zero{{/unless}}">
31
+ <span class="hero-stat__value" style="color:var(--flaky);">{{stats.flaky}}</span>
32
+ <span class="hero-stat__label">Flaky</span>
33
+ </div>
34
+ </div>
35
+
36
+ <div class="summary-row">
37
+ <div class="summary-row__item">
38
+ <span class="summary-row__label">Duration</span>
39
+ <span class="summary-row__value">{{formatDuration stats.duration}}</span>
40
+ </div>
41
+ {{#unless (or options.showPassRate @root.sections.charts)}}
42
+ <div class="summary-row__item">
43
+ <span class="summary-row__label">Pass Rate</span>
44
+ <span class="summary-row__value" style="color:var(--pass);">{{stats.passRate}}%</span>
45
+ </div>
46
+ {{/unless}}
47
+ <div class="summary-row__item">
48
+ <span class="summary-row__label">Generated</span>
49
+ <span class="summary-row__value">{{formatDate meta.generatedAt}}</span>
50
+ </div>
51
+ <div class="summary-row__item">
52
+ <span class="summary-row__label">Project</span>
53
+ <span class="summary-row__value">{{meta.projectName}}</span>
54
+ </div>
55
+ </div>
56
+
57
+ {{#if stats.flaky}}
58
+ <div class="flaky-callout">
59
+ <span class="flaky-callout__icon">&#9888;</span>
60
+ <div class="flaky-callout__body">
61
+ <span class="flaky-callout__title">{{stats.flaky}} Flaky Test{{#if (gt stats.flaky 1)}}s{{/if}} Detected</span>
62
+ <span class="flaky-callout__text">These tests passed on retry. Results may vary across re-runs. Investigate before shipping to production.</span>
63
+ </div>
64
+ </div>
65
+ {{/if}}
66
+ </div>
@@ -635,6 +635,28 @@ code {
635
635
 
636
636
  /* These section styles ship to every template so any section can be toggled in styled; ~a few KB of unused CSS in templates where the section is off is an accepted cost. */
637
637
 
638
+ /* ── Brief band — the executive lede sentence, shared so any template can
639
+ toggle briefBand on. No border/side-stripe (deliberately distinct from the
640
+ card recipe used everywhere else); a tinted surface instead. ── */
641
+ .brief-band {
642
+ display: flex; align-items: flex-start; gap: 14px;
643
+ background: var(--surface-2);
644
+ padding: 18px 22px 20px;
645
+ border-radius: 6px;
646
+ margin-bottom: 22px;
647
+ }
648
+ .brief-band__quote {
649
+ font-family: 'Source Serif Pro', Georgia, serif;
650
+ font-size: 40px; font-weight: 700;
651
+ color: var(--accent); opacity: .5; line-height: .6;
652
+ flex-shrink: 0; margin-top: 8px;
653
+ }
654
+ .brief-band__text {
655
+ font-family: 'Source Serif Pro', Georgia, serif;
656
+ font-style: italic; font-size: 15px;
657
+ color: var(--text-1); line-height: 1.55;
658
+ }
659
+
638
660
  /* Charts grid + cards (available to any template via sections.charts) */
639
661
  .charts-grid {
640
662
  display: grid; grid-template-columns: 210px 1fr;
@@ -131,6 +131,37 @@
131
131
  .chart-card h3 {
132
132
  letter-spacing: 1.3px;
133
133
  }
134
+ .charts-grid--secondary {
135
+ grid-template-columns: 1fr 1fr;
136
+ margin-top: 4px;
137
+ }
134
138
 
135
139
  /* Executive failures now render inline in the suite breakdown (compact, no
136
140
  screenshots) — card styles live in base.css. */
141
+
142
+ /* ── Hero summary — executive-exclusive replacement for the 6-card KPI strip ── */
143
+ .hero-summary { text-align: center; padding: 18px 0 2px; }
144
+ .hero-summary__value {
145
+ font-family: 'Source Serif Pro', Georgia, serif;
146
+ font-size: 68px; font-weight: 700; color: var(--pass);
147
+ letter-spacing: -1.5px; line-height: 1;
148
+ }
149
+ .hero-summary__pct { font-size: 32px; }
150
+ .hero-summary__label {
151
+ font-family: 'Inter', system-ui, sans-serif; font-size: 10px; font-weight: 600;
152
+ letter-spacing: 1.4px; text-transform: uppercase; color: var(--text-3); margin-top: 6px;
153
+ }
154
+ .hero-stat-row { display: flex; justify-content: center; gap: 0; margin: 18px 0 24px; }
155
+ .hero-stat {
156
+ display: flex; flex-direction: column; align-items: center;
157
+ padding: 0 22px; border-right: 1px solid var(--border);
158
+ }
159
+ .hero-stat:last-child { border-right: none; }
160
+ .hero-stat--zero { opacity: .45; }
161
+ .hero-stat__value {
162
+ font-family: 'Source Serif Pro', Georgia, serif; font-size: 20px; font-weight: 700; color: var(--text-1);
163
+ }
164
+ .hero-stat__label {
165
+ display: block; font-family: 'Inter', system-ui, sans-serif; font-size: 8px; font-weight: 600;
166
+ letter-spacing: 1px; text-transform: uppercase; color: var(--text-3); margin-top: 4px;
167
+ }
@@ -6,9 +6,17 @@
6
6
  padding: 20px 22px;
7
7
  background: var(--surface);
8
8
  border: 1px solid var(--border-2);
9
- border-left: 3px solid var(--primary, #CC785C);
10
9
  border-radius: 6px;
11
10
  margin-bottom: 22px;
11
+ position: relative;
12
+ overflow: hidden;
13
+ }
14
+ .report-header::before {
15
+ content: '';
16
+ position: absolute; top: 0; left: 0; right: 0; height: 3px;
17
+ background: var(--accent);
18
+ -webkit-print-color-adjust: exact;
19
+ print-color-adjust: exact;
12
20
  }
13
21
  .report-header__logo { height: 36px; width: auto; }
14
22
  .report-header__title {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
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",