executable-stories-pytest 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (23) hide show
  1. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/PKG-INFO +1 -1
  2. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/pyproject.toml +1 -1
  3. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_plugin.py +7 -0
  4. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_story_api.py +34 -1
  5. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_types.py +2 -0
  6. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/PKG-INFO +1 -1
  7. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_plugin.py +71 -0
  8. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_story_api.py +12 -0
  9. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/LICENSE +0 -0
  10. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/README.md +0 -0
  11. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/setup.cfg +0 -0
  12. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/__init__.py +0 -0
  13. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_collector.py +0 -0
  14. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_json_writer.py +0 -0
  15. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/SOURCES.txt +0 -0
  16. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/dependency_links.txt +0 -0
  17. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/entry_points.txt +0 -0
  18. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/requires.txt +0 -0
  19. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/top_level.txt +0 -0
  20. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_assertion_counts.py +0 -0
  21. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_json_output.py +0 -0
  22. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_name_filtering.py +0 -0
  23. {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_run_inventory.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: executable-stories-pytest
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: pytest plugin for executable-stories BDD documentation
5
5
  Author: Jag Reehal
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "executable-stories-pytest"
7
- version = "0.1.1"
7
+ version = "0.2.0"
8
8
  description = "pytest plugin for executable-stories BDD documentation"
9
9
  requires-python = ">=3.10"
10
10
  license = "Apache-2.0"
@@ -172,6 +172,13 @@ def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) ->
172
172
  if report.when != "call":
173
173
  return
174
174
 
175
+ # A red test shows how far its story got without opening the report. A
176
+ # section, not longrepr, so the recorded error message stays as it was.
177
+ if report.failed:
178
+ trail = story._step_trail()
179
+ if trail:
180
+ report.sections.append(("Story steps", trail))
181
+
175
182
  start_ms = _test_start_times.pop(item.nodeid, 0.0)
176
183
  now_ms = time.time() * 1000
177
184
  duration_ms = now_ms - start_ms if start_ms else 0.0
@@ -49,6 +49,8 @@ class _StoryContext:
49
49
  "timer_counter",
50
50
  "otel_spans",
51
51
  "planned",
52
+ "started_at_ms",
53
+ "failed_step",
52
54
  )
53
55
 
54
56
  def __init__(
@@ -76,6 +78,9 @@ class _StoryContext:
76
78
  self.timer_counter: int = 0
77
79
  self.otel_spans: list[dict[str, Any]] | None = None
78
80
  self.planned: bool = False
81
+ self.started_at_ms: float = time.time() * 1000
82
+ # The wrapped step whose body raised, if one did.
83
+ self.failed_step: dict[str, Any] | None = None
79
84
 
80
85
 
81
86
  class Story:
@@ -246,8 +251,29 @@ class Story:
246
251
  result["docs"] = list(ctx.docs)
247
252
  if ctx.otel_spans:
248
253
  result["otelSpans"] = ctx.otel_spans
254
+ # A planned scenario never ran, so it has no start time.
255
+ if not ctx.planned:
256
+ result["startedAtMs"] = ctx.started_at_ms
249
257
  return result
250
258
 
259
+ def _step_trail(self) -> str | None:
260
+ """The story's steps as a checklist for a failed test's report.
261
+
262
+ The failing step is the wrapped step whose body raised, else the last
263
+ step: a marker states a claim and the assertion that broke follows it.
264
+ When the last step is a wrapped body that finished, the failure came
265
+ after it.
266
+ """
267
+ ctx = self._ctx
268
+ if ctx is None or not ctx.steps:
269
+ return None
270
+ last = ctx.steps[-1]
271
+ failed = ctx.failed_step or (None if last.get("wrapped") else last)
272
+ lines = [f" {'✗' if s is failed else '✓'} {s['keyword']} {s['text']}" for s in ctx.steps]
273
+ if failed is None:
274
+ lines.append(" ✗ (failed after the last step)")
275
+ return "\n".join(lines)
276
+
251
277
  def _get_attachments(self) -> list[dict[str, Any]]:
252
278
  """Return the attachments list for the current test."""
253
279
  ctx = self._ctx
@@ -279,13 +305,15 @@ class Story:
279
305
  ctx = self._ctx
280
306
  if ctx is None:
281
307
  raise RuntimeError(f"story.{keyword.lower()}() called before story.init()")
308
+ # A new step means the test caught an earlier wrapped step's exception.
309
+ ctx.failed_step = None
282
310
  # Auto-And: repeated primary keywords render as "And"
283
311
  if keyword in ("Given", "When", "Then"):
284
312
  if keyword in ctx.seen_primary_keywords:
285
313
  keyword = "And"
286
314
  else:
287
315
  ctx.seen_primary_keywords.add(keyword)
288
- step: dict[str, Any] = {"keyword": keyword, "text": text}
316
+ step: dict[str, Any] = {"keyword": keyword, "text": text, "startedAtMs": time.time() * 1000}
289
317
  step["id"] = f"step-{ctx.step_counter}"
290
318
  ctx.step_counter += 1
291
319
  if mode is not None:
@@ -370,6 +398,11 @@ class Story:
370
398
  try:
371
399
  result = body()
372
400
  return result
401
+ except BaseException:
402
+ # A nested step that raised first keeps the mark.
403
+ if ctx.failed_step is None:
404
+ ctx.failed_step = step
405
+ raise
373
406
  finally:
374
407
  step["durationMs"] = (time.perf_counter() - start) * 1000.0
375
408
 
@@ -189,6 +189,7 @@ class StoryStep(TypedDict):
189
189
  # An absent key means unobserved, which is not the same as zero.
190
190
  assertions: NotRequired[int]
191
191
  durationMs: NotRequired[float]
192
+ startedAtMs: NotRequired[float]
192
193
  docs: NotRequired[list[DocEntry]]
193
194
 
194
195
 
@@ -205,6 +206,7 @@ class StoryMeta(TypedDict):
205
206
  suitePath: NotRequired[list[str]]
206
207
  docs: NotRequired[list[DocEntry]]
207
208
  sourceOrder: NotRequired[int]
209
+ startedAtMs: NotRequired[float]
208
210
 
209
211
 
210
212
  # ── RawTestCase ────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: executable-stories-pytest
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: pytest plugin for executable-stories BDD documentation
5
5
  Author: Jag Reehal
6
6
  License-Expression: Apache-2.0
@@ -273,3 +273,74 @@ def test_ordinary_story():
273
273
  raw_run = json.loads((pytester.path / ".executable-stories" / "raw-run.json").read_text())
274
274
  statuses = sorted(tc["status"] for tc in raw_run["testCases"])
275
275
  assert statuses == ["pass", "todo"]
276
+
277
+
278
+ class TestFailureStepTrail:
279
+ """A failing story test lists its steps, so a red run shows how far it got."""
280
+
281
+ def test_lists_steps_and_marks_the_failing_one(self, pytester):
282
+ pytester.makepyfile(
283
+ test_trail="""
284
+ from executable_stories import story
285
+
286
+ def test_marker():
287
+ story.init("Marker")
288
+ story.given("a balance of 50")
289
+ story.then("the balance is 150")
290
+ assert 50 + 100 == 151
291
+
292
+ def test_wrapped():
293
+ story.init("Wrapped")
294
+ story.fn("Given", "setup", lambda: None)
295
+ def boom():
296
+ raise RuntimeError("kaput")
297
+ story.fn("When", "it breaks", boom)
298
+ story.then("never reached")
299
+
300
+ def test_after_wrapped():
301
+ story.init("After")
302
+ story.fn("Given", "setup", lambda: None)
303
+ raise RuntimeError("later")
304
+
305
+ def test_caught():
306
+ story.init("Caught")
307
+ def rejected():
308
+ raise ValueError("rejected")
309
+ try:
310
+ story.fn("Given", "a rejected input", rejected)
311
+ except ValueError:
312
+ pass
313
+ story.then("the total is 3")
314
+ assert 1 + 2 == 4
315
+
316
+ def test_nested():
317
+ story.init("Nested")
318
+ def declined():
319
+ raise RuntimeError("declined")
320
+ story.fn("When", "the order is placed", lambda: story.fn("And", "payment is taken", declined))
321
+ """
322
+ )
323
+ result = pytester.runpytest_subprocess(*_DISABLE_PLUGINS)
324
+ result.assert_outcomes(failed=5)
325
+ result.stdout.fnmatch_lines(
326
+ [
327
+ "*Story steps*",
328
+ " ✓ Given a balance of 50",
329
+ " ✗ Then the balance is 150",
330
+ "*Story steps*",
331
+ " ✓ Given setup",
332
+ " ✗ When it breaks",
333
+ "*Story steps*",
334
+ " ✓ Given setup",
335
+ " ✗ (failed after the last step)",
336
+ "*Story steps*",
337
+ " ✓ Given a rejected input",
338
+ " ✗ Then the total is 3",
339
+ "*Story steps*",
340
+ " ✓ When the order is placed",
341
+ " ✗ And payment is taken",
342
+ ]
343
+ )
344
+ # The trail is a report section, not part of the recorded error.
345
+ raw = (pytester.path / ".executable-stories" / "raw-run.json").read_text()
346
+ assert "Story steps" not in raw
@@ -647,6 +647,18 @@ class TestTicketNormalization:
647
647
 
648
648
 
649
649
  class TestStepTiming:
650
+ def test_started_at_ms_on_scenario_and_steps(self, fresh_story: Story):
651
+ fresh_story.init("Start times")
652
+ fresh_story.given("a step")
653
+ fresh_story.fn("When", "a wrapped step", lambda: None)
654
+ meta = fresh_story._get_meta()
655
+ assert meta is not None
656
+ scenario_start = meta["startedAtMs"]
657
+ first, second = (s["startedAtMs"] for s in meta["steps"])
658
+ assert scenario_start > 1_000_000_000_000 # epoch ms, not seconds or zero
659
+ assert first >= scenario_start
660
+ assert second >= first
661
+
650
662
  def test_start_timer_end_timer_sets_duration_ms(self, fresh_story: Story):
651
663
  fresh_story.init("Timing test")
652
664
  fresh_story.given("a step")