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,489 @@
1
+ """Render progress to the user.
2
+
3
+ Two renderers consume :class:`EventBus` state — the choice depends on
4
+ whether stdout is a TTY:
5
+
6
+ * :class:`LiveProgressRenderer` (TTY) — Rich ``Live`` sticky overlay with
7
+ a single-line aggregate (progress bar + counters) on top and a
8
+ per-worker table below. Refreshes at 4 Hz.
9
+ * :class:`MilestoneRenderer` (non-TTY / CI / piped output) — subscribes
10
+ to the bus and prints one plain timestamped line per state change. No
11
+ overwriting, safe to redirect to a file.
12
+
13
+ :func:`render_summary` is a one-shot final summary printed after the
14
+ workers exit, regardless of which live renderer was used.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import time
21
+ from dataclasses import dataclass
22
+ from typing import Optional
23
+
24
+ from rich.console import Console, Group
25
+ from rich.live import Live
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+
29
+ from .event_bus import EventBus, EventBusSnapshot, WorkerState
30
+ from .queue import ScenarioResult
31
+
32
+
33
+ _STALLED_THRESHOLD_S = 30.0
34
+ _ICONS = {
35
+ "passed": "[green]✓[/green]",
36
+ "failed": "[red]✗[/red]",
37
+ "skipped": "[yellow]⊘[/yellow]",
38
+ "error": "[red]⚠[/red]",
39
+ "interrupted": "[yellow]⛔[/yellow]",
40
+ "worker_died": "[red]☠[/red]",
41
+ }
42
+
43
+
44
+ def _fmt_elapsed(seconds: float) -> str:
45
+ if seconds < 60:
46
+ return f"{seconds:.1f}s"
47
+ mins, secs = divmod(int(seconds), 60)
48
+ if mins < 60:
49
+ return f"{mins}m{secs:02d}s"
50
+ hrs, mins = divmod(mins, 60)
51
+ return f"{hrs}h{mins:02d}m"
52
+
53
+
54
+ def _aggregate_done(snap: EventBusSnapshot) -> int:
55
+ return sum(snap.counts.get(k, 0) for k in ("passed", "failed", "skipped", "error", "interrupted", "worker_died"))
56
+
57
+
58
+ def _running_count(snap: EventBusSnapshot) -> int:
59
+ return sum(1 for ws in snap.workers.values() if ws.current_test is not None and not ws.finished)
60
+
61
+
62
+ # ── Live (TTY) ──────────────────────────────────────────────────────────────
63
+
64
+
65
+ class LiveProgressRenderer:
66
+ """Rich ``Live`` overlay refreshed at 4 Hz.
67
+
68
+ Top row: aggregate counters (done · failed · running · elapsed).
69
+ Bottom: per-worker table (worker · status · scenario · test · elapsed).
70
+ """
71
+
72
+ def __init__(self, bus: EventBus, console: Console, total_scenarios: int):
73
+ self._bus = bus
74
+ self._console = console
75
+ self._total_scenarios = total_scenarios
76
+ self._stop = threading.Event()
77
+ self._thread: Optional[threading.Thread] = None
78
+ self._live: Optional[Live] = None
79
+ self._started_at = time.time()
80
+
81
+ def start(self) -> None:
82
+ self._thread = threading.Thread(target=self._run, name="live-progress", daemon=True)
83
+ self._thread.start()
84
+
85
+ def stop(self) -> None:
86
+ self._stop.set()
87
+ if self._thread is not None:
88
+ self._thread.join(timeout=2)
89
+
90
+ def _run(self) -> None:
91
+ try:
92
+ with Live(self._render(), console=self._console, refresh_per_second=4, transient=False) as live:
93
+ self._live = live
94
+ while not self._stop.wait(0.25):
95
+ live.update(self._render())
96
+ # Final paint after stop so the last frame reflects reality.
97
+ live.update(self._render())
98
+ except Exception:
99
+ pass
100
+
101
+ def _render(self) -> Group:
102
+ snap = self._bus.snapshot()
103
+ return Group(self._header(snap), self._worker_table(snap))
104
+
105
+ def _header(self, snap: EventBusSnapshot) -> Text:
106
+ done = _aggregate_done(snap)
107
+ failed = snap.counts.get("failed", 0) + snap.counts.get("error", 0) + snap.counts.get("worker_died", 0)
108
+ running = _running_count(snap)
109
+ elapsed = time.time() - self._started_at
110
+ scenarios_finished = sum(1 for s in snap.scenarios if s.finished_at is not None)
111
+ bar_width = 24
112
+ if self._total_scenarios > 0:
113
+ filled = min(bar_width, int(bar_width * scenarios_finished / self._total_scenarios))
114
+ else:
115
+ filled = 0
116
+ bar = "[" + "#" * filled + "-" * (bar_width - filled) + "]"
117
+ text = Text()
118
+ text.append(f"{bar} ", style="cyan")
119
+ text.append(f"{scenarios_finished}/{self._total_scenarios}", style="bold")
120
+ text.append(" scenarios · ")
121
+ text.append(f"{done}", style="bold")
122
+ text.append(" tests done · ")
123
+ if failed:
124
+ text.append(f"{failed} failed", style="bold red")
125
+ else:
126
+ text.append("0 failed", style="dim")
127
+ text.append(" · ")
128
+ text.append(f"{running} running", style="cyan" if running else "dim")
129
+ text.append(" · ")
130
+ text.append(_fmt_elapsed(elapsed), style="dim")
131
+ return text
132
+
133
+ def _worker_table(self, snap: EventBusSnapshot) -> Table:
134
+ table = Table.grid(padding=(0, 1))
135
+ table.add_column(style="bold cyan", no_wrap=True)
136
+ table.add_column(style="bold", no_wrap=True)
137
+ table.add_column(no_wrap=True)
138
+ table.add_column(no_wrap=True)
139
+ table.add_column(justify="right", no_wrap=True, style="dim")
140
+ now = time.time()
141
+ for worker_id in sorted(snap.workers.keys()):
142
+ ws = snap.workers[worker_id]
143
+ status, scenario, test, elapsed = self._worker_row(ws, now)
144
+ table.add_row(worker_id, status, scenario, test, elapsed)
145
+ return table
146
+
147
+ @staticmethod
148
+ def _worker_row(ws: WorkerState, now: float) -> tuple[str, str, str, str]:
149
+ if ws.finished and ws.current_test is None:
150
+ return "[dim]done[/dim]", "", "", ""
151
+ if ws.current_test is not None:
152
+ elapsed = now - (ws.current_test_started_at or now)
153
+ stalled = elapsed > _STALLED_THRESHOLD_S
154
+ status = "[yellow dim]stalled[/yellow dim]" if stalled else "[green]running[/green]"
155
+ return status, ws.current_scenario or "-", _short_nodeid(ws.current_test), _fmt_elapsed(elapsed)
156
+ if ws.current_scenario is not None:
157
+ elapsed = now - (ws.scenario_started_at or now)
158
+ return "[cyan]setup[/cyan]", ws.current_scenario, "(awaiting test-start)", _fmt_elapsed(elapsed)
159
+ return "[dim]idle[/dim]", "", "", ""
160
+
161
+
162
+ def _short_nodeid(nodeid: str) -> str:
163
+ """Strip the file path from a pytest nodeid for compact display.
164
+
165
+ ``tests/e2e/test_login.py::test_login[editor-fr]`` → ``test_login[editor-fr]``.
166
+ """
167
+ if "::" in nodeid:
168
+ return nodeid.split("::", 1)[1]
169
+ return nodeid
170
+
171
+
172
+ # ── Milestone (non-TTY) ─────────────────────────────────────────────────────
173
+
174
+
175
+ class MilestoneRenderer:
176
+ """Subscribe to the bus and print one line per significant state change.
177
+
178
+ No ANSI overwriting, no Rich Live. Safe to ``tee`` / redirect / read in
179
+ CI logs. Output is deterministic given the event stream, modulo
180
+ wall-clock timestamps.
181
+ """
182
+
183
+ def __init__(self, bus: EventBus, console: Console, total_scenarios: int):
184
+ self._bus = bus
185
+ self._console = console
186
+ self._total_scenarios = total_scenarios
187
+ bus.subscribe(self._on_event)
188
+
189
+ def start(self) -> None:
190
+ # Nothing to start — we're purely event-driven.
191
+ pass
192
+
193
+ def stop(self) -> None:
194
+ # No background work to stop.
195
+ pass
196
+
197
+ def _on_event(self, worker_id: str, kind: str, snap: EventBusSnapshot) -> None:
198
+ if kind == "scenario-start":
199
+ ws = snap.workers.get(worker_id)
200
+ if ws is None or ws.current_scenario is None:
201
+ return
202
+ self._line(f"{worker_id} START scenario {ws.current_scenario}", snap)
203
+ elif kind == "scenario-end":
204
+ # Find the matching scenario record by worker_id (newest unfinished).
205
+ rec = next(
206
+ (s for s in reversed(snap.scenarios) if s.worker_id == worker_id and s.finished_at is not None),
207
+ None,
208
+ )
209
+ if rec is None:
210
+ return
211
+ duration = (rec.finished_at - rec.started_at) if rec.finished_at else 0.0
212
+ tag = "PASS" if rec.exit_code == 0 else f"FAIL(exit={rec.exit_code})"
213
+ color = "green" if rec.exit_code == 0 else "red"
214
+ self._line(f"{worker_id} {tag} scenario {rec.slug} ({_fmt_elapsed(duration)})", snap, style=color)
215
+ elif kind == "test-end":
216
+ rec = next(
217
+ (t for t in reversed(snap.tests) if t.worker_id == worker_id and t.finished_at is not None),
218
+ None,
219
+ )
220
+ if rec is None or rec.outcome is None:
221
+ return
222
+ duration = (rec.finished_at - rec.started_at) if rec.finished_at else 0.0
223
+ outcome_label = rec.outcome.upper()
224
+ color = {
225
+ "passed": "green",
226
+ "failed": "red",
227
+ "skipped": "yellow",
228
+ "error": "red",
229
+ "interrupted": "yellow",
230
+ "worker_died": "red",
231
+ }.get(rec.outcome, "white")
232
+ self._line(
233
+ f"{worker_id} {outcome_label} {_short_nodeid(rec.nodeid)} ({_fmt_elapsed(duration)})",
234
+ snap,
235
+ style=color,
236
+ )
237
+
238
+ def _line(self, body: str, snap: EventBusSnapshot, style: Optional[str] = None) -> None:
239
+ done = _aggregate_done(snap)
240
+ failed = snap.counts.get("failed", 0) + snap.counts.get("error", 0) + snap.counts.get("worker_died", 0)
241
+ ts = time.strftime("%H:%M:%S")
242
+ line = f"[{ts}] {body} · {done} done, {failed} failed"
243
+ if style:
244
+ self._console.print(line, style=style, highlight=False, markup=False)
245
+ else:
246
+ self._console.print(line, highlight=False, markup=False)
247
+
248
+
249
+ # ── Final summary ───────────────────────────────────────────────────────────
250
+
251
+
252
+ @dataclass
253
+ class _SummaryRow:
254
+ slug: str
255
+ worker_id: str
256
+ exit_code: int
257
+ duration_s: float
258
+ fail_tails: list[tuple[str, str]] # (nodeid, tail)
259
+
260
+
261
+ def render_summary(
262
+ *,
263
+ console: Console,
264
+ bus: EventBus,
265
+ scenario_results: list[ScenarioResult],
266
+ orphans: dict[str, list[str]],
267
+ tmp_dir,
268
+ project_root=None,
269
+ verdict=None,
270
+ retry_buses=None,
271
+ verbose: bool = False,
272
+ ) -> None:
273
+ """Print the final summary after every worker has exited.
274
+
275
+ Pulls test-level outcomes from the bus and per-scenario exit codes
276
+ from ``scenario_results`` (the queue's source of truth). Failures
277
+ embed the b64-decoded longrepr tails captured during the run.
278
+
279
+ When ``project_root`` is provided, the failure / orphan blocks point
280
+ at the persistent ``output/failures/<slug>/`` artifact dirs written
281
+ by :func:`steppy._parallel.failures.persist_failure_artifacts`
282
+ instead of the tmp_dir (which gets wiped on exit).
283
+
284
+ ``verdict`` (a ``retry.Verdict``) adds the FLAKY section and narrows the
285
+ Failures block to scenarios that failed every attempt. ``retry_buses`` are
286
+ the retry passes' event buses — the retry's fail tails live there, not in
287
+ the main ``bus``. Both None ⇒ the pre-retry output.
288
+
289
+ Failures are grouped by cause. ``verbose`` restores the old behaviour of
290
+ dumping every scenario's tail in full.
291
+ """
292
+ snap = bus.snapshot()
293
+ counts = snap.counts
294
+ total_tests = sum(counts.values())
295
+ passed_tests = counts.get("passed", 0)
296
+ failed_tests = counts.get("failed", 0) + counts.get("error", 0)
297
+ skipped_tests = counts.get("skipped", 0)
298
+ interrupted = counts.get("interrupted", 0)
299
+ worker_died = counts.get("worker_died", 0)
300
+
301
+ flaky_slugs = set(verdict.flaky) if verdict else set()
302
+ passed_scen = sum(1 for r in scenario_results if r.exit_code == 0 and r.slug not in flaky_slugs)
303
+ failed_scen = sum(1 for r in scenario_results if r.exit_code != 0)
304
+ orphan_count = sum(len(v) for v in orphans.values())
305
+ wall = time.time() - snap.wall_clock_started
306
+
307
+ console.print()
308
+ console.rule("[bold]Run summary[/bold]")
309
+ console.print(
310
+ f" scenarios: [bold green]{passed_scen} passed[/bold green]"
311
+ + (f", [bold yellow]{len(flaky_slugs)} flaky[/bold yellow]" if flaky_slugs else "")
312
+ + (f", [bold red]{failed_scen} failed[/bold red]" if failed_scen else "")
313
+ + (f", [yellow]{orphan_count} orphaned[/yellow]" if orphan_count else "")
314
+ + f" [dim]({len(scenario_results)} total)[/dim]"
315
+ )
316
+ console.print(
317
+ f" tests: [bold green]{passed_tests} passed[/bold green]"
318
+ + (f", [bold red]{failed_tests} failed[/bold red]" if failed_tests else "")
319
+ + (f", [yellow]{skipped_tests} skipped[/yellow]" if skipped_tests else "")
320
+ + (f", [yellow]{interrupted} interrupted[/yellow]" if interrupted else "")
321
+ + (f", [red]{worker_died} worker-died[/red]" if worker_died else "")
322
+ + f" [dim]({total_tests} total)[/dim]"
323
+ )
324
+ console.print(f" duration: [bold]{_fmt_elapsed(wall)}[/bold] wall clock")
325
+
326
+ # Per-scenario failure block. With a verdict, only scenarios that failed
327
+ # EVERY attempt belong here — a flaky one ended green and gets its own
328
+ # section, so it must not be listed as a failure.
329
+ failure_rows = _build_failure_rows(bus, scenario_results, buses=retry_buses)
330
+ if verdict is not None:
331
+ real_failures = set(verdict.failed)
332
+ failure_rows = [r for r in failure_rows if r.slug in real_failures]
333
+
334
+ if failure_rows or orphans:
335
+ if project_root is not None:
336
+ note_dir = project_root / "output" / "failures"
337
+ console.print(
338
+ f"[dim]These artifacts persist after the run; tmp dir is cleaned up. ({note_dir})[/dim]",
339
+ soft_wrap=True,
340
+ )
341
+
342
+ _render_flaky_block(console, verdict)
343
+
344
+ if failure_rows:
345
+ # Slugs whose tail is already shown in full by the causes section. In
346
+ # verbose mode nothing is elided.
347
+ exemplars = _render_failure_causes(console, failure_rows) if not verbose else set()
348
+ console.print()
349
+ console.rule("[bold red]Failures[/bold red]")
350
+ for row in failure_rows:
351
+ elide = row.slug in exemplars
352
+ console.print(
353
+ f" [red]✗[/red] [bold]{row.slug}[/bold] "
354
+ f"[dim]{row.worker_id} · exit={row.exit_code} · {_fmt_elapsed(row.duration_s)}[/dim]"
355
+ + (" [dim](cause shown above)[/dim]" if elide else "")
356
+ )
357
+ if not elide:
358
+ for nodeid, tail in row.fail_tails:
359
+ console.print(f" [dim]{_short_nodeid(nodeid)}[/dim]")
360
+ for line in tail.splitlines():
361
+ console.print(f" {line}", highlight=False, markup=False)
362
+ artifacts_dir = project_root / "output" / "failures" / row.slug if project_root is not None else None
363
+ if artifacts_dir is not None:
364
+ console.print(f" [cyan]artifacts:[/cyan] [bold]{artifacts_dir}[/bold]", soft_wrap=True)
365
+ pytest_log = artifacts_dir / "pytest.log"
366
+ summary_txt = artifacts_dir / "summary.txt"
367
+ if pytest_log.exists():
368
+ console.print(f" [dim]pytest log:[/dim] {pytest_log}", soft_wrap=True)
369
+ if summary_txt.exists():
370
+ console.print(f" [dim]summary: [/dim] {summary_txt}", soft_wrap=True)
371
+ else:
372
+ stack_log = tmp_dir / f"{row.worker_id}-stack.log" if tmp_dir is not None else None
373
+ if stack_log is not None:
374
+ console.print(f" [dim]full log: {stack_log}[/dim]", soft_wrap=True)
375
+ console.print()
376
+
377
+ # Orphan block.
378
+ if orphans:
379
+ console.print()
380
+ console.rule("[bold yellow]Orphaned (worker died mid-scenario)[/bold yellow]")
381
+ for wid, slugs in orphans.items():
382
+ for slug in slugs:
383
+ console.print(f" [yellow]⚠[/yellow] {wid}: [bold]{slug}[/bold]")
384
+ if project_root is not None:
385
+ artifacts_dir = project_root / "output" / "failures" / slug
386
+ console.print(f" [cyan]artifacts:[/cyan] [bold]{artifacts_dir}[/bold]", soft_wrap=True)
387
+ pytest_log = artifacts_dir / "pytest.log"
388
+ summary_txt = artifacts_dir / "summary.txt"
389
+ if pytest_log.exists():
390
+ console.print(f" [dim]pytest log:[/dim] {pytest_log}", soft_wrap=True)
391
+ if summary_txt.exists():
392
+ console.print(f" [dim]summary: [/dim] {summary_txt}", soft_wrap=True)
393
+
394
+
395
+ def _render_flaky_block(console: Console, verdict) -> None:
396
+ """List scenarios that failed then passed on retry.
397
+
398
+ Kept separate from Failures on purpose: these ended green, so burying them
399
+ in the failure list would make a clean run look broken — and burying them in
400
+ the pass count would hide the thing worth fixing."""
401
+ if verdict is None or not verdict.flaky:
402
+ return
403
+ console.print()
404
+ console.rule("[bold yellow]Flaky (failed, then passed on retry)[/bold yellow]")
405
+ for slug in verdict.flaky:
406
+ attempts = verdict.attempts.get(slug)
407
+ suffix = f" [dim]passed on attempt {attempts}[/dim]" if attempts else ""
408
+ console.print(f" [yellow]~[/yellow] [bold]{slug}[/bold]{suffix}")
409
+ console.print(
410
+ " [dim]These did not fail the build. Rank repeat offenders with: [bold]steppy flaky[/bold][/dim]",
411
+ soft_wrap=True,
412
+ )
413
+
414
+
415
+ def _render_failure_causes(console: Console, failure_rows: list[_SummaryRow]) -> set[str]:
416
+ """Print failures grouped by root cause. Returns the set of slugs whose
417
+ tail was shown in full (the per-group exemplars) so the caller can elide
418
+ them from the detailed block.
419
+
420
+ With one broken selector across forty scenarios, this is the difference
421
+ between a three-line summary and forty stack traces.
422
+ """
423
+ from .fingerprint import group_failures
424
+
425
+ groups = group_failures(failure_rows)
426
+ if not groups:
427
+ return set()
428
+
429
+ console.print()
430
+ plural = "s" if len(groups) != 1 else ""
431
+ total = sum(g.count for g in groups)
432
+ console.rule(f"[bold red]Failure causes[/bold red] [dim]({len(groups)} distinct cause{plural})[/dim]")
433
+ exemplars: set[str] = set()
434
+ for group in groups:
435
+ console.print(
436
+ f" [red]×{group.count}[/red] [bold]{group.headline}[/bold] [dim]({group.fingerprint})[/dim]",
437
+ highlight=False,
438
+ )
439
+ slugs = group.slugs
440
+ shown = ", ".join(slugs[:6]) + (f", +{len(slugs) - 6} more" if len(slugs) > 6 else "")
441
+ console.print(f" [dim]{shown}[/dim]", soft_wrap=True)
442
+ exemplars.update(slugs[1:]) # keep the first scenario's full tail below
443
+ if total > len(groups):
444
+ console.print(" [dim]Repeat occurrences are elided below; --verbose prints every tail.[/dim]")
445
+ return exemplars
446
+
447
+
448
+ def _build_failure_rows(
449
+ bus: EventBus,
450
+ scenario_results: list[ScenarioResult],
451
+ buses=None,
452
+ ) -> list[_SummaryRow]:
453
+ """Build one row per failed scenario, with its captured fail tails.
454
+
455
+ ``buses`` are additional event buses to harvest tails from — the retry pass
456
+ runs on its own bus (a shared one would overwrite attempt 1's records), so
457
+ without this a retried-and-still-failing scenario would print with no
458
+ detail at all. Later buses win, so the tail shown is the final attempt's.
459
+ """
460
+ all_buses = [bus, *(buses or [])]
461
+ # Group failure tails by (worker_id, scenario_slug).
462
+ tails_by_scenario: dict[tuple[str, str], list[tuple[str, str]]] = {}
463
+ for b in all_buses:
464
+ for t in b.snapshot().tests:
465
+ if t.outcome in ("failed", "error", "worker_died") and t.fail_tail:
466
+ tails_by_scenario.setdefault((t.worker_id, t.scenario_slug), []).append((t.nodeid, t.fail_tail))
467
+ rows: list[_SummaryRow] = []
468
+ for r in scenario_results:
469
+ if r.exit_code == 0:
470
+ continue
471
+ tails = tails_by_scenario.get((r.worker_id, r.slug), [])
472
+ rows.append(
473
+ _SummaryRow(
474
+ slug=r.slug,
475
+ worker_id=r.worker_id,
476
+ exit_code=r.exit_code,
477
+ duration_s=r.duration_s,
478
+ fail_tails=tails,
479
+ )
480
+ )
481
+ return rows
482
+
483
+
484
+ def make_renderer(*, bus: EventBus, console: Console, total_scenarios: int):
485
+ """Pick the renderer that fits this console (TTY → Live, else
486
+ Milestone). Returned object has matching ``start()``/``stop()``."""
487
+ if console.is_terminal:
488
+ return LiveProgressRenderer(bus=bus, console=console, total_scenarios=total_scenarios)
489
+ return MilestoneRenderer(bus=bus, console=console, total_scenarios=total_scenarios)