steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
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()
|