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.
@@ -1 +1 @@
1
- __version__ = "0.1.15"
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 OpenAI augmentation (best-effort)
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
- 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
- )
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
- # Console output
153
- # ------------------------------------------------------------
154
- print("\n=== RELEASE DECISION ===")
155
- print(decision)
156
-
157
- print("\n=== TEST RESULTS ===")
158
- for r in results:
159
- response = r.get("response", {})
160
- status_code = response.get("status_code")
161
-
162
- print(
163
- f"{r.get('name'):25} "
164
- f"{str(status_code):5} "
165
- f"{r.get('reason')}"
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
@@ -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
@@ -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:
@@ -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 and returns (decision, results).
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
- # Sort by risk (desc) then recent
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></td>"
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='5'>(no JSON reports found)</td></tr>"
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.15
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=qb0TalpSt1CbprnFyeLUKqgrqNtmnk9IoQQ7umAoXVY,23
2
- ai_testing_swarm/cli.py,sha256=IeCU0E1Ju8hfkHXT4ySI1KVSvKAlYK429Fa5atcAjdo,5626
3
- ai_testing_swarm/orchestrator.py,sha256=8uaUuyGy2lm3QzTNVl_8AeYtbyzrrxuykGQchRn1EEo,6295
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=mCCa67F2ilsPJG_msIwft4TLRySiKoklEhZyc17RFR0,13762
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/config.py,sha256=loZcH45TlUseZMYZ9pkYpsK4AH1PaQ7PLv-AOZq_nQU,908
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=9FtBWwq3_a1xZd5r-F78g9G6WaxA76xJBMe4LR90A_4,3646
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=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,,
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,,