@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,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."""