steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1,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 %}
|