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/auth.py ADDED
@@ -0,0 +1,493 @@
1
+ """Authentication management for Steppy."""
2
+
3
+ import os
4
+ import stat
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from urllib.parse import urlparse
9
+
10
+ import httpx
11
+ from playwright.sync_api import Browser, BrowserContext, Page
12
+
13
+ from .models import SteppyConfig, AppConfig
14
+ from .totp import _totp_now
15
+
16
+
17
+ class AuthenticationError(Exception):
18
+ """Raised when authentication fails."""
19
+
20
+ pass
21
+
22
+
23
+ def _fill_totp_if_configured(page: Page, form, credentials) -> None:
24
+ """Fill a TOTP/MFA code after the password submit, when configured (FLAWS §6.2).
25
+
26
+ No-op unless both a ``totp_selector`` (on ``auth.form``) and a ``totp_secret``
27
+ (on the app credentials) are set. The selector wait tolerates absence with a
28
+ short timeout so a non-MFA session (already trusted device) falls straight
29
+ through. ``totp_submit_selector`` defaults to the form's ``submit_selector``.
30
+ """
31
+ totp_selector = getattr(form, "totp_selector", None)
32
+ totp_secret = getattr(credentials, "totp_secret", None)
33
+ if not totp_selector or not totp_secret:
34
+ return
35
+ try:
36
+ page.wait_for_selector(totp_selector, timeout=10000)
37
+ except Exception:
38
+ return # no MFA prompt this session — nothing to fill
39
+ page.fill(totp_selector, _totp_now(totp_secret))
40
+ submit = getattr(form, "totp_submit_selector", None) or form.submit_selector
41
+ page.click(submit)
42
+
43
+
44
+ def get_storage_state_path(role: str, auth_dir: Path) -> Path:
45
+ """Get the path to the storage state file for a role."""
46
+ return auth_dir / f"{role}.json"
47
+
48
+
49
+ def _restrict_permissions(path: Path) -> None:
50
+ """Best-effort chmod 0o600 on a saved storage-state file.
51
+
52
+ ``.auth/<role>.json`` holds cleartext cookies + JWTs, so restrict it to the
53
+ owner. A no-op on platforms (e.g. Windows) where POSIX modes don't apply;
54
+ failures are swallowed so auth never breaks over a permissions quirk.
55
+ """
56
+ try:
57
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
58
+ except OSError:
59
+ pass
60
+
61
+
62
+ def is_storage_state_valid(role: str, auth_dir: Path, ttl: int) -> bool:
63
+ """
64
+ Check if a cached storage state exists and is still valid.
65
+
66
+ Args:
67
+ role: Role name (e.g., "editor", "reviewer")
68
+ auth_dir: Directory containing storage state files
69
+ ttl: Time to live in seconds
70
+
71
+ Returns:
72
+ True if valid cache exists, False otherwise
73
+ """
74
+ state_path = get_storage_state_path(role, auth_dir)
75
+
76
+ if not state_path.exists():
77
+ return False
78
+
79
+ age = time.time() - state_path.stat().st_mtime
80
+ return age < ttl
81
+
82
+
83
+ def authenticate_auth0(
84
+ browser: Browser,
85
+ role: str,
86
+ app_config: AppConfig,
87
+ config: SteppyConfig,
88
+ ) -> BrowserContext:
89
+ """
90
+ Authenticate using Auth0 and save storage state.
91
+
92
+ Args:
93
+ browser: Playwright browser instance
94
+ role: Role name
95
+ app_config: App configuration for this role
96
+ config: Global Steppy configuration
97
+
98
+ Returns:
99
+ Authenticated browser context
100
+
101
+ Raises:
102
+ AuthenticationError: If authentication fails
103
+ """
104
+ auth_dir = Path(config.auth.storage_state_dir)
105
+ state_path = get_storage_state_path(role, auth_dir)
106
+
107
+ # Check cache first
108
+ if is_storage_state_valid(role, auth_dir, config.auth.storage_state_ttl):
109
+ return browser.new_context(
110
+ storage_state=str(state_path),
111
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
112
+ base_url=app_config.url,
113
+ )
114
+
115
+ # Fresh login required — guard the missing-credentials case with a clear,
116
+ # non-wrapped message before launching the browser (FLAWS §5.4).
117
+ if not app_config.auth or not app_config.auth.credentials:
118
+ raise AuthenticationError(
119
+ f"Form login for role '{role}' requires apps.{role}.auth.credentials (username/password)."
120
+ )
121
+
122
+ context = browser.new_context(
123
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
124
+ base_url=app_config.url,
125
+ )
126
+ page = context.new_page()
127
+
128
+ try:
129
+ # Navigate to app → triggers Auth0 redirect
130
+ page.goto("/", timeout=30000)
131
+
132
+ # Wait for the hosted login page
133
+ form = config.auth.form
134
+ if not form:
135
+ raise AuthenticationError("Login-form configuration (auth.form) is missing")
136
+
137
+ page.wait_for_selector(form.username_selector, timeout=15000)
138
+
139
+ # Fill credentials
140
+ page.fill(form.username_selector, app_config.auth.credentials.username)
141
+ page.fill(form.password_selector, app_config.auth.credentials.password)
142
+ page.click(form.submit_selector)
143
+
144
+ # Optional TOTP/MFA step (no-op unless configured)
145
+ _fill_totp_if_configured(page, form, app_config.auth.credentials)
146
+
147
+ # Handle optional consent screen
148
+ if form.consent_selector:
149
+ try:
150
+ page.wait_for_selector(form.consent_selector, timeout=3000)
151
+ page.click(form.consent_selector)
152
+ except Exception:
153
+ pass # No consent screen, continue
154
+
155
+ # Wait for redirect back to the app
156
+ page.wait_for_url(f"{app_config.url}/**", timeout=15000)
157
+ page.wait_for_load_state("networkidle", timeout=30000)
158
+
159
+ # Cache the authenticated state
160
+ auth_dir.mkdir(parents=True, exist_ok=True)
161
+ context.storage_state(path=str(state_path))
162
+ _restrict_permissions(state_path)
163
+
164
+ page.close()
165
+ return context
166
+
167
+ except Exception as e:
168
+ page.close()
169
+ context.close()
170
+ raise AuthenticationError(f"Form authentication failed for {role}: {e}")
171
+
172
+
173
+ # Preferred name for the generic hosted-login form-fill (FLAWS §6.2); the
174
+ # historical ``authenticate_auth0`` name is kept as the implementation.
175
+ authenticate_form = authenticate_auth0
176
+
177
+
178
+ def authenticate_storage_state(
179
+ browser: Browser,
180
+ app_config: AppConfig,
181
+ config: SteppyConfig,
182
+ ) -> BrowserContext:
183
+ """
184
+ Load authentication from an existing storage state file.
185
+
186
+ Args:
187
+ browser: Playwright browser instance
188
+ app_config: App configuration
189
+ config: Global Steppy configuration
190
+
191
+ Returns:
192
+ Authenticated browser context
193
+
194
+ Raises:
195
+ AuthenticationError: If storage state file doesn't exist
196
+ """
197
+ state_path = app_config.auth.storage_state_path
198
+ if not state_path or not Path(state_path).exists():
199
+ raise AuthenticationError(f"Storage state file not found: {state_path}")
200
+
201
+ return browser.new_context(
202
+ storage_state=state_path,
203
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
204
+ base_url=app_config.url,
205
+ )
206
+
207
+
208
+ def authenticate_bypass(
209
+ browser: Browser,
210
+ app_config: AppConfig,
211
+ config: SteppyConfig,
212
+ ) -> BrowserContext:
213
+ """
214
+ Create context with bypass authentication (no real login).
215
+
216
+ Args:
217
+ browser: Playwright browser instance
218
+ app_config: App configuration
219
+ config: Global Steppy configuration
220
+
221
+ Returns:
222
+ Browser context with injected auth credentials
223
+ """
224
+ context = browser.new_context(
225
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
226
+ base_url=app_config.url,
227
+ )
228
+
229
+ bypass = app_config.auth.bypass
230
+ if not bypass:
231
+ return context
232
+
233
+ # Inject cookie if configured
234
+ if bypass.cookie:
235
+ context.add_cookies([bypass.cookie])
236
+
237
+ # Inject localStorage if configured
238
+ if bypass.local_storage:
239
+ page = context.new_page()
240
+ page.goto(app_config.url)
241
+ for key, value in bypass.local_storage.items():
242
+ # Pass key/value as arguments rather than interpolating into the JS
243
+ # source, so a value containing a quote or backslash can't break or
244
+ # inject into the script.
245
+ page.evaluate("([k, v]) => localStorage.setItem(k, v)", [key, value])
246
+ page.close()
247
+
248
+ return context
249
+
250
+
251
+ def authenticate_token(
252
+ browser: Browser,
253
+ role: str,
254
+ app_config: AppConfig,
255
+ config: SteppyConfig,
256
+ ) -> BrowserContext:
257
+ """
258
+ Authenticate by calling a token endpoint and injecting the JWT.
259
+
260
+ Args:
261
+ browser: Playwright browser instance
262
+ role: Role name
263
+ app_config: App configuration
264
+ config: Global Steppy configuration
265
+
266
+ Returns:
267
+ Authenticated browser context
268
+
269
+ Raises:
270
+ AuthenticationError: If token retrieval fails
271
+ """
272
+ token_config = app_config.auth.token
273
+ if not token_config:
274
+ raise AuthenticationError("Token configuration is missing")
275
+
276
+ # Call the token endpoint
277
+ try:
278
+ response = httpx.request(
279
+ method=token_config.method,
280
+ url=token_config.endpoint,
281
+ json=token_config.body,
282
+ timeout=10.0,
283
+ )
284
+ response.raise_for_status()
285
+ token_data = response.json()
286
+
287
+ # Extract token (assume it's in the response root or in a "token" field)
288
+ token = token_data.get("token") or token_data.get("access_token") or token_data
289
+
290
+ if not isinstance(token, str):
291
+ raise AuthenticationError(f"Token endpoint returned invalid data: {token_data}")
292
+
293
+ except Exception as e:
294
+ raise AuthenticationError(f"Failed to retrieve token for {role}: {e}")
295
+
296
+ # Create context and inject token
297
+ context = browser.new_context(
298
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
299
+ base_url=app_config.url,
300
+ )
301
+
302
+ inject_as = token_config.inject_as
303
+ key = token_config.key
304
+
305
+ if inject_as == "localStorage":
306
+ page = context.new_page()
307
+ page.goto(app_config.url)
308
+ # Pass key/token as arguments (not string-interpolated) — the token is
309
+ # network-sourced and could contain a quote that would otherwise break
310
+ # or inject into the JS.
311
+ page.evaluate("([k, v]) => localStorage.setItem(k, v)", [key, token])
312
+ page.close()
313
+ elif inject_as == "cookie":
314
+ # Extract the host safely — urlparse tolerates ports/paths and won't
315
+ # IndexError on a URL missing "://" (FLAWS §5.4).
316
+ domain = urlparse(app_config.url).hostname
317
+ if not domain:
318
+ raise AuthenticationError(
319
+ f"Cannot derive a cookie domain from apps.{role}.url "
320
+ f"({app_config.url!r}); expected a URL like https://host[:port]."
321
+ )
322
+ context.add_cookies(
323
+ [
324
+ {
325
+ "name": key,
326
+ "value": token,
327
+ "domain": domain,
328
+ "path": "/",
329
+ }
330
+ ]
331
+ )
332
+ elif inject_as == "header":
333
+ # Inject the token as a default request header on the context. `key` is
334
+ # the header name (e.g. "Authorization"); the token endpoint should
335
+ # return the full header value (e.g. "Bearer <jwt>") when a scheme is
336
+ # required (FLAWS §6.2).
337
+ context.set_extra_http_headers({key: token})
338
+
339
+ return context
340
+
341
+
342
+ def get_authenticated_context(
343
+ browser: Browser,
344
+ role: str,
345
+ config: SteppyConfig,
346
+ ) -> BrowserContext:
347
+ """
348
+ Get an authenticated browser context for a role using the configured auth method.
349
+
350
+ Args:
351
+ browser: Playwright browser instance
352
+ role: Role name (e.g., "editor", "reviewer")
353
+ config: Global Steppy configuration
354
+
355
+ Returns:
356
+ Authenticated browser context
357
+
358
+ Raises:
359
+ AuthenticationError: If authentication fails
360
+ ValueError: If role or auth mode is unknown
361
+ """
362
+ if role not in config.apps:
363
+ raise ValueError(f"Unknown role: {role}. Available roles: {list(config.apps.keys())}")
364
+
365
+ app_config = config.apps[role]
366
+ mode = app_config.auth.mode
367
+
368
+ if mode in ("form", "auth0"):
369
+ return authenticate_auth0(browser, role, app_config, config)
370
+ elif mode == "storage-state":
371
+ return authenticate_storage_state(browser, app_config, config)
372
+ elif mode == "bypass":
373
+ return authenticate_bypass(browser, app_config, config)
374
+ elif mode == "token":
375
+ return authenticate_token(browser, role, app_config, config)
376
+ else:
377
+ raise ValueError(f"Unknown auth mode: {mode}")
378
+
379
+
380
+ def interactive_login(
381
+ role: str,
382
+ config: SteppyConfig,
383
+ browser: Browser,
384
+ ) -> None:
385
+ """
386
+ Open a headed browser and wait for the user to log in manually.
387
+ Save the storage state when done.
388
+
389
+ Args:
390
+ role: Role name
391
+ config: Global Steppy configuration
392
+ browser: Playwright browser instance (should be headed)
393
+
394
+ Raises:
395
+ ValueError: If role is unknown
396
+ """
397
+ if role not in config.apps:
398
+ raise ValueError(f"Unknown role: {role}. Available roles: {list(config.apps.keys())}")
399
+
400
+ app_config = config.apps[role]
401
+ auth_dir = Path(config.auth.storage_state_dir)
402
+ state_path = get_storage_state_path(role, auth_dir)
403
+
404
+ context = browser.new_context(
405
+ viewport={"width": config.screenshot.viewport.width, "height": config.screenshot.viewport.height},
406
+ base_url=app_config.url,
407
+ )
408
+ page = context.new_page()
409
+
410
+ # Navigate to the app
411
+ page.goto("/", timeout=60000)
412
+
413
+ # Wait for user to log in - just wait for the URL to change from login page
414
+ # This is a simple heuristic - user should close the browser when done
415
+ print(f"\nPlease log in to {app_config.name} in the browser window.")
416
+ print("Press Enter when you have successfully logged in...")
417
+ input()
418
+
419
+ # Save storage state
420
+ auth_dir.mkdir(parents=True, exist_ok=True)
421
+ context.storage_state(path=str(state_path))
422
+ _restrict_permissions(state_path)
423
+
424
+ page.close()
425
+ context.close()
426
+
427
+
428
+ def clear_storage_state(role: Optional[str], auth_dir: Path) -> list[str]:
429
+ """
430
+ Clear cached storage state(s).
431
+
432
+ Args:
433
+ role: Role name to clear, or None to clear all
434
+ auth_dir: Directory containing storage state files
435
+
436
+ Returns:
437
+ List of cleared role names
438
+ """
439
+ cleared = []
440
+
441
+ if role:
442
+ # Clear specific role
443
+ state_path = get_storage_state_path(role, auth_dir)
444
+ if state_path.exists():
445
+ state_path.unlink()
446
+ cleared.append(role)
447
+ else:
448
+ # Clear all
449
+ for state_file in auth_dir.glob("*.json"):
450
+ role_name = state_file.stem
451
+ state_file.unlink()
452
+ cleared.append(role_name)
453
+
454
+ return cleared
455
+
456
+
457
+ def get_storage_state_status(config: SteppyConfig) -> dict[str, dict]:
458
+ """
459
+ Get status of all storage states.
460
+
461
+ Args:
462
+ config: Global Steppy configuration
463
+
464
+ Returns:
465
+ Dict mapping role names to status info (exists, age, valid)
466
+ """
467
+ auth_dir = Path(config.auth.storage_state_dir)
468
+ ttl = config.auth.storage_state_ttl
469
+
470
+ status = {}
471
+ for role in config.apps.keys():
472
+ state_path = get_storage_state_path(role, auth_dir)
473
+
474
+ if state_path.exists():
475
+ age = time.time() - state_path.stat().st_mtime
476
+ valid = age < ttl
477
+ status[role] = {
478
+ "exists": True,
479
+ "age_seconds": int(age),
480
+ "age_minutes": int(age / 60),
481
+ "valid": valid,
482
+ "path": str(state_path),
483
+ }
484
+ else:
485
+ status[role] = {
486
+ "exists": False,
487
+ "age_seconds": None,
488
+ "age_minutes": None,
489
+ "valid": False,
490
+ "path": str(state_path),
491
+ }
492
+
493
+ return status