@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,107 @@
1
+ """Interaction graph — serializable JSON view of the discovered app.
2
+
3
+ Nodes: page / form / table / modal / action. Edges: navigation / submit /
4
+ validation. Zero's generator walks it deterministically; MCP hands it to IDE
5
+ agents; LLM mode uses it as reasoning context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from suitest_lifecycle.blackbox.detector import is_destructive
13
+ from suitest_lifecycle.blackbox.selector import build_locator, describe
14
+
15
+ if TYPE_CHECKING:
16
+ from suitest_lifecycle.blackbox.models import DiscoveryResult
17
+
18
+
19
+ def build_graph(discovery: DiscoveryResult) -> dict[str, Any]:
20
+ nodes: list[dict[str, Any]] = []
21
+ edges: list[dict[str, Any]] = []
22
+
23
+ def node(nid: str, kind: str, **attrs: Any) -> None:
24
+ nodes.append({"id": nid, "kind": kind, **attrs})
25
+
26
+ def edge(src: str, dst: str, kind: str, **attrs: Any) -> None:
27
+ edges.append({"from": src, "to": dst, "kind": kind, **attrs})
28
+
29
+ for p in discovery.pages:
30
+ pid = f"page:{p.route}"
31
+ node(
32
+ pid,
33
+ "page",
34
+ route=p.route,
35
+ pattern=p.pattern,
36
+ protected=p.protected,
37
+ title=p.title,
38
+ blank=p.blank,
39
+ consoleErrors=len(p.console_errors),
40
+ networkErrors=len(p.network_errors),
41
+ )
42
+ for target in p.nav_routes:
43
+ edge(pid, f"page:{target}", "navigation")
44
+ if p.has_form:
45
+ fid = f"form:{p.route}"
46
+ node(
47
+ fid,
48
+ "form",
49
+ route=p.route,
50
+ fields=[
51
+ {
52
+ "label": describe(e),
53
+ "locator": build_locator(e),
54
+ "type": e.input_type or e.kind,
55
+ "required": e.required,
56
+ }
57
+ for e in p.inputs
58
+ if e.input_type not in ("hidden",)
59
+ ][:20],
60
+ )
61
+ submit = next(
62
+ (b for b in p.buttons if not is_destructive(b)),
63
+ None,
64
+ )
65
+ if submit is not None:
66
+ edge(
67
+ fid,
68
+ pid,
69
+ "submit",
70
+ locator=build_locator(submit),
71
+ destructive=False,
72
+ )
73
+ edge(fid, pid, "validation", note="empty-required-submit is the safe probe")
74
+ if p.has_table:
75
+ tid = f"table:{p.route}"
76
+ node(tid, "table", route=p.route, rowLocator=p.row_locator)
77
+ edge(pid, tid, "navigation")
78
+ if p.has_modal:
79
+ mid = f"modal:{p.route}"
80
+ node(mid, "modal", route=p.route)
81
+ edge(pid, mid, "navigation")
82
+ for b in p.buttons[:20]:
83
+ if is_destructive(b):
84
+ continue
85
+ aid = f"action:{p.route}:{describe(b)}"
86
+ node(aid, "action", route=p.route, label=describe(b), locator=build_locator(b))
87
+ edge(pid, aid, "navigation")
88
+
89
+ if discovery.login is not None:
90
+ edge(
91
+ f"page:{discovery.login.route}",
92
+ f"page:{discovery.login_probe.landed_route or '/'}",
93
+ "submit",
94
+ note="login",
95
+ )
96
+
97
+ return {
98
+ "baseUrl": discovery.base_url,
99
+ "nodes": nodes,
100
+ "edges": edges,
101
+ "login": discovery.login.to_json() if discovery.login else None,
102
+ "loginProbe": discovery.login_probe.to_json(),
103
+ "skippedRoutes": discovery.skipped_routes,
104
+ }
105
+
106
+
107
+ __all__ = ["build_graph"]
@@ -0,0 +1,546 @@
1
+ """MCP tool implementations for the blackbox engine.
2
+
3
+ Each tool takes plain JSON kwargs and returns the standard lifecycle envelope
4
+ (``success/summary/data/artifacts/errors``) so IDE agents (Claude Code, Cursor,
5
+ Codex) can chain them: discover → graph → generate → run → summarize. State is
6
+ persisted as JSON files under the run's output dir, so every stage can also be
7
+ called independently in a fresh session.
8
+
9
+ Config resolution per call: an explicit ``config_path`` (suitest.config.json
10
+ with a ``ui`` section) wins; bare ``url``/``username``/``password`` kwargs are
11
+ enough for the no-config quick path.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from suitest_lifecycle.blackbox.models import BlackboxUiConfig, DiscoveryResult
21
+ from suitest_lifecycle.models import Mode
22
+ from suitest_lifecycle.paths import Paths, build_paths
23
+
24
+
25
+ def _envelope(
26
+ success: bool,
27
+ summary: str,
28
+ data: dict[str, Any] | None = None,
29
+ artifacts: list[str] | None = None,
30
+ errors: list[str] | None = None,
31
+ ) -> dict[str, Any]:
32
+ return {
33
+ "success": success,
34
+ "summary": summary,
35
+ "data": data or {},
36
+ "artifacts": artifacts or [],
37
+ "errors": errors or [],
38
+ }
39
+
40
+
41
+ def _resolve(
42
+ config_path: str = "",
43
+ url: str = "",
44
+ username: str = "",
45
+ password: str = "",
46
+ max_routes: int = 0,
47
+ **_: Any,
48
+ ) -> tuple[BlackboxUiConfig, Paths]:
49
+ ui = BlackboxUiConfig()
50
+ # Absolute: the runner executes tests with cwd=<test dir>, so a relative
51
+ # output root would double-resolve.
52
+ out_dir = Path("suitest-output").resolve()
53
+ if config_path:
54
+ from suitest_lifecycle.config import load_config
55
+
56
+ cfg = load_config(config_path)
57
+ if isinstance(cfg.ui, BlackboxUiConfig):
58
+ ui = cfg.ui
59
+ if not ui.target_url:
60
+ ui.target_url = cfg.base_url
61
+ if not ui.auth.username:
62
+ ui.auth.username = cfg.auth.username
63
+ ui.auth.password = cfg.auth.password
64
+ out_dir = cfg.output_dir
65
+ if url:
66
+ ui.target_url = url.rstrip("/")
67
+ if username:
68
+ ui.auth.username = username
69
+ if password:
70
+ ui.auth.password = password
71
+ if max_routes:
72
+ ui.crawl.max_routes = int(max_routes)
73
+ if not ui.target_url:
74
+ raise ValueError("no target: pass url=… or a config_path with ui.targetUrl/baseUrl")
75
+ paths = build_paths(out_dir, Mode.FRONTEND)
76
+ paths.ensure()
77
+ return ui, paths
78
+
79
+
80
+ def _evidence_dir(paths: Paths) -> Path:
81
+ return paths.tmp_dir / "blackbox"
82
+
83
+
84
+ def _save_discovery(paths: Paths, discovery: DiscoveryResult) -> str:
85
+ p = paths.tmp_dir / "discovery.json"
86
+ p.write_text(json.dumps(discovery.to_json(), indent=2), encoding="utf-8")
87
+ return str(p)
88
+
89
+
90
+ def _load_discovery(paths: Paths) -> DiscoveryResult | None:
91
+ p = paths.tmp_dir / "discovery.json"
92
+ if not p.is_file():
93
+ return None
94
+ return DiscoveryResult.from_json(json.loads(p.read_text(encoding="utf-8")))
95
+
96
+
97
+ # --------------------------------------------------------------------------- #
98
+ # tools
99
+ # --------------------------------------------------------------------------- #
100
+
101
+
102
+ def blackbox_discover_app(**kwargs: Any) -> dict[str, Any]:
103
+ """Full discovery: detect login, log in, crawl, capture evidence, save JSON."""
104
+ from suitest_lifecycle.blackbox.crawler import discover
105
+ from suitest_lifecycle.blackbox.graph import build_graph
106
+ from suitest_lifecycle.blackbox.reporter import summarize, write_report
107
+
108
+ ui, paths = _resolve(**kwargs)
109
+ discovery = discover(ui, _evidence_dir(paths))
110
+ disc_path = _save_discovery(paths, discovery)
111
+ graph = build_graph(discovery)
112
+ graph_path = paths.tmp_dir / "interaction_graph.json"
113
+ graph_path.write_text(json.dumps(graph, indent=2), encoding="utf-8")
114
+ report_path = write_report(summarize(discovery, graph=graph), paths.reports_dir)
115
+ return _envelope(
116
+ success=not discovery.errors,
117
+ summary=(
118
+ f"discovered {len(discovery.pages)} route(s); "
119
+ f"login {'ok' if discovery.login_probe.success else ('detected' if discovery.login else 'not found')}"
120
+ ),
121
+ data=summarize(discovery, graph=graph),
122
+ artifacts=[disc_path, str(graph_path), report_path],
123
+ errors=discovery.errors,
124
+ )
125
+
126
+
127
+ def blackbox_detect_login(**kwargs: Any) -> dict[str, Any]:
128
+ """Detect the login form on the target's login page (no credentials needed)."""
129
+ from suitest_lifecycle.blackbox.crawler import analyze_single_page
130
+ from suitest_lifecycle.blackbox.detector import detect_login_form
131
+
132
+ ui, paths = _resolve(**kwargs)
133
+ page = analyze_single_page(ui, ui.auth.login_url or "/login", _evidence_dir(paths))
134
+ form = detect_login_form(page, ignore_testids=ui.crawl.ignore_testids)
135
+ return _envelope(
136
+ success=form.found(),
137
+ summary="login form detected" if form.found() else "no login form found",
138
+ data={"login": form.to_json(), "pattern": page.pattern},
139
+ artifacts=[page.screenshot] if page.screenshot else [],
140
+ )
141
+
142
+
143
+ def blackbox_perform_login(**kwargs: Any) -> dict[str, Any]:
144
+ """Detect + actually perform the login; report where the app landed."""
145
+ from suitest_lifecycle.blackbox.crawler import discover
146
+
147
+ ui, paths = _resolve(**kwargs)
148
+ ui.crawl.max_routes = 2 # login page + landing page only
149
+ discovery = discover(ui, _evidence_dir(paths))
150
+ probe = discovery.login_probe
151
+ return _envelope(
152
+ success=probe.success,
153
+ summary=(
154
+ f"login {'succeeded' if probe.success else 'failed'}"
155
+ + (f" — landed on {probe.landed_route}" if probe.landed_route else "")
156
+ ),
157
+ data={
158
+ "login": discovery.login.to_json() if discovery.login else None,
159
+ "probe": probe.to_json(),
160
+ },
161
+ )
162
+
163
+
164
+ def blackbox_crawl_routes(**kwargs: Any) -> dict[str, Any]:
165
+ """Login + BFS crawl; returns the route map (alias of discover, data-focused)."""
166
+ result = blackbox_discover_app(**kwargs)
167
+ data = result.get("data", {})
168
+ return _envelope(
169
+ success=bool(result.get("success")),
170
+ summary=f"crawled {data.get('routesDiscovered', 0)} route(s)",
171
+ data={
172
+ "routeMap": data.get("routeMap", {}),
173
+ "skippedRoutes": data.get("skippedRoutes", []),
174
+ },
175
+ artifacts=list(result.get("artifacts", [])),
176
+ errors=list(result.get("errors", [])),
177
+ )
178
+
179
+
180
+ def blackbox_analyze_page(**kwargs: Any) -> dict[str, Any]:
181
+ """Analyze one page/route: pattern classification + interactive elements."""
182
+ from suitest_lifecycle.blackbox.crawler import analyze_single_page
183
+
184
+ page_url = str(kwargs.pop("page_url", "") or kwargs.pop("route", "") or "/")
185
+ ui, paths = _resolve(**kwargs)
186
+ page = analyze_single_page(ui, page_url, _evidence_dir(paths))
187
+ return _envelope(
188
+ success=not page.blank,
189
+ summary=f"{page.route}: pattern={page.pattern}",
190
+ data=page.to_json(),
191
+ artifacts=[page.screenshot] if page.screenshot else [],
192
+ )
193
+
194
+
195
+ def blackbox_build_interaction_graph(**kwargs: Any) -> dict[str, Any]:
196
+ """Build the interaction graph from the saved discovery artifact."""
197
+ from suitest_lifecycle.blackbox.graph import build_graph
198
+
199
+ _, paths = _resolve(**kwargs)
200
+ discovery = _load_discovery(paths)
201
+ if discovery is None:
202
+ return _envelope(False, "no discovery.json — run blackbox_discover_app first")
203
+ graph = build_graph(discovery)
204
+ graph_path = paths.tmp_dir / "interaction_graph.json"
205
+ graph_path.write_text(json.dumps(graph, indent=2), encoding="utf-8")
206
+ return _envelope(
207
+ True,
208
+ f"graph: {len(graph['nodes'])} node(s), {len(graph['edges'])} edge(s)",
209
+ data=graph,
210
+ artifacts=[str(graph_path)],
211
+ )
212
+
213
+
214
+ def blackbox_generate_playwright_tests(**kwargs: Any) -> dict[str, Any]:
215
+ """Generate Playwright tests from the saved discovery.
216
+
217
+ Deterministic baseline always; pass ``prd_file`` (markdown) to append
218
+ PRD-driven semantic cases via the workspace LLM (TestSprite-parity flow).
219
+ """
220
+ from suitest_lifecycle.blackbox.generator import export_blackbox_tests
221
+ from suitest_lifecycle.serialize import plan_to_json
222
+
223
+ prd_file = str(kwargs.pop("prd_file", "") or "")
224
+ ui, paths = _resolve(**kwargs)
225
+ discovery = _load_discovery(paths)
226
+ if discovery is None:
227
+ return _envelope(False, "no discovery.json — run blackbox_discover_app first")
228
+ llm = None
229
+ prd_context = ""
230
+ if prd_file:
231
+ import os as _os
232
+
233
+ from suitest_lifecycle.blackbox.prd_ingest import load_prd
234
+ from suitest_lifecycle.llm_bridge import RemoteLlmClient
235
+
236
+ prd_doc = load_prd(prd_file)
237
+ (paths.tmp_dir / "prd_ingest.json").write_text(
238
+ json.dumps(prd_doc.to_json(), indent=2), encoding="utf-8"
239
+ )
240
+ prd_context = prd_doc.as_prompt_context()
241
+ api_url = _os.environ.get("SUITEST_API_URL", "")
242
+ token = _os.environ.get("SUITEST_API_KEY", "")
243
+ if api_url and token:
244
+ llm = RemoteLlmClient(api_url, token)
245
+ cases = export_blackbox_tests(discovery, ui, paths, llm=llm, prd_context=prd_context)
246
+ paths.test_plan_json.write_text(json.dumps(plan_to_json(cases), indent=2), encoding="utf-8")
247
+ manifest = [{"id": c.id, "title": c.title, "file": c.automation_file} for c in cases]
248
+ (paths.tmp_dir / "blackbox_cases.json").write_text(
249
+ json.dumps(manifest, indent=2), encoding="utf-8"
250
+ )
251
+ return _envelope(
252
+ True,
253
+ f"generated {len(cases)} test case(s)",
254
+ data={"cases": manifest},
255
+ artifacts=[str(paths.test_file(str(c.automation_file))) for c in cases],
256
+ )
257
+
258
+
259
+ def blackbox_run_playwright_tests(**kwargs: Any) -> dict[str, Any]:
260
+ """Execute the generated tests; returns per-case outcomes + evidence."""
261
+ from suitest_lifecycle.models import PlanCase, Priority
262
+ from suitest_lifecycle.runner import run_tests
263
+ from suitest_lifecycle.serialize import results_to_json
264
+
265
+ _, paths = _resolve(**kwargs)
266
+ manifest_path = paths.tmp_dir / "blackbox_cases.json"
267
+ if not manifest_path.is_file():
268
+ return _envelope(False, "no generated tests — run blackbox_generate_playwright_tests first")
269
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
270
+ cases = [
271
+ PlanCase(
272
+ id=str(m["id"]),
273
+ title=str(m["title"]),
274
+ description="",
275
+ category="Blackbox",
276
+ priority=Priority.MEDIUM,
277
+ source_ref="bb:run",
278
+ steps=[],
279
+ automation_file=str(m["file"]),
280
+ )
281
+ for m in manifest
282
+ ]
283
+ results = run_tests(cases, paths.mode_dir, selected_ids=None, timeout_sec=120)
284
+ payload = results_to_json(results)
285
+ (paths.tmp_dir / "blackbox_results.json").write_text(
286
+ json.dumps(payload, indent=2), encoding="utf-8"
287
+ )
288
+ passed = sum(1 for r in results if r.status.value == "PASSED")
289
+
290
+ # Publishing is not optional: when the MCP server carries Suitest
291
+ # credentials (SUITEST_API_URL/KEY), every run lands in the web TCM.
292
+ # A publish failure fails the tool — results must never stay local silently.
293
+ import os as _os
294
+
295
+ publish_summary = "publish skipped — SUITEST_API_URL/KEY not set"
296
+ publish_ok = True
297
+ if _os.environ.get("SUITEST_API_URL") and _os.environ.get("SUITEST_API_KEY"):
298
+ pub = blackbox_publish_results(**kwargs)
299
+ publish_ok = bool(pub.get("success"))
300
+ publish_summary = str(pub.get("summary"))
301
+ return _envelope(
302
+ passed == len(results) and publish_ok,
303
+ f"{passed}/{len(results)} passed; {publish_summary}",
304
+ data={"results": payload, "publish": publish_summary},
305
+ artifacts=[str(paths.tmp_dir / "blackbox_results.json")],
306
+ )
307
+
308
+
309
+ def blackbox_collect_evidence(**kwargs: Any) -> dict[str, Any]:
310
+ """Index every evidence artifact produced so far."""
311
+ _, paths = _resolve(**kwargs)
312
+ ev = _evidence_dir(paths)
313
+ shots = sorted(str(p) for p in ev.glob("*.png")) if ev.is_dir() else []
314
+ videos = sorted(str(p) for p in (paths.tmp_dir / "videos").rglob("*.webm"))
315
+ traces = sorted(str(p) for p in paths.tmp_dir.rglob("*.zip"))
316
+ jsons = [
317
+ str(p)
318
+ for p in (
319
+ paths.tmp_dir / "discovery.json",
320
+ paths.tmp_dir / "interaction_graph.json",
321
+ paths.tmp_dir / "blackbox_results.json",
322
+ paths.reports_dir / "blackbox_report.json",
323
+ )
324
+ if p.is_file()
325
+ ]
326
+ return _envelope(
327
+ True,
328
+ f"{len(shots)} screenshot(s), {len(videos)} video(s), {len(jsons)} artifact json(s)",
329
+ data={"screenshots": shots, "videos": videos, "traces": traces, "reports": jsons},
330
+ artifacts=[*shots, *videos, *jsons],
331
+ )
332
+
333
+
334
+ def blackbox_summarize_findings(**kwargs: Any) -> dict[str, Any]:
335
+ """Route map + bug candidates + test outcomes, one JSON for agent reasoning."""
336
+ from suitest_lifecycle.blackbox.graph import build_graph
337
+ from suitest_lifecycle.blackbox.reporter import summarize, write_report
338
+
339
+ _, paths = _resolve(**kwargs)
340
+ discovery = _load_discovery(paths)
341
+ if discovery is None:
342
+ return _envelope(False, "no discovery.json — run blackbox_discover_app first")
343
+ results_path = paths.tmp_dir / "blackbox_results.json"
344
+ results = json.loads(results_path.read_text(encoding="utf-8")) if results_path.is_file() else []
345
+ report = summarize(discovery, graph=build_graph(discovery), test_results=results)
346
+ report_path = write_report(report, paths.reports_dir)
347
+ return _envelope(
348
+ True,
349
+ f"{report['routesDiscovered']} route(s), {len(report['bugCandidates'])} bug candidate(s)",
350
+ data=report,
351
+ artifacts=[report_path],
352
+ )
353
+
354
+
355
+ def bootstrap_project(**kwargs: Any) -> dict[str, Any]:
356
+ """TestSprite-style setup: open a browser wizard, wait for the user to fill
357
+ target URL / credentials / crawl scope / optional PRD, write
358
+ suitest.config.json into the project. Blocks until submitted."""
359
+ from suitest_lifecycle.blackbox.bootstrap import run_bootstrap_wizard
360
+
361
+ project_path = str(kwargs.get("project_path", ".") or ".")
362
+ timeout = int(kwargs.get("timeout_sec", 600) or 600)
363
+ result = run_bootstrap_wizard(project_path, timeout_sec=timeout)
364
+ if not result:
365
+ return _envelope(
366
+ False,
367
+ f"setup form was not submitted within {timeout}s",
368
+ errors=["bootstrap timeout — ask the user to rerun and fill the form"],
369
+ )
370
+ return _envelope(
371
+ True,
372
+ f"config saved: {result['configPath']}" + (" (with PRD)" if result.get("prdFile") else ""),
373
+ data=result,
374
+ artifacts=[result["configPath"]],
375
+ )
376
+
377
+
378
+ _PRIO_TO_P = {"High": "P1", "Medium": "P2", "Low": "P3"}
379
+
380
+
381
+ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
382
+ """Publish the blackbox suite + latest run (with video/screenshot evidence)
383
+ to the Suitest server so it shows up in the web TCM (Cases + Runs).
384
+
385
+ Needs ``project_id`` (or config publish.projectId) and the usual
386
+ ``SUITEST_API_URL``/``SUITEST_API_KEY`` env the MCP server already carries.
387
+ """
388
+ import os as _os
389
+ import re as _re
390
+
391
+ project_id = str(kwargs.pop("project_id", "") or "")
392
+ suite_name = str(kwargs.pop("suite_name", "") or "")
393
+ config_path = str(kwargs.get("config_path", "") or "")
394
+ ui, paths = _resolve(**kwargs)
395
+ if config_path and not project_id:
396
+ from suitest_lifecycle.config import load_config
397
+
398
+ cfg = load_config(config_path)
399
+ project_id = cfg.publish.project_id
400
+ # No project configured → the server finds-or-creates one by a slug derived
401
+ # from the target host. Publishing is MANDATORY in the blackbox pipeline;
402
+ # "no project yet" is not an excuse to keep results local.
403
+ host = ui.target_url.split("//")[-1].split("/")[0].removeprefix("www.")
404
+ project_slug = _re.sub(r"[^a-z0-9]+", "-", host.lower()).strip("-")[:64] or "blackbox"
405
+ project_name = host or "Blackbox"
406
+ api_url = _os.environ.get("SUITEST_API_URL", "")
407
+ token = _os.environ.get("SUITEST_API_KEY", "")
408
+ if not api_url or not token:
409
+ return _envelope(False, "SUITEST_API_URL / SUITEST_API_KEY not set")
410
+ try:
411
+ from suitest_sdk import SuitestAPIError, SuitestClient
412
+ except ImportError:
413
+ return _envelope(False, "suiflex-suitest-sdk not installed")
414
+
415
+ plan_path = paths.test_plan_json
416
+ if not plan_path.is_file():
417
+ return _envelope(False, "no test plan — run blackbox_generate_playwright_tests first")
418
+ plan = json.loads(plan_path.read_text(encoding="utf-8"))
419
+ suite = suite_name or f"{ui.target_url.split('//')[-1]} blackbox"
420
+
421
+ def _sidecar(case_id: str) -> dict[str, Any]:
422
+ p_ = paths.mode_dir / f"{case_id}.result.json"
423
+ return json.loads(p_.read_text(encoding="utf-8")) if p_.is_file() else {}
424
+
425
+ try:
426
+ with SuitestClient(api_url, token=token, timeout=180.0) as client:
427
+
428
+ def _up(path: str, mime: str) -> str:
429
+ try:
430
+ return client.upload_file(path, content_type=mime)
431
+ except Exception:
432
+ return ""
433
+
434
+ cases_payload: list[dict[str, Any]] = []
435
+ results_payload: list[dict[str, Any]] = []
436
+ for c in plan:
437
+ code = ""
438
+ if c.get("automation_file"):
439
+ fp = paths.test_file(str(c["automation_file"]))
440
+ if fp.is_file():
441
+ code = fp.read_text(encoding="utf-8")
442
+ cases_payload.append(
443
+ {
444
+ "sourceRef": c.get("source_ref", "bb:blackbox"),
445
+ "name": c["title"],
446
+ "slug": c["title"],
447
+ "description": c.get("description", ""),
448
+ "source": "MCP",
449
+ "priority": _PRIO_TO_P.get(str(c.get("priority")), "P2"),
450
+ "category": c.get("category", "Blackbox"),
451
+ "tags": ["blackbox"],
452
+ "automationFilePath": c.get("automation_file", ""),
453
+ "automationCode": code,
454
+ "generatedBy": "suitest-blackbox",
455
+ "steps": [
456
+ {
457
+ "order": i + 1,
458
+ "action": st["description"],
459
+ "expected": st["description"] if st["type"] == "assertion" else "",
460
+ }
461
+ for i, st in enumerate(c.get("steps", []))
462
+ ],
463
+ }
464
+ )
465
+ side = _sidecar(str(c["id"]))
466
+ if not side:
467
+ continue
468
+ video = side.get("video") or ""
469
+ artifacts = (
470
+ [
471
+ {
472
+ "kind": "VIDEO",
473
+ "url": _up(video, "video/webm") or "file://" + video,
474
+ "mimeType": "video/webm",
475
+ "sizeBytes": Path(video).stat().st_size if Path(video).is_file() else 0,
476
+ }
477
+ ]
478
+ if video and Path(video).is_file()
479
+ else []
480
+ )
481
+ results_payload.append(
482
+ {
483
+ "name": c["title"],
484
+ "slug": c["title"],
485
+ "sourceRef": c.get("source_ref", ""),
486
+ "outcome": str(side.get("status", "PASSED")),
487
+ "durationMs": 0,
488
+ "error": str(side.get("error", "")),
489
+ "steps": [
490
+ {
491
+ "order": st.get("index", i + 1),
492
+ "type": st.get("type", "action"),
493
+ "description": st.get("description", ""),
494
+ "outcome": st.get("status", "PASSED"),
495
+ "screenshot": (
496
+ _up(st["screenshot"], "image/png")
497
+ if st.get("screenshot") and Path(st["screenshot"]).is_file()
498
+ else ""
499
+ ),
500
+ }
501
+ for i, st in enumerate(side.get("steps", []))
502
+ ],
503
+ "artifacts": artifacts,
504
+ }
505
+ )
506
+ imported = client.bulk_import_cases(
507
+ project_id=project_id,
508
+ project_slug=project_slug,
509
+ project_name=project_name,
510
+ suite_name=suite,
511
+ mode="frontend",
512
+ cases=cases_payload,
513
+ )
514
+ run = client.ingest_run(
515
+ project_id=project_id,
516
+ project_slug=project_slug,
517
+ project_name=project_name,
518
+ suite_name=suite,
519
+ name=f"{suite} run",
520
+ results=results_payload,
521
+ )
522
+ except SuitestAPIError as exc:
523
+ return _envelope(False, f"publish failed: {exc}", errors=[str(exc)])
524
+ return _envelope(
525
+ True,
526
+ f"published: {len(imported.get('imported', []))} case(s), run {run.get('runId')}",
527
+ data={"imported": imported, "run": run},
528
+ )
529
+
530
+
531
+ BLACKBOX_TOOLS = {
532
+ "blackbox_publish_results": blackbox_publish_results,
533
+ "bootstrap_project": bootstrap_project,
534
+ "blackbox_discover_app": blackbox_discover_app,
535
+ "blackbox_detect_login": blackbox_detect_login,
536
+ "blackbox_perform_login": blackbox_perform_login,
537
+ "blackbox_crawl_routes": blackbox_crawl_routes,
538
+ "blackbox_analyze_page": blackbox_analyze_page,
539
+ "blackbox_build_interaction_graph": blackbox_build_interaction_graph,
540
+ "blackbox_generate_playwright_tests": blackbox_generate_playwright_tests,
541
+ "blackbox_run_playwright_tests": blackbox_run_playwright_tests,
542
+ "blackbox_collect_evidence": blackbox_collect_evidence,
543
+ "blackbox_summarize_findings": blackbox_summarize_findings,
544
+ }
545
+
546
+ __all__ = ["BLACKBOX_TOOLS", *BLACKBOX_TOOLS.keys()]