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