executable-stories-formatters 1.9.2 → 1.10.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.cjs CHANGED
@@ -37,6 +37,7 @@ __export(src_exports, {
37
37
  CucumberHtmlFormatter: () => CucumberHtmlFormatter,
38
38
  CucumberJsonFormatter: () => CucumberJsonFormatter,
39
39
  CucumberMessagesFormatter: () => CucumberMessagesFormatter,
40
+ DEFAULT_LOCKFILE_PATH: () => DEFAULT_LOCKFILE_PATH,
40
41
  ES_THEME_TOKENS_CSS: () => import_tokens2.ES_THEME_TOKENS_CSS,
41
42
  ES_THEME_TOKEN_VALUES: () => import_tokens2.ES_THEME_TOKEN_VALUES,
42
43
  JUnitFormatter: () => JUnitFormatter,
@@ -44,6 +45,7 @@ __export(src_exports, {
44
45
  MIN_METRIC_SAMPLES: () => MIN_METRIC_SAMPLES,
45
46
  MIN_PERF_SAMPLES: () => MIN_PERF_SAMPLES,
46
47
  MarkdownFormatter: () => MarkdownFormatter,
48
+ PROVIDER_NAMES: () => PROVIDER_NAMES,
47
49
  ReleaseManifestFormatter: () => ReleaseManifestFormatter,
48
50
  ReportGenerator: () => ReportGenerator,
49
51
  ReviewHtmlFormatter: () => ReviewHtmlFormatter,
@@ -62,11 +64,15 @@ __export(src_exports, {
62
64
  adaptPlaywrightRun: () => adaptPlaywrightRun,
63
65
  adaptVitestRun: () => adaptVitestRun,
64
66
  advanceState: () => import_executable_stories_core3.advanceState,
67
+ analyzeSync: () => analyzeSync,
68
+ applySync: () => applySync,
65
69
  assembleCodeDiff: () => assembleCodeDiff,
66
70
  assertValidRun: () => import_validate.assertValidRun,
67
71
  buildCheck: () => buildCheck,
72
+ buildCoverageJson: () => buildCoverageJson,
68
73
  buildGoal: () => buildGoal,
69
74
  buildHtmlDocEntry: () => import_doc_builders.buildHtmlDocEntry,
75
+ buildProvider: () => buildProvider,
70
76
  buildReview: () => buildReview,
71
77
  buildTriage: () => buildTriage,
72
78
  bundleAssets: () => bundleAssets,
@@ -76,11 +82,14 @@ __export(src_exports, {
76
82
  classifyStatusChange: () => classifyStatusChange,
77
83
  clearVersionCache: () => clearVersionCache,
78
84
  codeDiffDiagnostics: () => codeDiffDiagnostics,
85
+ collectAttachments: () => collectAttachments,
79
86
  computeTestMetrics: () => computeTestMetrics,
80
87
  copyMarkdownAssets: () => copyMarkdownAssets,
81
88
  createAnchor: () => createAnchor,
82
89
  createPrCommentSummary: () => createPrCommentSummary,
83
90
  createReportGenerator: () => createReportGenerator,
91
+ createTestRailProvider: () => createTestRailProvider,
92
+ createXrayProvider: () => createXrayProvider,
84
93
  deriveAudience: () => deriveAudience,
85
94
  deriveChangeType: () => deriveChangeType,
86
95
  deriveStepResults: () => import_acl8.deriveStepResults,
@@ -88,6 +97,7 @@ __export(src_exports, {
88
97
  detectPerformanceTrend: () => detectPerformanceTrend,
89
98
  diffRuns: () => diffRuns,
90
99
  diffStoryReports: () => diffStoryReports,
100
+ emptyLockfile: () => emptyLockfile,
91
101
  findGitDir: () => findGitDir,
92
102
  formatDuration: () => import_duration.formatDuration,
93
103
  generateRunComparison: () => generateRunComparison,
@@ -97,7 +107,9 @@ __export(src_exports, {
97
107
  getEnvironmentDrift: () => getEnvironmentDrift,
98
108
  gradeEvidence: () => gradeEvidence,
99
109
  hasSufficientHistory: () => hasSufficientHistory,
110
+ hashCaseBody: () => hashCaseBody,
100
111
  initialRunState: () => import_executable_stories_core3.initialRunState,
112
+ isProviderName: () => isProviderName,
101
113
  isReviewableSource: () => isReviewableSource,
102
114
  isTestFile: () => isTestFile,
103
115
  joinNameAndExt: () => joinNameAndExt,
@@ -112,19 +124,26 @@ __export(src_exports, {
112
124
  normalizeStatus: () => import_acl4.normalizeStatus,
113
125
  normalizeVitestResults: () => normalizeVitestResults,
114
126
  parseEnvelopes: () => import_ndjson_parser.parseEnvelopes,
127
+ parseLockfile: () => parseLockfile,
115
128
  parseNdjson: () => import_ndjson_parser.parseNdjson,
116
129
  parseUnifiedDiff: () => parseUnifiedDiff,
130
+ projectBehaviours: () => projectBehaviours,
117
131
  publishConfluencePage: () => publishConfluencePage,
118
132
  publishJiraIssue: () => publishJiraIssue,
119
133
  readBranchName: () => readBranchName,
120
134
  readGitSha: () => readGitSha,
135
+ readLockfile: () => readLockfile,
121
136
  readPackageVersion: () => readPackageVersion,
122
137
  recordDeployment: () => recordDeployment,
123
138
  regenerateArtifacts: () => regenerateArtifacts,
124
139
  regenerateRun: () => regenerateRun,
125
140
  relocateAnchor: () => relocateAnchor,
141
+ renderApplyResult: () => renderApplyResult,
126
142
  renderCheck: () => renderCheck,
143
+ renderCoverageMarkdown: () => renderCoverageMarkdown,
144
+ renderCoverageText: () => renderCoverageText,
127
145
  renderGoal: () => renderGoal,
146
+ renderPlan: () => renderPlan,
128
147
  renderTriage: () => renderTriage,
129
148
  resolveAttachment: () => import_acl10.resolveAttachment,
130
149
  resolveAttachments: () => import_acl11.resolveAttachments,
@@ -136,6 +155,7 @@ __export(src_exports, {
136
155
  sendSlackNotification: () => sendSlackNotification,
137
156
  sendTeamsNotification: () => sendTeamsNotification,
138
157
  sendWebhookNotification: () => sendWebhookNotification,
158
+ serializeLockfile: () => serializeLockfile,
139
159
  signBody: () => signBody,
140
160
  slugify: () => import_acl7.slugify,
141
161
  startWatch: () => startWatch,
@@ -143,6 +163,7 @@ __export(src_exports, {
143
163
  toAgentText: () => toAgentText,
144
164
  toBehaviorManifest: () => toBehaviorManifest,
145
165
  toCIInfo: () => import_ci.toCIInfo,
166
+ toCaseBody: () => toCaseBody,
146
167
  toRawCIInfo: () => import_ci.toRawCIInfo,
147
168
  toReleaseManifest: () => toReleaseManifest,
148
169
  toScenarioIndex: () => toScenarioIndex,
@@ -150,10 +171,11 @@ __export(src_exports, {
150
171
  toTraceabilityMatrix: () => toTraceabilityMatrix,
151
172
  tryGetActiveOtelContext: () => import_otel_detect.tryGetActiveOtelContext,
152
173
  updateHistory: () => updateHistory,
153
- validateCanonicalRun: () => import_validate.validateCanonicalRun
174
+ validateCanonicalRun: () => import_validate.validateCanonicalRun,
175
+ writeLockfile: () => writeLockfile
154
176
  });
155
177
  module.exports = __toCommonJS(src_exports);
156
- var path9 = __toESM(require("path"), 1);
178
+ var path10 = __toESM(require("path"), 1);
157
179
  var fsPromises = __toESM(require("fs/promises"), 1);
158
180
  var import_story_report5 = require("executable-stories-core/converters/story-report");
159
181
 
@@ -801,50 +823,50 @@ function scenarioLines(scenario) {
801
823
  if (scenario.errorMessage) lines.push(indent(`error: ${scenario.errorMessage}`, " "));
802
824
  return lines;
803
825
  }
804
- function docLines(entry, pad) {
805
- const lines = ownDocLines(entry, pad);
806
- for (const child of entry.children ?? []) lines.push(...docLines(child, pad + " "));
826
+ function docLines(entry, pad2) {
827
+ const lines = ownDocLines(entry, pad2);
828
+ for (const child of entry.children ?? []) lines.push(...docLines(child, pad2 + " "));
807
829
  return lines;
808
830
  }
809
- function ownDocLines(entry, pad) {
831
+ function ownDocLines(entry, pad2) {
810
832
  switch (entry.kind) {
811
833
  case "note":
812
- return [indent(entry.text, pad)];
834
+ return [indent(entry.text, pad2)];
813
835
  case "tag":
814
836
  return [];
815
837
  // already on the scenario's tag line
816
838
  case "kv":
817
- return [`${pad}${entry.label}: ${compact(entry.value)}`];
839
+ return [`${pad2}${entry.label}: ${compact(entry.value)}`];
818
840
  case "code":
819
- return [`${pad}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad + " ")];
841
+ return [`${pad2}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad2 + " ")];
820
842
  case "table":
821
843
  return [
822
- `${pad}table ${entry.label}: ${entry.columns.join(" | ")}`,
823
- ...entry.rows.map((row) => `${pad} ${row.join(" | ")}`)
844
+ `${pad2}table ${entry.label}: ${entry.columns.join(" | ")}`,
845
+ ...entry.rows.map((row) => `${pad2} ${row.join(" | ")}`)
824
846
  ];
825
847
  case "link":
826
- return [`${pad}link ${entry.label}: ${entry.url}`];
848
+ return [`${pad2}link ${entry.label}: ${entry.url}`];
827
849
  case "section":
828
- return [`${pad}section ${entry.title}:`, indent(entry.markdown, pad + " ")];
850
+ return [`${pad2}section ${entry.title}:`, indent(entry.markdown, pad2 + " ")];
829
851
  case "mermaid":
830
- return [`${pad}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad + " ")];
852
+ return [`${pad2}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad2 + " ")];
831
853
  case "screenshot":
832
- return [`${pad}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
854
+ return [`${pad2}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
833
855
  case "video":
834
- return [`${pad}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
856
+ return [`${pad2}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
835
857
  case "html":
836
- return [`${pad}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
858
+ return [`${pad2}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
837
859
  case "state":
838
- return [`${pad}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
860
+ return [`${pad2}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
839
861
  case "custom":
840
- return [`${pad}custom ${entry.type}: ${compact(entry.data)}`];
862
+ return [`${pad2}custom ${entry.type}: ${compact(entry.data)}`];
841
863
  }
842
864
  }
843
865
  function compact(value) {
844
866
  return typeof value === "string" ? value : JSON.stringify(value);
845
867
  }
846
- function indent(text2, pad) {
847
- return text2.split("\n").map((line) => pad + line).join("\n");
868
+ function indent(text2, pad2) {
869
+ return text2.split("\n").map((line) => pad2 + line).join("\n");
848
870
  }
849
871
 
850
872
  // src/formatters/junit-xml.ts
@@ -1747,9 +1769,9 @@ var MAX_FUZZ = 2;
1747
1769
  var normalize = (line) => line.trim();
1748
1770
  var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
1749
1771
  function stripPathPrefix(raw) {
1750
- const path10 = raw.split(" ")[0].trim();
1751
- if (path10 === "/dev/null") return void 0;
1752
- return path10.replace(/^[ab]\//, "");
1772
+ const path11 = raw.split(" ")[0].trim();
1773
+ if (path11 === "/dev/null") return void 0;
1774
+ return path11.replace(/^[ab]\//, "");
1753
1775
  }
1754
1776
  function parseUnifiedDiff(patch) {
1755
1777
  const files = [];
@@ -1829,7 +1851,7 @@ function createAnchor(args) {
1829
1851
  };
1830
1852
  }
1831
1853
  function changedRunCandidates(anchor, file, fileIndex) {
1832
- const path10 = file.newPath ?? file.oldPath ?? "";
1854
+ const path11 = file.newPath ?? file.oldPath ?? "";
1833
1855
  const out = [];
1834
1856
  file.hunks.forEach((hunk, hunkIndex) => {
1835
1857
  outer: for (let i = 0; i + anchor.changed.length <= hunk.lines.length; i++) {
@@ -1840,7 +1862,7 @@ function changedRunCandidates(anchor, file, fileIndex) {
1840
1862
  continue outer;
1841
1863
  }
1842
1864
  }
1843
- out.push({ fileIndex, file: path10, hunkIndex, lineIndex: i, lines: hunk.lines });
1865
+ out.push({ fileIndex, file: path11, hunkIndex, lineIndex: i, lines: hunk.lines });
1844
1866
  }
1845
1867
  });
1846
1868
  return out;
@@ -1937,18 +1959,18 @@ function deriveChangeType(tags) {
1937
1959
  }
1938
1960
  return "unknown";
1939
1961
  }
1940
- function extensionOf(path10) {
1941
- const base = path10.split("/").pop() ?? path10;
1962
+ function extensionOf(path11) {
1963
+ const base = path11.split("/").pop() ?? path11;
1942
1964
  const dot = base.lastIndexOf(".");
1943
1965
  return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
1944
1966
  }
1945
- function isTestFile(path10) {
1946
- return TEST_INFIX.test(path10);
1967
+ function isTestFile(path11) {
1968
+ return TEST_INFIX.test(path11);
1947
1969
  }
1948
- function isReviewableSource(path10) {
1949
- if (isTestFile(path10)) return false;
1950
- if (path10.endsWith(".d.ts")) return false;
1951
- return CODE_EXTENSIONS.has(extensionOf(path10));
1970
+ function isReviewableSource(path11) {
1971
+ if (isTestFile(path11)) return false;
1972
+ if (path11.endsWith(".d.ts")) return false;
1973
+ return CODE_EXTENSIONS.has(extensionOf(path11));
1952
1974
  }
1953
1975
  function testBaseKey(testFile) {
1954
1976
  return testFile.replace(TEST_INFIX, "");
@@ -2052,7 +2074,7 @@ function toClaim(testCase, changedSourcePaths) {
2052
2074
  const { strength, reasons } = gradeEvidence(testCase, audience);
2053
2075
  const key = testBaseKey(testCase.sourceFile);
2054
2076
  const coversFiles = changedSourcePaths.filter(
2055
- (path10) => sourceBaseKey(path10) === key
2077
+ (path11) => sourceBaseKey(path11) === key
2056
2078
  );
2057
2079
  return {
2058
2080
  id: testCase.id,
@@ -2224,14 +2246,14 @@ var TraceabilityMatrixFormatter = class {
2224
2246
  lines.push("");
2225
2247
  lines.push(`Status: ${renderRequirementStatus(req.status)}`);
2226
2248
  if (req.covers.length > 0) {
2227
- lines.push(`Covers: ${req.covers.map((path10) => `\`${path10}\``).join(", ")}`);
2249
+ lines.push(`Covers: ${req.covers.map((path11) => `\`${path11}\``).join(", ")}`);
2228
2250
  }
2229
2251
  lines.push("");
2230
2252
  lines.push("| Status | Scenario | Source | Covers |");
2231
2253
  lines.push("| --- | --- | --- | --- |");
2232
2254
  for (const scenario of req.scenarios) {
2233
2255
  const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
2234
- const covers = scenario.covers.length > 0 ? scenario.covers.map((path10) => `\`${path10}\``).join(", ") : "";
2256
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path11) => `\`${path11}\``).join(", ") : "";
2235
2257
  lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
2236
2258
  }
2237
2259
  lines.push("");
@@ -2996,8 +3018,8 @@ function extractDocAttachments(step) {
2996
3018
  }
2997
3019
  return attachments;
2998
3020
  }
2999
- function guessMediaType(path10) {
3000
- const lower = path10.toLowerCase();
3021
+ function guessMediaType(path11) {
3022
+ const lower = path11.toLowerCase();
3001
3023
  if (lower.endsWith(".png")) return "image/png";
3002
3024
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
3003
3025
  if (lower.endsWith(".gif")) return "image/gif";
@@ -3138,11 +3160,11 @@ var CucumberHtmlFormatter = class {
3138
3160
  for (const envelope of envelopes) {
3139
3161
  const accepted = htmlStream.write(envelope);
3140
3162
  if (!accepted) {
3141
- await new Promise((resolve8) => htmlStream.once("drain", resolve8));
3163
+ await new Promise((resolve9) => htmlStream.once("drain", resolve9));
3142
3164
  }
3143
3165
  }
3144
- await new Promise((resolve8, reject) => {
3145
- collector.on("finish", resolve8);
3166
+ await new Promise((resolve9, reject) => {
3167
+ collector.on("finish", resolve9);
3146
3168
  collector.on("error", reject);
3147
3169
  htmlStream.end();
3148
3170
  });
@@ -5643,6 +5665,1395 @@ function convertAttachments(attachments) {
5643
5665
 
5644
5666
  // src/index.ts
5645
5667
  var import_story = require("executable-stories-core/types/story");
5668
+
5669
+ // src/sync/engine.ts
5670
+ var import_ids3 = require("executable-stories-core/converters/acl/ids");
5671
+
5672
+ // src/sync/lockfile.ts
5673
+ var fs4 = __toESM(require("fs"), 1);
5674
+ var path5 = __toESM(require("path"), 1);
5675
+ var import_node_crypto4 = require("crypto");
5676
+ var DEFAULT_LOCKFILE_PATH = ".executable-stories/sync.lock.json";
5677
+ var LOCKFILE_VERSION = 1;
5678
+ function emptyLockfile() {
5679
+ return { version: LOCKFILE_VERSION, providers: {} };
5680
+ }
5681
+ function hashCaseBody(body) {
5682
+ const canonical = JSON.stringify({
5683
+ title: body.title.trim(),
5684
+ steps: body.steps.map((s) => `${s.keyword.toLowerCase()}:${s.text.trim()}`),
5685
+ description: body.description.trim()
5686
+ });
5687
+ return (0, import_node_crypto4.createHash)("sha1").update(canonical).digest("hex").slice(0, 16);
5688
+ }
5689
+ function parseLockfile(contents, label) {
5690
+ let parsed;
5691
+ try {
5692
+ parsed = JSON.parse(contents);
5693
+ } catch (err) {
5694
+ throw new Error(
5695
+ `Sync lockfile at ${label} is not valid JSON: ${err.message}
5696
+ Fix or delete it \u2014 deleting orphans every existing case binding, so prefer fixing.`
5697
+ );
5698
+ }
5699
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5700
+ throw new Error(`Sync lockfile at ${label} must contain an object.`);
5701
+ }
5702
+ const lock = parsed;
5703
+ if (lock.version !== LOCKFILE_VERSION) {
5704
+ throw new Error(
5705
+ `Sync lockfile at ${label} has version ${String(lock.version)}, expected ${LOCKFILE_VERSION}.`
5706
+ );
5707
+ }
5708
+ return { version: LOCKFILE_VERSION, providers: lock.providers ?? {} };
5709
+ }
5710
+ function serializeLockfile(lock) {
5711
+ const providers = {};
5712
+ for (const provider of Object.keys(lock.providers).sort()) {
5713
+ const entries = lock.providers[provider] ?? {};
5714
+ const sorted = {};
5715
+ for (const key of Object.keys(entries).sort()) sorted[key] = entries[key];
5716
+ providers[provider] = sorted;
5717
+ }
5718
+ return `${JSON.stringify({ version: LOCKFILE_VERSION, providers }, null, 2)}
5719
+ `;
5720
+ }
5721
+ function readLockfile(file) {
5722
+ if (!fs4.existsSync(file)) return emptyLockfile();
5723
+ return parseLockfile(fs4.readFileSync(file, "utf8"), file);
5724
+ }
5725
+ function writeLockfile(file, lock) {
5726
+ fs4.mkdirSync(path5.dirname(path5.resolve(file)), { recursive: true });
5727
+ fs4.writeFileSync(file, serializeLockfile(lock), "utf8");
5728
+ }
5729
+ function entriesFor(lock, provider) {
5730
+ return lock.providers[provider] ?? {};
5731
+ }
5732
+ function setEntry(lock, provider, fingerprint, entry) {
5733
+ lock.providers[provider] ??= {};
5734
+ lock.providers[provider][fingerprint] = entry;
5735
+ }
5736
+
5737
+ // src/sync/engine.ts
5738
+ var DEFAULT_DUPLICATE_THRESHOLD = 0.7;
5739
+ var PARTIAL_RUN_ORPHAN_RATIO = 0.25;
5740
+ function normalizeTitle(text2) {
5741
+ return text2.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
5742
+ }
5743
+ function renderDocs(docs, depth = 0) {
5744
+ if (!docs || docs.length === 0) return "";
5745
+ const lines = [];
5746
+ for (const doc of docs) {
5747
+ switch (doc.kind) {
5748
+ case "note":
5749
+ lines.push(doc.text);
5750
+ break;
5751
+ case "kv":
5752
+ lines.push(`**${doc.label}:** ${formatValue(doc.value)}`);
5753
+ break;
5754
+ case "state":
5755
+ lines.push(`**${doc.label ?? "State"}:** ${formatValue(doc.value)}`);
5756
+ break;
5757
+ case "code":
5758
+ lines.push(`**${doc.label}**`, "", "```" + (doc.lang ?? ""), doc.content, "```");
5759
+ break;
5760
+ case "table":
5761
+ lines.push(
5762
+ `**${doc.label}**`,
5763
+ "",
5764
+ `| ${doc.columns.join(" | ")} |`,
5765
+ `| ${doc.columns.map(() => "---").join(" | ")} |`,
5766
+ ...doc.rows.map((row) => `| ${row.join(" | ")} |`)
5767
+ );
5768
+ break;
5769
+ case "link":
5770
+ lines.push(`[${doc.label}](${doc.url})`);
5771
+ break;
5772
+ case "section":
5773
+ lines.push(`${"#".repeat(Math.min(6, depth + 3))} ${doc.title}`, "", doc.markdown);
5774
+ break;
5775
+ case "mermaid":
5776
+ lines.push(...doc.title ? [`**${doc.title}**`, ""] : [], "```mermaid", doc.code, "```");
5777
+ break;
5778
+ case "screenshot":
5779
+ lines.push(`_Screenshot: ${doc.alt ?? doc.path}_`);
5780
+ break;
5781
+ case "video":
5782
+ lines.push(`_Video: ${doc.caption ?? doc.path}_`);
5783
+ break;
5784
+ case "html":
5785
+ lines.push(`_Embedded: ${doc.title ?? doc.url ?? doc.path ?? "html"}_`);
5786
+ break;
5787
+ case "custom":
5788
+ lines.push(`_${doc.type}_: ${formatValue(doc.data)}`);
5789
+ break;
5790
+ case "tag":
5791
+ break;
5792
+ }
5793
+ const children = renderDocs(doc.children, depth + 1);
5794
+ if (children) lines.push(children);
5795
+ }
5796
+ return lines.join("\n");
5797
+ }
5798
+ function formatValue(value) {
5799
+ if (typeof value === "string") return value;
5800
+ return JSON.stringify(value);
5801
+ }
5802
+ function scenarioUrl(config, tc) {
5803
+ if (!config.reportUrl) return void 0;
5804
+ const base = config.reportUrl.replace(/\/$/, "");
5805
+ const anchor = config.scenarioAnchor?.(tc);
5806
+ return anchor ? `${base}#${anchor}` : base;
5807
+ }
5808
+ function toCaseBody(tc, config) {
5809
+ const sections = [];
5810
+ const docs = renderDocs(tc.story.docs);
5811
+ if (docs) sections.push(docs);
5812
+ const tickets = tc.story.tickets ?? [];
5813
+ if (tickets.length > 0) {
5814
+ sections.push(
5815
+ `**Requirements:** ${tickets.map((t) => t.url ? `[${t.id}](${t.url})` : t.id).join(", ")}`
5816
+ );
5817
+ }
5818
+ if (tc.story.covers?.length) {
5819
+ sections.push(`**Covers:** ${tc.story.covers.join(", ")}`);
5820
+ }
5821
+ sections.push(`_Generated from ${tc.sourceFile}:${tc.sourceLine} by executable-stories. Edit the test, not this case._`);
5822
+ const links = [];
5823
+ const report = scenarioUrl(config, tc);
5824
+ if (report) links.push({ label: "Living documentation", url: report });
5825
+ for (const ticket of tickets) {
5826
+ if (ticket.url) links.push({ label: ticket.id, url: ticket.url });
5827
+ }
5828
+ return {
5829
+ title: tc.story.scenario,
5830
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
5831
+ description: sections.join("\n\n"),
5832
+ links
5833
+ };
5834
+ }
5835
+ function projectBehaviours(run, config) {
5836
+ const fingerprints = run.testCases.map(
5837
+ (tc) => (0, import_ids3.behaviourFingerprint)({
5838
+ scenario: tc.story.scenario,
5839
+ sourceFile: tc.sourceFile,
5840
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
5841
+ covers: tc.story.covers
5842
+ })
5843
+ );
5844
+ const counts = /* @__PURE__ */ new Map();
5845
+ for (const fp of fingerprints) {
5846
+ if (fp) counts.set(fp, (counts.get(fp) ?? 0) + 1);
5847
+ }
5848
+ return run.testCases.map((tc, index) => {
5849
+ const fp = fingerprints[index];
5850
+ const unique = fp !== "" && counts.get(fp) === 1;
5851
+ return {
5852
+ fingerprint: unique ? fp : tc.id,
5853
+ testCase: tc,
5854
+ body: toCaseBody(tc, config)
5855
+ };
5856
+ });
5857
+ }
5858
+ function roleFor(attachment) {
5859
+ const type = attachment.mediaType.toLowerCase();
5860
+ if (type.startsWith("image/")) return "screenshot";
5861
+ if (type.startsWith("video/")) return "video";
5862
+ if (attachment.name.toLowerCase().includes("trace")) return "trace";
5863
+ return "log";
5864
+ }
5865
+ function decode(attachment) {
5866
+ return attachment.contentEncoding === "BASE64" ? Uint8Array.from(Buffer.from(attachment.body, "base64")) : new TextEncoder().encode(attachment.body);
5867
+ }
5868
+ function collectAttachments(args) {
5869
+ const { testCase, policy, maxBytes } = args;
5870
+ if (policy === "none") return { attachments: [], oversized: [] };
5871
+ if (policy === "failed" && testCase.status !== "failed") return { attachments: [], oversized: [] };
5872
+ const attachments = [];
5873
+ const oversized = [];
5874
+ for (const raw of testCase.attachments) {
5875
+ const body = decode(raw);
5876
+ if (maxBytes !== void 0 && body.byteLength > maxBytes) {
5877
+ oversized.push({ filename: raw.name, bytes: body.byteLength, limit: maxBytes });
5878
+ continue;
5879
+ }
5880
+ attachments.push({
5881
+ filename: raw.name,
5882
+ mediaType: raw.mediaType,
5883
+ body,
5884
+ role: roleFor(raw)
5885
+ });
5886
+ }
5887
+ return { attachments, oversized };
5888
+ }
5889
+ function toCaseResult(args) {
5890
+ const { behaviour, caseId, provider, config } = args;
5891
+ const tc = behaviour.testCase;
5892
+ if (tc.status === "pending") return void 0;
5893
+ const { attachments, oversized } = collectAttachments({
5894
+ testCase: tc,
5895
+ policy: config.attach ?? "failed",
5896
+ maxBytes: provider.maxAttachmentBytes
5897
+ });
5898
+ return {
5899
+ result: {
5900
+ caseId,
5901
+ status: tc.status,
5902
+ durationMs: tc.durationMs,
5903
+ message: tc.errorMessage,
5904
+ url: scenarioUrl(config, tc),
5905
+ attachments: attachments.length > 0 ? attachments : void 0
5906
+ },
5907
+ oversized
5908
+ };
5909
+ }
5910
+ function ticketBinding(tc, config) {
5911
+ const prefix = config.ticketPrefix;
5912
+ if (!prefix) return void 0;
5913
+ const match = (tc.story.tickets ?? []).find((t) => t.id.startsWith(prefix));
5914
+ if (!match) return void 0;
5915
+ return config.ticketPrefixStrip === false ? match.id : match.id.slice(prefix.length);
5916
+ }
5917
+ async function analyzeSync(args) {
5918
+ const { run, provider, lockfile, config } = args;
5919
+ const local = projectBehaviours(run, config);
5920
+ const remoteCases = await provider.listCases();
5921
+ const remoteById = new Map(remoteCases.map((c) => [c.id, c]));
5922
+ const locked = entriesFor(lockfile, provider.name);
5923
+ const create = [];
5924
+ const update = [];
5925
+ const unchanged = [];
5926
+ const adopted = [];
5927
+ const skipped = [];
5928
+ const results = [];
5929
+ const oversized = [];
5930
+ const boundCaseIds = /* @__PURE__ */ new Set();
5931
+ let driftUncheckable = 0;
5932
+ for (const behaviour of local) {
5933
+ const entry = locked[behaviour.fingerprint];
5934
+ const caseId = entry?.caseId ?? ticketBinding(behaviour.testCase, config);
5935
+ const remote2 = caseId ? remoteById.get(caseId) : void 0;
5936
+ if (!caseId) {
5937
+ create.push({
5938
+ fingerprint: behaviour.fingerprint,
5939
+ scenario: behaviour.body.title,
5940
+ body: behaviour.body
5941
+ });
5942
+ continue;
5943
+ }
5944
+ if (!remote2) {
5945
+ skipped.push({
5946
+ fingerprint: behaviour.fingerprint,
5947
+ caseId,
5948
+ url: entry?.url ?? "",
5949
+ title: entry?.title ?? behaviour.body.title,
5950
+ reason: "case-missing"
5951
+ });
5952
+ continue;
5953
+ }
5954
+ boundCaseIds.add(caseId);
5955
+ const pending = toCaseResult({ behaviour, caseId, provider, config });
5956
+ if (pending) {
5957
+ results.push(pending.result);
5958
+ oversized.push(...pending.oversized);
5959
+ }
5960
+ const planned = {
5961
+ fingerprint: behaviour.fingerprint,
5962
+ caseId,
5963
+ url: remote2.url,
5964
+ scenario: behaviour.body.title,
5965
+ body: behaviour.body
5966
+ };
5967
+ if (!entry?.owned) {
5968
+ adopted.push(planned);
5969
+ continue;
5970
+ }
5971
+ const remoteHash = remote2.body ? hashCaseBody(remote2.body) : void 0;
5972
+ if (remoteHash !== void 0 && remoteHash !== entry.hash) {
5973
+ skipped.push({
5974
+ fingerprint: behaviour.fingerprint,
5975
+ caseId,
5976
+ url: remote2.url,
5977
+ title: remote2.title,
5978
+ reason: "remote-edited"
5979
+ });
5980
+ continue;
5981
+ }
5982
+ const baseline = remoteHash ?? entry.hash;
5983
+ if (baseline !== "" && hashCaseBody(behaviour.body) === baseline) {
5984
+ unchanged.push(planned);
5985
+ } else {
5986
+ update.push(planned);
5987
+ }
5988
+ if (remoteHash === void 0) driftUncheckable += 1;
5989
+ }
5990
+ const byNormalizedTitle = /* @__PURE__ */ new Map();
5991
+ for (const behaviour of local) byNormalizedTitle.set(normalizeTitle(behaviour.body.title), behaviour);
5992
+ const threshold = config.duplicateThreshold ?? DEFAULT_DUPLICATE_THRESHOLD;
5993
+ const remote = remoteCases.map((remoteCase) => {
5994
+ if (boundCaseIds.has(remoteCase.id)) {
5995
+ return { case: remoteCase, classification: "automated" };
5996
+ }
5997
+ const titleMatch = byNormalizedTitle.get(normalizeTitle(remoteCase.title));
5998
+ if (titleMatch) {
5999
+ return {
6000
+ case: remoteCase,
6001
+ classification: "duplicated",
6002
+ resembles: titleMatch.body.title
6003
+ };
6004
+ }
6005
+ if (remoteCase.body && remoteCase.body.steps.length > 0) {
6006
+ let best;
6007
+ for (const behaviour of local) {
6008
+ const score = (0, import_ids3.behaviourSimilarity)(
6009
+ {
6010
+ scenario: remoteCase.title,
6011
+ sourceFile: "",
6012
+ steps: remoteCase.body.steps
6013
+ },
6014
+ {
6015
+ scenario: behaviour.body.title,
6016
+ sourceFile: behaviour.testCase.sourceFile,
6017
+ steps: behaviour.body.steps
6018
+ }
6019
+ );
6020
+ if (!best || score > best.score) best = { behaviour, score };
6021
+ }
6022
+ if (best && best.score >= threshold) {
6023
+ return {
6024
+ case: remoteCase,
6025
+ classification: "possible-duplicate",
6026
+ resembles: best.behaviour.body.title,
6027
+ similarity: Number(best.score.toFixed(2))
6028
+ };
6029
+ }
6030
+ }
6031
+ return { case: remoteCase, classification: "manual-only" };
6032
+ });
6033
+ const localFingerprints = new Set(local.map((b) => b.fingerprint));
6034
+ const orphaned = Object.entries(locked).filter(([fingerprint]) => !localFingerprints.has(fingerprint)).map(([fingerprint, entry]) => ({
6035
+ fingerprint,
6036
+ caseId: entry.caseId,
6037
+ url: entry.url,
6038
+ title: entry.title
6039
+ }));
6040
+ const lockedCount = Object.keys(locked).length;
6041
+ const partialRunWarning = lockedCount > 0 && orphaned.length / lockedCount > PARTIAL_RUN_ORPHAN_RATIO ? `${orphaned.length} of ${lockedCount} bindings have no matching story. If this run was filtered (a -t/--grep flag, a single file), those are not deletions. Nothing is removed either way.` : void 0;
6042
+ const unsupported = [];
6043
+ if (create.length > 0 && !provider.createCase) unsupported.push("createCase");
6044
+ if (update.length > 0 && !provider.updateCase) unsupported.push("updateCase");
6045
+ if (results.length > 0 && !provider.recordResults) unsupported.push("recordResults");
6046
+ const byRole = {};
6047
+ let files = 0;
6048
+ let bytes = 0;
6049
+ for (const result of results) {
6050
+ for (const attachment of result.attachments ?? []) {
6051
+ files += 1;
6052
+ bytes += attachment.body.byteLength;
6053
+ const role = attachment.role ?? "log";
6054
+ byRole[role] = (byRole[role] ?? 0) + 1;
6055
+ }
6056
+ }
6057
+ return {
6058
+ provider: provider.name,
6059
+ target: provider.describeTarget?.(),
6060
+ local,
6061
+ remote,
6062
+ create,
6063
+ update,
6064
+ unchanged,
6065
+ adopted,
6066
+ skipped,
6067
+ orphaned,
6068
+ results,
6069
+ attachments: { files, bytes, oversized, byRole },
6070
+ unsupported,
6071
+ driftUncheckable,
6072
+ partialRunWarning
6073
+ };
6074
+ }
6075
+ async function applySync(args, deps) {
6076
+ const { analysis, provider, lockfile, config } = args;
6077
+ const result = {
6078
+ created: [],
6079
+ updated: [],
6080
+ resultsRecorded: 0,
6081
+ resultsSkipped: [],
6082
+ attachmentsUploaded: 0,
6083
+ errors: []
6084
+ };
6085
+ const byFingerprint = new Map(analysis.local.map((b) => [b.fingerprint, b]));
6086
+ const results = [...analysis.results];
6087
+ if (analysis.create.length > 0 && provider.createCase) {
6088
+ for (const planned of analysis.create) {
6089
+ try {
6090
+ const created = await provider.createCase(planned.body);
6091
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6092
+ caseId: created.id,
6093
+ url: created.url,
6094
+ hash: hashCaseBody(created.body ?? planned.body),
6095
+ title: created.title,
6096
+ owned: true
6097
+ });
6098
+ result.created.push({ scenario: planned.scenario, caseId: created.id, url: created.url });
6099
+ const behaviour = byFingerprint.get(planned.fingerprint);
6100
+ if (behaviour) {
6101
+ const pending = toCaseResult({ behaviour, caseId: created.id, provider, config });
6102
+ if (pending) results.push(pending.result);
6103
+ }
6104
+ } catch (err) {
6105
+ result.errors.push(`create "${planned.scenario}": ${err.message}`);
6106
+ }
6107
+ }
6108
+ }
6109
+ if (analysis.update.length > 0 && provider.updateCase) {
6110
+ for (const planned of analysis.update) {
6111
+ try {
6112
+ const updated = await provider.updateCase(planned.caseId, planned.body);
6113
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6114
+ caseId: updated.id,
6115
+ url: updated.url,
6116
+ hash: hashCaseBody(updated.body ?? planned.body),
6117
+ title: updated.title,
6118
+ owned: true
6119
+ });
6120
+ result.updated.push({ scenario: planned.scenario, caseId: updated.id, url: updated.url });
6121
+ } catch (err) {
6122
+ result.errors.push(`update "${planned.scenario}" (${planned.caseId}): ${err.message}`);
6123
+ }
6124
+ }
6125
+ }
6126
+ for (const planned of analysis.unchanged) {
6127
+ const existing = entriesFor(lockfile, provider.name)[planned.fingerprint];
6128
+ if (existing) {
6129
+ setEntry(lockfile, provider.name, planned.fingerprint, { ...existing, url: planned.url });
6130
+ }
6131
+ }
6132
+ for (const planned of analysis.adopted) {
6133
+ setEntry(lockfile, provider.name, planned.fingerprint, {
6134
+ caseId: planned.caseId,
6135
+ url: planned.url,
6136
+ hash: "",
6137
+ title: planned.scenario,
6138
+ owned: false
6139
+ });
6140
+ }
6141
+ if (results.length > 0 && provider.recordResults) {
6142
+ try {
6143
+ const summary = await provider.recordResults(results);
6144
+ result.resultsRecorded = summary.recorded;
6145
+ result.resultsSkipped = summary.skipped;
6146
+ result.attachmentsUploaded = summary.attachmentsUploaded;
6147
+ result.runUrl = summary.runUrl;
6148
+ } catch (err) {
6149
+ result.errors.push(`record results: ${err.message}`);
6150
+ }
6151
+ } else if (results.length > 0) {
6152
+ deps.logger.warn(
6153
+ `${provider.name} does not support recording results \u2014 ${results.length} execution(s) not pushed.`
6154
+ );
6155
+ }
6156
+ return result;
6157
+ }
6158
+
6159
+ // src/sync/report.ts
6160
+ var MANUAL_ONLY_LIMIT = 50;
6161
+ function summarize2(analysis) {
6162
+ const count2 = (kind) => analysis.remote.filter((c) => c.classification === kind).length;
6163
+ return {
6164
+ provider: analysis.provider,
6165
+ target: analysis.target,
6166
+ totalCases: analysis.remote.length,
6167
+ automated: count2("automated"),
6168
+ duplicated: count2("duplicated"),
6169
+ possibleDuplicate: count2("possible-duplicate"),
6170
+ manualOnly: count2("manual-only"),
6171
+ untracked: analysis.create.length,
6172
+ adopted: analysis.adopted.length
6173
+ };
6174
+ }
6175
+ function sectionBreakdown(analysis) {
6176
+ const sections = /* @__PURE__ */ new Map();
6177
+ for (const entry of analysis.remote) {
6178
+ const name = entry.case.section ?? "(no section)";
6179
+ const bucket2 = sections.get(name) ?? { total: 0, automated: 0 };
6180
+ bucket2.total += 1;
6181
+ if (entry.classification === "automated") bucket2.automated += 1;
6182
+ sections.set(name, bucket2);
6183
+ }
6184
+ return [...sections.entries()].map(([name, value]) => ({ name, ...value })).sort((a, b) => b.automated - a.automated || b.total - a.total);
6185
+ }
6186
+ function renderCoverageText(analysis) {
6187
+ const summary = summarize2(analysis);
6188
+ const lines = [];
6189
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"} (${summary.totalCases} cases)`);
6190
+ lines.push("");
6191
+ lines.push(` ${pad(summary.automated)} automated already covered by a story`);
6192
+ lines.push(` ${pad(summary.duplicated)} duplicated manual case duplicates an automated story`);
6193
+ if (summary.possibleDuplicate > 0) {
6194
+ lines.push(` ${pad(summary.possibleDuplicate)} possible dupe similar to a story, needs a human to confirm`);
6195
+ }
6196
+ lines.push(` ${pad(summary.manualOnly)} manual only no automated equivalent`);
6197
+ lines.push(` ${pad(summary.untracked)} untracked story with no case`);
6198
+ if (summary.adopted > 0) {
6199
+ lines.push(` ${pad(summary.adopted)} linked story bound to a hand-authored case`);
6200
+ }
6201
+ const sections = sectionBreakdown(analysis).filter((s) => s.automated > 0);
6202
+ if (sections.length > 0) {
6203
+ const top = sections[0];
6204
+ lines.push("");
6205
+ lines.push(`Biggest overlap: "${top.name}" section, ${top.automated} of ${top.total} automated.`);
6206
+ }
6207
+ if (analysis.orphaned.length > 0) {
6208
+ lines.push("");
6209
+ lines.push(`${analysis.orphaned.length} case(s) bound to a story that no longer exists. Nothing was removed.`);
6210
+ }
6211
+ if (analysis.partialRunWarning) {
6212
+ lines.push("");
6213
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6214
+ }
6215
+ return lines.join("\n");
6216
+ }
6217
+ function pad(value) {
6218
+ return String(value).padStart(4, " ");
6219
+ }
6220
+ function renderCoverageMarkdown(analysis) {
6221
+ const summary = summarize2(analysis);
6222
+ const lines = [];
6223
+ lines.push(`# Test coverage vs ${analysis.provider}`);
6224
+ lines.push("");
6225
+ if (analysis.target) lines.push(`**Target:** ${analysis.target}`, "");
6226
+ lines.push("| Cases | Count | Meaning |");
6227
+ lines.push("| --- | ---: | --- |");
6228
+ lines.push(`| Automated | ${summary.automated} | Already covered by a story |`);
6229
+ lines.push(`| Duplicated | ${summary.duplicated} | Manual case duplicates an automated story |`);
6230
+ lines.push(`| Possible duplicate | ${summary.possibleDuplicate} | Similar to a story, needs review |`);
6231
+ lines.push(`| Manual only | ${summary.manualOnly} | No automated equivalent |`);
6232
+ lines.push(`| Untracked stories | ${summary.untracked} | Story with no case |`);
6233
+ lines.push("");
6234
+ const duplicates = analysis.remote.filter(
6235
+ (c) => c.classification === "duplicated" || c.classification === "possible-duplicate"
6236
+ );
6237
+ if (duplicates.length > 0) {
6238
+ lines.push("## Retire these first");
6239
+ lines.push("");
6240
+ lines.push("Manual cases an automated story already covers.");
6241
+ lines.push("");
6242
+ lines.push("| Case | Title | Covered by | Confidence |");
6243
+ lines.push("| --- | --- | --- | --- |");
6244
+ for (const entry of duplicates) {
6245
+ const confidence = entry.classification === "duplicated" ? "exact title" : `similarity ${entry.similarity}`;
6246
+ lines.push(
6247
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.resembles ?? "")} | ${confidence} |`
6248
+ );
6249
+ }
6250
+ lines.push("");
6251
+ }
6252
+ const manualOnly = analysis.remote.filter((c) => c.classification === "manual-only");
6253
+ if (manualOnly.length > 0) {
6254
+ lines.push("## Not automated yet");
6255
+ lines.push("");
6256
+ lines.push("Cases with no automated equivalent. This is the backlog.");
6257
+ lines.push("");
6258
+ lines.push("| Case | Title | Section |");
6259
+ lines.push("| --- | --- | --- |");
6260
+ for (const entry of manualOnly.slice(0, MANUAL_ONLY_LIMIT)) {
6261
+ lines.push(
6262
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.case.section ?? "")} |`
6263
+ );
6264
+ }
6265
+ if (manualOnly.length > MANUAL_ONLY_LIMIT) {
6266
+ lines.push("");
6267
+ lines.push(
6268
+ `_${manualOnly.length - MANUAL_ONLY_LIMIT} more not listed here. The JSON artifact has all ${manualOnly.length}._`
6269
+ );
6270
+ }
6271
+ lines.push("");
6272
+ }
6273
+ if (analysis.create.length > 0) {
6274
+ lines.push("## Stories with no case");
6275
+ lines.push("");
6276
+ for (const planned of analysis.create) lines.push(`- ${planned.scenario}`);
6277
+ lines.push("");
6278
+ }
6279
+ if (analysis.orphaned.length > 0) {
6280
+ lines.push("## Cases with no story");
6281
+ lines.push("");
6282
+ lines.push("Bound to a story that has since been deleted. Nothing was removed automatically.");
6283
+ lines.push("");
6284
+ for (const orphan of analysis.orphaned) {
6285
+ lines.push(`- [${orphan.caseId}](${orphan.url}) ${escapeCell2(orphan.title)}`);
6286
+ }
6287
+ lines.push("");
6288
+ }
6289
+ const sections = sectionBreakdown(analysis);
6290
+ if (sections.length > 1) {
6291
+ lines.push("## By section");
6292
+ lines.push("");
6293
+ lines.push("| Section | Automated | Total |");
6294
+ lines.push("| --- | ---: | ---: |");
6295
+ for (const section of sections) {
6296
+ lines.push(`| ${escapeCell2(section.name)} | ${section.automated} | ${section.total} |`);
6297
+ }
6298
+ lines.push("");
6299
+ }
6300
+ if (analysis.partialRunWarning) {
6301
+ lines.push(`> ${analysis.partialRunWarning}`, "");
6302
+ }
6303
+ return lines.join("\n");
6304
+ }
6305
+ function escapeCell2(text2) {
6306
+ return text2.replace(/\|/g, "\\|");
6307
+ }
6308
+ function buildCoverageJson(analysis) {
6309
+ return {
6310
+ schema: "executable-stories/sync-coverage/v1",
6311
+ ...summarize2(analysis),
6312
+ cases: analysis.remote.map((entry) => ({
6313
+ id: entry.case.id,
6314
+ url: entry.case.url,
6315
+ title: entry.case.title,
6316
+ section: entry.case.section,
6317
+ classification: entry.classification,
6318
+ resembles: entry.resembles,
6319
+ similarity: entry.similarity
6320
+ })),
6321
+ untrackedScenarios: analysis.create.map((c) => c.scenario),
6322
+ orphaned: analysis.orphaned.map((o) => ({ caseId: o.caseId, url: o.url, title: o.title })),
6323
+ sections: sectionBreakdown(analysis)
6324
+ };
6325
+ }
6326
+ function formatBytes(bytes) {
6327
+ if (bytes < 1024) return `${bytes} B`;
6328
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6329
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
6330
+ }
6331
+ function renderPlan(analysis, opts) {
6332
+ const lines = [];
6333
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"}`);
6334
+ lines.push("");
6335
+ lines.push(` + create ${pad(analysis.create.length)} cases`);
6336
+ lines.push(` ~ update ${pad(analysis.update.length)} cases`);
6337
+ lines.push(` = unchanged ${pad(analysis.unchanged.length)} cases`);
6338
+ if (analysis.adopted.length > 0) {
6339
+ lines.push(` \xB7 linked ${pad(analysis.adopted.length)} cases (hand-authored, results only)`);
6340
+ }
6341
+ if (analysis.skipped.length > 0) {
6342
+ const edited = analysis.skipped.filter((s) => s.reason === "remote-edited").length;
6343
+ const missing = analysis.skipped.filter((s) => s.reason === "case-missing").length;
6344
+ if (edited > 0) {
6345
+ lines.push(` ! skipped ${pad(edited)} cases (edited in ${analysis.provider} since last sync)`);
6346
+ }
6347
+ if (missing > 0) {
6348
+ lines.push(` ! skipped ${pad(missing)} cases (bound case no longer exists)`);
6349
+ }
6350
+ }
6351
+ if (analysis.orphaned.length > 0) {
6352
+ lines.push(` ? orphaned ${pad(analysis.orphaned.length)} cases (story deleted from codebase, never removed)`);
6353
+ }
6354
+ lines.push(` \u2192 results ${pad(analysis.results.length)} executions`);
6355
+ if (analysis.attachments.files > 0) {
6356
+ const roles = Object.entries(analysis.attachments.byRole).map(([role, count2]) => `${count2} ${role}${count2 === 1 ? "" : "s"}`).join(", ");
6357
+ lines.push(
6358
+ ` \u2191 upload ${pad(analysis.attachments.files)} attachments (${roles}, ${formatBytes(analysis.attachments.bytes)})`
6359
+ );
6360
+ }
6361
+ for (const oversized of analysis.attachments.oversized) {
6362
+ lines.push(
6363
+ ` ! oversized ${oversized.filename} (${formatBytes(oversized.bytes)}, provider limit ${formatBytes(oversized.limit)})`
6364
+ );
6365
+ }
6366
+ for (const capability of analysis.unsupported) {
6367
+ lines.push(` ! ${analysis.provider} does not support ${capability} \u2014 those changes are not applied`);
6368
+ }
6369
+ if (analysis.driftUncheckable > 0) {
6370
+ lines.push(
6371
+ ` ! ${analysis.driftUncheckable} case(s): ${analysis.provider} returned no body, so a hand edit to them cannot be detected`
6372
+ );
6373
+ }
6374
+ if (analysis.partialRunWarning) {
6375
+ lines.push("");
6376
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6377
+ }
6378
+ if (analysis.skipped.some((s) => s.reason === "remote-edited")) {
6379
+ lines.push("");
6380
+ lines.push("Skipped cases were edited by hand after we last wrote them. Nothing overwrites them.");
6381
+ for (const skip of analysis.skipped.filter((s) => s.reason === "remote-edited")) {
6382
+ lines.push(` ${skip.caseId} ${skip.title} ${skip.url}`);
6383
+ }
6384
+ }
6385
+ if (opts.dryRun && hasWork(analysis)) {
6386
+ lines.push("");
6387
+ lines.push("Nothing was written. Run the same command with --apply to make these changes.");
6388
+ }
6389
+ return lines.join("\n");
6390
+ }
6391
+ function hasWork(analysis) {
6392
+ return analysis.create.length > 0 || analysis.update.length > 0 || analysis.results.length > 0;
6393
+ }
6394
+ function renderApplyResult(result) {
6395
+ const lines = [];
6396
+ for (const created of result.created) {
6397
+ lines.push(` + ${created.caseId} ${created.scenario} ${created.url}`);
6398
+ }
6399
+ for (const updated of result.updated) {
6400
+ lines.push(` ~ ${updated.caseId} ${updated.scenario}`);
6401
+ }
6402
+ lines.push("");
6403
+ lines.push(
6404
+ `Created ${result.created.length}, updated ${result.updated.length}, recorded ${result.resultsRecorded} execution(s), uploaded ${result.attachmentsUploaded} attachment(s).`
6405
+ );
6406
+ if (result.runUrl) lines.push(`Run: ${result.runUrl}`);
6407
+ for (const skipped of result.resultsSkipped) {
6408
+ lines.push(` ! result for case ${skipped.caseId} not recorded: ${skipped.reason}`);
6409
+ }
6410
+ for (const error of result.errors) {
6411
+ lines.push(` \u2717 ${error}`);
6412
+ }
6413
+ return lines.join("\n");
6414
+ }
6415
+
6416
+ // src/sync/adapters/case-text.ts
6417
+ var BDD_KEYWORDS = ["Given", "When", "Then", "And", "But"];
6418
+ function encodeStepText(step) {
6419
+ const keyword = step.keyword.trim();
6420
+ return keyword ? `${keyword} ${step.text}` : step.text;
6421
+ }
6422
+ function decodeStepText(content) {
6423
+ const trimmed = content.trim();
6424
+ const firstSpace = trimmed.indexOf(" ");
6425
+ if (firstSpace > 0) {
6426
+ const head = trimmed.slice(0, firstSpace);
6427
+ if (BDD_KEYWORDS.some((keyword) => keyword.toLowerCase() === head.toLowerCase())) {
6428
+ return { keyword: head, text: trimmed.slice(firstSpace + 1) };
6429
+ }
6430
+ }
6431
+ return { keyword: "", text: trimmed };
6432
+ }
6433
+ function encodeDescription(body) {
6434
+ if (body.links.length === 0) return body.description;
6435
+ const links = body.links.map((link2) => `- [${link2.label}](${link2.url})`).join("\n");
6436
+ return `${body.description}
6437
+
6438
+ ${links}`;
6439
+ }
6440
+ function decodeDescription(raw) {
6441
+ const links = [];
6442
+ const lines = raw.split("\n");
6443
+ let cut = lines.length;
6444
+ for (let index = lines.length - 1; index >= 0; index--) {
6445
+ const line = lines[index].trim();
6446
+ if (line === "") continue;
6447
+ const match = /^- \[([^\]]+)\]\(([^)]+)\)$/.exec(line);
6448
+ if (!match) break;
6449
+ links.unshift({ label: match[1], url: match[2] });
6450
+ cut = index;
6451
+ }
6452
+ return { description: lines.slice(0, cut).join("\n").trim(), links };
6453
+ }
6454
+
6455
+ // src/sync/adapters/testrail.ts
6456
+ var DEFAULT_STEPS_FIELD = "custom_steps_separated";
6457
+ var DEFAULT_DESCRIPTION_FIELD = "custom_preconds";
6458
+ var DEFAULT_MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
6459
+ var PAGE_LIMIT = 250;
6460
+ var MAX_ATTEMPTS = 3;
6461
+ function unwrapList(payload, key) {
6462
+ if (Array.isArray(payload)) return payload;
6463
+ if (payload && typeof payload === "object") {
6464
+ const list = payload[key];
6465
+ if (Array.isArray(list)) return list;
6466
+ }
6467
+ return [];
6468
+ }
6469
+ function encodeElapsed(durationMs) {
6470
+ const seconds = Math.floor(durationMs / 1e3);
6471
+ if (seconds < 1) return void 0;
6472
+ if (seconds < 60) return `${seconds}s`;
6473
+ const minutes = Math.floor(seconds / 60);
6474
+ const rest = seconds % 60;
6475
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
6476
+ }
6477
+ function authHint(status) {
6478
+ if (status === 401) {
6479
+ return "\n TESTRAIL_USERNAME must be the login email and TESTRAIL_API_KEY an API key from My Settings -> API Keys. A password fails here when the instance enforces API keys.";
6480
+ }
6481
+ if (status === 403) {
6482
+ return "\n The credentials were accepted but the request was refused. Usually the API is disabled: an admin enables it under Administration -> Site Settings -> API.";
6483
+ }
6484
+ return "";
6485
+ }
6486
+ function createTestRailProvider(config, auth, deps) {
6487
+ const base = config.url.replace(/\/$/, "");
6488
+ const stepsField = config.fields?.steps ?? DEFAULT_STEPS_FIELD;
6489
+ const descriptionField = config.fields?.description ?? DEFAULT_DESCRIPTION_FIELD;
6490
+ const basicAuth = Buffer.from(`${auth.username}:${auth.apiKey}`).toString("base64");
6491
+ let projectName;
6492
+ let suiteName;
6493
+ const sectionNames = /* @__PURE__ */ new Map();
6494
+ async function api(method, init) {
6495
+ const url = `${base}/index.php?/api/v2/${method}`;
6496
+ const headers = {
6497
+ Authorization: `Basic ${basicAuth}`
6498
+ };
6499
+ let body;
6500
+ if (init?.form) {
6501
+ body = init.form;
6502
+ } else if (init?.body !== void 0) {
6503
+ headers["Content-Type"] = "application/json";
6504
+ body = JSON.stringify(init.body);
6505
+ }
6506
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
6507
+ const response = await deps.fetch(url, {
6508
+ method: body === void 0 ? "GET" : "POST",
6509
+ headers,
6510
+ body
6511
+ });
6512
+ if (response.status === 429 && attempt < MAX_ATTEMPTS - 1) {
6513
+ const retryAfter = Number(response.headers.get("retry-after") ?? "1");
6514
+ deps.logger.warn(`TestRail rate limit hit, retrying in ${retryAfter}s`);
6515
+ await new Promise((resolve9) => setTimeout(resolve9, Math.max(1, retryAfter) * 1e3));
6516
+ continue;
6517
+ }
6518
+ const text2 = await response.text();
6519
+ if (text2.length === 0) {
6520
+ if (response.ok) return void 0;
6521
+ throw new Error(
6522
+ `TestRail ${method} failed (${response.status}) with an empty response${authHint(response.status)}`
6523
+ );
6524
+ }
6525
+ let parsed;
6526
+ try {
6527
+ parsed = JSON.parse(text2);
6528
+ } catch {
6529
+ throw new Error(
6530
+ `TestRail ${method} returned HTML rather than JSON (status ${response.status}).
6531
+ Check that sync.testrail.url is the instance root, e.g. https://acme.testrail.io, with no path after it.`
6532
+ );
6533
+ }
6534
+ if (!response.ok) {
6535
+ const detail = parsed.error ?? text2;
6536
+ throw new Error(
6537
+ `TestRail ${method} failed (${response.status}): ${detail}${authHint(response.status)}`
6538
+ );
6539
+ }
6540
+ return parsed;
6541
+ }
6542
+ throw new Error(`TestRail ${method} failed: rate limited after ${MAX_ATTEMPTS} attempts`);
6543
+ }
6544
+ async function paginate(method, key) {
6545
+ const all = [];
6546
+ for (let offset = 0; ; offset += PAGE_LIMIT) {
6547
+ const page = await api(`${method}&limit=${PAGE_LIMIT}&offset=${offset}`);
6548
+ const items = unwrapList(page, key);
6549
+ all.push(...items);
6550
+ if (items.length < PAGE_LIMIT) return all;
6551
+ }
6552
+ }
6553
+ function caseUrl(id) {
6554
+ return `${base}/index.php?/cases/view/${id}`;
6555
+ }
6556
+ function toRemoteCase(raw, context) {
6557
+ if (raw?.id === void 0) {
6558
+ throw new Error(
6559
+ `TestRail ${context} returned no case id. The response was: ${JSON.stringify(raw)?.slice(0, 200)}`
6560
+ );
6561
+ }
6562
+ const title = typeof raw.title === "string" ? raw.title : "";
6563
+ const steps = Array.isArray(raw[stepsField]) ? raw[stepsField] : [];
6564
+ const rawDescription = typeof raw[descriptionField] === "string" ? raw[descriptionField] : "";
6565
+ const { description, links } = decodeDescription(rawDescription);
6566
+ return {
6567
+ id: String(raw.id),
6568
+ url: caseUrl(raw.id),
6569
+ title,
6570
+ section: raw.section_id === void 0 ? void 0 : sectionNames.get(raw.section_id),
6571
+ body: {
6572
+ title,
6573
+ steps: steps.map((step) => decodeStepText(step.content ?? "")),
6574
+ description,
6575
+ links
6576
+ }
6577
+ };
6578
+ }
6579
+ function bodyToPayload(body) {
6580
+ const payload = {
6581
+ title: body.title,
6582
+ [stepsField]: body.steps.map((step) => ({ content: encodeStepText(step), expected: "" })),
6583
+ [descriptionField]: encodeDescription(body)
6584
+ };
6585
+ if (config.templateId !== void 0) payload["template_id"] = config.templateId;
6586
+ return payload;
6587
+ }
6588
+ const suiteQuery = config.suiteId === void 0 ? "" : `&suite_id=${config.suiteId}`;
6589
+ return {
6590
+ name: "testrail",
6591
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
6592
+ describeTarget() {
6593
+ const project = projectName ?? `project ${config.projectId}`;
6594
+ const suite = suiteName ?? (config.suiteId === void 0 ? void 0 : `suite ${config.suiteId}`);
6595
+ return suite ? `${project} / ${suite}` : project;
6596
+ },
6597
+ async listCases() {
6598
+ try {
6599
+ const project = await api(`get_project/${config.projectId}`);
6600
+ projectName = project?.name;
6601
+ if (config.suiteId !== void 0) {
6602
+ const suite = await api(`get_suite/${config.suiteId}`);
6603
+ suiteName = suite?.name;
6604
+ }
6605
+ const sections = await paginate(
6606
+ `get_sections/${config.projectId}${suiteQuery}`,
6607
+ "sections"
6608
+ );
6609
+ for (const section of sections) sectionNames.set(section.id, section.name);
6610
+ } catch (err) {
6611
+ deps.logger.warn(`TestRail metadata lookup failed, continuing without names: ${err.message}`);
6612
+ }
6613
+ const cases = await paginate(`get_cases/${config.projectId}${suiteQuery}`, "cases");
6614
+ return cases.map((raw) => toRemoteCase(raw, "get_cases"));
6615
+ },
6616
+ async createCase(body) {
6617
+ if (config.sectionId === void 0) {
6618
+ throw new Error(
6619
+ "TestRail needs a sectionId to create cases. Set sync.testrail.sectionId to the section new cases should land in."
6620
+ );
6621
+ }
6622
+ const created = await api(`add_case/${config.sectionId}`, {
6623
+ body: bodyToPayload(body)
6624
+ });
6625
+ return toRemoteCase(created, `add_case/${config.sectionId}`);
6626
+ },
6627
+ async updateCase(id, body) {
6628
+ const updated = await api(`update_case/${id}`, { body: bodyToPayload(body) });
6629
+ return toRemoteCase(updated, `update_case/${id}`);
6630
+ },
6631
+ async recordResults(results) {
6632
+ const statusIds = {
6633
+ passed: config.statusIds?.passed ?? 1,
6634
+ failed: config.statusIds?.failed ?? 5,
6635
+ skipped: config.statusIds?.skipped
6636
+ };
6637
+ const skipped = [];
6638
+ const sendable = [];
6639
+ for (const result of results) {
6640
+ const caseId = Number(result.caseId);
6641
+ if (!Number.isFinite(caseId)) {
6642
+ skipped.push({ caseId: result.caseId, reason: "case id is not numeric" });
6643
+ continue;
6644
+ }
6645
+ const statusId = result.status === "skipped" ? statusIds.skipped : statusIds[result.status];
6646
+ if (statusId === void 0) {
6647
+ skipped.push({
6648
+ caseId: result.caseId,
6649
+ reason: "no TestRail status id configured for skipped (set sync.testrail.statusIds.skipped)"
6650
+ });
6651
+ continue;
6652
+ }
6653
+ sendable.push({ result, caseId, statusId });
6654
+ }
6655
+ if (sendable.length === 0) {
6656
+ return { recorded: 0, skipped, attachmentsUploaded: 0 };
6657
+ }
6658
+ let runId = config.runId;
6659
+ if (runId === void 0) {
6660
+ const name = `${config.runName ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`;
6661
+ const run = await api(`add_run/${config.projectId}`, {
6662
+ body: {
6663
+ ...config.suiteId === void 0 ? {} : { suite_id: config.suiteId },
6664
+ name,
6665
+ include_all: false,
6666
+ // Deduplicated: two stories can carry the same ticket id, and
6667
+ // TestRail rejects a run whose case list repeats one.
6668
+ case_ids: [...new Set(sendable.map((s) => s.caseId))]
6669
+ }
6670
+ });
6671
+ runId = run.id;
6672
+ }
6673
+ const runUrl = `${base}/index.php?/runs/view/${runId}`;
6674
+ const payload = sendable.map(({ result, caseId, statusId }) => {
6675
+ const elapsed = encodeElapsed(result.durationMs);
6676
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
6677
+ return {
6678
+ case_id: caseId,
6679
+ status_id: statusId,
6680
+ ...comment ? { comment } : {},
6681
+ ...elapsed ? { elapsed } : {}
6682
+ };
6683
+ });
6684
+ const recorded = unwrapList(
6685
+ await api(`add_results_for_cases/${runId}`, { body: { results: payload } }),
6686
+ "results"
6687
+ );
6688
+ let attachmentsUploaded = 0;
6689
+ for (const [index, entry] of recorded.entries()) {
6690
+ const source = sendable[index]?.result;
6691
+ for (const attachment of source?.attachments ?? []) {
6692
+ try {
6693
+ const form = new FormData();
6694
+ const bytes = attachment.body.buffer.slice(
6695
+ attachment.body.byteOffset,
6696
+ attachment.body.byteOffset + attachment.body.byteLength
6697
+ );
6698
+ form.append(
6699
+ "attachment",
6700
+ new Blob([bytes], { type: attachment.mediaType }),
6701
+ attachment.filename
6702
+ );
6703
+ await api(`add_attachment_to_result/${entry.id}`, { form });
6704
+ attachmentsUploaded += 1;
6705
+ } catch (err) {
6706
+ deps.logger.warn(
6707
+ `TestRail attachment "${attachment.filename}" failed: ${err.message}`
6708
+ );
6709
+ }
6710
+ }
6711
+ }
6712
+ if (config.closeRun && config.runId === void 0) {
6713
+ await api(`close_run/${runId}`, { body: {} });
6714
+ }
6715
+ return {
6716
+ runId: String(runId),
6717
+ runUrl,
6718
+ recorded: recorded.length,
6719
+ skipped,
6720
+ attachmentsUploaded
6721
+ };
6722
+ }
6723
+ };
6724
+ }
6725
+
6726
+ // src/sync/adapters/xray.ts
6727
+ var DEFAULT_XRAY_BASE = "https://xray.cloud.getxray.app";
6728
+ var DEFAULT_TEST_TYPE = "Manual";
6729
+ var DEFAULT_MAX_ATTACHMENT_BYTES2 = 32 * 1024 * 1024;
6730
+ var PAGE_LIMIT2 = 100;
6731
+ function toAdf(text2) {
6732
+ const paragraphs = text2.split("\n\n").filter((block) => block.trim() !== "");
6733
+ return {
6734
+ version: 1,
6735
+ type: "doc",
6736
+ content: paragraphs.length === 0 ? [{ type: "paragraph", content: [] }] : paragraphs.map((block) => ({
6737
+ type: "paragraph",
6738
+ content: [{ type: "text", text: block }]
6739
+ }))
6740
+ };
6741
+ }
6742
+ function fromAdf(value) {
6743
+ if (typeof value === "string") return value;
6744
+ if (!value || typeof value !== "object") return "";
6745
+ const blocks = [];
6746
+ const walk = (node, collected) => {
6747
+ if (!node || typeof node !== "object") return;
6748
+ const typed = node;
6749
+ if (typed.type === "text" && typeof typed.text === "string") collected.push(typed.text);
6750
+ for (const child of typed.content ?? []) walk(child, collected);
6751
+ };
6752
+ for (const node of value.content ?? []) {
6753
+ const collected = [];
6754
+ walk(node, collected);
6755
+ blocks.push(collected.join(""));
6756
+ }
6757
+ return blocks.join("\n\n").trim();
6758
+ }
6759
+ function createXrayProvider(config, auth, deps) {
6760
+ const xrayBase = (config.xrayBaseUrl ?? DEFAULT_XRAY_BASE).replace(/\/$/, "");
6761
+ const jiraBase = config.jiraBaseUrl.replace(/\/$/, "");
6762
+ const jql = config.jql ?? `project = "${config.projectKey}" AND issuetype = Test`;
6763
+ const issueIds = /* @__PURE__ */ new Map();
6764
+ let token;
6765
+ async function authenticate() {
6766
+ if (token) return token;
6767
+ const response = await deps.fetch(`${xrayBase}/api/v2/authenticate`, {
6768
+ method: "POST",
6769
+ headers: { "Content-Type": "application/json" },
6770
+ body: JSON.stringify({ client_id: auth.clientId, client_secret: auth.clientSecret })
6771
+ });
6772
+ const text2 = await response.text();
6773
+ if (!response.ok) {
6774
+ throw new Error(
6775
+ `Xray authentication failed (${response.status}): ${text2}
6776
+ XRAY_CLIENT_ID and XRAY_CLIENT_SECRET come from Jira -> Apps -> Xray -> API Keys. A Jira API token is a different credential and is rejected here.`
6777
+ );
6778
+ }
6779
+ token = JSON.parse(text2).replace(/^"|"$/g, "");
6780
+ return token;
6781
+ }
6782
+ async function graphql(query, variables) {
6783
+ const bearer = await authenticate();
6784
+ const response = await deps.fetch(`${xrayBase}/api/v2/graphql`, {
6785
+ method: "POST",
6786
+ headers: {
6787
+ Authorization: `Bearer ${bearer}`,
6788
+ "Content-Type": "application/json"
6789
+ },
6790
+ body: JSON.stringify({ query, variables })
6791
+ });
6792
+ const text2 = await response.text();
6793
+ if (!response.ok) throw new Error(`Xray GraphQL failed (${response.status}): ${text2}`);
6794
+ const payload = JSON.parse(text2);
6795
+ if (payload.errors?.length) {
6796
+ throw new Error(`Xray GraphQL error: ${payload.errors.map((e) => e.message).join("; ")}`);
6797
+ }
6798
+ return payload.data;
6799
+ }
6800
+ function issueUrl(key) {
6801
+ return `${jiraBase}/browse/${key}`;
6802
+ }
6803
+ function toRemoteCase(test) {
6804
+ const key = test.jira?.key ?? test.issueId;
6805
+ if (!key) {
6806
+ throw new Error(
6807
+ `Xray returned a test with neither a Jira key nor an issue id: ${JSON.stringify(test)?.slice(0, 200)}`
6808
+ );
6809
+ }
6810
+ if (test.jira?.key) issueIds.set(test.jira.key, test.issueId);
6811
+ const { description, links } = decodeDescription(fromAdf(test.jira?.description));
6812
+ return {
6813
+ id: key,
6814
+ url: issueUrl(key),
6815
+ title: test.jira?.summary ?? key,
6816
+ body: {
6817
+ title: test.jira?.summary ?? key,
6818
+ steps: (test.steps ?? []).map((step) => decodeStepText(step.action ?? "")),
6819
+ description,
6820
+ links
6821
+ }
6822
+ };
6823
+ }
6824
+ async function jiraUpdate(key, fields) {
6825
+ if (!auth.jiraEmail || !auth.jiraToken) {
6826
+ deps.logger.warn(
6827
+ `Xray: summary/description for ${key} left unchanged. Set JIRA_EMAIL and JIRA_TOKEN to update Jira fields (steps are updated either way).`
6828
+ );
6829
+ return;
6830
+ }
6831
+ const basic = Buffer.from(`${auth.jiraEmail}:${auth.jiraToken}`).toString("base64");
6832
+ const response = await deps.fetch(`${jiraBase}/rest/api/3/issue/${key}`, {
6833
+ method: "PUT",
6834
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
6835
+ body: JSON.stringify({ fields })
6836
+ });
6837
+ if (!response.ok) {
6838
+ throw new Error(`Jira update of ${key} failed (${response.status}): ${await response.text()}`);
6839
+ }
6840
+ }
6841
+ return {
6842
+ name: "xray",
6843
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES2,
6844
+ describeTarget() {
6845
+ return `${config.projectKey} (${jiraBase})`;
6846
+ },
6847
+ async listCases() {
6848
+ const tests = [];
6849
+ for (let start = 0; ; start += PAGE_LIMIT2) {
6850
+ const data = await graphql(
6851
+ `query($jql: String!, $limit: Int!, $start: Int!) {
6852
+ getTests(jql: $jql, limit: $limit, start: $start) {
6853
+ total
6854
+ results {
6855
+ issueId
6856
+ jira(fields: ["key", "summary", "description"])
6857
+ steps { id action data result }
6858
+ }
6859
+ }
6860
+ }`,
6861
+ { jql, limit: PAGE_LIMIT2, start }
6862
+ );
6863
+ const page = data.getTests?.results ?? [];
6864
+ tests.push(...page);
6865
+ if (page.length < PAGE_LIMIT2) break;
6866
+ }
6867
+ return tests.map(toRemoteCase);
6868
+ },
6869
+ async createCase(body) {
6870
+ const data = await graphql(
6871
+ `mutation($testType: UpdateTestTypeInput!, $steps: [CreateStepInput], $jira: JSON!) {
6872
+ createTest(testType: $testType, steps: $steps, jira: $jira) {
6873
+ test { issueId jira(fields: ["key", "summary", "description"]) }
6874
+ warnings
6875
+ }
6876
+ }`,
6877
+ {
6878
+ testType: { name: config.testType ?? DEFAULT_TEST_TYPE },
6879
+ steps: body.steps.map((step) => ({ action: encodeStepText(step), result: "" })),
6880
+ jira: {
6881
+ fields: {
6882
+ summary: body.title,
6883
+ description: toAdf(encodeDescription(body)),
6884
+ project: { key: config.projectKey }
6885
+ }
6886
+ }
6887
+ }
6888
+ );
6889
+ const created = data.createTest?.test;
6890
+ if (!created) throw new Error(`Xray createTest returned no test for "${body.title}"`);
6891
+ for (const warning of data.createTest?.warnings ?? []) deps.logger.warn(`Xray: ${warning}`);
6892
+ return toRemoteCase(created);
6893
+ },
6894
+ async updateCase(id, body) {
6895
+ const issueId = issueIds.get(id);
6896
+ if (!issueId) {
6897
+ throw new Error(`Xray: no internal issue id cached for ${id}. Run listCases first.`);
6898
+ }
6899
+ const current = await graphql(
6900
+ `query($issueId: String!) { getTest(issueId: $issueId) { steps { id } } }`,
6901
+ { issueId }
6902
+ );
6903
+ const existing = current.getTest?.steps ?? [];
6904
+ for (const [index, step] of body.steps.entries()) {
6905
+ const action = encodeStepText(step);
6906
+ const target = existing[index];
6907
+ if (target) {
6908
+ await graphql(
6909
+ `mutation($stepId: String!, $step: UpdateStepInput!) {
6910
+ updateTestStep(stepId: $stepId, step: $step)
6911
+ }`,
6912
+ { stepId: target.id, step: { action, result: "" } }
6913
+ );
6914
+ } else {
6915
+ await graphql(
6916
+ `mutation($issueId: String!, $step: CreateStepInput!) {
6917
+ addTestStep(issueId: $issueId, step: $step) { id }
6918
+ }`,
6919
+ { issueId, step: { action, result: "" } }
6920
+ );
6921
+ }
6922
+ }
6923
+ for (const surplus of existing.slice(body.steps.length).reverse()) {
6924
+ await graphql(`mutation($stepId: String!) { removeTestStep(stepId: $stepId) }`, {
6925
+ stepId: surplus.id
6926
+ });
6927
+ }
6928
+ await jiraUpdate(id, {
6929
+ summary: body.title,
6930
+ description: toAdf(encodeDescription(body))
6931
+ });
6932
+ return {
6933
+ id,
6934
+ url: issueUrl(id),
6935
+ title: body.title,
6936
+ body
6937
+ };
6938
+ },
6939
+ async recordResults(results) {
6940
+ const statuses = {
6941
+ passed: config.statuses?.passed ?? "PASSED",
6942
+ failed: config.statuses?.failed ?? "FAILED",
6943
+ skipped: config.statuses?.skipped ?? "TODO"
6944
+ };
6945
+ let attachmentsUploaded = 0;
6946
+ const tests = results.map((result) => {
6947
+ const evidence = (result.attachments ?? []).map((attachment) => {
6948
+ attachmentsUploaded += 1;
6949
+ return {
6950
+ data: Buffer.from(attachment.body).toString("base64"),
6951
+ filename: attachment.filename,
6952
+ contentType: attachment.mediaType
6953
+ };
6954
+ });
6955
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
6956
+ return {
6957
+ testKey: result.caseId,
6958
+ status: statuses[result.status],
6959
+ ...comment ? { comment } : {},
6960
+ ...evidence.length > 0 ? { evidence } : {}
6961
+ };
6962
+ });
6963
+ const bearer = await authenticate();
6964
+ const payload = {
6965
+ ...config.testExecutionKey ? { testExecutionKey: config.testExecutionKey } : {},
6966
+ info: {
6967
+ summary: `${config.executionSummary ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`,
6968
+ project: config.projectKey,
6969
+ ...config.testPlanKey ? { testPlanKey: config.testPlanKey } : {}
6970
+ },
6971
+ tests
6972
+ };
6973
+ const response = await deps.fetch(`${xrayBase}/api/v2/import/execution`, {
6974
+ method: "POST",
6975
+ headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
6976
+ body: JSON.stringify(payload)
6977
+ });
6978
+ const text2 = await response.text();
6979
+ if (!response.ok) {
6980
+ throw new Error(`Xray import execution failed (${response.status}): ${text2}`);
6981
+ }
6982
+ const imported = JSON.parse(text2);
6983
+ return {
6984
+ runId: imported.key,
6985
+ runUrl: imported.key ? issueUrl(imported.key) : void 0,
6986
+ recorded: tests.length,
6987
+ skipped: [],
6988
+ attachmentsUploaded
6989
+ };
6990
+ }
6991
+ };
6992
+ }
6993
+
6994
+ // src/sync/adapters/index.ts
6995
+ var PROVIDER_NAMES = ["testrail", "xray"];
6996
+ function isProviderName(value) {
6997
+ return PROVIDER_NAMES.includes(value);
6998
+ }
6999
+ function required(env, name, hint) {
7000
+ const value = env[name];
7001
+ if (!value) throw new Error(`Missing ${name}. ${hint}`);
7002
+ return value;
7003
+ }
7004
+ function buildProvider(args, deps) {
7005
+ const { name, targets, env } = args;
7006
+ if (name === "testrail") {
7007
+ const config2 = targets.testrail;
7008
+ if (!config2) {
7009
+ throw new Error(
7010
+ 'No TestRail config found. Add a `sync: { testrail: { url, projectId } }` block to executable-stories.config.mjs, or run "executable-stories sync testrail --init".'
7011
+ );
7012
+ }
7013
+ const provider2 = createTestRailProvider(
7014
+ config2,
7015
+ {
7016
+ username: required(env, "TESTRAIL_USERNAME", "This is the login email of the TestRail account."),
7017
+ apiKey: required(
7018
+ env,
7019
+ "TESTRAIL_API_KEY",
7020
+ "Generate one in TestRail under My Settings -> API Keys. A password will not work when the instance requires API keys."
7021
+ )
7022
+ },
7023
+ deps
7024
+ );
7025
+ return {
7026
+ provider: provider2,
7027
+ // TestRail ids are numeric and conventionally written "C1234" in a ticket,
7028
+ // so the prefix is decoration and gets stripped.
7029
+ engineDefaults: { ticketPrefix: "C", ticketPrefixStrip: true }
7030
+ };
7031
+ }
7032
+ const config = targets.xray;
7033
+ if (!config) {
7034
+ throw new Error(
7035
+ 'No Xray config found. Add a `sync: { xray: { jiraBaseUrl, projectKey } }` block to executable-stories.config.mjs, or run "executable-stories sync xray --init".'
7036
+ );
7037
+ }
7038
+ const provider = createXrayProvider(
7039
+ config,
7040
+ {
7041
+ clientId: required(env, "XRAY_CLIENT_ID", "Create an API key pair in Jira under Apps -> Xray -> API Keys."),
7042
+ clientSecret: required(env, "XRAY_CLIENT_SECRET", "This is the secret half of the Xray API key pair."),
7043
+ jiraEmail: env["JIRA_EMAIL"],
7044
+ jiraToken: env["JIRA_TOKEN"]
7045
+ },
7046
+ deps
7047
+ );
7048
+ return {
7049
+ provider,
7050
+ // An Xray case id is a Jira issue key, so the project prefix is part of the
7051
+ // id and must survive.
7052
+ engineDefaults: { ticketPrefix: `${config.projectKey}-`, ticketPrefixStrip: false }
7053
+ };
7054
+ }
7055
+
7056
+ // src/index.ts
5646
7057
  var import_story_report6 = require("executable-stories-core/types/story-report");
5647
7058
  var import_tokens2 = require("executable-stories-core/theme/tokens");
5648
7059
  var import_acl3 = require("executable-stories-core/converters/acl/index");
@@ -5657,21 +7068,21 @@ var import_acl11 = require("executable-stories-core/converters/acl/index");
5657
7068
  var import_validate = require("executable-stories-core/converters/acl/validate");
5658
7069
 
5659
7070
  // src/coverage-index.ts
5660
- function normalizePath(path10) {
5661
- return path10.replace(/^\.\//, "");
7071
+ function normalizePath(path11) {
7072
+ return path11.replace(/^\.\//, "");
5662
7073
  }
5663
7074
  function scenariosCoveringPaths(index, paths) {
5664
7075
  const queries = paths.map(normalizePath);
5665
7076
  return index.scenarios.filter(
5666
7077
  (scenario) => scenario.covers.some(
5667
- (glob) => queries.some((path10) => matchesPattern(normalizePath(glob), path10))
7078
+ (glob) => queries.some((path11) => matchesPattern(normalizePath(glob), path11))
5668
7079
  )
5669
7080
  );
5670
7081
  }
5671
7082
 
5672
7083
  // src/watch.ts
5673
- var fs4 = __toESM(require("fs"), 1);
5674
- var path5 = __toESM(require("path"), 1);
7084
+ var fs5 = __toESM(require("fs"), 1);
7085
+ var path6 = __toESM(require("path"), 1);
5675
7086
  var import_acl = require("executable-stories-core/converters/acl/index");
5676
7087
  var import_synthesize = require("executable-stories-core/converters/synthesize");
5677
7088
  function toRun(data, inputType, synthesize) {
@@ -5681,8 +7092,8 @@ function toRun(data, inputType, synthesize) {
5681
7092
  return (0, import_acl.canonicalizeRun)(raw);
5682
7093
  }
5683
7094
  async function regenerateRun(options, deps = {}) {
5684
- const read = deps.readFile ?? ((filePath) => fs4.readFileSync(filePath, "utf8"));
5685
- const data = JSON.parse(read(path5.resolve(options.input)));
7095
+ const read = deps.readFile ?? ((filePath) => fs5.readFileSync(filePath, "utf8"));
7096
+ const data = JSON.parse(read(path6.resolve(options.input)));
5686
7097
  const run = toRun(data, options.inputType ?? "raw", options.synthesize !== false);
5687
7098
  const generator = new ReportGenerator({
5688
7099
  formats: options.formats,
@@ -5698,7 +7109,7 @@ async function regenerateArtifacts(options, deps = {}) {
5698
7109
  function startWatch(options, deps = {}) {
5699
7110
  const log = deps.log ?? ((message) => console.log(message));
5700
7111
  const regenerate = deps.regenerate ?? ((input) => regenerateArtifacts({ ...options, input }, deps));
5701
- const watchFn = deps.watch ?? ((filePath, listener) => fs4.watch(filePath, listener));
7112
+ const watchFn = deps.watch ?? ((filePath, listener) => fs5.watch(filePath, listener));
5702
7113
  const debounceMs = options.debounceMs ?? 150;
5703
7114
  let timer;
5704
7115
  let running = false;
@@ -5727,7 +7138,7 @@ function startWatch(options, deps = {}) {
5727
7138
  timer = setTimeout(() => void run(), debounceMs);
5728
7139
  };
5729
7140
  trigger();
5730
- const watcher = watchFn(path5.resolve(options.input), trigger);
7141
+ const watcher = watchFn(path6.resolve(options.input), trigger);
5731
7142
  return {
5732
7143
  close: () => {
5733
7144
  if (timer) clearTimeout(timer);
@@ -6019,27 +7430,27 @@ async function updateDescription(issueKey, base, adf, headers, fetchFn) {
6019
7430
  var import_ndjson_parser = require("executable-stories-core/converters/ndjson-parser");
6020
7431
 
6021
7432
  // src/utils/git-info.ts
6022
- var fs5 = __toESM(require("fs"), 1);
6023
- var path6 = __toESM(require("path"), 1);
7433
+ var fs6 = __toESM(require("fs"), 1);
7434
+ var path7 = __toESM(require("path"), 1);
6024
7435
  function readGitSha(cwd = process.cwd()) {
6025
7436
  const envSha = process.env.GITHUB_SHA || process.env.GIT_COMMIT || process.env.CI_COMMIT_SHA;
6026
7437
  if (envSha) return envSha;
6027
7438
  const gitDir = findGitDir(cwd);
6028
7439
  if (!gitDir) return void 0;
6029
7440
  try {
6030
- const headPath = path6.join(gitDir, "HEAD");
6031
- const head = fs5.readFileSync(headPath, "utf8").trim();
7441
+ const headPath = path7.join(gitDir, "HEAD");
7442
+ const head = fs6.readFileSync(headPath, "utf8").trim();
6032
7443
  if (!head.startsWith("ref:")) {
6033
7444
  return head;
6034
7445
  }
6035
7446
  const refPath = head.replace("ref:", "").trim();
6036
- const refFile = path6.join(gitDir, refPath);
6037
- if (fs5.existsSync(refFile)) {
6038
- return fs5.readFileSync(refFile, "utf8").trim();
7447
+ const refFile = path7.join(gitDir, refPath);
7448
+ if (fs6.existsSync(refFile)) {
7449
+ return fs6.readFileSync(refFile, "utf8").trim();
6039
7450
  }
6040
- const packedRefs = path6.join(gitDir, "packed-refs");
6041
- if (fs5.existsSync(packedRefs)) {
6042
- const content = fs5.readFileSync(packedRefs, "utf8");
7451
+ const packedRefs = path7.join(gitDir, "packed-refs");
7452
+ if (fs6.existsSync(packedRefs)) {
7453
+ const content = fs6.readFileSync(packedRefs, "utf8");
6043
7454
  for (const line of content.split("\n")) {
6044
7455
  if (!line || line.startsWith("#") || line.startsWith("^")) continue;
6045
7456
  const [sha, ref] = line.split(" ");
@@ -6054,19 +7465,19 @@ function readGitSha(cwd = process.cwd()) {
6054
7465
  function findGitDir(start) {
6055
7466
  let current = start;
6056
7467
  while (true) {
6057
- const candidate = path6.join(current, ".git");
6058
- if (fs5.existsSync(candidate)) {
6059
- const stat = fs5.statSync(candidate);
7468
+ const candidate = path7.join(current, ".git");
7469
+ if (fs6.existsSync(candidate)) {
7470
+ const stat = fs6.statSync(candidate);
6060
7471
  if (stat.isFile()) {
6061
- const content = fs5.readFileSync(candidate, "utf8").trim();
7472
+ const content = fs6.readFileSync(candidate, "utf8").trim();
6062
7473
  const match = content.match(/^gitdir: (.+)$/);
6063
7474
  if (match) {
6064
- return path6.resolve(current, match[1]);
7475
+ return path7.resolve(current, match[1]);
6065
7476
  }
6066
7477
  }
6067
7478
  return candidate;
6068
7479
  }
6069
- const parent = path6.dirname(current);
7480
+ const parent = path7.dirname(current);
6070
7481
  if (parent === current) return void 0;
6071
7482
  current = parent;
6072
7483
  }
@@ -6077,8 +7488,8 @@ function readBranchName(cwd = process.cwd()) {
6077
7488
  const gitDir = findGitDir(cwd);
6078
7489
  if (!gitDir) return void 0;
6079
7490
  try {
6080
- const headPath = path6.join(gitDir, "HEAD");
6081
- const head = fs5.readFileSync(headPath, "utf8").trim();
7491
+ const headPath = path7.join(gitDir, "HEAD");
7492
+ const head = fs6.readFileSync(headPath, "utf8").trim();
6082
7493
  if (head.startsWith("ref:")) {
6083
7494
  const refPath = head.replace("ref:", "").trim();
6084
7495
  const match = refPath.match(/^refs\/heads\/(.+)$/);
@@ -6096,8 +7507,8 @@ var import_duration2 = require("executable-stories-core/utils/duration");
6096
7507
  var import_duration3 = require("executable-stories-core/utils/duration");
6097
7508
 
6098
7509
  // src/utils/metadata.ts
6099
- var fs6 = __toESM(require("fs"), 1);
6100
- var path7 = __toESM(require("path"), 1);
7510
+ var fs7 = __toESM(require("fs"), 1);
7511
+ var path8 = __toESM(require("path"), 1);
6101
7512
  var versionCache = /* @__PURE__ */ new Map();
6102
7513
  function readPackageVersion(root) {
6103
7514
  if (versionCache.has(root)) {
@@ -6108,18 +7519,18 @@ function readPackageVersion(root) {
6108
7519
  return version;
6109
7520
  }
6110
7521
  function findPackageVersion(startDir) {
6111
- let current = path7.resolve(startDir);
7522
+ let current = path8.resolve(startDir);
6112
7523
  while (true) {
6113
- const pkgPath = path7.join(current, "package.json");
7524
+ const pkgPath = path8.join(current, "package.json");
6114
7525
  try {
6115
- if (fs6.existsSync(pkgPath)) {
6116
- const raw = fs6.readFileSync(pkgPath, "utf8");
7526
+ if (fs7.existsSync(pkgPath)) {
7527
+ const raw = fs7.readFileSync(pkgPath, "utf8");
6117
7528
  const parsed = JSON.parse(raw);
6118
7529
  return parsed.version;
6119
7530
  }
6120
7531
  } catch {
6121
7532
  }
6122
- const parent = path7.dirname(current);
7533
+ const parent = path8.dirname(current);
6123
7534
  if (parent === current) {
6124
7535
  return void 0;
6125
7536
  }
@@ -6427,7 +7838,7 @@ async function sendTeamsNotification(args, deps) {
6427
7838
  }
6428
7839
 
6429
7840
  // src/notifiers/hmac.ts
6430
- var import_node_crypto4 = require("crypto");
7841
+ var import_node_crypto5 = require("crypto");
6431
7842
  function signBody(args) {
6432
7843
  let input;
6433
7844
  let timestamp;
@@ -6437,7 +7848,7 @@ function signBody(args) {
6437
7848
  } else {
6438
7849
  input = args.body;
6439
7850
  }
6440
- const hex = (0, import_node_crypto4.createHmac)("sha256", args.secret).update(input, "utf8").digest("hex");
7851
+ const hex = (0, import_node_crypto5.createHmac)("sha256", args.secret).update(input, "utf8").digest("hex");
6441
7852
  return {
6442
7853
  signature: `sha256=${hex}`,
6443
7854
  timestamp
@@ -6684,24 +8095,8 @@ function calculateFlakiness(args) {
6684
8095
  const countable = entries.filter(
6685
8096
  (e) => e.status === "passed" || e.status === "failed"
6686
8097
  );
6687
- if (countable.length < MIN_FLAKINESS_SAMPLES) {
6688
- return {
6689
- flakinessLevel: "stable",
6690
- flakinessScore: 0,
6691
- failureRate: 0,
6692
- longestPassStreak: countable.length,
6693
- longestFailStreak: 0
6694
- };
6695
- }
6696
- let transitions = 0;
6697
- for (let i = 1; i < countable.length; i++) {
6698
- if (countable[i].status !== countable[i - 1].status) {
6699
- transitions++;
6700
- }
6701
- }
6702
- const transitionScore = transitions / (countable.length - 1);
6703
8098
  const failures = countable.filter((e) => e.status === "failed").length;
6704
- const failureRate = failures / countable.length;
8099
+ const failureRate = countable.length > 0 ? failures / countable.length : 0;
6705
8100
  let longestPassStreak = 0;
6706
8101
  let longestFailStreak = 0;
6707
8102
  let currentPassStreak = 0;
@@ -6721,6 +8116,22 @@ function calculateFlakiness(args) {
6721
8116
  }
6722
8117
  }
6723
8118
  }
8119
+ if (countable.length < MIN_FLAKINESS_SAMPLES) {
8120
+ return {
8121
+ flakinessLevel: "stable",
8122
+ flakinessScore: 0,
8123
+ failureRate,
8124
+ longestPassStreak,
8125
+ longestFailStreak
8126
+ };
8127
+ }
8128
+ let transitions = 0;
8129
+ for (let i = 1; i < countable.length; i++) {
8130
+ if (countable[i].status !== countable[i - 1].status) {
8131
+ transitions++;
8132
+ }
8133
+ }
8134
+ const transitionScore = transitions / (countable.length - 1);
6724
8135
  let flakinessLevel;
6725
8136
  if (transitionScore > 0.5 || transitionScore > 0.3 && failureRate > 0.2) {
6726
8137
  flakinessLevel = "flaky";
@@ -7313,7 +8724,7 @@ function statusIcon2(status) {
7313
8724
  return "\u2022";
7314
8725
  }
7315
8726
  }
7316
- function escapeCell2(value) {
8727
+ function escapeCell3(value) {
7317
8728
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
7318
8729
  }
7319
8730
  function intentSummary(intent) {
@@ -7342,7 +8753,7 @@ function renderWeakBand(lines, files) {
7342
8753
  lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
7343
8754
  lines.push("");
7344
8755
  for (const file of weak) {
7345
- const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
8756
+ const covered = file.claims.map((c) => `${escapeCell3(c.scenario)} (${c.strength})`).join(", ");
7346
8757
  lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
7347
8758
  }
7348
8759
  lines.push("");
@@ -7367,7 +8778,7 @@ function renderClaim(lines, claim) {
7367
8778
  );
7368
8779
  }
7369
8780
  if (claim.intent) {
7370
- lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
8781
+ lines.push(`- Why: ${escapeCell3(intentSummary(claim.intent))}`);
7371
8782
  }
7372
8783
  lines.push("");
7373
8784
  }
@@ -7424,7 +8835,7 @@ function renderCodeDiff(lines, evidence) {
7424
8835
  } else {
7425
8836
  for (const ref of annotation.scenarios) {
7426
8837
  lines.push(
7427
- ref.resolved && ref.status ? `- ${statusIcon2(ref.status)} ${escapeCell2(ref.scenario ?? ref.id)} (\`${ref.id}\`)` : `- \u26A0\uFE0F \`${ref.id}\` \u2014 unverified reference (scenario not in this run)`
8838
+ ref.resolved && ref.status ? `- ${statusIcon2(ref.status)} ${escapeCell3(ref.scenario ?? ref.id)} (\`${ref.id}\`)` : `- \u26A0\uFE0F \`${ref.id}\` \u2014 unverified reference (scenario not in this run)`
7428
8839
  );
7429
8840
  }
7430
8841
  }
@@ -7643,9 +9054,9 @@ function renderDiffHunk(file, hunk, anchoredStart, anchoredCount) {
7643
9054
  const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
7644
9055
  return `<tr class="${cls}"><td class="diff-ln">${o}</td><td class="diff-ln">${n}</td><td class="diff-sign">${sign}</td><td class="diff-code">${escapeHtml3(line.text)}</td></tr>`;
7645
9056
  });
7646
- const path10 = file.newPath ?? file.oldPath ?? "";
9057
+ const path11 = file.newPath ?? file.oldPath ?? "";
7647
9058
  return `<div class="diff-hunk">
7648
- <div class="diff-file-header"><code>${escapeHtml3(path10)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
9059
+ <div class="diff-file-header"><code>${escapeHtml3(path11)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
7649
9060
  <table class="diff-table"><tbody>${rows.join("")}</tbody></table>
7650
9061
  </div>`;
7651
9062
  }
@@ -7880,8 +9291,8 @@ applyTheme(getEffectiveTheme());` : "";
7880
9291
  };
7881
9292
 
7882
9293
  // src/deploy/ledger.ts
7883
- var fs7 = __toESM(require("fs"), 1);
7884
- var path8 = __toESM(require("path"), 1);
9294
+ var fs8 = __toESM(require("fs"), 1);
9295
+ var path9 = __toESM(require("path"), 1);
7885
9296
  function createEmptyLedger() {
7886
9297
  return {
7887
9298
  deployments: [],
@@ -7889,12 +9300,12 @@ function createEmptyLedger() {
7889
9300
  };
7890
9301
  }
7891
9302
  function loadLedger(ledgerPath) {
7892
- const resolved = path8.resolve(ledgerPath);
7893
- if (!fs7.existsSync(resolved)) {
9303
+ const resolved = path9.resolve(ledgerPath);
9304
+ if (!fs8.existsSync(resolved)) {
7894
9305
  return createEmptyLedger();
7895
9306
  }
7896
9307
  try {
7897
- const raw = JSON.parse(fs7.readFileSync(resolved, "utf8"));
9308
+ const raw = JSON.parse(fs8.readFileSync(resolved, "utf8"));
7898
9309
  if (raw.schemaVersion !== 1) {
7899
9310
  throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
7900
9311
  }
@@ -7905,10 +9316,10 @@ function loadLedger(ledgerPath) {
7905
9316
  }
7906
9317
  }
7907
9318
  function saveLedger(ledger, ledgerPath) {
7908
- const resolved = path8.resolve(ledgerPath);
7909
- const dir = path8.dirname(resolved);
7910
- fs7.mkdirSync(dir, { recursive: true });
7911
- fs7.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
9319
+ const resolved = path9.resolve(ledgerPath);
9320
+ const dir = path9.dirname(resolved);
9321
+ fs8.mkdirSync(dir, { recursive: true });
9322
+ fs8.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
7912
9323
  }
7913
9324
  function getLatestDeployment(ledger, environment) {
7914
9325
  return [...ledger.deployments].reverse().find((d) => d.environment === environment);
@@ -8035,11 +9446,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
8035
9446
  const ext = FORMAT_EXTENSIONS[format];
8036
9447
  const effectiveName = outputName + (outputNameSuffix ?? "");
8037
9448
  if (mode === "aggregated") {
8038
- return toPosix(path9.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
9449
+ return toPosix(path10.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
8039
9450
  }
8040
9451
  const normalizedSource = toPosix(sourceFile);
8041
- const dirOfSource = path9.posix.dirname(normalizedSource);
8042
- let baseName = path9.posix.basename(normalizedSource);
9452
+ const dirOfSource = path10.posix.dirname(normalizedSource);
9453
+ let baseName = path10.posix.basename(normalizedSource);
8043
9454
  for (const testExt of TEST_EXTENSIONS) {
8044
9455
  if (baseName.endsWith(testExt)) {
8045
9456
  baseName = baseName.slice(0, -testExt.length);
@@ -8048,12 +9459,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
8048
9459
  }
8049
9460
  const fileName = `${baseName}.${effectiveName}${ext}`;
8050
9461
  if (colocatedStyle === "adjacent") {
8051
- return toPosix(path9.posix.join(dirOfSource, fileName));
9462
+ return toPosix(path10.posix.join(dirOfSource, fileName));
8052
9463
  }
8053
9464
  if (colocatedStyle === "flat") {
8054
- return toPosix(path9.posix.join(baseOutputDir, `${(0, import_source_file2.cleanTestStem)(normalizedSource)}${ext}`));
9465
+ return toPosix(path10.posix.join(baseOutputDir, `${(0, import_source_file2.cleanTestStem)(normalizedSource)}${ext}`));
8055
9466
  }
8056
- return toPosix(path9.posix.join(baseOutputDir, dirOfSource, fileName));
9467
+ return toPosix(path10.posix.join(baseOutputDir, dirOfSource, fileName));
8057
9468
  }
8058
9469
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
8059
9470
  const groups = /* @__PURE__ */ new Map();
@@ -8273,8 +9684,8 @@ var ReportGenerator = class {
8273
9684
  if (astroPaths) {
8274
9685
  for (const mdPath of astroPaths) {
8275
9686
  const content = await fsPromises.readFile(mdPath, "utf8");
8276
- const mdDir = path9.dirname(mdPath);
8277
- const assetsDir = path9.resolve(this.options.astro.assetsDir);
9687
+ const mdDir = path10.dirname(mdPath);
9688
+ const assetsDir = path10.resolve(this.options.astro.assetsDir);
8278
9689
  const result = copyMarkdownAssets({
8279
9690
  markdown: content,
8280
9691
  markdownDir: mdDir,
@@ -8322,16 +9733,16 @@ var ReportGenerator = class {
8322
9733
  bySourceFile.set(sourceFile, outputPath);
8323
9734
  }
8324
9735
  if (bySourceFile.size === 0) return void 0;
8325
- const indexPath = toPosix(path9.join(this.options.outputDir, "index.html"));
9736
+ const indexPath = toPosix(path10.join(this.options.outputDir, "index.html"));
8326
9737
  if (htmlPaths.some((p) => toPosix(p) === indexPath)) {
8327
9738
  this.deps.logger.warn?.(
8328
9739
  `Skipping colocated index: a report already occupies ${indexPath}.`
8329
9740
  );
8330
9741
  return void 0;
8331
9742
  }
8332
- const entries = buildIndexEntries(run, bySourceFile, path9.dirname(indexPath));
9743
+ const entries = buildIndexEntries(run, bySourceFile, path10.dirname(indexPath));
8333
9744
  const html = renderColocatedIndex(entries, this.options.html.title);
8334
- await fsPromises.mkdir(path9.dirname(indexPath), { recursive: true });
9745
+ await fsPromises.mkdir(path10.dirname(indexPath), { recursive: true });
8335
9746
  await this.deps.writeFile(indexPath, html);
8336
9747
  return indexPath;
8337
9748
  }
@@ -8350,9 +9761,9 @@ var ReportGenerator = class {
8350
9761
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
8351
9762
  const ext = FORMAT_EXTENSIONS[format];
8352
9763
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
8353
- const outputPath = toPosix(path9.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
9764
+ const outputPath = toPosix(path10.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
8354
9765
  const content = await this.formatContent(run, format);
8355
- const dir = path9.dirname(outputPath);
9766
+ const dir = path10.dirname(outputPath);
8356
9767
  await fsPromises.mkdir(dir, { recursive: true });
8357
9768
  await this.deps.writeFile(outputPath, content);
8358
9769
  return [outputPath];
@@ -8364,7 +9775,7 @@ var ReportGenerator = class {
8364
9775
  testCases
8365
9776
  };
8366
9777
  const content = await this.formatContent(groupRun, format);
8367
- const dir = path9.dirname(outputPath);
9778
+ const dir = path10.dirname(outputPath);
8368
9779
  await fsPromises.mkdir(dir, { recursive: true });
8369
9780
  await this.deps.writeFile(outputPath, content);
8370
9781
  writtenPaths.push(outputPath);
@@ -8555,7 +9966,7 @@ async function generateRunComparison(args) {
8555
9966
  await fsPromises.mkdir(outputDir, { recursive: true });
8556
9967
  for (const format of args.formats) {
8557
9968
  const ext = format === "html" ? ".html" : format === "changelog" ? ".changelog.md" : ".md";
8558
- const outputPath = toPosix(path9.join(outputDir, `${outputName}${ext}`));
9969
+ const outputPath = toPosix(path10.join(outputDir, `${outputName}${ext}`));
8559
9970
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : format === "changelog" ? new RunDiffChangelogFormatter().format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
8560
9971
  await fsPromises.writeFile(outputPath, content, "utf8");
8561
9972
  files.push(outputPath);
@@ -8583,6 +9994,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8583
9994
  CucumberHtmlFormatter,
8584
9995
  CucumberJsonFormatter,
8585
9996
  CucumberMessagesFormatter,
9997
+ DEFAULT_LOCKFILE_PATH,
8586
9998
  ES_THEME_TOKENS_CSS,
8587
9999
  ES_THEME_TOKEN_VALUES,
8588
10000
  JUnitFormatter,
@@ -8590,6 +10002,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8590
10002
  MIN_METRIC_SAMPLES,
8591
10003
  MIN_PERF_SAMPLES,
8592
10004
  MarkdownFormatter,
10005
+ PROVIDER_NAMES,
8593
10006
  ReleaseManifestFormatter,
8594
10007
  ReportGenerator,
8595
10008
  ReviewHtmlFormatter,
@@ -8608,11 +10021,15 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8608
10021
  adaptPlaywrightRun,
8609
10022
  adaptVitestRun,
8610
10023
  advanceState,
10024
+ analyzeSync,
10025
+ applySync,
8611
10026
  assembleCodeDiff,
8612
10027
  assertValidRun,
8613
10028
  buildCheck,
10029
+ buildCoverageJson,
8614
10030
  buildGoal,
8615
10031
  buildHtmlDocEntry,
10032
+ buildProvider,
8616
10033
  buildReview,
8617
10034
  buildTriage,
8618
10035
  bundleAssets,
@@ -8622,11 +10039,14 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8622
10039
  classifyStatusChange,
8623
10040
  clearVersionCache,
8624
10041
  codeDiffDiagnostics,
10042
+ collectAttachments,
8625
10043
  computeTestMetrics,
8626
10044
  copyMarkdownAssets,
8627
10045
  createAnchor,
8628
10046
  createPrCommentSummary,
8629
10047
  createReportGenerator,
10048
+ createTestRailProvider,
10049
+ createXrayProvider,
8630
10050
  deriveAudience,
8631
10051
  deriveChangeType,
8632
10052
  deriveStepResults,
@@ -8634,6 +10054,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8634
10054
  detectPerformanceTrend,
8635
10055
  diffRuns,
8636
10056
  diffStoryReports,
10057
+ emptyLockfile,
8637
10058
  findGitDir,
8638
10059
  formatDuration,
8639
10060
  generateRunComparison,
@@ -8643,7 +10064,9 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8643
10064
  getEnvironmentDrift,
8644
10065
  gradeEvidence,
8645
10066
  hasSufficientHistory,
10067
+ hashCaseBody,
8646
10068
  initialRunState,
10069
+ isProviderName,
8647
10070
  isReviewableSource,
8648
10071
  isTestFile,
8649
10072
  joinNameAndExt,
@@ -8658,19 +10081,26 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8658
10081
  normalizeStatus,
8659
10082
  normalizeVitestResults,
8660
10083
  parseEnvelopes,
10084
+ parseLockfile,
8661
10085
  parseNdjson,
8662
10086
  parseUnifiedDiff,
10087
+ projectBehaviours,
8663
10088
  publishConfluencePage,
8664
10089
  publishJiraIssue,
8665
10090
  readBranchName,
8666
10091
  readGitSha,
10092
+ readLockfile,
8667
10093
  readPackageVersion,
8668
10094
  recordDeployment,
8669
10095
  regenerateArtifacts,
8670
10096
  regenerateRun,
8671
10097
  relocateAnchor,
10098
+ renderApplyResult,
8672
10099
  renderCheck,
10100
+ renderCoverageMarkdown,
10101
+ renderCoverageText,
8673
10102
  renderGoal,
10103
+ renderPlan,
8674
10104
  renderTriage,
8675
10105
  resolveAttachment,
8676
10106
  resolveAttachments,
@@ -8682,6 +10112,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8682
10112
  sendSlackNotification,
8683
10113
  sendTeamsNotification,
8684
10114
  sendWebhookNotification,
10115
+ serializeLockfile,
8685
10116
  signBody,
8686
10117
  slugify,
8687
10118
  startWatch,
@@ -8689,6 +10120,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8689
10120
  toAgentText,
8690
10121
  toBehaviorManifest,
8691
10122
  toCIInfo,
10123
+ toCaseBody,
8692
10124
  toRawCIInfo,
8693
10125
  toReleaseManifest,
8694
10126
  toScenarioIndex,
@@ -8696,6 +10128,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
8696
10128
  toTraceabilityMatrix,
8697
10129
  tryGetActiveOtelContext,
8698
10130
  updateHistory,
8699
- validateCanonicalRun
10131
+ validateCanonicalRun,
10132
+ writeLockfile
8700
10133
  });
8701
10134
  //# sourceMappingURL=index.cjs.map