executable-stories-formatters 0.10.0 → 0.11.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.
@@ -113,6 +113,13 @@ type DocEntry = {
113
113
  alt?: string;
114
114
  phase: DocPhase;
115
115
  children?: DocEntry[];
116
+ } | {
117
+ kind: "video";
118
+ path: string;
119
+ caption?: string;
120
+ poster?: string;
121
+ phase: DocPhase;
122
+ children?: DocEntry[];
116
123
  } | {
117
124
  kind: "custom";
118
125
  type: string;
@@ -113,6 +113,13 @@ type DocEntry = {
113
113
  alt?: string;
114
114
  phase: DocPhase;
115
115
  children?: DocEntry[];
116
+ } | {
117
+ kind: "video";
118
+ path: string;
119
+ caption?: string;
120
+ poster?: string;
121
+ phase: DocPhase;
122
+ children?: DocEntry[];
116
123
  } | {
117
124
  kind: "custom";
118
125
  type: string;
package/dist/index.cjs CHANGED
@@ -87,6 +87,7 @@ __export(src_exports, {
87
87
  hasSufficientHistory: () => hasSufficientHistory,
88
88
  isReviewableSource: () => isReviewableSource,
89
89
  isTestFile: () => isTestFile,
90
+ joinNameAndExt: () => joinNameAndExt,
90
91
  listScenarios: () => listScenarios,
91
92
  loadHistory: () => loadHistory,
92
93
  mergeStepResults: () => mergeStepResults,
@@ -893,6 +894,15 @@ function copyDocEntry(entry) {
893
894
  phase: entry.phase,
894
895
  ...children
895
896
  };
897
+ case "video":
898
+ return {
899
+ kind: "video",
900
+ path: entry.path,
901
+ ...entry.caption ? { caption: entry.caption } : {},
902
+ ...entry.poster ? { poster: entry.poster } : {},
903
+ phase: entry.phase,
904
+ ...children
905
+ };
896
906
  case "custom":
897
907
  return {
898
908
  kind: "custom",
@@ -14054,6 +14064,23 @@ function renderDocScreenshot(entry, deps) {
14054
14064
  ${entry.alt ? `<div class="doc-screenshot-caption">${deps.escapeHtml(entry.alt)}</div>` : ""}
14055
14065
  </div>`;
14056
14066
  }
14067
+ function renderDocVideo(entry, deps) {
14068
+ const isRemote = /^(?:https?:|data:)/i.test(entry.path);
14069
+ const isAbsoluteFsPath = !isRemote && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(entry.path);
14070
+ const captionHtml = entry.caption ? `<div class="doc-video-caption">${deps.escapeHtml(entry.caption)}</div>` : "";
14071
+ if ((deps.embedScreenshots ?? true) && isAbsoluteFsPath) {
14072
+ return `<div class="doc-video doc-video-missing">
14073
+ <div class="doc-video-missing-label">Video unavailable</div>
14074
+ <div class="doc-video-missing-path">${deps.escapeHtml(entry.path)}</div>
14075
+ ${captionHtml}
14076
+ </div>`;
14077
+ }
14078
+ const poster = entry.poster ? ` poster="${deps.escapeHtml(entry.poster)}"` : "";
14079
+ return `<div class="doc-video">
14080
+ <video class="doc-video-player" controls preload="metadata"${poster} src="${deps.escapeHtml(entry.path)}"></video>
14081
+ ${captionHtml}
14082
+ </div>`;
14083
+ }
14057
14084
  function renderDocCustom(entry, deps) {
14058
14085
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
14059
14086
  const data = entry.data;
@@ -14107,6 +14134,9 @@ function renderDocEntry(entry, deps) {
14107
14134
  case "screenshot":
14108
14135
  html = renderDocScreenshot(entry, deps);
14109
14136
  break;
14137
+ case "video":
14138
+ html = renderDocVideo(entry, deps);
14139
+ break;
14110
14140
  case "custom":
14111
14141
  html = renderDocCustom(entry, deps);
14112
14142
  break;
@@ -15433,6 +15463,19 @@ var MarkdownFormatter = class {
15433
15463
  case "screenshot":
15434
15464
  lines.push(`${indent}![${entry.alt ?? "Screenshot"}](${entry.path})`);
15435
15465
  break;
15466
+ case "video": {
15467
+ const poster = entry.poster ? ` poster="${entry.poster}"` : "";
15468
+ lines.push(`${indent}`);
15469
+ lines.push(`${indent}<video controls preload="metadata"${poster} class="doc-video">`);
15470
+ lines.push(`${indent} <source src="${entry.path}" />`);
15471
+ lines.push(`${indent}</video>`);
15472
+ if (entry.caption) {
15473
+ lines.push(`${indent}`);
15474
+ lines.push(`${indent}*${entry.caption}*`);
15475
+ }
15476
+ lines.push(`${indent}`);
15477
+ break;
15478
+ }
15436
15479
  case "custom":
15437
15480
  if (entry.type === "visual" && entry.data && typeof entry.data === "object") {
15438
15481
  const data = entry.data;
@@ -16592,6 +16635,8 @@ function formatDocEntry(doc) {
16592
16635
  return `${escapeHtml2(doc.title ?? "mermaid diagram")}: <code>${escapeHtml2(doc.code)}</code>`;
16593
16636
  case "screenshot":
16594
16637
  return `${doc.alt ? `${escapeHtml2(doc.alt)}: ` : ""}${escapeHtml2(doc.path)}`;
16638
+ case "video":
16639
+ return `${doc.caption ? `${escapeHtml2(doc.caption)}: ` : ""}${escapeHtml2(doc.path)}`;
16595
16640
  case "custom":
16596
16641
  return `${escapeHtml2(doc.type)}: ${escapeHtml2(JSON.stringify(doc.data))}`;
16597
16642
  }
@@ -17048,6 +17093,8 @@ function formatDocEntry2(doc) {
17048
17093
  return `${doc.title ?? "mermaid diagram"}: \`${doc.code}\``;
17049
17094
  case "screenshot":
17050
17095
  return `${doc.alt ? `${doc.alt}: ` : ""}${doc.path}`;
17096
+ case "video":
17097
+ return `${doc.caption ? `${doc.caption}: ` : ""}${doc.path}`;
17051
17098
  case "custom":
17052
17099
  return `${doc.type}: ${JSON.stringify(doc.data)}`;
17053
17100
  }
@@ -17293,19 +17340,35 @@ function replaceAssetRef(html, original, replacement) {
17293
17340
  return html;
17294
17341
  }
17295
17342
 
17343
+ // src/utils/source-file.ts
17344
+ function cleanTestStem(fileName) {
17345
+ const base = fileName.split(/[\\/]/).pop() ?? fileName;
17346
+ const stripped = base.replace(/\.(story\.)?(test|spec|cy)\.[cm]?[jt]sx?$/i, "");
17347
+ if (stripped !== base) return stripped;
17348
+ return base.replace(/\.[^.]+$/, "");
17349
+ }
17350
+ function humanizeSourceFile(fileName) {
17351
+ return cleanTestStem(fileName).split(/[-_.\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
17352
+ }
17353
+
17296
17354
  // src/formatters/astro.ts
17297
17355
  var AstroFormatter = class _AstroFormatter {
17298
17356
  markdownFormatter;
17299
17357
  title;
17358
+ perFileTitle;
17300
17359
  constructor(options = {}) {
17301
17360
  this.title = options.markdown?.title ?? "User Stories";
17361
+ this.perFileTitle = options.perFileTitle ?? false;
17302
17362
  this.markdownFormatter = new MarkdownFormatter({
17303
17363
  ...options.markdown,
17304
17364
  title: this.title,
17305
17365
  stepStyle: "gherkin",
17306
17366
  includeFrontMatter: false,
17307
17367
  includeSummaryTable: false,
17308
- includeMetadata: false
17368
+ includeMetadata: false,
17369
+ // A per-file page is one file already — group by suite/describe so the
17370
+ // body shows clean section headings, not the redundant source path.
17371
+ groupBy: this.perFileTitle ? "suite" : options.markdown?.groupBy ?? "file"
17309
17372
  });
17310
17373
  }
17311
17374
  format(run) {
@@ -17315,13 +17378,31 @@ var AstroFormatter = class _AstroFormatter {
17315
17378
  return `${frontmatter}
17316
17379
  ${body}`;
17317
17380
  }
17381
+ /**
17382
+ * Title for the page. A per-file page (one source file — i.e. colocated mode)
17383
+ * is titled by its suite/describe name, falling back to a humanized filename,
17384
+ * so the docs nav reads "Convert Currency" not "User Stories" six times over.
17385
+ * Multi-file (aggregated) pages keep the configured title.
17386
+ */
17387
+ deriveTitle(run) {
17388
+ if (!this.perFileTitle) return this.title;
17389
+ const sourceFiles = new Set(
17390
+ run.testCases.map((tc) => tc.sourceFile).filter((f) => f && f !== "unknown")
17391
+ );
17392
+ if (sourceFiles.size !== 1) return this.title;
17393
+ const suites = new Set(
17394
+ run.testCases.map((tc) => tc.titlePath?.[0]).filter((s) => Boolean(s))
17395
+ );
17396
+ if (suites.size === 1) return [...suites][0];
17397
+ return humanizeSourceFile([...sourceFiles][0]) || this.title;
17398
+ }
17318
17399
  buildFrontmatter(run) {
17319
17400
  const badge = _AstroFormatter.computeBadge(run.testCases);
17320
17401
  const count = run.testCases.length;
17321
17402
  const description = `${count} scenario${count !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
17322
17403
  const lines = [
17323
17404
  "---",
17324
- `title: ${this.title}`,
17405
+ `title: ${yamlScalar(this.deriveTitle(run))}`,
17325
17406
  `description: ${description}`,
17326
17407
  "sidebar:",
17327
17408
  " badge:",
@@ -17339,6 +17420,12 @@ ${body}`;
17339
17420
  return { text: "Passed", variant: "success" };
17340
17421
  }
17341
17422
  };
17423
+ function yamlScalar(value) {
17424
+ if (/[:#[\]{}&*!|>'"%@`]|^[\s-]|\s$/.test(value)) {
17425
+ return `'${value.replace(/'/g, "''")}'`;
17426
+ }
17427
+ return value;
17428
+ }
17342
17429
 
17343
17430
  // src/formatters/confluence.ts
17344
17431
  var ConfluenceFormatter = class {
@@ -17615,6 +17702,15 @@ ${tc.errorStack}` : "");
17615
17702
  ])
17616
17703
  );
17617
17704
  break;
17705
+ case "video":
17706
+ content.push(
17707
+ paragraph([
17708
+ text(entry.caption ?? "Video", strong()),
17709
+ text(": "),
17710
+ link(entry.path, entry.path)
17711
+ ])
17712
+ );
17713
+ break;
17618
17714
  case "custom":
17619
17715
  content.push(paragraph([text(`[${entry.type}]`, strong())]));
17620
17716
  content.push(codeBlock(JSON.stringify(entry.data ?? null, null, 2), "json"));
@@ -17784,6 +17880,13 @@ function scanMarkdownAssets(markdown) {
17784
17880
  found.add(src);
17785
17881
  }
17786
17882
  }
17883
+ const posterRe = /<video[^>]+\bposter=["']([^"']+)["'][^>]*>/gi;
17884
+ while ((match = posterRe.exec(stripped)) !== null) {
17885
+ const src = match[1].trim();
17886
+ if (isLocalPath(src)) {
17887
+ found.add(src);
17888
+ }
17889
+ }
17787
17890
  return Array.from(found);
17788
17891
  }
17789
17892
  function splitByCode(markdown) {
@@ -17834,6 +17937,19 @@ function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
17834
17937
  return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
17835
17938
  }
17836
17939
  );
17940
+ result = result.replace(
17941
+ /(<video[^>]+\bposter=["'])([^"']+)(["'][^>]*>)/gi,
17942
+ (full, pre, src, post) => {
17943
+ const trimmed = src.trim();
17944
+ if (!isLocalPath(trimmed)) return full;
17945
+ if (pathMap) {
17946
+ const mapped = pathMap.get(trimmed);
17947
+ if (mapped === void 0) return full;
17948
+ return `${pre}${assetsBaseUrl}/${mapped}${post}`;
17949
+ }
17950
+ return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
17951
+ }
17952
+ );
17837
17953
  return result;
17838
17954
  }
17839
17955
  function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
@@ -20865,6 +20981,10 @@ var FORMAT_EXTENSIONS = {
20865
20981
  "scenario-index-json": ".scenarios-index.json",
20866
20982
  "story-report-json": ".story-report.json"
20867
20983
  };
20984
+ function joinNameAndExt(name, ext) {
20985
+ const stutter = `.${name}.`;
20986
+ return ext.startsWith(stutter) ? `${name}.${ext.slice(stutter.length)}` : `${name}${ext}`;
20987
+ }
20868
20988
  var TEST_EXTENSIONS = [
20869
20989
  ".test.ts",
20870
20990
  ".test.tsx",
@@ -20890,7 +21010,7 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20890
21010
  const ext = FORMAT_EXTENSIONS[format];
20891
21011
  const effectiveName = outputName + (outputNameSuffix ?? "");
20892
21012
  if (mode === "aggregated") {
20893
- return toPosix(path10.join(baseOutputDir, `${effectiveName}${ext}`));
21013
+ return toPosix(path10.join(baseOutputDir, joinNameAndExt(effectiveName, ext)));
20894
21014
  }
20895
21015
  const normalizedSource = toPosix(sourceFile);
20896
21016
  const dirOfSource = path10.posix.dirname(normalizedSource);
@@ -20905,6 +21025,9 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
20905
21025
  if (colocatedStyle === "adjacent") {
20906
21026
  return toPosix(path10.posix.join(dirOfSource, fileName));
20907
21027
  }
21028
+ if (colocatedStyle === "flat") {
21029
+ return toPosix(path10.posix.join(baseOutputDir, `${cleanTestStem(normalizedSource)}${ext}`));
21030
+ }
20908
21031
  return toPosix(path10.posix.join(baseOutputDir, dirOfSource, fileName));
20909
21032
  }
20910
21033
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
@@ -21144,7 +21267,7 @@ var ReportGenerator = class {
21144
21267
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
21145
21268
  const ext = FORMAT_EXTENSIONS[format];
21146
21269
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
21147
- const outputPath = toPosix(path10.join(this.options.outputDir, `${effectiveName}${ext}`));
21270
+ const outputPath = toPosix(path10.join(this.options.outputDir, joinNameAndExt(effectiveName, ext)));
21148
21271
  const content = await this.formatContent(run, format);
21149
21272
  const dir = path10.dirname(outputPath);
21150
21273
  await fsPromises.mkdir(dir, { recursive: true });
@@ -21224,6 +21347,8 @@ var ReportGenerator = class {
21224
21347
  case "astro": {
21225
21348
  const formatter = new AstroFormatter({
21226
21349
  assetsBaseUrl: this.options.astro.assetsBaseUrl,
21350
+ // Colocated = one page per file, so title each by its own suite/file.
21351
+ perFileTitle: this.options.output.mode === "colocated",
21227
21352
  markdown: this.options.astro.markdown
21228
21353
  });
21229
21354
  return formatter.format(run);
@@ -21377,6 +21502,7 @@ function normalizePlaywrightResults(testResults, adapterOptions, canonicalizeOpt
21377
21502
  hasSufficientHistory,
21378
21503
  isReviewableSource,
21379
21504
  isTestFile,
21505
+ joinNameAndExt,
21380
21506
  listScenarios,
21381
21507
  loadHistory,
21382
21508
  mergeStepResults,