executable-stories-formatters 0.11.3 → 0.12.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.
@@ -120,6 +120,15 @@ type DocEntry = {
120
120
  poster?: string;
121
121
  phase: DocPhase;
122
122
  children?: DocEntry[];
123
+ } | {
124
+ kind: "html";
125
+ path?: string;
126
+ url?: string;
127
+ content?: string;
128
+ title?: string;
129
+ height?: number | string;
130
+ phase: DocPhase;
131
+ children?: DocEntry[];
123
132
  } | {
124
133
  kind: "custom";
125
134
  type: string;
@@ -120,6 +120,15 @@ type DocEntry = {
120
120
  poster?: string;
121
121
  phase: DocPhase;
122
122
  children?: DocEntry[];
123
+ } | {
124
+ kind: "html";
125
+ path?: string;
126
+ url?: string;
127
+ content?: string;
128
+ title?: string;
129
+ height?: number | string;
130
+ phase: DocPhase;
131
+ children?: DocEntry[];
123
132
  } | {
124
133
  kind: "custom";
125
134
  type: string;
package/dist/index.cjs CHANGED
@@ -59,6 +59,7 @@ __export(src_exports, {
59
59
  adaptPlaywrightRun: () => adaptPlaywrightRun,
60
60
  adaptVitestRun: () => adaptVitestRun,
61
61
  assertValidRun: () => assertValidRun,
62
+ buildHtmlDocEntry: () => buildHtmlDocEntry,
62
63
  buildReview: () => buildReview,
63
64
  bundleAssets: () => bundleAssets,
64
65
  calculateFlakiness: () => calculateFlakiness,
@@ -630,6 +631,14 @@ var CucumberJsonFormatter = class {
630
631
  }
631
632
  const embeddings = [];
632
633
  for (const doc of step.docs) {
634
+ if (doc.kind === "html" && doc.content !== void 0) {
635
+ embeddings.push({
636
+ data: Buffer.from(doc.content, "utf8").toString("base64"),
637
+ mime_type: "text/html",
638
+ name: doc.title
639
+ });
640
+ continue;
641
+ }
633
642
  if (doc.kind !== "screenshot" || !doc.path.startsWith("data:")) {
634
643
  continue;
635
644
  }
@@ -809,6 +818,17 @@ ${doc.markdown}`,
809
818
  };
810
819
  case "screenshot":
811
820
  return null;
821
+ case "html":
822
+ if (doc.url !== void 0 || doc.path !== void 0) {
823
+ return {
824
+ doc_string: {
825
+ content: `[${doc.title ?? "Embedded HTML"}](${doc.url ?? doc.path})`,
826
+ content_type: "text/markdown",
827
+ line: 0
828
+ }
829
+ };
830
+ }
831
+ return null;
812
832
  default:
813
833
  return null;
814
834
  }
@@ -908,6 +928,17 @@ function copyDocEntry(entry) {
908
928
  phase: entry.phase,
909
929
  ...children
910
930
  };
931
+ case "html":
932
+ return {
933
+ kind: "html",
934
+ ...entry.path !== void 0 ? { path: entry.path } : {},
935
+ ...entry.url !== void 0 ? { url: entry.url } : {},
936
+ ...entry.content !== void 0 ? { content: entry.content } : {},
937
+ ...entry.title !== void 0 ? { title: entry.title } : {},
938
+ ...entry.height !== void 0 ? { height: entry.height } : {},
939
+ phase: entry.phase,
940
+ ...children
941
+ };
911
942
  case "custom":
912
943
  return {
913
944
  kind: "custom",
@@ -1296,7 +1327,7 @@ function scenarioHasDocs(scenario) {
1296
1327
  var fs2 = __toESM(require("fs"), 1);
1297
1328
  var path3 = __toESM(require("path"), 1);
1298
1329
 
1299
- // src/formatters/html/template.ts
1330
+ // src/formatters/html/template-scripts.ts
1300
1331
  var JS_THEME = `
1301
1332
  // Theme management
1302
1333
  function getSystemTheme() {
@@ -2098,6 +2129,23 @@ function parseMarkdownSections(marked) {
2098
2129
  });
2099
2130
  }
2100
2131
  `;
2132
+ var JS_HTML_EMBED = `
2133
+ // Open srcdoc-embedded HTML (doc-html iframes) in a new tab via a blob URL
2134
+ function initHtmlEmbeds() {
2135
+ document.querySelectorAll('.doc-html-open-srcdoc').forEach((btn) => {
2136
+ btn.addEventListener('click', function() {
2137
+ const container = btn.closest('.doc-html');
2138
+ const iframe = container ? container.querySelector('iframe.doc-html-frame') : null;
2139
+ const html = iframe ? iframe.getAttribute('srcdoc') : null;
2140
+ if (!html) return;
2141
+ const url = URL.createObjectURL(new Blob([html], { type: 'text/html' }));
2142
+ window.open(url, '_blank', 'noopener');
2143
+ });
2144
+ });
2145
+ }
2146
+ `;
2147
+
2148
+ // src/formatters/html/template.ts
2101
2149
  function generateScript(options) {
2102
2150
  const initCalls = [];
2103
2151
  if (options.includeDarkMode) {
@@ -2115,6 +2163,7 @@ function generateScript(options) {
2115
2163
  initCalls.push("initHashScroll();");
2116
2164
  initCalls.push("initToc();");
2117
2165
  initCalls.push("initThemePicker();");
2166
+ initCalls.push("initHtmlEmbeds();");
2118
2167
  const initScript = `
2119
2168
  // Initialize on load
2120
2169
  document.addEventListener('DOMContentLoaded', () => {
@@ -2123,6 +2172,7 @@ document.addEventListener('DOMContentLoaded', () => {
2123
2172
  `;
2124
2173
  let script = options.includeDarkMode ? JS_THEME : "";
2125
2174
  script += JS_CORE;
2175
+ script += JS_HTML_EMBED;
2126
2176
  if (options.additionalJs) {
2127
2177
  script += options.additionalJs;
2128
2178
  }
@@ -3788,6 +3838,82 @@ body {
3788
3838
  opacity: 0.8;
3789
3839
  }
3790
3840
 
3841
+ /* ============================================================================
3842
+ Documentation Entries - Embedded HTML
3843
+ ============================================================================ */
3844
+ .doc-html {
3845
+ margin-bottom: 0.5rem;
3846
+ border: 1px solid var(--border);
3847
+ border-radius: calc(var(--radius) - 2px);
3848
+ overflow: hidden;
3849
+ }
3850
+
3851
+ .doc-html:last-child {
3852
+ margin-bottom: 0;
3853
+ }
3854
+
3855
+ .doc-html-header {
3856
+ display: flex;
3857
+ align-items: center;
3858
+ justify-content: space-between;
3859
+ gap: 0.5rem;
3860
+ padding: 0.375rem 0.75rem;
3861
+ background: var(--muted, transparent);
3862
+ border-bottom: 1px solid var(--border);
3863
+ }
3864
+
3865
+ .doc-html-title {
3866
+ font-size: 0.75rem;
3867
+ font-weight: 600;
3868
+ color: var(--muted-foreground);
3869
+ text-transform: uppercase;
3870
+ letter-spacing: 0.04em;
3871
+ }
3872
+
3873
+ .doc-html-open {
3874
+ font-size: 0.875rem;
3875
+ line-height: 1;
3876
+ padding: 0.125rem 0.375rem;
3877
+ border: 1px solid var(--border);
3878
+ border-radius: calc(var(--radius) - 4px);
3879
+ background: transparent;
3880
+ color: var(--muted-foreground);
3881
+ cursor: pointer;
3882
+ text-decoration: none;
3883
+ }
3884
+
3885
+ .doc-html-open:hover {
3886
+ color: var(--foreground);
3887
+ border-color: var(--foreground);
3888
+ }
3889
+
3890
+ .doc-html-frame {
3891
+ display: block;
3892
+ width: 100%;
3893
+ border: 0;
3894
+ background: #fff;
3895
+ }
3896
+
3897
+ .doc-html-missing {
3898
+ padding: 0.75rem 1rem;
3899
+ border: 1px dashed var(--border);
3900
+ background: var(--muted, transparent);
3901
+ color: var(--muted-foreground);
3902
+ font-size: 0.8125rem;
3903
+ }
3904
+
3905
+ .doc-html-missing-label {
3906
+ font-weight: 600;
3907
+ margin-bottom: 0.25rem;
3908
+ }
3909
+
3910
+ .doc-html-missing-path {
3911
+ font-family: var(--font-mono, ui-monospace, monospace);
3912
+ font-size: 0.75rem;
3913
+ word-break: break-all;
3914
+ opacity: 0.8;
3915
+ }
3916
+
3791
3917
  /* ============================================================================
3792
3918
  Documentation Entries - Visual Check
3793
3919
  ============================================================================ */
@@ -14086,6 +14212,36 @@ function renderDocVideo(entry, deps) {
14086
14212
  ${captionHtml}
14087
14213
  </div>`;
14088
14214
  }
14215
+ function resolveHtmlSource(entry, deps) {
14216
+ if (entry.url !== void 0) return { mode: "src", value: entry.url };
14217
+ if (entry.content !== void 0) return { mode: "srcdoc", value: entry.content };
14218
+ const filePath = entry.path ?? "";
14219
+ if (/^https?:/i.test(filePath)) return { mode: "src", value: filePath };
14220
+ const inlined = deps.readHtmlFile?.(filePath);
14221
+ if (inlined !== void 0) return { mode: "srcdoc", value: inlined };
14222
+ const isAbsoluteFsPath = /^(?:[/\\]|[A-Za-z]:[/\\])/.test(filePath);
14223
+ if (deps.readHtmlFile && isAbsoluteFsPath) return { mode: "missing", value: filePath };
14224
+ return { mode: "src", value: filePath };
14225
+ }
14226
+ function renderDocHtml(entry, deps) {
14227
+ const source = resolveHtmlSource(entry, deps);
14228
+ if (source.mode === "missing") {
14229
+ return `<div class="doc-html doc-html-missing">
14230
+ <div class="doc-html-missing-label">HTML unavailable</div>
14231
+ <div class="doc-html-missing-path">${deps.escapeHtml(source.value)}</div>
14232
+ </div>`;
14233
+ }
14234
+ const heightCss = typeof entry.height === "number" ? `${entry.height}px` : entry.height ?? "400px";
14235
+ 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>`;
14236
+ 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>`;
14237
+ return `<div class="doc-html">
14238
+ <div class="doc-html-header">
14239
+ <span class="doc-html-title">${deps.escapeHtml(entry.title ?? "HTML")}</span>
14240
+ ${openBtn}
14241
+ </div>
14242
+ ${frame}
14243
+ </div>`;
14244
+ }
14089
14245
  function renderDocCustom(entry, deps) {
14090
14246
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
14091
14247
  const data = entry.data;
@@ -14142,6 +14298,9 @@ function renderDocEntry(entry, deps) {
14142
14298
  case "video":
14143
14299
  html = renderDocVideo(entry, deps);
14144
14300
  break;
14301
+ case "html":
14302
+ html = renderDocHtml(entry, deps);
14303
+ break;
14145
14304
  case "custom":
14146
14305
  html = renderDocCustom(entry, deps);
14147
14306
  break;
@@ -14713,6 +14872,21 @@ function readScreenshotAsDataUri(filePath) {
14713
14872
  return void 0;
14714
14873
  }
14715
14874
  }
14875
+ var HTML_INLINE_WARN_BYTES = 1024 * 1024;
14876
+ function readHtmlFileContent(filePath) {
14877
+ try {
14878
+ if (!fs2.existsSync(filePath)) return void 0;
14879
+ const buf = fs2.readFileSync(filePath);
14880
+ if (buf.byteLength > HTML_INLINE_WARN_BYTES) {
14881
+ console.warn(
14882
+ `[executable-stories] Inlining large HTML file (${Math.round(buf.byteLength / 1024)} KiB) into the report: ${filePath}. Consider --asset-mode copy.`
14883
+ );
14884
+ }
14885
+ return buf.toString("utf8");
14886
+ } catch {
14887
+ return void 0;
14888
+ }
14889
+ }
14716
14890
  function normalizeOptions(options = {}) {
14717
14891
  return {
14718
14892
  title: options.title ?? "Test Results",
@@ -14720,6 +14894,7 @@ function normalizeOptions(options = {}) {
14720
14894
  searchable: options.searchable ?? true,
14721
14895
  startCollapsed: options.startCollapsed ?? false,
14722
14896
  embedScreenshots: options.embedScreenshots ?? true,
14897
+ embedHtmlFiles: options.embedHtmlFiles ?? true,
14723
14898
  syntaxHighlighting: options.syntaxHighlighting ?? true,
14724
14899
  mermaidEnabled: options.mermaidEnabled ?? true,
14725
14900
  markdownEnabled: options.markdownEnabled ?? true,
@@ -14738,7 +14913,10 @@ function createHtmlFormatter(options = {}) {
14738
14913
  markdownEnabled: opts.markdownEnabled,
14739
14914
  mermaidEnabled: opts.mermaidEnabled,
14740
14915
  embedScreenshots: opts.embedScreenshots,
14741
- readScreenshot: (filePath) => readScreenshotAsDataUri(filePath)
14916
+ readScreenshot: (filePath) => readScreenshotAsDataUri(filePath),
14917
+ // When html-file inlining is off (e.g. --asset-mode copy), omit the read
14918
+ // hook so doc-html iframes keep their src path for the asset bundler.
14919
+ ...opts.embedHtmlFiles ? { readHtmlFile: (filePath) => readHtmlFileContent(filePath) } : {}
14742
14920
  };
14743
14921
  const renderDocs = (docs, containerClass) => {
14744
14922
  if (!docs || docs.length === 0) return "";
@@ -15030,6 +15208,8 @@ var JUnitFormatter = class {
15030
15208
  }
15031
15209
  case "screenshot":
15032
15210
  return `${indent}Screenshot: ${entry.alt ?? entry.path}`;
15211
+ case "html":
15212
+ return `${indent}HTML: ${entry.title ?? "Embedded HTML"} (${entry.url ?? entry.path ?? "inline"})`;
15033
15213
  case "custom": {
15034
15214
  const dataStr = JSON.stringify(entry.data, null, 2);
15035
15215
  const lines = [];
@@ -15481,6 +15661,25 @@ var MarkdownFormatter = class {
15481
15661
  lines.push(`${indent}`);
15482
15662
  break;
15483
15663
  }
15664
+ case "html": {
15665
+ const htmlLabel = entry.title ?? "Embedded HTML";
15666
+ if (entry.url !== void 0 || entry.path !== void 0) {
15667
+ lines.push(`${indent}[${htmlLabel}](${entry.url ?? entry.path})`);
15668
+ break;
15669
+ }
15670
+ lines.push(`${indent}<details>`);
15671
+ lines.push(`${indent}<summary>${htmlLabel}</summary>`);
15672
+ lines.push(`${indent}`);
15673
+ lines.push(`${indent}\`\`\`html`);
15674
+ for (const line of (entry.content ?? "").split("\n")) {
15675
+ lines.push(`${indent}${line}`);
15676
+ }
15677
+ lines.push(`${indent}\`\`\``);
15678
+ lines.push(`${indent}`);
15679
+ lines.push(`${indent}</details>`);
15680
+ lines.push(`${indent}`);
15681
+ break;
15682
+ }
15484
15683
  case "custom":
15485
15684
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15486
15685
  const data = entry.data;
@@ -16724,6 +16923,8 @@ function formatDocEntry(doc) {
16724
16923
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16725
16924
  case "video":
16726
16925
  return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16926
+ case "html":
16927
+ return `${doc.title ? `${escapeHtml2(doc.title)}: ` : ""}${escapeHtml2(doc.url ?? doc.path ?? "(inline html)")}`;
16727
16928
  case "custom":
16728
16929
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16729
16930
  }
@@ -17182,6 +17383,8 @@ function formatDocEntry2(doc) {
17182
17383
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17183
17384
  case "video":
17184
17385
  return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17386
+ case "html":
17387
+ return `${doc.title ? `${doc.title}: ` : ""}${doc.url ?? doc.path ?? "(inline html)"}`;
17185
17388
  case "custom":
17186
17389
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17187
17390
  }
@@ -17331,7 +17534,7 @@ var path5 = __toESM(require("path"), 1);
17331
17534
  function scanHtmlAssets(html) {
17332
17535
  const seen = /* @__PURE__ */ new Set();
17333
17536
  const patterns = [
17334
- /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17537
+ /<(?:img|video|iframe)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17335
17538
  /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
17336
17539
  /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
17337
17540
  ];
@@ -17410,7 +17613,7 @@ function bundleAssets(htmlPath, options = {}) {
17410
17613
  function replaceAssetRef(html, original, replacement) {
17411
17614
  const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17412
17615
  const srcPattern = new RegExp(
17413
- `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17616
+ `(<(?:img|video|iframe)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17414
17617
  "g"
17415
17618
  );
17416
17619
  html = html.replace(srcPattern, `$1${replacement}$2`);
@@ -17798,6 +18001,21 @@ ${tc.errorStack}` : "");
17798
18001
  ])
17799
18002
  );
17800
18003
  break;
18004
+ case "html":
18005
+ if (entry.url !== void 0 || entry.path !== void 0) {
18006
+ const target = entry.url ?? entry.path ?? "";
18007
+ content.push(
18008
+ paragraph([
18009
+ text(entry.title ?? "Embedded HTML", strong()),
18010
+ text(": "),
18011
+ link(target, target)
18012
+ ])
18013
+ );
18014
+ break;
18015
+ }
18016
+ content.push(paragraph([text(entry.title ?? "Embedded HTML", strong())]));
18017
+ content.push(codeBlock(entry.content ?? "", "html"));
18018
+ break;
17801
18019
  case "custom":
17802
18020
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17803
18021
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -19515,6 +19733,27 @@ function resolveTraceUrl(template, traceId) {
19515
19733
  return template.replace(/\{traceId\}/g, traceId);
19516
19734
  }
19517
19735
 
19736
+ // src/utils/doc-builders.ts
19737
+ function buildHtmlDocEntry(options) {
19738
+ const sources = [options.path, options.url, options.content].filter(
19739
+ (v) => v !== void 0
19740
+ );
19741
+ if (sources.length !== 1) {
19742
+ throw new Error(
19743
+ "story.html() requires exactly one of path, url, or content"
19744
+ );
19745
+ }
19746
+ return {
19747
+ kind: "html",
19748
+ path: options.path,
19749
+ url: options.url,
19750
+ content: options.content,
19751
+ title: options.title,
19752
+ height: options.height,
19753
+ phase: "runtime"
19754
+ };
19755
+ }
19756
+
19518
19757
  // src/notifiers/slack.ts
19519
19758
  function truncate(text2, maxLen) {
19520
19759
  if (text2.length <= maxLen) return text2;
@@ -21324,6 +21563,9 @@ var ReportGenerator = class {
21324
21563
  searchable: options.html?.searchable ?? true,
21325
21564
  startCollapsed: options.html?.startCollapsed ?? false,
21326
21565
  embedScreenshots: options.html?.embedScreenshots ?? true,
21566
+ // Under "copy" asset mode local html files become hashed assets with
21567
+ // an iframe src instead of being inlined into the report.
21568
+ embedHtmlFiles: options.html?.embedHtmlFiles ?? (options.assetMode ?? "none") !== "copy",
21327
21569
  syntaxHighlighting: options.html?.syntaxHighlighting ?? true,
21328
21570
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
21329
21571
  markdownEnabled: options.html?.markdownEnabled ?? true,
@@ -21499,6 +21741,7 @@ var ReportGenerator = class {
21499
21741
  searchable: this.options.html.searchable,
21500
21742
  startCollapsed: this.options.html.startCollapsed,
21501
21743
  embedScreenshots: this.options.html.embedScreenshots,
21744
+ embedHtmlFiles: this.options.html.embedHtmlFiles,
21502
21745
  syntaxHighlighting: this.options.html.syntaxHighlighting,
21503
21746
  mermaidEnabled: this.options.html.mermaidEnabled,
21504
21747
  markdownEnabled: this.options.html.markdownEnabled,
@@ -21670,6 +21913,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21670
21913
  adaptPlaywrightRun,
21671
21914
  adaptVitestRun,
21672
21915
  assertValidRun,
21916
+ buildHtmlDocEntry,
21673
21917
  buildReview,
21674
21918
  bundleAssets,
21675
21919
  calculateFlakiness,