@reportforge/playwright-pdf 0.4.1 → 0.5.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/CHANGELOG.md +35 -0
- package/README.md +4 -0
- package/dist/demo/cli.js +50 -7
- package/dist/index.js +105 -47
- package/dist/index.mjs +105 -47
- package/dist/templates/layouts/detailed.hbs +5 -4
- package/dist/templates/layouts/minimal.hbs +2 -0
- package/dist/templates/partials/charts.hbs +30 -16
- package/dist/templates/partials/cover-page.hbs +1 -1
- package/dist/templates/partials/executive-summary.hbs +5 -1
- package/dist/templates/partials/failure-deep-dive.hbs +2 -2
- package/dist/templates/partials/requirements-matrix.hbs +1 -1
- package/dist/templates/partials/slow-tests.hbs +2 -2
- package/dist/templates/partials/suite-breakdown.hbs +2 -1
- package/dist/templates/styles/base.css +244 -6
- package/dist/templates/styles/detailed.css +6 -159
- package/dist/templates/styles/executive.css +3 -76
- package/dist/templates/styles/minimal.css +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,41 @@
|
|
|
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.5.1] — 2026-06-19
|
|
7
|
+
|
|
8
|
+
### Security
|
|
9
|
+
|
|
10
|
+
- **Chart labels escaped for inline-script safety** — a suite / describe / file title containing `</script>` can no longer break out of the inline Chart.js data block in the rendered report (relevant when the reporter runs over untrusted test code, e.g. external-contributor CI).
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Sharper failure-analysis classification** — high-precision deterministic rules now label the error shapes the offline model struggles with: timed-out web-first assertions (`toHaveClass`/`toHaveText`/… with the element resolved) as **assertions**, connection/DNS/SSL errors as **network**, aborted/redirected/interrupted navigations as **navigation**, and resolved-to-0 / strict-mode locators as **locator-not-found**. Rules match the error header only, so call-log context and asserted values no longer cause mislabels (a navigation-wait timeout stays a timeout, a refused `page.goto` is network).
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## [0.5.0] — 2026-06-19
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Timed-out tests are first-class** — surfaced as their own KPI card, pass-rate doughnut slice, and Suite Results bar segment instead of being folded into "Failed".
|
|
23
|
+
- **Release gate on every template** — the ship/hold verdict banner now appears in `minimal` and `detailed`, not just `executive`.
|
|
24
|
+
- **Severity-ranked failures** — failure cards are colour-coded and ordered critical-first (also drives defect-log numbering and sidecar-overflow priority).
|
|
25
|
+
- **Describe-block Suite Results** — a single-file run breaks down by describe block instead of rendering one bar.
|
|
26
|
+
- **Threshold-coloured requirements coverage bars** — red `<50%`, amber `<80%`, green `≥80%`.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- **Compact page flow** — large sections flow and break between rows instead of each starting on a fresh page; a typical `detailed` run drops ~2 pages.
|
|
31
|
+
- The pass-rate verdict renders "TIMED OUT" instead of "TIMEDOUT".
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Pass rate could exceed 100%** on runs with flaky tests — a passed-on-retry test was double-counted in both `passed` and `flaky`. It is now counted as flaky only, so the rate is correct and the KPI cards reconcile to the total.
|
|
36
|
+
- The pass-rate doughnut centre now matches the KPI strip (single source of truth) rather than computing a second, divergent figure.
|
|
37
|
+
- Timed-out status badges render with the correct styling, and timed-out tests are recovered in sharded (`shardResults`) runs instead of being reported as failures.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
6
41
|
## [0.4.0] — 2026-06-12
|
|
7
42
|
|
|
8
43
|
### Added
|
package/README.md
CHANGED
|
@@ -334,6 +334,10 @@ Everything in `minimal`, **plus** Chart.js pass-rate + suite-results charts, a n
|
|
|
334
334
|
|
|
335
335
|
Full-page cover with verdict + KPI strip, followed by a compact dashboard with charts and a failures summary (titles only — **no raw stack traces**). Best for sprint reports and management dashboards.
|
|
336
336
|
|
|
337
|
+
### Shared across all three
|
|
338
|
+
|
|
339
|
+
Every report leads with a **release-gate** ship/hold banner derived from the run verdict, surfaces **timed-out** tests as their own KPI card and pass-rate chart slice, and lists failures **most-severe first** (with severity-coloured card borders in `minimal` and `detailed`). Pages **pack compactly** — large sections flow and break between rows instead of each starting on a fresh page. The Suite Results chart breaks a single-file run down by **describe block** so it never collapses to one bar. Requirements coverage bars are coloured by threshold (red <50%, amber <80%, green ≥80%).
|
|
340
|
+
|
|
337
341
|
### Generating multiple templates in one run
|
|
338
342
|
|
|
339
343
|
Pass an array to `template` to generate one PDF per template from a single test run. The template name is appended to the output filename automatically:
|
package/dist/demo/cli.js
CHANGED
|
@@ -6251,7 +6251,8 @@ function statsFromTests(tests) {
|
|
|
6251
6251
|
return {
|
|
6252
6252
|
total: tests.length,
|
|
6253
6253
|
passed: tests.filter((t) => t.status === "passed").length,
|
|
6254
|
-
failed: tests.filter((t) => t.status === "failed"
|
|
6254
|
+
failed: tests.filter((t) => t.status === "failed").length,
|
|
6255
|
+
timedOut: tests.filter((t) => t.status === "timedOut").length,
|
|
6255
6256
|
skipped: tests.filter((t) => t.status === "skipped").length,
|
|
6256
6257
|
flaky: tests.filter((t) => t.status === "flaky").length
|
|
6257
6258
|
};
|
|
@@ -6381,7 +6382,7 @@ function buildDemoFailures() {
|
|
|
6381
6382
|
}
|
|
6382
6383
|
function buildDemoCharts(passed, failed, skipped, flaky, total) {
|
|
6383
6384
|
return {
|
|
6384
|
-
passRate: { passed, failed, skipped, flaky },
|
|
6385
|
+
passRate: { passed, failed, timedOut: 0, skipped, flaky },
|
|
6385
6386
|
suiteResults: [
|
|
6386
6387
|
suiteEntry("auth", DEMO_AUTH_TESTS),
|
|
6387
6388
|
suiteEntry("checkout", DEMO_CHECKOUT_TESTS),
|
|
@@ -6531,6 +6532,9 @@ function dominantCategory(clusters) {
|
|
|
6531
6532
|
}
|
|
6532
6533
|
|
|
6533
6534
|
// src/templates/engine.ts
|
|
6535
|
+
function jsonForInlineScript(value) {
|
|
6536
|
+
return JSON.stringify(value).replace(/<\//g, "<\\/");
|
|
6537
|
+
}
|
|
6534
6538
|
var TemplateEngine = class {
|
|
6535
6539
|
constructor() {
|
|
6536
6540
|
this.chartjsInline = "";
|
|
@@ -6714,6 +6718,19 @@ var TemplateEngine = class {
|
|
|
6714
6718
|
"upperCase",
|
|
6715
6719
|
(s) => typeof s === "string" ? s.toUpperCase() : s
|
|
6716
6720
|
);
|
|
6721
|
+
this.handlebars.registerHelper("verdictLabel", (v) => {
|
|
6722
|
+
const map = {
|
|
6723
|
+
passed: "PASSED",
|
|
6724
|
+
failed: "FAILED",
|
|
6725
|
+
timedout: "TIMED OUT",
|
|
6726
|
+
interrupted: "INTERRUPTED"
|
|
6727
|
+
};
|
|
6728
|
+
return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
|
|
6729
|
+
});
|
|
6730
|
+
this.handlebars.registerHelper("coverageBand", (pct) => {
|
|
6731
|
+
const n = Number(pct);
|
|
6732
|
+
return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
|
|
6733
|
+
});
|
|
6717
6734
|
this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
|
|
6718
6735
|
this.handlebars.registerHelper(
|
|
6719
6736
|
"join",
|
|
@@ -6733,7 +6750,7 @@ var TemplateEngine = class {
|
|
|
6733
6750
|
if (!Array.isArray(arr)) return "";
|
|
6734
6751
|
const values = arr.map((item) => {
|
|
6735
6752
|
const v = item[key];
|
|
6736
|
-
return typeof v === "string" ?
|
|
6753
|
+
return typeof v === "string" ? jsonForInlineScript(v) : String(v ?? 0);
|
|
6737
6754
|
});
|
|
6738
6755
|
return new import_handlebars.default.SafeString(values.join(","));
|
|
6739
6756
|
}
|
|
@@ -6744,7 +6761,7 @@ var TemplateEngine = class {
|
|
|
6744
6761
|
this.handlebars.registerHelper("absFixed", (n) => Math.abs(n).toFixed(0));
|
|
6745
6762
|
this.handlebars.registerHelper(
|
|
6746
6763
|
"safeJsonData",
|
|
6747
|
-
(value) => new import_handlebars.default.SafeString(
|
|
6764
|
+
(value) => new import_handlebars.default.SafeString(jsonForInlineScript(value))
|
|
6748
6765
|
);
|
|
6749
6766
|
this.handlebars.registerHelper(
|
|
6750
6767
|
"reverse",
|
|
@@ -11670,11 +11687,33 @@ var STRENGTH = {
|
|
|
11670
11687
|
strongMinMargin: 0.25
|
|
11671
11688
|
};
|
|
11672
11689
|
var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
|
|
11673
|
-
var
|
|
11690
|
+
var OVERRIDE_MARGIN = 999;
|
|
11674
11691
|
|
|
11675
11692
|
// src/analysis/FailureClusterer.ts
|
|
11676
11693
|
var MAX_TESTS_PER_CLUSTER = 20;
|
|
11677
11694
|
var MAX_MESSAGE_CHARS = 200;
|
|
11695
|
+
function errorHeader(message) {
|
|
11696
|
+
const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
|
|
11697
|
+
return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
|
|
11698
|
+
}
|
|
11699
|
+
function isAssertionTimeout(message) {
|
|
11700
|
+
return /Expect "[^"]+" with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
|
|
11701
|
+
}
|
|
11702
|
+
function isNetwork(message) {
|
|
11703
|
+
return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
|
|
11704
|
+
}
|
|
11705
|
+
function isNavigation(message) {
|
|
11706
|
+
return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
|
|
11707
|
+
}
|
|
11708
|
+
function isLocatorNotFound(message) {
|
|
11709
|
+
return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
|
|
11710
|
+
}
|
|
11711
|
+
var MESSAGE_RULES = [
|
|
11712
|
+
[isAssertionTimeout, "assertion"],
|
|
11713
|
+
[isNavigation, "navigation"],
|
|
11714
|
+
[isNetwork, "network"],
|
|
11715
|
+
[isLocatorNotFound, "locator-not-found"]
|
|
11716
|
+
];
|
|
11678
11717
|
function strengthOf(margin) {
|
|
11679
11718
|
if (margin >= STRENGTH.strongMinMargin) return "strong";
|
|
11680
11719
|
if (margin >= STRENGTH.moderateMinMargin) return "moderate";
|
|
@@ -11697,11 +11736,15 @@ function representativeMessage(message) {
|
|
|
11697
11736
|
return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
|
|
11698
11737
|
}
|
|
11699
11738
|
function classifyOne(f, model) {
|
|
11739
|
+
const frame = topFrame(f.error.stack);
|
|
11700
11740
|
if (f.retryHistory.some((r) => r.status === "passed")) {
|
|
11701
|
-
return { category: "flaky-by-retry", margin:
|
|
11741
|
+
return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, failure: f, frame };
|
|
11742
|
+
}
|
|
11743
|
+
for (const [match, category] of MESSAGE_RULES) {
|
|
11744
|
+
if (match(f.error.message)) return { category, margin: OVERRIDE_MARGIN, failure: f, frame };
|
|
11702
11745
|
}
|
|
11703
11746
|
const { label, margin } = classify(model, classifyInput(f));
|
|
11704
|
-
return { category: label, margin, failure: f, frame
|
|
11747
|
+
return { category: label, margin, failure: f, frame };
|
|
11705
11748
|
}
|
|
11706
11749
|
function classifyAll(failures, model) {
|
|
11707
11750
|
return failures.map((f) => classifyOne(f, model));
|
package/dist/index.js
CHANGED
|
@@ -10993,11 +10993,33 @@ var STRENGTH = {
|
|
|
10993
10993
|
strongMinMargin: 0.25
|
|
10994
10994
|
};
|
|
10995
10995
|
var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
|
|
10996
|
-
var
|
|
10996
|
+
var OVERRIDE_MARGIN = 999;
|
|
10997
10997
|
|
|
10998
10998
|
// src/analysis/FailureClusterer.ts
|
|
10999
10999
|
var MAX_TESTS_PER_CLUSTER = 20;
|
|
11000
11000
|
var MAX_MESSAGE_CHARS = 200;
|
|
11001
|
+
function errorHeader(message) {
|
|
11002
|
+
const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
|
|
11003
|
+
return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
|
|
11004
|
+
}
|
|
11005
|
+
function isAssertionTimeout(message) {
|
|
11006
|
+
return /Expect "[^"]+" with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
|
|
11007
|
+
}
|
|
11008
|
+
function isNetwork(message) {
|
|
11009
|
+
return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
|
|
11010
|
+
}
|
|
11011
|
+
function isNavigation(message) {
|
|
11012
|
+
return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
|
|
11013
|
+
}
|
|
11014
|
+
function isLocatorNotFound(message) {
|
|
11015
|
+
return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
|
|
11016
|
+
}
|
|
11017
|
+
var MESSAGE_RULES = [
|
|
11018
|
+
[isAssertionTimeout, "assertion"],
|
|
11019
|
+
[isNavigation, "navigation"],
|
|
11020
|
+
[isNetwork, "network"],
|
|
11021
|
+
[isLocatorNotFound, "locator-not-found"]
|
|
11022
|
+
];
|
|
11001
11023
|
function strengthOf(margin) {
|
|
11002
11024
|
if (margin >= STRENGTH.strongMinMargin) return "strong";
|
|
11003
11025
|
if (margin >= STRENGTH.moderateMinMargin) return "moderate";
|
|
@@ -11020,11 +11042,15 @@ function representativeMessage(message) {
|
|
|
11020
11042
|
return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
|
|
11021
11043
|
}
|
|
11022
11044
|
function classifyOne(f, model) {
|
|
11045
|
+
const frame = topFrame(f.error.stack);
|
|
11023
11046
|
if (f.retryHistory.some((r) => r.status === "passed")) {
|
|
11024
|
-
return { category: "flaky-by-retry", margin:
|
|
11047
|
+
return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, failure: f, frame };
|
|
11048
|
+
}
|
|
11049
|
+
for (const [match, category] of MESSAGE_RULES) {
|
|
11050
|
+
if (match(f.error.message)) return { category, margin: OVERRIDE_MARGIN, failure: f, frame };
|
|
11025
11051
|
}
|
|
11026
11052
|
const { label, margin } = classify(model, classifyInput(f));
|
|
11027
|
-
return { category: label, margin, failure: f, frame
|
|
11053
|
+
return { category: label, margin, failure: f, frame };
|
|
11028
11054
|
}
|
|
11029
11055
|
function classifyAll(failures, model) {
|
|
11030
11056
|
return failures.map((f) => classifyOne(f, model));
|
|
@@ -11233,11 +11259,13 @@ init_cjs_shims();
|
|
|
11233
11259
|
|
|
11234
11260
|
// src/collector/stats-utils.ts
|
|
11235
11261
|
init_cjs_shims();
|
|
11262
|
+
var TOP_SUITES_FOR_CHART = 10;
|
|
11236
11263
|
function statsFromTests(tests) {
|
|
11237
11264
|
return {
|
|
11238
11265
|
total: tests.length,
|
|
11239
11266
|
passed: tests.filter((t) => t.status === "passed").length,
|
|
11240
|
-
failed: tests.filter((t) => t.status === "failed"
|
|
11267
|
+
failed: tests.filter((t) => t.status === "failed").length,
|
|
11268
|
+
timedOut: tests.filter((t) => t.status === "timedOut").length,
|
|
11241
11269
|
skipped: tests.filter((t) => t.status === "skipped").length,
|
|
11242
11270
|
flaky: tests.filter((t) => t.status === "flaky").length
|
|
11243
11271
|
};
|
|
@@ -11248,12 +11276,32 @@ function aggregateStats(parts) {
|
|
|
11248
11276
|
total: acc.total + s.total,
|
|
11249
11277
|
passed: acc.passed + s.passed,
|
|
11250
11278
|
failed: acc.failed + s.failed,
|
|
11279
|
+
timedOut: acc.timedOut + s.timedOut,
|
|
11251
11280
|
skipped: acc.skipped + s.skipped,
|
|
11252
11281
|
flaky: acc.flaky + s.flaky
|
|
11253
11282
|
}),
|
|
11254
|
-
{ total: 0, passed: 0, failed: 0, skipped: 0, flaky: 0 }
|
|
11283
|
+
{ total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
|
|
11255
11284
|
);
|
|
11256
11285
|
}
|
|
11286
|
+
function toRow(s) {
|
|
11287
|
+
return {
|
|
11288
|
+
label: s.title,
|
|
11289
|
+
passed: s.stats.passed,
|
|
11290
|
+
failed: s.stats.failed,
|
|
11291
|
+
timedOut: s.stats.timedOut,
|
|
11292
|
+
skipped: s.stats.skipped
|
|
11293
|
+
};
|
|
11294
|
+
}
|
|
11295
|
+
function buildSuiteResults(projects, limit) {
|
|
11296
|
+
const top = projects.flatMap((p) => p.suites);
|
|
11297
|
+
if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
|
|
11298
|
+
const file = top[0];
|
|
11299
|
+
const fs14 = statsFromTests(file.tests);
|
|
11300
|
+
const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs14.passed, failed: fs14.failed, timedOut: fs14.timedOut, skipped: fs14.skipped }] : [];
|
|
11301
|
+
return [...rootRow, ...file.suites.map(toRow)].slice(0, limit);
|
|
11302
|
+
}
|
|
11303
|
+
return top.map(toRow).slice(0, limit);
|
|
11304
|
+
}
|
|
11257
11305
|
|
|
11258
11306
|
// src/collector/SuiteWalker.ts
|
|
11259
11307
|
var SuiteWalker = class {
|
|
@@ -11313,6 +11361,18 @@ var SuiteWalker = class {
|
|
|
11313
11361
|
}
|
|
11314
11362
|
};
|
|
11315
11363
|
|
|
11364
|
+
// src/collector/severity.ts
|
|
11365
|
+
init_cjs_shims();
|
|
11366
|
+
var SEVERITY_RANK = {
|
|
11367
|
+
critical: 0,
|
|
11368
|
+
high: 1,
|
|
11369
|
+
medium: 2,
|
|
11370
|
+
low: 3
|
|
11371
|
+
};
|
|
11372
|
+
function sortBySeverity(failures) {
|
|
11373
|
+
return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
11374
|
+
}
|
|
11375
|
+
|
|
11316
11376
|
// src/utils/env.ts
|
|
11317
11377
|
init_cjs_shims();
|
|
11318
11378
|
var import_child_process = require("child_process");
|
|
@@ -11402,7 +11462,6 @@ function gitCommit() {
|
|
|
11402
11462
|
}
|
|
11403
11463
|
|
|
11404
11464
|
// src/collector/DataCollector.ts
|
|
11405
|
-
var TOP_SUITES_FOR_CHART = 10;
|
|
11406
11465
|
var DataCollector = class {
|
|
11407
11466
|
constructor() {
|
|
11408
11467
|
this.resultMap = /* @__PURE__ */ new Map();
|
|
@@ -11439,13 +11498,13 @@ var DataCollector = class {
|
|
|
11439
11498
|
finalize(fullResult) {
|
|
11440
11499
|
const walker = new SuiteWalker();
|
|
11441
11500
|
const projects = walker.build(this.rootSuite, this.resultMap);
|
|
11442
|
-
const stats = this.computeStats(
|
|
11501
|
+
const stats = this.computeStats(fullResult);
|
|
11443
11502
|
const environment = this.buildEnvironment();
|
|
11444
11503
|
const charts = this.buildChartData(stats, projects);
|
|
11445
11504
|
return {
|
|
11446
11505
|
projects,
|
|
11447
11506
|
stats,
|
|
11448
|
-
failures:
|
|
11507
|
+
failures: sortBySeverity(this.failureRecords),
|
|
11449
11508
|
environment,
|
|
11450
11509
|
charts
|
|
11451
11510
|
};
|
|
@@ -11489,13 +11548,16 @@ var DataCollector = class {
|
|
|
11489
11548
|
if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
|
|
11490
11549
|
return "medium";
|
|
11491
11550
|
}
|
|
11492
|
-
computeStats(
|
|
11551
|
+
computeStats(fullResult) {
|
|
11493
11552
|
let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
|
|
11494
11553
|
for (const [, result] of this.resultMap) {
|
|
11495
11554
|
total++;
|
|
11496
11555
|
switch (result.status) {
|
|
11556
|
+
// A test that passes on a retry (retry > 0) is flaky, NOT a clean pass.
|
|
11557
|
+
// Counting it in `passed` too would double-count it against `flaky` and
|
|
11558
|
+
// push passRate over 100%.
|
|
11497
11559
|
case "passed":
|
|
11498
|
-
passed++;
|
|
11560
|
+
result.retry > 0 ? flaky++ : passed++;
|
|
11499
11561
|
break;
|
|
11500
11562
|
case "failed":
|
|
11501
11563
|
failed++;
|
|
@@ -11512,13 +11574,6 @@ var DataCollector = class {
|
|
|
11512
11574
|
break;
|
|
11513
11575
|
}
|
|
11514
11576
|
}
|
|
11515
|
-
for (const project of projects) {
|
|
11516
|
-
for (const suite of this.flattenSuites(project.suites)) {
|
|
11517
|
-
for (const test of suite.tests) {
|
|
11518
|
-
if (test.status === "flaky") flaky++;
|
|
11519
|
-
}
|
|
11520
|
-
}
|
|
11521
|
-
}
|
|
11522
11577
|
const duration = fullResult.duration ?? Date.now() - this.startTime;
|
|
11523
11578
|
const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
|
|
11524
11579
|
return {
|
|
@@ -11564,27 +11619,18 @@ var DataCollector = class {
|
|
|
11564
11619
|
};
|
|
11565
11620
|
}
|
|
11566
11621
|
buildChartData(stats, projects) {
|
|
11567
|
-
const suiteResults = projects
|
|
11568
|
-
(p) => p.suites.map((s) => ({
|
|
11569
|
-
label: s.title,
|
|
11570
|
-
passed: s.stats.passed,
|
|
11571
|
-
failed: s.stats.failed,
|
|
11572
|
-
skipped: s.stats.skipped
|
|
11573
|
-
}))
|
|
11574
|
-
).slice(0, TOP_SUITES_FOR_CHART);
|
|
11622
|
+
const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
|
|
11575
11623
|
return {
|
|
11576
11624
|
passRate: {
|
|
11577
11625
|
passed: stats.passed,
|
|
11578
11626
|
failed: stats.failed,
|
|
11627
|
+
timedOut: stats.timedOut,
|
|
11579
11628
|
skipped: stats.skipped,
|
|
11580
11629
|
flaky: stats.flaky
|
|
11581
11630
|
},
|
|
11582
11631
|
suiteResults
|
|
11583
11632
|
};
|
|
11584
11633
|
}
|
|
11585
|
-
flattenSuites(suites) {
|
|
11586
|
-
return suites.flatMap((s) => [s, ...this.flattenSuites(s.suites)]);
|
|
11587
|
-
}
|
|
11588
11634
|
};
|
|
11589
11635
|
|
|
11590
11636
|
// src/collector/ShardMerger.ts
|
|
@@ -11592,15 +11638,14 @@ init_cjs_shims();
|
|
|
11592
11638
|
var import_fs3 = require("fs");
|
|
11593
11639
|
var import_glob = require("glob");
|
|
11594
11640
|
var import_node_module = require("module");
|
|
11595
|
-
var TOP_SUITES_FOR_CHART2 = 10;
|
|
11596
11641
|
var ShardMerger = class {
|
|
11597
11642
|
merge(filePaths) {
|
|
11598
11643
|
if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
|
|
11599
11644
|
const resolved = this.resolveShardPaths(filePaths);
|
|
11600
11645
|
const reports = this.loadReports(resolved);
|
|
11601
11646
|
const projects = this.mergeProjects(reports);
|
|
11602
|
-
const stats = this.mergeStats(reports);
|
|
11603
|
-
const failures = this.extractFailures(reports);
|
|
11647
|
+
const stats = this.mergeStats(reports, projects);
|
|
11648
|
+
const failures = sortBySeverity(this.extractFailures(reports));
|
|
11604
11649
|
const environment = this.buildEnvironment(reports[0]);
|
|
11605
11650
|
const charts = this.buildCharts(stats, projects);
|
|
11606
11651
|
return { projects, stats, failures, environment, charts };
|
|
@@ -11707,7 +11752,7 @@ var ShardMerger = class {
|
|
|
11707
11752
|
id: spec.id,
|
|
11708
11753
|
title: spec.title,
|
|
11709
11754
|
fullTitle: [...suitePath, spec.title].join(" > "),
|
|
11710
|
-
status: this.resolveStatus(test),
|
|
11755
|
+
status: this.resolveStatus(test, lastResult),
|
|
11711
11756
|
duration: lastResult?.duration ?? 0,
|
|
11712
11757
|
retryCount: test ? test.results.length - 1 : 0,
|
|
11713
11758
|
tags: spec.tags ?? [],
|
|
@@ -11715,8 +11760,9 @@ var ShardMerger = class {
|
|
|
11715
11760
|
location: `${spec.file}:${spec.line}`
|
|
11716
11761
|
};
|
|
11717
11762
|
}
|
|
11718
|
-
resolveStatus(test) {
|
|
11763
|
+
resolveStatus(test, lastResult) {
|
|
11719
11764
|
if (!test) return "skipped";
|
|
11765
|
+
if (lastResult?.status === "timedOut") return "timedOut";
|
|
11720
11766
|
switch (test.status) {
|
|
11721
11767
|
case "expected":
|
|
11722
11768
|
return "passed";
|
|
@@ -11730,7 +11776,7 @@ var ShardMerger = class {
|
|
|
11730
11776
|
return "failed";
|
|
11731
11777
|
}
|
|
11732
11778
|
}
|
|
11733
|
-
mergeStats(reports) {
|
|
11779
|
+
mergeStats(reports, projects) {
|
|
11734
11780
|
let expected = 0, unexpected = 0, flaky = 0, skipped = 0, maxDuration = 0;
|
|
11735
11781
|
for (const r of reports) {
|
|
11736
11782
|
expected += r.stats.expected;
|
|
@@ -11741,13 +11787,14 @@ var ShardMerger = class {
|
|
|
11741
11787
|
}
|
|
11742
11788
|
const total = expected + unexpected + flaky + skipped;
|
|
11743
11789
|
const passRate = total > 0 ? Math.round((expected + flaky) / total * 100) : 0;
|
|
11790
|
+
const timedOut = projects.reduce((n, p) => n + p.stats.timedOut, 0);
|
|
11744
11791
|
return {
|
|
11745
11792
|
total,
|
|
11746
11793
|
passed: expected,
|
|
11747
|
-
failed: unexpected,
|
|
11794
|
+
failed: Math.max(0, unexpected - timedOut),
|
|
11748
11795
|
skipped,
|
|
11749
11796
|
flaky,
|
|
11750
|
-
timedOut
|
|
11797
|
+
timedOut,
|
|
11751
11798
|
duration: maxDuration,
|
|
11752
11799
|
passRate,
|
|
11753
11800
|
verdict: unexpected > 0 ? "failed" : "passed"
|
|
@@ -11825,18 +11872,13 @@ var ShardMerger = class {
|
|
|
11825
11872
|
};
|
|
11826
11873
|
}
|
|
11827
11874
|
buildCharts(stats, projects) {
|
|
11828
|
-
const suiteResults = projects
|
|
11829
|
-
(p) => p.suites.map((s) => ({
|
|
11830
|
-
label: s.title,
|
|
11831
|
-
passed: s.stats.passed,
|
|
11832
|
-
failed: s.stats.failed,
|
|
11833
|
-
skipped: s.stats.skipped
|
|
11834
|
-
}))
|
|
11835
|
-
).slice(0, TOP_SUITES_FOR_CHART2);
|
|
11875
|
+
const suiteResults = buildSuiteResults(projects, TOP_SUITES_FOR_CHART);
|
|
11836
11876
|
return {
|
|
11837
11877
|
passRate: {
|
|
11838
11878
|
passed: stats.passed,
|
|
11839
11879
|
failed: stats.failed,
|
|
11880
|
+
timedOut: stats.timedOut,
|
|
11881
|
+
// always 0 in shard JSON (no separate timedOut count)
|
|
11840
11882
|
skipped: stats.skipped,
|
|
11841
11883
|
flaky: stats.flaky
|
|
11842
11884
|
},
|
|
@@ -11868,6 +11910,9 @@ function formatDuration(ms) {
|
|
|
11868
11910
|
}
|
|
11869
11911
|
|
|
11870
11912
|
// src/templates/engine.ts
|
|
11913
|
+
function jsonForInlineScript(value) {
|
|
11914
|
+
return JSON.stringify(value).replace(/<\//g, "<\\/");
|
|
11915
|
+
}
|
|
11871
11916
|
var TemplateEngine = class {
|
|
11872
11917
|
constructor() {
|
|
11873
11918
|
this.chartjsInline = "";
|
|
@@ -12051,6 +12096,19 @@ var TemplateEngine = class {
|
|
|
12051
12096
|
"upperCase",
|
|
12052
12097
|
(s) => typeof s === "string" ? s.toUpperCase() : s
|
|
12053
12098
|
);
|
|
12099
|
+
this.handlebars.registerHelper("verdictLabel", (v) => {
|
|
12100
|
+
const map = {
|
|
12101
|
+
passed: "PASSED",
|
|
12102
|
+
failed: "FAILED",
|
|
12103
|
+
timedout: "TIMED OUT",
|
|
12104
|
+
interrupted: "INTERRUPTED"
|
|
12105
|
+
};
|
|
12106
|
+
return map[v] ?? (typeof v === "string" ? v.toUpperCase() : v);
|
|
12107
|
+
});
|
|
12108
|
+
this.handlebars.registerHelper("coverageBand", (pct) => {
|
|
12109
|
+
const n = Number(pct);
|
|
12110
|
+
return n >= 80 ? "high" : n >= 50 ? "mid" : "low";
|
|
12111
|
+
});
|
|
12054
12112
|
this.handlebars.registerHelper("addOne", (n) => (n ?? 0) + 1);
|
|
12055
12113
|
this.handlebars.registerHelper(
|
|
12056
12114
|
"join",
|
|
@@ -12070,7 +12128,7 @@ var TemplateEngine = class {
|
|
|
12070
12128
|
if (!Array.isArray(arr)) return "";
|
|
12071
12129
|
const values = arr.map((item) => {
|
|
12072
12130
|
const v = item[key];
|
|
12073
|
-
return typeof v === "string" ?
|
|
12131
|
+
return typeof v === "string" ? jsonForInlineScript(v) : String(v ?? 0);
|
|
12074
12132
|
});
|
|
12075
12133
|
return new import_handlebars.default.SafeString(values.join(","));
|
|
12076
12134
|
}
|
|
@@ -12081,7 +12139,7 @@ var TemplateEngine = class {
|
|
|
12081
12139
|
this.handlebars.registerHelper("absFixed", (n) => Math.abs(n).toFixed(0));
|
|
12082
12140
|
this.handlebars.registerHelper(
|
|
12083
12141
|
"safeJsonData",
|
|
12084
|
-
(value) => new import_handlebars.default.SafeString(
|
|
12142
|
+
(value) => new import_handlebars.default.SafeString(jsonForInlineScript(value))
|
|
12085
12143
|
);
|
|
12086
12144
|
this.handlebars.registerHelper(
|
|
12087
12145
|
"reverse",
|
|
@@ -13068,7 +13126,7 @@ var PdfReporter = class {
|
|
|
13068
13126
|
this.dataCollector = new DataCollector();
|
|
13069
13127
|
this.pdfGenerator = new PdfGenerator();
|
|
13070
13128
|
this.options = parseOptions(rawOptions);
|
|
13071
|
-
const version = true ? "0.
|
|
13129
|
+
const version = true ? "0.5.1" : "0.x";
|
|
13072
13130
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
13073
13131
|
this.licenseClient = new LicenseClient({
|
|
13074
13132
|
licenseKey: this.options.licenseKey,
|