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,371 @@
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
+ """Ingestor Bridge — converts IngestResult artifacts into scenario task dicts.
7
+
8
+ Bridges any :class:`~mannf.product.ingestors.models.IngestResult` produced by
9
+ an ingestor plugin (HAR, Postman, cURL, Playwright, etc.) into the same
10
+ ``{"url": ..., "steps": [...], "metadata": {...}}`` shape that
11
+ :class:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator` and the
12
+ Phase 3 dashboard discovery flow expect.
13
+
14
+ Usage::
15
+
16
+ from mannf.product.ingestors import HARIngestor, IngestorSource
17
+ from mannf.core.browser.ingestor_bridge import IngestorBridge
18
+
19
+ source = IngestorSource(format="har", path="/path/to/recording.har")
20
+ result = await HARIngestor().ingest(source, config={})
21
+
22
+ bridge = IngestorBridge(base_url="https://example.com")
23
+ scenarios = bridge.convert(result)
24
+ # scenarios matches ScenarioConfigGenerator.generate_all() output shape
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from typing import Any
31
+ from urllib.parse import urljoin, urlparse
32
+
33
+ from mannf.product.ingestors.models import IngestedTestCase, IngestResult
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Action mapping: ingestor step actions → browser actions
39
+ # ---------------------------------------------------------------------------
40
+
41
+ # Maps ingestor-level step action names to FunctionalTestOrchestrator actions.
42
+ _ACTION_MAP: dict[str, str] = {
43
+ # Browser-level actions (already in the right shape)
44
+ "navigate": "navigate",
45
+ "goto": "navigate",
46
+ "fill": "fill",
47
+ "type": "fill",
48
+ "click": "click",
49
+ "submit": "click",
50
+ "check": "click",
51
+ "uncheck": "click",
52
+ "select": "fill",
53
+ # HTTP-level actions from API ingestors — mapped to navigate for GET, fill
54
+ # for POST bodies (best-effort)
55
+ "send_request": "navigate",
56
+ "get": "navigate",
57
+ "post": "navigate",
58
+ "put": "navigate",
59
+ "delete": "navigate",
60
+ "patch": "navigate",
61
+ # Assertion steps — kept as-is so the orchestrator can handle them
62
+ "assert_status": "assert",
63
+ "assert_text": "assert",
64
+ "assert": "assert",
65
+ "wait": "wait",
66
+ "screenshot": "screenshot",
67
+ }
68
+
69
+ # Selector/field patterns that indicate an authentication step
70
+ _AUTH_SELECTOR_PATTERNS: tuple[str, ...] = (
71
+ "password", "passwd", "pass", "secret", "pin",
72
+ "token", "api_key", "apikey",
73
+ )
74
+
75
+ # Selector/field patterns that indicate a username/email auth step
76
+ _AUTH_USER_PATTERNS: tuple[str, ...] = (
77
+ "email", "username", "user", "login", "signin",
78
+ )
79
+
80
+ # URL path patterns that indicate an auth/login navigation step
81
+ _AUTH_URL_PATTERNS: tuple[str, ...] = (
82
+ "/login", "/signin", "/auth", "/oauth", "/sso",
83
+ "/signup", "/register", "/logout",
84
+ )
85
+
86
+
87
+ def _map_action(action: str) -> str:
88
+ """Return the orchestrator-compatible action name for *action*."""
89
+ return _ACTION_MAP.get(action.lower(), action)
90
+
91
+
92
+ def _resolve_url(step: dict[str, Any], base_url: str) -> str:
93
+ """Extract a URL from a step dict, falling back to *base_url*."""
94
+ for key in ("url", "selector", "href", "location", "path"):
95
+ value = step.get(key, "")
96
+ if isinstance(value, str) and value.strip():
97
+ # Absolute URL
98
+ parsed = urlparse(value)
99
+ if parsed.scheme in ("http", "https"):
100
+ return value
101
+ # Relative path — resolve against base_url
102
+ if value.startswith("/"):
103
+ return urljoin(base_url.rstrip("/") + "/", value.lstrip("/"))
104
+ return base_url
105
+
106
+
107
+ def _is_auth_selector(selector: str) -> bool:
108
+ """Return ``True`` when *selector* looks like an authentication field."""
109
+ lower = selector.lower()
110
+ return any(pat in lower for pat in _AUTH_SELECTOR_PATTERNS)
111
+
112
+
113
+ def _is_auth_user_selector(selector: str) -> bool:
114
+ """Return ``True`` when *selector* looks like a username/email field."""
115
+ lower = selector.lower()
116
+ return any(pat in lower for pat in _AUTH_USER_PATTERNS)
117
+
118
+
119
+ def _is_auth_url(url: str) -> bool:
120
+ """Return ``True`` when *url* path indicates an auth page."""
121
+ lower = url.lower()
122
+ return any(pat in lower for pat in _AUTH_URL_PATTERNS)
123
+
124
+
125
+ def _extract_nav_flow(steps: list[dict[str, Any]]) -> list[str]:
126
+ """Return the ordered list of URLs visited in *steps*."""
127
+ urls: list[str] = []
128
+ for step in steps:
129
+ if step.get("action") == "navigate":
130
+ url = step.get("url") or step.get("selector", "")
131
+ if url and url not in urls:
132
+ urls.append(url)
133
+ return urls
134
+
135
+
136
+ def _extract_auth_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]:
137
+ """Return steps that look like authentication interactions.
138
+
139
+ Includes:
140
+ * Navigate steps to auth URLs
141
+ * Fill steps targeting password / email / username fields
142
+ """
143
+ auth: list[dict[str, Any]] = []
144
+ for step in steps:
145
+ action = step.get("action", "")
146
+ if action == "navigate":
147
+ url = step.get("url") or step.get("selector", "")
148
+ if url and _is_auth_url(str(url)):
149
+ auth.append(step)
150
+ elif action == "fill":
151
+ selector = str(step.get("selector", ""))
152
+ if _is_auth_selector(selector) or _is_auth_user_selector(selector):
153
+ auth.append(step)
154
+ return auth
155
+
156
+
157
+ def _extract_assertion_steps(steps: list[dict[str, Any]]) -> list[dict[str, Any]]:
158
+ """Return steps that are assertions (action == 'assert' or 'assert_*')."""
159
+ return [
160
+ step for step in steps
161
+ if str(step.get("action", "")).startswith("assert")
162
+ ]
163
+
164
+
165
+ def _convert_step(step: dict[str, Any], base_url: str) -> dict[str, Any]:
166
+ """Normalise a single ingestor step into a browser-action step dict."""
167
+ if not isinstance(step, dict):
168
+ return {"action": "navigate", "selector": base_url}
169
+
170
+ action = step.get("action", step.get("type", "navigate"))
171
+ mapped = _map_action(str(action))
172
+
173
+ converted: dict[str, Any] = {"action": mapped}
174
+
175
+ if mapped == "navigate":
176
+ url = _resolve_url(step, base_url)
177
+ converted["selector"] = url
178
+ converted["url"] = url
179
+ elif mapped == "fill":
180
+ converted["selector"] = step.get(
181
+ "selector", step.get("element", step.get("target", ""))
182
+ )
183
+ converted["value"] = str(
184
+ step.get("value", step.get("text", step.get("data", "")))
185
+ )
186
+ elif mapped == "click":
187
+ converted["selector"] = step.get(
188
+ "selector", step.get("element", step.get("target", ""))
189
+ )
190
+ else:
191
+ # Pass through unknown/assertion/wait steps
192
+ converted.update({k: v for k, v in step.items() if k != "action"})
193
+
194
+ return converted
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # IngestorBridge
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ class IngestorBridge:
203
+ """Converts an :class:`IngestResult` into a list of scenario task dicts.
204
+
205
+ Parameters
206
+ ----------
207
+ base_url:
208
+ Fallback URL used when a step doesn't provide an explicit URL.
209
+ source_tag:
210
+ Label embedded in ``metadata.source_ingestor`` to identify the origin.
211
+ """
212
+
213
+ def __init__(self, base_url: str = "", source_tag: str = "ingestor") -> None:
214
+ self._base_url = base_url
215
+ self._source_tag = source_tag
216
+
217
+ # ------------------------------------------------------------------
218
+ # Public API
219
+ # ------------------------------------------------------------------
220
+
221
+ def convert(self, result: IngestResult) -> list[dict[str, Any]]:
222
+ """Convert *result* into a list of scenario task dicts.
223
+
224
+ Parameters
225
+ ----------
226
+ result:
227
+ An :class:`IngestResult` from any ingestor plugin.
228
+
229
+ Returns
230
+ -------
231
+ list[dict]
232
+ Scenario dicts matching
233
+ ``{"url": ..., "steps": [...], "metadata": {...}}``.
234
+ """
235
+ if not result.success and not result.test_cases:
236
+ logger.warning(
237
+ "IngestorBridge: result from %r has no test cases (success=%s, errors=%s)",
238
+ result.source.format,
239
+ result.success,
240
+ result.errors,
241
+ )
242
+
243
+ scenarios: list[dict[str, Any]] = []
244
+
245
+ # Convert test_cases → scenarios
246
+ for tc in result.test_cases:
247
+ scenario = self._test_case_to_scenario(tc, result)
248
+ if scenario:
249
+ scenarios.append(scenario)
250
+
251
+ # If no test cases, generate one scenario per endpoint (navigate-only)
252
+ if not scenarios:
253
+ for ep in result.endpoints:
254
+ scenario = self._endpoint_to_scenario(ep, result)
255
+ if scenario:
256
+ scenarios.append(scenario)
257
+
258
+ logger.debug(
259
+ "IngestorBridge: converted %d test_cases + %d endpoints → %d scenarios",
260
+ len(result.test_cases),
261
+ len(result.endpoints),
262
+ len(scenarios),
263
+ )
264
+ return scenarios
265
+
266
+ # ------------------------------------------------------------------
267
+ # Internal converters
268
+ # ------------------------------------------------------------------
269
+
270
+ def _test_case_to_scenario(
271
+ self, tc: IngestedTestCase, result: IngestResult
272
+ ) -> dict[str, Any] | None:
273
+ """Convert a single :class:`IngestedTestCase` to a scenario dict."""
274
+ # Determine the URL from the first navigate-type step or base_url
275
+ url = self._base_url
276
+ steps: list[dict[str, Any]] = []
277
+
278
+ for raw_step in tc.steps:
279
+ converted = _convert_step(raw_step, self._base_url)
280
+ if converted["action"] in ("navigate", "goto") and url == self._base_url:
281
+ url = converted.get("url", converted.get("selector", self._base_url))
282
+ steps.append(converted)
283
+
284
+ if not steps and not url:
285
+ return None
286
+
287
+ # Enrich metadata with nav flow, auth steps, and assertions
288
+ nav_flow = _extract_nav_flow(steps)
289
+ auth_steps = _extract_auth_steps(steps)
290
+ assertion_steps = _extract_assertion_steps(steps)
291
+ has_auth = bool(auth_steps)
292
+
293
+ return {
294
+ "url": url,
295
+ "steps": steps,
296
+ "metadata": {
297
+ "scenario_type": self._infer_scenario_type(tc),
298
+ "source_page": url,
299
+ "source_form_action": "",
300
+ "description": tc.description or tc.name,
301
+ "generator": "ingestor",
302
+ "source_ingestor": self._source_tag or result.source.format,
303
+ "source_ref": tc.source_ref,
304
+ "priority": tc.priority,
305
+ "tags": list(tc.tags),
306
+ # Enriched fields
307
+ "nav_flow": nav_flow,
308
+ "has_auth": has_auth,
309
+ "auth_steps": auth_steps,
310
+ "assertions": assertion_steps,
311
+ **tc.metadata,
312
+ },
313
+ }
314
+
315
+ def _endpoint_to_scenario(
316
+ self, ep: Any, result: IngestResult
317
+ ) -> dict[str, Any] | None:
318
+ """Create a minimal navigate scenario from an :class:`IngestedEndpoint`."""
319
+ # Build URL from endpoint path + base_url
320
+ path = getattr(ep, "path", "") or ""
321
+ if urlparse(path).scheme in ("http", "https"):
322
+ url = path
323
+ else:
324
+ url = urljoin(self._base_url.rstrip("/") + "/", path.lstrip("/"))
325
+
326
+ if not url:
327
+ return None
328
+
329
+ method = getattr(ep, "method", "GET").upper()
330
+ steps: list[dict[str, Any]] = [{"action": "navigate", "selector": url, "url": url}]
331
+
332
+ return {
333
+ "url": url,
334
+ "steps": steps,
335
+ "metadata": {
336
+ "scenario_type": "api_endpoint",
337
+ "source_page": url,
338
+ "source_form_action": path,
339
+ "description": getattr(ep, "summary", "") or f"{method} {path}",
340
+ "generator": "ingestor",
341
+ "source_ingestor": self._source_tag or result.source.format,
342
+ "http_method": method,
343
+ "tags": list(getattr(ep, "tags", [])),
344
+ },
345
+ }
346
+
347
+ @staticmethod
348
+ def _infer_scenario_type(tc: IngestedTestCase) -> str:
349
+ """Heuristically determine a scenario_type label from a test case."""
350
+ tags_lower = {t.lower() for t in tc.tags}
351
+ name_lower = tc.name.lower()
352
+ desc_lower = tc.description.lower()
353
+
354
+ if any(kw in tags_lower for kw in ("negative", "edge", "error", "invalid")):
355
+ return "negative"
356
+ if any(kw in tags_lower for kw in ("happy", "smoke", "positive", "sunny")):
357
+ return "happy_path"
358
+ if any(kw in tags_lower for kw in ("boundary", "limit", "min", "max")):
359
+ return "boundary"
360
+ if any(kw in tags_lower for kw in ("exploratory", "flow", "journey")):
361
+ return "flow"
362
+
363
+ # Fall back to name/description heuristics
364
+ for kw in ("negative", "invalid", "error", "fail", "reject"):
365
+ if kw in name_lower or kw in desc_lower:
366
+ return "negative"
367
+ for kw in ("happy", "success", "valid", "positive"):
368
+ if kw in name_lower or kw in desc_lower:
369
+ return "happy_path"
370
+
371
+ return "exploratory"
@@ -0,0 +1,217 @@
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
+ """PerformanceMetrics – Web Vitals evaluation engine.
7
+
8
+ Evaluates raw performance metric dictionaries (e.g. collected from a browser
9
+ Performance API or provided programmatically) against Google Core Web Vitals
10
+ thresholds and produces a :class:`PerformanceResult`.
11
+
12
+ **No browser runtime is required** — all evaluation operates on numeric
13
+ dictionaries passed by callers.
14
+
15
+ Default thresholds (Google Core Web Vitals)
16
+ -------------------------------------------
17
+ * ``LCP`` — Largest Contentful Paint good ≤ 2500 ms poor > 4000 ms
18
+ * ``FCP`` — First Contentful Paint good ≤ 1800 ms poor > 3000 ms
19
+ * ``TTI`` — Time to Interactive good ≤ 3800 ms poor > 7300 ms
20
+ * ``CLS`` — Cumulative Layout Shift good ≤ 0.1 poor > 0.25 (unitless)
21
+ * ``TBT`` — Total Blocking Time good ≤ 200 ms poor > 600 ms
22
+ * ``page_load_time`` — Total page load time good ≤ 3000 ms poor > 6000 ms
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from datetime import datetime, timezone
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Default thresholds
33
+ # ---------------------------------------------------------------------------
34
+
35
+ #: Mapping of metric name → ``(threshold_good, threshold_poor, unit)``.
36
+ #:
37
+ #: * ``threshold_good`` — maximum value rated "good"
38
+ #: * ``threshold_poor`` — minimum value rated "poor" (anything between the two
39
+ #: is "needs-improvement")
40
+ #: * ``unit`` — ``"ms"``, ``"s"``, or ``""`` (unitless)
41
+ DEFAULT_THRESHOLDS: dict[str, tuple[float, float, str]] = {
42
+ "LCP": (2500.0, 4000.0, "ms"),
43
+ "FCP": (1800.0, 3000.0, "ms"),
44
+ "TTI": (3800.0, 7300.0, "ms"),
45
+ "CLS": (0.1, 0.25, ""),
46
+ "TBT": (200.0, 600.0, "ms"),
47
+ "page_load_time": (3000.0, 6000.0, "ms"),
48
+ }
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # Data models
53
+ # ---------------------------------------------------------------------------
54
+
55
+ @dataclass
56
+ class PerformanceMetric:
57
+ """A single evaluated performance metric.
58
+
59
+ Parameters
60
+ ----------
61
+ name:
62
+ Metric identifier, e.g. ``"LCP"``, ``"FCP"``, ``"page_load_time"``.
63
+ value:
64
+ Measured value (milliseconds, or unitless for CLS).
65
+ unit:
66
+ ``"ms"``, ``"s"``, or ``""`` for unitless metrics.
67
+ rating:
68
+ One of ``"good"``, ``"needs-improvement"``, or ``"poor"``.
69
+ threshold_good:
70
+ Maximum value that qualifies as ``"good"``.
71
+ threshold_poor:
72
+ Minimum value that qualifies as ``"poor"``.
73
+ """
74
+
75
+ name: str
76
+ value: float
77
+ unit: str
78
+ rating: str
79
+ threshold_good: float
80
+ threshold_poor: float
81
+
82
+
83
+ @dataclass
84
+ class PerformanceResult:
85
+ """Aggregated result of a performance evaluation for a single page.
86
+
87
+ Parameters
88
+ ----------
89
+ url:
90
+ URL of the page the metrics were collected from.
91
+ timestamp:
92
+ ISO 8601 UTC timestamp of when the evaluation was performed.
93
+ metrics:
94
+ All evaluated :class:`PerformanceMetric` objects.
95
+ page_load_time_ms:
96
+ Total page load time in milliseconds.
97
+ """
98
+
99
+ url: str
100
+ timestamp: str
101
+ metrics: list[PerformanceMetric] = field(default_factory=list)
102
+ page_load_time_ms: float = 0.0
103
+
104
+ @property
105
+ def good_count(self) -> int:
106
+ """Number of metrics rated ``"good"``."""
107
+ return sum(1 for m in self.metrics if m.rating == "good")
108
+
109
+ @property
110
+ def poor_count(self) -> int:
111
+ """Number of metrics rated ``"poor"``."""
112
+ return sum(1 for m in self.metrics if m.rating == "poor")
113
+
114
+ @property
115
+ def performance_score(self) -> float:
116
+ """0.0–100.0 performance score based on metric ratings.
117
+
118
+ Weights: good = 1.0, needs-improvement = 0.5, poor = 0.0.
119
+ Returns ``100.0`` when no metrics are present.
120
+ """
121
+ if not self.metrics:
122
+ return 100.0
123
+ weights = {"good": 1.0, "needs-improvement": 0.5, "poor": 0.0}
124
+ total = sum(weights.get(m.rating, 0.5) for m in self.metrics)
125
+ return round((total / len(self.metrics)) * 100.0, 1)
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Internal helpers
130
+ # ---------------------------------------------------------------------------
131
+
132
+ def _rate_metric(value: float, threshold_good: float, threshold_poor: float) -> str:
133
+ """Return the rating string for *value* given the two thresholds."""
134
+ if value <= threshold_good:
135
+ return "good"
136
+ if value > threshold_poor:
137
+ return "poor"
138
+ return "needs-improvement"
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Public API
143
+ # ---------------------------------------------------------------------------
144
+
145
+ def evaluate_metrics(
146
+ raw_metrics: dict[str, float],
147
+ url: str = "",
148
+ custom_thresholds: dict[str, tuple[float, float, str]] | None = None,
149
+ ) -> PerformanceResult:
150
+ """Evaluate raw performance metrics against Web Vitals thresholds.
151
+
152
+ Parameters
153
+ ----------
154
+ raw_metrics:
155
+ Dict mapping metric names to values, e.g.
156
+ ``{"LCP": 2100, "FCP": 1500, ...}``.
157
+ url:
158
+ The page URL these metrics were collected from.
159
+ custom_thresholds:
160
+ Optional overrides for default thresholds. Any metric name present
161
+ here will use the supplied ``(good, poor, unit)`` tuple instead of
162
+ the default.
163
+
164
+ Returns
165
+ -------
166
+ PerformanceResult
167
+ Evaluated metrics with ratings and an overall performance score.
168
+ """
169
+ timestamp = datetime.now(timezone.utc).isoformat()
170
+ thresholds = dict(DEFAULT_THRESHOLDS)
171
+ if custom_thresholds:
172
+ thresholds.update(custom_thresholds)
173
+
174
+ evaluated: list[PerformanceMetric] = []
175
+ for name, value in raw_metrics.items():
176
+ if name in thresholds:
177
+ good, poor, unit = thresholds[name]
178
+ else:
179
+ # Unknown metric: treat as unitless with no hard thresholds
180
+ good, poor, unit = float("inf"), float("inf"), "ms"
181
+ rating = _rate_metric(value, good, poor)
182
+ evaluated.append(
183
+ PerformanceMetric(
184
+ name=name,
185
+ value=value,
186
+ unit=unit,
187
+ rating=rating,
188
+ threshold_good=good,
189
+ threshold_poor=poor,
190
+ )
191
+ )
192
+
193
+ page_load = raw_metrics.get("page_load_time", 0.0)
194
+
195
+ return PerformanceResult(
196
+ url=url,
197
+ timestamp=timestamp,
198
+ metrics=evaluated,
199
+ page_load_time_ms=page_load,
200
+ )
201
+
202
+
203
+ def get_threshold_violations(result: PerformanceResult) -> list[PerformanceMetric]:
204
+ """Return metrics from *result* that are rated ``"poor"``.
205
+
206
+ Parameters
207
+ ----------
208
+ result:
209
+ A :class:`PerformanceResult` previously returned by
210
+ :func:`evaluate_metrics`.
211
+
212
+ Returns
213
+ -------
214
+ list[PerformanceMetric]
215
+ All metrics whose :attr:`~PerformanceMetric.rating` is ``"poor"``.
216
+ """
217
+ return [m for m in result.metrics if m.rating == "poor"]