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
steppy/tours/model.py ADDED
@@ -0,0 +1,120 @@
1
+ """The compiled intermediate representation and the replay hand-off.
2
+
3
+ Pure data: frozen dataclasses with no behaviour beyond small lookups, so the
4
+ inference, text, emission and replay layers can be tested in isolation.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Literal, Optional
11
+
12
+ Disposition = Literal["setup", "tour", "run", "drop"]
13
+
14
+
15
+ @dataclass
16
+ class TourStepPlan:
17
+ """One step of a tour, before text resolution. ``step_id`` is set by ``ids``."""
18
+
19
+ step_index: int # index into scenario.steps
20
+ step_number: int
21
+ step_title: str
22
+ action: str
23
+ target: Optional[str] # test-id value, or None for a centred step
24
+ source_selector: Optional[str] # authored selector CI acts on (None when centred)
25
+ advance: str
26
+ wait_for: Optional[str] = None
27
+ placement: Optional[str] = None
28
+ layouts: Optional[tuple[str, ...]] = None
29
+ field: Optional[str] = None # fill-form: the `fields` key this sub-step fills
30
+ step_id: str = ""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ReplayTourStep:
35
+ """A tour step as the replay test sees it."""
36
+
37
+ step_id: str
38
+ index: int # 0-based position in the tour (identical in every locale)
39
+ target: Optional[str]
40
+ wait_for: Optional[str]
41
+ advance: str
42
+ layouts: Optional[tuple[str, ...]]
43
+ field: Optional[str] = None
44
+ source_selector: Optional[str] = None
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ReplayEntry:
49
+ """What the replay does with one scenario step (aligned 1:1 with scenario.steps)."""
50
+
51
+ step_number: int
52
+ disposition: Disposition
53
+ reason: str
54
+ tour_steps: tuple[ReplayTourStep, ...] = ()
55
+ reloads_page: bool = False
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class ReplayPlan:
60
+ """Everything the replay renderer needs; produced by the compiler."""
61
+
62
+ scenario_slug: str
63
+ tour_id: str
64
+ role: str
65
+ auth: bool
66
+ default_locale: str
67
+ locales: tuple[str, ...]
68
+ entry_route: Optional[str]
69
+ entry_params: tuple[tuple[str, str], ...] # (param, _interp template) in route order
70
+ entry_step_number: Optional[int]
71
+ start_index: int
72
+ entries: tuple[ReplayEntry, ...]
73
+ tour_step_count: int
74
+ layouts: tuple[tuple[str, str], ...] = () # (layout, media query) for layouts kept steps use
75
+ test_id_attribute: str = "data-testid"
76
+
77
+ def entry_for(self, step_number: int) -> ReplayEntry:
78
+ for entry in self.entries:
79
+ if entry.step_number == step_number:
80
+ return entry
81
+ raise KeyError(step_number)
82
+
83
+
84
+ @dataclass
85
+ class ScenarioInference:
86
+ """The locale-independent result of inferring one scenario."""
87
+
88
+ role: str
89
+ entry_route: Optional[str]
90
+ entry_params: tuple[tuple[str, str], ...]
91
+ entry_step_index: Optional[int]
92
+ start_index: int
93
+ steps: list[TourStepPlan]
94
+ entries: list[ReplayEntry] = field(default_factory=list)
95
+
96
+
97
+ @dataclass
98
+ class CompiledTour:
99
+ """One tour, ready to serialize: inference + per-locale text."""
100
+
101
+ id: str
102
+ scenario_slug: str
103
+ role: str
104
+ entry_route: Optional[str]
105
+ steps: list[TourStepPlan]
106
+ locales: tuple[str, ...] # emitted locales, in config order
107
+ titles: dict[str, str] # locale -> tour title
108
+ step_texts: dict[str, list[tuple[Optional[str], Optional[str]]]] # locale -> [(title, text)] per step
109
+ guide_urls: dict[str, str] # locale -> guideUrl (absent when no guide link)
110
+
111
+
112
+ @dataclass
113
+ class TourCompileResult:
114
+ tours: list[CompiledTour]
115
+ replay_plans: dict[str, ReplayPlan]
116
+ diagnostics: list # list[Diagnostic]
117
+
118
+ @property
119
+ def has_errors(self) -> bool:
120
+ return any(d.severity == "error" for d in self.diagnostics)
steppy/tours/paths.py ADDED
@@ -0,0 +1,126 @@
1
+ """Filesystem guards for tour output: where we may write, and what we may delete.
2
+
3
+ Tour files land in two kinds of place: the canonical tree under
4
+ ``output.tours_dir`` and app-assets directories inside *someone else's*
5
+ repository. Pruning stale files there is only safe when the directory is
6
+ provably ours, so the rules are strict:
7
+
8
+ * a directory is ours only when its ``tours.manifest.json`` carries our
9
+ ``kind`` marker — a foreign or unreadable manifest stops everything;
10
+ * only files that previous manifest *listed* (and that look like tour file
11
+ names) are ever deleted — never a glob — and an existing file is only ever
12
+ overwritten or deleted when it is itself a tour file (its JSON ``kind``);
13
+ * no target may be, contain, or sit inside the project root, the scenarios,
14
+ the generated tests, the captures, or another target.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import tempfile
23
+ from pathlib import Path
24
+ from typing import Iterable, Optional
25
+
26
+ from ..naming import TOUR_FILE_KIND, TOURS_MANIFEST_FILENAME, TOURS_MANIFEST_KIND
27
+ from ..tour_spec import TOUR_FILE_NAME_PATTERN
28
+
29
+ #: Names that betray a project or package root — never write into a directory holding one.
30
+ GUARDED_NAMES = ("config.yaml", "pyproject.toml", ".git", "scenarios", "src", "package.json", "angular.json")
31
+
32
+
33
+ class TourPathError(ValueError):
34
+ """A tour target or prune would touch a path it must not."""
35
+
36
+
37
+ def _resolved(path: Path) -> Path:
38
+ return Path(os.path.abspath(path))
39
+
40
+
41
+ def overlaps(a: Path, b: Path) -> bool:
42
+ """True when ``a`` and ``b`` are the same path or one contains the other."""
43
+ a, b = _resolved(a), _resolved(b)
44
+ return a == b or a in b.parents or b in a.parents
45
+
46
+
47
+ def check_emit_dir(path: Path, *, project_root: Path, owned_roots: Iterable[Path], label: str) -> None:
48
+ """Refuse a write target that could clobber something that is not ours."""
49
+ target = _resolved(path)
50
+ root = _resolved(project_root)
51
+ if target == Path(target.anchor):
52
+ raise TourPathError(f"{label} {path} is a filesystem root")
53
+ if target == root or target in root.parents:
54
+ raise TourPathError(f"{label} {path} is the project root or one of its parents")
55
+ for owned in owned_roots:
56
+ if overlaps(target, owned):
57
+ raise TourPathError(f"{label} {path} overlaps {owned}, which Steppy owns for something else")
58
+ if target.is_dir():
59
+ clash = [name for name in GUARDED_NAMES if (target / name).exists()]
60
+ if clash:
61
+ raise TourPathError(
62
+ f"{label} {path} contains {', '.join(clash)} — it looks like a project directory, not a tours folder"
63
+ )
64
+
65
+
66
+ def read_marker(directory: Path) -> Optional[dict]:
67
+ """The previous manifest if ``directory`` is ours, None if it has none; raise if foreign."""
68
+ manifest = directory / TOURS_MANIFEST_FILENAME
69
+ if not manifest.exists():
70
+ return None
71
+ try:
72
+ data = json.loads(manifest.read_bytes().decode("utf-8"))
73
+ except (ValueError, UnicodeDecodeError, OSError) as exc:
74
+ raise TourPathError(f"{manifest} is not readable JSON ({exc}); refusing to touch {directory}") from exc
75
+ if not isinstance(data, dict) or data.get("kind") != TOURS_MANIFEST_KIND:
76
+ raise TourPathError(
77
+ f"{manifest} is not a Steppy tours manifest; refusing to touch {directory} — delete it manually"
78
+ )
79
+ return data
80
+
81
+
82
+ def listed_files(manifest: Optional[dict]) -> set[str]:
83
+ """Tour file names a (marker-checked) manifest lists; anything path-like is ignored."""
84
+ if not manifest:
85
+ return set()
86
+ names: set[str] = set()
87
+ for tour in manifest.get("tours") or []:
88
+ if not isinstance(tour, dict):
89
+ continue
90
+ for name in (tour.get("files") or {}).values():
91
+ if isinstance(name, str) and re.fullmatch(TOUR_FILE_NAME_PATTERN, name):
92
+ names.add(name)
93
+ return names
94
+
95
+
96
+ def is_tour_file(path: Path) -> bool:
97
+ """True when ``path`` is a JSON file carrying the tour-file ``kind`` marker."""
98
+ try:
99
+ data = json.loads(path.read_bytes().decode("utf-8"))
100
+ except (ValueError, UnicodeDecodeError, OSError):
101
+ return False
102
+ return isinstance(data, dict) and data.get("kind") == TOUR_FILE_KIND
103
+
104
+
105
+ def check_tour_file(path: Path, verb: str) -> None:
106
+ """Refuse to ``verb`` (overwrite/delete) a file that is not a Steppy tour file."""
107
+ if not is_tour_file(path):
108
+ raise TourPathError(
109
+ f"{path} is not a Steppy tour file; refusing to {verb} it — move it away or delete it manually"
110
+ )
111
+
112
+
113
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
114
+ """Write ``data`` to ``path`` via a sibling temp file + ``os.replace``."""
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
117
+ try:
118
+ with os.fdopen(fd, "wb") as handle:
119
+ handle.write(data)
120
+ os.replace(tmp, path)
121
+ except BaseException:
122
+ try:
123
+ os.unlink(tmp)
124
+ except OSError:
125
+ pass
126
+ raise
@@ -0,0 +1,277 @@
1
+ """The entry point ``steppy generate`` and ``steppy tours`` share.
2
+
3
+ :func:`prepare_tours` does everything that can fail — parsing, compiling,
4
+ resolving the build id, serializing, self-checking, guarding every target and
5
+ reading every marker — **before** anything is written, so a refusal never
6
+ leaves half an output behind. :meth:`TourPipeline.emit` then writes.
7
+
8
+ Tours are always compiled from **every** scenario in the directory, even when
9
+ ``generate`` was asked for one file or a tag: a manifest must never depend on
10
+ CLI arguments.
11
+
12
+ ``mode="check"`` (``steppy tours --check``) plans only the app-assets
13
+ targets — the copies an app commits — and compares their *content* for the
14
+ build id each one records: the canonical tree is a gitignored build output, and
15
+ committed files always carry an older id under ``git-sha`` or a CI-stamped id.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+ from pathlib import Path
22
+ from typing import Literal, Optional
23
+
24
+ from ..buildid import BuildIdError, is_build_id, resolve_build_id
25
+ from ..captures import DEFAULT_GUIDES_DIR, DEFAULT_SCREENSHOTS_DIR, resolve_output_dir
26
+ from ..contracts.tours import TourDataInvalid
27
+ from ..models import SteppyConfig, Scenario
28
+ from ..naming import TOURS_MANIFEST_FILENAME
29
+ from ..parser import ParseError, parse_scenario_file, validate_scenario_layouts, validate_scenario_roles
30
+ from .compiler import compile_tours, opted_in
31
+ from .diagnostics import Diagnostic, sort_diagnostics
32
+ from .emit import (
33
+ DirPlan,
34
+ FileStatus,
35
+ TourArtifacts,
36
+ apply_dir,
37
+ plan_dir,
38
+ remove_build_dir,
39
+ render_artifacts,
40
+ sibling_build_dirs,
41
+ )
42
+ from .model import ReplayPlan, TourCompileResult
43
+ from .paths import TourPathError, check_emit_dir, read_marker
44
+ from .settings import ToursSettings, settings_diagnostics
45
+
46
+ Mode = Literal["generate", "tours", "validate", "check"]
47
+
48
+
49
+ @dataclass
50
+ class TourPipeline:
51
+ settings: ToursSettings
52
+ diagnostics: list[Diagnostic] = field(default_factory=list)
53
+ result: Optional[TourCompileResult] = None
54
+ build_id: Optional[str] = None
55
+ artifacts: Optional[TourArtifacts] = None
56
+ plans: list[DirPlan] = field(default_factory=list)
57
+ stale_builds: list[Path] = field(default_factory=list)
58
+ replay_plans: dict[str, ReplayPlan] = field(default_factory=dict)
59
+ #: ``check`` mode: app-assets dir → the build id its manifest records (None: no tour files there).
60
+ checked: dict[Path, Optional[str]] = field(default_factory=dict)
61
+ #: ``check`` mode: why the configured build id could not be resolved here, if it could not.
62
+ build_id_error: Optional[str] = None
63
+
64
+ @property
65
+ def has_errors(self) -> bool:
66
+ return any(d.severity == "error" for d in self.diagnostics)
67
+
68
+ def statuses(self) -> dict[str, str]:
69
+ out: dict[str, str] = {}
70
+ for plan in self.plans:
71
+ for status in plan.statuses:
72
+ out[str(status.path)] = status.status
73
+ for directory in self.stale_builds:
74
+ out[str(directory)] = "removed"
75
+ return out
76
+
77
+ def emit(self, dry_run: bool = False) -> dict[str, str]:
78
+ """Write every planned file (unless ``dry_run``); return path → status."""
79
+ statuses = self.statuses()
80
+ if dry_run or self.has_errors:
81
+ return statuses
82
+ for plan in self.plans:
83
+ apply_dir(plan)
84
+ for directory in self.stale_builds:
85
+ remove_build_dir(directory)
86
+ return statuses
87
+
88
+
89
+ def _err(code: str, message: str, scenario: Optional[str] = None) -> Diagnostic:
90
+ return Diagnostic("error", code, message, scenario=scenario)
91
+
92
+
93
+ def _read_dir(scenarios_dir: Path, diagnostics: list[Diagnostic]) -> list[Scenario]:
94
+ out = []
95
+ for path in sorted(scenarios_dir.glob("*.md")):
96
+ try:
97
+ out.append(parse_scenario_file(path))
98
+ except ParseError as exc:
99
+ first = str(exc).splitlines()[0] if str(exc) else repr(exc)
100
+ diagnostics.append(
101
+ _err(
102
+ "scenario-unreadable",
103
+ f"{path.name} does not parse ({first}); in-app tours are compiled from every scenario, "
104
+ "so the tour set would be incomplete",
105
+ )
106
+ )
107
+ return out
108
+
109
+
110
+ def prepare_tours(
111
+ config: SteppyConfig,
112
+ *,
113
+ project_root: Path,
114
+ scenarios_dir: Path,
115
+ selected: Optional[list[Scenario]] = None,
116
+ all_scenarios: Optional[list[Scenario]] = None,
117
+ build_id_override: Optional[str] = None,
118
+ mode: Mode = "generate",
119
+ ) -> TourPipeline:
120
+ root = Path(project_root).resolve()
121
+ settings = ToursSettings.from_config(config, root)
122
+ pipeline = TourPipeline(settings=settings)
123
+ diags = pipeline.diagnostics
124
+ diags.extend(settings_diagnostics(config, settings))
125
+
126
+ scenarios = all_scenarios if all_scenarios is not None else _read_dir(scenarios_dir, diags)
127
+ by_slug = {s.slug: s for s in scenarios}
128
+
129
+ resolved_dir = scenarios_dir.resolve()
130
+ selected_slugs = None
131
+ if selected is not None:
132
+ selected_slugs = {s.slug for s in selected}
133
+ for s in opted_in(selected, settings):
134
+ path = Path(s.file_path).resolve() if s.file_path else None
135
+ if path is None or path.parent != resolved_dir:
136
+ diags.append(
137
+ _err(
138
+ "scenario-unreadable",
139
+ f"{s.slug} opts into tours but is not in {scenarios_dir}; tours are compiled from that "
140
+ "directory, so run the command with --scenarios-dir pointing at it",
141
+ scenario=s.slug,
142
+ )
143
+ )
144
+ by_slug.setdefault(s.slug, s)
145
+
146
+ roles, layouts = set(config.apps), set(config.screenshot.layouts)
147
+ for s in opted_in(list(by_slug.values()), settings):
148
+ if selected_slugs is not None and s.slug in selected_slugs:
149
+ continue # the caller already validated the selection
150
+ for problem in validate_scenario_roles(s, roles) + validate_scenario_layouts(s, layouts):
151
+ diags.append(_err("scenario-unreadable", f"{problem}", scenario=s.slug))
152
+
153
+ result = compile_tours(list(by_slug.values()), settings)
154
+ pipeline.result = result
155
+ diags.extend(result.diagnostics)
156
+ if not result.tours and not result.has_errors:
157
+ diags.append(
158
+ Diagnostic(
159
+ "warning",
160
+ "no-tours",
161
+ "tours are enabled but no scenario opts in (`tour: true`); an empty manifest is written",
162
+ )
163
+ )
164
+ if settings.replay:
165
+ pipeline.replay_plans = {
166
+ slug: plan for slug, plan in result.replay_plans.items() if selected_slugs is None or slug in selected_slugs
167
+ }
168
+ if pipeline.has_errors or mode == "validate":
169
+ pipeline.diagnostics[:] = sort_diagnostics(diags)
170
+ return pipeline
171
+
172
+ owned = [
173
+ resolve_output_dir(root, config.output.screenshots_dir, DEFAULT_SCREENSHOTS_DIR),
174
+ resolve_output_dir(root, config.output.guides_dir, DEFAULT_GUIDES_DIR),
175
+ resolved_dir,
176
+ root / "tests",
177
+ root / ".steppy",
178
+ root / ".auth",
179
+ root / ".git",
180
+ ]
181
+
182
+ try:
183
+ pipeline.build_id = resolve_build_id(settings.build_id_raw, root, override=build_id_override)
184
+ except BuildIdError as exc:
185
+ if mode == "check":
186
+ pipeline.build_id_error = str(exc) # only shown in a note: check compares recorded ids
187
+ else:
188
+ diags.append(_err("build-id", str(exc)))
189
+ pipeline.diagnostics[:] = sort_diagnostics(diags)
190
+ return pipeline
191
+
192
+ if mode == "check":
193
+ _prepare_check(pipeline, result, root, owned)
194
+ pipeline.diagnostics[:] = sort_diagnostics(diags)
195
+ return pipeline
196
+
197
+ try:
198
+ pipeline.artifacts = render_artifacts(result, settings, pipeline.build_id)
199
+ except TourDataInvalid as exc:
200
+ diags.append(_err("limit-exceeded", str(exc)))
201
+ pipeline.diagnostics[:] = sort_diagnostics(diags)
202
+ return pipeline
203
+
204
+ try:
205
+ check_emit_dir(
206
+ settings.tours_root,
207
+ project_root=root,
208
+ owned_roots=[*owned, *settings.app_assets_dirs],
209
+ label="output.tours_dir",
210
+ )
211
+ canonical = settings.tours_root / pipeline.build_id
212
+ pipeline.plans.append(plan_dir(canonical, pipeline.artifacts))
213
+ pipeline.stale_builds = sibling_build_dirs(settings.tours_root, pipeline.build_id)
214
+ for directory in settings.app_assets_dirs:
215
+ others = [d for d in settings.app_assets_dirs if d != directory]
216
+ check_emit_dir(
217
+ directory,
218
+ project_root=root,
219
+ owned_roots=[*owned, settings.tours_root, *others],
220
+ label="tours app-assets target",
221
+ )
222
+ if not directory.parent.is_dir():
223
+ message = f"the app-assets target {directory} has no parent directory (is the app checked out?)"
224
+ if mode == "generate":
225
+ diags.append(Diagnostic("warning", "app-assets-skipped", message + "; skipped"))
226
+ continue
227
+ diags.append(_err("target-path", message))
228
+ continue
229
+ pipeline.plans.append(plan_dir(directory, pipeline.artifacts))
230
+ except TourPathError as exc:
231
+ diags.append(_err("target-path", str(exc)))
232
+ pipeline.diagnostics[:] = sort_diagnostics(diags)
233
+ return pipeline
234
+
235
+
236
+ def _prepare_check(pipeline: TourPipeline, result: TourCompileResult, root: Path, owned: list[Path]) -> None:
237
+ """Plan the app-assets targets only, each against the build id its own manifest records."""
238
+ settings = pipeline.settings
239
+ diags = pipeline.diagnostics
240
+ rendered: dict[str, TourArtifacts] = {}
241
+ for directory in settings.app_assets_dirs:
242
+ manifest_path = directory / TOURS_MANIFEST_FILENAME
243
+ try:
244
+ others = [d for d in settings.app_assets_dirs if d != directory]
245
+ check_emit_dir(
246
+ directory,
247
+ project_root=root,
248
+ owned_roots=[*owned, settings.tours_root, *others],
249
+ label="tours app-assets target",
250
+ )
251
+ if not directory.parent.is_dir():
252
+ diags.append(
253
+ _err(
254
+ "target-path",
255
+ f"the app-assets target {directory} has no parent directory (is the app checked out?)",
256
+ )
257
+ )
258
+ continue
259
+ previous = read_marker(directory) if directory.is_dir() else None
260
+ if previous is None:
261
+ pipeline.checked[directory] = None
262
+ pipeline.plans.append(DirPlan(directory=directory, statuses=[FileStatus(manifest_path, "missing")]))
263
+ continue
264
+ recorded = previous.get("buildId")
265
+ if not is_build_id(recorded):
266
+ diags.append(
267
+ _err("build-id", f"{manifest_path} records the build id {recorded!r}, which is not a valid one")
268
+ )
269
+ continue
270
+ pipeline.checked[directory] = recorded
271
+ if recorded not in rendered:
272
+ rendered[recorded] = render_artifacts(result, settings, recorded)
273
+ pipeline.plans.append(plan_dir(directory, rendered[recorded]))
274
+ except TourDataInvalid as exc:
275
+ diags.append(_err("limit-exceeded", str(exc)))
276
+ except TourPathError as exc:
277
+ diags.append(_err("target-path", str(exc)))
@@ -0,0 +1,124 @@
1
+ """The ``steppy build`` side of in-app tours: the ``guide`` target.
2
+
3
+ ``steppy generate`` / ``steppy tours`` write the canonical tree
4
+ (``output/tours/<build-id>/``); a static guide build republishes it byte for
5
+ byte at ``<site>/tours/<build-id>/``, where an app in remote mode fetches it.
6
+ ``build`` never compiles tours itself — it needs no scenario parsing for this,
7
+ and a guide site must serve exactly the bytes whose hashes the apps bundle.
8
+
9
+ Everything here runs **before** the build wipes its output directory: a tree
10
+ that is missing, stale (a file whose SRI no longer matches) or edited fails the
11
+ build while the previous site is still intact.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Iterable, Optional
20
+
21
+ from ..buildid import BuildIdError, resolve_build_id
22
+ from ..contracts.tours import TourDataInvalid, TourSchemaUnsupported, check_consistency, parse_tours_manifest
23
+ from ..naming import GUIDE_SITE_TOURS_DIR, TOURS_MANIFEST_FILENAME
24
+ from .paths import TourPathError, overlaps, read_marker
25
+ from .settings import ToursSettings
26
+
27
+
28
+ class TourPublishError(ValueError):
29
+ """The guide target cannot be published as configured; the build must not wipe anything."""
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class GuidePublishPlan:
34
+ build_id: str
35
+ #: (file name, bytes): tour files first, the manifest last.
36
+ files: tuple[tuple[str, bytes], ...]
37
+ tour_ids: tuple[str, ...]
38
+ warnings: tuple[str, ...] = ()
39
+
40
+ @property
41
+ def site_dir(self) -> str:
42
+ """The site-relative directory the tree is published under."""
43
+ return f"{GUIDE_SITE_TOURS_DIR}/{self.build_id}"
44
+
45
+
46
+ def check_build_overlaps(settings: ToursSettings, output_dir: Path, assets_dir: Path) -> None:
47
+ """The build rmtrees ``output_dir`` and ``assets_dir``: no tour directory may live there."""
48
+ tour_dirs = [("output.tours_dir", settings.tours_root)]
49
+ tour_dirs += [("an app-assets target", d) for d in settings.app_assets_dirs]
50
+ for label, tour_dir in tour_dirs:
51
+ for build_label, build_dir in (("outputDir", output_dir), ("assetsDir", assets_dir)):
52
+ if overlaps(tour_dir, build_dir):
53
+ raise TourPublishError(
54
+ f"tours: {label} ({tour_dir}) overlaps the build's {build_label} ({build_dir}), which "
55
+ "`steppy build` deletes before writing — move one of them"
56
+ )
57
+
58
+
59
+ def plan_guide_publish(
60
+ settings: ToursSettings,
61
+ *,
62
+ guide_slugs: Iterable[str],
63
+ build_locales: Iterable[str],
64
+ ) -> GuidePublishPlan:
65
+ """Read and verify the canonical tree for the configured build id."""
66
+ try:
67
+ build_id = resolve_build_id(settings.build_id_raw, settings.project_root)
68
+ except BuildIdError as exc:
69
+ raise TourPublishError(f"tours: {exc}") from exc
70
+ tree = settings.tours_root / build_id
71
+ manifest_path = tree / TOURS_MANIFEST_FILENAME
72
+ if not manifest_path.is_file():
73
+ raise TourPublishError(
74
+ f"In-app tours for build {build_id} not found at {tree}. Run `steppy generate` or "
75
+ "`steppy tours` first (with the same tours.build_id)."
76
+ )
77
+ try:
78
+ read_marker(tree)
79
+ raw = manifest_path.read_bytes()
80
+ manifest = parse_tours_manifest(json.loads(raw.decode("utf-8")))
81
+ except (TourPathError, TourDataInvalid, TourSchemaUnsupported, ValueError) as exc:
82
+ raise TourPublishError(f"tours: {manifest_path} is not a valid tours manifest: {exc}") from exc
83
+ if manifest.buildId != build_id:
84
+ raise TourPublishError(f"tours: {manifest_path} is for build {manifest.buildId!r}, expected {build_id!r}")
85
+
86
+ names = sorted({name for tour in manifest.tours for name in tour.files.values()})
87
+ files = {name: (tree / name).read_bytes() for name in names if (tree / name).is_file()}
88
+ problems = check_consistency(manifest, files)
89
+ if problems:
90
+ raise TourPublishError(
91
+ f"tours: the tree at {tree} is stale or was edited ({problems[0]}); re-run `steppy tours`"
92
+ )
93
+
94
+ warnings = list(_guide_link_warnings(settings, manifest, files, set(guide_slugs), set(build_locales)))
95
+ return GuidePublishPlan(
96
+ build_id=build_id,
97
+ files=(*((name, files[name]) for name in names), (TOURS_MANIFEST_FILENAME, raw)),
98
+ tour_ids=tuple(tour.id for tour in manifest.tours),
99
+ warnings=tuple(warnings),
100
+ )
101
+
102
+
103
+ def _guide_link_warnings(
104
+ settings: ToursSettings,
105
+ manifest,
106
+ files: dict[str, bytes],
107
+ guide_slugs: set[str],
108
+ build_locales: set[str],
109
+ ) -> Iterable[str]:
110
+ """A tour's "open the full guide" link that this build will not satisfy."""
111
+ base = (settings.guide_base_url or "").rstrip("/") + "/"
112
+ seen: set[str] = set()
113
+ for tour in manifest.tours:
114
+ for name in tour.files.values():
115
+ url: Optional[str] = json.loads(files[name].decode("utf-8")).get("guideUrl")
116
+ if not url or not url.startswith(base) or url in seen:
117
+ continue
118
+ seen.add(url)
119
+ slug, _, page = url[len(base) :].partition("/")
120
+ locale = page.removesuffix(".html")
121
+ if slug not in guide_slugs:
122
+ yield f"tours: tour {tour.id!r} links to guide {slug!r}, which this build does not publish ({url})"
123
+ elif locale not in build_locales:
124
+ yield f"tours: tour {tour.id!r} links to the {locale!r} page of {slug!r}, which this build has no locale for"