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/cli.py ADDED
@@ -0,0 +1,2489 @@
1
+ """Steppy CLI - Main entry point."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, List, Optional
9
+
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.markup import escape
13
+ from rich.table import Table
14
+
15
+ from .tours_cli import prepare_tours_or_exit, run_tour_checks, tours_enabled, warn_opted_in_but_disabled
16
+ from ._cli_common import (
17
+ console,
18
+ set_verbose as _set_verbose,
19
+ _debug_enabled,
20
+ _error,
21
+ _report_unexpected,
22
+ _cli_errors,
23
+ _load_config_or_exit,
24
+ reject_xdist_args,
25
+ require_generated_tests,
26
+ DEFAULT_SEED_TIMEOUT,
27
+ )
28
+ from . import selection
29
+ from .captures import DEFAULT_GUIDES_DIR, DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
30
+ from .locales import LocaleSyntaxError, normalize_locale
31
+ from .config import load_config, ConfigError
32
+ from .parser import (
33
+ parse_scenario_file,
34
+ parse_scenarios_from_directory,
35
+ validate_scenario_roles,
36
+ validate_scenario_layouts,
37
+ lint_exclusive_groups,
38
+ lint_scenario_selectors,
39
+ ParseError,
40
+ )
41
+ from .generator import SteppyGenerator
42
+ from .runmeta import bundle_dirs, new_run_id, write_run_sidecar
43
+
44
+ # NOTE: `.guide` (jinja2 + markdown_it) and `.auth` (httpx + playwright) are
45
+ # deliberately NOT imported here. Between them they cost ~138ms of import time,
46
+ # which every `steppy` invocation would otherwise pay -- including `--help`,
47
+ # `validate`, `list` and `run`, none of which touch either module. They are
48
+ # imported inside the command bodies that need them, the same treatment
49
+ # `coordinator`/`_parallel`/`coverage` already get.
50
+ # Guard: tests/test_no_playwright_import_on_default_path.py
51
+
52
+ app = typer.Typer(
53
+ name="steppy",
54
+ help="Steppy - Generate Playwright tests from Markdown scenarios",
55
+ add_completion=False,
56
+ )
57
+
58
+
59
+ @app.callback()
60
+ def _main(
61
+ verbose: bool = typer.Option(
62
+ False,
63
+ "--verbose",
64
+ "-v",
65
+ help="Show full tracebacks on unexpected errors",
66
+ ),
67
+ ):
68
+ """Steppy - Generate Playwright tests from Markdown scenarios."""
69
+ _set_verbose(verbose)
70
+
71
+
72
+ @app.command()
73
+ def validate(
74
+ scenario: Optional[Path] = typer.Argument(
75
+ None,
76
+ help="Path to a specific scenario file to validate. If not provided, validates all scenarios.",
77
+ exists=True,
78
+ ),
79
+ scenarios_dir: Path = typer.Option(
80
+ Path("scenarios"),
81
+ "--scenarios-dir",
82
+ "-d",
83
+ help="Directory containing scenario files",
84
+ ),
85
+ ):
86
+ """
87
+ Validate scenario files without generating code.
88
+
89
+ Parses scenario files and checks for syntax and validation errors.
90
+ """
91
+ with _cli_errors():
92
+ config = _load_config_or_exit()
93
+ console.print("[green]OK[/green] Configuration loaded successfully")
94
+
95
+ configured_roles = set(config.apps.keys())
96
+
97
+ # Validate single scenario or all scenarios
98
+ if scenario:
99
+ console.print(f"\n[bold]Validating scenario:[/bold] {scenario}")
100
+ try:
101
+ parsed = parse_scenario_file(scenario)
102
+ except ParseError as e:
103
+ _error(f"Parse error: {e}")
104
+ raise typer.Exit(1)
105
+
106
+ role_errors = validate_scenario_roles(parsed, configured_roles)
107
+ layout_errors = validate_scenario_layouts(parsed, set(config.screenshot.layouts.keys()))
108
+ if role_errors or layout_errors:
109
+ console.print("[red]ERROR[/red] Validation failed:")
110
+ for err in role_errors + layout_errors:
111
+ console.print(f" {escape(str(err))}")
112
+ raise typer.Exit(1)
113
+
114
+ # Selector lint — warnings only, never affects the exit code.
115
+ for warn in lint_scenario_selectors(parsed):
116
+ console.print(f"[yellow]WARNING[/yellow] {escape(str(warn))}")
117
+
118
+ # In-app tours: compile checks for this scenario (silent unless it opts in).
119
+ if run_tour_checks(config, [parsed], scenarios_dir=parsed.file_path.parent):
120
+ raise typer.Exit(1)
121
+
122
+ console.print("[green]OK[/green] Scenario is valid")
123
+ console.print(f" Title: {parsed.title}")
124
+ console.print(f" Steps: {len(parsed.steps)}")
125
+ console.print(f" Tags: {', '.join(parsed.tags) if parsed.tags else 'none'}")
126
+ console.print(f" Roles: {', '.join(sorted(parsed.get_roles()))}")
127
+ else:
128
+ console.print(f"\n[bold]Validating all scenarios in:[/bold] {scenarios_dir}")
129
+
130
+ if not scenarios_dir.exists():
131
+ console.print(f"[red]ERROR[/red] Scenarios directory not found: {scenarios_dir}")
132
+ raise typer.Exit(1)
133
+
134
+ try:
135
+ scenarios = parse_scenarios_from_directory(scenarios_dir)
136
+ except ParseError as e:
137
+ _error(f"Parse error: {e}")
138
+ raise typer.Exit(1)
139
+
140
+ if not scenarios:
141
+ console.print("[yellow]WARNING[/yellow] No scenario files found")
142
+ raise typer.Exit(0)
143
+
144
+ configured_layouts = set(config.screenshot.layouts.keys())
145
+ had_errors = False
146
+ for s in scenarios:
147
+ role_errors = validate_scenario_roles(s, configured_roles)
148
+ layout_errors = validate_scenario_layouts(s, configured_layouts)
149
+ if role_errors or layout_errors:
150
+ had_errors = True
151
+ console.print(f"[red]ERROR[/red] {s.slug}: validation failed")
152
+ for err in role_errors + layout_errors:
153
+ console.print(f" {escape(str(err))}")
154
+ # Selector lint — warnings only, never affects the exit code.
155
+ for warn in lint_scenario_selectors(s):
156
+ console.print(f"[yellow]WARNING[/yellow] {s.slug}: {escape(str(warn))}")
157
+ # Cross-scenario lint — an exclusion group only means anything
158
+ # relative to the other scenarios, so this runs once over the corpus
159
+ # rather than per file. Warnings only, like the selector lint above.
160
+ for warn in lint_exclusive_groups(scenarios):
161
+ console.print(f"[yellow]WARNING[/yellow] {escape(str(warn))}")
162
+ # In-app tours: compile every opted-in scenario (silent when tours are off).
163
+ if run_tour_checks(config, scenarios, scenarios_dir=scenarios_dir):
164
+ had_errors = True
165
+ if had_errors:
166
+ raise typer.Exit(1)
167
+
168
+ console.print(f"[green]OK[/green] All {len(scenarios)} scenario(s) are valid\n")
169
+
170
+ # Show summary
171
+ for s in scenarios:
172
+ console.print(f" [green]OK[/green] {s.slug}: {s.title} ({len(s.steps)} steps)")
173
+
174
+ console.print("\n[green]OK[/green] Validation complete")
175
+
176
+
177
+ @app.command(name="list")
178
+ def list_scenarios(
179
+ scenarios_dir: Path = typer.Option(
180
+ Path("scenarios"),
181
+ "--scenarios-dir",
182
+ "-d",
183
+ help="Directory containing scenario files",
184
+ ),
185
+ tag: Optional[list[str]] = typer.Option(
186
+ None,
187
+ "--tag",
188
+ "-t",
189
+ help="Filter scenarios by tag (can be repeated, AND logic)",
190
+ ),
191
+ ):
192
+ """
193
+ List all scenarios with their metadata.
194
+
195
+ Shows title, tags, priority, and step count for each scenario.
196
+ """
197
+ with _cli_errors():
198
+ _load_config_or_exit()
199
+
200
+ # Parse scenarios
201
+ if not scenarios_dir.exists():
202
+ console.print(f"[red]ERROR[/red] Scenarios directory not found: {scenarios_dir}")
203
+ raise typer.Exit(1)
204
+
205
+ try:
206
+ scenarios = parse_scenarios_from_directory(scenarios_dir, tag_filter=tag)
207
+ except ParseError as e:
208
+ _error(f"Parse error: {e}")
209
+ raise typer.Exit(1)
210
+
211
+ if not scenarios:
212
+ if tag:
213
+ console.print(f"[yellow]WARNING[/yellow] No scenarios found with tag(s) '{', '.join(tag)}'")
214
+ else:
215
+ console.print("[yellow]WARNING[/yellow] No scenario files found")
216
+ raise typer.Exit(0)
217
+
218
+ # Create table
219
+ table = Table(title=f"Scenarios{f' (filtered by tags: {", ".join(tag)})' if tag else ''}", show_lines=False)
220
+ table.add_column("Slug", style="cyan", no_wrap=True)
221
+ table.add_column("Title", style="bold", max_width=40)
222
+ table.add_column("Priority", style="yellow", justify="center")
223
+ table.add_column("Tags", style="magenta", max_width=30)
224
+ table.add_column("Steps", justify="right", style="green")
225
+ table.add_column("Roles", style="blue")
226
+ table.add_column("Seed", style="dim", justify="center")
227
+ table.add_column("Guide", justify="center")
228
+
229
+ for scenario in scenarios:
230
+ priority = scenario.priority or "-"
231
+ tags_str = ", ".join(scenario.tags) if scenario.tags else "-"
232
+ roles_str = ", ".join(sorted(scenario.get_roles())) or "-"
233
+ if scenario.seed:
234
+ seed_str = (
235
+ f"{scenario.seed.script} ({scenario.seed.run})"
236
+ if scenario.seed.run != "before"
237
+ else scenario.seed.script
238
+ )
239
+ else:
240
+ seed_str = "-"
241
+
242
+ guide_str = "[green]✓[/green]" if scenario.guide.enabled else "-"
243
+
244
+ # Color-code priority
245
+ priority_display = priority
246
+ if priority == "critical":
247
+ priority_display = f"[red]{priority}[/red]"
248
+ elif priority == "high":
249
+ priority_display = f"[yellow]{priority}[/yellow]"
250
+ elif priority == "medium":
251
+ priority_display = f"[blue]{priority}[/blue]"
252
+ elif priority == "low":
253
+ priority_display = f"[dim]{priority}[/dim]"
254
+
255
+ table.add_row(
256
+ scenario.slug,
257
+ scenario.title,
258
+ priority_display,
259
+ tags_str,
260
+ str(len(scenario.steps)),
261
+ roles_str,
262
+ seed_str,
263
+ guide_str,
264
+ )
265
+
266
+ console.print()
267
+ console.print(table)
268
+ console.print()
269
+ console.print(f"[green]Total:[/green] {len(scenarios)} scenario(s)")
270
+
271
+
272
+ @app.command()
273
+ def generate(
274
+ scenario: Optional[Path] = typer.Argument(
275
+ None,
276
+ help="Path to a specific scenario file to generate. If not provided, generates all scenarios.",
277
+ ),
278
+ scenarios_dir: Path = typer.Option(
279
+ Path("scenarios"),
280
+ "--scenarios-dir",
281
+ "-d",
282
+ help="Directory containing scenario files",
283
+ ),
284
+ output_dir: Path = typer.Option(
285
+ Path("tests/e2e"),
286
+ "--output-dir",
287
+ "-o",
288
+ help="Output directory for generated test files",
289
+ ),
290
+ tag: Optional[list[str]] = typer.Option(
291
+ None,
292
+ "--tag",
293
+ "-t",
294
+ help="Only generate scenarios with these tags (can be repeated, AND logic)",
295
+ ),
296
+ force: bool = typer.Option(
297
+ False,
298
+ "--force",
299
+ "-f",
300
+ help="Overwrite existing files without checking for changes",
301
+ ),
302
+ dry_run: bool = typer.Option(
303
+ False,
304
+ "--dry-run",
305
+ help="Show what files would be created/updated without writing anything",
306
+ ),
307
+ ):
308
+ """
309
+ Generate pytest test files from scenario files.
310
+
311
+ Parses scenario .md files and generates Python test files in tests/e2e/.
312
+
313
+ By default, shows a summary and asks for confirmation if files would change.
314
+ Use --force to overwrite without asking, or --dry-run to preview changes.
315
+ """
316
+ with _cli_errors():
317
+ config = _load_config_or_exit()
318
+ console.print("[green]OK[/green] Configuration loaded")
319
+
320
+ # Parse scenarios
321
+ scenarios = []
322
+ if scenario:
323
+ # Single scenario
324
+ console.print(f"\n[bold]Generating from:[/bold] {scenario}")
325
+ if not scenario.exists():
326
+ console.print(f"[red]ERROR[/red] Scenario file not found: {scenario}")
327
+ raise typer.Exit(1)
328
+
329
+ try:
330
+ parsed = parse_scenario_file(scenario)
331
+ scenarios = [parsed]
332
+ except ParseError as e:
333
+ _error(f"Parse error: {e}")
334
+ raise typer.Exit(1)
335
+ else:
336
+ # All scenarios (with optional tag filter)
337
+ if tag:
338
+ console.print(f"\n[bold]Generating scenarios with tag(s):[/bold] {', '.join(tag)}")
339
+ else:
340
+ console.print(f"\n[bold]Generating all scenarios from:[/bold] {scenarios_dir}")
341
+
342
+ if not scenarios_dir.exists():
343
+ console.print(f"[red]ERROR[/red] Scenarios directory not found: {scenarios_dir}")
344
+ raise typer.Exit(1)
345
+
346
+ try:
347
+ scenarios = parse_scenarios_from_directory(scenarios_dir, tag_filter=tag)
348
+ except ParseError as e:
349
+ _error(f"Parse error: {e}")
350
+ raise typer.Exit(1)
351
+
352
+ if not scenarios:
353
+ console.print("[yellow]WARNING[/yellow] No scenarios to generate")
354
+ raise typer.Exit(0)
355
+
356
+ # Validate role and layout references against configured values
357
+ configured_roles = set(config.apps.keys())
358
+ configured_layouts = set(config.screenshot.layouts.keys())
359
+ had_errors = False
360
+ for s in scenarios:
361
+ role_errors = validate_scenario_roles(s, configured_roles)
362
+ layout_errors = validate_scenario_layouts(s, configured_layouts)
363
+ if role_errors or layout_errors:
364
+ had_errors = True
365
+ console.print(f"[red]ERROR[/red] {s.slug}: validation failed")
366
+ for err in role_errors + layout_errors:
367
+ console.print(f" {escape(str(err))}")
368
+ if had_errors:
369
+ raise typer.Exit(1)
370
+
371
+ # In-app tours: compiled from EVERY scenario in the directory (a manifest
372
+ # must not depend on CLI filters), before anything is written.
373
+ tour_pipeline = None
374
+ if tours_enabled(config):
375
+ tour_pipeline = prepare_tours_or_exit(
376
+ config,
377
+ scenarios_dir=scenarios_dir,
378
+ selected=scenarios,
379
+ all_scenarios=None if (scenario or tag) else scenarios,
380
+ mode="generate",
381
+ )
382
+ else:
383
+ warn_opted_in_but_disabled(config, scenarios)
384
+ tour_plans = tour_pipeline.replay_plans if tour_pipeline is not None else None
385
+
386
+ # Generate code
387
+ if dry_run:
388
+ console.print(f"\n[bold cyan]DRY RUN:[/bold cyan] Showing what would be generated to {output_dir}\n")
389
+ else:
390
+ console.print(f"\n[bold]Generating {len(scenarios)} test file(s) to:[/bold] {output_dir}\n")
391
+
392
+ try:
393
+ generator = SteppyGenerator(config)
394
+
395
+ if dry_run:
396
+ # Dry run: generate code but don't write files
397
+ results = {}
398
+
399
+ # Check conftest.py
400
+ conftest_path = output_dir / "conftest.py"
401
+ conftest_code = generator.generate_conftest(scenarios)
402
+ if conftest_path.exists():
403
+ existing_content = conftest_path.read_text(encoding="utf-8")
404
+ if existing_content == conftest_code:
405
+ results[str(conftest_path)] = "unchanged"
406
+ else:
407
+ results[str(conftest_path)] = "updated"
408
+ else:
409
+ results[str(conftest_path)] = "generated"
410
+
411
+ if tour_pipeline is not None:
412
+ helper_path = output_dir / "_steppy_tours.py"
413
+ helper_code = generator.tours_helper_source()
414
+ if helper_path.exists():
415
+ same = helper_path.read_text(encoding="utf-8") == helper_code
416
+ results[str(helper_path)] = "unchanged" if same else "updated"
417
+ else:
418
+ results[str(helper_path)] = "generated"
419
+
420
+ # Check each scenario test file
421
+ for scenario_obj in scenarios:
422
+ filename = f"test_{scenario_obj.slug}.py"
423
+ test_path = output_dir / filename
424
+ test_code = generator.generate_test_file(
425
+ scenario_obj, tour_plan=tour_plans.get(scenario_obj.slug) if tour_plans else None
426
+ )
427
+
428
+ if test_path.exists():
429
+ existing_content = test_path.read_text(encoding="utf-8")
430
+ if existing_content == test_code:
431
+ results[str(test_path)] = "unchanged"
432
+ else:
433
+ results[str(test_path)] = "updated"
434
+ else:
435
+ results[str(test_path)] = "generated"
436
+
437
+ else:
438
+ # Normal mode: generate and write files
439
+ results = generator.generate_all(scenarios, output_dir, force=force, tour_plans=tour_plans)
440
+
441
+ if tour_pipeline is not None:
442
+ results.update(tour_pipeline.emit(dry_run=dry_run))
443
+
444
+ except typer.Exit:
445
+ raise
446
+ except Exception as e:
447
+ _error(f"Generation failed: {e}")
448
+ if _debug_enabled():
449
+ import traceback
450
+
451
+ traceback.print_exc()
452
+ raise typer.Exit(1)
453
+
454
+ # Display results
455
+ generated_count = sum(1 for status in results.values() if status == "generated")
456
+ updated_count = sum(1 for status in results.values() if status == "updated")
457
+ unchanged_count = sum(1 for status in results.values() if status == "unchanged")
458
+ removed_count = sum(1 for status in results.values() if status == "removed")
459
+ removed_note = f", {removed_count} removed" if removed_count else ""
460
+
461
+ for file_path, status in results.items():
462
+ relative_path = (
463
+ Path(file_path).relative_to(Path.cwd()) if Path.cwd() in Path(file_path).parents else file_path
464
+ )
465
+ if status == "generated":
466
+ console.print(f" [green]+ GENERATED[/green] {relative_path}")
467
+ elif status == "updated":
468
+ console.print(f" [yellow]~ UPDATED[/yellow] {relative_path}")
469
+ elif status == "unchanged":
470
+ console.print(f" [dim]= UNCHANGED[/dim] {relative_path}")
471
+ elif status == "removed":
472
+ console.print(f" [red]- REMOVED[/red] {relative_path}")
473
+
474
+ console.print()
475
+ if dry_run:
476
+ console.print(
477
+ f"[cyan]Dry run summary:[/cyan] {generated_count} would be generated, {updated_count} would be updated, {unchanged_count} unchanged{removed_note}"
478
+ )
479
+ console.print("\n[cyan]INFO[/cyan] No files were written (dry run mode)")
480
+ else:
481
+ console.print(
482
+ f"[green]Summary:[/green] {generated_count} generated, {updated_count} updated, {unchanged_count} unchanged{removed_note}"
483
+ )
484
+ console.print("\n[green]OK[/green] Generation complete")
485
+
486
+
487
+ def _resolve_locale_layout_lists(
488
+ locales: Optional[str],
489
+ layouts: Optional[str],
490
+ guide: bool,
491
+ console: Console,
492
+ ) -> tuple[list[str], list[str]]:
493
+ """Resolve the locale + layout parametrization lists for a run/rerun.
494
+
495
+ Explicit --locales/--layouts win; else --guide pulls from config.yaml
496
+ (config.locales / config.screenshot.layouts); else empty (no
497
+ parametrization). Shared by `run` and `rerun` so the two paths can't drift.
498
+
499
+ The list-building rule lives in `selection.resolve_locales/resolve_layouts`
500
+ (shared with the parallel path, FLAWS §8.3); this wrapper adds cli's own
501
+ config-load + `ConfigError`-degrade behavior — a guide-mode run whose config
502
+ won't load warns once and falls back to empty parametrization rather than
503
+ failing.
504
+ """
505
+ # Only guide mode without an explicit flag needs the config defaults; load it
506
+ # once and degrade to None (→ empty lists) on error, warning a single time.
507
+ steppy_config = None
508
+ if guide and (not locales or not layouts):
509
+ try:
510
+ steppy_config = load_config()
511
+ except ConfigError as e:
512
+ console.print(f"[yellow]WARNING[/yellow] Could not load config for locale list: {e}")
513
+ steppy_config = None
514
+
515
+ try:
516
+ locale_list = selection.resolve_locales(locales, guide, steppy_config)
517
+ layout_list = selection.resolve_layouts(layouts, guide, steppy_config)
518
+ except selection.SelectionError as e:
519
+ _error(e.message)
520
+ raise typer.Exit(1)
521
+
522
+ return locale_list, layout_list
523
+
524
+
525
+ def _select_guide_test_files(
526
+ guide: bool,
527
+ no_guide: bool,
528
+ scenarios_dir: Path,
529
+ tests_dir: Path,
530
+ console: Console,
531
+ ) -> list[str]:
532
+ """Return the `test_<slug>.py` paths for guide (or non-guide) scenarios.
533
+
534
+ The scenario-set decision (parse + guide filter) comes from the shared
535
+ `selection.select_scenario_slugs` (FLAWS §8.3); this wrapper keeps cli's
536
+ projection to `test_<slug>.py` paths and its INFO/Exit(0)-on-empty messaging.
537
+ Shared by `run`'s guide/no_guide branches and `rerun --guide`.
538
+ """
539
+ try:
540
+ slugs = selection.select_scenario_slugs(
541
+ scenarios_dir=scenarios_dir,
542
+ tests_dir=tests_dir,
543
+ scenario=None,
544
+ tag=None,
545
+ guide=guide,
546
+ no_guide=no_guide,
547
+ )
548
+ except selection.SelectionError as e:
549
+ _error(e.message)
550
+ raise typer.Exit(1)
551
+
552
+ if guide:
553
+ if not slugs:
554
+ console.print("[yellow]INFO[/yellow] No scenarios found with guide.enabled=true")
555
+ raise typer.Exit(0)
556
+ console.print(f"\n[bold]Running {len(slugs)} guide scenario(s):[/bold] {', '.join(slugs)}")
557
+ else:
558
+ if not slugs:
559
+ console.print("[yellow]INFO[/yellow] No non-guide scenarios found (all scenarios have guide.enabled=true)")
560
+ raise typer.Exit(0)
561
+ console.print(f"\n[bold]Running {len(slugs)} non-guide scenario(s):[/bold] {', '.join(slugs)}")
562
+
563
+ return [str(tests_dir / f"test_{slug}.py") for slug in slugs]
564
+
565
+
566
+ def _build_run_env(
567
+ headed: bool,
568
+ screenshots: bool,
569
+ locale_list: list[str],
570
+ layout_list: list[str],
571
+ guide: bool,
572
+ no_asserts: bool,
573
+ console: Console,
574
+ coverage: bool = False,
575
+ coverage_run_id: Optional[str] = None,
576
+ trace_mode: str = "off",
577
+ video_mode: str = "off",
578
+ run_id: Optional[str] = None,
579
+ events_dir: Optional[Path] = None,
580
+ ) -> dict:
581
+ """Construct the environment for the pytest subprocess.
582
+
583
+ Mirrors the env-construction the single-worker `run` path has always used,
584
+ including its console output. Shared by `run` and `rerun` so the guide
585
+ parametrization env (STEPPY_GUIDE_MODE / STEPPY_LOCALES / STEPPY_LAYOUTS /
586
+ STEPPY_NO_ASSERTS) is built identically by both. Every optional kwarg is
587
+ defaulted so existing call sites (and the byte-identical env guard for
588
+ plain runs) are untouched: each var appears only when its feature was asked
589
+ for — STEPPY_COVERAGE with --coverage, STEPPY_EVENTS_DIR with --bundle, and
590
+ so on.
591
+
592
+ ``run_id`` supersedes ``coverage_run_id``: one run has one identity, shared
593
+ by the coverage records and the results bundle. ``coverage_run_id`` is kept
594
+ for the existing coverage-only call shape.
595
+ """
596
+ env = os.environ.copy()
597
+ if headed:
598
+ env["HEADED"] = "1"
599
+ console.print("[dim]Browser mode: headed (visible)[/dim]")
600
+ else:
601
+ console.print("[dim]Browser mode: headless[/dim]")
602
+
603
+ if screenshots:
604
+ env["STEPPY_SCREENSHOTS"] = "1"
605
+ console.print("[dim]Screenshots: enabled[/dim]")
606
+ else:
607
+ console.print("[dim]Screenshots: disabled[/dim]")
608
+
609
+ if locale_list:
610
+ env["STEPPY_LOCALES"] = ",".join(locale_list)
611
+ console.print(f"[dim]Locales: {', '.join(locale_list)} (per-locale screenshot capture)[/dim]")
612
+ else:
613
+ console.print("[dim]Locales: default (no parametrization)[/dim]")
614
+
615
+ if layout_list:
616
+ env["STEPPY_LAYOUTS"] = ",".join(layout_list)
617
+ console.print(f"[dim]Layouts: {', '.join(layout_list)} (per-layout screenshot capture)[/dim]")
618
+ else:
619
+ console.print("[dim]Layouts: default (no parametrization)[/dim]")
620
+
621
+ if guide:
622
+ env["STEPPY_GUIDE_MODE"] = "1"
623
+
624
+ if no_asserts:
625
+ env["STEPPY_NO_ASSERTS"] = "1"
626
+ console.print("[dim]Assertions: skipped[/dim]")
627
+ else:
628
+ console.print("[dim]Assertions: enabled[/dim]")
629
+
630
+ effective_run_id = run_id or coverage_run_id
631
+ if coverage:
632
+ env["STEPPY_COVERAGE"] = "1"
633
+ if effective_run_id:
634
+ env["STEPPY_RUN_ID"] = effective_run_id
635
+ console.print(f"[dim]Coverage recording: enabled (run {effective_run_id})[/dim]")
636
+ elif effective_run_id:
637
+ env["STEPPY_RUN_ID"] = effective_run_id
638
+
639
+ if events_dir is not None:
640
+ env["STEPPY_EVENTS_DIR"] = str(events_dir)
641
+ console.print(f"[dim]Event records: {events_dir}[/dim]")
642
+ # Per-step markers, so a bundle can name the step a test died in rather
643
+ # than only the test. On by default with the event records because
644
+ # `manifest.tests[].failed_step` is a bundle field and this is the flag
645
+ # that captures what a bundle needs. The escape hatch is the env var
646
+ # itself — exporting STEPPY_STEP_EVENTS=0 wins, for a suite where two
647
+ # extra file appends per step are unwelcome.
648
+ if os.environ.get("STEPPY_STEP_EVENTS") == "0":
649
+ console.print("[dim]Step records: disabled (STEPPY_STEP_EVENTS=0)[/dim]")
650
+ else:
651
+ env["STEPPY_STEP_EVENTS"] = "1"
652
+
653
+ if trace_mode and trace_mode != "off":
654
+ env["STEPPY_TRACE"] = trace_mode
655
+ console.print(f"[dim]Playwright trace: {trace_mode}[/dim]")
656
+
657
+ if video_mode and video_mode != "off":
658
+ env["STEPPY_VIDEO"] = video_mode
659
+ console.print(f"[dim]Playwright video: {video_mode}[/dim]")
660
+
661
+ return env
662
+
663
+
664
+ def _apply_bundle_junit_arg(pytest_cmd: list, junit_dir: Path, console: Console) -> None:
665
+ """Append ``--junitxml`` at the fixed path a results bundle reads.
666
+
667
+ Skipped when ``--junitxml`` is already on the argv — ``report.junit_xml``
668
+ got there first (``_apply_report_args``), and pytest honours only the LAST
669
+ one, so appending a second would silently discard the user's configured
670
+ report. The bundler falls back to that path in the same situation.
671
+ """
672
+ if any(str(arg).startswith("--junitxml") for arg in pytest_cmd):
673
+ console.print("[dim]JUnit report: reusing report.junit_xml for the bundle[/dim]")
674
+ return
675
+ try:
676
+ junit_dir.mkdir(parents=True, exist_ok=True)
677
+ except OSError as e:
678
+ console.print(f"[yellow]WARNING[/yellow] Could not create {junit_dir}: {e}; bundle will have no junit.xml")
679
+ return
680
+ path = junit_dir / "run.xml"
681
+ pytest_cmd.append(f"--junitxml={path}")
682
+ console.print(f"[dim]JUnit report: {path}[/dim]")
683
+
684
+
685
+ def _apply_report_args(pytest_cmd: list, report_cfg, console: Console) -> None:
686
+ """Append JUnit / HTML report flags to the pytest command (FLAWS §6.1).
687
+
688
+ JUnit is built into pytest (no plugin). HTML needs pytest-html — when the
689
+ plugin is missing we warn and skip it (never fail the run), pointing at the
690
+ ``steppy-dev[report]`` extra. Parent dirs are created so pytest can write.
691
+ """
692
+ if report_cfg.junit_xml:
693
+ path = Path(report_cfg.junit_xml)
694
+ path.parent.mkdir(parents=True, exist_ok=True)
695
+ pytest_cmd.append(f"--junitxml={path}")
696
+ console.print(f"[dim]JUnit report: {path}[/dim]")
697
+ if report_cfg.html:
698
+ try:
699
+ import importlib.util
700
+
701
+ if importlib.util.find_spec("pytest_html") is None:
702
+ raise ImportError
703
+ except ImportError:
704
+ console.print(
705
+ "[yellow]WARNING[/yellow] report.html is set but pytest-html is not "
706
+ "installed; skipping the HTML report. Install it with: pip install 'steppy-dev\\[report]'"
707
+ )
708
+ else:
709
+ path = Path(report_cfg.html)
710
+ path.parent.mkdir(parents=True, exist_ok=True)
711
+ pytest_cmd.append(f"--html={path}")
712
+ pytest_cmd.append("--self-contained-html")
713
+ console.print(f"[dim]HTML report: {path}[/dim]")
714
+ # Flaky-retry needs pytest-rerunfailures (the `steppy-dev[retry]` extra); warn
715
+ # and skip when it is missing rather than failing the run (mirrors HTML).
716
+ if getattr(report_cfg, "retries", 0) and report_cfg.retries > 0:
717
+ try:
718
+ import importlib.util
719
+
720
+ if importlib.util.find_spec("pytest_rerunfailures") is None:
721
+ raise ImportError
722
+ except ImportError:
723
+ console.print(
724
+ "[yellow]WARNING[/yellow] report.retries is set but pytest-rerunfailures is "
725
+ "not installed; skipping retries. Install it with: pip install 'steppy-dev\\[retry]'"
726
+ )
727
+ else:
728
+ pytest_cmd.append(f"--reruns={report_cfg.retries}")
729
+ console.print(f"[dim]Flaky-retry: up to {report_cfg.retries} rerun(s) per failure[/dim]")
730
+
731
+
732
+ def _resolve_report_modes(
733
+ *,
734
+ trace: bool,
735
+ video: bool,
736
+ retries: Optional[int],
737
+ ) -> tuple[str, str, int, Optional[Any]]:
738
+ """Resolve the effective trace / video / retries settings WITHOUT touching
739
+ any pytest argv (FLAWS §6.1).
740
+
741
+ The CLI flag wins, otherwise ``config.report`` decides. Best-effort config
742
+ load: a run without config.yaml resolves to the all-off defaults, which is
743
+ what keeps the plain single-worker path byte-identical
744
+ (tests/test_run_byte_identical.py).
745
+
746
+ Split out of :func:`_apply_report_and_modes` so the **parallel** dispatch
747
+ can reach the same three values — it has no host-side ``pytest_cmd`` to
748
+ append to; the settings travel to the worker containers as env vars
749
+ instead (``STEPPY_TRACE`` / ``STEPPY_VIDEO`` / ``STEPPY_RETRIES``).
750
+
751
+ Returns ``(trace_mode, video_mode, retries, report_cfg)``. ``report_cfg`` is
752
+ None when no config could be loaded.
753
+ """
754
+ trace_mode = "on-failure" if trace else "off"
755
+ video_mode = "on-failure" if video else "off"
756
+ try:
757
+ report_cfg = load_config().report
758
+ except ConfigError:
759
+ return trace_mode, video_mode, retries or 0, None
760
+ if retries is not None:
761
+ report_cfg = report_cfg.model_copy(update={"retries": retries})
762
+ if not trace:
763
+ trace_mode = report_cfg.trace
764
+ if not video:
765
+ video_mode = report_cfg.video
766
+ return trace_mode, video_mode, report_cfg.retries, report_cfg
767
+
768
+
769
+ def _apply_report_and_modes(
770
+ pytest_cmd: list,
771
+ *,
772
+ trace: bool,
773
+ video: bool,
774
+ retries: Optional[int],
775
+ console: Console,
776
+ ) -> tuple[str, str]:
777
+ """Append report flags and resolve the trace/video modes (FLAWS §6.1).
778
+
779
+ Shared verbatim by ``run`` and ``rerun``. Thin wrapper over
780
+ :func:`_resolve_report_modes` — the resolution rules live there.
781
+
782
+ Returns ``(trace_mode, video_mode)``.
783
+ """
784
+ trace_mode, video_mode, _retries, report_cfg = _resolve_report_modes(trace=trace, video=video, retries=retries)
785
+ if report_cfg is not None:
786
+ _apply_report_args(pytest_cmd, report_cfg, console)
787
+ return trace_mode, video_mode
788
+
789
+
790
+ def _new_coverage_run_id() -> str:
791
+ """Back-compat alias for :func:`steppy.runmeta.new_run_id`.
792
+
793
+ The generator moved to the ``runmeta`` leaf when ``--bundle`` gave a run an
794
+ identity independent of ``--coverage``; the name is kept because it is the
795
+ one that appears in older docs and issue reports.
796
+ """
797
+ return new_run_id()
798
+
799
+
800
+ def _merge_coverage_after_run(run_id: str, console: Console) -> None:
801
+ """Fold the just-recorded run's raw records into the persistent map store.
802
+
803
+ Lazy import on purpose: ``steppy run`` without --coverage must never load
804
+ steppy.coverage (tests/test_no_coverage_import_on_default_path.py). A
805
+ merge failure warns but never masks pytest's exit code — the raw records
806
+ stay on disk and `steppy coverage merge --run-id` retries the merge.
807
+ """
808
+ try:
809
+ from .coverage.merge import CoverageSettings, merge_run
810
+
811
+ settings = CoverageSettings.from_config(load_config(), Path.cwd())
812
+ summary = merge_run(settings, run_id)
813
+ console.print(
814
+ f"[green]OK[/green] Coverage: merged {summary['totals']['scenarios']} scenario(s), "
815
+ f"{summary['totals']['nodes']} map node(s) → {settings.store_path}"
816
+ )
817
+ for warning in summary.get("warnings", []):
818
+ console.print(f"[yellow]WARNING[/yellow] {warning}")
819
+ _write_coverage_gaps(settings, console)
820
+ console.print(
821
+ "[dim]Gap report: steppy coverage report (refresh the declared surface "
822
+ "first with: steppy coverage refresh)[/dim]"
823
+ )
824
+ except Exception as e:
825
+ console.print(f"[yellow]WARNING[/yellow] Coverage merge failed: {e}")
826
+ console.print(f"[dim]Raw records kept — retry with: steppy coverage merge --run-id {run_id}[/dim]")
827
+
828
+
829
+ def _write_coverage_gaps(settings, console: Console) -> None:
830
+ """Drop the gap report beside the raw records, for whoever reads them later.
831
+
832
+ The sqlite store lives outside ``output/`` on purpose — cleanup must not wipe
833
+ manual triage flags — which also means it can never be inside a results
834
+ bundle. So a bundle downloaded from CI has the raw records but no way to
835
+ answer "how much of the declared surface does this suite reach?". This file
836
+ is what crosses that gap, and writing it here means
837
+ ``steppy run --coverage --bundle`` is self-sufficient rather than needing an
838
+ extra incantation in every CI config.
839
+
840
+ Best-effort, and quiet about it: the merge already succeeded by this point, so
841
+ a failure to write a derived convenience file must not look like a failed
842
+ merge. ``steppy coverage report --json`` regenerates it on demand.
843
+ """
844
+ try:
845
+ from .coverage.gaps import compute_gaps
846
+ from .coverage.report import gaps_to_json
847
+ from .coverage.store import open_store, read_map
848
+ from .coverage.triage import apply_config_triage
849
+
850
+ conn = open_store(settings.store_path)
851
+ try:
852
+ gaps = compute_gaps(apply_config_triage(read_map(conn), settings.non_ui))
853
+ finally:
854
+ conn.close()
855
+ target = Path(settings.records_dir) / "gaps.json"
856
+ target.parent.mkdir(parents=True, exist_ok=True)
857
+ target.write_text(gaps_to_json(gaps), encoding="utf-8")
858
+ except Exception as e:
859
+ console.print(f"[dim]Coverage gap report not written ({e}); run `steppy coverage report --json` for it.[/dim]")
860
+
861
+
862
+ @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
863
+ def run(
864
+ ctx: typer.Context,
865
+ scenario: Optional[str] = typer.Option(
866
+ None,
867
+ "--scenario",
868
+ "-s",
869
+ help="Run a specific scenario by slug (e.g., 'cross--order-lifecycle')",
870
+ ),
871
+ tag: Optional[list[str]] = typer.Option(
872
+ None,
873
+ "--tag",
874
+ "-t",
875
+ help="Run only scenarios with these tags (can be repeated, AND logic)",
876
+ ),
877
+ headed: bool = typer.Option(
878
+ False,
879
+ "--headed",
880
+ help="Run with visible browser (headed mode)",
881
+ ),
882
+ screenshots: bool = typer.Option(
883
+ False,
884
+ "--screenshots",
885
+ help="Enable screenshot capture during test run",
886
+ ),
887
+ guide: bool = typer.Option(
888
+ False,
889
+ "--guide",
890
+ help="Run only scenarios marked with guide.enabled=true (auto-enables --screenshots)",
891
+ ),
892
+ no_asserts: bool = typer.Option(
893
+ False,
894
+ "--no-asserts",
895
+ help="Skip assertions at runtime (auto-enabled by --guide; useful for multi-locale runs where text asserts can't match every language)",
896
+ ),
897
+ no_guide: bool = typer.Option(
898
+ False,
899
+ "--no-guide",
900
+ help="Run only scenarios NOT marked with guide.enabled=true (excludes guide scenarios)",
901
+ ),
902
+ locales: Optional[str] = typer.Option(
903
+ None,
904
+ "--locales",
905
+ help="Comma-separated locale codes to run tests against (e.g. 'fr,nl'). Each locale "
906
+ "parametrizes the role page fixtures and applies the configured i18n.localstorage value "
907
+ "before pages load. With --guide and no explicit value, defaults to all locales from config.yaml.",
908
+ ),
909
+ layouts: Optional[str] = typer.Option(
910
+ None,
911
+ "--layouts",
912
+ help="Comma-separated layout names to parametrize over (e.g. 'desktop,mobile'). "
913
+ "Each layout sets the role page fixture's viewport from config.screenshot.layouts. "
914
+ "Works in both test and guide modes — use it on plain test runs to verify scenarios "
915
+ "across device viewports. With --guide and no explicit value, defaults to all "
916
+ "layouts declared in config.yaml.",
917
+ ),
918
+ tests_dir: Path = typer.Option(
919
+ Path("tests/e2e"),
920
+ "--tests-dir",
921
+ help="Directory containing generated test files",
922
+ ),
923
+ scenarios_dir: Path = typer.Option(
924
+ Path("scenarios"),
925
+ "--scenarios-dir",
926
+ "-d",
927
+ help="Directory containing scenario files (used for tag filtering)",
928
+ ),
929
+ workers: Optional[int] = typer.Option(
930
+ None,
931
+ "--workers",
932
+ min=1,
933
+ help="Spin up N Docker Compose stacks in parallel (each running a "
934
+ "steppy worker container). Requires `parallel:` block + per-app "
935
+ "`internal_url` in config.yaml. N=1 (or unset) uses the existing "
936
+ "single-process path with no Docker involvement. N>=2 dispatches to "
937
+ "the parallel coordinator. Default comes from config.yaml's "
938
+ "parallel.workers (1 if absent).",
939
+ ),
940
+ no_build: bool = typer.Option(
941
+ False,
942
+ "--no-build",
943
+ help="Skip auto-build of the steppy-worker image (parallel mode only). "
944
+ "Fails fast if the image is missing instead of building it.",
945
+ ),
946
+ verbose: bool = typer.Option(
947
+ False,
948
+ "--verbose",
949
+ "-v",
950
+ help="Parallel mode only: stream raw pytest output (prefixed with [wN]) under "
951
+ "the live progress display. Ignored by single-worker runs, whose output is "
952
+ "already streamed directly to the terminal.",
953
+ ),
954
+ coverage: bool = typer.Option(
955
+ False,
956
+ "--coverage",
957
+ help="Record per-scenario coverage signals (route trail, API requests, JS "
958
+ "coverage) and merge them into the coverage map after the run. Composes "
959
+ "with --scenario/--tag/--guide like the other mode flags. See "
960
+ "`steppy coverage report` for the gap analysis.",
961
+ ),
962
+ bundle: bool = typer.Option(
963
+ False,
964
+ "--bundle",
965
+ help="Capture the extra artifacts a results bundle needs: per-test event "
966
+ "records under output/events/<run-id>/ and JUnit XML under "
967
+ "output/junit/<run-id>/, plus an output/.steppy-run.json describing the "
968
+ "run. Does NOT create the bundle — run `steppy bundle create` afterwards. "
969
+ "Off by default because it changes the pytest invocation.",
970
+ ),
971
+ trace: bool = typer.Option(
972
+ False,
973
+ "--trace",
974
+ help="Record a Playwright trace and save output/traces/<test>.zip for each "
975
+ "FAILED test (equivalent to report.trace: on-failure for this run). "
976
+ "Open with: playwright show-trace <zip>.",
977
+ ),
978
+ video: bool = typer.Option(
979
+ False,
980
+ "--video",
981
+ help="Record a Playwright video and save output/videos/<test>.webm for each "
982
+ "FAILED test (equivalent to report.video: on-failure for this run).",
983
+ ),
984
+ retries: Optional[int] = typer.Option(
985
+ None,
986
+ "--retries",
987
+ help="Rerun each failed test up to N times (overrides report.retries for this "
988
+ "run). Requires the steppy-dev[retry] extra (pytest-rerunfailures).",
989
+ ),
990
+ retry_pass: Optional[int] = typer.Option(
991
+ None,
992
+ "--retry-pass",
993
+ help="[--workers N only] After the queue drains, re-run the FAILED scenarios "
994
+ "N more times at reduced concurrency. A scenario that then passes is reported "
995
+ "as FLAKY, not failed. Overrides parallel.retry.passes; --retry-pass 0 disables.",
996
+ ),
997
+ strict_flaky: bool = typer.Option(
998
+ False,
999
+ "--strict-flaky",
1000
+ help="[--workers N only] Make FLAKY scenarios (failed, then passed on retry) "
1001
+ "fail the build. Default is lenient: a pass on retry is a pass.",
1002
+ ),
1003
+ fail_fast: Optional[bool] = typer.Option(
1004
+ None,
1005
+ "--fail-fast/--no-fail-fast",
1006
+ help="[--workers N only] Abort the run early when nearly every completed "
1007
+ "scenario has failed (the app or auth is probably down). On by default; "
1008
+ "overrides parallel.circuit_breaker.enabled.",
1009
+ ),
1010
+ ):
1011
+ """
1012
+ Run generated pytest tests.
1013
+
1014
+ Wraps pytest with Steppy-specific environment variables and filtering.
1015
+ You can also run pytest directly: pytest tests/e2e/
1016
+ """
1017
+ with _cli_errors():
1018
+ # Parallel-mode dispatch. Lazy import keeps the single-worker path
1019
+ # from loading any coordinator/worker code at module-load time —
1020
+ # enforced by tests/test_no_parallel_import_on_default_path.py.
1021
+ # Resolution order for `workers`:
1022
+ # 1. CLI flag (any int >= 1)
1023
+ # 2. config.yaml's parallel.workers (int or "auto")
1024
+ # 3. Default 1 (existing path)
1025
+ # Any value >= 2 dispatches; 1 (or unset, with no config default)
1026
+ # falls through to the unchanged existing implementation below.
1027
+ if workers is None:
1028
+ try:
1029
+ _cfg = load_config()
1030
+ _cfg_workers = _cfg.parallel.workers if _cfg.parallel else 1
1031
+ # "auto" → force dispatch with sentinel 2; the coordinator
1032
+ # computes the real auto count once it knows scenario_count.
1033
+ workers = 2 if _cfg_workers == "auto" else int(_cfg_workers)
1034
+ except ConfigError:
1035
+ workers = 1
1036
+ if workers >= 2:
1037
+ from .coordinator import run_parallel
1038
+
1039
+ # Report settings resolve host-side (they need config.yaml) and
1040
+ # travel to the workers as env vars. Without this the `--trace` /
1041
+ # `--video` / `--retries` flags were silently dropped in parallel
1042
+ # mode — see tests/test_parallel_report_flags.py.
1043
+ par_trace, par_video, par_retries, _ = _resolve_report_modes(trace=trace, video=video, retries=retries)
1044
+ if ctx.args:
1045
+ reject_xdist_args(ctx.args)
1046
+
1047
+ raise typer.Exit(
1048
+ run_parallel(
1049
+ workers=workers,
1050
+ scenario=scenario,
1051
+ tag=tag,
1052
+ headed=headed,
1053
+ screenshots=screenshots,
1054
+ guide=guide,
1055
+ no_asserts=no_asserts,
1056
+ no_guide=no_guide,
1057
+ locales=locales,
1058
+ layouts=layouts,
1059
+ tests_dir=tests_dir,
1060
+ scenarios_dir=scenarios_dir,
1061
+ pytest_passthrough=list(ctx.args),
1062
+ no_build=no_build,
1063
+ verbose=verbose,
1064
+ coverage=coverage,
1065
+ trace_mode=par_trace,
1066
+ video_mode=par_video,
1067
+ retries=par_retries,
1068
+ retry_pass=retry_pass,
1069
+ strict_flaky=strict_flaky,
1070
+ fail_fast=fail_fast,
1071
+ bundle=bundle,
1072
+ )
1073
+ )
1074
+
1075
+ # ── Existing single-worker path: unchanged from here ──────────────
1076
+ require_generated_tests(tests_dir)
1077
+
1078
+ # Validate mutually exclusive options
1079
+ if guide and no_guide:
1080
+ console.print("[red]ERROR[/red] --guide and --no-guide are mutually exclusive")
1081
+ raise typer.Exit(1)
1082
+ # --guide/--no-guide compose with --scenario/--tag: they narrow the
1083
+ # selection (and --guide still drives guide *mode* env below). This
1084
+ # matches the parallel path (coordinator._select_scenario_slugs).
1085
+ if locales and no_guide:
1086
+ console.print("[red]ERROR[/red] --locales cannot be combined with --no-guide")
1087
+ raise typer.Exit(1)
1088
+
1089
+ # --guide auto-enables screenshots + --no-asserts
1090
+ screenshots, no_asserts = selection.apply_guide_defaults(guide, screenshots, no_asserts)
1091
+
1092
+ # Resolve locale + layout parametrization lists: explicit --locales/
1093
+ # --layouts win, else --guide pulls from config.yaml, else no
1094
+ # parametrization. Shared with `rerun` via this helper.
1095
+ locale_list, layout_list = _resolve_locale_layout_lists(locales, layouts, guide, console)
1096
+
1097
+ # Build pytest command - use sys.executable to ensure we use the same Python env
1098
+ pytest_cmd = [sys.executable, "-m", "pytest"]
1099
+
1100
+ # Add filtering. Most-specific selector wins; --guide/--no-guide narrow
1101
+ # the tag-matched set (and the guide *mode* env was already applied
1102
+ # above). Order: scenario → tag (∩ guide) → guide/no_guide → all.
1103
+ if scenario:
1104
+ # Filter by scenario slug - run the specific test file. Combined with
1105
+ # --guide this runs that one scenario in guide mode.
1106
+ test_file = tests_dir / f"test_{scenario}.py"
1107
+ if not test_file.exists():
1108
+ console.print(f"[red]ERROR[/red] Test file not found: {test_file}")
1109
+ console.print("\n[yellow]Hint:[/yellow] Run [bold]steppy generate[/bold] first to create test files")
1110
+ raise typer.Exit(1)
1111
+ pytest_cmd.append(str(test_file))
1112
+ console.print(f"\n[bold]Running scenario:[/bold] {scenario}")
1113
+ elif tag:
1114
+ # Filter by tag (∩ guide) via the shared selector (FLAWS §8.3), then
1115
+ # project to specific test files (avoids -k substring matching issues).
1116
+ try:
1117
+ matching_slugs = selection.select_scenario_slugs(
1118
+ scenarios_dir=scenarios_dir,
1119
+ tests_dir=tests_dir,
1120
+ scenario=None,
1121
+ tag=tag,
1122
+ guide=guide,
1123
+ no_guide=no_guide,
1124
+ )
1125
+ except selection.SelectionError as e:
1126
+ _error(e.message)
1127
+ raise typer.Exit(1)
1128
+
1129
+ guide_label = " guide-enabled" if guide else (" non-guide" if no_guide else "")
1130
+ if not matching_slugs:
1131
+ console.print(
1132
+ f"[yellow]WARNING[/yellow] No{guide_label} scenarios found with tag(s) '{', '.join(tag)}'"
1133
+ )
1134
+ raise typer.Exit(0)
1135
+
1136
+ for slug in matching_slugs:
1137
+ pytest_cmd.append(str(tests_dir / f"test_{slug}.py"))
1138
+ console.print(f"\n[bold]Running{guide_label} tests with tag(s):[/bold] {', '.join(tag)}")
1139
+ console.print(f"[dim]Matched {len(matching_slugs)} scenario(s): {', '.join(matching_slugs)}[/dim]")
1140
+ elif guide or no_guide:
1141
+ # Filter to scenarios with (guide) / without (no_guide) guide.enabled.
1142
+ pytest_cmd.extend(_select_guide_test_files(guide, no_guide, scenarios_dir, tests_dir, console))
1143
+ else:
1144
+ pytest_cmd.append(str(tests_dir))
1145
+ console.print(f"\n[bold]Running all tests in:[/bold] {tests_dir}")
1146
+
1147
+ # Add pytest-playwright plugin
1148
+ pytest_cmd.append("--browser")
1149
+ pytest_cmd.append("chromium")
1150
+
1151
+ trace_mode, video_mode = _apply_report_and_modes(
1152
+ pytest_cmd, trace=trace, video=video, retries=retries, console=console
1153
+ )
1154
+
1155
+ # --bundle: one identity for the whole run, plus the two artifact sinks
1156
+ # `steppy bundle create` needs and a plain run does not produce.
1157
+ run_id = new_run_id() if (bundle or coverage) else None
1158
+ events_dir = junit_dir = None
1159
+ if bundle:
1160
+ events_dir, junit_dir = bundle_dirs(Path.cwd(), run_id)
1161
+ _apply_bundle_junit_arg(pytest_cmd, junit_dir, console)
1162
+
1163
+ # Add passthrough arguments from context
1164
+ if ctx.args:
1165
+ reject_xdist_args(ctx.args)
1166
+ pytest_cmd.extend(ctx.args)
1167
+
1168
+ # Set environment variables
1169
+ env = _build_run_env(
1170
+ headed,
1171
+ screenshots,
1172
+ locale_list,
1173
+ layout_list,
1174
+ guide,
1175
+ no_asserts,
1176
+ console,
1177
+ coverage=coverage,
1178
+ trace_mode=trace_mode,
1179
+ video_mode=video_mode,
1180
+ run_id=run_id,
1181
+ events_dir=events_dir,
1182
+ )
1183
+
1184
+ if bundle:
1185
+ write_run_sidecar(
1186
+ Path.cwd(),
1187
+ run_id=run_id,
1188
+ spec={
1189
+ "guide": guide,
1190
+ "no_guide": no_guide,
1191
+ "no_asserts": no_asserts,
1192
+ "screenshots": screenshots,
1193
+ "coverage": coverage,
1194
+ "locales": list(locale_list),
1195
+ "layouts": list(layout_list),
1196
+ "workers": 1,
1197
+ "tag": list(tag) if tag else None,
1198
+ "scenario": scenario,
1199
+ },
1200
+ )
1201
+ console.print(f"[dim]Bundle capture: enabled (run {run_id}) — create it with: steppy bundle create[/dim]")
1202
+
1203
+ console.print(f"\n[dim]Command: {' '.join(pytest_cmd)}[/dim]\n")
1204
+
1205
+ # Run pytest
1206
+ result = subprocess.run(
1207
+ pytest_cmd,
1208
+ env=env,
1209
+ cwd=Path.cwd(),
1210
+ )
1211
+
1212
+ # Post-run coverage merge. Runs even on nonzero pytest exit — failed
1213
+ # scenarios still exercised surface (their records carry outcome) —
1214
+ # and never masks pytest's exit code.
1215
+ if coverage and run_id:
1216
+ _merge_coverage_after_run(run_id, console)
1217
+
1218
+ # Exit with pytest's exit code
1219
+ raise typer.Exit(result.returncode)
1220
+
1221
+
1222
+ # ── Rerun commands ──────────────────────────────────────────────────────────
1223
+
1224
+
1225
+ @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
1226
+ def rerun(
1227
+ ctx: typer.Context,
1228
+ headed: bool = typer.Option(
1229
+ False,
1230
+ "--headed",
1231
+ help="Run with visible browser (headed mode)",
1232
+ ),
1233
+ screenshots: bool = typer.Option(
1234
+ False,
1235
+ "--screenshots",
1236
+ help="Enable screenshot capture during test run",
1237
+ ),
1238
+ guide: bool = typer.Option(
1239
+ False,
1240
+ "--guide",
1241
+ help="Re-run failures from a guide capture run. Reconstructs the same "
1242
+ "environment as `steppy run --guide` (auto-enables --screenshots and "
1243
+ "--no-asserts, sets STEPPY_GUIDE_MODE, and parametrizes over locales/"
1244
+ "layouts) so the failed [locale-layout] node IDs line up with --lf. "
1245
+ "Pass the SAME --locales/--layouts you used for the original capture "
1246
+ "(or just --guide to take the config.yaml defaults).",
1247
+ ),
1248
+ no_asserts: bool = typer.Option(
1249
+ False,
1250
+ "--no-asserts",
1251
+ help="Skip assertions at runtime (auto-enabled by --guide).",
1252
+ ),
1253
+ locales: Optional[str] = typer.Option(
1254
+ None,
1255
+ "--locales",
1256
+ help="Comma-separated locale codes to parametrize over (e.g. 'fr,nl'). "
1257
+ "Must match the original capture run for --lf node IDs to align. With "
1258
+ "--guide and no explicit value, defaults to all locales from config.yaml.",
1259
+ ),
1260
+ layouts: Optional[str] = typer.Option(
1261
+ None,
1262
+ "--layouts",
1263
+ help="Comma-separated layout names to parametrize over (e.g. 'desktop,mobile'). "
1264
+ "Must match the original capture run for --lf node IDs to align. With "
1265
+ "--guide and no explicit value, defaults to all layouts from config.yaml.",
1266
+ ),
1267
+ tests_dir: Path = typer.Option(
1268
+ Path("tests/e2e"),
1269
+ "--tests-dir",
1270
+ help="Directory containing generated test files",
1271
+ ),
1272
+ scenarios_dir: Path = typer.Option(
1273
+ Path("scenarios"),
1274
+ "--scenarios-dir",
1275
+ "-d",
1276
+ help="Directory containing scenario files (used for --guide filtering)",
1277
+ ),
1278
+ trace: bool = typer.Option(
1279
+ False,
1280
+ "--trace",
1281
+ help="Record a Playwright trace and save output/traces/<test>.zip for each "
1282
+ "FAILED test. Open with: playwright show-trace <zip>.",
1283
+ ),
1284
+ video: bool = typer.Option(
1285
+ False,
1286
+ "--video",
1287
+ help="Record a Playwright video and save output/videos/<test>.webm for each FAILED test.",
1288
+ ),
1289
+ retries: Optional[int] = typer.Option(
1290
+ None,
1291
+ "--retries",
1292
+ help="Rerun each failed test up to N times (overrides report.retries for this "
1293
+ "run). Requires the steppy-dev[retry] extra (pytest-rerunfailures).",
1294
+ ),
1295
+ ):
1296
+ """
1297
+ Re-run only previously failed tests.
1298
+
1299
+ Uses pytest's --lf (last-failed) flag to re-run tests that failed
1300
+ in the most recent run. Requires a prior test run so that pytest's
1301
+ cache knows which tests failed.
1302
+
1303
+ Add --guide to re-run failures from a guide capture run: it rebuilds the
1304
+ same parametrization environment as `steppy run --guide`, so only the
1305
+ failed (locale, layout, scenario) variants are re-captured.
1306
+ """
1307
+ with _cli_errors():
1308
+ # `rerun` needs no test_*.py check: pytest's --lf cache decides.
1309
+ require_generated_tests(tests_dir, require_test_files=False)
1310
+
1311
+ # Check pytest cache for last-failed info
1312
+ cache_file = Path.cwd() / ".pytest_cache" / "v" / "cache" / "lastfailed"
1313
+ if not cache_file.exists():
1314
+ console.print("[yellow]INFO[/yellow] No previous test run found (no pytest cache)")
1315
+ console.print(
1316
+ "\n[yellow]Hint:[/yellow] Run [bold]steppy run[/bold] first, then use [bold]steppy rerun[/bold] to retry failures"
1317
+ )
1318
+ raise typer.Exit(0)
1319
+
1320
+ try:
1321
+ lastfailed = json.loads(cache_file.read_text(encoding="utf-8"))
1322
+ except (json.JSONDecodeError, OSError):
1323
+ console.print("[yellow]INFO[/yellow] Could not read pytest cache")
1324
+ raise typer.Exit(1)
1325
+
1326
+ if not lastfailed:
1327
+ console.print("[green]No failed tests to re-run — all tests passed in the last run![/green]")
1328
+ raise typer.Exit(0)
1329
+
1330
+ # Display which tests will be re-run
1331
+ console.print(f"\n[bold]Re-running {len(lastfailed)} previously failed test(s):[/bold]")
1332
+ for node_id in lastfailed:
1333
+ console.print(f" [dim]•[/dim] {node_id}")
1334
+
1335
+ # --guide reconstructs the same capture environment as `steppy run --guide`
1336
+ # so the parametrized [locale-layout] node IDs in the cache line up with --lf.
1337
+ screenshots, no_asserts = selection.apply_guide_defaults(guide, screenshots, no_asserts)
1338
+ locale_list, layout_list = _resolve_locale_layout_lists(locales, layouts, guide, console)
1339
+
1340
+ # Build pytest command. With --guide, scope --lf to the guide scenario
1341
+ # test files (so collection only parametrizes those); otherwise the whole
1342
+ # tests dir (unchanged behavior).
1343
+ pytest_cmd = [sys.executable, "-m", "pytest", "--lf"]
1344
+ if guide:
1345
+ pytest_cmd.extend(_select_guide_test_files(guide, False, scenarios_dir, tests_dir, console))
1346
+ else:
1347
+ pytest_cmd.append(str(tests_dir))
1348
+
1349
+ # Add pytest-playwright plugin
1350
+ pytest_cmd.extend(["--browser", "chromium"])
1351
+
1352
+ trace_mode, video_mode = _apply_report_and_modes(
1353
+ pytest_cmd, trace=trace, video=video, retries=retries, console=console
1354
+ )
1355
+
1356
+ # Add passthrough arguments from context
1357
+ if ctx.args:
1358
+ reject_xdist_args(ctx.args)
1359
+ pytest_cmd.extend(ctx.args)
1360
+
1361
+ # Set environment variables (shared builder with `run`)
1362
+ env = _build_run_env(
1363
+ headed,
1364
+ screenshots,
1365
+ locale_list,
1366
+ layout_list,
1367
+ guide,
1368
+ no_asserts,
1369
+ console,
1370
+ trace_mode=trace_mode,
1371
+ video_mode=video_mode,
1372
+ )
1373
+
1374
+ console.print(f"\n[dim]Command: {' '.join(pytest_cmd)}[/dim]\n")
1375
+
1376
+ # Run pytest
1377
+ result = subprocess.run(
1378
+ pytest_cmd,
1379
+ env=env,
1380
+ cwd=Path.cwd(),
1381
+ )
1382
+
1383
+ # Exit with pytest's exit code
1384
+ raise typer.Exit(result.returncode)
1385
+
1386
+
1387
+ # ── Guide commands ──────────────────────────────────────────────────────────
1388
+
1389
+
1390
+ def _handbook_targets(results: dict) -> list[tuple[str, Optional[str]]]:
1391
+ """The (locale, layout) pairs a tag actually produced guides for.
1392
+
1393
+ Derived from ``generate_guides_for_tag``'s result keys (``"<locale>"`` or
1394
+ ``"<locale>/<layout>"``) rather than re-probing the disk, so the handbook
1395
+ covers exactly the pages that were just written — including the case where
1396
+ scenarios in one tag declare different layouts.
1397
+ """
1398
+ targets: list[tuple[str, Optional[str]]] = []
1399
+ for locale_results in results.values():
1400
+ for key, result in locale_results.items():
1401
+ if result.get("status") != "success":
1402
+ continue
1403
+ locale, _, layout = key.partition("/")
1404
+ pair = (locale, layout or None)
1405
+ if pair not in targets:
1406
+ targets.append(pair)
1407
+ return targets
1408
+
1409
+
1410
+ def _emit_guide_pdfs(
1411
+ assembler,
1412
+ config,
1413
+ results_by_tag: dict[str, dict],
1414
+ *,
1415
+ want_pdf: bool,
1416
+ want_handbook: bool,
1417
+ scenarios_dir: Path,
1418
+ output_dir: Path,
1419
+ screenshots_dir: Path,
1420
+ ) -> int:
1421
+ """Render per-guide PDFs and/or handbooks for already-assembled guides.
1422
+
1423
+ Runs once, after all assembly, so the whole batch shares a single Chromium
1424
+ (a guide project is a cartesian of scenarios × locales × layouts — a browser
1425
+ launch per file would dominate). Returns the number of failures, which the
1426
+ caller folds into its own error count.
1427
+ """
1428
+ from .pdf import PdfJob, PdfRenderError, render_pdfs
1429
+
1430
+ jobs: list[PdfJob] = []
1431
+
1432
+ if want_pdf:
1433
+ for results in results_by_tag.values():
1434
+ for locale_results in results.values():
1435
+ for result in locale_results.values():
1436
+ if result.get("status") != "success":
1437
+ continue
1438
+ html_path = Path(result["html"])
1439
+ jobs.append(
1440
+ PdfJob(
1441
+ html=html_path,
1442
+ pdf=html_path.with_suffix(".pdf"),
1443
+ title=result.get("title", html_path.stem),
1444
+ )
1445
+ )
1446
+
1447
+ errors = 0
1448
+ if want_handbook:
1449
+ console.print("\n[bold]Assembling handbooks[/bold]")
1450
+ for guide_tag, results in results_by_tag.items():
1451
+ for locale, layout in _handbook_targets(results):
1452
+ try:
1453
+ handbook = assembler.generate_handbook(
1454
+ guide_tag,
1455
+ locale,
1456
+ scenarios_dir,
1457
+ output_dir,
1458
+ screenshots_dir,
1459
+ layout=layout,
1460
+ on_skip=lambda slug, reason: console.print(
1461
+ f" [yellow]SKIP[/yellow] {escape(slug)}: {escape(reason)}"
1462
+ ),
1463
+ )
1464
+ except Exception as e:
1465
+ _error(f"handbook {guide_tag} ({locale}): {e}")
1466
+ errors += 1
1467
+ continue
1468
+ if handbook is None:
1469
+ console.print(f" [yellow]INFO[/yellow] {guide_tag} ({locale}): no guides to include")
1470
+ continue
1471
+ console.print(f" [green]OK[/green] {handbook}")
1472
+ jobs.append(
1473
+ PdfJob(
1474
+ html=handbook,
1475
+ pdf=handbook.with_suffix(".pdf"),
1476
+ title=handbook.stem,
1477
+ )
1478
+ )
1479
+
1480
+ if not jobs:
1481
+ return errors
1482
+
1483
+ console.print(f"\n[bold]Rendering {len(jobs)} PDF(s)[/bold]")
1484
+
1485
+ def _report(result) -> None:
1486
+ if result.ok:
1487
+ console.print(f" [green]OK[/green] {result.job.pdf}")
1488
+ else:
1489
+ _error(f"{result.job.pdf}: {result.error}")
1490
+
1491
+ try:
1492
+ results = render_pdfs(jobs, config.pdf, on_done=_report)
1493
+ except PdfRenderError as e:
1494
+ _error(str(e))
1495
+ return errors + len(jobs)
1496
+
1497
+ return errors + sum(1 for r in results if not r.ok)
1498
+
1499
+
1500
+ def _emit_guide_videos(
1501
+ assembler,
1502
+ config,
1503
+ results_by_tag: dict[str, dict],
1504
+ *,
1505
+ scenarios_dir: Path,
1506
+ output_dir: Path,
1507
+ screenshots_dir: Path,
1508
+ ) -> int:
1509
+ """Render a narrated MP4 beside each guide page just assembled.
1510
+
1511
+ Runs once, after all assembly, so the whole batch shares one Chromium and
1512
+ one ffmpeg discovery — the same reason the PDF pass is batched.
1513
+
1514
+ A guide whose manifest cannot be built is **skipped, not fatal**: the
1515
+ interactive manifest validates far more strictly than the HTML guide (a step
1516
+ with no bbox, a locale with no ``guide-text``), so scenarios that render a
1517
+ perfectly good page can legitimately have no video. Returns the failure
1518
+ count for the caller's error tally.
1519
+ """
1520
+ from .captures import resolve_output_dir
1521
+ from .tts import assemble_track, voice_jobs
1522
+ from .video import VideoJob, VideoRenderError, find_ffmpeg, render_videos
1523
+
1524
+ options = config.video
1525
+ try:
1526
+ ffmpeg = find_ffmpeg(options)
1527
+ except VideoRenderError as e:
1528
+ _error(str(e))
1529
+ return 1
1530
+
1531
+ jobs: list[VideoJob] = []
1532
+ errors = 0
1533
+ for guide_tag, results in results_by_tag.items():
1534
+ role = guide_tag.replace("user-guide-", "")
1535
+ for slug, locale_results in results.items():
1536
+ for key, result in locale_results.items():
1537
+ if result.get("status") != "success":
1538
+ continue
1539
+ locale, _, layout = key.partition("/")
1540
+ page = Path(result["html"])
1541
+ try:
1542
+ scenario = assembler.scenario_by_slug(scenarios_dir, slug)
1543
+ manifest = assembler.prepare_video_manifest(
1544
+ scenario,
1545
+ role,
1546
+ locale,
1547
+ output_dir,
1548
+ screenshots_dir,
1549
+ layout=layout or None,
1550
+ title=result.get("title"),
1551
+ )
1552
+ except Exception as e:
1553
+ console.print(f" [yellow]SKIP[/yellow] {escape(slug)} ({escape(key)}): {escape(str(e))}")
1554
+ continue
1555
+ jobs.append(
1556
+ VideoJob(
1557
+ manifest=manifest,
1558
+ page_dir=page.parent,
1559
+ out=page.with_suffix(".mp4"),
1560
+ locale=locale,
1561
+ layout=layout or None,
1562
+ title=result.get("title", slug),
1563
+ )
1564
+ )
1565
+
1566
+ if not jobs:
1567
+ return errors
1568
+
1569
+ tts_cache = resolve_output_dir(Path.cwd(), options.tts.cache_dir, ".steppy/tts-cache")
1570
+ jobs = voice_jobs(
1571
+ jobs,
1572
+ options.tts,
1573
+ ffmpeg=ffmpeg,
1574
+ cache_root=tts_cache,
1575
+ work_dir=tts_cache / "work",
1576
+ on_warning=lambda msg: console.print(f" [yellow]WARN[/yellow] {escape(msg)}"),
1577
+ )
1578
+
1579
+ console.print(f"\n[bold]Rendering {len(jobs)} video(s)[/bold] (headless Chromium + ffmpeg)")
1580
+
1581
+ def _report(result) -> None:
1582
+ if result.ok:
1583
+ suffix = " [dim](cached)[/dim]" if result.cached else ""
1584
+ console.print(f" [green]OK[/green] {result.job.out}{suffix}")
1585
+ else:
1586
+ _error(f"{result.job.out}: {result.error}")
1587
+
1588
+ try:
1589
+ rendered = render_videos(
1590
+ jobs,
1591
+ options,
1592
+ assemble_audio=assemble_track,
1593
+ cache_root=resolve_output_dir(Path.cwd(), options.cache_dir, ".steppy/video-cache"),
1594
+ on_done=_report,
1595
+ )
1596
+ except VideoRenderError as e:
1597
+ _error(str(e))
1598
+ return errors + len(jobs)
1599
+
1600
+ return errors + sum(1 for r in rendered if not r.ok)
1601
+
1602
+
1603
+ @app.command()
1604
+ def guide(
1605
+ tag: Optional[str] = typer.Option(
1606
+ None,
1607
+ "--tag",
1608
+ "-t",
1609
+ help="Generate guides for scenarios with this tag (e.g., 'user-guide-admin')",
1610
+ ),
1611
+ locale: List[str] = typer.Option(
1612
+ ["fr"],
1613
+ "--locale",
1614
+ "-l",
1615
+ help="Locale(s) to generate guides for (can be specified multiple times)",
1616
+ ),
1617
+ all_pipeline: bool = typer.Option(
1618
+ False,
1619
+ "--all",
1620
+ help="Run full pipeline: generate tests, run with screenshots, then assemble guides",
1621
+ ),
1622
+ guide_mode: bool = typer.Option(
1623
+ False,
1624
+ "--guide",
1625
+ help="Assemble guides for every scenario marked guide.enabled=true, across all their user-guide-* roles",
1626
+ ),
1627
+ scenarios_dir: Path = typer.Option(
1628
+ Path("scenarios"),
1629
+ "--scenarios-dir",
1630
+ help="Directory containing scenario files",
1631
+ ),
1632
+ output_dir: Optional[Path] = typer.Option(
1633
+ None,
1634
+ "--output-dir",
1635
+ # No square brackets: Rich parses them as markup and silently eats the
1636
+ # text (the FLAWS §2.5 bug, in help strings rather than errors).
1637
+ help="Output directory for guides. Default: config output.guides_dir, else output/guides",
1638
+ ),
1639
+ screenshots_dir: Optional[Path] = typer.Option(
1640
+ None,
1641
+ "--screenshots-dir",
1642
+ help="Directory containing captured screenshots. Default: config output.screenshots_dir, else output/screenshots",
1643
+ ),
1644
+ pdf: bool = typer.Option(
1645
+ False,
1646
+ "--pdf",
1647
+ help="Also print each generated guide to PDF (headless Chromium)",
1648
+ ),
1649
+ handbook: bool = typer.Option(
1650
+ False,
1651
+ "--handbook",
1652
+ help="Also assemble one handbook (HTML + PDF) per role/locale/layout: cover, contents, every guide",
1653
+ ),
1654
+ video: bool = typer.Option(
1655
+ False,
1656
+ "--video",
1657
+ help=(
1658
+ "Also render a narrated MP4 beside each generated guide. Requires ffmpeg. "
1659
+ "(Unrelated to 'steppy run --video', which records Playwright debugging "
1660
+ "footage of failed tests.)"
1661
+ ),
1662
+ ),
1663
+ ):
1664
+ """
1665
+ Assemble user guides from scenario files and captured screenshots.
1666
+
1667
+ Requires that tests have been run with --screenshots enabled first.
1668
+
1669
+ Examples:
1670
+ steppy guide --tag user-guide-admin --locale fr
1671
+ steppy guide --tag user-guide-editor --locale fr --locale en
1672
+ steppy guide --guide --locale fr
1673
+ steppy guide --guide --pdf --handbook
1674
+ steppy guide --all
1675
+ """
1676
+ # Lazy: pulls in jinja2 + markdown_it (~40ms), only needed by this command.
1677
+ from .guide import GuideAssembler
1678
+
1679
+ with _cli_errors():
1680
+ config = _load_config_or_exit()
1681
+
1682
+ # Each --locale becomes a directory segment this command reads back and
1683
+ # `steppy build` reads after it; both of those now reject a malformed
1684
+ # tag, so accepting one here only moves the failure somewhere it cannot
1685
+ # be explained.
1686
+ for code in locale:
1687
+ try:
1688
+ normalize_locale(code)
1689
+ except LocaleSyntaxError as e:
1690
+ _error(f"--locale: {e}")
1691
+ raise typer.Exit(1)
1692
+
1693
+ # An explicit flag wins; otherwise the paths come from `config.output`,
1694
+ # which is also what the generated conftest writes to (FLAWS §4.6).
1695
+ if output_dir is None:
1696
+ output_dir = resolve_output_dir(Path.cwd(), config.output.guides_dir, DEFAULT_GUIDES_DIR)
1697
+ if screenshots_dir is None:
1698
+ screenshots_dir = resolve_output_dir(Path.cwd(), config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR)
1699
+
1700
+ # Handle --all flag: full pipeline
1701
+ if all_pipeline:
1702
+ console.print("\n[bold cyan]Running full guide pipeline[/bold cyan]\n")
1703
+
1704
+ # Step 1: Generate tests
1705
+ console.print("[bold]Step 1/3:[/bold] Generating test files...")
1706
+ try:
1707
+ generator = SteppyGenerator(config)
1708
+ scenarios = parse_scenarios_from_directory(scenarios_dir)
1709
+ tests_dir = Path("tests/e2e")
1710
+ tour_pipeline = None
1711
+ if tours_enabled(config):
1712
+ tour_pipeline = prepare_tours_or_exit(config, scenarios_dir=scenarios_dir, all_scenarios=scenarios)
1713
+ results = generator.generate_all(
1714
+ scenarios, tests_dir, force=False, tour_plans=tour_pipeline.replay_plans if tour_pipeline else None
1715
+ )
1716
+ if tour_pipeline is not None:
1717
+ results.update(tour_pipeline.emit())
1718
+
1719
+ generated = sum(1 for status in results.values() if status == "generated")
1720
+ updated = sum(1 for status in results.values() if status == "updated")
1721
+ unchanged = sum(1 for status in results.values() if status == "unchanged")
1722
+
1723
+ console.print(f" [green]OK[/green] {generated} generated, {updated} updated, {unchanged} unchanged\n")
1724
+ except typer.Exit:
1725
+ raise
1726
+ except Exception as e:
1727
+ console.print(f" [red]ERROR[/red] Failed to generate tests: {e}")
1728
+ raise typer.Exit(1)
1729
+
1730
+ # Step 2: Run tests with screenshots
1731
+ console.print("[bold]Step 2/3:[/bold] Running tests with screenshots...")
1732
+ console.print("[dim]This may take a few minutes...[/dim]\n")
1733
+
1734
+ pytest_cmd = [sys.executable, "-m", "pytest", str(tests_dir), "--browser", "chromium"]
1735
+
1736
+ env = os.environ.copy()
1737
+ env["STEPPY_SCREENSHOTS"] = "1"
1738
+
1739
+ result = subprocess.run(pytest_cmd, env=env, cwd=Path.cwd())
1740
+
1741
+ if result.returncode != 0:
1742
+ console.print("\n[yellow]WARNING[/yellow] Some tests failed, but continuing with guide generation...")
1743
+ else:
1744
+ console.print("\n [green]OK[/green] Tests completed successfully\n")
1745
+
1746
+ # Step 3: Assemble guides (will continue below with normal flow)
1747
+ console.print("[bold]Step 3/3:[/bold] Assembling user guides...\n")
1748
+
1749
+ # Find all user-guide tags in scenarios
1750
+ user_guide_tags = set()
1751
+ for scenario_file in scenarios_dir.glob("*.md"):
1752
+ scenario = parse_scenario_file(scenario_file)
1753
+ for scenario_tag in scenario.tags:
1754
+ if scenario_tag.startswith("user-guide-"):
1755
+ user_guide_tags.add(scenario_tag)
1756
+
1757
+ if not user_guide_tags:
1758
+ console.print("[yellow]INFO[/yellow] No scenarios found with user-guide-* tags")
1759
+ raise typer.Exit(0)
1760
+
1761
+ # Generate guides for all user-guide tags
1762
+ assembler = GuideAssembler(config)
1763
+ all_results = {}
1764
+
1765
+ for guide_tag in sorted(user_guide_tags):
1766
+ console.print(f"\n[bold]Generating guides for tag:[/bold] {guide_tag}")
1767
+ try:
1768
+ tag_results = assembler.generate_guides_for_tag(
1769
+ guide_tag,
1770
+ locale,
1771
+ scenarios_dir,
1772
+ output_dir,
1773
+ screenshots_dir,
1774
+ )
1775
+ all_results[guide_tag] = tag_results
1776
+
1777
+ # Print summary for this tag
1778
+ for scenario_slug, locale_results in tag_results.items():
1779
+ for loc, result in locale_results.items():
1780
+ if result["status"] == "success":
1781
+ console.print(f" [green]OK[/green] {scenario_slug} ({loc})")
1782
+ else:
1783
+ console.print(f" [red]ERROR[/red] {scenario_slug} ({loc}): {result['error']}")
1784
+
1785
+ except Exception as e:
1786
+ console.print(f" [red]ERROR[/red] {e}")
1787
+
1788
+ pdf_errors = 0
1789
+ if pdf or handbook:
1790
+ pdf_errors = _emit_guide_pdfs(
1791
+ assembler,
1792
+ config,
1793
+ all_results,
1794
+ want_pdf=pdf,
1795
+ want_handbook=handbook,
1796
+ scenarios_dir=scenarios_dir,
1797
+ output_dir=output_dir,
1798
+ screenshots_dir=screenshots_dir,
1799
+ )
1800
+ if video:
1801
+ pdf_errors += _emit_guide_videos(
1802
+ assembler,
1803
+ config,
1804
+ all_results,
1805
+ scenarios_dir=scenarios_dir,
1806
+ output_dir=output_dir,
1807
+ screenshots_dir=screenshots_dir,
1808
+ )
1809
+
1810
+ console.print("\n[green]OK[/green] Full pipeline completed\n")
1811
+ raise typer.Exit(1 if pdf_errors else 0)
1812
+
1813
+ # Handle --guide mode: assemble guides for all guide-enabled scenarios
1814
+ if guide_mode:
1815
+ if not scenarios_dir.exists():
1816
+ console.print(f"[red]ERROR[/red] Scenarios directory not found: {scenarios_dir}")
1817
+ raise typer.Exit(1)
1818
+
1819
+ if not screenshots_dir.exists():
1820
+ console.print(f"[red]ERROR[/red] Screenshots directory not found: {screenshots_dir}")
1821
+ console.print("\n[yellow]Hint:[/yellow] Run tests with screenshots first:")
1822
+ console.print(" [bold]steppy run --guide[/bold]")
1823
+ raise typer.Exit(1)
1824
+
1825
+ try:
1826
+ all_scenarios = parse_scenarios_from_directory(scenarios_dir)
1827
+ except ParseError as e:
1828
+ _error(f"Parse error: {e}")
1829
+ raise typer.Exit(1)
1830
+
1831
+ guide_scenarios = [s for s in all_scenarios if s.guide.enabled]
1832
+
1833
+ if not guide_scenarios:
1834
+ console.print("[yellow]INFO[/yellow] No scenarios found with guide.enabled=true")
1835
+ raise typer.Exit(0)
1836
+
1837
+ # Collect the set of user-guide-* tags exposed by these scenarios
1838
+ discovered_tags = set()
1839
+ for s in guide_scenarios:
1840
+ for t in s.tags:
1841
+ if t.startswith("user-guide-"):
1842
+ discovered_tags.add(t)
1843
+
1844
+ if not discovered_tags:
1845
+ console.print(
1846
+ "[yellow]INFO[/yellow] Guide-enabled scenarios have no user-guide-* tags; nothing to assemble"
1847
+ )
1848
+ raise typer.Exit(0)
1849
+
1850
+ console.print(f"\n[bold]Assembling guides for {len(guide_scenarios)} guide scenario(s)[/bold]")
1851
+ console.print(f"[bold]Roles (tags):[/bold] {', '.join(sorted(discovered_tags))}")
1852
+ console.print(f"[bold]Locales:[/bold] {', '.join(locale)}\n")
1853
+
1854
+ assembler = GuideAssembler(config)
1855
+ success_count = 0
1856
+ error_count = 0
1857
+ results_by_tag: dict[str, dict] = {}
1858
+
1859
+ for guide_tag in sorted(discovered_tags):
1860
+ console.print(f"\n[bold cyan]{guide_tag}[/bold cyan]")
1861
+ try:
1862
+ tag_results = assembler.generate_guides_for_tag(
1863
+ guide_tag,
1864
+ locale,
1865
+ scenarios_dir,
1866
+ output_dir,
1867
+ screenshots_dir,
1868
+ )
1869
+ except Exception as e:
1870
+ console.print(f" [red]ERROR[/red] {e}")
1871
+ error_count += 1
1872
+ continue
1873
+ results_by_tag[guide_tag] = tag_results
1874
+
1875
+ for scenario_slug, locale_results in tag_results.items():
1876
+ for loc, result in locale_results.items():
1877
+ if result["status"] == "success":
1878
+ console.print(f" [green]OK[/green] {scenario_slug} ({loc})")
1879
+ console.print(f" [dim]Markdown:[/dim] {result['markdown']}")
1880
+ console.print(f" [dim]HTML:[/dim] {result['html']}")
1881
+ success_count += 1
1882
+ else:
1883
+ console.print(f" [red]ERROR[/red] {scenario_slug} ({loc}): {result['error']}")
1884
+ error_count += 1
1885
+
1886
+ if video:
1887
+ error_count += _emit_guide_videos(
1888
+ assembler,
1889
+ config,
1890
+ results_by_tag,
1891
+ scenarios_dir=scenarios_dir,
1892
+ output_dir=output_dir,
1893
+ screenshots_dir=screenshots_dir,
1894
+ )
1895
+
1896
+ if pdf or handbook:
1897
+ error_count += _emit_guide_pdfs(
1898
+ assembler,
1899
+ config,
1900
+ results_by_tag,
1901
+ want_pdf=pdf,
1902
+ want_handbook=handbook,
1903
+ scenarios_dir=scenarios_dir,
1904
+ output_dir=output_dir,
1905
+ screenshots_dir=screenshots_dir,
1906
+ )
1907
+
1908
+ console.print("\n[bold]Summary:[/bold]")
1909
+ console.print(f" [green]Success:[/green] {success_count}")
1910
+ if error_count > 0:
1911
+ console.print(f" [red]Errors:[/red] {error_count}")
1912
+ console.print()
1913
+
1914
+ if error_count > 0:
1915
+ raise typer.Exit(1)
1916
+ raise typer.Exit(0)
1917
+
1918
+ # Normal mode: assemble guides from existing screenshots
1919
+ if not tag:
1920
+ console.print("[red]ERROR[/red] --tag is required (or use --guide / --all)")
1921
+ console.print("\n[yellow]Example:[/yellow] steppy guide --tag user-guide-admin --locale fr")
1922
+ raise typer.Exit(1)
1923
+
1924
+ # Validate tag format
1925
+ if not tag.startswith("user-guide-"):
1926
+ console.print(f"[red]ERROR[/red] Tag must be in format 'user-guide-{{role}}', got: {tag}")
1927
+ raise typer.Exit(1)
1928
+
1929
+ # Check if screenshots directory exists
1930
+ if not screenshots_dir.exists():
1931
+ console.print(f"[red]ERROR[/red] Screenshots directory not found: {screenshots_dir}")
1932
+ console.print("\n[yellow]Hint:[/yellow] Run tests with screenshots first:")
1933
+ console.print(f" [bold]steppy run --tag {tag} --screenshots[/bold]")
1934
+ raise typer.Exit(1)
1935
+
1936
+ console.print(f"\n[bold]Assembling guides for tag:[/bold] {tag}")
1937
+ console.print(f"[bold]Locales:[/bold] {', '.join(locale)}\n")
1938
+
1939
+ # Assemble guides
1940
+ assembler = GuideAssembler(config)
1941
+
1942
+ try:
1943
+ results = assembler.generate_guides_for_tag(
1944
+ tag,
1945
+ locale,
1946
+ scenarios_dir,
1947
+ output_dir,
1948
+ screenshots_dir,
1949
+ )
1950
+ except ValueError as e:
1951
+ _error(f"{e}")
1952
+ raise typer.Exit(1)
1953
+
1954
+ if not results:
1955
+ console.print(f"[yellow]INFO[/yellow] No scenarios found with tag: {tag}")
1956
+ raise typer.Exit(0)
1957
+
1958
+ # Display results
1959
+ success_count = 0
1960
+ error_count = 0
1961
+
1962
+ for scenario_slug, locale_results in results.items():
1963
+ console.print(f"\n[bold cyan]{scenario_slug}[/bold cyan]")
1964
+
1965
+ for loc, result in locale_results.items():
1966
+ if result["status"] == "success":
1967
+ console.print(f" [green]OK[/green] {loc}")
1968
+ console.print(f" [dim]Markdown:[/dim] {result['markdown']}")
1969
+ console.print(f" [dim]HTML:[/dim] {result['html']}")
1970
+ success_count += 1
1971
+ else:
1972
+ console.print(f" [red]ERROR[/red] {loc}: {result['error']}")
1973
+ error_count += 1
1974
+
1975
+ if video:
1976
+ error_count += _emit_guide_videos(
1977
+ assembler,
1978
+ config,
1979
+ {tag: results},
1980
+ scenarios_dir=scenarios_dir,
1981
+ output_dir=output_dir,
1982
+ screenshots_dir=screenshots_dir,
1983
+ )
1984
+
1985
+ if pdf or handbook:
1986
+ error_count += _emit_guide_pdfs(
1987
+ assembler,
1988
+ config,
1989
+ {tag: results},
1990
+ want_pdf=pdf,
1991
+ want_handbook=handbook,
1992
+ scenarios_dir=scenarios_dir,
1993
+ output_dir=output_dir,
1994
+ screenshots_dir=screenshots_dir,
1995
+ )
1996
+
1997
+ console.print("\n[bold]Summary:[/bold]")
1998
+ console.print(f" [green]Success:[/green] {success_count}")
1999
+ if error_count > 0:
2000
+ console.print(f" [red]Errors:[/red] {error_count}")
2001
+
2002
+ console.print()
2003
+
2004
+ if error_count > 0:
2005
+ raise typer.Exit(1)
2006
+
2007
+
2008
+ # ── Seed commands ───────────────────────────────────────────────────────────
2009
+
2010
+
2011
+ @app.command()
2012
+ def seed(
2013
+ name: str = typer.Argument(
2014
+ ...,
2015
+ help="Name of the seed script to run (e.g., 'base' runs seed/base.py)",
2016
+ ),
2017
+ seed_dir: Path = typer.Option(
2018
+ Path("seed"),
2019
+ "--seed-dir",
2020
+ help="Directory containing seed scripts",
2021
+ ),
2022
+ ):
2023
+ """
2024
+ Run a seed script to set up test data.
2025
+
2026
+ Seed scripts are Python files in the seed/ directory that create
2027
+ idempotent test data for scenarios.
2028
+
2029
+ Example:
2030
+ steppy seed base
2031
+ """
2032
+ with _cli_errors():
2033
+ # Check if seed directory exists
2034
+ if not seed_dir.exists():
2035
+ console.print(f"[red]ERROR[/red] Seed directory not found: {seed_dir}")
2036
+ console.print("\n[yellow]Hint:[/yellow] Create a seed/ directory with seed scripts")
2037
+ raise typer.Exit(1)
2038
+
2039
+ # Check if seed script exists
2040
+ seed_file = seed_dir / f"{name}.py"
2041
+ if not seed_file.exists():
2042
+ console.print(f"[red]ERROR[/red] Seed script not found: {seed_file}")
2043
+
2044
+ # List available seed scripts
2045
+ available_seeds = [f.stem for f in seed_dir.glob("*.py") if f.stem != "__init__"]
2046
+ if available_seeds:
2047
+ console.print(f"\n[yellow]Available seed scripts:[/yellow] {', '.join(available_seeds)}")
2048
+ raise typer.Exit(1)
2049
+
2050
+ console.print(f"\n[bold]Running seed script:[/bold] {name}")
2051
+ console.print(f"[dim]File:[/dim] {seed_file}\n")
2052
+
2053
+ # Run the seed script via subprocess (bounded — a hung seed must not
2054
+ # block the CLI forever; FLAWS §5.1).
2055
+ try:
2056
+ result = subprocess.run(
2057
+ [sys.executable, str(seed_file)],
2058
+ cwd=Path.cwd(),
2059
+ capture_output=True,
2060
+ text=True,
2061
+ timeout=DEFAULT_SEED_TIMEOUT,
2062
+ )
2063
+ except subprocess.TimeoutExpired:
2064
+ _error(f"Seed script '{name}' timed out after {DEFAULT_SEED_TIMEOUT}s: {seed_file}")
2065
+ raise typer.Exit(1)
2066
+
2067
+ # Print output
2068
+ if result.stdout:
2069
+ console.print(result.stdout)
2070
+ if result.stderr:
2071
+ console.print(result.stderr)
2072
+
2073
+ if result.returncode == 0:
2074
+ console.print(f"\n[green]OK[/green] Seed script '{name}' completed successfully\n")
2075
+ else:
2076
+ console.print(f"\n[red]ERROR[/red] Seed script '{name}' failed with exit code {result.returncode}\n")
2077
+ raise typer.Exit(result.returncode)
2078
+
2079
+
2080
+ # ── Build command ──────────────────────────────────────────────────────────
2081
+
2082
+
2083
+ @app.command()
2084
+ def build(
2085
+ config_file: Path = typer.Option(
2086
+ Path("steppy.guide.config.ts"),
2087
+ "--config",
2088
+ "-c",
2089
+ help="Path to the TypeScript guide config file",
2090
+ ),
2091
+ dry_run: bool = typer.Option(
2092
+ False,
2093
+ "--dry-run",
2094
+ help="Show what files would be created without writing",
2095
+ ),
2096
+ force: bool = typer.Option(
2097
+ False,
2098
+ "--force",
2099
+ "-f",
2100
+ help="Skip confirmation and overwrite existing files",
2101
+ ),
2102
+ pdf: bool = typer.Option(
2103
+ False,
2104
+ "--pdf",
2105
+ help="Also print each guide page to PDF and link it from the page (static target only)",
2106
+ ),
2107
+ handbook: bool = typer.Option(
2108
+ False,
2109
+ "--handbook",
2110
+ help="Also emit handbook/<locale>.html + .pdf — the whole site as one document (static target only)",
2111
+ ),
2112
+ video: bool = typer.Option(
2113
+ False,
2114
+ "--video",
2115
+ help=(
2116
+ "Also render a narrated MP4 per locale/layout for each guide declaring "
2117
+ "modes: ['video']. Requires ffmpeg. (Unrelated to 'steppy run --video', "
2118
+ "which records Playwright debugging footage of failed tests.)"
2119
+ ),
2120
+ ),
2121
+ ):
2122
+ """
2123
+ Build guide artifacts from scenario files.
2124
+
2125
+ Reads a TypeScript config (steppy.guide.config.ts), parses referenced
2126
+ scenarios, generates guide HTML fragments, and writes either:
2127
+ - Angular 21 standalone components + routes + navbar (default,
2128
+ when target='angular' or omitted), or
2129
+ - A self-contained static site with full HTML pages, an index,
2130
+ manifest.json, and copied screenshot assets (when target='static')
2131
+ suitable for GitHub Pages, Netlify, S3, or any plain static host.
2132
+
2133
+ Examples:
2134
+ steppy build
2135
+ steppy build --config path/to/steppy.guide.config.ts
2136
+ steppy build --dry-run
2137
+ steppy build --pdf --handbook
2138
+ steppy build --video
2139
+ """
2140
+ from .build import BuildAssembler, BuildError, load_build_config
2141
+
2142
+ try:
2143
+ console.print("\n[bold cyan]Building guide artifacts[/bold cyan]\n")
2144
+
2145
+ # Load config
2146
+ console.print(f"[dim]Config:[/dim] {config_file}")
2147
+ try:
2148
+ build_config = load_build_config(config_file)
2149
+ except BuildError as e:
2150
+ _error(f"{e}")
2151
+ raise typer.Exit(1)
2152
+
2153
+ console.print(f"[dim]Target:[/dim] {build_config.target.value}")
2154
+ console.print(
2155
+ f"[dim]Guides:[/dim] {len(build_config.guides)} ({', '.join(g.slug for g in build_config.guides)})"
2156
+ )
2157
+ console.print(f"[dim]Locales:[/dim] {', '.join(build_config.locales)}")
2158
+ console.print(f"[dim]Output:[/dim] {build_config.output_dir}")
2159
+ if build_config.target.value == "angular":
2160
+ console.print(f"[dim]Assets:[/dim] {build_config.assets_dir}\n")
2161
+ else:
2162
+ console.print(f"[dim]Assets:[/dim] {build_config.output_dir}/assets (self-contained)\n")
2163
+
2164
+ # Load steppy config if available (for role display names). Config is
2165
+ # optional for build, so a missing/invalid config only warns — but we
2166
+ # narrow the catch to ConfigError so real bugs surface via the handler.
2167
+ steppy_config = None
2168
+ try:
2169
+ steppy_config = load_config()
2170
+ except ConfigError as e:
2171
+ console.print(f"[yellow]WARNING[/yellow] Could not load config for role names: {e}")
2172
+ steppy_config = None
2173
+
2174
+ assembler = BuildAssembler(
2175
+ build_config,
2176
+ project_root=Path.cwd(),
2177
+ steppy_config=steppy_config,
2178
+ )
2179
+
2180
+ if dry_run:
2181
+ console.print("[yellow]DRY RUN[/yellow] — no files will be written\n")
2182
+ else:
2183
+ if pdf or handbook:
2184
+ console.print("[dim]PDF export:[/dim] rendering with headless Chromium (this takes a moment)\n")
2185
+ if video:
2186
+ console.print(
2187
+ "[dim]Video export:[/dim] rendering frame by frame with headless Chromium + ffmpeg "
2188
+ "(minutes per guide/locale/layout)\n"
2189
+ )
2190
+
2191
+ results = assembler.build_all(dry_run=dry_run, pdf=pdf, handbook=handbook, video=video)
2192
+
2193
+ # Summary
2194
+ generated = sum(1 for s in results.values() if s == "generated")
2195
+ updated = sum(1 for s in results.values() if s == "updated")
2196
+ unchanged = sum(1 for s in results.values() if s == "unchanged")
2197
+ planned = sum(1 for s in results.values() if s == "planned")
2198
+
2199
+ if dry_run:
2200
+ console.print(f"[green]OK[/green] {planned} files would be written\n")
2201
+ else:
2202
+ console.print(f"[green]OK[/green] {generated} generated, {updated} updated, {unchanged} unchanged\n")
2203
+
2204
+ except typer.Exit:
2205
+ raise
2206
+ except BuildError as e:
2207
+ _error(f"{e}")
2208
+ raise typer.Exit(1)
2209
+ except Exception as e:
2210
+ _report_unexpected(e)
2211
+ raise typer.Exit(1)
2212
+
2213
+
2214
+ # ── Auth commands ───────────────────────────────────────────────────────────
2215
+
2216
+ auth_app = typer.Typer(help="Manage authentication for different roles")
2217
+ app.add_typer(auth_app, name="auth")
2218
+
2219
+
2220
+ @auth_app.command("login")
2221
+ def auth_login(
2222
+ role: str = typer.Argument(
2223
+ ...,
2224
+ help="Role to authenticate (e.g., admin, editor, viewer)",
2225
+ ),
2226
+ ):
2227
+ """
2228
+ Interactively log in to an app for a specific role.
2229
+
2230
+ Opens a visible browser window and waits for you to log in manually.
2231
+ Saves the session for future test runs.
2232
+ """
2233
+ with _cli_errors():
2234
+ config = _load_config_or_exit()
2235
+
2236
+ # Validate role
2237
+ if role not in config.apps:
2238
+ console.print(f"[red]ERROR[/red] Unknown role: {role}")
2239
+ console.print(f"\n[yellow]Available roles:[/yellow] {', '.join(config.apps.keys())}")
2240
+ raise typer.Exit(1)
2241
+
2242
+ console.print(f"\n[bold]Interactive login for:[/bold] {config.apps[role].name} ({role})")
2243
+ console.print(f"[dim]URL:[/dim] {config.apps[role].url}\n")
2244
+
2245
+ # Launch headed browser
2246
+ from playwright.sync_api import sync_playwright
2247
+
2248
+ from .auth import interactive_login
2249
+
2250
+ with sync_playwright() as p:
2251
+ browser = p.chromium.launch(headless=False)
2252
+
2253
+ try:
2254
+ interactive_login(role, config, browser)
2255
+ console.print(f"\n[green]OK[/green] Authentication saved for {role}")
2256
+ console.print(f"[dim]Storage state saved to .auth/{role}.json[/dim]")
2257
+ except Exception as e:
2258
+ console.print(f"\n[red]ERROR[/red] Login failed: {e}")
2259
+ raise typer.Exit(1)
2260
+ finally:
2261
+ browser.close()
2262
+
2263
+
2264
+ @auth_app.command("status")
2265
+ def auth_status():
2266
+ """
2267
+ Show authentication status for all roles.
2268
+
2269
+ Displays which roles have valid cached sessions and their age.
2270
+ """
2271
+ from .auth import get_storage_state_status
2272
+
2273
+ with _cli_errors():
2274
+ config = _load_config_or_exit()
2275
+
2276
+ status = get_storage_state_status(config)
2277
+
2278
+ console.print("\n[bold]Authentication Status:[/bold]\n")
2279
+
2280
+ table = Table()
2281
+ table.add_column("Role", style="cyan")
2282
+ table.add_column("Status", style="bold")
2283
+ table.add_column("Age", style="yellow")
2284
+ table.add_column("Valid", style="green")
2285
+
2286
+ for role, info in status.items():
2287
+ if info["exists"]:
2288
+ age_str = f"{info['age_minutes']}m ago"
2289
+ valid_str = "[green]Yes[/green]" if info["valid"] else "[red]Expired[/red]"
2290
+ status_str = "[green]Cached[/green]"
2291
+ else:
2292
+ age_str = "-"
2293
+ valid_str = "[dim]No[/dim]"
2294
+ status_str = "[dim]Not logged in[/dim]"
2295
+
2296
+ table.add_row(role, status_str, age_str, valid_str)
2297
+
2298
+ console.print(table)
2299
+ console.print()
2300
+
2301
+ # Show TTL info
2302
+ ttl_minutes = config.auth.storage_state_ttl // 60
2303
+ console.print(f"[dim]Cache TTL: {ttl_minutes} minutes[/dim]")
2304
+ console.print(f"[dim]Auth directory: {config.auth.storage_state_dir}[/dim]\n")
2305
+
2306
+
2307
+ @auth_app.command("clear")
2308
+ def auth_clear(
2309
+ role: Optional[str] = typer.Argument(
2310
+ None,
2311
+ help="Role to clear (omit to clear all roles)",
2312
+ ),
2313
+ ):
2314
+ """
2315
+ Clear cached authentication state.
2316
+
2317
+ If no role is specified, clears all cached sessions.
2318
+ """
2319
+ from .auth import clear_storage_state
2320
+
2321
+ with _cli_errors():
2322
+ config = _load_config_or_exit()
2323
+
2324
+ auth_dir = Path(config.auth.storage_state_dir)
2325
+
2326
+ if role and role not in config.apps:
2327
+ console.print(f"[red]ERROR[/red] Unknown role: {role}")
2328
+ console.print(f"\n[yellow]Available roles:[/yellow] {', '.join(config.apps.keys())}")
2329
+ raise typer.Exit(1)
2330
+
2331
+ if role:
2332
+ console.print(f"\n[bold]Clearing authentication for:[/bold] {role}")
2333
+ else:
2334
+ console.print("\n[bold]Clearing all authentication states[/bold]")
2335
+
2336
+ cleared = clear_storage_state(role, auth_dir)
2337
+
2338
+ if cleared:
2339
+ for cleared_role in cleared:
2340
+ console.print(f" [green]OK[/green] Cleared: {cleared_role}")
2341
+ console.print(f"\n[green]OK[/green] Cleared {len(cleared)} role(s)\n")
2342
+ else:
2343
+ if role:
2344
+ console.print(f"\n[yellow]INFO[/yellow] No cached state found for {role}\n")
2345
+ else:
2346
+ console.print("\n[yellow]INFO[/yellow] No cached states found\n")
2347
+
2348
+
2349
+ # ── Parallel-mode subcommands ──────────────────────────────────────────────
2350
+ # These exist to support `steppy run --workers N >= 2`. The `worker` command
2351
+ # is hidden because it's invoked by the coordinator inside a worker container,
2352
+ # never directly by users. The `stacks cleanup` command is a rescue tool for
2353
+ # orphans left behind by a force-killed run.
2354
+
2355
+
2356
+ @app.command(hidden=True)
2357
+ def worker():
2358
+ """Internal — worker loop run inside a steppy parallel-mode container.
2359
+
2360
+ Reads STEPPY_WORKER_ID, STEPPY_COORD_DIR, and the usual STEPPY_* env vars,
2361
+ then loops claiming scenarios from the shared queue and invoking pytest
2362
+ until empty.
2363
+ """
2364
+ with _cli_errors():
2365
+ from .worker import worker_main
2366
+
2367
+ raise typer.Exit(worker_main())
2368
+
2369
+
2370
+ @app.command(
2371
+ "_preauth-run",
2372
+ hidden=True,
2373
+ context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
2374
+ )
2375
+ def _preauth_run(
2376
+ ctx: typer.Context,
2377
+ timeout: int = typer.Option(600, "--timeout", help="Seconds before giving up."),
2378
+ ):
2379
+ """Internal — parallel-mode pre-auth lock-wrapper, run inside a
2380
+ steppy-preauth container. Elects one winner across the worker stacks to run
2381
+ the configured ``parallel.pre_auth.command`` in-network; the losing stacks
2382
+ wait for its sentinel. The command to run is everything after ``--``."""
2383
+ with _cli_errors():
2384
+ from ._parallel.preauth import run_preauth
2385
+
2386
+ command = list(ctx.args)
2387
+ if command and command[0] == "--":
2388
+ command = command[1:]
2389
+ raise typer.Exit(run_preauth(command, timeout=timeout))
2390
+
2391
+
2392
+ stacks_app = typer.Typer(
2393
+ name="stacks",
2394
+ help="Manage parallel-mode docker stacks (rescue commands).",
2395
+ add_completion=False,
2396
+ )
2397
+ app.add_typer(stacks_app, name="stacks")
2398
+
2399
+
2400
+ @stacks_app.command("cleanup")
2401
+ def stacks_cleanup():
2402
+ """Remove orphaned `steppy-run-*` Compose projects (and their networks /
2403
+ volumes) left behind by a force-killed parallel run."""
2404
+ with _cli_errors():
2405
+ from .coordinator import cleanup_stacks
2406
+
2407
+ raise typer.Exit(cleanup_stacks())
2408
+
2409
+
2410
+ # ── Coverage map commands ──────────────────────────────────────
2411
+ # The `steppy coverage ...` sub-app lives in coverage_cli.py (FLAWS §4.2). Both
2412
+ # it and cli.py share the console / error scaffold via the leaf module
2413
+ # _cli_common (coverage_cli imports nothing from cli, so there is no import
2414
+ # cycle). Its top level imports no steppy.coverage.* (those stay lazy inside the
2415
+ # command bodies), so `import steppy.cli` stays coverage-free.
2416
+ from .coverage_cli import coverage_app # noqa: E402
2417
+
2418
+ app.add_typer(coverage_app, name="coverage")
2419
+
2420
+ # ── Flake history (`steppy flaky`) ─────────────────────────────
2421
+ # Same leaf-module shape as coverage_cli: it reads .steppy/history.jsonl through
2422
+ # the stdlib-only `steppy.history` and imports nothing from coordinator/_parallel,
2423
+ # so the no-parallel-import guard still holds.
2424
+ from .flaky_cli import register_flaky # noqa: E402
2425
+ from .migrate_cli import register_migrate # noqa: E402
2426
+
2427
+ register_flaky(app)
2428
+
2429
+ # `steppy migrate custom`. The rewrite engine under `steppy.migrate` is
2430
+ # imported inside the command body, not here.
2431
+ register_migrate(app)
2432
+
2433
+ # ── Project scaffolding (`steppy init` / `steppy update`) ──────
2434
+ # Both live in leaf modules that share the _cli_common scaffold (no cycle);
2435
+ # update_cli reuses init_cli's asset primitives.
2436
+ from .init_cli import register_init # noqa: E402
2437
+
2438
+ register_init(app)
2439
+
2440
+ from .update_cli import register_update # noqa: E402
2441
+
2442
+ register_update(app)
2443
+
2444
+ # ── Recorder (`steppy record`) ─────────────────────────────────
2445
+ # Leaf module (shares the _cli_common scaffold, no cycle); shells out to
2446
+ # `playwright codegen` and post-processes the output into a draft scenario.
2447
+ from .record_cli import register_record # noqa: E402
2448
+
2449
+ register_record(app)
2450
+
2451
+ # ── Video readiness (`steppy video check`) ─────────────────────
2452
+ # Leaf module: its top level imports no `steppy.video` / `steppy.tts` (those
2453
+ # stay lazy inside the command body), so `import steppy.cli` keeps pulling in
2454
+ # neither playwright nor httpx.
2455
+ from .video_cli import register_video # noqa: E402
2456
+
2457
+ register_video(app)
2458
+
2459
+
2460
+ # ── Results bundles (`steppy bundle`) ──────────────────────────
2461
+ # Leaf module: every `steppy.bundle` import stays lazy inside the command
2462
+ # bodies, so `import steppy.cli` pulls in neither the output/ walker nor the
2463
+ # optional `zstandard` (tests/test_no_bundle_import_on_default_path.py).
2464
+ from .bundle_cli import register_bundle # noqa: E402
2465
+
2466
+ register_bundle(app)
2467
+
2468
+
2469
+ # ── Run report site (`steppy report`) ──────────────────────────
2470
+ # Same leaf treatment: `steppy.report` builds a Jinja environment, reads tar
2471
+ # archives and walks output/, so every import of it stays inside the command body
2472
+ # (tests/test_no_report_import_on_default_path.py). Note `report` names three
2473
+ # other things in this codebase — the `report:` config block for pytest
2474
+ # artifacts, the `steppy-dev[report]` extra (pytest-html), and `steppy coverage
2475
+ # report` — none of which this command touches.
2476
+ from .report_cli import register_report # noqa: E402
2477
+
2478
+ register_report(app)
2479
+
2480
+ # In-app tours (`steppy tours`). The command body and the generate/validate
2481
+ # hooks import `steppy.tours` lazily, behind `tours.enabled`
2482
+ # (tests/test_no_tours_import_on_default_path.py).
2483
+ from .tours_cli import register_tours # noqa: E402
2484
+
2485
+ register_tours(app)
2486
+
2487
+
2488
+ if __name__ == "__main__":
2489
+ app()