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/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")
|