executable-stories-formatters 0.7.6 → 0.7.8

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/cli.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { parseArgs } from "util";
5
- import * as fs5 from "fs";
6
- import * as path5 from "path";
5
+ import * as fs7 from "fs";
6
+ import * as path7 from "path";
7
7
 
8
8
  // src/validation/schema-validator.ts
9
9
  import Ajv from "ajv/dist/2020.js";
@@ -492,17 +492,17 @@ function validateRawRun(data) {
492
492
  return { valid: true, errors: [] };
493
493
  }
494
494
  const errors = (validate.errors ?? []).map((err) => {
495
- const path6 = err.instancePath || "/";
495
+ const path8 = err.instancePath || "/";
496
496
  const message = err.message ?? "unknown error";
497
497
  if (err.keyword === "additionalProperties") {
498
498
  const extra = err.params.additionalProperty;
499
- return `${path6}: ${message} \u2014 '${extra}'`;
499
+ return `${path8}: ${message} \u2014 '${extra}'`;
500
500
  }
501
501
  if (err.keyword === "enum") {
502
502
  const allowed = err.params.allowedValues;
503
- return `${path6}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
503
+ return `${path8}: ${message} \u2014 allowed: ${JSON.stringify(allowed)}`;
504
504
  }
505
- return `${path6}: ${message}`;
505
+ return `${path8}: ${message}`;
506
506
  });
507
507
  return { valid: false, errors };
508
508
  }
@@ -966,7 +966,7 @@ ${result.errors.join("\n")}`);
966
966
 
967
967
  // src/index.ts
968
968
  import "fs";
969
- import * as path4 from "path";
969
+ import * as path5 from "path";
970
970
  import * as fsPromises from "fs/promises";
971
971
 
972
972
  // src/converters/acl/lines.ts
@@ -15671,8 +15671,8 @@ function extractDocAttachments(step) {
15671
15671
  }
15672
15672
  return attachments;
15673
15673
  }
15674
- function guessMediaType(path6) {
15675
- const lower = path6.toLowerCase();
15674
+ function guessMediaType(path8) {
15675
+ const lower = path8.toLowerCase();
15676
15676
  if (lower.endsWith(".png")) return "image/png";
15677
15677
  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
15678
15678
  if (lower.endsWith(".gif")) return "image/gif";
@@ -15813,11 +15813,11 @@ var CucumberHtmlFormatter = class {
15813
15813
  for (const envelope of envelopes) {
15814
15814
  const accepted = htmlStream.write(envelope);
15815
15815
  if (!accepted) {
15816
- await new Promise((resolve4) => htmlStream.once("drain", resolve4));
15816
+ await new Promise((resolve8) => htmlStream.once("drain", resolve8));
15817
15817
  }
15818
15818
  }
15819
- await new Promise((resolve4, reject) => {
15820
- collector.on("finish", resolve4);
15819
+ await new Promise((resolve8, reject) => {
15820
+ collector.on("finish", resolve8);
15821
15821
  collector.on("error", reject);
15822
15822
  htmlStream.end();
15823
15823
  });
@@ -16849,6 +16849,177 @@ function replaceAssetRef(html, original, replacement) {
16849
16849
  return html;
16850
16850
  }
16851
16851
 
16852
+ // src/formatters/astro.ts
16853
+ var AstroFormatter = class _AstroFormatter {
16854
+ markdownFormatter;
16855
+ title;
16856
+ constructor(options = {}) {
16857
+ this.title = options.markdown?.title ?? "User Stories";
16858
+ this.markdownFormatter = new MarkdownFormatter({
16859
+ ...options.markdown,
16860
+ title: this.title,
16861
+ stepStyle: "gherkin",
16862
+ includeFrontMatter: false,
16863
+ includeSummaryTable: false,
16864
+ includeMetadata: false
16865
+ });
16866
+ }
16867
+ format(run) {
16868
+ const markdown = this.markdownFormatter.format(run);
16869
+ const body = markdown.replace(/^# .+\n\n?/, "");
16870
+ const frontmatter = this.buildFrontmatter(run);
16871
+ return `${frontmatter}
16872
+ ${body}`;
16873
+ }
16874
+ buildFrontmatter(run) {
16875
+ const badge = _AstroFormatter.computeBadge(run.testCases);
16876
+ const count = run.testCases.length;
16877
+ const description = `${count} scenario${count !== 1 ? "s" : ""} \u2014 ${badge.text.toLowerCase()}`;
16878
+ const lines = [
16879
+ "---",
16880
+ `title: ${this.title}`,
16881
+ `description: ${description}`,
16882
+ "sidebar:",
16883
+ " badge:",
16884
+ ` text: ${badge.text}`,
16885
+ ` variant: ${badge.variant}`,
16886
+ "---"
16887
+ ];
16888
+ return lines.join("\n");
16889
+ }
16890
+ static computeBadge(testCases) {
16891
+ const statuses = new Set(testCases.map((tc) => tc.status));
16892
+ if (statuses.has("failed")) return { text: "Failed", variant: "danger" };
16893
+ if (statuses.has("pending")) return { text: "Pending", variant: "caution" };
16894
+ if (statuses.has("skipped") && !statuses.has("passed")) return { text: "Skipped", variant: "caution" };
16895
+ return { text: "Passed", variant: "success" };
16896
+ }
16897
+ };
16898
+
16899
+ // src/formatters/astro-assets.ts
16900
+ import * as fs4 from "fs";
16901
+ import * as path4 from "path";
16902
+ var SKIP_PREFIXES = ["http://", "https://", "data:", "#"];
16903
+ function isLocalPath(src) {
16904
+ const trimmed = src.trim();
16905
+ if (SKIP_PREFIXES.some((prefix) => trimmed.startsWith(prefix))) {
16906
+ return false;
16907
+ }
16908
+ return !path4.posix.isAbsolute(trimmed) && !path4.win32.isAbsolute(trimmed);
16909
+ }
16910
+ function stripCodeContent(markdown) {
16911
+ let result = markdown.replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$/gm, "");
16912
+ result = result.replace(/(`+)(?:(?!\1).)+\1/g, "");
16913
+ result = result.replace(/<pre\b[^>]*>[\s\S]*?<\/pre>/gi, "");
16914
+ result = result.replace(/<code\b[^>]*>[\s\S]*?<\/code>/gi, "");
16915
+ return result;
16916
+ }
16917
+ function scanMarkdownAssets(markdown) {
16918
+ const found = /* @__PURE__ */ new Set();
16919
+ const stripped = stripCodeContent(markdown);
16920
+ const mdImageRe = /!\[[^\]]*\]\(([^)"'\s]+)(?:\s+["'][^"']*["'])?\s*\)/g;
16921
+ let match;
16922
+ while ((match = mdImageRe.exec(stripped)) !== null) {
16923
+ const src = match[1].trim();
16924
+ if (isLocalPath(src)) {
16925
+ found.add(src);
16926
+ }
16927
+ }
16928
+ const htmlSrcRe = /<(?:img|source|video)[^>]+\bsrc=["']([^"']+)["'][^>]*>/gi;
16929
+ while ((match = htmlSrcRe.exec(stripped)) !== null) {
16930
+ const src = match[1].trim();
16931
+ if (isLocalPath(src)) {
16932
+ found.add(src);
16933
+ }
16934
+ }
16935
+ return Array.from(found);
16936
+ }
16937
+ function splitByCode(markdown) {
16938
+ const codeRe = /^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1\s*$|<pre\b[^>]*>[\s\S]*?<\/pre>|<code\b[^>]*>[\s\S]*?<\/code>|(`+)(?:(?!\2).)+\2/gim;
16939
+ const segments = [];
16940
+ let lastIndex = 0;
16941
+ for (const match of markdown.matchAll(codeRe)) {
16942
+ if (match.index > lastIndex) {
16943
+ segments.push(markdown.slice(lastIndex, match.index));
16944
+ }
16945
+ segments.push(match[0]);
16946
+ lastIndex = match.index + match[0].length;
16947
+ }
16948
+ if (lastIndex < markdown.length) {
16949
+ segments.push(markdown.slice(lastIndex));
16950
+ }
16951
+ return segments;
16952
+ }
16953
+ function isCode(segment) {
16954
+ const trimmed = segment.trimStart();
16955
+ return trimmed.startsWith("`") || trimmed.startsWith("~") || trimmed.startsWith("<pre") || trimmed.startsWith("<code");
16956
+ }
16957
+ function rewriteProseSegment(prose, assetsBaseUrl, pathMap) {
16958
+ let result = prose;
16959
+ result = result.replace(
16960
+ /(!\[[^\]]*\]\()([^)"'\s]+)((?:\s+["'][^"']*["'])?\s*\))/g,
16961
+ (full, pre, src, post) => {
16962
+ const trimmed = src.trim();
16963
+ if (!isLocalPath(trimmed)) return full;
16964
+ if (pathMap) {
16965
+ const mapped = pathMap.get(trimmed);
16966
+ if (mapped === void 0) return full;
16967
+ return `${pre}${assetsBaseUrl}/${mapped}${post}`;
16968
+ }
16969
+ return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
16970
+ }
16971
+ );
16972
+ result = result.replace(
16973
+ /(<(?:img|source|video)[^>]+\bsrc=["'])([^"']+)(["'][^>]*>)/gi,
16974
+ (full, pre, src, post) => {
16975
+ const trimmed = src.trim();
16976
+ if (!isLocalPath(trimmed)) return full;
16977
+ if (pathMap) {
16978
+ const mapped = pathMap.get(trimmed);
16979
+ if (mapped === void 0) return full;
16980
+ return `${pre}${assetsBaseUrl}/${mapped}${post}`;
16981
+ }
16982
+ return `${pre}${assetsBaseUrl}/${trimmed}${post}`;
16983
+ }
16984
+ );
16985
+ return result;
16986
+ }
16987
+ function rewriteAssetPaths(markdown, assetsBaseUrl, pathMap) {
16988
+ return splitByCode(markdown).map((seg) => isCode(seg) ? seg : rewriteProseSegment(seg, assetsBaseUrl, pathMap)).join("");
16989
+ }
16990
+ function copyMarkdownAssets(options) {
16991
+ const {
16992
+ markdown,
16993
+ markdownDir,
16994
+ assetsDir,
16995
+ assetsBaseUrl,
16996
+ allowMissing = false
16997
+ } = options;
16998
+ const refs = scanMarkdownAssets(markdown);
16999
+ const pathMap = /* @__PURE__ */ new Map();
17000
+ const missing = [];
17001
+ for (const ref of refs) {
17002
+ const absPath = path4.resolve(markdownDir, ref);
17003
+ if (!fs4.existsSync(absPath)) {
17004
+ if (!allowMissing) {
17005
+ throw new Error(`Asset not found: ${absPath}`);
17006
+ }
17007
+ missing.push(ref);
17008
+ continue;
17009
+ }
17010
+ const relativeCopied = copyAsset(absPath, assetsDir);
17011
+ const fileName = relativeCopied.replace(/^assets\//, "");
17012
+ pathMap.set(ref, fileName);
17013
+ }
17014
+ const rewritten = rewriteAssetPaths(markdown, assetsBaseUrl, pathMap);
17015
+ return {
17016
+ markdown: rewritten,
17017
+ copiedCount: pathMap.size,
17018
+ missingCount: missing.length,
17019
+ missing
17020
+ };
17021
+ }
17022
+
16852
17023
  // src/converters/ndjson-parser.ts
16853
17024
  function parseNdjson(ndjson) {
16854
17025
  const lines = ndjson.trim().split("\n").filter(Boolean);
@@ -17802,6 +17973,37 @@ function listScenarios(args, _deps) {
17802
17973
  }));
17803
17974
  return JSON.stringify(items, null, 2);
17804
17975
  }
17976
+ if (format === "csv") {
17977
+ const header = "id,scenario,status,sourceFile,sourceLine,tags";
17978
+ const rows = testCases.map((tc) => {
17979
+ const fields = [
17980
+ tc.id,
17981
+ tc.story.scenario,
17982
+ tc.status,
17983
+ tc.sourceFile,
17984
+ String(tc.sourceLine),
17985
+ tc.tags.join(" ")
17986
+ ];
17987
+ return fields.map((f) => {
17988
+ if (f.includes(",") || f.includes('"') || f.includes("\n")) {
17989
+ return `"${f.replace(/"/g, '""')}"`;
17990
+ }
17991
+ return f;
17992
+ }).join(",");
17993
+ });
17994
+ return [header, ...rows].join("\n");
17995
+ }
17996
+ if (format === "markdown-table") {
17997
+ const header = "| Status | Scenario | Location | Tags |";
17998
+ const divider = "|--------|----------|----------|------|";
17999
+ const rows = testCases.map((tc) => {
18000
+ const icon = STATUS_ICONS[tc.status] ?? "?";
18001
+ const location = `${tc.sourceFile}:${tc.sourceLine}`;
18002
+ const tags = tc.tags.map((t) => `@${t}`).join(" ");
18003
+ return `| ${icon} | ${tc.story.scenario} | ${location} | ${tags} |`;
18004
+ });
18005
+ return [header, divider, ...rows].join("\n");
18006
+ }
17805
18007
  if (testCases.length === 0) {
17806
18008
  return "No scenarios found.";
17807
18009
  }
@@ -17843,6 +18045,7 @@ function listScenarios(args, _deps) {
17843
18045
 
17844
18046
  // src/index.ts
17845
18047
  var FORMAT_EXTENSIONS = {
18048
+ astro: ".md",
17846
18049
  markdown: ".md",
17847
18050
  html: ".html",
17848
18051
  "cucumber-html": ".cucumber.html",
@@ -17875,11 +18078,11 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
17875
18078
  const ext = FORMAT_EXTENSIONS[format];
17876
18079
  const effectiveName = outputName + (outputNameSuffix ?? "");
17877
18080
  if (mode === "aggregated") {
17878
- return toPosix(path4.join(baseOutputDir, `${effectiveName}${ext}`));
18081
+ return toPosix(path5.join(baseOutputDir, `${effectiveName}${ext}`));
17879
18082
  }
17880
18083
  const normalizedSource = toPosix(sourceFile);
17881
- const dirOfSource = path4.posix.dirname(normalizedSource);
17882
- let baseName = path4.posix.basename(normalizedSource);
18084
+ const dirOfSource = path5.posix.dirname(normalizedSource);
18085
+ let baseName = path5.posix.basename(normalizedSource);
17883
18086
  for (const testExt of TEST_EXTENSIONS) {
17884
18087
  if (baseName.endsWith(testExt)) {
17885
18088
  baseName = baseName.slice(0, -testExt.length);
@@ -17888,9 +18091,9 @@ function computeOutputPath(sourceFile, format, mode, colocatedStyle, baseOutputD
17888
18091
  }
17889
18092
  const fileName = `${baseName}.${effectiveName}${ext}`;
17890
18093
  if (colocatedStyle === "adjacent") {
17891
- return toPosix(path4.posix.join(dirOfSource, fileName));
18094
+ return toPosix(path5.posix.join(dirOfSource, fileName));
17892
18095
  }
17893
- return toPosix(path4.posix.join(baseOutputDir, dirOfSource, fileName));
18096
+ return toPosix(path5.posix.join(baseOutputDir, dirOfSource, fileName));
17894
18097
  }
17895
18098
  function groupTestCasesByOutput(testCases, format, options, logger, outputNameSuffix) {
17896
18099
  const groups = /* @__PURE__ */ new Map();
@@ -18016,6 +18219,24 @@ var ReportGenerator = class {
18016
18219
  includeSourceLinks: options.markdown?.includeSourceLinks ?? true,
18017
18220
  customRenderers: options.markdown?.customRenderers
18018
18221
  },
18222
+ astro: {
18223
+ assetsDir: options.astro?.assetsDir ?? "public/stories/assets",
18224
+ assetsBaseUrl: options.astro?.assetsBaseUrl ?? "/stories/assets",
18225
+ markdown: {
18226
+ title: options.astro?.markdown?.title ?? "User Stories",
18227
+ includeStatusIcons: options.astro?.markdown?.includeStatusIcons ?? true,
18228
+ includeErrors: options.astro?.markdown?.includeErrors ?? true,
18229
+ scenarioHeadingLevel: options.astro?.markdown?.scenarioHeadingLevel ?? 3,
18230
+ groupBy: options.astro?.markdown?.groupBy ?? "file",
18231
+ sortScenarios: options.astro?.markdown?.sortScenarios ?? "source",
18232
+ suiteSeparator: options.astro?.markdown?.suiteSeparator ?? " - ",
18233
+ includeSourceLinks: options.astro?.markdown?.includeSourceLinks ?? true,
18234
+ permalinkBaseUrl: options.astro?.markdown?.permalinkBaseUrl,
18235
+ ticketUrlTemplate: options.astro?.markdown?.ticketUrlTemplate,
18236
+ traceUrlTemplate: options.astro?.markdown?.traceUrlTemplate,
18237
+ customRenderers: options.astro?.markdown?.customRenderers
18238
+ }
18239
+ },
18019
18240
  assetMode: options.assetMode ?? "none",
18020
18241
  allowMissingAssets: options.allowMissingAssets ?? false
18021
18242
  };
@@ -18053,6 +18274,24 @@ var ReportGenerator = class {
18053
18274
  });
18054
18275
  }
18055
18276
  }
18277
+ const astroPaths = results.get("astro");
18278
+ if (astroPaths) {
18279
+ for (const mdPath of astroPaths) {
18280
+ const content = await fsPromises.readFile(mdPath, "utf8");
18281
+ const mdDir = path5.dirname(mdPath);
18282
+ const assetsDir = path5.resolve(this.options.astro.assetsDir);
18283
+ const result = copyMarkdownAssets({
18284
+ markdown: content,
18285
+ markdownDir: mdDir,
18286
+ assetsDir,
18287
+ assetsBaseUrl: this.options.astro.assetsBaseUrl,
18288
+ allowMissing: this.options.allowMissingAssets
18289
+ });
18290
+ if (result.copiedCount > 0 || result.missingCount > 0) {
18291
+ await this.deps.writeFile(mdPath, result.markdown);
18292
+ }
18293
+ }
18294
+ }
18056
18295
  }
18057
18296
  return results;
18058
18297
  }
@@ -18071,9 +18310,9 @@ var ReportGenerator = class {
18071
18310
  if (groups.size === 0 && this.options.output.mode === "aggregated") {
18072
18311
  const ext = FORMAT_EXTENSIONS[format];
18073
18312
  const effectiveName = this.options.outputName + (outputNameSuffix ?? "");
18074
- const outputPath = toPosix(path4.join(this.options.outputDir, `${effectiveName}${ext}`));
18313
+ const outputPath = toPosix(path5.join(this.options.outputDir, `${effectiveName}${ext}`));
18075
18314
  const content = await this.formatContent(run, format);
18076
- const dir = path4.dirname(outputPath);
18315
+ const dir = path5.dirname(outputPath);
18077
18316
  await fsPromises.mkdir(dir, { recursive: true });
18078
18317
  await this.deps.writeFile(outputPath, content);
18079
18318
  return [outputPath];
@@ -18085,7 +18324,7 @@ var ReportGenerator = class {
18085
18324
  testCases
18086
18325
  };
18087
18326
  const content = await this.formatContent(groupRun, format);
18088
- const dir = path4.dirname(outputPath);
18327
+ const dir = path5.dirname(outputPath);
18089
18328
  await fsPromises.mkdir(dir, { recursive: true });
18090
18329
  await this.deps.writeFile(outputPath, content);
18091
18330
  writtenPaths.push(outputPath);
@@ -18148,6 +18387,13 @@ var ReportGenerator = class {
18148
18387
  });
18149
18388
  return formatter.formatToString(run);
18150
18389
  }
18390
+ case "astro": {
18391
+ const formatter = new AstroFormatter({
18392
+ assetsBaseUrl: this.options.astro.assetsBaseUrl,
18393
+ markdown: this.options.astro.markdown
18394
+ });
18395
+ return formatter.format(run);
18396
+ }
18151
18397
  case "markdown": {
18152
18398
  const formatter = new MarkdownFormatter({
18153
18399
  title: this.options.markdown.title,
@@ -18182,7 +18428,7 @@ async function generateRunComparison(args) {
18182
18428
  await fsPromises.mkdir(outputDir, { recursive: true });
18183
18429
  for (const format of args.formats) {
18184
18430
  const ext = format === "html" ? ".html" : ".md";
18185
- const outputPath = toPosix(path4.join(outputDir, `${outputName}${ext}`));
18431
+ const outputPath = toPosix(path5.join(outputDir, `${outputName}${ext}`));
18186
18432
  const content = format === "html" ? new RunDiffHtmlFormatter({ title: args.title }).format(diff) : new RunDiffMarkdownFormatter({ title: args.title }).format(diff);
18187
18433
  await fsPromises.writeFile(outputPath, content, "utf8");
18188
18434
  files.push(outputPath);
@@ -18190,6 +18436,61 @@ async function generateRunComparison(args) {
18190
18436
  return { files, diff };
18191
18437
  }
18192
18438
 
18439
+ // src/init-astro.ts
18440
+ import * as fs6 from "fs";
18441
+ import * as path6 from "path";
18442
+ import { fileURLToPath } from "url";
18443
+ var __dirname = path6.dirname(fileURLToPath(import.meta.url));
18444
+ function initAstro(options = {}) {
18445
+ const targetDir = options.targetDir ?? "./story-docs";
18446
+ const force = options.force ?? false;
18447
+ if (fs6.existsSync(targetDir)) {
18448
+ const entries = fs6.readdirSync(targetDir);
18449
+ if (entries.length > 0 && !force) {
18450
+ throw new Error(
18451
+ `Directory "${targetDir}" already exists and is not empty. Use --force to overwrite.`
18452
+ );
18453
+ }
18454
+ }
18455
+ const templateDir = path6.resolve(__dirname, "..", "templates", "astro-starlight");
18456
+ if (!fs6.existsSync(templateDir)) {
18457
+ throw new Error(
18458
+ `Template directory not found at ${templateDir}. Ensure the package is installed correctly.`
18459
+ );
18460
+ }
18461
+ copyDirRecursive(templateDir, targetDir);
18462
+ return { targetDir };
18463
+ }
18464
+ function copyDirRecursive(src, dest) {
18465
+ fs6.mkdirSync(dest, { recursive: true });
18466
+ const entries = fs6.readdirSync(src, { withFileTypes: true });
18467
+ for (const entry of entries) {
18468
+ const srcPath = path6.join(src, entry.name);
18469
+ const destPath = path6.join(dest, entry.name);
18470
+ if (entry.isDirectory()) {
18471
+ copyDirRecursive(srcPath, destPath);
18472
+ } else {
18473
+ fs6.copyFileSync(srcPath, destPath);
18474
+ }
18475
+ }
18476
+ }
18477
+
18478
+ // src/config.ts
18479
+ import { existsSync as existsSync6 } from "fs";
18480
+ import { resolve as resolve6 } from "path";
18481
+ async function loadConfig(configPath) {
18482
+ const resolved = configPath ? resolve6(configPath) : resolve6(process.cwd(), "executable-stories.config.js");
18483
+ if (!existsSync6(resolved)) return {};
18484
+ const mod = await import(resolved);
18485
+ const config = mod.default;
18486
+ if (!config || typeof config !== "object" || Array.isArray(config)) {
18487
+ throw new Error(
18488
+ `Config file at ${resolved} must export a default object. Got: ${typeof config}`
18489
+ );
18490
+ }
18491
+ return config;
18492
+ }
18493
+
18193
18494
  // src/cli.ts
18194
18495
  var EXIT_SUCCESS = 0;
18195
18496
  var EXIT_SCHEMA_VALIDATION = 1;
@@ -18206,21 +18507,25 @@ USAGE
18206
18507
  executable-stories list <file> [options]
18207
18508
  executable-stories validate <file>
18208
18509
  executable-stories validate --stdin
18510
+ executable-stories init-astro [directory]
18209
18511
 
18210
18512
  SUBCOMMANDS
18211
18513
  format Read raw test results and generate reports
18212
18514
  compare Compare two runs and generate a diff report
18213
18515
  list List scenarios from a test run (text table or JSON)
18214
18516
  validate Validate a JSON file against the schema (no output generated)
18517
+ init-astro Scaffold an Astro Starlight docs site for story output
18215
18518
 
18216
18519
  OPTIONS
18217
- --format <formats> Comma-separated formats (default: html)
18520
+ --format <formats> Comma-separated formats: html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html, astro, or custom names from config (default: html)
18521
+ astro Starlight-compatible Markdown (for Astro docs sites)
18218
18522
  html Custom HTML report (accessible, dark mode, mermaid)
18219
18523
  cucumber-html Official Cucumber HTML report
18220
18524
  markdown Markdown documentation
18221
18525
  junit JUnit XML
18222
18526
  cucumber-json Cucumber JSON
18223
18527
  cucumber-messages Raw NDJSON (Cucumber Messages)
18528
+ --config <path> Path to executable-stories.config.js (default: ./executable-stories.config.js)
18224
18529
  --input-type <type> Input type: raw, canonical, or ndjson (default: raw)
18225
18530
  --output-dir <dir> Output directory (default: reports)
18226
18531
  --output-name <name> Base filename (default: index)
@@ -18244,7 +18549,8 @@ OPTIONS
18244
18549
  --asset-mode <mode> Asset bundling: "none" (default) or "copy"
18245
18550
  --allow-missing-assets Warn on missing assets instead of failing
18246
18551
  --stdin Read JSON from stdin instead of file
18247
- --json-summary Print machine-parsable JSON summary
18552
+ --list-format <format> list output format: text (default), json, csv, markdown-table
18553
+ --json-summary Deprecated alias for --list-format json
18248
18554
  --baseline <path|auto> Compare baseline file, or auto-pick a prior run for compare
18249
18555
  --baseline-dir <dir> Directory to scan when --baseline auto is used
18250
18556
  --pr-summary Print a PR-friendly markdown summary after compare
@@ -18253,7 +18559,8 @@ OPTIONS
18253
18559
  --help Show this help message
18254
18560
 
18255
18561
  LIST
18256
- list prints one scenario per line (text by default, JSON with --json-summary)
18562
+ list prints one scenario per line (--list-format text by default)
18563
+ list --list-format json outputs machine-parsable JSON (--json-summary is a deprecated alias)
18257
18564
  list supports --include-tags, --exclude-tags for filtering
18258
18565
  list supports --input-type and --stdin
18259
18566
 
@@ -18261,6 +18568,10 @@ COMPARE
18261
18568
  compare supports --format html,markdown
18262
18569
  compare uses the same --input-type for both baseline and current files
18263
18570
 
18571
+ INIT-ASTRO
18572
+ executable-stories init-astro [directory] Scaffold into directory (default: ./story-docs)
18573
+ --force Overwrite existing directory
18574
+
18264
18575
  NOTIFICATIONS
18265
18576
  --slack-webhook <url> Slack incoming webhook URL (fallback: SLACK_WEBHOOK_URL env var)
18266
18577
  --teams-webhook <url> Teams incoming webhook URL (fallback: TEAMS_WEBHOOK_URL env var)
@@ -18288,17 +18599,38 @@ EXIT CODES
18288
18599
  3 Formatter/generation failure
18289
18600
  4 Bad arguments / usage error
18290
18601
  `.trim();
18291
- function parseCliArgs(argv) {
18602
+ async function parseCliArgs(argv) {
18292
18603
  const args = argv.slice(2);
18293
18604
  if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
18294
18605
  console.log(HELP_TEXT);
18295
18606
  process.exit(EXIT_SUCCESS);
18296
18607
  }
18297
18608
  const subcommand = args[0];
18298
- if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "list" && subcommand !== "validate") {
18299
- console.error(`Unknown subcommand: "${subcommand}". Use "format", "compare", "list", or "validate".`);
18609
+ if (subcommand !== "format" && subcommand !== "compare" && subcommand !== "list" && subcommand !== "validate" && subcommand !== "init-astro") {
18610
+ console.error(`Unknown subcommand: "${subcommand}". Use "format", "compare", "list", "validate", or "init-astro".`);
18300
18611
  process.exit(EXIT_USAGE);
18301
18612
  }
18613
+ if (subcommand === "init-astro") {
18614
+ const initArgs = args.slice(1);
18615
+ const targetDir = initArgs.find((a) => !a.startsWith("--")) ?? "./story-docs";
18616
+ const force = initArgs.includes("--force");
18617
+ try {
18618
+ const result = initAstro({ targetDir, force });
18619
+ console.log(`Scaffolded Astro Starlight project at ${result.targetDir}`);
18620
+ console.log("");
18621
+ console.log("Next steps:");
18622
+ console.log(` cd ${result.targetDir}`);
18623
+ console.log(" pnpm install # or npm install");
18624
+ console.log(" pnpm dev # start the dev server");
18625
+ console.log("");
18626
+ console.log("Generate story docs with:");
18627
+ console.log(` executable-stories format run.json --format astro --output-dir ${result.targetDir}/src/content/docs/stories --asset-mode copy`);
18628
+ process.exit(EXIT_SUCCESS);
18629
+ } catch (err) {
18630
+ console.error(`Error: ${err.message}`);
18631
+ process.exit(EXIT_USAGE);
18632
+ }
18633
+ }
18302
18634
  const { values, positionals } = parseArgs({
18303
18635
  args: args.slice(1),
18304
18636
  options: {
@@ -18327,6 +18659,7 @@ function parseCliArgs(argv) {
18327
18659
  "html-theme-picker": { type: "boolean", default: false },
18328
18660
  stdin: { type: "boolean", default: false },
18329
18661
  "json-summary": { type: "boolean", default: false },
18662
+ "list-format": { type: "string", default: "text" },
18330
18663
  "emit-canonical": { type: "string" },
18331
18664
  "slack-webhook": { type: "string" },
18332
18665
  "teams-webhook": { type: "string" },
@@ -18345,6 +18678,7 @@ function parseCliArgs(argv) {
18345
18678
  "allow-missing-assets": { type: "boolean", default: false },
18346
18679
  "pr-summary": { type: "boolean", default: false },
18347
18680
  "pr-summary-file": { type: "string" },
18681
+ "config": { type: "string" },
18348
18682
  help: { type: "boolean", default: false }
18349
18683
  },
18350
18684
  allowPositionals: true,
@@ -18382,15 +18716,20 @@ function parseCliArgs(argv) {
18382
18716
  console.error(`Error: --input-type must be "raw", "canonical", or "ndjson", got "${inputType}".`);
18383
18717
  process.exit(EXIT_USAGE);
18384
18718
  }
18385
- const validFormats = /* @__PURE__ */ new Set(["html", "markdown", "junit", "cucumber-json", "cucumber-messages", "cucumber-html"]);
18719
+ const pluginConfig = await loadConfig(values["config"]);
18720
+ const customFormatterNames = new Set(Object.keys(pluginConfig.formatters ?? {}));
18721
+ const builtInFormats = /* @__PURE__ */ new Set(["astro", "html", "markdown", "junit", "cucumber-json", "cucumber-messages", "cucumber-html"]);
18386
18722
  const formatStr = values.format;
18387
- const formats = formatStr.split(",").map((f) => f.trim());
18388
- for (const f of formats) {
18389
- if (!validFormats.has(f)) {
18390
- console.error(`Error: Unknown format "${f}". Valid: html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html.`);
18391
- process.exit(EXIT_USAGE);
18392
- }
18723
+ const allRequestedFormats = formatStr.split(",").map((f) => f.trim());
18724
+ const builtInRequested = allRequestedFormats.filter((f) => builtInFormats.has(f));
18725
+ const customRequested = allRequestedFormats.filter((f) => customFormatterNames.has(f));
18726
+ const unknownFormats = allRequestedFormats.filter((f) => !builtInFormats.has(f) && !customFormatterNames.has(f));
18727
+ if (unknownFormats.length > 0) {
18728
+ const knownCustom = customFormatterNames.size > 0 ? `, ${[...customFormatterNames].join(", ")}` : "";
18729
+ console.error(`Error: Unknown format(s): ${unknownFormats.join(", ")}. Valid built-in: astro, html, markdown, junit, cucumber-json, cucumber-messages, cucumber-html${knownCustom}.`);
18730
+ process.exit(EXIT_USAGE);
18393
18731
  }
18732
+ const formats = builtInRequested;
18394
18733
  const htmlTheme = values["html-theme"];
18395
18734
  const validThemes = /* @__PURE__ */ new Set(["default", "corporate", "terminal", "minimal", "dashboard", "playful"]);
18396
18735
  if (!validThemes.has(htmlTheme)) {
@@ -18458,7 +18797,7 @@ function parseCliArgs(argv) {
18458
18797
  console.error(`Error: --asset-mode must be "none" or "copy", got "${assetModeRaw}".`);
18459
18798
  process.exit(EXIT_USAGE);
18460
18799
  }
18461
- return {
18800
+ const cliArgs = {
18462
18801
  subcommand,
18463
18802
  inputFile,
18464
18803
  baselineFile,
@@ -18487,6 +18826,7 @@ function parseCliArgs(argv) {
18487
18826
  htmlNoToc: values["html-no-toc"],
18488
18827
  htmlThemePicker: values["html-theme-picker"],
18489
18828
  jsonSummary: values["json-summary"],
18829
+ listFormat: values["list-format"],
18490
18830
  emitCanonical: values["emit-canonical"],
18491
18831
  slackWebhook,
18492
18832
  teamsWebhook,
@@ -18504,34 +18844,36 @@ function parseCliArgs(argv) {
18504
18844
  assetMode: assetModeRaw,
18505
18845
  allowMissingAssets: values["allow-missing-assets"],
18506
18846
  prSummary: values["pr-summary"],
18507
- prSummaryFile: values["pr-summary-file"]
18847
+ prSummaryFile: values["pr-summary-file"],
18848
+ config: values["config"]
18508
18849
  };
18850
+ return { args: cliArgs, pluginConfig, customRequested };
18509
18851
  }
18510
18852
  async function readInput(args) {
18511
18853
  if (args.stdin) {
18512
18854
  return readStdin();
18513
18855
  }
18514
- const filePath = path5.resolve(args.inputFile);
18515
- if (!fs5.existsSync(filePath)) {
18856
+ const filePath = path7.resolve(args.inputFile);
18857
+ if (!fs7.existsSync(filePath)) {
18516
18858
  console.error(`Error: File not found: ${filePath}`);
18517
18859
  process.exit(EXIT_USAGE);
18518
18860
  }
18519
- return fs5.readFileSync(filePath, "utf8");
18861
+ return fs7.readFileSync(filePath, "utf8");
18520
18862
  }
18521
18863
  function readFileInput(filePath) {
18522
- const resolved = path5.resolve(filePath);
18523
- if (!fs5.existsSync(resolved)) {
18864
+ const resolved = path7.resolve(filePath);
18865
+ if (!fs7.existsSync(resolved)) {
18524
18866
  console.error(`Error: File not found: ${resolved}`);
18525
18867
  process.exit(EXIT_USAGE);
18526
18868
  }
18527
- return fs5.readFileSync(resolved, "utf8");
18869
+ return fs7.readFileSync(resolved, "utf8");
18528
18870
  }
18529
18871
  function readStdin() {
18530
- return new Promise((resolve4, reject) => {
18872
+ return new Promise((resolve8, reject) => {
18531
18873
  const chunks = [];
18532
18874
  process.stdin.setEncoding("utf8");
18533
18875
  process.stdin.on("data", (chunk) => chunks.push(chunk));
18534
- process.stdin.on("end", () => resolve4(chunks.join("")));
18876
+ process.stdin.on("end", () => resolve8(chunks.join("")));
18535
18877
  process.stdin.on("error", reject);
18536
18878
  });
18537
18879
  }
@@ -18657,14 +18999,14 @@ function tryNormalizeRunFromText(text, args) {
18657
18999
  }
18658
19000
  }
18659
19001
  function listBaselineCandidates(currentFile, args) {
18660
- const baselineDir = path5.resolve(args.baselineDir ?? path5.dirname(currentFile));
18661
- const currentResolved = path5.resolve(currentFile);
18662
- if (!fs5.existsSync(baselineDir)) {
19002
+ const baselineDir = path7.resolve(args.baselineDir ?? path7.dirname(currentFile));
19003
+ const currentResolved = path7.resolve(currentFile);
19004
+ if (!fs7.existsSync(baselineDir)) {
18663
19005
  console.error(`Error: baseline directory not found: ${baselineDir}`);
18664
19006
  process.exit(EXIT_USAGE);
18665
19007
  }
18666
- const entries = fs5.readdirSync(baselineDir, { withFileTypes: true });
18667
- return entries.filter((entry) => entry.isFile()).map((entry) => path5.join(baselineDir, entry.name)).filter((candidate) => path5.resolve(candidate) !== currentResolved).filter(
19008
+ const entries = fs7.readdirSync(baselineDir, { withFileTypes: true });
19009
+ return entries.filter((entry) => entry.isFile()).map((entry) => path7.join(baselineDir, entry.name)).filter((candidate) => path7.resolve(candidate) !== currentResolved).filter(
18668
19010
  (candidate) => args.inputType === "ndjson" ? candidate.endsWith(".ndjson") : candidate.endsWith(".json")
18669
19011
  );
18670
19012
  }
@@ -18672,14 +19014,14 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
18672
19014
  const candidates = listBaselineCandidates(currentFile, args);
18673
19015
  const comparable = [];
18674
19016
  for (const candidate of candidates) {
18675
- const run = tryNormalizeRunFromText(fs5.readFileSync(candidate, "utf8"), args);
19017
+ const run = tryNormalizeRunFromText(fs7.readFileSync(candidate, "utf8"), args);
18676
19018
  if (run) {
18677
19019
  comparable.push({ file: candidate, run });
18678
19020
  }
18679
19021
  }
18680
19022
  if (comparable.length === 0) {
18681
19023
  console.error(
18682
- `Error: no compatible baseline files found in ${path5.resolve(args.baselineDir ?? path5.dirname(currentFile))}.`
19024
+ `Error: no compatible baseline files found in ${path7.resolve(args.baselineDir ?? path7.dirname(currentFile))}.`
18683
19025
  );
18684
19026
  process.exit(EXIT_USAGE);
18685
19027
  }
@@ -18691,7 +19033,7 @@ function resolveBaselineAuto(currentFile, currentRun, args) {
18691
19033
  return picked.file;
18692
19034
  }
18693
19035
  async function main() {
18694
- const args = parseCliArgs(process.argv);
19036
+ const { args, pluginConfig, customRequested } = await parseCliArgs(process.argv);
18695
19037
  const startMs = Date.now();
18696
19038
  if (args.subcommand === "compare") {
18697
19039
  const currentText = readFileInput(args.currentFile);
@@ -18712,8 +19054,16 @@ async function main() {
18712
19054
  if (args.subcommand === "list") {
18713
19055
  const text2 = await readInput(args);
18714
19056
  const run = applySelection(normalizeRunFromText(text2, args).run, args);
18715
- const outputFormat = args.jsonSummary ? "json" : "text";
18716
- const output = listScenarios({ testCases: run.testCases, format: outputFormat }, {});
19057
+ const resolvedFormat = args.jsonSummary ? "json" : args.listFormat;
19058
+ const validListFormats = /* @__PURE__ */ new Set(["text", "json", "csv", "markdown-table"]);
19059
+ if (!validListFormats.has(resolvedFormat)) {
19060
+ console.error(`Error: Unknown list format "${resolvedFormat}". Valid: text, json, csv, markdown-table.`);
19061
+ process.exit(EXIT_USAGE);
19062
+ }
19063
+ const output = listScenarios(
19064
+ { testCases: run.testCases, format: resolvedFormat },
19065
+ {}
19066
+ );
18717
19067
  console.log(output);
18718
19068
  process.exit(EXIT_SUCCESS);
18719
19069
  }
@@ -18760,12 +19110,13 @@ async function main() {
18760
19110
  process.exit(EXIT_SCHEMA_VALIDATION);
18761
19111
  }
18762
19112
  if (args.emitCanonical) {
18763
- const outPath = path5.resolve(args.emitCanonical);
18764
- fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18765
- fs5.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
19113
+ const outPath = path7.resolve(args.emitCanonical);
19114
+ fs7.mkdirSync(path7.dirname(outPath), { recursive: true });
19115
+ fs7.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
18766
19116
  }
18767
19117
  try {
18768
19118
  const result = await generateReports(run, args);
19119
+ runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
18769
19120
  await dispatchNotifications(run, args);
18770
19121
  runHistoryPipeline(run, args);
18771
19122
  printResult(result, args, startMs);
@@ -18818,12 +19169,13 @@ ${msg}`);
18818
19169
  }
18819
19170
  const run = data;
18820
19171
  if (args.emitCanonical) {
18821
- const outPath = path5.resolve(args.emitCanonical);
18822
- fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18823
- fs5.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
19172
+ const outPath = path7.resolve(args.emitCanonical);
19173
+ fs7.mkdirSync(path7.dirname(outPath), { recursive: true });
19174
+ fs7.writeFileSync(outPath, JSON.stringify(run, null, 2), "utf8");
18824
19175
  }
18825
19176
  try {
18826
19177
  const result = await generateReports(run, args);
19178
+ runCustomFormatters(run, customRequested, pluginConfig.formatters ?? {}, args);
18827
19179
  await dispatchNotifications(run, args);
18828
19180
  runHistoryPipeline(run, args);
18829
19181
  printResult(result, args, startMs);
@@ -18875,12 +19227,13 @@ ${msg}`);
18875
19227
  process.exit(EXIT_CANONICAL_VALIDATION);
18876
19228
  }
18877
19229
  if (args.emitCanonical) {
18878
- const outPath = path5.resolve(args.emitCanonical);
18879
- fs5.mkdirSync(path5.dirname(outPath), { recursive: true });
18880
- fs5.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
19230
+ const outPath = path7.resolve(args.emitCanonical);
19231
+ fs7.mkdirSync(path7.dirname(outPath), { recursive: true });
19232
+ fs7.writeFileSync(outPath, JSON.stringify(canonical, null, 2), "utf8");
18881
19233
  }
18882
19234
  try {
18883
19235
  const result = await generateReports(canonical, args, droppedMissingStory);
19236
+ runCustomFormatters(canonical, customRequested, pluginConfig.formatters ?? {}, args);
18884
19237
  await dispatchNotifications(canonical, args);
18885
19238
  runHistoryPipeline(canonical, args);
18886
19239
  printResult(result, args, startMs, droppedMissingStory);
@@ -18891,6 +19244,25 @@ ${msg}`);
18891
19244
  process.exit(EXIT_GENERATION);
18892
19245
  }
18893
19246
  }
19247
+ function runCustomFormatters(run, customRequested, formatters, args) {
19248
+ if (customRequested.length === 0) return;
19249
+ const outputDir = args.outputDir ?? ".";
19250
+ for (const formatName of customRequested) {
19251
+ const formatter = formatters[formatName];
19252
+ try {
19253
+ const content = formatter.format(run);
19254
+ const ext = formatter.fileExtension ?? formatName;
19255
+ const baseName = args.outputName ?? "report";
19256
+ const filename = args.outputNameTimestamp ? `${baseName}-${Math.floor(run.startedAtMs / 1e3)}.${ext}` : `${baseName}.${ext}`;
19257
+ const filepath = path7.join(outputDir, filename);
19258
+ fs7.mkdirSync(outputDir, { recursive: true });
19259
+ fs7.writeFileSync(filepath, content, "utf8");
19260
+ console.log(`Generated: ${filepath}`);
19261
+ } catch (err) {
19262
+ console.error(`Error running custom formatter "${formatName}": ${err instanceof Error ? err.message : String(err)}`);
19263
+ }
19264
+ }
19265
+ }
18894
19266
  async function dispatchNotifications(run, args) {
18895
19267
  const webhooks = args.webhookUrls.map((url) => {
18896
19268
  const opts = { url };
@@ -18934,13 +19306,13 @@ async function dispatchNotifications(run, args) {
18934
19306
  }
18935
19307
  function runHistoryPipeline(run, args) {
18936
19308
  if (!args.historyFile) return;
18937
- const historyPath = path5.resolve(args.historyFile);
19309
+ const historyPath = path7.resolve(args.historyFile);
18938
19310
  const store = loadHistory(
18939
19311
  { filePath: historyPath },
18940
19312
  {
18941
19313
  readFile: (p) => {
18942
19314
  try {
18943
- return fs5.readFileSync(p, "utf8");
19315
+ return fs7.readFileSync(p, "utf8");
18944
19316
  } catch {
18945
19317
  return void 0;
18946
19318
  }
@@ -18953,11 +19325,11 @@ function runHistoryPipeline(run, args) {
18953
19325
  run,
18954
19326
  maxRuns: args.maxHistoryRuns
18955
19327
  });
18956
- const dir = path5.dirname(historyPath);
18957
- fs5.mkdirSync(dir, { recursive: true });
19328
+ const dir = path7.dirname(historyPath);
19329
+ fs7.mkdirSync(dir, { recursive: true });
18958
19330
  saveHistory(
18959
19331
  { filePath: historyPath, store: updated },
18960
- { writeFile: (p, content) => fs5.writeFileSync(p, content, "utf8") }
19332
+ { writeFile: (p, content) => fs7.writeFileSync(p, content, "utf8") }
18961
19333
  );
18962
19334
  let metricsCount = 0;
18963
19335
  for (const testId of Object.keys(updated.tests)) {
@@ -19055,9 +19427,9 @@ function printResult(result, args, startMs, droppedMissingStory = 0) {
19055
19427
  function printCompareResult(result, args, startMs) {
19056
19428
  const durationMs = Date.now() - startMs;
19057
19429
  if (result.prSummary && args.prSummaryFile) {
19058
- const outputPath = path5.resolve(args.prSummaryFile);
19059
- fs5.mkdirSync(path5.dirname(outputPath), { recursive: true });
19060
- fs5.writeFileSync(outputPath, result.prSummary, "utf8");
19430
+ const outputPath = path7.resolve(args.prSummaryFile);
19431
+ fs7.mkdirSync(path7.dirname(outputPath), { recursive: true });
19432
+ fs7.writeFileSync(outputPath, result.prSummary, "utf8");
19061
19433
  }
19062
19434
  if (args.jsonSummary) {
19063
19435
  console.log(