executable-stories-vitest 8.6.6 → 8.8.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/README.md CHANGED
@@ -153,6 +153,18 @@ story.init(task, {
153
153
 
154
154
  ## Developer experience
155
155
 
156
+ The reporter also maintains canonical state under `<outputDir>/by-file/`, one JSON
157
+ report per test source. Documentation formats render that accumulated suite; JUnit,
158
+ Cucumber, and release manifests contain only the current execution. Vitest name filters
159
+ are detected automatically. A full run may retire scenarios no longer reported, while a
160
+ filtered or incompletely collected file preserves its earlier scenarios and emits a
161
+ warning instead of deleting documentation.
162
+
163
+ Vitest's live assertion counter is attributed to steps. Marker-style assertions are
164
+ assigned to the current step until the next step or test end, and `story.expect` measures
165
+ its own body. An observable Then/And/But claim with zero assertions is marked in Markdown
166
+ and HTML and grades `none` in Evidence Review.
167
+
156
168
  - **API:** Steps are on the `story` object: `story.given`, `story.when`, `story.then`, `story.and`, `story.but`. There are no top-level `given`/`when`/`then` exports (to avoid `then` being treated as a thenable on the package namespace).
157
169
  - **Modifiers:** Use Vitest’s `.skip`, `.only`, `.todo`, `.fails`, `.concurrent` on step calls when needed (e.g. `story.then.skip('...')`). Use `story.skip` / `story.only` for scenario-level modifiers.
158
170
  - **Attach story to a plain `it()`:** Call `story.init(task)` inside a normal `it('...', ({ task }) => { ... })` so that test appears in generated docs. Vitest does not export top-level step helpers or `doc`.
package/dist/index.cjs CHANGED
@@ -38,6 +38,7 @@ __export(src_exports, {
38
38
  module.exports = __toCommonJS(src_exports);
39
39
 
40
40
  // src/story-api.ts
41
+ var import_vitest = require("vitest");
41
42
  var import_node_module = require("module");
42
43
  var import_otel_detect = require("executable-stories-core/utils/otel-detect");
43
44
  var import_doc_builders = require("executable-stories-core/utils/doc-builders");
@@ -202,6 +203,17 @@ function convertStoryDocsToEntries(docs) {
202
203
  }
203
204
  return entries;
204
205
  }
206
+ function feature(input) {
207
+ declaredFeature = { input };
208
+ }
209
+ var declaredFeature = null;
210
+ function attachFeature(task) {
211
+ if (!declaredFeature) return;
212
+ const file = task.file?.name;
213
+ declaredFeature.file ??= file;
214
+ if (declaredFeature.file !== file) return;
215
+ task.meta.storyFeature = { ...declaredFeature.input, sourceFile: file };
216
+ }
205
217
  function init(task, options) {
206
218
  const meta = {
207
219
  scenario: task.name,
@@ -234,6 +246,7 @@ function init(task, options) {
234
246
  }
235
247
  }
236
248
  task.meta.story = meta;
249
+ attachFeature(task);
237
250
  activeContext = {
238
251
  meta,
239
252
  currentStep: null,
@@ -242,8 +255,41 @@ function init(task, options) {
242
255
  attachments: [],
243
256
  activeTimers: /* @__PURE__ */ new Map(),
244
257
  timerCounter: 0,
245
- traceUrlTemplate
258
+ traceUrlTemplate,
259
+ pendingStep: null
246
260
  };
261
+ const ctx = activeContext;
262
+ try {
263
+ (0, import_vitest.onTestFinished)(() => {
264
+ flushPendingAssertions(ctx);
265
+ task.meta.story = ctx.meta;
266
+ });
267
+ } catch {
268
+ }
269
+ }
270
+ function flushPendingAssertions(ctx) {
271
+ const step = ctx.pendingStep;
272
+ const before = ctx.pendingFrom;
273
+ ctx.pendingStep = null;
274
+ ctx.pendingFrom = void 0;
275
+ if (!step || before === void 0) return;
276
+ const after = readAssertionCount();
277
+ if (after === void 0) return;
278
+ step.assertions = after - before;
279
+ }
280
+ function readAssertionCount() {
281
+ try {
282
+ const state2 = import_vitest.expect.getState?.();
283
+ return typeof state2?.assertionCalls === "number" ? state2.assertionCalls : void 0;
284
+ } catch {
285
+ return void 0;
286
+ }
287
+ }
288
+ function recordAssertions(step, before) {
289
+ if (before === void 0) return;
290
+ const after = readAssertionCount();
291
+ if (after === void 0) return;
292
+ step.assertions = after - before;
247
293
  }
248
294
  function createStepMarker(keyword) {
249
295
  function stepMarker(text, docsOrBody) {
@@ -262,8 +308,13 @@ function createStepMarker(keyword) {
262
308
  docs: stepDocs,
263
309
  ...isCallback ? { wrapped: true } : {}
264
310
  };
311
+ flushPendingAssertions(ctx);
265
312
  ctx.meta.steps.push(step);
266
313
  ctx.currentStep = step;
314
+ if (!isCallback) {
315
+ ctx.pendingStep = step;
316
+ ctx.pendingFrom = readAssertionCount();
317
+ }
267
318
  syncMetaToTask();
268
319
  if (isChildrenArray) {
269
320
  const children = docsOrBody;
@@ -283,27 +334,32 @@ function createStepMarker(keyword) {
283
334
  if (!isCallback) return;
284
335
  const body = docsOrBody;
285
336
  const start = performance.now();
337
+ const assertionsBefore = readAssertionCount();
286
338
  try {
287
339
  const result = body();
288
340
  if (result instanceof Promise) {
289
341
  return result.then(
290
342
  (val) => {
291
343
  step.durationMs = performance.now() - start;
344
+ recordAssertions(step, assertionsBefore);
292
345
  syncMetaToTask();
293
346
  return val;
294
347
  },
295
348
  (err) => {
296
349
  step.durationMs = performance.now() - start;
350
+ recordAssertions(step, assertionsBefore);
297
351
  syncMetaToTask();
298
352
  throw err;
299
353
  }
300
354
  );
301
355
  }
302
356
  step.durationMs = performance.now() - start;
357
+ recordAssertions(step, assertionsBefore);
303
358
  syncMetaToTask();
304
359
  return result;
305
360
  } catch (err) {
306
361
  step.durationMs = performance.now() - start;
362
+ recordAssertions(step, assertionsBefore);
307
363
  syncMetaToTask();
308
364
  throw err;
309
365
  }
@@ -548,6 +604,7 @@ function attachSpans(spans, options) {
548
604
  var story = {
549
605
  // Core
550
606
  init,
607
+ feature,
551
608
  // BDD step markers
552
609
  given: createStepMarker("Given"),
553
610
  when: createStepMarker("When"),
@@ -680,6 +737,11 @@ var StoryReporter = class {
680
737
  startTime = 0;
681
738
  packageVersion;
682
739
  gitSha;
740
+ /**
741
+ * Left unknown until onInit sees a config. Claiming full coverage without
742
+ * having looked would let a later merge retire scenarios on a guess.
743
+ */
744
+ runScope;
683
745
  coverageByFile = {};
684
746
  constructor(options = {}) {
685
747
  this.options = options;
@@ -688,6 +750,9 @@ var StoryReporter = class {
688
750
  this.ctx = ctx;
689
751
  this.startTime = Date.now();
690
752
  const root = ctx.config?.root ?? process.cwd();
753
+ if (ctx.config) {
754
+ this.runScope = ctx.config.testNamePattern != null ? "filtered" : "full";
755
+ }
691
756
  const includeMetadata = this.options.markdown?.includeMetadata ?? true;
692
757
  if (includeMetadata) {
693
758
  this.packageVersion = (0, import_executable_stories_formatters.readPackageVersion)(root);
@@ -704,14 +769,21 @@ var StoryReporter = class {
704
769
  if (reason === "interrupted") return;
705
770
  const root = this.ctx?.config?.root ?? process.cwd();
706
771
  const rawTestCases = this.collectTestCases(testModules, root);
772
+ const coveredSourceFiles = this.collectCoveredSourceFiles(testModules, root);
773
+ const incompleteSourceFiles = this.collectIncompleteSourceFiles(testModules, root);
774
+ const features = collectFeatures(testModules, root);
707
775
  const rawRun = {
708
776
  testCases: rawTestCases,
777
+ ...features.length > 0 ? { features } : {},
709
778
  startedAtMs: this.startTime,
710
779
  finishedAtMs: Date.now(),
711
780
  projectRoot: root,
712
781
  packageVersion: this.packageVersion,
713
782
  gitSha: this.gitSha,
714
- ci: (0, import_executable_stories_formatters.detectCI)()
783
+ ci: (0, import_executable_stories_formatters.detectCI)(),
784
+ ...this.runScope ? { runScope: this.runScope } : {},
785
+ ...coveredSourceFiles.length > 0 ? { coveredSourceFiles } : {},
786
+ ...incompleteSourceFiles.length > 0 ? { incompleteSourceFiles } : {}
715
787
  };
716
788
  const rawRunPath = this.options.rawRunPath;
717
789
  if (rawRunPath) {
@@ -788,6 +860,58 @@ var StoryReporter = class {
788
860
  /**
789
861
  * Collect test cases from Vitest test modules.
790
862
  */
863
+ /**
864
+ * Every module vitest ran, whether or not it produced a story.
865
+ *
866
+ * The collected test cases only name modules that produced something, so a
867
+ * file whose last story was deleted would otherwise look like a file that did
868
+ * not run and keep its old scenarios for good.
869
+ */
870
+ collectCoveredSourceFiles(testModules, root) {
871
+ const files = /* @__PURE__ */ new Set();
872
+ for (const mod of testModules) {
873
+ const moduleId = mod.moduleId ?? mod.relativeModuleId ?? "";
874
+ if (!moduleId) continue;
875
+ const absolute = path.isAbsolute(moduleId) ? moduleId : path.resolve(root, moduleId);
876
+ files.add(toRelativePosix(absolute, root));
877
+ }
878
+ return [...files].sort();
879
+ }
880
+ /**
881
+ * Files whose scenarios could not be collected in full.
882
+ *
883
+ * A module that errored, or a test that failed without ever declaring a
884
+ * story, is missing scenarios because the run broke. Treating that as
885
+ * deletion would throw away documentation at the exact moment the suite is
886
+ * unhealthy. Checked per test, not per file: one healthy story in a module
887
+ * does not vouch for its broken siblings.
888
+ */
889
+ collectIncompleteSourceFiles(testModules, root) {
890
+ const incomplete = /* @__PURE__ */ new Set();
891
+ for (const mod of testModules) {
892
+ const moduleId = mod.moduleId ?? mod.relativeModuleId ?? "";
893
+ if (!moduleId) continue;
894
+ const absolute = path.isAbsolute(moduleId) ? moduleId : path.resolve(root, moduleId);
895
+ const sourceFile = toRelativePosix(absolute, root);
896
+ const errors = mod.errors?.() ?? [];
897
+ const collection = mod.children;
898
+ if (errors.length > 0 || !collection) {
899
+ const state2 = mod.state?.();
900
+ if (errors.length > 0 || state2 === "failed") incomplete.add(sourceFile);
901
+ continue;
902
+ }
903
+ for (const test of collection.allTests()) {
904
+ const meta = this.getStoryMeta(test);
905
+ if (meta?.scenario && Array.isArray(meta.steps)) continue;
906
+ if (test.options?.mode === "todo") continue;
907
+ if (test.result?.()?.state === "failed") {
908
+ incomplete.add(sourceFile);
909
+ break;
910
+ }
911
+ }
912
+ }
913
+ return [...incomplete].sort();
914
+ }
791
915
  collectTestCases(testModules, root) {
792
916
  const testCases = [];
793
917
  for (const mod of testModules) {
@@ -909,6 +1033,24 @@ var StoryReporter = class {
909
1033
  function createStoryReporter(options) {
910
1034
  return new StoryReporter(options);
911
1035
  }
1036
+ function collectFeatures(testModules, root) {
1037
+ const byFile = /* @__PURE__ */ new Map();
1038
+ for (const mod of testModules) {
1039
+ const collection = mod.children;
1040
+ if (!collection) continue;
1041
+ const moduleId = mod.moduleId ?? mod.relativeModuleId ?? "";
1042
+ const absoluteModuleId = path.isAbsolute(moduleId) ? moduleId : path.resolve(root, moduleId);
1043
+ const sourceFile = toRelativePosix(absoluteModuleId, root);
1044
+ for (const test of collection.allTests()) {
1045
+ const declared = test.meta()?.storyFeature;
1046
+ if (declared?.title) {
1047
+ byFile.set(sourceFile, { ...declared, sourceFile });
1048
+ break;
1049
+ }
1050
+ }
1051
+ }
1052
+ return [...byFile.values()];
1053
+ }
912
1054
 
913
1055
  // src/index.ts
914
1056
  var STORY_REPORTER_GUARD_MSG = 'Do not import StoryReporter from "executable-stories-vitest". In vitest.config, import it from "executable-stories-vitest/reporter".';