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.
@@ -1 +1 @@
1
- __version__ = "0.1.13"
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
- return build_request_from_openapi(
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(request)
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"
@@ -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(self, request: dict):
44
- """Runs the full AI testing swarm and returns (decision, results)."""
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 JSON REPORT
140
+ # 4️⃣ WRITE REPORT
106
141
  # --------------------------------------------------------
107
- meta = {"decision": decision}
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 JSON report written to: %s", report_path)
127
- print(f"📄 Swarm JSON report written to: {report_path}")
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("&", "&amp;")
12
+ .replace("<", "&lt;")
13
+ .replace(">", "&gt;")
14
+ .replace('"', "&quot;")
15
+ .replace("'", "&#39;")
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 write_report(request: dict, results: list, *, meta: dict | None = None) -> str:
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("&", "&amp;")
158
+ .replace("<", "&lt;")
159
+ .replace(">", "&gt;")
160
+ .replace('"', "&quot;")
161
+ .replace("'", "&#39;")
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
- with open(report_path, "w") as f:
143
- json.dump(report, f, indent=2)
144
-
145
- print(f"📄 Swarm JSON report written to: {report_path}")
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.13
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 report with summaries.
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
- A JSON report is written under:
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>.json`
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=khDKUuWafURKVs5EAZkpOMiUHI2-V7axlqrWLPUpuZo,23
2
- ai_testing_swarm/cli.py,sha256=o7JVbC5wNBFxAW9Ox8688JKA78RbY9eBdhUiWJvtgc0,4628
3
- ai_testing_swarm/orchestrator.py,sha256=WXOjMHwIEr054JkXbvIzmcU8yDYJ1qMP19kPmE7x0J4,4913
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=C4femMQLw8CPxxmaqinEcmweeDBAzkAOI3Df6CZOSIQ,6678
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/report_writer.py,sha256=LvqKbhg3RUhEH_mWKgJQS0OmH6atdMvba9BQvqPcMZY,4227
21
- ai_testing_swarm-0.1.13.dist-info/METADATA,sha256=8c3_vsgmcfTOGUDIygejtMT1xwjS-XugAPo2B1pZETc,4919
22
- ai_testing_swarm-0.1.13.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
23
- ai_testing_swarm-0.1.13.dist-info/entry_points.txt,sha256=vbW-IBcVcls5I-NA3xFUZxH4Ktevt7lA4w9P4Me0yXo,54
24
- ai_testing_swarm-0.1.13.dist-info/top_level.txt,sha256=OSqbej3vG04SKqgEcgzDTMn8QzpVsxwOzpSG7quhWJw,17
25
- ai_testing_swarm-0.1.13.dist-info/RECORD,,
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,,