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
@@ -0,0 +1,1335 @@
1
+ Metadata-Version: 2.5
2
+ Name: steppy-dev
3
+ Version: 0.3.0
4
+ Summary: Generate Playwright pytest suites from Markdown scenarios, and turn their screenshots into multi-locale user guides
5
+ Project-URL: Homepage, https://github.com/Steppy-dev/steppy
6
+ Project-URL: Source, https://github.com/Steppy-dev/steppy
7
+ Project-URL: Changelog, https://github.com/Steppy-dev/steppy/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/Steppy-dev/steppy/issues
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE
11
+ License-File: NOTICE
12
+ Keywords: code-generation,e2e,markdown,playwright,pytest,screenshots,testing,user-guide
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Framework :: Pytest
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3 :: Only
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Documentation
23
+ Classifier: Topic :: Software Development :: Code Generators
24
+ Classifier: Topic :: Software Development :: Quality Assurance
25
+ Classifier: Topic :: Software Development :: Testing
26
+ Requires-Python: >=3.12
27
+ Requires-Dist: httpx>=0.27.0
28
+ Requires-Dist: jinja2>=3.1.0
29
+ Requires-Dist: markdown-it-py>=3.0
30
+ Requires-Dist: playwright<1.61,>=1.60.0
31
+ Requires-Dist: pydantic>=2.0.0
32
+ Requires-Dist: pytest-playwright>=0.5.0
33
+ Requires-Dist: pytest>=8.0.0
34
+ Requires-Dist: python-frontmatter>=1.0.0
35
+ Requires-Dist: pyyaml>=6.0
36
+ Requires-Dist: rich>=13.0.0
37
+ Requires-Dist: typer>=0.9.0
38
+ Provides-Extra: bundle
39
+ Requires-Dist: zstandard>=0.22.0; extra == 'bundle'
40
+ Provides-Extra: report
41
+ Requires-Dist: pytest-html>=4.0.0; extra == 'report'
42
+ Provides-Extra: retry
43
+ Requires-Dist: pytest-rerunfailures>=14.0; extra == 'retry'
44
+ Description-Content-Type: text/markdown
45
+
46
+ <h1 align="center">
47
+ <picture>
48
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Steppy-dev/steppy/main/docs/brand/steppy-mark-dark.svg">
49
+ <img alt="" src="https://raw.githubusercontent.com/Steppy-dev/steppy/main/docs/brand/steppy-mark.svg" width="96">
50
+ </picture>
51
+ <br>
52
+ Steppy
53
+ </h1>
54
+
55
+ <p align="center"><em>Every walkthrough, proven.</em></p>
56
+
57
+ Python CLI that generates Playwright pytest files from Markdown scenario files, then turns the captured screenshots into multi-locale user guides (standalone HTML, Angular components, or a static site).
58
+
59
+ - **Code generator, not a runtime engine** — generated tests are standalone pytest files you can run with or without Steppy.
60
+ - **Markdown-first** — scenarios are `.md` files with YAML frontmatter, no DSL to learn.
61
+ - **Multi-locale + multi-layout capture** — one run produces screenshots for every (locale × viewport) combination.
62
+ - **Three guide modes** — `stepbystep` (annotated screenshots with an animated cursor), `interactive` (a Lit web component that replays the flow), and `video` (a narrated MP4 synthesized from the same captures, per locale and layout).
63
+ - **In-app tours (opt-in)** — the same scenarios compile into guided popovers over the live app, and CI replays every tour.
64
+ - **Optional parallel execution** — opt-in Docker Compose orchestration for large suites.
65
+
66
+ ---
67
+
68
+ ## Installation
69
+
70
+ The package is named **`steppy-dev`**, because `steppy` on PyPI is an unrelated
71
+ project, which a pip command naming plain `steppy` installs. Use `steppy-dev` wherever pip
72
+ reads a name, extras included (`steppy-dev[report]`). The command you run and the
73
+ module you import are both `steppy`.
74
+
75
+ ### Option 1 — From PyPI (simplest)
76
+
77
+ ```bash
78
+ pip install steppy-dev
79
+
80
+ # With the optional extras (HTML report, --retries, zstd bundles)
81
+ pip install "steppy-dev[report,retry,bundle]"
82
+ ```
83
+
84
+ ### Option 2 — Directly from the git repo
85
+
86
+ Pin a release tag rather than a branch — see the
87
+ [releases page](https://github.com/Steppy-dev/steppy/releases) for the latest (the
88
+ examples below use `vX.Y.Z` / `X.Y.Z` as placeholders). The player JS bundle is
89
+ committed to git, so `pip` can build a wheel from the source tree with no Node
90
+ toolchain involved:
91
+
92
+ ```bash
93
+ # Pinned to a tag (recommended)
94
+ pip install "steppy-dev @ git+https://github.com/Steppy-dev/steppy.git@vX.Y.Z"
95
+
96
+ # With the optional extras (HTML report, --retries, zstd bundles)
97
+ pip install "steppy-dev[report,retry,bundle] @ git+https://github.com/Steppy-dev/steppy.git@vX.Y.Z"
98
+
99
+ # Latest main
100
+ pip install git+https://github.com/Steppy-dev/steppy.git
101
+ ```
102
+
103
+ ### Option 3 — From a GitHub Release wheel
104
+
105
+ Faster install (no clone, no build) and version-pinned:
106
+
107
+ ```bash
108
+ pip install "https://github.com/Steppy-dev/steppy/releases/download/vX.Y.Z/steppy_dev-X.Y.Z-py3-none-any.whl"
109
+ ```
110
+
111
+ ### Option 4 — In CI / `requirements.txt`
112
+
113
+ Every form above works unchanged in CI — no token is needed. In `requirements.txt`:
114
+
115
+ ```
116
+ steppy-dev[report,retry]==X.Y.Z
117
+ # or, from git:
118
+ steppy-dev[report,retry] @ git+https://github.com/Steppy-dev/steppy.git@vX.Y.Z
119
+ ```
120
+
121
+ For GitHub Actions, `steppy init --ci` scaffolds a complete workflow, and
122
+ [`.github/workflows/steppy-e2e.yml`](.github/workflows/steppy-e2e.yml) is the same
123
+ recipe as a reusable workflow you can call with `uses:`.
124
+
125
+ ### Option 5 — From source, editable (for Steppy development)
126
+
127
+ Required if you're contributing to Steppy itself.
128
+
129
+ ```bash
130
+ git clone https://github.com/Steppy-dev/steppy.git
131
+ cd steppy
132
+
133
+ python -m venv venv
134
+ .\venv\Scripts\activate # Windows
135
+ # source venv/bin/activate # Linux/macOS
136
+ pip install -e .
137
+ ```
138
+
139
+ If you modify the player TypeScript (`packages/player/src/`), rebuild and re-vendor the JS into the Python package, then commit the result:
140
+
141
+ ```bash
142
+ npm --prefix packages/player install # once
143
+ npm --prefix packages/player run build
144
+ for f in steppy-player steppy-cursor-legacy steppy-video; do
145
+ cp packages/player/dist/$f.js packages/player/dist/$f.js.map src/steppy/_player_bundle/
146
+ done
147
+ git add src/steppy/_player_bundle/*.js src/steppy/_player_bundle/*.js.map
148
+ ```
149
+
150
+ Only those three are vendored: `dist/` also holds the in-app tour overlay (`steppy-tour.js`), which has no Python consumer and is meant for apps through npm (`@steppy/player/tour`; publish-ready, not on npm yet: until then, depend on a built `packages/player` or its `npm pack` tarball).
151
+
152
+ The release pipeline rebuilds these from scratch for each tagged release, but the committed copies are what makes `pip install git+...` work for everyone else without needing Node.
153
+
154
+ ### After install
155
+
156
+ ```bash
157
+ # Install Playwright browsers (one-time)
158
+ playwright install chromium
159
+
160
+ # Verify
161
+ steppy --help
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Releasing a new version (maintainers)
167
+
168
+ The release pipeline lives in [.github/workflows/release.yml](.github/workflows/release.yml) and runs on tag push.
169
+
170
+ ```bash
171
+ # 1. Bump version in pyproject.toml
172
+ # version = "X.Y.Z"
173
+
174
+ git add pyproject.toml
175
+ git commit -m "Release vX.Y.Z"
176
+
177
+ # 2. Tag and push
178
+ git tag vX.Y.Z
179
+ git push origin main --tags
180
+ ```
181
+
182
+ `packages/*/package.json` carry the same version (`tests/test_versions_aligned.py`
183
+ enforces it), so bump them in the same commit.
184
+
185
+ CI then:
186
+ 1. Validates the tag matches `pyproject.toml`'s `version` and the npm packages' versions.
187
+ 2. Builds the player bundles (`npm run build`).
188
+ 3. Vendors all three into `src/steppy/_player_bundle/`.
189
+ 4. Builds the Python wheel and sdist.
190
+ 5. Sanity-checks that the three bundles, their third-party license file, and `LICENSE`/`NOTICE` are in the wheel (the build fails loudly if any is missing).
191
+ 6. Creates a GitHub Release with the wheel and sdist attached.
192
+
193
+ ---
194
+
195
+ ## Quickstart
196
+
197
+ ```bash
198
+ # 1. Scaffold a project (config.yaml, an example scenario, .gitignore, schema)
199
+ steppy init # add --ci to also drop in a GitHub Actions workflow
200
+
201
+ # 2. Edit config.yaml (app URLs, roles, credentials) and write your scenarios
202
+ # in scenarios/*.md (see STEPPY-REFERENCE.md)
203
+
204
+ # 2b. (optional) Bootstrap a scenario draft from a live click-through
205
+ steppy record https://app.example.com --role admin --slug my-flow
206
+
207
+ # 3. Validate them
208
+ steppy validate
209
+
210
+ # 4. Generate the pytest files
211
+ steppy generate
212
+
213
+ # 5. Run them
214
+ steppy run --scenario my-flow
215
+
216
+ # 6. (optional) Capture screenshots and assemble a guide
217
+ steppy run --guide
218
+ steppy build
219
+ ```
220
+
221
+ ---
222
+
223
+ ## CLI reference
224
+
225
+ ### Scaffold
226
+
227
+ ```bash
228
+ steppy init # scaffold in the current directory
229
+ steppy init my-project # scaffold into a subdirectory
230
+ steppy init --ci # also write a GitHub Actions workflow
231
+ steppy init --no-skills # skip the packaged Claude skills
232
+ steppy init --app-dir ../invoice-app # also put the app-side skill in the app's repository
233
+ ```
234
+
235
+ `init` writes `config.yaml` (with a `$schema` header for editor autocomplete),
236
+ `scenarios/example--first-flow.md`, `credentials.yaml.example`,
237
+ `steppy-config.schema.json`, and a `.steppy/scaffold.json` manifest, and appends
238
+ the right `.gitignore` entries. It never overwrites existing files, so it is safe
239
+ to re-run in an existing project: it adds what is missing, merges the manifest, and
240
+ leaves the starter scenario out when `scenarios/` already has some.
241
+
242
+ By default it also installs four packaged Claude skills into `.claude/skills/`,
243
+ split by job so an AI assistant in the project is expert on the surface it is
244
+ actually being asked about:
245
+
246
+ | Skill | Covers |
247
+ |---|---|
248
+ | `steppy-scenario-author` | the scenario grammar, guides, `steppy build`, PDF and video |
249
+ | `steppy-template` | styling and theming — guides, the player, print, the run report |
250
+ | `steppy-ci-report` | CI wiring, `steppy report`, publishing to Pages, the coverage map and its badge |
251
+ | `steppy-app-tours` | wiring in-app tours into the app's own code: the overlay, the Angular adapter, test-ids, CSP |
252
+
253
+ Pass `--no-skills` to skip them. `--ci` / `--ci-gitlab` add the matching CI
254
+ pipeline. The app-side skill is only useful where the app's code is, which is often
255
+ another repository: `--app-dir <app>` installs it there too, and records it so
256
+ `steppy update` keeps it current.
257
+
258
+ ```bash
259
+ steppy update # reconcile a project after upgrading steppy
260
+ steppy update --check # report-only: scan for deprecated usage (CI-able)
261
+ ```
262
+
263
+ `update` runs **after** you upgrade the `steppy-dev` package (pip/uv/pipx own the
264
+ upgrade itself). It refreshes the Steppy-owned scaffold assets — the config
265
+ schema, the Claude skills, and the CI workflow — from the newly-installed
266
+ version: files you haven't touched are replaced silently, and any file you've
267
+ edited is left intact with the new version written alongside as `<file>.new` (so
268
+ you never lose edits, and never silently miss an update). Skills shipped since the
269
+ project was scaffolded are installed, and skill folders copied in by hand before
270
+ Steppy packaged them (`steppy`, `steppy-angular-export`) are reported for you to
271
+ delete. Starter files you own (`config.yaml`, your scenarios) are never touched. It then reports any deprecated
272
+ scenario/config usage and the changelog highlights since your last version.
273
+ `steppy update --check` is the report-only compat scan on its own — it writes
274
+ nothing and exits non-zero on breaking usage, so CI can gate on it.
275
+
276
+ ### Record
277
+
278
+ ```bash
279
+ steppy record https://app.example.com --role admin --slug my-flow # record a draft scenario
280
+ steppy record --role admin --slug my-flow # start URL defaults to the role's app URL
281
+ steppy record --slug my-flow --no-auth # fresh session (e.g. to record the login flow)
282
+ steppy record --slug my-flow --placeholderize-all # turn every recorded value into a ${VAR} ref
283
+ ```
284
+
285
+ `record` wraps `playwright codegen`: a headed browser opens, you perform the flow, and
286
+ closing the window writes a **draft** `scenarios/<slug>.md` — a reviewable starting point,
287
+ not a finished scenario. Refine it with the `steppy-scenario-author` skill, then `steppy
288
+ validate` / `steppy generate`.
289
+
290
+ It records as an **authenticated** session by default (loading `.auth/<role>.json` if
291
+ present — run `steppy auth login <role>` first) and **never bakes secrets**: a
292
+ username/password/submit sequence collapses to a single `- action: login` (credentials
293
+ come from `config.yaml`), password/token fields become `${VAR}` env refs, and the command
294
+ prints a ready-to-paste `screenshot.mask` block to redact passwords in screenshots.
295
+ Selectors prefer `data-testid`; the selector lint runs on the output.
296
+
297
+ > **V1 (CLI plumbing).** A foundational recorder — the client-facing browser-extension
298
+ > version with a visual blur picker and instant preview is a later phase. Recorded
299
+ > selectors and mappings are best-effort against Playwright codegen output; always review
300
+ > the draft before committing.
301
+
302
+ ### Scenarios
303
+
304
+ ```bash
305
+ steppy validate # validate all scenarios
306
+ steppy validate scenarios/my-flow.md # validate a single file
307
+ steppy list # tabular view of all scenarios
308
+ steppy list --tag e2e # filtered
309
+ ```
310
+
311
+ ### Generate
312
+
313
+ ```bash
314
+ steppy generate # regenerate all test files
315
+ steppy generate scenarios/my-flow.md # specific scenario
316
+ steppy generate --tag e2e # filter by tag
317
+ steppy generate --dry-run # preview changes
318
+ steppy generate --force # overwrite without prompting
319
+ ```
320
+
321
+ ### Run
322
+
323
+ ```bash
324
+ steppy run # run all generated tests
325
+ steppy run --scenario my-flow # specific scenario
326
+ steppy run --tag e2e # filter by tag
327
+ steppy run --headed # visible browser
328
+ steppy run --screenshots # enable screenshot capture
329
+ steppy run --no-asserts # skip `expect()` calls
330
+ steppy run --locales fr,nl # parametrize over locales
331
+ steppy run --layouts desktop,mobile # parametrize over viewports
332
+ steppy run --guide # guide-capture mode (see below)
333
+ steppy run --trace # save a Playwright trace per FAILED test
334
+ steppy run -- -v -x --tb=short # passthrough args to pytest
335
+ ```
336
+
337
+ See [Continuous integration](#continuous-integration-opt-in) for JUnit/HTML
338
+ reports and the shipped GitHub Actions template.
339
+
340
+ ### Guide capture and assembly
341
+
342
+ ```bash
343
+ steppy run --guide # runs every `guide.enabled` scenario
344
+ # across all locales × layouts,
345
+ # auto-enables screenshots + --no-asserts
346
+
347
+ steppy guide --tag user-guide-admin --locale fr
348
+ steppy guide --guide # auto-discovers all user-guide-* tags
349
+ # from `guide.enabled` scenarios
350
+ steppy guide --guide --pdf # + one PDF next to each guide
351
+ steppy guide --guide --handbook # + one handbook (HTML + PDF) per
352
+ # role/locale/layout
353
+ steppy guide --guide --video # + a narrated MP4 (+ .vtt + poster)
354
+ # next to each guide (needs ffmpeg)
355
+ ```
356
+
357
+ Captures land in `output/screenshots/` and guides in `output/guides/`. Both are
358
+ configurable in one place — `output.screenshots_dir` / `output.guides_dir` in
359
+ `config.yaml` — and the capture side and every reader (`guide`, `build`,
360
+ `bundle`, the parallel failure harvest) resolve the same value, so they cannot
361
+ drift apart. `--screenshots-dir` / `--output-dir` still override for one
362
+ invocation. See [Artifact output paths](STEPPY-REFERENCE.md#8-artifact-output-paths)
363
+ for what happens if you point them outside `output/`.
364
+
365
+ See [PDF export](#pdf-export-opt-in) and [Video guide mode](#video-guide-mode-opt-in) below.
366
+
367
+ ### Build
368
+
369
+ ```bash
370
+ steppy build # target chosen by steppy.guide.config.ts
371
+ steppy build --config path/to/config.ts
372
+ steppy build --dry-run
373
+ steppy build --pdf --handbook # + per-page PDFs and a site handbook
374
+ # (static target)
375
+ steppy build --video # + a narrated MP4 per locale/layout for
376
+ # each guide declaring modes: ['video']
377
+
378
+ steppy video check # is ffmpeg/Chromium/the voice ready?
379
+ ```
380
+
381
+ See [the `steppy build` section](#steppy-build) and [Video guide mode](#video-guide-mode-opt-in) below.
382
+
383
+ ### In-app tours (opt-in)
384
+
385
+ With `tours: {enabled: true}` in `config.yaml`, `validate` also compiles the
386
+ tours of every `tour:`-enabled scenario, and `generate` writes their files and
387
+ appends a CI replay to each scenario's test.
388
+
389
+ ```bash
390
+ steppy tours # write the tour files only (tests untouched)
391
+ steppy tours --check # CI: exit 1 when the app's committed tour files are stale
392
+ steppy tours --build-id 2026.10 # stamp another build id (this command only)
393
+ steppy tours --explain # also say why each step was left out
394
+ ```
395
+
396
+ See [In-app tours](#in-app-tours-opt-in-1) below.
397
+
398
+ ### Authentication
399
+
400
+ ```bash
401
+ steppy auth login editor # interactive (headed) login
402
+ steppy auth status # cached session state per role
403
+ steppy auth clear # clear everything
404
+ steppy auth clear editor # clear a single role
405
+ ```
406
+
407
+ ### Seed scripts
408
+
409
+ ```bash
410
+ steppy seed base # run seed/base.py via subprocess
411
+ ```
412
+
413
+ ### Parallel execution (opt-in)
414
+
415
+ ```bash
416
+ steppy run --workers 3 # 3 isolated Docker stacks
417
+ steppy run --workers 3 --no-build # skip auto-build of worker image
418
+ steppy stacks cleanup # rescue orphaned steppy-run-* projects
419
+ ```
420
+
421
+ Requires a `parallel:` block in `config.yaml` + per-app `internal_url`. See [Parallel execution](#parallel-execution-opt-in) below.
422
+
423
+ ### Coverage map (opt-in)
424
+
425
+ ```bash
426
+ steppy run --coverage # record route/API/JS signals + merge into the map
427
+ steppy run --coverage --scenario my-flow # works for a single scenario in isolation
428
+ steppy coverage refresh # pull declared surface from openapi.json (+ sitemap)
429
+ steppy coverage report --json gaps.json # tested / declared-unreached / discovered-uncovered
430
+ steppy coverage triage set /webhooks/stripe --method POST --api my-api --flag non_ui
431
+ steppy coverage merge --run-id <id> # rescue: re-merge a recorded run
432
+ ```
433
+
434
+ Requires a `coverage:` block in `config.yaml` for manifest/API attribution. See [Coverage map](#coverage-map-opt-in-1) below.
435
+
436
+ ### Results bundle (opt-in)
437
+
438
+ ```bash
439
+ steppy run --bundle # record per-test events + JUnit XML alongside the run
440
+ steppy bundle create # package output/ into output/bundles/steppy-bundle-<id>.tar.zst
441
+ steppy bundle create --dry-run # show what would be archived, write nothing
442
+ steppy bundle inspect <bundle> # print the manifest without extracting
443
+ steppy bundle verify <bundle> # re-hash every member against the manifest
444
+ ```
445
+
446
+ A portable, self-describing archive of one run. See [Results bundles](#results-bundles-opt-in-1) below.
447
+
448
+ ---
449
+
450
+ ## `steppy build`
451
+
452
+ Generates guide artifacts from a TypeScript config. The config's `target` field picks the output:
453
+
454
+ - `static` (default) — self-contained, framework-agnostic static site (HTML pages,
455
+ manifest, no runtime deps); embed anywhere, including via the `@steppy/react` /
456
+ `@steppy/vue` wrapper packages
457
+ - `angular` — standalone Angular 21 components + routes + ngx-translate i18n bundles
458
+ (set `target: 'angular'` explicitly)
459
+
460
+ ### Config (`steppy.guide.config.ts`)
461
+
462
+ ```ts
463
+ import { defineGuideConfig } from 'steppy';
464
+
465
+ export default defineGuideConfig({
466
+ scenariosRoot: './e2e/scenarios',
467
+ screenshotsDir: '../steppy/output/screenshots',
468
+ outputDir: './src/app/features/guides/generated',
469
+ assetsDir: './public/assets/editor',
470
+ role: 'editor',
471
+ locales: ['fr', 'nl'],
472
+ defaultLocale: 'fr',
473
+ layouts: ['desktop', 'mobile'],
474
+ defaultLayout: 'desktop',
475
+ site: { title: 'Demo Guides' },
476
+ guides: [
477
+ { slug: 'onboarding', title: 'Onboarding', scenario: 'editor/onboarding.md', modes: ['stepbystep'] },
478
+ { slug: 'invoices', title: 'Invoices', scenario: 'editor/invoices.md', modes: ['stepbystep', 'interactive', 'video'] },
479
+ ],
480
+ navbar: { order: ['onboarding', 'invoices'] },
481
+ });
482
+ ```
483
+
484
+ Install the types package in your Angular app: `"steppy": "file:../path-to-steppy/packages/steppy"` in `devDependencies`. The host app also needs `@ngx-translate/core` — generated components inject `TranslateService` and resolve titles via the translate pipe.
485
+
486
+ > **Why two config languages?** Tests use `config.yaml` (no toolchain beyond
487
+ > Python); builds use `steppy.guide.config.ts` because it buys type-checked DX
488
+ > via `defineGuideConfig`, and `steppy build` already requires Node for `tsx`
489
+ > regardless. If you build guides without Node, a JSON config variant is a
490
+ > possible future addition.
491
+
492
+ ### Angular output
493
+
494
+ ```
495
+ outputDir/
496
+ <role>.routes.ts # exports <ROLE>_ROUTES (lazy-loaded)
497
+ <role>.nav.json # navigation tree (translation-key references)
498
+ i18n/<role>/<locale>.json # per-locale bundle, keyed under uppercase <ROLE>
499
+ {slug}/
500
+ {slug}.component.ts # standalone component, ngx-translate driven
501
+ {slug}.content.ts # CONTENT[locale][layout] HTML fragments
502
+ {slug}-interactive.component.ts # if modes includes 'interactive'
503
+ {slug}-video.component.ts # if modes includes 'video' and --video was passed
504
+ {slug}-stepbystep-{locale}.html # human-readable debug fragment
505
+ _groups/<group>.group.component.ts # if groups configured
506
+ _onboarding/onboarding.component.ts # if onboarding enabled
507
+ ```
508
+
509
+ Screenshots are copied to `assetsDir`. Image URLs are absolute `/assets/<role>/<slug>/[<locale>/][<layout>/]step-NN.png` — point `assetsDir` at your host's `public/assets/<role>/` to wire them up directly.
510
+
511
+ Integration:
512
+
513
+ ```ts
514
+ // app.routes.ts
515
+ import { EDITOR_ROUTES } from './features/guides/generated/editor.routes';
516
+ export const routes: Routes = [
517
+ { path: 'guides', children: EDITOR_ROUTES },
518
+ ];
519
+
520
+ // app.config.ts — merge generated i18n bundles into TranslateService
521
+ import frTranslations from './features/guides/generated/i18n/editor/fr.json';
522
+ import nlTranslations from './features/guides/generated/i18n/editor/nl.json';
523
+ ```
524
+
525
+ Steppy never touches `app.routes.ts` or your navbar — integration is purely via imports.
526
+
527
+ ### Static output
528
+
529
+ Set `target: 'static'` in the config. Emits a self-contained site with HTML pages at `<slug>/<locale>.html`, an `index.html` per locale, a `manifest.json`, a shared `assets/guides.css`, `.nojekyll` and `_headers`. Screenshots co-located under `outputDir/assets/<slug>/`. Deploy anywhere static.
530
+
531
+ A guide gets one sibling page per extra mode — `<slug>/interactive.html` for the tour, `<slug>/video.html` for the narrated MP4 — and each guide page links whichever of them it has.
532
+
533
+ ### SEO / indexing (`seo` block)
534
+
535
+ Static builds are **private by default** — every page gets `<meta name="robots" content="noindex,nofollow,noarchive">` and a `robots.txt` with `Disallow: /`, so an accidentally-exposed client guide site is not indexable. For a public showcase site, opt in:
536
+
537
+ ```ts
538
+ export default defineGuideConfig({
539
+ // …
540
+ target: 'static',
541
+ seo: {
542
+ mode: 'public', // default: 'private'
543
+ baseUrl: 'https://guides.example.com', // required when public
544
+ description: { fr: 'Guides produit', nl: 'Producthandleidingen' }, // optional site-wide default
545
+ openGraph: true, // optional OG / Twitter cards
546
+ lastmod: '2026-09-14', // optional <lastmod> on every sitemap entry
547
+ },
548
+ guides: [
549
+ { slug: 'onboarding', title: 'Onboarding', scenario: 'onboarding.md',
550
+ description: 'Get started in 5 minutes' }, // optional per-guide meta description
551
+ ],
552
+ });
553
+ ```
554
+
555
+ Public mode emits `sitemap.xml` (absolute URLs + per-locale `hreflang` alternates), a `robots.txt` pointing at it, `<link rel="canonical">` + `hreflang` alternates (with `x-default`) on every indexable page, `<meta name="description">` (guide `description` → its title; index uses `seo.description` → site title), and — when `openGraph: true` — Open Graph / Twitter tags (`og:image` is a guide's first screenshot). Indexing controls are **defense-in-depth, not access control** — keep private sites behind auth. Static target only (the Angular host owns its own `<head>`).
556
+
557
+ **One canonical URL per guide.** A guide is rendered up to four ways — `<slug>/<locale>.html` per locale, plus `interactive.html` and `video.html` — from the same steps. The per-locale pages are canonical; `interactive.html` and `video.html` point `rel=canonical` at the default-locale one and stay out of `sitemap.xml`, so a guide occupies one indexable URL per language rather than three or four near-identical ones. The site root is canonical as `https://…/` rather than `https://…/index.html` for the same reason. Bare directory URLs (`/<slug>/`, `/<group>/`, `/getting-started/`) serve a meta-refresh stub to the default locale; stubs are never listed in the sitemap. The handbook (`--handbook`) carries `noindex` and is excluded too — it duplicates every guide on the site.
558
+
559
+ **`sitemap.xml` is generated, not templated.** Steppy ships no `sitemap.xml.j2`; the document is built by `src/steppy/sitemap.py`. Dropping a `templates/static/sitemap.xml.j2` into your project still takes over — that is the way to reach the parts of the protocol Steppy does not emit, such as `<image:image>` over a guide's screenshots, `<video:video>`, or a `<sitemapindex>` split. The context is `urls` (each with `.loc`, `.alternates[].hreflang` / `.href`, `.lastmod`) plus `lastmod`.
560
+
561
+ **`lastmod` is opt-in.** With `seo.lastmod` unset no `<lastmod>` is emitted at all, which is what keeps a build byte-reproducible. There is deliberately no `'auto'` mode: `actions/checkout` stamps every file at clone time, so an mtime-derived value would read as "now" on every build — which crawlers learn to ignore — and screenshots are not in git at all. Feed it the commit date instead (`git log -1 --format=%cI`). Note that a single site-wide value over-reports, since a commit touching one guide did not change the others; it buys standards-compliance and control, not ranking. `<changefreq>` and `<priority>` are not emitted — Google and Bing both ignore them.
562
+
563
+ **Locales must be BCP-47 — on both sides.** `config.yaml`'s `locales:`, `steppy run --locales`, `steppy guide --locale` and the build config's `locales` are all validated at load, because every one of them becomes a screenshot directory segment that the other side reads back; an unchecked entry only defers the failure to a place that cannot explain it. Each entry in `locales` is validated at config load (`fr`, `fr-BE`, `pt-BR`, `zh-Hant`, `es-419`, …), because a single malformed `hreflang` makes a search engine discard the *whole* alternates cluster. The tag you write is kept **verbatim** in filenames and screenshot directories — it is also your app's i18n key and your `guide-text:` map key — while `hreflang`, `<html lang>` and `og:locale` get the canonical spelling, so `fr_BE` is accepted and becomes `<html lang="fr-BE">` at `…/fr_BE.html`.
564
+
565
+ A regional locale resolves *content* through its bare language, so adding `fr-BE` does not mean rewriting every scenario: `guide-text: { fr: … }`, a `{fr: …}` `LocalizedText` map and an `i18n.localstorage.values` entry keyed `fr` all serve `fr-BE`, and an exact `fr-BE:` key still wins where you write one. When the app cannot be switched into a locale at all — no matching `i18n.localstorage.values` entry, not even by language — the run **warns** rather than skipping quietly, because the screenshots get captured and filed under that locale either way.
566
+
567
+ ---
568
+
569
+ ## PDF export (opt-in)
570
+
571
+ Two flags, on both `steppy guide` and `steppy build`. Steppy does not lay out a PDF itself — it prints the guide pages it already renders through headless Chromium, so the `@media print` rules in the templates are exactly what the PDF shows (no animated cursor, a step never split across a page break, tall captures scaled to fit instead of clipped).
572
+
573
+ | Flag | `steppy guide` | `steppy build` (static target) |
574
+ |---|---|---|
575
+ | `--pdf` | `<slug>.pdf` next to every generated guide page | `<slug>/<locale>.pdf` per guide page, plus a "PDF" link in the page header |
576
+ | `--handbook` | `<role>-handbook.html` + `.pdf` per (role, locale, layout) | `handbook/<locale>.html` + `.pdf` for the whole site, linked from the index |
577
+
578
+ ```bash
579
+ steppy run --guide # capture first
580
+ steppy guide --guide --pdf --handbook
581
+ steppy build --pdf --handbook
582
+ ```
583
+
584
+ The **handbook** is every guide in one document — cover, table of contents, then each guide starting on a fresh page (grouped under their group titles in the build target, ordered by slug in `steppy guide`). It is written as real HTML next to the PDF, so it doubles as a single-page read.
585
+
586
+ Paper settings live in `config.yaml` and are shared by both commands:
587
+
588
+ ```yaml
589
+ pdf:
590
+ format: A4 # A0–A6 | Letter | Legal | Tabloid | Ledger
591
+ landscape: false
592
+ margin: { top: 12mm, right: 12mm, bottom: 14mm, left: 12mm }
593
+ print_background: true # keep the step badges / role chip coloured
594
+ scale: 1.0 # 0.1–2.0
595
+ page_numbers: true # footer: guide title + "N / M"
596
+ timeout: 30000 # ms per rendered page
597
+ ```
598
+
599
+ Notes:
600
+
601
+ - **Chromium is required** (`python -m playwright install chromium`); the error says so if it is missing.
602
+ - One browser renders the whole batch, so a large (scenarios × locales × layouts) project pays a single launch.
603
+ - A page whose screenshots don't finish loading is still printed, with a warning — a PDF missing one image beats no PDF.
604
+ - Multi-layout build pages print the guide's **default layout** (one PDF per guide and locale). The layout toggle is a viewing preference; the file name stays predictable.
605
+ - Chromium's print-to-PDF cannot emit PDF bookmarks or page numbers inside a table of contents, so the handbook's TOC links to in-document anchors only.
606
+ - The handbook page carries `noindex` (in both SEO modes) and stays out of `sitemap.xml` — it duplicates every guide's content, which search engines penalise. It is the one static page that does not include the shared SEO `<head>` partial, so it carries no canonical either. Override `templates/guide.book.html.j2` if you want it indexed.
607
+ - **A PDF cannot carry a `robots` meta**, so the published `handbook/<locale>.pdf` used to be the only indexable form of a page that is deliberately `noindex` — and `<slug>/<locale>.pdf` competes the same way with its own HTML. Every static build now emits a `_headers` sidecar setting `X-Robots-Tag: noindex` on PDFs (on everything, in private mode). **Read the limitation**: Netlify and Cloudflare Pages honour `_headers`; GitHub Pages and GitLab Pages — the two the shipped CI scaffolds target — ignore it, and neither can serve a custom header at all. It is emitted for the same reason `.nojekyll` is: meaningful where it is read, harmless where it is not, and not access control anywhere.
608
+ - `robots.txt` and `_headers` are only honoured at the **origin root**. A GitHub Pages *project* site is served under `/<repo>/`, so a correct `seo.baseUrl` there carries a path and both files land somewhere nothing reads them; the build warns when it detects this. Canonical links, `hreflang` and `sitemap.xml` are absolute and unaffected, and every page still carries its own robots meta.
609
+ - Angular builds reject `--pdf`/`--handbook`: that target emits components for a host app to render, so there is no page for Steppy to print.
610
+ - To retheme, override `--steppy-print-max-image-height` (default `180mm`) in your `templates/` copy — it is the cap that keeps a full-page capture on one page.
611
+
612
+ ---
613
+
614
+ ## Video guide mode (opt-in)
615
+
616
+ Narrated walkthrough videos, synthesized from the screenshots and cursor
617
+ choreography a run already captured — one MP4 per (guide × locale × layout).
618
+ Nothing is screen-recorded: Steppy replays the same timeline the interactive
619
+ player uses, frame by frame, in headless Chromium, and pipes the stills to
620
+ ffmpeg. So the video is a function of the scenario, not of how fast the render
621
+ machine happens to be, and it regenerates on every release in every language
622
+ alongside the tests that prove the flow still works.
623
+
624
+ > Not to be confused with `steppy run --video`, which records Playwright
625
+ > debugging footage of *failed tests*. Different command, different artifact.
626
+
627
+ **Two gates, both required.** The guide declares the mode (a content decision,
628
+ versioned in the build config); the flag arms the render (a cost decision — a
629
+ render is minutes per guide, and a paid TTS provider bills per character).
630
+
631
+ ```ts
632
+ // steppy.guide.config.ts
633
+ guides: [
634
+ { slug: 'invoices', title: 'Invoices', scenario: 'editor/invoices.md',
635
+ modes: ['stepbystep', 'interactive', 'video'] },
636
+ ]
637
+ ```
638
+
639
+ ```bash
640
+ steppy video check # verify ffmpeg / Chromium / voice, in 5s
641
+ steppy run --guide --locales fr,nl --layouts desktop,mobile
642
+ steppy build --video # MP4 + WebVTT + poster per guide/locale/layout
643
+ steppy guide --video # same, beside each generated guide page
644
+ steppy video cache # what the caches hold; --prune-older-than / --max-size / --clear
645
+ ```
646
+
647
+ | Flag | `steppy guide` | `steppy build` |
648
+ |---|---|---|
649
+ | `--video` | `<slug>.mp4` + `.vtt` + `.jpg` next to every generated guide page | `<locale>[-<layout>].mp4` per guide, a `<slug>/video.html` player page (static) or a `<slug>-video.component.ts` host (Angular) |
650
+
651
+ Both build targets are supported. The static target emits a sibling
652
+ `video.html` (like `interactive.html`) with a locale/layout switcher; Angular
653
+ emits a component wired into the existing mode toggle, with the media served
654
+ from `assetsDir` like every other asset.
655
+
656
+ ### Narration
657
+
658
+ Voiceover text is the per-locale `guide-text` you already wrote — there is no
659
+ new authoring surface. The provider is chosen entirely in config:
660
+
661
+ ```yaml
662
+ video:
663
+ fps: 24 # cost knob: a render is frames × screenshot latency
664
+ width: 1280 # height follows the capture aspect, so a mobile
665
+ # layout yields a portrait video. NOTE: cursor travel
666
+ # is timed over display distance, so changing width
667
+ # changes the video's duration.
668
+ captions: band # band | off
669
+ crf: 23
670
+ preset: medium
671
+ intro_ms: 1200
672
+ outro_ms: 1500
673
+ min_step_ms: 1500 # floor on a step's on-screen time
674
+ narration_pad_ms: 400 # breathing room after the voiceover
675
+ branding: { logo: ./assets/logo.png, accent: '#d24f2c' }
676
+ stamp:
677
+ enabled: true # burn a provenance line into every frame
678
+ text: "{version} · {date}"
679
+ version: ${STEPPY_RELEASE:-dev}
680
+ tts:
681
+ provider: none # captions only — the default: no install, no bill
682
+ voice: { fr: fr_FR-siwis-medium, nl: nl_NL-mls-medium }
683
+ api_key: ${ELEVENLABS_API_KEY}
684
+ ```
685
+
686
+ | `provider` | What it is |
687
+ |---|---|
688
+ | `none` *(default)* | Burned-in captions, silent track. No install, no network, no cost. |
689
+ | `piper` | Free, offline, neural, deterministic. `pip install piper-tts` ([OHF-Voice/piper1-gpl](https://github.com/OHF-Voice/piper1-gpl)); voices download on first use into `.steppy/voices/`. Run as a subprocess, never imported — it is GPL-3.0 and deliberately not a Steppy dependency. |
690
+ | `espeak` | Free, offline, no download. Audibly robotic; a fallback, not a finish. |
691
+ | `elevenlabs` `openai` `azure` `google` | Cloud voices. Set `api_key` (and `region` for Azure). |
692
+ | `http` | Any REST API, described directly under `video.tts.http` — no preset, no code, no Steppy release needed. |
693
+ | *your own* | Register a `steppy.tts_providers` entry point. |
694
+
695
+ Swapping providers is a config edit and nothing else: the vendor presets are
696
+ data rows over one generic HTTP engine, so no code path branches on vendor.
697
+
698
+ Notes:
699
+
700
+ - **ffmpeg is required**, including with `tts.provider: none` — a silent captioned video still has to be encoded. `steppy video check` tells you before a long render does.
701
+ - **Chromium is required** (`python -m playwright install --with-deps chromium`). Use `--with-deps`: a bare install has no CJK/Arabic fonts, and captions are burned into the frame permanently.
702
+ - Put the API key in the environment (`${ELEVENLABS_API_KEY}`), not in `config.yaml` — a plaintext key earns a hygiene warning, and an unresolved `${VAR}` is rejected with the variable name rather than becoming a confusing vendor 401.
703
+ - Frames are deduplicated: a hold or a voiceover pause reuses the previous still instead of re-screenshotting. Holds dominate a narrated video, so this is most of the render budget.
704
+ - Each step is stretched to fit its voiceover, so narration is never cut off mid-sentence.
705
+ - A WebVTT caption track and a poster frame are emitted beside every MP4.
706
+
707
+ ### What an edit actually costs
708
+
709
+ Narration is synthesized **one clip per step**, keyed on the *text* rather than
710
+ the step's position, and assembled onto the timeline afterwards. So editing one
711
+ sentence re-synthesizes one sentence — reordering steps, inserting a step, or
712
+ adding a guide that reuses a line costs nothing at all, and a paid provider is
713
+ never re-billed for text that has not changed.
714
+
715
+ Videos are cached whole, because they have to be: a step's duration changes with
716
+ its narration, and every later step shifts with it, so no rendered frame
717
+ survives an edit. The two caches therefore invalidate on different things.
718
+
719
+ | Cache | Key includes | So it re-runs when |
720
+ |---|---|---|
721
+ | `.steppy/tts-cache/<sha>.wav` | provider, engine version (or the `http` request shape), text, locale, voice, model, speed, format | that step's wording changes — nothing else |
722
+ | `.steppy/video-cache/<sha>.mp4` | the manifest, **screenshot bytes**, clip bytes and durations, branding, the player bundle, and the pixel-affecting `video:` settings | any of those change. Re-capturing screenshots re-renders even if the manifest is identical; setting an API key or a timeout does **not** |
723
+
724
+ Whitespace is normalized before hashing, so re-wrapping a `guide-text` line is
725
+ not a re-bill. Both caches are written atomically — an interrupted render leaves
726
+ no entry rather than a truncated one that would be served forever.
727
+
728
+ Both grow monotonically. `steppy video cache` reports what they hold;
729
+ `--prune-older-than <days>` and `--max-size <MB>` reclaim least-recently-*used*
730
+ entries first (a lookup touches what it reads, so a clip rebuilt into every
731
+ release stays young), `--clear` empties them, and `--tts` / `--video` scope any
732
+ of those to one cache.
733
+
734
+ ---
735
+
736
+ ## In-app tours (opt-in)
737
+
738
+ The scenarios that test a flow and document it can also teach it inside the
739
+ app: a tour is a sequence of popovers over the live application, each pointing
740
+ at the element a step acts on. Steppy compiles tours from your scenarios —
741
+ never by hand — and **CI replays every tour**, so renaming a test-id a tour
742
+ depends on fails the build with the tour, the step and the test-id named,
743
+ instead of breaking the tour in production.
744
+
745
+ 1. Enable tours and say where your app serves them from:
746
+
747
+ ```yaml
748
+ # config.yaml
749
+ tours:
750
+ enabled: true
751
+ targets:
752
+ - type: app-assets
753
+ path: ../invoice-app/public/tours
754
+ ```
755
+
756
+ 2. Opt a scenario in with `tour: true` in its frontmatter (or a `tour:` block
757
+ with an id, a title, an entry route). Steps can be fine-tuned with
758
+ `tour: skip|keep`, `tour-text`, `tour-advance` and `tour-placement`.
759
+
760
+ 3. Mount the overlay once in your app and start a tour from a button. Import
761
+ `@steppy/player/tour` from your app's own code (it registers the element),
762
+ then:
763
+
764
+ ```html
765
+ <steppy-tour-host fallback-manifest="/tours/tours.manifest.json"></steppy-tour-host>
766
+ ```
767
+
768
+ Angular apps use `@steppy/angular` instead: `provideSteppyTours()`,
769
+ `<steppy-tours />` once in the shell, and the `SteppyTours` service.
770
+
771
+ | What | Where |
772
+ |---|---|
773
+ | Canonical tour files (manifest + one file per tour and locale, SRI-pinned) | `output/tours/<build-id>/` |
774
+ | The copy your app bundles | each `app-assets` target (flat) |
775
+ | The copy a static guide site serves, for remote mode | `<site>/tours/<build-id>/` (`steppy build`, `guide` target) |
776
+ | The CI replay | `test_<slug>__tour` in the scenario's own test file |
777
+
778
+ A step points at one test-id value, never a CSS selector; tour files are data,
779
+ never code; the overlay never clicks for the user. The full reference —
780
+ configuration, annotations, how steps are inferred, the host element's API and
781
+ events, [localization](docs/tours.md#localization) (buttons ship in English,
782
+ French, Dutch and German; the tour follows the app's language mid-tour), React,
783
+ Vue and Angular wiring, theming, CSP and CORS, the replay — is
784
+ [docs/tours.md](docs/tours.md). `fixtures/tour-demo/` (plain JavaScript) and
785
+ `fixtures/tour-demo-angular/` (Angular, PrimeNG, ngx-translate) are working
786
+ examples driven by the same scenarios.
787
+
788
+ ## Parallel execution (opt-in)
789
+
790
+ `steppy run --workers N` spins up N isolated Docker Compose stacks pulling scenarios from a shared queue. **Strictly opt-in** — `steppy run` (no flags) and `steppy run --workers 1` never touch Docker.
791
+
792
+ ### When to use it
793
+
794
+ A 1-hour sequential suite typically completes in ~20–25 min with `--workers 3`. Each stack runs its own copy of your app + chromium, so you need RAM + CPU headroom.
795
+
796
+ ### Setup
797
+
798
+ 1. Add a `parallel:` block to `config.yaml` and an `internal_url` per role:
799
+
800
+ ```yaml
801
+ apps:
802
+ editor:
803
+ url: "http://localhost:4200" # host-facing (single-worker mode)
804
+ internal_url: "http://editor-app:3000" # inside the docker network
805
+ # ...
806
+ parallel:
807
+ workers: 3 # default if --workers omitted
808
+ compose_file: "docker-compose.steppy.yml"
809
+ # Optional — capture auth at the in-network origin before dispatching
810
+ # workers. Needed for `mode: storage-state` (see "Pre-authentication in
811
+ # worker mode" below); omit it entirely if you don't use storage-state auth.
812
+ # pre_auth:
813
+ # command: "python auth_login.py --internal"
814
+ ```
815
+
816
+ 2. Make sure your `compose_file` declares healthchecks for every service referenced by `depends_on: condition: service_healthy`. The coordinator pre-flights this and refuses to start otherwise.
817
+
818
+ 3. Run:
819
+
820
+ ```bash
821
+ steppy run --workers 3
822
+ ```
823
+
824
+ First parallel run auto-builds the `steppy-worker:<version>` image (~3 min). Subsequent runs reuse it.
825
+
826
+ ### Prerequisites
827
+
828
+ - **Docker Compose 2.20+** (uses the `!reset` YAML tag to clear inherited port mappings).
829
+ - **Per-role `internal_url`** for every role used by selected scenarios.
830
+ - **Healthchecks** on every service referenced via `depends_on: condition: service_healthy`.
831
+
832
+ ### Rescue command
833
+
834
+ If a parallel run is force-killed before tearing down its stacks:
835
+
836
+ ```bash
837
+ steppy stacks cleanup
838
+ ```
839
+
840
+ Lists every `steppy-run-*` Compose project and tears each one down.
841
+
842
+ ### Parallel + guide mode
843
+
844
+ `steppy run --workers N --guide` composes cleanly. Each scenario is claimed by exactly one worker; that worker's pytest invocation handles every (locale × layout) cell sequentially in one process — browser launched once, auth amortized. Screenshots land in the shared `output/` bind mount at the same paths a single-worker `--guide` run produces, so `steppy guide --guide` and `steppy build` (pure disk readers) work unchanged.
845
+
846
+ ### Scenarios that must not run concurrently (`exclusive:`)
847
+
848
+ Work stealing assumes every scenario is independent of every other. Some are not:
849
+ two flows that mutate the same record, drain the same mailbox, or consume the same
850
+ third-party sandbox cannot be in flight at once. Declare an **exclusion group** in
851
+ frontmatter and Steppy will never run two of its members at the same time:
852
+
853
+ ```yaml
854
+ ---
855
+ title: Checkout with a stored card
856
+ exclusive: billing
857
+ ---
858
+ ```
859
+
860
+ Any scenario naming `billing` is serialised against every other one that does.
861
+ Different groups still run concurrently, and ungrouped scenarios are untouched —
862
+ so `{s1, s3}` in `billing` and `{s2, s5}` in `mail` gives you `s1 ∥ s2` and
863
+ `s3 ∥ s5`, but never `s1 ∥ s3`.
864
+
865
+ Mechanically, the coordinator collapses each group into a **single queue task**
866
+ before any container starts; one worker claims it and runs the members in
867
+ sequence, one pytest process each. There is no lock and no waiting state, so a
868
+ worker dying mid-group is reported and retried like any other failure. The run
869
+ prints its census next to the queue line:
870
+
871
+ ```
872
+ queued 11 scenario(s) in 6 task(s) across 4 worker(s)
873
+ exclusion groups: billing[3], mail[2] — 2 group(s) serialising 5 scenario(s) into 2 task(s)
874
+ ```
875
+
876
+ Four things worth knowing before you declare one:
877
+
878
+ - **It costs wall-clock.** The run can never finish faster than the group's total
879
+ duration. A group is free while that total stays under *(suite total ÷ workers)*;
880
+ past that, the makespan is the heaviest group's total.
881
+ - **Non-overlap only** — not ordering, and not indivisibility. Members may run in
882
+ any order, and a retry pass legitimately re-runs only the ones that failed.
883
+ - **The name is your assertion, and nothing verifies it.** Two differently-named
884
+ groups that actually contend on the same system still run concurrently. If a
885
+ flow touches two shared resources, every scenario touching *either* must use the
886
+ same name — naming the combination (`stripe-and-mailbox`) does **not** exclude
887
+ it against a scenario named `stripe`. `steppy validate` warns about a group with
888
+ a single member, which is what a typo looks like.
889
+ - **Scope is one run.** Two concurrent `steppy run` invocations — CI plus a dev
890
+ box against one shared sandbox — do not see each other's groups. And it is inert
891
+ without `--workers N ≥ 2`, correctly: one pytest process is already sequential.
892
+
893
+ ### Pre-authentication in worker mode (`pre_auth`)
894
+
895
+ `mode: storage-state` auth needs care in parallel mode. Playwright storage state is **origin-bound** — a session captured on the host (`http://localhost:4200`) does *not* authenticate a worker, which reaches the app at its `internal_url` (`http://web:80`). Left alone, every scenario in a worker run redirects to login.
896
+
897
+ `parallel.pre_auth` closes the gap: before dispatching workers, the coordinator runs your `command` in a one-off `steppy-preauth` container **inside the stack network**, gated (Compose `service_completed_successfully`) to finish before any worker claims a scenario. Point it at whatever logs in at the internal origin and writes `.auth/<role>.json`:
898
+
899
+ ```yaml
900
+ parallel:
901
+ pre_auth:
902
+ command: "python auth_login.py --internal" # runs in-network; captures the internal origin
903
+ timeout: 600 # optional (default 600s)
904
+ # image: my/custom:tag # optional (defaults to the worker image)
905
+ ```
906
+
907
+ - The command runs with `STEPPY_BASE_URL_OVERRIDE` (`{role: internal_url}`) in its env, the project mounted read-only, and `.auth/` mounted read-write — so it can resolve each role's `internal_url` and write the cache all workers share.
908
+ - It's **deduped once per run** across the N stacks: an O_EXCL lock elects a single winner that logs in; the other stacks wait for its sentinel. Make the command idempotent (skip roles whose session is still fresh) to keep repeat runs cheap.
909
+ - The preauth container waits for every app service that declares a **healthcheck** before running — add a healthcheck to your web/frontend service so the login doesn't race startup.
910
+ - Leaving `pre_auth` unset renders no preauth service and keeps dispatch byte-identical to before. (`bypass` / `token` auth, or a host-and-internal same-origin setup, don't need it.)
911
+
912
+ ### Known caveats
913
+
914
+ - **Shared `.auth/<role>.json`.** With `auth.storage_state_ttl > 0` (default 3600s), one worker's cached session may be reused by another worker whose stack has different signing keys. If each stack runs an isolated app + DB, set `auth.storage_state_ttl: 0` to force per-worker re-auth. For `mode: storage-state`, remember sessions are also **origin-bound** — use `pre_auth` (above) to capture the in-network origin, or workers will redirect to login.
915
+ - **Seed scripts run inside the worker container.** Hard-coded `http://localhost:5432` URLs are unreachable. Have seeds read the `STEPPY_BASE_URL_OVERRIDE` JSON env var (`{role: internal_url}`) and fall back to the host URL when unset (single-worker mode).
916
+ - **Only `output/` comes back from the container.** The project itself is mounted read-only and `output/` is the one writable bind mount, so an `output.screenshots_dir` pointing outside it cannot produce host-visible captures. The coordinator warns at startup if you have configured one.
917
+ - **v0.1 maturity.** Parallel mode is single-machine and Linux-container only (Docker + Compose 2.20+). A worker crash mid-scenario is fatal — the scenario is not requeued (`Queue.orphaned_running_tasks()` reports it). Use `steppy stacks cleanup` to tear down orphaned stacks after a force-kill.
918
+
919
+ ---
920
+
921
+ ## Continuous integration (opt-in)
922
+
923
+ Steppy runs anywhere pytest runs. Two pieces make it CI-native:
924
+
925
+ - **Reports** — set a `report:` block in `config.yaml` and `steppy run` emits the
926
+ matching artifacts:
927
+
928
+ ```yaml
929
+ report:
930
+ junit_xml: "./output/reports/junit.xml" # native JUnit (no extra deps)
931
+ html: "./output/reports/report.html" # needs: pip install 'steppy-dev[report]'
932
+ trace: on-failure # off | on | on-failure
933
+ ```
934
+
935
+ JUnit XML is ingested natively by most CI systems. `trace: on-failure` (or the
936
+ `steppy run --trace` flag) saves a Playwright trace zip under `output/traces/`
937
+ for each failed test — open it with `playwright show-trace <zip>` for a
938
+ step-by-step, DOM-snapshotted replay. The per-failure full-page screenshot +
939
+ console/network dump under `output/screenshots/_failures/` is always produced.
940
+
941
+ - **Workflow templates** — ready-to-copy pipelines ship in the package for both
942
+ vendors: `templates/ci/github-actions.yml` and `templates/ci/gitlab-ci.yml`
943
+ (checkout → install → `playwright install chromium` → `steppy generate` →
944
+ `steppy run --bundle` → `steppy report` → upload). `steppy init --ci` writes the
945
+ GitHub one, `--ci-gitlab` the GitLab one; map your credential secrets onto the
946
+ `${VAR}` refs in `config.yaml`. Each carries a commented, opt-in Pages job — see
947
+ [Publishing to Pages](#publishing-to-pages).
948
+
949
+ ---
950
+
951
+ ## Run report (opt-in)
952
+
953
+ `steppy report` turns one run into a self-contained static site: what passed, what
954
+ failed, **which step it failed on**, and the screenshot at that moment.
955
+
956
+ ```bash
957
+ steppy run --bundle # records per-test and per-step events
958
+ steppy report # → output/report/index.html
959
+ ```
960
+
961
+ It reads a live `output/` tree, a `.tar.zst`/`.tar.gz` results bundle, or a
962
+ directory somebody already unpacked one into — all three produce the same report:
963
+
964
+ ```bash
965
+ steppy report # this project's output/
966
+ steppy report steppy-bundle-*.tar.zst # a bundle downloaded from CI
967
+ steppy report ./unpacked-bundle -o public # after tar -xf
968
+ ```
969
+
970
+ ### What it shows
971
+
972
+ - **Failure causes, grouped.** One broken selector across forty scenarios reads as
973
+ one row with a count, not forty tracebacks — the same normalisation the parallel
974
+ console summary uses. The full tail is shown once per cause.
975
+ - **The failing step, named.** `Failed at step 7 — Submit the order`, with the
976
+ step timeline around it (title, action, elapsed) and the captures for each step.
977
+ Step numbers are the scenario's own and stay sparse: a step skipped by
978
+ `only-in:` or `layouts:` is a gap, never renumbered.
979
+ - **A scenario × (locale, layout) matrix**, built from what actually ran — a flow
980
+ deselected by a disjoint frontmatter `layouts:` does not grow an empty column.
981
+ - **Coverage buckets and the e2e-surface percentage**, when the run recorded them.
982
+ - **`report.json`** — the same model, versioned (`steppy_report: 1`) and
983
+ deterministic, so CI can diff two of them. Shape:
984
+ [`contracts/report.schema.json`](contracts/report.schema.json).
985
+
986
+ ### Screenshots
987
+
988
+ `--screenshots failed` (the default) copies the captures of variants that did not
989
+ pass, plus every failure screenshot. `all` copies everything — worth knowing that
990
+ on a 4-locale × 2-layout corpus that is hundreds of megabytes. `none` renders text
991
+ only. Captures identical across layouts are copied once.
992
+
993
+ Traces and videos are **named, not copied**: a Playwright trace needs
994
+ `playwright show-trace` to open, so a link would be a dead end.
995
+
996
+ ### Honest about what it cannot know
997
+
998
+ A report of a run made without `--bundle` says so and lists the scenarios it found
999
+ captures for, rather than rendering an empty page. A run with no per-step events
1000
+ says *that* instead of drawing an empty timeline. Coverage rebuilt from a detached
1001
+ bundle's raw records refuses to report assert *strength*, because the scenario
1002
+ files it would need to count assert steps in never travel — every endpoint would
1003
+ otherwise read as weakly covered.
1004
+
1005
+ **The exit code describes rendering, not the run.** A report of a failing run exits
1006
+ 0. Gate your build on `steppy run`, or read `totals.exit_code` from `report.json`.
1007
+
1008
+ ### Publishing to Pages
1009
+
1010
+ The site is relative and self-contained, so publishing it is a copy. Both shipped
1011
+ CI templates carry the job, commented out, plus the warnings below.
1012
+
1013
+ > **Pages is public.** Your screenshots show your app with real-looking data in it.
1014
+ > `screenshot.mask` redacts fields at capture time and the report marks every page
1015
+ > `noindex` with a `Disallow: /` robots.txt — but a leak is permanent and cannot be
1016
+ > taken back. This is for showcase and open-source repos; for client work, read the
1017
+ > report from the CI artifact instead. GitLab Pages can be restricted to project
1018
+ > members, which is the one place it beats GitHub's for this.
1019
+
1020
+ Deploy as an **artifact, never a `gh-pages` branch** — screenshots committed on
1021
+ every run grow the repository's history forever.
1022
+
1023
+ ### Coverage badge
1024
+
1025
+ With `--coverage` recorded, the site includes a shields.io endpoint at
1026
+ `badge.json`:
1027
+
1028
+ ```markdown
1029
+ ![e2e surface](https://img.shields.io/endpoint?url=https://<owner>.github.io/<repo>/badge.json)
1030
+ ```
1031
+
1032
+ The number is **`tested / (declared − non_ui)`** over your declared routes and API
1033
+ endpoints — not line coverage. Declare `coverage.non_ui` in `config.yaml` first
1034
+ (health checks, webhook receivers, OAuth callbacks, internal cron): triage flags
1035
+ set with `steppy coverage triage` live in the gitignored `.steppy/coverage.db`, so
1036
+ a fresh CI checkout has none and those endpoints would count against you.
1037
+
1038
+ ---
1039
+
1040
+ ## Results bundles (opt-in)
1041
+
1042
+ A **results bundle** is one run's artifacts in a single portable file: a `.tar.zst` (or `.tar.gz`) holding your `output/` tree **exactly as Steppy wrote it** — no path rewriting — plus three generated members.
1043
+
1044
+ ```
1045
+ manifest.json what this run was, what it produced, sha256 of every file
1046
+ junit.xml every worker's JUnit XML merged into one document
1047
+ events/ per-test records: <scenario>__<locale>-<layout>.jsonl
1048
+ output/ screenshots, sidecars, coverage records, guides, failures, traces, videos
1049
+ ```
1050
+
1051
+ Because the tree is unrewritten, `tar -xf` then `steppy guide` or `steppy build` just works — the bundle is an archive of a run, not a new format to convert back out of.
1052
+
1053
+ ### Producing one
1054
+
1055
+ ```bash
1056
+ steppy run --guide --bundle # capture events + JUnit alongside the normal run
1057
+ steppy bundle create # → output/bundles/steppy-bundle-<run-id>.tar.zst
1058
+ ```
1059
+
1060
+ `--bundle` is opt-in because it changes the pytest invocation. Without it a bundle is still valid, but `manifest.tests[]` will be empty — there is nothing on disk recording what passed.
1061
+
1062
+ It also records step boundaries, so a failure names the step rather than only the test:
1063
+
1064
+ ```
1065
+ outcome=failed scenario=order-lifecycle locale=fr layout=mobile failed_step=7
1066
+ ```
1067
+
1068
+ `failed_step` is the step that started and never finished, which is why a test that dies in fixture teardown reports `null` instead of blaming its last step. Export `STEPPY_STEP_EVENTS=0` to keep everything else and drop the per-step records.
1069
+
1070
+ It composes with everything else, including `--workers N`: each worker writes into the shared `output/` mount under per-scenario filenames, and `steppy bundle create` merges them.
1071
+
1072
+ ### Reading one
1073
+
1074
+ ```bash
1075
+ steppy bundle inspect bundle.tar.zst # manifest summary; --json for the raw manifest
1076
+ steppy bundle inspect bundle.tar.zst --json # feed it to jq, or to a CI annotation
1077
+ steppy bundle verify bundle.tar.zst # re-hash every member; non-zero on any mismatch
1078
+ ```
1079
+
1080
+ ### Compression
1081
+
1082
+ `.tar.zst` when the optional extra is installed, `.tar.gz` otherwise — automatically, with a notice:
1083
+
1084
+ ```bash
1085
+ pip install 'steppy-dev[bundle]' # adds zstandard
1086
+ steppy bundle create --format gz # or force gzip explicitly
1087
+ ```
1088
+
1089
+ An explicitly named output file decides the codec, so `-o run.tar.zst` never silently produces gzip content.
1090
+
1091
+ ### The manifest is a published contract
1092
+
1093
+ `manifest.json` is versioned (`steppy_bundle: 1`) and its shape is committed at
1094
+ [`contracts/bundle-manifest.schema.json`](contracts/bundle-manifest.schema.json),
1095
+ generated from the models in `src/steppy/contracts/` and drift-guarded by a test.
1096
+ Write a consumer against it in any language.
1097
+
1098
+ Two rules make it safe to build on: fields are only ever **added**, never
1099
+ repurposed, and readers default what is absent. So a newer Steppy's bundle stays
1100
+ readable by an older consumer, and an artifact `kind` you have never seen is
1101
+ something to store rather than something to fail on.
1102
+
1103
+ ### Useful properties
1104
+
1105
+ - **Reproducible.** The same `output/` tree bundles to the same bytes: members are sorted, timestamps and ownership are normalised, and the run's own creation time comes from the run rather than the clock.
1106
+ - **Content-addressed.** Every archived file carries its sha256 in the manifest, so a consumer can fetch only what it does not already have.
1107
+ - **Honest about what is missing.** `--max-size` drops the cheapest-to-lose artifacts first (videos, then traces, then guides — all regenerable), sets `truncated: true`, and lists every drop. Screenshots and their sidecars are never dropped.
1108
+
1109
+ ### In CI
1110
+
1111
+ ```yaml
1112
+ - run: steppy run --guide --bundle
1113
+ - run: steppy bundle create
1114
+ - uses: actions/upload-artifact@v4
1115
+ if: always()
1116
+ with:
1117
+ name: steppy-bundle
1118
+ path: output/bundles/
1119
+ ```
1120
+
1121
+ ---
1122
+
1123
+ ## Coverage map (opt-in)
1124
+
1125
+ `steppy run --coverage` records what each scenario actually exercises and merges it into a persistent map, so you can ask the inverse question: **what is NOT yet tested?** Everything is deterministic — no LLM calls in recording, merging, or gap computation — and framework-agnostic (URL, History API, V8 coverage, network requests; the app under test can be Angular, React, Vue, …).
1126
+
1127
+ Three signals per scenario run, captured by the generated conftest (env-gated on `STEPPY_COVERAGE=1`, zero overhead when off):
1128
+
1129
+ - **Route trail** — every URL the run visited, SPA-aware: Playwright's `framenavigated` fires for History-API changes, and an injected hook tags push/replace/pop/hash transitions. Dynamic segments cluster (`/invoices/123` + `/invoices/456` → `/invoices/:id`).
1130
+ - **API endpoints** — every request's `{method, url}`, attributed to a configured API by base-URL prefix and matched against its OpenAPI path templates.
1131
+ - **Source modules** — V8 JS coverage via CDP (Chromium), mapped back to source files through source maps (read from `coverage.sourcemap_dir` on disk; inline `data:` maps captured at record time). Degrades gracefully: no map → bundle-level granularity; non-Chromium → routes/endpoints still work.
1132
+
1133
+ ### Flow
1134
+
1135
+ ```bash
1136
+ steppy run --coverage --scenario my-flow # 1. record (works per-scenario) + auto-merge
1137
+ steppy coverage refresh # 2. declared surface from openapi.json/sitemap.xml
1138
+ steppy coverage report --json gaps.json # 3. gap buckets
1139
+ ```
1140
+
1141
+ The report buckets every endpoint/route as **tested** (declared ∩ exercised; *strong* when an asserting scenario covered it, *weak* when only assert-free or `--no-asserts`/`--guide` runs did), **declared-unreached** (in the manifest, never hit — the key bucket for directing new scenarios), or **discovered-uncovered** (seen in a trail, weakly covered). Endpoints that aren't UI-reachable (payment webhooks, cron callbacks) get a persisted triage flag so they stop polluting the actionable list:
1142
+
1143
+ ```bash
1144
+ steppy coverage triage set /webhooks/stripe --method POST --api my-api --flag non_ui --note "payment callback"
1145
+ ```
1146
+
1147
+ **In CI, declare them in `config.yaml` instead.** The flag above is stored in
1148
+ `.steppy/coverage.db`, which is gitignored — so a fresh checkout has an empty store,
1149
+ every endpoint reads `unknown`, and any percentage computed there is wrong for
1150
+ reasons nobody can fix from CI:
1151
+
1152
+ ```yaml
1153
+ coverage:
1154
+ non_ui:
1155
+ - "GET /health"
1156
+ - "/webhooks/*"
1157
+ - "POST /internal/cron/*"
1158
+ ```
1159
+
1160
+ Grammar is `[<METHOD> ]<glob>`. `*` crosses `/`, a pattern applies across every
1161
+ configured API, and a method-qualified pattern never matches a route.
1162
+ `steppy coverage report` warns about a pattern that matches nothing — a renamed
1163
+ endpoint leaves a stale rule that silently inflates the number. An explicit
1164
+ `triage set --flag ui_testable` still wins over a pattern; nothing is ever written
1165
+ back to the store.
1166
+
1167
+ The percentage this settles is **`tested / (declared − non_ui)`**, printed by
1168
+ `steppy coverage report` and available as a shields.io badge:
1169
+
1170
+ ```bash
1171
+ steppy coverage report --badge output/badge.json # also --badge-surface endpoints|routes|all
1172
+ ```
1173
+
1174
+ ### Storage
1175
+
1176
+ - **Raw records** (lossless observations): `output/coverage/raw/<run_id>/<scenario>/*.json` — one per page fixture. All interpretation happens at merge time, so normalization rules can evolve without recapturing.
1177
+ - **The map**: sqlite at `.steppy/coverage.db` (configurable `coverage.store_path`). Lives outside `output/` so wiping run artifacts never deletes triage flags. Gitignore `.steppy/` (or commit it if you want team-shared triage).
1178
+ - **Merge semantics**: keyed `(node, scenario_id)` and idempotent — re-running a scenario *replaces* its prior contribution; nothing accumulates stale rows. Merge↔refresh order doesn't matter (observed endpoints re-link onto newly declared templates).
1179
+
1180
+ ### Config
1181
+
1182
+ ```yaml
1183
+ coverage:
1184
+ apis:
1185
+ - name: editor-api
1186
+ openapi: "http://localhost:8080/openapi.json" # URL or local file path
1187
+ base_urls: ["http://localhost:8080", "http://editor-backend:8080"]
1188
+ sitemap: null # optional: declares the route surface
1189
+ sourcemap_dir: "../app/dist" # local build output holding *.js.map
1190
+ store_path: "./.steppy/coverage.db"
1191
+ records_dir: "./output/coverage"
1192
+ ```
1193
+
1194
+ `base_urls` must mirror each OpenAPI `servers[].url` (including path prefixes like `/api`) and should include Docker-internal `internal_url` origins so records captured by parallel workers classify correctly. `--coverage` composes with `--scenario`, `--tag`, `--guide` (guide runs record as weak evidence), and `--workers N` (workers record into the shared `output/` mount; the coordinator merges after the run).
1195
+
1196
+ **v1 limits**: a mid-scenario full document navigation may drop pre-navigation JS coverage (CDP limitation; routes/endpoints unaffected); modules have no declared manifest, so they're reported as a covered inventory only; `sourcemap_dir` must correspond to the build actually being served (mismatch falls back to bundle granularity, never an error).
1197
+
1198
+ ---
1199
+
1200
+ ## Project layout
1201
+
1202
+ ```
1203
+ steppy/
1204
+ ├── src/steppy/
1205
+ │ ├── cli.py # CLI entry point (Typer app)
1206
+ │ ├── init_cli.py # `steppy init` — scaffold a project
1207
+ │ ├── update_cli.py # `steppy update` — reconcile with the installed version
1208
+ │ ├── record_cli.py # `steppy record` — click-to-Markdown recorder (draft scenarios)
1209
+ │ ├── coverage_cli.py # `steppy coverage` commands (lazy-imported)
1210
+ │ ├── tours_cli.py # `steppy tours` + the validate/generate tour hooks
1211
+ │ ├── video_cli.py # `steppy video check` — video toolchain readiness
1212
+ │ ├── _cli_common.py # shared CLI helpers (error handling, config load)
1213
+ │ ├── config.py # config loader (config.yaml)
1214
+ │ ├── config_schema.py # config JSON Schema generation + drift guard
1215
+ │ ├── compat.py # kept-forever deprecated aliases
1216
+ │ ├── presets.py # UI-kit selector presets (primeng/material/…)
1217
+ │ ├── models.py # pydantic models (scenarios + config)
1218
+ │ ├── build_models.py # pydantic models for the `steppy build` config
1219
+ │ ├── parser.py # scenario parser + selector lint
1220
+ │ ├── generator.py # codegen → tests/e2e/
1221
+ │ ├── selection.py # scenario/locale/layout selection (shared by run + parallel)
1222
+ │ ├── auth.py # auth manager
1223
+ │ ├── totp.py # RFC 6238 TOTP helper (MFA login)
1224
+ │ ├── guide.py # guide assembler
1225
+ │ ├── build.py # `steppy build` pipeline
1226
+ │ ├── pdf.py # print-to-PDF over generated guide HTML (lazy-imported)
1227
+ │ ├── video.py # narrated MP4 renderer (lazy-imported, leaf module)
1228
+ │ ├── tts/ # voiceover providers: registry, presets, cache, track (lazy-imported)
1229
+ │ ├── coordinator.py # parallel-mode coordinator (lazy-imported)
1230
+ │ ├── worker.py # parallel-mode worker (lazy-imported)
1231
+ │ ├── coverage/ # coverage map: store, normalize, sourcemap, merge, manifest, gaps (lazy-imported)
1232
+ │ ├── tours/ # in-app tour compiler, emitter, replay planner, build publish (lazy-imported)
1233
+ │ ├── contracts/ # versioned wire contracts: results bundle, run report, in-app tours
1234
+ │ ├── naming.py # product-derived names (tour element, kinds, kill switch); TS twin in player/src/ui
1235
+ │ ├── tour_spec.py # in-app tour enums, patterns and size caps (stdlib leaf)
1236
+ │ ├── testids.py # selector → single test-id extraction (stdlib leaf)
1237
+ │ ├── buildid.py # tour build-id grammar + git resolution (stdlib leaf)
1238
+ │ ├── _parallel/ # queue, compose, image, lifecycle, progress
1239
+ │ ├── _player_bundle/ # vendored player + cursor + video JS (committed to git)
1240
+ │ ├── templates/ # bundled fallback templates — GENERATED from root templates/ (gitignored)
1241
+ │ └── skills/ # bundled Claude skills — GENERATED from root skills/ (gitignored)
1242
+ ├── packages/
1243
+ │ ├── player/ # TypeScript: Lit <steppy-guide> + cursor animator + video timeline,
1244
+ │ │ # and the in-app tour overlay <steppy-tour-host> (./tour, not vendored)
1245
+ │ ├── steppy/ # npm types package (defineGuideConfig)
1246
+ │ ├── react/ # @steppy/react — thin <steppy-guide> wrapper
1247
+ │ ├── vue/ # @steppy/vue — thin <steppy-guide> wrapper
1248
+ │ └── angular/ # @steppy/angular — the in-app tour adapter (provideSteppyTours, <steppy-tours>)
1249
+ ├── templates/ # CANONICAL Jinja2 templates — the single source of truth;
1250
+ │ # edit here (the bundled copy above regenerates on build)
1251
+ ├── skills/ # CANONICAL Claude skills (scenario-author, template, ci-report, app-tours);
1252
+ │ # installed by `steppy init`; bundled copy regenerates on build
1253
+ ├── contracts/ # generated JSON Schemas + shared golden fixtures (contracts/fixtures/)
1254
+ ├── docs/ # docs/tours.md (in-app tours), docs/design/, brand assets
1255
+ ├── fixtures/ # demo-guide (build fixture), tour-demo (in-app tour demo app + project),
1256
+ │ # tour-demo-angular (the same demo in Angular + PrimeNG)
1257
+ ├── scenarios/ # example .md scenario files
1258
+ ├── seed/ # seed scripts (run via subprocess)
1259
+ ├── tests/ # unit tests (tests/e2e/ is generated, gitignored)
1260
+ ├── output/ # gitignored: screenshots, assembled guides
1261
+ ├── config.yaml # global configuration
1262
+ ├── pyproject.toml
1263
+ └── .github/workflows/release.yml # tag push → build wheel → GitHub Release
1264
+ ```
1265
+
1266
+ ---
1267
+
1268
+ ## Real-world scenarios
1269
+
1270
+ The `scenarios/` directory ships a small, runnable set that between them exercise
1271
+ every action *family* — a good starting point to copy from:
1272
+
1273
+ - [`admin--create-project.md`](scenarios/admin--create-project.md) — navigate, click, fill, `select-option`, `contains(...)`.
1274
+ - [`admin--edit-settings.md`](scenarios/admin--edit-settings.md) — the component drivers (`expand`, `toggle`, `fill-date`, `pick-time`, `confirm`) + the `count(sel, >=N)` comparator.
1275
+ - [`editor--manage-items.md`](scenarios/editor--manage-items.md) — `within`/`match` disambiguation, `only-in`/`layouts` conditional steps, `pick-date`, plain `select`.
1276
+ - [`cross--task-approval.md`](scenarios/cross--task-approval.md) — a multi-role (editor → admin) flow.
1277
+
1278
+ ---
1279
+
1280
+ ## Troubleshooting
1281
+
1282
+ - **`playwright install`** — the generated tests need browser binaries. Run
1283
+ `playwright install chromium --with-deps` once (per machine / CI runner).
1284
+ - **`steppy build` needs Node** — the guide build reads a TypeScript config via
1285
+ `npx tsx`, so Node ≥ 18 must be on `PATH`. `generate`/`run` do **not** need Node.
1286
+ - **See the full error** — commands print a friendly one-liner by default. Add
1287
+ `--verbose` (or set `STEPPY_DEBUG=1`) to get the full traceback.
1288
+ - **`validate` warnings vs errors** — brittle-selector notices print as yellow
1289
+ `WARNING`s and do **not** fail validation; unknown roles, malformed steps, and a
1290
+ too-new `steppy:` version are red `ERROR`s that do.
1291
+ - **Config typos** — with the `$schema` header that `steppy init` writes, a YAML-aware
1292
+ editor flags unknown keys inline; otherwise `extra = "forbid"` reports them at load.
1293
+ - **"In-app tours for build … not found"** (`steppy build`) — the guide target copies the tree
1294
+ `steppy generate` / `steppy tours` wrote; run one of them first, with the same `tours.build_id`.
1295
+ - **An app's tour covers what a non-Steppy E2E suite clicks** — set
1296
+ `globalThis.__STEPPY_TOURS_DISABLED__ = true` in that suite's browser contexts (Steppy's own
1297
+ generated tests already do). See [docs/tours.md](docs/tours.md#kill-switch).
1298
+
1299
+ ---
1300
+
1301
+ ## Development
1302
+
1303
+ ```bash
1304
+ # Install in editable mode
1305
+ pip install -e .
1306
+
1307
+ # Run unit tests
1308
+ python -m pytest tests/ --ignore=tests/e2e -q
1309
+
1310
+ # Build the player workspace
1311
+ npm --prefix packages/player install
1312
+ npm --prefix packages/player run build
1313
+ npm --prefix packages/player test # Vitest suite
1314
+ npm --prefix packages/player run size:tour # in-app tour overlay size budget
1315
+
1316
+ # In-app tours in Chromium (needs the player build above and `playwright install chromium`)
1317
+ STEPPY_TOUR_E2E=1 python -m pytest tests/integration/test_tour_replay_it.py tests/integration/test_tour_player_it.py -q
1318
+
1319
+ # After editing player TS, re-vendor the three wheel bundles (not the tour overlay)
1320
+ for f in steppy-player steppy-cursor-legacy steppy-video; do
1321
+ cp packages/player/dist/$f.js packages/player/dist/$f.js.map src/steppy/_player_bundle/
1322
+ done
1323
+ ```
1324
+
1325
+ See [STEPPY-REFERENCE.md](STEPPY-REFERENCE.md) for the complete scenario file format reference and [CLAUDE.md](CLAUDE.md) for architectural notes.
1326
+
1327
+ ---
1328
+
1329
+ ## License
1330
+
1331
+ Steppy is licensed under the [Apache License, Version 2.0](LICENSE). See
1332
+ [NOTICE](NOTICE) for attribution, including the third-party code bundled into the
1333
+ player JavaScript.
1334
+
1335
+ Contributions are accepted under the same license — see [CONTRIBUTING.md](CONTRIBUTING.md).