executable-stories-formatters 0.10.0 → 0.11.1

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
@@ -44,6 +44,7 @@ __export(src_exports, {
44
44
  MIN_METRIC_SAMPLES: () => MIN_METRIC_SAMPLES,
45
45
  MIN_PERF_SAMPLES: () => MIN_PERF_SAMPLES,
46
46
  MarkdownFormatter: () => MarkdownFormatter,
47
+ ReleaseManifestFormatter: () => ReleaseManifestFormatter,
47
48
  ReportGenerator: () => ReportGenerator,
48
49
  ReviewHtmlFormatter: () => ReviewHtmlFormatter,
49
50
  ReviewMarkdownFormatter: () => ReviewMarkdownFormatter,
@@ -83,10 +84,13 @@ __export(src_exports, {
83
84
  generateTestCaseId: () => generateTestCaseId,
84
85
  getAvailableThemes: () => getAvailableThemes,
85
86
  getCssOnlyThemes: () => getCssOnlyThemes,
87
+ getDeploymentStatus: () => getDeploymentStatus,
88
+ getEnvironmentDrift: () => getEnvironmentDrift,
86
89
  gradeEvidence: () => gradeEvidence,
87
90
  hasSufficientHistory: () => hasSufficientHistory,
88
91
  isReviewableSource: () => isReviewableSource,
89
92
  isTestFile: () => isTestFile,
93
+ joinNameAndExt: () => joinNameAndExt,
90
94
  listScenarios: () => listScenarios,
91
95
  loadHistory: () => loadHistory,
92
96
  mergeStepResults: () => mergeStepResults,
@@ -103,6 +107,7 @@ __export(src_exports, {
103
107
  readBranchName: () => readBranchName,
104
108
  readGitSha: () => readGitSha,
105
109
  readPackageVersion: () => readPackageVersion,
110
+ recordDeployment: () => recordDeployment,
106
111
  regenerateArtifacts: () => regenerateArtifacts,
107
112
  resolveAttachment: () => resolveAttachment,
108
113
  resolveAttachments: () => resolveAttachments,
@@ -122,6 +127,7 @@ __export(src_exports, {
122
127
  toBehaviorManifest: () => toBehaviorManifest,
123
128
  toCIInfo: () => toCIInfo,
124
129
  toRawCIInfo: () => toRawCIInfo,
130
+ toReleaseManifest: () => toReleaseManifest,
125
131
  toScenarioIndex: () => toScenarioIndex,
126
132
  toStoryReport: () => toStoryReport,
127
133
  tryGetActiveOtelContext: () => tryGetActiveOtelContext,
@@ -129,8 +135,8 @@ __export(src_exports, {
129
135
  validateCanonicalRun: () => validateCanonicalRun
130
136
  });
131
137
  module.exports = __toCommonJS(src_exports);
132
- var fs9 = require("fs");
133
- var path10 = __toESM(require("path"), 1);
138
+ var fs10 = require("fs");
139
+ var path11 = __toESM(require("path"), 1);
134
140
  var fsPromises = __toESM(require("fs/promises"), 1);
135
141
 
136
142
  // src/converters/acl/status.ts
@@ -893,6 +899,15 @@ function copyDocEntry(entry) {
893
899
  phase: entry.phase,
894
900
  ...children
895
901
  };
902
+ case "video":
903
+ return {
904
+ kind: "video",
905
+ path: entry.path,
906
+ ...entry.caption ? { caption: entry.caption } : {},
907
+ ...entry.poster ? { poster: entry.poster } : {},
908
+ phase: entry.phase,
909
+ ...children
910
+ };
896
911
  case "custom":
897
912
  return {
898
913
  kind: "custom",
@@ -1010,23 +1025,23 @@ function buildFeature(relSourceFile, group) {
1010
1025
  function ensureUniqueFeatureIds(features) {
1011
1026
  const seen = /* @__PURE__ */ new Map();
1012
1027
  for (const f of features) {
1013
- const count = seen.get(f.id) ?? 0;
1014
- if (count > 0) f.id = `${f.id}-${count + 1}`;
1015
- seen.set(f.id, count + 1);
1028
+ const count2 = seen.get(f.id) ?? 0;
1029
+ if (count2 > 0) f.id = `${f.id}-${count2 + 1}`;
1030
+ seen.set(f.id, count2 + 1);
1016
1031
  }
1017
1032
  }
1018
1033
  function ensureUniqueScenarioIds(feature) {
1019
1034
  const seen = /* @__PURE__ */ new Map();
1020
1035
  for (const s of feature.scenarios) {
1021
- const count = seen.get(s.id) ?? 0;
1022
- if (count > 0) {
1023
- const newId = `${s.id}-${count + 1}`;
1036
+ const count2 = seen.get(s.id) ?? 0;
1037
+ if (count2 > 0) {
1038
+ const newId = `${s.id}-${count2 + 1}`;
1024
1039
  for (const step of s.steps) {
1025
1040
  step.id = step.id.replace(s.id, newId);
1026
1041
  }
1027
1042
  s.id = newId;
1028
1043
  }
1029
- seen.set(s.id, count + 1);
1044
+ seen.set(s.id, count2 + 1);
1030
1045
  }
1031
1046
  }
1032
1047
  function toStoryReport(run) {
@@ -14054,6 +14069,23 @@ function renderDocScreenshot(entry, deps) {
14054
14069
  ${entry.alt ? `<div class="doc-screenshot-caption">${deps.escapeHtml(entry.alt)}</div>` : ""}
14055
14070
  </div>`;
14056
14071
  }
14072
+ function renderDocVideo(entry, deps) {
14073
+ const isRemote = /^(?:https?:|data:)/i.test(entry.path);
14074
+ const isAbsoluteFsPath = !isRemote && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(entry.path);
14075
+ const captionHtml = entry.caption ? `<div class="doc-video-caption">${deps.escapeHtml(entry.caption)}</div>` : "";
14076
+ if ((deps.embedScreenshots ?? true) && isAbsoluteFsPath) {
14077
+ return `<div class="doc-video doc-video-missing">
14078
+ <div class="doc-video-missing-label">Video unavailable</div>
14079
+ <div class="doc-video-missing-path">${deps.escapeHtml(entry.path)}</div>
14080
+ ${captionHtml}
14081
+ </div>`;
14082
+ }
14083
+ const poster = entry.poster ? ` poster="${deps.escapeHtml(entry.poster)}"` : "";
14084
+ return `<div class="doc-video">
14085
+ <video class="doc-video-player" controls preload="metadata"${poster} src="${deps.escapeHtml(entry.path)}"></video>
14086
+ ${captionHtml}
14087
+ </div>`;
14088
+ }
14057
14089
  function renderDocCustom(entry, deps) {
14058
14090
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
14059
14091
  const data = entry.data;
@@ -14107,6 +14139,9 @@ function renderDocEntry(entry, deps) {
14107
14139
  case "screenshot":
14108
14140
  html = renderDocScreenshot(entry, deps);
14109
14141
  break;
14142
+ case "video":
14143
+ html = renderDocVideo(entry, deps);
14144
+ break;
14110
14145
  case "custom":
14111
14146
  html = renderDocCustom(entry, deps);
14112
14147
  break;
@@ -15433,6 +15468,19 @@ var MarkdownFormatter = class {
15433
15468
  case "screenshot":
15434
15469
  lines.push(`${indent}![${entry.alt ?? "Screenshot"}](${entry.path})`);
15435
15470
  break;
15471
+ case "video": {
15472
+ const poster = entry.poster ? ` poster="${entry.poster}"` : "";
15473
+ lines.push(`${indent}`);
15474
+ lines.push(`${indent}<video controls preload="metadata"${poster} class="doc-video">`);
15475
+ lines.push(`${indent} <source src="${entry.path}" />`);
15476
+ lines.push(`${indent}</video>`);
15477
+ if (entry.caption) {
15478
+ lines.push(`${indent}`);
15479
+ lines.push(`${indent}*${entry.caption}*`);
15480
+ }
15481
+ lines.push(`${indent}`);
15482
+ break;
15483
+ }
15436
15484
  case "custom":
15437
15485
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15438
15486
  const data = entry.data;
@@ -15525,6 +15573,88 @@ function groupBy6(items, keyFn) {
15525
15573
  return map;
15526
15574
  }
15527
15575
 
15576
+ // src/formatters/release-manifest.ts
15577
+ var import_node_crypto2 = require("crypto");
15578
+ var ReleaseManifestFormatter = class {
15579
+ format(run) {
15580
+ const manifest = toReleaseManifest(run);
15581
+ const lines = [];
15582
+ lines.push("# Release Manifest");
15583
+ lines.push("");
15584
+ lines.push(`Generated: ${manifest.generatedAt}`);
15585
+ lines.push(`Run: ${manifest.run.startedAt} to ${manifest.run.finishedAt}`);
15586
+ if (manifest.run.branch) lines.push(`Branch: ${manifest.run.branch}`);
15587
+ if (manifest.run.gitSha) lines.push(`Commit: ${manifest.run.gitSha}`);
15588
+ lines.push(`Tested-together hash: \`${manifest.testedTogetherHash}\``);
15589
+ lines.push("");
15590
+ lines.push("| Scenarios | Passed | Failed | Skipped | Pending |");
15591
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15592
+ lines.push(`| ${manifest.run.total} | ${manifest.run.passed} | ${manifest.run.failed} | ${manifest.run.skipped} | ${manifest.run.pending} |`);
15593
+ lines.push("");
15594
+ lines.push("## Scenarios");
15595
+ lines.push("");
15596
+ lines.push("| Status | Scenario | Source | Tags |");
15597
+ lines.push("| --- | --- | --- | --- |");
15598
+ for (const scenario of manifest.scenarios) {
15599
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15600
+ const tags = scenario.tags.length > 0 ? scenario.tags.map((tag) => `\`${tag}\``).join(", ") : "";
15601
+ lines.push(`| ${renderStatus(scenario.status)} | ${escapePipe(scenario.title)} | \`${source}\` | ${tags} |`);
15602
+ }
15603
+ return lines.join("\n");
15604
+ }
15605
+ };
15606
+ function toReleaseManifest(run) {
15607
+ const scenarios = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id)).map((tc) => ({
15608
+ id: tc.id,
15609
+ title: tc.story.scenario,
15610
+ status: tc.status,
15611
+ sourceFile: tc.sourceFile,
15612
+ sourceLine: tc.sourceLine,
15613
+ tags: tc.tags
15614
+ }));
15615
+ const fingerprint = scenarios.map((scenario) => `${scenario.id}:${scenario.status}`).join("\n");
15616
+ return {
15617
+ schemaVersion: "1.0",
15618
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15619
+ run: {
15620
+ startedAt: new Date(run.startedAtMs).toISOString(),
15621
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15622
+ gitSha: run.gitSha,
15623
+ branch: getBranch(run),
15624
+ total: run.testCases.length,
15625
+ passed: count(run.testCases, "passed"),
15626
+ failed: count(run.testCases, "failed"),
15627
+ skipped: count(run.testCases, "skipped"),
15628
+ pending: count(run.testCases, "pending")
15629
+ },
15630
+ testedTogetherHash: (0, import_node_crypto2.createHash)("sha256").update(fingerprint).digest("hex"),
15631
+ scenarios
15632
+ };
15633
+ }
15634
+ function getBranch(run) {
15635
+ return run.ci?.branch;
15636
+ }
15637
+ function count(testCases, status) {
15638
+ return testCases.filter((tc) => tc.status === status).length;
15639
+ }
15640
+ function renderStatus(status) {
15641
+ switch (status) {
15642
+ case "passed":
15643
+ return "passed";
15644
+ case "failed":
15645
+ return "failed";
15646
+ case "skipped":
15647
+ return "skipped";
15648
+ case "pending":
15649
+ return "pending";
15650
+ default:
15651
+ return status;
15652
+ }
15653
+ }
15654
+ function escapePipe(value) {
15655
+ return value.replace(/\|/g, "\\|");
15656
+ }
15657
+
15528
15658
  // src/formatters/cucumber-messages/synthesize-feature.ts
15529
15659
  function extractFeatureName(testCases, uri) {
15530
15660
  for (const tc of testCases) {
@@ -15591,7 +15721,7 @@ function synthesizeFeature(uri, testCases) {
15591
15721
  }
15592
15722
 
15593
15723
  // src/utils/cucumber-messages.ts
15594
- var import_node_crypto2 = require("crypto");
15724
+ var import_node_crypto3 = require("crypto");
15595
15725
  function msToTimestamp(ms) {
15596
15726
  const seconds = Math.floor(ms / 1e3);
15597
15727
  const nanos = Math.round(ms % 1e3 * 1e6);
@@ -15657,7 +15787,7 @@ function statusToCucumberStatus(status) {
15657
15787
  }
15658
15788
  function deterministicId(kind, salt, ...parts) {
15659
15789
  const input = [salt, kind, ...parts].join("::");
15660
- return (0, import_node_crypto2.createHash)("sha1").update(input).digest("hex").slice(0, 36);
15790
+ return (0, import_node_crypto3.createHash)("sha1").update(input).digest("hex").slice(0, 36);
15661
15791
  }
15662
15792
 
15663
15793
  // src/formatters/cucumber-messages/build-gherkin-document.ts
@@ -16145,8 +16275,8 @@ function extractDocAttachments(step) {
16145
16275
  }
16146
16276
  return attachments;
16147
16277
  }
16148
- function guessMediaType(path11) {
16149
- const lower = path11.toLowerCase();
16278
+ function guessMediaType(path12) {
16279
+ const lower = path12.toLowerCase();
16150
16280
  if (lower.endsWith(".png")) return "image/png";
16151
16281
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
16152
16282
  if (lower.endsWith(".gif")) return "image/gif";
@@ -16287,11 +16417,11 @@ var CucumberHtmlFormatter = class {
16287
16417
  for (const envelope of envelopes) {
16288
16418
  const accepted = htmlStream.write(envelope);
16289
16419
  if (!accepted) {
16290
- await new Promise((resolve8) => htmlStream.once("drain", resolve8));
16420
+ await new Promise((resolve9) => htmlStream.once("drain", resolve9));
16291
16421
  }
16292
16422
  }
16293
- await new Promise((resolve8, reject) => {
16294
- collector.on("finish", resolve8);
16423
+ await new Promise((resolve9, reject) => {
16424
+ collector.on("finish", resolve9);
16295
16425
  collector.on("error", reject);
16296
16426
  htmlStream.end();
16297
16427
  });
@@ -16592,6 +16722,8 @@ function formatDocEntry(doc) {
16592
16722
  return `${escapeHtml2(doc.title ?? "mermaid diagram")}: <code>${escapeHtml2(doc.code)}</code>`;
16593
16723
  case "screenshot":
16594
16724
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16725
+ case "video":
16726
+ return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16595
16727
  case "custom":
16596
16728
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16597
16729
  }
@@ -17048,6 +17180,8 @@ function formatDocEntry2(doc) {
17048
17180
  return `${doc.title ?? "mermaid diagram"}: \`${doc.code}\``;
17049
17181
  case "screenshot":
17050
17182
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17183
+ case "video":
17184
+ return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17051
17185
  case "custom":
17052
17186
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17053
17187
  }
@@ -17293,19 +17427,35 @@ function replaceAssetRef(html, original, replacement) {
17293
17427
  return html;
17294
17428
  }
17295
17429
 
17430
+ // src/utils/source-file.ts
17431
+ function cleanTestStem(fileName) {
17432
+ const base = fileName.split(/[\\/]/).pop() ?? fileName;
17433
+ const stripped = base.replace(/\.(story\.)?(test|spec|cy)\.[cm]?[jt]sx?$/i, "");
17434
+ if (stripped !== base) return stripped;
17435
+ return base.replace(/\.[^.]+$/, "");
17436
+ }
17437
+ function humanizeSourceFile(fileName) {
17438
+ return cleanTestStem(fileName).split(/[-_.\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
17439
+ }
17440
+
17296
17441
  // src/formatters/astro.ts
17297
17442
  var AstroFormatter = class _AstroFormatter {
17298
17443
  markdownFormatter;
17299
17444
  title;
17445
+ perFileTitle;
17300
17446
  constructor(options = {}) {
17301
17447
  this.title = options.markdown?.title ?? "User Stories";
17448
+ this.perFileTitle = options.perFileTitle ?? false;
17302
17449
  this.markdownFormatter = new MarkdownFormatter({
17303
17450
  ...options.markdown,
17304
17451
  title: this.title,
17305
17452
  stepStyle: "gherkin",
17306
17453
  includeFrontMatter: false,
17307
17454
  includeSummaryTable: false,
17308
- includeMetadata: false
17455
+ includeMetadata: false,
17456
+ // A per-file page is one file already — group by suite/describe so the
17457
+ // body shows clean section headings, not the redundant source path.
17458
+ groupBy: this.perFileTitle ? "suite" : options.markdown?.groupBy ?? "file"
17309
17459
  });
17310
17460
  }
17311
17461
  format(run) {
@@ -17315,13 +17465,31 @@ var AstroFormatter = class _AstroFormatter {
17315
17465
  return `${frontmatter}
17316
17466
  ${body}`;
17317
17467
  }
17468
+ /**
17469
+ * Title for the page. A per-file page (one source file — i.e. colocated mode)
17470
+ * is titled by its suite/describe name, falling back to a humanized filename,
17471
+ * so the docs nav reads "Convert Currency" not "User Stories" six times over.
17472
+ * Multi-file (aggregated) pages keep the configured title.
17473
+ */
17474
+ deriveTitle(run) {
17475
+ if (!this.perFileTitle) return this.title;
17476
+ const sourceFiles = new Set(
17477
+ run.testCases.map((tc) => tc.sourceFile).filter((f) => f && f !== "unknown")
17478
+ );
17479
+ if (sourceFiles.size !== 1) return this.title;
17480
+ const suites = new Set(
17481
+ run.testCases.map((tc) => tc.titlePath?.[0]).filter((s) => Boolean(s))
17482
+ );
17483
+ if (suites.size === 1) return [...suites][0];
17484
+ return humanizeSourceFile([...sourceFiles][0]) || this.title;
17485
+ }
17318
17486
  buildFrontmatter(run) {
17319
17487
  const badge = _AstroFormatter.computeBadge(run.testCases);
17320
- const count = run.testCases.length;
17321
- const description = `${count} scenario${count !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
17488
+ const count2 = run.testCases.length;
17489
+ const description = `${count2} scenario${count2 !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
17322
17490
  const lines = [
17323
17491
  "---",
17324
- `title: ${this.title}`,
17492
+ `title: ${yamlScalar(this.deriveTitle(run))}`,
17325
17493
  `description: ${description}`,
17326
17494
  "sidebar:",
17327
17495
  " badge:",
@@ -17339,6 +17507,12 @@ ${body}`;
17339
17507
  return { text: "Passed", variant: "success" };
17340
17508
  }
17341
17509
  };
17510
+ function yamlScalar(value) {
17511
+ if (/[:#[\]{}&*!|>'"%@`]|^[\s-]|\s$/.test(value)) {
17512
+ return `'${value.replace(/'/g, "''")}'`;
17513
+ }
17514
+ return value;
17515
+ }
17342
17516
 
17343
17517
  // src/formatters/confluence.ts
17344
17518
  var ConfluenceFormatter = class {
@@ -17615,6 +17789,15 @@ ${tc.errorStack}` : "");
17615
17789
  ])
17616
17790
  );
17617
17791
  break;
17792
+ case "video":
17793
+ content.push(
17794
+ paragraph([
17795
+ text(entry.caption ?? "Video", strong()),
17796
+ text(": "),
17797
+ link(entry.path, entry.path)
17798
+ ])
17799
+ );
17800
+ break;
17618
17801
  case "custom":
17619
17802
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17620
17803
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -17784,6 +17967,13 @@ function scanMarkdownAssets(markdown) {
17784
17967
  found.add(src);
17785
17968
  }
17786
17969
  }
17970
+ const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
17971
+ while ((match = posterRe.exec(stripped)) !== null) {
17972
+ const src = match[1].trim();
17973
+ if (isLocalPath(src)) {
17974
+ found.add(src);
17975
+ }
17976
+ }
17787
17977
  return Array.from(found);
17788
17978
  }
17789
17979
  function splitByCode(markdown) {
@@ -17834,6 +18024,19 @@ function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
17834
18024
  return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
17835
18025
  }
17836
18026
  );
18027
+ result = result.replace(
18028
+ /(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
18029
+ (full, pre, src, post) => {
18030
+ const trimmed = src.trim();
18031
+ if (!isLocalPath(trimmed)) return full;
18032
+ if (pathMap) {
18033
+ const mapped = pathMap.get(trimmed);
18034
+ if (mapped === void 0) return full;
18035
+ return `${pre}${assetsBaseUrl}/${mapped}${post}`;
18036
+ }
18037
+ return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
18038
+ }
18039
+ );
17837
18040
  return result;
17838
18041
  }
17839
18042
  function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
@@ -18281,14 +18484,14 @@ ${result.errors.join("\n")}`);
18281
18484
  }
18282
18485
 
18283
18486
  // src/coverage-index.ts
18284
- function normalizePath(path11) {
18285
- return path11.replace(/^\.\//, "");
18487
+ function normalizePath(path12) {
18488
+ return path12.replace(/^\.\//, "");
18286
18489
  }
18287
18490
  function scenariosCoveringPaths(index, paths) {
18288
18491
  const queries = paths.map(normalizePath);
18289
18492
  return index.scenarios.filter(
18290
18493
  (scenario) => scenario.covers.some(
18291
- (glob) => queries.some((path11) => matchesPattern(normalizePath(glob), path11))
18494
+ (glob) => queries.some((path12) => matchesPattern(normalizePath(glob), path12))
18292
18495
  )
18293
18496
  );
18294
18497
  }
@@ -19600,7 +19803,7 @@ async function sendTeamsNotification(args, deps) {
19600
19803
  }
19601
19804
 
19602
19805
  // src/notifiers/hmac.ts
19603
- var import_node_crypto3 = require("crypto");
19806
+ var import_node_crypto4 = require("crypto");
19604
19807
  function signBody(args) {
19605
19808
  let input;
19606
19809
  let timestamp;
@@ -19610,7 +19813,7 @@ function signBody(args) {
19610
19813
  } else {
19611
19814
  input = args.body;
19612
19815
  }
19613
- const hex = (0, import_node_crypto3.createHmac)("sha256", args.secret).update(input, "utf8").digest("hex");
19816
+ const hex = (0, import_node_crypto4.createHmac)("sha256", args.secret).update(input, "utf8").digest("hex");
19614
19817
  return {
19615
19818
  signature: `sha256=${hex}`,
19616
19819
  timestamp
@@ -20206,18 +20409,18 @@ function deriveChangeType(tags) {
20206
20409
  }
20207
20410
  return "unknown";
20208
20411
  }
20209
- function extensionOf(path11) {
20210
- const base = path11.split("/").pop() ?? path11;
20412
+ function extensionOf(path12) {
20413
+ const base = path12.split("/").pop() ?? path12;
20211
20414
  const dot = base.lastIndexOf(".");
20212
20415
  return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
20213
20416
  }
20214
- function isTestFile(path11) {
20215
- return TEST_INFIX.test(path11);
20417
+ function isTestFile(path12) {
20418
+ return TEST_INFIX.test(path12);
20216
20419
  }
20217
- function isReviewableSource(path11) {
20218
- if (isTestFile(path11)) return false;
20219
- if (path11.endsWith(".d.ts")) return false;
20220
- return CODE_EXTENSIONS.has(extensionOf(path11));
20420
+ function isReviewableSource(path12) {
20421
+ if (isTestFile(path12)) return false;
20422
+ if (path12.endsWith(".d.ts")) return false;
20423
+ return CODE_EXTENSIONS.has(extensionOf(path12));
20221
20424
  }
20222
20425
  function testBaseKey(testFile) {
20223
20426
  return testFile.replace(TEST_INFIX, "");
@@ -20321,7 +20524,7 @@ function toClaim(testCase, changedSourcePaths) {
20321
20524
  const { strength, reasons } = gradeEvidence(testCase, audience);
20322
20525
  const key = testBaseKey(testCase.sourceFile);
20323
20526
  const coversFiles = changedSourcePaths.filter(
20324
- (path11) => sourceBaseKey(path11) === key
20527
+ (path12) => sourceBaseKey(path12) === key
20325
20528
  );
20326
20529
  return {
20327
20530
  id: testCase.id,
@@ -20851,11 +21054,116 @@ applyTheme(getEffectiveTheme());` : "";
20851
21054
  }
20852
21055
  };
20853
21056
 
21057
+ // src/deploy/ledger.ts
21058
+ var fs9 = __toESM(require("fs"), 1);
21059
+ var path10 = __toESM(require("path"), 1);
21060
+ function createEmptyLedger() {
21061
+ return {
21062
+ deployments: [],
21063
+ schemaVersion: 1
21064
+ };
21065
+ }
21066
+ function loadLedger(ledgerPath) {
21067
+ const resolved = path10.resolve(ledgerPath);
21068
+ if (!fs9.existsSync(resolved)) {
21069
+ return createEmptyLedger();
21070
+ }
21071
+ try {
21072
+ const raw = JSON.parse(fs9.readFileSync(resolved, "utf8"));
21073
+ if (raw.schemaVersion !== 1) {
21074
+ throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
21075
+ }
21076
+ return raw;
21077
+ } catch (err) {
21078
+ const msg = err instanceof Error ? err.message : String(err);
21079
+ throw new Error(`Failed to load deployment ledger at ${resolved}: ${msg}`);
21080
+ }
21081
+ }
21082
+ function saveLedger(ledger, ledgerPath) {
21083
+ const resolved = path10.resolve(ledgerPath);
21084
+ const dir = path10.dirname(resolved);
21085
+ fs9.mkdirSync(dir, { recursive: true });
21086
+ fs9.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
21087
+ }
21088
+ function getLatestDeployment(ledger, environment) {
21089
+ return [...ledger.deployments].reverse().find((d) => d.environment === environment);
21090
+ }
21091
+
21092
+ // src/deploy/index.ts
21093
+ function recordDeployment(args) {
21094
+ const ledger = loadLedger(args.ledgerPath);
21095
+ const previous = getLatestDeployment(ledger, args.environment);
21096
+ const entry = {
21097
+ environment: args.environment,
21098
+ tag: args.tag,
21099
+ sha: args.run.gitSha,
21100
+ runFile: args.runFilePath,
21101
+ scenarioIds: args.run.testCases.map((tc) => tc.id),
21102
+ scenarioStatuses: Object.fromEntries(args.run.testCases.map((tc) => [tc.id, tc.status])),
21103
+ timestamp: new Date(args.run.finishedAtMs).toISOString(),
21104
+ summary: countStatuses(args.run)
21105
+ };
21106
+ ledger.deployments.push(entry);
21107
+ if (previous) {
21108
+ const previousIds = new Set(previous.scenarioIds);
21109
+ const added = entry.scenarioIds.filter((id) => !previousIds.has(id)).length;
21110
+ const removed = previous.scenarioIds.filter((id) => !entry.scenarioIds.includes(id)).length;
21111
+ if (added > 0 || removed > 0) {
21112
+ }
21113
+ }
21114
+ saveLedger(ledger, args.ledgerPath);
21115
+ return { entry, ledgerPath: args.ledgerPath };
21116
+ }
21117
+ function getDeploymentStatus(ledgerPath) {
21118
+ const ledger = loadLedger(ledgerPath);
21119
+ const environments = {};
21120
+ for (const entry of ledger.deployments) {
21121
+ environments[entry.environment] = {
21122
+ latest: entry,
21123
+ previousDeployment: environments[entry.environment]?.latest
21124
+ };
21125
+ }
21126
+ return { environments, ledgerPath };
21127
+ }
21128
+ function getEnvironmentDrift(ledgerPath, envA, envB) {
21129
+ const ledger = loadLedger(ledgerPath);
21130
+ const aEntry = getLatestDeployment(ledger, envA);
21131
+ const bEntry = getLatestDeployment(ledger, envB);
21132
+ if (!aEntry) {
21133
+ throw new Error(`No deployment found for environment "${envA}"`);
21134
+ }
21135
+ if (!bEntry) {
21136
+ throw new Error(`No deployment found for environment "${envB}"`);
21137
+ }
21138
+ const aIds = new Set(aEntry.scenarioIds);
21139
+ const bIds = new Set(bEntry.scenarioIds);
21140
+ const onlyInA = aEntry.scenarioIds.filter((id) => !bIds.has(id));
21141
+ const onlyInB = bEntry.scenarioIds.filter((id) => !aIds.has(id));
21142
+ const inBoth = aEntry.scenarioIds.filter((id) => bIds.has(id));
21143
+ const statusChanged = inBoth.map((id) => ({
21144
+ id,
21145
+ statusA: aEntry.scenarioStatuses?.[id] ?? "unknown",
21146
+ statusB: bEntry.scenarioStatuses?.[id] ?? "unknown"
21147
+ })).filter((item) => item.statusA !== item.statusB);
21148
+ return { environmentA: envA, environmentB: envB, onlyInA, onlyInB, inBoth, statusChanged, aEntry, bEntry };
21149
+ }
21150
+ function countStatuses(run) {
21151
+ const summary = { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
21152
+ for (const tc of run.testCases) {
21153
+ summary.total++;
21154
+ if (tc.status in summary) {
21155
+ summary[tc.status]++;
21156
+ }
21157
+ }
21158
+ return summary;
21159
+ }
21160
+
20854
21161
  // src/index.ts
20855
21162
  var FORMAT_EXTENSIONS = {
20856
21163
  astro: ".md",
20857
21164
  "behavior-manifest-json": ".behavior-manifest.json",
20858
21165
  markdown: ".md",
21166
+ "release-manifest": ".release-manifest.md",
20859
21167
  html: ".html",
20860
21168
  "cucumber-html": ".cucumber.html",
20861
21169
  junit: ".junit.xml",
@@ -20865,6 +21173,10 @@ var FORMAT_EXTENSIONS = {
20865
21173
  "scenario-index-json": ".scenarios-index.json",
20866
21174
  "story-report-json": ".story-report.json"
20867
21175
  };
21176
+ function joinNameAndExt(name, ext) {
21177
+ const stutter = `.${name}.`;
21178
+ return ext.startsWith(stutter) ? `${name}.${ext.slice(stutter.length)}` : `${name}${ext}`;
21179
+ }
20868
21180
  var TEST_EXTENSIONS = [
20869
21181
  ".test.ts",
20870
21182
  ".test.tsx",
@@ -20890,11 +21202,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20890
21202
  const ext = FORMAT_EXTENSIONS[format];
20891
21203
  const effectiveName = outputName + (outputNameSuffix ?? "");
20892
21204
  if (mode === "aggregated") {
20893
- return toPosix(path10.join(baseOutputDir, `${effectiveName}${ext}`));
21205
+ return toPosix(path11.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
20894
21206
  }
20895
21207
  const normalizedSource = toPosix(sourceFile);
20896
- const dirOfSource = path10.posix.dirname(normalizedSource);
20897
- let baseName = path10.posix.basename(normalizedSource);
21208
+ const dirOfSource = path11.posix.dirname(normalizedSource);
21209
+ let baseName = path11.posix.basename(normalizedSource);
20898
21210
  for (const testExt of TEST_EXTENSIONS) {
20899
21211
  if (baseName.endsWith(testExt)) {
20900
21212
  baseName = baseName.slice(0, -testExt.length);
@@ -20903,9 +21215,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20903
21215
  }
20904
21216
  const fileName = `${baseName}.${effectiveName}${ext}`;
20905
21217
  if (colocatedStyle === "adjacent") {
20906
- return toPosix(path10.posix.join(dirOfSource, fileName));
21218
+ return toPosix(path11.posix.join(dirOfSource, fileName));
20907
21219
  }
20908
- return toPosix(path10.posix.join(baseOutputDir, dirOfSource, fileName));
21220
+ if (colocatedStyle === "flat") {
21221
+ return toPosix(path11.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
21222
+ }
21223
+ return toPosix(path11.posix.join(baseOutputDir, dirOfSource, fileName));
20909
21224
  }
20910
21225
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
20911
21226
  const groups = /* @__PURE__ */ new Map();
@@ -21112,8 +21427,8 @@ var ReportGenerator = class {
21112
21427
  if (astroPaths) {
21113
21428
  for (const mdPath of astroPaths) {
21114
21429
  const content = await fsPromises.readFile(mdPath, "utf8");
21115
- const mdDir = path10.dirname(mdPath);
21116
- const assetsDir = path10.resolve(this.options.astro.assetsDir);
21430
+ const mdDir = path11.dirname(mdPath);
21431
+ const assetsDir = path11.resolve(this.options.astro.assetsDir);
21117
21432
  const result = copyMarkdownAssets({
21118
21433
  markdown: content,
21119
21434
  markdownDir: mdDir,
@@ -21144,9 +21459,9 @@ var ReportGenerator = class {
21144
21459
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
21145
21460
  const ext = FORMAT_EXTENSIONS[format];
21146
21461
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
21147
- const outputPath = toPosix(path10.join(this.options.outputDir, `${effectiveName}${ext}`));
21462
+ const outputPath = toPosix(path11.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
21148
21463
  const content = await this.formatContent(run, format);
21149
- const dir = path10.dirname(outputPath);
21464
+ const dir = path11.dirname(outputPath);
21150
21465
  await fsPromises.mkdir(dir, { recursive: true });
21151
21466
  await this.deps.writeFile(outputPath, content);
21152
21467
  return [outputPath];
@@ -21158,7 +21473,7 @@ var ReportGenerator = class {
21158
21473
  testCases
21159
21474
  };
21160
21475
  const content = await this.formatContent(groupRun, format);
21161
- const dir = path10.dirname(outputPath);
21476
+ const dir = path11.dirname(outputPath);
21162
21477
  await fsPromises.mkdir(dir, { recursive: true });
21163
21478
  await this.deps.writeFile(outputPath, content);
21164
21479
  writtenPaths.push(outputPath);
@@ -21224,6 +21539,8 @@ var ReportGenerator = class {
21224
21539
  case "astro": {
21225
21540
  const formatter = new AstroFormatter({
21226
21541
  assetsBaseUrl: this.options.astro.assetsBaseUrl,
21542
+ // Colocated = one page per file, so title each by its own suite/file.
21543
+ perFileTitle: this.options.output.mode === "colocated",
21227
21544
  markdown: this.options.astro.markdown
21228
21545
  });
21229
21546
  return formatter.format(run);
@@ -21265,6 +21582,10 @@ var ReportGenerator = class {
21265
21582
  });
21266
21583
  return formatter.format(run);
21267
21584
  }
21585
+ case "release-manifest": {
21586
+ const formatter = new ReleaseManifestFormatter();
21587
+ return formatter.format(run);
21588
+ }
21268
21589
  case "story-report-json": {
21269
21590
  const formatter = new StoryReportJsonFormatter({
21270
21591
  pretty: this.options.storyReportJson.pretty
@@ -21299,7 +21620,7 @@ async function generateRunComparison(args) {
21299
21620
  await fsPromises.mkdir(outputDir, { recursive: true });
21300
21621
  for (const format of args.formats) {
21301
21622
  const ext = format === "html" ? ".html" : ".md";
21302
- const outputPath = toPosix(path10.join(outputDir, `${outputName}${ext}`));
21623
+ const outputPath = toPosix(path11.join(outputDir, `${outputName}${ext}`));
21303
21624
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
21304
21625
  await fsPromises.writeFile(outputPath, content, "utf8");
21305
21626
  files.push(outputPath);
@@ -21334,6 +21655,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21334
21655
  MIN_METRIC_SAMPLES,
21335
21656
  MIN_PERF_SAMPLES,
21336
21657
  MarkdownFormatter,
21658
+ ReleaseManifestFormatter,
21337
21659
  ReportGenerator,
21338
21660
  ReviewHtmlFormatter,
21339
21661
  ReviewMarkdownFormatter,
@@ -21373,10 +21695,13 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21373
21695
  generateTestCaseId,
21374
21696
  getAvailableThemes,
21375
21697
  getCssOnlyThemes,
21698
+ getDeploymentStatus,
21699
+ getEnvironmentDrift,
21376
21700
  gradeEvidence,
21377
21701
  hasSufficientHistory,
21378
21702
  isReviewableSource,
21379
21703
  isTestFile,
21704
+ joinNameAndExt,
21380
21705
  listScenarios,
21381
21706
  loadHistory,
21382
21707
  mergeStepResults,
@@ -21393,6 +21718,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21393
21718
  readBranchName,
21394
21719
  readGitSha,
21395
21720
  readPackageVersion,
21721
+ recordDeployment,
21396
21722
  regenerateArtifacts,
21397
21723
  resolveAttachment,
21398
21724
  resolveAttachments,
@@ -21412,6 +21738,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21412
21738
  toBehaviorManifest,
21413
21739
  toCIInfo,
21414
21740
  toRawCIInfo,
21741
+ toReleaseManifest,
21415
21742
  toScenarioIndex,
21416
21743
  toStoryReport,
21417
21744
  tryGetActiveOtelContext,