@suiflex/suitest-mcp 0.1.0

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 (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +77 -0
  3. package/bin/suitest-mcp.js +123 -0
  4. package/package.json +50 -0
  5. package/python/suitest_lifecycle/__init__.py +3 -0
  6. package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
  7. package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
  8. package/python/suitest_lifecycle/analyzers/express.py +226 -0
  9. package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
  10. package/python/suitest_lifecycle/analyzers/postman.py +132 -0
  11. package/python/suitest_lifecycle/analyzers/react.py +107 -0
  12. package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
  13. package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
  14. package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
  15. package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
  16. package/python/suitest_lifecycle/blackbox/detector.py +169 -0
  17. package/python/suitest_lifecycle/blackbox/generator.py +608 -0
  18. package/python/suitest_lifecycle/blackbox/graph.py +107 -0
  19. package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
  20. package/python/suitest_lifecycle/blackbox/models.py +299 -0
  21. package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
  22. package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
  23. package/python/suitest_lifecycle/blackbox/selector.py +111 -0
  24. package/python/suitest_lifecycle/cli.py +127 -0
  25. package/python/suitest_lifecycle/config.py +314 -0
  26. package/python/suitest_lifecycle/enrich.py +140 -0
  27. package/python/suitest_lifecycle/exporters/__init__.py +1 -0
  28. package/python/suitest_lifecycle/exporters/backend.py +345 -0
  29. package/python/suitest_lifecycle/exporters/frontend.py +459 -0
  30. package/python/suitest_lifecycle/frontend_runtime.py +77 -0
  31. package/python/suitest_lifecycle/llm_bridge.py +365 -0
  32. package/python/suitest_lifecycle/mcp_server.py +187 -0
  33. package/python/suitest_lifecycle/models.py +166 -0
  34. package/python/suitest_lifecycle/orchestrator.py +500 -0
  35. package/python/suitest_lifecycle/paths.py +90 -0
  36. package/python/suitest_lifecycle/plan.py +366 -0
  37. package/python/suitest_lifecycle/plan_frontend.py +252 -0
  38. package/python/suitest_lifecycle/prd.py +92 -0
  39. package/python/suitest_lifecycle/process.py +111 -0
  40. package/python/suitest_lifecycle/publish.py +218 -0
  41. package/python/suitest_lifecycle/readiness.py +83 -0
  42. package/python/suitest_lifecycle/report.py +179 -0
  43. package/python/suitest_lifecycle/runner.py +138 -0
  44. package/python/suitest_lifecycle/serialize.py +131 -0
  45. package/python/suitest_lifecycle/tcm.py +149 -0
  46. package/python/suitest_lifecycle/tools.py +217 -0
@@ -0,0 +1,345 @@
1
+ """Render runnable backend ``TCxxx.py`` files (``requests``) from a test plan.
2
+
3
+ Output matches TestSprite's backend tests: plain ``requests``, real login →
4
+ bearer-token flow, real CRUD that seeds a record before hitting ``/:id`` routes,
5
+ and standalone execution at the bottom (guarded so pytest doesn't double-run).
6
+ Each file is fully runnable on its own — no Suitest import needed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import TYPE_CHECKING
13
+
14
+ from suitest_lifecycle.analyzers.zod_schema import ZodField, find_create_schema, sample_value
15
+
16
+ if TYPE_CHECKING:
17
+ from suitest_lifecycle.config import Config
18
+ from suitest_lifecycle.models import CodeSummary, PlanCase
19
+ from suitest_lifecycle.paths import Paths
20
+
21
+
22
+ def _archetype(title: str) -> str:
23
+ suffix_map = {
24
+ "_returns_service_status_without_authentication": "health",
25
+ "_with_valid_credentials_returns_token": "login_valid",
26
+ "_with_invalid_credentials_returns_401": "login_invalid",
27
+ "_requires_authentication": "requires_auth",
28
+ "_with_valid_token_returns_profile": "me",
29
+ "_with_valid_token_returns_list": "list",
30
+ "_with_valid_id_returns_resource": "get_by_id",
31
+ "_with_valid_data_creates_resource": "create",
32
+ "_with_valid_data_updates_resource": "update",
33
+ "_with_valid_id_deletes_resource": "delete",
34
+ "_with_missing_required_field_returns_validation_error": "validation",
35
+ "_with_missing_credentials_returns_validation_error": "login_missing",
36
+ "_with_invalid_token_returns_401": "invalid_token",
37
+ "_with_unknown_id_returns_404": "not_found",
38
+ "_with_duplicate_unique_field_returns_conflict": "duplicate",
39
+ }
40
+ for suffix, name in suffix_map.items():
41
+ if title.endswith(suffix):
42
+ return name
43
+ return "unknown"
44
+
45
+
46
+ def _ref_parts(source_ref: str) -> tuple[str, str]:
47
+ method, _, path = source_ref.partition(" ")
48
+ return method.strip().upper(), path.strip()
49
+
50
+
51
+ def _rel(path: str, config: Config) -> str:
52
+ """Path relative to the api base (strip the apiBasePath prefix)."""
53
+ api_prefix = "/" + config.api_base_path.strip("/")
54
+ if path.startswith(api_prefix):
55
+ return path[len(api_prefix) :] or "/"
56
+ return path
57
+
58
+
59
+ def _collection_for(resource: str, summary: CodeSummary, config: Config) -> tuple[str, str] | None:
60
+ """Return (relative_collection_path, create_method) for seeding, if a POST exists."""
61
+ for ep in summary.endpoints:
62
+ if ep.method == "POST" and ":" not in ep.path and resource in ep.path:
63
+ return _rel(ep.path, config), "POST"
64
+ return None
65
+
66
+
67
+ def _example_literal(example: dict[str, object]) -> str:
68
+ """Render a python dict-literal from an OpenAPI/Postman example body, making
69
+ obviously-unique fields (sku/email) f-strings keyed on the test's ``token``."""
70
+ items: list[str] = []
71
+ for key, val in example.items():
72
+ lk = str(key).lower()
73
+ if isinstance(val, str):
74
+ if lk == "sku":
75
+ rendered = 'f"SKU-{token}"'
76
+ elif lk == "email":
77
+ rendered = 'f"user_{token}@example.com"'
78
+ else:
79
+ rendered = '"' + val.replace('"', '\\"') + '"'
80
+ elif isinstance(val, bool):
81
+ rendered = "True" if val else "False"
82
+ elif isinstance(val, (int, float)):
83
+ rendered = json.dumps(val)
84
+ else:
85
+ rendered = json.dumps(val)
86
+ items.append(f' "{key}": {rendered},')
87
+ return "{\n" + "\n".join(items) + "\n }"
88
+
89
+
90
+ def _resolve_payload(res_name: str, summary: CodeSummary, config: Config) -> str:
91
+ """Prefer a spec/Postman example body (no-repo); fall back to the project's
92
+ Zod create-schema (repo mode)."""
93
+ for ep in summary.endpoints:
94
+ if (
95
+ ep.method == "POST"
96
+ and ":" not in ep.path
97
+ and "{" not in ep.path
98
+ and res_name in ep.path
99
+ and ep.request_example
100
+ ):
101
+ return _example_literal(ep.request_example)
102
+ fields = find_create_schema(config.project_path, res_name)
103
+ return _payload_literal(fields) if fields else "{}"
104
+
105
+
106
+ def _payload_literal(fields: list[ZodField]) -> str:
107
+ """Build a python dict-literal string for a valid create payload."""
108
+ items: list[str] = []
109
+ for f in fields:
110
+ if not f.required and f.base_type == "boolean":
111
+ continue # skip optionals to keep payload minimal
112
+ val = sample_value(f, "{token}")
113
+ if isinstance(val, str):
114
+ rendered = '"' + val.replace('"', '\\"') + '"'
115
+ if "{token}" in val:
116
+ rendered = "f" + rendered
117
+ elif isinstance(val, bool):
118
+ rendered = "True" if val else "False"
119
+ else:
120
+ rendered = json.dumps(val)
121
+ items.append(f' "{f.name}": {rendered},')
122
+ return "{\n" + "\n".join(items) + "\n }"
123
+
124
+
125
+ _HEADER = """import requests
126
+ import uuid
127
+
128
+ BASE_URL = "{api_url}"
129
+ TIMEOUT = 30
130
+ USERNAME = "{username}"
131
+ PASSWORD = "{password}"
132
+
133
+
134
+ def _login():
135
+ resp = requests.post(
136
+ f"{{BASE_URL}}{login_rel}",
137
+ json={{"{ufield}": USERNAME, "{pfield}": PASSWORD}},
138
+ timeout=TIMEOUT,
139
+ )
140
+ assert resp.status_code == 200, f"login failed: {{resp.status_code}} {{resp.text}}"
141
+ token = resp.json().get("{token_field}")
142
+ assert token, "no token in login response"
143
+ return token
144
+
145
+
146
+ def _auth_headers():
147
+ return {{"Authorization": f"Bearer {{_login()}}"}}
148
+
149
+
150
+ def _extract_id(body):
151
+ if isinstance(body, dict):
152
+ if isinstance(body.get("data"), dict) and "id" in body["data"]:
153
+ return body["data"]["id"]
154
+ if "id" in body:
155
+ return body["id"]
156
+ return None
157
+ """
158
+
159
+
160
+ def _render(case: PlanCase, config: Config, summary: CodeSummary) -> str:
161
+ method, path = _ref_parts(case.source_ref)
162
+ rel = _rel(path, config)
163
+ arch = _archetype(case.title)
164
+ fn = f"test_{case.title}"
165
+ login_rel = _rel(config.auth.login_path, config)
166
+ header = _HEADER.format(
167
+ api_url=config.api_url,
168
+ username=config.auth.username,
169
+ password=config.auth.password,
170
+ login_rel=login_rel,
171
+ ufield=config.auth.username_field,
172
+ pfield=config.auth.password_field,
173
+ token_field=config.auth.token_field,
174
+ )
175
+
176
+ body = ""
177
+ if arch == "health":
178
+ body = f"""
179
+ def {fn}():
180
+ resp = requests.get(f"{{BASE_URL}}{rel}", timeout=TIMEOUT)
181
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
182
+ assert isinstance(resp.json(), dict)
183
+ """
184
+ elif arch == "login_valid":
185
+ body = f'''
186
+ def {fn}():
187
+ resp = requests.post(
188
+ f"{{BASE_URL}}{rel}",
189
+ json={{"{config.auth.username_field}": USERNAME, "{config.auth.password_field}": PASSWORD}},
190
+ timeout=TIMEOUT,
191
+ )
192
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
193
+ assert resp.json().get("{config.auth.token_field}"), "missing token"
194
+ '''
195
+ elif arch == "login_invalid":
196
+ body = f'''
197
+ def {fn}():
198
+ resp = requests.post(
199
+ f"{{BASE_URL}}{rel}",
200
+ json={{"{config.auth.username_field}": USERNAME, "{config.auth.password_field}": "wrong-password-xyz"}},
201
+ timeout=TIMEOUT,
202
+ )
203
+ assert resp.status_code == 401, f"expected 401, got {{resp.status_code}}"
204
+ '''
205
+ elif arch == "requires_auth":
206
+ verb = method.lower()
207
+ body = f"""
208
+ def {fn}():
209
+ resp = requests.{verb}(f"{{BASE_URL}}{rel}", timeout=TIMEOUT)
210
+ assert resp.status_code == 401, f"expected 401, got {{resp.status_code}}"
211
+ """
212
+ elif arch == "me":
213
+ body = f"""
214
+ def {fn}():
215
+ resp = requests.get(f"{{BASE_URL}}{rel}", headers=_auth_headers(), timeout=TIMEOUT)
216
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
217
+ assert isinstance(resp.json(), dict)
218
+ """
219
+ elif arch == "list":
220
+ body = f"""
221
+ def {fn}():
222
+ resp = requests.get(f"{{BASE_URL}}{rel}", headers=_auth_headers(), timeout=TIMEOUT)
223
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
224
+ """
225
+ elif arch in {"get_by_id", "update", "delete"}:
226
+ resource = [
227
+ p for p in path.strip("/").split("/") if p and not p.startswith(":") and p != "api"
228
+ ]
229
+ res_name = resource[-1] if resource else "resource"
230
+ coll = _collection_for(res_name, summary, config)
231
+ payload = _resolve_payload(res_name, summary, config)
232
+ coll_rel = coll[0] if coll else rel.rsplit("/", 1)[0] or "/"
233
+ item_rel = rel.replace(":id", "{rid}").replace("{id}", "{rid}")
234
+ seed = f"""
235
+ headers = _auth_headers()
236
+ token = uuid.uuid4().hex[:8]
237
+ payload = {payload}
238
+ created = requests.post(f"{{BASE_URL}}{coll_rel}", json=payload, headers=headers, timeout=TIMEOUT)
239
+ assert created.status_code in (200, 201), f"seed failed: {{created.status_code}} {{created.text}}"
240
+ rid = _extract_id(created.json())
241
+ assert rid is not None, "could not extract id from created resource"
242
+ """
243
+ if arch == "get_by_id":
244
+ action = f""" resp = requests.get(f"{{BASE_URL}}{item_rel}", headers=headers, timeout=TIMEOUT)
245
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
246
+ """
247
+ elif arch == "update":
248
+ action = f""" resp = requests.{method.lower()}(f"{{BASE_URL}}{item_rel}", json={{"name": "Suitest Updated"}}, headers=headers, timeout=TIMEOUT)
249
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
250
+ """
251
+ else: # delete
252
+ action = f""" resp = requests.delete(f"{{BASE_URL}}{item_rel}", headers=headers, timeout=TIMEOUT)
253
+ assert resp.status_code == 200, f"expected 200, got {{resp.status_code}}"
254
+ """
255
+ body = f"\ndef {fn}():{seed}{action}"
256
+ elif arch == "create":
257
+ resource = [
258
+ p for p in path.strip("/").split("/") if p and not p.startswith(":") and p != "api"
259
+ ]
260
+ res_name = resource[-1] if resource else "resource"
261
+ payload = _resolve_payload(res_name, summary, config)
262
+ body = f"""
263
+ def {fn}():
264
+ headers = _auth_headers()
265
+ token = uuid.uuid4().hex[:8]
266
+ payload = {payload}
267
+ resp = requests.post(f"{{BASE_URL}}{rel}", json=payload, headers=headers, timeout=TIMEOUT)
268
+ assert resp.status_code in (200, 201), f"expected 2xx, got {{resp.status_code}} {{resp.text}}"
269
+ """
270
+ elif arch == "validation":
271
+ body = f"""
272
+ def {fn}():
273
+ headers = _auth_headers()
274
+ resp = requests.post(f"{{BASE_URL}}{rel}", json={{}}, headers=headers, timeout=TIMEOUT)
275
+ assert resp.status_code in (400, 422), f"expected validation 4xx, got {{resp.status_code}} {{resp.text}}"
276
+ """
277
+ elif arch == "login_missing":
278
+ body = f"""
279
+ def {fn}():
280
+ resp = requests.post(f"{{BASE_URL}}{rel}", json={{}}, timeout=TIMEOUT)
281
+ assert resp.status_code in (400, 422), f"expected validation 4xx, got {{resp.status_code}} {{resp.text}}"
282
+ """
283
+ elif arch == "invalid_token":
284
+ verb = method.lower()
285
+ body = f"""
286
+ def {fn}():
287
+ headers = {{"Authorization": "Bearer invalid-token-xyz"}}
288
+ resp = requests.{verb}(f"{{BASE_URL}}{rel}", headers=headers, timeout=TIMEOUT)
289
+ assert resp.status_code == 401, f"expected 401, got {{resp.status_code}}"
290
+ """
291
+ elif arch == "not_found":
292
+ verb = method.lower()
293
+ missing_rel = rel.replace(":id", "999999").replace("{id}", "999999")
294
+ extra = ', json={"name": "Suitest Updated"}' if verb in ("put", "patch") else ""
295
+ body = f"""
296
+ def {fn}():
297
+ headers = _auth_headers()
298
+ resp = requests.{verb}(f"{{BASE_URL}}{missing_rel}", headers=headers{extra}, timeout=TIMEOUT)
299
+ assert resp.status_code == 404, f"expected 404, got {{resp.status_code}} {{resp.text}}"
300
+ """
301
+ elif arch == "duplicate":
302
+ resource = [
303
+ p for p in path.strip("/").split("/") if p and not p.startswith(":") and p != "api"
304
+ ]
305
+ res_name = resource[-1] if resource else "resource"
306
+ payload = _resolve_payload(res_name, summary, config)
307
+ body = f"""
308
+ def {fn}():
309
+ headers = _auth_headers()
310
+ token = uuid.uuid4().hex[:8]
311
+ payload = {payload}
312
+ first = requests.post(f"{{BASE_URL}}{rel}", json=payload, headers=headers, timeout=TIMEOUT)
313
+ assert first.status_code in (200, 201), f"seed failed: {{first.status_code}} {{first.text}}"
314
+ resp = requests.post(f"{{BASE_URL}}{rel}", json=payload, headers=headers, timeout=TIMEOUT)
315
+ assert resp.status_code in (400, 409), f"expected conflict, got {{resp.status_code}} {{resp.text}}"
316
+ """
317
+ else:
318
+ body = f"""
319
+ def {fn}():
320
+ raise AssertionError("unsupported archetype for {case.id}")
321
+ """
322
+
323
+ footer = f"""
324
+
325
+ if __name__ == "__main__":
326
+ {fn}()
327
+ print("PASS {case.id}")
328
+ """
329
+ return header + body + footer
330
+
331
+
332
+ def export_backend_tests(
333
+ cases: list[PlanCase], summary: CodeSummary, config: Config, paths: Paths
334
+ ) -> list[PlanCase]:
335
+ """Write one runnable .py per case; set ``automation_file`` on each case."""
336
+ paths.ensure()
337
+ for case in cases:
338
+ filename = f"{case.id}_{case.title}.py"
339
+ code = _render(case, config, summary)
340
+ paths.test_file(filename).write_text(code, encoding="utf-8")
341
+ case.automation_file = filename
342
+ return cases
343
+
344
+
345
+ __all__ = ["export_backend_tests"]