steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/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
@@ -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