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,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
|