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/history.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""Append-only run history — the substrate for ``steppy flaky``.
|
|
2
|
+
|
|
3
|
+
A single run's summary tells you what broke today. It cannot tell you that
|
|
4
|
+
``checkout-with-coupon`` has failed in seven of the last twenty runs while
|
|
5
|
+
everything around it is stable. That ranking is what actually directs a fix,
|
|
6
|
+
and it needs one thing the codebase did not have: memory.
|
|
7
|
+
|
|
8
|
+
Storage is ``.steppy/history.jsonl`` — one JSON object per (run, scenario),
|
|
9
|
+
appended, never rewritten. Deliberately **not** under ``output/``: that tree is
|
|
10
|
+
wiped by cleanup, and history that evaporates on a clean build is worse than no
|
|
11
|
+
history because it looks authoritative. This mirrors the coverage store's
|
|
12
|
+
reasoning for living at ``.steppy/coverage.db``.
|
|
13
|
+
|
|
14
|
+
JSONL rather than sqlite because the access pattern is "read the tail, group in
|
|
15
|
+
memory" — a few thousand lines is nothing, and a text file survives partial
|
|
16
|
+
writes, concurrent appends and manual inspection in a way a binary store does
|
|
17
|
+
not.
|
|
18
|
+
|
|
19
|
+
This is a leaf module: stdlib only, no imports from ``cli``, ``coordinator`` or
|
|
20
|
+
``_parallel``. ``steppy.flaky_cli`` and the coordinator both use it, and the
|
|
21
|
+
no-parallel-import guard depends on it staying that way.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import statistics
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Iterable, Optional
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
HISTORY_DIRNAME = ".steppy"
|
|
36
|
+
HISTORY_FILENAME = "history.jsonl"
|
|
37
|
+
|
|
38
|
+
# Bumped if the record shape changes incompatibly. Readers skip records whose
|
|
39
|
+
# version they don't understand rather than crashing on an old file.
|
|
40
|
+
RECORD_SCHEMA_VERSION = 1
|
|
41
|
+
|
|
42
|
+
# Outcomes a record can carry.
|
|
43
|
+
OUTCOME_PASSED = "passed"
|
|
44
|
+
OUTCOME_FLAKY = "flaky"
|
|
45
|
+
OUTCOME_FAILED = "failed"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def history_path(project_root: Path) -> Path:
|
|
49
|
+
return Path(project_root) / HISTORY_DIRNAME / HISTORY_FILENAME
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Writing ─────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def append_run(
|
|
56
|
+
*,
|
|
57
|
+
project_root: Path,
|
|
58
|
+
run_id: str,
|
|
59
|
+
workers: int,
|
|
60
|
+
passes: Iterable[Any],
|
|
61
|
+
verdict: Optional[Any] = None,
|
|
62
|
+
bus: Optional[Any] = None,
|
|
63
|
+
started_at: Optional[float] = None,
|
|
64
|
+
) -> Optional[Path]:
|
|
65
|
+
"""Append one record per scenario for a just-finished parallel run.
|
|
66
|
+
|
|
67
|
+
``passes`` are the :class:`~steppy._parallel.retry.PassResult` objects
|
|
68
|
+
(attempt 1 first). ``verdict`` is the classification when a retry pass ran;
|
|
69
|
+
without one every scenario is simply passed/failed.
|
|
70
|
+
|
|
71
|
+
Returns the path written, or ``None`` on any failure — history is
|
|
72
|
+
telemetry, and the caller's run has already produced its real outcome. This
|
|
73
|
+
function never raises.
|
|
74
|
+
"""
|
|
75
|
+
try:
|
|
76
|
+
passes = list(passes)
|
|
77
|
+
records = _build_records(
|
|
78
|
+
run_id=run_id,
|
|
79
|
+
workers=workers,
|
|
80
|
+
passes=passes,
|
|
81
|
+
verdict=verdict,
|
|
82
|
+
bus=bus,
|
|
83
|
+
started_at=started_at,
|
|
84
|
+
)
|
|
85
|
+
if not records:
|
|
86
|
+
return None
|
|
87
|
+
path = history_path(project_root)
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
# One open/append per run keeps the write atomic enough for our
|
|
90
|
+
# purposes: concurrent runs interleave whole lines, never partial ones.
|
|
91
|
+
with path.open("a", encoding="utf-8") as f:
|
|
92
|
+
for record in records:
|
|
93
|
+
f.write(json.dumps(record, sort_keys=True) + "\n")
|
|
94
|
+
_ensure_gitignored(path.parent)
|
|
95
|
+
return path
|
|
96
|
+
except (OSError, TypeError, ValueError, AttributeError):
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _build_records(*, run_id, workers, passes, verdict, bus, started_at) -> list[dict]:
|
|
101
|
+
"""One record per scenario, reflecting its FINAL outcome across attempts."""
|
|
102
|
+
if not passes:
|
|
103
|
+
return []
|
|
104
|
+
now = time.time()
|
|
105
|
+
started = started_at
|
|
106
|
+
if started is None and bus is not None:
|
|
107
|
+
started = getattr(bus.snapshot(), "wall_clock_started", None)
|
|
108
|
+
started = started if started is not None else now
|
|
109
|
+
|
|
110
|
+
flaky = set(getattr(verdict, "flaky", ()) or ())
|
|
111
|
+
failed = set(getattr(verdict, "failed", ()) or ())
|
|
112
|
+
|
|
113
|
+
# Last attempt wins for the per-scenario metadata (worker, duration).
|
|
114
|
+
last_result: dict[str, Any] = {}
|
|
115
|
+
attempts_used: dict[str, int] = {}
|
|
116
|
+
for p in passes:
|
|
117
|
+
for r in p.results:
|
|
118
|
+
last_result[r.slug] = r
|
|
119
|
+
attempts_used[r.slug] = p.attempt
|
|
120
|
+
for slugs in p.orphans.values():
|
|
121
|
+
for slug in slugs:
|
|
122
|
+
attempts_used.setdefault(slug, p.attempt)
|
|
123
|
+
last_result.setdefault(slug, None)
|
|
124
|
+
|
|
125
|
+
fingerprints = _fingerprints_by_slug(passes)
|
|
126
|
+
|
|
127
|
+
records: list[dict] = []
|
|
128
|
+
for slug in sorted(last_result):
|
|
129
|
+
result = last_result[slug]
|
|
130
|
+
if verdict is not None:
|
|
131
|
+
if slug in failed:
|
|
132
|
+
outcome = OUTCOME_FAILED
|
|
133
|
+
elif slug in flaky:
|
|
134
|
+
outcome = OUTCOME_FLAKY
|
|
135
|
+
else:
|
|
136
|
+
outcome = OUTCOME_PASSED
|
|
137
|
+
else:
|
|
138
|
+
outcome = OUTCOME_PASSED if (result is not None and result.exit_code == 0) else OUTCOME_FAILED
|
|
139
|
+
records.append(
|
|
140
|
+
{
|
|
141
|
+
"v": RECORD_SCHEMA_VERSION,
|
|
142
|
+
"run_id": run_id,
|
|
143
|
+
"started_at": round(started, 3),
|
|
144
|
+
"mode": "parallel",
|
|
145
|
+
"workers": workers,
|
|
146
|
+
"slug": slug,
|
|
147
|
+
"outcome": outcome,
|
|
148
|
+
"attempts": attempts_used.get(slug, 1),
|
|
149
|
+
"duration_s": round(result.duration_s, 3) if result is not None else None,
|
|
150
|
+
"worker_id": result.worker_id if result is not None else None,
|
|
151
|
+
"fingerprint": fingerprints.get(slug),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return records
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _fingerprints_by_slug(passes) -> dict[str, str]:
|
|
158
|
+
"""Failure fingerprint per scenario, from the last attempt that produced a
|
|
159
|
+
tail. Lets ``steppy flaky`` say "these five always fail the same way"."""
|
|
160
|
+
try:
|
|
161
|
+
from ._parallel.fingerprint import fingerprint
|
|
162
|
+
except ImportError:
|
|
163
|
+
return {}
|
|
164
|
+
out: dict[str, str] = {}
|
|
165
|
+
for p in passes:
|
|
166
|
+
bus = getattr(p, "bus", None)
|
|
167
|
+
if bus is None:
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
tests = bus.snapshot().tests
|
|
171
|
+
except AttributeError:
|
|
172
|
+
continue
|
|
173
|
+
for t in tests:
|
|
174
|
+
if t.outcome in ("failed", "error", "worker_died") and t.fail_tail and t.scenario_slug:
|
|
175
|
+
out[t.scenario_slug] = fingerprint(t.fail_tail)
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _ensure_gitignored(steppy_dir: Path) -> None:
|
|
180
|
+
"""``.steppy/`` is already gitignored in scaffolded projects; add a local
|
|
181
|
+
guard for projects that predate it so history never lands in a commit."""
|
|
182
|
+
marker = steppy_dir / ".gitignore"
|
|
183
|
+
if marker.exists():
|
|
184
|
+
return
|
|
185
|
+
try:
|
|
186
|
+
marker.write_text("# Created by steppy automatically.\n*\n", encoding="utf-8")
|
|
187
|
+
except OSError:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ── Reading ─────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load(project_root: Path, *, last_runs: Optional[int] = None) -> list[dict]:
|
|
195
|
+
"""Read history records, oldest first.
|
|
196
|
+
|
|
197
|
+
``last_runs`` keeps only the N most recent runs (by first appearance), which
|
|
198
|
+
is what makes "flaky over the last 20 runs" cheap. Malformed or
|
|
199
|
+
future-versioned lines are skipped, never fatal — a corrupt tail from a
|
|
200
|
+
killed process must not make the command unusable.
|
|
201
|
+
"""
|
|
202
|
+
path = history_path(project_root)
|
|
203
|
+
if not path.is_file():
|
|
204
|
+
return []
|
|
205
|
+
records: list[dict] = []
|
|
206
|
+
try:
|
|
207
|
+
with path.open("r", encoding="utf-8", errors="replace") as f:
|
|
208
|
+
for line in f:
|
|
209
|
+
line = line.strip()
|
|
210
|
+
if not line:
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
record = json.loads(line)
|
|
214
|
+
except json.JSONDecodeError:
|
|
215
|
+
continue
|
|
216
|
+
if not isinstance(record, dict) or record.get("v") != RECORD_SCHEMA_VERSION:
|
|
217
|
+
continue
|
|
218
|
+
if record.get("slug"):
|
|
219
|
+
records.append(record)
|
|
220
|
+
except OSError:
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
if last_runs is not None and last_runs > 0:
|
|
224
|
+
seen: list[str] = []
|
|
225
|
+
for record in records:
|
|
226
|
+
rid = record.get("run_id")
|
|
227
|
+
if rid not in seen:
|
|
228
|
+
seen.append(rid)
|
|
229
|
+
keep = set(seen[-last_runs:])
|
|
230
|
+
records = [r for r in records if r.get("run_id") in keep]
|
|
231
|
+
return records
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass(frozen=True)
|
|
235
|
+
class FlakeStat:
|
|
236
|
+
"""Aggregated behaviour of one scenario over a window of runs."""
|
|
237
|
+
|
|
238
|
+
slug: str
|
|
239
|
+
runs: int
|
|
240
|
+
failures: int # runs that ended failed
|
|
241
|
+
flakes: int # runs that needed a retry to pass
|
|
242
|
+
median_s: Optional[float]
|
|
243
|
+
p95_s: Optional[float]
|
|
244
|
+
last_outcome: str
|
|
245
|
+
fingerprints: list[str]
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def flake_rate(self) -> float:
|
|
249
|
+
"""Fraction of runs where this scenario did not pass first try.
|
|
250
|
+
|
|
251
|
+
Flakes and outright failures both count: from the reader's point of
|
|
252
|
+
view "this scenario cost me attention" is the signal, and a scenario
|
|
253
|
+
that fails outright half the time is not healthier than one that
|
|
254
|
+
flakes half the time."""
|
|
255
|
+
return (self.failures + self.flakes) / self.runs if self.runs else 0.0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def flake_stats(records: Iterable[dict]) -> list[FlakeStat]:
|
|
259
|
+
"""Aggregate records per scenario, worst first.
|
|
260
|
+
|
|
261
|
+
Ordering is by flake rate, then by absolute count, then slug — so a
|
|
262
|
+
scenario that failed 7/10 outranks one that failed 1/2 despite the lower
|
|
263
|
+
ratio being computed from more evidence.
|
|
264
|
+
"""
|
|
265
|
+
by_slug: dict[str, list[dict]] = {}
|
|
266
|
+
for record in records:
|
|
267
|
+
by_slug.setdefault(record["slug"], []).append(record)
|
|
268
|
+
|
|
269
|
+
stats: list[FlakeStat] = []
|
|
270
|
+
for slug, rows in by_slug.items():
|
|
271
|
+
durations = [r["duration_s"] for r in rows if isinstance(r.get("duration_s"), (int, float))]
|
|
272
|
+
fps = []
|
|
273
|
+
for r in rows:
|
|
274
|
+
fp = r.get("fingerprint")
|
|
275
|
+
if fp and fp not in fps:
|
|
276
|
+
fps.append(fp)
|
|
277
|
+
stats.append(
|
|
278
|
+
FlakeStat(
|
|
279
|
+
slug=slug,
|
|
280
|
+
runs=len(rows),
|
|
281
|
+
failures=sum(1 for r in rows if r.get("outcome") == OUTCOME_FAILED),
|
|
282
|
+
flakes=sum(1 for r in rows if r.get("outcome") == OUTCOME_FLAKY),
|
|
283
|
+
median_s=round(statistics.median(durations), 1) if durations else None,
|
|
284
|
+
p95_s=round(_percentile(durations, 0.95), 1) if durations else None,
|
|
285
|
+
last_outcome=rows[-1].get("outcome", "unknown"),
|
|
286
|
+
fingerprints=fps,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
stats.sort(key=lambda s: (-s.flake_rate, -(s.failures + s.flakes), s.slug))
|
|
290
|
+
return stats
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _percentile(values: list[float], q: float) -> float:
|
|
294
|
+
"""Nearest-rank percentile. Avoids a numpy dependency for one number."""
|
|
295
|
+
if not values:
|
|
296
|
+
return 0.0
|
|
297
|
+
ordered = sorted(values)
|
|
298
|
+
index = max(0, min(len(ordered) - 1, int(round(q * (len(ordered) - 1)))))
|
|
299
|
+
return ordered[index]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def median_durations(project_root: Path, *, last_runs: int = 10) -> dict[str, float]:
|
|
303
|
+
"""Median duration per scenario, for longest-first queue ordering.
|
|
304
|
+
|
|
305
|
+
Returns ``{}`` when there is no history — the caller falls back to whatever
|
|
306
|
+
order it already had.
|
|
307
|
+
"""
|
|
308
|
+
durations: dict[str, list[float]] = {}
|
|
309
|
+
for record in load(project_root, last_runs=last_runs):
|
|
310
|
+
value = record.get("duration_s")
|
|
311
|
+
if isinstance(value, (int, float)) and value > 0:
|
|
312
|
+
durations.setdefault(record["slug"], []).append(float(value))
|
|
313
|
+
return {slug: statistics.median(values) for slug, values in durations.items()}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def order_by_duration_desc(slugs: list[str], durations: dict[str, float]) -> list[str]:
|
|
317
|
+
"""Longest-first (LPT) ordering, preserving the caller's order for ties.
|
|
318
|
+
|
|
319
|
+
Scenarios with no recorded duration go last: an unknown is most likely a
|
|
320
|
+
newly added scenario, and guessing it is slow would push a genuinely slow
|
|
321
|
+
known one behind it. Unknown-last also makes the no-history case a no-op.
|
|
322
|
+
"""
|
|
323
|
+
if not durations:
|
|
324
|
+
return list(slugs)
|
|
325
|
+
known = [s for s in slugs if s in durations]
|
|
326
|
+
unknown = [s for s in slugs if s not in durations]
|
|
327
|
+
known.sort(key=lambda s: -durations[s])
|
|
328
|
+
return known + unknown
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def prune(project_root: Path, *, keep_runs: int) -> int:
|
|
332
|
+
"""Rewrite the history file keeping only the newest ``keep_runs`` runs.
|
|
333
|
+
|
|
334
|
+
Returns the number of records dropped (0 on any error). Uses a temp file +
|
|
335
|
+
replace so an interrupted prune can't truncate the history."""
|
|
336
|
+
path = history_path(project_root)
|
|
337
|
+
if not path.is_file():
|
|
338
|
+
return 0
|
|
339
|
+
before = load(project_root)
|
|
340
|
+
kept = load(project_root, last_runs=keep_runs)
|
|
341
|
+
if len(kept) == len(before):
|
|
342
|
+
return 0
|
|
343
|
+
tmp = path.with_suffix(".jsonl.tmp")
|
|
344
|
+
try:
|
|
345
|
+
with tmp.open("w", encoding="utf-8") as f:
|
|
346
|
+
for record in kept:
|
|
347
|
+
f.write(json.dumps(record, sort_keys=True) + "\n")
|
|
348
|
+
os.replace(tmp, path)
|
|
349
|
+
except OSError:
|
|
350
|
+
tmp.unlink(missing_ok=True)
|
|
351
|
+
return 0
|
|
352
|
+
return len(before) - len(kept)
|
steppy/init_cli.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""``steppy init`` — scaffold a new project (FLAWS §7.1).
|
|
2
|
+
|
|
3
|
+
Zero-to-first-test is otherwise ~7 non-trivial steps, two of which (authoring
|
|
4
|
+
``config.yaml`` and a first scenario) have no scaffolding at all. ``init`` writes
|
|
5
|
+
a commented neutral ``config.yaml`` (with a ``$schema`` header for editor
|
|
6
|
+
autocomplete), a runnable starter scenario, ``credentials.yaml.example``, a
|
|
7
|
+
config JSON Schema, and a ``.steppy/scaffold.json`` manifest, and appends the
|
|
8
|
+
right ``.gitignore`` entries. ``--ci`` also drops in the GitHub Actions workflow,
|
|
9
|
+
and ``--app-dir`` puts the app-side skill in the app's own repository.
|
|
10
|
+
|
|
11
|
+
Re-running it is safe, which is also how a project set up by hand adopts the
|
|
12
|
+
scaffold: existing files are kept, the starter scenario is left out when
|
|
13
|
+
``scenarios/`` already holds some, and the manifest is merged, not replaced.
|
|
14
|
+
|
|
15
|
+
A leaf module: imports only from ``_cli_common`` + stdlib (no cycle with
|
|
16
|
+
``cli``). Registered via :func:`register_init` at the bottom of ``cli.py``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import importlib.resources
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional
|
|
27
|
+
|
|
28
|
+
import typer
|
|
29
|
+
|
|
30
|
+
from . import __version__
|
|
31
|
+
from ._cli_common import _cli_errors, _error, console
|
|
32
|
+
from .config_schema import schema_json
|
|
33
|
+
|
|
34
|
+
# Files init lays down, resolved relative to the target project root. Value is
|
|
35
|
+
# the bundled source path under steppy/templates/ (None ⇒ generated in code).
|
|
36
|
+
_SCHEMA_FILENAME = "steppy-config.schema.json"
|
|
37
|
+
|
|
38
|
+
# Packaged Claude skills installed into <project>/.claude/skills/<name>/SKILL.md
|
|
39
|
+
# (FLAWS §7.1). Each name matches the skill's frontmatter `name:` and its
|
|
40
|
+
# bundled source dir under steppy/skills/<name>/.
|
|
41
|
+
SKILL_NAMES = ("steppy-scenario-author", "steppy-template", "steppy-ci-report", "steppy-app-tours")
|
|
42
|
+
|
|
43
|
+
# The skills that also belong in the app's own repository (``--app-dir``). The
|
|
44
|
+
# code they walk an assistant through lives there, and an assistant only sees
|
|
45
|
+
# the skills of the tree it works in.
|
|
46
|
+
APP_SKILL_NAMES = ("steppy-app-tours",)
|
|
47
|
+
|
|
48
|
+
# Skill folders people copied in by hand before Steppy packaged its skills, and
|
|
49
|
+
# what replaced each. init never wrote them, so no manifest has their hash and
|
|
50
|
+
# nothing can tell a pristine copy from an edited one: they are reported, never
|
|
51
|
+
# removed. Left in place, an assistant loads them beside their replacement.
|
|
52
|
+
LEGACY_SKILLS = {
|
|
53
|
+
"steppy": "steppy-scenario-author",
|
|
54
|
+
"steppy-angular-export": "steppy-scenario-author",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
# .gitignore lines init ensures are present (idempotent append). The negation
|
|
58
|
+
# un-ignores the committed scaffold manifest that lives under the ignored
|
|
59
|
+
# .steppy/ dir.
|
|
60
|
+
_GITIGNORE_LINES = (
|
|
61
|
+
".auth/",
|
|
62
|
+
"output/",
|
|
63
|
+
".steppy/",
|
|
64
|
+
"credentials.yaml",
|
|
65
|
+
"!.steppy/scaffold.json",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _bundled(*parts: str) -> Path:
|
|
70
|
+
"""Absolute path to a bundled asset under ``steppy/templates/``."""
|
|
71
|
+
return Path(importlib.resources.files("steppy") / "templates" / Path(*parts))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bundled_skill(*parts: str) -> Path:
|
|
75
|
+
"""Absolute path to a bundled asset under ``steppy/skills/``."""
|
|
76
|
+
return Path(importlib.resources.files("steppy") / "skills" / Path(*parts))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _sha256(text: str) -> str:
|
|
80
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _rel(root: Path, path: Path) -> str:
|
|
84
|
+
"""``path`` as the manifest records it: relative to ``root``, ``../`` included.
|
|
85
|
+
|
|
86
|
+
An app repository given to ``--app-dir`` usually sits beside the project.
|
|
87
|
+
On another Windows drive no relative path exists, so the path stays absolute.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
return Path(os.path.relpath(path, root)).as_posix()
|
|
91
|
+
except ValueError:
|
|
92
|
+
return path.as_posix()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _write(target: Path, content: str, written: dict, skipped: list) -> None:
|
|
96
|
+
"""Write ``content`` to ``target`` unless it already exists.
|
|
97
|
+
|
|
98
|
+
Records the file (relative to the project root, recorded by the caller) so
|
|
99
|
+
the scaffold manifest can hash exactly what init produced. Never clobbers a
|
|
100
|
+
user file — an existing path is reported and skipped.
|
|
101
|
+
"""
|
|
102
|
+
if target.exists():
|
|
103
|
+
skipped.append((target, "already exists"))
|
|
104
|
+
return
|
|
105
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
target.write_text(content, encoding="utf-8", newline="\n")
|
|
107
|
+
written[target] = _sha256(content)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _ensure_gitignore(root: Path, written: dict) -> None:
|
|
111
|
+
"""Append missing ignore lines idempotently (never duplicates a line)."""
|
|
112
|
+
gitignore = root / ".gitignore"
|
|
113
|
+
existing = ""
|
|
114
|
+
if gitignore.exists():
|
|
115
|
+
existing = gitignore.read_text(encoding="utf-8")
|
|
116
|
+
present = {line.strip() for line in existing.splitlines()}
|
|
117
|
+
to_add = [line for line in _GITIGNORE_LINES if line not in present]
|
|
118
|
+
if not to_add:
|
|
119
|
+
return
|
|
120
|
+
block = ""
|
|
121
|
+
if existing and not existing.endswith("\n"):
|
|
122
|
+
block += "\n"
|
|
123
|
+
if existing:
|
|
124
|
+
block += "\n"
|
|
125
|
+
block += "# Steppy\n" + "\n".join(to_add) + "\n"
|
|
126
|
+
with open(gitignore, "a", encoding="utf-8", newline="\n") as f:
|
|
127
|
+
f.write(block)
|
|
128
|
+
# The manifest records the resulting file's hash so `update` can tell a
|
|
129
|
+
# pristine .gitignore from a user-edited one.
|
|
130
|
+
written[gitignore] = _sha256(gitignore.read_text(encoding="utf-8"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_manifest(root: Path, written: dict) -> None:
|
|
134
|
+
"""Write ``.steppy/scaffold.json`` = {steppy_version, files:{rel: sha256}}.
|
|
135
|
+
|
|
136
|
+
Must ship from the first ``init`` — modification detection (§7.7 ``steppy
|
|
137
|
+
update``) can't be retrofitted onto files whose original hashes were never
|
|
138
|
+
recorded.
|
|
139
|
+
|
|
140
|
+
A re-run merges into the manifest it finds. Replacing it would forget every
|
|
141
|
+
file an earlier run recorded, and ``update`` would stop managing them. The
|
|
142
|
+
recorded ``steppy_version`` is kept too: ``update`` shows the changelog since
|
|
143
|
+
that version, and the older baselines still date from it.
|
|
144
|
+
"""
|
|
145
|
+
manifest_path = root / ".steppy" / "scaffold.json"
|
|
146
|
+
manifest = {"steppy_version": __version__, "files": {}}
|
|
147
|
+
if manifest_path.is_file():
|
|
148
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
149
|
+
files = dict(manifest.get("files", {}))
|
|
150
|
+
files.update({_rel(root, path): digest for path, digest in written.items()})
|
|
151
|
+
manifest["files"] = dict(sorted(files.items()))
|
|
152
|
+
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
153
|
+
manifest_path.write_text(
|
|
154
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
155
|
+
encoding="utf-8",
|
|
156
|
+
newline="\n",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _legacy_skills(root: Path) -> list[tuple[str, str]]:
|
|
161
|
+
"""The :data:`LEGACY_SKILLS` folders present under ``root``, with each one's replacement."""
|
|
162
|
+
return [
|
|
163
|
+
(old, new) for old, new in LEGACY_SKILLS.items() if (root / ".claude" / "skills" / old / "SKILL.md").is_file()
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _report_legacy_skills(root: Path) -> bool:
|
|
168
|
+
"""Print one line per legacy skill folder; returns whether there was any."""
|
|
169
|
+
found = _legacy_skills(root)
|
|
170
|
+
for old, new in found:
|
|
171
|
+
console.print(
|
|
172
|
+
f" [yellow]legacy[/yellow] .claude/skills/{old}/ — an old copy, superseded by {new}; "
|
|
173
|
+
"delete the folder once any local edits are carried over"
|
|
174
|
+
)
|
|
175
|
+
return bool(found)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _run_init(
|
|
179
|
+
directory: Path,
|
|
180
|
+
ci: bool,
|
|
181
|
+
no_skills: bool = False,
|
|
182
|
+
ci_gitlab: bool = False,
|
|
183
|
+
app_dir: Optional[Path] = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
root = directory.resolve()
|
|
186
|
+
app_root = app_dir.resolve() if app_dir is not None else None
|
|
187
|
+
if app_root is not None and not app_root.is_dir():
|
|
188
|
+
_error(f"--app-dir: {app_dir} is not a directory")
|
|
189
|
+
raise typer.Exit(1)
|
|
190
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
written: dict[Path, str] = {}
|
|
193
|
+
skipped: list[tuple[Path, str]] = []
|
|
194
|
+
|
|
195
|
+
# Core scaffold assets (bundled templates + the generated schema).
|
|
196
|
+
_write(
|
|
197
|
+
root / "config.yaml",
|
|
198
|
+
_bundled("init", "config.yaml").read_text(encoding="utf-8"),
|
|
199
|
+
written,
|
|
200
|
+
skipped,
|
|
201
|
+
)
|
|
202
|
+
# The starter scenario is for an empty project. In one that already has
|
|
203
|
+
# scenarios, it would be generated and run against the real app.
|
|
204
|
+
starter = root / "scenarios" / "example--first-flow.md"
|
|
205
|
+
if not starter.exists() and any((root / "scenarios").rglob("*.md")):
|
|
206
|
+
skipped.append((starter, "scenarios/ already has scenarios"))
|
|
207
|
+
else:
|
|
208
|
+
_write(
|
|
209
|
+
starter,
|
|
210
|
+
_bundled("init", "scenarios", "example--first-flow.md").read_text(encoding="utf-8"),
|
|
211
|
+
written,
|
|
212
|
+
skipped,
|
|
213
|
+
)
|
|
214
|
+
_write(
|
|
215
|
+
root / "credentials.yaml.example",
|
|
216
|
+
_bundled("init", "credentials.yaml.example").read_text(encoding="utf-8"),
|
|
217
|
+
written,
|
|
218
|
+
skipped,
|
|
219
|
+
)
|
|
220
|
+
_write(root / _SCHEMA_FILENAME, schema_json(), written, skipped)
|
|
221
|
+
|
|
222
|
+
if ci:
|
|
223
|
+
_write(
|
|
224
|
+
root / ".github" / "workflows" / "steppy.yml",
|
|
225
|
+
_bundled("ci", "github-actions.yml").read_text(encoding="utf-8"),
|
|
226
|
+
written,
|
|
227
|
+
skipped,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# A separate flag rather than `--ci <vendor>`: `--ci` is a boolean people
|
|
231
|
+
# already type, and giving it a value would break every existing invocation.
|
|
232
|
+
# Writing both files unconditionally would leave a stray .github/ in a GitLab
|
|
233
|
+
# project, which is noise in a scaffold meant to be read.
|
|
234
|
+
if ci_gitlab:
|
|
235
|
+
_write(
|
|
236
|
+
root / ".gitlab-ci.yml",
|
|
237
|
+
_bundled("ci", "gitlab-ci.yml").read_text(encoding="utf-8"),
|
|
238
|
+
written,
|
|
239
|
+
skipped,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Packaged Claude skills — installed by default so the project's AI
|
|
243
|
+
# assistant is a Steppy expert on day one (FLAWS §7.1). Routed through
|
|
244
|
+
# _write so they land in the manifest and never clobber user edits.
|
|
245
|
+
if not no_skills:
|
|
246
|
+
for name in SKILL_NAMES:
|
|
247
|
+
_write(
|
|
248
|
+
root / ".claude" / "skills" / name / "SKILL.md",
|
|
249
|
+
_bundled_skill(name, "SKILL.md").read_text(encoding="utf-8"),
|
|
250
|
+
written,
|
|
251
|
+
skipped,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
# The app-side skills, in the app's repository. Recorded in this project's
|
|
255
|
+
# manifest (as ../<app>/…), so `steppy update` refreshes them with the rest.
|
|
256
|
+
# When the app is this directory, the loop above already installed them.
|
|
257
|
+
app_skills: list[Path] = []
|
|
258
|
+
if app_root is not None and (app_root != root or no_skills):
|
|
259
|
+
for name in APP_SKILL_NAMES:
|
|
260
|
+
target = app_root / ".claude" / "skills" / name / "SKILL.md"
|
|
261
|
+
_write(target, _bundled_skill(name, "SKILL.md").read_text(encoding="utf-8"), written, skipped)
|
|
262
|
+
if target in written:
|
|
263
|
+
app_skills.append(target)
|
|
264
|
+
|
|
265
|
+
_ensure_gitignore(root, written)
|
|
266
|
+
_write_manifest(root, written)
|
|
267
|
+
|
|
268
|
+
# Report.
|
|
269
|
+
for path in sorted(written):
|
|
270
|
+
console.print(f" [green]create[/green] {_rel(root, path)}")
|
|
271
|
+
for path, reason in sorted(skipped):
|
|
272
|
+
console.print(f" [yellow]skip[/yellow] {_rel(root, path)} ({reason})")
|
|
273
|
+
_report_legacy_skills(root)
|
|
274
|
+
|
|
275
|
+
if skipped and not written:
|
|
276
|
+
console.print("\n[yellow]Nothing to do[/yellow] — all scaffold files already exist.")
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
if app_skills:
|
|
280
|
+
console.print(
|
|
281
|
+
"\nCommit the skill in the app's repository: it is what an AI assistant working on the app reads."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
console.print("\n[bold]Next steps:[/bold]")
|
|
285
|
+
console.print(" 1. Edit config.yaml — set your app URLs, roles, and credentials.")
|
|
286
|
+
console.print(" 2. steppy validate # check your scenarios")
|
|
287
|
+
console.print(" 3. steppy generate # produce the Playwright tests")
|
|
288
|
+
console.print(" 4. steppy run # run them")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def register_init(app: typer.Typer) -> None:
|
|
292
|
+
"""Register the ``init`` command on the root Typer app."""
|
|
293
|
+
|
|
294
|
+
@app.command("init")
|
|
295
|
+
def init(
|
|
296
|
+
directory: Path = typer.Argument(
|
|
297
|
+
Path("."),
|
|
298
|
+
help="Directory to scaffold (default: current directory).",
|
|
299
|
+
),
|
|
300
|
+
ci: bool = typer.Option(
|
|
301
|
+
False,
|
|
302
|
+
"--ci",
|
|
303
|
+
help="Also write a GitHub Actions workflow (.github/workflows/steppy.yml).",
|
|
304
|
+
),
|
|
305
|
+
ci_gitlab: bool = typer.Option(
|
|
306
|
+
False,
|
|
307
|
+
"--ci-gitlab",
|
|
308
|
+
help="Also write a GitLab CI pipeline (.gitlab-ci.yml).",
|
|
309
|
+
),
|
|
310
|
+
no_skills: bool = typer.Option(
|
|
311
|
+
False,
|
|
312
|
+
"--no-skills",
|
|
313
|
+
help="Do not install the packaged Claude skills into .claude/skills/.",
|
|
314
|
+
),
|
|
315
|
+
app_dir: Optional[Path] = typer.Option(
|
|
316
|
+
None,
|
|
317
|
+
"--app-dir",
|
|
318
|
+
help="Also install the app-side skill (steppy-app-tours: wiring in-app tours into the app's code) "
|
|
319
|
+
"into this app repository's .claude/skills/. `steppy update` keeps it current.",
|
|
320
|
+
),
|
|
321
|
+
):
|
|
322
|
+
"""Scaffold a new Steppy project (config, example scenario, schema, gitignore).
|
|
323
|
+
|
|
324
|
+
Installs the packaged Claude skills into .claude/skills/ by default so the
|
|
325
|
+
project's AI assistant knows the Steppy scenario format (use --no-skills to
|
|
326
|
+
opt out). Safe to re-run in an existing project: it only adds what is
|
|
327
|
+
missing, and leaves the starter scenario out when scenarios/ has some.
|
|
328
|
+
"""
|
|
329
|
+
with _cli_errors():
|
|
330
|
+
_run_init(directory, ci, no_skills, ci_gitlab=ci_gitlab, app_dir=app_dir)
|