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.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- 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
|
+
"""
|