@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,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()]
|