@reportforge/playwright-pdf 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1229,13 +1229,13 @@ var require_ast = __commonJS({
1229
1229
  helperExpression: function helperExpression(node) {
1230
1230
  return node.type === "SubExpression" || (node.type === "MustacheStatement" || node.type === "BlockStatement") && !!(node.params && node.params.length || node.hash);
1231
1231
  },
1232
- scopedId: function scopedId(path16) {
1233
- return /^\.|this\b/.test(path16.original);
1232
+ scopedId: function scopedId(path17) {
1233
+ return /^\.|this\b/.test(path17.original);
1234
1234
  },
1235
1235
  // an ID is simple if it only has one part, and that part is not
1236
1236
  // `..` or `this`.
1237
- simpleId: function simpleId(path16) {
1238
- return path16.parts.length === 1 && !AST.helpers.scopedId(path16) && !path16.depth;
1237
+ simpleId: function simpleId(path17) {
1238
+ return path17.parts.length === 1 && !AST.helpers.scopedId(path17) && !path17.depth;
1239
1239
  }
1240
1240
  }
1241
1241
  };
@@ -2309,12 +2309,12 @@ var require_helpers2 = __commonJS({
2309
2309
  loc
2310
2310
  };
2311
2311
  }
2312
- function prepareMustache(path16, params, hash, open, strip, locInfo) {
2312
+ function prepareMustache(path17, params, hash, open, strip, locInfo) {
2313
2313
  var escapeFlag = open.charAt(3) || open.charAt(2), escaped = escapeFlag !== "{" && escapeFlag !== "&";
2314
2314
  var decorator = /\*/.test(open);
2315
2315
  return {
2316
2316
  type: decorator ? "Decorator" : "MustacheStatement",
2317
- path: path16,
2317
+ path: path17,
2318
2318
  params,
2319
2319
  hash,
2320
2320
  escaped,
@@ -2634,9 +2634,9 @@ var require_compiler = __commonJS({
2634
2634
  },
2635
2635
  DecoratorBlock: function DecoratorBlock(decorator) {
2636
2636
  var program = decorator.program && this.compileProgram(decorator.program);
2637
- var params = this.setupFullMustacheParams(decorator, program, void 0), path16 = decorator.path;
2637
+ var params = this.setupFullMustacheParams(decorator, program, void 0), path17 = decorator.path;
2638
2638
  this.useDecorators = true;
2639
- this.opcode("registerDecorator", params.length, path16.original);
2639
+ this.opcode("registerDecorator", params.length, path17.original);
2640
2640
  },
2641
2641
  PartialStatement: function PartialStatement(partial) {
2642
2642
  this.usePartial = true;
@@ -2700,46 +2700,46 @@ var require_compiler = __commonJS({
2700
2700
  }
2701
2701
  },
2702
2702
  ambiguousSexpr: function ambiguousSexpr(sexpr, program, inverse) {
2703
- var path16 = sexpr.path, name = path16.parts[0], isBlock = program != null || inverse != null;
2704
- this.opcode("getContext", path16.depth);
2703
+ var path17 = sexpr.path, name = path17.parts[0], isBlock = program != null || inverse != null;
2704
+ this.opcode("getContext", path17.depth);
2705
2705
  this.opcode("pushProgram", program);
2706
2706
  this.opcode("pushProgram", inverse);
2707
- path16.strict = true;
2708
- this.accept(path16);
2707
+ path17.strict = true;
2708
+ this.accept(path17);
2709
2709
  this.opcode("invokeAmbiguous", name, isBlock);
2710
2710
  },
2711
2711
  simpleSexpr: function simpleSexpr(sexpr) {
2712
- var path16 = sexpr.path;
2713
- path16.strict = true;
2714
- this.accept(path16);
2712
+ var path17 = sexpr.path;
2713
+ path17.strict = true;
2714
+ this.accept(path17);
2715
2715
  this.opcode("resolvePossibleLambda");
2716
2716
  },
2717
2717
  helperSexpr: function helperSexpr(sexpr, program, inverse) {
2718
- var params = this.setupFullMustacheParams(sexpr, program, inverse), path16 = sexpr.path, name = path16.parts[0];
2718
+ var params = this.setupFullMustacheParams(sexpr, program, inverse), path17 = sexpr.path, name = path17.parts[0];
2719
2719
  if (this.options.knownHelpers[name]) {
2720
2720
  this.opcode("invokeKnownHelper", params.length, name);
2721
2721
  } else if (this.options.knownHelpersOnly) {
2722
2722
  throw new _exception2["default"]("You specified knownHelpersOnly, but used the unknown helper " + name, sexpr);
2723
2723
  } else {
2724
- path16.strict = true;
2725
- path16.falsy = true;
2726
- this.accept(path16);
2727
- this.opcode("invokeHelper", params.length, path16.original, _ast2["default"].helpers.simpleId(path16));
2724
+ path17.strict = true;
2725
+ path17.falsy = true;
2726
+ this.accept(path17);
2727
+ this.opcode("invokeHelper", params.length, path17.original, _ast2["default"].helpers.simpleId(path17));
2728
2728
  }
2729
2729
  },
2730
- PathExpression: function PathExpression(path16) {
2731
- this.addDepth(path16.depth);
2732
- this.opcode("getContext", path16.depth);
2733
- var name = path16.parts[0], scoped = _ast2["default"].helpers.scopedId(path16), blockParamId = !path16.depth && !scoped && this.blockParamIndex(name);
2730
+ PathExpression: function PathExpression(path17) {
2731
+ this.addDepth(path17.depth);
2732
+ this.opcode("getContext", path17.depth);
2733
+ var name = path17.parts[0], scoped = _ast2["default"].helpers.scopedId(path17), blockParamId = !path17.depth && !scoped && this.blockParamIndex(name);
2734
2734
  if (blockParamId) {
2735
- this.opcode("lookupBlockParam", blockParamId, path16.parts);
2735
+ this.opcode("lookupBlockParam", blockParamId, path17.parts);
2736
2736
  } else if (!name) {
2737
2737
  this.opcode("pushContext");
2738
- } else if (path16.data) {
2738
+ } else if (path17.data) {
2739
2739
  this.options.data = true;
2740
- this.opcode("lookupData", path16.depth, path16.parts, path16.strict);
2740
+ this.opcode("lookupData", path17.depth, path17.parts, path17.strict);
2741
2741
  } else {
2742
- this.opcode("lookupOnContext", path16.parts, path16.falsy, path16.strict, scoped);
2742
+ this.opcode("lookupOnContext", path17.parts, path17.falsy, path17.strict, scoped);
2743
2743
  }
2744
2744
  },
2745
2745
  StringLiteral: function StringLiteral(string) {
@@ -3095,16 +3095,16 @@ var require_util = __commonJS({
3095
3095
  }
3096
3096
  exports.urlGenerate = urlGenerate;
3097
3097
  function normalize(aPath) {
3098
- var path16 = aPath;
3098
+ var path17 = aPath;
3099
3099
  var url = urlParse(aPath);
3100
3100
  if (url) {
3101
3101
  if (!url.path) {
3102
3102
  return aPath;
3103
3103
  }
3104
- path16 = url.path;
3104
+ path17 = url.path;
3105
3105
  }
3106
- var isAbsolute = exports.isAbsolute(path16);
3107
- var parts = path16.split(/\/+/);
3106
+ var isAbsolute = exports.isAbsolute(path17);
3107
+ var parts = path17.split(/\/+/);
3108
3108
  for (var part, up = 0, i = parts.length - 1; i >= 0; i--) {
3109
3109
  part = parts[i];
3110
3110
  if (part === ".") {
@@ -3121,15 +3121,15 @@ var require_util = __commonJS({
3121
3121
  }
3122
3122
  }
3123
3123
  }
3124
- path16 = parts.join("/");
3125
- if (path16 === "") {
3126
- path16 = isAbsolute ? "/" : ".";
3124
+ path17 = parts.join("/");
3125
+ if (path17 === "") {
3126
+ path17 = isAbsolute ? "/" : ".";
3127
3127
  }
3128
3128
  if (url) {
3129
- url.path = path16;
3129
+ url.path = path17;
3130
3130
  return urlGenerate(url);
3131
3131
  }
3132
- return path16;
3132
+ return path17;
3133
3133
  }
3134
3134
  exports.normalize = normalize;
3135
3135
  function join(aRoot, aPath) {
@@ -5932,8 +5932,8 @@ var require_printer = __commonJS({
5932
5932
  return this.accept(sexpr.path) + " " + params + hash;
5933
5933
  };
5934
5934
  PrintVisitor.prototype.PathExpression = function(id) {
5935
- var path16 = id.parts.join("/");
5936
- return (id.data ? "@" : "") + "PATH:" + path16;
5935
+ var path17 = id.parts.join("/");
5936
+ return (id.data ? "@" : "") + "PATH:" + path17;
5937
5937
  };
5938
5938
  PrintVisitor.prototype.StringLiteral = function(string) {
5939
5939
  return '"' + string.value + '"';
@@ -6053,7 +6053,7 @@ var require_win32 = __commonJS({
6053
6053
  "node_modules/chrome-finder/lib/win32.js"(exports, module) {
6054
6054
  "use strict";
6055
6055
  init_esm_shims();
6056
- var path16 = __require("path");
6056
+ var path17 = __require("path");
6057
6057
  var { canAccess } = require_util2();
6058
6058
  function win32() {
6059
6059
  const installations = [];
@@ -6067,7 +6067,7 @@ var require_win32 = __commonJS({
6067
6067
  const prefixes = [process.env.LOCALAPPDATA, process.env.PROGRAMFILES, process.env["PROGRAMFILES(X86)"]];
6068
6068
  prefixes.forEach((prefix) => suffixes.forEach((suffix) => {
6069
6069
  if (prefix) {
6070
- const chromePath = path16.join(prefix, suffix);
6070
+ const chromePath = path17.join(prefix, suffix);
6071
6071
  if (canAccess(chromePath)) {
6072
6072
  installations.push(chromePath);
6073
6073
  }
@@ -6085,7 +6085,7 @@ var require_darwin = __commonJS({
6085
6085
  "use strict";
6086
6086
  init_esm_shims();
6087
6087
  var { execSync: execSync3 } = __require("child_process");
6088
- var path16 = __require("path");
6088
+ var path17 = __require("path");
6089
6089
  var { canAccess, newLineRegex, sort } = require_util2();
6090
6090
  function darwin() {
6091
6091
  const suffixes = ["/Contents/MacOS/Google Chrome Canary", "/Contents/MacOS/Google Chrome", "/Contents/MacOS/Chromium"];
@@ -6095,7 +6095,7 @@ var require_darwin = __commonJS({
6095
6095
  ${LSREGISTER} -dump | grep -E -i -o '/.+(google chrome( canary)?|chromium)\\.app(\\s|$)' | grep -E -v 'Caches|TimeMachine|Temporary|/Volumes|\\.Trash'
6096
6096
  `).toString().split(newLineRegex).forEach((inst) => {
6097
6097
  suffixes.forEach((suffix) => {
6098
- const execPath = path16.join(inst.trim(), suffix);
6098
+ const execPath = path17.join(inst.trim(), suffix);
6099
6099
  if (canAccess(execPath)) {
6100
6100
  installations.push(execPath);
6101
6101
  }
@@ -6124,7 +6124,7 @@ var require_linux = __commonJS({
6124
6124
  "use strict";
6125
6125
  init_esm_shims();
6126
6126
  var { execSync: execSync3, execFileSync } = __require("child_process");
6127
- var path16 = __require("path");
6127
+ var path17 = __require("path");
6128
6128
  var fs16 = __require("fs");
6129
6129
  var { canAccess, sort, isExecutable, newLineRegex } = require_util2();
6130
6130
  function findChromeExecutablesForLinuxDesktop(folder) {
@@ -6142,7 +6142,7 @@ var require_linux = __commonJS({
6142
6142
  function linux() {
6143
6143
  let installations = [];
6144
6144
  const desktopInstallationFolders = [
6145
- path16.join(__require("os").homedir(), ".local/share/applications/"),
6145
+ path17.join(__require("os").homedir(), ".local/share/applications/"),
6146
6146
  "/usr/share/applications/"
6147
6147
  ];
6148
6148
  desktopInstallationFolders.forEach((folder) => {
@@ -6750,8 +6750,8 @@ function getErrorMap() {
6750
6750
  // node_modules/zod/v3/helpers/parseUtil.js
6751
6751
  init_esm_shims();
6752
6752
  var makeIssue = (params) => {
6753
- const { data, path: path16, errorMaps, issueData } = params;
6754
- const fullPath = [...path16, ...issueData.path || []];
6753
+ const { data, path: path17, errorMaps, issueData } = params;
6754
+ const fullPath = [...path17, ...issueData.path || []];
6755
6755
  const fullIssue = {
6756
6756
  ...issueData,
6757
6757
  path: fullPath
@@ -6871,11 +6871,11 @@ var errorUtil;
6871
6871
 
6872
6872
  // node_modules/zod/v3/types.js
6873
6873
  var ParseInputLazyPath = class {
6874
- constructor(parent, value, path16, key) {
6874
+ constructor(parent, value, path17, key) {
6875
6875
  this._cachedPath = [];
6876
6876
  this.parent = parent;
6877
6877
  this.data = value;
6878
- this._path = path16;
6878
+ this._path = path17;
6879
6879
  this._key = key;
6880
6880
  }
6881
6881
  get path() {
@@ -10331,6 +10331,7 @@ var DEFAULT_OPTIONS = {
10331
10331
  historySize: 10,
10332
10332
  showTrend: true,
10333
10333
  flakinessTopN: 5,
10334
+ quarantineThreshold: 40,
10334
10335
  slowTestThreshold: 10,
10335
10336
  // Ticket-ID shape: @ODP-5328, REQ-001, JIRA-123. Tags matching this land in
10336
10337
  // the Requirements Traceability matrix; the rest fall to the tag summary.
@@ -10585,6 +10586,10 @@ var ReporterOptionsSchema = external_exports.object({
10585
10586
  // local history file is wiped every run. Aggregate numbers only.
10586
10587
  remoteHistory: external_exports.boolean().default(false),
10587
10588
  flakinessTopN: external_exports.number().int().min(0, "flakinessTopN must be 0 or greater").optional().default(DEFAULT_OPTIONS.flakinessTopN),
10589
+ quarantineThreshold: external_exports.number().int().min(0, "quarantineThreshold must be between 0 and 100").max(100, "quarantineThreshold must be between 0 and 100").optional().default(DEFAULT_OPTIONS.quarantineThreshold),
10590
+ evidenceUrlTemplate: external_exports.string().refine((s) => s.includes("{path}"), {
10591
+ message: "evidenceUrlTemplate must contain the {path} token"
10592
+ }).optional(),
10588
10593
  slowTestThreshold: external_exports.number().int().min(0, "slowTestThreshold must be 0 or greater").optional().default(DEFAULT_OPTIONS.slowTestThreshold),
10589
10594
  requirementTagPattern: external_exports.string().refine((v) => {
10590
10595
  try {
@@ -13374,7 +13379,7 @@ var Redactor = class {
13374
13379
 
13375
13380
  // src/pipeline/orchestrator.ts
13376
13381
  init_esm_shims();
13377
- import path15 from "path";
13382
+ import path16 from "path";
13378
13383
 
13379
13384
  // src/license/update-notice.ts
13380
13385
  init_esm_shims();
@@ -13791,8 +13796,8 @@ function buildBrowserMatrix(projects, cap = DEFAULT_ROW_CAP) {
13791
13796
  let projectNames = projects.map((p, i) => p.name || `project ${i + 1}`);
13792
13797
  const byTest = /* @__PURE__ */ new Map();
13793
13798
  projects.forEach((project, column) => {
13794
- walk(project.suites, [], (path16, test) => {
13795
- const key = [...path16, test.title].join(" > ");
13799
+ walk(project.suites, [], (path17, test) => {
13800
+ const key = [...path17, test.title].join(" > ");
13796
13801
  let statuses = byTest.get(key);
13797
13802
  if (!statuses) {
13798
13803
  statuses = projectNames.map(() => null);
@@ -13835,14 +13840,66 @@ function buildBrowserMatrix(projects, cap = DEFAULT_ROW_CAP) {
13835
13840
  function severityRank(row) {
13836
13841
  return row.statuses.some((s) => s === "failed" || s === "timedOut") ? 1 : 0;
13837
13842
  }
13838
- function walk(suites, path16, visit) {
13843
+ function walk(suites, path17, visit) {
13839
13844
  for (const suite of suites) {
13840
- const next = [...path16, suite.title];
13845
+ const next = [...path17, suite.title];
13841
13846
  for (const test of suite.tests) visit(next, test);
13842
13847
  walk(suite.suites, next, visit);
13843
13848
  }
13844
13849
  }
13845
13850
 
13851
+ // src/utils/evidence-links.ts
13852
+ init_esm_shims();
13853
+ import path14 from "path";
13854
+ import qrcodeFactory from "qrcode-generator";
13855
+ function applyEvidenceUrls(data, template, cwd) {
13856
+ let warned = false;
13857
+ const warnOnce = (err) => {
13858
+ if (warned) return;
13859
+ warned = true;
13860
+ const message = err instanceof Error ? err.message : String(err);
13861
+ logger.warn(`evidenceUrlTemplate: link generation failed for at least one artifact \u2014 ${message}`);
13862
+ };
13863
+ for (const failure of data.failures) {
13864
+ const evidence = failure.execution?.evidence;
13865
+ if (!evidence) continue;
13866
+ if (evidence.tracePath) {
13867
+ try {
13868
+ evidence.traceUrl = expandTemplate(template, relativizeArtifact(evidence.tracePath, cwd));
13869
+ evidence.traceQr = qrDataUri(evidence.traceUrl);
13870
+ } catch (err) {
13871
+ warnOnce(err);
13872
+ }
13873
+ }
13874
+ if (evidence.videoPath) {
13875
+ try {
13876
+ evidence.videoUrl = expandTemplate(template, relativizeArtifact(evidence.videoPath, cwd));
13877
+ evidence.videoQr = qrDataUri(evidence.videoUrl);
13878
+ } catch (err) {
13879
+ warnOnce(err);
13880
+ }
13881
+ }
13882
+ }
13883
+ }
13884
+ function relativizeArtifact(artifactPath, cwd) {
13885
+ if (!path14.isAbsolute(artifactPath)) return artifactPath;
13886
+ const rel = path14.relative(cwd, artifactPath);
13887
+ if (rel === ".." || rel.startsWith(`..${path14.sep}`) || path14.isAbsolute(rel)) return artifactPath;
13888
+ return rel;
13889
+ }
13890
+ function expandTemplate(template, artifactPath) {
13891
+ const normalized = artifactPath.replace(/\\/g, "/");
13892
+ const encoded = normalized.split("/").map((seg) => encodeURIComponent(seg)).join("/");
13893
+ return template.split("{path}").join(encoded);
13894
+ }
13895
+ function qrDataUri(url) {
13896
+ const qr = qrcodeFactory(0, "M");
13897
+ qr.addData(url);
13898
+ qr.make();
13899
+ const svg = qr.createSvgTag({ cellSize: 2, margin: 2, scalable: true });
13900
+ return `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`;
13901
+ }
13902
+
13846
13903
  // src/redact/redact-report.ts
13847
13904
  init_esm_shims();
13848
13905
  function redactReportData(data, r) {
@@ -13899,7 +13956,7 @@ function redactSuite(s, r) {
13899
13956
  // src/history/HistoryManager.ts
13900
13957
  init_esm_shims();
13901
13958
  import fs15 from "fs";
13902
- import path14 from "path";
13959
+ import path15 from "path";
13903
13960
  var HistoryManager = class {
13904
13961
  constructor(filePath) {
13905
13962
  this.filePath = filePath;
@@ -13920,7 +13977,7 @@ var HistoryManager = class {
13920
13977
  }
13921
13978
  append(entry, maxSize) {
13922
13979
  try {
13923
- fs15.mkdirSync(path14.dirname(this.filePath), { recursive: true });
13980
+ fs15.mkdirSync(path15.dirname(this.filePath), { recursive: true });
13924
13981
  const next = {
13925
13982
  version: 1,
13926
13983
  entries: [entry, ...this.read()].slice(0, maxSize)
@@ -14008,7 +14065,7 @@ function splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses
14008
14065
  return { fixed, noLongerRun };
14009
14066
  }
14010
14067
  function computeRunDiff(args) {
14011
- const { currentFailing, currentStatuses, priorEntries, branch } = args;
14068
+ const { currentFailing, currentFlaky = [], currentStatuses, priorEntries, branch } = args;
14012
14069
  const baseline = priorEntries.find((e) => e.branch === branch && Array.isArray(e.failedTests));
14013
14070
  if (!baseline) return null;
14014
14071
  const baselineFailed = /* @__PURE__ */ new Map();
@@ -14017,7 +14074,15 @@ function computeRunDiff(args) {
14017
14074
  }
14018
14075
  const { newFailures, stillFailing, currentFailedIds } = splitCurrentFailing(currentFailing, baselineFailed);
14019
14076
  const { fixed, noLongerRun } = splitBaselineResolved(baselineFailed, currentFailedIds, currentStatuses);
14020
- for (const bucket of [newFailures, stillFailing, fixed, noLongerRun]) {
14077
+ const baselineFlakyIds = new Set((baseline.flakyTests ?? []).map((t) => t.id));
14078
+ const newlyFlaky = [];
14079
+ const seenFlaky = /* @__PURE__ */ new Set();
14080
+ for (const item of currentFlaky) {
14081
+ if (seenFlaky.has(item.id) || baselineFlakyIds.has(item.id)) continue;
14082
+ seenFlaky.add(item.id);
14083
+ newlyFlaky.push(item);
14084
+ }
14085
+ for (const bucket of [newFailures, stillFailing, fixed, noLongerRun, newlyFlaky]) {
14021
14086
  bucket.sort((a, b) => a.title.localeCompare(b.title));
14022
14087
  }
14023
14088
  return {
@@ -14025,6 +14090,7 @@ function computeRunDiff(args) {
14025
14090
  stillFailing,
14026
14091
  fixed,
14027
14092
  noLongerRun,
14093
+ newlyFlaky,
14028
14094
  baselineRunId: baseline.runId,
14029
14095
  baselineTimestamp: baseline.timestamp,
14030
14096
  baselineTruncated: baseline.failedTestsTruncated === true
@@ -14033,10 +14099,11 @@ function computeRunDiff(args) {
14033
14099
 
14034
14100
  // src/history/FlakinessAnalyser.ts
14035
14101
  init_esm_shims();
14036
- function computeTopN(entries, topN) {
14037
- if (topN === 0) return [];
14102
+ var QUARANTINE_MIN_RUNS = 3;
14103
+ function computeTopN(entries, topN, quarantineThreshold = 0) {
14104
+ if (topN === 0) return { rows: [], quarantineTotal: 0 };
14038
14105
  const qualifying = entries.filter((e) => Array.isArray(e.flakyTests));
14039
- if (qualifying.length === 0) return [];
14106
+ if (qualifying.length === 0) return { rows: [], quarantineTotal: 0 };
14040
14107
  const map = /* @__PURE__ */ new Map();
14041
14108
  qualifying.forEach((entry, idx) => {
14042
14109
  const seen = /* @__PURE__ */ new Set();
@@ -14054,17 +14121,26 @@ function computeTopN(entries, topN) {
14054
14121
  const originalIdx = qualifying.length - 1 - reversedIdx;
14055
14122
  return hits.has(originalIdx);
14056
14123
  });
14124
+ let streak = 0;
14125
+ while (streak < totalRuns && hits.has(streak)) streak++;
14126
+ const flakeRate = Math.round(flakeCount / totalRuns * 100);
14057
14127
  return {
14058
14128
  title,
14059
- flakeRate: Math.round(flakeCount / totalRuns * 100),
14129
+ flakeRate,
14060
14130
  flakeCount,
14061
14131
  totalRuns,
14062
- sparkline
14132
+ sparkline,
14133
+ streak,
14134
+ quarantine: quarantineThreshold > 0 && totalRuns >= QUARANTINE_MIN_RUNS && flakeRate >= quarantineThreshold
14063
14135
  };
14064
14136
  });
14065
- return rows.sort(
14066
- (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
14067
- ).slice(0, topN);
14137
+ const quarantineTotal = rows.filter((r) => r.quarantine).length;
14138
+ return {
14139
+ rows: rows.sort(
14140
+ (a, b) => b.flakeRate - a.flakeRate || b.flakeCount - a.flakeCount || a.title.localeCompare(b.title)
14141
+ ).slice(0, topN),
14142
+ quarantineTotal
14143
+ };
14068
14144
  }
14069
14145
 
14070
14146
  // src/notify/index.ts
@@ -14507,6 +14583,9 @@ async function runReportPipeline(hooks) {
14507
14583
  }
14508
14584
  }
14509
14585
  const reportData = buildReportData(collected, licenseInfo, hooks);
14586
+ if (options.evidenceUrlTemplate) {
14587
+ applyEvidenceUrls(reportData, options.evidenceUrlTemplate, hooks.cwd);
14588
+ }
14510
14589
  runFailureAnalysis(reportData, options, hooks.cwd);
14511
14590
  reportData.briefSentence = buildBriefSentence(reportData.stats, reportData.analysis, reportData.charts.trend);
14512
14591
  if (hooks.redactor) {
@@ -14555,7 +14634,7 @@ async function appendTrend(collected, licenseInfo, hooks) {
14555
14634
  branch: collected.environment.branch
14556
14635
  };
14557
14636
  const localEntries = hm.append(entry, options.historySize);
14558
- collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch);
14637
+ collected.runDiff = resolveRunDiff(collected.projects, redactedFailing, localEntries.slice(1), collected.environment.branch, flakyTests);
14559
14638
  let trendEntries = localEntries;
14560
14639
  if (options.remoteHistory && licenseInfo.jwt) {
14561
14640
  const serverUrl = options.serverUrl ?? process.env.REPORTFORGE_SERVER_URL ?? DEFAULT_SERVER_URL;
@@ -14588,9 +14667,17 @@ function populateHistoryCharts(trendEntries, localEntries, collected, options) {
14588
14667
  };
14589
14668
  }
14590
14669
  if (options.flakinessTopN > 0) {
14591
- const table = computeTopN(localEntries, options.flakinessTopN);
14592
- if (table.length > 0) {
14593
- collected.charts.flakinessTable = table;
14670
+ const { rows, quarantineTotal } = computeTopN(
14671
+ localEntries,
14672
+ options.flakinessTopN,
14673
+ options.quarantineThreshold
14674
+ );
14675
+ if (rows.length > 0) {
14676
+ collected.charts.flakinessTable = rows;
14677
+ if (quarantineTotal > 0) {
14678
+ collected.charts.flakinessQuarantineTotal = quarantineTotal;
14679
+ collected.charts.flakinessQuarantineThreshold = options.quarantineThreshold;
14680
+ }
14594
14681
  }
14595
14682
  }
14596
14683
  }
@@ -14623,7 +14710,7 @@ function buildReportData(collected, licenseInfo, hooks) {
14623
14710
  }
14624
14711
  function resolveProjectNameFromPkg(cwd) {
14625
14712
  try {
14626
- const pkgPath = path15.resolve(cwd, "package.json");
14713
+ const pkgPath = path16.resolve(cwd, "package.json");
14627
14714
  const pkg = __require(pkgPath);
14628
14715
  return pkg.name ?? "Playwright Tests";
14629
14716
  } catch {
@@ -14672,9 +14759,9 @@ function deriveVerdict(stats) {
14672
14759
  }
14673
14760
  function resolveHistoryPath(options, cwd) {
14674
14761
  if (options.historyFile) {
14675
- return path15.isAbsolute(options.historyFile) ? options.historyFile : path15.resolve(cwd, options.historyFile);
14762
+ return path16.isAbsolute(options.historyFile) ? options.historyFile : path16.resolve(cwd, options.historyFile);
14676
14763
  }
14677
- return path15.join(resolveProjectDir(options, cwd), "history.json");
14764
+ return path16.join(resolveProjectDir(options, cwd), "history.json");
14678
14765
  }
14679
14766
  function flattenSuiteNodes(suites) {
14680
14767
  return suites.flatMap((s) => [s, ...flattenSuiteNodes(s.suites)]);
@@ -14727,9 +14814,10 @@ function buildFailedTestsFields(projects, redactor) {
14727
14814
  const failedTests = redactedFailing.slice(0, FAILED_TESTS_CAP);
14728
14815
  return redactedFailing.length > FAILED_TESTS_CAP ? { redactedFailing, failedTests, failedTestsTruncated: true } : { redactedFailing, failedTests };
14729
14816
  }
14730
- function resolveRunDiff(projects, currentFailing, priorEntries, branch) {
14817
+ function resolveRunDiff(projects, currentFailing, priorEntries, branch, currentFlaky) {
14731
14818
  return computeRunDiff({
14732
14819
  currentFailing,
14820
+ currentFlaky,
14733
14821
  currentStatuses: collectCurrentStatuses(projects),
14734
14822
  priorEntries,
14735
14823
  branch
@@ -15249,9 +15337,9 @@ var MAX_FILE_CHARS = 300;
15249
15337
  var MAX_PROJECT_CHARS = 100;
15250
15338
  var MAX_SUITE_SEGMENTS = 10;
15251
15339
  function suiteIdentity(test) {
15252
- const path16 = typeof test.titlePath === "function" ? test.titlePath() : [];
15253
- if (!Array.isArray(path16) || path16.length < 4) return {};
15254
- const [, project, file, ...rest] = path16;
15340
+ const path17 = typeof test.titlePath === "function" ? test.titlePath() : [];
15341
+ if (!Array.isArray(path17) || path17.length < 4) return {};
15342
+ const [, project, file, ...rest] = path17;
15255
15343
  const describes = rest.slice(0, -1);
15256
15344
  const out = {};
15257
15345
  if (typeof file === "string" && file) {
@@ -15370,7 +15458,7 @@ var PdfReporter = class {
15370
15458
  if (this.options.redact.enabled) {
15371
15459
  this.redactor = new Redactor(this.options.redact);
15372
15460
  }
15373
- this.version = true ? "0.29.0" : "0.x";
15461
+ this.version = true ? "0.30.0" : "0.x";
15374
15462
  logger.info(`@reportforge/playwright-pdf v${this.version} initialised`);
15375
15463
  this.licenseClient = new LicenseClient({
15376
15464
  licenseKey: this.options.licenseKey,
@@ -13,21 +13,29 @@
13
13
  <div class="flakiness-card__header">
14
14
  <span class="flakiness-card__title">Top flaky tests &mdash; last {{charts.flakinessTable.[0].totalRuns}} runs</span>
15
15
  </div>
16
+ {{#if charts.flakinessQuarantineTotal}}
17
+ <div class="flakiness-quarantine-callout">
18
+ <strong>{{charts.flakinessQuarantineTotal}} {{pluralise charts.flakinessQuarantineTotal "test exceeds" "tests exceed"}} the {{#if charts.flakinessQuarantineThreshold}}{{charts.flakinessQuarantineThreshold}}% {{/if}}quarantine threshold.</strong>
19
+ Quarantine or stabilize {{pluralise charts.flakinessQuarantineTotal "it before it masks" "them before they mask"}} real regressions.
20
+ </div>
21
+ {{/if}}
16
22
  <table class="flakiness-table">
17
23
  <thead>
18
24
  <tr>
19
25
  <th class="flakiness-table__test">Test</th>
20
26
  <th class="flakiness-table__rate">Flake rate</th>
21
27
  <th class="flakiness-table__count">Runs flaky</th>
28
+ <th class="flakiness-table__streak">Streak</th>
22
29
  <th class="flakiness-table__spark">History</th>
23
30
  </tr>
24
31
  </thead>
25
32
  <tbody>
26
33
  {{#each charts.flakinessTable}}
27
34
  <tr>
28
- <td class="flakiness-table__test" title="{{this.title}}">{{this.title}}</td>
35
+ <td class="flakiness-table__test" title="{{this.title}}"><span class="flakiness-table__test-inner"><span class="flakiness-table__test-title">{{this.title}}</span>{{#if this.quarantine}}<span class="quarantine-chip">QUARANTINE</span>{{/if}}</span></td>
29
36
  <td class="flakiness-table__rate">{{this.flakeRate}}%</td>
30
37
  <td class="flakiness-table__count">{{this.flakeCount}} / {{this.totalRuns}}</td>
38
+ <td class="flakiness-table__streak">{{#if this.streak}}{{this.streak}}&times;{{else}}&ndash;{{/if}}</td>
31
39
  <td class="flakiness-table__spark">
32
40
  <span class="flaky-spark">
33
41
  {{#each this.sparkline}}
@@ -64,8 +64,8 @@
64
64
 
65
65
  {{#if execution.evidence}}
66
66
  <div class="evidence-links">
67
- {{#if execution.evidence.tracePath}}<span class="evidence-link"><span class="evidence-link__tag">Trace</span><span class="evidence-link__path">{{execution.evidence.tracePath}}</span></span>{{/if}}
68
- {{#if execution.evidence.videoPath}}<span class="evidence-link"><span class="evidence-link__tag">Video</span><span class="evidence-link__path">{{execution.evidence.videoPath}}</span></span>{{/if}}
67
+ {{#if execution.evidence.tracePath}}<span class="evidence-link"><span class="evidence-link__tag">Trace</span>{{#if execution.evidence.traceUrl}}<a class="evidence-link__path evidence-link__path--url" href="{{execution.evidence.traceUrl}}">{{execution.evidence.traceUrl}}</a>{{#if execution.evidence.traceQr}}<img class="evidence-link__qr" src="{{execution.evidence.traceQr}}" alt="Trace QR" />{{/if}}{{else}}<span class="evidence-link__path">{{execution.evidence.tracePath}}</span>{{/if}}</span>{{/if}}
68
+ {{#if execution.evidence.videoPath}}<span class="evidence-link"><span class="evidence-link__tag">Video</span>{{#if execution.evidence.videoUrl}}<a class="evidence-link__path evidence-link__path--url" href="{{execution.evidence.videoUrl}}">{{execution.evidence.videoUrl}}</a>{{#if execution.evidence.videoQr}}<img class="evidence-link__qr" src="{{execution.evidence.videoQr}}" alt="Video QR" />{{/if}}{{else}}<span class="evidence-link__path">{{execution.evidence.videoPath}}</span>{{/if}}</span>{{/if}}
69
69
  </div>
70
70
  {{/if}}
71
71
  </div>
@@ -3,7 +3,7 @@
3
3
  <h2>Since Last Run</h2>
4
4
  <div class="run-diff__head">
5
5
  <div class="run-diff__strip">
6
- <span class="run-diff__count run-diff__count--new"><strong>{{runDiff.newFailures.length}}</strong> {{pluralise runDiff.newFailures.length "new failure" "new failures"}}</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--fixed"><strong>{{runDiff.fixed.length}}</strong> fixed</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--still"><strong>{{runDiff.stillFailing.length}}</strong> still failing</span>{{#if runDiff.noLongerRun.length}}&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--neutral"><strong>{{runDiff.noLongerRun.length}}</strong> no longer run</span>{{/if}}
6
+ <span class="run-diff__count run-diff__count--new"><strong>{{runDiff.newFailures.length}}</strong> {{pluralise runDiff.newFailures.length "new failure" "new failures"}}</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--fixed"><strong>{{runDiff.fixed.length}}</strong> fixed</span>&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--still"><strong>{{runDiff.stillFailing.length}}</strong> still failing</span>{{#if runDiff.newlyFlaky.length}}&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--flaky"><strong>{{runDiff.newlyFlaky.length}}</strong> newly flaky</span>{{/if}}{{#if runDiff.noLongerRun.length}}&nbsp;&middot;&nbsp;<span class="run-diff__count run-diff__count--neutral"><strong>{{runDiff.noLongerRun.length}}</strong> no longer run</span>{{/if}}
7
7
  </div>
8
8
  <div class="run-diff__baseline">vs. previous run on {{formatDate runDiff.baselineTimestamp}}</div>
9
9
  {{#if runDiff.baselineTruncated}}
@@ -13,6 +13,7 @@
13
13
 
14
14
  {{#if (eq variant 'full')}}
15
15
  {{> run-diff-list items=runDiff.newFailures label="New Failures" }}
16
+ {{> run-diff-list items=runDiff.newlyFlaky label="Newly Flaky" }}
16
17
  {{> run-diff-list items=runDiff.fixed label="Fixed" }}
17
18
  {{> run-diff-list items=runDiff.stillFailing label="Still Failing" }}
18
19
  {{#if runDiff.noLongerRun.length}}
@@ -978,3 +978,34 @@ code {
978
978
  .browser-matrix__note {
979
979
  font-size: 9px; color: var(--text-3); margin-top: 6px;
980
980
  }
981
+
982
+ /* ── Flaky intelligence (quarantine + streak) ───────────────────────────── */
983
+ /* The title cell ellipsizes; the chip must never be the part that clips, so
984
+ the cell content is a flex row: shrinking title span + fixed chip. */
985
+ .flakiness-table__test-inner { display: flex; align-items: center; gap: 5px; min-width: 0; }
986
+ .flakiness-table__test-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
987
+ .flakiness-quarantine-callout {
988
+ font-size: 9.5px; color: var(--text-2);
989
+ background: var(--flaky-dim);
990
+ border: 1px solid var(--flaky);
991
+ border-radius: 6px; padding: 7px 10px; margin-bottom: 10px;
992
+ }
993
+ .quarantine-chip {
994
+ flex: none;
995
+ font-family: 'Inter', system-ui, sans-serif; font-weight: 700;
996
+ font-size: 6.5px; letter-spacing: 0.7px;
997
+ color: var(--flaky); border: 1px solid var(--flaky);
998
+ border-radius: 3px; padding: 1px 4px;
999
+ }
1000
+ .flakiness-table__streak { text-align: center; width: 52px; }
1001
+ .run-diff__count--flaky strong { color: var(--flaky); }
1002
+
1003
+ /* ── Evidence deep links + QR (evidenceUrlTemplate) ─────────────────────── */
1004
+ .evidence-link__path--url {
1005
+ color: var(--accent); text-decoration: none;
1006
+ word-break: break-all;
1007
+ }
1008
+ .evidence-link__qr {
1009
+ width: 44px; height: 44px; margin-left: 8px; vertical-align: middle;
1010
+ border: 1px solid var(--border); border-radius: 3px; background: #fff;
1011
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reportforge/playwright-pdf",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Playwright Test reporter that generates designed PDF reports: minimal, detailed, and executive templates with CI/CD integrations",
5
5
  "license": "Elastic-2.0",
6
6
  "author": "ReportForge",
@@ -91,6 +91,7 @@
91
91
  "chrome-finder": "^1.0.7",
92
92
  "glob": "^13.0.6",
93
93
  "handlebars": "^4.7.8",
94
+ "qrcode-generator": "^2.0.4",
94
95
  "sharp": "^0.35.3",
95
96
  "zod": "^3.23.8"
96
97
  },