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