steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/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
|