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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
)
|