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/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
|