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/pdf.py ADDED
@@ -0,0 +1,193 @@
1
+ """PDF export — headless-Chromium print-to-PDF over already-generated guide HTML.
2
+
3
+ Steppy never lays out a PDF itself. The guide templates already carry
4
+ ``@media print`` rules (hide the animated cursor, keep a step on one page, cap
5
+ image height so a tall capture is scaled instead of clipped), so a PDF is just
6
+ "print the page we already render". That keeps one source of truth for the
7
+ layout and means a themed project's PDFs inherit its theme for free.
8
+
9
+ A **leaf module**: stdlib + ``models`` only, and ``playwright`` is imported
10
+ inside :func:`render_pdfs` so importing ``steppy.cli`` stays cheap
11
+ (guard: ``tests/test_no_playwright_import_on_default_path.py``).
12
+
13
+ Two invariants worth keeping if you touch this:
14
+
15
+ * **Pages are loaded over ``file://``.** Guide HTML references its screenshots
16
+ with *relative* URLs; ``page.set_content`` would resolve them against
17
+ ``about:blank`` and every image would be blank.
18
+ * **Lazy images are forced eager and awaited.** Both guide templates emit
19
+ ``loading="lazy"``; images below the fold are the classic reason a
20
+ print-to-PDF comes out with empty stages.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import html as _html
26
+ import sys
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Callable, Optional, Sequence
30
+
31
+ from .models import PdfConfig
32
+
33
+
34
+ class PdfRenderError(Exception):
35
+ """Fatal PDF-export failure — playwright missing, or Chromium unlaunchable.
36
+
37
+ Per-page failures are *not* this: they land in a :class:`PdfResult` so one
38
+ broken guide can't cost the other 149.
39
+ """
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class PdfJob:
44
+ """One page to print. ``title`` is only used for the footer."""
45
+
46
+ html: Path
47
+ pdf: Path
48
+ title: str = ""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PdfResult:
53
+ job: PdfJob
54
+ ok: bool
55
+ error: Optional[str] = None
56
+
57
+
58
+ # Chromium renders header/footer templates in their own document: external
59
+ # styles do not apply and an unset font-size renders at zero. Only the special
60
+ # classes (pageNumber / totalPages / title / date / url) are substituted.
61
+ _EMPTY_HEADER = "<span></span>"
62
+
63
+ _FOOTER_STYLE = (
64
+ "width:100%;font-size:9px;color:#666;padding:0 12mm;"
65
+ "font-family:-apple-system,'Segoe UI',Roboto,sans-serif;"
66
+ "display:flex;justify-content:space-between;"
67
+ )
68
+
69
+ # Flip lazy images to eager so Chromium starts loading the ones below the fold.
70
+ _EAGER_IMAGES_JS = """() => {
71
+ for (const img of document.images) {
72
+ if (img.loading === 'lazy') { img.loading = 'eager'; }
73
+ }
74
+ }"""
75
+
76
+ _IMAGES_COMPLETE_JS = "() => Array.from(document.images).every(img => img.complete)"
77
+
78
+ _BROWSER_HINT = "Chromium is required for PDF export. Install it with:\n python -m playwright install chromium"
79
+
80
+
81
+ def _footer_template(title: str) -> str:
82
+ """Footer with the guide title on the left and ``N / M`` on the right."""
83
+ return (
84
+ f'<div style="{_FOOTER_STYLE}">'
85
+ f"<span>{_html.escape(title)}</span>"
86
+ '<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>'
87
+ "</div>"
88
+ )
89
+
90
+
91
+ def pdf_options(options: PdfConfig, title: str = "") -> dict:
92
+ """Map a :class:`~steppy.models.PdfConfig` onto ``page.pdf()`` kwargs.
93
+
94
+ Split out from :func:`render_pdfs` so the mapping is testable without a
95
+ browser. ``path`` is added by the caller.
96
+ """
97
+ kwargs: dict = {
98
+ "format": options.format,
99
+ "landscape": options.landscape,
100
+ "print_background": options.print_background,
101
+ "scale": options.scale,
102
+ "margin": {
103
+ "top": options.margin.top,
104
+ "right": options.margin.right,
105
+ "bottom": options.margin.bottom,
106
+ "left": options.margin.left,
107
+ },
108
+ }
109
+ if options.page_numbers:
110
+ kwargs["display_header_footer"] = True
111
+ # Without an explicit header template Chromium prints its own
112
+ # (date + document title), which is not what "page numbers" asked for.
113
+ kwargs["header_template"] = _EMPTY_HEADER
114
+ kwargs["footer_template"] = _footer_template(title)
115
+ return kwargs
116
+
117
+
118
+ def _default_warn(message: str) -> None:
119
+ print(f"WARNING: {message}", file=sys.stderr)
120
+
121
+
122
+ def render_pdfs(
123
+ jobs: Sequence[PdfJob],
124
+ options: Optional[PdfConfig] = None,
125
+ *,
126
+ on_done: Optional[Callable[[PdfResult], None]] = None,
127
+ on_warning: Optional[Callable[[str], None]] = None,
128
+ ) -> list[PdfResult]:
129
+ """Print every job to PDF, reusing one browser and one page for the batch.
130
+
131
+ A guide project is a cartesian of scenarios × locales × layouts, so the
132
+ batch is routinely in the hundreds — a browser launch per file would
133
+ dominate the runtime.
134
+
135
+ ``on_done`` is invoked after each job so callers can stream progress.
136
+ Returns one :class:`PdfResult` per job, in order. Raises
137
+ :class:`PdfRenderError` only when the browser itself can't start.
138
+ """
139
+ if not jobs:
140
+ return []
141
+
142
+ options = options or PdfConfig()
143
+ warn = on_warning or _default_warn
144
+
145
+ try:
146
+ from playwright.sync_api import sync_playwright
147
+ except ImportError as exc: # pragma: no cover - playwright is a hard dep
148
+ raise PdfRenderError(f"playwright is not installed: {exc}") from exc
149
+
150
+ results: list[PdfResult] = []
151
+ try:
152
+ with sync_playwright() as p:
153
+ try:
154
+ browser = p.chromium.launch()
155
+ except Exception as exc:
156
+ raise PdfRenderError(f"Could not launch Chromium: {exc}\n{_BROWSER_HINT}") from exc
157
+ try:
158
+ page = browser.new_page()
159
+ for job in jobs:
160
+ result = _render_one(page, job, options, warn)
161
+ results.append(result)
162
+ if on_done:
163
+ on_done(result)
164
+ finally:
165
+ browser.close()
166
+ except PdfRenderError:
167
+ raise
168
+ except Exception as exc: # driver startup / teardown failures
169
+ raise PdfRenderError(f"PDF export failed: {exc}\n{_BROWSER_HINT}") from exc
170
+
171
+ return results
172
+
173
+
174
+ def _render_one(page, job: PdfJob, options: PdfConfig, warn: Callable[[str], None]) -> PdfResult:
175
+ """Print a single job. Never raises — failures come back as a result."""
176
+ try:
177
+ if not job.html.is_file():
178
+ return PdfResult(job=job, ok=False, error=f"source HTML not found: {job.html}")
179
+
180
+ page.goto(job.html.resolve().as_uri(), wait_until="load", timeout=options.timeout)
181
+ page.evaluate(_EAGER_IMAGES_JS)
182
+ try:
183
+ page.wait_for_function(_IMAGES_COMPLETE_JS, timeout=options.timeout)
184
+ except Exception:
185
+ # A PDF with one missing screenshot beats no PDF at all — say so
186
+ # and print anyway.
187
+ warn(f"{job.html.name}: some screenshots did not finish loading within {options.timeout}ms")
188
+
189
+ job.pdf.parent.mkdir(parents=True, exist_ok=True)
190
+ page.pdf(path=str(job.pdf), **pdf_options(options, job.title))
191
+ return PdfResult(job=job, ok=True)
192
+ except Exception as exc:
193
+ return PdfResult(job=job, ok=False, error=str(exc))
steppy/presets.py ADDED
@@ -0,0 +1,284 @@
1
+ """UI-kit selector presets for the component-driver config blocks (FLAWS §4.3).
2
+
3
+ The ``select-option`` / ``confirm`` / ``toggle`` / ``pick-date`` / ``pick-time`` /
4
+ ``expand`` actions drive framework components (dropdowns, dialogs, switches,
5
+ datepickers, fieldsets) whose internal markup has no stable ``data-testid``.
6
+ Their selectors therefore come from a config preset. Historically the only
7
+ preset was PrimeNG, baked in as the field defaults.
8
+
9
+ A config block may now name a ``preset:`` and get a whole kit's selectors at
10
+ once — e.g. ``dropdown: {preset: material}``. Explicitly set selector fields
11
+ always win over the preset; omitting ``preset:`` keeps the PrimeNG defaults, so
12
+ existing configs are byte-identical.
13
+
14
+ ``primeng`` is authoritative (it mirrors the long-standing defaults, verified
15
+ against real PrimeNG apps). Every other preset — ``material`` (Angular Material
16
+ MDC), ``primevue`` (PrimeVue), ``mui`` (Material UI / React), ``vuetify``
17
+ (Vuetify 3 / Vue), and ``shadcn`` (shadcn/ui + Radix / React) — is a
18
+ **best-effort starter set**: the markup varies by framework version and by
19
+ add-ons, so treat these selectors as a starting point and override any that
20
+ does not match your app. They are foundational and will be hardened against
21
+ real markup as Steppy is exercised on cross-framework showcase projects; until
22
+ then, `steppy validate` + a quick run against your app is the way to confirm
23
+ them.
24
+
25
+ Each entry maps a config key (``dropdown``/``dialog``/``toggle``/
26
+ ``datepicker``/``fieldset``) to the selector fields of the matching model.
27
+ Only selector fields appear here — non-selector fields (e.g. dropdown
28
+ ``settle``) are never touched by a preset.
29
+ """
30
+
31
+ PRESETS: dict[str, dict[str, dict[str, str]]] = {
32
+ # ── PrimeNG (default) ────────────────────────────────────────────────────
33
+ # Mirrors the field defaults on the models below. Keep in sync if a default
34
+ # changes, so `preset: primeng` stays equivalent to omitting `preset`.
35
+ "primeng": {
36
+ "dropdown": {
37
+ "trigger": "p-select",
38
+ "overlay": ".p-select-overlay",
39
+ "option": ".p-select-option, .p-select-list-container li",
40
+ "filter": ".p-select-filter-container input, .p-select-filter",
41
+ },
42
+ "dialog": {
43
+ "overlay": ".p-confirmdialog, p-confirmdialog",
44
+ "accept": "button.p-confirmdialog-accept-button, button.p-confirm-dialog-accept",
45
+ "reject": "button.p-confirmdialog-reject-button, button.p-confirm-dialog-reject",
46
+ "danger": "button.p-button-danger, button.p-confirmdialog-accept-button",
47
+ },
48
+ "toggle": {
49
+ "switch": 'input[role="switch"]',
50
+ "segment": ".p-togglebutton",
51
+ },
52
+ "datepicker": {
53
+ "panel": ".p-datepicker-panel",
54
+ "next_button": ".p-datepicker-next-button",
55
+ "prev_button": ".p-datepicker-prev-button",
56
+ "day_cell": ".p-datepicker-day-cell:not(.p-datepicker-other-month)",
57
+ "trigger": "button.p-datepicker-dropdown, button.p-datepicker-trigger",
58
+ "hour_picker": ".p-datepicker-hour-picker",
59
+ "minute_picker": ".p-datepicker-minute-picker",
60
+ },
61
+ "fieldset": {
62
+ "toggle": ".p-fieldset-toggle-button, .p-fieldset-legend",
63
+ "content": ".p-toggleable-content, .p-fieldset-content-container",
64
+ },
65
+ },
66
+ # ── Angular Material (MDC) — starter set, verify against your app ─────────
67
+ "material": {
68
+ "dropdown": {
69
+ "trigger": "mat-select",
70
+ "overlay": ".cdk-overlay-pane .mat-mdc-select-panel",
71
+ "option": "mat-option, .mat-mdc-option",
72
+ # mat-select has no native filter; ngx-mat-select-search adds one.
73
+ "filter": ".mat-select-search-input, input.mat-mdc-input-element",
74
+ },
75
+ "dialog": {
76
+ "overlay": "mat-dialog-container, .mat-mdc-dialog-container",
77
+ "accept": '.mat-mdc-dialog-actions button[color="primary"], .mat-mdc-dialog-actions button:last-child',
78
+ "reject": '.mat-mdc-dialog-actions button:not([color="primary"]), .mat-mdc-dialog-actions button:first-child',
79
+ "danger": '.mat-mdc-dialog-actions button[color="warn"]',
80
+ },
81
+ "toggle": {
82
+ "switch": 'mat-slide-toggle button[role="switch"], .mat-mdc-slide-toggle button',
83
+ "segment": ".mat-button-toggle",
84
+ },
85
+ "datepicker": {
86
+ "panel": ".mat-datepicker-content, .mat-calendar",
87
+ "next_button": ".mat-calendar-next-button",
88
+ "prev_button": ".mat-calendar-previous-button",
89
+ "day_cell": ".mat-calendar-body-cell:not(.mat-calendar-body-disabled)",
90
+ "trigger": ".mat-datepicker-toggle button",
91
+ # Material core has no time picker; ngx-mat-timepicker markup shown.
92
+ "hour_picker": ".ngx-mat-timepicker .mat-mdc-form-field:nth-child(1) input",
93
+ "minute_picker": ".ngx-mat-timepicker .mat-mdc-form-field:nth-child(2) input",
94
+ },
95
+ "fieldset": {
96
+ "toggle": "mat-expansion-panel-header, .mat-expansion-panel-header",
97
+ "content": ".mat-expansion-panel-content, .mat-expansion-panel-body",
98
+ },
99
+ },
100
+ # ── PrimeVue — starter set, verify against your app ───────────────────────
101
+ # PrimeVue shares PrimeNG's ``.p-*`` DNA, so the cheapest cross-framework
102
+ # win is to reuse the PrimeNG selectors almost verbatim. Kept as its own
103
+ # entry (rather than aliasing) so it can diverge as PrimeVue-specific markup
104
+ # is discovered on real apps.
105
+ "primevue": {
106
+ "dropdown": {
107
+ "trigger": ".p-select, .p-dropdown",
108
+ "overlay": ".p-select-overlay, .p-dropdown-panel",
109
+ "option": ".p-select-option, .p-dropdown-item, .p-select-list-container li",
110
+ "filter": ".p-select-filter, .p-dropdown-filter, .p-select-filter-container input",
111
+ },
112
+ "dialog": {
113
+ "overlay": ".p-confirmdialog, .p-confirm-dialog",
114
+ "accept": "button.p-confirmdialog-accept-button, button.p-confirm-dialog-accept",
115
+ "reject": "button.p-confirmdialog-reject-button, button.p-confirm-dialog-reject",
116
+ "danger": "button.p-button-danger, button.p-confirmdialog-accept-button",
117
+ },
118
+ "toggle": {
119
+ "switch": 'input[role="switch"], .p-toggleswitch-input',
120
+ "segment": ".p-togglebutton",
121
+ },
122
+ "datepicker": {
123
+ "panel": ".p-datepicker-panel",
124
+ "next_button": ".p-datepicker-next-button",
125
+ "prev_button": ".p-datepicker-prev-button",
126
+ "day_cell": ".p-datepicker-day-cell:not(.p-datepicker-other-month)",
127
+ "trigger": "button.p-datepicker-dropdown, button.p-datepicker-trigger",
128
+ "hour_picker": ".p-datepicker-hour-picker",
129
+ "minute_picker": ".p-datepicker-minute-picker",
130
+ },
131
+ "fieldset": {
132
+ "toggle": ".p-fieldset-toggle-button, .p-fieldset-legend",
133
+ "content": ".p-toggleable-content, .p-fieldset-content-container",
134
+ },
135
+ },
136
+ # ── Material UI (React / MUI v5–v6) — starter set, verify against your app ─
137
+ # MUI ships stable ``.Mui*`` class names; the date picker is the ``@mui/x``
138
+ # add-on (``.MuiPickers*`` / ``.MuiClock*``). Override if you use a custom
139
+ # theme that strips the default classes.
140
+ "mui": {
141
+ "dropdown": {
142
+ "trigger": ".MuiSelect-select, .MuiAutocomplete-root .MuiInputBase-root",
143
+ "overlay": ".MuiMenu-paper, .MuiAutocomplete-popper .MuiPaper-root",
144
+ "option": ".MuiMenuItem-root, .MuiAutocomplete-option",
145
+ "filter": ".MuiAutocomplete-input, .MuiInputBase-input",
146
+ },
147
+ "dialog": {
148
+ "overlay": ".MuiDialog-paper",
149
+ "accept": ".MuiDialogActions-root button:last-child",
150
+ "reject": ".MuiDialogActions-root button:first-child",
151
+ "danger": ".MuiDialogActions-root button.MuiButton-colorError, .MuiDialogActions-root button.MuiButton-containedError",
152
+ },
153
+ "toggle": {
154
+ "switch": ".MuiSwitch-input",
155
+ "segment": ".MuiToggleButton-root",
156
+ },
157
+ "datepicker": {
158
+ "panel": ".MuiPickersLayout-root, .MuiDateCalendar-root",
159
+ "next_button": '.MuiPickersArrowSwitcher-button[title="Next month"], .MuiPickersArrowSwitcher-button:last-child',
160
+ "prev_button": '.MuiPickersArrowSwitcher-button[title="Previous month"], .MuiPickersArrowSwitcher-button:first-child',
161
+ "day_cell": ".MuiPickersDay-root:not(.Mui-disabled):not(.MuiPickersDay-dayOutsideMonth)",
162
+ "trigger": ".MuiInputAdornment-root button, button.MuiIconButton-root",
163
+ # @mui/x digital clock: sections of selectable minute/hour options.
164
+ "hour_picker": ".MuiMultiSectionDigitalClockSection-root:nth-child(1)",
165
+ "minute_picker": ".MuiMultiSectionDigitalClockSection-root:nth-child(2)",
166
+ },
167
+ "fieldset": {
168
+ "toggle": ".MuiAccordionSummary-root",
169
+ "content": ".MuiAccordionDetails-root, .MuiCollapse-root",
170
+ },
171
+ },
172
+ # ── Vuetify 3 (Vue) — starter set, verify against your app ────────────────
173
+ # Vuetify renders overlays into a ``.v-overlay-container``; components use
174
+ # stable ``.v-*`` BEM-ish classes. The date picker is ``.v-date-picker``.
175
+ "vuetify": {
176
+ "dropdown": {
177
+ "trigger": ".v-select, .v-autocomplete",
178
+ "overlay": ".v-overlay .v-list, .v-menu > .v-overlay__content",
179
+ "option": ".v-list-item",
180
+ "filter": ".v-autocomplete input, .v-field__input",
181
+ },
182
+ "dialog": {
183
+ "overlay": ".v-dialog .v-card, .v-overlay--active .v-card",
184
+ "accept": ".v-card-actions button:last-child",
185
+ "reject": ".v-card-actions button:first-child",
186
+ "danger": ".v-card-actions button.text-error, .v-card-actions button.bg-error",
187
+ },
188
+ "toggle": {
189
+ "switch": ".v-switch input",
190
+ "segment": ".v-btn-toggle .v-btn",
191
+ },
192
+ "datepicker": {
193
+ "panel": ".v-date-picker, .v-date-picker-month",
194
+ "next_button": ".v-date-picker-controls button:last-child, .v-date-picker-month__header button:last-child",
195
+ "prev_button": ".v-date-picker-controls button:first-child, .v-date-picker-month__header button:first-child",
196
+ "day_cell": ".v-date-picker-month__day:not(.v-date-picker-month__day--hide-adjacent) .v-btn:not(.v-btn--disabled)",
197
+ "trigger": ".v-field__append-inner button, .v-input__append button",
198
+ # Vuetify core has no time picker; ``.v-time-picker`` is the add-on.
199
+ "hour_picker": ".v-time-picker-clock__container, .v-time-picker__controls .v-time-picker__hour",
200
+ "minute_picker": ".v-time-picker-clock__container, .v-time-picker__controls .v-time-picker__minute",
201
+ },
202
+ "fieldset": {
203
+ "toggle": ".v-expansion-panel-title",
204
+ "content": ".v-expansion-panel-text",
205
+ },
206
+ },
207
+ # ── shadcn/ui (Radix primitives, React) — starter set, verify per app ─────
208
+ # shadcn/ui composes Radix primitives, which expose ARIA roles and
209
+ # ``[data-state]`` / ``[data-radix-*]`` attributes rather than stable class
210
+ # names — so these selectors lean on roles/data-attributes. Class names come
211
+ # from your own Tailwind copy of each component, so override where you
212
+ # renamed things.
213
+ "shadcn": {
214
+ "dropdown": {
215
+ # Radix Select trigger + Command combobox both expose role.
216
+ "trigger": '[role="combobox"]',
217
+ "overlay": '[role="listbox"], [data-radix-popper-content-wrapper]',
218
+ "option": '[role="option"]',
219
+ "filter": '[role="combobox"] input, [cmdk-input]',
220
+ },
221
+ "dialog": {
222
+ "overlay": '[role="alertdialog"], [role="dialog"]',
223
+ # shadcn AlertDialog: Cancel then Action, in DOM order.
224
+ "accept": '[role="alertdialog"] button:last-of-type, [role="dialog"] button:last-of-type',
225
+ "reject": '[role="alertdialog"] button:first-of-type, [role="dialog"] button:first-of-type',
226
+ "danger": '[role="alertdialog"] button.bg-destructive, [role="dialog"] button.bg-destructive',
227
+ },
228
+ "toggle": {
229
+ "switch": '[role="switch"]',
230
+ "segment": '[role="radiogroup"] button, [role="group"] [data-state]',
231
+ },
232
+ "datepicker": {
233
+ # shadcn date picker = a Popover wrapping react-day-picker (rdp).
234
+ "panel": ".rdp, [data-radix-popper-content-wrapper] table",
235
+ "next_button": 'button[name="next-month"], .rdp-button_next',
236
+ "prev_button": 'button[name="previous-month"], .rdp-button_previous',
237
+ "day_cell": ".rdp-day:not([disabled]):not(.rdp-day_outside)",
238
+ "trigger": '[data-state] > button, button[aria-haspopup="dialog"]',
239
+ # react-day-picker has no time picker; leave the PrimeNG-style
240
+ # placeholders empty-ish so pick-time is clearly unsupported until a
241
+ # real app supplies a widget. Override these if you add one.
242
+ "hour_picker": '[data-time="hour"]',
243
+ "minute_picker": '[data-time="minute"]',
244
+ },
245
+ "fieldset": {
246
+ # shadcn Accordion (Radix): trigger + region content.
247
+ "toggle": "[data-radix-collection-item] button, [data-state] > h3 button",
248
+ "content": '[role="region"], [data-state="open"] > [data-radix-accordion-content]',
249
+ },
250
+ },
251
+ }
252
+
253
+
254
+ def available_presets() -> list[str]:
255
+ """Sorted list of known preset names (for error messages)."""
256
+ return sorted(PRESETS)
257
+
258
+
259
+ # ── IdP login-form presets (FLAWS §6.2) ──────────────────────────────────────
260
+ # The generic hosted-login form-fill (auth ``mode: form``, formerly ``auth0``)
261
+ # needs three selectors: username field, password field, submit button. Most
262
+ # IdPs differ only in these — so a named ``preset:`` fills them at once, exactly
263
+ # like the UI-kit presets above but on a separate registry so the two name
264
+ # spaces don't collide. ``auth0`` mirrors the historical FormAuthConfig defaults.
265
+ IDP_PRESETS: dict[str, dict[str, dict[str, str]]] = {
266
+ "auth0": {
267
+ "form": {
268
+ "username_selector": "input[name='username']",
269
+ "password_selector": "input[name='password']",
270
+ "submit_selector": "button[data-action-button-primary='true']",
271
+ # Auth0 Guardian OTP prompt — best-effort starter, verify per tenant.
272
+ "totp_selector": "input[name='code']",
273
+ },
274
+ },
275
+ "keycloak": {
276
+ "form": {
277
+ "username_selector": "#username",
278
+ "password_selector": "#password",
279
+ "submit_selector": "#kc-login",
280
+ # Keycloak OTP form — best-effort starter, verify per realm/theme.
281
+ "totp_selector": "#otp",
282
+ },
283
+ },
284
+ }