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,37 @@
|
|
|
1
|
+
"""``steppy report``: one results bundle rendered to a self-contained static site.
|
|
2
|
+
|
|
3
|
+
**Lazy-imported only.** Nothing here may be reached by ``import steppy.cli`` —
|
|
4
|
+
this package walks and hashes an ``output/`` tree, opens tar archives and builds a
|
|
5
|
+
Jinja environment, none of which belongs on the path a plain ``steppy run`` (or
|
|
6
|
+
``steppy --help``) takes. Every ``steppy.report`` import lives inside a
|
|
7
|
+
``report_cli`` command body. Guard:
|
|
8
|
+
``tests/test_no_report_import_on_default_path.py``.
|
|
9
|
+
|
|
10
|
+
**A note on the name**, because "report" already means four things here and a
|
|
11
|
+
fifth would be one too many. ``config.yaml``'s ``report:`` block
|
|
12
|
+
(:class:`steppy.models.ReportConfig`) is about *pytest* artifacts — JUnit XML, an
|
|
13
|
+
HTML report, traces, videos, reruns. The ``steppy-dev[report]`` extra is pytest-html.
|
|
14
|
+
``bundle``'s file-kind ``report`` labels those artifacts inside an archive. And
|
|
15
|
+
``steppy coverage report`` prints the gap buckets. This package is none of them:
|
|
16
|
+
it is the *run report site* — the thing you publish to GitHub or GitLab Pages.
|
|
17
|
+
|
|
18
|
+
What it is, and just as importantly what it is not. The report **transforms one
|
|
19
|
+
bundle**. That is a structural property, not a restriction: with a single run's
|
|
20
|
+
artifacts there is nothing to diff against, no history to rank flakes by, and no
|
|
21
|
+
baseline anyone accepted. Those need memory, and memory lives in a control plane.
|
|
22
|
+
|
|
23
|
+
Module map:
|
|
24
|
+
|
|
25
|
+
- ``source`` — three inputs (live tree / archive / unpacked dir), one protocol.
|
|
26
|
+
- ``model`` — the pure view-model builder. **No jinja2**, deliberately: this is
|
|
27
|
+
the module a control plane imports, and it must not drag a renderer along.
|
|
28
|
+
- ``assets`` — which captures travel, where they land, and how they dedupe.
|
|
29
|
+
- ``coverage_source`` — the four-tier coverage resolution and the badge number.
|
|
30
|
+
- ``render`` — jinja2 → HTML, CSS, ``report.json``, ``badge.json``, robots.
|
|
31
|
+
- ``build`` — orchestration, :class:`ReportError`, :class:`ReportResult`.
|
|
32
|
+
|
|
33
|
+
This file re-exports **nothing**, on purpose. ``from steppy.report import
|
|
34
|
+
build_report`` would make importing ``steppy.report.model`` pull in the renderer
|
|
35
|
+
and therefore jinja2 — which is exactly what the model/render split exists to
|
|
36
|
+
prevent. Import the submodule you need (the same shape ``steppy.coverage`` uses).
|
|
37
|
+
"""
|
steppy/report/assets.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Which captures travel into the report site, and where they land.
|
|
2
|
+
|
|
3
|
+
Two jobs, and both are load-bearing for different reasons.
|
|
4
|
+
|
|
5
|
+
**The policy.** ``all`` on a real corpus is scenarios × locales × layouts ×
|
|
6
|
+
frames — hundreds of megabytes pushed to Pages on every run — so the default is
|
|
7
|
+
``failed``: the captures of the variants that did not pass, plus every failure
|
|
8
|
+
screenshot. A run report's job is diagnosis; the guide site is where every capture
|
|
9
|
+
already lives. ``all`` is one flag away for a showcase repo that wants the full
|
|
10
|
+
"look what CI verified" strip.
|
|
11
|
+
|
|
12
|
+
**The naming.** Destinations are computed from *validated components*, never from
|
|
13
|
+
a manifest path. That is a security boundary, not tidiness: a downloaded bundle's
|
|
14
|
+
``BundleFile.path`` is attacker-controlled, and streaming tar members
|
|
15
|
+
one-by-one skips Python's extraction filters — so ``../../evil.png`` must not be
|
|
16
|
+
able to decide where anything is written. Every component is reduced to
|
|
17
|
+
``[A-Za-z0-9._-]`` and the shape is fixed:
|
|
18
|
+
|
|
19
|
+
assets/<scenario>/<locale>-<layout>/step-NN[-frame].png
|
|
20
|
+
assets/_failures/<scenario>/<locale>-<layout>.png
|
|
21
|
+
|
|
22
|
+
Captures are also deduped by ``sha256``, so a screenshot that is byte-identical
|
|
23
|
+
across two layouts is copied once and both URLs point at it. On a guide corpus
|
|
24
|
+
where only a handful of steps differ per layout, that is most of the payload.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import re
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Optional
|
|
33
|
+
|
|
34
|
+
from ..contracts.bundle import BundleFile, BundleManifest
|
|
35
|
+
|
|
36
|
+
#: Copy every step capture, only the ones belonging to a variant that did not
|
|
37
|
+
#: pass, or none at all.
|
|
38
|
+
POLICIES = ("failed", "all", "none")
|
|
39
|
+
DEFAULT_POLICY = "failed"
|
|
40
|
+
|
|
41
|
+
ASSETS_DIRNAME = "assets"
|
|
42
|
+
FAILURES_DIRNAME = "_failures"
|
|
43
|
+
|
|
44
|
+
#: Above this, a Pages deploy is heading for trouble — GitHub caps a published
|
|
45
|
+
#: site at 1 GB and warns from 1 GB, so half of that is where saying something is
|
|
46
|
+
#: still useful rather than merely alarming.
|
|
47
|
+
WARN_BYTES = 500 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
_SAFE = re.compile(r"[^A-Za-z0-9._-]+")
|
|
50
|
+
_UNSET = "_"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def sanitise(component: str) -> str:
|
|
54
|
+
"""One path component, reduced to characters that cannot mean anything else.
|
|
55
|
+
|
|
56
|
+
Collapses runs of unsafe characters to ``-`` and strips leading/trailing dots
|
|
57
|
+
and dashes, so ``..``, ``/``, a drive letter or a NUL cannot survive into a
|
|
58
|
+
destination. Stripping the *trailing* dot matters too: Windows rejects a
|
|
59
|
+
filename that ends in one, and a report that renders on Linux and fails to
|
|
60
|
+
unzip on Windows is a bug someone else has to diagnose.
|
|
61
|
+
"""
|
|
62
|
+
cleaned = _SAFE.sub("-", component or "").strip(".-")
|
|
63
|
+
return cleaned or _UNSET
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def variant_dirname(locale: Optional[str], layout: Optional[str]) -> str:
|
|
67
|
+
"""``fr-mobile``, ``fr-_``, ``_-_`` — one directory per (locale, layout).
|
|
68
|
+
|
|
69
|
+
The ``_`` placeholder for an unset dimension mirrors the ``default`` sentinel
|
|
70
|
+
the capture path grammar and the event filenames already use: three writers,
|
|
71
|
+
one idea, so a missing dimension never silently merges two variants.
|
|
72
|
+
"""
|
|
73
|
+
return f"{sanitise(locale or _UNSET)}-{sanitise(layout or _UNSET)}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class AssetPlan:
|
|
78
|
+
"""What to copy where, and the URL each manifest path resolves to."""
|
|
79
|
+
|
|
80
|
+
#: manifest path → destination file (absolute).
|
|
81
|
+
into: dict[str, Path] = field(default_factory=dict)
|
|
82
|
+
#: manifest path → site-relative URL. Includes deduped paths, whose URL points
|
|
83
|
+
#: at the one copy that was actually made.
|
|
84
|
+
urls: dict[str, str] = field(default_factory=dict)
|
|
85
|
+
#: Bytes the copies will occupy, after dedupe.
|
|
86
|
+
bytes: int = 0
|
|
87
|
+
warnings: list[str] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def copies(self) -> int:
|
|
91
|
+
return len(self.into)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def plan_assets(
|
|
95
|
+
manifest: BundleManifest,
|
|
96
|
+
*,
|
|
97
|
+
site_dir: Path,
|
|
98
|
+
policy: str = DEFAULT_POLICY,
|
|
99
|
+
) -> AssetPlan:
|
|
100
|
+
"""Decide which captures travel, under *policy*.
|
|
101
|
+
|
|
102
|
+
Failure screenshots always travel when the policy is not ``none``: they are the
|
|
103
|
+
single most useful artifact in a red run, and there are at most one per failing
|
|
104
|
+
variant.
|
|
105
|
+
"""
|
|
106
|
+
if policy not in POLICIES:
|
|
107
|
+
raise ValueError(f"screenshot policy must be one of {', '.join(POLICIES)} (got {policy!r})")
|
|
108
|
+
|
|
109
|
+
plan = AssetPlan()
|
|
110
|
+
if policy == "none":
|
|
111
|
+
return plan
|
|
112
|
+
|
|
113
|
+
wanted_variants = _variants_to_include(manifest, policy)
|
|
114
|
+
by_digest: dict[str, str] = {}
|
|
115
|
+
|
|
116
|
+
for entry in _sorted_captures(manifest):
|
|
117
|
+
if entry.kind == "screenshot":
|
|
118
|
+
if (entry.scenario, entry.locale, entry.layout) not in wanted_variants:
|
|
119
|
+
continue
|
|
120
|
+
rel = _step_rel(entry)
|
|
121
|
+
else: # failure_screenshot
|
|
122
|
+
rel = _failure_rel(entry)
|
|
123
|
+
if rel is None:
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
existing = by_digest.get(entry.sha256)
|
|
127
|
+
if existing is not None:
|
|
128
|
+
# Byte-identical capture already copied — point at the one copy.
|
|
129
|
+
plan.urls[entry.path] = existing
|
|
130
|
+
continue
|
|
131
|
+
by_digest[entry.sha256] = rel
|
|
132
|
+
plan.urls[entry.path] = rel
|
|
133
|
+
plan.into[entry.path] = site_dir / Path(rel)
|
|
134
|
+
plan.bytes += entry.size
|
|
135
|
+
|
|
136
|
+
if plan.bytes > WARN_BYTES:
|
|
137
|
+
plan.warnings.append(
|
|
138
|
+
f"the report will copy {plan.bytes / (1024 * 1024):.0f} MiB of screenshots; "
|
|
139
|
+
"GitHub Pages caps a published site at 1 GB — consider --screenshots failed"
|
|
140
|
+
)
|
|
141
|
+
return plan
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _sorted_captures(manifest: BundleManifest) -> list[BundleFile]:
|
|
145
|
+
"""Captures in a stable order, so dedupe picks the same winner every run.
|
|
146
|
+
|
|
147
|
+
Which copy survives has to be deterministic or two renders of one bundle
|
|
148
|
+
produce different URLs, and the report stops being diffable.
|
|
149
|
+
"""
|
|
150
|
+
captures = [f for f in manifest.files if f.kind in ("screenshot", "failure_screenshot")]
|
|
151
|
+
return sorted(captures, key=lambda f: f.path)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _variants_to_include(manifest: BundleManifest, policy: str) -> set[tuple]:
|
|
155
|
+
"""The (scenario, locale, layout) keys whose step captures travel.
|
|
156
|
+
|
|
157
|
+
Under ``failed`` this is every variant that did not pass — and, when there are
|
|
158
|
+
no test rows at all (a run made without ``--bundle``), nothing: an empty set is
|
|
159
|
+
the honest answer, and the report says so rather than shipping the whole
|
|
160
|
+
corpus by accident.
|
|
161
|
+
"""
|
|
162
|
+
if policy == "all":
|
|
163
|
+
return {(f.scenario, f.locale, f.layout) for f in manifest.files if f.kind == "screenshot"}
|
|
164
|
+
return {(t.scenario, t.locale, t.layout) for t in manifest.tests if t.outcome not in ("passed", "skipped")}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _step_rel(entry: BundleFile) -> Optional[str]:
|
|
168
|
+
if entry.step is None:
|
|
169
|
+
return None
|
|
170
|
+
stem = f"step-{entry.step:02d}" + (f"-{sanitise(entry.frame)}" if entry.frame else "")
|
|
171
|
+
return "/".join(
|
|
172
|
+
(
|
|
173
|
+
ASSETS_DIRNAME,
|
|
174
|
+
sanitise(entry.scenario or _UNSET),
|
|
175
|
+
variant_dirname(entry.locale, entry.layout),
|
|
176
|
+
f"{stem}{_suffix(entry.path)}",
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _failure_rel(entry: BundleFile) -> str:
|
|
182
|
+
# One failure capture per (scenario, variant, fixture); the fixture name is
|
|
183
|
+
# already gone from the manifest, so a second one would collide. Including the
|
|
184
|
+
# digest prefix keeps both rather than silently dropping one.
|
|
185
|
+
return "/".join(
|
|
186
|
+
(
|
|
187
|
+
ASSETS_DIRNAME,
|
|
188
|
+
FAILURES_DIRNAME,
|
|
189
|
+
sanitise(entry.scenario or _UNSET),
|
|
190
|
+
f"{variant_dirname(entry.locale, entry.layout)}-{entry.sha256[:8]}{_suffix(entry.path)}",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _suffix(path: str) -> str:
|
|
196
|
+
suffix = Path(path).suffix.lower()
|
|
197
|
+
return suffix if re.fullmatch(r"\.[a-z0-9]{1,8}", suffix) else ".png"
|
steppy/report/build.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Orchestration: a run in, a static site out.
|
|
2
|
+
|
|
3
|
+
The order matters in one place and is otherwise unremarkable. Assets are planned
|
|
4
|
+
and copied **before** the model is built, because the model needs the URL each
|
|
5
|
+
capture ended up at; and the target directory is validated **before** anything is
|
|
6
|
+
removed, because ``-o`` is a path off the command line and one typo away from
|
|
7
|
+
somewhere that matters.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shutil
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional, Sequence
|
|
16
|
+
|
|
17
|
+
from ..contracts.report import ReportModel, ReportSourceInfo
|
|
18
|
+
|
|
19
|
+
#: Default site location. Deliberately *not* a ``config.output.*`` key: this root
|
|
20
|
+
#: has one writer and no automated reader (a CI job names the path in its own
|
|
21
|
+
#: upload step), and ``OutputConfig`` already carries a deprecated-and-inert
|
|
22
|
+
#: ``reports_dir`` — a live ``report_dir`` one letter away would be a permanent
|
|
23
|
+
#: trap in every future review of that file.
|
|
24
|
+
DEFAULT_SITE_SUBDIR = "report"
|
|
25
|
+
|
|
26
|
+
#: Files whose presence means "this is a directory that matters, not a stale site".
|
|
27
|
+
#: A second lock on rmtree: even ``--force`` will not delete a project.
|
|
28
|
+
GUARDED_NAMES = ("config.yaml", "pyproject.toml", ".git", "scenarios", "src")
|
|
29
|
+
|
|
30
|
+
#: The site's own marker. ``report.json`` is already written at the root and
|
|
31
|
+
#: self-identifies via ``steppy_report``, so there is no extra file to keep in sync.
|
|
32
|
+
MARKER_NAME = "report.json"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReportError(Exception):
|
|
36
|
+
"""The report could not be produced. Always actionable."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class ReportResult:
|
|
41
|
+
site_dir: Path
|
|
42
|
+
model: ReportModel
|
|
43
|
+
files: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
copied: int = 0
|
|
45
|
+
copied_bytes: int = 0
|
|
46
|
+
warnings: list[str] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def verdict(self) -> str:
|
|
50
|
+
return self.model.totals.verdict
|
|
51
|
+
|
|
52
|
+
def count(self, status: str) -> int:
|
|
53
|
+
return sum(1 for value in self.files.values() if value == status)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_report(
|
|
57
|
+
*,
|
|
58
|
+
project_root: Path,
|
|
59
|
+
bundle: Optional[Path] = None,
|
|
60
|
+
site_dir: Optional[Path] = None,
|
|
61
|
+
output_dir: Optional[Path] = None,
|
|
62
|
+
run_id: Optional[str] = None,
|
|
63
|
+
screenshots: str = "failed",
|
|
64
|
+
badge_surface: str = "all",
|
|
65
|
+
badge_label: Optional[str] = None,
|
|
66
|
+
title: Optional[str] = None,
|
|
67
|
+
templates_dir: Optional[Path] = None,
|
|
68
|
+
non_ui: Sequence[str] = (),
|
|
69
|
+
force: bool = False,
|
|
70
|
+
dry_run: bool = False,
|
|
71
|
+
) -> ReportResult:
|
|
72
|
+
"""Render one run to a self-contained static site."""
|
|
73
|
+
from .. import __version__
|
|
74
|
+
from ..coverage.gaps import SURFACES
|
|
75
|
+
from .assets import POLICIES, plan_assets
|
|
76
|
+
from .coverage_source import resolve_coverage
|
|
77
|
+
from .model import build_model
|
|
78
|
+
from .render import render_site
|
|
79
|
+
from .source import ReportSourceError, open_source
|
|
80
|
+
|
|
81
|
+
# Validated before anything is read or removed: an unusable flag should cost
|
|
82
|
+
# the caller a message, not a half-written site. Both are checked here rather
|
|
83
|
+
# than only in Typer so a programmatic caller gets the same guarantee — and
|
|
84
|
+
# an unknown surface would otherwise render a silently empty badge, which is
|
|
85
|
+
# the worst of the three outcomes.
|
|
86
|
+
if screenshots not in POLICIES:
|
|
87
|
+
raise ReportError(f"--screenshots must be one of {', '.join(POLICIES)} (got {screenshots!r})")
|
|
88
|
+
if badge_surface not in SURFACES:
|
|
89
|
+
raise ReportError(f"--badge-surface must be one of {', '.join(SURFACES)} (got {badge_surface!r})")
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
source = open_source(bundle, project_root=project_root, output_dir=output_dir, run_id=run_id)
|
|
93
|
+
except ReportSourceError as e:
|
|
94
|
+
raise ReportError(str(e)) from e
|
|
95
|
+
|
|
96
|
+
target = Path(site_dir) if site_dir else project_root / "output" / DEFAULT_SITE_SUBDIR
|
|
97
|
+
target = target.resolve()
|
|
98
|
+
_check_target(target, force=force, dry_run=dry_run)
|
|
99
|
+
|
|
100
|
+
warnings: list[str] = list(source.warnings)
|
|
101
|
+
warnings.extend(_placement_warnings(target, project_root))
|
|
102
|
+
|
|
103
|
+
plan = plan_assets(source.manifest, site_dir=target, policy=screenshots)
|
|
104
|
+
warnings.extend(plan.warnings)
|
|
105
|
+
|
|
106
|
+
if not dry_run:
|
|
107
|
+
_reset(target)
|
|
108
|
+
_, fetch_warnings = source.fetch(into=plan.into)
|
|
109
|
+
warnings.extend(fetch_warnings)
|
|
110
|
+
|
|
111
|
+
coverage = resolve_coverage(
|
|
112
|
+
source,
|
|
113
|
+
project_root=project_root,
|
|
114
|
+
non_ui=non_ui,
|
|
115
|
+
surface=badge_surface,
|
|
116
|
+
badge_label=badge_label,
|
|
117
|
+
href="coverage.html",
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
attempts = _timeline(source)
|
|
121
|
+
model = build_model(
|
|
122
|
+
manifest=source.manifest,
|
|
123
|
+
source=ReportSourceInfo(kind=source.kind, label=source.label),
|
|
124
|
+
attempts=attempts,
|
|
125
|
+
coverage=coverage,
|
|
126
|
+
assets=plan.urls,
|
|
127
|
+
screenshots_policy=screenshots,
|
|
128
|
+
steppy_version=__version__,
|
|
129
|
+
extra_warnings=warnings,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
rendered = render_site(
|
|
133
|
+
model,
|
|
134
|
+
site_dir=target,
|
|
135
|
+
title=title or _default_title(model),
|
|
136
|
+
templates_dir=templates_dir,
|
|
137
|
+
project_root=project_root,
|
|
138
|
+
dry_run=dry_run,
|
|
139
|
+
)
|
|
140
|
+
return ReportResult(
|
|
141
|
+
site_dir=target,
|
|
142
|
+
model=model,
|
|
143
|
+
files=rendered.files,
|
|
144
|
+
copied=plan.copies,
|
|
145
|
+
copied_bytes=plan.bytes,
|
|
146
|
+
warnings=warnings,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _timeline(source) -> dict:
|
|
151
|
+
"""``{nodeid: [TestAttempt]}`` from the run's event JSONL, or ``{}``.
|
|
152
|
+
|
|
153
|
+
Absent step events are a *capability*, not a failure: they exist only when the
|
|
154
|
+
run set ``STEPPY_STEP_EVENTS=1`` (which ``steppy run --bundle`` does), and a
|
|
155
|
+
tree generated before the markers existed emits none either. The model records
|
|
156
|
+
which, and the page says so.
|
|
157
|
+
"""
|
|
158
|
+
from ..bundle.results import parse_event_lines, steps_from_records
|
|
159
|
+
|
|
160
|
+
wanted = {f.path for f in source.manifest.files if f.kind == "events"}
|
|
161
|
+
if not wanted:
|
|
162
|
+
return {}
|
|
163
|
+
fetched, _warnings = source.fetch(texts=wanted)
|
|
164
|
+
texts = [(name, payload.decode("utf-8", errors="replace")) for name, payload in sorted(fetched.items())]
|
|
165
|
+
records, _parse_warnings = parse_event_lines(texts)
|
|
166
|
+
return steps_from_records(records)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _default_title(model: ReportModel) -> str:
|
|
170
|
+
branch = model.run.git.branch
|
|
171
|
+
return f"Steppy run report — {branch}" if branch else "Steppy run report"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _check_target(target: Path, *, force: bool, dry_run: bool) -> None:
|
|
175
|
+
"""Refuse to write where a mistake would be expensive.
|
|
176
|
+
|
|
177
|
+
``steppy report -o <dir>`` takes its path from the command line, so ``-o .`` in
|
|
178
|
+
a project root is one keystroke away. Three checks, and the **order matters**:
|
|
179
|
+
|
|
180
|
+
1. Our own marker wins first. A directory holding ``report.json`` with a
|
|
181
|
+
``steppy_report`` key is a previous render of this report, and re-rendering
|
|
182
|
+
into it is the normal case. This has to come first because the site itself
|
|
183
|
+
writes a ``scenarios/`` directory — which is also one of the signals below
|
|
184
|
+
that a directory is a *project*. Checking that first would refuse every
|
|
185
|
+
second render, permanently.
|
|
186
|
+
2. A directory that looks like a project (``config.yaml`` / ``pyproject.toml``
|
|
187
|
+
/ ``.git`` / ``scenarios`` / ``src``) is refused **even with ``--force``**.
|
|
188
|
+
Nothing about rendering a report justifies deleting one of those. A
|
|
189
|
+
directory cannot become both, because step 2 refuses the first render into
|
|
190
|
+
a project before step 1 could ever have written a marker there.
|
|
191
|
+
3. Otherwise a non-empty directory needs ``--force``.
|
|
192
|
+
"""
|
|
193
|
+
if not target.exists():
|
|
194
|
+
return
|
|
195
|
+
if not target.is_dir():
|
|
196
|
+
raise ReportError(f"{target} exists and is not a directory")
|
|
197
|
+
|
|
198
|
+
if _looks_like_a_report(target):
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
guarded = [name for name in GUARDED_NAMES if (target / name).exists()]
|
|
202
|
+
if guarded:
|
|
203
|
+
raise ReportError(
|
|
204
|
+
f"refusing to write a report into {target} — it contains {', '.join(sorted(guarded))}, "
|
|
205
|
+
"which makes it look like a project rather than a report directory. "
|
|
206
|
+
"Point -o at a dedicated directory (the default is output/report)."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
if dry_run or not any(target.iterdir()) or force:
|
|
210
|
+
return
|
|
211
|
+
raise ReportError(
|
|
212
|
+
f"{target} is not empty and does not look like a Steppy report "
|
|
213
|
+
f"(no {MARKER_NAME} with a 'steppy_report' key). Pass --force to replace its contents, "
|
|
214
|
+
"or point -o somewhere else."
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _looks_like_a_report(target: Path) -> bool:
|
|
219
|
+
import json
|
|
220
|
+
|
|
221
|
+
marker = target / MARKER_NAME
|
|
222
|
+
if not marker.is_file():
|
|
223
|
+
return False
|
|
224
|
+
try:
|
|
225
|
+
return "steppy_report" in json.loads(marker.read_text(encoding="utf-8"))
|
|
226
|
+
except (OSError, ValueError):
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _reset(target: Path) -> None:
|
|
231
|
+
"""Clear the site so a removed scenario's page does not linger.
|
|
232
|
+
|
|
233
|
+
Only ever reached after :func:`_check_target` approved the path.
|
|
234
|
+
"""
|
|
235
|
+
if target.exists():
|
|
236
|
+
shutil.rmtree(target)
|
|
237
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _placement_warnings(target: Path, project_root: Path) -> list[str]:
|
|
241
|
+
"""Warn when the site lands somewhere ``bundle create`` will archive.
|
|
242
|
+
|
|
243
|
+
``output/report/`` is skipped by the bundler by name. Anywhere else under
|
|
244
|
+
``output/`` is not, so a later ``steppy bundle create`` would archive the whole
|
|
245
|
+
site — including every screenshot it copied — and roughly double the bundle.
|
|
246
|
+
"""
|
|
247
|
+
output = (project_root / "output").resolve()
|
|
248
|
+
default = (output / DEFAULT_SITE_SUBDIR).resolve()
|
|
249
|
+
if target == default:
|
|
250
|
+
return []
|
|
251
|
+
try:
|
|
252
|
+
inside = target.is_relative_to(output)
|
|
253
|
+
except (OSError, ValueError): # pragma: no cover - defensive
|
|
254
|
+
return []
|
|
255
|
+
if not inside:
|
|
256
|
+
return []
|
|
257
|
+
return [
|
|
258
|
+
f"the report is being written to {target}, which is inside output/ but is not "
|
|
259
|
+
f"output/{DEFAULT_SITE_SUBDIR} — a later `steppy bundle create` will archive it as run "
|
|
260
|
+
"artifacts, roughly doubling the bundle's screenshot payload"
|
|
261
|
+
]
|