executable-stories-vitest 8.7.0 → 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");
@@ -254,8 +255,41 @@ function init(task, options) {
254
255
  attachments: [],
255
256
  activeTimers: /* @__PURE__ */ new Map(),
256
257
  timerCounter: 0,
257
- traceUrlTemplate
258
+ traceUrlTemplate,
259
+ pendingStep: null
258
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;
259
293
  }
260
294
  function createStepMarker(keyword) {
261
295
  function stepMarker(text, docsOrBody) {
@@ -274,8 +308,13 @@ function createStepMarker(keyword) {
274
308
  docs: stepDocs,
275
309
  ...isCallback ? { wrapped: true } : {}
276
310
  };
311
+ flushPendingAssertions(ctx);
277
312
  ctx.meta.steps.push(step);
278
313
  ctx.currentStep = step;
314
+ if (!isCallback) {
315
+ ctx.pendingStep = step;
316
+ ctx.pendingFrom = readAssertionCount();
317
+ }
279
318
  syncMetaToTask();
280
319
  if (isChildrenArray) {
281
320
  const children = docsOrBody;
@@ -295,27 +334,32 @@ function createStepMarker(keyword) {
295
334
  if (!isCallback) return;
296
335
  const body = docsOrBody;
297
336
  const start = performance.now();
337
+ const assertionsBefore = readAssertionCount();
298
338
  try {
299
339
  const result = body();
300
340
  if (result instanceof Promise) {
301
341
  return result.then(
302
342
  (val) => {
303
343
  step.durationMs = performance.now() - start;
344
+ recordAssertions(step, assertionsBefore);
304
345
  syncMetaToTask();
305
346
  return val;
306
347
  },
307
348
  (err) => {
308
349
  step.durationMs = performance.now() - start;
350
+ recordAssertions(step, assertionsBefore);
309
351
  syncMetaToTask();
310
352
  throw err;
311
353
  }
312
354
  );
313
355
  }
314
356
  step.durationMs = performance.now() - start;
357
+ recordAssertions(step, assertionsBefore);
315
358
  syncMetaToTask();
316
359
  return result;
317
360
  } catch (err) {
318
361
  step.durationMs = performance.now() - start;
362
+ recordAssertions(step, assertionsBefore);
319
363
  syncMetaToTask();
320
364
  throw err;
321
365
  }
@@ -693,6 +737,11 @@ var StoryReporter = class {
693
737
  startTime = 0;
694
738
  packageVersion;
695
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;
696
745
  coverageByFile = {};
697
746
  constructor(options = {}) {
698
747
  this.options = options;
@@ -701,6 +750,9 @@ var StoryReporter = class {
701
750
  this.ctx = ctx;
702
751
  this.startTime = Date.now();
703
752
  const root = ctx.config?.root ?? process.cwd();
753
+ if (ctx.config) {
754
+ this.runScope = ctx.config.testNamePattern != null ? "filtered" : "full";
755
+ }
704
756
  const includeMetadata = this.options.markdown?.includeMetadata ?? true;
705
757
  if (includeMetadata) {
706
758
  this.packageVersion = (0, import_executable_stories_formatters.readPackageVersion)(root);
@@ -717,6 +769,8 @@ var StoryReporter = class {
717
769
  if (reason === "interrupted") return;
718
770
  const root = this.ctx?.config?.root ?? process.cwd();
719
771
  const rawTestCases = this.collectTestCases(testModules, root);
772
+ const coveredSourceFiles = this.collectCoveredSourceFiles(testModules, root);
773
+ const incompleteSourceFiles = this.collectIncompleteSourceFiles(testModules, root);
720
774
  const features = collectFeatures(testModules, root);
721
775
  const rawRun = {
722
776
  testCases: rawTestCases,
@@ -726,7 +780,10 @@ var StoryReporter = class {
726
780
  projectRoot: root,
727
781
  packageVersion: this.packageVersion,
728
782
  gitSha: this.gitSha,
729
- 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 } : {}
730
787
  };
731
788
  const rawRunPath = this.options.rawRunPath;
732
789
  if (rawRunPath) {
@@ -803,6 +860,58 @@ var StoryReporter = class {
803
860
  /**
804
861
  * Collect test cases from Vitest test modules.
805
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
+ }
806
915
  collectTestCases(testModules, root) {
807
916
  const testCases = [];
808
917
  for (const mod of testModules) {