steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/worker.py ADDED
@@ -0,0 +1,301 @@
1
+ """``steppy worker`` subcommand body. Runs INSIDE a worker container.
2
+
3
+ The coordinator's compose override sets these env vars before launching us:
4
+
5
+ STEPPY_WORKER_ID e.g. "w1"
6
+ STEPPY_RUN_ID short uuid hex of the parent run
7
+ STEPPY_COORD_DIR /steppy/coord (shared mount; queue/running/results live here)
8
+ STEPPY_BASE_URL_OVERRIDE JSON {role: internal_url} — read by generated conftest
9
+ STEPPY_LOCALES, STEPPY_LAYOUTS, STEPPY_GUIDE_MODE,
10
+ STEPPY_NO_ASSERTS, STEPPY_SCREENSHOTS — pass-through, semantics identical to
11
+ the host-side single-worker run
12
+
13
+ Loop: claim a task from the shared queue, invoke pytest on each of its scenarios'
14
+ test files in turn, write a result per scenario, repeat. Exit 0 when the queue
15
+ empties. A task is one scenario unless it is an exclusion group, in which case
16
+ running its members here — sequentially, in one worker — IS the exclusion
17
+ guarantee; nothing else enforces it at run time.
18
+
19
+ The pytest invocation here intentionally MIRRORS the one in ``cli.run``
20
+ (cli.py:702) — same ``sys.executable -m pytest <file> --browser chromium``
21
+ shape — so per-scenario behavior is identical to a single-worker run. The
22
+ duplication is deliberate: it keeps the existing path in cli.py completely
23
+ untouched, which is the v0.1 sacred invariant.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shlex
30
+ import subprocess
31
+ import sys
32
+ import time
33
+ from pathlib import Path
34
+ from typing import Optional
35
+
36
+ from ._parallel.queue import Queue, ScenarioResult, TaskPayloadError
37
+
38
+
39
+ def _env_or_die(name: str) -> str:
40
+ value = os.environ.get(name)
41
+ if not value:
42
+ print(f"steppy worker: required env var {name} is unset", file=sys.stderr)
43
+ sys.exit(2)
44
+ return value
45
+
46
+
47
+ def _emit_event(*parts: str) -> None:
48
+ """Print one ``STEPPY-EVT::*`` line to stdout, flushed immediately.
49
+
50
+ The coordinator's EventBus parses these as scenario-level milestones —
51
+ visible even when the per-test pytest plugin can't run (e.g. the test
52
+ file is missing). Inert in single-worker mode because nothing parses
53
+ these lines outside the coordinator.
54
+ """
55
+ print("STEPPY-EVT::" + "::".join(parts), flush=True)
56
+
57
+
58
+ def _rerunfailures_available() -> bool:
59
+ """True when pytest-rerunfailures (the ``steppy-dev[retry]`` extra) is importable."""
60
+ import importlib.util
61
+
62
+ return importlib.util.find_spec("pytest_rerunfailures") is not None
63
+
64
+
65
+ def _build_pytest_argv(
66
+ test_file: Path,
67
+ emit_events: bool,
68
+ env: Optional[dict] = None,
69
+ slug: Optional[str] = None,
70
+ ) -> list[str]:
71
+ """Build the per-scenario pytest argv.
72
+
73
+ When ``emit_events`` is True (parallel mode — STEPPY_EMIT_EVENTS=1),
74
+ append ``-q --tb=short`` so raw pytest output is minimal and the
75
+ coordinator's renderer drives the visible signal via the structured
76
+ events plugin. Also disable the cache provider — the project root is
77
+ bind-mounted read-only into the worker container, and pytest's
78
+ cacheprovider would otherwise emit a ``PytestCacheWarning: cache
79
+ could not write path /steppy/project/.pytest_cache/...`` for every
80
+ scenario.
81
+
82
+ When False, keep the argv byte-identical to ``cli.run``'s
83
+ single-worker invocation (guarded by
84
+ ``tests/test_run_byte_identical.py``).
85
+
86
+ ``env`` carries the two report settings that have to reach pytest as
87
+ **argv** rather than env vars (``STEPPY_TRACE``/``STEPPY_VIDEO`` are read
88
+ by the generated conftest and need nothing here):
89
+
90
+ STEPPY_RETRIES → ``--reruns=N`` (pytest-rerunfailures). Mirrors
91
+ ``cli._apply_report_args``: warn and skip when the
92
+ plugin is missing rather than failing the run.
93
+ STEPPY_PYTEST_EXTRA → the user's ``--`` passthrough argv, shlex-encoded
94
+ by ``compose.render_override``.
95
+ STEPPY_JUNIT_DIR → ``--junitxml=<dir>/<slug>.xml`` (``--bundle``).
96
+ Per SCENARIO, not per worker: N workers share one
97
+ ``output/`` mount, so a single fixed path would
98
+ leave only the last finisher's report — which is
99
+ exactly what the ``-- --junitxml=…`` passthrough
100
+ does today. The slug is unique across the run by
101
+ construction (it is the queue's unit of work), a
102
+ retry overwrites only its own file, and a MISSING
103
+ ``<slug>.xml`` is itself a precise signal that
104
+ pytest died before collection.
105
+
106
+ All are absent on the single-worker path, so the argv stays unchanged.
107
+ """
108
+ argv = [sys.executable, "-m", "pytest", str(test_file), "--browser", "chromium"]
109
+ if emit_events:
110
+ argv.extend(["-q", "--tb=short", "-p", "no:cacheprovider"])
111
+ env = env if env is not None else {}
112
+ junit_dir = env.get("STEPPY_JUNIT_DIR", "").strip()
113
+ if junit_dir and slug:
114
+ try:
115
+ Path(junit_dir).mkdir(parents=True, exist_ok=True)
116
+ argv.append(f"--junitxml={Path(junit_dir) / f'{slug}.xml'}")
117
+ except OSError as e:
118
+ print(f"steppy worker: cannot write JUnit XML to {junit_dir}: {e}", file=sys.stderr, flush=True)
119
+ retries = env.get("STEPPY_RETRIES", "").strip()
120
+ if retries and retries != "0":
121
+ if _rerunfailures_available():
122
+ argv.append(f"--reruns={retries}")
123
+ else:
124
+ print(
125
+ "steppy worker: STEPPY_RETRIES is set but pytest-rerunfailures is not "
126
+ "installed in the worker image; skipping retries.",
127
+ file=sys.stderr,
128
+ flush=True,
129
+ )
130
+ extra = env.get("STEPPY_PYTEST_EXTRA", "").strip()
131
+ if extra:
132
+ argv.extend(shlex.split(extra))
133
+ return argv
134
+
135
+
136
+ def _run_one_scenario(
137
+ slug: str,
138
+ tests_dir: Path,
139
+ env: dict[str, str],
140
+ worker_id: str,
141
+ log_dir: Optional[Path] = None,
142
+ ) -> tuple[int, float, float, float]:
143
+ """Invoke pytest on a single scenario's test file. Returns
144
+ (exit_code, duration_s, started_at, finished_at).
145
+
146
+ Brackets the pytest subprocess in ``scenario-start`` / ``scenario-end``
147
+ events so the coordinator knows scenario boundaries even when the
148
+ inner pytest hooks never fire (missing test file, pytest crash before
149
+ collection).
150
+
151
+ When ``log_dir`` is provided, pytest's combined stdout/stderr is tee'd
152
+ to ``log_dir / f"{slug}.log"`` in addition to flowing through this
153
+ process's stdout (which the coordinator tails via
154
+ ``docker compose logs -f``). This is what gives the coordinator
155
+ something to copy into ``output/failures/<slug>/pytest.log`` after a
156
+ failure — without it, per-scenario log lines are jumbled with every
157
+ other scenario this worker ran inside the same shared
158
+ ``w{i}-stack.log`` file."""
159
+ emit_events = env.get("STEPPY_EMIT_EVENTS") == "1"
160
+ test_file = tests_dir / f"test_{slug}.py"
161
+ started = time.time()
162
+ if emit_events:
163
+ _emit_event("scenario-start", slug, worker_id, f"{started:.3f}")
164
+ if not test_file.exists():
165
+ # Missing test file → hard scenario failure, surfaced by the
166
+ # coordinator. Usually means `steppy generate` wasn't run before
167
+ # `steppy run --workers N`.
168
+ finished = time.time()
169
+ if emit_events:
170
+ _emit_event("scenario-end", slug, worker_id, "99", "0")
171
+ return 99, 0.0, started, finished
172
+ argv = _build_pytest_argv(test_file, emit_events, env, slug=slug)
173
+ if log_dir is None:
174
+ # No teeing — preserve the byte-identical subprocess.run path used
175
+ # by tests and any consumer that wants the unchanged shape.
176
+ result_code = subprocess.run(argv, env=env).returncode
177
+ else:
178
+ result_code = _run_pytest_with_tee(argv, env, log_dir / f"{slug}.log")
179
+ finished = time.time()
180
+ if emit_events:
181
+ duration_ms = int((finished - started) * 1000)
182
+ _emit_event("scenario-end", slug, worker_id, str(result_code), str(duration_ms))
183
+ return result_code, finished - started, started, finished
184
+
185
+
186
+ def _run_pytest_with_tee(argv: list[str], env: dict[str, str], log_path: Path) -> int:
187
+ """Run pytest, copying every line of its combined stdout/stderr to
188
+ both ``sys.stdout`` (so STEPPY-EVT lines still reach the coordinator's
189
+ EventBus via ``docker compose logs -f``) and ``log_path`` (so the
190
+ coordinator can persist it under ``output/failures/<slug>/`` on
191
+ failure). Returns pytest's exit code."""
192
+ log_path.parent.mkdir(parents=True, exist_ok=True)
193
+ proc = subprocess.Popen(
194
+ argv,
195
+ env=env,
196
+ stdout=subprocess.PIPE,
197
+ stderr=subprocess.STDOUT,
198
+ text=True,
199
+ bufsize=1,
200
+ encoding="utf-8",
201
+ errors="replace",
202
+ )
203
+ assert proc.stdout is not None
204
+ try:
205
+ with log_path.open("w", encoding="utf-8") as f:
206
+ for line in proc.stdout:
207
+ sys.stdout.write(line)
208
+ sys.stdout.flush()
209
+ f.write(line)
210
+ finally:
211
+ proc.stdout.close()
212
+ return proc.wait()
213
+
214
+
215
+ def worker_main() -> int:
216
+ """Entry point for ``steppy worker``. Returns the process exit code.
217
+
218
+ Exit semantics:
219
+ 0 — queue drained cleanly, all claimed scenarios produced a result
220
+ 1 — at least one scenario reported a non-zero pytest exit code
221
+ 2 — fatal misconfig (missing required env var)
222
+ """
223
+ worker_id = _env_or_die("STEPPY_WORKER_ID")
224
+ coord_dir = Path(_env_or_die("STEPPY_COORD_DIR"))
225
+
226
+ # Inside the container, the project is mounted at /steppy/project.
227
+ # working_dir is set to that in the compose override, so cwd-relative
228
+ # paths work for pytest the same way they do on the host single-worker
229
+ # path.
230
+ project_root = Path.cwd()
231
+ tests_dir = project_root / "tests" / "e2e"
232
+
233
+ queue = Queue(root=coord_dir)
234
+
235
+ # Per-scenario pytest logs land here. Bind-mounted to the coordinator's
236
+ # tmp_dir, so the host can copy each failed scenario's log into
237
+ # output/failures/<slug>/pytest.log before tmp_dir is torn down.
238
+ log_dir = coord_dir / "logs" / worker_id
239
+ log_dir.mkdir(parents=True, exist_ok=True)
240
+
241
+ any_failed = False
242
+ emit_events = os.environ.get("STEPPY_EMIT_EVENTS") == "1"
243
+ print(f"[{worker_id}] worker started; coord dir: {coord_dir}", flush=True)
244
+
245
+ while True:
246
+ try:
247
+ task = queue.claim_task(worker_id)
248
+ except TaskPayloadError as e:
249
+ # The claim already succeeded, so this task is now claimed with no
250
+ # results: its members surface as orphans and the retry pass picks
251
+ # them up. One unreadable marker must not cost the rest of the queue.
252
+ print(f"[{worker_id}] {e}", file=sys.stderr, flush=True)
253
+ if emit_events:
254
+ _emit_event("warning", "task-payload-unreadable", e.task_id)
255
+ any_failed = True
256
+ continue
257
+ if task is None:
258
+ # Emit BEFORE the prose line so the EventBus flips the renderer
259
+ # row to "done" before the raw "queue empty" line shows up in
260
+ # verbose mode. Gated on STEPPY_EMIT_EVENTS so the single-worker
261
+ # path (which doesn't set this env) never emits stray events.
262
+ if emit_events:
263
+ _emit_event("worker-done", worker_id)
264
+ print(f"[{worker_id}] queue empty — exiting", flush=True)
265
+ break
266
+ if task.group is None:
267
+ print(f"[{worker_id}] claimed: {task.task_id}", flush=True)
268
+ else:
269
+ print(
270
+ f"[{worker_id}] claimed exclusion group '{task.group}' "
271
+ f"({len(task.slugs)} scenarios: {', '.join(task.slugs)})",
272
+ flush=True,
273
+ )
274
+ # One pytest process per scenario, exactly as for an ungrouped task —
275
+ # so results, logs, JUnit and the scenario-start/end events stay keyed
276
+ # per scenario, and a crash mid-batch reduces to "which member has no
277
+ # result file". Running the members is what serialises them: this loop
278
+ # is the whole of the exclusion guarantee at run time.
279
+ for slug in task.slugs:
280
+ exit_code, duration, started_at, finished_at = _run_one_scenario(
281
+ slug,
282
+ tests_dir,
283
+ dict(os.environ),
284
+ worker_id,
285
+ log_dir=log_dir,
286
+ )
287
+ result = ScenarioResult(
288
+ slug=slug,
289
+ worker_id=worker_id,
290
+ exit_code=exit_code,
291
+ duration_s=duration,
292
+ started_at=started_at,
293
+ finished_at=finished_at,
294
+ )
295
+ queue.write_result(result)
296
+ status = "PASS" if exit_code == 0 else f"FAIL(code={exit_code})"
297
+ print(f"[{worker_id}] {status} {slug} ({duration:.1f}s)", flush=True)
298
+ if exit_code != 0:
299
+ any_failed = True
300
+
301
+ return 1 if any_failed else 0