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,329 @@
|
|
|
1
|
+
"""Path → ``BundleFile`` classification.
|
|
2
|
+
|
|
3
|
+
Pure except for the ``stat``/hash the caller performs: given a path relative to
|
|
4
|
+
the project root and a little context (which run, which locales and layouts the
|
|
5
|
+
project declares, which nodeids actually ran), decide what a file *is* and what
|
|
6
|
+
scenario / locale / layout / step it belongs to.
|
|
7
|
+
|
|
8
|
+
Two shapes in the output tree resist naive parsing, and both are handled here
|
|
9
|
+
rather than guessed at by the caller:
|
|
10
|
+
|
|
11
|
+
**A one-segment capture directory is ambiguous.** ``<slug>/fr/`` and
|
|
12
|
+
``<slug>/desktop/`` are structurally identical, so only the name distinguishes
|
|
13
|
+
them — resolved against the configured sets by :mod:`steppy.captures`.
|
|
14
|
+
|
|
15
|
+
**Failure screenshots, traces and videos are keyed by a sanitised nodeid**,
|
|
16
|
+
and the sanitiser is lossy (``::``→``__``, ``[``→``(``): a slug containing an
|
|
17
|
+
underscore is indistinguishable from a separator. So we never invert it. We
|
|
18
|
+
build the *forward* map instead — sanitise every nodeid the run reported and
|
|
19
|
+
look the filename up — which is exact for every test that ran, and honestly
|
|
20
|
+
unattributed for anything else.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Iterable, Optional
|
|
30
|
+
|
|
31
|
+
from ..captures import FAILURES_DIRNAME, classify_capture_path, sanitise_nodeid
|
|
32
|
+
from ..contracts.bundle import BundleFile
|
|
33
|
+
|
|
34
|
+
# ── kinds ───────────────────────────────────────────────────────────────────
|
|
35
|
+
# A consumer stores unknown kinds opaquely (contract rule 2), so adding one is
|
|
36
|
+
# always safe. `other` is the explicit bucket for anything unrecognised — a
|
|
37
|
+
# bundle that silently DROPS a file is far worse than one carrying a file
|
|
38
|
+
# nobody classified.
|
|
39
|
+
KIND_SCREENSHOT = "screenshot"
|
|
40
|
+
KIND_SIDECAR = "sidecar"
|
|
41
|
+
KIND_FAILURE_SCREENSHOT = "failure_screenshot"
|
|
42
|
+
KIND_COVERAGE_RECORD = "coverage_record"
|
|
43
|
+
KIND_COVERAGE_SOURCEMAP = "coverage_sourcemap"
|
|
44
|
+
KIND_COVERAGE_GAPS = "coverage_gaps"
|
|
45
|
+
KIND_FAILURE_LOG = "failure_log"
|
|
46
|
+
KIND_WORKER_LOG = "worker_log"
|
|
47
|
+
KIND_GUIDE_HTML = "guide_html"
|
|
48
|
+
KIND_GUIDE_MD = "guide_md"
|
|
49
|
+
KIND_INTERACTIVE_MANIFEST = "interactive_manifest"
|
|
50
|
+
KIND_GUIDE_ASSET = "guide_asset"
|
|
51
|
+
KIND_TRACE = "trace"
|
|
52
|
+
KIND_VIDEO = "video"
|
|
53
|
+
KIND_REPORT = "report"
|
|
54
|
+
KIND_EVENTS = "events"
|
|
55
|
+
KIND_JUNIT = "junit"
|
|
56
|
+
KIND_RUN_SIDECAR = "run_sidecar"
|
|
57
|
+
KIND_OTHER = "other"
|
|
58
|
+
|
|
59
|
+
#: Kinds a size budget may drop, cheapest-loss first. Guides are regenerable
|
|
60
|
+
#: from the screenshots the bundle still carries — that is precisely the point
|
|
61
|
+
#: of shipping the tree unrewritten — so they cost less to lose than a capture.
|
|
62
|
+
#:
|
|
63
|
+
#: Three kinds are deliberately absent, and the omission is the rule rather than
|
|
64
|
+
#: an oversight (this tuple reads as exhaustive, so it is worth stating): a
|
|
65
|
+
#: ``screenshot`` and its ``sidecar`` are the run's irreplaceable evidence, and
|
|
66
|
+
#: ``coverage_gaps`` is a few kilobytes that is the *only* way a detached bundle
|
|
67
|
+
#: can report a real coverage surface — the store it came from lives outside
|
|
68
|
+
#: ``output/`` and never travels.
|
|
69
|
+
DROP_ORDER = (
|
|
70
|
+
KIND_VIDEO,
|
|
71
|
+
KIND_TRACE,
|
|
72
|
+
KIND_GUIDE_ASSET,
|
|
73
|
+
KIND_GUIDE_HTML,
|
|
74
|
+
KIND_GUIDE_MD,
|
|
75
|
+
KIND_INTERACTIVE_MANIFEST,
|
|
76
|
+
KIND_REPORT,
|
|
77
|
+
KIND_OTHER,
|
|
78
|
+
KIND_COVERAGE_SOURCEMAP,
|
|
79
|
+
KIND_FAILURE_SCREENSHOT,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
#: ``<role>_page__fr-mobile.json`` / ``<role>_page_noauth__default-default.json``
|
|
83
|
+
_COVERAGE_RECORD_RE = re.compile(r"^(?P<fixture>.+)__(?P<locale>[^-]*)-(?P<layout>[^-]*)\.json$")
|
|
84
|
+
|
|
85
|
+
_GUIDE_TEXT_EXT = {".html": KIND_GUIDE_HTML, ".md": KIND_GUIDE_MD}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class ClassifyContext:
|
|
90
|
+
"""Everything the classifier needs that it cannot read off a path."""
|
|
91
|
+
|
|
92
|
+
#: The run being bundled. Coverage and event directories are per-run, so
|
|
93
|
+
#: this is what keeps a *previous* run's records out of this bundle.
|
|
94
|
+
run_id: str
|
|
95
|
+
locales: frozenset[str] = frozenset()
|
|
96
|
+
layouts: frozenset[str] = frozenset()
|
|
97
|
+
#: ``sanitise_nodeid(nodeid)`` → ``(nodeid, scenario, locale, layout)``, for
|
|
98
|
+
#: the artifacts named after a nodeid. Built by :func:`nodeid_index`.
|
|
99
|
+
nodeid_index: dict = field(default_factory=dict)
|
|
100
|
+
#: Directory names under ``output/`` for each root, so a project that
|
|
101
|
+
#: relocated one via ``config.output`` (screenshots, guides) or
|
|
102
|
+
#: ``coverage.records_dir`` still classifies. Filled by
|
|
103
|
+
#: :func:`steppy.bundle.create.resolve_roots`. ``reports_dirname`` has no
|
|
104
|
+
#: config knob — ``output.reports_dir`` is deprecated and inert.
|
|
105
|
+
screenshots_dirname: str = "screenshots"
|
|
106
|
+
coverage_dirname: str = "coverage"
|
|
107
|
+
guides_dirname: str = "guides"
|
|
108
|
+
reports_dirname: str = "reports"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def nodeid_index(tests: Iterable) -> dict:
|
|
112
|
+
"""Forward map from sanitised nodeid to attribution.
|
|
113
|
+
|
|
114
|
+
Built from the run's own test results, so it covers exactly the tests that
|
|
115
|
+
ran — which is exactly the set that can have produced a failure screenshot,
|
|
116
|
+
a trace or a video.
|
|
117
|
+
"""
|
|
118
|
+
index = {}
|
|
119
|
+
for test in tests:
|
|
120
|
+
nodeid = getattr(test, "nodeid", None)
|
|
121
|
+
if not nodeid:
|
|
122
|
+
continue
|
|
123
|
+
index[sanitise_nodeid(nodeid)] = (
|
|
124
|
+
nodeid,
|
|
125
|
+
getattr(test, "scenario", None),
|
|
126
|
+
getattr(test, "locale", None),
|
|
127
|
+
getattr(test, "layout", None),
|
|
128
|
+
)
|
|
129
|
+
return index
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _attribute_nodeid(stem: str, ctx: ClassifyContext) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
133
|
+
"""``(scenario, locale, layout)`` for a nodeid-named artifact.
|
|
134
|
+
|
|
135
|
+
Failure screenshots carry a trailing ``__<fixture>`` the traces and videos
|
|
136
|
+
do not, so try the whole stem first and then progressively shorter prefixes.
|
|
137
|
+
An exact hit is authoritative; a miss returns all-``None`` rather than a
|
|
138
|
+
guess, because the sanitiser is not invertible.
|
|
139
|
+
"""
|
|
140
|
+
hit = ctx.nodeid_index.get(stem)
|
|
141
|
+
if hit is not None:
|
|
142
|
+
return hit[1], hit[2], hit[3]
|
|
143
|
+
# Peel trailing "__<suffix>" groups (the fixture name on failure shots).
|
|
144
|
+
prefix = stem
|
|
145
|
+
while "__" in prefix:
|
|
146
|
+
prefix = prefix.rsplit("__", 1)[0]
|
|
147
|
+
hit = ctx.nodeid_index.get(prefix)
|
|
148
|
+
if hit is not None:
|
|
149
|
+
return hit[1], hit[2], hit[3]
|
|
150
|
+
return None, None, None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _literal_or_none(value: str, configured: frozenset[str]) -> Optional[str]:
|
|
154
|
+
"""Map the sentinel ``default`` back to ``None``.
|
|
155
|
+
|
|
156
|
+
The coverage record writer substitutes the literal string ``default`` for an
|
|
157
|
+
unset dimension, unlike the screenshot tree which omits the segment. A
|
|
158
|
+
project *could* legitimately have a locale named ``default``, so only treat
|
|
159
|
+
it as the sentinel when it is not one the project declares.
|
|
160
|
+
"""
|
|
161
|
+
if not value:
|
|
162
|
+
return None
|
|
163
|
+
if value == "default" and value not in configured:
|
|
164
|
+
return None
|
|
165
|
+
return value
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
#: Gap report a run leaves beside its raw records, so a bundle can show real
|
|
169
|
+
#: coverage buckets. The sqlite store it is computed from lives outside
|
|
170
|
+
#: ``output/`` (deliberately — cleanup must not wipe manual triage) and so can
|
|
171
|
+
#: never be in an archive; this file is what crosses the gap.
|
|
172
|
+
COVERAGE_GAPS_NAME = "gaps.json"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _coverage_file(rel: Path, parts: tuple[str, ...], ctx: ClassifyContext) -> Optional[BundleFile]:
|
|
176
|
+
"""``<coverage>/...`` — the gap report, raw records, gzipped source maps.
|
|
177
|
+
|
|
178
|
+
``None`` means **skip this file entirely**, and it is reserved for exactly one
|
|
179
|
+
case: a *previous* run's raw records, which is what keeps an ``output/``
|
|
180
|
+
directory that accumulates across runs from polluting today's bundle.
|
|
181
|
+
|
|
182
|
+
Everything else under the coverage root gets a kind, even if that kind is
|
|
183
|
+
``other``. Returning ``None`` for an unrecognised name would silently drop a
|
|
184
|
+
file — and this module's own rule is that carrying a file nobody labelled
|
|
185
|
+
beats dropping one nobody noticed. Before this branch existed,
|
|
186
|
+
``output/coverage/gaps.json`` vanished from every bundle for that reason.
|
|
187
|
+
"""
|
|
188
|
+
# parts: (<coverage>, "raw", <run_id>, ...) for a record; (<coverage>,
|
|
189
|
+
# "gaps.json") for the gap report.
|
|
190
|
+
if parts[1:] == (COVERAGE_GAPS_NAME,):
|
|
191
|
+
return BundleFile(path="", kind=KIND_COVERAGE_GAPS, sha256="", size=0)
|
|
192
|
+
if parts[1:2] != ("raw",) or len(parts) < 4:
|
|
193
|
+
return BundleFile(path="", kind=KIND_OTHER, sha256="", size=0)
|
|
194
|
+
if parts[2] != ctx.run_id:
|
|
195
|
+
# A previous run's records. Excluding them is what makes an output/
|
|
196
|
+
# directory that accumulates across runs harmless.
|
|
197
|
+
return None
|
|
198
|
+
tail = parts[3:]
|
|
199
|
+
if tail[0] == "_maps":
|
|
200
|
+
return BundleFile(path="", kind=KIND_COVERAGE_SOURCEMAP, sha256="", size=0)
|
|
201
|
+
if len(tail) != 2:
|
|
202
|
+
return BundleFile(path="", kind=KIND_OTHER, sha256="", size=0)
|
|
203
|
+
scenario = tail[0]
|
|
204
|
+
match = _COVERAGE_RECORD_RE.match(tail[1])
|
|
205
|
+
locale = layout = None
|
|
206
|
+
if match:
|
|
207
|
+
locale = _literal_or_none(match.group("locale"), ctx.locales)
|
|
208
|
+
layout = _literal_or_none(match.group("layout"), ctx.layouts)
|
|
209
|
+
return BundleFile(
|
|
210
|
+
path="",
|
|
211
|
+
kind=KIND_COVERAGE_RECORD,
|
|
212
|
+
sha256="",
|
|
213
|
+
size=0,
|
|
214
|
+
scenario=scenario,
|
|
215
|
+
locale=locale,
|
|
216
|
+
layout=layout,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _guide_file(parts: tuple[str, ...], ctx: ClassifyContext) -> BundleFile:
|
|
221
|
+
"""``<guides>/<role>/<locale>/[<layout>/]<slug>.{md,html}`` and friends."""
|
|
222
|
+
name = parts[-1]
|
|
223
|
+
suffix = Path(name).suffix.lower()
|
|
224
|
+
if name == "guide.json":
|
|
225
|
+
return BundleFile(path="", kind=KIND_INTERACTIVE_MANIFEST, sha256="", size=0, scenario=parts[-2])
|
|
226
|
+
kind = _GUIDE_TEXT_EXT.get(suffix, KIND_GUIDE_ASSET)
|
|
227
|
+
# parts = (<guides>, <role>, <locale>, [<layout>,] <file>)
|
|
228
|
+
middle = parts[2:-1]
|
|
229
|
+
locale = middle[0] if len(middle) >= 1 else None
|
|
230
|
+
layout = middle[1] if len(middle) >= 2 else None
|
|
231
|
+
scenario = Path(name).stem if kind in (KIND_GUIDE_HTML, KIND_GUIDE_MD) else None
|
|
232
|
+
if scenario and scenario.endswith("-handbook"):
|
|
233
|
+
scenario = None
|
|
234
|
+
return BundleFile(
|
|
235
|
+
path="",
|
|
236
|
+
kind=kind,
|
|
237
|
+
sha256="",
|
|
238
|
+
size=0,
|
|
239
|
+
scenario=scenario,
|
|
240
|
+
locale=_literal_or_none(locale or "", ctx.locales) if locale else None,
|
|
241
|
+
layout=layout,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def classify(rel: Path, ctx: ClassifyContext) -> Optional[BundleFile]:
|
|
246
|
+
"""Classify one path relative to ``output/``.
|
|
247
|
+
|
|
248
|
+
Returns a :class:`BundleFile` with ``path``/``sha256``/``size`` left blank
|
|
249
|
+
for the caller to fill (it is the one doing I/O), or ``None`` when the file
|
|
250
|
+
belongs to a different run and must be skipped entirely.
|
|
251
|
+
"""
|
|
252
|
+
parts = rel.parts
|
|
253
|
+
if not parts:
|
|
254
|
+
return None
|
|
255
|
+
root = parts[0]
|
|
256
|
+
|
|
257
|
+
if root == ctx.screenshots_dirname:
|
|
258
|
+
if len(parts) >= 2 and parts[1] == FAILURES_DIRNAME:
|
|
259
|
+
scenario, locale, layout = _attribute_nodeid(Path(parts[-1]).stem, ctx)
|
|
260
|
+
return BundleFile(
|
|
261
|
+
path="",
|
|
262
|
+
kind=KIND_FAILURE_SCREENSHOT,
|
|
263
|
+
sha256="",
|
|
264
|
+
size=0,
|
|
265
|
+
scenario=scenario,
|
|
266
|
+
locale=locale,
|
|
267
|
+
layout=layout,
|
|
268
|
+
)
|
|
269
|
+
capture = classify_capture_path(parts[1:], locales=ctx.locales, layouts=ctx.layouts)
|
|
270
|
+
if capture is None:
|
|
271
|
+
return BundleFile(path="", kind=KIND_OTHER, sha256="", size=0)
|
|
272
|
+
return BundleFile(
|
|
273
|
+
path="",
|
|
274
|
+
kind=KIND_SCREENSHOT if capture.ext == "png" else KIND_SIDECAR,
|
|
275
|
+
sha256="",
|
|
276
|
+
size=0,
|
|
277
|
+
scenario=capture.scenario,
|
|
278
|
+
locale=capture.locale,
|
|
279
|
+
layout=capture.layout,
|
|
280
|
+
step=capture.step,
|
|
281
|
+
frame=capture.frame,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if root == ctx.coverage_dirname:
|
|
285
|
+
return _coverage_file(rel, parts, ctx)
|
|
286
|
+
|
|
287
|
+
if root == ctx.guides_dirname and len(parts) >= 2:
|
|
288
|
+
return _guide_file(parts, ctx)
|
|
289
|
+
|
|
290
|
+
if root == "failures" and len(parts) >= 2:
|
|
291
|
+
if parts[1] == "_worker-logs":
|
|
292
|
+
return BundleFile(path="", kind=KIND_WORKER_LOG, sha256="", size=0)
|
|
293
|
+
return BundleFile(path="", kind=KIND_FAILURE_LOG, sha256="", size=0, scenario=parts[1])
|
|
294
|
+
|
|
295
|
+
if root == "traces":
|
|
296
|
+
scenario, locale, layout = _attribute_nodeid(Path(parts[-1]).stem, ctx)
|
|
297
|
+
return BundleFile(path="", kind=KIND_TRACE, sha256="", size=0, scenario=scenario, locale=locale, layout=layout)
|
|
298
|
+
|
|
299
|
+
if root == "videos":
|
|
300
|
+
scenario, locale, layout = _attribute_nodeid(Path(parts[-1]).stem, ctx)
|
|
301
|
+
return BundleFile(path="", kind=KIND_VIDEO, sha256="", size=0, scenario=scenario, locale=locale, layout=layout)
|
|
302
|
+
|
|
303
|
+
if root == ctx.reports_dirname:
|
|
304
|
+
return BundleFile(path="", kind=KIND_REPORT, sha256="", size=0)
|
|
305
|
+
|
|
306
|
+
return BundleFile(path="", kind=KIND_OTHER, sha256="", size=0)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def count_coverage_records(coverage_root: Path, run_id: str) -> int:
|
|
310
|
+
"""How many raw coverage records this run produced, for ``manifest.coverage``."""
|
|
311
|
+
raw = coverage_root / "raw" / run_id
|
|
312
|
+
if not raw.is_dir():
|
|
313
|
+
return 0
|
|
314
|
+
return sum(1 for p in raw.rglob("*.json") if p.is_file() and p.parent.name != "_maps")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def read_coverage_record_attribution(path: Path) -> Optional[tuple]:
|
|
318
|
+
"""``(scenario, locale, layout)`` read from a coverage record's own JSON.
|
|
319
|
+
|
|
320
|
+
Self-description beats filename parsing, so this is tried first and the
|
|
321
|
+
regex is only the fallback for a truncated or unreadable record.
|
|
322
|
+
"""
|
|
323
|
+
try:
|
|
324
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
325
|
+
except (OSError, ValueError):
|
|
326
|
+
return None
|
|
327
|
+
if not isinstance(data, dict):
|
|
328
|
+
return None
|
|
329
|
+
return data.get("scenario"), data.get("locale"), data.get("layout")
|