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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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)
|