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,224 @@
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
+ """BrowserCoordinatorAgent – ECNP coordinator for browser-based functional tests.
7
+
8
+ Extends :class:`CoordinatorAgent` to handle browser-specific task coordination:
9
+
10
+ * Creates browser-specific ECNP task announcements with ``url``, ``steps``,
11
+ and ``task_type: "browser"``.
12
+ * Collects ``INTERACTION_RESULT`` messages to track browser test completions.
13
+ * Tracks DOM snapshots and screenshots received from browser agents.
14
+ * Provides :meth:`dispatch_browser_task` for programmatic task submission.
15
+ * Provides :meth:`get_functional_results` to retrieve aggregated outcomes.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import logging
22
+ import uuid
23
+ from typing import Any
24
+
25
+ from mannf.core.agents.coordinator_agent import CoordinatorAgent
26
+ from mannf.core.messaging.bus import MessageBus
27
+ from mannf.core.messaging.messages import Message, MessageType
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class BrowserCoordinatorAgent(CoordinatorAgent):
33
+ """Coordinator that handles browser-based functional testing tasks.
34
+
35
+ Parameters
36
+ ----------
37
+ agent_id:
38
+ Unique identifier.
39
+ bus:
40
+ Shared message bus.
41
+ service_names:
42
+ Services tracked by the BDI belief state.
43
+ bid_timeout_s:
44
+ ECNP bid collection window (seconds).
45
+ max_renegotiations:
46
+ Maximum re-announcements when no bids arrive.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ agent_id: str,
52
+ bus: MessageBus,
53
+ service_names: list[str],
54
+ bid_timeout_s: float = 0.05,
55
+ max_renegotiations: int = 2,
56
+ ) -> None:
57
+ super().__init__(
58
+ agent_id=agent_id,
59
+ bus=bus,
60
+ service_names=service_names,
61
+ bid_timeout_s=bid_timeout_s,
62
+ max_renegotiations=max_renegotiations,
63
+ )
64
+
65
+ # task_id -> {url, steps, status, result}
66
+ self._browser_tasks: dict[str, dict[str, Any]] = {}
67
+
68
+ # Aggregated pass/fail counters
69
+ self._passed_count = 0
70
+ self._failed_count = 0
71
+
72
+ # Collected telemetry from browser agents
73
+ self._dom_snapshots: list[dict[str, Any]] = []
74
+ self._screenshots: list[dict[str, Any]] = []
75
+
76
+ # ------------------------------------------------------------------
77
+ # Subscriptions
78
+ # ------------------------------------------------------------------
79
+
80
+ @property
81
+ def subscribed_types(self) -> set[MessageType]:
82
+ return super().subscribed_types | {
83
+ MessageType.INTERACTION_RESULT,
84
+ MessageType.DOM_SNAPSHOT,
85
+ MessageType.SCREENSHOT_CAPTURED,
86
+ }
87
+
88
+ # ------------------------------------------------------------------
89
+ # Message handling
90
+ # ------------------------------------------------------------------
91
+
92
+ async def _handle_message(self, message: Message) -> None:
93
+ if message.type == MessageType.INTERACTION_RESULT:
94
+ self._handle_interaction_result(message)
95
+ elif message.type == MessageType.DOM_SNAPSHOT:
96
+ self._dom_snapshots.append(message.payload or {})
97
+ elif message.type == MessageType.SCREENSHOT_CAPTURED:
98
+ self._screenshots.append(message.payload or {})
99
+ else:
100
+ await super()._handle_message(message)
101
+
102
+ def _handle_interaction_result(self, message: Message) -> None:
103
+ """Record the outcome of a completed browser task."""
104
+ payload: dict[str, Any] = message.payload or {}
105
+ task_id: str | None = payload.get("task_id")
106
+
107
+ passed: bool = bool(payload.get("passed", False))
108
+
109
+ if task_id and task_id in self._browser_tasks:
110
+ self._browser_tasks[task_id]["status"] = "passed" if passed else "failed"
111
+ self._browser_tasks[task_id]["result"] = payload
112
+ else:
113
+ # Result for a task not tracked here (e.g. ad-hoc INTERACTION_REQUEST)
114
+ logger.debug(
115
+ "BrowserCoordinatorAgent %s: INTERACTION_RESULT for unknown task_id %r",
116
+ self.agent_id, task_id,
117
+ )
118
+
119
+ if passed:
120
+ self._passed_count += 1
121
+ else:
122
+ self._failed_count += 1
123
+
124
+ logger.debug(
125
+ "BrowserCoordinatorAgent %s: task %s → %s",
126
+ self.agent_id, task_id, "PASS" if passed else "FAIL",
127
+ )
128
+
129
+ # ------------------------------------------------------------------
130
+ # Public API
131
+ # ------------------------------------------------------------------
132
+
133
+ async def dispatch_browser_task(
134
+ self,
135
+ url: str,
136
+ steps: list[dict[str, Any]],
137
+ *,
138
+ task_id: str | None = None,
139
+ ) -> str:
140
+ """Announce a browser-based functional test task via ECNP.
141
+
142
+ Parameters
143
+ ----------
144
+ url:
145
+ The URL to navigate to.
146
+ steps:
147
+ List of interaction steps, e.g.
148
+ ``[{"action": "fill", "selector": "#email", "value": "..."}]``.
149
+ task_id:
150
+ Optional explicit task identifier; generated if not provided.
151
+
152
+ Returns
153
+ -------
154
+ str
155
+ The ``task_id`` assigned to this task.
156
+ """
157
+ if task_id is None:
158
+ task_id = str(uuid.uuid4())
159
+
160
+ task: dict[str, Any] = {
161
+ "task_id": task_id,
162
+ "task_type": "browser",
163
+ "target": url, # ExecutorAgent._evaluate_and_bid uses "target"
164
+ "url": url,
165
+ "steps": steps,
166
+ "priority": 0.5,
167
+ "risk_estimate": 0.5,
168
+ }
169
+
170
+ self._browser_tasks[task_id] = {
171
+ "url": url,
172
+ "steps": steps,
173
+ "status": "pending",
174
+ "result": None,
175
+ }
176
+
177
+ # Track task_id so _handle_announcement's dedup check doesn't skip it
178
+ # Initialize _pending_bids for this task_id so the award phase works
179
+ if task_id not in self._pending_bids:
180
+ self._pending_bids[task_id] = []
181
+
182
+ await self._publish(
183
+ Message(MessageType.TASK_ANNOUNCEMENT, self.agent_id, task)
184
+ )
185
+
186
+ # Start bid-collection window and subsequent award (ECNP phase 3→5)
187
+ asyncio.get_running_loop().create_task(
188
+ self._award_after_timeout(task)
189
+ )
190
+
191
+ logger.debug(
192
+ "BrowserCoordinatorAgent %s: dispatched browser task %s → %s",
193
+ self.agent_id, task_id, url,
194
+ )
195
+ return task_id
196
+
197
+ def get_functional_results(self) -> dict[str, Any]:
198
+ """Return a summary of all browser test outcomes.
199
+
200
+ Returns
201
+ -------
202
+ dict
203
+ ``{passed, failed, total, tasks, dom_snapshots, screenshots}``
204
+ """
205
+ return {
206
+ "passed": self._passed_count,
207
+ "failed": self._failed_count,
208
+ "total": self._passed_count + self._failed_count,
209
+ "tasks": dict(self._browser_tasks),
210
+ "dom_snapshots": list(self._dom_snapshots),
211
+ "screenshots": list(self._screenshots),
212
+ }
213
+
214
+ # ------------------------------------------------------------------
215
+ # Diagnostics
216
+ # ------------------------------------------------------------------
217
+
218
+ @property
219
+ def passed_count(self) -> int:
220
+ return self._passed_count
221
+
222
+ @property
223
+ def failed_count(self) -> int:
224
+ return self._failed_count
@@ -0,0 +1,410 @@
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
+ """BrowserExecutorAgent – wraps a Playwright browser session.
7
+
8
+ Extends :class:`ExecutorAgent` to drive real browser interactions (click,
9
+ fill, navigate, screenshot, DOM snapshot) instead of raw HTTP calls.
10
+
11
+ Playwright is imported **lazily** inside ``start_browser``; the module can
12
+ therefore be imported without Playwright installed, and tests can mock the
13
+ ``async_playwright`` context manager.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Any
20
+
21
+ from mannf.core.agents.executor_agent import ExecutorAgent
22
+ from mannf.core.browser.dom_snapshot import DOMSnapshot, capture_dom_snapshot
23
+ from mannf.core.messaging.bus import MessageBus
24
+ from mannf.core.messaging.messages import Message, MessageType
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class BrowserExecutorAgent(ExecutorAgent):
30
+ """Executor agent that drives a Playwright browser session.
31
+
32
+ Parameters
33
+ ----------
34
+ agent_id:
35
+ Unique identifier.
36
+ bus:
37
+ Shared message bus.
38
+ service_names:
39
+ Services tracked by the BDI belief state.
40
+ browser_type:
41
+ Playwright browser family: ``"chromium"``, ``"firefox"``, or
42
+ ``"webkit"``.
43
+ headless:
44
+ Run the browser in headless mode (no visible window).
45
+ viewport_width:
46
+ Browser viewport width in pixels.
47
+ viewport_height:
48
+ Browser viewport height in pixels.
49
+
50
+ Notes
51
+ -----
52
+ A dummy ``None`` value is passed for the ``sut`` parameter required by
53
+ :class:`ExecutorAgent`, because this agent targets URLs via Playwright
54
+ rather than a :class:`~mannf.core.distributed.system_under_test.SystemUnderTest`.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ agent_id: str,
60
+ bus: MessageBus,
61
+ service_names: list[str],
62
+ *,
63
+ browser_type: str = "chromium",
64
+ headless: bool = True,
65
+ viewport_width: int = 1280,
66
+ viewport_height: int = 720,
67
+ ) -> None:
68
+ super().__init__(
69
+ agent_id=agent_id,
70
+ bus=bus,
71
+ service_names=service_names,
72
+ sut=None, # type: ignore[arg-type]
73
+ )
74
+ self._browser_type = browser_type
75
+ self._headless = headless
76
+ self._viewport_width = viewport_width
77
+ self._viewport_height = viewport_height
78
+
79
+ # Playwright state — populated by start_browser()
80
+ self._playwright: Any = None
81
+ self._browser: Any = None
82
+ self._context: Any = None
83
+ self._page: Any = None
84
+
85
+ # ------------------------------------------------------------------
86
+ # Subscription
87
+ # ------------------------------------------------------------------
88
+
89
+ @property
90
+ def subscribed_types(self) -> set[MessageType]:
91
+ return super().subscribed_types | {MessageType.INTERACTION_REQUEST}
92
+
93
+ # ------------------------------------------------------------------
94
+ # Message handling
95
+ # ------------------------------------------------------------------
96
+
97
+ async def _handle_message(self, message: Message) -> None:
98
+ if message.type == MessageType.INTERACTION_REQUEST:
99
+ await self._handle_interaction_request(message)
100
+ else:
101
+ await super()._handle_message(message)
102
+
103
+ async def _handle_interaction_request(self, message: Message) -> None:
104
+ """Dispatch an ad-hoc interaction request from the bus."""
105
+ payload: dict[str, Any] = message.payload or {}
106
+ action = payload.get("action", "")
107
+ try:
108
+ result: dict[str, Any]
109
+ if action == "navigate":
110
+ result = await self.navigate(payload["url"])
111
+ elif action == "click":
112
+ result = await self.click(payload["selector"])
113
+ elif action == "fill":
114
+ result = await self.fill(payload["selector"], payload["value"])
115
+ elif action == "submit_form":
116
+ result = await self.submit_form(payload.get("form_selector", "form"))
117
+ else:
118
+ result = {"error": f"Unknown action: {action!r}"}
119
+ except Exception as exc: # noqa: BLE001
120
+ result = {"error": str(exc), "action": action}
121
+
122
+ await self._publish(
123
+ message.reply(
124
+ MessageType.INTERACTION_RESULT,
125
+ payload=result,
126
+ sender_id=self.agent_id,
127
+ )
128
+ )
129
+
130
+ # ------------------------------------------------------------------
131
+ # Browser lifecycle
132
+ # ------------------------------------------------------------------
133
+
134
+ async def start_browser(self) -> None:
135
+ """Launch Playwright and open a browser context + page.
136
+
137
+ Safe to call even if the browser is already running (idempotent).
138
+ """
139
+ if self._playwright is not None:
140
+ return
141
+
142
+ from playwright.async_api import async_playwright # lazy import
143
+
144
+ self._playwright = await async_playwright().start()
145
+ launcher = getattr(self._playwright, self._browser_type)
146
+ self._browser = await launcher.launch(headless=self._headless)
147
+ self._context = await self._browser.new_context(
148
+ viewport={"width": self._viewport_width, "height": self._viewport_height}
149
+ )
150
+ self._page = await self._context.new_page()
151
+ logger.info(
152
+ "BrowserExecutorAgent %s: browser started (%s, headless=%s)",
153
+ self.agent_id, self._browser_type, self._headless,
154
+ )
155
+
156
+ async def stop_browser(self) -> None:
157
+ """Close the browser and Playwright instance.
158
+
159
+ Safe to call when no browser is running (idempotent).
160
+ """
161
+ if self._playwright is None:
162
+ return
163
+
164
+ try:
165
+ if self._context is not None:
166
+ await self._context.close()
167
+ if self._browser is not None:
168
+ await self._browser.close()
169
+ await self._playwright.stop()
170
+ except Exception: # noqa: BLE001
171
+ logger.exception("BrowserExecutorAgent %s: error during stop_browser", self.agent_id)
172
+ finally:
173
+ self._page = None
174
+ self._context = None
175
+ self._browser = None
176
+ self._playwright = None
177
+ logger.info("BrowserExecutorAgent %s: browser stopped", self.agent_id)
178
+
179
+ # ------------------------------------------------------------------
180
+ # Browser actions
181
+ # ------------------------------------------------------------------
182
+
183
+ async def navigate(self, url: str) -> dict[str, Any]:
184
+ """Navigate to *url* and wait for the page to load.
185
+
186
+ Returns
187
+ -------
188
+ dict
189
+ ``{title, url, status}`` — basic page metadata.
190
+ """
191
+ try:
192
+ response = await self._page.goto(url, wait_until="load")
193
+ title = await self._page.title()
194
+ return {
195
+ "title": title,
196
+ "url": self._page.url,
197
+ "status": response.status if response else None,
198
+ }
199
+ except Exception as exc: # noqa: BLE001
200
+ logger.warning("BrowserExecutorAgent %s: navigate error: %s", self.agent_id, exc)
201
+ return {"error": str(exc), "url": url}
202
+
203
+ async def click(self, selector: str) -> dict[str, Any]:
204
+ """Click the element matched by *selector*.
205
+
206
+ Returns
207
+ -------
208
+ dict
209
+ ``{success, url}`` after the click, or ``{success, error}`` on failure.
210
+ """
211
+ try:
212
+ await self._page.click(selector)
213
+ return {"success": True, "url": self._page.url}
214
+ except Exception as exc: # noqa: BLE001
215
+ logger.warning("BrowserExecutorAgent %s: click error: %s", self.agent_id, exc)
216
+ return {"success": False, "error": str(exc)}
217
+
218
+ async def fill(self, selector: str, value: str) -> dict[str, Any]:
219
+ """Fill the input field matched by *selector* with *value*.
220
+
221
+ Returns
222
+ -------
223
+ dict
224
+ ``{success}`` or ``{success, error}`` on failure.
225
+ """
226
+ try:
227
+ await self._page.fill(selector, value)
228
+ return {"success": True}
229
+ except Exception as exc: # noqa: BLE001
230
+ logger.warning("BrowserExecutorAgent %s: fill error: %s", self.agent_id, exc)
231
+ return {"success": False, "error": str(exc)}
232
+
233
+ async def submit_form(self, form_selector: str = "form") -> dict[str, Any]:
234
+ """Submit the form matched by *form_selector*.
235
+
236
+ Returns
237
+ -------
238
+ dict
239
+ ``{success, url}`` or ``{success, error}`` on failure.
240
+ """
241
+ try:
242
+ await self._page.evaluate(
243
+ f'document.querySelector("{form_selector}").submit()'
244
+ )
245
+ return {"success": True, "url": self._page.url}
246
+ except Exception as exc: # noqa: BLE001
247
+ logger.warning("BrowserExecutorAgent %s: submit_form error: %s", self.agent_id, exc)
248
+ return {"success": False, "error": str(exc)}
249
+
250
+ async def capture_screenshot(self, full_page: bool = False) -> bytes:
251
+ """Take a screenshot and publish a :attr:`~MessageType.SCREENSHOT_CAPTURED` message.
252
+
253
+ Parameters
254
+ ----------
255
+ full_page:
256
+ When ``True``, capture the full scrollable page instead of just
257
+ the visible viewport.
258
+
259
+ Returns
260
+ -------
261
+ bytes
262
+ Raw PNG image bytes.
263
+ """
264
+ png_bytes: bytes = await self._page.screenshot(full_page=full_page)
265
+ await self._publish(
266
+ Message(
267
+ MessageType.SCREENSHOT_CAPTURED,
268
+ sender_id=self.agent_id,
269
+ payload={
270
+ "agent_id": self.agent_id,
271
+ "url": self._page.url,
272
+ "size": len(png_bytes),
273
+ "full_page": full_page,
274
+ },
275
+ )
276
+ )
277
+ return png_bytes
278
+
279
+ async def capture_dom(self) -> DOMSnapshot:
280
+ """Capture a DOM snapshot and publish a :attr:`~MessageType.DOM_SNAPSHOT` message.
281
+
282
+ Returns
283
+ -------
284
+ DOMSnapshot
285
+ The full page DOM state.
286
+ """
287
+ snapshot = await capture_dom_snapshot(self._page)
288
+ await self._publish(
289
+ Message(
290
+ MessageType.DOM_SNAPSHOT,
291
+ sender_id=self.agent_id,
292
+ payload={
293
+ "agent_id": self.agent_id,
294
+ "url": snapshot.url,
295
+ "title": snapshot.title,
296
+ "timestamp": snapshot.timestamp,
297
+ "element_count": len(snapshot.elements),
298
+ "interactive_count": len(snapshot.interactive_elements),
299
+ "form_count": len(snapshot.forms),
300
+ },
301
+ )
302
+ )
303
+ return snapshot
304
+
305
+ # ------------------------------------------------------------------
306
+ # Contract execution (override)
307
+ # ------------------------------------------------------------------
308
+
309
+ async def _execute_contract(self, contract: dict[str, Any]) -> None:
310
+ """Execute a browser-based test contract.
311
+
312
+ Expected ``contract`` payload shape::
313
+
314
+ {
315
+ "url": "https://example.com",
316
+ "steps": [
317
+ {"action": "fill", "selector": "#email", "value": "..."},
318
+ {"action": "click", "selector": "#submit"},
319
+ ],
320
+ }
321
+ """
322
+ target_url: str = contract.get("url", contract.get("target", ""))
323
+ steps: list[dict[str, Any]] = contract.get("steps", [])
324
+
325
+ results: list[dict[str, Any]] = []
326
+ passed = True
327
+ error_msg: str | None = None
328
+
329
+ try:
330
+ nav_result = await self.navigate(target_url)
331
+ results.append({"action": "navigate", **nav_result})
332
+ if "error" in nav_result:
333
+ passed = False
334
+ error_msg = nav_result["error"]
335
+
336
+ if passed:
337
+ for step in steps:
338
+ action = step.get("action", "")
339
+ step_result: dict[str, Any]
340
+
341
+ if action == "click":
342
+ step_result = await self.click(step["selector"])
343
+ elif action == "fill":
344
+ step_result = await self.fill(step["selector"], step["value"])
345
+ elif action == "submit_form":
346
+ step_result = await self.submit_form(
347
+ step.get("form_selector", "form")
348
+ )
349
+ else:
350
+ step_result = {"error": f"Unknown action: {action!r}"}
351
+
352
+ step_result["action"] = action
353
+ results.append(step_result)
354
+
355
+ # Capture DOM snapshot after each step
356
+ try:
357
+ await self.capture_dom()
358
+ except Exception: # noqa: BLE001
359
+ logger.debug(
360
+ "BrowserExecutorAgent %s: DOM capture skipped after step %r",
361
+ self.agent_id, action,
362
+ )
363
+
364
+ if not step_result.get("success", True) or "error" in step_result:
365
+ passed = False
366
+ error_msg = step_result.get("error")
367
+ break
368
+
369
+ # Capture final screenshot
370
+ try:
371
+ await self.capture_screenshot()
372
+ except Exception: # noqa: BLE001
373
+ logger.debug(
374
+ "BrowserExecutorAgent %s: screenshot skipped at contract end",
375
+ self.agent_id,
376
+ )
377
+
378
+ except Exception as exc: # noqa: BLE001
379
+ passed = False
380
+ error_msg = str(exc)
381
+ logger.warning(
382
+ "BrowserExecutorAgent %s: contract execution error: %s",
383
+ self.agent_id, exc,
384
+ )
385
+
386
+ # Update BDI beliefs
387
+ evidence = 0.0 if passed else 1.0
388
+ target_service = contract.get("target", target_url)
389
+ self._update_belief_from_result(target_service, evidence)
390
+
391
+ # Publish interaction result
392
+ await self._publish(
393
+ Message(
394
+ MessageType.INTERACTION_RESULT,
395
+ sender_id=self.agent_id,
396
+ payload={
397
+ "task_id": contract.get("task_id"),
398
+ "url": target_url,
399
+ "passed": passed,
400
+ "error": error_msg,
401
+ "steps": results,
402
+ "executor_id": self.agent_id,
403
+ },
404
+ correlation_id=contract.get("task_id"),
405
+ )
406
+ )
407
+ logger.debug(
408
+ "BrowserExecutorAgent %s: contract %s → %s",
409
+ self.agent_id, target_url, "PASS" if passed else "FAIL",
410
+ )