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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- 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
|
+
)
|