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.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // src/index.ts
2
2
  import "fs";
3
- import * as path10 from "path";
3
+ import * as path11 from "path";
4
4
  import * as fsPromises from "fs/promises";
5
5
 
6
6
  // src/converters/acl/status.ts
@@ -763,6 +763,15 @@ function copyDocEntry(entry) {
763
763
  phase: entry.phase,
764
764
  ...children
765
765
  };
766
+ case "video":
767
+ return {
768
+ kind: "video",
769
+ path: entry.path,
770
+ ...entry.caption ? { caption: entry.caption } : {},
771
+ ...entry.poster ? { poster: entry.poster } : {},
772
+ phase: entry.phase,
773
+ ...children
774
+ };
766
775
  case "custom":
767
776
  return {
768
777
  kind: "custom",
@@ -880,23 +889,23 @@ function buildFeature(relSourceFile, group) {
880
889
  function ensureUniqueFeatureIds(features) {
881
890
  const seen = /* @__PURE__ */ new Map();
882
891
  for (const f of features) {
883
- const count = seen.get(f.id) ?? 0;
884
- if (count > 0) f.id = `${f.id}-${count + 1}`;
885
- seen.set(f.id, count + 1);
892
+ const count2 = seen.get(f.id) ?? 0;
893
+ if (count2 > 0) f.id = `${f.id}-${count2 + 1}`;
894
+ seen.set(f.id, count2 + 1);
886
895
  }
887
896
  }
888
897
  function ensureUniqueScenarioIds(feature) {
889
898
  const seen = /* @__PURE__ */ new Map();
890
899
  for (const s of feature.scenarios) {
891
- const count = seen.get(s.id) ?? 0;
892
- if (count > 0) {
893
- const newId = `${s.id}-${count + 1}`;
900
+ const count2 = seen.get(s.id) ?? 0;
901
+ if (count2 > 0) {
902
+ const newId = `${s.id}-${count2 + 1}`;
894
903
  for (const step of s.steps) {
895
904
  step.id = step.id.replace(s.id, newId);
896
905
  }
897
906
  s.id = newId;
898
907
  }
899
- seen.set(s.id, count + 1);
908
+ seen.set(s.id, count2 + 1);
900
909
  }
901
910
  }
902
911
  function toStoryReport(run) {
@@ -13924,6 +13933,23 @@ function renderDocScreenshot(entry, deps) {
13924
13933
  ${entry.alt ? `<div class="doc-screenshot-caption">${deps.escapeHtml(entry.alt)}</div>` : ""}
13925
13934
  </div>`;
13926
13935
  }
13936
+ function renderDocVideo(entry, deps) {
13937
+ const isRemote = /^(?:https?:|data:)/i.test(entry.path);
13938
+ const isAbsoluteFsPath = !isRemote && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(entry.path);
13939
+ const captionHtml = entry.caption ? `<div class="doc-video-caption">${deps.escapeHtml(entry.caption)}</div>` : "";
13940
+ if ((deps.embedScreenshots ?? true) && isAbsoluteFsPath) {
13941
+ return `<div class="doc-video doc-video-missing">
13942
+ <div class="doc-video-missing-label">Video unavailable</div>
13943
+ <div class="doc-video-missing-path">${deps.escapeHtml(entry.path)}</div>
13944
+ ${captionHtml}
13945
+ </div>`;
13946
+ }
13947
+ const poster = entry.poster ? ` poster="${deps.escapeHtml(entry.poster)}"` : "";
13948
+ return `<div class="doc-video">
13949
+ <video class="doc-video-player" controls preload="metadata"${poster} src="${deps.escapeHtml(entry.path)}"></video>
13950
+ ${captionHtml}
13951
+ </div>`;
13952
+ }
13927
13953
  function renderDocCustom(entry, deps) {
13928
13954
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
13929
13955
  const data = entry.data;
@@ -13977,6 +14003,9 @@ function renderDocEntry(entry, deps) {
13977
14003
  case "screenshot":
13978
14004
  html = renderDocScreenshot(entry, deps);
13979
14005
  break;
14006
+ case "video":
14007
+ html = renderDocVideo(entry, deps);
14008
+ break;
13980
14009
  case "custom":
13981
14010
  html = renderDocCustom(entry, deps);
13982
14011
  break;
@@ -15303,6 +15332,19 @@ var MarkdownFormatter = class {
15303
15332
  case "screenshot":
15304
15333
  lines.push(`${indent}![${entry.alt ?? "Screenshot"}](${entry.path})`);
15305
15334
  break;
15335
+ case "video": {
15336
+ const poster = entry.poster ? ` poster="${entry.poster}"` : "";
15337
+ lines.push(`${indent}`);
15338
+ lines.push(`${indent}<video controls preload="metadata"${poster} class="doc-video">`);
15339
+ lines.push(`${indent} <source src="${entry.path}" />`);
15340
+ lines.push(`${indent}</video>`);
15341
+ if (entry.caption) {
15342
+ lines.push(`${indent}`);
15343
+ lines.push(`${indent}*${entry.caption}*`);
15344
+ }
15345
+ lines.push(`${indent}`);
15346
+ break;
15347
+ }
15306
15348
  case "custom":
15307
15349
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15308
15350
  const data = entry.data;
@@ -15395,6 +15437,88 @@ function groupBy6(items, keyFn) {
15395
15437
  return map;
15396
15438
  }
15397
15439
 
15440
+ // src/formatters/release-manifest.ts
15441
+ import { createHash as createHash2 } from "crypto";
15442
+ var ReleaseManifestFormatter = class {
15443
+ format(run) {
15444
+ const manifest = toReleaseManifest(run);
15445
+ const lines = [];
15446
+ lines.push("# Release Manifest");
15447
+ lines.push("");
15448
+ lines.push(`Generated: ${manifest.generatedAt}`);
15449
+ lines.push(`Run: ${manifest.run.startedAt} to ${manifest.run.finishedAt}`);
15450
+ if (manifest.run.branch) lines.push(`Branch: ${manifest.run.branch}`);
15451
+ if (manifest.run.gitSha) lines.push(`Commit: ${manifest.run.gitSha}`);
15452
+ lines.push(`Tested-together hash: \`${manifest.testedTogetherHash}\``);
15453
+ lines.push("");
15454
+ lines.push("| Scenarios | Passed | Failed | Skipped | Pending |");
15455
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15456
+ lines.push(`| ${manifest.run.total} | ${manifest.run.passed} | ${manifest.run.failed} | ${manifest.run.skipped} | ${manifest.run.pending} |`);
15457
+ lines.push("");
15458
+ lines.push("## Scenarios");
15459
+ lines.push("");
15460
+ lines.push("| Status | Scenario | Source | Tags |");
15461
+ lines.push("| --- | --- | --- | --- |");
15462
+ for (const scenario of manifest.scenarios) {
15463
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15464
+ const tags = scenario.tags.length > 0 ? scenario.tags.map((tag) => `\`${tag}\``).join(", ") : "";
15465
+ lines.push(`| ${renderStatus(scenario.status)} | ${escapePipe(scenario.title)} | \`${source}\` | ${tags} |`);
15466
+ }
15467
+ return lines.join("\n");
15468
+ }
15469
+ };
15470
+ function toReleaseManifest(run) {
15471
+ const scenarios = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id)).map((tc) => ({
15472
+ id: tc.id,
15473
+ title: tc.story.scenario,
15474
+ status: tc.status,
15475
+ sourceFile: tc.sourceFile,
15476
+ sourceLine: tc.sourceLine,
15477
+ tags: tc.tags
15478
+ }));
15479
+ const fingerprint = scenarios.map((scenario) => `${scenario.id}:${scenario.status}`).join("\n");
15480
+ return {
15481
+ schemaVersion: "1.0",
15482
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15483
+ run: {
15484
+ startedAt: new Date(run.startedAtMs).toISOString(),
15485
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15486
+ gitSha: run.gitSha,
15487
+ branch: getBranch(run),
15488
+ total: run.testCases.length,
15489
+ passed: count(run.testCases, "passed"),
15490
+ failed: count(run.testCases, "failed"),
15491
+ skipped: count(run.testCases, "skipped"),
15492
+ pending: count(run.testCases, "pending")
15493
+ },
15494
+ testedTogetherHash: createHash2("sha256").update(fingerprint).digest("hex"),
15495
+ scenarios
15496
+ };
15497
+ }
15498
+ function getBranch(run) {
15499
+ return run.ci?.branch;
15500
+ }
15501
+ function count(testCases, status) {
15502
+ return testCases.filter((tc) => tc.status === status).length;
15503
+ }
15504
+ function renderStatus(status) {
15505
+ switch (status) {
15506
+ case "passed":
15507
+ return "passed";
15508
+ case "failed":
15509
+ return "failed";
15510
+ case "skipped":
15511
+ return "skipped";
15512
+ case "pending":
15513
+ return "pending";
15514
+ default:
15515
+ return status;
15516
+ }
15517
+ }
15518
+ function escapePipe(value) {
15519
+ return value.replace(/\|/g, "\\|");
15520
+ }
15521
+
15398
15522
  // src/formatters/cucumber-messages/synthesize-feature.ts
15399
15523
  function extractFeatureName(testCases, uri) {
15400
15524
  for (const tc of testCases) {
@@ -15461,7 +15585,7 @@ function synthesizeFeature(uri, testCases) {
15461
15585
  }
15462
15586
 
15463
15587
  // src/utils/cucumber-messages.ts
15464
- import { createHash as createHash2 } from "crypto";
15588
+ import { createHash as createHash3 } from "crypto";
15465
15589
  function msToTimestamp(ms) {
15466
15590
  const seconds = Math.floor(ms / 1e3);
15467
15591
  const nanos = Math.round(ms % 1e3 * 1e6);
@@ -15527,7 +15651,7 @@ function statusToCucumberStatus(status) {
15527
15651
  }
15528
15652
  function deterministicId(kind, salt, ...parts) {
15529
15653
  const input = [salt, kind, ...parts].join("::");
15530
- return createHash2("sha1").update(input).digest("hex").slice(0, 36);
15654
+ return createHash3("sha1").update(input).digest("hex").slice(0, 36);
15531
15655
  }
15532
15656
 
15533
15657
  // src/formatters/cucumber-messages/build-gherkin-document.ts
@@ -16015,8 +16139,8 @@ function extractDocAttachments(step) {
16015
16139
  }
16016
16140
  return attachments;
16017
16141
  }
16018
- function guessMediaType(path11) {
16019
- const lower = path11.toLowerCase();
16142
+ function guessMediaType(path12) {
16143
+ const lower = path12.toLowerCase();
16020
16144
  if (lower.endsWith(".png")) return "image/png";
16021
16145
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
16022
16146
  if (lower.endsWith(".gif")) return "image/gif";
@@ -16157,11 +16281,11 @@ var CucumberHtmlFormatter = class {
16157
16281
  for (const envelope of envelopes) {
16158
16282
  const accepted = htmlStream.write(envelope);
16159
16283
  if (!accepted) {
16160
- await new Promise((resolve8) => htmlStream.once("drain", resolve8));
16284
+ await new Promise((resolve9) => htmlStream.once("drain", resolve9));
16161
16285
  }
16162
16286
  }
16163
- await new Promise((resolve8, reject) => {
16164
- collector.on("finish", resolve8);
16287
+ await new Promise((resolve9, reject) => {
16288
+ collector.on("finish", resolve9);
16165
16289
  collector.on("error", reject);
16166
16290
  htmlStream.end();
16167
16291
  });
@@ -16462,6 +16586,8 @@ function formatDocEntry(doc) {
16462
16586
  return `${escapeHtml2(doc.title ?? "mermaid diagram")}: <code>${escapeHtml2(doc.code)}</code>`;
16463
16587
  case "screenshot":
16464
16588
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16589
+ case "video":
16590
+ return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16465
16591
  case "custom":
16466
16592
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16467
16593
  }
@@ -16918,6 +17044,8 @@ function formatDocEntry2(doc) {
16918
17044
  return `${doc.title ?? "mermaid diagram"}: \`${doc.code}\``;
16919
17045
  case "screenshot":
16920
17046
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17047
+ case "video":
17048
+ return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
16921
17049
  case "custom":
16922
17050
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
16923
17051
  }
@@ -17163,19 +17291,35 @@ function replaceAssetRef(html, original, replacement) {
17163
17291
  return html;
17164
17292
  }
17165
17293
 
17294
+ // src/utils/source-file.ts
17295
+ function cleanTestStem(fileName) {
17296
+ const base = fileName.split(/[\\/]/).pop() ?? fileName;
17297
+ const stripped = base.replace(/\.(story\.)?(test|spec|cy)\.[cm]?[jt]sx?$/i, "");
17298
+ if (stripped !== base) return stripped;
17299
+ return base.replace(/\.[^.]+$/, "");
17300
+ }
17301
+ function humanizeSourceFile(fileName) {
17302
+ return cleanTestStem(fileName).split(/[-_.\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
17303
+ }
17304
+
17166
17305
  // src/formatters/astro.ts
17167
17306
  var AstroFormatter = class _AstroFormatter {
17168
17307
  markdownFormatter;
17169
17308
  title;
17309
+ perFileTitle;
17170
17310
  constructor(options = {}) {
17171
17311
  this.title = options.markdown?.title ?? "User Stories";
17312
+ this.perFileTitle = options.perFileTitle ?? false;
17172
17313
  this.markdownFormatter = new MarkdownFormatter({
17173
17314
  ...options.markdown,
17174
17315
  title: this.title,
17175
17316
  stepStyle: "gherkin",
17176
17317
  includeFrontMatter: false,
17177
17318
  includeSummaryTable: false,
17178
- includeMetadata: false
17319
+ includeMetadata: false,
17320
+ // A per-file page is one file already — group by suite/describe so the
17321
+ // body shows clean section headings, not the redundant source path.
17322
+ groupBy: this.perFileTitle ? "suite" : options.markdown?.groupBy ?? "file"
17179
17323
  });
17180
17324
  }
17181
17325
  format(run) {
@@ -17185,13 +17329,31 @@ var AstroFormatter = class _AstroFormatter {
17185
17329
  return `${frontmatter}
17186
17330
  ${body}`;
17187
17331
  }
17332
+ /**
17333
+ * Title for the page. A per-file page (one source file — i.e. colocated mode)
17334
+ * is titled by its suite/describe name, falling back to a humanized filename,
17335
+ * so the docs nav reads "Convert Currency" not "User Stories" six times over.
17336
+ * Multi-file (aggregated) pages keep the configured title.
17337
+ */
17338
+ deriveTitle(run) {
17339
+ if (!this.perFileTitle) return this.title;
17340
+ const sourceFiles = new Set(
17341
+ run.testCases.map((tc) => tc.sourceFile).filter((f) => f && f !== "unknown")
17342
+ );
17343
+ if (sourceFiles.size !== 1) return this.title;
17344
+ const suites = new Set(
17345
+ run.testCases.map((tc) => tc.titlePath?.[0]).filter((s) => Boolean(s))
17346
+ );
17347
+ if (suites.size === 1) return [...suites][0];
17348
+ return humanizeSourceFile([...sourceFiles][0]) || this.title;
17349
+ }
17188
17350
  buildFrontmatter(run) {
17189
17351
  const badge = _AstroFormatter.computeBadge(run.testCases);
17190
- const count = run.testCases.length;
17191
- const description = `${count} scenario${count !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
17352
+ const count2 = run.testCases.length;
17353
+ const description = `${count2} scenario${count2 !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
17192
17354
  const lines = [
17193
17355
  "---",
17194
- `title: ${this.title}`,
17356
+ `title: ${yamlScalar(this.deriveTitle(run))}`,
17195
17357
  `description: ${description}`,
17196
17358
  "sidebar:",
17197
17359
  " badge:",
@@ -17209,6 +17371,12 @@ ${body}`;
17209
17371
  return { text: "Passed", variant: "success" };
17210
17372
  }
17211
17373
  };
17374
+ function yamlScalar(value) {
17375
+ if (/[:#[\]{}&*!|>'"%@`]|^[\s-]|\s$/.test(value)) {
17376
+ return `'${value.replace(/'/g, "''")}'`;
17377
+ }
17378
+ return value;
17379
+ }
17212
17380
 
17213
17381
  // src/formatters/confluence.ts
17214
17382
  var ConfluenceFormatter = class {
@@ -17485,6 +17653,15 @@ ${tc.errorStack}` : "");
17485
17653
  ])
17486
17654
  );
17487
17655
  break;
17656
+ case "video":
17657
+ content.push(
17658
+ paragraph([
17659
+ text(entry.caption ?? "Video", strong()),
17660
+ text(": "),
17661
+ link(entry.path, entry.path)
17662
+ ])
17663
+ );
17664
+ break;
17488
17665
  case "custom":
17489
17666
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17490
17667
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -17654,6 +17831,13 @@ function scanMarkdownAssets(markdown) {
17654
17831
  found.add(src);
17655
17832
  }
17656
17833
  }
17834
+ const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
17835
+ while ((match = posterRe.exec(stripped)) !== null) {
17836
+ const src = match[1].trim();
17837
+ if (isLocalPath(src)) {
17838
+ found.add(src);
17839
+ }
17840
+ }
17657
17841
  return Array.from(found);
17658
17842
  }
17659
17843
  function splitByCode(markdown) {
@@ -17704,6 +17888,19 @@ function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
17704
17888
  return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
17705
17889
  }
17706
17890
  );
17891
+ result = result.replace(
17892
+ /(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
17893
+ (full, pre, src, post) => {
17894
+ const trimmed = src.trim();
17895
+ if (!isLocalPath(trimmed)) return full;
17896
+ if (pathMap) {
17897
+ const mapped = pathMap.get(trimmed);
17898
+ if (mapped === void 0) return full;
17899
+ return `${pre}${assetsBaseUrl}/${mapped}${post}`;
17900
+ }
17901
+ return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
17902
+ }
17903
+ );
17707
17904
  return result;
17708
17905
  }
17709
17906
  function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
@@ -18151,14 +18348,14 @@ ${result.errors.join("\n")}`);
18151
18348
  }
18152
18349
 
18153
18350
  // src/coverage-index.ts
18154
- function normalizePath(path11) {
18155
- return path11.replace(/^\.\//, "");
18351
+ function normalizePath(path12) {
18352
+ return path12.replace(/^\.\//, "");
18156
18353
  }
18157
18354
  function scenariosCoveringPaths(index, paths) {
18158
18355
  const queries = paths.map(normalizePath);
18159
18356
  return index.scenarios.filter(
18160
18357
  (scenario) => scenario.covers.some(
18161
- (glob) => queries.some((path11) => matchesPattern(normalizePath(glob), path11))
18358
+ (glob) => queries.some((path12) => matchesPattern(normalizePath(glob), path12))
18162
18359
  )
18163
18360
  );
18164
18361
  }
@@ -20075,18 +20272,18 @@ function deriveChangeType(tags) {
20075
20272
  }
20076
20273
  return "unknown";
20077
20274
  }
20078
- function extensionOf(path11) {
20079
- const base = path11.split("/").pop() ?? path11;
20275
+ function extensionOf(path12) {
20276
+ const base = path12.split("/").pop() ?? path12;
20080
20277
  const dot = base.lastIndexOf(".");
20081
20278
  return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
20082
20279
  }
20083
- function isTestFile(path11) {
20084
- return TEST_INFIX.test(path11);
20280
+ function isTestFile(path12) {
20281
+ return TEST_INFIX.test(path12);
20085
20282
  }
20086
- function isReviewableSource(path11) {
20087
- if (isTestFile(path11)) return false;
20088
- if (path11.endsWith(".d.ts")) return false;
20089
- return CODE_EXTENSIONS.has(extensionOf(path11));
20283
+ function isReviewableSource(path12) {
20284
+ if (isTestFile(path12)) return false;
20285
+ if (path12.endsWith(".d.ts")) return false;
20286
+ return CODE_EXTENSIONS.has(extensionOf(path12));
20090
20287
  }
20091
20288
  function testBaseKey(testFile) {
20092
20289
  return testFile.replace(TEST_INFIX, "");
@@ -20190,7 +20387,7 @@ function toClaim(testCase, changedSourcePaths) {
20190
20387
  const { strength, reasons } = gradeEvidence(testCase, audience);
20191
20388
  const key = testBaseKey(testCase.sourceFile);
20192
20389
  const coversFiles = changedSourcePaths.filter(
20193
- (path11) => sourceBaseKey(path11) === key
20390
+ (path12) => sourceBaseKey(path12) === key
20194
20391
  );
20195
20392
  return {
20196
20393
  id: testCase.id,
@@ -20720,11 +20917,116 @@ applyTheme(getEffectiveTheme());` : "";
20720
20917
  }
20721
20918
  };
20722
20919
 
20920
+ // src/deploy/ledger.ts
20921
+ import * as fs9 from "fs";
20922
+ import * as path10 from "path";
20923
+ function createEmptyLedger() {
20924
+ return {
20925
+ deployments: [],
20926
+ schemaVersion: 1
20927
+ };
20928
+ }
20929
+ function loadLedger(ledgerPath) {
20930
+ const resolved = path10.resolve(ledgerPath);
20931
+ if (!fs9.existsSync(resolved)) {
20932
+ return createEmptyLedger();
20933
+ }
20934
+ try {
20935
+ const raw = JSON.parse(fs9.readFileSync(resolved, "utf8"));
20936
+ if (raw.schemaVersion !== 1) {
20937
+ throw new Error(`Unsupported ledger schemaVersion: ${raw.schemaVersion}`);
20938
+ }
20939
+ return raw;
20940
+ } catch (err) {
20941
+ const msg = err instanceof Error ? err.message : String(err);
20942
+ throw new Error(`Failed to load deployment ledger at ${resolved}: ${msg}`);
20943
+ }
20944
+ }
20945
+ function saveLedger(ledger, ledgerPath) {
20946
+ const resolved = path10.resolve(ledgerPath);
20947
+ const dir = path10.dirname(resolved);
20948
+ fs9.mkdirSync(dir, { recursive: true });
20949
+ fs9.writeFileSync(resolved, JSON.stringify(ledger, null, 2), "utf8");
20950
+ }
20951
+ function getLatestDeployment(ledger, environment) {
20952
+ return [...ledger.deployments].reverse().find((d) => d.environment === environment);
20953
+ }
20954
+
20955
+ // src/deploy/index.ts
20956
+ function recordDeployment(args) {
20957
+ const ledger = loadLedger(args.ledgerPath);
20958
+ const previous = getLatestDeployment(ledger, args.environment);
20959
+ const entry = {
20960
+ environment: args.environment,
20961
+ tag: args.tag,
20962
+ sha: args.run.gitSha,
20963
+ runFile: args.runFilePath,
20964
+ scenarioIds: args.run.testCases.map((tc) => tc.id),
20965
+ scenarioStatuses: Object.fromEntries(args.run.testCases.map((tc) => [tc.id, tc.status])),
20966
+ timestamp: new Date(args.run.finishedAtMs).toISOString(),
20967
+ summary: countStatuses(args.run)
20968
+ };
20969
+ ledger.deployments.push(entry);
20970
+ if (previous) {
20971
+ const previousIds = new Set(previous.scenarioIds);
20972
+ const added = entry.scenarioIds.filter((id) => !previousIds.has(id)).length;
20973
+ const removed = previous.scenarioIds.filter((id) => !entry.scenarioIds.includes(id)).length;
20974
+ if (added > 0 || removed > 0) {
20975
+ }
20976
+ }
20977
+ saveLedger(ledger, args.ledgerPath);
20978
+ return { entry, ledgerPath: args.ledgerPath };
20979
+ }
20980
+ function getDeploymentStatus(ledgerPath) {
20981
+ const ledger = loadLedger(ledgerPath);
20982
+ const environments = {};
20983
+ for (const entry of ledger.deployments) {
20984
+ environments[entry.environment] = {
20985
+ latest: entry,
20986
+ previousDeployment: environments[entry.environment]?.latest
20987
+ };
20988
+ }
20989
+ return { environments, ledgerPath };
20990
+ }
20991
+ function getEnvironmentDrift(ledgerPath, envA, envB) {
20992
+ const ledger = loadLedger(ledgerPath);
20993
+ const aEntry = getLatestDeployment(ledger, envA);
20994
+ const bEntry = getLatestDeployment(ledger, envB);
20995
+ if (!aEntry) {
20996
+ throw new Error(`No deployment found for environment "${envA}"`);
20997
+ }
20998
+ if (!bEntry) {
20999
+ throw new Error(`No deployment found for environment "${envB}"`);
21000
+ }
21001
+ const aIds = new Set(aEntry.scenarioIds);
21002
+ const bIds = new Set(bEntry.scenarioIds);
21003
+ const onlyInA = aEntry.scenarioIds.filter((id) => !bIds.has(id));
21004
+ const onlyInB = bEntry.scenarioIds.filter((id) => !aIds.has(id));
21005
+ const inBoth = aEntry.scenarioIds.filter((id) => bIds.has(id));
21006
+ const statusChanged = inBoth.map((id) => ({
21007
+ id,
21008
+ statusA: aEntry.scenarioStatuses?.[id] ?? "unknown",
21009
+ statusB: bEntry.scenarioStatuses?.[id] ?? "unknown"
21010
+ })).filter((item) => item.statusA !== item.statusB);
21011
+ return { environmentA: envA, environmentB: envB, onlyInA, onlyInB, inBoth, statusChanged, aEntry, bEntry };
21012
+ }
21013
+ function countStatuses(run) {
21014
+ const summary = { total: 0, passed: 0, failed: 0, skipped: 0, pending: 0 };
21015
+ for (const tc of run.testCases) {
21016
+ summary.total++;
21017
+ if (tc.status in summary) {
21018
+ summary[tc.status]++;
21019
+ }
21020
+ }
21021
+ return summary;
21022
+ }
21023
+
20723
21024
  // src/index.ts
20724
21025
  var FORMAT_EXTENSIONS = {
20725
21026
  astro: ".md",
20726
21027
  "behavior-manifest-json": ".behavior-manifest.json",
20727
21028
  markdown: ".md",
21029
+ "release-manifest": ".release-manifest.md",
20728
21030
  html: ".html",
20729
21031
  "cucumber-html": ".cucumber.html",
20730
21032
  junit: ".junit.xml",
@@ -20734,6 +21036,10 @@ var FORMAT_EXTENSIONS = {
20734
21036
  "scenario-index-json": ".scenarios-index.json",
20735
21037
  "story-report-json": ".story-report.json"
20736
21038
  };
21039
+ function joinNameAndExt(name, ext) {
21040
+ const stutter = `.${name}.`;
21041
+ return ext.startsWith(stutter) ? `${name}.${ext.slice(stutter.length)}` : `${name}${ext}`;
21042
+ }
20737
21043
  var TEST_EXTENSIONS = [
20738
21044
  ".test.ts",
20739
21045
  ".test.tsx",
@@ -20759,11 +21065,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20759
21065
  const ext = FORMAT_EXTENSIONS[format];
20760
21066
  const effectiveName = outputName + (outputNameSuffix ?? "");
20761
21067
  if (mode === "aggregated") {
20762
- return toPosix(path10.join(baseOutputDir, `${effectiveName}${ext}`));
21068
+ return toPosix(path11.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
20763
21069
  }
20764
21070
  const normalizedSource = toPosix(sourceFile);
20765
- const dirOfSource = path10.posix.dirname(normalizedSource);
20766
- let baseName = path10.posix.basename(normalizedSource);
21071
+ const dirOfSource = path11.posix.dirname(normalizedSource);
21072
+ let baseName = path11.posix.basename(normalizedSource);
20767
21073
  for (const testExt of TEST_EXTENSIONS) {
20768
21074
  if (baseName.endsWith(testExt)) {
20769
21075
  baseName = baseName.slice(0, -testExt.length);
@@ -20772,9 +21078,12 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20772
21078
  }
20773
21079
  const fileName = `${baseName}.${effectiveName}${ext}`;
20774
21080
  if (colocatedStyle === "adjacent") {
20775
- return toPosix(path10.posix.join(dirOfSource, fileName));
21081
+ return toPosix(path11.posix.join(dirOfSource, fileName));
20776
21082
  }
20777
- return toPosix(path10.posix.join(baseOutputDir, dirOfSource, fileName));
21083
+ if (colocatedStyle === "flat") {
21084
+ return toPosix(path11.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
21085
+ }
21086
+ return toPosix(path11.posix.join(baseOutputDir, dirOfSource, fileName));
20778
21087
  }
20779
21088
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
20780
21089
  const groups = /* @__PURE__ */ new Map();
@@ -20981,8 +21290,8 @@ var ReportGenerator = class {
20981
21290
  if (astroPaths) {
20982
21291
  for (const mdPath of astroPaths) {
20983
21292
  const content = await fsPromises.readFile(mdPath, "utf8");
20984
- const mdDir = path10.dirname(mdPath);
20985
- const assetsDir = path10.resolve(this.options.astro.assetsDir);
21293
+ const mdDir = path11.dirname(mdPath);
21294
+ const assetsDir = path11.resolve(this.options.astro.assetsDir);
20986
21295
  const result = copyMarkdownAssets({
20987
21296
  markdown: content,
20988
21297
  markdownDir: mdDir,
@@ -21013,9 +21322,9 @@ var ReportGenerator = class {
21013
21322
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
21014
21323
  const ext = FORMAT_EXTENSIONS[format];
21015
21324
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
21016
- const outputPath = toPosix(path10.join(this.options.outputDir, `${effectiveName}${ext}`));
21325
+ const outputPath = toPosix(path11.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
21017
21326
  const content = await this.formatContent(run, format);
21018
- const dir = path10.dirname(outputPath);
21327
+ const dir = path11.dirname(outputPath);
21019
21328
  await fsPromises.mkdir(dir, { recursive: true });
21020
21329
  await this.deps.writeFile(outputPath, content);
21021
21330
  return [outputPath];
@@ -21027,7 +21336,7 @@ var ReportGenerator = class {
21027
21336
  testCases
21028
21337
  };
21029
21338
  const content = await this.formatContent(groupRun, format);
21030
- const dir = path10.dirname(outputPath);
21339
+ const dir = path11.dirname(outputPath);
21031
21340
  await fsPromises.mkdir(dir, { recursive: true });
21032
21341
  await this.deps.writeFile(outputPath, content);
21033
21342
  writtenPaths.push(outputPath);
@@ -21093,6 +21402,8 @@ var ReportGenerator = class {
21093
21402
  case "astro": {
21094
21403
  const formatter = new AstroFormatter({
21095
21404
  assetsBaseUrl: this.options.astro.assetsBaseUrl,
21405
+ // Colocated = one page per file, so title each by its own suite/file.
21406
+ perFileTitle: this.options.output.mode === "colocated",
21096
21407
  markdown: this.options.astro.markdown
21097
21408
  });
21098
21409
  return formatter.format(run);
@@ -21134,6 +21445,10 @@ var ReportGenerator = class {
21134
21445
  });
21135
21446
  return formatter.format(run);
21136
21447
  }
21448
+ case "release-manifest": {
21449
+ const formatter = new ReleaseManifestFormatter();
21450
+ return formatter.format(run);
21451
+ }
21137
21452
  case "story-report-json": {
21138
21453
  const formatter = new StoryReportJsonFormatter({
21139
21454
  pretty: this.options.storyReportJson.pretty
@@ -21168,7 +21483,7 @@ async function generateRunComparison(args) {
21168
21483
  await fsPromises.mkdir(outputDir, { recursive: true });
21169
21484
  for (const format of args.formats) {
21170
21485
  const ext = format === "html" ? ".html" : ".md";
21171
- const outputPath = toPosix(path10.join(outputDir, `${outputName}${ext}`));
21486
+ const outputPath = toPosix(path11.join(outputDir, `${outputName}${ext}`));
21172
21487
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
21173
21488
  await fsPromises.writeFile(outputPath, content, "utf8");
21174
21489
  files.push(outputPath);
@@ -21202,6 +21517,7 @@ export {
21202
21517
  MIN_METRIC_SAMPLES,
21203
21518
  MIN_PERF_SAMPLES,
21204
21519
  MarkdownFormatter,
21520
+ ReleaseManifestFormatter,
21205
21521
  ReportGenerator,
21206
21522
  ReviewHtmlFormatter,
21207
21523
  ReviewMarkdownFormatter,
@@ -21241,10 +21557,13 @@ export {
21241
21557
  generateTestCaseId,
21242
21558
  getAvailableThemes,
21243
21559
  getCssOnlyThemes,
21560
+ getDeploymentStatus,
21561
+ getEnvironmentDrift,
21244
21562
  gradeEvidence,
21245
21563
  hasSufficientHistory,
21246
21564
  isReviewableSource,
21247
21565
  isTestFile,
21566
+ joinNameAndExt,
21248
21567
  listScenarios,
21249
21568
  loadHistory,
21250
21569
  mergeStepResults,
@@ -21261,6 +21580,7 @@ export {
21261
21580
  readBranchName,
21262
21581
  readGitSha,
21263
21582
  readPackageVersion,
21583
+ recordDeployment,
21264
21584
  regenerateArtifacts,
21265
21585
  resolveAttachment,
21266
21586
  resolveAttachments,
@@ -21280,6 +21600,7 @@ export {
21280
21600
  toBehaviorManifest,
21281
21601
  toCIInfo,
21282
21602
  toRawCIInfo,
21603
+ toReleaseManifest,
21283
21604
  toScenarioIndex,
21284
21605
  toStoryReport,
21285
21606
  tryGetActiveOtelContext,