@reportforge/playwright-pdf 0.26.1 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11008,8 +11008,22 @@ function acceptDeliveredModel(d) {
11008
11008
  }
11009
11009
  }
11010
11010
 
11011
- // 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();
11012
11024
  var DEFAULT_SERVER_URL = "https://reportforge.org";
11025
+
11026
+ // src/license/LicenseClient.ts
11013
11027
  var REFRESH_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
11014
11028
  var NETWORK_TIMEOUT_MS = 8e3;
11015
11029
  var TERMINAL = /* @__PURE__ */ Symbol("terminal");
@@ -11047,9 +11061,16 @@ var LicenseClient = class {
11047
11061
  return toLicenseInfo(cacheValid, cached, normalized, "jwt-cache");
11048
11062
  }
11049
11063
  const network = await this.tryNetwork(cacheValid ? cached : null, normalized, verifier);
11050
- if (network === TERMINAL) return null;
11064
+ if (network === TERMINAL) {
11065
+ await settleWindowsTlsTeardown();
11066
+ return null;
11067
+ }
11051
11068
  if (network) return network;
11052
- return this._offlineFallback(cacheValid, normalized, nowMs);
11069
+ const fallback = this._offlineFallback(cacheValid, normalized, nowMs);
11070
+ if (!fallback) {
11071
+ await settleWindowsTlsTeardown();
11072
+ }
11073
+ return fallback;
11053
11074
  }
11054
11075
  _validateKey(key) {
11055
11076
  const parsed = this.parser.parse(key);
@@ -11237,111 +11258,24 @@ function normalizeUrl(url) {
11237
11258
  return url.endsWith("/") ? url.slice(0, -1) : url;
11238
11259
  }
11239
11260
 
11240
- // src/license/update-notice.ts
11241
- init_cjs_shims();
11242
-
11243
- // src/license/version.ts
11244
- init_cjs_shims();
11245
- function parseTriple(v) {
11246
- if (!v) return null;
11247
- const m = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(v.trim());
11248
- if (!m) return null;
11249
- return [Number(m[1]), Number(m[2]), Number(m[3])];
11250
- }
11251
- function isNewer(latest, current) {
11252
- const l = parseTriple(latest);
11253
- const c = parseTriple(current);
11254
- if (!l || !c) return false;
11255
- if (l[0] !== c[0]) return l[0] > c[0];
11256
- if (l[1] !== c[1]) return l[1] > c[1];
11257
- return l[2] > c[2];
11258
- }
11259
-
11260
- // src/license/update-notice.ts
11261
- function maybePrintUpdateNotice(licenseInfo, currentVersion) {
11262
- try {
11263
- const latest = licenseInfo?.latestVersion;
11264
- if (!latest || !isNewer(latest, currentVersion)) return;
11265
- const shown = latest.replace(/[^\w.+-]/g, "");
11266
- logger.info(
11267
- `Update available: ${currentVersion} -> ${shown}. Run: npm i -D @reportforge/playwright-pdf@latest`
11268
- );
11269
- } catch (err) {
11270
- logger.debug("update notice failed (ignored)", { error: err.message });
11271
- }
11272
- }
11273
-
11274
- // src/analysis/index.ts
11275
- init_cjs_shims();
11276
-
11277
- // src/analysis/LocalModelStore.ts
11278
- init_cjs_shims();
11279
- var import_node_fs2 = __toESM(require("fs"));
11280
- var import_node_os2 = __toESM(require("os"));
11281
- var import_node_path2 = __toESM(require("path"));
11282
- var testDeps2 = null;
11283
- function defaultLocalModelPath() {
11284
- return testDeps2?.file ?? import_node_path2.default.join(import_node_os2.default.homedir(), ".reportforge", "model-local.json");
11285
- }
11286
- function loadLocalModel(overridePath) {
11287
- const file = overridePath ?? defaultLocalModelPath();
11288
- const explicit = overridePath !== void 0;
11289
- const reject = (why) => {
11290
- const msg = `local model at ${file} not used: ${why}`;
11291
- if (explicit) logger.warn(msg);
11292
- else logger.debug(msg);
11293
- return null;
11294
- };
11295
- let parsed;
11296
- try {
11297
- parsed = JSON.parse(import_node_fs2.default.readFileSync(file, "utf8"));
11298
- } catch (e) {
11299
- return explicit ? reject(`unreadable (${e.message})`) : null;
11300
- }
11301
- if (parsed.formatVersion !== 1) return reject(`unsupported formatVersion ${String(parsed.formatVersion)}`);
11302
- if (!isValidNaiveBayesModel(parsed.model)) return reject("embedded model failed schema validation");
11303
- const meta = parsed.meta;
11304
- if (!meta || typeof meta.gatePassed !== "boolean") return reject("missing meta");
11305
- if (!meta.gatePassed) return reject("its evaluation gate did not pass - run train-model after labeling more feedback");
11306
- if (typeof meta.acceptThreshold !== "number") return reject("gate passed but no calibrated threshold (corrupt meta)");
11307
- return {
11308
- model: parsed.model,
11309
- acceptThreshold: meta.acceptThreshold,
11310
- strongThreshold: typeof meta.strongThreshold === "number" ? meta.strongThreshold : null,
11311
- meta
11312
- };
11313
- }
11314
-
11315
- // src/analysis/FailureClusterer.ts
11261
+ // src/collector/DataCollector.ts
11316
11262
  init_cjs_shims();
11317
11263
 
11318
- // src/analysis/redact.ts
11264
+ // src/utils/strip-ansi.ts
11319
11265
  init_cjs_shims();
11320
- var URI_RE = /[a-z][\w+.-]*:\/\/\S+/gi;
11321
- var EMAIL_RE = /\b[\w.+-]+@[\w.-]+\.\w+\b/g;
11322
- var SECRET_RE = /\b(?=[A-Za-z0-9_+/=-]*[0-9])(?=[A-Za-z0-9_+/=-]*[A-Za-z])[A-Za-z0-9_+/=-]{16,}\b/g;
11323
- var IP_RE = /\b\d{1,3}(?:\.\d{1,3}){3}\b/g;
11324
- var WIN_PATH_RE = /[A-Za-z]:\\[^\s'"]+/g;
11325
- var UNIX_PATH_RE = /(?:\/[\w.-]+){2,}/g;
11326
- function redactForStorage(raw) {
11327
- 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 ");
11328
- return tokenize(redacted).join(" ");
11329
- }
11330
- function localInferenceTokens(message) {
11331
- 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, "");
11332
11275
  }
11333
11276
 
11334
- // src/analysis/constants.ts
11277
+ // src/collector/execution-capture.ts
11335
11278
  init_cjs_shims();
11336
- var STRENGTH = {
11337
- unknownMaxMargin: 0.05,
11338
- collectMaxMargin: 0.1,
11339
- harvestMinMargin: 0.25,
11340
- moderateMinMargin: 0.1,
11341
- strongMinMargin: 0.25
11342
- };
11343
- var STRENGTH_LEVELS = ["weak", "moderate", "strong"];
11344
- var OVERRIDE_MARGIN = 999;
11345
11279
 
11346
11280
  // src/utils/truncate.ts
11347
11281
  init_cjs_shims();
@@ -11349,920 +11283,540 @@ function clampWithEllipsis(text, max) {
11349
11283
  return text.length > max ? `${text.slice(0, max)}\u2026` : text;
11350
11284
  }
11351
11285
 
11352
- // src/analysis/FailureClusterer.ts
11353
- var MAX_TESTS_PER_CLUSTER = 20;
11354
- var MAX_MESSAGE_CHARS = 200;
11355
- function errorHeader(message) {
11356
- const beforeCallLog = message.split(/\n\s*Call log:/i)[0];
11357
- return beforeCallLog.split("\n").filter((line) => !/^\s*[-+]?\s*(?:Expected|Received)\b/i.test(line)).join("\n");
11358
- }
11359
- function isAssertionTimeout(message) {
11360
- return /(?:Expect "[^"]+"|expect\.\w+) with timeout/.test(message) && /resolved to [1-9]\d* element/.test(message);
11361
- }
11362
- function isNetwork(message) {
11363
- 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));
11364
- }
11365
- function isNavigation(message) {
11366
- 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));
11367
- }
11368
- function isLocatorNotFound(message) {
11369
- 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));
11370
- }
11371
- var MESSAGE_RULES = [
11372
- [isAssertionTimeout, "assertion"],
11373
- [isNavigation, "navigation"],
11374
- [isNetwork, "network"],
11375
- [isLocatorNotFound, "locator-not-found"]
11376
- ];
11377
- function strengthOf(margin) {
11378
- if (margin >= STRENGTH.strongMinMargin) return "strong";
11379
- if (margin >= STRENGTH.moderateMinMargin) return "moderate";
11380
- return "weak";
11381
- }
11382
- function classifyInput(f) {
11383
- const head = (f.error.stack ?? "").split("\n").slice(0, 3).join(" ");
11384
- return `${f.error.message} ${head}`;
11385
- }
11386
- function topFrame(stack) {
11387
- if (!stack) return void 0;
11388
- for (const line of stack.split("\n")) {
11389
- const t = line.trim();
11390
- if (t.startsWith("at ") && !t.replace(/\\/g, "/").includes("node_modules/playwright")) return t;
11391
- }
11392
- 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;
11393
11314
  }
11394
- function representativeMessage(message) {
11395
- 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
+ };
11396
11330
  }
11397
- function classifyOne(f, chain) {
11398
- const frame = topFrame(f.error.stack);
11399
- if (f.retryHistory.some((r) => r.status === "passed")) {
11400
- return { category: "flaky-by-retry", margin: OVERRIDE_MARGIN, strength: "strong", source: "retry", failure: f, frame };
11401
- }
11402
- for (const [match, category] of MESSAGE_RULES) {
11403
- if (match(f.error.message)) {
11404
- 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;
11405
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);
11406
11359
  }
11407
- if (chain.local) {
11408
- const { model, acceptThreshold, strongThreshold } = chain.local;
11409
- const r = classifyTokens(model, localInferenceTokens(f.error.message));
11410
- if (r.label !== "unknown" && r.margin >= acceptThreshold) {
11411
- const strength = strongThreshold !== null && r.margin >= strongThreshold ? "strong" : "moderate";
11412
- 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)];
11413
11367
  }
11414
11368
  }
11415
- const { label, margin } = classify(chain.base, classifyInput(f));
11416
- return { category: label, margin, strength: strengthOf(margin), source: "base", failure: f, frame };
11417
- }
11418
- function classifyAll(failures, chain) {
11419
- 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
+ };
11420
11392
  }
11421
- function perTestClassifications(classified) {
11422
- return classified.map((c) => ({
11423
- testId: c.failure.testId,
11424
- category: c.category,
11425
- message: representativeMessage(c.failure.error.message),
11426
- strength: c.strength
11427
- // resolved at classification time, per producing source
11428
- }));
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
+ };
11429
11411
  }
11430
- function clusterClassified(classified) {
11431
- const groups = /* @__PURE__ */ new Map();
11432
- for (const c of classified) {
11433
- const key = `${c.category}::${c.frame ?? "__no-stack__"}`;
11434
- const bucket = groups.get(key);
11435
- if (bucket) bucket.push(c);
11436
- 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;
11437
11420
  }
11438
- const rank = Object.fromEntries(
11439
- STRENGTH_LEVELS.map((level, i) => [level, i])
11440
- );
11441
- const clusters = [];
11442
- for (const members of groups.values()) {
11443
- const strongest = members.reduce((a, b) => rank[b.strength] > rank[a.strength] ? b : a);
11444
- clusters.push({
11445
- category: members[0].category,
11446
- strength: strongest.strength,
11447
- margin: Math.max(...members.map((m) => m.margin)),
11448
- count: members.length,
11449
- tests: members.slice(0, MAX_TESTS_PER_CLUSTER).map((m) => m.failure.testTitle),
11450
- testsTotal: members.length,
11451
- representativeMessage: representativeMessage(members[0].failure.error.message),
11452
- 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
11453
11430
  });
11431
+ if (steps.length > 0) capture.steps = steps;
11432
+ if (failingStep) capture.failingStep = failingStep;
11454
11433
  }
11455
- return clusters;
11456
- }
11457
-
11458
- // src/analysis/SummaryWriter.ts
11459
- init_cjs_shims();
11460
- var CATEGORY_LABEL = {
11461
- assertion: "Assertion failure",
11462
- timeout: "Timeout",
11463
- "locator-not-found": "Element not found",
11464
- network: "Network error",
11465
- navigation: "Navigation error",
11466
- "env-config": "Environment / config",
11467
- "flaky-by-retry": "Flaky (passed on retry)",
11468
- unknown: "Unclassified"
11469
- };
11470
- var SHORT_LABEL = {
11471
- assertion: "assertion",
11472
- timeout: "timeout",
11473
- "locator-not-found": "element not found",
11474
- network: "network",
11475
- navigation: "navigation",
11476
- "env-config": "env/config",
11477
- "flaky-by-retry": "flaky",
11478
- unknown: "unclassified"
11479
- };
11480
- function tallyEntries(clusters) {
11481
- const byCategory = /* @__PURE__ */ new Map();
11482
- for (const c of clusters) {
11483
- 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;
11484
11437
  }
11485
- return [...byCategory.entries()].sort((a, b) => b[1] - a[1]);
11486
- }
11487
- function buildOneLiner(clusters) {
11488
- if (clusters.length === 0) return "";
11489
- const head = `${clusters.length} root ${clusters.length === 1 ? "cause" : "causes"}`;
11490
- const tally = tallyEntries(clusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`);
11491
- return [head, ...tally].join(" \xB7 ");
11492
- }
11493
- function buildFailureTally(analysis) {
11494
- if (!analysis) return "";
11495
- const failureClusters = analysis.clusters.filter((c) => c.category !== "flaky-by-retry");
11496
- return tallyEntries(failureClusters).map(([cat, count]) => `${count} ${SHORT_LABEL[cat]}`).join(", ");
11497
- }
11498
- function dominantCategory(clusters) {
11499
- 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;
11500
11445
  }
11501
- function trendClause(passRate, delta) {
11502
- if (delta === 0) return `Pass rate held steady at ${passRate}%.`;
11503
- const verb = delta > 0 ? "climbed" : "dropped";
11504
- const abs = Math.abs(delta);
11505
- const points = abs === 1 ? "point" : "points";
11506
- 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");
11507
11448
  }
11508
- function causesClause(analysis) {
11509
- const [head, ...tallyParts] = buildOneLiner(analysis.clusters).split(" \xB7 ");
11510
- const verb = analysis.clusters.length === 1 ? "remains" : "remain";
11511
- return `${head} ${verb}: ${tallyParts.join(", ")}.`;
11449
+ function decodeShardChunk(c) {
11450
+ return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11512
11451
  }
11513
- function buildBriefSentence(stats, analysis, trend) {
11514
- if (trend && trend.delta !== null) {
11515
- const trendSentence = trendClause(stats.passRate, trend.delta);
11516
- return analysis && analysis.clusters.length > 0 ? `${trendSentence} ${causesClause(analysis)}` : trendSentence;
11517
- }
11518
- 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
+ );
11519
11463
  }
11520
11464
 
11521
- // src/analysis/UnclassifiedCollector.ts
11465
+ // src/collector/SuiteWalker.ts
11522
11466
  init_cjs_shims();
11523
- var import_node_fs4 = __toESM(require("fs"));
11524
- var import_node_os4 = __toESM(require("os"));
11525
- var import_node_path4 = __toESM(require("path"));
11526
11467
 
11527
- // src/analysis/feedback-csv.ts
11468
+ // src/collector/stats-utils.ts
11528
11469
  init_cjs_shims();
11529
- var FEEDBACK_HEADER = "category,margin,label,text";
11530
- var LEGACY_FEEDBACK_HEADER = "category,margin,text";
11531
- function parseFeedbackRows(content) {
11532
- const lines = content.trim().split("\n").filter(Boolean);
11533
- if (lines.length === 0) return [];
11534
- const header = lines[0].trim();
11535
- const isV2 = header === FEEDBACK_HEADER;
11536
- if (!isV2 && header !== LEGACY_FEEDBACK_HEADER) return [];
11537
- const minParts = isV2 ? 4 : 3;
11538
- const rows = [];
11539
- for (const line of lines.slice(1)) {
11540
- const parts = line.split(",");
11541
- if (parts.length < minParts) continue;
11542
- 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(",") };
11543
- if (row.text.trim().length === 0) continue;
11544
- rows.push(row);
11545
- }
11546
- return rows;
11547
- }
11548
- function serializeFeedbackRow(r) {
11549
- const label = r.label.replace(/[,\r\n]/g, "").trim();
11550
- 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";
11551
11481
  }
11552
- function feedbackIdentity(category, text) {
11553
- 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
+ };
11554
11491
  }
11555
-
11556
- // src/analysis/FeedbackStore.ts
11557
- init_cjs_shims();
11558
- var import_node_fs3 = __toESM(require("fs"));
11559
- var import_node_os3 = __toESM(require("os"));
11560
- var import_node_path3 = __toESM(require("path"));
11561
- var MARKER_FILENAME = "project.json";
11562
- function writeProjectMarker(projectDir, marker) {
11563
- try {
11564
- atomicWrite(import_node_path3.default.join(projectDir, MARKER_FILENAME), JSON.stringify(marker));
11565
- } catch {
11566
- }
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
+ );
11567
11504
  }
11568
- function rewriteFeedbackFile(file, rows) {
11569
- const body = rows.map(serializeFeedbackRow).join("\n");
11570
- atomicWrite(file, `${FEEDBACK_HEADER}
11571
- ${body}${body ? "\n" : ""}`);
11572
- }
11573
- function atomicWrite(file, content) {
11574
- import_node_fs3.default.mkdirSync(import_node_path3.default.dirname(file), { recursive: true });
11575
- const tmp = `${file}.${process.pid}.tmp`;
11576
- import_node_fs3.default.writeFileSync(tmp, content, "utf8");
11577
- import_node_fs3.default.renameSync(tmp, file);
11578
- }
11579
-
11580
- // src/analysis/UnclassifiedCollector.ts
11581
- var MAX_ROWS = 500;
11582
- var FILENAME = "unclassified.csv";
11583
- function shouldCollect(c, scope) {
11584
- if (c.source !== "base") return false;
11585
- if (c.category === "unknown" || c.margin <= STRENGTH.collectMaxMargin) return true;
11586
- return scope === "all";
11587
- }
11588
- function tildeify(p) {
11589
- const home = import_node_os4.default.homedir();
11590
- 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
+ };
11591
11513
  }
11592
- function collectUnclassified(classified, projectDir, opts = {}) {
11593
- const scope = opts.scope ?? "blind-spots";
11594
- 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);
11595
- if (rows.length === 0) return;
11596
- const file = import_node_path4.default.join(projectDir, FILENAME);
11597
- const firstTime = !import_node_fs4.default.existsSync(file);
11598
- const byIdentity = /* @__PURE__ */ new Map();
11599
- if (!firstTime) {
11600
- for (const r of parseFeedbackRows(import_node_fs4.default.readFileSync(file, "utf8"))) {
11601
- byIdentity.set(feedbackIdentity(r.category, r.text), r);
11602
- }
11603
- }
11604
- for (const r of rows) {
11605
- const id = feedbackIdentity(r.category, r.text);
11606
- const label = byIdentity.get(id)?.label ?? "";
11607
- byIdentity.delete(id);
11608
- byIdentity.set(id, { category: r.category, margin: String(r.margin), label, text: r.text });
11609
- }
11610
- const all = evictOldestUnlabeledFirst([...byIdentity.values()], MAX_ROWS);
11611
- import_node_fs4.default.mkdirSync(projectDir, { recursive: true });
11612
- rewriteFeedbackFile(file, all);
11613
- if (opts.project) {
11614
- writeProjectMarker(projectDir, { cwd: opts.project.cwd, outputFile: opts.project.outputFile, updatedAt: Date.now() });
11615
- }
11616
- if (firstTime) {
11617
- logger.info(
11618
- `Unrecognized failures collected locally at ${tildeify(file)} to improve offline classification. Local only - never sent anywhere. Disable: failureAnalysis.collectUnclassified=false`
11619
- );
11620
- }
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 };
11621
11538
  }
11622
- function evictOldestUnlabeledFirst(rows, max) {
11623
- const excess = rows.length - max;
11624
- if (excess <= 0) return rows;
11625
- let unlabeledToDrop = Math.min(excess, rows.filter((r) => r.label === "").length);
11626
- let labeledToDrop = excess - unlabeledToDrop;
11627
- const out = [];
11628
- for (const r of rows) {
11629
- if (r.label === "" && unlabeledToDrop > 0) {
11630
- unlabeledToDrop--;
11631
- continue;
11632
- }
11633
- if (r.label !== "" && labeledToDrop > 0) {
11634
- labeledToDrop--;
11635
- continue;
11636
- }
11637
- 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);
11638
11546
  }
11639
- return out;
11640
- }
11641
-
11642
- // src/utils/paths.ts
11643
- init_cjs_shims();
11644
- var import_node_os5 = __toESM(require("os"));
11645
- var import_node_path5 = __toESM(require("path"));
11646
- var import_node_crypto2 = __toESM(require("crypto"));
11647
- function resolveProjectDir(options, cwd) {
11648
- const projectKey = import_node_crypto2.default.createHash("sha256").update(cwd + (options.outputFile ?? "")).digest("hex").slice(0, 8);
11649
- return import_node_path5.default.join(import_node_os5.default.homedir(), ".reportforge", projectKey);
11547
+ return capRows(top.map(toRow), limit);
11650
11548
  }
11651
11549
 
11652
- // src/analysis/index.ts
11653
- var DEFAULTS = { maxClusters: 10, minStrength: "weak", maxFailuresToAnalyse: 500 };
11654
- var STRENGTH_RANK = Object.fromEntries(
11655
- STRENGTH_LEVELS.map((level, i) => [level, i])
11656
- );
11657
- function analyseClassified(classified, totalFailures, opts = {}) {
11658
- const maxClusters = opts.maxClusters ?? DEFAULTS.maxClusters;
11659
- const minRank = STRENGTH_RANK[opts.minStrength ?? DEFAULTS.minStrength];
11660
- const cap = opts.maxFailuresToAnalyse ?? DEFAULTS.maxFailuresToAnalyse;
11661
- const clusters = clusterClassified(classified).filter((c) => STRENGTH_RANK[c.strength] >= minRank).sort((a, b) => b.count - a.count).slice(0, maxClusters);
11662
- return {
11663
- clusters,
11664
- totalFailures,
11665
- analysedCount: classified.length,
11666
- truncated: totalFailures > cap,
11667
- // dominantCategory is part of the AnalysisResult contract; reserved for
11668
- // consumers (e.g. a future executive badge / notify summary). Not rendered
11669
- // by the v1 partials.
11670
- dominantCategory: dominantCategory(clusters),
11671
- oneLiner: buildOneLiner(clusters)
11672
- };
11673
- }
11674
- function templatesConsumeAnalysis(options) {
11675
- if (options.templatePath && options.templatePath.length > 0) return true;
11676
- const templates = Array.isArray(options.template) ? options.template : [options.template];
11677
- return templates.some((t) => t === "detailed" || t === "executive");
11678
- }
11679
- function runFailureAnalysis(reportData, options, cwd) {
11680
- const fa = options.failureAnalysis;
11681
- if (reportData.stats.failed === 0 || !fa.enabled) return;
11682
- const cap = fa.maxFailuresToAnalyse;
11683
- const analysed = reportData.failures.slice(0, cap);
11684
- const base = loadModel(fa.autoUpdateModel);
11685
- const local = fa.localModel ? loadLocalModel(fa.localModelPath) ?? void 0 : void 0;
11686
- if (local) {
11687
- logger.debug(
11688
- `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)
11689
11555
  );
11690
- if (local.meta.baseVersionAtEval !== base.version) {
11691
- logger.debug(
11692
- `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`
11693
- );
11694
- }
11695
11556
  }
11696
- const classified = classifyAll(analysed, { base, local });
11697
- logger.debug(`failure analysis: ${classified.length} classified - ${sourceTally(classified)}`);
11698
- reportData.classifications = perTestClassifications(classified);
11699
- if (templatesConsumeAnalysis(options)) {
11700
- 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 };
11701
11563
  }
11702
- if (fa.collectUnclassified) {
11703
- collectUnclassified(classified, resolveProjectDir(options, cwd), {
11704
- scope: fa.collectScope,
11705
- project: { cwd, outputFile: options.outputFile }
11706
- });
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
+ };
11707
11579
  }
11708
- }
11709
- function sourceTally(classified) {
11710
- const counts = /* @__PURE__ */ new Map();
11711
- for (const c of classified) counts.set(c.source, (counts.get(c.source) ?? 0) + 1);
11712
- return ["rule", "retry", "local", "base"].filter((s) => counts.has(s)).map((s) => `${counts.get(s)} ${s}`).join(" / ");
11713
- }
11714
-
11715
- // src/collector/DataCollector.ts
11716
- 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
+ };
11717
11596
 
11718
- // src/utils/strip-ansi.ts
11597
+ // src/collector/severity.ts
11719
11598
  init_cjs_shims();
11720
- var ANSI_PATTERN = new RegExp(
11721
- [
11722
- "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
11723
- "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
11724
- ].join("|"),
11725
- "g"
11726
- );
11727
- function stripAnsi(input) {
11728
- 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";
11729
11617
  }
11730
11618
 
11731
- // src/collector/execution-capture.ts
11619
+ // src/utils/env.ts
11732
11620
  init_cjs_shims();
11733
- var MAX_TITLE_CHARS = 200;
11734
- var DEFAULT_MAX_STEPS = 50;
11735
- var DEFAULT_MAX_CONSOLE_LINES = 50;
11736
- var isTeardown = (category) => category === "hook" || category === "fixture";
11737
- var inHookSubtree = (flat, f) => isTeardown(f.category) || f.ancestors.some((a) => isTeardown(flat[a].category));
11738
- var isTopLevel = (flat, f) => !f.ancestors.some((a) => {
11739
- const c = flat[a].category;
11740
- return c === "pw:api" || c === "expect";
11741
- });
11742
- var testStepDepth = (flat, f) => f.ancestors.reduce((n, a) => n + (flat[a].category === "test.step" ? 1 : 0), 0);
11743
- function flatten(steps) {
11744
- const out = [];
11745
- const walk = (nodes, depth, ancestors) => {
11746
- for (const s of nodes ?? []) {
11747
- const index = out.length;
11748
- out.push({
11749
- raw: s,
11750
- category: s.category ?? "",
11751
- depth,
11752
- hasError: s.error != null,
11753
- ancestors
11754
- });
11755
- if (s.steps?.length) walk(s.steps, depth + 1, [...ancestors, index]);
11756
- }
11757
- };
11758
- walk(steps ?? [], 0, []);
11759
- return out;
11760
- }
11761
- function materialize(n, isFailing, depth) {
11762
- const loc = n.raw.location;
11763
- const firstLine = isFailing && n.raw.error?.message ? stripAnsi(n.raw.error.message).split("\n").map((s) => s.trim()).find((s) => s.length) ?? "" : "";
11764
- return {
11765
- title: clampWithEllipsis(n.raw.title ?? "", MAX_TITLE_CHARS),
11766
- category: n.category,
11767
- durationMs: Math.max(0, Math.round(n.raw.duration ?? 0)),
11768
- depth,
11769
- failed: n.hasError,
11770
- // Marks the single failing leaf so the template renders the error there once —
11771
- // independent of whether a step-level message survives (falls back to the record error).
11772
- failingLeaf: isFailing || void 0,
11773
- errorMessage: firstLine ? clampWithEllipsis(firstLine, MAX_TITLE_CHARS) : void 0,
11774
- location: loc?.file != null ? `${loc.file}:${loc.line ?? 0}` : void 0
11775
- };
11776
- }
11777
- function pickFailingIdx(flat) {
11778
- const pick = (allowTeardown) => {
11779
- let idx = -1;
11780
- for (let i = 0; i < flat.length; i++) {
11781
- const f = flat[i];
11782
- if (!f.hasError || !allowTeardown && inHookSubtree(flat, f)) continue;
11783
- if (idx === -1 || f.depth >= flat[idx].depth) idx = i;
11784
- }
11785
- return idx;
11786
- };
11787
- const body = pick(false);
11788
- return body >= 0 ? body : pick(true);
11789
- }
11790
- function compactSteps(rawSteps, opts = {}) {
11791
- const flat = flatten(rawSteps);
11792
- if (flat.length === 0) return { steps: [] };
11793
- const maxSteps = opts.maxSteps ?? DEFAULT_MAX_STEPS;
11794
- const failingIdx = pickFailingIdx(flat);
11795
- const keep = /* @__PURE__ */ new Set();
11796
- flat.forEach((f, i) => {
11797
- if (inHookSubtree(flat, f)) return;
11798
- if (f.category === "test.step") keep.add(i);
11799
- else if (f.category === "expect" && isTopLevel(flat, f)) keep.add(i);
11800
- else if (opts.apiSteps && f.category === "pw:api" && isTopLevel(flat, f)) keep.add(i);
11801
- });
11802
- if (failingIdx >= 0) {
11803
- keep.add(failingIdx);
11804
- 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
+ };
11805
11637
  }
11806
- let keepArr = [...keep].sort((a, b) => a - b);
11807
- let omitted = 0;
11808
- if (keepArr.length > maxSteps) {
11809
- omitted = keepArr.length - maxSteps;
11810
- keepArr = keepArr.slice(-maxSteps);
11811
- if (failingIdx >= 0 && !keepArr.includes(failingIdx)) {
11812
- keepArr = [failingIdx, ...keepArr.slice(1)];
11813
- }
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
+ };
11814
11645
  }
11815
- const cache = /* @__PURE__ */ new Map();
11816
- const mat = (i) => {
11817
- let es = cache.get(i);
11818
- if (!es) {
11819
- es = materialize(flat[i], i === failingIdx, testStepDepth(flat, flat[i]));
11820
- cache.set(i, es);
11821
- }
11822
- return es;
11823
- };
11824
- const steps = keepArr.map(mat);
11825
- if (omitted > 0) {
11826
- steps.unshift({
11827
- title: `\u2026 ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted`,
11828
- category: "",
11829
- durationMs: 0,
11830
- depth: 0,
11831
- failed: false
11832
- });
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
+ };
11833
11685
  }
11834
11686
  return {
11835
- steps,
11836
- failingStep: failingIdx >= 0 ? mat(failingIdx) : void 0
11837
- };
11838
- }
11839
- function tailConsole(stdout, stderr, maxLines = DEFAULT_MAX_CONSOLE_LINES) {
11840
- const tail = (chunks) => {
11841
- if (chunks.length === 0) return { lines: [], truncated: false };
11842
- const all = chunks.join("").split(/\r?\n/);
11843
- if (all.length > 0 && all[all.length - 1] === "") all.pop();
11844
- const lines = all.slice(-maxLines).map(stripAnsi);
11845
- return { lines, truncated: all.length > lines.length };
11846
- };
11847
- const o = tail(stdout);
11848
- const e = tail(stderr);
11849
- if (o.lines.length === 0 && e.lines.length === 0) return void 0;
11850
- return {
11851
- stdout: o.lines,
11852
- stderr: e.lines,
11853
- truncated: o.truncated || e.truncated,
11854
- stdoutTruncated: o.truncated,
11855
- stderrTruncated: e.truncated
11687
+ branch: gitBranch(),
11688
+ commit: gitCommit(),
11689
+ buildUrl: "",
11690
+ ciProvider: "local"
11856
11691
  };
11857
11692
  }
11858
- function extractEvidence(attachments) {
11859
- let tracePath;
11860
- let videoPath;
11861
- for (const a of attachments) {
11862
- if (!a.path) continue;
11863
- const looksLikeTrace = a.name === "trace" || a.contentType === "application/zip" && (a.name ?? "").includes("trace");
11864
- if (!tracePath && looksLikeTrace) tracePath = a.path;
11865
- 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);
11866
11711
  }
11867
- if (!tracePath && !videoPath) return void 0;
11868
- return { tracePath, videoPath };
11712
+ return null;
11869
11713
  }
11870
- function buildExecution(input, opts, source) {
11871
- const capture = {};
11872
- if (opts.steps) {
11873
- const { steps, failingStep } = compactSteps(input.steps, {
11874
- maxSteps: opts.maxSteps,
11875
- apiSteps: opts.apiSteps
11876
- });
11877
- if (steps.length > 0) capture.steps = steps;
11878
- if (failingStep) capture.failingStep = failingStep;
11879
- }
11880
- if (opts.console) {
11881
- const console2 = tailConsole(input.stdout, input.stderr, opts.maxConsoleLines);
11882
- if (console2) capture.console = console2;
11883
- }
11884
- if (opts.evidence) {
11885
- const evidence = extractEvidence(input.attachments);
11886
- 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;
11887
11718
  }
11888
- if (Object.keys(capture).length === 0) return void 0;
11889
- capture.source = source;
11890
- return capture;
11719
+ return void 0;
11891
11720
  }
11892
- function chunkToString(chunk) {
11893
- 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
+ }
11894
11727
  }
11895
- function decodeShardChunk(c) {
11896
- return c.text ?? (c.buffer ? Buffer.from(c.buffer, "base64").toString("utf8") : "");
11897
- }
11898
- function executionFromResult(result, opts) {
11899
- return buildExecution(
11900
- {
11901
- steps: result.steps,
11902
- stdout: result.stdout.map(chunkToString),
11903
- stderr: result.stderr.map(chunkToString),
11904
- attachments: result.attachments
11905
- },
11906
- opts,
11907
- "reporter"
11908
- );
11909
- }
11910
-
11911
- // src/collector/SuiteWalker.ts
11912
- init_cjs_shims();
11913
-
11914
- // src/collector/stats-utils.ts
11915
- init_cjs_shims();
11916
- var SUITE_CHART_MAX_ROWS = 150;
11917
- function resolveTestStatus(test, result) {
11918
- if (!result) return "skipped";
11919
- const expected = test.expectedStatus ?? "passed";
11920
- const actual = result.status;
11921
- if (actual === "skipped") return "skipped";
11922
- if (actual === "timedOut") return "timedOut";
11923
- if (actual === "passed" && result.retry > 0) return "flaky";
11924
- if (actual === "passed" && expected === "passed") return "passed";
11925
- if (actual === "failed" && expected === "failed") return "passed";
11926
- return "failed";
11927
- }
11928
- function statsFromTests(tests) {
11929
- return {
11930
- total: tests.length,
11931
- passed: tests.filter((t) => t.status === "passed").length,
11932
- failed: tests.filter((t) => t.status === "failed").length,
11933
- timedOut: tests.filter((t) => t.status === "timedOut").length,
11934
- skipped: tests.filter((t) => t.status === "skipped").length,
11935
- flaky: tests.filter((t) => t.status === "flaky").length
11936
- };
11937
- }
11938
- function aggregateStats(parts) {
11939
- return parts.reduce(
11940
- (acc, s) => ({
11941
- total: acc.total + s.total,
11942
- passed: acc.passed + s.passed,
11943
- failed: acc.failed + s.failed,
11944
- timedOut: acc.timedOut + s.timedOut,
11945
- skipped: acc.skipped + s.skipped,
11946
- flaky: acc.flaky + s.flaky
11947
- }),
11948
- { total: 0, passed: 0, failed: 0, timedOut: 0, skipped: 0, flaky: 0 }
11949
- );
11950
- }
11951
- function toRow(s) {
11952
- return {
11953
- label: s.title,
11954
- passed: s.stats.passed,
11955
- failed: s.stats.failed,
11956
- timedOut: s.stats.timedOut,
11957
- skipped: s.stats.skipped
11958
- };
11959
- }
11960
- function capRows(rows, limit) {
11961
- if (rows.length <= limit) return { rows };
11962
- const size = (r) => r.passed + r.failed + r.timedOut + r.skipped;
11963
- const scored = [...rows].sort(
11964
- (a, b) => b.failed + b.timedOut - (a.failed + a.timedOut) || size(b) - size(a)
11965
- );
11966
- const rest = scored.slice(limit);
11967
- const sums = rest.reduce(
11968
- (acc, r) => ({
11969
- passed: acc.passed + r.passed,
11970
- failed: acc.failed + r.failed,
11971
- timedOut: acc.timedOut + r.timedOut,
11972
- skipped: acc.skipped + r.skipped
11973
- }),
11974
- { passed: 0, failed: 0, timedOut: 0, skipped: 0 }
11975
- );
11976
- const parts = [
11977
- sums.failed > 0 ? `${sums.failed} failed` : "",
11978
- sums.timedOut > 0 ? `${sums.timedOut} timed out` : "",
11979
- sums.passed > 0 ? `${sums.passed} passed` : "",
11980
- sums.skipped > 0 ? `${sums.skipped} skipped` : ""
11981
- ].filter(Boolean);
11982
- const overflowNote = `+ ${rest.length} more suites` + (parts.length > 0 ? `: ${parts.join(" \xB7 ")}` : "");
11983
- return { rows: scored.slice(0, limit), overflowNote };
11984
- }
11985
- function buildSuiteResults(projects, limit) {
11986
- const top = projects.flatMap((p) => p.suites);
11987
- if (top.length <= 1 && top[0] && top[0].suites.length > 0) {
11988
- const file = top[0];
11989
- const fs16 = statsFromTests(file.tests);
11990
- const rootRow = file.tests.length > 0 ? [{ label: file.title, passed: fs16.passed, failed: fs16.failed, timedOut: fs16.timedOut, skipped: fs16.skipped }] : [];
11991
- return capRows([...rootRow, ...file.suites.map(toRow)], limit);
11992
- }
11993
- 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
+ }
11994
11734
  }
11995
11735
 
11996
- // src/collector/SuiteWalker.ts
11997
- var SuiteWalker = class {
11998
- build(rootSuite, resultMap) {
11999
- return rootSuite.suites.map(
12000
- (projectSuite) => this.buildProject(projectSuite, resultMap)
12001
- );
12002
- }
12003
- buildProject(projectSuite, resultMap) {
12004
- const suites = projectSuite.suites.map(
12005
- (s) => this.buildSuite(s, resultMap)
12006
- );
12007
- const stats = aggregateStats(suites.map((s) => s.stats));
12008
- 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();
12009
11748
  }
12010
- buildSuite(suite, resultMap) {
12011
- const childSuites = suite.suites.map((s) => this.buildSuite(s, resultMap));
12012
- const tests = suite.tests.map((t) => this.buildTest(t, resultMap));
12013
- const stats = aggregateStats([
12014
- ...childSuites.map((s) => s.stats),
12015
- statsFromTests(tests)
12016
- ]);
12017
- return {
12018
- title: suite.title,
12019
- type: suite.type === "describe" ? "describe" : "file",
12020
- location: suite.location ? `${suite.location.file}:${suite.location.line}` : void 0,
12021
- suites: childSuites,
12022
- tests,
12023
- stats
12024
- };
11749
+ onBegin(config, rootSuite) {
11750
+ this.config = config;
11751
+ this.rootSuite = rootSuite;
11752
+ this.startTime = Date.now();
12025
11753
  }
12026
- buildTest(test, resultMap) {
12027
- const result = resultMap.get(test.id);
12028
- const status = resolveTestStatus(test, result);
12029
- return {
12030
- id: test.id,
12031
- title: test.title,
12032
- fullTitle: test.titlePath().join(" > "),
12033
- status,
12034
- duration: result?.duration ?? 0,
12035
- retryCount: result?.retry ?? 0,
12036
- tags: test.tags ?? [],
12037
- annotations: test.annotations ?? [],
12038
- location: test.location ? `${test.location.file}:${test.location.line}` : void 0
12039
- };
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
+ }
12040
11771
  }
12041
- };
12042
-
12043
- // src/collector/severity.ts
12044
- init_cjs_shims();
12045
- var SEVERITY_RANK = {
12046
- critical: 0,
12047
- high: 1,
12048
- medium: 2,
12049
- low: 3
12050
- };
12051
- function sortBySeverity(failures) {
12052
- return [...failures].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
12053
- }
12054
- function inferSeverity(annotations, tags) {
12055
- const allLabels = [
12056
- ...annotations.map((a) => a.type.toLowerCase()),
12057
- ...tags.map((t) => t.toLowerCase())
12058
- ];
12059
- if (allLabels.some((l) => l.includes("critical") || l.includes("blocker"))) return "critical";
12060
- if (allLabels.some((l) => l.includes("high") || l.includes("major"))) return "high";
12061
- if (allLabels.some((l) => l.includes("low") || l.includes("minor"))) return "low";
12062
- return "medium";
12063
- }
12064
-
12065
- // src/utils/env.ts
12066
- init_cjs_shims();
12067
- var import_child_process = require("child_process");
12068
- var import_crypto5 = require("crypto");
12069
- var COMMIT_HASH_LENGTH = 8;
12070
- function detectCiEnv() {
12071
- const env = process.env;
12072
- if (env.GITHUB_ACTIONS === "true") {
12073
- const ghServer = env.GITHUB_SERVER_URL;
12074
- const ghRepo = env.GITHUB_REPOSITORY;
12075
- const ghRunId = env.GITHUB_RUN_ID;
12076
- 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);
12077
11785
  return {
12078
- branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || "unknown",
12079
- commit: env.GITHUB_SHA?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12080
- buildUrl,
12081
- ciProvider: "GitHub Actions"
11786
+ projects,
11787
+ stats,
11788
+ failures: sortBySeverity(this.failureRecords),
11789
+ environment,
11790
+ charts
12082
11791
  };
12083
11792
  }
12084
- 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 ?? []);
12085
11800
  return {
12086
- branch: env.CI_COMMIT_REF_NAME || "unknown",
12087
- commit: env.CI_COMMIT_SHORT_SHA || "unknown",
12088
- buildUrl: env.CI_JOB_URL || "",
12089
- ciProvider: "GitLab CI"
12090
- };
12091
- }
12092
- if (env.JENKINS_URL) {
12093
- return {
12094
- branch: env.GIT_BRANCH?.replace("origin/", "") || env.BRANCH_NAME || "unknown",
12095
- commit: env.GIT_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12096
- buildUrl: env.BUILD_URL || "",
12097
- ciProvider: "Jenkins"
12098
- };
12099
- }
12100
- if (env.TF_BUILD === "True") {
12101
- const adoUri = env.SYSTEM_TEAMFOUNDATIONSERVERURI;
12102
- const adoProject = env.SYSTEM_TEAMPROJECT;
12103
- const adoBuildId = env.BUILD_BUILDID;
12104
- const buildUrl = adoUri && adoProject && adoBuildId ? `${adoUri}${adoProject}/_build/results?buildId=${adoBuildId}` : "";
12105
- return {
12106
- branch: env.BUILD_SOURCEBRANCH?.replace("refs/heads/", "") || "unknown",
12107
- commit: env.BUILD_SOURCEVERSION?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12108
- buildUrl,
12109
- ciProvider: "Azure DevOps"
12110
- };
12111
- }
12112
- if (env.CIRCLECI === "true") {
12113
- return {
12114
- branch: env.CIRCLE_BRANCH || "unknown",
12115
- commit: env.CIRCLE_SHA1?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12116
- buildUrl: env.CIRCLE_BUILD_URL || "",
12117
- ciProvider: "CircleCI"
12118
- };
12119
- }
12120
- if (env.BITBUCKET_BUILD_NUMBER) {
12121
- const workspace = env.BITBUCKET_WORKSPACE;
12122
- const repoSlug = env.BITBUCKET_REPO_SLUG;
12123
- const buildNumber = env.BITBUCKET_BUILD_NUMBER;
12124
- const buildUrl = workspace && repoSlug && buildNumber ? `https://bitbucket.org/${workspace}/${repoSlug}/pipelines/results/${buildNumber}` : "";
12125
- return {
12126
- branch: env.BITBUCKET_BRANCH || "unknown",
12127
- commit: env.BITBUCKET_COMMIT?.slice(0, COMMIT_HASH_LENGTH) || "unknown",
12128
- buildUrl,
12129
- ciProvider: "Bitbucket Pipelines"
12130
- };
12131
- }
12132
- return {
12133
- branch: gitBranch(),
12134
- commit: gitCommit(),
12135
- buildUrl: "",
12136
- ciProvider: "local"
12137
- };
12138
- }
12139
- var CI_RUN_SOURCES = [
12140
- { provider: "github.com", repoKeys: ["GITHUB_REPOSITORY"], runKeys: ["GITHUB_RUN_ID"], attemptKeys: ["GITHUB_RUN_ATTEMPT"] },
12141
- { provider: "gitlab.com", repoKeys: ["CI_PROJECT_PATH"], runKeys: ["CI_PIPELINE_ID"] },
12142
- { provider: "circleci.com", repoKeys: ["CIRCLE_PROJECT_REPONAME"], runKeys: ["CIRCLE_WORKFLOW_ID"] },
12143
- { provider: "buildkite.com", repoKeys: ["BUILDKITE_REPO"], runKeys: ["BUILDKITE_BUILD_ID"] },
12144
- { provider: "dev.azure.com", repoKeys: ["BUILD_REPOSITORY_NAME"], runKeys: ["BUILD_BUILDID"] },
12145
- { provider: "bitbucket.org", repoKeys: ["BITBUCKET_REPO_FULL_NAME"], runKeys: ["BITBUCKET_PIPELINE_UUID"] },
12146
- { provider: "jenkins", repoKeys: ["JOB_NAME"], runKeys: ["BUILD_NUMBER"] }
12147
- ];
12148
- var RUN_ID_FALLBACK_SECRET = "rf-live-runid";
12149
- function detectCiRunId() {
12150
- for (const src of CI_RUN_SOURCES) {
12151
- const runId = firstEnv(src.runKeys);
12152
- if (!runId) continue;
12153
- const repo = firstEnv(src.repoKeys) ?? "unknown";
12154
- const attempt = src.attemptKeys ? firstEnv(src.attemptKeys) ?? "1" : "1";
12155
- const secret = process.env.RF_LICENSE_KEY?.trim() || RUN_ID_FALLBACK_SECRET;
12156
- return (0, import_crypto5.createHmac)("sha256", secret).update(`${src.provider}:${repo}:${runId}:${attempt}`).digest("hex").slice(0, 32);
12157
- }
12158
- return null;
12159
- }
12160
- function firstEnv(keys) {
12161
- for (const k of keys) {
12162
- const v = process.env[k]?.trim();
12163
- if (v) return v;
12164
- }
12165
- return void 0;
12166
- }
12167
- function gitBranch() {
12168
- try {
12169
- return (0, import_child_process.execSync)("git rev-parse --abbrev-ref HEAD", { stdio: "pipe" }).toString().trim();
12170
- } catch {
12171
- return "unknown";
12172
- }
12173
- }
12174
- function gitCommit() {
12175
- try {
12176
- return (0, import_child_process.execSync)("git rev-parse --short HEAD", { stdio: "pipe" }).toString().trim();
12177
- } catch {
12178
- return "unknown";
12179
- }
12180
- }
12181
-
12182
- // src/collector/DataCollector.ts
12183
- var DataCollector = class {
12184
- /** @param captureOpts Per-run rich-capture knobs (steps/console/evidence); all off by default. */
12185
- constructor(captureOpts = {}) {
12186
- this.captureOpts = captureOpts;
12187
- this.resultMap = /* @__PURE__ */ new Map();
12188
- // Parallel to resultMap — needed so computeStats() (which only sees
12189
- // TestResult, not TestCase) can reconcile against expectedStatus the same
12190
- // way resolveTestStatus does for the suite tree and the failure list.
12191
- this.expectedStatusMap = /* @__PURE__ */ new Map();
12192
- this.failureRecords = [];
12193
- this.startTime = Date.now();
12194
- }
12195
- onBegin(config, rootSuite) {
12196
- this.config = config;
12197
- this.rootSuite = rootSuite;
12198
- this.startTime = Date.now();
12199
- }
12200
- onTestEnd(test, result) {
12201
- this.resultMap.set(test.id, result);
12202
- this.expectedStatusMap.set(test.id, test.expectedStatus);
12203
- const resolved = resolveTestStatus(test, result);
12204
- const isRealFailure = resolved === "failed" || resolved === "timedOut";
12205
- if (isRealFailure) {
12206
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12207
- const record = this.buildFailureRecord(test, result);
12208
- if (existing >= 0) {
12209
- this.failureRecords[existing] = record;
12210
- } else {
12211
- this.failureRecords.push(record);
12212
- }
12213
- } else {
12214
- const existing = this.failureRecords.findIndex((f) => f.testId === test.id);
12215
- if (existing >= 0) this.failureRecords.splice(existing, 1);
12216
- }
12217
- }
12218
- /**
12219
- * Builds the final `ReportData` payload from all accumulated hook data.
12220
- *
12221
- * @param fullResult - Playwright's `FullResult` (contains `duration` + `status`).
12222
- * @returns Structured data for the PDF generator, including projects tree,
12223
- * aggregated stats, failures, environment info, and chart inputs.
12224
- */
12225
- finalize(fullResult) {
12226
- const walker = new SuiteWalker();
12227
- const projects = walker.build(this.rootSuite, this.resultMap);
12228
- const stats = this.computeStats(fullResult);
12229
- const environment = this.buildEnvironment();
12230
- const charts = this.buildChartData(stats, projects);
12231
- return {
12232
- projects,
12233
- stats,
12234
- failures: sortBySeverity(this.failureRecords),
12235
- environment,
12236
- charts
12237
- };
12238
- }
12239
- buildFailureRecord(test, result) {
12240
- const error = result.errors[0] ?? { message: "Unknown error" };
12241
- const screenshotAttachment = result.attachments.find(
12242
- (a) => a.name.toLowerCase().includes("screenshot") && a.contentType.startsWith("image/")
12243
- );
12244
- const suitePath = test.titlePath().slice(0, -1);
12245
- const severity = inferSeverity(test.annotations ?? [], test.tags ?? []);
12246
- return {
12247
- testId: test.id,
12248
- testTitle: test.title,
12249
- suitePath,
12250
- error: {
12251
- message: stripAnsi(error.message ?? String(error)),
12252
- stack: error.stack ? stripAnsi(error.stack) : void 0
12253
- },
12254
- screenshotPath: screenshotAttachment?.path,
12255
- screenshotBuffer: screenshotAttachment?.body,
12256
- retryHistory: [
12257
- {
12258
- attempt: result.retry,
12259
- status: result.status,
12260
- duration: result.duration
12261
- }
12262
- ],
12263
- durationMs: result.duration,
12264
- severity,
12265
- 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)
12266
11820
  };
12267
11821
  }
12268
11822
  computeStats(fullResult) {
@@ -12578,7 +12132,9 @@ var ShardMerger = class {
12578
12132
  {
12579
12133
  // Guard each field — a partial / non-conformant / older shard JSON can
12580
12134
  // omit steps or the stdout/stderr arrays; an unguarded .map/iterate would
12581
- // 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).
12582
12138
  steps: lastResult.steps ?? [],
12583
12139
  stdout: (lastResult.stdout ?? []).map(decodeShardChunk),
12584
12140
  stderr: (lastResult.stderr ?? []).map(decodeShardChunk),
@@ -12594,13 +12150,16 @@ var ShardMerger = class {
12594
12150
  const browsers = [
12595
12151
  ...new Set(report.config.projects.map((p) => p.use?.browserName ?? p.name).filter(Boolean))
12596
12152
  ];
12597
- let playwrightVersion = "unknown";
12598
- try {
12599
- const req = (0, import_node_module.createRequire)(importMetaUrl);
12600
- const pwPkg = req("@playwright/test/package.json");
12601
- playwrightVersion = pwPkg.version;
12602
- } 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
+ }
12603
12161
  }
12162
+ if (!playwrightVersion) playwrightVersion = "unknown";
12604
12163
  return {
12605
12164
  branch: ci.branch,
12606
12165
  commit: ci.commit,
@@ -12611,7 +12170,7 @@ var ShardMerger = class {
12611
12170
  nodeVersion: process.version,
12612
12171
  playwrightVersion,
12613
12172
  projectCount: report.config.projects.length,
12614
- workerCount: 1
12173
+ workerCount: report.config.workers ?? 1
12615
12174
  };
12616
12175
  }
12617
12176
  buildCharts(stats, projects) {
@@ -12620,8 +12179,9 @@ var ShardMerger = class {
12620
12179
  passRate: {
12621
12180
  passed: stats.passed,
12622
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).
12623
12184
  timedOut: stats.timedOut,
12624
- // always 0 in shard JSON (no separate timedOut count)
12625
12185
  skipped: stats.skipped,
12626
12186
  flaky: stats.flaky
12627
12187
  },
@@ -12654,6 +12214,69 @@ function formatDuration(ms) {
12654
12214
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
12655
12215
  }
12656
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
+
12657
12280
  // src/templates/engine.ts
12658
12281
  function jsonForInlineScript(value) {
12659
12282
  return JSON.stringify(value).replace(/<\//g, "<\\/");
@@ -12978,7 +12601,20 @@ var TemplateEngine = class {
12978
12601
  // src/screenshots/ScreenshotEmbedder.ts
12979
12602
  init_cjs_shims();
12980
12603
  var import_fs5 = __toESM(require("fs"));
12981
- 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
+ }
12982
12618
  var ScreenshotEmbedder = class {
12983
12619
  async embedAll(failures, opts) {
12984
12620
  await Promise.all(failures.map((f) => this.embed(f, opts)));
@@ -13014,7 +12650,11 @@ var ScreenshotEmbedder = class {
13014
12650
  if (this.looksLikeSvg(buf)) {
13015
12651
  return { buffer: buf, mime: "image/svg+xml" };
13016
12652
  }
13017
- 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" });
13018
12658
  const meta = await img.metadata();
13019
12659
  const needsResize = (meta.width ?? 0) > opts.maxWidth;
13020
12660
  let pipeline = img.rotate();
@@ -13535,154 +13175,565 @@ var PdfGenerator = class {
13535
13175
  return { kind: "custom", absolutePath, label };
13536
13176
  });
13537
13177
  }
13538
- const rawIds = Array.isArray(options.template) ? options.template : [options.template];
13539
- return [...new Set(rawIds)].map((id) => ({ kind: "builtin", id }));
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);
13625
+ }
13540
13626
  }
13541
- resolveSettings(options, data) {
13542
- return resolveCompression({
13543
- level: options.compressionLevel ?? "auto",
13544
- testCount: data.stats.total,
13545
- failureCount: data.failures.length,
13546
- inlineFailuresOverride: options.maxInlineFailures
13547
- });
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 });
13548
13632
  }
13549
- /**
13550
- * Builds retune settings sized against the cap using *measured* bytes from
13551
- * the previous pass. The per-failure cost in a PDF includes more than just
13552
- * the JPEG payload — page layout, font glyph rendering, and per-page chrome
13553
- * all scale with failure count. Compute observed bytes per inline failure,
13554
- * then pick an inline count that fits the cap with a 15% safety margin.
13555
- * Also tightens JPEG quality and width to claw back bytes from each
13556
- * remaining image.
13557
- */
13558
- tightenForCap(prev, actualBytes, capBytes, actualInlinedCount) {
13559
- const observedPerFailure = actualBytes / Math.max(1, actualInlinedCount);
13560
- const targetInline = Math.max(20, Math.floor(capBytes * 0.85 / observedPerFailure));
13561
- return {
13562
- effective: "max",
13563
- reencode: true,
13564
- quality: 55,
13565
- maxWidth: 800,
13566
- maxInlineFailures: Math.min(prev.maxInlineFailures, targetInline)
13567
- };
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() });
13568
13638
  }
13569
- /**
13570
- * One full render pass: paginate failures, re-embed screenshots at the
13571
- * target compression, render HTML, PDF the page, write to outputPath.
13572
- * Separated from generateAll() so the size-cap retune can reuse it with
13573
- * stricter settings without reshaping the rest of the flow.
13574
- */
13575
- async renderPdf(data, options, source, compression, outputPath, page) {
13576
- const paginated = await this.failurePaginator.paginate(
13577
- data.failures,
13578
- compression.maxInlineFailures,
13579
- 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`
13580
13642
  );
13581
- const renderData = {
13582
- ...data,
13583
- failures: paginated.inline,
13584
- overflowFailureCount: paginated.overflowCount,
13585
- overflowSidecarName: paginated.sidecarPath ? import_path7.default.basename(paginated.sidecarPath) + (options.pdfPassword ? ".enc" : "") : void 0
13586
- };
13587
- for (const f of renderData.failures) f.screenshotBase64 = void 0;
13588
- if (options.includeScreenshots !== false) {
13589
- await this.screenshotEmbedder.embedAll(renderData.failures, {
13590
- reencode: compression.reencode,
13591
- quality: compression.quality,
13592
- maxWidth: compression.maxWidth
13593
- });
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;
13594
13655
  }
13595
- const templateLabel = source.kind === "builtin" ? source.id : source.label;
13596
- logger.info(`Rendering template: ${templateLabel}`);
13597
- const renderOpts = {
13598
- sections: options.sections,
13599
- slowTestThreshold: options.slowTestThreshold,
13600
- requirementTagPattern: options.requirementTagPattern
13601
- };
13602
- const html = source.kind === "builtin" ? this.templateEngine.render(renderData, source.id, renderOpts) : this.templateEngine.renderFromPath(renderData, source.absolutePath, renderOpts);
13603
- const tempHtmlPath = await this.htmlWriter.write(html);
13604
- const fileUrl = this.htmlWriter.toFileUrl(tempHtmlPath);
13605
- try {
13606
- logger.info(`Navigating to temp HTML (${Math.round(html.length / 1024)} KB)...`);
13607
- await page.goto(fileUrl, { waitUntil: "domcontentloaded", timeout: 6e4 });
13608
- const showCharts = source.kind === "builtin" ? resolveSections(source.id, options.sections).charts : true;
13609
- await this.chartRenderer.waitForCharts(page, showCharts);
13610
- logger.info(`Generating PDF \u2192 ${import_path7.default.relative(process.cwd(), outputPath)}`);
13611
- await page.pdf({
13612
- path: outputPath,
13613
- format: "A4",
13614
- printBackground: true,
13615
- preferCSSPageSize: true
13616
- });
13617
- } finally {
13618
- await this.htmlWriter.cleanup();
13656
+ if (r.label !== "" && labeledToDrop > 0) {
13657
+ labeledToDrop--;
13658
+ continue;
13619
13659
  }
13620
- return { sidecarPath: paginated.overflowCount > 0 ? paginated.sidecarPath : void 0 };
13660
+ out.push(r);
13621
13661
  }
13622
- };
13623
- function fmtMb(bytes) {
13624
- return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
13662
+ return out;
13625
13663
  }
13626
13664
 
13627
- // src/redact/Redactor.ts
13665
+ // src/utils/paths.ts
13628
13666
  init_cjs_shims();
13629
- var Redactor = class {
13630
- constructor(opts) {
13631
- /** Indexes of user patterns disabled after throwing (warned once each). */
13632
- this.disabled = /* @__PURE__ */ new Set();
13633
- this.maskRaw = opts.mask;
13634
- this.maskReplacement = opts.mask.replace(/\$/g, "$$$$");
13635
- this.useBuiltins = opts.builtins;
13636
- this.userPatterns = opts.patterns.map((p) => new RegExp(p, "g"));
13637
- }
13638
- redactText(s) {
13639
- if (!s) return s;
13640
- let out = s;
13641
- if (this.useBuiltins) out = this.applyBuiltins(out);
13642
- for (let i = 0; i < this.userPatterns.length; i++) {
13643
- if (this.disabled.has(i)) continue;
13644
- try {
13645
- out = out.replace(this.userPatterns[i], this.maskReplacement);
13646
- } catch (err) {
13647
- this.disabled.add(i);
13648
- logger.warn(
13649
- `redact: custom pattern #${i + 1} threw and is disabled for this run: ${err.message}`
13650
- );
13651
- }
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
+ );
13652
13717
  }
13653
- return out;
13654
13718
  }
13655
- applyBuiltins(s) {
13656
- const M = this.maskRaw;
13657
- const MR = this.maskReplacement;
13658
- return s.replace(/(\b[a-z][\w+.-]{0,63}:\/\/[^/\s:@]{1,512}:)([^@\s/]{1,512})(?=@)/gi, `$1${MR}`).replace(
13659
- /\b(?:(authorization\s*[:=]\s*basic\s+)([A-Za-z0-9._~+/=-]{8,})|((?:authorization\s*[:=]\s*)?bearer\s+)([A-Za-z0-9._~+/=-]{8,}))/gi,
13660
- (fullMatch, basicPrefix, _basicToken, bearerPrefix, bearerToken) => {
13661
- if (basicPrefix) return `${basicPrefix}${M}`;
13662
- if (bearerToken && /[^a-z]/.test(bearerToken)) return `${bearerPrefix}${M}`;
13663
- return fullMatch;
13664
- }
13665
- ).replace(/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g, MR).replace(
13666
- /\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,
13667
- MR
13668
- ).replace(
13669
- /\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,
13670
- (_m, key, sep, q, _quotedVal, _plainVal) => q ? `${key}${sep}${q}${M}${q}` : `${key}${sep}${M}`
13671
- ).replace(
13672
- /\b(fill|type|press)\b([^\n]{0,80}?\b(?:password|secret|token|pass|passcode|pin|otp)\b[^\n]{0,80}?\s)(["'])((?:(?!\3).)+)\3/gi,
13673
- (_m, action, mid, q) => `${action}${mid}${q}${M}${q}`
13674
- ).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) => {
13675
- const before = offset > 0 ? str[offset - 1] : " ";
13676
- const after = offset + m.length < str.length ? str[offset + m.length] : " ";
13677
- const beforeIsWord = /[A-Za-z0-9_]/.test(before);
13678
- const afterIsWord = /[A-Za-z0-9_]/.test(after);
13679
- if (!beforeIsWord && !afterIsWord && /\d/.test(m) && /[A-Za-z]/.test(m)) {
13680
- return M;
13681
- }
13682
- 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 }
13683
13729
  });
13684
13730
  }
13685
- };
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
+ }
13686
13737
 
13687
13738
  // src/redact/redact-report.ts
13688
13739
  init_cjs_shims();
@@ -13733,9 +13784,6 @@ function redactSuite(s, r) {
13733
13784
  s.suites.forEach((child) => redactSuite(child, r));
13734
13785
  }
13735
13786
 
13736
- // src/reporter.ts
13737
- var import_path12 = __toESM(require("path"));
13738
-
13739
13787
  // src/history/HistoryManager.ts
13740
13788
  init_cjs_shims();
13741
13789
  var import_fs12 = __toESM(require("fs"));
@@ -14320,6 +14368,261 @@ var NotificationSender = class {
14320
14368
  }
14321
14369
  };
14322
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
+
14323
14626
  // src/live/LiveStreamer.ts
14324
14627
  init_cjs_shims();
14325
14628
  var NETWORK_TIMEOUT_MS3 = 8e3;
@@ -14940,7 +15243,6 @@ function chunkToString2(chunk) {
14940
15243
  }
14941
15244
 
14942
15245
  // src/reporter.ts
14943
- var DEFAULT_SERVER_URL2 = "https://reportforge.org";
14944
15246
  var LICENSE_STALE_MARGIN_MS = 5 * 60 * 1e3;
14945
15247
  var PdfReporter = class {
14946
15248
  constructor(rawOptions = {}) {
@@ -14955,7 +15257,7 @@ var PdfReporter = class {
14955
15257
  if (this.options.redact.enabled) {
14956
15258
  this.redactor = new Redactor(this.options.redact);
14957
15259
  }
14958
- this.version = true ? "0.26.1" : "0.x";
15260
+ this.version = true ? "0.28.0" : "0.x";
14959
15261
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
14960
15262
  this.licenseClient = new LicenseClient({
14961
15263
  licenseKey: this.options.licenseKey,
@@ -15026,44 +15328,19 @@ var PdfReporter = class {
15026
15328
  }
15027
15329
  }
15028
15330
  async onEnd(result) {
15029
- const licenseInfo = await this.freshLicense();
15030
- if (!licenseInfo) return;
15031
- const collected = this._collectData(result);
15032
- if (!collected) {
15033
- maybePrintUpdateNotice(licenseInfo, this.version);
15034
- return;
15035
- }
15036
- if (collected.stats.total === 0) {
15037
- logger.warn("No tests were executed \u2014 check your --grep / project filters. The report has nothing to assess.");
15038
- }
15039
- if (this.options.showTrend && collected.stats.total > 0) {
15040
- try {
15041
- await this._appendTrend(collected, licenseInfo);
15042
- } catch (err) {
15043
- logger.warn(`History write failed \u2014 trend data skipped: ${err.message}`);
15044
- }
15045
- }
15046
- const reportData = this._buildReportData(collected, licenseInfo);
15047
- runFailureAnalysis(reportData, this.options, this.cwd);
15048
- reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
15049
- if (this.redactor) {
15050
- redactReportData(reportData, this.redactor);
15051
- }
15052
- let pdfPaths = [];
15053
- try {
15054
- pdfPaths = await this.pdfGenerator.generateAll(reportData, this.options, licenseInfo);
15055
- if (this.options.open) {
15056
- for (const pdfPath of pdfPaths) await this.openPdf(pdfPath);
15057
- }
15058
- } catch (err) {
15059
- logger.error(`Failed to generate PDF report: ${err.message}`);
15060
- if (process.env.RF_DEBUG) console.error(err);
15061
- }
15062
- if (this.options.notify) {
15063
- const sender = new NotificationSender();
15064
- await sender.sendAll(reportData, this.options.notify, pdfPaths);
15065
- }
15066
- 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
+ });
15067
15344
  }
15068
15345
  _collectData(result) {
15069
15346
  if (this.options.shardResults && this.options.shardResults.length > 0) {
@@ -15076,94 +15353,6 @@ var PdfReporter = class {
15076
15353
  }
15077
15354
  return this.dataCollector.finalize(result);
15078
15355
  }
15079
- async _appendTrend(collected, licenseInfo) {
15080
- const historyPath = resolveHistoryPath(this.options, this.cwd);
15081
- const hm = new HistoryManager(historyPath);
15082
- const suffix = Math.random().toString(36).padEnd(8, "0").slice(2, 6);
15083
- const redactor = this.redactor;
15084
- const flakyTests = collectFlakyTests(collected.projects).map(
15085
- (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15086
- );
15087
- const { redactedFailing, ...failedTestsFields } = buildFailedTestsFields(collected.projects, redactor);
15088
- const entry = {
15089
- runId: `${(/* @__PURE__ */ new Date()).toISOString()}-${suffix}`,
15090
- timestamp: Date.now(),
15091
- passRate: collected.stats.passRate,
15092
- total: collected.stats.total,
15093
- passed: collected.stats.passed,
15094
- failed: collected.stats.failed,
15095
- flaky: collected.stats.flaky,
15096
- duration: collected.stats.duration,
15097
- verdict: deriveVerdict(collected.stats),
15098
- flakyTests,
15099
- ...failedTestsFields,
15100
- branch: collected.environment.branch
15101
- };
15102
- const localEntries = hm.append(entry, this.options.historySize);
15103
- collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
15104
- let trendEntries = localEntries;
15105
- if (this.options.remoteHistory && licenseInfo.jwt) {
15106
- const serverUrl = this.options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL2;
15107
- const remote = await appendRemoteHistory({
15108
- serverUrl,
15109
- jwt: licenseInfo.jwt,
15110
- projectId: buildProjectId(this.options.projectName, this.options.outputFile),
15111
- branchHash: buildBranchHash(collected.environment.branch),
15112
- entry
15113
- });
15114
- if (remote) {
15115
- trendEntries = remote;
15116
- logger.debug(`remote history: ${remote.length} entr${remote.length === 1 ? "y" : "ies"} for trend`);
15117
- }
15118
- }
15119
- this._populateHistoryCharts(trendEntries, localEntries, collected);
15120
- }
15121
- _populateHistoryCharts(trendEntries, localEntries, collected) {
15122
- if (trendEntries.length >= 2) {
15123
- collected.charts.trend = {
15124
- entries: trendEntries.map((e) => ({
15125
- runId: e.runId,
15126
- timestamp: e.timestamp,
15127
- passRate: e.passRate,
15128
- total: e.total,
15129
- duration: e.duration,
15130
- verdict: e.verdict
15131
- })),
15132
- delta: trendEntries[0].passRate - trendEntries[1].passRate
15133
- };
15134
- }
15135
- if (this.options.flakinessTopN > 0) {
15136
- const table = computeTopN(localEntries, this.options.flakinessTopN);
15137
- if (table.length > 0) {
15138
- collected.charts.flakinessTable = table;
15139
- }
15140
- }
15141
- }
15142
- _buildReportData(collected, licenseInfo) {
15143
- const { projects, stats, failures, environment, charts, runDiff } = collected;
15144
- const projectName = this.options.projectName ?? this.resolveProjectName();
15145
- const firstTemplate = this.options.templatePath?.length ? "detailed" : Array.isArray(this.options.template) ? this.options.template[0] : this.options.template;
15146
- return {
15147
- meta: {
15148
- title: this.options.reportTitle,
15149
- generatedAt: /* @__PURE__ */ new Date(),
15150
- template: firstTemplate,
15151
- branding: {
15152
- primaryColor: this.options.primaryColor,
15153
- accentColor: this.options.accentColor,
15154
- watermark: this.options.watermark
15155
- },
15156
- licenseInfo,
15157
- projectName
15158
- },
15159
- stats,
15160
- projects,
15161
- failures,
15162
- environment,
15163
- charts,
15164
- runDiff
15165
- };
15166
- }
15167
15356
  printsToStdio() {
15168
15357
  return false;
15169
15358
  }
@@ -15219,7 +15408,7 @@ var PdfReporter = class {
15219
15408
  setupLive(config) {
15220
15409
  const live = this.options.live;
15221
15410
  const runId = deriveRunId({ runId: live.runId });
15222
- 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;
15223
15412
  const shard = config.shard ?? null;
15224
15413
  this.liveShardKey = shardKeyOf(shard);
15225
15414
  this.liveSteps = live.steps;
@@ -15291,121 +15480,7 @@ var PdfReporter = class {
15291
15480
  await new NotificationSender().sendLiveStarted(notify, watchUrl, env?.branch);
15292
15481
  }
15293
15482
  }
15294
- resolveProjectName() {
15295
- try {
15296
- const pkgPath = import_path12.default.resolve(process.cwd(), "package.json");
15297
- const pkg = require(pkgPath);
15298
- return pkg.name ?? "Playwright Tests";
15299
- } catch {
15300
- return "Playwright Tests";
15301
- }
15302
- }
15303
- async openPdf(pdfPath) {
15304
- try {
15305
- const { existsSync } = await import("fs");
15306
- if (!existsSync(pdfPath)) {
15307
- logger.warn(`open: true \u2014 PDF not found at ${pdfPath}; file may not have been written yet.`);
15308
- return;
15309
- }
15310
- const { spawn } = await import("child_process");
15311
- const platform = process.platform;
15312
- const [cmd, args] = platform === "darwin" ? ["open", [pdfPath]] : platform === "win32" ? ["cmd", ["/c", "start", "", pdfPath]] : ["xdg-open", [pdfPath]];
15313
- const child = spawn(cmd, args, { detached: true, stdio: "ignore", shell: false });
15314
- child.on("error", (err) => {
15315
- logger.warn(`open: true \u2014 could not open ${pdfPath}: ${err.message}`);
15316
- });
15317
- child.unref();
15318
- if (platform === "win32") {
15319
- child.on("close", (code) => {
15320
- if (code !== 0 && code !== null) {
15321
- logger.warn(
15322
- `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}`
15323
- );
15324
- }
15325
- });
15326
- }
15327
- } catch (err) {
15328
- logger.warn(`open: true \u2014 unexpected error: ${err.message}`);
15329
- }
15330
- }
15331
15483
  };
15332
- function deriveVerdict(stats) {
15333
- if (stats.total === 0) return "unknown";
15334
- if (stats.verdict === "interrupted" || stats.verdict === "timedout") return "failed";
15335
- if (stats.failed === 0 && stats.timedOut === 0) {
15336
- if (stats.passed === 0 && stats.flaky === 0) return "unknown";
15337
- return "passed";
15338
- }
15339
- if (stats.failed / stats.total > 0.5) return "failed";
15340
- if (stats.timedOut > 0) return "failed";
15341
- if (stats.failed > 0) return "partial";
15342
- return "unknown";
15343
- }
15344
- function resolveHistoryPath(options, cwd) {
15345
- if (options.historyFile) {
15346
- return import_path12.default.isAbsolute(options.historyFile) ? options.historyFile : import_path12.default.resolve(cwd, options.historyFile);
15347
- }
15348
- return import_path12.default.join(resolveProjectDir(options, cwd), "history.json");
15349
- }
15350
- function flattenSuiteNodes(suites) {
15351
- return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
15352
- }
15353
- function collectFlakyTests(projects) {
15354
- const seen = /* @__PURE__ */ new Set();
15355
- const result = [];
15356
- for (const project of projects) {
15357
- for (const suite of flattenSuiteNodes(project.suites)) {
15358
- for (const test of suite.tests) {
15359
- if (test.status === "flaky" && !seen.has(test.id)) {
15360
- seen.add(test.id);
15361
- result.push({ id: test.id, title: test.fullTitle });
15362
- }
15363
- }
15364
- }
15365
- }
15366
- return result;
15367
- }
15368
- function collectFailedTests(projects) {
15369
- const seen = /* @__PURE__ */ new Set();
15370
- const result = [];
15371
- for (const project of projects) {
15372
- for (const suite of flattenSuiteNodes(project.suites)) {
15373
- for (const test of suite.tests) {
15374
- if ((test.status === "failed" || test.status === "timedOut") && !seen.has(test.id)) {
15375
- seen.add(test.id);
15376
- result.push({ id: test.id, title: test.fullTitle });
15377
- }
15378
- }
15379
- }
15380
- }
15381
- return result;
15382
- }
15383
- function collectCurrentStatuses(projects) {
15384
- const statuses = /* @__PURE__ */ new Map();
15385
- for (const project of projects) {
15386
- for (const suite of flattenSuiteNodes(project.suites)) {
15387
- for (const test of suite.tests) {
15388
- statuses.set(test.id, test.status);
15389
- }
15390
- }
15391
- }
15392
- return statuses;
15393
- }
15394
- function buildFailedTestsFields(projects, redactor) {
15395
- const redactedFailing = collectFailedTests(projects).map(
15396
- (t) => redactor ? { ...t, title: redactor.redactText(t.title) } : t
15397
- );
15398
- const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
15399
- return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
15400
- }
15401
- function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
15402
- return computeRunDiff({
15403
- currentFailing,
15404
- currentStatuses: collectCurrentStatuses(projects),
15405
- priorEntries,
15406
- branch
15407
- }) ?? void 0;
15408
- }
15409
15484
 
15410
15485
  // src/index.ts
15411
15486
  function defineReporterConfig(options) {