executable-stories-pytest 0.1.0__py3-none-any.whl
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/__init__.py +5 -0
- executable_stories/_collector.py +37 -0
- executable_stories/_json_writer.py +21 -0
- executable_stories/_plugin.py +221 -0
- executable_stories/_story_api.py +554 -0
- executable_stories/_types.py +206 -0
- executable_stories_pytest-0.1.0.dist-info/METADATA +23 -0
- executable_stories_pytest-0.1.0.dist-info/RECORD +11 -0
- executable_stories_pytest-0.1.0.dist-info/WHEEL +5 -0
- executable_stories_pytest-0.1.0.dist-info/entry_points.txt +2 -0
- executable_stories_pytest-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Thread-safe test case collector.
|
|
2
|
+
|
|
3
|
+
Accumulates RawTestCase dicts as tests complete, then hands them off
|
|
4
|
+
to the JSON writer at session end.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _Collector:
|
|
14
|
+
"""Thread-safe registry for completed test case results."""
|
|
15
|
+
|
|
16
|
+
def __init__(self) -> None:
|
|
17
|
+
self._lock = threading.Lock()
|
|
18
|
+
self._cases: list[dict[str, Any]] = []
|
|
19
|
+
|
|
20
|
+
def record(self, test_case: dict[str, Any]) -> None:
|
|
21
|
+
"""Append a completed RawTestCase dict."""
|
|
22
|
+
with self._lock:
|
|
23
|
+
self._cases.append(test_case)
|
|
24
|
+
|
|
25
|
+
def get_all(self) -> list[dict[str, Any]]:
|
|
26
|
+
"""Return all collected test cases."""
|
|
27
|
+
with self._lock:
|
|
28
|
+
return list(self._cases)
|
|
29
|
+
|
|
30
|
+
def clear(self) -> None:
|
|
31
|
+
"""Reset the collector."""
|
|
32
|
+
with self._lock:
|
|
33
|
+
self._cases.clear()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Module-level singleton
|
|
37
|
+
_collector = _Collector()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Simple JSON serialization for RawRun output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def write_raw_run(raw_run: dict[str, Any], output_path: str) -> None:
|
|
11
|
+
"""Write a RawRun dict to a JSON file.
|
|
12
|
+
|
|
13
|
+
Creates parent directories if they don't exist.
|
|
14
|
+
"""
|
|
15
|
+
parent = os.path.dirname(output_path)
|
|
16
|
+
if parent:
|
|
17
|
+
os.makedirs(parent, exist_ok=True)
|
|
18
|
+
|
|
19
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
20
|
+
json.dump(raw_run, f, indent=2)
|
|
21
|
+
f.write("\n")
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""pytest plugin that hooks into test lifecycle to capture BDD story data.
|
|
2
|
+
|
|
3
|
+
Registered via the ``pytest11`` entry point in pyproject.toml.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from executable_stories._collector import _collector
|
|
16
|
+
from executable_stories._json_writer import write_raw_run
|
|
17
|
+
from executable_stories._story_api import story
|
|
18
|
+
|
|
19
|
+
# ── CI detection ──────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _detect_ci() -> dict[str, Any] | None:
|
|
23
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
24
|
+
ci: dict[str, Any] = {"name": "github"}
|
|
25
|
+
build_num = os.environ.get("GITHUB_RUN_NUMBER")
|
|
26
|
+
if build_num:
|
|
27
|
+
ci["buildNumber"] = build_num
|
|
28
|
+
server = os.environ.get("GITHUB_SERVER_URL")
|
|
29
|
+
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
30
|
+
run_id = os.environ.get("GITHUB_RUN_ID")
|
|
31
|
+
if server and repo and run_id:
|
|
32
|
+
ci["url"] = f"{server}/{repo}/actions/runs/{run_id}"
|
|
33
|
+
return ci
|
|
34
|
+
if os.environ.get("CIRCLECI") == "true":
|
|
35
|
+
ci = {"name": "circleci"}
|
|
36
|
+
build_num = os.environ.get("CIRCLE_BUILD_NUM")
|
|
37
|
+
if build_num:
|
|
38
|
+
ci["buildNumber"] = build_num
|
|
39
|
+
url = os.environ.get("CIRCLE_BUILD_URL")
|
|
40
|
+
if url:
|
|
41
|
+
ci["url"] = url
|
|
42
|
+
return ci
|
|
43
|
+
if os.environ.get("JENKINS_URL"):
|
|
44
|
+
ci = {"name": "jenkins"}
|
|
45
|
+
build_num = os.environ.get("BUILD_NUMBER")
|
|
46
|
+
if build_num:
|
|
47
|
+
ci["buildNumber"] = build_num
|
|
48
|
+
url = os.environ.get("BUILD_URL")
|
|
49
|
+
if url:
|
|
50
|
+
ci["url"] = url
|
|
51
|
+
return ci
|
|
52
|
+
if os.environ.get("TRAVIS") == "true":
|
|
53
|
+
ci = {"name": "travis"}
|
|
54
|
+
build_num = os.environ.get("TRAVIS_BUILD_NUMBER")
|
|
55
|
+
if build_num:
|
|
56
|
+
ci["buildNumber"] = build_num
|
|
57
|
+
url = os.environ.get("TRAVIS_BUILD_WEB_URL")
|
|
58
|
+
if url:
|
|
59
|
+
ci["url"] = url
|
|
60
|
+
return ci
|
|
61
|
+
if os.environ.get("GITLAB_CI") == "true":
|
|
62
|
+
ci = {"name": "gitlab"}
|
|
63
|
+
build_num = os.environ.get("CI_PIPELINE_IID")
|
|
64
|
+
if build_num:
|
|
65
|
+
ci["buildNumber"] = build_num
|
|
66
|
+
url = os.environ.get("CI_PIPELINE_URL")
|
|
67
|
+
if url:
|
|
68
|
+
ci["url"] = url
|
|
69
|
+
return ci
|
|
70
|
+
if os.environ.get("CI") == "true":
|
|
71
|
+
return {"name": "ci"}
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── Session-level timestamps ───────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
_started_at_ms: float = 0.0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
81
|
+
global _started_at_ms
|
|
82
|
+
_started_at_ms = time.time() * 1000
|
|
83
|
+
_collector.clear()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Per-test hooks ─────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
# We store per-test start times keyed by node id.
|
|
89
|
+
_test_start_times: dict[str, float] = {}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
93
|
+
def pytest_runtest_call(item: pytest.Item) -> Any:
|
|
94
|
+
"""Wrap test execution to set up / tear down the story context."""
|
|
95
|
+
_test_start_times[item.nodeid] = time.time() * 1000
|
|
96
|
+
yield
|
|
97
|
+
# Story context is cleared after report is built (in makereport)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_STATUS_MAP = {
|
|
101
|
+
"passed": "pass",
|
|
102
|
+
"failed": "fail",
|
|
103
|
+
"skipped": "skip",
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
108
|
+
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo[None]) -> Any:
|
|
109
|
+
"""Capture test result on the 'call' phase and record it."""
|
|
110
|
+
outcome = yield
|
|
111
|
+
report: pytest.TestReport = outcome.get_result()
|
|
112
|
+
|
|
113
|
+
if report.when != "call":
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Build the test case dict
|
|
117
|
+
start_ms = _test_start_times.pop(item.nodeid, 0.0)
|
|
118
|
+
now_ms = time.time() * 1000
|
|
119
|
+
duration_ms = now_ms - start_ms if start_ms else 0.0
|
|
120
|
+
|
|
121
|
+
# Status mapping
|
|
122
|
+
if hasattr(report, "wasxfail"):
|
|
123
|
+
status = "skip"
|
|
124
|
+
else:
|
|
125
|
+
status = _STATUS_MAP.get(report.outcome, "unknown")
|
|
126
|
+
|
|
127
|
+
# Retry info from pytest-rerunfailures (if available)
|
|
128
|
+
rerun = getattr(item, "execution_count", None)
|
|
129
|
+
retry = (rerun - 1) if rerun is not None and rerun > 0 else 0
|
|
130
|
+
reruns_count = getattr(item.session.config, "_rerun_count", None)
|
|
131
|
+
retries = reruns_count if reruns_count is not None else 0
|
|
132
|
+
|
|
133
|
+
test_case: dict[str, Any] = {
|
|
134
|
+
"status": status,
|
|
135
|
+
"externalId": item.nodeid,
|
|
136
|
+
"title": item.name,
|
|
137
|
+
"durationMs": round(duration_ms, 2),
|
|
138
|
+
"retry": retry,
|
|
139
|
+
"retries": retries,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# Source file info
|
|
143
|
+
try:
|
|
144
|
+
fspath = str(item.fspath)
|
|
145
|
+
if fspath:
|
|
146
|
+
test_case["sourceFile"] = fspath
|
|
147
|
+
if item.location and item.location[1] is not None:
|
|
148
|
+
test_case["sourceLine"] = item.location[1] + 1 # 0-based to 1-based
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Error info
|
|
153
|
+
if report.failed and report.longrepr:
|
|
154
|
+
error: dict[str, str] = {}
|
|
155
|
+
if isinstance(report.longrepr, tuple):
|
|
156
|
+
error["message"] = str(report.longrepr[2])
|
|
157
|
+
error["stack"] = f"{report.longrepr[0]}:{report.longrepr[1]}"
|
|
158
|
+
else:
|
|
159
|
+
repr_str = str(report.longrepr)
|
|
160
|
+
error["message"] = repr_str
|
|
161
|
+
error["stack"] = repr_str
|
|
162
|
+
test_case["error"] = error
|
|
163
|
+
|
|
164
|
+
# Story metadata
|
|
165
|
+
story_meta = story._get_meta()
|
|
166
|
+
if story_meta is not None:
|
|
167
|
+
test_case["story"] = story_meta
|
|
168
|
+
# Build stepEvents from steps with durationMs
|
|
169
|
+
step_events: list[dict[str, Any]] = []
|
|
170
|
+
if "steps" in story_meta:
|
|
171
|
+
for i, step in enumerate(story_meta["steps"]):
|
|
172
|
+
if "durationMs" in step:
|
|
173
|
+
step_events.append(
|
|
174
|
+
{
|
|
175
|
+
"index": i,
|
|
176
|
+
"title": step["text"],
|
|
177
|
+
"durationMs": step["durationMs"],
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
if step_events:
|
|
181
|
+
test_case["stepEvents"] = step_events
|
|
182
|
+
|
|
183
|
+
# Attachments
|
|
184
|
+
attachments = story._get_attachments()
|
|
185
|
+
if attachments:
|
|
186
|
+
test_case["attachments"] = attachments
|
|
187
|
+
|
|
188
|
+
_collector.record(test_case)
|
|
189
|
+
|
|
190
|
+
# Clear story context for next test
|
|
191
|
+
story._clear()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── Session finish — write output ──────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|
198
|
+
test_cases = _collector.get_all()
|
|
199
|
+
if not test_cases:
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
finished_at_ms = time.time() * 1000
|
|
203
|
+
|
|
204
|
+
raw_run: dict[str, Any] = {
|
|
205
|
+
"schemaVersion": 1,
|
|
206
|
+
"testCases": test_cases,
|
|
207
|
+
"projectRoot": str(session.config.rootdir), # type: ignore[attr-defined]
|
|
208
|
+
"startedAtMs": round(_started_at_ms, 2),
|
|
209
|
+
"finishedAtMs": round(finished_at_ms, 2),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
ci = _detect_ci()
|
|
213
|
+
if ci is not None:
|
|
214
|
+
raw_run["ci"] = ci
|
|
215
|
+
|
|
216
|
+
output_path = os.environ.get(
|
|
217
|
+
"EXECUTABLE_STORIES_OUTPUT",
|
|
218
|
+
os.path.join(str(session.config.rootdir), ".executable-stories", "raw-run.json"), # type: ignore[attr-defined]
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
write_raw_run(raw_run, output_path)
|
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
"""Thread-safe BDD story API.
|
|
2
|
+
|
|
3
|
+
Usage in tests::
|
|
4
|
+
|
|
5
|
+
from executable_stories import story
|
|
6
|
+
|
|
7
|
+
def test_login():
|
|
8
|
+
story.init("User logs in with valid credentials", tags=["auth", "smoke"])
|
|
9
|
+
story.given("a registered user")
|
|
10
|
+
story.when("they submit valid credentials")
|
|
11
|
+
story.then("they see the dashboard")
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any, Callable, TypeVar
|
|
21
|
+
|
|
22
|
+
_T = TypeVar("_T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _StoryContext:
|
|
26
|
+
"""Per-test story context stored in thread-local storage."""
|
|
27
|
+
|
|
28
|
+
__slots__ = (
|
|
29
|
+
"scenario",
|
|
30
|
+
"steps",
|
|
31
|
+
"tags",
|
|
32
|
+
"tickets",
|
|
33
|
+
"meta",
|
|
34
|
+
"suite_path",
|
|
35
|
+
"docs",
|
|
36
|
+
"seen_primary_keywords",
|
|
37
|
+
"step_counter",
|
|
38
|
+
"attachments",
|
|
39
|
+
"_current_step",
|
|
40
|
+
"active_timers",
|
|
41
|
+
"timer_counter",
|
|
42
|
+
"otel_spans",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
scenario: str,
|
|
48
|
+
*,
|
|
49
|
+
tags: list[str] | None = None,
|
|
50
|
+
tickets: list[dict[str, Any]] | None = None,
|
|
51
|
+
meta: dict[str, Any] | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.scenario = scenario
|
|
54
|
+
self.steps: list[dict[str, Any]] = []
|
|
55
|
+
self.tags = tags or []
|
|
56
|
+
self.tickets: list[dict[str, Any]] = tickets or []
|
|
57
|
+
self.meta = meta or {}
|
|
58
|
+
self.suite_path: list[str] = []
|
|
59
|
+
self.docs: list[dict[str, Any]] = []
|
|
60
|
+
self.seen_primary_keywords: set[str] = set()
|
|
61
|
+
self.step_counter: int = 0
|
|
62
|
+
self.attachments: list[dict[str, Any]] = []
|
|
63
|
+
self._current_step: dict[str, Any] | None = None
|
|
64
|
+
self.active_timers: dict[int, dict[str, Any]] = {}
|
|
65
|
+
self.timer_counter: int = 0
|
|
66
|
+
self.otel_spans: list[dict[str, Any]] | None = None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Story:
|
|
70
|
+
"""Thread-safe BDD story builder.
|
|
71
|
+
|
|
72
|
+
Each test thread gets its own context via ``threading.local()``.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self._local = threading.local()
|
|
77
|
+
|
|
78
|
+
# ── context management ─────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def _ctx(self) -> _StoryContext | None:
|
|
82
|
+
return getattr(self._local, "ctx", None)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _normalize_tickets(ticket: Any) -> list[dict[str, Any]] | None:
|
|
86
|
+
"""Normalize ticket input to list of ``{"id": ...}`` dicts."""
|
|
87
|
+
if ticket is None:
|
|
88
|
+
return None
|
|
89
|
+
if not isinstance(ticket, list):
|
|
90
|
+
ticket = [ticket]
|
|
91
|
+
return [{"id": t} if isinstance(t, str) else t for t in ticket]
|
|
92
|
+
|
|
93
|
+
def init(
|
|
94
|
+
self,
|
|
95
|
+
scenario: str,
|
|
96
|
+
*,
|
|
97
|
+
tags: list[str] | None = None,
|
|
98
|
+
ticket: str | list[str] | dict | list[dict | str] | None = None,
|
|
99
|
+
meta: dict[str, Any] | None = None,
|
|
100
|
+
trace_url_template: str | None = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Start a new story context for the current test."""
|
|
103
|
+
tickets = self._normalize_tickets(ticket)
|
|
104
|
+
self._local.ctx = _StoryContext(scenario, tags=tags, tickets=tickets, meta=meta)
|
|
105
|
+
|
|
106
|
+
# OTel bridge: detect active span, flow data bidirectionally
|
|
107
|
+
ctx = self._local.ctx
|
|
108
|
+
try:
|
|
109
|
+
from opentelemetry import trace as otel_trace # type: ignore[import-not-found]
|
|
110
|
+
|
|
111
|
+
span = otel_trace.get_current_span()
|
|
112
|
+
span_ctx = span.get_span_context()
|
|
113
|
+
if span_ctx and span_ctx.trace_id and span_ctx.trace_id != 0:
|
|
114
|
+
trace_id = format(span_ctx.trace_id, "032x")
|
|
115
|
+
span_id = format(span_ctx.span_id, "016x")
|
|
116
|
+
|
|
117
|
+
# OTel -> Story: capture traceId in structured meta
|
|
118
|
+
ctx.meta["otel"] = {"traceId": trace_id, "spanId": span_id}
|
|
119
|
+
|
|
120
|
+
# OTel -> Story: inject human-readable doc entries
|
|
121
|
+
ctx.docs.append({"kind": "kv", "label": "Trace ID", "value": trace_id, "phase": "runtime"})
|
|
122
|
+
|
|
123
|
+
template = trace_url_template or os.environ.get("OTEL_TRACE_URL_TEMPLATE")
|
|
124
|
+
if template:
|
|
125
|
+
url = template.replace("{traceId}", trace_id)
|
|
126
|
+
ctx.docs.append({"kind": "link", "label": "View Trace", "url": url, "phase": "runtime"})
|
|
127
|
+
|
|
128
|
+
# Story -> OTel: enrich active span with story attributes
|
|
129
|
+
span.set_attribute("story.scenario", scenario)
|
|
130
|
+
if tags:
|
|
131
|
+
span.set_attribute("story.tags", tags)
|
|
132
|
+
if ticket:
|
|
133
|
+
ticket_list = [ticket] if isinstance(ticket, str) else ticket
|
|
134
|
+
span.set_attribute("story.tickets", ticket_list)
|
|
135
|
+
except ImportError:
|
|
136
|
+
pass # opentelemetry not installed
|
|
137
|
+
except Exception:
|
|
138
|
+
pass # OTel not available or no active span
|
|
139
|
+
|
|
140
|
+
def _get_meta(self) -> dict[str, Any] | None:
|
|
141
|
+
"""Return the StoryMeta dict for the current test, or None."""
|
|
142
|
+
ctx = self._ctx
|
|
143
|
+
if ctx is None:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
result: dict[str, Any] = {"scenario": ctx.scenario}
|
|
147
|
+
if ctx.steps:
|
|
148
|
+
result["steps"] = list(ctx.steps)
|
|
149
|
+
if ctx.tags:
|
|
150
|
+
result["tags"] = list(ctx.tags)
|
|
151
|
+
if ctx.tickets:
|
|
152
|
+
result["tickets"] = list(ctx.tickets)
|
|
153
|
+
if ctx.meta:
|
|
154
|
+
result["meta"] = dict(ctx.meta)
|
|
155
|
+
if ctx.suite_path:
|
|
156
|
+
result["suitePath"] = list(ctx.suite_path)
|
|
157
|
+
if ctx.docs:
|
|
158
|
+
result["docs"] = list(ctx.docs)
|
|
159
|
+
if ctx.otel_spans:
|
|
160
|
+
result["otelSpans"] = ctx.otel_spans
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
def _get_attachments(self) -> list[dict[str, Any]]:
|
|
164
|
+
"""Return the attachments list for the current test."""
|
|
165
|
+
ctx = self._ctx
|
|
166
|
+
if ctx is None:
|
|
167
|
+
return []
|
|
168
|
+
return list(ctx.attachments)
|
|
169
|
+
|
|
170
|
+
def _require_context(self) -> _StoryContext:
|
|
171
|
+
"""Return the current context or raise."""
|
|
172
|
+
ctx = self._ctx
|
|
173
|
+
if ctx is None:
|
|
174
|
+
raise RuntimeError("story method called before story.init()")
|
|
175
|
+
return ctx
|
|
176
|
+
|
|
177
|
+
def _clear(self) -> None:
|
|
178
|
+
"""Clear the current test's story context."""
|
|
179
|
+
self._local.ctx = None
|
|
180
|
+
|
|
181
|
+
# ── BDD steps ──────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
def _add_step(
|
|
184
|
+
self,
|
|
185
|
+
keyword: str,
|
|
186
|
+
text: str,
|
|
187
|
+
*,
|
|
188
|
+
mode: str | None = None,
|
|
189
|
+
docs: list[dict[str, Any]] | None = None,
|
|
190
|
+
) -> None:
|
|
191
|
+
ctx = self._ctx
|
|
192
|
+
if ctx is None:
|
|
193
|
+
raise RuntimeError(f"story.{keyword.lower()}() called before story.init()")
|
|
194
|
+
# Auto-And: repeated primary keywords render as "And"
|
|
195
|
+
if keyword in ("Given", "When", "Then"):
|
|
196
|
+
if keyword in ctx.seen_primary_keywords:
|
|
197
|
+
keyword = "And"
|
|
198
|
+
else:
|
|
199
|
+
ctx.seen_primary_keywords.add(keyword)
|
|
200
|
+
step: dict[str, Any] = {"keyword": keyword, "text": text}
|
|
201
|
+
step["id"] = f"step-{ctx.step_counter}"
|
|
202
|
+
ctx.step_counter += 1
|
|
203
|
+
if mode is not None:
|
|
204
|
+
step["mode"] = mode
|
|
205
|
+
if docs:
|
|
206
|
+
step["docs"] = list(docs)
|
|
207
|
+
ctx.steps.append(step)
|
|
208
|
+
ctx._current_step = step
|
|
209
|
+
|
|
210
|
+
def given(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
211
|
+
self._add_step("Given", text, mode=mode, docs=docs)
|
|
212
|
+
|
|
213
|
+
def when(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
214
|
+
self._add_step("When", text, mode=mode, docs=docs)
|
|
215
|
+
|
|
216
|
+
def then(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
217
|
+
self._add_step("Then", text, mode=mode, docs=docs)
|
|
218
|
+
|
|
219
|
+
def and_(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
220
|
+
self._add_step("And", text, mode=mode, docs=docs)
|
|
221
|
+
|
|
222
|
+
def but(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
223
|
+
self._add_step("But", text, mode=mode, docs=docs)
|
|
224
|
+
|
|
225
|
+
# ── AAA pattern aliases ────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
def arrange(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
228
|
+
"""Add an Arrange step (alias for Given)."""
|
|
229
|
+
self._add_step("Given", text, mode=mode, docs=docs)
|
|
230
|
+
|
|
231
|
+
def act(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
232
|
+
"""Add an Act step (alias for When)."""
|
|
233
|
+
self._add_step("When", text, mode=mode, docs=docs)
|
|
234
|
+
|
|
235
|
+
def assert_(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
236
|
+
"""Add an Assert step (alias for Then). Uses assert_ because assert is reserved."""
|
|
237
|
+
self._add_step("Then", text, mode=mode, docs=docs)
|
|
238
|
+
|
|
239
|
+
# ── Additional aliases ─────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
def setup(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
242
|
+
"""Add a Setup step (alias for Given)."""
|
|
243
|
+
self._add_step("Given", text, mode=mode, docs=docs)
|
|
244
|
+
|
|
245
|
+
def context(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
246
|
+
"""Add a Context step (alias for Given)."""
|
|
247
|
+
self._add_step("Given", text, mode=mode, docs=docs)
|
|
248
|
+
|
|
249
|
+
def execute(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
250
|
+
"""Add an Execute step (alias for When)."""
|
|
251
|
+
self._add_step("When", text, mode=mode, docs=docs)
|
|
252
|
+
|
|
253
|
+
def action(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
254
|
+
"""Add an Action step (alias for When)."""
|
|
255
|
+
self._add_step("When", text, mode=mode, docs=docs)
|
|
256
|
+
|
|
257
|
+
def verify(self, text: str, *, docs: list[dict[str, Any]] | None = None, mode: str | None = None) -> None:
|
|
258
|
+
"""Add a Verify step (alias for Then)."""
|
|
259
|
+
self._add_step("Then", text, mode=mode, docs=docs)
|
|
260
|
+
|
|
261
|
+
# ── Wrapped step execution ────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def fn(self, keyword: str, text: str, body: Callable[[], _T]) -> _T:
|
|
264
|
+
"""Wrap a callable as a BDD step with automatic timing.
|
|
265
|
+
|
|
266
|
+
Creates a step marked as ``wrapped=True``, executes *body*,
|
|
267
|
+
records ``durationMs``, and re-raises any exception.
|
|
268
|
+
"""
|
|
269
|
+
self._add_step(keyword, text)
|
|
270
|
+
ctx = self._require_context()
|
|
271
|
+
step = ctx._current_step
|
|
272
|
+
assert step is not None
|
|
273
|
+
step["wrapped"] = True
|
|
274
|
+
|
|
275
|
+
start = time.perf_counter()
|
|
276
|
+
try:
|
|
277
|
+
result = body()
|
|
278
|
+
return result
|
|
279
|
+
finally:
|
|
280
|
+
step["durationMs"] = (time.perf_counter() - start) * 1000.0
|
|
281
|
+
|
|
282
|
+
def expect(self, text: str, body: Callable[[], _T]) -> _T:
|
|
283
|
+
"""Shorthand for ``fn("Then", text, body)``."""
|
|
284
|
+
return self.fn("Then", text, body)
|
|
285
|
+
|
|
286
|
+
# ── Step timing ────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def start_timer(self) -> int:
|
|
289
|
+
"""Start a high-resolution timer tied to the current step.
|
|
290
|
+
Returns a token to pass to end_timer().
|
|
291
|
+
"""
|
|
292
|
+
ctx = self._require_context()
|
|
293
|
+
token = ctx.timer_counter
|
|
294
|
+
ctx.timer_counter += 1
|
|
295
|
+
|
|
296
|
+
entry: dict[str, Any] = {
|
|
297
|
+
"start": time.perf_counter(),
|
|
298
|
+
"consumed": False,
|
|
299
|
+
}
|
|
300
|
+
if ctx._current_step is not None:
|
|
301
|
+
entry["step_index"] = len(ctx.steps) - 1
|
|
302
|
+
entry["step_id"] = ctx._current_step.get("id")
|
|
303
|
+
|
|
304
|
+
ctx.active_timers[token] = entry
|
|
305
|
+
return token
|
|
306
|
+
|
|
307
|
+
def end_timer(self, token: int) -> None:
|
|
308
|
+
"""Stop the timer and record durationMs on the step that was active
|
|
309
|
+
when start_timer() was called. Double-end is a no-op.
|
|
310
|
+
"""
|
|
311
|
+
ctx = self._require_context()
|
|
312
|
+
entry = ctx.active_timers.get(token)
|
|
313
|
+
if entry is None or entry["consumed"]:
|
|
314
|
+
return
|
|
315
|
+
entry["consumed"] = True
|
|
316
|
+
|
|
317
|
+
duration_ms = (time.perf_counter() - entry["start"]) * 1000.0
|
|
318
|
+
|
|
319
|
+
step = None
|
|
320
|
+
step_id = entry.get("step_id")
|
|
321
|
+
if step_id is not None:
|
|
322
|
+
for s in ctx.steps:
|
|
323
|
+
if s.get("id") == step_id:
|
|
324
|
+
step = s
|
|
325
|
+
break
|
|
326
|
+
if step is None:
|
|
327
|
+
step_index = entry.get("step_index")
|
|
328
|
+
if step_index is not None and step_index < len(ctx.steps):
|
|
329
|
+
step = ctx.steps[step_index]
|
|
330
|
+
|
|
331
|
+
if step is not None:
|
|
332
|
+
step["durationMs"] = duration_ms
|
|
333
|
+
|
|
334
|
+
# ── attachments ───────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
def attach(
|
|
337
|
+
self,
|
|
338
|
+
name: str,
|
|
339
|
+
media_type: str,
|
|
340
|
+
*,
|
|
341
|
+
path: str | None = None,
|
|
342
|
+
body: str | None = None,
|
|
343
|
+
encoding: str | None = None,
|
|
344
|
+
charset: str | None = None,
|
|
345
|
+
file_name: str | None = None,
|
|
346
|
+
) -> "Story":
|
|
347
|
+
"""Attach a file or inline content to the current test."""
|
|
348
|
+
ctx = self._require_context()
|
|
349
|
+
a: dict[str, Any] = {"name": name, "mediaType": media_type}
|
|
350
|
+
if path is not None:
|
|
351
|
+
a["path"] = path
|
|
352
|
+
if body is not None:
|
|
353
|
+
a["body"] = body
|
|
354
|
+
if encoding is not None:
|
|
355
|
+
a["encoding"] = encoding
|
|
356
|
+
if charset is not None:
|
|
357
|
+
a["charset"] = charset
|
|
358
|
+
if file_name is not None:
|
|
359
|
+
a["fileName"] = file_name
|
|
360
|
+
if ctx._current_step is not None:
|
|
361
|
+
steps = ctx.steps
|
|
362
|
+
idx = len(steps) - 1
|
|
363
|
+
a["stepIndex"] = idx
|
|
364
|
+
a["stepId"] = ctx._current_step.get("id")
|
|
365
|
+
ctx.attachments.append(a)
|
|
366
|
+
return self
|
|
367
|
+
|
|
368
|
+
def attach_spans(self, spans: list[dict[str, Any]]) -> "Story":
|
|
369
|
+
"""Attach OTel spans for trace waterfall rendering in HTML reports."""
|
|
370
|
+
ctx = self._require_context()
|
|
371
|
+
ctx.otel_spans = spans
|
|
372
|
+
return self
|
|
373
|
+
|
|
374
|
+
# ── doc helpers ────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
def _attach_doc(self, entry: dict[str, Any]) -> None:
|
|
377
|
+
"""Attach a doc entry to the current step or story-level docs."""
|
|
378
|
+
ctx = self._ctx
|
|
379
|
+
if ctx is None:
|
|
380
|
+
raise RuntimeError("Doc method called before story.init()")
|
|
381
|
+
if ctx.steps:
|
|
382
|
+
# Attach to last step
|
|
383
|
+
last = ctx.steps[-1]
|
|
384
|
+
if "docs" not in last:
|
|
385
|
+
last["docs"] = []
|
|
386
|
+
last["docs"].append(entry)
|
|
387
|
+
else:
|
|
388
|
+
# Attach to story-level docs
|
|
389
|
+
ctx.docs.append(entry)
|
|
390
|
+
|
|
391
|
+
def _current_step_docs(self) -> list[dict[str, Any]]:
|
|
392
|
+
"""Return the docs list of the current (last) step."""
|
|
393
|
+
ctx = self._require_context()
|
|
394
|
+
last = ctx.steps[-1]
|
|
395
|
+
if "docs" not in last:
|
|
396
|
+
last["docs"] = []
|
|
397
|
+
return last["docs"]
|
|
398
|
+
|
|
399
|
+
def _story_docs(self) -> list[dict[str, Any]]:
|
|
400
|
+
"""Return the story-level docs list."""
|
|
401
|
+
return self._require_context().docs
|
|
402
|
+
|
|
403
|
+
def _dedup_children(self, children: list[dict[str, Any]]) -> None:
|
|
404
|
+
"""Remove children from all flat containers so they only appear nested."""
|
|
405
|
+
child_ids = {id(c) for c in children}
|
|
406
|
+
ctx = self._require_context()
|
|
407
|
+
ctx.docs[:] = [d for d in ctx.docs if id(d) not in child_ids]
|
|
408
|
+
for step in ctx.steps:
|
|
409
|
+
docs = step.get("docs")
|
|
410
|
+
if docs is not None:
|
|
411
|
+
step["docs"] = [d for d in docs if id(d) not in child_ids]
|
|
412
|
+
|
|
413
|
+
def _apply_children(self, entry: dict[str, Any], children: list[dict[str, Any]] | None) -> None:
|
|
414
|
+
"""If children are provided, attach them and deduplicate."""
|
|
415
|
+
if children:
|
|
416
|
+
entry["children"] = children
|
|
417
|
+
self._dedup_children(children)
|
|
418
|
+
|
|
419
|
+
def note(self, text: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
420
|
+
"""Add a free-text note."""
|
|
421
|
+
entry: dict[str, Any] = {"kind": "note", "text": text, "phase": "runtime"}
|
|
422
|
+
if children:
|
|
423
|
+
entry["children"] = children
|
|
424
|
+
self._dedup_children(children)
|
|
425
|
+
self._attach_doc(entry)
|
|
426
|
+
return entry
|
|
427
|
+
|
|
428
|
+
def tag(self, name_or_names: str | list[str], *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
429
|
+
"""Add tag(s) as a doc entry."""
|
|
430
|
+
names = [name_or_names] if isinstance(name_or_names, str) else list(name_or_names)
|
|
431
|
+
entry: dict[str, Any] = {"kind": "tag", "names": names, "phase": "runtime"}
|
|
432
|
+
if children:
|
|
433
|
+
entry["children"] = children
|
|
434
|
+
self._dedup_children(children)
|
|
435
|
+
self._attach_doc(entry)
|
|
436
|
+
return entry
|
|
437
|
+
|
|
438
|
+
def kv(self, label: str, value: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
439
|
+
"""Add a key-value pair."""
|
|
440
|
+
entry: dict[str, Any] = {"kind": "kv", "label": label, "value": value, "phase": "runtime"}
|
|
441
|
+
if children:
|
|
442
|
+
entry["children"] = children
|
|
443
|
+
self._dedup_children(children)
|
|
444
|
+
self._attach_doc(entry)
|
|
445
|
+
return entry
|
|
446
|
+
|
|
447
|
+
def json(self, label: str, value: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
448
|
+
"""Add a JSON code block (serialized with indent=2)."""
|
|
449
|
+
entry: dict[str, Any] = {
|
|
450
|
+
"kind": "code",
|
|
451
|
+
"label": label,
|
|
452
|
+
"content": json.dumps(value, indent=2),
|
|
453
|
+
"lang": "json",
|
|
454
|
+
"phase": "runtime",
|
|
455
|
+
}
|
|
456
|
+
if children:
|
|
457
|
+
entry["children"] = children
|
|
458
|
+
self._dedup_children(children)
|
|
459
|
+
self._attach_doc(entry)
|
|
460
|
+
return entry
|
|
461
|
+
|
|
462
|
+
def code(self, label: str, content: str, *, lang: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
463
|
+
"""Add a code block."""
|
|
464
|
+
entry: dict[str, Any] = {
|
|
465
|
+
"kind": "code",
|
|
466
|
+
"label": label,
|
|
467
|
+
"content": content,
|
|
468
|
+
"phase": "runtime",
|
|
469
|
+
}
|
|
470
|
+
if lang is not None:
|
|
471
|
+
entry["lang"] = lang
|
|
472
|
+
if children:
|
|
473
|
+
entry["children"] = children
|
|
474
|
+
self._dedup_children(children)
|
|
475
|
+
self._attach_doc(entry)
|
|
476
|
+
return entry
|
|
477
|
+
|
|
478
|
+
def table(self, label: str, columns: list[str], rows: list[list[str]], *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
479
|
+
"""Add a table."""
|
|
480
|
+
entry: dict[str, Any] = {
|
|
481
|
+
"kind": "table",
|
|
482
|
+
"label": label,
|
|
483
|
+
"columns": columns,
|
|
484
|
+
"rows": rows,
|
|
485
|
+
"phase": "runtime",
|
|
486
|
+
}
|
|
487
|
+
if children:
|
|
488
|
+
entry["children"] = children
|
|
489
|
+
self._dedup_children(children)
|
|
490
|
+
self._attach_doc(entry)
|
|
491
|
+
return entry
|
|
492
|
+
|
|
493
|
+
def link(self, label: str, url: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
494
|
+
"""Add a hyperlink."""
|
|
495
|
+
entry: dict[str, Any] = {"kind": "link", "label": label, "url": url, "phase": "runtime"}
|
|
496
|
+
if children:
|
|
497
|
+
entry["children"] = children
|
|
498
|
+
self._dedup_children(children)
|
|
499
|
+
self._attach_doc(entry)
|
|
500
|
+
return entry
|
|
501
|
+
|
|
502
|
+
def section(self, title: str, markdown: str, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
503
|
+
"""Add a titled markdown section."""
|
|
504
|
+
entry: dict[str, Any] = {
|
|
505
|
+
"kind": "section",
|
|
506
|
+
"title": title,
|
|
507
|
+
"markdown": markdown,
|
|
508
|
+
"phase": "runtime",
|
|
509
|
+
}
|
|
510
|
+
if children:
|
|
511
|
+
entry["children"] = children
|
|
512
|
+
self._dedup_children(children)
|
|
513
|
+
self._attach_doc(entry)
|
|
514
|
+
return entry
|
|
515
|
+
|
|
516
|
+
def mermaid(self, code: str, *, title: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
517
|
+
"""Add a Mermaid diagram."""
|
|
518
|
+
entry: dict[str, Any] = {"kind": "mermaid", "code": code, "phase": "runtime"}
|
|
519
|
+
if title is not None:
|
|
520
|
+
entry["title"] = title
|
|
521
|
+
if children:
|
|
522
|
+
entry["children"] = children
|
|
523
|
+
self._dedup_children(children)
|
|
524
|
+
self._attach_doc(entry)
|
|
525
|
+
return entry
|
|
526
|
+
|
|
527
|
+
def screenshot(self, path: str, *, alt: str | None = None, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
528
|
+
"""Add a screenshot reference."""
|
|
529
|
+
entry: dict[str, Any] = {"kind": "screenshot", "path": path, "phase": "runtime"}
|
|
530
|
+
if alt is not None:
|
|
531
|
+
entry["alt"] = alt
|
|
532
|
+
if children:
|
|
533
|
+
entry["children"] = children
|
|
534
|
+
self._dedup_children(children)
|
|
535
|
+
self._attach_doc(entry)
|
|
536
|
+
return entry
|
|
537
|
+
|
|
538
|
+
def custom(self, type: str, data: Any, *, children: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
|
539
|
+
"""Add a custom doc entry."""
|
|
540
|
+
entry: dict[str, Any] = {
|
|
541
|
+
"kind": "custom",
|
|
542
|
+
"type": type,
|
|
543
|
+
"data": data,
|
|
544
|
+
"phase": "runtime",
|
|
545
|
+
}
|
|
546
|
+
if children:
|
|
547
|
+
entry["children"] = children
|
|
548
|
+
self._dedup_children(children)
|
|
549
|
+
self._attach_doc(entry)
|
|
550
|
+
return entry
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# Module-level singleton
|
|
554
|
+
story = Story()
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Type definitions matching the raw-run.schema.json schema.
|
|
2
|
+
|
|
3
|
+
These are plain TypedDicts used for documentation and type-checking.
|
|
4
|
+
At runtime everything is just dicts serialized to JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, NotRequired, TypedDict
|
|
10
|
+
|
|
11
|
+
# ── DocEntry variants ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class NoteDoc(TypedDict):
|
|
15
|
+
kind: str # "note"
|
|
16
|
+
text: str
|
|
17
|
+
phase: str # "static" | "runtime"
|
|
18
|
+
children: NotRequired[list["DocEntry"]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TagDoc(TypedDict):
|
|
22
|
+
kind: str # "tag"
|
|
23
|
+
names: list[str]
|
|
24
|
+
phase: str
|
|
25
|
+
children: NotRequired[list["DocEntry"]]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class KvDoc(TypedDict):
|
|
29
|
+
kind: str # "kv"
|
|
30
|
+
label: str
|
|
31
|
+
value: Any
|
|
32
|
+
phase: str
|
|
33
|
+
children: NotRequired[list["DocEntry"]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CodeDoc(TypedDict):
|
|
37
|
+
kind: str # "code"
|
|
38
|
+
label: str
|
|
39
|
+
content: str
|
|
40
|
+
phase: str
|
|
41
|
+
lang: NotRequired[str]
|
|
42
|
+
children: NotRequired[list["DocEntry"]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TableDoc(TypedDict):
|
|
46
|
+
kind: str # "table"
|
|
47
|
+
label: str
|
|
48
|
+
columns: list[str]
|
|
49
|
+
rows: list[list[str]]
|
|
50
|
+
phase: str
|
|
51
|
+
children: NotRequired[list["DocEntry"]]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class LinkDoc(TypedDict):
|
|
55
|
+
kind: str # "link"
|
|
56
|
+
label: str
|
|
57
|
+
url: str
|
|
58
|
+
phase: str
|
|
59
|
+
children: NotRequired[list["DocEntry"]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SectionDoc(TypedDict):
|
|
63
|
+
kind: str # "section"
|
|
64
|
+
title: str
|
|
65
|
+
markdown: str
|
|
66
|
+
phase: str
|
|
67
|
+
children: NotRequired[list["DocEntry"]]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MermaidDoc(TypedDict):
|
|
71
|
+
kind: str # "mermaid"
|
|
72
|
+
code: str
|
|
73
|
+
phase: str
|
|
74
|
+
title: NotRequired[str]
|
|
75
|
+
children: NotRequired[list["DocEntry"]]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ScreenshotDoc(TypedDict):
|
|
79
|
+
kind: str # "screenshot"
|
|
80
|
+
path: str
|
|
81
|
+
phase: str
|
|
82
|
+
alt: NotRequired[str]
|
|
83
|
+
children: NotRequired[list["DocEntry"]]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CustomDoc(TypedDict):
|
|
87
|
+
kind: str # "custom"
|
|
88
|
+
type: str
|
|
89
|
+
data: Any
|
|
90
|
+
phase: str
|
|
91
|
+
children: NotRequired[list["DocEntry"]]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# Union of all doc entry types
|
|
95
|
+
DocEntry = NoteDoc | TagDoc | KvDoc | CodeDoc | TableDoc | LinkDoc | SectionDoc | MermaidDoc | ScreenshotDoc | CustomDoc
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── Ticket ────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class TicketDoc(TypedDict):
|
|
102
|
+
id: str
|
|
103
|
+
url: NotRequired[str]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── Attachment ────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Attachment(TypedDict, total=False):
|
|
110
|
+
name: str
|
|
111
|
+
mediaType: str
|
|
112
|
+
path: str
|
|
113
|
+
body: str
|
|
114
|
+
encoding: str # "BASE64" | "IDENTITY"
|
|
115
|
+
charset: str
|
|
116
|
+
fileName: str
|
|
117
|
+
byteLength: int
|
|
118
|
+
stepIndex: int
|
|
119
|
+
stepId: str
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── RawStepEvent ──────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class RawStepEvent(TypedDict, total=False):
|
|
126
|
+
index: int
|
|
127
|
+
title: str
|
|
128
|
+
status: str
|
|
129
|
+
durationMs: float
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── StoryStep ──────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class StoryStep(TypedDict):
|
|
136
|
+
keyword: str # "Given" | "When" | "Then" | "And" | "But"
|
|
137
|
+
text: str
|
|
138
|
+
id: NotRequired[str]
|
|
139
|
+
mode: NotRequired[str]
|
|
140
|
+
wrapped: NotRequired[bool]
|
|
141
|
+
durationMs: NotRequired[float]
|
|
142
|
+
docs: NotRequired[list[DocEntry]]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ── StoryMeta ──────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class StoryMeta(TypedDict):
|
|
149
|
+
scenario: str
|
|
150
|
+
steps: NotRequired[list[StoryStep]]
|
|
151
|
+
tags: NotRequired[list[str]]
|
|
152
|
+
tickets: NotRequired[list[TicketDoc]]
|
|
153
|
+
meta: NotRequired[dict[str, Any]]
|
|
154
|
+
suitePath: NotRequired[list[str]]
|
|
155
|
+
docs: NotRequired[list[DocEntry]]
|
|
156
|
+
sourceOrder: NotRequired[int]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ── RawTestCase ────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class ErrorInfo(TypedDict, total=False):
|
|
163
|
+
message: str
|
|
164
|
+
stack: str
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class RawTestCase(TypedDict):
|
|
168
|
+
status: str # "pass" | "fail" | "skip" | ...
|
|
169
|
+
externalId: NotRequired[str]
|
|
170
|
+
title: NotRequired[str]
|
|
171
|
+
titlePath: NotRequired[list[str]]
|
|
172
|
+
story: NotRequired[StoryMeta]
|
|
173
|
+
sourceFile: NotRequired[str]
|
|
174
|
+
sourceLine: NotRequired[int]
|
|
175
|
+
durationMs: NotRequired[float]
|
|
176
|
+
error: NotRequired[ErrorInfo]
|
|
177
|
+
meta: NotRequired[dict[str, Any]]
|
|
178
|
+
retry: NotRequired[int]
|
|
179
|
+
retries: NotRequired[int]
|
|
180
|
+
attachments: NotRequired[list[Attachment]]
|
|
181
|
+
stepEvents: NotRequired[list[RawStepEvent]]
|
|
182
|
+
projectName: NotRequired[str]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── RawCIInfo ──────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class RawCIInfo(TypedDict):
|
|
189
|
+
name: str
|
|
190
|
+
url: NotRequired[str]
|
|
191
|
+
buildNumber: NotRequired[str]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── RawRun ─────────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class RawRun(TypedDict):
|
|
198
|
+
schemaVersion: int # must be 1
|
|
199
|
+
testCases: list[RawTestCase]
|
|
200
|
+
projectRoot: str
|
|
201
|
+
startedAtMs: NotRequired[float]
|
|
202
|
+
finishedAtMs: NotRequired[float]
|
|
203
|
+
packageVersion: NotRequired[str]
|
|
204
|
+
gitSha: NotRequired[str]
|
|
205
|
+
ci: NotRequired[RawCIInfo]
|
|
206
|
+
meta: NotRequired[dict[str, Any]]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: executable-stories-pytest
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: pytest plugin for executable-stories BDD documentation
|
|
5
|
+
Author: Jag Reehal
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jagreehal/executable-stories
|
|
8
|
+
Project-URL: Repository, https://github.com/jagreehal/executable-stories.git
|
|
9
|
+
Project-URL: Documentation, https://github.com/jagreehal/executable-stories#readme
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Provides-Extra: otel
|
|
13
|
+
Requires-Dist: opentelemetry-api>=1.20; extra == "otel"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
16
|
+
Requires-Dist: mypy>=1.5.0; extra == "dev"
|
|
17
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
18
|
+
|
|
19
|
+
# executable-stories-pytest
|
|
20
|
+
|
|
21
|
+
pytest plugin for executable-stories.
|
|
22
|
+
|
|
23
|
+
Adds BDD-style story helpers and structured story metadata so pytest tests can generate executable documentation.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
executable_stories/__init__.py,sha256=X-m5x5nt_kkzd8IGJZ52e6lfr6aDArhbXci9K0fLWLM,132
|
|
2
|
+
executable_stories/_collector.py,sha256=JCl4crRHdAx1qqqW6ia7ABWi2yG4NN91IPZgB-VWVOs,932
|
|
3
|
+
executable_stories/_json_writer.py,sha256=l0wlGiijxTtEzdQ4N0oQJZI4uEHer-aIAUNlFQ-wgws,528
|
|
4
|
+
executable_stories/_plugin.py,sha256=f-QhkcyndouaymIiM38GOg63HRrvVN9U7lXnIiZZAyA,7190
|
|
5
|
+
executable_stories/_story_api.py,sha256=jOSf4-9MJGumVGhlKrX6hbEsD7tXY3V0D99Fxp3wGMw,21381
|
|
6
|
+
executable_stories/_types.py,sha256=iIoBdZ63eT6TIkimO7o_PXMzVSBBsope5L4gGL5ulAw,5648
|
|
7
|
+
executable_stories_pytest-0.1.0.dist-info/METADATA,sha256=nQLaU3J04FiaKUgN1wGAxJqYBk5Ho3wqimVZl_aP_oY,882
|
|
8
|
+
executable_stories_pytest-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
9
|
+
executable_stories_pytest-0.1.0.dist-info/entry_points.txt,sha256=NXzZqAD31EI6q3ovbbWQE7VOzsok09tPpmd-QPgSTiE,59
|
|
10
|
+
executable_stories_pytest-0.1.0.dist-info/top_level.txt,sha256=0CmAUpeJrtl-ykfogdr3L373JMtP92uU1P7-GIlbF7I,19
|
|
11
|
+
executable_stories_pytest-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
executable_stories
|