executable-stories-formatters 1.9.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import * as path9 from "path";
2
+ import * as path10 from "path";
3
3
  import * as fsPromises from "fs/promises";
4
4
  import { toStoryReportWithIndex } from "executable-stories-core/converters/story-report";
5
5
 
@@ -647,50 +647,50 @@ function scenarioLines(scenario) {
647
647
  if (scenario.errorMessage) lines.push(indent(`error: ${scenario.errorMessage}`, " "));
648
648
  return lines;
649
649
  }
650
- function docLines(entry, pad) {
651
- const lines = ownDocLines(entry, pad);
652
- for (const child of entry.children ?? []) lines.push(...docLines(child, pad + " "));
650
+ function docLines(entry, pad2) {
651
+ const lines = ownDocLines(entry, pad2);
652
+ for (const child of entry.children ?? []) lines.push(...docLines(child, pad2 + " "));
653
653
  return lines;
654
654
  }
655
- function ownDocLines(entry, pad) {
655
+ function ownDocLines(entry, pad2) {
656
656
  switch (entry.kind) {
657
657
  case "note":
658
- return [indent(entry.text, pad)];
658
+ return [indent(entry.text, pad2)];
659
659
  case "tag":
660
660
  return [];
661
661
  // already on the scenario's tag line
662
662
  case "kv":
663
- return [`${pad}${entry.label}: ${compact(entry.value)}`];
663
+ return [`${pad2}${entry.label}: ${compact(entry.value)}`];
664
664
  case "code":
665
- return [`${pad}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad + " ")];
665
+ return [`${pad2}code ${entry.label}${entry.lang ? ` (${entry.lang})` : ""}:`, indent(entry.content, pad2 + " ")];
666
666
  case "table":
667
667
  return [
668
- `${pad}table ${entry.label}: ${entry.columns.join(" | ")}`,
669
- ...entry.rows.map((row) => `${pad} ${row.join(" | ")}`)
668
+ `${pad2}table ${entry.label}: ${entry.columns.join(" | ")}`,
669
+ ...entry.rows.map((row) => `${pad2} ${row.join(" | ")}`)
670
670
  ];
671
671
  case "link":
672
- return [`${pad}link ${entry.label}: ${entry.url}`];
672
+ return [`${pad2}link ${entry.label}: ${entry.url}`];
673
673
  case "section":
674
- return [`${pad}section ${entry.title}:`, indent(entry.markdown, pad + " ")];
674
+ return [`${pad2}section ${entry.title}:`, indent(entry.markdown, pad2 + " ")];
675
675
  case "mermaid":
676
- return [`${pad}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad + " ")];
676
+ return [`${pad2}mermaid${entry.title ? ` ${entry.title}` : ""}:`, indent(entry.code, pad2 + " ")];
677
677
  case "screenshot":
678
- return [`${pad}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
678
+ return [`${pad2}screenshot ${entry.path}${entry.alt ? ` \u2014 ${entry.alt}` : ""}`];
679
679
  case "video":
680
- return [`${pad}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
680
+ return [`${pad2}video ${entry.path}${entry.caption ? ` \u2014 ${entry.caption}` : ""}`];
681
681
  case "html":
682
- return [`${pad}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
682
+ return [`${pad2}html ${entry.title ?? entry.path ?? entry.url ?? "(inline)"}`];
683
683
  case "state":
684
- return [`${pad}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
684
+ return [`${pad2}state${entry.label ? ` ${entry.label}` : ""}: ${compact(entry.value)}`];
685
685
  case "custom":
686
- return [`${pad}custom ${entry.type}: ${compact(entry.data)}`];
686
+ return [`${pad2}custom ${entry.type}: ${compact(entry.data)}`];
687
687
  }
688
688
  }
689
689
  function compact(value) {
690
690
  return typeof value === "string" ? value : JSON.stringify(value);
691
691
  }
692
- function indent(text2, pad) {
693
- return text2.split("\n").map((line) => pad + line).join("\n");
692
+ function indent(text2, pad2) {
693
+ return text2.split("\n").map((line) => pad2 + line).join("\n");
694
694
  }
695
695
 
696
696
  // src/formatters/junit-xml.ts
@@ -1593,9 +1593,9 @@ var MAX_FUZZ = 2;
1593
1593
  var normalize = (line) => line.trim();
1594
1594
  var HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@ ?(.*)$/;
1595
1595
  function stripPathPrefix(raw) {
1596
- const path10 = raw.split(" ")[0].trim();
1597
- if (path10 === "/dev/null") return void 0;
1598
- return path10.replace(/^[ab]\//, "");
1596
+ const path11 = raw.split(" ")[0].trim();
1597
+ if (path11 === "/dev/null") return void 0;
1598
+ return path11.replace(/^[ab]\//, "");
1599
1599
  }
1600
1600
  function parseUnifiedDiff(patch) {
1601
1601
  const files = [];
@@ -1675,7 +1675,7 @@ function createAnchor(args) {
1675
1675
  };
1676
1676
  }
1677
1677
  function changedRunCandidates(anchor, file, fileIndex) {
1678
- const path10 = file.newPath ?? file.oldPath ?? "";
1678
+ const path11 = file.newPath ?? file.oldPath ?? "";
1679
1679
  const out = [];
1680
1680
  file.hunks.forEach((hunk, hunkIndex) => {
1681
1681
  outer: for (let i = 0; i + anchor.changed.length <= hunk.lines.length; i++) {
@@ -1686,7 +1686,7 @@ function changedRunCandidates(anchor, file, fileIndex) {
1686
1686
  continue outer;
1687
1687
  }
1688
1688
  }
1689
- out.push({ fileIndex, file: path10, hunkIndex, lineIndex: i, lines: hunk.lines });
1689
+ out.push({ fileIndex, file: path11, hunkIndex, lineIndex: i, lines: hunk.lines });
1690
1690
  }
1691
1691
  });
1692
1692
  return out;
@@ -1783,18 +1783,18 @@ function deriveChangeType(tags) {
1783
1783
  }
1784
1784
  return "unknown";
1785
1785
  }
1786
- function extensionOf(path10) {
1787
- const base = path10.split("/").pop() ?? path10;
1786
+ function extensionOf(path11) {
1787
+ const base = path11.split("/").pop() ?? path11;
1788
1788
  const dot = base.lastIndexOf(".");
1789
1789
  return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
1790
1790
  }
1791
- function isTestFile(path10) {
1792
- return TEST_INFIX.test(path10);
1791
+ function isTestFile(path11) {
1792
+ return TEST_INFIX.test(path11);
1793
1793
  }
1794
- function isReviewableSource(path10) {
1795
- if (isTestFile(path10)) return false;
1796
- if (path10.endsWith(".d.ts")) return false;
1797
- return CODE_EXTENSIONS.has(extensionOf(path10));
1794
+ function isReviewableSource(path11) {
1795
+ if (isTestFile(path11)) return false;
1796
+ if (path11.endsWith(".d.ts")) return false;
1797
+ return CODE_EXTENSIONS.has(extensionOf(path11));
1798
1798
  }
1799
1799
  function testBaseKey(testFile) {
1800
1800
  return testFile.replace(TEST_INFIX, "");
@@ -1898,7 +1898,7 @@ function toClaim(testCase, changedSourcePaths) {
1898
1898
  const { strength, reasons } = gradeEvidence(testCase, audience);
1899
1899
  const key = testBaseKey(testCase.sourceFile);
1900
1900
  const coversFiles = changedSourcePaths.filter(
1901
- (path10) => sourceBaseKey(path10) === key
1901
+ (path11) => sourceBaseKey(path11) === key
1902
1902
  );
1903
1903
  return {
1904
1904
  id: testCase.id,
@@ -2070,14 +2070,14 @@ var TraceabilityMatrixFormatter = class {
2070
2070
  lines.push("");
2071
2071
  lines.push(`Status: ${renderRequirementStatus(req.status)}`);
2072
2072
  if (req.covers.length > 0) {
2073
- lines.push(`Covers: ${req.covers.map((path10) => `\`${path10}\``).join(", ")}`);
2073
+ lines.push(`Covers: ${req.covers.map((path11) => `\`${path11}\``).join(", ")}`);
2074
2074
  }
2075
2075
  lines.push("");
2076
2076
  lines.push("| Status | Scenario | Source | Covers |");
2077
2077
  lines.push("| --- | --- | --- | --- |");
2078
2078
  for (const scenario of req.scenarios) {
2079
2079
  const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
2080
- const covers = scenario.covers.length > 0 ? scenario.covers.map((path10) => `\`${path10}\``).join(", ") : "";
2080
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path11) => `\`${path11}\``).join(", ") : "";
2081
2081
  lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
2082
2082
  }
2083
2083
  lines.push("");
@@ -2842,8 +2842,8 @@ function extractDocAttachments(step) {
2842
2842
  }
2843
2843
  return attachments;
2844
2844
  }
2845
- function guessMediaType(path10) {
2846
- const lower = path10.toLowerCase();
2845
+ function guessMediaType(path11) {
2846
+ const lower = path11.toLowerCase();
2847
2847
  if (lower.endsWith(".png")) return "image/png";
2848
2848
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
2849
2849
  if (lower.endsWith(".gif")) return "image/gif";
@@ -2984,11 +2984,11 @@ var CucumberHtmlFormatter = class {
2984
2984
  for (const envelope of envelopes) {
2985
2985
  const accepted = htmlStream.write(envelope);
2986
2986
  if (!accepted) {
2987
- await new Promise((resolve8) => htmlStream.once("drain", resolve8));
2987
+ await new Promise((resolve9) => htmlStream.once("drain", resolve9));
2988
2988
  }
2989
2989
  }
2990
- await new Promise((resolve8, reject) => {
2991
- collector.on("finish", resolve8);
2990
+ await new Promise((resolve9, reject) => {
2991
+ collector.on("finish", resolve9);
2992
2992
  collector.on("error", reject);
2993
2993
  htmlStream.end();
2994
2994
  });
@@ -5492,6 +5492,1395 @@ function convertAttachments(attachments) {
5492
5492
 
5493
5493
  // src/index.ts
5494
5494
  import { STORY_META_KEY } from "executable-stories-core/types/story";
5495
+
5496
+ // src/sync/engine.ts
5497
+ import { behaviourFingerprint as behaviourFingerprint2, behaviourSimilarity as behaviourSimilarity2 } from "executable-stories-core/converters/acl/ids";
5498
+
5499
+ // src/sync/lockfile.ts
5500
+ import * as fs4 from "fs";
5501
+ import * as path5 from "path";
5502
+ import { createHash as createHash5 } from "crypto";
5503
+ var DEFAULT_LOCKFILE_PATH = ".executable-stories/sync.lock.json";
5504
+ var LOCKFILE_VERSION = 1;
5505
+ function emptyLockfile() {
5506
+ return { version: LOCKFILE_VERSION, providers: {} };
5507
+ }
5508
+ function hashCaseBody(body) {
5509
+ const canonical = JSON.stringify({
5510
+ title: body.title.trim(),
5511
+ steps: body.steps.map((s) => `${s.keyword.toLowerCase()}:${s.text.trim()}`),
5512
+ description: body.description.trim()
5513
+ });
5514
+ return createHash5("sha1").update(canonical).digest("hex").slice(0, 16);
5515
+ }
5516
+ function parseLockfile(contents, label) {
5517
+ let parsed;
5518
+ try {
5519
+ parsed = JSON.parse(contents);
5520
+ } catch (err) {
5521
+ throw new Error(
5522
+ `Sync lockfile at ${label} is not valid JSON: ${err.message}
5523
+ Fix or delete it \u2014 deleting orphans every existing case binding, so prefer fixing.`
5524
+ );
5525
+ }
5526
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5527
+ throw new Error(`Sync lockfile at ${label} must contain an object.`);
5528
+ }
5529
+ const lock = parsed;
5530
+ if (lock.version !== LOCKFILE_VERSION) {
5531
+ throw new Error(
5532
+ `Sync lockfile at ${label} has version ${String(lock.version)}, expected ${LOCKFILE_VERSION}.`
5533
+ );
5534
+ }
5535
+ return { version: LOCKFILE_VERSION, providers: lock.providers ?? {} };
5536
+ }
5537
+ function serializeLockfile(lock) {
5538
+ const providers = {};
5539
+ for (const provider of Object.keys(lock.providers).sort()) {
5540
+ const entries = lock.providers[provider] ?? {};
5541
+ const sorted = {};
5542
+ for (const key of Object.keys(entries).sort()) sorted[key] = entries[key];
5543
+ providers[provider] = sorted;
5544
+ }
5545
+ return `${JSON.stringify({ version: LOCKFILE_VERSION, providers }, null, 2)}
5546
+ `;
5547
+ }
5548
+ function readLockfile(file) {
5549
+ if (!fs4.existsSync(file)) return emptyLockfile();
5550
+ return parseLockfile(fs4.readFileSync(file, "utf8"), file);
5551
+ }
5552
+ function writeLockfile(file, lock) {
5553
+ fs4.mkdirSync(path5.dirname(path5.resolve(file)), { recursive: true });
5554
+ fs4.writeFileSync(file, serializeLockfile(lock), "utf8");
5555
+ }
5556
+ function entriesFor(lock, provider) {
5557
+ return lock.providers[provider] ?? {};
5558
+ }
5559
+ function setEntry(lock, provider, fingerprint, entry) {
5560
+ lock.providers[provider] ??= {};
5561
+ lock.providers[provider][fingerprint] = entry;
5562
+ }
5563
+
5564
+ // src/sync/engine.ts
5565
+ var DEFAULT_DUPLICATE_THRESHOLD = 0.7;
5566
+ var PARTIAL_RUN_ORPHAN_RATIO = 0.25;
5567
+ function normalizeTitle(text2) {
5568
+ return text2.toLowerCase().replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
5569
+ }
5570
+ function renderDocs(docs, depth = 0) {
5571
+ if (!docs || docs.length === 0) return "";
5572
+ const lines = [];
5573
+ for (const doc of docs) {
5574
+ switch (doc.kind) {
5575
+ case "note":
5576
+ lines.push(doc.text);
5577
+ break;
5578
+ case "kv":
5579
+ lines.push(`**${doc.label}:** ${formatValue(doc.value)}`);
5580
+ break;
5581
+ case "state":
5582
+ lines.push(`**${doc.label ?? "State"}:** ${formatValue(doc.value)}`);
5583
+ break;
5584
+ case "code":
5585
+ lines.push(`**${doc.label}**`, "", "```" + (doc.lang ?? ""), doc.content, "```");
5586
+ break;
5587
+ case "table":
5588
+ lines.push(
5589
+ `**${doc.label}**`,
5590
+ "",
5591
+ `| ${doc.columns.join(" | ")} |`,
5592
+ `| ${doc.columns.map(() => "---").join(" | ")} |`,
5593
+ ...doc.rows.map((row) => `| ${row.join(" | ")} |`)
5594
+ );
5595
+ break;
5596
+ case "link":
5597
+ lines.push(`[${doc.label}](${doc.url})`);
5598
+ break;
5599
+ case "section":
5600
+ lines.push(`${"#".repeat(Math.min(6, depth + 3))} ${doc.title}`, "", doc.markdown);
5601
+ break;
5602
+ case "mermaid":
5603
+ lines.push(...doc.title ? [`**${doc.title}**`, ""] : [], "```mermaid", doc.code, "```");
5604
+ break;
5605
+ case "screenshot":
5606
+ lines.push(`_Screenshot: ${doc.alt ?? doc.path}_`);
5607
+ break;
5608
+ case "video":
5609
+ lines.push(`_Video: ${doc.caption ?? doc.path}_`);
5610
+ break;
5611
+ case "html":
5612
+ lines.push(`_Embedded: ${doc.title ?? doc.url ?? doc.path ?? "html"}_`);
5613
+ break;
5614
+ case "custom":
5615
+ lines.push(`_${doc.type}_: ${formatValue(doc.data)}`);
5616
+ break;
5617
+ case "tag":
5618
+ break;
5619
+ }
5620
+ const children = renderDocs(doc.children, depth + 1);
5621
+ if (children) lines.push(children);
5622
+ }
5623
+ return lines.join("\n");
5624
+ }
5625
+ function formatValue(value) {
5626
+ if (typeof value === "string") return value;
5627
+ return JSON.stringify(value);
5628
+ }
5629
+ function scenarioUrl(config, tc) {
5630
+ if (!config.reportUrl) return void 0;
5631
+ const base = config.reportUrl.replace(/\/$/, "");
5632
+ const anchor = config.scenarioAnchor?.(tc);
5633
+ return anchor ? `${base}#${anchor}` : base;
5634
+ }
5635
+ function toCaseBody(tc, config) {
5636
+ const sections = [];
5637
+ const docs = renderDocs(tc.story.docs);
5638
+ if (docs) sections.push(docs);
5639
+ const tickets = tc.story.tickets ?? [];
5640
+ if (tickets.length > 0) {
5641
+ sections.push(
5642
+ `**Requirements:** ${tickets.map((t) => t.url ? `[${t.id}](${t.url})` : t.id).join(", ")}`
5643
+ );
5644
+ }
5645
+ if (tc.story.covers?.length) {
5646
+ sections.push(`**Covers:** ${tc.story.covers.join(", ")}`);
5647
+ }
5648
+ sections.push(`_Generated from ${tc.sourceFile}:${tc.sourceLine} by executable-stories. Edit the test, not this case._`);
5649
+ const links = [];
5650
+ const report = scenarioUrl(config, tc);
5651
+ if (report) links.push({ label: "Living documentation", url: report });
5652
+ for (const ticket of tickets) {
5653
+ if (ticket.url) links.push({ label: ticket.id, url: ticket.url });
5654
+ }
5655
+ return {
5656
+ title: tc.story.scenario,
5657
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
5658
+ description: sections.join("\n\n"),
5659
+ links
5660
+ };
5661
+ }
5662
+ function projectBehaviours(run, config) {
5663
+ const fingerprints = run.testCases.map(
5664
+ (tc) => behaviourFingerprint2({
5665
+ scenario: tc.story.scenario,
5666
+ sourceFile: tc.sourceFile,
5667
+ steps: tc.story.steps.map((s) => ({ keyword: s.keyword, text: s.text })),
5668
+ covers: tc.story.covers
5669
+ })
5670
+ );
5671
+ const counts = /* @__PURE__ */ new Map();
5672
+ for (const fp of fingerprints) {
5673
+ if (fp) counts.set(fp, (counts.get(fp) ?? 0) + 1);
5674
+ }
5675
+ return run.testCases.map((tc, index) => {
5676
+ const fp = fingerprints[index];
5677
+ const unique = fp !== "" && counts.get(fp) === 1;
5678
+ return {
5679
+ fingerprint: unique ? fp : tc.id,
5680
+ testCase: tc,
5681
+ body: toCaseBody(tc, config)
5682
+ };
5683
+ });
5684
+ }
5685
+ function roleFor(attachment) {
5686
+ const type = attachment.mediaType.toLowerCase();
5687
+ if (type.startsWith("image/")) return "screenshot";
5688
+ if (type.startsWith("video/")) return "video";
5689
+ if (attachment.name.toLowerCase().includes("trace")) return "trace";
5690
+ return "log";
5691
+ }
5692
+ function decode(attachment) {
5693
+ return attachment.contentEncoding === "BASE64" ? Uint8Array.from(Buffer.from(attachment.body, "base64")) : new TextEncoder().encode(attachment.body);
5694
+ }
5695
+ function collectAttachments(args) {
5696
+ const { testCase, policy, maxBytes } = args;
5697
+ if (policy === "none") return { attachments: [], oversized: [] };
5698
+ if (policy === "failed" && testCase.status !== "failed") return { attachments: [], oversized: [] };
5699
+ const attachments = [];
5700
+ const oversized = [];
5701
+ for (const raw of testCase.attachments) {
5702
+ const body = decode(raw);
5703
+ if (maxBytes !== void 0 && body.byteLength > maxBytes) {
5704
+ oversized.push({ filename: raw.name, bytes: body.byteLength, limit: maxBytes });
5705
+ continue;
5706
+ }
5707
+ attachments.push({
5708
+ filename: raw.name,
5709
+ mediaType: raw.mediaType,
5710
+ body,
5711
+ role: roleFor(raw)
5712
+ });
5713
+ }
5714
+ return { attachments, oversized };
5715
+ }
5716
+ function toCaseResult(args) {
5717
+ const { behaviour, caseId, provider, config } = args;
5718
+ const tc = behaviour.testCase;
5719
+ if (tc.status === "pending") return void 0;
5720
+ const { attachments, oversized } = collectAttachments({
5721
+ testCase: tc,
5722
+ policy: config.attach ?? "failed",
5723
+ maxBytes: provider.maxAttachmentBytes
5724
+ });
5725
+ return {
5726
+ result: {
5727
+ caseId,
5728
+ status: tc.status,
5729
+ durationMs: tc.durationMs,
5730
+ message: tc.errorMessage,
5731
+ url: scenarioUrl(config, tc),
5732
+ attachments: attachments.length > 0 ? attachments : void 0
5733
+ },
5734
+ oversized
5735
+ };
5736
+ }
5737
+ function ticketBinding(tc, config) {
5738
+ const prefix = config.ticketPrefix;
5739
+ if (!prefix) return void 0;
5740
+ const match = (tc.story.tickets ?? []).find((t) => t.id.startsWith(prefix));
5741
+ if (!match) return void 0;
5742
+ return config.ticketPrefixStrip === false ? match.id : match.id.slice(prefix.length);
5743
+ }
5744
+ async function analyzeSync(args) {
5745
+ const { run, provider, lockfile, config } = args;
5746
+ const local = projectBehaviours(run, config);
5747
+ const remoteCases = await provider.listCases();
5748
+ const remoteById = new Map(remoteCases.map((c) => [c.id, c]));
5749
+ const locked = entriesFor(lockfile, provider.name);
5750
+ const create = [];
5751
+ const update = [];
5752
+ const unchanged = [];
5753
+ const adopted = [];
5754
+ const skipped = [];
5755
+ const results = [];
5756
+ const oversized = [];
5757
+ const boundCaseIds = /* @__PURE__ */ new Set();
5758
+ let driftUncheckable = 0;
5759
+ for (const behaviour of local) {
5760
+ const entry = locked[behaviour.fingerprint];
5761
+ const caseId = entry?.caseId ?? ticketBinding(behaviour.testCase, config);
5762
+ const remote2 = caseId ? remoteById.get(caseId) : void 0;
5763
+ if (!caseId) {
5764
+ create.push({
5765
+ fingerprint: behaviour.fingerprint,
5766
+ scenario: behaviour.body.title,
5767
+ body: behaviour.body
5768
+ });
5769
+ continue;
5770
+ }
5771
+ if (!remote2) {
5772
+ skipped.push({
5773
+ fingerprint: behaviour.fingerprint,
5774
+ caseId,
5775
+ url: entry?.url ?? "",
5776
+ title: entry?.title ?? behaviour.body.title,
5777
+ reason: "case-missing"
5778
+ });
5779
+ continue;
5780
+ }
5781
+ boundCaseIds.add(caseId);
5782
+ const pending = toCaseResult({ behaviour, caseId, provider, config });
5783
+ if (pending) {
5784
+ results.push(pending.result);
5785
+ oversized.push(...pending.oversized);
5786
+ }
5787
+ const planned = {
5788
+ fingerprint: behaviour.fingerprint,
5789
+ caseId,
5790
+ url: remote2.url,
5791
+ scenario: behaviour.body.title,
5792
+ body: behaviour.body
5793
+ };
5794
+ if (!entry?.owned) {
5795
+ adopted.push(planned);
5796
+ continue;
5797
+ }
5798
+ const remoteHash = remote2.body ? hashCaseBody(remote2.body) : void 0;
5799
+ if (remoteHash !== void 0 && remoteHash !== entry.hash) {
5800
+ skipped.push({
5801
+ fingerprint: behaviour.fingerprint,
5802
+ caseId,
5803
+ url: remote2.url,
5804
+ title: remote2.title,
5805
+ reason: "remote-edited"
5806
+ });
5807
+ continue;
5808
+ }
5809
+ const baseline = remoteHash ?? entry.hash;
5810
+ if (baseline !== "" && hashCaseBody(behaviour.body) === baseline) {
5811
+ unchanged.push(planned);
5812
+ } else {
5813
+ update.push(planned);
5814
+ }
5815
+ if (remoteHash === void 0) driftUncheckable += 1;
5816
+ }
5817
+ const byNormalizedTitle = /* @__PURE__ */ new Map();
5818
+ for (const behaviour of local) byNormalizedTitle.set(normalizeTitle(behaviour.body.title), behaviour);
5819
+ const threshold = config.duplicateThreshold ?? DEFAULT_DUPLICATE_THRESHOLD;
5820
+ const remote = remoteCases.map((remoteCase) => {
5821
+ if (boundCaseIds.has(remoteCase.id)) {
5822
+ return { case: remoteCase, classification: "automated" };
5823
+ }
5824
+ const titleMatch = byNormalizedTitle.get(normalizeTitle(remoteCase.title));
5825
+ if (titleMatch) {
5826
+ return {
5827
+ case: remoteCase,
5828
+ classification: "duplicated",
5829
+ resembles: titleMatch.body.title
5830
+ };
5831
+ }
5832
+ if (remoteCase.body && remoteCase.body.steps.length > 0) {
5833
+ let best;
5834
+ for (const behaviour of local) {
5835
+ const score = behaviourSimilarity2(
5836
+ {
5837
+ scenario: remoteCase.title,
5838
+ sourceFile: "",
5839
+ steps: remoteCase.body.steps
5840
+ },
5841
+ {
5842
+ scenario: behaviour.body.title,
5843
+ sourceFile: behaviour.testCase.sourceFile,
5844
+ steps: behaviour.body.steps
5845
+ }
5846
+ );
5847
+ if (!best || score > best.score) best = { behaviour, score };
5848
+ }
5849
+ if (best && best.score >= threshold) {
5850
+ return {
5851
+ case: remoteCase,
5852
+ classification: "possible-duplicate",
5853
+ resembles: best.behaviour.body.title,
5854
+ similarity: Number(best.score.toFixed(2))
5855
+ };
5856
+ }
5857
+ }
5858
+ return { case: remoteCase, classification: "manual-only" };
5859
+ });
5860
+ const localFingerprints = new Set(local.map((b) => b.fingerprint));
5861
+ const orphaned = Object.entries(locked).filter(([fingerprint]) => !localFingerprints.has(fingerprint)).map(([fingerprint, entry]) => ({
5862
+ fingerprint,
5863
+ caseId: entry.caseId,
5864
+ url: entry.url,
5865
+ title: entry.title
5866
+ }));
5867
+ const lockedCount = Object.keys(locked).length;
5868
+ 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;
5869
+ const unsupported = [];
5870
+ if (create.length > 0 && !provider.createCase) unsupported.push("createCase");
5871
+ if (update.length > 0 && !provider.updateCase) unsupported.push("updateCase");
5872
+ if (results.length > 0 && !provider.recordResults) unsupported.push("recordResults");
5873
+ const byRole = {};
5874
+ let files = 0;
5875
+ let bytes = 0;
5876
+ for (const result of results) {
5877
+ for (const attachment of result.attachments ?? []) {
5878
+ files += 1;
5879
+ bytes += attachment.body.byteLength;
5880
+ const role = attachment.role ?? "log";
5881
+ byRole[role] = (byRole[role] ?? 0) + 1;
5882
+ }
5883
+ }
5884
+ return {
5885
+ provider: provider.name,
5886
+ target: provider.describeTarget?.(),
5887
+ local,
5888
+ remote,
5889
+ create,
5890
+ update,
5891
+ unchanged,
5892
+ adopted,
5893
+ skipped,
5894
+ orphaned,
5895
+ results,
5896
+ attachments: { files, bytes, oversized, byRole },
5897
+ unsupported,
5898
+ driftUncheckable,
5899
+ partialRunWarning
5900
+ };
5901
+ }
5902
+ async function applySync(args, deps) {
5903
+ const { analysis, provider, lockfile, config } = args;
5904
+ const result = {
5905
+ created: [],
5906
+ updated: [],
5907
+ resultsRecorded: 0,
5908
+ resultsSkipped: [],
5909
+ attachmentsUploaded: 0,
5910
+ errors: []
5911
+ };
5912
+ const byFingerprint = new Map(analysis.local.map((b) => [b.fingerprint, b]));
5913
+ const results = [...analysis.results];
5914
+ if (analysis.create.length > 0 && provider.createCase) {
5915
+ for (const planned of analysis.create) {
5916
+ try {
5917
+ const created = await provider.createCase(planned.body);
5918
+ setEntry(lockfile, provider.name, planned.fingerprint, {
5919
+ caseId: created.id,
5920
+ url: created.url,
5921
+ hash: hashCaseBody(created.body ?? planned.body),
5922
+ title: created.title,
5923
+ owned: true
5924
+ });
5925
+ result.created.push({ scenario: planned.scenario, caseId: created.id, url: created.url });
5926
+ const behaviour = byFingerprint.get(planned.fingerprint);
5927
+ if (behaviour) {
5928
+ const pending = toCaseResult({ behaviour, caseId: created.id, provider, config });
5929
+ if (pending) results.push(pending.result);
5930
+ }
5931
+ } catch (err) {
5932
+ result.errors.push(`create "${planned.scenario}": ${err.message}`);
5933
+ }
5934
+ }
5935
+ }
5936
+ if (analysis.update.length > 0 && provider.updateCase) {
5937
+ for (const planned of analysis.update) {
5938
+ try {
5939
+ const updated = await provider.updateCase(planned.caseId, planned.body);
5940
+ setEntry(lockfile, provider.name, planned.fingerprint, {
5941
+ caseId: updated.id,
5942
+ url: updated.url,
5943
+ hash: hashCaseBody(updated.body ?? planned.body),
5944
+ title: updated.title,
5945
+ owned: true
5946
+ });
5947
+ result.updated.push({ scenario: planned.scenario, caseId: updated.id, url: updated.url });
5948
+ } catch (err) {
5949
+ result.errors.push(`update "${planned.scenario}" (${planned.caseId}): ${err.message}`);
5950
+ }
5951
+ }
5952
+ }
5953
+ for (const planned of analysis.unchanged) {
5954
+ const existing = entriesFor(lockfile, provider.name)[planned.fingerprint];
5955
+ if (existing) {
5956
+ setEntry(lockfile, provider.name, planned.fingerprint, { ...existing, url: planned.url });
5957
+ }
5958
+ }
5959
+ for (const planned of analysis.adopted) {
5960
+ setEntry(lockfile, provider.name, planned.fingerprint, {
5961
+ caseId: planned.caseId,
5962
+ url: planned.url,
5963
+ hash: "",
5964
+ title: planned.scenario,
5965
+ owned: false
5966
+ });
5967
+ }
5968
+ if (results.length > 0 && provider.recordResults) {
5969
+ try {
5970
+ const summary = await provider.recordResults(results);
5971
+ result.resultsRecorded = summary.recorded;
5972
+ result.resultsSkipped = summary.skipped;
5973
+ result.attachmentsUploaded = summary.attachmentsUploaded;
5974
+ result.runUrl = summary.runUrl;
5975
+ } catch (err) {
5976
+ result.errors.push(`record results: ${err.message}`);
5977
+ }
5978
+ } else if (results.length > 0) {
5979
+ deps.logger.warn(
5980
+ `${provider.name} does not support recording results \u2014 ${results.length} execution(s) not pushed.`
5981
+ );
5982
+ }
5983
+ return result;
5984
+ }
5985
+
5986
+ // src/sync/report.ts
5987
+ var MANUAL_ONLY_LIMIT = 50;
5988
+ function summarize2(analysis) {
5989
+ const count2 = (kind) => analysis.remote.filter((c) => c.classification === kind).length;
5990
+ return {
5991
+ provider: analysis.provider,
5992
+ target: analysis.target,
5993
+ totalCases: analysis.remote.length,
5994
+ automated: count2("automated"),
5995
+ duplicated: count2("duplicated"),
5996
+ possibleDuplicate: count2("possible-duplicate"),
5997
+ manualOnly: count2("manual-only"),
5998
+ untracked: analysis.create.length,
5999
+ adopted: analysis.adopted.length
6000
+ };
6001
+ }
6002
+ function sectionBreakdown(analysis) {
6003
+ const sections = /* @__PURE__ */ new Map();
6004
+ for (const entry of analysis.remote) {
6005
+ const name = entry.case.section ?? "(no section)";
6006
+ const bucket2 = sections.get(name) ?? { total: 0, automated: 0 };
6007
+ bucket2.total += 1;
6008
+ if (entry.classification === "automated") bucket2.automated += 1;
6009
+ sections.set(name, bucket2);
6010
+ }
6011
+ return [...sections.entries()].map(([name, value]) => ({ name, ...value })).sort((a, b) => b.automated - a.automated || b.total - a.total);
6012
+ }
6013
+ function renderCoverageText(analysis) {
6014
+ const summary = summarize2(analysis);
6015
+ const lines = [];
6016
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"} (${summary.totalCases} cases)`);
6017
+ lines.push("");
6018
+ lines.push(` ${pad(summary.automated)} automated already covered by a story`);
6019
+ lines.push(` ${pad(summary.duplicated)} duplicated manual case duplicates an automated story`);
6020
+ if (summary.possibleDuplicate > 0) {
6021
+ lines.push(` ${pad(summary.possibleDuplicate)} possible dupe similar to a story, needs a human to confirm`);
6022
+ }
6023
+ lines.push(` ${pad(summary.manualOnly)} manual only no automated equivalent`);
6024
+ lines.push(` ${pad(summary.untracked)} untracked story with no case`);
6025
+ if (summary.adopted > 0) {
6026
+ lines.push(` ${pad(summary.adopted)} linked story bound to a hand-authored case`);
6027
+ }
6028
+ const sections = sectionBreakdown(analysis).filter((s) => s.automated > 0);
6029
+ if (sections.length > 0) {
6030
+ const top = sections[0];
6031
+ lines.push("");
6032
+ lines.push(`Biggest overlap: "${top.name}" section, ${top.automated} of ${top.total} automated.`);
6033
+ }
6034
+ if (analysis.orphaned.length > 0) {
6035
+ lines.push("");
6036
+ lines.push(`${analysis.orphaned.length} case(s) bound to a story that no longer exists. Nothing was removed.`);
6037
+ }
6038
+ if (analysis.partialRunWarning) {
6039
+ lines.push("");
6040
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6041
+ }
6042
+ return lines.join("\n");
6043
+ }
6044
+ function pad(value) {
6045
+ return String(value).padStart(4, " ");
6046
+ }
6047
+ function renderCoverageMarkdown(analysis) {
6048
+ const summary = summarize2(analysis);
6049
+ const lines = [];
6050
+ lines.push(`# Test coverage vs ${analysis.provider}`);
6051
+ lines.push("");
6052
+ if (analysis.target) lines.push(`**Target:** ${analysis.target}`, "");
6053
+ lines.push("| Cases | Count | Meaning |");
6054
+ lines.push("| --- | ---: | --- |");
6055
+ lines.push(`| Automated | ${summary.automated} | Already covered by a story |`);
6056
+ lines.push(`| Duplicated | ${summary.duplicated} | Manual case duplicates an automated story |`);
6057
+ lines.push(`| Possible duplicate | ${summary.possibleDuplicate} | Similar to a story, needs review |`);
6058
+ lines.push(`| Manual only | ${summary.manualOnly} | No automated equivalent |`);
6059
+ lines.push(`| Untracked stories | ${summary.untracked} | Story with no case |`);
6060
+ lines.push("");
6061
+ const duplicates = analysis.remote.filter(
6062
+ (c) => c.classification === "duplicated" || c.classification === "possible-duplicate"
6063
+ );
6064
+ if (duplicates.length > 0) {
6065
+ lines.push("## Retire these first");
6066
+ lines.push("");
6067
+ lines.push("Manual cases an automated story already covers.");
6068
+ lines.push("");
6069
+ lines.push("| Case | Title | Covered by | Confidence |");
6070
+ lines.push("| --- | --- | --- | --- |");
6071
+ for (const entry of duplicates) {
6072
+ const confidence = entry.classification === "duplicated" ? "exact title" : `similarity ${entry.similarity}`;
6073
+ lines.push(
6074
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.resembles ?? "")} | ${confidence} |`
6075
+ );
6076
+ }
6077
+ lines.push("");
6078
+ }
6079
+ const manualOnly = analysis.remote.filter((c) => c.classification === "manual-only");
6080
+ if (manualOnly.length > 0) {
6081
+ lines.push("## Not automated yet");
6082
+ lines.push("");
6083
+ lines.push("Cases with no automated equivalent. This is the backlog.");
6084
+ lines.push("");
6085
+ lines.push("| Case | Title | Section |");
6086
+ lines.push("| --- | --- | --- |");
6087
+ for (const entry of manualOnly.slice(0, MANUAL_ONLY_LIMIT)) {
6088
+ lines.push(
6089
+ `| [${entry.case.id}](${entry.case.url}) | ${escapeCell2(entry.case.title)} | ${escapeCell2(entry.case.section ?? "")} |`
6090
+ );
6091
+ }
6092
+ if (manualOnly.length > MANUAL_ONLY_LIMIT) {
6093
+ lines.push("");
6094
+ lines.push(
6095
+ `_${manualOnly.length - MANUAL_ONLY_LIMIT} more not listed here. The JSON artifact has all ${manualOnly.length}._`
6096
+ );
6097
+ }
6098
+ lines.push("");
6099
+ }
6100
+ if (analysis.create.length > 0) {
6101
+ lines.push("## Stories with no case");
6102
+ lines.push("");
6103
+ for (const planned of analysis.create) lines.push(`- ${planned.scenario}`);
6104
+ lines.push("");
6105
+ }
6106
+ if (analysis.orphaned.length > 0) {
6107
+ lines.push("## Cases with no story");
6108
+ lines.push("");
6109
+ lines.push("Bound to a story that has since been deleted. Nothing was removed automatically.");
6110
+ lines.push("");
6111
+ for (const orphan of analysis.orphaned) {
6112
+ lines.push(`- [${orphan.caseId}](${orphan.url}) ${escapeCell2(orphan.title)}`);
6113
+ }
6114
+ lines.push("");
6115
+ }
6116
+ const sections = sectionBreakdown(analysis);
6117
+ if (sections.length > 1) {
6118
+ lines.push("## By section");
6119
+ lines.push("");
6120
+ lines.push("| Section | Automated | Total |");
6121
+ lines.push("| --- | ---: | ---: |");
6122
+ for (const section of sections) {
6123
+ lines.push(`| ${escapeCell2(section.name)} | ${section.automated} | ${section.total} |`);
6124
+ }
6125
+ lines.push("");
6126
+ }
6127
+ if (analysis.partialRunWarning) {
6128
+ lines.push(`> ${analysis.partialRunWarning}`, "");
6129
+ }
6130
+ return lines.join("\n");
6131
+ }
6132
+ function escapeCell2(text2) {
6133
+ return text2.replace(/\|/g, "\\|");
6134
+ }
6135
+ function buildCoverageJson(analysis) {
6136
+ return {
6137
+ schema: "executable-stories/sync-coverage/v1",
6138
+ ...summarize2(analysis),
6139
+ cases: analysis.remote.map((entry) => ({
6140
+ id: entry.case.id,
6141
+ url: entry.case.url,
6142
+ title: entry.case.title,
6143
+ section: entry.case.section,
6144
+ classification: entry.classification,
6145
+ resembles: entry.resembles,
6146
+ similarity: entry.similarity
6147
+ })),
6148
+ untrackedScenarios: analysis.create.map((c) => c.scenario),
6149
+ orphaned: analysis.orphaned.map((o) => ({ caseId: o.caseId, url: o.url, title: o.title })),
6150
+ sections: sectionBreakdown(analysis)
6151
+ };
6152
+ }
6153
+ function formatBytes(bytes) {
6154
+ if (bytes < 1024) return `${bytes} B`;
6155
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6156
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
6157
+ }
6158
+ function renderPlan(analysis, opts) {
6159
+ const lines = [];
6160
+ lines.push(`${analysis.provider}: ${analysis.target ?? "(target not described)"}`);
6161
+ lines.push("");
6162
+ lines.push(` + create ${pad(analysis.create.length)} cases`);
6163
+ lines.push(` ~ update ${pad(analysis.update.length)} cases`);
6164
+ lines.push(` = unchanged ${pad(analysis.unchanged.length)} cases`);
6165
+ if (analysis.adopted.length > 0) {
6166
+ lines.push(` \xB7 linked ${pad(analysis.adopted.length)} cases (hand-authored, results only)`);
6167
+ }
6168
+ if (analysis.skipped.length > 0) {
6169
+ const edited = analysis.skipped.filter((s) => s.reason === "remote-edited").length;
6170
+ const missing = analysis.skipped.filter((s) => s.reason === "case-missing").length;
6171
+ if (edited > 0) {
6172
+ lines.push(` ! skipped ${pad(edited)} cases (edited in ${analysis.provider} since last sync)`);
6173
+ }
6174
+ if (missing > 0) {
6175
+ lines.push(` ! skipped ${pad(missing)} cases (bound case no longer exists)`);
6176
+ }
6177
+ }
6178
+ if (analysis.orphaned.length > 0) {
6179
+ lines.push(` ? orphaned ${pad(analysis.orphaned.length)} cases (story deleted from codebase, never removed)`);
6180
+ }
6181
+ lines.push(` \u2192 results ${pad(analysis.results.length)} executions`);
6182
+ if (analysis.attachments.files > 0) {
6183
+ const roles = Object.entries(analysis.attachments.byRole).map(([role, count2]) => `${count2} ${role}${count2 === 1 ? "" : "s"}`).join(", ");
6184
+ lines.push(
6185
+ ` \u2191 upload ${pad(analysis.attachments.files)} attachments (${roles}, ${formatBytes(analysis.attachments.bytes)})`
6186
+ );
6187
+ }
6188
+ for (const oversized of analysis.attachments.oversized) {
6189
+ lines.push(
6190
+ ` ! oversized ${oversized.filename} (${formatBytes(oversized.bytes)}, provider limit ${formatBytes(oversized.limit)})`
6191
+ );
6192
+ }
6193
+ for (const capability of analysis.unsupported) {
6194
+ lines.push(` ! ${analysis.provider} does not support ${capability} \u2014 those changes are not applied`);
6195
+ }
6196
+ if (analysis.driftUncheckable > 0) {
6197
+ lines.push(
6198
+ ` ! ${analysis.driftUncheckable} case(s): ${analysis.provider} returned no body, so a hand edit to them cannot be detected`
6199
+ );
6200
+ }
6201
+ if (analysis.partialRunWarning) {
6202
+ lines.push("");
6203
+ lines.push(`Note: ${analysis.partialRunWarning}`);
6204
+ }
6205
+ if (analysis.skipped.some((s) => s.reason === "remote-edited")) {
6206
+ lines.push("");
6207
+ lines.push("Skipped cases were edited by hand after we last wrote them. Nothing overwrites them.");
6208
+ for (const skip of analysis.skipped.filter((s) => s.reason === "remote-edited")) {
6209
+ lines.push(` ${skip.caseId} ${skip.title} ${skip.url}`);
6210
+ }
6211
+ }
6212
+ if (opts.dryRun && hasWork(analysis)) {
6213
+ lines.push("");
6214
+ lines.push("Nothing was written. Run the same command with --apply to make these changes.");
6215
+ }
6216
+ return lines.join("\n");
6217
+ }
6218
+ function hasWork(analysis) {
6219
+ return analysis.create.length > 0 || analysis.update.length > 0 || analysis.results.length > 0;
6220
+ }
6221
+ function renderApplyResult(result) {
6222
+ const lines = [];
6223
+ for (const created of result.created) {
6224
+ lines.push(` + ${created.caseId} ${created.scenario} ${created.url}`);
6225
+ }
6226
+ for (const updated of result.updated) {
6227
+ lines.push(` ~ ${updated.caseId} ${updated.scenario}`);
6228
+ }
6229
+ lines.push("");
6230
+ lines.push(
6231
+ `Created ${result.created.length}, updated ${result.updated.length}, recorded ${result.resultsRecorded} execution(s), uploaded ${result.attachmentsUploaded} attachment(s).`
6232
+ );
6233
+ if (result.runUrl) lines.push(`Run: ${result.runUrl}`);
6234
+ for (const skipped of result.resultsSkipped) {
6235
+ lines.push(` ! result for case ${skipped.caseId} not recorded: ${skipped.reason}`);
6236
+ }
6237
+ for (const error of result.errors) {
6238
+ lines.push(` \u2717 ${error}`);
6239
+ }
6240
+ return lines.join("\n");
6241
+ }
6242
+
6243
+ // src/sync/adapters/case-text.ts
6244
+ var BDD_KEYWORDS = ["Given", "When", "Then", "And", "But"];
6245
+ function encodeStepText(step) {
6246
+ const keyword = step.keyword.trim();
6247
+ return keyword ? `${keyword} ${step.text}` : step.text;
6248
+ }
6249
+ function decodeStepText(content) {
6250
+ const trimmed = content.trim();
6251
+ const firstSpace = trimmed.indexOf(" ");
6252
+ if (firstSpace > 0) {
6253
+ const head = trimmed.slice(0, firstSpace);
6254
+ if (BDD_KEYWORDS.some((keyword) => keyword.toLowerCase() === head.toLowerCase())) {
6255
+ return { keyword: head, text: trimmed.slice(firstSpace + 1) };
6256
+ }
6257
+ }
6258
+ return { keyword: "", text: trimmed };
6259
+ }
6260
+ function encodeDescription(body) {
6261
+ if (body.links.length === 0) return body.description;
6262
+ const links = body.links.map((link2) => `- [${link2.label}](${link2.url})`).join("\n");
6263
+ return `${body.description}
6264
+
6265
+ ${links}`;
6266
+ }
6267
+ function decodeDescription(raw) {
6268
+ const links = [];
6269
+ const lines = raw.split("\n");
6270
+ let cut = lines.length;
6271
+ for (let index = lines.length - 1; index >= 0; index--) {
6272
+ const line = lines[index].trim();
6273
+ if (line === "") continue;
6274
+ const match = /^- \[([^\]]+)\]\(([^)]+)\)$/.exec(line);
6275
+ if (!match) break;
6276
+ links.unshift({ label: match[1], url: match[2] });
6277
+ cut = index;
6278
+ }
6279
+ return { description: lines.slice(0, cut).join("\n").trim(), links };
6280
+ }
6281
+
6282
+ // src/sync/adapters/testrail.ts
6283
+ var DEFAULT_STEPS_FIELD = "custom_steps_separated";
6284
+ var DEFAULT_DESCRIPTION_FIELD = "custom_preconds";
6285
+ var DEFAULT_MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
6286
+ var PAGE_LIMIT = 250;
6287
+ var MAX_ATTEMPTS = 3;
6288
+ function unwrapList(payload, key) {
6289
+ if (Array.isArray(payload)) return payload;
6290
+ if (payload && typeof payload === "object") {
6291
+ const list = payload[key];
6292
+ if (Array.isArray(list)) return list;
6293
+ }
6294
+ return [];
6295
+ }
6296
+ function encodeElapsed(durationMs) {
6297
+ const seconds = Math.floor(durationMs / 1e3);
6298
+ if (seconds < 1) return void 0;
6299
+ if (seconds < 60) return `${seconds}s`;
6300
+ const minutes = Math.floor(seconds / 60);
6301
+ const rest = seconds % 60;
6302
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
6303
+ }
6304
+ function authHint(status) {
6305
+ if (status === 401) {
6306
+ 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.";
6307
+ }
6308
+ if (status === 403) {
6309
+ 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.";
6310
+ }
6311
+ return "";
6312
+ }
6313
+ function createTestRailProvider(config, auth, deps) {
6314
+ const base = config.url.replace(/\/$/, "");
6315
+ const stepsField = config.fields?.steps ?? DEFAULT_STEPS_FIELD;
6316
+ const descriptionField = config.fields?.description ?? DEFAULT_DESCRIPTION_FIELD;
6317
+ const basicAuth = Buffer.from(`${auth.username}:${auth.apiKey}`).toString("base64");
6318
+ let projectName;
6319
+ let suiteName;
6320
+ const sectionNames = /* @__PURE__ */ new Map();
6321
+ async function api(method, init) {
6322
+ const url = `${base}/index.php?/api/v2/${method}`;
6323
+ const headers = {
6324
+ Authorization: `Basic ${basicAuth}`
6325
+ };
6326
+ let body;
6327
+ if (init?.form) {
6328
+ body = init.form;
6329
+ } else if (init?.body !== void 0) {
6330
+ headers["Content-Type"] = "application/json";
6331
+ body = JSON.stringify(init.body);
6332
+ }
6333
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
6334
+ const response = await deps.fetch(url, {
6335
+ method: body === void 0 ? "GET" : "POST",
6336
+ headers,
6337
+ body
6338
+ });
6339
+ if (response.status === 429 && attempt < MAX_ATTEMPTS - 1) {
6340
+ const retryAfter = Number(response.headers.get("retry-after") ?? "1");
6341
+ deps.logger.warn(`TestRail rate limit hit, retrying in ${retryAfter}s`);
6342
+ await new Promise((resolve9) => setTimeout(resolve9, Math.max(1, retryAfter) * 1e3));
6343
+ continue;
6344
+ }
6345
+ const text2 = await response.text();
6346
+ if (text2.length === 0) {
6347
+ if (response.ok) return void 0;
6348
+ throw new Error(
6349
+ `TestRail ${method} failed (${response.status}) with an empty response${authHint(response.status)}`
6350
+ );
6351
+ }
6352
+ let parsed;
6353
+ try {
6354
+ parsed = JSON.parse(text2);
6355
+ } catch {
6356
+ throw new Error(
6357
+ `TestRail ${method} returned HTML rather than JSON (status ${response.status}).
6358
+ Check that sync.testrail.url is the instance root, e.g. https://acme.testrail.io, with no path after it.`
6359
+ );
6360
+ }
6361
+ if (!response.ok) {
6362
+ const detail = parsed.error ?? text2;
6363
+ throw new Error(
6364
+ `TestRail ${method} failed (${response.status}): ${detail}${authHint(response.status)}`
6365
+ );
6366
+ }
6367
+ return parsed;
6368
+ }
6369
+ throw new Error(`TestRail ${method} failed: rate limited after ${MAX_ATTEMPTS} attempts`);
6370
+ }
6371
+ async function paginate(method, key) {
6372
+ const all = [];
6373
+ for (let offset = 0; ; offset += PAGE_LIMIT) {
6374
+ const page = await api(`${method}&limit=${PAGE_LIMIT}&offset=${offset}`);
6375
+ const items = unwrapList(page, key);
6376
+ all.push(...items);
6377
+ if (items.length < PAGE_LIMIT) return all;
6378
+ }
6379
+ }
6380
+ function caseUrl(id) {
6381
+ return `${base}/index.php?/cases/view/${id}`;
6382
+ }
6383
+ function toRemoteCase(raw, context) {
6384
+ if (raw?.id === void 0) {
6385
+ throw new Error(
6386
+ `TestRail ${context} returned no case id. The response was: ${JSON.stringify(raw)?.slice(0, 200)}`
6387
+ );
6388
+ }
6389
+ const title = typeof raw.title === "string" ? raw.title : "";
6390
+ const steps = Array.isArray(raw[stepsField]) ? raw[stepsField] : [];
6391
+ const rawDescription = typeof raw[descriptionField] === "string" ? raw[descriptionField] : "";
6392
+ const { description, links } = decodeDescription(rawDescription);
6393
+ return {
6394
+ id: String(raw.id),
6395
+ url: caseUrl(raw.id),
6396
+ title,
6397
+ section: raw.section_id === void 0 ? void 0 : sectionNames.get(raw.section_id),
6398
+ body: {
6399
+ title,
6400
+ steps: steps.map((step) => decodeStepText(step.content ?? "")),
6401
+ description,
6402
+ links
6403
+ }
6404
+ };
6405
+ }
6406
+ function bodyToPayload(body) {
6407
+ const payload = {
6408
+ title: body.title,
6409
+ [stepsField]: body.steps.map((step) => ({ content: encodeStepText(step), expected: "" })),
6410
+ [descriptionField]: encodeDescription(body)
6411
+ };
6412
+ if (config.templateId !== void 0) payload["template_id"] = config.templateId;
6413
+ return payload;
6414
+ }
6415
+ const suiteQuery = config.suiteId === void 0 ? "" : `&suite_id=${config.suiteId}`;
6416
+ return {
6417
+ name: "testrail",
6418
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES,
6419
+ describeTarget() {
6420
+ const project = projectName ?? `project ${config.projectId}`;
6421
+ const suite = suiteName ?? (config.suiteId === void 0 ? void 0 : `suite ${config.suiteId}`);
6422
+ return suite ? `${project} / ${suite}` : project;
6423
+ },
6424
+ async listCases() {
6425
+ try {
6426
+ const project = await api(`get_project/${config.projectId}`);
6427
+ projectName = project?.name;
6428
+ if (config.suiteId !== void 0) {
6429
+ const suite = await api(`get_suite/${config.suiteId}`);
6430
+ suiteName = suite?.name;
6431
+ }
6432
+ const sections = await paginate(
6433
+ `get_sections/${config.projectId}${suiteQuery}`,
6434
+ "sections"
6435
+ );
6436
+ for (const section of sections) sectionNames.set(section.id, section.name);
6437
+ } catch (err) {
6438
+ deps.logger.warn(`TestRail metadata lookup failed, continuing without names: ${err.message}`);
6439
+ }
6440
+ const cases = await paginate(`get_cases/${config.projectId}${suiteQuery}`, "cases");
6441
+ return cases.map((raw) => toRemoteCase(raw, "get_cases"));
6442
+ },
6443
+ async createCase(body) {
6444
+ if (config.sectionId === void 0) {
6445
+ throw new Error(
6446
+ "TestRail needs a sectionId to create cases. Set sync.testrail.sectionId to the section new cases should land in."
6447
+ );
6448
+ }
6449
+ const created = await api(`add_case/${config.sectionId}`, {
6450
+ body: bodyToPayload(body)
6451
+ });
6452
+ return toRemoteCase(created, `add_case/${config.sectionId}`);
6453
+ },
6454
+ async updateCase(id, body) {
6455
+ const updated = await api(`update_case/${id}`, { body: bodyToPayload(body) });
6456
+ return toRemoteCase(updated, `update_case/${id}`);
6457
+ },
6458
+ async recordResults(results) {
6459
+ const statusIds = {
6460
+ passed: config.statusIds?.passed ?? 1,
6461
+ failed: config.statusIds?.failed ?? 5,
6462
+ skipped: config.statusIds?.skipped
6463
+ };
6464
+ const skipped = [];
6465
+ const sendable = [];
6466
+ for (const result of results) {
6467
+ const caseId = Number(result.caseId);
6468
+ if (!Number.isFinite(caseId)) {
6469
+ skipped.push({ caseId: result.caseId, reason: "case id is not numeric" });
6470
+ continue;
6471
+ }
6472
+ const statusId = result.status === "skipped" ? statusIds.skipped : statusIds[result.status];
6473
+ if (statusId === void 0) {
6474
+ skipped.push({
6475
+ caseId: result.caseId,
6476
+ reason: "no TestRail status id configured for skipped (set sync.testrail.statusIds.skipped)"
6477
+ });
6478
+ continue;
6479
+ }
6480
+ sendable.push({ result, caseId, statusId });
6481
+ }
6482
+ if (sendable.length === 0) {
6483
+ return { recorded: 0, skipped, attachmentsUploaded: 0 };
6484
+ }
6485
+ let runId = config.runId;
6486
+ if (runId === void 0) {
6487
+ const name = `${config.runName ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`;
6488
+ const run = await api(`add_run/${config.projectId}`, {
6489
+ body: {
6490
+ ...config.suiteId === void 0 ? {} : { suite_id: config.suiteId },
6491
+ name,
6492
+ include_all: false,
6493
+ // Deduplicated: two stories can carry the same ticket id, and
6494
+ // TestRail rejects a run whose case list repeats one.
6495
+ case_ids: [...new Set(sendable.map((s) => s.caseId))]
6496
+ }
6497
+ });
6498
+ runId = run.id;
6499
+ }
6500
+ const runUrl = `${base}/index.php?/runs/view/${runId}`;
6501
+ const payload = sendable.map(({ result, caseId, statusId }) => {
6502
+ const elapsed = encodeElapsed(result.durationMs);
6503
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
6504
+ return {
6505
+ case_id: caseId,
6506
+ status_id: statusId,
6507
+ ...comment ? { comment } : {},
6508
+ ...elapsed ? { elapsed } : {}
6509
+ };
6510
+ });
6511
+ const recorded = unwrapList(
6512
+ await api(`add_results_for_cases/${runId}`, { body: { results: payload } }),
6513
+ "results"
6514
+ );
6515
+ let attachmentsUploaded = 0;
6516
+ for (const [index, entry] of recorded.entries()) {
6517
+ const source = sendable[index]?.result;
6518
+ for (const attachment of source?.attachments ?? []) {
6519
+ try {
6520
+ const form = new FormData();
6521
+ const bytes = attachment.body.buffer.slice(
6522
+ attachment.body.byteOffset,
6523
+ attachment.body.byteOffset + attachment.body.byteLength
6524
+ );
6525
+ form.append(
6526
+ "attachment",
6527
+ new Blob([bytes], { type: attachment.mediaType }),
6528
+ attachment.filename
6529
+ );
6530
+ await api(`add_attachment_to_result/${entry.id}`, { form });
6531
+ attachmentsUploaded += 1;
6532
+ } catch (err) {
6533
+ deps.logger.warn(
6534
+ `TestRail attachment "${attachment.filename}" failed: ${err.message}`
6535
+ );
6536
+ }
6537
+ }
6538
+ }
6539
+ if (config.closeRun && config.runId === void 0) {
6540
+ await api(`close_run/${runId}`, { body: {} });
6541
+ }
6542
+ return {
6543
+ runId: String(runId),
6544
+ runUrl,
6545
+ recorded: recorded.length,
6546
+ skipped,
6547
+ attachmentsUploaded
6548
+ };
6549
+ }
6550
+ };
6551
+ }
6552
+
6553
+ // src/sync/adapters/xray.ts
6554
+ var DEFAULT_XRAY_BASE = "https://xray.cloud.getxray.app";
6555
+ var DEFAULT_TEST_TYPE = "Manual";
6556
+ var DEFAULT_MAX_ATTACHMENT_BYTES2 = 32 * 1024 * 1024;
6557
+ var PAGE_LIMIT2 = 100;
6558
+ function toAdf(text2) {
6559
+ const paragraphs = text2.split("\n\n").filter((block) => block.trim() !== "");
6560
+ return {
6561
+ version: 1,
6562
+ type: "doc",
6563
+ content: paragraphs.length === 0 ? [{ type: "paragraph", content: [] }] : paragraphs.map((block) => ({
6564
+ type: "paragraph",
6565
+ content: [{ type: "text", text: block }]
6566
+ }))
6567
+ };
6568
+ }
6569
+ function fromAdf(value) {
6570
+ if (typeof value === "string") return value;
6571
+ if (!value || typeof value !== "object") return "";
6572
+ const blocks = [];
6573
+ const walk = (node, collected) => {
6574
+ if (!node || typeof node !== "object") return;
6575
+ const typed = node;
6576
+ if (typed.type === "text" && typeof typed.text === "string") collected.push(typed.text);
6577
+ for (const child of typed.content ?? []) walk(child, collected);
6578
+ };
6579
+ for (const node of value.content ?? []) {
6580
+ const collected = [];
6581
+ walk(node, collected);
6582
+ blocks.push(collected.join(""));
6583
+ }
6584
+ return blocks.join("\n\n").trim();
6585
+ }
6586
+ function createXrayProvider(config, auth, deps) {
6587
+ const xrayBase = (config.xrayBaseUrl ?? DEFAULT_XRAY_BASE).replace(/\/$/, "");
6588
+ const jiraBase = config.jiraBaseUrl.replace(/\/$/, "");
6589
+ const jql = config.jql ?? `project = "${config.projectKey}" AND issuetype = Test`;
6590
+ const issueIds = /* @__PURE__ */ new Map();
6591
+ let token;
6592
+ async function authenticate() {
6593
+ if (token) return token;
6594
+ const response = await deps.fetch(`${xrayBase}/api/v2/authenticate`, {
6595
+ method: "POST",
6596
+ headers: { "Content-Type": "application/json" },
6597
+ body: JSON.stringify({ client_id: auth.clientId, client_secret: auth.clientSecret })
6598
+ });
6599
+ const text2 = await response.text();
6600
+ if (!response.ok) {
6601
+ throw new Error(
6602
+ `Xray authentication failed (${response.status}): ${text2}
6603
+ 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.`
6604
+ );
6605
+ }
6606
+ token = JSON.parse(text2).replace(/^"|"$/g, "");
6607
+ return token;
6608
+ }
6609
+ async function graphql(query, variables) {
6610
+ const bearer = await authenticate();
6611
+ const response = await deps.fetch(`${xrayBase}/api/v2/graphql`, {
6612
+ method: "POST",
6613
+ headers: {
6614
+ Authorization: `Bearer ${bearer}`,
6615
+ "Content-Type": "application/json"
6616
+ },
6617
+ body: JSON.stringify({ query, variables })
6618
+ });
6619
+ const text2 = await response.text();
6620
+ if (!response.ok) throw new Error(`Xray GraphQL failed (${response.status}): ${text2}`);
6621
+ const payload = JSON.parse(text2);
6622
+ if (payload.errors?.length) {
6623
+ throw new Error(`Xray GraphQL error: ${payload.errors.map((e) => e.message).join("; ")}`);
6624
+ }
6625
+ return payload.data;
6626
+ }
6627
+ function issueUrl(key) {
6628
+ return `${jiraBase}/browse/${key}`;
6629
+ }
6630
+ function toRemoteCase(test) {
6631
+ const key = test.jira?.key ?? test.issueId;
6632
+ if (!key) {
6633
+ throw new Error(
6634
+ `Xray returned a test with neither a Jira key nor an issue id: ${JSON.stringify(test)?.slice(0, 200)}`
6635
+ );
6636
+ }
6637
+ if (test.jira?.key) issueIds.set(test.jira.key, test.issueId);
6638
+ const { description, links } = decodeDescription(fromAdf(test.jira?.description));
6639
+ return {
6640
+ id: key,
6641
+ url: issueUrl(key),
6642
+ title: test.jira?.summary ?? key,
6643
+ body: {
6644
+ title: test.jira?.summary ?? key,
6645
+ steps: (test.steps ?? []).map((step) => decodeStepText(step.action ?? "")),
6646
+ description,
6647
+ links
6648
+ }
6649
+ };
6650
+ }
6651
+ async function jiraUpdate(key, fields) {
6652
+ if (!auth.jiraEmail || !auth.jiraToken) {
6653
+ deps.logger.warn(
6654
+ `Xray: summary/description for ${key} left unchanged. Set JIRA_EMAIL and JIRA_TOKEN to update Jira fields (steps are updated either way).`
6655
+ );
6656
+ return;
6657
+ }
6658
+ const basic = Buffer.from(`${auth.jiraEmail}:${auth.jiraToken}`).toString("base64");
6659
+ const response = await deps.fetch(`${jiraBase}/rest/api/3/issue/${key}`, {
6660
+ method: "PUT",
6661
+ headers: { Authorization: `Basic ${basic}`, "Content-Type": "application/json" },
6662
+ body: JSON.stringify({ fields })
6663
+ });
6664
+ if (!response.ok) {
6665
+ throw new Error(`Jira update of ${key} failed (${response.status}): ${await response.text()}`);
6666
+ }
6667
+ }
6668
+ return {
6669
+ name: "xray",
6670
+ maxAttachmentBytes: config.maxAttachmentBytes ?? DEFAULT_MAX_ATTACHMENT_BYTES2,
6671
+ describeTarget() {
6672
+ return `${config.projectKey} (${jiraBase})`;
6673
+ },
6674
+ async listCases() {
6675
+ const tests = [];
6676
+ for (let start = 0; ; start += PAGE_LIMIT2) {
6677
+ const data = await graphql(
6678
+ `query($jql: String!, $limit: Int!, $start: Int!) {
6679
+ getTests(jql: $jql, limit: $limit, start: $start) {
6680
+ total
6681
+ results {
6682
+ issueId
6683
+ jira(fields: ["key", "summary", "description"])
6684
+ steps { id action data result }
6685
+ }
6686
+ }
6687
+ }`,
6688
+ { jql, limit: PAGE_LIMIT2, start }
6689
+ );
6690
+ const page = data.getTests?.results ?? [];
6691
+ tests.push(...page);
6692
+ if (page.length < PAGE_LIMIT2) break;
6693
+ }
6694
+ return tests.map(toRemoteCase);
6695
+ },
6696
+ async createCase(body) {
6697
+ const data = await graphql(
6698
+ `mutation($testType: UpdateTestTypeInput!, $steps: [CreateStepInput], $jira: JSON!) {
6699
+ createTest(testType: $testType, steps: $steps, jira: $jira) {
6700
+ test { issueId jira(fields: ["key", "summary", "description"]) }
6701
+ warnings
6702
+ }
6703
+ }`,
6704
+ {
6705
+ testType: { name: config.testType ?? DEFAULT_TEST_TYPE },
6706
+ steps: body.steps.map((step) => ({ action: encodeStepText(step), result: "" })),
6707
+ jira: {
6708
+ fields: {
6709
+ summary: body.title,
6710
+ description: toAdf(encodeDescription(body)),
6711
+ project: { key: config.projectKey }
6712
+ }
6713
+ }
6714
+ }
6715
+ );
6716
+ const created = data.createTest?.test;
6717
+ if (!created) throw new Error(`Xray createTest returned no test for "${body.title}"`);
6718
+ for (const warning of data.createTest?.warnings ?? []) deps.logger.warn(`Xray: ${warning}`);
6719
+ return toRemoteCase(created);
6720
+ },
6721
+ async updateCase(id, body) {
6722
+ const issueId = issueIds.get(id);
6723
+ if (!issueId) {
6724
+ throw new Error(`Xray: no internal issue id cached for ${id}. Run listCases first.`);
6725
+ }
6726
+ const current = await graphql(
6727
+ `query($issueId: String!) { getTest(issueId: $issueId) { steps { id } } }`,
6728
+ { issueId }
6729
+ );
6730
+ const existing = current.getTest?.steps ?? [];
6731
+ for (const [index, step] of body.steps.entries()) {
6732
+ const action = encodeStepText(step);
6733
+ const target = existing[index];
6734
+ if (target) {
6735
+ await graphql(
6736
+ `mutation($stepId: String!, $step: UpdateStepInput!) {
6737
+ updateTestStep(stepId: $stepId, step: $step)
6738
+ }`,
6739
+ { stepId: target.id, step: { action, result: "" } }
6740
+ );
6741
+ } else {
6742
+ await graphql(
6743
+ `mutation($issueId: String!, $step: CreateStepInput!) {
6744
+ addTestStep(issueId: $issueId, step: $step) { id }
6745
+ }`,
6746
+ { issueId, step: { action, result: "" } }
6747
+ );
6748
+ }
6749
+ }
6750
+ for (const surplus of existing.slice(body.steps.length).reverse()) {
6751
+ await graphql(`mutation($stepId: String!) { removeTestStep(stepId: $stepId) }`, {
6752
+ stepId: surplus.id
6753
+ });
6754
+ }
6755
+ await jiraUpdate(id, {
6756
+ summary: body.title,
6757
+ description: toAdf(encodeDescription(body))
6758
+ });
6759
+ return {
6760
+ id,
6761
+ url: issueUrl(id),
6762
+ title: body.title,
6763
+ body
6764
+ };
6765
+ },
6766
+ async recordResults(results) {
6767
+ const statuses = {
6768
+ passed: config.statuses?.passed ?? "PASSED",
6769
+ failed: config.statuses?.failed ?? "FAILED",
6770
+ skipped: config.statuses?.skipped ?? "TODO"
6771
+ };
6772
+ let attachmentsUploaded = 0;
6773
+ const tests = results.map((result) => {
6774
+ const evidence = (result.attachments ?? []).map((attachment) => {
6775
+ attachmentsUploaded += 1;
6776
+ return {
6777
+ data: Buffer.from(attachment.body).toString("base64"),
6778
+ filename: attachment.filename,
6779
+ contentType: attachment.mediaType
6780
+ };
6781
+ });
6782
+ const comment = [result.message, result.url ? `Living documentation: ${result.url}` : void 0].filter(Boolean).join("\n\n");
6783
+ return {
6784
+ testKey: result.caseId,
6785
+ status: statuses[result.status],
6786
+ ...comment ? { comment } : {},
6787
+ ...evidence.length > 0 ? { evidence } : {}
6788
+ };
6789
+ });
6790
+ const bearer = await authenticate();
6791
+ const payload = {
6792
+ ...config.testExecutionKey ? { testExecutionKey: config.testExecutionKey } : {},
6793
+ info: {
6794
+ summary: `${config.executionSummary ?? "executable-stories"} ${(/* @__PURE__ */ new Date()).toISOString()}`,
6795
+ project: config.projectKey,
6796
+ ...config.testPlanKey ? { testPlanKey: config.testPlanKey } : {}
6797
+ },
6798
+ tests
6799
+ };
6800
+ const response = await deps.fetch(`${xrayBase}/api/v2/import/execution`, {
6801
+ method: "POST",
6802
+ headers: { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" },
6803
+ body: JSON.stringify(payload)
6804
+ });
6805
+ const text2 = await response.text();
6806
+ if (!response.ok) {
6807
+ throw new Error(`Xray import execution failed (${response.status}): ${text2}`);
6808
+ }
6809
+ const imported = JSON.parse(text2);
6810
+ return {
6811
+ runId: imported.key,
6812
+ runUrl: imported.key ? issueUrl(imported.key) : void 0,
6813
+ recorded: tests.length,
6814
+ skipped: [],
6815
+ attachmentsUploaded
6816
+ };
6817
+ }
6818
+ };
6819
+ }
6820
+
6821
+ // src/sync/adapters/index.ts
6822
+ var PROVIDER_NAMES = ["testrail", "xray"];
6823
+ function isProviderName(value) {
6824
+ return PROVIDER_NAMES.includes(value);
6825
+ }
6826
+ function required(env, name, hint) {
6827
+ const value = env[name];
6828
+ if (!value) throw new Error(`Missing ${name}. ${hint}`);
6829
+ return value;
6830
+ }
6831
+ function buildProvider(args, deps) {
6832
+ const { name, targets, env } = args;
6833
+ if (name === "testrail") {
6834
+ const config2 = targets.testrail;
6835
+ if (!config2) {
6836
+ throw new Error(
6837
+ 'No TestRail config found. Add a `sync: { testrail: { url, projectId } }` block to executable-stories.config.mjs, or run "executable-stories sync testrail --init".'
6838
+ );
6839
+ }
6840
+ const provider2 = createTestRailProvider(
6841
+ config2,
6842
+ {
6843
+ username: required(env, "TESTRAIL_USERNAME", "This is the login email of the TestRail account."),
6844
+ apiKey: required(
6845
+ env,
6846
+ "TESTRAIL_API_KEY",
6847
+ "Generate one in TestRail under My Settings -> API Keys. A password will not work when the instance requires API keys."
6848
+ )
6849
+ },
6850
+ deps
6851
+ );
6852
+ return {
6853
+ provider: provider2,
6854
+ // TestRail ids are numeric and conventionally written "C1234" in a ticket,
6855
+ // so the prefix is decoration and gets stripped.
6856
+ engineDefaults: { ticketPrefix: "C", ticketPrefixStrip: true }
6857
+ };
6858
+ }
6859
+ const config = targets.xray;
6860
+ if (!config) {
6861
+ throw new Error(
6862
+ 'No Xray config found. Add a `sync: { xray: { jiraBaseUrl, projectKey } }` block to executable-stories.config.mjs, or run "executable-stories sync xray --init".'
6863
+ );
6864
+ }
6865
+ const provider = createXrayProvider(
6866
+ config,
6867
+ {
6868
+ clientId: required(env, "XRAY_CLIENT_ID", "Create an API key pair in Jira under Apps -> Xray -> API Keys."),
6869
+ clientSecret: required(env, "XRAY_CLIENT_SECRET", "This is the secret half of the Xray API key pair."),
6870
+ jiraEmail: env["JIRA_EMAIL"],
6871
+ jiraToken: env["JIRA_TOKEN"]
6872
+ },
6873
+ deps
6874
+ );
6875
+ return {
6876
+ provider,
6877
+ // An Xray case id is a Jira issue key, so the project prefix is part of the
6878
+ // id and must survive.
6879
+ engineDefaults: { ticketPrefix: `${config.projectKey}-`, ticketPrefixStrip: false }
6880
+ };
6881
+ }
6882
+
6883
+ // src/index.ts
5495
6884
  import {
5496
6885
  STORY_REPORT_SCHEMA_VERSION,
5497
6886
  STORY_REPORT_SCHEMA_MAJOR
@@ -5512,21 +6901,21 @@ import {
5512
6901
  } from "executable-stories-core/converters/acl/validate";
5513
6902
 
5514
6903
  // src/coverage-index.ts
5515
- function normalizePath(path10) {
5516
- return path10.replace(/^\.\//, "");
6904
+ function normalizePath(path11) {
6905
+ return path11.replace(/^\.\//, "");
5517
6906
  }
5518
6907
  function scenariosCoveringPaths(index, paths) {
5519
6908
  const queries = paths.map(normalizePath);
5520
6909
  return index.scenarios.filter(
5521
6910
  (scenario) => scenario.covers.some(
5522
- (glob) => queries.some((path10) => matchesPattern(normalizePath(glob), path10))
6911
+ (glob) => queries.some((path11) => matchesPattern(normalizePath(glob), path11))
5523
6912
  )
5524
6913
  );
5525
6914
  }
5526
6915
 
5527
6916
  // src/watch.ts
5528
- import * as fs4 from "fs";
5529
- import * as path5 from "path";
6917
+ import * as fs5 from "fs";
6918
+ import * as path6 from "path";
5530
6919
  import { canonicalizeRun } from "executable-stories-core/converters/acl/index";
5531
6920
  import { synthesizeStories } from "executable-stories-core/converters/synthesize";
5532
6921
  function toRun(data, inputType, synthesize) {
@@ -5536,8 +6925,8 @@ function toRun(data, inputType, synthesize) {
5536
6925
  return canonicalizeRun(raw);
5537
6926
  }
5538
6927
  async function regenerateRun(options, deps = {}) {
5539
- const read = deps.readFile ?? ((filePath) => fs4.readFileSync(filePath, "utf8"));
5540
- const data = JSON.parse(read(path5.resolve(options.input)));
6928
+ const read = deps.readFile ?? ((filePath) => fs5.readFileSync(filePath, "utf8"));
6929
+ const data = JSON.parse(read(path6.resolve(options.input)));
5541
6930
  const run = toRun(data, options.inputType ?? "raw", options.synthesize !== false);
5542
6931
  const generator = new ReportGenerator({
5543
6932
  formats: options.formats,
@@ -5553,7 +6942,7 @@ async function regenerateArtifacts(options, deps = {}) {
5553
6942
  function startWatch(options, deps = {}) {
5554
6943
  const log = deps.log ?? ((message) => console.log(message));
5555
6944
  const regenerate = deps.regenerate ?? ((input) => regenerateArtifacts({ ...options, input }, deps));
5556
- const watchFn = deps.watch ?? ((filePath, listener) => fs4.watch(filePath, listener));
6945
+ const watchFn = deps.watch ?? ((filePath, listener) => fs5.watch(filePath, listener));
5557
6946
  const debounceMs = options.debounceMs ?? 150;
5558
6947
  let timer;
5559
6948
  let running = false;
@@ -5582,7 +6971,7 @@ function startWatch(options, deps = {}) {
5582
6971
  timer = setTimeout(() => void run(), debounceMs);
5583
6972
  };
5584
6973
  trigger();
5585
- const watcher = watchFn(path5.resolve(options.input), trigger);
6974
+ const watcher = watchFn(path6.resolve(options.input), trigger);
5586
6975
  return {
5587
6976
  close: () => {
5588
6977
  if (timer) clearTimeout(timer);
@@ -5874,27 +7263,27 @@ async function updateDescription(issueKey, base, adf, headers, fetchFn) {
5874
7263
  import { parseNdjson, parseEnvelopes } from "executable-stories-core/converters/ndjson-parser";
5875
7264
 
5876
7265
  // src/utils/git-info.ts
5877
- import * as fs5 from "fs";
5878
- import * as path6 from "path";
7266
+ import * as fs6 from "fs";
7267
+ import * as path7 from "path";
5879
7268
  function readGitSha(cwd = process.cwd()) {
5880
7269
  const envSha = process.env.GITHUB_SHA || process.env.GIT_COMMIT || process.env.CI_COMMIT_SHA;
5881
7270
  if (envSha) return envSha;
5882
7271
  const gitDir = findGitDir(cwd);
5883
7272
  if (!gitDir) return void 0;
5884
7273
  try {
5885
- const headPath = path6.join(gitDir, "HEAD");
5886
- const head = fs5.readFileSync(headPath, "utf8").trim();
7274
+ const headPath = path7.join(gitDir, "HEAD");
7275
+ const head = fs6.readFileSync(headPath, "utf8").trim();
5887
7276
  if (!head.startsWith("ref:")) {
5888
7277
  return head;
5889
7278
  }
5890
7279
  const refPath = head.replace("ref:", "").trim();
5891
- const refFile = path6.join(gitDir, refPath);
5892
- if (fs5.existsSync(refFile)) {
5893
- return fs5.readFileSync(refFile, "utf8").trim();
7280
+ const refFile = path7.join(gitDir, refPath);
7281
+ if (fs6.existsSync(refFile)) {
7282
+ return fs6.readFileSync(refFile, "utf8").trim();
5894
7283
  }
5895
- const packedRefs = path6.join(gitDir, "packed-refs");
5896
- if (fs5.existsSync(packedRefs)) {
5897
- const content = fs5.readFileSync(packedRefs, "utf8");
7284
+ const packedRefs = path7.join(gitDir, "packed-refs");
7285
+ if (fs6.existsSync(packedRefs)) {
7286
+ const content = fs6.readFileSync(packedRefs, "utf8");
5898
7287
  for (const line of content.split("\n")) {
5899
7288
  if (!line || line.startsWith("#") || line.startsWith("^")) continue;
5900
7289
  const [sha, ref] = line.split(" ");
@@ -5909,19 +7298,19 @@ function readGitSha(cwd = process.cwd()) {
5909
7298
  function findGitDir(start) {
5910
7299
  let current = start;
5911
7300
  while (true) {
5912
- const candidate = path6.join(current, ".git");
5913
- if (fs5.existsSync(candidate)) {
5914
- const stat = fs5.statSync(candidate);
7301
+ const candidate = path7.join(current, ".git");
7302
+ if (fs6.existsSync(candidate)) {
7303
+ const stat = fs6.statSync(candidate);
5915
7304
  if (stat.isFile()) {
5916
- const content = fs5.readFileSync(candidate, "utf8").trim();
7305
+ const content = fs6.readFileSync(candidate, "utf8").trim();
5917
7306
  const match = content.match(/^gitdir: (.+)$/);
5918
7307
  if (match) {
5919
- return path6.resolve(current, match[1]);
7308
+ return path7.resolve(current, match[1]);
5920
7309
  }
5921
7310
  }
5922
7311
  return candidate;
5923
7312
  }
5924
- const parent = path6.dirname(current);
7313
+ const parent = path7.dirname(current);
5925
7314
  if (parent === current) return void 0;
5926
7315
  current = parent;
5927
7316
  }
@@ -5932,8 +7321,8 @@ function readBranchName(cwd = process.cwd()) {
5932
7321
  const gitDir = findGitDir(cwd);
5933
7322
  if (!gitDir) return void 0;
5934
7323
  try {
5935
- const headPath = path6.join(gitDir, "HEAD");
5936
- const head = fs5.readFileSync(headPath, "utf8").trim();
7324
+ const headPath = path7.join(gitDir, "HEAD");
7325
+ const head = fs6.readFileSync(headPath, "utf8").trim();
5937
7326
  if (head.startsWith("ref:")) {
5938
7327
  const refPath = head.replace("ref:", "").trim();
5939
7328
  const match = refPath.match(/^refs\/heads\/(.+)$/);
@@ -5951,8 +7340,8 @@ import { msToNanoseconds } from "executable-stories-core/utils/duration";
5951
7340
  import { nanosecondsToMs } from "executable-stories-core/utils/duration";
5952
7341
 
5953
7342
  // src/utils/metadata.ts
5954
- import * as fs6 from "fs";
5955
- import * as path7 from "path";
7343
+ import * as fs7 from "fs";
7344
+ import * as path8 from "path";
5956
7345
  var versionCache = /* @__PURE__ */ new Map();
5957
7346
  function readPackageVersion(root) {
5958
7347
  if (versionCache.has(root)) {
@@ -5963,18 +7352,18 @@ function readPackageVersion(root) {
5963
7352
  return version;
5964
7353
  }
5965
7354
  function findPackageVersion(startDir) {
5966
- let current = path7.resolve(startDir);
7355
+ let current = path8.resolve(startDir);
5967
7356
  while (true) {
5968
- const pkgPath = path7.join(current, "package.json");
7357
+ const pkgPath = path8.join(current, "package.json");
5969
7358
  try {
5970
- if (fs6.existsSync(pkgPath)) {
5971
- const raw = fs6.readFileSync(pkgPath, "utf8");
7359
+ if (fs7.existsSync(pkgPath)) {
7360
+ const raw = fs7.readFileSync(pkgPath, "utf8");
5972
7361
  const parsed = JSON.parse(raw);
5973
7362
  return parsed.version;
5974
7363
  }
5975
7364
  } catch {
5976
7365
  }
5977
- const parent = path7.dirname(current);
7366
+ const parent = path8.dirname(current);
5978
7367
  if (parent === current) {
5979
7368
  return void 0;
5980
7369
  }
@@ -6542,24 +7931,8 @@ function calculateFlakiness(args) {
6542
7931
  const countable = entries.filter(
6543
7932
  (e) => e.status === "passed" || e.status === "failed"
6544
7933
  );
6545
- if (countable.length < MIN_FLAKINESS_SAMPLES) {
6546
- return {
6547
- flakinessLevel: "stable",
6548
- flakinessScore: 0,
6549
- failureRate: 0,
6550
- longestPassStreak: countable.length,
6551
- longestFailStreak: 0
6552
- };
6553
- }
6554
- let transitions = 0;
6555
- for (let i = 1; i < countable.length; i++) {
6556
- if (countable[i].status !== countable[i - 1].status) {
6557
- transitions++;
6558
- }
6559
- }
6560
- const transitionScore = transitions / (countable.length - 1);
6561
7934
  const failures = countable.filter((e) => e.status === "failed").length;
6562
- const failureRate = failures / countable.length;
7935
+ const failureRate = countable.length > 0 ? failures / countable.length : 0;
6563
7936
  let longestPassStreak = 0;
6564
7937
  let longestFailStreak = 0;
6565
7938
  let currentPassStreak = 0;
@@ -6579,6 +7952,22 @@ function calculateFlakiness(args) {
6579
7952
  }
6580
7953
  }
6581
7954
  }
7955
+ if (countable.length < MIN_FLAKINESS_SAMPLES) {
7956
+ return {
7957
+ flakinessLevel: "stable",
7958
+ flakinessScore: 0,
7959
+ failureRate,
7960
+ longestPassStreak,
7961
+ longestFailStreak
7962
+ };
7963
+ }
7964
+ let transitions = 0;
7965
+ for (let i = 1; i < countable.length; i++) {
7966
+ if (countable[i].status !== countable[i - 1].status) {
7967
+ transitions++;
7968
+ }
7969
+ }
7970
+ const transitionScore = transitions / (countable.length - 1);
6582
7971
  let flakinessLevel;
6583
7972
  if (transitionScore > 0.5 || transitionScore > 0.3 && failureRate > 0.2) {
6584
7973
  flakinessLevel = "flaky";
@@ -7171,7 +8560,7 @@ function statusIcon2(status) {
7171
8560
  return "\u2022";
7172
8561
  }
7173
8562
  }
7174
- function escapeCell2(value) {
8563
+ function escapeCell3(value) {
7175
8564
  return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
7176
8565
  }
7177
8566
  function intentSummary(intent) {
@@ -7200,7 +8589,7 @@ function renderWeakBand(lines, files) {
7200
8589
  lines.push(`## \u{1F7E1} Changed code with weak evidence (${weak.length})`);
7201
8590
  lines.push("");
7202
8591
  for (const file of weak) {
7203
- const covered = file.claims.map((c) => `${escapeCell2(c.scenario)} (${c.strength})`).join(", ");
8592
+ const covered = file.claims.map((c) => `${escapeCell3(c.scenario)} (${c.strength})`).join(", ");
7204
8593
  lines.push(`- \`${file.path}\` _(${file.changeKind})_ \u2014 only: ${covered}`);
7205
8594
  }
7206
8595
  lines.push("");
@@ -7225,7 +8614,7 @@ function renderClaim(lines, claim) {
7225
8614
  );
7226
8615
  }
7227
8616
  if (claim.intent) {
7228
- lines.push(`- Why: ${escapeCell2(intentSummary(claim.intent))}`);
8617
+ lines.push(`- Why: ${escapeCell3(intentSummary(claim.intent))}`);
7229
8618
  }
7230
8619
  lines.push("");
7231
8620
  }
@@ -7282,7 +8671,7 @@ function renderCodeDiff(lines, evidence) {
7282
8671
  } else {
7283
8672
  for (const ref of annotation.scenarios) {
7284
8673
  lines.push(
7285
- 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)`
8674
+ 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)`
7286
8675
  );
7287
8676
  }
7288
8677
  }
@@ -7501,9 +8890,9 @@ function renderDiffHunk(file, hunk, anchoredStart, anchoredCount) {
7501
8890
  const sign = line.kind === "add" ? "+" : line.kind === "del" ? "-" : " ";
7502
8891
  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>`;
7503
8892
  });
7504
- const path10 = file.newPath ?? file.oldPath ?? "";
8893
+ const path11 = file.newPath ?? file.oldPath ?? "";
7505
8894
  return `<div class="diff-hunk">
7506
- <div class="diff-file-header"><code>${escapeHtml3(path10)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
8895
+ <div class="diff-file-header"><code>${escapeHtml3(path11)}</code> <span class="subtle">@@ -${hunk.oldStart} +${hunk.newStart} @@ ${escapeHtml3(hunk.header)}</span></div>
7507
8896
  <table class="diff-table"><tbody>${rows.join("")}</tbody></table>
7508
8897
  </div>`;
7509
8898
  }
@@ -7738,8 +9127,8 @@ applyTheme(getEffectiveTheme());` : "";
7738
9127
  };
7739
9128
 
7740
9129
  // src/deploy/ledger.ts
7741
- import * as fs7 from "fs";
7742
- import * as path8 from "path";
9130
+ import * as fs8 from "fs";
9131
+ import * as path9 from "path";
7743
9132
  function createEmptyLedger() {
7744
9133
  return {
7745
9134
  deployments: [],
@@ -7747,12 +9136,12 @@ function createEmptyLedger() {
7747
9136
  };
7748
9137
  }
7749
9138
  function loadLedger(ledgerPath) {
7750
- const resolved = path8.resolve(ledgerPath);
7751
- if (!fs7.existsSync(resolved)) {
9139
+ const resolved = path9.resolve(ledgerPath);
9140
+ if (!fs8.existsSync(resolved)) {
7752
9141
  return createEmptyLedger();
7753
9142
  }
7754
9143
  try {
7755
- const raw = JSON.parse(fs7.readFileSync(resolved, "utf8"));
9144
+ const raw = JSON.parse(fs8.readFileSync(resolved, "utf8"));
7756
9145
  if (raw.schemaVersion !== 1) {
7757
9146
  throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
7758
9147
  }
@@ -7763,10 +9152,10 @@ function loadLedger(ledgerPath) {
7763
9152
  }
7764
9153
  }
7765
9154
  function saveLedger(ledger, ledgerPath) {
7766
- const resolved = path8.resolve(ledgerPath);
7767
- const dir = path8.dirname(resolved);
7768
- fs7.mkdirSync(dir, { recursive: true });
7769
- fs7.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
9155
+ const resolved = path9.resolve(ledgerPath);
9156
+ const dir = path9.dirname(resolved);
9157
+ fs8.mkdirSync(dir, { recursive: true });
9158
+ fs8.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
7770
9159
  }
7771
9160
  function getLatestDeployment(ledger, environment) {
7772
9161
  return [...ledger.deployments].reverse().find((d) => d.environment === environment);
@@ -7893,11 +9282,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
7893
9282
  const ext = FORMAT_EXTENSIONS[format];
7894
9283
  const effectiveName = outputName + (outputNameSuffix ?? "");
7895
9284
  if (mode === "aggregated") {
7896
- return toPosix(path9.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
9285
+ return toPosix(path10.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
7897
9286
  }
7898
9287
  const normalizedSource = toPosix(sourceFile);
7899
- const dirOfSource = path9.posix.dirname(normalizedSource);
7900
- let baseName = path9.posix.basename(normalizedSource);
9288
+ const dirOfSource = path10.posix.dirname(normalizedSource);
9289
+ let baseName = path10.posix.basename(normalizedSource);
7901
9290
  for (const testExt of TEST_EXTENSIONS) {
7902
9291
  if (baseName.endsWith(testExt)) {
7903
9292
  baseName = baseName.slice(0, -testExt.length);
@@ -7906,12 +9295,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
7906
9295
  }
7907
9296
  const fileName = `${baseName}.${effectiveName}${ext}`;
7908
9297
  if (colocatedStyle === "adjacent") {
7909
- return toPosix(path9.posix.join(dirOfSource, fileName));
9298
+ return toPosix(path10.posix.join(dirOfSource, fileName));
7910
9299
  }
7911
9300
  if (colocatedStyle === "flat") {
7912
- return toPosix(path9.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
9301
+ return toPosix(path10.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
7913
9302
  }
7914
- return toPosix(path9.posix.join(baseOutputDir, dirOfSource, fileName));
9303
+ return toPosix(path10.posix.join(baseOutputDir, dirOfSource, fileName));
7915
9304
  }
7916
9305
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
7917
9306
  const groups = /* @__PURE__ */ new Map();
@@ -8131,8 +9520,8 @@ var ReportGenerator = class {
8131
9520
  if (astroPaths) {
8132
9521
  for (const mdPath of astroPaths) {
8133
9522
  const content = await fsPromises.readFile(mdPath, "utf8");
8134
- const mdDir = path9.dirname(mdPath);
8135
- const assetsDir = path9.resolve(this.options.astro.assetsDir);
9523
+ const mdDir = path10.dirname(mdPath);
9524
+ const assetsDir = path10.resolve(this.options.astro.assetsDir);
8136
9525
  const result = copyMarkdownAssets({
8137
9526
  markdown: content,
8138
9527
  markdownDir: mdDir,
@@ -8180,16 +9569,16 @@ var ReportGenerator = class {
8180
9569
  bySourceFile.set(sourceFile, outputPath);
8181
9570
  }
8182
9571
  if (bySourceFile.size === 0) return void 0;
8183
- const indexPath = toPosix(path9.join(this.options.outputDir, "index.html"));
9572
+ const indexPath = toPosix(path10.join(this.options.outputDir, "index.html"));
8184
9573
  if (htmlPaths.some((p) => toPosix(p) === indexPath)) {
8185
9574
  this.deps.logger.warn?.(
8186
9575
  `Skipping colocated index: a report already occupies ${indexPath}.`
8187
9576
  );
8188
9577
  return void 0;
8189
9578
  }
8190
- const entries = buildIndexEntries(run, bySourceFile, path9.dirname(indexPath));
9579
+ const entries = buildIndexEntries(run, bySourceFile, path10.dirname(indexPath));
8191
9580
  const html = renderColocatedIndex(entries, this.options.html.title);
8192
- await fsPromises.mkdir(path9.dirname(indexPath), { recursive: true });
9581
+ await fsPromises.mkdir(path10.dirname(indexPath), { recursive: true });
8193
9582
  await this.deps.writeFile(indexPath, html);
8194
9583
  return indexPath;
8195
9584
  }
@@ -8208,9 +9597,9 @@ var ReportGenerator = class {
8208
9597
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
8209
9598
  const ext = FORMAT_EXTENSIONS[format];
8210
9599
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
8211
- const outputPath = toPosix(path9.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
9600
+ const outputPath = toPosix(path10.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
8212
9601
  const content = await this.formatContent(run, format);
8213
- const dir = path9.dirname(outputPath);
9602
+ const dir = path10.dirname(outputPath);
8214
9603
  await fsPromises.mkdir(dir, { recursive: true });
8215
9604
  await this.deps.writeFile(outputPath, content);
8216
9605
  return [outputPath];
@@ -8222,7 +9611,7 @@ var ReportGenerator = class {
8222
9611
  testCases
8223
9612
  };
8224
9613
  const content = await this.formatContent(groupRun, format);
8225
- const dir = path9.dirname(outputPath);
9614
+ const dir = path10.dirname(outputPath);
8226
9615
  await fsPromises.mkdir(dir, { recursive: true });
8227
9616
  await this.deps.writeFile(outputPath, content);
8228
9617
  writtenPaths.push(outputPath);
@@ -8413,7 +9802,7 @@ async function generateRunComparison(args) {
8413
9802
  await fsPromises.mkdir(outputDir, { recursive: true });
8414
9803
  for (const format of args.formats) {
8415
9804
  const ext = format === "html" ? ".html" : format === "changelog" ? ".changelog.md" : ".md";
8416
- const outputPath = toPosix(path9.join(outputDir, `${outputName}${ext}`));
9805
+ const outputPath = toPosix(path10.join(outputDir, `${outputName}${ext}`));
8417
9806
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : format === "changelog" ? new RunDiffChangelogFormatter().format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
8418
9807
  await fsPromises.writeFile(outputPath, content, "utf8");
8419
9808
  files.push(outputPath);
@@ -8440,6 +9829,7 @@ export {
8440
9829
  CucumberHtmlFormatter,
8441
9830
  CucumberJsonFormatter,
8442
9831
  CucumberMessagesFormatter,
9832
+ DEFAULT_LOCKFILE_PATH,
8443
9833
  ES_THEME_TOKENS_CSS2 as ES_THEME_TOKENS_CSS,
8444
9834
  ES_THEME_TOKEN_VALUES,
8445
9835
  JUnitFormatter,
@@ -8447,6 +9837,7 @@ export {
8447
9837
  MIN_METRIC_SAMPLES,
8448
9838
  MIN_PERF_SAMPLES,
8449
9839
  MarkdownFormatter,
9840
+ PROVIDER_NAMES,
8450
9841
  ReleaseManifestFormatter,
8451
9842
  ReportGenerator,
8452
9843
  ReviewHtmlFormatter,
@@ -8465,11 +9856,15 @@ export {
8465
9856
  adaptPlaywrightRun,
8466
9857
  adaptVitestRun,
8467
9858
  advanceState,
9859
+ analyzeSync,
9860
+ applySync,
8468
9861
  assembleCodeDiff,
8469
9862
  assertValidRun,
8470
9863
  buildCheck,
9864
+ buildCoverageJson,
8471
9865
  buildGoal,
8472
9866
  buildHtmlDocEntry,
9867
+ buildProvider,
8473
9868
  buildReview,
8474
9869
  buildTriage,
8475
9870
  bundleAssets,
@@ -8479,11 +9874,14 @@ export {
8479
9874
  classifyStatusChange,
8480
9875
  clearVersionCache,
8481
9876
  codeDiffDiagnostics,
9877
+ collectAttachments,
8482
9878
  computeTestMetrics,
8483
9879
  copyMarkdownAssets,
8484
9880
  createAnchor,
8485
9881
  createPrCommentSummary,
8486
9882
  createReportGenerator,
9883
+ createTestRailProvider,
9884
+ createXrayProvider,
8487
9885
  deriveAudience,
8488
9886
  deriveChangeType,
8489
9887
  deriveStepResults,
@@ -8491,6 +9889,7 @@ export {
8491
9889
  detectPerformanceTrend,
8492
9890
  diffRuns,
8493
9891
  diffStoryReports,
9892
+ emptyLockfile,
8494
9893
  findGitDir,
8495
9894
  formatDuration4 as formatDuration,
8496
9895
  generateRunComparison,
@@ -8500,7 +9899,9 @@ export {
8500
9899
  getEnvironmentDrift,
8501
9900
  gradeEvidence,
8502
9901
  hasSufficientHistory,
9902
+ hashCaseBody,
8503
9903
  initialRunState,
9904
+ isProviderName,
8504
9905
  isReviewableSource,
8505
9906
  isTestFile,
8506
9907
  joinNameAndExt,
@@ -8515,19 +9916,26 @@ export {
8515
9916
  normalizeStatus,
8516
9917
  normalizeVitestResults,
8517
9918
  parseEnvelopes,
9919
+ parseLockfile,
8518
9920
  parseNdjson,
8519
9921
  parseUnifiedDiff,
9922
+ projectBehaviours,
8520
9923
  publishConfluencePage,
8521
9924
  publishJiraIssue,
8522
9925
  readBranchName,
8523
9926
  readGitSha,
9927
+ readLockfile,
8524
9928
  readPackageVersion,
8525
9929
  recordDeployment,
8526
9930
  regenerateArtifacts,
8527
9931
  regenerateRun,
8528
9932
  relocateAnchor,
9933
+ renderApplyResult,
8529
9934
  renderCheck,
9935
+ renderCoverageMarkdown,
9936
+ renderCoverageText,
8530
9937
  renderGoal,
9938
+ renderPlan,
8531
9939
  renderTriage,
8532
9940
  resolveAttachment,
8533
9941
  resolveAttachments,
@@ -8539,6 +9947,7 @@ export {
8539
9947
  sendSlackNotification,
8540
9948
  sendTeamsNotification,
8541
9949
  sendWebhookNotification,
9950
+ serializeLockfile,
8542
9951
  signBody,
8543
9952
  slugify2 as slugify,
8544
9953
  startWatch,
@@ -8546,6 +9955,7 @@ export {
8546
9955
  toAgentText,
8547
9956
  toBehaviorManifest,
8548
9957
  toCIInfo,
9958
+ toCaseBody,
8549
9959
  toRawCIInfo,
8550
9960
  toReleaseManifest,
8551
9961
  toScenarioIndex,
@@ -8553,6 +9963,7 @@ export {
8553
9963
  toTraceabilityMatrix,
8554
9964
  tryGetActiveOtelContext,
8555
9965
  updateHistory,
8556
- validateCanonicalRun
9966
+ validateCanonicalRun,
9967
+ writeLockfile
8557
9968
  };
8558
9969
  //# sourceMappingURL=index.js.map