@reportforge/playwright-pdf 0.7.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,7 @@ Three templates, one reporter. Every PDF is fully offline — fonts, charts, and
58
58
  - **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)
59
59
  - **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).
60
60
  - `npx @reportforge/playwright-pdf export-feedback` — exports locally-collected unrecognized failures (tokenized, local-only) for optional manual sharing.
61
+ - **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.
61
62
  - **Configurable sections** — add or remove any report section per template via `sections`.
62
63
  - **Hybrid licensing** — one short key unlocks it; reports keep rendering offline via a cached JWT between refreshes
63
64
 
@@ -194,6 +195,7 @@ All options are the second element of the reporter tuple.
194
195
  | `shardResults` | `string \| string[]` | — | Glob or path array of Playwright JSON shard report files to merge into one PDF. See the Shard Merging docs. |
195
196
  | `notify` | `object` | — | Notification channels: `slack`, `teams`, `discord` (each `{ url, enabled, on }`), `email` (`{ to, enabled, on, attachPdf }`). See the Notifications docs. |
196
197
  | `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. |
198
+ | `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. |
197
199
  | `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. |
198
200
  | `historySize` | `number` | `10` | Maximum number of test runs to keep in the history file (integer ≥ 2). Older runs are pruned on append. |
199
201
  | `showTrend` | `boolean` | `true` | Show the pass-rate sparkline and delta badge in the `detailed` template. Set to `false` to disable history tracking entirely. |
@@ -396,7 +398,7 @@ reporter: [['@reportforge/playwright-pdf', {
396
398
  | `failureDeepDive` | Inline failure detail under each failed test in the breakdown: error message, stack trace, screenshot, retry history |
397
399
  | `failureAnalysis` | Inline per-test root-cause chip under each failed test (category + representative error + match strength, from the offline classifier) |
398
400
  | `slowTests` | `SLOW` badge on the duration-ranked slowest tests, shown inline in the breakdown |
399
- | `defectLog` | `DEF-####` numbered failure table (severity, retries) |
401
+ | `defectLog` | `DEF-####` numbered failure table (severity, duration) + opt-in repro detail (`capture`) |
400
402
 
401
403
  #### Display modifiers — tune a section that's on
402
404
 
package/dist/cli/index.js CHANGED
@@ -138,6 +138,7 @@ function buildDemoFailures() {
138
138
  { attempt: 1, status: "failed", duration: 4900 },
139
139
  { attempt: 2, status: "failed", duration: 4900 }
140
140
  ],
141
+ durationMs: 4900,
141
142
  severity: "critical"
142
143
  },
143
144
  {
@@ -153,6 +154,7 @@ function buildDemoFailures() {
153
154
  { attempt: 1, status: "failed", duration: 5e3 },
154
155
  { attempt: 2, status: "failed", duration: 4900 }
155
156
  ],
157
+ durationMs: 4900,
156
158
  severity: "high"
157
159
  },
158
160
  {
@@ -168,6 +170,7 @@ function buildDemoFailures() {
168
170
  { attempt: 1, status: "failed", duration: 4e3 },
169
171
  { attempt: 2, status: "failed", duration: 3900 }
170
172
  ],
173
+ durationMs: 3900,
171
174
  severity: "medium"
172
175
  }
173
176
  ];
@@ -11828,7 +11831,7 @@ ${issues}`);
11828
11831
  }
11829
11832
  return result.data;
11830
11833
  }
11831
- var hexColor, channelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11834
+ var hexColor, channelConfigSchema, emailChannelConfigSchema, notifyConfigSchema, failureAnalysisConfigSchema, captureConfigSchema, sectionFlagsShape, sectionFlagsSchema, sectionsSchema, ReporterOptionsSchema;
11832
11835
  var init_schema = __esm({
11833
11836
  "src/config/schema.ts"() {
11834
11837
  "use strict";
@@ -11867,6 +11870,14 @@ var init_schema = __esm({
11867
11870
  collectUnclassified: external_exports.boolean().default(true),
11868
11871
  autoUpdateModel: external_exports.boolean().default(true)
11869
11872
  }).default({});
11873
+ captureConfigSchema = external_exports.object({
11874
+ steps: external_exports.boolean().default(false),
11875
+ apiSteps: external_exports.boolean().default(false),
11876
+ console: external_exports.boolean().default(false),
11877
+ evidence: external_exports.boolean().default(false),
11878
+ maxSteps: external_exports.number().int().min(1).max(500).default(50),
11879
+ maxConsoleLines: external_exports.number().int().min(1).max(1e3).default(50)
11880
+ }).default({});
11870
11881
  sectionFlagsShape = Object.fromEntries(
11871
11882
  SECTION_KEYS.map((k) => [k, external_exports.boolean().optional()])
11872
11883
  );
@@ -11907,6 +11918,7 @@ var init_schema = __esm({
11907
11918
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
11908
11919
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
11909
11920
  failureAnalysis: failureAnalysisConfigSchema,
11921
+ capture: captureConfigSchema,
11910
11922
  templatePath: external_exports.union([
11911
11923
  external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
11912
11924
  external_exports.array(
@@ -12072,6 +12084,17 @@ var init_constants = __esm({
12072
12084
  }
12073
12085
  });
12074
12086
 
12087
+ // src/utils/truncate.ts
12088
+ function clampWithEllipsis(text, max) {
12089
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
12090
+ }
12091
+ var init_truncate = __esm({
12092
+ "src/utils/truncate.ts"() {
12093
+ "use strict";
12094
+ init_cjs_shims();
12095
+ }
12096
+ });
12097
+
12075
12098
  // src/analysis/FailureClusterer.ts
12076
12099
  function errorHeader(message) {
12077
12100
  const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
@@ -12107,8 +12130,7 @@ function topFrame(stack) {
12107
12130
  return void 0;
12108
12131
  }
12109
12132
  function representativeMessage(message) {
12110
- const firstLine = message.split("\n")[0];
12111
- return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
12133
+ return clampWithEllipsis(message.split("\n")[0], MAX_MESSAGE_CHARS);
12112
12134
  }
12113
12135
  function classifyOne(f, model) {
12114
12136
  const frame = topFrame(f.error.stack);
@@ -12163,6 +12185,7 @@ var init_FailureClusterer = __esm({
12163
12185
  init_cjs_shims();
12164
12186
  init_NaiveBayesClassifier();
12165
12187
  init_constants();
12188
+ init_truncate();
12166
12189
  MAX_TESTS_PER_CLUSTER = 20;
12167
12190
  MAX_MESSAGE_CHARS = 200;
12168
12191
  MESSAGE_RULES = [
package/dist/index.d.mts CHANGED
@@ -209,6 +209,34 @@ interface ReporterOptions {
209
209
  */
210
210
  autoUpdateModel?: boolean;
211
211
  };
212
+ /**
213
+ * Opt-in rich execution capture for the defect log. Reporter-side only — reads
214
+ * what Playwright already records on each test result (steps, console, trace/
215
+ * video attachments); no fixtures and no test-code changes. Off by default, so
216
+ * reports are unchanged until you enable it. Captured detail renders in the
217
+ * Defect Log section of the `detailed` template.
218
+ *
219
+ * @example
220
+ * capture: { steps: true, console: true, evidence: true }
221
+ */
222
+ capture?: {
223
+ /** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
224
+ steps?: boolean;
225
+ /**
226
+ * Include granular `pw:api` actions (click/fill/goto) in the repro trail. Off by
227
+ * default — the trail shows only `test.step` intent + `expect` assertions + the
228
+ * failing action. Default: false
229
+ */
230
+ apiSteps?: boolean;
231
+ /** Capture the tail of the test's Node stdout/stderr. Default: false */
232
+ console?: boolean;
233
+ /** Capture file links to the Playwright trace.zip and video. Default: false */
234
+ evidence?: boolean;
235
+ /** Max repro steps retained per failure. Default: 50 */
236
+ maxSteps?: number;
237
+ /** Max console lines retained per stream. Default: 50 */
238
+ maxConsoleLines?: number;
239
+ };
212
240
  /**
213
241
  * Path to the history JSON file. Defaults to
214
242
  * `~/.reportforge/{projectKey}/history.json` where `projectKey` is derived
package/dist/index.d.ts CHANGED
@@ -209,6 +209,34 @@ interface ReporterOptions {
209
209
  */
210
210
  autoUpdateModel?: boolean;
211
211
  };
212
+ /**
213
+ * Opt-in rich execution capture for the defect log. Reporter-side only — reads
214
+ * what Playwright already records on each test result (steps, console, trace/
215
+ * video attachments); no fixtures and no test-code changes. Off by default, so
216
+ * reports are unchanged until you enable it. Captured detail renders in the
217
+ * Defect Log section of the `detailed` template.
218
+ *
219
+ * @example
220
+ * capture: { steps: true, console: true, evidence: true }
221
+ */
222
+ capture?: {
223
+ /** Capture a compacted repro-steps trail from the Playwright step tree. Default: false */
224
+ steps?: boolean;
225
+ /**
226
+ * Include granular `pw:api` actions (click/fill/goto) in the repro trail. Off by
227
+ * default — the trail shows only `test.step` intent + `expect` assertions + the
228
+ * failing action. Default: false
229
+ */
230
+ apiSteps?: boolean;
231
+ /** Capture the tail of the test's Node stdout/stderr. Default: false */
232
+ console?: boolean;
233
+ /** Capture file links to the Playwright trace.zip and video. Default: false */
234
+ evidence?: boolean;
235
+ /** Max repro steps retained per failure. Default: 50 */
236
+ maxSteps?: number;
237
+ /** Max console lines retained per stream. Default: 50 */
238
+ maxConsoleLines?: number;
239
+ };
212
240
  /**
213
241
  * Path to the history JSON file. Defaults to
214
242
  * `~/.reportforge/{projectKey}/history.json` where `projectKey` is derived
package/dist/index.js CHANGED
@@ -10476,6 +10476,14 @@ var failureAnalysisConfigSchema = external_exports.object({
10476
10476
  collectUnclassified: external_exports.boolean().default(true),
10477
10477
  autoUpdateModel: external_exports.boolean().default(true)
10478
10478
  }).default({});
10479
+ var captureConfigSchema = external_exports.object({
10480
+ steps: external_exports.boolean().default(false),
10481
+ apiSteps: external_exports.boolean().default(false),
10482
+ console: external_exports.boolean().default(false),
10483
+ evidence: external_exports.boolean().default(false),
10484
+ maxSteps: external_exports.number().int().min(1).max(500).default(50),
10485
+ maxConsoleLines: external_exports.number().int().min(1).max(1e3).default(50)
10486
+ }).default({});
10479
10487
  var sectionFlagsShape = Object.fromEntries(
10480
10488
  SECTION_KEYS.map((k) => [k, external_exports.boolean().optional()])
10481
10489
  );
@@ -10516,6 +10524,7 @@ var ReporterOptionsSchema = external_exports.object({
10516
10524
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10517
10525
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10518
10526
  failureAnalysis: failureAnalysisConfigSchema,
10527
+ capture: captureConfigSchema,
10519
10528
  templatePath: external_exports.union([
10520
10529
  external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
10521
10530
  external_exports.array(
@@ -11118,6 +11127,12 @@ var STRENGTH = {
11118
11127
  var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11119
11128
  var OVERRIDE_MARGIN = 999;
11120
11129
 
11130
+ // src/utils/truncate.ts
11131
+ init_cjs_shims();
11132
+ function clampWithEllipsis(text, max) {
11133
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
11134
+ }
11135
+
11121
11136
  // src/analysis/FailureClusterer.ts
11122
11137
  var MAX_TESTS_PER_CLUSTER = 20;
11123
11138
  var MAX_MESSAGE_CHARS = 200;
@@ -11161,8 +11176,7 @@ function topFrame(stack) {
11161
11176
  return void 0;
11162
11177
  }
11163
11178
  function representativeMessage(message) {
11164
- const firstLine = message.split("\n")[0];
11165
- return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
11179
+ return clampWithEllipsis(message.split("\n")[0], MAX_MESSAGE_CHARS);
11166
11180
  }
11167
11181
  function classifyOne(f, model) {
11168
11182
  const frame = topFrame(f.error.stack);
@@ -11386,6 +11400,170 @@ function stripAnsi(input) {
11386
11400
  return input.replace(ANSI_PATTERN, "");
11387
11401
  }
11388
11402
 
11403
+ // src/collector/execution-capture.ts
11404
+ init_cjs_shims();
11405
+ var MAX_TITLE_CHARS = 200;
11406
+ var DEFAULT_MAX_STEPS = 50;
11407
+ var DEFAULT_MAX_CONSOLE_LINES = 50;
11408
+ var PW_API_WINDOW = 10;
11409
+ var isTeardown = (category) => category === "hook" || category === "fixture";
11410
+ var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11411
+ function flatten(steps) {
11412
+ const out = [];
11413
+ const walk = (nodes, depth, ancestors) => {
11414
+ for (const s of nodes ?? []) {
11415
+ const index = out.length;
11416
+ out.push({
11417
+ raw: s,
11418
+ category: s.category ?? "",
11419
+ depth,
11420
+ hasError: s.error != null,
11421
+ ancestors
11422
+ });
11423
+ if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11424
+ }
11425
+ };
11426
+ walk(steps ?? [], 0, []);
11427
+ return out;
11428
+ }
11429
+ function materialize(n, isFailing) {
11430
+ const loc = n.raw.location;
11431
+ return {
11432
+ title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11433
+ category: n.category,
11434
+ durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11435
+ depth: n.depth,
11436
+ failed: n.hasError,
11437
+ errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11438
+ location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11439
+ };
11440
+ }
11441
+ function pickFailingIdx(flat) {
11442
+ const pick = (allowTeardown) => {
11443
+ let idx = -1;
11444
+ for (let i = 0; i < flat.length; i++) {
11445
+ const f = flat[i];
11446
+ if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11447
+ if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11448
+ }
11449
+ return idx;
11450
+ };
11451
+ const body = pick(false);
11452
+ return body >= 0 ? body : pick(true);
11453
+ }
11454
+ function compactSteps(rawSteps, opts = {}) {
11455
+ const flat = flatten(rawSteps);
11456
+ if (flat.length === 0) return { steps: [] };
11457
+ const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11458
+ const failingIdx = pickFailingIdx(flat);
11459
+ const keep = /* @__PURE__ */ new Set();
11460
+ flat.forEach((f, i) => {
11461
+ if (f.category === "test.step" || f.category === "expect") keep.add(i);
11462
+ });
11463
+ if (failingIdx >= 0) {
11464
+ keep.add(failingIdx);
11465
+ for (const a of flat[failingIdx].ancestors) keep.add(a);
11466
+ if (opts.apiSteps) {
11467
+ const pwApiBefore = [];
11468
+ for (let i = 0; i < failingIdx; i++) {
11469
+ if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
11470
+ }
11471
+ for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
11472
+ }
11473
+ }
11474
+ let keepArr = [...keep].sort((a, b) => a - b);
11475
+ if (keepArr.length > maxSteps) {
11476
+ keepArr = keepArr.slice(-maxSteps);
11477
+ if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11478
+ keepArr = [failingIdx, ...keepArr.slice(1)];
11479
+ }
11480
+ }
11481
+ const cache = /* @__PURE__ */ new Map();
11482
+ const mat = (i) => {
11483
+ let es = cache.get(i);
11484
+ if (!es) {
11485
+ es = materialize(flat[i], i === failingIdx);
11486
+ cache.set(i, es);
11487
+ }
11488
+ return es;
11489
+ };
11490
+ return {
11491
+ steps: keepArr.map(mat),
11492
+ failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11493
+ };
11494
+ }
11495
+ function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11496
+ const tail = (chunks) => {
11497
+ if (chunks.length === 0) return { lines: [], truncated: false };
11498
+ const all = chunks.join("").split(/\r?\n/);
11499
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
11500
+ const lines = all.slice(-maxLines).map(stripAnsi);
11501
+ return { lines, truncated: all.length > lines.length };
11502
+ };
11503
+ const o = tail(stdout);
11504
+ const e = tail(stderr);
11505
+ if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11506
+ return {
11507
+ stdout: o.lines,
11508
+ stderr: e.lines,
11509
+ truncated: o.truncated || e.truncated,
11510
+ stdoutTruncated: o.truncated,
11511
+ stderrTruncated: e.truncated
11512
+ };
11513
+ }
11514
+ function extractEvidence(attachments) {
11515
+ let tracePath;
11516
+ let videoPath;
11517
+ for (const a of attachments) {
11518
+ if (!a.path) continue;
11519
+ const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11520
+ if (!tracePath && looksLikeTrace) tracePath = a.path;
11521
+ if (!videoPath && a.contentType?.startsWith("video/")) videoPath = a.path;
11522
+ }
11523
+ if (!tracePath && !videoPath) return void 0;
11524
+ return { tracePath, videoPath };
11525
+ }
11526
+ function buildExecution(input, opts, source) {
11527
+ const capture = {};
11528
+ if (opts.steps) {
11529
+ const { steps, failingStep } = compactSteps(input.steps, {
11530
+ maxSteps: opts.maxSteps,
11531
+ apiSteps: opts.apiSteps
11532
+ });
11533
+ if (steps.length > 0) capture.steps = steps;
11534
+ if (failingStep) capture.failingStep = failingStep;
11535
+ }
11536
+ if (opts.console) {
11537
+ const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11538
+ if (console2) capture.console = console2;
11539
+ }
11540
+ if (opts.evidence) {
11541
+ const evidence = extractEvidence(input.attachments);
11542
+ if (evidence) capture.evidence = evidence;
11543
+ }
11544
+ if (Object.keys(capture).length === 0) return void 0;
11545
+ capture.source = source;
11546
+ return capture;
11547
+ }
11548
+ function chunkToString(chunk) {
11549
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
11550
+ }
11551
+ function decodeShardChunk(c) {
11552
+ return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11553
+ }
11554
+ function executionFromResult(result, opts) {
11555
+ return buildExecution(
11556
+ {
11557
+ steps: result.steps,
11558
+ stdout: result.stdout.map(chunkToString),
11559
+ stderr: result.stderr.map(chunkToString),
11560
+ attachments: result.attachments
11561
+ },
11562
+ opts,
11563
+ "reporter"
11564
+ );
11565
+ }
11566
+
11389
11567
  // src/collector/SuiteWalker.ts
11390
11568
  init_cjs_shims();
11391
11569
 
@@ -11595,7 +11773,9 @@ function gitCommit() {
11595
11773
 
11596
11774
  // src/collector/DataCollector.ts
11597
11775
  var DataCollector = class {
11598
- constructor() {
11776
+ /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
11777
+ constructor(captureOpts = {}) {
11778
+ this.captureOpts = captureOpts;
11599
11779
  this.resultMap = /* @__PURE__ */ new Map();
11600
11780
  this.failureRecords = [];
11601
11781
  this.startTime = Date.now();
@@ -11665,7 +11845,9 @@ var DataCollector = class {
11665
11845
  duration: result.duration
11666
11846
  }
11667
11847
  ],
11668
- severity
11848
+ durationMs: result.duration,
11849
+ severity,
11850
+ execution: executionFromResult(result, this.captureOpts)
11669
11851
  };
11670
11852
  }
11671
11853
  inferSeverity(test) {
@@ -11771,6 +11953,10 @@ var import_fs3 = require("fs");
11771
11953
  var import_glob = require("glob");
11772
11954
  var import_node_module = require("module");
11773
11955
  var ShardMerger = class {
11956
+ /** @param captureOpts Per-run rich-capture knobs; all off by default (parity with DataCollector). */
11957
+ constructor(captureOpts = {}) {
11958
+ this.captureOpts = captureOpts;
11959
+ }
11774
11960
  merge(filePaths) {
11775
11961
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11776
11962
  const resolved = this.resolveShardPaths(filePaths);
@@ -11975,7 +12161,21 @@ var ShardMerger = class {
11975
12161
  status: r.status,
11976
12162
  duration: r.duration
11977
12163
  })),
11978
- severity: "medium"
12164
+ durationMs: lastResult?.duration ?? 0,
12165
+ severity: "medium",
12166
+ execution: lastResult ? buildExecution(
12167
+ {
12168
+ // Guard each field — a partial / non-conformant / older shard JSON can
12169
+ // omit steps or the stdout/stderr arrays; an unguarded .map/iterate would
12170
+ // throw and (via the merge catch) silently skip the entire PDF.
12171
+ steps: lastResult.steps ?? [],
12172
+ stdout: (lastResult.stdout ?? []).map(decodeShardChunk),
12173
+ stderr: (lastResult.stderr ?? []).map(decodeShardChunk),
12174
+ attachments: lastResult.attachments ?? []
12175
+ },
12176
+ this.captureOpts,
12177
+ "shard"
12178
+ ) : void 0
11979
12179
  };
11980
12180
  }
11981
12181
  buildEnvironment(report) {
@@ -13292,10 +13492,10 @@ var NotificationSender = class {
13292
13492
  var PdfReporter = class {
13293
13493
  constructor(rawOptions = {}) {
13294
13494
  this.cwd = process.cwd();
13295
- this.dataCollector = new DataCollector();
13296
13495
  this.pdfGenerator = new PdfGenerator();
13297
13496
  this.options = parseOptions(rawOptions);
13298
- const version = true ? "0.7.2" : "0.x";
13497
+ this.dataCollector = new DataCollector(this.options.capture);
13498
+ const version = true ? "0.8.1" : "0.x";
13299
13499
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13300
13500
  this.licenseClient = new LicenseClient({
13301
13501
  licenseKey: this.options.licenseKey,
@@ -13342,7 +13542,7 @@ var PdfReporter = class {
13342
13542
  _collectData(result) {
13343
13543
  if (this.options.shardResults && this.options.shardResults.length > 0) {
13344
13544
  try {
13345
- return new ShardMerger().merge(this.options.shardResults);
13545
+ return new ShardMerger(this.options.capture).merge(this.options.shardResults);
13346
13546
  } catch (err) {
13347
13547
  logger.warn(`shardResults merge failed: ${err.message}. PDF generation skipped.`);
13348
13548
  return null;
package/dist/index.mjs CHANGED
@@ -10477,6 +10477,14 @@ var failureAnalysisConfigSchema = external_exports.object({
10477
10477
  collectUnclassified: external_exports.boolean().default(true),
10478
10478
  autoUpdateModel: external_exports.boolean().default(true)
10479
10479
  }).default({});
10480
+ var captureConfigSchema = external_exports.object({
10481
+ steps: external_exports.boolean().default(false),
10482
+ apiSteps: external_exports.boolean().default(false),
10483
+ console: external_exports.boolean().default(false),
10484
+ evidence: external_exports.boolean().default(false),
10485
+ maxSteps: external_exports.number().int().min(1).max(500).default(50),
10486
+ maxConsoleLines: external_exports.number().int().min(1).max(1e3).default(50)
10487
+ }).default({});
10480
10488
  var sectionFlagsShape = Object.fromEntries(
10481
10489
  SECTION_KEYS.map((k) => [k, external_exports.boolean().optional()])
10482
10490
  );
@@ -10517,6 +10525,7 @@ var ReporterOptionsSchema = external_exports.object({
10517
10525
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10518
10526
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10519
10527
  failureAnalysis: failureAnalysisConfigSchema,
10528
+ capture: captureConfigSchema,
10520
10529
  templatePath: external_exports.union([
10521
10530
  external_exports.string().regex(/\.hbs$/, "templatePath must end with .hbs").refine((v) => v.slice(0, -4).trim().length > 0, "templatePath basename must not be empty"),
10522
10531
  external_exports.array(
@@ -11119,6 +11128,12 @@ var STRENGTH = {
11119
11128
  var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11120
11129
  var OVERRIDE_MARGIN = 999;
11121
11130
 
11131
+ // src/utils/truncate.ts
11132
+ init_esm_shims();
11133
+ function clampWithEllipsis(text, max) {
11134
+ return text.length > max ? `${text.slice(0, max)}\u2026` : text;
11135
+ }
11136
+
11122
11137
  // src/analysis/FailureClusterer.ts
11123
11138
  var MAX_TESTS_PER_CLUSTER = 20;
11124
11139
  var MAX_MESSAGE_CHARS = 200;
@@ -11162,8 +11177,7 @@ function topFrame(stack) {
11162
11177
  return void 0;
11163
11178
  }
11164
11179
  function representativeMessage(message) {
11165
- const firstLine = message.split("\n")[0];
11166
- return firstLine.length > MAX_MESSAGE_CHARS ? `${firstLine.slice(0, MAX_MESSAGE_CHARS)}\u2026` : firstLine;
11180
+ return clampWithEllipsis(message.split("\n")[0], MAX_MESSAGE_CHARS);
11167
11181
  }
11168
11182
  function classifyOne(f, model) {
11169
11183
  const frame = topFrame(f.error.stack);
@@ -11387,6 +11401,170 @@ function stripAnsi(input) {
11387
11401
  return input.replace(ANSI_PATTERN, "");
11388
11402
  }
11389
11403
 
11404
+ // src/collector/execution-capture.ts
11405
+ init_esm_shims();
11406
+ var MAX_TITLE_CHARS = 200;
11407
+ var DEFAULT_MAX_STEPS = 50;
11408
+ var DEFAULT_MAX_CONSOLE_LINES = 50;
11409
+ var PW_API_WINDOW = 10;
11410
+ var isTeardown = (category) => category === "hook" || category === "fixture";
11411
+ var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11412
+ function flatten(steps) {
11413
+ const out = [];
11414
+ const walk = (nodes, depth, ancestors) => {
11415
+ for (const s of nodes ?? []) {
11416
+ const index = out.length;
11417
+ out.push({
11418
+ raw: s,
11419
+ category: s.category ?? "",
11420
+ depth,
11421
+ hasError: s.error != null,
11422
+ ancestors
11423
+ });
11424
+ if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11425
+ }
11426
+ };
11427
+ walk(steps ?? [], 0, []);
11428
+ return out;
11429
+ }
11430
+ function materialize(n, isFailing) {
11431
+ const loc = n.raw.location;
11432
+ return {
11433
+ title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11434
+ category: n.category,
11435
+ durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11436
+ depth: n.depth,
11437
+ failed: n.hasError,
11438
+ errorMessage: isFailing && n.raw.error?.message ? clampWithEllipsis(stripAnsi(n.raw.error.message).split("\n")[0], MAX_TITLE_CHARS) : void 0,
11439
+ location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11440
+ };
11441
+ }
11442
+ function pickFailingIdx(flat) {
11443
+ const pick = (allowTeardown) => {
11444
+ let idx = -1;
11445
+ for (let i = 0; i < flat.length; i++) {
11446
+ const f = flat[i];
11447
+ if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11448
+ if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11449
+ }
11450
+ return idx;
11451
+ };
11452
+ const body = pick(false);
11453
+ return body >= 0 ? body : pick(true);
11454
+ }
11455
+ function compactSteps(rawSteps, opts = {}) {
11456
+ const flat = flatten(rawSteps);
11457
+ if (flat.length === 0) return { steps: [] };
11458
+ const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11459
+ const failingIdx = pickFailingIdx(flat);
11460
+ const keep = /* @__PURE__ */ new Set();
11461
+ flat.forEach((f, i) => {
11462
+ if (f.category === "test.step" || f.category === "expect") keep.add(i);
11463
+ });
11464
+ if (failingIdx >= 0) {
11465
+ keep.add(failingIdx);
11466
+ for (const a of flat[failingIdx].ancestors) keep.add(a);
11467
+ if (opts.apiSteps) {
11468
+ const pwApiBefore = [];
11469
+ for (let i = 0; i < failingIdx; i++) {
11470
+ if (flat[i].category === "pw:api" && !inHookSubtree(flat, flat[i])) pwApiBefore.push(i);
11471
+ }
11472
+ for (const i of pwApiBefore.slice(-PW_API_WINDOW)) keep.add(i);
11473
+ }
11474
+ }
11475
+ let keepArr = [...keep].sort((a, b) => a - b);
11476
+ if (keepArr.length > maxSteps) {
11477
+ keepArr = keepArr.slice(-maxSteps);
11478
+ if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11479
+ keepArr = [failingIdx, ...keepArr.slice(1)];
11480
+ }
11481
+ }
11482
+ const cache = /* @__PURE__ */ new Map();
11483
+ const mat = (i) => {
11484
+ let es = cache.get(i);
11485
+ if (!es) {
11486
+ es = materialize(flat[i], i === failingIdx);
11487
+ cache.set(i, es);
11488
+ }
11489
+ return es;
11490
+ };
11491
+ return {
11492
+ steps: keepArr.map(mat),
11493
+ failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11494
+ };
11495
+ }
11496
+ function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11497
+ const tail = (chunks) => {
11498
+ if (chunks.length === 0) return { lines: [], truncated: false };
11499
+ const all = chunks.join("").split(/\r?\n/);
11500
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
11501
+ const lines = all.slice(-maxLines).map(stripAnsi);
11502
+ return { lines, truncated: all.length > lines.length };
11503
+ };
11504
+ const o = tail(stdout);
11505
+ const e = tail(stderr);
11506
+ if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11507
+ return {
11508
+ stdout: o.lines,
11509
+ stderr: e.lines,
11510
+ truncated: o.truncated || e.truncated,
11511
+ stdoutTruncated: o.truncated,
11512
+ stderrTruncated: e.truncated
11513
+ };
11514
+ }
11515
+ function extractEvidence(attachments) {
11516
+ let tracePath;
11517
+ let videoPath;
11518
+ for (const a of attachments) {
11519
+ if (!a.path) continue;
11520
+ const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11521
+ if (!tracePath && looksLikeTrace) tracePath = a.path;
11522
+ if (!videoPath && a.contentType?.startsWith("video/")) videoPath = a.path;
11523
+ }
11524
+ if (!tracePath && !videoPath) return void 0;
11525
+ return { tracePath, videoPath };
11526
+ }
11527
+ function buildExecution(input, opts, source) {
11528
+ const capture = {};
11529
+ if (opts.steps) {
11530
+ const { steps, failingStep } = compactSteps(input.steps, {
11531
+ maxSteps: opts.maxSteps,
11532
+ apiSteps: opts.apiSteps
11533
+ });
11534
+ if (steps.length > 0) capture.steps = steps;
11535
+ if (failingStep) capture.failingStep = failingStep;
11536
+ }
11537
+ if (opts.console) {
11538
+ const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11539
+ if (console2) capture.console = console2;
11540
+ }
11541
+ if (opts.evidence) {
11542
+ const evidence = extractEvidence(input.attachments);
11543
+ if (evidence) capture.evidence = evidence;
11544
+ }
11545
+ if (Object.keys(capture).length === 0) return void 0;
11546
+ capture.source = source;
11547
+ return capture;
11548
+ }
11549
+ function chunkToString(chunk) {
11550
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
11551
+ }
11552
+ function decodeShardChunk(c) {
11553
+ return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11554
+ }
11555
+ function executionFromResult(result, opts) {
11556
+ return buildExecution(
11557
+ {
11558
+ steps: result.steps,
11559
+ stdout: result.stdout.map(chunkToString),
11560
+ stderr: result.stderr.map(chunkToString),
11561
+ attachments: result.attachments
11562
+ },
11563
+ opts,
11564
+ "reporter"
11565
+ );
11566
+ }
11567
+
11390
11568
  // src/collector/SuiteWalker.ts
11391
11569
  init_esm_shims();
11392
11570
 
@@ -11596,7 +11774,9 @@ function gitCommit() {
11596
11774
 
11597
11775
  // src/collector/DataCollector.ts
11598
11776
  var DataCollector = class {
11599
- constructor() {
11777
+ /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
11778
+ constructor(captureOpts = {}) {
11779
+ this.captureOpts = captureOpts;
11600
11780
  this.resultMap = /* @__PURE__ */ new Map();
11601
11781
  this.failureRecords = [];
11602
11782
  this.startTime = Date.now();
@@ -11666,7 +11846,9 @@ var DataCollector = class {
11666
11846
  duration: result.duration
11667
11847
  }
11668
11848
  ],
11669
- severity
11849
+ durationMs: result.duration,
11850
+ severity,
11851
+ execution: executionFromResult(result, this.captureOpts)
11670
11852
  };
11671
11853
  }
11672
11854
  inferSeverity(test) {
@@ -11772,6 +11954,10 @@ import { readFileSync } from "fs";
11772
11954
  import { globSync } from "glob";
11773
11955
  import { createRequire } from "module";
11774
11956
  var ShardMerger = class {
11957
+ /** @param captureOpts Per-run rich-capture knobs; all off by default (parity with DataCollector). */
11958
+ constructor(captureOpts = {}) {
11959
+ this.captureOpts = captureOpts;
11960
+ }
11775
11961
  merge(filePaths) {
11776
11962
  if (filePaths.length === 0) throw new Error("shardResults: no valid JSON shard files found");
11777
11963
  const resolved = this.resolveShardPaths(filePaths);
@@ -11976,7 +12162,21 @@ var ShardMerger = class {
11976
12162
  status: r.status,
11977
12163
  duration: r.duration
11978
12164
  })),
11979
- severity: "medium"
12165
+ durationMs: lastResult?.duration ?? 0,
12166
+ severity: "medium",
12167
+ execution: lastResult ? buildExecution(
12168
+ {
12169
+ // Guard each field — a partial / non-conformant / older shard JSON can
12170
+ // omit steps or the stdout/stderr arrays; an unguarded .map/iterate would
12171
+ // throw and (via the merge catch) silently skip the entire PDF.
12172
+ steps: lastResult.steps ?? [],
12173
+ stdout: (lastResult.stdout ?? []).map(decodeShardChunk),
12174
+ stderr: (lastResult.stderr ?? []).map(decodeShardChunk),
12175
+ attachments: lastResult.attachments ?? []
12176
+ },
12177
+ this.captureOpts,
12178
+ "shard"
12179
+ ) : void 0
11980
12180
  };
11981
12181
  }
11982
12182
  buildEnvironment(report) {
@@ -13293,10 +13493,10 @@ var NotificationSender = class {
13293
13493
  var PdfReporter = class {
13294
13494
  constructor(rawOptions = {}) {
13295
13495
  this.cwd = process.cwd();
13296
- this.dataCollector = new DataCollector();
13297
13496
  this.pdfGenerator = new PdfGenerator();
13298
13497
  this.options = parseOptions(rawOptions);
13299
- const version = true ? "0.7.2" : "0.x";
13498
+ this.dataCollector = new DataCollector(this.options.capture);
13499
+ const version = true ? "0.8.1" : "0.x";
13300
13500
  logger.info(`@reportforge/playwright-pdf v${version} initialised`);
13301
13501
  this.licenseClient = new LicenseClient({
13302
13502
  licenseKey: this.options.licenseKey,
@@ -13343,7 +13543,7 @@ var PdfReporter = class {
13343
13543
  _collectData(result) {
13344
13544
  if (this.options.shardResults && this.options.shardResults.length > 0) {
13345
13545
  try {
13346
- return new ShardMerger().merge(this.options.shardResults);
13546
+ return new ShardMerger(this.options.capture).merge(this.options.shardResults);
13347
13547
  } catch (err) {
13348
13548
  logger.warn(`shardResults merge failed: ${err.message}. PDF generation skipped.`);
13349
13549
  return null;
@@ -8,20 +8,70 @@
8
8
  <th>Title</th>
9
9
  <th style="width:100px;">Suite</th>
10
10
  <th style="width:80px;">Severity</th>
11
- <th style="width:55px;text-align:center;">Retries</th>
11
+ <th style="width:72px;text-align:right;">Duration</th>
12
12
  </tr>
13
13
  </thead>
14
- <tbody>
15
14
  {{#each failures}}
15
+ <tbody class="defect-row-group">
16
16
  <tr class="no-break">
17
17
  <td class="defect-id">DEF-{{addOne @index}}</td>
18
18
  <td style="font-weight:500;">{{testTitle}}</td>
19
19
  <td style="font-size:9px;color:var(--text-3);font-family:'JetBrains Mono',monospace;">{{lastItem suitePath}}</td>
20
20
  <td><span class="severity--{{severity}}">{{upperCase severity}}</span></td>
21
- <td style="text-align:center;color:var(--text-3);">{{retryCount}}</td>
21
+ <td style="text-align:right;color:var(--text-3);font-family:'JetBrains Mono',monospace;">{{formatDuration durationMs}}</td>
22
22
  </tr>
23
+ {{#if execution}}
24
+ <tr class="defect-detail-row">
25
+ <td colspan="5">
26
+ <div class="defect-detail">
27
+ {{! Error sits at the failing step inside the trail. Only when there is no
28
+ identified failing step (capture.steps off, or no errored step) is it
29
+ shown here under the title as a fallback. }}
30
+ {{#unless execution.failingStep}}
31
+ {{#if error.message}}
32
+ <div class="defect-detail__error">{{error.message}}</div>
33
+ {{/if}}
34
+ {{/unless}}
35
+
36
+ {{#if execution.steps.length}}
37
+ <div class="repro">
38
+ <div class="repro__tag">Steps to Reproduce</div>
39
+ <ol class="repro-steps">
40
+ {{#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>
48
+ {{/each}}
49
+ </ol>
50
+ </div>
51
+ {{/if}}
52
+
53
+ {{#if execution.console}}
54
+ <div class="console-tail">
55
+ <div class="console-tail__tag">Console</div>
56
+ {{#if execution.console.stdoutTruncated}}<div class="console-tail__more">… earlier stdout truncated</div>{{/if}}
57
+ {{#each execution.console.stdout}}<div class="console-tail__line"><span class="console-tail__stream">out</span>{{this}}</div>{{/each}}
58
+ {{#if execution.console.stderrTruncated}}<div class="console-tail__more">… earlier stderr truncated</div>{{/if}}
59
+ {{#each execution.console.stderr}}<div class="console-tail__line console-tail__line--err"><span class="console-tail__stream">err</span>{{this}}</div>{{/each}}
60
+ </div>
61
+ {{/if}}
62
+
63
+ {{#if execution.evidence}}
64
+ <div class="evidence-links">
65
+ {{#if execution.evidence.tracePath}}<span class="evidence-link"><span class="evidence-link__tag">Trace</span><span class="evidence-link__path">{{execution.evidence.tracePath}}</span></span>{{/if}}
66
+ {{#if execution.evidence.videoPath}}<span class="evidence-link"><span class="evidence-link__tag">Video</span><span class="evidence-link__path">{{execution.evidence.videoPath}}</span></span>{{/if}}
67
+ </div>
68
+ {{/if}}
69
+ </div>
70
+ </td>
71
+ </tr>
72
+ {{/if}}
73
+ </tbody>
23
74
  {{/each}}
24
- </tbody>
25
75
  </table>
26
76
  </div>
27
77
  {{/if}}
@@ -11,6 +11,14 @@
11
11
  <div class="failure-item__message">{{failure.error.message}}</div>
12
12
  {{/if}}
13
13
 
14
+ {{#if (and showFull failure.execution.failingStep)}}
15
+ <div class="failing-step">
16
+ <span class="failing-step__tag">Failed at</span>
17
+ <span class="failing-step__title">{{failure.execution.failingStep.title}}</span>
18
+ {{#if failure.execution.failingStep.location}}<span class="failing-step__loc">{{failure.execution.failingStep.location}}</span>{{/if}}
19
+ </div>
20
+ {{/if}}
21
+
14
22
  {{#if (and showFull failure.screenshotBase64)}}
15
23
  <img class="screenshot" src="{{failure.screenshotBase64}}" alt="Screenshot at failure">
16
24
  {{/if}}
@@ -404,6 +404,89 @@ code {
404
404
  .root-cause-chip--env-config .root-cause-chip__label { color: oklch(48% 0.14 145); }
405
405
  .root-cause-chip--unknown .root-cause-chip__label { color: var(--text-2); }
406
406
 
407
+ /* ── Repro detail for the defect-log section (opt-in `capture`) ──────────────
408
+ Rendered only when the capture option is enabled and data is present. Quiet,
409
+ design-system-consistent: a light bordered card (NO side rail, per the
410
+ side-stripe ban in DESIGN.md), with hairline dividers between sub-blocks —
411
+ mirroring the reworked .failure-detail and .retry-history patterns. */
412
+ /* Each failure is its own <tbody> so its summary row and detail card stay together
413
+ across a page break — the detail no longer strands under a repeated header. */
414
+ .defect-row-group { break-inside: avoid; page-break-inside: avoid; }
415
+ .defect-detail-row td { padding: 0; border-top: none; }
416
+ .defect-detail {
417
+ border: 1px solid var(--border-2);
418
+ border-radius: 6px;
419
+ margin: 2px 0 12px;
420
+ padding: 10px 12px 11px;
421
+ }
422
+ .defect-detail__error {
423
+ font-family: 'JetBrains Mono', monospace;
424
+ font-size: 9.5px; color: #8A2E1E; line-height: 1.6; word-break: break-word;
425
+ }
426
+ .failing-step {
427
+ margin-top: 9px; font-size: 9.5px;
428
+ font-family: 'Inter', system-ui, sans-serif;
429
+ }
430
+ .failing-step__tag, .repro__tag, .console-tail__tag, .evidence-link__tag {
431
+ font-size: 8px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
432
+ color: var(--text-3);
433
+ }
434
+ .failing-step__title { font-weight: 700; color: var(--text-1); margin-left: 6px; }
435
+ .failing-step__loc {
436
+ font-family: 'JetBrains Mono', monospace; font-size: 8.5px; color: var(--text-3); margin-left: 6px;
437
+ }
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;
446
+ }
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__error {
450
+ flex-basis: 100%; margin: 3px 0 2px; padding-left: 60px;
451
+ font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1.55;
452
+ color: #8A2E1E; white-space: pre-wrap; word-break: break-word;
453
+ }
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
+ .console-tail { margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border); }
466
+ .console-tail__line {
467
+ font-family: 'JetBrains Mono', monospace; font-size: 8.5px; line-height: 1.55;
468
+ color: var(--text-2); word-break: break-word; white-space: pre-wrap;
469
+ }
470
+ /* Non-colour stream cue (out/err) so the two streams read without relying on colour. */
471
+ .console-tail__stream {
472
+ display: inline-block; width: 22px; margin-right: 8px; flex: none;
473
+ font-size: 7.5px; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase;
474
+ color: var(--text-3); vertical-align: baseline;
475
+ }
476
+ .console-tail__line--err { color: #8A2E1E; }
477
+ .console-tail__line--err .console-tail__stream { color: var(--fail); }
478
+ /* Truncation note now leads the tail (earlier lines were dropped — they precede what's shown). */
479
+ .console-tail__more { font-size: 8px; color: var(--text-3); margin: 0 0 4px; font-style: italic; }
480
+ .evidence-links {
481
+ margin-top: 11px; padding-top: 10px; border-top: 1px solid var(--border);
482
+ display: flex; flex-wrap: wrap; gap: 14px;
483
+ }
484
+ .evidence-link { font-size: 9px; }
485
+ .evidence-link__path {
486
+ font-family: 'JetBrains Mono', monospace; font-size: 8.5px; color: var(--text-2);
487
+ margin-left: 5px; word-break: break-all;
488
+ }
489
+
407
490
  /* ── History trend card — shared by detailed + executive (charts.hbs) ─────── */
408
491
  .trend-card {
409
492
  background: var(--surface);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.7.2",
3
+ "version": "0.8.1",
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",