@reportforge/playwright-pdf 0.6.3 → 0.7.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/README.md CHANGED
@@ -46,7 +46,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
46
46
  ## Features
47
47
 
48
48
  - **3 templates** — `minimal` (developer), `detailed` (QA team), `executive` (stakeholders)
49
- - **Detailed reports** — KPI dashboard, suite breakdown, failure deep-dive with screenshots, CI/CD environment
49
+ - **Detailed reports** — KPI dashboard, suite breakdown with inline failure detail (error, screenshot, root-cause chip) under each failed test, CI/CD environment
50
50
  - **Embedded Chart.js** — pass-rate doughnut + per-suite bar chart, bundled inline (no CDN)
51
51
  - **Self-contained PDFs** — screenshots, fonts, logos all embedded; share or archive with confidence
52
52
  - **Custom branding** — logo, primary/accent colours, watermark, PDF password encryption
@@ -56,7 +56,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
56
56
  - **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
57
57
  - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email supports optional PDF attachment via `attachPdf`
58
58
  - **Flakiness trend** — top-N flakiest tests table with per-test dot sparkline across stored history runs in the `detailed` template; `flakinessTopN` option (default 5, `0` disables)
59
- - **Offline failure analysis** — sorts failures into 7 root-cause buckets and clusters them, right inside the `detailed` PDF (one-liner in `executive`). No network, no AI service — a small embedded classifier that updates itself safely over your existing license refresh; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
59
+ - **Offline failure analysis** — sorts each failure into one of 7 root-cause buckets and renders a per-test root-cause chip inline under the failed test in the `detailed` PDF (dominant-cause one-liner in `executive`). No network, no AI service — a small embedded classifier that updates itself safely over your existing license refresh; pin it any time. Full details at [reportforge.org/docs/advanced/failure-analysis](https://reportforge.org/docs/advanced/failure-analysis).
60
60
  - `npx @reportforge/playwright-pdf export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
61
61
  - **Configurable sections** — add or remove any report section per template via `sections`.
62
62
  - **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
@@ -198,6 +198,7 @@ All options are the second element of the reporter tuple.
198
198
  | `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
199
199
  | `showTrend` | `boolean` | `true` | Show the pass-rate sparkline and delta badge in the `detailed` template. Set to `false` to disable history tracking entirely. |
200
200
  | `flakinessTopN` | `number` | `5` | Maximum flaky tests to show in the flakiness table (`detailed` template). Set to `0` to disable the table entirely. |
201
+ | `slowTestThreshold` | `number` | `10` | Minimum timed-test count before `SLOW` badges appear in the suite breakdown. The badge marks the 10 slowest tests; below this many it stays hidden (every test would be trivially "slowest"). Set to `0` to always badge the slowest. |
201
202
  | `templatePath` | `string \| string[]` | — | 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. |
202
203
  | `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. |
203
204
  <!-- AUTOGEN:options-table END -->
@@ -391,10 +392,10 @@ reporter: [['@reportforge/playwright-pdf', {
391
392
  | `trend` | Pass-rate trend line, run-history table, and flakiness table (rendered inside `charts`) |
392
393
  | `requirementsMatrix` | Tests grouped by `@tag('REQ-…')` with coverage bars (requirements traceability) |
393
394
  | `ciEnvironment` | Branch, commit, browsers, CI provider, OS, Node, Playwright, projects, workers |
394
- | `suiteBreakdown` | Per-suite → per-test table (status, duration, tags, retries) |
395
- | `failureDeepDive` | Failure cards: error message, stack trace, screenshot, retry history |
396
- | `failureAnalysis` | Clustered root-cause buckets from the offline classifier |
397
- | `slowTests` | Slowest tests, duration-ranked |
395
+ | `suiteBreakdown` | Per-suite → per-test table (status, duration, tags, retries). Failed tests carry their failure detail, root-cause chip, and a `SLOW` badge inline (the three keys below) |
396
+ | `failureDeepDive` | Inline failure detail under each failed test in the breakdown: error message, stack trace, screenshot, retry history |
397
+ | `failureAnalysis` | Inline per-test root-cause chip under each failed test (category + representative error + match strength, from the offline classifier) |
398
+ | `slowTests` | `SLOW` badge on the duration-ranked slowest tests, shown inline in the breakdown |
398
399
  | `defectLog` | `DEF-####` numbered failure table (severity, retries) |
399
400
 
400
401
  #### Display modifiers — tune a section that's on
@@ -404,8 +405,8 @@ reporter: [['@reportforge/playwright-pdf', {
404
405
  | `passRate` | Show the pass-rate % on the Passed KPI card (`summary`) |
405
406
  | `fullEnvironment` | Full environment table instead of the compact grid (`ciEnvironment`) |
406
407
  | `retries` | Retries column in the suite breakdown (`suiteBreakdown`) |
407
- | `fullFailures` | Full failure cards instead of one-line summaries (`failureDeepDive`) |
408
- | `stackTraces` | Include `<pre>` stack traces in the failure cards (`failureDeepDive`) |
408
+ | `fullFailures` | Include the screenshot in the inline failure detail (compact templates omit it) (`failureDeepDive`) |
409
+ | `stackTraces` | Include `<pre>` stack traces in the inline failure detail (`failureDeepDive`) |
409
410
 
410
411
  #### Per-template defaults
411
412
 
@@ -421,7 +422,7 @@ Omit `sections` entirely and each template renders exactly this (✓ = on):
421
422
  | `trend` | – | ✓ | ✓ |
422
423
  | `requirementsMatrix` | – | ✓ | – |
423
424
  | `ciEnvironment` | ✓ | ✓ | ✓ |
424
- | `suiteBreakdown` | ✓ | ✓ | |
425
+ | `suiteBreakdown` | ✓ | ✓ | |
425
426
  | `failureDeepDive` | ✓ | ✓ | ✓ |
426
427
  | `failureAnalysis` | – | ✓ | – |
427
428
  | `slowTests` | – | ✓ | ✓ |
@@ -435,7 +436,8 @@ Omit `sections` entirely and each template renders exactly this (✓ = on):
435
436
  #### Dependencies & gotchas
436
437
 
437
438
  - **`trend` needs `charts`.** The trend line, run-history, and flakiness table live inside the charts block, so `trend: true` does nothing with `charts: false` (it's coerced off). It also needs history data — keep the top-level `showTrend` option on.
438
- - **Data-gated sections** `failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog` render only when there's matching data (clusters, tagged tests, timed tests, failures). Toggling them on with no data shows nothing.
439
+ - **Inline failure detail needs `suiteBreakdown`.** `failureDeepDive` (failure detail), `failureAnalysis` (root-cause chip), and `slowTests` (`SLOW` badge) now render *inside* the suite breakdown, so they show nothing when `suiteBreakdown` is off. The chip (`failureAnalysis`) also needs `failureDeepDive` it lives inside the failure detail, so it's coerced off without it.
440
+ - **Data-gated sections** — `failureAnalysis`, `requirementsMatrix`, `slowTests`, `defectLog` render only when there's matching data (classified failures, tagged tests, a meaningful slow set, failures). Toggling them on with no data shows nothing.
439
441
  - **Render-layer only.** `sections` controls what's drawn, never what's collected — the data switches stay separate: `showTrend` (history), `flakinessTopN` (flaky-table size), `failureAnalysis.enabled` (classifier), `includeScreenshots` (images).
440
442
  - **Custom templates.** `sections` applies to the built-in templates; a custom `templatePath` controls its own layout (every section available) and ignores `sections` — you'll get a warning if both are set.
441
443
 
package/dist/cli/index.js CHANGED
@@ -6243,6 +6243,29 @@ var require_lib = __commonJS({
6243
6243
  }
6244
6244
  });
6245
6245
 
6246
+ // src/config/defaults.ts
6247
+ var DEFAULT_OPTIONS;
6248
+ var init_defaults = __esm({
6249
+ "src/config/defaults.ts"() {
6250
+ "use strict";
6251
+ init_cjs_shims();
6252
+ DEFAULT_OPTIONS = {
6253
+ outputFile: "playwright-report/{date}-report.pdf",
6254
+ template: "minimal",
6255
+ primaryColor: "#CC785C",
6256
+ accentColor: "#3F7D58",
6257
+ reportTitle: "Playwright Test Report",
6258
+ open: false,
6259
+ compressionLevel: "auto",
6260
+ maxFileSizeMb: 8,
6261
+ historySize: 10,
6262
+ showTrend: true,
6263
+ flakinessTopN: 5,
6264
+ slowTestThreshold: 10
6265
+ };
6266
+ }
6267
+ });
6268
+
6246
6269
  // src/utils/duration.ts
6247
6270
  function formatDuration(ms) {
6248
6271
  if (ms < 1e3) return `${ms}ms`;
@@ -6316,6 +6339,7 @@ function resolveSections(template, userSections) {
6316
6339
  }
6317
6340
  const result = { ...base, ...flat, ...perTemplate };
6318
6341
  if (!result.charts) result.trend = false;
6342
+ if (!result.failureDeepDive) result.failureAnalysis = false;
6319
6343
  return result;
6320
6344
  }
6321
6345
  function allSectionsOn() {
@@ -6398,7 +6422,10 @@ var init_sections = __esm({
6398
6422
  trend: true,
6399
6423
  requirementsMatrix: false,
6400
6424
  ciEnvironment: true,
6401
- suiteBreakdown: false,
6425
+ // Suite breakdown now hosts the inline failure detail + slow badge, so the
6426
+ // executive layout renders it too (compact: fullFailures stays false → no
6427
+ // screenshots; failureAnalysis stays off → no per-test chip).
6428
+ suiteBreakdown: true,
6402
6429
  failureDeepDive: true,
6403
6430
  failureAnalysis: false,
6404
6431
  slowTests: true,
@@ -6454,6 +6481,7 @@ var init_engine = __esm({
6454
6481
  import_handlebars = __toESM(require_lib());
6455
6482
  import_fs = __toESM(require("fs"));
6456
6483
  import_path = __toESM(require("path"));
6484
+ init_defaults();
6457
6485
  init_duration();
6458
6486
  init_SummaryWriter();
6459
6487
  init_sections();
@@ -6482,17 +6510,17 @@ var init_engine = __esm({
6482
6510
  setChartjsBundle(inline) {
6483
6511
  this.chartjsInline = inline;
6484
6512
  }
6485
- render(data, template, userSections) {
6513
+ render(data, template, userSections, slowTestThreshold) {
6486
6514
  const layoutPath = import_path.default.join(this.templatesDir, "layouts", `${template}.hbs`);
6487
6515
  if (!import_fs.default.existsSync(layoutPath)) {
6488
6516
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
6489
6517
  }
6490
6518
  const layoutSource = import_fs.default.readFileSync(layoutPath, "utf8");
6491
6519
  const compiledLayout = this.handlebars.compile(layoutSource);
6492
- const ctx = this.buildContext(data, template, userSections);
6520
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
6493
6521
  return compiledLayout(ctx);
6494
6522
  }
6495
- renderFromPath(data, filePath) {
6523
+ renderFromPath(data, filePath, slowTestThreshold) {
6496
6524
  if (!this.customTemplateCache.has(filePath)) {
6497
6525
  if (!import_fs.default.existsSync(filePath)) {
6498
6526
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -6501,32 +6529,41 @@ var init_engine = __esm({
6501
6529
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
6502
6530
  }
6503
6531
  const compiledFn = this.customTemplateCache.get(filePath);
6504
- return compiledFn(this.buildCustomContext(data));
6532
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
6505
6533
  }
6506
- buildCustomContext(data) {
6534
+ buildCustomContext(data, slowTestThreshold) {
6507
6535
  const sections = allSectionsOn();
6536
+ const slow = this.buildSlowTests(data);
6508
6537
  return {
6509
6538
  ...data,
6539
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
6510
6540
  options: deriveOptions(sections),
6511
6541
  sections,
6512
6542
  tagGroups: this.buildTagGroups(data),
6513
- slowTests: this.buildSlowTests(data),
6543
+ slowTests: slow.ranked,
6514
6544
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
6515
6545
  templateCSS: "",
6516
6546
  chartjsScript: this.buildChartjsScript()
6517
6547
  };
6518
6548
  }
6519
- buildContext(data, template, userSections) {
6549
+ buildContext(data, template, userSections, slowTestThreshold) {
6520
6550
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
6521
6551
  const templateCSS = this.readStyle(`${template}.css`);
6522
6552
  const sections = resolveSections(template, userSections);
6523
6553
  const options = deriveOptions(sections);
6554
+ const slow = this.buildSlowTests(data);
6524
6555
  return {
6525
6556
  ...data,
6557
+ projects: this.enrichProjects(
6558
+ data,
6559
+ slow,
6560
+ sections,
6561
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
6562
+ ),
6526
6563
  options,
6527
6564
  sections,
6528
6565
  tagGroups: this.buildTagGroups(data),
6529
- slowTests: this.buildSlowTests(data),
6566
+ slowTests: slow.ranked,
6530
6567
  baseCSS,
6531
6568
  templateCSS,
6532
6569
  chartjsScript: this.buildChartjsScript()
@@ -6563,6 +6600,7 @@ var init_engine = __esm({
6563
6600
  for (const test of suite.tests) {
6564
6601
  if (test.duration > 0) {
6565
6602
  tests.push({
6603
+ id: test.id,
6566
6604
  title: test.title,
6567
6605
  suiteTitle: suite.title,
6568
6606
  durationMs: test.duration,
@@ -6574,7 +6612,36 @@ var init_engine = __esm({
6574
6612
  }
6575
6613
  }
6576
6614
  }
6577
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
6615
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
6616
+ return { ranked, timedTotal: tests.length };
6617
+ }
6618
+ /**
6619
+ * Project the suite tree into its render form: attach each test's failure detail
6620
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
6621
+ * ReportData is never mutated. Gating lives here (not the template): a field is
6622
+ * attached only when its section is on, so the template just checks presence.
6623
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
6624
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
6625
+ */
6626
+ enrichProjects(data, slow, sections, slowTestThreshold) {
6627
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
6628
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
6629
+ const slowRankById = /* @__PURE__ */ new Map();
6630
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
6631
+ slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
6632
+ }
6633
+ const enrichTest = (t) => ({
6634
+ ...t,
6635
+ failure: failuresById.get(t.id),
6636
+ chip: chipById.get(t.id),
6637
+ slowRank: slowRankById.get(t.id)
6638
+ });
6639
+ const enrichSuite = (s) => ({
6640
+ ...s,
6641
+ suites: s.suites.map(enrichSuite),
6642
+ tests: s.tests.map(enrichTest)
6643
+ });
6644
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
6578
6645
  }
6579
6646
  readStyle(filename) {
6580
6647
  const cached = this.styleCache.get(filename);
@@ -7598,7 +7665,7 @@ var init_PdfGenerator = __esm({
7598
7665
  }
7599
7666
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
7600
7667
  logger.info(`Rendering template: ${templateLabel}`);
7601
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
7668
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
7602
7669
  const tempHtmlPath = await this.htmlWriter.write(html);
7603
7670
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
7604
7671
  try {
@@ -11747,28 +11814,6 @@ var init_zod = __esm({
11747
11814
  }
11748
11815
  });
11749
11816
 
11750
- // src/config/defaults.ts
11751
- var DEFAULT_OPTIONS;
11752
- var init_defaults = __esm({
11753
- "src/config/defaults.ts"() {
11754
- "use strict";
11755
- init_cjs_shims();
11756
- DEFAULT_OPTIONS = {
11757
- outputFile: "playwright-report/{date}-report.pdf",
11758
- template: "minimal",
11759
- primaryColor: "#CC785C",
11760
- accentColor: "#3F7D58",
11761
- reportTitle: "Playwright Test Report",
11762
- open: false,
11763
- compressionLevel: "auto",
11764
- maxFileSizeMb: 8,
11765
- historySize: 10,
11766
- showTrend: true,
11767
- flakinessTopN: 5
11768
- };
11769
- }
11770
- });
11771
-
11772
11817
  // src/config/schema.ts
11773
11818
  function parseOptions(raw) {
11774
11819
  const result = ReporterOptionsSchema.safeParse(raw ?? {});
@@ -11856,6 +11901,7 @@ var init_schema = __esm({
11856
11901
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
11857
11902
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
11858
11903
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
11904
+ slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
11859
11905
  failureAnalysis: failureAnalysisConfigSchema,
11860
11906
  templatePath: external_exports.union([
11861
11907
  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"),
@@ -12074,6 +12120,14 @@ function classifyOne(f, model) {
12074
12120
  function classifyAll(failures, model) {
12075
12121
  return failures.map((f) => classifyOne(f, model));
12076
12122
  }
12123
+ function perTestClassifications(classified) {
12124
+ return classified.map((c) => ({
12125
+ testId: c.failure.testId,
12126
+ category: c.category,
12127
+ message: representativeMessage(c.failure.error.message),
12128
+ strength: strengthOf(c.margin)
12129
+ }));
12130
+ }
12077
12131
  function clusterClassified(classified) {
12078
12132
  const groups = /* @__PURE__ */ new Map();
12079
12133
  for (const c of classified) {
@@ -12246,6 +12300,7 @@ function runFailureAnalysis(reportData, options, cwd) {
12246
12300
  const analysed = reportData.failures.slice(0, cap);
12247
12301
  const model = loadModel(fa.autoUpdateModel);
12248
12302
  const classified = classifyAll(analysed, model);
12303
+ reportData.classifications = perTestClassifications(classified);
12249
12304
  if (templatesConsumeAnalysis(options)) {
12250
12305
  reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
12251
12306
  }
package/dist/index.d.mts CHANGED
@@ -234,6 +234,14 @@ interface ReporterOptions {
234
234
  * @default 5
235
235
  */
236
236
  flakinessTopN?: number;
237
+ /**
238
+ * Minimum number of timed tests before `SLOW` badges appear in the suite
239
+ * breakdown. The badge marks the 10 slowest tests; below this many timed
240
+ * tests every test would trivially be the "slowest", so badges stay hidden.
241
+ * Set to 0 to always badge the slowest. Must be a non-negative integer.
242
+ * @default 10
243
+ */
244
+ slowTestThreshold?: number;
237
245
  /**
238
246
  * Path to a custom Handlebars (.hbs) template file. When set, takes
239
247
  * precedence over `template`. Relative paths resolve from `process.cwd()`.
package/dist/index.d.ts CHANGED
@@ -234,6 +234,14 @@ interface ReporterOptions {
234
234
  * @default 5
235
235
  */
236
236
  flakinessTopN?: number;
237
+ /**
238
+ * Minimum number of timed tests before `SLOW` badges appear in the suite
239
+ * breakdown. The badge marks the 10 slowest tests; below this many timed
240
+ * tests every test would trivially be the "slowest", so badges stay hidden.
241
+ * Set to 0 to always badge the slowest. Must be a non-negative integer.
242
+ * @default 10
243
+ */
244
+ slowTestThreshold?: number;
237
245
  /**
238
246
  * Path to a custom Handlebars (.hbs) template file. When set, takes
239
247
  * precedence over `template`. Relative paths resolve from `process.cwd()`.
package/dist/index.js CHANGED
@@ -10329,7 +10329,8 @@ var DEFAULT_OPTIONS = {
10329
10329
  maxFileSizeMb: 8,
10330
10330
  historySize: 10,
10331
10331
  showTrend: true,
10332
- flakinessTopN: 5
10332
+ flakinessTopN: 5,
10333
+ slowTestThreshold: 10
10333
10334
  };
10334
10335
 
10335
10336
  // src/templates/sections.ts
@@ -10406,7 +10407,10 @@ var SECTION_DEFAULTS = {
10406
10407
  trend: true,
10407
10408
  requirementsMatrix: false,
10408
10409
  ciEnvironment: true,
10409
- suiteBreakdown: false,
10410
+ // Suite breakdown now hosts the inline failure detail + slow badge, so the
10411
+ // executive layout renders it too (compact: fullFailures stays false → no
10412
+ // screenshots; failureAnalysis stays off → no per-test chip).
10413
+ suiteBreakdown: true,
10410
10414
  failureDeepDive: true,
10411
10415
  failureAnalysis: false,
10412
10416
  slowTests: true,
@@ -10433,6 +10437,7 @@ function resolveSections(template, userSections) {
10433
10437
  }
10434
10438
  const result = { ...base, ...flat, ...perTemplate };
10435
10439
  if (!result.charts) result.trend = false;
10440
+ if (!result.failureDeepDive) result.failureAnalysis = false;
10436
10441
  return result;
10437
10442
  }
10438
10443
  function allSectionsOn() {
@@ -10509,6 +10514,7 @@ var ReporterOptionsSchema = external_exports.object({
10509
10514
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
10510
10515
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
10511
10516
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10517
+ slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10512
10518
  failureAnalysis: failureAnalysisConfigSchema,
10513
10519
  templatePath: external_exports.union([
10514
10520
  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"),
@@ -11172,6 +11178,14 @@ function classifyOne(f, model) {
11172
11178
  function classifyAll(failures, model) {
11173
11179
  return failures.map((f) => classifyOne(f, model));
11174
11180
  }
11181
+ function perTestClassifications(classified) {
11182
+ return classified.map((c) => ({
11183
+ testId: c.failure.testId,
11184
+ category: c.category,
11185
+ message: representativeMessage(c.failure.error.message),
11186
+ strength: strengthOf(c.margin)
11187
+ }));
11188
+ }
11175
11189
  function clusterClassified(classified) {
11176
11190
  const groups = /* @__PURE__ */ new Map();
11177
11191
  for (const c of classified) {
@@ -11347,6 +11361,7 @@ function runFailureAnalysis(reportData, options, cwd) {
11347
11361
  const analysed = reportData.failures.slice(0, cap);
11348
11362
  const model = loadModel(fa.autoUpdateModel);
11349
11363
  const classified = classifyAll(analysed, model);
11364
+ reportData.classifications = perTestClassifications(classified);
11350
11365
  if (templatesConsumeAnalysis(options)) {
11351
11366
  reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
11352
11367
  }
@@ -12069,17 +12084,17 @@ var TemplateEngine = class {
12069
12084
  setChartjsBundle(inline) {
12070
12085
  this.chartjsInline = inline;
12071
12086
  }
12072
- render(data, template, userSections) {
12087
+ render(data, template, userSections, slowTestThreshold) {
12073
12088
  const layoutPath = import_path3.default.join(this.templatesDir, "layouts", `${template}.hbs`);
12074
12089
  if (!import_fs4.default.existsSync(layoutPath)) {
12075
12090
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12076
12091
  }
12077
12092
  const layoutSource = import_fs4.default.readFileSync(layoutPath, "utf8");
12078
12093
  const compiledLayout = this.handlebars.compile(layoutSource);
12079
- const ctx = this.buildContext(data, template, userSections);
12094
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
12080
12095
  return compiledLayout(ctx);
12081
12096
  }
12082
- renderFromPath(data, filePath) {
12097
+ renderFromPath(data, filePath, slowTestThreshold) {
12083
12098
  if (!this.customTemplateCache.has(filePath)) {
12084
12099
  if (!import_fs4.default.existsSync(filePath)) {
12085
12100
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -12088,32 +12103,41 @@ var TemplateEngine = class {
12088
12103
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12089
12104
  }
12090
12105
  const compiledFn = this.customTemplateCache.get(filePath);
12091
- return compiledFn(this.buildCustomContext(data));
12106
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
12092
12107
  }
12093
- buildCustomContext(data) {
12108
+ buildCustomContext(data, slowTestThreshold) {
12094
12109
  const sections = allSectionsOn();
12110
+ const slow = this.buildSlowTests(data);
12095
12111
  return {
12096
12112
  ...data,
12113
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
12097
12114
  options: deriveOptions(sections),
12098
12115
  sections,
12099
12116
  tagGroups: this.buildTagGroups(data),
12100
- slowTests: this.buildSlowTests(data),
12117
+ slowTests: slow.ranked,
12101
12118
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12102
12119
  templateCSS: "",
12103
12120
  chartjsScript: this.buildChartjsScript()
12104
12121
  };
12105
12122
  }
12106
- buildContext(data, template, userSections) {
12123
+ buildContext(data, template, userSections, slowTestThreshold) {
12107
12124
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
12108
12125
  const templateCSS = this.readStyle(`${template}.css`);
12109
12126
  const sections = resolveSections(template, userSections);
12110
12127
  const options = deriveOptions(sections);
12128
+ const slow = this.buildSlowTests(data);
12111
12129
  return {
12112
12130
  ...data,
12131
+ projects: this.enrichProjects(
12132
+ data,
12133
+ slow,
12134
+ sections,
12135
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
12136
+ ),
12113
12137
  options,
12114
12138
  sections,
12115
12139
  tagGroups: this.buildTagGroups(data),
12116
- slowTests: this.buildSlowTests(data),
12140
+ slowTests: slow.ranked,
12117
12141
  baseCSS,
12118
12142
  templateCSS,
12119
12143
  chartjsScript: this.buildChartjsScript()
@@ -12150,6 +12174,7 @@ var TemplateEngine = class {
12150
12174
  for (const test of suite.tests) {
12151
12175
  if (test.duration > 0) {
12152
12176
  tests.push({
12177
+ id: test.id,
12153
12178
  title: test.title,
12154
12179
  suiteTitle: suite.title,
12155
12180
  durationMs: test.duration,
@@ -12161,7 +12186,36 @@ var TemplateEngine = class {
12161
12186
  }
12162
12187
  }
12163
12188
  }
12164
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12189
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12190
+ return { ranked, timedTotal: tests.length };
12191
+ }
12192
+ /**
12193
+ * Project the suite tree into its render form: attach each test's failure detail
12194
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
12195
+ * ReportData is never mutated. Gating lives here (not the template): a field is
12196
+ * attached only when its section is on, so the template just checks presence.
12197
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
12198
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
12199
+ */
12200
+ enrichProjects(data, slow, sections, slowTestThreshold) {
12201
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
12202
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12203
+ const slowRankById = /* @__PURE__ */ new Map();
12204
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12205
+ slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
12206
+ }
12207
+ const enrichTest = (t) => ({
12208
+ ...t,
12209
+ failure: failuresById.get(t.id),
12210
+ chip: chipById.get(t.id),
12211
+ slowRank: slowRankById.get(t.id)
12212
+ });
12213
+ const enrichSuite = (s) => ({
12214
+ ...s,
12215
+ suites: s.suites.map(enrichSuite),
12216
+ tests: s.tests.map(enrichTest)
12217
+ });
12218
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
12165
12219
  }
12166
12220
  readStyle(filename) {
12167
12221
  const cached = this.styleCache.get(filename);
@@ -12840,7 +12894,7 @@ var PdfGenerator = class {
12840
12894
  }
12841
12895
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
12842
12896
  logger.info(`Rendering template: ${templateLabel}`);
12843
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections) : this.templateEngine.renderFromPath(renderData, source.absolutePath);
12897
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
12844
12898
  const tempHtmlPath = await this.htmlWriter.write(html);
12845
12899
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12846
12900
  try {
@@ -13237,7 +13291,7 @@ var PdfReporter = class {
13237
13291
  this.dataCollector = new DataCollector();
13238
13292
  this.pdfGenerator = new PdfGenerator();
13239
13293
  this.options = parseOptions(rawOptions);
13240
- const version = true ? "0.6.3" : "0.x";
13294
+ const version = true ? "0.7.0" : "0.x";
13241
13295
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13242
13296
  this.licenseClient = new LicenseClient({
13243
13297
  licenseKey: this.options.licenseKey,
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
  }
@@ -12070,17 +12085,17 @@ var TemplateEngine = class {
12070
12085
  setChartjsBundle(inline) {
12071
12086
  this.chartjsInline = inline;
12072
12087
  }
12073
- render(data, template, userSections) {
12088
+ render(data, template, userSections, slowTestThreshold) {
12074
12089
  const layoutPath = path7.join(this.templatesDir, "layouts", `${template}.hbs`);
12075
12090
  if (!fs5.existsSync(layoutPath)) {
12076
12091
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12077
12092
  }
12078
12093
  const layoutSource = fs5.readFileSync(layoutPath, "utf8");
12079
12094
  const compiledLayout = this.handlebars.compile(layoutSource);
12080
- const ctx = this.buildContext(data, template, userSections);
12095
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
12081
12096
  return compiledLayout(ctx);
12082
12097
  }
12083
- renderFromPath(data, filePath) {
12098
+ renderFromPath(data, filePath, slowTestThreshold) {
12084
12099
  if (!this.customTemplateCache.has(filePath)) {
12085
12100
  if (!fs5.existsSync(filePath)) {
12086
12101
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -12089,32 +12104,41 @@ var TemplateEngine = class {
12089
12104
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12090
12105
  }
12091
12106
  const compiledFn = this.customTemplateCache.get(filePath);
12092
- return compiledFn(this.buildCustomContext(data));
12107
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
12093
12108
  }
12094
- buildCustomContext(data) {
12109
+ buildCustomContext(data, slowTestThreshold) {
12095
12110
  const sections = allSectionsOn();
12111
+ const slow = this.buildSlowTests(data);
12096
12112
  return {
12097
12113
  ...data,
12114
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
12098
12115
  options: deriveOptions(sections),
12099
12116
  sections,
12100
12117
  tagGroups: this.buildTagGroups(data),
12101
- slowTests: this.buildSlowTests(data),
12118
+ slowTests: slow.ranked,
12102
12119
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12103
12120
  templateCSS: "",
12104
12121
  chartjsScript: this.buildChartjsScript()
12105
12122
  };
12106
12123
  }
12107
- buildContext(data, template, userSections) {
12124
+ buildContext(data, template, userSections, slowTestThreshold) {
12108
12125
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
12109
12126
  const templateCSS = this.readStyle(`${template}.css`);
12110
12127
  const sections = resolveSections(template, userSections);
12111
12128
  const options = deriveOptions(sections);
12129
+ const slow = this.buildSlowTests(data);
12112
12130
  return {
12113
12131
  ...data,
12132
+ projects: this.enrichProjects(
12133
+ data,
12134
+ slow,
12135
+ sections,
12136
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
12137
+ ),
12114
12138
  options,
12115
12139
  sections,
12116
12140
  tagGroups: this.buildTagGroups(data),
12117
- slowTests: this.buildSlowTests(data),
12141
+ slowTests: slow.ranked,
12118
12142
  baseCSS,
12119
12143
  templateCSS,
12120
12144
  chartjsScript: this.buildChartjsScript()
@@ -12151,6 +12175,7 @@ var TemplateEngine = class {
12151
12175
  for (const test of suite.tests) {
12152
12176
  if (test.duration > 0) {
12153
12177
  tests.push({
12178
+ id: test.id,
12154
12179
  title: test.title,
12155
12180
  suiteTitle: suite.title,
12156
12181
  durationMs: test.duration,
@@ -12162,7 +12187,36 @@ var TemplateEngine = class {
12162
12187
  }
12163
12188
  }
12164
12189
  }
12165
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12190
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12191
+ return { ranked, timedTotal: tests.length };
12192
+ }
12193
+ /**
12194
+ * Project the suite tree into its render form: attach each test's failure detail
12195
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
12196
+ * ReportData is never mutated. Gating lives here (not the template): a field is
12197
+ * attached only when its section is on, so the template just checks presence.
12198
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
12199
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
12200
+ */
12201
+ enrichProjects(data, slow, sections, slowTestThreshold) {
12202
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
12203
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12204
+ const slowRankById = /* @__PURE__ */ new Map();
12205
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12206
+ slow.ranked.forEach((t, i) => slowRankById.set(t.id, i + 1));
12207
+ }
12208
+ const enrichTest = (t) => ({
12209
+ ...t,
12210
+ failure: failuresById.get(t.id),
12211
+ chip: chipById.get(t.id),
12212
+ slowRank: slowRankById.get(t.id)
12213
+ });
12214
+ const enrichSuite = (s) => ({
12215
+ ...s,
12216
+ suites: s.suites.map(enrichSuite),
12217
+ tests: s.tests.map(enrichTest)
12218
+ });
12219
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
12166
12220
  }
12167
12221
  readStyle(filename) {
12168
12222
  const cached = this.styleCache.get(filename);
@@ -12841,7 +12895,7 @@ var PdfGenerator = class {
12841
12895
  }
12842
12896
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
12843
12897
  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);
12898
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
12845
12899
  const tempHtmlPath = await this.htmlWriter.write(html);
12846
12900
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12847
12901
  try {
@@ -13238,7 +13292,7 @@ var PdfReporter = class {
13238
13292
  this.dataCollector = new DataCollector();
13239
13293
  this.pdfGenerator = new PdfGenerator();
13240
13294
  this.options = parseOptions(rawOptions);
13241
- const version = true ? "0.6.3" : "0.x";
13295
+ const version = true ? "0.7.0" : "0.x";
13242
13296
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13243
13297
  this.licenseClient = new LicenseClient({
13244
13298
  licenseKey: this.options.licenseKey,
@@ -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
 
@@ -24,17 +24,11 @@
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>
@@ -24,13 +24,7 @@
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
 
@@ -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,9 +32,9 @@
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>
@@ -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>
@@ -292,12 +292,90 @@ code {
292
292
  .release-gate__sep { color: var(--border-3); }
293
293
  .release-gate__flaky-warn { color: var(--flaky); font-weight: 600; }
294
294
 
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); }
295
+ /* ── Inline failure detail ONE block hanging off the failed test row ────────
296
+ A single severity-coloured left rail + hairline dividers tie the error,
297
+ retry history, and root cause together as the failed test's detail (not
298
+ three separate boxes). Lives here so every template's breakdown can use it. */
299
+ .failure-row > td { padding: 0; border: none; background: transparent; }
300
+ .failure-detail {
301
+ border-left: 3px solid var(--fail);
302
+ margin: 2px 10px 10px 12px; /* slight indent under the test row + gap before next row */
303
+ padding: 11px 0 11px 16px;
304
+ page-break-inside: avoid;
305
+ }
306
+ .failure-detail--critical { border-left-color: var(--fail); }
307
+ .failure-detail--high { border-left-color: #A35E1E; }
308
+ .failure-detail--medium { border-left-color: var(--timeout); }
309
+ .failure-detail--low { border-left-color: var(--text-3); }
310
+
311
+ /* error — flat on the rail, no inner box (override the base pre panel) */
312
+ .failure-detail pre {
313
+ margin: 0; padding: 0;
314
+ background: transparent; border: none; border-radius: 0; max-height: none;
315
+ }
316
+ .failure-detail .failure-item__message {
317
+ background: transparent; border: none; border-radius: 0; padding: 0; margin: 0;
318
+ font-family: 'JetBrains Mono', monospace;
319
+ font-size: 9.5px; color: #8A2E1E; word-break: break-word; line-height: 1.6;
320
+ }
321
+
322
+ /* retry history — divider above, same block */
323
+ .retry-history {
324
+ font-size: 9px; color: var(--text-3); margin-top: 11px;
325
+ padding-top: 10px; border-top: 1px solid var(--border);
326
+ font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
327
+ letter-spacing: 0.5px; text-transform: uppercase;
328
+ }
329
+ .retry-attempt {
330
+ display: inline-block; padding: 2px 7px;
331
+ border-radius: 3px; margin-right: 4px;
332
+ font-size: 8.5px; font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
333
+ -webkit-print-color-adjust: exact;
334
+ print-color-adjust: exact;
335
+ }
336
+ .retry-attempt--failed { background: var(--fail-dim); color: var(--fail); border: 1px solid #E5C9BD; }
337
+ .retry-attempt--passed { background: var(--pass-dim); color: var(--pass); border: 1px solid #C5DBC9; }
338
+
339
+ /* Slow-test badge — a compact tag inline beside the status badge (min-width:0
340
+ overrides the .badge 50px floor so it doesn't wrap onto its own line). */
341
+ .badge--slow {
342
+ background: var(--timeout-dim); color: var(--timeout);
343
+ border: 1px solid #E5D2A8;
344
+ min-width: 0; margin-left: 5px;
345
+ padding: 1px 5px; font-size: 7.5px; letter-spacing: 0.4px;
346
+ }
347
+
348
+ /* Root cause — footer of the same failure block: a divider above, no box and no
349
+ second rail (the block already carries one). */
350
+ .root-cause-chip {
351
+ display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
352
+ margin-top: 11px; padding-top: 10px;
353
+ border-top: 1px solid var(--border);
354
+ font-family: 'Inter', system-ui, sans-serif; font-size: 9.5px;
355
+ }
356
+ .root-cause-chip__tag {
357
+ font-size: 8px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
358
+ color: var(--text-3);
359
+ }
360
+ .root-cause-chip__label { font-weight: 700; color: var(--text-1); }
361
+ .root-cause-chip__msg {
362
+ font-family: 'JetBrains Mono', monospace; color: var(--text-2);
363
+ word-break: break-word;
364
+ }
365
+ .root-cause-chip__strength {
366
+ margin-left: auto; font-size: 8.5px; font-weight: 600;
367
+ letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-3);
368
+ }
369
+ /* Subtle category cue — tint only the category name (no box, no second rail), so
370
+ the chip stays "quiet" but failures are still scannable by root-cause type. */
371
+ .root-cause-chip--assertion .root-cause-chip__label,
372
+ .root-cause-chip--locator-not-found .root-cause-chip__label { color: oklch(52% 0.19 25); }
373
+ .root-cause-chip--timeout .root-cause-chip__label { color: oklch(50% 0.13 65); }
374
+ .root-cause-chip--network .root-cause-chip__label,
375
+ .root-cause-chip--navigation .root-cause-chip__label { color: oklch(50% 0.15 260); }
376
+ .root-cause-chip--flaky-by-retry .root-cause-chip__label { color: oklch(50% 0.15 290); }
377
+ .root-cause-chip--env-config .root-cause-chip__label { color: oklch(48% 0.14 145); }
378
+ .root-cause-chip--unknown .root-cause-chip__label { color: var(--text-2); }
301
379
 
302
380
  /* ── History trend card — shared by detailed + executive (charts.hbs) ─────── */
303
381
  .trend-card {
@@ -656,23 +734,6 @@ code {
656
734
  font-size: 9.5px; color: var(--text-1);
657
735
  }
658
736
 
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; }
737
+ /* The aggregate failure-analysis cluster section was retired root causes now
738
+ render as inline per-test chips in the suite breakdown (.root-cause-chip above).
739
+ The .analysis-oneliner one-liner lives further up and is still used by executive. */
@@ -130,50 +130,7 @@
130
130
 
131
131
  /* ── Suite breakdown · styles live in base.css (shared across templates) ──── */
132
132
 
133
- /* ── Failure item ────────────────────────────────────────────────────────── */
134
- .failure-item {
135
- background: var(--surface);
136
- border: 1px solid var(--border-2);
137
- border-left: 3px solid var(--fail);
138
- border-radius: 6px; padding: 16px 18px;
139
- margin-bottom: 12px; page-break-inside: avoid;
140
- }
141
- .failure-item__header {
142
- display: flex; justify-content: space-between;
143
- align-items: flex-start; margin-bottom: 10px;
144
- }
145
- .failure-item__title {
146
- font-family: 'Inter', system-ui, sans-serif;
147
- font-weight: 600; font-size: 12px; color: var(--text-1);
148
- }
149
- .failure-item__path {
150
- font-family: 'JetBrains Mono', monospace;
151
- font-size: 8.5px; color: var(--text-3); margin-bottom: 10px;
152
- }
153
- .failure-item__message {
154
- background: var(--fail-dim);
155
- border: 1px solid #E5C9BD;
156
- border-left: 2px solid var(--fail);
157
- border-radius: 4px; padding: 10px 14px; margin-bottom: 12px;
158
- font-family: 'JetBrains Mono', monospace;
159
- font-size: 9.5px; color: #8A2E1E;
160
- word-break: break-word; line-height: 1.6;
161
- }
162
- .retry-history {
163
- font-size: 9px; color: var(--text-3); margin-top: 10px;
164
- padding-top: 10px; border-top: 1px solid var(--border);
165
- font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
166
- letter-spacing: 0.5px; text-transform: uppercase;
167
- }
168
- .retry-attempt {
169
- display: inline-block; padding: 2px 7px;
170
- border-radius: 3px; margin-right: 4px;
171
- font-size: 8.5px; font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
172
- -webkit-print-color-adjust: exact;
173
- print-color-adjust: exact;
174
- }
175
- .retry-attempt--failed { background: var(--fail-dim); color: var(--fail); border: 1px solid #E5C9BD; }
176
- .retry-attempt--passed { background: var(--pass-dim); color: var(--pass); border: 1px solid #C5DBC9; }
133
+ /* ── Failure card · styles live in base.css (rendered inside the breakdown) ── */
177
134
 
178
135
  /* ── Defect log, coverage bar, environment table, failure analysis ───────── */
179
136
  /* All moved to base.css so executive/minimal can toggle these sections on too. */
@@ -132,35 +132,5 @@
132
132
  letter-spacing: 1.3px;
133
133
  }
134
134
 
135
- /* ── Executive failure summary ───────────────────────────────────────────── */
136
- .failure-summary-item {
137
- display: flex; align-items: center; justify-content: space-between;
138
- padding: 12px 16px;
139
- border-bottom: 1px solid var(--border);
140
- background: var(--surface);
141
- border-left: 3px solid var(--fail);
142
- }
143
- .failure-summary-item:first-child {
144
- border-radius: 6px 6px 0 0;
145
- border-top: 1px solid var(--border-2);
146
- border-right: 1px solid var(--border-2);
147
- }
148
- .failure-summary-item:last-child {
149
- border-radius: 0 0 6px 6px;
150
- border-bottom: 1px solid var(--border-2);
151
- border-right: 1px solid var(--border-2);
152
- }
153
- .failure-summary-item:only-child {
154
- border-radius: 6px;
155
- border: 1px solid var(--border-2);
156
- border-left: 3px solid var(--fail);
157
- }
158
- .failure-summary-item__title {
159
- font-size: 11px; font-weight: 600; color: var(--text-1);
160
- padding-left: 6px;
161
- }
162
- .failure-summary-item__suite {
163
- font-size: 9px; color: var(--text-3); margin-top: 3px;
164
- font-family: 'JetBrains Mono', monospace;
165
- padding-left: 6px;
166
- }
135
+ /* Executive failures now render inline in the suite breakdown (compact, no
136
+ screenshots) — card styles live in base.css. */
@@ -143,49 +143,4 @@
143
143
  }
144
144
  .suite-stats { gap: 6px; }
145
145
 
146
- /* ── Failure item ────────────────────────────────────────────────────────── */
147
- .failure-item {
148
- background: var(--surface);
149
- border: 1px solid var(--border-2);
150
- border-left: 3px solid var(--fail);
151
- border-radius: 6px;
152
- padding: 14px 16px; margin-bottom: 10px;
153
- page-break-inside: avoid;
154
- }
155
- .failure-item__header {
156
- display: flex; justify-content: space-between;
157
- align-items: flex-start; margin-bottom: 8px;
158
- }
159
- .failure-item__title {
160
- font-family: 'Inter', system-ui, sans-serif;
161
- font-weight: 600; font-size: 12px;
162
- color: var(--text-1); margin-bottom: 4px;
163
- }
164
- .failure-item__path {
165
- font-family: 'JetBrains Mono', monospace;
166
- font-size: 8.5px; color: var(--text-3); margin-bottom: 10px;
167
- }
168
- .failure-item__message {
169
- background: var(--fail-dim);
170
- border: 1px solid #E5C9BD;
171
- border-left: 2px solid var(--fail);
172
- border-radius: 4px;
173
- padding: 9px 12px; margin-bottom: 10px;
174
- font-family: 'JetBrains Mono', monospace;
175
- font-size: 9.5px; color: #8A2E1E;
176
- word-break: break-word; line-height: 1.6;
177
- }
178
- .retry-history {
179
- font-size: 9px; color: var(--text-3); margin-top: 10px;
180
- padding-top: 8px; border-top: 1px solid var(--border);
181
- }
182
- .retry-attempt {
183
- display: inline-block; padding: 2px 7px;
184
- border-radius: 3px; margin-right: 4px; font-size: 8.5px;
185
- font-family: 'Inter', system-ui, sans-serif; font-weight: 600;
186
- letter-spacing: 0.4px; text-transform: uppercase;
187
- -webkit-print-color-adjust: exact;
188
- print-color-adjust: exact;
189
- }
190
- .retry-attempt--failed { background: var(--fail-dim); color: var(--fail); border: 1px solid #E5C9BD; }
191
- .retry-attempt--passed { background: var(--pass-dim); color: var(--pass); border: 1px solid #C5DBC9; }
146
+ /* ── Failure card · styles live in base.css (rendered inside the breakdown) ── */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.6.3",
3
+ "version": "0.7.0",
4
4
  "description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "MIT",
6
6
  "author": "ReportForge",
@@ -1,42 +0,0 @@
1
- {{#if analysis.clusters.length}}
2
- <section class="failure-analysis">
3
- <h2>Failure Analysis</h2>
4
- <p class="oneliner">{{analysis.oneLiner}}</p>
5
-
6
- {{#if analysis.truncated}}
7
- <p class="analysis-truncated">
8
- Showing analysis of the first {{analysis.analysedCount}} of {{analysis.totalFailures}} failures.
9
- Raise <code>failureAnalysis.maxFailuresToAnalyse</code> for full coverage.
10
- </p>
11
- {{/if}}
12
-
13
- {{#each analysis.clusters}}
14
- <div class="cluster cluster--{{category}}">
15
- <div class="cluster-header">
16
- <span class="cluster-category">{{categoryLabel category}}</span>
17
- <span class="cluster-count">{{count}} {{pluralise count "test" "tests"}}</span>
18
- <span class="cluster-strength cluster-strength--{{strength}}">{{strengthLabel strength}} match</span>
19
- </div>
20
-
21
- {{#if stackFrame}}
22
- <div class="cluster-frame"><code>{{stackFrame}}</code></div>
23
- {{/if}}
24
-
25
- <div class="cluster-message">{{representativeMessage}}</div>
26
-
27
- <ul class="cluster-tests">
28
- {{#each (take tests 5)}}
29
- <li>{{this}}</li>
30
- {{/each}}
31
- {{#if (gt testsTotal 5)}}
32
- <li class="more">+{{subtract testsTotal 5}} more</li>
33
- {{/if}}
34
- </ul>
35
-
36
- {{#if (eq strength "weak")}}
37
- <p class="confidence-warning">* Weak match — manual review recommended</p>
38
- {{/if}}
39
- </div>
40
- {{/each}}
41
- </section>
42
- {{/if}}
@@ -1,62 +0,0 @@
1
- {{#if failures.length}}
2
- <div class="section">
3
- <h2>Failures ({{failures.length}}{{#if overflowFailureCount}} shown · {{overflowFailureCount}} more in sidecar{{/if}})</h2>
4
- {{#if overflowFailureCount}}
5
- <div class="overflow-banner">
6
- Showing the first {{failures.length}} failures. {{overflowFailureCount}} additional
7
- failure{{#if (gt overflowFailureCount 1)}}s are{{else}} is{{/if}} available in
8
- <code>{{overflowSidecarName}}</code> alongside this report.
9
- </div>
10
- {{/if}}
11
- {{#each failures}}
12
- <div class="failure-item failure-item--{{severity}} no-break">
13
- {{#if ../options.showFullFailures}}
14
-
15
- <div class="failure-item__header">
16
- <div>
17
- <div class="failure-item__title">{{testTitle}}</div>
18
- <div class="failure-item__path">{{join suitePath " › "}}</div>
19
- </div>
20
- <span class="badge badge--failed severity--{{severity}}" style="white-space:nowrap;">
21
- {{upperCase severity}}
22
- </span>
23
- </div>
24
-
25
- {{!-- Playwright's error.stack already begins with the message, so rendering
26
- both duplicates it. Show the formatted stack when available; otherwise
27
- fall back to the bare message. --}}
28
- {{#if (and error.stack ../options.showStackTraces)}}
29
- <pre>{{error.stack}}</pre>
30
- {{else}}
31
- <div class="failure-item__message">{{error.message}}</div>
32
- {{/if}}
33
-
34
- {{#if screenshotBase64}}
35
- <img class="screenshot" src="{{screenshotBase64}}" alt="Screenshot at failure">
36
- {{/if}}
37
-
38
- {{#if retryHistory.length}}
39
- <div class="retry-history">
40
- Retry history:&nbsp;
41
- {{#each retryHistory}}
42
- <span class="retry-attempt retry-attempt--{{status}}">
43
- #{{addOne attempt}} {{status}} · {{formatDuration duration}}
44
- </span>
45
- {{/each}}
46
- </div>
47
- {{/if}}
48
-
49
- {{else}}
50
- {{!-- Executive: title + suite only --}}
51
- <div class="failure-summary-item">
52
- <div>
53
- <div class="failure-summary-item__title">{{testTitle}}</div>
54
- <div class="failure-summary-item__suite">{{join suitePath " › "}}</div>
55
- </div>
56
- <span class="badge badge--failed">FAILED</span>
57
- </div>
58
- {{/if}}
59
- </div>
60
- {{/each}}
61
- </div>
62
- {{/if}}
@@ -1,27 +0,0 @@
1
- {{#if slowTests.length}}
2
- <div class="section">
3
- <h2>Slowest Tests</h2>
4
- <table>
5
- <thead>
6
- <tr>
7
- <th>Test</th>
8
- <th style="width:130px;">Suite</th>
9
- {{#if showProject}}<th style="width:80px;">Project</th>{{/if}}
10
- <th style="width:76px;text-align:right;">Duration</th>
11
- <th style="width:68px;">Status</th>
12
- </tr>
13
- </thead>
14
- <tbody>
15
- {{#each (take slowTests limit)}}
16
- <tr class="no-break">
17
- <td style="font-weight:500;">{{title}}</td>
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);white-space:nowrap;">{{project}}</td>{{/if}}
20
- <td style="font-family:'JetBrains Mono',monospace;font-size:9.5px;color:var(--text-2);text-align:right;">{{durationFormatted}}</td>
21
- <td>{{statusBadge status}}</td>
22
- </tr>
23
- {{/each}}
24
- </tbody>
25
- </table>
26
- </div>
27
- {{/if}}