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,249 @@
|
|
|
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
|
+
"""Test-generation agent.
|
|
7
|
+
|
|
8
|
+
The :class:`TestAgent` uses its neural network to generate prioritised test
|
|
9
|
+
cases for a given set of distributed service endpoints. It listens for
|
|
10
|
+
:attr:`MessageType.TEST_REQUEST` messages and responds with
|
|
11
|
+
:attr:`MessageType.TEST_CASE` messages.
|
|
12
|
+
|
|
13
|
+
It also listens for :attr:`MessageType.FEEDBACK` messages to update its
|
|
14
|
+
neural network with observed rewards, so that over time it learns to generate
|
|
15
|
+
test cases that are more likely to find defects.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import random
|
|
22
|
+
import string
|
|
23
|
+
import uuid
|
|
24
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from mannf.core.agents.base import BaseAgent
|
|
29
|
+
from mannf.core.messaging.bus import MessageBus
|
|
30
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
31
|
+
from mannf.core.neural import NeuralNetwork
|
|
32
|
+
from mannf.core.testing.models import TestCase
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from mannf.llm.base import LLMProvider
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestAgent(BaseAgent):
|
|
41
|
+
"""Generates adaptive test cases for distributed service endpoints.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
agent_id:
|
|
46
|
+
Unique agent identifier.
|
|
47
|
+
bus:
|
|
48
|
+
Shared message bus.
|
|
49
|
+
service_names:
|
|
50
|
+
Names of the services this agent can target.
|
|
51
|
+
feature_dim:
|
|
52
|
+
Dimensionality of the input feature vector fed to the NN.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
agent_id: str,
|
|
58
|
+
bus: MessageBus,
|
|
59
|
+
service_names: List[str],
|
|
60
|
+
feature_dim: int = 8,
|
|
61
|
+
llm_provider: Optional["LLMProvider"] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
network = NeuralNetwork(
|
|
64
|
+
layer_sizes=[feature_dim, 32, 16, len(service_names)],
|
|
65
|
+
hidden_activation="relu",
|
|
66
|
+
learning_rate=0.01,
|
|
67
|
+
)
|
|
68
|
+
super().__init__(agent_id, bus, network)
|
|
69
|
+
self.service_names = service_names
|
|
70
|
+
self.feature_dim = feature_dim
|
|
71
|
+
self._generated_count = 0
|
|
72
|
+
self.llm_provider = llm_provider
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def subscribed_types(self) -> Set[MessageType]:
|
|
76
|
+
return {MessageType.TEST_REQUEST, MessageType.FEEDBACK}
|
|
77
|
+
|
|
78
|
+
# ------------------------------------------------------------------
|
|
79
|
+
# Message handling
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async def _handle_message(self, message: Message) -> None:
|
|
83
|
+
if message.type == MessageType.TEST_REQUEST:
|
|
84
|
+
test_case = self._generate_test_case(context=message.payload)
|
|
85
|
+
await self._publish(
|
|
86
|
+
message.reply(MessageType.TEST_CASE, test_case, sender_id=self.agent_id)
|
|
87
|
+
)
|
|
88
|
+
elif message.type == MessageType.FEEDBACK:
|
|
89
|
+
self._apply_feedback(message.payload)
|
|
90
|
+
|
|
91
|
+
async def _on_idle(self) -> None:
|
|
92
|
+
# Proactively generate a test case every idle cycle
|
|
93
|
+
test_case = self._generate_test_case()
|
|
94
|
+
await self._publish(Message(MessageType.TEST_CASE, self.agent_id, test_case))
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Test generation
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def _generate_test_case(
|
|
101
|
+
self, context: Optional[Dict[str, Any]] = None
|
|
102
|
+
) -> TestCase:
|
|
103
|
+
"""Use the neural network to select a target and build a test case."""
|
|
104
|
+
# Build a context feature vector
|
|
105
|
+
ctx_vec = self._context_to_features(context)
|
|
106
|
+
|
|
107
|
+
# Forward pass: get unnormalised scores for each service
|
|
108
|
+
assert self.network is not None
|
|
109
|
+
scores = self.network.predict(ctx_vec)
|
|
110
|
+
|
|
111
|
+
# Softmax to get a probability distribution over services
|
|
112
|
+
exp_scores = np.exp(scores - scores.max())
|
|
113
|
+
probs = exp_scores / exp_scores.sum()
|
|
114
|
+
|
|
115
|
+
# Sample from the distribution (exploration built into softmax)
|
|
116
|
+
target_idx = int(np.random.choice(len(self.service_names), p=probs))
|
|
117
|
+
target = self.service_names[target_idx]
|
|
118
|
+
priority = float(probs[target_idx])
|
|
119
|
+
|
|
120
|
+
inputs = self._generate_inputs(target, context)
|
|
121
|
+
self._generated_count += 1
|
|
122
|
+
|
|
123
|
+
return TestCase(
|
|
124
|
+
target=target,
|
|
125
|
+
inputs=inputs,
|
|
126
|
+
expected_behavior=f"Service {target!r} should return a successful response",
|
|
127
|
+
priority=priority,
|
|
128
|
+
metadata={"generator": self.agent_id, "generation": self._generated_count},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
async def _generate_test_case_with_llm(
|
|
132
|
+
self, context: Optional[Dict[str, Any]] = None
|
|
133
|
+
) -> TestCase:
|
|
134
|
+
"""Generate a test case, using LLM for inputs 20% of the time when available."""
|
|
135
|
+
# Build a context feature vector
|
|
136
|
+
ctx_vec = self._context_to_features(context)
|
|
137
|
+
|
|
138
|
+
# Forward pass: get unnormalised scores for each service
|
|
139
|
+
assert self.network is not None
|
|
140
|
+
scores = self.network.predict(ctx_vec)
|
|
141
|
+
|
|
142
|
+
# Softmax to get a probability distribution over services
|
|
143
|
+
exp_scores = np.exp(scores - scores.max())
|
|
144
|
+
probs = exp_scores / exp_scores.sum()
|
|
145
|
+
|
|
146
|
+
# Sample from the distribution (exploration built into softmax)
|
|
147
|
+
target_idx = int(np.random.choice(len(self.service_names), p=probs))
|
|
148
|
+
target = self.service_names[target_idx]
|
|
149
|
+
priority = float(probs[target_idx])
|
|
150
|
+
|
|
151
|
+
# Use LLM 20% of the time when available
|
|
152
|
+
use_llm = (
|
|
153
|
+
self.llm_provider is not None
|
|
154
|
+
and self.llm_provider.is_available()
|
|
155
|
+
and random.random() < 0.20
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if use_llm:
|
|
159
|
+
assert self.llm_provider is not None
|
|
160
|
+
try:
|
|
161
|
+
endpoint_info = {
|
|
162
|
+
"method": "POST",
|
|
163
|
+
"path": target,
|
|
164
|
+
"description": f"Service endpoint: {target}",
|
|
165
|
+
"parameters": [],
|
|
166
|
+
"schema": {},
|
|
167
|
+
}
|
|
168
|
+
raw_cases = await self.llm_provider.generate_test_cases(
|
|
169
|
+
endpoint_info, {}, n=1
|
|
170
|
+
)
|
|
171
|
+
if raw_cases and isinstance(raw_cases[0].get("inputs"), dict):
|
|
172
|
+
inputs = raw_cases[0]["inputs"]
|
|
173
|
+
self._generated_count += 1
|
|
174
|
+
return TestCase(
|
|
175
|
+
target=target,
|
|
176
|
+
inputs=inputs,
|
|
177
|
+
expected_behavior=raw_cases[0].get(
|
|
178
|
+
"expected_behavior",
|
|
179
|
+
f"Service {target!r} should return a successful response",
|
|
180
|
+
),
|
|
181
|
+
priority=priority,
|
|
182
|
+
metadata={
|
|
183
|
+
"generator": "llm",
|
|
184
|
+
"generation": self._generated_count,
|
|
185
|
+
"agent_id": self.agent_id,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
logger.warning("TestAgent %s: LLM input generation failed: %s", self.agent_id, exc)
|
|
190
|
+
|
|
191
|
+
inputs = self._generate_inputs(target, context)
|
|
192
|
+
self._generated_count += 1
|
|
193
|
+
|
|
194
|
+
return TestCase(
|
|
195
|
+
target=target,
|
|
196
|
+
inputs=inputs,
|
|
197
|
+
expected_behavior=f"Service {target!r} should return a successful response",
|
|
198
|
+
priority=priority,
|
|
199
|
+
metadata={"generator": self.agent_id, "generation": self._generated_count},
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def _context_to_features(self, context: Optional[Dict[str, Any]]) -> np.ndarray:
|
|
203
|
+
"""Convert an optional context dictionary into a feature vector."""
|
|
204
|
+
vec = np.zeros(self.feature_dim)
|
|
205
|
+
if context is None:
|
|
206
|
+
return vec
|
|
207
|
+
# Encode pass_rate if available
|
|
208
|
+
vec[0] = float(context.get("pass_rate", 0.5))
|
|
209
|
+
# Encode defect_detection_rate
|
|
210
|
+
vec[1] = float(context.get("defect_detection_rate", 0.0))
|
|
211
|
+
# Encode step count (normalised)
|
|
212
|
+
vec[2] = min(float(context.get("step_count", 0)) / 1000.0, 1.0)
|
|
213
|
+
# Hash target hint if present
|
|
214
|
+
target_hint = context.get("target_hint", "")
|
|
215
|
+
if target_hint and target_hint in self.service_names:
|
|
216
|
+
idx = self.service_names.index(target_hint)
|
|
217
|
+
vec[3] = idx / max(len(self.service_names) - 1, 1)
|
|
218
|
+
return vec
|
|
219
|
+
|
|
220
|
+
@staticmethod
|
|
221
|
+
def _generate_inputs(target: str, context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
222
|
+
"""Produce plausible random inputs for a service invocation."""
|
|
223
|
+
return {
|
|
224
|
+
"request_id": str(uuid.uuid4()),
|
|
225
|
+
"user_id": random.randint(1, 10_000),
|
|
226
|
+
"payload_size": random.randint(1, 1024),
|
|
227
|
+
"action": random.choice(["create", "read", "update", "delete", "list"]),
|
|
228
|
+
"auth_token": "".join(random.choices(string.ascii_letters, k=16)),
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# ------------------------------------------------------------------
|
|
232
|
+
# Feedback / online learning
|
|
233
|
+
# ------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def _apply_feedback(self, feedback: Optional[Dict[str, Any]]) -> None:
|
|
236
|
+
"""Update the network weights given a feedback payload."""
|
|
237
|
+
if not feedback:
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
x = np.array(feedback["features"], dtype=float)
|
|
241
|
+
y = np.array(feedback["target"], dtype=float)
|
|
242
|
+
assert self.network is not None
|
|
243
|
+
self.network.train_step(x, y)
|
|
244
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
245
|
+
logger.warning("TestAgent %s: invalid feedback payload – %s", self.agent_id, exc)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def generated_count(self) -> int:
|
|
249
|
+
return self._generated_count
|
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
"""VisualRegressionAgent – detects visual regressions in functional test runs.
|
|
7
|
+
|
|
8
|
+
Listens for :attr:`~mannf.core.messaging.messages.MessageType.SCREENSHOT_CAPTURED`
|
|
9
|
+
events published by :class:`~mannf.core.agents.browser_executor_agent.BrowserExecutorAgent`,
|
|
10
|
+
compares each screenshot against the stored baseline, and publishes:
|
|
11
|
+
|
|
12
|
+
* :attr:`~MessageType.VISUAL_BASELINE_SAVED` when a new baseline is stored
|
|
13
|
+
(first run or ``update_baselines=True``).
|
|
14
|
+
* :attr:`~MessageType.VISUAL_DIFF_DETECTED` when a screenshot exceeds the
|
|
15
|
+
configured diff threshold.
|
|
16
|
+
|
|
17
|
+
Screenshot bytes must be deposited via :meth:`register_screenshot` before
|
|
18
|
+
(or immediately after) the :attr:`~MessageType.SCREENSHOT_CAPTURED` message
|
|
19
|
+
arrives so the agent can retrieve them for comparison.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from mannf.core.agents.bdi_agent import BDIAgent
|
|
30
|
+
from mannf.core.browser.visual_comparer import (
|
|
31
|
+
VisualComparisonResult,
|
|
32
|
+
compare_screenshots,
|
|
33
|
+
load_baseline,
|
|
34
|
+
save_baseline,
|
|
35
|
+
)
|
|
36
|
+
from mannf.core.messaging.bus import MessageBus
|
|
37
|
+
from mannf.core.messaging.messages import Message, MessageType
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class VisualRegressionAgent(BDIAgent):
|
|
43
|
+
"""BDI agent that performs visual regression testing.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
agent_id:
|
|
48
|
+
Unique identifier for this agent.
|
|
49
|
+
bus:
|
|
50
|
+
Shared message bus.
|
|
51
|
+
service_names:
|
|
52
|
+
Services in the system under test (forwarded to :class:`BDIAgent`).
|
|
53
|
+
baseline_dir:
|
|
54
|
+
Directory where baseline PNG files are stored.
|
|
55
|
+
diff_threshold:
|
|
56
|
+
Maximum allowed diff percentage (0–100) before a visual regression is
|
|
57
|
+
flagged.
|
|
58
|
+
update_baselines:
|
|
59
|
+
When ``True``, every screenshot is saved as the new baseline instead
|
|
60
|
+
of being compared.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
agent_id: str,
|
|
66
|
+
bus: MessageBus,
|
|
67
|
+
service_names: list[str],
|
|
68
|
+
baseline_dir: str = ".nat/visual_baselines",
|
|
69
|
+
diff_threshold: float = 0.1,
|
|
70
|
+
update_baselines: bool = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(agent_id=agent_id, bus=bus, service_names=service_names)
|
|
73
|
+
|
|
74
|
+
self._baseline_dir = baseline_dir
|
|
75
|
+
self._diff_threshold = diff_threshold
|
|
76
|
+
self._update_baselines = update_baselines
|
|
77
|
+
|
|
78
|
+
# url -> latest PNG bytes deposited by browser agents / orchestrator
|
|
79
|
+
self._screenshot_registry: dict[str, bytes] = {}
|
|
80
|
+
|
|
81
|
+
# Tracking for the visual report
|
|
82
|
+
self._results: list[dict[str, Any]] = []
|
|
83
|
+
self._baselines_created = 0
|
|
84
|
+
self._tested_urls: set[str] = set()
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# BDIAgent contract
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def subscribed_types(self) -> set[MessageType]:
|
|
92
|
+
return {
|
|
93
|
+
MessageType.SCREENSHOT_CAPTURED,
|
|
94
|
+
MessageType.INTERACTION_RESULT,
|
|
95
|
+
MessageType.BELIEF_UPDATE,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async def _handle_message(self, message: Message) -> None:
|
|
99
|
+
if message.type == MessageType.SCREENSHOT_CAPTURED:
|
|
100
|
+
await self._on_screenshot_captured(message)
|
|
101
|
+
elif message.type == MessageType.INTERACTION_RESULT:
|
|
102
|
+
self._on_interaction_result(message)
|
|
103
|
+
elif message.type == MessageType.BELIEF_UPDATE:
|
|
104
|
+
payload = message.payload or {}
|
|
105
|
+
peer_beliefs = payload.get("beliefs", {})
|
|
106
|
+
if peer_beliefs:
|
|
107
|
+
self._absorb_peer_beliefs(message.sender_id, peer_beliefs)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Screenshot registry
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
def register_screenshot(self, url: str, png_bytes: bytes) -> None:
|
|
114
|
+
"""Deposit screenshot bytes so they can be retrieved on the next
|
|
115
|
+
:attr:`~MessageType.SCREENSHOT_CAPTURED` message for *url*.
|
|
116
|
+
|
|
117
|
+
Parameters
|
|
118
|
+
----------
|
|
119
|
+
url:
|
|
120
|
+
The page URL the screenshot was taken from.
|
|
121
|
+
png_bytes:
|
|
122
|
+
Raw PNG image bytes.
|
|
123
|
+
"""
|
|
124
|
+
self._screenshot_registry[url] = png_bytes
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Message handlers
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def _on_screenshot_captured(self, message: Message) -> None:
|
|
131
|
+
payload = message.payload or {}
|
|
132
|
+
url: str = payload.get("url", "")
|
|
133
|
+
|
|
134
|
+
png_bytes = self._screenshot_registry.get(url)
|
|
135
|
+
if png_bytes is None:
|
|
136
|
+
logger.debug(
|
|
137
|
+
"VisualRegressionAgent %s: no screenshot bytes registered for %s — skipping",
|
|
138
|
+
self.agent_id, url,
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
await self.compare_with_baseline(url, png_bytes)
|
|
143
|
+
|
|
144
|
+
def _on_interaction_result(self, message: Message) -> None:
|
|
145
|
+
payload = message.payload or {}
|
|
146
|
+
url: str = payload.get("url", "")
|
|
147
|
+
if url:
|
|
148
|
+
self._tested_urls.add(url)
|
|
149
|
+
|
|
150
|
+
# ------------------------------------------------------------------
|
|
151
|
+
# Core comparison logic
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async def compare_with_baseline(
|
|
155
|
+
self, url: str, screenshot: bytes
|
|
156
|
+
) -> VisualComparisonResult:
|
|
157
|
+
"""Compare *screenshot* against the stored baseline for *url*.
|
|
158
|
+
|
|
159
|
+
If no baseline exists, or :attr:`update_baselines` is ``True``, the
|
|
160
|
+
screenshot is saved as the new baseline and
|
|
161
|
+
:attr:`~MessageType.VISUAL_BASELINE_SAVED` is published.
|
|
162
|
+
|
|
163
|
+
Otherwise the screenshot is compared pixel-by-pixel; if the diff
|
|
164
|
+
exceeds :attr:`_diff_threshold`,
|
|
165
|
+
:attr:`~MessageType.VISUAL_DIFF_DETECTED` is published.
|
|
166
|
+
|
|
167
|
+
Parameters
|
|
168
|
+
----------
|
|
169
|
+
url:
|
|
170
|
+
The page URL used to derive the baseline filename.
|
|
171
|
+
screenshot:
|
|
172
|
+
Raw PNG bytes of the current screenshot.
|
|
173
|
+
|
|
174
|
+
Returns
|
|
175
|
+
-------
|
|
176
|
+
VisualComparisonResult
|
|
177
|
+
The comparison outcome.
|
|
178
|
+
"""
|
|
179
|
+
filename = self._url_to_filename(url)
|
|
180
|
+
baseline_path = os.path.join(self._baseline_dir, f"{filename}.png")
|
|
181
|
+
|
|
182
|
+
existing_baseline = load_baseline(baseline_path)
|
|
183
|
+
|
|
184
|
+
if existing_baseline is None or self._update_baselines:
|
|
185
|
+
# Save as new baseline
|
|
186
|
+
save_baseline(baseline_path, screenshot)
|
|
187
|
+
self._baselines_created += 1
|
|
188
|
+
logger.debug(
|
|
189
|
+
"VisualRegressionAgent %s: saved baseline for %s → %s",
|
|
190
|
+
self.agent_id, url, baseline_path,
|
|
191
|
+
)
|
|
192
|
+
await self._publish(
|
|
193
|
+
Message(
|
|
194
|
+
MessageType.VISUAL_BASELINE_SAVED,
|
|
195
|
+
sender_id=self.agent_id,
|
|
196
|
+
payload={"url": url, "baseline_path": baseline_path},
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
result = VisualComparisonResult(
|
|
200
|
+
matched=True,
|
|
201
|
+
diff_percentage=0.0,
|
|
202
|
+
diff_image=None,
|
|
203
|
+
baseline_size=(0, 0),
|
|
204
|
+
actual_size=(0, 0),
|
|
205
|
+
threshold=self._diff_threshold,
|
|
206
|
+
)
|
|
207
|
+
self._results.append(
|
|
208
|
+
{
|
|
209
|
+
"url": url,
|
|
210
|
+
"matched": True,
|
|
211
|
+
"diff_percentage": 0.0,
|
|
212
|
+
"baseline_path": baseline_path,
|
|
213
|
+
"baseline_created": True,
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
# Compare against existing baseline
|
|
219
|
+
result = await compare_screenshots(
|
|
220
|
+
existing_baseline, screenshot, self._diff_threshold
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if not result.matched:
|
|
224
|
+
logger.info(
|
|
225
|
+
"VisualRegressionAgent %s: visual diff detected for %s "
|
|
226
|
+
"(diff=%.2f%% > threshold=%.2f%%)",
|
|
227
|
+
self.agent_id, url, result.diff_percentage, self._diff_threshold,
|
|
228
|
+
)
|
|
229
|
+
await self._publish(
|
|
230
|
+
Message(
|
|
231
|
+
MessageType.VISUAL_DIFF_DETECTED,
|
|
232
|
+
sender_id=self.agent_id,
|
|
233
|
+
payload={
|
|
234
|
+
"url": url,
|
|
235
|
+
"diff_percentage": result.diff_percentage,
|
|
236
|
+
"threshold": self._diff_threshold,
|
|
237
|
+
"baseline_path": baseline_path,
|
|
238
|
+
},
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Persist diff image alongside the baseline
|
|
243
|
+
if result.diff_image is not None:
|
|
244
|
+
diff_path = os.path.join(self._baseline_dir, f"{filename}_diff.png")
|
|
245
|
+
save_baseline(diff_path, result.diff_image)
|
|
246
|
+
logger.debug(
|
|
247
|
+
"VisualRegressionAgent %s: diff image saved to %s",
|
|
248
|
+
self.agent_id, diff_path,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
self._results.append(
|
|
252
|
+
{
|
|
253
|
+
"url": url,
|
|
254
|
+
"matched": result.matched,
|
|
255
|
+
"diff_percentage": result.diff_percentage,
|
|
256
|
+
"baseline_path": baseline_path,
|
|
257
|
+
"baseline_created": False,
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
return result
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# Reporting
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def get_visual_report(self) -> dict[str, Any]:
|
|
267
|
+
"""Return a summary of all visual comparisons performed.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
dict
|
|
272
|
+
``{total_comparisons, passed, failed, baselines_created,
|
|
273
|
+
results: [{url, matched, diff_percentage, baseline_path}...]}``
|
|
274
|
+
"""
|
|
275
|
+
passed = sum(1 for r in self._results if r["matched"])
|
|
276
|
+
failed = len(self._results) - passed
|
|
277
|
+
return {
|
|
278
|
+
"total_comparisons": len(self._results),
|
|
279
|
+
"passed": passed,
|
|
280
|
+
"failed": failed,
|
|
281
|
+
"baselines_created": self._baselines_created,
|
|
282
|
+
"results": [
|
|
283
|
+
{
|
|
284
|
+
"url": r["url"],
|
|
285
|
+
"matched": r["matched"],
|
|
286
|
+
"diff_percentage": r["diff_percentage"],
|
|
287
|
+
"baseline_path": r["baseline_path"],
|
|
288
|
+
}
|
|
289
|
+
for r in self._results
|
|
290
|
+
],
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# ------------------------------------------------------------------
|
|
294
|
+
# Helpers
|
|
295
|
+
# ------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
@staticmethod
|
|
298
|
+
def _url_to_filename(url: str) -> str:
|
|
299
|
+
"""Derive a safe filesystem filename from *url* using a SHA-256 prefix.
|
|
300
|
+
|
|
301
|
+
Parameters
|
|
302
|
+
----------
|
|
303
|
+
url:
|
|
304
|
+
Any URL string.
|
|
305
|
+
|
|
306
|
+
Returns
|
|
307
|
+
-------
|
|
308
|
+
str
|
|
309
|
+
A 16-character hexadecimal string safe for use as a filename.
|
|
310
|
+
"""
|
|
311
|
+
return hashlib.sha256(url.encode()).hexdigest()[:16]
|