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.js CHANGED
@@ -494,6 +494,14 @@ var CucumberJsonFormatter = class {
494
494
  }
495
495
  const embeddings = [];
496
496
  for (const doc of step.docs) {
497
+ if (doc.kind === "html" && doc.content !== void 0) {
498
+ embeddings.push({
499
+ data: Buffer.from(doc.content, "utf8").toString("base64"),
500
+ mime_type: "text/html",
501
+ name: doc.title
502
+ });
503
+ continue;
504
+ }
497
505
  if (doc.kind !== "screenshot" || !doc.path.startsWith("data:")) {
498
506
  continue;
499
507
  }
@@ -518,14 +526,14 @@ var CucumberJsonFormatter = class {
518
526
  duration: 0
519
527
  };
520
528
  }
521
- const statusMap = {
529
+ const statusMap2 = {
522
530
  passed: "passed",
523
531
  failed: "failed",
524
532
  skipped: "skipped",
525
533
  pending: "pending"
526
534
  };
527
535
  const stepResult = {
528
- status: statusMap[result.status] ?? "undefined",
536
+ status: statusMap2[result.status] ?? "undefined",
529
537
  // Duration in nanoseconds (Cucumber uses nanoseconds)
530
538
  duration: result.durationMs * 1e6
531
539
  };
@@ -673,6 +681,17 @@ ${doc.markdown}`,
673
681
  };
674
682
  case "screenshot":
675
683
  return null;
684
+ case "html":
685
+ if (doc.url !== void 0 || doc.path !== void 0) {
686
+ return {
687
+ doc_string: {
688
+ content: `[${doc.title ?? "Embedded HTML"}](${doc.url ?? doc.path})`,
689
+ content_type: "text/markdown",
690
+ line: 0
691
+ }
692
+ };
693
+ }
694
+ return null;
676
695
  default:
677
696
  return null;
678
697
  }
@@ -772,6 +791,17 @@ function copyDocEntry(entry) {
772
791
  phase: entry.phase,
773
792
  ...children
774
793
  };
794
+ case "html":
795
+ return {
796
+ kind: "html",
797
+ ...entry.path !== void 0 ? { path: entry.path } : {},
798
+ ...entry.url !== void 0 ? { url: entry.url } : {},
799
+ ...entry.content !== void 0 ? { content: entry.content } : {},
800
+ ...entry.title !== void 0 ? { title: entry.title } : {},
801
+ ...entry.height !== void 0 ? { height: entry.height } : {},
802
+ phase: entry.phase,
803
+ ...children
804
+ };
775
805
  case "custom":
776
806
  return {
777
807
  kind: "custom",
@@ -1160,7 +1190,7 @@ function scenarioHasDocs(scenario) {
1160
1190
  import * as fs2 from "fs";
1161
1191
  import * as path3 from "path";
1162
1192
 
1163
- // src/formatters/html/template.ts
1193
+ // src/formatters/html/template-scripts.ts
1164
1194
  var JS_THEME = `
1165
1195
  // Theme management
1166
1196
  function getSystemTheme() {
@@ -1962,6 +1992,23 @@ function parseMarkdownSections(marked) {
1962
1992
  });
1963
1993
  }
1964
1994
  `;
1995
+ var JS_HTML_EMBED = `
1996
+ // Open srcdoc-embedded HTML (doc-html iframes) in a new tab via a blob URL
1997
+ function initHtmlEmbeds() {
1998
+ document.querySelectorAll('.doc-html-open-srcdoc').forEach((btn) => {
1999
+ btn.addEventListener('click', function() {
2000
+ const container = btn.closest('.doc-html');
2001
+ const iframe = container ? container.querySelector('iframe.doc-html-frame') : null;
2002
+ const html = iframe ? iframe.getAttribute('srcdoc') : null;
2003
+ if (!html) return;
2004
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
2005
+ window.open(url, '_blank', 'noopener');
2006
+ });
2007
+ });
2008
+ }
2009
+ `;
2010
+
2011
+ // src/formatters/html/template.ts
1965
2012
  function generateScript(options) {
1966
2013
  const initCalls = [];
1967
2014
  if (options.includeDarkMode) {
@@ -1979,6 +2026,7 @@ function generateScript(options) {
1979
2026
  initCalls.push("initHashScroll();");
1980
2027
  initCalls.push("initToc();");
1981
2028
  initCalls.push("initThemePicker();");
2029
+ initCalls.push("initHtmlEmbeds();");
1982
2030
  const initScript = `
1983
2031
  // Initialize on load
1984
2032
  document.addEventListener('DOMContentLoaded', () => {
@@ -1987,6 +2035,7 @@ document.addEventListener('DOMContentLoaded', () => {
1987
2035
  `;
1988
2036
  let script = options.includeDarkMode ? JS_THEME : "";
1989
2037
  script += JS_CORE;
2038
+ script += JS_HTML_EMBED;
1990
2039
  if (options.additionalJs) {
1991
2040
  script += options.additionalJs;
1992
2041
  }
@@ -3652,6 +3701,82 @@ body {
3652
3701
  opacity: 0.8;
3653
3702
  }
3654
3703
 
3704
+ /* ============================================================================
3705
+ Documentation Entries - Embedded HTML
3706
+ ============================================================================ */
3707
+ .doc-html {
3708
+ margin-bottom: 0.5rem;
3709
+ border: 1px solid var(--border);
3710
+ border-radius: calc(var(--radius) - 2px);
3711
+ overflow: hidden;
3712
+ }
3713
+
3714
+ .doc-html:last-child {
3715
+ margin-bottom: 0;
3716
+ }
3717
+
3718
+ .doc-html-header {
3719
+ display: flex;
3720
+ align-items: center;
3721
+ justify-content: space-between;
3722
+ gap: 0.5rem;
3723
+ padding: 0.375rem 0.75rem;
3724
+ background: var(--muted, transparent);
3725
+ border-bottom: 1px solid var(--border);
3726
+ }
3727
+
3728
+ .doc-html-title {
3729
+ font-size: 0.75rem;
3730
+ font-weight: 600;
3731
+ color: var(--muted-foreground);
3732
+ text-transform: uppercase;
3733
+ letter-spacing: 0.04em;
3734
+ }
3735
+
3736
+ .doc-html-open {
3737
+ font-size: 0.875rem;
3738
+ line-height: 1;
3739
+ padding: 0.125rem 0.375rem;
3740
+ border: 1px solid var(--border);
3741
+ border-radius: calc(var(--radius) - 4px);
3742
+ background: transparent;
3743
+ color: var(--muted-foreground);
3744
+ cursor: pointer;
3745
+ text-decoration: none;
3746
+ }
3747
+
3748
+ .doc-html-open:hover {
3749
+ color: var(--foreground);
3750
+ border-color: var(--foreground);
3751
+ }
3752
+
3753
+ .doc-html-frame {
3754
+ display: block;
3755
+ width: 100%;
3756
+ border: 0;
3757
+ background: #fff;
3758
+ }
3759
+
3760
+ .doc-html-missing {
3761
+ padding: 0.75rem 1rem;
3762
+ border: 1px dashed var(--border);
3763
+ background: var(--muted, transparent);
3764
+ color: var(--muted-foreground);
3765
+ font-size: 0.8125rem;
3766
+ }
3767
+
3768
+ .doc-html-missing-label {
3769
+ font-weight: 600;
3770
+ margin-bottom: 0.25rem;
3771
+ }
3772
+
3773
+ .doc-html-missing-path {
3774
+ font-family: var(--font-mono, ui-monospace, monospace);
3775
+ font-size: 0.75rem;
3776
+ word-break: break-all;
3777
+ opacity: 0.8;
3778
+ }
3779
+
3655
3780
  /* ============================================================================
3656
3781
  Documentation Entries - Visual Check
3657
3782
  ============================================================================ */
@@ -13950,6 +14075,36 @@ function renderDocVideo(entry, deps) {
13950
14075
  ${captionHtml}
13951
14076
  </div>`;
13952
14077
  }
14078
+ function resolveHtmlSource(entry, deps) {
14079
+ if (entry.url !== void 0) return { mode: "src", value: entry.url };
14080
+ if (entry.content !== void 0) return { mode: "srcdoc", value: entry.content };
14081
+ const filePath = entry.path ?? "";
14082
+ if (/^https?:/i.test(filePath)) return { mode: "src", value: filePath };
14083
+ const inlined = deps.readHtmlFile?.(filePath);
14084
+ if (inlined !== void 0) return { mode: "srcdoc", value: inlined };
14085
+ const isAbsoluteFsPath = /^(?:[/\\]|[A-Za-z]:[/\\])/.test(filePath);
14086
+ if (deps.readHtmlFile && isAbsoluteFsPath) return { mode: "missing", value: filePath };
14087
+ return { mode: "src", value: filePath };
14088
+ }
14089
+ function renderDocHtml(entry, deps) {
14090
+ const source = resolveHtmlSource(entry, deps);
14091
+ if (source.mode === "missing") {
14092
+ return `<div class="doc-html doc-html-missing">
14093
+ <div class="doc-html-missing-label">HTML unavailable</div>
14094
+ <div class="doc-html-missing-path">${deps.escapeHtml(source.value)}</div>
14095
+ </div>`;
14096
+ }
14097
+ const heightCss = typeof entry.height === "number" ? `${entry.height}px` : entry.height ?? "400px";
14098
+ 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>`;
14099
+ 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>`;
14100
+ return `<div class="doc-html">
14101
+ <div class="doc-html-header">
14102
+ <span class="doc-html-title">${deps.escapeHtml(entry.title ?? "HTML")}</span>
14103
+ ${openBtn}
14104
+ </div>
14105
+ ${frame}
14106
+ </div>`;
14107
+ }
13953
14108
  function renderDocCustom(entry, deps) {
13954
14109
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
13955
14110
  const data = entry.data;
@@ -14006,6 +14161,9 @@ function renderDocEntry(entry, deps) {
14006
14161
  case "video":
14007
14162
  html = renderDocVideo(entry, deps);
14008
14163
  break;
14164
+ case "html":
14165
+ html = renderDocHtml(entry, deps);
14166
+ break;
14009
14167
  case "custom":
14010
14168
  html = renderDocCustom(entry, deps);
14011
14169
  break;
@@ -14577,6 +14735,21 @@ function readScreenshotAsDataUri(filePath) {
14577
14735
  return void 0;
14578
14736
  }
14579
14737
  }
14738
+ var HTML_INLINE_WARN_BYTES = 1024 * 1024;
14739
+ function readHtmlFileContent(filePath) {
14740
+ try {
14741
+ if (!fs2.existsSync(filePath)) return void 0;
14742
+ const buf = fs2.readFileSync(filePath);
14743
+ if (buf.byteLength > HTML_INLINE_WARN_BYTES) {
14744
+ console.warn(
14745
+ `[executable-stories] Inlining large HTML file (${Math.round(buf.byteLength / 1024)} KiB) into the report: ${filePath}. Consider --asset-mode copy.`
14746
+ );
14747
+ }
14748
+ return buf.toString("utf8");
14749
+ } catch {
14750
+ return void 0;
14751
+ }
14752
+ }
14580
14753
  function normalizeOptions(options = {}) {
14581
14754
  return {
14582
14755
  title: options.title ?? "Test Results",
@@ -14584,6 +14757,7 @@ function normalizeOptions(options = {}) {
14584
14757
  searchable: options.searchable ?? true,
14585
14758
  startCollapsed: options.startCollapsed ?? false,
14586
14759
  embedScreenshots: options.embedScreenshots ?? true,
14760
+ embedHtmlFiles: options.embedHtmlFiles ?? true,
14587
14761
  syntaxHighlighting: options.syntaxHighlighting ?? true,
14588
14762
  mermaidEnabled: options.mermaidEnabled ?? true,
14589
14763
  markdownEnabled: options.markdownEnabled ?? true,
@@ -14602,7 +14776,10 @@ function createHtmlFormatter(options = {}) {
14602
14776
  markdownEnabled: opts.markdownEnabled,
14603
14777
  mermaidEnabled: opts.mermaidEnabled,
14604
14778
  embedScreenshots: opts.embedScreenshots,
14605
- readScreenshot: (filePath) => readScreenshotAsDataUri(filePath)
14779
+ readScreenshot: (filePath) => readScreenshotAsDataUri(filePath),
14780
+ // When html-file inlining is off (e.g. --asset-mode copy), omit the read
14781
+ // hook so doc-html iframes keep their src path for the asset bundler.
14782
+ ...opts.embedHtmlFiles ? { readHtmlFile: (filePath) => readHtmlFileContent(filePath) } : {}
14606
14783
  };
14607
14784
  const renderDocs = (docs, containerClass) => {
14608
14785
  if (!docs || docs.length === 0) return "";
@@ -14894,6 +15071,8 @@ var JUnitFormatter = class {
14894
15071
  }
14895
15072
  case "screenshot":
14896
15073
  return `${indent}Screenshot: ${entry.alt ?? entry.path}`;
15074
+ case "html":
15075
+ return `${indent}HTML: ${entry.title ?? "Embedded HTML"} (${entry.url ?? entry.path ?? "inline"})`;
14897
15076
  case "custom": {
14898
15077
  const dataStr = JSON.stringify(entry.data, null, 2);
14899
15078
  const lines = [];
@@ -15345,6 +15524,25 @@ var MarkdownFormatter = class {
15345
15524
  lines.push(`${indent}`);
15346
15525
  break;
15347
15526
  }
15527
+ case "html": {
15528
+ const htmlLabel = entry.title ?? "Embedded HTML";
15529
+ if (entry.url !== void 0 || entry.path !== void 0) {
15530
+ lines.push(`${indent}[${htmlLabel}](${entry.url ?? entry.path})`);
15531
+ break;
15532
+ }
15533
+ lines.push(`${indent}<details>`);
15534
+ lines.push(`${indent}<summary>${htmlLabel}</summary>`);
15535
+ lines.push(`${indent}`);
15536
+ lines.push(`${indent}\`\`\`html`);
15537
+ for (const line of (entry.content ?? "").split("\n")) {
15538
+ lines.push(`${indent}${line}`);
15539
+ }
15540
+ lines.push(`${indent}\`\`\``);
15541
+ lines.push(`${indent}`);
15542
+ lines.push(`${indent}</details>`);
15543
+ lines.push(`${indent}`);
15544
+ break;
15545
+ }
15348
15546
  case "custom":
15349
15547
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15350
15548
  const data = entry.data;
@@ -15519,6 +15717,132 @@ function escapePipe(value) {
15519
15717
  return value.replace(/\|/g, "\\|");
15520
15718
  }
15521
15719
 
15720
+ // src/formatters/traceability-matrix.ts
15721
+ var TraceabilityMatrixFormatter = class {
15722
+ format(run) {
15723
+ const matrix = toTraceabilityMatrix(run);
15724
+ const lines = [];
15725
+ lines.push("# Traceability Matrix");
15726
+ lines.push("");
15727
+ lines.push(`Generated: ${matrix.generatedAt}`);
15728
+ lines.push(`Run: ${matrix.run.startedAt} to ${matrix.run.finishedAt}`);
15729
+ if (matrix.run.branch) lines.push(`Branch: ${matrix.run.branch}`);
15730
+ if (matrix.run.gitSha) lines.push(`Commit: ${matrix.run.gitSha}`);
15731
+ lines.push("");
15732
+ lines.push("| Requirements | Verified | Failing | Scenarios | Untraced |");
15733
+ lines.push("| ---: | ---: | ---: | ---: | ---: |");
15734
+ lines.push(
15735
+ `| ${matrix.summary.requirements} | ${matrix.summary.requirementsVerified} | ${matrix.summary.requirementsFailing} | ${matrix.summary.scenarios} | ${matrix.summary.untracedScenarios} |`
15736
+ );
15737
+ lines.push("");
15738
+ for (const req of matrix.requirements) {
15739
+ const heading2 = req.url ? `[${req.ticket}](${req.url})` : req.ticket;
15740
+ lines.push(`## ${heading2}`);
15741
+ lines.push("");
15742
+ lines.push(`Status: ${renderRequirementStatus(req.status)}`);
15743
+ if (req.covers.length > 0) {
15744
+ lines.push(`Covers: ${req.covers.map((path12) => `\`${path12}\``).join(", ")}`);
15745
+ }
15746
+ lines.push("");
15747
+ lines.push("| Status | Scenario | Source | Covers |");
15748
+ lines.push("| --- | --- | --- | --- |");
15749
+ for (const scenario of req.scenarios) {
15750
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15751
+ const covers = scenario.covers.length > 0 ? scenario.covers.map((path12) => `\`${path12}\``).join(", ") : "";
15752
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` | ${covers} |`);
15753
+ }
15754
+ lines.push("");
15755
+ }
15756
+ if (matrix.untraced.length > 0) {
15757
+ lines.push("## Untraced scenarios");
15758
+ lines.push("");
15759
+ lines.push("Behavior with no requirement link. Add a `ticket` to each so it appears against a requirement.");
15760
+ lines.push("");
15761
+ lines.push("| Status | Scenario | Source |");
15762
+ lines.push("| --- | --- | --- |");
15763
+ for (const scenario of matrix.untraced) {
15764
+ const source = `${scenario.sourceFile}:${scenario.sourceLine}`;
15765
+ lines.push(`| ${scenario.status} | ${escapePipe2(scenario.title)} | \`${source}\` |`);
15766
+ }
15767
+ lines.push("");
15768
+ }
15769
+ return lines.join("\n").trimEnd();
15770
+ }
15771
+ };
15772
+ function toTraceabilityMatrix(run) {
15773
+ const sorted = [...run.testCases].sort((a, b) => a.id.localeCompare(b.id));
15774
+ const byTicket = /* @__PURE__ */ new Map();
15775
+ const untraced = [];
15776
+ for (const tc of sorted) {
15777
+ const tickets = tc.story.tickets ?? [];
15778
+ if (tickets.length === 0) {
15779
+ untraced.push({
15780
+ id: tc.id,
15781
+ title: tc.story.scenario,
15782
+ status: tc.status,
15783
+ sourceFile: tc.sourceFile,
15784
+ sourceLine: tc.sourceLine
15785
+ });
15786
+ continue;
15787
+ }
15788
+ for (const ticket of tickets) {
15789
+ const entry = byTicket.get(ticket.id) ?? { url: ticket.url, cases: [] };
15790
+ if (!entry.url && ticket.url) entry.url = ticket.url;
15791
+ entry.cases.push(tc);
15792
+ byTicket.set(ticket.id, entry);
15793
+ }
15794
+ }
15795
+ const requirements = [...byTicket.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([ticket, entry]) => {
15796
+ const scenarios = entry.cases.map((tc) => ({
15797
+ id: tc.id,
15798
+ title: tc.story.scenario,
15799
+ status: tc.status,
15800
+ sourceFile: tc.sourceFile,
15801
+ sourceLine: tc.sourceLine,
15802
+ covers: tc.story.covers ?? []
15803
+ }));
15804
+ const covers = [...new Set(scenarios.flatMap((s) => s.covers))].sort();
15805
+ return { ticket, url: entry.url, status: requirementStatus(entry.cases), scenarios, covers };
15806
+ });
15807
+ return {
15808
+ schemaVersion: "1.0",
15809
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
15810
+ run: {
15811
+ startedAt: new Date(run.startedAtMs).toISOString(),
15812
+ finishedAt: new Date(run.finishedAtMs).toISOString(),
15813
+ gitSha: run.gitSha,
15814
+ branch: run.ci?.branch
15815
+ },
15816
+ summary: {
15817
+ requirements: requirements.length,
15818
+ requirementsVerified: requirements.filter((r) => r.status === "verified").length,
15819
+ requirementsFailing: requirements.filter((r) => r.status === "failing").length,
15820
+ scenarios: run.testCases.length,
15821
+ untracedScenarios: untraced.length
15822
+ },
15823
+ requirements,
15824
+ untraced
15825
+ };
15826
+ }
15827
+ function requirementStatus(cases) {
15828
+ if (cases.some((tc) => tc.status === "failed")) return "failing";
15829
+ if (cases.some((tc) => tc.status === "passed")) return "verified";
15830
+ return "incomplete";
15831
+ }
15832
+ function renderRequirementStatus(status) {
15833
+ switch (status) {
15834
+ case "verified":
15835
+ return "verified (all scenarios passed)";
15836
+ case "failing":
15837
+ return "failing (a scenario failed)";
15838
+ default:
15839
+ return "incomplete (no scenario passed yet)";
15840
+ }
15841
+ }
15842
+ function escapePipe2(value) {
15843
+ return value.replace(/\|/g, "\\|");
15844
+ }
15845
+
15522
15846
  // src/formatters/cucumber-messages/synthesize-feature.ts
15523
15847
  function extractFeatureName(testCases, uri) {
15524
15848
  for (const tc of testCases) {
@@ -16588,6 +16912,8 @@ function formatDocEntry(doc) {
16588
16912
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16589
16913
  case "video":
16590
16914
  return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16915
+ case "html":
16916
+ return `${doc.title ? `${escapeHtml2(doc.title)}: ` : ""}${escapeHtml2(doc.url ?? doc.path ?? "(inline html)")}`;
16591
16917
  case "custom":
16592
16918
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16593
16919
  }
@@ -17046,6 +17372,8 @@ function formatDocEntry2(doc) {
17046
17372
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17047
17373
  case "video":
17048
17374
  return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17375
+ case "html":
17376
+ return `${doc.title ? `${doc.title}: ` : ""}${doc.url ?? doc.path ?? "(inline html)"}`;
17049
17377
  case "custom":
17050
17378
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17051
17379
  }
@@ -17195,7 +17523,7 @@ import * as path5 from "path";
17195
17523
  function scanHtmlAssets(html) {
17196
17524
  const seen = /* @__PURE__ */ new Set();
17197
17525
  const patterns = [
17198
- /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17526
+ /<(?:img|video|iframe)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17199
17527
  /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
17200
17528
  /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
17201
17529
  ];
@@ -17274,7 +17602,7 @@ function bundleAssets(htmlPath, options = {}) {
17274
17602
  function replaceAssetRef(html, original, replacement) {
17275
17603
  const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17276
17604
  const srcPattern = new RegExp(
17277
- `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17605
+ `(<(?:img|video|iframe)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17278
17606
  "g"
17279
17607
  );
17280
17608
  html = html.replace(srcPattern, `$1${replacement}$2`);
@@ -17662,6 +17990,21 @@ ${tc.errorStack}` : "");
17662
17990
  ])
17663
17991
  );
17664
17992
  break;
17993
+ case "html":
17994
+ if (entry.url !== void 0 || entry.path !== void 0) {
17995
+ const target = entry.url ?? entry.path ?? "";
17996
+ content.push(
17997
+ paragraph([
17998
+ text(entry.title ?? "Embedded HTML", strong()),
17999
+ text(": "),
18000
+ link(target, target)
18001
+ ])
18002
+ );
18003
+ break;
18004
+ }
18005
+ content.push(paragraph([text(entry.title ?? "Embedded HTML", strong())]));
18006
+ content.push(codeBlock(entry.content ?? "", "html"));
18007
+ break;
17665
18008
  case "custom":
17666
18009
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17667
18010
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -19378,6 +19721,27 @@ function resolveTraceUrl(template, traceId) {
19378
19721
  return template.replace(/\{traceId\}/g, traceId);
19379
19722
  }
19380
19723
 
19724
+ // src/utils/doc-builders.ts
19725
+ function buildHtmlDocEntry(options) {
19726
+ const sources = [options.path, options.url, options.content].filter(
19727
+ (v) => v !== void 0
19728
+ );
19729
+ if (sources.length !== 1) {
19730
+ throw new Error(
19731
+ "story.html() requires exactly one of path, url, or content"
19732
+ );
19733
+ }
19734
+ return {
19735
+ kind: "html",
19736
+ path: options.path,
19737
+ url: options.url,
19738
+ content: options.content,
19739
+ title: options.title,
19740
+ height: options.height,
19741
+ phase: "runtime"
19742
+ };
19743
+ }
19744
+
19381
19745
  // src/notifiers/slack.ts
19382
19746
  function truncate(text2, maxLen) {
19383
19747
  if (text2.length <= maxLen) return text2;
@@ -20226,6 +20590,280 @@ function collectDocKinds(testCase) {
20226
20590
  return [...kinds].sort();
20227
20591
  }
20228
20592
 
20593
+ // src/scenario-failure.ts
20594
+ function failingScenarioMessage(tc) {
20595
+ const failingStep = tc.stepResults.find((s) => s.status === "failed" && s.errorMessage);
20596
+ return failingStep?.errorMessage ?? tc.errorMessage;
20597
+ }
20598
+
20599
+ // src/check.ts
20600
+ var ICON_PASS = "\u2713";
20601
+ var ICON_FAIL = "\u2717";
20602
+ var ICON_SKIP = "\u2298";
20603
+ var ICON_PENDING = "\u23F3";
20604
+ var ICON_WARN = "\u26A0";
20605
+ function buildCheck(args, _deps = {}) {
20606
+ const { testCases, baseline } = args;
20607
+ const summary = {
20608
+ total: testCases.length,
20609
+ passed: testCases.filter((tc) => tc.status === "passed").length,
20610
+ failed: testCases.filter((tc) => tc.status === "failed").length,
20611
+ skipped: testCases.filter((tc) => tc.status === "skipped").length,
20612
+ pending: testCases.filter((tc) => tc.status === "pending").length
20613
+ };
20614
+ let regressed = 0;
20615
+ let fixed = 0;
20616
+ if (baseline) {
20617
+ for (const tc of testCases) {
20618
+ const before = baseline.get(tc.id);
20619
+ if (before === "passed" && tc.status === "failed") regressed += 1;
20620
+ if (before === "failed" && tc.status === "passed") fixed += 1;
20621
+ }
20622
+ }
20623
+ const failures = testCases.filter((tc) => tc.status === "failed").map((tc) => toFailure(tc, baseline)).sort((a, b) => {
20624
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20625
+ return a.location.localeCompare(b.location);
20626
+ });
20627
+ return {
20628
+ summary,
20629
+ failures,
20630
+ regressed,
20631
+ fixed,
20632
+ comparedToBaseline: baseline !== void 0
20633
+ };
20634
+ }
20635
+ function toFailure(tc, baseline) {
20636
+ const failedIndexes = new Set(
20637
+ tc.stepResults.filter((s) => s.status === "failed").map((s) => s.index)
20638
+ );
20639
+ const steps = tc.story.steps.map((step, index) => ({
20640
+ keyword: step.keyword,
20641
+ text: step.text,
20642
+ failed: failedIndexes.has(index)
20643
+ }));
20644
+ return {
20645
+ id: tc.id,
20646
+ scenario: tc.story.scenario,
20647
+ location: `${tc.sourceFile}:${tc.sourceLine}`,
20648
+ steps,
20649
+ errorMessage: failingScenarioMessage(tc),
20650
+ covers: tc.story.covers ?? [],
20651
+ tickets: (tc.story.tickets ?? []).map((t) => t.id),
20652
+ regressed: baseline?.get(tc.id) === "passed"
20653
+ };
20654
+ }
20655
+ function renderCheck(report, format) {
20656
+ return format === "json" ? JSON.stringify(report, null, 2) : renderCheckText(report);
20657
+ }
20658
+ function renderCheckText(report) {
20659
+ const { summary, failures } = report;
20660
+ const headlineParts = [`${ICON_PASS} ${summary.passed} passed`];
20661
+ if (summary.failed > 0) headlineParts.push(`${ICON_FAIL} ${summary.failed} failed`);
20662
+ if (summary.skipped > 0) headlineParts.push(`${ICON_SKIP} ${summary.skipped} skipped`);
20663
+ if (summary.pending > 0) headlineParts.push(`${ICON_PENDING} ${summary.pending} pending`);
20664
+ const headline = `${headlineParts.join(" ")} (${summary.total} scenarios)`;
20665
+ if (failures.length === 0) {
20666
+ const lines2 = [headline];
20667
+ if (report.comparedToBaseline && report.fixed > 0) {
20668
+ lines2.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20669
+ }
20670
+ lines2.push("All scenarios green.");
20671
+ return lines2.join("\n");
20672
+ }
20673
+ const lines = [headline, ""];
20674
+ for (const f of failures) {
20675
+ lines.push(`${ICON_FAIL} ${f.scenario}${f.regressed ? " (regressed)" : ""}`);
20676
+ lines.push(` ${f.location}`);
20677
+ for (const step of f.steps) {
20678
+ const marker = step.failed ? ` ${ICON_FAIL} ` : " ";
20679
+ lines.push(`${marker}${step.keyword} ${step.text}`);
20680
+ }
20681
+ if (f.errorMessage) {
20682
+ const firstLine = f.errorMessage.split("\n")[0];
20683
+ lines.push(` \u2192 ${firstLine}`);
20684
+ }
20685
+ if (f.covers.length > 0) {
20686
+ lines.push(` covers: ${f.covers.join(", ")}`);
20687
+ }
20688
+ if (f.tickets.length > 0) {
20689
+ lines.push(` ticket: ${f.tickets.join(", ")}`);
20690
+ }
20691
+ lines.push("");
20692
+ }
20693
+ if (report.comparedToBaseline) {
20694
+ if (report.regressed > 0) {
20695
+ lines.push(`${ICON_WARN} ${report.regressed} regressed since baseline (was passing).`);
20696
+ }
20697
+ if (report.fixed > 0) {
20698
+ lines.push(`${ICON_PASS} ${report.fixed} fixed since baseline.`);
20699
+ }
20700
+ if (report.regressed === 0 && report.fixed === 0) {
20701
+ lines.push("No status changes vs. baseline.");
20702
+ }
20703
+ }
20704
+ return lines.join("\n").trimEnd();
20705
+ }
20706
+
20707
+ // src/goal.ts
20708
+ var ACTIVE = ["passed", "failed"];
20709
+ function buildGoal(args, _deps = {}) {
20710
+ const { run, baseline } = args;
20711
+ const cases = run.testCases;
20712
+ const selectors = [
20713
+ ...args.requireTags.map((tag) => ({ label: `tag:${tag}`, match: (tc) => tc.tags.includes(tag) })),
20714
+ ...args.requireTickets.map((id) => ({ label: `ticket:${id}`, match: (tc) => (tc.story.tickets ?? []).some((t) => t.id === id) })),
20715
+ ...args.requireScenarios.map((sel) => ({ label: `scenario:${sel}`, match: (tc) => tc.id === sel || tc.story.scenario === sel }))
20716
+ ];
20717
+ const requirements = selectors.length === 0 ? [evaluate("all scenarios", cases)] : selectors.map((s) => evaluate(s.label, cases.filter(s.match)));
20718
+ const regressions = [];
20719
+ if (baseline && args.enforceNoRegressions) {
20720
+ const before = statusMap(baseline);
20721
+ for (const tc of cases) {
20722
+ if (before.get(tc.id) === "passed" && tc.status === "failed") {
20723
+ regressions.push({ id: tc.id, title: tc.story.scenario });
20724
+ }
20725
+ }
20726
+ }
20727
+ const violations = [];
20728
+ if (baseline && args.enforceRatchet) {
20729
+ const current = new Map(cases.map((tc) => [tc.id, tc]));
20730
+ for (const base of baseline.testCases) {
20731
+ const now = current.get(base.id);
20732
+ if (!now) {
20733
+ violations.push({ id: base.id, title: base.story.scenario, kind: "removed", detail: "scenario no longer present" });
20734
+ continue;
20735
+ }
20736
+ if (ACTIVE.includes(base.status) && (now.status === "skipped" || now.status === "pending")) {
20737
+ violations.push({ id: base.id, title: base.story.scenario, kind: "disabled", detail: `${base.status} -> ${now.status}` });
20738
+ }
20739
+ const baseSteps = base.story.steps.length;
20740
+ const nowSteps = now.story.steps.length;
20741
+ if (nowSteps < baseSteps) {
20742
+ violations.push({ id: base.id, title: base.story.scenario, kind: "weakened", detail: `${baseSteps} steps -> ${nowSteps} steps` });
20743
+ }
20744
+ }
20745
+ }
20746
+ const met = requirements.every((r) => r.met) && regressions.length === 0 && violations.length === 0;
20747
+ return {
20748
+ met,
20749
+ requirements,
20750
+ regressions,
20751
+ regressionsEnforced: Boolean(baseline && args.enforceNoRegressions),
20752
+ ratchet: { enforced: Boolean(baseline && args.enforceRatchet), violations }
20753
+ };
20754
+ }
20755
+ function evaluate(selector, matched) {
20756
+ const passed = matched.filter((tc) => tc.status === "passed").length;
20757
+ const failing = matched.filter((tc) => tc.status !== "passed").map((tc) => tc.story.scenario);
20758
+ return {
20759
+ selector,
20760
+ matched: matched.length,
20761
+ passed,
20762
+ failing,
20763
+ met: matched.length > 0 && failing.length === 0
20764
+ };
20765
+ }
20766
+ function statusMap(run) {
20767
+ return new Map(run.testCases.map((tc) => [tc.id, tc.status]));
20768
+ }
20769
+ function renderGoal(report, format) {
20770
+ if (format === "json") return JSON.stringify(report, null, 2);
20771
+ const lines = [`GOAL: ${report.met ? "met" : "not met"}`];
20772
+ for (const req of report.requirements) {
20773
+ if (req.matched === 0) {
20774
+ lines.push(` ${req.selector}: no matching scenario (no proof)`);
20775
+ continue;
20776
+ }
20777
+ const tail = req.failing.length > 0 ? ` (${req.failing.length} failing)` : "";
20778
+ lines.push(` ${req.selector}: ${req.passed}/${req.matched} scenarios pass${tail}`);
20779
+ }
20780
+ if (report.regressionsEnforced) {
20781
+ if (report.regressions.length === 0) {
20782
+ lines.push(" regressions: 0");
20783
+ } else {
20784
+ lines.push(` regressions: ${report.regressions.length} (${report.regressions.map((r) => r.title).join(", ")})`);
20785
+ }
20786
+ }
20787
+ if (report.ratchet.enforced) {
20788
+ if (report.ratchet.violations.length === 0) {
20789
+ lines.push(" ratchet: clean (0 scenarios removed/weakened)");
20790
+ } else {
20791
+ lines.push(` ratchet: ${report.ratchet.violations.length} removed/weakened`);
20792
+ for (const v of report.ratchet.violations) {
20793
+ lines.push(` ${v.kind}: ${v.title} (${v.detail})`);
20794
+ }
20795
+ }
20796
+ }
20797
+ return lines.join("\n");
20798
+ }
20799
+
20800
+ // src/triage.ts
20801
+ function buildTriage(args, _deps = {}) {
20802
+ const { testCases, baseline } = args;
20803
+ const failing = testCases.filter((tc) => tc.status === "failed");
20804
+ const ranked = failing.map((tc) => {
20805
+ const regressed = baseline?.get(tc.id) === "passed";
20806
+ return {
20807
+ tc,
20808
+ regressed,
20809
+ covers: tc.story.covers ?? []
20810
+ };
20811
+ }).sort((a, b) => {
20812
+ if (a.regressed !== b.regressed) return a.regressed ? -1 : 1;
20813
+ const la = `${a.tc.sourceFile}:${a.tc.sourceLine}`;
20814
+ const lb = `${b.tc.sourceFile}:${b.tc.sourceLine}`;
20815
+ return la.localeCompare(lb);
20816
+ });
20817
+ const items = ranked.map((entry, index) => ({
20818
+ rank: index + 1,
20819
+ id: entry.tc.id,
20820
+ scenario: entry.tc.story.scenario,
20821
+ status: entry.tc.status,
20822
+ location: `${entry.tc.sourceFile}:${entry.tc.sourceLine}`,
20823
+ covers: entry.covers,
20824
+ tickets: (entry.tc.story.tickets ?? []).map((t) => t.id),
20825
+ errorMessage: failingScenarioMessage(entry.tc),
20826
+ regressed: entry.regressed,
20827
+ reason: entry.regressed ? "regression" : "failing"
20828
+ }));
20829
+ return {
20830
+ total: testCases.length,
20831
+ failing: failing.length,
20832
+ regressions: items.filter((i) => i.regressed).length,
20833
+ needsCovers: items.filter((i) => i.covers.length === 0).length,
20834
+ items
20835
+ };
20836
+ }
20837
+ function renderTriage(report, format) {
20838
+ if (format === "json") return JSON.stringify(report, null, 2);
20839
+ if (report.items.length === 0) {
20840
+ return "Nothing to triage. No failing scenarios.";
20841
+ }
20842
+ const header = report.regressions > 0 ? `${report.items.length} items to triage (${report.regressions} regression${report.regressions === 1 ? "" : "s"})` : `${report.items.length} items to triage`;
20843
+ const lines = [header, ""];
20844
+ for (const item of report.items) {
20845
+ const tag = item.regressed ? "[regression] " : "";
20846
+ lines.push(`${item.rank}. ${tag}${item.scenario}`);
20847
+ lines.push(` ${item.location}`);
20848
+ if (item.errorMessage) {
20849
+ lines.push(` \u2192 ${item.errorMessage.split("\n")[0]}`);
20850
+ }
20851
+ if (item.covers.length > 0) {
20852
+ lines.push(` fix: ${item.covers.join(", ")}`);
20853
+ } else {
20854
+ lines.push(" fix: (no covers declared \u2014 add `covers` to route this to code)");
20855
+ }
20856
+ if (item.tickets.length > 0) {
20857
+ lines.push(` ticket: ${item.tickets.join(", ")}`);
20858
+ }
20859
+ lines.push("");
20860
+ }
20861
+ if (report.needsCovers > 0) {
20862
+ lines.push(`${report.needsCovers} failing scenario(s) have no covers and can't be routed to code automatically.`);
20863
+ }
20864
+ return lines.join("\n").trimEnd();
20865
+ }
20866
+
20229
20867
  // src/review/conventions.ts
20230
20868
  var CHANGE_TAG_PREFIX = "change:";
20231
20869
  var AUDIENCE_TAG_PREFIX = "audience:";
@@ -21027,6 +21665,7 @@ var FORMAT_EXTENSIONS = {
21027
21665
  "behavior-manifest-json": ".behavior-manifest.json",
21028
21666
  markdown: ".md",
21029
21667
  "release-manifest": ".release-manifest.md",
21668
+ "traceability-matrix": ".traceability-matrix.md",
21030
21669
  html: ".html",
21031
21670
  "cucumber-html": ".cucumber.html",
21032
21671
  junit: ".junit.xml",
@@ -21187,6 +21826,9 @@ var ReportGenerator = class {
21187
21826
  searchable: options.html?.searchable ?? true,
21188
21827
  startCollapsed: options.html?.startCollapsed ?? false,
21189
21828
  embedScreenshots: options.html?.embedScreenshots ?? true,
21829
+ // Under "copy" asset mode local html files become hashed assets with
21830
+ // an iframe src instead of being inlined into the report.
21831
+ embedHtmlFiles: options.html?.embedHtmlFiles ?? (options.assetMode ?? "none") !== "copy",
21190
21832
  syntaxHighlighting: options.html?.syntaxHighlighting ?? true,
21191
21833
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
21192
21834
  markdownEnabled: options.html?.markdownEnabled ?? true,
@@ -21362,6 +22004,7 @@ var ReportGenerator = class {
21362
22004
  searchable: this.options.html.searchable,
21363
22005
  startCollapsed: this.options.html.startCollapsed,
21364
22006
  embedScreenshots: this.options.html.embedScreenshots,
22007
+ embedHtmlFiles: this.options.html.embedHtmlFiles,
21365
22008
  syntaxHighlighting: this.options.html.syntaxHighlighting,
21366
22009
  mermaidEnabled: this.options.html.mermaidEnabled,
21367
22010
  markdownEnabled: this.options.html.markdownEnabled,
@@ -21449,6 +22092,10 @@ var ReportGenerator = class {
21449
22092
  const formatter = new ReleaseManifestFormatter();
21450
22093
  return formatter.format(run);
21451
22094
  }
22095
+ case "traceability-matrix": {
22096
+ const formatter = new TraceabilityMatrixFormatter();
22097
+ return formatter.format(run);
22098
+ }
21452
22099
  case "story-report-json": {
21453
22100
  const formatter = new StoryReportJsonFormatter({
21454
22101
  pretty: this.options.storyReportJson.pretty
@@ -21528,11 +22175,16 @@ export {
21528
22175
  STORY_REPORT_SCHEMA_VERSION,
21529
22176
  ScenarioIndexJsonFormatter,
21530
22177
  StoryReportJsonFormatter,
22178
+ TraceabilityMatrixFormatter,
21531
22179
  adaptJestRun,
21532
22180
  adaptPlaywrightRun,
21533
22181
  adaptVitestRun,
21534
22182
  assertValidRun,
22183
+ buildCheck,
22184
+ buildGoal,
22185
+ buildHtmlDocEntry,
21535
22186
  buildReview,
22187
+ buildTriage,
21536
22188
  bundleAssets,
21537
22189
  calculateFlakiness,
21538
22190
  calculateStability,
@@ -21582,6 +22234,9 @@ export {
21582
22234
  readPackageVersion,
21583
22235
  recordDeployment,
21584
22236
  regenerateArtifacts,
22237
+ renderCheck,
22238
+ renderGoal,
22239
+ renderTriage,
21585
22240
  resolveAttachment,
21586
22241
  resolveAttachments,
21587
22242
  resolveTheme,
@@ -21603,6 +22258,7 @@ export {
21603
22258
  toReleaseManifest,
21604
22259
  toScenarioIndex,
21605
22260
  toStoryReport,
22261
+ toTraceabilityMatrix,
21606
22262
  tryGetActiveOtelContext,
21607
22263
  updateHistory,
21608
22264
  validateCanonicalRun