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/models.py ADDED
@@ -0,0 +1,2539 @@
1
+ """Pydantic models for Steppy configuration and scenarios."""
2
+
3
+ import os
4
+ import re
5
+ from typing import Annotated, Any, ClassVar, Literal, Optional, Union
6
+ from pathlib import Path
7
+
8
+ from pydantic import BaseModel, Field, field_validator, model_validator
9
+
10
+ from .assertions import AssertionSyntaxError, parse_assertion
11
+ from .buildid import check_build_id_syntax
12
+ from .durations import DurationSyntaxError, parse_ms
13
+ from .exclusive import normalize_group_name
14
+ from .locales import base_language, normalize_locale, validate_locale_tag
15
+ from .presets import PRESETS, IDP_PRESETS
16
+ from .testids import check_test_id_attribute
17
+ from .tour_spec import MAX_LOCALES, is_layout_name, is_media_query, is_tour_id
18
+
19
+ # Historical default locale set. Referenced in both the SteppyConfig.locales
20
+ # default and its default_locale validator so the literal lives in one place
21
+ # (FLAWS §4.4). Projects override `locales`/`default_locale` in config.yaml.
22
+ DEFAULT_LOCALES: list[str] = ["fr", "en", "nl", "de"]
23
+ DEFAULT_LOCALE: str = "fr"
24
+
25
+ # Highest scenario-format version this Steppy understands (FLAWS §6.4). A
26
+ # scenario opts in via the optional frontmatter key `steppy: <int>`; absent ⇒ 1,
27
+ # so every existing file is already a valid v1. Mirrors the version discipline of
28
+ # the coverage store (`SCHEMA_VERSION`) and the player manifest
29
+ # (`SUPPORTED_MAJOR_VERSION`). Bump only when the scenario language changes in a
30
+ # way older Steppy can't parse; a file declaring a higher version fails loudly.
31
+ SUPPORTED_SCENARIO_VERSION: int = 1
32
+
33
+
34
+ # ── Guide Text ──────────────────────────────────────────────────────────────
35
+
36
+
37
+ class GuideTextContent(BaseModel):
38
+ """Structured content for a guide text in a single locale.
39
+
40
+ Supports both simple string format (backward compatible) and structured format:
41
+ - Simple: "Text here" -> auto-extracts title, uses full text as content
42
+ - Structured: {title: "...", subtitle: "...", text: "..."}
43
+ """
44
+
45
+ title: str
46
+ subtitle: Optional[str] = None
47
+ text: Optional[str] = None
48
+
49
+ class Config:
50
+ extra = "forbid"
51
+
52
+
53
+ class GuideText(BaseModel):
54
+ """Per-locale text for user guides.
55
+
56
+ Each locale can be either:
57
+ - A string (legacy): "Some text." -> title="Some text", text="Some text."
58
+ - A dict (new): {title: "Title", subtitle: "Subtitle", text: "Description"}
59
+
60
+ Locales are NOT limited to the four named fields below (FLAWS §4.4): those
61
+ stay for backward-compatible ``.fr``/``.en`` attribute access, but any other
62
+ locale code (es, it, pt, …) is accepted via ``extra="allow"`` and reached
63
+ through ``.get(locale)`` — so adding a language is config-only, no model
64
+ edit required.
65
+ """
66
+
67
+ fr: Optional[GuideTextContent] = None
68
+ en: Optional[GuideTextContent] = None
69
+ nl: Optional[GuideTextContent] = None
70
+ de: Optional[GuideTextContent] = None
71
+
72
+ class Config:
73
+ extra = "allow"
74
+
75
+ @model_validator(mode="before")
76
+ @classmethod
77
+ def normalize_text(cls, data):
78
+ """Normalize every locale's value to a ``GuideTextContent`` (or None).
79
+
80
+ Runs over ALL keys, not just the four declared fields, so extra locales
81
+ are normalized identically. Accepts a plain string (legacy: title =
82
+ first sentence, text = the whole string), a dict, or an already-built
83
+ ``GuideTextContent``.
84
+ """
85
+ if not isinstance(data, dict):
86
+ return data
87
+ normalized = {}
88
+ for locale, v in data.items():
89
+ if v is None or isinstance(v, GuideTextContent):
90
+ normalized[locale] = v
91
+ elif isinstance(v, str):
92
+ title = v.split(".")[0].strip() if "." in v else v.strip()
93
+ normalized[locale] = GuideTextContent(title=title, text=v)
94
+ elif isinstance(v, dict):
95
+ normalized[locale] = GuideTextContent(**v)
96
+ else:
97
+ normalized[locale] = v
98
+ return normalized
99
+
100
+ def get(self, locale: str) -> Optional[GuideTextContent]:
101
+ """Get structured text content for a specific locale.
102
+
103
+ Works for both the declared fields and ``extra="allow"`` locales, since
104
+ Pydantic exposes extras as attributes.
105
+
106
+ Falls back to the bare language subtag (``fr-BE`` -> ``fr``), the same
107
+ rung ``build.pick_locale`` uses for titles and descriptions. Without it a
108
+ regional build behaved two different ways for one cause: the guide path
109
+ raised ``Missing guide-text for locale 'fr-BE'`` and stopped the build,
110
+ while the interactive-manifest path silently emitted nulls. An author who
111
+ wants distinct regional wording still writes ``fr-BE:`` and it wins.
112
+
113
+ The **query** is truncated, never widened: ``fr`` must not match a block
114
+ that declares only ``fr-BE``, because with ``fr-BE`` and ``fr-CA`` both
115
+ present the choice would be arbitrary and attribute-order dependent.
116
+ """
117
+ found = getattr(self, locale, None)
118
+ if found is not None:
119
+ return found
120
+ bare = base_language(locale)
121
+ if bare != locale:
122
+ return getattr(self, bare, None)
123
+ return None
124
+
125
+
126
+ # ── Guide Cursor ────────────────────────────────────────────────────────────
127
+
128
+
129
+ class GuideCursorTiming(BaseModel):
130
+ """Per-scenario cursor animation timings for the guide rendering layer."""
131
+
132
+ pre_action_ms: int = 200
133
+ action_ms: int = 300
134
+ post_action_ms: int = 150
135
+ travel_speed_px_per_ms: float = 1.5
136
+
137
+ class Config:
138
+ extra = "forbid"
139
+
140
+
141
+ class GuideConfig(BaseModel):
142
+ """Top-level `guide:` frontmatter block."""
143
+
144
+ enabled: bool = False
145
+ cursor: GuideCursorTiming = Field(default_factory=GuideCursorTiming)
146
+
147
+ class Config:
148
+ extra = "forbid"
149
+
150
+
151
+ class GuideCursorOverride(BaseModel):
152
+ """Step-level `guide-cursor` override, only meaningful on `custom` steps."""
153
+
154
+ behavior: Literal["click", "type", "hover"]
155
+ target: str
156
+ value: Optional[str] = None
157
+
158
+ class Config:
159
+ extra = "forbid"
160
+
161
+
162
+ def expect_checks(expect) -> list[str]:
163
+ """Every assertion string in an ``expect:``, whatever form it took.
164
+
165
+ One reader for the scalar and the list forms, so validation and codegen
166
+ cannot disagree about how many assertions a step carries.
167
+ """
168
+ if expect is None:
169
+ return []
170
+ if isinstance(expect, str):
171
+ return [expect]
172
+ return [e if isinstance(e, str) else e.check for e in expect]
173
+
174
+
175
+ class ExpectEntry(BaseModel):
176
+ """One assertion in a multi-assertion ``expect:`` list.
177
+
178
+ ``expect:`` was a single scalar with no failure message, and the corpus says
179
+ both halves cost: of one project's 1 594 assert-bearing custom blocks, 481
180
+ hold more than one assertion and 1 247 (78%) carry a message — 1 229 of them
181
+ f-strings that print the value actually observed. A scenario author could
182
+ express neither, so the whole verification step stayed in Python.
183
+
184
+ ``message`` maps to Playwright's own ``expect(locator, message=...)``.
185
+ """
186
+
187
+ check: str
188
+ message: Optional[str] = None
189
+
190
+ class Config:
191
+ extra = "forbid"
192
+
193
+
194
+ # ── Step Model ──────────────────────────────────────────────────────────────
195
+
196
+
197
+ #: Every action a scenario step may declare.
198
+ #:
199
+ #: Module-level (not rebuilt inside the validator on every step parse) and
200
+ #: public, because the interactive player carries its own copy as the
201
+ #: ``ManifestAction`` Zod enum in ``packages/player/src/manifest/schema.ts``.
202
+ #: That enum sits inside a ``.strict()`` schema, so an action added here but
203
+ #: not there makes the player reject the whole manifest at runtime — a green
204
+ #: pytest suite, a green Vitest suite, a successful ``steppy build``, and a
205
+ #: guide that will not render in the viewer's browser.
206
+ #: ``tests/test_action_enum_sync.py`` pins the two together.
207
+ #:
208
+ #: ``auth0-login`` is deliberately absent: it is a deprecated alias normalized
209
+ #: to ``login`` before this set is consulted (FLAWS §6.2).
210
+ VALID_ACTIONS: frozenset[str] = frozenset(
211
+ {
212
+ "navigate",
213
+ "click",
214
+ "fill",
215
+ "fill-form",
216
+ "select",
217
+ "wait",
218
+ "wait-for",
219
+ "wait-for-text",
220
+ "scroll",
221
+ "hover",
222
+ "keyboard",
223
+ "assert",
224
+ "custom",
225
+ "login",
226
+ "capture",
227
+ "upload",
228
+ "select-option",
229
+ "confirm",
230
+ "fill-date",
231
+ "toggle",
232
+ "pick-date",
233
+ "pick-time",
234
+ "expand",
235
+ "call",
236
+ }
237
+ )
238
+
239
+
240
+ #: Load-bearing fields per action: ``action -> ((attr, description), ...)``.
241
+ #:
242
+ #: Drives ``Step.validate_required_action_fields``, which raises
243
+ #: ``"<action> action requires '<attr>' (<description>)"``. A field is missing
244
+ #: when it is ``None`` or blank after ``strip()``.
245
+ #:
246
+ #: This is the whole required-field story for an action — adding one is a row
247
+ #: here, not a new validator. The per-action validators that remain below exist
248
+ #: only for rules a table cannot express: mutual exclusion (``select-option``'s
249
+ #: option/multiple), format regexes (``pick-time``'s HH:MM), cross-field shapes
250
+ #: (``capture``'s from.type), and not-yet-implemented rejections (``fill-date``
251
+ #: method=calendar).
252
+ #:
253
+ #: ``capture`` is deliberately absent: its required field is exposed as ``from``
254
+ #: but stored as ``from_``, so the attribute name and the name users must see in
255
+ #: the error diverge. It keeps its own checks.
256
+ _REQUIRED_FIELDS: dict[str, tuple[tuple[str, str], ...]] = {
257
+ # Core actions
258
+ "click": (("selector", "the element to click"),),
259
+ "hover": (("selector", "the element to hover"),),
260
+ "scroll": (("selector", "the element to scroll to"),),
261
+ "wait-for": (("selector", "the element to wait for"),),
262
+ "fill": (("selector", "the input"), ("value", "the text to type")),
263
+ "select": (("selector", "the <select>"), ("value", "the option value")),
264
+ "keyboard": (("value", "the key(s) to press"),),
265
+ "wait-for-text": (("value", "the text to wait for"),),
266
+ "wait": (("duration", 'the wait time, e.g. "3s"'),),
267
+ # Component-widget actions
268
+ "upload": (("selector", "the file input"), ("value", "the file path")),
269
+ "select-option": (("selector", "the dropdown trigger"),),
270
+ "toggle": (("selector", "the switch/host"),),
271
+ "expand": (("selector", "the fieldset host"),),
272
+ "fill-date": (
273
+ ("selector", "the date input"),
274
+ ("date", "today|this-<weekday>|next-<weekday>|±Nd/w/m/y|ISO"),
275
+ ),
276
+ "pick-date": (
277
+ ("selector", "the datepicker host"),
278
+ ("date", "today|this-<weekday>|next-<weekday>|±Nd/w/m/y|ISO"),
279
+ ),
280
+ "pick-time": (("selector", "the timepicker host"), ("time", '"HH:MM"')),
281
+ "call": (("fn", "the dotted helper path, e.g. ui_helpers.ensure_open"),),
282
+ }
283
+
284
+
285
+ #: Fields every action accepts, whatever it is: identity, the trailing
286
+ #: ``expect:``, screenshot/guide metadata, the conditional-execution filters and
287
+ #: the two internal tracking slots.
288
+ _UNIVERSAL_FIELDS: frozenset[str] = frozenset(
289
+ {
290
+ "tour",
291
+ "tour_text",
292
+ "tour_advance",
293
+ "tour_placement",
294
+ "action",
295
+ "role",
296
+ "expect",
297
+ "screenshot",
298
+ "screenshot_when",
299
+ "guide_text",
300
+ "guide_cursor",
301
+ "only_in",
302
+ "layouts",
303
+ "wait_after",
304
+ "note",
305
+ "optional",
306
+ "when",
307
+ "step_number",
308
+ "step_title",
309
+ }
310
+ )
311
+
312
+ #: The INVERSE of ``_REQUIRED_FIELDS``: what each action *accepts* beyond the
313
+ #: universal set above.
314
+ #:
315
+ #: ``Step`` is one flat model with ``extra = "forbid"``, which catches a key no
316
+ #: action has ever heard of — but said nothing about a key that belongs to a
317
+ #: *different* action. Probed before this table existed: ``retry``, ``interval``,
318
+ #: ``format``, ``variant``, ``button``, ``danger``, ``search`` and ``method``
319
+ #: each validated cleanly on all 23 actions and generated code on one. So
320
+ #: ``- action: click`` + ``- date: today`` + ``- variant: reject`` passed
321
+ #: ``steppy validate`` and silently did nothing — the same failure shape as the
322
+ #: unknown-``expect:`` hole in ``steppy.assertions``.
323
+ #:
324
+ #: Adding a field to an action means adding it here, or ``steppy validate``
325
+ #: starts rejecting the very scenario the new feature is for.
326
+ #: Two absences in the ``force`` rows are deliberate: ``upload`` because
327
+ #: Playwright's ``set_input_files`` takes no ``force``, and ``toggle`` because
328
+ #: its switch branch already emits ``check(force=True)`` unconditionally — the
329
+ #: flag would read as a choice the author does not actually have.
330
+ _ALLOWED_FIELDS: dict[str, frozenset[str]] = {
331
+ "navigate": frozenset({"target", "wait", "reload", "timeout"}),
332
+ "click": frozenset({"selector", "within", "match", "synthetic", "timeout", "force"}),
333
+ "login": frozenset(),
334
+ "fill": frozenset({"selector", "value", "clear", "sequential", "blur_key", "timeout", "force", "within", "match"}),
335
+ "fill-form": frozenset({"fields", "interactive_progressive", "timeout"}),
336
+ "select": frozenset({"selector", "value", "timeout", "force", "within", "match"}),
337
+ "select-option": frozenset({"selector", "search", "option", "multiple", "timeout", "force", "within", "match"}),
338
+ "confirm": frozenset({"variant", "button", "danger", "timeout", "force"}),
339
+ "fill-date": frozenset({"selector", "date", "format", "method", "timeout", "within", "match"}),
340
+ "toggle": frozenset({"selector", "state", "option", "timeout", "control", "within", "match"}),
341
+ "expand": frozenset({"selector", "timeout", "control", "detect", "state", "within", "match"}),
342
+ "pick-date": frozenset({"selector", "date", "timeout", "within", "match"}),
343
+ "pick-time": frozenset({"selector", "time", "timeout", "within", "match"}),
344
+ "wait": frozenset({"duration"}),
345
+ "wait-for": frozenset({"selector", "state", "within", "match", "timeout", "until", "interval", "wait"}),
346
+ "wait-for-text": frozenset({"value", "timeout"}),
347
+ "scroll": frozenset({"selector", "timeout", "within", "match"}),
348
+ "hover": frozenset({"selector", "within", "match", "timeout", "force"}),
349
+ "upload": frozenset({"selector", "value", "timeout", "within", "match"}),
350
+ "keyboard": frozenset({"value"}),
351
+ "assert": frozenset({"within", "match", "timeout"}),
352
+ "capture": frozenset({"store", "from_", "retry", "interval", "timeout"}),
353
+ "call": frozenset({"fn", "args", "kwargs", "store", "page", "timeout"}),
354
+ "custom": frozenset({"code"}),
355
+ }
356
+
357
+ #: Attribute name -> the spelling a scenario author actually types, for error
358
+ #: messages. Only the fields whose alias differs from the attribute.
359
+ _FIELD_ALIASES: dict[str, str] = {
360
+ "from_": "from",
361
+ "blur_key": "blur-key",
362
+ "screenshot_when": "screenshot-when",
363
+ "guide_text": "guide-text",
364
+ "guide_cursor": "guide-cursor",
365
+ "only_in": "only-in",
366
+ "interactive_progressive": "interactive-progressive",
367
+ "tour_text": "tour-text",
368
+ "tour_advance": "tour-advance",
369
+ "tour_placement": "tour-placement",
370
+ }
371
+
372
+ #: Spellings of a boolean a step-level ``tour:`` must not take (step scalars
373
+ #: arrive as strings, frontmatter values as real YAML booleans).
374
+ _BOOLISH = frozenset({"true", "false", "yes", "no", "on", "off"})
375
+
376
+
377
+ class Step(BaseModel):
378
+ """A single step in a scenario."""
379
+
380
+ # Core fields
381
+ action: str
382
+ role: Optional[str] = None # Required for most actions except 'wait'
383
+
384
+ # Action-specific fields
385
+ target: Optional[str] = None # For navigate
386
+ selector: Optional[str] = None # For click, fill, select, etc.
387
+ value: Optional[str] = None # For fill, select, keyboard, wait-for-text
388
+ fields: Optional[dict[str, str]] = None # For fill-form
389
+ duration: Optional[str] = None # For wait (e.g., "3s", "5000ms")
390
+ code: Optional[str] = None # For custom
391
+ # wait-for: visible|hidden|detached|attached. toggle: on|off (the latter two
392
+ # are accepted here because PyYAML coerces `on`/`off` to bool, normalized by
393
+ # the field_validator below). Per-action subsets are enforced in validators.
394
+ state: Optional[Literal["visible", "hidden", "detached", "attached", "on", "off"]] = None
395
+ # navigate: load-state strategy to await after goto (networkidle default).
396
+ wait: Optional[Literal["networkidle", "load", "domcontentloaded", "none"]] = None
397
+ # navigate: reload the current page (page.reload()) instead of goto — the
398
+ # hard-refetch idiom after async/SQS propagation. 'target' is not required.
399
+ reload: Optional[bool] = None
400
+
401
+ # select-option action (component dropdown, e.g. PrimeNG p-select)
402
+ search: Optional[str] = None # Type into the dropdown filter box before picking
403
+ option: Optional[str] = None # first | last | N (index) | text:"..."
404
+ multiple: Optional[list[str]] = None # Multiselect: pick several by label, then Escape
405
+
406
+ # Locator disambiguation for click/hover/wait-for/assert. Both default to
407
+ # "off" so unfiltered steps regenerate byte-identically.
408
+ within: Optional[str] = None # Scope selector: .locator(within).locator(selector)
409
+ match: Optional[str] = None # first | last | N (index) on the resolved locator
410
+
411
+ # confirm action (modal / confirm-dialog button). All optional; default = accept.
412
+ variant: Optional[Literal["accept", "reject"]] = None # accept (default) | reject
413
+ button: Optional[str] = None # Match by visible label when class lookup is ambiguous
414
+ danger: Optional[bool] = None # Prefer the destructive/danger button
415
+
416
+ # fill-date / pick-date action (relative/dynamic date).
417
+ date: Optional[str] = None # today | this-<weekday> | next-<weekday> | ±Nd/w/m/y | ISO
418
+ format: Optional[str] = None # strftime format; default "%d/%m/%Y"
419
+ method: Optional[Literal["type", "calendar"]] = None # type (default) | calendar
420
+
421
+ # pick-time action (drive an hour/minute clock widget).
422
+ time: Optional[str] = None # "HH:MM"
423
+
424
+ # fill action flags (off by default → byte-identical output).
425
+ clear: Optional[bool] = None # select-all + delete before typing
426
+ sequential: Optional[bool] = None # press_sequentially instead of fill (fires keydown/input)
427
+ blur_key: Optional[str] = Field(default=None, alias="blur-key") # key pressed after typing to commit
428
+
429
+ # click action flag: dispatch synthetic MouseEvents via evaluate() for
430
+ # elements that swallow Playwright's native .click() (off → byte-identical).
431
+ synthetic: Optional[bool] = None
432
+
433
+ # Per-step Playwright timeout, overriding config.timing.step_timeout (and
434
+ # Scenario.timeout) for this step only. Accepts "25s" / "500ms" / a bare
435
+ # integer read as milliseconds, matching the `wait` action's duration
436
+ # grammar. Absent ⇒ the global default and byte-identical output.
437
+ #
438
+ # A single global cannot serve both a 5s dropdown and a 6-minute report
439
+ # generation, which is why 42% of one project's custom blocks carry a
440
+ # hand-written `timeout=`.
441
+ timeout: Optional[Union[str, int]] = None
442
+
443
+ # Where the control actually is, for `toggle` and `expand`.
444
+ #
445
+ # Both actions hard-wire a DESCENDANT hop: toggle builds
446
+ # `locator(selector).locator(config.toggle.switch)`, expand builds
447
+ # `locator(selector).locator(config.fieldset.toggle)`. So a selector aimed
448
+ # at the checkbox (or the disclosure button) itself resolves to nothing, and
449
+ # a page with two widget flavours needs a global config change to reach the
450
+ # second. `control: self` skips the hop; any other value overrides the
451
+ # config selector for this step only. Absent ⇒ today's descendant hop.
452
+ control: Optional[str] = None
453
+
454
+ # A state predicate for `wait-for`, beyond DOM presence.
455
+ #
456
+ # Playwright's own wait accepts only visible|hidden|detached|attached, none
457
+ # of which says "the form has finished pre-filling" — the single most
458
+ # frequent reason one project kept a step in custom code, 47 times over, all
459
+ # of them the same hand-rolled `deadline = time.time() + 10` loop.
460
+ #
461
+ # Grammar: value-not-empty | value:<text> | text:<text> | count:<cmp><n>
462
+ until: Optional[str] = None
463
+
464
+ # How `expand` decides whether the section is already open.
465
+ #
466
+ # `content` (default) asks whether the content element is visible.
467
+ # `aria` reads `aria-expanded` off the toggle — which is what a disclosure
468
+ # button exposes, and what the projects' own helpers read. The two disagree
469
+ # whenever the content is present but clipped, or animated in.
470
+ detect: Optional[Literal["content", "aria"]] = None
471
+
472
+ # Pass force=True to the underlying Playwright call, skipping the
473
+ # actionability (visible / stable / receives-events) checks. The idiom for a
474
+ # component button that never reaches "stable" because its overlay is still
475
+ # animating. Distinct from `synthetic:`, which dispatches untrusted
476
+ # MouseEvents and bypasses real input events entirely.
477
+ force: Optional[bool] = None
478
+
479
+ # Capture action: store a value fetched from an external source
480
+ store: Optional[str] = None # Variable name to store the captured value
481
+ from_: Optional[dict[str, Any]] = Field(default=None, alias="from") # Fetcher spec
482
+ retry: Optional[int] = None # Number of attempts (default 1 in template)
483
+ interval: Optional[str] = None # Retry interval, e.g. "500ms" or "1s"
484
+
485
+ # Assertions. A bare string is one check, exactly as before; a list holds
486
+ # several, each optionally carrying its own failure message.
487
+ expect: Optional[Union[str, list[Union[str, ExpectEntry]]]] = None
488
+
489
+ # Screenshot and guide
490
+ screenshot: Optional[str | bool] = None # False, "all", True, or a role name defined in config.apps
491
+ screenshot_when: Literal["before", "after"] = Field(default="after", alias="screenshot-when")
492
+ guide_text: Optional[GuideText] = Field(default=None, alias="guide-text")
493
+ guide_cursor: Optional[GuideCursorOverride] = Field(default=None, alias="guide-cursor")
494
+
495
+ # Conditional execution. Both default to "step always runs".
496
+ # only-in: restrict to test or guide mode (default = both).
497
+ # layouts: restrict to specific layouts in guide mode (default = all configured layouts;
498
+ # ignored in test mode since there's no layout dimension there).
499
+ only_in: Optional[Literal["test", "guide"]] = Field(default=None, alias="only-in")
500
+ layouts: Optional[list[str]] = None
501
+
502
+ # In-app tour annotations, read only when the scenario opts into tours (all
503
+ # inert otherwise). `tour: skip` leaves a step out of the tour — it still
504
+ # runs in the test and in the tour replay; `tour: keep` forces a step that
505
+ # inference would skip into the tour. `tour-text` overrides `guide-text` for
506
+ # the tour popover; `tour-advance` / `tour-placement` override what inference
507
+ # picks. The value sets are `steppy.tour_spec`'s, pinned by a test.
508
+ tour: Optional[Literal["skip", "keep"]] = None
509
+ tour_text: Optional[GuideText] = Field(default=None, alias="tour-text")
510
+ tour_advance: Optional[Literal["click", "input", "change", "visible", "manual"]] = Field(
511
+ default=None, alias="tour-advance"
512
+ )
513
+ tour_placement: Optional[Literal["top", "bottom", "left", "right", "auto"]] = Field(
514
+ default=None, alias="tour-placement"
515
+ )
516
+
517
+ # Progressive capture for fill-form: snapshot once per field with a
518
+ # single-target cursor on the next-to-fill field, then a settled "all
519
+ # filled" frame with no cursor. Interactive players advance frame-by-frame
520
+ # with a click between each, so the user sees the form fill up step by step.
521
+ # Only valid on action='fill-form'.
522
+ interactive_progressive: bool = Field(default=False, alias="interactive-progressive")
523
+
524
+ # `call` action: run a project helper function as a first-class step.
525
+ #
526
+ # The single biggest bucket of residual custom code: 2 398 blocks in one
527
+ # project import a `*_helpers` module, and 926 of them do nothing else. The
528
+ # helpers themselves are genuinely domain-specific — one knows the app's
529
+ # French status labels *and* branches on viewport — so Steppy should not
530
+ # absorb them; it should let a scenario name one.
531
+ #
532
+ # A `call` step is also a NAMED step, which a `custom` block is not: it can
533
+ # carry a `guide-cursor:` and appear in the interactive player and the video
534
+ # instead of publishing as `cursor: null, interactive: false`.
535
+ fn: Optional[str] = None # dotted path, e.g. "ui_helpers.ensure_open"
536
+ args: Optional[list[Any]] = None # positional arguments, after the page
537
+ kwargs: Optional[dict[str, Any]] = None # keyword arguments
538
+ # The page is passed first by default, which is how every helper in the
539
+ # measured corpus is written. `page: false` is the escape hatch for a pure
540
+ # value producer (`sentinel_email("editor")`) that takes no page at all.
541
+ page: Optional[bool] = None
542
+
543
+ # Conditional execution against LIVE PAGE STATE, as opposed to `only-in:`
544
+ # and `layouts:`, which gate on the run's *mode*.
545
+ #
546
+ # `optional: true` — run it, and carry on if it raises. For the panel that
547
+ # only appears the first time, or the banner that has
548
+ # already cleared.
549
+ # `when: visible(#a)` — run it only if the condition holds right now.
550
+ #
551
+ # Between them these cover the "an optional confirm plus a wait on a banner
552
+ # appearing then clearing" and "a click that falls back between two
553
+ # conditionally-rendered buttons" cases the corpus names by hand.
554
+ optional: Optional[bool] = None
555
+ when: Optional[str] = None
556
+
557
+ # Metadata
558
+ wait_after: Optional[int] = None # milliseconds
559
+ note: Optional[str] = None
560
+
561
+ # Internal tracking
562
+ step_number: Optional[int] = None
563
+ step_title: Optional[str] = None
564
+
565
+ class Config:
566
+ populate_by_name = True
567
+ extra = "forbid"
568
+
569
+ @field_validator("action", mode="before")
570
+ @classmethod
571
+ def validate_action(cls, v: str) -> str:
572
+ """Validate that action is one of the known types.
573
+
574
+ ``auth0-login`` is a kept-forever deprecated alias for ``login``,
575
+ normalized here so downstream (codegen) sees a single name (FLAWS §6.2).
576
+ """
577
+ if v == "auth0-login":
578
+ v = "login"
579
+ if v not in VALID_ACTIONS:
580
+ raise ValueError(f"Unknown action type: {v}. Valid actions: {', '.join(sorted(VALID_ACTIONS))}")
581
+ return v
582
+
583
+ @field_validator("tour", mode="before")
584
+ @classmethod
585
+ def normalize_tour_mode(cls, v):
586
+ """Step-level ``tour:`` takes ``skip`` or ``keep``.
587
+
588
+ The frontmatter ``tour: true`` opts a whole scenario in. Written on a step
589
+ it would read as plausible and do nothing, so the boolean spellings are
590
+ rejected with a pointer to where they belong.
591
+ """
592
+ if v is None:
593
+ return v
594
+ if isinstance(v, bool) or (isinstance(v, str) and v.strip().lower() in _BOOLISH):
595
+ raise ValueError(
596
+ "step-level 'tour' takes 'skip' or 'keep'; to opt a scenario into in-app tours, "
597
+ "set `tour: true` in its frontmatter"
598
+ )
599
+ return v.strip() if isinstance(v, str) else v
600
+
601
+ @field_validator("tour_text")
602
+ @classmethod
603
+ def validate_tour_text_locales(cls, v):
604
+ """Every ``tour-text`` key is a locale tag, and at least one carries text.
605
+
606
+ ``GuideText`` accepts any key as a locale (``extra="allow"``), so without
607
+ this a misspelt inner key would silently become a locale nothing ever
608
+ asks for.
609
+ """
610
+ if v is None:
611
+ return v
612
+ values = {name: getattr(v, name) for name in ("fr", "en", "nl", "de")}
613
+ values.update(v.model_extra or {})
614
+ present = [key for key, value in values.items() if value is not None]
615
+ if not present:
616
+ raise ValueError("'tour-text' needs text for at least one locale")
617
+ for key in present:
618
+ validate_locale_tag(key)
619
+ return v
620
+
621
+ @field_validator("layouts")
622
+ @classmethod
623
+ def validate_layouts(cls, v):
624
+ """Reject empty lists and empty layout names. Membership against
625
+ ``config.screenshot.layouts`` is checked post-parse in
626
+ ``parser.validate_scenario_metadata``."""
627
+ if v is None:
628
+ return v
629
+ if not v:
630
+ raise ValueError("'layouts' must be a non-empty list (or omitted)")
631
+ for item in v:
632
+ if not isinstance(item, str) or not item.strip():
633
+ raise ValueError(f"'layouts' entries must be non-empty strings, got: {item!r}")
634
+ return v
635
+
636
+ @field_validator("state", mode="before")
637
+ @classmethod
638
+ def normalize_state(cls, v):
639
+ """``toggle`` uses ``state: on|off`` — but PyYAML coerces the bare words
640
+ ``on``/``off`` (and ``yes``/``no``) to Python booleans. Map booleans back
641
+ to the ``on``/``off`` strings so the Literal accepts them. wait-for states
642
+ are always quoted strings and pass through untouched."""
643
+ if isinstance(v, bool):
644
+ return "on" if v else "off"
645
+ return v
646
+
647
+ @model_validator(mode="after")
648
+ def validate_interactive_progressive(self) -> "Step":
649
+ """``interactive-progressive`` only makes sense for fill-form."""
650
+ if self.interactive_progressive and self.action != "fill-form":
651
+ raise ValueError(f"'interactive-progressive' is only valid on fill-form steps (got action='{self.action}')")
652
+ return self
653
+
654
+ @model_validator(mode="after")
655
+ def validate_required_action_fields(self) -> "Step":
656
+ """Enforce the load-bearing fields for every action (see _REQUIRED_FIELDS).
657
+
658
+ Without this, e.g. a ``click`` with no ``selector`` generates
659
+ ``page.click(None)`` and fails at runtime with a baffling Playwright
660
+ timeout instead of at ``steppy validate`` time.
661
+
662
+ Per-action validators below cover only rules a required-field table
663
+ cannot express (mutual exclusion, format regexes, cross-field shapes).
664
+ """
665
+ for field, desc in _REQUIRED_FIELDS.get(self.action, ()):
666
+ v = getattr(self, field)
667
+ if v is None or not str(v).strip():
668
+ raise ValueError(f"{self.action} action requires '{field}' ({desc})")
669
+
670
+ # navigate needs a target unless it's a reload
671
+ if self.action == "navigate" and not self.reload:
672
+ if not self.target or not self.target.strip():
673
+ raise ValueError("navigate action requires 'target' (the URL/path) unless reload: true")
674
+
675
+ # DOM actions generate `<role>_page` — role is mandatory (wait is exempt)
676
+ if self.action in {
677
+ "click",
678
+ "hover",
679
+ "scroll",
680
+ "wait-for",
681
+ "fill",
682
+ "select",
683
+ "keyboard",
684
+ "wait-for-text",
685
+ "navigate",
686
+ }:
687
+ if not self.role or not self.role.strip():
688
+ raise ValueError(f"{self.action} action requires 'role'")
689
+
690
+ return self
691
+
692
+ @model_validator(mode="after")
693
+ def validate_capture_fields(self) -> "Step":
694
+ """When action == 'capture', enforce store + from.type shape."""
695
+ if self.action != "capture":
696
+ return self
697
+ if not self.store:
698
+ raise ValueError("capture action requires 'store' (variable name)")
699
+ if not self.from_:
700
+ raise ValueError("capture action requires 'from' (fetcher spec)")
701
+ ftype = self.from_.get("type")
702
+ valid_types = {"redis", "http", "shell", "dom", "date"}
703
+ if ftype not in valid_types:
704
+ raise ValueError(f"capture 'from.type' must be one of {valid_types}, got: {ftype!r}")
705
+ if ftype == "redis" and not self.from_.get("key"):
706
+ raise ValueError("capture from.type=redis requires 'key'")
707
+ if ftype == "http" and not self.from_.get("url"):
708
+ raise ValueError("capture from.type=http requires 'url'")
709
+ if ftype == "shell" and not self.from_.get("command"):
710
+ raise ValueError("capture from.type=shell requires 'command'")
711
+ if ftype == "date":
712
+ # Steppy already owns the date grammar — `_resolve_date` understands
713
+ # today | this-<weekday> | next-<weekday> | ±N[d|w|m|y] | ISO — but it
714
+ # was reachable only from inside `fill-date`/`pick-date`, i.e. only
715
+ # to TYPE a date into a field. The corpus needs the resolved value as
716
+ # a SELECTOR fragment
717
+ # (`[data-testid="agenda-calendar-day-{{target_day}}"]`) and as an ISO
718
+ # week number. Capturing it makes the same grammar interpolatable
719
+ # anywhere, with no new date code and no page involved.
720
+ if not self.from_.get("date"):
721
+ raise ValueError("capture from.type=date requires 'date' (today|next-monday|+7d|YYYY-MM-DD)")
722
+ if ftype == "dom":
723
+ # DOM capture reads from the live page, so it needs a role page and
724
+ # a selector. ``extract`` selects the source (text|attribute|regex);
725
+ # attribute/regex require their companion field.
726
+ if not self.from_.get("selector"):
727
+ raise ValueError("capture from.type=dom requires 'selector'")
728
+ if not self.role:
729
+ raise ValueError("capture from.type=dom requires 'role' (it reads the page)")
730
+ extract = self.from_.get("extract", "text")
731
+ if extract not in ("text", "attribute", "regex"):
732
+ raise ValueError(f"capture from.type=dom 'extract' must be text|attribute|regex, got: {extract!r}")
733
+ if extract == "attribute" and not self.from_.get("attribute"):
734
+ raise ValueError("capture from.type=dom extract=attribute requires 'attribute'")
735
+ if extract == "regex" and not self.from_.get("regex"):
736
+ raise ValueError("capture from.type=dom extract=regex requires 'regex'")
737
+ return self
738
+
739
+ @model_validator(mode="after")
740
+ def validate_select_option_fields(self) -> "Step":
741
+ """select-option takes exactly one of 'option' (single pick) or
742
+ 'multiple' (multiselect) — never both, never neither.
743
+
744
+ The required 'selector' is table-driven (_REQUIRED_FIELDS).
745
+ """
746
+ if self.action != "select-option":
747
+ return self
748
+ has_option = self.option is not None and str(self.option).strip() != ""
749
+ has_multiple = self.multiple is not None
750
+ if has_option and has_multiple:
751
+ raise ValueError("select-option accepts either 'option' or 'multiple', not both")
752
+ if not has_option and not has_multiple:
753
+ raise ValueError("select-option requires 'option' (first|last|N|text:\"...\") or 'multiple' (list)")
754
+ return self
755
+
756
+ @model_validator(mode="after")
757
+ def validate_match_within(self) -> "Step":
758
+ """'match'/'within' disambiguate a selector, so they apply to every
759
+ action that resolves one. 'match' accepts first|last|<integer index>.
760
+
761
+ The original four (click, hover, wait-for, assert) left every
762
+ widget-driving action with no way to name the n-th widget on a page.
763
+ Playwright's own ``>>`` chaining is a workaround — one project uses it
764
+ 159 times — but it hides the disambiguation inside an opaque selector
765
+ string, which means guide mode cannot resolve a bbox for it
766
+ (``_safe_bbox_locator`` needs a real locator, not a selector with an
767
+ engine chain in it). Widening the set is what makes the resolved locator
768
+ available to both codegen and the cursor.
769
+
770
+ ``confirm`` stays out: it has no ``selector`` at all, resolving its
771
+ button imperatively from the dialog overlay."""
772
+ if self.match is None and self.within is None:
773
+ return self
774
+ supported = {
775
+ "click",
776
+ "hover",
777
+ "wait-for",
778
+ "assert",
779
+ "fill",
780
+ "select",
781
+ "select-option",
782
+ "toggle",
783
+ "expand",
784
+ "upload",
785
+ "scroll",
786
+ "fill-date",
787
+ "pick-date",
788
+ "pick-time",
789
+ }
790
+ if self.action not in supported:
791
+ raise ValueError(f"'match'/'within' are only valid on {sorted(supported)} (got action='{self.action}')")
792
+ if self.match is not None:
793
+ m = str(self.match).strip().lower()
794
+ if m not in ("first", "last") and not m.isdigit():
795
+ raise ValueError(f"'match' must be first|last|<index>, got: {self.match!r}")
796
+ return self
797
+
798
+ @model_validator(mode="after")
799
+ def validate_confirm_fields(self) -> "Step":
800
+ """confirm drives a confirmation-dialog button. All fields optional
801
+ (default = accept the active dialog); 'button' and 'variant'/'danger'
802
+ are alternative ways to pick the target button."""
803
+ if self.action != "confirm":
804
+ return self
805
+ if self.button and (self.variant or self.danger):
806
+ raise ValueError("confirm 'button' (label match) cannot combine with 'variant'/'danger'")
807
+ return self
808
+
809
+ @model_validator(mode="after")
810
+ def validate_fill_date_fields(self) -> "Step":
811
+ """fill-date's 'calendar' method is reserved for the pick-date action.
812
+
813
+ The required 'selector'/'date' are table-driven (_REQUIRED_FIELDS).
814
+ """
815
+ if self.action != "fill-date":
816
+ return self
817
+ if self.method == "calendar":
818
+ raise ValueError(
819
+ "fill-date method='calendar' is not yet implemented; use the "
820
+ "'type' method (default), or the 'pick-date' action"
821
+ )
822
+ return self
823
+
824
+ @model_validator(mode="after")
825
+ def validate_wait_for_state(self) -> "Step":
826
+ """``wait-for`` only accepts the DOM-presence states. The ``state``
827
+ Literal is broadened to include ``on``/``off`` for ``toggle``, so guard
828
+ that those don't leak onto wait-for."""
829
+ if self.action == "wait-for" and self.state in ("on", "off"):
830
+ raise ValueError(f"wait-for 'state' must be visible|hidden|detached|attached, got: {self.state!r}")
831
+ return self
832
+
833
+ @model_validator(mode="after")
834
+ def validate_navigate_fields(self) -> "Step":
835
+ """``wait:`` (load-state strategy) applies to ``navigate`` and
836
+ ``wait-for``; ``reload:`` (hard refetch) to ``navigate`` alone.
837
+
838
+ ``wait-for`` was widened rather than given a ``load-state:`` field of its
839
+ own: ``wait:`` already carries exactly this vocabulary, and one concept
840
+ with two spellings is worse than a slightly broader field. It covers the
841
+ "settle, then check" idiom that 80 custom blocks spell as a bare
842
+ ``wait_for_load_state`` beside their real wait.
843
+
844
+ ``reload`` reloads the current page, so it cannot combine with
845
+ ``target``."""
846
+ if self.wait is not None and self.action not in ("navigate", "wait-for"):
847
+ raise ValueError(
848
+ f"'wait' (load-state strategy) is only valid on navigate and wait-for (got action='{self.action}')"
849
+ )
850
+ if self.reload is not None and self.action != "navigate":
851
+ raise ValueError(f"'reload' is only valid on navigate (got action='{self.action}')")
852
+ if self.reload and self.target:
853
+ raise ValueError("navigate 'reload: true' reloads the current page; remove 'target'")
854
+ return self
855
+
856
+ @model_validator(mode="after")
857
+ def validate_fill_flags(self) -> "Step":
858
+ """``clear``/``sequential``/``blur-key`` only apply to ``fill``."""
859
+ if self.action == "fill":
860
+ return self
861
+ flags = []
862
+ if self.clear is not None:
863
+ flags.append("clear")
864
+ if self.sequential is not None:
865
+ flags.append("sequential")
866
+ if self.blur_key is not None:
867
+ flags.append("blur-key")
868
+ if flags:
869
+ raise ValueError(
870
+ f"{flags} {'is' if len(flags) == 1 else 'are'} only valid on fill (got action='{self.action}')"
871
+ )
872
+ return self
873
+
874
+ @model_validator(mode="after")
875
+ def validate_synthetic_field(self) -> "Step":
876
+ """``synthetic: true`` (dispatch manual MouseEvents for elements that
877
+ swallow Playwright's native .click()) only applies to ``click``."""
878
+ if self.synthetic is not None and self.action != "click":
879
+ raise ValueError(f"'synthetic' is only valid on click (got action='{self.action}')")
880
+ return self
881
+
882
+ @model_validator(mode="after")
883
+ def validate_toggle_fields(self) -> "Step":
884
+ """toggle drives a switch (``state: on|off``, omit = flip) or a segmented
885
+ button group (``option: <label>``); the two are mutually exclusive.
886
+
887
+ The required 'selector' is table-driven (_REQUIRED_FIELDS).
888
+ """
889
+ if self.action != "toggle":
890
+ return self
891
+ has_state = self.state is not None
892
+ has_option = self.option is not None and str(self.option).strip() != ""
893
+ if has_state and has_option:
894
+ raise ValueError("toggle accepts either 'state' (on|off, switch) or 'option' (label, segmented), not both")
895
+ if has_state and self.state not in ("on", "off"):
896
+ raise ValueError(f"toggle 'state' must be on|off (or omitted to flip), got: {self.state!r}")
897
+ return self
898
+
899
+ @model_validator(mode="after")
900
+ def validate_when_condition(self) -> "Step":
901
+ """``when:`` takes a *condition*, a deliberately smaller vocabulary than
902
+ ``expect:``.
903
+
904
+ The assertion forms auto-wait, which is wrong for a condition: "click it
905
+ if it is there" has to answer from what is rendered now, or every
906
+ guarded step pays the full timeout waiting for something that was never
907
+ coming. Only the four forms that can be answered immediately are
908
+ allowed.
909
+ """
910
+ if self.when is None:
911
+ return self
912
+ spec = str(self.when).strip()
913
+ head = spec.partition("(")[0]
914
+ allowed = ("visible", "hidden", "count", "contains")
915
+ if head not in allowed or not spec.endswith(")"):
916
+ raise ValueError(
917
+ f"unknown 'when' condition {spec!r}. "
918
+ 'Valid: visible(sel) | hidden(sel) | count(sel, >=N) | contains(sel, "text")'
919
+ )
920
+ if not self.role:
921
+ raise ValueError("'when' reads the page, so the step needs a 'role'")
922
+ return self
923
+
924
+ @model_validator(mode="after")
925
+ def validate_call_fields(self) -> "Step":
926
+ """``fn`` must be ``module.function`` — importable, and not a bare name.
927
+
928
+ A bare name would have to be resolved against the test module's globals,
929
+ which is the ``custom``-block behaviour this action exists to replace.
930
+ Requiring the module makes the dependency visible to a reader and to
931
+ ``steppy validate``.
932
+ """
933
+ if self.action != "call":
934
+ return self
935
+ fn = str(self.fn or "").strip()
936
+ if not re.fullmatch(r"[A-Za-z_][\w]*(\.[A-Za-z_][\w]*)+", fn):
937
+ raise ValueError(f"call 'fn' must be a dotted path like 'ui_helpers.ensure_open', got: {self.fn!r}")
938
+ return self
939
+
940
+ @model_validator(mode="after")
941
+ def validate_until_predicate(self) -> "Step":
942
+ """Reject an ``until:`` the runtime helper would not understand.
943
+
944
+ ``_wait_until`` in the generated conftest raises on an unknown
945
+ predicate, but only once the test is running — and only if that step is
946
+ reached. Checking the grammar here fails at ``steppy validate`` instead.
947
+ """
948
+ if self.until is None:
949
+ return self
950
+ spec = str(self.until).strip()
951
+ if spec == "value-not-empty":
952
+ return self
953
+ for prefix in ("value:", "text:"):
954
+ if spec.startswith(prefix) and spec[len(prefix) :]:
955
+ return self
956
+ if spec.startswith("count:"):
957
+ if re.match(r"^(>=|<=|==|!=|>|<)\s*\d+$", spec[6:].strip()):
958
+ return self
959
+ raise ValueError(f"'until' count predicate must be a comparator and an integer, got: {spec[6:]!r}")
960
+ raise ValueError(
961
+ f"unknown 'until' predicate {spec!r}. "
962
+ "Valid: value-not-empty | value:<text> | text:<text> | count:<comparator><n>"
963
+ )
964
+
965
+ @model_validator(mode="after")
966
+ def validate_expand_fields(self) -> "Step":
967
+ """``expand``'s ``state:`` reuses ``toggle``'s ``on``/``off`` spelling.
968
+
969
+ Not ``open``/``closed``: ``toggle`` already established ``on``/``off``
970
+ for exactly this idempotent-set semantics, and one concept should not
971
+ have two spellings. Omitting ``state`` keeps today's open-only
972
+ behaviour, so existing scenarios regenerate byte-identically.
973
+ """
974
+ if self.action != "expand":
975
+ return self
976
+ if self.state is not None and self.state not in ("on", "off"):
977
+ raise ValueError(f"expand 'state' must be on|off (or omitted to open), got: {self.state!r}")
978
+ return self
979
+
980
+ @model_validator(mode="after")
981
+ def validate_pick_time_fields(self) -> "Step":
982
+ """pick-time's 'time' must be 24h ``HH:MM``.
983
+
984
+ The required 'selector'/'time' are table-driven (_REQUIRED_FIELDS);
985
+ ``pick-date`` and ``expand`` need nothing beyond the table at all.
986
+ """
987
+ if self.action != "pick-time":
988
+ return self
989
+ if not re.match(r"^\d{1,2}:\d{2}$", str(self.time).strip()):
990
+ raise ValueError(f"pick-time 'time' must be \"HH:MM\", got: {self.time!r}")
991
+ return self
992
+
993
+ @field_validator("timeout")
994
+ @classmethod
995
+ def validate_timeout(cls, v):
996
+ """Reject a timeout the generator could not turn into milliseconds.
997
+
998
+ Validated here rather than at codegen so a typo fails at
999
+ ``steppy validate``, and parsed by the same helper codegen uses so the
1000
+ two cannot disagree.
1001
+ """
1002
+ if v is None:
1003
+ return v
1004
+ try:
1005
+ parse_ms(v)
1006
+ except DurationSyntaxError as exc:
1007
+ raise ValueError(f"'timeout': {exc}") from exc
1008
+ return v
1009
+
1010
+ @model_validator(mode="after")
1011
+ def validate_allowed_fields(self) -> "Step":
1012
+ """Reject a field that belongs to a *different* action.
1013
+
1014
+ ``extra = "forbid"`` catches a key no action has, never a key some other
1015
+ action owns. See ``_ALLOWED_FIELDS`` for the measurement that motivated
1016
+ this. Only explicitly-set keys are checked (``model_fields_set``), so a
1017
+ default never trips it and existing scenarios are unaffected.
1018
+ """
1019
+ allowed = _ALLOWED_FIELDS.get(self.action)
1020
+ if allowed is None: # pragma: no cover - validate_action ran first
1021
+ return self
1022
+ # Explicitly-passed ``None`` is not "set" for this purpose: it generates
1023
+ # nothing either way, and the parser only ever hands us keys the author
1024
+ # actually typed. Being strict about it would only punish programmatic
1025
+ # construction (tests, codemods) for spelling out the full signature.
1026
+ stray = sorted(
1027
+ f for f in self.model_fields_set - allowed - _UNIVERSAL_FIELDS if getattr(self, f, None) is not None
1028
+ )
1029
+ if stray:
1030
+ names = ", ".join(repr(_FIELD_ALIASES.get(f, f)) for f in stray)
1031
+ plural = "s" if len(stray) > 1 else ""
1032
+ raise ValueError(
1033
+ f"{self.action} action does not accept field{plural} {names}. "
1034
+ f"Accepted here: {', '.join(sorted(_FIELD_ALIASES.get(f, f) for f in allowed)) or '(none)'}"
1035
+ )
1036
+ return self
1037
+
1038
+ @model_validator(mode="after")
1039
+ def validate_expect_grammar(self) -> "Step":
1040
+ """Reject an ``expect:`` the assertion grammar does not recognise.
1041
+
1042
+ Without this, an unknown form reached ``assert_body``'s ``{% else %}``
1043
+ and generated ``pass`` — a green test asserting nothing, with
1044
+ ``steppy validate`` silent. The grammar itself lives in
1045
+ ``steppy.assertions`` so codegen and validation cannot disagree.
1046
+ """
1047
+ if self.expect is None:
1048
+ return self
1049
+ for check in expect_checks(self.expect):
1050
+ try:
1051
+ parse_assertion(check)
1052
+ except AssertionSyntaxError as exc:
1053
+ raise ValueError(str(exc)) from exc
1054
+ return self
1055
+
1056
+ @field_validator("screenshot")
1057
+ @classmethod
1058
+ def validate_screenshot(cls, v: str | bool | None) -> str | bool | None:
1059
+ """Normalize screenshot field. Role membership is checked post-parse
1060
+ against config.apps (see parser.validate_scenario_roles)."""
1061
+ if v is None or isinstance(v, bool):
1062
+ return v
1063
+ if isinstance(v, str):
1064
+ if v.lower() == "false":
1065
+ return False
1066
+ if v.lower() == "true":
1067
+ return True
1068
+ if not v.strip():
1069
+ raise ValueError("screenshot role must be a non-empty string")
1070
+ return v
1071
+
1072
+
1073
+ # ── Seed Config ─────────────────────────────────────────────────────────────
1074
+
1075
+
1076
+ class SeedConfig(BaseModel):
1077
+ """Seed script configuration."""
1078
+
1079
+ script: str
1080
+ run: Literal["before", "after", "both"] = "before"
1081
+ timeout: int = Field(default=120, ge=1) # seconds; kill a hung seed script
1082
+
1083
+ class Config:
1084
+ extra = "forbid"
1085
+
1086
+
1087
+ # ── Scenario Model ──────────────────────────────────────────────────────────
1088
+
1089
+
1090
+ class ScenarioTour(BaseModel):
1091
+ """Frontmatter ``tour:`` block: opts the scenario into the in-app tours output.
1092
+
1093
+ ``tour: true`` / ``tour: false`` are shorthands for ``{enabled: ...}``; a
1094
+ block without ``enabled`` opts in. Everything else is optional:
1095
+
1096
+ * ``id`` — the tour id apps call ``start()`` with (default: the file slug);
1097
+ * ``title`` — a string, or one per locale (default: the scenario title);
1098
+ * ``entry`` — the route pattern the tour starts on, e.g.
1099
+ ``/invoices/:invoiceId`` (default: inferred from the scenario's first
1100
+ navigation after login);
1101
+ * ``role`` — whose app the tour runs in, required when steps use several roles;
1102
+ * ``guide_slug`` — the guide page ``Open the full guide`` links to (default:
1103
+ the file slug, when the scenario is ``guide.enabled``).
1104
+
1105
+ Route grammar beyond the cheap shape checks below is applied by the tours
1106
+ compiler, which also knows the app URL the entry is relative to.
1107
+ """
1108
+
1109
+ enabled: bool = True
1110
+ id: Optional[str] = None
1111
+ title: Optional[Union[str, dict[str, str]]] = None
1112
+ entry: Optional[str] = None
1113
+ role: Optional[str] = None
1114
+ guide_slug: Optional[str] = None
1115
+
1116
+ class Config:
1117
+ extra = "forbid"
1118
+
1119
+ @field_validator("id")
1120
+ @classmethod
1121
+ def validate_id(cls, v):
1122
+ if v is not None and not is_tour_id(v):
1123
+ raise ValueError(
1124
+ f"tour id {v!r} must be 1-64 characters [A-Za-z0-9_-], start with a letter or digit, "
1125
+ "and not be a Windows device name"
1126
+ )
1127
+ return v
1128
+
1129
+ @field_validator("title")
1130
+ @classmethod
1131
+ def validate_title(cls, v):
1132
+ if v is None:
1133
+ return v
1134
+ if isinstance(v, str):
1135
+ if not v.strip():
1136
+ raise ValueError("tour title must not be empty")
1137
+ return v
1138
+ if not v:
1139
+ raise ValueError("tour title map must not be empty")
1140
+ for locale, text in v.items():
1141
+ validate_locale_tag(locale)
1142
+ if not isinstance(text, str) or not text.strip():
1143
+ raise ValueError(f"tour title for {locale!r} must be a non-empty string")
1144
+ return v
1145
+
1146
+ @field_validator("entry")
1147
+ @classmethod
1148
+ def validate_entry(cls, v):
1149
+ if v is None:
1150
+ return v
1151
+ v = v.strip()
1152
+ bad = v.startswith("//") or v.startswith("/\\") or any(c in v for c in "#? \t\n\\")
1153
+ if not v.startswith("/") or bad:
1154
+ raise ValueError(
1155
+ f"tour entry {v!r} must be a same-origin path pattern such as /invoices/:invoiceId "
1156
+ "(no scheme, host, query or fragment)"
1157
+ )
1158
+ return v
1159
+
1160
+ @field_validator("guide_slug")
1161
+ @classmethod
1162
+ def validate_guide_slug(cls, v):
1163
+ if v is not None and not re.fullmatch(r"[A-Za-z0-9_-]{1,128}", v):
1164
+ raise ValueError(f"guide_slug {v!r} must be 1-128 characters [A-Za-z0-9_-]")
1165
+ return v
1166
+
1167
+ @field_validator("role")
1168
+ @classmethod
1169
+ def validate_role(cls, v):
1170
+ if v is not None and not v.strip():
1171
+ raise ValueError("tour role must not be empty")
1172
+ return v
1173
+
1174
+
1175
+ class Scenario(BaseModel):
1176
+ """A scenario with metadata and steps."""
1177
+
1178
+ # Frontmatter metadata
1179
+ steppy: int = 1 # Scenario-format version (FLAWS §6.4); absent ⇒ 1.
1180
+ title: str
1181
+ description: Optional[str] = None
1182
+ tags: list[str] = Field(default_factory=list)
1183
+ seed: Optional[SeedConfig] = None
1184
+ priority: Optional[Literal["critical", "high", "medium", "low"]] = None
1185
+ auth: bool = True # Set to false for scenarios that test the login flow itself
1186
+ timeout: Optional[int] = None # milliseconds
1187
+
1188
+ # Layouts this scenario declares it must pass in (scenario-level). When set,
1189
+ # the whole flow parametrizes over these viewports — authoritative on a plain
1190
+ # ``steppy run``, filtered by ``--layouts``/``--guide``. Absent ⇒ no
1191
+ # scenario-level layout dimension (byte-identical default behaviour).
1192
+ layouts: Optional[list[str]] = None
1193
+
1194
+ # Exclusion group. Two scenarios naming the same group never run at the same
1195
+ # time under ``steppy run --workers N``; different groups still run
1196
+ # concurrently. Absent ⇒ no constraint (byte-identical default behaviour), and
1197
+ # inert on the single-worker path, which runs every file sequentially in one
1198
+ # pytest process anyway. Promises non-overlap only — not ordering, and not
1199
+ # indivisibility (a retry pass legitimately re-runs a subset of a group).
1200
+ exclusive: Optional[str] = None
1201
+
1202
+ # Guide rendering configuration (cursor timings, etc.)
1203
+ guide: GuideConfig = Field(default_factory=GuideConfig)
1204
+
1205
+ # In-app tour opt-in (``tour: true`` or a ``tour:`` block). Absent ⇒ the
1206
+ # project default ``tours.default``; inert unless ``tours.enabled``.
1207
+ tour: Optional[ScenarioTour] = None
1208
+
1209
+ # Steps parsed from the body
1210
+ steps: list[Step] = Field(default_factory=list)
1211
+
1212
+ # File metadata (set by parser)
1213
+ file_path: Optional[Path] = None
1214
+ slug: Optional[str] = None # e.g., "cross--order-lifecycle"
1215
+
1216
+ class Config:
1217
+ extra = "forbid"
1218
+
1219
+ @field_validator("steppy")
1220
+ @classmethod
1221
+ def validate_version(cls, v: int) -> int:
1222
+ """Reject scenario-format versions this Steppy can't handle (FLAWS §6.4).
1223
+
1224
+ A too-new file surfaces as a clear, actionable error at parse time (for
1225
+ `validate`, `generate`, and `run` alike) instead of a baffling
1226
+ `extra="forbid"`/typo-shaped failure downstream.
1227
+ """
1228
+ if v < 1:
1229
+ raise ValueError(f"steppy version must be >= 1 (got {v})")
1230
+ if v > SUPPORTED_SCENARIO_VERSION:
1231
+ raise ValueError(
1232
+ f"scenario declares steppy: {v} but this Steppy supports up to "
1233
+ f"version {SUPPORTED_SCENARIO_VERSION}; upgrade Steppy to run it"
1234
+ )
1235
+ return v
1236
+
1237
+ @field_validator("layouts")
1238
+ @classmethod
1239
+ def validate_layouts(cls, v):
1240
+ """Reject empty lists and empty layout names. Membership against
1241
+ ``config.screenshot.layouts`` is checked post-parse in
1242
+ ``parser.validate_scenario_layouts``. Mirrors ``Step.validate_layouts``."""
1243
+ if v is None:
1244
+ return v
1245
+ if not v:
1246
+ raise ValueError("'layouts' must be a non-empty list (or omitted)")
1247
+ for item in v:
1248
+ if not isinstance(item, str) or not item.strip():
1249
+ raise ValueError(f"'layouts' entries must be non-empty strings, got: {item!r}")
1250
+ return v
1251
+
1252
+ @field_validator("exclusive", mode="before")
1253
+ @classmethod
1254
+ def normalize_exclusive(cls, v):
1255
+ """Validate the exclusion group name (``steppy.exclusive`` owns the rule).
1256
+
1257
+ ``mode="before"`` so a list value is rejected with the closure-rule message
1258
+ rather than by pydantic's generic str coercion error — the advice an author
1259
+ reaches for when they need two resources is unsound, so the message has to
1260
+ say what is actually safe.
1261
+ """
1262
+ if v is None:
1263
+ return None
1264
+ return normalize_group_name(v)
1265
+
1266
+ @field_validator("tour", mode="before")
1267
+ @classmethod
1268
+ def normalize_tour(cls, v):
1269
+ """Accept ``tour: true|false`` and the ``tour: {...}`` block (the ``seed`` pattern)."""
1270
+ if v is None or isinstance(v, (ScenarioTour, dict)):
1271
+ return v
1272
+ if isinstance(v, bool):
1273
+ return {"enabled": v}
1274
+ raise ValueError("'tour' must be true, false, or a block {enabled, id, title, entry, role, guide_slug}")
1275
+
1276
+ def tour_opted_in(self, project_default: bool) -> bool:
1277
+ """Whether this scenario produces an in-app tour, given ``tours.default``."""
1278
+ if self.tour is None:
1279
+ return project_default
1280
+ return self.tour.enabled
1281
+
1282
+ @field_validator("seed", mode="before")
1283
+ @classmethod
1284
+ def normalize_seed(cls, v):
1285
+ """Accept both string ('base') and dict ({script: base, run: both}) forms."""
1286
+ if v is None:
1287
+ return None
1288
+ if isinstance(v, str):
1289
+ return SeedConfig(script=v, run="before")
1290
+ if isinstance(v, dict):
1291
+ return SeedConfig(**v)
1292
+ return v
1293
+
1294
+ def get_roles(self) -> set[str]:
1295
+ """Get all unique roles used in this scenario."""
1296
+ roles = set()
1297
+ for step in self.steps:
1298
+ if step.role:
1299
+ roles.add(step.role)
1300
+ return roles
1301
+
1302
+ def has_tag(self, tag: str) -> bool:
1303
+ """Check if scenario has a specific tag."""
1304
+ return tag in self.tags
1305
+
1306
+ def has_all_tags(self, tags: list[str]) -> bool:
1307
+ """Check if scenario has ALL specified tags."""
1308
+ return all(tag in self.tags for tag in tags)
1309
+
1310
+
1311
+ # ── Config Models ───────────────────────────────────────────────────────────
1312
+
1313
+
1314
+ class _PresetModel(BaseModel):
1315
+ """Base for selector-config blocks that support a named ``preset:`` (FLAWS
1316
+ §4.3, §6.2).
1317
+
1318
+ A block may name a ``preset:`` to adopt a whole kit's selectors at once. The
1319
+ after-validator backfills every selector field the user did NOT set
1320
+ explicitly from ``_preset_registry[preset][_preset_key]``. Explicitly set
1321
+ fields win; omitting ``preset`` leaves the class defaults untouched — so
1322
+ existing configs are byte-identical. The UI-kit blocks read ``PRESETS``; the
1323
+ auth form block overrides ``_preset_registry`` with ``IDP_PRESETS``."""
1324
+
1325
+ # Which key in the registry holds this model's selectors. Set by each subclass.
1326
+ _preset_key: ClassVar[str] = ""
1327
+ # Which preset registry to read. Defaults to the UI-kit registry; the auth
1328
+ # form block overrides it with the IdP registry (FLAWS §6.2). Kept a ClassVar
1329
+ # so it isn't treated as a pydantic field.
1330
+ _preset_registry: ClassVar[dict] = PRESETS
1331
+
1332
+ preset: Optional[str] = None
1333
+
1334
+ class Config:
1335
+ extra = "forbid"
1336
+
1337
+ @model_validator(mode="after")
1338
+ def _apply_preset(self):
1339
+ if self.preset is None:
1340
+ return self
1341
+ try:
1342
+ kit = self._preset_registry[self.preset]
1343
+ except KeyError:
1344
+ raise ValueError(f"Unknown preset {self.preset!r}; available: {sorted(self._preset_registry)}")
1345
+ selectors = kit.get(self._preset_key, {})
1346
+ for name, value in selectors.items():
1347
+ # Only fill fields the user left at their default; explicit wins.
1348
+ if name not in self.model_fields_set:
1349
+ setattr(self, name, value)
1350
+ return self
1351
+
1352
+
1353
+ class FormAuthConfig(_PresetModel):
1354
+ """Selectors for the generic hosted-login form-fill (auth ``mode: form``,
1355
+ formerly ``mode: auth0``).
1356
+
1357
+ Works against any IdP with a fillable login form (Auth0, Keycloak, …) by
1358
+ overriding the three selectors, or by naming an IdP ``preset:`` (``auth0`` |
1359
+ ``keycloak``) that fills them at once (FLAWS §6.2). Defaults mirror the
1360
+ historical Auth0 selectors so existing configs are unchanged."""
1361
+
1362
+ _preset_key: ClassVar[str] = "form"
1363
+ _preset_registry: ClassVar[dict] = IDP_PRESETS
1364
+
1365
+ username_selector: str = "input[name='username']"
1366
+ password_selector: str = "input[name='password']"
1367
+ submit_selector: str = "button[data-action-button-primary='true']"
1368
+ post_login_url_pattern: Optional[str] = None
1369
+ consent_selector: Optional[str] = None
1370
+ # TOTP/MFA (FLAWS §6.2). When ``totp_selector`` is set and the app's
1371
+ # credentials carry a ``totp_secret``, the login flow fills a time-based
1372
+ # one-time code after the password submit. ``totp_submit_selector`` defaults
1373
+ # to ``submit_selector`` when omitted. Both may be filled by an IdP preset.
1374
+ totp_selector: Optional[str] = None
1375
+ totp_submit_selector: Optional[str] = None
1376
+
1377
+
1378
+ # Back-compat alias: the mode/selectors were historically named ``auth0``.
1379
+ Auth0Config = FormAuthConfig
1380
+
1381
+
1382
+ class AuthCredentials(BaseModel):
1383
+ """Authentication credentials."""
1384
+
1385
+ username: str
1386
+ password: str
1387
+ # Optional TOTP/MFA shared secret (base32) for automated multi-factor login
1388
+ # (FLAWS §6.2). Best supplied as an env ref (``${STEPPY_<ROLE>_TOTP}``) so it
1389
+ # is never committed in plaintext. Only used when ``auth.form.totp_selector``
1390
+ # is also set.
1391
+ totp_secret: Optional[str] = None
1392
+
1393
+
1394
+ class BypassConfig(BaseModel):
1395
+ """Bypass authentication configuration."""
1396
+
1397
+ header: Optional[dict[str, str]] = None
1398
+ cookie: Optional[dict[str, Any]] = None
1399
+ local_storage: Optional[dict[str, str]] = None
1400
+
1401
+
1402
+ class TokenConfig(BaseModel):
1403
+ """Token-based authentication configuration."""
1404
+
1405
+ endpoint: str
1406
+ method: str = "POST"
1407
+ body: Optional[dict[str, Any]] = None
1408
+ inject_as: Literal["localStorage", "cookie", "header"]
1409
+ key: str
1410
+
1411
+
1412
+ class AppAuthConfig(BaseModel):
1413
+ """Authentication configuration for a single app."""
1414
+
1415
+ # ``form`` is the generic hosted-login form-fill (any IdP). ``auth0`` is a
1416
+ # kept-forever deprecated alias, normalized to ``form`` below (FLAWS §6.2).
1417
+ mode: Literal["form", "auth0", "storage-state", "bypass", "token"]
1418
+ credentials: Optional[AuthCredentials] = None
1419
+ storage_state_path: Optional[str] = None
1420
+ bypass: Optional[BypassConfig] = None
1421
+ token: Optional[TokenConfig] = None
1422
+
1423
+ @field_validator("mode", mode="before")
1424
+ @classmethod
1425
+ def _normalize_mode(cls, v):
1426
+ """Map the legacy ``auth0`` mode name onto ``form``."""
1427
+ return "form" if v == "auth0" else v
1428
+
1429
+
1430
+ class LocalStorageI18nConfig(BaseModel):
1431
+ """Per-locale localStorage values used to switch the app's UI language.
1432
+
1433
+ The same `key` is written for every locale; only the value changes.
1434
+ """
1435
+
1436
+ key: str
1437
+ values: dict[str, str]
1438
+
1439
+ class Config:
1440
+ extra = "forbid"
1441
+
1442
+ @field_validator("values")
1443
+ @classmethod
1444
+ def validate_values_non_empty(cls, v: dict[str, str]) -> dict[str, str]:
1445
+ if not v:
1446
+ raise ValueError("i18n.localstorage.values must declare at least one locale")
1447
+ return v
1448
+
1449
+
1450
+ class AppI18nConfig(BaseModel):
1451
+ """Per-app i18n hooks. Currently exposes a localStorage strategy."""
1452
+
1453
+ localstorage: LocalStorageI18nConfig
1454
+
1455
+ class Config:
1456
+ extra = "forbid"
1457
+
1458
+
1459
+ class AppConfig(BaseModel):
1460
+ """Configuration for a single app/role."""
1461
+
1462
+ name: str
1463
+ url: str
1464
+ # In-network URL used by parallelism workers running inside Docker. When
1465
+ # the coordinator dispatches workers (`steppy run --workers >= 2`), it
1466
+ # passes a {role: internal_url} JSON map via STEPPY_BASE_URL_OVERRIDE so the
1467
+ # generated conftest can target the app via Docker DNS instead of the
1468
+ # host-facing `url`. Optional in single-worker mode; required at
1469
+ # coordinator startup for any role used by selected scenarios.
1470
+ internal_url: Optional[str] = None
1471
+ auth: AppAuthConfig
1472
+ i18n: Optional[AppI18nConfig] = None
1473
+ # Playwright BrowserContext locale (BCP-47, e.g. "fr-FR"). When set, every
1474
+ # generated test runs against a context with this locale → navigator.language,
1475
+ # Accept-Language, and Intl.* defaults are pinned. Makes date / number
1476
+ # formatting deterministic across single-worker (host OS locale) and parallel
1477
+ # (Linux container locale) runs. Optional; when None, Playwright uses the
1478
+ # browser default. ``--guide`` runs that pass ``STEPPY_LOCALES`` still override
1479
+ # this per parametrization so internationalized captures keep working.
1480
+ locale: Optional[str] = None
1481
+ # Playwright BrowserContext timezone_id (IANA tz, e.g. "Europe/Brussels").
1482
+ # Pins the browser's reported timezone, making time-of-day-sensitive UI
1483
+ # deterministic across machines / containers regardless of host TZ.
1484
+ timezone: Optional[str] = None
1485
+
1486
+
1487
+ class GlobalAuthConfig(BaseModel):
1488
+ """Global authentication settings."""
1489
+
1490
+ storage_state_dir: str = "./.auth"
1491
+ storage_state_ttl: int = 3600 # seconds
1492
+ # Login-form selectors for auth ``mode: form``. The legacy key ``auth0`` is
1493
+ # accepted and copied onto ``form`` by the validator below (FLAWS §6.2).
1494
+ form: Optional[FormAuthConfig] = None
1495
+
1496
+ @model_validator(mode="before")
1497
+ @classmethod
1498
+ def _accept_legacy_auth0_key(cls, data):
1499
+ """Copy a legacy ``auth0:`` block onto ``form`` when ``form`` is absent."""
1500
+ if isinstance(data, dict) and "auth0" in data and data.get("form") is None:
1501
+ data = dict(data)
1502
+ data["form"] = data.pop("auth0")
1503
+ return data
1504
+
1505
+
1506
+ class ViewportConfig(BaseModel):
1507
+ """Viewport configuration for screenshots."""
1508
+
1509
+ width: int = 1280
1510
+ height: int = 720
1511
+
1512
+
1513
+ class LayoutConfig(BaseModel):
1514
+ """A named viewport for guide capture. When ``screenshot.layouts`` is
1515
+ populated, ``steppy run --guide`` parametrizes captures over each layout
1516
+ and screenshots land under ``output/screenshots/<scenario>/[<locale>/]<layout>/``."""
1517
+
1518
+ width: int
1519
+ height: int
1520
+ # Capture-time devicePixelRatio for this layout. >1 yields a denser PNG
1521
+ # (e.g. 3 on a narrow mobile viewport) so the screenshot stays crisp when
1522
+ # the guide displays it at the content-column width. Default 1 = Playwright's
1523
+ # default → byte-identical output for projects that don't set it. This does
1524
+ # NOT set is_mobile/has_touch, so the captured page layout is unchanged —
1525
+ # only the pixel density increases.
1526
+ device_scale_factor: int = Field(default=1, ge=1)
1527
+
1528
+ class Config:
1529
+ extra = "forbid"
1530
+
1531
+
1532
+ class ScreenshotConfig(BaseModel):
1533
+ """Screenshot settings."""
1534
+
1535
+ viewport: ViewportConfig = Field(default_factory=ViewportConfig)
1536
+ format: Literal["png", "jpeg"] = "png"
1537
+ full_page: bool = False
1538
+ highlight: bool = True
1539
+ highlight_color: str = "rgba(255, 0, 0, 0.3)"
1540
+ highlight_border: str = "2px solid red"
1541
+ # Default capture-time devicePixelRatio for the no-layout viewport, and the
1542
+ # fallback for any layout that omits its own ``device_scale_factor``. Default
1543
+ # 1 keeps existing output byte-identical. See ``LayoutConfig`` above.
1544
+ device_scale_factor: int = Field(default=1, ge=1)
1545
+ # Optional named layouts for per-layout guide capture. Empty = single
1546
+ # viewport (the existing behavior). Populated = ``--guide`` parametrizes
1547
+ # over these layouts in addition to locales.
1548
+ layouts: dict[str, LayoutConfig] = Field(default_factory=dict)
1549
+ # Redaction: CSS selectors whose matched elements are covered with a solid
1550
+ # box in every captured screenshot (Playwright's native ``mask=``). Use it
1551
+ # to keep secrets and personal data (passwords, customer records) out of published guides.
1552
+ # Empty (default) = no masking, byte-identical output. ``mask_color`` is any
1553
+ # CSS color string for the overlay boxes.
1554
+ mask: list[str] = Field(default_factory=list)
1555
+ mask_color: str = "#000000"
1556
+
1557
+
1558
+ class TimingConfig(BaseModel):
1559
+ """Timing defaults."""
1560
+
1561
+ navigation_wait: int = 2000 # milliseconds
1562
+ action_wait: int = 500
1563
+ sqs_wait: int = 5000
1564
+ step_timeout: int = 30000
1565
+ # Default load-state strategy awaited by the ``navigate`` action after goto.
1566
+ # Per-step ``wait:`` overrides this. "none" skips the wait entirely (useful
1567
+ # for apps with long-lived SSE streams where networkidle never settles).
1568
+ navigate_wait: Literal["networkidle", "load", "domcontentloaded", "none"] = "networkidle"
1569
+
1570
+
1571
+ class OutputConfig(BaseModel):
1572
+ """Where run artifacts are written and read back (FLAWS §4.6).
1573
+
1574
+ Both live keys are resolved against the project root, so a relative value
1575
+ lands under it and an absolute one wins outright. ``screenshots_dir`` is
1576
+ read by the *writer* (the generated conftest, at runtime — editing it takes
1577
+ effect without re-running ``steppy generate``) and by every reader:
1578
+ ``steppy guide``'s ``--screenshots-dir`` default, ``steppy build``'s
1579
+ fallback when the TypeScript config omits ``screenshotsDir``, the parallel
1580
+ coordinator's failure harvest, and ``steppy bundle create``.
1581
+ ``guides_dir`` is ``steppy guide``'s ``--output-dir`` default and the
1582
+ bundle's guides root. ``tours_dir`` holds the canonical in-app tour tree
1583
+ (``<tours_dir>/<build-id>/``) that ``steppy generate`` / ``steppy tours``
1584
+ write and the static ``steppy build`` republishes.
1585
+
1586
+ A value outside ``output/`` is allowed and honoured, but note what only
1587
+ archives ``output/``: results bundles, the shipped CI artifact upload, the
1588
+ scaffolded ``.gitignore``, and the ``--workers N`` bind mount (where a
1589
+ capture written elsewhere cannot leave the container at all). Each of those
1590
+ warns rather than failing silently.
1591
+
1592
+ ``reports_dir`` is **deprecated and ignored**. Nothing ever wrote
1593
+ ``output/reports``: Playwright traces and videos are top-level
1594
+ ``output/traces`` / ``output/videos``, and ``report.junit_xml`` /
1595
+ ``report.html`` are explicit paths of their own. It is still accepted so an
1596
+ existing ``config.yaml`` keeps loading; ``steppy validate`` warns and
1597
+ ``steppy update --check`` reports it.
1598
+ """
1599
+
1600
+ screenshots_dir: str = "./output/screenshots"
1601
+ guides_dir: str = "./output/guides"
1602
+ tours_dir: str = "./output/tours"
1603
+ reports_dir: Optional[str] = None # deprecated, inert — see the docstring
1604
+
1605
+ class Config:
1606
+ extra = "forbid"
1607
+
1608
+
1609
+ class DropdownConfig(_PresetModel):
1610
+ """Selectors for the component dropdown driven by the ``select-option``
1611
+ action. Defaults target PrimeNG ``p-select``; set ``preset:`` (e.g.
1612
+ ``material``) or override individual selectors in ``config.yaml`` for other
1613
+ UI kits. ``select-option`` works out-of-the-box for PrimeNG apps with no
1614
+ config."""
1615
+
1616
+ _preset_key: ClassVar[str] = "dropdown"
1617
+ trigger: str = "p-select"
1618
+ overlay: str = ".p-select-overlay"
1619
+ option: str = ".p-select-option, .p-select-list-container li"
1620
+ filter: str = ".p-select-filter-container input, .p-select-filter"
1621
+ # How to wait after typing into the filter box before picking an option.
1622
+ # 'api' -> wait_for_load_state('networkidle') (option list fetched remotely)
1623
+ # 'none' -> no extra wait (purely client-side filtering)
1624
+ settle: Literal["api", "none"] = "api"
1625
+
1626
+ class Config:
1627
+ extra = "forbid"
1628
+
1629
+
1630
+ class DialogConfig(_PresetModel):
1631
+ """Selectors for the confirmation dialog driven by the ``confirm`` action.
1632
+ Defaults target PrimeNG ``p-confirmDialog`` (whose accept/reject buttons
1633
+ have no ``data-testid``); set ``preset:`` or override this block in
1634
+ ``config.yaml`` for other UI kits. ``confirm`` works out-of-the-box for
1635
+ PrimeNG apps with no config."""
1636
+
1637
+ _preset_key: ClassVar[str] = "dialog"
1638
+ overlay: str = ".p-confirmdialog, p-confirmdialog"
1639
+ accept: str = "button.p-confirmdialog-accept-button, button.p-confirm-dialog-accept"
1640
+ reject: str = "button.p-confirmdialog-reject-button, button.p-confirm-dialog-reject"
1641
+ danger: str = "button.p-button-danger, button.p-confirmdialog-accept-button"
1642
+
1643
+ class Config:
1644
+ extra = "forbid"
1645
+
1646
+
1647
+ class ToggleConfig(_PresetModel):
1648
+ """Selectors for the ``toggle`` action. Defaults target PrimeNG: a
1649
+ ``ToggleSwitch`` hides a real ``input[role="switch"]`` behind styling, and
1650
+ segmented groups are ``p-togglebutton`` items. Set ``preset:`` or override
1651
+ for other UI kits."""
1652
+
1653
+ _preset_key: ClassVar[str] = "toggle"
1654
+ switch: str = 'input[role="switch"]'
1655
+ segment: str = ".p-togglebutton"
1656
+
1657
+ class Config:
1658
+ extra = "forbid"
1659
+
1660
+
1661
+ class DatepickerConfig(_PresetModel):
1662
+ """Selectors for the ``pick-date`` / ``pick-time`` actions' calendar and
1663
+ clock widgets. Defaults target PrimeNG ``p-datepicker``; set ``preset:`` or
1664
+ override for other UI kits. Works out-of-the-box for PrimeNG apps with no
1665
+ config."""
1666
+
1667
+ _preset_key: ClassVar[str] = "datepicker"
1668
+ panel: str = ".p-datepicker-panel"
1669
+ next_button: str = ".p-datepicker-next-button"
1670
+ prev_button: str = ".p-datepicker-prev-button"
1671
+ day_cell: str = ".p-datepicker-day-cell:not(.p-datepicker-other-month)"
1672
+ # Optional open trigger for showIcon pickers (calendar/clock button). The
1673
+ # helper clicks it when present, else the inner <input>, else the host — so
1674
+ # both icon and no-icon pickers open with no extra config.
1675
+ trigger: str = "button.p-datepicker-dropdown, button.p-datepicker-trigger"
1676
+ # Time picker: PrimeNG renders two columns, each an up button, a value
1677
+ # <span>, and a down button. The helper scopes by column ({col} button
1678
+ # first/last + {col} > span), independent of the increment/decrement classes.
1679
+ hour_picker: str = ".p-datepicker-hour-picker"
1680
+ minute_picker: str = ".p-datepicker-minute-picker"
1681
+
1682
+ class Config:
1683
+ extra = "forbid"
1684
+
1685
+
1686
+ class FieldsetConfig(_PresetModel):
1687
+ """Selectors for the ``expand`` action (collapsible section). Defaults target
1688
+ PrimeNG ``p-fieldset``: ``toggle`` is the clickable legend/toggle button and
1689
+ ``content`` is the toggleable body (used to detect whether the section is
1690
+ already open, so ``expand`` is idempotent). Set ``preset:`` or override for
1691
+ other UI kits."""
1692
+
1693
+ _preset_key: ClassVar[str] = "fieldset"
1694
+ toggle: str = ".p-fieldset-toggle-button, .p-fieldset-legend"
1695
+ content: str = ".p-toggleable-content, .p-fieldset-content-container"
1696
+
1697
+ class Config:
1698
+ extra = "forbid"
1699
+
1700
+
1701
+ class PreAuthConfig(BaseModel):
1702
+ """Pre-authentication hook for ``steppy run --workers N`` (parallel mode).
1703
+
1704
+ When set, the coordinator injects a one-off ``steppy-preauth`` service into
1705
+ each worker's stack and runs ``command`` INSIDE the stack network — gated
1706
+ via Compose ``service_completed_successfully`` so it finishes before any
1707
+ ``steppy-worker`` claims a scenario. Its job is to populate the shared
1708
+ ``.auth`` cache with a session captured at the in-network origin
1709
+ (``apps.<role>.internal_url``); a host-captured ``storage-state`` file
1710
+ cannot serve that origin because Playwright storage state is origin-bound.
1711
+ De-duplicated once-per-run across the N per-worker stacks via an O_EXCL
1712
+ lock in the shared coord dir. Unset ⇒ the feature is entirely inert (the
1713
+ rendered override + dispatch are byte-identical to today)."""
1714
+
1715
+ # Command run inside the preauth container (cwd = /steppy/project), e.g.
1716
+ # ``python auth_login.py --internal``. A plain string is executed via
1717
+ # ``sh -c``; an argv list is executed directly.
1718
+ command: str | list[str]
1719
+ # Seconds before the preauth step is abandoned (covers logging every role);
1720
+ # also bounds how long a lock-loser waits for the winner to finish.
1721
+ timeout: int = Field(default=600, ge=1)
1722
+ # Image for the preauth container. Defaults to the resolved worker image
1723
+ # (already carries playwright + chromium + the steppy package).
1724
+ image: Optional[str] = None
1725
+
1726
+ class Config:
1727
+ extra = "forbid"
1728
+
1729
+ @field_validator("command")
1730
+ @classmethod
1731
+ def _command_non_empty(cls, v):
1732
+ if isinstance(v, str) and not v.strip():
1733
+ raise ValueError("parallel.pre_auth.command must not be empty")
1734
+ if isinstance(v, list) and not [p for p in v if str(p).strip()]:
1735
+ raise ValueError("parallel.pre_auth.command list must not be empty")
1736
+ return v
1737
+
1738
+
1739
+ class RetryConfig(BaseModel):
1740
+ """Scenario-level retry pass for ``steppy run --workers N`` (N >= 2).
1741
+
1742
+ Distinct from ``report.retries`` (pytest-rerunfailures), which reruns a
1743
+ failed test **in-process, immediately** — same worker, same moment, same
1744
+ contention. At scale the dominant flake source is cross-worker contention,
1745
+ and retrying into it proves nothing. This pass instead waits for the whole
1746
+ queue to drain, then re-runs only the failed scenarios at **reduced
1747
+ concurrency** in the still-running stacks.
1748
+
1749
+ That difference is also what makes the outcome meaningful: a scenario that
1750
+ fails under 8 workers and passes alone is FLAKY (contention), not broken.
1751
+
1752
+ ``passes: 0`` disables the feature entirely.
1753
+ """
1754
+
1755
+ # How many extra passes to run over the failures. 1 is almost always right;
1756
+ # >1 mostly buys noise. 0 = off.
1757
+ passes: int = Field(default=1, ge=0, le=3)
1758
+ # Workers for the retry pass. 1 (the default) means the retries run serially
1759
+ # with zero cross-worker contention, which is the whole point.
1760
+ workers: int = Field(default=1, ge=1)
1761
+
1762
+ class Config:
1763
+ extra = "forbid"
1764
+
1765
+
1766
+ class CircuitBreakerConfig(BaseModel):
1767
+ """Abort a parallel run early when nearly everything is failing.
1768
+
1769
+ When the app is down or auth is misconfigured, all N scenarios fail and the
1770
+ user waits out the full wall clock to learn it. The coordinator watches
1771
+ results stream in and aborts once the evidence is overwhelming.
1772
+
1773
+ Defaults are deliberately conservative — ``min_completed`` scenarios must
1774
+ have finished AND ``fail_ratio`` of them must have failed — so a
1775
+ legitimately red suite never trips it.
1776
+ """
1777
+
1778
+ enabled: bool = True
1779
+ # Don't judge before this many scenarios have reported.
1780
+ min_completed: int = Field(default=10, ge=1)
1781
+ # Fraction of completed scenarios that must have failed to trip. 1.0 =
1782
+ # only trip when literally everything failed.
1783
+ fail_ratio: float = Field(default=0.9, gt=0.0, le=1.0)
1784
+
1785
+ class Config:
1786
+ extra = "forbid"
1787
+
1788
+
1789
+ class ParallelConfig(BaseModel):
1790
+ """Settings for ``steppy run --workers N`` (N >= 2). The whole block is
1791
+ optional — only consulted when parallel mode is engaged. Single-worker
1792
+ runs (no flag, or ``--workers 1``) ignore it entirely."""
1793
+
1794
+ # Default worker count when --workers is omitted on the CLI. CLI value
1795
+ # always wins. Accepts an integer or the literal string "auto" — auto
1796
+ # resolves to ``min(cpu_count // 2, scenario_count, 8)`` at coordinator
1797
+ # startup.
1798
+ workers: int | Literal["auto"] = 1
1799
+ # Path to the user's docker-compose file (relative to repo root).
1800
+ # Coordinator orchestrates one stack per worker via ``-f <user> -f <override>``.
1801
+ compose_file: str = "docker-compose.yml"
1802
+ # Tag prefix for the worker image. Coordinator probes
1803
+ # ``<worker_image>:<package_version>-<src_hash>`` and builds it if missing
1804
+ # (when ``build_image`` is true).
1805
+ worker_image: str = "steppy-worker"
1806
+ # Seconds to wait for ``docker compose up --wait`` to report stack healthy.
1807
+ healthcheck_timeout: int = 120
1808
+ # Auto-build the worker image on first parallel run if absent locally.
1809
+ # Disable with ``--no-build`` on the CLI to fail fast instead.
1810
+ build_image: bool = True
1811
+ # Escape hatch: build the worker image from your own Dockerfile instead of
1812
+ # the one steppy resolves (source checkout → synthesized from the installed
1813
+ # package). Path is absolute or relative to the project root.
1814
+ dockerfile: Optional[str] = None
1815
+ # Build context for ``dockerfile``. Defaults to the Dockerfile's parent
1816
+ # directory. Only meaningful together with ``dockerfile``.
1817
+ build_context: Optional[str] = None
1818
+ # Override the Playwright base image the worker is built FROM. Passed as
1819
+ # ``--build-arg PLAYWRIGHT_BASE=...``; folded into the image tag so a
1820
+ # change rebuilds. Default: the pin in steppy's bundled Dockerfiles.
1821
+ worker_base_image: Optional[str] = None
1822
+ # Optional pre-authentication hook run in-network before workers claim.
1823
+ # None ⇒ no preauth service is rendered and dispatch is unchanged.
1824
+ pre_auth: Optional[PreAuthConfig] = None
1825
+ # Scenario-level retry pass over the failures, run after the queue drains.
1826
+ retry: RetryConfig = Field(default_factory=RetryConfig)
1827
+ # Early abort when nearly every scenario is failing (infra, not tests).
1828
+ circuit_breaker: CircuitBreakerConfig = Field(default_factory=CircuitBreakerConfig)
1829
+ # Give every worker a PRIVATE `.auth` dir instead of sharing the project's.
1830
+ # The shared dir is bind-mounted read-write into all N workers, so they race
1831
+ # on one storage-state file; isolating trades one login for N. Turn this on
1832
+ # when each stack has its own database.
1833
+ isolate_auth: bool = False
1834
+
1835
+ class Config:
1836
+ extra = "forbid"
1837
+
1838
+ @field_validator("workers")
1839
+ @classmethod
1840
+ def validate_workers(cls, v):
1841
+ if isinstance(v, int) and v < 1:
1842
+ raise ValueError("parallel.workers must be >= 1 or 'auto'")
1843
+ return v
1844
+
1845
+ @model_validator(mode="after")
1846
+ def validate_auth_isolation(self):
1847
+ """``isolate_auth`` and ``pre_auth`` are mutually exclusive.
1848
+
1849
+ ``pre_auth`` exists to populate ONE shared ``.auth`` cache from a single
1850
+ in-network login; ``isolate_auth`` gives every worker its own dir, which
1851
+ that single login would never reach. Silently preferring one would leave
1852
+ workers unauthenticated with no visible cause, so reject the combination
1853
+ at config load.
1854
+ """
1855
+ if self.isolate_auth and self.pre_auth is not None:
1856
+ raise ValueError(
1857
+ "parallel.isolate_auth and parallel.pre_auth are mutually exclusive: "
1858
+ "pre_auth captures ONE shared session, but isolate_auth gives each worker "
1859
+ "a private .auth dir that the shared login never reaches. Drop pre_auth and "
1860
+ "let each worker authenticate itself, or turn isolate_auth off."
1861
+ )
1862
+ return self
1863
+
1864
+
1865
+ class CoverageApiConfig(BaseModel):
1866
+ """One backend API tracked by the coverage map (a project may have several,
1867
+ e.g. an orders-backend + a billing-backend)."""
1868
+
1869
+ # Identifier baked into endpoint node identity — keep it stable.
1870
+ name: str
1871
+ # OpenAPI document location: http(s) URL or a local file path, in JSON or
1872
+ # YAML (a served /openapi.json is usually the first, a spec committed to the
1873
+ # repo the second).
1874
+ openapi: str
1875
+ # URL prefixes that attribute observed requests to this API. Must mirror
1876
+ # the OpenAPI ``servers[].url`` (including any path prefix like /api) and
1877
+ # should include Docker-internal ``internal_url`` origins so records
1878
+ # captured by parallel workers classify correctly.
1879
+ base_urls: list[str] = Field(default_factory=list)
1880
+
1881
+ class Config:
1882
+ extra = "forbid"
1883
+
1884
+ @field_validator("name", "openapi")
1885
+ @classmethod
1886
+ def validate_non_empty(cls, v: str) -> str:
1887
+ if not v or not v.strip():
1888
+ raise ValueError("coverage.apis entries need non-empty 'name' and 'openapi'")
1889
+ return v
1890
+
1891
+
1892
+ class CoverageConfig(BaseModel):
1893
+ """Settings for the coverage map (``steppy run --coverage`` + ``steppy
1894
+ coverage`` commands). The whole block is optional — recording is off
1895
+ unless ``--coverage`` is passed, regardless of this block."""
1896
+
1897
+ apis: list[CoverageApiConfig] = Field(default_factory=list)
1898
+ # Optional sitemap.xml (URL or path) declaring the route surface.
1899
+ sitemap: Optional[str] = None
1900
+ # Local directory holding the app build's *.js.map files (source maps are
1901
+ # read from disk at merge time; inline data: maps work without this).
1902
+ sourcemap_dir: Optional[str] = None
1903
+ # Persistent sqlite map. Lives OUTSIDE output/ so wiping run artifacts
1904
+ # never deletes manual triage flags.
1905
+ store_path: str = "./.steppy/coverage.db"
1906
+ # Raw per-run records root. Must stay under a worker-writable mount
1907
+ # (output/) for parallel runs.
1908
+ records_dir: str = "./output/coverage"
1909
+ # Playwright resource types treated as API calls (web-standard).
1910
+ api_resource_types: list[str] = Field(default_factory=lambda: ["xhr", "fetch"])
1911
+ # Extra regexes (full-segment match) collapsed to ``:id`` during route
1912
+ # normalization, e.g. '^INV-\\d+$' for business reference segments.
1913
+ route_param_patterns: list[str] = Field(default_factory=list)
1914
+ # Source-module path prefixes excluded from the module inventory.
1915
+ module_ignore: list[str] = Field(default_factory=lambda: ["node_modules/", "webpack/"])
1916
+ # Endpoints/routes that can never have a UI test: health checks, webhook
1917
+ # receivers, OAuth callbacks, internal cron. Declared HERE rather than only
1918
+ # via ``steppy coverage triage set``, because that flag lives in
1919
+ # ``.steppy/coverage.db`` and ``.steppy/`` is gitignored — so a fresh CI
1920
+ # checkout marks every endpoint ``unknown`` and any percentage computed
1921
+ # there is wrong for reasons nobody can fix from CI.
1922
+ #
1923
+ # Grammar: ``[<METHOD> ]<glob>``, matched against the path template
1924
+ # (``/health``, ``GET /health``, ``/internal/*``). ``*`` crosses ``/``, a
1925
+ # pattern applies across every configured API, and a method-qualified
1926
+ # pattern never matches a route. See steppy/coverage/triage.py.
1927
+ non_ui: list[str] = Field(default_factory=list)
1928
+
1929
+ class Config:
1930
+ extra = "forbid"
1931
+
1932
+ @field_validator("non_ui")
1933
+ @classmethod
1934
+ def validate_non_ui_patterns(cls, v: list[str]) -> list[str]:
1935
+ """Reject patterns that cannot mean anything.
1936
+
1937
+ An empty entry would match nothing while looking deliberate, and a bare
1938
+ method (``"GET"``) parses as a *glob* of ``GET`` — it would silently
1939
+ match no path rather than every GET, which is the opposite of what
1940
+ someone writing it intends.
1941
+
1942
+ The import stays inside the body and the grammar keeps one home. Pydantic
1943
+ does not run a field validator over a ``default_factory`` value, so a
1944
+ project that never declares ``coverage.non_ui`` never reaches this line —
1945
+ which is what keeps ``tests/test_no_coverage_import_on_default_path.py``
1946
+ honest rather than merely green. ``coverage/__init__.py`` is a docstring
1947
+ and ``coverage/triage.py`` is stdlib-only, so a project that *does*
1948
+ declare it pays nothing measurable either.
1949
+ """
1950
+ from .coverage.triage import split_pattern
1951
+
1952
+ for pattern in v:
1953
+ if not pattern.strip():
1954
+ raise ValueError("coverage.non_ui entries must not be empty")
1955
+ method, glob = split_pattern(pattern)
1956
+ if not glob:
1957
+ raise ValueError(
1958
+ f"coverage.non_ui entry {pattern!r} has no path pattern — "
1959
+ "write '<METHOD> <glob>' or just '<glob>' (e.g. 'GET /health' or '/webhooks/*')"
1960
+ )
1961
+ if method is None and glob.isalpha() and glob.isupper():
1962
+ raise ValueError(
1963
+ f"coverage.non_ui entry {pattern!r} looks like a bare HTTP method — "
1964
+ "it would be read as a path glob and match nothing; add a path (e.g. 'GET /health')"
1965
+ )
1966
+ return v
1967
+
1968
+ @field_validator("route_param_patterns")
1969
+ @classmethod
1970
+ def validate_patterns_compile(cls, v: list[str]) -> list[str]:
1971
+ import re
1972
+
1973
+ for pattern in v:
1974
+ try:
1975
+ re.compile(pattern)
1976
+ except re.error as exc:
1977
+ raise ValueError(f"invalid coverage.route_param_patterns regex {pattern!r}: {exc}")
1978
+ return v
1979
+
1980
+ @field_validator("apis")
1981
+ @classmethod
1982
+ def validate_unique_api_names(cls, v: list[CoverageApiConfig]) -> list[CoverageApiConfig]:
1983
+ names = [a.name for a in v]
1984
+ if len(names) != len(set(names)):
1985
+ raise ValueError(f"coverage.apis names must be unique, got: {names}")
1986
+ return v
1987
+
1988
+
1989
+ class SecurityConfig(BaseModel):
1990
+ """Security-sensitive toggles.
1991
+
1992
+ ``allow_shell_capture`` gates the ``capture from.type=shell`` action, which
1993
+ runs ``subprocess.run(..., shell=True)`` with values that may be scraped from
1994
+ the live page (DOM) or an HTTP response. It is a command-injection vector by
1995
+ design, so it is **disabled by default** and must be explicitly enabled.
1996
+ """
1997
+
1998
+ allow_shell_capture: bool = False
1999
+
2000
+ class Config:
2001
+ extra = "forbid"
2002
+
2003
+
2004
+ class CloudConfig(BaseModel):
2005
+ """Where ``steppy bundle upload`` pushes results.
2006
+
2007
+ **The URL only, never a token.** A control-plane token is a credential, and
2008
+ ``config.yaml`` is committed — the same line :func:`config._check_credential_hygiene`
2009
+ already draws for ``apps.*.auth.credentials``. The token comes from
2010
+ ``STEPPY_CLOUD_TOKEN`` or ``--token``, and there is deliberately no config
2011
+ key for it to be pasted into.
2012
+
2013
+ Wholly optional and inert when absent, like ``parallel:`` and ``coverage:``.
2014
+ Nothing in Steppy assumes a control plane exists; this block is read by one
2015
+ command and ignored by every other.
2016
+ """
2017
+
2018
+ #: Base URL of the control plane, e.g. ``https://steppy.example.com``. No
2019
+ #: version path — the client appends ``/v1``, so a server that moves to v2
2020
+ #: does not need every project to edit its config.
2021
+ url: Optional[str] = None
2022
+
2023
+ class Config:
2024
+ extra = "forbid"
2025
+
2026
+
2027
+ class ReportConfig(BaseModel):
2028
+ """CI-friendly reporting artifacts + flaky-retry (FLAWS §6.1).
2029
+
2030
+ All fields are off by default, so omitting this block keeps ``steppy run``
2031
+ byte-identical. ``junit_xml``/``html`` are output paths; when set, ``run``
2032
+ appends the matching pytest flag. ``trace`` turns on Playwright tracing;
2033
+ ``on-failure`` (the useful default when enabled) saves a trace zip only for
2034
+ failed tests, ``on`` saves every trace, ``off`` disables it. ``video``
2035
+ mirrors ``trace`` for Playwright video recording (``on-failure`` keeps only
2036
+ failed-test webms). ``retries`` reruns each failed test up to N times via
2037
+ pytest-rerunfailures (the ``steppy-dev[retry]`` extra); 0 disables it. The CLI
2038
+ ``--trace``/``--video`` flags force ``on-failure`` and ``--retries N``
2039
+ overrides ``retries`` for a single run.
2040
+ """
2041
+
2042
+ junit_xml: Optional[str] = None
2043
+ html: Optional[str] = None
2044
+ trace: Literal["off", "on", "on-failure"] = "off"
2045
+ video: Literal["off", "on", "on-failure"] = "off"
2046
+ retries: int = Field(default=0, ge=0) # pytest --reruns N; 0 = off
2047
+
2048
+ class Config:
2049
+ extra = "forbid"
2050
+
2051
+
2052
+ class PdfMarginConfig(BaseModel):
2053
+ """Page margins for PDF export. Any CSS length Chromium accepts
2054
+ (``12mm``, ``0.5in``, ``48px``). The bottom default leaves room for the
2055
+ page-number footer."""
2056
+
2057
+ top: str = "12mm"
2058
+ right: str = "12mm"
2059
+ bottom: str = "14mm"
2060
+ left: str = "12mm"
2061
+
2062
+ class Config:
2063
+ extra = "forbid"
2064
+
2065
+
2066
+ class PdfConfig(BaseModel):
2067
+ """PDF export settings, shared by ``steppy guide --pdf/--handbook`` and
2068
+ ``steppy build --pdf/--handbook``.
2069
+
2070
+ Rendering is headless Chromium's print-to-PDF over the already-generated
2071
+ guide HTML, so the ``@media print`` rules in the guide templates are what
2072
+ the PDF actually shows. Defaults are chosen so an unconfigured project
2073
+ gets a usable document: A4 portrait, backgrounds ON (Playwright defaults
2074
+ them off, which would drop the step badges and role chip), and a
2075
+ ``N / M`` footer.
2076
+ """
2077
+
2078
+ format: Literal["A0", "A1", "A2", "A3", "A4", "A5", "A6", "Letter", "Legal", "Tabloid", "Ledger"] = "A4"
2079
+ landscape: bool = False
2080
+ margin: PdfMarginConfig = Field(default_factory=PdfMarginConfig)
2081
+ print_background: bool = True
2082
+ scale: float = Field(default=1.0, ge=0.1, le=2.0) # Chromium's accepted range
2083
+ page_numbers: bool = True # footer "N / M"
2084
+ timeout: int = Field(default=30000, ge=1000) # ms per rendered page
2085
+
2086
+ class Config:
2087
+ extra = "forbid"
2088
+
2089
+
2090
+ class TtsHttpConfig(BaseModel):
2091
+ """Raw HTTP shape of a text-to-speech request.
2092
+
2093
+ This is the engine every vendor preset runs on: ``elevenlabs``, ``openai``,
2094
+ ``azure`` and ``google`` are rows of data in ``steppy/tts/presets.py`` that
2095
+ fill these fields in, and a vendor Steppy has never heard of is reachable by
2096
+ writing them out here directly. That is what makes "swap the provider by
2097
+ editing config.yaml" literally true rather than aspirational.
2098
+
2099
+ String leaves in ``headers``/``json``/``body`` may contain ``{text}``,
2100
+ ``{voice}``, ``{locale}``, ``{model}``, ``{speed}``, ``{api_key}``,
2101
+ ``{region}``, plus any key from ``TtsConfig.options``. Substitution happens
2102
+ on the parsed structure, never on serialized JSON, so narration containing
2103
+ a brace cannot corrupt the request body.
2104
+ """
2105
+
2106
+ url: Optional[str] = None
2107
+ method: str = "POST"
2108
+ headers: dict[str, str] = Field(default_factory=dict)
2109
+ # Request body as JSON. Mutually exclusive with ``body`` (raw text, e.g. SSML).
2110
+ json_body: Optional[dict] = Field(default=None, alias="json")
2111
+ body: Optional[str] = None
2112
+ content_type: Optional[str] = None
2113
+ # ``binary`` = the response *is* the audio; ``json`` = dig it out of a field.
2114
+ response: Literal["binary", "json"] = "binary"
2115
+ # Dotted path to the audio field when ``response: json`` (e.g. "audioContent").
2116
+ audio_path: Optional[str] = None
2117
+ encoding: Literal["raw", "base64"] = "raw"
2118
+ format: Optional[str] = None
2119
+
2120
+ class Config:
2121
+ extra = "forbid"
2122
+ populate_by_name = True
2123
+
2124
+ @model_validator(mode="after")
2125
+ def validate_body_exclusivity(self):
2126
+ if self.json_body is not None and self.body is not None:
2127
+ raise ValueError("video.tts.http: set either 'json' or 'body', not both")
2128
+ if self.response == "json" and not self.audio_path:
2129
+ raise ValueError("video.tts.http: 'audio_path' is required when response is 'json'")
2130
+ return self
2131
+
2132
+
2133
+ class PiperConfig(BaseModel):
2134
+ """Settings for the ``piper`` provider — the free, offline, neural voice.
2135
+
2136
+ Steppy shells out to ``python -m piper`` and never imports the package:
2137
+ piper1-gpl is GPL-3.0, and an arms-length subprocess keeps it a user-installed
2138
+ tool rather than a dependency of a permissively-licensed wheel. It is
2139
+ deliberately absent from ``pyproject.toml`` extras for the same reason —
2140
+ install it yourself with ``pip install piper-tts``.
2141
+
2142
+ Voice models are per-language (``fr_FR-siwis-medium``, ``nl_NL-mls-medium``),
2143
+ which is why ``TtsConfig.voice`` accepts a per-locale map.
2144
+ """
2145
+
2146
+ # Under .steppy/ so `output/` cleanup never re-downloads tens of MB of voices.
2147
+ data_dir: str = ".steppy/voices"
2148
+ download: bool = True
2149
+ # Interpreter to run ``-m piper`` with. None = the one running Steppy.
2150
+ python: Optional[str] = None
2151
+
2152
+ class Config:
2153
+ extra = "forbid"
2154
+
2155
+
2156
+ class TtsConfig(BaseModel):
2157
+ """Text-to-speech provider selection for video narration.
2158
+
2159
+ ``provider`` is a plain string rather than a ``Literal`` on purpose: besides
2160
+ the built-ins (``none``, ``piper``, ``espeak``, ``http``) and the vendor
2161
+ presets (``elevenlabs``, ``openai``, ``azure``, ``google``), a third party
2162
+ can register one through the ``steppy.tts_providers`` entry-point group, and
2163
+ a closed enum would make that unreachable.
2164
+
2165
+ Default is ``none``: captions only, no audio, no network, no bill. Adding a
2166
+ voice is one config line.
2167
+
2168
+ Put the key in the environment, not here — ``${ELEVENLABS_API_KEY}`` is
2169
+ resolved at config load, and a literal value earns a hygiene warning.
2170
+ """
2171
+
2172
+ provider: str = "none"
2173
+ # str for one voice everywhere, or {locale: voice} — the multilingual case.
2174
+ voice: Optional[Union[str, dict[str, str]]] = None
2175
+ model: Optional[str] = None
2176
+ speed: float = Field(default=1.0, gt=0, le=4.0)
2177
+ format: Optional[str] = None
2178
+ api_key: Optional[str] = None
2179
+ base_url: Optional[str] = None
2180
+ region: Optional[str] = None
2181
+ timeout: int = Field(default=60000, ge=1000)
2182
+ # Synthesized clips are cached under .steppy/ and keyed by content, so a
2183
+ # re-run of an unchanged guide costs nothing. Disabling it re-bills.
2184
+ cache: bool = True
2185
+ cache_dir: str = ".steppy/tts-cache"
2186
+ # Provider-specific extras, also available as {placeholders} in http/presets.
2187
+ options: dict[str, Any] = Field(default_factory=dict)
2188
+ piper: PiperConfig = Field(default_factory=PiperConfig)
2189
+ http: TtsHttpConfig = Field(default_factory=TtsHttpConfig)
2190
+
2191
+ class Config:
2192
+ extra = "forbid"
2193
+
2194
+ def voice_for(self, locale: str) -> Optional[str]:
2195
+ """Resolve the voice for ``locale`` from the str-or-map form."""
2196
+ if self.voice is None:
2197
+ return None
2198
+ if isinstance(self.voice, str):
2199
+ return self.voice
2200
+ return self.voice.get(locale)
2201
+
2202
+
2203
+ class VideoBrandingConfig(BaseModel):
2204
+ """Marks burned into every frame. ``logo`` is a project-relative image path;
2205
+ it is embedded as a data URI, since the render page has no network."""
2206
+
2207
+ logo: Optional[str] = None
2208
+ accent: Optional[str] = None
2209
+ font_family: Optional[str] = None
2210
+
2211
+ class Config:
2212
+ extra = "forbid"
2213
+
2214
+
2215
+ class VideoStampConfig(BaseModel):
2216
+ """Provenance line burned into the frame — the audit-trail artifact a
2217
+ regulated buyer asks for ("verified against 2.14 on 2026-08-05").
2218
+
2219
+ ``text`` accepts ``{version}``, ``{date}``, ``{locale}``, ``{layout}`` and
2220
+ ``{slug}``. Point ``version`` at the environment (``${STEPPY_RELEASE}``) so
2221
+ CI stamps the real tag.
2222
+ """
2223
+
2224
+ enabled: bool = False
2225
+ text: str = "{version} · {date}"
2226
+ version: str = "dev"
2227
+
2228
+ class Config:
2229
+ extra = "forbid"
2230
+
2231
+
2232
+ class VideoConfig(BaseModel):
2233
+ """Narrated-video settings for guides declaring ``modes: ['video']``.
2234
+
2235
+ Only consulted once a render is armed with ``steppy build --video`` or
2236
+ ``steppy guide --video``; every other command ignores this block, and a
2237
+ guide that declares the mode without the flag renders nothing.
2238
+
2239
+ Frames are stepped deterministically rather than screen-recorded, so the
2240
+ output depends on the manifest and this config — not on how fast the render
2241
+ host happens to be. Two consequences worth knowing: ``fps`` and ``width``
2242
+ are the cost knobs (a render is frames x screenshot latency), and because
2243
+ cursor travel is timed over *display* distance, changing ``width`` changes
2244
+ the video's duration.
2245
+
2246
+ Requires ffmpeg on PATH — including with ``tts.provider: none``, since a
2247
+ silent captioned video still has to be encoded.
2248
+ """
2249
+
2250
+ fps: int = Field(default=24, ge=1, le=60)
2251
+ # Output width in px; height follows the capture's aspect (so a mobile
2252
+ # layout yields a portrait video). Both are rounded down to even for libx264.
2253
+ width: int = Field(default=1280, ge=160, le=3840)
2254
+ max_height: Optional[int] = Field(default=None, ge=160)
2255
+ captions: Literal["band", "off"] = "band"
2256
+ crf: int = Field(default=23, ge=0, le=51)
2257
+ preset: Literal["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"] = (
2258
+ "medium"
2259
+ )
2260
+ jpeg_quality: int = Field(default=90, ge=1, le=100)
2261
+ intro_ms: int = Field(default=1200, ge=0)
2262
+ outro_ms: int = Field(default=1500, ge=0)
2263
+ hold_ms: int = Field(default=700, ge=0)
2264
+ min_step_ms: int = Field(default=1500, ge=0)
2265
+ narration_pad_ms: int = Field(default=400, ge=0)
2266
+ ffmpeg_path: Optional[str] = None
2267
+ # Per-guide budget for the whole render, and for one page load.
2268
+ timeout: int = Field(default=120000, ge=1000)
2269
+ # Skip a re-render whose inputs are unchanged. Off means every build pays
2270
+ # full price for every guide, which "regenerated on every release" cannot
2271
+ # afford on a real corpus.
2272
+ cache: bool = True
2273
+ cache_dir: str = ".steppy/video-cache"
2274
+ branding: VideoBrandingConfig = Field(default_factory=VideoBrandingConfig)
2275
+ stamp: VideoStampConfig = Field(default_factory=VideoStampConfig)
2276
+ tts: TtsConfig = Field(default_factory=TtsConfig)
2277
+
2278
+ class Config:
2279
+ extra = "forbid"
2280
+
2281
+
2282
+ class AppAssetsTourTarget(BaseModel):
2283
+ """Write the tour files flat into a directory your app serves (bundled mode)."""
2284
+
2285
+ type: Literal["app-assets"]
2286
+ path: str
2287
+
2288
+ class Config:
2289
+ extra = "forbid"
2290
+
2291
+ @field_validator("path")
2292
+ @classmethod
2293
+ def validate_path(cls, v: str) -> str:
2294
+ if not v.strip():
2295
+ raise ValueError("an app-assets tour target needs a non-empty 'path'")
2296
+ return v
2297
+
2298
+
2299
+ class GuideTourTarget(BaseModel):
2300
+ """Publish the tours with the static guide site, under ``tours/<build-id>/``."""
2301
+
2302
+ type: Literal["guide"]
2303
+
2304
+ class Config:
2305
+ extra = "forbid"
2306
+
2307
+
2308
+ TourTarget = Annotated[Union[AppAssetsTourTarget, GuideTourTarget], Field(discriminator="type")]
2309
+
2310
+
2311
+ class ToursConfig(BaseModel):
2312
+ """In-app tours: guided popovers over the live app, compiled from scenarios.
2313
+
2314
+ Absent or ``enabled: false`` ⇒ no tour output at all and every existing
2315
+ output byte-identical. When enabled, ``steppy generate`` and ``steppy
2316
+ tours`` compile every opted-in scenario into ``<output.tours_dir>/<build-id>/``
2317
+ plus a flat copy per ``app-assets`` target, and ``generate`` appends a CI
2318
+ replay to each opted-in scenario's test file (``replay``). A ``guide``
2319
+ target makes the static ``steppy build`` republish the tree.
2320
+
2321
+ ``build_id`` is ``git-sha``, ``git-sha:<repo-path>`` or a literal (see
2322
+ :mod:`steppy.buildid`); an unset ``${VAR}`` is accepted here and only
2323
+ rejected when tours are emitted, so unrelated commands keep working.
2324
+ ``layouts`` maps the layout names scenario steps use to CSS media queries
2325
+ the overlay evaluates (skip-only: a step whose layouts do not match is
2326
+ skipped, never re-routed).
2327
+ """
2328
+
2329
+ enabled: bool = False
2330
+ default: bool = False
2331
+ build_id: str = "git-sha"
2332
+ locales: Optional[list[str]] = None
2333
+ default_locale: Optional[str] = None
2334
+ guide_base_url: Optional[str] = None
2335
+ test_id_attribute: str = "data-testid"
2336
+ layouts: Optional[dict[str, str]] = None
2337
+ replay: bool = True
2338
+ targets: list[TourTarget] = Field(default_factory=list)
2339
+
2340
+ class Config:
2341
+ extra = "forbid"
2342
+
2343
+ @field_validator("build_id")
2344
+ @classmethod
2345
+ def validate_build_id(cls, v: str) -> str:
2346
+ return check_build_id_syntax(v)
2347
+
2348
+ @field_validator("locales")
2349
+ @classmethod
2350
+ def validate_locales(cls, v):
2351
+ if v is None:
2352
+ return v
2353
+ if not v:
2354
+ raise ValueError("tours.locales must be a non-empty list (or omitted)")
2355
+ for tag in v:
2356
+ validate_locale_tag(tag)
2357
+ return v # duplicates and the size cap: SteppyConfig.validate_tours, on the effective list
2358
+
2359
+ @field_validator("default_locale")
2360
+ @classmethod
2361
+ def validate_default_locale(cls, v):
2362
+ if v is not None:
2363
+ validate_locale_tag(v)
2364
+ return v
2365
+
2366
+ @field_validator("guide_base_url")
2367
+ @classmethod
2368
+ def validate_guide_base_url(cls, v):
2369
+ if v is None or "${" in v:
2370
+ return v
2371
+ from urllib.parse import urlsplit
2372
+
2373
+ url = v.strip()
2374
+ parts = urlsplit(url)
2375
+ if parts.scheme not in ("http", "https") or not parts.hostname:
2376
+ raise ValueError(f"tours.guide_base_url {v!r} must be an absolute http(s) URL")
2377
+ if parts.query or parts.fragment or parts.username or parts.password:
2378
+ raise ValueError("tours.guide_base_url must not carry a query, fragment or credentials")
2379
+ if not parts.hostname.isascii():
2380
+ raise ValueError("tours.guide_base_url: write an internationalised host in its punycode (xn--) form")
2381
+ return url if url.endswith("/") else url + "/"
2382
+
2383
+ @field_validator("test_id_attribute")
2384
+ @classmethod
2385
+ def validate_test_id_attribute(cls, v: str) -> str:
2386
+ return check_test_id_attribute(v)
2387
+
2388
+ @field_validator("layouts")
2389
+ @classmethod
2390
+ def validate_layouts(cls, v):
2391
+ if v is None:
2392
+ return v
2393
+ if not v:
2394
+ raise ValueError("tours.layouts must map at least one layout (or be omitted)")
2395
+ out = {}
2396
+ for name, query in v.items():
2397
+ if not is_layout_name(name):
2398
+ raise ValueError(f"tours.layouts: {name!r} is not a valid layout name")
2399
+ q = query.strip() if isinstance(query, str) else query
2400
+ if not is_media_query(q):
2401
+ raise ValueError(f"tours.layouts[{name!r}]: {query!r} is not a media query this player accepts")
2402
+ out[name] = q
2403
+ return out
2404
+
2405
+ @model_validator(mode="after")
2406
+ def validate_targets(self):
2407
+ if self.enabled and not self.targets:
2408
+ raise ValueError(
2409
+ "tours.enabled needs at least one target: {type: app-assets, path: <dir your app serves>} "
2410
+ "and/or {type: guide}"
2411
+ )
2412
+ if sum(1 for t in self.targets if t.type == "guide") > 1:
2413
+ raise ValueError("tours.targets lists more than one 'guide' target")
2414
+ paths = [os.path.normpath(t.path) for t in self.targets if t.type == "app-assets"]
2415
+ if len(set(paths)) != len(paths):
2416
+ raise ValueError("tours.targets lists the same app-assets path twice")
2417
+ return self
2418
+
2419
+
2420
+ class SteppyConfig(BaseModel):
2421
+ """Main Steppy configuration."""
2422
+
2423
+ apps: dict[str, AppConfig]
2424
+ auth: GlobalAuthConfig = Field(default_factory=GlobalAuthConfig)
2425
+ locales: list[str] = Field(default_factory=lambda: list(DEFAULT_LOCALES))
2426
+ default_locale: str = DEFAULT_LOCALE
2427
+ screenshot: ScreenshotConfig = Field(default_factory=ScreenshotConfig)
2428
+ timing: TimingConfig = Field(default_factory=TimingConfig)
2429
+ output: OutputConfig = Field(default_factory=OutputConfig)
2430
+ # Selector preset for the ``select-option`` action's component dropdown.
2431
+ # Defaults to PrimeNG p-select; override for other UI kits.
2432
+ dropdown: DropdownConfig = Field(default_factory=DropdownConfig)
2433
+ # Selector preset for the ``confirm`` action's confirmation dialog.
2434
+ # Defaults to PrimeNG p-confirmDialog; override for other UI kits.
2435
+ dialog: DialogConfig = Field(default_factory=DialogConfig)
2436
+ # Selector preset for the ``toggle`` action (switch + segmented button).
2437
+ toggle: ToggleConfig = Field(default_factory=ToggleConfig)
2438
+ # Selector preset for the ``pick-date``/``pick-time`` calendar/clock widgets.
2439
+ datepicker: DatepickerConfig = Field(default_factory=DatepickerConfig)
2440
+ # Selector preset for the ``expand`` action (collapsible section / fieldset).
2441
+ fieldset: FieldsetConfig = Field(default_factory=FieldsetConfig)
2442
+ # Optional. Only required when invoking ``steppy run --workers N`` with
2443
+ # N >= 2. Single-worker runs ignore this block entirely.
2444
+ parallel: Optional[ParallelConfig] = None
2445
+ # Optional. Consulted by ``steppy run --coverage`` and the ``steppy
2446
+ # coverage`` commands; plain runs ignore this block entirely.
2447
+ coverage: Optional[CoverageConfig] = None
2448
+ # Security toggles (opt-in shell capture, etc.). Defaults are the safe
2449
+ # posture, so omitting this block keeps the locked-down behavior.
2450
+ security: SecurityConfig = Field(default_factory=SecurityConfig)
2451
+ # CI reporting artifacts (JUnit XML, HTML report, Playwright trace). All off
2452
+ # by default, so omitting this block keeps `steppy run` byte-identical.
2453
+ report: ReportConfig = Field(default_factory=ReportConfig)
2454
+ # Paper settings for PDF export. Only consulted by `steppy guide --pdf`
2455
+ # and `steppy build --pdf` (and their `--handbook` siblings); every other
2456
+ # command ignores this block entirely.
2457
+ pdf: PdfConfig = Field(default_factory=PdfConfig)
2458
+ # Narrated-video settings. Only consulted by `steppy build --video` and
2459
+ # `steppy guide --video` for guides declaring `modes: ['video']`; every
2460
+ # other command ignores this block entirely.
2461
+ video: VideoConfig = Field(default_factory=VideoConfig)
2462
+ # Optional. Only consulted by `steppy bundle upload`. Holds the control
2463
+ # plane's URL and nothing else — the token is env-only, on purpose.
2464
+ cloud: Optional[CloudConfig] = None
2465
+ # Optional. In-app tours compiled from `tour:`-enabled scenarios. Declared
2466
+ # last so its validator can read every other block (locales, layouts).
2467
+ tours: Optional[ToursConfig] = None
2468
+
2469
+ class Config:
2470
+ extra = "forbid"
2471
+
2472
+ @field_validator("tours")
2473
+ @classmethod
2474
+ def validate_tours(cls, v, info):
2475
+ """Cross-block rules: the tour locales and layouts must be ones the project has."""
2476
+ if v is None:
2477
+ return v
2478
+ locales = v.locales or info.data.get("locales", DEFAULT_LOCALES)
2479
+ if v.enabled or v.locales is not None:
2480
+ # Each tour locale names a file (`<id>.<locale>.json`), so the list a tour set is
2481
+ # emitted for — inherited from `locales` when tours.locales is omitted — must not
2482
+ # hold one language twice (`fr-BE`/`fr_be` collide on NTFS/APFS) or exceed the cap
2483
+ # the overlay enforces.
2484
+ where = "tours.locales" if v.locales is not None else "locales (inherited by tours)"
2485
+ if len(locales) > MAX_LOCALES:
2486
+ raise ValueError(f"{where} lists {len(locales)} locales; in-app tours support at most {MAX_LOCALES}")
2487
+ seen: dict[str, str] = {}
2488
+ for tag in locales:
2489
+ key = normalize_locale(tag).lower()
2490
+ if key in seen:
2491
+ raise ValueError(f"{where} lists the same locale twice: {seen[key]!r} and {tag!r}")
2492
+ seen[key] = tag
2493
+ default = v.default_locale or info.data.get("default_locale", DEFAULT_LOCALE)
2494
+ if default not in locales:
2495
+ raise ValueError(
2496
+ f"the tours default locale {default!r} must be one of the tour locales {locales} "
2497
+ "(set tours.default_locale, or add it to tours.locales)"
2498
+ )
2499
+ if v.layouts:
2500
+ screenshot = info.data.get("screenshot")
2501
+ known = set(screenshot.layouts) if screenshot is not None else set()
2502
+ unknown = sorted(set(v.layouts) - known)
2503
+ if unknown:
2504
+ raise ValueError(
2505
+ f"tours.layouts names {unknown} that screenshot.layouts does not declare "
2506
+ f"(declared: {sorted(known) or 'none'}); step `layouts:` refer to screenshot.layouts"
2507
+ )
2508
+ return v
2509
+
2510
+ @field_validator("locales")
2511
+ @classmethod
2512
+ def validate_locale_tags(cls, v: list[str]) -> list[str]:
2513
+ """Every locale must be a usable BCP-47 tag.
2514
+
2515
+ The capture side writes these verbatim as screenshot directory segments
2516
+ (``output/screenshots/<scenario>/<locale>/``) that ``steppy build`` later
2517
+ reads back, and the build side has validated its own ``locales`` since
2518
+ the hreflang work — so leaving this one unchecked meant the two halves of
2519
+ one project could disagree about what a locale is, with the disagreement
2520
+ surfacing as missing captures rather than as an error.
2521
+
2522
+ Validated, **not** rewritten: the authored spelling is a join key with
2523
+ those directories and with the app's own i18n codes. See
2524
+ ``src/steppy/locales.py``.
2525
+ """
2526
+ for item in v:
2527
+ validate_locale_tag(item)
2528
+ return v
2529
+
2530
+ @field_validator("default_locale")
2531
+ @classmethod
2532
+ def validate_default_locale(cls, v: str, info) -> str:
2533
+ """Validate that default_locale is a usable tag and is in ``locales``."""
2534
+ validate_locale_tag(v)
2535
+ # Note: info.data contains already-validated fields
2536
+ locales = info.data.get("locales", DEFAULT_LOCALES)
2537
+ if v not in locales:
2538
+ raise ValueError(f"default_locale '{v}' must be in locales list: {locales}")
2539
+ return v