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,469 @@
|
|
|
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
|
+
"""gRPC System-Under-Test implementation.
|
|
7
|
+
|
|
8
|
+
:class:`GrpcSUT` invokes unary and streaming RPC methods against a live gRPC
|
|
9
|
+
service. It implements the same ``execute()`` / ``health_check()`` interface
|
|
10
|
+
as :class:`~mannf.product.integrations.http_sut.HttpApiSUT` so the
|
|
11
|
+
:class:`~mannf.nat_orchestrator.NATOrchestrator` and BDI agent pipeline work
|
|
12
|
+
unchanged.
|
|
13
|
+
|
|
14
|
+
The adapter uses ``grpcio`` when available. When ``grpcio`` is *not*
|
|
15
|
+
installed it falls back to a lightweight HTTP/2 hint-based mode that records a
|
|
16
|
+
skip result, allowing the rest of NAT to function in environments without the
|
|
17
|
+
``grpcio`` package.
|
|
18
|
+
|
|
19
|
+
Supported RPC types:
|
|
20
|
+
|
|
21
|
+
* **Unary** — single request / single response.
|
|
22
|
+
* **Server-streaming** — single request / multiple responses consumed.
|
|
23
|
+
* **Client-streaming** — multiple requests / single response (sent sequentially).
|
|
24
|
+
* **Bidirectional streaming** — multiple requests / multiple responses.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import time
|
|
31
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
32
|
+
|
|
33
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
34
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest
|
|
35
|
+
from mannf.core.testing.models import TestCase, TestResult
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# gRPC status code constants (mirrors grpc.StatusCode)
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_OK = 0
|
|
44
|
+
_STATUS_NAMES: dict[int, str] = {
|
|
45
|
+
0: "OK",
|
|
46
|
+
1: "CANCELLED",
|
|
47
|
+
2: "UNKNOWN",
|
|
48
|
+
3: "INVALID_ARGUMENT",
|
|
49
|
+
4: "DEADLINE_EXCEEDED",
|
|
50
|
+
5: "NOT_FOUND",
|
|
51
|
+
6: "ALREADY_EXISTS",
|
|
52
|
+
7: "PERMISSION_DENIED",
|
|
53
|
+
8: "RESOURCE_EXHAUSTED",
|
|
54
|
+
9: "FAILED_PRECONDITION",
|
|
55
|
+
10: "ABORTED",
|
|
56
|
+
11: "OUT_OF_RANGE",
|
|
57
|
+
12: "UNIMPLEMENTED",
|
|
58
|
+
13: "INTERNAL",
|
|
59
|
+
14: "UNAVAILABLE",
|
|
60
|
+
15: "DATA_LOSS",
|
|
61
|
+
16: "UNAUTHENTICATED",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _grpc_available() -> bool:
|
|
66
|
+
"""Return True if the ``grpcio`` package is installed."""
|
|
67
|
+
try:
|
|
68
|
+
import grpc # noqa: F401
|
|
69
|
+
return True
|
|
70
|
+
except ImportError:
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _passthrough(x: Any) -> Any:
|
|
75
|
+
"""Identity serializer/deserializer — pass raw bytes through unchanged."""
|
|
76
|
+
return x
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _make_stub(channel: Any, service_name: str, method_name: str) -> Any:
|
|
80
|
+
"""Create a dynamic gRPC stub method using the generic unary–unary approach."""
|
|
81
|
+
method_path = f"/{service_name}/{method_name}"
|
|
82
|
+
return channel.unary_unary(
|
|
83
|
+
method_path,
|
|
84
|
+
request_serializer=_passthrough,
|
|
85
|
+
response_deserializer=_passthrough,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class GrpcSUT(SystemUnderTest):
|
|
90
|
+
"""Execute gRPC test cases against a live gRPC service.
|
|
91
|
+
|
|
92
|
+
Parameters
|
|
93
|
+
----------
|
|
94
|
+
endpoint:
|
|
95
|
+
gRPC server address in ``host:port`` format
|
|
96
|
+
(e.g. ``"localhost:50051"``).
|
|
97
|
+
timeout:
|
|
98
|
+
Per-call timeout in seconds (default: 10).
|
|
99
|
+
use_tls:
|
|
100
|
+
Use TLS credentials (default: ``False`` — insecure channel).
|
|
101
|
+
metadata:
|
|
102
|
+
Sequence of ``(key, value)`` pairs sent as gRPC call metadata
|
|
103
|
+
(e.g. for authentication tokens).
|
|
104
|
+
max_streaming_responses:
|
|
105
|
+
Maximum number of responses to collect from server-streaming or
|
|
106
|
+
bidi-streaming RPCs before closing the stream (default: 100).
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
endpoint: str,
|
|
112
|
+
timeout: float = 10.0,
|
|
113
|
+
use_tls: bool = False,
|
|
114
|
+
metadata: Optional[List[tuple]] = None,
|
|
115
|
+
max_streaming_responses: int = 100,
|
|
116
|
+
) -> None:
|
|
117
|
+
self.endpoint = endpoint
|
|
118
|
+
self.timeout = timeout
|
|
119
|
+
self.use_tls = use_tls
|
|
120
|
+
self.call_metadata: List[tuple] = metadata or []
|
|
121
|
+
self.max_streaming_responses = max_streaming_responses
|
|
122
|
+
|
|
123
|
+
self._registry = EndpointRegistry()
|
|
124
|
+
host, _, port_str = endpoint.partition(":")
|
|
125
|
+
try:
|
|
126
|
+
port = int(port_str) if port_str else 50051
|
|
127
|
+
except ValueError:
|
|
128
|
+
port = 50051
|
|
129
|
+
self._registry.register(
|
|
130
|
+
Endpoint(
|
|
131
|
+
name="default",
|
|
132
|
+
host=host or "localhost",
|
|
133
|
+
port=port,
|
|
134
|
+
protocol="grpc",
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# SystemUnderTest interface
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def registry(self) -> EndpointRegistry:
|
|
144
|
+
return self._registry
|
|
145
|
+
|
|
146
|
+
async def execute(self, test_case: TestCase) -> TestResult:
|
|
147
|
+
"""Invoke the gRPC method described by *test_case* and return the result.
|
|
148
|
+
|
|
149
|
+
``test_case.target`` is treated as the full gRPC method path
|
|
150
|
+
(e.g. ``"/helloworld.Greeter/SayHello"``).
|
|
151
|
+
|
|
152
|
+
``test_case.inputs`` may contain:
|
|
153
|
+
|
|
154
|
+
* ``request`` — dict payload serialised to JSON-encoded bytes for the
|
|
155
|
+
request message.
|
|
156
|
+
* ``rpc_type`` — one of ``"unary"``, ``"server_streaming"``,
|
|
157
|
+
``"client_streaming"``, ``"bidi_streaming"`` (default: ``"unary"``).
|
|
158
|
+
* ``requests`` — list of request dicts for streaming RPCs.
|
|
159
|
+
* ``expected_status_codes`` — list of acceptable gRPC status codes
|
|
160
|
+
(ints). Defaults to ``[0]`` (OK).
|
|
161
|
+
"""
|
|
162
|
+
if not _grpc_available():
|
|
163
|
+
return TestResult(
|
|
164
|
+
test_case_id=test_case.id,
|
|
165
|
+
passed=False,
|
|
166
|
+
actual_output=None,
|
|
167
|
+
execution_time_ms=0.0,
|
|
168
|
+
error=(
|
|
169
|
+
"grpcio package is not installed. "
|
|
170
|
+
"Install it with: pip install grpcio"
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
rpc_type: str = str(test_case.inputs.get("rpc_type", "unary")).lower()
|
|
175
|
+
method_path: str = test_case.target
|
|
176
|
+
request_data: dict = test_case.inputs.get("request") or {}
|
|
177
|
+
expected_codes = frozenset(
|
|
178
|
+
test_case.inputs.get("expected_status_codes") or [_OK]
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if rpc_type == "unary":
|
|
182
|
+
return await self._execute_unary(
|
|
183
|
+
test_case, method_path, request_data, expected_codes
|
|
184
|
+
)
|
|
185
|
+
elif rpc_type == "server_streaming":
|
|
186
|
+
return await self._execute_server_streaming(
|
|
187
|
+
test_case, method_path, request_data, expected_codes
|
|
188
|
+
)
|
|
189
|
+
elif rpc_type == "client_streaming":
|
|
190
|
+
requests = test_case.inputs.get("requests") or [request_data]
|
|
191
|
+
return await self._execute_client_streaming(
|
|
192
|
+
test_case, method_path, requests, expected_codes
|
|
193
|
+
)
|
|
194
|
+
elif rpc_type == "bidi_streaming":
|
|
195
|
+
requests = test_case.inputs.get("requests") or [request_data]
|
|
196
|
+
return await self._execute_bidi_streaming(
|
|
197
|
+
test_case, method_path, requests, expected_codes
|
|
198
|
+
)
|
|
199
|
+
else:
|
|
200
|
+
return TestResult(
|
|
201
|
+
test_case_id=test_case.id,
|
|
202
|
+
passed=False,
|
|
203
|
+
actual_output=None,
|
|
204
|
+
execution_time_ms=0.0,
|
|
205
|
+
error=f"Unknown rpc_type: {rpc_type!r}",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def health_check(self) -> bool:
|
|
209
|
+
"""Check that the gRPC server is reachable via a channel connectivity check."""
|
|
210
|
+
if not _grpc_available():
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
import grpc
|
|
214
|
+
|
|
215
|
+
if self.use_tls:
|
|
216
|
+
credentials = grpc.ssl_channel_credentials()
|
|
217
|
+
channel = grpc.secure_channel(self.endpoint, credentials)
|
|
218
|
+
else:
|
|
219
|
+
channel = grpc.insecure_channel(self.endpoint)
|
|
220
|
+
|
|
221
|
+
grpc.channel_ready_future(channel).result(timeout=min(self.timeout, 5.0))
|
|
222
|
+
channel.close()
|
|
223
|
+
return True
|
|
224
|
+
except Exception: # noqa: BLE001
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# RPC-type-specific execution helpers
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _make_channel(self) -> Any:
|
|
232
|
+
"""Create an insecure or TLS gRPC channel."""
|
|
233
|
+
import grpc
|
|
234
|
+
|
|
235
|
+
if self.use_tls:
|
|
236
|
+
credentials = grpc.ssl_channel_credentials()
|
|
237
|
+
return grpc.secure_channel(self.endpoint, credentials)
|
|
238
|
+
return grpc.insecure_channel(self.endpoint)
|
|
239
|
+
|
|
240
|
+
def _encode_request(self, request_data: dict) -> bytes:
|
|
241
|
+
"""Encode *request_data* as raw bytes (JSON encoding for dynamic stub)."""
|
|
242
|
+
import json as _json
|
|
243
|
+
return _json.dumps(request_data).encode("utf-8")
|
|
244
|
+
|
|
245
|
+
def _parse_rpc_path(self, method_path: str) -> tuple[str, str]:
|
|
246
|
+
"""Return (service_path, method_name) from a full method path."""
|
|
247
|
+
# e.g. "/helloworld.Greeter/SayHello" → ("/helloworld.Greeter", "SayHello")
|
|
248
|
+
path = method_path.lstrip("/")
|
|
249
|
+
if "/" in path:
|
|
250
|
+
svc, _, method = path.rpartition("/")
|
|
251
|
+
return svc, method
|
|
252
|
+
return path, ""
|
|
253
|
+
|
|
254
|
+
async def _execute_unary(
|
|
255
|
+
self,
|
|
256
|
+
test_case: TestCase,
|
|
257
|
+
method_path: str,
|
|
258
|
+
request_data: dict,
|
|
259
|
+
expected_codes: frozenset,
|
|
260
|
+
) -> TestResult:
|
|
261
|
+
"""Execute a unary RPC call."""
|
|
262
|
+
import grpc
|
|
263
|
+
|
|
264
|
+
start = time.monotonic()
|
|
265
|
+
try:
|
|
266
|
+
channel = self._make_channel()
|
|
267
|
+
request_bytes = self._encode_request(request_data)
|
|
268
|
+
|
|
269
|
+
svc_path, method_name = self._parse_rpc_path(method_path)
|
|
270
|
+
stub = channel.unary_unary(
|
|
271
|
+
method_path,
|
|
272
|
+
request_serializer=_passthrough,
|
|
273
|
+
response_deserializer=_passthrough,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
response = stub(request_bytes, timeout=self.timeout, metadata=self.call_metadata)
|
|
277
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
278
|
+
|
|
279
|
+
# Dynamic stub returns raw bytes on success
|
|
280
|
+
passed = _OK in expected_codes
|
|
281
|
+
channel.close()
|
|
282
|
+
|
|
283
|
+
return TestResult(
|
|
284
|
+
test_case_id=test_case.id,
|
|
285
|
+
passed=passed,
|
|
286
|
+
actual_output={"raw_bytes": len(response) if response else 0},
|
|
287
|
+
execution_time_ms=elapsed_ms,
|
|
288
|
+
error=None if passed else f"Unexpected gRPC status; expected one of {expected_codes}",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
except Exception as exc: # noqa: BLE001
|
|
292
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
293
|
+
status_code = _extract_grpc_status_code(exc)
|
|
294
|
+
passed = status_code in expected_codes if status_code is not None else False
|
|
295
|
+
|
|
296
|
+
error_msg: Optional[str] = None
|
|
297
|
+
if not passed:
|
|
298
|
+
status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
|
|
299
|
+
error_msg = f"gRPC {status_name} ({status_code}): {exc}"
|
|
300
|
+
|
|
301
|
+
return TestResult(
|
|
302
|
+
test_case_id=test_case.id,
|
|
303
|
+
passed=passed,
|
|
304
|
+
actual_output=None,
|
|
305
|
+
execution_time_ms=elapsed_ms,
|
|
306
|
+
error=error_msg,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
async def _execute_server_streaming(
|
|
310
|
+
self,
|
|
311
|
+
test_case: TestCase,
|
|
312
|
+
method_path: str,
|
|
313
|
+
request_data: dict,
|
|
314
|
+
expected_codes: frozenset,
|
|
315
|
+
) -> TestResult:
|
|
316
|
+
"""Execute a server-streaming RPC call, collecting up to N responses."""
|
|
317
|
+
start = time.monotonic()
|
|
318
|
+
try:
|
|
319
|
+
channel = self._make_channel()
|
|
320
|
+
request_bytes = self._encode_request(request_data)
|
|
321
|
+
|
|
322
|
+
stub = channel.unary_stream(
|
|
323
|
+
method_path,
|
|
324
|
+
request_serializer=_passthrough,
|
|
325
|
+
response_deserializer=_passthrough,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
responses: list[Any] = []
|
|
329
|
+
for resp in stub(request_bytes, timeout=self.timeout, metadata=self.call_metadata):
|
|
330
|
+
responses.append(resp)
|
|
331
|
+
if len(responses) >= self.max_streaming_responses:
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
335
|
+
passed = _OK in expected_codes
|
|
336
|
+
channel.close()
|
|
337
|
+
|
|
338
|
+
return TestResult(
|
|
339
|
+
test_case_id=test_case.id,
|
|
340
|
+
passed=passed,
|
|
341
|
+
actual_output={"response_count": len(responses)},
|
|
342
|
+
execution_time_ms=elapsed_ms,
|
|
343
|
+
error=None,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
except Exception as exc: # noqa: BLE001
|
|
347
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
348
|
+
status_code = _extract_grpc_status_code(exc)
|
|
349
|
+
passed = status_code in expected_codes if status_code is not None else False
|
|
350
|
+
status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
|
|
351
|
+
return TestResult(
|
|
352
|
+
test_case_id=test_case.id,
|
|
353
|
+
passed=passed,
|
|
354
|
+
actual_output=None,
|
|
355
|
+
execution_time_ms=elapsed_ms,
|
|
356
|
+
error=f"gRPC {status_name}: {exc}" if not passed else None,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
async def _execute_client_streaming(
|
|
360
|
+
self,
|
|
361
|
+
test_case: TestCase,
|
|
362
|
+
method_path: str,
|
|
363
|
+
requests: list[dict],
|
|
364
|
+
expected_codes: frozenset,
|
|
365
|
+
) -> TestResult:
|
|
366
|
+
"""Execute a client-streaming RPC call."""
|
|
367
|
+
start = time.monotonic()
|
|
368
|
+
try:
|
|
369
|
+
channel = self._make_channel()
|
|
370
|
+
|
|
371
|
+
def _request_iter() -> Iterator[bytes]:
|
|
372
|
+
for req in requests:
|
|
373
|
+
yield self._encode_request(req)
|
|
374
|
+
|
|
375
|
+
stub = channel.stream_unary(
|
|
376
|
+
method_path,
|
|
377
|
+
request_serializer=_passthrough,
|
|
378
|
+
response_deserializer=_passthrough,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
response = stub(_request_iter(), timeout=self.timeout, metadata=self.call_metadata)
|
|
382
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
383
|
+
passed = _OK in expected_codes
|
|
384
|
+
channel.close()
|
|
385
|
+
|
|
386
|
+
return TestResult(
|
|
387
|
+
test_case_id=test_case.id,
|
|
388
|
+
passed=passed,
|
|
389
|
+
actual_output={"raw_bytes": len(response) if response else 0},
|
|
390
|
+
execution_time_ms=elapsed_ms,
|
|
391
|
+
error=None if passed else "Unexpected gRPC status",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
except Exception as exc: # noqa: BLE001
|
|
395
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
396
|
+
status_code = _extract_grpc_status_code(exc)
|
|
397
|
+
passed = status_code in expected_codes if status_code is not None else False
|
|
398
|
+
status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
|
|
399
|
+
return TestResult(
|
|
400
|
+
test_case_id=test_case.id,
|
|
401
|
+
passed=passed,
|
|
402
|
+
actual_output=None,
|
|
403
|
+
execution_time_ms=elapsed_ms,
|
|
404
|
+
error=f"gRPC {status_name}: {exc}" if not passed else None,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
async def _execute_bidi_streaming(
|
|
408
|
+
self,
|
|
409
|
+
test_case: TestCase,
|
|
410
|
+
method_path: str,
|
|
411
|
+
requests: list[dict],
|
|
412
|
+
expected_codes: frozenset,
|
|
413
|
+
) -> TestResult:
|
|
414
|
+
"""Execute a bidirectional streaming RPC call."""
|
|
415
|
+
start = time.monotonic()
|
|
416
|
+
try:
|
|
417
|
+
channel = self._make_channel()
|
|
418
|
+
|
|
419
|
+
def _request_iter() -> Iterator[bytes]:
|
|
420
|
+
for req in requests:
|
|
421
|
+
yield self._encode_request(req)
|
|
422
|
+
|
|
423
|
+
stub = channel.stream_stream(
|
|
424
|
+
method_path,
|
|
425
|
+
request_serializer=_passthrough,
|
|
426
|
+
response_deserializer=_passthrough,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
responses: list[Any] = []
|
|
430
|
+
for resp in stub(_request_iter(), timeout=self.timeout, metadata=self.call_metadata):
|
|
431
|
+
responses.append(resp)
|
|
432
|
+
if len(responses) >= self.max_streaming_responses:
|
|
433
|
+
break
|
|
434
|
+
|
|
435
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
436
|
+
passed = _OK in expected_codes
|
|
437
|
+
channel.close()
|
|
438
|
+
|
|
439
|
+
return TestResult(
|
|
440
|
+
test_case_id=test_case.id,
|
|
441
|
+
passed=passed,
|
|
442
|
+
actual_output={"response_count": len(responses)},
|
|
443
|
+
execution_time_ms=elapsed_ms,
|
|
444
|
+
error=None,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
except Exception as exc: # noqa: BLE001
|
|
448
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
449
|
+
status_code = _extract_grpc_status_code(exc)
|
|
450
|
+
passed = status_code in expected_codes if status_code is not None else False
|
|
451
|
+
status_name = _STATUS_NAMES.get(status_code or 2, "UNKNOWN")
|
|
452
|
+
return TestResult(
|
|
453
|
+
test_case_id=test_case.id,
|
|
454
|
+
passed=passed,
|
|
455
|
+
actual_output=None,
|
|
456
|
+
execution_time_ms=elapsed_ms,
|
|
457
|
+
error=f"gRPC {status_name}: {exc}" if not passed else None,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _extract_grpc_status_code(exc: Exception) -> Optional[int]:
|
|
462
|
+
"""Extract a numeric gRPC status code from an RpcError exception."""
|
|
463
|
+
try:
|
|
464
|
+
# grpc.RpcError has a .code() method
|
|
465
|
+
code_obj = exc.code() # type: ignore[attr-defined]
|
|
466
|
+
# grpc.StatusCode enum has .value[0] (the numeric code)
|
|
467
|
+
return int(code_obj.value[0])
|
|
468
|
+
except Exception: # noqa: BLE001
|
|
469
|
+
return None
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
"""HTTP System-Under-Test implementation.
|
|
7
|
+
|
|
8
|
+
:class:`HttpApiSUT` is a concrete :class:`~mannf.core.distributed.system_under_test.SystemUnderTest`
|
|
9
|
+
that executes test cases against real HTTP APIs using ``httpx``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import time
|
|
15
|
+
import logging
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from mannf.core.distributed.endpoint import Endpoint, EndpointRegistry
|
|
21
|
+
from mannf.core.distributed.system_under_test import SystemUnderTest
|
|
22
|
+
from mannf.product.integrations.auth import AuthStrategy, NoAuth
|
|
23
|
+
from mannf.core.testing.models import TestCase, TestResult
|
|
24
|
+
|
|
25
|
+
# Imported lazily to avoid a hard circular dependency
|
|
26
|
+
_RegressionRecorder = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_recorder_type():
|
|
30
|
+
global _RegressionRecorder
|
|
31
|
+
if _RegressionRecorder is None:
|
|
32
|
+
from mannf.product.regression.recorder import RegressionRecorder # noqa: PLC0415
|
|
33
|
+
_RegressionRecorder = RegressionRecorder
|
|
34
|
+
return _RegressionRecorder
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
# HTTP methods that send a request body
|
|
39
|
+
_BODY_METHODS = {"POST", "PUT", "PATCH"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class HttpApiSUT(SystemUnderTest):
|
|
43
|
+
"""Execute test cases against a real HTTP API.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
base_url:
|
|
48
|
+
Root URL of the API under test (e.g. ``"http://localhost:8080"``).
|
|
49
|
+
default_headers:
|
|
50
|
+
Headers added to every request (e.g. ``{"Accept": "application/json"}``).
|
|
51
|
+
timeout:
|
|
52
|
+
Default request timeout in seconds.
|
|
53
|
+
auth:
|
|
54
|
+
:class:`~mannf.product.integrations.auth.AuthStrategy` applied before each
|
|
55
|
+
request. Defaults to :class:`~mannf.product.integrations.auth.NoAuth`.
|
|
56
|
+
expected_status_codes:
|
|
57
|
+
Iterable of HTTP status codes considered passing. Defaults to all
|
|
58
|
+
2xx codes (200–299).
|
|
59
|
+
latency_sla_ms:
|
|
60
|
+
Maximum acceptable response time in milliseconds. A response that
|
|
61
|
+
exceeds this limit is treated as a failure even if the status code is
|
|
62
|
+
otherwise acceptable. ``None`` means no SLA check.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
base_url: str,
|
|
68
|
+
default_headers: Optional[Dict[str, str]] = None,
|
|
69
|
+
timeout: float = 10.0,
|
|
70
|
+
auth: Optional[AuthStrategy] = None,
|
|
71
|
+
expected_status_codes: Optional[List[int]] = None,
|
|
72
|
+
latency_sla_ms: Optional[float] = None,
|
|
73
|
+
recorder: Optional[Any] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self.base_url = base_url.rstrip("/")
|
|
76
|
+
self.default_headers: Dict[str, str] = default_headers or {}
|
|
77
|
+
self.timeout = timeout
|
|
78
|
+
self.auth: AuthStrategy = auth or NoAuth()
|
|
79
|
+
self.latency_sla_ms = latency_sla_ms
|
|
80
|
+
self.recorder = recorder
|
|
81
|
+
|
|
82
|
+
if expected_status_codes is not None:
|
|
83
|
+
self._expected_status_codes: Optional[frozenset] = frozenset(expected_status_codes)
|
|
84
|
+
else:
|
|
85
|
+
self._expected_status_codes = None # 2xx check applied dynamically
|
|
86
|
+
|
|
87
|
+
self._registry = EndpointRegistry()
|
|
88
|
+
# Register a single "default" endpoint representing the base URL
|
|
89
|
+
from urllib.parse import urlparse
|
|
90
|
+
parsed = urlparse(self.base_url)
|
|
91
|
+
host = parsed.hostname or "localhost"
|
|
92
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
|
93
|
+
self._registry.register(
|
|
94
|
+
Endpoint(
|
|
95
|
+
name="default",
|
|
96
|
+
host=host,
|
|
97
|
+
port=port,
|
|
98
|
+
protocol=parsed.scheme or "http",
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# SystemUnderTest interface
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def registry(self) -> EndpointRegistry:
|
|
108
|
+
return self._registry
|
|
109
|
+
|
|
110
|
+
async def execute(self, test_case: TestCase) -> TestResult:
|
|
111
|
+
"""Make an HTTP request described by *test_case* and return the result.
|
|
112
|
+
|
|
113
|
+
``test_case.target`` is treated as the endpoint path (e.g. ``"/users"``).
|
|
114
|
+
``test_case.inputs`` may contain:
|
|
115
|
+
|
|
116
|
+
* ``method`` – HTTP verb (default ``"GET"``).
|
|
117
|
+
* ``headers`` – per-request headers merged on top of *default_headers*.
|
|
118
|
+
* ``body`` – raw request body bytes or string.
|
|
119
|
+
* ``json`` – JSON-serialisable dict sent as the request body.
|
|
120
|
+
* ``query_params`` – dict of URL query parameters.
|
|
121
|
+
* ``expected_status_codes`` – per-test override of acceptable status
|
|
122
|
+
codes (list of ints).
|
|
123
|
+
"""
|
|
124
|
+
method: str = str(test_case.inputs.get("method", "GET")).upper()
|
|
125
|
+
path: str = test_case.target
|
|
126
|
+
if not path.startswith("/"):
|
|
127
|
+
path = "/" + path
|
|
128
|
+
url = self.base_url + path
|
|
129
|
+
|
|
130
|
+
# Merge headers
|
|
131
|
+
merged_headers: Dict[str, str] = {**self.default_headers}
|
|
132
|
+
extra_headers = test_case.inputs.get("headers") or {}
|
|
133
|
+
merged_headers.update(extra_headers)
|
|
134
|
+
|
|
135
|
+
request_kwargs: Dict[str, Any] = {
|
|
136
|
+
"method": method,
|
|
137
|
+
"url": url,
|
|
138
|
+
"headers": merged_headers,
|
|
139
|
+
"timeout": self.timeout,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
query_params = test_case.inputs.get("query_params")
|
|
143
|
+
if query_params:
|
|
144
|
+
request_kwargs["params"] = query_params
|
|
145
|
+
|
|
146
|
+
if method in _BODY_METHODS:
|
|
147
|
+
if "json" in test_case.inputs:
|
|
148
|
+
request_kwargs["json"] = test_case.inputs["json"]
|
|
149
|
+
elif "body" in test_case.inputs:
|
|
150
|
+
request_kwargs["content"] = test_case.inputs["body"]
|
|
151
|
+
|
|
152
|
+
# Apply auth strategy
|
|
153
|
+
request_kwargs = await self.auth.apply(request_kwargs)
|
|
154
|
+
|
|
155
|
+
# Determine per-test expected status codes
|
|
156
|
+
per_test_codes = test_case.inputs.get("expected_status_codes")
|
|
157
|
+
if per_test_codes is not None:
|
|
158
|
+
expected_codes: Optional[frozenset] = frozenset(per_test_codes)
|
|
159
|
+
else:
|
|
160
|
+
expected_codes = self._expected_status_codes
|
|
161
|
+
|
|
162
|
+
# Execute the request
|
|
163
|
+
start = time.monotonic()
|
|
164
|
+
try:
|
|
165
|
+
async with httpx.AsyncClient() as client:
|
|
166
|
+
response = await client.request(**request_kwargs)
|
|
167
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
168
|
+
|
|
169
|
+
# Determine pass/fail
|
|
170
|
+
if expected_codes is not None:
|
|
171
|
+
status_ok = response.status_code in expected_codes
|
|
172
|
+
else:
|
|
173
|
+
status_ok = 200 <= response.status_code < 300
|
|
174
|
+
|
|
175
|
+
latency_ok = (
|
|
176
|
+
self.latency_sla_ms is None or elapsed_ms <= self.latency_sla_ms
|
|
177
|
+
)
|
|
178
|
+
passed = status_ok and latency_ok
|
|
179
|
+
|
|
180
|
+
error_msg: Optional[str] = None
|
|
181
|
+
if not status_ok:
|
|
182
|
+
error_msg = f"Unexpected status {response.status_code}"
|
|
183
|
+
elif not latency_ok:
|
|
184
|
+
error_msg = (
|
|
185
|
+
f"Latency {elapsed_ms:.1f}ms exceeds SLA {self.latency_sla_ms}ms"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Try to parse response body as JSON, fall back to text
|
|
189
|
+
try:
|
|
190
|
+
actual_output: Any = response.json()
|
|
191
|
+
except Exception:
|
|
192
|
+
actual_output = response.text
|
|
193
|
+
|
|
194
|
+
if self.recorder is not None:
|
|
195
|
+
self.recorder.record(
|
|
196
|
+
test_case_id=test_case.id,
|
|
197
|
+
method=method,
|
|
198
|
+
url=url,
|
|
199
|
+
request_headers=merged_headers,
|
|
200
|
+
request_body=request_kwargs.get("json") or request_kwargs.get("content"),
|
|
201
|
+
query_params=query_params,
|
|
202
|
+
status_code=response.status_code,
|
|
203
|
+
response_headers=dict(response.headers),
|
|
204
|
+
response_body=actual_output,
|
|
205
|
+
latency_ms=elapsed_ms,
|
|
206
|
+
passed=passed,
|
|
207
|
+
error=error_msg,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return TestResult(
|
|
211
|
+
test_case_id=test_case.id,
|
|
212
|
+
passed=passed,
|
|
213
|
+
actual_output=actual_output,
|
|
214
|
+
execution_time_ms=elapsed_ms,
|
|
215
|
+
error=error_msg,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
except httpx.TimeoutException as exc:
|
|
219
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
220
|
+
logger.warning("HttpApiSUT: timeout for %s %s — %s", method, url, exc)
|
|
221
|
+
return TestResult(
|
|
222
|
+
test_case_id=test_case.id,
|
|
223
|
+
passed=False,
|
|
224
|
+
actual_output=None,
|
|
225
|
+
execution_time_ms=elapsed_ms,
|
|
226
|
+
error=f"Request timed out: {exc}",
|
|
227
|
+
)
|
|
228
|
+
except httpx.RequestError as exc:
|
|
229
|
+
elapsed_ms = (time.monotonic() - start) * 1000.0
|
|
230
|
+
logger.warning("HttpApiSUT: connection error for %s %s — %s", method, url, exc)
|
|
231
|
+
return TestResult(
|
|
232
|
+
test_case_id=test_case.id,
|
|
233
|
+
passed=False,
|
|
234
|
+
actual_output=None,
|
|
235
|
+
execution_time_ms=elapsed_ms,
|
|
236
|
+
error=f"Connection error: {exc}",
|
|
237
|
+
)
|