@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,365 @@
1
+ """LLM bridge — lifecycle-side client for the Suitest LLM proxy.
2
+
3
+ The lifecycle never holds an LLM provider key. It calls
4
+ ``POST /api/v1/llm/complete`` on the Suitest server (authenticated with the
5
+ same ``SUITEST_API_KEY`` used for publishing); the server runs the completion
6
+ against the workspace's active, AES-encrypted LLM config. No config / ZERO
7
+ tier → the server answers 409 and every entry point here degrades to ``None``
8
+ so the deterministic baseline keeps working.
9
+
10
+ Two capabilities:
11
+
12
+ * :meth:`RemoteLlmClient.propose_edge_cases` — real plan enrichment
13
+ (implements the :class:`suitest_lifecycle.enrich.LlmClient` protocol).
14
+ * :meth:`RemoteLlmClient.generate_frontend_body` — TestSprite-style Playwright
15
+ codegen: given a plan case + the crawled DOM digest, the model writes the
16
+ ``async def _body(page)`` for apps that don't follow any testid convention.
17
+
18
+ The SDK import stays lazy so the lifecycle core remains stdlib-only.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import os
25
+ import re
26
+ from typing import TYPE_CHECKING
27
+
28
+ from suitest_lifecycle.enrich import EdgeSuggestion
29
+ from suitest_lifecycle.models import Priority
30
+
31
+ if TYPE_CHECKING:
32
+ from suitest_lifecycle.analyzers.crawl import CrawlResult
33
+ from suitest_lifecycle.config import Config
34
+ from suitest_lifecycle.models import CodeSummary, PlanCase
35
+
36
+ _PRIORITY = {"high": Priority.HIGH, "medium": Priority.MEDIUM, "low": Priority.LOW}
37
+
38
+ # The generated body runs inside the fixed exporter wrapper — these names are
39
+ # in scope. The model must use them and nothing else needs importing.
40
+ _CODEGEN_SYSTEM = """You are a senior QA automation engineer writing one Playwright (Python, async) test body.
41
+
42
+ Output ONLY Python code (no markdown fences, no prose) of exactly this shape:
43
+
44
+ async def _body(page):
45
+ _begin("action", "<step description>")
46
+ ...playwright actions...
47
+ await _ok(page)
48
+ _begin("assertion", "<what is verified>")
49
+ ...expect(...) assertions...
50
+ await _ok(page)
51
+
52
+ Contract (already in scope — do NOT import anything):
53
+ - page: playwright.async_api Page; expect: playwright's expect; TIMEOUT (ms)
54
+ - BASE_URL, USERNAME, PASSWORD: strings from config
55
+ - _begin(step_type, description): starts a recorded step ("action"/"assertion")
56
+ - await _ok(page): closes the current step (screenshot). EVERY step needs it.
57
+ - Do NOT call _login() unless the provided login selectors are data-testids.
58
+
59
+ Selector policy, in priority order:
60
+ 1. data-testid from the provided page digest → page.get_by_test_id("...")
61
+ 2. otherwise the digest's input name/placeholder/type → page.locator('input[name="..."]') / get_by_placeholder
62
+ 3. otherwise button text → page.get_by_role("button", name="...")
63
+ Never invent selectors that are not derivable from the digest.
64
+ Keep it to 3-7 steps.
65
+ Assertion policy — assert ONLY what the planned steps demand, using observable outcomes:
66
+ - element visibility (expect(...).to_be_visible(timeout=TIMEOUT)), URL ("..." in page.url), row counts, visible text.
67
+ - NEVER assert focus, CSS, animation, or any behavior the digest/steps do not explicitly state.
68
+ - NEVER assert literal values you did not see in the digest — no guessed URLs, route names,
69
+ user names, or emails. For navigation outcomes assert the URL CHANGED from the previous
70
+ route (capture `before = page.url` first), or that a digest element became visible.
71
+ - For negative cases (invalid/empty input) assert: an error element is visible OR the URL did not change — nothing more.
72
+
73
+ API cheat-sheet — use ONLY these patterns (anything else risks a runtime error):
74
+ await locator.fill("...") / await locator.click()
75
+ await expect(locator).to_be_visible(timeout=TIMEOUT)
76
+ await expect(locator).to_contain_text("...")
77
+ n = await locator.count()
78
+ before = page.url
79
+ ...action...
80
+ await page.wait_for_timeout(800)
81
+ assert page.url != before # navigation happened
82
+ assert page.url == before # stayed put
83
+ Forbidden (will crash): the `re` module (NOT in scope), expect(...).to_have_url(...),
84
+ page.wait_for_url(...) inside the body, calling a Locator like a function.
85
+ Placeholders/labels are ATTRIBUTES, not text — never assert them via to_contain_text;
86
+ assert the element is visible instead. Text assertions may use ONLY strings that appear
87
+ in a route's "visible text" sample, copied EXACTLY (matching is case-sensitive).
88
+ ALWAYS append .first to any get_by_text/get_by_role lookup you write yourself —
89
+ strict mode fails on multiple matches.
90
+ """
91
+
92
+
93
+ def _strip_fences(text: str) -> str:
94
+ t = text.strip()
95
+ if t.startswith("```"):
96
+ t = re.sub(r"^```[a-zA-Z]*\n", "", t)
97
+ t = re.sub(r"\n```\s*$", "", t)
98
+ return t.strip()
99
+
100
+
101
+ def _extract_json_array(text: str) -> list[object]:
102
+ """Best-effort: find the first JSON array in the completion."""
103
+ t = _strip_fences(text)
104
+ start, end = t.find("["), t.rfind("]")
105
+ if start == -1 or end <= start:
106
+ return []
107
+ try:
108
+ parsed = json.loads(t[start : end + 1])
109
+ except ValueError:
110
+ return []
111
+ return parsed if isinstance(parsed, list) else []
112
+
113
+
114
+ class RemoteLlmClient:
115
+ """LLM client backed by the Suitest server's ``/llm/complete`` proxy."""
116
+
117
+ def __init__(self, api_url: str, token: str, workspace_id: str | None = None) -> None:
118
+ self._api_url = api_url
119
+ self._token = token
120
+ self._workspace_id = workspace_id
121
+ self._disabled = False # flipped on 409/tier errors → deterministic fallback
122
+
123
+ # -- transport -----------------------------------------------------------
124
+ def _complete(self, prompt: str, *, system: str | None = None, max_tokens: int = 4096) -> str:
125
+ if self._disabled:
126
+ return ""
127
+ try:
128
+ from suitest_sdk import SuitestAPIError, SuitestClient
129
+ except ImportError:
130
+ self._disabled = True
131
+ return ""
132
+ try:
133
+ with SuitestClient(
134
+ self._api_url,
135
+ token=self._token,
136
+ workspace_id=self._workspace_id or None,
137
+ # Plan/codegen completions run long on slow gateways — the SDK
138
+ # default (30s) regularly times out mid-generation.
139
+ timeout=180.0,
140
+ ) as client:
141
+ return client.llm_complete(prompt, system=system, max_tokens=max_tokens)
142
+ except SuitestAPIError as exc: # 409 = no LLM (ZERO tier) → stop asking
143
+ if getattr(exc, "status_code", None) == 409:
144
+ self._disabled = True
145
+ return ""
146
+ except Exception: # network hiccup — never fail the run over enrichment
147
+ return ""
148
+
149
+ # -- enrichment (LlmClient protocol) --------------------------------------
150
+ def propose_edge_cases(
151
+ self, summary: CodeSummary, existing_titles: set[str]
152
+ ) -> list[EdgeSuggestion]:
153
+ endpoints = [f"{e.method} {e.path}" for e in summary.endpoints][:40]
154
+ pages = [f"{p.route} ({'protected' if p.protected else 'public'})" for p in summary.pages][
155
+ :40
156
+ ]
157
+ prompt = (
158
+ "Propose up to 5 NEW high-value edge-case tests for this app.\n"
159
+ f"Mode: {summary.mode.value}\n"
160
+ f"Endpoints: {json.dumps(endpoints)}\n"
161
+ f"Pages: {json.dumps(pages)}\n"
162
+ f"Existing test titles (do NOT duplicate): {json.dumps(sorted(existing_titles))}\n\n"
163
+ "Reply with ONLY a JSON array; each item:\n"
164
+ '{"title": "snake_case_sentence", "description": "...", "category": "...",'
165
+ ' "priority": "High|Medium|Low", "source_ref": "<METHOD /path or fe:llm /route>",'
166
+ ' "steps": [["action", "..."], ["assertion", "..."]]}'
167
+ )
168
+ out: list[EdgeSuggestion] = []
169
+ for item in _extract_json_array(self._complete(prompt, max_tokens=2048)):
170
+ if not isinstance(item, dict):
171
+ continue
172
+ title = str(item.get("title", "")).strip().lower().replace(" ", "_")
173
+ if not title or title in existing_titles:
174
+ continue
175
+ steps_raw = item.get("steps")
176
+ steps: list[tuple[str, str]] = []
177
+ if isinstance(steps_raw, list):
178
+ for s in steps_raw:
179
+ if isinstance(s, (list, tuple)) and len(s) == 2:
180
+ kind = "assertion" if str(s[0]).lower() == "assertion" else "action"
181
+ steps.append((kind, str(s[1])))
182
+ if not steps:
183
+ continue
184
+ out.append(
185
+ EdgeSuggestion(
186
+ archetype="llm",
187
+ title=title[:200],
188
+ description=str(item.get("description", ""))[:500],
189
+ category=str(item.get("category", "LLM"))[:60] or "LLM",
190
+ priority=_PRIORITY.get(str(item.get("priority", "")).lower(), Priority.MEDIUM),
191
+ source_ref=str(item.get("source_ref", "")) or "fe:llm /",
192
+ steps=steps,
193
+ )
194
+ )
195
+ if len(out) >= 5:
196
+ break
197
+ return out
198
+
199
+ # -- PRD-driven planning (TestSprite-parity upload flow) --------------------
200
+ def plan_from_prd(
201
+ self,
202
+ prd_context: str,
203
+ app_context: str,
204
+ existing_titles: set[str],
205
+ *,
206
+ max_cases: int = 15,
207
+ allow_mutation: bool = False,
208
+ ) -> list[dict[str, object]]:
209
+ """Turn an uploaded PRD (markdown) + the app's discovered reality into
210
+ a semantic test plan. Returns raw case dicts; the caller builds
211
+ PlanCases and generates code. Empty list = LLM unavailable/unusable →
212
+ caller keeps the deterministic baseline.
213
+ """
214
+ mutation_rule = (
215
+ "Mutating flows (create/update) ARE allowed when the PRD demands them."
216
+ if allow_mutation
217
+ else (
218
+ "SAFE MODE: never propose destructive/mutating flows "
219
+ "(no delete/publish/payment; creates only when clearly reversible)."
220
+ )
221
+ )
222
+ prompt = (
223
+ "You are planning UI tests for a web app from its product spec.\n\n"
224
+ f"=== PRODUCT SPEC (uploaded PRD) ===\n{prd_context}\n\n"
225
+ f"=== DISCOVERED APP REALITY (live DOM crawl) ===\n{app_context}\n\n"
226
+ f"Existing test titles (do NOT duplicate): {json.dumps(sorted(existing_titles))}\n\n"
227
+ f"Propose up to {max_cases} tests that VERIFY THE PRD'S REQUIREMENTS "
228
+ "against the discovered routes/elements. Cover positive AND negative "
229
+ "paths per requirement. Only reference routes that exist in the "
230
+ "discovery. The app may be freshly installed (empty data) — prefer "
231
+ "flows that hold in an empty state (empty-state visible, validation, "
232
+ f"auth) or that create their own data first. {mutation_rule}\n\n"
233
+ "Reply with ONLY a JSON array; each item:\n"
234
+ '{"title": "snake_case_sentence", "description": "which PRD requirement '
235
+ 'this verifies", "category": "...", "priority": "High|Medium|Low", '
236
+ '"route": "/discovered/route", '
237
+ '"steps": [["action", "..."], ["assertion", "..."]]}'
238
+ )
239
+ out: list[dict[str, object]] = []
240
+ for item in _extract_json_array(self._complete(prompt, max_tokens=6000)):
241
+ if not isinstance(item, dict):
242
+ continue
243
+ title = str(item.get("title", "")).strip().lower().replace(" ", "_")
244
+ steps = item.get("steps")
245
+ if not title or title in existing_titles or not isinstance(steps, list):
246
+ continue
247
+ existing_titles.add(title)
248
+ out.append(item)
249
+ if len(out) >= max_cases:
250
+ break
251
+ return out
252
+
253
+ # -- frontend codegen ------------------------------------------------------
254
+ def generate_frontend_body(self, case: PlanCase, dom_context: str) -> str | None:
255
+ """Return validated ``async def _body(page)`` source, or None on failure."""
256
+ prompt = (
257
+ f"Test case: {case.title}\n"
258
+ f"Intent: {case.description}\n"
259
+ "Planned steps:\n"
260
+ + "\n".join(f"- [{s.type}] {s.description}" for s in case.steps)
261
+ + f"\n\nApp context (crawled live DOM):\n{dom_context}\n"
262
+ )
263
+ code = _strip_fences(self._complete(prompt, system=_CODEGEN_SYSTEM, max_tokens=3000))
264
+ return code if _valid_body(code) else None
265
+
266
+
267
+ # Compiles fine but crashes at runtime — reject and fall back instead.
268
+ _RUNTIME_LANDMINES = (
269
+ re.compile(r"\bre\."), # `re` is not in the generated file's scope
270
+ re.compile(r"\.to_have_url\("), # models pass locators/invalid values here
271
+ re.compile(r"page\.wait_for_url\("), # bodies must use the before/after-url pattern
272
+ )
273
+
274
+
275
+ def _valid_body(code: str) -> bool:
276
+ """Structural gate on LLM output — wrong shape falls back to deterministic."""
277
+ if not code.startswith("async def _body(page):"):
278
+ return False
279
+ if "_ok(page)" not in code or "_begin(" not in code:
280
+ return False
281
+ if re.search(r"^\s*(import|from)\s", code, re.M): # wrapper provides everything
282
+ return False
283
+ if any(rx.search(code) for rx in _RUNTIME_LANDMINES):
284
+ return False
285
+ try:
286
+ compile(code, "<llm-body>", "exec")
287
+ except SyntaxError:
288
+ return False
289
+ return True
290
+
291
+
292
+ def build_dom_context(crawl: CrawlResult | None, summary: CodeSummary) -> str:
293
+ """Compact per-route digest the codegen prompt can rely on."""
294
+ lines: list[str] = []
295
+ if crawl is not None and crawl.login.email:
296
+ lines.append(
297
+ "Login selectors (data-testid): "
298
+ f"email={crawl.login.email} password={crawl.login.password} "
299
+ f"submit={crawl.login.submit} error={crawl.login.error or '-'}"
300
+ )
301
+ for p in summary.pages[:12]:
302
+ lines.append(f"Route {p.route} ({'protected' if p.protected else 'public'}):")
303
+ if crawl is not None:
304
+ tids = crawl.page_testids.get(p.route, [])
305
+ if tids:
306
+ lines.append(f" testids: {json.dumps(tids[:30])}")
307
+ els = crawl.page_elements.get(p.route, {})
308
+ if els.get("inputs"):
309
+ lines.append(f" inputs: {json.dumps(els['inputs'][:15])}")
310
+ if els.get("buttons"):
311
+ lines.append(f" buttons: {json.dumps(els['buttons'][:15])}")
312
+ return "\n".join(lines) or "No DOM digest available — rely on the planned steps only."
313
+
314
+
315
+ def build_dom_context_from_discovery(discovery: object) -> str:
316
+ """Compact digest of a blackbox ``DiscoveryResult`` for codegen prompts."""
317
+ from suitest_lifecycle.blackbox.models import DiscoveryResult
318
+ from suitest_lifecycle.blackbox.selector import build_locator, describe
319
+
320
+ if not isinstance(discovery, DiscoveryResult):
321
+ return "No DOM digest available — rely on the planned steps only."
322
+ lines: list[str] = []
323
+ if discovery.login is not None and discovery.login.found():
324
+ lines.append(
325
+ "Login locators (Playwright expressions, ready to use): "
326
+ f"username={discovery.login.username} password={discovery.login.password} "
327
+ f"submit={discovery.login.submit}"
328
+ )
329
+ lines.append(
330
+ "A helper `await _bb_login(page)` performing this login IS in scope — "
331
+ "use it instead of re-implementing login."
332
+ )
333
+ for p in discovery.pages[:12]:
334
+ lines.append(f"Route {p.route} (pattern={p.pattern}):")
335
+ if p.row_locator:
336
+ lines.append(f" rows: {p.row_locator}")
337
+ if p.search_locator:
338
+ lines.append(f" search: {p.search_locator}")
339
+ for e in (p.inputs + p.buttons)[:14]:
340
+ lines.append(f" {e.kind or e.tag} '{describe(e)}': {build_locator(e)}")
341
+ if p.visible_text_sample:
342
+ sample = " ".join(p.visible_text_sample.split())[:280]
343
+ lines.append(f" visible text (assert ONLY strings occurring here): {sample}")
344
+ return "\n".join(lines)[:14_000]
345
+
346
+
347
+ def resolve_remote(config: Config) -> RemoteLlmClient | None:
348
+ """Build the proxy client from publish config / env; None when unreachable.
349
+
350
+ Mirrors :mod:`suitest_lifecycle.publish` secret resolution: config wins,
351
+ env (``SUITEST_API_URL`` / ``SUITEST_API_KEY``) fills the gaps.
352
+ """
353
+ api_url = config.publish.api_url or os.environ.get("SUITEST_API_URL", "")
354
+ token = config.publish.token or os.environ.get("SUITEST_API_KEY", "")
355
+ if not api_url or not token:
356
+ return None
357
+ return RemoteLlmClient(api_url, token, config.publish.workspace_id or None)
358
+
359
+
360
+ __all__ = [
361
+ "RemoteLlmClient",
362
+ "build_dom_context",
363
+ "build_dom_context_from_discovery",
364
+ "resolve_remote",
365
+ ]
@@ -0,0 +1,187 @@
1
+ """Minimal MCP stdio server exposing the Suitest lifecycle tools.
2
+
3
+ Speaks newline-delimited JSON-RPC 2.0 (the MCP stdio framing): ``initialize``,
4
+ ``tools/list``, ``tools/call``. Stdlib-only so it runs anywhere ``python`` does::
5
+
6
+ python -m suitest_lifecycle.mcp_server
7
+
8
+ Every tool takes a single ``config_path`` argument and returns the structured
9
+ ``{success, summary, data, artifacts, errors}`` envelope as JSON text content.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ from typing import TYPE_CHECKING, TextIO
17
+
18
+ from suitest_lifecycle.tools import KWARG_TOOLS, TOOLS
19
+
20
+ if TYPE_CHECKING:
21
+ from collections.abc import Callable
22
+
23
+ PROTOCOL_VERSION = "2024-11-05"
24
+
25
+ _TOOL_DESCRIPTIONS = {
26
+ "analyze_project": "Static-analyze the target project; list endpoints (backend) or pages (frontend).",
27
+ "generate_test_cases": "Analyze, build a PRD + test plan, and export runnable test files.",
28
+ "generate_backend_tests": "Generate backend (requests) test files. Errors if config mode != backend.",
29
+ "generate_frontend_tests": "Generate frontend (playwright) test files. Errors if config mode != frontend.",
30
+ "run_backend_tests": "Full backend lifecycle: start, wait ready, run, report. Mode-guarded.",
31
+ "run_frontend_tests": "Full frontend lifecycle: start, wait ready, run, report. Mode-guarded.",
32
+ "run_tests": "Run the full lifecycle for whatever mode the config declares.",
33
+ "sync_tcm": "Report the TCM mirror (case/run counts + file paths).",
34
+ "generate_report": "Re-surface the last run's report artifacts without re-running.",
35
+ "bootstrap_project": "Open a browser setup wizard (target URL, credentials, crawl scope, optional markdown PRD upload); writes suitest.config.json into the project and returns its path. Call this FIRST when no config exists.",
36
+ "blackbox_discover_app": "Blackbox: open the app URL, detect+perform login, crawl routes, capture evidence, save discovery/graph/report JSON. No repo needed.",
37
+ "blackbox_detect_login": "Blackbox: detect the login form (username/password/submit locators) on the target — heuristics, no data-testid required.",
38
+ "blackbox_perform_login": "Blackbox: detect the login form and actually log in with the given credentials; reports the landing route.",
39
+ "blackbox_crawl_routes": "Blackbox: login + safe BFS crawl; returns the route map (safeMode skips destructive links).",
40
+ "blackbox_analyze_page": "Blackbox: classify one page (login/dashboard/list/form/…); returns its interactive elements + evidence screenshot.",
41
+ "blackbox_build_interaction_graph": "Blackbox: build the serializable interaction graph (page/form/table/modal nodes) from the saved discovery.",
42
+ "blackbox_generate_playwright_tests": "Blackbox: deterministically generate Playwright tests (smoke/auth/navigation/lists/forms) from the saved discovery.",
43
+ "blackbox_run_playwright_tests": "Blackbox: execute the generated tests; per-case outcomes + video/screenshot evidence.",
44
+ "blackbox_collect_evidence": "Blackbox: index all evidence (screenshots, videos, traces, report JSONs).",
45
+ "blackbox_publish_results": "Publish the blackbox suite + latest run (video/screenshot evidence) into the Suitest web TCM. Needs project_id (or publish.projectId in config).",
46
+ "blackbox_summarize_findings": "Blackbox: one JSON summary — route map, bug candidates, test outcomes — for agent reasoning.",
47
+ }
48
+
49
+
50
+ _BLACKBOX_INPUT_SCHEMA: dict[str, object] = {
51
+ "type": "object",
52
+ "properties": {
53
+ "config_path": {
54
+ "type": "string",
55
+ "description": "Optional suitest.config.json with a 'ui' blackbox section",
56
+ },
57
+ "url": {"type": "string", "description": "Target app URL (overrides config)"},
58
+ "username": {"type": "string", "description": "Test credential username/email"},
59
+ "password": {"type": "string", "description": "Test credential password"},
60
+ "max_routes": {"type": "integer", "description": "Crawl route cap"},
61
+ "page_url": {
62
+ "type": "string",
63
+ "description": "Route or absolute URL (blackbox_analyze_page only)",
64
+ },
65
+ "project_path": {
66
+ "type": "string",
67
+ "description": "Project directory for the setup wizard (bootstrap_project)",
68
+ },
69
+ "timeout_sec": {
70
+ "type": "integer",
71
+ "description": "How long to wait for the user to submit the wizard (bootstrap_project)",
72
+ },
73
+ "project_id": {
74
+ "type": "string",
75
+ "description": "Suitest project id to publish into (blackbox_publish_results)",
76
+ },
77
+ "prd_file": {
78
+ "type": "string",
79
+ "description": "Markdown PRD path — PRD-driven semantic plan via the workspace LLM (blackbox_generate_playwright_tests)",
80
+ },
81
+ },
82
+ "required": [],
83
+ }
84
+
85
+
86
+ def _tool_schema(name: str) -> dict[str, object]:
87
+ if name in KWARG_TOOLS:
88
+ return {
89
+ "name": name,
90
+ "description": _TOOL_DESCRIPTIONS.get(name, name),
91
+ "inputSchema": _BLACKBOX_INPUT_SCHEMA,
92
+ }
93
+ return {
94
+ "name": name,
95
+ "description": _TOOL_DESCRIPTIONS.get(name, name),
96
+ "inputSchema": {
97
+ "type": "object",
98
+ "properties": {
99
+ "config_path": {
100
+ "type": "string",
101
+ "description": "Path to suitest.config.json",
102
+ "default": "suitest.config.json",
103
+ }
104
+ },
105
+ "required": ["config_path"],
106
+ },
107
+ }
108
+
109
+
110
+ def _ok(req_id: object, result: dict[str, object]) -> dict[str, object]:
111
+ return {"jsonrpc": "2.0", "id": req_id, "result": result}
112
+
113
+
114
+ def _err(req_id: object, code: int, message: str) -> dict[str, object]:
115
+ return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}
116
+
117
+
118
+ def handle(message: dict[str, object]) -> dict[str, object] | None:
119
+ method = message.get("method")
120
+ req_id = message.get("id")
121
+ if method == "initialize":
122
+ return _ok(
123
+ req_id,
124
+ {
125
+ "protocolVersion": PROTOCOL_VERSION,
126
+ "capabilities": {"tools": {}},
127
+ "serverInfo": {"name": "suitest-lifecycle", "version": "0.1.0"},
128
+ },
129
+ )
130
+ if method in ("notifications/initialized", "initialized"):
131
+ return None # notification, no response
132
+ if method == "tools/list":
133
+ return _ok(req_id, {"tools": [_tool_schema(n) for n in TOOLS]})
134
+ if method == "tools/call":
135
+ params = message.get("params") or {}
136
+ if not isinstance(params, dict):
137
+ return _err(req_id, -32602, "invalid params")
138
+ name = params.get("name")
139
+ args = params.get("arguments") or {}
140
+ tool: Callable[..., dict[str, object]] | None = TOOLS.get(str(name))
141
+ if tool is None:
142
+ return _err(req_id, -32601, f"unknown tool: {name}")
143
+ arguments = args if isinstance(args, dict) else {}
144
+ try:
145
+ if str(name) in KWARG_TOOLS:
146
+ envelope = tool(**arguments)
147
+ else:
148
+ envelope = tool(str(arguments.get("config_path", "suitest.config.json")))
149
+ except Exception as exc: # defensive: never crash the server on a tool bug
150
+ envelope = {
151
+ "success": False,
152
+ "summary": f"tool crashed: {exc}",
153
+ "data": {},
154
+ "artifacts": [],
155
+ "errors": [str(exc)],
156
+ }
157
+ return _ok(
158
+ req_id,
159
+ {
160
+ "content": [{"type": "text", "text": json.dumps(envelope)}],
161
+ "isError": not bool(envelope.get("success")),
162
+ },
163
+ )
164
+ if req_id is not None:
165
+ return _err(req_id, -32601, f"method not found: {method}")
166
+ return None
167
+
168
+
169
+ def serve(stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout) -> None:
170
+ for line in stdin:
171
+ line = line.strip()
172
+ if not line:
173
+ continue
174
+ try:
175
+ message = json.loads(line)
176
+ except json.JSONDecodeError:
177
+ continue
178
+ if not isinstance(message, dict):
179
+ continue
180
+ response = handle(message)
181
+ if response is not None:
182
+ stdout.write(json.dumps(response) + "\n")
183
+ stdout.flush()
184
+
185
+
186
+ if __name__ == "__main__":
187
+ serve()