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/coverage/merge.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""Merge raw run records into the coverage map store.
|
|
2
|
+
|
|
3
|
+
A coverage run (``steppy run --coverage``) leaves raw, lossless observation
|
|
4
|
+
records under ``<records_dir>/raw/<run_id>/<scenario>/*.json`` (one per page
|
|
5
|
+
fixture instance) plus content-deduplicated inline source maps under
|
|
6
|
+
``raw/<run_id>/_maps/``. This module turns one run directory into per-scenario
|
|
7
|
+
contribution sets — normalizing routes, classifying + resolving network
|
|
8
|
+
requests against declared endpoint templates, decoding JS coverage through
|
|
9
|
+
source maps — and upserts them transactionally: each scenario's whole prior
|
|
10
|
+
contribution is replaced, so merging is idempotent and never accumulates
|
|
11
|
+
stale rows.
|
|
12
|
+
|
|
13
|
+
All interpretation lives here (not in the recorder) so normalization rules
|
|
14
|
+
can evolve without recapturing. Everything is pure given the run directory,
|
|
15
|
+
the store, and the settings — no network, no wall clock.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from ..parser import parse_scenario_file
|
|
25
|
+
from .normalize import (
|
|
26
|
+
classify_request,
|
|
27
|
+
compile_extra_patterns,
|
|
28
|
+
match_template,
|
|
29
|
+
normalize_path,
|
|
30
|
+
normalize_route,
|
|
31
|
+
route_path_from_url,
|
|
32
|
+
strip_query,
|
|
33
|
+
)
|
|
34
|
+
from .sourcemap import (
|
|
35
|
+
SourceMapUnusable,
|
|
36
|
+
bundle_key,
|
|
37
|
+
load_map_for_entry,
|
|
38
|
+
modules_for_script,
|
|
39
|
+
)
|
|
40
|
+
from .store import Contribution, open_store, replace_scenario_contribution
|
|
41
|
+
|
|
42
|
+
RECORD_SCHEMA_VERSION = 1
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MergeError(RuntimeError):
|
|
46
|
+
"""Raised when a run directory cannot be merged."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CoverageSettings:
|
|
51
|
+
"""Resolved coverage configuration (paths absolute, defaults applied)."""
|
|
52
|
+
|
|
53
|
+
store_path: Path
|
|
54
|
+
records_dir: Path
|
|
55
|
+
scenarios_dir: Path = Path("scenarios")
|
|
56
|
+
apis: list[dict] = field(default_factory=list) # {"name", "openapi", "base_urls"}
|
|
57
|
+
sitemap: str | None = None
|
|
58
|
+
sourcemap_dir: Path | None = None
|
|
59
|
+
api_resource_types: tuple[str, ...] = ("xhr", "fetch")
|
|
60
|
+
route_param_patterns: list[str] = field(default_factory=list)
|
|
61
|
+
module_ignore: tuple[str, ...] = ("node_modules/", "webpack/")
|
|
62
|
+
# Committed ``non_ui`` declarations (see coverage/triage.py). Merge never
|
|
63
|
+
# reads these — they are a *read-time* overlay, so the store stays a record
|
|
64
|
+
# of what was observed and declared, not of how we chose to discount it.
|
|
65
|
+
non_ui: tuple[str, ...] = ()
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_config(cls, config, project_root: Path) -> "CoverageSettings":
|
|
69
|
+
"""Build settings from a loaded SteppyConfig (coverage block optional)."""
|
|
70
|
+
|
|
71
|
+
def resolve(value: str) -> Path:
|
|
72
|
+
path = Path(value)
|
|
73
|
+
return path if path.is_absolute() else project_root / path
|
|
74
|
+
|
|
75
|
+
cov = getattr(config, "coverage", None)
|
|
76
|
+
if cov is None:
|
|
77
|
+
return cls(
|
|
78
|
+
store_path=resolve("./.steppy/coverage.db"),
|
|
79
|
+
records_dir=resolve("./output/coverage"),
|
|
80
|
+
scenarios_dir=resolve("scenarios"),
|
|
81
|
+
)
|
|
82
|
+
return cls(
|
|
83
|
+
store_path=resolve(cov.store_path),
|
|
84
|
+
records_dir=resolve(cov.records_dir),
|
|
85
|
+
scenarios_dir=resolve("scenarios"),
|
|
86
|
+
apis=[{"name": a.name, "openapi": a.openapi, "base_urls": list(a.base_urls)} for a in cov.apis],
|
|
87
|
+
sitemap=cov.sitemap,
|
|
88
|
+
sourcemap_dir=resolve(cov.sourcemap_dir) if cov.sourcemap_dir else None,
|
|
89
|
+
api_resource_types=tuple(cov.api_resource_types),
|
|
90
|
+
route_param_patterns=list(cov.route_param_patterns),
|
|
91
|
+
module_ignore=tuple(cov.module_ignore),
|
|
92
|
+
non_ui=tuple(cov.non_ui),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def latest_run_id(records_dir: Path) -> str | None:
|
|
97
|
+
"""Lexicographically last run dir under ``<records_dir>/raw`` (ids sort by time)."""
|
|
98
|
+
raw_root = Path(records_dir) / "raw"
|
|
99
|
+
if not raw_root.is_dir():
|
|
100
|
+
return None
|
|
101
|
+
runs = sorted(p.name for p in raw_root.iterdir() if p.is_dir() and p.name != "_maps")
|
|
102
|
+
return runs[-1] if runs else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _load_records(run_dir: Path) -> tuple[dict[str, list[dict]], list[str]]:
|
|
106
|
+
"""Group record files by scenario id; malformed files become warnings."""
|
|
107
|
+
by_scenario: dict[str, list[dict]] = {}
|
|
108
|
+
warnings: list[str] = []
|
|
109
|
+
for scenario_dir in sorted(p for p in run_dir.iterdir() if p.is_dir()):
|
|
110
|
+
if scenario_dir.name == "_maps":
|
|
111
|
+
continue
|
|
112
|
+
for record_file in sorted(scenario_dir.glob("*.json")):
|
|
113
|
+
try:
|
|
114
|
+
record = json.loads(record_file.read_text(encoding="utf-8"))
|
|
115
|
+
except (OSError, ValueError) as exc:
|
|
116
|
+
warnings.append(f"skipped unreadable record {record_file.name}: {exc}")
|
|
117
|
+
continue
|
|
118
|
+
if record.get("steppy_coverage_record") != RECORD_SCHEMA_VERSION:
|
|
119
|
+
warnings.append(
|
|
120
|
+
f"skipped record {record_file.name}: unsupported schema {record.get('steppy_coverage_record')!r}"
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
scenario_id = record.get("scenario") or scenario_dir.name
|
|
124
|
+
by_scenario.setdefault(scenario_id, []).append(record)
|
|
125
|
+
return by_scenario, warnings
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _EvidenceAccumulator:
|
|
129
|
+
"""Merge per-record observations into one contribution per node identity."""
|
|
130
|
+
|
|
131
|
+
def __init__(self) -> None:
|
|
132
|
+
self._acc: dict[tuple[str, str, str, str], dict] = {}
|
|
133
|
+
|
|
134
|
+
def add(
|
|
135
|
+
self,
|
|
136
|
+
kind: str,
|
|
137
|
+
key: str,
|
|
138
|
+
*,
|
|
139
|
+
api: str = "",
|
|
140
|
+
method: str = "",
|
|
141
|
+
sample: str | None = None,
|
|
142
|
+
count: int = 1,
|
|
143
|
+
record: dict,
|
|
144
|
+
) -> None:
|
|
145
|
+
ident = (kind, api, method, key)
|
|
146
|
+
entry = self._acc.setdefault(
|
|
147
|
+
ident,
|
|
148
|
+
{
|
|
149
|
+
"samples": set(),
|
|
150
|
+
"count": 0,
|
|
151
|
+
"roles": set(),
|
|
152
|
+
"guide_mode": False,
|
|
153
|
+
"outcomes": set(),
|
|
154
|
+
"asserts_enabled": False,
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
if sample:
|
|
158
|
+
entry["samples"].add(sample)
|
|
159
|
+
entry["count"] += count
|
|
160
|
+
if record.get("role"):
|
|
161
|
+
entry["roles"].add(record["role"])
|
|
162
|
+
entry["guide_mode"] = entry["guide_mode"] or bool(record.get("guide_mode"))
|
|
163
|
+
entry["outcomes"].add(record.get("outcome") or "unknown")
|
|
164
|
+
entry["asserts_enabled"] = entry["asserts_enabled"] or bool(record.get("asserts_enabled"))
|
|
165
|
+
|
|
166
|
+
def contributions(self) -> list[Contribution]:
|
|
167
|
+
result = []
|
|
168
|
+
for (kind, api, method, key), entry in sorted(self._acc.items()):
|
|
169
|
+
if entry["outcomes"] & {"failed"}:
|
|
170
|
+
outcome = "failed"
|
|
171
|
+
elif entry["outcomes"] == {"passed"}:
|
|
172
|
+
outcome = "passed"
|
|
173
|
+
else:
|
|
174
|
+
outcome = "unknown"
|
|
175
|
+
evidence = {
|
|
176
|
+
"samples": sorted(entry["samples"])[:5],
|
|
177
|
+
"count": entry["count"],
|
|
178
|
+
"roles": sorted(entry["roles"]),
|
|
179
|
+
"guide_mode": entry["guide_mode"],
|
|
180
|
+
"outcome": outcome,
|
|
181
|
+
}
|
|
182
|
+
result.append(
|
|
183
|
+
Contribution(
|
|
184
|
+
kind=kind,
|
|
185
|
+
key=key,
|
|
186
|
+
api=api,
|
|
187
|
+
method=method,
|
|
188
|
+
evidence=evidence,
|
|
189
|
+
asserts_enabled=entry["asserts_enabled"],
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _route_events(record: dict) -> list[dict]:
|
|
196
|
+
"""Merge nav + SPA streams by timestamp; collapse consecutive same-URL events."""
|
|
197
|
+
events = [
|
|
198
|
+
e
|
|
199
|
+
for e in list(record.get("nav_events") or []) + list(record.get("spa_events") or [])
|
|
200
|
+
if isinstance(e, dict) and str(e.get("u", "")).startswith(("http://", "https://"))
|
|
201
|
+
]
|
|
202
|
+
events.sort(key=lambda e: (e.get("t") or 0, e.get("u", ""), e.get("k", "")))
|
|
203
|
+
collapsed: list[dict] = []
|
|
204
|
+
for event in events:
|
|
205
|
+
if collapsed and collapsed[-1]["u"] == event["u"]:
|
|
206
|
+
continue
|
|
207
|
+
collapsed.append(event)
|
|
208
|
+
return collapsed
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_contributions(
|
|
212
|
+
records: list[dict],
|
|
213
|
+
settings: CoverageSettings,
|
|
214
|
+
declared_templates: dict[tuple[str, str], list[str]],
|
|
215
|
+
maps_dir: Path | None,
|
|
216
|
+
) -> tuple[list[Contribution], dict]:
|
|
217
|
+
"""Union one scenario's records into its contribution set.
|
|
218
|
+
|
|
219
|
+
``declared_templates`` maps ``(api, method)`` to known OpenAPI path
|
|
220
|
+
templates: observed requests matching a template attach to the declared
|
|
221
|
+
node identity; others get a heuristic-normalized observed identity (the
|
|
222
|
+
refresh re-link pass can move them later).
|
|
223
|
+
"""
|
|
224
|
+
extra = compile_extra_patterns(settings.route_param_patterns)
|
|
225
|
+
acc = _EvidenceAccumulator()
|
|
226
|
+
info = {"records": len(records), "modules_unavailable": False}
|
|
227
|
+
|
|
228
|
+
for record in sorted(
|
|
229
|
+
records,
|
|
230
|
+
key=lambda r: (r.get("role") or "", r.get("locale") or "", r.get("layout") or "", r.get("noauth") or False),
|
|
231
|
+
):
|
|
232
|
+
# Routes
|
|
233
|
+
for event in _route_events(record):
|
|
234
|
+
url = event["u"]
|
|
235
|
+
acc.add(
|
|
236
|
+
"route",
|
|
237
|
+
normalize_route(url, extra),
|
|
238
|
+
sample=strip_query(route_path_from_url(url)),
|
|
239
|
+
record=record,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Endpoints
|
|
243
|
+
for request in record.get("requests") or []:
|
|
244
|
+
classified = classify_request(
|
|
245
|
+
request.get("m", ""),
|
|
246
|
+
request.get("u", ""),
|
|
247
|
+
request.get("rt", ""),
|
|
248
|
+
settings.apis,
|
|
249
|
+
settings.api_resource_types,
|
|
250
|
+
)
|
|
251
|
+
if classified is None:
|
|
252
|
+
continue
|
|
253
|
+
api, method, path = classified["api"], classified["method"], classified["path"]
|
|
254
|
+
template = match_template(path, declared_templates.get((api, method), []))
|
|
255
|
+
key = template if template else normalize_path(strip_query(path), extra)
|
|
256
|
+
acc.add(
|
|
257
|
+
"endpoint",
|
|
258
|
+
key,
|
|
259
|
+
api=api,
|
|
260
|
+
method=method,
|
|
261
|
+
sample=strip_query(path),
|
|
262
|
+
record=record,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Modules (JS coverage)
|
|
266
|
+
js = record.get("js_coverage") or {}
|
|
267
|
+
if not js.get("ok"):
|
|
268
|
+
info["modules_unavailable"] = True
|
|
269
|
+
continue
|
|
270
|
+
for entry in js.get("scripts") or []:
|
|
271
|
+
offsets = entry.get("executed_offsets") or []
|
|
272
|
+
if not offsets:
|
|
273
|
+
continue
|
|
274
|
+
line_starts = entry.get("line_starts")
|
|
275
|
+
map_obj = load_map_for_entry(entry, maps_dir, settings.sourcemap_dir)
|
|
276
|
+
modules: set[str]
|
|
277
|
+
if map_obj is None or not line_starts:
|
|
278
|
+
modules = {bundle_key(entry.get("url", ""))}
|
|
279
|
+
else:
|
|
280
|
+
try:
|
|
281
|
+
modules = modules_for_script(offsets, line_starts, map_obj, settings.module_ignore)
|
|
282
|
+
except SourceMapUnusable:
|
|
283
|
+
modules = {bundle_key(entry.get("url", ""))}
|
|
284
|
+
for module in sorted(modules):
|
|
285
|
+
acc.add("module", module, record=record)
|
|
286
|
+
|
|
287
|
+
return acc.contributions(), info
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def count_scenario_asserts(scenarios_dir: Path, scenario_id: str) -> int:
|
|
291
|
+
"""Assert steps in the scenario markdown (0 when missing/unparseable).
|
|
292
|
+
|
|
293
|
+
Counts both ``action: assert`` steps and other actions carrying an
|
|
294
|
+
``expect:`` — the same set the generated test wraps in ``expect()``.
|
|
295
|
+
"""
|
|
296
|
+
path = Path(scenarios_dir) / f"{scenario_id}.md"
|
|
297
|
+
if not path.is_file():
|
|
298
|
+
return 0
|
|
299
|
+
try:
|
|
300
|
+
scenario = parse_scenario_file(path)
|
|
301
|
+
except Exception:
|
|
302
|
+
return 0
|
|
303
|
+
return sum(1 for step in scenario.steps if step.action == "assert" or getattr(step, "expect", None))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _declared_endpoint_templates(conn) -> dict[tuple[str, str], list[str]]:
|
|
307
|
+
templates: dict[tuple[str, str], list[str]] = {}
|
|
308
|
+
for row in conn.execute(
|
|
309
|
+
"SELECT DISTINCT n.api, n.method, n.key FROM nodes n"
|
|
310
|
+
" JOIN declared d ON d.node_id = n.id"
|
|
311
|
+
" WHERE n.kind = 'endpoint' ORDER BY n.api, n.method, n.key"
|
|
312
|
+
):
|
|
313
|
+
templates.setdefault((row["api"], row["method"]), []).append(row["key"])
|
|
314
|
+
return templates
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def merge_run(settings: CoverageSettings, run_id: str | None = None) -> dict:
|
|
318
|
+
"""Merge one recorded run into the store. Returns a summary dict.
|
|
319
|
+
|
|
320
|
+
``run_id=None`` merges the latest run under ``<records_dir>/raw``.
|
|
321
|
+
"""
|
|
322
|
+
if run_id is None:
|
|
323
|
+
run_id = latest_run_id(settings.records_dir)
|
|
324
|
+
if run_id is None:
|
|
325
|
+
raise MergeError(
|
|
326
|
+
f"no coverage runs found under {Path(settings.records_dir) / 'raw'} — "
|
|
327
|
+
"record one with `steppy run --coverage`"
|
|
328
|
+
)
|
|
329
|
+
run_dir = Path(settings.records_dir) / "raw" / run_id
|
|
330
|
+
if not run_dir.is_dir():
|
|
331
|
+
raise MergeError(f"coverage run directory not found: {run_dir}")
|
|
332
|
+
|
|
333
|
+
by_scenario, warnings = _load_records(run_dir)
|
|
334
|
+
if not by_scenario:
|
|
335
|
+
raise MergeError(f"no coverage records in {run_dir}")
|
|
336
|
+
|
|
337
|
+
maps_dir = run_dir / "_maps"
|
|
338
|
+
conn = open_store(settings.store_path)
|
|
339
|
+
try:
|
|
340
|
+
declared_templates = _declared_endpoint_templates(conn)
|
|
341
|
+
summary: dict = {"run_id": run_id, "scenarios": {}, "warnings": warnings}
|
|
342
|
+
for scenario_id in sorted(by_scenario):
|
|
343
|
+
records = by_scenario[scenario_id]
|
|
344
|
+
contributions, info = build_contributions(
|
|
345
|
+
records, settings, declared_templates, maps_dir if maps_dir.is_dir() else None
|
|
346
|
+
)
|
|
347
|
+
recorded_at = max((str(r.get("finished_at") or "") for r in records), default="")
|
|
348
|
+
replace_scenario_contribution(
|
|
349
|
+
conn,
|
|
350
|
+
scenario_id,
|
|
351
|
+
contributions,
|
|
352
|
+
asserts_count=count_scenario_asserts(settings.scenarios_dir, scenario_id),
|
|
353
|
+
run_id=run_id,
|
|
354
|
+
recorded_at=recorded_at,
|
|
355
|
+
)
|
|
356
|
+
summary["scenarios"][scenario_id] = {"nodes": len(contributions), **info}
|
|
357
|
+
summary["totals"] = {
|
|
358
|
+
"scenarios": len(summary["scenarios"]),
|
|
359
|
+
"nodes": sum(s["nodes"] for s in summary["scenarios"].values()),
|
|
360
|
+
}
|
|
361
|
+
return summary
|
|
362
|
+
finally:
|
|
363
|
+
conn.close()
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""URL / route / request normalization — pure, deterministic, web-standard.
|
|
2
|
+
|
|
3
|
+
Operates only on URL structure (scheme/host/path/query/fragment): no
|
|
4
|
+
framework assumptions. Three jobs:
|
|
5
|
+
|
|
6
|
+
- ``normalize_route`` — observed URL -> route pattern. Dynamic segments
|
|
7
|
+
(integers, UUIDs, long hex, ISO dates, plus project-configured regexes)
|
|
8
|
+
collapse to ``:id`` so ``/invoices/123`` and ``/invoices/456`` cluster.
|
|
9
|
+
Hash-based SPA routing (``#/path``) is treated as the route path.
|
|
10
|
+
Word segments are deliberately left alone — clustering ``/settings/profile``
|
|
11
|
+
with ``/settings/billing`` would be wrong, so only provably-dynamic shapes
|
|
12
|
+
normalize.
|
|
13
|
+
- ``assign_api`` / ``classify_request`` — attribute an observed network
|
|
14
|
+
request to a configured API by longest base-URL prefix match (base URLs
|
|
15
|
+
must mirror the OpenAPI ``servers[].url``, including any path prefix, and
|
|
16
|
+
should include Docker-internal ``internal_url`` origins for parallel runs).
|
|
17
|
+
- ``match_template`` — concrete path -> declared OpenAPI template
|
|
18
|
+
(segment-wise; literal segments exact, ``{param}``/``:param`` match any
|
|
19
|
+
non-empty segment; most-literal-segments wins, so ``/users/me`` beats
|
|
20
|
+
``/users/{id}``).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
|
|
28
|
+
_DYNAMIC_SEGMENT_RES = (
|
|
29
|
+
re.compile(r"^\d+$"), # integer ids
|
|
30
|
+
re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"), # UUID
|
|
31
|
+
re.compile(r"^[0-9a-fA-F]{16,}$"), # long hex tokens (hashes, mongo ids)
|
|
32
|
+
re.compile(r"^\d{4}-\d{2}-\d{2}$"), # ISO dates
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
PARAM_TOKEN = ":id"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_dynamic_segment(segment: str, extra_patterns: tuple[re.Pattern, ...] = ()) -> bool:
|
|
39
|
+
for pattern in _DYNAMIC_SEGMENT_RES + tuple(extra_patterns):
|
|
40
|
+
if pattern.match(segment):
|
|
41
|
+
return True
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def compile_extra_patterns(patterns: list[str]) -> tuple[re.Pattern, ...]:
|
|
46
|
+
"""Compile ``coverage.route_param_patterns`` once for reuse across a merge."""
|
|
47
|
+
return tuple(re.compile(p) for p in patterns)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def split_segments(path: str) -> list[str]:
|
|
51
|
+
return [s for s in path.split("/") if s]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_path(path: str, extra_patterns: tuple[re.Pattern, ...] = ()) -> str:
|
|
55
|
+
"""Collapse dynamic segments of a path to ``:id``; strip trailing slash."""
|
|
56
|
+
segments = split_segments(path)
|
|
57
|
+
if not segments:
|
|
58
|
+
return "/"
|
|
59
|
+
normalized = [PARAM_TOKEN if _is_dynamic_segment(s, extra_patterns) else s for s in segments]
|
|
60
|
+
return "/" + "/".join(normalized)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def route_path_from_url(url: str) -> str:
|
|
64
|
+
"""Extract the route path from a URL, hash-routing aware.
|
|
65
|
+
|
|
66
|
+
A fragment starting with ``/`` is hash-based SPA routing — the fragment
|
|
67
|
+
(minus its own query part) IS the route. Query strings and ordinary
|
|
68
|
+
fragments are dropped.
|
|
69
|
+
"""
|
|
70
|
+
parts = urlsplit(url)
|
|
71
|
+
if parts.fragment.startswith("/"):
|
|
72
|
+
return parts.fragment.split("?", 1)[0] or "/"
|
|
73
|
+
return parts.path or "/"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_route(url: str, extra_patterns: tuple[re.Pattern, ...] = ()) -> str:
|
|
77
|
+
"""Observed URL (full or path-only) -> normalized route pattern."""
|
|
78
|
+
return normalize_path(route_path_from_url(url), extra_patterns)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def strip_query(url_or_path: str) -> str:
|
|
82
|
+
"""Drop query string and fragment — used when storing evidence samples."""
|
|
83
|
+
return url_or_path.split("#", 1)[0].split("?", 1)[0]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _base_parts(base_url: str) -> tuple[str, str, str]:
|
|
87
|
+
parts = urlsplit(base_url)
|
|
88
|
+
return (parts.scheme.lower(), parts.netloc.lower(), parts.path.rstrip("/"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def assign_api(url: str, apis: list[dict]) -> tuple[str, str] | None:
|
|
92
|
+
"""Match ``url`` to a configured API by longest base-URL prefix.
|
|
93
|
+
|
|
94
|
+
``apis`` entries are ``{"name": str, "base_urls": [str, ...]}``.
|
|
95
|
+
Returns ``(api_name, path_relative_to_base)`` or ``None``. Prefix
|
|
96
|
+
matching is segment-aligned: base ``http://h/api`` matches
|
|
97
|
+
``http://h/api/users`` but not ``http://h/apiv2``.
|
|
98
|
+
"""
|
|
99
|
+
parts = urlsplit(url)
|
|
100
|
+
scheme, netloc, path = parts.scheme.lower(), parts.netloc.lower(), parts.path
|
|
101
|
+
best: tuple[int, str, str] | None = None
|
|
102
|
+
for api in apis:
|
|
103
|
+
for base in api.get("base_urls", []):
|
|
104
|
+
b_scheme, b_netloc, b_path = _base_parts(base)
|
|
105
|
+
if (b_scheme, b_netloc) != (scheme, netloc):
|
|
106
|
+
continue
|
|
107
|
+
if b_path and not (path == b_path or path.startswith(b_path + "/")):
|
|
108
|
+
continue
|
|
109
|
+
if best is None or len(b_path) > best[0]:
|
|
110
|
+
rel = path[len(b_path) :] if b_path else path
|
|
111
|
+
best = (len(b_path), api["name"], rel or "/")
|
|
112
|
+
if best is None:
|
|
113
|
+
return None
|
|
114
|
+
_, name, rel = best
|
|
115
|
+
if not rel.startswith("/"):
|
|
116
|
+
rel = "/" + rel
|
|
117
|
+
return name, rel
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def classify_request(
|
|
121
|
+
method: str,
|
|
122
|
+
url: str,
|
|
123
|
+
resource_type: str,
|
|
124
|
+
apis: list[dict],
|
|
125
|
+
api_resource_types: tuple[str, ...] = ("xhr", "fetch"),
|
|
126
|
+
) -> dict | None:
|
|
127
|
+
"""Classify an observed network request as an API call, or ``None``.
|
|
128
|
+
|
|
129
|
+
API calls are requests of the configured resource types (web-standard
|
|
130
|
+
Playwright resource types — default xhr + fetch) over http(s). Requests
|
|
131
|
+
matching no configured API are kept with ``api=''`` so the report can
|
|
132
|
+
surface them as unclassified instead of silently dropping signal.
|
|
133
|
+
"""
|
|
134
|
+
if resource_type not in api_resource_types:
|
|
135
|
+
return None
|
|
136
|
+
parts = urlsplit(url)
|
|
137
|
+
if parts.scheme.lower() not in ("http", "https"):
|
|
138
|
+
return None
|
|
139
|
+
assigned = assign_api(url, apis)
|
|
140
|
+
if assigned is None:
|
|
141
|
+
return {"api": "", "method": method.upper(), "path": parts.path or "/"}
|
|
142
|
+
name, rel = assigned
|
|
143
|
+
return {"api": name, "method": method.upper(), "path": rel}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_param_segment(segment: str) -> bool:
|
|
147
|
+
return (segment.startswith("{") and segment.endswith("}")) or segment.startswith(":")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def match_template(raw_path: str, templates: list[str]) -> str | None:
|
|
151
|
+
"""Match a concrete path against declared path templates.
|
|
152
|
+
|
|
153
|
+
Segment-wise: equal segment count; literal segments compare exactly;
|
|
154
|
+
``{param}`` / ``:param`` segments match any non-empty segment. Ties break
|
|
155
|
+
toward the template with the most literal segments (``/users/me`` beats
|
|
156
|
+
``/users/{id}``), then lexicographically for full determinism.
|
|
157
|
+
"""
|
|
158
|
+
raw_segments = split_segments(strip_query(raw_path))
|
|
159
|
+
best: tuple[int, str] | None = None
|
|
160
|
+
for template in sorted(templates):
|
|
161
|
+
t_segments = split_segments(template)
|
|
162
|
+
if len(t_segments) != len(raw_segments):
|
|
163
|
+
continue
|
|
164
|
+
literals = 0
|
|
165
|
+
for raw, tpl in zip(raw_segments, t_segments):
|
|
166
|
+
if _is_param_segment(tpl):
|
|
167
|
+
continue
|
|
168
|
+
if raw != tpl:
|
|
169
|
+
break
|
|
170
|
+
literals += 1
|
|
171
|
+
else:
|
|
172
|
+
if best is None or literals > best[0]:
|
|
173
|
+
best = (literals, template)
|
|
174
|
+
return best[1] if best else None
|