steppy-dev 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,229 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ import { Component, computed, effect, ElementRef, inject, signal, ViewEncapsulation } from '@angular/core';
3
+ import { DomSanitizer } from '@angular/platform-browser';
4
+ import { toSignal } from '@angular/core/rxjs-interop';
5
+ import { map, startWith } from 'rxjs/operators';
6
+ import { TranslateService, TranslatePipe } from '@ngx-translate/core';
7
+ {% if has_multiple_modes or has_multiple_layouts %}
8
+ import { FormsModule } from '@angular/forms';
9
+ import { SelectButtonModule } from 'primeng/selectbutton';
10
+ {% endif %}
11
+ {% if onboarding_strip %}
12
+ import { RouterLink } from '@angular/router';
13
+ {% endif %}
14
+ {% if has_interactive %}
15
+ import { {{ class_name }}InteractiveComponent } from './{{ slug }}-interactive.component';
16
+ {% endif %}
17
+ {% if has_video %}
18
+ import { {{ class_name }}VideoComponent } from './{{ slug }}-video.component';
19
+ {% endif %}
20
+ import { CONTENT, STEPS, TIMINGS } from './{{ slug }}.content';
21
+
22
+ // Stepbystep cursor animation. The guide HTML is injected via [innerHTML], and
23
+ // the browser never runs <script> tags inserted that way — so the cursor
24
+ // driver is loaded here (once per page, like the interactive player bundle)
25
+ // and (re)invoked after every render. The vendored IIFE exposes
26
+ // window.__STEPPY_CURSOR__.mount(root, steps, timings), which returns a
27
+ // disposer that disconnects the observer and aborts any in-flight animation.
28
+ const CURSOR_BUNDLE_URL = '/assets/{{ role }}/steppy-cursor.js';
29
+
30
+ interface SteppyCursorApi {
31
+ mount(root: ParentNode, steps: unknown, timings?: unknown): () => void;
32
+ }
33
+
34
+ let cursorScriptLoading: Promise<void> | null = null;
35
+
36
+ function ensureCursorLoaded(): Promise<void> {
37
+ // Idempotent: inserts the <script> once per page no matter how many guides
38
+ // mount. Resolves immediately once window.__STEPPY_CURSOR__ is available.
39
+ if (cursorScriptLoading) return cursorScriptLoading;
40
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
41
+ return Promise.resolve();
42
+ }
43
+ if ((window as unknown as { __STEPPY_CURSOR__?: SteppyCursorApi }).__STEPPY_CURSOR__) {
44
+ cursorScriptLoading = Promise.resolve();
45
+ return cursorScriptLoading;
46
+ }
47
+ cursorScriptLoading = new Promise<void>((resolve, reject) => {
48
+ const script = document.createElement('script');
49
+ script.src = CURSOR_BUNDLE_URL;
50
+ script.async = true;
51
+ script.onload = () => resolve();
52
+ script.onerror = (err) => reject(err);
53
+ document.head.appendChild(script);
54
+ });
55
+ return cursorScriptLoading;
56
+ }
57
+
58
+ function scheduleAfterRender(cb: () => void): void {
59
+ // Defer past Angular's [innerHTML] DOM flush so the freshly-injected
60
+ // .steppy-step sections exist before the driver observes them.
61
+ if (typeof requestAnimationFrame === 'function') {
62
+ requestAnimationFrame(() => cb());
63
+ } else {
64
+ setTimeout(cb, 0);
65
+ }
66
+ }
67
+
68
+ @Component({
69
+ selector: 'app-guide-{{ slug }}',
70
+ standalone: true,
71
+ imports: [
72
+ TranslatePipe,
73
+ {% if has_multiple_modes or has_multiple_layouts %}
74
+ FormsModule,
75
+ SelectButtonModule,
76
+ {% endif %}
77
+ {% if onboarding_strip %}
78
+ RouterLink,
79
+ {% endif %}
80
+ {% if has_interactive %}
81
+ {{ class_name }}InteractiveComponent,
82
+ {% endif %}
83
+ {% if has_video %}
84
+ {{ class_name }}VideoComponent,
85
+ {% endif %}
86
+ ],
87
+ template: `
88
+ {% if onboarding_strip %}
89
+ <nav class="steppy-onboarding-strip" aria-label="Onboarding progress">
90
+ <span class="steppy-onboarding-strip-progress">
91
+ <a [routerLink]="['../', '{{ onboarding_strip.path_slug }}']">{{ '{{' }} '{{ role_namespace }}.onboarding.title' | translate {{ '}}' }}</a>
92
+ — {{ '{{' }} '{{ role_namespace }}.onboarding.stepLabel' | translate: { position: {{ onboarding_strip.position }}, total: {{ onboarding_strip.total }} } {{ '}}' }}
93
+ </span>
94
+ <span class="steppy-onboarding-strip-nav">
95
+ {% if onboarding_strip.prev %}
96
+ <a class="steppy-onboarding-prev"
97
+ [routerLink]="['../', '{{ onboarding_strip.prev.slug }}']"
98
+ [queryParams]="{ onboarding: 1 }">‹ {{ '{{' }} '{{ role_namespace }}.guides.{{ onboarding_strip.prev.slug }}.title' | translate {{ '}}' }}</a>
99
+ {% else %}
100
+ <span class="steppy-onboarding-prev steppy-onboarding-disabled" aria-disabled="true">‹ {{ '{{' }} '{{ role_namespace }}.onboarding.previousLabel' | translate {{ '}}' }}</span>
101
+ {% endif %}
102
+ {% if onboarding_strip.next %}
103
+ <a class="steppy-onboarding-next"
104
+ [routerLink]="['../', '{{ onboarding_strip.next.slug }}']"
105
+ [queryParams]="{ onboarding: 1 }">{{ '{{' }} '{{ role_namespace }}.guides.{{ onboarding_strip.next.slug }}.title' | translate {{ '}}' }} ›</a>
106
+ {% else %}
107
+ <span class="steppy-onboarding-next steppy-onboarding-disabled" aria-disabled="true">{{ '{{' }} '{{ role_namespace }}.onboarding.doneLabel' | translate {{ '}}' }}</span>
108
+ {% endif %}
109
+ </span>
110
+ </nav>
111
+ {% endif %}
112
+ {% if has_multiple_modes or has_multiple_layouts %}
113
+ <div class="guide-toolbar">
114
+ {% if has_multiple_layouts %}
115
+ <p-selectButton
116
+ [options]="layoutOptions"
117
+ [ngModel]="selectedLayout()"
118
+ (ngModelChange)="selectedLayout.set($event)"
119
+ optionLabel="label"
120
+ optionValue="value" />
121
+ {% endif %}
122
+ {% if has_multiple_modes %}
123
+ <p-selectButton
124
+ [options]="modeOptions"
125
+ [(ngModel)]="selectedMode"
126
+ optionLabel="label"
127
+ optionValue="value" />
128
+ {% endif %}
129
+ </div>
130
+ {% endif %}
131
+ {% if has_interactive or has_video %}
132
+ @if (selectedMode() === 'stepbystep') {
133
+ <div [innerHTML]="trustedHtml()"></div>
134
+ }
135
+ {% if has_interactive %}
136
+ @if (selectedMode() === 'interactive') {
137
+ <app-guide-{{ slug }}-interactive{% if has_multiple_layouts %} [variant]="selectedLayout()"{% endif %} />
138
+ }
139
+ {% endif %}
140
+ {% if has_video %}
141
+ @if (selectedMode() === 'video') {
142
+ <app-guide-{{ slug }}-video{% if has_multiple_layouts %} [variant]="selectedLayout()"{% endif %} />
143
+ }
144
+ {% endif %}
145
+ {% else %}
146
+ <div [innerHTML]="trustedHtml()"></div>
147
+ {% endif %}
148
+ `,
149
+ encapsulation: ViewEncapsulation.None,
150
+ })
151
+ export class {{ class_name }}Component {
152
+ private readonly translate = inject(TranslateService);
153
+ private readonly sanitizer = inject(DomSanitizer);
154
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
155
+
156
+ {% if has_multiple_layouts %}
157
+ readonly layoutOptions = [
158
+ {% for layout in layouts %}
159
+ { label: '{{ layout | capitalize }}', value: '{{ layout }}' },
160
+ {% endfor %}
161
+ ];
162
+ {% endif %}
163
+
164
+ {% if has_multiple_modes %}
165
+ readonly modeOptions = [
166
+ {% for mode in modes %}
167
+ { label: '{{ mode_labels[mode] }}', value: '{{ mode }}' },
168
+ {% endfor %}
169
+ ];
170
+ {% endif %}
171
+
172
+ readonly selectedLocale = toSignal(
173
+ this.translate.onLangChange.pipe(
174
+ map((event) => event.lang),
175
+ startWith(this.translate.currentLang || this.translate.getDefaultLang() || '{{ default_locale }}'),
176
+ ),
177
+ { initialValue: this.translate.currentLang || this.translate.getDefaultLang() || '{{ default_locale }}' },
178
+ );
179
+
180
+ readonly selectedLayout = signal<string>('{{ default_layout }}');
181
+ {% if has_multiple_modes %}
182
+ selectedMode = signal('{{ default_mode }}');
183
+ {% else %}
184
+ selectedMode = signal('stepbystep');
185
+ {% endif %}
186
+
187
+ readonly trustedHtml = computed(() => {
188
+ const byLayout = CONTENT[this.selectedLocale()] ?? {};
189
+ const layoutKey = this.selectedLayout();
190
+ // Fall back to "default" (the back-compat single-layout key) if the
191
+ // requested layout isn't present — handles cases where the consumer
192
+ // toggles to a layout that this guide didn't capture.
193
+ const raw = byLayout[layoutKey] ?? byLayout['default'] ?? '';
194
+ return this.sanitizer.bypassSecurityTrustHtml(raw);
195
+ });
196
+
197
+ // Stepbystep cursor lifecycle. Re-drives the animator whenever the rendered
198
+ // DOM changes — locale, layout, and the mode toggle each swap the
199
+ // [innerHTML] content under our feet. The effect's cleanup disposes the
200
+ // previous mount before the next one (and on destroy).
201
+ private cursorDispose: (() => void) | null = null;
202
+
203
+ constructor() {
204
+ effect((onCleanup) => {
205
+ // Track the signals that change the injected DOM so this re-runs on each.
206
+ const locale = this.selectedLocale();
207
+ const layout = this.selectedLayout();
208
+ const mode = this.selectedMode();
209
+ if (mode !== 'stepbystep' || typeof window === 'undefined') return;
210
+ let cancelled = false;
211
+ onCleanup(() => {
212
+ cancelled = true;
213
+ this.cursorDispose?.();
214
+ this.cursorDispose = null;
215
+ });
216
+ scheduleAfterRender(() => {
217
+ if (cancelled) return;
218
+ const byLayout = STEPS[locale] ?? {};
219
+ const steps = byLayout[layout] ?? byLayout['default'] ?? [];
220
+ void ensureCursorLoaded().then(() => {
221
+ if (cancelled) return;
222
+ const api = (window as unknown as { __STEPPY_CURSOR__?: SteppyCursorApi }).__STEPPY_CURSOR__;
223
+ if (!api) return;
224
+ this.cursorDispose = api.mount(this.host.nativeElement, steps, TIMINGS);
225
+ });
226
+ });
227
+ });
228
+ }
229
+ }
@@ -0,0 +1,31 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ export const CONTENT: Record<string, Record<string, string>> = {
3
+ {% for locale, by_layout in content_by_locale.items() %}
4
+ '{{ locale }}': {
5
+ {% for layout_key, html in by_layout.items() %}
6
+ '{{ layout_key }}': {{ html | tojson }},
7
+ {% endfor %}
8
+ },
9
+ {% endfor %}
10
+ };
11
+
12
+ // Per-step cursor frame data, keyed by locale then layout (mirroring CONTENT;
13
+ // single-layout configs collapse to a single "default" key). The stepbystep
14
+ // cursor driver (window.__STEPPY_CURSOR__.mount) reads this to animate the
15
+ // arrow over the [innerHTML]-injected guide: each frame carries the
16
+ // capture-space viewport + cursor behavior the animator projects onto the
17
+ // displayed screenshot. `index` matches the `data-step-index` on each
18
+ // `.steppy-step` section.
19
+ export interface SteppyCursorStepFrame {
20
+ viewport: { width: number; height: number } | null;
21
+ cursor: unknown;
22
+ }
23
+ export interface SteppyCursorStep {
24
+ index: number;
25
+ frames: SteppyCursorStepFrame[];
26
+ }
27
+ export const STEPS: Record<string, Record<string, SteppyCursorStep[]>> = {{ steps_by_locale | tojson }};
28
+
29
+ // Cursor timing overrides for this guide (scenario.guide.cursor); passed
30
+ // straight to the animator, which fills any gaps with its defaults.
31
+ export const TIMINGS: Record<string, number> = {{ cursor_timings | tojson }};
@@ -0,0 +1,3 @@
1
+ # GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ # Everything in this directory is generated and should not be manually edited.
3
+ *
@@ -0,0 +1,39 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ import { Component, ViewEncapsulation } from '@angular/core';
3
+ import { RouterLink } from '@angular/router';
4
+ import { TranslatePipe } from '@ngx-translate/core';
5
+
6
+ interface GroupChild {
7
+ slug: string;
8
+ }
9
+
10
+ @Component({
11
+ selector: 'app-guide-group-{{ group.slug }}',
12
+ standalone: true,
13
+ imports: [RouterLink, TranslatePipe],
14
+ template: `
15
+ <section class="steppy-index">
16
+ <h1>{{ '{{' }} '{{ role_namespace }}.groups.{{ group.slug }}.title' | translate {{ '}}' }}</h1>
17
+ {% if has_description %}
18
+ <p class="steppy-group-description">{{ '{{' }} '{{ role_namespace }}.groups.{{ group.slug }}.description' | translate {{ '}}' }}</p>
19
+ {% endif %}
20
+ @if (children.length === 0) {
21
+ <p class="steppy-index-empty">{{ '{{' }} '{{ role_namespace }}.ui.groupEmpty' | translate {{ '}}' }}</p>
22
+ } @else {
23
+ <ul class="steppy-index-list">
24
+ @for (child of children; track child.slug) {
25
+ <li>
26
+ <a [routerLink]="['../', child.slug]">
27
+ <span class="steppy-index-title">{{ '{{' }} '{{ role_namespace }}.guides.' + child.slug + '.title' | translate {{ '}}' }}</span>
28
+ </a>
29
+ </li>
30
+ }
31
+ </ul>
32
+ }
33
+ </section>
34
+ `,
35
+ encapsulation: ViewEncapsulation.None,
36
+ })
37
+ export class {{ class_name }}GroupComponent {
38
+ readonly children: GroupChild[] = {{ children | tojson(indent=2) }};
39
+ }
@@ -0,0 +1,43 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ import { Component, ViewEncapsulation } from '@angular/core';
3
+ import { RouterLink } from '@angular/router';
4
+ import { TranslatePipe } from '@ngx-translate/core';
5
+
6
+ interface OnboardingStep {
7
+ slug: string;
8
+ }
9
+
10
+ @Component({
11
+ selector: 'app-guide-onboarding',
12
+ standalone: true,
13
+ imports: [RouterLink, TranslatePipe],
14
+ template: `
15
+ <section class="steppy-onboarding">
16
+ <h1>{{ '{{' }} '{{ role_namespace }}.onboarding.title' | translate {{ '}}' }}</h1>
17
+ {% if has_description %}
18
+ <p class="steppy-onboarding-description">{{ '{{' }} '{{ role_namespace }}.onboarding.description' | translate {{ '}}' }}</p>
19
+ {% endif %}
20
+ <ol class="steppy-onboarding-steps">
21
+ @for (step of steps; track step.slug; let i = $index) {
22
+ <li>
23
+ <a [routerLink]="['../', step.slug]" [queryParams]="{ onboarding: 1 }">
24
+ <span class="steppy-onboarding-step-number">{{ '{{' }} i + 1 {{ '}}' }}</span>
25
+ <span class="steppy-onboarding-step-title">{{ '{{' }} '{{ role_namespace }}.guides.' + step.slug + '.title' | translate {{ '}}' }}</span>
26
+ </a>
27
+ </li>
28
+ }
29
+ </ol>
30
+ @if (steps.length > 0) {
31
+ <p class="steppy-onboarding-cta">
32
+ <a class="steppy-onboarding-start"
33
+ [routerLink]="['../', steps[0].slug]"
34
+ [queryParams]="{ onboarding: 1 }">{{ '{{' }} '{{ role_namespace }}.onboarding.start' | translate {{ '}}' }}</a>
35
+ </p>
36
+ }
37
+ </section>
38
+ `,
39
+ encapsulation: ViewEncapsulation.None,
40
+ })
41
+ export class OnboardingComponent {
42
+ readonly steps: OnboardingStep[] = {{ steps | tojson(indent=2) }};
43
+ }
@@ -0,0 +1,34 @@
1
+ // GENERATED BY STEPPY — DO NOT EDIT. Regenerate with: steppy build
2
+ // Role: {{ role }}
3
+ import { Routes } from '@angular/router';
4
+
5
+ export const {{ routes_const }}: Routes = [
6
+ {% for guide in guides %}
7
+ {
8
+ path: '{{ guide.slug }}',
9
+ loadComponent: () =>
10
+ import('./{{ guide.slug }}/{{ guide.slug }}.component').then(
11
+ (m) => m.{{ guide.class_name }}Component,
12
+ ),
13
+ },
14
+ {% endfor %}
15
+ {% for group in groups %}
16
+ {
17
+ path: '{{ group.slug }}',
18
+ loadComponent: () =>
19
+ import('./_groups/{{ group.slug }}.group.component').then(
20
+ (m) => m.{{ group.class_name }}GroupComponent,
21
+ ),
22
+ },
23
+ {% endfor %}
24
+ {% if has_onboarding %}
25
+ {
26
+ path: '{{ onboarding_slug }}',
27
+ loadComponent: () =>
28
+ import('./_onboarding/onboarding.component').then(
29
+ (m) => m.OnboardingComponent,
30
+ ),
31
+ },
32
+ {% endif %}
33
+ { path: '', redirectTo: '{{ default_slug }}', pathMatch: 'full' },
34
+ ];
@@ -0,0 +1,284 @@
1
+ # Steppy CI workflow — copy this to .github/workflows/steppy.yml in your project.
2
+ #
3
+ # Brings your application stack up with Docker Compose, runs your Steppy
4
+ # scenarios against it, and publishes a browsable run report as a build artifact.
5
+ #
6
+ # Prerequisites in your repo:
7
+ # - config.yaml + scenarios/ committed
8
+ # - a docker-compose.yml that boots your app (see the two notes on the compose
9
+ # step below — healthchecks and published ports are both load-bearing)
10
+ # - credentials provided via repository secrets (referenced as ${VAR} in
11
+ # config.yaml — e.g. STEPPY_ADMIN_USERNAME / STEPPY_ADMIN_PASSWORD)
12
+ #
13
+ # If your app already runs somewhere the runner can reach, drop the compose steps
14
+ # and point apps.<role>.url at that deployment instead.
15
+ #
16
+ # This file is the OWNED copy: every trap is documented beside the step it applies
17
+ # to, and it is yours to edit. If you would rather not own it, Steppy also ships
18
+ # the same recipe as a reusable workflow — pin a tag, never a branch:
19
+ #
20
+ # jobs:
21
+ # e2e:
22
+ # uses: Steppy-dev/steppy/.github/workflows/steppy-e2e.yml@v0.3.0
23
+ # with:
24
+ # compose-file: docker-compose.yml
25
+ # secrets:
26
+ # steppy-env: ${{ secrets.STEPPY_ENV }}
27
+ #
28
+ # That form exposes verdict / exit-code / badge-percent as job outputs, but it IS
29
+ # the job — you cannot interleave your own steps into it. Keep this file if you
30
+ # need to (building an image before the run, for instance).
31
+ name: Steppy E2E
32
+
33
+ on:
34
+ push:
35
+ pull_request:
36
+
37
+ jobs:
38
+ e2e:
39
+ runs-on: ubuntu-latest
40
+ steps:
41
+ - uses: actions/checkout@v4
42
+
43
+ - uses: actions/setup-python@v5
44
+ with:
45
+ python-version: '3.12'
46
+
47
+ - name: Install Steppy
48
+ # Steppy is not on PyPI yet, so CI installs from git. Pin a tag rather
49
+ # than a branch: an e2e suite that silently changes its own tooling
50
+ # mid-week turns every unexplained failure into a two-hour bisect.
51
+ #
52
+ # The extras are what make `report.html` and `report.retries` /
53
+ # `steppy run --retries N` actually do something — without them Steppy
54
+ # warns and skips those features rather than failing the run.
55
+ run: pip install 'steppy-dev[retry,report] @ git+https://github.com/Steppy-dev/steppy.git@v0.3.0'
56
+
57
+ - name: Install Playwright browsers
58
+ run: playwright install chromium --with-deps
59
+
60
+ # In-app tours (only with `tours.enabled: true` in config.yaml): fail fast
61
+ # when the tour files committed to the app no longer match the scenarios.
62
+ # No browser, no app. It compares content for the build id each committed
63
+ # copy records, so a new commit alone never fails it. The app must then be
64
+ # BUILT with those files (`docker compose up --build` below) before the
65
+ # tour replays run against it.
66
+ # - name: Check the in-app tour files are current
67
+ # run: steppy tours --check
68
+
69
+ # Two things decide whether this step actually leaves you with a running
70
+ # app, and both are properties of YOUR compose file, not of this workflow:
71
+ #
72
+ # 1. `--wait` only waits on services that declare a `healthcheck:`. With
73
+ # no healthchecks it returns as soon as the containers are created,
74
+ # the scenarios start against a port that is not serving yet, and the
75
+ # run dies wholesale. This is the most common first-run failure.
76
+ # 2. The scenarios and your seed scripts run HERE, on the runner — not
77
+ # inside a container. So the app (and the database, if seeds touch it)
78
+ # must publish its ports, and apps.<role>.url points at localhost.
79
+ - name: Start the application stack
80
+ run: docker compose up -d --wait --wait-timeout 180
81
+
82
+ - name: Generate tests
83
+ run: steppy generate
84
+
85
+ - name: Run scenarios
86
+ env:
87
+ # Map your repository secrets onto the ${VAR} refs in config.yaml.
88
+ STEPPY_ADMIN_USERNAME: ${{ secrets.STEPPY_ADMIN_USERNAME }}
89
+ STEPPY_ADMIN_PASSWORD: ${{ secrets.STEPPY_ADMIN_PASSWORD }}
90
+ # --bundle records per-test and per-step events plus JUnit XML, which is
91
+ # what lets the report below name the step a failure died in instead of
92
+ # only the test. It changes the pytest invocation, so it is opt-in.
93
+ run: steppy run --trace --bundle
94
+
95
+ # When the suite goes red, the question is almost always "was it the tests
96
+ # or the stack?" — and the stack's own logs are the fastest way to know.
97
+ - name: Application logs
98
+ if: failure()
99
+ run: docker compose logs --no-color --tail 200
100
+
101
+ # A browsable report of this run: outcomes, failure causes grouped so one
102
+ # broken selector across forty scenarios reads as one row, the failing step
103
+ # named, and its screenshot. Self-contained and relative — open index.html
104
+ # straight out of the downloaded artifact.
105
+ #
106
+ # `if: always()` because a failing run is exactly when you want it, and
107
+ # `steppy report` exits 0 either way: it describes rendering, not the run.
108
+ - name: Build the run report
109
+ if: always()
110
+ run: steppy report
111
+
112
+ - name: Upload the report
113
+ if: always()
114
+ uses: actions/upload-artifact@v4
115
+ with:
116
+ name: steppy-report
117
+ path: output/report/
118
+
119
+ - name: Upload results
120
+ if: always()
121
+ uses: actions/upload-artifact@v4
122
+ with:
123
+ name: steppy-output
124
+ path: output/
125
+
126
+ # `-v` so the next run starts from a clean database rather than inheriting
127
+ # whatever the last one's seeds left behind.
128
+ - name: Stop the application stack
129
+ if: always()
130
+ run: docker compose down -v
131
+
132
+ # ── Publishing the report to GitHub Pages ─────────────────────────────────────
133
+ #
134
+ # ⚠ READ THIS FIRST. GitHub Pages is PUBLIC — on a public repo there is no access
135
+ # control, and a published screenshot is permanent and indexable. Your captures
136
+ # show your app with real-looking data in it. `screenshot.mask` (config.yaml) can
137
+ # redact fields at capture time, but a leak cannot be taken back.
138
+ #
139
+ # * This is for SHOWCASE and open-source repos, where the stack under test is
140
+ # seeded with throwaway data. For client work, keep the artifact upload above
141
+ # and read the report from there.
142
+ # * The report marks every page `noindex` and ships a `robots.txt` that
143
+ # disallows everything, so it will not be indexed by a well-behaved crawler.
144
+ # That is a courtesy, not a control.
145
+ # * `--screenshots failed` (the default) copies only the captures of variants
146
+ # that did not pass. Use `--screenshots all` deliberately, knowing it puts
147
+ # every step of every locale and layout on the public internet — and that on
148
+ # a large suite it is also what pushes the site towards Pages' size limit.
149
+ #
150
+ # One-time setup: repository Settings → Pages → Source: "GitHub Actions".
151
+ #
152
+ # Publish as an ARTIFACT, never by committing to a gh-pages branch: screenshots
153
+ # pushed on every run grow the repository's history forever and cannot be pruned
154
+ # without a rewrite.
155
+ #
156
+ # permissions:
157
+ # contents: read
158
+ # pages: write
159
+ # id-token: write
160
+ #
161
+ # jobs:
162
+ # # ... the e2e job above ...
163
+ #
164
+ # publish-report:
165
+ # # Default branch only. A report from every PR would publish work in
166
+ # # progress, and PR runs from forks cannot get an id-token anyway.
167
+ # if: always() && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
168
+ # needs: e2e
169
+ # runs-on: ubuntu-latest
170
+ # environment:
171
+ # name: github-pages
172
+ # url: ${{ steps.deploy.outputs.page_url }}
173
+ # steps:
174
+ # - uses: actions/download-artifact@v4
175
+ # with:
176
+ # name: steppy-report
177
+ # path: site
178
+ # - uses: actions/configure-pages@v5
179
+ # - uses: actions/upload-pages-artifact@v3
180
+ # with:
181
+ # path: site
182
+ # - id: deploy
183
+ # uses: actions/deploy-pages@v4
184
+ #
185
+ # A repository has exactly ONE Pages site. If you also publish generated guides
186
+ # (`steppy build`), decide the tree before anything links to it — e.g. the report
187
+ # under site/report/ and the guides under site/guides/ — because moving the
188
+ # report later also moves badge.json, and that URL is a contract with every
189
+ # README that points at it.
190
+ #
191
+ # ── The e2e-surface coverage badge ────────────────────────────────────────────
192
+ #
193
+ # `steppy report` writes a shields endpoint at badge.json in the site root, so
194
+ # once the Pages job above is live the badge is served at <page_url>/badge.json:
195
+ #
196
+ # ![e2e surface](https://img.shields.io/endpoint?url=https://<owner>.github.io/<repo>/badge.json)
197
+ #
198
+ # Three prerequisites, in order — miss any one and there is no number:
199
+ #
200
+ # 1. badge.json is written ONLY when the run recorded coverage. Without
201
+ # `steppy run --coverage` the report has no coverage section and no badge
202
+ # file at all, and the shields URL 404s.
203
+ # 2. A percentage needs a DECLARED surface to score against, which means a
204
+ # `coverage:` block in config.yaml naming `coverage.apis[].openapi` (a URL
205
+ # the runner can reach — your compose stack serving /openapi.json qualifies)
206
+ # and optionally `coverage.sitemap`. With coverage recorded but nothing
207
+ # declared, the badge honestly reads `n/a` in grey rather than 0%.
208
+ # 3. `steppy coverage refresh` EXITS 1 when no `coverage:` block is configured,
209
+ # which is why these steps are not armed by default. Add them once you have
210
+ # declared a surface.
211
+ #
212
+ # Two things worth knowing before you turn it on:
213
+ #
214
+ # * Recording drives CDP precise coverage, which costs runtime. On free
215
+ # runners, recording it on default-branch pushes only — and not on every PR
216
+ # — is usually the right trade.
217
+ # * Declare `coverage.non_ui` in config.yaml (health checks, webhook receivers,
218
+ # OAuth callbacks, internal cron). Triage flags set with `steppy coverage
219
+ # triage` live in the gitignored .steppy/coverage.db, so a fresh CI checkout
220
+ # has none and every one of those endpoints counts against you.
221
+ #
222
+ # - name: Run scenarios
223
+ # run: steppy run --trace --bundle --coverage
224
+ #
225
+ # - name: Refresh the declared surface
226
+ # if: always()
227
+ # run: steppy coverage refresh && steppy coverage report --json output/coverage/gaps.json
228
+ #
229
+ # ── Results bundles ───────────────────────────────────────────────────────────
230
+ #
231
+ # Instead of uploading a loose output/ tree, package the run into one portable,
232
+ # self-describing archive. The run step already passes --bundle, so the event
233
+ # records and JUnit XML a bundle needs are captured; add the packaging step:
234
+ #
235
+ # - name: Package results
236
+ # if: always()
237
+ # run: steppy bundle create
238
+ #
239
+ # - name: Upload results
240
+ # if: always()
241
+ # uses: actions/upload-artifact@v4
242
+ # with:
243
+ # name: steppy-bundle
244
+ # path: output/bundles/
245
+ #
246
+ # The archive holds output/ exactly as steppy wrote it, so `tar -xf` then
247
+ # `steppy report` / `steppy guide` / `steppy build` works unchanged on a
248
+ # downloaded artifact. Adding the `bundle` extra to the install line above gets
249
+ # zstd compression; without it bundles are .tar.gz.
250
+
251
+ # ── Running in parallel ───────────────────────────────────────────────────────
252
+ #
253
+ # For a large suite, swap the run step for `steppy run --workers 4 --trace`. Note
254
+ # what that changes about the compose steps above: the coordinator brings up ONE
255
+ # FULL STACK PER WORKER and tears them down itself, so you drop the explicit
256
+ # `docker compose up` and give it `parallel.compose_file` instead. Four copies of
257
+ # your app on one hosted runner is a lot — this earns its keep on a large suite
258
+ # or a beefy self-hosted runner, not on a thirty-scenario suite.
259
+ #
260
+ # It needs a `parallel:` block + per-app `internal_url` in config.yaml,
261
+ # healthchecks on your compose services, and Docker Compose 2.20+ on the runner
262
+ # (ubuntu-latest has it). Two things worth knowing about the CI output:
263
+ #
264
+ # * Failed scenarios are automatically re-run once at reduced concurrency. One
265
+ # that then passes is reported as FLAKY and does NOT fail the build — it lost
266
+ # a race, it is not broken. Pass --strict-flaky if you would rather CI go red
267
+ # on flakes, or --retry-pass 0 to turn the retry off entirely.
268
+ # * If the app or auth is down, the run aborts after ~10 scenarios instead of
269
+ # burning the full wall clock on 300 identical failures.
270
+ #
271
+ # `steppy flaky` ranks repeat offenders across runs, which is only useful if the
272
+ # history survives between builds. Cache it around the run step:
273
+ #
274
+ # - uses: actions/cache@v4
275
+ # with:
276
+ # path: .steppy/history.jsonl
277
+ # key: steppy-history-${{ github.run_id }}
278
+ # restore-keys: steppy-history-
279
+ #
280
+ # ...then add a reporting step after the run:
281
+ #
282
+ # - name: Flakiest scenarios
283
+ # if: always()
284
+ # run: steppy flaky --last 20 --min-rate 0.1