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,767 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-generated by Steppy from: {{ scenario.file_path | string | replace("\\", "/") }}
|
|
3
|
+
Do not edit manually — changes will be overwritten.
|
|
4
|
+
Re-generate with: steppy generate
|
|
5
|
+
|
|
6
|
+
Title: {{ scenario.title | docstring }}
|
|
7
|
+
{% if scenario.description -%}
|
|
8
|
+
Description: {{ scenario.description | docstring }}
|
|
9
|
+
{% endif -%}
|
|
10
|
+
{% if scenario.tags -%}
|
|
11
|
+
Tags: {{ scenario.tags | join(', ') | docstring }}
|
|
12
|
+
{% endif -%}
|
|
13
|
+
{% if scenario.priority -%}
|
|
14
|
+
Priority: {{ scenario.priority | docstring }}
|
|
15
|
+
{% endif -%}
|
|
16
|
+
"""
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
import pytest
|
|
21
|
+
from playwright.sync_api import Page, expect
|
|
22
|
+
|
|
23
|
+
from .conftest import _assert_stays_hidden, _wait_until, _when, call_helper, take_screenshot_with_bbox, take_fillform_frames, take_fillform_progressive_frames, _safe_bbox, _safe_bbox_scrolled, _safe_bbox_locator, fill_login_credentials, capture, capture_dom, synthetic_click, _resolve_date, _pick_date, _pick_time, _interp, _interp_val, SKIP_ASSERTS, _step_runs, _steppy_mark_step, _steppy_end_step
|
|
24
|
+
{% if tour_replay %}
|
|
25
|
+
from ._steppy_tours import TourProbe, _tour_start, _tour_expect_step, _tour_ensure_advanced, _tour_expect_complete
|
|
26
|
+
{% endif %}
|
|
27
|
+
|
|
28
|
+
SCENARIO = "{{ scenario.slug }}"
|
|
29
|
+
SCENARIO_LAYOUTS = {% if scenario.layouts %}{{ scenario.layouts | tojson }}{% else %}None{% endif %}
|
|
30
|
+
{% macro disambig_locator(role, selector, step) -%}
|
|
31
|
+
{{ role }}_page{% if step.within %}.locator(_interp({{ step.within | pystr }}, _vars)){% endif %}.locator(_interp({{ selector | pystr }}, _vars)){{ step.match | format_match_suffix }}
|
|
32
|
+
{%- endmacro %}
|
|
33
|
+
{#
|
|
34
|
+
One screenshot capture, guarded.
|
|
35
|
+
|
|
36
|
+
Every interactive action repeats the same scaffold: capture `_bbox`, emit a
|
|
37
|
+
pre-action shot when `screenshot-when: before`, run the action, emit a
|
|
38
|
+
post-action shot otherwise. This macro owns the guard and the kwargs so a new
|
|
39
|
+
action writes two `shot(...)` lines instead of copy-pasting eight.
|
|
40
|
+
|
|
41
|
+
`when` is "before" or "after" and is matched against the step's
|
|
42
|
+
`screenshot-when`. `selector`/`bbox`/`highlight` control the optional kwargs —
|
|
43
|
+
omitted entirely when falsy, which is what keeps output byte-identical for
|
|
44
|
+
the actions that never passed them.
|
|
45
|
+
#}
|
|
46
|
+
{% macro shot(step, action, when, selector=None, bbox=False, highlight=False) -%}
|
|
47
|
+
{% if step | emits_screenshot and ((when == "before") == (step.screenshot_when == "before")) %}
|
|
48
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "{{ action }}"{% if selector is not none %}, selector={{ selector | pystr }}{% endif %}{% if bbox %}, precaptured_bbox=_bbox{% endif %}{% if highlight %}, highlight=True{% endif %})
|
|
49
|
+
{% endif %}
|
|
50
|
+
{%- endmacro %}
|
|
51
|
+
{#
|
|
52
|
+
The assertion body, shared by the two places an `expect:` can appear:
|
|
53
|
+
a standalone `- action: assert` step, and an `expect:` riding on another
|
|
54
|
+
action (click, fill, …). Both emit the same 13 assertion types; only the
|
|
55
|
+
locator differs, so each call site passes its own `loc` in.
|
|
56
|
+
|
|
57
|
+
IMPORTANT: the trailing-expect site must pass the plain locator, NOT
|
|
58
|
+
disambig_locator's output. `within:`/`match:` disambiguate the *action's*
|
|
59
|
+
target; routing the assertion through them would silently retarget the
|
|
60
|
+
assertion of any step carrying both `within:` and `expect:`.
|
|
61
|
+
|
|
62
|
+
`unknown_comment` reproduces the standalone site's extra comment line on the
|
|
63
|
+
fallback branch.
|
|
64
|
+
#}
|
|
65
|
+
{% macro assert_body(role, assertion, loc, timeout, toast_timeout, unknown_comment) -%}
|
|
66
|
+
{% if assertion.type == "toast" %}
|
|
67
|
+
expect({{ role }}_page.locator(_interp({{ ('text="' ~ assertion.message ~ '"') | pystr }}, _vars)){% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_visible(timeout={{ toast_timeout }})
|
|
68
|
+
{% elif assertion.type == "visible" %}
|
|
69
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_visible(timeout={{ timeout }})
|
|
70
|
+
{% elif assertion.type == "hidden" %}
|
|
71
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_hidden(timeout={{ timeout }})
|
|
72
|
+
{% elif assertion.type == "text" %}
|
|
73
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_have_text(_interp({{ assertion.text | pystr }}, _vars), timeout={{ timeout }})
|
|
74
|
+
{% elif assertion.type == "contains" %}
|
|
75
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_contain_text(_interp({{ assertion.text | pystr }}, _vars), timeout={{ timeout }})
|
|
76
|
+
{% elif assertion.type == "url-contains" %}
|
|
77
|
+
expect({{ role }}_page{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_have_url(re.compile(r".*" + {{ assertion.path | pystr }} + r".*"), timeout={{ timeout }})
|
|
78
|
+
{% elif assertion.type == "count" %}
|
|
79
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_have_count({{ assertion.count }}, timeout={{ timeout }})
|
|
80
|
+
{% elif assertion.type == "count-cmp" %}
|
|
81
|
+
_n = {{ loc }}.count()
|
|
82
|
+
{% if assertion.fail_message %}
|
|
83
|
+
assert _n {{ assertion.comparator }} {{ assertion.count }}, _interp({{ assertion.fail_message | pystr }}, _vars) + f" (count was {_n})"
|
|
84
|
+
{% else %}
|
|
85
|
+
assert _n {{ assertion.comparator }} {{ assertion.count }}, f"expected count {{ assertion.comparator }} {{ assertion.count }}, got {_n}"
|
|
86
|
+
{% endif %}
|
|
87
|
+
{% elif assertion.type == "row-contains" %}
|
|
88
|
+
expect({{ role }}_page.locator(_interp({{ ('text="' ~ assertion.text ~ '"') | pystr }}, _vars)){% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_visible(timeout={{ timeout }})
|
|
89
|
+
{% elif assertion.type == "value" %}
|
|
90
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_have_value(_interp({{ assertion.text | pystr }}, _vars), timeout={{ timeout }})
|
|
91
|
+
{% elif assertion.type == "attribute" %}
|
|
92
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_have_attribute({{ assertion.attribute | pystr }}, _interp({{ assertion.text | pystr }}, _vars), timeout={{ timeout }})
|
|
93
|
+
{% elif assertion.type == "not-contains" %}
|
|
94
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).not_to_contain_text(_interp({{ assertion.text | pystr }}, _vars), timeout={{ timeout }})
|
|
95
|
+
{% elif assertion.type == "not-visible" %}
|
|
96
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).not_to_be_visible(timeout={{ timeout }})
|
|
97
|
+
{% elif assertion.type == "icontains" %}
|
|
98
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_contain_text(re.compile(re.escape(_interp({{ assertion.text | pystr }}, _vars)), re.IGNORECASE), timeout={{ timeout }})
|
|
99
|
+
{% elif assertion.type == "checked" %}
|
|
100
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_checked(timeout={{ timeout }})
|
|
101
|
+
{% elif assertion.type == "unchecked" %}
|
|
102
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_checked(checked=False, timeout={{ timeout }})
|
|
103
|
+
{% elif assertion.type == "stays-hidden" %}
|
|
104
|
+
_assert_stays_hidden({{ loc }}, {{ assertion.duration | timeout_ms }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %})
|
|
105
|
+
{% elif assertion.type == "not-empty" %}
|
|
106
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).not_to_be_empty(timeout={{ timeout }})
|
|
107
|
+
{% elif assertion.type == "disabled" %}
|
|
108
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_disabled(timeout={{ timeout }})
|
|
109
|
+
{% elif assertion.type == "enabled" %}
|
|
110
|
+
expect({{ loc }}{% if assertion.fail_message %}, message=_interp({{ assertion.fail_message | pystr }}, _vars){% endif %}).to_be_enabled(timeout={{ timeout }})
|
|
111
|
+
{% else %}
|
|
112
|
+
{% if unknown_comment %}
|
|
113
|
+
# Unknown assertion: {{ unknown_comment }}
|
|
114
|
+
{% endif %}
|
|
115
|
+
pass
|
|
116
|
+
{% endif %}
|
|
117
|
+
{%- endmacro %}
|
|
118
|
+
{#
|
|
119
|
+
The code for one step, shared by the scenario test and (for opted-in
|
|
120
|
+
scenarios) the in-app tour replay, so the two can never diverge. Renders
|
|
121
|
+
exactly what the former inline `{% set body %}` block rendered.
|
|
122
|
+
#}
|
|
123
|
+
{% macro step_body(step) %}
|
|
124
|
+
{% if step.action == "navigate" %}
|
|
125
|
+
{% if step.reload %}
|
|
126
|
+
{{ step.role }}_page.reload({{ step | call_kwargs(scenario, config, False) }})
|
|
127
|
+
{% else %}
|
|
128
|
+
{{ step.role }}_page.goto(_interp({{ step.target | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
129
|
+
{% endif %}
|
|
130
|
+
{% set _nav_wait = step.wait or config.timing.navigate_wait %}
|
|
131
|
+
{% if _nav_wait != "none" %}
|
|
132
|
+
{{ step.role }}_page.wait_for_load_state("{{ _nav_wait }}")
|
|
133
|
+
{% endif %}
|
|
134
|
+
{% if step | emits_screenshot %}
|
|
135
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "navigate")
|
|
136
|
+
{% endif %}
|
|
137
|
+
|
|
138
|
+
{%- elif step.action == "click" %}
|
|
139
|
+
{% if step | emits_screenshot %}
|
|
140
|
+
{% if step.within or step.match %}
|
|
141
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
142
|
+
{% else %}
|
|
143
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
144
|
+
{% endif %}
|
|
145
|
+
{{ shot(step, "click", "before", step.selector, True, True) -}}
|
|
146
|
+
{% endif %}
|
|
147
|
+
{% if step.synthetic %}
|
|
148
|
+
{% if step.within or step.match %}
|
|
149
|
+
synthetic_click({{ disambig_locator(step.role, step.selector, step) }})
|
|
150
|
+
{% else %}
|
|
151
|
+
synthetic_click({{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)))
|
|
152
|
+
{% endif %}
|
|
153
|
+
{% else %}
|
|
154
|
+
{% if step.within or step.match %}
|
|
155
|
+
{{ disambig_locator(step.role, step.selector, step) }}.click({{ step | call_kwargs(scenario, config, False) }})
|
|
156
|
+
{% else %}
|
|
157
|
+
{{ step.role }}_page.click(_interp({{ step.selector | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
158
|
+
{% endif %}
|
|
159
|
+
{% endif %}
|
|
160
|
+
{% if step.wait_after %}
|
|
161
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
162
|
+
{% endif %}
|
|
163
|
+
{{ shot(step, "click", "after", step.selector, True, True) -}}
|
|
164
|
+
|
|
165
|
+
{%- elif step.action == "login" %}
|
|
166
|
+
fill_login_credentials({{ step.role }}_page, "{{ step.role }}")
|
|
167
|
+
{% if step.wait_after %}
|
|
168
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
169
|
+
{% endif %}
|
|
170
|
+
|
|
171
|
+
{%- elif step.action == "fill" %}
|
|
172
|
+
{% if step | emits_screenshot %}
|
|
173
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
174
|
+
{{ shot(step, "fill", "before", step.selector, True, False) -}}
|
|
175
|
+
{% endif %}
|
|
176
|
+
{% if step.clear or step.sequential or step.blur_key %}
|
|
177
|
+
{% if step.within or step.match %}
|
|
178
|
+
_el = {{ disambig_locator(step.role, step.selector, step) }}
|
|
179
|
+
{% else %}
|
|
180
|
+
_el = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars))
|
|
181
|
+
{% endif %}
|
|
182
|
+
_el.click()
|
|
183
|
+
{% if step.clear %}
|
|
184
|
+
_el.press("ControlOrMeta+A")
|
|
185
|
+
_el.press("Delete")
|
|
186
|
+
{% endif %}
|
|
187
|
+
{% if step.sequential %}
|
|
188
|
+
_el.press_sequentially(_interp({{ step.value | pystr }}, _vars), delay=50)
|
|
189
|
+
{% else %}
|
|
190
|
+
_el.fill(_interp({{ step.value | pystr }}, _vars))
|
|
191
|
+
{% endif %}
|
|
192
|
+
{% if step.blur_key %}
|
|
193
|
+
{{ step.role }}_page.keyboard.press({{ step.blur_key | pystr }})
|
|
194
|
+
{% endif %}
|
|
195
|
+
{% else %}
|
|
196
|
+
{% if step.within or step.match %}
|
|
197
|
+
{{ disambig_locator(step.role, step.selector, step) }}.fill(_interp_val({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
198
|
+
{% else %}
|
|
199
|
+
{{ step.role }}_page.fill(_interp({{ step.selector | pystr }}, _vars), _interp_val({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
200
|
+
{% endif %}
|
|
201
|
+
{% endif %}
|
|
202
|
+
{% if step.wait_after %}
|
|
203
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
204
|
+
{% endif %}
|
|
205
|
+
{{ shot(step, "fill", "after", step.selector, True, False) -}}
|
|
206
|
+
|
|
207
|
+
{%- elif step.action == "fill-form" %}
|
|
208
|
+
{% if step.interactive_progressive %}
|
|
209
|
+
{# Progressive capture: the helper fills fields itself, one at a time, with a
|
|
210
|
+
single-target cursor sidecar per frame. Emits one frame per field plus a
|
|
211
|
+
final settled "all filled" frame. Always captures (the helper interleaves
|
|
212
|
+
capture + fill internally). screenshot-when is ignored here — progressive
|
|
213
|
+
capture is inherently before+after on every field. #}
|
|
214
|
+
{% if step | emits_screenshot %}
|
|
215
|
+
_form_fields = [{% for selector, value in step.fields.items() %}(_interp({{ selector | pystr }}, _vars), _interp({{ value | pystr }}, _vars)){% if not loop.last %}, {% endif %}{% endfor %}]
|
|
216
|
+
take_fillform_progressive_frames({{ step.role }}_page, SCENARIO, {{ step.step_number }}, _form_fields)
|
|
217
|
+
{% else %}
|
|
218
|
+
{# Even without screenshots, the page still needs the fields filled. #}
|
|
219
|
+
{% for selector, value in step.fields.items() %}
|
|
220
|
+
{{ step.role }}_page.fill(_interp({{ selector | pystr }}, _vars), _interp({{ value | pystr }}, _vars))
|
|
221
|
+
{% endfor %}
|
|
222
|
+
{% endif %}
|
|
223
|
+
{% if step.wait_after %}
|
|
224
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
225
|
+
{% endif %}
|
|
226
|
+
{% else %}
|
|
227
|
+
{% if step | emits_screenshot %}
|
|
228
|
+
_form_selectors = [{% for selector in step.fields.keys() %}_interp({{ selector | pystr }}, _vars){% if not loop.last %}, {% endif %}{% endfor %}]
|
|
229
|
+
{% if step.screenshot_when == "before" %}
|
|
230
|
+
# Capture the empty form BEFORE filling, so the guide cursor animates over
|
|
231
|
+
# pristine fields rather than pre-filled values.
|
|
232
|
+
take_fillform_frames({{ step.role }}_page, SCENARIO, {{ step.step_number }}, _form_selectors)
|
|
233
|
+
{% endif %}
|
|
234
|
+
{% endif %}
|
|
235
|
+
{% for selector, value in step.fields.items() %}
|
|
236
|
+
{{ step.role }}_page.fill(_interp({{ selector | pystr }}, _vars), _interp({{ value | pystr }}, _vars))
|
|
237
|
+
{% endfor %}
|
|
238
|
+
{% if step.wait_after %}
|
|
239
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
240
|
+
{% endif %}
|
|
241
|
+
{% if step | emits_screenshot and step.screenshot_when != "before" %}
|
|
242
|
+
# Take as many frames as the form height requires so every field ends up
|
|
243
|
+
# fully in-viewport in at least one screenshot. The helper auto-detects
|
|
244
|
+
# coverage and emits frames -a, -b, -c, …
|
|
245
|
+
take_fillform_frames({{ step.role }}_page, SCENARIO, {{ step.step_number }}, _form_selectors)
|
|
246
|
+
{% endif %}
|
|
247
|
+
{% endif %}
|
|
248
|
+
|
|
249
|
+
{%- elif step.action == "select" %}
|
|
250
|
+
{% if step | emits_screenshot %}
|
|
251
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
252
|
+
{{ shot(step, "select", "before", step.selector, True, False) -}}
|
|
253
|
+
{% endif %}
|
|
254
|
+
{% if step.within or step.match %}
|
|
255
|
+
{{ disambig_locator(step.role, step.selector, step) }}.select_option(_interp_val({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
256
|
+
{% else %}
|
|
257
|
+
{{ step.role }}_page.select_option(_interp({{ step.selector | pystr }}, _vars), _interp_val({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
258
|
+
{% endif %}
|
|
259
|
+
{% if step.wait_after %}
|
|
260
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
261
|
+
{% endif %}
|
|
262
|
+
{{ shot(step, "select", "after", step.selector, True, False) -}}
|
|
263
|
+
|
|
264
|
+
{%- elif step.action == "select-option" %}
|
|
265
|
+
{% if step | emits_screenshot %}
|
|
266
|
+
{% if step.within or step.match %}
|
|
267
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
268
|
+
{% else %}
|
|
269
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
270
|
+
{% endif %}
|
|
271
|
+
{{ shot(step, "select-option", "before", step.selector, True, True) -}}
|
|
272
|
+
{% endif %}
|
|
273
|
+
{% if step.within or step.match %}
|
|
274
|
+
{{ disambig_locator(step.role, step.selector, step) }}.click({{ step | call_kwargs(scenario, config, False) }})
|
|
275
|
+
{% else %}
|
|
276
|
+
{{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).click({{ step | call_kwargs(scenario, config, False) }})
|
|
277
|
+
{% endif %}
|
|
278
|
+
{{ step.role }}_page.wait_for_selector({{ config.dropdown.overlay | pystr }}, state='visible', timeout={{ step | resolve_timeout(scenario, config) }})
|
|
279
|
+
{% if step.search %}
|
|
280
|
+
{{ step.role }}_page.locator({{ config.dropdown.filter | pystr }}).fill(_interp({{ step.search | pystr }}, _vars))
|
|
281
|
+
{% if config.dropdown.settle == "api" %}
|
|
282
|
+
{{ step.role }}_page.wait_for_load_state("networkidle")
|
|
283
|
+
{% endif %}
|
|
284
|
+
{% endif %}
|
|
285
|
+
{% if step.multiple %}
|
|
286
|
+
{% for label in step.multiple %}
|
|
287
|
+
{{ step.role }}_page.locator({{ config.dropdown.overlay | pystr }}).locator({{ config.dropdown.option | pystr }}).filter(has_text=_interp({{ label | pystr }}, _vars)).first.click(force=True)
|
|
288
|
+
{% endfor %}
|
|
289
|
+
{{ step.role }}_page.keyboard.press("Escape")
|
|
290
|
+
{% else %}
|
|
291
|
+
{% set opt = step.option | format_dropdown_option %}
|
|
292
|
+
{% if opt.kind == "first" %}
|
|
293
|
+
{{ step.role }}_page.locator({{ config.dropdown.overlay | pystr }}).locator({{ config.dropdown.option | pystr }}).first.click(force=True)
|
|
294
|
+
{% elif opt.kind == "last" %}
|
|
295
|
+
{{ step.role }}_page.locator({{ config.dropdown.overlay | pystr }}).locator({{ config.dropdown.option | pystr }}).last.click(force=True)
|
|
296
|
+
{% elif opt.kind == "nth" %}
|
|
297
|
+
{{ step.role }}_page.locator({{ config.dropdown.overlay | pystr }}).locator({{ config.dropdown.option | pystr }}).nth({{ opt.index }}).click(force=True)
|
|
298
|
+
{% else %}
|
|
299
|
+
{{ step.role }}_page.locator({{ config.dropdown.overlay | pystr }}).locator({{ config.dropdown.option | pystr }}).filter(has_text=_interp({{ opt.text | pystr }}, _vars)).first.click(force=True)
|
|
300
|
+
{% endif %}
|
|
301
|
+
{% endif %}
|
|
302
|
+
{% if step.wait_after %}
|
|
303
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
304
|
+
{% endif %}
|
|
305
|
+
{{ shot(step, "select-option", "after", step.selector, True, True) -}}
|
|
306
|
+
|
|
307
|
+
{%- elif step.action == "confirm" %}
|
|
308
|
+
_overlay = {{ step.role }}_page.locator({{ config.dialog.overlay | pystr }}).last
|
|
309
|
+
_overlay.wait_for(state='visible', timeout={{ step | resolve_timeout(scenario, config) }})
|
|
310
|
+
{% if step.button %}
|
|
311
|
+
_confirm_btn = _overlay.locator('button').filter(has_text=_interp({{ step.button | pystr }}, _vars)).first
|
|
312
|
+
{% elif step.variant == "reject" %}
|
|
313
|
+
_confirm_btn = _overlay.locator({{ config.dialog.reject | pystr }}).first
|
|
314
|
+
{% elif step.danger %}
|
|
315
|
+
_confirm_btn = _overlay.locator({{ config.dialog.danger | pystr }}).first
|
|
316
|
+
{% else %}
|
|
317
|
+
_confirm_btn = _overlay.locator({{ config.dialog.accept | pystr }}).first
|
|
318
|
+
{% endif %}
|
|
319
|
+
{% if step | emits_screenshot %}
|
|
320
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, _confirm_btn)
|
|
321
|
+
{{ shot(step, "confirm", "before", None, True, True) -}}
|
|
322
|
+
{% endif %}
|
|
323
|
+
_confirm_btn.click({{ step | call_kwargs(scenario, config, False) }})
|
|
324
|
+
{% if step.wait_after %}
|
|
325
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
326
|
+
{% endif %}
|
|
327
|
+
{{ shot(step, "confirm", "after", None, True, True) -}}
|
|
328
|
+
|
|
329
|
+
{%- elif step.action == "fill-date" %}
|
|
330
|
+
{% if step | emits_screenshot %}
|
|
331
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
332
|
+
{{ shot(step, "fill-date", "before", step.selector, True, False) -}}
|
|
333
|
+
{% endif %}
|
|
334
|
+
_date = _resolve_date({{ step.date | pystr }}, {{ (step.format or "%d/%m/%Y") | pystr }})
|
|
335
|
+
_el = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars))
|
|
336
|
+
_el.click()
|
|
337
|
+
_el.fill('')
|
|
338
|
+
_el.press_sequentially(_date, delay=50)
|
|
339
|
+
{{ step.role }}_page.keyboard.press('Tab')
|
|
340
|
+
{% if step.wait_after %}
|
|
341
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
342
|
+
{% endif %}
|
|
343
|
+
{{ shot(step, "fill-date", "after", step.selector, True, False) -}}
|
|
344
|
+
|
|
345
|
+
{%- elif step.action == "toggle" %}
|
|
346
|
+
{% if step | emits_screenshot %}
|
|
347
|
+
{% if step.within or step.match %}
|
|
348
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
349
|
+
{% else %}
|
|
350
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
351
|
+
{% endif %}
|
|
352
|
+
{{ shot(step, "toggle", "before", step.selector, True, True) -}}
|
|
353
|
+
{% endif %}
|
|
354
|
+
{% if step.option %}
|
|
355
|
+
{% if step.within or step.match %}
|
|
356
|
+
{{ disambig_locator(step.role, step.selector, step) }}.locator({{ config.toggle.segment | pystr }}).filter(has_text=_interp({{ step.option | pystr }}, _vars)).first.click()
|
|
357
|
+
{% else %}
|
|
358
|
+
{{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).locator({{ config.toggle.segment | pystr }}).filter(has_text=_interp({{ step.option | pystr }}, _vars)).first.click()
|
|
359
|
+
{% endif %}
|
|
360
|
+
{% else %}
|
|
361
|
+
{% if step.control == "self" %}
|
|
362
|
+
_switch = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars))
|
|
363
|
+
{% elif step.control %}
|
|
364
|
+
_switch = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).locator(_interp({{ step.control | pystr }}, _vars))
|
|
365
|
+
{% else %}
|
|
366
|
+
{% if step.within or step.match %}
|
|
367
|
+
_switch = {{ disambig_locator(step.role, step.selector, step) }}.locator({{ config.toggle.switch | pystr }})
|
|
368
|
+
{% else %}
|
|
369
|
+
_switch = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).locator({{ config.toggle.switch | pystr }})
|
|
370
|
+
{% endif %}
|
|
371
|
+
{% endif %}
|
|
372
|
+
{% if step.state == "on" %}
|
|
373
|
+
if not _switch.is_checked():
|
|
374
|
+
_switch.check(force=True)
|
|
375
|
+
{% elif step.state == "off" %}
|
|
376
|
+
if _switch.is_checked():
|
|
377
|
+
_switch.uncheck(force=True)
|
|
378
|
+
{% else %}
|
|
379
|
+
_switch.click(force=True)
|
|
380
|
+
{% endif %}
|
|
381
|
+
{% endif %}
|
|
382
|
+
{% if step.wait_after %}
|
|
383
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
384
|
+
{% endif %}
|
|
385
|
+
{{ shot(step, "toggle", "after", step.selector, True, True) -}}
|
|
386
|
+
|
|
387
|
+
{%- elif step.action == "expand" %}
|
|
388
|
+
{% if step.within or step.match %}
|
|
389
|
+
_fieldset = {{ disambig_locator(step.role, step.selector, step) }}
|
|
390
|
+
{% else %}
|
|
391
|
+
_fieldset = {{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars))
|
|
392
|
+
{% endif %}
|
|
393
|
+
{% if step.control == "self" %}
|
|
394
|
+
_toggle = _fieldset.first
|
|
395
|
+
{% elif step.control %}
|
|
396
|
+
_toggle = _fieldset.locator(_interp({{ step.control | pystr }}, _vars)).first
|
|
397
|
+
{% else %}
|
|
398
|
+
_toggle = _fieldset.locator({{ config.fieldset.toggle | pystr }}).first
|
|
399
|
+
{% endif %}
|
|
400
|
+
{% if step.detect != "aria" %}
|
|
401
|
+
_content = _fieldset.locator({{ config.fieldset.content | pystr }}).first
|
|
402
|
+
{% endif %}
|
|
403
|
+
{% if step | emits_screenshot %}
|
|
404
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, _toggle)
|
|
405
|
+
{{ shot(step, "expand", "before", None, True, True) -}}
|
|
406
|
+
{% endif %}
|
|
407
|
+
{# Four shapes, one per (detect, state) pair. `detect: aria` reads the
|
|
408
|
+
disclosure button's own aria-expanded — what a project's `ensure_open`
|
|
409
|
+
helper reads — because the content element can be present but clipped, or
|
|
410
|
+
animating, and `is_visible()` then disagrees with the widget. `state: off`
|
|
411
|
+
collapses instead of expanding. Neither field set emits the original three
|
|
412
|
+
lines verbatim, so existing scenarios regenerate byte-identically. #}
|
|
413
|
+
{% if step.detect == "aria" %}
|
|
414
|
+
_toggle.wait_for(state="visible", timeout={{ step | resolve_timeout(scenario, config) }})
|
|
415
|
+
{% if step.state == "off" %}
|
|
416
|
+
if (_toggle.get_attribute("aria-expanded") or "false") == "true":
|
|
417
|
+
_toggle.click()
|
|
418
|
+
{% else %}
|
|
419
|
+
if (_toggle.get_attribute("aria-expanded") or "false") != "true":
|
|
420
|
+
_toggle.click()
|
|
421
|
+
{% endif %}
|
|
422
|
+
{% elif step.state == "off" %}
|
|
423
|
+
if _content.is_visible():
|
|
424
|
+
_toggle.click()
|
|
425
|
+
_content.wait_for(state="hidden", timeout={{ step | resolve_timeout(scenario, config) }})
|
|
426
|
+
{% else %}
|
|
427
|
+
if not _content.is_visible():
|
|
428
|
+
_toggle.click()
|
|
429
|
+
_content.wait_for(state="visible", timeout={{ step | resolve_timeout(scenario, config) }})
|
|
430
|
+
{% endif %}
|
|
431
|
+
{% if step.wait_after %}
|
|
432
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
433
|
+
{% endif %}
|
|
434
|
+
{{ shot(step, "expand", "after", None, True, True) -}}
|
|
435
|
+
|
|
436
|
+
{%- elif step.action == "pick-date" %}
|
|
437
|
+
{% if step | emits_screenshot %}
|
|
438
|
+
{% if step.within or step.match %}
|
|
439
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
440
|
+
{% else %}
|
|
441
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
442
|
+
{% endif %}
|
|
443
|
+
{{ shot(step, "pick-date", "before", step.selector, True, True) -}}
|
|
444
|
+
{% endif %}
|
|
445
|
+
_pick_date(
|
|
446
|
+
{{ step.role }}_page,
|
|
447
|
+
_interp({{ step | disambig_selector | pystr }}, _vars),
|
|
448
|
+
{{ step.date | pystr }},
|
|
449
|
+
panel={{ config.datepicker.panel | pystr }},
|
|
450
|
+
next_btn={{ config.datepicker.next_button | pystr }},
|
|
451
|
+
prev_btn={{ config.datepicker.prev_button | pystr }},
|
|
452
|
+
day_cell={{ config.datepicker.day_cell | pystr }},
|
|
453
|
+
trigger={{ config.datepicker.trigger | pystr }},
|
|
454
|
+
timeout={{ step | resolve_timeout(scenario, config) }},
|
|
455
|
+
)
|
|
456
|
+
{% if step.wait_after %}
|
|
457
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
458
|
+
{% endif %}
|
|
459
|
+
{{ shot(step, "pick-date", "after", step.selector, True, True) -}}
|
|
460
|
+
|
|
461
|
+
{%- elif step.action == "pick-time" %}
|
|
462
|
+
{% if step | emits_screenshot %}
|
|
463
|
+
{% if step.within or step.match %}
|
|
464
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
465
|
+
{% else %}
|
|
466
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
467
|
+
{% endif %}
|
|
468
|
+
{{ shot(step, "pick-time", "before", step.selector, True, True) -}}
|
|
469
|
+
{% endif %}
|
|
470
|
+
_pick_time(
|
|
471
|
+
{{ step.role }}_page,
|
|
472
|
+
_interp({{ step | disambig_selector | pystr }}, _vars),
|
|
473
|
+
{{ step.time | pystr }},
|
|
474
|
+
panel={{ config.datepicker.panel | pystr }},
|
|
475
|
+
hour_picker={{ config.datepicker.hour_picker | pystr }},
|
|
476
|
+
minute_picker={{ config.datepicker.minute_picker | pystr }},
|
|
477
|
+
trigger={{ config.datepicker.trigger | pystr }},
|
|
478
|
+
timeout={{ step | resolve_timeout(scenario, config) }},
|
|
479
|
+
)
|
|
480
|
+
{% if step.wait_after %}
|
|
481
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
482
|
+
{% endif %}
|
|
483
|
+
{{ shot(step, "pick-time", "after", step.selector, True, True) -}}
|
|
484
|
+
|
|
485
|
+
{%- elif step.action == "wait" %}
|
|
486
|
+
time.sleep({{ step.duration | format_wait_duration }}){% if step.note %} # {{ step.note }}{% endif %}
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
{%- elif step.action == "wait-for" %}
|
|
490
|
+
{% if step.until %}
|
|
491
|
+
{# A state predicate, not mere DOM presence. `_wait_until` polls through the
|
|
492
|
+
locator so every selector engine keeps working. #}
|
|
493
|
+
_wait_until({{ disambig_locator(step.role, step.selector, step) }}, {{ step.until | pystr }}, {{ step | resolve_timeout(scenario, config) }}{% if step.interval %}, interval={{ step.interval | timeout_ms }}{% endif %})
|
|
494
|
+
{% elif step.within or step.match %}
|
|
495
|
+
{{ disambig_locator(step.role, step.selector, step) }}.wait_for(timeout={{ step | resolve_timeout(scenario, config) }}{% if step.state %}, state={{ step.state | pystr }}{% endif %})
|
|
496
|
+
{% else %}
|
|
497
|
+
{{ step.role }}_page.wait_for_selector(_interp({{ step.selector | pystr }}, _vars), timeout={{ step | resolve_timeout(scenario, config) }}{% if step.state %}, state={{ step.state | pystr }}{% endif %})
|
|
498
|
+
{% endif %}
|
|
499
|
+
{% if step.wait %}
|
|
500
|
+
{{ step.role }}_page.wait_for_load_state({{ step.wait | pystr }})
|
|
501
|
+
{% endif %}
|
|
502
|
+
{% if step.wait_after %}
|
|
503
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
504
|
+
{% endif %}
|
|
505
|
+
{% if step | emits_screenshot %}
|
|
506
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "wait-for")
|
|
507
|
+
{% endif %}
|
|
508
|
+
|
|
509
|
+
{%- elif step.action == "wait-for-text" %}
|
|
510
|
+
{{ step.role }}_page.wait_for_selector(_interp({{ ('text="' ~ step.value ~ '"') | pystr }}, _vars), timeout={{ step | resolve_timeout(scenario, config) }})
|
|
511
|
+
{% if step | emits_screenshot %}
|
|
512
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "wait-for-text")
|
|
513
|
+
{% endif %}
|
|
514
|
+
|
|
515
|
+
{%- elif step.action == "scroll" %}
|
|
516
|
+
{% if step.within or step.match %}
|
|
517
|
+
{{ disambig_locator(step.role, step.selector, step) }}.scroll_into_view_if_needed({{ step | call_kwargs(scenario, config, False) }})
|
|
518
|
+
{% else %}
|
|
519
|
+
{{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).scroll_into_view_if_needed({{ step | call_kwargs(scenario, config, False) }})
|
|
520
|
+
{% endif %}
|
|
521
|
+
{% if step | emits_screenshot %}
|
|
522
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
523
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "scroll", selector={{ step.selector | pystr }}, precaptured_bbox=_bbox)
|
|
524
|
+
{% endif %}
|
|
525
|
+
|
|
526
|
+
{%- elif step.action == "hover" %}
|
|
527
|
+
{% if step | emits_screenshot %}
|
|
528
|
+
{% if step.within or step.match %}
|
|
529
|
+
_bbox = _safe_bbox_locator({{ step.role }}_page, {{ disambig_locator(step.role, step.selector, step) }})
|
|
530
|
+
{% else %}
|
|
531
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
532
|
+
{% endif %}
|
|
533
|
+
{{ shot(step, "hover", "before", step.selector, True, False) -}}
|
|
534
|
+
{% endif %}
|
|
535
|
+
{% if step.within or step.match %}
|
|
536
|
+
{{ disambig_locator(step.role, step.selector, step) }}.hover({{ step | call_kwargs(scenario, config, False) }})
|
|
537
|
+
{% else %}
|
|
538
|
+
{{ step.role }}_page.hover(_interp({{ step.selector | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
539
|
+
{% endif %}
|
|
540
|
+
{% if step.wait_after %}
|
|
541
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
542
|
+
{% endif %}
|
|
543
|
+
{{ shot(step, "hover", "after", step.selector, True, False) -}}
|
|
544
|
+
|
|
545
|
+
{%- elif step.action == "upload" %}
|
|
546
|
+
{% if step | emits_screenshot %}
|
|
547
|
+
{# When the <input type=file> is visually hidden behind a styled button or
|
|
548
|
+
drop zone, the scenario sets guide-cursor.target to the visible trigger so
|
|
549
|
+
bbox + cursor land on something the reader can actually see. #}
|
|
550
|
+
{% if step.guide_cursor %}
|
|
551
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.guide_cursor.target | pystr }}, _vars))
|
|
552
|
+
{% else %}
|
|
553
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.selector | pystr }}, _vars))
|
|
554
|
+
{% endif %}
|
|
555
|
+
{{ shot(step, "upload", "before", (step.guide_cursor.target if step.guide_cursor else step.selector), True, False) -}}
|
|
556
|
+
{% endif %}
|
|
557
|
+
{% if step.within or step.match %}
|
|
558
|
+
{{ disambig_locator(step.role, step.selector, step) }}.set_input_files(_interp({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
559
|
+
{% else %}
|
|
560
|
+
{{ step.role }}_page.locator(_interp({{ step.selector | pystr }}, _vars)).set_input_files(_interp({{ step.value | pystr }}, _vars){{ step | call_kwargs(scenario, config) }})
|
|
561
|
+
{% endif %}
|
|
562
|
+
{% if step.wait_after %}
|
|
563
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
564
|
+
{% endif %}
|
|
565
|
+
{{ shot(step, "upload", "after", (step.guide_cursor.target if step.guide_cursor else step.selector), True, False) -}}
|
|
566
|
+
|
|
567
|
+
{%- elif step.action == "keyboard" %}
|
|
568
|
+
{{ step.role }}_page.keyboard.press({{ step.value | pystr }})
|
|
569
|
+
{% if step | emits_screenshot %}
|
|
570
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "keyboard")
|
|
571
|
+
{% endif %}
|
|
572
|
+
|
|
573
|
+
{%- elif step.action == "assert" %}
|
|
574
|
+
if not SKIP_ASSERTS:
|
|
575
|
+
{% for assertion in step.expect | format_assertions %}
|
|
576
|
+
{% set _aloc %}{{ disambig_locator(step.role, assertion.selector, step) }}{% endset %}
|
|
577
|
+
{{ assert_body(step.role, assertion, _aloc, step | resolve_timeout(scenario, config), step | resolve_timeout(scenario, config, 5000), assertion.raw) -}}
|
|
578
|
+
{% endfor %}
|
|
579
|
+
{% if step.wait_after %}
|
|
580
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
581
|
+
{% endif %}
|
|
582
|
+
{% if step | emits_screenshot %}
|
|
583
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "assert")
|
|
584
|
+
{% endif %}
|
|
585
|
+
|
|
586
|
+
{%- elif step.action == "capture" %}
|
|
587
|
+
{% if step.from_.type == "dom" %}
|
|
588
|
+
_vars[{{ step.store | pystr }}] = capture_dom(
|
|
589
|
+
{{ step.role }}_page,
|
|
590
|
+
{{ step.from_ | tojson }},
|
|
591
|
+
_vars,
|
|
592
|
+
retry={{ step.retry or 1 }},
|
|
593
|
+
interval={{ (step.interval or "200ms") | format_wait_duration }},
|
|
594
|
+
)
|
|
595
|
+
{% else %}
|
|
596
|
+
_vars[{{ step.store | pystr }}] = capture(
|
|
597
|
+
{{ step.from_ | tojson }},
|
|
598
|
+
_vars,
|
|
599
|
+
retry={{ step.retry or 1 }},
|
|
600
|
+
interval={{ (step.interval or "200ms") | format_wait_duration }},
|
|
601
|
+
)
|
|
602
|
+
{% endif %}
|
|
603
|
+
|
|
604
|
+
{%- elif step.action == "call" %}
|
|
605
|
+
{% if step | emits_screenshot and step.guide_cursor %}
|
|
606
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.guide_cursor.target | pystr }}, _vars))
|
|
607
|
+
{% endif %}
|
|
608
|
+
{% if step | emits_screenshot and step.screenshot_when == "before" %}
|
|
609
|
+
{% if step.guide_cursor %}
|
|
610
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "call", selector={{ step.guide_cursor.target | pystr }}, precaptured_bbox=_bbox)
|
|
611
|
+
{% else %}
|
|
612
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "call")
|
|
613
|
+
{% endif %}
|
|
614
|
+
{% endif %}
|
|
615
|
+
{# `lstrip_blocks` eats the leading indent when a line starts with a tag, so
|
|
616
|
+
the two forms are spelled out rather than sharing a prefix. #}
|
|
617
|
+
{% if step.store %}
|
|
618
|
+
_vars[{{ step.store | pystr }}] = call_helper(
|
|
619
|
+
{% else %}
|
|
620
|
+
call_helper(
|
|
621
|
+
{% endif %}
|
|
622
|
+
{{ step.role }}_page,
|
|
623
|
+
{{ step.fn | pystr }},
|
|
624
|
+
{% if step.args %}
|
|
625
|
+
args={{ step.args | tojson }},
|
|
626
|
+
{% endif %}
|
|
627
|
+
{% if step.kwargs %}
|
|
628
|
+
kwargs={{ step.kwargs | tojson }},
|
|
629
|
+
{% endif %}
|
|
630
|
+
_vars=_vars,
|
|
631
|
+
{% if step.page is false %}
|
|
632
|
+
pass_page=False,
|
|
633
|
+
{% endif %}
|
|
634
|
+
)
|
|
635
|
+
{% if step.wait_after %}
|
|
636
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
637
|
+
{% endif %}
|
|
638
|
+
{% if step | emits_screenshot and step.screenshot_when != "before" %}
|
|
639
|
+
{% if step.guide_cursor %}
|
|
640
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "call", selector={{ step.guide_cursor.target | pystr }}, precaptured_bbox=_bbox)
|
|
641
|
+
{% else %}
|
|
642
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "call")
|
|
643
|
+
{% endif %}
|
|
644
|
+
{% endif %}
|
|
645
|
+
|
|
646
|
+
{%- elif step.action == "custom" %}
|
|
647
|
+
{% if step | emits_screenshot and step.guide_cursor %}
|
|
648
|
+
_bbox = _safe_bbox_scrolled({{ step.role }}_page, _interp({{ step.guide_cursor.target | pystr }}, _vars))
|
|
649
|
+
{% endif %}
|
|
650
|
+
{% if step | emits_screenshot and step.screenshot_when == "before" %}
|
|
651
|
+
{% if step.guide_cursor %}
|
|
652
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "custom", selector={{ step.guide_cursor.target | pystr }}, precaptured_bbox=_bbox)
|
|
653
|
+
{% else %}
|
|
654
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "custom")
|
|
655
|
+
{% endif %}
|
|
656
|
+
{% endif %}
|
|
657
|
+
# Custom code:
|
|
658
|
+
page = {{ step.role }}_page
|
|
659
|
+
{{ step.code | indent(4) }}
|
|
660
|
+
{% if step.wait_after %}
|
|
661
|
+
time.sleep({{ step.wait_after / 1000 }})
|
|
662
|
+
{% endif %}
|
|
663
|
+
{% if step | emits_screenshot and step.screenshot_when != "before" %}
|
|
664
|
+
{% if step.guide_cursor %}
|
|
665
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "custom", selector={{ step.guide_cursor.target | pystr }}, precaptured_bbox=_bbox)
|
|
666
|
+
{% else %}
|
|
667
|
+
take_screenshot_with_bbox({{ step.role }}_page, SCENARIO, {{ step.step_number }}, "custom")
|
|
668
|
+
{% endif %}
|
|
669
|
+
{% endif %}
|
|
670
|
+
{% endif %}
|
|
671
|
+
|
|
672
|
+
{%- if step.expect and step.action != "assert" %}
|
|
673
|
+
{% for assertion in step.expect | format_assertions %}
|
|
674
|
+
# Assert: {{ assertion.type }}
|
|
675
|
+
{% endfor %}
|
|
676
|
+
if not SKIP_ASSERTS:
|
|
677
|
+
{% for assertion in step.expect | format_assertions %}
|
|
678
|
+
{% set _eloc %}{{ step.role }}_page.locator(_interp({{ assertion.selector | pystr }}, _vars)){% endset %}
|
|
679
|
+
{{ assert_body(step.role, assertion, _eloc, step | resolve_timeout(scenario, config), step | resolve_timeout(scenario, config, 5000), None) -}}
|
|
680
|
+
{% endfor %}
|
|
681
|
+
{% endif %}
|
|
682
|
+
{% endmacro %}
|
|
683
|
+
|
|
684
|
+
{% if scenario.seed %}
|
|
685
|
+
|
|
686
|
+
@pytest.fixture(autouse=True)
|
|
687
|
+
def seed():
|
|
688
|
+
"""Run seed script {{ scenario.seed.run }} this scenario."""
|
|
689
|
+
import subprocess
|
|
690
|
+
import sys
|
|
691
|
+
from pathlib import Path
|
|
692
|
+
|
|
693
|
+
seed_file = Path({{ ('seed/' ~ scenario.seed.script ~ '.py') | pystr }})
|
|
694
|
+
|
|
695
|
+
if not seed_file.exists():
|
|
696
|
+
pytest.fail(f'Seed script not found: {seed_file}')
|
|
697
|
+
|
|
698
|
+
def _run_seed():
|
|
699
|
+
try:
|
|
700
|
+
result = subprocess.run(
|
|
701
|
+
[sys.executable, str(seed_file)],
|
|
702
|
+
capture_output=True,
|
|
703
|
+
text=True,
|
|
704
|
+
timeout={{ scenario.seed.timeout }},
|
|
705
|
+
)
|
|
706
|
+
except subprocess.TimeoutExpired:
|
|
707
|
+
pytest.fail(f'Seed script timed out after {{ scenario.seed.timeout }}s: {seed_file}')
|
|
708
|
+
if result.returncode != 0:
|
|
709
|
+
error_msg = f'Seed script failed (exit code {result.returncode})'
|
|
710
|
+
if result.stderr:
|
|
711
|
+
error_msg += f'\nStderr: {result.stderr}'
|
|
712
|
+
if result.stdout:
|
|
713
|
+
error_msg += f'\nStdout: {result.stdout}'
|
|
714
|
+
pytest.fail(error_msg)
|
|
715
|
+
|
|
716
|
+
{% if scenario.seed.run in ("before", "both") %}
|
|
717
|
+
_run_seed()
|
|
718
|
+
{% endif %}
|
|
719
|
+
|
|
720
|
+
yield
|
|
721
|
+
|
|
722
|
+
{% if scenario.seed.run in ("after", "both") %}
|
|
723
|
+
_run_seed()
|
|
724
|
+
{% endif %}
|
|
725
|
+
{% endif %}
|
|
726
|
+
|
|
727
|
+
{% if not scenario.auth %}
|
|
728
|
+
def test_{{ scenario.slug.replace('-', '_') }}(
|
|
729
|
+
{%- for role in roles_needed -%}
|
|
730
|
+
{{ role }}_page_noauth: Page{{ ", " if not loop.last else "" }}
|
|
731
|
+
{%- endfor -%}
|
|
732
|
+
):
|
|
733
|
+
"""{{ scenario.title | docstring }}{% if scenario.description %} — {{ scenario.description | docstring }}{% endif %}."""
|
|
734
|
+
_vars: dict[str, str] = {}
|
|
735
|
+
{% for role in roles_needed %}
|
|
736
|
+
{{ role }}_page = {{ role }}_page_noauth
|
|
737
|
+
{% endfor %}
|
|
738
|
+
{% else %}
|
|
739
|
+
def test_{{ scenario.slug.replace('-', '_') }}(
|
|
740
|
+
{%- for role in roles_needed -%}
|
|
741
|
+
{{ role }}_page: Page{{ ", " if not loop.last else "" }}
|
|
742
|
+
{%- endfor -%}
|
|
743
|
+
):
|
|
744
|
+
"""{{ scenario.title | docstring }}{% if scenario.description %} — {{ scenario.description | docstring }}{% endif %}."""
|
|
745
|
+
_vars: dict[str, str] = {}
|
|
746
|
+
{% endif %}
|
|
747
|
+
{% for step in scenario.steps %}
|
|
748
|
+
|
|
749
|
+
# ── Step {{ step.step_number }}: {{ step.step_title }} ──────────────────────────────────────────────────────
|
|
750
|
+
{#
|
|
751
|
+
`bracket_step` adds the per-step event markers, `apply_step_guard` wraps the
|
|
752
|
+
result in `if _step_runs(...)` when the step is conditional. Order matters:
|
|
753
|
+
the markers must end up INSIDE the guard, so a skipped step emits neither and
|
|
754
|
+
`failed_step` can never name a step that did not run.
|
|
755
|
+
|
|
756
|
+
Both are Python filters rather than template lines because the captured body's
|
|
757
|
+
trailing newline varies by action branch — see `generator._bracket_step`.
|
|
758
|
+
#}
|
|
759
|
+
{{ step_body(step) | apply_optional(step) | bracket_step(step) | apply_step_guard(step) }}
|
|
760
|
+
{%- endfor %}
|
|
761
|
+
{#
|
|
762
|
+
In-app tour replay (only for scenarios whose tour is compiled, see
|
|
763
|
+
steppy.tours.replay): composed in Python around `step_body`, so every
|
|
764
|
+
action runs exactly the code the scenario test above runs.
|
|
765
|
+
#}
|
|
766
|
+
{% if tour_replay %}
|
|
767
|
+
{{ tour_replay | tour_section(step_body) }}{% endif %}
|