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,404 @@
1
+ """In-app tour replay helpers — generated by Steppy, do not edit.
2
+
3
+ Written next to ``conftest.py`` only when ``tours.enabled`` is set in
4
+ ``config.yaml``, and imported by the ``test_<slug>__tour`` functions that
5
+ ``steppy generate`` appends to opted-in scenarios. A replay follows the tour
6
+ the way a user would: it checks the popover shows each step anchored on its
7
+ target, performs the scenario's own action, and checks the tour moved on.
8
+
9
+ Standalone on purpose (stdlib only): the generated suite must not depend on
10
+ Steppy being importable at test time. The constants below mirror
11
+ ``steppy.naming`` / ``steppy.tour_spec`` and the overlay's DOM contract;
12
+ ``tests/test_tour_replay_helpers_mirror.py`` keeps them equal.
13
+
14
+ Events are collected with a context-level init script plus an exposed binding,
15
+ so they survive full navigations; callbacks only append to a list (sync
16
+ Playwright API calls are not allowed inside them), and every wait pumps
17
+ through ``page.wait_for_timeout`` so those callbacks are delivered.
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import time
23
+
24
+ TOUR_HOST_TAG = "steppy-tour-host"
25
+ TOUR_EVENTS = ("tour-start", "tour-step", "tour-complete", "tour-dismiss", "tour-offpath", "tour-error")
26
+ TOURS_KILL_SWITCH_GLOBAL = "__STEPPY_TOURS_DISABLED__"
27
+ TOUR_PROBE_BINDING = "__steppy_tour_event"
28
+ ATTR_STEP_ID = "data-step-id"
29
+
30
+ DEFINE_TIMEOUT_MS = 15000
31
+ ADVANCE_TIMEOUT_MS = 10000
32
+ INPUT_GRACE_MS = 750
33
+ POLL_MS = 100
34
+ SLACK_PX = 2
35
+
36
+ _now = time.monotonic
37
+
38
+
39
+ class TourReplayError(AssertionError):
40
+ """A tour step the replay could not follow — the message names tour, step and test-id."""
41
+
42
+
43
+ _PROBE_JS = """
44
+ (() => {
45
+ if (window.top !== window || window.__steppyTourProbe) return;
46
+ window.__steppyTourProbe = true;
47
+ for (const name of %s) {
48
+ window.addEventListener(name, (event) => {
49
+ let detail = null;
50
+ try { detail = JSON.parse(JSON.stringify(event.detail ?? null)); } catch (_) {}
51
+ try { window.%s({type: name, detail, path: location.pathname}); } catch (_) {}
52
+ }, true);
53
+ }
54
+ })();
55
+ """ % (json.dumps(list(TOUR_EVENTS)), TOUR_PROBE_BINDING)
56
+
57
+ _START_JS = """
58
+ async ({tag, id, params, locale, timeoutMs}) => {
59
+ const ready = await Promise.race([
60
+ customElements.whenDefined(tag).then(() => true),
61
+ new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)),
62
+ ]);
63
+ const host = document.querySelector(tag);
64
+ if (!host) return {error: 'missing'};
65
+ if (!ready) return {error: 'undefined'};
66
+ if (locale) host.locale = locale;
67
+ try {
68
+ return {ok: await host.start(id, {params})};
69
+ } catch (err) {
70
+ return {error: 'threw', message: String(err)};
71
+ }
72
+ }
73
+ """
74
+
75
+ _SNAPSHOT_JS = """
76
+ ([tag]) => {
77
+ const out = {path: location.pathname, defined: !!customElements.get(tag), host: false, active: false,
78
+ current: null, bubble: null, spotlight: null, pill: false,
79
+ viewport: {w: innerWidth, h: innerHeight}};
80
+ const host = document.querySelector(tag);
81
+ if (!host) return out;
82
+ out.host = true;
83
+ out.active = !!host.isActive;
84
+ out.current = host.current ? JSON.parse(JSON.stringify(host.current)) : null;
85
+ const root = host.shadowRoot;
86
+ if (!root) return out;
87
+ const shown = (el) => {
88
+ if (!el || !el.isConnected) return false;
89
+ const cs = getComputedStyle(el);
90
+ if (cs.display === 'none' || cs.visibility === 'hidden') return false;
91
+ const r = el.getBoundingClientRect();
92
+ return r.width > 0 && r.height > 0;
93
+ };
94
+ const rect = (el) => { const r = el.getBoundingClientRect(); return {x: r.left, y: r.top, w: r.width, h: r.height}; };
95
+ const bubble = root.querySelector('[part~="bubble"]');
96
+ if (shown(bubble)) {
97
+ const next = bubble.querySelector('[part~="next"]');
98
+ const body = bubble.querySelector('[part~="body"]') || bubble;
99
+ out.bubble = {stepId: bubble.getAttribute('data-step-id'), state: bubble.getAttribute('data-state'),
100
+ rect: rect(bubble), text: body.textContent.trim(),
101
+ next: next ? {disabled: !!next.disabled} : null};
102
+ }
103
+ const spot = root.querySelector('[part~="spotlight"]');
104
+ if (shown(spot)) out.spotlight = rect(spot);
105
+ out.pill = shown(root.querySelector('[part~="pill"]'));
106
+ return out;
107
+ }
108
+ """
109
+
110
+
111
+ def _callspec(request):
112
+ params = getattr(getattr(request.node, "callspec", None), "params", None) or {}
113
+ for value in params.values():
114
+ if isinstance(value, tuple) and len(value) == 2:
115
+ return value
116
+ return (None, None)
117
+
118
+
119
+ def _css_string(value):
120
+ return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
121
+
122
+
123
+ class TourProbe:
124
+ """Per-test state: the page, the baked tour spec and every tour event seen."""
125
+
126
+ def __init__(self, page, request, spec, *, step_runs):
127
+ self.page = page
128
+ self.spec = spec
129
+ self.step_runs = step_runs
130
+ self.steps = {step["id"]: step for step in spec["steps"]}
131
+ self.order = [step["id"] for step in spec["steps"]]
132
+ self.events = []
133
+ self.navigations = 0
134
+ self.start_mark = None
135
+ self.locale, self.layout = _callspec(request)
136
+ self.timeout_ms = max(int(spec.get("timeout_ms") or 0), DEFINE_TIMEOUT_MS)
137
+ page.context.expose_binding(TOUR_PROBE_BINDING, lambda source, payload: self.events.append(payload))
138
+ page.context.add_init_script(_PROBE_JS)
139
+ page.on("framenavigated", self._on_nav)
140
+ request.node._steppy_tour_probe = self
141
+
142
+ def _on_nav(self, frame):
143
+ if frame == self.page.main_frame and self.start_mark is not None:
144
+ self.navigations += 1
145
+
146
+ # ── plumbing ──────────────────────────────────────────────────────────
147
+
148
+ def pump(self, ms=POLL_MS):
149
+ self.page.wait_for_timeout(ms)
150
+
151
+ def snapshot(self):
152
+ try:
153
+ return self.page.evaluate(_SNAPSHOT_JS, [TOUR_HOST_TAG])
154
+ except Exception as exc: # a navigation can destroy the context mid-call
155
+ return {"error": str(exc), "bubble": None, "spotlight": None, "active": False, "current": None}
156
+
157
+ def since_start(self, *types):
158
+ events = self.events[self.start_mark or 0 :]
159
+ return [e for e in events if not types or e.get("type") in types]
160
+
161
+ def advanced_past(self, step_id):
162
+ snap = self.snapshot()
163
+ if snap.get("error"):
164
+ return False
165
+ current = snap.get("current") or {}
166
+ if not snap.get("active"):
167
+ return bool(self.since_start("tour-complete"))
168
+ return current.get("stepId") not in (None, step_id)
169
+
170
+ # ── reporting ─────────────────────────────────────────────────────────
171
+
172
+ def describe(self, step_id):
173
+ step = self.steps.get(step_id) or {}
174
+ parts = [f'scenario step {step.get("scenario_step")} "{step.get("title", "")}"', step.get("action", "?")]
175
+ if step.get("target"):
176
+ parts.append(f'test-id "{step["target"]}"')
177
+ return f"[tour {self.spec['id']}] step '{step_id}' ({', '.join(parts)})"
178
+
179
+ def fail(self, step_id, problem, hint=None):
180
+ snap = self.snapshot()
181
+ bubble = snap.get("bubble") or {}
182
+ current = (snap.get("current") or {}).get("stepId")
183
+ lines = [f"{self.describe(step_id) if step_id else '[tour ' + self.spec['id'] + ']'}: {problem}"]
184
+ if hint:
185
+ lines.append(f" hint: {hint}")
186
+ lines.append(
187
+ f" page: {snap.get('path')} tour: {'active' if snap.get('active') else 'inactive'}"
188
+ f"{', current ' + repr(current) if current else ''} bubble: {bubble.get('state') or 'none'}"
189
+ f" navigations since start: {self.navigations}"
190
+ )
191
+ tail = []
192
+ for event in self.events[-8:]:
193
+ detail = event.get("detail") or {}
194
+ label = detail.get("stepId") or detail.get("reason") or detail.get("source") or ""
195
+ if event.get("type") == "tour-error":
196
+ label = ", ".join(
197
+ str(v) for v in (detail.get("reason"), detail.get("stepId") or detail.get("testId")) if v
198
+ )
199
+ tail.append(f"{event.get('type')}({label})")
200
+ lines.append(" last events: " + (" | ".join(tail) if tail else "none"))
201
+ raise TourReplayError("\n".join(lines))
202
+
203
+ def check_terminal(self, step_id, allow_complete=False):
204
+ for event in self.since_start("tour-error", "tour-dismiss", "tour-offpath"):
205
+ detail = event.get("detail") or {}
206
+ kind = event["type"]
207
+ if kind == "tour-error" and detail.get("reason") == "target-timeout":
208
+ which = detail.get("testId") or detail.get("stepId")
209
+ self.fail(
210
+ detail.get("stepId") or step_id,
211
+ f"target-timeout: the overlay never found [{self.spec['attribute']}={_css_string(which)}]"
212
+ + (" (the waitFor gate)" if detail.get("kind") == "waitFor" else ""),
213
+ "the test-id was renamed or removed in the app, or the element appears too late",
214
+ )
215
+ if kind == "tour-error":
216
+ self.fail(step_id, f"tour-error {detail.get('reason')}: {detail.get('message', '')}")
217
+ if kind == "tour-dismiss":
218
+ self.fail(step_id, f"the tour was dismissed ({detail.get('via') or detail.get('reason')})")
219
+ if kind == "tour-offpath":
220
+ self.fail(step_id, "the tour went off-path: its target disappeared and did not come back")
221
+ if not allow_complete and self.since_start("tour-complete"):
222
+ self.fail(step_id, "the tour completed before reaching this step")
223
+
224
+
225
+ # ── the four calls a generated replay makes ───────────────────────────────
226
+
227
+
228
+ def _tour_start(tour, params):
229
+ page, spec = tour.page, tour.spec
230
+ for name, query in (spec.get("layouts") or {}).items():
231
+ matches = bool(page.evaluate("(q) => matchMedia(q).matches", query))
232
+ expected = bool(tour.step_runs(None, [name]))
233
+ if matches != expected:
234
+ size = page.viewport_size or {}
235
+ raise TourReplayError(
236
+ f"[tour {spec['id']}] layout mismatch: this replay runs layout '{tour.layout or 'default'}' "
237
+ f"(viewport {size.get('width')}x{size.get('height')}) but tours.layouts['{name}'] = '{query}' "
238
+ f"evaluates {str(matches).lower()} there, so the overlay and the scenario disagree about {name} steps"
239
+ )
240
+ page.context.add_init_script(f"globalThis.{TOURS_KILL_SWITCH_GLOBAL} = false;")
241
+ page.evaluate(f"() => {{ globalThis.{TOURS_KILL_SWITCH_GLOBAL} = false; }}")
242
+ tour.start_mark = len(tour.events)
243
+ locale = tour.locale or spec.get("default_locale")
244
+ result = page.evaluate(
245
+ _START_JS,
246
+ {"tag": TOUR_HOST_TAG, "id": spec["id"], "params": params, "locale": locale, "timeoutMs": DEFINE_TIMEOUT_MS},
247
+ )
248
+ if result.get("error") == "missing":
249
+ tour.fail(None, f"<{TOUR_HOST_TAG}> is not on the page", "mount it once in the app shell")
250
+ if result.get("error") == "undefined":
251
+ tour.fail(None, f"<{TOUR_HOST_TAG}> was never defined", "import @steppy/player/tour in the app bundle")
252
+ if result.get("error"):
253
+ tour.fail(None, f"start() threw: {result.get('message')}")
254
+ deadline = _now() + tour.timeout_ms / 1000
255
+ while not tour.since_start("tour-start"):
256
+ for event in tour.since_start("tour-error"):
257
+ detail = event.get("detail") or {}
258
+ reason = detail.get("reason")
259
+ hints = {
260
+ "load-failed": "the app does not serve the tour files (check the manifest URL and the app-assets target)",
261
+ "invalid-data": "the tour files the app serves were rejected; regenerate them with steppy tours",
262
+ "schema-unsupported": "the app's overlay is older than the tour files",
263
+ "entry-params": f"route {(spec.get('entry') or {}).get('route')} could not be filled from {params}",
264
+ }
265
+ tour.fail(None, f"the tour did not start: {reason} {detail.get('message', '')}".rstrip(), hints.get(reason))
266
+ if _now() > deadline:
267
+ tour.fail(None, "tour-start never fired", "is the kill switch set, or the host `disabled`?")
268
+ tour.pump()
269
+ expected = os.environ.get("STEPPY_TOUR_EXPECT_BUILD_ID")
270
+ started = tour.since_start("tour-start")[0].get("detail") or {}
271
+ if expected and started.get("buildId") != expected:
272
+ tour.fail(
273
+ None,
274
+ f"the app serves tours built for {started.get('buildId')!r}, expected {expected!r}",
275
+ "rebuild the app after steppy tours, or align tours.build_id",
276
+ )
277
+
278
+
279
+ def _tour_expect_step(tour, step_id, selector=None):
280
+ if tour.steps[step_id]["advance"] == "visible":
281
+ return _expect_visible_step(tour, step_id)
282
+ deadline = _now() + tour.timeout_ms / 1000
283
+ why = "the popover for this step never appeared"
284
+ while True:
285
+ tour.check_terminal(step_id)
286
+ snap = tour.snapshot()
287
+ bubble = snap.get("bubble")
288
+ if bubble and bubble.get("stepId") == step_id:
289
+ if bubble.get("state") == "fallback":
290
+ tour.fail(
291
+ step_id,
292
+ "target-timeout: the overlay shows its 'Can't find this element' fallback",
293
+ "the test-id was renamed or removed in the app",
294
+ )
295
+ if not bubble.get("text"):
296
+ tour.fail(step_id, "the popover has no text")
297
+ if selector is None:
298
+ return
299
+ why = _anchored(tour, selector, snap)
300
+ if why is None:
301
+ return
302
+ elif bubble and bubble.get("stepId") in tour.order and step_id in tour.order:
303
+ if tour.order.index(bubble["stepId"]) > tour.order.index(step_id):
304
+ tour.fail(step_id, f"the tour skipped ahead to '{bubble['stepId']}'", "stale tour files?")
305
+ elif bubble and bubble.get("stepId") not in tour.order:
306
+ tour.fail(step_id, f"the overlay shows an unknown step '{bubble.get('stepId')}'", "stale tour files?")
307
+ if _now() > deadline:
308
+ tour.fail(step_id, why)
309
+ tour.pump()
310
+
311
+
312
+ def _expect_visible_step(tour, step_id):
313
+ """A ``visible`` step advances by itself as soon as its target shows — when
314
+ the target is already there, in the same pass that reaches the step, before
315
+ any popover is drawn. So the proof the overlay reached it is its
316
+ ``tour-step`` event, not a popover; ``_tour_ensure_advanced`` then waits for
317
+ the tour to move past it."""
318
+ deadline = _now() + tour.timeout_ms / 1000
319
+ while True:
320
+ tour.check_terminal(step_id, allow_complete=True)
321
+ if any((e.get("detail") or {}).get("stepId") == step_id for e in tour.since_start("tour-step")):
322
+ return
323
+ bubble = tour.snapshot().get("bubble")
324
+ if bubble and bubble.get("stepId") in tour.order and step_id in tour.order:
325
+ if tour.order.index(bubble["stepId"]) > tour.order.index(step_id):
326
+ tour.fail(step_id, f"the tour skipped ahead to '{bubble['stepId']}'", "stale tour files?")
327
+ if tour.since_start("tour-complete"):
328
+ tour.fail(step_id, "the tour completed before reaching this step")
329
+ if _now() > deadline:
330
+ tour.fail(step_id, "the tour never reached this step")
331
+ tour.pump()
332
+
333
+
334
+ def _anchored(tour, selector, snap):
335
+ """None when the spotlight sits on the element CI acts on, else why not."""
336
+ locator = tour.page.locator(selector)
337
+ try:
338
+ box = locator.first.bounding_box(timeout=1000)
339
+ if box is None:
340
+ box = locator.filter(visible=True).first.bounding_box(timeout=1000)
341
+ except Exception:
342
+ box = None
343
+ if box is None:
344
+ return f"the target {selector} is not visible"
345
+ cx, cy = box["x"] + box["width"] / 2, box["y"] + box["height"] / 2
346
+ spot = snap.get("spotlight")
347
+ if not spot:
348
+ return "the popover shows but no spotlight is drawn"
349
+ inside = (spot["x"] - SLACK_PX <= cx <= spot["x"] + spot["w"] + SLACK_PX) and (
350
+ spot["y"] - SLACK_PX <= cy <= spot["y"] + spot["h"] + SLACK_PX
351
+ )
352
+ if not inside:
353
+ return f"the spotlight {spot} is not on the target {box}"
354
+ b = (snap.get("bubble") or {}).get("rect")
355
+ if b and b["x"] <= cx <= b["x"] + b["w"] and b["y"] <= cy <= b["y"] + b["h"]:
356
+ return "the popover covers the centre of its own target"
357
+ return None
358
+
359
+
360
+ def _tour_ensure_advanced(tour, step_id):
361
+ kind = tour.steps[step_id]["advance"]
362
+ if kind in ("input", "manual"):
363
+ if kind == "input":
364
+ grace = _now() + INPUT_GRACE_MS / 1000
365
+ while _now() < grace:
366
+ if tour.advanced_past(step_id):
367
+ return
368
+ tour.pump()
369
+ selector = f'{TOUR_HOST_TAG} [part~="bubble"][{ATTR_STEP_ID}={_css_string(step_id)}] [part~="next"]'
370
+ button = tour.page.locator(selector)
371
+ if button.count() == 0:
372
+ if tour.advanced_past(step_id):
373
+ return
374
+ tour.fail(step_id, "the popover has no Next button")
375
+ if button.first.is_disabled():
376
+ tour.fail(step_id, "Next is disabled: the overlay saw an empty value", "did the action fill the field?")
377
+ button.first.click()
378
+ deadline = _now() + ADVANCE_TIMEOUT_MS / 1000
379
+ while not tour.advanced_past(step_id):
380
+ tour.check_terminal(step_id, allow_complete=True)
381
+ if _now() > deadline:
382
+ hints = {
383
+ "change": "the overlay saw no value change on its target (a body-appended option list "
384
+ "the popover covers? an already-selected value?)",
385
+ "click": "the click did not reach the target the tour points at",
386
+ }
387
+ tour.fail(step_id, f"the tour did not advance after the step's action ({kind})", hints.get(kind))
388
+ tour.pump()
389
+
390
+
391
+ def _tour_expect_complete(tour):
392
+ deadline = _now() + tour.timeout_ms / 1000
393
+ while not tour.since_start("tour-complete"):
394
+ tour.check_terminal(None, allow_complete=True)
395
+ if _now() > deadline:
396
+ tour.fail(None, "tour-complete never fired after the last step")
397
+ tour.pump()
398
+ for event in tour.since_start("tour-error", "tour-dismiss", "tour-offpath"):
399
+ tour.fail(None, f"{event['type']} during the tour: {event.get('detail')}")
400
+ seen = {(e.get("detail") or {}).get("stepId") for e in tour.since_start("tour-step")}
401
+ for step in tour.spec["steps"]:
402
+ applicable = not step.get("layouts") or tour.step_runs(None, step["layouts"])
403
+ if applicable and step["id"] not in seen:
404
+ tour.fail(step["id"], "the tour completed without showing this step")
@@ -0,0 +1,109 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ //
3
+ // Interactive guide host component for "{{ slug }}". Mounts the
4
+ // ``<steppy-guide>`` Lit web component (shipped as @steppy/player's ESM
5
+ // bundle) against the per-guide ``guide.json`` manifest emitted alongside
6
+ // the screenshots in the host app's ``public/assets/{{ role }}/{{ slug }}/`` directory.
7
+ //
8
+ // The player bundle (``steppy-player.js``) is fetched once on first mount —
9
+ // no host npm install required. Subsequent interactive components reuse the
10
+ // already-loaded custom element.
11
+ import {
12
+ Component,
13
+ CUSTOM_ELEMENTS_SCHEMA,
14
+ inject,
15
+ {% if has_multiple_layouts %}
16
+ input,
17
+ {% endif %}
18
+ OnInit,
19
+ ViewEncapsulation,
20
+ } from '@angular/core';
21
+ import { toSignal } from '@angular/core/rxjs-interop';
22
+ import { map, startWith } from 'rxjs/operators';
23
+ import { TranslateService } from '@ngx-translate/core';
24
+
25
+ const PLAYER_BUNDLE_URL = '/assets/{{ role }}/steppy-player.js';
26
+ const MANIFEST_URL = '/assets/{{ role }}/{{ slug }}/guide.json';
27
+ let playerScriptLoading: Promise<void> | null = null;
28
+
29
+ function ensurePlayerLoaded(): Promise<void> {
30
+ // Idempotent: only inserts the <script> tag once per page, regardless of
31
+ // how many interactive guides mount.
32
+ if (playerScriptLoading) return playerScriptLoading;
33
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
34
+ return Promise.resolve();
35
+ }
36
+ if (customElements.get('steppy-guide')) {
37
+ playerScriptLoading = Promise.resolve();
38
+ return playerScriptLoading;
39
+ }
40
+ playerScriptLoading = new Promise<void>((resolve, reject) => {
41
+ const script = document.createElement('script');
42
+ script.type = 'module';
43
+ script.src = PLAYER_BUNDLE_URL;
44
+ script.async = true;
45
+ script.onload = () => resolve();
46
+ script.onerror = (err) => reject(err);
47
+ document.head.appendChild(script);
48
+ });
49
+ return playerScriptLoading;
50
+ }
51
+
52
+ @Component({
53
+ selector: 'app-guide-{{ slug }}-interactive',
54
+ standalone: true,
55
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
56
+ template: `
57
+ <steppy-guide
58
+ class="steppy-guide-host"
59
+ [attr.src]="manifestUrl"
60
+ [attr.locale]="locale()"
61
+ {% if has_multiple_layouts %}
62
+ [attr.variant]="variant()"
63
+ hide-layout-switcher
64
+ {% endif %}
65
+ {% if cursor_mode %}
66
+ cursor="{{ cursor_mode }}"
67
+ {% endif %}
68
+ template="{{ default_template }}"
69
+ ></steppy-guide>
70
+ `,
71
+ styles: [`
72
+ :host { display: block; }
73
+ .steppy-guide-host { display: block; width: 100%; }
74
+ `],
75
+ encapsulation: ViewEncapsulation.None,
76
+ })
77
+ export class {{ class_name }}InteractiveComponent implements OnInit {
78
+ private readonly translate = inject(TranslateService);
79
+
80
+ readonly manifestUrl = MANIFEST_URL;
81
+ {% if has_multiple_layouts %}
82
+ // Layout (variant) is driven by the host toolbar's layout toggle (the parent
83
+ // component binds [variant]="selectedLayout()"); the player's built-in
84
+ // switcher is suppressed via `hide-layout-switcher`.
85
+ readonly variant = input<string>('{{ default_layout }}');
86
+ {% endif %}
87
+
88
+ // Bridge ngx-translate's current language into the player's locale
89
+ // attribute. The player handles the switch internally on attribute change.
90
+ readonly locale = toSignal(
91
+ this.translate.onLangChange.pipe(
92
+ map((event) => event.lang),
93
+ startWith(
94
+ this.translate.currentLang ||
95
+ this.translate.getDefaultLang() ||
96
+ '{{ default_locale }}',
97
+ ),
98
+ ),
99
+ { initialValue:
100
+ this.translate.currentLang ||
101
+ this.translate.getDefaultLang() ||
102
+ '{{ default_locale }}',
103
+ },
104
+ );
105
+
106
+ ngOnInit(): void {
107
+ void ensurePlayerLoaded();
108
+ }
109
+ }
@@ -0,0 +1,112 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ //
3
+ // Video guide host component for "{{ slug }}". Plays the narrated MP4 that
4
+ // ``steppy build --video`` rendered from this guide's captures, served from the
5
+ // host app's ``public/assets/{{ role }}/{{ slug }}/`` directory alongside the
6
+ // screenshots.
7
+ //
8
+ // Unlike the interactive mode there is no player bundle to load: the artifact
9
+ // is a plain ``.mp4`` with a WebVTT caption track, so a native ``<video>`` is
10
+ // the whole implementation. Locale (and layout, when the guide has several)
11
+ // select which rendering to play.
12
+ import {
13
+ ChangeDetectionStrategy,
14
+ Component,
15
+ computed,
16
+ effect,
17
+ ElementRef,
18
+ inject,
19
+ {% if has_multiple_layouts %}
20
+ input,
21
+ {% endif %}
22
+ ViewChild,
23
+ ViewEncapsulation,
24
+ } from '@angular/core';
25
+ import { toSignal } from '@angular/core/rxjs-interop';
26
+ import { map, startWith } from 'rxjs/operators';
27
+ import { TranslateService } from '@ngx-translate/core';
28
+
29
+ const ASSET_BASE = '/assets/{{ role }}/{{ slug }}';
30
+
31
+ // key -> {src, poster, captions}, keyed exactly as the build named the files.
32
+ const VIDEOS: Record<string, { src: string; poster: string; captions: string }> = {
33
+ {% for key, item in videos.items() %}
34
+ '{{ key }}': {
35
+ src: `${ASSET_BASE}/{{ item.file }}`,
36
+ poster: `${ASSET_BASE}/{{ item.poster_file }}`,
37
+ captions: `${ASSET_BASE}/{{ item.captions_file }}`,
38
+ },
39
+ {% endfor %}
40
+ };
41
+
42
+ const FALLBACK_KEY = '{{ default_key }}';
43
+
44
+ @Component({
45
+ selector: 'app-guide-{{ slug }}-video',
46
+ standalone: true,
47
+ changeDetection: ChangeDetectionStrategy.OnPush,
48
+ template: `
49
+ <video
50
+ #player
51
+ class="steppy-video-host"
52
+ controls
53
+ playsinline
54
+ preload="metadata"
55
+ [poster]="current().poster"
56
+ >
57
+ <source [src]="current().src" type="video/mp4" />
58
+ <track kind="captions" [src]="current().captions" [srclang]="locale()" default />
59
+ </video>
60
+ `,
61
+ styles: [`
62
+ :host { display: block; }
63
+ .steppy-video-host { display: block; width: 100%; background: #000; border-radius: 8px; }
64
+ `],
65
+ encapsulation: ViewEncapsulation.None,
66
+ })
67
+ export class {{ class_name }}VideoComponent {
68
+ private readonly translate = inject(TranslateService);
69
+
70
+ @ViewChild('player') private playerRef?: ElementRef<HTMLVideoElement>;
71
+
72
+ {% if has_multiple_layouts %}
73
+ readonly variant = input<string>('{{ default_layout }}');
74
+ {% endif %}
75
+
76
+ readonly locale = toSignal(
77
+ this.translate.onLangChange.pipe(
78
+ map((event) => event.lang),
79
+ startWith(
80
+ this.translate.currentLang ||
81
+ this.translate.getDefaultLang() ||
82
+ '{{ default_locale }}',
83
+ ),
84
+ ),
85
+ { initialValue:
86
+ this.translate.currentLang ||
87
+ this.translate.getDefaultLang() ||
88
+ '{{ default_locale }}',
89
+ },
90
+ );
91
+
92
+ readonly current = computed(() => {
93
+ {% if has_multiple_layouts %}
94
+ const key = `${this.locale()}-${this.variant()}`;
95
+ {% else %}
96
+ const key = this.locale();
97
+ {% endif %}
98
+ return VIDEOS[key] ?? VIDEOS[FALLBACK_KEY];
99
+ });
100
+
101
+ constructor() {
102
+ // Changing a <source> element after load is a no-op — the browser only
103
+ // re-reads the media when load() is called on the <video> itself.
104
+ effect(() => {
105
+ this.current();
106
+ const element = this.playerRef?.nativeElement;
107
+ if (!element) return;
108
+ element.pause();
109
+ element.load();
110
+ });
111
+ }
112
+ }