steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/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"
@@ -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
+ """