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