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.
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/PKG-INFO +1 -1
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/pyproject.toml +1 -1
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_plugin.py +7 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_story_api.py +34 -1
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_types.py +2 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/PKG-INFO +1 -1
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_plugin.py +71 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_story_api.py +12 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/LICENSE +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/README.md +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/setup.cfg +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/__init__.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_collector.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_json_writer.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/SOURCES.txt +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/dependency_links.txt +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/entry_points.txt +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/requires.txt +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories_pytest.egg-info/top_level.txt +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_assertion_counts.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_json_output.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_name_filtering.py +0 -0
- {executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_run_inventory.py +0 -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
|
|
{executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/src/executable_stories/_types.py
RENAMED
|
@@ -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 ────────────────────────────────────────────────────
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_assertion_counts.py
RENAMED
|
File without changes
|
{executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_json_output.py
RENAMED
|
File without changes
|
{executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_name_filtering.py
RENAMED
|
File without changes
|
{executable_stories_pytest-0.1.1 → executable_stories_pytest-0.2.0}/tests/test_run_inventory.py
RENAMED
|
File without changes
|