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/naming.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Product-derived names, in one place.
|
|
2
|
+
|
|
3
|
+
Every string the outside world sees that carries the product name — the in-app
|
|
4
|
+
tour element, the tour file ``kind`` markers, the kill-switch global, the
|
|
5
|
+
browser storage key — is built from :data:`PRODUCT` here, so a future rename is
|
|
6
|
+
one line plus the TypeScript mirror (``packages/player/src/ui/names.ts``, kept
|
|
7
|
+
equal by ``tests/test_tour_contract_sync.py``).
|
|
8
|
+
|
|
9
|
+
A stdlib-only leaf: ``models.py`` and the generated-test helpers may import it
|
|
10
|
+
on the eager CLI path.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
PRODUCT = "steppy"
|
|
14
|
+
|
|
15
|
+
#: The custom element the app mounts once in its shell.
|
|
16
|
+
TOUR_HOST_TAG = f"{PRODUCT}-tour-host"
|
|
17
|
+
|
|
18
|
+
#: ``kind`` markers that make a tours manifest / tour file self-identifying —
|
|
19
|
+
#: the prune step refuses to touch a directory whose manifest lacks it.
|
|
20
|
+
TOURS_MANIFEST_KIND = f"{PRODUCT}-tours-manifest"
|
|
21
|
+
TOUR_FILE_KIND = f"{PRODUCT}-tour"
|
|
22
|
+
|
|
23
|
+
TOURS_MANIFEST_FILENAME = "tours.manifest.json"
|
|
24
|
+
|
|
25
|
+
#: Sub-directory of a static guide site that holds ``<build-id>/`` tour trees.
|
|
26
|
+
GUIDE_SITE_TOURS_DIR = "tours"
|
|
27
|
+
|
|
28
|
+
#: ``globalThis[...] === true`` makes the overlay's ``start()`` a no-op. The
|
|
29
|
+
#: generated E2E contexts set it so an app's own auto-started tour cannot
|
|
30
|
+
#: intercept clicks in unrelated tests; the tour replay lifts it.
|
|
31
|
+
TOURS_KILL_SWITCH_GLOBAL = f"__{PRODUCT.upper()}_TOURS_DISABLED__"
|
|
32
|
+
|
|
33
|
+
#: ``sessionStorage`` key of the resume record (survives a full reload).
|
|
34
|
+
TOUR_RESUME_STORAGE_KEY = f"{PRODUCT}:tour"
|
|
35
|
+
|
|
36
|
+
#: Name of the Playwright binding the replay helper exposes to collect events.
|
|
37
|
+
TOUR_PROBE_BINDING = f"__{PRODUCT}_tour_event"
|
steppy/parser.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""Parser for Markdown scenario files."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import yaml
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
import frontmatter
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from .models import Scenario, Step
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParseError(Exception):
|
|
15
|
+
"""Raised when a scenario file cannot be parsed."""
|
|
16
|
+
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_scenario_file(file_path: Path) -> Scenario:
|
|
21
|
+
"""
|
|
22
|
+
Parse a scenario Markdown file into a Scenario model.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
file_path: Path to the .md scenario file.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Parsed and validated Scenario instance.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ParseError: If the file cannot be parsed or is invalid.
|
|
32
|
+
"""
|
|
33
|
+
if not file_path.exists():
|
|
34
|
+
raise ParseError(f"Scenario file not found: {file_path}")
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
38
|
+
post = frontmatter.load(f)
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise ParseError(f"Failed to read scenario file {file_path}: {e}")
|
|
41
|
+
|
|
42
|
+
# Extract frontmatter metadata
|
|
43
|
+
metadata = dict(post.metadata)
|
|
44
|
+
if not metadata.get("title"):
|
|
45
|
+
raise ParseError(f"Scenario {file_path} is missing required 'title' in frontmatter")
|
|
46
|
+
|
|
47
|
+
# Parse steps from the body
|
|
48
|
+
body = post.content
|
|
49
|
+
steps = _parse_steps(body, file_path)
|
|
50
|
+
|
|
51
|
+
# Create slug from filename (e.g., "cross--order-lifecycle.md" → "cross--order-lifecycle")
|
|
52
|
+
slug = file_path.stem
|
|
53
|
+
|
|
54
|
+
# Build scenario
|
|
55
|
+
try:
|
|
56
|
+
scenario = Scenario(**metadata, steps=steps, file_path=file_path, slug=slug)
|
|
57
|
+
except ValidationError as e:
|
|
58
|
+
error_msg = f"Scenario validation failed for {file_path}:\n\n"
|
|
59
|
+
for error in e.errors():
|
|
60
|
+
field = " -> ".join(str(x) for x in error["loc"])
|
|
61
|
+
msg = error["msg"]
|
|
62
|
+
error_msg += f" Field '{field}': {msg}\n"
|
|
63
|
+
error_msg += "\nHint: Check the scenario frontmatter and step definitions."
|
|
64
|
+
raise ParseError(error_msg)
|
|
65
|
+
|
|
66
|
+
return scenario
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def validate_scenario_roles(scenario: Scenario, configured_roles: set[str]) -> list[str]:
|
|
70
|
+
"""Return human-readable errors for role references not in configured_roles.
|
|
71
|
+
|
|
72
|
+
Checks both step.role and step.screenshot (when it is a string role,
|
|
73
|
+
excluding the "all" sentinel and bool/None values). The "all" value
|
|
74
|
+
means "every role" and is always accepted regardless of config.
|
|
75
|
+
"""
|
|
76
|
+
errors: list[str] = []
|
|
77
|
+
available = ", ".join(sorted(configured_roles)) or "<none>"
|
|
78
|
+
|
|
79
|
+
for step in scenario.steps:
|
|
80
|
+
step_label = f"step {step.step_number}" if step.step_number is not None else "step"
|
|
81
|
+
|
|
82
|
+
if step.role and step.role not in configured_roles:
|
|
83
|
+
errors.append(f"{step_label}: unknown role '{step.role}'. Available roles: {available}")
|
|
84
|
+
|
|
85
|
+
if isinstance(step.screenshot, str) and step.screenshot != "all" and step.screenshot not in configured_roles:
|
|
86
|
+
errors.append(f"{step_label}: unknown screenshot role '{step.screenshot}'. Available roles: {available}")
|
|
87
|
+
|
|
88
|
+
return errors
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def validate_scenario_layouts(scenario: Scenario, configured_layouts: set[str]) -> list[str]:
|
|
92
|
+
"""Return human-readable errors for `scenario.layouts` / `step.layouts`
|
|
93
|
+
entries not in ``configured_layouts`` (the keys of ``config.screenshot.layouts``).
|
|
94
|
+
|
|
95
|
+
No-op when neither the scenario nor any step declares `layouts`. When
|
|
96
|
+
`configured_layouts` is empty, any layout reference is rejected with a hint
|
|
97
|
+
that points to the config block."""
|
|
98
|
+
errors: list[str] = []
|
|
99
|
+
available = ", ".join(sorted(configured_layouts)) or "<none configured>"
|
|
100
|
+
|
|
101
|
+
for layout in scenario.layouts or []:
|
|
102
|
+
if layout not in configured_layouts:
|
|
103
|
+
errors.append(
|
|
104
|
+
f"scenario: unknown layout '{layout}'. "
|
|
105
|
+
f"Available layouts: {available}. "
|
|
106
|
+
f"Declare layouts under `screenshot.layouts` in config.yaml."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for step in scenario.steps:
|
|
110
|
+
if not step.layouts:
|
|
111
|
+
continue
|
|
112
|
+
step_label = f"step {step.step_number}" if step.step_number is not None else "step"
|
|
113
|
+
for layout in step.layouts:
|
|
114
|
+
if layout not in configured_layouts:
|
|
115
|
+
errors.append(
|
|
116
|
+
f"{step_label}: unknown layout '{layout}'. "
|
|
117
|
+
f"Available layouts: {available}. "
|
|
118
|
+
f"Declare layouts under `screenshot.layouts` in config.yaml."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return errors
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# ── Selector lint (FLAWS §6.3) ────────────────────────────────────────────────
|
|
125
|
+
# Static, browser-free heuristics that flag brittle selectors. These are
|
|
126
|
+
# *warnings*, never errors: they never change `validate`'s exit code — the point
|
|
127
|
+
# is to nudge authors toward stable `data-testid`/role locators, not to block.
|
|
128
|
+
|
|
129
|
+
# Positional pseudo-classes couple a selector to sibling order — the first thing
|
|
130
|
+
# to break when markup is reordered.
|
|
131
|
+
_POSITIONAL_RE = re.compile(
|
|
132
|
+
r":(?:nth-child|nth-of-type|nth-last-child|nth-last-of-type|"
|
|
133
|
+
r"first-child|last-child|first-of-type|last-of-type|only-child)\b",
|
|
134
|
+
re.IGNORECASE,
|
|
135
|
+
)
|
|
136
|
+
# Playwright's text engine ties a selector to visible copy — breaks on i18n and
|
|
137
|
+
# wording changes.
|
|
138
|
+
_TEXT_COUPLED_RE = re.compile(r":has-text\(|>>\s*text=|(?:^|\s)text=", re.IGNORECASE)
|
|
139
|
+
# More than this many descendant hops is a fragile deep chain.
|
|
140
|
+
_MAX_DESCENDANT_HOPS = 3
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _count_top_level_parts(selector: str) -> int:
|
|
144
|
+
"""Count the selector parts separated by combinators at bracket/paren depth 0.
|
|
145
|
+
|
|
146
|
+
Whitespace **and** the ``>`` / ``+`` / ``~`` combinators are all separators;
|
|
147
|
+
runs of them collapse to a single boundary (so ``div > span`` is 2 parts, not
|
|
148
|
+
3, and ``div>span`` is also 2). Anything inside ``[...]`` / ``(...)`` is
|
|
149
|
+
ignored, so ``[data-testid~="x"]`` and ``:nth-child(2n+1)`` — whose ``~`` / ``+``
|
|
150
|
+
live inside brackets/parens — don't inflate the count.
|
|
151
|
+
"""
|
|
152
|
+
depth = 0
|
|
153
|
+
parts = 0
|
|
154
|
+
in_sep = True # leading position acts like a preceding separator
|
|
155
|
+
for ch in selector.strip():
|
|
156
|
+
if depth == 0 and (ch.isspace() or ch in ">+~"):
|
|
157
|
+
in_sep = True
|
|
158
|
+
continue
|
|
159
|
+
if in_sep:
|
|
160
|
+
parts += 1
|
|
161
|
+
in_sep = False
|
|
162
|
+
if ch in "([":
|
|
163
|
+
depth += 1
|
|
164
|
+
elif ch in ")]":
|
|
165
|
+
depth = max(0, depth - 1)
|
|
166
|
+
return parts
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _lint_one_selector(selector: str) -> list[str]:
|
|
170
|
+
"""Return brittleness warnings for a single CSS/Playwright selector string."""
|
|
171
|
+
reasons: list[str] = []
|
|
172
|
+
if _POSITIONAL_RE.search(selector):
|
|
173
|
+
reasons.append("positional pseudo-class (breaks when siblings reorder)")
|
|
174
|
+
if _TEXT_COUPLED_RE.search(selector):
|
|
175
|
+
reasons.append("text-coupled selector (breaks on i18n/wording changes)")
|
|
176
|
+
parts = _count_top_level_parts(selector)
|
|
177
|
+
if parts - 1 > _MAX_DESCENDANT_HOPS:
|
|
178
|
+
reasons.append(f"deep descendant chain ({parts} parts)")
|
|
179
|
+
elif parts >= 2 and not any(c in selector for c in ".#["):
|
|
180
|
+
# A multi-part chain of bare tags/combinators with no data-testid, class,
|
|
181
|
+
# or id anchor rides entirely on document shape.
|
|
182
|
+
reasons.append("structure-only selector (no data-testid/class/id anchor; breaks when markup changes)")
|
|
183
|
+
return reasons
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def lint_scenario_selectors(scenario: Scenario) -> list[str]:
|
|
187
|
+
"""Return brittleness *warnings* for the selectors a scenario's steps use.
|
|
188
|
+
|
|
189
|
+
Inspects every author-written CSS surface that flows verbatim into a generated
|
|
190
|
+
locator call: ``step.selector``, ``step.within``, the ``guide-cursor.target``
|
|
191
|
+
override, and the ``from.selector`` of a ``capture from.type=dom`` step.
|
|
192
|
+
``navigate``'s ``target`` is intentionally excluded — it is a URL, not a
|
|
193
|
+
selector. Every warning ends with the same nudge toward stable locators.
|
|
194
|
+
Callers print these as warnings and must NOT fail on them (FLAWS §6.3).
|
|
195
|
+
"""
|
|
196
|
+
warnings: list[str] = []
|
|
197
|
+
for step in scenario.steps:
|
|
198
|
+
step_label = f"step {step.step_number}" if step.step_number is not None else "step"
|
|
199
|
+
fields: list[tuple[str, Any]] = [
|
|
200
|
+
("selector", step.selector),
|
|
201
|
+
("within", step.within),
|
|
202
|
+
]
|
|
203
|
+
if step.guide_cursor is not None:
|
|
204
|
+
fields.append(("guide-cursor.target", step.guide_cursor.target))
|
|
205
|
+
if isinstance(step.from_, dict):
|
|
206
|
+
fields.append(("from.selector", step.from_.get("selector")))
|
|
207
|
+
for field_name, selector in fields:
|
|
208
|
+
if not selector or not isinstance(selector, str):
|
|
209
|
+
continue
|
|
210
|
+
reasons = _lint_one_selector(selector)
|
|
211
|
+
for reason in reasons:
|
|
212
|
+
warnings.append(
|
|
213
|
+
f"{step_label}: brittle {field_name} '{selector}' — {reason}; "
|
|
214
|
+
f"prefer a data-testid or role-based locator"
|
|
215
|
+
)
|
|
216
|
+
return warnings
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def lint_exclusive_groups(scenarios: list[Scenario]) -> list[str]:
|
|
220
|
+
"""Return *warnings* for exclusion groups with a single member.
|
|
221
|
+
|
|
222
|
+
The one cross-scenario validator here — ``validate_scenario_roles`` and
|
|
223
|
+
``validate_scenario_layouts`` each judge one file, but a group only means
|
|
224
|
+
anything relative to the others.
|
|
225
|
+
|
|
226
|
+
A typo (``stripe-sandbox`` on one file, ``stripe-sandbx`` on another) is legal
|
|
227
|
+
on both, produces two one-member groups, and silently serialises nothing —
|
|
228
|
+
the *dangerous* direction, since the constraint evaporates rather than
|
|
229
|
+
over-applying. It cannot be an error, because narrowing a selection with
|
|
230
|
+
``--tag`` legitimately leaves a group with one member. Callers print these as
|
|
231
|
+
warnings and must NOT fail on them (FLAWS §6.3).
|
|
232
|
+
"""
|
|
233
|
+
members: dict[str, list[str]] = {}
|
|
234
|
+
for scenario in scenarios:
|
|
235
|
+
if scenario.exclusive:
|
|
236
|
+
members.setdefault(scenario.exclusive, []).append(scenario.slug or "?")
|
|
237
|
+
return [
|
|
238
|
+
f"exclusion group '{group}' has a single member ({slugs[0]}) — typo? A group of one serialises nothing."
|
|
239
|
+
for group, slugs in sorted(members.items())
|
|
240
|
+
if len(slugs) == 1
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parse_steps(body: str, file_path: Path) -> list[Step]:
|
|
245
|
+
"""
|
|
246
|
+
Parse steps from the scenario body.
|
|
247
|
+
|
|
248
|
+
Each step is an H3 heading (###) followed by a YAML-like list of key-value pairs.
|
|
249
|
+
"""
|
|
250
|
+
steps = []
|
|
251
|
+
|
|
252
|
+
# Split body into step sections using ### headers
|
|
253
|
+
# Pattern: ### followed by optional number and period, then the title
|
|
254
|
+
step_pattern = re.compile(r"^###\s+(?:\d+\.\s+)?(.+)$", re.MULTILINE)
|
|
255
|
+
|
|
256
|
+
matches = list(step_pattern.finditer(body))
|
|
257
|
+
|
|
258
|
+
if not matches:
|
|
259
|
+
# No steps found in body - this is valid (empty scenario)
|
|
260
|
+
return steps
|
|
261
|
+
|
|
262
|
+
for i, match in enumerate(matches):
|
|
263
|
+
step_title = match.group(1).strip()
|
|
264
|
+
step_number = i + 1
|
|
265
|
+
|
|
266
|
+
# Extract the content between this heading and the next (or end of file)
|
|
267
|
+
start_pos = match.end()
|
|
268
|
+
end_pos = matches[i + 1].start() if i + 1 < len(matches) else len(body)
|
|
269
|
+
step_content = body[start_pos:end_pos].strip()
|
|
270
|
+
|
|
271
|
+
# Parse the step content as YAML-like key-value pairs
|
|
272
|
+
try:
|
|
273
|
+
step_data = _parse_step_content(step_content, step_title, file_path)
|
|
274
|
+
step_data["step_number"] = step_number
|
|
275
|
+
step_data["step_title"] = step_title
|
|
276
|
+
|
|
277
|
+
step = Step(**step_data)
|
|
278
|
+
steps.append(step)
|
|
279
|
+
except Exception as e:
|
|
280
|
+
raise ParseError(f"Failed to parse step {step_number} ('{step_title}') in {file_path}: {e}")
|
|
281
|
+
|
|
282
|
+
return steps
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _collect_indented_lines(lines: list[str], start: int) -> list[str]:
|
|
286
|
+
"""Collect a contiguous run of indented lines starting at ``lines[start]``.
|
|
287
|
+
|
|
288
|
+
Stops at the first non-indented line (i.e. the next sibling step field at
|
|
289
|
+
column 0, or end of content). Indented lines that themselves start with
|
|
290
|
+
``- `` are included — those are YAML list children of the parent key, not
|
|
291
|
+
sibling step fields. Empty lines also break the run, mirroring the
|
|
292
|
+
original parser semantics.
|
|
293
|
+
"""
|
|
294
|
+
collected: list[str] = []
|
|
295
|
+
j = start
|
|
296
|
+
while j < len(lines):
|
|
297
|
+
next_line = lines[j]
|
|
298
|
+
if next_line and (next_line.startswith(" ") or next_line.startswith("\t")):
|
|
299
|
+
collected.append(next_line)
|
|
300
|
+
j += 1
|
|
301
|
+
else:
|
|
302
|
+
break
|
|
303
|
+
return collected
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _structured_step_keys() -> frozenset[str]:
|
|
307
|
+
"""Author-facing step keys whose value is a list, dict or nested model.
|
|
308
|
+
|
|
309
|
+
Only these may be written as a YAML *flow* collection (``[a, b]`` /
|
|
310
|
+
``{k: v}``) on the ``- key:`` line itself. The restriction is not fussiness:
|
|
311
|
+
a bare attribute selector is indistinguishable from a flow sequence, and
|
|
312
|
+
``- selector: [data-testid="row"]`` is how 2 981 steps across the live
|
|
313
|
+
projects are written. Parsing every bracketed value as YAML would turn each
|
|
314
|
+
of them into a one-element list.
|
|
315
|
+
|
|
316
|
+
Derived from the model so a new list-valued field works without editing the
|
|
317
|
+
parser — and so a field that changes type cannot leave a stale entry here.
|
|
318
|
+
"""
|
|
319
|
+
keys: set[str] = set()
|
|
320
|
+
for name, field in Step.model_fields.items():
|
|
321
|
+
annotation = str(field.annotation)
|
|
322
|
+
nested_model = "GuideText" in annotation or "GuideCursor" in annotation
|
|
323
|
+
if "list[" in annotation or "dict[" in annotation or nested_model:
|
|
324
|
+
keys.add(field.alias or name)
|
|
325
|
+
return frozenset(keys)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
#: Computed once: ``Step.model_fields`` does not change at run time.
|
|
329
|
+
_STRUCTURED_STEP_KEYS = _structured_step_keys()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _parse_step_content(content: str, step_title: str, file_path: Path) -> dict[str, Any]:
|
|
333
|
+
"""
|
|
334
|
+
Parse the YAML-like key-value list under a step heading.
|
|
335
|
+
|
|
336
|
+
Format:
|
|
337
|
+
- key: value
|
|
338
|
+
- key: value
|
|
339
|
+
- multi-line-key:
|
|
340
|
+
nested: value
|
|
341
|
+
nested2: value
|
|
342
|
+
"""
|
|
343
|
+
step_data = {}
|
|
344
|
+
|
|
345
|
+
# Split into lines and process
|
|
346
|
+
lines = content.split("\n")
|
|
347
|
+
i = 0
|
|
348
|
+
|
|
349
|
+
while i < len(lines):
|
|
350
|
+
line = lines[i]
|
|
351
|
+
|
|
352
|
+
# Skip empty lines
|
|
353
|
+
if not line.strip():
|
|
354
|
+
i += 1
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
# A non-blank, non-indented line that isn't a "- " bullet is a
|
|
358
|
+
# malformed step field (e.g. "* click:", "-click:", stray prose).
|
|
359
|
+
# Raising here turns silent field-dropping into a loud, locatable error.
|
|
360
|
+
if not line.strip().startswith("- "):
|
|
361
|
+
raise ParseError(f"Malformed step line (expected '- key: value'): {line.strip()!r}")
|
|
362
|
+
|
|
363
|
+
# Remove "- " prefix
|
|
364
|
+
line = line.strip()[2:]
|
|
365
|
+
|
|
366
|
+
# A bullet with no colon dropped its field silently before — surface it.
|
|
367
|
+
if ":" not in line:
|
|
368
|
+
raise ParseError(f"Malformed step line (expected '- key: value'): {('- ' + line)!r}")
|
|
369
|
+
|
|
370
|
+
key, _, value = line.partition(":")
|
|
371
|
+
key = key.strip()
|
|
372
|
+
value = value.strip()
|
|
373
|
+
|
|
374
|
+
# YAML block scalar indicator (`|`, `|-`, `|+`, `>`, `>-`, `>+`) —
|
|
375
|
+
# the actual value spans the indented lines that follow, and newline
|
|
376
|
+
# handling depends on the indicator. Defer to PyYAML so we get correct
|
|
377
|
+
# multiline strings for `custom` step `code`, multiline `value`, etc.
|
|
378
|
+
# Without this branch the parser would store the literal indicator
|
|
379
|
+
# as the value and silently drop the indented body, producing invalid
|
|
380
|
+
# generated code (a stray `|` character in the test file).
|
|
381
|
+
if value in ("|", "|-", "|+", ">", ">-", ">+"):
|
|
382
|
+
nested_lines = _collect_indented_lines(lines, i + 1)
|
|
383
|
+
if nested_lines:
|
|
384
|
+
try:
|
|
385
|
+
yaml_text = f"{key}: {value}\n" + "\n".join(nested_lines)
|
|
386
|
+
parsed = yaml.safe_load(yaml_text)
|
|
387
|
+
step_data[key] = parsed[key]
|
|
388
|
+
except (yaml.YAMLError, KeyError, TypeError) as e:
|
|
389
|
+
raise ParseError(f"Failed to parse block scalar value for '{key}': {e}")
|
|
390
|
+
i = i + 1 + len(nested_lines)
|
|
391
|
+
continue
|
|
392
|
+
# No indented body — fall through and store the indicator literally
|
|
393
|
+
# so validation surfaces the malformed scenario instead of swallowing it.
|
|
394
|
+
|
|
395
|
+
# If value is empty, check if next lines are indented (nested dict or list)
|
|
396
|
+
if not value:
|
|
397
|
+
nested_lines = _collect_indented_lines(lines, i + 1)
|
|
398
|
+
|
|
399
|
+
if nested_lines:
|
|
400
|
+
# Use PyYAML for any nested block — handles dict children,
|
|
401
|
+
# list children (`- item`), and multi-level structures alike.
|
|
402
|
+
try:
|
|
403
|
+
yaml_text = f"{key}:\n" + "\n".join(nested_lines)
|
|
404
|
+
parsed = yaml.safe_load(yaml_text)
|
|
405
|
+
step_data[key] = parsed[key]
|
|
406
|
+
except (yaml.YAMLError, KeyError, TypeError) as e:
|
|
407
|
+
raise ParseError(f"Failed to parse nested content for '{key}': {e}")
|
|
408
|
+
i = i + 1 + len(nested_lines)
|
|
409
|
+
continue
|
|
410
|
+
|
|
411
|
+
# A YAML flow collection on the `- key:` line itself, for the keys whose
|
|
412
|
+
# value is a collection. CLAUDE.md has documented `- layouts: [desktop,
|
|
413
|
+
# mobile]` in five places while the parser stored the literal string
|
|
414
|
+
# "[desktop, mobile]" and validation then rejected it as not-a-list.
|
|
415
|
+
# Restricted to _STRUCTURED_STEP_KEYS because a bare attribute selector
|
|
416
|
+
# is shaped exactly like a flow sequence.
|
|
417
|
+
if key in _STRUCTURED_STEP_KEYS and (
|
|
418
|
+
(value.startswith("[") and value.endswith("]")) or (value.startswith("{") and value.endswith("}"))
|
|
419
|
+
):
|
|
420
|
+
try:
|
|
421
|
+
step_data[key] = yaml.safe_load(value)
|
|
422
|
+
except yaml.YAMLError as e:
|
|
423
|
+
raise ParseError(f"Failed to parse inline value for '{key}': {e}")
|
|
424
|
+
i += 1
|
|
425
|
+
continue
|
|
426
|
+
|
|
427
|
+
# Handle simple key: value — strip surrounding quotes for consistency
|
|
428
|
+
# with the YAML-parsed nested blocks, which strip them too.
|
|
429
|
+
step_data[key] = value.strip('"').strip("'")
|
|
430
|
+
i += 1
|
|
431
|
+
|
|
432
|
+
return step_data
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def parse_scenarios_from_directory(directory: Path, tag_filter: Optional[list[str]] = None) -> list[Scenario]:
|
|
436
|
+
"""
|
|
437
|
+
Parse all scenario files in a directory.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
directory: Directory containing .md scenario files.
|
|
441
|
+
tag_filter: Optional list of tags to filter scenarios. Only scenarios with ALL specified tags will be included.
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
List of parsed Scenario instances.
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
ParseError: If any scenario file fails to parse.
|
|
448
|
+
"""
|
|
449
|
+
if not directory.exists():
|
|
450
|
+
raise ParseError(f"Scenarios directory not found: {directory}")
|
|
451
|
+
|
|
452
|
+
if not directory.is_dir():
|
|
453
|
+
raise ParseError(f"Not a directory: {directory}")
|
|
454
|
+
|
|
455
|
+
scenario_files = sorted(directory.glob("*.md"))
|
|
456
|
+
|
|
457
|
+
if not scenario_files:
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
scenarios = []
|
|
461
|
+
for file_path in scenario_files:
|
|
462
|
+
try:
|
|
463
|
+
scenario = parse_scenario_file(file_path)
|
|
464
|
+
|
|
465
|
+
# Apply tag filter if specified
|
|
466
|
+
if tag_filter and not scenario.has_all_tags(tag_filter):
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
scenarios.append(scenario)
|
|
470
|
+
except ParseError:
|
|
471
|
+
raise # Re-raise parse errors
|
|
472
|
+
except Exception as e:
|
|
473
|
+
raise ParseError(f"Unexpected error parsing {file_path}: {e}")
|
|
474
|
+
|
|
475
|
+
return scenarios
|