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/assertions.py ADDED
@@ -0,0 +1,251 @@
1
+ """The ``expect:`` assertion grammar — parsed in ONE place.
2
+
3
+ Historically this lived only in ``generator._format_assertion``, which made
4
+ codegen the sole authority on what an assertion means. That had a hole with
5
+ teeth: ``_format_assertion`` returned ``{"type": "unknown"}`` for anything it did
6
+ not recognise, ``assert_body``'s ``{% else %}`` rendered ``pass``, and
7
+ ``steppy validate`` said nothing. A typo — or ``status("done")``, which *parsed*
8
+ but had no template branch — produced a green test that asserted nothing.
9
+
10
+ So the grammar moved here, to a stdlib-only leaf both sides import:
11
+
12
+ * ``generator._format_assertion`` delegates to :func:`parse_assertion`;
13
+ * ``Step.validate_expect_grammar`` (models.py) calls it too, so an
14
+ unrecognised form is a loud ``ValidationError`` at ``steppy validate``
15
+ instead of a silent ``pass``.
16
+
17
+ ``models.py`` is on the eager CLI path, so this module imports stdlib only —
18
+ the same rule ``captures.py``, ``runmeta.py`` and ``history.py`` follow.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import Optional
25
+
26
+ __all__ = [
27
+ "ASSERTION_FORMS",
28
+ "AssertionSyntaxError",
29
+ "parse_assertion",
30
+ "split_top_level",
31
+ ]
32
+
33
+
34
+ class AssertionSyntaxError(ValueError):
35
+ """Raised when an ``expect:`` string is not a recognised assertion."""
36
+
37
+
38
+ #: Every recognised head, for the error message. Declaration order matches the
39
+ #: way the docs list them.
40
+ ASSERTION_FORMS: tuple[str, ...] = (
41
+ 'toast("message")',
42
+ "visible(selector)",
43
+ "hidden(selector)",
44
+ 'text(selector, "expected")',
45
+ 'contains(selector, "expected")',
46
+ 'url-contains("/path")',
47
+ "count(selector, N) | count(selector, >=N)",
48
+ 'row-contains("text")',
49
+ "not-empty(selector)",
50
+ "disabled(selector)",
51
+ "enabled(selector)",
52
+ # Round 2. Every one of these was previously a silently-passing assertion:
53
+ # unrecognised forms fell through to `pass`, so a scenario asserting
54
+ # `value(...)` was green and checking nothing.
55
+ 'value(selector, "expected")',
56
+ 'attribute(selector, "name", "expected")',
57
+ 'not-contains(selector, "text")',
58
+ "not-visible(selector)",
59
+ 'icontains(selector, "text")',
60
+ "checked(selector)",
61
+ "unchecked(selector)",
62
+ 'stays-hidden(selector, "3s")',
63
+ )
64
+
65
+ _COMPARATOR = re.compile(r"^(>=|<=|==|!=|>|<)\s*(\d+)$")
66
+
67
+
68
+ def split_top_level(inner: str, maxsplit: int = -1) -> list[str]:
69
+ """Split ``inner`` on commas that are not inside quotes or brackets.
70
+
71
+ ``inner.split(",", 1)`` — what this replaces — splits on the FIRST comma
72
+ wherever it falls. Selectors are written bare here (``contains([data-testid=
73
+ "row"], "Approved")``), so a comma inside the attribute brackets or inside a
74
+ quoted attribute value used to cut the selector in half and hand codegen two
75
+ nonsense fragments. Tracking quote and bracket depth is the whole fix; a
76
+ selector with no comma splits identically to before.
77
+
78
+ A bare, unbracketed selector *list* (``#a, #b``) stays ambiguous — it is
79
+ indistinguishable from the argument separator — and is not supported, same
80
+ as before.
81
+ """
82
+ parts: list[str] = []
83
+ buf: list[str] = []
84
+ depth = 0
85
+ quote: Optional[str] = None
86
+ escaped = False
87
+ for ch in inner:
88
+ if escaped:
89
+ buf.append(ch)
90
+ escaped = False
91
+ continue
92
+ if quote:
93
+ buf.append(ch)
94
+ if ch == "\\":
95
+ escaped = True
96
+ elif ch == quote:
97
+ quote = None
98
+ continue
99
+ if ch in "\"'":
100
+ quote = ch
101
+ buf.append(ch)
102
+ continue
103
+ if ch in "([{":
104
+ depth += 1
105
+ elif ch in ")]}":
106
+ depth -= 1
107
+ if ch == "," and depth == 0 and (maxsplit < 0 or len(parts) < maxsplit):
108
+ parts.append("".join(buf))
109
+ buf = []
110
+ continue
111
+ buf.append(ch)
112
+ parts.append("".join(buf))
113
+ return parts
114
+
115
+
116
+ def _unquote(value: str) -> str:
117
+ """Strip one layer of surrounding quotes, matching the historical
118
+ ``.strip('"').strip("'")`` exactly (including its tolerance of unbalanced
119
+ quotes) so existing scenarios keep generating byte-identically."""
120
+ return value.strip('"').strip("'")
121
+
122
+
123
+ def _inner(expect: str, head: str) -> str:
124
+ """The text between ``head(`` and the closing ``)``.
125
+
126
+ The old code sliced ``expect[len(head):-1]`` unconditionally, so a missing
127
+ ``)`` silently ate the last character instead of failing. Checking it here
128
+ turns that into a validation error.
129
+ """
130
+ if not expect.endswith(")"):
131
+ raise AssertionSyntaxError(f"assertion {expect!r} is missing its closing ')'")
132
+ return expect[len(head) + 1 : -1]
133
+
134
+
135
+ def parse_assertion(expect: str) -> dict[str, str]:
136
+ """Parse an ``expect:`` string into the codegen descriptor.
137
+
138
+ Raises :class:`AssertionSyntaxError` for anything unrecognised — the
139
+ behaviour that replaces the old silent ``{"type": "unknown"}`` -> ``pass``.
140
+ """
141
+ expect = (expect or "").strip()
142
+ if not expect:
143
+ raise AssertionSyntaxError("expect: is empty")
144
+
145
+ if expect.startswith("toast("):
146
+ return {"type": "toast", "message": _unquote(_inner(expect, "toast"))}
147
+
148
+ if expect.startswith("visible("):
149
+ return {"type": "visible", "selector": _inner(expect, "visible").strip()}
150
+
151
+ if expect.startswith("hidden("):
152
+ return {"type": "hidden", "selector": _inner(expect, "hidden").strip()}
153
+
154
+ if expect.startswith("text("):
155
+ parts = split_top_level(_inner(expect, "text"), maxsplit=1)
156
+ if len(parts) != 2:
157
+ raise AssertionSyntaxError('text(...) needs two arguments: text(selector, "expected")')
158
+ return {"type": "text", "selector": parts[0].strip(), "text": _unquote(parts[1].strip())}
159
+
160
+ # Checked before ``contains(`` only for readability; the heads do not overlap.
161
+ if expect.startswith("url-contains("):
162
+ return {"type": "url-contains", "path": _unquote(_inner(expect, "url-contains").strip())}
163
+
164
+ if expect.startswith("contains("):
165
+ parts = split_top_level(_inner(expect, "contains"), maxsplit=1)
166
+ if len(parts) != 2:
167
+ raise AssertionSyntaxError('contains(...) needs two arguments: contains(selector, "expected")')
168
+ return {"type": "contains", "selector": parts[0].strip(), "text": _unquote(parts[1].strip())}
169
+
170
+ if expect.startswith("count("):
171
+ parts = split_top_level(_inner(expect, "count"), maxsplit=1)
172
+ if len(parts) != 2:
173
+ raise AssertionSyntaxError("count(...) needs two arguments: count(selector, N) or count(selector, >=N)")
174
+ selector, raw = parts[0].strip(), parts[1].strip()
175
+ cmp_match = _COMPARATOR.match(raw)
176
+ if cmp_match:
177
+ return {
178
+ "type": "count-cmp",
179
+ "selector": selector,
180
+ "comparator": cmp_match.group(1),
181
+ "count": cmp_match.group(2),
182
+ }
183
+ if not raw.isdigit():
184
+ # This used to fall through to ``to_have_count(<raw>)``, which emits
185
+ # a SyntaxError in the generated file for e.g. ``count(sel, >={{n}})``.
186
+ raise AssertionSyntaxError(
187
+ "count(...) expects an integer, or a comparator (>=, <=, ==, !=, >, <) "
188
+ f"followed by an integer, got {raw!r}"
189
+ )
190
+ return {"type": "count", "selector": selector, "count": raw}
191
+
192
+ if expect.startswith("value("):
193
+ parts = split_top_level(_inner(expect, "value"), maxsplit=1)
194
+ if len(parts) != 2:
195
+ raise AssertionSyntaxError('value(...) needs two arguments: value(selector, "expected")')
196
+ return {"type": "value", "selector": parts[0].strip(), "text": _unquote(parts[1].strip())}
197
+
198
+ if expect.startswith("attribute("):
199
+ parts = split_top_level(_inner(expect, "attribute"), maxsplit=2)
200
+ if len(parts) != 3:
201
+ raise AssertionSyntaxError('attribute(...) needs three arguments: attribute(selector, "name", "expected")')
202
+ return {
203
+ "type": "attribute",
204
+ "selector": parts[0].strip(),
205
+ "attribute": _unquote(parts[1].strip()),
206
+ "text": _unquote(parts[2].strip()),
207
+ }
208
+
209
+ # Checked before `contains(` and `visible(` would ever match: neither head
210
+ # is a prefix of these, but keeping them adjacent makes the negation set
211
+ # easy to read.
212
+ if expect.startswith("not-contains("):
213
+ parts = split_top_level(_inner(expect, "not-contains"), maxsplit=1)
214
+ if len(parts) != 2:
215
+ raise AssertionSyntaxError('not-contains(...) needs two arguments: not-contains(selector, "text")')
216
+ return {"type": "not-contains", "selector": parts[0].strip(), "text": _unquote(parts[1].strip())}
217
+
218
+ if expect.startswith("not-visible("):
219
+ return {"type": "not-visible", "selector": _inner(expect, "not-visible").strip()}
220
+
221
+ if expect.startswith("icontains("):
222
+ parts = split_top_level(_inner(expect, "icontains"), maxsplit=1)
223
+ if len(parts) != 2:
224
+ raise AssertionSyntaxError('icontains(...) needs two arguments: icontains(selector, "text")')
225
+ return {"type": "icontains", "selector": parts[0].strip(), "text": _unquote(parts[1].strip())}
226
+
227
+ if expect.startswith("unchecked("):
228
+ return {"type": "unchecked", "selector": _inner(expect, "unchecked").strip()}
229
+
230
+ if expect.startswith("checked("):
231
+ return {"type": "checked", "selector": _inner(expect, "checked").strip()}
232
+
233
+ if expect.startswith("stays-hidden("):
234
+ parts = split_top_level(_inner(expect, "stays-hidden"), maxsplit=1)
235
+ if len(parts) != 2:
236
+ raise AssertionSyntaxError('stays-hidden(...) needs two arguments: stays-hidden(selector, "3s")')
237
+ return {"type": "stays-hidden", "selector": parts[0].strip(), "duration": _unquote(parts[1].strip())}
238
+
239
+ if expect.startswith("row-contains("):
240
+ return {"type": "row-contains", "text": _unquote(_inner(expect, "row-contains").strip())}
241
+
242
+ if expect.startswith("not-empty("):
243
+ return {"type": "not-empty", "selector": _inner(expect, "not-empty").strip()}
244
+
245
+ if expect.startswith("disabled("):
246
+ return {"type": "disabled", "selector": _inner(expect, "disabled").strip()}
247
+
248
+ if expect.startswith("enabled("):
249
+ return {"type": "enabled", "selector": _inner(expect, "enabled").strip()}
250
+
251
+ raise AssertionSyntaxError(f"unknown assertion {expect!r}. Valid forms: " + " | ".join(ASSERTION_FORMS))