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