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
@@ -0,0 +1,402 @@
1
+ """How each scenario action becomes (or does not become) a tour step.
2
+
3
+ ``ACTION_TOUR_KIND`` classifies **every** action in ``models.VALID_ACTIONS``;
4
+ ``tests/test_tour_action_table.py`` fails when a new action is added without a
5
+ row here — a missing row would silently drop that step from every tour.
6
+
7
+ Kinds:
8
+
9
+ ``click`` / ``change`` / ``input`` the user acts on one target; the tour points
10
+ at it and advances on that kind of event
11
+ ``fill-form`` one ``input`` sub-step per field
12
+ ``upload`` a file input, usually hidden behind a trigger
13
+ ``check`` an assertion / wait: a hidden gate, or an info
14
+ step when it carries text
15
+ ``cursor`` ``custom`` / ``call``: only a ``guide-cursor``
16
+ says what the user does
17
+ ``unguided`` the user must act but there is no test-id to
18
+ point at (confirm dialogs)
19
+ ``navigation`` changes the page; never part of a tour
20
+ ``skip`` setup, waits, hovers, key presses
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import keyword
26
+ import re
27
+ from dataclasses import dataclass, field
28
+ from typing import Literal, Optional
29
+
30
+ from ..assertions import AssertionSyntaxError, parse_assertion
31
+ from ..models import Step, expect_checks
32
+ from ..testids import TargetExtraction, extract_test_id
33
+
34
+ ACTION_TOUR_KIND: dict[str, str] = {
35
+ "click": "click",
36
+ "expand": "click",
37
+ "toggle": "change",
38
+ "select": "change",
39
+ "select-option": "change",
40
+ "pick-date": "change",
41
+ "pick-time": "change",
42
+ "fill": "input",
43
+ "fill-date": "input",
44
+ "fill-form": "fill-form",
45
+ "upload": "upload",
46
+ "assert": "check",
47
+ "wait-for": "check",
48
+ "custom": "cursor",
49
+ "call": "cursor",
50
+ "confirm": "unguided",
51
+ "navigate": "navigation",
52
+ "login": "navigation",
53
+ "hover": "skip",
54
+ "scroll": "skip",
55
+ "keyboard": "skip",
56
+ "wait": "skip",
57
+ "wait-for-text": "skip",
58
+ "capture": "skip",
59
+ }
60
+
61
+ #: Assertion forms that imply "this element is on screen" — the only ones that
62
+ #: can gate a step. ``hidden``/``not-visible``/``stays-hidden``/``count 0`` say
63
+ #: the opposite; ``toast``/``row-contains``/``url-contains`` have no target.
64
+ POSITIVE_FORMS = frozenset({"visible", "text", "contains", "icontains", "not-empty"})
65
+
66
+ #: ``custom`` code that acts on the page: skipping it inside a tour leaves the
67
+ #: user with an action nobody told them to do.
68
+ INTERACTIVE_CODE_RE = re.compile(
69
+ r"\.(?:click|dblclick|fill|type|press|press_sequentially|check|uncheck|select_option|set_input_files"
70
+ r"|dispatch_event)\(|dispatchEvent\("
71
+ )
72
+
73
+ #: Actions that leave the page as they found it. A gate (a hidden check) still
74
+ #: holds after one of them runs unshown inside a tour; after any other — a
75
+ #: skipped click or fill, a confirm, a key press, a navigate — the element it
76
+ #: waited for may be gone. An action added later counts as page-changing until
77
+ #: it is listed here, which is the safe side: a dropped gate only lets the next
78
+ #: step show sooner, a stale one keeps it from ever showing.
79
+ PAGE_NEUTRAL_ACTIONS = frozenset({"assert", "wait-for", "wait", "wait-for-text", "hover", "scroll", "capture"})
80
+
81
+ #: Calls ``custom`` code can make and still leave the page alone. Code calling
82
+ #: anything else — ``click``, ``goto``, ``evaluate``, a project helper — may
83
+ #: change it. An allowlist on purpose: on the consumer corpora, a denylist
84
+ #: (``INTERACTIVE_CODE_RE`` plus the navigations) missed form values set
85
+ #: through ``evaluate`` and clicks inside helpers.
86
+ _READ_ONLY_CALLS = frozenset(
87
+ (
88
+ # Playwright: locating, reading, waiting, asserting; the ``to_*`` / ``not_to_*`` matchers and the
89
+ # ``expect_*`` waiters (whose ``with`` block holds the call that acts) are matched by prefix
90
+ "locator get_by_role get_by_text get_by_label get_by_placeholder get_by_alt_text get_by_title "
91
+ "get_by_test_id frame_locator filter nth and_ or_ all count is_visible is_hidden is_enabled is_disabled "
92
+ "is_checked is_editable text_content inner_text inner_html input_value get_attribute all_text_contents "
93
+ "all_inner_texts bounding_box title wait_for wait_for_timeout wait_for_selector wait_for_load_state "
94
+ "wait_for_url wait_for_function expect "
95
+ # The page-neutral actions above, written as code
96
+ "hover scroll_into_view_if_needed "
97
+ # Python builtins, and the list / str / re / datetime / time calls code uses to compute values
98
+ "print len str int float bool list dict set tuple range sorted min max any abs round enumerate zip "
99
+ "isinstance globals insert append extend strip lower upper split join replace format startswith endswith "
100
+ "removeprefix removesuffix isdigit get keys items values compile sub search match fullmatch findall group "
101
+ "now today date datetime timedelta weekday strftime isoformat time monotonic sleep"
102
+ ).split()
103
+ )
104
+ _READ_ONLY_PREFIXES = ("to_", "not_to_", "expect_")
105
+
106
+ _STRING_RE = re.compile(r"'''.*?'''|\"\"\".*?\"\"\"|'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"", re.S)
107
+ _COMMENT_RE = re.compile(r"#[^\n]*")
108
+ _CALL_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(")
109
+
110
+ #: Advance kinds the scenario's own action can satisfy in the replay. A
111
+ #: ``tour-advance`` outside this set would make the replay wait forever.
112
+ _REPLAYABLE = {
113
+ "click": frozenset({"click", "manual", "visible"}),
114
+ "input": frozenset({"input", "change", "manual"}),
115
+ "change": frozenset({"change", "click", "manual"}),
116
+ "upload": frozenset({"change", "manual"}),
117
+ "info": frozenset({"manual", "visible"}),
118
+ }
119
+
120
+
121
+ @dataclass
122
+ class Sub:
123
+ """One target of a kept candidate (fill-form has several)."""
124
+
125
+ extraction: Optional[TargetExtraction]
126
+ selector: Optional[str]
127
+ field: Optional[str] = None
128
+
129
+
130
+ @dataclass
131
+ class Candidate:
132
+ kind: Literal["kept", "gate", "skip"]
133
+ reason: str = ""
134
+ advance: Optional[str] = None
135
+ subs: list[Sub] = field(default_factory=list)
136
+ #: A failed/absent target is acceptable (centred step) — info steps, text-only custom.
137
+ centred_ok: bool = False
138
+ gate: Optional[str] = None
139
+ #: Which replayable-advance family the step belongs to (see ``_REPLAYABLE``).
140
+ family: str = "info"
141
+ reloads_page: bool = False
142
+ #: Deferred notes: emitted only if the step is not explicitly ``tour: skip``-ped.
143
+ notes: list[tuple[str, str, Optional[str]]] = field(default_factory=list)
144
+ trailing_gates: list[str] = field(default_factory=list)
145
+
146
+
147
+ def has_text(step: Step) -> bool:
148
+ for text in (step.tour_text, step.guide_text):
149
+ if text is None:
150
+ continue
151
+ values = [getattr(text, n) for n in ("fr", "en", "nl", "de")] + list((text.model_extra or {}).values())
152
+ if any(v is not None for v in values):
153
+ return True
154
+ return False
155
+
156
+
157
+ def _extract(step: Step, selector: Optional[str], attr: str, *, scoped: bool = True) -> TargetExtraction:
158
+ if scoped:
159
+ return extract_test_id(selector, attr, within=step.within, match=step.match)
160
+ return extract_test_id(selector, attr)
161
+
162
+
163
+ def _checks(step: Step, attr: str, *, scoped: bool) -> list[tuple[Optional[str], Optional[str], bool]]:
164
+ """``(test-id, selector, positive)`` per assertion of the step."""
165
+ out = []
166
+ for check in expect_checks(step.expect):
167
+ try:
168
+ parsed = parse_assertion(check)
169
+ except AssertionSyntaxError:
170
+ continue
171
+ selector = parsed.get("selector")
172
+ if selector is None:
173
+ continue
174
+ kind = parsed["type"]
175
+ positive = kind in POSITIVE_FORMS
176
+ if kind == "count":
177
+ positive = _int(parsed.get("count")) >= 1
178
+ elif kind == "count-cmp":
179
+ n, cmp = _int(parsed.get("count")), parsed.get("comparator")
180
+ positive = (cmp in (">=", "==") and n >= 1) or (cmp == ">" and n >= 0)
181
+ ext = _extract(step, selector, attr, scoped=scoped)
182
+ out.append((ext.test_id, selector, positive))
183
+ return out
184
+
185
+
186
+ def _int(value) -> int:
187
+ try:
188
+ return int(value)
189
+ except (TypeError, ValueError):
190
+ return 0
191
+
192
+
193
+ def trailing_gates(step: Step, attr: str) -> list[str]:
194
+ """Positive, extractable ``expect:`` checks riding on a non-assert action.
195
+
196
+ Extracted **without** ``within``/``match``: the trailing expect uses a plain
197
+ locator in the generated test, whatever scopes the action itself.
198
+ """
199
+ if step.action in ("assert", "wait-for"):
200
+ return []
201
+ return [tid for tid, _sel, positive in _checks(step, attr, scoped=False) if positive and tid]
202
+
203
+
204
+ def natural_target(step: Step, attr: str) -> tuple[Optional[TargetExtraction], Optional[str]]:
205
+ """The element a step is "about", for ``tour: keep`` on a skipped step."""
206
+ if step.selector:
207
+ return _extract(step, step.selector, attr), step.selector
208
+ if step.guide_cursor is not None:
209
+ return extract_test_id(step.guide_cursor.target, attr), step.guide_cursor.target
210
+ for tid, selector, _positive in _checks(step, attr, scoped=True):
211
+ return (extract_test_id(selector, attr, within=step.within, match=step.match), selector)
212
+ if step.fields:
213
+ key = next(iter(step.fields))
214
+ return extract_test_id(key, attr), key
215
+ return None, None
216
+
217
+
218
+ def infer_candidate(step: Step, attr: str) -> Candidate:
219
+ """What inference alone makes of ``step`` (annotations are applied later)."""
220
+ action = step.action
221
+ kind = ACTION_TOUR_KIND[action]
222
+ gates = trailing_gates(step, attr)
223
+
224
+ if step.when or step.optional:
225
+ return Candidate("skip", reason="conditional", trailing_gates=gates)
226
+
227
+ if kind in ("click", "change", "input"):
228
+ cand = Candidate("kept", advance=kind, family=kind, trailing_gates=gates)
229
+ cand.subs.append(Sub(_extract(step, step.selector, attr), step.selector))
230
+ if action == "expand":
231
+ cand.notes.append(
232
+ (
233
+ "may-not-change",
234
+ "an `expand` step only clicks when the section is closed, so its click may never happen",
235
+ "use `tour-advance: manual`, or `tour: skip`",
236
+ )
237
+ )
238
+ if action == "toggle" and step.state:
239
+ cand.notes.append(
240
+ (
241
+ "may-not-change",
242
+ "a `toggle` with `state:` does nothing when the switch is already in that state",
243
+ "use `tour-advance: manual`, or `tour: skip`",
244
+ )
245
+ )
246
+ return cand
247
+
248
+ if kind == "fill-form":
249
+ if not step.fields:
250
+ return Candidate("skip", reason="fill-form-empty", trailing_gates=gates)
251
+ cand = Candidate("kept", advance="input", family="input", trailing_gates=gates)
252
+ for key in step.fields:
253
+ cand.subs.append(Sub(extract_test_id(key, attr), key, field=key))
254
+ return cand
255
+
256
+ if kind == "upload":
257
+ if step.guide_cursor is not None:
258
+ target = step.guide_cursor.target
259
+ cand = Candidate("kept", advance="manual", family="upload", trailing_gates=gates)
260
+ cand.subs.append(Sub(extract_test_id(target, attr), target))
261
+ return cand
262
+ ext = _extract(step, step.selector, attr)
263
+ cand = Candidate("kept", advance="change", family="upload", trailing_gates=gates)
264
+ cand.subs.append(Sub(ext, step.selector))
265
+ if ext.ok and ext.reduced_from is None:
266
+ cand.notes.append(
267
+ (
268
+ "upload-target-may-be-hidden",
269
+ "the tour points at the file input itself, which apps usually hide behind a styled button",
270
+ "add `guide-cursor: {behavior: click, target: <the visible trigger>}`",
271
+ )
272
+ )
273
+ return cand
274
+
275
+ if kind == "check":
276
+ if action == "wait-for":
277
+ positive = step.state in (None, "visible")
278
+ ext = _extract(step, step.selector, attr)
279
+ checks = [(ext.test_id, step.selector, positive)]
280
+ else:
281
+ checks = _checks(step, attr, scoped=True)
282
+ if has_text(step):
283
+ cand = Candidate("kept", advance="manual", family="info", centred_ok=True)
284
+ first = next(((tid, sel) for tid, sel, pos in checks if pos and tid), None)
285
+ if first is not None:
286
+ cand.subs.append(Sub(TargetExtraction(test_id=first[0]), first[1]))
287
+ cand.gate = first[0]
288
+ else:
289
+ cand.subs.append(Sub(None, None))
290
+ return cand
291
+ usable = [tid for tid, _sel, pos in checks if pos and tid]
292
+ if usable:
293
+ return Candidate("gate", gate=usable[-1], reason="gate")
294
+ dropped = any(pos and not tid for tid, _sel, pos in checks)
295
+ return Candidate("skip", reason="gate-dropped" if dropped else "check")
296
+
297
+ if kind == "cursor":
298
+ cursor = step.guide_cursor
299
+ if cursor is not None and cursor.behavior in ("click", "type"):
300
+ advance = "click" if cursor.behavior == "click" else "input"
301
+ cand = Candidate("kept", advance=advance, family=advance, trailing_gates=gates)
302
+ cand.subs.append(Sub(extract_test_id(cursor.target, attr), cursor.target))
303
+ return cand
304
+ if cursor is not None:
305
+ return Candidate("skip", reason="hover-cursor", trailing_gates=gates)
306
+ if has_text(step):
307
+ cand = Candidate("kept", advance="manual", family="info", centred_ok=True, trailing_gates=gates)
308
+ cand.subs.append(Sub(None, None))
309
+ cand.notes.append(
310
+ (
311
+ "unguided-action",
312
+ "this step has text but no target, so the tour shows it as a centred note while the user "
313
+ "must still do whatever the code does",
314
+ "add `guide-cursor: {behavior: click, target: <test-id selector>}`",
315
+ )
316
+ )
317
+ return cand
318
+ cand = Candidate("skip", reason=action, trailing_gates=gates)
319
+ if action == "custom" and step.code and INTERACTIVE_CODE_RE.search(step.code):
320
+ cand.notes.append(
321
+ (
322
+ "unguided-action",
323
+ "this custom code acts on the page, but the tour has nothing to show for it; "
324
+ "the next step's target may never appear",
325
+ "migrate it to `action: click` (with `synthetic: true` if needed) or add `guide-cursor`",
326
+ )
327
+ )
328
+ return cand
329
+
330
+ if kind == "unguided":
331
+ cand = Candidate("skip", reason=action, trailing_gates=gates)
332
+ cand.notes.append(
333
+ (
334
+ "unguided-action",
335
+ "confirm dialogs have no test-id to point at, so the tour cannot guide this click",
336
+ "say what to do in the previous step's `tour-text`, or give the dialog buttons test-ids "
337
+ "and click them by test-id",
338
+ )
339
+ )
340
+ return cand
341
+
342
+ if kind == "navigation":
343
+ cand = Candidate("skip", reason=action, reloads_page=True, trailing_gates=gates)
344
+ if action == "navigate" and not step.reload:
345
+ from .routes import ID_LIKE_RE
346
+
347
+ target = step.target or ""
348
+ id_like = any(ID_LIKE_RE.fullmatch(seg) for seg in target.split("?")[0].split("/") if seg)
349
+ cand.notes.append(
350
+ (
351
+ "navigate-in-tour",
352
+ f"the tour cannot take the user to {target!r}"
353
+ + (" — it contains fixture ids no real user has" if id_like else ""),
354
+ "start the tour later (tour.entry), or let the user get there by clicking",
355
+ )
356
+ )
357
+ else:
358
+ cand.notes.append(
359
+ (
360
+ "reloads-page",
361
+ "this step reloads the page or logs in again inside the tour",
362
+ "the tour resumes from sessionStorage, but move it before the tour starts if you can",
363
+ )
364
+ )
365
+ return cand
366
+
367
+ # plain skips
368
+ cand = Candidate("skip", reason=action, trailing_gates=gates)
369
+ if action == "keyboard" and "escape" in (step.value or "").lower():
370
+ cand.notes.append(
371
+ (
372
+ "escape-in-tour",
373
+ "an Escape key press inside the tour may dismiss it",
374
+ "the player ignores Escape inside dialogs and open dropdowns; check the replay",
375
+ )
376
+ )
377
+ return cand
378
+
379
+
380
+ def page_change(step: Step) -> Optional[str]:
381
+ """Why ``step`` may change the page when it runs unshown, or ``None`` if it cannot.
382
+
383
+ Phrased as the start of the ``gate-stale`` message. String literals and
384
+ comments are blanked before ``custom`` code is scanned, so a selector such
385
+ as ``':has-text("Save")'`` is not read as a call.
386
+ """
387
+ action = step.action
388
+ if action in PAGE_NEUTRAL_ACTIONS:
389
+ return None
390
+ if action == "call":
391
+ return None if step.page is False else f"the helper `{step.fn}` gets the page and may change it"
392
+ if action == "custom":
393
+ code = _COMMENT_RE.sub("", _STRING_RE.sub("''", step.code or ""))
394
+ for name in _CALL_RE.findall(code):
395
+ if not (keyword.iskeyword(name) or name in _READ_ONLY_CALLS or name.startswith(_READ_ONLY_PREFIXES)):
396
+ return f"this code calls `{name}()`, which may change the page"
397
+ return None
398
+ return f"this `{action}` may change the page"
399
+
400
+
401
+ def replayable(candidate: Candidate, advance: str) -> bool:
402
+ return advance in _REPLAYABLE.get(candidate.family, _REPLAYABLE["info"])
@@ -0,0 +1,144 @@
1
+ """All opted-in scenarios → compiled tours, replay plans and diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..models import Scenario
6
+ from .diagnostics import Diagnostic, DiagSink, sort_diagnostics
7
+ from .inference import infer_scenario
8
+ from .model import CompiledTour, ReplayPlan, ScenarioInference, TourCompileResult
9
+ from .settings import ToursSettings
10
+ from .text import check_texts, content_for, emitted_locales, guide_url, resolve_texts, tour_title
11
+
12
+
13
+ def opted_in(scenarios: list[Scenario], settings: ToursSettings) -> list[Scenario]:
14
+ return [s for s in scenarios if s.tour_opted_in(settings.default_opt_in)]
15
+
16
+
17
+ def compile_tours(scenarios: list[Scenario], settings: ToursSettings) -> TourCompileResult:
18
+ """Compile every opted-in scenario (the caller passes the whole directory)."""
19
+ diagnostics: list[Diagnostic] = []
20
+ tours: list[CompiledTour] = []
21
+ plans: dict[str, ReplayPlan] = {}
22
+ ids: dict[str, str] = {}
23
+ untitled: list[str] = [] # multi-locale tours whose title is the (one-language) scenario title
24
+
25
+ for scenario in opted_in(scenarios, settings):
26
+ slug = scenario.slug or (scenario.file_path.stem if scenario.file_path else "scenario")
27
+ tour_id = scenario.tour.id if scenario.tour and scenario.tour.id else slug
28
+ sink = DiagSink(slug, tour_id, replay=settings.replay)
29
+ from ..tour_spec import is_tour_id
30
+
31
+ if not is_tour_id(tour_id):
32
+ sink.add(
33
+ "tour-id-invalid",
34
+ f"{tour_id!r} cannot be a tour id (1-64 characters [A-Za-z0-9_-], no device names)",
35
+ hint="set `tour: {id: <id>}`",
36
+ )
37
+ diagnostics.extend(sink.items)
38
+ continue
39
+ key = tour_id.casefold()
40
+ if key in ids:
41
+ sink.add("tour-id-duplicate", f"tour id {tour_id!r} is also used by {ids[key]}", hint="set `tour.id`")
42
+ diagnostics.extend(sink.items)
43
+ continue
44
+ ids[key] = slug
45
+
46
+ inference = infer_scenario(scenario, settings, sink)
47
+ if inference is None:
48
+ diagnostics.extend(sink.items)
49
+ continue
50
+ tour = _compile_one(scenario, slug, tour_id, inference, settings, sink)
51
+ diagnostics.extend(sink.items)
52
+ if sink.has_errors:
53
+ continue
54
+ tours.append(tour)
55
+ if (scenario.tour is None or scenario.tour.title is None) and len(tour.locales) > 1:
56
+ untitled.append(tour_id)
57
+ plans[slug] = ReplayPlan(
58
+ scenario_slug=slug,
59
+ tour_id=tour_id,
60
+ role=inference.role,
61
+ auth=scenario.auth,
62
+ default_locale=settings.default_locale,
63
+ locales=tour.locales,
64
+ entry_route=inference.entry_route,
65
+ entry_params=inference.entry_params,
66
+ entry_step_number=(
67
+ scenario.steps[inference.entry_step_index].step_number
68
+ if inference.entry_step_index is not None
69
+ else None
70
+ ),
71
+ start_index=inference.start_index,
72
+ entries=tuple(inference.entries),
73
+ tour_step_count=len(inference.steps),
74
+ layouts=tuple(
75
+ (name, query)
76
+ for name, query in settings.layouts
77
+ if any(p.layouts and name in p.layouts for p in inference.steps)
78
+ ),
79
+ test_id_attribute=settings.test_id_attribute,
80
+ )
81
+
82
+ if untitled:
83
+ shown = ", ".join(sorted(untitled)[:5]) + (", …" if len(untitled) > 5 else "")
84
+ diagnostics.append(
85
+ Diagnostic(
86
+ "warning",
87
+ "title-fallback",
88
+ f"{len(untitled)} multi-language tour(s) have no `tour.title` ({shown}); every language shows "
89
+ "the scenario title, which apps listing tours display as is",
90
+ hint="set `tour: {title: {en: …, fr: …}}`",
91
+ )
92
+ )
93
+ tours.sort(key=lambda t: t.id)
94
+ return TourCompileResult(tours=tours, replay_plans=plans, diagnostics=sort_diagnostics(diagnostics))
95
+
96
+
97
+ def _compile_one(
98
+ scenario: Scenario,
99
+ slug: str,
100
+ tour_id: str,
101
+ inference: ScenarioInference,
102
+ settings: ToursSettings,
103
+ sink: DiagSink,
104
+ ) -> CompiledTour:
105
+ locales = emitted_locales(scenario, inference.steps, settings)
106
+ dropped = [loc for loc in settings.locales if loc not in locales]
107
+ if dropped:
108
+ sink.add(
109
+ "locale-not-emitted",
110
+ f"no kept step has text in {', '.join(dropped)}, so no file is written for it; those users get "
111
+ f"the tour in their base language if it has a file, else in {settings.default_locale}",
112
+ hint="add `tour-text` or `guide-text` in that language",
113
+ )
114
+ if not any(content_for(scenario.steps[p.step_index], settings.default_locale) for p in inference.steps):
115
+ sink.add(
116
+ "no-authored-text",
117
+ f"no step has text in the default locale {settings.default_locale!r}; every step shows a generic instruction",
118
+ )
119
+ titles = {}
120
+ fell_back = []
121
+ for locale in locales:
122
+ titles[locale], fb = tour_title(scenario, locale, settings.default_locale)
123
+ if fb:
124
+ fell_back.append(locale)
125
+ if fell_back:
126
+ sink.add(
127
+ "title-fallback",
128
+ f"tour.title has no entry for {', '.join(fell_back)}; the {settings.default_locale} title "
129
+ "(else the scenario title) is used there",
130
+ )
131
+ texts = resolve_texts(scenario, inference.steps, locales)
132
+ check_texts(scenario, inference.steps, texts, settings, sink)
133
+ urls = {loc: u for loc in locales if (u := guide_url(scenario, settings, loc))}
134
+ return CompiledTour(
135
+ id=tour_id,
136
+ scenario_slug=slug,
137
+ role=inference.role,
138
+ entry_route=inference.entry_route,
139
+ steps=inference.steps,
140
+ locales=locales,
141
+ titles=titles,
142
+ step_texts=texts,
143
+ guide_urls=urls,
144
+ )
@@ -0,0 +1,137 @@
1
+ """Compile diagnostics: stable kebab-case codes, one rendered line each.
2
+
3
+ Errors stop emission (``steppy validate`` exits 1); warnings never change
4
+ an exit code; infos only print with ``steppy tours --explain``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Literal, Optional
11
+
12
+ Severity = Literal["error", "warning", "info"]
13
+
14
+ #: Every code the compiler emits, with its severity. Tests pin that the compiler
15
+ #: never emits a code missing here, that every code here is emitted somewhere
16
+ #: (``tests/test_tours_diagnostics.py``), and that ``docs/tours.md`` lists each
17
+ #: one with this severity (``tests/test_tours_docs_sync.py``).
18
+ CODES: dict[str, Severity] = {
19
+ # errors
20
+ "scenario-unreadable": "error",
21
+ "tour-role-required": "error",
22
+ "tour-role-unknown": "error",
23
+ "tour-no-role": "error",
24
+ "tour-id-invalid": "error",
25
+ "tour-id-duplicate": "error",
26
+ "entry-invalid": "error",
27
+ "entry-no-match": "error",
28
+ "no-entry": "error", # downgraded to a warning when tours.replay is off
29
+ "target-not-test-id": "error",
30
+ "no-kept-steps": "error",
31
+ "layouts-unmapped": "error",
32
+ "annotation-outside-tour": "error",
33
+ "annotation-on-skipped-step": "error",
34
+ "skip-and-annotations": "error",
35
+ "keep-not-allowed": "error",
36
+ "advance-needs-target": "error",
37
+ "advance-not-replayable": "error", # downgraded to a warning when tours.replay is off
38
+ "limit-exceeded": "error",
39
+ "layout-media-mismatch": "error",
40
+ "build-id": "error",
41
+ "target-path": "error",
42
+ # warnings
43
+ "unguided-action": "warning",
44
+ "navigate-in-tour": "warning",
45
+ "reloads-page": "warning",
46
+ "escape-in-tour": "warning",
47
+ "may-not-change": "warning",
48
+ "upload-target-may-be-hidden": "warning",
49
+ "entry-query-dropped": "warning",
50
+ "entry-id-segment": "warning",
51
+ "app-url-unresolved": "warning",
52
+ "fixture-leak": "warning",
53
+ "generic-text": "warning",
54
+ "no-authored-text": "warning",
55
+ "title-fallback": "warning",
56
+ "placement-ignored": "warning",
57
+ "tour-text-locale-untargeted": "warning",
58
+ "locale-not-emitted": "warning",
59
+ "chrome-strings-missing": "warning",
60
+ "text-link-dropped": "warning",
61
+ "layout-media-overlap": "warning",
62
+ "app-assets-skipped": "warning",
63
+ "no-tours": "warning",
64
+ # infos
65
+ "step-skipped": "info",
66
+ "gate-dropped": "info",
67
+ "gate-stale": "info",
68
+ "reduced-to-host": "info",
69
+ "conditional-skipped": "info",
70
+ "layout-media-unchecked": "info",
71
+ }
72
+
73
+
74
+ @dataclass(frozen=True)
75
+ class Diagnostic:
76
+ severity: Severity
77
+ code: str
78
+ message: str
79
+ scenario: Optional[str] = None
80
+ tour: Optional[str] = None
81
+ step_number: Optional[int] = None
82
+ step_title: Optional[str] = None
83
+ hint: Optional[str] = None
84
+
85
+ def render(self) -> str:
86
+ where = ""
87
+ if self.tour and self.scenario and self.tour != self.scenario:
88
+ where = f"tour '{self.tour}' ({self.scenario})"
89
+ elif self.tour or self.scenario:
90
+ where = f"tour '{self.tour or self.scenario}'"
91
+ if self.step_number is not None:
92
+ where += f", step {self.step_number}"
93
+ if self.step_title:
94
+ where += f" '{self.step_title}'"
95
+ text = f"{where}: {self.message}" if where else self.message
96
+ if self.hint:
97
+ text += f" — {self.hint}"
98
+ return f"[{self.code}] {text}"
99
+
100
+ def sort_key(self):
101
+ return (self.scenario or "", self.step_number or 0, self.code, self.message)
102
+
103
+
104
+ class DiagSink:
105
+ """Collects diagnostics for one scenario (or the whole run when ``scenario`` is None)."""
106
+
107
+ def __init__(self, scenario: Optional[str] = None, tour: Optional[str] = None, *, replay: bool = True):
108
+ self.scenario = scenario
109
+ self.tour = tour
110
+ self.replay = replay
111
+ self.items: list[Diagnostic] = []
112
+
113
+ def add(self, code: str, message: str, *, step=None, hint: Optional[str] = None) -> None:
114
+ severity = CODES[code]
115
+ if code in ("no-entry", "advance-not-replayable") and not self.replay:
116
+ severity = "warning"
117
+ self.items.append(
118
+ Diagnostic(
119
+ severity=severity,
120
+ code=code,
121
+ message=message,
122
+ scenario=self.scenario,
123
+ tour=self.tour,
124
+ step_number=getattr(step, "step_number", None),
125
+ step_title=getattr(step, "step_title", None),
126
+ hint=hint,
127
+ )
128
+ )
129
+
130
+ @property
131
+ def has_errors(self) -> bool:
132
+ return any(d.severity == "error" for d in self.items)
133
+
134
+
135
+ def sort_diagnostics(items: list[Diagnostic]) -> list[Diagnostic]:
136
+ order = {"error": 0, "warning": 1, "info": 2}
137
+ return sorted(items, key=lambda d: (order[d.severity], *d.sort_key()))