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
steppy/bundle_cli.py ADDED
@@ -0,0 +1,455 @@
1
+ """``steppy bundle`` — package a run's results into a portable archive.
2
+
3
+ Leaf CLI module, registered by ``cli.py`` like ``coverage_cli`` /
4
+ ``video_cli``. Only ``typer`` + ``_cli_common`` + stdlib at module scope; every
5
+ ``steppy.bundle`` import happens inside a command body so ``import steppy.cli``
6
+ never pulls in the walker or the optional ``zstandard``. Guard:
7
+ ``tests/test_no_bundle_import_on_default_path.py``.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json as _json
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ import typer
17
+
18
+ from ._cli_common import _cli_errors, _error, console
19
+
20
+ bundle_app = typer.Typer(
21
+ help="Package a run's output/ tree into a portable results bundle.",
22
+ no_args_is_help=True,
23
+ )
24
+
25
+
26
+ def register_bundle(app: typer.Typer) -> None:
27
+ """Attach the ``bundle`` command group to the root Typer app."""
28
+ app.add_typer(bundle_app, name="bundle")
29
+
30
+
31
+ def _parse_size(value: str) -> int:
32
+ """``2GiB`` / ``500MB`` / ``0`` → bytes. ``0`` disables the budget."""
33
+ text = value.strip().lower().replace(" ", "")
34
+ if not text:
35
+ return 0
36
+ units = (("gib", 1024**3), ("mib", 1024**2), ("kib", 1024), ("gb", 10**9), ("mb", 10**6), ("kb", 10**3), ("b", 1))
37
+ for suffix, factor in units:
38
+ if text.endswith(suffix):
39
+ number = text[: -len(suffix)]
40
+ try:
41
+ return int(float(number) * factor)
42
+ except ValueError:
43
+ break
44
+ try:
45
+ return int(text)
46
+ except ValueError:
47
+ raise typer.BadParameter(f"could not parse a size from {value!r} (try 2GiB, 500MB or 0)")
48
+
49
+
50
+ def _human(size: int) -> str:
51
+ for unit in ("B", "KiB", "MiB", "GiB"):
52
+ if size < 1024 or unit == "GiB":
53
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
54
+ size /= 1024.0
55
+ return f"{size:.1f} GiB"
56
+
57
+
58
+ def _print_summary(manifest, *, run_id_source: str) -> None:
59
+ from collections import Counter
60
+
61
+ run = manifest.run
62
+ console.print(f"\n[bold]Bundle[/bold] {run.run_id} [dim]({run_id_source})[/dim]")
63
+ console.print(f"[dim]steppy {run.steppy_version} · executor {run.executor} · created {run.created_at}[/dim]")
64
+ if run.git.sha:
65
+ branch = f" on {run.git.branch}" if run.git.branch else ""
66
+ pr = f" (PR #{run.git.pr})" if run.git.pr else ""
67
+ console.print(f"[dim]git {run.git.sha[:12]}{branch}{pr}[/dim]")
68
+
69
+ spec = run.spec
70
+ dimensions = []
71
+ if spec.locales:
72
+ dimensions.append(f"locales {', '.join(spec.locales)}")
73
+ if spec.layouts:
74
+ dimensions.append(f"layouts {', '.join(spec.layouts)}")
75
+ if spec.guide:
76
+ dimensions.append("guide mode")
77
+ if spec.coverage:
78
+ dimensions.append("coverage")
79
+ if dimensions:
80
+ console.print(f"[dim]{' · '.join(dimensions)}[/dim]")
81
+
82
+ if manifest.tests:
83
+ outcomes = Counter(t.outcome for t in manifest.tests)
84
+ parts = [f"{count} {name}" for name, count in sorted(outcomes.items())]
85
+ console.print(f"\n[bold]Tests[/bold] ({len(manifest.tests)}): {', '.join(parts)}")
86
+ else:
87
+ console.print("\n[bold]Tests[/bold]: none recorded")
88
+
89
+ kinds = Counter(f.kind for f in manifest.files)
90
+ total = sum(f.size for f in manifest.files)
91
+ console.print(f"[bold]Files[/bold] ({len(manifest.files)}, {_human(total)}):")
92
+ for kind, count in sorted(kinds.items()):
93
+ kind_size = sum(f.size for f in manifest.files if f.kind == kind)
94
+ console.print(f" [dim]{kind:<22}[/dim] {count:>5} {_human(kind_size)}")
95
+
96
+ if manifest.coverage and manifest.coverage.records:
97
+ console.print(f"[bold]Coverage[/bold]: {manifest.coverage.records} raw record(s)")
98
+ if manifest.truncated:
99
+ console.print("[yellow]TRUNCATED[/yellow] — some artifacts were dropped to fit the size budget")
100
+ for warning in manifest.warnings:
101
+ console.print(f"[yellow]WARNING[/yellow] {warning}")
102
+
103
+
104
+ @bundle_app.command("create")
105
+ def create(
106
+ output: Optional[Path] = typer.Option(
107
+ None,
108
+ "--output",
109
+ "-o",
110
+ help="Where to write the bundle. A directory gets a generated filename; "
111
+ "a path with an extension is used verbatim. "
112
+ "Default: output/bundles/steppy-bundle-<run-id>.tar.zst",
113
+ ),
114
+ run_id: Optional[str] = typer.Option(
115
+ None,
116
+ "--run-id",
117
+ help="Run identity for manifest.run.run_id. Defaults to the run recorded "
118
+ "in output/.steppy-run.json, else the newest recorded run on disk.",
119
+ ),
120
+ fmt: str = typer.Option(
121
+ "auto",
122
+ "--format",
123
+ help="Compression: auto (zstd when the steppy-dev[bundle] extra is installed, else gzip), zst, or gz.",
124
+ ),
125
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Project root."),
126
+ output_dir: Optional[Path] = typer.Option(
127
+ None, "--output-dir", help="The tree to archive. Default: <project-root>/output"
128
+ ),
129
+ executor: str = typer.Option("local", "--executor", help="What ran the tests: local, github-actions, agent, ..."),
130
+ git_sha: Optional[str] = typer.Option(None, "--git-sha", help="Override the detected commit sha."),
131
+ git_branch: Optional[str] = typer.Option(None, "--git-branch", help="Override the detected branch."),
132
+ pr: Optional[int] = typer.Option(None, "--pr", help="Pull-request number this run belongs to."),
133
+ guides: bool = typer.Option(True, "--guides/--no-guides", help="Include output/guides/."),
134
+ traces: bool = typer.Option(True, "--traces/--no-traces", help="Include output/traces/."),
135
+ videos: bool = typer.Option(True, "--videos/--no-videos", help="Include output/videos/."),
136
+ coverage: bool = typer.Option(True, "--coverage/--no-coverage", help="Include output/coverage/."),
137
+ max_size: str = typer.Option(
138
+ "0",
139
+ "--max-size",
140
+ help="Uncompressed payload budget, e.g. 2GiB. Over budget, the cheapest-to-lose "
141
+ "artifacts are dropped (videos, then traces, then guides) and the manifest is "
142
+ "marked truncated. 0 disables the budget.",
143
+ ),
144
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be archived; write nothing."),
145
+ as_json: bool = typer.Option(False, "--json", help="Print the manifest as JSON instead of a summary."),
146
+ ) -> None:
147
+ """Package a run's output/ tree into a results bundle.
148
+
149
+ The archive holds output/ exactly as steppy wrote it — no path rewriting —
150
+ so unpacking it gives a tree `steppy guide` and `steppy build` consume
151
+ unchanged. Alongside it sit a manifest.json describing every file, a merged
152
+ junit.xml, and the run's per-test event records.
153
+
154
+ Run your scenarios with `steppy run --bundle` first: that captures the event
155
+ records and JUnit XML a plain run does not produce. Without them the bundle
156
+ is still valid, but manifest.tests[] will be empty.
157
+ """
158
+ with _cli_errors():
159
+ from .bundle import BundleError, create_bundle
160
+ from .bundle.create import manifest_bytes
161
+
162
+ try:
163
+ result = create_bundle(
164
+ project_root=project_root,
165
+ output_dir=output_dir,
166
+ target=output,
167
+ run_id=run_id,
168
+ fmt=fmt,
169
+ executor=executor,
170
+ git_sha=git_sha,
171
+ git_branch=git_branch,
172
+ pr=pr,
173
+ guides=guides,
174
+ traces=traces,
175
+ videos=videos,
176
+ coverage=coverage,
177
+ max_size=_parse_size(max_size),
178
+ dry_run=dry_run,
179
+ )
180
+ except BundleError as e:
181
+ _error(str(e))
182
+ raise typer.Exit(1)
183
+
184
+ if as_json:
185
+ typer.echo(manifest_bytes(result.manifest).decode("utf-8"), nl=False)
186
+ raise typer.Exit(0)
187
+
188
+ _print_summary(result.manifest, run_id_source=result.run_id_source)
189
+ if dry_run:
190
+ console.print(f"\n[dim]Dry run — nothing written. Format would be: {result.fmt}[/dim]")
191
+ else:
192
+ ratio = f" ({100 * result.compressed_size / result.payload_size:.0f}%)" if result.payload_size else ""
193
+ console.print(
194
+ f"\n[green]OK[/green] {result.path} [dim]{_human(result.compressed_size)}{ratio}, {result.fmt}[/dim]"
195
+ )
196
+
197
+
198
+ def _config_cloud_url(project_root: Path) -> Optional[str]:
199
+ """``cloud.url`` from the project's config, or None.
200
+
201
+ A config that does not load is not an error here — the flag and the
202
+ environment variable both still work, and refusing to upload a finished run
203
+ because ``config.yaml`` no longer parses would be the same poor trade
204
+ ``resolve_roots`` already declines to make.
205
+ """
206
+ try:
207
+ from .config import load_config
208
+
209
+ config = load_config(project_root / "config.yaml")
210
+ except Exception:
211
+ return None
212
+ return config.cloud.url if config.cloud else None
213
+
214
+
215
+ @bundle_app.command("upload")
216
+ def upload(
217
+ bundle_path: Optional[Path] = typer.Argument(
218
+ None,
219
+ help="An existing .tar.zst/.tar.gz bundle to upload. Omit to upload straight "
220
+ "from output/ without writing an archive (the usual case).",
221
+ ),
222
+ url: Optional[str] = typer.Option(
223
+ None, "--url", help="Control plane base URL. Else $STEPPY_CLOUD_URL, else cloud.url."
224
+ ),
225
+ token: Optional[str] = typer.Option(
226
+ None, "--token", help="Project token. Else $STEPPY_CLOUD_TOKEN. There is no config key for it on purpose."
227
+ ),
228
+ attach: Optional[str] = typer.Option(
229
+ None,
230
+ "--attach",
231
+ help="Id of an existing run on the plane to upload into, instead of creating one. "
232
+ "What `steppy ci run` will use, where the plane created the run first.",
233
+ ),
234
+ run_id: Optional[str] = typer.Option(
235
+ None,
236
+ "--run-id",
237
+ help="Run identity for manifest.run.run_id, as in `bundle create`. Doubles as the "
238
+ "idempotency key, so re-uploading attaches to the same run rather than making a second.",
239
+ ),
240
+ project_root: Path = typer.Option(Path("."), "--project-root", help="Project root."),
241
+ output_dir: Optional[Path] = typer.Option(
242
+ None, "--output-dir", help="The tree to upload. Default: <project-root>/output"
243
+ ),
244
+ executor: str = typer.Option("local", "--executor", help="What ran the tests, recorded on a created run."),
245
+ exit_code: Optional[int] = typer.Option(
246
+ None, "--exit-code", help="The run's verdict. Default: derived from the manifest's test outcomes."
247
+ ),
248
+ git_sha: Optional[str] = typer.Option(None, "--git-sha", help="Override the detected commit sha."),
249
+ git_branch: Optional[str] = typer.Option(None, "--git-branch", help="Override the detected branch."),
250
+ pr: Optional[int] = typer.Option(None, "--pr", help="Pull-request number this run belongs to."),
251
+ guides: bool = typer.Option(True, "--guides/--no-guides", help="Include output/guides/."),
252
+ traces: bool = typer.Option(True, "--traces/--no-traces", help="Include output/traces/."),
253
+ videos: bool = typer.Option(True, "--videos/--no-videos", help="Include output/videos/."),
254
+ coverage: bool = typer.Option(True, "--coverage/--no-coverage", help="Include output/coverage/."),
255
+ max_size: str = typer.Option("0", "--max-size", help="Uncompressed payload budget, e.g. 2GiB. 0 disables it."),
256
+ concurrency: int = typer.Option(4, "--concurrency", min=1, help="Parallel uploads (ignored for an archive)."),
257
+ timeout: float = typer.Option(60.0, "--timeout", help="Per-request timeout, seconds."),
258
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be uploaded; contact nothing."),
259
+ as_json: bool = typer.Option(False, "--json", help="Print the outcome as JSON."),
260
+ ) -> None:
261
+ """Push a run's results to a Steppy control plane.
262
+
263
+ Uploads are content-addressed: the server is offered the manifest and hands
264
+ back URLs only for the files it does not already hold, so a re-run that
265
+ changed two screenshots uploads two screenshots. Interrupted? Run it again —
266
+ it attaches to the same run and sends only what is missing.
267
+
268
+ Run your scenarios with `steppy run --bundle` first. Two things depend on
269
+ it: the per-test event records, without which the plane gets an empty
270
+ tests[] and no failure detail; and the run identity in
271
+ output/.steppy-run.json, which is the idempotency key that makes a retried
272
+ upload attach rather than create a second run. Without a recorded identity
273
+ a fresh one is minted per invocation — pass --run-id to pin it, or --attach
274
+ to upload into a run that already exists.
275
+ """
276
+ with _cli_errors():
277
+ from .bundle import BundleError, plan_bundle
278
+ from .cloud import ArchiveSource, CloudError, TreeSource, cloud_client, derive_exit_code, upload_bundle
279
+
280
+ try:
281
+ if bundle_path is not None:
282
+ source = ArchiveSource(bundle_path)
283
+ else:
284
+ source = TreeSource(
285
+ plan_bundle(
286
+ project_root=project_root,
287
+ output_dir=output_dir,
288
+ run_id=run_id,
289
+ git_sha=git_sha,
290
+ git_branch=git_branch,
291
+ pr=pr,
292
+ guides=guides,
293
+ traces=traces,
294
+ videos=videos,
295
+ coverage=coverage,
296
+ max_size=_parse_size(max_size),
297
+ executor=executor,
298
+ )
299
+ )
300
+ except BundleError as e:
301
+ _error(str(e))
302
+ raise typer.Exit(1)
303
+
304
+ manifest = source.manifest
305
+ if dry_run:
306
+ _print_summary(manifest, run_id_source="planned")
307
+ console.print(
308
+ f"\n[dim]Dry run — nothing uploaded. {len(manifest.files)} file(s), "
309
+ f"{_human(sum(f.size for f in manifest.files))} before dedup; "
310
+ f"verdict would be exit {derive_exit_code(manifest) if exit_code is None else exit_code}.[/dim]"
311
+ )
312
+ raise typer.Exit(0)
313
+
314
+ try:
315
+ client = cloud_client(url=url, token=token, config_url=_config_cloud_url(project_root), timeout=timeout)
316
+ except CloudError as e:
317
+ _error(str(e))
318
+ raise typer.Exit(1)
319
+
320
+ with client:
321
+ try:
322
+ outcome = _run_upload(
323
+ upload_bundle,
324
+ client,
325
+ source,
326
+ run_id=attach,
327
+ executor=executor,
328
+ exit_code=exit_code,
329
+ concurrency=concurrency,
330
+ )
331
+ except CloudError as e:
332
+ _error(str(e))
333
+ raise typer.Exit(1)
334
+
335
+ if as_json:
336
+ typer.echo(_json.dumps(outcome.as_dict(), indent=2))
337
+ raise typer.Exit(0)
338
+
339
+ _print_upload_summary(outcome, client.endpoint.url)
340
+
341
+
342
+ def _run_upload(upload_bundle, client, source, *, run_id, executor, exit_code, concurrency):
343
+ """Drive the upload behind a byte-oriented progress bar.
344
+
345
+ Progress is measured in bytes rather than files: a bundle is a handful of
346
+ megabyte screenshots among hundreds of tiny sidecars, so a file counter
347
+ would sit at 90% for most of the wall clock.
348
+ """
349
+ from rich.progress import BarColumn, Progress, TextColumn, TimeElapsedColumn
350
+
351
+ total = sum(f.size for f in source.manifest.files)
352
+ with Progress(
353
+ TextColumn("[progress.description]{task.description}"),
354
+ BarColumn(),
355
+ TextColumn("{task.fields[sent]}"),
356
+ TimeElapsedColumn(),
357
+ console=console,
358
+ transient=True,
359
+ ) as progress:
360
+ bar = progress.add_task("uploading", total=total or 1, sent="")
361
+ sent = 0
362
+
363
+ def advance(blob) -> None:
364
+ nonlocal sent
365
+ sent += blob.size
366
+ progress.update(bar, completed=sent, sent=_human(sent))
367
+
368
+ return upload_bundle(
369
+ client,
370
+ source,
371
+ run_id=run_id,
372
+ executor=executor,
373
+ exit_code=exit_code,
374
+ concurrency=concurrency,
375
+ on_progress=advance,
376
+ )
377
+
378
+
379
+ def _print_upload_summary(outcome, base_url: str) -> None:
380
+ verb = "Created run" if outcome.created_run else "Uploaded to run"
381
+ console.print(f"\n[bold]{verb}[/bold] #{outcome.run_number} [dim]{outcome.run_id}[/dim]")
382
+
383
+ # Blobs, not files: uploads are content-addressed, so a screenshot that is
384
+ # byte-identical across two locales is one upload serving two manifest
385
+ # entries. Reporting "3 of 4 files" for that would be a lie in both numbers.
386
+ if outcome.uploaded:
387
+ console.print(
388
+ f"[bold]Uploaded[/bold] {outcome.uploaded} of {outcome.total_blobs} blob(s), "
389
+ f"{_human(outcome.uploaded_bytes)} [dim]({outcome.total_files} file(s))[/dim]"
390
+ )
391
+ else:
392
+ console.print("[bold]Uploaded[/bold] nothing — the server already held every blob in this run")
393
+ if outcome.skipped_bytes:
394
+ console.print(f"[dim]{_human(outcome.skipped_bytes)} not sent — already held (content-addressed dedup)[/dim]")
395
+
396
+ for warning in outcome.warnings:
397
+ console.print(f"[yellow]WARNING[/yellow] {warning}")
398
+
399
+ colour = {"completed": "green", "failed": "red", "error": "red", "expired": "red", "canceled": "yellow"}.get(
400
+ outcome.run_status, "yellow"
401
+ )
402
+ console.print(f"[bold]Status[/bold] [{colour}]{outcome.run_status}[/{colour}]")
403
+ console.print(f"[dim]{base_url}/runs/{outcome.run_id}[/dim]")
404
+
405
+
406
+ @bundle_app.command("inspect")
407
+ def inspect(
408
+ bundle_path: Path = typer.Argument(..., help="Path to a .tar.zst or .tar.gz bundle."),
409
+ as_json: bool = typer.Option(False, "--json", help="Print the raw manifest JSON."),
410
+ ) -> None:
411
+ """Print a bundle's manifest without extracting it."""
412
+ with _cli_errors():
413
+ from .bundle import BundleError, inspect_bundle
414
+ from .bundle.create import manifest_bytes
415
+
416
+ try:
417
+ manifest = inspect_bundle(bundle_path)
418
+ except BundleError as e:
419
+ _error(str(e))
420
+ raise typer.Exit(1)
421
+
422
+ if as_json:
423
+ typer.echo(manifest_bytes(manifest).decode("utf-8"), nl=False)
424
+ raise typer.Exit(0)
425
+ _print_summary(manifest, run_id_source="archived")
426
+
427
+
428
+ @bundle_app.command("verify")
429
+ def verify(
430
+ bundle_path: Path = typer.Argument(..., help="Path to a .tar.zst or .tar.gz bundle."),
431
+ as_json: bool = typer.Option(False, "--json", help="Print the problem list as JSON."),
432
+ ) -> None:
433
+ """Re-hash every archived file against the manifest.
434
+
435
+ Exits non-zero if anything is missing, extra, or corrupted.
436
+ """
437
+ with _cli_errors():
438
+ from .bundle import BundleError, verify_bundle
439
+
440
+ try:
441
+ manifest, problems = verify_bundle(bundle_path)
442
+ except BundleError as e:
443
+ _error(str(e))
444
+ raise typer.Exit(1)
445
+
446
+ if as_json:
447
+ typer.echo(_json.dumps({"ok": not problems, "problems": problems}, indent=2))
448
+ raise typer.Exit(1 if problems else 0)
449
+
450
+ if problems:
451
+ _error(f"{len(problems)} problem(s) in {bundle_path.name}:")
452
+ for problem in problems:
453
+ console.print(f" [red]·[/red] {problem}")
454
+ raise typer.Exit(1)
455
+ console.print(f"[green]OK[/green] {bundle_path.name}: {len(manifest.files)} file(s) match the manifest")
steppy/captures.py ADDED
@@ -0,0 +1,207 @@
1
+ """Capture-path grammar — the filename and directory shape of ``output/screenshots``.
2
+
3
+ The *write* side of this grammar lives in the generated conftest
4
+ (``_screenshot_folder`` / ``take_screenshot`` in ``templates/conftest.py.j2``):
5
+ a capture lands at
6
+
7
+ <screenshots_dir>/<slug>/[<locale>/][<layout>/]step-NN[-<frame>].(png|json)
8
+
9
+ with the locale segment appended before the layout segment, and either omitted
10
+ when that dimension is not parametrized.
11
+
12
+ Three consumers have to agree on that shape, and historically each answered it
13
+ independently (see :meth:`steppy.guide.GuideAssembler.resolve_capture_segments`
14
+ for what that cost): the guide read side, the build's copy/URL side, and — since
15
+ the results bundle — the *inverse* direction, turning a path found on disk back
16
+ into ``(scenario, locale, layout, step, frame)``.
17
+
18
+ This module is the one home for the grammar — including its *root*, which
19
+ :func:`resolve_output_dir` derives from ``config.output`` for every consumer
20
+ that used to hardcode ``output/screenshots`` of its own (FLAWS §4.6).
21
+
22
+ It is a **leaf**: stdlib only, no ``jinja2``/``markdown_it``/``pydantic``, so
23
+ the bundler can import it without dragging ``guide.py``'s rendering stack onto
24
+ the default CLI path. ``guide.py`` re-exports :func:`step_stem` and
25
+ :func:`_has_captures` so its own callers are unaffected.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import re
31
+ from dataclasses import dataclass
32
+ from pathlib import Path
33
+ from typing import Optional
34
+
35
+
36
+ #: ``step-07.png``, ``step-07.json``, ``step-07-b.png``. The step number is
37
+ #: written zero-padded to two digits but matched more loosely — a scenario with
38
+ #: 100+ steps must not silently stop being recognised.
39
+ STEP_FILE_RE = re.compile(r"^step-(\d{2,})(?:-([a-z]+))?\.(png|json)$")
40
+
41
+ #: Directory holding full-page screenshots of failed tests, keyed by a sanitised
42
+ #: pytest nodeid rather than by scenario/step. Written by the generated
43
+ #: conftest's ``pytest_runtest_makereport`` hook.
44
+ FAILURES_DIRNAME = "_failures"
45
+
46
+ #: Fallbacks for ``config.output.*``. The grammar above hangs off a *root*, and
47
+ #: that root is the last piece of it that used to be re-derived per consumer —
48
+ #: four independent copies of the same literal (FLAWS §4.6). These are the only
49
+ #: two left, and the generated conftest carries a third by necessity (it is
50
+ #: standalone rendered code and cannot import this module).
51
+ DEFAULT_SCREENSHOTS_DIR = "output/screenshots"
52
+ DEFAULT_GUIDES_DIR = "output/guides"
53
+ #: Canonical in-app tour tree (``<tours_dir>/<build-id>/``); see ``steppy.tours``.
54
+ DEFAULT_TOURS_DIR = "output/tours"
55
+
56
+
57
+ def resolve_output_dir(project_root: Path, configured: Optional[str], default: str) -> Path:
58
+ """Resolve a ``config.output.*`` value against the project root.
59
+
60
+ A relative value (``./output/screenshots``) lands under the project; an
61
+ absolute one wins outright. Both are plain ``Path.__truediv__`` semantics,
62
+ deliberately — the same rule the conftest applies to
63
+ ``coverage.records_dir``, so writer and readers cannot disagree.
64
+
65
+ An empty/absent value falls back to *default*, because ``CONFIG`` is raw
66
+ YAML on the writer side: an absent ``output:`` block yields ``None`` there
67
+ and pydantic's ``default_factory`` never runs.
68
+ """
69
+ return (Path(project_root) / (configured or default)).resolve()
70
+
71
+
72
+ def step_stem(step_index: int, frame: Optional[str] = None) -> str:
73
+ """Filename stem for a captured step: ``step-07`` or ``step-07-b``.
74
+
75
+ One definition shared by the read side (guide.py) and the build's URL
76
+ builders, so a change to the capture naming scheme can't half-land.
77
+ """
78
+ return f"step-{step_index:02d}" + (f"-{frame}" if frame else "")
79
+
80
+
81
+ def _has_captures(directory: Path) -> bool:
82
+ """True when *directory* actually holds screenshots.
83
+
84
+ The single definition of "this is a capture directory". Existence alone is
85
+ not enough: the generated conftest's ``_screenshot_folder`` mkdirs before
86
+ calling ``page.screenshot``, so an interrupted run leaves empty directories
87
+ behind, and a per-locale directory that only holds per-layout subdirs holds
88
+ no captures of its own.
89
+ """
90
+ return directory.is_dir() and any(directory.glob("step-*.png"))
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class StepFile:
95
+ """A parsed capture filename. The inverse of :func:`step_stem`."""
96
+
97
+ step: int
98
+ frame: Optional[str]
99
+ ext: str # "png" | "json"
100
+
101
+
102
+ def parse_step_stem(filename: str) -> Optional[StepFile]:
103
+ """Parse ``step-NN[-<frame>].(png|json)``. ``None`` when it isn't one.
104
+
105
+ Round-trips with :func:`step_stem`::
106
+
107
+ parse_step_stem(step_stem(7, "b") + ".png") == StepFile(7, "b", "png")
108
+ """
109
+ match = STEP_FILE_RE.match(filename)
110
+ if match is None:
111
+ return None
112
+ number, frame, ext = match.groups()
113
+ return StepFile(step=int(number), frame=frame, ext=ext)
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class CaptureRef:
118
+ """A capture file located in the screenshots tree."""
119
+
120
+ scenario: str
121
+ locale: Optional[str]
122
+ layout: Optional[str]
123
+ step: int
124
+ frame: Optional[str]
125
+ ext: str
126
+
127
+
128
+ def classify_capture_path(
129
+ rel_parts: tuple[str, ...] | list[str],
130
+ *,
131
+ locales: frozenset[str] | set[str] = frozenset(),
132
+ layouts: frozenset[str] | set[str] = frozenset(),
133
+ ) -> Optional[CaptureRef]:
134
+ """Invert the capture path shape. ``rel_parts`` is relative to the
135
+ screenshots dir, e.g. ``("order-lifecycle", "fr", "mobile", "step-01.png")``.
136
+
137
+ Returns ``None`` for anything that is not a step capture — including the
138
+ ``_failures/`` tree, whose filenames are sanitised pytest nodeids rather
139
+ than steps.
140
+
141
+ The middle segments are what need deciding, and only one case is genuinely
142
+ ambiguous:
143
+
144
+ * **0 segments** — flat/legacy capture, ``(None, None)``.
145
+ * **2 segments** — unambiguous. The writer appends locale then layout, in
146
+ that order, and never any other pair.
147
+ * **1 segment** — ``<slug>/fr/`` and ``<slug>/desktop/`` are structurally
148
+ identical, so the name is the only signal. Resolved by membership in the
149
+ configured sets; when it matches neither (a locale or layout removed
150
+ from ``config.yaml`` after the capture), it is read as a **locale**,
151
+ mirroring the "locale beats layout" precedence
152
+ :meth:`~steppy.guide.GuideAssembler.resolve_capture_segments` documents
153
+ — the locale determines the language actually visible in the image, and
154
+ mislabelling that is worse than mislabelling a viewport.
155
+ """
156
+ parts = tuple(rel_parts)
157
+ if len(parts) < 2 or parts[0] == FAILURES_DIRNAME:
158
+ return None
159
+
160
+ parsed = parse_step_stem(parts[-1])
161
+ if parsed is None:
162
+ return None
163
+
164
+ scenario = parts[0]
165
+ middle = parts[1:-1]
166
+
167
+ if len(middle) == 0:
168
+ locale, layout = None, None
169
+ elif len(middle) == 1:
170
+ segment = middle[0]
171
+ if segment in locales:
172
+ locale, layout = segment, None
173
+ elif segment in layouts:
174
+ locale, layout = None, segment
175
+ else:
176
+ locale, layout = segment, None
177
+ elif len(middle) == 2:
178
+ locale, layout = middle[0], middle[1]
179
+ else:
180
+ # Deeper than the writer can produce — not ours to interpret.
181
+ return None
182
+
183
+ return CaptureRef(
184
+ scenario=scenario,
185
+ locale=locale,
186
+ layout=layout,
187
+ step=parsed.step,
188
+ frame=parsed.frame,
189
+ ext=parsed.ext,
190
+ )
191
+
192
+
193
+ def sanitise_nodeid(nodeid: str) -> str:
194
+ """Make a pytest nodeid filesystem-safe. Mirror of ``_sanitise_nodeid``.
195
+
196
+ **Byte-mirrored** from ``_sanitise_nodeid`` in ``templates/conftest.py.j2``,
197
+ which names the failure screenshots, traces and videos. Guard:
198
+ ``tests/test_nodeid_sanitiser_mirror.py`` — same arrangement as
199
+ ``totp._totp_now`` (CLAUDE.md). Apply any change to both.
200
+
201
+ The mapping is **lossy and not invertible**: a slug containing ``_`` is
202
+ indistinguishable from a separator, and ``(fr-mobile)`` cannot be told from
203
+ a literal parenthesis in a test name. So consumers must build the *forward*
204
+ map — sanitise every nodeid they know about and look filenames up in it —
205
+ never attempt to reverse a filename.
206
+ """
207
+ return nodeid.replace("/", "_").replace("\\", "_").replace("::", "__").replace("[", "(").replace("]", ")")