steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -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