@reportforge/playwright-pdf 0.25.0 → 0.27.0

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