@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 +12 -10
- package/dist/cli/index.js +93 -34
- package/dist/index.d.mts +9 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +71 -13
- package/dist/index.mjs +71 -13
- package/dist/templates/layouts/detailed.hbs +2 -8
- package/dist/templates/layouts/executive.hbs +2 -8
- package/dist/templates/layouts/minimal.hbs +2 -8
- package/dist/templates/partials/charts.hbs +13 -7
- package/dist/templates/partials/executive-summary.hbs +18 -4
- package/dist/templates/partials/failure-card.hbs +27 -0
- package/dist/templates/partials/suite-breakdown.hbs +31 -5
- package/dist/templates/styles/base.css +98 -26
- package/dist/templates/styles/detailed.css +1 -44
- package/dist/templates/styles/executive.css +2 -32
- package/dist/templates/styles/minimal.css +1 -46
- package/package.json +1 -1
- package/dist/templates/partials/failure-analysis.hbs +0 -42
- package/dist/templates/partials/failure-deep-dive.hbs +0 -62
- package/dist/templates/partials/slow-tests.hbs +0 -27
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
|
|
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
|
|
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` |
|
|
396
|
-
| `failureAnalysis` |
|
|
397
|
-
| `slowTests` |
|
|
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` |
|
|
408
|
-
| `stackTraces` | Include `<pre>` stack traces in the failure
|
|
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
|
-
- **
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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.
|
|
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,
|