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