@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/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 — below this, every test is trivially the "slowest" so badges stay hidden. On larger runs only genuine outliers (at least 2× the median test duration) are badged, so the badge stays rare. Set to `0` to drop the run-size gate (outliers still required). |
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,
@@ -6446,7 +6473,7 @@ function deriveOptions(s) {
6446
6473
  showFullFailures: s.fullFailures
6447
6474
  };
6448
6475
  }
6449
- var import_handlebars, import_fs, import_path, TemplateEngine;
6476
+ var import_handlebars, import_fs, import_path, SLOW_MEDIAN_FACTOR, TemplateEngine;
6450
6477
  var init_engine = __esm({
6451
6478
  "src/templates/engine.ts"() {
6452
6479
  "use strict";
@@ -6454,9 +6481,11 @@ 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();
6488
+ SLOW_MEDIAN_FACTOR = 2;
6460
6489
  TemplateEngine = class {
6461
6490
  constructor() {
6462
6491
  this.chartjsInline = "";
@@ -6482,17 +6511,17 @@ var init_engine = __esm({
6482
6511
  setChartjsBundle(inline) {
6483
6512
  this.chartjsInline = inline;
6484
6513
  }
6485
- render(data, template, userSections) {
6514
+ render(data, template, userSections, slowTestThreshold) {
6486
6515
  const layoutPath = import_path.default.join(this.templatesDir, "layouts", `${template}.hbs`);
6487
6516
  if (!import_fs.default.existsSync(layoutPath)) {
6488
6517
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
6489
6518
  }
6490
6519
  const layoutSource = import_fs.default.readFileSync(layoutPath, "utf8");
6491
6520
  const compiledLayout = this.handlebars.compile(layoutSource);
6492
- const ctx = this.buildContext(data, template, userSections);
6521
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
6493
6522
  return compiledLayout(ctx);
6494
6523
  }
6495
- renderFromPath(data, filePath) {
6524
+ renderFromPath(data, filePath, slowTestThreshold) {
6496
6525
  if (!this.customTemplateCache.has(filePath)) {
6497
6526
  if (!import_fs.default.existsSync(filePath)) {
6498
6527
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -6501,32 +6530,41 @@ var init_engine = __esm({
6501
6530
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
6502
6531
  }
6503
6532
  const compiledFn = this.customTemplateCache.get(filePath);
6504
- return compiledFn(this.buildCustomContext(data));
6533
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
6505
6534
  }
6506
- buildCustomContext(data) {
6535
+ buildCustomContext(data, slowTestThreshold) {
6507
6536
  const sections = allSectionsOn();
6537
+ const slow = this.buildSlowTests(data);
6508
6538
  return {
6509
6539
  ...data,
6540
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
6510
6541
  options: deriveOptions(sections),
6511
6542
  sections,
6512
6543
  tagGroups: this.buildTagGroups(data),
6513
- slowTests: this.buildSlowTests(data),
6544
+ slowTests: slow.ranked,
6514
6545
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
6515
6546
  templateCSS: "",
6516
6547
  chartjsScript: this.buildChartjsScript()
6517
6548
  };
6518
6549
  }
6519
- buildContext(data, template, userSections) {
6550
+ buildContext(data, template, userSections, slowTestThreshold) {
6520
6551
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
6521
6552
  const templateCSS = this.readStyle(`${template}.css`);
6522
6553
  const sections = resolveSections(template, userSections);
6523
6554
  const options = deriveOptions(sections);
6555
+ const slow = this.buildSlowTests(data);
6524
6556
  return {
6525
6557
  ...data,
6558
+ projects: this.enrichProjects(
6559
+ data,
6560
+ slow,
6561
+ sections,
6562
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
6563
+ ),
6526
6564
  options,
6527
6565
  sections,
6528
6566
  tagGroups: this.buildTagGroups(data),
6529
- slowTests: this.buildSlowTests(data),
6567
+ slowTests: slow.ranked,
6530
6568
  baseCSS,
6531
6569
  templateCSS,
6532
6570
  chartjsScript: this.buildChartjsScript()
@@ -6563,6 +6601,7 @@ var init_engine = __esm({
6563
6601
  for (const test of suite.tests) {
6564
6602
  if (test.duration > 0) {
6565
6603
  tests.push({
6604
+ id: test.id,
6566
6605
  title: test.title,
6567
6606
  suiteTitle: suite.title,
6568
6607
  durationMs: test.duration,
@@ -6574,7 +6613,39 @@ var init_engine = __esm({
6574
6613
  }
6575
6614
  }
6576
6615
  }
6577
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
6616
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
6617
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
6618
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
6619
+ return { ranked, timedTotal: tests.length, medianMs };
6620
+ }
6621
+ /**
6622
+ * Project the suite tree into its render form: attach each test's failure detail
6623
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
6624
+ * ReportData is never mutated. Gating lives here (not the template): a field is
6625
+ * attached only when its section is on, so the template just checks presence.
6626
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
6627
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
6628
+ */
6629
+ enrichProjects(data, slow, sections, slowTestThreshold) {
6630
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
6631
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
6632
+ const slowRankById = /* @__PURE__ */ new Map();
6633
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
6634
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
6635
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
6636
+ }
6637
+ const enrichTest = (t) => ({
6638
+ ...t,
6639
+ failure: failuresById.get(t.id),
6640
+ chip: chipById.get(t.id),
6641
+ slowRank: slowRankById.get(t.id)
6642
+ });
6643
+ const enrichSuite = (s) => ({
6644
+ ...s,
6645
+ suites: s.suites.map(enrichSuite),
6646
+ tests: s.tests.map(enrichTest)
6647
+ });
6648
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
6578
6649
  }
6579
6650
  readStyle(filename) {
6580
6651
  const cached = this.styleCache.get(filename);
@@ -7598,7 +7669,7 @@ var init_PdfGenerator = __esm({
7598
7669
  }
7599
7670
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
7600
7671
  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);
7672
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
7602
7673
  const tempHtmlPath = await this.htmlWriter.write(html);
7603
7674
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
7604
7675
  try {
@@ -11747,28 +11818,6 @@ var init_zod = __esm({
11747
11818
  }
11748
11819
  });
11749
11820
 
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
11821
  // src/config/schema.ts
11773
11822
  function parseOptions(raw) {
11774
11823
  const result = ReporterOptionsSchema.safeParse(raw ?? {});
@@ -11856,6 +11905,7 @@ var init_schema = __esm({
11856
11905
  historySize: external_exports.number().int().min(2).optional().default(DEFAULT_OPTIONS.historySize),
11857
11906
  showTrend: external_exports.boolean().optional().default(DEFAULT_OPTIONS.showTrend),
11858
11907
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
11908
+ slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
11859
11909
  failureAnalysis: failureAnalysisConfigSchema,
11860
11910
  templatePath: external_exports.union([
11861
11911
  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 +12124,14 @@ function classifyOne(f, model) {
12074
12124
  function classifyAll(failures, model) {
12075
12125
  return failures.map((f) => classifyOne(f, model));
12076
12126
  }
12127
+ function perTestClassifications(classified) {
12128
+ return classified.map((c) => ({
12129
+ testId: c.failure.testId,
12130
+ category: c.category,
12131
+ message: representativeMessage(c.failure.error.message),
12132
+ strength: strengthOf(c.margin)
12133
+ }));
12134
+ }
12077
12135
  function clusterClassified(classified) {
12078
12136
  const groups = /* @__PURE__ */ new Map();
12079
12137
  for (const c of classified) {
@@ -12246,6 +12304,7 @@ function runFailureAnalysis(reportData, options, cwd) {
12246
12304
  const analysed = reportData.failures.slice(0, cap);
12247
12305
  const model = loadModel(fa.autoUpdateModel);
12248
12306
  const classified = classifyAll(analysed, model);
12307
+ reportData.classifications = perTestClassifications(classified);
12249
12308
  if (templatesConsumeAnalysis(options)) {
12250
12309
  reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
12251
12310
  }
package/dist/index.d.mts CHANGED
@@ -234,6 +234,15 @@ 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 — below this, every test is trivially the "slowest", so badges
240
+ * stay hidden. On larger runs only genuine outliers (at least 2× the median
241
+ * test duration) are badged, so the badge stays rare. Set to 0 to drop the
242
+ * run-size gate (outliers are still required). Must be a non-negative integer.
243
+ * @default 10
244
+ */
245
+ slowTestThreshold?: number;
237
246
  /**
238
247
  * Path to a custom Handlebars (.hbs) template file. When set, takes
239
248
  * precedence over `template`. Relative paths resolve from `process.cwd()`.
package/dist/index.d.ts CHANGED
@@ -234,6 +234,15 @@ 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 — below this, every test is trivially the "slowest", so badges
240
+ * stay hidden. On larger runs only genuine outliers (at least 2× the median
241
+ * test duration) are badged, so the badge stays rare. Set to 0 to drop the
242
+ * run-size gate (outliers are still required). Must be a non-negative integer.
243
+ * @default 10
244
+ */
245
+ slowTestThreshold?: number;
237
246
  /**
238
247
  * Path to a custom Handlebars (.hbs) template file. When set, takes
239
248
  * 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
  }
@@ -12033,6 +12048,7 @@ function jsonForInlineScript(value) {
12033
12048
  function isUnknownValue(v) {
12034
12049
  return !v || v === "unknown";
12035
12050
  }
12051
+ var SLOW_MEDIAN_FACTOR = 2;
12036
12052
  function deriveOptions(s) {
12037
12053
  return {
12038
12054
  showCharts: s.charts,
@@ -12069,17 +12085,17 @@ var TemplateEngine = class {
12069
12085
  setChartjsBundle(inline) {
12070
12086
  this.chartjsInline = inline;
12071
12087
  }
12072
- render(data, template, userSections) {
12088
+ render(data, template, userSections, slowTestThreshold) {
12073
12089
  const layoutPath = import_path3.default.join(this.templatesDir, "layouts", `${template}.hbs`);
12074
12090
  if (!import_fs4.default.existsSync(layoutPath)) {
12075
12091
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12076
12092
  }
12077
12093
  const layoutSource = import_fs4.default.readFileSync(layoutPath, "utf8");
12078
12094
  const compiledLayout = this.handlebars.compile(layoutSource);
12079
- const ctx = this.buildContext(data, template, userSections);
12095
+ const ctx = this.buildContext(data, template, userSections, slowTestThreshold);
12080
12096
  return compiledLayout(ctx);
12081
12097
  }
12082
- renderFromPath(data, filePath) {
12098
+ renderFromPath(data, filePath, slowTestThreshold) {
12083
12099
  if (!this.customTemplateCache.has(filePath)) {
12084
12100
  if (!import_fs4.default.existsSync(filePath)) {
12085
12101
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
@@ -12088,32 +12104,41 @@ var TemplateEngine = class {
12088
12104
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12089
12105
  }
12090
12106
  const compiledFn = this.customTemplateCache.get(filePath);
12091
- return compiledFn(this.buildCustomContext(data));
12107
+ return compiledFn(this.buildCustomContext(data, slowTestThreshold));
12092
12108
  }
12093
- buildCustomContext(data) {
12109
+ buildCustomContext(data, slowTestThreshold) {
12094
12110
  const sections = allSectionsOn();
12111
+ const slow = this.buildSlowTests(data);
12095
12112
  return {
12096
12113
  ...data,
12114
+ projects: this.enrichProjects(data, slow, sections, slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold),
12097
12115
  options: deriveOptions(sections),
12098
12116
  sections,
12099
12117
  tagGroups: this.buildTagGroups(data),
12100
- slowTests: this.buildSlowTests(data),
12118
+ slowTests: slow.ranked,
12101
12119
  baseCSS: this.fontsBundleCss + "\n" + this.readStyle("base.css"),
12102
12120
  templateCSS: "",
12103
12121
  chartjsScript: this.buildChartjsScript()
12104
12122
  };
12105
12123
  }
12106
- buildContext(data, template, userSections) {
12124
+ buildContext(data, template, userSections, slowTestThreshold) {
12107
12125
  const baseCSS = this.fontsBundleCss + "\n" + this.readStyle("base.css");
12108
12126
  const templateCSS = this.readStyle(`${template}.css`);
12109
12127
  const sections = resolveSections(template, userSections);
12110
12128
  const options = deriveOptions(sections);
12129
+ const slow = this.buildSlowTests(data);
12111
12130
  return {
12112
12131
  ...data,
12132
+ projects: this.enrichProjects(
12133
+ data,
12134
+ slow,
12135
+ sections,
12136
+ slowTestThreshold ?? DEFAULT_OPTIONS.slowTestThreshold
12137
+ ),
12113
12138
  options,
12114
12139
  sections,
12115
12140
  tagGroups: this.buildTagGroups(data),
12116
- slowTests: this.buildSlowTests(data),
12141
+ slowTests: slow.ranked,
12117
12142
  baseCSS,
12118
12143
  templateCSS,
12119
12144
  chartjsScript: this.buildChartjsScript()
@@ -12150,6 +12175,7 @@ var TemplateEngine = class {
12150
12175
  for (const test of suite.tests) {
12151
12176
  if (test.duration > 0) {
12152
12177
  tests.push({
12178
+ id: test.id,
12153
12179
  title: test.title,
12154
12180
  suiteTitle: suite.title,
12155
12181
  durationMs: test.duration,
@@ -12161,7 +12187,39 @@ var TemplateEngine = class {
12161
12187
  }
12162
12188
  }
12163
12189
  }
12164
- return tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12190
+ const sortedDurations = tests.map((t) => t.durationMs).sort((a, b) => a - b);
12191
+ const medianMs = sortedDurations.length ? sortedDurations[Math.floor((sortedDurations.length - 1) / 2)] : 0;
12192
+ const ranked = tests.sort((a, b) => b.durationMs - a.durationMs).slice(0, 10);
12193
+ return { ranked, timedTotal: tests.length, medianMs };
12194
+ }
12195
+ /**
12196
+ * Project the suite tree into its render form: attach each test's failure detail
12197
+ * (by testId), root-cause chip, and slow rank. A pure projection — the source
12198
+ * ReportData is never mutated. Gating lives here (not the template): a field is
12199
+ * attached only when its section is on, so the template just checks presence.
12200
+ * `failuresById` is built from the (possibly overflow-trimmed) `failures` array,
12201
+ * so a trimmed failure yields no inline card; the overflow note points to the sidecar.
12202
+ */
12203
+ enrichProjects(data, slow, sections, slowTestThreshold) {
12204
+ const failuresById = sections.failureDeepDive ? new Map(data.failures.map((f) => [f.testId, f])) : /* @__PURE__ */ new Map();
12205
+ const chipById = sections.failureAnalysis ? new Map((data.classifications ?? []).map((c) => [c.testId, c])) : /* @__PURE__ */ new Map();
12206
+ const slowRankById = /* @__PURE__ */ new Map();
12207
+ if (sections.slowTests && slow.timedTotal > slowTestThreshold) {
12208
+ const floorMs = slow.medianMs * SLOW_MEDIAN_FACTOR;
12209
+ slow.ranked.filter((t) => t.durationMs >= floorMs).forEach((t, i) => slowRankById.set(t.id, i + 1));
12210
+ }
12211
+ const enrichTest = (t) => ({
12212
+ ...t,
12213
+ failure: failuresById.get(t.id),
12214
+ chip: chipById.get(t.id),
12215
+ slowRank: slowRankById.get(t.id)
12216
+ });
12217
+ const enrichSuite = (s) => ({
12218
+ ...s,
12219
+ suites: s.suites.map(enrichSuite),
12220
+ tests: s.tests.map(enrichTest)
12221
+ });
12222
+ return data.projects.map((p) => ({ ...p, suites: p.suites.map(enrichSuite) }));
12165
12223
  }
12166
12224
  readStyle(filename) {
12167
12225
  const cached = this.styleCache.get(filename);
@@ -12840,7 +12898,7 @@ var PdfGenerator = class {
12840
12898
  }
12841
12899
  const templateLabel = source.kind === "builtin" ? source.id : source.label;
12842
12900
  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);
12901
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, options.sections, options.slowTestThreshold) : this.templateEngine.renderFromPath(renderData, source.absolutePath, options.slowTestThreshold);
12844
12902
  const tempHtmlPath = await this.htmlWriter.write(html);
12845
12903
  const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
12846
12904
  try {
@@ -13237,7 +13295,7 @@ var PdfReporter = class {
13237
13295
  this.dataCollector = new DataCollector();
13238
13296
  this.pdfGenerator = new PdfGenerator();
13239
13297
  this.options = parseOptions(rawOptions);
13240
- const version = true ? "0.6.3" : "0.x";
13298
+ const version = true ? "0.7.1" : "0.x";
13241
13299
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13242
13300
  this.licenseClient = new LicenseClient({
13243
13301
  licenseKey: this.options.licenseKey,