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/emit.py ADDED
@@ -0,0 +1,239 @@
1
+ """Payloads, canonical bytes, and write plans for tour output.
2
+
3
+ Every byte written goes through :func:`steppy.contracts.tours.dumps_canonical`
4
+ and is re-validated by the producer contract before anything touches disk, so
5
+ a compiler bug can never ship a file the overlay would reject.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from ..contracts.tours import (
17
+ TourDataInvalid,
18
+ check_consistency,
19
+ dumps_canonical,
20
+ parse_tour_file,
21
+ parse_tours_manifest,
22
+ sri_sha384,
23
+ )
24
+ from ..naming import TOUR_FILE_KIND, TOURS_MANIFEST_FILENAME, TOURS_MANIFEST_KIND
25
+ from ..tour_spec import MAX_MANIFEST_BYTES, MAX_TOUR_FILE_BYTES, TOUR_SCHEMA_VERSION, tour_file_name
26
+ from .model import CompiledTour, TourCompileResult
27
+ from .paths import TourPathError, atomic_write_bytes, check_tour_file, is_tour_file, listed_files, read_marker
28
+ from .settings import ToursSettings
29
+
30
+ Status = Literal["generated", "updated", "unchanged", "removed", "planned", "missing"]
31
+
32
+
33
+ class TourContractBug(RuntimeError):
34
+ """The compiler produced something its own contract rejects (always a bug)."""
35
+
36
+
37
+ @dataclass
38
+ class TourArtifacts:
39
+ build_id: str
40
+ files: dict[str, bytes] # tour file name -> bytes
41
+ manifest: bytes
42
+
43
+ def all_files(self) -> dict[str, bytes]:
44
+ return {**self.files, TOURS_MANIFEST_FILENAME: self.manifest}
45
+
46
+
47
+ @dataclass
48
+ class FileStatus:
49
+ path: Path
50
+ status: Status
51
+
52
+
53
+ @dataclass
54
+ class DirPlan:
55
+ directory: Path
56
+ statuses: list[FileStatus] = field(default_factory=list)
57
+ writes: dict[str, bytes] = field(default_factory=dict) # name -> bytes, tour files then manifest
58
+ removals: list[str] = field(default_factory=list)
59
+
60
+
61
+ def tour_file_payload(tour: CompiledTour, locale: str, build_id: str) -> dict:
62
+ steps = []
63
+ for n, plan in enumerate(tour.steps):
64
+ title, text = tour.step_texts[locale][n]
65
+ step = {"id": plan.step_id, "target": plan.target, "advance": {"on": plan.advance}}
66
+ if plan.wait_for:
67
+ step["waitFor"] = plan.wait_for
68
+ if title:
69
+ step["title"] = title
70
+ if text:
71
+ step["text"] = text
72
+ if plan.placement and plan.placement != "auto":
73
+ step["placement"] = plan.placement
74
+ if plan.layouts:
75
+ step["layouts"] = list(plan.layouts)
76
+ steps.append(step)
77
+ payload = {
78
+ "schema": TOUR_SCHEMA_VERSION,
79
+ "kind": TOUR_FILE_KIND,
80
+ "id": tour.id,
81
+ "buildId": build_id,
82
+ "locale": locale,
83
+ "title": tour.titles[locale],
84
+ "entry": {"route": tour.entry_route} if tour.entry_route else None,
85
+ "steps": steps,
86
+ }
87
+ if locale in tour.guide_urls:
88
+ payload["guideUrl"] = tour.guide_urls[locale]
89
+ return payload
90
+
91
+
92
+ def render_artifacts(result: TourCompileResult, settings: ToursSettings, build_id: str) -> TourArtifacts:
93
+ """Serialize every tour file and the manifest, then self-check them."""
94
+ files: dict[str, bytes] = {}
95
+ entries = []
96
+ used_layouts: set[str] = set()
97
+ for tour in result.tours:
98
+ integrity = {}
99
+ names = {}
100
+ for locale in tour.locales:
101
+ data = dumps_canonical(tour_file_payload(tour, locale, build_id))
102
+ name = tour_file_name(tour.id, locale)
103
+ if len(data) > MAX_TOUR_FILE_BYTES:
104
+ raise TourDataInvalid(f"{name} is {len(data)} bytes; the player accepts at most {MAX_TOUR_FILE_BYTES}")
105
+ files[name] = data
106
+ names[locale] = name
107
+ integrity[locale] = sri_sha384(data)
108
+ for plan in tour.steps:
109
+ used_layouts.update(plan.layouts or ())
110
+ entries.append(
111
+ {
112
+ "id": tour.id,
113
+ "role": tour.role,
114
+ "title": {loc: tour.titles[loc] for loc in tour.locales},
115
+ "entry": {"route": tour.entry_route} if tour.entry_route else None,
116
+ "steps": len(tour.steps),
117
+ "files": names,
118
+ "integrity": integrity,
119
+ }
120
+ )
121
+ manifest = {
122
+ "schema": TOUR_SCHEMA_VERSION,
123
+ "kind": TOURS_MANIFEST_KIND,
124
+ "buildId": build_id,
125
+ "defaultLocale": settings.default_locale,
126
+ "locales": list(settings.locales),
127
+ "testIdAttribute": settings.test_id_attribute,
128
+ "tours": entries,
129
+ }
130
+ if settings.guide_origin and any(t.guide_urls for t in result.tours):
131
+ manifest["guideOrigin"] = settings.guide_origin
132
+ if used_layouts:
133
+ manifest["layouts"] = {name: q for name, q in settings.layouts if name in used_layouts}
134
+ manifest_bytes = dumps_canonical(manifest)
135
+ if len(manifest_bytes) > MAX_MANIFEST_BYTES:
136
+ raise TourDataInvalid(f"the manifest is {len(manifest_bytes)} bytes; the player accepts {MAX_MANIFEST_BYTES}")
137
+ try:
138
+ parsed = parse_tours_manifest(json.loads(manifest_bytes))
139
+ for data in files.values():
140
+ parse_tour_file(json.loads(data))
141
+ except TourDataInvalid as exc:
142
+ raise TourContractBug(f"compiled tour data violates the v1 contract: {exc}") from exc
143
+ problems = check_consistency(parsed, files)
144
+ if problems:
145
+ raise TourContractBug("compiled tour data is inconsistent: " + "; ".join(problems))
146
+ return TourArtifacts(build_id=build_id, files=files, manifest=manifest_bytes)
147
+
148
+
149
+ def plan_dir(directory: Path, artifacts: TourArtifacts, *, prune: bool = True) -> DirPlan:
150
+ """Byte-compare against disk; plan writes and (marker-checked) stale removals.
151
+
152
+ Raises :class:`TourPathError` — at planning time, before anything is written —
153
+ when a write or a removal would hit a file that is not a tour file (the
154
+ manifest itself is vouched for by :func:`read_marker`).
155
+ """
156
+ plan = DirPlan(directory=directory)
157
+ previous = read_marker(directory) if directory.is_dir() else None
158
+ for name, data in [*sorted(artifacts.files.items()), (TOURS_MANIFEST_FILENAME, artifacts.manifest)]:
159
+ path = directory / name
160
+ if path.is_file():
161
+ if path.read_bytes() == data:
162
+ plan.statuses.append(FileStatus(path, "unchanged"))
163
+ continue
164
+ if name != TOURS_MANIFEST_FILENAME:
165
+ check_tour_file(path, "overwrite")
166
+ plan.statuses.append(FileStatus(path, "updated"))
167
+ elif path.exists() or path.is_symlink():
168
+ raise TourPathError(f"{path} exists and is not a regular file; refusing to replace it")
169
+ else:
170
+ plan.statuses.append(FileStatus(path, "generated"))
171
+ plan.writes[name] = data
172
+ if prune:
173
+ for name in sorted(listed_files(previous) - set(artifacts.files)):
174
+ path = directory / name
175
+ # A case-only rename (``Fr-be`` -> ``fr-BE``) on a case-insensitive
176
+ # filesystem: the old name is the file about to be rewritten.
177
+ if any(_same_file(path, directory / w) for w in artifacts.files if w.casefold() == name.casefold()):
178
+ continue
179
+ if path.is_file() and not path.is_symlink():
180
+ check_tour_file(path, "delete")
181
+ plan.removals.append(name)
182
+ plan.statuses.append(FileStatus(path, "removed"))
183
+ return plan
184
+
185
+
186
+ def apply_dir(plan: DirPlan) -> None:
187
+ """Tour files first, the manifest last (so a reader never sees a manifest without its files)."""
188
+ names = [n for n in plan.writes if n != TOURS_MANIFEST_FILENAME]
189
+ if TOURS_MANIFEST_FILENAME in plan.writes:
190
+ names.append(TOURS_MANIFEST_FILENAME)
191
+ for name in names:
192
+ atomic_write_bytes(plan.directory / name, plan.writes[name])
193
+ for name in plan.removals:
194
+ (plan.directory / name).unlink(missing_ok=True)
195
+
196
+
197
+ def _same_file(a: Path, b: Path) -> bool:
198
+ try:
199
+ return os.path.samefile(a, b)
200
+ except OSError:
201
+ return False
202
+
203
+
204
+ def sibling_build_dirs(tours_root: Path, keep: str) -> list[Path]:
205
+ """Other ``<build-id>/`` trees under the canonical root that carry our marker."""
206
+ out = []
207
+ if not tours_root.is_dir():
208
+ return out
209
+ for child in sorted(tours_root.iterdir()):
210
+ if child.name == keep or not child.is_dir() or child.is_symlink():
211
+ continue
212
+ # The tree just written, reached under a case variant of its id on a
213
+ # case-insensitive filesystem.
214
+ if _same_file(child, tours_root / keep):
215
+ continue
216
+ try:
217
+ if read_marker(child) is not None:
218
+ out.append(child)
219
+ except ValueError:
220
+ continue
221
+ return out
222
+
223
+
224
+ def remove_build_dir(directory: Path) -> list[Path]:
225
+ """Remove a marker-checked build tree: only its manifest and the files it lists."""
226
+ manifest = read_marker(directory)
227
+ removed = []
228
+ for name in sorted(listed_files(manifest)):
229
+ path = directory / name
230
+ if path.is_file() and not path.is_symlink() and is_tour_file(path):
231
+ path.unlink()
232
+ removed.append(path)
233
+ (directory / TOURS_MANIFEST_FILENAME).unlink(missing_ok=True)
234
+ removed.append(directory / TOURS_MANIFEST_FILENAME)
235
+ try:
236
+ directory.rmdir()
237
+ except OSError:
238
+ pass
239
+ return removed
steppy/tours/ids.py ADDED
@@ -0,0 +1,40 @@
1
+ """Tour and step ids.
2
+
3
+ Step ids travel in every ``tour-step`` event an app forwards to analytics, so
4
+ they must survive edits: they are never positional. A targeted step is named
5
+ after its test-id (stable as long as the element is); a centred step after its
6
+ heading. Repeats get ``-2``, ``-3``… in order.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ import unicodedata
13
+
14
+ from ..tour_spec import STEP_ID_PATTERN
15
+ from .model import TourStepPlan
16
+
17
+ _MAX_ID = 128
18
+
19
+
20
+ def slugify_heading(title: str) -> str:
21
+ """``"7b. Ouvrir la fiche"`` → ``"7b-ouvrir-la-fiche"`` (ASCII, ≤ 64 chars)."""
22
+ ascii_text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
23
+ slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")[:64].strip("-")
24
+ return slug or "step"
25
+
26
+
27
+ def assign_step_ids(steps: list[TourStepPlan]) -> None:
28
+ """Set ``step_id`` on every plan, deduplicating in order against every id so far."""
29
+ used: set[str] = set()
30
+ for step in steps:
31
+ base = step.target or slugify_heading(step.step_title)
32
+ if not re.fullmatch(STEP_ID_PATTERN, base):
33
+ base = slugify_heading(base)
34
+ candidate, n = base, 1
35
+ while candidate in used:
36
+ n += 1
37
+ suffix = f"-{n}"
38
+ candidate = base[: _MAX_ID - len(suffix)] + suffix
39
+ used.add(candidate)
40
+ step.step_id = candidate
@@ -0,0 +1,399 @@
1
+ """One scenario → its tour steps and the replay's per-step dispositions.
2
+
3
+ Pure and locale-independent: the same scenario yields the same steps whatever
4
+ locales are configured; text is resolved afterwards (``text.py``).
5
+
6
+ Order of decisions:
7
+
8
+ 1. **Role.** ``tour.role``, else the scenario's only role.
9
+ 2. **Start point.** With ``tour.entry``: just after the navigate step whose
10
+ target matches it — plus the run of ``only-in: guide`` steps right after an
11
+ ``only-in: test`` navigate, which is the same journey by clicking (the "guide
12
+ twin"). Without: just after the first navigate following the last
13
+ ``login``. Steps before the start point are *setup*: not in the tour, but
14
+ the replay runs them (in test mode) to get there.
15
+ 3. **Mode.** ``only-in: test`` steps are dropped from the tour range (the guide
16
+ path runs instead); ``only-in: guide`` steps are kept; other roles' steps
17
+ run in the replay only.
18
+ 4. **Candidates** per action (``actions.infer_candidate``), then annotations.
19
+ 5. **Gates.** A hidden gate becomes the ``waitFor`` of the next kept step —
20
+ unless a skipped step in between may change the page
21
+ (``actions.page_change``), which drops it: the element may be gone by then,
22
+ and a step waiting for it would never show. ``gate-stale`` says so when a
23
+ kept step loses its wait that way.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from typing import Optional
29
+
30
+ from ..models import Scenario, Step
31
+ from ..testids import TargetExtraction
32
+ from ..tour_spec import MAX_STEPS, TARGETED_ADVANCE_KINDS
33
+ from .actions import Candidate, infer_candidate, natural_target, page_change, replayable
34
+ from .diagnostics import DiagSink
35
+ from .model import ReplayEntry, ReplayTourStep, ScenarioInference, TourStepPlan
36
+ from .routes import app_base, bind, matches, parse_entry_pattern, route_from_target, target_segments
37
+ from .settings import ToursSettings
38
+
39
+
40
+ def _annotated(step: Step) -> bool:
41
+ return bool(step.tour_text or step.tour_advance or step.tour_placement or step.tour)
42
+
43
+
44
+ def resolve_role(scenario: Scenario, configured_roles: set[str], sink: DiagSink) -> Optional[str]:
45
+ roles = sorted(scenario.get_roles())
46
+ wanted = scenario.tour.role if scenario.tour else None
47
+ if wanted:
48
+ if wanted not in roles or wanted not in configured_roles:
49
+ sink.add(
50
+ "tour-role-unknown",
51
+ f"tour.role {wanted!r} is not a role this scenario uses ({', '.join(roles) or 'none'})",
52
+ )
53
+ return None
54
+ return wanted
55
+ if len(roles) == 1:
56
+ return roles[0]
57
+ if not roles:
58
+ sink.add("tour-no-role", "the scenario has no role, so there is no app to run a tour in")
59
+ return None
60
+ sink.add(
61
+ "tour-role-required",
62
+ f"the scenario uses several roles ({', '.join(roles)}); say whose app the tour runs in",
63
+ hint="set `tour: {role: <role>}`",
64
+ )
65
+ return None
66
+
67
+
68
+ def infer_scenario(scenario: Scenario, settings: ToursSettings, sink: DiagSink) -> Optional[ScenarioInference]:
69
+ """Infer one opted-in scenario; ``None`` when an error makes the tour meaningless."""
70
+ role = resolve_role(scenario, set(settings.app_urls), sink)
71
+ if role is None:
72
+ return None
73
+ steps = scenario.steps
74
+ base = app_base(settings.app_urls.get(role))
75
+ if base.unresolved:
76
+ sink.add(
77
+ "app-url-unresolved",
78
+ f"apps.{role}.url is not an absolute URL at compile time; routes are read relative to '/'",
79
+ )
80
+
81
+ def in_role(step: Step) -> bool:
82
+ return step.role in (role, None)
83
+
84
+ navs = [
85
+ i
86
+ for i, s in enumerate(steps)
87
+ if s.action == "navigate" and not s.reload and s.role == role and s.only_in != "guide" and s.target
88
+ ]
89
+ logins = [i for i, s in enumerate(steps) if s.action == "login" and in_role(s)]
90
+ last_login = logins[-1] if logins else -1
91
+
92
+ entry_route: Optional[str] = None
93
+ entry_params: tuple[tuple[str, str], ...] = ()
94
+ k: Optional[int] = None
95
+
96
+ explicit = scenario.tour.entry if scenario.tour else None
97
+ if explicit:
98
+ pattern = parse_entry_pattern(explicit)
99
+ if pattern.error:
100
+ sink.add("entry-invalid", pattern.error)
101
+ return None
102
+ candidates = []
103
+ for i in navs:
104
+ segs = target_segments(steps[i].target, base)
105
+ if segs is not None and matches(pattern.segments, segs):
106
+ candidates.append((i, segs))
107
+ if not candidates:
108
+ seen = ", ".join(repr(steps[i].target) for i in navs) or "none"
109
+ sink.add(
110
+ "entry-no-match",
111
+ f"tour.entry {explicit!r} matches no navigate step (navigations: {seen})",
112
+ hint="the tour starts right after the navigate step that opens the entry route",
113
+ )
114
+ return None
115
+ after = [c for c in candidates if c[0] > last_login]
116
+ k, segs = (after or candidates)[0]
117
+ entry_route = pattern.route
118
+ entry_params = bind(pattern.segments, segs)
119
+ else:
120
+ after = [i for i in navs if i > last_login]
121
+ k = (after or navs or [None])[0]
122
+ if k is None:
123
+ sink.add(
124
+ "no-entry",
125
+ "the scenario never navigates, so the tour has no entry route and the replay would start on a blank page",
126
+ hint="add a navigate step, or set tours.replay: false",
127
+ )
128
+ else:
129
+ result = route_from_target(steps[k].target, base)
130
+ if result.error:
131
+ sink.add("entry-invalid", result.error, step=steps[k], hint="set tour.entry to the route pattern")
132
+ return None
133
+ if result.query_dropped:
134
+ sink.add("entry-query-dropped", "the entry's query string is ignored by in-app tours", step=steps[k])
135
+ if result.id_like:
136
+ sink.add(
137
+ "entry-id-segment",
138
+ f"the entry route {result.route!r} contains fixture-looking ids ({', '.join(result.id_like)})",
139
+ step=steps[k],
140
+ hint="set tour.entry with a :param, e.g. /invoices/:invoiceId",
141
+ )
142
+ entry_route = result.route
143
+ entry_params = tuple(
144
+ (seg[1:], raw) for seg, raw in zip(result.segments, result.raw_segments) if seg.startswith(":")
145
+ )
146
+
147
+ start = 0 if k is None else k + 1
148
+ if k is not None and steps[k].only_in == "test":
149
+ while start < len(steps) and steps[start].only_in == "guide":
150
+ start += 1
151
+
152
+ kept: list[TourStepPlan] = []
153
+ # (entry index, list of plans) so replay tour_steps can be filled after ids are assigned
154
+ entries: list[tuple[int, str, str, list[TourStepPlan], bool]] = []
155
+ pending_gate: Optional[str] = None
156
+ # (gate, the step that dropped it, why): reported only if a kept step would have waited for it.
157
+ stale: Optional[tuple[str, Step, str]] = None
158
+ attr = settings.test_id_attribute
159
+
160
+ for i, step in enumerate(steps):
161
+ if i < start:
162
+ disposition = "drop" if step.only_in == "guide" else "setup"
163
+ entries.append((i, disposition, "before-entry", [], False))
164
+ if _annotated(step):
165
+ sink.add(
166
+ "annotation-outside-tour",
167
+ "tour annotations on a step before the tour starts have no effect",
168
+ step=step,
169
+ hint="remove them, or move tour.entry earlier",
170
+ )
171
+ continue
172
+ if step.only_in == "test":
173
+ entries.append((i, "drop", "only-in-test", [], False))
174
+ if _annotated(step):
175
+ sink.add("annotation-outside-tour", "tour annotations on an `only-in: test` step", step=step)
176
+ continue
177
+ if not in_role(step):
178
+ entries.append((i, "run", "other-role", [], False))
179
+ if _annotated(step):
180
+ sink.add("annotation-outside-tour", f"tour annotations on another role's step ({step.role})", step=step)
181
+ continue
182
+
183
+ cand = _apply_annotations(step, infer_candidate(step, attr), settings, sink)
184
+ if cand.kind == "gate":
185
+ pending_gate, stale = cand.gate, None
186
+ entries.append((i, "run", "gate", [], False))
187
+ elif cand.kind == "kept":
188
+ plans: list[TourStepPlan] = []
189
+ for j, sub in enumerate(cand.subs):
190
+ tid = sub.extraction.test_id if sub.extraction is not None and sub.extraction.ok else None
191
+ if tid and sub.extraction.reduced_from:
192
+ sink.add(
193
+ "reduced-to-host",
194
+ f"'{sub.selector}' points at the host test-id {tid!r}",
195
+ step=step,
196
+ )
197
+ wait_for = pending_gate if j == 0 and pending_gate and pending_gate != tid else None
198
+ if j == 0 and stale is not None and stale[0] != tid:
199
+ gate, cause, why = stale
200
+ number = step.step_number or i + 1
201
+ sink.add(
202
+ "gate-stale",
203
+ f"{why}, so step {number} no longer waits for {gate!r} (an earlier check)",
204
+ step=cause,
205
+ hint=f"add a `wait-for` on a test-id after this step if step {number} should wait for the page",
206
+ )
207
+ advance = cand.advance or "manual"
208
+ plans.append(
209
+ TourStepPlan(
210
+ step_index=i,
211
+ step_number=step.step_number or i + 1,
212
+ step_title=step.step_title or "",
213
+ action=step.action,
214
+ target=tid,
215
+ source_selector=sub.selector if tid else None,
216
+ advance=advance,
217
+ wait_for=wait_for,
218
+ placement=step.tour_placement if step.tour_placement and tid else None,
219
+ layouts=tuple(step.layouts) if step.layouts else None,
220
+ field=sub.field,
221
+ )
222
+ )
223
+ if step.tour_placement and step.tour_placement != "auto" and not any(p.target for p in plans):
224
+ sink.add("placement-ignored", "a centred step has no side to place the popover on", step=step)
225
+ kept.extend(plans)
226
+ pending_gate, stale = None, None
227
+ entries.append((i, "tour", "tour-step", plans, False))
228
+ else:
229
+ entries.append((i, "run", cand.reason or step.action, [], cand.reloads_page))
230
+ if cand.reason == "conditional":
231
+ sink.add(
232
+ "conditional-skipped",
233
+ "guarded by `when:`/`optional:`, so it may not happen; a linear tour leaves it out",
234
+ step=step,
235
+ )
236
+ elif cand.reason == "gate-dropped":
237
+ sink.add(
238
+ "gate-dropped",
239
+ "this check's selector is not a test-id, so the next tour step does not wait for it",
240
+ step=step,
241
+ hint="point the check at a test-id to make it a gate",
242
+ )
243
+ elif cand.reason != "tour-skip":
244
+ sink.add("step-skipped", f"left out of the tour ({cand.reason or step.action})", step=step)
245
+ why = page_change(step) if pending_gate else None
246
+ if why:
247
+ stale, pending_gate = (pending_gate, step, why), None
248
+ for code, message, hint in cand.notes:
249
+ sink.add(code, message, step=step, hint=hint)
250
+ for gate in cand.trailing_gates:
251
+ pending_gate, stale = gate, None
252
+
253
+ if not kept:
254
+ sink.add(
255
+ "no-kept-steps",
256
+ "the tour has no steps after the start point",
257
+ hint="check tour.entry, or mark steps `tour: keep`",
258
+ )
259
+ return None
260
+ if len(kept) > MAX_STEPS:
261
+ sink.add("limit-exceeded", f"the tour has {len(kept)} steps; the player accepts at most {MAX_STEPS}")
262
+ return None
263
+ mapped = set(settings.layout_map)
264
+ for plan in kept:
265
+ if plan.layouts and not set(plan.layouts) <= mapped:
266
+ sink.add(
267
+ "layouts-unmapped",
268
+ f"this step only runs on layouts {list(plan.layouts)}, which tours.layouts does not map to media queries",
269
+ step=steps[plan.step_index],
270
+ hint="add them under tours.layouts in config.yaml",
271
+ )
272
+ if sink.has_errors:
273
+ return None
274
+
275
+ from .ids import assign_step_ids
276
+
277
+ assign_step_ids(kept)
278
+ index_of = {id(p): n for n, p in enumerate(kept)}
279
+ replay_entries = []
280
+ for i, disposition, reason, plans, reloads in entries:
281
+ tour_steps = tuple(
282
+ ReplayTourStep(
283
+ step_id=p.step_id,
284
+ index=index_of[id(p)],
285
+ target=p.target,
286
+ wait_for=p.wait_for,
287
+ advance=p.advance,
288
+ layouts=p.layouts,
289
+ field=p.field,
290
+ source_selector=p.source_selector,
291
+ )
292
+ for p in plans
293
+ )
294
+ replay_entries.append(
295
+ ReplayEntry(
296
+ step_number=steps[i].step_number or i + 1,
297
+ disposition=disposition, # type: ignore[arg-type]
298
+ reason=reason,
299
+ tour_steps=tour_steps,
300
+ reloads_page=reloads,
301
+ )
302
+ )
303
+ return ScenarioInference(
304
+ role=role,
305
+ entry_route=entry_route,
306
+ entry_params=entry_params,
307
+ entry_step_index=k,
308
+ start_index=start,
309
+ steps=kept,
310
+ entries=replay_entries,
311
+ )
312
+
313
+
314
+ def _apply_annotations(step: Step, cand: Candidate, settings: ToursSettings, sink: DiagSink) -> Candidate:
315
+ """Fold ``tour`` / ``tour-advance`` / ``tour-placement`` / ``tour-text`` into the candidate."""
316
+ attr = settings.test_id_attribute
317
+ extra = bool(step.tour_text or step.tour_advance or step.tour_placement)
318
+
319
+ if cand.kind == "skip" and cand.reason == "conditional":
320
+ if step.tour == "keep":
321
+ sink.add(
322
+ "keep-not-allowed",
323
+ "a step guarded by `when:` or `optional:` may not happen, and a linear tour cannot branch",
324
+ step=step,
325
+ )
326
+ elif extra:
327
+ sink.add("annotation-on-skipped-step", "tour annotations on a `when:`/`optional:` step", step=step)
328
+ return cand
329
+
330
+ if step.tour == "skip":
331
+ if extra:
332
+ sink.add("skip-and-annotations", "`tour: skip` together with other tour annotations", step=step)
333
+ return Candidate("skip", reason="tour-skip", trailing_gates=cand.trailing_gates, reloads_page=cand.reloads_page)
334
+
335
+ if step.tour == "keep":
336
+ if cand.kind != "kept":
337
+ ext, selector = natural_target(step, attr)
338
+ tid_ok = ext is not None and ext.ok
339
+ forced = Candidate(
340
+ "kept",
341
+ advance=step.tour_advance or "manual",
342
+ family="info",
343
+ centred_ok=True,
344
+ trailing_gates=cand.trailing_gates,
345
+ )
346
+ forced.subs.append(_sub(ext, selector, tid_ok))
347
+ cand = forced
348
+ else:
349
+ for sub in cand.subs:
350
+ if sub.extraction is not None and not sub.extraction.ok:
351
+ sub.extraction = None
352
+ cand.centred_ok = True
353
+ if step.tour_advance:
354
+ cand.advance = step.tour_advance
355
+ elif all(sub.extraction is None for sub in cand.subs):
356
+ cand.advance = "manual"
357
+ else:
358
+ if cand.kind != "kept":
359
+ if extra:
360
+ sink.add(
361
+ "annotation-on-skipped-step",
362
+ f"tour annotations on a step the tour leaves out ({cand.reason or step.action})",
363
+ step=step,
364
+ hint="add `tour: keep` to show it",
365
+ )
366
+ return cand
367
+ for sub in cand.subs:
368
+ if sub.extraction is not None and not sub.extraction.ok and not cand.centred_ok:
369
+ sink.add(
370
+ "target-not-test-id",
371
+ f"'{sub.selector}' is not a test-id: {sub.extraction.explain()}",
372
+ step=step,
373
+ )
374
+ if step.tour_advance:
375
+ cand.advance = step.tour_advance
376
+
377
+ if step.tour_advance and not replayable(cand, step.tour_advance):
378
+ sink.add(
379
+ "advance-not-replayable",
380
+ f"`tour-advance: {step.tour_advance}` cannot be satisfied by this step's own `{step.action}`, "
381
+ "so the tour replay would wait forever",
382
+ step=step,
383
+ )
384
+ has_target = any(sub.extraction is not None and sub.extraction.ok for sub in cand.subs)
385
+ # A non-centred step without a target already got `target-not-test-id`.
386
+ if cand.centred_ok and not has_target and cand.advance in TARGETED_ADVANCE_KINDS:
387
+ sink.add(
388
+ "advance-needs-target",
389
+ f"advance '{cand.advance}' needs a target, and this step has none",
390
+ step=step,
391
+ hint="use `tour-advance: manual`",
392
+ )
393
+ return cand
394
+
395
+
396
+ def _sub(ext: Optional[TargetExtraction], selector: Optional[str], ok: bool):
397
+ from .actions import Sub
398
+
399
+ return Sub(ext if ok else None, selector if ok else None)