steppy-dev 0.3.0__py3-none-any.whl

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