executable-stories-formatters 0.11.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -55,11 +55,16 @@ __export(src_exports, {
55
55
  STORY_REPORT_SCHEMA_VERSION: () => STORY_REPORT_SCHEMA_VERSION,
56
56
  ScenarioIndexJsonFormatter: () => ScenarioIndexJsonFormatter,
57
57
  StoryReportJsonFormatter: () => StoryReportJsonFormatter,
58
+ TraceabilityMatrixFormatter: () => TraceabilityMatrixFormatter,
58
59
  adaptJestRun: () => adaptJestRun,
59
60
  adaptPlaywrightRun: () => adaptPlaywrightRun,
60
61
  adaptVitestRun: () => adaptVitestRun,
61
62
  assertValidRun: () => assertValidRun,
63
+ buildCheck: () => buildCheck,
64
+ buildGoal: () => buildGoal,
65
+ buildHtmlDocEntry: () => buildHtmlDocEntry,
62
66
  buildReview: () => buildReview,
67
+ buildTriage: () => buildTriage,
63
68
  bundleAssets: () => bundleAssets,
64
69
  calculateFlakiness: () => calculateFlakiness,
65
70
  calculateStability: () => calculateStability,
@@ -109,6 +114,9 @@ __export(src_exports, {
109
114
  readPackageVersion: () => readPackageVersion,
110
115
  recordDeployment: () => recordDeployment,
111
116
  regenerateArtifacts: () => regenerateArtifacts,
117
+ renderCheck: () => renderCheck,
118
+ renderGoal: () => renderGoal,
119
+ renderTriage: () => renderTriage,
112
120
  resolveAttachment: () => resolveAttachment,
113
121
  resolveAttachments: () => resolveAttachments,
114
122
  resolveTheme: () => resolveTheme,
@@ -130,6 +138,7 @@ __export(src_exports, {
130
138
  toReleaseManifest: () => toReleaseManifest,
131
139
  toScenarioIndex: () => toScenarioIndex,
132
140
  toStoryReport: () => toStoryReport,
141
+ toTraceabilityMatrix: () => toTraceabilityMatrix,
133
142
  tryGetActiveOtelContext: () => tryGetActiveOtelContext,
134
143
  updateHistory: () => updateHistory,
135
144
  validateCanonicalRun: () => validateCanonicalRun
@@ -630,6 +639,14 @@ var CucumberJsonFormatter = class {
630
639
  }
631
640
  const embeddings = [];
632
641
  for (const doc of step.docs) {
642
+ if (doc.kind === "html" && doc.content !== void 0) {
643
+ embeddings.push({
644
+ data: Buffer.from(doc.content, "utf8").toString("base64"),
645
+ mime_type: "text/html",
646
+ name: doc.title
647
+ });
648
+ continue;
649
+ }
633
650
  if (doc.kind !== "screenshot" || !doc.path.startsWith("data:")) {
634
651
  continue;
635
652
  }
@@ -654,14 +671,14 @@ var CucumberJsonFormatter = class {
654
671
  duration: 0
655
672
  };
656
673
  }
657
- const statusMap = {
674
+ const statusMap2 = {
658
675
  passed: "passed",
659
676
  failed: "failed",
660
677
  skipped: "skipped",
661
678
  pending: "pending"
662
679
  };
663
680
  const stepResult = {
664
- status: statusMap[result.status] ?? "undefined",
681
+ status: statusMap2[result.status] ?? "undefined",
665
682
  // Duration in nanoseconds (Cucumber uses nanoseconds)
666
683
  duration: result.durationMs * 1e6
667
684
  };
@@ -809,6 +826,17 @@ ${doc.markdown}`,
809
826
  };
810
827
  case "screenshot":
811
828
  return null;
829
+ case "html":
830
+ if (doc.url !== void 0 || doc.path !== void 0) {
831
+ return {
832
+ doc_string: {
833
+ content: `[${doc.title ?? "Embedded HTML"}](${doc.url ?? doc.path})`,
834
+ content_type: "text/markdown",
835
+ line: 0
836
+ }
837
+ };
838
+ }
839
+ return null;
812
840
  default:
813
841
  return null;
814
842
  }
@@ -908,6 +936,17 @@ function copyDocEntry(entry) {
908
936
  phase: entry.phase,
909
937
  ...children
910
938
  };
939
+ case "html":
940
+ return {
941
+ kind: "html",
942
+ ...entry.path !== void 0 ? { path: entry.path } : {},
943
+ ...entry.url !== void 0 ? { url: entry.url } : {},
944
+ ...entry.content !== void 0 ? { content: entry.content } : {},
945
+ ...entry.title !== void 0 ? { title: entry.title } : {},
946
+ ...entry.height !== void 0 ? { height: entry.height } : {},
947
+ phase: entry.phase,
948
+ ...children
949
+ };
911
950
  case "custom":
912
951
  return {
913
952
  kind: "custom",
@@ -1296,7 +1335,7 @@ function scenarioHasDocs(scenario) {
1296
1335
  var fs2 = __toESM(require("fs"), 1);
1297
1336
  var path3 = __toESM(require("path"), 1);
1298
1337
 
1299
- // src/formatters/html/template.ts
1338
+ // src/formatters/html/template-scripts.ts
1300
1339
  var JS_THEME = `
1301
1340
  // Theme management
1302
1341
  function getSystemTheme() {
@@ -2098,6 +2137,23 @@ function parseMarkdownSections(marked) {
2098
2137
  });
2099
2138
  }
2100
2139
  `;
2140
+ var JS_HTML_EMBED = `
2141
+ // Open srcdoc-embedded HTML (doc-html iframes) in a new tab via a blob URL
2142
+ function initHtmlEmbeds() {
2143
+ document.querySelectorAll('.doc-html-open-srcdoc').forEach((btn) => {
2144
+ btn.addEventListener('click', function() {
2145
+ const container = btn.closest('.doc-html');
2146
+ const iframe = container ? container.querySelector('iframe.doc-html-frame') : null;
2147
+ const html = iframe ? iframe.getAttribute('srcdoc') : null;
2148
+ if (!html) return;
2149
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
2150
+ window.open(url, '_blank', 'noopener');
2151
+ });
2152
+ });
2153
+ }
2154
+ `;
2155
+
2156
+ // src/formatters/html/template.ts
2101
2157
  function generateScript(options) {
2102
2158
  const initCalls = [];
2103
2159
  if (options.includeDarkMode) {
@@ -2115,6 +2171,7 @@ function generateScript(options) {
2115
2171
  initCalls.push("initHashScroll();");
2116
2172
  initCalls.push("initToc();");
2117
2173
  initCalls.push("initThemePicker();");
2174
+ initCalls.push("initHtmlEmbeds();");
2118
2175
  const initScript = `
2119
2176
  // Initialize on load
2120
2177
  document.addEventListener('DOMContentLoaded', () => {
@@ -2123,6 +2180,7 @@ document.addEventListener('DOMContentLoaded', () => {
2123
2180
  `;
2124
2181
  let script = options.includeDarkMode ? JS_THEME : "";
2125
2182
  script += JS_CORE;
2183
+ script += JS_HTML_EMBED;
2126
2184
  if (options.additionalJs) {
2127
2185
  script += options.additionalJs;
2128
2186
  }
@@ -3788,6 +3846,82 @@ body {
3788
3846
  opacity: 0.8;
3789
3847
  }
3790
3848
 
3849
+ /* ============================================================================
3850
+ Documentation Entries - Embedded HTML
3851
+ ============================================================================ */
3852
+ .doc-html {
3853
+ margin-bottom: 0.5rem;
3854
+ border: 1px solid var(--border);
3855
+ border-radius: calc(var(--radius) - 2px);
3856
+ overflow: hidden;
3857
+ }
3858
+
3859
+ .doc-html:last-child {
3860
+ margin-bottom: 0;
3861
+ }
3862
+
3863
+ .doc-html-header {
3864
+ display: flex;
3865
+ align-items: center;
3866
+ justify-content: space-between;
3867
+ gap: 0.5rem;
3868
+ padding: 0.375rem 0.75rem;
3869
+ background: var(--muted, transparent);
3870
+ border-bottom: 1px solid var(--border);
3871
+ }
3872
+
3873
+ .doc-html-title {
3874
+ font-size: 0.75rem;
3875
+ font-weight: 600;
3876
+ color: var(--muted-foreground);
3877
+ text-transform: uppercase;
3878
+ letter-spacing: 0.04em;
3879
+ }
3880
+
3881
+ .doc-html-open {
3882
+ font-size: 0.875rem;
3883
+ line-height: 1;
3884
+ padding: 0.125rem 0.375rem;
3885
+ border: 1px solid var(--border);
3886
+ border-radius: calc(var(--radius) - 4px);
3887
+ background: transparent;
3888
+ color: var(--muted-foreground);
3889
+ cursor: pointer;
3890
+ text-decoration: none;
3891
+ }
3892
+
3893
+ .doc-html-open:hover {
3894
+ color: var(--foreground);
3895
+ border-color: var(--foreground);
3896
+ }
3897
+
3898
+ .doc-html-frame {
3899
+ display: block;
3900
+ width: 100%;
3901
+ border: 0;
3902
+ background: #fff;
3903
+ }
3904
+
3905
+ .doc-html-missing {
3906
+ padding: 0.75rem 1rem;
3907
+ border: 1px dashed var(--border);
3908
+ background: var(--muted, transparent);
3909
+ color: var(--muted-foreground);
3910
+ font-size: 0.8125rem;
3911
+ }
3912
+
3913
+ .doc-html-missing-label {
3914
+ font-weight: 600;
3915
+ margin-bottom: 0.25rem;
3916
+ }
3917
+
3918
+ .doc-html-missing-path {
3919
+ font-family: var(--font-mono, ui-monospace, monospace);
3920
+ font-size: 0.75rem;
3921
+ word-break: break-all;
3922
+ opacity: 0.8;
3923
+ }
3924
+
3791
3925
  /* ============================================================================
3792
3926
  Documentation Entries - Visual Check
3793
3927
  ============================================================================ */
@@ -14086,6 +14220,36 @@ function renderDocVideo(entry, deps) {
14086
14220
  ${captionHtml}
14087
14221
  </div>`;
14088
14222
  }
14223
+ function resolveHtmlSource(entry, deps) {
14224
+ if (entry.url !== void 0) return { mode: "src", value: entry.url };
14225
+ if (entry.content !== void 0) return { mode: "srcdoc", value: entry.content };
14226
+ const filePath = entry.path ?? "";
14227
+ if (/^https?:/i.test(filePath)) return { mode: "src", value: filePath };
14228
+ const inlined = deps.readHtmlFile?.(filePath);
14229
+ if (inlined !== void 0) return { mode: "srcdoc", value: inlined };
14230
+ const isAbsoluteFsPath = /^(?:[/\\]|[A-Za-z]:[/\\])/.test(filePath);
14231
+ if (deps.readHtmlFile && isAbsoluteFsPath) return { mode: "missing", value: filePath };
14232
+ return { mode: "src", value: filePath };
14233
+ }
14234
+ function renderDocHtml(entry, deps) {
14235
+ const source = resolveHtmlSource(entry, deps);
14236
+ if (source.mode === "missing") {
14237
+ return `<div class="doc-html doc-html-missing">
14238
+ <div class="doc-html-missing-label">HTML unavailable</div>
14239
+ <div class="doc-html-missing-path">${deps.escapeHtml(source.value)}</div>
14240
+ </div>`;
14241
+ }
14242
+ const heightCss = typeof entry.height === "number" ? `${entry.height}px` : entry.height ?? "400px";
14243
+ const frame = `<iframe class="doc-html-frame" sandbox="allow-scripts" loading="lazy" style="height: ${deps.escapeHtml(heightCss)};" title="${deps.escapeHtml(entry.title ?? "Embedded HTML")}" ${source.mode}="${deps.escapeHtml(source.value)}"></iframe>`;
14244
+ const openBtn = source.mode === "src" ? `<a class="doc-html-open" href="${deps.escapeHtml(source.value)}" target="_blank" rel="noopener noreferrer" title="Open in new tab" aria-label="Open in new tab">&#x2197;</a>` : `<button type="button" class="doc-html-open doc-html-open-srcdoc" title="Open in new tab" aria-label="Open in new tab">&#x2197;</button>`;
14245
+ return `<div class="doc-html">
14246
+ <div class="doc-html-header">
14247
+ <span class="doc-html-title">${deps.escapeHtml(entry.title ?? "HTML")}</span>
14248
+ ${openBtn}
14249
+ </div>
14250
+ ${frame}
14251
+ </div>`;
14252
+ }
14089
14253
  function renderDocCustom(entry, deps) {
14090
14254
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
14091
14255
  const data = entry.data;
@@ -14142,6 +14306,9 @@ function renderDocEntry(entry, deps) {
14142
14306
  case "video":
14143
14307
  html = renderDocVideo(entry, deps);
14144
14308
  break;
14309
+ case "html":
14310
+ html = renderDocHtml(entry, deps);
14311
+ break;
14145
14312
  case "custom":
14146
14313
  html = renderDocCustom(entry, deps);
14147
14314
  break;
@@ -14713,6 +14880,21 @@ function readScreenshotAsDataUri(filePath) {
14713
14880
  return void 0;
14714
14881
  }
14715
14882
  }
14883
+ var HTML_INLINE_WARN_BYTES = 1024 * 1024;
14884
+ function readHtmlFileContent(filePath) {
14885
+ try {
14886
+ if (!fs2.existsSync(filePath)) return void 0;
14887
+ const buf = fs2.readFileSync(filePath);
14888
+ if (buf.byteLength > HTML_INLINE_WARN_BYTES) {
14889
+ console.warn(
14890
+ `[executable-stories] Inlining large HTML file (${Math.round(buf.byteLength / 1024)} KiB) into the report: ${filePath}. Consider --asset-mode copy.`
14891
+ );
14892
+ }
14893
+ return buf.toString("utf8");
14894
+ } catch {
14895
+ return void 0;
14896
+ }
14897
+ }
14716
14898
  function normalizeOptions(options = {}) {
14717
14899
  return {
14718
14900
  title: options.title ?? "Test Results",
@@ -14720,6 +14902,7 @@ function normalizeOptions(options = {}) {
14720
14902
  searchable: options.searchable ?? true,
14721
14903
  startCollapsed: options.startCollapsed ?? false,
14722
14904
  embedScreenshots: options.embedScreenshots ?? true,
14905
+ embedHtmlFiles: options.embedHtmlFiles ?? true,
14723
14906
  syntaxHighlighting: options.syntaxHighlighting ?? true,
14724
14907
  mermaidEnabled: options.mermaidEnabled ?? true,
14725
14908
  markdownEnabled: options.markdownEnabled ?? true,
@@ -14738,7 +14921,10 @@ function createHtmlFormatter(options = {}) {
14738
14921
  markdownEnabled: opts.markdownEnabled,
14739
14922
  mermaidEnabled: opts.mermaidEnabled,
14740
14923
  embedScreenshots: opts.embedScreenshots,
14741
- readScreenshot: (filePath) => readScreenshotAsDataUri(filePath)
14924
+ readScreenshot: (filePath) => readScreenshotAsDataUri(filePath),
14925
+ // When html-file inlining is off (e.g. --asset-mode copy), omit the read
14926
+ // hook so doc-html iframes keep their src path for the asset bundler.
14927
+ ...opts.embedHtmlFiles ? { readHtmlFile: (filePath) => readHtmlFileContent(filePath) } : {}
14742
14928
  };
14743
14929
  const renderDocs = (docs, containerClass) => {
14744
14930
  if (!docs || docs.length === 0) return "";
@@ -15030,6 +15216,8 @@ var JUnitFormatter = class {
15030
15216
  }
15031
15217
  case "screenshot":
15032
15218
  return `${indent}Screenshot: ${entry.alt ?? entry.path}`;
15219
+ case "html":
15220
+ return `${indent}HTML: ${entry.title ?? "Embedded HTML"} (${entry.url ?? entry.path ?? "inline"})`;
15033
15221
  case "custom": {
15034
15222
  const dataStr = JSON.stringify(entry.data, null, 2);
15035
15223
  const lines = [];
@@ -15481,6 +15669,25 @@ var MarkdownFormatter = class {
15481
15669
  lines.push(`${indent}`);
15482
15670
  break;
15483
15671
  }
15672
+ case "html": {
15673
+ const htmlLabel = entry.title ?? "Embedded HTML";
15674
+ if (entry.url !== void 0 || entry.path !== void 0) {
15675
+ lines.push(`${indent}[${htmlLabel}](${entry.url ?? entry.path})`);
15676
+ break;
15677
+ }
15678
+ lines.push(`${indent}<details>`);
15679
+ lines.push(`${indent}<summary>${htmlLabel}</summary>`);
15680
+ lines.push(`${indent}`);
15681
+ lines.push(`${indent}\`\`\`html`);
15682
+ for (const line of (entry.content ?? "").split("\n")) {
15683
+ lines.push(`${indent}${line}`);
15684
+ }
15685
+ lines.push(`${indent}\`\`\``);
15686
+ lines.push(`${indent}`);
15687
+ lines.push(`${indent}</details>`);
15688
+ lines.push(`${indent}`);
15689
+ break;
15690
+ }
15484
15691
  case "custom":
15485
15692
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15486
15693
  const data = entry.data;
@@ -15655,6 +15862,132 @@ function escapePipe(value) {
15655
15862
  return value.replace(/\|/g, "\\|");
15656
15863
  }
15657
15864
 
15865
+ // src/formatters/traceability-matrix.ts
15866
+ var TraceabilityMatrixFormatter = class {
15867
+ format(run) {
15868
+ const matrix = toTraceabilityMatrix(run);
15869
+ const lines = [];
15870
+ lines.push("# Traceability Matrix");
15871
+ lines.push("");
15872
+ lines.push(`Generated: ${matrix.generatedAt}`);
15873
+ lines.push(`Run: ${matrix.run.startedAt} to ${matrix.run.finishedAt}`);
15874
+ if (matrix.run.branch) lines.push(`Branch: ${matrix.run.branch}`);
15875
+ if (matrix.run.gitSha) lines.push(`Commit: ${matrix.run.gitSha}`);
15876
+ lines.push("");
15877
+ lines.push("| Requirements | Verified | Failing | Scenarios | Untraced |");
15878
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15879
+ lines.push(
15880
+ `| ${matrix.summary.requirements} | ${matrix.summary.requirementsVerified} | ${matrix.summary.requirementsFailing} | ${matrix.summary.scenarios} | ${matrix.summary.untracedScenarios} |`
15881
+ );
15882
+ lines.push("");
15883
+ for (const req of matrix.requirements) {
15884
+ const heading2 = req.url ? `[${req.ticket}](${req.url})` : req.ticket;
15885
+ lines.push(`## ${heading2}`);
15886
+ lines.push("");
15887
+ lines.push(`Status: ${renderRequirementStatus(req.status)}`);
15888
+ if (req.covers.length > 0) {
15889
+ lines.push(`Covers: ${req.covers.map((path12) => `\`${path12}\``).join(", ")}`);
15890
+ }
15891
+ lines.push("");
15892
+ lines.push("| Status | Scenario | Source | Covers |");
15893
+ lines.push("| --- | --- | --- | --- |");
15894
+ for (const scenario of req.scenarios) {
15895
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15896
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path12) => `\`${path12}\``).join(", ") : "";
15897
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
15898
+ }
15899
+ lines.push("");
15900
+ }
15901
+ if (matrix.untraced.length > 0) {
15902
+ lines.push("## Untraced scenarios");
15903
+ lines.push("");
15904
+ lines.push("Behavior with no requirement link. Add a `ticket` to each so it appears against a requirement.");
15905
+ lines.push("");
15906
+ lines.push("| Status | Scenario | Source |");
15907
+ lines.push("| --- | --- | --- |");
15908
+ for (const scenario of matrix.untraced) {
15909
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15910
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` |`);
15911
+ }
15912
+ lines.push("");
15913
+ }
15914
+ return lines.join("\n").trimEnd();
15915
+ }
15916
+ };
15917
+ function toTraceabilityMatrix(run) {
15918
+ const sorted = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id));
15919
+ const byTicket = /* @__PURE__ */ new Map();
15920
+ const untraced = [];
15921
+ for (const tc of sorted) {
15922
+ const tickets = tc.story.tickets ?? [];
15923
+ if (tickets.length === 0) {
15924
+ untraced.push({
15925
+ id: tc.id,
15926
+ title: tc.story.scenario,
15927
+ status: tc.status,
15928
+ sourceFile: tc.sourceFile,
15929
+ sourceLine: tc.sourceLine
15930
+ });
15931
+ continue;
15932
+ }
15933
+ for (const ticket of tickets) {
15934
+ const entry = byTicket.get(ticket.id) ?? { url: ticket.url, cases: [] };
15935
+ if (!entry.url && ticket.url) entry.url = ticket.url;
15936
+ entry.cases.push(tc);
15937
+ byTicket.set(ticket.id, entry);
15938
+ }
15939
+ }
15940
+ const requirements = [...byTicket.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ticket, entry]) => {
15941
+ const scenarios = entry.cases.map((tc) => ({
15942
+ id: tc.id,
15943
+ title: tc.story.scenario,
15944
+ status: tc.status,
15945
+ sourceFile: tc.sourceFile,
15946
+ sourceLine: tc.sourceLine,
15947
+ covers: tc.story.covers ?? []
15948
+ }));
15949
+ const covers = [...new Set(scenarios.flatMap((s) => s.covers))].sort();
15950
+ return { ticket, url: entry.url, status: requirementStatus(entry.cases), scenarios, covers };
15951
+ });
15952
+ return {
15953
+ schemaVersion: "1.0",
15954
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15955
+ run: {
15956
+ startedAt: new Date(run.startedAtMs).toISOString(),
15957
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15958
+ gitSha: run.gitSha,
15959
+ branch: run.ci?.branch
15960
+ },
15961
+ summary: {
15962
+ requirements: requirements.length,
15963
+ requirementsVerified: requirements.filter((r) => r.status === "verified").length,
15964
+ requirementsFailing: requirements.filter((r) => r.status === "failing").length,
15965
+ scenarios: run.testCases.length,
15966
+ untracedScenarios: untraced.length
15967
+ },
15968
+ requirements,
15969
+ untraced
15970
+ };
15971
+ }
15972
+ function requirementStatus(cases) {
15973
+ if (cases.some((tc) => tc.status === "failed")) return "failing";
15974
+ if (cases.some((tc) => tc.status === "passed")) return "verified";
15975
+ return "incomplete";
15976
+ }
15977
+ function renderRequirementStatus(status) {
15978
+ switch (status) {
15979
+ case "verified":
15980
+ return "verified (all scenarios passed)";
15981
+ case "failing":
15982
+ return "failing (a scenario failed)";
15983
+ default:
15984
+ return "incomplete (no scenario passed yet)";
15985
+ }
15986
+ }
15987
+ function escapePipe2(value) {
15988
+ return value.replace(/\|/g, "\\|");
15989
+ }
15990
+
15658
15991
  // src/formatters/cucumber-messages/synthesize-feature.ts
15659
15992
  function extractFeatureName(testCases, uri) {
15660
15993
  for (const tc of testCases) {
@@ -16724,6 +17057,8 @@ function formatDocEntry(doc) {
16724
17057
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16725
17058
  case "video":
16726
17059
  return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
17060
+ case "html":
17061
+ return `${doc.title ? `${escapeHtml2(doc.title)}: ` : ""}${escapeHtml2(doc.url ?? doc.path ?? "(inline html)")}`;
16727
17062
  case "custom":
16728
17063
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16729
17064
  }
@@ -17182,6 +17517,8 @@ function formatDocEntry2(doc) {
17182
17517
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17183
17518
  case "video":
17184
17519
  return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17520
+ case "html":
17521
+ return `${doc.title ? `${doc.title}: ` : ""}${doc.url ?? doc.path ?? "(inline html)"}`;
17185
17522
  case "custom":
17186
17523
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17187
17524
  }
@@ -17331,7 +17668,7 @@ var path5 = __toESM(require("path"), 1);
17331
17668
  function scanHtmlAssets(html) {
17332
17669
  const seen = /* @__PURE__ */ new Set();
17333
17670
  const patterns = [
17334
- /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17671
+ /<(?:img|video|iframe)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17335
17672
  /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
17336
17673
  /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
17337
17674
  ];
@@ -17410,7 +17747,7 @@ function bundleAssets(htmlPath, options = {}) {
17410
17747
  function replaceAssetRef(html, original, replacement) {
17411
17748
  const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17412
17749
  const srcPattern = new RegExp(
17413
- `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17750
+ `(<(?:img|video|iframe)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17414
17751
  "g"
17415
17752
  );
17416
17753
  html = html.replace(srcPattern, `$1${replacement}$2`);
@@ -17798,6 +18135,21 @@ ${tc.errorStack}` : "");
17798
18135
  ])
17799
18136
  );
17800
18137
  break;
18138
+ case "html":
18139
+ if (entry.url !== void 0 || entry.path !== void 0) {
18140
+ const target = entry.url ?? entry.path ?? "";
18141
+ content.push(
18142
+ paragraph([
18143
+ text(entry.title ?? "Embedded HTML", strong()),
18144
+ text(": "),
18145
+ link(target, target)
18146
+ ])
18147
+ );
18148
+ break;
18149
+ }
18150
+ content.push(paragraph([text(entry.title ?? "Embedded HTML", strong())]));
18151
+ content.push(codeBlock(entry.content ?? "", "html"));
18152
+ break;
17801
18153
  case "custom":
17802
18154
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17803
18155
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -19515,6 +19867,27 @@ function resolveTraceUrl(template, traceId) {
19515
19867
  return template.replace(/\{traceId\}/g, traceId);
19516
19868
  }
19517
19869
 
19870
+ // src/utils/doc-builders.ts
19871
+ function buildHtmlDocEntry(options) {
19872
+ const sources = [options.path, options.url, options.content].filter(
19873
+ (v) => v !== void 0
19874
+ );
19875
+ if (sources.length !== 1) {
19876
+ throw new Error(
19877
+ "story.html() requires exactly one of path, url, or content"
19878
+ );
19879
+ }
19880
+ return {
19881
+ kind: "html",
19882
+ path: options.path,
19883
+ url: options.url,
19884
+ content: options.content,
19885
+ title: options.title,
19886
+ height: options.height,
19887
+ phase: "runtime"
19888
+ };
19889
+ }
19890
+
19518
19891
  // src/notifiers/slack.ts
19519
19892
  function truncate(text2, maxLen) {
19520
19893
  if (text2.length <= maxLen) return text2;
@@ -20363,6 +20736,280 @@ function collectDocKinds(testCase) {
20363
20736
  return [...kinds].sort();
20364
20737
  }
20365
20738
 
20739
+ // src/scenario-failure.ts
20740
+ function failingScenarioMessage(tc) {
20741
+ const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
20742
+ return failingStep?.errorMessage ?? tc.errorMessage;
20743
+ }
20744
+
20745
+ // src/check.ts
20746
+ var ICON_PASS = "\u2713";
20747
+ var ICON_FAIL = "\u2717";
20748
+ var ICON_SKIP = "\u2298";
20749
+ var ICON_PENDING = "\u23F3";
20750
+ var ICON_WARN = "\u26A0";
20751
+ function buildCheck(args, _deps = {}) {
20752
+ const { testCases, baseline } = args;
20753
+ const summary = {
20754
+ total: testCases.length,
20755
+ passed: testCases.filter((tc) => tc.status === "passed").length,
20756
+ failed: testCases.filter((tc) => tc.status === "failed").length,
20757
+ skipped: testCases.filter((tc) => tc.status === "skipped").length,
20758
+ pending: testCases.filter((tc) => tc.status === "pending").length
20759
+ };
20760
+ let regressed = 0;
20761
+ let fixed = 0;
20762
+ if (baseline) {
20763
+ for (const tc of testCases) {
20764
+ const before = baseline.get(tc.id);
20765
+ if (before === "passed" && tc.status === "failed") regressed += 1;
20766
+ if (before === "failed" && tc.status === "passed") fixed += 1;
20767
+ }
20768
+ }
20769
+ const failures = testCases.filter((tc) => tc.status === "failed").map((tc) => toFailure(tc, baseline)).sort((a, b) => {
20770
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20771
+ return a.location.localeCompare(b.location);
20772
+ });
20773
+ return {
20774
+ summary,
20775
+ failures,
20776
+ regressed,
20777
+ fixed,
20778
+ comparedToBaseline: baseline !== void 0
20779
+ };
20780
+ }
20781
+ function toFailure(tc, baseline) {
20782
+ const failedIndexes = new Set(
20783
+ tc.stepResults.filter((s) => s.status === "failed").map((s) => s.index)
20784
+ );
20785
+ const steps = tc.story.steps.map((step, index) => ({
20786
+ keyword: step.keyword,
20787
+ text: step.text,
20788
+ failed: failedIndexes.has(index)
20789
+ }));
20790
+ return {
20791
+ id: tc.id,
20792
+ scenario: tc.story.scenario,
20793
+ location: `${tc.sourceFile}:${tc.sourceLine}`,
20794
+ steps,
20795
+ errorMessage: failingScenarioMessage(tc),
20796
+ covers: tc.story.covers ?? [],
20797
+ tickets: (tc.story.tickets ?? []).map((t) => t.id),
20798
+ regressed: baseline?.get(tc.id) === "passed"
20799
+ };
20800
+ }
20801
+ function renderCheck(report, format) {
20802
+ return format === "json" ? JSON.stringify(report, null, 2) : renderCheckText(report);
20803
+ }
20804
+ function renderCheckText(report) {
20805
+ const { summary, failures } = report;
20806
+ const headlineParts = [`${ICON_PASS} ${summary.passed} passed`];
20807
+ if (summary.failed > 0) headlineParts.push(`${ICON_FAIL} ${summary.failed} failed`);
20808
+ if (summary.skipped > 0) headlineParts.push(`${ICON_SKIP} ${summary.skipped} skipped`);
20809
+ if (summary.pending > 0) headlineParts.push(`${ICON_PENDING} ${summary.pending} pending`);
20810
+ const headline = `${headlineParts.join(" ")} (${summary.total} scenarios)`;
20811
+ if (failures.length === 0) {
20812
+ const lines2 = [headline];
20813
+ if (report.comparedToBaseline && report.fixed > 0) {
20814
+ lines2.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20815
+ }
20816
+ lines2.push("All scenarios green.");
20817
+ return lines2.join("\n");
20818
+ }
20819
+ const lines = [headline, ""];
20820
+ for (const f of failures) {
20821
+ lines.push(`${ICON_FAIL} ${f.scenario}${f.regressed ? " (regressed)" : ""}`);
20822
+ lines.push(` ${f.location}`);
20823
+ for (const step of f.steps) {
20824
+ const marker = step.failed ? ` ${ICON_FAIL} ` : " ";
20825
+ lines.push(`${marker}${step.keyword} ${step.text}`);
20826
+ }
20827
+ if (f.errorMessage) {
20828
+ const firstLine = f.errorMessage.split("\n")[0];
20829
+ lines.push(` \u2192 ${firstLine}`);
20830
+ }
20831
+ if (f.covers.length > 0) {
20832
+ lines.push(` covers: ${f.covers.join(", ")}`);
20833
+ }
20834
+ if (f.tickets.length > 0) {
20835
+ lines.push(` ticket: ${f.tickets.join(", ")}`);
20836
+ }
20837
+ lines.push("");
20838
+ }
20839
+ if (report.comparedToBaseline) {
20840
+ if (report.regressed > 0) {
20841
+ lines.push(`${ICON_WARN} ${report.regressed} regressed since baseline (was passing).`);
20842
+ }
20843
+ if (report.fixed > 0) {
20844
+ lines.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20845
+ }
20846
+ if (report.regressed === 0 && report.fixed === 0) {
20847
+ lines.push("No status changes vs. baseline.");
20848
+ }
20849
+ }
20850
+ return lines.join("\n").trimEnd();
20851
+ }
20852
+
20853
+ // src/goal.ts
20854
+ var ACTIVE = ["passed", "failed"];
20855
+ function buildGoal(args, _deps = {}) {
20856
+ const { run, baseline } = args;
20857
+ const cases = run.testCases;
20858
+ const selectors = [
20859
+ ...args.requireTags.map((tag) => ({ label: `tag:${tag}`, match: (tc) => tc.tags.includes(tag) })),
20860
+ ...args.requireTickets.map((id) => ({ label: `ticket:${id}`, match: (tc) => (tc.story.tickets ?? []).some((t) => t.id === id) })),
20861
+ ...args.requireScenarios.map((sel) => ({ label: `scenario:${sel}`, match: (tc) => tc.id === sel || tc.story.scenario === sel }))
20862
+ ];
20863
+ const requirements = selectors.length === 0 ? [evaluate("all scenarios", cases)] : selectors.map((s) => evaluate(s.label, cases.filter(s.match)));
20864
+ const regressions = [];
20865
+ if (baseline && args.enforceNoRegressions) {
20866
+ const before = statusMap(baseline);
20867
+ for (const tc of cases) {
20868
+ if (before.get(tc.id) === "passed" && tc.status === "failed") {
20869
+ regressions.push({ id: tc.id, title: tc.story.scenario });
20870
+ }
20871
+ }
20872
+ }
20873
+ const violations = [];
20874
+ if (baseline && args.enforceRatchet) {
20875
+ const current = new Map(cases.map((tc) => [tc.id, tc]));
20876
+ for (const base of baseline.testCases) {
20877
+ const now = current.get(base.id);
20878
+ if (!now) {
20879
+ violations.push({ id: base.id, title: base.story.scenario, kind: "removed", detail: "scenario no longer present" });
20880
+ continue;
20881
+ }
20882
+ if (ACTIVE.includes(base.status) && (now.status === "skipped" || now.status === "pending")) {
20883
+ violations.push({ id: base.id, title: base.story.scenario, kind: "disabled", detail: `${base.status} -> ${now.status}` });
20884
+ }
20885
+ const baseSteps = base.story.steps.length;
20886
+ const nowSteps = now.story.steps.length;
20887
+ if (nowSteps < baseSteps) {
20888
+ violations.push({ id: base.id, title: base.story.scenario, kind: "weakened", detail: `${baseSteps} steps -> ${nowSteps} steps` });
20889
+ }
20890
+ }
20891
+ }
20892
+ const met = requirements.every((r) => r.met) && regressions.length === 0 && violations.length === 0;
20893
+ return {
20894
+ met,
20895
+ requirements,
20896
+ regressions,
20897
+ regressionsEnforced: Boolean(baseline && args.enforceNoRegressions),
20898
+ ratchet: { enforced: Boolean(baseline && args.enforceRatchet), violations }
20899
+ };
20900
+ }
20901
+ function evaluate(selector, matched) {
20902
+ const passed = matched.filter((tc) => tc.status === "passed").length;
20903
+ const failing = matched.filter((tc) => tc.status !== "passed").map((tc) => tc.story.scenario);
20904
+ return {
20905
+ selector,
20906
+ matched: matched.length,
20907
+ passed,
20908
+ failing,
20909
+ met: matched.length > 0 && failing.length === 0
20910
+ };
20911
+ }
20912
+ function statusMap(run) {
20913
+ return new Map(run.testCases.map((tc) => [tc.id, tc.status]));
20914
+ }
20915
+ function renderGoal(report, format) {
20916
+ if (format === "json") return JSON.stringify(report, null, 2);
20917
+ const lines = [`GOAL: ${report.met ? "met" : "not met"}`];
20918
+ for (const req of report.requirements) {
20919
+ if (req.matched === 0) {
20920
+ lines.push(` ${req.selector}: no matching scenario (no proof)`);
20921
+ continue;
20922
+ }
20923
+ const tail = req.failing.length > 0 ? ` (${req.failing.length} failing)` : "";
20924
+ lines.push(` ${req.selector}: ${req.passed}/${req.matched} scenarios pass${tail}`);
20925
+ }
20926
+ if (report.regressionsEnforced) {
20927
+ if (report.regressions.length === 0) {
20928
+ lines.push(" regressions: 0");
20929
+ } else {
20930
+ lines.push(` regressions: ${report.regressions.length} (${report.regressions.map((r) => r.title).join(", ")})`);
20931
+ }
20932
+ }
20933
+ if (report.ratchet.enforced) {
20934
+ if (report.ratchet.violations.length === 0) {
20935
+ lines.push(" ratchet: clean (0 scenarios removed/weakened)");
20936
+ } else {
20937
+ lines.push(` ratchet: ${report.ratchet.violations.length} removed/weakened`);
20938
+ for (const v of report.ratchet.violations) {
20939
+ lines.push(` ${v.kind}: ${v.title} (${v.detail})`);
20940
+ }
20941
+ }
20942
+ }
20943
+ return lines.join("\n");
20944
+ }
20945
+
20946
+ // src/triage.ts
20947
+ function buildTriage(args, _deps = {}) {
20948
+ const { testCases, baseline } = args;
20949
+ const failing = testCases.filter((tc) => tc.status === "failed");
20950
+ const ranked = failing.map((tc) => {
20951
+ const regressed = baseline?.get(tc.id) === "passed";
20952
+ return {
20953
+ tc,
20954
+ regressed,
20955
+ covers: tc.story.covers ?? []
20956
+ };
20957
+ }).sort((a, b) => {
20958
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20959
+ const la = `${a.tc.sourceFile}:${a.tc.sourceLine}`;
20960
+ const lb = `${b.tc.sourceFile}:${b.tc.sourceLine}`;
20961
+ return la.localeCompare(lb);
20962
+ });
20963
+ const items = ranked.map((entry, index) => ({
20964
+ rank: index + 1,
20965
+ id: entry.tc.id,
20966
+ scenario: entry.tc.story.scenario,
20967
+ status: entry.tc.status,
20968
+ location: `${entry.tc.sourceFile}:${entry.tc.sourceLine}`,
20969
+ covers: entry.covers,
20970
+ tickets: (entry.tc.story.tickets ?? []).map((t) => t.id),
20971
+ errorMessage: failingScenarioMessage(entry.tc),
20972
+ regressed: entry.regressed,
20973
+ reason: entry.regressed ? "regression" : "failing"
20974
+ }));
20975
+ return {
20976
+ total: testCases.length,
20977
+ failing: failing.length,
20978
+ regressions: items.filter((i) => i.regressed).length,
20979
+ needsCovers: items.filter((i) => i.covers.length === 0).length,
20980
+ items
20981
+ };
20982
+ }
20983
+ function renderTriage(report, format) {
20984
+ if (format === "json") return JSON.stringify(report, null, 2);
20985
+ if (report.items.length === 0) {
20986
+ return "Nothing to triage. No failing scenarios.";
20987
+ }
20988
+ const header = report.regressions > 0 ? `${report.items.length} items to triage (${report.regressions} regression${report.regressions === 1 ? "" : "s"})` : `${report.items.length} items to triage`;
20989
+ const lines = [header, ""];
20990
+ for (const item of report.items) {
20991
+ const tag = item.regressed ? "[regression] " : "";
20992
+ lines.push(`${item.rank}. ${tag}${item.scenario}`);
20993
+ lines.push(` ${item.location}`);
20994
+ if (item.errorMessage) {
20995
+ lines.push(` \u2192 ${item.errorMessage.split("\n")[0]}`);
20996
+ }
20997
+ if (item.covers.length > 0) {
20998
+ lines.push(` fix: ${item.covers.join(", ")}`);
20999
+ } else {
21000
+ lines.push(" fix: (no covers declared \u2014 add `covers` to route this to code)");
21001
+ }
21002
+ if (item.tickets.length > 0) {
21003
+ lines.push(` ticket: ${item.tickets.join(", ")}`);
21004
+ }
21005
+ lines.push("");
21006
+ }
21007
+ if (report.needsCovers > 0) {
21008
+ lines.push(`${report.needsCovers} failing scenario(s) have no covers and can't be routed to code automatically.`);
21009
+ }
21010
+ return lines.join("\n").trimEnd();
21011
+ }
21012
+
20366
21013
  // src/review/conventions.ts
20367
21014
  var CHANGE_TAG_PREFIX = "change:";
20368
21015
  var AUDIENCE_TAG_PREFIX = "audience:";
@@ -21164,6 +21811,7 @@ var FORMAT_EXTENSIONS = {
21164
21811
  "behavior-manifest-json": ".behavior-manifest.json",
21165
21812
  markdown: ".md",
21166
21813
  "release-manifest": ".release-manifest.md",
21814
+ "traceability-matrix": ".traceability-matrix.md",
21167
21815
  html: ".html",
21168
21816
  "cucumber-html": ".cucumber.html",
21169
21817
  junit: ".junit.xml",
@@ -21324,6 +21972,9 @@ var ReportGenerator = class {
21324
21972
  searchable: options.html?.searchable ?? true,
21325
21973
  startCollapsed: options.html?.startCollapsed ?? false,
21326
21974
  embedScreenshots: options.html?.embedScreenshots ?? true,
21975
+ // Under "copy" asset mode local html files become hashed assets with
21976
+ // an iframe src instead of being inlined into the report.
21977
+ embedHtmlFiles: options.html?.embedHtmlFiles ?? (options.assetMode ?? "none") !== "copy",
21327
21978
  syntaxHighlighting: options.html?.syntaxHighlighting ?? true,
21328
21979
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
21329
21980
  markdownEnabled: options.html?.markdownEnabled ?? true,
@@ -21499,6 +22150,7 @@ var ReportGenerator = class {
21499
22150
  searchable: this.options.html.searchable,
21500
22151
  startCollapsed: this.options.html.startCollapsed,
21501
22152
  embedScreenshots: this.options.html.embedScreenshots,
22153
+ embedHtmlFiles: this.options.html.embedHtmlFiles,
21502
22154
  syntaxHighlighting: this.options.html.syntaxHighlighting,
21503
22155
  mermaidEnabled: this.options.html.mermaidEnabled,
21504
22156
  markdownEnabled: this.options.html.markdownEnabled,
@@ -21586,6 +22238,10 @@ var ReportGenerator = class {
21586
22238
  const formatter = new ReleaseManifestFormatter();
21587
22239
  return formatter.format(run);
21588
22240
  }
22241
+ case "traceability-matrix": {
22242
+ const formatter = new TraceabilityMatrixFormatter();
22243
+ return formatter.format(run);
22244
+ }
21589
22245
  case "story-report-json": {
21590
22246
  const formatter = new StoryReportJsonFormatter({
21591
22247
  pretty: this.options.storyReportJson.pretty
@@ -21666,11 +22322,16 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21666
22322
  STORY_REPORT_SCHEMA_VERSION,
21667
22323
  ScenarioIndexJsonFormatter,
21668
22324
  StoryReportJsonFormatter,
22325
+ TraceabilityMatrixFormatter,
21669
22326
  adaptJestRun,
21670
22327
  adaptPlaywrightRun,
21671
22328
  adaptVitestRun,
21672
22329
  assertValidRun,
22330
+ buildCheck,
22331
+ buildGoal,
22332
+ buildHtmlDocEntry,
21673
22333
  buildReview,
22334
+ buildTriage,
21674
22335
  bundleAssets,
21675
22336
  calculateFlakiness,
21676
22337
  calculateStability,
@@ -21720,6 +22381,9 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21720
22381
  readPackageVersion,
21721
22382
  recordDeployment,
21722
22383
  regenerateArtifacts,
22384
+ renderCheck,
22385
+ renderGoal,
22386
+ renderTriage,
21723
22387
  resolveAttachment,
21724
22388
  resolveAttachments,
21725
22389
  resolveTheme,
@@ -21741,6 +22405,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21741
22405
  toReleaseManifest,
21742
22406
  toScenarioIndex,
21743
22407
  toStoryReport,
22408
+ toTraceabilityMatrix,
21744
22409
  tryGetActiveOtelContext,
21745
22410
  updateHistory,
21746
22411
  validateCanonicalRun