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,1086 @@
|
|
|
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
|
+
"""AutonomousTestLoopAgent — Phase 5 end-to-end autonomous functional testing.
|
|
7
|
+
|
|
8
|
+
Closes the loop: **crawl → generate scenarios → execute → analyze → feed back
|
|
9
|
+
into beliefs → re-generate → repeat** — all triggered by a single ``run_loop()``
|
|
10
|
+
call.
|
|
11
|
+
|
|
12
|
+
The agent **composes** (not inherits) all Phase 1–4 components:
|
|
13
|
+
|
|
14
|
+
* :class:`~mannf.core.agents.web_crawler_agent.WebCrawlerAgent` — BFS crawl
|
|
15
|
+
* :class:`~mannf.core.browser.scenario_generator.ScenarioConfigGenerator` — scenario generation
|
|
16
|
+
* :class:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator` — browser execution
|
|
17
|
+
* :class:`~mannf.core.prioritization.adaptive_controller.AdaptiveController` — belief feedback
|
|
18
|
+
|
|
19
|
+
Convergence criteria (first one met stops the loop):
|
|
20
|
+
* ``max_iterations`` reached
|
|
21
|
+
* ``max_duration_s`` time budget exhausted
|
|
22
|
+
* No new failures in last ``stability_threshold`` consecutive iterations
|
|
23
|
+
* (future) coverage target met
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from typing import Any, Dict, List, Optional, Set
|
|
34
|
+
from urllib.parse import urlparse
|
|
35
|
+
|
|
36
|
+
from mannf.core.agents.autonomous_loop_models import AutonomousRunReport, LoopIterationResult
|
|
37
|
+
from mannf.core.messaging.bus import MessageBus
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _page_key(url: str, base_url: str) -> str:
|
|
43
|
+
"""Normalize a URL to a page-relative path for belief state keys."""
|
|
44
|
+
try:
|
|
45
|
+
base = urlparse(base_url)
|
|
46
|
+
parsed = urlparse(url)
|
|
47
|
+
if parsed.netloc == base.netloc or not parsed.netloc:
|
|
48
|
+
return parsed.path or "/"
|
|
49
|
+
return url
|
|
50
|
+
except Exception: # noqa: BLE001
|
|
51
|
+
return url
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AutonomousTestLoopAgent:
|
|
55
|
+
"""Autonomous end-to-end functional test loop agent.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
target_url:
|
|
60
|
+
Root URL of the web application under test.
|
|
61
|
+
max_iterations:
|
|
62
|
+
Hard cap on loop iterations.
|
|
63
|
+
max_duration_s:
|
|
64
|
+
Wall-clock time budget in seconds.
|
|
65
|
+
stability_threshold:
|
|
66
|
+
Stop after this many consecutive iterations with no new failures.
|
|
67
|
+
strategy:
|
|
68
|
+
Scenario selection strategy: ``"all"``, ``"high_risk"``,
|
|
69
|
+
``"new_changed"``, or ``"belief_targeted"``.
|
|
70
|
+
risk_threshold:
|
|
71
|
+
Minimum ``risk_score`` for ``"high_risk"`` strategy.
|
|
72
|
+
max_pages:
|
|
73
|
+
Maximum pages to crawl per iteration.
|
|
74
|
+
max_depth:
|
|
75
|
+
Maximum BFS depth for the crawler.
|
|
76
|
+
num_browser_agents:
|
|
77
|
+
Number of parallel browser executor agents.
|
|
78
|
+
headless:
|
|
79
|
+
Run browsers in headless mode.
|
|
80
|
+
llm_provider:
|
|
81
|
+
Optional LLM provider for AI-enhanced scenario generation.
|
|
82
|
+
recrawl_every:
|
|
83
|
+
Re-crawl (expensive) every N iterations; between re-crawls only
|
|
84
|
+
re-generate scenarios from the cached discovery model.
|
|
85
|
+
enable_webhook:
|
|
86
|
+
Whether to POST progress updates to ``webhook_url``.
|
|
87
|
+
webhook_url:
|
|
88
|
+
URL to receive progress webhook POSTs.
|
|
89
|
+
enable_security_scenarios:
|
|
90
|
+
When ``True``, include browser-level security test scenarios
|
|
91
|
+
(XSS, SQL injection, path traversal, SSRF, etc.) alongside
|
|
92
|
+
the standard functional scenarios.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
target_url: str,
|
|
98
|
+
*,
|
|
99
|
+
max_iterations: int = 5,
|
|
100
|
+
max_duration_s: float = 600.0,
|
|
101
|
+
stability_threshold: int = 2,
|
|
102
|
+
strategy: str = "all",
|
|
103
|
+
risk_threshold: float = 0.6,
|
|
104
|
+
max_pages: int = 50,
|
|
105
|
+
max_depth: int = 5,
|
|
106
|
+
num_browser_agents: int = 2,
|
|
107
|
+
headless: bool = True,
|
|
108
|
+
llm_provider: Any | None = None,
|
|
109
|
+
recrawl_every: int = 3,
|
|
110
|
+
enable_webhook: bool = False,
|
|
111
|
+
webhook_url: str = "",
|
|
112
|
+
enable_security_scenarios: bool = False,
|
|
113
|
+
seeded_scenarios: list[dict[str, Any]] | None = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
self.target_url = target_url
|
|
116
|
+
self.max_iterations = max_iterations
|
|
117
|
+
self.max_duration_s = max_duration_s
|
|
118
|
+
self.stability_threshold = stability_threshold
|
|
119
|
+
self.strategy = strategy
|
|
120
|
+
self.risk_threshold = risk_threshold
|
|
121
|
+
self.max_pages = max_pages
|
|
122
|
+
self.max_depth = max_depth
|
|
123
|
+
self.num_browser_agents = num_browser_agents
|
|
124
|
+
self.headless = headless
|
|
125
|
+
self.llm_provider = llm_provider
|
|
126
|
+
self.recrawl_every = recrawl_every
|
|
127
|
+
self.enable_webhook = enable_webhook
|
|
128
|
+
self.webhook_url = webhook_url
|
|
129
|
+
self.enable_security_scenarios = enable_security_scenarios
|
|
130
|
+
# Pre-seeded scenarios injected before the first crawl (Phase 7.2 zero cold-start)
|
|
131
|
+
self.seeded_scenarios: list[dict[str, Any]] = list(seeded_scenarios or [])
|
|
132
|
+
|
|
133
|
+
# Internal state
|
|
134
|
+
self._run_id = str(uuid.uuid4())
|
|
135
|
+
self._stop_requested = False
|
|
136
|
+
self._pause_requested = False
|
|
137
|
+
self._discovery_model: Any | None = None # cached between re-crawl intervals
|
|
138
|
+
self._belief_state: Optional[Any] = None # lazy-created on first crawl
|
|
139
|
+
self._seen_failure_keys: Set[str] = set() # for dedup across iterations
|
|
140
|
+
self._consecutive_stable = 0
|
|
141
|
+
self._all_unique_failures: List[Dict[str, Any]] = []
|
|
142
|
+
self._covered_pages: Set[str] = set()
|
|
143
|
+
self._last_heartbeat_at: Optional[datetime] = None
|
|
144
|
+
self._paused_iteration: int = 0 # iteration at which run was paused
|
|
145
|
+
# Browser-level security findings accumulated across iterations
|
|
146
|
+
self._browser_security_findings: List[Dict[str, Any]] = []
|
|
147
|
+
# Belief evolution snapshots — one per iteration (Phase 6.5)
|
|
148
|
+
self._belief_snapshots: List[Dict[str, Any]] = []
|
|
149
|
+
# LLM root cause suggestions (Phase 6.5)
|
|
150
|
+
self._root_cause_suggestions: List[Dict[str, Any]] = []
|
|
151
|
+
# Coverage gaps — pages blocked by auth/CAPTCHA/timeout/unreachable (Phase 7.4)
|
|
152
|
+
self._coverage_gaps: List[Dict[str, Any]] = []
|
|
153
|
+
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
# Public API
|
|
156
|
+
# ------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def run_id(self) -> str:
|
|
160
|
+
return self._run_id
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def last_heartbeat_at(self) -> Optional[datetime]:
|
|
164
|
+
"""The UTC datetime of the last iteration heartbeat, or ``None``."""
|
|
165
|
+
return self._last_heartbeat_at
|
|
166
|
+
|
|
167
|
+
def request_stop(self) -> None:
|
|
168
|
+
"""Signal the loop to stop gracefully after the current phase."""
|
|
169
|
+
self._stop_requested = True
|
|
170
|
+
|
|
171
|
+
def request_pause(self) -> None:
|
|
172
|
+
"""Signal the loop to pause gracefully after the current iteration.
|
|
173
|
+
|
|
174
|
+
The run status will be set to ``"paused"`` and the agent's iteration
|
|
175
|
+
state serialised so it can be resumed later via :meth:`resume`.
|
|
176
|
+
"""
|
|
177
|
+
self._pause_requested = True
|
|
178
|
+
|
|
179
|
+
def get_pause_state(self) -> Dict[str, Any]:
|
|
180
|
+
"""Return a JSON-serialisable snapshot of the current agent state.
|
|
181
|
+
|
|
182
|
+
Used to persist pause state to the database so the run can be
|
|
183
|
+
reconstructed with :meth:`resume`.
|
|
184
|
+
"""
|
|
185
|
+
belief_data: Optional[Dict[str, Any]] = None
|
|
186
|
+
if self._belief_state is not None:
|
|
187
|
+
belief_data = {
|
|
188
|
+
"services": self._belief_state.services,
|
|
189
|
+
"initial_belief": self._belief_state.initial_belief,
|
|
190
|
+
"fault_likelihood": dict(self._belief_state.fault_likelihood),
|
|
191
|
+
"confidence": dict(self._belief_state.confidence),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
discovery_data: Optional[Dict[str, Any]] = None
|
|
195
|
+
if self._discovery_model is not None:
|
|
196
|
+
try:
|
|
197
|
+
discovery_data = self._discovery_model.to_dict()
|
|
198
|
+
except Exception: # noqa: BLE001
|
|
199
|
+
discovery_data = None
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
"run_id": self._run_id,
|
|
203
|
+
"target_url": self.target_url,
|
|
204
|
+
"strategy": self.strategy,
|
|
205
|
+
"max_iterations": self.max_iterations,
|
|
206
|
+
"max_duration_s": self.max_duration_s,
|
|
207
|
+
"stability_threshold": self.stability_threshold,
|
|
208
|
+
"risk_threshold": self.risk_threshold,
|
|
209
|
+
"max_pages": self.max_pages,
|
|
210
|
+
"max_depth": self.max_depth,
|
|
211
|
+
"recrawl_every": self.recrawl_every,
|
|
212
|
+
"paused_iteration": self._paused_iteration,
|
|
213
|
+
"consecutive_stable": self._consecutive_stable,
|
|
214
|
+
"seen_failure_keys": list(self._seen_failure_keys),
|
|
215
|
+
"all_unique_failures": self._all_unique_failures,
|
|
216
|
+
"covered_pages": list(self._covered_pages),
|
|
217
|
+
"belief_state": belief_data,
|
|
218
|
+
"discovery_model": discovery_data,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def resume(cls, pause_state: Dict[str, Any]) -> "AutonomousTestLoopAgent":
|
|
223
|
+
"""Reconstruct an agent from a previously saved pause state.
|
|
224
|
+
|
|
225
|
+
Parameters
|
|
226
|
+
----------
|
|
227
|
+
pause_state:
|
|
228
|
+
A dict previously returned by :meth:`get_pause_state`.
|
|
229
|
+
|
|
230
|
+
Returns
|
|
231
|
+
-------
|
|
232
|
+
AutonomousTestLoopAgent
|
|
233
|
+
A new agent instance with state restored, ready to continue
|
|
234
|
+
from the paused iteration.
|
|
235
|
+
"""
|
|
236
|
+
agent = cls(
|
|
237
|
+
target_url=pause_state["target_url"],
|
|
238
|
+
max_iterations=pause_state.get("max_iterations", 5),
|
|
239
|
+
max_duration_s=pause_state.get("max_duration_s", 600.0),
|
|
240
|
+
stability_threshold=pause_state.get("stability_threshold", 2),
|
|
241
|
+
strategy=pause_state.get("strategy", "all"),
|
|
242
|
+
risk_threshold=pause_state.get("risk_threshold", 0.6),
|
|
243
|
+
max_pages=pause_state.get("max_pages", 50),
|
|
244
|
+
max_depth=pause_state.get("max_depth", 5),
|
|
245
|
+
recrawl_every=pause_state.get("recrawl_every", 3),
|
|
246
|
+
)
|
|
247
|
+
# Restore run identity and iteration state
|
|
248
|
+
agent._run_id = pause_state.get("run_id", agent._run_id)
|
|
249
|
+
agent._consecutive_stable = pause_state.get("consecutive_stable", 0)
|
|
250
|
+
agent._seen_failure_keys = set(pause_state.get("seen_failure_keys", []))
|
|
251
|
+
agent._all_unique_failures = pause_state.get("all_unique_failures", [])
|
|
252
|
+
agent._covered_pages = set(pause_state.get("covered_pages", []))
|
|
253
|
+
agent._paused_iteration = pause_state.get("paused_iteration", 0)
|
|
254
|
+
|
|
255
|
+
# Restore belief state
|
|
256
|
+
belief_data = pause_state.get("belief_state")
|
|
257
|
+
if belief_data:
|
|
258
|
+
try:
|
|
259
|
+
from mannf.core.agents.belief_state import BeliefState # noqa: PLC0415
|
|
260
|
+
|
|
261
|
+
bs = BeliefState(
|
|
262
|
+
services=belief_data.get("services", []),
|
|
263
|
+
initial_belief=belief_data.get("initial_belief", 0.5),
|
|
264
|
+
)
|
|
265
|
+
bs.fault_likelihood.update(belief_data.get("fault_likelihood", {}))
|
|
266
|
+
bs.confidence.update(belief_data.get("confidence", {}))
|
|
267
|
+
agent._belief_state = bs
|
|
268
|
+
except Exception: # noqa: BLE001
|
|
269
|
+
logger.warning(
|
|
270
|
+
"AutonomousTestLoopAgent [%s]: could not restore belief state",
|
|
271
|
+
agent._run_id[:8],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# Restore discovery model
|
|
275
|
+
discovery_data = pause_state.get("discovery_model")
|
|
276
|
+
if discovery_data:
|
|
277
|
+
try:
|
|
278
|
+
from mannf.core.browser.discovery_model import DiscoveryModel # noqa: PLC0415
|
|
279
|
+
import dataclasses # noqa: PLC0415
|
|
280
|
+
|
|
281
|
+
def _from_dict(cls_: Any, data: Any) -> Any:
|
|
282
|
+
if not dataclasses.is_dataclass(cls_) or not isinstance(data, dict):
|
|
283
|
+
return data
|
|
284
|
+
fields = {f.name: f for f in dataclasses.fields(cls_)}
|
|
285
|
+
kwargs = {}
|
|
286
|
+
for name, fld in fields.items():
|
|
287
|
+
if name not in data:
|
|
288
|
+
continue
|
|
289
|
+
val = data[name]
|
|
290
|
+
origin = getattr(fld.type, "__origin__", None)
|
|
291
|
+
if origin is list and val is not None:
|
|
292
|
+
inner = fld.type.__args__[0] if fld.type.__args__ else None
|
|
293
|
+
if inner and dataclasses.is_dataclass(inner):
|
|
294
|
+
val = [_from_dict(inner, v) for v in val]
|
|
295
|
+
kwargs[name] = val
|
|
296
|
+
return cls_(**kwargs)
|
|
297
|
+
|
|
298
|
+
agent._discovery_model = _from_dict(DiscoveryModel, discovery_data)
|
|
299
|
+
except Exception: # noqa: BLE001
|
|
300
|
+
logger.warning(
|
|
301
|
+
"AutonomousTestLoopAgent [%s]: could not restore discovery model",
|
|
302
|
+
agent._run_id[:8],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
return agent
|
|
306
|
+
|
|
307
|
+
async def run_loop(self) -> AutonomousRunReport:
|
|
308
|
+
"""Execute the full autonomous test loop.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
AutonomousRunReport
|
|
313
|
+
Structured results for the complete run.
|
|
314
|
+
"""
|
|
315
|
+
started_at = datetime.now(timezone.utc).isoformat()
|
|
316
|
+
loop_start = time.monotonic()
|
|
317
|
+
|
|
318
|
+
report = AutonomousRunReport(
|
|
319
|
+
run_id=self._run_id,
|
|
320
|
+
target_url=self.target_url,
|
|
321
|
+
strategy=self.strategy,
|
|
322
|
+
total_iterations=0,
|
|
323
|
+
total_scenarios_executed=0,
|
|
324
|
+
total_passed=0,
|
|
325
|
+
total_failed=0,
|
|
326
|
+
total_flaky=0,
|
|
327
|
+
started_at=started_at,
|
|
328
|
+
status="running",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
stop_reason = "max_iterations"
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
for iteration in range(1, self.max_iterations + 1):
|
|
335
|
+
if self._stop_requested:
|
|
336
|
+
stop_reason = "stopped"
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
if self._pause_requested:
|
|
340
|
+
stop_reason = "paused"
|
|
341
|
+
self._paused_iteration = iteration
|
|
342
|
+
break
|
|
343
|
+
|
|
344
|
+
elapsed = time.monotonic() - loop_start
|
|
345
|
+
if elapsed >= self.max_duration_s:
|
|
346
|
+
stop_reason = "max_duration"
|
|
347
|
+
break
|
|
348
|
+
|
|
349
|
+
logger.info(
|
|
350
|
+
"AutonomousTestLoopAgent [%s]: starting iteration %d/%d",
|
|
351
|
+
self._run_id[:8],
|
|
352
|
+
iteration,
|
|
353
|
+
self.max_iterations,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
iter_result = await self._run_iteration(
|
|
357
|
+
iteration=iteration,
|
|
358
|
+
elapsed_budget=self.max_duration_s - elapsed,
|
|
359
|
+
)
|
|
360
|
+
report.iterations.append(iter_result)
|
|
361
|
+
report.total_iterations = iteration
|
|
362
|
+
report.total_scenarios_executed += iter_result.scenarios_executed
|
|
363
|
+
report.total_passed += iter_result.passed
|
|
364
|
+
report.total_failed += iter_result.failed
|
|
365
|
+
report.total_flaky += iter_result.flaky
|
|
366
|
+
|
|
367
|
+
# Update heartbeat timestamp after each completed iteration
|
|
368
|
+
self._last_heartbeat_at = datetime.now(timezone.utc)
|
|
369
|
+
|
|
370
|
+
# Broadcast iteration progress via WebSocket
|
|
371
|
+
await self._broadcast_iteration(report, iter_result)
|
|
372
|
+
|
|
373
|
+
# Convergence check — stability
|
|
374
|
+
if iter_result.new_failures == 0:
|
|
375
|
+
self._consecutive_stable += 1
|
|
376
|
+
else:
|
|
377
|
+
self._consecutive_stable = 0
|
|
378
|
+
|
|
379
|
+
if self._consecutive_stable >= self.stability_threshold:
|
|
380
|
+
stop_reason = "stable"
|
|
381
|
+
break
|
|
382
|
+
|
|
383
|
+
# Check time budget again after the iteration
|
|
384
|
+
if time.monotonic() - loop_start >= self.max_duration_s:
|
|
385
|
+
stop_reason = "max_duration"
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
# Finalize report
|
|
389
|
+
completed_at = datetime.now(timezone.utc).isoformat()
|
|
390
|
+
duration_s = round(time.monotonic() - loop_start, 2)
|
|
391
|
+
|
|
392
|
+
if stop_reason == "paused":
|
|
393
|
+
report.status = "paused"
|
|
394
|
+
else:
|
|
395
|
+
report.status = "complete"
|
|
396
|
+
report.stop_reason = stop_reason
|
|
397
|
+
report.completed_at = completed_at
|
|
398
|
+
report.duration_s = duration_s
|
|
399
|
+
report.unique_failures = list(self._all_unique_failures)
|
|
400
|
+
# Attach belief snapshots and root cause suggestions (Phase 6.5)
|
|
401
|
+
report.belief_snapshots = list(self._belief_snapshots)
|
|
402
|
+
report.root_cause_suggestions = list(self._root_cause_suggestions)
|
|
403
|
+
# Attach coverage gaps (Phase 7.4)
|
|
404
|
+
report.coverage_gaps = list(self._coverage_gaps)
|
|
405
|
+
# Build security coverage metrics
|
|
406
|
+
sec_findings = self._browser_security_findings
|
|
407
|
+
owasp_categories: set[str] = set()
|
|
408
|
+
for f in sec_findings:
|
|
409
|
+
cat = f.get("owasp_category", "")
|
|
410
|
+
if cat:
|
|
411
|
+
owasp_categories.add(cat)
|
|
412
|
+
report.coverage_summary = {
|
|
413
|
+
"pages_tested": len(self._covered_pages),
|
|
414
|
+
"total_pages_crawled": (
|
|
415
|
+
len(self._discovery_model.pages) if self._discovery_model else 0
|
|
416
|
+
),
|
|
417
|
+
"security_fields_tested": len({
|
|
418
|
+
f.get("endpoint", "") for f in sec_findings
|
|
419
|
+
}),
|
|
420
|
+
"security_payloads_attempted": sum(
|
|
421
|
+
1 for s in report.iterations
|
|
422
|
+
for _ in range(getattr(s, "scenarios_executed", 0))
|
|
423
|
+
) if self.enable_security_scenarios else 0,
|
|
424
|
+
"security_exploits_found": len(sec_findings),
|
|
425
|
+
"owasp_coverage": sorted(owasp_categories),
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
except Exception as exc: # noqa: BLE001
|
|
429
|
+
logger.exception("AutonomousTestLoopAgent [%s]: fatal error", self._run_id[:8])
|
|
430
|
+
report.status = "failed"
|
|
431
|
+
report.error = str(exc)
|
|
432
|
+
report.stop_reason = "error"
|
|
433
|
+
report.completed_at = datetime.now(timezone.utc).isoformat()
|
|
434
|
+
report.duration_s = round(time.monotonic() - loop_start, 2)
|
|
435
|
+
|
|
436
|
+
await self._broadcast_complete(report)
|
|
437
|
+
logger.info(
|
|
438
|
+
"AutonomousTestLoopAgent [%s]: finished — %s after %d iterations",
|
|
439
|
+
self._run_id[:8],
|
|
440
|
+
report.stop_reason,
|
|
441
|
+
report.total_iterations,
|
|
442
|
+
)
|
|
443
|
+
return report
|
|
444
|
+
|
|
445
|
+
# ------------------------------------------------------------------
|
|
446
|
+
# Per-iteration pipeline
|
|
447
|
+
# ------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
async def _run_iteration(
|
|
450
|
+
self,
|
|
451
|
+
iteration: int,
|
|
452
|
+
elapsed_budget: float,
|
|
453
|
+
) -> LoopIterationResult:
|
|
454
|
+
"""Run one full crawl → generate → filter → execute → analyze → feedback cycle."""
|
|
455
|
+
iter_start = time.monotonic()
|
|
456
|
+
|
|
457
|
+
# ------------------------------------------------------------------
|
|
458
|
+
# Phase 1: Crawl (every recrawl_every iterations, or on first iteration)
|
|
459
|
+
# ------------------------------------------------------------------
|
|
460
|
+
should_recrawl = (iteration == 1) or ((iteration - 1) % self.recrawl_every == 0)
|
|
461
|
+
if should_recrawl:
|
|
462
|
+
self._discovery_model = await self._crawl()
|
|
463
|
+
|
|
464
|
+
# Bail out early if crawl produced nothing (graceful degradation)
|
|
465
|
+
if self._discovery_model is None or not self._discovery_model.pages:
|
|
466
|
+
logger.warning(
|
|
467
|
+
"AutonomousTestLoopAgent [%s]: iteration %d — no pages discovered, skipping",
|
|
468
|
+
self._run_id[:8],
|
|
469
|
+
iteration,
|
|
470
|
+
)
|
|
471
|
+
return LoopIterationResult(
|
|
472
|
+
iteration=iteration,
|
|
473
|
+
scenarios_generated=0,
|
|
474
|
+
scenarios_executed=0,
|
|
475
|
+
passed=0,
|
|
476
|
+
failed=0,
|
|
477
|
+
flaky=0,
|
|
478
|
+
new_failures=0,
|
|
479
|
+
coverage_pages=0,
|
|
480
|
+
duration_s=round(time.monotonic() - iter_start, 2),
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
# ------------------------------------------------------------------
|
|
484
|
+
# Phase 2: Generate scenarios
|
|
485
|
+
# ------------------------------------------------------------------
|
|
486
|
+
scenarios = await self._generate_scenarios()
|
|
487
|
+
|
|
488
|
+
# Merge pre-seeded scenarios on iteration 1 (zero cold-start)
|
|
489
|
+
if iteration == 1 and self.seeded_scenarios:
|
|
490
|
+
logger.info(
|
|
491
|
+
"AutonomousTestLoopAgent [%s]: merging %d pre-seeded scenarios into iteration 1",
|
|
492
|
+
self._run_id[:8],
|
|
493
|
+
len(self.seeded_scenarios),
|
|
494
|
+
)
|
|
495
|
+
# Prepend seeds so strategy filter can prioritise them
|
|
496
|
+
scenarios = list(self.seeded_scenarios) + scenarios
|
|
497
|
+
|
|
498
|
+
# ------------------------------------------------------------------
|
|
499
|
+
# Phase 3: Filter scenarios
|
|
500
|
+
# ------------------------------------------------------------------
|
|
501
|
+
previously_tested: Set[str] = set()
|
|
502
|
+
for prev_iter in self._get_previous_iteration_pages():
|
|
503
|
+
previously_tested.add(prev_iter)
|
|
504
|
+
|
|
505
|
+
selected = self._filter_scenarios(scenarios, previously_tested)
|
|
506
|
+
scenarios_generated = len(scenarios)
|
|
507
|
+
|
|
508
|
+
if not selected:
|
|
509
|
+
logger.info(
|
|
510
|
+
"AutonomousTestLoopAgent [%s]: iteration %d — no scenarios selected by strategy=%s",
|
|
511
|
+
self._run_id[:8],
|
|
512
|
+
iteration,
|
|
513
|
+
self.strategy,
|
|
514
|
+
)
|
|
515
|
+
return LoopIterationResult(
|
|
516
|
+
iteration=iteration,
|
|
517
|
+
scenarios_generated=scenarios_generated,
|
|
518
|
+
scenarios_executed=0,
|
|
519
|
+
passed=0,
|
|
520
|
+
failed=0,
|
|
521
|
+
flaky=0,
|
|
522
|
+
new_failures=0,
|
|
523
|
+
coverage_pages=0,
|
|
524
|
+
duration_s=round(time.monotonic() - iter_start, 2),
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# ------------------------------------------------------------------
|
|
528
|
+
# Phase 4: Execute
|
|
529
|
+
# ------------------------------------------------------------------
|
|
530
|
+
orch_report = await self._execute_scenarios(selected)
|
|
531
|
+
|
|
532
|
+
# ------------------------------------------------------------------
|
|
533
|
+
# Phase 5: Analyze
|
|
534
|
+
# ------------------------------------------------------------------
|
|
535
|
+
passed, failed, flaky, task_details = self._analyze_report(orch_report)
|
|
536
|
+
|
|
537
|
+
# Track covered pages
|
|
538
|
+
for task in selected:
|
|
539
|
+
page_key = _page_key(task.get("url", ""), self.target_url)
|
|
540
|
+
self._covered_pages.add(page_key)
|
|
541
|
+
|
|
542
|
+
# Track coverage gaps from blocked/unreachable responses
|
|
543
|
+
self._collect_coverage_gaps(orch_report, iteration)
|
|
544
|
+
|
|
545
|
+
# ------------------------------------------------------------------
|
|
546
|
+
# Phase 5b: Reflection analysis for security scenarios
|
|
547
|
+
# ------------------------------------------------------------------
|
|
548
|
+
if self.enable_security_scenarios:
|
|
549
|
+
security_selected = [
|
|
550
|
+
s for s in selected
|
|
551
|
+
if s.get("metadata", {}).get("scenario_type") == "security"
|
|
552
|
+
]
|
|
553
|
+
if security_selected:
|
|
554
|
+
from mannf.core.browser.reflection_analyzer import analyze_security_results # noqa: PLC0415
|
|
555
|
+
result_items = orch_report.get("items", []) if isinstance(orch_report, dict) else []
|
|
556
|
+
new_sec_findings = analyze_security_results(security_selected, result_items)
|
|
557
|
+
if new_sec_findings:
|
|
558
|
+
logger.info(
|
|
559
|
+
"AutonomousTestLoopAgent [%s]: iteration %d — %d browser security findings",
|
|
560
|
+
self._run_id[:8],
|
|
561
|
+
iteration,
|
|
562
|
+
len(new_sec_findings),
|
|
563
|
+
)
|
|
564
|
+
self._browser_security_findings.extend(new_sec_findings)
|
|
565
|
+
|
|
566
|
+
# ------------------------------------------------------------------
|
|
567
|
+
# Phase 6: Feedback — update beliefs
|
|
568
|
+
# ------------------------------------------------------------------
|
|
569
|
+
prev_unique_count = len(self._all_unique_failures)
|
|
570
|
+
new_failures = self._update_beliefs(task_details)
|
|
571
|
+
|
|
572
|
+
# ------------------------------------------------------------------
|
|
573
|
+
# Phase 6b: Persist belief snapshot for this iteration (Phase 6.5)
|
|
574
|
+
# ------------------------------------------------------------------
|
|
575
|
+
self._persist_belief_snapshot(iteration)
|
|
576
|
+
|
|
577
|
+
# ------------------------------------------------------------------
|
|
578
|
+
# Phase 6c: LLM root cause analysis for new failures (Phase 6.5)
|
|
579
|
+
# ------------------------------------------------------------------
|
|
580
|
+
if new_failures > 0:
|
|
581
|
+
newly_added = self._all_unique_failures[prev_unique_count:]
|
|
582
|
+
self._schedule_root_cause_analysis(newly_added)
|
|
583
|
+
|
|
584
|
+
# ------------------------------------------------------------------
|
|
585
|
+
# Phase 7: Fire-and-forget exporter hook (non-blocking)
|
|
586
|
+
# ------------------------------------------------------------------
|
|
587
|
+
if new_failures > 0:
|
|
588
|
+
newly_added = self._all_unique_failures[prev_unique_count:]
|
|
589
|
+
self._schedule_exporter_hook(newly_added)
|
|
590
|
+
|
|
591
|
+
iter_duration = round(time.monotonic() - iter_start, 2)
|
|
592
|
+
|
|
593
|
+
logger.info(
|
|
594
|
+
"AutonomousTestLoopAgent [%s]: iteration %d done — "
|
|
595
|
+
"generated=%d executed=%d passed=%d failed=%d flaky=%d new_failures=%d (%.1fs)",
|
|
596
|
+
self._run_id[:8],
|
|
597
|
+
iteration,
|
|
598
|
+
scenarios_generated,
|
|
599
|
+
len(selected),
|
|
600
|
+
passed,
|
|
601
|
+
failed,
|
|
602
|
+
flaky,
|
|
603
|
+
new_failures,
|
|
604
|
+
iter_duration,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
return LoopIterationResult(
|
|
608
|
+
iteration=iteration,
|
|
609
|
+
scenarios_generated=scenarios_generated,
|
|
610
|
+
scenarios_executed=len(selected),
|
|
611
|
+
passed=passed,
|
|
612
|
+
failed=failed,
|
|
613
|
+
flaky=flaky,
|
|
614
|
+
new_failures=new_failures,
|
|
615
|
+
coverage_pages=len(self._covered_pages),
|
|
616
|
+
duration_s=iter_duration,
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
# ------------------------------------------------------------------
|
|
620
|
+
# Phase helpers
|
|
621
|
+
# ------------------------------------------------------------------
|
|
622
|
+
|
|
623
|
+
async def _crawl(self) -> Any | None:
|
|
624
|
+
"""Run the WebCrawlerAgent and return the discovery model."""
|
|
625
|
+
from mannf.core.agents.web_crawler_agent import WebCrawlerAgent # noqa: PLC0415
|
|
626
|
+
|
|
627
|
+
bus = MessageBus()
|
|
628
|
+
crawler = WebCrawlerAgent(
|
|
629
|
+
agent_id=f"auto-crawler-{self._run_id[:8]}",
|
|
630
|
+
bus=bus,
|
|
631
|
+
service_names=[self.target_url],
|
|
632
|
+
base_url=self.target_url,
|
|
633
|
+
headless=self.headless,
|
|
634
|
+
max_pages=self.max_pages,
|
|
635
|
+
max_depth=self.max_depth,
|
|
636
|
+
)
|
|
637
|
+
try:
|
|
638
|
+
await crawler.start_browser()
|
|
639
|
+
try:
|
|
640
|
+
await crawler._run_crawl()
|
|
641
|
+
finally:
|
|
642
|
+
await crawler.stop_browser()
|
|
643
|
+
return crawler._discovery_model
|
|
644
|
+
except Exception as exc: # noqa: BLE001
|
|
645
|
+
logger.warning(
|
|
646
|
+
"AutonomousTestLoopAgent [%s]: crawl failed — %s",
|
|
647
|
+
self._run_id[:8],
|
|
648
|
+
exc,
|
|
649
|
+
)
|
|
650
|
+
return None
|
|
651
|
+
|
|
652
|
+
async def _generate_scenarios(self) -> list[dict[str, Any]]:
|
|
653
|
+
"""Generate test scenarios from the current discovery model."""
|
|
654
|
+
from mannf.core.browser.scenario_generator import ScenarioConfigGenerator # noqa: PLC0415
|
|
655
|
+
|
|
656
|
+
if self._discovery_model is None:
|
|
657
|
+
return []
|
|
658
|
+
|
|
659
|
+
gen = ScenarioConfigGenerator(
|
|
660
|
+
self._discovery_model,
|
|
661
|
+
llm_provider=self.llm_provider,
|
|
662
|
+
include_security=self.enable_security_scenarios,
|
|
663
|
+
)
|
|
664
|
+
try:
|
|
665
|
+
if self.llm_provider is not None:
|
|
666
|
+
return await gen.generate_all_async()
|
|
667
|
+
return gen.generate_all()
|
|
668
|
+
except Exception as exc: # noqa: BLE001
|
|
669
|
+
logger.warning(
|
|
670
|
+
"AutonomousTestLoopAgent [%s]: scenario generation failed — %s",
|
|
671
|
+
self._run_id[:8],
|
|
672
|
+
exc,
|
|
673
|
+
)
|
|
674
|
+
return []
|
|
675
|
+
|
|
676
|
+
def _filter_scenarios(
|
|
677
|
+
self,
|
|
678
|
+
scenarios: list[dict[str, Any]],
|
|
679
|
+
previously_tested_pages: Set[str],
|
|
680
|
+
) -> list[dict[str, Any]]:
|
|
681
|
+
"""Apply the configured strategy to select which scenarios to execute."""
|
|
682
|
+
if self.strategy == "all":
|
|
683
|
+
return scenarios
|
|
684
|
+
|
|
685
|
+
if self.strategy == "high_risk":
|
|
686
|
+
return [
|
|
687
|
+
s for s in scenarios
|
|
688
|
+
if s.get("metadata", {}).get("risk_score", 0.0) >= self.risk_threshold
|
|
689
|
+
]
|
|
690
|
+
|
|
691
|
+
if self.strategy == "new_changed":
|
|
692
|
+
filtered = []
|
|
693
|
+
for s in scenarios:
|
|
694
|
+
page_key = _page_key(s.get("url", ""), self.target_url)
|
|
695
|
+
if page_key not in previously_tested_pages:
|
|
696
|
+
filtered.append(s)
|
|
697
|
+
# Fall back to all if nothing is new
|
|
698
|
+
return filtered or scenarios
|
|
699
|
+
|
|
700
|
+
if self.strategy == "belief_targeted":
|
|
701
|
+
return self._belief_targeted_filter(scenarios)
|
|
702
|
+
|
|
703
|
+
# Unknown strategy — run all
|
|
704
|
+
logger.warning(
|
|
705
|
+
"AutonomousTestLoopAgent: unknown strategy %r, falling back to 'all'",
|
|
706
|
+
self.strategy,
|
|
707
|
+
)
|
|
708
|
+
return scenarios
|
|
709
|
+
|
|
710
|
+
def _belief_targeted_filter(
|
|
711
|
+
self,
|
|
712
|
+
scenarios: list[dict[str, Any]],
|
|
713
|
+
) -> list[dict[str, Any]]:
|
|
714
|
+
"""Use belief state fault_likelihood to rank and select top-N scenarios."""
|
|
715
|
+
if self._belief_state is None:
|
|
716
|
+
return scenarios
|
|
717
|
+
|
|
718
|
+
fault_likelihoods = self._belief_state.fault_likelihood
|
|
719
|
+
|
|
720
|
+
def _score(s: dict[str, Any]) -> float:
|
|
721
|
+
page_key = _page_key(s.get("url", ""), self.target_url)
|
|
722
|
+
return fault_likelihoods.get(page_key, 0.5)
|
|
723
|
+
|
|
724
|
+
ranked = sorted(scenarios, key=_score, reverse=True)
|
|
725
|
+
# Take top 50% by belief score, minimum 1
|
|
726
|
+
top_n = max(1, len(ranked) // 2)
|
|
727
|
+
return ranked[:top_n]
|
|
728
|
+
|
|
729
|
+
async def _execute_scenarios(
|
|
730
|
+
self,
|
|
731
|
+
tasks: list[dict[str, Any]],
|
|
732
|
+
) -> dict[str, Any]:
|
|
733
|
+
"""Run the selected scenario tasks through FunctionalTestOrchestrator."""
|
|
734
|
+
from mannf.core.functional_orchestrator import FunctionalTestOrchestrator # noqa: PLC0415
|
|
735
|
+
|
|
736
|
+
try:
|
|
737
|
+
orch = FunctionalTestOrchestrator(num_browser_agents=self.num_browser_agents)
|
|
738
|
+
return await orch.run(tasks)
|
|
739
|
+
except Exception as exc: # noqa: BLE001
|
|
740
|
+
logger.warning(
|
|
741
|
+
"AutonomousTestLoopAgent [%s]: execution failed — %s",
|
|
742
|
+
self._run_id[:8],
|
|
743
|
+
exc,
|
|
744
|
+
)
|
|
745
|
+
# Return a minimal empty report so analysis phase doesn't crash
|
|
746
|
+
return {"passed": 0, "failed": len(tasks), "total": len(tasks), "tasks": []}
|
|
747
|
+
|
|
748
|
+
def _analyze_report(
|
|
749
|
+
self,
|
|
750
|
+
orch_report: dict[str, Any],
|
|
751
|
+
) -> tuple[int, int, int, list[dict[str, Any]]]:
|
|
752
|
+
"""Extract pass/fail/flaky counts and per-task details from orchestrator report.
|
|
753
|
+
|
|
754
|
+
Returns
|
|
755
|
+
-------
|
|
756
|
+
tuple[int, int, int, list[dict]]
|
|
757
|
+
``(passed, failed, flaky, task_details)``
|
|
758
|
+
"""
|
|
759
|
+
passed = int(orch_report.get("passed", 0))
|
|
760
|
+
failed = int(orch_report.get("failed", 0))
|
|
761
|
+
flaky = 0
|
|
762
|
+
|
|
763
|
+
raw_tasks: list[dict[str, Any]] = orch_report.get("tasks", [])
|
|
764
|
+
task_details: list[dict[str, Any]] = []
|
|
765
|
+
|
|
766
|
+
for t in raw_tasks:
|
|
767
|
+
task_id = t.get("task_id", "")
|
|
768
|
+
task_url = t.get("url", "")
|
|
769
|
+
outcome = t.get("outcome", "")
|
|
770
|
+
error = t.get("error", "")
|
|
771
|
+
is_flaky = t.get("flaky", False) or outcome == "flaky"
|
|
772
|
+
|
|
773
|
+
if is_flaky:
|
|
774
|
+
flaky += 1
|
|
775
|
+
# Flaky tasks were counted in passed or failed by orchestrator;
|
|
776
|
+
# don't double-count, just record for feedback
|
|
777
|
+
task_details.append(
|
|
778
|
+
{
|
|
779
|
+
"task_id": task_id,
|
|
780
|
+
"url": task_url,
|
|
781
|
+
"passed": outcome == "passed" or t.get("passed", False),
|
|
782
|
+
"failed": outcome == "failed" or (not t.get("passed", True)),
|
|
783
|
+
"flaky": is_flaky,
|
|
784
|
+
"error": error,
|
|
785
|
+
}
|
|
786
|
+
)
|
|
787
|
+
|
|
788
|
+
return passed, failed, flaky, task_details
|
|
789
|
+
|
|
790
|
+
def _collect_coverage_gaps(
|
|
791
|
+
self,
|
|
792
|
+
orch_report: dict[str, Any],
|
|
793
|
+
iteration: int,
|
|
794
|
+
) -> None:
|
|
795
|
+
"""Scan the orchestrator report for blocked/unreachable pages and record them.
|
|
796
|
+
|
|
797
|
+
Categories detected:
|
|
798
|
+
- **auth-blocked** — HTTP 401/403 responses.
|
|
799
|
+
- **captcha-blocked** — responses containing CAPTCHA indicators.
|
|
800
|
+
- **timeout** — tasks that exceeded the time budget.
|
|
801
|
+
- **connection-refused** — DNS/TCP failures.
|
|
802
|
+
|
|
803
|
+
Entries are deduplicated by URL+category so the same gap is not added
|
|
804
|
+
multiple times across iterations.
|
|
805
|
+
|
|
806
|
+
Parameters
|
|
807
|
+
----------
|
|
808
|
+
orch_report:
|
|
809
|
+
Raw dict returned by :meth:`_execute_scenarios`.
|
|
810
|
+
iteration:
|
|
811
|
+
Current iteration number (included in each gap entry for context).
|
|
812
|
+
"""
|
|
813
|
+
_REMEDIATION: Dict[str, str] = {
|
|
814
|
+
"auth-blocked": (
|
|
815
|
+
"Provide valid credentials via the `auth_config` option or configure "
|
|
816
|
+
"session cookies to allow NAT to access protected pages."
|
|
817
|
+
),
|
|
818
|
+
"captcha-blocked": (
|
|
819
|
+
"Disable CAPTCHA in your test environment, or use a bypass token "
|
|
820
|
+
"to allow automated access during testing."
|
|
821
|
+
),
|
|
822
|
+
"timeout": (
|
|
823
|
+
"Increase the `page_timeout_ms` budget or investigate slow-loading "
|
|
824
|
+
"pages that may indicate performance regressions."
|
|
825
|
+
),
|
|
826
|
+
"connection-refused": (
|
|
827
|
+
"Verify the target URL is reachable from the test runner and that "
|
|
828
|
+
"the service is healthy before starting NAT."
|
|
829
|
+
),
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
seen_keys: Set[str] = {
|
|
833
|
+
f"{g['url']}::{g['category']}" for g in self._coverage_gaps
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
raw_tasks: list[dict[str, Any]] = orch_report.get("tasks", [])
|
|
837
|
+
for task in raw_tasks:
|
|
838
|
+
url = task.get("url", "")
|
|
839
|
+
error = (task.get("error") or "").lower()
|
|
840
|
+
status_code = task.get("status_code")
|
|
841
|
+
|
|
842
|
+
category: Optional[str] = None
|
|
843
|
+
|
|
844
|
+
if status_code in (401, 403):
|
|
845
|
+
category = "auth-blocked"
|
|
846
|
+
elif "captcha" in error or "challenge" in error:
|
|
847
|
+
category = "captcha-blocked"
|
|
848
|
+
elif "timeout" in error or "timed out" in error:
|
|
849
|
+
category = "timeout"
|
|
850
|
+
elif (
|
|
851
|
+
"connection refused" in error
|
|
852
|
+
or "net::err_connection_refused" in error
|
|
853
|
+
or "econnrefused" in error
|
|
854
|
+
):
|
|
855
|
+
category = "connection-refused"
|
|
856
|
+
|
|
857
|
+
if category is None:
|
|
858
|
+
continue
|
|
859
|
+
|
|
860
|
+
gap_key = f"{url}::{category}"
|
|
861
|
+
if gap_key in seen_keys:
|
|
862
|
+
continue
|
|
863
|
+
seen_keys.add(gap_key)
|
|
864
|
+
|
|
865
|
+
self._coverage_gaps.append(
|
|
866
|
+
{
|
|
867
|
+
"url": url,
|
|
868
|
+
"category": category,
|
|
869
|
+
"status_code": status_code,
|
|
870
|
+
"error_detail": task.get("error", ""),
|
|
871
|
+
"iteration": iteration,
|
|
872
|
+
"remediation": _REMEDIATION[category],
|
|
873
|
+
}
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
def _update_beliefs(self, task_details: list[dict[str, Any]]) -> int:
|
|
877
|
+
"""Feed execution results back to BeliefState.
|
|
878
|
+
|
|
879
|
+
* Failed → increase fault_likelihood for that page
|
|
880
|
+
* Passed → decrease fault_likelihood
|
|
881
|
+
* Flaky → moderate increase
|
|
882
|
+
|
|
883
|
+
Returns
|
|
884
|
+
-------
|
|
885
|
+
int
|
|
886
|
+
Number of new (previously unseen) failures detected.
|
|
887
|
+
"""
|
|
888
|
+
from mannf.core.agents.belief_state import BeliefState # noqa: PLC0415
|
|
889
|
+
|
|
890
|
+
# Lazily initialise belief state on first call
|
|
891
|
+
if self._belief_state is None:
|
|
892
|
+
self._belief_state = BeliefState(services=[])
|
|
893
|
+
|
|
894
|
+
new_failures_count = 0
|
|
895
|
+
|
|
896
|
+
for t in task_details:
|
|
897
|
+
page_key = _page_key(t.get("url", ""), self.target_url)
|
|
898
|
+
|
|
899
|
+
if t.get("flaky"):
|
|
900
|
+
self._belief_state.update(page_key, 0.7)
|
|
901
|
+
elif t.get("failed"):
|
|
902
|
+
self._belief_state.update(page_key, 1.0)
|
|
903
|
+
|
|
904
|
+
# Track unique failures (dedup by url + error)
|
|
905
|
+
failure_key = f"{page_key}::{t.get('error', '')}"
|
|
906
|
+
if failure_key not in self._seen_failure_keys:
|
|
907
|
+
self._seen_failure_keys.add(failure_key)
|
|
908
|
+
new_failures_count += 1
|
|
909
|
+
self._all_unique_failures.append(
|
|
910
|
+
{
|
|
911
|
+
"url": t.get("url", ""),
|
|
912
|
+
"page": page_key,
|
|
913
|
+
"error": t.get("error", ""),
|
|
914
|
+
"task_id": t.get("task_id", ""),
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
elif t.get("passed"):
|
|
918
|
+
self._belief_state.update(page_key, 0.0)
|
|
919
|
+
|
|
920
|
+
return new_failures_count
|
|
921
|
+
|
|
922
|
+
def _schedule_exporter_hook(self, new_failures: List[Dict[str, Any]]) -> None:
|
|
923
|
+
"""Schedule a fire-and-forget background task to export new failures.
|
|
924
|
+
|
|
925
|
+
This is intentionally non-blocking — filing failures never crash or
|
|
926
|
+
delay the autonomous loop.
|
|
927
|
+
"""
|
|
928
|
+
if not new_failures:
|
|
929
|
+
return
|
|
930
|
+
try:
|
|
931
|
+
loop = asyncio.get_running_loop()
|
|
932
|
+
loop.create_task(self._export_new_failures(list(new_failures)))
|
|
933
|
+
except RuntimeError:
|
|
934
|
+
# No running event loop (e.g. called from a sync context / tests)
|
|
935
|
+
# — skip silently; filing is best-effort only.
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
async def _export_new_failures(self, failures: List[Dict[str, Any]]) -> None:
|
|
939
|
+
"""Best-effort: notify the dashboard's auto-filing pipeline about new failures."""
|
|
940
|
+
try:
|
|
941
|
+
from mannf.product.dashboard.app import _auto_file_bugs # noqa: PLC0415
|
|
942
|
+
from mannf.core.agents.autonomous_loop_models import AutonomousRunReport # noqa: PLC0415
|
|
943
|
+
|
|
944
|
+
# Build a minimal report with just the new failures so the dashboard
|
|
945
|
+
# pipeline can apply severity filtering and file tickets.
|
|
946
|
+
stub_report = AutonomousRunReport(
|
|
947
|
+
run_id=self._run_id,
|
|
948
|
+
target_url=self.target_url,
|
|
949
|
+
strategy=self.strategy,
|
|
950
|
+
total_iterations=0,
|
|
951
|
+
total_scenarios_executed=len(failures),
|
|
952
|
+
total_passed=0,
|
|
953
|
+
total_failed=len(failures),
|
|
954
|
+
total_flaky=0,
|
|
955
|
+
unique_failures=failures,
|
|
956
|
+
started_at="",
|
|
957
|
+
status="running",
|
|
958
|
+
)
|
|
959
|
+
await _auto_file_bugs(self._run_id, stub_report)
|
|
960
|
+
except Exception as exc: # noqa: BLE001
|
|
961
|
+
logger.debug(
|
|
962
|
+
"AutonomousTestLoopAgent [%s]: exporter hook failed (non-fatal): %s",
|
|
963
|
+
self._run_id[:8],
|
|
964
|
+
exc,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
# ------------------------------------------------------------------
|
|
968
|
+
# Helpers
|
|
969
|
+
# ------------------------------------------------------------------
|
|
970
|
+
|
|
971
|
+
def _persist_belief_snapshot(self, iteration: int) -> None:
|
|
972
|
+
"""Append a belief state snapshot for the current iteration (Phase 6.5)."""
|
|
973
|
+
if self._belief_state is None:
|
|
974
|
+
return
|
|
975
|
+
try:
|
|
976
|
+
snapshot = self._belief_state.export_snapshot(
|
|
977
|
+
run_id=self._run_id,
|
|
978
|
+
iteration=iteration,
|
|
979
|
+
)
|
|
980
|
+
self._belief_snapshots.append(snapshot)
|
|
981
|
+
except Exception as exc: # noqa: BLE001
|
|
982
|
+
logger.debug(
|
|
983
|
+
"AutonomousTestLoopAgent [%s]: belief snapshot failed (non-fatal): %s",
|
|
984
|
+
self._run_id[:8],
|
|
985
|
+
exc,
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
def _schedule_root_cause_analysis(self, new_failures: List[Dict[str, Any]]) -> None:
|
|
989
|
+
"""Schedule a fire-and-forget background task to run LLM root cause analysis."""
|
|
990
|
+
if not new_failures or self.llm_provider is None:
|
|
991
|
+
return
|
|
992
|
+
try:
|
|
993
|
+
loop = asyncio.get_running_loop()
|
|
994
|
+
loop.create_task(self._run_root_cause_analysis(list(new_failures)))
|
|
995
|
+
except RuntimeError:
|
|
996
|
+
pass
|
|
997
|
+
|
|
998
|
+
async def _run_root_cause_analysis(self, failures: List[Dict[str, Any]]) -> None:
|
|
999
|
+
"""Best-effort: call LLM root cause analyzer for new failures."""
|
|
1000
|
+
try:
|
|
1001
|
+
from mannf.core.diagnostics.root_cause_analyzer import RootCauseAnalyzer # noqa: PLC0415
|
|
1002
|
+
|
|
1003
|
+
analyzer = RootCauseAnalyzer(llm_provider=self.llm_provider)
|
|
1004
|
+
for failure in failures:
|
|
1005
|
+
page_key = failure.get("page", "")
|
|
1006
|
+
fault_likelihood = (
|
|
1007
|
+
self._belief_state.fault_likelihood.get(page_key, 1.0)
|
|
1008
|
+
if self._belief_state is not None
|
|
1009
|
+
else 1.0
|
|
1010
|
+
)
|
|
1011
|
+
# Collect belief history for this page from snapshots
|
|
1012
|
+
belief_history = [
|
|
1013
|
+
{
|
|
1014
|
+
"iteration": s.get("iteration"),
|
|
1015
|
+
"fault_likelihood": s.get("beliefs", {}).get(page_key),
|
|
1016
|
+
"timestamp": s.get("timestamp"),
|
|
1017
|
+
}
|
|
1018
|
+
for s in self._belief_snapshots
|
|
1019
|
+
if s.get("beliefs", {}).get(page_key) is not None
|
|
1020
|
+
]
|
|
1021
|
+
|
|
1022
|
+
result = await analyzer.analyze(
|
|
1023
|
+
failure=failure,
|
|
1024
|
+
fault_likelihood=fault_likelihood,
|
|
1025
|
+
belief_history=belief_history,
|
|
1026
|
+
)
|
|
1027
|
+
if result is not None:
|
|
1028
|
+
suggestion = result.to_dict()
|
|
1029
|
+
suggestion["task_id"] = failure.get("task_id", "")
|
|
1030
|
+
suggestion["url"] = failure.get("url", "")
|
|
1031
|
+
suggestion["page"] = page_key
|
|
1032
|
+
self._root_cause_suggestions.append(suggestion)
|
|
1033
|
+
logger.debug(
|
|
1034
|
+
"AutonomousTestLoopAgent [%s]: root cause for %s — %s (confidence=%.2f)",
|
|
1035
|
+
self._run_id[:8],
|
|
1036
|
+
page_key,
|
|
1037
|
+
result.likely_cause[:80],
|
|
1038
|
+
result.confidence,
|
|
1039
|
+
)
|
|
1040
|
+
except Exception as exc: # noqa: BLE001
|
|
1041
|
+
logger.debug(
|
|
1042
|
+
"AutonomousTestLoopAgent [%s]: root cause analysis failed (non-fatal): %s",
|
|
1043
|
+
self._run_id[:8],
|
|
1044
|
+
exc,
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
def _get_previous_iteration_pages(self) -> Set[str]:
|
|
1048
|
+
"""Return the set of page keys that have already been covered."""
|
|
1049
|
+
return set(self._covered_pages)
|
|
1050
|
+
|
|
1051
|
+
async def _broadcast_iteration(
|
|
1052
|
+
self,
|
|
1053
|
+
report: AutonomousRunReport,
|
|
1054
|
+
iter_result: LoopIterationResult,
|
|
1055
|
+
) -> None:
|
|
1056
|
+
"""Broadcast an autonomous_iteration WebSocket event (best-effort)."""
|
|
1057
|
+
try:
|
|
1058
|
+
from mannf.product.dashboard.telemetry import broadcast_autonomous_iteration # noqa: PLC0415
|
|
1059
|
+
|
|
1060
|
+
await broadcast_autonomous_iteration(
|
|
1061
|
+
run_id=self._run_id,
|
|
1062
|
+
iteration=iter_result.iteration,
|
|
1063
|
+
passed=iter_result.passed,
|
|
1064
|
+
failed=iter_result.failed,
|
|
1065
|
+
flaky=iter_result.flaky,
|
|
1066
|
+
new_failures=iter_result.new_failures,
|
|
1067
|
+
scenarios_executed=iter_result.scenarios_executed,
|
|
1068
|
+
coverage_pages=iter_result.coverage_pages,
|
|
1069
|
+
)
|
|
1070
|
+
except Exception: # noqa: BLE001
|
|
1071
|
+
pass
|
|
1072
|
+
|
|
1073
|
+
async def _broadcast_complete(self, report: AutonomousRunReport) -> None:
|
|
1074
|
+
"""Broadcast an autonomous_complete WebSocket event (best-effort)."""
|
|
1075
|
+
try:
|
|
1076
|
+
from mannf.product.dashboard.telemetry import broadcast_autonomous_complete # noqa: PLC0415
|
|
1077
|
+
|
|
1078
|
+
await broadcast_autonomous_complete(
|
|
1079
|
+
run_id=self._run_id,
|
|
1080
|
+
stop_reason=report.stop_reason,
|
|
1081
|
+
total_iterations=report.total_iterations,
|
|
1082
|
+
total_passed=report.total_passed,
|
|
1083
|
+
total_failed=report.total_failed,
|
|
1084
|
+
)
|
|
1085
|
+
except Exception: # noqa: BLE001
|
|
1086
|
+
pass
|