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