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,567 @@
1
+ ---
2
+ name: steppy-ci-report
3
+ description: |
4
+ How to run Steppy in CI and publish what came out of it. Use whenever a user asks to
5
+ "set up Steppy in CI / GitHub Actions / GitLab CI", "add a CI workflow", "publish the
6
+ test results", "publish to GitHub Pages / GitLab Pages", "build a run report", "show
7
+ which step failed", "why did my report render empty", "add a coverage badge", "what
8
+ isn't my suite testing", "why is my coverage percentage so low", "set up
9
+ coverage.non_ui", "package a run", "attach the results to a build", "tour replay
10
+ failed", "set up in-app tour CI", or edits
11
+ `.github/workflows/steppy.yml`, `.gitlab-ci.yml`, or the `coverage:` / `report:`
12
+ blocks in `config.yaml`.
13
+
14
+ Covers: `steppy report` (the static run-report site), `steppy run --bundle` +
15
+ `steppy bundle`, the coverage map end to end (record → merge → refresh → gaps), the
16
+ e2e-surface badge and the `coverage.non_ui` declaration it depends on, the in-app tour
17
+ replays (`test_<slug>__tour`) and their pipeline order, and the two shipped CI templates.
18
+
19
+ For writing scenarios use `steppy-scenario-author`. For styling guides use
20
+ `steppy-template`. For wiring tours into the app's code use `steppy-app-tours`. This
21
+ skill is about shipping results, not authoring or theming.
22
+ ---
23
+
24
+ # Running Steppy in CI, and publishing the results
25
+
26
+ Three artifacts come out of a run, and they answer different questions:
27
+
28
+ | Artifact | Question it answers | Command |
29
+ |---|---|---|
30
+ | **Run report** | Did it pass, and *which step* failed? | `steppy report` |
31
+ | **Results bundle** | Can I hand this whole run to someone else? | `steppy bundle create` |
32
+ | **Guides** | What does the app look like to a user? | `steppy guide` / `steppy build` |
33
+
34
+ Guides are a different job (see `steppy-scenario-author`). This skill is the first two,
35
+ plus the coverage map that feeds the report's badge.
36
+
37
+ > **One thing to be clear about up front.** `report:` in `config.yaml` is an *unrelated*
38
+ > block — it configures pytest artifacts (JUnit XML, an HTML report, Playwright traces
39
+ > and videos, reruns). The `steppy-dev[report]` extra is `pytest-html`. Neither has anything
40
+ > to do with the `steppy report` command. If a user says "the report", ask which they
41
+ > mean when it is ambiguous.
42
+
43
+ ---
44
+
45
+ ## The CI loop
46
+
47
+ Two shipped templates, both installed by `steppy init`:
48
+
49
+ ```bash
50
+ steppy init --ci # → .github/workflows/steppy.yml
51
+ steppy init --ci-gitlab # → .gitlab-ci.yml
52
+ ```
53
+
54
+ Separate booleans rather than `--ci <vendor>`, because `--ci` is a flag people already
55
+ type. Run `steppy update` after upgrading Steppy and both files are reconciled with the
56
+ installed version.
57
+
58
+ ### Two ways to run it on GitHub
59
+
60
+ The scaffolded workflow is the **owned** copy: every trap documented as a comment beside
61
+ the step it applies to, yours to edit. The alternative is the **reusable** workflow Steppy
62
+ ships, called by ref:
63
+
64
+ ```yaml
65
+ jobs:
66
+ e2e:
67
+ uses: Steppy-dev/steppy/.github/workflows/steppy-e2e.yml@v0.3.0
68
+ with:
69
+ compose-file: docker-compose.yml
70
+ secrets:
71
+ steppy-env: ${{ secrets.STEPPY_ENV }}
72
+ ```
73
+
74
+ Pin a tag, never a branch — that ref selects both the recipe and, by default, the Steppy
75
+ version it installs, so a suite's tooling changes only when someone edits that line.
76
+
77
+ It exposes `verdict`, `exit-code`, `scenarios-not-passed` and `badge-percent` as job
78
+ outputs, read from `report.json`, so a caller can gate or comment on the result without
79
+ re-parsing anything. Credentials arrive as **one optional `steppy-env` secret** holding
80
+ dotenv `KEY=VALUE` lines — the `${VAR}` names in a project's config.yaml belong to that
81
+ project and cannot be declared as individual secrets upstream.
82
+
83
+ The trade against a composite action: a reusable workflow **is** the job, so a caller
84
+ cannot interleave its own steps into it. For an e2e repo whose compose pulls published
85
+ images that costs nothing. Someone who needs to build an image first, in the same job, has
86
+ found the reason to promote it to a composite action.
87
+
88
+ The order that matters:
89
+
90
+ ```bash
91
+ docker compose up -d --wait # 0. the app the scenarios talk to
92
+ steppy generate # 1. rebuild the tests from scenarios
93
+ steppy run --trace --bundle # 2. run them, recording what a report needs
94
+ steppy report # 3. render output/report/
95
+ ```
96
+
97
+ With in-app tours enabled, two steps come *before* step 0, because the app must be built
98
+ with the current tour files: `steppy tours --check` (or `steppy tours` when CI
99
+ builds the app itself), then `docker compose up --build`. See "In-app tour replays" below;
100
+ the shipped CI templates carry both as commented steps to uncomment.
101
+
102
+ ### Step 0 — the stack under test
103
+
104
+ Steppy runs scenarios; it does not run your application. On a single-worker run nothing
105
+ starts your app for you, so CI has to — either `docker compose up` in the job, or an
106
+ `apps.<role>.url` pointing at an already-deployed environment.
107
+
108
+ A hosted runner is just another Linux box with Docker on it: if `docker compose up` then
109
+ `steppy run` works on a developer's laptop against that repo, CI is the same two commands
110
+ plus artifact plumbing. Two properties of **your** compose file decide whether it actually
111
+ works, and both fail the same way — every scenario red, no error that names the cause:
112
+
113
+ - **`--wait` only waits on services that declare a `healthcheck:`.** With none, it returns
114
+ as soon as the containers are created and the run starts against a port that is not
115
+ serving yet. On a suite of any size the circuit breaker then aborts after ~10 identical
116
+ failures, which is the good outcome — it fails in a minute instead of thirty.
117
+ - **The scenarios and the seed scripts run on the runner, not in a container.** So the app
118
+ — and the database, if `seed/*.py` touches it — must publish its ports, and
119
+ `apps.<role>.url` points at `localhost`. (Under `--workers N` this inverts: seeds run
120
+ *inside* the worker container, where host URLs do not resolve.)
121
+
122
+ Tear down with `docker compose down -v` in an always-run step. Without `-v` the next run
123
+ inherits the last one's seeded database, which produces failures that reproduce in CI and
124
+ never locally. Dump `docker compose logs` on failure too — when a suite goes red the first
125
+ question is always "the tests or the stack?", and that is the cheapest way to know.
126
+
127
+ `--workers N ≥ 2` changes this picture entirely: the coordinator brings up **one full
128
+ stack per worker** from `parallel.compose_file` and tears them down itself, so the
129
+ explicit compose steps come out. N copies of an app on one hosted runner is a lot — that
130
+ mode earns its keep on a large suite or a self-hosted runner, not on a thirty-scenario one.
131
+
132
+ **`--bundle` is the load-bearing flag.** It is opt-in because it changes the pytest
133
+ invocation, and without it the report can still tell you a test failed but *not which
134
+ step* — there are no per-step event records to read. It sets three things:
135
+ `STEPPY_EVENTS_DIR` (per-test and per-step JSONL), a fixed-path `--junitxml`, and
136
+ `STEPPY_RUN_ID`. Export `STEPPY_STEP_EVENTS=0` to keep the rest and drop step
137
+ granularity.
138
+
139
+ **`steppy report` exits 0 even when the run failed.** It describes rendering, not the
140
+ run. Gate the build on `steppy run` itself, or read `totals.exit_code` out of
141
+ `report.json`. A report step that goes red for a red run is a step people delete — so
142
+ run it with `if: always()` (GitHub) or in `after_script` (GitLab), because a failing run
143
+ is exactly when it matters.
144
+
145
+ ### In-app tour replays
146
+
147
+ When `config.yaml` has `tours.enabled: true`, every scenario that opts into an in-app tour
148
+ (`tour:` in its frontmatter) gets a second test, **`test_<slug>__tour`**, appended to its own
149
+ test file by `steppy generate`. It is the feature's CI promise: a developer who renames a
150
+ test-id a tour points at gets a red build naming the tour, the step and the test-id, instead
151
+ of a tour that quietly breaks in production. The replay:
152
+
153
+ 1. runs the steps before the tour's start point, as the scenario test does;
154
+ 2. starts the tour through the host element's API;
155
+ 3. for each tour step, checks the popover shows that step anchored on the element it acts
156
+ on (the spotlight around its centre, the popover not covering it), performs **the
157
+ scenario's own action code** for it, and checks the tour moved on;
158
+ 4. checks `tour-complete` fired and no `tour-error` did.
159
+
160
+ Because it lives in the scenario's file, `--scenario` / `--tag` selection, `--workers N`,
161
+ `lastfailed` and JUnit treat it like its scenario — running the replay needs no extra
162
+ step. What does need one is getting the *current* tour files into the app before it
163
+ starts:
164
+
165
+ **The order is the whole trick.** The replay tests the tour files *the running app serves*,
166
+ so they have to be in the app image before the app starts:
167
+
168
+ ```bash
169
+ steppy tours # 1. write the tour files into the app's app-assets folder
170
+ docker compose up -d --build --wait # 2. build the app image WITH them, then start it
171
+ steppy generate # 3. tests + replays
172
+ steppy run --trace --bundle # 4. the replays run with their scenarios
173
+ ```
174
+
175
+ (In a one-repository setup the app's own build usually runs `steppy tours`, and the
176
+ committed tour files are what step 2 bakes in.) Start the app first and the replays exercise
177
+ whatever tour files the image already had.
178
+
179
+ **Reading a failure.** One message carries the whole diagnosis:
180
+
181
+ ```text
182
+ TourReplayError: [tour create-invoice] step 'invoice-title' (scenario step 4 "Name the invoice", fill,
183
+ test-id "invoice-title"): target-timeout: the overlay never found [data-testid="invoice-title"]
184
+ hint: the test-id was renamed or removed in the app, or the element appears too late
185
+ page: /invoices tour: active, current 'invoice-title' bubble: fallback navigations since start: 0
186
+ last events: tour-start(bundled) | tour-step(new-invoice-btn) | tour-error(target-timeout, invoice-title)
187
+ ```
188
+
189
+ The first line is *which* — tour, tour step, scenario step, action, test-id — and the
190
+ overlay's error reason; `hint:` is the likely cause; `page:` / `tour:` / `bubble:` are the
191
+ state at the moment it failed; `last events:` is the overlay's own event tail
192
+ (`tour-start(bundled)` says the app's bundled tour files were used).
193
+
194
+ Things worth knowing before wiring it in:
195
+
196
+ - **The kill switch.** With tours enabled, the generated tests set
197
+ `globalThis.__STEPPY_TOURS_DISABLED__ = true` in every browser context, so a tour the
198
+ app starts on its own never covers what an ordinary test clicks; only the replays lift it.
199
+ Another E2E suite testing the same app should set it too.
200
+ - `steppy run -- -k "not __tour"` skips the replays for one run; `tours.replay: false`
201
+ stops generating them.
202
+ - `steppy run --guide` deselects them — guide captures are not tours.
203
+ - Replays record **no coverage**; `--coverage` numbers come from the scenario tests alone.
204
+ - `STEPPY_TOUR_EXPECT_BUILD_ID=<id>` makes a replay also check that the app serves tours
205
+ for that build — set it to the id the app build stamped to catch an image baked from the
206
+ wrong tour files. It reaches every worker under `--workers N` too.
207
+ - A replay re-runs its scenario's seed and steps, so the flow must be repeatable.
208
+ - **`steppy tours --check` is a freshness gate**: it writes nothing and exits 1 when a
209
+ committed `app-assets` copy is missing or its *content* no longer matches what the
210
+ scenarios compile to. No browser, no app — a cheap step that catches a scenario edited
211
+ without regenerating the committed tour files. When it reports files that "differ only
212
+ in line endings", git converted them on checkout (`core.autocrlf` on a Windows runner):
213
+ regenerating cannot fix that — mark the folder `-text` in the app repository's
214
+ `.gitattributes`. It compares each copy for the build id
215
+ that copy records and only prints a note when that differs from the one resolved now
216
+ (under `git-sha` a committed copy always carries an earlier commit), so it passes on a
217
+ fresh checkout; the gitignored `output/tours/` tree is not checked. It needs the app's
218
+ checkout (a missing `app-assets` folder is an error; `generate` only warns
219
+ `app-assets-skipped`), and it re-renders under CI's environment, so any `${VAR}` in
220
+ `tours.guide_base_url` or an app URL must resolve as it did where the files were
221
+ written. To gate the *build id* the running app serves, use
222
+ `STEPPY_TOUR_EXPECT_BUILD_ID` above.
223
+ - **Wiring it into a scaffolded pipeline.** `steppy init --ci` / `--ci-gitlab` write the
224
+ tour steps commented out (tours are opt-in). When the project enables tours, uncomment
225
+ them; leave them out of the reusable `steppy-e2e.yml` workflow, whose caller cannot
226
+ interleave steps — a project that builds its app in CI promotes it to a composite
227
+ action (see above).
228
+ - **In the run report, a replay is a row of its own under its scenario.** When both fail in
229
+ the same variant they share the failure screenshot, so read them as one cause first. A
230
+ red replay under a *green* scenario is the tour, not the app.
231
+
232
+ ---
233
+
234
+ ## `steppy report` — the run report site
235
+
236
+ ```bash
237
+ steppy report # this project's live output/
238
+ steppy report steppy-bundle-<run-id>.tar.zst # a bundle downloaded from CI
239
+ steppy report ./unpacked-bundle -o public # after tar -xf
240
+ ```
241
+
242
+ All three inputs produce the same report. That is the point: an assistant debugging a
243
+ failure from a downloaded CI artifact sees exactly what CI saw.
244
+
245
+ ### What the site contains
246
+
247
+ - **`index.html`** — run identity (run id, commit, branch, PR, executor), the spec
248
+ dimensions, outcome counts, **failure causes grouped by cause**, a scenario ×
249
+ (locale, layout) outcome matrix, warnings, and a coverage card.
250
+ - **`scenarios/<slug>.html`** — per (locale, layout) variant: outcome, duration, the
251
+ failing step **named**, the decoded failure tail, the step timeline (number, title,
252
+ action, elapsed) and the captures for each step.
253
+ - **`coverage.html`** + **`badge.json`** — when the run recorded coverage.
254
+ - **`report.json`** — the same model as a versioned wire contract
255
+ (`steppy_report: 1`, shape at `contracts/report.schema.json`). Deterministic: no
256
+ render-time clock, no absolute paths, so two renders of one run are byte-identical and
257
+ CI can diff them.
258
+
259
+ ### Failure causes are the feature
260
+
261
+ The report groups failures by *normalised* failure text, biggest first — the same
262
+ grouping the parallel console summary uses. One broken selector across forty scenarios
263
+ is **one row with a count**, not forty tracebacks, and the full tail is shown once per
264
+ cause. When a user says "the CI output is unreadable", this is the answer.
265
+
266
+ ### Flags worth knowing
267
+
268
+ | Flag | Why |
269
+ |---|---|
270
+ | `--screenshots failed \| all \| none` | Default `failed`: the captures of variants that did not pass, plus every failure screenshot. `all` on a 4-locale × 2-layout corpus is hundreds of MB pushed on every run. Captures identical across layouts are copied once. |
271
+ | `-o/--output` | Default `output/report`. See the warning below before pointing it elsewhere. |
272
+ | `--badge-surface all \| endpoints \| routes` | What the badge measures. `all` = endpoints + routes. |
273
+ | `--dry-run` | Report what would be written, write nothing. |
274
+ | `--json` | Print `report.json` to stdout instead of a summary — for a CI annotation. |
275
+ | `--force` | Replace a non-empty output directory. |
276
+ | `--open` | Open it in a browser locally. |
277
+
278
+ **`output/report/` is the one path `steppy bundle create` skips by name.** The rendered
279
+ site contains *copies* of the screenshots a bundle already carries, so archiving it
280
+ would roughly double the payload. Point `-o` somewhere else under `output/` and the
281
+ command warns you about exactly that. There is deliberately no `output.report_dir`
282
+ config key — one writer, no automated reader, and a live `report_dir` sitting one letter
283
+ from the deprecated `output.reports_dir` would be a permanent trap.
284
+
285
+ ### It is honest about what it could not know
286
+
287
+ This matters when reading a report that looks thin — the report says which case it is
288
+ rather than rendering an empty box, and `report.json`'s `capabilities` block carries the
289
+ same information for a machine:
290
+
291
+ | What you see | What it means |
292
+ |---|---|
293
+ | "No per-step timeline was recorded" | The run did not pass `--bundle`, or set `STEPPY_STEP_EVENTS=0`, or the tests were generated before per-step markers existed (`steppy generate` again). |
294
+ | "This run recorded no test outcomes" + verdict `unknown` | No `--bundle`, so there are no event records. The scenarios listed were reconstructed from captures on disk. **Not** `passed` — that would be a claim with no evidence. |
295
+ | "Assert strength is not shown" | Coverage was rebuilt from a detached bundle's raw records, which carry no scenario markdown to count assert steps in. Every endpoint would otherwise read as weakly covered. |
296
+ | "Truncated" | A `--max-size` budget dropped artifacts that belonged in the bundle. Screenshots and sidecars are never dropped. |
297
+
298
+ Traces and videos are **named, not copied** — a Playwright trace needs
299
+ `playwright show-trace` to open, so a link would be a dead end.
300
+
301
+ ---
302
+
303
+ ## Publishing to Pages
304
+
305
+ The site is relative and self-contained, so publishing it is a copy. Both shipped
306
+ templates carry the job **commented out**, with the warning below. Do not arm it for a
307
+ user without saying this out loud:
308
+
309
+ > **Pages is public.** Screenshots show the app with real-looking data in it.
310
+ > `screenshot.mask` redacts fields at capture time and the report marks every page
311
+ > `noindex` with a `Disallow: /` robots.txt — but a leak is permanent and cannot be
312
+ > taken back. This is for showcase and open-source repos. For client work, read the
313
+ > report from the CI artifact instead.
314
+
315
+ Two vendor facts worth carrying:
316
+
317
+ - **GitHub Pages**: publish as an **artifact** (`actions/configure-pages` +
318
+ `upload-pages-artifact` + `deploy-pages`), never by committing to a `gh-pages`
319
+ branch — screenshots pushed on every run grow the repository's history forever and
320
+ cannot be pruned without a rewrite. Needs `permissions: {pages: write, id-token:
321
+ write}` and Settings → Pages → Source: "GitHub Actions".
322
+ - **GitLab Pages** can be restricted to **project members** (Settings → Pages → Access
323
+ Control). That is the one place it beats GitHub's for this, and it is what makes
324
+ publishing a run report viable for client work. It is a project setting, not something
325
+ the pipeline can guarantee — check it before the first deploy.
326
+
327
+ Gate the deploy on the default branch either way: a report from every PR publishes work
328
+ in progress, and fork PRs cannot get an id-token anyway.
329
+
330
+ **A repository has exactly one Pages site.** A project that publishes both the run report
331
+ and generated guides (`steppy build`) has to place them — `site/report/` and
332
+ `site/guides/` — and that decision is worth making before anything links to either,
333
+ because moving the report also moves `badge.json`, and that URL is a contract with every
334
+ README and dashboard pointing at it. Deciding it late means either a dead badge or a
335
+ permanent redirect nobody owns.
336
+
337
+ The badge is then served at `<pages-url>/badge.json`, **when the run recorded coverage** —
338
+ `render.py` writes the file only when there is a coverage section, so a badge URL added
339
+ before `--coverage` is a 404 that reads to a visitor as an abandoned project:
340
+
341
+ ```markdown
342
+ ![e2e surface](https://img.shields.io/endpoint?url=https://<owner>.github.io/<repo>/badge.json)
343
+ ```
344
+
345
+ A shields **endpoint JSON**, not a generated SVG — it inherits whatever style the rest of
346
+ the README's badge row uses, and no image generation lands in the repo.
347
+
348
+ ---
349
+
350
+ ## The coverage map
351
+
352
+ **This is not line coverage.** Codecov answers "what percentage of source lines ran".
353
+ Steppy's map answers *which of the routes and API endpoints I declared has any e2e
354
+ scenario actually exercised* — which nothing else measures. Everything below is
355
+ deterministic: no LLM in recording, merging, or gap computation.
356
+
357
+ ### How it is built — four stages
358
+
359
+ **1. Record** (`steppy run --coverage`, sets `STEPPY_COVERAGE=1`). The *generated
360
+ conftest* does it, so there is zero overhead when the flag is off. Three signals per
361
+ scenario run:
362
+
363
+ - **Route trail** — Playwright's `framenavigated`, which is authoritative because it
364
+ fires for History-API changes too, plus an injected hook tagging push/replace/pop/hash.
365
+ - **API endpoints** — every request's `{method, url}`, filtered to
366
+ `coverage.api_resource_types` (default `xhr`, `fetch`).
367
+ - **Source modules** — V8 coverage over raw CDP `Profiler.startPreciseCoverage`
368
+ (playwright-python has no `page.coverage` API), with inline `data:` source maps
369
+ captured and gzipped.
370
+
371
+ Raw records land at
372
+ `output/coverage/raw/<run_id>/<scenario>/<fixture>__<locale>-<layout>.json`. The recorder
373
+ is deliberately **dumb** — it writes observations, nothing interpreted.
374
+
375
+ **2. Merge** (automatic after the run, or `steppy coverage merge`). *All* interpretation
376
+ happens here: dynamic route segments cluster (`/invoices/123` → `/invoices/:id`, extra
377
+ regexes via `coverage.route_param_patterns`), API attribution by longest `base_urls`
378
+ prefix, source-map decode with a bundle-granularity fallback. Upserts replace a
379
+ scenario's whole contribution, so re-running one scenario cannot leave stale rows.
380
+
381
+ **3. Refresh** (`steppy coverage refresh`) writes the **declared surface** — the
382
+ denominator. This is the step people forget, and without it every percentage is
383
+ meaningless.
384
+
385
+ **4. Gaps** (`steppy coverage report`) is pure bucket math:
386
+
387
+ | Bucket | Meaning |
388
+ |---|---|
389
+ | **tested** | declared ∩ exercised. *Strong* when an asserting scenario covered it, *weak* when only assert-free or `--guide`/`--no-asserts` runs did. |
390
+ | **declared-unreached** | In the manifest, never hit. **The bucket that matters** — this is the answer to "what should I write a scenario for next?" |
391
+ | **discovered-uncovered** | Seen in a trail, only weakly covered. |
392
+ | **discovered-tested** | Exercised but *absent* from the manifest — a stale spec, or a `base_urls` prefix mismatch. |
393
+ | **unclassified** | Requests matching no configured `base_urls` at all. Check `base_urls`. |
394
+
395
+ ### It needs a declared surface — usually an OpenAPI document
396
+
397
+ ```yaml
398
+ coverage:
399
+ apis:
400
+ - name: core
401
+ openapi: "./api/openapi.yaml" # URL or local path; JSON or YAML
402
+ base_urls: ["http://localhost:8080", "http://app-backend:8080"]
403
+ sitemap: "./public/sitemap.xml" # optional — declares the ROUTE surface
404
+ ```
405
+
406
+ Two things to get right:
407
+
408
+ - **Prefer a committed file over a URL in CI.** A URL makes the refresh depend on the
409
+ backend being up at that moment, which is an ordering constraint nobody needs.
410
+ - **`base_urls` must mirror the OpenAPI `servers[].url`, including any path prefix**, and
411
+ should include the Docker-internal origins for parallel runs. Get this wrong and
412
+ observed requests land in `unclassified` while the declared surface reads untested.
413
+
414
+ With **no** declared surface you still get recording and the *discovered* buckets — "here
415
+ is what the suite touched" — and the badge honestly reads **`n/a` in grey**, never `0%`.
416
+ What you lose is `declared-unreached`, which is the actionable half. A frontend-only
417
+ project can get a real route denominator from `coverage.sitemap` alone, with no backend
418
+ spec at all.
419
+
420
+ ### `coverage.non_ui` — required for a correct badge in CI
421
+
422
+ Some endpoints can *never* have a UI test: health checks, webhook receivers, OAuth
423
+ callbacks, internal cron. Marking them with `steppy coverage triage set` writes to
424
+ `.steppy/coverage.db`, which is **gitignored** — so a fresh CI checkout has an empty
425
+ store, every endpoint reads `unknown`, and all of them count against the denominator. The
426
+ badge then reads artificially low for reasons the maintainer cannot fix from CI, and a
427
+ badge like that gets deleted.
428
+
429
+ Declare them in committed config instead:
430
+
431
+ ```yaml
432
+ coverage:
433
+ non_ui:
434
+ - "GET /health"
435
+ - "/webhooks/*"
436
+ - "POST /internal/cron/*"
437
+ ```
438
+
439
+ Grammar is `[<METHOD> ]<glob>`:
440
+
441
+ - A bare glob matches **any method**; a method-qualified pattern matches only that one.
442
+ - `*` **crosses `/`** — `/internal/*` covers the whole subtree. This is intentional.
443
+ - A pattern applies across **every** configured API, because it matches on the path
444
+ alone. Use `steppy coverage triage set` for the surgical single-node case;
445
+ `steppy coverage triage list` shows every flag set that way.
446
+ - A method-qualified pattern never matches a *route* (routes have no method).
447
+
448
+ Precedence is a total order: **db `ui_testable` > config pattern > db `non_ui` >
449
+ `unknown`/absent**. An explicit human "yes this is reachable" outranks a glob; nothing is
450
+ ever written back to the store, so merge and refresh still never touch triage.
451
+ `steppy coverage report` **warns about a pattern that matches nothing** — after an API
452
+ rename a stale rule silently inflates the number.
453
+
454
+ ### The badge number
455
+
456
+ **`tested / (declared − non_ui)`, with non_ui subtracted from *both* sides.** Subtracting
457
+ it from the denominator only would let a tested-but-non_ui endpoint push the result past
458
+ 100%.
459
+
460
+ ```bash
461
+ steppy coverage report --badge output/badge.json --badge-surface all
462
+ ```
463
+
464
+ Worked example — 6 declared endpoints, 3 matched by the patterns above, 2 tested:
465
+ denominator 3, numerator 2, **67%**. Without `non_ui` the same suite reads 33%.
466
+
467
+ **Cost note:** `--coverage` drives CDP precise coverage, so it costs runtime. On free
468
+ runners, recording it on default-branch pushes (to refresh the badge) rather than on
469
+ every PR is usually the right trade.
470
+
471
+ **Why the coverage steps ship unarmed in both CI templates:** `steppy coverage refresh`
472
+ **exits 1** when neither `coverage.apis` nor `coverage.sitemap` is configured. Arming it
473
+ in the scaffold would break the pipeline of every project that has not declared a surface
474
+ yet — which is every project on the day it runs `steppy init`. Add the two steps once the
475
+ `coverage:` block exists. Until then the honest outcome is what the report already
476
+ produces: coverage recorded, nothing to score it against, badge reads **`n/a` in grey**
477
+ rather than a `0%` that looks like a finding.
478
+
479
+ ### How the report gets coverage — four tiers
480
+
481
+ The store lives **outside `output/`** on purpose, so wiping run artifacts never destroys
482
+ hand-made triage — which also means it is never inside a bundle. So:
483
+
484
+ | Tier | Source | When |
485
+ |---|---|---|
486
+ | `bundled` | `output/coverage/gaps.json` carried in the run | any input that has one |
487
+ | `store` | the project's live `.steppy/coverage.db` | **live `output/` tree only** |
488
+ | `records` | a throwaway map rebuilt from the raw records | records, no gaps.json, no store |
489
+ | `none` | no coverage section at all | nothing recorded |
490
+
491
+ `gaps.json` is written automatically after the post-run merge, so
492
+ `steppy run --coverage --bundle` is self-sufficient — tier 1 with no extra CI step. Two
493
+ guardrails are deliberate and worth knowing, because the wrong answer would look
494
+ entirely plausible:
495
+
496
+ - **Tier 2 is live-tree only.** Reading this machine's map to describe someone else's
497
+ downloaded bundle would attribute your coverage to their run.
498
+ - **Tier 3 cannot report assert strength**, because the scenario markdown it would need
499
+ to count assert steps in never travels in a bundle. It says so instead of guessing.
500
+
501
+ ---
502
+
503
+ ## Results bundles
504
+
505
+ ```bash
506
+ steppy run --bundle # capture per-test AND per-step events + JUnit XML
507
+ steppy bundle create # → output/bundles/steppy-bundle-<run-id>.tar.zst
508
+ steppy bundle inspect <file> # the manifest, without extracting
509
+ steppy bundle verify <file> # re-hash every member against it
510
+ ```
511
+
512
+ `--bundle` does not create the bundle; it captures what a bundle needs. `create` then
513
+ packages `output/` **exactly as Steppy wrote it** — no path rewriting — plus a generated
514
+ `manifest.json`, a merged `junit.xml`, the event JSONL, and `coverage/gaps.json` when the
515
+ run has one. Unpacking yields a tree that `steppy report`, `steppy guide` and
516
+ `steppy build` all consume unchanged.
517
+
518
+ Useful with no server involved: a self-describing archive to attach to a CI job, so a
519
+ failed run is one download rather than a scavenger hunt. `--no-videos` / `--no-traces` /
520
+ `--no-guides` trim it; `--max-size 2GiB` drops the cheapest-to-lose artifacts first and
521
+ marks the manifest `truncated`.
522
+
523
+ `steppy bundle upload` pushes to a Steppy control plane, which is optional and out of
524
+ scope here — everything above works with no plane at all.
525
+
526
+ ---
527
+
528
+ ## Troubleshooting
529
+
530
+ | Symptom | Cause | Fix |
531
+ |---|---|---|
532
+ | Report says "no test outcomes", verdict `unknown` | The run had no `--bundle`, so no event records exist | `steppy run --bundle` |
533
+ | Report shows outcomes but no step detail | `STEPPY_STEP_EVENTS=0`, or tests generated before per-step markers | Unset the env var; re-run `steppy generate` |
534
+ | Badge says `n/a` | Nothing declared — no `coverage.apis`, or `refresh` never ran | Configure `coverage.apis[].openapi`, then `steppy coverage refresh` |
535
+ | Coverage percentage far lower in CI than locally | Triage flags live in the gitignored `.steppy/coverage.db`; CI starts empty | Declare `coverage.non_ui` in `config.yaml` |
536
+ | Everything in `unclassified`, declared surface reads untested | `base_urls` does not match the OpenAPI `servers[].url` prefix | Fix `base_urls`; include internal origins for parallel runs |
537
+ | `discovered-tested` endpoints appear | Stale spec, or a base-URL mismatch | `steppy coverage refresh`, then check `base_urls` |
538
+ | Every tested endpoint reads "weak" | Assert-free run (`--guide` implies `--no-asserts`), or coverage rebuilt from a bundle (tier 3) | Record coverage on a normal test run, not a guide capture |
539
+ | No coverage section at all | Nothing recorded | `steppy run --coverage` |
540
+ | Report refuses to write | Target looks like a project (`config.yaml`, `.git`, `scenarios/`, `pyproject.toml`) — refused even with `--force` | Point `-o` at a dedicated directory |
541
+ | Report refuses a non-empty directory | Not a previous report (no `report.json` with a `steppy_report` key) | `--force`, or a different `-o` |
542
+ | Bundle roughly doubled in size | The report was written somewhere under `output/` other than `output/report/`, so the bundler archived the site | Use the default `-o`, or exclude it |
543
+ | CI step red but the run passed | Something else — `steppy report` always exits 0 | Read `report.json`'s `totals.exit_code` |
544
+ | A tour replay fails with `target-timeout`, or on a step id it does not expect, while its scenario passes | Stale tour files in the app image: the app serves tours compiled from older scenarios | Run `steppy tours`, rebuild the app image, then run; add `steppy tours --check` as a gate (it compares the committed `app-assets` copy's content, never the build id) |
545
+
546
+ ---
547
+
548
+ ## What this deliberately does not do
549
+
550
+ The report **transforms one run**. With a single run's artifacts there is nothing to
551
+ compare against, so it structurally cannot:
552
+
553
+ - diff guides or screenshots against an accepted baseline,
554
+ - track flake rate or duration trends across runs,
555
+ - show a coverage delta for a PR,
556
+ - gate a pull request, or let a human accept a baseline.
557
+
558
+ Those need memory across runs, which is a control plane's job. Do not try to build them
559
+ on top of `steppy report`. Two things that *do* exist for the adjacent needs:
560
+ `steppy flaky` ranks scenarios by flake rate from `.steppy/history.jsonl` (parallel runs
561
+ only — cache that file between CI runs or the history evaporates), and
562
+ `steppy bundle upload` hands a run to a plane if the project has one.
563
+
564
+ Nor is there an **analytics backend for in-app tours**. The overlay sends nothing anywhere:
565
+ its events (`tour-start`, `tour-step`, `tour-complete`, `tour-dismiss`, `tour-offpath`,
566
+ `tour-error`) stay in the page, for the app's own analytics if it has any. CI replays prove
567
+ a tour *works*; whether real users finish it is the app's question, not this pipeline's.