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,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
|
+
}
|