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,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
+ """
@@ -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
+ ]