@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,90 @@
1
+ """``suitest-output/`` directory layout.
2
+
3
+ Mirrors the TestSprite ``testsprite_tests/`` folder (PRD + plan + ``TCxxx.py``
4
+ at top level, ``tmp/`` for code-summary / config snapshot / results / report)
5
+ but rooted at a single ``suitest-output/`` tree so a repo stays clean::
6
+
7
+ suitest-output/
8
+ backend/ (or frontend/)
9
+ standard_prd.json
10
+ suitest_backend_test_plan.json
11
+ TC001_*.py ...
12
+ tmp/
13
+ code_summary.json
14
+ config.snapshot.json
15
+ test_results.json
16
+ raw_report.md
17
+ tcm/ ← source-of-truth mirror (cases.json, runs.json)
18
+ reports/ ← summary.{md,json,html}
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from pathlib import Path
25
+ from typing import TYPE_CHECKING
26
+
27
+ if TYPE_CHECKING:
28
+ from suitest_lifecycle.models import Mode
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Paths:
33
+ root: Path
34
+ mode_dir: Path
35
+ tmp_dir: Path
36
+ tcm_dir: Path
37
+ reports_dir: Path
38
+ mode: Mode
39
+
40
+ @property
41
+ def prd_json(self) -> Path:
42
+ return self.mode_dir / "standard_prd.json"
43
+
44
+ @property
45
+ def test_plan_json(self) -> Path:
46
+ return self.mode_dir / f"suitest_{self.mode.value}_test_plan.json"
47
+
48
+ @property
49
+ def code_summary_json(self) -> Path:
50
+ return self.tmp_dir / "code_summary.json"
51
+
52
+ @property
53
+ def config_snapshot_json(self) -> Path:
54
+ return self.tmp_dir / "config.snapshot.json"
55
+
56
+ @property
57
+ def test_results_json(self) -> Path:
58
+ return self.tmp_dir / "test_results.json"
59
+
60
+ @property
61
+ def raw_report_md(self) -> Path:
62
+ return self.tmp_dir / "raw_report.md"
63
+
64
+ @property
65
+ def tcm_cases_json(self) -> Path:
66
+ return self.tcm_dir / "cases.json"
67
+
68
+ @property
69
+ def tcm_runs_json(self) -> Path:
70
+ return self.tcm_dir / "runs.json"
71
+
72
+ def test_file(self, filename: str) -> Path:
73
+ return self.mode_dir / filename
74
+
75
+ def ensure(self) -> None:
76
+ for d in (self.mode_dir, self.tmp_dir, self.tcm_dir, self.reports_dir):
77
+ d.mkdir(parents=True, exist_ok=True)
78
+
79
+
80
+ def build_paths(output_dir: Path, mode: Mode) -> Paths:
81
+ root = Path(output_dir)
82
+ mode_dir = root / mode.value
83
+ return Paths(
84
+ root=root,
85
+ mode_dir=mode_dir,
86
+ tmp_dir=mode_dir / "tmp",
87
+ tcm_dir=root / "tcm",
88
+ reports_dir=root / "reports",
89
+ mode=mode,
90
+ )
@@ -0,0 +1,366 @@
1
+ """Deterministic backend test-plan generator (ZERO tier, no LLM).
2
+
3
+ Turns discovered endpoints into real, source-traceable test cases following the
4
+ same archetypes TestSprite emits: public probes, auth happy/invalid paths,
5
+ "protected endpoint rejects anonymous" 401s, and authenticated CRUD. Every case
6
+ carries a ``source_ref`` ("POST /api/products") so it can be traced back to the
7
+ endpoint that justified it — no dummy tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from suitest_lifecycle.models import CodeSummary, Endpoint, PlanCase, PlanStep, Priority
13
+
14
+
15
+ def _slug(method: str, path: str) -> str:
16
+ cleaned = (
17
+ path.strip("/")
18
+ .replace("/", "_")
19
+ .replace(":", "")
20
+ .replace("{", "")
21
+ .replace("}", "")
22
+ .replace("-", "_")
23
+ )
24
+ return f"{method.lower()}_{cleaned}".strip("_")
25
+
26
+
27
+ def _is_login(ep: Endpoint) -> bool:
28
+ return ep.method == "POST" and ep.path.rstrip("/").endswith("login")
29
+
30
+
31
+ def _has_id_param(ep: Endpoint) -> bool:
32
+ return ":" in ep.path or "{" in ep.path
33
+
34
+
35
+ def _resource(ep: Endpoint) -> str:
36
+ parts = [p for p in ep.path.strip("/").split("/") if p and not p.startswith(":") and p != "api"]
37
+ return parts[-1] if parts else "resource"
38
+
39
+
40
+ def _case(
41
+ cid: str,
42
+ title: str,
43
+ description: str,
44
+ category: str,
45
+ priority: Priority,
46
+ source_ref: str,
47
+ steps: list[tuple[str, str]],
48
+ ) -> PlanCase:
49
+ return PlanCase(
50
+ id=cid,
51
+ title=title,
52
+ description=description,
53
+ category=category,
54
+ priority=priority,
55
+ source_ref=source_ref,
56
+ steps=[PlanStep(type=t, description=d) for t, d in steps],
57
+ )
58
+
59
+
60
+ def generate_backend_plan(summary: CodeSummary) -> list[PlanCase]:
61
+ cases: list[PlanCase] = []
62
+ counter = 0
63
+ seen_unauth_resource: set[str] = set()
64
+
65
+ def next_id() -> str:
66
+ nonlocal counter
67
+ counter += 1
68
+ return f"TC{counter:03d}"
69
+
70
+ for ep in summary.endpoints:
71
+ ref = f"{ep.method} {ep.path}"
72
+
73
+ # Public probe (health / any public GET)
74
+ if ep.method == "GET" and not ep.auth_required and "health" in ep.path:
75
+ cases.append(
76
+ _case(
77
+ next_id(),
78
+ f"{_slug('get', ep.path)}_returns_service_status_without_authentication",
79
+ f"GET {ep.path} returns 200 with a JSON status body and requires no auth.",
80
+ "Health",
81
+ Priority.HIGH,
82
+ ref,
83
+ [
84
+ ("action", f"Send GET {ep.path} with no Authorization header"),
85
+ ("assertion", "Expect HTTP 200 and a JSON object"),
86
+ ],
87
+ )
88
+ )
89
+ continue
90
+
91
+ # Auth: login
92
+ if _is_login(ep):
93
+ cases.append(
94
+ _case(
95
+ next_id(),
96
+ f"{_slug('post', ep.path)}_with_valid_credentials_returns_token",
97
+ f"POST {ep.path} with valid credentials returns 200 and a bearer token.",
98
+ "Auth",
99
+ Priority.HIGH,
100
+ ref,
101
+ [
102
+ ("action", f"Send POST {ep.path} with valid username/password"),
103
+ ("assertion", "Expect HTTP 200 and a token field in the response"),
104
+ ],
105
+ )
106
+ )
107
+ cases.append(
108
+ _case(
109
+ next_id(),
110
+ f"{_slug('post', ep.path)}_with_invalid_credentials_returns_401",
111
+ f"POST {ep.path} with wrong credentials returns 401.",
112
+ "Auth",
113
+ Priority.HIGH,
114
+ ref,
115
+ [
116
+ ("action", f"Send POST {ep.path} with an invalid password"),
117
+ ("assertion", "Expect HTTP 401"),
118
+ ],
119
+ )
120
+ )
121
+ cases.append(
122
+ _case(
123
+ next_id(),
124
+ f"{_slug('post', ep.path)}_with_missing_credentials_returns_validation_error",
125
+ f"POST {ep.path} with an empty body returns a 400/422 validation error.",
126
+ "Auth",
127
+ Priority.MEDIUM,
128
+ ref,
129
+ [
130
+ ("action", f"Send POST {ep.path} with an empty JSON body"),
131
+ ("assertion", "Expect HTTP 400/422 and a validation error body"),
132
+ ],
133
+ )
134
+ )
135
+ continue
136
+
137
+ # Protected endpoint: emit anonymous-rejection + invalid-token cases once
138
+ # per resource (negative auth coverage without exploding the plan).
139
+ if ep.auth_required:
140
+ res = _resource(ep)
141
+ if res not in seen_unauth_resource:
142
+ seen_unauth_resource.add(res)
143
+ anon_ep = ep
144
+ cases.append(
145
+ _case(
146
+ next_id(),
147
+ f"{_slug(anon_ep.method.lower(), anon_ep.path)}_requires_authentication",
148
+ f"{anon_ep.method} {anon_ep.path} without a token returns 401.",
149
+ res.title(),
150
+ Priority.HIGH,
151
+ f"{anon_ep.method} {anon_ep.path}",
152
+ [
153
+ (
154
+ "action",
155
+ f"Send {anon_ep.method} {anon_ep.path} with no Authorization header",
156
+ ),
157
+ ("assertion", "Expect HTTP 401"),
158
+ ],
159
+ )
160
+ )
161
+ cases.append(
162
+ _case(
163
+ next_id(),
164
+ f"{_slug(anon_ep.method.lower(), anon_ep.path)}_with_invalid_token_returns_401",
165
+ f"{anon_ep.method} {anon_ep.path} with a malformed bearer token returns 401.",
166
+ res.title(),
167
+ Priority.MEDIUM,
168
+ f"{anon_ep.method} {anon_ep.path}",
169
+ [
170
+ (
171
+ "action",
172
+ f"Send {anon_ep.method} {anon_ep.path} with Authorization: Bearer <garbage>",
173
+ ),
174
+ ("assertion", "Expect HTTP 401"),
175
+ ],
176
+ )
177
+ )
178
+
179
+ # Authenticated happy paths by method
180
+ res = _resource(ep)
181
+ category = res.title()
182
+ if ep.method == "GET" and ep.path.rstrip("/").endswith("me"):
183
+ cases.append(
184
+ _case(
185
+ next_id(),
186
+ f"{_slug('get', ep.path)}_with_valid_token_returns_profile",
187
+ f"GET {ep.path} with a valid token returns the current user profile.",
188
+ "Auth",
189
+ Priority.HIGH,
190
+ ref,
191
+ [
192
+ ("action", "Log in to obtain a token"),
193
+ ("action", f"Send GET {ep.path} with Authorization: Bearer <token>"),
194
+ ("assertion", "Expect HTTP 200 and a user object"),
195
+ ],
196
+ )
197
+ )
198
+ elif ep.method == "GET" and _has_id_param(ep):
199
+ cases.append(
200
+ _case(
201
+ next_id(),
202
+ f"{_slug('get', ep.path)}_with_valid_id_returns_resource",
203
+ f"GET {ep.path} with a valid id returns 200 and the {res} record.",
204
+ category,
205
+ Priority.MEDIUM,
206
+ ref,
207
+ [
208
+ ("action", "Log in and create a record to read"),
209
+ ("action", f"Send authenticated GET {ep.path}"),
210
+ ("assertion", "Expect HTTP 200 and the record body"),
211
+ ],
212
+ )
213
+ )
214
+ cases.append(
215
+ _case(
216
+ next_id(),
217
+ f"{_slug('get', ep.path)}_with_unknown_id_returns_404",
218
+ f"GET {ep.path} with a nonexistent id returns 404.",
219
+ category,
220
+ Priority.MEDIUM,
221
+ ref,
222
+ [
223
+ ("action", "Log in to obtain a token"),
224
+ (
225
+ "action",
226
+ f"Send authenticated GET {ep.path} with an id that does not exist",
227
+ ),
228
+ ("assertion", "Expect HTTP 404"),
229
+ ],
230
+ )
231
+ )
232
+ elif ep.method == "GET":
233
+ cases.append(
234
+ _case(
235
+ next_id(),
236
+ f"{_slug('get', ep.path)}_with_valid_token_returns_list",
237
+ f"GET {ep.path} with a valid token returns 200 and a collection.",
238
+ category,
239
+ Priority.MEDIUM,
240
+ ref,
241
+ [
242
+ ("action", "Log in to obtain a token"),
243
+ ("action", f"Send authenticated GET {ep.path}"),
244
+ ("assertion", "Expect HTTP 200 and a JSON array/list"),
245
+ ],
246
+ )
247
+ )
248
+ elif ep.method == "POST":
249
+ cases.append(
250
+ _case(
251
+ next_id(),
252
+ f"{_slug('post', ep.path)}_with_valid_data_creates_resource",
253
+ f"POST {ep.path} with valid data creates a {res} and returns 2xx.",
254
+ category,
255
+ Priority.HIGH,
256
+ ref,
257
+ [
258
+ ("action", "Log in to obtain a token"),
259
+ ("action", f"Send authenticated POST {ep.path} with a valid payload"),
260
+ ("assertion", "Expect HTTP 200/201 and the created record"),
261
+ ],
262
+ )
263
+ )
264
+ cases.append(
265
+ _case(
266
+ next_id(),
267
+ f"{_slug('post', ep.path)}_with_missing_required_field_returns_validation_error",
268
+ f"POST {ep.path} with an empty body returns a 400/422 validation error.",
269
+ category,
270
+ Priority.MEDIUM,
271
+ ref,
272
+ [
273
+ ("action", "Log in to obtain a token"),
274
+ ("action", f"Send authenticated POST {ep.path} with an empty JSON body"),
275
+ ("assertion", "Expect HTTP 400/422 and a validation error body"),
276
+ ],
277
+ )
278
+ )
279
+ cases.append(
280
+ _case(
281
+ next_id(),
282
+ f"{_slug('post', ep.path)}_with_duplicate_unique_field_returns_conflict",
283
+ f"POST {ep.path} twice with the same unique field returns a 409 conflict.",
284
+ category,
285
+ Priority.MEDIUM,
286
+ ref,
287
+ [
288
+ ("action", f"Log in and create a {res} with a unique payload"),
289
+ ("action", f"Send the exact same POST {ep.path} payload again"),
290
+ ("assertion", "Expect HTTP 409 (conflict on the unique field)"),
291
+ ],
292
+ )
293
+ )
294
+ elif ep.method == "PUT" or ep.method == "PATCH":
295
+ cases.append(
296
+ _case(
297
+ next_id(),
298
+ f"{_slug(ep.method.lower(), ep.path)}_with_valid_data_updates_resource",
299
+ f"{ep.method} {ep.path} updates an existing {res} and returns 200.",
300
+ category,
301
+ Priority.MEDIUM,
302
+ ref,
303
+ [
304
+ ("action", "Log in and create a record to update"),
305
+ ("action", f"Send authenticated {ep.method} {ep.path} with updated fields"),
306
+ ("assertion", "Expect HTTP 200 and the updated record"),
307
+ ],
308
+ )
309
+ )
310
+ cases.append(
311
+ _case(
312
+ next_id(),
313
+ f"{_slug(ep.method.lower(), ep.path)}_with_unknown_id_returns_404",
314
+ f"{ep.method} {ep.path} against a nonexistent id returns 404.",
315
+ category,
316
+ Priority.MEDIUM,
317
+ ref,
318
+ [
319
+ ("action", "Log in to obtain a token"),
320
+ (
321
+ "action",
322
+ f"Send authenticated {ep.method} {ep.path} with an id that does not exist",
323
+ ),
324
+ ("assertion", "Expect HTTP 404"),
325
+ ],
326
+ )
327
+ )
328
+ elif ep.method == "DELETE":
329
+ cases.append(
330
+ _case(
331
+ next_id(),
332
+ f"{_slug('delete', ep.path)}_with_valid_id_deletes_resource",
333
+ f"DELETE {ep.path} removes an existing {res} and returns 200.",
334
+ category,
335
+ Priority.MEDIUM,
336
+ ref,
337
+ [
338
+ ("action", "Log in and create a record to delete"),
339
+ ("action", f"Send authenticated DELETE {ep.path}"),
340
+ ("assertion", "Expect HTTP 200"),
341
+ ],
342
+ )
343
+ )
344
+ cases.append(
345
+ _case(
346
+ next_id(),
347
+ f"{_slug('delete', ep.path)}_with_unknown_id_returns_404",
348
+ f"DELETE {ep.path} against a nonexistent id returns 404.",
349
+ category,
350
+ Priority.MEDIUM,
351
+ ref,
352
+ [
353
+ ("action", "Log in to obtain a token"),
354
+ (
355
+ "action",
356
+ f"Send authenticated DELETE {ep.path} with an id that does not exist",
357
+ ),
358
+ ("assertion", "Expect HTTP 404"),
359
+ ],
360
+ )
361
+ )
362
+
363
+ return cases
364
+
365
+
366
+ __all__ = ["generate_backend_plan"]