@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 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 repro steps, 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` / `console` / `evidence`); off by default.
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` (compacted repro-steps trail from the Playwright step tree, default `false`), `apiSteps` (include granular `pw:api` click/fill/goto actions in the trail; off by default the trail shows only `test.step` intent + `expect` assertions + the failing action, 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. |
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 granular `pw:api` actions (click/fill/goto) in the repro trail. Off by
227
- * defaultthe trail shows only `test.step` intent + `expect` assertions + the
228
- * failing action. Default: false
226
+ * Include every top-level `pw:api` action (click/fill/check/goto) in the repro
227
+ * outlinea 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 granular `pw:api` actions (click/fill/goto) in the repro trail. Off by
227
- * defaultthe trail shows only `test.step` intent + `expect` assertions + the
228
- * failing action. Default: false
226
+ * Include every top-level `pw:api` action (click/fill/check/goto) in the repro
227
+ * outlinea 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: n.depth,
11470
+ depth,
11466
11471
  failed: n.hasError,
11467
- errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
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 (f.category === "test.step" || f.category === "expect") keep.add(i);
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: keepArr.map(mat),
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 testStepDepth(step) {
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: testStepDepth(step)
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.11.2" : "0.x";
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: n.depth,
11471
+ depth,
11467
11472
  failed: n.hasError,
11468
- errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
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 (f.category === "test.step" || f.category === "expect") keep.add(i);
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: keepArr.map(mat),
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 testStepDepth(step) {
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: testStepDepth(step)
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.11.2" : "0.x";
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
- <ol class="repro-steps">
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
- <li class="repro-step{{#if failed}} repro-step--failed{{/if}}" style="--d:{{depth}};">
42
- <span class="repro-step__cat">{{category}}</span>
43
- <span class="repro-step__title">{{title}}</span>
44
- {{#if failed}}{{#if location}}<span class="repro-step__loc">{{location}}</span>{{/if}}{{/if}}
45
- <span class="repro-step__dur">{{formatDuration durationMs}}</span>
46
- {{#if errorMessage}}<div class="repro-step__error">{{../error.message}}</div>{{/if}}
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
- </ol>
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 { list-style: none; margin: 6px 0 0; padding: 0; }
440
- .repro-step {
441
- display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px;
442
- /* indent by tree depth (capped) the value comes from `--d` on the element */
443
- padding: 2px 0 2px min(60px, calc(var(--d, 0) * 12px));
444
- font-size: 9px; line-height: 1.5;
445
- page-break-inside: avoid; break-inside: avoid;
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
- /* The error sits at the failing step full width below its row, indented past the
448
- category column so it reads as belonging to that step. */
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
- flex-basis: 100%; margin: 3px 0 2px; padding-left: 60px;
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.11.2",
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",