@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,166 @@
1
+ """Domain models for the Suitest testing lifecycle.
2
+
3
+ Plain ``dataclasses`` (not Pydantic) on purpose: the lifecycle core runs as a
4
+ CLI / MCP subprocess with a stdlib-only footprint so it can drive *any* target
5
+ project without dragging in the API stack. All structures are fully typed (mypy
6
+ strict, no ``Any``) and serialise to/from JSON via :func:`to_jsonable`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import StrEnum
13
+
14
+
15
+ class Mode(StrEnum):
16
+ """Which side of the target app the lifecycle drives."""
17
+
18
+ BACKEND = "backend"
19
+ FRONTEND = "frontend"
20
+
21
+
22
+ class TestOutcome(StrEnum):
23
+ PASSED = "PASSED"
24
+ FAILED = "FAILED"
25
+ SKIPPED = "SKIPPED"
26
+ ERROR = "ERROR"
27
+
28
+
29
+ class Priority(StrEnum):
30
+ HIGH = "High"
31
+ MEDIUM = "Medium"
32
+ LOW = "Low"
33
+
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # Code analysis
37
+ # --------------------------------------------------------------------------- #
38
+ @dataclass(frozen=True)
39
+ class Endpoint:
40
+ """A single discovered HTTP endpoint of the backend under test."""
41
+
42
+ method: str # GET / POST / PUT / DELETE / PATCH
43
+ path: str # e.g. /api/products/:id (full, mount-prefixed)
44
+ auth_required: bool
45
+ source_file: str # repo-relative path the route was found in, or spec name
46
+ handler: str = "" # controller/handler name if recoverable
47
+ summary: str = ""
48
+ # No-repo: an example request body parsed from OpenAPI/Postman so the backend
49
+ # exporter can build a valid create payload without the project's Zod schema.
50
+ request_example: dict[str, object] | None = None
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class Page:
55
+ """A single discovered frontend page/route."""
56
+
57
+ route: str # e.g. /products/:id
58
+ name: str # component name, e.g. ProductFormPage
59
+ protected: bool
60
+ source_file: str
61
+
62
+
63
+ @dataclass
64
+ class CodeSummary:
65
+ """Static analysis result for one target project (the ``code_summary``)."""
66
+
67
+ project_name: str
68
+ mode: Mode
69
+ tech_stack: list[str] = field(default_factory=list)
70
+ endpoints: list[Endpoint] = field(default_factory=list)
71
+ pages: list[Page] = field(default_factory=list)
72
+ features: list[str] = field(default_factory=list)
73
+ auth_flow: str = "" # short human description if detected
74
+
75
+
76
+ # --------------------------------------------------------------------------- #
77
+ # PRD + test plan
78
+ # --------------------------------------------------------------------------- #
79
+ @dataclass
80
+ class PrdFeature:
81
+ name: str
82
+ description: str
83
+ user_flows: list[str] = field(default_factory=list)
84
+
85
+
86
+ @dataclass
87
+ class Prd:
88
+ """Normalised product requirement doc — mirrors TestSprite ``standard_prd``."""
89
+
90
+ project: str
91
+ date: str
92
+ prepared_by: str
93
+ product_overview: str
94
+ core_goals: list[str]
95
+ features: list[PrdFeature]
96
+
97
+
98
+ @dataclass
99
+ class PlanStep:
100
+ type: str # "action" | "assertion"
101
+ description: str
102
+
103
+
104
+ @dataclass
105
+ class PlanCase:
106
+ """One entry in the generated test plan (the source-of-truth test case)."""
107
+
108
+ id: str # TC001 ...
109
+ title: str # snake/sentence title
110
+ description: str
111
+ category: str
112
+ priority: Priority
113
+ steps: list[PlanStep] = field(default_factory=list)
114
+ # Traceability back to source (Goal 8 — no dummy tests).
115
+ source_ref: str = "" # "POST /api/products" or "page:/products/new"
116
+ automation_file: str = "" # filename of the exported TCxxx.py
117
+ tags: list[str] = field(default_factory=list) # e.g. ["llm"] for enriched cases
118
+
119
+
120
+ # --------------------------------------------------------------------------- #
121
+ # Execution results
122
+ # --------------------------------------------------------------------------- #
123
+ @dataclass
124
+ class StepResult:
125
+ """A recorded per-step outcome (drives the web Steps panel)."""
126
+
127
+ index: int
128
+ type: str # "action" | "assertion"
129
+ description: str
130
+ status: TestOutcome
131
+ duration_ms: int = 0
132
+ screenshot_path: str = "" # per-step screenshot (frontend) — "Preview: Step N"
133
+
134
+
135
+ @dataclass
136
+ class TestResult:
137
+ test_id: str
138
+ title: str
139
+ description: str
140
+ status: TestOutcome
141
+ duration_ms: int
142
+ error: str = ""
143
+ automation_file: str = ""
144
+ artifacts: list[str] = field(default_factory=list)
145
+ # Phase 2 — rich recording (collected from each test's sidecar JSON).
146
+ steps: list[StepResult] = field(default_factory=list)
147
+ video_path: str = ""
148
+ screenshot_path: str = ""
149
+
150
+
151
+ @dataclass
152
+ class RunSummary:
153
+ project: str
154
+ mode: Mode
155
+ base_url: str
156
+ total: int
157
+ passed: int
158
+ failed: int
159
+ skipped: int
160
+ errored: int
161
+ duration_ms: int
162
+ results: list[TestResult] = field(default_factory=list)
163
+ server_started: bool = False
164
+ ready: bool = False
165
+ ready_detail: str = ""
166
+ startup_log_tail: str = ""
@@ -0,0 +1,500 @@
1
+ """Lifecycle orchestrator — the analyze → generate → start → wait → run → report loop.
2
+
3
+ This is the single brain behind both the ``suitest test`` CLI and the MCP
4
+ lifecycle tools. It is deterministic (ZERO tier) end to end; LLM enrichment can
5
+ later sit on top of analysis/PRD without changing this control flow.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import datetime
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from typing import TYPE_CHECKING
14
+
15
+ from suitest_lifecycle.analyzers.express import analyze_express
16
+ from suitest_lifecycle.analyzers.react import analyze_react
17
+ from suitest_lifecycle.enrich import enrich_plan, resolve_client
18
+ from suitest_lifecycle.exporters.backend import export_backend_tests
19
+ from suitest_lifecycle.exporters.frontend import export_frontend_tests
20
+ from suitest_lifecycle.frontend_runtime import ensure_browser
21
+ from suitest_lifecycle.models import CodeSummary, Mode, PlanCase, RunSummary, TestOutcome
22
+ from suitest_lifecycle.paths import Paths, build_paths
23
+ from suitest_lifecycle.plan import generate_backend_plan
24
+ from suitest_lifecycle.plan_frontend import generate_frontend_plan
25
+ from suitest_lifecycle.prd import build_prd
26
+ from suitest_lifecycle.process import ProcessManager
27
+ from suitest_lifecycle.publish import publish_results
28
+ from suitest_lifecycle.readiness import wait_until_ready
29
+ from suitest_lifecycle.report import write_all_reports
30
+ from suitest_lifecycle.runner import run_tests
31
+ from suitest_lifecycle.serialize import (
32
+ code_summary_to_json,
33
+ plan_to_json,
34
+ prd_to_json,
35
+ results_to_json,
36
+ )
37
+ from suitest_lifecycle.tcm import sync_tcm
38
+
39
+ if TYPE_CHECKING:
40
+ from suitest_lifecycle.config import Config, DependencyConfig
41
+
42
+
43
+ @dataclass
44
+ class LifecycleResult:
45
+ success: bool
46
+ summary: str
47
+ run: RunSummary | None
48
+ artifacts: list[str] = field(default_factory=list)
49
+ errors: list[str] = field(default_factory=list)
50
+ steps: list[str] = field(default_factory=list)
51
+
52
+
53
+ def _publish_step(pub: dict[str, object]) -> str:
54
+ if pub.get("published"):
55
+ return (
56
+ f"published to Suitest: run {pub.get('runId')} ({pub.get('imported')} cases imported)"
57
+ )
58
+ return f"publish skipped — {pub.get('reason')}"
59
+
60
+
61
+ def _today() -> str:
62
+ return datetime.date.today().isoformat()
63
+
64
+
65
+ def _now_iso() -> str:
66
+ return datetime.datetime.now().replace(microsecond=0).isoformat()
67
+
68
+
69
+ def _analyze(config: Config) -> CodeSummary:
70
+ if config.mode is Mode.BACKEND:
71
+ if config.analysis_source == "openapi":
72
+ from suitest_lifecycle.analyzers.openapi import analyze_openapi, load_spec
73
+
74
+ spec = load_spec(
75
+ url=config.openapi_url, file=config.openapi_file, base_url=config.base_url
76
+ )
77
+ return analyze_openapi(spec, config.project_name)
78
+ if config.analysis_source == "postman":
79
+ from suitest_lifecycle.analyzers.postman import analyze_postman, load_collection
80
+
81
+ return analyze_postman(load_collection(config.postman_file), config.project_name)
82
+ return analyze_express(config.project_path, config.project_name)
83
+ return analyze_react(config.project_path, config.project_name)
84
+
85
+
86
+ def _plan(config: Config, summary: CodeSummary) -> list[PlanCase]:
87
+ if config.mode is Mode.BACKEND:
88
+ return generate_backend_plan(summary)
89
+ return generate_frontend_plan(summary, config)
90
+
91
+
92
+ def _export(
93
+ config: Config,
94
+ cases: list[PlanCase],
95
+ summary: CodeSummary,
96
+ paths: Paths,
97
+ *,
98
+ llm: object | None = None,
99
+ dom_context: str = "",
100
+ ) -> list[PlanCase]:
101
+ if config.mode is Mode.BACKEND:
102
+ return export_backend_tests(cases, summary, config, paths)
103
+ return export_frontend_tests(cases, summary, config, paths, llm=llm, dom_context=dom_context)
104
+
105
+
106
+ def _is_crawl(config: Config) -> bool:
107
+ """Crawl discovery needs the live app, so generation is deferred until after
108
+ the target is ready (unlike repo/openapi which analyze before start)."""
109
+ return config.mode is Mode.FRONTEND and config.analysis_source == "crawl"
110
+
111
+
112
+ def _is_blackbox(config: Config) -> bool:
113
+ """Blackbox DOM engine (no repo, no LLM) — also deferred until the app is up."""
114
+ return config.mode is Mode.FRONTEND and config.analysis_source == "blackbox"
115
+
116
+
117
+ def generate_only(
118
+ config: Config,
119
+ summary: CodeSummary | None = None,
120
+ *,
121
+ crawl: object | None = None,
122
+ ) -> tuple[CodeSummary, list[PlanCase], Paths]:
123
+ """Run analyze → PRD → plan → export and write artifacts (no execution).
124
+
125
+ ``summary`` may be pre-computed (e.g. from a live DOM crawl that already ran
126
+ after the app came up); otherwise it is analyzed here. ``crawl`` is the
127
+ optional :class:`CrawlResult` whose DOM digest powers LLM codegen for apps
128
+ with no data-testid convention.
129
+ """
130
+ paths = build_paths(config.output_dir, config.mode)
131
+ paths.ensure()
132
+ if summary is None:
133
+ summary = _analyze(config)
134
+ prd = build_prd(summary, _today(), config.project_name)
135
+ cases = _plan(config, summary)
136
+ client = resolve_client(config)
137
+ cases = enrich_plan(summary, cases, config, client)
138
+ # LLM codegen wants a client even when enrichment is off — resolve the
139
+ # remote bridge directly unless codegen is pinned deterministic.
140
+ codegen_llm = client
141
+ if codegen_llm is None and config.mode is Mode.FRONTEND and config.codegen != "deterministic":
142
+ from suitest_lifecycle.llm_bridge import resolve_remote
143
+
144
+ codegen_llm = resolve_remote(config)
145
+ from suitest_lifecycle.llm_bridge import build_dom_context
146
+
147
+ dom_context = build_dom_context(crawl, summary) # type: ignore[arg-type]
148
+ cases = _export(config, cases, summary, paths, llm=codegen_llm, dom_context=dom_context)
149
+
150
+ paths.code_summary_json.write_text(json.dumps(code_summary_to_json(summary), indent=2), "utf-8")
151
+ paths.prd_json.write_text(json.dumps(prd_to_json(prd), indent=2), encoding="utf-8")
152
+ paths.test_plan_json.write_text(json.dumps(plan_to_json(cases), indent=2), encoding="utf-8")
153
+ paths.config_snapshot_json.write_text(_config_snapshot(config), encoding="utf-8")
154
+ return summary, cases, paths
155
+
156
+
157
+ def _config_snapshot(config: Config) -> str:
158
+ return json.dumps(
159
+ {
160
+ "mode": config.mode.value,
161
+ "scope": config.scope,
162
+ "projectName": config.project_name,
163
+ "projectPath": str(config.project_path),
164
+ "baseUrl": config.base_url,
165
+ "apiBasePath": config.api_base_path,
166
+ "readyPath": config.ready_path,
167
+ "port": config.port,
168
+ "autostart": config.server.autostart,
169
+ "startCommand": config.server.start_command,
170
+ "testIds": config.test_ids,
171
+ },
172
+ indent=2,
173
+ )
174
+
175
+
176
+ def _blackbox_generate(config: Config) -> tuple[CodeSummary, list[PlanCase], Paths, list[str]]:
177
+ """Blackbox path: discover the live app → graph → deterministic tests.
178
+
179
+ Writes discovery.json / interaction_graph.json / blackbox_report.json next
180
+ to the other run artifacts so MCP tools and LLM consumers can pick them up.
181
+ """
182
+ import json as _json
183
+
184
+ from suitest_lifecycle.blackbox.crawler import discover
185
+ from suitest_lifecycle.blackbox.generator import export_blackbox_tests
186
+ from suitest_lifecycle.blackbox.graph import build_graph
187
+ from suitest_lifecycle.blackbox.models import BlackboxUiConfig
188
+ from suitest_lifecycle.blackbox.reporter import summarize, write_report
189
+ from suitest_lifecycle.models import Page
190
+
191
+ ui = config.ui if isinstance(config.ui, BlackboxUiConfig) else BlackboxUiConfig()
192
+ if not ui.target_url:
193
+ ui.target_url = config.base_url
194
+ if not ui.auth.username:
195
+ ui.auth.username = config.auth.username
196
+ ui.auth.password = config.auth.password
197
+
198
+ paths = build_paths(config.output_dir, config.mode)
199
+ paths.ensure()
200
+ evidence_dir = paths.tmp_dir / "blackbox"
201
+
202
+ discovery = discover(ui, evidence_dir)
203
+ steps = [
204
+ f"blackbox: discovered {len(discovery.pages)} route(s); "
205
+ f"login {'detected' if discovery.login else 'not found'}"
206
+ + (", login OK" if discovery.login_probe.success else ""),
207
+ ]
208
+ graph = build_graph(discovery)
209
+ (paths.tmp_dir / "discovery.json").write_text(
210
+ _json.dumps(discovery.to_json(), indent=2), encoding="utf-8"
211
+ )
212
+ (paths.tmp_dir / "interaction_graph.json").write_text(
213
+ _json.dumps(graph, indent=2), encoding="utf-8"
214
+ )
215
+ write_report(summarize(discovery, graph=graph), paths.reports_dir)
216
+
217
+ prd_context = ""
218
+ llm = None
219
+ if config.prd_file:
220
+ from suitest_lifecycle.blackbox.prd_ingest import load_prd
221
+ from suitest_lifecycle.llm_bridge import resolve_remote
222
+
223
+ prd_doc = load_prd(config.prd_file)
224
+ (paths.tmp_dir / "prd_ingest.json").write_text(
225
+ _json.dumps(prd_doc.to_json(), indent=2), encoding="utf-8"
226
+ )
227
+ prd_context = prd_doc.as_prompt_context()
228
+ llm = resolve_remote(config)
229
+ steps.append(
230
+ f"prd: ingested '{prd_doc.title or config.prd_file}' "
231
+ f"({len(prd_doc.requirements)} requirement(s)); "
232
+ f"LLM bridge {'available' if llm else 'unavailable — deterministic only'}"
233
+ )
234
+ cases = export_blackbox_tests(discovery, ui, paths, llm=llm, prd_context=prd_context)
235
+ steps.append(f"blackbox: generated {len(cases)} test case(s)")
236
+
237
+ summary = CodeSummary(
238
+ project_name=config.project_name,
239
+ mode=Mode.FRONTEND,
240
+ tech_stack=["Web", "blackbox"],
241
+ pages=[
242
+ Page(route=p.route, name=p.pattern, protected=p.protected, source_file="blackbox")
243
+ for p in discovery.pages
244
+ ],
245
+ features=[p.pattern for p in discovery.pages],
246
+ auth_flow=(
247
+ "Login form discovered via blackbox DOM heuristics."
248
+ if discovery.login
249
+ else "No login form found."
250
+ ),
251
+ )
252
+ paths.code_summary_json.write_text(
253
+ _json.dumps(code_summary_to_json(summary), indent=2), encoding="utf-8"
254
+ )
255
+ paths.test_plan_json.write_text(_json.dumps(plan_to_json(cases), indent=2), encoding="utf-8")
256
+ paths.config_snapshot_json.write_text(_config_snapshot(config), encoding="utf-8")
257
+ return summary, cases, paths, steps
258
+
259
+
260
+ def run_lifecycle(config: Config) -> LifecycleResult:
261
+ steps: list[str] = []
262
+ errors: list[str] = []
263
+
264
+ crawl_mode = _is_crawl(config) or _is_blackbox(config)
265
+ summary_code: CodeSummary | None
266
+ if crawl_mode:
267
+ # Discovery needs the live app — defer analyze/generate until after ready.
268
+ paths = build_paths(config.output_dir, config.mode)
269
+ paths.ensure()
270
+ summary_code = None
271
+ cases = []
272
+ steps.append("crawl mode: discovery deferred until target is ready")
273
+ else:
274
+ summary_code, cases, paths = generate_only(config)
275
+ steps.append(f"analyzed {config.mode.value}: {_count_label(summary_code)}")
276
+ steps.append(f"generated {len(cases)} test case(s) + runnable files")
277
+
278
+ pm = ProcessManager()
279
+ dep_managers: list[tuple[DependencyConfig, ProcessManager]] = []
280
+ server_started = False
281
+ ready_detail = "autostart disabled — assuming target already running"
282
+ is_ready = True
283
+ startup_tail = ""
284
+
285
+ host = "localhost"
286
+ ready_url = config.base_url.rstrip("/") + "/" + config.ready_path.lstrip("/")
287
+
288
+ def _finish_fail(detail: str) -> LifecycleResult:
289
+ errors.append(f"not ready: {detail}")
290
+ run_failed = _empty_run(config, summary_code, server_started, False, detail, startup_tail)
291
+ _finalize(config, cases, run_failed, paths)
292
+ if config.publish.enabled:
293
+ steps.append(_publish_step(publish_results(config, run_failed, cases, paths)))
294
+ return LifecycleResult(
295
+ success=False,
296
+ summary=f"FAILED — {detail}",
297
+ run=run_failed,
298
+ artifacts=_artifact_list(paths),
299
+ errors=errors,
300
+ steps=steps,
301
+ )
302
+
303
+ try:
304
+ # 1) start dependency services (e.g. the backend a frontend run needs)
305
+ for dep in config.dependencies:
306
+ dpm = ProcessManager()
307
+ dmanaged = dpm.start(dep.start_command, dep.cwd, dep.env)
308
+ dep_managers.append((dep, dpm))
309
+ steps.append(
310
+ f"started dependency '{dep.name}': {dep.start_command} (pid {dmanaged.popen.pid})"
311
+ )
312
+ dverdict = wait_until_ready(
313
+ dep.ready_url,
314
+ "localhost",
315
+ dep.port,
316
+ dep.ready_timeout_sec,
317
+ log_reader=dmanaged.log_text,
318
+ ready_log_pattern=dep.ready_log_pattern,
319
+ )
320
+ if not dverdict.ready:
321
+ return _finish_fail(f"dependency '{dep.name}' not ready: {dverdict.detail}")
322
+ steps.append(
323
+ f"dependency '{dep.name}' ready: {dverdict.strategy} ({dverdict.waited_ms} ms)"
324
+ )
325
+
326
+ # 2) start the main target (or wait for an already-running one)
327
+ if config.server.autostart:
328
+ cwd = (config.project_path / config.server.cwd).resolve()
329
+ managed = pm.start(config.server.start_command, cwd, config.server.env)
330
+ server_started = True
331
+ steps.append(f"started target: {config.server.start_command} (pid {managed.popen.pid})")
332
+ verdict = wait_until_ready(
333
+ ready_url,
334
+ host,
335
+ config.port,
336
+ config.server.ready_timeout_sec,
337
+ log_reader=managed.log_text,
338
+ ready_log_pattern=config.server.ready_log_pattern,
339
+ )
340
+ is_ready = verdict.ready
341
+ ready_detail = f"{verdict.strategy}: {verdict.detail} ({verdict.waited_ms} ms)"
342
+ startup_tail = managed.tail(40)
343
+ steps.append(f"readiness: {ready_detail}")
344
+ else:
345
+ verdict = wait_until_ready(
346
+ ready_url, host, config.port, config.server.ready_timeout_sec
347
+ )
348
+ is_ready = verdict.ready
349
+ ready_detail = f"{verdict.strategy}: {verdict.detail} ({verdict.waited_ms} ms)"
350
+ steps.append(f"readiness (no autostart): {ready_detail}")
351
+
352
+ if not is_ready:
353
+ return _finish_fail(f"target never became ready ({ready_detail})")
354
+
355
+ # 3) frontend: ensure Suitest's bundled browser is provisioned (user never
356
+ # installs playwright themselves — Suitest owns the runtime)
357
+ if config.mode is Mode.FRONTEND:
358
+ browser = ensure_browser()
359
+ steps.append(f"browser: {browser.detail}")
360
+ if not browser.ready:
361
+ return _finish_fail(f"browser unavailable: {browser.detail}")
362
+
363
+ # 4) crawl/blackbox mode: app is up — discover via live DOM, then generate.
364
+ if _is_blackbox(config):
365
+ summary_code, cases, paths, bb_steps = _blackbox_generate(config)
366
+ steps.extend(bb_steps)
367
+ elif crawl_mode:
368
+ from suitest_lifecycle.analyzers.crawl import analyze_crawl
369
+
370
+ crawl = analyze_crawl(config.base_url, config.auth.username, config.auth.password)
371
+ steps.append(
372
+ f"crawled {len(crawl.summary.pages)} page(s); "
373
+ f"login {'found' if crawl.login.email else 'not found'}"
374
+ )
375
+ summary_code, cases, paths = generate_only(config, crawl.summary, crawl=crawl)
376
+ steps.append(f"generated {len(cases)} test case(s) from crawl")
377
+
378
+ results = run_tests(
379
+ cases,
380
+ paths.mode_dir,
381
+ selected_ids=config.test_ids or None,
382
+ timeout_sec=120,
383
+ )
384
+ steps.append(f"executed {len(results)} test(s)")
385
+ finally:
386
+ if server_started:
387
+ pm.stop(config.server.stop_grace_sec)
388
+ steps.append("stopped target server")
389
+ for dep, dpm in reversed(dep_managers):
390
+ dpm.stop(dep.stop_grace_sec)
391
+ steps.append(f"stopped dependency '{dep.name}'")
392
+
393
+ run = _build_run(config, summary_code, results, server_started, ready_detail, startup_tail)
394
+ _finalize(config, cases, run, paths)
395
+ if config.publish.enabled:
396
+ steps.append(_publish_step(publish_results(config, run, cases, paths)))
397
+
398
+ ok = run.failed == 0 and run.errored == 0
399
+ verb = "PASSED" if ok else "FAILED"
400
+ return LifecycleResult(
401
+ success=ok,
402
+ summary=(
403
+ f"{verb} — {run.passed}/{run.total} passed "
404
+ f"({run.failed} failed, {run.skipped} skipped) in {run.duration_ms} ms"
405
+ ),
406
+ run=run,
407
+ artifacts=_artifact_list(paths),
408
+ errors=errors,
409
+ steps=steps,
410
+ )
411
+
412
+
413
+ def _finalize(config: Config, cases: list[PlanCase], run: RunSummary, paths: Paths) -> None:
414
+ paths.test_results_json.write_text(
415
+ json.dumps(results_to_json(run.results), indent=2), encoding="utf-8"
416
+ )
417
+ write_all_reports(run, paths, _today())
418
+ sync_tcm(cases, run, paths, config.mode, _now_iso())
419
+
420
+
421
+ def _build_run(
422
+ config: Config,
423
+ summary_code: CodeSummary,
424
+ results: list, # list[TestResult]
425
+ server_started: bool,
426
+ ready_detail: str,
427
+ startup_tail: str,
428
+ ) -> RunSummary:
429
+ passed = sum(1 for r in results if r.status is TestOutcome.PASSED)
430
+ failed = sum(1 for r in results if r.status is TestOutcome.FAILED)
431
+ skipped = sum(1 for r in results if r.status is TestOutcome.SKIPPED)
432
+ errored = sum(1 for r in results if r.status is TestOutcome.ERROR)
433
+ duration = sum(r.duration_ms for r in results)
434
+ return RunSummary(
435
+ project=config.project_name,
436
+ mode=config.mode,
437
+ base_url=config.base_url,
438
+ total=len(results),
439
+ passed=passed,
440
+ failed=failed,
441
+ skipped=skipped,
442
+ errored=errored,
443
+ duration_ms=duration,
444
+ results=results,
445
+ server_started=server_started,
446
+ ready=True,
447
+ ready_detail=ready_detail,
448
+ startup_log_tail=startup_tail,
449
+ )
450
+
451
+
452
+ def _empty_run(
453
+ config: Config,
454
+ summary_code: CodeSummary,
455
+ server_started: bool,
456
+ ready: bool,
457
+ ready_detail: str,
458
+ startup_tail: str,
459
+ ) -> RunSummary:
460
+ return RunSummary(
461
+ project=config.project_name,
462
+ mode=config.mode,
463
+ base_url=config.base_url,
464
+ total=0,
465
+ passed=0,
466
+ failed=0,
467
+ skipped=0,
468
+ errored=0,
469
+ duration_ms=0,
470
+ results=[],
471
+ server_started=server_started,
472
+ ready=ready,
473
+ ready_detail=ready_detail,
474
+ startup_log_tail=startup_tail,
475
+ )
476
+
477
+
478
+ def _count_label(summary: CodeSummary) -> str:
479
+ if summary.mode is Mode.BACKEND:
480
+ return f"{len(summary.endpoints)} endpoints"
481
+ return f"{len(summary.pages)} pages"
482
+
483
+
484
+ def _artifact_list(paths: Paths) -> list[str]:
485
+ candidates = [
486
+ paths.code_summary_json,
487
+ paths.prd_json,
488
+ paths.test_plan_json,
489
+ paths.test_results_json,
490
+ paths.raw_report_md,
491
+ paths.reports_dir / "summary.json",
492
+ paths.reports_dir / "summary.md",
493
+ paths.reports_dir / "summary.html",
494
+ paths.tcm_cases_json,
495
+ paths.tcm_runs_json,
496
+ ]
497
+ return [str(p) for p in candidates if p.exists()]
498
+
499
+
500
+ __all__ = ["LifecycleResult", "generate_only", "run_lifecycle"]