ai-testing-swarm 0.1.15__py3-none-any.whl → 0.1.17__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/test_planner_agent.py +52 -1
- ai_testing_swarm/cli.py +126 -22
- ai_testing_swarm/core/auth_matrix.py +93 -0
- ai_testing_swarm/core/config.py +6 -0
- ai_testing_swarm/core/openapi_fuzzer.py +396 -0
- ai_testing_swarm/core/risk.py +22 -0
- ai_testing_swarm/orchestrator.py +15 -1
- ai_testing_swarm/reporting/dashboard.py +35 -5
- ai_testing_swarm/reporting/report_writer.py +73 -0
- ai_testing_swarm/reporting/trend.py +110 -0
- {ai_testing_swarm-0.1.15.dist-info → ai_testing_swarm-0.1.17.dist-info}/METADATA +47 -2
- {ai_testing_swarm-0.1.15.dist-info → ai_testing_swarm-0.1.17.dist-info}/RECORD +16 -13
- {ai_testing_swarm-0.1.15.dist-info → ai_testing_swarm-0.1.17.dist-info}/WHEEL +0 -0
- {ai_testing_swarm-0.1.15.dist-info → ai_testing_swarm-0.1.17.dist-info}/entry_points.txt +0 -0
- {ai_testing_swarm-0.1.15.dist-info → ai_testing_swarm-0.1.17.dist-info}/top_level.txt +0 -0
ai_testing_swarm/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.17"
|
|
@@ -363,7 +363,58 @@ class TestPlannerAgent:
|
|
|
363
363
|
for key, value in body.items():
|
|
364
364
|
add_field_mutations("body", key, value)
|
|
365
365
|
|
|
366
|
-
# 6) Optional
|
|
366
|
+
# 6) Optional OpenAPI schema-based request-body fuzzing (best-effort)
|
|
367
|
+
try:
|
|
368
|
+
from ai_testing_swarm.core.config import (
|
|
369
|
+
AI_SWARM_OPENAPI_FUZZ,
|
|
370
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH,
|
|
371
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_INVALID,
|
|
372
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_VALID,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if AI_SWARM_OPENAPI_FUZZ and isinstance(request.get("_openapi"), dict):
|
|
376
|
+
ctx = request.get("_openapi") or {}
|
|
377
|
+
spec = ctx.get("spec")
|
|
378
|
+
path0 = ctx.get("path")
|
|
379
|
+
method0 = ctx.get("method")
|
|
380
|
+
|
|
381
|
+
if isinstance(spec, dict) and isinstance(path0, str) and isinstance(method0, str):
|
|
382
|
+
from ai_testing_swarm.core.openapi_fuzzer import generate_request_body_fuzz_cases
|
|
383
|
+
|
|
384
|
+
fuzz_cases = generate_request_body_fuzz_cases(
|
|
385
|
+
spec=spec,
|
|
386
|
+
path=path0,
|
|
387
|
+
method=method0,
|
|
388
|
+
max_valid=AI_SWARM_OPENAPI_FUZZ_MAX_VALID,
|
|
389
|
+
max_invalid=AI_SWARM_OPENAPI_FUZZ_MAX_INVALID,
|
|
390
|
+
max_depth=AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
existing = {t["name"] for t in tests}
|
|
394
|
+
for fc in fuzz_cases:
|
|
395
|
+
name = fc.name
|
|
396
|
+
if name in existing:
|
|
397
|
+
continue
|
|
398
|
+
tests.append(
|
|
399
|
+
{
|
|
400
|
+
"name": name,
|
|
401
|
+
"mutation": {
|
|
402
|
+
"target": "body_whole",
|
|
403
|
+
"path": [],
|
|
404
|
+
"operation": "REPLACE",
|
|
405
|
+
"original_value": body,
|
|
406
|
+
"new_value": fc.body,
|
|
407
|
+
"strategy": "openapi_fuzz_valid" if fc.is_valid else "openapi_fuzz_invalid",
|
|
408
|
+
"openapi": {"is_valid": fc.is_valid, "details": fc.details},
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
)
|
|
412
|
+
existing.add(name)
|
|
413
|
+
except Exception:
|
|
414
|
+
# Never fail planning due to fuzzing issues
|
|
415
|
+
pass
|
|
416
|
+
|
|
417
|
+
# 7) Optional OpenAI augmentation (best-effort)
|
|
367
418
|
try:
|
|
368
419
|
from ai_testing_swarm.core.config import (
|
|
369
420
|
AI_SWARM_MAX_AI_TESTS,
|
ai_testing_swarm/cli.py
CHANGED
|
@@ -107,6 +107,54 @@ def main():
|
|
|
107
107
|
help="Report format to write (default: json)",
|
|
108
108
|
)
|
|
109
109
|
|
|
110
|
+
# OpenAPI schema-based request-body fuzzing
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--openapi-fuzz",
|
|
113
|
+
dest="openapi_fuzz",
|
|
114
|
+
action="store_true",
|
|
115
|
+
default=True,
|
|
116
|
+
help="Enable OpenAPI schema-based request-body fuzzing when OpenAPI context is present (default: on)",
|
|
117
|
+
)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--no-openapi-fuzz",
|
|
120
|
+
dest="openapi_fuzz",
|
|
121
|
+
action="store_false",
|
|
122
|
+
help="Disable OpenAPI schema-based request-body fuzzing",
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--openapi-fuzz-max-valid",
|
|
126
|
+
type=int,
|
|
127
|
+
default=8,
|
|
128
|
+
help="Max schema-valid OpenAPI fuzz cases to add (default: 8)",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--openapi-fuzz-max-invalid",
|
|
132
|
+
type=int,
|
|
133
|
+
default=8,
|
|
134
|
+
help="Max schema-invalid OpenAPI fuzz cases to add (default: 8)",
|
|
135
|
+
)
|
|
136
|
+
parser.add_argument(
|
|
137
|
+
"--openapi-fuzz-max-depth",
|
|
138
|
+
type=int,
|
|
139
|
+
default=6,
|
|
140
|
+
help="Max recursion depth for OpenAPI schema instance generation (default: 6)",
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--fail-on-regression",
|
|
145
|
+
action="store_true",
|
|
146
|
+
help="Exit non-zero if the run regresses vs the previous JSON report for this endpoint",
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"--auth-matrix",
|
|
151
|
+
default="",
|
|
152
|
+
help=(
|
|
153
|
+
"Optional path to auth_matrix.yaml/json to run the same endpoint under multiple auth headers. "
|
|
154
|
+
"Each case is reported separately via a run label suffix."
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
|
|
110
158
|
# Batch1: risk gate thresholds (backward compatible defaults)
|
|
111
159
|
parser.add_argument(
|
|
112
160
|
"--gate-warn",
|
|
@@ -137,33 +185,89 @@ def main():
|
|
|
137
185
|
# ------------------------------------------------------------
|
|
138
186
|
# Run swarm
|
|
139
187
|
# ------------------------------------------------------------
|
|
188
|
+
import os
|
|
189
|
+
|
|
140
190
|
if args.public_only:
|
|
141
|
-
import os
|
|
142
191
|
os.environ["AI_SWARM_PUBLIC_ONLY"] = "1"
|
|
143
192
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
193
|
+
# CLI flags -> env toggles used by core.config
|
|
194
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ"] = "1" if args.openapi_fuzz else "0"
|
|
195
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_VALID"] = str(int(args.openapi_fuzz_max_valid))
|
|
196
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_INVALID"] = str(int(args.openapi_fuzz_max_invalid))
|
|
197
|
+
os.environ["AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH"] = str(int(args.openapi_fuzz_max_depth))
|
|
150
198
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
199
|
+
orch = SwarmOrchestrator()
|
|
200
|
+
|
|
201
|
+
def _print_console(decision, results, *, label: str = ""):
|
|
202
|
+
if label:
|
|
203
|
+
print(f"\n=== AUTH CASE: {label} ===")
|
|
204
|
+
print("\n=== RELEASE DECISION ===")
|
|
205
|
+
print(decision)
|
|
206
|
+
print("\n=== TEST RESULTS ===")
|
|
207
|
+
for r in results:
|
|
208
|
+
response = r.get("response", {})
|
|
209
|
+
status_code = response.get("status_code")
|
|
210
|
+
print(f"{r.get('name'):25} {str(status_code):5} {r.get('reason')}")
|
|
211
|
+
|
|
212
|
+
def _maybe_fail_on_regression(report_path: str):
|
|
213
|
+
try:
|
|
214
|
+
with open(report_path, "r", encoding="utf-8") as f:
|
|
215
|
+
report = json.load(f)
|
|
216
|
+
except Exception:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
trend = report.get("trend") or {}
|
|
220
|
+
regression_count = trend.get("regression_count")
|
|
221
|
+
try:
|
|
222
|
+
reg_i = int(regression_count or 0)
|
|
223
|
+
except Exception:
|
|
224
|
+
reg_i = 0
|
|
225
|
+
|
|
226
|
+
if reg_i > 0:
|
|
227
|
+
print(f"\n❌ Regression gate failed: regressions={reg_i} (see trend in report)")
|
|
228
|
+
raise SystemExit(2)
|
|
229
|
+
|
|
230
|
+
if args.auth_matrix:
|
|
231
|
+
from ai_testing_swarm.core.auth_matrix import load_auth_matrix, merge_auth_headers
|
|
232
|
+
|
|
233
|
+
cases = load_auth_matrix(args.auth_matrix)
|
|
234
|
+
for c in cases:
|
|
235
|
+
req2 = merge_auth_headers(request, c)
|
|
236
|
+
report_format = args.report_format
|
|
237
|
+
if args.fail_on_regression and report_format != "json":
|
|
238
|
+
# regression gate needs a machine-readable report
|
|
239
|
+
report_format = "json"
|
|
240
|
+
|
|
241
|
+
decision, results, report_path = orch.run(
|
|
242
|
+
req2,
|
|
243
|
+
report_format=report_format,
|
|
244
|
+
gate_warn=args.gate_warn,
|
|
245
|
+
gate_block=args.gate_block,
|
|
246
|
+
run_label=f"auth-{c.name}",
|
|
247
|
+
fail_on_regression=args.fail_on_regression,
|
|
248
|
+
return_report_path=True,
|
|
249
|
+
)
|
|
250
|
+
_print_console(decision, results, label=c.name)
|
|
251
|
+
|
|
252
|
+
if args.fail_on_regression:
|
|
253
|
+
_maybe_fail_on_regression(report_path)
|
|
254
|
+
else:
|
|
255
|
+
report_format = args.report_format
|
|
256
|
+
if args.fail_on_regression and report_format != "json":
|
|
257
|
+
report_format = "json"
|
|
258
|
+
|
|
259
|
+
decision, results, report_path = orch.run(
|
|
260
|
+
request,
|
|
261
|
+
report_format=report_format,
|
|
262
|
+
gate_warn=args.gate_warn,
|
|
263
|
+
gate_block=args.gate_block,
|
|
264
|
+
fail_on_regression=args.fail_on_regression,
|
|
265
|
+
return_report_path=True,
|
|
166
266
|
)
|
|
267
|
+
_print_console(decision, results)
|
|
268
|
+
|
|
269
|
+
if args.fail_on_regression:
|
|
270
|
+
_maybe_fail_on_regression(report_path)
|
|
167
271
|
|
|
168
272
|
|
|
169
273
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AuthCase:
|
|
12
|
+
name: str
|
|
13
|
+
headers: dict[str, str]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _sanitize_case_name(name: str) -> str:
|
|
17
|
+
name = str(name or "").strip()
|
|
18
|
+
if not name:
|
|
19
|
+
return "case"
|
|
20
|
+
# Keep it filesystem-friendly.
|
|
21
|
+
out = []
|
|
22
|
+
for ch in name:
|
|
23
|
+
if ch.isalnum() or ch in ("-", "_", "."):
|
|
24
|
+
out.append(ch)
|
|
25
|
+
else:
|
|
26
|
+
out.append("-")
|
|
27
|
+
return "".join(out).strip("-") or "case"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_auth_matrix(path: str | Path) -> list[AuthCase]:
|
|
31
|
+
"""Load an auth matrix config (yaml/json).
|
|
32
|
+
|
|
33
|
+
Schema:
|
|
34
|
+
{
|
|
35
|
+
"cases": [
|
|
36
|
+
{"name": "user", "headers": {"Authorization": "Bearer ..."}},
|
|
37
|
+
{"name": "admin", "headers": {"Authorization": "Bearer ..."}}
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
Notes:
|
|
42
|
+
- This is intentionally minimal and explicit.
|
|
43
|
+
- Headers are merged into the base request headers (case wins).
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
p = Path(path)
|
|
47
|
+
raw = p.read_text(encoding="utf-8")
|
|
48
|
+
if p.suffix.lower() in {".yaml", ".yml"}:
|
|
49
|
+
data = yaml.safe_load(raw) or {}
|
|
50
|
+
else:
|
|
51
|
+
data = json.loads(raw)
|
|
52
|
+
|
|
53
|
+
cases = data.get("cases") if isinstance(data, dict) else None
|
|
54
|
+
if not isinstance(cases, list) or not cases:
|
|
55
|
+
raise ValueError("auth matrix must contain a non-empty 'cases' list")
|
|
56
|
+
|
|
57
|
+
out: list[AuthCase] = []
|
|
58
|
+
for i, c in enumerate(cases):
|
|
59
|
+
if not isinstance(c, dict):
|
|
60
|
+
raise ValueError(f"auth case #{i} must be an object")
|
|
61
|
+
name = _sanitize_case_name(c.get("name") or f"case{i+1}")
|
|
62
|
+
headers = c.get("headers") or {}
|
|
63
|
+
if not isinstance(headers, dict):
|
|
64
|
+
raise ValueError(f"auth case '{name}' headers must be an object")
|
|
65
|
+
# stringify values (avoid accidental ints)
|
|
66
|
+
headers2 = {str(k): str(v) for k, v in headers.items() if v is not None}
|
|
67
|
+
out.append(AuthCase(name=name, headers=headers2))
|
|
68
|
+
|
|
69
|
+
# Ensure unique names
|
|
70
|
+
seen: set[str] = set()
|
|
71
|
+
uniq: list[AuthCase] = []
|
|
72
|
+
for c in out:
|
|
73
|
+
nm = c.name
|
|
74
|
+
if nm not in seen:
|
|
75
|
+
uniq.append(c)
|
|
76
|
+
seen.add(nm)
|
|
77
|
+
else:
|
|
78
|
+
j = 2
|
|
79
|
+
while f"{nm}-{j}" in seen:
|
|
80
|
+
j += 1
|
|
81
|
+
new = f"{nm}-{j}"
|
|
82
|
+
uniq.append(AuthCase(name=new, headers=c.headers))
|
|
83
|
+
seen.add(new)
|
|
84
|
+
|
|
85
|
+
return uniq
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def merge_auth_headers(request: dict, auth_case: AuthCase) -> dict:
|
|
89
|
+
req = dict(request)
|
|
90
|
+
base_headers = dict(req.get("headers") or {})
|
|
91
|
+
base_headers.update(auth_case.headers or {})
|
|
92
|
+
req["headers"] = base_headers
|
|
93
|
+
return req
|
ai_testing_swarm/core/config.py
CHANGED
|
@@ -20,3 +20,9 @@ AI_SWARM_USE_OPENAI = os.getenv("AI_SWARM_USE_OPENAI", "0").lower() in {"1", "tr
|
|
|
20
20
|
AI_SWARM_OPENAI_MODEL = os.getenv("AI_SWARM_OPENAI_MODEL", "gpt-4.1-mini")
|
|
21
21
|
AI_SWARM_MAX_AI_TESTS = env_int("AI_SWARM_MAX_AI_TESTS", 20)
|
|
22
22
|
AI_SWARM_AI_SUMMARY = os.getenv("AI_SWARM_AI_SUMMARY", "0").lower() in {"1", "true", "yes"}
|
|
23
|
+
|
|
24
|
+
# OpenAPI schema-based request-body fuzzing (on by default when OpenAPI context exists)
|
|
25
|
+
AI_SWARM_OPENAPI_FUZZ = os.getenv("AI_SWARM_OPENAPI_FUZZ", "1").lower() in {"1", "true", "yes"}
|
|
26
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_VALID = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_VALID", 8)
|
|
27
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_INVALID = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_INVALID", 8)
|
|
28
|
+
AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH = env_int("AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH", 6)
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
"""OpenAPI request-body schema based fuzzing.
|
|
4
|
+
|
|
5
|
+
This module is intentionally lightweight (no hypothesis/faker deps).
|
|
6
|
+
It generates:
|
|
7
|
+
- schema-valid "edge" payloads (still validate)
|
|
8
|
+
- schema-invalid payloads (should violate validation)
|
|
9
|
+
|
|
10
|
+
Used only when an OpenAPI spec is attached to the request.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class FuzzCase:
|
|
19
|
+
name: str
|
|
20
|
+
body: Any
|
|
21
|
+
is_valid: bool
|
|
22
|
+
details: dict | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_operation(spec: dict, *, path: str, method: str) -> dict | None:
|
|
26
|
+
paths = spec.get("paths") or {}
|
|
27
|
+
op = (paths.get(path) or {}).get(str(method).lower())
|
|
28
|
+
return op if isinstance(op, dict) else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_request_body_schema(*, spec: dict, path: str, method: str) -> dict | None:
|
|
32
|
+
op = _get_operation(spec, path=path, method=method)
|
|
33
|
+
if not op:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
rb = op.get("requestBody")
|
|
37
|
+
if not isinstance(rb, dict):
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
content = rb.get("content") or {}
|
|
41
|
+
if not isinstance(content, dict):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
# Prefer json
|
|
45
|
+
app_json = content.get("application/json") or content.get("application/*+json")
|
|
46
|
+
if not isinstance(app_json, dict):
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
schema = app_json.get("schema")
|
|
50
|
+
if isinstance(schema, dict):
|
|
51
|
+
return schema
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----------------------------
|
|
56
|
+
# Minimal schema resolution
|
|
57
|
+
# ----------------------------
|
|
58
|
+
|
|
59
|
+
def _resolve_ref(schema: dict, *, resolver) -> dict:
|
|
60
|
+
if "$ref" not in schema:
|
|
61
|
+
return schema
|
|
62
|
+
ref = schema.get("$ref")
|
|
63
|
+
try:
|
|
64
|
+
# jsonschema.RefResolver API
|
|
65
|
+
with resolver.resolving(ref) as resolved: # type: ignore[attr-defined]
|
|
66
|
+
if isinstance(resolved, dict):
|
|
67
|
+
return resolved
|
|
68
|
+
except Exception:
|
|
69
|
+
return schema
|
|
70
|
+
return schema
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _choose_subschema(schema: dict) -> dict:
|
|
74
|
+
"""Handle oneOf/anyOf/allOf in a deterministic best-effort manner."""
|
|
75
|
+
for key in ("oneOf", "anyOf"):
|
|
76
|
+
v = schema.get(key)
|
|
77
|
+
if isinstance(v, list) and v and isinstance(v[0], dict):
|
|
78
|
+
return v[0]
|
|
79
|
+
all_of = schema.get("allOf")
|
|
80
|
+
if isinstance(all_of, list) and all_of:
|
|
81
|
+
merged: dict = {}
|
|
82
|
+
for s in all_of:
|
|
83
|
+
if isinstance(s, dict):
|
|
84
|
+
# naive merge
|
|
85
|
+
merged.update({k: v for k, v in s.items() if k not in {"allOf"}})
|
|
86
|
+
return merged or schema
|
|
87
|
+
return schema
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _schema_type(schema: dict) -> str | None:
|
|
91
|
+
t = schema.get("type")
|
|
92
|
+
if isinstance(t, str):
|
|
93
|
+
return t
|
|
94
|
+
if isinstance(t, list) and t:
|
|
95
|
+
# choose first
|
|
96
|
+
if isinstance(t[0], str):
|
|
97
|
+
return t[0]
|
|
98
|
+
# Infer common
|
|
99
|
+
if "properties" in schema:
|
|
100
|
+
return "object"
|
|
101
|
+
if "items" in schema:
|
|
102
|
+
return "array"
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _string_example(schema: dict) -> str:
|
|
107
|
+
if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
|
|
108
|
+
return str(schema["enum"][0])
|
|
109
|
+
if "default" in schema:
|
|
110
|
+
return str(schema["default"])
|
|
111
|
+
if "example" in schema:
|
|
112
|
+
return str(schema["example"])
|
|
113
|
+
|
|
114
|
+
min_len = schema.get("minLength")
|
|
115
|
+
try:
|
|
116
|
+
min_len_i = int(min_len) if min_len is not None else 1
|
|
117
|
+
except Exception:
|
|
118
|
+
min_len_i = 1
|
|
119
|
+
|
|
120
|
+
fmt = str(schema.get("format") or "")
|
|
121
|
+
if fmt == "date-time":
|
|
122
|
+
base = "2020-01-01T00:00:00Z"
|
|
123
|
+
elif fmt == "date":
|
|
124
|
+
base = "2020-01-01"
|
|
125
|
+
elif fmt == "uuid":
|
|
126
|
+
base = "00000000-0000-0000-0000-000000000000"
|
|
127
|
+
elif fmt == "email":
|
|
128
|
+
base = "a@example.com"
|
|
129
|
+
else:
|
|
130
|
+
base = "x"
|
|
131
|
+
|
|
132
|
+
if len(base) >= min_len_i:
|
|
133
|
+
return base
|
|
134
|
+
return base + ("x" * (min_len_i - len(base)))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _number_example(schema: dict, *, is_int: bool) -> int | float:
|
|
138
|
+
for k in ("default", "example"):
|
|
139
|
+
if k in schema:
|
|
140
|
+
v = schema.get(k)
|
|
141
|
+
if isinstance(v, (int, float)):
|
|
142
|
+
return int(v) if is_int else float(v)
|
|
143
|
+
|
|
144
|
+
minimum = schema.get("minimum")
|
|
145
|
+
exclusive_minimum = schema.get("exclusiveMinimum")
|
|
146
|
+
|
|
147
|
+
if isinstance(exclusive_minimum, (int, float)):
|
|
148
|
+
x = exclusive_minimum + 1
|
|
149
|
+
return int(x) if is_int else float(x)
|
|
150
|
+
|
|
151
|
+
if isinstance(minimum, (int, float)):
|
|
152
|
+
return int(minimum) if is_int else float(minimum)
|
|
153
|
+
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def build_minimal_valid_instance(schema: dict, *, resolver=None, max_depth: int = 6):
|
|
158
|
+
if max_depth <= 0:
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
if resolver and isinstance(schema, dict) and "$ref" in schema:
|
|
162
|
+
schema = _resolve_ref(schema, resolver=resolver)
|
|
163
|
+
|
|
164
|
+
if not isinstance(schema, dict):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
schema = _choose_subschema(schema)
|
|
168
|
+
|
|
169
|
+
if "const" in schema:
|
|
170
|
+
return schema["const"]
|
|
171
|
+
|
|
172
|
+
t = _schema_type(schema)
|
|
173
|
+
|
|
174
|
+
if t == "object":
|
|
175
|
+
props = schema.get("properties") or {}
|
|
176
|
+
req = schema.get("required") or []
|
|
177
|
+
out: dict[str, Any] = {}
|
|
178
|
+
|
|
179
|
+
if isinstance(req, list):
|
|
180
|
+
for k in req:
|
|
181
|
+
if k in props and isinstance(props.get(k), dict):
|
|
182
|
+
out[str(k)] = build_minimal_valid_instance(props[k], resolver=resolver, max_depth=max_depth - 1)
|
|
183
|
+
|
|
184
|
+
# If nothing required, pick the first property to make something non-empty
|
|
185
|
+
if not out and isinstance(props, dict) and props:
|
|
186
|
+
first = next(iter(props.keys()))
|
|
187
|
+
if isinstance(props.get(first), dict):
|
|
188
|
+
out[str(first)] = build_minimal_valid_instance(props[first], resolver=resolver, max_depth=max_depth - 1)
|
|
189
|
+
|
|
190
|
+
return out
|
|
191
|
+
|
|
192
|
+
if t == "array":
|
|
193
|
+
items = schema.get("items")
|
|
194
|
+
if not isinstance(items, dict):
|
|
195
|
+
return []
|
|
196
|
+
min_items = schema.get("minItems")
|
|
197
|
+
try:
|
|
198
|
+
n = int(min_items) if min_items is not None else 1
|
|
199
|
+
except Exception:
|
|
200
|
+
n = 1
|
|
201
|
+
n = max(0, min(n, 3))
|
|
202
|
+
return [build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1) for _ in range(n)]
|
|
203
|
+
|
|
204
|
+
if t == "string":
|
|
205
|
+
return _string_example(schema)
|
|
206
|
+
|
|
207
|
+
if t == "integer":
|
|
208
|
+
return int(_number_example(schema, is_int=True))
|
|
209
|
+
|
|
210
|
+
if t == "number":
|
|
211
|
+
return float(_number_example(schema, is_int=False))
|
|
212
|
+
|
|
213
|
+
if t == "boolean":
|
|
214
|
+
return True
|
|
215
|
+
|
|
216
|
+
if t == "null":
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# unknown schema, best effort
|
|
220
|
+
if "enum" in schema and isinstance(schema.get("enum"), list) and schema["enum"]:
|
|
221
|
+
return schema["enum"][0]
|
|
222
|
+
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ----------------------------
|
|
227
|
+
# Case generation
|
|
228
|
+
# ----------------------------
|
|
229
|
+
|
|
230
|
+
def _validate(schema: dict, instance, *, spec: dict) -> bool:
|
|
231
|
+
try:
|
|
232
|
+
import jsonschema # type: ignore
|
|
233
|
+
|
|
234
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
235
|
+
cls = jsonschema.validators.validator_for(schema) # type: ignore[attr-defined]
|
|
236
|
+
cls.check_schema(schema)
|
|
237
|
+
v = cls(schema, resolver=resolver)
|
|
238
|
+
v.validate(instance)
|
|
239
|
+
return True
|
|
240
|
+
except Exception:
|
|
241
|
+
return False
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def generate_request_body_fuzz_cases(
|
|
245
|
+
*,
|
|
246
|
+
spec: dict,
|
|
247
|
+
path: str,
|
|
248
|
+
method: str,
|
|
249
|
+
max_valid: int = 8,
|
|
250
|
+
max_invalid: int = 8,
|
|
251
|
+
max_depth: int = 6,
|
|
252
|
+
) -> list[FuzzCase]:
|
|
253
|
+
"""Generate fuzz cases for an operation requestBody schema.
|
|
254
|
+
|
|
255
|
+
Requires optional dependency `jsonschema` (installed via ai-testing-swarm[openapi]).
|
|
256
|
+
Returns [] if schema missing or jsonschema not available.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
import jsonschema # type: ignore
|
|
261
|
+
except ImportError:
|
|
262
|
+
return []
|
|
263
|
+
|
|
264
|
+
schema = get_request_body_schema(spec=spec, path=path, method=method)
|
|
265
|
+
if not schema:
|
|
266
|
+
return []
|
|
267
|
+
|
|
268
|
+
resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
|
|
269
|
+
|
|
270
|
+
base = build_minimal_valid_instance(schema, resolver=resolver, max_depth=max_depth)
|
|
271
|
+
cases: list[FuzzCase] = []
|
|
272
|
+
|
|
273
|
+
# Always include a minimal valid payload
|
|
274
|
+
if _validate(schema, base, spec=spec):
|
|
275
|
+
cases.append(FuzzCase(name="openapi_valid_minimal", body=base, is_valid=True))
|
|
276
|
+
|
|
277
|
+
# Valid edge-ish variants
|
|
278
|
+
def add_valid(name: str, body, details: dict | None = None):
|
|
279
|
+
if len([c for c in cases if c.is_valid]) >= max_valid:
|
|
280
|
+
return
|
|
281
|
+
if _validate(schema, body, spec=spec):
|
|
282
|
+
cases.append(FuzzCase(name=name, body=body, is_valid=True, details=details))
|
|
283
|
+
|
|
284
|
+
def add_invalid(name: str, body, details: dict | None = None):
|
|
285
|
+
if len([c for c in cases if not c.is_valid]) >= max_invalid:
|
|
286
|
+
return
|
|
287
|
+
# Ensure invalid (best-effort)
|
|
288
|
+
if not _validate(schema, body, spec=spec):
|
|
289
|
+
cases.append(FuzzCase(name=name, body=body, is_valid=False, details=details))
|
|
290
|
+
|
|
291
|
+
# Only attempt structured mutations for object bodies
|
|
292
|
+
schema2 = _choose_subschema(_resolve_ref(schema, resolver=resolver) if isinstance(schema, dict) else {})
|
|
293
|
+
if _schema_type(schema2) == "object" and isinstance(base, dict):
|
|
294
|
+
props = schema2.get("properties") or {}
|
|
295
|
+
required = schema2.get("required") or []
|
|
296
|
+
additional = schema2.get("additionalProperties")
|
|
297
|
+
|
|
298
|
+
# Required field removal (invalid)
|
|
299
|
+
if isinstance(required, list) and required:
|
|
300
|
+
k = str(required[0])
|
|
301
|
+
if k in base:
|
|
302
|
+
b2 = dict(base)
|
|
303
|
+
b2.pop(k, None)
|
|
304
|
+
add_invalid("openapi_invalid_missing_required", b2, {"missing": k})
|
|
305
|
+
|
|
306
|
+
# Extra field when additionalProperties is false (invalid)
|
|
307
|
+
if additional is False:
|
|
308
|
+
b2 = dict(base)
|
|
309
|
+
b2["_unexpected"] = "x"
|
|
310
|
+
add_invalid("openapi_invalid_additional_property", b2)
|
|
311
|
+
|
|
312
|
+
# Per-property boundary tweaks
|
|
313
|
+
if isinstance(props, dict):
|
|
314
|
+
for pk, ps in list(props.items())[: max(1, (max_valid + max_invalid))]:
|
|
315
|
+
if not isinstance(ps, dict):
|
|
316
|
+
continue
|
|
317
|
+
ps = _choose_subschema(_resolve_ref(ps, resolver=resolver))
|
|
318
|
+
t = _schema_type(ps)
|
|
319
|
+
|
|
320
|
+
if t == "string":
|
|
321
|
+
min_len = ps.get("minLength")
|
|
322
|
+
max_len = ps.get("maxLength")
|
|
323
|
+
enum = ps.get("enum")
|
|
324
|
+
|
|
325
|
+
if isinstance(enum, list) and enum:
|
|
326
|
+
add_valid(f"openapi_valid_enum_{pk}", {**base, pk: enum[-1]})
|
|
327
|
+
|
|
328
|
+
if isinstance(min_len, int) and min_len >= 0:
|
|
329
|
+
add_valid(f"openapi_valid_minLength_{pk}", {**base, pk: "x" * min_len})
|
|
330
|
+
if min_len > 0:
|
|
331
|
+
add_invalid(f"openapi_invalid_below_minLength_{pk}", {**base, pk: ""})
|
|
332
|
+
|
|
333
|
+
if isinstance(max_len, int) and max_len >= 0:
|
|
334
|
+
add_valid(f"openapi_valid_maxLength_{pk}", {**base, pk: "x" * max_len})
|
|
335
|
+
add_invalid(f"openapi_invalid_above_maxLength_{pk}", {**base, pk: "x" * (max_len + 1)})
|
|
336
|
+
|
|
337
|
+
# wrong type
|
|
338
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: 123})
|
|
339
|
+
|
|
340
|
+
elif t in {"integer", "number"}:
|
|
341
|
+
minimum = ps.get("minimum")
|
|
342
|
+
maximum = ps.get("maximum")
|
|
343
|
+
if isinstance(minimum, (int, float)):
|
|
344
|
+
add_valid(f"openapi_valid_min_{pk}", {**base, pk: minimum})
|
|
345
|
+
add_invalid(f"openapi_invalid_below_min_{pk}", {**base, pk: minimum - 1})
|
|
346
|
+
if isinstance(maximum, (int, float)):
|
|
347
|
+
add_valid(f"openapi_valid_max_{pk}", {**base, pk: maximum})
|
|
348
|
+
add_invalid(f"openapi_invalid_above_max_{pk}", {**base, pk: maximum + 1})
|
|
349
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "x"})
|
|
350
|
+
|
|
351
|
+
elif t == "boolean":
|
|
352
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "true"})
|
|
353
|
+
|
|
354
|
+
elif t == "array":
|
|
355
|
+
items = ps.get("items") if isinstance(ps.get("items"), dict) else None
|
|
356
|
+
min_items = ps.get("minItems")
|
|
357
|
+
max_items = ps.get("maxItems")
|
|
358
|
+
if items:
|
|
359
|
+
item_ex = build_minimal_valid_instance(items, resolver=resolver, max_depth=max_depth - 1)
|
|
360
|
+
else:
|
|
361
|
+
item_ex = "x"
|
|
362
|
+
|
|
363
|
+
if isinstance(min_items, int) and min_items > 0:
|
|
364
|
+
add_valid(f"openapi_valid_minItems_{pk}", {**base, pk: [item_ex for _ in range(min_items)]})
|
|
365
|
+
add_invalid(f"openapi_invalid_below_minItems_{pk}", {**base, pk: []})
|
|
366
|
+
|
|
367
|
+
if isinstance(max_items, int) and max_items >= 0:
|
|
368
|
+
add_valid(f"openapi_valid_maxItems_{pk}", {**base, pk: [item_ex for _ in range(max_items)]})
|
|
369
|
+
add_invalid(
|
|
370
|
+
f"openapi_invalid_above_maxItems_{pk}",
|
|
371
|
+
{**base, pk: [item_ex for _ in range(max_items + 1)]},
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
add_invalid(f"openapi_invalid_type_{pk}", {**base, pk: "not-an-array"})
|
|
375
|
+
|
|
376
|
+
# Ensure we have at least one invalid case
|
|
377
|
+
add_invalid("openapi_invalid_body_null", None)
|
|
378
|
+
|
|
379
|
+
else:
|
|
380
|
+
# For non-object bodies, do a couple generic cases
|
|
381
|
+
add_invalid("openapi_invalid_body_null", None)
|
|
382
|
+
add_invalid("openapi_invalid_wrong_type", {"_": "x"} if not isinstance(base, dict) else "x")
|
|
383
|
+
|
|
384
|
+
# De-dupe by name
|
|
385
|
+
seen: set[str] = set()
|
|
386
|
+
out: list[FuzzCase] = []
|
|
387
|
+
for c in cases:
|
|
388
|
+
if c.name in seen:
|
|
389
|
+
continue
|
|
390
|
+
seen.add(c.name)
|
|
391
|
+
out.append(c)
|
|
392
|
+
|
|
393
|
+
# Cap total
|
|
394
|
+
valid = [c for c in out if c.is_valid][:max_valid]
|
|
395
|
+
invalid = [c for c in out if not c.is_valid][:max_invalid]
|
|
396
|
+
return valid + invalid
|
ai_testing_swarm/core/risk.py
CHANGED
|
@@ -67,15 +67,22 @@ def compute_test_risk_score(result: dict, *, sla_ms: int | None = None) -> int:
|
|
|
67
67
|
Inputs expected (best-effort):
|
|
68
68
|
- result['failure_type']
|
|
69
69
|
- result['status']
|
|
70
|
+
- result['mutation']['strategy'] (optional)
|
|
70
71
|
- result['response']['status_code']
|
|
71
72
|
- result['response']['elapsed_ms']
|
|
72
73
|
- result['response']['openapi_validation'] (list)
|
|
73
74
|
|
|
74
75
|
Returns: int in range 0..100.
|
|
76
|
+
|
|
77
|
+
Batch2: strategy-aware weighting.
|
|
78
|
+
The same failure_type can be more/less severe depending on the test strategy.
|
|
75
79
|
"""
|
|
76
80
|
|
|
77
81
|
ft = str(result.get("failure_type") or "unknown")
|
|
78
82
|
status = str(result.get("status") or "")
|
|
83
|
+
mutation = result.get("mutation") or {}
|
|
84
|
+
strategy = str(mutation.get("strategy") or "").strip().lower()
|
|
85
|
+
|
|
79
86
|
resp = result.get("response") or {}
|
|
80
87
|
sc = resp.get("status_code")
|
|
81
88
|
|
|
@@ -90,6 +97,21 @@ def compute_test_risk_score(result: dict, *, sla_ms: int | None = None) -> int:
|
|
|
90
97
|
# Unknown failure types are treated as high risk but not always a hard blocker.
|
|
91
98
|
base = 60
|
|
92
99
|
|
|
100
|
+
# Strategy-aware overrides (only when the strategy is known).
|
|
101
|
+
# These are designed to stay deterministic and explainable.
|
|
102
|
+
if strategy == "security" and ft == "security_risk":
|
|
103
|
+
base = max(base, 100)
|
|
104
|
+
if strategy in {"missing_param", "null_param", "invalid_param"} and ft.endswith("_accepted"):
|
|
105
|
+
# Validation bypass signals.
|
|
106
|
+
base = max(base, 80)
|
|
107
|
+
if strategy == "headers" and ft == "headers_accepted":
|
|
108
|
+
base = max(base, 55)
|
|
109
|
+
if strategy == "method_misuse" and ft == "method_risk":
|
|
110
|
+
base = max(base, 85)
|
|
111
|
+
if strategy == "auth" and ft == "auth_issue":
|
|
112
|
+
# Often indicates environment/config drift rather than product risk.
|
|
113
|
+
base = min(base, 70)
|
|
114
|
+
|
|
93
115
|
# Status-code adjustments (defense in depth)
|
|
94
116
|
if isinstance(sc, int):
|
|
95
117
|
if 500 <= sc:
|
ai_testing_swarm/orchestrator.py
CHANGED
|
@@ -53,12 +53,19 @@ class SwarmOrchestrator:
|
|
|
53
53
|
report_format: str = "json",
|
|
54
54
|
gate_warn: int = 30,
|
|
55
55
|
gate_block: int = 80,
|
|
56
|
+
run_label: str | None = None,
|
|
57
|
+
fail_on_regression: bool = False,
|
|
58
|
+
return_report_path: bool = False,
|
|
56
59
|
):
|
|
57
|
-
"""Runs the full AI testing swarm
|
|
60
|
+
"""Runs the full AI testing swarm.
|
|
58
61
|
|
|
59
62
|
gate_warn/gate_block:
|
|
60
63
|
Thresholds for PASS/WARN/BLOCK gate based on endpoint risk.
|
|
61
64
|
(Kept optional for backward compatibility.)
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
- default: (decision, results)
|
|
68
|
+
- if return_report_path=True: (decision, results, report_path)
|
|
62
69
|
"""
|
|
63
70
|
|
|
64
71
|
# Safety hook (currently no-op; kept for backward compatibility)
|
|
@@ -145,6 +152,11 @@ class SwarmOrchestrator:
|
|
|
145
152
|
"gate_thresholds": {"warn": thresholds.warn, "block": thresholds.block},
|
|
146
153
|
"endpoint_risk_score": endpoint_risk_score,
|
|
147
154
|
}
|
|
155
|
+
if run_label:
|
|
156
|
+
meta["run_label"] = str(run_label)
|
|
157
|
+
|
|
158
|
+
if fail_on_regression:
|
|
159
|
+
meta["fail_on_regression"] = True
|
|
148
160
|
|
|
149
161
|
# Optional AI summary for humans (best-effort)
|
|
150
162
|
try:
|
|
@@ -166,4 +178,6 @@ class SwarmOrchestrator:
|
|
|
166
178
|
logger.info("📄 Swarm report written to: %s", report_path)
|
|
167
179
|
print(f"📄 Swarm report written to: {report_path}")
|
|
168
180
|
|
|
181
|
+
if return_report_path:
|
|
182
|
+
return decision, results, report_path
|
|
169
183
|
return decision, results
|
|
@@ -26,6 +26,8 @@ class EndpointRow:
|
|
|
26
26
|
decision: str
|
|
27
27
|
report_relpath: str
|
|
28
28
|
top_risks: list[dict]
|
|
29
|
+
risk_delta: int | None = None
|
|
30
|
+
regression_count: int = 0
|
|
29
31
|
|
|
30
32
|
|
|
31
33
|
def _latest_json_report(endpoint_dir: Path) -> Path | None:
|
|
@@ -65,6 +67,19 @@ def write_dashboard_index(reports_dir: Path) -> str:
|
|
|
65
67
|
if endpoint_risk is None:
|
|
66
68
|
endpoint_risk = summary.get("endpoint_risk_score", 0)
|
|
67
69
|
|
|
70
|
+
trend = rpt.get("trend") or {}
|
|
71
|
+
risk_delta = trend.get("endpoint_risk_delta") if isinstance(trend, dict) else None
|
|
72
|
+
try:
|
|
73
|
+
risk_delta = int(risk_delta) if risk_delta is not None else None
|
|
74
|
+
except Exception:
|
|
75
|
+
risk_delta = None
|
|
76
|
+
|
|
77
|
+
regression_count = 0
|
|
78
|
+
try:
|
|
79
|
+
regression_count = int(trend.get("regression_count") or 0) if isinstance(trend, dict) else 0
|
|
80
|
+
except Exception:
|
|
81
|
+
regression_count = 0
|
|
82
|
+
|
|
68
83
|
row = EndpointRow(
|
|
69
84
|
endpoint_dir=child.name,
|
|
70
85
|
endpoint=str(rpt.get("endpoint") or child.name),
|
|
@@ -74,11 +89,13 @@ def write_dashboard_index(reports_dir: Path) -> str:
|
|
|
74
89
|
decision=str(meta.get("decision") or ""),
|
|
75
90
|
report_relpath=str(child.name + "/" + latest.name),
|
|
76
91
|
top_risks=list(summary.get("top_risks") or []),
|
|
92
|
+
risk_delta=risk_delta,
|
|
93
|
+
regression_count=regression_count,
|
|
77
94
|
)
|
|
78
95
|
rows.append(row)
|
|
79
96
|
|
|
80
|
-
#
|
|
81
|
-
rows.sort(key=lambda r: (r.endpoint_risk_score, r.run_time), reverse=True)
|
|
97
|
+
# Batch2: surface regressions first, then risk (desc), then recency.
|
|
98
|
+
rows.sort(key=lambda r: (int(r.regression_count or 0), r.endpoint_risk_score, r.run_time), reverse=True)
|
|
82
99
|
|
|
83
100
|
# Global top risks across endpoints
|
|
84
101
|
global_risks = []
|
|
@@ -102,16 +119,24 @@ def write_dashboard_index(reports_dir: Path) -> str:
|
|
|
102
119
|
cls = {"PASS": "pass", "WARN": "warn", "BLOCK": "block"}.get(gate, "")
|
|
103
120
|
return f"<span class='gate {cls}'>{_html_escape(gate)}</span>"
|
|
104
121
|
|
|
122
|
+
def _delta_badge(d: int | None) -> str:
|
|
123
|
+
if d is None:
|
|
124
|
+
return ""
|
|
125
|
+
cls = "pos" if d > 0 else "neg" if d < 0 else "zero"
|
|
126
|
+
sign = "+" if d > 0 else ""
|
|
127
|
+
return f"<span class='delta {cls}'>{sign}{_html_escape(d)}</span>"
|
|
128
|
+
|
|
105
129
|
endpoint_rows_html = "".join(
|
|
106
130
|
"<tr>"
|
|
107
131
|
f"<td>{badge(r.gate_status)}</td>"
|
|
108
|
-
f"<td><code>{_html_escape(r.endpoint_risk_score)}</code
|
|
132
|
+
f"<td><code>{_html_escape(r.endpoint_risk_score)}</code> {_delta_badge(r.risk_delta)}</td>"
|
|
133
|
+
f"<td><code>{_html_escape(r.regression_count)}</code></td>"
|
|
109
134
|
f"<td><a href='{_html_escape(r.report_relpath)}'>{_html_escape(r.endpoint)}</a></td>"
|
|
110
135
|
f"<td><code>{_html_escape(r.run_time)}</code></td>"
|
|
111
136
|
f"<td><code>{_html_escape(r.decision)}</code></td>"
|
|
112
137
|
"</tr>"
|
|
113
138
|
for r in rows
|
|
114
|
-
) or "<tr><td colspan='
|
|
139
|
+
) or "<tr><td colspan='6'>(no JSON reports found)</td></tr>"
|
|
115
140
|
|
|
116
141
|
top_risks_html = "".join(
|
|
117
142
|
"<tr>"
|
|
@@ -135,6 +160,10 @@ def write_dashboard_index(reports_dir: Path) -> str:
|
|
|
135
160
|
.gate.warn{background:#fff7e6; border-color:#ffab00;}
|
|
136
161
|
.gate.block{background:#ffebe6; border-color:#ff5630;}
|
|
137
162
|
.muted{color:#555}
|
|
163
|
+
.delta{display:inline-block; margin-left:6px; padding:1px 8px; border-radius:999px; font-size:12px; border:1px solid #bbb;}
|
|
164
|
+
.delta.pos{background:#ffebe6; border-color:#ff5630;}
|
|
165
|
+
.delta.neg{background:#e6ffed; border-color:#36b37e;}
|
|
166
|
+
.delta.zero{background:#f1f2f4; border-color:#bbb;}
|
|
138
167
|
"""
|
|
139
168
|
|
|
140
169
|
html = f"""<!doctype html>
|
|
@@ -153,7 +182,8 @@ def write_dashboard_index(reports_dir: Path) -> str:
|
|
|
153
182
|
<thead>
|
|
154
183
|
<tr>
|
|
155
184
|
<th>Gate</th>
|
|
156
|
-
<th>Risk</th>
|
|
185
|
+
<th>Risk (Δ)</th>
|
|
186
|
+
<th>Regressions</th>
|
|
157
187
|
<th>Endpoint</th>
|
|
158
188
|
<th>Run time</th>
|
|
159
189
|
<th>Decision</th>
|
|
@@ -113,6 +113,26 @@ def _render_markdown(report: dict) -> str:
|
|
|
113
113
|
|
|
114
114
|
lines.append("")
|
|
115
115
|
|
|
116
|
+
# Batch2: trend
|
|
117
|
+
trend = report.get("trend") or {}
|
|
118
|
+
if trend.get("has_previous"):
|
|
119
|
+
lines.append("## Trend vs previous run")
|
|
120
|
+
lines.append("")
|
|
121
|
+
lines.append(f"- **Endpoint risk delta:** `{trend.get('endpoint_risk_delta')}` (prev={trend.get('endpoint_risk_prev')})")
|
|
122
|
+
lines.append(f"- **Regressions:** `{trend.get('regression_count')}`")
|
|
123
|
+
regs = trend.get("regressions") or []
|
|
124
|
+
if regs:
|
|
125
|
+
lines.append("")
|
|
126
|
+
lines.append("### Regressed tests")
|
|
127
|
+
for x in regs[:10]:
|
|
128
|
+
lines.append(
|
|
129
|
+
"- "
|
|
130
|
+
f"`{_markdown_escape(x.get('name'))}`: "
|
|
131
|
+
f"{_markdown_escape(x.get('prev_status'))}→{_markdown_escape(x.get('curr_status'))} "
|
|
132
|
+
f"(risk {x.get('prev_risk_score')}→{x.get('curr_risk_score')})"
|
|
133
|
+
)
|
|
134
|
+
lines.append("")
|
|
135
|
+
|
|
116
136
|
summary = report.get("summary") or {}
|
|
117
137
|
counts_ft = summary.get("counts_by_failure_type") or {}
|
|
118
138
|
counts_sc = summary.get("counts_by_status_code") or {}
|
|
@@ -174,6 +194,28 @@ def _render_html(report: dict) -> str:
|
|
|
174
194
|
failed = [r for r in results if str(r.get("status")) == "FAILED"]
|
|
175
195
|
top_risky = (risky + failed)[:10]
|
|
176
196
|
|
|
197
|
+
trend = report.get("trend") or {}
|
|
198
|
+
trend_html = ""
|
|
199
|
+
if trend.get("has_previous"):
|
|
200
|
+
regs = trend.get("regressions") or []
|
|
201
|
+
items = "".join(
|
|
202
|
+
"<li><code>{}</code>: {}→{} (risk {}→{})</li>".format(
|
|
203
|
+
_html_escape(x.get("name")),
|
|
204
|
+
_html_escape(x.get("prev_status")),
|
|
205
|
+
_html_escape(x.get("curr_status")),
|
|
206
|
+
_html_escape(x.get("prev_risk_score")),
|
|
207
|
+
_html_escape(x.get("curr_risk_score")),
|
|
208
|
+
)
|
|
209
|
+
for x in regs[:10]
|
|
210
|
+
) or "<li>(none)</li>"
|
|
211
|
+
trend_html = (
|
|
212
|
+
"<h2>Trend vs previous run</h2>"
|
|
213
|
+
f"<div class='meta'><div><b>Endpoint risk delta:</b> <code>{_html_escape(trend.get('endpoint_risk_delta'))}</code> "
|
|
214
|
+
f"(prev <code>{_html_escape(trend.get('endpoint_risk_prev'))}</code>)</div>"
|
|
215
|
+
f"<div><b>Regressions:</b> <code>{_html_escape(trend.get('regression_count'))}</code></div></div>"
|
|
216
|
+
f"<ul>{items}</ul>"
|
|
217
|
+
)
|
|
218
|
+
|
|
177
219
|
def _kv_list(d: dict) -> str:
|
|
178
220
|
items = sorted((d or {}).items(), key=lambda kv: (-kv[1], kv[0]))
|
|
179
221
|
return "".join(f"<li><b>{_html_escape(k)}</b>: {v}</li>" for k, v in items) or "<li>(none)</li>"
|
|
@@ -251,6 +293,8 @@ def _render_html(report: dict) -> str:
|
|
|
251
293
|
<div><b>Endpoint risk:</b> <code>{_html_escape(meta.get('endpoint_risk_score'))}</code></div>
|
|
252
294
|
</div>
|
|
253
295
|
|
|
296
|
+
{trend_html}
|
|
297
|
+
|
|
254
298
|
<h2>Summary</h2>
|
|
255
299
|
<div class='grid'>
|
|
256
300
|
<div><h3>Counts by failure type</h3><ul>{_kv_list(summary.get('counts_by_failure_type') or {})}</ul></div>
|
|
@@ -287,6 +331,15 @@ def write_report(
|
|
|
287
331
|
url = request.get("url", "")
|
|
288
332
|
|
|
289
333
|
endpoint_name = extract_endpoint_name(method, url)
|
|
334
|
+
|
|
335
|
+
# Batch2: optional run label (auth-matrix case, environment label, etc.)
|
|
336
|
+
# This keeps reports for the same endpoint separated but still comparable.
|
|
337
|
+
run_label = str((meta or {}).get("run_label") or "").strip()
|
|
338
|
+
if run_label:
|
|
339
|
+
safe = re.sub(r"[^a-zA-Z0-9_.-]", "-", run_label).strip("-")
|
|
340
|
+
if safe:
|
|
341
|
+
endpoint_name = f"{endpoint_name}__{safe}"
|
|
342
|
+
|
|
290
343
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
291
344
|
|
|
292
345
|
endpoint_dir = REPORTS_DIR / endpoint_name
|
|
@@ -294,6 +347,18 @@ def write_report(
|
|
|
294
347
|
|
|
295
348
|
safe_results = _redact_results(results)
|
|
296
349
|
|
|
350
|
+
# Batch2: load the previous JSON report for trend comparison (best-effort)
|
|
351
|
+
previous_report = None
|
|
352
|
+
try:
|
|
353
|
+
from ai_testing_swarm.reporting.trend import compute_trend
|
|
354
|
+
|
|
355
|
+
json_candidates = sorted(endpoint_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
356
|
+
prev_path = json_candidates[0] if json_candidates else None
|
|
357
|
+
if prev_path and prev_path.exists():
|
|
358
|
+
previous_report = json.loads(prev_path.read_text(encoding="utf-8"))
|
|
359
|
+
except Exception:
|
|
360
|
+
previous_report = None
|
|
361
|
+
|
|
297
362
|
summary = {
|
|
298
363
|
"counts_by_failure_type": {},
|
|
299
364
|
"counts_by_status_code": {},
|
|
@@ -346,6 +411,14 @@ def write_report(
|
|
|
346
411
|
"results": safe_results,
|
|
347
412
|
}
|
|
348
413
|
|
|
414
|
+
# Batch2: attach trend comparison (previous vs current)
|
|
415
|
+
try:
|
|
416
|
+
from ai_testing_swarm.reporting.trend import compute_trend
|
|
417
|
+
|
|
418
|
+
report["trend"] = compute_trend(report, previous_report)
|
|
419
|
+
except Exception:
|
|
420
|
+
report["trend"] = {"has_previous": False, "regressions": [], "regression_count": 0}
|
|
421
|
+
|
|
349
422
|
report_format = (report_format or "json").lower().strip()
|
|
350
423
|
if report_format not in {"json", "md", "html"}:
|
|
351
424
|
report_format = "json"
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Regression:
|
|
8
|
+
name: str
|
|
9
|
+
prev_status: str
|
|
10
|
+
curr_status: str
|
|
11
|
+
prev_risk_score: int
|
|
12
|
+
curr_risk_score: int
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _status_rank(s: str) -> int:
|
|
16
|
+
s = (s or "").upper()
|
|
17
|
+
return {"PASSED": 0, "RISK": 1, "FAILED": 2}.get(s, 1)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def compute_trend(current_report: dict, previous_report: dict | None) -> dict:
|
|
21
|
+
"""Compute a best-effort trend comparison.
|
|
22
|
+
|
|
23
|
+
Trend is designed to be resilient to older report shapes.
|
|
24
|
+
|
|
25
|
+
Returns a dict that can be embedded into report['trend'].
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
if not previous_report:
|
|
29
|
+
return {
|
|
30
|
+
"has_previous": False,
|
|
31
|
+
"regressions": [],
|
|
32
|
+
"regression_count": 0,
|
|
33
|
+
"endpoint_risk_prev": None,
|
|
34
|
+
"endpoint_risk_delta": None,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
cur_meta = current_report.get("meta") or {}
|
|
38
|
+
prev_meta = previous_report.get("meta") or {}
|
|
39
|
+
|
|
40
|
+
cur_risk = cur_meta.get("endpoint_risk_score")
|
|
41
|
+
if cur_risk is None:
|
|
42
|
+
cur_risk = (current_report.get("summary") or {}).get("endpoint_risk_score")
|
|
43
|
+
|
|
44
|
+
prev_risk = prev_meta.get("endpoint_risk_score")
|
|
45
|
+
if prev_risk is None:
|
|
46
|
+
prev_risk = (previous_report.get("summary") or {}).get("endpoint_risk_score")
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
cur_risk_i = int(cur_risk or 0)
|
|
50
|
+
except Exception:
|
|
51
|
+
cur_risk_i = 0
|
|
52
|
+
try:
|
|
53
|
+
prev_risk_i = int(prev_risk or 0)
|
|
54
|
+
except Exception:
|
|
55
|
+
prev_risk_i = 0
|
|
56
|
+
|
|
57
|
+
cur_results = current_report.get("results") or []
|
|
58
|
+
prev_results = previous_report.get("results") or []
|
|
59
|
+
|
|
60
|
+
prev_by_name = {str(r.get("name")): r for r in prev_results if r.get("name") is not None}
|
|
61
|
+
|
|
62
|
+
regressions: list[Regression] = []
|
|
63
|
+
for r in cur_results:
|
|
64
|
+
name = r.get("name")
|
|
65
|
+
if name is None:
|
|
66
|
+
continue
|
|
67
|
+
name = str(name)
|
|
68
|
+
prev = prev_by_name.get(name)
|
|
69
|
+
if not prev:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
prev_status = str(prev.get("status") or "")
|
|
73
|
+
cur_status = str(r.get("status") or "")
|
|
74
|
+
prev_score = prev.get("risk_score")
|
|
75
|
+
cur_score = r.get("risk_score")
|
|
76
|
+
prev_score_i = int(prev_score) if isinstance(prev_score, int) else 0
|
|
77
|
+
cur_score_i = int(cur_score) if isinstance(cur_score, int) else 0
|
|
78
|
+
|
|
79
|
+
worsened_status = _status_rank(cur_status) > _status_rank(prev_status)
|
|
80
|
+
worsened_score = cur_score_i > prev_score_i
|
|
81
|
+
|
|
82
|
+
if worsened_status or worsened_score:
|
|
83
|
+
regressions.append(
|
|
84
|
+
Regression(
|
|
85
|
+
name=name,
|
|
86
|
+
prev_status=prev_status,
|
|
87
|
+
curr_status=cur_status,
|
|
88
|
+
prev_risk_score=prev_score_i,
|
|
89
|
+
curr_risk_score=cur_score_i,
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
regressions.sort(key=lambda x: (x.curr_risk_score - x.prev_risk_score, _status_rank(x.curr_status)), reverse=True)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"has_previous": True,
|
|
97
|
+
"endpoint_risk_prev": prev_risk_i,
|
|
98
|
+
"endpoint_risk_delta": cur_risk_i - prev_risk_i,
|
|
99
|
+
"regression_count": len(regressions),
|
|
100
|
+
"regressions": [
|
|
101
|
+
{
|
|
102
|
+
"name": x.name,
|
|
103
|
+
"prev_status": x.prev_status,
|
|
104
|
+
"curr_status": x.curr_status,
|
|
105
|
+
"prev_risk_score": x.prev_risk_score,
|
|
106
|
+
"curr_risk_score": x.curr_risk_score,
|
|
107
|
+
}
|
|
108
|
+
for x in regressions[:50]
|
|
109
|
+
],
|
|
110
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ai-testing-swarm
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.17
|
|
4
4
|
Summary: AI-powered testing swarm
|
|
5
5
|
Author-email: Arif Shah <ashah7775@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -10,6 +10,8 @@ Requires-Dist: requests>=2.28
|
|
|
10
10
|
Requires-Dist: PyYAML>=6.0
|
|
11
11
|
Provides-Extra: openapi
|
|
12
12
|
Requires-Dist: jsonschema>=4.0; extra == "openapi"
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
15
|
|
|
14
16
|
# AI Testing Swarm
|
|
15
17
|
|
|
@@ -70,7 +72,9 @@ A report is written under:
|
|
|
70
72
|
- `./ai_swarm_reports/<METHOD>_<endpoint>/<METHOD>_<endpoint>_<timestamp>.<json|md|html>`
|
|
71
73
|
|
|
72
74
|
Reports include:
|
|
73
|
-
- per-test results
|
|
75
|
+
- per-test results (including deterministic `risk_score` 0..100)
|
|
76
|
+
- endpoint-level risk gate (`PASS`/`WARN`/`BLOCK`)
|
|
77
|
+
- trend vs previous run for the same endpoint (risk delta + regressions)
|
|
74
78
|
- summary counts by status code / failure type
|
|
75
79
|
- optional AI summary (if enabled)
|
|
76
80
|
|
|
@@ -115,6 +119,10 @@ Reports include:
|
|
|
115
119
|
- Base URL is read from `spec.servers[0].url`.
|
|
116
120
|
- When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
|
|
117
121
|
- If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
|
|
122
|
+
- If an OpenAPI requestBody schema exists for the operation, the planner can also generate **schema-based request-body fuzz cases**:
|
|
123
|
+
- schema-valid edge cases (boundary values that should still validate)
|
|
124
|
+
- schema-invalid variants (missing required fields, wrong types, out-of-range, etc.)
|
|
125
|
+
- Controlled via CLI flags like `--no-openapi-fuzz` / `--openapi-fuzz-max-*`.
|
|
118
126
|
- Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
|
|
119
127
|
|
|
120
128
|
---
|
|
@@ -140,6 +148,28 @@ Then generates broad coverage across:
|
|
|
140
148
|
|
|
141
149
|
---
|
|
142
150
|
|
|
151
|
+
## Auth matrix runner (multiple tokens/headers)
|
|
152
|
+
|
|
153
|
+
To run the *same* request under multiple auth contexts (e.g., user/admin tokens), create `auth_matrix.yaml`:
|
|
154
|
+
|
|
155
|
+
```yaml
|
|
156
|
+
cases:
|
|
157
|
+
- name: user
|
|
158
|
+
headers:
|
|
159
|
+
Authorization: "Bearer USER_TOKEN"
|
|
160
|
+
- name: admin
|
|
161
|
+
headers:
|
|
162
|
+
Authorization: "Bearer ADMIN_TOKEN"
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Run:
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
ai-test --input request.json --auth-matrix auth_matrix.yaml
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Each auth case is written as a separate report using a `run_label` suffix (e.g. `__auth-user`).
|
|
172
|
+
|
|
143
173
|
## Safety mode (recommended for CI/demos)
|
|
144
174
|
|
|
145
175
|
Mutation testing can be noisy and may accidentally stress a real environment.
|
|
@@ -191,6 +221,21 @@ Reports include:
|
|
|
191
221
|
- `summary.counts_by_failure_type`
|
|
192
222
|
- `summary.counts_by_status_code`
|
|
193
223
|
- `summary.slow_tests` (based on SLA)
|
|
224
|
+
- `meta.endpoint_risk_score` + `meta.gate_status`
|
|
225
|
+
- `trend.*` (previous comparison if a prior report exists)
|
|
226
|
+
|
|
227
|
+
A static dashboard index is generated at:
|
|
228
|
+
- `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
|
|
229
|
+
|
|
230
|
+
### Regression gate (CI)
|
|
231
|
+
|
|
232
|
+
To fail CI when the run regresses vs the previous JSON report for the same endpoint:
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
ai-test --input request.json --fail-on-regression
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
This checks `report.trend.regression_count` and exits with a non-zero code if regressions are detected.
|
|
194
239
|
|
|
195
240
|
SLA threshold:
|
|
196
241
|
- `AI_SWARM_SLA_MS` (default: `2000`)
|
|
@@ -1,28 +1,31 @@
|
|
|
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=BzIjnki8Bz3evNWo6bjGxxpLhy_tN9MRYhtM0MnDiWs,23
|
|
2
|
+
ai_testing_swarm/cli.py,sha256=_UzXKfMF02EqUI0-XnKHw2k-MqBa0jEEfottomeaXFs,9458
|
|
3
|
+
ai_testing_swarm/orchestrator.py,sha256=jArGwLkfF0gVykVFDFX0EfeVBqb1SKv48cDsP1q_f38,6745
|
|
4
4
|
ai_testing_swarm/agents/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
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
|
|
9
|
-
ai_testing_swarm/agents/test_planner_agent.py,sha256=
|
|
9
|
+
ai_testing_swarm/agents/test_planner_agent.py,sha256=Ej0YFF-MIcUfuLQVzO0iTg4jnZtRycGkbTSlRADFmSc,16090
|
|
10
10
|
ai_testing_swarm/agents/test_writer_agent.py,sha256=tOCeUv01cl3t4vD8N8oXqVpxUFSE42agky5R9Dn2WVE,691
|
|
11
11
|
ai_testing_swarm/agents/ui_agent.py,sha256=sNCTFbDIxkU8woKGoCHlUi83IRJCJrbxvkTYcSrB0EY,781
|
|
12
12
|
ai_testing_swarm/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
ai_testing_swarm/core/api_client.py,sha256=ENAL5W7Rg2PsggjvgEE301a4PXuBKpgU-5O107gGeQQ,415
|
|
14
|
-
ai_testing_swarm/core/
|
|
14
|
+
ai_testing_swarm/core/auth_matrix.py,sha256=myCU6inO_Fnv5d4RWNGp8vy8SWySOCogpcrPUDzKuJE,2644
|
|
15
|
+
ai_testing_swarm/core/config.py,sha256=4doRsvOhfGY_hlZcoIe_G55spWQc9-UNpcokisYcqjA,1337
|
|
15
16
|
ai_testing_swarm/core/curl_parser.py,sha256=0dPEhRGqn-4u00t-bp7kW9Ii7GSiO0wteB4kDhRKUBQ,1483
|
|
16
17
|
ai_testing_swarm/core/openai_client.py,sha256=gxbrZZZUh_jBcXfxtsBei2t72_xEQrKZ0Z10HjS62Ss,5668
|
|
18
|
+
ai_testing_swarm/core/openapi_fuzzer.py,sha256=wIpL87J53EGYji_ArMB3_XZ1R3zhAuHEDvkQYQmA7pM,13630
|
|
17
19
|
ai_testing_swarm/core/openapi_loader.py,sha256=lZ1Y7lyyEL4Y90pc-naZQbCyNGndr4GmmUAvR71bpos,3639
|
|
18
20
|
ai_testing_swarm/core/openapi_validator.py,sha256=8oZNSCBSf6O6CyHZj9MB8Ojaqw54DzJqSm6pbF57HuE,5153
|
|
19
|
-
ai_testing_swarm/core/risk.py,sha256=
|
|
21
|
+
ai_testing_swarm/core/risk.py,sha256=hT_odEOXWffGu6I8w7_5nK_iM4iu90mw4mQtsj_y6mc,4644
|
|
20
22
|
ai_testing_swarm/core/safety.py,sha256=MvOMr7pKFAn0z37pBcE8X8dOqPK-juxhypCZQ4YcriI,825
|
|
21
23
|
ai_testing_swarm/reporting/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
-
ai_testing_swarm/reporting/dashboard.py,sha256=
|
|
23
|
-
ai_testing_swarm/reporting/report_writer.py,sha256=
|
|
24
|
-
ai_testing_swarm
|
|
25
|
-
ai_testing_swarm-0.1.
|
|
26
|
-
ai_testing_swarm-0.1.
|
|
27
|
-
ai_testing_swarm-0.1.
|
|
28
|
-
ai_testing_swarm-0.1.
|
|
24
|
+
ai_testing_swarm/reporting/dashboard.py,sha256=A-6XihDSY4s5VBjarNN6E-fBAMs_4JDaNV6YuXcVjwI,7067
|
|
25
|
+
ai_testing_swarm/reporting/report_writer.py,sha256=JsP-MLEGxImbK32tT0Lr4a5VBK27a4Fw66lfB04AQU8,15717
|
|
26
|
+
ai_testing_swarm/reporting/trend.py,sha256=YZhQA2SLLsJS2qwKUuRLoz1KdjS7LBYkTXhisVGEtx8,3386
|
|
27
|
+
ai_testing_swarm-0.1.17.dist-info/METADATA,sha256=Kxnaj9rNX9IFyVt9-tIbkoaWUnprWdd7wROHEkAUSgw,7191
|
|
28
|
+
ai_testing_swarm-0.1.17.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
29
|
+
ai_testing_swarm-0.1.17.dist-info/entry_points.txt,sha256=vbW-IBcVcls5I-NA3xFUZxH4Ktevt7lA4w9P4Me0yXo,54
|
|
30
|
+
ai_testing_swarm-0.1.17.dist-info/top_level.txt,sha256=OSqbej3vG04SKqgEcgzDTMn8QzpVsxwOzpSG7quhWJw,17
|
|
31
|
+
ai_testing_swarm-0.1.17.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|