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/generator.py ADDED
@@ -0,0 +1,633 @@
1
+ """Code generator for Steppy - transforms scenarios into pytest files."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from .assertions import parse_assertion
8
+ from .durations import parse_ms
9
+ from ._templating import make_template_env, template_search_paths
10
+ from .models import Scenario, SteppyConfig
11
+
12
+
13
+ class SteppyGenerator:
14
+ """Generates pytest test files from parsed scenarios."""
15
+
16
+ def __init__(
17
+ self,
18
+ config: SteppyConfig,
19
+ templates_dir: Optional[Path] = None,
20
+ project_root: Optional[Path] = None,
21
+ ):
22
+ """
23
+ Initialize the generator.
24
+
25
+ Args:
26
+ config: Validated Steppy configuration
27
+ templates_dir: Explicit template directory (error if missing).
28
+ project_root: Root whose ``templates/`` overrides the bundled ones.
29
+ Defaults to the current working directory.
30
+
31
+ See ``_templating.template_search_paths`` for the precedence rule,
32
+ which is shared with the guide and build assemblers.
33
+ """
34
+ self.config = config
35
+
36
+ self._search_paths = [str(p) for p in template_search_paths(templates_dir, project_root)]
37
+ self.env = make_template_env(templates_dir, project_root)
38
+
39
+ # Add custom filters
40
+ self.env.filters["format_wait_duration"] = self._format_wait_duration
41
+ self.env.filters["format_assertion"] = self._format_assertion
42
+ self.env.filters["format_assertions"] = self._format_assertions
43
+ self.env.filters["bracket_step"] = self._bracket_step
44
+ self.env.filters["apply_step_guard"] = self._apply_step_guard
45
+ self.env.filters["apply_optional"] = self._apply_optional
46
+ self.env.filters["format_dropdown_option"] = self._format_dropdown_option
47
+ self.env.filters["format_match_suffix"] = self._format_match_suffix
48
+ self.env.filters["disambig_selector"] = self._disambig_selector
49
+ self.env.filters["resolve_timeout"] = self._resolve_timeout
50
+ self.env.filters["timeout_kwarg"] = self._timeout_kwarg
51
+ self.env.filters["call_kwargs"] = self._call_kwargs
52
+ self.env.filters["timeout_ms"] = parse_ms
53
+ self.env.filters["pystr"] = self._py_str
54
+ self.env.filters["emits_screenshot"] = self._emits_screenshot
55
+ self.env.filters["docstring"] = self._docstring_safe
56
+ self.env.filters["tour_section"] = self._tour_section
57
+
58
+ @staticmethod
59
+ def _py_str(value) -> str:
60
+ """Render a value as a safe Python string literal via ``repr()``.
61
+
62
+ Produces single-quoted output for simple strings — byte-identical to the
63
+ historical hand-quoted templates (``'{{ x }}'``) — while correctly
64
+ escaping apostrophes, quotes, backslashes, newlines, and non-ASCII. The
65
+ result always ``eval``s back to the original string, so a scenario value
66
+ like ``L'adresse`` or a toast ``L'élément a été créé`` no longer
67
+ breaks the generated Python with a ``SyntaxError``.
68
+
69
+ ``None`` is coerced to the literal ``'None'`` to match Jinja's own
70
+ ``{{ None }}`` → ``None`` rendering, preserving byte-identity for the
71
+ (validation-caught) missing-field case.
72
+ """
73
+ return repr("None" if value is None else str(value))
74
+
75
+ @staticmethod
76
+ def _emits_screenshot(step) -> bool:
77
+ """True when this step emits a screenshot for its own role (FLAWS §8.2).
78
+
79
+ Replaces the screenshot-emit predicate that was copy-pasted 36× across
80
+ ``test_scenario.py.j2``. Mirrors the historical inline expression exactly
81
+ so codegen stays byte-identical: ``screenshot`` is truthy, is not the
82
+ literal ``False``, and is either ``True``, the step's own ``role``, or the
83
+ ``"all"`` sentinel. The role is read from the step itself (there is no bare
84
+ ``role`` in template scope), so the template calls ``step | emits_screenshot``
85
+ with no argument; timing/cursor clauses (``screenshot_when``, ``guide_cursor``)
86
+ stay at the call site since they vary per branch.
87
+ """
88
+ ss = getattr(step, "screenshot", None)
89
+ return bool(ss) and ss is not False and (ss is True or ss == step.role or ss == "all")
90
+
91
+ @staticmethod
92
+ def _docstring_safe(value) -> str:
93
+ """Escape a value for safe interpolation inside a triple-quoted docstring
94
+ (FLAWS §8.1).
95
+
96
+ Frontmatter ``title``/``description`` are emitted into the generated test
97
+ function's ``\"\"\"...\"\"\"`` docstring. A value containing a ``\"`` or a
98
+ trailing backslash could terminate or corrupt that literal (a ``SyntaxError``
99
+ at pytest collection, exactly the §1.1 failure mode). Escaping ``\\`` → ``\\\\``
100
+ and ``\"`` → ``\\\"`` closes the hole. Values with no quotes/backslashes (every
101
+ real title so far) render unchanged, so existing goldens stay byte-identical.
102
+ """
103
+ return str(value).replace("\\", "\\\\").replace('"', '\\"')
104
+
105
+ @staticmethod
106
+ def _bracket_step(body: str, step) -> str:
107
+ """Wrap a step's emitted body in the per-step event markers.
108
+
109
+ Done in Python rather than with two lines in the template, because
110
+ whether the captured body ends with a newline **depends on which action
111
+ branch rendered**: a branch whose last literal is followed by a
112
+ ``{%- elif %}`` has its trailing newline stripped at lex time, and one
113
+ that ends inside a nested ``{% if %}`` does not. A template-level marker
114
+ therefore glues onto the previous statement for *some* actions only —
115
+ which is a syntax error in the generated file for those scenarios and
116
+ nothing at all for the rest. Normalising here removes the whole class of
117
+ bug, and puts it beside ``_apply_step_guard``, which already owns the
118
+ body's final shape.
119
+
120
+ Applied BEFORE the guard filter so the markers land inside
121
+ ``if _step_runs(...)`` — a step skipped by ``only-in``/``layouts`` must
122
+ emit neither, or ``failed_step`` could name a step that never ran.
123
+ """
124
+ number = step.step_number
125
+ title = SteppyGenerator._py_str(step.step_title)
126
+ action = SteppyGenerator._py_str(step.action)
127
+ inner = body.strip("\n")
128
+ opening = f" _steppy_mark_step({number}, {title}, {action})"
129
+ closing = f" _steppy_end_step({number})"
130
+ if not inner.strip():
131
+ return f"{opening}\n{closing}\n"
132
+ return f"{opening}\n{inner}\n{closing}\n"
133
+
134
+ @staticmethod
135
+ def _apply_optional(body: str, step) -> str:
136
+ """Wrap a step's body in ``try/except`` when it declares ``optional: true``.
137
+
138
+ For the step that may legitimately not be there — the confirmation panel
139
+ that only appears the first time, the banner that has already cleared.
140
+ The corpus spells this as a bare ``try:``/``except Exception: pass``
141
+ around a custom block, 93 times in one project and 9 in the other.
142
+
143
+ Applied BEFORE ``bracket_step``, so the ``try`` sits *inside* the step
144
+ markers: the step really did run, and ``_steppy_end_step`` must still be
145
+ reached or ``failed_step`` would blame a step that merely opted out.
146
+
147
+ No-op when the flag is absent, so unflagged steps stay byte-identical.
148
+ """
149
+ if not getattr(step, "optional", None):
150
+ return body
151
+ inner = body.strip("\n")
152
+ if not inner.strip():
153
+ return body
154
+ indented = "\n".join((" " + line) if line.strip() else line for line in inner.split("\n"))
155
+ number = step.step_number
156
+ return (
157
+ " try:\n"
158
+ + indented
159
+ + "\n except Exception as _optional_err:\n"
160
+ + f' print(f"STEPPY: optional step {number} skipped: {{_optional_err}}", flush=True)\n'
161
+ )
162
+
163
+ @staticmethod
164
+ def _apply_step_guard(body: str, step) -> str:
165
+ """Wrap a step's emitted body in ``if _step_runs(only_in, layouts):``
166
+ when the step declares ``only-in`` or ``layouts`` filters. No-op for
167
+ unfiltered steps so the common path stays byte-identical to the
168
+ pre-feature output.
169
+
170
+ The captured ``body`` already has the standard 4-space test-function
171
+ indent. The wrap adds the guard line at that indent and re-indents
172
+ every non-blank body line by 4 more spaces so it sits inside the
173
+ ``if`` block."""
174
+ when = getattr(step, "when", None)
175
+ if not getattr(step, "only_in", None) and not getattr(step, "layouts", None) and not when:
176
+ return body
177
+ only_in_arg = repr(step.only_in) if step.only_in else "None"
178
+ layouts_arg = repr(step.layouts) if step.layouts else "None"
179
+ conditions = [f"_step_runs({only_in_arg}, {layouts_arg})"]
180
+ if when:
181
+ # Evaluated before the step markers, exactly like `_step_runs`: a
182
+ # step whose condition is false did not run, so it must emit
183
+ # neither marker.
184
+ conditions.append(f"_when({step.role}_page, {SteppyGenerator._py_str(when)}, _vars)")
185
+ guard = " and ".join(conditions)
186
+ lines = body.split("\n")
187
+ indented = []
188
+ for line in lines:
189
+ if line.strip():
190
+ indented.append(" " + line)
191
+ else:
192
+ indented.append(line)
193
+ return f" if {guard}:\n" + "\n".join(indented)
194
+
195
+ def _format_wait_duration(self, duration: str) -> float:
196
+ """
197
+ Convert duration string (e.g., '5s', '3000ms') to seconds as float.
198
+
199
+ Args:
200
+ duration: Duration string like "5s" or "3000ms"
201
+
202
+ Returns:
203
+ Duration in seconds as a float
204
+ """
205
+ duration = duration.strip().lower()
206
+
207
+ if duration.endswith("ms"):
208
+ return float(duration[:-2]) / 1000
209
+ elif duration.endswith("s"):
210
+ return float(duration[:-1])
211
+ else:
212
+ # Assume milliseconds if no unit
213
+ return float(duration) / 1000
214
+
215
+ @staticmethod
216
+ def _call_kwargs(step, scenario, config, has_args: bool = True) -> str:
217
+ """The extra keyword arguments this step adds to its Playwright call.
218
+
219
+ Returns ``", timeout=25000, force=True"``, or ``"timeout=25000"`` when
220
+ the call has no positional arguments to comma-follow (``locator.click()``
221
+ versus ``page.click(sel)``), or ``""`` when the step asks for neither.
222
+
223
+ The empty case is load-bearing: ``click``/``fill``/``hover``/``upload``/
224
+ ``scroll``/``select``/``navigate`` emit no timeout at all today and sit
225
+ on Playwright's own 30s default, not on ``config.timing.step_timeout``.
226
+ Emitting a resolved default for them would change both the generated
227
+ bytes and the runtime behaviour of every existing scenario.
228
+ """
229
+ parts = []
230
+ if getattr(step, "timeout", None) is not None or getattr(scenario, "timeout", None) is not None:
231
+ parts.append(f"timeout={SteppyGenerator._resolve_timeout(step, scenario, config)}")
232
+ if getattr(step, "force", None):
233
+ parts.append("force=True")
234
+ if not parts:
235
+ return ""
236
+ joined = ", ".join(parts)
237
+ return f", {joined}" if has_args else joined
238
+
239
+ @staticmethod
240
+ def _timeout_kwarg(step, scenario, config, lead: str = ", ") -> str:
241
+ """``", timeout=25000"`` when this step asks for one, ``""`` otherwise.
242
+
243
+ For the actions that emit no timeout at all today — ``click``, ``fill``,
244
+ ``hover``, ``upload``, ``scroll``, ``select``, ``navigate`` — the kwarg
245
+ has to be absent unless asked for, or every existing scenario's
246
+ generated file changes. Emitting the resolved default instead would be
247
+ a behaviour change too: those calls currently sit on Playwright's own
248
+ 30s, not on ``config.timing.step_timeout``.
249
+ """
250
+ if getattr(step, "timeout", None) is None and getattr(scenario, "timeout", None) is None:
251
+ return ""
252
+ return f"{lead}timeout={SteppyGenerator._resolve_timeout(step, scenario, config)}"
253
+
254
+ @staticmethod
255
+ def _resolve_timeout(step, scenario, config, default: Optional[int] = None) -> int:
256
+ """Milliseconds this step should wait, most specific source winning.
257
+
258
+ ``step.timeout`` -> ``scenario.timeout`` -> ``config.timing.step_timeout``.
259
+
260
+ The scenario level is not new surface: ``Scenario.timeout`` has existed
261
+ since the first release and was read by nothing — this is the wiring it
262
+ was always missing.
263
+
264
+ Returns an int so the template can emit ``timeout=25000`` directly. A
265
+ step that sets nothing resolves to the global default, which is what
266
+ every call site baked before, so unflagged steps stay byte-identical.
267
+ """
268
+ raw = getattr(step, "timeout", None)
269
+ if raw is None:
270
+ raw = getattr(scenario, "timeout", None)
271
+ if raw is None:
272
+ return config.timing.step_timeout if default is None else default
273
+ return parse_ms(raw)
274
+
275
+ def _format_assertion(self, expect: str) -> dict[str, str]:
276
+ """Parse an assertion string into components for code generation.
277
+
278
+ Delegates to :mod:`steppy.assertions`, which is also what
279
+ ``Step.validate_expect_grammar`` calls — so ``steppy validate`` and
280
+ codegen can never disagree about what an assertion means. The grammar
281
+ used to live here alone, and an unrecognised form returned
282
+ ``{"type": "unknown"}`` that ``assert_body`` rendered as ``pass``: a
283
+ green test asserting nothing. It is now a ``ValidationError`` raised at
284
+ parse time, long before this method runs.
285
+ """
286
+ return parse_assertion(expect)
287
+
288
+ def _format_dropdown_option(self, option: str) -> dict[str, object]:
289
+ """Parse a ``select-option`` ``option:`` spec into a codegen descriptor.
290
+
291
+ Forms:
292
+ first | last -> {"kind": "first"|"last"}
293
+ N (integer) -> {"kind": "nth", "index": N}
294
+ text:"After lunch" -> {"kind": "text", "text": "After lunch"}
295
+ """
296
+ option = (option or "").strip()
297
+ if option == "first":
298
+ return {"kind": "first"}
299
+ if option == "last":
300
+ return {"kind": "last"}
301
+ if option.startswith("text:"):
302
+ text = option[5:].strip().strip('"').strip("'")
303
+ return {"kind": "text", "text": text}
304
+ if re.match(r"^\d+$", option):
305
+ return {"kind": "nth", "index": int(option)}
306
+ # Fallback: treat as a literal substring match.
307
+ return {"kind": "text", "text": option}
308
+
309
+ @staticmethod
310
+ def _format_assertions(expect) -> list[dict]:
311
+ """Every assertion on a step, as descriptors carrying their message.
312
+
313
+ Always a list, so the template has one shape to render whether the
314
+ author wrote a scalar or a list. A scalar with no message produces a
315
+ single descriptor whose ``message`` is ``None``, which the macro renders
316
+ exactly as it did before this field grew.
317
+ """
318
+ out: list[dict] = []
319
+ if expect is None:
320
+ return out
321
+ entries = [expect] if isinstance(expect, str) else list(expect)
322
+ for entry in entries:
323
+ if isinstance(entry, str):
324
+ check, message = entry, None
325
+ else:
326
+ check, message = entry.check, entry.message
327
+ # `fail_message`, not `message`: the `toast(...)` descriptor already
328
+ # owns a `message` key (the toast text), and clobbering it silently
329
+ # asserted on the string "None".
330
+ out.append({**parse_assertion(check), "fail_message": message, "raw": check})
331
+ return out
332
+
333
+ @staticmethod
334
+ def _disambig_selector(step) -> str:
335
+ """``within``/``match`` folded back into a single selector string.
336
+
337
+ For the two actions that hand a *selector* to a conftest helper rather
338
+ than building a locator — ``pick-date`` and ``pick-time``, which pass one
339
+ into ``_pick_date``/``_pick_time`` — a locator chain cannot be threaded
340
+ without changing those signatures. Playwright's selector engine already
341
+ composes the same thing with ``>>``, so the chain is expressed there
342
+ instead. Returns the bare selector unchanged when neither field is set,
343
+ keeping existing output byte-identical.
344
+ """
345
+ parts = []
346
+ if getattr(step, "within", None):
347
+ parts.append(str(step.within))
348
+ parts.append(str(step.selector))
349
+ match = getattr(step, "match", None)
350
+ if match is not None:
351
+ spec = str(match).strip().lower()
352
+ if spec == "first":
353
+ parts.append("nth=0")
354
+ elif spec == "last":
355
+ parts.append("nth=-1")
356
+ else:
357
+ parts.append(f"nth={int(spec)}")
358
+ return " >> ".join(parts)
359
+
360
+ def _format_match_suffix(self, match: object) -> str:
361
+ """Convert a ``match:`` spec into the Playwright locator suffix it maps to.
362
+
363
+ Forms:
364
+ first -> ".first"
365
+ last -> ".last"
366
+ N (integer) -> ".nth(N)"
367
+ None/empty -> "" (no disambiguation)
368
+ """
369
+ if match is None:
370
+ return ""
371
+ m = str(match).strip().lower()
372
+ if m == "":
373
+ return ""
374
+ if m == "first":
375
+ return ".first"
376
+ if m == "last":
377
+ return ".last"
378
+ if re.match(r"^\d+$", m):
379
+ return f".nth({int(m)})"
380
+ # Validator already rejected anything else; be defensive.
381
+ return ""
382
+
383
+ def generate_test_file(self, scenario: Scenario, tour_plan=None) -> str:
384
+ """
385
+ Generate a pytest .py file from a parsed scenario.
386
+
387
+ Args:
388
+ scenario: Parsed and validated scenario
389
+ tour_plan: The scenario's in-app tour ``ReplayPlan`` (from
390
+ ``steppy.tours``), or ``None``. With a plan, the file also gets
391
+ the ``test_<slug>__tour`` replay; without one it is byte-identical
392
+ to a tours-free build.
393
+
394
+ Returns:
395
+ Python code as a string
396
+ """
397
+ template = self.env.get_template("test_scenario.py.j2")
398
+
399
+ roles_needed = sorted(scenario.get_roles())
400
+
401
+ if tour_plan is None:
402
+ # ``tour_replay`` is passed explicitly rather than left undefined: an
403
+ # undefined name resolves to Jinja's ``missing`` sentinel, and a
404
+ # re-imported jinja2 (tests purge sys.modules) makes the compiled
405
+ # template compare it against a different sentinel object.
406
+ return template.render(
407
+ scenario=scenario,
408
+ config=self.config,
409
+ roles_needed=roles_needed,
410
+ tour_replay=None,
411
+ )
412
+
413
+ from .tours.replay import build_replay
414
+
415
+ replay = build_replay(scenario, tour_plan, self.config)
416
+ code = template.render(
417
+ scenario=scenario,
418
+ config=self.config,
419
+ roles_needed=roles_needed,
420
+ tour_replay=replay,
421
+ )
422
+ if f"def {replay.function_name}(" not in code:
423
+ # A project-local templates/test_scenario.py.j2 that predates in-app
424
+ # tours renders no replay — refuse rather than ship a tour CI never checks.
425
+ raise ValueError(
426
+ "your templates/test_scenario.py.j2 override predates in-app tours (no step_body macro / tour "
427
+ "replay section): copy the bundled template's changes into it, or remove the override"
428
+ )
429
+ return code
430
+
431
+ def _tour_section(self, replay, step_body) -> str:
432
+ """The ``test_<slug>__tour`` replay function, composed from ``step_body`` bodies.
433
+
434
+ Composed in Python for the same reason ``_bracket_step`` is: whether a
435
+ captured body ends with a newline depends on the action branch, and the
436
+ tour checks must sit on their own lines around it.
437
+ """
438
+ from pprint import pformat
439
+
440
+ role = replay.role
441
+ if replay.auth:
442
+ params = ", ".join(f"{r}_page: Page" for r in replay.roles)
443
+ aliases = []
444
+ else:
445
+ params = ", ".join(f"{r}_page_noauth: Page" for r in replay.roles)
446
+ aliases = [f" {r}_page = {r}_page_noauth" for r in replay.roles]
447
+ tour_id = replay.spec["id"]
448
+ lines = [
449
+ "",
450
+ "",
451
+ "# The in-app tour this scenario compiles to, as the replay checks it.",
452
+ f"TOUR = {pformat(replay.spec, width=110, sort_dicts=False)}",
453
+ "",
454
+ "",
455
+ f"def {replay.function_name}({params}, request):",
456
+ f' """In-app tour replay of {self._py_str(tour_id)}: the scenario, followed the way a user '
457
+ 'follows the tour."""',
458
+ " _vars: dict[str, str] = {}",
459
+ *aliases,
460
+ f" _tour = TourProbe({role}_page, request, TOUR, step_runs=_step_runs)",
461
+ ]
462
+ out = "\n".join(lines) + "\n"
463
+ for item in replay.items:
464
+ out += self._tour_item(item, step_body, replay)
465
+ out += f"\n\n{replay.function_name}._steppy_tour_role = {self._py_str(role)}\n"
466
+ return out
467
+
468
+ def _tour_item(self, item, step_body, replay) -> str:
469
+ if item.kind == "deferred-ensure":
470
+ body = f" _tour_ensure_advanced(_tour, {self._py_str(item.step_id)})\n"
471
+ return self._apply_step_guard(body, item.step)
472
+ rule = "─" * max(3, 74 - len(item.comment))
473
+ head = f"\n # ── {item.comment} {rule}\n"
474
+ if item.kind == "omitted":
475
+ return head
476
+ if item.kind == "start":
477
+ args = ", ".join(f"{self._py_str(p)}: _interp({self._py_str(t)}, _vars)" for p, t in replay.params)
478
+ return head + f" _tour_start(_tour, {{{args}}})\n"
479
+ if item.kind == "complete":
480
+ return head + " _tour_expect_complete(_tour)\n"
481
+ if item.kind in ("setup", "plain"):
482
+ body = self._apply_optional(str(step_body(item.step)), item.step)
483
+ return head + self._apply_step_guard(self._bracket_step(body, item.step), item.step)
484
+ chunks = []
485
+ for part, check in item.parts:
486
+ target = f", _interp({self._py_str(check.selector)}, _vars)" if check.selector else ""
487
+ chunks.append(f" _tour_expect_step(_tour, {self._py_str(check.step_id)}{target})")
488
+ chunks.append(str(step_body(part)))
489
+ if check.ensure == "inline":
490
+ chunks.append(f" _tour_ensure_advanced(_tour, {self._py_str(check.step_id)})")
491
+ body = "\n".join(c.strip("\n") for c in chunks if c.strip()) + "\n"
492
+ return head + self._apply_step_guard(self._bracket_step(body, item.step), item.step)
493
+
494
+ def tours_helper_source(self) -> str:
495
+ """The static ``_steppy_tours.py`` replay helper (written only when tours are on)."""
496
+ return self._read_static_template("_steppy_tours.py")
497
+
498
+ def _read_static_template(self, filename: str) -> str:
499
+ """Read a static (non-Jinja) template file from the configured search
500
+ paths. The first match wins — same precedence as the Jinja loader, so
501
+ a user-customised file in ``templates/`` overrides the bundled copy."""
502
+ for path_str in self._search_paths:
503
+ candidate = Path(path_str) / filename
504
+ if candidate.exists():
505
+ return candidate.read_text(encoding="utf-8")
506
+ raise FileNotFoundError(f"Static template not found in search paths: {filename}")
507
+
508
+ def generate_conftest(self, all_scenarios: list[Scenario]) -> str:
509
+ """
510
+ Generate the shared conftest.py with auth fixtures.
511
+
512
+ Args:
513
+ all_scenarios: List of all scenarios to determine which role fixtures are needed
514
+
515
+ Returns:
516
+ Python code as a string
517
+ """
518
+ # Collect all roles used across all scenarios
519
+ all_roles = set()
520
+ for scenario in all_scenarios:
521
+ all_roles.update(scenario.get_roles())
522
+
523
+ template = self.env.get_template("conftest.py.j2")
524
+
525
+ return template.render(
526
+ config=self.config,
527
+ roles=sorted(all_roles),
528
+ tours_enabled=self._tours_enabled(),
529
+ )
530
+
531
+ def _tours_enabled(self) -> bool:
532
+ tours = getattr(self.config, "tours", None)
533
+ return bool(tours is not None and tours.enabled)
534
+
535
+ def generate_all(
536
+ self,
537
+ scenarios: list[Scenario],
538
+ output_dir: Path,
539
+ force: bool = False,
540
+ *,
541
+ tour_plans=None,
542
+ ) -> dict[str, str]:
543
+ """
544
+ Generate all test files + conftest.
545
+
546
+ Args:
547
+ scenarios: List of parsed scenarios
548
+ output_dir: Output directory (e.g., tests/e2e/)
549
+ force: If True, overwrite existing files without asking
550
+
551
+ Returns:
552
+ Dict mapping file paths to status ("generated", "updated", "unchanged")
553
+ """
554
+ output_dir.mkdir(parents=True, exist_ok=True)
555
+
556
+ results = {}
557
+
558
+ # Ensure __init__.py files exist to make it a package
559
+ tests_init = output_dir.parent / "__init__.py"
560
+ if not tests_init.exists():
561
+ tests_init.write_text("# This file makes tests a package\n", encoding="utf-8")
562
+ results[str(tests_init)] = "generated"
563
+
564
+ e2e_init = output_dir / "__init__.py"
565
+ if not e2e_init.exists():
566
+ e2e_init.write_text("# This file makes tests.e2e a package\n", encoding="utf-8")
567
+ results[str(e2e_init)] = "generated"
568
+
569
+ # Generate conftest.py
570
+ conftest_path = output_dir / "conftest.py"
571
+ conftest_code = self.generate_conftest(scenarios)
572
+
573
+ if conftest_path.exists() and not force:
574
+ existing_content = conftest_path.read_text(encoding="utf-8")
575
+ if existing_content == conftest_code:
576
+ results[str(conftest_path)] = "unchanged"
577
+ else:
578
+ conftest_path.write_text(conftest_code, encoding="utf-8")
579
+ results[str(conftest_path)] = "updated"
580
+ else:
581
+ conftest_path.write_text(conftest_code, encoding="utf-8")
582
+ results[str(conftest_path)] = "generated"
583
+
584
+ # Drop the parallel-mode events plugin next to conftest. The conftest
585
+ # gates the import on STEPPY_EMIT_EVENTS, so this file is inert in
586
+ # single-worker runs. Plain Python, no Jinja substitution.
587
+ events_path = output_dir / "_steppy_events.py"
588
+ events_code = self._read_static_template("_steppy_events.py")
589
+ if events_path.exists() and not force:
590
+ existing_events = events_path.read_text(encoding="utf-8")
591
+ if existing_events == events_code:
592
+ results[str(events_path)] = "unchanged"
593
+ else:
594
+ events_path.write_text(events_code, encoding="utf-8")
595
+ results[str(events_path)] = "updated"
596
+ else:
597
+ events_path.write_text(events_code, encoding="utf-8")
598
+ results[str(events_path)] = "generated"
599
+
600
+ # The in-app tour replay helper, only when tours are on (a config-level
601
+ # gate, never per scenario: a partial generate must not drop it).
602
+ if self._tours_enabled():
603
+ if tour_plans is None and self.config.tours.replay:
604
+ raise ValueError("tours are enabled: generate_all needs the compiled tour plans (tour_plans=...)")
605
+ helper_path = output_dir / "_steppy_tours.py"
606
+ helper_code = self.tours_helper_source()
607
+ if helper_path.exists() and not force:
608
+ same = helper_path.read_text(encoding="utf-8") == helper_code
609
+ if not same:
610
+ helper_path.write_text(helper_code, encoding="utf-8")
611
+ results[str(helper_path)] = "unchanged" if same else "updated"
612
+ else:
613
+ helper_path.write_text(helper_code, encoding="utf-8")
614
+ results[str(helper_path)] = "generated"
615
+
616
+ # Generate test files
617
+ for scenario in scenarios:
618
+ filename = f"test_{scenario.slug}.py"
619
+ test_path = output_dir / filename
620
+ test_code = self.generate_test_file(scenario, tour_plan=(tour_plans or {}).get(scenario.slug))
621
+
622
+ if test_path.exists() and not force:
623
+ existing_content = test_path.read_text(encoding="utf-8")
624
+ if existing_content == test_code:
625
+ results[str(test_path)] = "unchanged"
626
+ else:
627
+ test_path.write_text(test_code, encoding="utf-8")
628
+ results[str(test_path)] = "updated"
629
+ else:
630
+ test_path.write_text(test_code, encoding="utf-8")
631
+ results[str(test_path)] = "generated"
632
+
633
+ return results