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,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")