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/tours/actions.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""How each scenario action becomes (or does not become) a tour step.
|
|
2
|
+
|
|
3
|
+
``ACTION_TOUR_KIND`` classifies **every** action in ``models.VALID_ACTIONS``;
|
|
4
|
+
``tests/test_tour_action_table.py`` fails when a new action is added without a
|
|
5
|
+
row here — a missing row would silently drop that step from every tour.
|
|
6
|
+
|
|
7
|
+
Kinds:
|
|
8
|
+
|
|
9
|
+
``click`` / ``change`` / ``input`` the user acts on one target; the tour points
|
|
10
|
+
at it and advances on that kind of event
|
|
11
|
+
``fill-form`` one ``input`` sub-step per field
|
|
12
|
+
``upload`` a file input, usually hidden behind a trigger
|
|
13
|
+
``check`` an assertion / wait: a hidden gate, or an info
|
|
14
|
+
step when it carries text
|
|
15
|
+
``cursor`` ``custom`` / ``call``: only a ``guide-cursor``
|
|
16
|
+
says what the user does
|
|
17
|
+
``unguided`` the user must act but there is no test-id to
|
|
18
|
+
point at (confirm dialogs)
|
|
19
|
+
``navigation`` changes the page; never part of a tour
|
|
20
|
+
``skip`` setup, waits, hovers, key presses
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import keyword
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Literal, Optional
|
|
29
|
+
|
|
30
|
+
from ..assertions import AssertionSyntaxError, parse_assertion
|
|
31
|
+
from ..models import Step, expect_checks
|
|
32
|
+
from ..testids import TargetExtraction, extract_test_id
|
|
33
|
+
|
|
34
|
+
ACTION_TOUR_KIND: dict[str, str] = {
|
|
35
|
+
"click": "click",
|
|
36
|
+
"expand": "click",
|
|
37
|
+
"toggle": "change",
|
|
38
|
+
"select": "change",
|
|
39
|
+
"select-option": "change",
|
|
40
|
+
"pick-date": "change",
|
|
41
|
+
"pick-time": "change",
|
|
42
|
+
"fill": "input",
|
|
43
|
+
"fill-date": "input",
|
|
44
|
+
"fill-form": "fill-form",
|
|
45
|
+
"upload": "upload",
|
|
46
|
+
"assert": "check",
|
|
47
|
+
"wait-for": "check",
|
|
48
|
+
"custom": "cursor",
|
|
49
|
+
"call": "cursor",
|
|
50
|
+
"confirm": "unguided",
|
|
51
|
+
"navigate": "navigation",
|
|
52
|
+
"login": "navigation",
|
|
53
|
+
"hover": "skip",
|
|
54
|
+
"scroll": "skip",
|
|
55
|
+
"keyboard": "skip",
|
|
56
|
+
"wait": "skip",
|
|
57
|
+
"wait-for-text": "skip",
|
|
58
|
+
"capture": "skip",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#: Assertion forms that imply "this element is on screen" — the only ones that
|
|
62
|
+
#: can gate a step. ``hidden``/``not-visible``/``stays-hidden``/``count 0`` say
|
|
63
|
+
#: the opposite; ``toast``/``row-contains``/``url-contains`` have no target.
|
|
64
|
+
POSITIVE_FORMS = frozenset({"visible", "text", "contains", "icontains", "not-empty"})
|
|
65
|
+
|
|
66
|
+
#: ``custom`` code that acts on the page: skipping it inside a tour leaves the
|
|
67
|
+
#: user with an action nobody told them to do.
|
|
68
|
+
INTERACTIVE_CODE_RE = re.compile(
|
|
69
|
+
r"\.(?:click|dblclick|fill|type|press|press_sequentially|check|uncheck|select_option|set_input_files"
|
|
70
|
+
r"|dispatch_event)\(|dispatchEvent\("
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
#: Actions that leave the page as they found it. A gate (a hidden check) still
|
|
74
|
+
#: holds after one of them runs unshown inside a tour; after any other — a
|
|
75
|
+
#: skipped click or fill, a confirm, a key press, a navigate — the element it
|
|
76
|
+
#: waited for may be gone. An action added later counts as page-changing until
|
|
77
|
+
#: it is listed here, which is the safe side: a dropped gate only lets the next
|
|
78
|
+
#: step show sooner, a stale one keeps it from ever showing.
|
|
79
|
+
PAGE_NEUTRAL_ACTIONS = frozenset({"assert", "wait-for", "wait", "wait-for-text", "hover", "scroll", "capture"})
|
|
80
|
+
|
|
81
|
+
#: Calls ``custom`` code can make and still leave the page alone. Code calling
|
|
82
|
+
#: anything else — ``click``, ``goto``, ``evaluate``, a project helper — may
|
|
83
|
+
#: change it. An allowlist on purpose: on the consumer corpora, a denylist
|
|
84
|
+
#: (``INTERACTIVE_CODE_RE`` plus the navigations) missed form values set
|
|
85
|
+
#: through ``evaluate`` and clicks inside helpers.
|
|
86
|
+
_READ_ONLY_CALLS = frozenset(
|
|
87
|
+
(
|
|
88
|
+
# Playwright: locating, reading, waiting, asserting; the ``to_*`` / ``not_to_*`` matchers and the
|
|
89
|
+
# ``expect_*`` waiters (whose ``with`` block holds the call that acts) are matched by prefix
|
|
90
|
+
"locator get_by_role get_by_text get_by_label get_by_placeholder get_by_alt_text get_by_title "
|
|
91
|
+
"get_by_test_id frame_locator filter nth and_ or_ all count is_visible is_hidden is_enabled is_disabled "
|
|
92
|
+
"is_checked is_editable text_content inner_text inner_html input_value get_attribute all_text_contents "
|
|
93
|
+
"all_inner_texts bounding_box title wait_for wait_for_timeout wait_for_selector wait_for_load_state "
|
|
94
|
+
"wait_for_url wait_for_function expect "
|
|
95
|
+
# The page-neutral actions above, written as code
|
|
96
|
+
"hover scroll_into_view_if_needed "
|
|
97
|
+
# Python builtins, and the list / str / re / datetime / time calls code uses to compute values
|
|
98
|
+
"print len str int float bool list dict set tuple range sorted min max any abs round enumerate zip "
|
|
99
|
+
"isinstance globals insert append extend strip lower upper split join replace format startswith endswith "
|
|
100
|
+
"removeprefix removesuffix isdigit get keys items values compile sub search match fullmatch findall group "
|
|
101
|
+
"now today date datetime timedelta weekday strftime isoformat time monotonic sleep"
|
|
102
|
+
).split()
|
|
103
|
+
)
|
|
104
|
+
_READ_ONLY_PREFIXES = ("to_", "not_to_", "expect_")
|
|
105
|
+
|
|
106
|
+
_STRING_RE = re.compile(r"'''.*?'''|\"\"\".*?\"\"\"|'(?:[^'\\\n]|\\.)*'|\"(?:[^\"\\\n]|\\.)*\"", re.S)
|
|
107
|
+
_COMMENT_RE = re.compile(r"#[^\n]*")
|
|
108
|
+
_CALL_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(")
|
|
109
|
+
|
|
110
|
+
#: Advance kinds the scenario's own action can satisfy in the replay. A
|
|
111
|
+
#: ``tour-advance`` outside this set would make the replay wait forever.
|
|
112
|
+
_REPLAYABLE = {
|
|
113
|
+
"click": frozenset({"click", "manual", "visible"}),
|
|
114
|
+
"input": frozenset({"input", "change", "manual"}),
|
|
115
|
+
"change": frozenset({"change", "click", "manual"}),
|
|
116
|
+
"upload": frozenset({"change", "manual"}),
|
|
117
|
+
"info": frozenset({"manual", "visible"}),
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass
|
|
122
|
+
class Sub:
|
|
123
|
+
"""One target of a kept candidate (fill-form has several)."""
|
|
124
|
+
|
|
125
|
+
extraction: Optional[TargetExtraction]
|
|
126
|
+
selector: Optional[str]
|
|
127
|
+
field: Optional[str] = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Candidate:
|
|
132
|
+
kind: Literal["kept", "gate", "skip"]
|
|
133
|
+
reason: str = ""
|
|
134
|
+
advance: Optional[str] = None
|
|
135
|
+
subs: list[Sub] = field(default_factory=list)
|
|
136
|
+
#: A failed/absent target is acceptable (centred step) — info steps, text-only custom.
|
|
137
|
+
centred_ok: bool = False
|
|
138
|
+
gate: Optional[str] = None
|
|
139
|
+
#: Which replayable-advance family the step belongs to (see ``_REPLAYABLE``).
|
|
140
|
+
family: str = "info"
|
|
141
|
+
reloads_page: bool = False
|
|
142
|
+
#: Deferred notes: emitted only if the step is not explicitly ``tour: skip``-ped.
|
|
143
|
+
notes: list[tuple[str, str, Optional[str]]] = field(default_factory=list)
|
|
144
|
+
trailing_gates: list[str] = field(default_factory=list)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def has_text(step: Step) -> bool:
|
|
148
|
+
for text in (step.tour_text, step.guide_text):
|
|
149
|
+
if text is None:
|
|
150
|
+
continue
|
|
151
|
+
values = [getattr(text, n) for n in ("fr", "en", "nl", "de")] + list((text.model_extra or {}).values())
|
|
152
|
+
if any(v is not None for v in values):
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _extract(step: Step, selector: Optional[str], attr: str, *, scoped: bool = True) -> TargetExtraction:
|
|
158
|
+
if scoped:
|
|
159
|
+
return extract_test_id(selector, attr, within=step.within, match=step.match)
|
|
160
|
+
return extract_test_id(selector, attr)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _checks(step: Step, attr: str, *, scoped: bool) -> list[tuple[Optional[str], Optional[str], bool]]:
|
|
164
|
+
"""``(test-id, selector, positive)`` per assertion of the step."""
|
|
165
|
+
out = []
|
|
166
|
+
for check in expect_checks(step.expect):
|
|
167
|
+
try:
|
|
168
|
+
parsed = parse_assertion(check)
|
|
169
|
+
except AssertionSyntaxError:
|
|
170
|
+
continue
|
|
171
|
+
selector = parsed.get("selector")
|
|
172
|
+
if selector is None:
|
|
173
|
+
continue
|
|
174
|
+
kind = parsed["type"]
|
|
175
|
+
positive = kind in POSITIVE_FORMS
|
|
176
|
+
if kind == "count":
|
|
177
|
+
positive = _int(parsed.get("count")) >= 1
|
|
178
|
+
elif kind == "count-cmp":
|
|
179
|
+
n, cmp = _int(parsed.get("count")), parsed.get("comparator")
|
|
180
|
+
positive = (cmp in (">=", "==") and n >= 1) or (cmp == ">" and n >= 0)
|
|
181
|
+
ext = _extract(step, selector, attr, scoped=scoped)
|
|
182
|
+
out.append((ext.test_id, selector, positive))
|
|
183
|
+
return out
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _int(value) -> int:
|
|
187
|
+
try:
|
|
188
|
+
return int(value)
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
return 0
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def trailing_gates(step: Step, attr: str) -> list[str]:
|
|
194
|
+
"""Positive, extractable ``expect:`` checks riding on a non-assert action.
|
|
195
|
+
|
|
196
|
+
Extracted **without** ``within``/``match``: the trailing expect uses a plain
|
|
197
|
+
locator in the generated test, whatever scopes the action itself.
|
|
198
|
+
"""
|
|
199
|
+
if step.action in ("assert", "wait-for"):
|
|
200
|
+
return []
|
|
201
|
+
return [tid for tid, _sel, positive in _checks(step, attr, scoped=False) if positive and tid]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def natural_target(step: Step, attr: str) -> tuple[Optional[TargetExtraction], Optional[str]]:
|
|
205
|
+
"""The element a step is "about", for ``tour: keep`` on a skipped step."""
|
|
206
|
+
if step.selector:
|
|
207
|
+
return _extract(step, step.selector, attr), step.selector
|
|
208
|
+
if step.guide_cursor is not None:
|
|
209
|
+
return extract_test_id(step.guide_cursor.target, attr), step.guide_cursor.target
|
|
210
|
+
for tid, selector, _positive in _checks(step, attr, scoped=True):
|
|
211
|
+
return (extract_test_id(selector, attr, within=step.within, match=step.match), selector)
|
|
212
|
+
if step.fields:
|
|
213
|
+
key = next(iter(step.fields))
|
|
214
|
+
return extract_test_id(key, attr), key
|
|
215
|
+
return None, None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def infer_candidate(step: Step, attr: str) -> Candidate:
|
|
219
|
+
"""What inference alone makes of ``step`` (annotations are applied later)."""
|
|
220
|
+
action = step.action
|
|
221
|
+
kind = ACTION_TOUR_KIND[action]
|
|
222
|
+
gates = trailing_gates(step, attr)
|
|
223
|
+
|
|
224
|
+
if step.when or step.optional:
|
|
225
|
+
return Candidate("skip", reason="conditional", trailing_gates=gates)
|
|
226
|
+
|
|
227
|
+
if kind in ("click", "change", "input"):
|
|
228
|
+
cand = Candidate("kept", advance=kind, family=kind, trailing_gates=gates)
|
|
229
|
+
cand.subs.append(Sub(_extract(step, step.selector, attr), step.selector))
|
|
230
|
+
if action == "expand":
|
|
231
|
+
cand.notes.append(
|
|
232
|
+
(
|
|
233
|
+
"may-not-change",
|
|
234
|
+
"an `expand` step only clicks when the section is closed, so its click may never happen",
|
|
235
|
+
"use `tour-advance: manual`, or `tour: skip`",
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
if action == "toggle" and step.state:
|
|
239
|
+
cand.notes.append(
|
|
240
|
+
(
|
|
241
|
+
"may-not-change",
|
|
242
|
+
"a `toggle` with `state:` does nothing when the switch is already in that state",
|
|
243
|
+
"use `tour-advance: manual`, or `tour: skip`",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
return cand
|
|
247
|
+
|
|
248
|
+
if kind == "fill-form":
|
|
249
|
+
if not step.fields:
|
|
250
|
+
return Candidate("skip", reason="fill-form-empty", trailing_gates=gates)
|
|
251
|
+
cand = Candidate("kept", advance="input", family="input", trailing_gates=gates)
|
|
252
|
+
for key in step.fields:
|
|
253
|
+
cand.subs.append(Sub(extract_test_id(key, attr), key, field=key))
|
|
254
|
+
return cand
|
|
255
|
+
|
|
256
|
+
if kind == "upload":
|
|
257
|
+
if step.guide_cursor is not None:
|
|
258
|
+
target = step.guide_cursor.target
|
|
259
|
+
cand = Candidate("kept", advance="manual", family="upload", trailing_gates=gates)
|
|
260
|
+
cand.subs.append(Sub(extract_test_id(target, attr), target))
|
|
261
|
+
return cand
|
|
262
|
+
ext = _extract(step, step.selector, attr)
|
|
263
|
+
cand = Candidate("kept", advance="change", family="upload", trailing_gates=gates)
|
|
264
|
+
cand.subs.append(Sub(ext, step.selector))
|
|
265
|
+
if ext.ok and ext.reduced_from is None:
|
|
266
|
+
cand.notes.append(
|
|
267
|
+
(
|
|
268
|
+
"upload-target-may-be-hidden",
|
|
269
|
+
"the tour points at the file input itself, which apps usually hide behind a styled button",
|
|
270
|
+
"add `guide-cursor: {behavior: click, target: <the visible trigger>}`",
|
|
271
|
+
)
|
|
272
|
+
)
|
|
273
|
+
return cand
|
|
274
|
+
|
|
275
|
+
if kind == "check":
|
|
276
|
+
if action == "wait-for":
|
|
277
|
+
positive = step.state in (None, "visible")
|
|
278
|
+
ext = _extract(step, step.selector, attr)
|
|
279
|
+
checks = [(ext.test_id, step.selector, positive)]
|
|
280
|
+
else:
|
|
281
|
+
checks = _checks(step, attr, scoped=True)
|
|
282
|
+
if has_text(step):
|
|
283
|
+
cand = Candidate("kept", advance="manual", family="info", centred_ok=True)
|
|
284
|
+
first = next(((tid, sel) for tid, sel, pos in checks if pos and tid), None)
|
|
285
|
+
if first is not None:
|
|
286
|
+
cand.subs.append(Sub(TargetExtraction(test_id=first[0]), first[1]))
|
|
287
|
+
cand.gate = first[0]
|
|
288
|
+
else:
|
|
289
|
+
cand.subs.append(Sub(None, None))
|
|
290
|
+
return cand
|
|
291
|
+
usable = [tid for tid, _sel, pos in checks if pos and tid]
|
|
292
|
+
if usable:
|
|
293
|
+
return Candidate("gate", gate=usable[-1], reason="gate")
|
|
294
|
+
dropped = any(pos and not tid for tid, _sel, pos in checks)
|
|
295
|
+
return Candidate("skip", reason="gate-dropped" if dropped else "check")
|
|
296
|
+
|
|
297
|
+
if kind == "cursor":
|
|
298
|
+
cursor = step.guide_cursor
|
|
299
|
+
if cursor is not None and cursor.behavior in ("click", "type"):
|
|
300
|
+
advance = "click" if cursor.behavior == "click" else "input"
|
|
301
|
+
cand = Candidate("kept", advance=advance, family=advance, trailing_gates=gates)
|
|
302
|
+
cand.subs.append(Sub(extract_test_id(cursor.target, attr), cursor.target))
|
|
303
|
+
return cand
|
|
304
|
+
if cursor is not None:
|
|
305
|
+
return Candidate("skip", reason="hover-cursor", trailing_gates=gates)
|
|
306
|
+
if has_text(step):
|
|
307
|
+
cand = Candidate("kept", advance="manual", family="info", centred_ok=True, trailing_gates=gates)
|
|
308
|
+
cand.subs.append(Sub(None, None))
|
|
309
|
+
cand.notes.append(
|
|
310
|
+
(
|
|
311
|
+
"unguided-action",
|
|
312
|
+
"this step has text but no target, so the tour shows it as a centred note while the user "
|
|
313
|
+
"must still do whatever the code does",
|
|
314
|
+
"add `guide-cursor: {behavior: click, target: <test-id selector>}`",
|
|
315
|
+
)
|
|
316
|
+
)
|
|
317
|
+
return cand
|
|
318
|
+
cand = Candidate("skip", reason=action, trailing_gates=gates)
|
|
319
|
+
if action == "custom" and step.code and INTERACTIVE_CODE_RE.search(step.code):
|
|
320
|
+
cand.notes.append(
|
|
321
|
+
(
|
|
322
|
+
"unguided-action",
|
|
323
|
+
"this custom code acts on the page, but the tour has nothing to show for it; "
|
|
324
|
+
"the next step's target may never appear",
|
|
325
|
+
"migrate it to `action: click` (with `synthetic: true` if needed) or add `guide-cursor`",
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return cand
|
|
329
|
+
|
|
330
|
+
if kind == "unguided":
|
|
331
|
+
cand = Candidate("skip", reason=action, trailing_gates=gates)
|
|
332
|
+
cand.notes.append(
|
|
333
|
+
(
|
|
334
|
+
"unguided-action",
|
|
335
|
+
"confirm dialogs have no test-id to point at, so the tour cannot guide this click",
|
|
336
|
+
"say what to do in the previous step's `tour-text`, or give the dialog buttons test-ids "
|
|
337
|
+
"and click them by test-id",
|
|
338
|
+
)
|
|
339
|
+
)
|
|
340
|
+
return cand
|
|
341
|
+
|
|
342
|
+
if kind == "navigation":
|
|
343
|
+
cand = Candidate("skip", reason=action, reloads_page=True, trailing_gates=gates)
|
|
344
|
+
if action == "navigate" and not step.reload:
|
|
345
|
+
from .routes import ID_LIKE_RE
|
|
346
|
+
|
|
347
|
+
target = step.target or ""
|
|
348
|
+
id_like = any(ID_LIKE_RE.fullmatch(seg) for seg in target.split("?")[0].split("/") if seg)
|
|
349
|
+
cand.notes.append(
|
|
350
|
+
(
|
|
351
|
+
"navigate-in-tour",
|
|
352
|
+
f"the tour cannot take the user to {target!r}"
|
|
353
|
+
+ (" — it contains fixture ids no real user has" if id_like else ""),
|
|
354
|
+
"start the tour later (tour.entry), or let the user get there by clicking",
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
else:
|
|
358
|
+
cand.notes.append(
|
|
359
|
+
(
|
|
360
|
+
"reloads-page",
|
|
361
|
+
"this step reloads the page or logs in again inside the tour",
|
|
362
|
+
"the tour resumes from sessionStorage, but move it before the tour starts if you can",
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
return cand
|
|
366
|
+
|
|
367
|
+
# plain skips
|
|
368
|
+
cand = Candidate("skip", reason=action, trailing_gates=gates)
|
|
369
|
+
if action == "keyboard" and "escape" in (step.value or "").lower():
|
|
370
|
+
cand.notes.append(
|
|
371
|
+
(
|
|
372
|
+
"escape-in-tour",
|
|
373
|
+
"an Escape key press inside the tour may dismiss it",
|
|
374
|
+
"the player ignores Escape inside dialogs and open dropdowns; check the replay",
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
return cand
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def page_change(step: Step) -> Optional[str]:
|
|
381
|
+
"""Why ``step`` may change the page when it runs unshown, or ``None`` if it cannot.
|
|
382
|
+
|
|
383
|
+
Phrased as the start of the ``gate-stale`` message. String literals and
|
|
384
|
+
comments are blanked before ``custom`` code is scanned, so a selector such
|
|
385
|
+
as ``':has-text("Save")'`` is not read as a call.
|
|
386
|
+
"""
|
|
387
|
+
action = step.action
|
|
388
|
+
if action in PAGE_NEUTRAL_ACTIONS:
|
|
389
|
+
return None
|
|
390
|
+
if action == "call":
|
|
391
|
+
return None if step.page is False else f"the helper `{step.fn}` gets the page and may change it"
|
|
392
|
+
if action == "custom":
|
|
393
|
+
code = _COMMENT_RE.sub("", _STRING_RE.sub("''", step.code or ""))
|
|
394
|
+
for name in _CALL_RE.findall(code):
|
|
395
|
+
if not (keyword.iskeyword(name) or name in _READ_ONLY_CALLS or name.startswith(_READ_ONLY_PREFIXES)):
|
|
396
|
+
return f"this code calls `{name}()`, which may change the page"
|
|
397
|
+
return None
|
|
398
|
+
return f"this `{action}` may change the page"
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def replayable(candidate: Candidate, advance: str) -> bool:
|
|
402
|
+
return advance in _REPLAYABLE.get(candidate.family, _REPLAYABLE["info"])
|
steppy/tours/compiler.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""All opted-in scenarios → compiled tours, replay plans and diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..models import Scenario
|
|
6
|
+
from .diagnostics import Diagnostic, DiagSink, sort_diagnostics
|
|
7
|
+
from .inference import infer_scenario
|
|
8
|
+
from .model import CompiledTour, ReplayPlan, ScenarioInference, TourCompileResult
|
|
9
|
+
from .settings import ToursSettings
|
|
10
|
+
from .text import check_texts, content_for, emitted_locales, guide_url, resolve_texts, tour_title
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def opted_in(scenarios: list[Scenario], settings: ToursSettings) -> list[Scenario]:
|
|
14
|
+
return [s for s in scenarios if s.tour_opted_in(settings.default_opt_in)]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compile_tours(scenarios: list[Scenario], settings: ToursSettings) -> TourCompileResult:
|
|
18
|
+
"""Compile every opted-in scenario (the caller passes the whole directory)."""
|
|
19
|
+
diagnostics: list[Diagnostic] = []
|
|
20
|
+
tours: list[CompiledTour] = []
|
|
21
|
+
plans: dict[str, ReplayPlan] = {}
|
|
22
|
+
ids: dict[str, str] = {}
|
|
23
|
+
untitled: list[str] = [] # multi-locale tours whose title is the (one-language) scenario title
|
|
24
|
+
|
|
25
|
+
for scenario in opted_in(scenarios, settings):
|
|
26
|
+
slug = scenario.slug or (scenario.file_path.stem if scenario.file_path else "scenario")
|
|
27
|
+
tour_id = scenario.tour.id if scenario.tour and scenario.tour.id else slug
|
|
28
|
+
sink = DiagSink(slug, tour_id, replay=settings.replay)
|
|
29
|
+
from ..tour_spec import is_tour_id
|
|
30
|
+
|
|
31
|
+
if not is_tour_id(tour_id):
|
|
32
|
+
sink.add(
|
|
33
|
+
"tour-id-invalid",
|
|
34
|
+
f"{tour_id!r} cannot be a tour id (1-64 characters [A-Za-z0-9_-], no device names)",
|
|
35
|
+
hint="set `tour: {id: <id>}`",
|
|
36
|
+
)
|
|
37
|
+
diagnostics.extend(sink.items)
|
|
38
|
+
continue
|
|
39
|
+
key = tour_id.casefold()
|
|
40
|
+
if key in ids:
|
|
41
|
+
sink.add("tour-id-duplicate", f"tour id {tour_id!r} is also used by {ids[key]}", hint="set `tour.id`")
|
|
42
|
+
diagnostics.extend(sink.items)
|
|
43
|
+
continue
|
|
44
|
+
ids[key] = slug
|
|
45
|
+
|
|
46
|
+
inference = infer_scenario(scenario, settings, sink)
|
|
47
|
+
if inference is None:
|
|
48
|
+
diagnostics.extend(sink.items)
|
|
49
|
+
continue
|
|
50
|
+
tour = _compile_one(scenario, slug, tour_id, inference, settings, sink)
|
|
51
|
+
diagnostics.extend(sink.items)
|
|
52
|
+
if sink.has_errors:
|
|
53
|
+
continue
|
|
54
|
+
tours.append(tour)
|
|
55
|
+
if (scenario.tour is None or scenario.tour.title is None) and len(tour.locales) > 1:
|
|
56
|
+
untitled.append(tour_id)
|
|
57
|
+
plans[slug] = ReplayPlan(
|
|
58
|
+
scenario_slug=slug,
|
|
59
|
+
tour_id=tour_id,
|
|
60
|
+
role=inference.role,
|
|
61
|
+
auth=scenario.auth,
|
|
62
|
+
default_locale=settings.default_locale,
|
|
63
|
+
locales=tour.locales,
|
|
64
|
+
entry_route=inference.entry_route,
|
|
65
|
+
entry_params=inference.entry_params,
|
|
66
|
+
entry_step_number=(
|
|
67
|
+
scenario.steps[inference.entry_step_index].step_number
|
|
68
|
+
if inference.entry_step_index is not None
|
|
69
|
+
else None
|
|
70
|
+
),
|
|
71
|
+
start_index=inference.start_index,
|
|
72
|
+
entries=tuple(inference.entries),
|
|
73
|
+
tour_step_count=len(inference.steps),
|
|
74
|
+
layouts=tuple(
|
|
75
|
+
(name, query)
|
|
76
|
+
for name, query in settings.layouts
|
|
77
|
+
if any(p.layouts and name in p.layouts for p in inference.steps)
|
|
78
|
+
),
|
|
79
|
+
test_id_attribute=settings.test_id_attribute,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if untitled:
|
|
83
|
+
shown = ", ".join(sorted(untitled)[:5]) + (", …" if len(untitled) > 5 else "")
|
|
84
|
+
diagnostics.append(
|
|
85
|
+
Diagnostic(
|
|
86
|
+
"warning",
|
|
87
|
+
"title-fallback",
|
|
88
|
+
f"{len(untitled)} multi-language tour(s) have no `tour.title` ({shown}); every language shows "
|
|
89
|
+
"the scenario title, which apps listing tours display as is",
|
|
90
|
+
hint="set `tour: {title: {en: …, fr: …}}`",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
tours.sort(key=lambda t: t.id)
|
|
94
|
+
return TourCompileResult(tours=tours, replay_plans=plans, diagnostics=sort_diagnostics(diagnostics))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _compile_one(
|
|
98
|
+
scenario: Scenario,
|
|
99
|
+
slug: str,
|
|
100
|
+
tour_id: str,
|
|
101
|
+
inference: ScenarioInference,
|
|
102
|
+
settings: ToursSettings,
|
|
103
|
+
sink: DiagSink,
|
|
104
|
+
) -> CompiledTour:
|
|
105
|
+
locales = emitted_locales(scenario, inference.steps, settings)
|
|
106
|
+
dropped = [loc for loc in settings.locales if loc not in locales]
|
|
107
|
+
if dropped:
|
|
108
|
+
sink.add(
|
|
109
|
+
"locale-not-emitted",
|
|
110
|
+
f"no kept step has text in {', '.join(dropped)}, so no file is written for it; those users get "
|
|
111
|
+
f"the tour in their base language if it has a file, else in {settings.default_locale}",
|
|
112
|
+
hint="add `tour-text` or `guide-text` in that language",
|
|
113
|
+
)
|
|
114
|
+
if not any(content_for(scenario.steps[p.step_index], settings.default_locale) for p in inference.steps):
|
|
115
|
+
sink.add(
|
|
116
|
+
"no-authored-text",
|
|
117
|
+
f"no step has text in the default locale {settings.default_locale!r}; every step shows a generic instruction",
|
|
118
|
+
)
|
|
119
|
+
titles = {}
|
|
120
|
+
fell_back = []
|
|
121
|
+
for locale in locales:
|
|
122
|
+
titles[locale], fb = tour_title(scenario, locale, settings.default_locale)
|
|
123
|
+
if fb:
|
|
124
|
+
fell_back.append(locale)
|
|
125
|
+
if fell_back:
|
|
126
|
+
sink.add(
|
|
127
|
+
"title-fallback",
|
|
128
|
+
f"tour.title has no entry for {', '.join(fell_back)}; the {settings.default_locale} title "
|
|
129
|
+
"(else the scenario title) is used there",
|
|
130
|
+
)
|
|
131
|
+
texts = resolve_texts(scenario, inference.steps, locales)
|
|
132
|
+
check_texts(scenario, inference.steps, texts, settings, sink)
|
|
133
|
+
urls = {loc: u for loc in locales if (u := guide_url(scenario, settings, loc))}
|
|
134
|
+
return CompiledTour(
|
|
135
|
+
id=tour_id,
|
|
136
|
+
scenario_slug=slug,
|
|
137
|
+
role=inference.role,
|
|
138
|
+
entry_route=inference.entry_route,
|
|
139
|
+
steps=inference.steps,
|
|
140
|
+
locales=locales,
|
|
141
|
+
titles=titles,
|
|
142
|
+
step_texts=texts,
|
|
143
|
+
guide_urls=urls,
|
|
144
|
+
)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Compile diagnostics: stable kebab-case codes, one rendered line each.
|
|
2
|
+
|
|
3
|
+
Errors stop emission (``steppy validate`` exits 1); warnings never change
|
|
4
|
+
an exit code; infos only print with ``steppy tours --explain``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal, Optional
|
|
11
|
+
|
|
12
|
+
Severity = Literal["error", "warning", "info"]
|
|
13
|
+
|
|
14
|
+
#: Every code the compiler emits, with its severity. Tests pin that the compiler
|
|
15
|
+
#: never emits a code missing here, that every code here is emitted somewhere
|
|
16
|
+
#: (``tests/test_tours_diagnostics.py``), and that ``docs/tours.md`` lists each
|
|
17
|
+
#: one with this severity (``tests/test_tours_docs_sync.py``).
|
|
18
|
+
CODES: dict[str, Severity] = {
|
|
19
|
+
# errors
|
|
20
|
+
"scenario-unreadable": "error",
|
|
21
|
+
"tour-role-required": "error",
|
|
22
|
+
"tour-role-unknown": "error",
|
|
23
|
+
"tour-no-role": "error",
|
|
24
|
+
"tour-id-invalid": "error",
|
|
25
|
+
"tour-id-duplicate": "error",
|
|
26
|
+
"entry-invalid": "error",
|
|
27
|
+
"entry-no-match": "error",
|
|
28
|
+
"no-entry": "error", # downgraded to a warning when tours.replay is off
|
|
29
|
+
"target-not-test-id": "error",
|
|
30
|
+
"no-kept-steps": "error",
|
|
31
|
+
"layouts-unmapped": "error",
|
|
32
|
+
"annotation-outside-tour": "error",
|
|
33
|
+
"annotation-on-skipped-step": "error",
|
|
34
|
+
"skip-and-annotations": "error",
|
|
35
|
+
"keep-not-allowed": "error",
|
|
36
|
+
"advance-needs-target": "error",
|
|
37
|
+
"advance-not-replayable": "error", # downgraded to a warning when tours.replay is off
|
|
38
|
+
"limit-exceeded": "error",
|
|
39
|
+
"layout-media-mismatch": "error",
|
|
40
|
+
"build-id": "error",
|
|
41
|
+
"target-path": "error",
|
|
42
|
+
# warnings
|
|
43
|
+
"unguided-action": "warning",
|
|
44
|
+
"navigate-in-tour": "warning",
|
|
45
|
+
"reloads-page": "warning",
|
|
46
|
+
"escape-in-tour": "warning",
|
|
47
|
+
"may-not-change": "warning",
|
|
48
|
+
"upload-target-may-be-hidden": "warning",
|
|
49
|
+
"entry-query-dropped": "warning",
|
|
50
|
+
"entry-id-segment": "warning",
|
|
51
|
+
"app-url-unresolved": "warning",
|
|
52
|
+
"fixture-leak": "warning",
|
|
53
|
+
"generic-text": "warning",
|
|
54
|
+
"no-authored-text": "warning",
|
|
55
|
+
"title-fallback": "warning",
|
|
56
|
+
"placement-ignored": "warning",
|
|
57
|
+
"tour-text-locale-untargeted": "warning",
|
|
58
|
+
"locale-not-emitted": "warning",
|
|
59
|
+
"chrome-strings-missing": "warning",
|
|
60
|
+
"text-link-dropped": "warning",
|
|
61
|
+
"layout-media-overlap": "warning",
|
|
62
|
+
"app-assets-skipped": "warning",
|
|
63
|
+
"no-tours": "warning",
|
|
64
|
+
# infos
|
|
65
|
+
"step-skipped": "info",
|
|
66
|
+
"gate-dropped": "info",
|
|
67
|
+
"gate-stale": "info",
|
|
68
|
+
"reduced-to-host": "info",
|
|
69
|
+
"conditional-skipped": "info",
|
|
70
|
+
"layout-media-unchecked": "info",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Diagnostic:
|
|
76
|
+
severity: Severity
|
|
77
|
+
code: str
|
|
78
|
+
message: str
|
|
79
|
+
scenario: Optional[str] = None
|
|
80
|
+
tour: Optional[str] = None
|
|
81
|
+
step_number: Optional[int] = None
|
|
82
|
+
step_title: Optional[str] = None
|
|
83
|
+
hint: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
def render(self) -> str:
|
|
86
|
+
where = ""
|
|
87
|
+
if self.tour and self.scenario and self.tour != self.scenario:
|
|
88
|
+
where = f"tour '{self.tour}' ({self.scenario})"
|
|
89
|
+
elif self.tour or self.scenario:
|
|
90
|
+
where = f"tour '{self.tour or self.scenario}'"
|
|
91
|
+
if self.step_number is not None:
|
|
92
|
+
where += f", step {self.step_number}"
|
|
93
|
+
if self.step_title:
|
|
94
|
+
where += f" '{self.step_title}'"
|
|
95
|
+
text = f"{where}: {self.message}" if where else self.message
|
|
96
|
+
if self.hint:
|
|
97
|
+
text += f" — {self.hint}"
|
|
98
|
+
return f"[{self.code}] {text}"
|
|
99
|
+
|
|
100
|
+
def sort_key(self):
|
|
101
|
+
return (self.scenario or "", self.step_number or 0, self.code, self.message)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class DiagSink:
|
|
105
|
+
"""Collects diagnostics for one scenario (or the whole run when ``scenario`` is None)."""
|
|
106
|
+
|
|
107
|
+
def __init__(self, scenario: Optional[str] = None, tour: Optional[str] = None, *, replay: bool = True):
|
|
108
|
+
self.scenario = scenario
|
|
109
|
+
self.tour = tour
|
|
110
|
+
self.replay = replay
|
|
111
|
+
self.items: list[Diagnostic] = []
|
|
112
|
+
|
|
113
|
+
def add(self, code: str, message: str, *, step=None, hint: Optional[str] = None) -> None:
|
|
114
|
+
severity = CODES[code]
|
|
115
|
+
if code in ("no-entry", "advance-not-replayable") and not self.replay:
|
|
116
|
+
severity = "warning"
|
|
117
|
+
self.items.append(
|
|
118
|
+
Diagnostic(
|
|
119
|
+
severity=severity,
|
|
120
|
+
code=code,
|
|
121
|
+
message=message,
|
|
122
|
+
scenario=self.scenario,
|
|
123
|
+
tour=self.tour,
|
|
124
|
+
step_number=getattr(step, "step_number", None),
|
|
125
|
+
step_title=getattr(step, "step_title", None),
|
|
126
|
+
hint=hint,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def has_errors(self) -> bool:
|
|
132
|
+
return any(d.severity == "error" for d in self.items)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sort_diagnostics(items: list[Diagnostic]) -> list[Diagnostic]:
|
|
136
|
+
order = {"error": 0, "warning": 1, "info": 2}
|
|
137
|
+
return sorted(items, key=lambda d: (order[d.severity], *d.sort_key()))
|