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.
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
  }
@@ -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;
@@ -16588,6 +16786,8 @@ function formatDocEntry(doc) {
16588
16786
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16589
16787
  case "video":
16590
16788
  return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16789
+ case "html":
16790
+ return `${doc.title ? `${escapeHtml2(doc.title)}: ` : ""}${escapeHtml2(doc.url ?? doc.path ?? "(inline html)")}`;
16591
16791
  case "custom":
16592
16792
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16593
16793
  }
@@ -17046,6 +17246,8 @@ function formatDocEntry2(doc) {
17046
17246
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17047
17247
  case "video":
17048
17248
  return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17249
+ case "html":
17250
+ return `${doc.title ? `${doc.title}: ` : ""}${doc.url ?? doc.path ?? "(inline html)"}`;
17049
17251
  case "custom":
17050
17252
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17051
17253
  }
@@ -17195,7 +17397,7 @@ import * as path5 from "path";
17195
17397
  function scanHtmlAssets(html) {
17196
17398
  const seen = /* @__PURE__ */ new Set();
17197
17399
  const patterns = [
17198
- /<(?:img|video)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17400
+ /<(?:img|video|iframe)\b[^>]*?\bsrc=["']([^"']+)["']/g,
17199
17401
  /<a\b[^>]*?\bclass=["']attachment["'][^>]*?\bhref=["']([^"']+)["']/g,
17200
17402
  /<a\b[^>]*?\bhref=["']([^"']+)["'][^>]*?\bclass=["']attachment["']/g
17201
17403
  ];
@@ -17274,7 +17476,7 @@ function bundleAssets(htmlPath, options = {}) {
17274
17476
  function replaceAssetRef(html, original, replacement) {
17275
17477
  const escaped = original.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17276
17478
  const srcPattern = new RegExp(
17277
- `(<(?:img|video)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17479
+ `(<(?:img|video|iframe)\\b[^>]*?\\bsrc=["'])${escaped}(["'])`,
17278
17480
  "g"
17279
17481
  );
17280
17482
  html = html.replace(srcPattern, `$1${replacement}$2`);
@@ -17662,6 +17864,21 @@ ${tc.errorStack}` : "");
17662
17864
  ])
17663
17865
  );
17664
17866
  break;
17867
+ case "html":
17868
+ if (entry.url !== void 0 || entry.path !== void 0) {
17869
+ const target = entry.url ?? entry.path ?? "";
17870
+ content.push(
17871
+ paragraph([
17872
+ text(entry.title ?? "Embedded HTML", strong()),
17873
+ text(": "),
17874
+ link(target, target)
17875
+ ])
17876
+ );
17877
+ break;
17878
+ }
17879
+ content.push(paragraph([text(entry.title ?? "Embedded HTML", strong())]));
17880
+ content.push(codeBlock(entry.content ?? "", "html"));
17881
+ break;
17665
17882
  case "custom":
17666
17883
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17667
17884
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -19378,6 +19595,27 @@ function resolveTraceUrl(template, traceId) {
19378
19595
  return template.replace(/\{traceId\}/g, traceId);
19379
19596
  }
19380
19597
 
19598
+ // src/utils/doc-builders.ts
19599
+ function buildHtmlDocEntry(options) {
19600
+ const sources = [options.path, options.url, options.content].filter(
19601
+ (v) => v !== void 0
19602
+ );
19603
+ if (sources.length !== 1) {
19604
+ throw new Error(
19605
+ "story.html() requires exactly one of path, url, or content"
19606
+ );
19607
+ }
19608
+ return {
19609
+ kind: "html",
19610
+ path: options.path,
19611
+ url: options.url,
19612
+ content: options.content,
19613
+ title: options.title,
19614
+ height: options.height,
19615
+ phase: "runtime"
19616
+ };
19617
+ }
19618
+
19381
19619
  // src/notifiers/slack.ts
19382
19620
  function truncate(text2, maxLen) {
19383
19621
  if (text2.length <= maxLen) return text2;
@@ -21187,6 +21425,9 @@ var ReportGenerator = class {
21187
21425
  searchable: options.html?.searchable ?? true,
21188
21426
  startCollapsed: options.html?.startCollapsed ?? false,
21189
21427
  embedScreenshots: options.html?.embedScreenshots ?? true,
21428
+ // Under "copy" asset mode local html files become hashed assets with
21429
+ // an iframe src instead of being inlined into the report.
21430
+ embedHtmlFiles: options.html?.embedHtmlFiles ?? (options.assetMode ?? "none") !== "copy",
21190
21431
  syntaxHighlighting: options.html?.syntaxHighlighting ?? true,
21191
21432
  mermaidEnabled: options.html?.mermaidEnabled ?? true,
21192
21433
  markdownEnabled: options.html?.markdownEnabled ?? true,
@@ -21362,6 +21603,7 @@ var ReportGenerator = class {
21362
21603
  searchable: this.options.html.searchable,
21363
21604
  startCollapsed: this.options.html.startCollapsed,
21364
21605
  embedScreenshots: this.options.html.embedScreenshots,
21606
+ embedHtmlFiles: this.options.html.embedHtmlFiles,
21365
21607
  syntaxHighlighting: this.options.html.syntaxHighlighting,
21366
21608
  mermaidEnabled: this.options.html.mermaidEnabled,
21367
21609
  markdownEnabled: this.options.html.markdownEnabled,
@@ -21532,6 +21774,7 @@ export {
21532
21774
  adaptPlaywrightRun,
21533
21775
  adaptVitestRun,
21534
21776
  assertValidRun,
21777
+ buildHtmlDocEntry,
21535
21778
  buildReview,
21536
21779
  bundleAssets,
21537
21780
  calculateFlakiness,