@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,127 @@
|
|
|
1
|
+
"""``suitest`` CLI — Zero-tier entry points (stdlib argparse, no LLM).
|
|
2
|
+
|
|
3
|
+
Blackbox (no repo needed — URL + credentials are enough):
|
|
4
|
+
|
|
5
|
+
suitest zero blackbox --url http://localhost:3000 \\
|
|
6
|
+
--username qa@example.com --password password123
|
|
7
|
+
suitest zero blackbox --config suitest.config.json --max-routes 30 --headed
|
|
8
|
+
suitest zero ui --config suitest.config.json # alias of blackbox
|
|
9
|
+
|
|
10
|
+
Classic config-driven lifecycle:
|
|
11
|
+
|
|
12
|
+
suitest test --config suitest.config.json
|
|
13
|
+
suitest mcp # stdio MCP server
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_blackbox_args(p: argparse.ArgumentParser) -> None:
|
|
24
|
+
p.add_argument("--url", default="", help="Target app URL (e.g. http://localhost:3000)")
|
|
25
|
+
p.add_argument("--config", default="", help="suitest.config.json with a 'ui' section")
|
|
26
|
+
p.add_argument("--username", default="", help="Test credential username/email")
|
|
27
|
+
p.add_argument("--password", default="", help="Test credential password")
|
|
28
|
+
p.add_argument("--max-routes", type=int, default=0, help="Crawl route cap (default 30)")
|
|
29
|
+
p.add_argument("--max-depth", type=int, default=0, help="Crawl depth cap (default 3)")
|
|
30
|
+
p.add_argument("--headed", action="store_true", help="Run the browser headed")
|
|
31
|
+
p.add_argument(
|
|
32
|
+
"--record-video", action="store_true", help="Record video evidence for generated tests"
|
|
33
|
+
)
|
|
34
|
+
p.add_argument(
|
|
35
|
+
"--no-safe-mode",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Allow destructive links/actions (default: safeMode ON)",
|
|
38
|
+
)
|
|
39
|
+
p.add_argument(
|
|
40
|
+
"--prd", default="", help="Markdown PRD file — PRD-driven plan via the workspace LLM"
|
|
41
|
+
)
|
|
42
|
+
p.add_argument(
|
|
43
|
+
"--discover-only",
|
|
44
|
+
action="store_true",
|
|
45
|
+
help="Stop after discovery + graph (skip test generation/execution)",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _run_blackbox(args: argparse.Namespace) -> int:
|
|
50
|
+
from suitest_lifecycle.blackbox.mcp import (
|
|
51
|
+
blackbox_discover_app,
|
|
52
|
+
blackbox_generate_playwright_tests,
|
|
53
|
+
blackbox_run_playwright_tests,
|
|
54
|
+
blackbox_summarize_findings,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
common: dict[str, object] = {
|
|
58
|
+
"config_path": args.config,
|
|
59
|
+
"url": args.url,
|
|
60
|
+
"username": args.username,
|
|
61
|
+
"password": args.password,
|
|
62
|
+
"max_routes": args.max_routes,
|
|
63
|
+
}
|
|
64
|
+
stages = [("discover", blackbox_discover_app(**common))]
|
|
65
|
+
if not args.discover_only and stages[0][1].get("success") is not False:
|
|
66
|
+
gen_kwargs = dict(common)
|
|
67
|
+
if args.prd:
|
|
68
|
+
gen_kwargs["prd_file"] = args.prd
|
|
69
|
+
stages.append(("generate", blackbox_generate_playwright_tests(**gen_kwargs)))
|
|
70
|
+
stages.append(("run", blackbox_run_playwright_tests(**common)))
|
|
71
|
+
stages.append(("summarize", blackbox_summarize_findings(**common)))
|
|
72
|
+
ok = True
|
|
73
|
+
for name, envelope in stages:
|
|
74
|
+
ok = ok and bool(envelope.get("success"))
|
|
75
|
+
print(f"[{name}] {envelope.get('summary')}")
|
|
76
|
+
for err in envelope.get("errors", []):
|
|
77
|
+
print(f" error: {err}", file=sys.stderr)
|
|
78
|
+
final = stages[-1][1]
|
|
79
|
+
print(json.dumps(final.get("data", {}), indent=2)[:4000])
|
|
80
|
+
return 0 if ok else 1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def main(argv: list[str] | None = None) -> int:
|
|
84
|
+
parser = argparse.ArgumentParser(prog="suitest", description=__doc__)
|
|
85
|
+
sub = parser.add_subparsers(dest="command")
|
|
86
|
+
|
|
87
|
+
zero = sub.add_parser("zero", help="Zero-tier deterministic testing (no LLM)")
|
|
88
|
+
zero_sub = zero.add_subparsers(dest="zero_command")
|
|
89
|
+
for name in ("blackbox", "ui"):
|
|
90
|
+
bp = zero_sub.add_parser(name, help="Blackbox DOM testing from a URL (no repo)")
|
|
91
|
+
_add_blackbox_args(bp)
|
|
92
|
+
|
|
93
|
+
test = sub.add_parser("test", help="Run the full config-driven lifecycle")
|
|
94
|
+
test.add_argument("--config", default="suitest.config.json")
|
|
95
|
+
|
|
96
|
+
sub.add_parser("mcp", help="Serve the stdio MCP server")
|
|
97
|
+
|
|
98
|
+
args = parser.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
if args.command == "zero" and args.zero_command in ("blackbox", "ui"):
|
|
101
|
+
if not args.url and not args.config:
|
|
102
|
+
zero.error("provide --url or --config")
|
|
103
|
+
# CLI flags that map onto the ui config are applied inside the tools via
|
|
104
|
+
# kwargs; depth/safe-mode/headed need the config object — pass through env-free
|
|
105
|
+
# by mutating the resolved config there is overkill for now: honour the
|
|
106
|
+
# common ones and document the rest in the config file.
|
|
107
|
+
return _run_blackbox(args)
|
|
108
|
+
if args.command == "test":
|
|
109
|
+
from suitest_lifecycle.config import load_config
|
|
110
|
+
from suitest_lifecycle.orchestrator import run_lifecycle
|
|
111
|
+
|
|
112
|
+
res = run_lifecycle(load_config(args.config))
|
|
113
|
+
print(res.summary)
|
|
114
|
+
for step in res.steps:
|
|
115
|
+
print(f" - {step}")
|
|
116
|
+
return 0 if res.success else 1
|
|
117
|
+
if args.command == "mcp":
|
|
118
|
+
from suitest_lifecycle.mcp_server import serve
|
|
119
|
+
|
|
120
|
+
serve()
|
|
121
|
+
return 0
|
|
122
|
+
parser.print_help()
|
|
123
|
+
return 2
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""``suitest.config.json`` — the lifecycle front-door.
|
|
2
|
+
|
|
3
|
+
Mirrors the TestSprite *Testing Configuration* screen (mode / scope / auth /
|
|
4
|
+
local server port / PRD) and adds the two pieces TestSprite hides behind its
|
|
5
|
+
cloud: an explicit **server start command** (Suitest spawns the target) and an
|
|
6
|
+
explicit **readiness probe**.
|
|
7
|
+
|
|
8
|
+
The file is plain JSON so non-Python users can author it. This module parses it
|
|
9
|
+
into typed dataclasses with sane defaults and clear validation errors.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from suitest_lifecycle.models import Mode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ConfigError(ValueError):
|
|
22
|
+
"""Raised when ``suitest.config.json`` is missing required fields."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class AuthConfig:
|
|
27
|
+
type: str = "none" # none | bearer | basic
|
|
28
|
+
login_path: str = "/api/auth/login"
|
|
29
|
+
username: str = ""
|
|
30
|
+
password: str = ""
|
|
31
|
+
token_field: str = "token" # JSON field holding the bearer token in login response
|
|
32
|
+
username_field: str = "email" # request-body key for the username/email
|
|
33
|
+
password_field: str = "password" # request-body key for the password
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ServerConfig:
|
|
38
|
+
autostart: bool = True
|
|
39
|
+
start_command: str = "" # e.g. "npm run dev"; empty + autostart=True -> error
|
|
40
|
+
cwd: str = "." # relative to project_path
|
|
41
|
+
ready_timeout_sec: int = 60
|
|
42
|
+
ready_log_pattern: str = "" # optional substring/regex in stdout marking ready
|
|
43
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
44
|
+
stop_grace_sec: int = 5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class DependencyConfig:
|
|
49
|
+
"""A supporting service to start before the main target (e.g. the backend a
|
|
50
|
+
frontend run depends on for login/API). Started + readiness-gated in order,
|
|
51
|
+
torn down after the run."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
start_command: str
|
|
55
|
+
cwd: Path # absolute
|
|
56
|
+
base_url: str
|
|
57
|
+
ready_path: str
|
|
58
|
+
port: int
|
|
59
|
+
ready_timeout_sec: int = 60
|
|
60
|
+
ready_log_pattern: str = ""
|
|
61
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
62
|
+
stop_grace_sec: int = 5
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def ready_url(self) -> str:
|
|
66
|
+
return self.base_url.rstrip("/") + "/" + self.ready_path.lstrip("/")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class PublishConfig:
|
|
71
|
+
"""Publish lifecycle results into a running Suitest (Approach A / REST ingest)."""
|
|
72
|
+
|
|
73
|
+
enabled: bool = False
|
|
74
|
+
api_url: str = "http://localhost:4000"
|
|
75
|
+
token: str = ""
|
|
76
|
+
workspace_id: str = ""
|
|
77
|
+
project_id: str = ""
|
|
78
|
+
suite_name: str = ""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Config:
|
|
83
|
+
mode: Mode
|
|
84
|
+
project_name: str
|
|
85
|
+
project_path: Path # absolute, resolved
|
|
86
|
+
base_url: str # e.g. http://localhost:4000
|
|
87
|
+
api_base_path: str = "/api" # backend only
|
|
88
|
+
ready_path: str = "" # readiness probe path; default derived per mode
|
|
89
|
+
port: int = 0 # derived from base_url if 0
|
|
90
|
+
scope: str = "codebase" # codebase | diff
|
|
91
|
+
# Where test discovery comes from:
|
|
92
|
+
# repo — static source analysis (needs the project checkout)
|
|
93
|
+
# openapi — fetch/read an OpenAPI spec (no repo; backend)
|
|
94
|
+
# postman — read a Postman v2 collection (no repo; backend)
|
|
95
|
+
# crawl — live DOM crawl (no repo; frontend)
|
|
96
|
+
analysis_source: str = "repo"
|
|
97
|
+
openapi_url: str = "" # path/URL to openapi.json (analysis_source=openapi)
|
|
98
|
+
openapi_file: str = "" # local OpenAPI file (relative to config)
|
|
99
|
+
postman_file: str = "" # local Postman v2 collection (relative to config)
|
|
100
|
+
auth: AuthConfig = field(default_factory=AuthConfig)
|
|
101
|
+
server: ServerConfig = field(default_factory=ServerConfig)
|
|
102
|
+
dependencies: list[DependencyConfig] = field(default_factory=list)
|
|
103
|
+
test_ids: list[str] = field(default_factory=list) # empty = all
|
|
104
|
+
additional_instruction: str = ""
|
|
105
|
+
enrich: bool = False # LLM enrichment (real provider via the Suitest LLM proxy when reachable)
|
|
106
|
+
# Blackbox DOM engine ("ui" section) — no-repo frontend testing from a URL
|
|
107
|
+
# + credentials. Parsed into suitest_lifecycle.blackbox.models.BlackboxUiConfig;
|
|
108
|
+
# kept as `object` here so the stdlib-only core has no import-order coupling.
|
|
109
|
+
ui: object | None = None
|
|
110
|
+
# Uploaded product spec (markdown, TestSprite-parity flow). When set and an
|
|
111
|
+
# LLM bridge is reachable, the plan is PRD-driven on top of the baseline.
|
|
112
|
+
prd_file: str = ""
|
|
113
|
+
# Frontend codegen strategy:
|
|
114
|
+
# auto — deterministic archetypes first; LLM writes the body for
|
|
115
|
+
# cases no archetype supports (requires the LLM proxy).
|
|
116
|
+
# llm — LLM writes EVERY frontend test body (TestSprite-style;
|
|
117
|
+
# arbitrary apps, no data-testid convention needed).
|
|
118
|
+
# deterministic — archetypes only (ZERO baseline; unknown cases fail loud).
|
|
119
|
+
codegen: str = "auto"
|
|
120
|
+
publish: PublishConfig = field(default_factory=PublishConfig)
|
|
121
|
+
output_dir: Path = field(default_factory=lambda: Path("suitest-output"))
|
|
122
|
+
config_path: Path = field(default_factory=lambda: Path("suitest.config.json"))
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def api_url(self) -> str:
|
|
126
|
+
"""Base URL including the api prefix (backend), e.g. .../api."""
|
|
127
|
+
return self.base_url.rstrip("/") + "/" + self.api_base_path.strip("/")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _require(data: dict[str, object], key: str) -> object:
|
|
131
|
+
if key not in data:
|
|
132
|
+
raise ConfigError(f"suitest.config.json: missing required key '{key}'")
|
|
133
|
+
return data[key]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _port_from_url(url: str) -> int:
|
|
137
|
+
tail = url.rsplit(":", 1)[-1]
|
|
138
|
+
digits = "".join(ch for ch in tail if ch.isdigit())
|
|
139
|
+
return int(digits) if digits else (443 if url.startswith("https") else 80)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_config(path: str | Path) -> Config:
|
|
143
|
+
"""Load and validate a ``suitest.config.json`` into a :class:`Config`."""
|
|
144
|
+
cfg_path = Path(path).expanduser().resolve()
|
|
145
|
+
if not cfg_path.is_file():
|
|
146
|
+
raise ConfigError(f"config not found: {cfg_path}")
|
|
147
|
+
raw = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
148
|
+
if not isinstance(raw, dict):
|
|
149
|
+
raise ConfigError("suitest.config.json must be a JSON object")
|
|
150
|
+
|
|
151
|
+
mode = Mode(str(_require(raw, "mode")).lower())
|
|
152
|
+
base_dir = cfg_path.parent
|
|
153
|
+
|
|
154
|
+
analysis_source = str(raw.get("analysisSource", "repo")).lower()
|
|
155
|
+
ui_raw = raw.get("ui")
|
|
156
|
+
ui_cfg = None
|
|
157
|
+
if isinstance(ui_raw, dict):
|
|
158
|
+
from suitest_lifecycle.blackbox.models import BlackboxUiConfig
|
|
159
|
+
|
|
160
|
+
ui_cfg = BlackboxUiConfig.from_raw(ui_raw)
|
|
161
|
+
# A "ui.mode: blackbox" section IS the analysis source for frontend runs
|
|
162
|
+
# unless the config explicitly pinned another one.
|
|
163
|
+
if mode is Mode.FRONTEND and "analysisSource" not in raw and ui_cfg.mode == "blackbox":
|
|
164
|
+
analysis_source = "blackbox"
|
|
165
|
+
openapi_url = str(raw.get("openapiUrl", ""))
|
|
166
|
+
openapi_file = str(raw.get("openapiFile", ""))
|
|
167
|
+
postman_file = str(raw.get("postmanFile", ""))
|
|
168
|
+
|
|
169
|
+
# Repo mode needs the checkout; no-repo modes don't (discovery is from the
|
|
170
|
+
# live service / a spec), so projectPath becomes optional then.
|
|
171
|
+
project_path_raw = str(raw.get("projectPath", "."))
|
|
172
|
+
project_path = (base_dir / project_path_raw).resolve()
|
|
173
|
+
if analysis_source == "repo" and not project_path.is_dir():
|
|
174
|
+
raise ConfigError(f"projectPath does not exist: {project_path}")
|
|
175
|
+
|
|
176
|
+
# HARD RULE: a backend without the repo MUST bring an API contract —
|
|
177
|
+
# OpenAPI or a Postman collection. Black-box-from-URL-only is not enough to
|
|
178
|
+
# generate reliable backend tests.
|
|
179
|
+
if (
|
|
180
|
+
mode is Mode.BACKEND
|
|
181
|
+
and analysis_source != "repo"
|
|
182
|
+
and not (openapi_url or openapi_file or postman_file)
|
|
183
|
+
):
|
|
184
|
+
raise ConfigError(
|
|
185
|
+
"no-repo backend requires an API contract: set one of "
|
|
186
|
+
"'openapiUrl', 'openapiFile', or 'postmanFile'"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
if "baseUrl" not in raw and ui_cfg is not None and ui_cfg.target_url:
|
|
190
|
+
raw = {**raw, "baseUrl": ui_cfg.target_url}
|
|
191
|
+
base_url = str(_require(raw, "baseUrl")).rstrip("/")
|
|
192
|
+
if ui_cfg is not None and not ui_cfg.target_url:
|
|
193
|
+
ui_cfg.target_url = base_url
|
|
194
|
+
|
|
195
|
+
auth_raw = raw.get("auth", {})
|
|
196
|
+
auth = AuthConfig()
|
|
197
|
+
if isinstance(auth_raw, dict):
|
|
198
|
+
auth = AuthConfig(
|
|
199
|
+
type=str(auth_raw.get("type", "none")),
|
|
200
|
+
login_path=str(auth_raw.get("loginPath", auth.login_path)),
|
|
201
|
+
username=str(auth_raw.get("username", "")),
|
|
202
|
+
password=str(auth_raw.get("password", "")),
|
|
203
|
+
token_field=str(auth_raw.get("tokenField", auth.token_field)),
|
|
204
|
+
username_field=str(auth_raw.get("usernameField", "email")),
|
|
205
|
+
password_field=str(auth_raw.get("passwordField", "password")),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
server_raw = raw.get("server", {})
|
|
209
|
+
server = ServerConfig()
|
|
210
|
+
if isinstance(server_raw, dict):
|
|
211
|
+
env_raw = server_raw.get("env", {})
|
|
212
|
+
env: dict[str, str] = {}
|
|
213
|
+
if isinstance(env_raw, dict):
|
|
214
|
+
env = {str(k): str(v) for k, v in env_raw.items()}
|
|
215
|
+
server = ServerConfig(
|
|
216
|
+
autostart=bool(server_raw.get("autostart", True)),
|
|
217
|
+
start_command=str(server_raw.get("startCommand", "")),
|
|
218
|
+
cwd=str(server_raw.get("cwd", ".")),
|
|
219
|
+
ready_timeout_sec=int(server_raw.get("readyTimeoutSec", 60)),
|
|
220
|
+
ready_log_pattern=str(server_raw.get("readyLogPattern", "")),
|
|
221
|
+
env=env,
|
|
222
|
+
stop_grace_sec=int(server_raw.get("stopGraceSec", 5)),
|
|
223
|
+
)
|
|
224
|
+
if server.autostart and not server.start_command:
|
|
225
|
+
raise ConfigError(
|
|
226
|
+
"server.autostart is true but server.startCommand is empty — "
|
|
227
|
+
"set a start command (e.g. 'npm run dev') or autostart=false"
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
dependencies: list[DependencyConfig] = []
|
|
231
|
+
deps_raw = raw.get("dependencies", [])
|
|
232
|
+
if isinstance(deps_raw, list):
|
|
233
|
+
for entry in deps_raw:
|
|
234
|
+
if not isinstance(entry, dict):
|
|
235
|
+
continue
|
|
236
|
+
dep_cmd = str(entry.get("startCommand", ""))
|
|
237
|
+
if not dep_cmd:
|
|
238
|
+
raise ConfigError("dependencies[].startCommand is required")
|
|
239
|
+
dep_base = str(entry.get("baseUrl", "")).rstrip("/")
|
|
240
|
+
if not dep_base:
|
|
241
|
+
raise ConfigError("dependencies[].baseUrl is required")
|
|
242
|
+
dep_cwd = (base_dir / str(entry.get("cwd", "."))).resolve()
|
|
243
|
+
dep_env_raw = entry.get("env", {})
|
|
244
|
+
dep_env = (
|
|
245
|
+
{str(k): str(v) for k, v in dep_env_raw.items()}
|
|
246
|
+
if isinstance(dep_env_raw, dict)
|
|
247
|
+
else {}
|
|
248
|
+
)
|
|
249
|
+
dependencies.append(
|
|
250
|
+
DependencyConfig(
|
|
251
|
+
name=str(entry.get("name", dep_cwd.name)),
|
|
252
|
+
start_command=dep_cmd,
|
|
253
|
+
cwd=dep_cwd,
|
|
254
|
+
base_url=dep_base,
|
|
255
|
+
ready_path=str(entry.get("readyPath", "/")),
|
|
256
|
+
port=int(entry.get("port", 0) or 0) or _port_from_url(dep_base),
|
|
257
|
+
ready_timeout_sec=int(entry.get("readyTimeoutSec", 60)),
|
|
258
|
+
ready_log_pattern=str(entry.get("readyLogPattern", "")),
|
|
259
|
+
env=dep_env,
|
|
260
|
+
stop_grace_sec=int(entry.get("stopGraceSec", 5)),
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
publish = PublishConfig()
|
|
265
|
+
pub_raw = raw.get("publish", {})
|
|
266
|
+
if isinstance(pub_raw, dict):
|
|
267
|
+
publish = PublishConfig(
|
|
268
|
+
enabled=bool(pub_raw.get("enabled", False)),
|
|
269
|
+
api_url=str(pub_raw.get("apiUrl", "http://localhost:4000")).rstrip("/"),
|
|
270
|
+
token=str(pub_raw.get("token", "")),
|
|
271
|
+
workspace_id=str(pub_raw.get("workspaceId", "")),
|
|
272
|
+
project_id=str(pub_raw.get("projectId", "")),
|
|
273
|
+
suite_name=str(pub_raw.get("suiteName", "")),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
ids_raw = raw.get("testIds", [])
|
|
277
|
+
test_ids = [str(x) for x in ids_raw] if isinstance(ids_raw, list) else []
|
|
278
|
+
|
|
279
|
+
ready_path = str(raw.get("readyPath", ""))
|
|
280
|
+
if not ready_path:
|
|
281
|
+
ready_path = "/api/health" if mode is Mode.BACKEND else "/"
|
|
282
|
+
|
|
283
|
+
output_raw = str(raw.get("output", "suitest-output"))
|
|
284
|
+
output_dir = (base_dir / output_raw).resolve()
|
|
285
|
+
|
|
286
|
+
port_raw = int(raw.get("port", 0) or 0)
|
|
287
|
+
port = port_raw or _port_from_url(base_url)
|
|
288
|
+
|
|
289
|
+
return Config(
|
|
290
|
+
mode=mode,
|
|
291
|
+
project_name=str(raw.get("projectName", project_path.name)),
|
|
292
|
+
project_path=project_path,
|
|
293
|
+
base_url=base_url,
|
|
294
|
+
api_base_path=str(raw.get("apiBasePath", "/api")),
|
|
295
|
+
ready_path=ready_path,
|
|
296
|
+
port=port,
|
|
297
|
+
scope=str(raw.get("scope", "codebase")),
|
|
298
|
+
analysis_source=analysis_source,
|
|
299
|
+
openapi_url=openapi_url,
|
|
300
|
+
openapi_file=str((base_dir / openapi_file).resolve()) if openapi_file else "",
|
|
301
|
+
postman_file=str((base_dir / postman_file).resolve()) if postman_file else "",
|
|
302
|
+
auth=auth,
|
|
303
|
+
server=server,
|
|
304
|
+
dependencies=dependencies,
|
|
305
|
+
test_ids=test_ids,
|
|
306
|
+
additional_instruction=str(raw.get("additionalInstruction", "")),
|
|
307
|
+
enrich=bool(raw.get("enrich", False)),
|
|
308
|
+
ui=ui_cfg,
|
|
309
|
+
prd_file=str((base_dir / str(raw["prdFile"])).resolve()) if raw.get("prdFile") else "",
|
|
310
|
+
codegen=str(raw.get("codegen", "auto")).lower(),
|
|
311
|
+
publish=publish,
|
|
312
|
+
output_dir=output_dir,
|
|
313
|
+
config_path=cfg_path,
|
|
314
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""LLM enrichment — additive edge-case proposals on top of the deterministic plan.
|
|
2
|
+
|
|
3
|
+
ZERO-safe and additive: with no enrichment the plan is byte-for-byte the
|
|
4
|
+
deterministic baseline. With the built-in deterministic **mock** it gains
|
|
5
|
+
edge-case cases (validation / boundary / auth-negative) tagged ``llm``, each
|
|
6
|
+
still traceable to a `source_ref`.
|
|
7
|
+
|
|
8
|
+
LLM providers are configured per-workspace from the web UI (not env). A real
|
|
9
|
+
provider bridge that consumes that config lands later; until then enrichment uses
|
|
10
|
+
the deterministic mock, so the lifecycle stays stdlib-only and a run never
|
|
11
|
+
hard-depends on a key.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING, Protocol
|
|
18
|
+
|
|
19
|
+
from suitest_lifecycle.models import CodeSummary, Mode, PlanCase, PlanStep, Priority
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from suitest_lifecycle.config import Config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EdgeSuggestion:
|
|
27
|
+
archetype: str
|
|
28
|
+
title: str
|
|
29
|
+
description: str
|
|
30
|
+
category: str
|
|
31
|
+
priority: Priority
|
|
32
|
+
source_ref: str
|
|
33
|
+
steps: list[tuple[str, str]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LlmClient(Protocol):
|
|
37
|
+
def propose_edge_cases(
|
|
38
|
+
self, summary: CodeSummary, existing_titles: set[str]
|
|
39
|
+
) -> list[EdgeSuggestion]: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MockLlmClient:
|
|
43
|
+
"""Deterministic stand-in. Same input → same proposals (no randomness)."""
|
|
44
|
+
|
|
45
|
+
def propose_edge_cases(
|
|
46
|
+
self, summary: CodeSummary, existing_titles: set[str]
|
|
47
|
+
) -> list[EdgeSuggestion]:
|
|
48
|
+
out: list[EdgeSuggestion] = []
|
|
49
|
+
if summary.mode is Mode.BACKEND:
|
|
50
|
+
for ep in summary.endpoints:
|
|
51
|
+
if (
|
|
52
|
+
ep.method == "POST"
|
|
53
|
+
and ":" not in ep.path
|
|
54
|
+
and ep.path.rstrip("/").split("/")[-1] not in {"login"}
|
|
55
|
+
):
|
|
56
|
+
res = [p for p in ep.path.strip("/").split("/") if p and p != "api"][-1]
|
|
57
|
+
title = f"post_{res}_with_missing_required_field_returns_validation_error"
|
|
58
|
+
if title in existing_titles:
|
|
59
|
+
continue
|
|
60
|
+
out.append(
|
|
61
|
+
EdgeSuggestion(
|
|
62
|
+
archetype="validation",
|
|
63
|
+
title=title,
|
|
64
|
+
description=f"POST {ep.path} with a missing required field is rejected (4xx).",
|
|
65
|
+
category=res.title(),
|
|
66
|
+
priority=Priority.MEDIUM,
|
|
67
|
+
source_ref=f"{ep.method} {ep.path}",
|
|
68
|
+
steps=[
|
|
69
|
+
("action", "Log in to obtain a token"),
|
|
70
|
+
(
|
|
71
|
+
"action",
|
|
72
|
+
f"Send authenticated POST {ep.path} with an incomplete payload",
|
|
73
|
+
),
|
|
74
|
+
("assertion", "Expect HTTP 400/422 (validation error)"),
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def resolve_client(config: Config) -> LlmClient | None:
|
|
82
|
+
"""None → no enrichment (deterministic baseline).
|
|
83
|
+
|
|
84
|
+
With ``config.enrich`` set, prefer the REAL bridge: the Suitest server's
|
|
85
|
+
``/llm/complete`` proxy (workspace LLM config, key stays server-side; see
|
|
86
|
+
:mod:`suitest_lifecycle.llm_bridge`). When no API url/key is available the
|
|
87
|
+
deterministic :class:`MockLlmClient` keeps the run key-independent.
|
|
88
|
+
"""
|
|
89
|
+
if not config.enrich:
|
|
90
|
+
return None
|
|
91
|
+
from suitest_lifecycle.llm_bridge import resolve_remote
|
|
92
|
+
|
|
93
|
+
remote = resolve_remote(config)
|
|
94
|
+
if remote is not None:
|
|
95
|
+
return remote
|
|
96
|
+
return MockLlmClient()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def enrich_plan(
|
|
100
|
+
summary: CodeSummary, cases: list[PlanCase], config: Config, client: LlmClient | None
|
|
101
|
+
) -> list[PlanCase]:
|
|
102
|
+
"""Return cases plus any LLM-proposed edge cases (tagged ``llm``).
|
|
103
|
+
|
|
104
|
+
Idempotent: proposals whose title already exists are skipped, so re-running
|
|
105
|
+
never duplicates. With ``client is None`` the input list is returned unchanged.
|
|
106
|
+
"""
|
|
107
|
+
if client is None:
|
|
108
|
+
return cases
|
|
109
|
+
existing = {c.title for c in cases}
|
|
110
|
+
next_n = _max_tc(cases)
|
|
111
|
+
enriched = list(cases)
|
|
112
|
+
for sug in client.propose_edge_cases(summary, existing):
|
|
113
|
+
if sug.title in existing:
|
|
114
|
+
continue
|
|
115
|
+
next_n += 1
|
|
116
|
+
enriched.append(
|
|
117
|
+
PlanCase(
|
|
118
|
+
id=f"TC{next_n:03d}",
|
|
119
|
+
title=sug.title,
|
|
120
|
+
description=sug.description,
|
|
121
|
+
category=sug.category,
|
|
122
|
+
priority=sug.priority,
|
|
123
|
+
source_ref=sug.source_ref,
|
|
124
|
+
steps=[PlanStep(type=t, description=d) for t, d in sug.steps],
|
|
125
|
+
tags=["llm"],
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
existing.add(sug.title)
|
|
129
|
+
return enriched
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _max_tc(cases: list[PlanCase]) -> int:
|
|
133
|
+
best = 0
|
|
134
|
+
for c in cases:
|
|
135
|
+
if c.id.startswith("TC") and c.id[2:].isdigit():
|
|
136
|
+
best = max(best, int(c.id[2:]))
|
|
137
|
+
return best
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
__all__ = ["EdgeSuggestion", "LlmClient", "MockLlmClient", "enrich_plan", "resolve_client"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Runnable test-file exporters."""
|