steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,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 %}