@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,252 @@
|
|
|
1
|
+
"""Deterministic frontend test-plan generator (ZERO tier).
|
|
2
|
+
|
|
3
|
+
Builds UI test cases from discovered pages: login happy/invalid, protected-route
|
|
4
|
+
redirect, dashboard/list render, create-via-form, search empty state, logout.
|
|
5
|
+
Each case's ``source_ref`` is ``fe:<archetype> <route>`` so the playwright
|
|
6
|
+
exporter can render the right script, and is traceable to a real page route.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from suitest_lifecycle.models import CodeSummary, PlanCase, PlanStep, Priority
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from suitest_lifecycle.config import Config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _routes(summary: CodeSummary) -> dict[str, bool]:
|
|
20
|
+
return {p.route: p.protected for p in summary.pages}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _case(
|
|
24
|
+
cid: str,
|
|
25
|
+
title: str,
|
|
26
|
+
desc: str,
|
|
27
|
+
category: str,
|
|
28
|
+
prio: Priority,
|
|
29
|
+
ref: str,
|
|
30
|
+
steps: list[tuple[str, str]],
|
|
31
|
+
) -> PlanCase:
|
|
32
|
+
return PlanCase(
|
|
33
|
+
id=cid,
|
|
34
|
+
title=title,
|
|
35
|
+
description=desc,
|
|
36
|
+
category=category,
|
|
37
|
+
priority=prio,
|
|
38
|
+
source_ref=ref,
|
|
39
|
+
steps=[PlanStep(type=t, description=d) for t, d in steps],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_frontend_plan(summary: CodeSummary, config: Config) -> list[PlanCase]:
|
|
44
|
+
routes = _routes(summary)
|
|
45
|
+
has_login = "/login" in routes
|
|
46
|
+
protected = [r for r, prot in routes.items() if prot]
|
|
47
|
+
cases: list[PlanCase] = []
|
|
48
|
+
n = 0
|
|
49
|
+
|
|
50
|
+
def nid() -> str:
|
|
51
|
+
nonlocal n
|
|
52
|
+
n += 1
|
|
53
|
+
return f"TC{n:03d}"
|
|
54
|
+
|
|
55
|
+
if has_login:
|
|
56
|
+
cases.append(
|
|
57
|
+
_case(
|
|
58
|
+
nid(),
|
|
59
|
+
"successful_login_opens_the_dashboard",
|
|
60
|
+
"Valid credentials log in and land on the dashboard.",
|
|
61
|
+
"Auth",
|
|
62
|
+
Priority.HIGH,
|
|
63
|
+
"fe:login_success /login",
|
|
64
|
+
[
|
|
65
|
+
("action", "Navigate to /login"),
|
|
66
|
+
("action", "Fill email and password and submit"),
|
|
67
|
+
("assertion", "Dashboard page is visible"),
|
|
68
|
+
],
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
cases.append(
|
|
72
|
+
_case(
|
|
73
|
+
nid(),
|
|
74
|
+
"invalid_login_shows_an_error",
|
|
75
|
+
"Wrong credentials keep the user on login with an error.",
|
|
76
|
+
"Auth",
|
|
77
|
+
Priority.MEDIUM,
|
|
78
|
+
"fe:invalid_login /login",
|
|
79
|
+
[
|
|
80
|
+
("action", "Navigate to /login"),
|
|
81
|
+
("action", "Submit an invalid password"),
|
|
82
|
+
("assertion", "An error message is shown and URL stays /login"),
|
|
83
|
+
],
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
cases.append(
|
|
87
|
+
_case(
|
|
88
|
+
nid(),
|
|
89
|
+
"login_with_empty_fields_shows_validation_error",
|
|
90
|
+
"Submitting the login form with empty fields shows a validation error.",
|
|
91
|
+
"Auth",
|
|
92
|
+
Priority.MEDIUM,
|
|
93
|
+
"fe:empty_login /login",
|
|
94
|
+
[
|
|
95
|
+
("action", "Navigate to /login"),
|
|
96
|
+
("action", "Submit the form with both fields empty"),
|
|
97
|
+
("assertion", "A validation error is shown and URL stays /login"),
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if protected:
|
|
103
|
+
target = "/products" if "/products" in routes else protected[0]
|
|
104
|
+
cases.append(
|
|
105
|
+
_case(
|
|
106
|
+
nid(),
|
|
107
|
+
"protected_route_redirects_anonymous_to_login",
|
|
108
|
+
f"Visiting {target} unauthenticated redirects to /login.",
|
|
109
|
+
"Auth",
|
|
110
|
+
Priority.HIGH,
|
|
111
|
+
f"fe:protected_redirect {target}",
|
|
112
|
+
[
|
|
113
|
+
("action", f"Navigate directly to {target} with no session"),
|
|
114
|
+
("assertion", "Login page is shown"),
|
|
115
|
+
],
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if "/dashboard" in routes:
|
|
120
|
+
cases.append(
|
|
121
|
+
_case(
|
|
122
|
+
nid(),
|
|
123
|
+
"dashboard_shows_summary_after_login",
|
|
124
|
+
"After login the dashboard renders its summary cards.",
|
|
125
|
+
"Dashboard",
|
|
126
|
+
Priority.MEDIUM,
|
|
127
|
+
"fe:dashboard_loads /dashboard",
|
|
128
|
+
[
|
|
129
|
+
("action", "Log in"),
|
|
130
|
+
("assertion", "Dashboard summary is visible"),
|
|
131
|
+
],
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
if has_login:
|
|
135
|
+
cases.append(
|
|
136
|
+
_case(
|
|
137
|
+
nid(),
|
|
138
|
+
"logout_returns_to_login_and_clears_session",
|
|
139
|
+
"Logging out returns to /login and protected routes redirect again.",
|
|
140
|
+
"Auth",
|
|
141
|
+
Priority.MEDIUM,
|
|
142
|
+
"fe:logout /dashboard",
|
|
143
|
+
[
|
|
144
|
+
("action", "Log in"),
|
|
145
|
+
("action", "Click the logout button"),
|
|
146
|
+
("assertion", "Login page is shown"),
|
|
147
|
+
("action", "Navigate to /dashboard again"),
|
|
148
|
+
("assertion", "Still on the login page (session cleared)"),
|
|
149
|
+
],
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
if "/products" in routes:
|
|
154
|
+
cases.append(
|
|
155
|
+
_case(
|
|
156
|
+
nid(),
|
|
157
|
+
"products_list_loads_after_login",
|
|
158
|
+
"Authenticated user can open the products list.",
|
|
159
|
+
"Products",
|
|
160
|
+
Priority.MEDIUM,
|
|
161
|
+
"fe:products_list /products",
|
|
162
|
+
[
|
|
163
|
+
("action", "Log in and go to /products"),
|
|
164
|
+
("assertion", "Products page is visible"),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
cases.append(
|
|
169
|
+
_case(
|
|
170
|
+
nid(),
|
|
171
|
+
"search_with_no_match_shows_empty_state",
|
|
172
|
+
"Searching for a non-existent product shows an empty state.",
|
|
173
|
+
"Products",
|
|
174
|
+
Priority.LOW,
|
|
175
|
+
"fe:search_empty /products",
|
|
176
|
+
[
|
|
177
|
+
("action", "Log in, open /products, type an unlikely query"),
|
|
178
|
+
("assertion", "Empty state is visible"),
|
|
179
|
+
],
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
if "/products/new" in routes:
|
|
183
|
+
cases.append(
|
|
184
|
+
_case(
|
|
185
|
+
nid(),
|
|
186
|
+
"search_with_match_filters_the_product_list",
|
|
187
|
+
"Searching for an existing product narrows the list to that product.",
|
|
188
|
+
"Products",
|
|
189
|
+
Priority.MEDIUM,
|
|
190
|
+
"fe:search_match /products",
|
|
191
|
+
[
|
|
192
|
+
("action", "Log in and create a uniquely-named product"),
|
|
193
|
+
("action", "Open /products and search for that exact name"),
|
|
194
|
+
("assertion", "Exactly the matching product row is shown"),
|
|
195
|
+
],
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
cases.append(
|
|
199
|
+
_case(
|
|
200
|
+
nid(),
|
|
201
|
+
"delete_product_removes_it_from_the_list",
|
|
202
|
+
"Deleting a product removes its row from the list.",
|
|
203
|
+
"Products",
|
|
204
|
+
Priority.MEDIUM,
|
|
205
|
+
"fe:delete_product /products",
|
|
206
|
+
[
|
|
207
|
+
("action", "Log in and create a uniquely-named product"),
|
|
208
|
+
(
|
|
209
|
+
"action",
|
|
210
|
+
"Search for it and click its delete button, accepting the confirm",
|
|
211
|
+
),
|
|
212
|
+
("assertion", "The product row disappears from the list"),
|
|
213
|
+
],
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
if "/products/new" in routes:
|
|
218
|
+
cases.append(
|
|
219
|
+
_case(
|
|
220
|
+
nid(),
|
|
221
|
+
"create_product_via_form_returns_to_list",
|
|
222
|
+
"Filling the product form creates a product and returns to the list.",
|
|
223
|
+
"Products",
|
|
224
|
+
Priority.HIGH,
|
|
225
|
+
"fe:create_product /products/new",
|
|
226
|
+
[
|
|
227
|
+
("action", "Log in and open /products/new"),
|
|
228
|
+
("action", "Fill required fields and submit"),
|
|
229
|
+
("assertion", "Returns to the products list"),
|
|
230
|
+
],
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
cases.append(
|
|
234
|
+
_case(
|
|
235
|
+
nid(),
|
|
236
|
+
"create_product_with_invalid_data_shows_validation_error",
|
|
237
|
+
"Submitting the product form with invalid data keeps the user on the form.",
|
|
238
|
+
"Products",
|
|
239
|
+
Priority.MEDIUM,
|
|
240
|
+
"fe:create_invalid /products/new",
|
|
241
|
+
[
|
|
242
|
+
("action", "Log in and open /products/new"),
|
|
243
|
+
("action", "Fill a too-short name and no SKU, then submit"),
|
|
244
|
+
("assertion", "Form stays visible with validation errors; no navigation"),
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return cases
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
__all__ = ["generate_frontend_plan"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Build a normalised ``standard_prd`` from static analysis (ZERO tier).
|
|
2
|
+
|
|
3
|
+
Deterministic: groups endpoints/pages into features and renders human-readable
|
|
4
|
+
user-flows. An LLM enrichment pass (CLOUD/LOCAL tier) can later rewrite the prose
|
|
5
|
+
via ``packages/agent``; this baseline never requires a model.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from suitest_lifecycle.models import CodeSummary, Endpoint, Mode, Prd, PrdFeature
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _title(slug: str) -> str:
|
|
14
|
+
return slug.replace("-", " ").replace("_", " ").strip().title() or "Root"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _backend_features(summary: CodeSummary) -> list[PrdFeature]:
|
|
18
|
+
by_group: dict[str, list[Endpoint]] = {}
|
|
19
|
+
for ep in summary.endpoints:
|
|
20
|
+
parts = [
|
|
21
|
+
p for p in ep.path.strip("/").split("/") if p and not p.startswith(":") and p != "api"
|
|
22
|
+
]
|
|
23
|
+
key = parts[0] if parts else "root"
|
|
24
|
+
by_group.setdefault(key, []).append(ep)
|
|
25
|
+
|
|
26
|
+
features: list[PrdFeature] = []
|
|
27
|
+
for group, eps in sorted(by_group.items()):
|
|
28
|
+
flows: list[str] = []
|
|
29
|
+
for ep in eps:
|
|
30
|
+
auth = " (auth required)" if ep.auth_required else " (public)"
|
|
31
|
+
flows.append(f"{ep.method} {ep.path}{auth} -> expected 2xx for valid input")
|
|
32
|
+
features.append(
|
|
33
|
+
PrdFeature(
|
|
34
|
+
name=_title(group),
|
|
35
|
+
description=f"{group} API surface ({len(eps)} endpoint(s)).",
|
|
36
|
+
user_flows=flows,
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return features
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _frontend_features(summary: CodeSummary) -> list[PrdFeature]:
|
|
43
|
+
features: list[PrdFeature] = []
|
|
44
|
+
for page in summary.pages:
|
|
45
|
+
guard = " (protected)" if page.protected else " (public)"
|
|
46
|
+
features.append(
|
|
47
|
+
PrdFeature(
|
|
48
|
+
name=page.name,
|
|
49
|
+
description=f"Page at route {page.route}{guard}.",
|
|
50
|
+
user_flows=[f"Navigate to {page.route} -> page renders without error"],
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return features
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_prd(summary: CodeSummary, date: str, project: str) -> Prd:
|
|
57
|
+
if summary.mode is Mode.BACKEND:
|
|
58
|
+
overview = (
|
|
59
|
+
f"{project} is a {', '.join(summary.tech_stack)} backend exposing "
|
|
60
|
+
f"{len(summary.endpoints)} HTTP endpoint(s)."
|
|
61
|
+
)
|
|
62
|
+
features = _backend_features(summary)
|
|
63
|
+
goals = [
|
|
64
|
+
"Every endpoint returns the documented status for valid and invalid input.",
|
|
65
|
+
"Protected endpoints reject unauthenticated requests with 401.",
|
|
66
|
+
"Authenticated CRUD flows create, read, update and delete correctly.",
|
|
67
|
+
]
|
|
68
|
+
else:
|
|
69
|
+
overview = (
|
|
70
|
+
f"{project} is a {', '.join(summary.tech_stack)} web app with "
|
|
71
|
+
f"{len(summary.pages)} page route(s)."
|
|
72
|
+
)
|
|
73
|
+
features = _frontend_features(summary)
|
|
74
|
+
goals = [
|
|
75
|
+
"Users can log in and reach protected pages.",
|
|
76
|
+
"Protected routes redirect unauthenticated visitors to login.",
|
|
77
|
+
"Core CRUD UI flows (create/edit/delete) work end to end.",
|
|
78
|
+
]
|
|
79
|
+
if summary.auth_flow:
|
|
80
|
+
goals.insert(0, summary.auth_flow)
|
|
81
|
+
|
|
82
|
+
return Prd(
|
|
83
|
+
project=project,
|
|
84
|
+
date=date,
|
|
85
|
+
prepared_by="Generated by Suitest",
|
|
86
|
+
product_overview=overview,
|
|
87
|
+
core_goals=goals,
|
|
88
|
+
features=features,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
__all__ = ["build_prd"]
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Target server process manager.
|
|
2
|
+
|
|
3
|
+
Spawns the app-under-test (``server.startCommand``) in its own process group so
|
|
4
|
+
the *whole* tree (npm → tsx → node) can be torn down, streams its output to an
|
|
5
|
+
in-memory ring buffer for ready-log detection + failure diagnostics, and stops
|
|
6
|
+
it gracefully (SIGTERM → SIGKILL). POSIX-focused (the dev target is darwin/linux).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import signal
|
|
15
|
+
import subprocess
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from collections import deque
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ManagedProcess:
|
|
28
|
+
popen: subprocess.Popen[bytes]
|
|
29
|
+
_buffer: deque[str]
|
|
30
|
+
_lock: threading.Lock
|
|
31
|
+
|
|
32
|
+
def log_text(self) -> str:
|
|
33
|
+
with self._lock:
|
|
34
|
+
return "".join(self._buffer)
|
|
35
|
+
|
|
36
|
+
def tail(self, lines: int = 40) -> str:
|
|
37
|
+
text = self.log_text()
|
|
38
|
+
return "\n".join(text.splitlines()[-lines:])
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def alive(self) -> bool:
|
|
42
|
+
return self.popen.poll() is None
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def returncode(self) -> int | None:
|
|
46
|
+
return self.popen.poll()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class ProcessManager:
|
|
50
|
+
"""Start/stop a single target server."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, *, buffer_chars: int = 200_000) -> None:
|
|
53
|
+
self._proc: ManagedProcess | None = None
|
|
54
|
+
self._buffer_chars = buffer_chars
|
|
55
|
+
self._reader: threading.Thread | None = None
|
|
56
|
+
|
|
57
|
+
def start(self, command: str, cwd: Path, env: dict[str, str]) -> ManagedProcess:
|
|
58
|
+
full_env = {**os.environ, **env}
|
|
59
|
+
buffer: deque[str] = deque()
|
|
60
|
+
lock = threading.Lock()
|
|
61
|
+
char_count = {"n": 0}
|
|
62
|
+
|
|
63
|
+
popen = subprocess.Popen(
|
|
64
|
+
shlex.split(command),
|
|
65
|
+
cwd=str(cwd),
|
|
66
|
+
env=full_env,
|
|
67
|
+
stdout=subprocess.PIPE,
|
|
68
|
+
stderr=subprocess.STDOUT,
|
|
69
|
+
start_new_session=True, # own process group for clean teardown
|
|
70
|
+
)
|
|
71
|
+
managed = ManagedProcess(popen=popen, _buffer=buffer, _lock=lock)
|
|
72
|
+
|
|
73
|
+
def _drain() -> None:
|
|
74
|
+
stream = popen.stdout
|
|
75
|
+
if stream is None:
|
|
76
|
+
return
|
|
77
|
+
for raw in iter(stream.readline, b""):
|
|
78
|
+
chunk = raw.decode("utf-8", errors="replace")
|
|
79
|
+
with lock:
|
|
80
|
+
buffer.append(chunk)
|
|
81
|
+
char_count["n"] += len(chunk)
|
|
82
|
+
while char_count["n"] > self._buffer_chars and buffer:
|
|
83
|
+
char_count["n"] -= len(buffer.popleft())
|
|
84
|
+
|
|
85
|
+
reader = threading.Thread(target=_drain, daemon=True)
|
|
86
|
+
reader.start()
|
|
87
|
+
self._proc = managed
|
|
88
|
+
self._reader = reader
|
|
89
|
+
return managed
|
|
90
|
+
|
|
91
|
+
def stop(self, grace_sec: int = 5) -> None:
|
|
92
|
+
if self._proc is None:
|
|
93
|
+
return
|
|
94
|
+
popen = self._proc.popen
|
|
95
|
+
if popen.poll() is None:
|
|
96
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
97
|
+
os.killpg(os.getpgid(popen.pid), signal.SIGTERM)
|
|
98
|
+
deadline = time.monotonic() + grace_sec
|
|
99
|
+
while time.monotonic() < deadline and popen.poll() is None:
|
|
100
|
+
time.sleep(0.1)
|
|
101
|
+
if popen.poll() is None:
|
|
102
|
+
with contextlib.suppress(ProcessLookupError, PermissionError):
|
|
103
|
+
os.killpg(os.getpgid(popen.pid), signal.SIGKILL)
|
|
104
|
+
self._proc = None
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def current(self) -> ManagedProcess | None:
|
|
108
|
+
return self._proc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
__all__ = ["ManagedProcess", "ProcessManager"]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Publish lifecycle results into a running Suitest (Approach A — REST ingest).
|
|
2
|
+
|
|
3
|
+
Builds the bulk-import (cases + steps + source code) and run-ingest (completed
|
|
4
|
+
run + per-step outcomes + video/screenshot artifacts) payloads, then sends them
|
|
5
|
+
via the Suitest SDK. The SDK is imported lazily so the lifecycle core stays
|
|
6
|
+
stdlib-only; if it (or the server) is unavailable, publishing degrades to a
|
|
7
|
+
clean ``{"published": False, "reason": ...}`` instead of failing the run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from typing import TYPE_CHECKING, Protocol
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from suitest_lifecycle.config import Config
|
|
17
|
+
from suitest_lifecycle.models import PlanCase, RunSummary
|
|
18
|
+
from suitest_lifecycle.paths import Paths
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Uploader(Protocol):
|
|
22
|
+
"""Minimal upload surface the publisher needs (satisfied by SuitestClient).
|
|
23
|
+
|
|
24
|
+
Artifacts go THROUGH the API — the server holds the S3 credentials, so the
|
|
25
|
+
lifecycle/MCP client needs no ``SUITEST_S3_*`` env of its own.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def upload_file(self, path: str, *, content_type: str | None = None) -> str: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PRIORITY = {"High": "P1", "Medium": "P2", "Low": "P3"}
|
|
32
|
+
_MIME = {".webm": "video/webm", ".png": "image/png", ".jpg": "image/jpeg"}
|
|
33
|
+
|
|
34
|
+
# PlanCase.title is the generated test function slug (codegen emits
|
|
35
|
+
# ``test_<title>``), so the publish layer is where the human display title is
|
|
36
|
+
# minted: ``slug`` carries the technical key, ``title`` the readable sentence.
|
|
37
|
+
# Mirrors suitest_shared.text.humanize_slug (lifecycle stays stdlib-only).
|
|
38
|
+
_ACRONYMS = frozenset({"api", "url", "id", "ui", "ux", "http", "sql", "ok", "sso", "mcp"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _humanize(slug: str) -> str:
|
|
42
|
+
words = [w for w in slug.replace("_", " ").replace("-", " ").split() if w]
|
|
43
|
+
if not words:
|
|
44
|
+
return slug.strip()
|
|
45
|
+
out: list[str] = []
|
|
46
|
+
for i, word in enumerate(words):
|
|
47
|
+
lower = word.lower()
|
|
48
|
+
if lower in _ACRONYMS:
|
|
49
|
+
out.append(lower.upper())
|
|
50
|
+
elif i == 0:
|
|
51
|
+
out.append(word[:1].upper() + word[1:].lower())
|
|
52
|
+
else:
|
|
53
|
+
out.append(lower)
|
|
54
|
+
return " ".join(out)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _suite_name(config: Config) -> str:
|
|
58
|
+
return config.publish.suite_name or f"{config.project_name} {config.mode.value}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _case_payloads(cases: list[PlanCase], paths: Paths) -> list[dict[str, object]]:
|
|
62
|
+
out: list[dict[str, object]] = []
|
|
63
|
+
for c in cases:
|
|
64
|
+
code = ""
|
|
65
|
+
if c.automation_file:
|
|
66
|
+
fp = paths.test_file(c.automation_file)
|
|
67
|
+
if fp.is_file():
|
|
68
|
+
code = fp.read_text(encoding="utf-8")
|
|
69
|
+
out.append(
|
|
70
|
+
{
|
|
71
|
+
"sourceRef": c.source_ref,
|
|
72
|
+
# ``name`` stays the slug — it is the server's idempotency match
|
|
73
|
+
# key for rows published before the title/slug split.
|
|
74
|
+
"name": c.title,
|
|
75
|
+
"slug": c.title,
|
|
76
|
+
"title": _humanize(c.title),
|
|
77
|
+
"description": c.description,
|
|
78
|
+
# Lifecycle cases are produced by the MCP-native plan/run loop, so
|
|
79
|
+
# they surface under the MCP filter (not the generic IMPORT bucket).
|
|
80
|
+
"source": "MCP",
|
|
81
|
+
"priority": _PRIORITY.get(c.priority.value, "P2"),
|
|
82
|
+
"category": c.category,
|
|
83
|
+
"tags": list(c.tags),
|
|
84
|
+
"automationFilePath": c.automation_file,
|
|
85
|
+
"automationCode": code,
|
|
86
|
+
"generatedBy": "suitest-lifecycle",
|
|
87
|
+
"steps": [
|
|
88
|
+
{
|
|
89
|
+
"order": i + 1,
|
|
90
|
+
"action": s.description,
|
|
91
|
+
# For assertions the description *is* the expectation; for
|
|
92
|
+
# actions there's no distinct expected, so leave it blank
|
|
93
|
+
# and let the reader derive one.
|
|
94
|
+
"expected": s.description if s.type == "assertion" else "",
|
|
95
|
+
"code": None,
|
|
96
|
+
}
|
|
97
|
+
for i, s in enumerate(c.steps)
|
|
98
|
+
],
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _resolve_url(client: Uploader, path: str, mime: str) -> str:
|
|
105
|
+
"""Upload the artifact THROUGH the API (server owns the S3 creds) and return
|
|
106
|
+
the durable ``s3://`` URL. On any upload hiccup fall back to a local
|
|
107
|
+
``file://`` URL so publishing never fails on an artifact."""
|
|
108
|
+
try:
|
|
109
|
+
return client.upload_file(path, content_type=mime)
|
|
110
|
+
except Exception: # never fail publish on an upload hiccup
|
|
111
|
+
return "file://" + os.path.abspath(path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _artifact(client: Uploader, path: str, kind: str) -> dict[str, object] | None:
|
|
115
|
+
if not path or not os.path.isfile(path):
|
|
116
|
+
return None
|
|
117
|
+
ext = os.path.splitext(path)[1].lower()
|
|
118
|
+
mime = _MIME.get(ext, "application/octet-stream")
|
|
119
|
+
return {
|
|
120
|
+
"kind": kind,
|
|
121
|
+
"url": _resolve_url(client, path, mime),
|
|
122
|
+
"mimeType": mime,
|
|
123
|
+
"sizeBytes": os.path.getsize(path),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _result_payloads(
|
|
128
|
+
client: Uploader, summary: RunSummary, cases: list[PlanCase]
|
|
129
|
+
) -> list[dict[str, object]]:
|
|
130
|
+
ref_by_id = {c.id: c.source_ref for c in cases}
|
|
131
|
+
name_by_id = {c.id: c.title for c in cases}
|
|
132
|
+
out: list[dict[str, object]] = []
|
|
133
|
+
for r in summary.results:
|
|
134
|
+
# Case level carries only the VIDEO now; screenshots are per-step (each
|
|
135
|
+
# run_step gets its own SCREENSHOT), so the "final" one would be redundant.
|
|
136
|
+
artifacts = [a for a in (_artifact(client, r.video_path, "VIDEO"),) if a is not None]
|
|
137
|
+
slug = name_by_id.get(r.test_id, r.title)
|
|
138
|
+
out.append(
|
|
139
|
+
{
|
|
140
|
+
"name": slug,
|
|
141
|
+
"slug": slug,
|
|
142
|
+
"sourceRef": ref_by_id.get(r.test_id, r.test_id),
|
|
143
|
+
"outcome": r.status.value,
|
|
144
|
+
"durationMs": r.duration_ms,
|
|
145
|
+
"error": r.error,
|
|
146
|
+
"steps": [
|
|
147
|
+
{
|
|
148
|
+
"order": s.index,
|
|
149
|
+
"type": s.type,
|
|
150
|
+
"description": s.description,
|
|
151
|
+
"outcome": s.status.value,
|
|
152
|
+
# Per-step screenshot, uploaded so the web can sign + show it.
|
|
153
|
+
"screenshot": (
|
|
154
|
+
_resolve_url(client, s.screenshot_path, "image/png")
|
|
155
|
+
if s.screenshot_path and os.path.isfile(s.screenshot_path)
|
|
156
|
+
else ""
|
|
157
|
+
),
|
|
158
|
+
}
|
|
159
|
+
for s in r.steps
|
|
160
|
+
],
|
|
161
|
+
"artifacts": artifacts,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def publish_results(
|
|
168
|
+
config: Config, summary: RunSummary, cases: list[PlanCase], paths: Paths
|
|
169
|
+
) -> dict[str, object]:
|
|
170
|
+
if not config.publish.enabled:
|
|
171
|
+
return {"published": False, "reason": "publish disabled"}
|
|
172
|
+
if not config.publish.project_id:
|
|
173
|
+
return {"published": False, "reason": "publish.projectId not set"}
|
|
174
|
+
try:
|
|
175
|
+
from suitest_sdk import SuitestAPIError, SuitestClient
|
|
176
|
+
except ImportError:
|
|
177
|
+
return {"published": False, "reason": "suiflex-suitest-sdk not installed"}
|
|
178
|
+
|
|
179
|
+
suite = _suite_name(config)
|
|
180
|
+
# Secrets (the API key) and the endpoint can come from the environment so
|
|
181
|
+
# they stay out of a committed suitest.config.json — the MCP client injects
|
|
182
|
+
# SUITEST_API_KEY / SUITEST_API_URL. Config values win when both are set.
|
|
183
|
+
api_url = config.publish.api_url or os.environ.get("SUITEST_API_URL", "")
|
|
184
|
+
token = config.publish.token or os.environ.get("SUITEST_API_KEY") or None
|
|
185
|
+
client = SuitestClient(
|
|
186
|
+
api_url,
|
|
187
|
+
token=token,
|
|
188
|
+
workspace_id=config.publish.workspace_id or None,
|
|
189
|
+
# Video artifacts upload THROUGH the API to remote object storage — the
|
|
190
|
+
# default 30s regularly times out on multi-MB webm files.
|
|
191
|
+
timeout=180.0,
|
|
192
|
+
)
|
|
193
|
+
try:
|
|
194
|
+
with client:
|
|
195
|
+
imported = client.bulk_import_cases(
|
|
196
|
+
project_id=config.publish.project_id,
|
|
197
|
+
suite_name=suite,
|
|
198
|
+
mode=config.mode.value,
|
|
199
|
+
cases=_case_payloads(cases, paths),
|
|
200
|
+
)
|
|
201
|
+
run = client.ingest_run(
|
|
202
|
+
project_id=config.publish.project_id,
|
|
203
|
+
suite_name=suite,
|
|
204
|
+
name=f"{config.project_name} lifecycle",
|
|
205
|
+
results=_result_payloads(client, summary, cases),
|
|
206
|
+
)
|
|
207
|
+
except SuitestAPIError as exc:
|
|
208
|
+
return {"published": False, "reason": f"api error: {exc}"}
|
|
209
|
+
except Exception as exc: # publish must never fail the run (network/SDK errors)
|
|
210
|
+
return {"published": False, "reason": f"connection error: {type(exc).__name__}: {exc}"}
|
|
211
|
+
return {
|
|
212
|
+
"published": True,
|
|
213
|
+
"runId": run.get("runId") if isinstance(run, dict) else None,
|
|
214
|
+
"imported": len(imported.get("imported", [])) if isinstance(imported, dict) else 0,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
__all__ = ["publish_results"]
|