@reportforge/playwright-pdf 0.26.1 → 0.28.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
@@ -11009,8 +11009,22 @@ function acceptDeliveredModel(d) {
11009
11009
  }
11010
11010
  }
11011
11011
 
11012
- // 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();
11013
11025
  var DEFAULT_SERVER_URL = "https://reportforge.org";
11026
+
11027
+ // src/license/LicenseClient.ts
11014
11028
  var REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
11015
11029
  var NETWORK_TIMEOUT_MS = 8e3;
11016
11030
  var TERMINAL = /* @__PURE__ */ Symbol("terminal");
@@ -11048,9 +11062,16 @@ var LicenseClient = class {
11048
11062
  return toLicenseInfo(cacheValid, cached, normalized, "jwt-cache");
11049
11063
  }
11050
11064
  const network = await this.tryNetwork(cacheValid ? cached : null, normalized, verifier);
11051
- if (network === TERMINAL) return null;
11065
+ if (network === TERMINAL) {
11066
+ await settleWindowsTlsTeardown();
11067
+ return null;
11068
+ }
11052
11069
  if (network) return network;
11053
- return this._offlineFallback(cacheValid, normalized, nowMs);
11070
+ const fallback = this._offlineFallback(cacheValid, normalized, nowMs);
11071
+ if (!fallback) {
11072
+ await settleWindowsTlsTeardown();
11073
+ }
11074
+ return fallback;
11054
11075
  }
11055
11076
  _validateKey(key) {
11056
11077
  const parsed = this.parser.parse(key);
@@ -11238,111 +11259,24 @@ function normalizeUrl(url) {
11238
11259
  return url.endsWith("/") ? url.slice(0, -1) : url;
11239
11260
  }
11240
11261
 
11241
- // src/license/update-notice.ts
11242
- init_esm_shims();
11243
-
11244
- // src/license/version.ts
11245
- init_esm_shims();
11246
- function parseTriple(v) {
11247
- if (!v) return null;
11248
- const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
11249
- if (!m) return null;
11250
- return [Number(m[1]), Number(m[2]), Number(m[3])];
11251
- }
11252
- function isNewer(latest, current) {
11253
- const l = parseTriple(latest);
11254
- const c = parseTriple(current);
11255
- if (!l || !c) return false;
11256
- if (l[0] !== c[0]) return l[0] > c[0];
11257
- if (l[1] !== c[1]) return l[1] > c[1];
11258
- return l[2] > c[2];
11259
- }
11260
-
11261
- // src/license/update-notice.ts
11262
- function maybePrintUpdateNotice(licenseInfo, currentVersion) {
11263
- try {
11264
- const latest = licenseInfo?.latestVersion;
11265
- if (!latest || !isNewer(latest, currentVersion)) return;
11266
- const shown = latest.replace(/[^\w.+-]/g, "");
11267
- logger.info(
11268
- `Update available: ${currentVersion} -> ${shown}. Run: npm i -D @reportforge/playwright-pdf@latest`
11269
- );
11270
- } catch (err) {
11271
- logger.debug("update notice failed (ignored)", { error: err.message });
11272
- }
11273
- }
11274
-
11275
- // src/analysis/index.ts
11276
- init_esm_shims();
11277
-
11278
- // src/analysis/LocalModelStore.ts
11279
- init_esm_shims();
11280
- import fs4 from "fs";
11281
- import os5 from "os";
11282
- import path5 from "path";
11283
- var testDeps2 = null;
11284
- function defaultLocalModelPath() {
11285
- return testDeps2?.file ?? path5.join(os5.homedir(), ".reportforge", "model-local.json");
11286
- }
11287
- function loadLocalModel(overridePath) {
11288
- const file = overridePath ?? defaultLocalModelPath();
11289
- const explicit = overridePath !== void 0;
11290
- const reject = (why) => {
11291
- const msg = `local model at ${file} not used: ${why}`;
11292
- if (explicit) logger.warn(msg);
11293
- else logger.debug(msg);
11294
- return null;
11295
- };
11296
- let parsed;
11297
- try {
11298
- parsed = JSON.parse(fs4.readFileSync(file, "utf8"));
11299
- } catch (e) {
11300
- return explicit ? reject(`unreadable (${e.message})`) : null;
11301
- }
11302
- if (parsed.formatVersion !== 1) return reject(`unsupported formatVersion ${String(parsed.formatVersion)}`);
11303
- if (!isValidNaiveBayesModel(parsed.model)) return reject("embedded model failed schema validation");
11304
- const meta = parsed.meta;
11305
- if (!meta || typeof meta.gatePassed !== "boolean") return reject("missing meta");
11306
- if (!meta.gatePassed) return reject("its evaluation gate did not pass - run train-model after labeling more feedback");
11307
- if (typeof meta.acceptThreshold !== "number") return reject("gate passed but no calibrated threshold (corrupt meta)");
11308
- return {
11309
- model: parsed.model,
11310
- acceptThreshold: meta.acceptThreshold,
11311
- strongThreshold: typeof meta.strongThreshold === "number" ? meta.strongThreshold : null,
11312
- meta
11313
- };
11314
- }
11315
-
11316
- // src/analysis/FailureClusterer.ts
11262
+ // src/collector/DataCollector.ts
11317
11263
  init_esm_shims();
11318
11264
 
11319
- // src/analysis/redact.ts
11265
+ // src/utils/strip-ansi.ts
11320
11266
  init_esm_shims();
11321
- var URI_RE = /[a-z][\w+.-]*:\/\/\S+/gi;
11322
- var EMAIL_RE = /\b[\w.+-]+@[\w.-]+\.\w+\b/g;
11323
- var SECRET_RE = /\b(?=[A-Za-z0-9_+/=-]*[0-9])(?=[A-Za-z0-9_+/=-]*[A-Za-z])[A-Za-z0-9_+/=-]{16,}\b/g;
11324
- var IP_RE = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
11325
- var WIN_PATH_RE = /[A-Za-z]:\\[^\s'"]+/g;
11326
- var UNIX_PATH_RE = /(?:\/[\w.-]+){2,}/g;
11327
- function redactForStorage(raw) {
11328
- 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 ");
11329
- return tokenize(redacted).join(" ");
11330
- }
11331
- function localInferenceTokens(message) {
11332
- 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, "");
11333
11276
  }
11334
11277
 
11335
- // src/analysis/constants.ts
11278
+ // src/collector/execution-capture.ts
11336
11279
  init_esm_shims();
11337
- var STRENGTH = {
11338
- unknownMaxMargin: 0.05,
11339
- collectMaxMargin: 0.1,
11340
- harvestMinMargin: 0.25,
11341
- moderateMinMargin: 0.1,
11342
- strongMinMargin: 0.25
11343
- };
11344
- var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11345
- var OVERRIDE_MARGIN = 999;
11346
11280
 
11347
11281
  // src/utils/truncate.ts
11348
11282
  init_esm_shims();
@@ -11350,949 +11284,569 @@ function clampWithEllipsis(text, max) {
11350
11284
  return text.length > max ? `${text.slice(0, max)}\u2026` : text;
11351
11285
  }
11352
11286
 
11353
- // src/analysis/FailureClusterer.ts
11354
- var MAX_TESTS_PER_CLUSTER = 20;
11355
- var MAX_MESSAGE_CHARS = 200;
11356
- function errorHeader(message) {
11357
- const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11358
- return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11359
- }
11360
- function isAssertionTimeout(message) {
11361
- return /(?:Expect "[^"]+"|expect\.\w+) with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11362
- }
11363
- function isNetwork(message) {
11364
- 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));
11365
- }
11366
- function isNavigation(message) {
11367
- 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));
11368
- }
11369
- function isLocatorNotFound(message) {
11370
- 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));
11371
- }
11372
- var MESSAGE_RULES = [
11373
- [isAssertionTimeout, "assertion"],
11374
- [isNavigation, "navigation"],
11375
- [isNetwork, "network"],
11376
- [isLocatorNotFound, "locator-not-found"]
11377
- ];
11378
- function strengthOf(margin) {
11379
- if (margin >= STRENGTH.strongMinMargin) return "strong";
11380
- if (margin >= STRENGTH.moderateMinMargin) return "moderate";
11381
- return "weak";
11382
- }
11383
- function classifyInput(f) {
11384
- const head = (f.error.stack ?? "").split("\n").slice(0, 3).join(" ");
11385
- 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;
11386
11315
  }
11387
- function topFrame(stack) {
11388
- if (!stack) return void 0;
11389
- for (const line of stack.split("\n")) {
11390
- const t = line.trim();
11391
- if (t.startsWith("at ") && !t.replace(/\\/g, "/").includes("node_modules/playwright")) return t;
11392
- }
11393
- 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
+ };
11394
11331
  }
11395
- function representativeMessage(message) {
11396
- 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);
11397
11344
  }
11398
- function classifyOne(f, chain) {
11399
- const frame = topFrame(f.error.stack);
11400
- if (f.retryHistory.some((r) => r.status === "passed")) {
11401
- 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);
11402
11360
  }
11403
- for (const [match, category] of MESSAGE_RULES) {
11404
- if (match(f.error.message)) {
11405
- 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)];
11406
11368
  }
11407
11369
  }
11408
- if (chain.local) {
11409
- const { model, acceptThreshold, strongThreshold } = chain.local;
11410
- const r = classifyTokens(model, localInferenceTokens(f.error.message));
11411
- if (r.label !== "unknown" && r.margin >= acceptThreshold) {
11412
- const strength = strongThreshold !== null && r.margin >= strongThreshold ? "strong" : "moderate";
11413
- 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);
11414
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
+ });
11415
11388
  }
11416
- const { label, margin } = classify(chain.base, classifyInput(f));
11417
- return { category: label, margin, strength: strengthOf(margin), source: "base", failure: f, frame };
11418
- }
11419
- function classifyAll(failures, chain) {
11420
- return failures.map((f) => classifyOne(f, chain));
11389
+ return {
11390
+ steps,
11391
+ failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11392
+ };
11421
11393
  }
11422
- function perTestClassifications(classified) {
11423
- return classified.map((c) => ({
11424
- testId: c.failure.testId,
11425
- category: c.category,
11426
- message: representativeMessage(c.failure.error.message),
11427
- strength: c.strength
11428
- // resolved at classification time, per producing source
11429
- }));
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
+ };
11430
11412
  }
11431
- function clusterClassified(classified) {
11432
- const groups = /* @__PURE__ */ new Map();
11433
- for (const c of classified) {
11434
- const key = `${c.category}::${c.frame ?? "__no-stack__"}`;
11435
- const bucket = groups.get(key);
11436
- if (bucket) bucket.push(c);
11437
- 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;
11438
11421
  }
11439
- const rank = Object.fromEntries(
11440
- STRENGTH_LEVELS.map((level, i) => [level, i])
11441
- );
11442
- const clusters = [];
11443
- for (const members of groups.values()) {
11444
- const strongest = members.reduce((a, b) => rank[b.strength] > rank[a.strength] ? b : a);
11445
- clusters.push({
11446
- category: members[0].category,
11447
- strength: strongest.strength,
11448
- margin: Math.max(...members.map((m) => m.margin)),
11449
- count: members.length,
11450
- tests: members.slice(0, MAX_TESTS_PER_CLUSTER).map((m) => m.failure.testTitle),
11451
- testsTotal: members.length,
11452
- representativeMessage: representativeMessage(members[0].failure.error.message),
11453
- 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
11454
11431
  });
11432
+ if (steps.length > 0) capture.steps = steps;
11433
+ if (failingStep) capture.failingStep = failingStep;
11455
11434
  }
11456
- return clusters;
11457
- }
11458
-
11459
- // src/analysis/SummaryWriter.ts
11460
- init_esm_shims();
11461
- var CATEGORY_LABEL = {
11462
- assertion: "Assertion failure",
11463
- timeout: "Timeout",
11464
- "locator-not-found": "Element not found",
11465
- network: "Network error",
11466
- navigation: "Navigation error",
11467
- "env-config": "Environment / config",
11468
- "flaky-by-retry": "Flaky (passed on retry)",
11469
- unknown: "Unclassified"
11470
- };
11471
- var SHORT_LABEL = {
11472
- assertion: "assertion",
11473
- timeout: "timeout",
11474
- "locator-not-found": "element not found",
11475
- network: "network",
11476
- navigation: "navigation",
11477
- "env-config": "env/config",
11478
- "flaky-by-retry": "flaky",
11479
- unknown: "unclassified"
11480
- };
11481
- function tallyEntries(clusters) {
11482
- const byCategory = /* @__PURE__ */ new Map();
11483
- for (const c of clusters) {
11484
- 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;
11485
11438
  }
11486
- return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
11487
- }
11488
- function buildOneLiner(clusters) {
11489
- if (clusters.length === 0) return "";
11490
- const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
11491
- const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11492
- return [head, ...tally].join(" \xB7 ");
11493
- }
11494
- function buildFailureTally(analysis) {
11495
- if (!analysis) return "";
11496
- const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
11497
- return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
11498
- }
11499
- function dominantCategory(clusters) {
11500
- 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;
11501
11446
  }
11502
- function trendClause(passRate, delta) {
11503
- if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
11504
- const verb = delta > 0 ? "climbed" : "dropped";
11505
- const abs = Math.abs(delta);
11506
- const points = abs === 1 ? "point" : "points";
11507
- 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");
11508
11449
  }
11509
- function causesClause(analysis) {
11510
- const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
11511
- const verb = analysis.clusters.length === 1 ? "remains" : "remain";
11512
- return `${head} ${verb}: ${tallyParts.join(", ")}.`;
11450
+ function decodeShardChunk(c) {
11451
+ return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11513
11452
  }
11514
- function buildBriefSentence(stats, analysis, trend) {
11515
- if (trend && trend.delta !== null) {
11516
- const trendSentence = trendClause(stats.passRate, trend.delta);
11517
- return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
11518
- }
11519
- 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
+ );
11520
11464
  }
11521
11465
 
11522
- // src/analysis/UnclassifiedCollector.ts
11466
+ // src/collector/SuiteWalker.ts
11523
11467
  init_esm_shims();
11524
- import fs6 from "fs";
11525
- import os7 from "os";
11526
- import path7 from "path";
11527
11468
 
11528
- // src/analysis/feedback-csv.ts
11469
+ // src/collector/stats-utils.ts
11529
11470
  init_esm_shims();
11530
- var FEEDBACK_HEADER = "category,margin,label,text";
11531
- var LEGACY_FEEDBACK_HEADER = "category,margin,text";
11532
- function parseFeedbackRows(content) {
11533
- const lines = content.trim().split("\n").filter(Boolean);
11534
- if (lines.length === 0) return [];
11535
- const header = lines[0].trim();
11536
- const isV2 = header === FEEDBACK_HEADER;
11537
- if (!isV2 && header !== LEGACY_FEEDBACK_HEADER) return [];
11538
- const minParts = isV2 ? 4 : 3;
11539
- const rows = [];
11540
- for (const line of lines.slice(1)) {
11541
- const parts = line.split(",");
11542
- if (parts.length < minParts) continue;
11543
- 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(",") };
11544
- if (row.text.trim().length === 0) continue;
11545
- rows.push(row);
11546
- }
11547
- 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";
11548
11482
  }
11549
- function serializeFeedbackRow(r) {
11550
- const label = r.label.replace(/[,\r\n]/g, "").trim();
11551
- 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
+ };
11552
11492
  }
11553
- function feedbackIdentity(category, text) {
11554
- 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
+ );
11555
11505
  }
11556
-
11557
- // src/analysis/FeedbackStore.ts
11558
- init_esm_shims();
11559
- import fs5 from "fs";
11560
- import os6 from "os";
11561
- import path6 from "path";
11562
- var MARKER_FILENAME = "project.json";
11563
- function writeProjectMarker(projectDir, marker) {
11564
- try {
11565
- atomicWrite(path6.join(projectDir, MARKER_FILENAME), JSON.stringify(marker));
11566
- } catch {
11567
- }
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
+ };
11568
11514
  }
11569
- function rewriteFeedbackFile(file, rows) {
11570
- const body = rows.map(serializeFeedbackRow).join("\n");
11571
- atomicWrite(file, `${FEEDBACK_HEADER}
11572
- ${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 };
11573
11539
  }
11574
- function atomicWrite(file, content) {
11575
- fs5.mkdirSync(path6.dirname(file), { recursive: true });
11576
- const tmp = `${file}.${process.pid}.tmp`;
11577
- fs5.writeFileSync(tmp, content, "utf8");
11578
- 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);
11579
11549
  }
11580
11550
 
11581
- // src/analysis/UnclassifiedCollector.ts
11582
- var MAX_ROWS = 500;
11583
- var FILENAME = "unclassified.csv";
11584
- function shouldCollect(c, scope) {
11585
- if (c.source !== "base") return false;
11586
- if (c.category === "unknown" || c.margin <= STRENGTH.collectMaxMargin) return true;
11587
- return scope === "all";
11588
- }
11589
- function tildeify(p) {
11590
- const home = os7.homedir();
11591
- return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
11592
- }
11593
- function collectUnclassified(classified, projectDir, opts = {}) {
11594
- const scope = opts.scope ?? "blind-spots";
11595
- 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);
11596
- if (rows.length === 0) return;
11597
- const file = path7.join(projectDir, FILENAME);
11598
- const firstTime = !fs6.existsSync(file);
11599
- const byIdentity = /* @__PURE__ */ new Map();
11600
- if (!firstTime) {
11601
- for (const r of parseFeedbackRows(fs6.readFileSync(file, "utf8"))) {
11602
- byIdentity.set(feedbackIdentity(r.category, r.text), r);
11603
- }
11604
- }
11605
- for (const r of rows) {
11606
- const id = feedbackIdentity(r.category, r.text);
11607
- const label = byIdentity.get(id)?.label ?? "";
11608
- byIdentity.delete(id);
11609
- byIdentity.set(id, { category: r.category, margin: String(r.margin), label, text: r.text });
11610
- }
11611
- const all = evictOldestUnlabeledFirst([...byIdentity.values()], MAX_ROWS);
11612
- fs6.mkdirSync(projectDir, { recursive: true });
11613
- rewriteFeedbackFile(file, all);
11614
- if (opts.project) {
11615
- writeProjectMarker(projectDir, { cwd: opts.project.cwd, outputFile: opts.project.outputFile, updatedAt: Date.now() });
11616
- }
11617
- if (firstTime) {
11618
- logger.info(
11619
- `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)
11620
11556
  );
11621
11557
  }
11622
- }
11623
- function evictOldestUnlabeledFirst(rows, max) {
11624
- const excess = rows.length - max;
11625
- if (excess <= 0) return rows;
11626
- let unlabeledToDrop = Math.min(excess, rows.filter((r) => r.label === "").length);
11627
- let labeledToDrop = excess - unlabeledToDrop;
11628
- const out = [];
11629
- for (const r of rows) {
11630
- if (r.label === "" && unlabeledToDrop > 0) {
11631
- unlabeledToDrop--;
11632
- continue;
11633
- }
11634
- if (r.label !== "" && labeledToDrop > 0) {
11635
- labeledToDrop--;
11636
- continue;
11637
- }
11638
- out.push(r);
11639
- }
11640
- return out;
11641
- }
11642
-
11643
- // src/utils/paths.ts
11644
- init_esm_shims();
11645
- import os8 from "os";
11646
- import path8 from "path";
11647
- import crypto from "crypto";
11648
- function resolveProjectDir(options, cwd) {
11649
- const projectKey = crypto.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
11650
- return path8.join(os8.homedir(), ".reportforge", projectKey);
11651
- }
11652
-
11653
- // src/analysis/index.ts
11654
- var DEFAULTS = { maxClusters: 10, minStrength: "weak", maxFailuresToAnalyse: 500 };
11655
- var STRENGTH_RANK = Object.fromEntries(
11656
- STRENGTH_LEVELS.map((level, i) => [level, i])
11657
- );
11658
- function analyseClassified(classified, totalFailures, opts = {}) {
11659
- const maxClusters = opts.maxClusters ?? DEFAULTS.maxClusters;
11660
- const minRank = STRENGTH_RANK[opts.minStrength ?? DEFAULTS.minStrength];
11661
- const cap = opts.maxFailuresToAnalyse ?? DEFAULTS.maxFailuresToAnalyse;
11662
- const clusters = clusterClassified(classified).filter((c) => STRENGTH_RANK[c.strength] >= minRank).sort((a, b) => b.count - a.count).slice(0, maxClusters);
11663
- return {
11664
- clusters,
11665
- totalFailures,
11666
- analysedCount: classified.length,
11667
- truncated: totalFailures > cap,
11668
- // dominantCategory is part of the AnalysisResult contract; reserved for
11669
- // consumers (e.g. a future executive badge / notify summary). Not rendered
11670
- // by the v1 partials.
11671
- dominantCategory: dominantCategory(clusters),
11672
- oneLiner: buildOneLiner(clusters)
11673
- };
11674
- }
11675
- function templatesConsumeAnalysis(options) {
11676
- if (options.templatePath && options.templatePath.length > 0) return true;
11677
- const templates = Array.isArray(options.template) ? options.template : [options.template];
11678
- return templates.some((t) => t === "detailed" || t === "executive");
11679
- }
11680
- function runFailureAnalysis(reportData, options, cwd) {
11681
- const fa = options.failureAnalysis;
11682
- if (reportData.stats.failed === 0 || !fa.enabled) return;
11683
- const cap = fa.maxFailuresToAnalyse;
11684
- const analysed = reportData.failures.slice(0, cap);
11685
- const base = loadModel(fa.autoUpdateModel);
11686
- const local = fa.localModel ? loadLocalModel(fa.localModelPath) ?? void 0 : void 0;
11687
- if (local) {
11688
- logger.debug(
11689
- `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)
11690
11561
  );
11691
- if (local.meta.baseVersionAtEval !== base.version) {
11692
- logger.debug(
11693
- `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`
11694
- );
11695
- }
11562
+ const stats = aggregateStats(suites.map((s) => s.stats));
11563
+ return { name: projectSuite.title || "default", suites, stats };
11696
11564
  }
11697
- const classified = classifyAll(analysed, { base, local });
11698
- logger.debug(`failure analysis: ${classified.length} classified - ${sourceTally(classified)}`);
11699
- reportData.classifications = perTestClassifications(classified);
11700
- if (templatesConsumeAnalysis(options)) {
11701
- 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
+ };
11702
11580
  }
11703
- if (fa.collectUnclassified) {
11704
- collectUnclassified(classified, resolveProjectDir(options, cwd), {
11705
- scope: fa.collectScope,
11706
- project: { cwd, outputFile: options.outputFile }
11707
- });
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
+ };
11708
11595
  }
11709
- }
11710
- function sourceTally(classified) {
11711
- const counts = /* @__PURE__ */ new Map();
11712
- for (const c of classified) counts.set(c.source, (counts.get(c.source) ?? 0) + 1);
11713
- return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
11714
- }
11715
-
11716
- // src/collector/DataCollector.ts
11717
- init_esm_shims();
11596
+ };
11718
11597
 
11719
- // src/utils/strip-ansi.ts
11598
+ // src/collector/severity.ts
11720
11599
  init_esm_shims();
11721
- var ANSI_PATTERN = new RegExp(
11722
- [
11723
- "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
11724
- "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
11725
- ].join("|"),
11726
- "g"
11727
- );
11728
- function stripAnsi(input) {
11729
- 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";
11730
11618
  }
11731
11619
 
11732
- // src/collector/execution-capture.ts
11620
+ // src/utils/env.ts
11733
11621
  init_esm_shims();
11734
- var MAX_TITLE_CHARS = 200;
11735
- var DEFAULT_MAX_STEPS = 50;
11736
- var DEFAULT_MAX_CONSOLE_LINES = 50;
11737
- var isTeardown = (category) => category === "hook" || category === "fixture";
11738
- var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11739
- var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11740
- const c = flat[a].category;
11741
- return c === "pw:api" || c === "expect";
11742
- });
11743
- var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11744
- function flatten(steps) {
11745
- const out = [];
11746
- const walk = (nodes, depth, ancestors) => {
11747
- for (const s of nodes ?? []) {
11748
- const index = out.length;
11749
- out.push({
11750
- raw: s,
11751
- category: s.category ?? "",
11752
- depth,
11753
- hasError: s.error != null,
11754
- ancestors
11755
- });
11756
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11757
- }
11758
- };
11759
- walk(steps ?? [], 0, []);
11760
- return out;
11761
- }
11762
- function materialize(n, isFailing, depth) {
11763
- const loc = n.raw.location;
11764
- const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11765
- return {
11766
- title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11767
- category: n.category,
11768
- durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11769
- depth,
11770
- failed: n.hasError,
11771
- // Marks the single failing leaf so the template renders the error there once —
11772
- // independent of whether a step-level message survives (falls back to the record error).
11773
- failingLeaf: isFailing || void 0,
11774
- errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11775
- location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11776
- };
11777
- }
11778
- function pickFailingIdx(flat) {
11779
- const pick = (allowTeardown) => {
11780
- let idx = -1;
11781
- for (let i = 0; i < flat.length; i++) {
11782
- const f = flat[i];
11783
- if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11784
- if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11785
- }
11786
- return idx;
11787
- };
11788
- const body = pick(false);
11789
- return body >= 0 ? body : pick(true);
11790
- }
11791
- function compactSteps(rawSteps, opts = {}) {
11792
- const flat = flatten(rawSteps);
11793
- if (flat.length === 0) return { steps: [] };
11794
- const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11795
- const failingIdx = pickFailingIdx(flat);
11796
- const keep = /* @__PURE__ */ new Set();
11797
- flat.forEach((f, i) => {
11798
- if (inHookSubtree(flat, f)) return;
11799
- if (f.category === "test.step") keep.add(i);
11800
- else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11801
- else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11802
- });
11803
- if (failingIdx >= 0) {
11804
- keep.add(failingIdx);
11805
- 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
+ };
11806
11638
  }
11807
- let keepArr = [...keep].sort((a, b) => a - b);
11808
- let omitted = 0;
11809
- if (keepArr.length > maxSteps) {
11810
- omitted = keepArr.length - maxSteps;
11811
- keepArr = keepArr.slice(-maxSteps);
11812
- if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11813
- keepArr = [failingIdx, ...keepArr.slice(1)];
11814
- }
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
+ };
11815
11646
  }
11816
- const cache = /* @__PURE__ */ new Map();
11817
- const mat = (i) => {
11818
- let es = cache.get(i);
11819
- if (!es) {
11820
- es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11821
- cache.set(i, es);
11822
- }
11823
- return es;
11824
- };
11825
- const steps = keepArr.map(mat);
11826
- if (omitted > 0) {
11827
- steps.unshift({
11828
- title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11829
- category: "",
11830
- durationMs: 0,
11831
- depth: 0,
11832
- failed: false
11833
- });
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
+ };
11834
11686
  }
11835
11687
  return {
11836
- steps,
11837
- failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11838
- };
11839
- }
11840
- function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11841
- const tail = (chunks) => {
11842
- if (chunks.length === 0) return { lines: [], truncated: false };
11843
- const all = chunks.join("").split(/\r?\n/);
11844
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
11845
- const lines = all.slice(-maxLines).map(stripAnsi);
11846
- return { lines, truncated: all.length > lines.length };
11847
- };
11848
- const o = tail(stdout);
11849
- const e = tail(stderr);
11850
- if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11851
- return {
11852
- stdout: o.lines,
11853
- stderr: e.lines,
11854
- truncated: o.truncated || e.truncated,
11855
- stdoutTruncated: o.truncated,
11856
- stderrTruncated: e.truncated
11688
+ branch: gitBranch(),
11689
+ commit: gitCommit(),
11690
+ buildUrl: "",
11691
+ ciProvider: "local"
11857
11692
  };
11858
11693
  }
11859
- function extractEvidence(attachments) {
11860
- let tracePath;
11861
- let videoPath;
11862
- for (const a of attachments) {
11863
- if (!a.path) continue;
11864
- const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11865
- if (!tracePath && looksLikeTrace) tracePath = a.path;
11866
- 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);
11867
11712
  }
11868
- if (!tracePath && !videoPath) return void 0;
11869
- return { tracePath, videoPath };
11713
+ return null;
11870
11714
  }
11871
- function buildExecution(input, opts, source) {
11872
- const capture = {};
11873
- if (opts.steps) {
11874
- const { steps, failingStep } = compactSteps(input.steps, {
11875
- maxSteps: opts.maxSteps,
11876
- apiSteps: opts.apiSteps
11877
- });
11878
- if (steps.length > 0) capture.steps = steps;
11879
- if (failingStep) capture.failingStep = failingStep;
11880
- }
11881
- if (opts.console) {
11882
- const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11883
- if (console2) capture.console = console2;
11884
- }
11885
- if (opts.evidence) {
11886
- const evidence = extractEvidence(input.attachments);
11887
- 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;
11888
11719
  }
11889
- if (Object.keys(capture).length === 0) return void 0;
11890
- capture.source = source;
11891
- return capture;
11892
- }
11893
- function chunkToString(chunk) {
11894
- return typeof chunk === "string" ? chunk : chunk.toString("utf8");
11720
+ return void 0;
11895
11721
  }
11896
- function decodeShardChunk(c) {
11897
- 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
+ }
11898
11728
  }
11899
- function executionFromResult(result, opts) {
11900
- return buildExecution(
11901
- {
11902
- steps: result.steps,
11903
- stdout: result.stdout.map(chunkToString),
11904
- stderr: result.stderr.map(chunkToString),
11905
- attachments: result.attachments
11906
- },
11907
- opts,
11908
- "reporter"
11909
- );
11729
+ function gitCommit() {
11730
+ try {
11731
+ return execSync("git rev-parse --short HEAD", { stdio: "pipe" }).toString().trim();
11732
+ } catch {
11733
+ return "unknown";
11734
+ }
11910
11735
  }
11911
11736
 
11912
- // src/collector/SuiteWalker.ts
11913
- init_esm_shims();
11914
-
11915
- // src/collector/stats-utils.ts
11916
- init_esm_shims();
11917
- var SUITE_CHART_MAX_ROWS = 150;
11918
- function resolveTestStatus(test, result) {
11919
- if (!result) return "skipped";
11920
- const expected = test.expectedStatus ?? "passed";
11921
- const actual = result.status;
11922
- if (actual === "skipped") return "skipped";
11923
- if (actual === "timedOut") return "timedOut";
11924
- if (actual === "passed" && result.retry > 0) return "flaky";
11925
- if (actual === "passed" && expected === "passed") return "passed";
11926
- if (actual === "failed" && expected === "failed") return "passed";
11927
- return "failed";
11928
- }
11929
- function statsFromTests(tests) {
11930
- return {
11931
- total: tests.length,
11932
- passed: tests.filter((t) => t.status === "passed").length,
11933
- failed: tests.filter((t) => t.status === "failed").length,
11934
- timedOut: tests.filter((t) => t.status === "timedOut").length,
11935
- skipped: tests.filter((t) => t.status === "skipped").length,
11936
- flaky: tests.filter((t) => t.status === "flaky").length
11937
- };
11938
- }
11939
- function aggregateStats(parts) {
11940
- return parts.reduce(
11941
- (acc, s) => ({
11942
- total: acc.total + s.total,
11943
- passed: acc.passed + s.passed,
11944
- failed: acc.failed + s.failed,
11945
- timedOut: acc.timedOut + s.timedOut,
11946
- skipped: acc.skipped + s.skipped,
11947
- flaky: acc.flaky + s.flaky
11948
- }),
11949
- { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11950
- );
11951
- }
11952
- function toRow(s) {
11953
- return {
11954
- label: s.title,
11955
- passed: s.stats.passed,
11956
- failed: s.stats.failed,
11957
- timedOut: s.stats.timedOut,
11958
- skipped: s.stats.skipped
11959
- };
11960
- }
11961
- function capRows(rows, limit) {
11962
- if (rows.length <= limit) return { rows };
11963
- const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11964
- const scored = [...rows].sort(
11965
- (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11966
- );
11967
- const rest = scored.slice(limit);
11968
- const sums = rest.reduce(
11969
- (acc, r) => ({
11970
- passed: acc.passed + r.passed,
11971
- failed: acc.failed + r.failed,
11972
- timedOut: acc.timedOut + r.timedOut,
11973
- skipped: acc.skipped + r.skipped
11974
- }),
11975
- { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11976
- );
11977
- const parts = [
11978
- sums.failed > 0 ? `${sums.failed} failed` : "",
11979
- sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11980
- sums.passed > 0 ? `${sums.passed} passed` : "",
11981
- sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11982
- ].filter(Boolean);
11983
- const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11984
- return { rows: scored.slice(0, limit), overflowNote };
11985
- }
11986
- function buildSuiteResults(projects, limit) {
11987
- const top = projects.flatMap((p) => p.suites);
11988
- if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11989
- const file = top[0];
11990
- const fs16 = statsFromTests(file.tests);
11991
- const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs16.passed, failed: fs16.failed, timedOut: fs16.timedOut, skipped: fs16.skipped }] : [];
11992
- return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11993
- }
11994
- return capRows(top.map(toRow), limit);
11995
- }
11996
-
11997
- // src/collector/SuiteWalker.ts
11998
- var SuiteWalker = class {
11999
- build(rootSuite, resultMap) {
12000
- return rootSuite.suites.map(
12001
- (projectSuite) => this.buildProject(projectSuite, resultMap)
12002
- );
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();
12003
11749
  }
12004
- buildProject(projectSuite, resultMap) {
12005
- const suites = projectSuite.suites.map(
12006
- (s) => this.buildSuite(s, resultMap)
12007
- );
12008
- const stats = aggregateStats(suites.map((s) => s.stats));
12009
- return { name: projectSuite.title || "default", suites, stats };
11750
+ onBegin(config, rootSuite) {
11751
+ this.config = config;
11752
+ this.rootSuite = rootSuite;
11753
+ this.startTime = Date.now();
12010
11754
  }
12011
- buildSuite(suite, resultMap) {
12012
- const childSuites = suite.suites.map((s) => this.buildSuite(s, resultMap));
12013
- const tests = suite.tests.map((t) => this.buildTest(t, resultMap));
12014
- const stats = aggregateStats([
12015
- ...childSuites.map((s) => s.stats),
12016
- statsFromTests(tests)
12017
- ]);
12018
- return {
12019
- title: suite.title,
12020
- type: suite.type === "describe" ? "describe" : "file",
12021
- location: suite.location ? `${suite.location.file}:${suite.location.line}` : void 0,
12022
- suites: childSuites,
12023
- tests,
12024
- stats
12025
- };
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
+ }
12026
11772
  }
12027
- buildTest(test, resultMap) {
12028
- const result = resultMap.get(test.id);
12029
- 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);
12030
11786
  return {
12031
- id: test.id,
12032
- title: test.title,
12033
- fullTitle: test.titlePath().join(" > "),
12034
- status,
12035
- duration: result?.duration ?? 0,
12036
- retryCount: result?.retry ?? 0,
12037
- tags: test.tags ?? [],
12038
- annotations: test.annotations ?? [],
12039
- location: test.location ? `${test.location.file}:${test.location.line}` : void 0
11787
+ projects,
11788
+ stats,
11789
+ failures: sortBySeverity(this.failureRecords),
11790
+ environment,
11791
+ charts
12040
11792
  };
12041
11793
  }
12042
- };
12043
-
12044
- // src/collector/severity.ts
12045
- init_esm_shims();
12046
- var SEVERITY_RANK = {
12047
- critical: 0,
12048
- high: 1,
12049
- medium: 2,
12050
- low: 3
12051
- };
12052
- function sortBySeverity(failures) {
12053
- return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
12054
- }
12055
- function inferSeverity(annotations, tags) {
12056
- const allLabels = [
12057
- ...annotations.map((a) => a.type.toLowerCase()),
12058
- ...tags.map((t) => t.toLowerCase())
12059
- ];
12060
- if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
12061
- if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
12062
- if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
12063
- return "medium";
12064
- }
12065
-
12066
- // src/utils/env.ts
12067
- init_esm_shims();
12068
- import { execSync } from "child_process";
12069
- import { createHmac as createHmac2 } from "crypto";
12070
- var COMMIT_HASH_LENGTH = 8;
12071
- function detectCiEnv() {
12072
- const env = process.env;
12073
- if (env.GITHUB_ACTIONS === "true") {
12074
- const ghServer = env.GITHUB_SERVER_URL;
12075
- const ghRepo = env.GITHUB_REPOSITORY;
12076
- const ghRunId = env.GITHUB_RUN_ID;
12077
- 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 ?? []);
12078
11801
  return {
12079
- branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || "unknown",
12080
- commit: env.GITHUB_SHA?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12081
- buildUrl,
12082
- 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)
12083
11821
  };
12084
11822
  }
12085
- if (env.GITLAB_CI === "true") {
12086
- return {
12087
- branch: env.CI_COMMIT_REF_NAME || "unknown",
12088
- commit: env.CI_COMMIT_SHORT_SHA || "unknown",
12089
- buildUrl: env.CI_JOB_URL || "",
12090
- ciProvider: "GitLab CI"
12091
- };
12092
- }
12093
- if (env.JENKINS_URL) {
12094
- return {
12095
- branch: env.GIT_BRANCH?.replace("origin/", "") || env.BRANCH_NAME || "unknown",
12096
- commit: env.GIT_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12097
- buildUrl: env.BUILD_URL || "",
12098
- ciProvider: "Jenkins"
12099
- };
12100
- }
12101
- if (env.TF_BUILD === "True") {
12102
- const adoUri = env.SYSTEM_TEAMFOUNDATIONSERVERURI;
12103
- const adoProject = env.SYSTEM_TEAMPROJECT;
12104
- const adoBuildId = env.BUILD_BUILDID;
12105
- const buildUrl = adoUri && adoProject && adoBuildId ? `${adoUri}${adoProject}/_build/results?buildId=${adoBuildId}` : "";
12106
- return {
12107
- branch: env.BUILD_SOURCEBRANCH?.replace("refs/heads/", "") || "unknown",
12108
- commit: env.BUILD_SOURCEVERSION?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12109
- buildUrl,
12110
- ciProvider: "Azure DevOps"
12111
- };
12112
- }
12113
- if (env.CIRCLECI === "true") {
12114
- return {
12115
- branch: env.CIRCLE_BRANCH || "unknown",
12116
- commit: env.CIRCLE_SHA1?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12117
- buildUrl: env.CIRCLE_BUILD_URL || "",
12118
- ciProvider: "CircleCI"
12119
- };
12120
- }
12121
- if (env.BITBUCKET_BUILD_NUMBER) {
12122
- const workspace = env.BITBUCKET_WORKSPACE;
12123
- const repoSlug = env.BITBUCKET_REPO_SLUG;
12124
- const buildNumber = env.BITBUCKET_BUILD_NUMBER;
12125
- const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
12126
- return {
12127
- branch: env.BITBUCKET_BRANCH || "unknown",
12128
- commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12129
- buildUrl,
12130
- ciProvider: "Bitbucket Pipelines"
12131
- };
12132
- }
12133
- return {
12134
- branch: gitBranch(),
12135
- commit: gitCommit(),
12136
- buildUrl: "",
12137
- ciProvider: "local"
12138
- };
12139
- }
12140
- var CI_RUN_SOURCES = [
12141
- { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
12142
- { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
12143
- { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
12144
- { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
12145
- { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
12146
- { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
12147
- { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
12148
- ];
12149
- var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
12150
- function detectCiRunId() {
12151
- for (const src of CI_RUN_SOURCES) {
12152
- const runId = firstEnv(src.runKeys);
12153
- if (!runId) continue;
12154
- const repo = firstEnv(src.repoKeys) ?? "unknown";
12155
- const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
12156
- const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
12157
- return createHmac2("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
12158
- }
12159
- return null;
12160
- }
12161
- function firstEnv(keys) {
12162
- for (const k of keys) {
12163
- const v = process.env[k]?.trim();
12164
- if (v) return v;
12165
- }
12166
- return void 0;
12167
- }
12168
- function gitBranch() {
12169
- try {
12170
- return execSync("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
12171
- } catch {
12172
- return "unknown";
12173
- }
12174
- }
12175
- function gitCommit() {
12176
- try {
12177
- return execSync("git rev-parse --short HEAD", { stdio: "pipe" }).toString().trim();
12178
- } catch {
12179
- return "unknown";
12180
- }
12181
- }
12182
-
12183
- // src/collector/DataCollector.ts
12184
- var DataCollector = class {
12185
- /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
12186
- constructor(captureOpts = {}) {
12187
- this.captureOpts = captureOpts;
12188
- this.resultMap = /* @__PURE__ */ new Map();
12189
- // Parallel to resultMap — needed so computeStats() (which only sees
12190
- // TestResult, not TestCase) can reconcile against expectedStatus the same
12191
- // way resolveTestStatus does for the suite tree and the failure list.
12192
- this.expectedStatusMap = /* @__PURE__ */ new Map();
12193
- this.failureRecords = [];
12194
- this.startTime = Date.now();
12195
- }
12196
- onBegin(config, rootSuite) {
12197
- this.config = config;
12198
- this.rootSuite = rootSuite;
12199
- this.startTime = Date.now();
12200
- }
12201
- onTestEnd(test, result) {
12202
- this.resultMap.set(test.id, result);
12203
- this.expectedStatusMap.set(test.id, test.expectedStatus);
12204
- const resolved = resolveTestStatus(test, result);
12205
- const isRealFailure = resolved === "failed" || resolved === "timedOut";
12206
- if (isRealFailure) {
12207
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12208
- const record = this.buildFailureRecord(test, result);
12209
- if (existing >= 0) {
12210
- this.failureRecords[existing] = record;
12211
- } else {
12212
- this.failureRecords.push(record);
12213
- }
12214
- } else {
12215
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12216
- if (existing >= 0) this.failureRecords.splice(existing, 1);
12217
- }
12218
- }
12219
- /**
12220
- * Builds the final `ReportData` payload from all accumulated hook data.
12221
- *
12222
- * @param fullResult - Playwright's `FullResult` (contains `duration` + `status`).
12223
- * @returns Structured data for the PDF generator, including projects tree,
12224
- * aggregated stats, failures, environment info, and chart inputs.
12225
- */
12226
- finalize(fullResult) {
12227
- const walker = new SuiteWalker();
12228
- const projects = walker.build(this.rootSuite, this.resultMap);
12229
- const stats = this.computeStats(fullResult);
12230
- const environment = this.buildEnvironment();
12231
- const charts = this.buildChartData(stats, projects);
12232
- return {
12233
- projects,
12234
- stats,
12235
- failures: sortBySeverity(this.failureRecords),
12236
- environment,
12237
- charts
12238
- };
12239
- }
12240
- buildFailureRecord(test, result) {
12241
- const error = result.errors[0] ?? { message: "Unknown error" };
12242
- const screenshotAttachment = result.attachments.find(
12243
- (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
12244
- );
12245
- const suitePath = test.titlePath().slice(0, -1);
12246
- const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
12247
- return {
12248
- testId: test.id,
12249
- testTitle: test.title,
12250
- suitePath,
12251
- error: {
12252
- message: stripAnsi(error.message ?? String(error)),
12253
- stack: error.stack ? stripAnsi(error.stack) : void 0
12254
- },
12255
- screenshotPath: screenshotAttachment?.path,
12256
- screenshotBuffer: screenshotAttachment?.body,
12257
- retryHistory: [
12258
- {
12259
- attempt: result.retry,
12260
- status: result.status,
12261
- duration: result.duration
12262
- }
12263
- ],
12264
- durationMs: result.duration,
12265
- severity,
12266
- execution: executionFromResult(result, this.captureOpts)
12267
- };
12268
- }
12269
- computeStats(fullResult) {
12270
- let total = 0, passed = 0, failed = 0, skipped = 0, flaky = 0, timedOut = 0;
12271
- for (const [testId, result] of this.resultMap) {
12272
- total++;
12273
- const expectedStatus = this.expectedStatusMap.get(testId);
12274
- const resolved = resolveTestStatus({ expectedStatus }, result);
12275
- switch (resolved) {
12276
- case "passed":
12277
- passed++;
12278
- break;
12279
- case "flaky":
12280
- flaky++;
12281
- break;
12282
- case "failed":
12283
- failed++;
12284
- break;
12285
- case "timedOut":
12286
- timedOut++;
12287
- break;
12288
- // counted separately, not in failed
12289
- case "skipped":
12290
- skipped++;
12291
- break;
12292
- }
12293
- }
12294
- const duration = fullResult.duration ?? Date.now() - this.startTime;
12295
- 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;
12296
11850
  return {
12297
11851
  total,
12298
11852
  passed,
@@ -12579,7 +12133,9 @@ var ShardMerger = class {
12579
12133
  {
12580
12134
  // Guard each field — a partial / non-conformant / older shard JSON can
12581
12135
  // omit steps or the stdout/stderr arrays; an unguarded .map/iterate would
12582
- // 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).
12583
12139
  steps: lastResult.steps ?? [],
12584
12140
  stdout: (lastResult.stdout ?? []).map(decodeShardChunk),
12585
12141
  stderr: (lastResult.stderr ?? []).map(decodeShardChunk),
@@ -12595,13 +12151,16 @@ var ShardMerger = class {
12595
12151
  const browsers = [
12596
12152
  ...new Set(report.config.projects.map((p) => p.use?.browserName ?? p.name).filter(Boolean))
12597
12153
  ];
12598
- let playwrightVersion = "unknown";
12599
- try {
12600
- const req = createRequire(import.meta.url);
12601
- const pwPkg = req("@playwright/test/package.json");
12602
- playwrightVersion = pwPkg.version;
12603
- } 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
+ }
12604
12162
  }
12163
+ if (!playwrightVersion) playwrightVersion = "unknown";
12605
12164
  return {
12606
12165
  branch: ci.branch,
12607
12166
  commit: ci.commit,
@@ -12612,7 +12171,7 @@ var ShardMerger = class {
12612
12171
  nodeVersion: process.version,
12613
12172
  playwrightVersion,
12614
12173
  projectCount: report.config.projects.length,
12615
- workerCount: 1
12174
+ workerCount: report.config.workers ?? 1
12616
12175
  };
12617
12176
  }
12618
12177
  buildCharts(stats, projects) {
@@ -12621,8 +12180,9 @@ var ShardMerger = class {
12621
12180
  passRate: {
12622
12181
  passed: stats.passed,
12623
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).
12624
12185
  timedOut: stats.timedOut,
12625
- // always 0 in shard JSON (no separate timedOut count)
12626
12186
  skipped: stats.skipped,
12627
12187
  flaky: stats.flaky
12628
12188
  },
@@ -12634,14 +12194,14 @@ var ShardMerger = class {
12634
12194
 
12635
12195
  // src/pdf/PdfGenerator.ts
12636
12196
  init_esm_shims();
12637
- import fs14 from "fs";
12638
- import path13 from "path";
12197
+ import fs11 from "fs";
12198
+ import path9 from "path";
12639
12199
 
12640
12200
  // src/templates/engine.ts
12641
12201
  init_esm_shims();
12642
12202
  var import_handlebars = __toESM(require_lib());
12643
- import fs7 from "fs";
12644
- import path9 from "path";
12203
+ import fs4 from "fs";
12204
+ import path5 from "path";
12645
12205
 
12646
12206
  // src/utils/duration.ts
12647
12207
  init_esm_shims();
@@ -12655,11 +12215,74 @@ function formatDuration(ms) {
12655
12215
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
12656
12216
  }
12657
12217
 
12658
- // src/templates/engine.ts
12659
- function jsonForInlineScript(value) {
12660
- 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]);
12661
12246
  }
12662
- 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;
12663
12286
  var PAGE_MARGIN_Y_MM = 16;
12664
12287
  var PX_PER_MM = 96 / 25.4;
12665
12288
  var SUITE_CHART_SMALL_MAX = 10;
@@ -12702,9 +12325,9 @@ var TemplateEngine = class {
12702
12325
  this.customTemplateCache = /* @__PURE__ */ new Map();
12703
12326
  this.styleCache = /* @__PURE__ */ new Map();
12704
12327
  this.handlebars = import_handlebars.default.create();
12705
- this.templatesDir = path9.resolve(__dirname, "templates");
12706
- if (!fs7.existsSync(this.templatesDir)) {
12707
- 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");
12708
12331
  }
12709
12332
  this.registerHelpers();
12710
12333
  this.registerPartials();
@@ -12721,11 +12344,11 @@ var TemplateEngine = class {
12721
12344
  this.chartjsInline = inline;
12722
12345
  }
12723
12346
  render(data, template, opts = {}) {
12724
- const layoutPath = path9.join(this.templatesDir, "layouts", `${template}.hbs`);
12725
- if (!fs7.existsSync(layoutPath)) {
12347
+ const layoutPath = path5.join(this.templatesDir, "layouts", `${template}.hbs`);
12348
+ if (!fs4.existsSync(layoutPath)) {
12726
12349
  throw new Error(`[reportforge] Template layout not found: ${layoutPath}`);
12727
12350
  }
12728
- const layoutSource = fs7.readFileSync(layoutPath, "utf8");
12351
+ const layoutSource = fs4.readFileSync(layoutPath, "utf8");
12729
12352
  const compiledLayout = this.handlebars.compile(layoutSource);
12730
12353
  const ctx = this.assembleContext(
12731
12354
  data,
@@ -12737,10 +12360,10 @@ var TemplateEngine = class {
12737
12360
  }
12738
12361
  renderFromPath(data, filePath, opts = {}) {
12739
12362
  if (!this.customTemplateCache.has(filePath)) {
12740
- if (!fs7.existsSync(filePath)) {
12363
+ if (!fs4.existsSync(filePath)) {
12741
12364
  throw new Error(`[reportforge] Custom template not found: ${filePath}`);
12742
12365
  }
12743
- const source = fs7.readFileSync(filePath, "utf8");
12366
+ const source = fs4.readFileSync(filePath, "utf8");
12744
12367
  this.customTemplateCache.set(filePath, this.handlebars.compile(source));
12745
12368
  }
12746
12369
  const compiledFn = this.customTemplateCache.get(filePath);
@@ -12862,18 +12485,18 @@ var TemplateEngine = class {
12862
12485
  readStyle(filename) {
12863
12486
  const cached = this.styleCache.get(filename);
12864
12487
  if (cached !== void 0) return cached;
12865
- const stylePath = path9.join(this.templatesDir, "styles", filename);
12866
- 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") : "";
12867
12490
  this.styleCache.set(filename, css);
12868
12491
  return css;
12869
12492
  }
12870
12493
  registerPartials() {
12871
- const partialsDir = path9.join(this.templatesDir, "partials");
12872
- if (!fs7.existsSync(partialsDir)) return;
12873
- 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)) {
12874
12497
  if (!file.endsWith(".hbs")) continue;
12875
- const name = path9.basename(file, ".hbs");
12876
- 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");
12877
12500
  this.handlebars.registerPartial(name, src);
12878
12501
  }
12879
12502
  }
@@ -12978,8 +12601,21 @@ var TemplateEngine = class {
12978
12601
 
12979
12602
  // src/screenshots/ScreenshotEmbedder.ts
12980
12603
  init_esm_shims();
12981
- import fs8 from "fs";
12982
- 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
+ }
12983
12619
  var ScreenshotEmbedder = class {
12984
12620
  async embedAll(failures, opts) {
12985
12621
  await Promise.all(failures.map((f) => this.embed(f, opts)));
@@ -12987,7 +12623,7 @@ var ScreenshotEmbedder = class {
12987
12623
  async embed(failure, opts) {
12988
12624
  if (!failure.screenshotPath && !failure.screenshotBuffer) return;
12989
12625
  try {
12990
- const raw = failure.screenshotBuffer ?? await fs8.promises.readFile(failure.screenshotPath);
12626
+ const raw = failure.screenshotBuffer ?? await fs5.promises.readFile(failure.screenshotPath);
12991
12627
  const { buffer, mime } = opts.reencode ? await this.recompress(raw, opts) : { buffer: raw, mime: this.detectMimeType(raw) };
12992
12628
  failure.screenshotBase64 = `data:${mime};base64,${buffer.toString("base64")}`;
12993
12629
  } catch (e) {
@@ -12999,7 +12635,7 @@ var ScreenshotEmbedder = class {
12999
12635
  }
13000
12636
  async embedLogoFile(logoPath) {
13001
12637
  try {
13002
- const buffer = await fs8.promises.readFile(logoPath);
12638
+ const buffer = await fs5.promises.readFile(logoPath);
13003
12639
  const ext = logoPath.split(".").pop()?.toLowerCase();
13004
12640
  let mime = "image/png";
13005
12641
  if (ext === "jpg" || ext === "jpeg") mime = "image/jpeg";
@@ -13015,6 +12651,10 @@ var ScreenshotEmbedder = class {
13015
12651
  if (this.looksLikeSvg(buf)) {
13016
12652
  return { buffer: buf, mime: "image/svg+xml" };
13017
12653
  }
12654
+ const sharp = loadSharp();
12655
+ if (!sharp) {
12656
+ return { buffer: buf, mime: this.detectMimeType(buf) };
12657
+ }
13018
12658
  const img = sharp(buf, { failOn: "none" });
13019
12659
  const meta = await img.metadata();
13020
12660
  const needsResize = (meta.width ?? 0) > opts.maxWidth;
@@ -13044,7 +12684,7 @@ var ScreenshotEmbedder = class {
13044
12684
  // src/pdf/BrowserManager.ts
13045
12685
  init_esm_shims();
13046
12686
  import { execSync as execSync2 } from "child_process";
13047
- import fs9 from "fs";
12687
+ import fs6 from "fs";
13048
12688
  var LAUNCH_ARGS = [
13049
12689
  "--no-sandbox",
13050
12690
  "--disable-setuid-sandbox",
@@ -13076,13 +12716,13 @@ var BrowserManager = class {
13076
12716
  }
13077
12717
  }
13078
12718
  resolveChromePath(explicit, puppeteer) {
13079
- if (explicit && fs9.existsSync(explicit)) return explicit;
12719
+ if (explicit && fs6.existsSync(explicit)) return explicit;
13080
12720
  const envPath = process.env.PUPPETEER_EXECUTABLE_PATH;
13081
- if (envPath && fs9.existsSync(envPath)) return envPath;
12721
+ if (envPath && fs6.existsSync(envPath)) return envPath;
13082
12722
  try {
13083
12723
  const chromeFinder = require_lib2();
13084
12724
  const found = chromeFinder();
13085
- if (found && fs9.existsSync(found)) return found;
12725
+ if (found && fs6.existsSync(found)) return found;
13086
12726
  } catch (err) {
13087
12727
  logger.debug("chrome discovery: chrome-finder failed", { error: err.message });
13088
12728
  }
@@ -13094,20 +12734,20 @@ var BrowserManager = class {
13094
12734
  "/snap/bin/chromium"
13095
12735
  ];
13096
12736
  for (const p of linuxPaths) {
13097
- if (fs9.existsSync(p)) return p;
12737
+ if (fs6.existsSync(p)) return p;
13098
12738
  }
13099
12739
  try {
13100
12740
  const found = execSync2("which google-chrome chromium chromium-browser 2>/dev/null", {
13101
12741
  stdio: "pipe"
13102
12742
  }).toString().trim().split("\n")[0];
13103
- if (found && fs9.existsSync(found)) return found;
12743
+ if (found && fs6.existsSync(found)) return found;
13104
12744
  } catch {
13105
12745
  logger.debug("chrome discovery: which lookup found nothing");
13106
12746
  }
13107
12747
  if (puppeteer.executablePath) {
13108
12748
  try {
13109
12749
  const bundled = puppeteer.executablePath();
13110
- if (bundled && fs9.existsSync(bundled)) return bundled;
12750
+ if (bundled && fs6.existsSync(bundled)) return bundled;
13111
12751
  } catch (err) {
13112
12752
  logger.debug("chrome discovery: no puppeteer bundled executable", { error: err.message });
13113
12753
  }
@@ -13121,22 +12761,22 @@ var BrowserManager = class {
13121
12761
 
13122
12762
  // src/pdf/HtmlWriter.ts
13123
12763
  init_esm_shims();
13124
- import fs10 from "fs";
13125
- import os9 from "os";
13126
- import path10 from "path";
13127
- import crypto2 from "crypto";
12764
+ import fs7 from "fs";
12765
+ import os5 from "os";
12766
+ import path6 from "path";
12767
+ import crypto from "crypto";
13128
12768
  var HtmlWriter = class {
13129
12769
  async write(html) {
13130
- const tmpDir = os9.tmpdir();
13131
- const filename = `rf-report-${crypto2.randomBytes(8).toString("hex")}.html`;
13132
- this.tempPath = path10.join(tmpDir, filename);
13133
- 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");
13134
12774
  return this.tempPath;
13135
12775
  }
13136
12776
  async cleanup() {
13137
12777
  if (this.tempPath) {
13138
12778
  const tempPath = this.tempPath;
13139
- await fs10.promises.unlink(tempPath).catch(
12779
+ await fs7.promises.unlink(tempPath).catch(
13140
12780
  (err) => logger.debug("temp HTML cleanup failed (ignored)", { path: tempPath, error: err.message })
13141
12781
  );
13142
12782
  this.tempPath = void 0;
@@ -13170,14 +12810,14 @@ var ChartRenderer = class {
13170
12810
 
13171
12811
  // src/pdf/PdfEncryptor.ts
13172
12812
  init_esm_shims();
13173
- import fs11 from "fs";
12813
+ import fs8 from "fs";
13174
12814
  import { PDFDocument, PDFHeader } from "@cantoo/pdf-lib";
13175
12815
  var AES256_HEADER_MINOR = "7ext3";
13176
12816
  var PdfEncryptor = class {
13177
12817
  async encrypt(pdfPath, password) {
13178
12818
  if (!password) return;
13179
12819
  try {
13180
- const doc = await PDFDocument.load(await fs11.promises.readFile(pdfPath));
12820
+ const doc = await PDFDocument.load(await fs8.promises.readFile(pdfPath));
13181
12821
  doc.context.header = PDFHeader.forVersion(1, AES256_HEADER_MINOR);
13182
12822
  doc.encrypt({
13183
12823
  userPassword: password,
@@ -13187,10 +12827,10 @@ var PdfEncryptor = class {
13187
12827
  const encryptedBytes = await doc.save();
13188
12828
  const tmpPath = `${pdfPath}.rf-enc-tmp`;
13189
12829
  try {
13190
- await fs11.promises.writeFile(tmpPath, encryptedBytes);
13191
- await fs11.promises.rename(tmpPath, pdfPath);
12830
+ await fs8.promises.writeFile(tmpPath, encryptedBytes);
12831
+ await fs8.promises.rename(tmpPath, pdfPath);
13192
12832
  } finally {
13193
- fs11.rmSync(tmpPath, { force: true });
12833
+ fs8.rmSync(tmpPath, { force: true });
13194
12834
  }
13195
12835
  logger.info("PDF encrypted with password (AES-256).");
13196
12836
  } catch (e) {
@@ -13201,8 +12841,8 @@ var PdfEncryptor = class {
13201
12841
 
13202
12842
  // src/filename/FilenameResolver.ts
13203
12843
  init_esm_shims();
13204
- import path11 from "path";
13205
- import fs12 from "fs";
12844
+ import path7 from "path";
12845
+ import fs9 from "fs";
13206
12846
  var MAX_BRANCH_LENGTH = 50;
13207
12847
  var FilenameResolver = class {
13208
12848
  /**
@@ -13219,8 +12859,8 @@ var FilenameResolver = class {
13219
12859
  const branch = this.sanitiseBranch(ctx.branch);
13220
12860
  const status = ctx.status;
13221
12861
  const resolved = template.replace(/\{date\}/g, date).replace(/\{branch\}/g, branch).replace(/\{status\}/g, status);
13222
- const absolute = path11.isAbsolute(resolved) ? resolved : path11.resolve(process.cwd(), resolved);
13223
- 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 });
13224
12864
  return absolute;
13225
12865
  }
13226
12866
  /**
@@ -13232,7 +12872,7 @@ var FilenameResolver = class {
13232
12872
  * // → '/out/2026-report-executive.pdf'
13233
12873
  */
13234
12874
  injectTemplateSuffix(outputPath, suffix) {
13235
- const ext = path11.extname(outputPath);
12875
+ const ext = path7.extname(outputPath);
13236
12876
  const base = outputPath.slice(0, outputPath.length - ext.length);
13237
12877
  return `${base}-${suffix}${ext}`;
13238
12878
  }
@@ -13290,12 +12930,12 @@ var PRESETS = {
13290
12930
 
13291
12931
  // src/pdf/FailurePaginator.ts
13292
12932
  init_esm_shims();
13293
- import fs13 from "fs";
13294
- import path12 from "path";
12933
+ import fs10 from "fs";
12934
+ import path8 from "path";
13295
12935
  function sidecarPathFor(pdfPath) {
13296
- const dir = path12.dirname(pdfPath);
13297
- const base = path12.basename(pdfPath, path12.extname(pdfPath));
13298
- 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`);
13299
12939
  }
13300
12940
  var FailurePaginator = class {
13301
12941
  async paginate(failures, maxInline, pdfOutputPath) {
@@ -13306,13 +12946,13 @@ var FailurePaginator = class {
13306
12946
  const overflow = failures.slice(maxInline);
13307
12947
  const sidecarPath = sidecarPathFor(pdfOutputPath);
13308
12948
  try {
13309
- await fs13.promises.writeFile(
12949
+ await fs10.promises.writeFile(
13310
12950
  sidecarPath,
13311
12951
  JSON.stringify(this.serialize(overflow), null, 2),
13312
12952
  "utf8"
13313
12953
  );
13314
12954
  logger.info(
13315
- `Emitted ${overflow.length} overflow failures \u2192 ${path12.basename(sidecarPath)}`
12955
+ `Emitted ${overflow.length} overflow failures \u2192 ${path8.basename(sidecarPath)}`
13316
12956
  );
13317
12957
  } catch (e) {
13318
12958
  logger.warn(`Could not write failure sidecar: ${e.message}`);
@@ -13336,15 +12976,15 @@ var FailurePaginator = class {
13336
12976
 
13337
12977
  // src/pdf/sidecar-crypto.ts
13338
12978
  init_esm_shims();
13339
- import crypto3 from "crypto";
12979
+ import crypto2 from "crypto";
13340
12980
  var MAGIC = Buffer.from("RFENC1");
13341
12981
  var SALT_LEN = 16;
13342
12982
  var IV_LEN = 12;
13343
12983
  function encryptSidecar(plain, password) {
13344
- const salt = crypto3.randomBytes(SALT_LEN);
13345
- const key = crypto3.scryptSync(password, salt, 32, { N: 16384, r: 8, p: 1 });
13346
- const iv = crypto3.randomBytes(IV_LEN);
13347
- 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);
13348
12988
  const enc = Buffer.concat([cipher.update(plain), cipher.final()]);
13349
12989
  return Buffer.concat([MAGIC, salt, iv, cipher.getAuthTag(), enc]);
13350
12990
  }
@@ -13412,7 +13052,7 @@ var PdfGenerator = class {
13412
13052
  const sources = this.resolveTemplateSources(options);
13413
13053
  const isMulti = sources.length > 1;
13414
13054
  for (const source of sources) {
13415
- if (source.kind === "custom" && !fs14.existsSync(source.absolutePath)) {
13055
+ if (source.kind === "custom" && !fs11.existsSync(source.absolutePath)) {
13416
13056
  throw new Error(
13417
13057
  `[reportforge] Custom template not found: ${source.absolutePath}`
13418
13058
  );
@@ -13449,7 +13089,7 @@ var PdfGenerator = class {
13449
13089
  let { sidecarPath } = await this.renderPdf(templateData, templateOptions, source, compression, outputPath, page);
13450
13090
  const capBytes = (options.maxFileSizeMb ?? 0) * 1024 * 1024;
13451
13091
  if (capBytes > 0) {
13452
- const sizeBytes = (await fs14.promises.stat(outputPath)).size;
13092
+ const sizeBytes = (await fs11.promises.stat(outputPath)).size;
13453
13093
  if (sizeBytes > capBytes) {
13454
13094
  const inlinedCount = Math.min(templateData.failures.length, compression.maxInlineFailures);
13455
13095
  const tighter = this.tightenForCap(compression, sizeBytes, capBytes, inlinedCount);
@@ -13458,7 +13098,7 @@ var PdfGenerator = class {
13458
13098
  );
13459
13099
  const retuned = await this.renderPdf(templateData, templateOptions, source, tighter, outputPath, page);
13460
13100
  sidecarPath = retuned.sidecarPath ?? sidecarPath;
13461
- const finalSize = (await fs14.promises.stat(outputPath)).size;
13101
+ const finalSize = (await fs11.promises.stat(outputPath)).size;
13462
13102
  if (finalSize > capBytes) {
13463
13103
  logger.warn(
13464
13104
  `PDF still ${fmtMb(finalSize)} after retune. Consider lowering maxInlineFailures or splitting the report.`
@@ -13473,16 +13113,16 @@ var PdfGenerator = class {
13473
13113
  try {
13474
13114
  await this.pdfEncryptor.encrypt(outputPath, options.pdfPassword);
13475
13115
  pdfEncrypted = true;
13476
- if (sidecarPath && fs14.existsSync(sidecarPath)) {
13477
- const blob = encryptSidecar(await fs14.promises.readFile(sidecarPath), options.pdfPassword);
13478
- await fs14.promises.writeFile(`${sidecarPath}.enc`, blob);
13479
- await fs14.promises.unlink(sidecarPath);
13480
- 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`);
13481
13121
  } else {
13482
13122
  const staleSidecarPath = sidecarPathFor(outputPath);
13483
- if (fs14.existsSync(staleSidecarPath)) {
13484
- fs14.rmSync(staleSidecarPath, { force: true });
13485
- 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)}`);
13486
13126
  }
13487
13127
  }
13488
13128
  } catch (err) {
@@ -13490,7 +13130,7 @@ var PdfGenerator = class {
13490
13130
  const cleanupTargets = pdfEncrypted ? sidecarTargets : [outputPath, ...sidecarTargets];
13491
13131
  for (const target of cleanupTargets) {
13492
13132
  try {
13493
- fs14.rmSync(target, { force: true });
13133
+ fs11.rmSync(target, { force: true });
13494
13134
  } catch (cleanupErr) {
13495
13135
  logger.warn(
13496
13136
  `Could not remove leftover file after encryption failure: ${target} (${cleanupErr.message})`
@@ -13500,7 +13140,7 @@ var PdfGenerator = class {
13500
13140
  throw err;
13501
13141
  }
13502
13142
  }
13503
- const finalSizeMb = fmtMb((await fs14.promises.stat(outputPath)).size);
13143
+ const finalSizeMb = fmtMb((await fs11.promises.stat(outputPath)).size);
13504
13144
  logger.info(`PDF report generated: ${outputPath} (${finalSizeMb})`);
13505
13145
  outputPaths.push(outputPath);
13506
13146
  }
@@ -13513,17 +13153,17 @@ var PdfGenerator = class {
13513
13153
  if (options.templatePath && options.templatePath.length > 0) {
13514
13154
  const byAbsolutePath = /* @__PURE__ */ new Map();
13515
13155
  for (const p of options.templatePath) {
13516
- const absolutePath = path13.isAbsolute(p) ? p : path13.resolve(process.cwd(), p);
13156
+ const absolutePath = path9.isAbsolute(p) ? p : path9.resolve(process.cwd(), p);
13517
13157
  if (!byAbsolutePath.has(absolutePath)) byAbsolutePath.set(absolutePath, p);
13518
13158
  }
13519
13159
  const labelCounts = /* @__PURE__ */ new Map();
13520
13160
  for (const rawPath of byAbsolutePath.values()) {
13521
- const label = path13.basename(rawPath, ".hbs");
13161
+ const label = path9.basename(rawPath, ".hbs");
13522
13162
  labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
13523
13163
  }
13524
13164
  const seenPerLabel = /* @__PURE__ */ new Map();
13525
13165
  return Array.from(byAbsolutePath.entries()).map(([absolutePath, rawPath]) => {
13526
- const baseLabel = path13.basename(rawPath, ".hbs");
13166
+ const baseLabel = path9.basename(rawPath, ".hbs");
13527
13167
  let label = baseLabel;
13528
13168
  if ((labelCounts.get(baseLabel) ?? 0) > 1) {
13529
13169
  const n = (seenPerLabel.get(baseLabel) ?? 0) + 1;
@@ -13536,154 +13176,565 @@ var PdfGenerator = class {
13536
13176
  return { kind: "custom", absolutePath, label };
13537
13177
  });
13538
13178
  }
13539
- const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13540
- return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
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);
13626
+ }
13541
13627
  }
13542
- resolveSettings(options, data) {
13543
- return resolveCompression({
13544
- level: options.compressionLevel ?? "auto",
13545
- testCount: data.stats.total,
13546
- failureCount: data.failures.length,
13547
- inlineFailuresOverride: options.maxInlineFailures
13548
- });
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 });
13549
13633
  }
13550
- /**
13551
- * Builds retune settings sized against the cap using *measured* bytes from
13552
- * the previous pass. The per-failure cost in a PDF includes more than just
13553
- * the JPEG payload — page layout, font glyph rendering, and per-page chrome
13554
- * all scale with failure count. Compute observed bytes per inline failure,
13555
- * then pick an inline count that fits the cap with a 15% safety margin.
13556
- * Also tightens JPEG quality and width to claw back bytes from each
13557
- * remaining image.
13558
- */
13559
- tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13560
- const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13561
- const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13562
- return {
13563
- effective: "max",
13564
- reencode: true,
13565
- quality: 55,
13566
- maxWidth: 800,
13567
- maxInlineFailures: Math.min(prev.maxInlineFailures, targetInline)
13568
- };
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() });
13569
13639
  }
13570
- /**
13571
- * One full render pass: paginate failures, re-embed screenshots at the
13572
- * target compression, render HTML, PDF the page, write to outputPath.
13573
- * Separated from generateAll() so the size-cap retune can reuse it with
13574
- * stricter settings without reshaping the rest of the flow.
13575
- */
13576
- async renderPdf(data, options, source, compression, outputPath, page) {
13577
- const paginated = await this.failurePaginator.paginate(
13578
- data.failures,
13579
- compression.maxInlineFailures,
13580
- 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`
13581
13643
  );
13582
- const renderData = {
13583
- ...data,
13584
- failures: paginated.inline,
13585
- overflowFailureCount: paginated.overflowCount,
13586
- overflowSidecarName: paginated.sidecarPath ? path13.basename(paginated.sidecarPath) + (options.pdfPassword ? ".enc" : "") : void 0
13587
- };
13588
- for (const f of renderData.failures) f.screenshotBase64 = void 0;
13589
- if (options.includeScreenshots !== false) {
13590
- await this.screenshotEmbedder.embedAll(renderData.failures, {
13591
- reencode: compression.reencode,
13592
- quality: compression.quality,
13593
- maxWidth: compression.maxWidth
13594
- });
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;
13595
13656
  }
13596
- const templateLabel = source.kind === "builtin" ? source.id : source.label;
13597
- logger.info(`Rendering template: ${templateLabel}`);
13598
- const renderOpts = {
13599
- sections: options.sections,
13600
- slowTestThreshold: options.slowTestThreshold,
13601
- requirementTagPattern: options.requirementTagPattern
13602
- };
13603
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, renderOpts) : this.templateEngine.renderFromPath(renderData, source.absolutePath, renderOpts);
13604
- const tempHtmlPath = await this.htmlWriter.write(html);
13605
- const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
13606
- try {
13607
- logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
13608
- await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13609
- const showCharts = source.kind === "builtin" ? resolveSections(source.id, options.sections).charts : true;
13610
- await this.chartRenderer.waitForCharts(page, showCharts);
13611
- logger.info(`Generating PDF \u2192 ${path13.relative(process.cwd(), outputPath)}`);
13612
- await page.pdf({
13613
- path: outputPath,
13614
- format: "A4",
13615
- printBackground: true,
13616
- preferCSSPageSize: true
13617
- });
13618
- } finally {
13619
- await this.htmlWriter.cleanup();
13657
+ if (r.label !== "" && labeledToDrop > 0) {
13658
+ labeledToDrop--;
13659
+ continue;
13620
13660
  }
13621
- return { sidecarPath: paginated.overflowCount > 0 ? paginated.sidecarPath : void 0 };
13661
+ out.push(r);
13622
13662
  }
13623
- };
13624
- function fmtMb(bytes) {
13625
- return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
13663
+ return out;
13626
13664
  }
13627
13665
 
13628
- // src/redact/Redactor.ts
13666
+ // src/utils/paths.ts
13629
13667
  init_esm_shims();
13630
- var Redactor = class {
13631
- constructor(opts) {
13632
- /** Indexes of user patterns disabled after throwing (warned once each). */
13633
- this.disabled = /* @__PURE__ */ new Set();
13634
- this.maskRaw = opts.mask;
13635
- this.maskReplacement = opts.mask.replace(/\$/g, "$$$$");
13636
- this.useBuiltins = opts.builtins;
13637
- this.userPatterns = opts.patterns.map((p) => new RegExp(p, "g"));
13638
- }
13639
- redactText(s) {
13640
- if (!s) return s;
13641
- let out = s;
13642
- if (this.useBuiltins) out = this.applyBuiltins(out);
13643
- for (let i = 0; i < this.userPatterns.length; i++) {
13644
- if (this.disabled.has(i)) continue;
13645
- try {
13646
- out = out.replace(this.userPatterns[i], this.maskReplacement);
13647
- } catch (err) {
13648
- this.disabled.add(i);
13649
- logger.warn(
13650
- `redact: custom pattern #${i + 1} threw and is disabled for this run: ${err.message}`
13651
- );
13652
- }
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
+ );
13653
13718
  }
13654
- return out;
13655
13719
  }
13656
- applyBuiltins(s) {
13657
- const M = this.maskRaw;
13658
- const MR = this.maskReplacement;
13659
- return s.replace(/(\b[a-z][\w+.-]{0,63}:\/\/[^/\s:@]{1,512}:)([^@\s/]{1,512})(?=@)/gi, `$1${MR}`).replace(
13660
- /\b(?:(authorization\s*[:=]\s*basic\s+)([A-Za-z0-9._~+/=-]{8,})|((?:authorization\s*[:=]\s*)?bearer\s+)([A-Za-z0-9._~+/=-]{8,}))/gi,
13661
- (fullMatch, basicPrefix, _basicToken, bearerPrefix, bearerToken) => {
13662
- if (basicPrefix) return `${basicPrefix}${M}`;
13663
- if (bearerToken && /[^a-z]/.test(bearerToken)) return `${bearerPrefix}${M}`;
13664
- return fullMatch;
13665
- }
13666
- ).replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, MR).replace(
13667
- /\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,
13668
- MR
13669
- ).replace(
13670
- /\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,
13671
- (_m, key, sep, q, _quotedVal, _plainVal) => q ? `${key}${sep}${q}${M}${q}` : `${key}${sep}${M}`
13672
- ).replace(
13673
- /\b(fill|type|press)\b([^\n]{0,80}?\b(?:password|secret|token|pass|passcode|pin|otp)\b[^\n]{0,80}?\s)(["'])((?:(?!\3).)+)\3/gi,
13674
- (_m, action, mid, q) => `${action}${mid}${q}${M}${q}`
13675
- ).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) => {
13676
- const before = offset > 0 ? str[offset - 1] : " ";
13677
- const after = offset + m.length < str.length ? str[offset + m.length] : " ";
13678
- const beforeIsWord = /[A-Za-z0-9_]/.test(before);
13679
- const afterIsWord = /[A-Za-z0-9_]/.test(after);
13680
- if (!beforeIsWord && !afterIsWord && /\d/.test(m) && /[A-Za-z]/.test(m)) {
13681
- return M;
13682
- }
13683
- 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 }
13684
13730
  });
13685
13731
  }
13686
- };
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
+ }
13687
13738
 
13688
13739
  // src/redact/redact-report.ts
13689
13740
  init_esm_shims();
@@ -13734,9 +13785,6 @@ function redactSuite(s, r) {
13734
13785
  s.suites.forEach((child) => redactSuite(child, r));
13735
13786
  }
13736
13787
 
13737
- // src/reporter.ts
13738
- import path15 from "path";
13739
-
13740
13788
  // src/history/HistoryManager.ts
13741
13789
  init_esm_shims();
13742
13790
  import fs15 from "fs";
@@ -14321,6 +14369,261 @@ var NotificationSender = class {
14321
14369
  }
14322
14370
  };
14323
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
+
14324
14627
  // src/live/LiveStreamer.ts
14325
14628
  init_esm_shims();
14326
14629
  var NETWORK_TIMEOUT_MS3 = 8e3;
@@ -14941,7 +15244,6 @@ function chunkToString2(chunk) {
14941
15244
  }
14942
15245
 
14943
15246
  // src/reporter.ts
14944
- var DEFAULT_SERVER_URL2 = "https://reportforge.org";
14945
15247
  var LICENSE_STALE_MARGIN_MS = 5 * 60 * 1e3;
14946
15248
  var PdfReporter = class {
14947
15249
  constructor(rawOptions = {}) {
@@ -14956,7 +15258,7 @@ var PdfReporter = class {
14956
15258
  if (this.options.redact.enabled) {
14957
15259
  this.redactor = new Redactor(this.options.redact);
14958
15260
  }
14959
- this.version = true ? "0.26.1" : "0.x";
15261
+ this.version = true ? "0.28.0" : "0.x";
14960
15262
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14961
15263
  this.licenseClient = new LicenseClient({
14962
15264
  licenseKey: this.options.licenseKey,
@@ -15027,44 +15329,19 @@ var PdfReporter = class {
15027
15329
  }
15028
15330
  }
15029
15331
  async onEnd(result) {
15030
- const licenseInfo = await this.freshLicense();
15031
- if (!licenseInfo) return;
15032
- const collected = this._collectData(result);
15033
- if (!collected) {
15034
- maybePrintUpdateNotice(licenseInfo, this.version);
15035
- return;
15036
- }
15037
- if (collected.stats.total === 0) {
15038
- logger.warn("No tests were executed \u2014 check your --grep / project filters. The report has nothing to assess.");
15039
- }
15040
- if (this.options.showTrend && collected.stats.total > 0) {
15041
- try {
15042
- await this._appendTrend(collected, licenseInfo);
15043
- } catch (err) {
15044
- logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
15045
- }
15046
- }
15047
- const reportData = this._buildReportData(collected, licenseInfo);
15048
- runFailureAnalysis(reportData, this.options, this.cwd);
15049
- reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
15050
- if (this.redactor) {
15051
- redactReportData(reportData, this.redactor);
15052
- }
15053
- let pdfPaths = [];
15054
- try {
15055
- pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);
15056
- if (this.options.open) {
15057
- for (const pdfPath of pdfPaths) await this.openPdf(pdfPath);
15058
- }
15059
- } catch (err) {
15060
- logger.error(`Failed to generate PDF report: ${err.message}`);
15061
- if (process.env.RF_DEBUG) console.error(err);
15062
- }
15063
- if (this.options.notify) {
15064
- const sender = new NotificationSender();
15065
- await sender.sendAll(reportData, this.options.notify, pdfPaths);
15066
- }
15067
- 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
+ });
15068
15345
  }
15069
15346
  _collectData(result) {
15070
15347
  if (this.options.shardResults && this.options.shardResults.length > 0) {
@@ -15077,94 +15354,6 @@ var PdfReporter = class {
15077
15354
  }
15078
15355
  return this.dataCollector.finalize(result);
15079
15356
  }
15080
- async _appendTrend(collected, licenseInfo) {
15081
- const historyPath = resolveHistoryPath(this.options, this.cwd);
15082
- const hm = new HistoryManager(historyPath);
15083
- const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
15084
- const redactor = this.redactor;
15085
- const flakyTests = collectFlakyTests(collected.projects).map(
15086
- (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15087
- );
15088
- const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
15089
- const entry = {
15090
- runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
15091
- timestamp: Date.now(),
15092
- passRate: collected.stats.passRate,
15093
- total: collected.stats.total,
15094
- passed: collected.stats.passed,
15095
- failed: collected.stats.failed,
15096
- flaky: collected.stats.flaky,
15097
- duration: collected.stats.duration,
15098
- verdict: deriveVerdict(collected.stats),
15099
- flakyTests,
15100
- ...failedTestsFields,
15101
- branch: collected.environment.branch
15102
- };
15103
- const localEntries = hm.append(entry, this.options.historySize);
15104
- collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
15105
- let trendEntries = localEntries;
15106
- if (this.options.remoteHistory && licenseInfo.jwt) {
15107
- const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
15108
- const remote = await appendRemoteHistory({
15109
- serverUrl,
15110
- jwt: licenseInfo.jwt,
15111
- projectId: buildProjectId(this.options.projectName, this.options.outputFile),
15112
- branchHash: buildBranchHash(collected.environment.branch),
15113
- entry
15114
- });
15115
- if (remote) {
15116
- trendEntries = remote;
15117
- logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
15118
- }
15119
- }
15120
- this._populateHistoryCharts(trendEntries, localEntries, collected);
15121
- }
15122
- _populateHistoryCharts(trendEntries, localEntries, collected) {
15123
- if (trendEntries.length >= 2) {
15124
- collected.charts.trend = {
15125
- entries: trendEntries.map((e) => ({
15126
- runId: e.runId,
15127
- timestamp: e.timestamp,
15128
- passRate: e.passRate,
15129
- total: e.total,
15130
- duration: e.duration,
15131
- verdict: e.verdict
15132
- })),
15133
- delta: trendEntries[0].passRate - trendEntries[1].passRate
15134
- };
15135
- }
15136
- if (this.options.flakinessTopN > 0) {
15137
- const table = computeTopN(localEntries, this.options.flakinessTopN);
15138
- if (table.length > 0) {
15139
- collected.charts.flakinessTable = table;
15140
- }
15141
- }
15142
- }
15143
- _buildReportData(collected, licenseInfo) {
15144
- const { projects, stats, failures, environment, charts, runDiff } = collected;
15145
- const projectName = this.options.projectName ?? this.resolveProjectName();
15146
- const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
15147
- return {
15148
- meta: {
15149
- title: this.options.reportTitle,
15150
- generatedAt: /* @__PURE__ */ new Date(),
15151
- template: firstTemplate,
15152
- branding: {
15153
- primaryColor: this.options.primaryColor,
15154
- accentColor: this.options.accentColor,
15155
- watermark: this.options.watermark
15156
- },
15157
- licenseInfo,
15158
- projectName
15159
- },
15160
- stats,
15161
- projects,
15162
- failures,
15163
- environment,
15164
- charts,
15165
- runDiff
15166
- };
15167
- }
15168
15357
  printsToStdio() {
15169
15358
  return false;
15170
15359
  }
@@ -15220,7 +15409,7 @@ var PdfReporter = class {
15220
15409
  setupLive(config) {
15221
15410
  const live = this.options.live;
15222
15411
  const runId = deriveRunId({ runId: live.runId });
15223
- 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;
15224
15413
  const shard = config.shard ?? null;
15225
15414
  this.liveShardKey = shardKeyOf(shard);
15226
15415
  this.liveSteps = live.steps;
@@ -15292,121 +15481,7 @@ var PdfReporter = class {
15292
15481
  await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
15293
15482
  }
15294
15483
  }
15295
- resolveProjectName() {
15296
- try {
15297
- const pkgPath = path15.resolve(process.cwd(), "package.json");
15298
- const pkg = __require(pkgPath);
15299
- return pkg.name ?? "Playwright Tests";
15300
- } catch {
15301
- return "Playwright Tests";
15302
- }
15303
- }
15304
- async openPdf(pdfPath) {
15305
- try {
15306
- const { existsSync } = await import("fs");
15307
- if (!existsSync(pdfPath)) {
15308
- logger.warn(`open: true \u2014 PDF not found at ${pdfPath}; file may not have been written yet.`);
15309
- return;
15310
- }
15311
- const { spawn } = await import("child_process");
15312
- const platform = process.platform;
15313
- const [cmd, args] = platform === "darwin" ? ["open", [pdfPath]] : platform === "win32" ? ["cmd", ["/c", "start", "", pdfPath]] : ["xdg-open", [pdfPath]];
15314
- const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
15315
- child.on("error", (err) => {
15316
- logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
15317
- });
15318
- child.unref();
15319
- if (platform === "win32") {
15320
- child.on("close", (code) => {
15321
- if (code !== 0 && code !== null) {
15322
- logger.warn(
15323
- `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}`
15324
- );
15325
- }
15326
- });
15327
- }
15328
- } catch (err) {
15329
- logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
15330
- }
15331
- }
15332
15484
  };
15333
- function deriveVerdict(stats) {
15334
- if (stats.total === 0) return "unknown";
15335
- if (stats.verdict === "interrupted" || stats.verdict === "timedout") return "failed";
15336
- if (stats.failed === 0 && stats.timedOut === 0) {
15337
- if (stats.passed === 0 && stats.flaky === 0) return "unknown";
15338
- return "passed";
15339
- }
15340
- if (stats.failed / stats.total > 0.5) return "failed";
15341
- if (stats.timedOut > 0) return "failed";
15342
- if (stats.failed > 0) return "partial";
15343
- return "unknown";
15344
- }
15345
- function resolveHistoryPath(options, cwd) {
15346
- if (options.historyFile) {
15347
- return path15.isAbsolute(options.historyFile) ? options.historyFile : path15.resolve(cwd, options.historyFile);
15348
- }
15349
- return path15.join(resolveProjectDir(options, cwd), "history.json");
15350
- }
15351
- function flattenSuiteNodes(suites) {
15352
- return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
15353
- }
15354
- function collectFlakyTests(projects) {
15355
- const seen = /* @__PURE__ */ new Set();
15356
- const result = [];
15357
- for (const project of projects) {
15358
- for (const suite of flattenSuiteNodes(project.suites)) {
15359
- for (const test of suite.tests) {
15360
- if (test.status === "flaky" && !seen.has(test.id)) {
15361
- seen.add(test.id);
15362
- result.push({ id: test.id, title: test.fullTitle });
15363
- }
15364
- }
15365
- }
15366
- }
15367
- return result;
15368
- }
15369
- function collectFailedTests(projects) {
15370
- const seen = /* @__PURE__ */ new Set();
15371
- const result = [];
15372
- for (const project of projects) {
15373
- for (const suite of flattenSuiteNodes(project.suites)) {
15374
- for (const test of suite.tests) {
15375
- if ((test.status === "failed" || test.status === "timedOut") && !seen.has(test.id)) {
15376
- seen.add(test.id);
15377
- result.push({ id: test.id, title: test.fullTitle });
15378
- }
15379
- }
15380
- }
15381
- }
15382
- return result;
15383
- }
15384
- function collectCurrentStatuses(projects) {
15385
- const statuses = /* @__PURE__ */ new Map();
15386
- for (const project of projects) {
15387
- for (const suite of flattenSuiteNodes(project.suites)) {
15388
- for (const test of suite.tests) {
15389
- statuses.set(test.id, test.status);
15390
- }
15391
- }
15392
- }
15393
- return statuses;
15394
- }
15395
- function buildFailedTestsFields(projects, redactor) {
15396
- const redactedFailing = collectFailedTests(projects).map(
15397
- (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15398
- );
15399
- const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
15400
- return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
15401
- }
15402
- function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
15403
- return computeRunDiff({
15404
- currentFailing,
15405
- currentStatuses: collectCurrentStatuses(projects),
15406
- priorEntries,
15407
- branch
15408
- }) ?? void 0;
15409
- }
15410
15485
 
15411
15486
  // src/index.ts
15412
15487
  function defineReporterConfig(options) {