@reportforge/playwright-pdf 0.11.2 → 0.13.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
@@ -55,11 +55,11 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
55
55
  - **CI/CD snippets** — GitHub Actions, GitLab CI, Bitbucket Pipelines, Jenkins, Azure DevOps
56
56
  - **Live runs** — opt-in `live` streams per-test progress to an unguessable watch link printed at run start; all shards of one CI run converge on a single live dashboard while tests execute. The PDF is still produced at the end, unchanged
57
57
  - **Shard merging** — combine N Playwright JSON shard reports into one PDF via `shardResults`; accepts glob patterns or explicit paths
58
- - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email supports optional PDF attachment via `attachPdf`
58
+ - **Notifications** — post pass/fail summaries to Slack, Teams, Discord, or email after each run; configurable trigger (`always` / `failure` / `success`) per channel; email and Discord support optional PDF attachment via `attachPdf`
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
 
@@ -194,9 +194,9 @@ All options are the second element of the reporter tuple.
194
194
  | `maxInlineFailures` | `number` | derived from `compressionLevel` | Cap on failure entries rendered inline in the PDF. Overflow is written to a sibling `{basename}-failures.json` sidecar file. |
195
195
  | `maxFileSizeMb` | `number` | `8` | Soft cap on the final PDF size in MB. If exceeded, the report is re-rendered once with the `'max'` compression preset. |
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
- | `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
197
+ | `notify` | `object` | — | Notification channels: `slack`, `teams` (each `{ url, enabled, on }`), `discord` (`{ url, enabled, on, attachPdf }`), `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. |
@@ -282,6 +282,7 @@ notify: {
282
282
  url: process.env.DISCORD_WEBHOOK_URL,
283
283
  enabled: true,
284
284
  on: 'failure',
285
+ attachPdf: true, // upload the PDF with the message (default: false)
285
286
  },
286
287
  email: {
287
288
  to: ['team@company.com', 'qa@company.com'],
@@ -304,6 +305,8 @@ The `on` trigger controls when the message fires:
304
305
 
305
306
  **Email** requires `RESEND_API_KEY` in the environment (get one at [resend.com](https://resend.com)). Sender defaults to `noreply@reportforge.org`; override with `RESEND_FROM`. Store webhook URLs and API keys as CI secrets — never commit them.
306
307
 
308
+ **PDF attachment** (`attachPdf: true`) is available on `email` and `discord` — Slack and Teams webhooks cannot carry file uploads. On Discord the PDF is uploaded with the message; if the upload fails or the PDF exceeds Discord's file-size cap, the summary still posts without the attachment. With multiple templates, the first PDF is attached.
309
+
307
310
  The summary includes pass rate, test counts, duration, and the report filename. Notifications require a valid license and fire after PDF generation (or after a PDF failure — you still get the ping).
308
311
 
309
312
  ### Test History Trending
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", {
@@ -11831,7 +11921,7 @@ ${issues}`);
11831
11921
  }
11832
11922
  return result.data;
11833
11923
  }
11834
- var hexColor, channelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11924
+ var hexColor, channelConfigSchema, discordChannelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, liveConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11835
11925
  var init_schema = __esm({
11836
11926
  "src/config/schema.ts"() {
11837
11927
  "use strict";
@@ -11848,6 +11938,9 @@ var init_schema = __esm({
11848
11938
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
11849
11939
  enabled: external_exports.boolean().default(false)
11850
11940
  });
11941
+ discordChannelConfigSchema = channelConfigSchema.extend({
11942
+ attachPdf: external_exports.boolean().default(false)
11943
+ });
11851
11944
  emailChannelConfigSchema = external_exports.object({
11852
11945
  to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
11853
11946
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -11857,7 +11950,7 @@ var init_schema = __esm({
11857
11950
  notifyConfigSchema = external_exports.object({
11858
11951
  slack: channelConfigSchema.optional(),
11859
11952
  teams: channelConfigSchema.optional(),
11860
- discord: channelConfigSchema.optional(),
11953
+ discord: discordChannelConfigSchema.optional(),
11861
11954
  email: emailChannelConfigSchema.optional()
11862
11955
  }).optional();
11863
11956
  failureAnalysisConfigSchema = external_exports.object({
package/dist/index.d.mts CHANGED
@@ -158,7 +158,12 @@ interface ReporterOptions {
158
158
  * `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
159
159
  *
160
160
  * - `slack` / `teams` / `discord`: webhook URL channels
161
+ * - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
162
+ * to the summary alone if the upload fails or the PDF exceeds Discord's cap)
161
163
  * - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
164
+ *
165
+ * Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
166
+ * exist on those channels.
162
167
  */
163
168
  notify?: {
164
169
  slack?: {
@@ -175,6 +180,7 @@ interface ReporterOptions {
175
180
  url: string;
176
181
  on?: 'always' | 'failure' | 'success';
177
182
  enabled?: boolean;
183
+ attachPdf?: boolean;
178
184
  };
179
185
  email?: {
180
186
  to: string[];
@@ -223,9 +229,10 @@ interface ReporterOptions {
223
229
  /** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
224
230
  steps?: boolean;
225
231
  /**
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
232
+ * Include every top-level `pw:api` action (click/fill/check/goto) in the repro
233
+ * outlinea full, copy-pasteable action trail across the whole test, nested under
234
+ * its parent `test.step`. Off by default, so the outline shows only `test.step`
235
+ * intent + `expect` assertions. Default: false
229
236
  */
230
237
  apiSteps?: boolean;
231
238
  /** Capture the tail of the test's Node stdout/stderr. Default: false */
package/dist/index.d.ts CHANGED
@@ -158,7 +158,12 @@ interface ReporterOptions {
158
158
  * `on` controls the trigger: `'always'` (default), `'failure'`, or `'success'`.
159
159
  *
160
160
  * - `slack` / `teams` / `discord`: webhook URL channels
161
+ * - `discord`: `attachPdf: true` uploads the PDF with the message (falls back
162
+ * to the summary alone if the upload fails or the PDF exceeds Discord's cap)
161
163
  * - `email`: sends via Resend; `to` is required; `attachPdf: true` attaches the PDF
164
+ *
165
+ * Slack and Teams webhooks cannot carry file uploads, so `attachPdf` does not
166
+ * exist on those channels.
162
167
  */
163
168
  notify?: {
164
169
  slack?: {
@@ -175,6 +180,7 @@ interface ReporterOptions {
175
180
  url: string;
176
181
  on?: 'always' | 'failure' | 'success';
177
182
  enabled?: boolean;
183
+ attachPdf?: boolean;
178
184
  };
179
185
  email?: {
180
186
  to: string[];
@@ -223,9 +229,10 @@ interface ReporterOptions {
223
229
  /** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
224
230
  steps?: boolean;
225
231
  /**
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
232
+ * Include every top-level `pw:api` action (click/fill/check/goto) in the repro
233
+ * outlinea full, copy-pasteable action trail across the whole test, nested under
234
+ * its parent `test.step`. Off by default, so the outline shows only `test.step`
235
+ * intent + `expect` assertions. Default: false
229
236
  */
230
237
  apiSteps?: boolean;
231
238
  /** Capture the tail of the test's Node stdout/stderr. Default: false */
package/dist/index.js CHANGED
@@ -10454,6 +10454,9 @@ var channelConfigSchema = external_exports.object({
10454
10454
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10455
10455
  enabled: external_exports.boolean().default(false)
10456
10456
  });
10457
+ var discordChannelConfigSchema = channelConfigSchema.extend({
10458
+ attachPdf: external_exports.boolean().default(false)
10459
+ });
10457
10460
  var emailChannelConfigSchema = external_exports.object({
10458
10461
  to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
10459
10462
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -10463,7 +10466,7 @@ var emailChannelConfigSchema = external_exports.object({
10463
10466
  var notifyConfigSchema = external_exports.object({
10464
10467
  slack: channelConfigSchema.optional(),
10465
10468
  teams: channelConfigSchema.optional(),
10466
- discord: channelConfigSchema.optional(),
10469
+ discord: discordChannelConfigSchema.optional(),
10467
10470
  email: emailChannelConfigSchema.optional()
10468
10471
  }).optional();
10469
10472
  var failureAnalysisConfigSchema = external_exports.object({
@@ -11435,9 +11438,13 @@ init_cjs_shims();
11435
11438
  var MAX_TITLE_CHARS = 200;
11436
11439
  var DEFAULT_MAX_STEPS = 50;
11437
11440
  var DEFAULT_MAX_CONSOLE_LINES = 50;
11438
- var PW_API_WINDOW = 10;
11439
11441
  var isTeardown = (category) => category === "hook" || category === "fixture";
11440
11442
  var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11443
+ var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11444
+ const c = flat[a].category;
11445
+ return c === "pw:api" || c === "expect";
11446
+ });
11447
+ var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11441
11448
  function flatten(steps) {
11442
11449
  const out = [];
11443
11450
  const walk = (nodes, depth, ancestors) => {
@@ -11456,15 +11463,19 @@ function flatten(steps) {
11456
11463
  walk(steps ?? [], 0, []);
11457
11464
  return out;
11458
11465
  }
11459
- function materialize(n, isFailing) {
11466
+ function materialize(n, isFailing, depth) {
11460
11467
  const loc = n.raw.location;
11468
+ const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11461
11469
  return {
11462
11470
  title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11463
11471
  category: n.category,
11464
11472
  durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11465
- depth: n.depth,
11473
+ depth,
11466
11474
  failed: n.hasError,
11467
- errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11475
+ // Marks the single failing leaf so the template renders the error there once
11476
+ // independent of whether a step-level message survives (falls back to the record error).
11477
+ failingLeaf: isFailing || void 0,
11478
+ errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11468
11479
  location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11469
11480
  };
11470
11481
  }
@@ -11488,21 +11499,19 @@ function compactSteps(rawSteps, opts = {}) {
11488
11499
  const failingIdx = pickFailingIdx(flat);
11489
11500
  const keep = /* @__PURE__ */ new Set();
11490
11501
  flat.forEach((f, i) => {
11491
- if (f.category === "test.step" || f.category === "expect") keep.add(i);
11502
+ if (inHookSubtree(flat, f)) return;
11503
+ if (f.category === "test.step") keep.add(i);
11504
+ else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11505
+ else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11492
11506
  });
11493
11507
  if (failingIdx >= 0) {
11494
11508
  keep.add(failingIdx);
11495
11509
  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
11510
  }
11504
11511
  let keepArr = [...keep].sort((a, b) => a - b);
11512
+ let omitted = 0;
11505
11513
  if (keepArr.length > maxSteps) {
11514
+ omitted = keepArr.length - maxSteps;
11506
11515
  keepArr = keepArr.slice(-maxSteps);
11507
11516
  if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11508
11517
  keepArr = [failingIdx, ...keepArr.slice(1)];
@@ -11512,13 +11521,23 @@ function compactSteps(rawSteps, opts = {}) {
11512
11521
  const mat = (i) => {
11513
11522
  let es = cache.get(i);
11514
11523
  if (!es) {
11515
- es = materialize(flat[i], i === failingIdx);
11524
+ es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11516
11525
  cache.set(i, es);
11517
11526
  }
11518
11527
  return es;
11519
11528
  };
11529
+ const steps = keepArr.map(mat);
11530
+ if (omitted > 0) {
11531
+ steps.unshift({
11532
+ title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11533
+ category: "",
11534
+ durationMs: 0,
11535
+ depth: 0,
11536
+ failed: false
11537
+ });
11538
+ }
11520
11539
  return {
11521
- steps: keepArr.map(mat),
11540
+ steps,
11522
11541
  failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11523
11542
  };
11524
11543
  }
@@ -12500,6 +12519,7 @@ var TemplateEngine = class {
12500
12519
  }
12501
12520
  registerHelpers() {
12502
12521
  this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
12522
+ this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
12503
12523
  this.handlebars.registerHelper("formatDate", (d) => {
12504
12524
  const date = d instanceof Date ? d : new Date(d);
12505
12525
  return date.toLocaleString("en-US", {
@@ -13182,7 +13202,7 @@ function fmtMb(bytes) {
13182
13202
  }
13183
13203
 
13184
13204
  // src/reporter.ts
13185
- var import_path12 = __toESM(require("path"));
13205
+ var import_path13 = __toESM(require("path"));
13186
13206
 
13187
13207
  // src/history/HistoryManager.ts
13188
13208
  init_cjs_shims();
@@ -13263,6 +13283,8 @@ init_cjs_shims();
13263
13283
 
13264
13284
  // src/notify/NotificationSender.ts
13265
13285
  init_cjs_shims();
13286
+ var import_promises2 = require("fs/promises");
13287
+ var import_path12 = require("path");
13266
13288
 
13267
13289
  // src/notify/formatters/slack.ts
13268
13290
  init_cjs_shims();
@@ -13512,6 +13534,7 @@ var FORMATTERS = {
13512
13534
  teams: buildTeamsPayload,
13513
13535
  discord: buildDiscordPayload
13514
13536
  };
13537
+ var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
13515
13538
  var NotificationSender = class {
13516
13539
  async sendAll(reportData, notifyConfig, pdfPaths) {
13517
13540
  const result = { sent: [], skipped: [], failed: [] };
@@ -13524,6 +13547,9 @@ var NotificationSender = class {
13524
13547
  return Promise.resolve();
13525
13548
  }
13526
13549
  const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
13550
+ if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
13551
+ return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
13552
+ }
13527
13553
  return this.post(name, channel.url, payload, result);
13528
13554
  })
13529
13555
  );
@@ -13565,6 +13591,47 @@ var NotificationSender = class {
13565
13591
  })
13566
13592
  );
13567
13593
  }
13594
+ /**
13595
+ * Posts the summary to a Discord webhook with the PDF as a multipart file
13596
+ * upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
13597
+ * over Discord's upload cap, or a failed upload — falls back to the plain
13598
+ * JSON summary post so the notification still lands; only the fallback's
13599
+ * outcome is recorded in the result. Attaches the first PDF only (matches
13600
+ * the email channel's multi-template behaviour).
13601
+ */
13602
+ async postDiscordWithPdf(url, payload, pdfPath, result) {
13603
+ let file;
13604
+ try {
13605
+ file = await (0, import_promises2.readFile)(pdfPath);
13606
+ if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
13607
+ const mb = (file.byteLength / 1024 / 1024).toFixed(1);
13608
+ throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
13609
+ }
13610
+ } catch (err) {
13611
+ logger.warn(
13612
+ `[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
13613
+ );
13614
+ return this.post("discord", url, payload, result);
13615
+ }
13616
+ const form = new FormData();
13617
+ form.append("payload_json", JSON.stringify(payload));
13618
+ form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), (0, import_path12.basename)(pdfPath));
13619
+ const controller = new AbortController();
13620
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
13621
+ try {
13622
+ const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
13623
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
13624
+ logger.info("[notify] discord: sent (PDF attached)");
13625
+ result.sent.push("discord");
13626
+ } catch (err) {
13627
+ logger.warn(
13628
+ `[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
13629
+ );
13630
+ return this.post("discord", url, payload, result);
13631
+ } finally {
13632
+ clearTimeout(timeoutId);
13633
+ }
13634
+ }
13568
13635
  async post(name, url, payload, result) {
13569
13636
  const controller = new AbortController();
13570
13637
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13887,7 +13954,7 @@ function mapCurrentStep(testId, step, shardKey) {
13887
13954
  currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
13888
13955
  };
13889
13956
  }
13890
- function testStepDepth(step) {
13957
+ function testStepDepth2(step) {
13891
13958
  let depth = 0;
13892
13959
  for (let p = step.parent; p; p = p.parent) {
13893
13960
  if (p.category === "test.step") depth++;
@@ -13907,7 +13974,7 @@ function mapStep(testId, step, phase, shardKey) {
13907
13974
  startedAt: step.startTime?.getTime(),
13908
13975
  // Nesting depth (test.step ancestors) so the page indents children under their
13909
13976
  // parent step. 0 → flat, so a test without test.steps renders as a flat trail.
13910
- depth: testStepDepth(step)
13977
+ depth: testStepDepth2(step)
13911
13978
  };
13912
13979
  }
13913
13980
  function mapConsole(kind, chunk, testId, shardKey) {
@@ -13934,7 +14001,7 @@ var PdfReporter = class {
13934
14001
  this.liveConsole = false;
13935
14002
  this.options = parseOptions(rawOptions);
13936
14003
  this.dataCollector = new DataCollector(this.options.capture);
13937
- const version = true ? "0.11.2" : "0.x";
14004
+ const version = true ? "0.13.0" : "0.x";
13938
14005
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13939
14006
  this.licenseClient = new LicenseClient({
13940
14007
  licenseKey: this.options.licenseKey,
@@ -14202,7 +14269,7 @@ var PdfReporter = class {
14202
14269
  }
14203
14270
  resolveProjectName() {
14204
14271
  try {
14205
- const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
14272
+ const pkgPath = import_path13.default.resolve(process.cwd(), "package.json");
14206
14273
  const pkg = require(pkgPath);
14207
14274
  return pkg.name ?? "Playwright Tests";
14208
14275
  } catch {
@@ -14252,9 +14319,9 @@ function deriveVerdict(stats) {
14252
14319
  }
14253
14320
  function resolveHistoryPath(options, cwd) {
14254
14321
  if (options.historyFile) {
14255
- return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
14322
+ return import_path13.default.isAbsolute(options.historyFile) ? options.historyFile : import_path13.default.resolve(cwd, options.historyFile);
14256
14323
  }
14257
- return import_path12.default.join(resolveProjectDir(options, cwd), "history.json");
14324
+ return import_path13.default.join(resolveProjectDir(options, cwd), "history.json");
14258
14325
  }
14259
14326
  function flattenSuiteNodes(suites) {
14260
14327
  return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
package/dist/index.mjs CHANGED
@@ -10455,6 +10455,9 @@ var channelConfigSchema = external_exports.object({
10455
10455
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
10456
10456
  enabled: external_exports.boolean().default(false)
10457
10457
  });
10458
+ var discordChannelConfigSchema = channelConfigSchema.extend({
10459
+ attachPdf: external_exports.boolean().default(false)
10460
+ });
10458
10461
  var emailChannelConfigSchema = external_exports.object({
10459
10462
  to: external_exports.array(external_exports.string().email('Each "to" entry must be a valid email')).min(1, '"to" must have at least one address'),
10460
10463
  on: external_exports.enum(["always", "failure", "success"]).default("always"),
@@ -10464,7 +10467,7 @@ var emailChannelConfigSchema = external_exports.object({
10464
10467
  var notifyConfigSchema = external_exports.object({
10465
10468
  slack: channelConfigSchema.optional(),
10466
10469
  teams: channelConfigSchema.optional(),
10467
- discord: channelConfigSchema.optional(),
10470
+ discord: discordChannelConfigSchema.optional(),
10468
10471
  email: emailChannelConfigSchema.optional()
10469
10472
  }).optional();
10470
10473
  var failureAnalysisConfigSchema = external_exports.object({
@@ -11436,9 +11439,13 @@ init_esm_shims();
11436
11439
  var MAX_TITLE_CHARS = 200;
11437
11440
  var DEFAULT_MAX_STEPS = 50;
11438
11441
  var DEFAULT_MAX_CONSOLE_LINES = 50;
11439
- var PW_API_WINDOW = 10;
11440
11442
  var isTeardown = (category) => category === "hook" || category === "fixture";
11441
11443
  var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11444
+ var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11445
+ const c = flat[a].category;
11446
+ return c === "pw:api" || c === "expect";
11447
+ });
11448
+ var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11442
11449
  function flatten(steps) {
11443
11450
  const out = [];
11444
11451
  const walk = (nodes, depth, ancestors) => {
@@ -11457,15 +11464,19 @@ function flatten(steps) {
11457
11464
  walk(steps ?? [], 0, []);
11458
11465
  return out;
11459
11466
  }
11460
- function materialize(n, isFailing) {
11467
+ function materialize(n, isFailing, depth) {
11461
11468
  const loc = n.raw.location;
11469
+ const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11462
11470
  return {
11463
11471
  title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11464
11472
  category: n.category,
11465
11473
  durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11466
- depth: n.depth,
11474
+ depth,
11467
11475
  failed: n.hasError,
11468
- errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11476
+ // Marks the single failing leaf so the template renders the error there once
11477
+ // independent of whether a step-level message survives (falls back to the record error).
11478
+ failingLeaf: isFailing || void 0,
11479
+ errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11469
11480
  location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11470
11481
  };
11471
11482
  }
@@ -11489,21 +11500,19 @@ function compactSteps(rawSteps, opts = {}) {
11489
11500
  const failingIdx = pickFailingIdx(flat);
11490
11501
  const keep = /* @__PURE__ */ new Set();
11491
11502
  flat.forEach((f, i) => {
11492
- if (f.category === "test.step" || f.category === "expect") keep.add(i);
11503
+ if (inHookSubtree(flat, f)) return;
11504
+ if (f.category === "test.step") keep.add(i);
11505
+ else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11506
+ else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11493
11507
  });
11494
11508
  if (failingIdx >= 0) {
11495
11509
  keep.add(failingIdx);
11496
11510
  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
11511
  }
11505
11512
  let keepArr = [...keep].sort((a, b) => a - b);
11513
+ let omitted = 0;
11506
11514
  if (keepArr.length > maxSteps) {
11515
+ omitted = keepArr.length - maxSteps;
11507
11516
  keepArr = keepArr.slice(-maxSteps);
11508
11517
  if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11509
11518
  keepArr = [failingIdx, ...keepArr.slice(1)];
@@ -11513,13 +11522,23 @@ function compactSteps(rawSteps, opts = {}) {
11513
11522
  const mat = (i) => {
11514
11523
  let es = cache.get(i);
11515
11524
  if (!es) {
11516
- es = materialize(flat[i], i === failingIdx);
11525
+ es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11517
11526
  cache.set(i, es);
11518
11527
  }
11519
11528
  return es;
11520
11529
  };
11530
+ const steps = keepArr.map(mat);
11531
+ if (omitted > 0) {
11532
+ steps.unshift({
11533
+ title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11534
+ category: "",
11535
+ durationMs: 0,
11536
+ depth: 0,
11537
+ failed: false
11538
+ });
11539
+ }
11521
11540
  return {
11522
- steps: keepArr.map(mat),
11541
+ steps,
11523
11542
  failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11524
11543
  };
11525
11544
  }
@@ -12501,6 +12520,7 @@ var TemplateEngine = class {
12501
12520
  }
12502
12521
  registerHelpers() {
12503
12522
  this.handlebars.registerHelper("formatDuration", (ms) => formatDuration(ms ?? 0));
12523
+ this.handlebars.registerHelper("repeat", (s, n) => typeof s === "string" ? s.repeat(Math.max(0, n | 0)) : "");
12504
12524
  this.handlebars.registerHelper("formatDate", (d) => {
12505
12525
  const date = d instanceof Date ? d : new Date(d);
12506
12526
  return date.toLocaleString("en-US", {
@@ -13264,6 +13284,8 @@ init_esm_shims();
13264
13284
 
13265
13285
  // src/notify/NotificationSender.ts
13266
13286
  init_esm_shims();
13287
+ import { readFile as readFile2 } from "fs/promises";
13288
+ import { basename as basename3 } from "path";
13267
13289
 
13268
13290
  // src/notify/formatters/slack.ts
13269
13291
  init_esm_shims();
@@ -13513,6 +13535,7 @@ var FORMATTERS = {
13513
13535
  teams: buildTeamsPayload,
13514
13536
  discord: buildDiscordPayload
13515
13537
  };
13538
+ var DISCORD_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
13516
13539
  var NotificationSender = class {
13517
13540
  async sendAll(reportData, notifyConfig, pdfPaths) {
13518
13541
  const result = { sent: [], skipped: [], failed: [] };
@@ -13525,6 +13548,9 @@ var NotificationSender = class {
13525
13548
  return Promise.resolve();
13526
13549
  }
13527
13550
  const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
13551
+ if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
13552
+ return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
13553
+ }
13528
13554
  return this.post(name, channel.url, payload, result);
13529
13555
  })
13530
13556
  );
@@ -13566,6 +13592,47 @@ var NotificationSender = class {
13566
13592
  })
13567
13593
  );
13568
13594
  }
13595
+ /**
13596
+ * Posts the summary to a Discord webhook with the PDF as a multipart file
13597
+ * upload (`payload_json` + `files[0]`). Any problem — unreadable file, PDF
13598
+ * over Discord's upload cap, or a failed upload — falls back to the plain
13599
+ * JSON summary post so the notification still lands; only the fallback's
13600
+ * outcome is recorded in the result. Attaches the first PDF only (matches
13601
+ * the email channel's multi-template behaviour).
13602
+ */
13603
+ async postDiscordWithPdf(url, payload, pdfPath, result) {
13604
+ let file;
13605
+ try {
13606
+ file = await readFile2(pdfPath);
13607
+ if (file.byteLength > DISCORD_MAX_UPLOAD_BYTES) {
13608
+ const mb = (file.byteLength / 1024 / 1024).toFixed(1);
13609
+ throw new Error(`PDF is ${mb}MB \u2014 over Discord's upload limit`);
13610
+ }
13611
+ } catch (err) {
13612
+ logger.warn(
13613
+ `[notify] discord: ${err instanceof Error ? err.message : String(err)} \u2014 sending summary without the PDF`
13614
+ );
13615
+ return this.post("discord", url, payload, result);
13616
+ }
13617
+ const form = new FormData();
13618
+ form.append("payload_json", JSON.stringify(payload));
13619
+ form.append("files[0]", new Blob([new Uint8Array(file)], { type: "application/pdf" }), basename3(pdfPath));
13620
+ const controller = new AbortController();
13621
+ const timeoutId = setTimeout(() => controller.abort(), 3e4);
13622
+ try {
13623
+ const res = await fetch(url, { method: "POST", body: form, signal: controller.signal });
13624
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
13625
+ logger.info("[notify] discord: sent (PDF attached)");
13626
+ result.sent.push("discord");
13627
+ } catch (err) {
13628
+ logger.warn(
13629
+ `[notify] discord: upload failed (${err instanceof Error ? err.message : String(err)}) \u2014 retrying without the PDF`
13630
+ );
13631
+ return this.post("discord", url, payload, result);
13632
+ } finally {
13633
+ clearTimeout(timeoutId);
13634
+ }
13635
+ }
13569
13636
  async post(name, url, payload, result) {
13570
13637
  const controller = new AbortController();
13571
13638
  const timeoutId = setTimeout(() => controller.abort(), 1e4);
@@ -13888,7 +13955,7 @@ function mapCurrentStep(testId, step, shardKey) {
13888
13955
  currentStep: clampWithEllipsis(step.title ?? "", MAX_STEP_TITLE_CHARS)
13889
13956
  };
13890
13957
  }
13891
- function testStepDepth(step) {
13958
+ function testStepDepth2(step) {
13892
13959
  let depth = 0;
13893
13960
  for (let p = step.parent; p; p = p.parent) {
13894
13961
  if (p.category === "test.step") depth++;
@@ -13908,7 +13975,7 @@ function mapStep(testId, step, phase, shardKey) {
13908
13975
  startedAt: step.startTime?.getTime(),
13909
13976
  // Nesting depth (test.step ancestors) so the page indents children under their
13910
13977
  // parent step. 0 → flat, so a test without test.steps renders as a flat trail.
13911
- depth: testStepDepth(step)
13978
+ depth: testStepDepth2(step)
13912
13979
  };
13913
13980
  }
13914
13981
  function mapConsole(kind, chunk, testId, shardKey) {
@@ -13935,7 +14002,7 @@ var PdfReporter = class {
13935
14002
  this.liveConsole = false;
13936
14003
  this.options = parseOptions(rawOptions);
13937
14004
  this.dataCollector = new DataCollector(this.options.capture);
13938
- const version = true ? "0.11.2" : "0.x";
14005
+ const version = true ? "0.13.0" : "0.x";
13939
14006
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13940
14007
  this.licenseClient = new LicenseClient({
13941
14008
  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.13.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",