ai-testing-swarm 0.1.16__tar.gz → 0.1.17__tar.gz

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.
Files changed (44) hide show
  1. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/PKG-INFO +15 -1
  2. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/README.md +14 -0
  3. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/pyproject.toml +1 -1
  4. ai_testing_swarm-0.1.17/src/ai_testing_swarm/__init__.py +1 -0
  5. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/test_planner_agent.py +52 -1
  6. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/cli.py +88 -5
  7. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/config.py +6 -0
  8. ai_testing_swarm-0.1.17/src/ai_testing_swarm/core/openapi_fuzzer.py +396 -0
  9. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/orchestrator.py +12 -1
  10. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/PKG-INFO +15 -1
  11. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/SOURCES.txt +2 -0
  12. ai_testing_swarm-0.1.17/tests/test_openapi_fuzzer.py +91 -0
  13. ai_testing_swarm-0.1.16/src/ai_testing_swarm/__init__.py +0 -1
  14. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/setup.cfg +0 -0
  15. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/__init__.py +0 -0
  16. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/execution_agent.py +0 -0
  17. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/learning_agent.py +0 -0
  18. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/llm_reasoning_agent.py +0 -0
  19. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/release_gate_agent.py +0 -0
  20. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/test_writer_agent.py +0 -0
  21. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/agents/ui_agent.py +0 -0
  22. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/__init__.py +0 -0
  23. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/api_client.py +0 -0
  24. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/auth_matrix.py +0 -0
  25. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/curl_parser.py +0 -0
  26. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openai_client.py +0 -0
  27. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_loader.py +0 -0
  28. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/openapi_validator.py +0 -0
  29. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/risk.py +0 -0
  30. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/core/safety.py +0 -0
  31. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/__init__.py +0 -0
  32. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/dashboard.py +0 -0
  33. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/report_writer.py +0 -0
  34. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm/reporting/trend.py +0 -0
  35. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/dependency_links.txt +0 -0
  36. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/entry_points.txt +0 -0
  37. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/requires.txt +0 -0
  38. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/src/ai_testing_swarm.egg-info/top_level.txt +0 -0
  39. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_batch2_trend_and_auth.py +0 -0
  40. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_openapi_loader.py +0 -0
  41. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_openapi_validator.py +0 -0
  42. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_policy_expected_negatives.py +0 -0
  43. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_risk_scoring_and_gate.py +0 -0
  44. {ai_testing_swarm-0.1.16 → ai_testing_swarm-0.1.17}/tests/test_swarm_api.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-testing-swarm
3
- Version: 0.1.16
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
@@ -119,6 +119,10 @@ Reports include:
119
119
  - Base URL is read from `spec.servers[0].url`.
120
120
  - When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
121
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-*`.
122
126
  - Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
123
127
 
124
128
  ---
@@ -223,6 +227,16 @@ Reports include:
223
227
  A static dashboard index is generated at:
224
228
  - `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
225
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.
239
+
226
240
  SLA threshold:
227
241
  - `AI_SWARM_SLA_MS` (default: `2000`)
228
242
 
@@ -104,6 +104,10 @@ Reports include:
104
104
  - Base URL is read from `spec.servers[0].url`.
105
105
  - When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
106
106
  - If `jsonschema` is installed (via `ai-testing-swarm[openapi]`) and the response is JSON, response bodies are validated against the OpenAPI `application/json` schema.
107
+ - If an OpenAPI requestBody schema exists for the operation, the planner can also generate **schema-based request-body fuzz cases**:
108
+ - schema-valid edge cases (boundary values that should still validate)
109
+ - schema-invalid variants (missing required fields, wrong types, out-of-range, etc.)
110
+ - Controlled via CLI flags like `--no-openapi-fuzz` / `--openapi-fuzz-max-*`.
107
111
  - Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
108
112
 
109
113
  ---
@@ -208,6 +212,16 @@ Reports include:
208
212
  A static dashboard index is generated at:
209
213
  - `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
210
214
 
215
+ ### Regression gate (CI)
216
+
217
+ To fail CI when the run regresses vs the previous JSON report for the same endpoint:
218
+
219
+ ```bash
220
+ ai-test --input request.json --fail-on-regression
221
+ ```
222
+
223
+ This checks `report.trend.regression_count` and exits with a non-zero code if regressions are detected.
224
+
211
225
  SLA threshold:
212
226
  - `AI_SWARM_SLA_MS` (default: `2000`)
213
227
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ai-testing-swarm"
7
- version = "0.1.16"
7
+ version = "0.1.17"
8
8
  description = "AI-powered testing swarm"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,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 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,
@@ -107,6 +107,45 @@ 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
+
110
149
  parser.add_argument(
111
150
  "--auth-matrix",
112
151
  default="",
@@ -146,10 +185,17 @@ def main():
146
185
  # ------------------------------------------------------------
147
186
  # Run swarm
148
187
  # ------------------------------------------------------------
188
+ import os
189
+
149
190
  if args.public_only:
150
- import os
151
191
  os.environ["AI_SWARM_PUBLIC_ONLY"] = "1"
152
192
 
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))
198
+
153
199
  orch = SwarmOrchestrator()
154
200
 
155
201
  def _print_console(decision, results, *, label: str = ""):
@@ -163,29 +209,66 @@ def main():
163
209
  status_code = response.get("status_code")
164
210
  print(f"{r.get('name'):25} {str(status_code):5} {r.get('reason')}")
165
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
+
166
230
  if args.auth_matrix:
167
231
  from ai_testing_swarm.core.auth_matrix import load_auth_matrix, merge_auth_headers
168
232
 
169
233
  cases = load_auth_matrix(args.auth_matrix)
170
234
  for c in cases:
171
235
  req2 = merge_auth_headers(request, c)
172
- decision, results = orch.run(
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(
173
242
  req2,
174
- report_format=args.report_format,
243
+ report_format=report_format,
175
244
  gate_warn=args.gate_warn,
176
245
  gate_block=args.gate_block,
177
246
  run_label=f"auth-{c.name}",
247
+ fail_on_regression=args.fail_on_regression,
248
+ return_report_path=True,
178
249
  )
179
250
  _print_console(decision, results, label=c.name)
251
+
252
+ if args.fail_on_regression:
253
+ _maybe_fail_on_regression(report_path)
180
254
  else:
181
- decision, results = orch.run(
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(
182
260
  request,
183
- report_format=args.report_format,
261
+ report_format=report_format,
184
262
  gate_warn=args.gate_warn,
185
263
  gate_block=args.gate_block,
264
+ fail_on_regression=args.fail_on_regression,
265
+ return_report_path=True,
186
266
  )
187
267
  _print_console(decision, results)
188
268
 
269
+ if args.fail_on_regression:
270
+ _maybe_fail_on_regression(report_path)
271
+
189
272
 
190
273
  if __name__ == "__main__":
191
274
  main()
@@ -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
@@ -54,12 +54,18 @@ class SwarmOrchestrator:
54
54
  gate_warn: int = 30,
55
55
  gate_block: int = 80,
56
56
  run_label: str | None = None,
57
+ fail_on_regression: bool = False,
58
+ return_report_path: bool = False,
57
59
  ):
58
- """Runs the full AI testing swarm and returns (decision, results).
60
+ """Runs the full AI testing swarm.
59
61
 
60
62
  gate_warn/gate_block:
61
63
  Thresholds for PASS/WARN/BLOCK gate based on endpoint risk.
62
64
  (Kept optional for backward compatibility.)
65
+
66
+ Returns:
67
+ - default: (decision, results)
68
+ - if return_report_path=True: (decision, results, report_path)
63
69
  """
64
70
 
65
71
  # Safety hook (currently no-op; kept for backward compatibility)
@@ -149,6 +155,9 @@ class SwarmOrchestrator:
149
155
  if run_label:
150
156
  meta["run_label"] = str(run_label)
151
157
 
158
+ if fail_on_regression:
159
+ meta["fail_on_regression"] = True
160
+
152
161
  # Optional AI summary for humans (best-effort)
153
162
  try:
154
163
  from ai_testing_swarm.core.config import AI_SWARM_AI_SUMMARY, AI_SWARM_OPENAI_MODEL, AI_SWARM_USE_OPENAI
@@ -169,4 +178,6 @@ class SwarmOrchestrator:
169
178
  logger.info("📄 Swarm report written to: %s", report_path)
170
179
  print(f"📄 Swarm report written to: {report_path}")
171
180
 
181
+ if return_report_path:
182
+ return decision, results, report_path
172
183
  return decision, results
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ai-testing-swarm
3
- Version: 0.1.16
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
@@ -119,6 +119,10 @@ Reports include:
119
119
  - Base URL is read from `spec.servers[0].url`.
120
120
  - When using OpenAPI input, the swarm will also *optionally* validate response status codes against `operation.responses`.
121
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-*`.
122
126
  - Override with `AI_SWARM_OPENAPI_BASE_URL` if your spec doesn’t include servers.
123
127
 
124
128
  ---
@@ -223,6 +227,16 @@ Reports include:
223
227
  A static dashboard index is generated at:
224
228
  - `./ai_swarm_reports/index.html` (latest JSON report per endpoint, sorted by regressions/risk)
225
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.
239
+
226
240
  SLA threshold:
227
241
  - `AI_SWARM_SLA_MS` (default: `2000`)
228
242
 
@@ -23,6 +23,7 @@ src/ai_testing_swarm/core/auth_matrix.py
23
23
  src/ai_testing_swarm/core/config.py
24
24
  src/ai_testing_swarm/core/curl_parser.py
25
25
  src/ai_testing_swarm/core/openai_client.py
26
+ src/ai_testing_swarm/core/openapi_fuzzer.py
26
27
  src/ai_testing_swarm/core/openapi_loader.py
27
28
  src/ai_testing_swarm/core/openapi_validator.py
28
29
  src/ai_testing_swarm/core/risk.py
@@ -32,6 +33,7 @@ src/ai_testing_swarm/reporting/dashboard.py
32
33
  src/ai_testing_swarm/reporting/report_writer.py
33
34
  src/ai_testing_swarm/reporting/trend.py
34
35
  tests/test_batch2_trend_and_auth.py
36
+ tests/test_openapi_fuzzer.py
35
37
  tests/test_openapi_loader.py
36
38
  tests/test_openapi_validator.py
37
39
  tests/test_policy_expected_negatives.py
@@ -0,0 +1,91 @@
1
+ import pytest
2
+
3
+
4
+ def _spec():
5
+ return {
6
+ "openapi": "3.0.0",
7
+ "servers": [{"url": "https://example.com"}],
8
+ "paths": {
9
+ "/pets": {
10
+ "post": {
11
+ "requestBody": {
12
+ "required": True,
13
+ "content": {
14
+ "application/json": {
15
+ "schema": {
16
+ "type": "object",
17
+ "additionalProperties": False,
18
+ "required": ["name", "age", "tags"],
19
+ "properties": {
20
+ "name": {"type": "string", "minLength": 2, "maxLength": 5},
21
+ "age": {"type": "integer", "minimum": 0, "maximum": 120},
22
+ "tags": {
23
+ "type": "array",
24
+ "minItems": 1,
25
+ "maxItems": 2,
26
+ "items": {"type": "string", "minLength": 1},
27
+ },
28
+ },
29
+ }
30
+ }
31
+ },
32
+ },
33
+ "responses": {"200": {"description": "ok"}},
34
+ }
35
+ }
36
+ },
37
+ }
38
+
39
+
40
+ def test_openapi_fuzzer_generates_valid_and_invalid_cases():
41
+ jsonschema = pytest.importorskip("jsonschema")
42
+
43
+ from ai_testing_swarm.core.openapi_fuzzer import (
44
+ generate_request_body_fuzz_cases,
45
+ get_request_body_schema,
46
+ )
47
+
48
+ spec = _spec()
49
+ schema = get_request_body_schema(spec=spec, path="/pets", method="POST")
50
+ assert schema and schema.get("type") == "object"
51
+
52
+ cases = generate_request_body_fuzz_cases(spec=spec, path="/pets", method="POST", max_valid=10, max_invalid=10)
53
+ assert any(c.is_valid for c in cases)
54
+ assert any(not c.is_valid for c in cases)
55
+
56
+ resolver = jsonschema.RefResolver.from_schema(spec) # type: ignore[attr-defined]
57
+ cls = jsonschema.validators.validator_for(schema) # type: ignore[attr-defined]
58
+ v = cls(schema, resolver=resolver)
59
+
60
+ for c in cases:
61
+ if c.is_valid:
62
+ v.validate(c.body)
63
+ else:
64
+ with pytest.raises(Exception):
65
+ v.validate(c.body)
66
+
67
+
68
+ def test_planner_includes_openapi_fuzz_cases(monkeypatch):
69
+ pytest.importorskip("jsonschema")
70
+
71
+ from ai_testing_swarm.agents.test_planner_agent import TestPlannerAgent
72
+ import ai_testing_swarm.core.config as config
73
+
74
+ # Ensure enabled in this test regardless of env at import time
75
+ monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ", True)
76
+ monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_VALID", 3)
77
+ monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_INVALID", 3)
78
+ monkeypatch.setattr(config, "AI_SWARM_OPENAPI_FUZZ_MAX_DEPTH", 5)
79
+
80
+ request = {
81
+ "method": "POST",
82
+ "url": "https://example.com/pets",
83
+ "headers": {"content-type": "application/json"},
84
+ "params": {},
85
+ "body": {},
86
+ "_openapi": {"spec": _spec(), "path": "/pets", "method": "POST", "source": "inline"},
87
+ }
88
+
89
+ tests = TestPlannerAgent().plan(request)
90
+ names = {t.get("name") for t in tests}
91
+ assert any(str(n).startswith("openapi_") for n in names)
@@ -1 +0,0 @@
1
- __version__ = "0.1.16"