@reportforge/playwright-pdf 0.13.1 → 0.14.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/CHANGELOG.md +28 -0
- package/README.md +2 -1
- package/dist/cli/index.js +62 -22
- package/dist/index.d.mts +3 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +121 -65
- package/dist/index.mjs +121 -65
- package/dist/templates/layouts/detailed.hbs +2 -0
- package/dist/templates/layouts/executive.hbs +4 -2
- package/dist/templates/layouts/minimal.hbs +2 -0
- package/dist/templates/partials/brief-band.hbs +6 -0
- package/dist/templates/partials/chart-summary-grid.hbs +18 -0
- package/dist/templates/partials/chart-trend-card.hbs +16 -0
- package/dist/templates/partials/charts.hbs +7 -30
- package/dist/templates/partials/executive-hero-summary.hbs +66 -0
- package/dist/templates/styles/base.css +22 -0
- package/dist/templates/styles/executive.css +31 -0
- package/dist/templates/styles/minimal.css +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## [0.14.0] - 2026-07-06
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Executive report leads with a plain-language brief and a hero pass-rate stat**: a new tinted brief band composes the trend delta and root-cause tally into one sentence (e.g. "Pass rate climbed 4 points to 92%... 3 root causes remain: 2 assertion, 1 timeout"), replacing the 6-card KPI grid with a single hero pass-rate number and a compact secondary stat row. The trend chart is promoted above the smaller pass-rate/suite-results charts. `detailed` and `minimal` templates are unchanged.
|
|
11
|
+
- **New `sections.briefBand` option** to toggle the brief band on any template (on by default for `executive`).
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **Flaky doughnut slice now matches the `--flaky` design token** instead of a stray hardcoded color.
|
|
16
|
+
- **`minimal` template's header no longer uses a side-stripe border**, now consistent with the design system's top-accent-bar convention used everywhere else.
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## [0.13.2] - 2026-07-04
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **License refresh no longer wipes a valid cache on rate-limit**: an HTTP 429/408 from the license server was misclassified as a denial, clearing a still-valid cached license and skipping PDF generation. Now treated as transient — falls back to the cache.
|
|
25
|
+
- **`pdfPassword` encryption now actually works**: a malformed qpdf command meant every password-protected PDF shipped unencrypted while the run reported success. qpdf failures are now fatal instead of silent, and the password is no longer passed via process arguments.
|
|
26
|
+
- **`test.fail()` expected failures no longer miscounted**: an expected failure was counted as a failure in the KPI stats and failure list even though the suite tree correctly showed it as passed.
|
|
27
|
+
- **Sharded runs: describe blocks split across shards now merge correctly** instead of rendering as duplicate, half-populated nodes.
|
|
28
|
+
- **Sharded runs: failure severity now reflects tags/annotations** instead of always showing "medium".
|
|
29
|
+
- **Custom templates sharing a basename no longer overwrite each other's output.**
|
|
30
|
+
- **PDF size-cap retuning now actually reduces the inline failure count** when the report has fewer real failures than the configured cap.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
6
34
|
## [0.13.1] - 2026-07-03
|
|
7
35
|
|
|
8
36
|
### Changed
|
package/README.md
CHANGED
|
@@ -61,6 +61,7 @@ Three templates, one reporter. Every PDF is fully offline: fonts, charts, and sc
|
|
|
61
61
|
- `npx @reportforge/playwright-pdf export-feedback`: exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
|
|
62
62
|
- **Rich execution capture**: opt-in "Steps to Reproduce" outline (a nested, copy-pasteable Markdown list of your `test.step`s and assertions; add `apiSteps` for the full click/fill/check trail), Node console tail, and trace/video links under each failed test in the defect log. Read straight from the Playwright step tree: no fixtures, no test-code changes. Enable via `capture` (`steps` / `apiSteps` / `console` / `evidence`); off by default.
|
|
63
63
|
- **Configurable sections**: add or remove any report section per template via `sections`.
|
|
64
|
+
- **Executive report brief band**: leads with a plain-language brief (trend + root causes) and one hero pass-rate number instead of a flat KPI grid
|
|
64
65
|
- **Hybrid licensing**: one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
|
|
65
66
|
|
|
66
67
|
---
|
|
@@ -204,7 +205,7 @@ All options are the second element of the reporter tuple.
|
|
|
204
205
|
| `flakinessTopN` | `number` | `5` | Maximum flaky tests to show in the flakiness table (`detailed` template). Set to `0` to disable the table entirely. |
|
|
205
206
|
| `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). |
|
|
206
207
|
| `templatePath` | `string \| string[]` | n/a | 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. |
|
|
207
|
-
| `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. |
|
|
208
|
+
| `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`, `briefBand`. Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`, `stackTraces`. See the Report Sections docs. |
|
|
208
209
|
<!-- AUTOGEN:options-table END -->
|
|
209
210
|
|
|
210
211
|
Full reference: [reportforge.org/docs/configuration](https://reportforge.org/docs/configuration).
|
package/dist/cli/index.js
CHANGED
|
@@ -6388,6 +6388,25 @@ function buildOneLiner(clusters) {
|
|
|
6388
6388
|
function dominantCategory(clusters) {
|
|
6389
6389
|
return clusters[0]?.category ?? "unknown";
|
|
6390
6390
|
}
|
|
6391
|
+
function trendClause(passRate, delta) {
|
|
6392
|
+
if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
|
|
6393
|
+
const verb = delta > 0 ? "climbed" : "dropped";
|
|
6394
|
+
const abs = Math.abs(delta);
|
|
6395
|
+
const points = abs === 1 ? "point" : "points";
|
|
6396
|
+
return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
|
|
6397
|
+
}
|
|
6398
|
+
function causesClause(analysis) {
|
|
6399
|
+
const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
|
|
6400
|
+
const verb = analysis.clusters.length === 1 ? "remains" : "remain";
|
|
6401
|
+
return `${head} ${verb}: ${tallyParts.join(", ")}.`;
|
|
6402
|
+
}
|
|
6403
|
+
function buildBriefSentence(stats, analysis, trend) {
|
|
6404
|
+
if (trend && trend.delta !== null) {
|
|
6405
|
+
const trendSentence = trendClause(stats.passRate, trend.delta);
|
|
6406
|
+
return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
|
|
6407
|
+
}
|
|
6408
|
+
return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
|
|
6409
|
+
}
|
|
6391
6410
|
var CATEGORY_LABEL, SHORT_LABEL;
|
|
6392
6411
|
var init_SummaryWriter = __esm({
|
|
6393
6412
|
"src/analysis/SummaryWriter.ts"() {
|
|
@@ -6457,6 +6476,7 @@ var init_sections = __esm({
|
|
|
6457
6476
|
"failureAnalysis",
|
|
6458
6477
|
"slowTests",
|
|
6459
6478
|
"defectLog",
|
|
6479
|
+
"briefBand",
|
|
6460
6480
|
// display modifiers
|
|
6461
6481
|
"passRate",
|
|
6462
6482
|
"fullEnvironment",
|
|
@@ -6479,6 +6499,7 @@ var init_sections = __esm({
|
|
|
6479
6499
|
failureAnalysis: false,
|
|
6480
6500
|
slowTests: false,
|
|
6481
6501
|
defectLog: false,
|
|
6502
|
+
briefBand: false,
|
|
6482
6503
|
passRate: true,
|
|
6483
6504
|
fullEnvironment: false,
|
|
6484
6505
|
retries: true,
|
|
@@ -6499,6 +6520,7 @@ var init_sections = __esm({
|
|
|
6499
6520
|
failureAnalysis: true,
|
|
6500
6521
|
slowTests: true,
|
|
6501
6522
|
defectLog: true,
|
|
6523
|
+
briefBand: false,
|
|
6502
6524
|
passRate: true,
|
|
6503
6525
|
fullEnvironment: true,
|
|
6504
6526
|
retries: true,
|
|
@@ -6507,7 +6529,9 @@ var init_sections = __esm({
|
|
|
6507
6529
|
},
|
|
6508
6530
|
executive: {
|
|
6509
6531
|
coverPage: true,
|
|
6510
|
-
|
|
6532
|
+
// Now off: its content (root-cause tally) is subsumed by briefBand below,
|
|
6533
|
+
// which also adds the trend delta. Showing both would say it twice.
|
|
6534
|
+
analysisOneliner: false,
|
|
6511
6535
|
releaseGate: true,
|
|
6512
6536
|
summary: true,
|
|
6513
6537
|
charts: true,
|
|
@@ -6522,6 +6546,7 @@ var init_sections = __esm({
|
|
|
6522
6546
|
failureAnalysis: false,
|
|
6523
6547
|
slowTests: true,
|
|
6524
6548
|
defectLog: false,
|
|
6549
|
+
briefBand: true,
|
|
6525
6550
|
passRate: true,
|
|
6526
6551
|
fullEnvironment: false,
|
|
6527
6552
|
retries: false,
|
|
@@ -7325,25 +7350,18 @@ var init_PdfEncryptor = __esm({
|
|
|
7325
7350
|
return;
|
|
7326
7351
|
}
|
|
7327
7352
|
const tmpPath = import_path3.default.join(import_os2.default.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
|
|
7328
|
-
const
|
|
7353
|
+
const argFile = import_path3.default.join(import_os2.default.tmpdir(), `rf-qpdf-args-${import_crypto2.default.randomBytes(8).toString("hex")}`);
|
|
7329
7354
|
try {
|
|
7330
|
-
|
|
7331
|
-
|
|
7332
|
-
(0, import_child_process2.execFileSync)("qpdf", [
|
|
7333
|
-
`--password-file=${pwFile}`,
|
|
7334
|
-
"--encrypt",
|
|
7335
|
-
"256",
|
|
7336
|
-
"--",
|
|
7337
|
-
pdfPath,
|
|
7338
|
-
tmpPath
|
|
7339
|
-
], { stdio: "pipe" });
|
|
7355
|
+
const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
|
|
7356
|
+
import_fs5.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
|
|
7357
|
+
(0, import_child_process2.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
|
|
7340
7358
|
import_fs5.default.renameSync(tmpPath, pdfPath);
|
|
7341
7359
|
logger.info(`PDF encrypted with password.`);
|
|
7342
7360
|
} catch (e) {
|
|
7343
|
-
|
|
7361
|
+
throw new Error(`qpdf encryption failed: ${e.message}`);
|
|
7344
7362
|
} finally {
|
|
7345
7363
|
if (import_fs5.default.existsSync(tmpPath)) import_fs5.default.unlinkSync(tmpPath);
|
|
7346
|
-
import_fs5.default.rmSync(
|
|
7364
|
+
import_fs5.default.rmSync(argFile, { force: true });
|
|
7347
7365
|
}
|
|
7348
7366
|
}
|
|
7349
7367
|
isQpdfAvailable() {
|
|
@@ -7668,7 +7686,8 @@ var init_PdfGenerator = __esm({
|
|
|
7668
7686
|
if (capBytes > 0) {
|
|
7669
7687
|
const sizeBytes = (await import_fs8.default.promises.stat(outputPath)).size;
|
|
7670
7688
|
if (sizeBytes > capBytes) {
|
|
7671
|
-
const
|
|
7689
|
+
const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
|
|
7690
|
+
const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
|
|
7672
7691
|
logger.warn(
|
|
7673
7692
|
`PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
|
|
7674
7693
|
);
|
|
@@ -7697,11 +7716,30 @@ var init_PdfGenerator = __esm({
|
|
|
7697
7716
|
}
|
|
7698
7717
|
resolveTemplateSources(options) {
|
|
7699
7718
|
if (options.templatePath && options.templatePath.length > 0) {
|
|
7700
|
-
|
|
7701
|
-
|
|
7702
|
-
absolutePath
|
|
7703
|
-
|
|
7704
|
-
}
|
|
7719
|
+
const byAbsolutePath = /* @__PURE__ */ new Map();
|
|
7720
|
+
for (const p of options.templatePath) {
|
|
7721
|
+
const absolutePath = import_path6.default.isAbsolute(p) ? p : import_path6.default.resolve(process.cwd(), p);
|
|
7722
|
+
if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
|
|
7723
|
+
}
|
|
7724
|
+
const labelCounts = /* @__PURE__ */ new Map();
|
|
7725
|
+
for (const rawPath of byAbsolutePath.values()) {
|
|
7726
|
+
const label = import_path6.default.basename(rawPath, ".hbs");
|
|
7727
|
+
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
|
7728
|
+
}
|
|
7729
|
+
const seenPerLabel = /* @__PURE__ */ new Map();
|
|
7730
|
+
return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
|
|
7731
|
+
const baseLabel = import_path6.default.basename(rawPath, ".hbs");
|
|
7732
|
+
let label = baseLabel;
|
|
7733
|
+
if ((labelCounts.get(baseLabel) ?? 0) > 1) {
|
|
7734
|
+
const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
|
|
7735
|
+
seenPerLabel.set(baseLabel, n);
|
|
7736
|
+
label = `${baseLabel}-${n}`;
|
|
7737
|
+
logger.warn(
|
|
7738
|
+
`Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
|
|
7739
|
+
);
|
|
7740
|
+
}
|
|
7741
|
+
return { kind: "custom", absolutePath, label };
|
|
7742
|
+
});
|
|
7705
7743
|
}
|
|
7706
7744
|
const rawIds = Array.isArray(options.template) ? options.template : [options.template];
|
|
7707
7745
|
return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
|
|
@@ -7723,8 +7761,8 @@ var init_PdfGenerator = __esm({
|
|
|
7723
7761
|
* Also tightens JPEG quality and width to claw back bytes from each
|
|
7724
7762
|
* remaining image.
|
|
7725
7763
|
*/
|
|
7726
|
-
tightenForCap(prev, actualBytes, capBytes) {
|
|
7727
|
-
const observedPerFailure = actualBytes / Math.max(1,
|
|
7764
|
+
tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
|
|
7765
|
+
const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
|
|
7728
7766
|
const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
|
|
7729
7767
|
return {
|
|
7730
7768
|
effective: "max",
|
|
@@ -12489,6 +12527,7 @@ async function run() {
|
|
|
12489
12527
|
const options = parseOptions({ template, outputFile, failureAnalysis: { collectUnclassified: false } });
|
|
12490
12528
|
const generator = new PdfGenerator();
|
|
12491
12529
|
runFailureAnalysis(reportData, options, process.cwd());
|
|
12530
|
+
reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
|
|
12492
12531
|
const paths = await generator.generateAll(reportData, options, DEMO_LICENSE_INFO2);
|
|
12493
12532
|
if (paths.length > 0) {
|
|
12494
12533
|
console.log(`
|
|
@@ -12529,6 +12568,7 @@ var init_cli = __esm({
|
|
|
12529
12568
|
init_schema();
|
|
12530
12569
|
init_types2();
|
|
12531
12570
|
init_analysis();
|
|
12571
|
+
init_SummaryWriter();
|
|
12532
12572
|
DEMO_LICENSE_INFO2 = {
|
|
12533
12573
|
plan: "subscription",
|
|
12534
12574
|
source: "demo",
|
package/dist/index.d.mts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Canonical section catalog.
|
|
4
|
+
* Canonical section catalog. 14 block toggles (section present/absent) followed
|
|
5
5
|
* by 5 display modifiers (tweak a section that is on). Single source of truth —
|
|
6
6
|
* the Zod schema in src/config/schema.ts builds its flag shape from this array.
|
|
7
7
|
*/
|
|
8
|
-
declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
|
|
8
|
+
declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
|
|
9
9
|
type SectionKey = (typeof SECTION_KEYS)[number];
|
|
10
10
|
/** A partial map of section flags — used both as the flat baseline and per-template. */
|
|
11
11
|
type SectionFlags = Partial<Record<SectionKey, boolean>>;
|
|
@@ -332,7 +332,7 @@ interface ReporterOptions {
|
|
|
332
332
|
*
|
|
333
333
|
* Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
|
|
334
334
|
* `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
|
|
335
|
-
* `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`.
|
|
335
|
+
* `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
|
|
336
336
|
* Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
|
|
337
337
|
* `stackTraces`.
|
|
338
338
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Reporter, FullConfig, Suite, TestCase, TestResult, TestStep, FullResult } from '@playwright/test/reporter';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Canonical section catalog.
|
|
4
|
+
* Canonical section catalog. 14 block toggles (section present/absent) followed
|
|
5
5
|
* by 5 display modifiers (tweak a section that is on). Single source of truth —
|
|
6
6
|
* the Zod schema in src/config/schema.ts builds its flag shape from this array.
|
|
7
7
|
*/
|
|
8
|
-
declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
|
|
8
|
+
declare const SECTION_KEYS: readonly ["coverPage", "analysisOneliner", "releaseGate", "summary", "charts", "trend", "requirementsMatrix", "ciEnvironment", "suiteBreakdown", "failureDeepDive", "failureAnalysis", "slowTests", "defectLog", "briefBand", "passRate", "fullEnvironment", "retries", "fullFailures", "stackTraces"];
|
|
9
9
|
type SectionKey = (typeof SECTION_KEYS)[number];
|
|
10
10
|
/** A partial map of section flags — used both as the flat baseline and per-template. */
|
|
11
11
|
type SectionFlags = Partial<Record<SectionKey, boolean>>;
|
|
@@ -332,7 +332,7 @@ interface ReporterOptions {
|
|
|
332
332
|
*
|
|
333
333
|
* Block toggles: `coverPage`, `analysisOneliner`, `releaseGate`, `summary`,
|
|
334
334
|
* `charts`, `trend`, `requirementsMatrix`, `ciEnvironment`, `suiteBreakdown`,
|
|
335
|
-
* `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`.
|
|
335
|
+
* `failureDeepDive`, `failureAnalysis`, `slowTests`, `defectLog`, `briefBand`.
|
|
336
336
|
* Display modifiers: `passRate`, `fullEnvironment`, `retries`, `fullFailures`,
|
|
337
337
|
* `stackTraces`.
|
|
338
338
|
*
|
package/dist/index.js
CHANGED
|
@@ -10350,6 +10350,7 @@ var SECTION_KEYS = [
|
|
|
10350
10350
|
"failureAnalysis",
|
|
10351
10351
|
"slowTests",
|
|
10352
10352
|
"defectLog",
|
|
10353
|
+
"briefBand",
|
|
10353
10354
|
// display modifiers
|
|
10354
10355
|
"passRate",
|
|
10355
10356
|
"fullEnvironment",
|
|
@@ -10372,6 +10373,7 @@ var SECTION_DEFAULTS = {
|
|
|
10372
10373
|
failureAnalysis: false,
|
|
10373
10374
|
slowTests: false,
|
|
10374
10375
|
defectLog: false,
|
|
10376
|
+
briefBand: false,
|
|
10375
10377
|
passRate: true,
|
|
10376
10378
|
fullEnvironment: false,
|
|
10377
10379
|
retries: true,
|
|
@@ -10392,6 +10394,7 @@ var SECTION_DEFAULTS = {
|
|
|
10392
10394
|
failureAnalysis: true,
|
|
10393
10395
|
slowTests: true,
|
|
10394
10396
|
defectLog: true,
|
|
10397
|
+
briefBand: false,
|
|
10395
10398
|
passRate: true,
|
|
10396
10399
|
fullEnvironment: true,
|
|
10397
10400
|
retries: true,
|
|
@@ -10400,7 +10403,9 @@ var SECTION_DEFAULTS = {
|
|
|
10400
10403
|
},
|
|
10401
10404
|
executive: {
|
|
10402
10405
|
coverPage: true,
|
|
10403
|
-
|
|
10406
|
+
// Now off: its content (root-cause tally) is subsumed by briefBand below,
|
|
10407
|
+
// which also adds the trend delta. Showing both would say it twice.
|
|
10408
|
+
analysisOneliner: false,
|
|
10404
10409
|
releaseGate: true,
|
|
10405
10410
|
summary: true,
|
|
10406
10411
|
charts: true,
|
|
@@ -10415,6 +10420,7 @@ var SECTION_DEFAULTS = {
|
|
|
10415
10420
|
failureAnalysis: false,
|
|
10416
10421
|
slowTests: true,
|
|
10417
10422
|
defectLog: false,
|
|
10423
|
+
briefBand: true,
|
|
10418
10424
|
passRate: true,
|
|
10419
10425
|
fullEnvironment: false,
|
|
10420
10426
|
retries: false,
|
|
@@ -11061,6 +11067,11 @@ var LicenseClient = class {
|
|
|
11061
11067
|
body: JSON.stringify(body),
|
|
11062
11068
|
signal: controller.signal
|
|
11063
11069
|
});
|
|
11070
|
+
if (res.status === 429 || res.status === 408) {
|
|
11071
|
+
await res.body?.cancel().catch(() => {
|
|
11072
|
+
});
|
|
11073
|
+
throw new Error(`HTTP ${res.status}`);
|
|
11074
|
+
}
|
|
11064
11075
|
if (res.status >= 400 && res.status < 500) {
|
|
11065
11076
|
const text = await res.text().catch(() => "");
|
|
11066
11077
|
throw new Error(`license-denied: ${text || `HTTP ${res.status}`}`);
|
|
@@ -11293,6 +11304,25 @@ function buildOneLiner(clusters) {
|
|
|
11293
11304
|
function dominantCategory(clusters) {
|
|
11294
11305
|
return clusters[0]?.category ?? "unknown";
|
|
11295
11306
|
}
|
|
11307
|
+
function trendClause(passRate, delta) {
|
|
11308
|
+
if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
|
|
11309
|
+
const verb = delta > 0 ? "climbed" : "dropped";
|
|
11310
|
+
const abs = Math.abs(delta);
|
|
11311
|
+
const points = abs === 1 ? "point" : "points";
|
|
11312
|
+
return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
|
|
11313
|
+
}
|
|
11314
|
+
function causesClause(analysis) {
|
|
11315
|
+
const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
|
|
11316
|
+
const verb = analysis.clusters.length === 1 ? "remains" : "remain";
|
|
11317
|
+
return `${head} ${verb}: ${tallyParts.join(", ")}.`;
|
|
11318
|
+
}
|
|
11319
|
+
function buildBriefSentence(stats, analysis, trend) {
|
|
11320
|
+
if (trend && trend.delta !== null) {
|
|
11321
|
+
const trendSentence = trendClause(stats.passRate, trend.delta);
|
|
11322
|
+
return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
|
|
11323
|
+
}
|
|
11324
|
+
return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
|
|
11325
|
+
}
|
|
11296
11326
|
|
|
11297
11327
|
// src/analysis/UnclassifiedCollector.ts
|
|
11298
11328
|
init_cjs_shims();
|
|
@@ -11619,6 +11649,17 @@ init_cjs_shims();
|
|
|
11619
11649
|
// src/collector/stats-utils.ts
|
|
11620
11650
|
init_cjs_shims();
|
|
11621
11651
|
var TOP_SUITES_FOR_CHART = 10;
|
|
11652
|
+
function resolveTestStatus(test, result) {
|
|
11653
|
+
if (!result) return "skipped";
|
|
11654
|
+
const expected = test.expectedStatus ?? "passed";
|
|
11655
|
+
const actual = result.status;
|
|
11656
|
+
if (actual === "skipped") return "skipped";
|
|
11657
|
+
if (actual === "timedOut") return "timedOut";
|
|
11658
|
+
if (actual === "passed" && result.retry > 0) return "flaky";
|
|
11659
|
+
if (actual === "passed" && expected === "passed") return "passed";
|
|
11660
|
+
if (actual === "failed" && expected === "failed") return "passed";
|
|
11661
|
+
return "failed";
|
|
11662
|
+
}
|
|
11622
11663
|
function statsFromTests(tests) {
|
|
11623
11664
|
return {
|
|
11624
11665
|
total: tests.length,
|
|
@@ -11694,7 +11735,7 @@ var SuiteWalker = class {
|
|
|
11694
11735
|
}
|
|
11695
11736
|
buildTest(test, resultMap) {
|
|
11696
11737
|
const result = resultMap.get(test.id);
|
|
11697
|
-
const status =
|
|
11738
|
+
const status = resolveTestStatus(test, result);
|
|
11698
11739
|
return {
|
|
11699
11740
|
id: test.id,
|
|
11700
11741
|
title: test.title,
|
|
@@ -11707,17 +11748,6 @@ var SuiteWalker = class {
|
|
|
11707
11748
|
location: test.location ? `${test.location.file}:${test.location.line}` : void 0
|
|
11708
11749
|
};
|
|
11709
11750
|
}
|
|
11710
|
-
resolveStatus(test, result) {
|
|
11711
|
-
if (!result) return "skipped";
|
|
11712
|
-
const expected = test.expectedStatus ?? "passed";
|
|
11713
|
-
const actual = result.status;
|
|
11714
|
-
if (actual === "skipped") return "skipped";
|
|
11715
|
-
if (actual === "timedOut") return "timedOut";
|
|
11716
|
-
if (actual === "passed" && result.retry > 0) return "flaky";
|
|
11717
|
-
if (actual === "passed" && expected === "passed") return "passed";
|
|
11718
|
-
if (actual === "failed" && expected === "failed") return "passed";
|
|
11719
|
-
return "failed";
|
|
11720
|
-
}
|
|
11721
11751
|
};
|
|
11722
11752
|
|
|
11723
11753
|
// src/collector/severity.ts
|
|
@@ -11731,6 +11761,16 @@ var SEVERITY_RANK = {
|
|
|
11731
11761
|
function sortBySeverity(failures) {
|
|
11732
11762
|
return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
11733
11763
|
}
|
|
11764
|
+
function inferSeverity(annotations, tags) {
|
|
11765
|
+
const allLabels = [
|
|
11766
|
+
...annotations.map((a) => a.type.toLowerCase()),
|
|
11767
|
+
...tags.map((t) => t.toLowerCase())
|
|
11768
|
+
];
|
|
11769
|
+
if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
|
|
11770
|
+
if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
|
|
11771
|
+
if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
|
|
11772
|
+
return "medium";
|
|
11773
|
+
}
|
|
11734
11774
|
|
|
11735
11775
|
// src/utils/env.ts
|
|
11736
11776
|
init_cjs_shims();
|
|
@@ -11855,6 +11895,10 @@ var DataCollector = class {
|
|
|
11855
11895
|
constructor(captureOpts = {}) {
|
|
11856
11896
|
this.captureOpts = captureOpts;
|
|
11857
11897
|
this.resultMap = /* @__PURE__ */ new Map();
|
|
11898
|
+
// Parallel to resultMap — needed so computeStats() (which only sees
|
|
11899
|
+
// TestResult, not TestCase) can reconcile against expectedStatus the same
|
|
11900
|
+
// way resolveTestStatus does for the suite tree and the failure list.
|
|
11901
|
+
this.expectedStatusMap = /* @__PURE__ */ new Map();
|
|
11858
11902
|
this.failureRecords = [];
|
|
11859
11903
|
this.startTime = Date.now();
|
|
11860
11904
|
}
|
|
@@ -11865,7 +11909,10 @@ var DataCollector = class {
|
|
|
11865
11909
|
}
|
|
11866
11910
|
onTestEnd(test, result) {
|
|
11867
11911
|
this.resultMap.set(test.id, result);
|
|
11868
|
-
|
|
11912
|
+
this.expectedStatusMap.set(test.id, test.expectedStatus);
|
|
11913
|
+
const resolved = resolveTestStatus(test, result);
|
|
11914
|
+
const isRealFailure = resolved === "failed" || resolved === "timedOut";
|
|
11915
|
+
if (isRealFailure) {
|
|
11869
11916
|
const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
|
|
11870
11917
|
const record = this.buildFailureRecord(test, result);
|
|
11871
11918
|
if (existing >= 0) {
|
|
@@ -11905,7 +11952,7 @@ var DataCollector = class {
|
|
|
11905
11952
|
(a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
|
|
11906
11953
|
);
|
|
11907
11954
|
const suitePath = test.titlePath().slice(0, -1);
|
|
11908
|
-
const severity =
|
|
11955
|
+
const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
|
|
11909
11956
|
return {
|
|
11910
11957
|
testId: test.id,
|
|
11911
11958
|
testTitle: test.title,
|
|
@@ -11928,28 +11975,18 @@ var DataCollector = class {
|
|
|
11928
11975
|
execution: executionFromResult(result, this.captureOpts)
|
|
11929
11976
|
};
|
|
11930
11977
|
}
|
|
11931
|
-
inferSeverity(test) {
|
|
11932
|
-
const annotations = test.annotations ?? [];
|
|
11933
|
-
const tags = test.tags ?? [];
|
|
11934
|
-
const allLabels = [
|
|
11935
|
-
...annotations.map((a) => a.type.toLowerCase()),
|
|
11936
|
-
...tags.map((t) => t.toLowerCase())
|
|
11937
|
-
];
|
|
11938
|
-
if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
|
|
11939
|
-
if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
|
|
11940
|
-
if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
|
|
11941
|
-
return "medium";
|
|
11942
|
-
}
|
|
11943
11978
|
computeStats(fullResult) {
|
|
11944
11979
|
let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
|
|
11945
|
-
for (const [, result] of this.resultMap) {
|
|
11980
|
+
for (const [testId, result] of this.resultMap) {
|
|
11946
11981
|
total++;
|
|
11947
|
-
|
|
11948
|
-
|
|
11949
|
-
|
|
11950
|
-
// push passRate over 100%.
|
|
11982
|
+
const expectedStatus = this.expectedStatusMap.get(testId);
|
|
11983
|
+
const resolved = resolveTestStatus({ expectedStatus }, result);
|
|
11984
|
+
switch (resolved) {
|
|
11951
11985
|
case "passed":
|
|
11952
|
-
|
|
11986
|
+
passed++;
|
|
11987
|
+
break;
|
|
11988
|
+
case "flaky":
|
|
11989
|
+
flaky++;
|
|
11953
11990
|
break;
|
|
11954
11991
|
case "failed":
|
|
11955
11992
|
failed++;
|
|
@@ -11961,9 +11998,6 @@ var DataCollector = class {
|
|
|
11961
11998
|
case "skipped":
|
|
11962
11999
|
skipped++;
|
|
11963
12000
|
break;
|
|
11964
|
-
case "interrupted":
|
|
11965
|
-
failed++;
|
|
11966
|
-
break;
|
|
11967
12001
|
}
|
|
11968
12002
|
}
|
|
11969
12003
|
const duration = fullResult.duration ?? Date.now() - this.startTime;
|
|
@@ -12123,18 +12157,26 @@ var ShardMerger = class {
|
|
|
12123
12157
|
});
|
|
12124
12158
|
}
|
|
12125
12159
|
buildDescribeSuites(jsonSuites, projectName, parentPath) {
|
|
12126
|
-
|
|
12127
|
-
|
|
12128
|
-
|
|
12129
|
-
|
|
12160
|
+
const groups = /* @__PURE__ */ new Map();
|
|
12161
|
+
for (const suite of jsonSuites) {
|
|
12162
|
+
if (!groups.has(suite.title)) groups.set(suite.title, []);
|
|
12163
|
+
groups.get(suite.title).push(suite);
|
|
12164
|
+
}
|
|
12165
|
+
return Array.from(groups.values()).map((group) => {
|
|
12166
|
+
const ref = group[0];
|
|
12167
|
+
const suitePath = [...parentPath, ref.title];
|
|
12168
|
+
const allSpecs = group.flatMap((s) => s.specs);
|
|
12169
|
+
const allChildSuites = group.flatMap((s) => s.suites ?? []);
|
|
12170
|
+
const tests = allSpecs.map((spec) => this.specToTestNode(spec, projectName, suitePath));
|
|
12171
|
+
const childSuites = this.buildDescribeSuites(allChildSuites, projectName, suitePath);
|
|
12130
12172
|
const stats = aggregateStats([
|
|
12131
12173
|
...childSuites.map((s) => s.stats),
|
|
12132
12174
|
statsFromTests(tests)
|
|
12133
12175
|
]);
|
|
12134
12176
|
return {
|
|
12135
|
-
title:
|
|
12177
|
+
title: ref.title,
|
|
12136
12178
|
type: "describe",
|
|
12137
|
-
location:
|
|
12179
|
+
location: ref.file ? `${ref.file}:${ref.line ?? 0}` : void 0,
|
|
12138
12180
|
suites: childSuites,
|
|
12139
12181
|
tests,
|
|
12140
12182
|
stats
|
|
@@ -12240,7 +12282,7 @@ var ShardMerger = class {
|
|
|
12240
12282
|
duration: r.duration
|
|
12241
12283
|
})),
|
|
12242
12284
|
durationMs: lastResult?.duration ?? 0,
|
|
12243
|
-
severity:
|
|
12285
|
+
severity: inferSeverity(test.annotations ?? [], spec.tags ?? []),
|
|
12244
12286
|
execution: lastResult ? buildExecution(
|
|
12245
12287
|
{
|
|
12246
12288
|
// Guard each field — a partial / non-conformant / older shard JSON can
|
|
@@ -12816,25 +12858,18 @@ var PdfEncryptor = class {
|
|
|
12816
12858
|
return;
|
|
12817
12859
|
}
|
|
12818
12860
|
const tmpPath = import_path5.default.join(import_os5.default.tmpdir(), `rf-encrypted-${Date.now()}.pdf`);
|
|
12819
|
-
const
|
|
12861
|
+
const argFile = import_path5.default.join(import_os5.default.tmpdir(), `rf-qpdf-args-${import_crypto7.default.randomBytes(8).toString("hex")}`);
|
|
12820
12862
|
try {
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
(0, import_child_process3.execFileSync)("qpdf", [
|
|
12824
|
-
`--password-file=${pwFile}`,
|
|
12825
|
-
"--encrypt",
|
|
12826
|
-
"256",
|
|
12827
|
-
"--",
|
|
12828
|
-
pdfPath,
|
|
12829
|
-
tmpPath
|
|
12830
|
-
], { stdio: "pipe" });
|
|
12863
|
+
const args = ["--encrypt", password, password, "256", "--", pdfPath, tmpPath];
|
|
12864
|
+
import_fs8.default.writeFileSync(argFile, args.join("\n"), { mode: 384 });
|
|
12865
|
+
(0, import_child_process3.execFileSync)("qpdf", [`@${argFile}`], { stdio: "pipe" });
|
|
12831
12866
|
import_fs8.default.renameSync(tmpPath, pdfPath);
|
|
12832
12867
|
logger.info(`PDF encrypted with password.`);
|
|
12833
12868
|
} catch (e) {
|
|
12834
|
-
|
|
12869
|
+
throw new Error(`qpdf encryption failed: ${e.message}`);
|
|
12835
12870
|
} finally {
|
|
12836
12871
|
if (import_fs8.default.existsSync(tmpPath)) import_fs8.default.unlinkSync(tmpPath);
|
|
12837
|
-
import_fs8.default.rmSync(
|
|
12872
|
+
import_fs8.default.rmSync(argFile, { force: true });
|
|
12838
12873
|
}
|
|
12839
12874
|
}
|
|
12840
12875
|
isQpdfAvailable() {
|
|
@@ -13083,7 +13118,8 @@ var PdfGenerator = class {
|
|
|
13083
13118
|
if (capBytes > 0) {
|
|
13084
13119
|
const sizeBytes = (await import_fs11.default.promises.stat(outputPath)).size;
|
|
13085
13120
|
if (sizeBytes > capBytes) {
|
|
13086
|
-
const
|
|
13121
|
+
const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
|
|
13122
|
+
const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
|
|
13087
13123
|
logger.warn(
|
|
13088
13124
|
`PDF size ${fmtMb(sizeBytes)} exceeds cap ${fmtMb(capBytes)} \u2014 re-rendering (jpeg-q=${tighter.quality}, max-w=${tighter.maxWidth}px, inline-failures\u2264${tighter.maxInlineFailures}).`
|
|
13089
13125
|
);
|
|
@@ -13112,11 +13148,30 @@ var PdfGenerator = class {
|
|
|
13112
13148
|
}
|
|
13113
13149
|
resolveTemplateSources(options) {
|
|
13114
13150
|
if (options.templatePath && options.templatePath.length > 0) {
|
|
13115
|
-
|
|
13116
|
-
|
|
13117
|
-
absolutePath
|
|
13118
|
-
|
|
13119
|
-
}
|
|
13151
|
+
const byAbsolutePath = /* @__PURE__ */ new Map();
|
|
13152
|
+
for (const p of options.templatePath) {
|
|
13153
|
+
const absolutePath = import_path8.default.isAbsolute(p) ? p : import_path8.default.resolve(process.cwd(), p);
|
|
13154
|
+
if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
|
|
13155
|
+
}
|
|
13156
|
+
const labelCounts = /* @__PURE__ */ new Map();
|
|
13157
|
+
for (const rawPath of byAbsolutePath.values()) {
|
|
13158
|
+
const label = import_path8.default.basename(rawPath, ".hbs");
|
|
13159
|
+
labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
|
13160
|
+
}
|
|
13161
|
+
const seenPerLabel = /* @__PURE__ */ new Map();
|
|
13162
|
+
return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
|
|
13163
|
+
const baseLabel = import_path8.default.basename(rawPath, ".hbs");
|
|
13164
|
+
let label = baseLabel;
|
|
13165
|
+
if ((labelCounts.get(baseLabel) ?? 0) > 1) {
|
|
13166
|
+
const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
|
|
13167
|
+
seenPerLabel.set(baseLabel, n);
|
|
13168
|
+
label = `${baseLabel}-${n}`;
|
|
13169
|
+
logger.warn(
|
|
13170
|
+
`Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
|
|
13171
|
+
);
|
|
13172
|
+
}
|
|
13173
|
+
return { kind: "custom", absolutePath, label };
|
|
13174
|
+
});
|
|
13120
13175
|
}
|
|
13121
13176
|
const rawIds = Array.isArray(options.template) ? options.template : [options.template];
|
|
13122
13177
|
return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
|
|
@@ -13138,8 +13193,8 @@ var PdfGenerator = class {
|
|
|
13138
13193
|
* Also tightens JPEG quality and width to claw back bytes from each
|
|
13139
13194
|
* remaining image.
|
|
13140
13195
|
*/
|
|
13141
|
-
tightenForCap(prev, actualBytes, capBytes) {
|
|
13142
|
-
const observedPerFailure = actualBytes / Math.max(1,
|
|
13196
|
+
tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
|
|
13197
|
+
const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
|
|
13143
13198
|
const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
|
|
13144
13199
|
return {
|
|
13145
13200
|
effective: "max",
|
|
@@ -14001,7 +14056,7 @@ var PdfReporter = class {
|
|
|
14001
14056
|
this.liveConsole = false;
|
|
14002
14057
|
this.options = parseOptions(rawOptions);
|
|
14003
14058
|
this.dataCollector = new DataCollector(this.options.capture);
|
|
14004
|
-
const version = true ? "0.
|
|
14059
|
+
const version = true ? "0.14.0" : "0.x";
|
|
14005
14060
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
14006
14061
|
this.licenseClient = new LicenseClient({
|
|
14007
14062
|
licenseKey: this.options.licenseKey,
|
|
@@ -14085,6 +14140,7 @@ var PdfReporter = class {
|
|
|
14085
14140
|
}
|
|
14086
14141
|
const reportData = this._buildReportData(collected, licenseInfo);
|
|
14087
14142
|
runFailureAnalysis(reportData, this.options, this.cwd);
|
|
14143
|
+
reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
|
|
14088
14144
|
let pdfPaths = [];
|
|
14089
14145
|
try {
|
|
14090
14146
|
pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);
|