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
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Render the gap report: deterministic JSON, a shields badge, a human summary."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
_MAX_ROWS = 50
|
|
8
|
+
|
|
9
|
+
#: shields.io endpoint colours, coarsest-first. Deliberately the palette a
|
|
10
|
+
#: README already uses for test/coverage badges, so a Steppy badge sits in a row
|
|
11
|
+
#: of them without looking foreign.
|
|
12
|
+
_BADGE_COLORS = ((50, "red"), (70, "orange"), (85, "yellow"), (95, "green"))
|
|
13
|
+
_BADGE_COLOR_BEST = "brightgreen"
|
|
14
|
+
_BADGE_COLOR_UNKNOWN = "lightgrey"
|
|
15
|
+
|
|
16
|
+
DEFAULT_BADGE_LABEL = "e2e surface"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def gaps_to_json(gaps: dict) -> str:
|
|
20
|
+
"""Machine-readable report: sorted keys, no wall clock — diffable."""
|
|
21
|
+
return json.dumps(gaps, indent=2, sort_keys=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def badge_color(percent: int) -> str:
|
|
25
|
+
"""Shields colour for a percentage."""
|
|
26
|
+
for ceiling, color in _BADGE_COLORS:
|
|
27
|
+
if percent < ceiling:
|
|
28
|
+
return color
|
|
29
|
+
return _BADGE_COLOR_BEST
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def badge_payload(gaps: dict, *, surface: str = "all", label: str = DEFAULT_BADGE_LABEL) -> dict:
|
|
33
|
+
"""A shields.io **endpoint** object for the e2e surface ratio.
|
|
34
|
+
|
|
35
|
+
An endpoint JSON rather than a generated SVG: it inherits whatever style the
|
|
36
|
+
rest of the README's badge row uses, it is cacheable by shields, and no image
|
|
37
|
+
rendering lands in this repo. Consumed as
|
|
38
|
+
``https://img.shields.io/endpoint?url=<raw-url-to-this-file>``.
|
|
39
|
+
|
|
40
|
+
Nothing declared ⇒ ``"n/a"`` in grey. A badge reading ``0%`` when the truth is
|
|
41
|
+
"no OpenAPI surface is configured" is worse than one admitting it does not
|
|
42
|
+
know, because the first invites someone to go fix a number that is not broken.
|
|
43
|
+
"""
|
|
44
|
+
ratio = (gaps.get("e2e_surface") or {}).get(surface) or {}
|
|
45
|
+
percent = ratio.get("percent")
|
|
46
|
+
if percent is None:
|
|
47
|
+
return {"schemaVersion": 1, "label": label, "message": "n/a", "color": _BADGE_COLOR_UNKNOWN}
|
|
48
|
+
return {
|
|
49
|
+
"schemaVersion": 1,
|
|
50
|
+
"label": label,
|
|
51
|
+
"message": f"{percent}%",
|
|
52
|
+
"color": badge_color(percent),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def badge_to_json(gaps: dict, *, surface: str = "all", label: str = DEFAULT_BADGE_LABEL) -> str:
|
|
57
|
+
""":func:`badge_payload` as deterministic JSON text."""
|
|
58
|
+
return json.dumps(badge_payload(gaps, surface=surface, label=label), indent=2, sort_keys=True) + "\n"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _print_bucket_table(console, title: str, rows: list[dict], columns: list[str]) -> None:
|
|
62
|
+
from rich.table import Table
|
|
63
|
+
|
|
64
|
+
table = Table(title=f"{title} ({len(rows)})", title_justify="left")
|
|
65
|
+
for col in columns:
|
|
66
|
+
table.add_column(col)
|
|
67
|
+
for row in rows[:_MAX_ROWS]:
|
|
68
|
+
table.add_row(*(str(row.get(col, "")) for col in columns))
|
|
69
|
+
console.print(table)
|
|
70
|
+
if len(rows) > _MAX_ROWS:
|
|
71
|
+
console.print(f"[dim]… and {len(rows) - _MAX_ROWS} more (see --json output)[/dim]")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _count_sources(entries: list[dict]) -> list[tuple[str, int]]:
|
|
75
|
+
"""``[(source, count)]`` sorted for stable output."""
|
|
76
|
+
tally: dict[str, int] = {}
|
|
77
|
+
for entry in entries:
|
|
78
|
+
source = entry.get("triage_source", "")
|
|
79
|
+
tally[source] = tally.get(source, 0) + 1
|
|
80
|
+
return sorted(tally.items())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _print_surface(gaps: dict, console) -> None:
|
|
84
|
+
"""The one line the badge is derived from, so the two can never disagree."""
|
|
85
|
+
ratio = (gaps.get("e2e_surface") or {}).get("all") or {}
|
|
86
|
+
percent = ratio.get("percent")
|
|
87
|
+
if percent is None:
|
|
88
|
+
console.print(
|
|
89
|
+
"[bold]E2E surface[/bold]: not measured — nothing declared. "
|
|
90
|
+
"Configure [bold]coverage.apis[/bold] (and optionally [bold]coverage.sitemap[/bold]), "
|
|
91
|
+
"then run [bold]steppy coverage refresh[/bold].\n"
|
|
92
|
+
)
|
|
93
|
+
return
|
|
94
|
+
console.print(
|
|
95
|
+
f"[bold]E2E surface[/bold]: {percent}% "
|
|
96
|
+
f"({ratio['tested']}/{ratio['declared']} declared endpoints + routes a UI test could reach)\n"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def print_summary(gaps: dict, console) -> None:
|
|
101
|
+
"""Short human summary: counts first, then the bucket lists."""
|
|
102
|
+
counts = gaps["counts"]
|
|
103
|
+
ep = counts["endpoints"]
|
|
104
|
+
console.print(
|
|
105
|
+
f"[bold]Endpoints[/bold]: {ep['declared']} declared | "
|
|
106
|
+
f"{ep['tested']} tested ({ep['tested_strong']} strong) | "
|
|
107
|
+
f"[red]{ep['declared_unreached']} declared-unreached[/red] "
|
|
108
|
+
f"({ep['declared_unreached_actionable']} actionable, rest non_ui) | "
|
|
109
|
+
f"{ep['discovered_uncovered']} discovered-uncovered | "
|
|
110
|
+
f"{ep['unclassified']} unclassified"
|
|
111
|
+
)
|
|
112
|
+
rt = counts["routes"]
|
|
113
|
+
console.print(
|
|
114
|
+
f"[bold]Routes[/bold]: {rt['declared']} declared | {rt['tested']} tested | "
|
|
115
|
+
f"[red]{rt['declared_unreached']} declared-unreached[/red] | "
|
|
116
|
+
f"{rt['discovered_uncovered']} discovered-uncovered"
|
|
117
|
+
)
|
|
118
|
+
console.print(
|
|
119
|
+
f"[bold]Modules[/bold]: {counts['modules']['covered']} covered | scenarios in map: {counts['scenarios']}"
|
|
120
|
+
)
|
|
121
|
+
_print_surface(gaps, console)
|
|
122
|
+
|
|
123
|
+
unreached = gaps["endpoints"]["declared_unreached"]
|
|
124
|
+
actionable = [e for e in unreached if e["triage"] != "non_ui"]
|
|
125
|
+
if actionable:
|
|
126
|
+
_print_bucket_table(
|
|
127
|
+
console,
|
|
128
|
+
"Declared but unreached (actionable)",
|
|
129
|
+
actionable,
|
|
130
|
+
["api", "method", "key", "triage", "note"],
|
|
131
|
+
)
|
|
132
|
+
non_ui = [e for e in unreached if e["triage"] == "non_ui"]
|
|
133
|
+
if non_ui:
|
|
134
|
+
by_source = _count_sources(non_ui)
|
|
135
|
+
detail = ", ".join(f"{n} from {src or 'unspecified'}" for src, n in by_source)
|
|
136
|
+
console.print(f"[dim]{len(non_ui)} declared-unreached triaged non_ui, hidden ({detail})[/dim]")
|
|
137
|
+
|
|
138
|
+
if gaps["endpoints"]["discovered_uncovered"]:
|
|
139
|
+
_print_bucket_table(
|
|
140
|
+
console,
|
|
141
|
+
"Discovered but weakly covered (endpoints)",
|
|
142
|
+
gaps["endpoints"]["discovered_uncovered"],
|
|
143
|
+
["api", "method", "key", "scenarios"],
|
|
144
|
+
)
|
|
145
|
+
if gaps["routes"]["declared_unreached"]:
|
|
146
|
+
_print_bucket_table(
|
|
147
|
+
console,
|
|
148
|
+
"Declared but unreached (routes)",
|
|
149
|
+
gaps["routes"]["declared_unreached"],
|
|
150
|
+
["key", "triage", "note"],
|
|
151
|
+
)
|
|
152
|
+
if gaps["routes"]["discovered_uncovered"]:
|
|
153
|
+
_print_bucket_table(
|
|
154
|
+
console,
|
|
155
|
+
"Discovered but weakly covered (routes)",
|
|
156
|
+
gaps["routes"]["discovered_uncovered"],
|
|
157
|
+
["key", "scenarios"],
|
|
158
|
+
)
|
|
159
|
+
if gaps["endpoints"]["unclassified"]:
|
|
160
|
+
console.print(
|
|
161
|
+
f"[yellow]{len(gaps['endpoints']['unclassified'])} unclassified API request "
|
|
162
|
+
"pattern(s)[/yellow] — requests matching no configured coverage.apis base_urls; "
|
|
163
|
+
"check base_urls (include internal_url origins for parallel runs)."
|
|
164
|
+
)
|
|
165
|
+
if gaps["endpoints"]["discovered_tested"]:
|
|
166
|
+
console.print(
|
|
167
|
+
f"[yellow]{len(gaps['endpoints']['discovered_tested'])} tested endpoint(s) missing "
|
|
168
|
+
"from the manifest[/yellow] — stale spec or base-URL prefix mismatch; "
|
|
169
|
+
"run `steppy coverage refresh`."
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
declared_total = ep["declared"]
|
|
173
|
+
if declared_total and ep["tested"] == 0:
|
|
174
|
+
console.print(
|
|
175
|
+
"[yellow]No declared endpoint was ever matched by an observed request — "
|
|
176
|
+
"if scenarios did run, coverage.apis base_urls probably don't mirror the "
|
|
177
|
+
"OpenAPI servers[].url prefix.[/yellow]"
|
|
178
|
+
)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Source-map decoding: executed V8 offsets -> source modules. Pure Python.
|
|
2
|
+
|
|
3
|
+
The recorder (generated conftest) persists, per script: the executed function
|
|
4
|
+
start offsets (V8 character offsets), a ``line_starts`` newline-offset table
|
|
5
|
+
computed from the script source, and a reference to the source map (inline
|
|
6
|
+
``data:`` maps are decoded + stored at record time; external maps are looked
|
|
7
|
+
up on local disk at merge time via ``coverage.sourcemap_dir``).
|
|
8
|
+
|
|
9
|
+
This module turns those into source-module sets:
|
|
10
|
+
|
|
11
|
+
offset --(bisect over line_starts)--> (generated line, col)
|
|
12
|
+
--(floor lookup in the decoded mappings line index)--> sources[idx]
|
|
13
|
+
--(webpack:// / sourceRoot / dot-segment normalization, ignore
|
|
14
|
+
filter)--> module key
|
|
15
|
+
|
|
16
|
+
Degradation ladder (per script, never an error): usable map -> module keys;
|
|
17
|
+
unusable/missing map or missing line_starts -> a single ``bundle:<url-path>``
|
|
18
|
+
node so the signal survives at bundle granularity.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import gzip
|
|
24
|
+
import json
|
|
25
|
+
import posixpath
|
|
26
|
+
import re
|
|
27
|
+
from bisect import bisect_right
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
_B64 = {c: i for i, c in enumerate("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")}
|
|
32
|
+
_SCHEME_RE = re.compile(r"^[a-zA-Z][\w+.\-]*://")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SourceMapUnusable(Exception):
|
|
36
|
+
"""Structural problem with a map — caller degrades to bundle granularity."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decode_vlq_segment(segment: str) -> list[int]:
|
|
40
|
+
values: list[int] = []
|
|
41
|
+
result = shift = 0
|
|
42
|
+
for ch in segment:
|
|
43
|
+
try:
|
|
44
|
+
digit = _B64[ch]
|
|
45
|
+
except KeyError:
|
|
46
|
+
raise SourceMapUnusable(f"invalid VLQ character {ch!r}") from None
|
|
47
|
+
result |= (digit & 31) << shift
|
|
48
|
+
if digit & 32:
|
|
49
|
+
shift += 5
|
|
50
|
+
else:
|
|
51
|
+
values.append(-(result >> 1) if result & 1 else result >> 1)
|
|
52
|
+
result = shift = 0
|
|
53
|
+
if shift:
|
|
54
|
+
raise SourceMapUnusable("truncated VLQ segment")
|
|
55
|
+
return values
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_line_index(mappings: str) -> list[tuple[list[int], list[int]]]:
|
|
59
|
+
"""Decode ``mappings`` into per-generated-line ``(gen_cols, src_idxs)``.
|
|
60
|
+
|
|
61
|
+
``gen_col`` resets per line; ``src_idx`` accumulates across lines (spec
|
|
62
|
+
behaviour). Segments without a source field get ``src_idx = -1``.
|
|
63
|
+
"""
|
|
64
|
+
lines: list[tuple[list[int], list[int]]] = []
|
|
65
|
+
src_idx = 0
|
|
66
|
+
for line_str in mappings.split(";"):
|
|
67
|
+
cols: list[int] = []
|
|
68
|
+
idxs: list[int] = []
|
|
69
|
+
gen_col = 0
|
|
70
|
+
for seg in line_str.split(","):
|
|
71
|
+
if not seg:
|
|
72
|
+
continue
|
|
73
|
+
fields = _decode_vlq_segment(seg)
|
|
74
|
+
gen_col += fields[0]
|
|
75
|
+
if len(fields) >= 4:
|
|
76
|
+
src_idx += fields[1]
|
|
77
|
+
cols.append(gen_col)
|
|
78
|
+
idxs.append(src_idx)
|
|
79
|
+
else:
|
|
80
|
+
cols.append(gen_col)
|
|
81
|
+
idxs.append(-1)
|
|
82
|
+
lines.append((cols, idxs))
|
|
83
|
+
return lines
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def normalize_source_path(source: str, source_root: str = "") -> str:
|
|
87
|
+
"""Normalize a source-map ``sources`` entry to a stable module key.
|
|
88
|
+
|
|
89
|
+
Resolves against ``sourceRoot``, strips scheme prefixes (``webpack://``,
|
|
90
|
+
``ng://`` …) and leading slashes, and collapses dot segments. Webpack
|
|
91
|
+
namespace components (``webpack://my-app/src/…``) are kept — they are
|
|
92
|
+
stable across runs and can be ignored via ``coverage.module_ignore``.
|
|
93
|
+
"""
|
|
94
|
+
s = source
|
|
95
|
+
if source_root:
|
|
96
|
+
root = source_root if source_root.endswith("/") else source_root + "/"
|
|
97
|
+
if not _SCHEME_RE.match(s) and not s.startswith("/"):
|
|
98
|
+
s = root + s
|
|
99
|
+
m = _SCHEME_RE.match(s)
|
|
100
|
+
if m:
|
|
101
|
+
s = s[m.end() :]
|
|
102
|
+
s = s.lstrip("/")
|
|
103
|
+
if s:
|
|
104
|
+
s = posixpath.normpath(s)
|
|
105
|
+
if s == ".":
|
|
106
|
+
s = ""
|
|
107
|
+
return s
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_ignored(module: str, ignore: tuple[str, ...]) -> bool:
|
|
111
|
+
"""True when ``module`` falls under any ignore entry (prefix or any path level)."""
|
|
112
|
+
for entry in ignore:
|
|
113
|
+
clean = entry.rstrip("/")
|
|
114
|
+
if not clean:
|
|
115
|
+
continue
|
|
116
|
+
if module == clean or module.startswith(clean + "/"):
|
|
117
|
+
return True
|
|
118
|
+
if f"/{clean}/" in f"/{module}":
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def modules_for_script(
|
|
124
|
+
executed_offsets: list[int],
|
|
125
|
+
line_starts: list[int],
|
|
126
|
+
map_obj: dict,
|
|
127
|
+
ignore: tuple[str, ...] = (),
|
|
128
|
+
) -> set[str]:
|
|
129
|
+
"""Map executed character offsets to the set of executed source modules.
|
|
130
|
+
|
|
131
|
+
Raises :class:`SourceMapUnusable` on structural problems (index maps,
|
|
132
|
+
missing sources, malformed VLQ) so the caller can fall back to bundle
|
|
133
|
+
granularity. Offsets that hit no mapping segment are silently skipped.
|
|
134
|
+
"""
|
|
135
|
+
if not isinstance(map_obj, dict):
|
|
136
|
+
raise SourceMapUnusable("map is not a JSON object")
|
|
137
|
+
if "sections" in map_obj:
|
|
138
|
+
raise SourceMapUnusable("index maps (sections) not supported")
|
|
139
|
+
sources = map_obj.get("sources") or []
|
|
140
|
+
if not sources:
|
|
141
|
+
raise SourceMapUnusable("map has no sources")
|
|
142
|
+
if not line_starts:
|
|
143
|
+
raise SourceMapUnusable("missing line_starts")
|
|
144
|
+
source_root = map_obj.get("sourceRoot") or ""
|
|
145
|
+
normalized = [normalize_source_path(s or "", source_root) for s in sources]
|
|
146
|
+
line_index = build_line_index(map_obj.get("mappings") or "")
|
|
147
|
+
|
|
148
|
+
modules: set[str] = set()
|
|
149
|
+
for offset in executed_offsets:
|
|
150
|
+
line = bisect_right(line_starts, offset) - 1
|
|
151
|
+
if line < 0 or line >= len(line_index):
|
|
152
|
+
continue
|
|
153
|
+
col = offset - line_starts[line]
|
|
154
|
+
cols, idxs = line_index[line]
|
|
155
|
+
i = bisect_right(cols, col) - 1
|
|
156
|
+
if i < 0:
|
|
157
|
+
continue
|
|
158
|
+
idx = idxs[i]
|
|
159
|
+
if 0 <= idx < len(normalized):
|
|
160
|
+
module = normalized[idx]
|
|
161
|
+
if module and not is_ignored(module, ignore):
|
|
162
|
+
modules.add(module)
|
|
163
|
+
return modules
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def bundle_key(script_url: str) -> str:
|
|
167
|
+
"""Fallback module key when no usable map exists for a script."""
|
|
168
|
+
path = urlsplit(script_url).path
|
|
169
|
+
return f"bundle:{path or script_url}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def load_map_for_entry(
|
|
173
|
+
entry: dict,
|
|
174
|
+
maps_dir: Path | None,
|
|
175
|
+
sourcemap_dir: Path | None,
|
|
176
|
+
) -> dict | None:
|
|
177
|
+
"""Resolve the source map for one recorded script entry.
|
|
178
|
+
|
|
179
|
+
Order: inline blob persisted by the recorder (``_maps/<sha256>.json.gz``)
|
|
180
|
+
-> local-disk lookup under ``sourcemap_dir`` (by sourceMappingURL
|
|
181
|
+
basename, then ``<script-basename>.map``; recursive, lexicographically
|
|
182
|
+
first match) -> ``None`` (bundle fallback).
|
|
183
|
+
"""
|
|
184
|
+
map_info = entry.get("map") or {}
|
|
185
|
+
if map_info.get("kind") == "inline" and map_info.get("hash") and maps_dir is not None:
|
|
186
|
+
blob = Path(maps_dir) / f"{map_info['hash']}.json.gz"
|
|
187
|
+
if blob.is_file():
|
|
188
|
+
try:
|
|
189
|
+
return json.loads(gzip.decompress(blob.read_bytes()).decode("utf-8"))
|
|
190
|
+
except (OSError, ValueError):
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
if sourcemap_dir is None:
|
|
194
|
+
return None
|
|
195
|
+
sourcemap_dir = Path(sourcemap_dir)
|
|
196
|
+
if not sourcemap_dir.is_dir():
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
candidates: list[str] = []
|
|
200
|
+
if map_info.get("kind") == "external" and map_info.get("url"):
|
|
201
|
+
name = posixpath.basename(urlsplit(map_info["url"]).path)
|
|
202
|
+
if name:
|
|
203
|
+
candidates.append(name)
|
|
204
|
+
script_name = posixpath.basename(urlsplit(entry.get("url") or "").path)
|
|
205
|
+
if script_name:
|
|
206
|
+
candidates.append(script_name + ".map")
|
|
207
|
+
|
|
208
|
+
seen: set[str] = set()
|
|
209
|
+
for name in candidates:
|
|
210
|
+
if name in seen:
|
|
211
|
+
continue
|
|
212
|
+
seen.add(name)
|
|
213
|
+
matches = sorted(sourcemap_dir.rglob(name), key=lambda p: p.as_posix())
|
|
214
|
+
for match in matches:
|
|
215
|
+
try:
|
|
216
|
+
return json.loads(match.read_text(encoding="utf-8"))
|
|
217
|
+
except (OSError, ValueError):
|
|
218
|
+
continue
|
|
219
|
+
return None
|