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/emit.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Payloads, canonical bytes, and write plans for tour output.
|
|
2
|
+
|
|
3
|
+
Every byte written goes through :func:`steppy.contracts.tours.dumps_canonical`
|
|
4
|
+
and is re-validated by the producer contract before anything touches disk, so
|
|
5
|
+
a compiler bug can never ship a file the overlay would reject.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Literal
|
|
15
|
+
|
|
16
|
+
from ..contracts.tours import (
|
|
17
|
+
TourDataInvalid,
|
|
18
|
+
check_consistency,
|
|
19
|
+
dumps_canonical,
|
|
20
|
+
parse_tour_file,
|
|
21
|
+
parse_tours_manifest,
|
|
22
|
+
sri_sha384,
|
|
23
|
+
)
|
|
24
|
+
from ..naming import TOUR_FILE_KIND, TOURS_MANIFEST_FILENAME, TOURS_MANIFEST_KIND
|
|
25
|
+
from ..tour_spec import MAX_MANIFEST_BYTES, MAX_TOUR_FILE_BYTES, TOUR_SCHEMA_VERSION, tour_file_name
|
|
26
|
+
from .model import CompiledTour, TourCompileResult
|
|
27
|
+
from .paths import TourPathError, atomic_write_bytes, check_tour_file, is_tour_file, listed_files, read_marker
|
|
28
|
+
from .settings import ToursSettings
|
|
29
|
+
|
|
30
|
+
Status = Literal["generated", "updated", "unchanged", "removed", "planned", "missing"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TourContractBug(RuntimeError):
|
|
34
|
+
"""The compiler produced something its own contract rejects (always a bug)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class TourArtifacts:
|
|
39
|
+
build_id: str
|
|
40
|
+
files: dict[str, bytes] # tour file name -> bytes
|
|
41
|
+
manifest: bytes
|
|
42
|
+
|
|
43
|
+
def all_files(self) -> dict[str, bytes]:
|
|
44
|
+
return {**self.files, TOURS_MANIFEST_FILENAME: self.manifest}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class FileStatus:
|
|
49
|
+
path: Path
|
|
50
|
+
status: Status
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class DirPlan:
|
|
55
|
+
directory: Path
|
|
56
|
+
statuses: list[FileStatus] = field(default_factory=list)
|
|
57
|
+
writes: dict[str, bytes] = field(default_factory=dict) # name -> bytes, tour files then manifest
|
|
58
|
+
removals: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def tour_file_payload(tour: CompiledTour, locale: str, build_id: str) -> dict:
|
|
62
|
+
steps = []
|
|
63
|
+
for n, plan in enumerate(tour.steps):
|
|
64
|
+
title, text = tour.step_texts[locale][n]
|
|
65
|
+
step = {"id": plan.step_id, "target": plan.target, "advance": {"on": plan.advance}}
|
|
66
|
+
if plan.wait_for:
|
|
67
|
+
step["waitFor"] = plan.wait_for
|
|
68
|
+
if title:
|
|
69
|
+
step["title"] = title
|
|
70
|
+
if text:
|
|
71
|
+
step["text"] = text
|
|
72
|
+
if plan.placement and plan.placement != "auto":
|
|
73
|
+
step["placement"] = plan.placement
|
|
74
|
+
if plan.layouts:
|
|
75
|
+
step["layouts"] = list(plan.layouts)
|
|
76
|
+
steps.append(step)
|
|
77
|
+
payload = {
|
|
78
|
+
"schema": TOUR_SCHEMA_VERSION,
|
|
79
|
+
"kind": TOUR_FILE_KIND,
|
|
80
|
+
"id": tour.id,
|
|
81
|
+
"buildId": build_id,
|
|
82
|
+
"locale": locale,
|
|
83
|
+
"title": tour.titles[locale],
|
|
84
|
+
"entry": {"route": tour.entry_route} if tour.entry_route else None,
|
|
85
|
+
"steps": steps,
|
|
86
|
+
}
|
|
87
|
+
if locale in tour.guide_urls:
|
|
88
|
+
payload["guideUrl"] = tour.guide_urls[locale]
|
|
89
|
+
return payload
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def render_artifacts(result: TourCompileResult, settings: ToursSettings, build_id: str) -> TourArtifacts:
|
|
93
|
+
"""Serialize every tour file and the manifest, then self-check them."""
|
|
94
|
+
files: dict[str, bytes] = {}
|
|
95
|
+
entries = []
|
|
96
|
+
used_layouts: set[str] = set()
|
|
97
|
+
for tour in result.tours:
|
|
98
|
+
integrity = {}
|
|
99
|
+
names = {}
|
|
100
|
+
for locale in tour.locales:
|
|
101
|
+
data = dumps_canonical(tour_file_payload(tour, locale, build_id))
|
|
102
|
+
name = tour_file_name(tour.id, locale)
|
|
103
|
+
if len(data) > MAX_TOUR_FILE_BYTES:
|
|
104
|
+
raise TourDataInvalid(f"{name} is {len(data)} bytes; the player accepts at most {MAX_TOUR_FILE_BYTES}")
|
|
105
|
+
files[name] = data
|
|
106
|
+
names[locale] = name
|
|
107
|
+
integrity[locale] = sri_sha384(data)
|
|
108
|
+
for plan in tour.steps:
|
|
109
|
+
used_layouts.update(plan.layouts or ())
|
|
110
|
+
entries.append(
|
|
111
|
+
{
|
|
112
|
+
"id": tour.id,
|
|
113
|
+
"role": tour.role,
|
|
114
|
+
"title": {loc: tour.titles[loc] for loc in tour.locales},
|
|
115
|
+
"entry": {"route": tour.entry_route} if tour.entry_route else None,
|
|
116
|
+
"steps": len(tour.steps),
|
|
117
|
+
"files": names,
|
|
118
|
+
"integrity": integrity,
|
|
119
|
+
}
|
|
120
|
+
)
|
|
121
|
+
manifest = {
|
|
122
|
+
"schema": TOUR_SCHEMA_VERSION,
|
|
123
|
+
"kind": TOURS_MANIFEST_KIND,
|
|
124
|
+
"buildId": build_id,
|
|
125
|
+
"defaultLocale": settings.default_locale,
|
|
126
|
+
"locales": list(settings.locales),
|
|
127
|
+
"testIdAttribute": settings.test_id_attribute,
|
|
128
|
+
"tours": entries,
|
|
129
|
+
}
|
|
130
|
+
if settings.guide_origin and any(t.guide_urls for t in result.tours):
|
|
131
|
+
manifest["guideOrigin"] = settings.guide_origin
|
|
132
|
+
if used_layouts:
|
|
133
|
+
manifest["layouts"] = {name: q for name, q in settings.layouts if name in used_layouts}
|
|
134
|
+
manifest_bytes = dumps_canonical(manifest)
|
|
135
|
+
if len(manifest_bytes) > MAX_MANIFEST_BYTES:
|
|
136
|
+
raise TourDataInvalid(f"the manifest is {len(manifest_bytes)} bytes; the player accepts {MAX_MANIFEST_BYTES}")
|
|
137
|
+
try:
|
|
138
|
+
parsed = parse_tours_manifest(json.loads(manifest_bytes))
|
|
139
|
+
for data in files.values():
|
|
140
|
+
parse_tour_file(json.loads(data))
|
|
141
|
+
except TourDataInvalid as exc:
|
|
142
|
+
raise TourContractBug(f"compiled tour data violates the v1 contract: {exc}") from exc
|
|
143
|
+
problems = check_consistency(parsed, files)
|
|
144
|
+
if problems:
|
|
145
|
+
raise TourContractBug("compiled tour data is inconsistent: " + "; ".join(problems))
|
|
146
|
+
return TourArtifacts(build_id=build_id, files=files, manifest=manifest_bytes)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def plan_dir(directory: Path, artifacts: TourArtifacts, *, prune: bool = True) -> DirPlan:
|
|
150
|
+
"""Byte-compare against disk; plan writes and (marker-checked) stale removals.
|
|
151
|
+
|
|
152
|
+
Raises :class:`TourPathError` — at planning time, before anything is written —
|
|
153
|
+
when a write or a removal would hit a file that is not a tour file (the
|
|
154
|
+
manifest itself is vouched for by :func:`read_marker`).
|
|
155
|
+
"""
|
|
156
|
+
plan = DirPlan(directory=directory)
|
|
157
|
+
previous = read_marker(directory) if directory.is_dir() else None
|
|
158
|
+
for name, data in [*sorted(artifacts.files.items()), (TOURS_MANIFEST_FILENAME, artifacts.manifest)]:
|
|
159
|
+
path = directory / name
|
|
160
|
+
if path.is_file():
|
|
161
|
+
if path.read_bytes() == data:
|
|
162
|
+
plan.statuses.append(FileStatus(path, "unchanged"))
|
|
163
|
+
continue
|
|
164
|
+
if name != TOURS_MANIFEST_FILENAME:
|
|
165
|
+
check_tour_file(path, "overwrite")
|
|
166
|
+
plan.statuses.append(FileStatus(path, "updated"))
|
|
167
|
+
elif path.exists() or path.is_symlink():
|
|
168
|
+
raise TourPathError(f"{path} exists and is not a regular file; refusing to replace it")
|
|
169
|
+
else:
|
|
170
|
+
plan.statuses.append(FileStatus(path, "generated"))
|
|
171
|
+
plan.writes[name] = data
|
|
172
|
+
if prune:
|
|
173
|
+
for name in sorted(listed_files(previous) - set(artifacts.files)):
|
|
174
|
+
path = directory / name
|
|
175
|
+
# A case-only rename (``Fr-be`` -> ``fr-BE``) on a case-insensitive
|
|
176
|
+
# filesystem: the old name is the file about to be rewritten.
|
|
177
|
+
if any(_same_file(path, directory / w) for w in artifacts.files if w.casefold() == name.casefold()):
|
|
178
|
+
continue
|
|
179
|
+
if path.is_file() and not path.is_symlink():
|
|
180
|
+
check_tour_file(path, "delete")
|
|
181
|
+
plan.removals.append(name)
|
|
182
|
+
plan.statuses.append(FileStatus(path, "removed"))
|
|
183
|
+
return plan
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def apply_dir(plan: DirPlan) -> None:
|
|
187
|
+
"""Tour files first, the manifest last (so a reader never sees a manifest without its files)."""
|
|
188
|
+
names = [n for n in plan.writes if n != TOURS_MANIFEST_FILENAME]
|
|
189
|
+
if TOURS_MANIFEST_FILENAME in plan.writes:
|
|
190
|
+
names.append(TOURS_MANIFEST_FILENAME)
|
|
191
|
+
for name in names:
|
|
192
|
+
atomic_write_bytes(plan.directory / name, plan.writes[name])
|
|
193
|
+
for name in plan.removals:
|
|
194
|
+
(plan.directory / name).unlink(missing_ok=True)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _same_file(a: Path, b: Path) -> bool:
|
|
198
|
+
try:
|
|
199
|
+
return os.path.samefile(a, b)
|
|
200
|
+
except OSError:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def sibling_build_dirs(tours_root: Path, keep: str) -> list[Path]:
|
|
205
|
+
"""Other ``<build-id>/`` trees under the canonical root that carry our marker."""
|
|
206
|
+
out = []
|
|
207
|
+
if not tours_root.is_dir():
|
|
208
|
+
return out
|
|
209
|
+
for child in sorted(tours_root.iterdir()):
|
|
210
|
+
if child.name == keep or not child.is_dir() or child.is_symlink():
|
|
211
|
+
continue
|
|
212
|
+
# The tree just written, reached under a case variant of its id on a
|
|
213
|
+
# case-insensitive filesystem.
|
|
214
|
+
if _same_file(child, tours_root / keep):
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
if read_marker(child) is not None:
|
|
218
|
+
out.append(child)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
return out
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def remove_build_dir(directory: Path) -> list[Path]:
|
|
225
|
+
"""Remove a marker-checked build tree: only its manifest and the files it lists."""
|
|
226
|
+
manifest = read_marker(directory)
|
|
227
|
+
removed = []
|
|
228
|
+
for name in sorted(listed_files(manifest)):
|
|
229
|
+
path = directory / name
|
|
230
|
+
if path.is_file() and not path.is_symlink() and is_tour_file(path):
|
|
231
|
+
path.unlink()
|
|
232
|
+
removed.append(path)
|
|
233
|
+
(directory / TOURS_MANIFEST_FILENAME).unlink(missing_ok=True)
|
|
234
|
+
removed.append(directory / TOURS_MANIFEST_FILENAME)
|
|
235
|
+
try:
|
|
236
|
+
directory.rmdir()
|
|
237
|
+
except OSError:
|
|
238
|
+
pass
|
|
239
|
+
return removed
|
steppy/tours/ids.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Tour and step ids.
|
|
2
|
+
|
|
3
|
+
Step ids travel in every ``tour-step`` event an app forwards to analytics, so
|
|
4
|
+
they must survive edits: they are never positional. A targeted step is named
|
|
5
|
+
after its test-id (stable as long as the element is); a centred step after its
|
|
6
|
+
heading. Repeats get ``-2``, ``-3``… in order.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import unicodedata
|
|
13
|
+
|
|
14
|
+
from ..tour_spec import STEP_ID_PATTERN
|
|
15
|
+
from .model import TourStepPlan
|
|
16
|
+
|
|
17
|
+
_MAX_ID = 128
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slugify_heading(title: str) -> str:
|
|
21
|
+
"""``"7b. Ouvrir la fiche"`` → ``"7b-ouvrir-la-fiche"`` (ASCII, ≤ 64 chars)."""
|
|
22
|
+
ascii_text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
|
23
|
+
slug = re.sub(r"[^a-z0-9]+", "-", ascii_text.lower()).strip("-")[:64].strip("-")
|
|
24
|
+
return slug or "step"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def assign_step_ids(steps: list[TourStepPlan]) -> None:
|
|
28
|
+
"""Set ``step_id`` on every plan, deduplicating in order against every id so far."""
|
|
29
|
+
used: set[str] = set()
|
|
30
|
+
for step in steps:
|
|
31
|
+
base = step.target or slugify_heading(step.step_title)
|
|
32
|
+
if not re.fullmatch(STEP_ID_PATTERN, base):
|
|
33
|
+
base = slugify_heading(base)
|
|
34
|
+
candidate, n = base, 1
|
|
35
|
+
while candidate in used:
|
|
36
|
+
n += 1
|
|
37
|
+
suffix = f"-{n}"
|
|
38
|
+
candidate = base[: _MAX_ID - len(suffix)] + suffix
|
|
39
|
+
used.add(candidate)
|
|
40
|
+
step.step_id = candidate
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""One scenario → its tour steps and the replay's per-step dispositions.
|
|
2
|
+
|
|
3
|
+
Pure and locale-independent: the same scenario yields the same steps whatever
|
|
4
|
+
locales are configured; text is resolved afterwards (``text.py``).
|
|
5
|
+
|
|
6
|
+
Order of decisions:
|
|
7
|
+
|
|
8
|
+
1. **Role.** ``tour.role``, else the scenario's only role.
|
|
9
|
+
2. **Start point.** With ``tour.entry``: just after the navigate step whose
|
|
10
|
+
target matches it — plus the run of ``only-in: guide`` steps right after an
|
|
11
|
+
``only-in: test`` navigate, which is the same journey by clicking (the "guide
|
|
12
|
+
twin"). Without: just after the first navigate following the last
|
|
13
|
+
``login``. Steps before the start point are *setup*: not in the tour, but
|
|
14
|
+
the replay runs them (in test mode) to get there.
|
|
15
|
+
3. **Mode.** ``only-in: test`` steps are dropped from the tour range (the guide
|
|
16
|
+
path runs instead); ``only-in: guide`` steps are kept; other roles' steps
|
|
17
|
+
run in the replay only.
|
|
18
|
+
4. **Candidates** per action (``actions.infer_candidate``), then annotations.
|
|
19
|
+
5. **Gates.** A hidden gate becomes the ``waitFor`` of the next kept step —
|
|
20
|
+
unless a skipped step in between may change the page
|
|
21
|
+
(``actions.page_change``), which drops it: the element may be gone by then,
|
|
22
|
+
and a step waiting for it would never show. ``gate-stale`` says so when a
|
|
23
|
+
kept step loses its wait that way.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from ..models import Scenario, Step
|
|
31
|
+
from ..testids import TargetExtraction
|
|
32
|
+
from ..tour_spec import MAX_STEPS, TARGETED_ADVANCE_KINDS
|
|
33
|
+
from .actions import Candidate, infer_candidate, natural_target, page_change, replayable
|
|
34
|
+
from .diagnostics import DiagSink
|
|
35
|
+
from .model import ReplayEntry, ReplayTourStep, ScenarioInference, TourStepPlan
|
|
36
|
+
from .routes import app_base, bind, matches, parse_entry_pattern, route_from_target, target_segments
|
|
37
|
+
from .settings import ToursSettings
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _annotated(step: Step) -> bool:
|
|
41
|
+
return bool(step.tour_text or step.tour_advance or step.tour_placement or step.tour)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_role(scenario: Scenario, configured_roles: set[str], sink: DiagSink) -> Optional[str]:
|
|
45
|
+
roles = sorted(scenario.get_roles())
|
|
46
|
+
wanted = scenario.tour.role if scenario.tour else None
|
|
47
|
+
if wanted:
|
|
48
|
+
if wanted not in roles or wanted not in configured_roles:
|
|
49
|
+
sink.add(
|
|
50
|
+
"tour-role-unknown",
|
|
51
|
+
f"tour.role {wanted!r} is not a role this scenario uses ({', '.join(roles) or 'none'})",
|
|
52
|
+
)
|
|
53
|
+
return None
|
|
54
|
+
return wanted
|
|
55
|
+
if len(roles) == 1:
|
|
56
|
+
return roles[0]
|
|
57
|
+
if not roles:
|
|
58
|
+
sink.add("tour-no-role", "the scenario has no role, so there is no app to run a tour in")
|
|
59
|
+
return None
|
|
60
|
+
sink.add(
|
|
61
|
+
"tour-role-required",
|
|
62
|
+
f"the scenario uses several roles ({', '.join(roles)}); say whose app the tour runs in",
|
|
63
|
+
hint="set `tour: {role: <role>}`",
|
|
64
|
+
)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def infer_scenario(scenario: Scenario, settings: ToursSettings, sink: DiagSink) -> Optional[ScenarioInference]:
|
|
69
|
+
"""Infer one opted-in scenario; ``None`` when an error makes the tour meaningless."""
|
|
70
|
+
role = resolve_role(scenario, set(settings.app_urls), sink)
|
|
71
|
+
if role is None:
|
|
72
|
+
return None
|
|
73
|
+
steps = scenario.steps
|
|
74
|
+
base = app_base(settings.app_urls.get(role))
|
|
75
|
+
if base.unresolved:
|
|
76
|
+
sink.add(
|
|
77
|
+
"app-url-unresolved",
|
|
78
|
+
f"apps.{role}.url is not an absolute URL at compile time; routes are read relative to '/'",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def in_role(step: Step) -> bool:
|
|
82
|
+
return step.role in (role, None)
|
|
83
|
+
|
|
84
|
+
navs = [
|
|
85
|
+
i
|
|
86
|
+
for i, s in enumerate(steps)
|
|
87
|
+
if s.action == "navigate" and not s.reload and s.role == role and s.only_in != "guide" and s.target
|
|
88
|
+
]
|
|
89
|
+
logins = [i for i, s in enumerate(steps) if s.action == "login" and in_role(s)]
|
|
90
|
+
last_login = logins[-1] if logins else -1
|
|
91
|
+
|
|
92
|
+
entry_route: Optional[str] = None
|
|
93
|
+
entry_params: tuple[tuple[str, str], ...] = ()
|
|
94
|
+
k: Optional[int] = None
|
|
95
|
+
|
|
96
|
+
explicit = scenario.tour.entry if scenario.tour else None
|
|
97
|
+
if explicit:
|
|
98
|
+
pattern = parse_entry_pattern(explicit)
|
|
99
|
+
if pattern.error:
|
|
100
|
+
sink.add("entry-invalid", pattern.error)
|
|
101
|
+
return None
|
|
102
|
+
candidates = []
|
|
103
|
+
for i in navs:
|
|
104
|
+
segs = target_segments(steps[i].target, base)
|
|
105
|
+
if segs is not None and matches(pattern.segments, segs):
|
|
106
|
+
candidates.append((i, segs))
|
|
107
|
+
if not candidates:
|
|
108
|
+
seen = ", ".join(repr(steps[i].target) for i in navs) or "none"
|
|
109
|
+
sink.add(
|
|
110
|
+
"entry-no-match",
|
|
111
|
+
f"tour.entry {explicit!r} matches no navigate step (navigations: {seen})",
|
|
112
|
+
hint="the tour starts right after the navigate step that opens the entry route",
|
|
113
|
+
)
|
|
114
|
+
return None
|
|
115
|
+
after = [c for c in candidates if c[0] > last_login]
|
|
116
|
+
k, segs = (after or candidates)[0]
|
|
117
|
+
entry_route = pattern.route
|
|
118
|
+
entry_params = bind(pattern.segments, segs)
|
|
119
|
+
else:
|
|
120
|
+
after = [i for i in navs if i > last_login]
|
|
121
|
+
k = (after or navs or [None])[0]
|
|
122
|
+
if k is None:
|
|
123
|
+
sink.add(
|
|
124
|
+
"no-entry",
|
|
125
|
+
"the scenario never navigates, so the tour has no entry route and the replay would start on a blank page",
|
|
126
|
+
hint="add a navigate step, or set tours.replay: false",
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
result = route_from_target(steps[k].target, base)
|
|
130
|
+
if result.error:
|
|
131
|
+
sink.add("entry-invalid", result.error, step=steps[k], hint="set tour.entry to the route pattern")
|
|
132
|
+
return None
|
|
133
|
+
if result.query_dropped:
|
|
134
|
+
sink.add("entry-query-dropped", "the entry's query string is ignored by in-app tours", step=steps[k])
|
|
135
|
+
if result.id_like:
|
|
136
|
+
sink.add(
|
|
137
|
+
"entry-id-segment",
|
|
138
|
+
f"the entry route {result.route!r} contains fixture-looking ids ({', '.join(result.id_like)})",
|
|
139
|
+
step=steps[k],
|
|
140
|
+
hint="set tour.entry with a :param, e.g. /invoices/:invoiceId",
|
|
141
|
+
)
|
|
142
|
+
entry_route = result.route
|
|
143
|
+
entry_params = tuple(
|
|
144
|
+
(seg[1:], raw) for seg, raw in zip(result.segments, result.raw_segments) if seg.startswith(":")
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
start = 0 if k is None else k + 1
|
|
148
|
+
if k is not None and steps[k].only_in == "test":
|
|
149
|
+
while start < len(steps) and steps[start].only_in == "guide":
|
|
150
|
+
start += 1
|
|
151
|
+
|
|
152
|
+
kept: list[TourStepPlan] = []
|
|
153
|
+
# (entry index, list of plans) so replay tour_steps can be filled after ids are assigned
|
|
154
|
+
entries: list[tuple[int, str, str, list[TourStepPlan], bool]] = []
|
|
155
|
+
pending_gate: Optional[str] = None
|
|
156
|
+
# (gate, the step that dropped it, why): reported only if a kept step would have waited for it.
|
|
157
|
+
stale: Optional[tuple[str, Step, str]] = None
|
|
158
|
+
attr = settings.test_id_attribute
|
|
159
|
+
|
|
160
|
+
for i, step in enumerate(steps):
|
|
161
|
+
if i < start:
|
|
162
|
+
disposition = "drop" if step.only_in == "guide" else "setup"
|
|
163
|
+
entries.append((i, disposition, "before-entry", [], False))
|
|
164
|
+
if _annotated(step):
|
|
165
|
+
sink.add(
|
|
166
|
+
"annotation-outside-tour",
|
|
167
|
+
"tour annotations on a step before the tour starts have no effect",
|
|
168
|
+
step=step,
|
|
169
|
+
hint="remove them, or move tour.entry earlier",
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
if step.only_in == "test":
|
|
173
|
+
entries.append((i, "drop", "only-in-test", [], False))
|
|
174
|
+
if _annotated(step):
|
|
175
|
+
sink.add("annotation-outside-tour", "tour annotations on an `only-in: test` step", step=step)
|
|
176
|
+
continue
|
|
177
|
+
if not in_role(step):
|
|
178
|
+
entries.append((i, "run", "other-role", [], False))
|
|
179
|
+
if _annotated(step):
|
|
180
|
+
sink.add("annotation-outside-tour", f"tour annotations on another role's step ({step.role})", step=step)
|
|
181
|
+
continue
|
|
182
|
+
|
|
183
|
+
cand = _apply_annotations(step, infer_candidate(step, attr), settings, sink)
|
|
184
|
+
if cand.kind == "gate":
|
|
185
|
+
pending_gate, stale = cand.gate, None
|
|
186
|
+
entries.append((i, "run", "gate", [], False))
|
|
187
|
+
elif cand.kind == "kept":
|
|
188
|
+
plans: list[TourStepPlan] = []
|
|
189
|
+
for j, sub in enumerate(cand.subs):
|
|
190
|
+
tid = sub.extraction.test_id if sub.extraction is not None and sub.extraction.ok else None
|
|
191
|
+
if tid and sub.extraction.reduced_from:
|
|
192
|
+
sink.add(
|
|
193
|
+
"reduced-to-host",
|
|
194
|
+
f"'{sub.selector}' points at the host test-id {tid!r}",
|
|
195
|
+
step=step,
|
|
196
|
+
)
|
|
197
|
+
wait_for = pending_gate if j == 0 and pending_gate and pending_gate != tid else None
|
|
198
|
+
if j == 0 and stale is not None and stale[0] != tid:
|
|
199
|
+
gate, cause, why = stale
|
|
200
|
+
number = step.step_number or i + 1
|
|
201
|
+
sink.add(
|
|
202
|
+
"gate-stale",
|
|
203
|
+
f"{why}, so step {number} no longer waits for {gate!r} (an earlier check)",
|
|
204
|
+
step=cause,
|
|
205
|
+
hint=f"add a `wait-for` on a test-id after this step if step {number} should wait for the page",
|
|
206
|
+
)
|
|
207
|
+
advance = cand.advance or "manual"
|
|
208
|
+
plans.append(
|
|
209
|
+
TourStepPlan(
|
|
210
|
+
step_index=i,
|
|
211
|
+
step_number=step.step_number or i + 1,
|
|
212
|
+
step_title=step.step_title or "",
|
|
213
|
+
action=step.action,
|
|
214
|
+
target=tid,
|
|
215
|
+
source_selector=sub.selector if tid else None,
|
|
216
|
+
advance=advance,
|
|
217
|
+
wait_for=wait_for,
|
|
218
|
+
placement=step.tour_placement if step.tour_placement and tid else None,
|
|
219
|
+
layouts=tuple(step.layouts) if step.layouts else None,
|
|
220
|
+
field=sub.field,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
if step.tour_placement and step.tour_placement != "auto" and not any(p.target for p in plans):
|
|
224
|
+
sink.add("placement-ignored", "a centred step has no side to place the popover on", step=step)
|
|
225
|
+
kept.extend(plans)
|
|
226
|
+
pending_gate, stale = None, None
|
|
227
|
+
entries.append((i, "tour", "tour-step", plans, False))
|
|
228
|
+
else:
|
|
229
|
+
entries.append((i, "run", cand.reason or step.action, [], cand.reloads_page))
|
|
230
|
+
if cand.reason == "conditional":
|
|
231
|
+
sink.add(
|
|
232
|
+
"conditional-skipped",
|
|
233
|
+
"guarded by `when:`/`optional:`, so it may not happen; a linear tour leaves it out",
|
|
234
|
+
step=step,
|
|
235
|
+
)
|
|
236
|
+
elif cand.reason == "gate-dropped":
|
|
237
|
+
sink.add(
|
|
238
|
+
"gate-dropped",
|
|
239
|
+
"this check's selector is not a test-id, so the next tour step does not wait for it",
|
|
240
|
+
step=step,
|
|
241
|
+
hint="point the check at a test-id to make it a gate",
|
|
242
|
+
)
|
|
243
|
+
elif cand.reason != "tour-skip":
|
|
244
|
+
sink.add("step-skipped", f"left out of the tour ({cand.reason or step.action})", step=step)
|
|
245
|
+
why = page_change(step) if pending_gate else None
|
|
246
|
+
if why:
|
|
247
|
+
stale, pending_gate = (pending_gate, step, why), None
|
|
248
|
+
for code, message, hint in cand.notes:
|
|
249
|
+
sink.add(code, message, step=step, hint=hint)
|
|
250
|
+
for gate in cand.trailing_gates:
|
|
251
|
+
pending_gate, stale = gate, None
|
|
252
|
+
|
|
253
|
+
if not kept:
|
|
254
|
+
sink.add(
|
|
255
|
+
"no-kept-steps",
|
|
256
|
+
"the tour has no steps after the start point",
|
|
257
|
+
hint="check tour.entry, or mark steps `tour: keep`",
|
|
258
|
+
)
|
|
259
|
+
return None
|
|
260
|
+
if len(kept) > MAX_STEPS:
|
|
261
|
+
sink.add("limit-exceeded", f"the tour has {len(kept)} steps; the player accepts at most {MAX_STEPS}")
|
|
262
|
+
return None
|
|
263
|
+
mapped = set(settings.layout_map)
|
|
264
|
+
for plan in kept:
|
|
265
|
+
if plan.layouts and not set(plan.layouts) <= mapped:
|
|
266
|
+
sink.add(
|
|
267
|
+
"layouts-unmapped",
|
|
268
|
+
f"this step only runs on layouts {list(plan.layouts)}, which tours.layouts does not map to media queries",
|
|
269
|
+
step=steps[plan.step_index],
|
|
270
|
+
hint="add them under tours.layouts in config.yaml",
|
|
271
|
+
)
|
|
272
|
+
if sink.has_errors:
|
|
273
|
+
return None
|
|
274
|
+
|
|
275
|
+
from .ids import assign_step_ids
|
|
276
|
+
|
|
277
|
+
assign_step_ids(kept)
|
|
278
|
+
index_of = {id(p): n for n, p in enumerate(kept)}
|
|
279
|
+
replay_entries = []
|
|
280
|
+
for i, disposition, reason, plans, reloads in entries:
|
|
281
|
+
tour_steps = tuple(
|
|
282
|
+
ReplayTourStep(
|
|
283
|
+
step_id=p.step_id,
|
|
284
|
+
index=index_of[id(p)],
|
|
285
|
+
target=p.target,
|
|
286
|
+
wait_for=p.wait_for,
|
|
287
|
+
advance=p.advance,
|
|
288
|
+
layouts=p.layouts,
|
|
289
|
+
field=p.field,
|
|
290
|
+
source_selector=p.source_selector,
|
|
291
|
+
)
|
|
292
|
+
for p in plans
|
|
293
|
+
)
|
|
294
|
+
replay_entries.append(
|
|
295
|
+
ReplayEntry(
|
|
296
|
+
step_number=steps[i].step_number or i + 1,
|
|
297
|
+
disposition=disposition, # type: ignore[arg-type]
|
|
298
|
+
reason=reason,
|
|
299
|
+
tour_steps=tour_steps,
|
|
300
|
+
reloads_page=reloads,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
return ScenarioInference(
|
|
304
|
+
role=role,
|
|
305
|
+
entry_route=entry_route,
|
|
306
|
+
entry_params=entry_params,
|
|
307
|
+
entry_step_index=k,
|
|
308
|
+
start_index=start,
|
|
309
|
+
steps=kept,
|
|
310
|
+
entries=replay_entries,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _apply_annotations(step: Step, cand: Candidate, settings: ToursSettings, sink: DiagSink) -> Candidate:
|
|
315
|
+
"""Fold ``tour`` / ``tour-advance`` / ``tour-placement`` / ``tour-text`` into the candidate."""
|
|
316
|
+
attr = settings.test_id_attribute
|
|
317
|
+
extra = bool(step.tour_text or step.tour_advance or step.tour_placement)
|
|
318
|
+
|
|
319
|
+
if cand.kind == "skip" and cand.reason == "conditional":
|
|
320
|
+
if step.tour == "keep":
|
|
321
|
+
sink.add(
|
|
322
|
+
"keep-not-allowed",
|
|
323
|
+
"a step guarded by `when:` or `optional:` may not happen, and a linear tour cannot branch",
|
|
324
|
+
step=step,
|
|
325
|
+
)
|
|
326
|
+
elif extra:
|
|
327
|
+
sink.add("annotation-on-skipped-step", "tour annotations on a `when:`/`optional:` step", step=step)
|
|
328
|
+
return cand
|
|
329
|
+
|
|
330
|
+
if step.tour == "skip":
|
|
331
|
+
if extra:
|
|
332
|
+
sink.add("skip-and-annotations", "`tour: skip` together with other tour annotations", step=step)
|
|
333
|
+
return Candidate("skip", reason="tour-skip", trailing_gates=cand.trailing_gates, reloads_page=cand.reloads_page)
|
|
334
|
+
|
|
335
|
+
if step.tour == "keep":
|
|
336
|
+
if cand.kind != "kept":
|
|
337
|
+
ext, selector = natural_target(step, attr)
|
|
338
|
+
tid_ok = ext is not None and ext.ok
|
|
339
|
+
forced = Candidate(
|
|
340
|
+
"kept",
|
|
341
|
+
advance=step.tour_advance or "manual",
|
|
342
|
+
family="info",
|
|
343
|
+
centred_ok=True,
|
|
344
|
+
trailing_gates=cand.trailing_gates,
|
|
345
|
+
)
|
|
346
|
+
forced.subs.append(_sub(ext, selector, tid_ok))
|
|
347
|
+
cand = forced
|
|
348
|
+
else:
|
|
349
|
+
for sub in cand.subs:
|
|
350
|
+
if sub.extraction is not None and not sub.extraction.ok:
|
|
351
|
+
sub.extraction = None
|
|
352
|
+
cand.centred_ok = True
|
|
353
|
+
if step.tour_advance:
|
|
354
|
+
cand.advance = step.tour_advance
|
|
355
|
+
elif all(sub.extraction is None for sub in cand.subs):
|
|
356
|
+
cand.advance = "manual"
|
|
357
|
+
else:
|
|
358
|
+
if cand.kind != "kept":
|
|
359
|
+
if extra:
|
|
360
|
+
sink.add(
|
|
361
|
+
"annotation-on-skipped-step",
|
|
362
|
+
f"tour annotations on a step the tour leaves out ({cand.reason or step.action})",
|
|
363
|
+
step=step,
|
|
364
|
+
hint="add `tour: keep` to show it",
|
|
365
|
+
)
|
|
366
|
+
return cand
|
|
367
|
+
for sub in cand.subs:
|
|
368
|
+
if sub.extraction is not None and not sub.extraction.ok and not cand.centred_ok:
|
|
369
|
+
sink.add(
|
|
370
|
+
"target-not-test-id",
|
|
371
|
+
f"'{sub.selector}' is not a test-id: {sub.extraction.explain()}",
|
|
372
|
+
step=step,
|
|
373
|
+
)
|
|
374
|
+
if step.tour_advance:
|
|
375
|
+
cand.advance = step.tour_advance
|
|
376
|
+
|
|
377
|
+
if step.tour_advance and not replayable(cand, step.tour_advance):
|
|
378
|
+
sink.add(
|
|
379
|
+
"advance-not-replayable",
|
|
380
|
+
f"`tour-advance: {step.tour_advance}` cannot be satisfied by this step's own `{step.action}`, "
|
|
381
|
+
"so the tour replay would wait forever",
|
|
382
|
+
step=step,
|
|
383
|
+
)
|
|
384
|
+
has_target = any(sub.extraction is not None and sub.extraction.ok for sub in cand.subs)
|
|
385
|
+
# A non-centred step without a target already got `target-not-test-id`.
|
|
386
|
+
if cand.centred_ok and not has_target and cand.advance in TARGETED_ADVANCE_KINDS:
|
|
387
|
+
sink.add(
|
|
388
|
+
"advance-needs-target",
|
|
389
|
+
f"advance '{cand.advance}' needs a target, and this step has none",
|
|
390
|
+
step=step,
|
|
391
|
+
hint="use `tour-advance: manual`",
|
|
392
|
+
)
|
|
393
|
+
return cand
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _sub(ext: Optional[TargetExtraction], selector: Optional[str], ok: bool):
|
|
397
|
+
from .actions import Sub
|
|
398
|
+
|
|
399
|
+
return Sub(ext if ok else None, selector if ok else None)
|