steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,277 @@
1
+ """Where the report's coverage numbers come from, in order of trustworthiness.
2
+
3
+ Named ``coverage_source`` rather than ``coverage`` so ``from . import coverage``
4
+ inside this package cannot shadow :mod:`steppy.coverage`.
5
+
6
+ The problem this module exists to solve. The coverage map is a sqlite store at
7
+ ``.steppy/coverage.db``, which lives **outside** ``output/`` on purpose — cleaning
8
+ run artifacts must never wipe hand-made triage flags — and is therefore never
9
+ inside a results bundle. The *declared* surface it is scored against comes from
10
+ ``coverage refresh``, which fetches an OpenAPI document over the network. So a
11
+ bundle downloaded from someone else's CI carries the raw per-scenario records and
12
+ nothing that says what they should be compared to.
13
+
14
+ Four tiers, best first:
15
+
16
+ =========== ========================================== ===============================
17
+ tier source when
18
+ =========== ========================================== ===============================
19
+ ``bundled`` ``output/coverage/gaps.json`` in the run any input that carries one
20
+ ``store`` the project's live ``.steppy/coverage.db`` **live-tree input only**
21
+ ``records`` a throwaway store rebuilt from raw records records, no gaps.json, no store
22
+ ``none`` no coverage section at all nothing was recorded
23
+ =========== ========================================== ===============================
24
+
25
+ Two of those rows are guardrails against a plausible-looking lie, and both are the
26
+ kind of bug that would never surface as a crash:
27
+
28
+ **``store`` is live-tree only.** Reading this machine's map to describe a bundle
29
+ that came from elsewhere would attribute *our* coverage to *their* run and print a
30
+ confident percentage about it.
31
+
32
+ **``records`` cannot report assert strength.** Rebuilding via ``merge_run`` calls
33
+ ``count_scenario_asserts``, which reads ``scenarios/*.md`` — and a bundle carries
34
+ none, so it returns 0 for every scenario and ``gaps._strength`` marks every tested
35
+ endpoint "weak". So that tier suppresses strength outright and says why, rather
36
+ than publishing a number that looks like a finding.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from pathlib import Path
43
+ from typing import Optional, Sequence
44
+
45
+ from ..contracts.report import ReportBadge, ReportCoverage, ReportNonUi
46
+
47
+ TIER_BUNDLED = "bundled"
48
+ TIER_STORE = "store"
49
+ TIER_RECORDS = "records"
50
+ TIER_NONE = "none"
51
+
52
+ #: Manifest kind of the gap report that travels with a run (``bundle/classify``).
53
+ GAPS_KIND = "coverage_gaps"
54
+ RECORD_KIND = "coverage_record"
55
+
56
+ STRENGTH_UNKNOWN_WARNING = (
57
+ "assert strength is unavailable: this coverage was rebuilt from raw records, and a bundle "
58
+ "carries no scenarios/*.md to count assert steps in — every tested endpoint would otherwise "
59
+ "read as weakly covered"
60
+ )
61
+
62
+
63
+ def resolve_coverage(
64
+ source,
65
+ *,
66
+ project_root: Path,
67
+ non_ui: Sequence[str] = (),
68
+ surface: str = "all",
69
+ badge_label: Optional[str] = None,
70
+ href: Optional[str] = None,
71
+ ) -> ReportCoverage:
72
+ """Best available coverage for *source*. Never raises — degrades and says so."""
73
+ from ..coverage.report import DEFAULT_BADGE_LABEL
74
+
75
+ label = badge_label or DEFAULT_BADGE_LABEL
76
+ warnings: list[str] = []
77
+
78
+ gaps, tier = _from_bundle(source, warnings)
79
+ if gaps is None and source.is_live_tree:
80
+ gaps, tier = _from_store(project_root, non_ui, warnings)
81
+ if gaps is None:
82
+ gaps, tier = _from_records(source, project_root, non_ui, warnings)
83
+ if gaps is None:
84
+ return ReportCoverage(tier=TIER_NONE, warnings=warnings)
85
+
86
+ if tier == TIER_RECORDS:
87
+ warnings.append(STRENGTH_UNKNOWN_WARNING)
88
+
89
+ return _view(gaps, tier=tier, surface=surface, label=label, href=href, warnings=warnings)
90
+
91
+
92
+ def _view(
93
+ gaps: dict,
94
+ *,
95
+ tier: str,
96
+ surface: str,
97
+ label: str,
98
+ href: Optional[str],
99
+ warnings: list[str],
100
+ ) -> ReportCoverage:
101
+ from ..coverage.report import badge_payload
102
+
103
+ ratio = (gaps.get("e2e_surface") or {}).get(surface) or {}
104
+ payload = badge_payload(gaps, surface=surface, label=label)
105
+ return ReportCoverage(
106
+ tier=tier,
107
+ counts=gaps.get("counts") or {},
108
+ e2e_surface=gaps.get("e2e_surface") or {},
109
+ buckets={kind: gaps[kind] for kind in ("endpoints", "routes", "modules") if isinstance(gaps.get(kind), dict)},
110
+ non_ui=_non_ui_summary(gaps),
111
+ badge=ReportBadge(
112
+ surface=surface,
113
+ label=label,
114
+ tested=ratio.get("tested", 0),
115
+ declared=ratio.get("declared", 0),
116
+ ratio=ratio.get("ratio"),
117
+ percent=ratio.get("percent"),
118
+ color=payload["color"],
119
+ ),
120
+ href=href,
121
+ warnings=warnings,
122
+ )
123
+
124
+
125
+ def _non_ui_summary(gaps: dict) -> ReportNonUi:
126
+ """How much was discounted, and on whose authority.
127
+
128
+ ``by_source`` is what lets a reader see a percentage was raised by committed
129
+ config rather than by a flag somebody set locally and never shared.
130
+ """
131
+ counts = gaps.get("counts") or {}
132
+ declared = sum((counts.get(kind) or {}).get("declared_non_ui", 0) for kind in ("endpoints", "routes"))
133
+ tested = sum((counts.get(kind) or {}).get("tested_non_ui", 0) for kind in ("endpoints", "routes"))
134
+ by_source: dict[str, int] = {}
135
+ for kind in ("endpoints", "routes"):
136
+ section = gaps.get(kind) or {}
137
+ for bucket in ("tested", "declared_unreached"):
138
+ for entry in section.get(bucket) or []:
139
+ if entry.get("triage") == "non_ui":
140
+ key = entry.get("triage_source") or "unspecified"
141
+ by_source[key] = by_source.get(key, 0) + 1
142
+ return ReportNonUi(declared=declared, tested=tested, by_source=dict(sorted(by_source.items())))
143
+
144
+
145
+ def _from_bundle(source, warnings: list[str]) -> tuple[Optional[dict], str]:
146
+ """The gap report that travelled with the run, if it did."""
147
+ entries = [f for f in source.manifest.files if f.kind == GAPS_KIND]
148
+ if not entries:
149
+ return None, TIER_NONE
150
+ try:
151
+ fetched, fetch_warnings = source.fetch(texts={entries[0].path})
152
+ except Exception as e:
153
+ warnings.append(f"coverage: could not read {entries[0].path} from the bundle ({e})")
154
+ return None, TIER_NONE
155
+ warnings.extend(fetch_warnings)
156
+ payload = fetched.get(entries[0].path)
157
+ if not payload:
158
+ return None, TIER_NONE
159
+ try:
160
+ gaps = json.loads(payload.decode("utf-8"))
161
+ except (UnicodeDecodeError, ValueError) as e:
162
+ warnings.append(f"coverage: {entries[0].path} is not readable JSON ({e})")
163
+ return None, TIER_NONE
164
+ if not isinstance(gaps, dict):
165
+ return None, TIER_NONE
166
+ return gaps, TIER_BUNDLED
167
+
168
+
169
+ def _from_store(project_root: Path, non_ui: Sequence[str], warnings: list[str]) -> tuple[Optional[dict], str]:
170
+ """The project's own map. Only ever reached for a live ``output/`` tree."""
171
+ try:
172
+ from ..config import load_config
173
+ from ..coverage.gaps import compute_gaps
174
+ from ..coverage.merge import CoverageSettings
175
+ from ..coverage.store import open_store, read_map
176
+ from ..coverage.triage import apply_config_triage
177
+
178
+ settings = CoverageSettings.from_config(load_config(project_root / "config.yaml"), project_root)
179
+ store_path = Path(settings.store_path)
180
+ if not store_path.is_file():
181
+ return None, TIER_NONE
182
+ patterns = tuple(non_ui) or settings.non_ui
183
+ conn = open_store(store_path)
184
+ try:
185
+ gaps = compute_gaps(apply_config_triage(read_map(conn), patterns))
186
+ finally:
187
+ conn.close()
188
+ return gaps, TIER_STORE
189
+ except Exception as e:
190
+ warnings.append(f"coverage: the local map at .steppy/coverage.db could not be read ({e})")
191
+ return None, TIER_NONE
192
+
193
+
194
+ def _from_records(source, project_root: Path, non_ui: Sequence[str], warnings: list[str]) -> tuple[Optional[dict], str]:
195
+ """Last resort: rebuild a throwaway map from the raw records in the bundle.
196
+
197
+ Observed nodes only. There is no declared surface here (that needs the network)
198
+ and no triage table, so the buckets are honest about what was *exercised* and
199
+ silent about what was missed.
200
+ """
201
+ entries = [f for f in source.manifest.files if f.kind == RECORD_KIND]
202
+ if not entries:
203
+ return None, TIER_NONE
204
+
205
+ import tempfile
206
+
207
+ try:
208
+ from ..config import ConfigError, load_config
209
+ from ..coverage.gaps import compute_gaps
210
+ from ..coverage.merge import CoverageSettings, merge_run
211
+ from ..coverage.store import open_store, read_map
212
+ from ..coverage.triage import apply_config_triage
213
+ except Exception as e: # pragma: no cover - import failure is not a report failure
214
+ warnings.append(f"coverage: records present but the coverage modules could not be loaded ({e})")
215
+ return None, TIER_NONE
216
+
217
+ run_id = source.manifest.run.run_id
218
+ with tempfile.TemporaryDirectory(prefix="steppy-report-cov-") as tmp:
219
+ tmp_root = Path(tmp)
220
+ records_dir = tmp_root / "coverage"
221
+ wanted = {entry.path: records_dir / _record_rel(entry.path) for entry in entries}
222
+ try:
223
+ _, fetch_warnings = source.fetch(into=wanted)
224
+ except Exception as e:
225
+ warnings.append(f"coverage: raw records could not be read ({e})")
226
+ return None, TIER_NONE
227
+ warnings.extend(fetch_warnings)
228
+
229
+ try:
230
+ base = load_config(project_root / "config.yaml")
231
+ except (ConfigError, OSError):
232
+ base = None
233
+ settings = (
234
+ CoverageSettings.from_config(base, project_root)
235
+ if base is not None
236
+ else CoverageSettings(store_path=tmp_root / "map.db", records_dir=records_dir)
237
+ )
238
+ settings = _relocated(settings, store_path=tmp_root / "map.db", records_dir=records_dir)
239
+
240
+ try:
241
+ merge_run(settings, run_id)
242
+ conn = open_store(settings.store_path)
243
+ try:
244
+ patterns = tuple(non_ui) or settings.non_ui
245
+ gaps = compute_gaps(apply_config_triage(read_map(conn), patterns))
246
+ finally:
247
+ conn.close()
248
+ except Exception as e:
249
+ warnings.append(f"coverage: raw records could not be merged ({e})")
250
+ return None, TIER_NONE
251
+ return gaps, TIER_RECORDS
252
+
253
+
254
+ def _relocated(settings, *, store_path: Path, records_dir: Path):
255
+ """A copy of *settings* pointed at a throwaway store.
256
+
257
+ ``dataclasses.replace`` rather than mutation: the caller's settings describe
258
+ the project, and quietly repointing them at a temp directory is the sort of
259
+ thing that works until something else reads them.
260
+ """
261
+ from dataclasses import replace
262
+
263
+ return replace(settings, store_path=store_path, records_dir=records_dir)
264
+
265
+
266
+ def _record_rel(manifest_path: str) -> Path:
267
+ """``output/coverage/raw/<run>/<scenario>/<file>`` → ``raw/<run>/<scenario>/<file>``.
268
+
269
+ ``merge_run`` expects the ``raw/<run_id>/`` layout under its ``records_dir``,
270
+ so the tail from ``raw/`` onwards is what has to be reproduced. Falls back to
271
+ the basename if the path is not that shape, which keeps a malformed manifest
272
+ from escaping the temp directory.
273
+ """
274
+ parts = Path(manifest_path).parts
275
+ if "raw" in parts:
276
+ return Path(*parts[parts.index("raw") :])
277
+ return Path(Path(manifest_path).name)
steppy/report/model.py ADDED
@@ -0,0 +1,423 @@
1
+ """Bundle manifest + event timeline + coverage → :class:`ReportModel`.
2
+
3
+ **This module must never import jinja2.** It is the half of the reporter a control
4
+ plane can consume — ``from steppy.report.model import build_model`` — and dragging
5
+ a renderer along would defeat the point of the model/render split. That is the
6
+ concrete form of "one renderer, not two": the *model* is shared, the presentation
7
+ is not. (It also may not import :mod:`steppy.guide`, which builds a module-level
8
+ ``MarkdownIt``; a guard test forbids ``markdown_it`` on the default import path.)
9
+
10
+ The build is a pure function of its inputs, with no clock and no filesystem reads.
11
+ Everything it needs has already been fetched: the manifest carries the file
12
+ inventory *with* attribution (``bundle/classify.py`` did that work at create
13
+ time, so nothing here parses a path), the timeline comes from the event JSONL, and
14
+ coverage arrives resolved. Same input, same bytes out — which is what lets a test
15
+ assert on the model and CI diff two reports.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import base64
21
+ from typing import Iterable, Optional
22
+
23
+ from ..contracts.bundle import BundleFile, BundleManifest, BundleTest
24
+ from ..contracts.report import (
25
+ REPORT_SCHEMA_VERSION,
26
+ ReportArtifactGroup,
27
+ ReportArtifacts,
28
+ ReportCapabilities,
29
+ ReportCoverage,
30
+ ReportFailureCause,
31
+ ReportFailureOccurrence,
32
+ ReportModel,
33
+ ReportScenario,
34
+ ReportSourceInfo,
35
+ ReportStep,
36
+ ReportTotals,
37
+ ReportVariant,
38
+ )
39
+
40
+ #: Worst-first. A run's verdict, and a scenario's, is the worst thing that
41
+ #: happened to it — a flow that fails only in Dutch on mobile is a failing flow.
42
+ #: Unknown outcomes sort *after* ``failed`` and before ``passed``: something we
43
+ #: cannot classify is not a pass, and claiming it is a failure would be a guess.
44
+ _OUTCOME_RANK = {
45
+ "worker_died": 0,
46
+ "error": 1,
47
+ "failed": 2,
48
+ "interrupted": 3,
49
+ "unknown": 4,
50
+ "skipped": 5,
51
+ "passed": 6,
52
+ }
53
+ _UNRANKED = 4
54
+
55
+ #: Outcomes that do not fail a gate. ``skipped`` is deliberate scope, and the
56
+ #: coordinator already treats a pass-on-retry as non-fatal.
57
+ _PASSING = frozenset({"passed", "skipped"})
58
+
59
+
60
+ def worse(a: str, b: str) -> str:
61
+ """The worse of two outcomes, by :data:`_OUTCOME_RANK`."""
62
+ return a if _rank(a) <= _rank(b) else b
63
+
64
+
65
+ def _rank(outcome: str) -> int:
66
+ return _OUTCOME_RANK.get(outcome, _UNRANKED)
67
+
68
+
69
+ def _decode_tail(test: BundleTest) -> str:
70
+ if not test.fail_tail_b64:
71
+ return ""
72
+ try:
73
+ return base64.b64decode(test.fail_tail_b64).decode("utf-8", errors="replace")
74
+ except (ValueError, TypeError):
75
+ # A corrupt tail is not worth losing the row over — the outcome, the step
76
+ # and the screenshot all still say something useful.
77
+ return ""
78
+
79
+
80
+ class _Row:
81
+ """Duck-type for ``_parallel.fingerprint.group_failures``.
82
+
83
+ That function needs only ``.slug`` and ``.fail_tails``, which is why the
84
+ parallel renderer's grouping works here unchanged rather than being
85
+ reimplemented — the console summary and the HTML report collapse identical
86
+ failures identically, by construction.
87
+ """
88
+
89
+ __slots__ = ("slug", "fail_tails")
90
+
91
+ def __init__(self, slug: str, fail_tails: list[tuple[str, str]]):
92
+ self.slug = slug
93
+ self.fail_tails = fail_tails
94
+
95
+
96
+ def group_manifest_failures(tests: Iterable[BundleTest]) -> tuple[list[ReportFailureCause], dict[str, str]]:
97
+ """``(causes, {nodeid: fingerprint})`` for every failing test.
98
+
99
+ Returns the per-nodeid map as well so each variant can link to its cause
100
+ without the templates re-deriving anything.
101
+ """
102
+ from .._parallel.fingerprint import fingerprint, group_failures
103
+
104
+ by_slug: dict[str, list[tuple[str, str]]] = {}
105
+ context: dict[str, BundleTest] = {}
106
+ prints: dict[str, str] = {}
107
+ for test in tests:
108
+ if test.outcome in _PASSING:
109
+ continue
110
+ tail = _decode_tail(test)
111
+ by_slug.setdefault(test.scenario, []).append((test.nodeid, tail))
112
+ context[test.nodeid] = test
113
+ prints[test.nodeid] = fingerprint(tail)
114
+
115
+ rows = [_Row(slug, tails) for slug, tails in by_slug.items()]
116
+ causes = []
117
+ for group in group_failures(rows):
118
+ occurrences = []
119
+ exemplar = ""
120
+ for _slug, nodeid, tail in group.occurrences:
121
+ test = context.get(nodeid)
122
+ occurrences.append(
123
+ ReportFailureOccurrence(
124
+ scenario=test.scenario if test else _slug,
125
+ nodeid=nodeid,
126
+ locale=test.locale if test else None,
127
+ layout=test.layout if test else None,
128
+ )
129
+ )
130
+ if not exemplar:
131
+ exemplar = tail
132
+ causes.append(
133
+ ReportFailureCause(
134
+ fingerprint=group.fingerprint,
135
+ headline=group.headline,
136
+ count=group.count,
137
+ scenarios=group.slugs,
138
+ occurrences=occurrences,
139
+ exemplar_tail=exemplar,
140
+ )
141
+ )
142
+ return causes, prints
143
+
144
+
145
+ def scenario_href(slug: str) -> str:
146
+ """Site-relative URL of a scenario page.
147
+
148
+ One definition, used for the link *and* for the filename the renderer writes —
149
+ two would drift, and they would drift silently into a 404. The slug is
150
+ sanitised for the same reason asset paths are: it may have arrived in a
151
+ downloaded manifest, and here it decides a path on disk.
152
+ """
153
+ from .assets import sanitise
154
+
155
+ return f"scenarios/{sanitise(slug)}.html"
156
+
157
+
158
+ def build_model(
159
+ *,
160
+ manifest: BundleManifest,
161
+ source: ReportSourceInfo,
162
+ attempts: Optional[dict] = None,
163
+ coverage: Optional[ReportCoverage] = None,
164
+ assets: Optional[dict] = None,
165
+ screenshots_policy: str = "none",
166
+ steppy_version: str = "",
167
+ extra_warnings: Optional[list[str]] = None,
168
+ ) -> ReportModel:
169
+ """Assemble the report model.
170
+
171
+ ``attempts`` is ``{nodeid: [TestAttempt]}`` from
172
+ ``bundle.results.steps_from_records``; ``assets`` maps a manifest path to the
173
+ site-relative URL it was copied to (absent ⇒ not copied, which is a policy
174
+ outcome rather than an error).
175
+ """
176
+ attempts = attempts or {}
177
+ assets = assets or {}
178
+ coverage = coverage or ReportCoverage()
179
+
180
+ tests = list(manifest.tests)
181
+ causes, prints = group_manifest_failures(tests)
182
+
183
+ # Scenarios come from the tests AND the files, so a run made without
184
+ # --bundle — no test rows at all — still lists what it captured instead of
185
+ # rendering an empty page.
186
+ slugs: list[str] = []
187
+ seen: set[str] = set()
188
+ for name in [t.scenario for t in tests] + [f.scenario for f in manifest.files if f.scenario]:
189
+ if name and name not in seen:
190
+ seen.add(name)
191
+ slugs.append(name)
192
+ slugs.sort()
193
+
194
+ by_scenario: dict[str, list[BundleTest]] = {}
195
+ for test in tests:
196
+ by_scenario.setdefault(test.scenario, []).append(test)
197
+
198
+ failure_shots = _index_failure_screenshots(manifest, assets)
199
+ failure_logs = _index_by_scenario(manifest, "failure_log")
200
+ traces = _index_paths(manifest, "trace")
201
+ videos = _index_paths(manifest, "video")
202
+
203
+ scenarios: list[ReportScenario] = []
204
+ any_steps = False
205
+ any_attempts = False
206
+ outcomes: dict[str, int] = {}
207
+ duration_sum = 0
208
+ verdict = "passed"
209
+
210
+ for slug in slugs:
211
+ rows = by_scenario.get(slug, [])
212
+ variants: list[ReportVariant] = []
213
+ scenario_outcome = "passed" if rows else "unknown"
214
+ scenario_counts: dict[str, int] = {}
215
+ scenario_duration = 0
216
+
217
+ for test in sorted(rows, key=lambda t: (t.locale or "", t.layout or "", t.nodeid)):
218
+ runs = attempts.get(test.nodeid) or []
219
+ if len(runs) > 1:
220
+ any_attempts = True
221
+ last = runs[-1] if runs else None
222
+ steps = _steps_for(last, test, assets, manifest)
223
+ if steps:
224
+ any_steps = True
225
+
226
+ variants.append(
227
+ ReportVariant(
228
+ nodeid=test.nodeid,
229
+ locale=test.locale,
230
+ layout=test.layout,
231
+ outcome=test.outcome,
232
+ duration_ms=test.duration_ms,
233
+ attempts=max(len(runs), 1),
234
+ attempt_outcomes=[a.outcome or "unknown" for a in runs],
235
+ failed_step=test.failed_step,
236
+ failed_step_title=_step_title(steps, test.failed_step),
237
+ fingerprint=prints.get(test.nodeid),
238
+ fail_tail=_decode_tail(test),
239
+ steps=steps,
240
+ failure_screenshot=failure_shots.get(test.nodeid),
241
+ trace_path=traces.get(test.nodeid),
242
+ video_path=videos.get(test.nodeid),
243
+ )
244
+ )
245
+ outcomes[test.outcome] = outcomes.get(test.outcome, 0) + 1
246
+ scenario_counts[test.outcome] = scenario_counts.get(test.outcome, 0) + 1
247
+ scenario_outcome = worse(scenario_outcome, test.outcome)
248
+ verdict = worse(verdict, test.outcome)
249
+ duration_sum += test.duration_ms or 0
250
+ scenario_duration += test.duration_ms or 0
251
+
252
+ scenarios.append(
253
+ ReportScenario(
254
+ slug=slug,
255
+ outcome=scenario_outcome,
256
+ outcomes=scenario_counts,
257
+ duration_ms_sum=scenario_duration,
258
+ href=scenario_href(slug),
259
+ variants=variants,
260
+ failure_log=failure_logs.get(slug),
261
+ )
262
+ )
263
+
264
+ not_passed = sum(1 for s in scenarios if s.outcome not in _PASSING)
265
+ if not tests:
266
+ # No outcomes were recorded at all — a run without ``--bundle``, or a
267
+ # capture-only guide run. "passed" would be a claim we have no evidence
268
+ # for, which is the exact failure mode this whole model tries to avoid.
269
+ # ``exit_code`` stays 0 because nothing *failed*: the run itself already
270
+ # reported its own verdict, and a report is not the gate.
271
+ verdict = "unknown"
272
+ exit_code = 0
273
+ else:
274
+ exit_code = 0 if verdict in _PASSING else 1
275
+ totals = ReportTotals(
276
+ tests=len(tests),
277
+ scenarios=len(scenarios),
278
+ scenarios_not_passed=not_passed,
279
+ outcomes=dict(sorted(outcomes.items())),
280
+ duration_ms_sum=duration_sum,
281
+ verdict=verdict,
282
+ exit_code=exit_code,
283
+ )
284
+
285
+ warnings = list(manifest.warnings) + list(extra_warnings or [])
286
+ return ReportModel(
287
+ steppy_report=REPORT_SCHEMA_VERSION,
288
+ generated_by=steppy_version,
289
+ source=source,
290
+ run=manifest.run,
291
+ totals=totals,
292
+ dimensions=_observed_dimensions(tests),
293
+ capabilities=ReportCapabilities(
294
+ step_timeline=any_steps,
295
+ attempts=any_attempts,
296
+ coverage=coverage.tier,
297
+ screenshots=screenshots_policy,
298
+ strength_reliable=coverage.tier != "records",
299
+ ),
300
+ scenarios=scenarios,
301
+ failure_causes=causes,
302
+ coverage=coverage,
303
+ artifacts=_artifacts(manifest, assets),
304
+ warnings=warnings,
305
+ truncated=manifest.truncated,
306
+ )
307
+
308
+
309
+ def _observed_dimensions(tests: list[BundleTest]) -> dict[str, list[str]]:
310
+ """Locales and layouts that actually produced a row.
311
+
312
+ Not ``run.spec``: a scenario whose frontmatter ``layouts:`` is disjoint from
313
+ the run's is silently deselected, so a matrix built from the *requested* set
314
+ grows a column of empty cells that reads like a failure.
315
+ """
316
+ locales = sorted({t.locale for t in tests if t.locale})
317
+ layouts = sorted({t.layout for t in tests if t.layout})
318
+ return {"locales": locales, "layouts": layouts}
319
+
320
+
321
+ def _steps_for(attempt, test: BundleTest, assets: dict, manifest: BundleManifest) -> list[ReportStep]:
322
+ """The timeline for one variant, with each step's captures attached."""
323
+ if attempt is None:
324
+ return []
325
+ shots = _index_step_screenshots(manifest, assets, test.scenario, test.locale, test.layout)
326
+ steps = []
327
+ for row in attempt.offsets():
328
+ steps.append(ReportStep(**row, screenshots=shots.get(row["step"], [])))
329
+ return steps
330
+
331
+
332
+ def _step_title(steps: list[ReportStep], failed_step: Optional[int]) -> str:
333
+ if failed_step is None:
334
+ return ""
335
+ for step in steps:
336
+ if step.step == failed_step:
337
+ return step.title
338
+ return ""
339
+
340
+
341
+ def _index_step_screenshots(
342
+ manifest: BundleManifest, assets: dict, scenario: Optional[str], locale, layout
343
+ ) -> dict[int, list[str]]:
344
+ """``{step: [url, …]}`` for one variant, in frame order.
345
+
346
+ Reads ``manifest.files[]`` attribution rather than parsing paths — that is what
347
+ ``bundle/classify.py`` recorded it for.
348
+ """
349
+ out: dict[int, list[tuple[str, str]]] = {}
350
+ for entry in manifest.files:
351
+ if entry.kind != "screenshot" or entry.step is None:
352
+ continue
353
+ if entry.scenario != scenario or entry.locale != locale or entry.layout != layout:
354
+ continue
355
+ url = assets.get(entry.path)
356
+ if url:
357
+ out.setdefault(entry.step, []).append((entry.frame or "", url))
358
+ return {step: [url for _frame, url in sorted(frames)] for step, frames in out.items()}
359
+
360
+
361
+ def _index_failure_screenshots(manifest: BundleManifest, assets: dict) -> dict[str, str]:
362
+ """``{nodeid: url}``, joined through the manifest's own attribution.
363
+
364
+ ``sanitise_nodeid`` is lossy and must only ever be used forwards, so nothing
365
+ here tries to reverse a ``_failures/`` filename: ``classify`` already resolved
366
+ each one to a (scenario, locale, layout) at create time.
367
+ """
368
+ by_key: dict[tuple, str] = {}
369
+ for entry in manifest.files:
370
+ if entry.kind != "failure_screenshot":
371
+ continue
372
+ url = assets.get(entry.path)
373
+ if url:
374
+ by_key.setdefault((entry.scenario, entry.locale, entry.layout), url)
375
+ return {
376
+ test.nodeid: url
377
+ for test in manifest.tests
378
+ if (url := by_key.get((test.scenario, test.locale, test.layout))) is not None
379
+ }
380
+
381
+
382
+ def _index_by_scenario(manifest: BundleManifest, kind: str) -> dict[str, str]:
383
+ out: dict[str, str] = {}
384
+ for entry in manifest.files:
385
+ if entry.kind == kind and entry.scenario:
386
+ out.setdefault(entry.scenario, entry.path)
387
+ return out
388
+
389
+
390
+ def _index_paths(manifest: BundleManifest, kind: str) -> dict[str, str]:
391
+ """``{nodeid: in-bundle path}`` for artifacts that are named, not copied."""
392
+ by_key: dict[tuple, str] = {}
393
+ for entry in manifest.files:
394
+ if entry.kind == kind:
395
+ by_key.setdefault((entry.scenario, entry.locale, entry.layout), entry.path)
396
+ return {
397
+ test.nodeid: path
398
+ for test in manifest.tests
399
+ if (path := by_key.get((test.scenario, test.locale, test.layout))) is not None
400
+ }
401
+
402
+
403
+ def _artifacts(manifest: BundleManifest, assets: dict) -> ReportArtifacts:
404
+ by_kind: dict[str, ReportArtifactGroup] = {}
405
+ total_bytes = 0
406
+ for entry in manifest.files:
407
+ group = by_kind.setdefault(entry.kind, ReportArtifactGroup())
408
+ group.count += 1
409
+ group.bytes += entry.size
410
+ total_bytes += entry.size
411
+ copied = {url for url in assets.values() if url}
412
+ return ReportArtifacts(
413
+ files=len(manifest.files),
414
+ bytes=total_bytes,
415
+ deduped=max(len(assets) - len(copied), 0),
416
+ by_kind=dict(sorted(by_kind.items())),
417
+ )
418
+
419
+
420
+ def files_of_kind(manifest: BundleManifest, *kinds: str) -> list[BundleFile]:
421
+ """Manifest rows of any of *kinds*, in manifest order (sorted by path)."""
422
+ wanted = set(kinds)
423
+ return [f for f in manifest.files if f.kind in wanted]