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/testids.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Test-id grammar and selector → test-id extraction.
|
|
2
|
+
|
|
3
|
+
In-app tours store a single test-id value per step, never a CSS selector
|
|
4
|
+
(spec fixed decision 2). Scenarios, however, write free-form Playwright
|
|
5
|
+
selectors. :func:`extract_test_id` decides whether an authored selector *is* a
|
|
6
|
+
test-id in disguise and returns its value, or says precisely why not — with a
|
|
7
|
+
fix-it hint the compiler prints verbatim.
|
|
8
|
+
|
|
9
|
+
Accepted shapes (``A`` = the configured attribute, ``data-testid`` by default)::
|
|
10
|
+
|
|
11
|
+
[A="X"] [A='X'] [A=X] a pure test-id
|
|
12
|
+
tag[A="X"] tag-qualified
|
|
13
|
+
[A="X"] tag host + one bare descendant tag, reduced to X:
|
|
14
|
+
clicks/inputs inside the host bubble to it
|
|
15
|
+
|
|
16
|
+
Everything else — attribute prefix matches, descendant qualifiers, selector
|
|
17
|
+
lists, Playwright engines and ``>>`` chains, ``{{var}}`` ids, ``within:`` and
|
|
18
|
+
``match:`` other than ``first`` — cannot be expressed as one stable test-id.
|
|
19
|
+
|
|
20
|
+
A stdlib-only leaf (it reuses :func:`steppy.assertions.split_top_level`).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
from .assertions import split_top_level
|
|
30
|
+
from .tour_spec import TEST_ID_ATTRIBUTE_PATTERN, TEST_ID_PATTERN
|
|
31
|
+
|
|
32
|
+
DEFAULT_TEST_ID_ATTRIBUTE = "data-testid"
|
|
33
|
+
|
|
34
|
+
_ENGINE_PREFIXES = ("text=", "css=", "xpath=", "role=", "id=", "internal:", "//", "..")
|
|
35
|
+
|
|
36
|
+
#: Rejection reason -> (explanation, fix-it hint).
|
|
37
|
+
REASONS: dict[str, tuple[str, str]] = {
|
|
38
|
+
"within": (
|
|
39
|
+
"the step is scoped with `within:`, so the test-id alone is not unique",
|
|
40
|
+
"give the element its own unique test-id in the app, or mark the step `tour: skip`",
|
|
41
|
+
),
|
|
42
|
+
"match": (
|
|
43
|
+
"the step picks one of several matches with `match:`",
|
|
44
|
+
"give the element its own unique test-id in the app, or mark the step `tour: skip`",
|
|
45
|
+
),
|
|
46
|
+
"empty": ("the selector is empty", "point the step at a test-id"),
|
|
47
|
+
"interpolated": (
|
|
48
|
+
"the test-id is computed at run time ({{var}})",
|
|
49
|
+
"point the step at a stable test-id, or mark it `tour: skip`",
|
|
50
|
+
),
|
|
51
|
+
"playwright-engine": (
|
|
52
|
+
"it uses a Playwright selector engine, which the in-app player cannot evaluate",
|
|
53
|
+
"point the step at a test-id, or mark it `tour: skip`",
|
|
54
|
+
),
|
|
55
|
+
"playwright-chain": (
|
|
56
|
+
"it is a Playwright `>>` chain",
|
|
57
|
+
"point the step at a single test-id, or mark it `tour: skip`",
|
|
58
|
+
),
|
|
59
|
+
"selector-list": (
|
|
60
|
+
"it is a comma-separated selector list",
|
|
61
|
+
"point the step at a single test-id, or mark it `tour: skip`",
|
|
62
|
+
),
|
|
63
|
+
"partial-match": (
|
|
64
|
+
"it matches the test-id by prefix/suffix/substring",
|
|
65
|
+
"point the step at an exact test-id, or mark it `tour: skip`",
|
|
66
|
+
),
|
|
67
|
+
"no-test-id": (
|
|
68
|
+
"it does not use the test-id attribute",
|
|
69
|
+
"add a test-id in the app (or set `tours.test_id_attribute`), mark the step `tour: skip`, "
|
|
70
|
+
"or `tour: keep` to show it as a centred note",
|
|
71
|
+
),
|
|
72
|
+
"qualified-descendant": (
|
|
73
|
+
"it reaches an element inside the test-id host with a qualified selector",
|
|
74
|
+
"point the step at the host test-id, add a test-id on the inner element, mark the step `tour: skip`, "
|
|
75
|
+
"or `tour: keep` to show it as a centred note",
|
|
76
|
+
),
|
|
77
|
+
"invalid-value": (
|
|
78
|
+
"the test-id value has characters outside [A-Za-z0-9_.:/-] (or is longer than 128)",
|
|
79
|
+
"rename the test-id in the app",
|
|
80
|
+
),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class TargetExtraction:
|
|
86
|
+
"""Outcome of :func:`extract_test_id`: a test-id, or the reason there is none."""
|
|
87
|
+
|
|
88
|
+
test_id: Optional[str] = None
|
|
89
|
+
reason: Optional[str] = None
|
|
90
|
+
#: The bare descendant tag the selector was reduced from (``[A="X"] button`` → ``button``).
|
|
91
|
+
reduced_from: Optional[str] = None
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def ok(self) -> bool:
|
|
95
|
+
return self.test_id is not None
|
|
96
|
+
|
|
97
|
+
def explain(self) -> str:
|
|
98
|
+
"""``<explanation> — <hint>`` for a failed extraction (empty when ok)."""
|
|
99
|
+
if self.ok or self.reason is None:
|
|
100
|
+
return ""
|
|
101
|
+
why, hint = REASONS[self.reason]
|
|
102
|
+
return f"{why} — {hint}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def is_test_id(value: object) -> bool:
|
|
106
|
+
return isinstance(value, str) and re.fullmatch(TEST_ID_PATTERN, value) is not None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def check_test_id_attribute(value: object) -> str:
|
|
110
|
+
"""Validate ``tours.test_id_attribute``; raise ``ValueError`` with the rule otherwise."""
|
|
111
|
+
if not isinstance(value, str) or re.fullmatch(TEST_ID_ATTRIBUTE_PATTERN, value) is None:
|
|
112
|
+
raise ValueError(f"test-id attribute {value!r} must match {TEST_ID_ATTRIBUTE_PATTERN} (e.g. 'data-testid')")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _shape(attribute: str) -> re.Pattern[str]:
|
|
117
|
+
a = re.escape(attribute)
|
|
118
|
+
return re.compile(
|
|
119
|
+
r"\s*(?P<tag>[A-Za-z][A-Za-z0-9-]*)?"
|
|
120
|
+
r"\[\s*" + a + r"\s*=\s*"
|
|
121
|
+
r"(?:\"(?P<dq>[^\"\\]*)\"|'(?P<sq>[^'\\]*)'|(?P<uq>-?[A-Za-z_][A-Za-z0-9_-]*))"
|
|
122
|
+
r"\s*\]"
|
|
123
|
+
r"(?:\s+(?P<desc>[A-Za-z][A-Za-z0-9-]*))?\s*"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def extract_test_id(
|
|
128
|
+
selector: Optional[str],
|
|
129
|
+
attribute: str = DEFAULT_TEST_ID_ATTRIBUTE,
|
|
130
|
+
*,
|
|
131
|
+
within: Optional[str] = None,
|
|
132
|
+
match: Optional[str] = None,
|
|
133
|
+
) -> TargetExtraction:
|
|
134
|
+
"""Return the single test-id ``selector`` designates, or why it designates none.
|
|
135
|
+
|
|
136
|
+
The checks run in a fixed order and the first that applies decides, so the
|
|
137
|
+
reason a scenario author sees is always the most specific one.
|
|
138
|
+
"""
|
|
139
|
+
if within:
|
|
140
|
+
return TargetExtraction(reason="within")
|
|
141
|
+
if match is not None and str(match).strip().lower() != "first":
|
|
142
|
+
return TargetExtraction(reason="match")
|
|
143
|
+
sel = (selector or "").strip()
|
|
144
|
+
if not sel:
|
|
145
|
+
return TargetExtraction(reason="empty")
|
|
146
|
+
if "{{" in sel:
|
|
147
|
+
return TargetExtraction(reason="interpolated")
|
|
148
|
+
if sel.startswith(_ENGINE_PREFIXES):
|
|
149
|
+
return TargetExtraction(reason="playwright-engine")
|
|
150
|
+
if ">>" in sel:
|
|
151
|
+
return TargetExtraction(reason="playwright-chain")
|
|
152
|
+
if len(split_top_level(sel)) > 1:
|
|
153
|
+
return TargetExtraction(reason="selector-list")
|
|
154
|
+
if re.search(re.escape(attribute) + r"\s*[\^$*~|]=", sel):
|
|
155
|
+
return TargetExtraction(reason="partial-match")
|
|
156
|
+
if not re.search(r"\[\s*" + re.escape(attribute) + r"\s*(?:[\^$*~|]?=|\])", sel):
|
|
157
|
+
return TargetExtraction(reason="no-test-id")
|
|
158
|
+
m = _shape(attribute).fullmatch(sel)
|
|
159
|
+
if m is None:
|
|
160
|
+
return TargetExtraction(reason="qualified-descendant")
|
|
161
|
+
value = (
|
|
162
|
+
m.group("dq") if m.group("dq") is not None else m.group("sq") if m.group("sq") is not None else m.group("uq")
|
|
163
|
+
)
|
|
164
|
+
if not is_test_id(value):
|
|
165
|
+
return TargetExtraction(reason="invalid-value")
|
|
166
|
+
return TargetExtraction(test_id=value, reduced_from=m.group("desc"))
|
steppy/totp.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Time-based one-time password (TOTP / RFC 6238) — stdlib only (FLAWS §6.2).
|
|
2
|
+
|
|
3
|
+
Steppy's automated MFA login needs to compute a TOTP code from a shared secret.
|
|
4
|
+
Rather than take a ``pyotp`` dependency, this implements RFC 6238 (HMAC-SHA1,
|
|
5
|
+
30-second window, 6 digits) in ~15 lines of the standard library. That keeps the
|
|
6
|
+
**generated** ``conftest.py.j2`` self-contained: it embeds a byte-identical copy
|
|
7
|
+
of ``_totp_now`` (generated runtime code can't import ``steppy``), so a consumer
|
|
8
|
+
project running MFA logins under ``steppy run`` needs no extra package. The
|
|
9
|
+
``tests/test_totp.py`` guard extracts both copies and asserts they agree, and
|
|
10
|
+
pins ``_totp_now`` against the RFC 6238 test vector.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
import hmac
|
|
15
|
+
import hashlib
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _totp_now(secret: str, digits: int = 6, period: int = 30) -> str:
|
|
20
|
+
"""Current TOTP code for a base32 ``secret`` (RFC 6238, HMAC-SHA1)."""
|
|
21
|
+
key = base64.b32decode(secret.strip().replace(" ", "").upper() + "=" * (-len(secret.strip().replace(" ", "")) % 8))
|
|
22
|
+
counter = int(time.time()) // period
|
|
23
|
+
mac = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest()
|
|
24
|
+
offset = mac[-1] & 0x0F
|
|
25
|
+
code = (int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF) % (10**digits)
|
|
26
|
+
return str(code).zfill(digits)
|
steppy/tour_spec.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The in-app tour data contract, v1: vocabulary, grammars and limits.
|
|
2
|
+
|
|
3
|
+
Shared by the scenario models (annotation enums), the tours compiler, the
|
|
4
|
+
producer-side contract (:mod:`steppy.contracts.tours`) and — through
|
|
5
|
+
``tests/test_tour_contract_sync.py`` — the TypeScript player, whose
|
|
6
|
+
``packages/player/src/tour/contract.ts`` must hold the very same values. The
|
|
7
|
+
player is the security boundary (it validates every file it loads, bundled or
|
|
8
|
+
remote); the compiler must therefore never emit anything these patterns or
|
|
9
|
+
limits reject, or a tour would fail with ``invalid-data`` in production.
|
|
10
|
+
|
|
11
|
+
Patterns are written without backslashes so the TypeScript mirror can carry
|
|
12
|
+
them as plain string literals, and are always applied with ``re.fullmatch``
|
|
13
|
+
(Python's ``$`` also matches before a trailing newline).
|
|
14
|
+
|
|
15
|
+
A stdlib-only leaf: ``models.py`` imports it on the eager CLI path.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
#: The contract version. A player rejects any other major with
|
|
23
|
+
#: ``schema-unsupported`` instead of guessing.
|
|
24
|
+
TOUR_SCHEMA_VERSION = 1
|
|
25
|
+
ACCEPTED_TOUR_SCHEMA_MAJORS = (1,)
|
|
26
|
+
|
|
27
|
+
# ── Vocabulary ────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
#: How a step advances. ``visible`` waits for the step's target to appear;
|
|
30
|
+
#: ``manual`` waits for the Next button.
|
|
31
|
+
ADVANCE_KINDS = ("click", "input", "change", "visible", "manual")
|
|
32
|
+
#: Advance kinds that need a target element — a targetless (centred) step can
|
|
33
|
+
#: only advance on ``manual``.
|
|
34
|
+
TARGETED_ADVANCE_KINDS = frozenset({"click", "input", "change", "visible"})
|
|
35
|
+
|
|
36
|
+
PLACEMENTS = ("top", "bottom", "left", "right", "auto")
|
|
37
|
+
|
|
38
|
+
#: Values of the step-level ``tour:`` annotation.
|
|
39
|
+
STEP_TOUR_MODES = ("skip", "keep")
|
|
40
|
+
|
|
41
|
+
#: DOM events the overlay dispatches (bubbling, composed). Colon-free on
|
|
42
|
+
#: purpose: Angular templates read ``(a:b)`` as a global event target.
|
|
43
|
+
TOUR_EVENTS = ("tour-start", "tour-step", "tour-complete", "tour-dismiss", "tour-offpath", "tour-error")
|
|
44
|
+
|
|
45
|
+
#: ``tour-error`` reasons.
|
|
46
|
+
TOUR_ERROR_REASONS = ("target-timeout", "entry-params", "load-failed", "schema-unsupported", "invalid-data")
|
|
47
|
+
|
|
48
|
+
#: Languages the overlay ships chrome strings for (buttons, progress, generic
|
|
49
|
+
#: hints). Any other tour language shows them in English unless the app sets
|
|
50
|
+
#: the host's ``strings`` — the compiler warns ``chrome-strings-missing``.
|
|
51
|
+
CHROME_STRING_LOCALES = ("en", "fr", "nl", "de")
|
|
52
|
+
|
|
53
|
+
#: ``part`` names the overlay exposes for ``::part()`` theming. The replay test
|
|
54
|
+
#: helpers locate the bubble and its buttons through these, never through text.
|
|
55
|
+
TOUR_PARTS = (
|
|
56
|
+
"layer",
|
|
57
|
+
"spotlight",
|
|
58
|
+
"bubble",
|
|
59
|
+
"title",
|
|
60
|
+
"body",
|
|
61
|
+
"progress",
|
|
62
|
+
"button",
|
|
63
|
+
"primary",
|
|
64
|
+
"secondary",
|
|
65
|
+
"next",
|
|
66
|
+
"close",
|
|
67
|
+
"skip",
|
|
68
|
+
"end",
|
|
69
|
+
"resume",
|
|
70
|
+
"pill",
|
|
71
|
+
"guide-link",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
#: Attributes the overlay sets on its bubble (``section[part~=bubble]``). The
|
|
75
|
+
#: replay helpers scope the Next button by ``data-step-id`` and read
|
|
76
|
+
#: ``data-state`` to tell an anchored popover from the fallback one.
|
|
77
|
+
ATTR_TOUR_ID = "data-tour-id"
|
|
78
|
+
ATTR_STEP_ID = "data-step-id"
|
|
79
|
+
ATTR_STATE = "data-state"
|
|
80
|
+
ATTR_ADVANCE = "data-advance"
|
|
81
|
+
BUBBLE_STATES = ("shown", "fallback")
|
|
82
|
+
|
|
83
|
+
# ── Grammars ──────────────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
TEST_ID_PATTERN = r"^[A-Za-z0-9_.:/-]{1,128}$"
|
|
86
|
+
#: Step ids are test-ids or heading slugs, so the same grammar holds.
|
|
87
|
+
STEP_ID_PATTERN = TEST_ID_PATTERN
|
|
88
|
+
TEST_ID_ATTRIBUTE_PATTERN = r"^data-[a-z0-9-]+$"
|
|
89
|
+
TOUR_ID_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"
|
|
90
|
+
LAYOUT_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"
|
|
91
|
+
ROLE_PATTERN = r"^[A-Za-z0-9_-]{1,64}$"
|
|
92
|
+
#: Coarse shape only; the compiler additionally runs ``locales.validate_locale_tag``.
|
|
93
|
+
LOCALE_PATTERN = r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{1,8}){0,5}$"
|
|
94
|
+
SRI_PATTERN = r"^sha384-[A-Za-z0-9+/]{64}$"
|
|
95
|
+
TOUR_FILE_NAME_PATTERN = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}[.][A-Za-z0-9_-]{1,64}[.]json$"
|
|
96
|
+
#: A base-relative route: ``/`` or ``/seg/:param/...``. Literal segments use the
|
|
97
|
+
#: path-safe RFC 3986 set (percent-encoded otherwise); ``.``/``..`` segments
|
|
98
|
+
#: (``%2e`` counts as a dot, as URL parsers read it) and bad ``%XX`` escapes are
|
|
99
|
+
#: rejected by :func:`is_route`, not by the regex.
|
|
100
|
+
ROUTE_PATTERN = r"^/$|^(?:/(?::[A-Za-z_][A-Za-z0-9_]{0,63}|[A-Za-z0-9._~%!$&()*+,;=@-]{1,128})){1,32}$"
|
|
101
|
+
MEDIA_QUERY_PATTERN = r"^[A-Za-z0-9 :().,<>=/_-]{1,256}$"
|
|
102
|
+
|
|
103
|
+
# ── Limits (a hostile payload must not be able to freeze the page) ────────────
|
|
104
|
+
|
|
105
|
+
MAX_TOURS = 500
|
|
106
|
+
MAX_STEPS = 100
|
|
107
|
+
MAX_LOCALES = 32
|
|
108
|
+
MAX_LAYOUTS = 16
|
|
109
|
+
MAX_TITLE_CHARS = 200
|
|
110
|
+
MAX_TEXT_CHARS = 2000
|
|
111
|
+
MAX_ROUTE_CHARS = 512
|
|
112
|
+
MAX_URL_CHARS = 2048
|
|
113
|
+
MAX_MEDIA_QUERY_CHARS = 256
|
|
114
|
+
MAX_TOUR_FILE_BYTES = 262_144
|
|
115
|
+
MAX_MANIFEST_BYTES = 1_048_576
|
|
116
|
+
|
|
117
|
+
# Reserved on Windows for the basename *before* the extension, so `con.en.json`
|
|
118
|
+
# is un-creatable just as `con` is. Same list as `exclusive._WINDOWS_RESERVED`.
|
|
119
|
+
_WINDOWS_RESERVED = frozenset(
|
|
120
|
+
{"con", "prn", "aux", "nul"} | {f"com{i}" for i in range(1, 10)} | {f"lpt{i}" for i in range(1, 10)}
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
_PERCENT_RE = re.compile(r"%(?![0-9A-Fa-f]{2})")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _full(pattern: str, value: object) -> bool:
|
|
127
|
+
return isinstance(value, str) and re.fullmatch(pattern, value) is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_test_id(value: object) -> bool:
|
|
131
|
+
return _full(TEST_ID_PATTERN, value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_tour_id(value: object) -> bool:
|
|
135
|
+
"""A tour id names files (``<id>.<locale>.json``), so Windows device names are out."""
|
|
136
|
+
return _full(TOUR_ID_PATTERN, value) and str(value).casefold() not in _WINDOWS_RESERVED
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def is_layout_name(value: object) -> bool:
|
|
140
|
+
return _full(LAYOUT_NAME_PATTERN, value)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def is_route(value: object) -> bool:
|
|
144
|
+
"""A same-origin, base-relative route pattern the player will accept."""
|
|
145
|
+
if not isinstance(value, str) or len(value) > MAX_ROUTE_CHARS or not _full(ROUTE_PATTERN, value):
|
|
146
|
+
return False
|
|
147
|
+
if _PERCENT_RE.search(value):
|
|
148
|
+
return False
|
|
149
|
+
return not any(is_dot_segment(seg) for seg in value.split("/")[1:])
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def is_dot_segment(segment: str) -> bool:
|
|
153
|
+
"""``.``/``..``, also spelled ``%2e`` — URL parsers resolve both, escaping the app's base path."""
|
|
154
|
+
return segment.lower().replace("%2e", ".") in (".", "..")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def is_media_query(value: object) -> bool:
|
|
158
|
+
return _full(MEDIA_QUERY_PATTERN, value)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def tour_file_name(tour_id: str, locale: str) -> str:
|
|
162
|
+
"""``<tour-id>.<locale>.json`` — the only file names a manifest may list."""
|
|
163
|
+
return f"{tour_id}.{locale}.json"
|
steppy/tours/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""In-app tours compiler: scenarios → tour files + replay plans.
|
|
2
|
+
|
|
3
|
+
Lazily imported only (guard: ``tests/test_no_tours_import_on_default_path.py``):
|
|
4
|
+
``steppy generate`` / ``validate`` / ``build`` reach it behind
|
|
5
|
+
``tours.enabled``, and ``steppy tours`` through its leaf CLI module.
|
|
6
|
+
|
|
7
|
+
Module map — each step of the pipeline is a module:
|
|
8
|
+
|
|
9
|
+
* ``settings`` resolved, immutable view of ``config.yaml``'s ``tours:`` block
|
|
10
|
+
* ``actions`` the per-action table (``ACTION_TOUR_KIND``) and step candidates
|
|
11
|
+
* ``routes`` entry-route normalisation, matching and parameter binding
|
|
12
|
+
* ``inference`` one scenario → kept tour steps + per-step replay dispositions
|
|
13
|
+
* ``text`` per-locale titles/texts, leak and link checks
|
|
14
|
+
* ``ids`` tour and step ids (never positional)
|
|
15
|
+
* ``compiler`` all opted-in scenarios → ``TourCompileResult``
|
|
16
|
+
* ``emit`` payloads, canonical bytes, SRI, write plans, prune
|
|
17
|
+
* ``pipeline`` the entry point ``generate`` and ``tours`` share
|
|
18
|
+
* ``publish`` the ``steppy build`` side (guide target)
|
|
19
|
+
* ``replay`` ``ReplayPlan`` → what the test template renders
|
|
20
|
+
|
|
21
|
+
Nothing here imports ``steppy.guide`` (markdown-it) or Playwright.
|
|
22
|
+
"""
|