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/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
|