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/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Steppy - A Python CLI for generating Playwright tests from Markdown scenarios."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ # The name pip installs this package under. `steppy` on PyPI belongs to an
6
+ # unrelated project, so the distribution is `steppy-dev` while the import
7
+ # package and the command stay `steppy`. Code that asks importlib.metadata
8
+ # about the installed package goes through this name, never a literal.
9
+ DISTRIBUTION = "steppy-dev"
steppy/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Allow ``python -m steppy ...`` invocation alongside the ``steppy`` script.
2
+
3
+ Used by the worker container's entrypoint so the image doesn't depend on
4
+ the script entrypoint being on PATH (it's installed via pyproject's
5
+ ``[project.scripts]`` and Python 3.12 puts it in /usr/local/bin via pip,
6
+ but `python -m steppy` is more portable across base images).
7
+ """
8
+
9
+ from .cli import app
10
+
11
+
12
+ if __name__ == "__main__":
13
+ app()
steppy/_cli_common.py ADDED
@@ -0,0 +1,183 @@
1
+ """Shared CLI helpers (FLAWS §4.2).
2
+
3
+ A leaf module — it imports neither ``cli`` nor ``coverage_cli``, so both can
4
+ import from it without a circular dependency (the coverage sub-app lives in its
5
+ own module but needs the same console / error scaffold).
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+ import typer
14
+ from rich.console import Console
15
+ from rich.markup import escape
16
+
17
+ from .config import load_config, ConfigError
18
+
19
+
20
+ def _widen_console_encoding() -> None:
21
+ """Stop a non-ASCII character in our own output from killing the command.
22
+
23
+ Steppy's summaries use ``→``, ``×`` and ``…`` in a dozen places. On a Windows
24
+ console still running a legacy code page, ``sys.stdout`` is cp1252 and the
25
+ first arrow raises ``UnicodeEncodeError`` from inside Rich — so
26
+ ``steppy coverage merge`` (and every other command with an arrow in it) dies
27
+ at the moment it tries to report success, with a traceback about charmap that
28
+ tells the user nothing about their run.
29
+
30
+ Asking for UTF-8 fixes it outright on any modern terminal; ``errors="replace"``
31
+ is the belt and braces for one that genuinely cannot render the glyph, where a
32
+ ``?`` is a fine outcome and a crash is not. Guarded on every axis because this
33
+ runs at import time under pytest capture too, where the stream may be a
34
+ substitute with no ``reconfigure`` at all.
35
+ """
36
+ for stream in (sys.stdout, sys.stderr):
37
+ reconfigure = getattr(stream, "reconfigure", None)
38
+ if reconfigure is None:
39
+ continue
40
+ try:
41
+ reconfigure(encoding="utf-8", errors="replace")
42
+ except Exception:
43
+ pass
44
+
45
+
46
+ _widen_console_encoding()
47
+
48
+ console = Console()
49
+
50
+ # Default wall-clock ceiling (seconds) for the standalone ``steppy seed``
51
+ # subprocess. Per-scenario seeds carry their own ``SeedConfig.timeout``; this is
52
+ # the fallback for the ad-hoc command that has no scenario context (FLAWS §5.1).
53
+ DEFAULT_SEED_TIMEOUT = 120
54
+
55
+ # Set by the root Typer callback (``--verbose``). Module-level so both cli.py
56
+ # and coverage_cli.py observe the same flag.
57
+ _VERBOSE = False
58
+
59
+
60
+ def set_verbose(value: bool) -> None:
61
+ """Record the ``--verbose`` flag from the root callback."""
62
+ global _VERBOSE
63
+ _VERBOSE = value
64
+
65
+
66
+ def _debug_enabled() -> bool:
67
+ """True when the user opted into full tracebacks via --verbose or STEPPY_DEBUG."""
68
+ return _VERBOSE or os.environ.get("STEPPY_DEBUG", "") not in ("", "0")
69
+
70
+
71
+ def _error(message: str) -> None:
72
+ """Print a red ERROR line, escaping Rich markup in the dynamic message.
73
+
74
+ Scenario content routinely contains square brackets (CSS selectors like
75
+ ``[data-testid="x"]``), which Rich would otherwise parse as style tags and
76
+ silently swallow — mangling the very error the user needs to read.
77
+ """
78
+ console.print(f"[red]ERROR[/red] {escape(message)}")
79
+
80
+
81
+ def _report_unexpected(e: Exception) -> None:
82
+ """Print a friendly one-line error for an unexpected exception.
83
+
84
+ The full traceback is shown only when the user opted in (``--verbose`` or
85
+ ``STEPPY_DEBUG=1``). Callers raise ``typer.Exit(1)`` afterwards.
86
+ """
87
+ _error(f"Unexpected error: {e}")
88
+ if _debug_enabled():
89
+ import traceback
90
+
91
+ traceback.print_exc()
92
+
93
+
94
+ @contextmanager
95
+ def _cli_errors():
96
+ """Shared command error scaffold (FLAWS §4.2).
97
+
98
+ Replaces the per-command ``try / except typer.Exit: raise / except
99
+ Exception: _report_unexpected; Exit(1)`` tail that was copy-pasted into
100
+ every command — the exact duplication that let the §2.1 ``typer.Exit``
101
+ bug reach some commands and not others. Re-raises ``typer.Exit`` (so
102
+ intentional exit codes, including the benign ``Exit(0)``, are preserved)
103
+ and turns any other exception into a friendly one-line message.
104
+ """
105
+ try:
106
+ yield
107
+ except typer.Exit:
108
+ raise
109
+ except Exception as e:
110
+ _report_unexpected(e)
111
+ raise typer.Exit(1)
112
+
113
+
114
+ def reject_xdist_args(args) -> None:
115
+ """Reject pytest-xdist flags spliced into the pytest passthrough (FLAWS §5.3).
116
+
117
+ The generated ``conftest.py`` files screenshots by locale/layout via mutable
118
+ module globals (``CURRENT_LOCALE``/``CURRENT_LAYOUT``). Those are correct only
119
+ because a pytest process runs tests sequentially. Distributing a single run
120
+ across xdist workers (``steppy run -- -n 4``) is untested and redundant with
121
+ Steppy's own supported parallel path (``steppy run --workers N``), so we block
122
+ it with a clear pointer instead of silently mis-filing captures.
123
+ """
124
+ xdist_flags = ("-n", "--numprocesses", "--dist", "--tx")
125
+ for i, arg in enumerate(args):
126
+ head = arg.split("=", 1)[0]
127
+ # ``-n4`` glues the worker count to the short flag (no space, no ``=``).
128
+ if head in xdist_flags or arg.startswith("-n"):
129
+ _error(
130
+ "pytest-xdist (-n/--numprocesses/--dist) is not supported via the "
131
+ "pytest passthrough - it would mis-file screenshots. Use Steppy's "
132
+ "parallel mode instead: steppy run --workers N"
133
+ )
134
+ raise typer.Exit(1)
135
+ # ``-p xdist`` / ``-p pytest_xdist`` explicitly loads the plugin.
136
+ if arg == "-p" and i + 1 < len(args) and "xdist" in args[i + 1]:
137
+ _error(
138
+ "pytest-xdist is not supported via the pytest passthrough - it "
139
+ "would mis-file screenshots. Use: steppy run --workers N"
140
+ )
141
+ raise typer.Exit(1)
142
+
143
+
144
+ def _load_config_or_exit():
145
+ """Load config, or print a friendly error and exit 1 (FLAWS §4.2).
146
+
147
+ Extracts the ``try: load_config() except ConfigError`` block that was
148
+ inlined at the top of most commands. Callers keep their own post-load
149
+ console output.
150
+ """
151
+ try:
152
+ return load_config()
153
+ except ConfigError as e:
154
+ _error(f"Configuration error: {e}")
155
+ raise typer.Exit(1)
156
+
157
+
158
+ _GENERATE_HINT = "\n[yellow]Hint:[/yellow] Run [bold]steppy generate[/bold] first to create test files"
159
+
160
+
161
+ def require_generated_tests(tests_dir: Path, *, require_test_files: bool = True) -> None:
162
+ """Exit 1 unless *tests_dir* holds generated tests.
163
+
164
+ The pre-flight shared by ``run`` and ``rerun``: the directory, its
165
+ ``conftest.py``, and (for ``run``) at least one ``test_*.py``. ``rerun``
166
+ skips the last check because pytest's ``--lf`` cache decides what runs.
167
+
168
+ Messages are unchanged from the five inlined copies this replaces.
169
+ """
170
+ if not tests_dir.exists():
171
+ console.print(f"[red]ERROR[/red] Tests directory not found: {tests_dir}")
172
+ console.print(_GENERATE_HINT)
173
+ raise typer.Exit(1)
174
+
175
+ if not (tests_dir / "conftest.py").exists():
176
+ console.print(f"[red]ERROR[/red] No generated tests found in {tests_dir}")
177
+ console.print(_GENERATE_HINT)
178
+ raise typer.Exit(1)
179
+
180
+ if require_test_files and not list(tests_dir.glob("test_*.py")):
181
+ console.print(f"[red]ERROR[/red] No test files found in {tests_dir}")
182
+ console.print(_GENERATE_HINT)
183
+ raise typer.Exit(1)
@@ -0,0 +1,53 @@
1
+ # Worker image built from an INSTALLED steppy (wheel), not a repo checkout.
2
+ #
3
+ # The repo-root Dockerfile COPYs pyproject.toml / hatch_build.py / src /
4
+ # templates / skills / CHANGELOG.md and pip-installs the package from source.
5
+ # None of those exist when steppy was installed as a wheel (pip install
6
+ # git+..., pip install <dir>, or from an index), so that Dockerfile cannot be
7
+ # used off a checkout-less machine. This one can.
8
+ #
9
+ # The coordinator synthesizes the build context under its run tmp dir:
10
+ #
11
+ # <ctx>/Dockerfile <- this file, copied verbatim
12
+ # <ctx>/pkg/steppy/ <- copy of the installed package
13
+ # <ctx>/pkg/steppy_dev-<v>.dist-info/ <- so importlib.metadata still works
14
+ # <ctx>/requirements.txt <- runtime deps read from installed metadata
15
+ #
16
+ # This is functionally equivalent to the repo build because the worker only
17
+ # ever runs `python -m steppy worker` -> pytest on the ALREADY generated test
18
+ # files bind-mounted from the project. It never generates code, so it needs
19
+ # the package plus its runtime deps and nothing from the repo.
20
+ #
21
+ # Base image: parameterized so `parallel.worker_base_image` can override it.
22
+ # The default is kept in lockstep with the repo Dockerfile's FROM line and
23
+ # with DEFAULT_PLAYWRIGHT_BASE in steppy/_parallel/image.py — the drift guard
24
+ # is tests/test_parallel_worker_image.py.
25
+ ARG PLAYWRIGHT_BASE=mcr.microsoft.com/playwright/python:v1.60.0-noble
26
+ FROM ${PLAYWRIGHT_BASE}
27
+
28
+ # Matches the coordinator's compose override (`working_dir: /steppy/project`)
29
+ # so cwd-relative paths work for pytest exactly like the host single-worker path.
30
+ WORKDIR /steppy/project
31
+
32
+ # Deps first — this layer is the expensive one and is cached across rebuilds
33
+ # triggered by a steppy source change (the common case on editable installs).
34
+ COPY requirements.txt /tmp/steppy-requirements.txt
35
+ RUN pip install --no-cache-dir -r /tmp/steppy-requirements.txt && rm /tmp/steppy-requirements.txt
36
+
37
+ # The package itself goes on PYTHONPATH rather than through pip — there is no
38
+ # sdist/pyproject to install from, and `python -m steppy` only needs the
39
+ # package importable. The copied .dist-info sits alongside it so
40
+ # importlib.metadata.version("steppy-dev") keeps working.
41
+ COPY pkg /steppy/pkg
42
+ ENV PYTHONPATH=/steppy/pkg
43
+
44
+ # `steppy ...` console-script shim. pip creates this in the repo-built image;
45
+ # `parallel.pre_auth` commands may shell out to it, so keep the two images
46
+ # interchangeable.
47
+ RUN printf '#!/bin/sh\nexec python -m steppy "$@"\n' > /usr/local/bin/steppy \
48
+ && chmod +x /usr/local/bin/steppy
49
+
50
+ # Worker entrypoint reads STEPPY_WORKER_ID, STEPPY_COORD_DIR, etc. from the
51
+ # coordinator-injected env and loops claiming scenarios until the queue is
52
+ # empty, then exits with 0 (success) or 1 (any scenario failed).
53
+ ENTRYPOINT ["python", "-m", "steppy", "worker"]
@@ -0,0 +1,7 @@
1
+ """Internal helpers for ``steppy run --workers N >= 2``.
2
+
3
+ This package is imported only on the parallel code path. The single-worker
4
+ path in ``steppy.cli.run`` uses lazy imports so default invocations never
5
+ load anything from here. See the lazy-import guard test in
6
+ ``tests/test_no_parallel_import_on_default_path.py``.
7
+ """
@@ -0,0 +1,349 @@
1
+ """Docker Compose helpers for parallel mode.
2
+
3
+ Three things live here:
4
+
5
+ 1. ``check_compose_version()`` — detect the local Docker Compose, fail fast
6
+ if older than 2.20 (we depend on the ``!reset`` YAML tag for nullifying
7
+ inherited port mappings).
8
+
9
+ 2. ``introspect_user_compose()`` — run ``docker compose -f <user> config
10
+ --format json`` to learn which services exist (and which need their host
11
+ ports nulled out).
12
+
13
+ 3. ``render_override()`` — build a per-worker ``override-w<N>.yml`` that adds
14
+ the ``steppy-worker`` service and uses ``!reset []`` to clear port
15
+ bindings on user services.
16
+
17
+ We hand-build the YAML rather than going through PyYAML because the
18
+ ``!reset`` tag is a Compose-specific extension and PyYAML's representer for
19
+ unknown tags is awkward. The override is small and stable, so a templated
20
+ string is the simplest tool.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import os
27
+ import re
28
+ import shlex
29
+ import subprocess
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+
34
+ MIN_COMPOSE_VERSION = (2, 20, 0)
35
+
36
+
37
+ class ComposeError(RuntimeError):
38
+ """Raised when a docker compose command fails or returns unparseable output."""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class ComposeIntrospection:
43
+ """The bits of the user's compose we need for override generation."""
44
+
45
+ services_with_ports: list[str]
46
+ networks: list[str] # named networks declared at the top level
47
+ # Services declaring a `healthcheck:` block — the only ones a preauth/worker
48
+ # gate can meaningfully wait on via `condition: service_healthy`.
49
+ services_with_healthcheck: list[str] = field(default_factory=list)
50
+
51
+
52
+ def _run_compose(args: list[str], cwd: Path | None = None, timeout: int = 30) -> subprocess.CompletedProcess:
53
+ """Run ``docker compose <args>`` with stderr captured. Raises on non-zero."""
54
+ cmd = ["docker", "compose", *args]
55
+ try:
56
+ result = subprocess.run(
57
+ cmd,
58
+ cwd=cwd,
59
+ capture_output=True,
60
+ text=True,
61
+ encoding="utf-8",
62
+ errors="replace",
63
+ timeout=timeout,
64
+ )
65
+ except FileNotFoundError as e:
66
+ raise ComposeError(
67
+ "docker not found on PATH. Install Docker Desktop (Windows/macOS) "
68
+ "or the docker CLI (Linux) and ensure `docker compose` works."
69
+ ) from e
70
+ except subprocess.TimeoutExpired as e:
71
+ raise ComposeError(f"docker compose timed out after {timeout}s: {' '.join(cmd)}") from e
72
+ if result.returncode != 0:
73
+ raise ComposeError(
74
+ f"docker compose failed (exit {result.returncode}):\n"
75
+ f" cmd: {' '.join(cmd)}\n"
76
+ f" stderr: {result.stderr.strip()}"
77
+ )
78
+ return result
79
+
80
+
81
+ def check_compose_version() -> tuple[int, int, int]:
82
+ """Return the parsed ``(major, minor, patch)`` of the local Docker Compose.
83
+ Raises ``ComposeError`` if Compose is older than 2.20 (we need ``!reset``)."""
84
+ result = _run_compose(["version", "--format", "json"])
85
+ try:
86
+ data = json.loads(result.stdout)
87
+ version_str = data.get("version", "").lstrip("v")
88
+ except json.JSONDecodeError as e:
89
+ raise ComposeError(f"unparseable `docker compose version` output: {result.stdout!r}") from e
90
+ match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_str)
91
+ if not match:
92
+ raise ComposeError(f"could not parse compose version: {version_str!r}")
93
+ parsed = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
94
+ if parsed < MIN_COMPOSE_VERSION:
95
+ min_str = ".".join(str(x) for x in MIN_COMPOSE_VERSION)
96
+ raise ComposeError(
97
+ f"steppy parallel mode requires Docker Compose >= {min_str} (found {version_str}). "
98
+ f"Compose 2.20 introduced the !reset YAML tag we use to clear inherited port mappings. "
99
+ f"Update Docker Desktop or your standalone docker-compose-plugin."
100
+ )
101
+ return parsed
102
+
103
+
104
+ def introspect_user_compose(compose_file: Path) -> ComposeIntrospection:
105
+ """Discover which services declare host port mappings (need nulling) and
106
+ which named networks the user defines (need joining by steppy-worker).
107
+
108
+ Uses ``docker compose -f <file> config --format json`` so we get a fully
109
+ resolved view (env-var substitution applied, !include processed, etc.) —
110
+ much safer than re-implementing Compose semantics in PyYAML.
111
+ """
112
+ result = _run_compose(["-f", str(compose_file), "config", "--format", "json"], cwd=compose_file.parent)
113
+ try:
114
+ data = json.loads(result.stdout)
115
+ except json.JSONDecodeError as e:
116
+ raise ComposeError(f"unparseable `docker compose config` output: {e}") from e
117
+
118
+ services = data.get("services", {}) or {}
119
+ services_with_ports = [name for name, svc in services.items() if svc.get("ports")]
120
+
121
+ networks = list((data.get("networks") or {}).keys())
122
+
123
+ # Pre-flight: every depends_on with `condition: service_healthy` must point
124
+ # at a service that actually declares a healthcheck. Without this,
125
+ # `compose up --wait` returns immediately on a false success.
126
+ missing: list[str] = []
127
+ for svc_name, svc in services.items():
128
+ depends = svc.get("depends_on") or {}
129
+ if isinstance(depends, list):
130
+ continue # short form, no condition
131
+ for dep_name, spec in depends.items():
132
+ if isinstance(spec, dict) and spec.get("condition") == "service_healthy":
133
+ if not (services.get(dep_name) or {}).get("healthcheck"):
134
+ missing.append(
135
+ f"{svc_name} depends on {dep_name} (service_healthy) but {dep_name} has no healthcheck block"
136
+ )
137
+ if missing:
138
+ raise ComposeError(
139
+ "docker-compose pre-flight failed:\n - "
140
+ + "\n - ".join(missing)
141
+ + "\nAdd a `healthcheck:` block to each service referenced via "
142
+ "`depends_on: condition: service_healthy`, or `compose up --wait` "
143
+ "will report a false success and workers will race the app."
144
+ )
145
+
146
+ return ComposeIntrospection(
147
+ services_with_ports=sorted(services_with_ports),
148
+ networks=sorted(networks),
149
+ services_with_healthcheck=sorted(name for name, svc in services.items() if svc.get("healthcheck")),
150
+ )
151
+
152
+
153
+ def render_override(
154
+ *,
155
+ introspection: ComposeIntrospection,
156
+ worker_image: str,
157
+ image_tag: str,
158
+ worker_index: int,
159
+ run_id: str,
160
+ project_root: Path,
161
+ tmp_dir: Path,
162
+ base_url_override: str, # JSON-encoded {role: url} map
163
+ locales: str,
164
+ layouts: str,
165
+ guide_mode: str,
166
+ no_asserts: str,
167
+ screenshots: str,
168
+ coverage: str = "",
169
+ pre_auth_command: str | list[str] | None = None,
170
+ pre_auth_image: str | None = None,
171
+ pre_auth_timeout: int = 600,
172
+ trace_mode: str = "off",
173
+ video_mode: str = "off",
174
+ retries: int = 0,
175
+ pytest_extra: list[str] | None = None,
176
+ coord_subdir: str = "",
177
+ auth_mount: str = "",
178
+ events_dir: str = "",
179
+ junit_dir: str = "",
180
+ tour_expect_build_id: str = "",
181
+ ) -> str:
182
+ """Render a docker-compose override YAML for one worker.
183
+
184
+ Pairs with the user's compose via ``-f <user> -f <override>``. Uses the
185
+ ``!reset`` YAML tag (Compose 2.20+) to clear inherited host port mappings
186
+ so multiple stacks can run concurrently without port collisions —
187
+ workers reach the app via internal Docker DNS, host ports aren't needed.
188
+
189
+ Mount strategy:
190
+ /steppy/project — project root, RO
191
+ /steppy/project/.auth — overlay RW (storage_state cache shared across workers,
192
+ or a per-worker private dir when ``auth_mount`` is set)
193
+ /steppy/project/output — overlay RW (each scenario writes to its own subdir)
194
+ /steppy/coord — coordinator's tmp dir (queue/, running/, results/)
195
+
196
+ Optional knobs, all inert when unset so the rendered YAML stays
197
+ byte-identical to the pre-feature output (guard:
198
+ ``tests/test_parallel_report_flags.py`` / ``tests/test_parallel_preauth.py``):
199
+
200
+ ``trace_mode`` / ``video_mode`` / ``retries`` / ``pytest_extra``
201
+ Report settings resolved host-side by ``cli._resolve_report_modes`` and
202
+ the ``--`` passthrough argv. Emitted as ``STEPPY_TRACE`` /
203
+ ``STEPPY_VIDEO`` / ``STEPPY_RETRIES`` / ``STEPPY_PYTEST_EXTRA``; the
204
+ first two are read by the generated conftest, the last two by
205
+ ``worker._build_pytest_argv``.
206
+ ``coord_subdir``
207
+ Points ``STEPPY_COORD_DIR`` at a subdirectory of the shared coord mount.
208
+ The retry pass uses this to give attempt *n* its own queue/claims/results
209
+ tree, so each attempt's evidence survives independently.
210
+ ``auth_mount``
211
+ Absolute host path to bind at ``/steppy/project/.auth`` instead of the
212
+ project's shared ``.auth``. Used by ``parallel.isolate_auth`` to give
213
+ each worker a private storage-state dir.
214
+ ``tour_expect_build_id``
215
+ The host's ``STEPPY_TOUR_EXPECT_BUILD_ID``, read inside the container by
216
+ the tour replays' helper (``_steppy_tours.py``): the build id the app
217
+ must serve.
218
+ """
219
+ worker_id = f"w{worker_index}"
220
+ networks_block = ", ".join(introspection.networks) if introspection.networks else "default"
221
+
222
+ # !reset on each user service that had ports. Hand-rolled because PyYAML
223
+ # doesn't represent custom tags cleanly.
224
+ reset_blocks = "\n".join(f" {svc}:\n ports: !reset []" for svc in introspection.services_with_ports)
225
+ if not reset_blocks:
226
+ reset_blocks = " # (no user services declare host ports; nothing to clear)"
227
+
228
+ # Volume bind sources are absolute host paths so Docker Desktop's
229
+ # path-translation works on Windows, macOS, and Linux without surprises.
230
+ project_abs = project_root.resolve().as_posix()
231
+ tmp_abs = tmp_dir.resolve().as_posix()
232
+
233
+ # The worker's view of the coordinator dir. A retry pass hands us a subdir
234
+ # so attempt N gets its own queue/claims/results without disturbing the
235
+ # records the previous attempt left behind.
236
+ coord_dir = "/steppy/coord" + (f"/{coord_subdir}" if coord_subdir else "")
237
+ # Where the worker's `.auth` comes from. Default = the project's shared
238
+ # dir (one storage-state cache for every worker); `parallel.isolate_auth`
239
+ # swaps in a per-worker private dir.
240
+ auth_source = auth_mount or f"{project_abs}/.auth"
241
+
242
+ # Optional env lines. Each is emitted ONLY when set, so an unused feature
243
+ # leaves the rendered override byte-identical to the pre-feature output.
244
+ extra_env = ""
245
+ if trace_mode and trace_mode != "off":
246
+ extra_env += f" STEPPY_TRACE: '{trace_mode}'\n"
247
+ if video_mode and video_mode != "off":
248
+ extra_env += f" STEPPY_VIDEO: '{video_mode}'\n"
249
+ if retries:
250
+ extra_env += f" STEPPY_RETRIES: '{retries}'\n"
251
+ if pytest_extra:
252
+ # shlex-joined so the worker can round-trip it with shlex.split. Single
253
+ # quotes are the YAML string delimiter here, so double them to escape.
254
+ joined = shlex.join(pytest_extra).replace("'", "''")
255
+ extra_env += f" STEPPY_PYTEST_EXTRA: '{joined}'\n"
256
+ # ``--bundle`` sinks. In-container paths under the writable output/ mount —
257
+ # every worker writes into the SAME shared directories, which is safe
258
+ # because both are keyed by scenario and a scenario is claimed by exactly
259
+ # one worker.
260
+ if events_dir:
261
+ extra_env += f" STEPPY_EVENTS_DIR: '{events_dir}'\n"
262
+ # Mirrors cli._build_run_env: step markers ride with the event records,
263
+ # and STEPPY_STEP_EVENTS=0 on the host opts out.
264
+ if os.environ.get("STEPPY_STEP_EVENTS") != "0":
265
+ extra_env += " STEPPY_STEP_EVENTS: '1'\n"
266
+ if junit_dir:
267
+ extra_env += f" STEPPY_JUNIT_DIR: '{junit_dir}'\n"
268
+ if tour_expect_build_id:
269
+ quoted = tour_expect_build_id.replace("'", "''")
270
+ extra_env += f" STEPPY_TOUR_EXPECT_BUILD_ID: '{quoted}'\n"
271
+
272
+ # Optional pre-auth gate (config `parallel.pre_auth`). When set, add a
273
+ # one-off `steppy-preauth` service that runs the login command in-network
274
+ # (reusing the worker image + the project/.auth/coord mounts) and make
275
+ # `steppy-worker` wait for it via `service_completed_successfully`. Absent ⇒
276
+ # both blocks are empty strings ⇒ the override is byte-identical to before.
277
+ preauth_service = ""
278
+ worker_depends = ""
279
+ if pre_auth_command is not None:
280
+ preauth_image = pre_auth_image or f"{worker_image}:{image_tag}"
281
+ inner = (
282
+ ["sh", "-c", pre_auth_command] if isinstance(pre_auth_command, str) else [str(p) for p in pre_auth_command]
283
+ )
284
+ entrypoint_json = json.dumps(["python", "-m", "steppy", "_preauth-run"])
285
+ command_json = json.dumps(["--timeout", str(pre_auth_timeout), "--", *inner])
286
+ # Gate the login on every user service that declares a healthcheck, so
287
+ # it only runs once the app is actually serving. Services without a
288
+ # healthcheck can't be gated (see the pre_auth docs) — if none exist the
289
+ # container relies on the login command's own readiness waits.
290
+ if introspection.services_with_healthcheck:
291
+ dep_lines = "\n".join(
292
+ f" {svc}:\n condition: service_healthy" for svc in introspection.services_with_healthcheck
293
+ )
294
+ preauth_depends = f" depends_on:\n{dep_lines}\n"
295
+ else:
296
+ preauth_depends = ""
297
+ preauth_service = f"""
298
+ steppy-preauth:
299
+ image: {preauth_image}
300
+ networks: [{networks_block}]
301
+ entrypoint: {entrypoint_json}
302
+ command: {command_json}
303
+ environment:
304
+ STEPPY_COORD_DIR: /steppy/coord
305
+ STEPPY_BASE_URL_OVERRIDE: '{base_url_override}'
306
+ PYTHONUNBUFFERED: '1'
307
+ working_dir: /steppy/project
308
+ volumes:
309
+ - {project_abs}:/steppy/project:ro
310
+ - {project_abs}/.auth:/steppy/project/.auth
311
+ - {tmp_abs}:/steppy/coord
312
+ {preauth_depends}"""
313
+ worker_depends = " depends_on:\n steppy-preauth:\n condition: service_completed_successfully\n"
314
+
315
+ return f"""# Auto-generated by steppy run --workers — do NOT edit.
316
+ # Pairs with the user compose via: docker compose -f <user> -f <this>
317
+ # Worker: {worker_id} RunId: {run_id}
318
+
319
+ services:
320
+ {reset_blocks}
321
+ {preauth_service}
322
+ steppy-worker:
323
+ image: {worker_image}:{image_tag}
324
+ networks: [{networks_block}]
325
+ {worker_depends} environment:
326
+ STEPPY_WORKER_ID: '{worker_id}'
327
+ STEPPY_RUN_ID: '{run_id}'
328
+ STEPPY_COORD_DIR: {coord_dir}
329
+ STEPPY_BASE_URL_OVERRIDE: '{base_url_override}'
330
+ STEPPY_LOCALES: '{locales}'
331
+ STEPPY_LAYOUTS: '{layouts}'
332
+ STEPPY_GUIDE_MODE: '{guide_mode}'
333
+ STEPPY_NO_ASSERTS: '{no_asserts}'
334
+ STEPPY_SCREENSHOTS: '{screenshots}'
335
+ STEPPY_COVERAGE: '{coverage}'
336
+ {extra_env} # Parallel-mode visibility: the generated conftest loads the events
337
+ # plugin only when this is '1', and the worker switches pytest argv
338
+ # to ``-q --tb=short``. Single-worker runs never set these.
339
+ STEPPY_EMIT_EVENTS: '1'
340
+ # Unblock Python's stdout buffering inside the container so pytest /
341
+ # events appear in real time over ``docker compose logs -f``.
342
+ PYTHONUNBUFFERED: '1'
343
+ working_dir: /steppy/project
344
+ volumes:
345
+ - {project_abs}:/steppy/project:ro
346
+ - {auth_source}:/steppy/project/.auth
347
+ - {project_abs}/output:/steppy/project/output
348
+ - {tmp_abs}:/steppy/coord
349
+ """