ai-testing-swarm 0.1.13__py3-none-any.whl → 0.1.15__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.
- ai_testing_swarm/__init__.py +1 -1
- ai_testing_swarm/agents/execution_agent.py +23 -0
- ai_testing_swarm/cli.py +36 -2
- ai_testing_swarm/core/openapi_validator.py +157 -0
- ai_testing_swarm/core/risk.py +139 -0
- ai_testing_swarm/orchestrator.py +48 -8
- ai_testing_swarm/reporting/dashboard.py +188 -0
- ai_testing_swarm/reporting/report_writer.py +238 -11
- {ai_testing_swarm-0.1.13.dist-info → ai_testing_swarm-0.1.15.dist-info}/METADATA +22 -4
- {ai_testing_swarm-0.1.13.dist-info → ai_testing_swarm-0.1.15.dist-info}/RECORD +13 -10
- {ai_testing_swarm-0.1.13.dist-info → ai_testing_swarm-0.1.15.dist-info}/WHEEL +0 -0
- {ai_testing_swarm-0.1.13.dist-info → ai_testing_swarm-0.1.15.dist-info}/entry_points.txt +0 -0
- {ai_testing_swarm-0.1.13.dist-info → ai_testing_swarm-0.1.15.dist-info}/top_level.txt +0 -0
ai_testing_swarm/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.15"
|
|
@@ -137,6 +137,27 @@ class ExecutionAgent:
|
|
|
137
137
|
except Exception:
|
|
138
138
|
body_snippet = resp.text
|
|
139
139
|
|
|
140
|
+
openapi_validation = []
|
|
141
|
+
try:
|
|
142
|
+
ctx = request.get("_openapi") or {}
|
|
143
|
+
if isinstance(ctx, dict) and ctx.get("spec") and ctx.get("path") and ctx.get("method"):
|
|
144
|
+
# If method was mutated to something else, we can't reliably map to an OpenAPI op.
|
|
145
|
+
if str(ctx.get("method")).upper() == str(method).upper():
|
|
146
|
+
from ai_testing_swarm.core.openapi_validator import validate_openapi_response
|
|
147
|
+
|
|
148
|
+
issues = validate_openapi_response(
|
|
149
|
+
spec=ctx["spec"],
|
|
150
|
+
path=ctx["path"],
|
|
151
|
+
method=ctx["method"],
|
|
152
|
+
status_code=resp.status_code,
|
|
153
|
+
response_headers=dict(resp.headers),
|
|
154
|
+
response_json=body_snippet if isinstance(body_snippet, (dict, list)) else None,
|
|
155
|
+
)
|
|
156
|
+
openapi_validation = [i.__dict__ for i in issues]
|
|
157
|
+
except Exception:
|
|
158
|
+
# Best-effort only; never fail execution on validator issues.
|
|
159
|
+
openapi_validation = []
|
|
160
|
+
|
|
140
161
|
return {
|
|
141
162
|
"name": test["name"],
|
|
142
163
|
"mutation": mutation,
|
|
@@ -151,7 +172,9 @@ class ExecutionAgent:
|
|
|
151
172
|
"status_code": resp.status_code,
|
|
152
173
|
"elapsed_ms": elapsed_ms,
|
|
153
174
|
"attempt": attempt,
|
|
175
|
+
"headers": dict(resp.headers),
|
|
154
176
|
"body_snippet": body_snippet,
|
|
177
|
+
"openapi_validation": openapi_validation,
|
|
155
178
|
},
|
|
156
179
|
}
|
|
157
180
|
|
ai_testing_swarm/cli.py
CHANGED
|
@@ -31,7 +31,7 @@ def normalize_request(payload: dict) -> dict:
|
|
|
31
31
|
from ai_testing_swarm.core.openapi_loader import load_openapi, build_request_from_openapi
|
|
32
32
|
|
|
33
33
|
spec = load_openapi(payload["openapi"])
|
|
34
|
-
|
|
34
|
+
req = build_request_from_openapi(
|
|
35
35
|
spec,
|
|
36
36
|
path=payload["path"],
|
|
37
37
|
method=payload["method"],
|
|
@@ -40,6 +40,14 @@ def normalize_request(payload: dict) -> dict:
|
|
|
40
40
|
query_params=payload.get("query_params") or {},
|
|
41
41
|
body=payload.get("body"),
|
|
42
42
|
)
|
|
43
|
+
# Attach OpenAPI context for optional response validation/reporting.
|
|
44
|
+
req["_openapi"] = {
|
|
45
|
+
"source": payload["openapi"],
|
|
46
|
+
"path": payload["path"],
|
|
47
|
+
"method": str(payload["method"]).upper(),
|
|
48
|
+
"spec": spec,
|
|
49
|
+
}
|
|
50
|
+
return req
|
|
43
51
|
|
|
44
52
|
# Case 2: already normalized
|
|
45
53
|
required_keys = {"method", "url"}
|
|
@@ -92,6 +100,27 @@ def main():
|
|
|
92
100
|
help="Safety: allow only public test hosts (httpbin/postman-echo/reqres) for this run",
|
|
93
101
|
)
|
|
94
102
|
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--report-format",
|
|
105
|
+
default="json",
|
|
106
|
+
choices=["json", "md", "html"],
|
|
107
|
+
help="Report format to write (default: json)",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Batch1: risk gate thresholds (backward compatible defaults)
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--gate-warn",
|
|
113
|
+
type=int,
|
|
114
|
+
default=30,
|
|
115
|
+
help="Gate WARN threshold for endpoint risk score (default: 30)",
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--gate-block",
|
|
119
|
+
type=int,
|
|
120
|
+
default=80,
|
|
121
|
+
help="Gate BLOCK threshold for endpoint risk score (default: 80)",
|
|
122
|
+
)
|
|
123
|
+
|
|
95
124
|
args = parser.parse_args()
|
|
96
125
|
|
|
97
126
|
# ------------------------------------------------------------
|
|
@@ -112,7 +141,12 @@ def main():
|
|
|
112
141
|
import os
|
|
113
142
|
os.environ["AI_SWARM_PUBLIC_ONLY"] = "1"
|
|
114
143
|
|
|
115
|
-
decision, results = SwarmOrchestrator().run(
|
|
144
|
+
decision, results = SwarmOrchestrator().run(
|
|
145
|
+
request,
|
|
146
|
+
report_format=args.report_format,
|
|
147
|
+
gate_warn=args.gate_warn,
|
|
148
|
+
gate_block=args.gate_block,
|
|
149
|
+
)
|
|
116
150
|
|
|
117
151
|
# ------------------------------------------------------------
|
|
118
152
|
# Console output
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class OpenAPIValidationIssue:
|
|
8
|
+
type: str
|
|
9
|
+
message: str
|
|
10
|
+
details: dict | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _matches_status_key(status_code: int, key: str) -> bool:
|
|
14
|
+
"""OpenAPI response keys can be explicit ("200"), wildcard ("2XX"), or "default"."""
|
|
15
|
+
key = str(key).strip().upper()
|
|
16
|
+
if key == "DEFAULT":
|
|
17
|
+
return True
|
|
18
|
+
if len(key) == 3 and key.endswith("XX") and key[0].isdigit():
|
|
19
|
+
return int(key[0]) == int(status_code / 100)
|
|
20
|
+
return key == str(status_code)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_operation(spec: dict, *, path: str, method: str) -> dict | None:
|
|
24
|
+
paths = spec.get("paths") or {}
|
|
25
|
+
op = (paths.get(path) or {}).get(str(method).lower())
|
|
26
|
+
if isinstance(op, dict):
|
|
27
|
+
return op
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def validate_openapi_response(
|
|
32
|
+
*,
|
|
33
|
+
spec: dict,
|
|
34
|
+
path: str,
|
|
35
|
+
method: str,
|
|
36
|
+
status_code: int | None,
|
|
37
|
+
response_headers: dict | None = None,
|
|
38
|
+
response_json=None,
|
|
39
|
+
) -> list[OpenAPIValidationIssue]:
|
|
40
|
+
"""Validate a response against an OpenAPI operation.
|
|
41
|
+
|
|
42
|
+
- Status code must be declared in operation.responses (supports 2XX and default)
|
|
43
|
+
- If response appears to be JSON and jsonschema is installed, validate body
|
|
44
|
+
|
|
45
|
+
Returns a list of issues (empty => OK or validation skipped).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
issues: list[OpenAPIValidationIssue] = []
|
|
49
|
+
|
|
50
|
+
op = _get_operation(spec, path=path, method=method)
|
|
51
|
+
if not op:
|
|
52
|
+
return [
|
|
53
|
+
OpenAPIValidationIssue(
|
|
54
|
+
type="openapi_operation_missing",
|
|
55
|
+
message=f"Operation not found in spec: {str(method).upper()} {path}",
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
responses = op.get("responses") or {}
|
|
60
|
+
if status_code is None:
|
|
61
|
+
# Network errors etc. Not an OpenAPI mismatch.
|
|
62
|
+
return issues
|
|
63
|
+
|
|
64
|
+
# --------------------
|
|
65
|
+
# Status validation
|
|
66
|
+
# --------------------
|
|
67
|
+
declared_keys = [str(k) for k in responses.keys()]
|
|
68
|
+
if declared_keys:
|
|
69
|
+
if not any(_matches_status_key(int(status_code), k) for k in declared_keys):
|
|
70
|
+
issues.append(
|
|
71
|
+
OpenAPIValidationIssue(
|
|
72
|
+
type="openapi_status",
|
|
73
|
+
message=f"Status {status_code} not declared in OpenAPI responses: {declared_keys}",
|
|
74
|
+
details={"status_code": status_code, "declared": declared_keys},
|
|
75
|
+
)
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# --------------------
|
|
79
|
+
# JSON schema validation (optional)
|
|
80
|
+
# --------------------
|
|
81
|
+
ct = ""
|
|
82
|
+
if response_headers and isinstance(response_headers, dict):
|
|
83
|
+
for k, v in response_headers.items():
|
|
84
|
+
if str(k).lower() == "content-type" and v:
|
|
85
|
+
ct = str(v).lower()
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
is_json = isinstance(response_json, (dict, list))
|
|
89
|
+
if not is_json:
|
|
90
|
+
# if body wasn't parsed as JSON, skip schema validation
|
|
91
|
+
return issues
|
|
92
|
+
|
|
93
|
+
if ct and "json" not in ct:
|
|
94
|
+
# Respect explicit non-JSON content-type
|
|
95
|
+
return issues
|
|
96
|
+
|
|
97
|
+
# Find best matching response schema: exact > 2XX > default
|
|
98
|
+
chosen_resp: dict | None = None
|
|
99
|
+
for k in (str(status_code), f"{int(status_code/100)}XX", "default"):
|
|
100
|
+
if k in responses:
|
|
101
|
+
chosen_resp = responses.get(k)
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
if not isinstance(chosen_resp, dict):
|
|
105
|
+
return issues
|
|
106
|
+
|
|
107
|
+
content = chosen_resp.get("content") or {}
|
|
108
|
+
schema = None
|
|
109
|
+
if isinstance(content, dict):
|
|
110
|
+
app_json = content.get("application/json") or content.get("application/*+json")
|
|
111
|
+
if isinstance(app_json, dict):
|
|
112
|
+
schema = app_json.get("schema")
|
|
113
|
+
|
|
114
|
+
if not schema:
|
|
115
|
+
return issues
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
import jsonschema # type: ignore
|
|
119
|
+
|
|
120
|
+
# Resolve in-document refs like #/components/schemas/X
|
|
121
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
122
|
+
validator_cls = jsonschema.validators.validator_for(schema) # type: ignore[attr-defined]
|
|
123
|
+
validator_cls.check_schema(schema)
|
|
124
|
+
validator = validator_cls(schema, resolver=resolver)
|
|
125
|
+
errors = sorted(validator.iter_errors(response_json), key=lambda e: list(e.path))
|
|
126
|
+
if errors:
|
|
127
|
+
# Keep just the first few errors for signal.
|
|
128
|
+
details = {
|
|
129
|
+
"error_count": len(errors),
|
|
130
|
+
"errors": [
|
|
131
|
+
{
|
|
132
|
+
"message": e.message,
|
|
133
|
+
"path": list(e.path),
|
|
134
|
+
"schema_path": list(e.schema_path),
|
|
135
|
+
}
|
|
136
|
+
for e in errors[:5]
|
|
137
|
+
],
|
|
138
|
+
}
|
|
139
|
+
issues.append(
|
|
140
|
+
OpenAPIValidationIssue(
|
|
141
|
+
type="openapi_schema",
|
|
142
|
+
message="Response JSON does not match OpenAPI schema",
|
|
143
|
+
details=details,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
except ImportError:
|
|
147
|
+
# Optional dependency not installed: status validation still works.
|
|
148
|
+
return issues
|
|
149
|
+
except Exception as e:
|
|
150
|
+
issues.append(
|
|
151
|
+
OpenAPIValidationIssue(
|
|
152
|
+
type="openapi_schema_error",
|
|
153
|
+
message=f"OpenAPI schema validation failed: {e}",
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return issues
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Risk scoring for AI Testing Swarm.
|
|
2
|
+
|
|
3
|
+
Batch1 additions:
|
|
4
|
+
- Compute a numeric risk_score per test result (0..100)
|
|
5
|
+
- Aggregate endpoint risk and drive gate thresholds (PASS/WARN/BLOCK)
|
|
6
|
+
|
|
7
|
+
Design goals:
|
|
8
|
+
- Backward compatible: consumers can ignore risk_score fields.
|
|
9
|
+
- Deterministic and explainable.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class RiskThresholds:
|
|
19
|
+
"""Thresholds for the endpoint gate.
|
|
20
|
+
|
|
21
|
+
Gate is computed from endpoint_risk_score (currently max test risk).
|
|
22
|
+
|
|
23
|
+
- PASS: score < warn
|
|
24
|
+
- WARN: warn <= score < block
|
|
25
|
+
- BLOCK: score >= block
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
warn: int = 30
|
|
29
|
+
block: int = 80
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Keep aligned with orchestrator/release gate semantics
|
|
33
|
+
EXPECTED_FAILURES: set[str] = {
|
|
34
|
+
"success",
|
|
35
|
+
"missing_param",
|
|
36
|
+
"invalid_param",
|
|
37
|
+
"security",
|
|
38
|
+
"method_not_allowed",
|
|
39
|
+
"not_found",
|
|
40
|
+
"content_negotiation",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
RISKY_FAILURES: set[str] = {
|
|
44
|
+
"unknown",
|
|
45
|
+
"missing_param_accepted",
|
|
46
|
+
"null_param_accepted",
|
|
47
|
+
"invalid_param_accepted",
|
|
48
|
+
"headers_accepted",
|
|
49
|
+
"method_risk",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
BLOCKING_FAILURES: set[str] = {
|
|
53
|
+
"auth_issue",
|
|
54
|
+
"infra",
|
|
55
|
+
"security_risk",
|
|
56
|
+
"server_error",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clamp_int(x: int, lo: int, hi: int) -> int:
|
|
61
|
+
return lo if x < lo else hi if x > hi else x
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def compute_test_risk_score(result: dict, *, sla_ms: int | None = None) -> int:
|
|
65
|
+
"""Compute a risk score for a single test result.
|
|
66
|
+
|
|
67
|
+
Inputs expected (best-effort):
|
|
68
|
+
- result['failure_type']
|
|
69
|
+
- result['status']
|
|
70
|
+
- result['response']['status_code']
|
|
71
|
+
- result['response']['elapsed_ms']
|
|
72
|
+
- result['response']['openapi_validation'] (list)
|
|
73
|
+
|
|
74
|
+
Returns: int in range 0..100.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
ft = str(result.get("failure_type") or "unknown")
|
|
78
|
+
status = str(result.get("status") or "")
|
|
79
|
+
resp = result.get("response") or {}
|
|
80
|
+
sc = resp.get("status_code")
|
|
81
|
+
|
|
82
|
+
# Base score from semantic classification.
|
|
83
|
+
if ft in EXPECTED_FAILURES:
|
|
84
|
+
base = 0
|
|
85
|
+
elif ft in RISKY_FAILURES:
|
|
86
|
+
base = 35
|
|
87
|
+
elif ft in BLOCKING_FAILURES:
|
|
88
|
+
base = 90
|
|
89
|
+
else:
|
|
90
|
+
# Unknown failure types are treated as high risk but not always a hard blocker.
|
|
91
|
+
base = 60
|
|
92
|
+
|
|
93
|
+
# Status-code adjustments (defense in depth)
|
|
94
|
+
if isinstance(sc, int):
|
|
95
|
+
if 500 <= sc:
|
|
96
|
+
base = max(base, 90)
|
|
97
|
+
elif sc in (401, 403):
|
|
98
|
+
base = max(base, 80)
|
|
99
|
+
elif 400 <= sc < 500:
|
|
100
|
+
# Client errors for negative tests are expected; keep base.
|
|
101
|
+
base = max(base, base)
|
|
102
|
+
|
|
103
|
+
# Explicit FAILED status implies something unexpected.
|
|
104
|
+
if status.upper() == "FAILED":
|
|
105
|
+
base = max(base, 70)
|
|
106
|
+
|
|
107
|
+
# OpenAPI validation issues are a small additive risk.
|
|
108
|
+
issues = resp.get("openapi_validation") or []
|
|
109
|
+
if isinstance(issues, list) and issues:
|
|
110
|
+
base += 10
|
|
111
|
+
|
|
112
|
+
# SLA breach is a small additive risk.
|
|
113
|
+
if sla_ms is not None:
|
|
114
|
+
elapsed = resp.get("elapsed_ms")
|
|
115
|
+
if isinstance(elapsed, int) and elapsed > sla_ms:
|
|
116
|
+
base += 5
|
|
117
|
+
|
|
118
|
+
return clamp_int(int(base), 0, 100)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def compute_endpoint_risk_score(results: list[dict]) -> int:
|
|
122
|
+
"""Aggregate endpoint risk score.
|
|
123
|
+
|
|
124
|
+
Current policy: endpoint risk is the max risk_score across tests.
|
|
125
|
+
(This makes gating stable and easy to interpret.)
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
scores = [r.get("risk_score") for r in (results or []) if isinstance(r.get("risk_score"), int)]
|
|
129
|
+
if not scores:
|
|
130
|
+
return 0
|
|
131
|
+
return int(max(scores))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def gate_from_score(score: int, thresholds: RiskThresholds) -> str:
|
|
135
|
+
if score >= thresholds.block:
|
|
136
|
+
return "BLOCK"
|
|
137
|
+
if score >= thresholds.warn:
|
|
138
|
+
return "WARN"
|
|
139
|
+
return "PASS"
|
ai_testing_swarm/orchestrator.py
CHANGED
|
@@ -5,6 +5,12 @@ from ai_testing_swarm.agents.learning_agent import LearningAgent
|
|
|
5
5
|
from ai_testing_swarm.agents.release_gate_agent import ReleaseGateAgent
|
|
6
6
|
from ai_testing_swarm.reporting.report_writer import write_report
|
|
7
7
|
from ai_testing_swarm.core.safety import enforce_public_only
|
|
8
|
+
from ai_testing_swarm.core.risk import (
|
|
9
|
+
RiskThresholds,
|
|
10
|
+
compute_test_risk_score,
|
|
11
|
+
compute_endpoint_risk_score,
|
|
12
|
+
gate_from_score,
|
|
13
|
+
)
|
|
8
14
|
|
|
9
15
|
import logging
|
|
10
16
|
|
|
@@ -40,8 +46,20 @@ class SwarmOrchestrator:
|
|
|
40
46
|
self.learner = LearningAgent()
|
|
41
47
|
self.release_gate = ReleaseGateAgent()
|
|
42
48
|
|
|
43
|
-
def run(
|
|
44
|
-
|
|
49
|
+
def run(
|
|
50
|
+
self,
|
|
51
|
+
request: dict,
|
|
52
|
+
*,
|
|
53
|
+
report_format: str = "json",
|
|
54
|
+
gate_warn: int = 30,
|
|
55
|
+
gate_block: int = 80,
|
|
56
|
+
):
|
|
57
|
+
"""Runs the full AI testing swarm and returns (decision, results).
|
|
58
|
+
|
|
59
|
+
gate_warn/gate_block:
|
|
60
|
+
Thresholds for PASS/WARN/BLOCK gate based on endpoint risk.
|
|
61
|
+
(Kept optional for backward compatibility.)
|
|
62
|
+
"""
|
|
45
63
|
|
|
46
64
|
# Safety hook (currently no-op; kept for backward compatibility)
|
|
47
65
|
enforce_public_only(request["url"])
|
|
@@ -78,6 +96,19 @@ class SwarmOrchestrator:
|
|
|
78
96
|
else "FAILED"
|
|
79
97
|
),
|
|
80
98
|
})
|
|
99
|
+
|
|
100
|
+
# Batch1: numeric risk score per test (0..100)
|
|
101
|
+
try:
|
|
102
|
+
from ai_testing_swarm.core.config import AI_SWARM_SLA_MS
|
|
103
|
+
|
|
104
|
+
execution_result["risk_score"] = compute_test_risk_score(
|
|
105
|
+
execution_result,
|
|
106
|
+
sla_ms=AI_SWARM_SLA_MS,
|
|
107
|
+
)
|
|
108
|
+
except Exception:
|
|
109
|
+
# Keep backwards compatibility: don't fail the run if scoring breaks.
|
|
110
|
+
execution_result["risk_score"] = 0
|
|
111
|
+
|
|
81
112
|
# Optional learning step
|
|
82
113
|
try:
|
|
83
114
|
self.learner.learn(test_name, classification)
|
|
@@ -97,14 +128,23 @@ class SwarmOrchestrator:
|
|
|
97
128
|
results.append(results_by_name[t["name"]])
|
|
98
129
|
|
|
99
130
|
# --------------------------------------------------------
|
|
100
|
-
# 3️⃣ RELEASE DECISION
|
|
131
|
+
# 3️⃣ RELEASE DECISION + RISK GATE
|
|
101
132
|
# --------------------------------------------------------
|
|
102
133
|
decision = self.release_gate.decide(results)
|
|
103
134
|
|
|
135
|
+
thresholds = RiskThresholds(warn=int(gate_warn), block=int(gate_block))
|
|
136
|
+
endpoint_risk_score = compute_endpoint_risk_score(results)
|
|
137
|
+
gate_status = gate_from_score(endpoint_risk_score, thresholds)
|
|
138
|
+
|
|
104
139
|
# --------------------------------------------------------
|
|
105
|
-
# 4️⃣ WRITE
|
|
140
|
+
# 4️⃣ WRITE REPORT
|
|
106
141
|
# --------------------------------------------------------
|
|
107
|
-
meta = {
|
|
142
|
+
meta = {
|
|
143
|
+
"decision": decision,
|
|
144
|
+
"gate_status": gate_status,
|
|
145
|
+
"gate_thresholds": {"warn": thresholds.warn, "block": thresholds.block},
|
|
146
|
+
"endpoint_risk_score": endpoint_risk_score,
|
|
147
|
+
}
|
|
108
148
|
|
|
109
149
|
# Optional AI summary for humans (best-effort)
|
|
110
150
|
try:
|
|
@@ -122,8 +162,8 @@ class SwarmOrchestrator:
|
|
|
122
162
|
except Exception as e:
|
|
123
163
|
meta["ai_summary_error"] = str(e)
|
|
124
164
|
|
|
125
|
-
report_path = write_report(request, results, meta=meta)
|
|
126
|
-
logger.info("📄 Swarm
|
|
127
|
-
print(f"📄 Swarm
|
|
165
|
+
report_path = write_report(request, results, meta=meta, report_format=report_format)
|
|
166
|
+
logger.info("📄 Swarm report written to: %s", report_path)
|
|
167
|
+
print(f"📄 Swarm report written to: {report_path}")
|
|
128
168
|
|
|
129
169
|
return decision, results
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _html_escape(s: object) -> str:
|
|
9
|
+
return (
|
|
10
|
+
str(s)
|
|
11
|
+
.replace("&", "&")
|
|
12
|
+
.replace("<", "<")
|
|
13
|
+
.replace(">", ">")
|
|
14
|
+
.replace('"', """)
|
|
15
|
+
.replace("'", "'")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class EndpointRow:
|
|
21
|
+
endpoint_dir: str
|
|
22
|
+
endpoint: str
|
|
23
|
+
run_time: str
|
|
24
|
+
gate_status: str
|
|
25
|
+
endpoint_risk_score: int
|
|
26
|
+
decision: str
|
|
27
|
+
report_relpath: str
|
|
28
|
+
top_risks: list[dict]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _latest_json_report(endpoint_dir: Path) -> Path | None:
|
|
32
|
+
if not endpoint_dir.exists() or not endpoint_dir.is_dir():
|
|
33
|
+
return None
|
|
34
|
+
candidates = sorted(endpoint_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
35
|
+
return candidates[0] if candidates else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_report(p: Path) -> dict | None:
|
|
39
|
+
try:
|
|
40
|
+
return json.loads(p.read_text(encoding="utf-8"))
|
|
41
|
+
except Exception:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def write_dashboard_index(reports_dir: Path) -> str:
|
|
46
|
+
"""Create/overwrite a simple static index.html under reports_dir."""
|
|
47
|
+
|
|
48
|
+
reports_dir = Path(reports_dir)
|
|
49
|
+
reports_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
rows: list[EndpointRow] = []
|
|
52
|
+
|
|
53
|
+
for child in sorted([p for p in reports_dir.iterdir() if p.is_dir()]):
|
|
54
|
+
latest = _latest_json_report(child)
|
|
55
|
+
if not latest:
|
|
56
|
+
continue
|
|
57
|
+
rpt = _load_report(latest)
|
|
58
|
+
if not rpt:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
meta = rpt.get("meta") or {}
|
|
62
|
+
summary = rpt.get("summary") or {}
|
|
63
|
+
|
|
64
|
+
endpoint_risk = meta.get("endpoint_risk_score")
|
|
65
|
+
if endpoint_risk is None:
|
|
66
|
+
endpoint_risk = summary.get("endpoint_risk_score", 0)
|
|
67
|
+
|
|
68
|
+
row = EndpointRow(
|
|
69
|
+
endpoint_dir=child.name,
|
|
70
|
+
endpoint=str(rpt.get("endpoint") or child.name),
|
|
71
|
+
run_time=str(rpt.get("run_time") or ""),
|
|
72
|
+
gate_status=str(meta.get("gate_status") or ""),
|
|
73
|
+
endpoint_risk_score=int(endpoint_risk or 0),
|
|
74
|
+
decision=str(meta.get("decision") or ""),
|
|
75
|
+
report_relpath=str(child.name + "/" + latest.name),
|
|
76
|
+
top_risks=list(summary.get("top_risks") or []),
|
|
77
|
+
)
|
|
78
|
+
rows.append(row)
|
|
79
|
+
|
|
80
|
+
# Sort by risk (desc) then recent
|
|
81
|
+
rows.sort(key=lambda r: (r.endpoint_risk_score, r.run_time), reverse=True)
|
|
82
|
+
|
|
83
|
+
# Global top risks across endpoints
|
|
84
|
+
global_risks = []
|
|
85
|
+
for r in rows:
|
|
86
|
+
for item in (r.top_risks or [])[:3]:
|
|
87
|
+
global_risks.append(
|
|
88
|
+
{
|
|
89
|
+
"endpoint": r.endpoint,
|
|
90
|
+
"name": item.get("name"),
|
|
91
|
+
"risk_score": item.get("risk_score"),
|
|
92
|
+
"status": item.get("status"),
|
|
93
|
+
"failure_type": item.get("failure_type"),
|
|
94
|
+
"report": r.report_relpath,
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
global_risks.sort(key=lambda x: int(x.get("risk_score") or 0), reverse=True)
|
|
98
|
+
global_risks = global_risks[:15]
|
|
99
|
+
|
|
100
|
+
def badge(gate: str) -> str:
|
|
101
|
+
gate = (gate or "").upper()
|
|
102
|
+
cls = {"PASS": "pass", "WARN": "warn", "BLOCK": "block"}.get(gate, "")
|
|
103
|
+
return f"<span class='gate {cls}'>{_html_escape(gate)}</span>"
|
|
104
|
+
|
|
105
|
+
endpoint_rows_html = "".join(
|
|
106
|
+
"<tr>"
|
|
107
|
+
f"<td>{badge(r.gate_status)}</td>"
|
|
108
|
+
f"<td><code>{_html_escape(r.endpoint_risk_score)}</code></td>"
|
|
109
|
+
f"<td><a href='{_html_escape(r.report_relpath)}'>{_html_escape(r.endpoint)}</a></td>"
|
|
110
|
+
f"<td><code>{_html_escape(r.run_time)}</code></td>"
|
|
111
|
+
f"<td><code>{_html_escape(r.decision)}</code></td>"
|
|
112
|
+
"</tr>"
|
|
113
|
+
for r in rows
|
|
114
|
+
) or "<tr><td colspan='5'>(no JSON reports found)</td></tr>"
|
|
115
|
+
|
|
116
|
+
top_risks_html = "".join(
|
|
117
|
+
"<tr>"
|
|
118
|
+
f"<td><code>{_html_escape(x.get('risk_score'))}</code></td>"
|
|
119
|
+
f"<td>{_html_escape(x.get('status'))}</td>"
|
|
120
|
+
f"<td><code>{_html_escape(x.get('failure_type'))}</code></td>"
|
|
121
|
+
f"<td>{_html_escape(x.get('name'))}</td>"
|
|
122
|
+
f"<td><a href='{_html_escape(x.get('report'))}'>{_html_escape(x.get('endpoint'))}</a></td>"
|
|
123
|
+
"</tr>"
|
|
124
|
+
for x in global_risks
|
|
125
|
+
) or "<tr><td colspan='5'>(none)</td></tr>"
|
|
126
|
+
|
|
127
|
+
css = """
|
|
128
|
+
body{font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Arial; margin:20px;}
|
|
129
|
+
table{border-collapse:collapse; width:100%;}
|
|
130
|
+
th,td{border:1px solid #ddd; padding:8px; vertical-align:top}
|
|
131
|
+
th{background:#f6f7f9; text-align:left}
|
|
132
|
+
code{background:#f1f2f4; padding:1px 5px; border-radius:6px;}
|
|
133
|
+
.gate{display:inline-block; padding:2px 10px; border-radius:999px; border:1px solid #aaa; font-size:12px;}
|
|
134
|
+
.gate.pass{background:#e6ffed; border-color:#36b37e;}
|
|
135
|
+
.gate.warn{background:#fff7e6; border-color:#ffab00;}
|
|
136
|
+
.gate.block{background:#ffebe6; border-color:#ff5630;}
|
|
137
|
+
.muted{color:#555}
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
html = f"""<!doctype html>
|
|
141
|
+
<html>
|
|
142
|
+
<head>
|
|
143
|
+
<meta charset='utf-8'/>
|
|
144
|
+
<title>AI Testing Swarm — Dashboard</title>
|
|
145
|
+
<style>{css}</style>
|
|
146
|
+
</head>
|
|
147
|
+
<body>
|
|
148
|
+
<h1>AI Testing Swarm — Dashboard</h1>
|
|
149
|
+
<p class='muted'>Static index generated from latest JSON report per endpoint.</p>
|
|
150
|
+
|
|
151
|
+
<h2>Endpoints</h2>
|
|
152
|
+
<table>
|
|
153
|
+
<thead>
|
|
154
|
+
<tr>
|
|
155
|
+
<th>Gate</th>
|
|
156
|
+
<th>Risk</th>
|
|
157
|
+
<th>Endpoint</th>
|
|
158
|
+
<th>Run time</th>
|
|
159
|
+
<th>Decision</th>
|
|
160
|
+
</tr>
|
|
161
|
+
</thead>
|
|
162
|
+
<tbody>
|
|
163
|
+
{endpoint_rows_html}
|
|
164
|
+
</tbody>
|
|
165
|
+
</table>
|
|
166
|
+
|
|
167
|
+
<h2>Top risks (across endpoints)</h2>
|
|
168
|
+
<table>
|
|
169
|
+
<thead>
|
|
170
|
+
<tr>
|
|
171
|
+
<th>Risk</th>
|
|
172
|
+
<th>Status</th>
|
|
173
|
+
<th>Failure type</th>
|
|
174
|
+
<th>Test</th>
|
|
175
|
+
<th>Endpoint</th>
|
|
176
|
+
</tr>
|
|
177
|
+
</thead>
|
|
178
|
+
<tbody>
|
|
179
|
+
{top_risks_html}
|
|
180
|
+
</tbody>
|
|
181
|
+
</table>
|
|
182
|
+
|
|
183
|
+
</body>
|
|
184
|
+
</html>"""
|
|
185
|
+
|
|
186
|
+
out = reports_dir / "index.html"
|
|
187
|
+
out.write_text(html, encoding="utf-8")
|
|
188
|
+
return str(out)
|
|
@@ -58,6 +58,7 @@ def extract_endpoint_name(method: str, url: str) -> str:
|
|
|
58
58
|
# 📝 REPORT WRITER
|
|
59
59
|
# ============================================================
|
|
60
60
|
from ai_testing_swarm.core.config import AI_SWARM_SLA_MS
|
|
61
|
+
from ai_testing_swarm.reporting.dashboard import write_dashboard_index
|
|
61
62
|
|
|
62
63
|
SENSITIVE_HEADER_KEYS = {
|
|
63
64
|
"authorization",
|
|
@@ -92,7 +93,194 @@ def _redact_results(results: list) -> list:
|
|
|
92
93
|
return redacted
|
|
93
94
|
|
|
94
95
|
|
|
95
|
-
def
|
|
96
|
+
def _markdown_escape(s: str) -> str:
|
|
97
|
+
return str(s).replace("`", "\\`")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _render_markdown(report: dict) -> str:
|
|
101
|
+
lines: list[str] = []
|
|
102
|
+
lines.append(f"# AI Testing Swarm Report")
|
|
103
|
+
lines.append("")
|
|
104
|
+
lines.append(f"**Endpoint:** `{_markdown_escape(report.get('endpoint'))}` ")
|
|
105
|
+
lines.append(f"**Run time:** `{_markdown_escape(report.get('run_time'))}` ")
|
|
106
|
+
lines.append(f"**Total tests:** `{report.get('total_tests')}`")
|
|
107
|
+
|
|
108
|
+
meta = report.get("meta") or {}
|
|
109
|
+
if meta.get("gate_status"):
|
|
110
|
+
lines.append(f"**Gate:** `{_markdown_escape(meta.get('gate_status'))}` ")
|
|
111
|
+
if meta.get("endpoint_risk_score") is not None:
|
|
112
|
+
lines.append(f"**Endpoint risk:** `{_markdown_escape(meta.get('endpoint_risk_score'))}`")
|
|
113
|
+
|
|
114
|
+
lines.append("")
|
|
115
|
+
|
|
116
|
+
summary = report.get("summary") or {}
|
|
117
|
+
counts_ft = summary.get("counts_by_failure_type") or {}
|
|
118
|
+
counts_sc = summary.get("counts_by_status_code") or {}
|
|
119
|
+
|
|
120
|
+
lines.append("## Summary")
|
|
121
|
+
lines.append("")
|
|
122
|
+
lines.append("### Counts by failure type")
|
|
123
|
+
for k, v in sorted(counts_ft.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
124
|
+
lines.append(f"- **{_markdown_escape(k)}**: {v}")
|
|
125
|
+
|
|
126
|
+
lines.append("")
|
|
127
|
+
lines.append("### Counts by status code")
|
|
128
|
+
for k, v in sorted(counts_sc.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
129
|
+
lines.append(f"- **{_markdown_escape(k)}**: {v}")
|
|
130
|
+
|
|
131
|
+
# Risky findings
|
|
132
|
+
results = report.get("results") or []
|
|
133
|
+
risky = [r for r in results if str(r.get("status")) == "RISK"]
|
|
134
|
+
failed = [r for r in results if str(r.get("status")) == "FAILED"]
|
|
135
|
+
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append("## Top risky findings")
|
|
138
|
+
if not risky and not failed:
|
|
139
|
+
lines.append("- (none)")
|
|
140
|
+
else:
|
|
141
|
+
top = (risky + failed)[:10]
|
|
142
|
+
for r in top:
|
|
143
|
+
resp = r.get("response") or {}
|
|
144
|
+
sc = resp.get("status_code")
|
|
145
|
+
lines.append(
|
|
146
|
+
f"- **{_markdown_escape(r.get('status'))}** `{_markdown_escape(r.get('name'))}` "
|
|
147
|
+
f"(risk={_markdown_escape(r.get('risk_score'))}, status={sc}, "
|
|
148
|
+
f"failure_type={_markdown_escape(r.get('failure_type'))})"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
return "\n".join(lines) + "\n"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _html_escape(s: str) -> str:
|
|
155
|
+
return (
|
|
156
|
+
str(s)
|
|
157
|
+
.replace("&", "&")
|
|
158
|
+
.replace("<", "<")
|
|
159
|
+
.replace(">", ">")
|
|
160
|
+
.replace('"', """)
|
|
161
|
+
.replace("'", "'")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _render_html(report: dict) -> str:
|
|
166
|
+
endpoint = _html_escape(report.get("endpoint"))
|
|
167
|
+
run_time = _html_escape(report.get("run_time"))
|
|
168
|
+
total_tests = report.get("total_tests")
|
|
169
|
+
summary = report.get("summary") or {}
|
|
170
|
+
meta = report.get("meta") or {}
|
|
171
|
+
|
|
172
|
+
results = report.get("results") or []
|
|
173
|
+
risky = [r for r in results if str(r.get("status")) == "RISK"]
|
|
174
|
+
failed = [r for r in results if str(r.get("status")) == "FAILED"]
|
|
175
|
+
top_risky = (risky + failed)[:10]
|
|
176
|
+
|
|
177
|
+
def _kv_list(d: dict) -> str:
|
|
178
|
+
items = sorted((d or {}).items(), key=lambda kv: (-kv[1], kv[0]))
|
|
179
|
+
return "".join(f"<li><b>{_html_escape(k)}</b>: {v}</li>" for k, v in items) or "<li>(none)</li>"
|
|
180
|
+
|
|
181
|
+
def _pre(obj) -> str:
|
|
182
|
+
try:
|
|
183
|
+
txt = json.dumps(obj, indent=2, ensure_ascii=False)
|
|
184
|
+
except Exception:
|
|
185
|
+
txt = str(obj)
|
|
186
|
+
return f"<pre>{_html_escape(txt)}</pre>"
|
|
187
|
+
|
|
188
|
+
rows = []
|
|
189
|
+
for r in results:
|
|
190
|
+
resp = r.get("response") or {}
|
|
191
|
+
issues = resp.get("openapi_validation") or []
|
|
192
|
+
status = _html_escape(r.get("status"))
|
|
193
|
+
name = _html_escape(r.get("name"))
|
|
194
|
+
failure_type = _html_escape(r.get("failure_type"))
|
|
195
|
+
sc = _html_escape(resp.get("status_code"))
|
|
196
|
+
elapsed = _html_escape(resp.get("elapsed_ms"))
|
|
197
|
+
risk_score = _html_escape(r.get("risk_score"))
|
|
198
|
+
badge = {
|
|
199
|
+
"PASSED": "badge passed",
|
|
200
|
+
"FAILED": "badge failed",
|
|
201
|
+
"RISK": "badge risk",
|
|
202
|
+
}.get(r.get("status"), "badge")
|
|
203
|
+
|
|
204
|
+
rows.append(
|
|
205
|
+
"<details class='case'>"
|
|
206
|
+
f"<summary><span class='{badge}'>{status}</span> "
|
|
207
|
+
f"<b>{name}</b> — risk={risk_score}, status={sc}, elapsed_ms={elapsed}, failure_type={failure_type}"
|
|
208
|
+
f"{(' — openapi_issues=' + str(len(issues))) if issues else ''}"
|
|
209
|
+
"</summary>"
|
|
210
|
+
"<div class='grid'>"
|
|
211
|
+
f"<div><h4>Request</h4>{_pre(r.get('request'))}</div>"
|
|
212
|
+
f"<div><h4>Response</h4>{_pre(resp)}</div>"
|
|
213
|
+
f"<div><h4>Reasoning</h4>{_pre({k: r.get(k) for k in ('reason','confidence','failure_type','status','risk_score')})}</div>"
|
|
214
|
+
"</div>"
|
|
215
|
+
"</details>"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
top_list = "".join(
|
|
219
|
+
f"<li><b>{_html_escape(r.get('status'))}</b> {_html_escape(r.get('name'))}"
|
|
220
|
+
f" (failure_type={_html_escape(r.get('failure_type'))})</li>"
|
|
221
|
+
for r in top_risky
|
|
222
|
+
) or "<li>(none)</li>"
|
|
223
|
+
|
|
224
|
+
css = """
|
|
225
|
+
body{font-family:ui-sans-serif,system-ui,Segoe UI,Roboto,Arial; margin:20px;}
|
|
226
|
+
.meta{color:#444;margin-bottom:16px}
|
|
227
|
+
.grid{display:grid; grid-template-columns: 1fr 1fr 1fr; gap:12px; margin-top:10px}
|
|
228
|
+
pre{background:#0b1020;color:#e6e6e6;padding:10px;border-radius:8px;overflow:auto;max-height:360px}
|
|
229
|
+
details.case{border:1px solid #ddd; border-radius:10px; padding:10px; margin:10px 0}
|
|
230
|
+
summary{cursor:pointer}
|
|
231
|
+
.badge{display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; margin-right:8px; border:1px solid #aaa}
|
|
232
|
+
.badge.passed{background:#e6ffed;border-color:#36b37e}
|
|
233
|
+
.badge.failed{background:#ffebe6;border-color:#ff5630}
|
|
234
|
+
.badge.risk{background:#fff7e6;border-color:#ffab00}
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
return f"""<!doctype html>
|
|
238
|
+
<html>
|
|
239
|
+
<head>
|
|
240
|
+
<meta charset='utf-8'/>
|
|
241
|
+
<title>AI Testing Swarm Report</title>
|
|
242
|
+
<style>{css}</style>
|
|
243
|
+
</head>
|
|
244
|
+
<body>
|
|
245
|
+
<h1>AI Testing Swarm Report</h1>
|
|
246
|
+
<div class='meta'>
|
|
247
|
+
<div><b>Endpoint:</b> <code>{endpoint}</code></div>
|
|
248
|
+
<div><b>Run time:</b> <code>{run_time}</code></div>
|
|
249
|
+
<div><b>Total tests:</b> <code>{total_tests}</code></div>
|
|
250
|
+
<div><b>Gate:</b> <code>{_html_escape(meta.get('gate_status'))}</code></div>
|
|
251
|
+
<div><b>Endpoint risk:</b> <code>{_html_escape(meta.get('endpoint_risk_score'))}</code></div>
|
|
252
|
+
</div>
|
|
253
|
+
|
|
254
|
+
<h2>Summary</h2>
|
|
255
|
+
<div class='grid'>
|
|
256
|
+
<div><h3>Counts by failure type</h3><ul>{_kv_list(summary.get('counts_by_failure_type') or {})}</ul></div>
|
|
257
|
+
<div><h3>Counts by status code</h3><ul>{_kv_list(summary.get('counts_by_status_code') or {})}</ul></div>
|
|
258
|
+
<div><h3>Top risky findings</h3><ul>{top_list}</ul></div>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<h2>Results</h2>
|
|
262
|
+
{''.join(rows)}
|
|
263
|
+
|
|
264
|
+
</body>
|
|
265
|
+
</html>"""
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def write_report(
|
|
269
|
+
request: dict,
|
|
270
|
+
results: list,
|
|
271
|
+
*,
|
|
272
|
+
meta: dict | None = None,
|
|
273
|
+
report_format: str = "json",
|
|
274
|
+
update_index: bool = True,
|
|
275
|
+
) -> str:
|
|
276
|
+
"""Write a swarm report.
|
|
277
|
+
|
|
278
|
+
report_format:
|
|
279
|
+
- json (default): full machine-readable report
|
|
280
|
+
- md: human-readable markdown summary
|
|
281
|
+
- html: single-file HTML report with collapsible sections
|
|
282
|
+
"""
|
|
283
|
+
|
|
96
284
|
REPORTS_DIR.mkdir(exist_ok=True)
|
|
97
285
|
|
|
98
286
|
method = request.get("method", "UNKNOWN")
|
|
@@ -101,21 +289,18 @@ def write_report(request: dict, results: list, *, meta: dict | None = None) -> s
|
|
|
101
289
|
endpoint_name = extract_endpoint_name(method, url)
|
|
102
290
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
103
291
|
|
|
104
|
-
# report_path = REPORTS_DIR / f"{timestamp}_{endpoint_name}.json"
|
|
105
|
-
# 🔥 PER-ENDPOINT FOLDER
|
|
106
292
|
endpoint_dir = REPORTS_DIR / endpoint_name
|
|
107
293
|
endpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
108
294
|
|
|
109
|
-
# 🔥 FILE NAME FORMAT
|
|
110
|
-
report_path = endpoint_dir / f"{endpoint_name}_{timestamp}.json"
|
|
111
|
-
|
|
112
295
|
safe_results = _redact_results(results)
|
|
113
296
|
|
|
114
|
-
# Summary (counts + performance)
|
|
115
297
|
summary = {
|
|
116
298
|
"counts_by_failure_type": {},
|
|
117
299
|
"counts_by_status_code": {},
|
|
118
300
|
"slow_tests": [],
|
|
301
|
+
# Batch1: risk aggregation (optional fields)
|
|
302
|
+
"endpoint_risk_score": 0,
|
|
303
|
+
"top_risks": [],
|
|
119
304
|
}
|
|
120
305
|
|
|
121
306
|
for r in safe_results:
|
|
@@ -130,6 +315,28 @@ def write_report(request: dict, results: list, *, meta: dict | None = None) -> s
|
|
|
130
315
|
if isinstance(elapsed, int) and elapsed > AI_SWARM_SLA_MS:
|
|
131
316
|
summary["slow_tests"].append({"name": r.get("name"), "elapsed_ms": elapsed})
|
|
132
317
|
|
|
318
|
+
# Batch1: endpoint risk summary (best-effort)
|
|
319
|
+
try:
|
|
320
|
+
scores = [r.get("risk_score") for r in safe_results if isinstance(r.get("risk_score"), int)]
|
|
321
|
+
summary["endpoint_risk_score"] = int(max(scores)) if scores else 0
|
|
322
|
+
top = sorted(
|
|
323
|
+
(
|
|
324
|
+
{
|
|
325
|
+
"name": r.get("name"),
|
|
326
|
+
"status": r.get("status"),
|
|
327
|
+
"failure_type": r.get("failure_type"),
|
|
328
|
+
"risk_score": r.get("risk_score"),
|
|
329
|
+
}
|
|
330
|
+
for r in safe_results
|
|
331
|
+
if isinstance(r.get("risk_score"), int)
|
|
332
|
+
),
|
|
333
|
+
key=lambda x: int(x.get("risk_score") or 0),
|
|
334
|
+
reverse=True,
|
|
335
|
+
)
|
|
336
|
+
summary["top_risks"] = top[:10]
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
133
340
|
report = {
|
|
134
341
|
"endpoint": f"{method} {url}",
|
|
135
342
|
"run_time": timestamp,
|
|
@@ -139,9 +346,29 @@ def write_report(request: dict, results: list, *, meta: dict | None = None) -> s
|
|
|
139
346
|
"results": safe_results,
|
|
140
347
|
}
|
|
141
348
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
349
|
+
report_format = (report_format or "json").lower().strip()
|
|
350
|
+
if report_format not in {"json", "md", "html"}:
|
|
351
|
+
report_format = "json"
|
|
352
|
+
|
|
353
|
+
ext = {"json": "json", "md": "md", "html": "html"}[report_format]
|
|
354
|
+
report_path = endpoint_dir / f"{endpoint_name}_{timestamp}.{ext}"
|
|
355
|
+
|
|
356
|
+
if report_format == "json":
|
|
357
|
+
with open(report_path, "w", encoding="utf-8") as f:
|
|
358
|
+
json.dump(report, f, indent=2)
|
|
359
|
+
elif report_format == "md":
|
|
360
|
+
with open(report_path, "w", encoding="utf-8") as f:
|
|
361
|
+
f.write(_render_markdown(report))
|
|
362
|
+
else:
|
|
363
|
+
with open(report_path, "w", encoding="utf-8") as f:
|
|
364
|
+
f.write(_render_html(report))
|
|
365
|
+
|
|
366
|
+
# Batch1: update a simple static dashboard index.html
|
|
367
|
+
if update_index:
|
|
368
|
+
try:
|
|
369
|
+
write_dashboard_index(REPORTS_DIR)
|
|
370
|
+
except Exception:
|
|
371
|
+
# Dashboard is best-effort; never fail the run
|
|
372
|
+
pass
|
|
146
373
|
|
|
147
374
|
return str(report_path)
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-testing-swarm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.15
|
|
4
4
|
Summary: AI-powered testing swarm
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
7
7
|
Requires-Python: >=3.9
|
|
8
8
|
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: requests>=2.28
|
|
10
|
+
Requires-Dist: PyYAML>=6.0
|
|
11
|
+
Provides-Extra: openapi
|
|
12
|
+
Requires-Dist: jsonschema>=4.0; extra == "openapi"
|
|
9
13
|
|
|
10
14
|
# AI Testing Swarm
|
|
11
15
|
|
|
12
16
|
AI Testing Swarm is a **super-advanced, mutation-driven API testing framework** (with optional OpenAPI + OpenAI augmentation) built on top of **pytest**.
|
|
13
17
|
|
|
14
|
-
It generates a large set of deterministic negative/edge/security test cases for an API request, executes them (optionally in parallel, with retries/throttling), and produces a JSON
|
|
18
|
+
It generates a large set of deterministic negative/edge/security test cases for an API request, executes them (optionally in parallel, with retries/throttling), and produces a report (JSON/Markdown/HTML) with summaries.
|
|
15
19
|
|
|
16
20
|
> Notes:
|
|
17
21
|
> - UI testing is not the focus of the current releases.
|
|
@@ -25,6 +29,12 @@ It generates a large set of deterministic negative/edge/security test cases for
|
|
|
25
29
|
pip install ai-testing-swarm
|
|
26
30
|
```
|
|
27
31
|
|
|
32
|
+
Optional (OpenAPI JSON schema validation for responses):
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install "ai-testing-swarm[openapi]"
|
|
36
|
+
```
|
|
37
|
+
|
|
28
38
|
CLI entrypoint:
|
|
29
39
|
|
|
30
40
|
```bash
|
|
@@ -49,9 +59,15 @@ Run:
|
|
|
49
59
|
ai-test --input request.json
|
|
50
60
|
```
|
|
51
61
|
|
|
52
|
-
|
|
62
|
+
Choose a report format:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
ai-test --input request.json --report-format html
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A report is written under:
|
|
53
69
|
|
|
54
|
-
- `./ai_swarm_reports/<METHOD>_<endpoint>/<METHOD>_<endpoint>_<timestamp
|
|
70
|
+
- `./ai_swarm_reports/<METHOD>_<endpoint>/<METHOD>_<endpoint>_<timestamp>.<json|md|html>`
|
|
55
71
|
|
|
56
72
|
Reports include:
|
|
57
73
|
- per-test results
|
|
@@ -97,6 +113,8 @@ Reports include:
|
|
|
97
113
|
- OpenAPI **JSON** works by default.
|
|
98
114
|
- OpenAPI **YAML** requires `PyYAML` installed.
|
|
99
115
|
- Base URL is read from `spec.servers[0].url`.
|
|
116
|
+
- When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
|
|
117
|
+
- If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
|
|
100
118
|
- Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
|
|
101
119
|
|
|
102
120
|
---
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
ai_testing_swarm/__init__.py,sha256=
|
|
2
|
-
ai_testing_swarm/cli.py,sha256=
|
|
3
|
-
ai_testing_swarm/orchestrator.py,sha256=
|
|
1
|
+
ai_testing_swarm/__init__.py,sha256=qb0TalpSt1CbprnFyeLUKqgrqNtmnk9IoQQ7umAoXVY,23
|
|
2
|
+
ai_testing_swarm/cli.py,sha256=IeCU0E1Ju8hfkHXT4ySI1KVSvKAlYK429Fa5atcAjdo,5626
|
|
3
|
+
ai_testing_swarm/orchestrator.py,sha256=8uaUuyGy2lm3QzTNVl_8AeYtbyzrrxuykGQchRn1EEo,6295
|
|
4
4
|
ai_testing_swarm/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
-
ai_testing_swarm/agents/execution_agent.py,sha256=
|
|
5
|
+
ai_testing_swarm/agents/execution_agent.py,sha256=lkipsVOWe7agMt3kY9gDxjHfWujLtMh9DJCsAKhYzD8,8050
|
|
6
6
|
ai_testing_swarm/agents/learning_agent.py,sha256=lHqikc8A8s2-Yln_qCm8hCxB9KTqVrTcNvFp20_QQU0,777
|
|
7
7
|
ai_testing_swarm/agents/llm_reasoning_agent.py,sha256=nOA7Y8ixEh4sz67youSNPjFKipFSsfeup8XeSG4TvTg,8481
|
|
8
8
|
ai_testing_swarm/agents/release_gate_agent.py,sha256=ontmkoWe86CmId2aLWVmzxUVw_-T-F5dCxt40o27oZ8,5888
|
|
@@ -15,11 +15,14 @@ ai_testing_swarm/core/config.py,sha256=loZcH45TlUseZMYZ9pkYpsK4AH1PaQ7PLv-AOZq_n
|
|
|
15
15
|
ai_testing_swarm/core/curl_parser.py,sha256=0dPEhRGqn-4u00t-bp7kW9Ii7GSiO0wteB4kDhRKUBQ,1483
|
|
16
16
|
ai_testing_swarm/core/openai_client.py,sha256=gxbrZZZUh_jBcXfxtsBei2t72_xEQrKZ0Z10HjS62Ss,5668
|
|
17
17
|
ai_testing_swarm/core/openapi_loader.py,sha256=lZ1Y7lyyEL4Y90pc-naZQbCyNGndr4GmmUAvR71bpos,3639
|
|
18
|
+
ai_testing_swarm/core/openapi_validator.py,sha256=8oZNSCBSf6O6CyHZj9MB8Ojaqw54DzJqSm6pbF57HuE,5153
|
|
19
|
+
ai_testing_swarm/core/risk.py,sha256=9FtBWwq3_a1xZd5r-F78g9G6WaxA76xJBMe4LR90A_4,3646
|
|
18
20
|
ai_testing_swarm/core/safety.py,sha256=MvOMr7pKFAn0z37pBcE8X8dOqPK-juxhypCZQ4YcriI,825
|
|
19
21
|
ai_testing_swarm/reporting/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
20
|
-
ai_testing_swarm/reporting/
|
|
21
|
-
ai_testing_swarm
|
|
22
|
-
ai_testing_swarm-0.1.
|
|
23
|
-
ai_testing_swarm-0.1.
|
|
24
|
-
ai_testing_swarm-0.1.
|
|
25
|
-
ai_testing_swarm-0.1.
|
|
22
|
+
ai_testing_swarm/reporting/dashboard.py,sha256=W53SRD6ttT7C3VCcsB9nkXOGWaoRwzOp-cZaj1NcRx8,5682
|
|
23
|
+
ai_testing_swarm/reporting/report_writer.py,sha256=wLndx2VPPldGxbCuMyCaWd3VesVRLBPGQ8n9jyyTctI,12554
|
|
24
|
+
ai_testing_swarm-0.1.15.dist-info/METADATA,sha256=hAwNBIjwMxfpDBXHuomFLBHNM0csFl9ELwaXgAZtuyk,5562
|
|
25
|
+
ai_testing_swarm-0.1.15.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
26
|
+
ai_testing_swarm-0.1.15.dist-info/entry_points.txt,sha256=vbW-IBcVcls5I-NA3xFUZxH4Ktevt7lA4w9P4Me0yXo,54
|
|
27
|
+
ai_testing_swarm-0.1.15.dist-info/top_level.txt,sha256=OSqbej3vG04SKqgEcgzDTMn8QzpVsxwOzpSG7quhWJw,17
|
|
28
|
+
ai_testing_swarm-0.1.15.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|