nat-engine 1__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 (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1100 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """Scenario Config Generator — Phase 2 of the functional testing pipeline.
7
+
8
+ Transforms the structured :class:`~mannf.core.browser.discovery_model.DiscoveryModel`
9
+ produced by :class:`~mannf.core.agents.web_crawler_agent.WebCrawlerAgent` (Phase 1)
10
+ into actionable scenario config dicts that match the task shape expected by
11
+ :meth:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator.run`.
12
+
13
+ Each generated scenario looks like::
14
+
15
+ {
16
+ "url": "https://example.com/login",
17
+ "steps": [
18
+ {"action": "fill", "selector": "#email", "value": "test@example.com"},
19
+ {"action": "fill", "selector": "#password", "value": "TestP@ss123!"},
20
+ {"action": "click", "selector": "button[type=submit]"},
21
+ ],
22
+ "metadata": {
23
+ "scenario_type": "happy_path",
24
+ "source_page": "/login",
25
+ "source_form_action": "/api/auth/login",
26
+ "description": "Login with valid credentials",
27
+ },
28
+ }
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import datetime
34
+ import json
35
+ import logging
36
+ import re
37
+ from collections import deque
38
+ from typing import Any
39
+
40
+ from mannf.core.browser.discovery_model import (
41
+ DiscoveredField,
42
+ DiscoveredForm,
43
+ DiscoveredPage,
44
+ DiscoveryModel,
45
+ InteractiveElement,
46
+ PageEdge,
47
+ )
48
+
49
+ logger = logging.getLogger(__name__)
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Constants — realistic valid values per field type
53
+ # ---------------------------------------------------------------------------
54
+
55
+ _TODAY = datetime.date.today().isoformat()
56
+
57
+ _VALID_VALUES: dict[str, str] = {
58
+ "email": "test@example.com",
59
+ "password": "TestP@ss123!",
60
+ "tel": "+1-555-0100",
61
+ "url": "https://example.com",
62
+ "number": "42",
63
+ "date": _TODAY,
64
+ "datetime-local": f"{_TODAY}T12:00",
65
+ "time": "12:00",
66
+ "color": "#ff5733",
67
+ "range": "50",
68
+ "month": _TODAY[:7],
69
+ "week": f"{datetime.date.today().isocalendar()[0]}-W{datetime.date.today().isocalendar()[1]:02d}",
70
+ "search": "test query",
71
+ "checkbox": "true",
72
+ "radio": "option1",
73
+ "file": "",
74
+ "hidden": "",
75
+ "textarea": "Sample text input for testing purposes.",
76
+ "select": "",
77
+ "text": "Sample text",
78
+ }
79
+
80
+ # Heuristic label/name → value mapping (lower-cased keyword matching)
81
+ _LABEL_HEURISTICS: list[tuple[tuple[str, ...], str]] = [
82
+ (("first_name", "firstname", "fname", "first name"), "John"),
83
+ (("last_name", "lastname", "lname", "last name", "surname"), "Doe"),
84
+ (("full_name", "fullname", "name"), "John Doe"),
85
+ (("username", "user_name", "user name", "login"), "johndoe"),
86
+ (("email", "e-mail", "mail"), "test@example.com"),
87
+ (("password", "pass", "pwd"), "TestP@ss123!"),
88
+ (("confirm_password", "confirm password", "password_confirm", "retype"), "TestP@ss123!"),
89
+ (("phone", "tel", "telephone", "mobile", "cell"), "+1-555-0100"),
90
+ (("address", "street", "addr"), "123 Main St"),
91
+ (("city", "town"), "Springfield"),
92
+ (("state", "province", "region"), "IL"),
93
+ (("zip", "postal", "postcode", "zipcode"), "62701"),
94
+ (("country",), "US"),
95
+ (("age",), "30"),
96
+ (("dob", "birthdate", "birth_date", "date_of_birth"), "1990-01-15"),
97
+ (("company", "organization", "organisation"), "Acme Corp"),
98
+ (("title", "job_title", "position"), "Software Engineer"),
99
+ (("website", "url", "homepage"), "https://example.com"),
100
+ (("message", "comment", "description", "note", "bio", "about"), "This is a test message."),
101
+ (("subject",), "Test Subject"),
102
+ (("quantity", "qty", "amount", "count"), "1"),
103
+ (("price", "cost"), "9.99"),
104
+ (("search", "query", "keyword"), "test"),
105
+ ]
106
+
107
+ # Boundary / edge-case values per field type
108
+ _BOUNDARY_VALUES: dict[str, list[str]] = {
109
+ "text": [
110
+ "",
111
+ "a" * 1001,
112
+ "<script>alert('xss')</script>",
113
+ "'; DROP TABLE users; --",
114
+ "\u0000\u0001\u001f",
115
+ "\U0001F600\U0001F4A5", # emoji
116
+ "\u202e\u0041\u202c", # RTL override
117
+ ],
118
+ "email": [
119
+ "",
120
+ "notanemail",
121
+ "double@@domain.com",
122
+ "nodomain@",
123
+ "@nodomain.com",
124
+ "a" * 250 + "@example.com",
125
+ ],
126
+ "password": [
127
+ "",
128
+ "a",
129
+ "a" * 1001,
130
+ " ",
131
+ "\t\n",
132
+ ],
133
+ "number": [
134
+ "0",
135
+ "-1",
136
+ "999999999",
137
+ "NaN",
138
+ "",
139
+ "1.7976931348623157e+308",
140
+ ],
141
+ "tel": [
142
+ "",
143
+ "abc",
144
+ "+" + "1" * 30,
145
+ "0000000000",
146
+ ],
147
+ "url": [
148
+ "",
149
+ "not-a-url",
150
+ "javascript:alert(1)",
151
+ "http://",
152
+ "ftp://example.com/../../../etc/passwd",
153
+ ],
154
+ "date": [
155
+ "",
156
+ "9999-12-31",
157
+ "0000-01-01",
158
+ "not-a-date",
159
+ "2000-13-01",
160
+ "2000-01-32",
161
+ ],
162
+ "textarea": [
163
+ "",
164
+ "a" * 10001,
165
+ "<script>alert('xss')</script>",
166
+ "'; DROP TABLE users; --",
167
+ "\u202e\u0041\u202c",
168
+ ],
169
+ "select": ["", "__invalid_option__"],
170
+ "checkbox": ["false"],
171
+ "search": ["", "<script>alert(1)</script>", "' OR '1'='1"],
172
+ }
173
+
174
+
175
+ class ScenarioConfigGenerator:
176
+ """Generate functional test scenario configs from a :class:`DiscoveryModel`.
177
+
178
+ Parameters
179
+ ----------
180
+ discovery_model:
181
+ The structured page graph produced by :class:`WebCrawlerAgent`.
182
+ adaptive_controller:
183
+ Optional :class:`~mannf.core.prioritization.adaptive_controller.AdaptiveController`
184
+ instance for belief-guided strategy selection.
185
+ llm_provider:
186
+ Optional LLM provider (any object with an async ``generate`` method).
187
+ When set, LLM prompts are used for richer test data; when ``None`` the
188
+ generator falls back to heuristic value generation.
189
+ max_scenarios_per_form:
190
+ Upper bound on scenarios produced for any single form.
191
+ max_flow_depth:
192
+ Maximum number of pages in a generated multi-step flow.
193
+ include_negative:
194
+ Whether to include negative / invalid-input scenarios.
195
+ include_boundary:
196
+ Whether to include boundary-value scenarios.
197
+ include_exploratory:
198
+ Whether to include click-through scenarios for interactive elements.
199
+ """
200
+
201
+ def __init__(
202
+ self,
203
+ discovery_model: DiscoveryModel,
204
+ *,
205
+ adaptive_controller: Any | None = None,
206
+ llm_provider: Any | None = None,
207
+ max_scenarios_per_form: int = 10,
208
+ max_flow_depth: int = 5,
209
+ include_negative: bool = True,
210
+ include_boundary: bool = True,
211
+ include_exploratory: bool = True,
212
+ include_security: bool = False,
213
+ locale: str = "en-US",
214
+ domain_context: str = "",
215
+ ) -> None:
216
+ self._model = discovery_model
217
+ self._ac = adaptive_controller
218
+ self._llm = llm_provider
219
+ self._max_per_form = max_scenarios_per_form
220
+ self._max_flow_depth = max_flow_depth
221
+ self._include_negative = include_negative
222
+ self._include_boundary = include_boundary
223
+ self._include_exploratory = include_exploratory
224
+ self._include_security = include_security
225
+ self._locale = locale
226
+ self._domain_context = domain_context
227
+
228
+ # ------------------------------------------------------------------
229
+ # Public API
230
+ # ------------------------------------------------------------------
231
+
232
+ def generate_all(self) -> list[dict[str, Any]]:
233
+ """Generate all scenario configs from the discovery model.
234
+
235
+ Returns
236
+ -------
237
+ list[dict]
238
+ A list of task dicts compatible with
239
+ :meth:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator.run`.
240
+ """
241
+ scenarios: list[dict[str, Any]] = []
242
+
243
+ for page in self._model.pages:
244
+ for form in page.forms:
245
+ scenarios.extend(self._generate_form_scenarios(page, form))
246
+ if self._include_exploratory:
247
+ scenarios.extend(self._generate_interactive_element_scenarios(page))
248
+
249
+ scenarios.extend(self._generate_flow_scenarios())
250
+
251
+ if self._include_security:
252
+ from mannf.core.browser.security_scenario_generator import SecurityScenarioGenerator # noqa: PLC0415
253
+ sec_gen = SecurityScenarioGenerator(self._model)
254
+ scenarios.extend(sec_gen.generate())
255
+
256
+ return self._rank_by_adaptive_controller(scenarios)
257
+
258
+ async def generate_all_async(self) -> list[dict[str, Any]]:
259
+ """Generate all scenario configs, optionally augmented with LLM suggestions.
260
+
261
+ Generates heuristic scenarios first via :meth:`generate_all`, then—when
262
+ an LLM provider is configured and available—appends additional scenarios
263
+ produced by the three UI-focused LLM prompts:
264
+
265
+ * :meth:`_llm_form_fill` — realistic happy-path field values per form.
266
+ * :meth:`_llm_negative_path` — negative / edge-case field values per form.
267
+ * :meth:`_llm_flow_suggestions` — AI-suggested multi-step user journeys.
268
+
269
+ LLM calls are best-effort: any failure is logged and the heuristic
270
+ scenarios are returned unchanged.
271
+
272
+ Returns
273
+ -------
274
+ list[dict]
275
+ Heuristic scenarios followed by LLM-generated scenarios (if any).
276
+ """
277
+ scenarios: list[dict[str, Any]] = self.generate_all()
278
+
279
+ if self._llm is None or not self._llm.is_available():
280
+ return scenarios
281
+
282
+ llm_scenarios: list[dict[str, Any]] = []
283
+
284
+ for page in self._model.pages:
285
+ for form in page.forms:
286
+ # Happy-path LLM fill
287
+ try:
288
+ fill_results = await self._llm_form_fill(page, form)
289
+ if fill_results:
290
+ llm_scenarios.extend(
291
+ self._convert_llm_form_fill_to_scenarios(
292
+ page, form, fill_results
293
+ )
294
+ )
295
+ except Exception as exc: # noqa: BLE001
296
+ logger.warning("LLM form-fill conversion failed for %s: %s", page.url, exc)
297
+
298
+ # Negative-path LLM scenarios
299
+ try:
300
+ neg_results = await self._llm_negative_path(page, form)
301
+ if neg_results:
302
+ llm_scenarios.extend(
303
+ self._convert_llm_negative_to_scenarios(
304
+ page, form, neg_results
305
+ )
306
+ )
307
+ except Exception as exc: # noqa: BLE001
308
+ logger.warning(
309
+ "LLM negative-path conversion failed for %s: %s", page.url, exc
310
+ )
311
+
312
+ # Flow suggestions (once per model)
313
+ try:
314
+ flow_results = await self._llm_flow_suggestions()
315
+ if flow_results:
316
+ llm_scenarios.extend(
317
+ self._convert_llm_flow_to_scenarios(flow_results)
318
+ )
319
+ except Exception as exc: # noqa: BLE001
320
+ logger.warning("LLM flow-suggestion conversion failed: %s", exc)
321
+
322
+ # Security scenarios (LLM-augmented when include_security is set)
323
+ if self._include_security:
324
+ from mannf.core.browser.security_scenario_generator import SecurityScenarioGenerator # noqa: PLC0415
325
+ sec_gen = SecurityScenarioGenerator(self._model, llm_provider=self._llm)
326
+ try:
327
+ sec_scenarios = await sec_gen.generate_async()
328
+ llm_scenarios.extend(sec_scenarios)
329
+ except Exception as exc: # noqa: BLE001
330
+ logger.warning("Security scenario generation failed: %s", exc)
331
+
332
+ return self._rank_by_adaptive_controller(scenarios + llm_scenarios)
333
+
334
+ # ------------------------------------------------------------------
335
+ # Form scenarios
336
+ # ------------------------------------------------------------------
337
+
338
+ def _generate_form_scenarios(
339
+ self, page: DiscoveredPage, form: DiscoveredForm
340
+ ) -> list[dict[str, Any]]:
341
+ """Generate scenario configs for a single form on a page.
342
+
343
+ Produces (up to ``max_scenarios_per_form``):
344
+
345
+ 1. Happy path — all fields filled with valid data.
346
+ 2. Required-field omission — one variant per required field that skips it.
347
+ 3. Boundary values — edge-case values per field.
348
+ 4. Negative values — invalid format values per field.
349
+ 5. Empty submit — submit the form without filling anything.
350
+ """
351
+ scenarios: list[dict[str, Any]] = []
352
+ fillable = [f for f in form.fields if f.type not in ("hidden", "file")]
353
+
354
+ # 1. Happy path
355
+ scenarios.append(self._happy_path_scenario(page, form, fillable))
356
+
357
+ # 2. Required field omission
358
+ required_fields = [f for f in fillable if f.required]
359
+ for field in required_fields:
360
+ if len(scenarios) >= self._max_per_form:
361
+ break
362
+ scenarios.append(self._omit_field_scenario(page, form, fillable, field))
363
+
364
+ # 3. Boundary values
365
+ if self._include_boundary:
366
+ for field in fillable:
367
+ if len(scenarios) >= self._max_per_form:
368
+ break
369
+ boundary_vals = self._generate_boundary_value(field)
370
+ for bval in boundary_vals[:2]: # cap at 2 per field
371
+ if len(scenarios) >= self._max_per_form:
372
+ break
373
+ scenarios.append(
374
+ self._boundary_scenario(page, form, fillable, field, bval)
375
+ )
376
+
377
+ # 4. Negative values
378
+ if self._include_negative:
379
+ for field in fillable:
380
+ if len(scenarios) >= self._max_per_form:
381
+ break
382
+ neg_vals = self._generate_negative_value(field)
383
+ if neg_vals:
384
+ scenarios.append(
385
+ self._negative_scenario(page, form, fillable, field, neg_vals[0])
386
+ )
387
+
388
+ # 5. Empty submit
389
+ if len(scenarios) < self._max_per_form:
390
+ scenarios.append(self._empty_submit_scenario(page, form))
391
+
392
+ return scenarios[: self._max_per_form]
393
+
394
+ def _happy_path_scenario(
395
+ self,
396
+ page: DiscoveredPage,
397
+ form: DiscoveredForm,
398
+ fillable: list[DiscoveredField],
399
+ ) -> dict[str, Any]:
400
+ steps = self._navigate_step(page.url)
401
+ for field in fillable:
402
+ value = self._generate_valid_value(field)
403
+ if value:
404
+ steps.append({"action": "fill", "selector": field.selector, "value": value})
405
+ steps.append({"action": "click", "selector": form.submit_selector})
406
+ return self._make_scenario(
407
+ page=page,
408
+ form=form,
409
+ steps=steps,
410
+ scenario_type="happy_path",
411
+ description=f"Happy path — fill {form.action or page.url} with valid data",
412
+ )
413
+
414
+ def _omit_field_scenario(
415
+ self,
416
+ page: DiscoveredPage,
417
+ form: DiscoveredForm,
418
+ fillable: list[DiscoveredField],
419
+ omitted: DiscoveredField,
420
+ ) -> dict[str, Any]:
421
+ steps = self._navigate_step(page.url)
422
+ for field in fillable:
423
+ if field.selector == omitted.selector:
424
+ continue
425
+ value = self._generate_valid_value(field)
426
+ if value:
427
+ steps.append({"action": "fill", "selector": field.selector, "value": value})
428
+ steps.append({"action": "click", "selector": form.submit_selector})
429
+ label_hint = omitted.label or omitted.name or omitted.selector
430
+ return self._make_scenario(
431
+ page=page,
432
+ form=form,
433
+ steps=steps,
434
+ scenario_type="negative",
435
+ description=f"Required field omission — skip '{label_hint}'",
436
+ )
437
+
438
+ def _boundary_scenario(
439
+ self,
440
+ page: DiscoveredPage,
441
+ form: DiscoveredForm,
442
+ fillable: list[DiscoveredField],
443
+ target_field: DiscoveredField,
444
+ boundary_value: str,
445
+ ) -> dict[str, Any]:
446
+ steps = self._navigate_step(page.url)
447
+ for field in fillable:
448
+ if field.selector == target_field.selector:
449
+ value = boundary_value
450
+ else:
451
+ value = self._generate_valid_value(field)
452
+ if value:
453
+ steps.append({"action": "fill", "selector": field.selector, "value": value})
454
+ steps.append({"action": "click", "selector": form.submit_selector})
455
+ label_hint = target_field.label or target_field.name or target_field.selector
456
+ return self._make_scenario(
457
+ page=page,
458
+ form=form,
459
+ steps=steps,
460
+ scenario_type="boundary",
461
+ description=(
462
+ f"Boundary value for '{label_hint}': {boundary_value!r}"
463
+ ),
464
+ )
465
+
466
+ def _negative_scenario(
467
+ self,
468
+ page: DiscoveredPage,
469
+ form: DiscoveredForm,
470
+ fillable: list[DiscoveredField],
471
+ target_field: DiscoveredField,
472
+ negative_value: str,
473
+ ) -> dict[str, Any]:
474
+ steps = self._navigate_step(page.url)
475
+ for field in fillable:
476
+ if field.selector == target_field.selector:
477
+ value = negative_value
478
+ else:
479
+ value = self._generate_valid_value(field)
480
+ if value:
481
+ steps.append({"action": "fill", "selector": field.selector, "value": value})
482
+ steps.append({"action": "click", "selector": form.submit_selector})
483
+ label_hint = target_field.label or target_field.name or target_field.selector
484
+ return self._make_scenario(
485
+ page=page,
486
+ form=form,
487
+ steps=steps,
488
+ scenario_type="negative",
489
+ description=f"Negative value for '{label_hint}': {negative_value!r}",
490
+ )
491
+
492
+ def _empty_submit_scenario(
493
+ self, page: DiscoveredPage, form: DiscoveredForm
494
+ ) -> dict[str, Any]:
495
+ steps = self._navigate_step(page.url)
496
+ steps.append({"action": "click", "selector": form.submit_selector})
497
+ return self._make_scenario(
498
+ page=page,
499
+ form=form,
500
+ steps=steps,
501
+ scenario_type="negative",
502
+ description="Submit empty form without filling any fields",
503
+ )
504
+
505
+ # ------------------------------------------------------------------
506
+ # Flow scenarios
507
+ # ------------------------------------------------------------------
508
+
509
+ def _generate_flow_scenarios(self) -> list[dict[str, Any]]:
510
+ """Generate multi-step flow scenarios by traversing the page graph.
511
+
512
+ Uses BFS from each discovered page to find paths up to
513
+ ``max_flow_depth`` hops. Detects login-like forms (those with a
514
+ password field) and chains them with subsequent pages.
515
+ """
516
+ if not self._model.edges:
517
+ return []
518
+
519
+ # Build adjacency list: from_url → list[(to_url, edge)]
520
+ adjacency: dict[str, list[tuple[str, PageEdge]]] = {}
521
+ for edge in self._model.edges:
522
+ adjacency.setdefault(edge.from_url, []).append((edge.to_url, edge))
523
+
524
+ # Index pages by URL for quick lookup
525
+ page_by_url: dict[str, DiscoveredPage] = {p.url: p for p in self._model.pages}
526
+
527
+ # Identify entry pages (pages that have no incoming edges or are the first page)
528
+ destinations = {e.to_url for e in self._model.edges}
529
+ entry_pages = [
530
+ p.url
531
+ for p in self._model.pages
532
+ if p.url not in destinations or p.url == self._model.base_url
533
+ ]
534
+ if not entry_pages:
535
+ entry_pages = [self._model.pages[0].url] if self._model.pages else []
536
+
537
+ scenarios: list[dict[str, Any]] = []
538
+
539
+ for entry in entry_pages:
540
+ paths = self._bfs_paths(entry, adjacency, self._max_flow_depth)
541
+ for path in paths:
542
+ if len(path) < 2:
543
+ continue
544
+ flow_steps: list[dict[str, Any]] = []
545
+ for url in path:
546
+ page = page_by_url.get(url)
547
+ if page is None:
548
+ flow_steps.append({"action": "navigate", "selector": url})
549
+ continue
550
+ flow_steps.extend(self._navigate_step(url))
551
+ # Fill any login-like (password) form on this page
552
+ for form in page.forms:
553
+ fillable = [
554
+ f for f in form.fields if f.type not in ("hidden", "file")
555
+ ]
556
+ has_password = any(f.type == "password" for f in fillable)
557
+ if has_password or url == path[0]:
558
+ for field in fillable:
559
+ value = self._generate_valid_value(field)
560
+ if value:
561
+ flow_steps.append(
562
+ {
563
+ "action": "fill",
564
+ "selector": field.selector,
565
+ "value": value,
566
+ }
567
+ )
568
+ flow_steps.append(
569
+ {"action": "click", "selector": form.submit_selector}
570
+ )
571
+ break # only interact with the first form per page in a flow
572
+
573
+ if flow_steps:
574
+ page_titles = [
575
+ (page_by_url[u].title if u in page_by_url else u) for u in path
576
+ ]
577
+ scenarios.append(
578
+ {
579
+ "url": self._model.base_url,
580
+ "steps": flow_steps,
581
+ "metadata": {
582
+ "scenario_type": "flow",
583
+ "source_page": entry,
584
+ "source_form_action": "",
585
+ "description": (
586
+ f"Multi-step flow: {' → '.join(page_titles)}"
587
+ ),
588
+ },
589
+ }
590
+ )
591
+
592
+ return scenarios
593
+
594
+ def _bfs_paths(
595
+ self,
596
+ start: str,
597
+ adjacency: dict[str, list[tuple[str, PageEdge]]],
598
+ max_depth: int,
599
+ ) -> list[list[str]]:
600
+ """Return all simple paths reachable from *start* via BFS."""
601
+ paths: list[list[str]] = []
602
+ queue: deque[list[str]] = deque([[start]])
603
+ while queue:
604
+ path = queue.popleft()
605
+ current = path[-1]
606
+ neighbours = adjacency.get(current, [])
607
+ for neighbour_url, _ in neighbours:
608
+ if neighbour_url in path:
609
+ continue # avoid cycles
610
+ new_path = path + [neighbour_url]
611
+ paths.append(new_path)
612
+ if len(new_path) < max_depth:
613
+ queue.append(new_path)
614
+ return paths
615
+
616
+ # ------------------------------------------------------------------
617
+ # Interactive element scenarios
618
+ # ------------------------------------------------------------------
619
+
620
+ def _generate_interactive_element_scenarios(
621
+ self, page: DiscoveredPage
622
+ ) -> list[dict[str, Any]]:
623
+ """Generate click-through scenarios for non-form interactive elements."""
624
+ scenarios: list[dict[str, Any]] = []
625
+ for element in page.interactive_elements:
626
+ if element.type in ("button", "link"):
627
+ steps = self._navigate_step(page.url)
628
+ steps.append({"action": "click", "selector": element.selector})
629
+ scenarios.append(
630
+ {
631
+ "url": page.url,
632
+ "steps": steps,
633
+ "metadata": {
634
+ "scenario_type": "exploratory",
635
+ "source_page": page.url,
636
+ "source_form_action": "",
637
+ "description": (
638
+ f"Click interactive element: {element.text or element.selector}"
639
+ ),
640
+ },
641
+ }
642
+ )
643
+ return scenarios
644
+
645
+ # ------------------------------------------------------------------
646
+ # Value generation helpers
647
+ # ------------------------------------------------------------------
648
+
649
+ def _generate_valid_value(self, field: DiscoveredField) -> str:
650
+ """Generate a realistic valid value for *field*.
651
+
652
+ Priority order:
653
+ 1. Heuristic match on label/name/placeholder.
654
+ 2. Type-specific default value from ``_VALID_VALUES``.
655
+ 3. Generic ``"test"`` fallback.
656
+ """
657
+ heuristic = self._infer_field_value_from_label(field)
658
+ if heuristic:
659
+ return heuristic
660
+ return _VALID_VALUES.get(field.type, "test")
661
+
662
+ def _generate_boundary_value(self, field: DiscoveredField) -> list[str]:
663
+ """Generate boundary / edge-case values for *field*.
664
+
665
+ Returns a list of strings (may be empty for types without known boundaries).
666
+ """
667
+ ftype = field.type
668
+ # Map some types to their common boundary category
669
+ if ftype in ("textarea",):
670
+ return list(_BOUNDARY_VALUES.get("textarea", []))
671
+ if ftype in ("text", "search") or ftype not in _BOUNDARY_VALUES:
672
+ return list(_BOUNDARY_VALUES.get("text", []))
673
+ return list(_BOUNDARY_VALUES.get(ftype, []))
674
+
675
+ def _generate_negative_value(self, field: DiscoveredField) -> list[str]:
676
+ """Generate invalid-format values for *field* based on its type."""
677
+ ftype = field.type
678
+ if ftype == "email":
679
+ return ["notanemail", "double@@host.com", "@nodomain"]
680
+ if ftype == "password":
681
+ return ["", "a", " "]
682
+ if ftype == "url":
683
+ return ["not-a-url", "javascript:alert(1)", ""]
684
+ if ftype == "number":
685
+ return ["abc", "NaN", "--1"]
686
+ if ftype == "tel":
687
+ return ["abc", "000", ""]
688
+ if ftype == "date":
689
+ return ["not-a-date", "9999-99-99", ""]
690
+ if ftype in ("text", "textarea", "search"):
691
+ return [
692
+ "<script>alert('xss')</script>",
693
+ "'; DROP TABLE users; --",
694
+ "\u202e",
695
+ ]
696
+ if ftype == "checkbox":
697
+ return ["notabool"]
698
+ return []
699
+
700
+ def _infer_field_value_from_label(self, field: DiscoveredField) -> str:
701
+ """Use label/name/placeholder heuristics to infer a contextual value."""
702
+ # Build a candidate string from available text attributes and split into tokens
703
+ raw = " ".join(
704
+ filter(
705
+ None,
706
+ [
707
+ field.label,
708
+ field.name,
709
+ field.placeholder,
710
+ ],
711
+ )
712
+ ).lower()
713
+ candidate = re.sub(r"[\s_\-]+", "_", raw)
714
+ # Token set for whole-word matching (underscore-delimited)
715
+ tokens = set(candidate.split("_")) if candidate else set()
716
+
717
+ for keywords, value in _LABEL_HEURISTICS:
718
+ for kw in keywords:
719
+ normalised_kw = re.sub(r"[\s_\-]+", "_", kw.lower())
720
+ kw_tokens = set(normalised_kw.split("_"))
721
+ # Match if the keyword tokens are a subset of the candidate tokens,
722
+ # or if the full normalised keyword is an exact substring of the candidate
723
+ # when the keyword is a single token (avoids "name" matching "username").
724
+ if len(kw_tokens) == 1:
725
+ # Single-token keyword: require exact token match
726
+ if kw_tokens <= tokens:
727
+ return value
728
+ else:
729
+ # Multi-token keyword: require all tokens to be present
730
+ if kw_tokens <= tokens:
731
+ return value
732
+
733
+ # Special case: if the field type already implies an email address
734
+ if field.type == "email":
735
+ return "test@example.com"
736
+ if field.type == "password":
737
+ return "TestP@ss123!"
738
+
739
+ return ""
740
+
741
+ # ------------------------------------------------------------------
742
+ # LLM integration (optional)
743
+ # ------------------------------------------------------------------
744
+
745
+ async def _llm_form_fill(
746
+ self, page: DiscoveredPage, form: DiscoveredForm, n: int = 1
747
+ ) -> list[dict[str, Any]] | None:
748
+ """Call LLM for realistic happy-path fill values.
749
+
750
+ Returns parsed list of test case dicts, or ``None`` on failure.
751
+ """
752
+ if self._llm is None:
753
+ return None
754
+ from mannf.product.llm.prompts import UI_FORM_FILL_PROMPT # local import
755
+
756
+ fields_desc = self._format_fields_description(form.fields)
757
+ prompt = UI_FORM_FILL_PROMPT.format(
758
+ n=n,
759
+ locale=self._locale,
760
+ domain_context=self._domain_context or "general web application",
761
+ page_url=page.url,
762
+ form_action=form.action,
763
+ form_method=form.method,
764
+ fields_description=fields_desc,
765
+ )
766
+ try:
767
+ raw = await self._llm.generate(prompt)
768
+ return json.loads(raw)
769
+ except Exception as exc:
770
+ logger.warning("LLM form-fill call failed: %s", exc)
771
+ return None
772
+
773
+ async def _llm_negative_path(
774
+ self, page: DiscoveredPage, form: DiscoveredForm, n: int = 3
775
+ ) -> list[dict[str, Any]] | None:
776
+ """Call LLM for negative/edge-case fill values.
777
+
778
+ Returns parsed list of test case dicts, or ``None`` on failure.
779
+ """
780
+ if self._llm is None:
781
+ return None
782
+ from mannf.product.llm.prompts import UI_NEGATIVE_PATH_PROMPT # local import
783
+
784
+ fields_desc = self._format_fields_description(form.fields)
785
+ prompt = UI_NEGATIVE_PATH_PROMPT.format(
786
+ n=n,
787
+ page_url=page.url,
788
+ form_action=form.action,
789
+ form_method=form.method,
790
+ fields_description=fields_desc,
791
+ )
792
+ try:
793
+ raw = await self._llm.generate(prompt)
794
+ return json.loads(raw)
795
+ except Exception as exc:
796
+ logger.warning("LLM negative-path call failed: %s", exc)
797
+ return None
798
+
799
+ async def _llm_flow_suggestions(self, n: int = 3) -> list[dict[str, Any]] | None:
800
+ """Call LLM for multi-step flow suggestions.
801
+
802
+ Returns parsed list of scenario dicts, or ``None`` on failure.
803
+ """
804
+ if self._llm is None:
805
+ return None
806
+ from mannf.product.llm.prompts import UI_FLOW_SUGGESTION_PROMPT # local import
807
+
808
+ pages_desc = "\n".join(
809
+ f" - {p.url}: {p.title} ({len(p.forms)} forms, {len(p.links)} links)"
810
+ for p in self._model.pages
811
+ )
812
+ edges_desc = "\n".join(
813
+ f" - {e.from_url} --[{e.action}:{e.selector}]--> {e.to_url}"
814
+ for e in self._model.edges
815
+ )
816
+ prompt = UI_FLOW_SUGGESTION_PROMPT.format(
817
+ n=n,
818
+ base_url=self._model.base_url,
819
+ pages_description=pages_desc,
820
+ edges_description=edges_desc,
821
+ )
822
+ try:
823
+ raw = await self._llm.generate(prompt)
824
+ return json.loads(raw)
825
+ except Exception as exc:
826
+ logger.warning("LLM flow-suggestion call failed: %s", exc)
827
+ return None
828
+
829
+ async def _llm_accessibility(
830
+ self, page: DiscoveredPage, form: DiscoveredForm, n: int = 3
831
+ ) -> list[dict[str, Any]] | None:
832
+ """Call LLM for accessibility-focused test cases.
833
+
834
+ Returns parsed list of test case dicts, or ``None`` on failure.
835
+ """
836
+ if self._llm is None:
837
+ return None
838
+ from mannf.product.llm.prompts import UI_ACCESSIBILITY_PROMPT # local import
839
+
840
+ fields_desc = self._format_fields_description(form.fields)
841
+ prompt = UI_ACCESSIBILITY_PROMPT.format(
842
+ n=n,
843
+ page_url=page.url,
844
+ fields_description=fields_desc,
845
+ )
846
+ try:
847
+ raw = await self._llm.generate(prompt)
848
+ return json.loads(raw)
849
+ except Exception as exc:
850
+ logger.warning("LLM accessibility call failed: %s", exc)
851
+ return None
852
+
853
+ # ------------------------------------------------------------------
854
+ # LLM response → scenario dict converters
855
+ # ------------------------------------------------------------------
856
+
857
+ def _convert_llm_form_fill_to_scenarios(
858
+ self,
859
+ page: DiscoveredPage,
860
+ form: DiscoveredForm,
861
+ fill_results: list[dict[str, Any]],
862
+ ) -> list[dict[str, Any]]:
863
+ """Convert ``_llm_form_fill`` results into standard scenario dicts.
864
+
865
+ Parameters
866
+ ----------
867
+ page:
868
+ The page the form lives on.
869
+ form:
870
+ The form whose fields were filled.
871
+ fill_results:
872
+ List of dicts from the LLM, each with at least an ``"inputs"`` key
873
+ mapping ``selector → value``.
874
+
875
+ Returns
876
+ -------
877
+ list[dict]
878
+ Standard scenario dicts tagged with ``"generator": "llm"``.
879
+ """
880
+ scenarios: list[dict[str, Any]] = []
881
+ for item in fill_results:
882
+ if not isinstance(item, dict):
883
+ continue
884
+ inputs: dict[str, str] = item.get("inputs", {})
885
+ if not isinstance(inputs, dict):
886
+ continue
887
+ description = item.get("description", "LLM-generated happy-path scenario")
888
+ fill_steps = [
889
+ {"action": "fill", "selector": selector, "value": str(value)}
890
+ for selector, value in inputs.items()
891
+ ]
892
+ if form.submit_selector:
893
+ fill_steps.append({"action": "click", "selector": form.submit_selector})
894
+ steps = self._navigate_step(page.url) + fill_steps
895
+ scenario = {
896
+ "url": page.url,
897
+ "steps": steps,
898
+ "metadata": {
899
+ "scenario_type": "happy_path",
900
+ "source_page": page.url,
901
+ "source_form_action": form.action,
902
+ "description": f"LLM-generated: {description}",
903
+ "generator": "llm",
904
+ },
905
+ }
906
+ scenarios.append(scenario)
907
+ return scenarios
908
+
909
+ def _convert_llm_negative_to_scenarios(
910
+ self,
911
+ page: DiscoveredPage,
912
+ form: DiscoveredForm,
913
+ neg_results: list[dict[str, Any]],
914
+ ) -> list[dict[str, Any]]:
915
+ """Convert ``_llm_negative_path`` results into standard scenario dicts.
916
+
917
+ Parameters
918
+ ----------
919
+ page:
920
+ The page the form lives on.
921
+ form:
922
+ The form whose fields are being tested negatively.
923
+ neg_results:
924
+ List of dicts from the LLM, each with at least an ``"inputs"`` key.
925
+
926
+ Returns
927
+ -------
928
+ list[dict]
929
+ Standard scenario dicts tagged with ``"generator": "llm"``.
930
+ """
931
+ scenarios: list[dict[str, Any]] = []
932
+ for item in neg_results:
933
+ if not isinstance(item, dict):
934
+ continue
935
+ inputs: dict[str, str] = item.get("inputs", {})
936
+ if not isinstance(inputs, dict):
937
+ continue
938
+ description = item.get(
939
+ "expected_behavior",
940
+ item.get("description", "LLM-generated negative scenario"),
941
+ )
942
+ fill_steps = [
943
+ {"action": "fill", "selector": selector, "value": str(value)}
944
+ for selector, value in inputs.items()
945
+ ]
946
+ if form.submit_selector:
947
+ fill_steps.append({"action": "click", "selector": form.submit_selector})
948
+ steps = self._navigate_step(page.url) + fill_steps
949
+ scenario = {
950
+ "url": page.url,
951
+ "steps": steps,
952
+ "metadata": {
953
+ "scenario_type": "negative",
954
+ "source_page": page.url,
955
+ "source_form_action": form.action,
956
+ "description": f"LLM-generated: {description}",
957
+ "generator": "llm",
958
+ },
959
+ }
960
+ scenarios.append(scenario)
961
+ return scenarios
962
+
963
+ def _convert_llm_flow_to_scenarios(
964
+ self,
965
+ flow_results: list[dict[str, Any]],
966
+ ) -> list[dict[str, Any]]:
967
+ """Convert ``_llm_flow_suggestions`` results into standard scenario dicts.
968
+
969
+ Parameters
970
+ ----------
971
+ flow_results:
972
+ List of flow dicts from the LLM, each with ``"name"``, ``"steps"``,
973
+ ``"description"``, and optional ``"priority"`` keys.
974
+
975
+ Returns
976
+ -------
977
+ list[dict]
978
+ Standard scenario dicts tagged with ``"generator": "llm"``.
979
+ """
980
+ scenarios: list[dict[str, Any]] = []
981
+ base_url = self._model.base_url
982
+ for flow in flow_results:
983
+ if not isinstance(flow, dict):
984
+ continue
985
+ raw_steps: list[dict[str, Any]] = flow.get("steps", [])
986
+ if not isinstance(raw_steps, list):
987
+ raw_steps = []
988
+ # Derive the URL from the first navigate step, or fall back to base_url
989
+ url = base_url
990
+ for step in raw_steps:
991
+ if isinstance(step, dict) and step.get("action") == "navigate":
992
+ sel = step.get("selector", "")
993
+ if sel:
994
+ url = sel if sel.startswith("http") else f"{base_url.rstrip('/')}/{sel.lstrip('/')}"
995
+ break
996
+ description = flow.get("description", flow.get("name", "LLM-generated flow"))
997
+ scenario = {
998
+ "url": url,
999
+ "steps": raw_steps,
1000
+ "metadata": {
1001
+ "scenario_type": "flow",
1002
+ "source_page": url,
1003
+ "source_form_action": "",
1004
+ "description": f"LLM-generated: {description}",
1005
+ "generator": "llm",
1006
+ },
1007
+ }
1008
+ scenarios.append(scenario)
1009
+ return scenarios
1010
+
1011
+ # ------------------------------------------------------------------
1012
+ # AdaptiveController ranking
1013
+ # ------------------------------------------------------------------
1014
+
1015
+ def _rank_by_adaptive_controller(
1016
+ self, scenarios: list[dict[str, Any]]
1017
+ ) -> list[dict[str, Any]]:
1018
+ """Sort *scenarios* by descending risk score using the AdaptiveController.
1019
+
1020
+ When no ``adaptive_controller`` is set the input list is returned
1021
+ unchanged. When one is set, each scenario's target page URL is scored
1022
+ via ``_endpoint_risk()`` and the scenarios are re-ordered so that
1023
+ higher-risk pages appear first. The computed ``risk_score`` and
1024
+ ``confidence`` values are also stored in each scenario's ``metadata``
1025
+ so downstream consumers can surface them directly.
1026
+
1027
+ Parameters
1028
+ ----------
1029
+ scenarios:
1030
+ List of scenario dicts to rank.
1031
+
1032
+ Returns
1033
+ -------
1034
+ list[dict]
1035
+ Scenarios sorted by descending risk (highest-risk first) when an
1036
+ adaptive controller is available; otherwise the original list.
1037
+ """
1038
+ if self._ac is None or not scenarios:
1039
+ return scenarios
1040
+
1041
+ def _risk(scenario: dict[str, Any]) -> float:
1042
+ target = scenario.get("metadata", {}).get("source_page") or scenario.get("url", "")
1043
+ belief_states = getattr(self._ac, "belief_states", None) or []
1044
+ try:
1045
+ return self._ac._endpoint_risk(target, belief_states)
1046
+ except Exception: # noqa: BLE001
1047
+ return 0.5
1048
+
1049
+ for scenario in scenarios:
1050
+ risk = _risk(scenario)
1051
+ meta = scenario.setdefault("metadata", {})
1052
+ meta["risk_score"] = round(risk, 4)
1053
+ meta["confidence"] = round(max(0.0, 1.0 - risk * 0.5), 4)
1054
+
1055
+ return sorted(scenarios, key=_risk, reverse=True)
1056
+
1057
+ # ------------------------------------------------------------------
1058
+ # Internal helpers
1059
+ # ------------------------------------------------------------------
1060
+
1061
+ @staticmethod
1062
+ def _navigate_step(url: str) -> list[dict[str, Any]]:
1063
+ """Return a navigate step list for the given URL."""
1064
+ return [{"action": "navigate", "selector": url}]
1065
+
1066
+ @staticmethod
1067
+ def _make_scenario(
1068
+ page: DiscoveredPage,
1069
+ form: DiscoveredForm,
1070
+ steps: list[dict[str, Any]],
1071
+ scenario_type: str,
1072
+ description: str,
1073
+ ) -> dict[str, Any]:
1074
+ return {
1075
+ "url": page.url,
1076
+ "steps": steps,
1077
+ "metadata": {
1078
+ "scenario_type": scenario_type,
1079
+ "source_page": page.url,
1080
+ "source_form_action": form.action,
1081
+ "description": description,
1082
+ },
1083
+ }
1084
+
1085
+ @staticmethod
1086
+ def _format_fields_description(fields: list[DiscoveredField]) -> str:
1087
+ lines: list[str] = []
1088
+ for f in fields:
1089
+ lines.append(f" - selector={f.selector!r}")
1090
+ lines.append(f" type={f.type!r}")
1091
+ if f.label:
1092
+ lines.append(f" label={f.label!r}")
1093
+ if f.name:
1094
+ lines.append(f" name={f.name!r}")
1095
+ lines.append(f" required={f.required}")
1096
+ if f.placeholder:
1097
+ lines.append(f" placeholder={f.placeholder!r}")
1098
+ if f.pattern:
1099
+ lines.append(f" pattern={f.pattern!r} ← values MUST match this regex")
1100
+ return "\n".join(lines)