@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.
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/bin/suitest-mcp.js +123 -0
- package/package.json +50 -0
- package/python/suitest_lifecycle/__init__.py +3 -0
- package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
- package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
- package/python/suitest_lifecycle/analyzers/express.py +226 -0
- package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
- package/python/suitest_lifecycle/analyzers/postman.py +132 -0
- package/python/suitest_lifecycle/analyzers/react.py +107 -0
- package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
- package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
- package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
- package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
- package/python/suitest_lifecycle/blackbox/detector.py +169 -0
- package/python/suitest_lifecycle/blackbox/generator.py +608 -0
- package/python/suitest_lifecycle/blackbox/graph.py +107 -0
- package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
- package/python/suitest_lifecycle/blackbox/models.py +299 -0
- package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
- package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
- package/python/suitest_lifecycle/blackbox/selector.py +111 -0
- package/python/suitest_lifecycle/cli.py +127 -0
- package/python/suitest_lifecycle/config.py +314 -0
- package/python/suitest_lifecycle/enrich.py +140 -0
- package/python/suitest_lifecycle/exporters/__init__.py +1 -0
- package/python/suitest_lifecycle/exporters/backend.py +345 -0
- package/python/suitest_lifecycle/exporters/frontend.py +459 -0
- package/python/suitest_lifecycle/frontend_runtime.py +77 -0
- package/python/suitest_lifecycle/llm_bridge.py +365 -0
- package/python/suitest_lifecycle/mcp_server.py +187 -0
- package/python/suitest_lifecycle/models.py +166 -0
- package/python/suitest_lifecycle/orchestrator.py +500 -0
- package/python/suitest_lifecycle/paths.py +90 -0
- package/python/suitest_lifecycle/plan.py +366 -0
- package/python/suitest_lifecycle/plan_frontend.py +252 -0
- package/python/suitest_lifecycle/prd.py +92 -0
- package/python/suitest_lifecycle/process.py +111 -0
- package/python/suitest_lifecycle/publish.py +218 -0
- package/python/suitest_lifecycle/readiness.py +83 -0
- package/python/suitest_lifecycle/report.py +179 -0
- package/python/suitest_lifecycle/runner.py +138 -0
- package/python/suitest_lifecycle/serialize.py +131 -0
- package/python/suitest_lifecycle/tcm.py +149 -0
- 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"]
|