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,418 @@
1
+ """Parse worker stdout into structured progress events.
2
+
3
+ The coordinator's log tailer pushes every line from each worker
4
+ container's stdout (``docker compose logs -f``) into
5
+ :meth:`EventBus.feed_line`. Lines starting with ``STEPPY-EVT::`` are
6
+ parsed into typed events that update an internal state model; every
7
+ other line falls through to a :class:`RawLogChannel` that either prints
8
+ it with the ``[wN]`` prefix (``--verbose``) or writes it only to disk
9
+ for post-mortem.
10
+
11
+ The state model is the single source of truth for the live renderer:
12
+ per-worker current scenario / current test, aggregate pass/fail counts,
13
+ failure tails for the final summary.
14
+
15
+ Event grammar (one per line, fields ``::``-separated):
16
+
17
+ STEPPY-EVT::session-start::<count>
18
+ STEPPY-EVT::session-end::<unix_ts>
19
+ STEPPY-EVT::scenario-start::<slug>::<worker_id>::<unix_ts>
20
+ STEPPY-EVT::scenario-end::<slug>::<worker_id>::<exit_code>::<duration_ms>
21
+ STEPPY-EVT::test-start::<nodeid>::<unix_ts>
22
+ STEPPY-EVT::test-end::<nodeid>::<outcome>::<duration_ms>
23
+ STEPPY-EVT::test-fail-tail::<nodeid>::<b64(longrepr_tail)>
24
+ STEPPY-EVT::worker-done::<worker_id>
25
+ STEPPY-EVT::warning::<code>::<message>
26
+
27
+ ``outcome`` ∈ ``passed | failed | skipped | error``. The coordinator may
28
+ synthesise two extra outcomes for in-flight tests when the worker
29
+ container exits abnormally: ``interrupted`` (Ctrl-C) and
30
+ ``worker_died``. ``warning`` is a free-form diagnostic surfaced through
31
+ the raw-log sink — used when the events plugin can't load, etc.
32
+ ``worker-done`` is emitted by the worker right before it exits cleanly
33
+ (queue drained); the coordinator-side :meth:`EventBus.mark_worker_finished`
34
+ remains authoritative for abnormal exits (Ctrl-C, non-zero container
35
+ exit, never-emitted).
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import base64
41
+ import threading
42
+ import time
43
+ from dataclasses import dataclass, field
44
+ from pathlib import Path
45
+ from typing import Callable, Optional, TextIO
46
+
47
+
48
+ _EVT_PREFIX = "STEPPY-EVT::"
49
+
50
+
51
+ @dataclass
52
+ class TestRecord:
53
+ """One test's bookkeeping. Keyed by ``(worker_id, nodeid)`` in the bus."""
54
+
55
+ nodeid: str
56
+ worker_id: str
57
+ scenario_slug: str
58
+ started_at: float
59
+ finished_at: Optional[float] = None
60
+ outcome: Optional[str] = None
61
+ fail_tail: Optional[str] = None
62
+
63
+
64
+ @dataclass
65
+ class ScenarioRecord:
66
+ slug: str
67
+ worker_id: str
68
+ started_at: float
69
+ finished_at: Optional[float] = None
70
+ exit_code: Optional[int] = None
71
+
72
+
73
+ @dataclass
74
+ class WorkerState:
75
+ worker_id: str
76
+ current_scenario: Optional[str] = None
77
+ current_test: Optional[str] = None
78
+ current_test_started_at: Optional[float] = None
79
+ scenario_started_at: Optional[float] = None
80
+ last_event_at: float = field(default_factory=time.time)
81
+ finished: bool = False
82
+
83
+
84
+ @dataclass
85
+ class EventBusSnapshot:
86
+ """Read-only view of the bus state at one instant. Safe to iterate."""
87
+
88
+ workers: dict[str, WorkerState]
89
+ counts: dict[str, int]
90
+ tests: list[TestRecord]
91
+ scenarios: list[ScenarioRecord]
92
+ wall_clock_started: float
93
+
94
+
95
+ _TERMINAL_OUTCOMES = ("passed", "failed", "skipped", "error", "interrupted", "worker_died")
96
+
97
+
98
+ class EventBus:
99
+ """Thread-safe parser + state store.
100
+
101
+ The bus does not print anything itself. Output is the renderer's job;
102
+ raw (non-event) lines are routed to an optional sink callback so the
103
+ bus stays purely concerned with state.
104
+ """
105
+
106
+ def __init__(self, raw_line_sink: Optional[Callable[[str, str], None]] = None):
107
+ self._lock = threading.Lock()
108
+ self._workers: dict[str, WorkerState] = {}
109
+ self._tests: dict[tuple[str, str], TestRecord] = {}
110
+ self._scenarios: dict[tuple[str, str], ScenarioRecord] = {}
111
+ self._raw_sink = raw_line_sink
112
+ self._counts: dict[str, int] = {k: 0 for k in _TERMINAL_OUTCOMES}
113
+ self._started_at = time.time()
114
+ # Subscribers fire after each event apply — used by the milestone
115
+ # renderer to print one line per state change. The callback runs
116
+ # OUTSIDE the bus lock to avoid renderer/bus deadlocks.
117
+ self._subscribers: list[Callable[[str, str, "EventBusSnapshot"], None]] = []
118
+
119
+ # ── Public entry points ──────────────────────────────────────────────
120
+
121
+ def feed_line(self, worker_id: str, line: str) -> None:
122
+ """Called by the log tailer for every line received from ``worker_id``."""
123
+ line = line.rstrip("\r\n")
124
+ if not line.startswith(_EVT_PREFIX):
125
+ if self._raw_sink is not None:
126
+ self._raw_sink(worker_id, line)
127
+ return
128
+ try:
129
+ kind = self._parse_and_apply(worker_id, line[len(_EVT_PREFIX) :])
130
+ except Exception:
131
+ # Malformed event — fall through to the raw channel so it's
132
+ # visible somewhere instead of silently dropped.
133
+ if self._raw_sink is not None:
134
+ self._raw_sink(worker_id, line)
135
+ return
136
+ if self._subscribers:
137
+ snap = self.snapshot()
138
+ for sub in list(self._subscribers):
139
+ try:
140
+ sub(worker_id, kind, snap)
141
+ except Exception:
142
+ pass
143
+
144
+ def subscribe(self, fn: Callable[[str, str, "EventBusSnapshot"], None]) -> None:
145
+ """Register a callback fired after each event is applied. The
146
+ callback gets ``(worker_id, kind, snapshot)``. Renderers use this
147
+ to emit milestone lines on every state change.
148
+ """
149
+ self._subscribers.append(fn)
150
+
151
+ def snapshot(self) -> EventBusSnapshot:
152
+ """Return a shallow copy of the current state. Renderers must not
153
+ mutate the returned containers."""
154
+ with self._lock:
155
+ return EventBusSnapshot(
156
+ workers=dict(self._workers),
157
+ counts=dict(self._counts),
158
+ tests=list(self._tests.values()),
159
+ scenarios=list(self._scenarios.values()),
160
+ wall_clock_started=self._started_at,
161
+ )
162
+
163
+ def register_worker(self, worker_id: str) -> None:
164
+ """Pre-create a worker entry so the renderer shows the row from the
165
+ first frame, even before the first event arrives. Idempotent."""
166
+ with self._lock:
167
+ self._workers.setdefault(worker_id, WorkerState(worker_id=worker_id))
168
+
169
+ def mark_worker_finished(self, worker_id: str, *, interrupted: bool = False, died: bool = False) -> None:
170
+ """Coordinator-side reconciliation: the worker container exited.
171
+
172
+ ``interrupted=True`` → Ctrl-C path; ``died=True`` → non-zero
173
+ worker container exit while a test was in flight. Either way,
174
+ any dangling in-flight test gets a synthetic outcome so the
175
+ renderer can show the final state correctly.
176
+ """
177
+ with self._lock:
178
+ ws = self._workers.setdefault(worker_id, WorkerState(worker_id=worker_id))
179
+ ws.finished = True
180
+ if ws.current_test is not None:
181
+ key = (worker_id, ws.current_test)
182
+ rec = self._tests.get(key)
183
+ if rec is not None and rec.outcome is None:
184
+ rec.outcome = "worker_died" if died else "interrupted"
185
+ rec.finished_at = time.time()
186
+ self._counts[rec.outcome] += 1
187
+ ws.current_test = None
188
+ ws.current_test_started_at = None
189
+
190
+ # ── Parser ───────────────────────────────────────────────────────────
191
+
192
+ def _parse_and_apply(self, worker_id: str, body: str) -> str:
193
+ """Parse one event body (already stripped of the ``STEPPY-EVT::``
194
+ prefix) and apply it to the state model. Returns the event kind
195
+ for subscriber dispatch. Raises on malformed input.
196
+
197
+ Pytest nodeids contain ``::`` (file::test::param), so events that
198
+ carry a nodeid peel off the trailing fixed-arity fields with
199
+ :py:meth:`str.rpartition` instead of splitting from the left. This
200
+ keeps the nodeid intact regardless of how many ``::`` it contains.
201
+ """
202
+ kind, sep, rest = body.partition("::")
203
+ if not kind:
204
+ raise ValueError("empty event body")
205
+ now = time.time()
206
+ with self._lock:
207
+ ws = self._workers.setdefault(worker_id, WorkerState(worker_id=worker_id))
208
+ ws.last_event_at = now
209
+
210
+ if kind == "scenario-start":
211
+ # rest: <slug>::<worker_id>::<unix_ts> (slugs never contain ::)
212
+ fields = rest.split("::")
213
+ if len(fields) < 3:
214
+ raise ValueError("scenario-start malformed")
215
+ slug = fields[0]
216
+ started_at = float(fields[2])
217
+ ws.current_scenario = slug
218
+ ws.scenario_started_at = started_at
219
+ self._scenarios[(worker_id, slug)] = ScenarioRecord(
220
+ slug=slug, worker_id=worker_id, started_at=started_at
221
+ )
222
+
223
+ elif kind == "scenario-end":
224
+ # rest: <slug>::<worker_id>::<exit_code>::<duration_ms>
225
+ fields = rest.split("::")
226
+ if len(fields) < 4:
227
+ raise ValueError("scenario-end malformed")
228
+ slug = fields[0]
229
+ exit_code = int(fields[2])
230
+ rec = self._scenarios.get((worker_id, slug))
231
+ if rec is not None:
232
+ rec.exit_code = exit_code
233
+ rec.finished_at = now
234
+ # Self-heal the test counters when the pytest-side events
235
+ # plugin didn't fully report for this scenario. Two real-world
236
+ # failure modes we observed:
237
+ #
238
+ # 1. The plugin didn't load at all (stale conftest pre-dating
239
+ # ``_steppy_events``). Neither test-start nor test-end
240
+ # arrives — we have to fabricate a record from scratch.
241
+ #
242
+ # 2. ``test-start`` arrived (the renderer shows the running
243
+ # test name) but ``test-end`` never did — observed when
244
+ # pytest's stdout capture intercepts the call-phase
245
+ # ``pytest_runtest_logreport`` hook, or when buffering
246
+ # delays the line past container teardown. In this case
247
+ # a TestRecord exists but its ``outcome`` is None.
248
+ #
249
+ # Handle both: assign a synthetic outcome to every incomplete
250
+ # record for this scenario, and create a fresh one only when
251
+ # nothing existed. Either way the live header reflects the
252
+ # scenario's exit code. Records that already have an outcome
253
+ # are left alone — no double-counting when the plugin works.
254
+ synthetic_outcome = "passed" if exit_code == 0 else "failed"
255
+ pending_for_scenario = [
256
+ t
257
+ for t in self._tests.values()
258
+ if t.worker_id == worker_id and t.scenario_slug == slug and t.outcome is None
259
+ ]
260
+ completed_for_scenario = any(
261
+ t.worker_id == worker_id and t.scenario_slug == slug and t.outcome is not None
262
+ for t in self._tests.values()
263
+ )
264
+ for incomplete in pending_for_scenario:
265
+ incomplete.outcome = synthetic_outcome
266
+ incomplete.finished_at = now
267
+ self._counts[synthetic_outcome] += 1
268
+ if not pending_for_scenario and not completed_for_scenario:
269
+ synthetic_nodeid = f"<synthetic>::{slug}"
270
+ self._tests[(worker_id, synthetic_nodeid)] = TestRecord(
271
+ nodeid=synthetic_nodeid,
272
+ worker_id=worker_id,
273
+ scenario_slug=slug,
274
+ started_at=rec.started_at if rec is not None else now,
275
+ finished_at=now,
276
+ outcome=synthetic_outcome,
277
+ )
278
+ self._counts[synthetic_outcome] += 1
279
+ if ws.current_scenario == slug:
280
+ ws.current_scenario = None
281
+ ws.scenario_started_at = None
282
+ ws.current_test = None
283
+ ws.current_test_started_at = None
284
+
285
+ elif kind == "session-start" or kind == "session-end":
286
+ pass
287
+
288
+ elif kind == "worker-done":
289
+ # Worker drained the queue and is about to exit cleanly. Flip
290
+ # the renderer's row to "done" immediately, before the
291
+ # coordinator detects the container exit (~1.5s poll cycle).
292
+ # Handled inline rather than via ``mark_worker_finished`` to
293
+ # avoid re-entering the bus lock. The defensive worker_died
294
+ # synthesis below covers the case where a worker bug emits
295
+ # worker-done while a test is still open; in the happy path
296
+ # the preceding scenario-end already cleared current_test, so
297
+ # this never fires and counts are untouched.
298
+ ws.finished = True
299
+ if ws.current_test is not None:
300
+ key = (worker_id, ws.current_test)
301
+ rec = self._tests.get(key)
302
+ if rec is not None and rec.outcome is None:
303
+ rec.outcome = "worker_died"
304
+ rec.finished_at = now
305
+ self._counts["worker_died"] += 1
306
+ ws.current_test = None
307
+ ws.current_test_started_at = None
308
+
309
+ elif kind == "warning":
310
+ # rest: <code>::<message> (free-form, surfaced via raw sink
311
+ # so users can see why something degraded — e.g. the events
312
+ # plugin couldn't load and counts are synthesized instead)
313
+ code, _, message = rest.partition("::")
314
+ if self._raw_sink is not None:
315
+ label = code or "warning"
316
+ body = message or "(no detail)"
317
+ self._raw_sink(worker_id, f"[warning:{label}] {body}")
318
+
319
+ elif kind == "test-start":
320
+ # rest: <nodeid>::<unix_ts> (nodeid may contain ::)
321
+ nodeid, sep2, ts_str = rest.rpartition("::")
322
+ if not sep2:
323
+ raise ValueError("test-start malformed")
324
+ started_at = float(ts_str)
325
+ ws.current_test = nodeid
326
+ ws.current_test_started_at = started_at
327
+ self._tests[(worker_id, nodeid)] = TestRecord(
328
+ nodeid=nodeid,
329
+ worker_id=worker_id,
330
+ scenario_slug=ws.current_scenario or "",
331
+ started_at=started_at,
332
+ )
333
+
334
+ elif kind == "test-end":
335
+ # rest: <nodeid>::<outcome>::<duration_ms>
336
+ head, sep2, _duration = rest.rpartition("::")
337
+ nodeid, sep3, outcome = head.rpartition("::")
338
+ if not (sep2 and sep3):
339
+ raise ValueError("test-end malformed")
340
+ rec = self._tests.get((worker_id, nodeid))
341
+ if rec is None:
342
+ rec = TestRecord(
343
+ nodeid=nodeid,
344
+ worker_id=worker_id,
345
+ scenario_slug=ws.current_scenario or "",
346
+ started_at=now,
347
+ )
348
+ self._tests[(worker_id, nodeid)] = rec
349
+ rec.outcome = outcome
350
+ rec.finished_at = now
351
+ if outcome in self._counts:
352
+ self._counts[outcome] += 1
353
+ if ws.current_test == nodeid:
354
+ ws.current_test = None
355
+ ws.current_test_started_at = None
356
+
357
+ elif kind == "test-fail-tail":
358
+ # rest: <nodeid>::<b64>
359
+ nodeid, sep2, b64 = rest.rpartition("::")
360
+ if not sep2:
361
+ raise ValueError("test-fail-tail malformed")
362
+ try:
363
+ tail = base64.b64decode(b64).decode("utf-8", errors="replace")
364
+ except Exception:
365
+ tail = ""
366
+ rec = self._tests.get((worker_id, nodeid))
367
+ if rec is not None:
368
+ rec.fail_tail = tail
369
+
370
+ else:
371
+ raise ValueError(f"unknown event kind: {kind}")
372
+ return kind
373
+
374
+
375
+ class RawLogChannel:
376
+ """Sink for non-event lines from worker stdout.
377
+
378
+ Always writes each line to a per-worker file under ``log_dir`` for
379
+ post-mortem inspection. When ``verbose`` is True, also prints the
380
+ line to ``console`` with the ``[wN]`` prefix — same UX as the
381
+ pre-refactor tailer.
382
+ """
383
+
384
+ def __init__(self, *, console=None, verbose: bool = False, log_dir: Optional[Path] = None):
385
+ self._console = console
386
+ self._verbose = verbose
387
+ self._log_dir = log_dir
388
+ self._files: dict[str, TextIO] = {}
389
+ self._lock = threading.Lock()
390
+
391
+ def __call__(self, worker_id: str, line: str) -> None:
392
+ if self._verbose and self._console is not None:
393
+ self._console.print(f"[{worker_id}] {line}", highlight=False, markup=False)
394
+ if self._log_dir is None:
395
+ return
396
+ with self._lock:
397
+ f = self._files.get(worker_id)
398
+ if f is None:
399
+ try:
400
+ self._log_dir.mkdir(parents=True, exist_ok=True)
401
+ f = open(self._log_dir / f"{worker_id}-pytest.log", "a", encoding="utf-8", errors="replace")
402
+ self._files[worker_id] = f
403
+ except OSError:
404
+ return
405
+ try:
406
+ f.write(line + "\n")
407
+ f.flush()
408
+ except (OSError, ValueError):
409
+ pass
410
+
411
+ def close(self) -> None:
412
+ with self._lock:
413
+ for f in self._files.values():
414
+ try:
415
+ f.close()
416
+ except OSError:
417
+ pass
418
+ self._files.clear()
@@ -0,0 +1,267 @@
1
+ """Persist per-scenario failure artifacts to ``output/failures/`` so they
2
+ survive the coordinator's ``tmp_dir`` teardown.
3
+
4
+ In ``steppy run --workers N >= 2`` the coordinator runs scenarios inside
5
+ Docker workers and tears the run dir down at exit. Without this module,
6
+ when a scenario fails the user is left with at most a 15-line longrepr
7
+ tail printed in the run summary — the per-worker stack log and every
8
+ pytest line are wiped by ``shutil.rmtree(tmp_dir)`` moments later.
9
+
10
+ What this module copies (only when there were failures or orphans):
11
+
12
+ output/failures/
13
+ <slug>/
14
+ pytest.log # per-scenario pytest stdout (from worker.py's tee)
15
+ summary.txt # human-readable: exit code, worker, fail-tails, screenshots
16
+ _worker-logs/
17
+ wN-stack.log # full ``docker compose logs`` for the affected worker
18
+
19
+ Lazy-imported from :mod:`steppy.coordinator` so it only loads in
20
+ parallel mode (the single-worker path's ``test_no_parallel_import_on_default_path``
21
+ guard relies on this).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import shutil
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from .event_bus import EventBus
32
+ from .queue import ScenarioResult
33
+
34
+
35
+ FAILURES_DIRNAME = "failures"
36
+ WORKER_LOGS_DIRNAME = "_worker-logs"
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class PersistedArtifacts:
41
+ """What ``persist_failure_artifacts`` actually wrote. Returned so the
42
+ renderer can confidently reference the paths and tests can assert."""
43
+
44
+ failures_dir: Path
45
+ scenario_dirs: dict[str, Path] # slug -> output/failures/<slug>/
46
+ worker_logs: dict[str, Path] # worker_id -> output/failures/_worker-logs/<wid>-stack.log
47
+
48
+
49
+ def persist_failure_artifacts(
50
+ *,
51
+ project_root: Path,
52
+ tmp_dir: Path,
53
+ bus: EventBus,
54
+ scenario_results: list[ScenarioResult],
55
+ orphans: dict[str, list[str]],
56
+ log_roots: Optional[dict[str, Path]] = None,
57
+ screenshots_dir: Optional[Path] = None,
58
+ ) -> Optional[PersistedArtifacts]:
59
+ """Copy per-scenario logs and write human-readable summaries.
60
+
61
+ Returns the :class:`PersistedArtifacts` describing what was written,
62
+ or ``None`` when there is nothing to persist (no failures, no orphans).
63
+
64
+ ``screenshots_dir`` is the capture root the generated conftest wrote to
65
+ (``config.output.screenshots_dir``, resolved by the coordinator). This
66
+ function is a *reader* of that tree, so it has to follow the same knob or
67
+ it silently harvests nothing. Omitted ⇒ the conventional
68
+ ``<project>/output/screenshots``.
69
+
70
+ ``log_roots`` maps a slug to the coord root of the attempt that produced
71
+ its final outcome. It exists for the retry pass: attempt 2 writes its
72
+ per-scenario pytest log under ``<tmp>/retry-1/logs/...``, so without the
73
+ mapping we would copy attempt 1's stale log (or none at all). Omitted ⇒
74
+ every slug resolves against ``tmp_dir``, i.e. the pre-retry behaviour.
75
+
76
+ The function NEVER raises — artifact persistence is best-effort. Any
77
+ OSError on the host filesystem is silently swallowed since the run
78
+ itself has already produced a meaningful exit code and summary; we
79
+ don't want a stat() race on a teardown to crash the coordinator.
80
+ """
81
+ log_roots = log_roots or {}
82
+ failed_results = [r for r in scenario_results if r.exit_code != 0]
83
+ orphan_pairs: list[tuple[str, str]] = [(worker_id, slug) for worker_id, slugs in orphans.items() for slug in slugs]
84
+ if not failed_results and not orphan_pairs:
85
+ return None
86
+
87
+ failures_dir = project_root / "output" / FAILURES_DIRNAME
88
+ try:
89
+ _reset_dir(failures_dir)
90
+ except OSError:
91
+ return None
92
+
93
+ # Build the fail-tail lookup once. Mirrors renderer._build_failure_rows.
94
+ snap = bus.snapshot()
95
+ tails_by_scenario: dict[tuple[str, str], list[tuple[str, str]]] = {}
96
+ for t in snap.tests:
97
+ if t.outcome in ("failed", "error", "worker_died") and t.fail_tail:
98
+ tails_by_scenario.setdefault((t.worker_id, t.scenario_slug), []).append((t.nodeid, t.fail_tail))
99
+
100
+ # Discover failure screenshots (the generated conftest writes here).
101
+ failure_screenshots_dir = (screenshots_dir or project_root / "output" / "screenshots") / "_failures"
102
+ failure_screenshots: list[Path] = []
103
+ if failure_screenshots_dir.exists():
104
+ try:
105
+ failure_screenshots = sorted(failure_screenshots_dir.glob("*.png"))
106
+ except OSError:
107
+ failure_screenshots = []
108
+
109
+ scenario_dirs: dict[str, Path] = {}
110
+ workers_with_failures: set[str] = set()
111
+
112
+ # Failed scenarios.
113
+ for r in failed_results:
114
+ scenario_dir = failures_dir / r.slug
115
+ scenario_dir.mkdir(parents=True, exist_ok=True)
116
+ _copy_pytest_log(log_roots.get(r.slug, tmp_dir), r.worker_id, r.slug, scenario_dir)
117
+ screenshots = _screenshots_for_slug(r.slug, failure_screenshots)
118
+ _write_summary(
119
+ scenario_dir / "summary.txt",
120
+ slug=r.slug,
121
+ worker_id=r.worker_id,
122
+ exit_code=r.exit_code,
123
+ duration_s=r.duration_s,
124
+ started_at=r.started_at,
125
+ tails=tails_by_scenario.get((r.worker_id, r.slug), []),
126
+ screenshots=screenshots,
127
+ orphan=False,
128
+ )
129
+ scenario_dirs[r.slug] = scenario_dir
130
+ workers_with_failures.add(r.worker_id)
131
+
132
+ # Orphans (claimed but never reported). pytest.log may exist (worker
133
+ # died mid-run) or not (worker died before spawning pytest). Either way,
134
+ # emit a summary.txt with the ORPHAN marker.
135
+ for worker_id, slug in orphan_pairs:
136
+ if slug in scenario_dirs:
137
+ # A failed result already exists for this slug (race between
138
+ # write_result and crash). Don't double-write.
139
+ continue
140
+ scenario_dir = failures_dir / slug
141
+ scenario_dir.mkdir(parents=True, exist_ok=True)
142
+ _copy_pytest_log(log_roots.get(slug, tmp_dir), worker_id, slug, scenario_dir)
143
+ screenshots = _screenshots_for_slug(slug, failure_screenshots)
144
+ _write_summary(
145
+ scenario_dir / "summary.txt",
146
+ slug=slug,
147
+ worker_id=worker_id,
148
+ exit_code=None,
149
+ duration_s=None,
150
+ started_at=None,
151
+ tails=tails_by_scenario.get((worker_id, slug), []),
152
+ screenshots=screenshots,
153
+ orphan=True,
154
+ )
155
+ scenario_dirs[slug] = scenario_dir
156
+ workers_with_failures.add(worker_id)
157
+
158
+ # Per-worker stack logs (one copy per affected worker, not per failure).
159
+ worker_logs_dir = failures_dir / WORKER_LOGS_DIRNAME
160
+ worker_logs: dict[str, Path] = {}
161
+ if workers_with_failures:
162
+ try:
163
+ worker_logs_dir.mkdir(parents=True, exist_ok=True)
164
+ except OSError:
165
+ worker_logs_dir = None # type: ignore[assignment]
166
+ if worker_logs_dir is not None:
167
+ for worker_id in sorted(workers_with_failures):
168
+ src = tmp_dir / f"{worker_id}-stack.log"
169
+ if not src.exists():
170
+ continue
171
+ dst = worker_logs_dir / f"{worker_id}-stack.log"
172
+ try:
173
+ shutil.copy2(src, dst)
174
+ except OSError:
175
+ continue
176
+ worker_logs[worker_id] = dst
177
+
178
+ return PersistedArtifacts(
179
+ failures_dir=failures_dir,
180
+ scenario_dirs=scenario_dirs,
181
+ worker_logs=worker_logs,
182
+ )
183
+
184
+
185
+ # ── Helpers ─────────────────────────────────────────────────────────────────
186
+
187
+
188
+ def _reset_dir(d: Path) -> None:
189
+ """Remove ``d`` (recursively) if it exists, then recreate it empty.
190
+ Prevents stale failures from a prior run leaking into this one."""
191
+ if d.exists():
192
+ shutil.rmtree(d, ignore_errors=True)
193
+ d.mkdir(parents=True, exist_ok=True)
194
+
195
+
196
+ def _copy_pytest_log(coord_root: Path, worker_id: str, slug: str, scenario_dir: Path) -> None:
197
+ """``coord_root`` is the coord dir of the attempt that produced this
198
+ scenario's final outcome — ``tmp_dir`` for a single-pass run, or
199
+ ``tmp_dir/retry-N`` when the retry pass ran it last."""
200
+ src = coord_root / "logs" / worker_id / f"{slug}.log"
201
+ if not src.exists():
202
+ return
203
+ try:
204
+ shutil.copy2(src, scenario_dir / "pytest.log")
205
+ except OSError:
206
+ pass
207
+
208
+
209
+ def _screenshots_for_slug(slug: str, all_screenshots: list[Path]) -> list[Path]:
210
+ """Filter the list of failure screenshots to those that match this slug.
211
+
212
+ The generated conftest names files ``{test_id}__{fixture_name}.png``
213
+ where ``test_id`` derives from the pytest nodeid which itself derives
214
+ from ``test_{slug}.py``. Match by substring on the slug to keep this
215
+ robust to parametrized variants (``test_login[editor-fr]``)."""
216
+ return [p for p in all_screenshots if slug in p.name]
217
+
218
+
219
+ def _write_summary(
220
+ path: Path,
221
+ *,
222
+ slug: str,
223
+ worker_id: str,
224
+ exit_code: Optional[int],
225
+ duration_s: Optional[float],
226
+ started_at: Optional[float],
227
+ tails: list[tuple[str, str]],
228
+ screenshots: list[Path],
229
+ orphan: bool,
230
+ ) -> None:
231
+ """Write a human-readable summary.txt next to pytest.log."""
232
+ lines: list[str] = []
233
+ lines.append(f"scenario: {slug}")
234
+ lines.append(f"worker: {worker_id}")
235
+ if orphan:
236
+ lines.append("status: ORPHAN — worker died mid-scenario")
237
+ else:
238
+ lines.append(f"status: FAILED (exit={exit_code})")
239
+ if duration_s is not None:
240
+ lines.append(f"duration: {duration_s:.2f}s")
241
+ if started_at is not None:
242
+ lines.append(f"started: {started_at:.3f} (unix)")
243
+ lines.append("")
244
+ if tails:
245
+ lines.append("Failure tails (per test):")
246
+ for nodeid, tail in tails:
247
+ lines.append(f" • {nodeid}")
248
+ for tail_line in tail.splitlines():
249
+ lines.append(f" {tail_line}")
250
+ lines.append("")
251
+ else:
252
+ if orphan:
253
+ lines.append("No per-test fail-tail captured (worker exited before reporting).")
254
+ else:
255
+ lines.append("No per-test fail-tail captured (pytest exited non-zero without per-test events).")
256
+ lines.append("")
257
+ if screenshots:
258
+ lines.append("Failure screenshots:")
259
+ for shot in screenshots:
260
+ lines.append(f" • {shot}")
261
+ lines.append("")
262
+ lines.append("See pytest.log (this directory) for full pytest output.")
263
+ lines.append("See ../_worker-logs/ for the full worker stack log (worker + app).")
264
+ try:
265
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
266
+ except OSError:
267
+ pass