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
steppy/tours/replay.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""``ReplayPlan`` → what ``test_scenario.py.j2`` renders as ``test_<slug>__tour``.
|
|
2
|
+
|
|
3
|
+
The replay is the scenario followed the way a *user* follows its tour, inside
|
|
4
|
+
the scenario's own test file (every selection, parallel, lastfailed and JUnit
|
|
5
|
+
path is keyed on that file):
|
|
6
|
+
|
|
7
|
+
1. **Setup** — the steps before the tour's start point, in test mode
|
|
8
|
+
(``only-in: guide`` steps dropped), e.g. login and the entry navigation.
|
|
9
|
+
2. **Start** — the tour, through the overlay's element API.
|
|
10
|
+
3. **Range** — each tour step: assert the popover shows that step anchored on
|
|
11
|
+
its target → perform the scenario's *own* action for it (the same codegen
|
|
12
|
+
branch the scenario test uses, captures removed) → make sure the tour moved
|
|
13
|
+
on. Range steps that are not tour steps run exactly as in the scenario.
|
|
14
|
+
4. **Complete** — ``tour-complete`` fired and no ``tour-error`` did.
|
|
15
|
+
|
|
16
|
+
Every action body comes from the ``step_body`` template macro, so a replay can
|
|
17
|
+
never drift from the test it mirrors. This module only decides *which* steps,
|
|
18
|
+
in *which* order, with *which* tour checks around them.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Literal, Optional
|
|
25
|
+
|
|
26
|
+
from ..models import SteppyConfig, Scenario, Step
|
|
27
|
+
from .model import ReplayEntry, ReplayPlan, ReplayTourStep
|
|
28
|
+
|
|
29
|
+
#: Actions that act on whatever has focus: clicking the popover's Next button
|
|
30
|
+
#: before them would steal it, so the Next click is deferred past them.
|
|
31
|
+
FOCUS_DEPENDENT_ACTIONS = frozenset({"keyboard", "custom", "call"})
|
|
32
|
+
|
|
33
|
+
ItemKind = Literal["setup", "plain", "omitted", "start", "tour", "deferred-ensure", "complete"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ReplayPlanError(ValueError):
|
|
37
|
+
"""The compiler handed over a plan that does not line up with the scenario (a bug)."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ReplayCheck:
|
|
42
|
+
step_id: str
|
|
43
|
+
selector: Optional[str]
|
|
44
|
+
advance: str
|
|
45
|
+
ensure: Literal["inline", "deferred"] = "inline"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ReplayItem:
|
|
50
|
+
kind: ItemKind
|
|
51
|
+
comment: str = ""
|
|
52
|
+
step: Optional[Step] = None
|
|
53
|
+
#: tour items: one (step copy, check) per sub-step (fill-form has several)
|
|
54
|
+
parts: list[tuple[Step, ReplayCheck]] = field(default_factory=list)
|
|
55
|
+
step_id: Optional[str] = None # deferred-ensure
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class ReplayRender:
|
|
60
|
+
function_name: str
|
|
61
|
+
role: str
|
|
62
|
+
roles: list[str]
|
|
63
|
+
auth: bool
|
|
64
|
+
spec: dict
|
|
65
|
+
params: list[tuple[str, str]]
|
|
66
|
+
items: list[ReplayItem]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _copy(step: Step, **update) -> Step:
|
|
70
|
+
"""No captures in a replay; ``only-in`` is resolved statically, layouts/when stay runtime guards."""
|
|
71
|
+
return step.model_copy(update={"screenshot": None, "only_in": None, **update})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _check(plan: ReplayPlan, scenario: Scenario) -> None:
|
|
75
|
+
if len(plan.entries) != len(scenario.steps):
|
|
76
|
+
raise ReplayPlanError(f"{scenario.slug}: {len(plan.entries)} replay entries for {len(scenario.steps)} steps")
|
|
77
|
+
seen_range = False
|
|
78
|
+
expected_index = 0
|
|
79
|
+
for idx, (step, entry) in enumerate(zip(scenario.steps, plan.entries)):
|
|
80
|
+
if entry.step_number != (step.step_number or idx + 1):
|
|
81
|
+
raise ReplayPlanError(f"{scenario.slug}: entry {idx} is for step {entry.step_number}")
|
|
82
|
+
in_setup = idx < plan.start_index
|
|
83
|
+
if in_setup and seen_range:
|
|
84
|
+
raise ReplayPlanError(f"{scenario.slug}: setup step {entry.step_number} after the tour started")
|
|
85
|
+
seen_range = seen_range or not in_setup
|
|
86
|
+
if entry.disposition == "tour":
|
|
87
|
+
if in_setup or step.role not in (plan.role, None) or step.only_in == "test":
|
|
88
|
+
raise ReplayPlanError(f"{scenario.slug}: step {entry.step_number} cannot be a tour step")
|
|
89
|
+
fields = list(step.fields or {})
|
|
90
|
+
for ts in entry.tour_steps:
|
|
91
|
+
if ts.index != expected_index:
|
|
92
|
+
raise ReplayPlanError(f"{scenario.slug}: tour step {ts.step_id} is out of order")
|
|
93
|
+
expected_index += 1
|
|
94
|
+
if ts.field is not None and ts.field not in fields:
|
|
95
|
+
raise ReplayPlanError(f"{scenario.slug}: {ts.field!r} is not a field of step {entry.step_number}")
|
|
96
|
+
if expected_index != plan.tour_step_count:
|
|
97
|
+
raise ReplayPlanError(f"{scenario.slug}: {expected_index} tour steps, plan says {plan.tour_step_count}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parts(step: Step, entry: ReplayEntry) -> list[tuple[Step, ReplayCheck]]:
|
|
101
|
+
def check(ts: ReplayTourStep) -> ReplayCheck:
|
|
102
|
+
return ReplayCheck(ts.step_id, ts.source_selector, ts.advance)
|
|
103
|
+
|
|
104
|
+
if step.action == "fill-form" and any(ts.field for ts in entry.tour_steps):
|
|
105
|
+
parts = []
|
|
106
|
+
last = len(entry.tour_steps) - 1
|
|
107
|
+
for n, ts in enumerate(entry.tour_steps):
|
|
108
|
+
only = {ts.field: step.fields[ts.field]}
|
|
109
|
+
parts.append(
|
|
110
|
+
(
|
|
111
|
+
_copy(
|
|
112
|
+
step,
|
|
113
|
+
fields=only,
|
|
114
|
+
wait_after=step.wait_after if n == last else None,
|
|
115
|
+
expect=step.expect if n == last else None,
|
|
116
|
+
),
|
|
117
|
+
check(ts),
|
|
118
|
+
)
|
|
119
|
+
)
|
|
120
|
+
return parts
|
|
121
|
+
(ts,) = entry.tour_steps
|
|
122
|
+
return [(_copy(step), check(ts))]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_replay(scenario: Scenario, plan: ReplayPlan, config: SteppyConfig) -> ReplayRender:
|
|
126
|
+
_check(plan, scenario)
|
|
127
|
+
steps = scenario.steps
|
|
128
|
+
items: list[ReplayItem] = []
|
|
129
|
+
started = False
|
|
130
|
+
pending: Optional[tuple[str, Step]] = None # deferred Next click: (step id, step for its guard)
|
|
131
|
+
|
|
132
|
+
def flush_pending():
|
|
133
|
+
nonlocal pending
|
|
134
|
+
if pending is not None:
|
|
135
|
+
step_id, guard_step = pending
|
|
136
|
+
items.append(ReplayItem("deferred-ensure", step=guard_step, step_id=step_id))
|
|
137
|
+
pending = None
|
|
138
|
+
|
|
139
|
+
for idx, (step, entry) in enumerate(zip(steps, plan.entries)):
|
|
140
|
+
title = step.step_title or ""
|
|
141
|
+
if idx < plan.start_index:
|
|
142
|
+
if entry.disposition == "drop":
|
|
143
|
+
items.append(ReplayItem("omitted", comment=f"Step {entry.step_number}: {title} — guide-only, skipped"))
|
|
144
|
+
else:
|
|
145
|
+
items.append(
|
|
146
|
+
ReplayItem("setup", comment=f"Step {entry.step_number}: {title} — setup", step=_copy(step))
|
|
147
|
+
)
|
|
148
|
+
continue
|
|
149
|
+
if not started:
|
|
150
|
+
route = plan.entry_route or "the current page"
|
|
151
|
+
items.append(ReplayItem("start", comment=f"Tour '{plan.tour_id}' starts here (entry {route})"))
|
|
152
|
+
started = True
|
|
153
|
+
if entry.disposition == "drop":
|
|
154
|
+
items.append(
|
|
155
|
+
ReplayItem("omitted", comment=f"Step {entry.step_number}: {title} — test-only, not in the tour")
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
if entry.disposition != "tour":
|
|
159
|
+
items.append(ReplayItem("plain", comment=f"Step {entry.step_number}: {title}", step=_copy(step)))
|
|
160
|
+
continue
|
|
161
|
+
flush_pending()
|
|
162
|
+
parts = _parts(step, entry)
|
|
163
|
+
last_step, last_check = parts[-1]
|
|
164
|
+
if last_check.advance in ("input", "manual") and _focus_dependent_follows(steps, plan.entries, idx):
|
|
165
|
+
parts[-1] = (
|
|
166
|
+
last_step,
|
|
167
|
+
ReplayCheck(last_check.step_id, last_check.selector, last_check.advance, "deferred"),
|
|
168
|
+
)
|
|
169
|
+
pending = (last_check.step_id, _copy(step))
|
|
170
|
+
ids = ", ".join(f"'{c.step_id}'" for _s, c in parts)
|
|
171
|
+
items.append(
|
|
172
|
+
ReplayItem(
|
|
173
|
+
"tour",
|
|
174
|
+
comment=f"Step {entry.step_number}: {title} — tour step {ids} ({parts[0][1].advance})",
|
|
175
|
+
step=_copy(step),
|
|
176
|
+
parts=parts,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
if not started:
|
|
180
|
+
items.append(ReplayItem("start", comment=f"Tour '{plan.tour_id}' starts here"))
|
|
181
|
+
flush_pending()
|
|
182
|
+
items.append(ReplayItem("complete", comment=f"Tour '{plan.tour_id}' must complete"))
|
|
183
|
+
|
|
184
|
+
from ..generator import SteppyGenerator
|
|
185
|
+
|
|
186
|
+
timeout = SteppyGenerator._resolve_timeout(None, scenario, config)
|
|
187
|
+
tour_steps = [ts for e in plan.entries for ts in e.tour_steps]
|
|
188
|
+
spec = {
|
|
189
|
+
"id": plan.tour_id,
|
|
190
|
+
"role": plan.role,
|
|
191
|
+
"default_locale": plan.default_locale,
|
|
192
|
+
"attribute": plan.test_id_attribute,
|
|
193
|
+
"timeout_ms": int(timeout),
|
|
194
|
+
"entry": (
|
|
195
|
+
{"route": plan.entry_route, "params": [list(p) for p in plan.entry_params]} if plan.entry_route else None
|
|
196
|
+
),
|
|
197
|
+
"layouts": dict(plan.layouts),
|
|
198
|
+
"steps": [
|
|
199
|
+
{
|
|
200
|
+
"id": ts.step_id,
|
|
201
|
+
"target": ts.target,
|
|
202
|
+
"wait_for": ts.wait_for,
|
|
203
|
+
"advance": ts.advance,
|
|
204
|
+
"layouts": list(ts.layouts) if ts.layouts else None,
|
|
205
|
+
"scenario_step": e.step_number,
|
|
206
|
+
"action": steps[i].action,
|
|
207
|
+
"title": steps[i].step_title or "",
|
|
208
|
+
"field": ts.field,
|
|
209
|
+
"selector": ts.source_selector,
|
|
210
|
+
}
|
|
211
|
+
for i, e in enumerate(plan.entries)
|
|
212
|
+
for ts in e.tour_steps
|
|
213
|
+
],
|
|
214
|
+
}
|
|
215
|
+
assert len(spec["steps"]) == len(tour_steps)
|
|
216
|
+
slug_ = scenario.slug.replace("-", "_")
|
|
217
|
+
return ReplayRender(
|
|
218
|
+
function_name=f"test_{slug_}__tour",
|
|
219
|
+
role=plan.role,
|
|
220
|
+
roles=sorted(scenario.get_roles()),
|
|
221
|
+
auth=scenario.auth,
|
|
222
|
+
spec=spec,
|
|
223
|
+
params=list(plan.entry_params),
|
|
224
|
+
items=items,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _focus_dependent_follows(steps: list[Step], entries: tuple[ReplayEntry, ...], idx: int) -> bool:
|
|
229
|
+
for step, entry in zip(steps[idx + 1 :], entries[idx + 1 :]):
|
|
230
|
+
if entry.disposition == "tour":
|
|
231
|
+
return False
|
|
232
|
+
if entry.disposition == "run" and step.action in FOCUS_DEPENDENT_ACTIONS:
|
|
233
|
+
return True
|
|
234
|
+
return False
|
steppy/tours/routes.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Entry routes: from a scenario's navigate target to a tour's route pattern.
|
|
2
|
+
|
|
3
|
+
A tour may declare where it starts (``tour.entry: /invoices/:invoiceId``) or
|
|
4
|
+
inherit it from the scenario's navigation. Either way the stored route is
|
|
5
|
+
**base-relative** — the path *inside* the app, with the app URL's own path
|
|
6
|
+
prefix removed — because the overlay matches it against the document's base
|
|
7
|
+
URL (``<base href>``) and the app's router navigates to it.
|
|
8
|
+
|
|
9
|
+
Navigate targets are resolved the way the generated tests resolve ``goto()``:
|
|
10
|
+
relative to ``apps.<role>.url``. A ``{{var}}`` segment becomes a ``:var``
|
|
11
|
+
parameter; literal segments are percent-encoded the way ``location.pathname``
|
|
12
|
+
spells them.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Optional
|
|
20
|
+
from urllib.parse import quote, unquote, urlsplit
|
|
21
|
+
|
|
22
|
+
from ..tour_spec import is_dot_segment, is_route
|
|
23
|
+
from .settings import origin_of
|
|
24
|
+
|
|
25
|
+
#: Segments that look like fixture ids: the tour cannot know them in production.
|
|
26
|
+
ID_LIKE_RE = re.compile(r"\d+|[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}|(?=.*\d)[0-9a-fA-F]{8,}")
|
|
27
|
+
_VAR_SEG = re.compile(r"\{\{\s*([A-Za-z_][A-Za-z0-9_]{0,63})\s*\}\}")
|
|
28
|
+
_PARAM_SEG = re.compile(r":([A-Za-z_][A-Za-z0-9_]{0,63})")
|
|
29
|
+
_SAFE = "._~!$&()*+,;=@-%"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class AppBase:
|
|
34
|
+
origin: Optional[str]
|
|
35
|
+
path: str # always ends with "/"
|
|
36
|
+
unresolved: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def app_base(app_url: Optional[str]) -> AppBase:
|
|
40
|
+
"""Origin and path prefix of an app URL; ``unresolved`` when it has no usable host."""
|
|
41
|
+
if not app_url or "${" in app_url:
|
|
42
|
+
return AppBase(None, "/", unresolved=True)
|
|
43
|
+
origin = origin_of(app_url)
|
|
44
|
+
if origin is None:
|
|
45
|
+
return AppBase(None, "/", unresolved=True)
|
|
46
|
+
path = urlsplit(app_url).path or "/"
|
|
47
|
+
return AppBase(origin, path if path.endswith("/") else path + "/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class RouteResult:
|
|
52
|
+
"""A route pattern, or the reason there is none, plus non-fatal notes."""
|
|
53
|
+
|
|
54
|
+
segments: Optional[list[str]] = None # pattern segments (":param" or encoded literal)
|
|
55
|
+
raw_segments: list[str] = field(default_factory=list) # the target's own segments (for binding)
|
|
56
|
+
error: Optional[str] = None
|
|
57
|
+
query_dropped: bool = False
|
|
58
|
+
id_like: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def route(self) -> Optional[str]:
|
|
62
|
+
if self.segments is None:
|
|
63
|
+
return None
|
|
64
|
+
return "/" + "/".join(self.segments)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _target_path(raw: str, base: AppBase) -> tuple[Optional[str], Optional[str], bool]:
|
|
68
|
+
"""``(base-relative path, error, query_dropped)`` for a navigate target."""
|
|
69
|
+
text = raw.strip()
|
|
70
|
+
if "#" in text:
|
|
71
|
+
return None, "hash routes are not supported by in-app tours (v1)", False
|
|
72
|
+
if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", text) or text.startswith("//"):
|
|
73
|
+
if base.origin is None:
|
|
74
|
+
return None, "an absolute URL cannot be checked against an unresolved app URL — set tour.entry", False
|
|
75
|
+
if origin_of(text) != base.origin:
|
|
76
|
+
return None, f"{text!r} is not on the app's origin ({base.origin}) — a tour cannot navigate there", False
|
|
77
|
+
parts = urlsplit(text)
|
|
78
|
+
path, query = parts.path or "/", parts.query
|
|
79
|
+
else:
|
|
80
|
+
path, _, query = text.partition("?")
|
|
81
|
+
if not path.startswith("/"):
|
|
82
|
+
path = base.path + path
|
|
83
|
+
if base.path != "/":
|
|
84
|
+
prefix = base.path.rstrip("/")
|
|
85
|
+
if path == prefix:
|
|
86
|
+
path = "/"
|
|
87
|
+
elif path.startswith(base.path):
|
|
88
|
+
path = "/" + path[len(base.path) :]
|
|
89
|
+
else:
|
|
90
|
+
return None, f"{text!r} is outside the app's base path {base.path}", bool(query)
|
|
91
|
+
if len(path) > 1 and path.endswith("/"):
|
|
92
|
+
path = path.rstrip("/") or "/"
|
|
93
|
+
return path, None, bool(query)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _segments(path: str) -> list[str]:
|
|
97
|
+
return [] if path == "/" else path.split("/")[1:]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _literal(seg: str) -> Optional[str]:
|
|
101
|
+
if not seg or is_dot_segment(seg):
|
|
102
|
+
return None
|
|
103
|
+
return quote(seg, safe=_SAFE)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def route_from_target(raw: str, base: AppBase) -> RouteResult:
|
|
107
|
+
"""Inferred entry: the navigate target as a pattern (``{{var}}`` → ``:var``)."""
|
|
108
|
+
path, error, query = _target_path(raw, base)
|
|
109
|
+
if error:
|
|
110
|
+
return RouteResult(error=error, query_dropped=query)
|
|
111
|
+
result = RouteResult(query_dropped=query, segments=[])
|
|
112
|
+
for seg in _segments(path):
|
|
113
|
+
result.raw_segments.append(seg)
|
|
114
|
+
var = _VAR_SEG.fullmatch(seg)
|
|
115
|
+
if var:
|
|
116
|
+
result.segments.append(":" + var.group(1))
|
|
117
|
+
continue
|
|
118
|
+
if "{{" in seg:
|
|
119
|
+
return RouteResult(error=f"segment {seg!r} is only partly a {{{{var}}}}; make it a whole segment")
|
|
120
|
+
lit = _literal(seg)
|
|
121
|
+
if lit is None:
|
|
122
|
+
return RouteResult(error=f"segment {seg!r} is not a valid path segment")
|
|
123
|
+
if ID_LIKE_RE.fullmatch(unquote(seg)):
|
|
124
|
+
result.id_like.append(seg)
|
|
125
|
+
result.segments.append(lit)
|
|
126
|
+
if not is_route(result.route):
|
|
127
|
+
return RouteResult(error=f"{result.route!r} is not a route the in-app player accepts")
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_entry_pattern(text: str) -> RouteResult:
|
|
132
|
+
"""Explicit ``tour.entry``: already base-relative; ``:name`` and ``{{name}}`` are params."""
|
|
133
|
+
path = text.strip()
|
|
134
|
+
if len(path) > 1:
|
|
135
|
+
path = path.rstrip("/") or "/"
|
|
136
|
+
result = RouteResult(segments=[])
|
|
137
|
+
for seg in _segments(path):
|
|
138
|
+
param = _PARAM_SEG.fullmatch(seg) or _VAR_SEG.fullmatch(seg)
|
|
139
|
+
if param:
|
|
140
|
+
result.segments.append(":" + param.group(1))
|
|
141
|
+
continue
|
|
142
|
+
lit = _literal(seg)
|
|
143
|
+
if lit is None or "{{" in seg:
|
|
144
|
+
return RouteResult(error=f"tour.entry segment {seg!r} is not a valid path segment")
|
|
145
|
+
result.segments.append(lit)
|
|
146
|
+
if not is_route(result.route):
|
|
147
|
+
return RouteResult(error=f"tour.entry {text!r} is not a route the in-app player accepts")
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def target_segments(raw: str, base: AppBase) -> Optional[list[str]]:
|
|
152
|
+
"""The raw base-relative segments of a navigate target, or None when unusable."""
|
|
153
|
+
path, error, _ = _target_path(raw, base)
|
|
154
|
+
return None if error else _segments(path)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def matches(pattern: list[str], target: list[str]) -> bool:
|
|
158
|
+
"""Segment-wise match: ``:p`` takes any segment; a literal must equal a literal."""
|
|
159
|
+
if len(pattern) != len(target):
|
|
160
|
+
return False
|
|
161
|
+
for pat, seg in zip(pattern, target):
|
|
162
|
+
if pat.startswith(":"):
|
|
163
|
+
continue
|
|
164
|
+
if _VAR_SEG.fullmatch(seg) or pat != _literal(seg):
|
|
165
|
+
return False
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bind(pattern: list[str], target: list[str]) -> tuple[tuple[str, str], ...]:
|
|
170
|
+
"""``(param, template)`` pairs: the target's text for each ``:param`` (``{{var}}`` kept)."""
|
|
171
|
+
out = []
|
|
172
|
+
for pat, seg in zip(pattern, target):
|
|
173
|
+
if pat.startswith(":"):
|
|
174
|
+
out.append((pat[1:], seg if _VAR_SEG.fullmatch(seg) else unquote(seg)))
|
|
175
|
+
return tuple(out)
|
steppy/tours/settings.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""A resolved, immutable view of ``config.yaml``'s ``tours:`` block."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from ..captures import DEFAULT_TOURS_DIR, resolve_output_dir
|
|
12
|
+
from ..locales import base_language
|
|
13
|
+
from ..models import DEFAULT_LOCALE, DEFAULT_LOCALES, SteppyConfig
|
|
14
|
+
from ..tour_spec import CHROME_STRING_LOCALES
|
|
15
|
+
from .diagnostics import Diagnostic
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def origin_of(url: str) -> Optional[str]:
|
|
19
|
+
"""``scheme://host[:port]`` with default ports dropped, or None if not http(s)."""
|
|
20
|
+
parts = urlsplit(url)
|
|
21
|
+
if parts.scheme not in ("http", "https") or not parts.hostname:
|
|
22
|
+
return None
|
|
23
|
+
default = {"http": 80, "https": 443}[parts.scheme]
|
|
24
|
+
port = parts.port
|
|
25
|
+
return f"{parts.scheme}://{parts.hostname.lower()}" + (f":{port}" if port and port != default else "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ToursSettings:
|
|
30
|
+
enabled: bool
|
|
31
|
+
default_opt_in: bool
|
|
32
|
+
build_id_raw: str
|
|
33
|
+
locales: tuple[str, ...]
|
|
34
|
+
default_locale: str
|
|
35
|
+
test_id_attribute: str
|
|
36
|
+
guide_base_url: Optional[str]
|
|
37
|
+
guide_origin: Optional[str]
|
|
38
|
+
layouts: tuple[tuple[str, str], ...]
|
|
39
|
+
replay: bool
|
|
40
|
+
project_root: Path
|
|
41
|
+
tours_root: Path
|
|
42
|
+
app_assets_dirs: tuple[Path, ...]
|
|
43
|
+
publishes_guide: bool
|
|
44
|
+
app_urls: dict = field(default_factory=dict) # role -> configured app URL
|
|
45
|
+
screenshot_widths: dict = field(default_factory=dict) # layout -> viewport width
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def layout_map(self) -> dict[str, str]:
|
|
49
|
+
return dict(self.layouts)
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_config(cls, config: SteppyConfig, project_root: Path) -> "ToursSettings":
|
|
53
|
+
tours = config.tours
|
|
54
|
+
if tours is None:
|
|
55
|
+
raise ValueError("config.yaml has no tours: block")
|
|
56
|
+
root = Path(project_root).resolve()
|
|
57
|
+
locales = tuple(tours.locales or config.locales or DEFAULT_LOCALES)
|
|
58
|
+
default_locale = tours.default_locale or config.default_locale or DEFAULT_LOCALE
|
|
59
|
+
base = tours.guide_base_url
|
|
60
|
+
if base is not None and "${" in base:
|
|
61
|
+
base = None # unresolved: no guide links (reported by settings_diagnostics)
|
|
62
|
+
app_dirs = tuple(resolve_output_dir(root, t.path, t.path) for t in tours.targets if t.type == "app-assets")
|
|
63
|
+
return cls(
|
|
64
|
+
enabled=tours.enabled,
|
|
65
|
+
default_opt_in=tours.default,
|
|
66
|
+
build_id_raw=tours.build_id,
|
|
67
|
+
locales=locales,
|
|
68
|
+
default_locale=default_locale,
|
|
69
|
+
test_id_attribute=tours.test_id_attribute,
|
|
70
|
+
guide_base_url=base,
|
|
71
|
+
guide_origin=origin_of(base) if base else None,
|
|
72
|
+
layouts=tuple((tours.layouts or {}).items()),
|
|
73
|
+
replay=tours.replay,
|
|
74
|
+
project_root=root,
|
|
75
|
+
tours_root=resolve_output_dir(root, config.output.tours_dir, DEFAULT_TOURS_DIR),
|
|
76
|
+
app_assets_dirs=app_dirs,
|
|
77
|
+
publishes_guide=any(t.type == "guide" for t in tours.targets),
|
|
78
|
+
app_urls={role: app.url for role, app in config.apps.items()},
|
|
79
|
+
screenshot_widths={name: layout.width for name, layout in config.screenshot.layouts.items()},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Layout media-query sanity check ───────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
_TERM = re.compile(r"\(\s*(min|max)-width\s*:\s*(\d+(?:\.\d+)?)px\s*\)")
|
|
86
|
+
_PREFIX = re.compile(r"^(?:only\s+screen|screen|all)\s+and\s+", re.I)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse_simple_media(query: str) -> Optional[tuple[float, float]]:
|
|
90
|
+
"""``(lo, hi)`` widths a simple ``(min|max)-width: Npx`` query accepts, else None."""
|
|
91
|
+
text = _PREFIX.sub("", query.strip())
|
|
92
|
+
lo, hi = 0.0, float("inf")
|
|
93
|
+
for part in re.split(r"\s+and\s+", text):
|
|
94
|
+
m = _TERM.fullmatch(part.strip())
|
|
95
|
+
if not m:
|
|
96
|
+
return None
|
|
97
|
+
value = float(m.group(2))
|
|
98
|
+
if m.group(1) == "min":
|
|
99
|
+
lo = max(lo, value)
|
|
100
|
+
else:
|
|
101
|
+
hi = min(hi, value)
|
|
102
|
+
return lo, hi
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def settings_diagnostics(config: SteppyConfig, settings: ToursSettings) -> list[Diagnostic]:
|
|
106
|
+
"""Config-level checks that need more than one block at a time."""
|
|
107
|
+
out: list[Diagnostic] = []
|
|
108
|
+
tours = config.tours
|
|
109
|
+
if tours is not None and tours.guide_base_url and "${" in tours.guide_base_url:
|
|
110
|
+
out.append(
|
|
111
|
+
Diagnostic(
|
|
112
|
+
"warning",
|
|
113
|
+
"app-url-unresolved",
|
|
114
|
+
f"tours.guide_base_url {tours.guide_base_url!r} references an unset environment variable; "
|
|
115
|
+
"tour files get no 'Open the full guide' link",
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
for locale in settings.locales:
|
|
119
|
+
if base_language(locale) not in CHROME_STRING_LOCALES:
|
|
120
|
+
out.append(
|
|
121
|
+
Diagnostic(
|
|
122
|
+
"warning",
|
|
123
|
+
"chrome-strings-missing",
|
|
124
|
+
f"tour locale {locale!r} has no built-in overlay strings (buttons, progress, generic hints); "
|
|
125
|
+
f"the overlay shows them in English next to {locale} text unless your app sets the host's "
|
|
126
|
+
f"`strings` for {locale!r}",
|
|
127
|
+
hint=f"built in: {', '.join(CHROME_STRING_LOCALES)}",
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
widths = settings.screenshot_widths
|
|
131
|
+
for name, query in settings.layouts:
|
|
132
|
+
bounds = parse_simple_media(query)
|
|
133
|
+
width = widths.get(name)
|
|
134
|
+
if bounds is None or width is None:
|
|
135
|
+
out.append(
|
|
136
|
+
Diagnostic(
|
|
137
|
+
"info",
|
|
138
|
+
"layout-media-unchecked",
|
|
139
|
+
f"tours.layouts[{name!r}] = {query!r} is not a simple width query; "
|
|
140
|
+
"the tour replay will still check it in the browser",
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
continue
|
|
144
|
+
lo, hi = bounds
|
|
145
|
+
if not lo <= width <= hi:
|
|
146
|
+
out.append(
|
|
147
|
+
Diagnostic(
|
|
148
|
+
"error",
|
|
149
|
+
"layout-media-mismatch",
|
|
150
|
+
f"tours.layouts[{name!r}] = {query!r} does not match the {name} viewport width ({width}px) "
|
|
151
|
+
"from screenshot.layouts, so the overlay would skip that layout's steps where CI runs them",
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
for other, other_width in widths.items():
|
|
155
|
+
if other != name and other in dict(settings.layouts) and lo <= other_width <= hi:
|
|
156
|
+
out.append(
|
|
157
|
+
Diagnostic(
|
|
158
|
+
"warning",
|
|
159
|
+
"layout-media-overlap",
|
|
160
|
+
f"tours.layouts[{name!r}] = {query!r} also matches the {other} viewport ({other_width}px); "
|
|
161
|
+
f"{name}-only steps would show on {other} screens too",
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return out
|