steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/tours/text.py ADDED
@@ -0,0 +1,237 @@
1
+ """Per-locale tour text.
2
+
3
+ The ladder for a step and locale L is ``tour-text[L]`` → ``guide-text[L]``
4
+ (each: exact tag, then base language) → nothing. "Nothing" is legitimate: the
5
+ player then shows a generic, localised instruction chosen by the advance kind
6
+ ("Click the highlighted element"). Languages are never mixed — a French tour
7
+ never borrows the English step text — and the compiler warns per step.
8
+
9
+ A locale is emitted for a tour when at least one kept step has authored text in
10
+ it; the default locale is always emitted.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from typing import Optional
17
+ from urllib.parse import urljoin
18
+
19
+ from ..locales import base_language
20
+ from ..models import GuideText, GuideTextContent, Scenario, Step
21
+ from ..tour_spec import MAX_TEXT_CHARS, MAX_TITLE_CHARS
22
+ from .diagnostics import DiagSink
23
+ from .model import TourStepPlan
24
+ from .settings import ToursSettings, origin_of
25
+
26
+ _LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
27
+
28
+
29
+ def content_for(step: Step, locale: str) -> Optional[GuideTextContent]:
30
+ for text in (step.tour_text, step.guide_text):
31
+ if text is not None:
32
+ found = text.get(locale)
33
+ if found is not None:
34
+ return found
35
+ return None
36
+
37
+
38
+ def title_text(content: Optional[GuideTextContent]) -> tuple[Optional[str], Optional[str]]:
39
+ """``(title, body)`` for a popover.
40
+
41
+ A legacy string ``guide-text: "Do this. Then that."`` stores the first
42
+ sentence as the title *and* the whole string as the text; showing both would
43
+ repeat the sentence, so the derived title is dropped. A block with a title
44
+ and no text shows the title as the body.
45
+ """
46
+ if content is None:
47
+ return None, None
48
+ title = (content.title or "").strip() or None
49
+ text = (content.text or "").strip() or None
50
+ if text is None:
51
+ return None, title
52
+ if title is not None:
53
+ legacy = text.split(".")[0].strip() if "." in text else text.strip()
54
+ if title == legacy:
55
+ title = None
56
+ return title, text
57
+
58
+
59
+ def tour_title(scenario: Scenario, locale: str, default_locale: str) -> tuple[str, bool]:
60
+ """``(title, fell_back)`` — ``tour.title`` per locale, else the scenario title.
61
+
62
+ A per-locale map is read exact tag → base language → the default locale (and
63
+ its base) — the rung ``pick_locale`` gives guide titles — and only then falls
64
+ back to the scenario title. ``fell_back`` is True whenever the title is not
65
+ in ``locale``'s own language; without a ``tour.title`` it is False (the caller
66
+ reports that case once for the whole project).
67
+ """
68
+ wanted = scenario.tour.title if scenario.tour else None
69
+ if isinstance(wanted, str):
70
+ return wanted.strip(), False
71
+ if isinstance(wanted, dict):
72
+ own = (locale, base_language(locale))
73
+ for key in (*own, default_locale, base_language(default_locale)):
74
+ if key in wanted:
75
+ return wanted[key].strip(), key not in own
76
+ return scenario.title.strip(), True
77
+ return scenario.title.strip(), False
78
+
79
+
80
+ def emitted_locales(scenario: Scenario, steps: list[TourStepPlan], settings: ToursSettings) -> tuple[str, ...]:
81
+ out = []
82
+ for locale in settings.locales:
83
+ if locale == settings.default_locale or any(
84
+ content_for(scenario.steps[p.step_index], locale) is not None for p in steps
85
+ ):
86
+ out.append(locale)
87
+ return tuple(out)
88
+
89
+
90
+ def guide_url(scenario: Scenario, settings: ToursSettings, locale: str) -> Optional[str]:
91
+ if not settings.guide_base_url:
92
+ return None
93
+ slug = scenario.tour.guide_slug if scenario.tour else None
94
+ if slug is None and scenario.guide.enabled:
95
+ slug = scenario.slug
96
+ if not slug:
97
+ return None
98
+ return urljoin(settings.guide_base_url, f"{slug}/{locale}.html")
99
+
100
+
101
+ def leak_values(scenario: Scenario, steps: list[TourStepPlan]) -> dict[int, list[str]]:
102
+ """Literal fixture values per kept scenario step (``${…}``/``{{…}}`` are not knowable)."""
103
+ out: dict[int, list[str]] = {}
104
+ for idx in sorted({p.step_index for p in steps}):
105
+ step = scenario.steps[idx]
106
+ values: list[Optional[str]] = []
107
+ if step.action in ("fill", "select"):
108
+ values.append(step.value)
109
+ if step.action == "fill-form" and step.fields:
110
+ values.extend(step.fields.values())
111
+ if step.action == "select-option":
112
+ values.append(step.search)
113
+ if step.option and step.option.startswith("text:"):
114
+ values.append(step.option[5:].strip().strip('"').strip("'"))
115
+ values.extend(step.multiple or [])
116
+ clean = [
117
+ str(v).strip()
118
+ for v in values
119
+ if v is not None and "${" not in str(v) and "{{" not in str(v) and len(str(v).strip()) >= 2
120
+ ]
121
+ if clean:
122
+ out[idx] = clean
123
+ return out
124
+
125
+
126
+ def check_texts(
127
+ scenario: Scenario,
128
+ steps: list[TourStepPlan],
129
+ texts: dict[str, list[tuple[Optional[str], Optional[str]]]],
130
+ settings: ToursSettings,
131
+ sink: DiagSink,
132
+ ) -> None:
133
+ """Leaks, links, limits, untargeted ``tour-text`` locales and generic-text warnings."""
134
+ values = leak_values(scenario, steps)
135
+ reported: set[tuple[int, str]] = set()
136
+ for n, plan in enumerate(steps):
137
+ step = scenario.steps[plan.step_index]
138
+ for locale, rows in texts.items():
139
+ title, body = rows[n]
140
+ for label, value, limit in (("title", title, MAX_TITLE_CHARS), ("text", body, MAX_TEXT_CHARS)):
141
+ if value is not None and len(value) > limit:
142
+ sink.add(
143
+ "limit-exceeded", f"the {locale} step {label} is longer than {limit} characters", step=step
144
+ )
145
+ joined = " ".join(v for v in (title, body) if v)
146
+ for value in values.get(plan.step_index, []):
147
+ if (plan.step_index, value) in reported:
148
+ continue
149
+ if re.search(r"(?<!\w)" + re.escape(value) + r"(?!\w)", joined, re.IGNORECASE):
150
+ reported.add((plan.step_index, value))
151
+ sink.add(
152
+ "fixture-leak",
153
+ f"the {locale} text mentions the test value {value!r}; the tour would ask real users to type test data",
154
+ step=step,
155
+ hint="give the step a `tour-text` without it",
156
+ )
157
+ for _label, url in _LINK.findall(joined):
158
+ if not _link_allowed(url, settings):
159
+ sink.add(
160
+ "text-link-dropped",
161
+ f"the link {url!r} in the {locale} text will render as plain text "
162
+ "(only app routes and the guide site are linked)",
163
+ step=step,
164
+ )
165
+ for idx in sorted({p.step_index for p in steps}):
166
+ step = scenario.steps[idx]
167
+ missing = [loc for loc in texts if content_for(step, loc) is None]
168
+ if missing:
169
+ sink.add(
170
+ "generic-text",
171
+ f"no text for {', '.join(missing)}; the player will show a generic instruction",
172
+ step=step,
173
+ hint="add `guide-text` or `tour-text`",
174
+ )
175
+ for label, text in (("tour-text", step.tour_text), ("guide-text", step.guide_text)):
176
+ for key in _text_keys(text):
177
+ if key in settings.locales or any(base_language(loc) == key for loc in settings.locales):
178
+ continue
179
+ # Keys are read exactly (`GuideText.get`), while the overlay ignores case and
180
+ # -/_: a near miss silently becomes the generic hint in the compiled tour.
181
+ folded = key.casefold().replace("_", "-")
182
+ near = next(
183
+ (c for loc in settings.locales for c in (loc, base_language(loc)) if c.casefold() == folded),
184
+ None,
185
+ )
186
+ if near is not None:
187
+ sink.add(
188
+ "tour-text-locale-untargeted",
189
+ f"{label} has a {key!r} entry; locale keys are read exactly — did you mean {near!r}?",
190
+ step=step,
191
+ )
192
+ elif label == "tour-text":
193
+ sink.add(
194
+ "tour-text-locale-untargeted",
195
+ f"tour-text has a {key!r} entry, but tours do not target that locale",
196
+ step=step,
197
+ )
198
+
199
+
200
+ def _text_keys(text: Optional[GuideText]) -> list[str]:
201
+ """The locale keys a ``guide-text``/``tour-text`` block sets, in authored order."""
202
+ if text is None:
203
+ return []
204
+ keys = [n for n in ("fr", "en", "nl", "de") if getattr(text, n) is not None]
205
+ return keys + list((text.model_extra or {}).keys())
206
+
207
+
208
+ def _resolved_path(path: str) -> str:
209
+ """``path`` after dot-segment removal, as a browser resolves it (``%2e`` is a dot)."""
210
+ out: list[str] = []
211
+ segments = path.split("/")[1:]
212
+ for n, seg in enumerate(segments):
213
+ if seg.lower().replace("%2e", ".") in (".", ".."):
214
+ if seg.lower().replace("%2e", ".") == ".." and out:
215
+ out.pop()
216
+ if n == len(segments) - 1:
217
+ out.append("")
218
+ else:
219
+ out.append(seg)
220
+ return "/" + "/".join(out)
221
+
222
+
223
+ def _link_allowed(url: str, settings: ToursSettings) -> bool:
224
+ if url.startswith("/") and not url.startswith("//") and not url.startswith("/\\"):
225
+ # The overlay checks the *resolved* href: `/.//host` collapses into `//host`.
226
+ if re.search(r"[\\\s\x00-\x1f\x7f]", url):
227
+ return False
228
+ return not _resolved_path(re.split(r"[?#]", url, maxsplit=1)[0]).startswith("//")
229
+ return settings.guide_origin is not None and origin_of(url) == settings.guide_origin
230
+
231
+
232
+ def resolve_texts(
233
+ scenario: Scenario, steps: list[TourStepPlan], locales: tuple[str, ...]
234
+ ) -> dict[str, list[tuple[Optional[str], Optional[str]]]]:
235
+ return {
236
+ locale: [title_text(content_for(scenario.steps[p.step_index], locale)) for p in steps] for locale in locales
237
+ }
steppy/tours_cli.py ADDED
@@ -0,0 +1,219 @@
1
+ """``steppy tours`` — compile in-app tours — and the tour hooks of ``generate``/``validate``.
2
+
3
+ A leaf CLI module registered via ``register_tours(app)``. Everything under
4
+ :mod:`steppy.tours` is imported *inside* function bodies, so
5
+ ``import steppy.cli`` stays free of it
6
+ (``tests/test_no_tours_import_on_default_path.py``), and a project without a
7
+ ``tours:`` block never loads it at all.
8
+
9
+ ``steppy tours`` writes the tour files without touching the tests: it is the
10
+ command an app's own build pipeline runs to bundle its tours. ``steppy
11
+ generate`` does the same and also appends the CI replays.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Optional
18
+
19
+ import typer
20
+ from rich.markup import escape
21
+
22
+ from ._cli_common import _cli_errors, _error, _load_config_or_exit, console
23
+ from .models import SteppyConfig, Scenario
24
+
25
+ if TYPE_CHECKING: # pragma: no cover
26
+ from .tours.diagnostics import Diagnostic
27
+ from .tours.pipeline import TourPipeline
28
+
29
+ _STYLE = {"error": "[red]ERROR[/red]", "warning": "[yellow]WARNING[/yellow]", "info": "[dim]INFO[/dim]"}
30
+
31
+
32
+ def tours_enabled(config: SteppyConfig) -> bool:
33
+ return config.tours is not None and config.tours.enabled
34
+
35
+
36
+ def print_tour_diagnostics(diagnostics: "list[Diagnostic]", *, show_info: bool = False) -> None:
37
+ for diag in diagnostics:
38
+ if diag.severity == "info" and not show_info:
39
+ continue
40
+ console.print(f"{_STYLE[diag.severity]} {escape(diag.render())}")
41
+
42
+
43
+ def print_file_statuses(statuses: dict[str, str], *, dry_run: bool = False) -> None:
44
+ labels = {
45
+ "generated": "[green]+ GENERATED[/green]",
46
+ "updated": "[yellow]~ UPDATED[/yellow] ",
47
+ "unchanged": "[dim]= UNCHANGED[/dim]",
48
+ "removed": "[red]- REMOVED[/red] ",
49
+ "missing": "[red]! MISSING[/red] ",
50
+ }
51
+ cwd = Path.cwd()
52
+ for path, status in statuses.items():
53
+ shown = Path(path)
54
+ if cwd in shown.parents:
55
+ shown = shown.relative_to(cwd)
56
+ console.print(f" {labels.get(status, status)} {escape(str(shown))}")
57
+
58
+
59
+ def warn_opted_in_but_disabled(config: SteppyConfig, scenarios: list[Scenario]) -> None:
60
+ """One hint when scenarios ask for tours that the config does not produce."""
61
+ if tours_enabled(config):
62
+ return
63
+ wanting = [s.slug for s in scenarios if s.tour is not None and s.tour.enabled]
64
+ if wanting:
65
+ console.print(
66
+ f"[yellow]WARNING[/yellow] {len(wanting)} scenario(s) set `tour:` but in-app tours are off "
67
+ "(set tours.enabled: true in config.yaml): " + escape(", ".join(sorted(wanting)[:5]))
68
+ )
69
+
70
+
71
+ def prepare_tours_or_exit(
72
+ config: SteppyConfig,
73
+ *,
74
+ scenarios_dir: Path,
75
+ selected: Optional[list[Scenario]] = None,
76
+ all_scenarios: Optional[list[Scenario]] = None,
77
+ build_id_override: Optional[str] = None,
78
+ mode: str = "generate",
79
+ show_info: bool = False,
80
+ ) -> "TourPipeline":
81
+ """Compile tours, print diagnostics, and exit 1 on any error (nothing written yet)."""
82
+ from .tours.pipeline import prepare_tours
83
+
84
+ pipeline = prepare_tours(
85
+ config,
86
+ project_root=Path.cwd(),
87
+ scenarios_dir=scenarios_dir,
88
+ selected=selected,
89
+ all_scenarios=all_scenarios,
90
+ build_id_override=build_id_override,
91
+ mode=mode, # type: ignore[arg-type]
92
+ )
93
+ print_tour_diagnostics(pipeline.diagnostics, show_info=show_info)
94
+ if pipeline.has_errors:
95
+ _error("In-app tours have errors; nothing was written (fix them, or set tours.enabled: false)")
96
+ raise typer.Exit(1)
97
+ return pipeline
98
+
99
+
100
+ def run_tour_checks(config: SteppyConfig, scenarios: list[Scenario], *, scenarios_dir: Path) -> bool:
101
+ """``steppy validate``'s tour pass; returns True when there are errors."""
102
+ if not tours_enabled(config):
103
+ warn_opted_in_but_disabled(config, scenarios)
104
+ return False
105
+ from .tours.pipeline import prepare_tours
106
+
107
+ pipeline = prepare_tours(
108
+ config, project_root=Path.cwd(), scenarios_dir=scenarios_dir, all_scenarios=scenarios, mode="validate"
109
+ )
110
+ print_tour_diagnostics(pipeline.diagnostics)
111
+ if not pipeline.has_errors and pipeline.result is not None:
112
+ count = len(pipeline.result.tours)
113
+ console.print(f"[green]OK[/green] {count} in-app tour(s) compile")
114
+ return pipeline.has_errors
115
+
116
+
117
+ def register_tours(app: typer.Typer) -> None:
118
+ app.command(name="tours")(tours_command)
119
+
120
+
121
+ def tours_command(
122
+ scenarios_dir: Path = typer.Option(
123
+ Path("scenarios"), "--scenarios-dir", "-d", help="Directory containing scenario files"
124
+ ),
125
+ dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be written without writing anything"),
126
+ check: bool = typer.Option(
127
+ False,
128
+ "--check",
129
+ help="CI gate: exit 1 if an app-assets copy is missing or its content is stale (writes nothing; "
130
+ "compares against the build id each copy records)",
131
+ ),
132
+ build_id: Optional[str] = typer.Option(
133
+ None, "--build-id", help="Build id to stamp, overriding tours.build_id (same grammar)"
134
+ ),
135
+ explain: bool = typer.Option(False, "--explain", help="Also print why each step was left out of its tour"),
136
+ ) -> None:
137
+ """Compile in-app tours from every `tour:`-enabled scenario.
138
+
139
+ Writes <output.tours_dir>/<build-id>/ and a flat copy into every app-assets
140
+ target of the `tours:` block in config.yaml. Tests are not touched; use
141
+ `steppy generate` for the CI replays.
142
+
143
+ --check compares only the app-assets copies (the files an app commits), for
144
+ the build id each one records: the canonical tree is a gitignored build
145
+ output, and committed copies always carry an older id under git-sha.
146
+ """
147
+ with _cli_errors():
148
+ config = _load_config_or_exit()
149
+ if not tours_enabled(config):
150
+ _error("In-app tours are disabled: add a `tours:` block with `enabled: true` to config.yaml")
151
+ raise typer.Exit(1)
152
+ if not scenarios_dir.is_dir():
153
+ _error(f"Scenarios directory not found: {scenarios_dir}")
154
+ raise typer.Exit(1)
155
+ pipeline = prepare_tours_or_exit(
156
+ config,
157
+ scenarios_dir=scenarios_dir,
158
+ build_id_override=build_id,
159
+ mode="check" if check else "tours",
160
+ show_info=explain,
161
+ )
162
+ if check:
163
+ _report_check(pipeline)
164
+ return
165
+ statuses = pipeline.emit(dry_run=dry_run)
166
+ console.print(f"\n[bold]In-app tours[/bold] (build {escape(str(pipeline.build_id))})")
167
+ print_file_statuses(statuses)
168
+ counts = {k: sum(1 for s in statuses.values() if s == k) for k in ("generated", "updated", "removed")}
169
+ verb = "would be " if dry_run else ""
170
+ console.print(
171
+ f"\n[green]Summary:[/green] {len(pipeline.result.tours) if pipeline.result else 0} tour(s); "
172
+ f"{counts['generated']} {verb}generated, {counts['updated']} {verb}updated, "
173
+ f"{counts['removed']} {verb}removed"
174
+ )
175
+
176
+
177
+ def _report_check(pipeline: "TourPipeline") -> None:
178
+ """``--check``: print what differs in the app-assets copies; exit 1 when anything does."""
179
+ if not pipeline.settings.app_assets_dirs:
180
+ console.print(
181
+ "[green]OK[/green] Tours compile; no app-assets target is configured, so there are no committed "
182
+ "tour files to compare"
183
+ )
184
+ return
185
+ statuses = pipeline.statuses()
186
+ targets = len(pipeline.settings.app_assets_dirs)
187
+ console.print(f"\n[bold]In-app tours[/bold] (checking {targets} app-assets target(s))")
188
+ print_file_statuses(statuses)
189
+ for directory, recorded in pipeline.checked.items():
190
+ if recorded is None or recorded == pipeline.build_id:
191
+ continue
192
+ here = (
193
+ f"the build id here is {pipeline.build_id!r}"
194
+ if pipeline.build_id
195
+ else f"the build id cannot be resolved here ({pipeline.build_id_error})"
196
+ )
197
+ console.print(
198
+ f"[dim]NOTE[/dim] {escape(str(directory))} holds the tours of build {recorded!r} and {escape(here)}; "
199
+ "its content was compared for its own build"
200
+ )
201
+ stale = sum(1 for s in statuses.values() if s != "unchanged")
202
+ for plan in pipeline.plans:
203
+ eol_only = [
204
+ name
205
+ for name, data in plan.writes.items()
206
+ if (plan.directory / name).is_file()
207
+ and (on_disk := (plan.directory / name).read_bytes()) != data
208
+ and on_disk.replace(b"\r\n", b"\n") == data
209
+ ]
210
+ if eol_only:
211
+ console.print(
212
+ f"[yellow]HINT[/yellow] {len(eol_only)} file(s) in {escape(str(plan.directory))} differ only in line "
213
+ "endings: git converted them on checkout, and regenerating cannot fix that. Mark the folder binary "
214
+ "in the app repository's .gitattributes (`<folder>/*.json -text`)."
215
+ )
216
+ if stale:
217
+ _error(f"{stale} tour file(s) are missing or stale; run `steppy tours` and commit the result")
218
+ raise typer.Exit(1)
219
+ console.print("\n[green]OK[/green] Tour files are up to date")