@suiflex/suitest-mcp 0.1.1 → 0.1.3
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/package.json +1 -1
- package/python/suitest_lifecycle/blackbox/mcp.py +1 -4
- package/python/suitest_lifecycle/http_client.py +207 -0
- package/python/suitest_lifecycle/llm_bridge.py +2 -5
- package/python/suitest_lifecycle/mcp_server.py +36 -1
- package/python/suitest_lifecycle/models.py +3 -0
- package/python/suitest_lifecycle/publish.py +9 -7
- package/python/suitest_lifecycle/retest.py +4 -10
- package/python/suitest_lifecycle/runner.py +23 -5
package/package.json
CHANGED
|
@@ -414,10 +414,7 @@ def blackbox_publish_results(**kwargs: Any) -> dict[str, Any]:
|
|
|
414
414
|
token = _os.environ.get("SUITEST_API_KEY", "")
|
|
415
415
|
if not api_url or not token:
|
|
416
416
|
return _envelope(False, "SUITEST_API_URL / SUITEST_API_KEY not set")
|
|
417
|
-
|
|
418
|
-
from suitest_sdk import SuitestAPIError, SuitestClient
|
|
419
|
-
except ImportError:
|
|
420
|
-
return _envelope(False, "suiflex-suitest-sdk not installed")
|
|
417
|
+
from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
|
|
421
418
|
|
|
422
419
|
plan_path = paths.test_plan_json
|
|
423
420
|
if not plan_path.is_file():
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Stdlib-only Suitest API client for the bundled lifecycle.
|
|
2
|
+
|
|
3
|
+
Mirrors the slice of the ``suiflex-suitest-sdk`` surface the lifecycle needs
|
|
4
|
+
(binding resolve, bulk import, run ingest, artifact upload, LLM proxy) using
|
|
5
|
+
only ``urllib`` — so ``npx @suiflex/suitest-mcp`` publishes out of the box,
|
|
6
|
+
with zero ``pip install`` on the host. The pip SDK remains the richer client
|
|
7
|
+
for external integrations; this one exists so a brand-new QA user's first run
|
|
8
|
+
lands in the web UI instead of silently skipping publish.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import mimetypes
|
|
15
|
+
import os
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
JSONDict = dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SuitestAPIError(RuntimeError):
|
|
26
|
+
"""Raised on a non-2xx API response. Carries status + parsed body."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, status_code: int, body: object) -> None:
|
|
29
|
+
super().__init__(f"Suitest API error {status_code}: {body}")
|
|
30
|
+
self.status_code = status_code
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SuitestClient:
|
|
35
|
+
"""Drop-in for the SDK client's lifecycle-facing methods (sync, stdlib)."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
base_url: str,
|
|
40
|
+
*,
|
|
41
|
+
token: str | None = None,
|
|
42
|
+
workspace_id: str | None = None,
|
|
43
|
+
timeout: float = 30.0,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._base = base_url.rstrip("/")
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._headers: dict[str, str] = {"Accept": "application/json"}
|
|
48
|
+
if token:
|
|
49
|
+
self._headers["Authorization"] = f"Bearer {token}"
|
|
50
|
+
if workspace_id:
|
|
51
|
+
self._headers["X-Workspace-Id"] = workspace_id
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> SuitestClient:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(self, *exc: object) -> None:
|
|
57
|
+
self.close()
|
|
58
|
+
|
|
59
|
+
def close(self) -> None: # urllib is connectionless — kept for SDK parity
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
# -- low level ----------------------------------------------------------
|
|
63
|
+
def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
params: dict[str, object] | None = None,
|
|
69
|
+
json_body: JSONDict | None = None,
|
|
70
|
+
data: bytes | None = None,
|
|
71
|
+
headers: dict[str, str] | None = None,
|
|
72
|
+
) -> object:
|
|
73
|
+
url = self._base + path
|
|
74
|
+
if params:
|
|
75
|
+
url += "?" + urllib.parse.urlencode({k: str(v) for k, v in params.items()})
|
|
76
|
+
hdrs = dict(self._headers)
|
|
77
|
+
body: bytes | None = data
|
|
78
|
+
if json_body is not None:
|
|
79
|
+
body = json.dumps(json_body).encode("utf-8")
|
|
80
|
+
hdrs["Content-Type"] = "application/json"
|
|
81
|
+
if headers:
|
|
82
|
+
hdrs.update(headers)
|
|
83
|
+
req = urllib.request.Request(url, data=body, method=method, headers=hdrs)
|
|
84
|
+
try:
|
|
85
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
86
|
+
raw = resp.read()
|
|
87
|
+
if resp.status == 204 or not raw:
|
|
88
|
+
return None
|
|
89
|
+
return json.loads(raw)
|
|
90
|
+
except urllib.error.HTTPError as exc:
|
|
91
|
+
raw = exc.read()
|
|
92
|
+
try:
|
|
93
|
+
parsed: object = json.loads(raw)
|
|
94
|
+
except ValueError:
|
|
95
|
+
parsed = raw.decode("utf-8", errors="replace")
|
|
96
|
+
raise SuitestAPIError(exc.code, parsed) from exc
|
|
97
|
+
|
|
98
|
+
# -- LLM proxy ------------------------------------------------------------
|
|
99
|
+
def llm_complete(
|
|
100
|
+
self,
|
|
101
|
+
prompt: str,
|
|
102
|
+
*,
|
|
103
|
+
system: str | None = None,
|
|
104
|
+
max_tokens: int = 4096,
|
|
105
|
+
temperature: float = 0.2,
|
|
106
|
+
) -> str:
|
|
107
|
+
body: JSONDict = {"prompt": prompt, "maxTokens": max_tokens, "temperature": temperature}
|
|
108
|
+
if system is not None:
|
|
109
|
+
body["system"] = system
|
|
110
|
+
result = self._request("POST", "/api/v1/llm/complete", json_body=body)
|
|
111
|
+
return str(result.get("content", "")) if isinstance(result, dict) else ""
|
|
112
|
+
|
|
113
|
+
# -- lifecycle ingest -----------------------------------------------------
|
|
114
|
+
def resolve_project(
|
|
115
|
+
self, *, project_id: str = "", project_slug: str = "", project_name: str = ""
|
|
116
|
+
) -> JSONDict:
|
|
117
|
+
result = self._request(
|
|
118
|
+
"POST",
|
|
119
|
+
"/api/v1/ingest/resolve-project",
|
|
120
|
+
json_body={
|
|
121
|
+
"projectId": project_id,
|
|
122
|
+
"projectSlug": project_slug,
|
|
123
|
+
"projectName": project_name,
|
|
124
|
+
},
|
|
125
|
+
)
|
|
126
|
+
return result if isinstance(result, dict) else {}
|
|
127
|
+
|
|
128
|
+
def bulk_import_cases(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
project_id: str = "",
|
|
132
|
+
suite_name: str,
|
|
133
|
+
mode: str,
|
|
134
|
+
cases: list[JSONDict],
|
|
135
|
+
project_slug: str = "",
|
|
136
|
+
project_name: str = "",
|
|
137
|
+
mark_stale: bool = False,
|
|
138
|
+
) -> JSONDict:
|
|
139
|
+
result = self._request(
|
|
140
|
+
"POST",
|
|
141
|
+
"/api/v1/test-cases/bulk-import",
|
|
142
|
+
json_body={
|
|
143
|
+
"projectId": project_id,
|
|
144
|
+
"projectSlug": project_slug,
|
|
145
|
+
"projectName": project_name,
|
|
146
|
+
"suiteName": suite_name,
|
|
147
|
+
"mode": mode,
|
|
148
|
+
"cases": cases,
|
|
149
|
+
"markStale": mark_stale,
|
|
150
|
+
},
|
|
151
|
+
)
|
|
152
|
+
return result if isinstance(result, dict) else {}
|
|
153
|
+
|
|
154
|
+
def ingest_run(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
project_id: str = "",
|
|
158
|
+
suite_name: str,
|
|
159
|
+
name: str,
|
|
160
|
+
results: list[JSONDict],
|
|
161
|
+
env: str = "staging",
|
|
162
|
+
branch: str | None = None,
|
|
163
|
+
commit_sha: str | None = None,
|
|
164
|
+
project_slug: str = "",
|
|
165
|
+
project_name: str = "",
|
|
166
|
+
) -> JSONDict:
|
|
167
|
+
body: JSONDict = {
|
|
168
|
+
"projectId": project_id,
|
|
169
|
+
"projectSlug": project_slug,
|
|
170
|
+
"projectName": project_name,
|
|
171
|
+
"suiteName": suite_name,
|
|
172
|
+
"name": name,
|
|
173
|
+
"env": env,
|
|
174
|
+
"results": results,
|
|
175
|
+
}
|
|
176
|
+
if branch is not None:
|
|
177
|
+
body["branch"] = branch
|
|
178
|
+
if commit_sha is not None:
|
|
179
|
+
body["commitSha"] = commit_sha
|
|
180
|
+
result = self._request("POST", "/api/v1/runs/ingest", json_body=body)
|
|
181
|
+
return result if isinstance(result, dict) else {}
|
|
182
|
+
|
|
183
|
+
def upload_file(self, path: str, *, content_type: str | None = None) -> str:
|
|
184
|
+
"""Multipart upload to /api/v1/files; returns the durable s3:// URL.
|
|
185
|
+
|
|
186
|
+
The whole file is buffered in memory — run videos are a few MB, fine.
|
|
187
|
+
"""
|
|
188
|
+
ct = content_type or mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
189
|
+
boundary = uuid.uuid4().hex
|
|
190
|
+
name = os.path.basename(path)
|
|
191
|
+
with open(path, "rb") as fh:
|
|
192
|
+
payload = fh.read()
|
|
193
|
+
head = (
|
|
194
|
+
f"--{boundary}\r\n"
|
|
195
|
+
f'Content-Disposition: form-data; name="file"; filename="{name}"\r\n'
|
|
196
|
+
f"Content-Type: {ct}\r\n\r\n"
|
|
197
|
+
).encode()
|
|
198
|
+
tail = f"\r\n--{boundary}--\r\n".encode()
|
|
199
|
+
result = self._request(
|
|
200
|
+
"POST",
|
|
201
|
+
"/api/v1/files",
|
|
202
|
+
data=head + payload + tail,
|
|
203
|
+
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
|
|
204
|
+
)
|
|
205
|
+
if not isinstance(result, dict):
|
|
206
|
+
raise SuitestAPIError(0, "unexpected upload response")
|
|
207
|
+
return str(result["url"])
|
|
@@ -124,11 +124,8 @@ class RemoteLlmClient:
|
|
|
124
124
|
def _complete(self, prompt: str, *, system: str | None = None, max_tokens: int = 4096) -> str:
|
|
125
125
|
if self._disabled:
|
|
126
126
|
return ""
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
except ImportError:
|
|
130
|
-
self._disabled = True
|
|
131
|
-
return ""
|
|
127
|
+
from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
|
|
128
|
+
|
|
132
129
|
try:
|
|
133
130
|
with SuitestClient(
|
|
134
131
|
self._api_url,
|
|
@@ -12,7 +12,10 @@ Every tool takes a single ``config_path`` argument and returns the structured
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import json
|
|
15
|
+
import os
|
|
15
16
|
import sys
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
16
19
|
from typing import TYPE_CHECKING, TextIO
|
|
17
20
|
|
|
18
21
|
from suitest_lifecycle.tools import KWARG_TOOLS, TOOLS
|
|
@@ -147,7 +150,7 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
147
150
|
{
|
|
148
151
|
"protocolVersion": PROTOCOL_VERSION,
|
|
149
152
|
"capabilities": {"tools": {}},
|
|
150
|
-
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.
|
|
153
|
+
"serverInfo": {"name": "suitest-lifecycle", "version": "0.1.2"},
|
|
151
154
|
},
|
|
152
155
|
)
|
|
153
156
|
if method in ("notifications/initialized", "initialized"):
|
|
@@ -194,7 +197,39 @@ def handle(message: dict[str, object]) -> dict[str, object] | None:
|
|
|
194
197
|
return None
|
|
195
198
|
|
|
196
199
|
|
|
200
|
+
def verify_credentials() -> str | None:
|
|
201
|
+
"""Check SUITEST_API_URL + SUITEST_API_KEY; return an error string if unusable.
|
|
202
|
+
|
|
203
|
+
Both must be set, and the key must authenticate against the URL
|
|
204
|
+
(``GET /api/v1/api-keys/whoami`` — the key pins the workspace/project every
|
|
205
|
+
tool publishes into). Any failure must abort the connection: a server that
|
|
206
|
+
accepts empty or mismatched credentials silently drops all publishes.
|
|
207
|
+
"""
|
|
208
|
+
api_url = os.environ.get("SUITEST_API_URL", "").strip().rstrip("/")
|
|
209
|
+
api_key = os.environ.get("SUITEST_API_KEY", "").strip()
|
|
210
|
+
if not api_url or not api_key:
|
|
211
|
+
return (
|
|
212
|
+
"SUITEST_API_URL and SUITEST_API_KEY are both required "
|
|
213
|
+
"(set them in the mcpServers env block); refusing to start"
|
|
214
|
+
)
|
|
215
|
+
req = urllib.request.Request(
|
|
216
|
+
f"{api_url}/api/v1/api-keys/whoami",
|
|
217
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
with urllib.request.urlopen(req, timeout=10):
|
|
221
|
+
return None
|
|
222
|
+
except urllib.error.HTTPError as exc:
|
|
223
|
+
return f"SUITEST_API_KEY rejected by {api_url} (HTTP {exc.code}); refusing to start"
|
|
224
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
225
|
+
return f"SUITEST_API_URL {api_url} unreachable ({exc}); refusing to start"
|
|
226
|
+
|
|
227
|
+
|
|
197
228
|
def serve(stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout) -> None:
|
|
229
|
+
error = verify_credentials()
|
|
230
|
+
if error is not None:
|
|
231
|
+
sys.stderr.write(f"suitest-mcp: {error}\n")
|
|
232
|
+
raise SystemExit(1)
|
|
198
233
|
for line in stdin:
|
|
199
234
|
line = line.strip()
|
|
200
235
|
if not line:
|
|
@@ -142,6 +142,9 @@ class TestResult:
|
|
|
142
142
|
error: str = ""
|
|
143
143
|
automation_file: str = ""
|
|
144
144
|
artifacts: list[str] = field(default_factory=list)
|
|
145
|
+
# Captured subprocess output (tail-bounded) — published as run log lines.
|
|
146
|
+
stdout: str = ""
|
|
147
|
+
stderr: str = ""
|
|
145
148
|
# Phase 2 — rich recording (collected from each test's sidecar JSON).
|
|
146
149
|
steps: list[StepResult] = field(default_factory=list)
|
|
147
150
|
video_path: str = ""
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Builds the bulk-import (cases + steps + source code) and run-ingest (completed
|
|
4
4
|
run + per-step outcomes + video/screenshot artifacts) payloads, then sends them
|
|
5
|
-
via the
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
via the bundled stdlib client (:mod:`suitest_lifecycle.http_client`) — no pip
|
|
6
|
+
install needed on the host, so ``npx @suiflex/suitest-mcp`` publishes out of
|
|
7
|
+
the box. If the server is unavailable, publishing degrades to a clean
|
|
8
|
+
``{"published": False, "reason": ...}`` instead of failing the run.
|
|
8
9
|
"""
|
|
9
10
|
|
|
10
11
|
from __future__ import annotations
|
|
@@ -149,6 +150,8 @@ def _result_payloads(
|
|
|
149
150
|
"durationMs": r.duration_ms,
|
|
150
151
|
"error": r.error,
|
|
151
152
|
"failureKind": kinds.get(r.test_id, ""),
|
|
153
|
+
"stdout": r.stdout,
|
|
154
|
+
"stderr": r.stderr,
|
|
152
155
|
"steps": [
|
|
153
156
|
{
|
|
154
157
|
"order": s.index,
|
|
@@ -187,10 +190,9 @@ def publish_results(
|
|
|
187
190
|
# No binding info (legacy caller) + no id → refuse rather than guess.
|
|
188
191
|
if binding is None and not config.publish.project_id:
|
|
189
192
|
return {"published": False, "reason": "publish.projectId not set"}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return {"published": False, "reason": "suiflex-suitest-sdk not installed"}
|
|
193
|
+
# Bundled stdlib client — publish works out of the box under
|
|
194
|
+
# `npx @suiflex/suitest-mcp`, no pip install required on the host.
|
|
195
|
+
from suitest_lifecycle.http_client import SuitestAPIError, SuitestClient
|
|
194
196
|
|
|
195
197
|
# first_setup / explicit recreate publish by slug (server find-or-creates);
|
|
196
198
|
# valid / repaired / unverified publish by explicit id (server 404s stale ids
|
|
@@ -95,16 +95,14 @@ def rewrite_project_id(config_path: Path, project_id: str) -> bool:
|
|
|
95
95
|
return False
|
|
96
96
|
|
|
97
97
|
|
|
98
|
-
def _make_client(config: Config) -> BindingClient
|
|
98
|
+
def _make_client(config: Config) -> BindingClient:
|
|
99
99
|
import os
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
except ImportError:
|
|
104
|
-
return None
|
|
101
|
+
from suitest_lifecycle.http_client import SuitestClient
|
|
102
|
+
|
|
105
103
|
api_url = config.publish.api_url or os.environ.get("SUITEST_API_URL", "")
|
|
106
104
|
token = config.publish.token or os.environ.get("SUITEST_API_KEY") or None
|
|
107
|
-
return SuitestClient(
|
|
105
|
+
return SuitestClient(
|
|
108
106
|
api_url, token=token, workspace_id=config.publish.workspace_id or None, timeout=30.0
|
|
109
107
|
)
|
|
110
108
|
|
|
@@ -117,10 +115,6 @@ def resolve_binding(
|
|
|
117
115
|
return BindingResult("local_only", "publish_disabled")
|
|
118
116
|
if client is None:
|
|
119
117
|
client = _make_client(config)
|
|
120
|
-
if client is None:
|
|
121
|
-
return BindingResult(
|
|
122
|
-
"local_only", "sdk_not_installed", detail="suiflex-suitest-sdk not installed"
|
|
123
|
-
)
|
|
124
118
|
|
|
125
119
|
slug = project_slug(config.project_name)
|
|
126
120
|
if not config.publish.project_id:
|
|
@@ -62,7 +62,15 @@ def _as_outcome(value: str) -> TestOutcome:
|
|
|
62
62
|
return TestOutcome.PASSED
|
|
63
63
|
|
|
64
64
|
|
|
65
|
-
def
|
|
65
|
+
def _tail(text: str | None, lines: int = 500) -> str:
|
|
66
|
+
# ponytail: 500-line tail bound keeps the ingest payload sane for chatty tests.
|
|
67
|
+
return "\n".join((text or "").strip().splitlines()[-lines:])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_one(
|
|
71
|
+
file_path: Path, python: str, timeout_sec: int
|
|
72
|
+
) -> tuple[TestOutcome, int, str, str, str]:
|
|
73
|
+
"""Returns (outcome, duration_ms, error, stdout_tail, stderr_tail)."""
|
|
66
74
|
start = time.monotonic()
|
|
67
75
|
try:
|
|
68
76
|
proc = subprocess.run(
|
|
@@ -72,19 +80,25 @@ def _run_one(file_path: Path, python: str, timeout_sec: int) -> tuple[TestOutcom
|
|
|
72
80
|
timeout=timeout_sec,
|
|
73
81
|
cwd=str(file_path.parent),
|
|
74
82
|
)
|
|
75
|
-
except subprocess.TimeoutExpired:
|
|
83
|
+
except subprocess.TimeoutExpired as exc:
|
|
84
|
+
out = exc.stdout.decode() if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
|
85
|
+
err = exc.stderr.decode() if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
|
76
86
|
return (
|
|
77
87
|
TestOutcome.ERROR,
|
|
78
88
|
int((time.monotonic() - start) * 1000),
|
|
79
89
|
f"timeout after {timeout_sec}s",
|
|
90
|
+
_tail(out),
|
|
91
|
+
_tail(err),
|
|
80
92
|
)
|
|
81
93
|
duration_ms = int((time.monotonic() - start) * 1000)
|
|
94
|
+
out_tail = _tail(proc.stdout)
|
|
95
|
+
err_tail_full = _tail(proc.stderr)
|
|
82
96
|
if proc.returncode == 0:
|
|
83
|
-
return TestOutcome.PASSED, duration_ms, ""
|
|
97
|
+
return TestOutcome.PASSED, duration_ms, "", out_tail, err_tail_full
|
|
84
98
|
err = (proc.stderr or "").strip() or (proc.stdout or "").strip() or f"exit {proc.returncode}"
|
|
85
99
|
# Keep the last ~30 lines — enough to see the assertion without flooding the report.
|
|
86
100
|
err_tail = "\n".join(err.splitlines()[-30:])
|
|
87
|
-
return TestOutcome.FAILED, duration_ms, err_tail
|
|
101
|
+
return TestOutcome.FAILED, duration_ms, err_tail, out_tail, err_tail_full
|
|
88
102
|
|
|
89
103
|
|
|
90
104
|
def run_tests(
|
|
@@ -114,7 +128,9 @@ def run_tests(
|
|
|
114
128
|
)
|
|
115
129
|
continue
|
|
116
130
|
file_path = test_dir / case.automation_file
|
|
117
|
-
outcome, duration_ms, error = _run_one(
|
|
131
|
+
outcome, duration_ms, error, out_tail, err_tail = _run_one(
|
|
132
|
+
file_path, interpreter, timeout_sec
|
|
133
|
+
)
|
|
118
134
|
steps, video, screenshot = _collect_steps(case, test_dir, outcome)
|
|
119
135
|
artifacts = [p for p in (video, screenshot) if p]
|
|
120
136
|
results.append(
|
|
@@ -126,6 +142,8 @@ def run_tests(
|
|
|
126
142
|
duration_ms=duration_ms,
|
|
127
143
|
error=error,
|
|
128
144
|
automation_file=case.automation_file,
|
|
145
|
+
stdout=out_tail,
|
|
146
|
+
stderr=err_tail,
|
|
129
147
|
steps=steps,
|
|
130
148
|
video_path=video,
|
|
131
149
|
screenshot_path=screenshot,
|