@reportforge/playwright-pdf 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -10994,11 +10994,33 @@ var STRENGTH = {
10994
10994
  strongMinMargin: 0.25
10995
10995
  };
10996
10996
  var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
10997
- var FLAKY_MARGIN = 999;
10997
+ var OVERRIDE_MARGIN = 999;
10998
10998
 
10999
10999
  // src/analysis/FailureClusterer.ts
11000
11000
  var MAX_TESTS_PER_CLUSTER = 20;
11001
11001
  var MAX_MESSAGE_CHARS = 200;
11002
+ function errorHeader(message) {
11003
+ const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11004
+ return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11005
+ }
11006
+ function isAssertionTimeout(message) {
11007
+ return /Expect "[^"]+" with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11008
+ }
11009
+ function isNetwork(message) {
11010
+ return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
11011
+ }
11012
+ function isNavigation(message) {
11013
+ return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
11014
+ }
11015
+ function isLocatorNotFound(message) {
11016
+ return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
11017
+ }
11018
+ var MESSAGE_RULES = [
11019
+ [isAssertionTimeout, "assertion"],
11020
+ [isNavigation, "navigation"],
11021
+ [isNetwork, "network"],
11022
+ [isLocatorNotFound, "locator-not-found"]
11023
+ ];
11002
11024
  function strengthOf(margin) {
11003
11025
  if (margin >= STRENGTH.strongMinMargin) return "strong";
11004
11026
  if (margin >= STRENGTH.moderateMinMargin) return "moderate";
@@ -11021,11 +11043,15 @@ function representativeMessage(message) {
11021
11043
  return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
11022
11044
  }
11023
11045
  function classifyOne(f, model) {
11046
+ const frame = topFrame(f.error.stack);
11024
11047
  if (f.retryHistory.some((r) => r.status === "passed")) {
11025
- return { category: "flaky-by-retry", margin: FLAKY_MARGIN, failure: f, frame: topFrame(f.error.stack) };
11048
+ return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, failure: f, frame };
11049
+ }
11050
+ for (const [match, category] of MESSAGE_RULES) {
11051
+ if (match(f.error.message)) return { category, margin: OVERRIDE_MARGIN, failure: f, frame };
11026
11052
  }
11027
11053
  const { label, margin } = classify(model, classifyInput(f));
11028
- return { category: label, margin, failure: f, frame: topFrame(f.error.stack) };
11054
+ return { category: label, margin, failure: f, frame };
11029
11055
  }
11030
11056
  function classifyAll(failures, model) {
11031
11057
  return failures.map((f) => classifyOne(f, model));
@@ -11234,11 +11260,13 @@ init_esm_shims();
11234
11260
 
11235
11261
  // src/collector/stats-utils.ts
11236
11262
  init_esm_shims();
11263
+ var TOP_SUITES_FOR_CHART = 10;
11237
11264
  function statsFromTests(tests) {
11238
11265
  return {
11239
11266
  total: tests.length,
11240
11267
  passed: tests.filter((t) => t.status === "passed").length,
11241
- failed: tests.filter((t) => t.status === "failed" || t.status === "timedOut").length,
11268
+ failed: tests.filter((t) => t.status === "failed").length,
11269
+ timedOut: tests.filter((t) => t.status === "timedOut").length,
11242
11270
  skipped: tests.filter((t) => t.status === "skipped").length,
11243
11271
  flaky: tests.filter((t) => t.status === "flaky").length
11244
11272
  };
@@ -11249,12 +11277,32 @@ function aggregateStats(parts) {
11249
11277
  total: acc.total + s.total,
11250
11278
  passed: acc.passed + s.passed,
11251
11279
  failed: acc.failed + s.failed,
11280
+ timedOut: acc.timedOut + s.timedOut,
11252
11281
  skipped: acc.skipped + s.skipped,
11253
11282
  flaky: acc.flaky + s.flaky
11254
11283
  }),
11255
- { total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 }
11284
+ { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11256
11285
  );
11257
11286
  }
11287
+ function toRow(s) {
11288
+ return {
11289
+ label: s.title,
11290
+ passed: s.stats.passed,
11291
+ failed: s.stats.failed,
11292
+ timedOut: s.stats.timedOut,
11293
+ skipped: s.stats.skipped
11294
+ };
11295
+ }
11296
+ function buildSuiteResults(projects, limit) {
11297
+ const top = projects.flatMap((p) => p.suites);
11298
+ if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11299
+ const file = top[0];
11300
+ const fs14 = statsFromTests(file.tests);
11301
+ const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
11302
+ return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
11303
+ }
11304
+ return top.map(toRow).slice(0, limit);
11305
+ }
11258
11306
 
11259
11307
  // src/collector/SuiteWalker.ts
11260
11308
  var SuiteWalker = class {
@@ -11314,6 +11362,18 @@ var SuiteWalker = class {
11314
11362
  }
11315
11363
  };
11316
11364
 
11365
+ // src/collector/severity.ts
11366
+ init_esm_shims();
11367
+ var SEVERITY_RANK = {
11368
+ critical: 0,
11369
+ high: 1,
11370
+ medium: 2,
11371
+ low: 3
11372
+ };
11373
+ function sortBySeverity(failures) {
11374
+ return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11375
+ }
11376
+
11317
11377
  // src/utils/env.ts
11318
11378
  init_esm_shims();
11319
11379
  import { execSync } from "child_process";
@@ -11403,7 +11463,6 @@ function gitCommit() {
11403
11463
  }
11404
11464
 
11405
11465
  // src/collector/DataCollector.ts
11406
- var TOP_SUITES_FOR_CHART = 10;
11407
11466
  var DataCollector = class {
11408
11467
  constructor() {
11409
11468
  this.resultMap = /* @__PURE__ */ new Map();
@@ -11440,13 +11499,13 @@ var DataCollector = class {
11440
11499
  finalize(fullResult) {
11441
11500
  const walker = new SuiteWalker();
11442
11501
  const projects = walker.build(this.rootSuite, this.resultMap);
11443
- const stats = this.computeStats(projects, fullResult);
11502
+ const stats = this.computeStats(fullResult);
11444
11503
  const environment = this.buildEnvironment();
11445
11504
  const charts = this.buildChartData(stats, projects);
11446
11505
  return {
11447
11506
  projects,
11448
11507
  stats,
11449
- failures: [...this.failureRecords],
11508
+ failures: sortBySeverity(this.failureRecords),
11450
11509
  environment,
11451
11510
  charts
11452
11511
  };
@@ -11490,13 +11549,16 @@ var DataCollector = class {
11490
11549
  if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11491
11550
  return "medium";
11492
11551
  }
11493
- computeStats(projects, fullResult) {
11552
+ computeStats(fullResult) {
11494
11553
  let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11495
11554
  for (const [, result] of this.resultMap) {
11496
11555
  total++;
11497
11556
  switch (result.status) {
11557
+ // A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
11558
+ // Counting it in `passed` too would double-count it against `flaky` and
11559
+ // push passRate over 100%.
11498
11560
  case "passed":
11499
- passed++;
11561
+ result.retry > 0 ? flaky++ : passed++;
11500
11562
  break;
11501
11563
  case "failed":
11502
11564
  failed++;
@@ -11513,13 +11575,6 @@ var DataCollector = class {
11513
11575
  break;
11514
11576
  }
11515
11577
  }
11516
- for (const project of projects) {
11517
- for (const suite of this.flattenSuites(project.suites)) {
11518
- for (const test of suite.tests) {
11519
- if (test.status === "flaky") flaky++;
11520
- }
11521
- }
11522
- }
11523
11578
  const duration = fullResult.duration ?? Date.now() - this.startTime;
11524
11579
  const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
11525
11580
  return {
@@ -11565,27 +11620,18 @@ var DataCollector = class {
11565
11620
  };
11566
11621
  }
11567
11622
  buildChartData(stats, projects) {
11568
- const suiteResults = projects.flatMap(
11569
- (p) => p.suites.map((s) => ({
11570
- label: s.title,
11571
- passed: s.stats.passed,
11572
- failed: s.stats.failed,
11573
- skipped: s.stats.skipped
11574
- }))
11575
- ).slice(0, TOP_SUITES_FOR_CHART);
11623
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11576
11624
  return {
11577
11625
  passRate: {
11578
11626
  passed: stats.passed,
11579
11627
  failed: stats.failed,
11628
+ timedOut: stats.timedOut,
11580
11629
  skipped: stats.skipped,
11581
11630
  flaky: stats.flaky
11582
11631
  },
11583
11632
  suiteResults
11584
11633
  };
11585
11634
  }
11586
- flattenSuites(suites) {
11587
- return suites.flatMap((s) => [s, ...this.flattenSuites(s.suites)]);
11588
- }
11589
11635
  };
11590
11636
 
11591
11637
  // src/collector/ShardMerger.ts
@@ -11593,15 +11639,14 @@ init_esm_shims();
11593
11639
  import { readFileSync } from "fs";
11594
11640
  import { globSync } from "glob";
11595
11641
  import { createRequire } from "module";
11596
- var TOP_SUITES_FOR_CHART2 = 10;
11597
11642
  var ShardMerger = class {
11598
11643
  merge(filePaths) {
11599
11644
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11600
11645
  const resolved = this.resolveShardPaths(filePaths);
11601
11646
  const reports = this.loadReports(resolved);
11602
11647
  const projects = this.mergeProjects(reports);
11603
- const stats = this.mergeStats(reports);
11604
- const failures = this.extractFailures(reports);
11648
+ const stats = this.mergeStats(reports, projects);
11649
+ const failures = sortBySeverity(this.extractFailures(reports));
11605
11650
  const environment = this.buildEnvironment(reports[0]);
11606
11651
  const charts = this.buildCharts(stats, projects);
11607
11652
  return { projects, stats, failures, environment, charts };
@@ -11708,7 +11753,7 @@ var ShardMerger = class {
11708
11753
  id: spec.id,
11709
11754
  title: spec.title,
11710
11755
  fullTitle: [...suitePath, spec.title].join(" > "),
11711
- status: this.resolveStatus(test),
11756
+ status: this.resolveStatus(test, lastResult),
11712
11757
  duration: lastResult?.duration ?? 0,
11713
11758
  retryCount: test ? test.results.length - 1 : 0,
11714
11759
  tags: spec.tags ?? [],
@@ -11716,8 +11761,9 @@ var ShardMerger = class {
11716
11761
  location: `${spec.file}:${spec.line}`
11717
11762
  };
11718
11763
  }
11719
- resolveStatus(test) {
11764
+ resolveStatus(test, lastResult) {
11720
11765
  if (!test) return "skipped";
11766
+ if (lastResult?.status === "timedOut") return "timedOut";
11721
11767
  switch (test.status) {
11722
11768
  case "expected":
11723
11769
  return "passed";
@@ -11731,7 +11777,7 @@ var ShardMerger = class {
11731
11777
  return "failed";
11732
11778
  }
11733
11779
  }
11734
- mergeStats(reports) {
11780
+ mergeStats(reports, projects) {
11735
11781
  let expected = 0, unexpected = 0, flaky = 0, skipped = 0, maxDuration = 0;
11736
11782
  for (const r of reports) {
11737
11783
  expected += r.stats.expected;
@@ -11742,13 +11788,14 @@ var ShardMerger = class {
11742
11788
  }
11743
11789
  const total = expected + unexpected + flaky + skipped;
11744
11790
  const passRate = total > 0 ? Math.round((expected + flaky) / total * 100) : 0;
11791
+ const timedOut = projects.reduce((n, p) => n + p.stats.timedOut, 0);
11745
11792
  return {
11746
11793
  total,
11747
11794
  passed: expected,
11748
- failed: unexpected,
11795
+ failed: Math.max(0, unexpected - timedOut),
11749
11796
  skipped,
11750
11797
  flaky,
11751
- timedOut: 0,
11798
+ timedOut,
11752
11799
  duration: maxDuration,
11753
11800
  passRate,
11754
11801
  verdict: unexpected > 0 ? "failed" : "passed"
@@ -11826,18 +11873,13 @@ var ShardMerger = class {
11826
11873
  };
11827
11874
  }
11828
11875
  buildCharts(stats, projects) {
11829
- const suiteResults = projects.flatMap(
11830
- (p) => p.suites.map((s) => ({
11831
- label: s.title,
11832
- passed: s.stats.passed,
11833
- failed: s.stats.failed,
11834
- skipped: s.stats.skipped
11835
- }))
11836
- ).slice(0, TOP_SUITES_FOR_CHART2);
11876
+ const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
11837
11877
  return {
11838
11878
  passRate: {
11839
11879
  passed: stats.passed,
11840
11880
  failed: stats.failed,
11881
+ timedOut: stats.timedOut,
11882
+ // always 0 in shard JSON (no separate timedOut count)
11841
11883
  skipped: stats.skipped,
11842
11884
  flaky: stats.flaky
11843
11885
  },
@@ -11869,6 +11911,9 @@ function formatDuration(ms) {
11869
11911
  }
11870
11912
 
11871
11913
  // src/templates/engine.ts
11914
+ function jsonForInlineScript(value) {
11915
+ return JSON.stringify(value).replace(/<\//g, "<\\/");
11916
+ }
11872
11917
  var TemplateEngine = class {
11873
11918
  constructor() {
11874
11919
  this.chartjsInline = "";
@@ -12052,6 +12097,19 @@ var TemplateEngine = class {
12052
12097
  "upperCase",
12053
12098
  (s) => typeof s === "string" ? s.toUpperCase() : s
12054
12099
  );
12100
+ this.handlebars.registerHelper("verdictLabel", (v) => {
12101
+ const map = {
12102
+ passed: "PASSED",
12103
+ failed: "FAILED",
12104
+ timedout: "TIMED OUT",
12105
+ interrupted: "INTERRUPTED"
12106
+ };
12107
+ return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
12108
+ });
12109
+ this.handlebars.registerHelper("coverageBand", (pct) => {
12110
+ const n = Number(pct);
12111
+ return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
12112
+ });
12055
12113
  this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
12056
12114
  this.handlebars.registerHelper(
12057
12115
  "join",
@@ -12071,7 +12129,7 @@ var TemplateEngine = class {
12071
12129
  if (!Array.isArray(arr)) return "";
12072
12130
  const values = arr.map((item) => {
12073
12131
  const v = item[key];
12074
- return typeof v === "string" ? JSON.stringify(v) : String(v ?? 0);
12132
+ return typeof v === "string" ? jsonForInlineScript(v) : String(v ?? 0);
12075
12133
  });
12076
12134
  return new import_handlebars.default.SafeString(values.join(","));
12077
12135
  }
@@ -12082,7 +12140,7 @@ var TemplateEngine = class {
12082
12140
  this.handlebars.registerHelper("absFixed", (n) => Math.abs(n).toFixed(0));
12083
12141
  this.handlebars.registerHelper(
12084
12142
  "safeJsonData",
12085
- (value) => new import_handlebars.default.SafeString(JSON.stringify(value).replace(/<\//g, "<\\/"))
12143
+ (value) => new import_handlebars.default.SafeString(jsonForInlineScript(value))
12086
12144
  );
12087
12145
  this.handlebars.registerHelper(
12088
12146
  "reverse",
@@ -13069,7 +13127,7 @@ var PdfReporter = class {
13069
13127
  this.dataCollector = new DataCollector();
13070
13128
  this.pdfGenerator = new PdfGenerator();
13071
13129
  this.options = parseOptions(rawOptions);
13072
- const version = true ? "0.4.1" : "0.x";
13130
+ const version = true ? "0.5.1" : "0.x";
13073
13131
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13074
13132
  this.licenseClient = new LicenseClient({
13075
13133
  licenseKey: this.options.licenseKey,
@@ -12,14 +12,15 @@
12
12
  </div>
13
13
  </div>
14
14
 
15
+ {{> release-gate}}
16
+
15
17
  {{> executive-summary options=(obj showPassRate=true) }}
16
18
 
17
19
  {{> charts options=(obj showCharts=true showTrend=options.showTrend) }}
18
20
 
19
- <div class="page-group">
20
- {{> requirements-matrix }}
21
- {{> ci-environment options=(obj showFullEnvironment=true) }}
22
- </div>
21
+ {{> requirements-matrix }}
22
+
23
+ {{> ci-environment options=(obj showFullEnvironment=true) }}
23
24
 
24
25
  {{> suite-breakdown options=(obj showRetries=true) }}
25
26
 
@@ -12,6 +12,8 @@
12
12
  </div>
13
13
  </div>
14
14
 
15
+ {{> release-gate}}
16
+
15
17
  {{> executive-summary options=(obj showPassRate=true showCharts=false) }}
16
18
 
17
19
  {{> suite-breakdown options=(obj showRetries=true) }}
@@ -71,12 +71,14 @@
71
71
  var remaining = 0;
72
72
  function chartDone() { if (--remaining === 0) window.__chartsReady = true; }
73
73
 
74
- var passed = {{charts.passRate.passed}};
75
- var failed = {{charts.passRate.failed}};
76
- var skipped = {{charts.passRate.skipped}};
77
- var flaky = {{charts.passRate.flaky}};
78
- var total = passed + failed + skipped + flaky;
79
- var passRate = total > 0 ? Math.round((passed / total) * 100) : 0;
74
+ var passed = {{charts.passRate.passed}};
75
+ var failed = {{charts.passRate.failed}};
76
+ var timedOut = {{charts.passRate.timedOut}};
77
+ var skipped = {{charts.passRate.skipped}};
78
+ var flaky = {{charts.passRate.flaky}};
79
+ // Pass rate is the canonical server-side stat (flaky counts as pass, denominator
80
+ // is every test) so the doughnut centre can never disagree with the KPI strip.
81
+ var passRate = {{stats.passRate}};
80
82
 
81
83
  /* ── Centre-text plugin for the doughnut ─────────────────────────────── */
82
84
  var centerTextPlugin = {
@@ -107,11 +109,11 @@
107
109
  new Chart(document.getElementById('passRateChart'), {
108
110
  type: 'doughnut',
109
111
  data: {
110
- labels: ['Passed', 'Failed', 'Skipped', 'Flaky'],
112
+ labels: ['Passed', 'Failed', 'Timed Out', 'Skipped', 'Flaky'],
111
113
  datasets: [{
112
- data: [passed, failed, skipped, flaky],
113
- backgroundColor: ['#3F7D58', '#C3553C', '#7A786E', '#D49452'],
114
- borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
114
+ data: [passed, failed, timedOut, skipped, flaky],
115
+ backgroundColor: ['#3F7D58', '#C3553C', '#B5803A', '#7A786E', '#D49452'],
116
+ borderColor: ['#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5', '#FAF9F5'],
115
117
  borderWidth: 2,
116
118
  hoverOffset: 4
117
119
  }]
@@ -135,8 +137,9 @@
135
137
  /* ── Horizontal suite results bar ────────────────────────────────────── */
136
138
  var suiteLabels = [{{{jsonArray charts.suiteResults 'label'}}}];
137
139
  var suitePassed = [{{{jsonArray charts.suiteResults 'passed'}}}];
138
- var suiteFailed = [{{{jsonArray charts.suiteResults 'failed'}}}];
139
- var suiteSkipped = [{{{jsonArray charts.suiteResults 'skipped'}}}];
140
+ var suiteFailed = [{{{jsonArray charts.suiteResults 'failed'}}}];
141
+ var suiteTimedOut = [{{{jsonArray charts.suiteResults 'timedOut'}}}];
142
+ var suiteSkipped = [{{{jsonArray charts.suiteResults 'skipped'}}}];
140
143
 
141
144
  remaining += 1;
142
145
  new Chart(document.getElementById('suitesBarChart'), {
@@ -144,9 +147,10 @@
144
147
  data: {
145
148
  labels: suiteLabels,
146
149
  datasets: [
147
- { label: 'Passed', data: suitePassed, backgroundColor: '#3F7D58', borderRadius: 3 },
148
- { label: 'Failed', data: suiteFailed, backgroundColor: '#C3553C', borderRadius: 3 },
149
- { label: 'Skipped', data: suiteSkipped, backgroundColor: '#7A786E', borderRadius: 3 }
150
+ { label: 'Passed', data: suitePassed, backgroundColor: '#3F7D58', borderRadius: 3 },
151
+ { label: 'Failed', data: suiteFailed, backgroundColor: '#C3553C', borderRadius: 3 },
152
+ { label: 'Timed Out', data: suiteTimedOut, backgroundColor: '#B5803A', borderRadius: 3 },
153
+ { label: 'Skipped', data: suiteSkipped, backgroundColor: '#7A786E', borderRadius: 3 }
150
154
  ]
151
155
  },
152
156
  options: {
@@ -161,7 +165,17 @@
161
165
  },
162
166
  y: {
163
167
  stacked: true,
164
- ticks: { font: { size: 9, family: 'Inter, sans-serif' }, color: '#1F1F1C' },
168
+ // Widen the label gutter so the longest label isn't clipped at the canvas edge.
169
+ afterFit: function(scale) { if (!scale.$rfPadded) { scale.$rfPadded = true; scale.width += 8; } },
170
+ ticks: {
171
+ font: { size: 9, family: 'Inter, sans-serif' }, color: '#1F1F1C', autoSkip: false,
172
+ // Truncate long suite / describe labels so they fit the axis gutter
173
+ // instead of clipping at the canvas edge.
174
+ callback: function(value) {
175
+ var l = this.getLabelForValue(value);
176
+ return l.length > 24 ? l.slice(0, 23) + '…' : l;
177
+ }
178
+ },
165
179
  grid: { display: false },
166
180
  border: { color: '#D6D4C8' }
167
181
  }
@@ -9,7 +9,7 @@
9
9
  <div class="cover-page__divider"></div>
10
10
 
11
11
  <span class="cover-page__verdict cover-page__verdict--{{stats.verdict}}">
12
- {{upperCase stats.verdict}}
12
+ {{verdictLabel stats.verdict}}
13
13
  </span>
14
14
 
15
15
  <div class="cover-page__stats-strip">
@@ -1,6 +1,6 @@
1
1
  <div class="section">
2
2
  <span class="verdict verdict--{{stats.verdict}}">
3
- {{upperCase stats.verdict}}
3
+ {{verdictLabel stats.verdict}}
4
4
  </span>
5
5
 
6
6
  <div class="kpi-strip">
@@ -19,6 +19,10 @@
19
19
  <div class="kpi-card__value">{{stats.failed}}</div>
20
20
  <div class="kpi-card__label">Failed</div>
21
21
  </div>
22
+ <div class="kpi-card kpi-card--timedout">
23
+ <div class="kpi-card__value">{{stats.timedOut}}</div>
24
+ <div class="kpi-card__label">Timed Out</div>
25
+ </div>
22
26
  <div class="kpi-card kpi-card--skip">
23
27
  <div class="kpi-card__value">{{stats.skipped}}</div>
24
28
  <div class="kpi-card__label">Skipped</div>
@@ -1,5 +1,5 @@
1
1
  {{#if failures.length}}
2
- <div class="section page-break">
2
+ <div class="section">
3
3
  <h2>Failures ({{failures.length}}{{#if overflowFailureCount}} shown · {{overflowFailureCount}} more in sidecar{{/if}})</h2>
4
4
  {{#if overflowFailureCount}}
5
5
  <div class="overflow-banner">
@@ -9,7 +9,7 @@
9
9
  </div>
10
10
  {{/if}}
11
11
  {{#each failures}}
12
- <div class="failure-item no-break">
12
+ <div class="failure-item failure-item--{{severity}} no-break">
13
13
  {{#if ../options.showFullFailures}}
14
14
 
15
15
  <div class="failure-item__header">
@@ -23,7 +23,7 @@
23
23
  <td style="color:{{#if failed}}var(--fail){{else}}var(--pass){{/if}};font-weight:600;">{{failed}}</td>
24
24
  <td>
25
25
  <div class="coverage-bar">
26
- <div class="coverage-bar__fill" style="width:{{coveragePct}}%;"></div>
26
+ <div class="coverage-bar__fill coverage-bar__fill--{{coverageBand coveragePct}}" style="width:{{coveragePct}}%;"></div>
27
27
  </div>
28
28
  <span style="font-size:9.5px;color:var(--text-3);">{{coveragePct}}%</span>
29
29
  </td>
@@ -16,9 +16,9 @@
16
16
  <tr class="no-break">
17
17
  <td style="font-weight:500;">{{title}}</td>
18
18
  <td style="font-size:9px;color:var(--text-3);font-family:'JetBrains Mono',monospace;">{{suiteTitle}}</td>
19
- {{#if ../showProject}}<td style="font-size:9px;color:var(--text-3);">{{project}}</td>{{/if}}
19
+ {{#if ../showProject}}<td style="font-size:9px;color:var(--text-3);white-space:nowrap;">{{project}}</td>{{/if}}
20
20
  <td style="font-family:'JetBrains Mono',monospace;font-size:9.5px;color:var(--text-2);text-align:right;">{{durationFormatted}}</td>
21
- <td><span class="badge badge--{{status}}">{{status}}</span></td>
21
+ <td>{{statusBadge status}}</td>
22
22
  </tr>
23
23
  {{/each}}
24
24
  </tbody>
@@ -15,6 +15,7 @@
15
15
  <span class="suite-stats">
16
16
  <span class="badge badge--passed">{{stats.passed}}</span>
17
17
  {{#if stats.failed}}<span class="badge badge--failed">{{stats.failed}}</span>{{/if}}
18
+ {{#if stats.timedOut}}<span class="badge badge--timedout">{{stats.timedOut}}</span>{{/if}}
18
19
  {{#if stats.skipped}}<span class="badge badge--skipped">{{stats.skipped}}</span>{{/if}}
19
20
  {{#if stats.flaky}}<span class="badge badge--flaky">{{stats.flaky}}</span>{{/if}}
20
21
  </span>
@@ -41,7 +42,7 @@
41
42
  </div>
42
43
  {{/if}}
43
44
  </td>
44
- <td><span class="badge badge--{{status}}">{{status}}</span></td>
45
+ <td>{{statusBadge status}}</td>
45
46
  <td style="color:var(--text-2);font-family:'JetBrains Mono',monospace;font-size:9.5px;">{{formatDuration duration}}</td>
46
47
  {{#if ../../options.showRetries}}<td style="color:var(--text-3);text-align:center;">{{retryCount}}</td>{{/if}}
47
48
  </tr>