steppy-dev 0.3.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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,546 @@
1
+ """Test outcomes: event JSONL and JUnit XML → ``manifest.tests[]``.
2
+
3
+ Two sources, deliberately, and they are not redundant.
4
+
5
+ The **event JSONL** is primary. It carries every field a ``BundleTest`` needs
6
+ losslessly — including ``locale``/``layout``, which the plugin reads off the
7
+ pytest item's ``callspec.params`` rather than parsing out of a nodeid. That
8
+ matters more than it sounds: a locale like ``fr-BE`` contains the same separator
9
+ the id joins on, which makes string parsing wrong rather than merely awkward.
10
+
11
+ **JUnit XML** is the fallback and the cross-check: it covers tests the plugin
12
+ never saw (a stale generated tree, a crash before the plugin loaded), and it is
13
+ the artifact a human drops into any CI's test reporter. Rows sourced from it
14
+ carry ``locale``/``layout`` of ``None`` rather than a guess — the honest
15
+ degradation, and visible in the manifest.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ import json
22
+ import xml.etree.ElementTree as ET
23
+ from pathlib import Path
24
+ from typing import Optional
25
+
26
+ from ..contracts.bundle import BundleTest
27
+
28
+ #: Matches ``_steppy_events._FAIL_TAIL_LINES`` so a junit-sourced tail is the
29
+ #: same size as an event-sourced one.
30
+ FAIL_TAIL_LINES = 15
31
+
32
+
33
+ def _tail_b64(text: str, n: int = FAIL_TAIL_LINES) -> Optional[str]:
34
+ if not text:
35
+ return None
36
+ tail = "\n".join(text.splitlines()[-n:])
37
+ return base64.b64encode(tail.encode("utf-8", errors="replace")).decode("ascii")
38
+
39
+
40
+ def scenario_from_nodeid(nodeid: str) -> Optional[str]:
41
+ """Recover a scenario slug from a pytest node id's module path.
42
+
43
+ ``tests/e2e/test_order-lifecycle.py::test_flow[fr-mobile]`` →
44
+ ``order-lifecycle``. Exact, because the generator names every file
45
+ ``test_<slug>.py`` — unlike the parametrization id, which cannot be parsed
46
+ back into a locale and layout.
47
+
48
+ This is the fallback for rows that reach us without one: a test module
49
+ missing its baked ``SCENARIO``, or a JUnit-only row. Every ``BundleTest``
50
+ must carry a scenario — a Steppy test belongs to one by construction, and
51
+ the contract types it as required.
52
+ """
53
+ head = nodeid.split("::", 1)[0]
54
+ stem = head.replace("\\", "/").rsplit("/", 1)[-1]
55
+ if stem.endswith(".py"):
56
+ stem = stem[: -len(".py")]
57
+ if stem.startswith("test_"):
58
+ stem = stem[len("test_") :]
59
+ return stem or None
60
+
61
+
62
+ # ── event JSONL ─────────────────────────────────────────────────────────────
63
+
64
+
65
+ def read_event_files(events_dir: Path) -> list[Path]:
66
+ if not events_dir.is_dir():
67
+ return []
68
+ return sorted(p for p in events_dir.glob("*.jsonl") if p.is_file())
69
+
70
+
71
+ def tests_from_events(events_dir: Path) -> tuple[list[BundleTest], list[str]]:
72
+ """Fold the JSONL streams into one ``BundleTest`` per nodeid.
73
+
74
+ Returns ``(tests, warnings)``. Malformed lines are skipped rather than
75
+ fatal — a killed process leaves a torn last line, and losing one event is
76
+ not worth losing the run's results over. Same posture as
77
+ ``history.load``.
78
+ """
79
+ by_nodeid: dict[str, dict] = {}
80
+ warnings: list[str] = []
81
+ torn = 0
82
+
83
+ for path in read_event_files(events_dir):
84
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
85
+ line = line.strip()
86
+ if not line:
87
+ continue
88
+ try:
89
+ event = json.loads(line)
90
+ except ValueError:
91
+ torn += 1
92
+ continue
93
+ if not isinstance(event, dict):
94
+ torn += 1
95
+ continue
96
+ nodeid = event.get("nodeid")
97
+ if not nodeid:
98
+ continue
99
+ row = by_nodeid.setdefault(
100
+ nodeid,
101
+ {
102
+ "nodeid": nodeid,
103
+ "scenario": event.get("scenario"),
104
+ "locale": event.get("locale"),
105
+ "layout": event.get("layout"),
106
+ "outcome": None,
107
+ "duration_ms": None,
108
+ "fail_tail_b64": None,
109
+ "open_step": None,
110
+ },
111
+ )
112
+ kind = event.get("kind")
113
+ if kind == "test-end":
114
+ # Last writer wins: a retry pass re-runs the same nodeid and its
115
+ # final attempt is the run's verdict, matching how the
116
+ # coordinator classifies a pass-on-retry.
117
+ row["outcome"] = event.get("outcome")
118
+ row["duration_ms"] = event.get("duration_ms")
119
+ elif kind == "test-fail-tail" and event.get("phase") != "teardown":
120
+ row["fail_tail_b64"] = event.get("tail_b64")
121
+ elif kind == "test-start":
122
+ # A rerun of the same nodeid starts over — otherwise an unclosed
123
+ # step from attempt 1 would be blamed for attempt 2's result.
124
+ row["open_step"] = None
125
+ elif kind == "step-start":
126
+ row["open_step"] = event.get("step")
127
+ elif kind == "step-end":
128
+ # Steps are sequential, never nested, so at most one is open.
129
+ # Match on the number so an interleaved record from a rerun
130
+ # cannot close a step it did not open.
131
+ if row["open_step"] == event.get("step"):
132
+ row["open_step"] = None
133
+
134
+ if torn:
135
+ warnings.append(f"{torn} malformed event line(s) skipped in {events_dir}")
136
+
137
+ tests = []
138
+ for row in by_nodeid.values():
139
+ outcome = row["outcome"] or "error"
140
+ failed_step = _failed_step(row, outcome, warnings)
141
+ tests.append(
142
+ BundleTest(
143
+ nodeid=row["nodeid"],
144
+ # A module without its baked SCENARIO still has one in its filename.
145
+ scenario=row["scenario"] or scenario_from_nodeid(row["nodeid"]),
146
+ locale=row["locale"],
147
+ layout=row["layout"],
148
+ # A test that started and never ended — the worker died mid-scenario,
149
+ # or the run was interrupted. "error" is the honest reading: it did
150
+ # not pass, and we cannot say it failed on its own merits.
151
+ outcome=outcome,
152
+ duration_ms=row["duration_ms"],
153
+ fail_tail_b64=row["fail_tail_b64"],
154
+ failed_step=failed_step,
155
+ )
156
+ )
157
+ return tests, warnings
158
+
159
+
160
+ def _failed_step(row: dict, outcome: str, warnings: list[str]) -> Optional[int]:
161
+ """Which step was in flight when the test died — ``None`` if none was.
162
+
163
+ The unclosed ``step-start`` *is* the answer: the generated body emits its
164
+ ``step-end`` on the next line, so a step that raises never reaches it. Two
165
+ cases where the honest answer is ``None`` and a naive "last step reached"
166
+ would lie:
167
+
168
+ * the test passed — every step closed, nothing failed;
169
+ * the test failed in fixture teardown or between steps — every step closed
170
+ too, so blaming the final one would point a reader at working code.
171
+ """
172
+ open_step = row.get("open_step")
173
+ if open_step is None:
174
+ return None
175
+ if outcome not in ("failed", "error"):
176
+ # A passing test with an unclosed step means the emitter dropped a
177
+ # record. Say so rather than reporting a failure location for a test
178
+ # that did not fail.
179
+ warnings.append(
180
+ f"{row['nodeid']} reported outcome '{outcome}' with step {open_step} still open; "
181
+ "ignoring failed_step (a step-end record was lost)"
182
+ )
183
+ return None
184
+ return open_step
185
+
186
+
187
+ # ── per-step timeline ───────────────────────────────────────────────────────
188
+ #
189
+ # ``tests_from_events`` folds the same stream down to one verdict per nodeid and
190
+ # discards the ordering, which is right for ``manifest.tests[]`` and wrong for a
191
+ # reader that wants to show *what happened*. These two functions keep the JSONL
192
+ # grammar in this one module while exposing the timeline underneath it.
193
+ #
194
+ # They are text-based rather than directory-based on purpose: a bundle's events
195
+ # arrive as bytes read out of an archive, and forcing them through a temp
196
+ # directory just to reuse a ``Path``-shaped API would be the wrong trade.
197
+ #
198
+ # What the grammar guarantees, and what it does not:
199
+ #
200
+ # * ``step-start`` carries ``step``, ``title`` and ``action``; ``step-end``
201
+ # carries ``step``. Both exist only when ``STEPPY_STEP_EVENTS=1`` — which
202
+ # ``steppy run --bundle`` sets — so their absence is information, not an error.
203
+ # * Step numbers are **sparse**. A step skipped by ``only-in:`` or ``layouts:``
204
+ # emits neither marker, so gaps are expected and must never be renumbered.
205
+ # * No total-step-count exists anywhere in a bundle. "step 7", never "7 of 12".
206
+ # * ``ts`` is epoch seconds (``round(time.time(), 3)``). Offsets relative to the
207
+ # attempt's start are the useful, publishable form.
208
+
209
+
210
+ class StepRecord:
211
+ """One step's slice of an attempt's timeline.
212
+
213
+ ``status`` is ``passed`` when the step closed, ``running`` when it opened and
214
+ never did (which is the failing step, by the same rule as
215
+ :func:`_failed_step`), and ``duration_ms`` is ``None`` in that case because
216
+ the end is genuinely unknown rather than zero.
217
+ """
218
+
219
+ __slots__ = ("step", "title", "action", "status", "started_at", "ended_at")
220
+
221
+ def __init__(self, step: int, title: str = "", action: str = "", started_at: float = 0.0):
222
+ self.step = step
223
+ self.title = title
224
+ self.action = action
225
+ self.status = "running"
226
+ self.started_at = started_at
227
+ self.ended_at: Optional[float] = None
228
+
229
+ @property
230
+ def duration_ms(self) -> Optional[int]:
231
+ if self.ended_at is None:
232
+ return None
233
+ return max(int(round((self.ended_at - self.started_at) * 1000)), 0)
234
+
235
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
236
+ return f"StepRecord(step={self.step}, status={self.status!r}, title={self.title!r})"
237
+
238
+
239
+ class TestAttempt:
240
+ """One execution of one nodeid, with the steps it reached.
241
+
242
+ **Why attempts and not just steps.** The JSONL sink opens its file with
243
+ ``mode="w"`` on the first write *per process*. pytest ``--reruns`` reruns
244
+ in-process, so a retried test leaves both attempts in one file and this
245
+ reader can show "attempt 2 of 2". The parallel coordinator's retry pass is a
246
+ *new process*, so it truncates the file and attempt 1 is gone — that
247
+ asymmetry is real and worth reporting honestly rather than papering over.
248
+ """
249
+
250
+ __slots__ = ("nodeid", "scenario", "locale", "layout", "outcome", "duration_ms", "started_at", "steps")
251
+
252
+ def __init__(self, nodeid: str, scenario=None, locale=None, layout=None, started_at: float = 0.0):
253
+ self.nodeid = nodeid
254
+ self.scenario = scenario
255
+ self.locale = locale
256
+ self.layout = layout
257
+ self.outcome: Optional[str] = None
258
+ self.duration_ms: Optional[int] = None
259
+ self.started_at = started_at
260
+ self.steps: list[StepRecord] = []
261
+
262
+ @property
263
+ def failed_step(self) -> Optional[int]:
264
+ """The step that opened and never closed, or ``None``."""
265
+ for record in reversed(self.steps):
266
+ if record.status == "running":
267
+ return record.step
268
+ return None
269
+
270
+ def offsets(self) -> list[dict]:
271
+ """The timeline as plain dicts, offsets relative to this attempt's start.
272
+
273
+ Relative rather than absolute so the result is diffable and so a runner's
274
+ wall clock never leaks into a published artifact.
275
+ """
276
+ base = self.started_at or (self.steps[0].started_at if self.steps else 0.0)
277
+ rows = []
278
+ for record in self.steps:
279
+ rows.append(
280
+ {
281
+ "step": record.step,
282
+ "title": record.title,
283
+ "action": record.action,
284
+ "status": record.status,
285
+ "offset_ms": max(int(round((record.started_at - base) * 1000)), 0) if base else 0,
286
+ "duration_ms": record.duration_ms,
287
+ }
288
+ )
289
+ return rows
290
+
291
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
292
+ return f"TestAttempt(nodeid={self.nodeid!r}, outcome={self.outcome!r}, steps={len(self.steps)})"
293
+
294
+
295
+ def parse_event_lines(texts) -> tuple[list[dict], list[str]]:
296
+ """Parse ``(label, text)`` pairs of JSONL into records, in file order.
297
+
298
+ Torn and non-object lines are counted and reported, never fatal — a killed
299
+ process leaves a half-written last line, and losing one event is not worth
300
+ losing a run's timeline over. Same posture as :func:`tests_from_events` and
301
+ ``history.load``.
302
+ """
303
+ records: list[dict] = []
304
+ warnings: list[str] = []
305
+ for label, text in texts:
306
+ torn = 0
307
+ for line in (text or "").splitlines():
308
+ line = line.strip()
309
+ if not line:
310
+ continue
311
+ try:
312
+ event = json.loads(line)
313
+ except ValueError:
314
+ torn += 1
315
+ continue
316
+ if not isinstance(event, dict):
317
+ torn += 1
318
+ continue
319
+ records.append(event)
320
+ if torn:
321
+ warnings.append(f"{torn} malformed event line(s) skipped in {label}")
322
+ return records, warnings
323
+
324
+
325
+ def read_event_texts(events_dir: Path) -> list[tuple[str, str]]:
326
+ """``(filename, text)`` for every JSONL under *events_dir*, sorted."""
327
+ return [(path.name, path.read_text(encoding="utf-8", errors="replace")) for path in read_event_files(events_dir)]
328
+
329
+
330
+ def steps_from_records(records) -> dict[str, list[TestAttempt]]:
331
+ """``{nodeid: [TestAttempt, …]}`` in the order the attempts ran.
332
+
333
+ A ``test-start`` opens a new attempt. Records arriving before any
334
+ ``test-start`` — possible with a stale generated tree, or if the run was
335
+ interrupted mid-write — still get an attempt, so their steps are not lost.
336
+ """
337
+ by_nodeid: dict[str, list[TestAttempt]] = {}
338
+
339
+ def current(event: dict) -> Optional[TestAttempt]:
340
+ nodeid = event.get("nodeid")
341
+ if not nodeid:
342
+ return None
343
+ attempts = by_nodeid.setdefault(nodeid, [])
344
+ if not attempts:
345
+ attempts.append(
346
+ TestAttempt(
347
+ nodeid,
348
+ scenario=event.get("scenario"),
349
+ locale=event.get("locale"),
350
+ layout=event.get("layout"),
351
+ started_at=_ts(event),
352
+ )
353
+ )
354
+ return attempts[-1]
355
+
356
+ for event in records:
357
+ nodeid = event.get("nodeid")
358
+ if not nodeid:
359
+ continue
360
+ kind = event.get("kind")
361
+ if kind == "test-start":
362
+ by_nodeid.setdefault(nodeid, []).append(
363
+ TestAttempt(
364
+ nodeid,
365
+ scenario=event.get("scenario"),
366
+ locale=event.get("locale"),
367
+ layout=event.get("layout"),
368
+ started_at=_ts(event),
369
+ )
370
+ )
371
+ continue
372
+
373
+ attempt = current(event)
374
+ if attempt is None:
375
+ continue
376
+ if kind == "test-end":
377
+ attempt.outcome = event.get("outcome")
378
+ attempt.duration_ms = event.get("duration_ms")
379
+ elif kind == "step-start":
380
+ step = event.get("step")
381
+ if isinstance(step, int):
382
+ attempt.steps.append(
383
+ StepRecord(
384
+ step,
385
+ title=event.get("title") or "",
386
+ action=event.get("action") or "",
387
+ started_at=_ts(event),
388
+ )
389
+ )
390
+ elif kind == "step-end":
391
+ step = event.get("step")
392
+ # Steps are sequential, never nested, so at most one is open. Match on
393
+ # the number so a stray record cannot close a step it did not open.
394
+ for record in reversed(attempt.steps):
395
+ if record.step == step and record.status == "running":
396
+ record.status = "passed"
397
+ record.ended_at = _ts(event)
398
+ break
399
+ return by_nodeid
400
+
401
+
402
+ def _ts(event: dict) -> float:
403
+ value = event.get("ts")
404
+ return float(value) if isinstance(value, (int, float)) else 0.0
405
+
406
+
407
+ # ── JUnit XML ───────────────────────────────────────────────────────────────
408
+
409
+
410
+ def _junit_outcome(testcase: ET.Element) -> tuple[str, Optional[str]]:
411
+ """``(outcome, fail_tail_b64)`` for one ``<testcase>``."""
412
+ for tag, outcome in (("failure", "failed"), ("error", "error")):
413
+ node = testcase.find(tag)
414
+ if node is not None:
415
+ text = (node.text or "") or (node.get("message") or "")
416
+ return outcome, _tail_b64(text)
417
+ if testcase.find("skipped") is not None:
418
+ return "skipped", None
419
+ return "passed", None
420
+
421
+
422
+ def _junit_nodeid(testcase: ET.Element) -> str:
423
+ """Reconstruct a pytest nodeid from ``classname`` + ``name``.
424
+
425
+ pytest writes ``classname="tests.e2e.test_x"``, so the dots become path
426
+ separators. Lossy for a genuinely dotted module name, which is why event
427
+ JSONL wins the merge whenever both have a row.
428
+ """
429
+ classname = testcase.get("classname") or ""
430
+ name = testcase.get("name") or ""
431
+ if not classname:
432
+ return name
433
+ return f"{classname.replace('.', '/')}.py::{name}"
434
+
435
+
436
+ def read_junit_files(junit_dir: Path) -> list[Path]:
437
+ if not junit_dir.is_dir():
438
+ return []
439
+ return sorted(p for p in junit_dir.glob("*.xml") if p.is_file())
440
+
441
+
442
+ def tests_from_junit(junit_dir: Path) -> tuple[list[BundleTest], list[str]]:
443
+ tests: list[BundleTest] = []
444
+ warnings: list[str] = []
445
+ for path in read_junit_files(junit_dir):
446
+ try:
447
+ root = ET.parse(path).getroot()
448
+ except (OSError, ET.ParseError) as e:
449
+ warnings.append(f"skipped unreadable JUnit file {path.name}: {e}")
450
+ continue
451
+ for testcase in root.iter("testcase"):
452
+ outcome, tail = _junit_outcome(testcase)
453
+ try:
454
+ duration_ms = int(float(testcase.get("time") or 0.0) * 1000)
455
+ except ValueError:
456
+ duration_ms = None
457
+ nodeid = _junit_nodeid(testcase)
458
+ tests.append(
459
+ BundleTest(
460
+ nodeid=nodeid,
461
+ # The scenario IS recoverable — it is the module name, which
462
+ # the generator owns. Locale and layout are not: they live in
463
+ # the parametrization id, which is exactly what the event
464
+ # sink exists to avoid parsing. So derive one and leave the
465
+ # other two honestly empty.
466
+ scenario=scenario_from_nodeid(nodeid),
467
+ locale=None,
468
+ layout=None,
469
+ outcome=outcome,
470
+ duration_ms=duration_ms,
471
+ fail_tail_b64=tail,
472
+ source="junit",
473
+ )
474
+ )
475
+ return tests, warnings
476
+
477
+
478
+ def merge_junit(junit_dir: Path) -> Optional[bytes]:
479
+ """Merge every ``<slug>.xml`` into one ``<testsuites>`` document.
480
+
481
+ A parallel run writes one file per scenario (N workers sharing one output
482
+ mount cannot share one path). Wrapping the single-worker case identically
483
+ means the archive's ``junit.xml`` has ONE shape regardless of executor —
484
+ the "identical structure by construction" property the bundle contract
485
+ promises.
486
+ """
487
+ files = read_junit_files(junit_dir)
488
+ if not files:
489
+ return None
490
+
491
+ merged = ET.Element("testsuites")
492
+ totals = {"tests": 0, "failures": 0, "errors": 0, "skipped": 0}
493
+ time_total = 0.0
494
+
495
+ for path in files:
496
+ try:
497
+ root = ET.parse(path).getroot()
498
+ except (OSError, ET.ParseError):
499
+ continue
500
+ suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite"))
501
+ for suite in suites:
502
+ if not suite.get("name"):
503
+ suite.set("name", path.stem)
504
+ for key in totals:
505
+ try:
506
+ totals[key] += int(suite.get(key) or 0)
507
+ except ValueError:
508
+ pass
509
+ try:
510
+ time_total += float(suite.get("time") or 0.0)
511
+ except ValueError:
512
+ pass
513
+ merged.append(suite)
514
+
515
+ for key, value in totals.items():
516
+ merged.set(key, str(value))
517
+ merged.set("time", f"{time_total:.3f}")
518
+ return ET.tostring(merged, encoding="utf-8", xml_declaration=True)
519
+
520
+
521
+ # ── merge ───────────────────────────────────────────────────────────────────
522
+
523
+
524
+ def collect_tests(events_dir: Path, junit_dir: Path) -> tuple[list[BundleTest], list[str]]:
525
+ """Merged ``tests[]``, events winning per nodeid, sorted deterministically."""
526
+ event_tests, warnings = tests_from_events(events_dir)
527
+ junit_tests, junit_warnings = tests_from_junit(junit_dir)
528
+ warnings.extend(junit_warnings)
529
+
530
+ by_nodeid = {t.nodeid: t for t in event_tests}
531
+ added = 0
532
+ for test in junit_tests:
533
+ if test.nodeid not in by_nodeid:
534
+ by_nodeid[test.nodeid] = test
535
+ added += 1
536
+ if added:
537
+ warnings.append(
538
+ f"{added} test(s) present in JUnit XML but absent from the event records, "
539
+ "so they carry no locale/layout — re-run `steppy generate` if this persists"
540
+ )
541
+
542
+ tests = sorted(
543
+ by_nodeid.values(),
544
+ key=lambda t: (t.scenario or "", t.locale or "", t.layout or "", t.nodeid),
545
+ )
546
+ return tests, warnings