@reportforge/playwright-pdf 0.25.0 → 0.27.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/dist/index.mjs CHANGED
@@ -10355,6 +10355,7 @@ var SECTION_KEYS = [
10355
10355
  "slowTests",
10356
10356
  "defectLog",
10357
10357
  "briefBand",
10358
+ "runDiff",
10358
10359
  // display modifiers
10359
10360
  "passRate",
10360
10361
  "fullEnvironment",
@@ -10378,6 +10379,7 @@ var SECTION_DEFAULTS = {
10378
10379
  slowTests: false,
10379
10380
  defectLog: false,
10380
10381
  briefBand: false,
10382
+ runDiff: true,
10381
10383
  passRate: true,
10382
10384
  fullEnvironment: false,
10383
10385
  retries: true,
@@ -10399,6 +10401,7 @@ var SECTION_DEFAULTS = {
10399
10401
  slowTests: true,
10400
10402
  defectLog: true,
10401
10403
  briefBand: false,
10404
+ runDiff: true,
10402
10405
  passRate: true,
10403
10406
  fullEnvironment: true,
10404
10407
  retries: true,
@@ -10425,6 +10428,7 @@ var SECTION_DEFAULTS = {
10425
10428
  slowTests: true,
10426
10429
  defectLog: false,
10427
10430
  briefBand: true,
10431
+ runDiff: true,
10428
10432
  passRate: true,
10429
10433
  fullEnvironment: false,
10430
10434
  retries: false,
@@ -11005,8 +11009,22 @@ function acceptDeliveredModel(d) {
11005
11009
  }
11006
11010
  }
11007
11011
 
11008
- // src/license/LicenseClient.ts
11012
+ // src/license/tls-teardown-settle.ts
11013
+ init_esm_shims();
11014
+ var WINDOWS_TLS_TEARDOWN_SETTLE_MS = 500;
11015
+ async function settleWindowsTlsTeardown() {
11016
+ if (process.platform !== "win32") return;
11017
+ logger.debug(`settling ${WINDOWS_TLS_TEARDOWN_SETTLE_MS}ms before fast exit (win32 TLS teardown, #175)`);
11018
+ await new Promise((resolve) => {
11019
+ setTimeout(resolve, WINDOWS_TLS_TEARDOWN_SETTLE_MS);
11020
+ });
11021
+ }
11022
+
11023
+ // src/license/constants.ts
11024
+ init_esm_shims();
11009
11025
  var DEFAULT_SERVER_URL = "https://reportforge.org";
11026
+
11027
+ // src/license/LicenseClient.ts
11010
11028
  var REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
11011
11029
  var NETWORK_TIMEOUT_MS = 8e3;
11012
11030
  var TERMINAL = /* @__PURE__ */ Symbol("terminal");
@@ -11044,9 +11062,16 @@ var LicenseClient = class {
11044
11062
  return toLicenseInfo(cacheValid, cached, normalized, "jwt-cache");
11045
11063
  }
11046
11064
  const network = await this.tryNetwork(cacheValid ? cached : null, normalized, verifier);
11047
- if (network === TERMINAL) return null;
11065
+ if (network === TERMINAL) {
11066
+ await settleWindowsTlsTeardown();
11067
+ return null;
11068
+ }
11048
11069
  if (network) return network;
11049
- return this._offlineFallback(cacheValid, normalized, nowMs);
11070
+ const fallback = this._offlineFallback(cacheValid, normalized, nowMs);
11071
+ if (!fallback) {
11072
+ await settleWindowsTlsTeardown();
11073
+ }
11074
+ return fallback;
11050
11075
  }
11051
11076
  _validateKey(key) {
11052
11077
  const parsed = this.parser.parse(key);
@@ -11234,111 +11259,24 @@ function normalizeUrl(url) {
11234
11259
  return url.endsWith("/") ? url.slice(0, -1) : url;
11235
11260
  }
11236
11261
 
11237
- // src/license/update-notice.ts
11238
- init_esm_shims();
11239
-
11240
- // src/license/version.ts
11241
- init_esm_shims();
11242
- function parseTriple(v) {
11243
- if (!v) return null;
11244
- const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
11245
- if (!m) return null;
11246
- return [Number(m[1]), Number(m[2]), Number(m[3])];
11247
- }
11248
- function isNewer(latest, current) {
11249
- const l = parseTriple(latest);
11250
- const c = parseTriple(current);
11251
- if (!l || !c) return false;
11252
- if (l[0] !== c[0]) return l[0] > c[0];
11253
- if (l[1] !== c[1]) return l[1] > c[1];
11254
- return l[2] > c[2];
11255
- }
11256
-
11257
- // src/license/update-notice.ts
11258
- function maybePrintUpdateNotice(licenseInfo, currentVersion) {
11259
- try {
11260
- const latest = licenseInfo?.latestVersion;
11261
- if (!latest || !isNewer(latest, currentVersion)) return;
11262
- const shown = latest.replace(/[^\w.+-]/g, "");
11263
- logger.info(
11264
- `Update available: ${currentVersion} -> ${shown}. Run: npm i -D @reportforge/playwright-pdf@latest`
11265
- );
11266
- } catch (err) {
11267
- logger.debug("update notice failed (ignored)", { error: err.message });
11268
- }
11269
- }
11270
-
11271
- // src/analysis/index.ts
11272
- init_esm_shims();
11273
-
11274
- // src/analysis/LocalModelStore.ts
11275
- init_esm_shims();
11276
- import fs4 from "fs";
11277
- import os5 from "os";
11278
- import path5 from "path";
11279
- var testDeps2 = null;
11280
- function defaultLocalModelPath() {
11281
- return testDeps2?.file ?? path5.join(os5.homedir(), ".reportforge", "model-local.json");
11282
- }
11283
- function loadLocalModel(overridePath) {
11284
- const file = overridePath ?? defaultLocalModelPath();
11285
- const explicit = overridePath !== void 0;
11286
- const reject = (why) => {
11287
- const msg = `local model at ${file} not used: ${why}`;
11288
- if (explicit) logger.warn(msg);
11289
- else logger.debug(msg);
11290
- return null;
11291
- };
11292
- let parsed;
11293
- try {
11294
- parsed = JSON.parse(fs4.readFileSync(file, "utf8"));
11295
- } catch (e) {
11296
- return explicit ? reject(`unreadable (${e.message})`) : null;
11297
- }
11298
- if (parsed.formatVersion !== 1) return reject(`unsupported formatVersion ${String(parsed.formatVersion)}`);
11299
- if (!isValidNaiveBayesModel(parsed.model)) return reject("embedded model failed schema validation");
11300
- const meta = parsed.meta;
11301
- if (!meta || typeof meta.gatePassed !== "boolean") return reject("missing meta");
11302
- if (!meta.gatePassed) return reject("its evaluation gate did not pass - run train-model after labeling more feedback");
11303
- if (typeof meta.acceptThreshold !== "number") return reject("gate passed but no calibrated threshold (corrupt meta)");
11304
- return {
11305
- model: parsed.model,
11306
- acceptThreshold: meta.acceptThreshold,
11307
- strongThreshold: typeof meta.strongThreshold === "number" ? meta.strongThreshold : null,
11308
- meta
11309
- };
11310
- }
11311
-
11312
- // src/analysis/FailureClusterer.ts
11262
+ // src/collector/DataCollector.ts
11313
11263
  init_esm_shims();
11314
11264
 
11315
- // src/analysis/redact.ts
11265
+ // src/utils/strip-ansi.ts
11316
11266
  init_esm_shims();
11317
- var URI_RE = /[a-z][\w+.-]*:\/\/\S+/gi;
11318
- var EMAIL_RE = /\b[\w.+-]+@[\w.-]+\.\w+\b/g;
11319
- var SECRET_RE = /\b(?=[A-Za-z0-9_+/=-]*[0-9])(?=[A-Za-z0-9_+/=-]*[A-Za-z])[A-Za-z0-9_+/=-]{16,}\b/g;
11320
- var IP_RE = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
11321
- var WIN_PATH_RE = /[A-Za-z]:\\[^\s'"]+/g;
11322
- var UNIX_PATH_RE = /(?:\/[\w.-]+){2,}/g;
11323
- function redactForStorage(raw) {
11324
- const redacted = raw.replace(URI_RE, " URL ").replace(EMAIL_RE, " EMAIL ").replace(SECRET_RE, " SECRET ").replace(IP_RE, " IP ").replace(WIN_PATH_RE, " PATH ").replace(UNIX_PATH_RE, " PATH ");
11325
- return tokenize(redacted).join(" ");
11326
- }
11327
- function localInferenceTokens(message) {
11328
- return redactForStorage(message).split(" ").filter(Boolean);
11267
+ var ANSI_PATTERN = new RegExp(
11268
+ [
11269
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
11270
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
11271
+ ].join("|"),
11272
+ "g"
11273
+ );
11274
+ function stripAnsi(input) {
11275
+ return input.replace(ANSI_PATTERN, "");
11329
11276
  }
11330
11277
 
11331
- // src/analysis/constants.ts
11278
+ // src/collector/execution-capture.ts
11332
11279
  init_esm_shims();
11333
- var STRENGTH = {
11334
- unknownMaxMargin: 0.05,
11335
- collectMaxMargin: 0.1,
11336
- harvestMinMargin: 0.25,
11337
- moderateMinMargin: 0.1,
11338
- strongMinMargin: 0.25
11339
- };
11340
- var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11341
- var OVERRIDE_MARGIN = 999;
11342
11280
 
11343
11281
  // src/utils/truncate.ts
11344
11282
  init_esm_shims();
@@ -11346,949 +11284,569 @@ function clampWithEllipsis(text, max) {
11346
11284
  return text.length > max ? `${text.slice(0, max)}\u2026` : text;
11347
11285
  }
11348
11286
 
11349
- // src/analysis/FailureClusterer.ts
11350
- var MAX_TESTS_PER_CLUSTER = 20;
11351
- var MAX_MESSAGE_CHARS = 200;
11352
- function errorHeader(message) {
11353
- const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11354
- return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11355
- }
11356
- function isAssertionTimeout(message) {
11357
- return /(?:Expect "[^"]+"|expect\.\w+) with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11358
- }
11359
- function isNetwork(message) {
11360
- return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
11361
- }
11362
- function isNavigation(message) {
11363
- return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
11364
- }
11365
- function isLocatorNotFound(message) {
11366
- return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
11367
- }
11368
- var MESSAGE_RULES = [
11369
- [isAssertionTimeout, "assertion"],
11370
- [isNavigation, "navigation"],
11371
- [isNetwork, "network"],
11372
- [isLocatorNotFound, "locator-not-found"]
11373
- ];
11374
- function strengthOf(margin) {
11375
- if (margin >= STRENGTH.strongMinMargin) return "strong";
11376
- if (margin >= STRENGTH.moderateMinMargin) return "moderate";
11377
- return "weak";
11378
- }
11379
- function classifyInput(f) {
11380
- const head = (f.error.stack ?? "").split("\n").slice(0, 3).join(" ");
11381
- return `${f.error.message} ${head}`;
11287
+ // src/collector/execution-capture.ts
11288
+ var MAX_TITLE_CHARS = 200;
11289
+ var DEFAULT_MAX_STEPS = 50;
11290
+ var DEFAULT_MAX_CONSOLE_LINES = 50;
11291
+ var isTeardown = (category) => category === "hook" || category === "fixture";
11292
+ var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11293
+ var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11294
+ const c = flat[a].category;
11295
+ return c === "pw:api" || c === "expect";
11296
+ });
11297
+ var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11298
+ function flatten(steps) {
11299
+ const out = [];
11300
+ const walk = (nodes, depth, ancestors) => {
11301
+ for (const s of nodes ?? []) {
11302
+ const index = out.length;
11303
+ out.push({
11304
+ raw: s,
11305
+ category: s.category ?? "",
11306
+ depth,
11307
+ hasError: s.error != null,
11308
+ ancestors
11309
+ });
11310
+ if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11311
+ }
11312
+ };
11313
+ walk(steps ?? [], 0, []);
11314
+ return out;
11382
11315
  }
11383
- function topFrame(stack) {
11384
- if (!stack) return void 0;
11385
- for (const line of stack.split("\n")) {
11386
- const t = line.trim();
11387
- if (t.startsWith("at ") && !t.replace(/\\/g, "/").includes("node_modules/playwright")) return t;
11388
- }
11389
- return void 0;
11316
+ function materialize(n, isFailing, depth) {
11317
+ const loc = n.raw.location;
11318
+ const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11319
+ return {
11320
+ title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11321
+ category: n.category,
11322
+ durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11323
+ depth,
11324
+ failed: n.hasError,
11325
+ // Marks the single failing leaf so the template renders the error there once —
11326
+ // independent of whether a step-level message survives (falls back to the record error).
11327
+ failingLeaf: isFailing || void 0,
11328
+ errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11329
+ location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11330
+ };
11390
11331
  }
11391
- function representativeMessage(message) {
11392
- return clampWithEllipsis(message.split("\n")[0], MAX_MESSAGE_CHARS);
11332
+ function pickFailingIdx(flat) {
11333
+ const pick = (allowTeardown) => {
11334
+ let idx = -1;
11335
+ for (let i = 0; i < flat.length; i++) {
11336
+ const f = flat[i];
11337
+ if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11338
+ if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11339
+ }
11340
+ return idx;
11341
+ };
11342
+ const body = pick(false);
11343
+ return body >= 0 ? body : pick(true);
11393
11344
  }
11394
- function classifyOne(f, chain) {
11395
- const frame = topFrame(f.error.stack);
11396
- if (f.retryHistory.some((r) => r.status === "passed")) {
11397
- return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, strength: "strong", source: "retry", failure: f, frame };
11345
+ function compactSteps(rawSteps, opts = {}) {
11346
+ const flat = flatten(rawSteps);
11347
+ if (flat.length === 0) return { steps: [] };
11348
+ const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11349
+ const failingIdx = pickFailingIdx(flat);
11350
+ const keep = /* @__PURE__ */ new Set();
11351
+ flat.forEach((f, i) => {
11352
+ if (inHookSubtree(flat, f)) return;
11353
+ if (f.category === "test.step") keep.add(i);
11354
+ else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11355
+ else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11356
+ });
11357
+ if (failingIdx >= 0) {
11358
+ keep.add(failingIdx);
11359
+ for (const a of flat[failingIdx].ancestors) keep.add(a);
11398
11360
  }
11399
- for (const [match, category] of MESSAGE_RULES) {
11400
- if (match(f.error.message)) {
11401
- return { category, margin: OVERRIDE_MARGIN, strength: "strong", source: "rule", failure: f, frame };
11361
+ let keepArr = [...keep].sort((a, b) => a - b);
11362
+ let omitted = 0;
11363
+ if (keepArr.length > maxSteps) {
11364
+ omitted = keepArr.length - maxSteps;
11365
+ keepArr = keepArr.slice(-maxSteps);
11366
+ if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11367
+ keepArr = [failingIdx, ...keepArr.slice(1)];
11402
11368
  }
11403
11369
  }
11404
- if (chain.local) {
11405
- const { model, acceptThreshold, strongThreshold } = chain.local;
11406
- const r = classifyTokens(model, localInferenceTokens(f.error.message));
11407
- if (r.label !== "unknown" && r.margin >= acceptThreshold) {
11408
- const strength = strongThreshold !== null && r.margin >= strongThreshold ? "strong" : "moderate";
11409
- return { category: r.label, margin: r.margin, strength, source: "local", failure: f, frame };
11370
+ const cache = /* @__PURE__ */ new Map();
11371
+ const mat = (i) => {
11372
+ let es = cache.get(i);
11373
+ if (!es) {
11374
+ es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11375
+ cache.set(i, es);
11410
11376
  }
11377
+ return es;
11378
+ };
11379
+ const steps = keepArr.map(mat);
11380
+ if (omitted > 0) {
11381
+ steps.unshift({
11382
+ title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11383
+ category: "",
11384
+ durationMs: 0,
11385
+ depth: 0,
11386
+ failed: false
11387
+ });
11411
11388
  }
11412
- const { label, margin } = classify(chain.base, classifyInput(f));
11413
- return { category: label, margin, strength: strengthOf(margin), source: "base", failure: f, frame };
11414
- }
11415
- function classifyAll(failures, chain) {
11416
- return failures.map((f) => classifyOne(f, chain));
11389
+ return {
11390
+ steps,
11391
+ failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11392
+ };
11417
11393
  }
11418
- function perTestClassifications(classified) {
11419
- return classified.map((c) => ({
11420
- testId: c.failure.testId,
11421
- category: c.category,
11422
- message: representativeMessage(c.failure.error.message),
11423
- strength: c.strength
11424
- // resolved at classification time, per producing source
11425
- }));
11394
+ function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11395
+ const tail = (chunks) => {
11396
+ if (chunks.length === 0) return { lines: [], truncated: false };
11397
+ const all = chunks.join("").split(/\r?\n/);
11398
+ if (all.length > 0 && all[all.length - 1] === "") all.pop();
11399
+ const lines = all.slice(-maxLines).map(stripAnsi);
11400
+ return { lines, truncated: all.length > lines.length };
11401
+ };
11402
+ const o = tail(stdout);
11403
+ const e = tail(stderr);
11404
+ if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11405
+ return {
11406
+ stdout: o.lines,
11407
+ stderr: e.lines,
11408
+ truncated: o.truncated || e.truncated,
11409
+ stdoutTruncated: o.truncated,
11410
+ stderrTruncated: e.truncated
11411
+ };
11426
11412
  }
11427
- function clusterClassified(classified) {
11428
- const groups = /* @__PURE__ */ new Map();
11429
- for (const c of classified) {
11430
- const key = `${c.category}::${c.frame ?? "__no-stack__"}`;
11431
- const bucket = groups.get(key);
11432
- if (bucket) bucket.push(c);
11433
- else groups.set(key, [c]);
11413
+ function extractEvidence(attachments) {
11414
+ let tracePath;
11415
+ let videoPath;
11416
+ for (const a of attachments) {
11417
+ if (!a.path) continue;
11418
+ const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11419
+ if (!tracePath && looksLikeTrace) tracePath = a.path;
11420
+ if (!videoPath && a.contentType?.startsWith("video/")) videoPath = a.path;
11434
11421
  }
11435
- const rank = Object.fromEntries(
11436
- STRENGTH_LEVELS.map((level, i) => [level, i])
11437
- );
11438
- const clusters = [];
11439
- for (const members of groups.values()) {
11440
- const strongest = members.reduce((a, b) => rank[b.strength] > rank[a.strength] ? b : a);
11441
- clusters.push({
11442
- category: members[0].category,
11443
- strength: strongest.strength,
11444
- margin: Math.max(...members.map((m) => m.margin)),
11445
- count: members.length,
11446
- tests: members.slice(0, MAX_TESTS_PER_CLUSTER).map((m) => m.failure.testTitle),
11447
- testsTotal: members.length,
11448
- representativeMessage: representativeMessage(members[0].failure.error.message),
11449
- stackFrame: members[0].frame
11422
+ if (!tracePath && !videoPath) return void 0;
11423
+ return { tracePath, videoPath };
11424
+ }
11425
+ function buildExecution(input, opts, source) {
11426
+ const capture = {};
11427
+ if (opts.steps) {
11428
+ const { steps, failingStep } = compactSteps(input.steps, {
11429
+ maxSteps: opts.maxSteps,
11430
+ apiSteps: opts.apiSteps
11450
11431
  });
11432
+ if (steps.length > 0) capture.steps = steps;
11433
+ if (failingStep) capture.failingStep = failingStep;
11451
11434
  }
11452
- return clusters;
11453
- }
11454
-
11455
- // src/analysis/SummaryWriter.ts
11456
- init_esm_shims();
11457
- var CATEGORY_LABEL = {
11458
- assertion: "Assertion failure",
11459
- timeout: "Timeout",
11460
- "locator-not-found": "Element not found",
11461
- network: "Network error",
11462
- navigation: "Navigation error",
11463
- "env-config": "Environment / config",
11464
- "flaky-by-retry": "Flaky (passed on retry)",
11465
- unknown: "Unclassified"
11466
- };
11467
- var SHORT_LABEL = {
11468
- assertion: "assertion",
11469
- timeout: "timeout",
11470
- "locator-not-found": "element not found",
11471
- network: "network",
11472
- navigation: "navigation",
11473
- "env-config": "env/config",
11474
- "flaky-by-retry": "flaky",
11475
- unknown: "unclassified"
11476
- };
11477
- function tallyEntries(clusters) {
11478
- const byCategory = /* @__PURE__ */ new Map();
11479
- for (const c of clusters) {
11480
- byCategory.set(c.category, (byCategory.get(c.category) ?? 0) + c.count);
11435
+ if (opts.console) {
11436
+ const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11437
+ if (console2) capture.console = console2;
11481
11438
  }
11482
- return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
11483
- }
11484
- function buildOneLiner(clusters) {
11485
- if (clusters.length === 0) return "";
11486
- const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
11487
- const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11488
- return [head, ...tally].join(" \xB7 ");
11489
- }
11490
- function buildFailureTally(analysis) {
11491
- if (!analysis) return "";
11492
- const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
11493
- return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
11494
- }
11495
- function dominantCategory(clusters) {
11496
- return clusters[0]?.category ?? "unknown";
11439
+ if (opts.evidence) {
11440
+ const evidence = extractEvidence(input.attachments);
11441
+ if (evidence) capture.evidence = evidence;
11442
+ }
11443
+ if (Object.keys(capture).length === 0) return void 0;
11444
+ capture.source = source;
11445
+ return capture;
11497
11446
  }
11498
- function trendClause(passRate, delta) {
11499
- if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
11500
- const verb = delta > 0 ? "climbed" : "dropped";
11501
- const abs = Math.abs(delta);
11502
- const points = abs === 1 ? "point" : "points";
11503
- return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
11447
+ function chunkToString(chunk) {
11448
+ return typeof chunk === "string" ? chunk : chunk.toString("utf8");
11504
11449
  }
11505
- function causesClause(analysis) {
11506
- const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
11507
- const verb = analysis.clusters.length === 1 ? "remains" : "remain";
11508
- return `${head} ${verb}: ${tallyParts.join(", ")}.`;
11450
+ function decodeShardChunk(c) {
11451
+ return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11509
11452
  }
11510
- function buildBriefSentence(stats, analysis, trend) {
11511
- if (trend && trend.delta !== null) {
11512
- const trendSentence = trendClause(stats.passRate, trend.delta);
11513
- return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
11514
- }
11515
- return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
11453
+ function executionFromResult(result, opts) {
11454
+ return buildExecution(
11455
+ {
11456
+ steps: result.steps,
11457
+ stdout: result.stdout.map(chunkToString),
11458
+ stderr: result.stderr.map(chunkToString),
11459
+ attachments: result.attachments
11460
+ },
11461
+ opts,
11462
+ "reporter"
11463
+ );
11516
11464
  }
11517
11465
 
11518
- // src/analysis/UnclassifiedCollector.ts
11466
+ // src/collector/SuiteWalker.ts
11519
11467
  init_esm_shims();
11520
- import fs6 from "fs";
11521
- import os7 from "os";
11522
- import path7 from "path";
11523
11468
 
11524
- // src/analysis/feedback-csv.ts
11469
+ // src/collector/stats-utils.ts
11525
11470
  init_esm_shims();
11526
- var FEEDBACK_HEADER = "category,margin,label,text";
11527
- var LEGACY_FEEDBACK_HEADER = "category,margin,text";
11528
- function parseFeedbackRows(content) {
11529
- const lines = content.trim().split("\n").filter(Boolean);
11530
- if (lines.length === 0) return [];
11531
- const header = lines[0].trim();
11532
- const isV2 = header === FEEDBACK_HEADER;
11533
- if (!isV2 && header !== LEGACY_FEEDBACK_HEADER) return [];
11534
- const minParts = isV2 ? 4 : 3;
11535
- const rows = [];
11536
- for (const line of lines.slice(1)) {
11537
- const parts = line.split(",");
11538
- if (parts.length < minParts) continue;
11539
- const row = isV2 ? { category: parts[0], margin: parts[1], label: parts[2], text: parts.slice(3).join(",") } : { category: parts[0], margin: parts[1], label: "", text: parts.slice(2).join(",") };
11540
- if (row.text.trim().length === 0) continue;
11541
- rows.push(row);
11542
- }
11543
- return rows;
11471
+ var SUITE_CHART_MAX_ROWS = 150;
11472
+ function resolveTestStatus(test, result) {
11473
+ if (!result) return "skipped";
11474
+ const expected = test.expectedStatus ?? "passed";
11475
+ const actual = result.status;
11476
+ if (actual === "skipped") return "skipped";
11477
+ if (actual === "timedOut") return "timedOut";
11478
+ if (actual === "passed" && result.retry > 0) return "flaky";
11479
+ if (actual === "passed" && expected === "passed") return "passed";
11480
+ if (actual === "failed" && expected === "failed") return "passed";
11481
+ return "failed";
11544
11482
  }
11545
- function serializeFeedbackRow(r) {
11546
- const label = r.label.replace(/[,\r\n]/g, "").trim();
11547
- return `${r.category},${r.margin},${label},${r.text}`;
11483
+ function statsFromTests(tests) {
11484
+ return {
11485
+ total: tests.length,
11486
+ passed: tests.filter((t) => t.status === "passed").length,
11487
+ failed: tests.filter((t) => t.status === "failed").length,
11488
+ timedOut: tests.filter((t) => t.status === "timedOut").length,
11489
+ skipped: tests.filter((t) => t.status === "skipped").length,
11490
+ flaky: tests.filter((t) => t.status === "flaky").length
11491
+ };
11548
11492
  }
11549
- function feedbackIdentity(category, text) {
11550
- return `${category} ${text}`;
11493
+ function aggregateStats(parts) {
11494
+ return parts.reduce(
11495
+ (acc, s) => ({
11496
+ total: acc.total + s.total,
11497
+ passed: acc.passed + s.passed,
11498
+ failed: acc.failed + s.failed,
11499
+ timedOut: acc.timedOut + s.timedOut,
11500
+ skipped: acc.skipped + s.skipped,
11501
+ flaky: acc.flaky + s.flaky
11502
+ }),
11503
+ { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11504
+ );
11551
11505
  }
11552
-
11553
- // src/analysis/FeedbackStore.ts
11554
- init_esm_shims();
11555
- import fs5 from "fs";
11556
- import os6 from "os";
11557
- import path6 from "path";
11558
- var MARKER_FILENAME = "project.json";
11559
- function writeProjectMarker(projectDir, marker) {
11560
- try {
11561
- atomicWrite(path6.join(projectDir, MARKER_FILENAME), JSON.stringify(marker));
11562
- } catch {
11563
- }
11506
+ function toRow(s) {
11507
+ return {
11508
+ label: s.title,
11509
+ passed: s.stats.passed,
11510
+ failed: s.stats.failed,
11511
+ timedOut: s.stats.timedOut,
11512
+ skipped: s.stats.skipped
11513
+ };
11564
11514
  }
11565
- function rewriteFeedbackFile(file, rows) {
11566
- const body = rows.map(serializeFeedbackRow).join("\n");
11567
- atomicWrite(file, `${FEEDBACK_HEADER}
11568
- ${body}${body ? "\n" : ""}`);
11515
+ function capRows(rows, limit) {
11516
+ if (rows.length <= limit) return { rows };
11517
+ const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11518
+ const scored = [...rows].sort(
11519
+ (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11520
+ );
11521
+ const rest = scored.slice(limit);
11522
+ const sums = rest.reduce(
11523
+ (acc, r) => ({
11524
+ passed: acc.passed + r.passed,
11525
+ failed: acc.failed + r.failed,
11526
+ timedOut: acc.timedOut + r.timedOut,
11527
+ skipped: acc.skipped + r.skipped
11528
+ }),
11529
+ { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11530
+ );
11531
+ const parts = [
11532
+ sums.failed > 0 ? `${sums.failed} failed` : "",
11533
+ sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11534
+ sums.passed > 0 ? `${sums.passed} passed` : "",
11535
+ sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11536
+ ].filter(Boolean);
11537
+ const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11538
+ return { rows: scored.slice(0, limit), overflowNote };
11569
11539
  }
11570
- function atomicWrite(file, content) {
11571
- fs5.mkdirSync(path6.dirname(file), { recursive: true });
11572
- const tmp = `${file}.${process.pid}.tmp`;
11573
- fs5.writeFileSync(tmp, content, "utf8");
11574
- fs5.renameSync(tmp, file);
11540
+ function buildSuiteResults(projects, limit) {
11541
+ const top = projects.flatMap((p) => p.suites);
11542
+ if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11543
+ const file = top[0];
11544
+ const fs16 = statsFromTests(file.tests);
11545
+ const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs16.passed, failed: fs16.failed, timedOut: fs16.timedOut, skipped: fs16.skipped }] : [];
11546
+ return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11547
+ }
11548
+ return capRows(top.map(toRow), limit);
11575
11549
  }
11576
11550
 
11577
- // src/analysis/UnclassifiedCollector.ts
11578
- var MAX_ROWS = 500;
11579
- var FILENAME = "unclassified.csv";
11580
- function shouldCollect(c, scope) {
11581
- if (c.source !== "base") return false;
11582
- if (c.category === "unknown" || c.margin <= STRENGTH.collectMaxMargin) return true;
11583
- return scope === "all";
11584
- }
11585
- function tildeify(p) {
11586
- const home = os7.homedir();
11587
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
11588
- }
11589
- function collectUnclassified(classified, projectDir, opts = {}) {
11590
- const scope = opts.scope ?? "blind-spots";
11591
- const rows = classified.filter((c) => shouldCollect(c, scope)).map((c) => ({ text: redactForStorage(c.failure.error.message), category: c.category, margin: c.margin })).filter((r) => r.text.length > 0);
11592
- if (rows.length === 0) return;
11593
- const file = path7.join(projectDir, FILENAME);
11594
- const firstTime = !fs6.existsSync(file);
11595
- const byIdentity = /* @__PURE__ */ new Map();
11596
- if (!firstTime) {
11597
- for (const r of parseFeedbackRows(fs6.readFileSync(file, "utf8"))) {
11598
- byIdentity.set(feedbackIdentity(r.category, r.text), r);
11599
- }
11600
- }
11601
- for (const r of rows) {
11602
- const id = feedbackIdentity(r.category, r.text);
11603
- const label = byIdentity.get(id)?.label ?? "";
11604
- byIdentity.delete(id);
11605
- byIdentity.set(id, { category: r.category, margin: String(r.margin), label, text: r.text });
11606
- }
11607
- const all = evictOldestUnlabeledFirst([...byIdentity.values()], MAX_ROWS);
11608
- fs6.mkdirSync(projectDir, { recursive: true });
11609
- rewriteFeedbackFile(file, all);
11610
- if (opts.project) {
11611
- writeProjectMarker(projectDir, { cwd: opts.project.cwd, outputFile: opts.project.outputFile, updatedAt: Date.now() });
11612
- }
11613
- if (firstTime) {
11614
- logger.info(
11615
- `Unrecognized failures collected locally at ${tildeify(file)} to improve offline classification. Local only - never sent anywhere. Disable: failureAnalysis.collectUnclassified=false`
11551
+ // src/collector/SuiteWalker.ts
11552
+ var SuiteWalker = class {
11553
+ build(rootSuite, resultMap) {
11554
+ return rootSuite.suites.map(
11555
+ (projectSuite) => this.buildProject(projectSuite, resultMap)
11616
11556
  );
11617
11557
  }
11618
- }
11619
- function evictOldestUnlabeledFirst(rows, max) {
11620
- const excess = rows.length - max;
11621
- if (excess <= 0) return rows;
11622
- let unlabeledToDrop = Math.min(excess, rows.filter((r) => r.label === "").length);
11623
- let labeledToDrop = excess - unlabeledToDrop;
11624
- const out = [];
11625
- for (const r of rows) {
11626
- if (r.label === "" && unlabeledToDrop > 0) {
11627
- unlabeledToDrop--;
11628
- continue;
11629
- }
11630
- if (r.label !== "" && labeledToDrop > 0) {
11631
- labeledToDrop--;
11632
- continue;
11633
- }
11634
- out.push(r);
11635
- }
11636
- return out;
11637
- }
11638
-
11639
- // src/utils/paths.ts
11640
- init_esm_shims();
11641
- import os8 from "os";
11642
- import path8 from "path";
11643
- import crypto from "crypto";
11644
- function resolveProjectDir(options, cwd) {
11645
- const projectKey = crypto.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
11646
- return path8.join(os8.homedir(), ".reportforge", projectKey);
11647
- }
11648
-
11649
- // src/analysis/index.ts
11650
- var DEFAULTS = { maxClusters: 10, minStrength: "weak", maxFailuresToAnalyse: 500 };
11651
- var STRENGTH_RANK = Object.fromEntries(
11652
- STRENGTH_LEVELS.map((level, i) => [level, i])
11653
- );
11654
- function analyseClassified(classified, totalFailures, opts = {}) {
11655
- const maxClusters = opts.maxClusters ?? DEFAULTS.maxClusters;
11656
- const minRank = STRENGTH_RANK[opts.minStrength ?? DEFAULTS.minStrength];
11657
- const cap = opts.maxFailuresToAnalyse ?? DEFAULTS.maxFailuresToAnalyse;
11658
- const clusters = clusterClassified(classified).filter((c) => STRENGTH_RANK[c.strength] >= minRank).sort((a, b) => b.count - a.count).slice(0, maxClusters);
11659
- return {
11660
- clusters,
11661
- totalFailures,
11662
- analysedCount: classified.length,
11663
- truncated: totalFailures > cap,
11664
- // dominantCategory is part of the AnalysisResult contract; reserved for
11665
- // consumers (e.g. a future executive badge / notify summary). Not rendered
11666
- // by the v1 partials.
11667
- dominantCategory: dominantCategory(clusters),
11668
- oneLiner: buildOneLiner(clusters)
11669
- };
11670
- }
11671
- function templatesConsumeAnalysis(options) {
11672
- if (options.templatePath && options.templatePath.length > 0) return true;
11673
- const templates = Array.isArray(options.template) ? options.template : [options.template];
11674
- return templates.some((t) => t === "detailed" || t === "executive");
11675
- }
11676
- function runFailureAnalysis(reportData, options, cwd) {
11677
- const fa = options.failureAnalysis;
11678
- if (reportData.stats.failed === 0 || !fa.enabled) return;
11679
- const cap = fa.maxFailuresToAnalyse;
11680
- const analysed = reportData.failures.slice(0, cap);
11681
- const base = loadModel(fa.autoUpdateModel);
11682
- const local = fa.localModel ? loadLocalModel(fa.localModelPath) ?? void 0 : void 0;
11683
- if (local) {
11684
- logger.debug(
11685
- `local model active (trained ${local.meta.trainedAt}, ${local.meta.labeledCount} labeled rows, accept margin >= ${local.acceptThreshold})`
11558
+ buildProject(projectSuite, resultMap) {
11559
+ const suites = projectSuite.suites.map(
11560
+ (s) => this.buildSuite(s, resultMap)
11686
11561
  );
11687
- if (local.meta.baseVersionAtEval !== base.version) {
11688
- logger.debug(
11689
- `local model was gate-checked against base v${local.meta.baseVersionAtEval} but base v${base.version} is active - re-run train-model to re-validate against the current base`
11690
- );
11691
- }
11562
+ const stats = aggregateStats(suites.map((s) => s.stats));
11563
+ return { name: projectSuite.title || "default", suites, stats };
11692
11564
  }
11693
- const classified = classifyAll(analysed, { base, local });
11694
- logger.debug(`failure analysis: ${classified.length} classified - ${sourceTally(classified)}`);
11695
- reportData.classifications = perTestClassifications(classified);
11696
- if (templatesConsumeAnalysis(options)) {
11697
- reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
11565
+ buildSuite(suite, resultMap) {
11566
+ const childSuites = suite.suites.map((s) => this.buildSuite(s, resultMap));
11567
+ const tests = suite.tests.map((t) => this.buildTest(t, resultMap));
11568
+ const stats = aggregateStats([
11569
+ ...childSuites.map((s) => s.stats),
11570
+ statsFromTests(tests)
11571
+ ]);
11572
+ return {
11573
+ title: suite.title,
11574
+ type: suite.type === "describe" ? "describe" : "file",
11575
+ location: suite.location ? `${suite.location.file}:${suite.location.line}` : void 0,
11576
+ suites: childSuites,
11577
+ tests,
11578
+ stats
11579
+ };
11698
11580
  }
11699
- if (fa.collectUnclassified) {
11700
- collectUnclassified(classified, resolveProjectDir(options, cwd), {
11701
- scope: fa.collectScope,
11702
- project: { cwd, outputFile: options.outputFile }
11703
- });
11581
+ buildTest(test, resultMap) {
11582
+ const result = resultMap.get(test.id);
11583
+ const status = resolveTestStatus(test, result);
11584
+ return {
11585
+ id: test.id,
11586
+ title: test.title,
11587
+ fullTitle: test.titlePath().join(" > "),
11588
+ status,
11589
+ duration: result?.duration ?? 0,
11590
+ retryCount: result?.retry ?? 0,
11591
+ tags: test.tags ?? [],
11592
+ annotations: test.annotations ?? [],
11593
+ location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11594
+ };
11704
11595
  }
11705
- }
11706
- function sourceTally(classified) {
11707
- const counts = /* @__PURE__ */ new Map();
11708
- for (const c of classified) counts.set(c.source, (counts.get(c.source) ?? 0) + 1);
11709
- return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
11710
- }
11711
-
11712
- // src/collector/DataCollector.ts
11713
- init_esm_shims();
11596
+ };
11714
11597
 
11715
- // src/utils/strip-ansi.ts
11598
+ // src/collector/severity.ts
11716
11599
  init_esm_shims();
11717
- var ANSI_PATTERN = new RegExp(
11718
- [
11719
- "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
11720
- "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
11721
- ].join("|"),
11722
- "g"
11723
- );
11724
- function stripAnsi(input) {
11725
- return input.replace(ANSI_PATTERN, "");
11600
+ var SEVERITY_RANK = {
11601
+ critical: 0,
11602
+ high: 1,
11603
+ medium: 2,
11604
+ low: 3
11605
+ };
11606
+ function sortBySeverity(failures) {
11607
+ return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
11608
+ }
11609
+ function inferSeverity(annotations, tags) {
11610
+ const allLabels = [
11611
+ ...annotations.map((a) => a.type.toLowerCase()),
11612
+ ...tags.map((t) => t.toLowerCase())
11613
+ ];
11614
+ if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
11615
+ if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
11616
+ if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
11617
+ return "medium";
11726
11618
  }
11727
11619
 
11728
- // src/collector/execution-capture.ts
11620
+ // src/utils/env.ts
11729
11621
  init_esm_shims();
11730
- var MAX_TITLE_CHARS = 200;
11731
- var DEFAULT_MAX_STEPS = 50;
11732
- var DEFAULT_MAX_CONSOLE_LINES = 50;
11733
- var isTeardown = (category) => category === "hook" || category === "fixture";
11734
- var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11735
- var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11736
- const c = flat[a].category;
11737
- return c === "pw:api" || c === "expect";
11738
- });
11739
- var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11740
- function flatten(steps) {
11741
- const out = [];
11742
- const walk = (nodes, depth, ancestors) => {
11743
- for (const s of nodes ?? []) {
11744
- const index = out.length;
11745
- out.push({
11746
- raw: s,
11747
- category: s.category ?? "",
11748
- depth,
11749
- hasError: s.error != null,
11750
- ancestors
11751
- });
11752
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11753
- }
11754
- };
11755
- walk(steps ?? [], 0, []);
11756
- return out;
11757
- }
11758
- function materialize(n, isFailing, depth) {
11759
- const loc = n.raw.location;
11760
- const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11761
- return {
11762
- title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11763
- category: n.category,
11764
- durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11765
- depth,
11766
- failed: n.hasError,
11767
- // Marks the single failing leaf so the template renders the error there once —
11768
- // independent of whether a step-level message survives (falls back to the record error).
11769
- failingLeaf: isFailing || void 0,
11770
- errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11771
- location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11772
- };
11773
- }
11774
- function pickFailingIdx(flat) {
11775
- const pick = (allowTeardown) => {
11776
- let idx = -1;
11777
- for (let i = 0; i < flat.length; i++) {
11778
- const f = flat[i];
11779
- if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11780
- if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11781
- }
11782
- return idx;
11783
- };
11784
- const body = pick(false);
11785
- return body >= 0 ? body : pick(true);
11786
- }
11787
- function compactSteps(rawSteps, opts = {}) {
11788
- const flat = flatten(rawSteps);
11789
- if (flat.length === 0) return { steps: [] };
11790
- const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11791
- const failingIdx = pickFailingIdx(flat);
11792
- const keep = /* @__PURE__ */ new Set();
11793
- flat.forEach((f, i) => {
11794
- if (inHookSubtree(flat, f)) return;
11795
- if (f.category === "test.step") keep.add(i);
11796
- else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11797
- else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11798
- });
11799
- if (failingIdx >= 0) {
11800
- keep.add(failingIdx);
11801
- for (const a of flat[failingIdx].ancestors) keep.add(a);
11622
+ import { execSync } from "child_process";
11623
+ import { createHmac as createHmac2 } from "crypto";
11624
+ var COMMIT_HASH_LENGTH = 8;
11625
+ function detectCiEnv() {
11626
+ const env = process.env;
11627
+ if (env.GITHUB_ACTIONS === "true") {
11628
+ const ghServer = env.GITHUB_SERVER_URL;
11629
+ const ghRepo = env.GITHUB_REPOSITORY;
11630
+ const ghRunId = env.GITHUB_RUN_ID;
11631
+ const buildUrl = ghServer && ghRepo && ghRunId ? `${ghServer}/${ghRepo}/actions/runs/${ghRunId}` : "";
11632
+ return {
11633
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || "unknown",
11634
+ commit: env.GITHUB_SHA?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
11635
+ buildUrl,
11636
+ ciProvider: "GitHub Actions"
11637
+ };
11802
11638
  }
11803
- let keepArr = [...keep].sort((a, b) => a - b);
11804
- let omitted = 0;
11805
- if (keepArr.length > maxSteps) {
11806
- omitted = keepArr.length - maxSteps;
11807
- keepArr = keepArr.slice(-maxSteps);
11808
- if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11809
- keepArr = [failingIdx, ...keepArr.slice(1)];
11810
- }
11639
+ if (env.GITLAB_CI === "true") {
11640
+ return {
11641
+ branch: env.CI_COMMIT_REF_NAME || "unknown",
11642
+ commit: env.CI_COMMIT_SHORT_SHA || "unknown",
11643
+ buildUrl: env.CI_JOB_URL || "",
11644
+ ciProvider: "GitLab CI"
11645
+ };
11811
11646
  }
11812
- const cache = /* @__PURE__ */ new Map();
11813
- const mat = (i) => {
11814
- let es = cache.get(i);
11815
- if (!es) {
11816
- es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11817
- cache.set(i, es);
11818
- }
11819
- return es;
11820
- };
11821
- const steps = keepArr.map(mat);
11822
- if (omitted > 0) {
11823
- steps.unshift({
11824
- title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11825
- category: "",
11826
- durationMs: 0,
11827
- depth: 0,
11828
- failed: false
11829
- });
11647
+ if (env.JENKINS_URL) {
11648
+ return {
11649
+ branch: env.GIT_BRANCH?.replace("origin/", "") || env.BRANCH_NAME || "unknown",
11650
+ commit: env.GIT_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
11651
+ buildUrl: env.BUILD_URL || "",
11652
+ ciProvider: "Jenkins"
11653
+ };
11654
+ }
11655
+ if (env.TF_BUILD === "True") {
11656
+ const adoUri = env.SYSTEM_TEAMFOUNDATIONSERVERURI;
11657
+ const adoProject = env.SYSTEM_TEAMPROJECT;
11658
+ const adoBuildId = env.BUILD_BUILDID;
11659
+ const buildUrl = adoUri && adoProject && adoBuildId ? `${adoUri}${adoProject}/_build/results?buildId=${adoBuildId}` : "";
11660
+ return {
11661
+ branch: env.BUILD_SOURCEBRANCH?.replace("refs/heads/", "") || "unknown",
11662
+ commit: env.BUILD_SOURCEVERSION?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
11663
+ buildUrl,
11664
+ ciProvider: "Azure DevOps"
11665
+ };
11666
+ }
11667
+ if (env.CIRCLECI === "true") {
11668
+ return {
11669
+ branch: env.CIRCLE_BRANCH || "unknown",
11670
+ commit: env.CIRCLE_SHA1?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
11671
+ buildUrl: env.CIRCLE_BUILD_URL || "",
11672
+ ciProvider: "CircleCI"
11673
+ };
11674
+ }
11675
+ if (env.BITBUCKET_BUILD_NUMBER) {
11676
+ const workspace = env.BITBUCKET_WORKSPACE;
11677
+ const repoSlug = env.BITBUCKET_REPO_SLUG;
11678
+ const buildNumber = env.BITBUCKET_BUILD_NUMBER;
11679
+ const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
11680
+ return {
11681
+ branch: env.BITBUCKET_BRANCH || "unknown",
11682
+ commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
11683
+ buildUrl,
11684
+ ciProvider: "Bitbucket Pipelines"
11685
+ };
11830
11686
  }
11831
11687
  return {
11832
- steps,
11833
- failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11834
- };
11835
- }
11836
- function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11837
- const tail = (chunks) => {
11838
- if (chunks.length === 0) return { lines: [], truncated: false };
11839
- const all = chunks.join("").split(/\r?\n/);
11840
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
11841
- const lines = all.slice(-maxLines).map(stripAnsi);
11842
- return { lines, truncated: all.length > lines.length };
11843
- };
11844
- const o = tail(stdout);
11845
- const e = tail(stderr);
11846
- if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11847
- return {
11848
- stdout: o.lines,
11849
- stderr: e.lines,
11850
- truncated: o.truncated || e.truncated,
11851
- stdoutTruncated: o.truncated,
11852
- stderrTruncated: e.truncated
11688
+ branch: gitBranch(),
11689
+ commit: gitCommit(),
11690
+ buildUrl: "",
11691
+ ciProvider: "local"
11853
11692
  };
11854
11693
  }
11855
- function extractEvidence(attachments) {
11856
- let tracePath;
11857
- let videoPath;
11858
- for (const a of attachments) {
11859
- if (!a.path) continue;
11860
- const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11861
- if (!tracePath && looksLikeTrace) tracePath = a.path;
11862
- if (!videoPath && a.contentType?.startsWith("video/")) videoPath = a.path;
11694
+ var CI_RUN_SOURCES = [
11695
+ { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
11696
+ { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
11697
+ { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
11698
+ { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
11699
+ { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
11700
+ { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
11701
+ { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
11702
+ ];
11703
+ var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
11704
+ function detectCiRunId() {
11705
+ for (const src of CI_RUN_SOURCES) {
11706
+ const runId = firstEnv(src.runKeys);
11707
+ if (!runId) continue;
11708
+ const repo = firstEnv(src.repoKeys) ?? "unknown";
11709
+ const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
11710
+ const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
11711
+ return createHmac2("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
11863
11712
  }
11864
- if (!tracePath && !videoPath) return void 0;
11865
- return { tracePath, videoPath };
11713
+ return null;
11866
11714
  }
11867
- function buildExecution(input, opts, source) {
11868
- const capture = {};
11869
- if (opts.steps) {
11870
- const { steps, failingStep } = compactSteps(input.steps, {
11871
- maxSteps: opts.maxSteps,
11872
- apiSteps: opts.apiSteps
11873
- });
11874
- if (steps.length > 0) capture.steps = steps;
11875
- if (failingStep) capture.failingStep = failingStep;
11876
- }
11877
- if (opts.console) {
11878
- const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11879
- if (console2) capture.console = console2;
11880
- }
11881
- if (opts.evidence) {
11882
- const evidence = extractEvidence(input.attachments);
11883
- if (evidence) capture.evidence = evidence;
11715
+ function firstEnv(keys) {
11716
+ for (const k of keys) {
11717
+ const v = process.env[k]?.trim();
11718
+ if (v) return v;
11884
11719
  }
11885
- if (Object.keys(capture).length === 0) return void 0;
11886
- capture.source = source;
11887
- return capture;
11888
- }
11889
- function chunkToString(chunk) {
11890
- return typeof chunk === "string" ? chunk : chunk.toString("utf8");
11720
+ return void 0;
11891
11721
  }
11892
- function decodeShardChunk(c) {
11893
- return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11722
+ function gitBranch() {
11723
+ try {
11724
+ return execSync("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
11725
+ } catch {
11726
+ return "unknown";
11727
+ }
11894
11728
  }
11895
- function executionFromResult(result, opts) {
11896
- return buildExecution(
11897
- {
11898
- steps: result.steps,
11899
- stdout: result.stdout.map(chunkToString),
11900
- stderr: result.stderr.map(chunkToString),
11901
- attachments: result.attachments
11902
- },
11903
- opts,
11904
- "reporter"
11905
- );
11729
+ function gitCommit() {
11730
+ try {
11731
+ return execSync("git rev-parse --short HEAD", { stdio: "pipe" }).toString().trim();
11732
+ } catch {
11733
+ return "unknown";
11734
+ }
11906
11735
  }
11907
11736
 
11908
- // src/collector/SuiteWalker.ts
11909
- init_esm_shims();
11910
-
11911
- // src/collector/stats-utils.ts
11912
- init_esm_shims();
11913
- var SUITE_CHART_MAX_ROWS = 150;
11914
- function resolveTestStatus(test, result) {
11915
- if (!result) return "skipped";
11916
- const expected = test.expectedStatus ?? "passed";
11917
- const actual = result.status;
11918
- if (actual === "skipped") return "skipped";
11919
- if (actual === "timedOut") return "timedOut";
11920
- if (actual === "passed" && result.retry > 0) return "flaky";
11921
- if (actual === "passed" && expected === "passed") return "passed";
11922
- if (actual === "failed" && expected === "failed") return "passed";
11923
- return "failed";
11924
- }
11925
- function statsFromTests(tests) {
11926
- return {
11927
- total: tests.length,
11928
- passed: tests.filter((t) => t.status === "passed").length,
11929
- failed: tests.filter((t) => t.status === "failed").length,
11930
- timedOut: tests.filter((t) => t.status === "timedOut").length,
11931
- skipped: tests.filter((t) => t.status === "skipped").length,
11932
- flaky: tests.filter((t) => t.status === "flaky").length
11933
- };
11934
- }
11935
- function aggregateStats(parts) {
11936
- return parts.reduce(
11937
- (acc, s) => ({
11938
- total: acc.total + s.total,
11939
- passed: acc.passed + s.passed,
11940
- failed: acc.failed + s.failed,
11941
- timedOut: acc.timedOut + s.timedOut,
11942
- skipped: acc.skipped + s.skipped,
11943
- flaky: acc.flaky + s.flaky
11944
- }),
11945
- { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11946
- );
11947
- }
11948
- function toRow(s) {
11949
- return {
11950
- label: s.title,
11951
- passed: s.stats.passed,
11952
- failed: s.stats.failed,
11953
- timedOut: s.stats.timedOut,
11954
- skipped: s.stats.skipped
11955
- };
11956
- }
11957
- function capRows(rows, limit) {
11958
- if (rows.length <= limit) return { rows };
11959
- const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11960
- const scored = [...rows].sort(
11961
- (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11962
- );
11963
- const rest = scored.slice(limit);
11964
- const sums = rest.reduce(
11965
- (acc, r) => ({
11966
- passed: acc.passed + r.passed,
11967
- failed: acc.failed + r.failed,
11968
- timedOut: acc.timedOut + r.timedOut,
11969
- skipped: acc.skipped + r.skipped
11970
- }),
11971
- { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11972
- );
11973
- const parts = [
11974
- sums.failed > 0 ? `${sums.failed} failed` : "",
11975
- sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11976
- sums.passed > 0 ? `${sums.passed} passed` : "",
11977
- sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11978
- ].filter(Boolean);
11979
- const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11980
- return { rows: scored.slice(0, limit), overflowNote };
11981
- }
11982
- function buildSuiteResults(projects, limit) {
11983
- const top = projects.flatMap((p) => p.suites);
11984
- if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11985
- const file = top[0];
11986
- const fs16 = statsFromTests(file.tests);
11987
- const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs16.passed, failed: fs16.failed, timedOut: fs16.timedOut, skipped: fs16.skipped }] : [];
11988
- return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11989
- }
11990
- return capRows(top.map(toRow), limit);
11991
- }
11992
-
11993
- // src/collector/SuiteWalker.ts
11994
- var SuiteWalker = class {
11995
- build(rootSuite, resultMap) {
11996
- return rootSuite.suites.map(
11997
- (projectSuite) => this.buildProject(projectSuite, resultMap)
11998
- );
11737
+ // src/collector/DataCollector.ts
11738
+ var DataCollector = class {
11739
+ /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
11740
+ constructor(captureOpts = {}) {
11741
+ this.captureOpts = captureOpts;
11742
+ this.resultMap = /* @__PURE__ */ new Map();
11743
+ // Parallel to resultMap — needed so computeStats() (which only sees
11744
+ // TestResult, not TestCase) can reconcile against expectedStatus the same
11745
+ // way resolveTestStatus does for the suite tree and the failure list.
11746
+ this.expectedStatusMap = /* @__PURE__ */ new Map();
11747
+ this.failureRecords = [];
11748
+ this.startTime = Date.now();
11999
11749
  }
12000
- buildProject(projectSuite, resultMap) {
12001
- const suites = projectSuite.suites.map(
12002
- (s) => this.buildSuite(s, resultMap)
12003
- );
12004
- const stats = aggregateStats(suites.map((s) => s.stats));
12005
- return { name: projectSuite.title || "default", suites, stats };
11750
+ onBegin(config, rootSuite) {
11751
+ this.config = config;
11752
+ this.rootSuite = rootSuite;
11753
+ this.startTime = Date.now();
12006
11754
  }
12007
- buildSuite(suite, resultMap) {
12008
- const childSuites = suite.suites.map((s) => this.buildSuite(s, resultMap));
12009
- const tests = suite.tests.map((t) => this.buildTest(t, resultMap));
12010
- const stats = aggregateStats([
12011
- ...childSuites.map((s) => s.stats),
12012
- statsFromTests(tests)
12013
- ]);
12014
- return {
12015
- title: suite.title,
12016
- type: suite.type === "describe" ? "describe" : "file",
12017
- location: suite.location ? `${suite.location.file}:${suite.location.line}` : void 0,
12018
- suites: childSuites,
12019
- tests,
12020
- stats
12021
- };
11755
+ onTestEnd(test, result) {
11756
+ this.resultMap.set(test.id, result);
11757
+ this.expectedStatusMap.set(test.id, test.expectedStatus);
11758
+ const resolved = resolveTestStatus(test, result);
11759
+ const isRealFailure = resolved === "failed" || resolved === "timedOut";
11760
+ if (isRealFailure) {
11761
+ const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11762
+ const record = this.buildFailureRecord(test, result);
11763
+ if (existing >= 0) {
11764
+ this.failureRecords[existing] = record;
11765
+ } else {
11766
+ this.failureRecords.push(record);
11767
+ }
11768
+ } else {
11769
+ const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
11770
+ if (existing >= 0) this.failureRecords.splice(existing, 1);
11771
+ }
12022
11772
  }
12023
- buildTest(test, resultMap) {
12024
- const result = resultMap.get(test.id);
12025
- const status = resolveTestStatus(test, result);
11773
+ /**
11774
+ * Builds the final `ReportData` payload from all accumulated hook data.
11775
+ *
11776
+ * @param fullResult - Playwright's `FullResult` (contains `duration` + `status`).
11777
+ * @returns Structured data for the PDF generator, including projects tree,
11778
+ * aggregated stats, failures, environment info, and chart inputs.
11779
+ */
11780
+ finalize(fullResult) {
11781
+ const walker = new SuiteWalker();
11782
+ const projects = walker.build(this.rootSuite, this.resultMap);
11783
+ const stats = this.computeStats(fullResult);
11784
+ const environment = this.buildEnvironment();
11785
+ const charts = this.buildChartData(stats, projects);
12026
11786
  return {
12027
- id: test.id,
12028
- title: test.title,
12029
- fullTitle: test.titlePath().join(" > "),
12030
- status,
12031
- duration: result?.duration ?? 0,
12032
- retryCount: result?.retry ?? 0,
12033
- tags: test.tags ?? [],
12034
- annotations: test.annotations ?? [],
12035
- location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11787
+ projects,
11788
+ stats,
11789
+ failures: sortBySeverity(this.failureRecords),
11790
+ environment,
11791
+ charts
12036
11792
  };
12037
11793
  }
12038
- };
12039
-
12040
- // src/collector/severity.ts
12041
- init_esm_shims();
12042
- var SEVERITY_RANK = {
12043
- critical: 0,
12044
- high: 1,
12045
- medium: 2,
12046
- low: 3
12047
- };
12048
- function sortBySeverity(failures) {
12049
- return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
12050
- }
12051
- function inferSeverity(annotations, tags) {
12052
- const allLabels = [
12053
- ...annotations.map((a) => a.type.toLowerCase()),
12054
- ...tags.map((t) => t.toLowerCase())
12055
- ];
12056
- if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
12057
- if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
12058
- if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
12059
- return "medium";
12060
- }
12061
-
12062
- // src/utils/env.ts
12063
- init_esm_shims();
12064
- import { execSync } from "child_process";
12065
- import { createHmac as createHmac2 } from "crypto";
12066
- var COMMIT_HASH_LENGTH = 8;
12067
- function detectCiEnv() {
12068
- const env = process.env;
12069
- if (env.GITHUB_ACTIONS === "true") {
12070
- const ghServer = env.GITHUB_SERVER_URL;
12071
- const ghRepo = env.GITHUB_REPOSITORY;
12072
- const ghRunId = env.GITHUB_RUN_ID;
12073
- const buildUrl = ghServer && ghRepo && ghRunId ? `${ghServer}/${ghRepo}/actions/runs/${ghRunId}` : "";
11794
+ buildFailureRecord(test, result) {
11795
+ const error = result.errors[0] ?? { message: "Unknown error" };
11796
+ const screenshotAttachment = result.attachments.find(
11797
+ (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
11798
+ );
11799
+ const suitePath = test.titlePath().slice(0, -1);
11800
+ const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
12074
11801
  return {
12075
- branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || "unknown",
12076
- commit: env.GITHUB_SHA?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12077
- buildUrl,
12078
- ciProvider: "GitHub Actions"
11802
+ testId: test.id,
11803
+ testTitle: test.title,
11804
+ suitePath,
11805
+ error: {
11806
+ message: stripAnsi(error.message ?? String(error)),
11807
+ stack: error.stack ? stripAnsi(error.stack) : void 0
11808
+ },
11809
+ screenshotPath: screenshotAttachment?.path,
11810
+ screenshotBuffer: screenshotAttachment?.body,
11811
+ retryHistory: [
11812
+ {
11813
+ attempt: result.retry,
11814
+ status: result.status,
11815
+ duration: result.duration
11816
+ }
11817
+ ],
11818
+ durationMs: result.duration,
11819
+ severity,
11820
+ execution: executionFromResult(result, this.captureOpts)
12079
11821
  };
12080
11822
  }
12081
- if (env.GITLAB_CI === "true") {
12082
- return {
12083
- branch: env.CI_COMMIT_REF_NAME || "unknown",
12084
- commit: env.CI_COMMIT_SHORT_SHA || "unknown",
12085
- buildUrl: env.CI_JOB_URL || "",
12086
- ciProvider: "GitLab CI"
12087
- };
12088
- }
12089
- if (env.JENKINS_URL) {
12090
- return {
12091
- branch: env.GIT_BRANCH?.replace("origin/", "") || env.BRANCH_NAME || "unknown",
12092
- commit: env.GIT_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12093
- buildUrl: env.BUILD_URL || "",
12094
- ciProvider: "Jenkins"
12095
- };
12096
- }
12097
- if (env.TF_BUILD === "True") {
12098
- const adoUri = env.SYSTEM_TEAMFOUNDATIONSERVERURI;
12099
- const adoProject = env.SYSTEM_TEAMPROJECT;
12100
- const adoBuildId = env.BUILD_BUILDID;
12101
- const buildUrl = adoUri && adoProject && adoBuildId ? `${adoUri}${adoProject}/_build/results?buildId=${adoBuildId}` : "";
12102
- return {
12103
- branch: env.BUILD_SOURCEBRANCH?.replace("refs/heads/", "") || "unknown",
12104
- commit: env.BUILD_SOURCEVERSION?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12105
- buildUrl,
12106
- ciProvider: "Azure DevOps"
12107
- };
12108
- }
12109
- if (env.CIRCLECI === "true") {
12110
- return {
12111
- branch: env.CIRCLE_BRANCH || "unknown",
12112
- commit: env.CIRCLE_SHA1?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12113
- buildUrl: env.CIRCLE_BUILD_URL || "",
12114
- ciProvider: "CircleCI"
12115
- };
12116
- }
12117
- if (env.BITBUCKET_BUILD_NUMBER) {
12118
- const workspace = env.BITBUCKET_WORKSPACE;
12119
- const repoSlug = env.BITBUCKET_REPO_SLUG;
12120
- const buildNumber = env.BITBUCKET_BUILD_NUMBER;
12121
- const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
12122
- return {
12123
- branch: env.BITBUCKET_BRANCH || "unknown",
12124
- commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12125
- buildUrl,
12126
- ciProvider: "Bitbucket Pipelines"
12127
- };
12128
- }
12129
- return {
12130
- branch: gitBranch(),
12131
- commit: gitCommit(),
12132
- buildUrl: "",
12133
- ciProvider: "local"
12134
- };
12135
- }
12136
- var CI_RUN_SOURCES = [
12137
- { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
12138
- { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
12139
- { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
12140
- { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
12141
- { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
12142
- { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
12143
- { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
12144
- ];
12145
- var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
12146
- function detectCiRunId() {
12147
- for (const src of CI_RUN_SOURCES) {
12148
- const runId = firstEnv(src.runKeys);
12149
- if (!runId) continue;
12150
- const repo = firstEnv(src.repoKeys) ?? "unknown";
12151
- const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
12152
- const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
12153
- return createHmac2("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
12154
- }
12155
- return null;
12156
- }
12157
- function firstEnv(keys) {
12158
- for (const k of keys) {
12159
- const v = process.env[k]?.trim();
12160
- if (v) return v;
12161
- }
12162
- return void 0;
12163
- }
12164
- function gitBranch() {
12165
- try {
12166
- return execSync("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
12167
- } catch {
12168
- return "unknown";
12169
- }
12170
- }
12171
- function gitCommit() {
12172
- try {
12173
- return execSync("git rev-parse --short HEAD", { stdio: "pipe" }).toString().trim();
12174
- } catch {
12175
- return "unknown";
12176
- }
12177
- }
12178
-
12179
- // src/collector/DataCollector.ts
12180
- var DataCollector = class {
12181
- /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
12182
- constructor(captureOpts = {}) {
12183
- this.captureOpts = captureOpts;
12184
- this.resultMap = /* @__PURE__ */ new Map();
12185
- // Parallel to resultMap — needed so computeStats() (which only sees
12186
- // TestResult, not TestCase) can reconcile against expectedStatus the same
12187
- // way resolveTestStatus does for the suite tree and the failure list.
12188
- this.expectedStatusMap = /* @__PURE__ */ new Map();
12189
- this.failureRecords = [];
12190
- this.startTime = Date.now();
12191
- }
12192
- onBegin(config, rootSuite) {
12193
- this.config = config;
12194
- this.rootSuite = rootSuite;
12195
- this.startTime = Date.now();
12196
- }
12197
- onTestEnd(test, result) {
12198
- this.resultMap.set(test.id, result);
12199
- this.expectedStatusMap.set(test.id, test.expectedStatus);
12200
- const resolved = resolveTestStatus(test, result);
12201
- const isRealFailure = resolved === "failed" || resolved === "timedOut";
12202
- if (isRealFailure) {
12203
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12204
- const record = this.buildFailureRecord(test, result);
12205
- if (existing >= 0) {
12206
- this.failureRecords[existing] = record;
12207
- } else {
12208
- this.failureRecords.push(record);
12209
- }
12210
- } else {
12211
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12212
- if (existing >= 0) this.failureRecords.splice(existing, 1);
12213
- }
12214
- }
12215
- /**
12216
- * Builds the final `ReportData` payload from all accumulated hook data.
12217
- *
12218
- * @param fullResult - Playwright's `FullResult` (contains `duration` + `status`).
12219
- * @returns Structured data for the PDF generator, including projects tree,
12220
- * aggregated stats, failures, environment info, and chart inputs.
12221
- */
12222
- finalize(fullResult) {
12223
- const walker = new SuiteWalker();
12224
- const projects = walker.build(this.rootSuite, this.resultMap);
12225
- const stats = this.computeStats(fullResult);
12226
- const environment = this.buildEnvironment();
12227
- const charts = this.buildChartData(stats, projects);
12228
- return {
12229
- projects,
12230
- stats,
12231
- failures: sortBySeverity(this.failureRecords),
12232
- environment,
12233
- charts
12234
- };
12235
- }
12236
- buildFailureRecord(test, result) {
12237
- const error = result.errors[0] ?? { message: "Unknown error" };
12238
- const screenshotAttachment = result.attachments.find(
12239
- (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
12240
- );
12241
- const suitePath = test.titlePath().slice(0, -1);
12242
- const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
12243
- return {
12244
- testId: test.id,
12245
- testTitle: test.title,
12246
- suitePath,
12247
- error: {
12248
- message: stripAnsi(error.message ?? String(error)),
12249
- stack: error.stack ? stripAnsi(error.stack) : void 0
12250
- },
12251
- screenshotPath: screenshotAttachment?.path,
12252
- screenshotBuffer: screenshotAttachment?.body,
12253
- retryHistory: [
12254
- {
12255
- attempt: result.retry,
12256
- status: result.status,
12257
- duration: result.duration
12258
- }
12259
- ],
12260
- durationMs: result.duration,
12261
- severity,
12262
- execution: executionFromResult(result, this.captureOpts)
12263
- };
12264
- }
12265
- computeStats(fullResult) {
12266
- let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
12267
- for (const [testId, result] of this.resultMap) {
12268
- total++;
12269
- const expectedStatus = this.expectedStatusMap.get(testId);
12270
- const resolved = resolveTestStatus({ expectedStatus }, result);
12271
- switch (resolved) {
12272
- case "passed":
12273
- passed++;
12274
- break;
12275
- case "flaky":
12276
- flaky++;
12277
- break;
12278
- case "failed":
12279
- failed++;
12280
- break;
12281
- case "timedOut":
12282
- timedOut++;
12283
- break;
12284
- // counted separately, not in failed
12285
- case "skipped":
12286
- skipped++;
12287
- break;
12288
- }
12289
- }
12290
- const duration = fullResult.duration ?? Date.now() - this.startTime;
12291
- const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
11823
+ computeStats(fullResult) {
11824
+ let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
11825
+ for (const [testId, result] of this.resultMap) {
11826
+ total++;
11827
+ const expectedStatus = this.expectedStatusMap.get(testId);
11828
+ const resolved = resolveTestStatus({ expectedStatus }, result);
11829
+ switch (resolved) {
11830
+ case "passed":
11831
+ passed++;
11832
+ break;
11833
+ case "flaky":
11834
+ flaky++;
11835
+ break;
11836
+ case "failed":
11837
+ failed++;
11838
+ break;
11839
+ case "timedOut":
11840
+ timedOut++;
11841
+ break;
11842
+ // counted separately, not in failed
11843
+ case "skipped":
11844
+ skipped++;
11845
+ break;
11846
+ }
11847
+ }
11848
+ const duration = fullResult.duration ?? Date.now() - this.startTime;
11849
+ const passRate = total > 0 ? Math.round((passed + flaky) / total * 100) : 0;
12292
11850
  return {
12293
11851
  total,
12294
11852
  passed,
@@ -12575,7 +12133,9 @@ var ShardMerger = class {
12575
12133
  {
12576
12134
  // Guard each field — a partial / non-conformant / older shard JSON can
12577
12135
  // omit steps or the stdout/stderr arrays; an unguarded .map/iterate would
12578
- // throw and (via the merge catch) silently skip the entire PDF.
12136
+ // throw, and merge errors follow the caller's policy (the reporter
12137
+ // catches and silently skips the PDF; the standalone engine treats
12138
+ // them as a hard error).
12579
12139
  steps: lastResult.steps ?? [],
12580
12140
  stdout: (lastResult.stdout ?? []).map(decodeShardChunk),
12581
12141
  stderr: (lastResult.stderr ?? []).map(decodeShardChunk),
@@ -12591,13 +12151,16 @@ var ShardMerger = class {
12591
12151
  const browsers = [
12592
12152
  ...new Set(report.config.projects.map((p) => p.use?.browserName ?? p.name).filter(Boolean))
12593
12153
  ];
12594
- let playwrightVersion = "unknown";
12595
- try {
12596
- const req = createRequire(import.meta.url);
12597
- const pwPkg = req("@playwright/test/package.json");
12598
- playwrightVersion = pwPkg.version;
12599
- } catch {
12154
+ let playwrightVersion = report.config.metadata?.playwrightVersion;
12155
+ if (!playwrightVersion) {
12156
+ try {
12157
+ const req = createRequire(import.meta.url);
12158
+ const pwPkg = req("@playwright/test/package.json");
12159
+ playwrightVersion = pwPkg.version;
12160
+ } catch {
12161
+ }
12600
12162
  }
12163
+ if (!playwrightVersion) playwrightVersion = "unknown";
12601
12164
  return {
12602
12165
  branch: ci.branch,
12603
12166
  commit: ci.commit,
@@ -12608,7 +12171,7 @@ var ShardMerger = class {
12608
12171
  nodeVersion: process.version,
12609
12172
  playwrightVersion,
12610
12173
  projectCount: report.config.projects.length,
12611
- workerCount: 1
12174
+ workerCount: report.config.workers ?? 1
12612
12175
  };
12613
12176
  }
12614
12177
  buildCharts(stats, projects) {
@@ -12617,8 +12180,9 @@ var ShardMerger = class {
12617
12180
  passRate: {
12618
12181
  passed: stats.passed,
12619
12182
  failed: stats.failed,
12183
+ // Recovered from per-result status by mergeStats (aggregate shard
12184
+ // stats fold timeouts into `unexpected`; the tree pulls them back out).
12620
12185
  timedOut: stats.timedOut,
12621
- // always 0 in shard JSON (no separate timedOut count)
12622
12186
  skipped: stats.skipped,
12623
12187
  flaky: stats.flaky
12624
12188
  },
@@ -12630,14 +12194,14 @@ var ShardMerger = class {
12630
12194
 
12631
12195
  // src/pdf/PdfGenerator.ts
12632
12196
  init_esm_shims();
12633
- import fs14 from "fs";
12634
- import path13 from "path";
12197
+ import fs11 from "fs";
12198
+ import path9 from "path";
12635
12199
 
12636
12200
  // src/templates/engine.ts
12637
12201
  init_esm_shims();
12638
12202
  var import_handlebars = __toESM(require_lib());
12639
- import fs7 from "fs";
12640
- import path9 from "path";
12203
+ import fs4 from "fs";
12204
+ import path5 from "path";
12641
12205
 
12642
12206
  // src/utils/duration.ts
12643
12207
  init_esm_shims();
@@ -12651,11 +12215,74 @@ function formatDuration(ms) {
12651
12215
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
12652
12216
  }
12653
12217
 
12654
- // src/templates/engine.ts
12655
- function jsonForInlineScript(value) {
12656
- return JSON.stringify(value).replace(/<\//g, "<\\/");
12218
+ // src/analysis/SummaryWriter.ts
12219
+ init_esm_shims();
12220
+ var CATEGORY_LABEL = {
12221
+ assertion: "Assertion failure",
12222
+ timeout: "Timeout",
12223
+ "locator-not-found": "Element not found",
12224
+ network: "Network error",
12225
+ navigation: "Navigation error",
12226
+ "env-config": "Environment / config",
12227
+ "flaky-by-retry": "Flaky (passed on retry)",
12228
+ unknown: "Unclassified"
12229
+ };
12230
+ var SHORT_LABEL = {
12231
+ assertion: "assertion",
12232
+ timeout: "timeout",
12233
+ "locator-not-found": "element not found",
12234
+ network: "network",
12235
+ navigation: "navigation",
12236
+ "env-config": "env/config",
12237
+ "flaky-by-retry": "flaky",
12238
+ unknown: "unclassified"
12239
+ };
12240
+ function tallyEntries(clusters) {
12241
+ const byCategory = /* @__PURE__ */ new Map();
12242
+ for (const c of clusters) {
12243
+ byCategory.set(c.category, (byCategory.get(c.category) ?? 0) + c.count);
12244
+ }
12245
+ return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
12657
12246
  }
12658
- var A4_HEIGHT_MM = 297;
12247
+ function buildOneLiner(clusters) {
12248
+ if (clusters.length === 0) return "";
12249
+ const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
12250
+ const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
12251
+ return [head, ...tally].join(" \xB7 ");
12252
+ }
12253
+ function buildFailureTally(analysis) {
12254
+ if (!analysis) return "";
12255
+ const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
12256
+ return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
12257
+ }
12258
+ function dominantCategory(clusters) {
12259
+ return clusters[0]?.category ?? "unknown";
12260
+ }
12261
+ function trendClause(passRate, delta) {
12262
+ if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
12263
+ const verb = delta > 0 ? "climbed" : "dropped";
12264
+ const abs = Math.abs(delta);
12265
+ const points = abs === 1 ? "point" : "points";
12266
+ return `Pass rate ${verb} ${abs} ${points} to ${passRate}% since the last run.`;
12267
+ }
12268
+ function causesClause(analysis) {
12269
+ const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
12270
+ const verb = analysis.clusters.length === 1 ? "remains" : "remain";
12271
+ return `${head} ${verb}: ${tallyParts.join(", ")}.`;
12272
+ }
12273
+ function buildBriefSentence(stats, analysis, trend) {
12274
+ if (trend && trend.delta !== null) {
12275
+ const trendSentence = trendClause(stats.passRate, trend.delta);
12276
+ return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
12277
+ }
12278
+ return analysis && analysis.clusters.length > 0 ? causesClause(analysis) : void 0;
12279
+ }
12280
+
12281
+ // src/templates/engine.ts
12282
+ function jsonForInlineScript(value) {
12283
+ return JSON.stringify(value).replace(/<\//g, "<\\/");
12284
+ }
12285
+ var A4_HEIGHT_MM = 297;
12659
12286
  var PAGE_MARGIN_Y_MM = 16;
12660
12287
  var PX_PER_MM = 96 / 25.4;
12661
12288
  var SUITE_CHART_SMALL_MAX = 10;
@@ -12698,9 +12325,9 @@ var TemplateEngine = class {
12698
12325
  this.customTemplateCache = /* @__PURE__ */ new Map();
12699
12326
  this.styleCache = /* @__PURE__ */ new Map();
12700
12327
  this.handlebars = import_handlebars.default.create();
12701
- this.templatesDir = path9.resolve(__dirname, "templates");
12702
- if (!fs7.existsSync(this.templatesDir)) {
12703
- this.templatesDir = path9.resolve(__dirname, "../templates");
12328
+ this.templatesDir = path5.resolve(__dirname, "templates");
12329
+ if (!fs4.existsSync(this.templatesDir)) {
12330
+ this.templatesDir = path5.resolve(__dirname, "../templates");
12704
12331
  }
12705
12332
  this.registerHelpers();
12706
12333
  this.registerPartials();
@@ -12717,11 +12344,11 @@ var TemplateEngine = class {
12717
12344
  this.chartjsInline = inline;
12718
12345
  }
12719
12346
  render(data, template, opts = {}) {
12720
- const layoutPath = path9.join(this.templatesDir, "layouts", `${template}.hbs`);
12721
- if (!fs7.existsSync(layoutPath)) {
12347
+ const layoutPath = path5.join(this.templatesDir, "layouts", `${template}.hbs`);
12348
+ if (!fs4.existsSync(layoutPath)) {
12722
12349
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12723
12350
  }
12724
- const layoutSource = fs7.readFileSync(layoutPath, "utf8");
12351
+ const layoutSource = fs4.readFileSync(layoutPath, "utf8");
12725
12352
  const compiledLayout = this.handlebars.compile(layoutSource);
12726
12353
  const ctx = this.assembleContext(
12727
12354
  data,
@@ -12733,10 +12360,10 @@ var TemplateEngine = class {
12733
12360
  }
12734
12361
  renderFromPath(data, filePath, opts = {}) {
12735
12362
  if (!this.customTemplateCache.has(filePath)) {
12736
- if (!fs7.existsSync(filePath)) {
12363
+ if (!fs4.existsSync(filePath)) {
12737
12364
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
12738
12365
  }
12739
- const source = fs7.readFileSync(filePath, "utf8");
12366
+ const source = fs4.readFileSync(filePath, "utf8");
12740
12367
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12741
12368
  }
12742
12369
  const compiledFn = this.customTemplateCache.get(filePath);
@@ -12858,18 +12485,18 @@ var TemplateEngine = class {
12858
12485
  readStyle(filename) {
12859
12486
  const cached = this.styleCache.get(filename);
12860
12487
  if (cached !== void 0) return cached;
12861
- const stylePath = path9.join(this.templatesDir, "styles", filename);
12862
- const css = fs7.existsSync(stylePath) ? fs7.readFileSync(stylePath, "utf8") : "";
12488
+ const stylePath = path5.join(this.templatesDir, "styles", filename);
12489
+ const css = fs4.existsSync(stylePath) ? fs4.readFileSync(stylePath, "utf8") : "";
12863
12490
  this.styleCache.set(filename, css);
12864
12491
  return css;
12865
12492
  }
12866
12493
  registerPartials() {
12867
- const partialsDir = path9.join(this.templatesDir, "partials");
12868
- if (!fs7.existsSync(partialsDir)) return;
12869
- for (const file of fs7.readdirSync(partialsDir)) {
12494
+ const partialsDir = path5.join(this.templatesDir, "partials");
12495
+ if (!fs4.existsSync(partialsDir)) return;
12496
+ for (const file of fs4.readdirSync(partialsDir)) {
12870
12497
  if (!file.endsWith(".hbs")) continue;
12871
- const name = path9.basename(file, ".hbs");
12872
- const src = fs7.readFileSync(path9.join(partialsDir, file), "utf8");
12498
+ const name = path5.basename(file, ".hbs");
12499
+ const src = fs4.readFileSync(path5.join(partialsDir, file), "utf8");
12873
12500
  this.handlebars.registerPartial(name, src);
12874
12501
  }
12875
12502
  }
@@ -12974,8 +12601,21 @@ var TemplateEngine = class {
12974
12601
 
12975
12602
  // src/screenshots/ScreenshotEmbedder.ts
12976
12603
  init_esm_shims();
12977
- import fs8 from "fs";
12978
- import sharp from "sharp";
12604
+ import fs5 from "fs";
12605
+ var sharpModule;
12606
+ function loadSharp() {
12607
+ if (sharpModule === void 0) {
12608
+ try {
12609
+ sharpModule = __require("sharp");
12610
+ } catch {
12611
+ sharpModule = null;
12612
+ logger.warn(
12613
+ "sharp is not available \u2014 screenshots are embedded at original size. Reports with many screenshots may exceed maxFileSizeMb more often."
12614
+ );
12615
+ }
12616
+ }
12617
+ return sharpModule;
12618
+ }
12979
12619
  var ScreenshotEmbedder = class {
12980
12620
  async embedAll(failures, opts) {
12981
12621
  await Promise.all(failures.map((f) => this.embed(f, opts)));
@@ -12983,7 +12623,7 @@ var ScreenshotEmbedder = class {
12983
12623
  async embed(failure, opts) {
12984
12624
  if (!failure.screenshotPath && !failure.screenshotBuffer) return;
12985
12625
  try {
12986
- const raw = failure.screenshotBuffer ?? await fs8.promises.readFile(failure.screenshotPath);
12626
+ const raw = failure.screenshotBuffer ?? await fs5.promises.readFile(failure.screenshotPath);
12987
12627
  const { buffer, mime } = opts.reencode ? await this.recompress(raw, opts) : { buffer: raw, mime: this.detectMimeType(raw) };
12988
12628
  failure.screenshotBase64 = `data:${mime};base64,${buffer.toString("base64")}`;
12989
12629
  } catch (e) {
@@ -12995,7 +12635,7 @@ var ScreenshotEmbedder = class {
12995
12635
  }
12996
12636
  async embedLogoFile(logoPath) {
12997
12637
  try {
12998
- const buffer = await fs8.promises.readFile(logoPath);
12638
+ const buffer = await fs5.promises.readFile(logoPath);
12999
12639
  const ext = logoPath.split(".").pop()?.toLowerCase();
13000
12640
  let mime = "image/png";
13001
12641
  if (ext === "jpg" || ext === "jpeg") mime = "image/jpeg";
@@ -13011,6 +12651,10 @@ var ScreenshotEmbedder = class {
13011
12651
  if (this.looksLikeSvg(buf)) {
13012
12652
  return { buffer: buf, mime: "image/svg+xml" };
13013
12653
  }
12654
+ const sharp = loadSharp();
12655
+ if (!sharp) {
12656
+ return { buffer: buf, mime: this.detectMimeType(buf) };
12657
+ }
13014
12658
  const img = sharp(buf, { failOn: "none" });
13015
12659
  const meta = await img.metadata();
13016
12660
  const needsResize = (meta.width ?? 0) > opts.maxWidth;
@@ -13040,7 +12684,7 @@ var ScreenshotEmbedder = class {
13040
12684
  // src/pdf/BrowserManager.ts
13041
12685
  init_esm_shims();
13042
12686
  import { execSync as execSync2 } from "child_process";
13043
- import fs9 from "fs";
12687
+ import fs6 from "fs";
13044
12688
  var LAUNCH_ARGS = [
13045
12689
  "--no-sandbox",
13046
12690
  "--disable-setuid-sandbox",
@@ -13072,13 +12716,13 @@ var BrowserManager = class {
13072
12716
  }
13073
12717
  }
13074
12718
  resolveChromePath(explicit, puppeteer) {
13075
- if (explicit && fs9.existsSync(explicit)) return explicit;
12719
+ if (explicit && fs6.existsSync(explicit)) return explicit;
13076
12720
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
13077
- if (envPath && fs9.existsSync(envPath)) return envPath;
12721
+ if (envPath && fs6.existsSync(envPath)) return envPath;
13078
12722
  try {
13079
12723
  const chromeFinder = require_lib2();
13080
12724
  const found = chromeFinder();
13081
- if (found && fs9.existsSync(found)) return found;
12725
+ if (found && fs6.existsSync(found)) return found;
13082
12726
  } catch (err) {
13083
12727
  logger.debug("chrome discovery: chrome-finder failed", { error: err.message });
13084
12728
  }
@@ -13090,20 +12734,20 @@ var BrowserManager = class {
13090
12734
  "/snap/bin/chromium"
13091
12735
  ];
13092
12736
  for (const p of linuxPaths) {
13093
- if (fs9.existsSync(p)) return p;
12737
+ if (fs6.existsSync(p)) return p;
13094
12738
  }
13095
12739
  try {
13096
12740
  const found = execSync2("which google-chrome chromium chromium-browser 2>/dev/null", {
13097
12741
  stdio: "pipe"
13098
12742
  }).toString().trim().split("\n")[0];
13099
- if (found && fs9.existsSync(found)) return found;
12743
+ if (found && fs6.existsSync(found)) return found;
13100
12744
  } catch {
13101
12745
  logger.debug("chrome discovery: which lookup found nothing");
13102
12746
  }
13103
12747
  if (puppeteer.executablePath) {
13104
12748
  try {
13105
12749
  const bundled = puppeteer.executablePath();
13106
- if (bundled && fs9.existsSync(bundled)) return bundled;
12750
+ if (bundled && fs6.existsSync(bundled)) return bundled;
13107
12751
  } catch (err) {
13108
12752
  logger.debug("chrome discovery: no puppeteer bundled executable", { error: err.message });
13109
12753
  }
@@ -13117,22 +12761,22 @@ var BrowserManager = class {
13117
12761
 
13118
12762
  // src/pdf/HtmlWriter.ts
13119
12763
  init_esm_shims();
13120
- import fs10 from "fs";
13121
- import os9 from "os";
13122
- import path10 from "path";
13123
- import crypto2 from "crypto";
12764
+ import fs7 from "fs";
12765
+ import os5 from "os";
12766
+ import path6 from "path";
12767
+ import crypto from "crypto";
13124
12768
  var HtmlWriter = class {
13125
12769
  async write(html) {
13126
- const tmpDir = os9.tmpdir();
13127
- const filename = `rf-report-${crypto2.randomBytes(8).toString("hex")}.html`;
13128
- this.tempPath = path10.join(tmpDir, filename);
13129
- await fs10.promises.writeFile(this.tempPath, html, "utf8");
12770
+ const tmpDir = os5.tmpdir();
12771
+ const filename = `rf-report-${crypto.randomBytes(8).toString("hex")}.html`;
12772
+ this.tempPath = path6.join(tmpDir, filename);
12773
+ await fs7.promises.writeFile(this.tempPath, html, "utf8");
13130
12774
  return this.tempPath;
13131
12775
  }
13132
12776
  async cleanup() {
13133
12777
  if (this.tempPath) {
13134
12778
  const tempPath = this.tempPath;
13135
- await fs10.promises.unlink(tempPath).catch(
12779
+ await fs7.promises.unlink(tempPath).catch(
13136
12780
  (err) => logger.debug("temp HTML cleanup failed (ignored)", { path: tempPath, error: err.message })
13137
12781
  );
13138
12782
  this.tempPath = void 0;
@@ -13166,14 +12810,14 @@ var ChartRenderer = class {
13166
12810
 
13167
12811
  // src/pdf/PdfEncryptor.ts
13168
12812
  init_esm_shims();
13169
- import fs11 from "fs";
12813
+ import fs8 from "fs";
13170
12814
  import { PDFDocument, PDFHeader } from "@cantoo/pdf-lib";
13171
12815
  var AES256_HEADER_MINOR = "7ext3";
13172
12816
  var PdfEncryptor = class {
13173
12817
  async encrypt(pdfPath, password) {
13174
12818
  if (!password) return;
13175
12819
  try {
13176
- const doc = await PDFDocument.load(await fs11.promises.readFile(pdfPath));
12820
+ const doc = await PDFDocument.load(await fs8.promises.readFile(pdfPath));
13177
12821
  doc.context.header = PDFHeader.forVersion(1, AES256_HEADER_MINOR);
13178
12822
  doc.encrypt({
13179
12823
  userPassword: password,
@@ -13183,10 +12827,10 @@ var PdfEncryptor = class {
13183
12827
  const encryptedBytes = await doc.save();
13184
12828
  const tmpPath = `${pdfPath}.rf-enc-tmp`;
13185
12829
  try {
13186
- await fs11.promises.writeFile(tmpPath, encryptedBytes);
13187
- await fs11.promises.rename(tmpPath, pdfPath);
12830
+ await fs8.promises.writeFile(tmpPath, encryptedBytes);
12831
+ await fs8.promises.rename(tmpPath, pdfPath);
13188
12832
  } finally {
13189
- fs11.rmSync(tmpPath, { force: true });
12833
+ fs8.rmSync(tmpPath, { force: true });
13190
12834
  }
13191
12835
  logger.info("PDF encrypted with password (AES-256).");
13192
12836
  } catch (e) {
@@ -13197,8 +12841,8 @@ var PdfEncryptor = class {
13197
12841
 
13198
12842
  // src/filename/FilenameResolver.ts
13199
12843
  init_esm_shims();
13200
- import path11 from "path";
13201
- import fs12 from "fs";
12844
+ import path7 from "path";
12845
+ import fs9 from "fs";
13202
12846
  var MAX_BRANCH_LENGTH = 50;
13203
12847
  var FilenameResolver = class {
13204
12848
  /**
@@ -13215,8 +12859,8 @@ var FilenameResolver = class {
13215
12859
  const branch = this.sanitiseBranch(ctx.branch);
13216
12860
  const status = ctx.status;
13217
12861
  const resolved = template.replace(/\{date\}/g, date).replace(/\{branch\}/g, branch).replace(/\{status\}/g, status);
13218
- const absolute = path11.isAbsolute(resolved) ? resolved : path11.resolve(process.cwd(), resolved);
13219
- fs12.mkdirSync(path11.dirname(absolute), { recursive: true });
12862
+ const absolute = path7.isAbsolute(resolved) ? resolved : path7.resolve(process.cwd(), resolved);
12863
+ fs9.mkdirSync(path7.dirname(absolute), { recursive: true });
13220
12864
  return absolute;
13221
12865
  }
13222
12866
  /**
@@ -13228,7 +12872,7 @@ var FilenameResolver = class {
13228
12872
  * // → '/out/2026-report-executive.pdf'
13229
12873
  */
13230
12874
  injectTemplateSuffix(outputPath, suffix) {
13231
- const ext = path11.extname(outputPath);
12875
+ const ext = path7.extname(outputPath);
13232
12876
  const base = outputPath.slice(0, outputPath.length - ext.length);
13233
12877
  return `${base}-${suffix}${ext}`;
13234
12878
  }
@@ -13286,12 +12930,12 @@ var PRESETS = {
13286
12930
 
13287
12931
  // src/pdf/FailurePaginator.ts
13288
12932
  init_esm_shims();
13289
- import fs13 from "fs";
13290
- import path12 from "path";
12933
+ import fs10 from "fs";
12934
+ import path8 from "path";
13291
12935
  function sidecarPathFor(pdfPath) {
13292
- const dir = path12.dirname(pdfPath);
13293
- const base = path12.basename(pdfPath, path12.extname(pdfPath));
13294
- return path12.join(dir, `${base}-failures.json`);
12936
+ const dir = path8.dirname(pdfPath);
12937
+ const base = path8.basename(pdfPath, path8.extname(pdfPath));
12938
+ return path8.join(dir, `${base}-failures.json`);
13295
12939
  }
13296
12940
  var FailurePaginator = class {
13297
12941
  async paginate(failures, maxInline, pdfOutputPath) {
@@ -13302,13 +12946,13 @@ var FailurePaginator = class {
13302
12946
  const overflow = failures.slice(maxInline);
13303
12947
  const sidecarPath = sidecarPathFor(pdfOutputPath);
13304
12948
  try {
13305
- await fs13.promises.writeFile(
12949
+ await fs10.promises.writeFile(
13306
12950
  sidecarPath,
13307
12951
  JSON.stringify(this.serialize(overflow), null, 2),
13308
12952
  "utf8"
13309
12953
  );
13310
12954
  logger.info(
13311
- `Emitted ${overflow.length} overflow failures \u2192 ${path12.basename(sidecarPath)}`
12955
+ `Emitted ${overflow.length} overflow failures \u2192 ${path8.basename(sidecarPath)}`
13312
12956
  );
13313
12957
  } catch (e) {
13314
12958
  logger.warn(`Could not write failure sidecar: ${e.message}`);
@@ -13332,15 +12976,15 @@ var FailurePaginator = class {
13332
12976
 
13333
12977
  // src/pdf/sidecar-crypto.ts
13334
12978
  init_esm_shims();
13335
- import crypto3 from "crypto";
12979
+ import crypto2 from "crypto";
13336
12980
  var MAGIC = Buffer.from("RFENC1");
13337
12981
  var SALT_LEN = 16;
13338
12982
  var IV_LEN = 12;
13339
12983
  function encryptSidecar(plain, password) {
13340
- const salt = crypto3.randomBytes(SALT_LEN);
13341
- const key = crypto3.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
13342
- const iv = crypto3.randomBytes(IV_LEN);
13343
- const cipher = crypto3.createCipheriv("aes-256-gcm", key, iv);
12984
+ const salt = crypto2.randomBytes(SALT_LEN);
12985
+ const key = crypto2.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
12986
+ const iv = crypto2.randomBytes(IV_LEN);
12987
+ const cipher = crypto2.createCipheriv("aes-256-gcm", key, iv);
13344
12988
  const enc = Buffer.concat([cipher.update(plain), cipher.final()]);
13345
12989
  return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), enc]);
13346
12990
  }
@@ -13408,7 +13052,7 @@ var PdfGenerator = class {
13408
13052
  const sources = this.resolveTemplateSources(options);
13409
13053
  const isMulti = sources.length > 1;
13410
13054
  for (const source of sources) {
13411
- if (source.kind === "custom" && !fs14.existsSync(source.absolutePath)) {
13055
+ if (source.kind === "custom" && !fs11.existsSync(source.absolutePath)) {
13412
13056
  throw new Error(
13413
13057
  `[reportforge] Custom template not found: ${source.absolutePath}`
13414
13058
  );
@@ -13445,7 +13089,7 @@ var PdfGenerator = class {
13445
13089
  let { sidecarPath } = await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
13446
13090
  const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
13447
13091
  if (capBytes > 0) {
13448
- const sizeBytes = (await fs14.promises.stat(outputPath)).size;
13092
+ const sizeBytes = (await fs11.promises.stat(outputPath)).size;
13449
13093
  if (sizeBytes > capBytes) {
13450
13094
  const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13451
13095
  const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
@@ -13454,7 +13098,7 @@ var PdfGenerator = class {
13454
13098
  );
13455
13099
  const retuned = await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
13456
13100
  sidecarPath = retuned.sidecarPath ?? sidecarPath;
13457
- const finalSize = (await fs14.promises.stat(outputPath)).size;
13101
+ const finalSize = (await fs11.promises.stat(outputPath)).size;
13458
13102
  if (finalSize > capBytes) {
13459
13103
  logger.warn(
13460
13104
  `PDF still ${fmtMb(finalSize)} after retune. Consider lowering maxInlineFailures or splitting the report.`
@@ -13469,16 +13113,16 @@ var PdfGenerator = class {
13469
13113
  try {
13470
13114
  await this.pdfEncryptor.encrypt(outputPath, options.pdfPassword);
13471
13115
  pdfEncrypted = true;
13472
- if (sidecarPath && fs14.existsSync(sidecarPath)) {
13473
- const blob = encryptSidecar(await fs14.promises.readFile(sidecarPath), options.pdfPassword);
13474
- await fs14.promises.writeFile(`${sidecarPath}.enc`, blob);
13475
- await fs14.promises.unlink(sidecarPath);
13476
- logger.info(`Overflow sidecar encrypted \u2192 ${path13.basename(sidecarPath)}.enc`);
13116
+ if (sidecarPath && fs11.existsSync(sidecarPath)) {
13117
+ const blob = encryptSidecar(await fs11.promises.readFile(sidecarPath), options.pdfPassword);
13118
+ await fs11.promises.writeFile(`${sidecarPath}.enc`, blob);
13119
+ await fs11.promises.unlink(sidecarPath);
13120
+ logger.info(`Overflow sidecar encrypted \u2192 ${path9.basename(sidecarPath)}.enc`);
13477
13121
  } else {
13478
13122
  const staleSidecarPath = sidecarPathFor(outputPath);
13479
- if (fs14.existsSync(staleSidecarPath)) {
13480
- fs14.rmSync(staleSidecarPath, { force: true });
13481
- logger.info(`Removed stale plaintext sidecar: ${path13.basename(staleSidecarPath)}`);
13123
+ if (fs11.existsSync(staleSidecarPath)) {
13124
+ fs11.rmSync(staleSidecarPath, { force: true });
13125
+ logger.info(`Removed stale plaintext sidecar: ${path9.basename(staleSidecarPath)}`);
13482
13126
  }
13483
13127
  }
13484
13128
  } catch (err) {
@@ -13486,7 +13130,7 @@ var PdfGenerator = class {
13486
13130
  const cleanupTargets = pdfEncrypted ? sidecarTargets : [outputPath, ...sidecarTargets];
13487
13131
  for (const target of cleanupTargets) {
13488
13132
  try {
13489
- fs14.rmSync(target, { force: true });
13133
+ fs11.rmSync(target, { force: true });
13490
13134
  } catch (cleanupErr) {
13491
13135
  logger.warn(
13492
13136
  `Could not remove leftover file after encryption failure: ${target} (${cleanupErr.message})`
@@ -13496,7 +13140,7 @@ var PdfGenerator = class {
13496
13140
  throw err;
13497
13141
  }
13498
13142
  }
13499
- const finalSizeMb = fmtMb((await fs14.promises.stat(outputPath)).size);
13143
+ const finalSizeMb = fmtMb((await fs11.promises.stat(outputPath)).size);
13500
13144
  logger.info(`PDF report generated: ${outputPath} (${finalSizeMb})`);
13501
13145
  outputPaths.push(outputPath);
13502
13146
  }
@@ -13505,181 +13149,592 @@ var PdfGenerator = class {
13505
13149
  }
13506
13150
  return outputPaths;
13507
13151
  }
13508
- resolveTemplateSources(options) {
13509
- if (options.templatePath && options.templatePath.length > 0) {
13510
- const byAbsolutePath = /* @__PURE__ */ new Map();
13511
- for (const p of options.templatePath) {
13512
- const absolutePath = path13.isAbsolute(p) ? p : path13.resolve(process.cwd(), p);
13513
- if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13514
- }
13515
- const labelCounts = /* @__PURE__ */ new Map();
13516
- for (const rawPath of byAbsolutePath.values()) {
13517
- const label = path13.basename(rawPath, ".hbs");
13518
- labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13519
- }
13520
- const seenPerLabel = /* @__PURE__ */ new Map();
13521
- return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13522
- const baseLabel = path13.basename(rawPath, ".hbs");
13523
- let label = baseLabel;
13524
- if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13525
- const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13526
- seenPerLabel.set(baseLabel, n);
13527
- label = `${baseLabel}-${n}`;
13528
- logger.warn(
13529
- `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13530
- );
13531
- }
13532
- return { kind: "custom", absolutePath, label };
13533
- });
13152
+ resolveTemplateSources(options) {
13153
+ if (options.templatePath && options.templatePath.length > 0) {
13154
+ const byAbsolutePath = /* @__PURE__ */ new Map();
13155
+ for (const p of options.templatePath) {
13156
+ const absolutePath = path9.isAbsolute(p) ? p : path9.resolve(process.cwd(), p);
13157
+ if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13158
+ }
13159
+ const labelCounts = /* @__PURE__ */ new Map();
13160
+ for (const rawPath of byAbsolutePath.values()) {
13161
+ const label = path9.basename(rawPath, ".hbs");
13162
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13163
+ }
13164
+ const seenPerLabel = /* @__PURE__ */ new Map();
13165
+ return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13166
+ const baseLabel = path9.basename(rawPath, ".hbs");
13167
+ let label = baseLabel;
13168
+ if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13169
+ const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
13170
+ seenPerLabel.set(baseLabel, n);
13171
+ label = `${baseLabel}-${n}`;
13172
+ logger.warn(
13173
+ `Multiple templatePath entries resolve to the basename "${baseLabel}" \u2014 disambiguating output filenames with a numeric suffix ("${label}").`
13174
+ );
13175
+ }
13176
+ return { kind: "custom", absolutePath, label };
13177
+ });
13178
+ }
13179
+ const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13180
+ return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
13181
+ }
13182
+ resolveSettings(options, data) {
13183
+ return resolveCompression({
13184
+ level: options.compressionLevel ?? "auto",
13185
+ testCount: data.stats.total,
13186
+ failureCount: data.failures.length,
13187
+ inlineFailuresOverride: options.maxInlineFailures
13188
+ });
13189
+ }
13190
+ /**
13191
+ * Builds retune settings sized against the cap using *measured* bytes from
13192
+ * the previous pass. The per-failure cost in a PDF includes more than just
13193
+ * the JPEG payload — page layout, font glyph rendering, and per-page chrome
13194
+ * all scale with failure count. Compute observed bytes per inline failure,
13195
+ * then pick an inline count that fits the cap with a 15% safety margin.
13196
+ * Also tightens JPEG quality and width to claw back bytes from each
13197
+ * remaining image.
13198
+ */
13199
+ tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13200
+ const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13201
+ const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13202
+ return {
13203
+ effective: "max",
13204
+ reencode: true,
13205
+ quality: 55,
13206
+ maxWidth: 800,
13207
+ maxInlineFailures: Math.min(prev.maxInlineFailures, targetInline)
13208
+ };
13209
+ }
13210
+ /**
13211
+ * One full render pass: paginate failures, re-embed screenshots at the
13212
+ * target compression, render HTML, PDF the page, write to outputPath.
13213
+ * Separated from generateAll() so the size-cap retune can reuse it with
13214
+ * stricter settings without reshaping the rest of the flow.
13215
+ */
13216
+ async renderPdf(data, options, source, compression, outputPath, page) {
13217
+ const paginated = await this.failurePaginator.paginate(
13218
+ data.failures,
13219
+ compression.maxInlineFailures,
13220
+ outputPath
13221
+ );
13222
+ const renderData = {
13223
+ ...data,
13224
+ failures: paginated.inline,
13225
+ overflowFailureCount: paginated.overflowCount,
13226
+ overflowSidecarName: paginated.sidecarPath ? path9.basename(paginated.sidecarPath) + (options.pdfPassword ? ".enc" : "") : void 0
13227
+ };
13228
+ for (const f of renderData.failures) f.screenshotBase64 = void 0;
13229
+ if (options.includeScreenshots !== false) {
13230
+ await this.screenshotEmbedder.embedAll(renderData.failures, {
13231
+ reencode: compression.reencode,
13232
+ quality: compression.quality,
13233
+ maxWidth: compression.maxWidth
13234
+ });
13235
+ }
13236
+ const templateLabel = source.kind === "builtin" ? source.id : source.label;
13237
+ logger.info(`Rendering template: ${templateLabel}`);
13238
+ const renderOpts = {
13239
+ sections: options.sections,
13240
+ slowTestThreshold: options.slowTestThreshold,
13241
+ requirementTagPattern: options.requirementTagPattern
13242
+ };
13243
+ const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, renderOpts) : this.templateEngine.renderFromPath(renderData, source.absolutePath, renderOpts);
13244
+ const tempHtmlPath = await this.htmlWriter.write(html);
13245
+ const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
13246
+ try {
13247
+ logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
13248
+ await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13249
+ const showCharts = source.kind === "builtin" ? resolveSections(source.id, options.sections).charts : true;
13250
+ await this.chartRenderer.waitForCharts(page, showCharts);
13251
+ logger.info(`Generating PDF \u2192 ${path9.relative(process.cwd(), outputPath)}`);
13252
+ await page.pdf({
13253
+ path: outputPath,
13254
+ format: "A4",
13255
+ printBackground: true,
13256
+ preferCSSPageSize: true
13257
+ });
13258
+ } finally {
13259
+ await this.htmlWriter.cleanup();
13260
+ }
13261
+ return { sidecarPath: paginated.overflowCount > 0 ? paginated.sidecarPath : void 0 };
13262
+ }
13263
+ };
13264
+ function fmtMb(bytes) {
13265
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
13266
+ }
13267
+
13268
+ // src/redact/Redactor.ts
13269
+ init_esm_shims();
13270
+ var Redactor = class {
13271
+ constructor(opts) {
13272
+ /** Indexes of user patterns disabled after throwing (warned once each). */
13273
+ this.disabled = /* @__PURE__ */ new Set();
13274
+ this.maskRaw = opts.mask;
13275
+ this.maskReplacement = opts.mask.replace(/\$/g, "$$$$");
13276
+ this.useBuiltins = opts.builtins;
13277
+ this.userPatterns = opts.patterns.map((p) => new RegExp(p, "g"));
13278
+ }
13279
+ redactText(s) {
13280
+ if (!s) return s;
13281
+ let out = s;
13282
+ if (this.useBuiltins) out = this.applyBuiltins(out);
13283
+ for (let i = 0; i < this.userPatterns.length; i++) {
13284
+ if (this.disabled.has(i)) continue;
13285
+ try {
13286
+ out = out.replace(this.userPatterns[i], this.maskReplacement);
13287
+ } catch (err) {
13288
+ this.disabled.add(i);
13289
+ logger.warn(
13290
+ `redact: custom pattern #${i + 1} threw and is disabled for this run: ${err.message}`
13291
+ );
13292
+ }
13293
+ }
13294
+ return out;
13295
+ }
13296
+ applyBuiltins(s) {
13297
+ const M = this.maskRaw;
13298
+ const MR = this.maskReplacement;
13299
+ return s.replace(/(\b[a-z][\w+.-]{0,63}:\/\/[^/\s:@]{1,512}:)([^@\s/]{1,512})(?=@)/gi, `$1${MR}`).replace(
13300
+ /\b(?:(authorization\s*[:=]\s*basic\s+)([A-Za-z0-9._~+/=-]{8,})|((?:authorization\s*[:=]\s*)?bearer\s+)([A-Za-z0-9._~+/=-]{8,}))/gi,
13301
+ (fullMatch, basicPrefix, _basicToken, bearerPrefix, bearerToken) => {
13302
+ if (basicPrefix) return `${basicPrefix}${M}`;
13303
+ if (bearerToken && /[^a-z]/.test(bearerToken)) return `${bearerPrefix}${M}`;
13304
+ return fullMatch;
13305
+ }
13306
+ ).replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, MR).replace(
13307
+ /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9_-]{16,}|rzp_(?:live|test)_[A-Za-z0-9]{8,}|AIza[A-Za-z0-9_-]{30,})\b/g,
13308
+ MR
13309
+ ).replace(
13310
+ /\b((?:[A-Za-z0-9]{1,32}[_-]){0,8}(?:password|passwd|passcode|pwd|secret|token|api[_-]?key|apikey|auth|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret))\b(["']?\s*[=:]\s*)(?!(?:bearer|basic)\b)(?:(["'])((?:(?!\3).){1,512})\3|([^\s"',;&]{1,512}))/gi,
13311
+ (_m, key, sep, q, _quotedVal, _plainVal) => q ? `${key}${sep}${q}${M}${q}` : `${key}${sep}${M}`
13312
+ ).replace(
13313
+ /\b(fill|type|press)\b([^\n]{0,80}?\b(?:password|secret|token|pass|passcode|pin|otp)\b[^\n]{0,80}?\s)(["'])((?:(?!\3).)+)\3/gi,
13314
+ (_m, action, mid, q) => `${action}${mid}${q}${M}${q}`
13315
+ ).replace(/\b([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{0,63}@([A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,})\b/g, "$1***@$2").replace(/[A-Za-z0-9_+/=-]{20,}/g, (m, offset, str) => {
13316
+ const before = offset > 0 ? str[offset - 1] : " ";
13317
+ const after = offset + m.length < str.length ? str[offset + m.length] : " ";
13318
+ const beforeIsWord = /[A-Za-z0-9_]/.test(before);
13319
+ const afterIsWord = /[A-Za-z0-9_]/.test(after);
13320
+ if (!beforeIsWord && !afterIsWord && /\d/.test(m) && /[A-Za-z]/.test(m)) {
13321
+ return M;
13322
+ }
13323
+ return m;
13324
+ });
13325
+ }
13326
+ };
13327
+
13328
+ // src/pipeline/orchestrator.ts
13329
+ init_esm_shims();
13330
+ import path15 from "path";
13331
+
13332
+ // src/license/update-notice.ts
13333
+ init_esm_shims();
13334
+
13335
+ // src/license/version.ts
13336
+ init_esm_shims();
13337
+ function parseTriple(v) {
13338
+ if (!v) return null;
13339
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
13340
+ if (!m) return null;
13341
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
13342
+ }
13343
+ function isNewer(latest, current) {
13344
+ const l = parseTriple(latest);
13345
+ const c = parseTriple(current);
13346
+ if (!l || !c) return false;
13347
+ if (l[0] !== c[0]) return l[0] > c[0];
13348
+ if (l[1] !== c[1]) return l[1] > c[1];
13349
+ return l[2] > c[2];
13350
+ }
13351
+
13352
+ // src/license/update-notice.ts
13353
+ var NPM_UPDATE_HINT = "npm i -D @reportforge/playwright-pdf@latest";
13354
+ function maybePrintUpdateNotice(licenseInfo, currentVersion, updateHint = NPM_UPDATE_HINT) {
13355
+ try {
13356
+ const latest = licenseInfo?.latestVersion;
13357
+ if (!latest || !isNewer(latest, currentVersion)) return;
13358
+ const shown = latest.replace(/[^\w.+-]/g, "");
13359
+ logger.info(
13360
+ `Update available: ${currentVersion} -> ${shown}. Run: ${updateHint}`
13361
+ );
13362
+ } catch (err) {
13363
+ logger.debug("update notice failed (ignored)", { error: err.message });
13364
+ }
13365
+ }
13366
+
13367
+ // src/analysis/index.ts
13368
+ init_esm_shims();
13369
+
13370
+ // src/analysis/LocalModelStore.ts
13371
+ init_esm_shims();
13372
+ import fs12 from "fs";
13373
+ import os6 from "os";
13374
+ import path10 from "path";
13375
+ var testDeps2 = null;
13376
+ function defaultLocalModelPath() {
13377
+ return testDeps2?.file ?? path10.join(os6.homedir(), ".reportforge", "model-local.json");
13378
+ }
13379
+ function loadLocalModel(overridePath) {
13380
+ const file = overridePath ?? defaultLocalModelPath();
13381
+ const explicit = overridePath !== void 0;
13382
+ const reject = (why) => {
13383
+ const msg = `local model at ${file} not used: ${why}`;
13384
+ if (explicit) logger.warn(msg);
13385
+ else logger.debug(msg);
13386
+ return null;
13387
+ };
13388
+ let parsed;
13389
+ try {
13390
+ parsed = JSON.parse(fs12.readFileSync(file, "utf8"));
13391
+ } catch (e) {
13392
+ return explicit ? reject(`unreadable (${e.message})`) : null;
13393
+ }
13394
+ if (parsed.formatVersion !== 1) return reject(`unsupported formatVersion ${String(parsed.formatVersion)}`);
13395
+ if (!isValidNaiveBayesModel(parsed.model)) return reject("embedded model failed schema validation");
13396
+ const meta = parsed.meta;
13397
+ if (!meta || typeof meta.gatePassed !== "boolean") return reject("missing meta");
13398
+ if (!meta.gatePassed) return reject("its evaluation gate did not pass - run train-model after labeling more feedback");
13399
+ if (typeof meta.acceptThreshold !== "number") return reject("gate passed but no calibrated threshold (corrupt meta)");
13400
+ return {
13401
+ model: parsed.model,
13402
+ acceptThreshold: meta.acceptThreshold,
13403
+ strongThreshold: typeof meta.strongThreshold === "number" ? meta.strongThreshold : null,
13404
+ meta
13405
+ };
13406
+ }
13407
+
13408
+ // src/analysis/FailureClusterer.ts
13409
+ init_esm_shims();
13410
+
13411
+ // src/analysis/redact.ts
13412
+ init_esm_shims();
13413
+ var URI_RE = /[a-z][\w+.-]*:\/\/\S+/gi;
13414
+ var EMAIL_RE = /\b[\w.+-]+@[\w.-]+\.\w+\b/g;
13415
+ var SECRET_RE = /\b(?=[A-Za-z0-9_+/=-]*[0-9])(?=[A-Za-z0-9_+/=-]*[A-Za-z])[A-Za-z0-9_+/=-]{16,}\b/g;
13416
+ var IP_RE = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
13417
+ var WIN_PATH_RE = /[A-Za-z]:\\[^\s'"]+/g;
13418
+ var UNIX_PATH_RE = /(?:\/[\w.-]+){2,}/g;
13419
+ function redactForStorage(raw) {
13420
+ const redacted = raw.replace(URI_RE, " URL ").replace(EMAIL_RE, " EMAIL ").replace(SECRET_RE, " SECRET ").replace(IP_RE, " IP ").replace(WIN_PATH_RE, " PATH ").replace(UNIX_PATH_RE, " PATH ");
13421
+ return tokenize(redacted).join(" ");
13422
+ }
13423
+ function localInferenceTokens(message) {
13424
+ return redactForStorage(message).split(" ").filter(Boolean);
13425
+ }
13426
+
13427
+ // src/analysis/constants.ts
13428
+ init_esm_shims();
13429
+ var STRENGTH = {
13430
+ unknownMaxMargin: 0.05,
13431
+ collectMaxMargin: 0.1,
13432
+ harvestMinMargin: 0.25,
13433
+ moderateMinMargin: 0.1,
13434
+ strongMinMargin: 0.25
13435
+ };
13436
+ var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
13437
+ var OVERRIDE_MARGIN = 999;
13438
+
13439
+ // src/analysis/FailureClusterer.ts
13440
+ var MAX_TESTS_PER_CLUSTER = 20;
13441
+ var MAX_MESSAGE_CHARS = 200;
13442
+ function errorHeader(message) {
13443
+ const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
13444
+ return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
13445
+ }
13446
+ function isAssertionTimeout(message) {
13447
+ return /(?:Expect "[^"]+"|expect\.\w+) with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
13448
+ }
13449
+ function isNetwork(message) {
13450
+ return /net::ERR_(CONNECTION|NAME_NOT_RESOLVED|INTERNET_DISCONNECTED|SSL|TIMED_OUT|ADDRESS|NETWORK_CHANGED|EMPTY_RESPONSE)|\bECONN(?:REFUSED|RESET)\b|\bENOTFOUND\b|\bEAI_AGAIN\b|getaddrinfo|socket hang up|fetch failed/.test(errorHeader(message));
13451
+ }
13452
+ function isNavigation(message) {
13453
+ return /net::ERR_(?:ABORTED|TOO_MANY_REDIRECTS)|navigation interrupted|interrupted by (?:a|another) navigation|because of a navigation|no history entry|page crashed|Execution context was destroyed|frame was detached/.test(errorHeader(message));
13454
+ }
13455
+ function isLocatorNotFound(message) {
13456
+ return /strict mode violation|resolved to 0 element|element not found|not attached to the DOM|No node found for selector|no element matches|Unable to find an element/i.test(errorHeader(message));
13457
+ }
13458
+ var MESSAGE_RULES = [
13459
+ [isAssertionTimeout, "assertion"],
13460
+ [isNavigation, "navigation"],
13461
+ [isNetwork, "network"],
13462
+ [isLocatorNotFound, "locator-not-found"]
13463
+ ];
13464
+ function strengthOf(margin) {
13465
+ if (margin >= STRENGTH.strongMinMargin) return "strong";
13466
+ if (margin >= STRENGTH.moderateMinMargin) return "moderate";
13467
+ return "weak";
13468
+ }
13469
+ function classifyInput(f) {
13470
+ const head = (f.error.stack ?? "").split("\n").slice(0, 3).join(" ");
13471
+ return `${f.error.message} ${head}`;
13472
+ }
13473
+ function topFrame(stack) {
13474
+ if (!stack) return void 0;
13475
+ for (const line of stack.split("\n")) {
13476
+ const t = line.trim();
13477
+ if (t.startsWith("at ") && !t.replace(/\\/g, "/").includes("node_modules/playwright")) return t;
13478
+ }
13479
+ return void 0;
13480
+ }
13481
+ function representativeMessage(message) {
13482
+ return clampWithEllipsis(message.split("\n")[0], MAX_MESSAGE_CHARS);
13483
+ }
13484
+ function classifyOne(f, chain) {
13485
+ const frame = topFrame(f.error.stack);
13486
+ if (f.retryHistory.some((r) => r.status === "passed")) {
13487
+ return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, strength: "strong", source: "retry", failure: f, frame };
13488
+ }
13489
+ for (const [match, category] of MESSAGE_RULES) {
13490
+ if (match(f.error.message)) {
13491
+ return { category, margin: OVERRIDE_MARGIN, strength: "strong", source: "rule", failure: f, frame };
13492
+ }
13493
+ }
13494
+ if (chain.local) {
13495
+ const { model, acceptThreshold, strongThreshold } = chain.local;
13496
+ const r = classifyTokens(model, localInferenceTokens(f.error.message));
13497
+ if (r.label !== "unknown" && r.margin >= acceptThreshold) {
13498
+ const strength = strongThreshold !== null && r.margin >= strongThreshold ? "strong" : "moderate";
13499
+ return { category: r.label, margin: r.margin, strength, source: "local", failure: f, frame };
13500
+ }
13501
+ }
13502
+ const { label, margin } = classify(chain.base, classifyInput(f));
13503
+ return { category: label, margin, strength: strengthOf(margin), source: "base", failure: f, frame };
13504
+ }
13505
+ function classifyAll(failures, chain) {
13506
+ return failures.map((f) => classifyOne(f, chain));
13507
+ }
13508
+ function perTestClassifications(classified) {
13509
+ return classified.map((c) => ({
13510
+ testId: c.failure.testId,
13511
+ category: c.category,
13512
+ message: representativeMessage(c.failure.error.message),
13513
+ strength: c.strength
13514
+ // resolved at classification time, per producing source
13515
+ }));
13516
+ }
13517
+ function clusterClassified(classified) {
13518
+ const groups = /* @__PURE__ */ new Map();
13519
+ for (const c of classified) {
13520
+ const key = `${c.category}::${c.frame ?? "__no-stack__"}`;
13521
+ const bucket = groups.get(key);
13522
+ if (bucket) bucket.push(c);
13523
+ else groups.set(key, [c]);
13524
+ }
13525
+ const rank = Object.fromEntries(
13526
+ STRENGTH_LEVELS.map((level, i) => [level, i])
13527
+ );
13528
+ const clusters = [];
13529
+ for (const members of groups.values()) {
13530
+ const strongest = members.reduce((a, b) => rank[b.strength] > rank[a.strength] ? b : a);
13531
+ clusters.push({
13532
+ category: members[0].category,
13533
+ strength: strongest.strength,
13534
+ margin: Math.max(...members.map((m) => m.margin)),
13535
+ count: members.length,
13536
+ tests: members.slice(0, MAX_TESTS_PER_CLUSTER).map((m) => m.failure.testTitle),
13537
+ testsTotal: members.length,
13538
+ representativeMessage: representativeMessage(members[0].failure.error.message),
13539
+ stackFrame: members[0].frame
13540
+ });
13541
+ }
13542
+ return clusters;
13543
+ }
13544
+
13545
+ // src/analysis/UnclassifiedCollector.ts
13546
+ init_esm_shims();
13547
+ import fs14 from "fs";
13548
+ import os8 from "os";
13549
+ import path12 from "path";
13550
+
13551
+ // src/analysis/feedback-csv.ts
13552
+ init_esm_shims();
13553
+ var FEEDBACK_HEADER = "category,margin,label,text";
13554
+ var LEGACY_FEEDBACK_HEADER = "category,margin,text";
13555
+ function parseFeedbackRows(content) {
13556
+ const lines = content.trim().split("\n").filter(Boolean);
13557
+ if (lines.length === 0) return [];
13558
+ const header = lines[0].trim();
13559
+ const isV2 = header === FEEDBACK_HEADER;
13560
+ if (!isV2 && header !== LEGACY_FEEDBACK_HEADER) return [];
13561
+ const minParts = isV2 ? 4 : 3;
13562
+ const rows = [];
13563
+ for (const line of lines.slice(1)) {
13564
+ const parts = line.split(",");
13565
+ if (parts.length < minParts) continue;
13566
+ const row = isV2 ? { category: parts[0], margin: parts[1], label: parts[2], text: parts.slice(3).join(",") } : { category: parts[0], margin: parts[1], label: "", text: parts.slice(2).join(",") };
13567
+ if (row.text.trim().length === 0) continue;
13568
+ rows.push(row);
13569
+ }
13570
+ return rows;
13571
+ }
13572
+ function serializeFeedbackRow(r) {
13573
+ const label = r.label.replace(/[,\r\n]/g, "").trim();
13574
+ return `${r.category},${r.margin},${label},${r.text}`;
13575
+ }
13576
+ function feedbackIdentity(category, text) {
13577
+ return `${category} ${text}`;
13578
+ }
13579
+
13580
+ // src/analysis/FeedbackStore.ts
13581
+ init_esm_shims();
13582
+ import fs13 from "fs";
13583
+ import os7 from "os";
13584
+ import path11 from "path";
13585
+ var MARKER_FILENAME = "project.json";
13586
+ function writeProjectMarker(projectDir, marker) {
13587
+ try {
13588
+ atomicWrite(path11.join(projectDir, MARKER_FILENAME), JSON.stringify(marker));
13589
+ } catch {
13590
+ }
13591
+ }
13592
+ function rewriteFeedbackFile(file, rows) {
13593
+ const body = rows.map(serializeFeedbackRow).join("\n");
13594
+ atomicWrite(file, `${FEEDBACK_HEADER}
13595
+ ${body}${body ? "\n" : ""}`);
13596
+ }
13597
+ function atomicWrite(file, content) {
13598
+ fs13.mkdirSync(path11.dirname(file), { recursive: true });
13599
+ const tmp = `${file}.${process.pid}.tmp`;
13600
+ fs13.writeFileSync(tmp, content, "utf8");
13601
+ fs13.renameSync(tmp, file);
13602
+ }
13603
+
13604
+ // src/analysis/UnclassifiedCollector.ts
13605
+ var MAX_ROWS = 500;
13606
+ var FILENAME = "unclassified.csv";
13607
+ function shouldCollect(c, scope) {
13608
+ if (c.source !== "base") return false;
13609
+ if (c.category === "unknown" || c.margin <= STRENGTH.collectMaxMargin) return true;
13610
+ return scope === "all";
13611
+ }
13612
+ function tildeify(p) {
13613
+ const home = os8.homedir();
13614
+ return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
13615
+ }
13616
+ function collectUnclassified(classified, projectDir, opts = {}) {
13617
+ const scope = opts.scope ?? "blind-spots";
13618
+ const rows = classified.filter((c) => shouldCollect(c, scope)).map((c) => ({ text: redactForStorage(c.failure.error.message), category: c.category, margin: c.margin })).filter((r) => r.text.length > 0);
13619
+ if (rows.length === 0) return;
13620
+ const file = path12.join(projectDir, FILENAME);
13621
+ const firstTime = !fs14.existsSync(file);
13622
+ const byIdentity = /* @__PURE__ */ new Map();
13623
+ if (!firstTime) {
13624
+ for (const r of parseFeedbackRows(fs14.readFileSync(file, "utf8"))) {
13625
+ byIdentity.set(feedbackIdentity(r.category, r.text), r);
13534
13626
  }
13535
- const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13536
- return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
13537
13627
  }
13538
- resolveSettings(options, data) {
13539
- return resolveCompression({
13540
- level: options.compressionLevel ?? "auto",
13541
- testCount: data.stats.total,
13542
- failureCount: data.failures.length,
13543
- inlineFailuresOverride: options.maxInlineFailures
13544
- });
13628
+ for (const r of rows) {
13629
+ const id = feedbackIdentity(r.category, r.text);
13630
+ const label = byIdentity.get(id)?.label ?? "";
13631
+ byIdentity.delete(id);
13632
+ byIdentity.set(id, { category: r.category, margin: String(r.margin), label, text: r.text });
13545
13633
  }
13546
- /**
13547
- * Builds retune settings sized against the cap using *measured* bytes from
13548
- * the previous pass. The per-failure cost in a PDF includes more than just
13549
- * the JPEG payload — page layout, font glyph rendering, and per-page chrome
13550
- * all scale with failure count. Compute observed bytes per inline failure,
13551
- * then pick an inline count that fits the cap with a 15% safety margin.
13552
- * Also tightens JPEG quality and width to claw back bytes from each
13553
- * remaining image.
13554
- */
13555
- tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13556
- const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13557
- const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13558
- return {
13559
- effective: "max",
13560
- reencode: true,
13561
- quality: 55,
13562
- maxWidth: 800,
13563
- maxInlineFailures: Math.min(prev.maxInlineFailures, targetInline)
13564
- };
13634
+ const all = evictOldestUnlabeledFirst([...byIdentity.values()], MAX_ROWS);
13635
+ fs14.mkdirSync(projectDir, { recursive: true });
13636
+ rewriteFeedbackFile(file, all);
13637
+ if (opts.project) {
13638
+ writeProjectMarker(projectDir, { cwd: opts.project.cwd, outputFile: opts.project.outputFile, updatedAt: Date.now() });
13565
13639
  }
13566
- /**
13567
- * One full render pass: paginate failures, re-embed screenshots at the
13568
- * target compression, render HTML, PDF the page, write to outputPath.
13569
- * Separated from generateAll() so the size-cap retune can reuse it with
13570
- * stricter settings without reshaping the rest of the flow.
13571
- */
13572
- async renderPdf(data, options, source, compression, outputPath, page) {
13573
- const paginated = await this.failurePaginator.paginate(
13574
- data.failures,
13575
- compression.maxInlineFailures,
13576
- outputPath
13640
+ if (firstTime) {
13641
+ logger.info(
13642
+ `Unrecognized failures collected locally at ${tildeify(file)} to improve offline classification. Local only - never sent anywhere. Disable: failureAnalysis.collectUnclassified=false`
13577
13643
  );
13578
- const renderData = {
13579
- ...data,
13580
- failures: paginated.inline,
13581
- overflowFailureCount: paginated.overflowCount,
13582
- overflowSidecarName: paginated.sidecarPath ? path13.basename(paginated.sidecarPath) + (options.pdfPassword ? ".enc" : "") : void 0
13583
- };
13584
- for (const f of renderData.failures) f.screenshotBase64 = void 0;
13585
- if (options.includeScreenshots !== false) {
13586
- await this.screenshotEmbedder.embedAll(renderData.failures, {
13587
- reencode: compression.reencode,
13588
- quality: compression.quality,
13589
- maxWidth: compression.maxWidth
13590
- });
13644
+ }
13645
+ }
13646
+ function evictOldestUnlabeledFirst(rows, max) {
13647
+ const excess = rows.length - max;
13648
+ if (excess <= 0) return rows;
13649
+ let unlabeledToDrop = Math.min(excess, rows.filter((r) => r.label === "").length);
13650
+ let labeledToDrop = excess - unlabeledToDrop;
13651
+ const out = [];
13652
+ for (const r of rows) {
13653
+ if (r.label === "" && unlabeledToDrop > 0) {
13654
+ unlabeledToDrop--;
13655
+ continue;
13591
13656
  }
13592
- const templateLabel = source.kind === "builtin" ? source.id : source.label;
13593
- logger.info(`Rendering template: ${templateLabel}`);
13594
- const renderOpts = {
13595
- sections: options.sections,
13596
- slowTestThreshold: options.slowTestThreshold,
13597
- requirementTagPattern: options.requirementTagPattern
13598
- };
13599
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, renderOpts) : this.templateEngine.renderFromPath(renderData, source.absolutePath, renderOpts);
13600
- const tempHtmlPath = await this.htmlWriter.write(html);
13601
- const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
13602
- try {
13603
- logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
13604
- await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13605
- const showCharts = source.kind === "builtin" ? resolveSections(source.id, options.sections).charts : true;
13606
- await this.chartRenderer.waitForCharts(page, showCharts);
13607
- logger.info(`Generating PDF \u2192 ${path13.relative(process.cwd(), outputPath)}`);
13608
- await page.pdf({
13609
- path: outputPath,
13610
- format: "A4",
13611
- printBackground: true,
13612
- preferCSSPageSize: true
13613
- });
13614
- } finally {
13615
- await this.htmlWriter.cleanup();
13657
+ if (r.label !== "" && labeledToDrop > 0) {
13658
+ labeledToDrop--;
13659
+ continue;
13616
13660
  }
13617
- return { sidecarPath: paginated.overflowCount > 0 ? paginated.sidecarPath : void 0 };
13661
+ out.push(r);
13618
13662
  }
13619
- };
13620
- function fmtMb(bytes) {
13621
- return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
13663
+ return out;
13622
13664
  }
13623
13665
 
13624
- // src/redact/Redactor.ts
13666
+ // src/utils/paths.ts
13625
13667
  init_esm_shims();
13626
- var Redactor = class {
13627
- constructor(opts) {
13628
- /** Indexes of user patterns disabled after throwing (warned once each). */
13629
- this.disabled = /* @__PURE__ */ new Set();
13630
- this.maskRaw = opts.mask;
13631
- this.maskReplacement = opts.mask.replace(/\$/g, "$$$$");
13632
- this.useBuiltins = opts.builtins;
13633
- this.userPatterns = opts.patterns.map((p) => new RegExp(p, "g"));
13634
- }
13635
- redactText(s) {
13636
- if (!s) return s;
13637
- let out = s;
13638
- if (this.useBuiltins) out = this.applyBuiltins(out);
13639
- for (let i = 0; i < this.userPatterns.length; i++) {
13640
- if (this.disabled.has(i)) continue;
13641
- try {
13642
- out = out.replace(this.userPatterns[i], this.maskReplacement);
13643
- } catch (err) {
13644
- this.disabled.add(i);
13645
- logger.warn(
13646
- `redact: custom pattern #${i + 1} threw and is disabled for this run: ${err.message}`
13647
- );
13648
- }
13668
+ import os9 from "os";
13669
+ import path13 from "path";
13670
+ import crypto3 from "crypto";
13671
+ function resolveProjectDir(options, cwd) {
13672
+ const projectKey = crypto3.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
13673
+ return path13.join(os9.homedir(), ".reportforge", projectKey);
13674
+ }
13675
+
13676
+ // src/analysis/index.ts
13677
+ var DEFAULTS = { maxClusters: 10, minStrength: "weak", maxFailuresToAnalyse: 500 };
13678
+ var STRENGTH_RANK = Object.fromEntries(
13679
+ STRENGTH_LEVELS.map((level, i) => [level, i])
13680
+ );
13681
+ function analyseClassified(classified, totalFailures, opts = {}) {
13682
+ const maxClusters = opts.maxClusters ?? DEFAULTS.maxClusters;
13683
+ const minRank = STRENGTH_RANK[opts.minStrength ?? DEFAULTS.minStrength];
13684
+ const cap = opts.maxFailuresToAnalyse ?? DEFAULTS.maxFailuresToAnalyse;
13685
+ const clusters = clusterClassified(classified).filter((c) => STRENGTH_RANK[c.strength] >= minRank).sort((a, b) => b.count - a.count).slice(0, maxClusters);
13686
+ return {
13687
+ clusters,
13688
+ totalFailures,
13689
+ analysedCount: classified.length,
13690
+ truncated: totalFailures > cap,
13691
+ // dominantCategory is part of the AnalysisResult contract; reserved for
13692
+ // consumers (e.g. a future executive badge / notify summary). Not rendered
13693
+ // by the v1 partials.
13694
+ dominantCategory: dominantCategory(clusters),
13695
+ oneLiner: buildOneLiner(clusters)
13696
+ };
13697
+ }
13698
+ function templatesConsumeAnalysis(options) {
13699
+ if (options.templatePath && options.templatePath.length > 0) return true;
13700
+ const templates = Array.isArray(options.template) ? options.template : [options.template];
13701
+ return templates.some((t) => t === "detailed" || t === "executive");
13702
+ }
13703
+ function runFailureAnalysis(reportData, options, cwd) {
13704
+ const fa = options.failureAnalysis;
13705
+ if (reportData.stats.failed === 0 || !fa.enabled) return;
13706
+ const cap = fa.maxFailuresToAnalyse;
13707
+ const analysed = reportData.failures.slice(0, cap);
13708
+ const base = loadModel(fa.autoUpdateModel);
13709
+ const local = fa.localModel ? loadLocalModel(fa.localModelPath) ?? void 0 : void 0;
13710
+ if (local) {
13711
+ logger.debug(
13712
+ `local model active (trained ${local.meta.trainedAt}, ${local.meta.labeledCount} labeled rows, accept margin >= ${local.acceptThreshold})`
13713
+ );
13714
+ if (local.meta.baseVersionAtEval !== base.version) {
13715
+ logger.debug(
13716
+ `local model was gate-checked against base v${local.meta.baseVersionAtEval} but base v${base.version} is active - re-run train-model to re-validate against the current base`
13717
+ );
13649
13718
  }
13650
- return out;
13651
13719
  }
13652
- applyBuiltins(s) {
13653
- const M = this.maskRaw;
13654
- const MR = this.maskReplacement;
13655
- return s.replace(/(\b[a-z][\w+.-]{0,63}:\/\/[^/\s:@]{1,512}:)([^@\s/]{1,512})(?=@)/gi, `$1${MR}`).replace(
13656
- /\b(?:(authorization\s*[:=]\s*basic\s+)([A-Za-z0-9._~+/=-]{8,})|((?:authorization\s*[:=]\s*)?bearer\s+)([A-Za-z0-9._~+/=-]{8,}))/gi,
13657
- (fullMatch, basicPrefix, _basicToken, bearerPrefix, bearerToken) => {
13658
- if (basicPrefix) return `${basicPrefix}${M}`;
13659
- if (bearerToken && /[^a-z]/.test(bearerToken)) return `${bearerPrefix}${M}`;
13660
- return fullMatch;
13661
- }
13662
- ).replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, MR).replace(
13663
- /\b(?:AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9_-]{16,}|rzp_(?:live|test)_[A-Za-z0-9]{8,}|AIza[A-Za-z0-9_-]{30,})\b/g,
13664
- MR
13665
- ).replace(
13666
- /\b((?:[A-Za-z0-9]{1,32}[_-]){0,8}(?:password|passwd|passcode|pwd|secret|token|api[_-]?key|apikey|auth|authorization|credential|private[_-]?key|access[_-]?key|client[_-]?secret))\b(["']?\s*[=:]\s*)(?!(?:bearer|basic)\b)(?:(["'])((?:(?!\3).){1,512})\3|([^\s"',;&]{1,512}))/gi,
13667
- (_m, key, sep, q, _quotedVal, _plainVal) => q ? `${key}${sep}${q}${M}${q}` : `${key}${sep}${M}`
13668
- ).replace(
13669
- /\b(fill|type|press)\b([^\n]{0,80}?\b(?:password|secret|token|pass|passcode|pin|otp)\b[^\n]{0,80}?\s)(["'])((?:(?!\3).)+)\3/gi,
13670
- (_m, action, mid, q) => `${action}${mid}${q}${M}${q}`
13671
- ).replace(/\b([A-Za-z0-9._%+-])[A-Za-z0-9._%+-]{0,63}@([A-Za-z0-9.-]{1,255}\.[A-Za-z]{2,})\b/g, "$1***@$2").replace(/[A-Za-z0-9_+/=-]{20,}/g, (m, offset, str) => {
13672
- const before = offset > 0 ? str[offset - 1] : " ";
13673
- const after = offset + m.length < str.length ? str[offset + m.length] : " ";
13674
- const beforeIsWord = /[A-Za-z0-9_]/.test(before);
13675
- const afterIsWord = /[A-Za-z0-9_]/.test(after);
13676
- if (!beforeIsWord && !afterIsWord && /\d/.test(m) && /[A-Za-z]/.test(m)) {
13677
- return M;
13678
- }
13679
- return m;
13720
+ const classified = classifyAll(analysed, { base, local });
13721
+ logger.debug(`failure analysis: ${classified.length} classified - ${sourceTally(classified)}`);
13722
+ reportData.classifications = perTestClassifications(classified);
13723
+ if (templatesConsumeAnalysis(options)) {
13724
+ reportData.analysis = analyseClassified(classified, reportData.failures.length, fa);
13725
+ }
13726
+ if (fa.collectUnclassified) {
13727
+ collectUnclassified(classified, resolveProjectDir(options, cwd), {
13728
+ scope: fa.collectScope,
13729
+ project: { cwd, outputFile: options.outputFile }
13680
13730
  });
13681
13731
  }
13682
- };
13732
+ }
13733
+ function sourceTally(classified) {
13734
+ const counts = /* @__PURE__ */ new Map();
13735
+ for (const c of classified) counts.set(c.source, (counts.get(c.source) ?? 0) + 1);
13736
+ return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
13737
+ }
13683
13738
 
13684
13739
  // src/redact/redact-report.ts
13685
13740
  init_esm_shims();
@@ -13730,9 +13785,6 @@ function redactSuite(s, r) {
13730
13785
  s.suites.forEach((child) => redactSuite(child, r));
13731
13786
  }
13732
13787
 
13733
- // src/reporter.ts
13734
- import path15 from "path";
13735
-
13736
13788
  // src/history/HistoryManager.ts
13737
13789
  init_esm_shims();
13738
13790
  import fs15 from "fs";
@@ -13784,6 +13836,9 @@ function buildBranchHash(branch) {
13784
13836
  async function appendRemoteHistory(opts) {
13785
13837
  const numericEntry = { ...opts.entry };
13786
13838
  delete numericEntry.flakyTests;
13839
+ delete numericEntry.failedTests;
13840
+ delete numericEntry.failedTestsTruncated;
13841
+ delete numericEntry.branch;
13787
13842
  const controller = new AbortController();
13788
13843
  const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS2);
13789
13844
  try {
@@ -13813,6 +13868,58 @@ async function appendRemoteHistory(opts) {
13813
13868
  }
13814
13869
  }
13815
13870
 
13871
+ // src/history/types.ts
13872
+ init_esm_shims();
13873
+ var FAILED_TESTS_CAP = 300;
13874
+
13875
+ // src/history/RunDiff.ts
13876
+ init_esm_shims();
13877
+ function splitCurrentFailing(currentFailing, baselineFailed) {
13878
+ const currentFailedIds = /* @__PURE__ */ new Set();
13879
+ const newFailures = [];
13880
+ const stillFailing = [];
13881
+ for (const item of currentFailing) {
13882
+ if (currentFailedIds.has(item.id)) continue;
13883
+ currentFailedIds.add(item.id);
13884
+ (baselineFailed.has(item.id) ? stillFailing : newFailures).push(item);
13885
+ }
13886
+ return { newFailures, stillFailing, currentFailedIds };
13887
+ }
13888
+ function splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses) {
13889
+ const fixed = [];
13890
+ const noLongerRun = [];
13891
+ for (const [id, title] of baselineFailed) {
13892
+ if (currentFailedIds.has(id)) continue;
13893
+ const status = currentStatuses.get(id);
13894
+ const bucket = status === "passed" || status === "flaky" ? fixed : noLongerRun;
13895
+ bucket.push({ id, title });
13896
+ }
13897
+ return { fixed, noLongerRun };
13898
+ }
13899
+ function computeRunDiff(args) {
13900
+ const { currentFailing, currentStatuses, priorEntries, branch } = args;
13901
+ const baseline = priorEntries.find((e) => e.branch === branch && Array.isArray(e.failedTests));
13902
+ if (!baseline) return null;
13903
+ const baselineFailed = /* @__PURE__ */ new Map();
13904
+ for (const item of baseline.failedTests) {
13905
+ if (!baselineFailed.has(item.id)) baselineFailed.set(item.id, item.title);
13906
+ }
13907
+ const { newFailures, stillFailing, currentFailedIds } = splitCurrentFailing(currentFailing, baselineFailed);
13908
+ const { fixed, noLongerRun } = splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses);
13909
+ for (const bucket of [newFailures, stillFailing, fixed, noLongerRun]) {
13910
+ bucket.sort((a, b) => a.title.localeCompare(b.title));
13911
+ }
13912
+ return {
13913
+ newFailures,
13914
+ stillFailing,
13915
+ fixed,
13916
+ noLongerRun,
13917
+ baselineRunId: baseline.runId,
13918
+ baselineTimestamp: baseline.timestamp,
13919
+ baselineTruncated: baseline.failedTestsTruncated === true
13920
+ };
13921
+ }
13922
+
13816
13923
  // src/history/FlakinessAnalyser.ts
13817
13924
  init_esm_shims();
13818
13925
  function computeTopN(entries, topN) {
@@ -13889,11 +13996,32 @@ function reportName(pdfPaths, reportTitle) {
13889
13996
  if (pdfPaths.length > 0) return basename(pdfPaths[0]);
13890
13997
  return reportTitle || "ReportForge";
13891
13998
  }
13999
+ function formatDiffLine(diff) {
14000
+ if (!diff) return null;
14001
+ const newFailures = diff.newFailures.length;
14002
+ const fixed = diff.fixed.length;
14003
+ const stillFailing = diff.stillFailing.length;
14004
+ const noLongerRun = diff.noLongerRun.length;
14005
+ const parts = [
14006
+ `${newFailures} new ${newFailures === 1 ? "failure" : "failures"}`,
14007
+ `${fixed} fixed`,
14008
+ `${stillFailing} still failing`
14009
+ ];
14010
+ if (noLongerRun > 0) parts.push(`${noLongerRun} no longer run`);
14011
+ return parts.join(" \xB7 ");
14012
+ }
13892
14013
 
13893
14014
  // src/notify/formatters/slack.ts
13894
- function buildSlackPayload(stats, pdfPaths, reportTitle) {
14015
+ function buildSlackPayload(stats, pdfPaths, reportTitle, runDiff) {
13895
14016
  const label = statusLabel(stats.verdict);
13896
14017
  const name = reportName(pdfPaths, reportTitle);
14018
+ const diffLine = formatDiffLine(runDiff);
14019
+ const lines = [
14020
+ `*${label}* \xB7 ${stats.passRate}% passed`,
14021
+ countsLine(stats),
14022
+ ...diffLine ? [diffLine] : [],
14023
+ `_${name}_`
14024
+ ];
13897
14025
  return {
13898
14026
  text: `${label} \xB7 ${stats.passRate}% passed`,
13899
14027
  blocks: [
@@ -13901,9 +14029,7 @@ function buildSlackPayload(stats, pdfPaths, reportTitle) {
13901
14029
  type: "section",
13902
14030
  text: {
13903
14031
  type: "mrkdwn",
13904
- text: `*${label}* \xB7 ${stats.passRate}% passed
13905
- ${countsLine(stats)}
13906
- _${name}_`
14032
+ text: lines.join("\n")
13907
14033
  }
13908
14034
  }
13909
14035
  ]
@@ -13928,9 +14054,37 @@ function buildTeamsSimpleMessage(text) {
13928
14054
  ]
13929
14055
  };
13930
14056
  }
13931
- function buildTeamsPayload(stats, pdfPaths, reportTitle) {
14057
+ function buildTeamsPayload(stats, pdfPaths, reportTitle, runDiff) {
13932
14058
  const label = statusLabel(stats.verdict);
13933
14059
  const name = reportName(pdfPaths, reportTitle);
14060
+ const diffLine = formatDiffLine(runDiff);
14061
+ const body = [
14062
+ {
14063
+ type: "TextBlock",
14064
+ text: `${label} \xB7 ${stats.passRate}% passed`,
14065
+ weight: "bolder",
14066
+ size: "medium"
14067
+ },
14068
+ {
14069
+ type: "TextBlock",
14070
+ text: countsLine(stats),
14071
+ isSubtle: true
14072
+ },
14073
+ {
14074
+ type: "TextBlock",
14075
+ text: name,
14076
+ isSubtle: true,
14077
+ spacing: "none"
14078
+ }
14079
+ ];
14080
+ if (diffLine) {
14081
+ body.push({
14082
+ type: "TextBlock",
14083
+ text: diffLine,
14084
+ isSubtle: true,
14085
+ spacing: "none"
14086
+ });
14087
+ }
13934
14088
  return {
13935
14089
  type: "message",
13936
14090
  attachments: [
@@ -13940,25 +14094,7 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13940
14094
  $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
13941
14095
  type: "AdaptiveCard",
13942
14096
  version: "1.4",
13943
- body: [
13944
- {
13945
- type: "TextBlock",
13946
- text: `${label} \xB7 ${stats.passRate}% passed`,
13947
- weight: "bolder",
13948
- size: "medium"
13949
- },
13950
- {
13951
- type: "TextBlock",
13952
- text: countsLine(stats),
13953
- isSubtle: true
13954
- },
13955
- {
13956
- type: "TextBlock",
13957
- text: name,
13958
- isSubtle: true,
13959
- spacing: "none"
13960
- }
13961
- ]
14097
+ body
13962
14098
  }
13963
14099
  }
13964
14100
  ]
@@ -13967,16 +14103,17 @@ function buildTeamsPayload(stats, pdfPaths, reportTitle) {
13967
14103
 
13968
14104
  // src/notify/formatters/discord.ts
13969
14105
  init_esm_shims();
13970
- function buildDiscordPayload(stats, pdfPaths, reportTitle) {
14106
+ function buildDiscordPayload(stats, pdfPaths, reportTitle, runDiff) {
13971
14107
  const label = statusLabel(stats.verdict);
13972
14108
  const name = reportName(pdfPaths, reportTitle);
13973
14109
  const color = stats.verdict === "passed" ? 3066993 : 15158332;
14110
+ const diffLine = formatDiffLine(runDiff);
14111
+ const descriptionLines = [countsLine(stats), ...diffLine ? [diffLine] : [], name];
13974
14112
  return {
13975
14113
  embeds: [
13976
14114
  {
13977
14115
  title: `${label} \xB7 ${stats.passRate}% passed`,
13978
- description: `${countsLine(stats)}
13979
- ${name}`,
14116
+ description: descriptionLines.join("\n"),
13980
14117
  color
13981
14118
  }
13982
14119
  ]
@@ -13990,10 +14127,16 @@ import { basename as basename2 } from "path";
13990
14127
 
13991
14128
  // src/notify/formatters/email.ts
13992
14129
  init_esm_shims();
13993
- function buildEmailContent(stats, pdfPaths, reportTitle) {
14130
+ function buildEmailContent(stats, pdfPaths, reportTitle, runDiff) {
13994
14131
  const label = statusLabel(stats.verdict);
13995
- const subject = `[ReportForge] ${label} \u2014 ${stats.passRate}% passed`;
14132
+ const subject = `[ReportForge] ${label} \xB7 ${stats.passRate}% passed`;
13996
14133
  const name = reportName(pdfPaths, reportTitle);
14134
+ const diffLine = formatDiffLine(runDiff);
14135
+ const diffRow = diffLine ? `
14136
+ <tr>
14137
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Since last run</td>
14138
+ <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(diffLine)}</td>
14139
+ </tr>` : "";
13997
14140
  const html = `<!DOCTYPE html>
13998
14141
  <html lang="en">
13999
14142
  <head><meta charset="utf-8"><title>${escHtml(subject)}</title></head>
@@ -14028,7 +14171,7 @@ function buildEmailContent(stats, pdfPaths, reportTitle) {
14028
14171
  <tr style="background:#f9f9f9">
14029
14172
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5">Duration</td>
14030
14173
  <td style="padding:8px 12px;border-top:1px solid #e5e5e5;text-align:right">${escHtml(countsLine(stats).split(" \xB7 ").pop() ?? "")}</td>
14031
- </tr>
14174
+ </tr>${diffRow}
14032
14175
  </table>
14033
14176
  <p style="margin:24px 0 0;color:#888;font-size:12px">Sent by ReportForge \xB7 <a href="https://reportforge.org" style="color:#888">reportforge.org</a></p>
14034
14177
  </body>
@@ -14053,7 +14196,8 @@ var EmailSender = class {
14053
14196
  const { subject, html } = buildEmailContent(
14054
14197
  reportData.stats,
14055
14198
  pdfPaths,
14056
- reportData.meta.title ?? ""
14199
+ reportData.meta.title ?? "",
14200
+ reportData.runDiff
14057
14201
  );
14058
14202
  const body = { from, to: config.to, subject, html };
14059
14203
  if (config.attachPdf && pdfPaths.length > 0) {
@@ -14117,7 +14261,7 @@ var NotificationSender = class {
14117
14261
  result.skipped.push(name);
14118
14262
  return Promise.resolve();
14119
14263
  }
14120
- const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle);
14264
+ const payload = FORMATTERS[name](reportData.stats, pdfPaths, reportTitle, reportData.runDiff);
14121
14265
  if (name === "discord" && notifyConfig.discord?.attachPdf && pdfPaths.length > 0) {
14122
14266
  return this.postDiscordWithPdf(channel.url, payload, pdfPaths[0], result);
14123
14267
  }
@@ -14225,6 +14369,261 @@ var NotificationSender = class {
14225
14369
  }
14226
14370
  };
14227
14371
 
14372
+ // src/pipeline/orchestrator.ts
14373
+ async function runReportPipeline(hooks) {
14374
+ const { options } = hooks;
14375
+ let licenseInfo;
14376
+ try {
14377
+ licenseInfo = await hooks.license();
14378
+ } catch (err) {
14379
+ logger.warn(`License resolution errored: ${err.message}. PDF generation skipped.`);
14380
+ licenseInfo = null;
14381
+ }
14382
+ if (!licenseInfo) return { status: "skipped-license" };
14383
+ const collected = hooks.collect();
14384
+ if (!collected) {
14385
+ maybePrintUpdateNotice(licenseInfo, hooks.version, hooks.updateHint);
14386
+ return { status: "skipped-collect" };
14387
+ }
14388
+ if (collected.stats.total === 0) {
14389
+ logger.warn("No tests were executed \u2014 check your --grep / project filters. The report has nothing to assess.");
14390
+ }
14391
+ if (options.showTrend && collected.stats.total > 0) {
14392
+ try {
14393
+ await appendTrend(collected, licenseInfo, hooks);
14394
+ } catch (err) {
14395
+ logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
14396
+ }
14397
+ }
14398
+ const reportData = buildReportData(collected, licenseInfo, hooks);
14399
+ runFailureAnalysis(reportData, options, hooks.cwd);
14400
+ reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
14401
+ if (hooks.redactor) {
14402
+ redactReportData(reportData, hooks.redactor);
14403
+ }
14404
+ let pdfPaths = [];
14405
+ let pdfFailed = false;
14406
+ try {
14407
+ pdfPaths = await hooks.pdfGenerator.generateAll(reportData, options, licenseInfo);
14408
+ if (options.open) {
14409
+ for (const pdfPath of pdfPaths) await openPdf(pdfPath);
14410
+ }
14411
+ } catch (err) {
14412
+ pdfFailed = true;
14413
+ logger.error(`Failed to generate PDF report: ${err.message}`);
14414
+ if (process.env.RF_DEBUG) console.error(err);
14415
+ }
14416
+ if (options.notify) {
14417
+ const sender = new NotificationSender();
14418
+ await sender.sendAll(reportData, options.notify, pdfPaths);
14419
+ }
14420
+ maybePrintUpdateNotice(licenseInfo, hooks.version, hooks.updateHint);
14421
+ return pdfFailed ? { status: "pdf-failed" } : { status: "ok", pdfPaths };
14422
+ }
14423
+ async function appendTrend(collected, licenseInfo, hooks) {
14424
+ const { options, cwd, redactor } = hooks;
14425
+ const historyPath = resolveHistoryPath(options, cwd);
14426
+ const hm = new HistoryManager(historyPath);
14427
+ const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
14428
+ const flakyTests = collectFlakyTests(collected.projects).map(
14429
+ (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14430
+ );
14431
+ const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
14432
+ const entry = {
14433
+ runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
14434
+ timestamp: Date.now(),
14435
+ passRate: collected.stats.passRate,
14436
+ total: collected.stats.total,
14437
+ passed: collected.stats.passed,
14438
+ failed: collected.stats.failed,
14439
+ flaky: collected.stats.flaky,
14440
+ duration: collected.stats.duration,
14441
+ verdict: deriveVerdict(collected.stats),
14442
+ flakyTests,
14443
+ ...failedTestsFields,
14444
+ branch: collected.environment.branch
14445
+ };
14446
+ const localEntries = hm.append(entry, options.historySize);
14447
+ collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
14448
+ let trendEntries = localEntries;
14449
+ if (options.remoteHistory && licenseInfo.jwt) {
14450
+ const serverUrl = options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL;
14451
+ const remote = await appendRemoteHistory({
14452
+ serverUrl,
14453
+ jwt: licenseInfo.jwt,
14454
+ projectId: buildProjectId(options.projectName, options.outputFile),
14455
+ branchHash: buildBranchHash(collected.environment.branch),
14456
+ entry
14457
+ });
14458
+ if (remote) {
14459
+ trendEntries = remote;
14460
+ logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
14461
+ }
14462
+ }
14463
+ populateHistoryCharts(trendEntries, localEntries, collected, options);
14464
+ }
14465
+ function populateHistoryCharts(trendEntries, localEntries, collected, options) {
14466
+ if (trendEntries.length >= 2) {
14467
+ collected.charts.trend = {
14468
+ entries: trendEntries.map((e) => ({
14469
+ runId: e.runId,
14470
+ timestamp: e.timestamp,
14471
+ passRate: e.passRate,
14472
+ total: e.total,
14473
+ duration: e.duration,
14474
+ verdict: e.verdict
14475
+ })),
14476
+ delta: trendEntries[0].passRate - trendEntries[1].passRate
14477
+ };
14478
+ }
14479
+ if (options.flakinessTopN > 0) {
14480
+ const table = computeTopN(localEntries, options.flakinessTopN);
14481
+ if (table.length > 0) {
14482
+ collected.charts.flakinessTable = table;
14483
+ }
14484
+ }
14485
+ }
14486
+ function buildReportData(collected, licenseInfo, hooks) {
14487
+ const { options } = hooks;
14488
+ const { projects, stats, failures, environment, charts, runDiff } = collected;
14489
+ const projectName = options.projectName ?? hooks.resolveProjectName?.() ?? resolveProjectNameFromPkg(hooks.cwd);
14490
+ const firstTemplate = options.templatePath?.length ? "detailed" : Array.isArray(options.template) ? options.template[0] : options.template;
14491
+ return {
14492
+ meta: {
14493
+ title: options.reportTitle,
14494
+ generatedAt: /* @__PURE__ */ new Date(),
14495
+ template: firstTemplate,
14496
+ branding: {
14497
+ primaryColor: options.primaryColor,
14498
+ accentColor: options.accentColor,
14499
+ watermark: options.watermark
14500
+ },
14501
+ licenseInfo,
14502
+ projectName
14503
+ },
14504
+ stats,
14505
+ projects,
14506
+ failures,
14507
+ environment,
14508
+ charts,
14509
+ runDiff
14510
+ };
14511
+ }
14512
+ function resolveProjectNameFromPkg(cwd) {
14513
+ try {
14514
+ const pkgPath = path15.resolve(cwd, "package.json");
14515
+ const pkg = __require(pkgPath);
14516
+ return pkg.name ?? "Playwright Tests";
14517
+ } catch {
14518
+ return "Playwright Tests";
14519
+ }
14520
+ }
14521
+ async function openPdf(pdfPath) {
14522
+ try {
14523
+ const { existsSync } = await import("fs");
14524
+ if (!existsSync(pdfPath)) {
14525
+ logger.warn(`open: true \u2014 PDF not found at ${pdfPath}; file may not have been written yet.`);
14526
+ return;
14527
+ }
14528
+ const { spawn } = await import("child_process");
14529
+ const platform = process.platform;
14530
+ const [cmd, args] = platform === "darwin" ? ["open", [pdfPath]] : platform === "win32" ? ["cmd", ["/c", "start", "", pdfPath]] : ["xdg-open", [pdfPath]];
14531
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
14532
+ child.on("error", (err) => {
14533
+ logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
14534
+ });
14535
+ child.unref();
14536
+ if (platform === "win32") {
14537
+ child.on("close", (code) => {
14538
+ if (code !== 0 && code !== null) {
14539
+ logger.warn(
14540
+ `open: true \u2014 shell-open exited with code ${code}. No PDF viewer may be registered for .pdf files on this machine. File is at: ${pdfPath}`
14541
+ );
14542
+ }
14543
+ });
14544
+ }
14545
+ } catch (err) {
14546
+ logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
14547
+ }
14548
+ }
14549
+ function deriveVerdict(stats) {
14550
+ if (stats.total === 0) return "unknown";
14551
+ if (stats.verdict === "interrupted" || stats.verdict === "timedout") return "failed";
14552
+ if (stats.failed === 0 && stats.timedOut === 0) {
14553
+ if (stats.passed === 0 && stats.flaky === 0) return "unknown";
14554
+ return "passed";
14555
+ }
14556
+ if (stats.failed / stats.total > 0.5) return "failed";
14557
+ if (stats.timedOut > 0) return "failed";
14558
+ if (stats.failed > 0) return "partial";
14559
+ return "unknown";
14560
+ }
14561
+ function resolveHistoryPath(options, cwd) {
14562
+ if (options.historyFile) {
14563
+ return path15.isAbsolute(options.historyFile) ? options.historyFile : path15.resolve(cwd, options.historyFile);
14564
+ }
14565
+ return path15.join(resolveProjectDir(options, cwd), "history.json");
14566
+ }
14567
+ function flattenSuiteNodes(suites) {
14568
+ return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
14569
+ }
14570
+ function collectFlakyTests(projects) {
14571
+ const seen = /* @__PURE__ */ new Set();
14572
+ const result = [];
14573
+ for (const project of projects) {
14574
+ for (const suite of flattenSuiteNodes(project.suites)) {
14575
+ for (const test of suite.tests) {
14576
+ if (test.status === "flaky" && !seen.has(test.id)) {
14577
+ seen.add(test.id);
14578
+ result.push({ id: test.id, title: test.fullTitle });
14579
+ }
14580
+ }
14581
+ }
14582
+ }
14583
+ return result;
14584
+ }
14585
+ function collectFailedTests(projects) {
14586
+ const seen = /* @__PURE__ */ new Set();
14587
+ const result = [];
14588
+ for (const project of projects) {
14589
+ for (const suite of flattenSuiteNodes(project.suites)) {
14590
+ for (const test of suite.tests) {
14591
+ if ((test.status === "failed" || test.status === "timedOut") && !seen.has(test.id)) {
14592
+ seen.add(test.id);
14593
+ result.push({ id: test.id, title: test.fullTitle });
14594
+ }
14595
+ }
14596
+ }
14597
+ }
14598
+ return result;
14599
+ }
14600
+ function collectCurrentStatuses(projects) {
14601
+ const statuses = /* @__PURE__ */ new Map();
14602
+ for (const project of projects) {
14603
+ for (const suite of flattenSuiteNodes(project.suites)) {
14604
+ for (const test of suite.tests) {
14605
+ statuses.set(test.id, test.status);
14606
+ }
14607
+ }
14608
+ }
14609
+ return statuses;
14610
+ }
14611
+ function buildFailedTestsFields(projects, redactor) {
14612
+ const redactedFailing = collectFailedTests(projects).map(
14613
+ (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14614
+ );
14615
+ const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
14616
+ return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
14617
+ }
14618
+ function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
14619
+ return computeRunDiff({
14620
+ currentFailing,
14621
+ currentStatuses: collectCurrentStatuses(projects),
14622
+ priorEntries,
14623
+ branch
14624
+ }) ?? void 0;
14625
+ }
14626
+
14228
14627
  // src/live/LiveStreamer.ts
14229
14628
  init_esm_shims();
14230
14629
  var NETWORK_TIMEOUT_MS3 = 8e3;
@@ -14845,7 +15244,6 @@ function chunkToString2(chunk) {
14845
15244
  }
14846
15245
 
14847
15246
  // src/reporter.ts
14848
- var DEFAULT_SERVER_URL2 = "https://reportforge.org";
14849
15247
  var LICENSE_STALE_MARGIN_MS = 5 * 60 * 1e3;
14850
15248
  var PdfReporter = class {
14851
15249
  constructor(rawOptions = {}) {
@@ -14860,7 +15258,7 @@ var PdfReporter = class {
14860
15258
  if (this.options.redact.enabled) {
14861
15259
  this.redactor = new Redactor(this.options.redact);
14862
15260
  }
14863
- this.version = true ? "0.25.0" : "0.x";
15261
+ this.version = true ? "0.27.0" : "0.x";
14864
15262
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14865
15263
  this.licenseClient = new LicenseClient({
14866
15264
  licenseKey: this.options.licenseKey,
@@ -14931,44 +15329,19 @@ var PdfReporter = class {
14931
15329
  }
14932
15330
  }
14933
15331
  async onEnd(result) {
14934
- const licenseInfo = await this.freshLicense();
14935
- if (!licenseInfo) return;
14936
- const collected = this._collectData(result);
14937
- if (!collected) {
14938
- maybePrintUpdateNotice(licenseInfo, this.version);
14939
- return;
14940
- }
14941
- if (collected.stats.total === 0) {
14942
- logger.warn("No tests were executed \u2014 check your --grep / project filters. The report has nothing to assess.");
14943
- }
14944
- if (this.options.showTrend && collected.stats.total > 0) {
14945
- try {
14946
- await this._appendTrend(collected, licenseInfo);
14947
- } catch (err) {
14948
- logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
14949
- }
14950
- }
14951
- const reportData = this._buildReportData(collected, licenseInfo);
14952
- runFailureAnalysis(reportData, this.options, this.cwd);
14953
- reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
14954
- if (this.redactor) {
14955
- redactReportData(reportData, this.redactor);
14956
- }
14957
- let pdfPaths = [];
14958
- try {
14959
- pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);
14960
- if (this.options.open) {
14961
- for (const pdfPath of pdfPaths) await this.openPdf(pdfPath);
14962
- }
14963
- } catch (err) {
14964
- logger.error(`Failed to generate PDF report: ${err.message}`);
14965
- if (process.env.RF_DEBUG) console.error(err);
14966
- }
14967
- if (this.options.notify) {
14968
- const sender = new NotificationSender();
14969
- await sender.sendAll(reportData, this.options.notify, pdfPaths);
14970
- }
14971
- maybePrintUpdateNotice(licenseInfo, this.version);
15332
+ await runReportPipeline({
15333
+ options: this.options,
15334
+ cwd: this.cwd,
15335
+ version: this.version,
15336
+ license: () => this.freshLicense(),
15337
+ collect: () => this._collectData(result),
15338
+ // Call-time cwd on purpose: pre-extraction behavior read process.cwd()
15339
+ // at onEnd for the package.json-name fallback (a mid-run chdir is then
15340
+ // honored), while history paths keep the construction-time this.cwd.
15341
+ resolveProjectName: () => resolveProjectNameFromPkg(process.cwd()),
15342
+ pdfGenerator: this.pdfGenerator,
15343
+ redactor: this.redactor
15344
+ });
14972
15345
  }
14973
15346
  _collectData(result) {
14974
15347
  if (this.options.shardResults && this.options.shardResults.length > 0) {
@@ -14981,89 +15354,6 @@ var PdfReporter = class {
14981
15354
  }
14982
15355
  return this.dataCollector.finalize(result);
14983
15356
  }
14984
- async _appendTrend(collected, licenseInfo) {
14985
- const historyPath = resolveHistoryPath(this.options, this.cwd);
14986
- const hm = new HistoryManager(historyPath);
14987
- const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
14988
- const redactor = this.redactor;
14989
- const flakyTests = collectFlakyTests(collected.projects).map(
14990
- (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
14991
- );
14992
- const entry = {
14993
- runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
14994
- timestamp: Date.now(),
14995
- passRate: collected.stats.passRate,
14996
- total: collected.stats.total,
14997
- passed: collected.stats.passed,
14998
- failed: collected.stats.failed,
14999
- flaky: collected.stats.flaky,
15000
- duration: collected.stats.duration,
15001
- verdict: deriveVerdict(collected.stats),
15002
- flakyTests
15003
- };
15004
- const localEntries = hm.append(entry, this.options.historySize);
15005
- let trendEntries = localEntries;
15006
- if (this.options.remoteHistory && licenseInfo.jwt) {
15007
- const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
15008
- const remote = await appendRemoteHistory({
15009
- serverUrl,
15010
- jwt: licenseInfo.jwt,
15011
- projectId: buildProjectId(this.options.projectName, this.options.outputFile),
15012
- branchHash: buildBranchHash(collected.environment.branch),
15013
- entry
15014
- });
15015
- if (remote) {
15016
- trendEntries = remote;
15017
- logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
15018
- }
15019
- }
15020
- this._populateHistoryCharts(trendEntries, localEntries, collected);
15021
- }
15022
- _populateHistoryCharts(trendEntries, localEntries, collected) {
15023
- if (trendEntries.length >= 2) {
15024
- collected.charts.trend = {
15025
- entries: trendEntries.map((e) => ({
15026
- runId: e.runId,
15027
- timestamp: e.timestamp,
15028
- passRate: e.passRate,
15029
- total: e.total,
15030
- duration: e.duration,
15031
- verdict: e.verdict
15032
- })),
15033
- delta: trendEntries[0].passRate - trendEntries[1].passRate
15034
- };
15035
- }
15036
- if (this.options.flakinessTopN > 0) {
15037
- const table = computeTopN(localEntries, this.options.flakinessTopN);
15038
- if (table.length > 0) {
15039
- collected.charts.flakinessTable = table;
15040
- }
15041
- }
15042
- }
15043
- _buildReportData(collected, licenseInfo) {
15044
- const { projects, stats, failures, environment, charts } = collected;
15045
- const projectName = this.options.projectName ?? this.resolveProjectName();
15046
- const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
15047
- return {
15048
- meta: {
15049
- title: this.options.reportTitle,
15050
- generatedAt: /* @__PURE__ */ new Date(),
15051
- template: firstTemplate,
15052
- branding: {
15053
- primaryColor: this.options.primaryColor,
15054
- accentColor: this.options.accentColor,
15055
- watermark: this.options.watermark
15056
- },
15057
- licenseInfo,
15058
- projectName
15059
- },
15060
- stats,
15061
- projects,
15062
- failures,
15063
- environment,
15064
- charts
15065
- };
15066
- }
15067
15357
  printsToStdio() {
15068
15358
  return false;
15069
15359
  }
@@ -15119,7 +15409,7 @@ var PdfReporter = class {
15119
15409
  setupLive(config) {
15120
15410
  const live = this.options.live;
15121
15411
  const runId = deriveRunId({ runId: live.runId });
15122
- const serverUrl = live.serverUrl ?? this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
15412
+ const serverUrl = live.serverUrl ?? this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL;
15123
15413
  const shard = config.shard ?? null;
15124
15414
  this.liveShardKey = shardKeyOf(shard);
15125
15415
  this.liveSteps = live.steps;
@@ -15191,80 +15481,7 @@ var PdfReporter = class {
15191
15481
  await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
15192
15482
  }
15193
15483
  }
15194
- resolveProjectName() {
15195
- try {
15196
- const pkgPath = path15.resolve(process.cwd(), "package.json");
15197
- const pkg = __require(pkgPath);
15198
- return pkg.name ?? "Playwright Tests";
15199
- } catch {
15200
- return "Playwright Tests";
15201
- }
15202
- }
15203
- async openPdf(pdfPath) {
15204
- try {
15205
- const { existsSync } = await import("fs");
15206
- if (!existsSync(pdfPath)) {
15207
- logger.warn(`open: true \u2014 PDF not found at ${pdfPath}; file may not have been written yet.`);
15208
- return;
15209
- }
15210
- const { spawn } = await import("child_process");
15211
- const platform = process.platform;
15212
- const [cmd, args] = platform === "darwin" ? ["open", [pdfPath]] : platform === "win32" ? ["cmd", ["/c", "start", "", pdfPath]] : ["xdg-open", [pdfPath]];
15213
- const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
15214
- child.on("error", (err) => {
15215
- logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
15216
- });
15217
- child.unref();
15218
- if (platform === "win32") {
15219
- child.on("close", (code) => {
15220
- if (code !== 0 && code !== null) {
15221
- logger.warn(
15222
- `open: true \u2014 shell-open exited with code ${code}. No PDF viewer may be registered for .pdf files on this machine. File is at: ${pdfPath}`
15223
- );
15224
- }
15225
- });
15226
- }
15227
- } catch (err) {
15228
- logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
15229
- }
15230
- }
15231
15484
  };
15232
- function deriveVerdict(stats) {
15233
- if (stats.total === 0) return "unknown";
15234
- if (stats.verdict === "interrupted" || stats.verdict === "timedout") return "failed";
15235
- if (stats.failed === 0 && stats.timedOut === 0) {
15236
- if (stats.passed === 0 && stats.flaky === 0) return "unknown";
15237
- return "passed";
15238
- }
15239
- if (stats.failed / stats.total > 0.5) return "failed";
15240
- if (stats.timedOut > 0) return "failed";
15241
- if (stats.failed > 0) return "partial";
15242
- return "unknown";
15243
- }
15244
- function resolveHistoryPath(options, cwd) {
15245
- if (options.historyFile) {
15246
- return path15.isAbsolute(options.historyFile) ? options.historyFile : path15.resolve(cwd, options.historyFile);
15247
- }
15248
- return path15.join(resolveProjectDir(options, cwd), "history.json");
15249
- }
15250
- function flattenSuiteNodes(suites) {
15251
- return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
15252
- }
15253
- function collectFlakyTests(projects) {
15254
- const seen = /* @__PURE__ */ new Set();
15255
- const result = [];
15256
- for (const project of projects) {
15257
- for (const suite of flattenSuiteNodes(project.suites)) {
15258
- for (const test of suite.tests) {
15259
- if (test.status === "flaky" && !seen.has(test.id)) {
15260
- seen.add(test.id);
15261
- result.push({ id: test.id, title: test.fullTitle });
15262
- }
15263
- }
15264
- }
15265
- }
15266
- return result;
15267
- }
15268
15485
 
15269
15486
  // src/index.ts
15270
15487
  function defineReporterConfig(options) {