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,346 @@
1
+ """``report.json`` — the run report's view model, as a versioned wire contract.
2
+
3
+ A results bundle says what a run *produced*. This says what a run *means*: which
4
+ scenarios passed, which step a failure died in, what the distinct failure causes
5
+ were, how much of the declared surface the suite reached. ``steppy report``
6
+ renders it to HTML; a control plane renders it its own way. That sharing is the
7
+ whole reason it is a contract rather than a private dict — the alternative is two
8
+ independently-written readers of the same bundle, and this repo has been burned by
9
+ duplicated definitions twice already.
10
+
11
+ Same compatibility charter as :mod:`steppy.contracts.bundle`, and for the same
12
+ reasons:
13
+
14
+ 1. Every model is ``extra="allow"``. A newer producer's extra field must survive
15
+ an older consumer, not crash it. (``models.py`` is ``extra="forbid"``, which is
16
+ the right posture for a hand-written config where a typo must fail loudly, and
17
+ the wrong one for a wire format.)
18
+ 2. Unknown enum-ish values are data. ``outcome`` and ``status`` are plain ``str``
19
+ — render what you get.
20
+ 3. Consumers accept version N and N−1. Additive fields never bump the version.
21
+ 4. Absent keys default. Never require a field a older producer could not have
22
+ written.
23
+
24
+ **Two properties worth stating because code depends on them.**
25
+
26
+ ``report.json`` carries **no render-time clock and no absolute paths**. The only
27
+ timestamp is the run's own ``created_at``, taken from the bundle. That makes the
28
+ file byte-reproducible for an unchanged bundle — so CI can diff two of them, and
29
+ so a test can assert on the model instead of on HTML. ``manifest.build`` refuses
30
+ ``now`` for the same reason.
31
+
32
+ ``capabilities`` is the **honesty block**. Per-step timelines exist only when the
33
+ run set ``STEPPY_STEP_EVENTS=1`` (which ``steppy run --bundle`` does), coverage
34
+ exists only if it was recorded, and assert *strength* is unknowable from a
35
+ detached bundle. Without somewhere to say so, a consumer cannot tell "no timeline
36
+ was recorded" from "the timeline was empty" — the same distinction ``truncated``
37
+ exists to make in the bundle manifest.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from typing import Any, Optional
43
+
44
+ from pydantic import BaseModel, ConfigDict, Field
45
+
46
+ from .bundle import BundleRun
47
+
48
+ #: Bump only on a breaking change. Additive fields never require it.
49
+ REPORT_SCHEMA_VERSION = 1
50
+
51
+ #: What a consumer at this version will read. The N and N−1 rule as data, so the
52
+ #: error message and the drift test read the same tuple.
53
+ ACCEPTED_REPORT_VERSIONS: tuple[int, ...] = (1,)
54
+
55
+
56
+ class _Wire(BaseModel):
57
+ """Base for every wire model: unknown fields are kept, never rejected."""
58
+
59
+ model_config = ConfigDict(extra="allow")
60
+
61
+
62
+ class ReportSourceInfo(_Wire):
63
+ """Where the rendered run came from, for the page footer and for support.
64
+
65
+ ``kind`` is ``tree`` (the project's live ``output/``), ``archive`` (a
66
+ ``.tar.zst``/``.tar.gz``) or ``dir`` (an unpacked bundle). ``label`` is a
67
+ *display* string — a bundle filename or a relative tree path, never an
68
+ absolute one, because this file gets published.
69
+ """
70
+
71
+ kind: str
72
+ label: str = ""
73
+
74
+
75
+ class ReportTotals(_Wire):
76
+ """Run-level arithmetic.
77
+
78
+ ``duration_ms_sum`` is the **sum of test durations**, not wall clock. Under
79
+ ``--workers N`` those differ by roughly N, and a bundle carries no
80
+ ``finished_at`` to compute the real elapsed time from — so the field is named
81
+ for what it actually is rather than implying a number it cannot know.
82
+ """
83
+
84
+ tests: int = 0
85
+ scenarios: int = 0
86
+ scenarios_not_passed: int = 0
87
+ outcomes: dict[str, int] = Field(default_factory=dict)
88
+ duration_ms_sum: int = 0
89
+ #: Worst outcome across the run, using the bundle's own vocabulary.
90
+ verdict: str = "passed"
91
+ #: What a gate *would* use. The ``steppy report`` command itself always exits
92
+ #: 0 — it is a renderer, and its exit code describes rendering.
93
+ exit_code: int = 0
94
+
95
+
96
+ class ReportCapabilities(_Wire):
97
+ """What this report could and could not know. See the module docstring."""
98
+
99
+ #: False when the run recorded no ``step-start`` events at all.
100
+ step_timeline: bool = False
101
+ #: True when at least one test has more than one recorded attempt.
102
+ attempts: bool = False
103
+ #: ``bundled`` | ``store`` | ``records`` | ``none`` — where coverage came from.
104
+ coverage: str = "none"
105
+ #: ``all`` | ``failed`` | ``none`` — which captures were copied into the site.
106
+ screenshots: str = "none"
107
+ #: False when assert *strength* had to be guessed (a detached bundle carries no
108
+ #: ``scenarios/*.md``, so every covered node would read "weak").
109
+ strength_reliable: bool = True
110
+
111
+
112
+ class ReportStep(_Wire):
113
+ """One step of one attempt.
114
+
115
+ ``step`` is the **scenario** step number and the sequence is **sparse**: a step
116
+ skipped by ``only-in:`` or ``layouts:`` emits no events, so gaps are expected
117
+ and must never be renumbered. There is no total-step count anywhere in a
118
+ bundle, so this is "step 7", never "step 7 of 12".
119
+
120
+ ``status`` is ``passed`` when the step closed and ``running`` when it opened and
121
+ never did — which is precisely the failing step. ``duration_ms`` is ``None``
122
+ there, because the end is unknown rather than zero.
123
+ """
124
+
125
+ step: int
126
+ title: str = ""
127
+ action: str = ""
128
+ status: str = "passed"
129
+ offset_ms: int = 0
130
+ duration_ms: Optional[int] = None
131
+ #: Site-relative URLs of the captures for this step, in frame order.
132
+ screenshots: list[str] = Field(default_factory=list)
133
+
134
+
135
+ class ReportVariant(_Wire):
136
+ """One (locale, layout) execution of one scenario.
137
+
138
+ ``attempts`` is recoverable only for in-process pytest ``--reruns``: the
139
+ parallel coordinator's retry pass runs a fresh process, which truncates the
140
+ JSONL and takes attempt 1 with it. So ``1`` can mean "ran once" or "the earlier
141
+ attempts were overwritten", and the report says which when it can.
142
+ """
143
+
144
+ nodeid: str
145
+ locale: Optional[str] = None
146
+ layout: Optional[str] = None
147
+ outcome: str = "unknown"
148
+ duration_ms: Optional[int] = None
149
+ attempts: int = 1
150
+ attempt_outcomes: list[str] = Field(default_factory=list)
151
+ failed_step: Optional[int] = None
152
+ failed_step_title: str = ""
153
+ #: 8-hex group id from ``_parallel/fingerprint``, joining this to a cause.
154
+ fingerprint: Optional[str] = None
155
+ #: Decoded tail, at most 15 lines (``results.FAIL_TAIL_LINES``).
156
+ fail_tail: str = ""
157
+ steps: list[ReportStep] = Field(default_factory=list)
158
+ failure_screenshot: Optional[str] = None
159
+ #: Present in the run but NOT copied into the site — a trace needs
160
+ #: ``playwright show-trace`` to open, so a link would be a dead end.
161
+ trace_path: Optional[str] = None
162
+ video_path: Optional[str] = None
163
+
164
+
165
+ class ReportScenario(_Wire):
166
+ """One scenario and every variant of it that ran.
167
+
168
+ ``outcome`` is the **worst** across variants: a flow that fails only in Dutch
169
+ on mobile is a failing flow.
170
+ """
171
+
172
+ slug: str
173
+ outcome: str = "unknown"
174
+ outcomes: dict[str, int] = Field(default_factory=dict)
175
+ duration_ms_sum: int = 0
176
+ href: str = ""
177
+ variants: list[ReportVariant] = Field(default_factory=list)
178
+ #: Parallel runs only — ``_parallel/failures`` writes it, single-worker does not.
179
+ failure_log: Optional[str] = None
180
+
181
+
182
+ class ReportFailureOccurrence(_Wire):
183
+ scenario: str
184
+ nodeid: str
185
+ locale: Optional[str] = None
186
+ layout: Optional[str] = None
187
+
188
+
189
+ class ReportFailureCause(_Wire):
190
+ """One distinct failure, and everything that hit it.
191
+
192
+ This is the primitive that makes a Steppy report readable where a JUnit report
193
+ is not: forty scenarios dying from one broken selector collapse into one row
194
+ instead of forty tracebacks. Grouping is ``_parallel/fingerprint``, unchanged —
195
+ the same code the parallel console summary uses.
196
+ """
197
+
198
+ fingerprint: str
199
+ headline: str = ""
200
+ count: int = 0
201
+ scenarios: list[str] = Field(default_factory=list)
202
+ occurrences: list[ReportFailureOccurrence] = Field(default_factory=list)
203
+ #: Full tail of the first occurrence only. The rest are the same failure.
204
+ exemplar_tail: str = ""
205
+
206
+
207
+ class ReportBadge(_Wire):
208
+ """The e2e-surface number, and the shields payload derived from it.
209
+
210
+ ``percent is None`` means *not measured* — nothing declared — which renders as
211
+ ``n/a`` in grey rather than ``0%``. A badge reading 0% invites someone to go
212
+ fix a number that is not broken.
213
+ """
214
+
215
+ surface: str = "all"
216
+ label: str = "e2e surface"
217
+ tested: int = 0
218
+ declared: int = 0
219
+ ratio: Optional[float] = None
220
+ percent: Optional[int] = None
221
+ color: str = "lightgrey"
222
+
223
+
224
+ class ReportNonUi(_Wire):
225
+ """How much of the declared surface was discounted, and on whose authority."""
226
+
227
+ declared: int = 0
228
+ tested: int = 0
229
+ by_source: dict[str, int] = Field(default_factory=dict)
230
+
231
+
232
+ class ReportCoverage(_Wire):
233
+ """The coverage section, or the reason there isn't one.
234
+
235
+ ``tier`` records where the numbers came from: ``bundled`` (a
236
+ ``coverage/gaps.json`` that travelled with the run), ``store`` (the project's
237
+ live ``.steppy/coverage.db`` — live-tree input only, since reading it for
238
+ someone else's bundle would attribute this machine's map to their run),
239
+ ``records`` (rebuilt from raw records, with strength suppressed), or ``none``.
240
+ """
241
+
242
+ tier: str = "none"
243
+ counts: dict[str, Any] = Field(default_factory=dict)
244
+ e2e_surface: dict[str, Any] = Field(default_factory=dict)
245
+ #: The gap buckets themselves, keyed ``endpoints``/``routes``/``modules`` —
246
+ #: ``compute_gaps``' own output, carried verbatim rather than reshaped. A
247
+ #: second shape for the same data is a second thing to keep in sync, and the
248
+ #: bucket lists are already a documented part of ``coverage report --json``.
249
+ buckets: dict[str, Any] = Field(default_factory=dict)
250
+ non_ui: ReportNonUi = Field(default_factory=ReportNonUi)
251
+ badge: ReportBadge = Field(default_factory=ReportBadge)
252
+ href: Optional[str] = None
253
+ warnings: list[str] = Field(default_factory=list)
254
+
255
+
256
+ class ReportArtifactGroup(_Wire):
257
+ count: int = 0
258
+ bytes: int = 0
259
+
260
+
261
+ class ReportArtifacts(_Wire):
262
+ """What the run produced, by kind — an inventory, not a copy.
263
+
264
+ The bundle manifest is the file list; duplicating it here would double the
265
+ size of every report for no reader.
266
+ """
267
+
268
+ files: int = 0
269
+ bytes: int = 0
270
+ #: Captures copied once despite appearing under several manifest paths.
271
+ deduped: int = 0
272
+ by_kind: dict[str, ReportArtifactGroup] = Field(default_factory=dict)
273
+
274
+
275
+ class ReportModel(_Wire):
276
+ """The whole document. See the module docstring for the two invariants."""
277
+
278
+ steppy_report: int = Field(description="Contract version; see ACCEPTED_REPORT_VERSIONS.")
279
+ generated_by: str = ""
280
+ source: ReportSourceInfo
281
+ run: BundleRun
282
+ totals: ReportTotals = Field(default_factory=ReportTotals)
283
+ #: Locales and layouts actually **observed** in the test rows, not what
284
+ #: ``run.spec`` asked for: a scenario deselected by a disjoint frontmatter
285
+ #: ``layouts:`` never ran, and a column of empty cells reads as a failure.
286
+ dimensions: dict[str, list[str]] = Field(default_factory=dict)
287
+ capabilities: ReportCapabilities = Field(default_factory=ReportCapabilities)
288
+ scenarios: list[ReportScenario] = Field(default_factory=list)
289
+ failure_causes: list[ReportFailureCause] = Field(default_factory=list)
290
+ coverage: ReportCoverage = Field(default_factory=ReportCoverage)
291
+ artifacts: ReportArtifacts = Field(default_factory=ReportArtifacts)
292
+ warnings: list[str] = Field(default_factory=list)
293
+ #: Carried through from the bundle: the size budget dropped something that
294
+ #: belonged. Never set for a flag-excluded artifact.
295
+ truncated: bool = False
296
+
297
+
298
+ SCHEMA_TITLE = "Steppy run report model"
299
+
300
+
301
+ def generate_report_schema() -> dict:
302
+ """JSON Schema for ``report.json``, for consumers that are not Python."""
303
+ schema = ReportModel.model_json_schema()
304
+ schema["title"] = SCHEMA_TITLE
305
+ schema["x-steppy-report-version"] = REPORT_SCHEMA_VERSION
306
+ schema["x-steppy-report-accepted"] = list(ACCEPTED_REPORT_VERSIONS)
307
+ return schema
308
+
309
+
310
+ def schema_json() -> str:
311
+ """The canonical serialized form written to disk and compared in tests."""
312
+ import json
313
+
314
+ return json.dumps(generate_report_schema(), indent=2, sort_keys=True) + "\n"
315
+
316
+
317
+ def accepts(version: int) -> bool:
318
+ return version in ACCEPTED_REPORT_VERSIONS
319
+
320
+
321
+ def parse_report(payload: dict[str, Any]) -> ReportModel:
322
+ """Validate a ``report.json`` payload, with actionable version errors."""
323
+ version = payload.get("steppy_report")
324
+ if not isinstance(version, int):
325
+ raise ValueError("report.json is missing the integer 'steppy_report' contract version")
326
+ if not accepts(version):
327
+ supported = ", ".join(f"v{v}" for v in sorted(ACCEPTED_REPORT_VERSIONS, reverse=True))
328
+ raise ValueError(
329
+ f"report contract v{version} is not accepted by this version of steppy (supports {supported}). "
330
+ "Upgrade the steppy that is reading this report, or re-render it with the steppy that wrote it."
331
+ )
332
+ return ReportModel.model_validate(payload)
333
+
334
+
335
+ def report_bytes(model: ReportModel) -> bytes:
336
+ """``report.json`` as it lands on disk: sorted keys, nulls kept, trailing NL.
337
+
338
+ ``exclude_none=False`` matters — the same reason it does for the bundle
339
+ manifest. ``failed_step: null`` is a *real answer* (nothing was open when the
340
+ test died), and dropping the key would make it indistinguishable from a
341
+ producer that never recorded steps at all.
342
+ """
343
+ import json
344
+
345
+ payload = model.model_dump(mode="json", exclude_none=False)
346
+ return (json.dumps(payload, indent=2, sort_keys=True) + "\n").encode("utf-8")