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/migrate/plan.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Turn a scenario file into a plan of rewrites, without touching disk.
|
|
2
|
+
|
|
3
|
+
Splitting the plan from the write is what makes ``--dry-run`` free and the
|
|
4
|
+
report honest: the same code path produces both, so what the preview says is
|
|
5
|
+
exactly what a real run would do.
|
|
6
|
+
|
|
7
|
+
Two folds happen here rather than in :mod:`steppy.migrate.rules`, because both
|
|
8
|
+
need to see a statement's *neighbours*:
|
|
9
|
+
|
|
10
|
+
* a trailing ``wait_for_timeout`` becomes the previous step's ``wait-after:``
|
|
11
|
+
rather than a standalone ``wait`` step. Without it, converting the 420
|
|
12
|
+
pure-wait blocks in one project would *add* 121 steps — and in a guide
|
|
13
|
+
scenario every one of those is a published step with its own number and
|
|
14
|
+
screenshot slot;
|
|
15
|
+
* a bare ``wait_for_load_state`` folds into the ``navigate`` before it as
|
|
16
|
+
``wait:``, which is what that field already means.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
from .rules import parse_block
|
|
27
|
+
|
|
28
|
+
__all__ = ["MigrationPlan", "StepPlan", "plan_file", "plan_scenarios"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class StepPlan:
|
|
33
|
+
"""One ``custom`` step and what it could become."""
|
|
34
|
+
|
|
35
|
+
heading: str
|
|
36
|
+
line: int
|
|
37
|
+
code: str
|
|
38
|
+
#: Other ``- key: value`` lines on the step (note, screenshot, guide-text …),
|
|
39
|
+
#: preserved verbatim on every rewritten step's first sibling.
|
|
40
|
+
keys: list[tuple[str, str]]
|
|
41
|
+
role: Optional[str]
|
|
42
|
+
steps: Optional[list[dict]] = None
|
|
43
|
+
reason: str = ""
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def convertible(self) -> bool:
|
|
47
|
+
return self.steps is not None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class MigrationPlan:
|
|
52
|
+
path: Path
|
|
53
|
+
steps: list[StepPlan] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def convertible(self) -> list[StepPlan]:
|
|
57
|
+
return [s for s in self.steps if s.convertible]
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def skipped(self) -> list[StepPlan]:
|
|
61
|
+
return [s for s in self.steps if not s.convertible]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_HEADING = re.compile(r"^###\s+(.*)$")
|
|
65
|
+
_KEY = re.compile(r"^- ([a-zA-Z0-9_-]+):\s*(.*)$")
|
|
66
|
+
_BLOCK_SCALAR = {"|", "|-", "|+", ">", ">-", ">+"}
|
|
67
|
+
#: A value captured as a raw indented block is stored with a LEADING newline,
|
|
68
|
+
#: which no real `- key: value` can ever have. A sentinel string was tried
|
|
69
|
+
#: first and was the wrong shape: any marker that leaks reaches compile().
|
|
70
|
+
_NESTED = "\n"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _fold(steps: list[dict]) -> list[dict]:
|
|
74
|
+
"""Apply the two neighbour-aware folds. Pure; order-preserving."""
|
|
75
|
+
folded: list[dict] = []
|
|
76
|
+
for step in steps:
|
|
77
|
+
if step.get("action") == "_load-state":
|
|
78
|
+
if folded and folded[-1].get("action") == "navigate":
|
|
79
|
+
folded[-1]["wait"] = step["wait"]
|
|
80
|
+
continue
|
|
81
|
+
# A load-state wait with no navigation before it is a `wait-for`
|
|
82
|
+
# concern, and we have no selector for one. Leave the block alone.
|
|
83
|
+
return []
|
|
84
|
+
if (
|
|
85
|
+
step.get("action") == "wait"
|
|
86
|
+
and folded
|
|
87
|
+
and folded[-1].get("action") not in (None, "wait")
|
|
88
|
+
and "wait_after" not in folded[-1]
|
|
89
|
+
):
|
|
90
|
+
duration = str(step["duration"])
|
|
91
|
+
if duration.endswith("ms") and duration[:-2].isdigit():
|
|
92
|
+
folded[-1]["wait_after"] = int(duration[:-2])
|
|
93
|
+
continue
|
|
94
|
+
folded.append(step)
|
|
95
|
+
return folded
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parse_steps(text: str) -> list[tuple[str, int, list[tuple[str, str]]]]:
|
|
99
|
+
"""Every ``### `` step block as (heading, 1-based line, key/value pairs).
|
|
100
|
+
|
|
101
|
+
Block scalars are reassembled into their key's value so a ``code:`` body
|
|
102
|
+
survives intact.
|
|
103
|
+
"""
|
|
104
|
+
lines = text.splitlines()
|
|
105
|
+
blocks: list[tuple[str, int, list[tuple[str, str]]]] = []
|
|
106
|
+
heading: Optional[str] = None
|
|
107
|
+
start = 0
|
|
108
|
+
pairs: list[tuple[str, str]] = []
|
|
109
|
+
i = 0
|
|
110
|
+
|
|
111
|
+
def flush() -> None:
|
|
112
|
+
if heading is not None:
|
|
113
|
+
blocks.append((heading, start, pairs))
|
|
114
|
+
|
|
115
|
+
while i < len(lines):
|
|
116
|
+
head = _HEADING.match(lines[i])
|
|
117
|
+
if head:
|
|
118
|
+
flush()
|
|
119
|
+
heading, start, pairs = lines[i], i + 1, []
|
|
120
|
+
i += 1
|
|
121
|
+
continue
|
|
122
|
+
if heading is None:
|
|
123
|
+
i += 1
|
|
124
|
+
continue
|
|
125
|
+
key = _KEY.match(lines[i])
|
|
126
|
+
if not key:
|
|
127
|
+
i += 1
|
|
128
|
+
continue
|
|
129
|
+
name, value = key.group(1), key.group(2).strip()
|
|
130
|
+
if value in _BLOCK_SCALAR:
|
|
131
|
+
body: list[str] = []
|
|
132
|
+
i += 1
|
|
133
|
+
while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", "\t"))):
|
|
134
|
+
if lines[i].strip() and _KEY.match(lines[i].strip()) and not lines[i].startswith(" "):
|
|
135
|
+
break
|
|
136
|
+
body.append(lines[i])
|
|
137
|
+
i += 1
|
|
138
|
+
indent = min((len(b) - len(b.lstrip()) for b in body if b.strip()), default=0)
|
|
139
|
+
pairs.append((name, "\n".join(b[indent:] if b.strip() else "" for b in body).rstrip()))
|
|
140
|
+
continue
|
|
141
|
+
if not value:
|
|
142
|
+
# A nested block (`guide-cursor:` / `guide-text:` / `fields:`). Kept
|
|
143
|
+
# as raw indented text and re-emitted verbatim: the codemod does not
|
|
144
|
+
# need to understand it, only to carry it across unharmed.
|
|
145
|
+
body = []
|
|
146
|
+
i += 1
|
|
147
|
+
while i < len(lines) and (not lines[i].strip() or lines[i].startswith((" ", " "))):
|
|
148
|
+
body.append(lines[i])
|
|
149
|
+
i += 1
|
|
150
|
+
if body:
|
|
151
|
+
pairs.append((name, _NESTED + "\n".join(body).rstrip()))
|
|
152
|
+
continue
|
|
153
|
+
pairs.append((name, ""))
|
|
154
|
+
continue
|
|
155
|
+
# Same unquoting the real parser applies to a simple `key: value`, so
|
|
156
|
+
# the codemod reads exactly what Steppy reads. 3 984 of one project's
|
|
157
|
+
# blocks are a single quoted line, not a block scalar.
|
|
158
|
+
pairs.append((name, value.strip('"').strip("'")))
|
|
159
|
+
i += 1
|
|
160
|
+
flush()
|
|
161
|
+
return blocks
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def plan_file(path: Path) -> MigrationPlan:
|
|
165
|
+
"""What could be rewritten in one scenario file."""
|
|
166
|
+
plan = MigrationPlan(path=path)
|
|
167
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
168
|
+
for heading, line, pairs in _parse_steps(text):
|
|
169
|
+
as_dict = dict(pairs)
|
|
170
|
+
if as_dict.get("action") != "custom":
|
|
171
|
+
continue
|
|
172
|
+
code = as_dict.get("code", "")
|
|
173
|
+
# `role` is carried on StepPlan and written first on every fragment;
|
|
174
|
+
# leaving it in `extra` too would emit it twice.
|
|
175
|
+
other = [(k, v) for k, v in pairs if k not in ("action", "code", "role")]
|
|
176
|
+
entry = StepPlan(heading=heading, line=line, code=code, keys=other, role=as_dict.get("role"))
|
|
177
|
+
parsed = parse_block(code) if code.strip() else None
|
|
178
|
+
if parsed is None:
|
|
179
|
+
entry.reason = "a statement matched no rule"
|
|
180
|
+
else:
|
|
181
|
+
folded = _fold(parsed)
|
|
182
|
+
if not folded:
|
|
183
|
+
entry.reason = "a load-state wait with no navigation before it"
|
|
184
|
+
else:
|
|
185
|
+
entry.steps = folded
|
|
186
|
+
plan.steps.append(entry)
|
|
187
|
+
return plan
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def plan_scenarios(directory: Path, only: Optional[str] = None) -> list[MigrationPlan]:
|
|
191
|
+
"""Plans for every scenario in ``directory``, or just the one named."""
|
|
192
|
+
plans = []
|
|
193
|
+
for path in sorted(directory.glob("*.md")):
|
|
194
|
+
if only and path.stem != only:
|
|
195
|
+
continue
|
|
196
|
+
plan = plan_file(path)
|
|
197
|
+
if plan.steps:
|
|
198
|
+
plans.append(plan)
|
|
199
|
+
return plans
|
steppy/migrate/rules.py
ADDED
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
"""Statement-level rewrite rules: one Playwright call → one declarative step.
|
|
2
|
+
|
|
3
|
+
The unit is a *statement*, not a block, because the corpus is overwhelmingly
|
|
4
|
+
made of short blocks that chain two or three of them with ``;``. Measured over
|
|
5
|
+
4 967 real ``custom`` bodies, twenty rules of this shape match every statement in
|
|
6
|
+
2 747 of them.
|
|
7
|
+
|
|
8
|
+
Each rule is a regex over a normalised statement plus a builder that returns the
|
|
9
|
+
step dict a scenario would have written. A rule returning ``None`` means "not
|
|
10
|
+
this one"; a statement no rule matches makes the whole block unconvertible, and
|
|
11
|
+
the block is left alone.
|
|
12
|
+
|
|
13
|
+
Pure and stdlib-only: no Playwright, no Jinja, no config. The decision about
|
|
14
|
+
whether a *block* may be rewritten lives in :mod:`steppy.migrate.plan`, and the
|
|
15
|
+
proof that the rewrite is equivalent lives in :mod:`steppy.migrate.verify`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import re
|
|
22
|
+
from typing import Any, Callable, Optional
|
|
23
|
+
|
|
24
|
+
__all__ = ["RULES", "parse_block", "split_statements", "statements"]
|
|
25
|
+
|
|
26
|
+
# ── Statement splitting ─────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
# `page`, or `<role>_page`, as the corpus writes it.
|
|
29
|
+
_PAGE = r"(?:\w+_)?page"
|
|
30
|
+
_STR = r"""(?:'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*")"""
|
|
31
|
+
_MATCH = r"(?:\.(?:first|last|nth\(\d+\)))?"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_statements(code: str) -> list[str]:
|
|
35
|
+
"""Split a ``custom`` body into statements, respecting quotes and brackets.
|
|
36
|
+
|
|
37
|
+
Bodies come in two shapes and both have to work: a YAML block scalar with
|
|
38
|
+
one statement per line, and a single quoted line chaining several with
|
|
39
|
+
``;``. 3 984 of one project's 4 967 blocks are the second kind.
|
|
40
|
+
"""
|
|
41
|
+
out: list[str] = []
|
|
42
|
+
for line in code.splitlines():
|
|
43
|
+
parts: list[str] = []
|
|
44
|
+
buf: list[str] = []
|
|
45
|
+
depth = 0
|
|
46
|
+
quote: Optional[str] = None
|
|
47
|
+
escaped = False
|
|
48
|
+
for ch in line:
|
|
49
|
+
if escaped:
|
|
50
|
+
buf.append(ch)
|
|
51
|
+
escaped = False
|
|
52
|
+
continue
|
|
53
|
+
if quote:
|
|
54
|
+
buf.append(ch)
|
|
55
|
+
if ch == "\\":
|
|
56
|
+
escaped = True
|
|
57
|
+
elif ch == quote:
|
|
58
|
+
quote = None
|
|
59
|
+
continue
|
|
60
|
+
if ch in "\"'":
|
|
61
|
+
quote = ch
|
|
62
|
+
buf.append(ch)
|
|
63
|
+
continue
|
|
64
|
+
if ch in "([{":
|
|
65
|
+
depth += 1
|
|
66
|
+
elif ch in ")]}":
|
|
67
|
+
depth -= 1
|
|
68
|
+
if ch == ";" and depth == 0:
|
|
69
|
+
parts.append("".join(buf))
|
|
70
|
+
buf = []
|
|
71
|
+
continue
|
|
72
|
+
buf.append(ch)
|
|
73
|
+
parts.append("".join(buf))
|
|
74
|
+
for part in parts:
|
|
75
|
+
stripped = part.strip()
|
|
76
|
+
if stripped and not stripped.startswith("#"):
|
|
77
|
+
out.extend(_split_tuple_expression(stripped))
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
#: Keywords whose own grammar puts a comma at the top level of a statement.
|
|
82
|
+
_COMMA_KEYWORDS = frozenset(
|
|
83
|
+
{"assert", "raise", "del", "return", "yield", "import", "from", "global", "nonlocal", "with", "for", "lambda"}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _split_tuple_expression(statement: str) -> list[str]:
|
|
88
|
+
"""``a(...), b(...)`` -> two statements.
|
|
89
|
+
|
|
90
|
+
Python's comma operator lets two calls share a line, and the corpus uses it:
|
|
91
|
+
``page.locator(sel).wait_for(...), ensure_open(page, sel)`` appears in 74
|
|
92
|
+
blocks. That is two statements wearing one coat, and splitting it here means
|
|
93
|
+
every rule sees the shape it expects instead of needing a tuple variant.
|
|
94
|
+
|
|
95
|
+
Only top-level commas count, and only when the statement is neither an
|
|
96
|
+
assignment (``a, b = f()`` is one statement) nor led by a keyword whose own
|
|
97
|
+
syntax uses a top-level comma. ``assert x, "message"`` is the one that
|
|
98
|
+
matters: splitting it silently threw away the failure message, which 78% of
|
|
99
|
+
the corpus's assert blocks carry.
|
|
100
|
+
"""
|
|
101
|
+
if statement.split("(", 1)[0].split()[0:1] and statement.split()[0] in _COMMA_KEYWORDS:
|
|
102
|
+
return [statement]
|
|
103
|
+
depth = 0
|
|
104
|
+
quote: Optional[str] = None
|
|
105
|
+
escaped = False
|
|
106
|
+
cuts: list[int] = []
|
|
107
|
+
for index, ch in enumerate(statement):
|
|
108
|
+
if escaped:
|
|
109
|
+
escaped = False
|
|
110
|
+
continue
|
|
111
|
+
if quote:
|
|
112
|
+
if ch == "\\":
|
|
113
|
+
escaped = True
|
|
114
|
+
elif ch == quote:
|
|
115
|
+
quote = None
|
|
116
|
+
continue
|
|
117
|
+
if ch in "\"'":
|
|
118
|
+
quote = ch
|
|
119
|
+
elif ch in "([{":
|
|
120
|
+
depth += 1
|
|
121
|
+
elif ch in ")]}":
|
|
122
|
+
depth -= 1
|
|
123
|
+
elif ch == "=" and depth == 0 and statement[index - 1 : index] not in ("=", "!", "<", ">"):
|
|
124
|
+
return [statement] # an assignment; the comma binds the target list
|
|
125
|
+
elif ch == "," and depth == 0:
|
|
126
|
+
cuts.append(index)
|
|
127
|
+
if not cuts:
|
|
128
|
+
return [statement]
|
|
129
|
+
pieces, previous = [], 0
|
|
130
|
+
for cut in cuts:
|
|
131
|
+
pieces.append(statement[previous:cut].strip())
|
|
132
|
+
previous = cut + 1
|
|
133
|
+
pieces.append(statement[previous:].strip())
|
|
134
|
+
return [piece for piece in pieces if piece]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
#: Kept under the historical name used by the analysis scripts.
|
|
138
|
+
statements = split_statements
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _literal(text: str) -> Any:
|
|
142
|
+
"""A Python literal as its value, or the raw text if it is not one."""
|
|
143
|
+
try:
|
|
144
|
+
return ast.literal_eval(text.strip())
|
|
145
|
+
except (ValueError, SyntaxError):
|
|
146
|
+
return text.strip()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _match_field(suffix: str) -> Optional[str]:
|
|
150
|
+
"""``.first`` / ``.nth(2)`` → the ``match:`` value it corresponds to."""
|
|
151
|
+
if not suffix:
|
|
152
|
+
return None
|
|
153
|
+
if suffix == ".first":
|
|
154
|
+
return "first"
|
|
155
|
+
if suffix == ".last":
|
|
156
|
+
return "last"
|
|
157
|
+
nth = re.fullmatch(r"\.nth\((\d+)\)", suffix)
|
|
158
|
+
return nth.group(1) if nth else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _with_match(step: dict, suffix: str) -> dict:
|
|
162
|
+
value = _match_field(suffix)
|
|
163
|
+
if value is not None:
|
|
164
|
+
step["match"] = value
|
|
165
|
+
return step
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _ms(raw: Optional[str]) -> Optional[int]:
|
|
169
|
+
return int(raw) if raw else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── The rules ───────────────────────────────────────────────────────────────
|
|
173
|
+
#
|
|
174
|
+
# Order matters only where one pattern could shadow another; each is anchored,
|
|
175
|
+
# so in practice they are independent.
|
|
176
|
+
|
|
177
|
+
Rule = tuple[str, re.Pattern, Callable[[re.Match], Optional[dict]]]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _rule(name: str, pattern: str, build: Callable[[re.Match], Optional[dict]]) -> Rule:
|
|
181
|
+
return name, re.compile(pattern), build
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _click(m: re.Match) -> dict:
|
|
185
|
+
step = {"action": "click", "selector": _literal(m.group("sel"))}
|
|
186
|
+
if m.group("force"):
|
|
187
|
+
step["force"] = True
|
|
188
|
+
return _with_match(step, m.group("match") or "")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _check(m: re.Match) -> dict:
|
|
192
|
+
# `toggle` is idempotent where a bare `.check()` is not, which is strictly
|
|
193
|
+
# better: re-running a scenario cannot untick a box that was already ticked.
|
|
194
|
+
step = {
|
|
195
|
+
"action": "toggle",
|
|
196
|
+
"selector": _literal(m.group("sel")),
|
|
197
|
+
"control": "self",
|
|
198
|
+
"state": "off" if m.group("un") else "on",
|
|
199
|
+
}
|
|
200
|
+
return _with_match(step, m.group("match") or "")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ambiguous_selector(selector: str) -> bool:
|
|
204
|
+
"""True when a bare selector holds a comma the `expect:` grammar cannot place.
|
|
205
|
+
|
|
206
|
+
`count(#a, #b, >=1)` has no reading: the grammar splits on the first comma
|
|
207
|
+
and cannot tell the selector list from the argument separator. Rather than
|
|
208
|
+
emit an assertion that means something else, the block stays custom.
|
|
209
|
+
"""
|
|
210
|
+
depth = 0
|
|
211
|
+
for ch in selector:
|
|
212
|
+
if ch in "([{":
|
|
213
|
+
depth += 1
|
|
214
|
+
elif ch in ")]}":
|
|
215
|
+
depth -= 1
|
|
216
|
+
elif ch == "," and depth == 0:
|
|
217
|
+
return True
|
|
218
|
+
return False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _assert(check: str, message: Optional[str]) -> Optional[dict]:
|
|
222
|
+
"""An assert step, carrying its failure message when there is one.
|
|
223
|
+
|
|
224
|
+
The message is the reason the author wrote the assertion by hand at all —
|
|
225
|
+
78% of the corpus's assert blocks have one — so a rewrite that silently
|
|
226
|
+
dropped it would trade a described failure for a bare traceback. Only a
|
|
227
|
+
literal message is kept: an f-string interpolates the observed value, which
|
|
228
|
+
a step cannot express, so those blocks stay custom.
|
|
229
|
+
"""
|
|
230
|
+
inner = check[check.index("(") + 1 : check.rindex(")")]
|
|
231
|
+
if _ambiguous_selector(inner.rsplit(",", 1)[0]):
|
|
232
|
+
return None
|
|
233
|
+
if message is None:
|
|
234
|
+
return {"action": "assert", "expect": check}
|
|
235
|
+
try:
|
|
236
|
+
text = ast.literal_eval(message.strip())
|
|
237
|
+
except (ValueError, SyntaxError):
|
|
238
|
+
return None
|
|
239
|
+
if not isinstance(text, str):
|
|
240
|
+
return None
|
|
241
|
+
return {"action": "assert", "expect": [{"check": check, "message": text}]}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _wait_for(m: re.Match) -> dict:
|
|
245
|
+
step: dict = {"action": "wait-for", "selector": _literal(m.group("sel"))}
|
|
246
|
+
if m.group("state"):
|
|
247
|
+
step["state"] = _literal(m.group("state"))
|
|
248
|
+
if m.group("timeout"):
|
|
249
|
+
step["timeout"] = _ms(m.group("timeout"))
|
|
250
|
+
return _with_match(step, m.group("match") or "")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _wait_for_selector(m: re.Match) -> dict:
|
|
254
|
+
step: dict = {"action": "wait-for", "selector": _literal(m.group("sel"))}
|
|
255
|
+
if m.group("state"):
|
|
256
|
+
step["state"] = _literal(m.group("state"))
|
|
257
|
+
if m.group("timeout"):
|
|
258
|
+
step["timeout"] = _ms(m.group("timeout"))
|
|
259
|
+
return step
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
RULES: list[Rule] = [
|
|
263
|
+
_rule(
|
|
264
|
+
"click",
|
|
265
|
+
rf"^{_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.click\((?P<force>force=True)?\)$",
|
|
266
|
+
_click,
|
|
267
|
+
),
|
|
268
|
+
_rule(
|
|
269
|
+
"click-direct",
|
|
270
|
+
rf"^{_PAGE}\.click\((?P<sel>{_STR})\)$",
|
|
271
|
+
lambda m: {"action": "click", "selector": _literal(m.group("sel"))},
|
|
272
|
+
),
|
|
273
|
+
_rule(
|
|
274
|
+
"check",
|
|
275
|
+
rf"^{_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.(?P<un>un)?check\((?:force=True)?\)$",
|
|
276
|
+
_check,
|
|
277
|
+
),
|
|
278
|
+
_rule(
|
|
279
|
+
"wait-for",
|
|
280
|
+
rf"^{_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.wait_for\("
|
|
281
|
+
rf"(?:state=(?P<state>{_STR}))?(?:,\s*)?(?:timeout=(?P<timeout>\d+))?\)$",
|
|
282
|
+
_wait_for,
|
|
283
|
+
),
|
|
284
|
+
_rule(
|
|
285
|
+
"wait-for-selector",
|
|
286
|
+
rf"^{_PAGE}\.wait_for_selector\((?P<sel>{_STR})"
|
|
287
|
+
rf"(?:,\s*state=(?P<state>{_STR}))?(?:,\s*timeout=(?P<timeout>\d+))?\)$",
|
|
288
|
+
_wait_for_selector,
|
|
289
|
+
),
|
|
290
|
+
_rule(
|
|
291
|
+
"sleep",
|
|
292
|
+
rf"^{_PAGE}\.wait_for_timeout\((?P<ms>\d+)\)$",
|
|
293
|
+
lambda m: {"action": "wait", "duration": f"{m.group('ms')}ms"},
|
|
294
|
+
),
|
|
295
|
+
_rule(
|
|
296
|
+
"navigate",
|
|
297
|
+
rf"^{_PAGE}\.goto\((?P<target>{_STR})\)$",
|
|
298
|
+
lambda m: {"action": "navigate", "target": _literal(m.group("target"))},
|
|
299
|
+
),
|
|
300
|
+
_rule(
|
|
301
|
+
"reload",
|
|
302
|
+
rf"^{_PAGE}\.reload\((?:wait_until=(?P<wait>{_STR}))?\)$",
|
|
303
|
+
lambda m: {
|
|
304
|
+
"action": "navigate",
|
|
305
|
+
"reload": True,
|
|
306
|
+
**({"wait": _literal(m.group("wait"))} if m.group("wait") else {}),
|
|
307
|
+
},
|
|
308
|
+
),
|
|
309
|
+
_rule(
|
|
310
|
+
"load-state",
|
|
311
|
+
rf"^{_PAGE}\.wait_for_load_state\((?P<wait>{_STR})?\)$",
|
|
312
|
+
# Nothing to emit on its own: a bare load-state wait is a modifier on the
|
|
313
|
+
# navigation before it, and `plan` folds it into that step.
|
|
314
|
+
lambda m: {"action": "_load-state", "wait": _literal(m.group("wait")) if m.group("wait") else "load"},
|
|
315
|
+
),
|
|
316
|
+
_rule(
|
|
317
|
+
"fill",
|
|
318
|
+
rf"^{_PAGE}\.fill\((?P<sel>{_STR}),\s*(?P<value>{_STR})\)$",
|
|
319
|
+
lambda m: {"action": "fill", "selector": _literal(m.group("sel")), "value": _literal(m.group("value"))},
|
|
320
|
+
),
|
|
321
|
+
_rule(
|
|
322
|
+
"fill-locator",
|
|
323
|
+
rf"^{_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.fill\((?P<value>{_STR})\)$",
|
|
324
|
+
lambda m: _with_match(
|
|
325
|
+
{"action": "fill", "selector": _literal(m.group("sel")), "value": _literal(m.group("value"))},
|
|
326
|
+
m.group("match") or "",
|
|
327
|
+
),
|
|
328
|
+
),
|
|
329
|
+
_rule(
|
|
330
|
+
"hover",
|
|
331
|
+
rf"^{_PAGE}\.hover\((?P<sel>{_STR})\)$",
|
|
332
|
+
lambda m: {"action": "hover", "selector": _literal(m.group("sel"))},
|
|
333
|
+
),
|
|
334
|
+
_rule(
|
|
335
|
+
"scroll",
|
|
336
|
+
rf"^{_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.scroll_into_view_if_needed\(\)$",
|
|
337
|
+
lambda m: _with_match({"action": "scroll", "selector": _literal(m.group("sel"))}, m.group("match") or ""),
|
|
338
|
+
),
|
|
339
|
+
_rule(
|
|
340
|
+
"keyboard",
|
|
341
|
+
rf"^{_PAGE}\.keyboard\.press\((?P<key>{_STR})\)$",
|
|
342
|
+
lambda m: {"action": "keyboard", "value": _literal(m.group("key"))},
|
|
343
|
+
),
|
|
344
|
+
_rule(
|
|
345
|
+
"assert-count",
|
|
346
|
+
rf"^assert {_PAGE}\.locator\((?P<sel>{_STR})\)\.count\(\)\s*"
|
|
347
|
+
rf"(?P<cmp>>=|<=|==|!=|>|<)\s*(?P<n>\d+)\s*(?:,\s*(?P<msg>.*))?$",
|
|
348
|
+
lambda m: _assert(f"count({_literal(m.group('sel'))}, {m.group('cmp')}{m.group('n')})", m.group("msg")),
|
|
349
|
+
),
|
|
350
|
+
_rule(
|
|
351
|
+
"assert-visible",
|
|
352
|
+
rf"^assert {_PAGE}\.locator\((?P<sel>{_STR})\)(?P<match>{_MATCH})\.is_visible\(\)\s*(?:,\s*(?P<msg>.*))?$",
|
|
353
|
+
lambda m: _with_match(_assert(f"visible({_literal(m.group('sel'))})", m.group("msg")), m.group("match") or ""),
|
|
354
|
+
),
|
|
355
|
+
_rule(
|
|
356
|
+
"call",
|
|
357
|
+
# `from x_helpers import y` has already been stripped by `parse_block`;
|
|
358
|
+
# what is left is the call itself.
|
|
359
|
+
r"^(?P<fn>[a-z_]\w*)\((?P<page>(?:\w+_)?page)(?:,\s*(?P<args>.*))?\)$",
|
|
360
|
+
# The module is filled in by `parse_block`, which is the only place that
|
|
361
|
+
# has seen the import line.
|
|
362
|
+
lambda m: {"action": "call", "_fn_name": m.group("fn"), "_args_src": (m.group("args") or "").strip()},
|
|
363
|
+
),
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
#: Statements that carry no behaviour and are simply dropped.
|
|
367
|
+
#:
|
|
368
|
+
#: The ``sys.path`` preamble opens 2 334 blocks in one project and was never
|
|
369
|
+
#: necessary: pytest is launched as ``python -m pytest`` from the project root,
|
|
370
|
+
#: and ``python -m`` puts the working directory on ``sys.path[0]``.
|
|
371
|
+
_NOISE = re.compile(
|
|
372
|
+
r"^(?:"
|
|
373
|
+
r"None"
|
|
374
|
+
r"|import\s+sys(?:\s*,\s*\w+)*"
|
|
375
|
+
r"|import\s+(?:datetime|re|json|time|os)"
|
|
376
|
+
r"|sys\.path\.insert\(.*\)"
|
|
377
|
+
r")$"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
_IMPORT = re.compile(r"^from\s+(?P<module>[\w.]+)\s+import\s+(?P<names>.+)$")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def parse_block(code: str) -> Optional[list[dict]]:
|
|
384
|
+
"""Every statement in ``code`` as a step dict, or ``None`` if any is unknown.
|
|
385
|
+
|
|
386
|
+
All-or-nothing on purpose. A block that half-converts would leave a step
|
|
387
|
+
doing less than the author wrote, and the reader has no way to see it: the
|
|
388
|
+
scenario would still look complete.
|
|
389
|
+
"""
|
|
390
|
+
steps: list[dict] = []
|
|
391
|
+
imported: dict[str, str] = {} # helper name -> module
|
|
392
|
+
|
|
393
|
+
for statement in split_statements(code):
|
|
394
|
+
if _NOISE.match(statement):
|
|
395
|
+
continue
|
|
396
|
+
imp = _IMPORT.match(statement)
|
|
397
|
+
if imp:
|
|
398
|
+
for name in imp.group("names").split(","):
|
|
399
|
+
name = name.strip().split(" as ")[0].strip()
|
|
400
|
+
if name:
|
|
401
|
+
imported[name] = imp.group("module")
|
|
402
|
+
continue
|
|
403
|
+
|
|
404
|
+
for _name, pattern, build in RULES:
|
|
405
|
+
found = pattern.match(statement)
|
|
406
|
+
if not found:
|
|
407
|
+
continue
|
|
408
|
+
step = build(found)
|
|
409
|
+
if step is None:
|
|
410
|
+
continue
|
|
411
|
+
if step.get("action") == "call":
|
|
412
|
+
fn_name = step.pop("_fn_name")
|
|
413
|
+
args_src = step.pop("_args_src")
|
|
414
|
+
module = imported.get(fn_name)
|
|
415
|
+
if module is None:
|
|
416
|
+
# A bare call to something never imported here: it may be a
|
|
417
|
+
# builtin, a local, or an import from an earlier step. Not
|
|
418
|
+
# safe to rewrite.
|
|
419
|
+
return None
|
|
420
|
+
step["fn"] = f"{module}.{fn_name}"
|
|
421
|
+
args = _parse_call_args(args_src)
|
|
422
|
+
if args is None:
|
|
423
|
+
return None
|
|
424
|
+
positional, keywords = args
|
|
425
|
+
if positional:
|
|
426
|
+
step["args"] = positional
|
|
427
|
+
if keywords:
|
|
428
|
+
step["kwargs"] = keywords
|
|
429
|
+
steps.append(step)
|
|
430
|
+
break
|
|
431
|
+
else:
|
|
432
|
+
return None
|
|
433
|
+
return steps or None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _parse_call_args(src: str) -> Optional[tuple[list, dict]]:
|
|
437
|
+
"""Literal positional and keyword arguments of a helper call.
|
|
438
|
+
|
|
439
|
+
``None`` when any argument is an expression rather than a literal — a
|
|
440
|
+
computed date, another call, a ``globals()`` read. Those carry behaviour the
|
|
441
|
+
step form cannot hold, so the block stays custom.
|
|
442
|
+
"""
|
|
443
|
+
if not src:
|
|
444
|
+
return [], {}
|
|
445
|
+
try:
|
|
446
|
+
call = ast.parse(f"_f({src})", mode="eval").body
|
|
447
|
+
except SyntaxError:
|
|
448
|
+
return None
|
|
449
|
+
if not isinstance(call, ast.Call):
|
|
450
|
+
return None
|
|
451
|
+
positional: list = []
|
|
452
|
+
for arg in call.args:
|
|
453
|
+
try:
|
|
454
|
+
positional.append(ast.literal_eval(arg))
|
|
455
|
+
except ValueError:
|
|
456
|
+
return None
|
|
457
|
+
keywords: dict = {}
|
|
458
|
+
for keyword in call.keywords:
|
|
459
|
+
if keyword.arg is None: # **kwargs
|
|
460
|
+
return None
|
|
461
|
+
try:
|
|
462
|
+
keywords[keyword.arg] = ast.literal_eval(keyword.value)
|
|
463
|
+
except ValueError:
|
|
464
|
+
return None
|
|
465
|
+
return positional, keywords
|