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/replay.py ADDED
@@ -0,0 +1,234 @@
1
+ """``ReplayPlan`` → what ``test_scenario.py.j2`` renders as ``test_<slug>__tour``.
2
+
3
+ The replay is the scenario followed the way a *user* follows its tour, inside
4
+ the scenario's own test file (every selection, parallel, lastfailed and JUnit
5
+ path is keyed on that file):
6
+
7
+ 1. **Setup** — the steps before the tour's start point, in test mode
8
+ (``only-in: guide`` steps dropped), e.g. login and the entry navigation.
9
+ 2. **Start** — the tour, through the overlay's element API.
10
+ 3. **Range** — each tour step: assert the popover shows that step anchored on
11
+ its target → perform the scenario's *own* action for it (the same codegen
12
+ branch the scenario test uses, captures removed) → make sure the tour moved
13
+ on. Range steps that are not tour steps run exactly as in the scenario.
14
+ 4. **Complete** — ``tour-complete`` fired and no ``tour-error`` did.
15
+
16
+ Every action body comes from the ``step_body`` template macro, so a replay can
17
+ never drift from the test it mirrors. This module only decides *which* steps,
18
+ in *which* order, with *which* tour checks around them.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Literal, Optional
25
+
26
+ from ..models import SteppyConfig, Scenario, Step
27
+ from .model import ReplayEntry, ReplayPlan, ReplayTourStep
28
+
29
+ #: Actions that act on whatever has focus: clicking the popover's Next button
30
+ #: before them would steal it, so the Next click is deferred past them.
31
+ FOCUS_DEPENDENT_ACTIONS = frozenset({"keyboard", "custom", "call"})
32
+
33
+ ItemKind = Literal["setup", "plain", "omitted", "start", "tour", "deferred-ensure", "complete"]
34
+
35
+
36
+ class ReplayPlanError(ValueError):
37
+ """The compiler handed over a plan that does not line up with the scenario (a bug)."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ReplayCheck:
42
+ step_id: str
43
+ selector: Optional[str]
44
+ advance: str
45
+ ensure: Literal["inline", "deferred"] = "inline"
46
+
47
+
48
+ @dataclass
49
+ class ReplayItem:
50
+ kind: ItemKind
51
+ comment: str = ""
52
+ step: Optional[Step] = None
53
+ #: tour items: one (step copy, check) per sub-step (fill-form has several)
54
+ parts: list[tuple[Step, ReplayCheck]] = field(default_factory=list)
55
+ step_id: Optional[str] = None # deferred-ensure
56
+
57
+
58
+ @dataclass
59
+ class ReplayRender:
60
+ function_name: str
61
+ role: str
62
+ roles: list[str]
63
+ auth: bool
64
+ spec: dict
65
+ params: list[tuple[str, str]]
66
+ items: list[ReplayItem]
67
+
68
+
69
+ def _copy(step: Step, **update) -> Step:
70
+ """No captures in a replay; ``only-in`` is resolved statically, layouts/when stay runtime guards."""
71
+ return step.model_copy(update={"screenshot": None, "only_in": None, **update})
72
+
73
+
74
+ def _check(plan: ReplayPlan, scenario: Scenario) -> None:
75
+ if len(plan.entries) != len(scenario.steps):
76
+ raise ReplayPlanError(f"{scenario.slug}: {len(plan.entries)} replay entries for {len(scenario.steps)} steps")
77
+ seen_range = False
78
+ expected_index = 0
79
+ for idx, (step, entry) in enumerate(zip(scenario.steps, plan.entries)):
80
+ if entry.step_number != (step.step_number or idx + 1):
81
+ raise ReplayPlanError(f"{scenario.slug}: entry {idx} is for step {entry.step_number}")
82
+ in_setup = idx < plan.start_index
83
+ if in_setup and seen_range:
84
+ raise ReplayPlanError(f"{scenario.slug}: setup step {entry.step_number} after the tour started")
85
+ seen_range = seen_range or not in_setup
86
+ if entry.disposition == "tour":
87
+ if in_setup or step.role not in (plan.role, None) or step.only_in == "test":
88
+ raise ReplayPlanError(f"{scenario.slug}: step {entry.step_number} cannot be a tour step")
89
+ fields = list(step.fields or {})
90
+ for ts in entry.tour_steps:
91
+ if ts.index != expected_index:
92
+ raise ReplayPlanError(f"{scenario.slug}: tour step {ts.step_id} is out of order")
93
+ expected_index += 1
94
+ if ts.field is not None and ts.field not in fields:
95
+ raise ReplayPlanError(f"{scenario.slug}: {ts.field!r} is not a field of step {entry.step_number}")
96
+ if expected_index != plan.tour_step_count:
97
+ raise ReplayPlanError(f"{scenario.slug}: {expected_index} tour steps, plan says {plan.tour_step_count}")
98
+
99
+
100
+ def _parts(step: Step, entry: ReplayEntry) -> list[tuple[Step, ReplayCheck]]:
101
+ def check(ts: ReplayTourStep) -> ReplayCheck:
102
+ return ReplayCheck(ts.step_id, ts.source_selector, ts.advance)
103
+
104
+ if step.action == "fill-form" and any(ts.field for ts in entry.tour_steps):
105
+ parts = []
106
+ last = len(entry.tour_steps) - 1
107
+ for n, ts in enumerate(entry.tour_steps):
108
+ only = {ts.field: step.fields[ts.field]}
109
+ parts.append(
110
+ (
111
+ _copy(
112
+ step,
113
+ fields=only,
114
+ wait_after=step.wait_after if n == last else None,
115
+ expect=step.expect if n == last else None,
116
+ ),
117
+ check(ts),
118
+ )
119
+ )
120
+ return parts
121
+ (ts,) = entry.tour_steps
122
+ return [(_copy(step), check(ts))]
123
+
124
+
125
+ def build_replay(scenario: Scenario, plan: ReplayPlan, config: SteppyConfig) -> ReplayRender:
126
+ _check(plan, scenario)
127
+ steps = scenario.steps
128
+ items: list[ReplayItem] = []
129
+ started = False
130
+ pending: Optional[tuple[str, Step]] = None # deferred Next click: (step id, step for its guard)
131
+
132
+ def flush_pending():
133
+ nonlocal pending
134
+ if pending is not None:
135
+ step_id, guard_step = pending
136
+ items.append(ReplayItem("deferred-ensure", step=guard_step, step_id=step_id))
137
+ pending = None
138
+
139
+ for idx, (step, entry) in enumerate(zip(steps, plan.entries)):
140
+ title = step.step_title or ""
141
+ if idx < plan.start_index:
142
+ if entry.disposition == "drop":
143
+ items.append(ReplayItem("omitted", comment=f"Step {entry.step_number}: {title} — guide-only, skipped"))
144
+ else:
145
+ items.append(
146
+ ReplayItem("setup", comment=f"Step {entry.step_number}: {title} — setup", step=_copy(step))
147
+ )
148
+ continue
149
+ if not started:
150
+ route = plan.entry_route or "the current page"
151
+ items.append(ReplayItem("start", comment=f"Tour '{plan.tour_id}' starts here (entry {route})"))
152
+ started = True
153
+ if entry.disposition == "drop":
154
+ items.append(
155
+ ReplayItem("omitted", comment=f"Step {entry.step_number}: {title} — test-only, not in the tour")
156
+ )
157
+ continue
158
+ if entry.disposition != "tour":
159
+ items.append(ReplayItem("plain", comment=f"Step {entry.step_number}: {title}", step=_copy(step)))
160
+ continue
161
+ flush_pending()
162
+ parts = _parts(step, entry)
163
+ last_step, last_check = parts[-1]
164
+ if last_check.advance in ("input", "manual") and _focus_dependent_follows(steps, plan.entries, idx):
165
+ parts[-1] = (
166
+ last_step,
167
+ ReplayCheck(last_check.step_id, last_check.selector, last_check.advance, "deferred"),
168
+ )
169
+ pending = (last_check.step_id, _copy(step))
170
+ ids = ", ".join(f"'{c.step_id}'" for _s, c in parts)
171
+ items.append(
172
+ ReplayItem(
173
+ "tour",
174
+ comment=f"Step {entry.step_number}: {title} — tour step {ids} ({parts[0][1].advance})",
175
+ step=_copy(step),
176
+ parts=parts,
177
+ )
178
+ )
179
+ if not started:
180
+ items.append(ReplayItem("start", comment=f"Tour '{plan.tour_id}' starts here"))
181
+ flush_pending()
182
+ items.append(ReplayItem("complete", comment=f"Tour '{plan.tour_id}' must complete"))
183
+
184
+ from ..generator import SteppyGenerator
185
+
186
+ timeout = SteppyGenerator._resolve_timeout(None, scenario, config)
187
+ tour_steps = [ts for e in plan.entries for ts in e.tour_steps]
188
+ spec = {
189
+ "id": plan.tour_id,
190
+ "role": plan.role,
191
+ "default_locale": plan.default_locale,
192
+ "attribute": plan.test_id_attribute,
193
+ "timeout_ms": int(timeout),
194
+ "entry": (
195
+ {"route": plan.entry_route, "params": [list(p) for p in plan.entry_params]} if plan.entry_route else None
196
+ ),
197
+ "layouts": dict(plan.layouts),
198
+ "steps": [
199
+ {
200
+ "id": ts.step_id,
201
+ "target": ts.target,
202
+ "wait_for": ts.wait_for,
203
+ "advance": ts.advance,
204
+ "layouts": list(ts.layouts) if ts.layouts else None,
205
+ "scenario_step": e.step_number,
206
+ "action": steps[i].action,
207
+ "title": steps[i].step_title or "",
208
+ "field": ts.field,
209
+ "selector": ts.source_selector,
210
+ }
211
+ for i, e in enumerate(plan.entries)
212
+ for ts in e.tour_steps
213
+ ],
214
+ }
215
+ assert len(spec["steps"]) == len(tour_steps)
216
+ slug_ = scenario.slug.replace("-", "_")
217
+ return ReplayRender(
218
+ function_name=f"test_{slug_}__tour",
219
+ role=plan.role,
220
+ roles=sorted(scenario.get_roles()),
221
+ auth=scenario.auth,
222
+ spec=spec,
223
+ params=list(plan.entry_params),
224
+ items=items,
225
+ )
226
+
227
+
228
+ def _focus_dependent_follows(steps: list[Step], entries: tuple[ReplayEntry, ...], idx: int) -> bool:
229
+ for step, entry in zip(steps[idx + 1 :], entries[idx + 1 :]):
230
+ if entry.disposition == "tour":
231
+ return False
232
+ if entry.disposition == "run" and step.action in FOCUS_DEPENDENT_ACTIONS:
233
+ return True
234
+ return False
steppy/tours/routes.py ADDED
@@ -0,0 +1,175 @@
1
+ """Entry routes: from a scenario's navigate target to a tour's route pattern.
2
+
3
+ A tour may declare where it starts (``tour.entry: /invoices/:invoiceId``) or
4
+ inherit it from the scenario's navigation. Either way the stored route is
5
+ **base-relative** — the path *inside* the app, with the app URL's own path
6
+ prefix removed — because the overlay matches it against the document's base
7
+ URL (``<base href>``) and the app's router navigates to it.
8
+
9
+ Navigate targets are resolved the way the generated tests resolve ``goto()``:
10
+ relative to ``apps.<role>.url``. A ``{{var}}`` segment becomes a ``:var``
11
+ parameter; literal segments are percent-encoded the way ``location.pathname``
12
+ spells them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional
20
+ from urllib.parse import quote, unquote, urlsplit
21
+
22
+ from ..tour_spec import is_dot_segment, is_route
23
+ from .settings import origin_of
24
+
25
+ #: Segments that look like fixture ids: the tour cannot know them in production.
26
+ ID_LIKE_RE = re.compile(r"\d+|[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}|(?=.*\d)[0-9a-fA-F]{8,}")
27
+ _VAR_SEG = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]{0,63})\s*\}\}")
28
+ _PARAM_SEG = re.compile(r":([A-Za-z_][A-Za-z0-9_]{0,63})")
29
+ _SAFE = "._~!$&()*+,;=@-%"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AppBase:
34
+ origin: Optional[str]
35
+ path: str # always ends with "/"
36
+ unresolved: bool = False
37
+
38
+
39
+ def app_base(app_url: Optional[str]) -> AppBase:
40
+ """Origin and path prefix of an app URL; ``unresolved`` when it has no usable host."""
41
+ if not app_url or "${" in app_url:
42
+ return AppBase(None, "/", unresolved=True)
43
+ origin = origin_of(app_url)
44
+ if origin is None:
45
+ return AppBase(None, "/", unresolved=True)
46
+ path = urlsplit(app_url).path or "/"
47
+ return AppBase(origin, path if path.endswith("/") else path + "/")
48
+
49
+
50
+ @dataclass
51
+ class RouteResult:
52
+ """A route pattern, or the reason there is none, plus non-fatal notes."""
53
+
54
+ segments: Optional[list[str]] = None # pattern segments (":param" or encoded literal)
55
+ raw_segments: list[str] = field(default_factory=list) # the target's own segments (for binding)
56
+ error: Optional[str] = None
57
+ query_dropped: bool = False
58
+ id_like: list[str] = field(default_factory=list)
59
+
60
+ @property
61
+ def route(self) -> Optional[str]:
62
+ if self.segments is None:
63
+ return None
64
+ return "/" + "/".join(self.segments)
65
+
66
+
67
+ def _target_path(raw: str, base: AppBase) -> tuple[Optional[str], Optional[str], bool]:
68
+ """``(base-relative path, error, query_dropped)`` for a navigate target."""
69
+ text = raw.strip()
70
+ if "#" in text:
71
+ return None, "hash routes are not supported by in-app tours (v1)", False
72
+ if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", text) or text.startswith("//"):
73
+ if base.origin is None:
74
+ return None, "an absolute URL cannot be checked against an unresolved app URL — set tour.entry", False
75
+ if origin_of(text) != base.origin:
76
+ return None, f"{text!r} is not on the app's origin ({base.origin}) — a tour cannot navigate there", False
77
+ parts = urlsplit(text)
78
+ path, query = parts.path or "/", parts.query
79
+ else:
80
+ path, _, query = text.partition("?")
81
+ if not path.startswith("/"):
82
+ path = base.path + path
83
+ if base.path != "/":
84
+ prefix = base.path.rstrip("/")
85
+ if path == prefix:
86
+ path = "/"
87
+ elif path.startswith(base.path):
88
+ path = "/" + path[len(base.path) :]
89
+ else:
90
+ return None, f"{text!r} is outside the app's base path {base.path}", bool(query)
91
+ if len(path) > 1 and path.endswith("/"):
92
+ path = path.rstrip("/") or "/"
93
+ return path, None, bool(query)
94
+
95
+
96
+ def _segments(path: str) -> list[str]:
97
+ return [] if path == "/" else path.split("/")[1:]
98
+
99
+
100
+ def _literal(seg: str) -> Optional[str]:
101
+ if not seg or is_dot_segment(seg):
102
+ return None
103
+ return quote(seg, safe=_SAFE)
104
+
105
+
106
+ def route_from_target(raw: str, base: AppBase) -> RouteResult:
107
+ """Inferred entry: the navigate target as a pattern (``{{var}}`` → ``:var``)."""
108
+ path, error, query = _target_path(raw, base)
109
+ if error:
110
+ return RouteResult(error=error, query_dropped=query)
111
+ result = RouteResult(query_dropped=query, segments=[])
112
+ for seg in _segments(path):
113
+ result.raw_segments.append(seg)
114
+ var = _VAR_SEG.fullmatch(seg)
115
+ if var:
116
+ result.segments.append(":" + var.group(1))
117
+ continue
118
+ if "{{" in seg:
119
+ return RouteResult(error=f"segment {seg!r} is only partly a {{{{var}}}}; make it a whole segment")
120
+ lit = _literal(seg)
121
+ if lit is None:
122
+ return RouteResult(error=f"segment {seg!r} is not a valid path segment")
123
+ if ID_LIKE_RE.fullmatch(unquote(seg)):
124
+ result.id_like.append(seg)
125
+ result.segments.append(lit)
126
+ if not is_route(result.route):
127
+ return RouteResult(error=f"{result.route!r} is not a route the in-app player accepts")
128
+ return result
129
+
130
+
131
+ def parse_entry_pattern(text: str) -> RouteResult:
132
+ """Explicit ``tour.entry``: already base-relative; ``:name`` and ``{{name}}`` are params."""
133
+ path = text.strip()
134
+ if len(path) > 1:
135
+ path = path.rstrip("/") or "/"
136
+ result = RouteResult(segments=[])
137
+ for seg in _segments(path):
138
+ param = _PARAM_SEG.fullmatch(seg) or _VAR_SEG.fullmatch(seg)
139
+ if param:
140
+ result.segments.append(":" + param.group(1))
141
+ continue
142
+ lit = _literal(seg)
143
+ if lit is None or "{{" in seg:
144
+ return RouteResult(error=f"tour.entry segment {seg!r} is not a valid path segment")
145
+ result.segments.append(lit)
146
+ if not is_route(result.route):
147
+ return RouteResult(error=f"tour.entry {text!r} is not a route the in-app player accepts")
148
+ return result
149
+
150
+
151
+ def target_segments(raw: str, base: AppBase) -> Optional[list[str]]:
152
+ """The raw base-relative segments of a navigate target, or None when unusable."""
153
+ path, error, _ = _target_path(raw, base)
154
+ return None if error else _segments(path)
155
+
156
+
157
+ def matches(pattern: list[str], target: list[str]) -> bool:
158
+ """Segment-wise match: ``:p`` takes any segment; a literal must equal a literal."""
159
+ if len(pattern) != len(target):
160
+ return False
161
+ for pat, seg in zip(pattern, target):
162
+ if pat.startswith(":"):
163
+ continue
164
+ if _VAR_SEG.fullmatch(seg) or pat != _literal(seg):
165
+ return False
166
+ return True
167
+
168
+
169
+ def bind(pattern: list[str], target: list[str]) -> tuple[tuple[str, str], ...]:
170
+ """``(param, template)`` pairs: the target's text for each ``:param`` (``{{var}}`` kept)."""
171
+ out = []
172
+ for pat, seg in zip(pattern, target):
173
+ if pat.startswith(":"):
174
+ out.append((pat[1:], seg if _VAR_SEG.fullmatch(seg) else unquote(seg)))
175
+ return tuple(out)
@@ -0,0 +1,164 @@
1
+ """A resolved, immutable view of ``config.yaml``'s ``tours:`` block."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from urllib.parse import urlsplit
10
+
11
+ from ..captures import DEFAULT_TOURS_DIR, resolve_output_dir
12
+ from ..locales import base_language
13
+ from ..models import DEFAULT_LOCALE, DEFAULT_LOCALES, SteppyConfig
14
+ from ..tour_spec import CHROME_STRING_LOCALES
15
+ from .diagnostics import Diagnostic
16
+
17
+
18
+ def origin_of(url: str) -> Optional[str]:
19
+ """``scheme://host[:port]`` with default ports dropped, or None if not http(s)."""
20
+ parts = urlsplit(url)
21
+ if parts.scheme not in ("http", "https") or not parts.hostname:
22
+ return None
23
+ default = {"http": 80, "https": 443}[parts.scheme]
24
+ port = parts.port
25
+ return f"{parts.scheme}://{parts.hostname.lower()}" + (f":{port}" if port and port != default else "")
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ToursSettings:
30
+ enabled: bool
31
+ default_opt_in: bool
32
+ build_id_raw: str
33
+ locales: tuple[str, ...]
34
+ default_locale: str
35
+ test_id_attribute: str
36
+ guide_base_url: Optional[str]
37
+ guide_origin: Optional[str]
38
+ layouts: tuple[tuple[str, str], ...]
39
+ replay: bool
40
+ project_root: Path
41
+ tours_root: Path
42
+ app_assets_dirs: tuple[Path, ...]
43
+ publishes_guide: bool
44
+ app_urls: dict = field(default_factory=dict) # role -> configured app URL
45
+ screenshot_widths: dict = field(default_factory=dict) # layout -> viewport width
46
+
47
+ @property
48
+ def layout_map(self) -> dict[str, str]:
49
+ return dict(self.layouts)
50
+
51
+ @classmethod
52
+ def from_config(cls, config: SteppyConfig, project_root: Path) -> "ToursSettings":
53
+ tours = config.tours
54
+ if tours is None:
55
+ raise ValueError("config.yaml has no tours: block")
56
+ root = Path(project_root).resolve()
57
+ locales = tuple(tours.locales or config.locales or DEFAULT_LOCALES)
58
+ default_locale = tours.default_locale or config.default_locale or DEFAULT_LOCALE
59
+ base = tours.guide_base_url
60
+ if base is not None and "${" in base:
61
+ base = None # unresolved: no guide links (reported by settings_diagnostics)
62
+ app_dirs = tuple(resolve_output_dir(root, t.path, t.path) for t in tours.targets if t.type == "app-assets")
63
+ return cls(
64
+ enabled=tours.enabled,
65
+ default_opt_in=tours.default,
66
+ build_id_raw=tours.build_id,
67
+ locales=locales,
68
+ default_locale=default_locale,
69
+ test_id_attribute=tours.test_id_attribute,
70
+ guide_base_url=base,
71
+ guide_origin=origin_of(base) if base else None,
72
+ layouts=tuple((tours.layouts or {}).items()),
73
+ replay=tours.replay,
74
+ project_root=root,
75
+ tours_root=resolve_output_dir(root, config.output.tours_dir, DEFAULT_TOURS_DIR),
76
+ app_assets_dirs=app_dirs,
77
+ publishes_guide=any(t.type == "guide" for t in tours.targets),
78
+ app_urls={role: app.url for role, app in config.apps.items()},
79
+ screenshot_widths={name: layout.width for name, layout in config.screenshot.layouts.items()},
80
+ )
81
+
82
+
83
+ # ── Layout media-query sanity check ───────────────────────────────────────────
84
+
85
+ _TERM = re.compile(r"\(\s*(min|max)-width\s*:\s*(\d+(?:\.\d+)?)px\s*\)")
86
+ _PREFIX = re.compile(r"^(?:only\s+screen|screen|all)\s+and\s+", re.I)
87
+
88
+
89
+ def parse_simple_media(query: str) -> Optional[tuple[float, float]]:
90
+ """``(lo, hi)`` widths a simple ``(min|max)-width: Npx`` query accepts, else None."""
91
+ text = _PREFIX.sub("", query.strip())
92
+ lo, hi = 0.0, float("inf")
93
+ for part in re.split(r"\s+and\s+", text):
94
+ m = _TERM.fullmatch(part.strip())
95
+ if not m:
96
+ return None
97
+ value = float(m.group(2))
98
+ if m.group(1) == "min":
99
+ lo = max(lo, value)
100
+ else:
101
+ hi = min(hi, value)
102
+ return lo, hi
103
+
104
+
105
+ def settings_diagnostics(config: SteppyConfig, settings: ToursSettings) -> list[Diagnostic]:
106
+ """Config-level checks that need more than one block at a time."""
107
+ out: list[Diagnostic] = []
108
+ tours = config.tours
109
+ if tours is not None and tours.guide_base_url and "${" in tours.guide_base_url:
110
+ out.append(
111
+ Diagnostic(
112
+ "warning",
113
+ "app-url-unresolved",
114
+ f"tours.guide_base_url {tours.guide_base_url!r} references an unset environment variable; "
115
+ "tour files get no 'Open the full guide' link",
116
+ )
117
+ )
118
+ for locale in settings.locales:
119
+ if base_language(locale) not in CHROME_STRING_LOCALES:
120
+ out.append(
121
+ Diagnostic(
122
+ "warning",
123
+ "chrome-strings-missing",
124
+ f"tour locale {locale!r} has no built-in overlay strings (buttons, progress, generic hints); "
125
+ f"the overlay shows them in English next to {locale} text unless your app sets the host's "
126
+ f"`strings` for {locale!r}",
127
+ hint=f"built in: {', '.join(CHROME_STRING_LOCALES)}",
128
+ )
129
+ )
130
+ widths = settings.screenshot_widths
131
+ for name, query in settings.layouts:
132
+ bounds = parse_simple_media(query)
133
+ width = widths.get(name)
134
+ if bounds is None or width is None:
135
+ out.append(
136
+ Diagnostic(
137
+ "info",
138
+ "layout-media-unchecked",
139
+ f"tours.layouts[{name!r}] = {query!r} is not a simple width query; "
140
+ "the tour replay will still check it in the browser",
141
+ )
142
+ )
143
+ continue
144
+ lo, hi = bounds
145
+ if not lo <= width <= hi:
146
+ out.append(
147
+ Diagnostic(
148
+ "error",
149
+ "layout-media-mismatch",
150
+ f"tours.layouts[{name!r}] = {query!r} does not match the {name} viewport width ({width}px) "
151
+ "from screenshot.layouts, so the overlay would skip that layout's steps where CI runs them",
152
+ )
153
+ )
154
+ for other, other_width in widths.items():
155
+ if other != name and other in dict(settings.layouts) and lo <= other_width <= hi:
156
+ out.append(
157
+ Diagnostic(
158
+ "warning",
159
+ "layout-media-overlap",
160
+ f"tours.layouts[{name!r}] = {query!r} also matches the {other} viewport ({other_width}px); "
161
+ f"{name}-only steps would show on {other} screens too",
162
+ )
163
+ )
164
+ return out