@reportforge/playwright-pdf 0.6.3 → 0.7.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
@@ -10330,7 +10330,8 @@ var DEFAULT_OPTIONS = {
10330
10330
  maxFileSizeMb: 8,
10331
10331
  historySize: 10,
10332
10332
  showTrend: true,
10333
- flakinessTopN: 5
10333
+ flakinessTopN: 5,
10334
+ slowTestThreshold: 10
10334
10335
  };
10335
10336
 
10336
10337
  // src/templates/sections.ts
@@ -10407,7 +10408,10 @@ var SECTION_DEFAULTS = {
10407
10408
  trend: true,
10408
10409
  requirementsMatrix: false,
10409
10410
  ciEnvironment: true,
10410
- suiteBreakdown: false,
10411
+ // Suite breakdown now hosts the inline failure detail + slow badge, so the
10412
+ // executive layout renders it too (compact: fullFailures stays false → no
10413
+ // screenshots; failureAnalysis stays off → no per-test chip).
10414
+ suiteBreakdown: true,
10411
10415
  failureDeepDive: true,
10412
10416
  failureAnalysis: false,
10413
10417
  slowTests: true,
@@ -10434,6 +10438,7 @@ function resolveSections(template, userSections) {
10434
10438
  }
10435
10439
  const result = { ...base, ...flat, ...perTemplate };
10436
10440
  if (!result.charts) result.trend = false;
10441
+ if (!result.failureDeepDive) result.failureAnalysis = false;
10437
10442
  return result;
10438
10443
  }
10439
10444
  function allSectionsOn() {
@@ -10510,6 +10515,7 @@ var ReporterOptionsSchema = external_exports.object({
10510
10515
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10511
10516
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10512
10517
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10518
+ slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10513
10519
  failureAnalysis: failureAnalysisConfigSchema,
10514
10520
  templatePath: external_exports.union([
10515
10521
  external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
@@ -11173,6 +11179,14 @@ function classifyOne(f, model) {
11173
11179
  function classifyAll(failures, model) {
11174
11180
  return failures.map((f) => classifyOne(f, model));
11175
11181
  }
11182
+ function perTestClassifications(classified) {
11183
+ return classified.map((c) => ({
11184
+ testId: c.failure.testId,
11185
+ category: c.category,
11186
+ message: representativeMessage(c.failure.error.message),
11187
+ strength: strengthOf(c.margin)
11188
+ }));
11189
+ }
11176
11190
  function clusterClassified(classified) {
11177
11191
  const groups = /* @__PURE__ */ new Map();
11178
11192
  for (const c of classified) {
@@ -11348,6 +11362,7 @@ function runFailureAnalysis(reportData, options, cwd) {
11348
11362
  const analysed = reportData.failures.slice(0, cap);
11349
11363
  const model = loadModel(fa.autoUpdateModel);
11350
11364
  const classified = classifyAll(analysed, model);
11365
+ reportData.classifications = perTestClassifications(classified);
11351
11366
  if (templatesConsumeAnalysis(options)) {
11352
11367
  reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
11353
11368
  }
@@ -12034,6 +12049,7 @@ function jsonForInlineScript(value) {
12034
12049
  function isUnknownValue(v) {
12035
12050
  return !v || v === "unknown";
12036
12051
  }
12052
+ var SLOW_MEDIAN_FACTOR = 2;
12037
12053
  function deriveOptions(s) {
12038
12054
  return {
12039
12055
  showCharts: s.charts,
@@ -12070,17 +12086,17 @@ var TemplateEngine = class {
12070
12086
  setChartjsBundle(inline) {
12071
12087
  this.chartjsInline = inline;
12072
12088
  }
12073
- render(data, template, userSections) {
12089
+ render(data, template, userSections, slowTestThreshold) {
12074
12090
  const layoutPath = path7.join(this.templatesDir, "layouts", `${template}.hbs`);
12075
12091
  if (!fs5.existsSync(layoutPath)) {
12076
12092
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12077
12093
  }
12078
12094
  const layoutSource = fs5.readFileSync(layoutPath, "utf8");
12079
12095
  const compiledLayout = this.handlebars.compile(layoutSource);
12080
- const ctx = this.buildContext(data, template, userSections);
12096
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
12081
12097
  return compiledLayout(ctx);
12082
12098
  }
12083
- renderFromPath(data, filePath) {
12099
+ renderFromPath(data, filePath, slowTestThreshold) {
12084
12100
  if (!this.customTemplateCache.has(filePath)) {
12085
12101
  if (!fs5.existsSync(filePath)) {
12086
12102
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -12089,32 +12105,41 @@ var TemplateEngine = class {
12089
12105
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12090
12106
  }
12091
12107
  const compiledFn = this.customTemplateCache.get(filePath);
12092
- return compiledFn(this.buildCustomContext(data));
12108
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
12093
12109
  }
12094
- buildCustomContext(data) {
12110
+ buildCustomContext(data, slowTestThreshold) {
12095
12111
  const sections = allSectionsOn();
12112
+ const slow = this.buildSlowTests(data);
12096
12113
  return {
12097
12114
  ...data,
12115
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
12098
12116
  options: deriveOptions(sections),
12099
12117
  sections,
12100
12118
  tagGroups: this.buildTagGroups(data),
12101
- slowTests: this.buildSlowTests(data),
12119
+ slowTests: slow.ranked,
12102
12120
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12103
12121
  templateCSS: "",
12104
12122
  chartjsScript: this.buildChartjsScript()
12105
12123
  };
12106
12124
  }
12107
- buildContext(data, template, userSections) {
12125
+ buildContext(data, template, userSections, slowTestThreshold) {
12108
12126
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
12109
12127
  const templateCSS = this.readStyle(`${template}.css`);
12110
12128
  const sections = resolveSections(template, userSections);
12111
12129
  const options = deriveOptions(sections);
12130
+ const slow = this.buildSlowTests(data);
12112
12131
  return {
12113
12132
  ...data,
12133
+ projects: this.enrichProjects(
12134
+ data,
12135
+ slow,
12136
+ sections,
12137
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
12138
+ ),
12114
12139
  options,
12115
12140
  sections,
12116
12141
  tagGroups: this.buildTagGroups(data),
12117
- slowTests: this.buildSlowTests(data),
12142
+ slowTests: slow.ranked,
12118
12143
  baseCSS,
12119
12144
  templateCSS,
12120
12145
  chartjsScript: this.buildChartjsScript()
@@ -12151,6 +12176,7 @@ var TemplateEngine = class {
12151
12176
  for (const test of suite.tests) {
12152
12177
  if (test.duration > 0) {
12153
12178
  tests.push({
12179
+ id: test.id,
12154
12180
  title: test.title,
12155
12181
  suiteTitle: suite.title,
12156
12182
  durationMs: test.duration,
@@ -12162,7 +12188,39 @@ var TemplateEngine = class {
12162
12188
  }
12163
12189
  }
12164
12190
  }
12165
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12191
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
12192
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
12193
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12194
+ return { ranked, timedTotal: tests.length, medianMs };
12195
+ }
12196
+ /**
12197
+ * Project the suite tree into its render form: attach each test's failure detail
12198
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
12199
+ * ReportData is never mutated. Gating lives here (not the template): a field is
12200
+ * attached only when its section is on, so the template just checks presence.
12201
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
12202
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
12203
+ */
12204
+ enrichProjects(data, slow, sections, slowTestThreshold) {
12205
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
12206
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12207
+ const slowRankById = /* @__PURE__ */ new Map();
12208
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12209
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
12210
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
12211
+ }
12212
+ const enrichTest = (t) => ({
12213
+ ...t,
12214
+ failure: failuresById.get(t.id),
12215
+ chip: chipById.get(t.id),
12216
+ slowRank: slowRankById.get(t.id)
12217
+ });
12218
+ const enrichSuite = (s) => ({
12219
+ ...s,
12220
+ suites: s.suites.map(enrichSuite),
12221
+ tests: s.tests.map(enrichTest)
12222
+ });
12223
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
12166
12224
  }
12167
12225
  readStyle(filename) {
12168
12226
  const cached = this.styleCache.get(filename);
@@ -12841,7 +12899,7 @@ var PdfGenerator = class {
12841
12899
  }
12842
12900
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
12843
12901
  logger.info(`Rendering template: ${templateLabel}`);
12844
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
12902
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
12845
12903
  const tempHtmlPath = await this.htmlWriter.write(html);
12846
12904
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12847
12905
  try {
@@ -13238,7 +13296,7 @@ var PdfReporter = class {
13238
13296
  this.dataCollector = new DataCollector();
13239
13297
  this.pdfGenerator = new PdfGenerator();
13240
13298
  this.options = parseOptions(rawOptions);
13241
- const version = true ? "0.6.3" : "0.x";
13299
+ const version = true ? "0.7.1" : "0.x";
13242
13300
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13243
13301
  this.licenseClient = new LicenseClient({
13244
13302
  licenseKey: this.options.licenseKey,
@@ -18,7 +18,7 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
@@ -26,13 +26,7 @@
26
26
 
27
27
  {{#if sections.ciEnvironment}}{{> ci-environment options=(obj showFullEnvironment=sections.fullEnvironment) }}{{/if}}
28
28
 
29
- {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries) }}{{/if}}
30
-
31
- {{#if sections.failureDeepDive}}{{> failure-deep-dive options=(obj showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
32
-
33
- {{#if sections.failureAnalysis}}{{> failure-analysis}}{{/if}}
34
-
35
- {{#if sections.slowTests}}{{> slow-tests limit=10 showProject=true}}{{/if}}
29
+ {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
36
30
 
37
31
  {{#if sections.defectLog}}{{> defect-log }}{{/if}}
38
32
 
@@ -18,23 +18,17 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
25
25
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
26
26
 
27
- {{#if sections.slowTests}}{{> slow-tests limit=5 showProject=false}}{{/if}}
28
-
29
- {{#if sections.failureDeepDive}}{{> failure-deep-dive options=(obj showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
30
-
31
- {{#if sections.failureAnalysis}}{{> failure-analysis}}{{/if}}
32
-
33
27
  {{#if sections.defectLog}}{{> defect-log }}{{/if}}
34
28
 
35
29
  {{#if sections.ciEnvironment}}{{> ci-environment options=(obj showFullEnvironment=sections.fullEnvironment) }}{{/if}}
36
30
 
37
- {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries) }}{{/if}}
31
+ {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
38
32
 
39
33
  <div class="report-footer">
40
34
  <span>{{meta.projectName}} &nbsp;·&nbsp; Playwright Test Report</span>
@@ -18,19 +18,13 @@
18
18
 
19
19
  {{#if sections.releaseGate}}{{> release-gate}}{{/if}}
20
20
 
21
- {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate) }}{{/if}}
21
+ {{#if sections.summary}}{{> executive-summary options=(obj showPassRate=sections.passRate releaseGateShown=sections.releaseGate) }}{{/if}}
22
22
 
23
23
  {{#if sections.charts}}{{> charts options=(obj showCharts=true showTrend=sections.trend) }}{{/if}}
24
24
 
25
25
  {{#if sections.requirementsMatrix}}{{> requirements-matrix }}{{/if}}
26
26
 
27
- {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries) }}{{/if}}
28
-
29
- {{#if sections.failureDeepDive}}{{> failure-deep-dive options=(obj showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
30
-
31
- {{#if sections.failureAnalysis}}{{> failure-analysis}}{{/if}}
32
-
33
- {{#if sections.slowTests}}{{> slow-tests limit=10 showProject=true}}{{/if}}
27
+ {{#if sections.suiteBreakdown}}{{> suite-breakdown options=(obj showRetries=sections.retries showFullFailures=sections.fullFailures showStackTraces=sections.stackTraces) }}{{/if}}
34
28
 
35
29
  {{#if sections.defectLog}}{{> defect-log }}{{/if}}
36
30
 
@@ -166,14 +166,14 @@
166
166
  y: {
167
167
  stacked: true,
168
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 += 12; } },
169
+ afterFit: function(scale) { if (!scale.$rfPadded) { scale.$rfPadded = true; scale.width += 30; } },
170
170
  ticks: {
171
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.
172
+ // Truncate only very long suite / describe labels; the wider gutter above
173
+ // lets most labels show in full instead of clipping at the canvas edge.
174
174
  callback: function(value) {
175
175
  var l = this.getLabelForValue(value);
176
- return l.length > 18 ? l.slice(0, 17) + '…' : l;
176
+ return l.length > 24 ? l.slice(0, 23) + '…' : l;
177
177
  }
178
178
  },
179
179
  grid: { display: false },
@@ -215,11 +215,17 @@
215
215
 
216
216
  // Smart y-axis: pad above/below the actual data range so variation is readable
217
217
  // rather than a flat line at the top of a 0–100 axis when all runs pass at 96%.
218
+ // Pick the step FIRST, then snap the bounds to multiples of that step, so every
219
+ // tick (min, min+step, … max) is a round value — never 45% / 70% from a min that
220
+ // isn't itself a multiple of the step. Clamps to 0/100 keep the bounds round too.
218
221
  var minRate = Math.min.apply(null, trendRates);
219
222
  var maxRate = Math.max.apply(null, trendRates);
220
223
  var yPad = Math.max(8, Math.round((maxRate - minRate) * 0.4));
221
- var yMin = Math.max(0, minRate - yPad);
222
- var yMax = Math.min(100, maxRate + yPad);
224
+ var rawMin = Math.max(0, minRate - yPad);
225
+ var rawMax = Math.min(100, maxRate + yPad);
226
+ var yStep = (rawMax - rawMin) <= 20 ? 5 : (rawMax - rawMin) <= 50 ? 10 : 25;
227
+ var yMin = Math.max(0, Math.floor(rawMin / yStep) * yStep);
228
+ var yMax = Math.min(100, Math.ceil(rawMax / yStep) * yStep);
223
229
 
224
230
  // Data-label plugin: renders pass-rate % above each point.
225
231
  // Canvas ID guard prevents firing on the doughnut and bar charts.
@@ -279,7 +285,7 @@
279
285
  callback: function(v) { return v + '%'; },
280
286
  font: { size: 8.5, family: 'Inter, sans-serif' },
281
287
  color: '#8C8B82',
282
- count: 5,
288
+ stepSize: yStep,
283
289
  },
284
290
  grid: { color: 'rgba(31,31,28,0.07)' },
285
291
  border: { color: 'transparent' },
@@ -1,7 +1,13 @@
1
1
  <div class="section">
2
+ {{!-- The release gate (when the layout renders it) already states the verdict
3
+ prominently, so the chip would just repeat it — suppress it then. The layout
4
+ passes options.releaseGateShown; it is absent for custom templates, so the
5
+ chip renders by default there (a custom template may not include a gate). --}}
6
+ {{#unless options.releaseGateShown}}
2
7
  <span class="verdict verdict--{{stats.verdict}}">
3
8
  {{verdictLabel stats.verdict}}
4
9
  </span>
10
+ {{/unless}}
5
11
 
6
12
  <div class="kpi-strip">
7
13
  <div class="kpi-card kpi-card--total">
@@ -15,19 +21,21 @@
15
21
  <div class="kpi-card__rate">{{stats.passRate}}%</div>
16
22
  {{/if}}
17
23
  </div>
18
- <div class="kpi-card kpi-card--fail">
24
+ {{!-- Zero-value status cards are dimmed: a coloured "0" for an absent status
25
+ draws the eye to nothing and dilutes the counts that matter. --}}
26
+ <div class="kpi-card kpi-card--fail{{#unless stats.failed}} kpi-card--zero{{/unless}}">
19
27
  <div class="kpi-card__value">{{stats.failed}}</div>
20
28
  <div class="kpi-card__label">Failed</div>
21
29
  </div>
22
- <div class="kpi-card kpi-card--timedout">
30
+ <div class="kpi-card kpi-card--timedout{{#unless stats.timedOut}} kpi-card--zero{{/unless}}">
23
31
  <div class="kpi-card__value">{{stats.timedOut}}</div>
24
32
  <div class="kpi-card__label">Timed Out</div>
25
33
  </div>
26
- <div class="kpi-card kpi-card--skip">
34
+ <div class="kpi-card kpi-card--skip{{#unless stats.skipped}} kpi-card--zero{{/unless}}">
27
35
  <div class="kpi-card__value">{{stats.skipped}}</div>
28
36
  <div class="kpi-card__label">Skipped</div>
29
37
  </div>
30
- <div class="kpi-card kpi-card--flaky">
38
+ <div class="kpi-card kpi-card--flaky{{#unless stats.flaky}} kpi-card--zero{{/unless}}">
31
39
  <div class="kpi-card__value">{{stats.flaky}}</div>
32
40
  <div class="kpi-card__label">Flaky</div>
33
41
  </div>
@@ -38,10 +46,16 @@
38
46
  <span class="summary-row__label">Duration</span>
39
47
  <span class="summary-row__value">{{formatDuration stats.duration}}</span>
40
48
  </div>
49
+ {{!-- Pass rate normally shows on the Passed KPI card and the doughnut centre, so
50
+ it is omitted here to avoid a third restatement on one page. Fallback: if
51
+ BOTH of those are off (passRate modifier off AND charts off, e.g. a stripped
52
+ minimal), show it here so the headline metric never disappears entirely. --}}
53
+ {{#unless (or options.showPassRate @root.sections.charts)}}
41
54
  <div class="summary-row__item">
42
55
  <span class="summary-row__label">Pass Rate</span>
43
56
  <span class="summary-row__value" style="color:var(--pass);">{{stats.passRate}}%</span>
44
57
  </div>
58
+ {{/unless}}
45
59
  <div class="summary-row__item">
46
60
  <span class="summary-row__label">Generated</span>
47
61
  <span class="summary-row__value">{{formatDate meta.generatedAt}}</span>
@@ -0,0 +1,27 @@
1
+ {{!-- Inline failure detail, rendered inside the single .failure-detail block in
2
+ the suite breakdown (which provides the rail, indent, and page-break guard).
3
+ The test row already shows the title/location, so this is error + screenshot
4
+ + retry history only. Caller passes the context:
5
+ failure — the FailureRecord for this test
6
+ showFull — include the screenshot (compact templates pass false)
7
+ showStack — render the stack trace instead of the bare message --}}
8
+ {{#if (and failure.error.stack showStack)}}
9
+ <pre>{{failure.error.stack}}</pre>
10
+ {{else}}
11
+ <div class="failure-item__message">{{failure.error.message}}</div>
12
+ {{/if}}
13
+
14
+ {{#if (and showFull failure.screenshotBase64)}}
15
+ <img class="screenshot" src="{{failure.screenshotBase64}}" alt="Screenshot at failure">
16
+ {{/if}}
17
+
18
+ {{#if failure.retryHistory.length}}
19
+ <div class="retry-history">
20
+ Retry history:&nbsp;
21
+ {{#each failure.retryHistory}}
22
+ <span class="retry-attempt retry-attempt--{{status}}">
23
+ #{{addOne attempt}} {{status}} · {{formatDuration duration}}
24
+ </span>
25
+ {{/each}}
26
+ </div>
27
+ {{/if}}
@@ -1,5 +1,12 @@
1
1
  <div class="section">
2
2
  <h2>Suite Breakdown</h2>
3
+ {{#if (and overflowFailureCount sections.failureDeepDive)}}
4
+ <div class="overflow-banner">
5
+ Inline failure detail covers the first {{failures.length}} failures.
6
+ {{overflowFailureCount}} more {{#if (gt overflowFailureCount 1)}}are{{else}}is{{/if}}
7
+ available in <code>{{overflowSidecarName}}</code> alongside this report.
8
+ </div>
9
+ {{/if}}
3
10
  {{#each projects}}
4
11
  <h3 style="color:var(--primary,#CC785C);margin-bottom:8px;">{{name}}</h3>
5
12
  {{#each suites}}
@@ -25,14 +32,14 @@
25
32
  <thead>
26
33
  <tr>
27
34
  <th>Test</th>
28
- <th style="width:88px;">Status</th>
35
+ <th style="width:112px;">Status</th>
29
36
  <th style="width:72px;">Duration</th>
30
- {{#if ../options.showRetries}}<th style="width:52px;">Retries</th>{{/if}}
37
+ {{#if @root.options.showRetries}}<th style="width:52px;">Retries</th>{{/if}}
31
38
  </tr>
32
39
  </thead>
33
40
  <tbody>
34
41
  {{#each tests}}
35
- <tr class="no-break">
42
+ <tr class="no-break{{#if failure}} test-row--has-detail{{/if}}">
36
43
  <td>
37
44
  <div style="font-weight:500;">{{title}}</div>
38
45
  {{#if location}}<div class="test-path">{{location}}</div>{{/if}}
@@ -42,10 +49,29 @@
42
49
  </div>
43
50
  {{/if}}
44
51
  </td>
45
- <td>{{statusBadge status}}</td>
52
+ <td style="white-space:nowrap;">
53
+ {{statusBadge status}}{{#if slowRank}}<span class="badge badge--slow" title="Among the slowest tests">SLOW</span>{{/if}}
54
+ </td>
46
55
  <td style="color:var(--text-2);font-family:'JetBrains Mono',monospace;font-size:9.5px;">{{formatDuration duration}}</td>
47
- {{#if ../../options.showRetries}}<td style="color:var(--text-3);text-align:center;">{{retryCount}}</td>{{/if}}
56
+ {{#if @root.options.showRetries}}<td style="color:var(--text-3);text-align:center;">{{retryCount}}</td>{{/if}}
57
+ </tr>
58
+ {{#if failure}}
59
+ <tr class="failure-row no-break">
60
+ <td colspan="{{#if @root.options.showRetries}}4{{else}}3{{/if}}">
61
+ <div class="failure-detail failure-detail--{{failure.severity}}">
62
+ {{> failure-card failure=failure showFull=@root.options.showFullFailures showStack=@root.options.showStackTraces}}
63
+ {{#if chip}}
64
+ <div class="root-cause-chip root-cause-chip--{{chip.category}}">
65
+ <span class="root-cause-chip__tag">Root cause</span>
66
+ <span class="root-cause-chip__label">{{categoryLabel chip.category}}</span>
67
+ <span class="root-cause-chip__msg">{{chip.message}}</span>
68
+ <span class="root-cause-chip__strength">{{strengthLabel chip.strength}} match</span>
69
+ </div>
70
+ {{/if}}
71
+ </div>
72
+ </td>
48
73
  </tr>
74
+ {{/if}}
49
75
  {{/each}}
50
76
  </tbody>
51
77
  </table>
@@ -153,6 +153,12 @@ tr:last-child td { border-bottom: none; }
153
153
 
154
154
  /* ── Page breaks ────────────────────────────────────────────────────────── */
155
155
  .no-break { page-break-inside: avoid; }
156
+ /* Keep a failed test row glued to its inline failure detail: the detail block is
157
+ page-break-inside:avoid, so when it can't fit the remaining space it moves to the
158
+ next page — these pull the test row along so the title never orphans above it. */
159
+ .test-row--has-detail { page-break-after: avoid; break-after: avoid; }
160
+ .test-row--has-detail > td { border-bottom: none; }
161
+ .failure-row { page-break-before: avoid; break-before: avoid; }
156
162
 
157
163
  /* ── Section wrapper ────────────────────────────────────────────────────── */
158
164
  /* Large sections (suite breakdown, failures, tables) MAY flow across pages —
@@ -292,12 +298,90 @@ code {
292
298
  .release-gate__sep { color: var(--border-3); }
293
299
  .release-gate__flaky-warn { color: var(--flaky); font-weight: 600; }
294
300
 
295
- /* ── Failure severitycolour the card's left edge by triage rank ───────── */
296
- /* Two-class specificity beats the per-template `.failure-item` border shorthand. */
297
- .failure-item.failure-item--critical { border-left-color: var(--fail); }
298
- .failure-item.failure-item--high { border-left-color: #A35E1E; }
299
- .failure-item.failure-item--medium { border-left-color: var(--timeout); }
300
- .failure-item.failure-item--low { border-left-color: var(--text-3); }
301
+ /* ── Inline failure detail ONE block hanging off the failed test row ────────
302
+ A single severity-coloured left rail + hairline dividers tie the error,
303
+ retry history, and root cause together as the failed test's detail (not
304
+ three separate boxes). Lives here so every template's breakdown can use it. */
305
+ .failure-row > td { padding: 0; border: none; background: transparent; }
306
+ .failure-detail {
307
+ border-left: 3px solid var(--fail);
308
+ margin: 2px 10px 10px 12px; /* slight indent under the test row + gap before next row */
309
+ padding: 11px 0 11px 16px;
310
+ page-break-inside: avoid;
311
+ }
312
+ .failure-detail--critical { border-left-color: var(--fail); }
313
+ .failure-detail--high { border-left-color: #A35E1E; }
314
+ .failure-detail--medium { border-left-color: var(--timeout); }
315
+ .failure-detail--low { border-left-color: var(--text-3); }
316
+
317
+ /* error — flat on the rail, no inner box (override the base pre panel) */
318
+ .failure-detail pre {
319
+ margin: 0; padding: 0;
320
+ background: transparent; border: none; border-radius: 0; max-height: none;
321
+ }
322
+ .failure-detail .failure-item__message {
323
+ background: transparent; border: none; border-radius: 0; padding: 0; margin: 0;
324
+ font-family: 'JetBrains Mono', monospace;
325
+ font-size: 9.5px; color: #8A2E1E; word-break: break-word; line-height: 1.6;
326
+ }
327
+
328
+ /* retry history — divider above, same block */
329
+ .retry-history {
330
+ font-size: 9px; color: var(--text-3); margin-top: 11px;
331
+ padding-top: 10px; border-top: 1px solid var(--border);
332
+ font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
333
+ letter-spacing: 0.5px; text-transform: uppercase;
334
+ }
335
+ .retry-attempt {
336
+ display: inline-block; padding: 2px 7px;
337
+ border-radius: 3px; margin-right: 4px;
338
+ font-size: 8.5px; font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
339
+ -webkit-print-color-adjust: exact;
340
+ print-color-adjust: exact;
341
+ }
342
+ .retry-attempt--failed { background: var(--fail-dim); color: var(--fail); border: 1px solid #E5C9BD; }
343
+ .retry-attempt--passed { background: var(--pass-dim); color: var(--pass); border: 1px solid #C5DBC9; }
344
+
345
+ /* Slow-test badge — a compact tag inline beside the status badge (min-width:0
346
+ overrides the .badge 50px floor so it doesn't wrap onto its own line). */
347
+ .badge--slow {
348
+ background: var(--timeout-dim); color: var(--timeout);
349
+ border: 1px solid #E5D2A8;
350
+ min-width: 0; margin-left: 5px;
351
+ padding: 1px 5px; font-size: 7.5px; letter-spacing: 0.4px;
352
+ }
353
+
354
+ /* Root cause — footer of the same failure block: a divider above, no box and no
355
+ second rail (the block already carries one). */
356
+ .root-cause-chip {
357
+ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
358
+ margin-top: 11px; padding-top: 10px;
359
+ border-top: 1px solid var(--border);
360
+ font-family: 'Inter', system-ui, sans-serif; font-size: 9.5px;
361
+ }
362
+ .root-cause-chip__tag {
363
+ font-size: 8px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
364
+ color: var(--text-3);
365
+ }
366
+ .root-cause-chip__label { font-weight: 700; color: var(--text-1); }
367
+ .root-cause-chip__msg {
368
+ font-family: 'JetBrains Mono', monospace; color: var(--text-2);
369
+ word-break: break-word;
370
+ }
371
+ .root-cause-chip__strength {
372
+ margin-left: auto; font-size: 8.5px; font-weight: 600;
373
+ letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-3);
374
+ }
375
+ /* Subtle category cue — tint only the category name (no box, no second rail), so
376
+ the chip stays "quiet" but failures are still scannable by root-cause type. */
377
+ .root-cause-chip--assertion .root-cause-chip__label,
378
+ .root-cause-chip--locator-not-found .root-cause-chip__label { color: oklch(52% 0.19 25); }
379
+ .root-cause-chip--timeout .root-cause-chip__label { color: oklch(50% 0.13 65); }
380
+ .root-cause-chip--network .root-cause-chip__label,
381
+ .root-cause-chip--navigation .root-cause-chip__label { color: oklch(50% 0.15 260); }
382
+ .root-cause-chip--flaky-by-retry .root-cause-chip__label { color: oklch(50% 0.15 290); }
383
+ .root-cause-chip--env-config .root-cause-chip__label { color: oklch(48% 0.14 145); }
384
+ .root-cause-chip--unknown .root-cause-chip__label { color: var(--text-2); }
301
385
 
302
386
  /* ── History trend card — shared by detailed + executive (charts.hbs) ─────── */
303
387
  .trend-card {
@@ -586,6 +670,11 @@ code {
586
670
  print-color-adjust: exact;
587
671
  }
588
672
 
673
+ /* Dimmed zero-value KPI card — a coloured "0" for an absent status (no timeouts,
674
+ no flakes) draws the eye to nothing; muting it lets the real counts lead. Shared
675
+ modifier so every template's kpi-strip gets it. */
676
+ .kpi-card--zero { opacity: 0.42; }
677
+
589
678
  /* ── Suite breakdown — shared so any template (incl. executive) can toggle it on ─ */
590
679
  /* Values match the detailed template; minimal.css keeps its own tuned overrides
591
680
  for .suite-group (margin) and .suite-stats (gap), which win via templateCSS order. */
@@ -656,23 +745,6 @@ code {
656
745
  font-size: 9.5px; color: var(--text-1);
657
746
  }
658
747
 
659
- /* ── Failure Analysis shared so any template can toggle the section on ────── */
660
- /* NOTE: .analysis-oneliner lives in base.css above — the executive template renders it too. */
661
- .failure-analysis { margin-top: 24px; }
662
- .failure-analysis .oneliner { font-weight: 600; }
663
- .analysis-truncated { font-size: 0.85em; opacity: 0.8; }
664
-
665
- .cluster { border-left: 3px solid; padding-left: 12px; margin-bottom: 16px; }
666
- .cluster-header { display: flex; gap: 12px; align-items: baseline; }
667
- .cluster-strength--strong { color: oklch(55% 0.15 145); }
668
- .cluster-strength--moderate { color: oklch(60% 0.18 65); }
669
- .cluster-strength--weak { color: oklch(55% 0.20 25); }
670
- .cluster--timeout { border-color: oklch(60% 0.18 65); }
671
- .cluster--assertion { border-color: oklch(55% 0.20 25); }
672
- .cluster--locator-not-found { border-color: oklch(55% 0.20 25); }
673
- .cluster--network { border-color: oklch(55% 0.15 260); }
674
- .cluster--navigation { border-color: oklch(55% 0.15 260); }
675
- .cluster--flaky-by-retry { border-color: oklch(55% 0.15 290); }
676
- .cluster--env-config { border-color: oklch(55% 0.15 145); }
677
- .cluster--unknown { border-color: oklch(60% 0.00 0); }
678
- .confidence-warning { font-size: 0.8em; opacity: 0.75; }
748
+ /* The aggregate failure-analysis cluster section was retired root causes now
749
+ render as inline per-test chips in the suite breakdown (.root-cause-chip above).
750
+ The .analysis-oneliner one-liner lives further up and is still used by executive. */