@reportforge/playwright-pdf 0.11.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli/index.js +93 -3
- package/dist/index.d.mts +4 -3
- package/dist/index.d.ts +4 -3
- package/dist/index.js +34 -17
- package/dist/index.mjs +34 -17
- package/dist/templates/partials/defect-log.hbs +11 -9
- package/dist/templates/styles/base.css +13 -21
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,7 +59,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
|
|
|
59
59
|
- **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)
|
|
60
60
|
- **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).
|
|
61
61
|
- `npx @reportforge/playwright-pdf export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
|
|
62
|
-
- **Rich execution capture** — opt-in
|
|
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 action 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
64
|
- **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
|
|
65
65
|
|
|
@@ -196,7 +196,7 @@ All options are the second element of the reporter tuple.
|
|
|
196
196
|
| `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard report files to merge into one PDF. See the Shard Merging docs. |
|
|
197
197
|
| `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
|
|
198
198
|
| `failureAnalysis` | `object` | `{ enabled: true }` | Offline failure root-cause analysis (embedded Naive Bayes; no runtime network). Fields: `enabled` (default `true`), `maxClusters` (default `10`), `minStrength` (`weak`\|`moderate`\|`strong`, default `weak`), `maxFailuresToAnalyse` (default `500`), `collectUnclassified` (default `true`), `autoUpdateModel` (default `true`). See the Failure Analysis docs. |
|
|
199
|
-
| `capture` | `object` | `{}` (off) | Opt-in rich execution capture for the defect log (reporter-side; no fixtures, no test-code changes). Fields: `steps` (
|
|
199
|
+
| `capture` | `object` | `{}` (off) | Opt-in rich execution capture for the defect log (reporter-side; no fixtures, no test-code changes). Fields: `steps` (a copy-pasteable "Steps to Reproduce" outline built from the Playwright step tree — each row a Markdown list line indented under its parent `test.step`, default `false`), `apiSteps` (include every top-level `pw:api` action (click/fill/check/goto) in the outline for a full action trail; off by default the outline shows only `test.step` intent + `expect` assertions, default `false`), `console` (Node stdout/stderr tail, default `false`), `evidence` (trace/video file links, default `false`), `maxSteps` (default `50`), `maxConsoleLines` (default `50`). Renders in the Defect Log section of the `detailed` template. |
|
|
200
200
|
| `live` | `object` | `{}` (off) | Opt-in live test-execution streaming. When `enabled`, the reporter prints an unguessable watch link to the CI logs at run start and streams per-test progress to the hosted dashboard while tests run — all shards of one CI run converge on a single live view. Fields: `enabled` (default `false`), `runId` (override the auto-derived run id), `serverUrl` (override the streaming server), `steps` (`none`\|`failed`\|`intent`\|`all`, default `failed`; `intent` shows only your `test.step` names plus any failing step), `console` (stream stdout/stderr tails, default `false`), `flushMs` (batch debounce 500–10000, default `2000`). The PDF at the end of the run is unaffected. Requires an active subscription with the `live` entitlement. See the Live Runs docs. |
|
|
201
201
|
| `historyFile` | `string` | `~/.reportforge/{key}/history.json` | Path to the history JSON file. Relative paths resolve from `cwd`. Enables pass-rate trending charts in the `detailed` template. |
|
|
202
202
|
| `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
|
package/dist/cli/index.js
CHANGED
|
@@ -139,7 +139,37 @@ function buildDemoFailures() {
|
|
|
139
139
|
{ attempt: 2, status: "failed", duration: 4900 }
|
|
140
140
|
],
|
|
141
141
|
durationMs: 4900,
|
|
142
|
-
severity: "critical"
|
|
142
|
+
severity: "critical",
|
|
143
|
+
// A test with no test.step → a flat repro outline (every step depth 0).
|
|
144
|
+
execution: {
|
|
145
|
+
source: "reporter",
|
|
146
|
+
steps: [
|
|
147
|
+
{ title: "Navigate to '/login'", category: "pw:api", durationMs: 420, depth: 0, failed: false },
|
|
148
|
+
{ title: "Fill getByLabel('Email')", category: "pw:api", durationMs: 60, depth: 0, failed: false },
|
|
149
|
+
{ title: "Fill getByLabel('Password')", category: "pw:api", durationMs: 55, depth: 0, failed: false },
|
|
150
|
+
{ title: "Click getByRole('button', { name: 'Sign in' }) \u2014 repeated 5\xD7", category: "pw:api", durationMs: 2100, depth: 0, failed: false },
|
|
151
|
+
{
|
|
152
|
+
title: "Expect response status toBe 429",
|
|
153
|
+
category: "expect",
|
|
154
|
+
durationMs: 80,
|
|
155
|
+
depth: 0,
|
|
156
|
+
failed: true,
|
|
157
|
+
failingLeaf: true,
|
|
158
|
+
errorMessage: "expect(received).toBe(expected) \u2014 Expected: 429 Received: 200",
|
|
159
|
+
location: "tests/auth.spec.ts:79"
|
|
160
|
+
}
|
|
161
|
+
],
|
|
162
|
+
failingStep: {
|
|
163
|
+
title: "Expect response status toBe 429",
|
|
164
|
+
category: "expect",
|
|
165
|
+
durationMs: 80,
|
|
166
|
+
depth: 0,
|
|
167
|
+
failed: true,
|
|
168
|
+
failingLeaf: true,
|
|
169
|
+
errorMessage: "expect(received).toBe(expected) \u2014 Expected: 429 Received: 200",
|
|
170
|
+
location: "tests/auth.spec.ts:79"
|
|
171
|
+
}
|
|
172
|
+
}
|
|
143
173
|
},
|
|
144
174
|
{
|
|
145
175
|
testId: "co-3",
|
|
@@ -155,7 +185,38 @@ function buildDemoFailures() {
|
|
|
155
185
|
{ attempt: 2, status: "failed", duration: 4900 }
|
|
156
186
|
],
|
|
157
187
|
durationMs: 4900,
|
|
158
|
-
severity: "high"
|
|
188
|
+
severity: "high",
|
|
189
|
+
// Nested outline — child actions/assertions indent under their parent test.step.
|
|
190
|
+
execution: {
|
|
191
|
+
source: "reporter",
|
|
192
|
+
steps: [
|
|
193
|
+
{ title: "Add product to cart", category: "test.step", durationMs: 1200, depth: 0, failed: false },
|
|
194
|
+
{ title: "Click getByRole('button', { name: 'Add to cart' })", category: "pw:api", durationMs: 340, depth: 1, failed: false },
|
|
195
|
+
{ title: "Apply expired promo code", category: "test.step", durationMs: 5600, depth: 0, failed: false },
|
|
196
|
+
{ title: "Fill getByLabel('Promo code')", category: "pw:api", durationMs: 120, depth: 1, failed: false },
|
|
197
|
+
{ title: "Click getByRole('button', { name: 'Apply' })", category: "pw:api", durationMs: 90, depth: 1, failed: false },
|
|
198
|
+
{
|
|
199
|
+
title: "Expect getByText('Promo code is invalid') to be visible",
|
|
200
|
+
category: "expect",
|
|
201
|
+
durationMs: 5001,
|
|
202
|
+
depth: 1,
|
|
203
|
+
failed: true,
|
|
204
|
+
failingLeaf: true,
|
|
205
|
+
errorMessage: "Timed out 5000ms waiting for expect(locator).toBeVisible()",
|
|
206
|
+
location: "tests/checkout.spec.ts:58"
|
|
207
|
+
}
|
|
208
|
+
],
|
|
209
|
+
failingStep: {
|
|
210
|
+
title: "Expect getByText('Promo code is invalid') to be visible",
|
|
211
|
+
category: "expect",
|
|
212
|
+
durationMs: 5001,
|
|
213
|
+
depth: 1,
|
|
214
|
+
failed: true,
|
|
215
|
+
failingLeaf: true,
|
|
216
|
+
errorMessage: "Timed out 5000ms waiting for expect(locator).toBeVisible()",
|
|
217
|
+
location: "tests/checkout.spec.ts:58"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
159
220
|
},
|
|
160
221
|
{
|
|
161
222
|
testId: "cat-7",
|
|
@@ -171,7 +232,35 @@ function buildDemoFailures() {
|
|
|
171
232
|
{ attempt: 2, status: "failed", duration: 3900 }
|
|
172
233
|
],
|
|
173
234
|
durationMs: 3900,
|
|
174
|
-
severity: "medium"
|
|
235
|
+
severity: "medium",
|
|
236
|
+
execution: {
|
|
237
|
+
source: "reporter",
|
|
238
|
+
steps: [
|
|
239
|
+
{ title: "Scroll to lazy-loaded section", category: "test.step", durationMs: 900, depth: 0, failed: false },
|
|
240
|
+
{ title: "Mouse wheel by (0, 2000)", category: "pw:api", durationMs: 300, depth: 1, failed: false },
|
|
241
|
+
{ title: "Verify lazy image renders", category: "test.step", durationMs: 5e3, depth: 0, failed: false },
|
|
242
|
+
{
|
|
243
|
+
title: "Expect getByTestId('lazy-img-12') to be visible",
|
|
244
|
+
category: "expect",
|
|
245
|
+
durationMs: 5e3,
|
|
246
|
+
depth: 1,
|
|
247
|
+
failed: true,
|
|
248
|
+
failingLeaf: true,
|
|
249
|
+
errorMessage: "expect(locator).toBeVisible() \u2014 Expected: visible Received: hidden",
|
|
250
|
+
location: "tests/catalog.spec.ts:112"
|
|
251
|
+
}
|
|
252
|
+
],
|
|
253
|
+
failingStep: {
|
|
254
|
+
title: "Expect getByTestId('lazy-img-12') to be visible",
|
|
255
|
+
category: "expect",
|
|
256
|
+
durationMs: 5e3,
|
|
257
|
+
depth: 1,
|
|
258
|
+
failed: true,
|
|
259
|
+
failingLeaf: true,
|
|
260
|
+
errorMessage: "expect(locator).toBeVisible() \u2014 Expected: visible Received: hidden",
|
|
261
|
+
location: "tests/catalog.spec.ts:112"
|
|
262
|
+
}
|
|
263
|
+
}
|
|
175
264
|
}
|
|
176
265
|
];
|
|
177
266
|
}
|
|
@@ -6670,6 +6759,7 @@ var init_engine = __esm({
|
|
|
6670
6759
|
}
|
|
6671
6760
|
registerHelpers() {
|
|
6672
6761
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
6762
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
6673
6763
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
6674
6764
|
const date = d instanceof Date ? d : new Date(d);
|
|
6675
6765
|
return date.toLocaleString("en-US", {
|
package/dist/index.d.mts
CHANGED
|
@@ -223,9 +223,10 @@ interface ReporterOptions {
|
|
|
223
223
|
/** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
|
|
224
224
|
steps?: boolean;
|
|
225
225
|
/**
|
|
226
|
-
* Include
|
|
227
|
-
*
|
|
228
|
-
*
|
|
226
|
+
* Include every top-level `pw:api` action (click/fill/check/goto) in the repro
|
|
227
|
+
* outline — a full, copy-pasteable action trail across the whole test, nested under
|
|
228
|
+
* its parent `test.step`. Off by default, so the outline shows only `test.step`
|
|
229
|
+
* intent + `expect` assertions. Default: false
|
|
229
230
|
*/
|
|
230
231
|
apiSteps?: boolean;
|
|
231
232
|
/** Capture the tail of the test's Node stdout/stderr. Default: false */
|
package/dist/index.d.ts
CHANGED
|
@@ -223,9 +223,10 @@ interface ReporterOptions {
|
|
|
223
223
|
/** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
|
|
224
224
|
steps?: boolean;
|
|
225
225
|
/**
|
|
226
|
-
* Include
|
|
227
|
-
*
|
|
228
|
-
*
|
|
226
|
+
* Include every top-level `pw:api` action (click/fill/check/goto) in the repro
|
|
227
|
+
* outline — a full, copy-pasteable action trail across the whole test, nested under
|
|
228
|
+
* its parent `test.step`. Off by default, so the outline shows only `test.step`
|
|
229
|
+
* intent + `expect` assertions. Default: false
|
|
229
230
|
*/
|
|
230
231
|
apiSteps?: boolean;
|
|
231
232
|
/** Capture the tail of the test's Node stdout/stderr. Default: false */
|
package/dist/index.js
CHANGED
|
@@ -11435,9 +11435,13 @@ init_cjs_shims();
|
|
|
11435
11435
|
var MAX_TITLE_CHARS = 200;
|
|
11436
11436
|
var DEFAULT_MAX_STEPS = 50;
|
|
11437
11437
|
var DEFAULT_MAX_CONSOLE_LINES = 50;
|
|
11438
|
-
var PW_API_WINDOW = 10;
|
|
11439
11438
|
var isTeardown = (category) => category === "hook" || category === "fixture";
|
|
11440
11439
|
var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
|
|
11440
|
+
var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
|
|
11441
|
+
const c = flat[a].category;
|
|
11442
|
+
return c === "pw:api" || c === "expect";
|
|
11443
|
+
});
|
|
11444
|
+
var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
|
|
11441
11445
|
function flatten(steps) {
|
|
11442
11446
|
const out = [];
|
|
11443
11447
|
const walk = (nodes, depth, ancestors) => {
|
|
@@ -11456,15 +11460,19 @@ function flatten(steps) {
|
|
|
11456
11460
|
walk(steps ?? [], 0, []);
|
|
11457
11461
|
return out;
|
|
11458
11462
|
}
|
|
11459
|
-
function materialize(n, isFailing) {
|
|
11463
|
+
function materialize(n, isFailing, depth) {
|
|
11460
11464
|
const loc = n.raw.location;
|
|
11465
|
+
const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
|
|
11461
11466
|
return {
|
|
11462
11467
|
title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
|
|
11463
11468
|
category: n.category,
|
|
11464
11469
|
durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
|
|
11465
|
-
depth
|
|
11470
|
+
depth,
|
|
11466
11471
|
failed: n.hasError,
|
|
11467
|
-
|
|
11472
|
+
// Marks the single failing leaf so the template renders the error there once —
|
|
11473
|
+
// independent of whether a step-level message survives (falls back to the record error).
|
|
11474
|
+
failingLeaf: isFailing || void 0,
|
|
11475
|
+
errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
|
|
11468
11476
|
location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
|
|
11469
11477
|
};
|
|
11470
11478
|
}
|
|
@@ -11488,21 +11496,19 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11488
11496
|
const failingIdx = pickFailingIdx(flat);
|
|
11489
11497
|
const keep = /* @__PURE__ */ new Set();
|
|
11490
11498
|
flat.forEach((f, i) => {
|
|
11491
|
-
if (
|
|
11499
|
+
if (inHookSubtree(flat, f)) return;
|
|
11500
|
+
if (f.category === "test.step") keep.add(i);
|
|
11501
|
+
else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
|
|
11502
|
+
else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
|
|
11492
11503
|
});
|
|
11493
11504
|
if (failingIdx >= 0) {
|
|
11494
11505
|
keep.add(failingIdx);
|
|
11495
11506
|
for (const a of flat[failingIdx].ancestors) keep.add(a);
|
|
11496
|
-
if (opts.apiSteps) {
|
|
11497
|
-
const pwApiBefore = [];
|
|
11498
|
-
for (let i = 0; i < failingIdx; i++) {
|
|
11499
|
-
if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
|
|
11500
|
-
}
|
|
11501
|
-
for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
|
|
11502
|
-
}
|
|
11503
11507
|
}
|
|
11504
11508
|
let keepArr = [...keep].sort((a, b) => a - b);
|
|
11509
|
+
let omitted = 0;
|
|
11505
11510
|
if (keepArr.length > maxSteps) {
|
|
11511
|
+
omitted = keepArr.length - maxSteps;
|
|
11506
11512
|
keepArr = keepArr.slice(-maxSteps);
|
|
11507
11513
|
if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
|
|
11508
11514
|
keepArr = [failingIdx, ...keepArr.slice(1)];
|
|
@@ -11512,13 +11518,23 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11512
11518
|
const mat = (i) => {
|
|
11513
11519
|
let es = cache.get(i);
|
|
11514
11520
|
if (!es) {
|
|
11515
|
-
es = materialize(flat[i], i === failingIdx);
|
|
11521
|
+
es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
|
|
11516
11522
|
cache.set(i, es);
|
|
11517
11523
|
}
|
|
11518
11524
|
return es;
|
|
11519
11525
|
};
|
|
11526
|
+
const steps = keepArr.map(mat);
|
|
11527
|
+
if (omitted > 0) {
|
|
11528
|
+
steps.unshift({
|
|
11529
|
+
title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
|
|
11530
|
+
category: "",
|
|
11531
|
+
durationMs: 0,
|
|
11532
|
+
depth: 0,
|
|
11533
|
+
failed: false
|
|
11534
|
+
});
|
|
11535
|
+
}
|
|
11520
11536
|
return {
|
|
11521
|
-
steps
|
|
11537
|
+
steps,
|
|
11522
11538
|
failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
|
|
11523
11539
|
};
|
|
11524
11540
|
}
|
|
@@ -12500,6 +12516,7 @@ var TemplateEngine = class {
|
|
|
12500
12516
|
}
|
|
12501
12517
|
registerHelpers() {
|
|
12502
12518
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
12519
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
12503
12520
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
12504
12521
|
const date = d instanceof Date ? d : new Date(d);
|
|
12505
12522
|
return date.toLocaleString("en-US", {
|
|
@@ -13887,7 +13904,7 @@ function mapCurrentStep(testId, step, shardKey) {
|
|
|
13887
13904
|
currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
|
|
13888
13905
|
};
|
|
13889
13906
|
}
|
|
13890
|
-
function
|
|
13907
|
+
function testStepDepth2(step) {
|
|
13891
13908
|
let depth = 0;
|
|
13892
13909
|
for (let p = step.parent; p; p = p.parent) {
|
|
13893
13910
|
if (p.category === "test.step") depth++;
|
|
@@ -13907,7 +13924,7 @@ function mapStep(testId, step, phase, shardKey) {
|
|
|
13907
13924
|
startedAt: step.startTime?.getTime(),
|
|
13908
13925
|
// Nesting depth (test.step ancestors) so the page indents children under their
|
|
13909
13926
|
// parent step. 0 → flat, so a test without test.steps renders as a flat trail.
|
|
13910
|
-
depth:
|
|
13927
|
+
depth: testStepDepth2(step)
|
|
13911
13928
|
};
|
|
13912
13929
|
}
|
|
13913
13930
|
function mapConsole(kind, chunk, testId, shardKey) {
|
|
@@ -13934,7 +13951,7 @@ var PdfReporter = class {
|
|
|
13934
13951
|
this.liveConsole = false;
|
|
13935
13952
|
this.options = parseOptions(rawOptions);
|
|
13936
13953
|
this.dataCollector = new DataCollector(this.options.capture);
|
|
13937
|
-
const version = true ? "0.
|
|
13954
|
+
const version = true ? "0.12.0" : "0.x";
|
|
13938
13955
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
13939
13956
|
this.licenseClient = new LicenseClient({
|
|
13940
13957
|
licenseKey: this.options.licenseKey,
|
package/dist/index.mjs
CHANGED
|
@@ -11436,9 +11436,13 @@ init_esm_shims();
|
|
|
11436
11436
|
var MAX_TITLE_CHARS = 200;
|
|
11437
11437
|
var DEFAULT_MAX_STEPS = 50;
|
|
11438
11438
|
var DEFAULT_MAX_CONSOLE_LINES = 50;
|
|
11439
|
-
var PW_API_WINDOW = 10;
|
|
11440
11439
|
var isTeardown = (category) => category === "hook" || category === "fixture";
|
|
11441
11440
|
var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
|
|
11441
|
+
var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
|
|
11442
|
+
const c = flat[a].category;
|
|
11443
|
+
return c === "pw:api" || c === "expect";
|
|
11444
|
+
});
|
|
11445
|
+
var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
|
|
11442
11446
|
function flatten(steps) {
|
|
11443
11447
|
const out = [];
|
|
11444
11448
|
const walk = (nodes, depth, ancestors) => {
|
|
@@ -11457,15 +11461,19 @@ function flatten(steps) {
|
|
|
11457
11461
|
walk(steps ?? [], 0, []);
|
|
11458
11462
|
return out;
|
|
11459
11463
|
}
|
|
11460
|
-
function materialize(n, isFailing) {
|
|
11464
|
+
function materialize(n, isFailing, depth) {
|
|
11461
11465
|
const loc = n.raw.location;
|
|
11466
|
+
const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
|
|
11462
11467
|
return {
|
|
11463
11468
|
title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
|
|
11464
11469
|
category: n.category,
|
|
11465
11470
|
durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
|
|
11466
|
-
depth
|
|
11471
|
+
depth,
|
|
11467
11472
|
failed: n.hasError,
|
|
11468
|
-
|
|
11473
|
+
// Marks the single failing leaf so the template renders the error there once —
|
|
11474
|
+
// independent of whether a step-level message survives (falls back to the record error).
|
|
11475
|
+
failingLeaf: isFailing || void 0,
|
|
11476
|
+
errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
|
|
11469
11477
|
location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
|
|
11470
11478
|
};
|
|
11471
11479
|
}
|
|
@@ -11489,21 +11497,19 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11489
11497
|
const failingIdx = pickFailingIdx(flat);
|
|
11490
11498
|
const keep = /* @__PURE__ */ new Set();
|
|
11491
11499
|
flat.forEach((f, i) => {
|
|
11492
|
-
if (
|
|
11500
|
+
if (inHookSubtree(flat, f)) return;
|
|
11501
|
+
if (f.category === "test.step") keep.add(i);
|
|
11502
|
+
else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
|
|
11503
|
+
else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
|
|
11493
11504
|
});
|
|
11494
11505
|
if (failingIdx >= 0) {
|
|
11495
11506
|
keep.add(failingIdx);
|
|
11496
11507
|
for (const a of flat[failingIdx].ancestors) keep.add(a);
|
|
11497
|
-
if (opts.apiSteps) {
|
|
11498
|
-
const pwApiBefore = [];
|
|
11499
|
-
for (let i = 0; i < failingIdx; i++) {
|
|
11500
|
-
if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
|
|
11501
|
-
}
|
|
11502
|
-
for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
|
|
11503
|
-
}
|
|
11504
11508
|
}
|
|
11505
11509
|
let keepArr = [...keep].sort((a, b) => a - b);
|
|
11510
|
+
let omitted = 0;
|
|
11506
11511
|
if (keepArr.length > maxSteps) {
|
|
11512
|
+
omitted = keepArr.length - maxSteps;
|
|
11507
11513
|
keepArr = keepArr.slice(-maxSteps);
|
|
11508
11514
|
if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
|
|
11509
11515
|
keepArr = [failingIdx, ...keepArr.slice(1)];
|
|
@@ -11513,13 +11519,23 @@ function compactSteps(rawSteps, opts = {}) {
|
|
|
11513
11519
|
const mat = (i) => {
|
|
11514
11520
|
let es = cache.get(i);
|
|
11515
11521
|
if (!es) {
|
|
11516
|
-
es = materialize(flat[i], i === failingIdx);
|
|
11522
|
+
es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
|
|
11517
11523
|
cache.set(i, es);
|
|
11518
11524
|
}
|
|
11519
11525
|
return es;
|
|
11520
11526
|
};
|
|
11527
|
+
const steps = keepArr.map(mat);
|
|
11528
|
+
if (omitted > 0) {
|
|
11529
|
+
steps.unshift({
|
|
11530
|
+
title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
|
|
11531
|
+
category: "",
|
|
11532
|
+
durationMs: 0,
|
|
11533
|
+
depth: 0,
|
|
11534
|
+
failed: false
|
|
11535
|
+
});
|
|
11536
|
+
}
|
|
11521
11537
|
return {
|
|
11522
|
-
steps
|
|
11538
|
+
steps,
|
|
11523
11539
|
failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
|
|
11524
11540
|
};
|
|
11525
11541
|
}
|
|
@@ -12501,6 +12517,7 @@ var TemplateEngine = class {
|
|
|
12501
12517
|
}
|
|
12502
12518
|
registerHelpers() {
|
|
12503
12519
|
this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
|
|
12520
|
+
this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
|
|
12504
12521
|
this.handlebars.registerHelper("formatDate", (d) => {
|
|
12505
12522
|
const date = d instanceof Date ? d : new Date(d);
|
|
12506
12523
|
return date.toLocaleString("en-US", {
|
|
@@ -13888,7 +13905,7 @@ function mapCurrentStep(testId, step, shardKey) {
|
|
|
13888
13905
|
currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
|
|
13889
13906
|
};
|
|
13890
13907
|
}
|
|
13891
|
-
function
|
|
13908
|
+
function testStepDepth2(step) {
|
|
13892
13909
|
let depth = 0;
|
|
13893
13910
|
for (let p = step.parent; p; p = p.parent) {
|
|
13894
13911
|
if (p.category === "test.step") depth++;
|
|
@@ -13908,7 +13925,7 @@ function mapStep(testId, step, phase, shardKey) {
|
|
|
13908
13925
|
startedAt: step.startTime?.getTime(),
|
|
13909
13926
|
// Nesting depth (test.step ancestors) so the page indents children under their
|
|
13910
13927
|
// parent step. 0 → flat, so a test without test.steps renders as a flat trail.
|
|
13911
|
-
depth:
|
|
13928
|
+
depth: testStepDepth2(step)
|
|
13912
13929
|
};
|
|
13913
13930
|
}
|
|
13914
13931
|
function mapConsole(kind, chunk, testId, shardKey) {
|
|
@@ -13935,7 +13952,7 @@ var PdfReporter = class {
|
|
|
13935
13952
|
this.liveConsole = false;
|
|
13936
13953
|
this.options = parseOptions(rawOptions);
|
|
13937
13954
|
this.dataCollector = new DataCollector(this.options.capture);
|
|
13938
|
-
const version = true ? "0.
|
|
13955
|
+
const version = true ? "0.12.0" : "0.x";
|
|
13939
13956
|
logger.info(`@reportforge/playwright-pdf v${version} initialised`);
|
|
13940
13957
|
this.licenseClient = new LicenseClient({
|
|
13941
13958
|
licenseKey: this.options.licenseKey,
|
|
@@ -36,17 +36,19 @@
|
|
|
36
36
|
{{#if execution.steps.length}}
|
|
37
37
|
<div class="repro">
|
|
38
38
|
<div class="repro__tag">Steps to Reproduce</div>
|
|
39
|
-
|
|
39
|
+
{{! Each row is a real Markdown list line — `' '×depth + '- ' + title` rendered
|
|
40
|
+
pre-wrap — so the outline both displays indented and survives a copy-paste
|
|
41
|
+
into a bug tracker as a nested list. }}
|
|
42
|
+
<div class="repro-steps">
|
|
40
43
|
{{#each execution.steps}}
|
|
41
|
-
<
|
|
42
|
-
<span class="repro-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
<
|
|
46
|
-
|
|
47
|
-
</li>
|
|
44
|
+
<div class="repro-step{{#if failed}} repro-step--failed{{/if}}">
|
|
45
|
+
<span class="repro-step__line">{{repeat " " depth}}- {{title}}{{#if failed}} [FAILED]{{#if location}} ({{location}}){{/if}}{{/if}}</span>
|
|
46
|
+
{{! Error renders once, at the failing leaf: the step's own message, else the
|
|
47
|
+
authoritative record error so an empty step message never hides it. }}
|
|
48
|
+
{{#if failingLeaf}}<div class="repro-step__error">{{#if errorMessage}}{{errorMessage}}{{else}}{{../error.message}}{{/if}}</div>{{/if}}
|
|
49
|
+
</div>
|
|
48
50
|
{{/each}}
|
|
49
|
-
</
|
|
51
|
+
</div>
|
|
50
52
|
</div>
|
|
51
53
|
{{/if}}
|
|
52
54
|
|
|
@@ -436,32 +436,24 @@ code {
|
|
|
436
436
|
font-family: 'JetBrains Mono', monospace; font-size: 8.5px; color: var(--text-3); margin-left: 6px;
|
|
437
437
|
}
|
|
438
438
|
.repro { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
439
|
-
.repro-steps {
|
|
440
|
-
.repro-step {
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
439
|
+
.repro-steps { margin: 6px 0 0; padding: 0; }
|
|
440
|
+
.repro-step { page-break-inside: avoid; break-inside: avoid; }
|
|
441
|
+
/* One monospace, pre-wrap line per step. The leading `' '×depth + '- '` (real
|
|
442
|
+
characters, not CSS padding) renders the outline indentation AND survives a PDF
|
|
443
|
+
text-copy — pasting yields a valid nested Markdown list. */
|
|
444
|
+
.repro-step__line {
|
|
445
|
+
display: block;
|
|
446
|
+
font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
|
|
447
|
+
color: var(--text-2); white-space: pre-wrap; word-break: break-word;
|
|
446
448
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
+
.repro-step--failed .repro-step__line { color: #8A2E1E; font-weight: 700; }
|
|
450
|
+
/* The error sits at the failing step — below its line, slightly indented so it reads
|
|
451
|
+
as belonging to that step. */
|
|
449
452
|
.repro-step__error {
|
|
450
|
-
|
|
453
|
+
margin: 2px 0 3px; padding-left: 18px;
|
|
451
454
|
font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
|
|
452
455
|
color: #8A2E1E; white-space: pre-wrap; word-break: break-word;
|
|
453
456
|
}
|
|
454
|
-
.repro-step__cat {
|
|
455
|
-
flex: none; min-width: 52px;
|
|
456
|
-
font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-3);
|
|
457
|
-
}
|
|
458
|
-
.repro-step__title { flex: 1; color: var(--text-2); word-break: break-word; }
|
|
459
|
-
.repro-step__loc {
|
|
460
|
-
flex: none; font-family: 'JetBrains Mono', monospace; font-size: 8px; color: var(--text-3);
|
|
461
|
-
}
|
|
462
|
-
.repro-step__dur { flex: none; color: var(--text-3); font-size: 8.5px; }
|
|
463
|
-
.repro-step--failed .repro-step__title { color: #8A2E1E; font-weight: 700; }
|
|
464
|
-
.repro-step--failed .repro-step__cat { color: var(--fail); }
|
|
465
457
|
.console-tail { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
|
|
466
458
|
.console-tail__line {
|
|
467
459
|
font-family: 'JetBrains Mono', monospace; font-size: 8.5px; line-height: 1.55;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reportforge/playwright-pdf",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Enterprise-ready PDF reports for Playwright Test — minimal, detailed, and executive templates with CI/CD integrations",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ReportForge",
|