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,2405 @@
1
+ """
2
+ Auto-generated by Steppy. Do not edit manually — changes will be overwritten.
3
+ Re-generate with: steppy generate
4
+ """
5
+ import base64
6
+ import hashlib
7
+ import hmac
8
+ import json
9
+ import os
10
+ import re
11
+ import time
12
+ import warnings
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+ import yaml
17
+ from playwright.sync_api import Browser, BrowserContext, Page, expect
18
+
19
+ # ── Config ──────────────────────────────────────────────────────────────────
20
+
21
+ CONFIG_PATH = Path(__file__).parent.parent.parent / "config.yaml"
22
+ CREDENTIALS_PATH = Path(__file__).parent.parent.parent / "credentials.yaml"
23
+ AUTH_DIR = Path(__file__).parent.parent.parent / ".auth"
24
+ # SCREENSHOTS_DIR is defined after CONFIG loads — it is resolved from
25
+ # `config.output.screenshots_dir` at runtime (see below).
26
+ TRACES_DIR = Path(__file__).parent.parent.parent / "output" / "traces"
27
+ VIDEOS_DIR = Path(__file__).parent.parent.parent / "output" / "videos"
28
+
29
+ # Playwright trace mode (FLAWS §6.1): "" (off) | "on" | "on-failure". Set by
30
+ # `steppy run --trace` / config.report.trace via the STEPPY_TRACE env var. When
31
+ # off, the trace helpers below are complete no-ops (byte-identical behavior).
32
+ TRACE_MODE = os.environ.get("STEPPY_TRACE", "")
33
+
34
+ # Playwright video mode (FLAWS §6.1): "" (off) | "on" | "on-failure". Set by
35
+ # `steppy run --video` / config.report.video via the STEPPY_VIDEO env var. When
36
+ # off, `_video_context_kwargs()` returns {} so `new_context` is byte-identical.
37
+ VIDEO_MODE = os.environ.get("STEPPY_VIDEO", "")
38
+
39
+ # Static default viewport from config. NOTE: the per-screenshot sidecar records
40
+ # the *actual* runtime viewport via `page.viewport_size`
41
+ # (see take_screenshot_with_bbox) so the guide renderer scales cursor bounding
42
+ # boxes against the size the browser truly ran at — not this default — which
43
+ # matters when `screenshot.layouts` declares per-layout viewports.
44
+ VIEWPORT_FOR_CURSOR = {"width": {{ config.screenshot.viewport.width }}, "height": {{ config.screenshot.viewport.height }}}
45
+ SCREENSHOT_FULL_PAGE = {{ "True" if config.screenshot.full_page else "False" }}
46
+
47
+ # Named layouts (e.g. desktop/mobile). Empty unless `screenshot.layouts` is
48
+ # declared in config.yaml. Populated layouts let `steppy run --guide` (or
49
+ # `--layouts ...`) parametrize fixtures over per-layout viewports.
50
+ LAYOUT_VIEWPORTS = {
51
+ {%- for name, layout in config.screenshot.layouts.items() %}
52
+ {{ name | tojson }}: {"width": {{ layout.width }}, "height": {{ layout.height }}},
53
+ {%- endfor %}
54
+ }
55
+
56
+ # Per-layout capture devicePixelRatio (mirrors LAYOUT_VIEWPORTS). A value >1
57
+ # makes Playwright render the page at that pixel density, so narrow (e.g.
58
+ # mobile) captures carry enough pixels to stay crisp when the guide displays
59
+ # them at the content-column width. Layouts without an entry fall back to
60
+ # `_get_scale_factor()` (the `screenshot.device_scale_factor` default, itself 1
61
+ # unless configured). This does NOT set is_mobile/has_touch — only density.
62
+ LAYOUT_SCALE_FACTORS = {
63
+ {%- for name, layout in config.screenshot.layouts.items() %}
64
+ {{ name | tojson }}: {{ layout.device_scale_factor }},
65
+ {%- endfor %}
66
+ }
67
+
68
+
69
+ def _deep_merge(base: dict, override: dict) -> dict:
70
+ """Recursively merge override into base. Override values win."""
71
+ result = base.copy()
72
+ for key, value in override.items():
73
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
74
+ result[key] = _deep_merge(result[key], value)
75
+ else:
76
+ result[key] = value
77
+ return result
78
+
79
+
80
+ _ENV_REF = re.compile(r"\$\{([A-Za-z_]\w*)(?::-([^}]*))?\}")
81
+
82
+
83
+ def _interp_env(obj):
84
+ """Substitute ${VAR} / ${VAR:-default} in every string leaf of the config.
85
+
86
+ Mirrors steppy.config._interpolate_env so ${ENV_VAR} references (e.g. auth
87
+ credentials) resolve at test runtime from the worker/CI environment. Silent:
88
+ an unset var with no default is left as the literal ${...}.
89
+ """
90
+ if isinstance(obj, dict):
91
+ return {k: _interp_env(v) for k, v in obj.items()}
92
+ if isinstance(obj, list):
93
+ return [_interp_env(v) for v in obj]
94
+ if isinstance(obj, str):
95
+ def _sub(m):
96
+ name, default = m.group(1), m.group(2)
97
+ if name in os.environ:
98
+ return os.environ[name]
99
+ if default is not None:
100
+ return default
101
+ return m.group(0)
102
+ return _ENV_REF.sub(_sub, obj)
103
+ return obj
104
+
105
+
106
+ with open(CONFIG_PATH) as f:
107
+ CONFIG = yaml.safe_load(f)
108
+
109
+ if CREDENTIALS_PATH.exists():
110
+ with open(CREDENTIALS_PATH) as f:
111
+ _creds = yaml.safe_load(f)
112
+ if _creds:
113
+ CONFIG = _deep_merge(CONFIG, _creds)
114
+
115
+ # Resolve ${VAR} references against the environment (after the overlay merge).
116
+ CONFIG = _interp_env(CONFIG)
117
+
118
+ # Per-app URL overrides for parallel mode. ``steppy run --workers N >= 2``
119
+ # runs workers inside the Docker stack network, where the host-facing
120
+ # ``apps.<role>.url`` (e.g. http://localhost:4200) doesn't resolve. The
121
+ # coordinator passes a JSON {role: internal_url} map; we splice it into
122
+ # CONFIG so every downstream reference to ``app["url"]`` keeps working
123
+ # unchanged. Unset (the default in single-worker mode) → no-op.
124
+ _BASE_URL_OVERRIDE = __import__("os").environ.get("STEPPY_BASE_URL_OVERRIDE", "").strip()
125
+ if _BASE_URL_OVERRIDE:
126
+ try:
127
+ for _role, _url in json.loads(_BASE_URL_OVERRIDE).items():
128
+ if _role in CONFIG.get("apps", {}):
129
+ CONFIG["apps"][_role]["url"] = _url
130
+ except (json.JSONDecodeError, TypeError, AttributeError):
131
+ pass # malformed override silently ignored; coordinator validates upstream
132
+
133
+ # Capture root. Resolved from config at runtime — NOT baked at generate time —
134
+ # so editing config.yaml takes effect without re-running ``steppy generate``,
135
+ # and the generated file stays identical for every project. Same mechanism as
136
+ # COVERAGE_RAW_DIR below, and the same value ``steppy guide`` / ``steppy build``
137
+ # default to, so the writer and its readers cannot disagree (FLAWS §4.6).
138
+ # The double fallback is load-bearing: CONFIG is raw YAML, so an absent
139
+ # ``output:`` block is None here and pydantic's default_factory never runs.
140
+ SCREENSHOTS_DIR = Path(__file__).parent.parent.parent / (
141
+ (CONFIG.get("output") or {}).get("screenshots_dir") or "output/screenshots"
142
+ )
143
+
144
+ # Tolerate empty-string values: the parallel coordinator's compose override
145
+ # sets these env vars to '' (rather than unsetting them) for the false case,
146
+ # and ``int('')`` would raise. Treat unset OR empty as "0".
147
+ HEADED = bool(int(__import__("os").environ.get("HEADED") or "0"))
148
+ SCREENSHOTS_ENABLED = bool(int(__import__("os").environ.get("STEPPY_SCREENSHOTS") or "0"))
149
+ SKIP_ASSERTS = bool(int(__import__("os").environ.get("STEPPY_NO_ASSERTS") or "0"))
150
+ # Set by ``steppy run --guide``. Steps gated with ``only-in: test`` are skipped
151
+ # when this is True; ``only-in: guide`` are skipped when False.
152
+ GUIDE_MODE = bool(int(__import__("os").environ.get("STEPPY_GUIDE_MODE") or "0"))
153
+
154
+ # Coverage recording. Set by ``steppy run --coverage``; off by default — the
155
+ # recorder helpers below return/accept ``None`` and plain runs attach no
156
+ # listeners, no init scripts, and no CDP session. The run id groups every
157
+ # record of one run under ``<records_dir>/raw/<run_id>/``; the parallel
158
+ # coordinator already exports STEPPY_RUN_ID, so worker records from all
159
+ # stacks land in the same shared run directory on the output/ bind mount.
160
+ COVERAGE_ENABLED = bool(int(__import__("os").environ.get("STEPPY_COVERAGE") or "0"))
161
+ COVERAGE_RUN_ID = (__import__("os").environ.get("STEPPY_RUN_ID") or "").strip() or time.strftime(
162
+ "%Y%m%dT%H%M%SZ", time.gmtime()
163
+ )
164
+ COVERAGE_RAW_DIR = (
165
+ Path(__file__).parent.parent.parent
166
+ / ((CONFIG.get("coverage") or {}).get("records_dir") or "output/coverage")
167
+ / "raw"
168
+ )
169
+
170
+ # Locale parametrization. When STEPPY_LOCALES is set (typically by
171
+ # ``steppy run --guide``), each role fixture is parametrized over these
172
+ # locales — the configured app i18n.localstorage value is injected before any
173
+ # page script runs and screenshots land under
174
+ # ``output/screenshots/<scenario>/<locale>/``. When unset, fixtures run once
175
+ # with no locale forced and screenshots use the flat per-scenario folder.
176
+ _LOCALES_ENV = __import__("os").environ.get("STEPPY_LOCALES", "").strip()
177
+ LOCALES_FOR_RUN = [l for l in _LOCALES_ENV.split(",") if l] or [None]
178
+ CURRENT_LOCALE: "str | None" = None
179
+
180
+ # Layout parametrization. Set by ``steppy run --guide`` (or ``--layouts``) when
181
+ # ``screenshot.layouts`` is configured. Each test is parametrized over the
182
+ # cartesian product of LOCALES_FOR_RUN × LAYOUTS_FOR_RUN, with every role page
183
+ # it requests bound in lockstep (see pytest_generate_tests). Screenshots land
184
+ # under ``output/screenshots/<scenario>/[<locale>/]<layout>/``. When unset
185
+ # (default), no layout dimension is added and the path stays flat.
186
+ _LAYOUTS_ENV = __import__("os").environ.get("STEPPY_LAYOUTS", "").strip()
187
+ LAYOUTS_FOR_RUN = [l for l in _LAYOUTS_ENV.split(",") if l] or [None]
188
+ CURRENT_LAYOUT: "str | None" = None
189
+
190
+ # Layout name treated as active when no explicit layout is parametrized.
191
+ # Matches the default Playwright viewport (desktop-sized) and lets the
192
+ # ``layouts:`` step filter act as a "this step is for that layout" gate even
193
+ # in test mode.
194
+ DEFAULT_TEST_LAYOUT = "desktop"
195
+
196
+ # Events-plugin opt-in: pull the plugin's hook functions into the conftest
197
+ # namespace so pytest collects them as if they were defined here. Two
198
+ # independent gates, because the plugin has two independent sinks:
199
+ #
200
+ # STEPPY_EMIT_EVENTS=1 stdout ``STEPPY-EVT::`` lines, parsed live by the
201
+ # parallel coordinator (``steppy run --workers N``)
202
+ # STEPPY_EVENTS_DIR=<dir> per-test JSONL, consumed by ``steppy bundle create``
203
+ # (``steppy run --bundle``)
204
+ #
205
+ # A plain single-worker run sets neither and the import never happens, keeping
206
+ # pytest output byte-identical to the historic behaviour.
207
+ #
208
+ # Why direct import and not ``pytest_plugins = [...]`` — pytest 8 hard-fails
209
+ # when ``pytest_plugins`` is declared in a non-rootdir conftest (this conftest
210
+ # lives at ``tests/e2e/conftest.py``). Direct import sidesteps that
211
+ # restriction: pytest just discovers hook names in the conftest module.
212
+ #
213
+ # The imported names must never collide with a hook this conftest defines
214
+ # itself (``pytest_generate_tests``, ``pytest_collection_modifyitems``,
215
+ # ``pytest_runtest_makereport``) — the import would silently shadow it.
216
+ if __import__("os").environ.get("STEPPY_EMIT_EVENTS") == "1" or __import__("os").environ.get("STEPPY_EVENTS_DIR"):
217
+ import sys as _sys
218
+ _sys.path.insert(0, str(Path(__file__).resolve().parent))
219
+ try:
220
+ from _steppy_events import (
221
+ pytest_configure,
222
+ pytest_collection_finish,
223
+ pytest_runtest_protocol,
224
+ pytest_runtest_logstart,
225
+ pytest_runtest_logreport,
226
+ pytest_sessionfinish,
227
+ )
228
+ except ImportError as _evt_err:
229
+ # ``_steppy_events.py`` missing or stale (an older ``steppy generate``
230
+ # output predating a hook this import expects). Emit a warning event so
231
+ # the coordinator can surface it to the user — without the plugin, only
232
+ # synthetic per-scenario test counts reach the live progress UI, and a
233
+ # results bundle's ``tests[]`` will be empty. Re-run ``steppy generate``
234
+ # to restore it.
235
+ print(
236
+ "STEPPY-EVT::warning::events-plugin-missing::"
237
+ + str(_evt_err)
238
+ + " (run `steppy generate` to refresh tests/e2e/)",
239
+ flush=True,
240
+ )
241
+
242
+
243
+ def _step_runs(only_in, layouts):
244
+ """Return False when a step's ``only-in`` / ``layouts`` filters exclude it
245
+ from the current run. Centralises the predicate so generated test files can
246
+ just call ``if _step_runs(...): <body>``.
247
+
248
+ - ``only-in: test`` → skip in guide mode
249
+ - ``only-in: guide`` → skip outside guide mode
250
+ - ``layouts: [...]`` → step runs iff the active layout is in the whitelist.
251
+ The active layout is ``CURRENT_LAYOUT`` when a layout is parametrized,
252
+ otherwise ``DEFAULT_TEST_LAYOUT`` (the default Playwright viewport is
253
+ desktop-sized, so a step gated to ``layouts: [mobile]`` is skipped in
254
+ plain test mode).
255
+ """
256
+ if only_in == "test" and GUIDE_MODE:
257
+ return False
258
+ if only_in == "guide" and not GUIDE_MODE:
259
+ return False
260
+ if layouts:
261
+ active = CURRENT_LAYOUT or DEFAULT_TEST_LAYOUT
262
+ if active not in layouts:
263
+ return False
264
+ return True
265
+
266
+
267
+ # ── Per-step events (STEPPY_STEP_EVENTS) ────────────────────────────────────
268
+ #
269
+ # Every generated step body is bracketed by ``_steppy_mark_step`` /
270
+ # ``_steppy_end_step``. Off, they are one env lookup each. On, they write
271
+ # ``step-start`` / ``step-end`` records into the same per-test JSONL the events
272
+ # plugin already owns, which is what lets a results bundle report WHICH step a
273
+ # test died in (``manifest.tests[].failed_step``) instead of only that it died.
274
+ #
275
+ # The calls are emitted unconditionally by the generator, never gated at
276
+ # template time, so a generated test file does not depend on the environment it
277
+ # was generated in.
278
+
279
+ STEP_EVENTS = os.environ.get("STEPPY_STEP_EVENTS") == "1"
280
+
281
+
282
+ def _steppy_current_nodeid():
283
+ """The running test's pytest node id, or None.
284
+
285
+ ``PYTEST_CURRENT_TEST`` is ``"<nodeid> (call)"`` — the phase suffix is
286
+ appended by pytest, so strip the LAST " (" group rather than splitting from
287
+ the left, since a parametrized id can legitimately contain " (".
288
+ """
289
+ raw = os.environ.get("PYTEST_CURRENT_TEST")
290
+ if not raw:
291
+ return None
292
+ return raw.rsplit(" (", 1)[0] or None
293
+
294
+
295
+ def _steppy_step_record(kind, step_number, **fields):
296
+ """Write one step record. Never raises — a broken sink must not fail a test.
297
+
298
+ The sink is the events plugin's ``_record``, so step records land in the
299
+ same ``<scenario>__<locale>-<layout>.jsonl`` as the test records and inherit
300
+ their scenario/locale/layout attribution for free. It is only importable
301
+ when the events gate above put this directory on ``sys.path``, and it
302
+ early-returns when no events dir is configured — so step events are
303
+ automatically inert outside a ``steppy run --bundle``.
304
+ """
305
+ if not STEP_EVENTS:
306
+ return
307
+ try:
308
+ from _steppy_events import _record
309
+
310
+ _record(kind, _steppy_current_nodeid(), step=step_number, **fields)
311
+ except Exception:
312
+ pass
313
+
314
+
315
+ def _steppy_mark_step(step_number, step_title, action):
316
+ """Mark the start of a step."""
317
+ _steppy_step_record("step-start", step_number, title=step_title, action=action)
318
+
319
+
320
+ def _steppy_end_step(step_number):
321
+ """Mark a step as completed.
322
+
323
+ Deliberately NOT emitted from a ``finally``: a step that raises must leave
324
+ its ``step-start`` unclosed, because that unclosed marker IS the answer to
325
+ "which step failed". A test that dies in fixture teardown closes every step
326
+ it ran and therefore correctly reports no failed step at all.
327
+ """
328
+ _steppy_step_record("step-end", step_number)
329
+
330
+
331
+ # ── Auth helpers ────────────────────────────────────────────────────────────
332
+
333
+ def _get_storage_state(role: str) -> str | None:
334
+ """Return path to cached auth state if valid, else None."""
335
+ state_path = AUTH_DIR / f"{role}.json"
336
+ if not state_path.exists():
337
+ return None
338
+ ttl = CONFIG.get("auth", {}).get("storage_state_ttl", 3600)
339
+ if time.time() - state_path.stat().st_mtime > ttl:
340
+ return None
341
+ return str(state_path)
342
+
343
+
344
+ _DEFAULT_VIEWPORT = {"width": 1280, "height": 720}
345
+
346
+ _FORM_DEFAULTS = {
347
+ "username_selector": "input[name='username']",
348
+ "password_selector": "input[name='password']",
349
+ "submit_selector": "button[data-action-button-primary='true']",
350
+ }
351
+
352
+
353
+ def _form_selectors() -> dict:
354
+ """Merge the login-form selector defaults with the configured overrides.
355
+
356
+ Accepts both the current ``auth.form`` block and the legacy ``auth.auth0``
357
+ key (mode ``auth0`` was renamed ``form`` in FLAWS §6.2; CONFIG here is raw
358
+ YAML, so both spellings can still reach us)."""
359
+ auth = CONFIG.get("auth", {})
360
+ overrides = auth.get("form") or auth.get("auth0") or {}
361
+ return {**_FORM_DEFAULTS, **overrides}
362
+
363
+
364
+ def _get_viewport() -> dict:
365
+ """Return viewport config with sensible defaults."""
366
+ return CONFIG.get("screenshot", {}).get("viewport", _DEFAULT_VIEWPORT)
367
+
368
+
369
+ def _get_viewport_for_layout(layout) -> dict:
370
+ """Pick the viewport for the active layout. Falls back to the default
371
+ viewport when no layout is set or the layout name has no entry in
372
+ ``LAYOUT_VIEWPORTS`` (which would only happen if a CLI ran with a layout
373
+ name that wasn't declared in config — the validator should catch that)."""
374
+ if layout and layout in LAYOUT_VIEWPORTS:
375
+ return LAYOUT_VIEWPORTS[layout]
376
+ return _get_viewport()
377
+
378
+
379
+ def _get_scale_factor() -> int:
380
+ """Default capture devicePixelRatio (used for the no-layout viewport and as
381
+ the fallback for layouts that don't override it)."""
382
+ return CONFIG.get("screenshot", {}).get("device_scale_factor", 1)
383
+
384
+
385
+ def _get_scale_factor_for_layout(layout) -> int:
386
+ """Pick the capture devicePixelRatio for the active layout, mirroring
387
+ ``_get_viewport_for_layout``. Falls back to the default scale factor when no
388
+ layout is set or the layout has no entry in ``LAYOUT_SCALE_FACTORS``."""
389
+ if layout and layout in LAYOUT_SCALE_FACTORS:
390
+ return LAYOUT_SCALE_FACTORS[layout]
391
+ return _get_scale_factor()
392
+
393
+
394
+ def _totp_now(secret: str, digits: int = 6, period: int = 30) -> str:
395
+ """Current TOTP code for a base32 ``secret`` (RFC 6238, HMAC-SHA1)."""
396
+ key = base64.b32decode(secret.strip().replace(" ", "").upper() + "=" * (-len(secret.strip().replace(" ", "")) % 8))
397
+ counter = int(time.time()) // period
398
+ mac = hmac.new(key, counter.to_bytes(8, "big"), hashlib.sha1).digest()
399
+ offset = mac[-1] & 0x0F
400
+ code = (int.from_bytes(mac[offset : offset + 4], "big") & 0x7FFFFFFF) % (10**digits)
401
+ return str(code).zfill(digits)
402
+
403
+
404
+ def _fill_totp(page: Page, form: dict, credentials: dict) -> None:
405
+ """Fill a TOTP/MFA code after the password submit, when configured (FLAWS §6.2).
406
+
407
+ No-op unless both ``auth.form.totp_selector`` and the app credentials'
408
+ ``totp_secret`` are set. Tolerates a missing prompt (trusted device)."""
409
+ totp_selector = form.get("totp_selector")
410
+ totp_secret = credentials.get("totp_secret")
411
+ if not totp_selector or not totp_secret:
412
+ return
413
+ try:
414
+ page.wait_for_selector(totp_selector, timeout=10000)
415
+ except Exception:
416
+ return # no MFA prompt this session — nothing to fill
417
+ page.fill(totp_selector, _totp_now(totp_secret))
418
+ submit = form.get("totp_submit_selector") or form["submit_selector"]
419
+ page.locator(submit).click()
420
+ page.wait_for_load_state("networkidle", timeout=15000)
421
+
422
+
423
+ def fill_login_credentials(page: Page, role: str) -> None:
424
+ """Fill the hosted login form with credentials from config. Call after the
425
+ login page appears.
426
+
427
+ Handles both single-page and two-step (identifier-first) flows. Works
428
+ against any IdP (Auth0, Keycloak, …) via the ``auth.form`` selectors.
429
+ """
430
+ app = CONFIG["apps"][role]
431
+ form = _form_selectors()
432
+
433
+ # Step 1: Fill username / email
434
+ page.wait_for_selector(form["username_selector"], timeout=15000)
435
+ page.fill(form["username_selector"], app["auth"]["credentials"]["username"])
436
+
437
+ # Check if password field is already visible (single-page flow)
438
+ pwd_visible = page.locator(form["password_selector"]).is_visible()
439
+
440
+ if pwd_visible:
441
+ # Single-page: fill password and submit once
442
+ page.fill(form["password_selector"], app["auth"]["credentials"]["password"])
443
+ page.locator(form["submit_selector"]).click()
444
+ page.wait_for_load_state("networkidle", timeout=15000)
445
+ else:
446
+ # Two-step (identifier-first): submit username, wait for password field
447
+ page.locator(form["submit_selector"]).click()
448
+ page.wait_for_selector(form["password_selector"], timeout=15000)
449
+ page.fill(form["password_selector"], app["auth"]["credentials"]["password"])
450
+ page.locator(form["submit_selector"]).click()
451
+ page.wait_for_load_state("networkidle", timeout=15000)
452
+
453
+ # Optional TOTP/MFA step (no-op unless configured)
454
+ _fill_totp(page, form, app["auth"]["credentials"])
455
+
456
+ # Handle optional consent screen
457
+ consent = form.get("consent_selector")
458
+ if consent:
459
+ try:
460
+ page.wait_for_selector(consent, timeout=3000)
461
+ page.click(consent)
462
+ except Exception:
463
+ pass
464
+
465
+
466
+ # Back-compat alias (the action/helper was historically named ``auth0``).
467
+ fill_auth0_credentials = fill_login_credentials
468
+
469
+
470
+ def _new_role_context(browser: Browser, role: str, **extra) -> BrowserContext:
471
+ """Create a browser context configured for ``role``.
472
+
473
+ Single definition of what a Steppy context IS: the layout's viewport and
474
+ capture density, the app's base URL, the browser locale/timezone, video
475
+ recording when enabled, and the insecure-origin polyfill.
476
+
477
+ Every auth mode builds its context here. Previously each of the six call
478
+ sites spelled the kwargs out, so every browser-capability feature
479
+ (device_scale_factor, video, locale/timezone, the polyfill) had to be
480
+ threaded through all six — and missing one silently degraded a single auth
481
+ mode with no test failure.
482
+
483
+ ``extra`` carries per-mode kwargs, e.g. ``storage_state=``.
484
+ """
485
+ app = CONFIG["apps"][role]
486
+ context = browser.new_context(
487
+ viewport=_get_viewport_for_layout(CURRENT_LAYOUT),
488
+ device_scale_factor=_get_scale_factor_for_layout(CURRENT_LAYOUT),
489
+ base_url=app["url"],
490
+ locale=_resolve_browser_locale(role),
491
+ timezone_id=_resolve_browser_timezone(role),
492
+ **_video_context_kwargs(),
493
+ **extra,
494
+ )
495
+ _apply_insecure_origin_polyfill(context)
496
+ return context
497
+
498
+
499
+ def _authenticate_auth0(browser: Browser, role: str) -> BrowserContext:
500
+ """Perform Auth0 login and cache the storage state."""
501
+ app = CONFIG["apps"][role]
502
+
503
+ context = _new_role_context(browser, role)
504
+ page = context.new_page()
505
+ page.goto("/")
506
+
507
+ fill_login_credentials(page, role)
508
+
509
+ # Wait for redirect back to app
510
+ page.wait_for_url(f"{app['url']}/**", timeout=15000)
511
+ page.wait_for_load_state("networkidle")
512
+
513
+ # Cache state
514
+ AUTH_DIR.mkdir(parents=True, exist_ok=True)
515
+ context.storage_state(path=str(AUTH_DIR / f"{role}.json"))
516
+ page.close()
517
+ return context
518
+
519
+
520
+ def get_authenticated_context(browser: Browser, role: str) -> BrowserContext:
521
+ """Get an authenticated browser context for the given role."""
522
+ app = CONFIG["apps"][role]
523
+ mode = app["auth"]["mode"]
524
+
525
+ if mode in ("form", "auth0"):
526
+ state = _get_storage_state(role)
527
+ if state:
528
+ return _new_role_context(browser, role, storage_state=state)
529
+ return _authenticate_auth0(browser, role)
530
+
531
+ elif mode == "storage-state":
532
+ return _new_role_context(browser, role, storage_state=app["auth"]["storage_state_path"])
533
+
534
+ elif mode == "bypass":
535
+ context = _new_role_context(browser, role)
536
+ bypass = app["auth"].get("bypass", {})
537
+ if bypass.get("cookie"):
538
+ context.add_cookies([bypass["cookie"]])
539
+ return context
540
+
541
+ elif mode == "token":
542
+ return _authenticate_token(browser, role)
543
+
544
+ else:
545
+ raise ValueError(f"Unknown auth mode: {mode}")
546
+
547
+
548
+ def _authenticate_token(browser: Browser, role: str) -> BrowserContext:
549
+ """Fetch a token from the configured endpoint and inject it (FLAWS §6.2).
550
+
551
+ Mirrors ``steppy.auth.authenticate_token`` but uses the stdlib
552
+ (``urllib``/``json``) so generated projects need no extra runtime deps.
553
+ ``inject_as``: ``localStorage`` | ``cookie`` | ``header``.
554
+ """
555
+ import urllib.request
556
+ from urllib.parse import urlparse
557
+
558
+ app = CONFIG["apps"][role]
559
+ token_cfg = app["auth"].get("token")
560
+ if not token_cfg:
561
+ raise ValueError(f"Token auth for role '{role}' requires apps.{role}.auth.token")
562
+
563
+ body = token_cfg.get("body")
564
+ data = json.dumps(body).encode("utf-8") if body is not None else None
565
+ req = urllib.request.Request(
566
+ token_cfg["endpoint"],
567
+ data=data,
568
+ method=token_cfg.get("method", "POST"),
569
+ headers={"Content-Type": "application/json"} if data is not None else {},
570
+ )
571
+ with urllib.request.urlopen(req, timeout=10) as resp:
572
+ payload = json.loads(resp.read().decode("utf-8"))
573
+ token = payload.get("token") or payload.get("access_token") or payload
574
+ if not isinstance(token, str):
575
+ raise ValueError(f"Token endpoint returned invalid data for role '{role}'")
576
+
577
+ inject_as = token_cfg["inject_as"]
578
+ key = token_cfg["key"]
579
+
580
+ context = _new_role_context(browser, role)
581
+
582
+ if inject_as == "header":
583
+ # Default request header on the context; `key` is the header name.
584
+ context.set_extra_http_headers({key: token})
585
+ elif inject_as == "cookie":
586
+ domain = urlparse(app["url"]).hostname
587
+ if not domain:
588
+ raise ValueError(f"Cannot derive a cookie domain from apps.{role}.url")
589
+ context.add_cookies([{"name": key, "value": token, "domain": domain, "path": "/"}])
590
+ elif inject_as == "localStorage":
591
+ page = context.new_page()
592
+ page.goto(app["url"])
593
+ # Pass key/token as arguments (not interpolated) — the token is
594
+ # network-sourced and could contain a quote (mirrors FLAWS §3.3).
595
+ page.evaluate("([k, v]) => localStorage.setItem(k, v)", [key, token])
596
+ page.close()
597
+ else:
598
+ raise ValueError(f"Unknown token inject_as: {inject_as}")
599
+
600
+ return context
601
+
602
+
603
+ # ── Locale init script ──────────────────────────────────────────────────────
604
+
605
+ # Warned-about (role, locale) pairs, so a 40-scenario run reports each gap once
606
+ # rather than once per test.
607
+ _LOCALE_NOT_SWITCHABLE: set = set()
608
+
609
+
610
+ def _base_language(tag: str) -> str:
611
+ """Bare language subtag of ``tag``, lowercased (``fr-BE`` -> ``fr``).
612
+
613
+ **Byte-mirrored** from ``base_language`` in ``src/steppy/locales.py`` --
614
+ generated tests are standalone and cannot import steppy, so the body lives
615
+ in both places. Guard: ``tests/test_conftest_locale_mirror.py``. Apply any
616
+ change to both.
617
+ """
618
+ return tag.replace("_", "-").split("-", 1)[0].lower()
619
+
620
+
621
+ def _warn_locale_not_switchable(role: str, locale: str, declared) -> None:
622
+ """Report that the app was NOT switched into ``locale``.
623
+
624
+ Skipping silently is the worst option available here, and it is what this
625
+ used to do: the run still captures a full set of screenshots and still files
626
+ them under the locale's directory, so ``steppy build`` publishes one
627
+ language's screenshots on another language's page and nothing at any stage
628
+ reports a problem. A warning lands in pytest's summary without ``-s``.
629
+ """
630
+ marker = (role, locale)
631
+ if marker in _LOCALE_NOT_SWITCHABLE:
632
+ return
633
+ _LOCALE_NOT_SWITCHABLE.add(marker)
634
+ warnings.warn(
635
+ f"apps.{role}.i18n.localstorage.values has no entry for locale {locale!r} "
636
+ f"(declared: {', '.join(declared) or 'none'}), so the app was not switched "
637
+ f"into it. Screenshots for this locale will show the app's default language "
638
+ f"while being filed under {locale!r}/. Add the entry to config.yaml.",
639
+ stacklevel=2,
640
+ )
641
+
642
+
643
+ def _apply_locale_init_script(context: BrowserContext, role: str, locale) -> None:
644
+ """If the app declares ``i18n.localstorage`` and a locale is active, inject
645
+ a ``localStorage.setItem`` init script so the value is in place before any
646
+ page script runs. No-op when locale is None or the app has no i18n block.
647
+
648
+ A regional locale falls back to its bare language (``fr-BE`` -> ``fr``), the
649
+ same rung ``GuideText.get`` and ``pick_locale`` use, so a project can add
650
+ ``fr-BE`` without rewriting its i18n map. When neither key exists the run
651
+ warns rather than skipping quietly -- see ``_warn_locale_not_switchable``.
652
+ """
653
+ if not locale:
654
+ return
655
+ app = CONFIG.get("apps", {}).get(role, {})
656
+ i18n = app.get("i18n") or {}
657
+ ls = i18n.get("localstorage") or {}
658
+ key = ls.get("key")
659
+ values = ls.get("values") or {}
660
+ if not key:
661
+ return
662
+ if locale in values:
663
+ value = values[locale]
664
+ else:
665
+ bare = _base_language(locale)
666
+ if bare in values:
667
+ value = values[bare]
668
+ else:
669
+ _warn_locale_not_switchable(role, locale, sorted(values))
670
+ return
671
+ script = (
672
+ "try { window.localStorage.setItem("
673
+ + json.dumps(key) + ", " + json.dumps(value)
674
+ + "); } catch (e) {}"
675
+ )
676
+ context.add_init_script(script)
677
+
678
+
679
+ # ── Fixtures ────────────────────────────────────────────────────────────────
680
+
681
+ _INSECURE_ORIGIN_POLYFILL = r"""
682
+ // Steppy parallel-mode polyfill: make a Docker-internal HTTP origin look
683
+ // like a secure context to the Auth0 SPA SDK (and any other library that
684
+ // gates on Web Crypto).
685
+ //
686
+ // Background: browsers only treat HTTPS / localhost / 127.0.0.1 / *.localhost
687
+ // as secure contexts. ``http://editor-frontend`` reached through Docker DNS
688
+ // is not. The standard escape hatch — Chromium's
689
+ // ``--unsafely-treat-insecure-origin-as-secure`` flag — is silently
690
+ // ignored by Playwright's headless Chromium because Playwright doesn't
691
+ // create the ``Local State`` file Chromium reads for that flag (see
692
+ // microsoft/playwright#22944 and #27768). The flag fix is therefore not
693
+ // available to us; we patch the runtime instead.
694
+ //
695
+ // What we polyfill:
696
+ // * ``window.isSecureContext`` → true (some libs check this directly)
697
+ // * ``window.crypto.subtle.digest('SHA-256', data)`` (the only Web
698
+ // Crypto operation @auth0/auth0-spa-js performs — PKCE challenge
699
+ // generation. Token-signature verification is server-side; the SDK
700
+ // just decodes the JWT to read claims and trusts the back-channel
701
+ // token-exchange response.)
702
+ //
703
+ // Pure-JS FIPS 180-2 SHA-256. Returns a Promise<ArrayBuffer> so the
704
+ // signature matches the real Web Crypto API; auth0-spa-js does
705
+ // ``await crypto.subtle.digest(...)``.
706
+ (() => {
707
+ try {
708
+ Object.defineProperty(window, 'isSecureContext', {
709
+ configurable: true, get: () => true,
710
+ });
711
+ } catch (e) { /* readonly in some builds; not fatal */ }
712
+
713
+ const K = new Uint32Array([
714
+ 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
715
+ 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
716
+ 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
717
+ 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
718
+ 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
719
+ 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
720
+ 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
721
+ 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
722
+ ]);
723
+ const rotr = (x, n) => ((x >>> n) | (x << (32 - n))) >>> 0;
724
+
725
+ function sha256(bytes) {
726
+ const len = bytes.length;
727
+ const padded = new Uint8Array(((len + 9 + 63) >> 6) << 6);
728
+ padded.set(bytes);
729
+ padded[len] = 0x80;
730
+ const dv = new DataView(padded.buffer);
731
+ // length in bits (big-endian, 64-bit). Length fits in 32 bits for our use.
732
+ dv.setUint32(padded.length - 4, len * 8, false);
733
+
734
+ const H = new Uint32Array([
735
+ 0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,
736
+ 0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19,
737
+ ]);
738
+ const W = new Uint32Array(64);
739
+ for (let off = 0; off < padded.length; off += 64) {
740
+ for (let t = 0; t < 16; t++) W[t] = dv.getUint32(off + t * 4, false);
741
+ for (let t = 16; t < 64; t++) {
742
+ const s0 = rotr(W[t-15], 7) ^ rotr(W[t-15], 18) ^ (W[t-15] >>> 3);
743
+ const s1 = rotr(W[t-2], 17) ^ rotr(W[t-2], 19) ^ (W[t-2] >>> 10);
744
+ W[t] = (W[t-16] + s0 + W[t-7] + s1) >>> 0;
745
+ }
746
+ let a=H[0],b=H[1],c=H[2],d=H[3],e=H[4],f=H[5],g=H[6],h=H[7];
747
+ for (let t = 0; t < 64; t++) {
748
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
749
+ const ch = (e & f) ^ ((~e) & g);
750
+ const T1 = (h + S1 + ch + K[t] + W[t]) >>> 0;
751
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
752
+ const maj = (a & b) ^ (a & c) ^ (b & c);
753
+ const T2 = (S0 + maj) >>> 0;
754
+ h = g; g = f; f = e; e = (d + T1) >>> 0;
755
+ d = c; c = b; b = a; a = (T1 + T2) >>> 0;
756
+ }
757
+ H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0;
758
+ H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
759
+ H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0;
760
+ H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
761
+ }
762
+ const out = new Uint8Array(32);
763
+ const outDv = new DataView(out.buffer);
764
+ for (let i = 0; i < 8; i++) outDv.setUint32(i * 4, H[i], false);
765
+ return out.buffer;
766
+ }
767
+
768
+ function toBytes(data) {
769
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
770
+ if (ArrayBuffer.isView(data)) return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
771
+ throw new TypeError('Polyfill digest: expected BufferSource');
772
+ }
773
+
774
+ const subtle = (window.crypto && window.crypto.subtle) || {};
775
+ const realDigest = subtle.digest && subtle.digest.bind(subtle);
776
+ const polyfillDigest = (algorithm, data) => {
777
+ const name = typeof algorithm === 'string' ? algorithm : (algorithm && algorithm.name);
778
+ if (name === 'SHA-256') {
779
+ try { return Promise.resolve(sha256(toBytes(data))); }
780
+ catch (e) { return Promise.reject(e); }
781
+ }
782
+ if (realDigest) return realDigest(algorithm, data);
783
+ return Promise.reject(new Error('Polyfill: unsupported algorithm ' + name));
784
+ };
785
+
786
+ try {
787
+ if (!window.crypto) window.crypto = {};
788
+ if (!window.crypto.subtle) {
789
+ Object.defineProperty(window.crypto, 'subtle', {
790
+ configurable: true, value: { digest: polyfillDigest },
791
+ });
792
+ } else if (!window.crypto.subtle.digest) {
793
+ window.crypto.subtle.digest = polyfillDigest;
794
+ }
795
+ } catch (e) { /* best effort */ }
796
+ })();
797
+ """
798
+
799
+
800
+ def _needs_insecure_origin_polyfill() -> bool:
801
+ """True when the worker is hitting at least one Docker-internal HTTP
802
+ origin. In single-worker mode the override is unset and the host's
803
+ ``http://localhost:<port>`` URLs are already secure contexts — no
804
+ polyfill needed."""
805
+ if not _BASE_URL_OVERRIDE:
806
+ return False
807
+ try:
808
+ for url in json.loads(_BASE_URL_OVERRIDE).values():
809
+ if isinstance(url, str) and url.startswith("http://"):
810
+ return True
811
+ except (json.JSONDecodeError, TypeError, AttributeError):
812
+ return False
813
+ return False
814
+
815
+
816
+ def _apply_insecure_origin_polyfill(context: BrowserContext) -> None:
817
+ """Inject the secure-context / crypto.subtle polyfill into every page
818
+ of ``context`` (parallel mode only — no-op otherwise)."""
819
+ if _needs_insecure_origin_polyfill():
820
+ context.add_init_script(_INSECURE_ORIGIN_POLYFILL)
821
+
822
+
823
+ @pytest.fixture(scope="session")
824
+ def browser_instance(playwright):
825
+ """Single browser instance shared across all tests."""
826
+ browser = playwright.chromium.launch(headless=not HEADED)
827
+ yield browser
828
+ browser.close()
829
+
830
+
831
+ def _locale_id(value):
832
+ """Pytest param id: locale code, or 'default' when no locale is forced."""
833
+ return value or "default"
834
+
835
+
836
+ def _locale_layout_params():
837
+ """Cartesian product of LOCALES_FOR_RUN × LAYOUTS_FOR_RUN as a list of
838
+ ``(locale, layout)`` tuples. Used as the ``params=`` for role fixtures."""
839
+ return [(locale, layout) for locale in LOCALES_FOR_RUN for layout in LAYOUTS_FOR_RUN]
840
+
841
+
842
+ def _locale_layout_id(value):
843
+ """Pytest param id: ``<locale>-<layout>``, with ``default`` substituted when
844
+ a dimension is unset. Renders as e.g. ``fr-mobile``, ``default-default``."""
845
+ locale, layout = value
846
+ return f"{locale or 'default'}-{layout or 'default'}"
847
+
848
+
849
+ # Role page-fixture names, baked from the project's roles. Used by the
850
+ # scenario-layout parametrization hook to spot which fixtures to parametrize.
851
+ _PAGE_FIXTURES = {
852
+ {%- for role in roles %}
853
+ "{{ role }}_page", "{{ role }}_page_noauth",
854
+ {%- endfor %}
855
+ }
856
+
857
+
858
+ def _effective_run_layouts(scenario_layouts):
859
+ """Resolve the layout dimension for one scenario module.
860
+
861
+ ``scenario_layouts`` is the module's baked ``SCENARIO_LAYOUTS`` (``None``
862
+ when the scenario declares none). Semantics:
863
+ * no declaration -> ``LAYOUTS_FOR_RUN`` unchanged (byte-identical default).
864
+ * declaration + no run-level ``--layouts`` (``LAYOUTS_FOR_RUN == [None]``)
865
+ -> the declaration is authoritative (a plain ``steppy run`` parametrizes
866
+ the whole flow over its declared layouts).
867
+ * declaration + run-level ``--layouts`` -> intersection in run-selected
868
+ order (``--layouts``/``--guide`` filters the declared set).
869
+ An empty result means the scenario's declaration is disjoint from the run
870
+ selection; ``pytest_collection_modifyitems`` deselects those scenarios."""
871
+ if not scenario_layouts:
872
+ return LAYOUTS_FOR_RUN
873
+ if LAYOUTS_FOR_RUN == [None]:
874
+ return list(scenario_layouts)
875
+ return [l for l in LAYOUTS_FOR_RUN if l in scenario_layouts]
876
+
877
+
878
+ def pytest_generate_tests(metafunc):
879
+ """Own the (locale x layout) parametrization of the role page fixtures so a
880
+ per-scenario ``SCENARIO_LAYOUTS`` can narrow (or, on a plain run, drive) the
881
+ layout dimension. Modules with no declaration yield exactly
882
+ ``_locale_layout_params()`` -> collection identical to the pre-feature run.
883
+
884
+ Every role page a test requests is bound under ONE ``parametrize`` call, so
885
+ the roles move in lockstep: a two-role scenario runs once per (locale,
886
+ layout), not once per pairing of each role's (locale, layout). Separate
887
+ calls on independent argnames multiply, and the mixed items were not just
888
+ redundant but wrong — ``CURRENT_LOCALE``/``CURRENT_LAYOUT`` are one global
889
+ every role fixture writes, so the last one set up decided the screenshot
890
+ directory for pages that were built at the other role's viewport."""
891
+ page_fixtures = [f for f in metafunc.fixturenames if f in _PAGE_FIXTURES]
892
+ if not page_fixtures:
893
+ return
894
+ scenario_layouts = getattr(metafunc.module, "SCENARIO_LAYOUTS", None)
895
+ if not scenario_layouts:
896
+ params = _locale_layout_params()
897
+ else:
898
+ layouts = _effective_run_layouts(scenario_layouts)
899
+ if not layouts:
900
+ # Declaration disjoint from the run selection: leave the fixtures
901
+ # unparametrized (an empty parametrize + id callable would crash on
902
+ # pytest's empty-set sentinel) — pytest_collection_modifyitems
903
+ # deselects the item before any fixture runs.
904
+ return
905
+ params = [(locale, layout) for locale in LOCALES_FOR_RUN for layout in layouts]
906
+ if len(page_fixtures) == 1:
907
+ # A string argname makes pytest wrap each value itself, so request.param
908
+ # stays the bare (locale, layout) tuple and single-role ids are unchanged.
909
+ metafunc.parametrize(page_fixtures[0], params, ids=_locale_layout_id, indirect=True)
910
+ return
911
+ # An id callable would be applied to each argname's value and the results
912
+ # hyphen-joined (``fr-mobile-fr-mobile``), hence one precomputed id per set.
913
+ metafunc.parametrize(
914
+ page_fixtures,
915
+ [tuple(p for _ in page_fixtures) for p in params],
916
+ ids=[_locale_layout_id(p) for p in params],
917
+ indirect=True,
918
+ )
919
+
920
+
921
+ def pytest_collection_modifyitems(config, items):
922
+ """Silently deselect scenarios whose declared layouts are disjoint from the
923
+ run's selected layouts (e.g. ``--layouts mobile`` against a desktop-only
924
+ scenario); otherwise they'd surface as a spurious 'empty parameter set' skip.
925
+ No-op when no run-level layout selection is active (nothing can be disjoint)."""
926
+ if LAYOUTS_FOR_RUN == [None]:
927
+ return
928
+ kept, removed = [], []
929
+ for item in items:
930
+ scenario_layouts = getattr(item.module, "SCENARIO_LAYOUTS", None)
931
+ if scenario_layouts and not _effective_run_layouts(scenario_layouts):
932
+ removed.append(item)
933
+ else:
934
+ kept.append(item)
935
+ if removed:
936
+ config.hook.pytest_deselected(items=removed)
937
+ items[:] = kept
938
+
939
+
940
+ def _resolve_browser_locale(role: str) -> "str | None":
941
+ """Pick the Playwright BrowserContext locale for ``role``.
942
+
943
+ ``--guide`` runs pass ``STEPPY_LOCALES`` and parametrize the role fixture
944
+ over those values, setting ``CURRENT_LOCALE`` per parametrization — that
945
+ wins, so internationalized captures render dates / numbers in the locale
946
+ they're being captured for. Otherwise the static ``apps.<role>.locale``
947
+ from config.yaml is used (makes test runs deterministic regardless of host
948
+ OS or container locale). Returns ``None`` when neither is set — Playwright
949
+ then uses the browser default (historic behaviour, backwards-compatible).
950
+ """
951
+ if CURRENT_LOCALE:
952
+ return CURRENT_LOCALE
953
+ return CONFIG.get("apps", {}).get(role, {}).get("locale")
954
+
955
+
956
+ def _resolve_browser_timezone(role: str) -> "str | None":
957
+ """Pick the Playwright BrowserContext ``timezone_id`` for ``role`` from
958
+ ``apps.<role>.timezone`` in config.yaml. Returns ``None`` when unset."""
959
+ return CONFIG.get("apps", {}).get(role, {}).get("timezone")
960
+
961
+
962
+ # ── Playwright trace (FLAWS §6.1) ─────────────────────────────────────────────
963
+
964
+ def _maybe_start_trace(context: BrowserContext) -> None:
965
+ """Start tracing on the context when trace mode is enabled (no-op if off)."""
966
+ if not TRACE_MODE:
967
+ return
968
+ try:
969
+ context.tracing.start(screenshots=True, snapshots=True, sources=True)
970
+ except Exception:
971
+ pass # tracing is a debug aid; never let it break the test
972
+
973
+
974
+ def _maybe_stop_trace(context: BrowserContext, request) -> None:
975
+ """Stop tracing and, for ``on`` (always) or ``on-failure`` (only failures),
976
+ save ``output/traces/<nodeid>.zip``. ``_steppy_call_failed`` is stamped by
977
+ ``pytest_runtest_makereport`` before fixture teardown."""
978
+ if not TRACE_MODE:
979
+ return
980
+ failed = getattr(request.node, "_steppy_call_failed", False)
981
+ keep = TRACE_MODE == "on" or (TRACE_MODE == "on-failure" and failed)
982
+ try:
983
+ if keep:
984
+ TRACES_DIR.mkdir(parents=True, exist_ok=True)
985
+ path = TRACES_DIR / f"{_sanitise_nodeid(request.node.nodeid)}.zip"
986
+ context.tracing.stop(path=str(path))
987
+ print(f"\n[steppy] trace: {path}", flush=True)
988
+ else:
989
+ context.tracing.stop()
990
+ except Exception:
991
+ pass
992
+
993
+
994
+ # ── Playwright video (FLAWS §6.1) ─────────────────────────────────────────────
995
+
996
+ def _video_context_kwargs() -> dict:
997
+ """`record_video_dir=` kwargs for `new_context` when video is on (else {}).
998
+
999
+ Unlike tracing (started on the returned context), video must be requested at
1000
+ context-construction time, so this is spliced into every `new_context(...)`.
1001
+ When off it returns {} → the call is byte-identical to the pre-feature form."""
1002
+ if not VIDEO_MODE:
1003
+ return {}
1004
+ VIDEOS_DIR.mkdir(parents=True, exist_ok=True)
1005
+ return {"record_video_dir": str(VIDEOS_DIR)}
1006
+
1007
+
1008
+ def _maybe_save_video(video, request) -> None:
1009
+ """Keep (``on`` / failed ``on-failure``) or drop the recorded video.
1010
+
1011
+ ``video`` is the ``page.video`` handle captured before the page/context were
1012
+ closed; Playwright finalizes the file on context close, so this runs after."""
1013
+ if not video:
1014
+ return
1015
+ failed = getattr(request.node, "_steppy_call_failed", False)
1016
+ keep = VIDEO_MODE == "on" or (VIDEO_MODE == "on-failure" and failed)
1017
+ try:
1018
+ if keep:
1019
+ path = VIDEOS_DIR / f"{_sanitise_nodeid(request.node.nodeid)}.webm"
1020
+ video.save_as(str(path))
1021
+ print(f"\n[steppy] video: {path}", flush=True)
1022
+ video.delete()
1023
+ except Exception:
1024
+ pass
1025
+
1026
+
1027
+ {% for role in roles %}
1028
+
1029
+ @pytest.fixture
1030
+ def {{ role }}_page(request, browser_instance) -> Page:
1031
+ """Authenticated {{ role }} browser page. Parametrized (indirect, via
1032
+ ``pytest_generate_tests``) over the cartesian product of STEPPY_LOCALES and
1033
+ the scenario's effective layouts, in lockstep with any other role page the
1034
+ test requests; otherwise yields a single un-localed, default-viewport page."""
1035
+ global CURRENT_LOCALE, CURRENT_LAYOUT
1036
+ locale, layout = request.param
1037
+ CURRENT_LOCALE = locale
1038
+ CURRENT_LAYOUT = layout
1039
+ ctx = get_authenticated_context(browser_instance, "{{ role }}")
1040
+ _maybe_start_trace(ctx)
1041
+ _apply_locale_init_script(ctx, "{{ role }}", locale)
1042
+ page = ctx.new_page()
1043
+ _attach_page_diagnostics(page)
1044
+ _cov = _coverage_start(ctx, page, "{{ role }}", noauth=False)
1045
+ yield page
1046
+ _coverage_stop_and_write(_cov, request)
1047
+ _maybe_stop_trace(ctx, request)
1048
+ _video = page.video if VIDEO_MODE else None
1049
+ page.close()
1050
+ ctx.close()
1051
+ _maybe_save_video(_video, request)
1052
+ CURRENT_LOCALE = None
1053
+ CURRENT_LAYOUT = None
1054
+
1055
+
1056
+ @pytest.fixture
1057
+ def {{ role }}_page_noauth(request, browser_instance) -> Page:
1058
+ """Unauthenticated {{ role }} browser page (for login-flow scenarios).
1059
+ Parametrized indirect via ``pytest_generate_tests`` (see ``{{ role }}_page``)."""
1060
+ global CURRENT_LOCALE, CURRENT_LAYOUT
1061
+ locale, layout = request.param
1062
+ CURRENT_LOCALE = locale
1063
+ CURRENT_LAYOUT = layout
1064
+ ctx = _new_role_context(browser_instance, "{{ role }}")
1065
+ _maybe_start_trace(ctx)
1066
+ _apply_locale_init_script(ctx, "{{ role }}", locale)
1067
+ page = ctx.new_page()
1068
+ _attach_page_diagnostics(page)
1069
+ _cov = _coverage_start(ctx, page, "{{ role }}", noauth=True)
1070
+ yield page
1071
+ _coverage_stop_and_write(_cov, request)
1072
+ _maybe_stop_trace(ctx, request)
1073
+ _video = page.video if VIDEO_MODE else None
1074
+ page.close()
1075
+ ctx.close()
1076
+ _maybe_save_video(_video, request)
1077
+ CURRENT_LOCALE = None
1078
+ CURRENT_LAYOUT = None
1079
+ {% endfor %}
1080
+
1081
+
1082
+ # ── Failure debugging ───────────────────────────────────────────────────────
1083
+
1084
+ def _sanitise_nodeid(nodeid: str) -> str:
1085
+ """Make a pytest nodeid filesystem-safe for use as a screenshot filename."""
1086
+ return (
1087
+ nodeid
1088
+ .replace("/", "_")
1089
+ .replace("\\", "_")
1090
+ .replace("::", "__")
1091
+ .replace("[", "(")
1092
+ .replace("]", ")")
1093
+ )
1094
+
1095
+
1096
+ def _attach_page_diagnostics(page: Page) -> None:
1097
+ """Attach passive listeners to capture console messages, JS errors, and
1098
+ failed network requests for later inspection by the failure hook.
1099
+
1100
+ Critical for headless parallel-mode runs where the browser is inside a
1101
+ worker container — without these, a ``Page.click: Timeout … waiting for
1102
+ locator(.login-cta)`` failure gives you no hint that the SPA never
1103
+ bootstrapped because, e.g., a script tag 404'd or the Auth0 SDK threw
1104
+ on a misconfigured redirect URI.
1105
+
1106
+ The listeners append to lists stored on the page object; the failure
1107
+ hook reads them via ``getattr``. Setting attributes on a Playwright
1108
+ Page is fine — it's a regular Python object with no __slots__.
1109
+ """
1110
+ page._steppy_console: list[str] = [] # type: ignore[attr-defined]
1111
+ page._steppy_errors: list[str] = [] # type: ignore[attr-defined]
1112
+ page._steppy_failed_requests: list[str] = [] # type: ignore[attr-defined]
1113
+
1114
+ def _on_console(msg):
1115
+ try:
1116
+ page._steppy_console.append(f"[{msg.type}] {msg.text}") # type: ignore[attr-defined]
1117
+ except Exception:
1118
+ pass
1119
+
1120
+ def _on_pageerror(exc):
1121
+ try:
1122
+ page._steppy_errors.append(str(exc)) # type: ignore[attr-defined]
1123
+ except Exception:
1124
+ pass
1125
+
1126
+ def _on_requestfailed(req):
1127
+ try:
1128
+ page._steppy_failed_requests.append( # type: ignore[attr-defined]
1129
+ f"{req.method} {req.url} → {req.failure}"
1130
+ )
1131
+ except Exception:
1132
+ pass
1133
+
1134
+ page.on("console", _on_console)
1135
+ page.on("pageerror", _on_pageerror)
1136
+ page.on("requestfailed", _on_requestfailed)
1137
+
1138
+
1139
+ def _dump_page_diagnostics(page: Page, prefix: str = " ") -> None:
1140
+ """Print the diagnostics captured by ``_attach_page_diagnostics`` for one
1141
+ page. Truncates each section so a chatty SPA doesn't drown the test log."""
1142
+ console = getattr(page, "_steppy_console", None)
1143
+ errors = getattr(page, "_steppy_errors", None)
1144
+ failed = getattr(page, "_steppy_failed_requests", None)
1145
+ if errors:
1146
+ print(f"{prefix}JS errors ({len(errors)}):", flush=True)
1147
+ for line in errors[-10:]:
1148
+ print(f"{prefix} {line}", flush=True)
1149
+ if failed:
1150
+ print(f"{prefix}Failed requests ({len(failed)}):", flush=True)
1151
+ for line in failed[-10:]:
1152
+ print(f"{prefix} {line}", flush=True)
1153
+ if console:
1154
+ recent_problems = [m for m in console if m.startswith(("[error]", "[warning]"))]
1155
+ sample = recent_problems[-10:] if recent_problems else console[-5:]
1156
+ print(f"{prefix}Console (showing {len(sample)} of {len(console)}):", flush=True)
1157
+ for line in sample:
1158
+ print(f"{prefix} {line}", flush=True)
1159
+
1160
+
1161
+ # ── Coverage recorder (STEPPY_COVERAGE=1) ───────────────────────────────────
1162
+ # Captures three web-standard signals per page fixture — route trail, network
1163
+ # requests, V8 JS coverage — and writes one raw record JSON per fixture
1164
+ # instance to ``<records_dir>/raw/<run_id>/<scenario>/``. Raw and lossless on
1165
+ # purpose: all interpretation (route normalization, API classification,
1166
+ # source-map decoding) happens steppy-side at merge (`steppy coverage`), so
1167
+ # rules can evolve without recapturing. Framework-agnostic: URL, History API,
1168
+ # CDP Profiler, and request events only.
1169
+
1170
+ _COVERAGE_HISTORY_HOOK = """
1171
+ (() => {
1172
+ if (window.__steppyRouteHook) return;
1173
+ window.__steppyRouteHook = true;
1174
+ var emit = function (kind) {
1175
+ try { window.__steppy_route_event({ u: String(location.href), k: kind, t: Date.now() }); }
1176
+ catch (e) {}
1177
+ };
1178
+ var wrap = function (fn, kind) {
1179
+ return function () { var r = fn.apply(this, arguments); emit(kind); return r; };
1180
+ };
1181
+ try { history.pushState = wrap(history.pushState, "push"); } catch (e) {}
1182
+ try { history.replaceState = wrap(history.replaceState, "replace"); } catch (e) {}
1183
+ window.addEventListener("popstate", function () { emit("pop"); });
1184
+ window.addEventListener("hashchange", function () { emit("hash"); });
1185
+ emit("init");
1186
+ })();
1187
+ """
1188
+
1189
+ _COVERAGE_MAX_REQUESTS = 2000
1190
+
1191
+
1192
+ def _coverage_start(context: BrowserContext, page: Page, role: str, noauth: bool):
1193
+ """Begin coverage recording for one page fixture.
1194
+
1195
+ Returns the recorder state dict, or ``None`` when ``STEPPY_COVERAGE`` is
1196
+ off (``_coverage_stop_and_write`` no-ops on ``None``).
1197
+
1198
+ Route trail is layered: ``framenavigated`` is authoritative (Playwright
1199
+ counts History-API URL changes as navigations), while the injected
1200
+ history hook enriches the trail with push/replace/pop/hash kinds and
1201
+ backstops Playwright behaviour changes. Handlers only append to plain
1202
+ lists — no Playwright calls inside event handlers (sync-API reentry is
1203
+ forbidden), same pattern as ``_attach_page_diagnostics``. JS coverage
1204
+ uses a CDP ``Profiler`` session (Chromium only; playwright-python has no
1205
+ ``page.coverage`` API) and degrades to ``js_coverage.ok = false``.
1206
+ """
1207
+ if not COVERAGE_ENABLED:
1208
+ return None
1209
+ state = {
1210
+ "page": page,
1211
+ "context": context,
1212
+ "role": role,
1213
+ "noauth": noauth,
1214
+ "locale": CURRENT_LOCALE,
1215
+ "layout": CURRENT_LAYOUT,
1216
+ "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1217
+ "nav_events": [],
1218
+ "spa_events": [],
1219
+ "requests": [],
1220
+ "requests_dropped": 0,
1221
+ "cdp": None,
1222
+ "js_error": None,
1223
+ }
1224
+
1225
+ def _on_framenavigated(frame):
1226
+ try:
1227
+ if frame.parent_frame is None:
1228
+ state["nav_events"].append(
1229
+ {"u": frame.url, "k": "nav", "t": time.time() * 1000}
1230
+ )
1231
+ except Exception:
1232
+ pass
1233
+
1234
+ def _on_request(request):
1235
+ try:
1236
+ if len(state["requests"]) >= _COVERAGE_MAX_REQUESTS:
1237
+ state["requests_dropped"] += 1
1238
+ return
1239
+ state["requests"].append(
1240
+ {"m": request.method, "u": request.url, "rt": request.resource_type}
1241
+ )
1242
+ except Exception:
1243
+ pass
1244
+
1245
+ page.on("framenavigated", _on_framenavigated)
1246
+ context.on("request", _on_request)
1247
+ try:
1248
+ context.expose_binding(
1249
+ "__steppy_route_event",
1250
+ lambda source, payload: state["spa_events"].append(payload),
1251
+ )
1252
+ context.add_init_script(_COVERAGE_HISTORY_HOOK)
1253
+ except Exception:
1254
+ pass # SPA kind-tagging is enrichment; framenavigated stays authoritative
1255
+ try:
1256
+ cdp = context.new_cdp_session(page)
1257
+ cdp.send("Profiler.enable")
1258
+ # Debugger.enable pins script sources so getScriptSource still works
1259
+ # at teardown; function-granularity coverage is enough for module
1260
+ # mapping and far cheaper than block coverage.
1261
+ cdp.send("Debugger.enable")
1262
+ cdp.send("Profiler.startPreciseCoverage", {"callCount": False, "detailed": False})
1263
+ state["cdp"] = cdp
1264
+ except Exception as exc:
1265
+ state["js_error"] = str(exc)
1266
+ return state
1267
+
1268
+
1269
+ def _coverage_map_info(source: str, script_url: str) -> dict:
1270
+ """Extract the script's sourceMappingURL reference.
1271
+
1272
+ Inline ``data:`` maps are decoded and persisted NOW (gzipped,
1273
+ content-hash-deduplicated under ``raw/<run_id>/_maps/``) because they
1274
+ only exist at record time. External map URLs are stored as plain strings;
1275
+ the merge step resolves them on local disk via ``coverage.sourcemap_dir``.
1276
+ """
1277
+ marker = "sourceMappingURL="
1278
+ ref = ""
1279
+ for chunk in (source[-1024:], source): # tail first — the comment is conventionally last
1280
+ pos = chunk.rfind(marker)
1281
+ if pos != -1:
1282
+ tokens = chunk[pos + len(marker):].split()
1283
+ ref = tokens[0].rstrip("*/").strip() if tokens else ""
1284
+ break
1285
+ if not ref:
1286
+ return {"kind": "none", "reason": "no sourceMappingURL"}
1287
+ if ref.startswith("data:"):
1288
+ try:
1289
+ import base64
1290
+ import gzip
1291
+ import hashlib
1292
+
1293
+ raw = base64.b64decode(ref.split(",", 1)[1])
1294
+ digest = hashlib.sha256(raw).hexdigest()
1295
+ maps_dir = COVERAGE_RAW_DIR / COVERAGE_RUN_ID / "_maps"
1296
+ maps_dir.mkdir(parents=True, exist_ok=True)
1297
+ blob = maps_dir / (digest + ".json.gz")
1298
+ if not blob.exists():
1299
+ blob.write_bytes(gzip.compress(raw))
1300
+ return {"kind": "inline", "hash": digest}
1301
+ except Exception as exc:
1302
+ return {"kind": "none", "reason": "inline map decode failed: " + str(exc)}
1303
+ return {"kind": "external", "url": ref}
1304
+
1305
+
1306
+ def _coverage_collect_js(state) -> dict:
1307
+ """Stop CDP profiling and shape the per-script coverage payload.
1308
+
1309
+ Per executed script (http(s) URLs only): the executed function start
1310
+ offsets, a ``line_starts`` newline-offset table computed from the script
1311
+ source (the source itself is discarded — never persisted), and the source
1312
+ map reference. Every CDP step is individually guarded; any failure
1313
+ degrades to ``ok: false`` instead of losing the rest of the record.
1314
+ """
1315
+ cdp = state.get("cdp")
1316
+ if cdp is None:
1317
+ return {
1318
+ "ok": False,
1319
+ "reason": state.get("js_error") or "cdp unavailable",
1320
+ "scripts": [],
1321
+ }
1322
+ scripts = []
1323
+ try:
1324
+ result = cdp.send("Profiler.takePreciseCoverage").get("result", [])
1325
+ for entry in result:
1326
+ url = entry.get("url") or ""
1327
+ if not url.startswith(("http://", "https://")):
1328
+ continue # anonymous evals, init scripts, extensions
1329
+ offsets = sorted(
1330
+ {
1331
+ f["ranges"][0]["startOffset"]
1332
+ for f in entry.get("functions", [])
1333
+ if f.get("ranges") and f["ranges"][0].get("count", 0) > 0
1334
+ }
1335
+ )
1336
+ if not offsets:
1337
+ continue
1338
+ source = None
1339
+ try:
1340
+ source = cdp.send(
1341
+ "Debugger.getScriptSource", {"scriptId": entry["scriptId"]}
1342
+ ).get("scriptSource")
1343
+ except Exception:
1344
+ source = None
1345
+ if source is None:
1346
+ try:
1347
+ resp = state["page"].request.get(url, timeout=10000)
1348
+ source = resp.text() if resp.ok else None
1349
+ except Exception:
1350
+ source = None
1351
+ line_starts = None
1352
+ map_info = {"kind": "none", "reason": "script source unavailable"}
1353
+ if source is not None:
1354
+ line_starts = [0]
1355
+ idx = source.find("\n")
1356
+ while idx != -1:
1357
+ line_starts.append(idx + 1)
1358
+ idx = source.find("\n", idx + 1)
1359
+ map_info = _coverage_map_info(source, url)
1360
+ scripts.append(
1361
+ {
1362
+ "url": url,
1363
+ "executed_offsets": offsets,
1364
+ "line_starts": line_starts,
1365
+ "map": map_info,
1366
+ }
1367
+ )
1368
+ except Exception as exc:
1369
+ return {"ok": False, "reason": str(exc), "scripts": []}
1370
+ finally:
1371
+ for command in ("Profiler.stopPreciseCoverage", "Profiler.disable", "Debugger.disable"):
1372
+ try:
1373
+ cdp.send(command)
1374
+ except Exception:
1375
+ pass
1376
+ try:
1377
+ cdp.detach()
1378
+ except Exception:
1379
+ pass
1380
+ scripts.sort(key=lambda s: s["url"])
1381
+ return {"ok": True, "reason": None, "scripts": scripts}
1382
+
1383
+
1384
+ def _coverage_stop_and_write(state, request) -> None:
1385
+ """Finish recording and write the raw record JSON. Never raises — a
1386
+ recorder bug must not fail the test run. Must be called BEFORE
1387
+ ``page.close()`` (the JS-coverage flush needs the live page/context)."""
1388
+ if state is None:
1389
+ return
1390
+ try:
1391
+ scenario = getattr(request.module, "SCENARIO", None) or _sanitise_nodeid(
1392
+ request.node.name
1393
+ )
1394
+ failed = getattr(request.node, "_steppy_call_failed", None)
1395
+ outcome = "unknown" if failed is None else ("failed" if failed else "passed")
1396
+ record = {
1397
+ "steppy_coverage_record": 1,
1398
+ "run_id": COVERAGE_RUN_ID,
1399
+ "scenario": scenario,
1400
+ "role": state["role"],
1401
+ "noauth": state["noauth"],
1402
+ "locale": state["locale"],
1403
+ "layout": state["layout"],
1404
+ "asserts_enabled": not SKIP_ASSERTS,
1405
+ "guide_mode": GUIDE_MODE,
1406
+ "outcome": outcome,
1407
+ "started_at": state["started_at"],
1408
+ "finished_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
1409
+ "nav_events": state["nav_events"],
1410
+ "spa_events": [e for e in state["spa_events"] if isinstance(e, dict)],
1411
+ "requests": state["requests"],
1412
+ "requests_dropped": state["requests_dropped"],
1413
+ "js_coverage": _coverage_collect_js(state),
1414
+ }
1415
+ folder = COVERAGE_RAW_DIR / COVERAGE_RUN_ID / scenario
1416
+ folder.mkdir(parents=True, exist_ok=True)
1417
+ suffix = f"{state['locale'] or 'default'}-{state['layout'] or 'default'}"
1418
+ name = f"{state['role']}_page{'_noauth' if state['noauth'] else ''}__{suffix}.json"
1419
+ (folder / name).write_text(json.dumps(record, indent=2), encoding="utf-8")
1420
+ except Exception as exc:
1421
+ print(f"[steppy] coverage record failed: {exc}", flush=True)
1422
+
1423
+
1424
+ @pytest.hookimpl(hookwrapper=True)
1425
+ def pytest_runtest_makereport(item, call):
1426
+ """Capture a screenshot, URL, title, JS errors, and failed requests from
1427
+ every active ``*_page`` fixture when a test fails. Lands under
1428
+ ``output/screenshots/_failures/`` with details printed inline so the user
1429
+ can diagnose without re-running with extra flags.
1430
+
1431
+ Best-effort: any exception during capture is swallowed so a debug aid
1432
+ never compounds a real test failure.
1433
+
1434
+ Also stamps the call-phase outcome onto the item: makereport(call) fires
1435
+ before fixture finalizers, so the coverage recorder's teardown can record
1436
+ whether the scenario passed.
1437
+ """
1438
+ outcome = yield
1439
+ rep = outcome.get_result()
1440
+ if rep.when == "call":
1441
+ item._steppy_call_failed = rep.failed
1442
+ if rep.when != "call" or not rep.failed:
1443
+ return
1444
+ failure_dir = SCREENSHOTS_DIR / "_failures"
1445
+ try:
1446
+ failure_dir.mkdir(parents=True, exist_ok=True)
1447
+ except OSError:
1448
+ return
1449
+ safe_id = _sanitise_nodeid(item.nodeid)
1450
+ for fixture_name, value in item.funcargs.items():
1451
+ if not (fixture_name.endswith("_page") or fixture_name.endswith("_page_noauth")):
1452
+ continue
1453
+ if not hasattr(value, "screenshot"):
1454
+ continue
1455
+ path = failure_dir / f"{safe_id}__{fixture_name}.png"
1456
+ try:
1457
+ value.screenshot(path=str(path), full_page=True, timeout=5000)
1458
+ except Exception:
1459
+ path = None
1460
+ try:
1461
+ url = value.url
1462
+ except Exception:
1463
+ url = "<unknown>"
1464
+ try:
1465
+ title = value.title()
1466
+ except Exception:
1467
+ title = "<unknown>"
1468
+ print(
1469
+ f"\n[steppy] {fixture_name} failure url={url} title={title!r}",
1470
+ flush=True,
1471
+ )
1472
+ if path is not None:
1473
+ print(f" screenshot: {path}", flush=True)
1474
+ _dump_page_diagnostics(value, prefix=" ")
1475
+
1476
+
1477
+ # ── Capture / interpolation helpers ─────────────────────────────────────────
1478
+
1479
+ {% raw %}
1480
+ def _interp(s, _vars):
1481
+ """Substitute {{var}} placeholders in `s` from `_vars`. No-op if no braces."""
1482
+ if not s or "{{" not in s:
1483
+ return s
1484
+ out = s
1485
+ for k, v in _vars.items():
1486
+ out = out.replace("{{" + k + "}}", str(v))
1487
+ return out
1488
+
1489
+
1490
+ def _interp_shell(s, _vars):
1491
+ """Like _interp, but shell-quote each substituted value.
1492
+
1493
+ Used only for `capture from.type=shell` commands, where a {{var}} may hold
1494
+ DOM-scraped or HTTP-sourced content. Quoting the *values* (not the author's
1495
+ static command template) neutralizes shell metacharacters so page content
1496
+ can't inject into the subprocess.
1497
+ """
1498
+ import shlex
1499
+ if not s or "{{" not in s:
1500
+ return s
1501
+ out = s
1502
+ for k, v in _vars.items():
1503
+ out = out.replace("{{" + k + "}}", shlex.quote(str(v)))
1504
+ return out
1505
+ {% endraw %}
1506
+
1507
+
1508
+ def _interp_val(s, _vars):
1509
+ """Like ``_interp`` but ALSO resolves ${VAR} / ${VAR:-default} from the
1510
+ environment.
1511
+
1512
+ Used only at fill/select VALUE sites so secret placeholders emitted by
1513
+ ``steppy record`` (e.g. ${STEPPY_ADMIN_TOKEN}) resolve at run time from the
1514
+ worker/CI environment. Selector and target sites keep plain ``_interp`` — their
1515
+ semantics are unchanged. An unset var with no default is left as the literal
1516
+ ${...} (same silent policy as ``_interp_env``). Defined after the endraw
1517
+ marker above so it stays out of the ``_interp`` unit-test extract.
1518
+ """
1519
+ if isinstance(s, str) and "${" in s:
1520
+ def _sub(m):
1521
+ name, default = m.group(1), m.group(2)
1522
+ if name in os.environ:
1523
+ return os.environ[name]
1524
+ if default is not None:
1525
+ return default
1526
+ return m.group(0)
1527
+ s = _ENV_REF.sub(_sub, s)
1528
+ return _interp(s, _vars)
1529
+
1530
+
1531
+ def _resolve_date_obj(expr):
1532
+ """Resolve a relative/dynamic date expression to a ``datetime.date`` AT RUN
1533
+ TIME (so generated tests stay valid forever). Shared by ``fill-date`` (which
1534
+ formats the result) and ``pick-date`` (which needs the date object to compute
1535
+ month deltas and the target day).
1536
+
1537
+ Grammar:
1538
+ today -> current date
1539
+ this-<weekday> -> that weekday in the current (Monday-anchored) week
1540
+ next-<weekday> -> the next strictly-future occurrence of that weekday
1541
+ ±N[d|w|m|y] -> offset by N days / weeks / months / years (e.g. +7d, -1m)
1542
+ YYYY-MM-DD -> ISO literal
1543
+ """
1544
+ import calendar
1545
+ import datetime
1546
+ import re as _re
1547
+
1548
+ expr = (expr or "").strip().lower()
1549
+ today = datetime.date.today()
1550
+ weekdays = {
1551
+ "monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3,
1552
+ "friday": 4, "saturday": 5, "sunday": 6,
1553
+ }
1554
+ if expr == "today":
1555
+ d = today
1556
+ elif expr.startswith("this-") and expr[5:] in weekdays:
1557
+ d = today - datetime.timedelta(days=today.weekday() - weekdays[expr[5:]])
1558
+ elif expr.startswith("next-") and expr[5:] in weekdays:
1559
+ delta = (weekdays[expr[5:]] - today.weekday()) % 7 or 7
1560
+ d = today + datetime.timedelta(days=delta)
1561
+ elif _re.match(r"^[+-]\d+[dwmy]$", expr):
1562
+ sign = 1 if expr[0] == "+" else -1
1563
+ n = sign * int(expr[1:-1])
1564
+ unit = expr[-1]
1565
+ if unit == "d":
1566
+ d = today + datetime.timedelta(days=n)
1567
+ elif unit == "w":
1568
+ d = today + datetime.timedelta(weeks=n)
1569
+ elif unit == "m":
1570
+ month_index = today.year * 12 + (today.month - 1) + n
1571
+ year, month0 = divmod(month_index, 12)
1572
+ month = month0 + 1
1573
+ day = min(today.day, calendar.monthrange(year, month)[1])
1574
+ d = datetime.date(year, month, day)
1575
+ else: # 'y'
1576
+ year = today.year + n
1577
+ day = min(today.day, calendar.monthrange(year, today.month)[1])
1578
+ d = datetime.date(year, today.month, day)
1579
+ else:
1580
+ d = datetime.date.fromisoformat(expr)
1581
+ return d
1582
+
1583
+
1584
+ def _resolve_date(expr, fmt="%d/%m/%Y"):
1585
+ """Resolve a date expression (see ``_resolve_date_obj``) and format it with
1586
+ ``fmt``. Driven by the ``fill-date`` action."""
1587
+ return _resolve_date_obj(expr).strftime(fmt)
1588
+
1589
+
1590
+ def _open_datepicker(page, selector, panel, trigger, timeout):
1591
+ """Open a PrimeNG (date|time)picker panel from its host: prefer a showIcon
1592
+ ``trigger`` button, else the inner <input>, else the host; wait for ``panel``."""
1593
+ host = page.locator(selector)
1594
+ opener = None
1595
+ if trigger:
1596
+ trig = host.locator(trigger)
1597
+ if trig.count():
1598
+ opener = trig.first
1599
+ if opener is None:
1600
+ inp = host.locator("input")
1601
+ opener = inp.first if inp.count() else host
1602
+ opener.click()
1603
+ page.wait_for_selector(panel, state="visible", timeout=timeout)
1604
+
1605
+
1606
+ def _pick_date(page, selector, expr, *, panel, next_btn, prev_btn, day_cell, trigger="", timeout=30000):
1607
+ """Drive a calendar widget (PrimeNG ``p-datepicker`` by default) to a target
1608
+ date computed at run time. Opens the panel from ``selector``, steps forward
1609
+ or backward to the target month, clicks the day cell, and waits for the panel
1610
+ to close. Selectors are config-driven so non-PrimeNG kits can retarget."""
1611
+ import datetime
1612
+ import re as _re
1613
+
1614
+ target = _resolve_date_obj(expr)
1615
+ today = datetime.date.today()
1616
+ months = (target.year - today.year) * 12 + (target.month - today.month)
1617
+ _open_datepicker(page, selector, panel, trigger, timeout)
1618
+ step_btn = next_btn if months >= 0 else prev_btn
1619
+ for _ in range(abs(months)):
1620
+ page.locator(step_btn).first.click()
1621
+ page.wait_for_timeout(120)
1622
+ # Exact day match so "1" doesn't match "10"/"11"/… (substring filter would).
1623
+ page.locator(day_cell).filter(
1624
+ has_text=_re.compile(rf"^\s*{target.day}\s*$")
1625
+ ).first.click()
1626
+ page.wait_for_selector(panel, state="hidden", timeout=timeout)
1627
+
1628
+
1629
+ def _pick_time(page, selector, value, *, panel, hour_picker, minute_picker, trigger="", timeout=30000):
1630
+ """Drive a PrimeNG ``timeOnly`` picker to a target ``HH:MM``. Opens the panel
1631
+ (showIcon trigger -> inner <input> -> host), spins each column's up/down arrow
1632
+ until its value <span> reads the target, then presses Escape to close so the
1633
+ overlay can't intercept later clicks. Column-scoped (``{col} button`` first/
1634
+ last + ``{col} > span``) so it works regardless of whether PrimeNG tags the
1635
+ arrows with increment/decrement classes. Assumes ``stepHour``/``stepMinute``
1636
+ == 1 (PrimeNG default). Selectors are config-driven for non-PrimeNG kits."""
1637
+ target_h, target_m = (int(p) for p in value.strip().split(":"))
1638
+ _open_datepicker(page, selector, panel, trigger, timeout)
1639
+ _panel = page.locator(panel).last
1640
+
1641
+ def _spin(col_sel, target_val, modulo):
1642
+ # Bounded by ``modulo`` so a stuck widget can't loop forever.
1643
+ buttons = _panel.locator(col_sel + " button") # first=up, last=down
1644
+ value_span = _panel.locator(col_sel + " > span") # direct-child value
1645
+ for _ in range(modulo):
1646
+ current = int((value_span.first.inner_text() or "0").strip())
1647
+ if current == target_val:
1648
+ return
1649
+ diff = (target_val - current) % modulo
1650
+ # Step the short way around the dial.
1651
+ (buttons.first if diff <= modulo // 2 else buttons.last).click()
1652
+ page.wait_for_timeout(80)
1653
+
1654
+ _spin(hour_picker, target_h, 24)
1655
+ _spin(minute_picker, target_m, 60)
1656
+ page.keyboard.press("Escape")
1657
+ page.wait_for_selector(panel, state="hidden", timeout=timeout)
1658
+
1659
+
1660
+ _SYNTHETIC_CLICK_JS = """
1661
+ el => {
1662
+ el.scrollIntoView({block: 'center'});
1663
+ const target = el.querySelector('button, input, [role="button"]') || el;
1664
+ const r = target.getBoundingClientRect();
1665
+ const opts = {bubbles: true, cancelable: true, view: window, button: 0,
1666
+ clientX: r.left + r.width / 2, clientY: r.top + r.height / 2};
1667
+ target.dispatchEvent(new MouseEvent('mousedown', opts));
1668
+ target.dispatchEvent(new MouseEvent('mouseup', opts));
1669
+ target.dispatchEvent(new MouseEvent('click', opts));
1670
+ }
1671
+ """
1672
+
1673
+
1674
+ _WAIT_COMPARATOR = re.compile(r"^(>=|<=|==|!=|>|<)\s*(\d+)$")
1675
+
1676
+
1677
+ def _wait_until(locator, spec: str, timeout: int, interval: int = 100) -> None:
1678
+ """Block until a *state* predicate holds on ``locator``, not just presence.
1679
+
1680
+ Playwright's own ``wait_for`` accepts only visible/hidden/detached/attached,
1681
+ which cannot say "the form has finished pre-filling" — so scenarios polled
1682
+ it by hand. The dominant shape in the corpus, 47 times over, was:
1683
+
1684
+ deadline = time.time() + 10
1685
+ while time.time() < deadline:
1686
+ if page.input_value(sel):
1687
+ break
1688
+ page.wait_for_timeout(100)
1689
+
1690
+ Polled from Python rather than through ``wait_for_function`` on purpose: the
1691
+ predicate has to observe the element through *Playwright's* locator, which
1692
+ understands ``text=``/``>>`` and the other selector engines that
1693
+ ``document.querySelector`` does not.
1694
+
1695
+ spec:
1696
+ value-not-empty -- the input has a value
1697
+ value:<text> -- the input's value equals <text>
1698
+ text:<text> -- the element's text contains <text>
1699
+ count:<cmp><n> -- how many elements match, e.g. count:>=2
1700
+ """
1701
+ deadline = time.monotonic() + timeout / 1000.0
1702
+ last = None
1703
+ while True:
1704
+ try:
1705
+ if spec == "value-not-empty":
1706
+ last = locator.first.input_value(timeout=timeout)
1707
+ if last:
1708
+ return
1709
+ elif spec.startswith("value:"):
1710
+ want = spec[6:]
1711
+ last = locator.first.input_value(timeout=timeout)
1712
+ if last == want:
1713
+ return
1714
+ elif spec.startswith("text:"):
1715
+ want = spec[5:]
1716
+ last = locator.first.inner_text(timeout=timeout)
1717
+ if want in last:
1718
+ return
1719
+ elif spec.startswith("count:"):
1720
+ match = _WAIT_COMPARATOR.match(spec[6:])
1721
+ comparator, expected = match.group(1), int(match.group(2))
1722
+ last = locator.count()
1723
+ if {
1724
+ ">=": last >= expected,
1725
+ "<=": last <= expected,
1726
+ "==": last == expected,
1727
+ "!=": last != expected,
1728
+ ">": last > expected,
1729
+ "<": last < expected,
1730
+ }[comparator]:
1731
+ return
1732
+ else: # pragma: no cover - validation rejects this at parse time
1733
+ raise AssertionError(f"_wait_until: unknown predicate {spec!r}")
1734
+ except AssertionError:
1735
+ raise
1736
+ except Exception as exc: # the element may not exist yet
1737
+ last = f"<{type(exc).__name__}>"
1738
+ if time.monotonic() >= deadline:
1739
+ raise AssertionError(f"wait-for until={spec!r} not satisfied within {timeout}ms (last saw {last!r})")
1740
+ time.sleep(interval / 1000.0)
1741
+
1742
+
1743
+ def _assert_stays_hidden(locator, ms: int, message: str = "", interval: int = 250) -> None:
1744
+ """Assert an element stays absent for ``ms``, rather than becoming absent.
1745
+
1746
+ ``expect(...).to_be_hidden()`` auto-waits for the element to *become* hidden,
1747
+ which passes the instant it is not there yet — the opposite of what "prove
1748
+ the replacement wizard never opens" needs. The corpus spells this as a
1749
+ for-loop of one-shot counts; this is that loop, once.
1750
+ """
1751
+ deadline = time.monotonic() + ms / 1000.0
1752
+ while True:
1753
+ if locator.count() and locator.first.is_visible():
1754
+ raise AssertionError(message or f"element appeared within {ms}ms but was expected to stay hidden")
1755
+ if time.monotonic() >= deadline:
1756
+ return
1757
+ time.sleep(interval / 1000.0)
1758
+
1759
+
1760
+ _WHEN_FORMS = ("visible(", "hidden(", "count(", "contains(")
1761
+
1762
+
1763
+ def _when(page, spec: str, _vars: dict) -> bool:
1764
+ """Evaluate a step's ``when:`` condition against the live page.
1765
+
1766
+ Deliberately NOT the assertion grammar: an assertion auto-waits, which is
1767
+ the opposite of what a condition wants. "Click it if it is there" must
1768
+ answer now, from what is rendered now — waiting 30s for an element that was
1769
+ never going to appear would make every guarded step pay the timeout.
1770
+
1771
+ Supported: visible(sel) | hidden(sel) | count(sel, <cmp>N) | contains(sel, "text")
1772
+ """
1773
+ spec = (spec or "").strip()
1774
+ head, _, rest = spec.partition("(")
1775
+ inner = rest[:-1] if rest.endswith(")") else rest
1776
+ if head == "visible":
1777
+ loc = page.locator(_interp(inner.strip(), _vars))
1778
+ return bool(loc.count()) and loc.first.is_visible()
1779
+ if head == "hidden":
1780
+ loc = page.locator(_interp(inner.strip(), _vars))
1781
+ return not loc.count() or not loc.first.is_visible()
1782
+ if head == "contains":
1783
+ selector, _, text = inner.partition(",")
1784
+ loc = page.locator(_interp(selector.strip(), _vars))
1785
+ if not loc.count():
1786
+ return False
1787
+ return _interp(text.strip().strip('"').strip("'"), _vars) in loc.first.inner_text()
1788
+ if head == "count":
1789
+ selector, _, raw = inner.rpartition(",")
1790
+ loc = page.locator(_interp(selector.strip(), _vars))
1791
+ match = _WAIT_COMPARATOR.match(raw.strip())
1792
+ if not match:
1793
+ return loc.count() == int(raw.strip())
1794
+ comparator, expected = match.group(1), int(match.group(2))
1795
+ found = loc.count()
1796
+ return {
1797
+ ">=": found >= expected,
1798
+ "<=": found <= expected,
1799
+ "==": found == expected,
1800
+ "!=": found != expected,
1801
+ ">": found > expected,
1802
+ "<": found < expected,
1803
+ }[comparator]
1804
+ raise AssertionError(f"_when: unsupported condition {spec!r}")
1805
+
1806
+
1807
+ def call_helper(page, dotted: str, args=None, kwargs=None, _vars=None, pass_page: bool = True):
1808
+ """Run a project helper function as a first-class step.
1809
+
1810
+ The project root is already on ``sys.path``: pytest is launched as
1811
+ ``python -m pytest`` from it, and ``python -m`` puts the working directory
1812
+ at ``sys.path[0]``. (The worker image sets ``WORKDIR /steppy/project`` for
1813
+ the same reason.) So the three-line ``sys.path.insert`` preamble that opens
1814
+ 2 334 custom blocks in the measured corpus was never necessary, and nothing
1815
+ here reproduces it.
1816
+
1817
+ String arguments go through ``_interp`` so a value captured by an earlier
1818
+ step reaches the helper as ``{{var}}``; non-strings pass through untouched,
1819
+ which is what keeps ``args: [6]`` an int.
1820
+ """
1821
+ import importlib
1822
+
1823
+ module_name, _, fn_name = dotted.rpartition(".")
1824
+ try:
1825
+ module = importlib.import_module(module_name)
1826
+ except ImportError as exc:
1827
+ raise AssertionError(
1828
+ f"call: cannot import {module_name!r} for {dotted!r}. "
1829
+ f"Helper modules are imported from the project root ({Path.cwd()})."
1830
+ ) from exc
1831
+ try:
1832
+ fn = getattr(module, fn_name)
1833
+ except AttributeError as exc:
1834
+ raise AssertionError(f"call: {module_name!r} has no attribute {fn_name!r}") from exc
1835
+
1836
+ _vars = _vars if _vars is not None else {}
1837
+ resolved_args = [_interp(a, _vars) if isinstance(a, str) else a for a in (args or [])]
1838
+ resolved_kwargs = {k: (_interp(v, _vars) if isinstance(v, str) else v) for k, v in (kwargs or {}).items()}
1839
+ if pass_page:
1840
+ return fn(page, *resolved_args, **resolved_kwargs)
1841
+ return fn(*resolved_args, **resolved_kwargs)
1842
+
1843
+
1844
+ def synthetic_click(locator) -> None:
1845
+ """Dispatch a manual mousedown/mouseup/click sequence on a locator's element
1846
+ (Cmd 12 — ``click synthetic: true``). For custom widgets that swallow
1847
+ Playwright's native ``.click()`` and only react to raw DOM MouseEvents."""
1848
+ locator.evaluate(_SYNTHETIC_CLICK_JS)
1849
+
1850
+
1851
+ def capture(spec: dict, _vars: dict, retry: int = 1, interval: float = 0.2) -> str:
1852
+ """Fetch a value from an external source. Retries with `interval` seconds between attempts.
1853
+
1854
+ spec.type:
1855
+ - 'redis': requires 'key'. Reads CONFIG['redis'] for connection.
1856
+ - 'http': requires 'url'. Optional 'json_path' (e.g. '$.code') and 'headers'.
1857
+ - 'shell': requires 'command'. Returns stdout (trimmed).
1858
+ - 'date': requires 'date' (today|next-monday|+7d|YYYY-MM-DD). Optional
1859
+ 'format' (strftime, default '%Y-%m-%d'; '%V'/'%G' give the ISO
1860
+ week and year). Pure — resolved at run time, never cached.
1861
+ All string fields are run through _interp() so they can reference earlier captures.
1862
+ """
1863
+ # Gate shell capture up front (not inside the retry loop) so the reason is
1864
+ # surfaced cleanly rather than wrapped in a retry AssertionError.
1865
+ if spec.get("type") == "shell" and not CONFIG.get("security", {}).get("allow_shell_capture", False):
1866
+ raise RuntimeError(
1867
+ "capture from.type=shell is disabled. It runs a shell command with "
1868
+ "page-derived values and is a command-injection vector, so it is "
1869
+ "opt-in: set security.allow_shell_capture: true in config.yaml to "
1870
+ "enable it (see SECURITY.md)."
1871
+ )
1872
+ last_err = None
1873
+ for _ in range(max(1, retry)):
1874
+ try:
1875
+ t = spec.get("type")
1876
+ if t == "redis":
1877
+ import redis # type: ignore
1878
+ cfg = CONFIG.get("redis", {})
1879
+ r = redis.Redis(
1880
+ host=cfg.get("host", "localhost"),
1881
+ port=cfg.get("port", 6379),
1882
+ db=cfg.get("db", 0),
1883
+ decode_responses=True,
1884
+ )
1885
+ v = r.get(_interp(spec["key"], _vars))
1886
+ if v is not None:
1887
+ return v
1888
+ elif t == "http":
1889
+ import httpx # type: ignore
1890
+ headers = spec.get("headers") or {}
1891
+ resp = httpx.get(_interp(spec["url"], _vars), headers=headers, timeout=5)
1892
+ resp.raise_for_status()
1893
+ if "json_path" in spec:
1894
+ import jsonpath_ng # type: ignore
1895
+ matches = jsonpath_ng.parse(spec["json_path"]).find(resp.json())
1896
+ if matches:
1897
+ return str(matches[0].value)
1898
+ else:
1899
+ return resp.text
1900
+ elif t == "date":
1901
+ # No I/O and nothing to retry: `_resolve_date` is pure. It is
1902
+ # here rather than in a new action because the value's *use* is
1903
+ # already what `capture` is for — binding a `{{var}}` that later
1904
+ # selectors and values interpolate.
1905
+ return _resolve_date(
1906
+ _interp(spec["date"], _vars),
1907
+ _interp(spec.get("format") or "%Y-%m-%d", _vars),
1908
+ )
1909
+ elif t == "shell":
1910
+ import subprocess
1911
+ out = subprocess.run(
1912
+ _interp_shell(spec["command"], _vars),
1913
+ shell=True,
1914
+ capture_output=True,
1915
+ text=True,
1916
+ check=True,
1917
+ )
1918
+ return out.stdout.strip()
1919
+ else:
1920
+ raise ValueError(f"Unknown capture type: {t!r}")
1921
+ except Exception as e:
1922
+ last_err = e
1923
+ time.sleep(interval)
1924
+ raise AssertionError(f"capture failed after {retry} attempts: spec={spec} last_err={last_err}")
1925
+
1926
+
1927
+ def capture_dom(page, spec: dict, _vars: dict, retry: int = 1, interval: float = 0.2) -> str:
1928
+ """Read a value out of the live DOM and store it in ``_vars`` (interpolatable
1929
+ by later steps via _interp), replacing the ``globals()[...]`` capture-and-reuse
1930
+ hack. Retries with ``interval`` seconds between attempts.
1931
+
1932
+ spec (from.type == 'dom'):
1933
+ - 'selector': required. The element to read (first match is used).
1934
+ - 'extract': text (default) | attribute | regex.
1935
+ - 'attribute': attribute name when extract == 'attribute'.
1936
+ - 'regex': pattern applied to the extracted string; returns group 1
1937
+ (or group 0 if the pattern has no groups). Required when
1938
+ extract == 'regex'; also honored as a post-filter otherwise.
1939
+ """
1940
+ import re as _re
1941
+
1942
+ extract = spec.get("extract", "text")
1943
+ last_err = None
1944
+ for _ in range(max(1, retry)):
1945
+ try:
1946
+ loc = page.locator(_interp(spec["selector"], _vars)).first
1947
+ loc.wait_for(state="visible", timeout=10000)
1948
+ if extract == "attribute":
1949
+ raw = loc.get_attribute(_interp(spec["attribute"], _vars))
1950
+ else:
1951
+ raw = loc.inner_text()
1952
+ if raw is None:
1953
+ raise ValueError("capture_dom: extracted value is None")
1954
+ raw = raw.strip()
1955
+ pattern = spec.get("regex")
1956
+ if pattern:
1957
+ m = _re.search(pattern, raw)
1958
+ if not m:
1959
+ raise ValueError(f"capture_dom: regex {pattern!r} did not match {raw!r}")
1960
+ return m.group(1) if m.groups() else m.group(0)
1961
+ return raw
1962
+ except Exception as e:
1963
+ last_err = e
1964
+ time.sleep(interval)
1965
+ raise AssertionError(f"capture_dom failed after {retry} attempts: spec={spec} last_err={last_err}")
1966
+
1967
+
1968
+ # ── Screenshot helper ───────────────────────────────────────────────────────
1969
+
1970
+ def _screenshot_folder(scenario: str) -> Path:
1971
+ """Return the folder PNGs/sidecars for ``scenario`` should land in.
1972
+
1973
+ When ``CURRENT_LOCALE`` is set (parametrized run), images go under a
1974
+ per-locale subdir so the build can serve a different screenshot set per
1975
+ language. Otherwise they fall back to the legacy flat layout.
1976
+ """
1977
+ folder = SCREENSHOTS_DIR / scenario
1978
+ if CURRENT_LOCALE:
1979
+ folder = folder / CURRENT_LOCALE
1980
+ if CURRENT_LAYOUT:
1981
+ folder = folder / CURRENT_LAYOUT
1982
+ folder.mkdir(parents=True, exist_ok=True)
1983
+ return folder
1984
+
1985
+
1986
+ def _screenshot_mask_kwargs(page: Page) -> dict:
1987
+ """Build page.screenshot(mask=...) kwargs from `screenshot.mask` config.
1988
+
1989
+ Returns {} when no mask selectors are configured, so output is byte-identical
1990
+ for projects that don't redact. Otherwise returns solid-box masks over every
1991
+ matched element (secrets/personal-data redaction — see SECURITY.md).
1992
+ """
1993
+ masks = CONFIG.get("screenshot", {}).get("mask", []) or []
1994
+ if not masks:
1995
+ return {}
1996
+ color = CONFIG.get("screenshot", {}).get("mask_color", "#000000")
1997
+ return {"mask": [page.locator(sel) for sel in masks], "mask_color": color}
1998
+
1999
+
2000
+ def take_screenshot(page: Page, scenario: str, step: int, suffix: str = ""):
2001
+ """Capture a screenshot if STEPPY_SCREENSHOTS is enabled."""
2002
+ if not SCREENSHOTS_ENABLED:
2003
+ return
2004
+ folder = _screenshot_folder(scenario)
2005
+ name = f"step-{step:02d}{'-' + suffix if suffix else ''}.png"
2006
+ page.screenshot(path=str(folder / name), **_screenshot_mask_kwargs(page))
2007
+
2008
+
2009
+ def _apply_highlight(page: Page, selector: str) -> None:
2010
+ """Outline + tint an element for a guide screenshot. Best-effort."""
2011
+ highlight_css = CONFIG.get("screenshot", {})
2012
+ color = highlight_css.get("highlight_color", "rgba(255, 0, 0, 0.3)")
2013
+ border = highlight_css.get("highlight_border", "2px solid red")
2014
+ try:
2015
+ page.eval_on_selector(
2016
+ selector,
2017
+ f"el => {% raw %}{{{% endraw %} el.style.outline = '{border}'; el.style.backgroundColor = '{color}'; {% raw %}}}{% endraw %}"
2018
+ )
2019
+ except Exception:
2020
+ pass # Element might not exist; screenshot without highlight
2021
+
2022
+
2023
+ def _clear_highlight(page: Page, selector: str) -> None:
2024
+ """Undo :func:`_apply_highlight`. Best-effort."""
2025
+ try:
2026
+ page.eval_on_selector(
2027
+ selector,
2028
+ "el => {% raw %}{{% endraw %} el.style.outline = ''; el.style.backgroundColor = ''; {% raw %}}{% endraw %}"
2029
+ )
2030
+ except Exception:
2031
+ pass
2032
+
2033
+
2034
+ def highlight_and_screenshot(page: Page, selector: str, scenario: str, step: int):
2035
+ """Highlight an element and take a screenshot (for guide generation)."""
2036
+ if not SCREENSHOTS_ENABLED:
2037
+ return
2038
+ _apply_highlight(page, selector)
2039
+ take_screenshot(page, scenario, step)
2040
+ _clear_highlight(page, selector)
2041
+
2042
+
2043
+ # Per-frame suffix for multi-frame captures (step-NN-a.png, -b, …). One
2044
+ # declaration: the two frame builders below must agree on the alphabet and
2045
+ # on the ceiling it puts on frame count.
2046
+ FRAME_LETTERS = "abcdefghijklmnopqrstuvwxyz"
2047
+
2048
+
2049
+ # ── Cursor sidecar helper ───────────────────────────────────────────────────
2050
+
2051
+ def _safe_bbox(page: Page, selector: str):
2052
+ """Return locator.bounding_box() or None on any failure."""
2053
+ try:
2054
+ return page.locator(selector).first.bounding_box(timeout=1000)
2055
+ except Exception:
2056
+ return None
2057
+
2058
+
2059
+ def _scroll_into_view(page: Page, selector: str) -> None:
2060
+ """Best-effort scroll so *selector* is in the viewport.
2061
+
2062
+ The single place the scroll policy (which locator, what timeout, swallow
2063
+ failures) is expressed — the cursor-bbox timing invariant below depends on
2064
+ it, and so do the fill-form frame helpers that anchor a viewport.
2065
+ """
2066
+ try:
2067
+ page.locator(selector).first.scroll_into_view_if_needed(timeout=1000)
2068
+ except Exception:
2069
+ pass
2070
+
2071
+
2072
+ def _safe_bbox_scrolled(page: Page, selector: str):
2073
+ """Scroll the element into view, then return its bounding box.
2074
+
2075
+ Playwright actions (click/fill/...) auto-scroll the element into view before
2076
+ firing. Capturing the bbox *before* the action therefore yields stale
2077
+ viewport coordinates when the element was off-screen (e.g. inside a scrolled
2078
+ modal). Scrolling first keeps the captured bbox aligned with the viewport
2079
+ the subsequent screenshot will see.
2080
+ """
2081
+ _scroll_into_view(page, selector)
2082
+ return _safe_bbox(page, selector)
2083
+
2084
+
2085
+ def _safe_bbox_locator(page: Page, locator):
2086
+ """Like ``_safe_bbox_scrolled`` but for an already-resolved Playwright
2087
+ Locator (used by actions that disambiguate via ``within``/``match`` or that
2088
+ build their target locator imperatively, e.g. ``confirm``). Scroll into view,
2089
+ then return its bounding box or None on any failure."""
2090
+ try:
2091
+ locator.scroll_into_view_if_needed(timeout=1000)
2092
+ except Exception:
2093
+ pass
2094
+ try:
2095
+ return locator.bounding_box(timeout=1000)
2096
+ except Exception:
2097
+ return None
2098
+
2099
+
2100
+ def _offset_for_full_page(page: Page):
2101
+ """Return (scrollX, scrollY) when full_page screenshots are in use, else (0, 0)."""
2102
+ if not SCREENSHOT_FULL_PAGE:
2103
+ return (0, 0)
2104
+ try:
2105
+ return (
2106
+ int(page.evaluate("() => window.scrollX || 0")),
2107
+ int(page.evaluate("() => window.scrollY || 0")),
2108
+ )
2109
+ except Exception:
2110
+ return (0, 0)
2111
+
2112
+
2113
+ def take_screenshot_with_bbox(
2114
+ page: Page,
2115
+ scenario: str,
2116
+ step: int,
2117
+ action: str,
2118
+ selector: str | None = None,
2119
+ selectors: list | None = None,
2120
+ precaptured_bbox: dict | None = None,
2121
+ precaptured_bboxes: list | None = None,
2122
+ highlight: bool = False,
2123
+ frame: str | None = None,
2124
+ ):
2125
+ """Capture a screenshot and a JSON sidecar containing the target bounding box(es).
2126
+
2127
+ - selector / precaptured_bbox: single-target actions (click, fill, hover, ...).
2128
+ - selectors / precaptured_bboxes: multi-target actions (fill-form).
2129
+ - precaptured_* win over live resolution so the caller can capture BEFORE the action
2130
+ mutates the DOM. If both are None and a selector is supplied we fall back to
2131
+ resolving it at screenshot time.
2132
+ - If neither selector nor list is supplied, target is None (hidden cursor in guide).
2133
+ - No-op when STEPPY_SCREENSHOTS is not enabled.
2134
+ """
2135
+ if not SCREENSHOTS_ENABLED:
2136
+ return
2137
+
2138
+ folder = _screenshot_folder(scenario)
2139
+ stem = f"step-{step:02d}" + (f"-{frame}" if frame else "")
2140
+ png_path = folder / f"{stem}.png"
2141
+ json_path = folder / f"{stem}.json"
2142
+
2143
+ dx, dy = _offset_for_full_page(page)
2144
+
2145
+ def _apply_offset(bbox):
2146
+ if not bbox:
2147
+ return None
2148
+ return {
2149
+ "x": bbox.get("x", 0) + dx,
2150
+ "y": bbox.get("y", 0) + dy,
2151
+ "width": bbox.get("width", 0),
2152
+ "height": bbox.get("height", 0),
2153
+ }
2154
+
2155
+ target_payload = None
2156
+ if selectors is not None or precaptured_bboxes is not None:
2157
+ sels = selectors or []
2158
+ bboxes = precaptured_bboxes
2159
+ if bboxes is None:
2160
+ bboxes = [_safe_bbox(page, s) for s in sels]
2161
+ fields = []
2162
+ for i, bbox in enumerate(bboxes):
2163
+ fields.append({
2164
+ "selector": sels[i] if i < len(sels) else None,
2165
+ "bbox": _apply_offset(bbox),
2166
+ })
2167
+ target_payload = {"kind": "multi", "fields": fields}
2168
+ elif selector is not None or precaptured_bbox is not None:
2169
+ bbox = precaptured_bbox if precaptured_bbox is not None else _safe_bbox(page, selector)
2170
+ target_payload = {
2171
+ "kind": "single",
2172
+ "selector": selector,
2173
+ "bbox": _apply_offset(bbox),
2174
+ }
2175
+
2176
+ # Optional highlight parity with highlight_and_screenshot (applied AFTER bbox capture).
2177
+ if highlight and selector:
2178
+ _apply_highlight(page, selector)
2179
+
2180
+ page.screenshot(path=str(png_path), **_screenshot_mask_kwargs(page))
2181
+
2182
+ if highlight and selector:
2183
+ _clear_highlight(page, selector)
2184
+
2185
+ sidecar = {
2186
+ "step": step,
2187
+ "screenshot": png_path.name,
2188
+ "action": action,
2189
+ # Record the viewport this page actually ran at, which is the space its
2190
+ # bboxes were measured in. The guide renderer scales cursor bboxes
2191
+ # against this value. `CURRENT_LAYOUT` is only the fallback (a context
2192
+ # built without a viewport reports None): it is one global shared by
2193
+ # every role page a test holds, so it describes this page only while
2194
+ # those pages are parametrized in lockstep.
2195
+ "viewport": page.viewport_size or _get_viewport_for_layout(CURRENT_LAYOUT),
2196
+ # Capture devicePixelRatio for THIS layout. The guide renderer multiplies
2197
+ # viewport * this to get the screenshot's intrinsic pixel width, which it
2198
+ # uses to cap the displayed `<img>` width so a dense (e.g. mobile DSF=3)
2199
+ # capture is never upscaled. Absent in legacy sidecars ⇒ readers default 1.
2200
+ "device_scale_factor": _get_scale_factor_for_layout(CURRENT_LAYOUT),
2201
+ "target": target_payload,
2202
+ }
2203
+ json_path.write_text(json.dumps(sidecar, indent=2), encoding="utf-8")
2204
+
2205
+
2206
+ def take_fillform_progressive_frames(
2207
+ page: Page,
2208
+ scenario: str,
2209
+ step: int,
2210
+ fields: list,
2211
+ ):
2212
+ """Capture a fill-form step field-by-field with a single-target cursor per frame.
2213
+
2214
+ ``fields`` is an ordered list of ``(selector, value)`` tuples. For each
2215
+ field, the function:
2216
+ 1. Scrolls the field into view (so its bbox lines up with the
2217
+ screenshot's viewport).
2218
+ 2. Captures the current form state with a single-target ``fill-form``
2219
+ sidecar pointing at THIS field (cursor will travel here, click,
2220
+ then type).
2221
+ 3. Fills the field on the real page.
2222
+
2223
+ After every field is filled, a final "settled" frame is captured with no
2224
+ cursor target — the form's fully-filled end state.
2225
+
2226
+ Frames are emitted as ``step-NN-a``, ``-b``, … in field declaration order,
2227
+ plus one extra letter for the settled frame. Reuses the existing letter
2228
+ scheme so the guide builder's frame discovery loop in
2229
+ ``_frame_screenshot_path`` works unchanged.
2230
+
2231
+ Capture time grows with field count (one screenshot + one fill per field),
2232
+ so authors should opt in deliberately on forms where the field-by-field
2233
+ progression adds clarity.
2234
+ """
2235
+ if not SCREENSHOTS_ENABLED:
2236
+ return
2237
+ if not fields:
2238
+ return
2239
+
2240
+ # +1 for the settled frame; if we'd overflow the alphabet, bail on the
2241
+ # settled frame rather than corrupting the sequence.
2242
+ n_fields = len(fields)
2243
+ if n_fields >= len(FRAME_LETTERS):
2244
+ n_fields = len(FRAME_LETTERS) - 1
2245
+
2246
+ for i in range(n_fields):
2247
+ selector, value = fields[i]
2248
+ bbox = _safe_bbox_scrolled(page, selector)
2249
+ take_screenshot_with_bbox(
2250
+ page,
2251
+ scenario,
2252
+ step,
2253
+ "fill-form",
2254
+ selector=selector,
2255
+ precaptured_bbox=bbox,
2256
+ frame=FRAME_LETTERS[i],
2257
+ )
2258
+ try:
2259
+ page.locator(selector).first.fill(value)
2260
+ except Exception:
2261
+ pass
2262
+
2263
+ # Settled frame: form fully filled, no cursor target. Anchor on the last
2264
+ # field so the viewport is sensible (matches the user's final scroll
2265
+ # position in real use).
2266
+ if fields:
2267
+ _scroll_into_view(page, fields[-1][0])
2268
+ take_screenshot_with_bbox(
2269
+ page,
2270
+ scenario,
2271
+ step,
2272
+ "fill-form",
2273
+ frame=FRAME_LETTERS[n_fields],
2274
+ )
2275
+
2276
+
2277
+ def take_fillform_frames(
2278
+ page: Page,
2279
+ scenario: str,
2280
+ step: int,
2281
+ selectors: list,
2282
+ ):
2283
+ """Capture as many frames as needed so every form field appears, fully
2284
+ in-viewport, in at least one screenshot.
2285
+
2286
+ Strategy: repeatedly pick the first field not yet covered, scroll it into
2287
+ view, take a frame, and mark every field whose bbox center now lies inside
2288
+ the viewport as covered. Stops when every field is covered, or when a pass
2289
+ makes no progress (safety net against pathological forms).
2290
+
2291
+ Frame suffixes are ``a``, ``b``, ``c``, … in capture order. Each sidecar
2292
+ contains bboxes for every field in the original declaration order (with
2293
+ the per-frame viewport), so the guide renderer can decide per frame which
2294
+ fields to animate.
2295
+ """
2296
+ if not SCREENSHOTS_ENABLED:
2297
+ return
2298
+ if not selectors:
2299
+ return
2300
+
2301
+ _vp = page.viewport_size or _get_viewport_for_layout(CURRENT_LAYOUT)
2302
+ vw = _vp.get("width", 1280)
2303
+ vh = _vp.get("height", 720)
2304
+
2305
+ def _in_viewport(bbox):
2306
+ if not bbox:
2307
+ return False
2308
+ cx = bbox.get("x", 0) + bbox.get("width", 0) / 2
2309
+ cy = bbox.get("y", 0) + bbox.get("height", 0) / 2
2310
+ return 0 <= cx <= vw and 0 <= cy <= vh
2311
+
2312
+ covered = [False] * len(selectors)
2313
+ frame_idx = 0
2314
+
2315
+ while not all(covered) and frame_idx < len(FRAME_LETTERS):
2316
+ # Anchor this frame on the first uncovered field.
2317
+ try:
2318
+ anchor = next(i for i, c in enumerate(covered) if not c)
2319
+ except StopIteration:
2320
+ break
2321
+ _scroll_into_view(page, selectors[anchor])
2322
+
2323
+ bboxes = [_safe_bbox(page, s) for s in selectors]
2324
+ newly_covered = [i for i, b in enumerate(bboxes) if _in_viewport(b) and not covered[i]]
2325
+ if not newly_covered:
2326
+ # No progress — anchor field didn't become visible. Force-mark it so
2327
+ # the loop terminates; the guide will fall back to hiding its cursor.
2328
+ covered[anchor] = True
2329
+ frame_idx += 1
2330
+ continue
2331
+
2332
+ for i in newly_covered:
2333
+ covered[i] = True
2334
+
2335
+ take_screenshot_with_bbox(
2336
+ page,
2337
+ scenario,
2338
+ step,
2339
+ "fill-form",
2340
+ selectors=selectors,
2341
+ precaptured_bboxes=bboxes,
2342
+ frame=FRAME_LETTERS[frame_idx],
2343
+ )
2344
+ frame_idx += 1
2345
+ {% if tours_enabled %}
2346
+
2347
+
2348
+ # ── In-app tours (config.yaml `tours.enabled`) ────────────────────────────────
2349
+ # Plain Python, appended only when tours are on. It rebinds three module-level
2350
+ # names instead of editing their bodies (callers look them up at call time), so
2351
+ # the hooks and helpers above stay byte-identical for tours-off projects.
2352
+ #
2353
+ # * every browser context gets the overlay's kill switch, so an app that starts
2354
+ # a tour on its own cannot cover the elements an unrelated test clicks; the
2355
+ # tour replay (`test_<slug>__tour`) lifts it for itself;
2356
+ # * a replay writes no coverage record (it would overwrite the scenario's);
2357
+ # * `steppy run --guide` deselects replays: guide captures are not tours.
2358
+ _TOURS_KILL_SWITCH_JS = "globalThis.__STEPPY_TOURS_DISABLED__ = true;"
2359
+
2360
+
2361
+ def _tours_replay_role(node):
2362
+ """The role a `test_<slug>__tour` function was generated for, else None."""
2363
+ return getattr(getattr(node, "function", None), "_steppy_tour_role", None)
2364
+
2365
+
2366
+ def _tours_with_kill_switch(build):
2367
+ def _new_role_context(browser, role, **extra):
2368
+ context = build(browser, role, **extra)
2369
+ context.add_init_script(_TOURS_KILL_SWITCH_JS)
2370
+ return context
2371
+
2372
+ return _new_role_context
2373
+
2374
+
2375
+ def _tours_skip_replay_coverage(write):
2376
+ def _coverage_stop_and_write(state, request):
2377
+ if state is not None and _tours_replay_role(request.node) is not None:
2378
+ cdp = state.get("cdp")
2379
+ if cdp is not None:
2380
+ try:
2381
+ cdp.detach()
2382
+ except Exception:
2383
+ pass
2384
+ return
2385
+ write(state, request)
2386
+
2387
+ return _coverage_stop_and_write
2388
+
2389
+
2390
+ def _tours_deselect_in_guide_mode(hook):
2391
+ def pytest_collection_modifyitems(config, items):
2392
+ if GUIDE_MODE:
2393
+ replays = [item for item in items if _tours_replay_role(item) is not None]
2394
+ if replays:
2395
+ config.hook.pytest_deselected(items=replays)
2396
+ items[:] = [item for item in items if _tours_replay_role(item) is None]
2397
+ hook(config, items)
2398
+
2399
+ return pytest_collection_modifyitems
2400
+
2401
+
2402
+ _new_role_context = _tours_with_kill_switch(_new_role_context)
2403
+ _coverage_stop_and_write = _tours_skip_replay_coverage(_coverage_stop_and_write)
2404
+ pytest_collection_modifyitems = _tours_deselect_in_guide_mode(pytest_collection_modifyitems)
2405
+ {% endif %}