@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,131 @@
1
+ """Minimal Zod schema reader — extracts create-payload field shapes.
2
+
3
+ Lets the backend exporter synthesise *valid* request bodies (so generated CRUD
4
+ tests actually pass) without an LLM. Parses ``export const xSchema = z.object({…})``
5
+ blocks and reads each field's base type + ``optional``/``int``/``min`` markers.
6
+ Heuristic and intentionally conservative: unknown constructs are skipped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass
13
+ from typing import TYPE_CHECKING
14
+
15
+ if TYPE_CHECKING:
16
+ from pathlib import Path
17
+
18
+ _SCHEMA_RE = re.compile(r"""export\s+const\s+(?P<name>\w+)\s*=\s*z\.object\(\{""")
19
+ # A field starts as `<name>: z` — the base type may be on the next line
20
+ # (`stock: z\n .number()`), so we only anchor on `z` here and read the type
21
+ # from the field's segment.
22
+ _FIELD_START_RE = re.compile(r"""(?P<name>\b\w+)\s*:\s*z\b""")
23
+ _BASE_TYPE_RE = re.compile(
24
+ r"""\.\s*(?P<type>string|number|boolean|date|array|enum|object|coerce)\b"""
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class ZodField:
30
+ name: str
31
+ base_type: str # string | number | boolean | ...
32
+ required: bool
33
+ is_int: bool
34
+ min_value: float | None
35
+
36
+
37
+ def _extract_object_body(text: str, open_index: int) -> str:
38
+ """Return the substring inside the ``z.object({`` … ``})`` starting at open_index."""
39
+ depth = 0
40
+ i = open_index
41
+ start = -1
42
+ while i < len(text):
43
+ ch = text[i]
44
+ if ch == "{":
45
+ depth += 1
46
+ if depth == 1:
47
+ start = i + 1
48
+ elif ch == "}":
49
+ depth -= 1
50
+ if depth == 0:
51
+ return text[start:i]
52
+ i += 1
53
+ return ""
54
+
55
+
56
+ def _parse_fields(body: str) -> list[ZodField]:
57
+ starts = list(_FIELD_START_RE.finditer(body))
58
+ fields: list[ZodField] = []
59
+ for idx, m in enumerate(starts):
60
+ seg_end = starts[idx + 1].start() if idx + 1 < len(starts) else len(body)
61
+ segment = body[m.start() : seg_end]
62
+ name = m.group("name")
63
+ type_match = _BASE_TYPE_RE.search(segment)
64
+ if type_match is None:
65
+ continue # not a real field (e.g. a nested option object)
66
+ base = type_match.group("type")
67
+ if base == "coerce":
68
+ inner = _BASE_TYPE_RE.search(segment[type_match.end() :])
69
+ base = inner.group("type") if inner else "number"
70
+ required = ".optional(" not in segment and ".nullable(" not in segment
71
+ is_int = ".int(" in segment
72
+ min_value: float | None = None
73
+ mm = re.search(r"\.min\(\s*([0-9]+(?:\.[0-9]+)?)", segment)
74
+ if mm:
75
+ min_value = float(mm.group(1))
76
+ fields.append(
77
+ ZodField(
78
+ name=name, base_type=base, required=required, is_int=is_int, min_value=min_value
79
+ )
80
+ )
81
+ return fields
82
+
83
+
84
+ def find_create_schema(project_path: Path, resource: str) -> list[ZodField]:
85
+ """Find the create-payload fields for ``resource`` (e.g. 'products').
86
+
87
+ Looks for a schema whose name contains 'create' and the singular/plural of
88
+ the resource (``createProductSchema`` for resource ``products``). Falls back
89
+ to the first ``create*Schema`` found.
90
+ """
91
+ src = project_path / "src"
92
+ if not src.is_dir():
93
+ src = project_path
94
+ singular = resource[:-1] if resource.endswith("s") else resource
95
+ best: list[ZodField] | None = None
96
+ fallback: list[ZodField] | None = None
97
+
98
+ for f in sorted(src.rglob("*.ts")):
99
+ if ".d.ts" in f.name:
100
+ continue
101
+ text = f.read_text(encoding="utf-8", errors="replace")
102
+ for m in _SCHEMA_RE.finditer(text):
103
+ name = m.group("name")
104
+ body = _extract_object_body(text, m.end() - 1)
105
+ fields = _parse_fields(body)
106
+ if not fields:
107
+ continue
108
+ lname = name.lower()
109
+ if "create" in lname and (singular in lname or resource in lname):
110
+ best = fields
111
+ elif "create" in lname and fallback is None:
112
+ fallback = fields
113
+ return best if best is not None else (fallback or [])
114
+
115
+
116
+ def sample_value(field: ZodField, unique_token: str) -> object:
117
+ """Produce a valid Python value for a field (used to build request bodies)."""
118
+ if field.base_type == "string":
119
+ if field.name.lower() == "sku":
120
+ return f"SKU-{unique_token}"
121
+ if field.name.lower() in {"email"}:
122
+ return f"user_{unique_token}@example.com"
123
+ min_len = int(field.min_value or 0)
124
+ base = f"Suitest {field.name.title()}"
125
+ return base if len(base) >= min_len else base + "x" * (min_len - len(base))
126
+ if field.base_type == "number":
127
+ base_num = field.min_value if field.min_value is not None else 1
128
+ return int(base_num) + 9 if field.is_int else float(base_num) + 9.99
129
+ if field.base_type == "boolean":
130
+ return True
131
+ return f"val_{unique_token}"
@@ -0,0 +1,11 @@
1
+ """Blackbox DOM Discovery & Testing Engine (ZERO tier, no repo, no LLM).
2
+
3
+ Shared core used by three consumers (do not fork logic into any single one):
4
+
5
+ * **Zero** — deterministic end-to-end: discover → generate → run → evidence.
6
+ * **MCP** — each stage is exposed as a ``blackbox_*`` tool for IDE agents.
7
+ * **LLM** — the serialized discovery/graph JSON is handed to models as context.
8
+
9
+ Import modules directly (``from suitest_lifecycle.blackbox.crawler import …``);
10
+ this package intentionally re-exports nothing (no barrel imports).
11
+ """
@@ -0,0 +1,249 @@
1
+ """Bootstrap wizard — the TestSprite-style "prompt → browser form" flow.
2
+
3
+ The ``bootstrap_project`` MCP tool spins up a tiny stdlib web server on a
4
+ random localhost port, opens the user's browser at a one-page setup form
5
+ (target URL, credentials, crawl scope, optional **markdown PRD upload**),
6
+ writes ``suitest.config.json`` (+ ``PRD.md``) into the project directory, then
7
+ shuts itself down and returns the config path to the agent — which continues
8
+ the pipeline (discover → generate → run → report) unattended.
9
+
10
+ Stdlib only: http.server + email.parser for the multipart form.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ import webbrowser
18
+ from email.parser import BytesParser
19
+ from email.policy import HTTP
20
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ _FORM_HTML = """<!doctype html>
25
+ <html><head><meta charset="utf-8"><title>Suitest — Project Setup</title>
26
+ <style>
27
+ :root {{ --bg:#0a0a0a; --card:#111; --line:#262626; --fg:#fafafa; --mut:#a3a3a3;
28
+ --dim:#737373; --acc:#4ade80; }}
29
+ * {{ box-sizing:border-box; }}
30
+ body {{ margin:0; background:var(--bg); color:var(--fg);
31
+ font:14px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif; }}
32
+ .wrap {{ max-width:640px; margin:48px auto; padding:0 20px; }}
33
+ .logo {{ font-weight:700; font-size:18px; letter-spacing:-.02em; }}
34
+ .logo span {{ color:var(--acc); }}
35
+ h1 {{ font-size:22px; letter-spacing:-.01em; margin:24px 0 4px; }}
36
+ p.sub {{ color:var(--mut); margin:0 0 24px; font-size:13px; }}
37
+ .card {{ background:var(--card); border:1px solid var(--line); border-radius:10px;
38
+ padding:20px; margin-bottom:16px; }}
39
+ .card h2 {{ font-size:13px; margin:0 0 12px; color:var(--fg); }}
40
+ label {{ display:block; font-size:12px; color:var(--mut); margin:12px 0 4px; }}
41
+ input[type=text],input[type=password],input[type=number] {{ width:100%; padding:9px 11px;
42
+ background:var(--bg); border:1px solid var(--line); border-radius:7px; color:var(--fg);
43
+ font-size:13px; outline:none; }}
44
+ input:focus {{ border-color:var(--acc); }}
45
+ .row {{ display:grid; grid-template-columns:1fr 1fr; gap:12px; }}
46
+ .check {{ display:flex; gap:8px; align-items:center; margin-top:12px; font-size:13px;
47
+ color:var(--mut); }}
48
+ .check input {{ accent-color:var(--acc); }}
49
+ .drop {{ border:1px solid var(--line); border-radius:8px; padding:18px; text-align:center;
50
+ color:var(--dim); font-size:12.5px; background:var(--bg); }}
51
+ button {{ width:100%; margin-top:8px; padding:12px; background:var(--acc); color:#052e12;
52
+ font-weight:600; font-size:14px; border:0; border-radius:8px; cursor:pointer; }}
53
+ button:hover {{ opacity:.9; }}
54
+ .hint {{ color:var(--dim); font-size:11.5px; margin-top:4px; }}
55
+ </style></head>
56
+ <body><div class="wrap">
57
+ <div class="logo">sui<span>test</span></div>
58
+ <h1>Project setup</h1>
59
+ <p class="sub">Blackbox UI testing (ZERO tier — deterministic, no LLM key needed).
60
+ Project: <code>{project}</code></p>
61
+ <form method="post" action="/submit" enctype="multipart/form-data">
62
+ <div class="card">
63
+ <h2>Target</h2>
64
+ <label>Application URL *</label>
65
+ <input type="text" name="targetUrl" placeholder="http://localhost:3000" required>
66
+ <div class="row">
67
+ <div><label>Login path</label>
68
+ <input type="text" name="loginUrl" value="/login"></div>
69
+ <div><label>Output directory</label>
70
+ <input type="text" name="output" value="suitest-output"></div>
71
+ </div>
72
+ </div>
73
+ <div class="card">
74
+ <h2>Test credentials (optional — leave empty for public apps)</h2>
75
+ <div class="row">
76
+ <div><label>Username / email</label>
77
+ <input type="text" name="username" placeholder="qa@example.com"></div>
78
+ <div><label>Password</label>
79
+ <input type="password" name="password"></div>
80
+ </div>
81
+ </div>
82
+ <div class="card">
83
+ <h2>Crawl scope</h2>
84
+ <div class="row">
85
+ <div><label>Max routes</label><input type="number" name="maxRoutes" value="30"></div>
86
+ <div><label>Max depth</label><input type="number" name="maxDepth" value="3"></div>
87
+ </div>
88
+ <label>Exclude paths (comma separated)</label>
89
+ <input type="text" name="exclude" value="/logout, /billing, /payment">
90
+ <div class="check"><input type="checkbox" name="safeMode" checked>
91
+ Safe mode — never click destructive actions (recommended)</div>
92
+ <div class="check"><input type="checkbox" name="allowMutation">
93
+ Allow mutating form submits (only with a resettable test database)</div>
94
+ </div>
95
+ <div class="card">
96
+ <h2>Product spec (optional)</h2>
97
+ <div class="drop">
98
+ <input type="file" name="prd" accept=".md,.markdown"><br>
99
+ Markdown PRD — with a workspace LLM configured, the plan becomes
100
+ requirement-driven (TestSprite-style)
101
+ </div>
102
+ <div class="hint">Without a PRD the deterministic baseline suite is generated.</div>
103
+ </div>
104
+ <button type="submit">Save &amp; continue in your IDE</button>
105
+ </form>
106
+ </div></body></html>"""
107
+
108
+ _DONE_HTML = """<!doctype html><html><head><meta charset="utf-8">
109
+ <style>body{background:#0a0a0a;color:#fafafa;font:15px -apple-system,sans-serif;
110
+ display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
111
+ .b{text-align:center}.t{color:#4ade80;font-size:40px}</style></head>
112
+ <body><div class="b"><div class="t">&#10003;</div>
113
+ <h2>Configuration saved</h2><p style="color:#a3a3a3">You can close this tab —
114
+ your IDE agent is continuing with discovery &rarr; tests &rarr; report.</p>
115
+ </div></body></html>"""
116
+
117
+
118
+ class _State:
119
+ def __init__(self, project: Path) -> None:
120
+ self.project = project
121
+ self.done = threading.Event()
122
+ self.result: dict[str, Any] = {}
123
+
124
+
125
+ def _parse_multipart(ctype: str, body: bytes) -> dict[str, tuple[str, bytes]]:
126
+ """Return ``{field: (filename, value_bytes)}`` from a multipart POST."""
127
+ msg = BytesParser(policy=HTTP).parsebytes(
128
+ b"Content-Type: " + ctype.encode() + b"\r\n\r\n" + body
129
+ )
130
+ out: dict[str, tuple[str, bytes]] = {}
131
+ for part in msg.iter_parts():
132
+ name = part.get_param("name", header="content-disposition")
133
+ if not name:
134
+ continue
135
+ filename = part.get_filename() or ""
136
+ payload = part.get_payload(decode=True) or b""
137
+ out[str(name)] = (filename, payload)
138
+ return out
139
+
140
+
141
+ def _build_config(fields: dict[str, tuple[str, bytes]], project: Path) -> dict[str, Any]:
142
+ def val(key: str, default: str = "") -> str:
143
+ return fields.get(key, ("", b""))[1].decode("utf-8", "replace").strip() or default
144
+
145
+ target = val("targetUrl").rstrip("/")
146
+ prd_name, prd_bytes = fields.get("prd", ("", b""))
147
+ prd_rel = ""
148
+ if prd_name and prd_bytes.strip():
149
+ prd_rel = "PRD.md"
150
+ (project / prd_rel).write_bytes(prd_bytes)
151
+
152
+ config: dict[str, Any] = {
153
+ "mode": "frontend",
154
+ "projectName": project.name or "blackbox-project",
155
+ "baseUrl": target,
156
+ "output": val("output", "suitest-output"),
157
+ "server": {"autostart": False},
158
+ # Publishing is mandatory in the blackbox pipeline — the run stage
159
+ # pushes into the Suitest TCM whenever the MCP server has credentials.
160
+ "publish": {"enabled": True},
161
+ "ui": {
162
+ "mode": "blackbox",
163
+ "targetUrl": target,
164
+ "auth": {
165
+ "strategy": "form",
166
+ "loginUrl": val("loginUrl", "/login"),
167
+ "username": val("username"),
168
+ "password": val("password"),
169
+ },
170
+ "crawl": {
171
+ "maxDepth": int(val("maxDepth", "3") or 3),
172
+ "maxRoutes": int(val("maxRoutes", "30") or 30),
173
+ "exclude": [x.strip() for x in val("exclude").split(",") if x.strip()],
174
+ "safeMode": "safeMode" in fields,
175
+ },
176
+ "testGeneration": {"allowMutation": "allowMutation" in fields},
177
+ },
178
+ }
179
+ if prd_rel:
180
+ config["prdFile"] = prd_rel
181
+ return config
182
+
183
+
184
+ def _make_handler(state: _State) -> type[BaseHTTPRequestHandler]:
185
+ class Handler(BaseHTTPRequestHandler):
186
+ def log_message(self, *args: object) -> None: # keep MCP stdio clean
187
+ pass
188
+
189
+ def _send(self, html: str, code: int = 200) -> None:
190
+ data = html.encode()
191
+ self.send_response(code)
192
+ self.send_header("Content-Type", "text/html; charset=utf-8")
193
+ self.send_header("Content-Length", str(len(data)))
194
+ self.end_headers()
195
+ self.wfile.write(data)
196
+
197
+ def do_GET(self) -> None:
198
+ self._send(_FORM_HTML.format(project=state.project))
199
+
200
+ def do_POST(self) -> None:
201
+ length = int(self.headers.get("Content-Length", "0"))
202
+ fields = _parse_multipart(self.headers.get("Content-Type", ""), self.rfile.read(length))
203
+ config = _build_config(fields, state.project)
204
+ config_path = state.project / "suitest.config.json"
205
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
206
+ state.result = {
207
+ "configPath": str(config_path),
208
+ "targetUrl": config["baseUrl"],
209
+ "prdFile": config.get("prdFile", ""),
210
+ "safeMode": config["ui"]["crawl"]["safeMode"],
211
+ }
212
+ self._send(_DONE_HTML)
213
+ state.done.set()
214
+
215
+ return Handler
216
+
217
+
218
+ def run_bootstrap_wizard(
219
+ project_path: str | Path = ".",
220
+ *,
221
+ open_browser: bool = True,
222
+ timeout_sec: int = 600,
223
+ on_ready: Any = None,
224
+ ) -> dict[str, Any]:
225
+ """Serve the setup form; block until submitted (or timeout). Returns
226
+ ``{configPath, targetUrl, prdFile, safeMode, url}``; empty dict on timeout.
227
+ ``on_ready(url)`` is invoked once the server is listening (tests hook this).
228
+ """
229
+ project = Path(project_path).resolve()
230
+ project.mkdir(parents=True, exist_ok=True)
231
+ state = _State(project)
232
+ server = ThreadingHTTPServer(("127.0.0.1", 0), _make_handler(state))
233
+ url = f"http://127.0.0.1:{server.server_address[1]}/"
234
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
235
+ thread.start()
236
+ try:
237
+ if on_ready is not None:
238
+ on_ready(url)
239
+ if open_browser:
240
+ webbrowser.open(url)
241
+ finished = state.done.wait(timeout=timeout_sec)
242
+ finally:
243
+ server.shutdown()
244
+ if not finished:
245
+ return {}
246
+ return {**state.result, "url": url}
247
+
248
+
249
+ __all__ = ["run_bootstrap_wizard"]