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/record_cli.py ADDED
@@ -0,0 +1,796 @@
1
+ """``steppy record`` - record a browser session into a *draft* scenario file.
2
+
3
+ A recorder in the Scribe/Tango mold whose output is **source code that
4
+ regenerates forever**: a Markdown scenario draft, not a frozen screenshot album.
5
+ V1 is a post-processor over ``playwright codegen`` - a headed browser opens, the
6
+ user performs the flow and closes the window, and the generated pytest is
7
+ transformed (with :mod:`ast`) into Steppy scenario Markdown. Recorded fill values
8
+ that look like secrets are placeholderized to ``${VAR}`` refs (login is collapsed
9
+ to ``- action: login``), a blanket ``input[type='password']`` screenshot mask is
10
+ recommended, and the existing selector lint runs on the output. Recordings always
11
+ land as **drafts for review** - the ``steppy-scenario-author`` skill refines them.
12
+
13
+ This is a **foundational V1 ("CLI plumbing")**. It will be extended and improved
14
+ when Steppy is implemented on the open-source React/Vue showcases (the
15
+ client-facing browser-extension recorder + visual blur picker + instant preview
16
+ is a later phase). Selectors and mappings are
17
+ best-effort against public Playwright-codegen output; the draft is meant to be
18
+ reviewed, not shipped verbatim.
19
+
20
+ A leaf module: imports only ``_cli_common`` + ``parser`` + stdlib (no cycle with
21
+ ``cli``). Registered via :func:`register_record` at the bottom of ``cli.py``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import ast
27
+ import os
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Optional
35
+ from urllib.parse import urlsplit
36
+
37
+ import typer
38
+ import yaml
39
+ from rich.markup import escape
40
+
41
+ from ._cli_common import (
42
+ _cli_errors,
43
+ _debug_enabled,
44
+ _error,
45
+ _load_config_or_exit,
46
+ console,
47
+ )
48
+ from .parser import ParseError, lint_scenario_selectors, parse_scenario_file
49
+
50
+ # Field tokens (test-id / role name / label / placeholder) that mark a value as
51
+ # secret → placeholderize + mask. Best-effort; the draft is reviewed.
52
+ _SECRET_RE = re.compile(r"pass|secret|token|otp|cvv|pin|card|ssn|api[-_]?key", re.I)
53
+ # A subset of the above that specifically signals the login password field.
54
+ _PASSWORD_RE = re.compile(r"pass", re.I)
55
+
56
+ # Sentinel for a codegen argument that isn't a plain literal (e.g. re.compile(...)).
57
+ _MISSING = object()
58
+
59
+
60
+ @dataclass
61
+ class _Seg:
62
+ """One link in a Playwright locator/terminal chain."""
63
+
64
+ name: str
65
+ is_call: bool
66
+ args: list
67
+ kwargs: dict
68
+
69
+
70
+ @dataclass
71
+ class RecordResult:
72
+ """Outcome of :func:`codegen_source_to_scenario`."""
73
+
74
+ markdown: str
75
+ step_count: int
76
+ notes: list = field(default_factory=list)
77
+ env_vars: list = field(default_factory=list)
78
+ mask_selectors: list = field(default_factory=list)
79
+
80
+
81
+ # ── AST walk ────────────────────────────────────────────────────────────────
82
+
83
+
84
+ def _lit(node):
85
+ """Literal value of an AST node, or ``_MISSING`` for anything non-constant."""
86
+ if isinstance(node, ast.Constant):
87
+ return node.value
88
+ return _MISSING
89
+
90
+
91
+ def _lits(args) -> list:
92
+ return [_lit(a) for a in args]
93
+
94
+
95
+ def _kwlits(keywords) -> dict:
96
+ return {kw.arg: _lit(kw.value) for kw in keywords if kw.arg}
97
+
98
+
99
+ def _walk(node):
100
+ """Flatten a method-chain expression.
101
+
102
+ Returns ``(base_name, base_arg_nodes, segs)`` where ``base_name`` is the
103
+ innermost name (``"page"``, ``"expect"``, ...), ``base_arg_nodes`` are the raw
104
+ AST args of a base *call* (``expect(<locator>)``) or ``None``, and ``segs`` is
105
+ the ordered list of :class:`_Seg` from just-after-base out to the terminal.
106
+ Returns ``None`` when the shape isn't understood.
107
+ """
108
+ segs: list[_Seg] = []
109
+ cur = node
110
+ while True:
111
+ if isinstance(cur, ast.Call) and isinstance(cur.func, ast.Attribute):
112
+ attr = cur.func
113
+ segs.append(_Seg(attr.attr, True, _lits(cur.args), _kwlits(cur.keywords)))
114
+ cur = attr.value
115
+ elif isinstance(cur, ast.Attribute):
116
+ # A property access such as ``.first`` / ``.last``.
117
+ segs.append(_Seg(cur.attr, False, [], {}))
118
+ cur = cur.value
119
+ elif isinstance(cur, ast.Call) and isinstance(cur.func, ast.Name):
120
+ segs.reverse()
121
+ return cur.func.id, cur.args, segs
122
+ elif isinstance(cur, ast.Name):
123
+ segs.reverse()
124
+ return cur.id, None, segs
125
+ else:
126
+ return None
127
+
128
+
129
+ def _find_test_fn(tree: ast.Module):
130
+ fns = [n for n in tree.body if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))]
131
+ for n in fns:
132
+ if n.name.startswith("test"):
133
+ return n
134
+ return fns[0] if fns else None
135
+
136
+
137
+ # ── Locator chain → Steppy selector ─────────────────────────────────────────
138
+
139
+
140
+ def _selector_from_chain(chain: list[_Seg]):
141
+ """Map a Playwright locator chain to a Steppy selector string.
142
+
143
+ Returns ``(selector, raw_token, lossy_note)``. ``selector`` is ``None`` when
144
+ a link can't be mapped (caller degrades the whole action to ``custom``).
145
+ ``raw_token`` is the human-ish label behind the locator (for titles + secret
146
+ detection). Positional links (``.nth``/``.first``/``.last``) fold into a
147
+ Playwright ``>> nth=`` suffix - uniform and always valid for a draft.
148
+ """
149
+ parts: list[str] = []
150
+ token = None
151
+ lossy = None
152
+ for seg in chain:
153
+ if not seg.is_call:
154
+ if seg.name == "first":
155
+ parts.append("nth=0")
156
+ elif seg.name == "last":
157
+ parts.append("nth=-1")
158
+ # Any other bare property (e.g. ``.keyboard``) contributes no selector.
159
+ continue
160
+ name = seg.name
161
+ a0 = seg.args[0] if seg.args else None
162
+ if name == "nth":
163
+ if a0 is _MISSING or a0 is None:
164
+ return None, None, None
165
+ parts.append(f"nth={a0}")
166
+ elif name == "get_by_test_id":
167
+ if a0 is _MISSING:
168
+ return None, None, None
169
+ parts.append(f'[data-testid="{a0}"]')
170
+ token = token or str(a0)
171
+ elif name == "locator":
172
+ if a0 is _MISSING:
173
+ return None, None, None
174
+ parts.append(str(a0))
175
+ token = token or str(a0)
176
+ elif name == "get_by_role":
177
+ if a0 is _MISSING:
178
+ return None, None, None
179
+ sel = f"role={a0}"
180
+ nm = seg.kwargs.get("name")
181
+ if nm is not None and nm is not _MISSING:
182
+ sel += f'[name="{nm}"]'
183
+ token = token or str(nm)
184
+ else:
185
+ token = token or str(a0)
186
+ parts.append(sel)
187
+ elif name == "get_by_placeholder":
188
+ if a0 is _MISSING:
189
+ return None, None, None
190
+ parts.append(f'[placeholder="{a0}"]')
191
+ token = token or str(a0)
192
+ elif name == "get_by_alt_text":
193
+ if a0 is _MISSING:
194
+ return None, None, None
195
+ parts.append(f'[alt="{a0}"]')
196
+ token = token or str(a0)
197
+ elif name == "get_by_title":
198
+ if a0 is _MISSING:
199
+ return None, None, None
200
+ parts.append(f'[title="{a0}"]')
201
+ token = token or str(a0)
202
+ elif name == "get_by_text":
203
+ if a0 is _MISSING:
204
+ return None, None, None
205
+ parts.append(f"text={a0}")
206
+ token = token or str(a0)
207
+ lossy = "recorded via get_by_text - a text= selector breaks on i18n/wording; prefer a data-testid"
208
+ elif name == "get_by_label":
209
+ if a0 is _MISSING:
210
+ return None, None, None
211
+ parts.append(f'[aria-label="{a0}"]')
212
+ token = token or str(a0)
213
+ lossy = "recorded via get_by_label - emitted an [aria-label] guess; verify the selector"
214
+ elif name == "filter":
215
+ ht = seg.kwargs.get("has_text")
216
+ if ht is None or ht is _MISSING:
217
+ return None, None, None
218
+ parts.append(f':has-text("{ht}")')
219
+ lossy = "recorded a .filter(has_text=...) - folded into :has-text(); verify the selector"
220
+ else:
221
+ return None, None, None
222
+ return " >> ".join(parts), token, lossy
223
+
224
+
225
+ def _humanize_token(token) -> Optional[str]:
226
+ if not token:
227
+ return None
228
+ t = re.sub(r"[-_]+", " ", str(token)).strip()
229
+ return t or None
230
+
231
+
232
+ def _strip_target(url, base_url: Optional[str]) -> str:
233
+ """Strip the origin from ``url`` when it matches the role's base URL → a path."""
234
+ if url is _MISSING or not isinstance(url, str):
235
+ return str(url)
236
+ if not base_url:
237
+ return url
238
+ try:
239
+ u, b = urlsplit(url), urlsplit(base_url)
240
+ except ValueError:
241
+ return url
242
+ if u.scheme == b.scheme and u.netloc == b.netloc and u.netloc:
243
+ path = u.path or "/"
244
+ if u.query:
245
+ path += "?" + u.query
246
+ return path
247
+ return url
248
+
249
+
250
+ # ── Statement → step dict ───────────────────────────────────────────────────
251
+
252
+
253
+ def _custom_step(stmt: ast.stmt, role: str, notes: list) -> dict:
254
+ code = ast.unparse(stmt) if hasattr(ast, "unparse") else "# (unrecognized recorded action)"
255
+ first = code.splitlines()[0] if code else ""
256
+ notes.append(f"kept an unrecognized recorded statement as a custom step: {first[:80]}")
257
+ return {"role": role, "action": "custom", "code": code, "_token": None}
258
+
259
+
260
+ def _page_step(chain, terminal, role, base_url, notes) -> Optional[dict]:
261
+ name = terminal.name
262
+ args = terminal.args
263
+
264
+ if name == "goto":
265
+ if not args or args[0] is _MISSING:
266
+ return None
267
+ return {"role": role, "action": "navigate", "target": _strip_target(args[0], base_url), "_token": None}
268
+
269
+ selector, token, lossy = _selector_from_chain(chain)
270
+ if not selector:
271
+ return None
272
+ tokh = _humanize_token(token)
273
+
274
+ if name == "click":
275
+ if lossy:
276
+ notes.append(lossy)
277
+ return {"role": role, "action": "click", "selector": selector, "_token": tokh}
278
+ if name in ("fill", "press_sequentially", "type"):
279
+ if not args or args[0] is _MISSING:
280
+ return None
281
+ if lossy:
282
+ notes.append(lossy)
283
+ return {"role": role, "action": "fill", "selector": selector, "value": args[0], "_token": tokh, "_field": token}
284
+ if name == "select_option":
285
+ val = args[0] if (args and args[0] is not _MISSING) else None
286
+ if val is None:
287
+ val = terminal.kwargs.get("value") or terminal.kwargs.get("label")
288
+ if val is None or val is _MISSING:
289
+ return None
290
+ if lossy:
291
+ notes.append(lossy)
292
+ return {"role": role, "action": "select", "selector": selector, "value": val, "_token": tokh, "_field": token}
293
+ if name == "press":
294
+ if not args or args[0] is _MISSING:
295
+ return None
296
+ return {"role": role, "action": "keyboard", "value": args[0], "_token": _humanize_token(args[0])}
297
+ if name in ("check", "uncheck", "set_checked"):
298
+ if lossy:
299
+ notes.append(lossy)
300
+ notes.append(f"recorded {name}() emitted as a click - verify it toggles as expected")
301
+ return {"role": role, "action": "click", "selector": selector, "_token": tokh}
302
+ if name == "hover":
303
+ if lossy:
304
+ notes.append(lossy)
305
+ return {"role": role, "action": "hover", "selector": selector, "_token": tokh}
306
+ if name == "set_input_files":
307
+ if not args or args[0] is _MISSING:
308
+ return None
309
+ return {"role": role, "action": "upload", "selector": selector, "value": args[0], "_token": tokh}
310
+ return None
311
+
312
+
313
+ def _expect_step(base_args, segs, role, base_url, notes) -> Optional[dict]:
314
+ if not segs:
315
+ return None
316
+ matcher = segs[-1]
317
+ inner_sel = None
318
+ tokh = None
319
+ if base_args:
320
+ w = _walk(base_args[0])
321
+ if w:
322
+ b2, _, inner_segs = w
323
+ if b2 == "page":
324
+ inner_sel, tok, lossy = _selector_from_chain(inner_segs)
325
+ tokh = _humanize_token(tok)
326
+ if lossy:
327
+ notes.append(lossy)
328
+
329
+ mname = matcher.name
330
+ margs = matcher.args
331
+ have_text = margs and margs[0] is not _MISSING
332
+ if mname == "to_be_visible" and inner_sel:
333
+ expect = f"visible({inner_sel})"
334
+ elif mname == "to_be_hidden" and inner_sel:
335
+ expect = f"hidden({inner_sel})"
336
+ elif mname == "to_contain_text" and inner_sel and have_text:
337
+ expect = f'contains({inner_sel}, "{margs[0]}")'
338
+ elif mname == "to_have_text" and inner_sel and have_text:
339
+ expect = f'text({inner_sel}, "{margs[0]}")'
340
+ elif mname == "to_have_url" and have_text:
341
+ expect = f'url-contains("{_strip_target(margs[0], base_url)}")'
342
+ else:
343
+ notes.append(f"dropped a recorded assertion (.{mname}) - add an expect: manually if you need it")
344
+ return None
345
+ return {"role": role, "action": "assert", "expect": expect, "_token": tokh}
346
+
347
+
348
+ def _call_to_step(call: ast.Call, role, base_url, notes) -> Optional[dict]:
349
+ walked = _walk(call)
350
+ if walked is None:
351
+ return None
352
+ base, base_args, segs = walked
353
+ if base == "page":
354
+ if not segs:
355
+ return None
356
+ terminal = segs[-1]
357
+ chain = segs[:-1]
358
+ return _page_step(chain, terminal, role, base_url, notes)
359
+ if base == "expect":
360
+ return _expect_step(base_args, segs, role, base_url, notes)
361
+ return None
362
+
363
+
364
+ def _stmt_to_step(stmt: ast.stmt, role, base_url, notes) -> Optional[dict]:
365
+ if isinstance(stmt, ast.Expr):
366
+ if isinstance(stmt.value, ast.Constant):
367
+ return None # a docstring
368
+ if isinstance(stmt.value, ast.Call):
369
+ return _call_to_step(stmt.value, role, base_url, notes) or _custom_step(stmt, role, notes)
370
+ return None
371
+ if isinstance(stmt, ast.Pass):
372
+ return None
373
+ # with / assign / if / ... - keep verbatim for the reviewer.
374
+ return _custom_step(stmt, role, notes)
375
+
376
+
377
+ # ── Login collapse + placeholderize ─────────────────────────────────────────
378
+
379
+
380
+ def _collapse_login(steps: list[dict], role: str, notes: list) -> bool:
381
+ """Collapse a username/password/submit sequence into ``- action: login``.
382
+
383
+ Returns True when a collapse happened (⇒ scenario frontmatter ``auth: false``).
384
+ """
385
+ pi = next(
386
+ (
387
+ i
388
+ for i, s in enumerate(steps)
389
+ if s["action"] == "fill" and s.get("_field") and _PASSWORD_RE.search(str(s["_field"]))
390
+ ),
391
+ None,
392
+ )
393
+ if pi is None:
394
+ return False
395
+ remove = {pi}
396
+ ui = next((i for i in range(pi - 1, -1, -1) if steps[i]["action"] == "fill"), None)
397
+ if ui is not None:
398
+ remove.add(ui)
399
+ si = next((i for i in range(pi + 1, len(steps)) if steps[i]["action"] == "click"), None)
400
+ if si is not None:
401
+ remove.add(si)
402
+ insert_at = min(remove)
403
+ new = [s for i, s in enumerate(steps) if i not in remove]
404
+ new.insert(insert_at, {"role": role, "action": "login", "_token": None})
405
+ steps[:] = new
406
+ notes.append(
407
+ "collapsed the recorded username/password/submit into `- action: login` - "
408
+ "credentials come from config.yaml (${STEPPY_%s_USERNAME/PASSWORD}), never the recording" % _envify(role)
409
+ )
410
+ return True
411
+
412
+
413
+ def _envify(text) -> str:
414
+ s = re.sub(r"[^0-9A-Za-z]+", "_", str(text)).strip("_").upper()
415
+ return s or "VALUE"
416
+
417
+
418
+ def _placeholderize(steps, role, placeholderize_all, env_vars: dict, mask: set, notes, login_collapsed):
419
+ """Replace secret (or, with ``--placeholderize-all``, every) fill/select value
420
+ with a ``${STEPPY_<ROLE>_<FIELD>}`` env ref; collect env names + mask selectors."""
421
+ saw_password = login_collapsed
422
+ for s in steps:
423
+ if s["action"] not in ("fill", "select"):
424
+ continue
425
+ f = s.get("_field")
426
+ if f and _PASSWORD_RE.search(str(f)):
427
+ saw_password = True
428
+ if placeholderize_all or (f and _SECRET_RE.search(str(f))):
429
+ base_env = f"STEPPY_{_envify(role)}_{_envify(f or 'value')}"
430
+ env = base_env
431
+ n = 2
432
+ while env in env_vars:
433
+ env = f"{base_env}_{n}"
434
+ n += 1
435
+ s["value"] = "${" + env + "}"
436
+ env_vars[env] = True
437
+ if s.get("selector"):
438
+ mask.add(s["selector"])
439
+ if saw_password:
440
+ mask.add("input[type='password']")
441
+ if placeholderize_all:
442
+ notes.append("--placeholderize-all: every recorded value is a ${VAR} ref - set them before running")
443
+
444
+
445
+ # ── Titles + rendering ──────────────────────────────────────────────────────
446
+
447
+
448
+ def _step_title(step: dict) -> str:
449
+ action = step["action"]
450
+ tok = step.get("_token")
451
+ if action == "navigate":
452
+ return f"Open {step.get('target', 'page')}"
453
+ if action == "login":
454
+ return f"Sign in as {step['role']}"
455
+ if action == "keyboard":
456
+ return f"Press {step.get('value', 'key')}"
457
+ if action == "custom":
458
+ return "Review recorded action"
459
+ verbs = {
460
+ "click": "Click",
461
+ "fill": "Fill",
462
+ "select": "Select",
463
+ "hover": "Hover",
464
+ "upload": "Upload",
465
+ "assert": "Verify",
466
+ }
467
+ verb = verbs.get(action, action.capitalize())
468
+ return f"{verb} {tok}" if tok else verb
469
+
470
+
471
+ def _emit_value(v: str) -> str:
472
+ """Wrap a step value so the parser's surrounding-quote strip preserves it."""
473
+ if '"' in v and "'" not in v:
474
+ return "'" + v + "'"
475
+ return '"' + v + '"'
476
+
477
+
478
+ def _render(title: str, auth_false: bool, steps: list[dict]) -> str:
479
+ fm: dict = {"title": title, "description": "Recorded with steppy record", "tags": ["e2e"]}
480
+ if auth_false:
481
+ fm["auth"] = False
482
+ out: list[str] = ["---", yaml.safe_dump(fm, sort_keys=False, allow_unicode=True).strip(), "---", ""]
483
+ for i, s in enumerate(steps, 1):
484
+ out.append(f"### {i}. {s['_title']}")
485
+ out.append(f"- role: {s['role']}")
486
+ out.append(f"- action: {s['action']}")
487
+ if s.get("target") is not None:
488
+ out.append(f"- target: {s['target']}")
489
+ if s.get("selector"):
490
+ out.append(f"- selector: {s['selector']}")
491
+ if s.get("value") is not None:
492
+ out.append(f"- value: {_emit_value(str(s['value']))}")
493
+ if s.get("expect"):
494
+ out.append(f"- expect: {s['expect']}")
495
+ if s.get("state"):
496
+ out.append(f"- state: {s['state']}")
497
+ if s.get("note"):
498
+ out.append(f"- note: {s['note']}")
499
+ if s.get("code"):
500
+ out.append("- code: |")
501
+ for line in s["code"].splitlines():
502
+ out.append(f" {line}")
503
+ out.append("")
504
+ text = "\n".join(out)
505
+ if not text.endswith("\n"):
506
+ text += "\n"
507
+ return text
508
+
509
+
510
+ def _slugify(text: str) -> str:
511
+ s = re.sub(r"[^0-9a-zA-Z]+", "-", text.strip().lower()).strip("-")
512
+ return s or "recording"
513
+
514
+
515
+ def _humanize(text: str) -> str:
516
+ s = re.sub(r"[-_]+", " ", text).strip()
517
+ return (s[:1].upper() + s[1:]) if s else "Recorded flow"
518
+
519
+
520
+ # ── The pure transform (unit-tested without a browser) ──────────────────────
521
+
522
+
523
+ def codegen_source_to_scenario(
524
+ source: str,
525
+ *,
526
+ role: str,
527
+ title: str,
528
+ base_url: Optional[str] = None,
529
+ auth_mode: Optional[str] = None,
530
+ has_credentials: bool = False,
531
+ placeholderize_all: bool = False,
532
+ ) -> RecordResult:
533
+ """Transform a ``playwright codegen --target python-pytest`` source into a
534
+ Steppy scenario Markdown draft. Pure and deterministic (no browser, no clock)."""
535
+ tree = ast.parse(source)
536
+ fn = _find_test_fn(tree)
537
+ notes: list = []
538
+ steps: list[dict] = []
539
+ if fn is not None:
540
+ for stmt in fn.body:
541
+ st = _stmt_to_step(stmt, role, base_url, notes)
542
+ if st:
543
+ steps.append(st)
544
+
545
+ auth_false = False
546
+ if auth_mode == "form" and has_credentials:
547
+ auth_false = _collapse_login(steps, role, notes)
548
+
549
+ env_vars: dict = {}
550
+ mask: set = set()
551
+ _placeholderize(steps, role, placeholderize_all, env_vars, mask, notes, auth_false)
552
+
553
+ for s in steps:
554
+ s["_title"] = _step_title(s)
555
+
556
+ markdown = _render(title, auth_false, steps)
557
+ return RecordResult(
558
+ markdown=markdown,
559
+ step_count=len(steps),
560
+ notes=notes,
561
+ env_vars=sorted(env_vars),
562
+ mask_selectors=sorted(mask),
563
+ )
564
+
565
+
566
+ # ── Command ─────────────────────────────────────────────────────────────────
567
+
568
+
569
+ def _print_report(result: RecordResult, out_path: Path) -> None:
570
+ if result.notes:
571
+ console.print("\n[bold]Review before committing:[/bold]")
572
+ for note in result.notes:
573
+ console.print(f" [dim]note[/dim] {escape(note)}")
574
+ if result.mask_selectors:
575
+ console.print(
576
+ "\n[bold]Redact secrets in screenshots[/bold] - add to config.yaml "
577
+ "(comments there are preserved only if you edit by hand):"
578
+ )
579
+ console.print(" screenshot:")
580
+ console.print(" mask:")
581
+ for sel in result.mask_selectors:
582
+ console.print(f' - "{escape(sel)}"')
583
+ if result.env_vars:
584
+ console.print("\n[bold]Set these before running the generated test:[/bold]")
585
+ for name in result.env_vars:
586
+ console.print(f" export {name}=...")
587
+ console.print(f"\n[bold]Next:[/bold] steppy validate {out_path.as_posix()} && steppy generate")
588
+ console.print(
589
+ "Refine the draft with the [bold]steppy-scenario-author[/bold] skill "
590
+ "(installed by `steppy init` in .claude/skills/)."
591
+ )
592
+
593
+
594
+ def _run_record(
595
+ config,
596
+ url: Optional[str],
597
+ role: Optional[str],
598
+ scenarios_dir: Path,
599
+ slug: Optional[str],
600
+ title: Optional[str],
601
+ placeholderize_all: bool,
602
+ browser: str,
603
+ channel: Optional[str],
604
+ no_auth: bool,
605
+ test_id_attribute: str,
606
+ ) -> None:
607
+ apps = config.apps
608
+ if not apps:
609
+ _error("no apps configured in config.yaml - nothing to record against")
610
+ raise typer.Exit(1)
611
+ if role is None:
612
+ role = next(iter(apps))
613
+ if role not in apps:
614
+ _error(f"unknown role '{role}' - configured roles: {', '.join(apps)}")
615
+ raise typer.Exit(1)
616
+ if url is None:
617
+ url = apps[role].url
618
+
619
+ if slug is None and title is None:
620
+ _error("provide --slug or --title so the draft has a filename (scenarios/<slug>.md)")
621
+ raise typer.Exit(1)
622
+ if title is None:
623
+ title = _humanize(slug)
624
+ if slug is None:
625
+ slug = _slugify(title)
626
+ out_path = scenarios_dir / f"{slug}.md"
627
+ if out_path.exists():
628
+ _error(f"refusing to overwrite {out_path.as_posix()} - choose a different --slug")
629
+ raise typer.Exit(1)
630
+
631
+ fd, tmp = tempfile.mkstemp(suffix="_codegen.py", prefix="steppy_record_")
632
+ os.close(fd)
633
+ tmp_path = Path(tmp)
634
+ try:
635
+ argv = [
636
+ sys.executable,
637
+ "-m",
638
+ "playwright",
639
+ "codegen",
640
+ url,
641
+ "--target",
642
+ "python-pytest",
643
+ "-o",
644
+ str(tmp_path),
645
+ "--test-id-attribute",
646
+ test_id_attribute,
647
+ "-b",
648
+ browser,
649
+ ]
650
+ if channel:
651
+ argv += ["--channel", channel]
652
+ storage = Path(config.auth.storage_state_dir) / f"{role}.json"
653
+ if not no_auth:
654
+ if storage.exists():
655
+ argv += ["--load-storage", str(storage)]
656
+ console.print(f"[dim]Using cached auth for '{role}' ({storage.as_posix()}).[/dim]")
657
+ else:
658
+ console.print(
659
+ f"[yellow]note[/yellow] no cached auth for '{role}' "
660
+ f"({storage.as_posix()}); recording unauthenticated. Run "
661
+ f"`steppy auth login {role}` first to record a logged-in flow."
662
+ )
663
+ console.print(f"\n[bold]Recording[/bold] as '{role}' at {url}")
664
+ console.print("A browser will open - perform your flow, then close the window to finish.\n")
665
+ try:
666
+ proc = subprocess.run(argv)
667
+ except FileNotFoundError:
668
+ _error("could not launch Playwright - is it installed? Try: pip install playwright && playwright install")
669
+ raise typer.Exit(1)
670
+ if proc.returncode != 0:
671
+ _error(
672
+ f"playwright codegen exited with code {proc.returncode}. "
673
+ f"If browsers are missing, run: playwright install {browser}"
674
+ )
675
+ raise typer.Exit(1)
676
+ source = tmp_path.read_text(encoding="utf-8") if tmp_path.exists() else ""
677
+ finally:
678
+ tmp_path.unlink(missing_ok=True)
679
+
680
+ try:
681
+ result = codegen_source_to_scenario(
682
+ source,
683
+ role=role,
684
+ title=title,
685
+ base_url=apps[role].url,
686
+ auth_mode=apps[role].auth.mode,
687
+ has_credentials=apps[role].auth.credentials is not None,
688
+ placeholderize_all=placeholderize_all,
689
+ )
690
+ except SyntaxError:
691
+ _error("could not parse the recorded output - no scenario written")
692
+ raise typer.Exit(1)
693
+
694
+ if result.step_count == 0:
695
+ console.print(
696
+ "[yellow]WARNING[/yellow] nothing recorded (interact before closing the window) - no scenario written."
697
+ )
698
+ raise typer.Exit(0)
699
+
700
+ if out_path.exists():
701
+ _error(f"refusing to overwrite {out_path.as_posix()}")
702
+ raise typer.Exit(1)
703
+ out_path.parent.mkdir(parents=True, exist_ok=True)
704
+ out_path.write_text(result.markdown, encoding="utf-8", newline="\n")
705
+ console.print(f" [green]create[/green] {out_path.as_posix()}")
706
+
707
+ # Selector lint (best-effort - a validation hiccup degrades to a warning, the
708
+ # draft is already written for review).
709
+ try:
710
+ scenario = parse_scenario_file(out_path)
711
+ for warn in lint_scenario_selectors(scenario):
712
+ console.print(f" [yellow]WARNING[/yellow] {escape(str(warn))}")
713
+ except ParseError as e:
714
+ console.print(
715
+ f" [yellow]WARNING[/yellow] the draft did not validate cleanly - run "
716
+ f"`steppy validate {out_path.as_posix()}` to see why"
717
+ )
718
+ if _debug_enabled():
719
+ console.print(escape(str(e)))
720
+
721
+ _print_report(result, out_path)
722
+
723
+
724
+ def register_record(app: typer.Typer) -> None:
725
+ """Register the ``record`` command on the root Typer app."""
726
+
727
+ @app.command("record")
728
+ def record(
729
+ url: Optional[str] = typer.Argument(None, help="Start URL (default: the role's configured app URL)."),
730
+ role: Optional[str] = typer.Option(
731
+ None,
732
+ "--role",
733
+ "-r",
734
+ help="Role to record as (default: first app in config.yaml). Stamped on "
735
+ "every step; also selects .auth/<role>.json for an authenticated session.",
736
+ ),
737
+ scenarios_dir: Path = typer.Option(
738
+ Path("scenarios"),
739
+ "--scenarios-dir",
740
+ "-d",
741
+ help="Directory to write the draft scenario into.",
742
+ ),
743
+ slug: Optional[str] = typer.Option(
744
+ None,
745
+ "--slug",
746
+ help="Output filename stem → scenarios/<slug>.md (default: derived from --title).",
747
+ ),
748
+ title: Optional[str] = typer.Option(None, "--title", help="Scenario title (default: derived from --slug)."),
749
+ placeholderize_all: bool = typer.Option(
750
+ False,
751
+ "--placeholderize-all/--no-placeholderize-all",
752
+ help="Replace EVERY recorded value with a ${VAR} env ref (default: only "
753
+ "detected secrets are placeholderized; other values stay literal).",
754
+ ),
755
+ browser: str = typer.Option(
756
+ "chromium", "--browser", "-b", help="Browser to record in (chromium|firefox|webkit)."
757
+ ),
758
+ channel: Optional[str] = typer.Option(None, "--channel", help="Browser channel (e.g. chrome, msedge)."),
759
+ no_auth: bool = typer.Option(
760
+ False,
761
+ "--no-auth",
762
+ help="Do not load .auth/<role>.json - record a fresh, unauthenticated session "
763
+ "(use when recording the login flow itself).",
764
+ ),
765
+ test_id_attribute: str = typer.Option(
766
+ "data-testid",
767
+ "--test-id-attribute",
768
+ help="Attribute Playwright codegen prefers for locators.",
769
+ ),
770
+ ):
771
+ """Record a browser session into a draft Steppy scenario.
772
+
773
+ Wraps `playwright codegen`: a headed browser opens; perform your flow, then
774
+ close the window. The recorded actions become a draft `scenarios/<slug>.md`
775
+ - a reviewable starting point, not a finished scenario. Detected secrets are
776
+ collapsed to `- action: login` or placeholderized to ${VAR} refs, and a
777
+ password screenshot mask is recommended. Refine the draft with the
778
+ steppy-scenario-author skill, then `steppy validate` / `steppy generate`.
779
+
780
+ V1 (CLI plumbing) - extended when Steppy lands on the OSS showcases.
781
+ """
782
+ with _cli_errors():
783
+ config = _load_config_or_exit()
784
+ _run_record(
785
+ config,
786
+ url,
787
+ role,
788
+ scenarios_dir,
789
+ slug,
790
+ title,
791
+ placeholderize_all,
792
+ browser,
793
+ channel,
794
+ no_auth,
795
+ test_id_attribute,
796
+ )