testy-pytest-adapter 1.0.0__py3-none-any.whl
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.
- testy.py +3 -0
- testy_pytest_adapter/__init__.py +9 -0
- testy_pytest_adapter/allure_meta.py +49 -0
- testy_pytest_adapter/attachments.py +19 -0
- testy_pytest_adapter/client.py +558 -0
- testy_pytest_adapter/config.py +152 -0
- testy_pytest_adapter/matcher.py +14 -0
- testy_pytest_adapter/models.py +32 -0
- testy_pytest_adapter/plugin.py +289 -0
- testy_pytest_adapter/step_capture.py +77 -0
- testy_pytest_adapter-1.0.0.dist-info/METADATA +321 -0
- testy_pytest_adapter-1.0.0.dist-info/RECORD +16 -0
- testy_pytest_adapter-1.0.0.dist-info/WHEEL +5 -0
- testy_pytest_adapter-1.0.0.dist-info/entry_points.txt +2 -0
- testy_pytest_adapter-1.0.0.dist-info/licenses/LICENSE +21 -0
- testy_pytest_adapter-1.0.0.dist-info/top_level.txt +2 -0
testy.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
_SUITE_ORDER = {"parentSuite": 0, "suite": 1, "subSuite": 2}
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def suite_path(item) -> list[str]:
|
|
7
|
+
found: dict[int, str] = {}
|
|
8
|
+
for marker in item.iter_markers(name="allure_label"):
|
|
9
|
+
label_type = marker.kwargs.get("label_type")
|
|
10
|
+
if label_type in _SUITE_ORDER and marker.args:
|
|
11
|
+
found[_SUITE_ORDER[label_type]] = str(marker.args[0]).strip()
|
|
12
|
+
return [found[key] for key in sorted(found) if found[key]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def title(item) -> str | None:
|
|
16
|
+
name = getattr(getattr(item, "function", None), "__allure_display_name__", None)
|
|
17
|
+
return str(name) if name else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def root_attribute_path(item) -> str:
|
|
21
|
+
parts = _nodeid_dir_parts(item.nodeid)
|
|
22
|
+
return parts[0] if parts else ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def suite_attribute_paths(item, suites: list[str]) -> list[str]:
|
|
26
|
+
if not suites:
|
|
27
|
+
return []
|
|
28
|
+
parts = _nodeid_dir_parts(item.nodeid)
|
|
29
|
+
if not parts:
|
|
30
|
+
return ["" for _ in suites]
|
|
31
|
+
|
|
32
|
+
paths: list[str] = []
|
|
33
|
+
for index, _ in enumerate(suites):
|
|
34
|
+
if len(suites) == 1 or index == len(suites) - 1:
|
|
35
|
+
depth = len(parts)
|
|
36
|
+
elif index == 0:
|
|
37
|
+
depth = min(2, len(parts))
|
|
38
|
+
else:
|
|
39
|
+
depth = min(index + 2, len(parts))
|
|
40
|
+
paths.append("/".join(parts[:depth]))
|
|
41
|
+
return paths
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _nodeid_dir_parts(nodeid: str) -> list[str]:
|
|
45
|
+
path = str(nodeid).replace("\\", "/").split("::", 1)[0]
|
|
46
|
+
path = path.split("[", 1)[0].strip("/")
|
|
47
|
+
if not path:
|
|
48
|
+
return []
|
|
49
|
+
return [part for part in path.split("/")[:-1] if part]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
_current: str | None = None
|
|
4
|
+
_pending: dict[str, list[str]] = {}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def set_current(nodeid: str | None) -> None:
|
|
8
|
+
global _current
|
|
9
|
+
_current = nodeid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register(path) -> None:
|
|
13
|
+
if _current is None:
|
|
14
|
+
return
|
|
15
|
+
_pending.setdefault(_current, []).append(str(path))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pop(nodeid: str) -> list[str]:
|
|
19
|
+
return _pending.pop(nodeid, [])
|
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import datetime as dt
|
|
8
|
+
|
|
9
|
+
import requests
|
|
10
|
+
|
|
11
|
+
from .config import TestyConfig
|
|
12
|
+
from .models import CaseResult
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("testy_pytest")
|
|
15
|
+
_FAILURE_OUTCOMES = {"failed", "error", "xpassed"}
|
|
16
|
+
|
|
17
|
+
def _flatten_steps(tree: list) -> list[dict]:
|
|
18
|
+
steps: list[dict] = []
|
|
19
|
+
|
|
20
|
+
def walk(nodes: list) -> None:
|
|
21
|
+
for node in nodes:
|
|
22
|
+
title = node["title"]
|
|
23
|
+
steps.append({
|
|
24
|
+
"name": title[:255],
|
|
25
|
+
"scenario": title,
|
|
26
|
+
"expected": "",
|
|
27
|
+
"sort_order": len(steps) + 1,
|
|
28
|
+
})
|
|
29
|
+
if node.get("children"):
|
|
30
|
+
walk(node["children"])
|
|
31
|
+
|
|
32
|
+
walk(tree)
|
|
33
|
+
return steps
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _flatten_step_statuses(tree: list) -> list[str]:
|
|
37
|
+
statuses: list[str] = []
|
|
38
|
+
|
|
39
|
+
def walk(nodes: list) -> None:
|
|
40
|
+
for node in nodes:
|
|
41
|
+
statuses.append(str(node.get("status") or "passed"))
|
|
42
|
+
if node.get("children"):
|
|
43
|
+
walk(node["children"])
|
|
44
|
+
|
|
45
|
+
walk(tree)
|
|
46
|
+
return statuses
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _runtime_step_statuses(result: CaseResult) -> list[str]:
|
|
50
|
+
statuses = _flatten_step_statuses(result.steps)
|
|
51
|
+
if (
|
|
52
|
+
result.outcome in {"failed", "error", "xpassed"}
|
|
53
|
+
and statuses
|
|
54
|
+
and all(status.lower() != "failed" for status in statuses)
|
|
55
|
+
):
|
|
56
|
+
statuses[-1] = "failed"
|
|
57
|
+
return statuses
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _steps_signature(steps: list[dict]) -> list[tuple[str, str, str]]:
|
|
61
|
+
ordered = sorted(
|
|
62
|
+
enumerate(steps),
|
|
63
|
+
key=lambda pair: int(pair[1].get("sort_order") or pair[0] + 1),
|
|
64
|
+
)
|
|
65
|
+
return [
|
|
66
|
+
(
|
|
67
|
+
str(step.get("name") or ""),
|
|
68
|
+
str(step.get("scenario") or ""),
|
|
69
|
+
str(step.get("expected") or ""),
|
|
70
|
+
)
|
|
71
|
+
for _, step in ordered
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ordered_steps(steps: list[dict]) -> list[dict]:
|
|
76
|
+
return [
|
|
77
|
+
step for _, step in sorted(
|
|
78
|
+
enumerate(steps),
|
|
79
|
+
key=lambda pair: int(pair[1].get("sort_order") or pair[0] + 1),
|
|
80
|
+
)
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _step_results_payload(
|
|
85
|
+
case_steps: list[dict],
|
|
86
|
+
runtime_statuses: list[str],
|
|
87
|
+
passed_status_id: int,
|
|
88
|
+
failed_status_id: int,
|
|
89
|
+
) -> list[dict]:
|
|
90
|
+
payload: list[dict] = []
|
|
91
|
+
for case_step, runtime_status in zip(case_steps, runtime_statuses):
|
|
92
|
+
step_id = case_step.get("id")
|
|
93
|
+
if step_id is None:
|
|
94
|
+
continue
|
|
95
|
+
status_id = failed_status_id if runtime_status.lower() == "failed" else passed_status_id
|
|
96
|
+
payload.append({"step": int(step_id), "status": int(status_id)})
|
|
97
|
+
return payload
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _variants_summary(result: CaseResult) -> str:
|
|
101
|
+
if len(result.variants) <= 1:
|
|
102
|
+
return ""
|
|
103
|
+
failed = [str(lbl) for lbl, oc in result.variants if oc in _FAILURE_OUTCOMES]
|
|
104
|
+
total = len(result.variants)
|
|
105
|
+
if failed:
|
|
106
|
+
return f"Параметризация: {total} вариант(ов), упали: {', '.join(failed)}"
|
|
107
|
+
return f"Параметризация: {total} вариант(ов), все пройдены"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _compose_comment(result: CaseResult, limit: int = 5000) -> str:
|
|
111
|
+
parts: list[str] = []
|
|
112
|
+
summary = _variants_summary(result)
|
|
113
|
+
if summary:
|
|
114
|
+
parts.append(summary)
|
|
115
|
+
if result.comment:
|
|
116
|
+
prefix, suffix = "```text\n", "\n```"
|
|
117
|
+
budget = max(limit - len(prefix) - len(suffix) - len("\n\n".join(parts)) - 2, 0)
|
|
118
|
+
body = result.comment.strip()[:budget]
|
|
119
|
+
parts.append(f"{prefix}{body}{suffix}")
|
|
120
|
+
return "\n\n".join(parts)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TestyClient:
|
|
124
|
+
__test__ = False # not a pytest test class despite the Test* name
|
|
125
|
+
|
|
126
|
+
def __init__(self, config: TestyConfig):
|
|
127
|
+
self.cfg = config
|
|
128
|
+
self.session = requests.Session()
|
|
129
|
+
self.session.headers["Authorization"] = f"{config.auth_scheme} {config.token}"
|
|
130
|
+
self._status_ids: dict[str, int] = {}
|
|
131
|
+
self._test_by_case: dict[int, int] = {}
|
|
132
|
+
self._case_by_external: dict[str, int | None] = {}
|
|
133
|
+
self._suite_cache: dict[tuple, int] = {}
|
|
134
|
+
self._suite_attrs_cache: set[tuple[int, str]] = set()
|
|
135
|
+
self._plan_cache: dict[tuple, int] = {}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _get(self, path: str, **params):
|
|
139
|
+
resp = self.session.get(
|
|
140
|
+
f"{self.cfg.api}/{path.lstrip('/')}",
|
|
141
|
+
params=params,
|
|
142
|
+
timeout=self.cfg.timeout,
|
|
143
|
+
verify=self.cfg.verify_ssl,
|
|
144
|
+
)
|
|
145
|
+
resp.raise_for_status()
|
|
146
|
+
return resp.json()
|
|
147
|
+
|
|
148
|
+
def _paged(self, path: str, **params):
|
|
149
|
+
data = self._get(path, **params)
|
|
150
|
+
while True:
|
|
151
|
+
results = data["results"] if isinstance(data, dict) else data
|
|
152
|
+
yield from results
|
|
153
|
+
nxt = data.get("links", {}).get("next") if isinstance(data, dict) else None
|
|
154
|
+
nxt = nxt or (data.get("next") if isinstance(data, dict) else None)
|
|
155
|
+
if not nxt:
|
|
156
|
+
return
|
|
157
|
+
data = self._get(nxt.split("/api/v2/", 1)[-1])
|
|
158
|
+
|
|
159
|
+
def load_statuses(self) -> None:
|
|
160
|
+
data = self._get("statuses/", project=self.cfg.project_id)
|
|
161
|
+
results = data["results"] if isinstance(data, dict) else data
|
|
162
|
+
for status in results:
|
|
163
|
+
self._status_ids[status["name"].lower()] = status["id"]
|
|
164
|
+
|
|
165
|
+
def load_plan_tests(self) -> None:
|
|
166
|
+
self.ensure_roots(need_plan=True)
|
|
167
|
+
path = (
|
|
168
|
+
f"testplans/{self.cfg.plan_id}/tests/"
|
|
169
|
+
f"?project={self.cfg.project_id}&show_descendants=true"
|
|
170
|
+
)
|
|
171
|
+
for test in self._paged(path):
|
|
172
|
+
self._test_by_case[int(test["case"])] = int(test["id"])
|
|
173
|
+
|
|
174
|
+
def resolve_case_by_external_id(self, external_id: str) -> int | None:
|
|
175
|
+
if external_id in self._case_by_external:
|
|
176
|
+
return self._case_by_external[external_id]
|
|
177
|
+
attr_filter = json.dumps({self.cfg.automation_key: external_id})
|
|
178
|
+
data = self._get(
|
|
179
|
+
"cases/", project=self.cfg.project_id, case_attributes=attr_filter,
|
|
180
|
+
)
|
|
181
|
+
results = data["results"] if isinstance(data, dict) else data
|
|
182
|
+
case_id = int(results[0]["id"]) if results else None
|
|
183
|
+
if case_id is None:
|
|
184
|
+
log.warning("TestY: no case with %s=%r in project %s",
|
|
185
|
+
self.cfg.automation_key, external_id, self.cfg.project_id)
|
|
186
|
+
self._case_by_external[external_id] = case_id
|
|
187
|
+
return case_id
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def ensure_roots(self, need_suite: bool = False, need_plan: bool = False) -> None:
|
|
191
|
+
"""Resolve configured root suite/plan ids, creating them by name if needed."""
|
|
192
|
+
if need_suite and self.cfg.suite_id is None:
|
|
193
|
+
suite_name = self.cfg.suite_name or self.cfg.root_name
|
|
194
|
+
if not suite_name:
|
|
195
|
+
raise ValueError("TestY suite root is missing: set TESTY_SUITE_ID or TESTY_ROOT_NAME")
|
|
196
|
+
self.cfg.suite_id = self._ensure_child_suite(suite_name, None)
|
|
197
|
+
if self.cfg.suite_id is None:
|
|
198
|
+
raise RuntimeError(f"TestY: failed to resolve root suite {suite_name!r}")
|
|
199
|
+
if need_plan and self.cfg.plan_id is None:
|
|
200
|
+
plan_name = self.cfg.plan_name or self.cfg.root_name
|
|
201
|
+
if not plan_name:
|
|
202
|
+
raise ValueError("TestY plan root is missing: set TESTY_PLAN_ID or TESTY_ROOT_NAME")
|
|
203
|
+
self.cfg.plan_id = self._ensure_root_plan(plan_name)
|
|
204
|
+
|
|
205
|
+
def _ensure_root_plan(self, name: str) -> int:
|
|
206
|
+
key = (None, name)
|
|
207
|
+
if key in self._plan_cache:
|
|
208
|
+
return self._plan_cache[key]
|
|
209
|
+
for plan in self._paged("testplans/", project=self.cfg.project_id):
|
|
210
|
+
if plan.get("name") != name:
|
|
211
|
+
continue
|
|
212
|
+
parent = plan.get("parent") if isinstance(plan, dict) else None
|
|
213
|
+
if "parent" not in plan or parent in (None, "", "null"):
|
|
214
|
+
plan_id = int(plan["id"])
|
|
215
|
+
self._plan_cache[key] = plan_id
|
|
216
|
+
return plan_id
|
|
217
|
+
payload = self._plan_payload(name)
|
|
218
|
+
resp = self.session.post(f"{self.cfg.api}/testplans/", json=payload,
|
|
219
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
|
|
220
|
+
if resp.status_code >= 400:
|
|
221
|
+
raise RuntimeError(
|
|
222
|
+
f"TestY: failed to create root plan {name!r}: "
|
|
223
|
+
f"{resp.status_code} {resp.text[:300]}"
|
|
224
|
+
)
|
|
225
|
+
body = resp.json()
|
|
226
|
+
obj = body[0] if isinstance(body, list) else body
|
|
227
|
+
plan_id = int(obj["id"])
|
|
228
|
+
self._plan_cache[key] = plan_id
|
|
229
|
+
log.info("TestY: created root plan %s %r", plan_id, name)
|
|
230
|
+
return plan_id
|
|
231
|
+
|
|
232
|
+
def ensure_suite_attributes(self, suite_id: int, attributes: dict) -> None:
|
|
233
|
+
self._ensure_suite_attributes(suite_id, attributes)
|
|
234
|
+
|
|
235
|
+
def ensure_suite_path(
|
|
236
|
+
self,
|
|
237
|
+
path: list[str],
|
|
238
|
+
root_id: int | None,
|
|
239
|
+
attribute_values: list[str] | None = None,
|
|
240
|
+
) -> int | None:
|
|
241
|
+
parent = root_id
|
|
242
|
+
for index, name in enumerate(path):
|
|
243
|
+
attributes = None
|
|
244
|
+
if attribute_values and index < len(attribute_values):
|
|
245
|
+
attributes = {self.cfg.automation_key: attribute_values[index]}
|
|
246
|
+
parent = self._ensure_child_suite(name, parent, attributes=attributes)
|
|
247
|
+
if parent is None:
|
|
248
|
+
return root_id
|
|
249
|
+
return parent
|
|
250
|
+
|
|
251
|
+
def _ensure_child_suite(
|
|
252
|
+
self,
|
|
253
|
+
name: str,
|
|
254
|
+
parent_id: int | None,
|
|
255
|
+
attributes: dict | None = None,
|
|
256
|
+
) -> int | None:
|
|
257
|
+
key = (parent_id, name)
|
|
258
|
+
if key in self._suite_cache:
|
|
259
|
+
suite_id = self._suite_cache[key]
|
|
260
|
+
self._ensure_suite_attributes(suite_id, attributes)
|
|
261
|
+
return suite_id
|
|
262
|
+
for suite in self._paged("suites/", project=self.cfg.project_id,
|
|
263
|
+
parent=parent_id if parent_id else "null"):
|
|
264
|
+
if suite.get("name") == name:
|
|
265
|
+
suite_id = int(suite["id"])
|
|
266
|
+
self._suite_cache[key] = suite_id
|
|
267
|
+
self._ensure_suite_attributes(suite_id, attributes, suite.get("attributes"))
|
|
268
|
+
return suite_id
|
|
269
|
+
payload = {"name": name[:255], "project": self.cfg.project_id}
|
|
270
|
+
if parent_id:
|
|
271
|
+
payload["parent"] = parent_id
|
|
272
|
+
if attributes:
|
|
273
|
+
payload["attributes"] = attributes
|
|
274
|
+
resp = self.session.post(f"{self.cfg.api}/suites/", json=payload,
|
|
275
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
|
|
276
|
+
if resp.status_code >= 400:
|
|
277
|
+
log.error("TestY: failed to create suite %r under %s: %s %s",
|
|
278
|
+
name, parent_id, resp.status_code, resp.text[:300])
|
|
279
|
+
return None
|
|
280
|
+
body = resp.json()
|
|
281
|
+
suite_id = int((body[0] if isinstance(body, list) else body)["id"])
|
|
282
|
+
self._suite_cache[key] = suite_id
|
|
283
|
+
log.info("TestY: created suite %s %r under %s", suite_id, name, parent_id)
|
|
284
|
+
return suite_id
|
|
285
|
+
|
|
286
|
+
def _ensure_suite_attributes(
|
|
287
|
+
self,
|
|
288
|
+
suite_id: int | None,
|
|
289
|
+
attributes: dict | None,
|
|
290
|
+
current: dict | None = None,
|
|
291
|
+
) -> None:
|
|
292
|
+
desired = {key: value for key, value in (attributes or {}).items() if value}
|
|
293
|
+
if not suite_id or not desired:
|
|
294
|
+
return
|
|
295
|
+
cache_key = (int(suite_id), json.dumps(desired, sort_keys=True, ensure_ascii=False))
|
|
296
|
+
if cache_key in self._suite_attrs_cache:
|
|
297
|
+
return
|
|
298
|
+
if current is None:
|
|
299
|
+
try:
|
|
300
|
+
suite = self._get(f"suites/{suite_id}/")
|
|
301
|
+
current = suite.get("attributes") or {}
|
|
302
|
+
except requests.HTTPError as exc:
|
|
303
|
+
log.warning("TestY: cannot read suite %s for attributes: %s", suite_id, exc)
|
|
304
|
+
return
|
|
305
|
+
merged = dict(current or {})
|
|
306
|
+
changed = False
|
|
307
|
+
for key, value in desired.items():
|
|
308
|
+
if merged.get(key) != value:
|
|
309
|
+
merged[key] = value
|
|
310
|
+
changed = True
|
|
311
|
+
if not changed:
|
|
312
|
+
self._suite_attrs_cache.add(cache_key)
|
|
313
|
+
return
|
|
314
|
+
resp = self.session.patch(
|
|
315
|
+
f"{self.cfg.api}/suites/{suite_id}/",
|
|
316
|
+
json={"attributes": merged},
|
|
317
|
+
timeout=self.cfg.timeout,
|
|
318
|
+
verify=self.cfg.verify_ssl,
|
|
319
|
+
)
|
|
320
|
+
if resp.status_code >= 400:
|
|
321
|
+
log.error("TestY: failed to update suite %s attributes: %s %s",
|
|
322
|
+
suite_id, resp.status_code, resp.text[:300])
|
|
323
|
+
return
|
|
324
|
+
self._suite_attrs_cache.add(cache_key)
|
|
325
|
+
log.info("TestY: updated suite %s attributes with %s", suite_id, sorted(desired))
|
|
326
|
+
|
|
327
|
+
def ensure_case(self, external_id: str, name: str, suite_id: int | None = None) -> int | None:
|
|
328
|
+
case_id = self.resolve_case_by_external_id(external_id)
|
|
329
|
+
if case_id is not None:
|
|
330
|
+
return case_id
|
|
331
|
+
target_suite = suite_id or self.cfg.suite_id
|
|
332
|
+
if not target_suite:
|
|
333
|
+
log.error("TestY: cannot auto-create case for %r — no suite (set --testy-suite)",
|
|
334
|
+
external_id)
|
|
335
|
+
return None
|
|
336
|
+
created = self.session.post(
|
|
337
|
+
f"{self.cfg.api}/cases/",
|
|
338
|
+
json={
|
|
339
|
+
"name": name[:255], "project": self.cfg.project_id,
|
|
340
|
+
"suite": target_suite, "scenario": "Created by testy-pytest sync",
|
|
341
|
+
"attributes": {self.cfg.automation_key: external_id},
|
|
342
|
+
},
|
|
343
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl,
|
|
344
|
+
)
|
|
345
|
+
if created.status_code >= 400:
|
|
346
|
+
log.error("TestY: failed to create case for %r: %s %s",
|
|
347
|
+
external_id, created.status_code, created.text[:300])
|
|
348
|
+
return None
|
|
349
|
+
case_id = int(created.json()["id"])
|
|
350
|
+
self._case_by_external[external_id] = case_id
|
|
351
|
+
log.info("TestY: created case %s for %r", case_id, external_id)
|
|
352
|
+
return case_id
|
|
353
|
+
|
|
354
|
+
def sync_plan(self, case_ids: list[int], plan_id: int | None = None) -> None:
|
|
355
|
+
self.ensure_roots(need_plan=True)
|
|
356
|
+
target_plan = plan_id or self.cfg.plan_id
|
|
357
|
+
if target_plan is None:
|
|
358
|
+
raise ValueError("TestY plan root is missing")
|
|
359
|
+
existing = self._load_tests_for_single_plan(target_plan)
|
|
360
|
+
to_add = [cid for cid in case_ids if cid not in existing]
|
|
361
|
+
added = 0
|
|
362
|
+
for case_id in to_add:
|
|
363
|
+
resp = self.session.post(
|
|
364
|
+
f"{self.cfg.api}/tests/",
|
|
365
|
+
json={"project": self.cfg.project_id, "case": case_id,
|
|
366
|
+
"plan": target_plan},
|
|
367
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl,
|
|
368
|
+
)
|
|
369
|
+
if resp.status_code >= 400:
|
|
370
|
+
log.error("TestY: failed to add case %s to plan %s: %s %s",
|
|
371
|
+
case_id, target_plan, resp.status_code, resp.text[:300])
|
|
372
|
+
continue
|
|
373
|
+
body = resp.json()
|
|
374
|
+
obj = body[0] if isinstance(body, list) else body
|
|
375
|
+
self._test_by_case[case_id] = int(obj["id"])
|
|
376
|
+
added += 1
|
|
377
|
+
if added:
|
|
378
|
+
log.info("TestY: added %s case(s) to plan %s (%s already present)",
|
|
379
|
+
added, target_plan, len(existing))
|
|
380
|
+
|
|
381
|
+
def _load_tests_for_single_plan(self, plan_id: int) -> dict[int, int]:
|
|
382
|
+
tests: dict[int, int] = {}
|
|
383
|
+
for test in self._paged(f"tests/?plan={plan_id}&project={self.cfg.project_id}"):
|
|
384
|
+
tests[int(test["case"])] = int(test["id"])
|
|
385
|
+
return tests
|
|
386
|
+
|
|
387
|
+
def ensure_plan_path(self, path: list[str], root_id: int) -> int:
|
|
388
|
+
parent = root_id
|
|
389
|
+
for name in path:
|
|
390
|
+
parent = self._ensure_child_plan(name, parent)
|
|
391
|
+
return parent
|
|
392
|
+
|
|
393
|
+
def _ensure_child_plan(self, name: str, parent_id: int) -> int:
|
|
394
|
+
key = (parent_id, name)
|
|
395
|
+
if key in self._plan_cache:
|
|
396
|
+
return self._plan_cache[key]
|
|
397
|
+
for plan in self._paged("testplans/", project=self.cfg.project_id, parent=parent_id):
|
|
398
|
+
if plan.get("name") == name:
|
|
399
|
+
self._plan_cache[key] = int(plan["id"])
|
|
400
|
+
return int(plan["id"])
|
|
401
|
+
payload = self._plan_payload(name, parent_id)
|
|
402
|
+
resp = self.session.post(f"{self.cfg.api}/testplans/", json=payload,
|
|
403
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
|
|
404
|
+
if resp.status_code >= 400:
|
|
405
|
+
log.error("TestY: failed to create plan %r under %s: %s %s",
|
|
406
|
+
name, parent_id, resp.status_code, resp.text[:300])
|
|
407
|
+
return parent_id
|
|
408
|
+
body = resp.json()
|
|
409
|
+
obj = body[0] if isinstance(body, list) else body
|
|
410
|
+
plan_id = int(obj["id"])
|
|
411
|
+
self._plan_cache[key] = plan_id
|
|
412
|
+
log.info("TestY: created plan %s %r under %s", plan_id, name, parent_id)
|
|
413
|
+
return plan_id
|
|
414
|
+
|
|
415
|
+
def _plan_payload(self, name: str, parent_id: int | None = None) -> dict:
|
|
416
|
+
now = dt.datetime.now(dt.timezone.utc)
|
|
417
|
+
payload = {
|
|
418
|
+
"name": name[:255],
|
|
419
|
+
"project": self.cfg.project_id,
|
|
420
|
+
"started_at": now.isoformat(),
|
|
421
|
+
"due_date": (now + dt.timedelta(days=7)).isoformat(),
|
|
422
|
+
"test_cases": [],
|
|
423
|
+
}
|
|
424
|
+
if parent_id:
|
|
425
|
+
payload["parent"] = parent_id
|
|
426
|
+
return payload
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def report(self, result: CaseResult) -> bool:
|
|
430
|
+
if not result.external_id:
|
|
431
|
+
return False
|
|
432
|
+
case_id = self.resolve_case_by_external_id(result.external_id)
|
|
433
|
+
if case_id is None:
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
test_id = self._test_by_case.get(case_id)
|
|
437
|
+
if test_id is None:
|
|
438
|
+
log.warning(
|
|
439
|
+
"TestY: case %s is not part of plan %s — skipping result for %s",
|
|
440
|
+
case_id, self.cfg.plan_id, result.nodeid,
|
|
441
|
+
)
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
status_name = self.cfg.status_name_for(result.outcome)
|
|
445
|
+
status_id = self._status_ids.get(status_name.lower())
|
|
446
|
+
if status_id is None:
|
|
447
|
+
log.warning("TestY: status %r not found in project %s (available: %s)",
|
|
448
|
+
status_name, self.cfg.project_id, sorted(self._status_ids))
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
attributes = {"source": "gitlab-ci", "nodeid": result.nodeid}
|
|
452
|
+
if self.cfg.ci_pipeline_url:
|
|
453
|
+
attributes["ci_pipeline_url"] = self.cfg.ci_pipeline_url
|
|
454
|
+
if self.cfg.ci_job_url:
|
|
455
|
+
attributes["ci_job_url"] = self.cfg.ci_job_url
|
|
456
|
+
|
|
457
|
+
attachment_ids: list[int] = []
|
|
458
|
+
if result.attachments and self.cfg.should_attach(result.outcome):
|
|
459
|
+
attachment_ids = self._upload_attachments(result.attachments)
|
|
460
|
+
|
|
461
|
+
steps_results: list[dict] = []
|
|
462
|
+
if result.steps:
|
|
463
|
+
case_steps = self._sync_case_steps(case_id, result.steps)
|
|
464
|
+
passed_status_id = self._status_ids.get(self.cfg.status_name_for("passed").lower())
|
|
465
|
+
failed_status_id = self._status_ids.get(self.cfg.status_name_for("failed").lower())
|
|
466
|
+
if case_steps and passed_status_id is not None and failed_status_id is not None:
|
|
467
|
+
steps_results = _step_results_payload(
|
|
468
|
+
case_steps,
|
|
469
|
+
_runtime_step_statuses(result),
|
|
470
|
+
passed_status_id,
|
|
471
|
+
failed_status_id,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
payload = {
|
|
475
|
+
"test": test_id,
|
|
476
|
+
"status": status_id,
|
|
477
|
+
"project": self.cfg.project_id,
|
|
478
|
+
"comment": _compose_comment(result),
|
|
479
|
+
"execution_time": result.execution_time,
|
|
480
|
+
"attributes": attributes,
|
|
481
|
+
}
|
|
482
|
+
if attachment_ids:
|
|
483
|
+
payload["attachments"] = attachment_ids
|
|
484
|
+
if steps_results:
|
|
485
|
+
payload["steps_results"] = steps_results
|
|
486
|
+
resp = self.session.post(
|
|
487
|
+
f"{self.cfg.api}/results/",
|
|
488
|
+
json=payload,
|
|
489
|
+
timeout=self.cfg.timeout,
|
|
490
|
+
verify=self.cfg.verify_ssl,
|
|
491
|
+
)
|
|
492
|
+
if resp.status_code >= 400:
|
|
493
|
+
log.error("TestY: failed to post result for %s: %s %s",
|
|
494
|
+
result.nodeid, resp.status_code, resp.text[:500])
|
|
495
|
+
return False
|
|
496
|
+
return True
|
|
497
|
+
|
|
498
|
+
def _sync_case_steps(self, case_id: int, steps_tree: list) -> list[dict]:
|
|
499
|
+
try:
|
|
500
|
+
case = self._get(f"cases/{case_id}/")
|
|
501
|
+
except requests.HTTPError as exc:
|
|
502
|
+
log.warning("TestY: cannot read case %s for steps: %s", case_id, exc)
|
|
503
|
+
return []
|
|
504
|
+
desired_steps = _flatten_steps(steps_tree)
|
|
505
|
+
current_steps = case.get("steps") or []
|
|
506
|
+
if current_steps and not self.cfg.override_cases:
|
|
507
|
+
return _ordered_steps(current_steps) # frozen: never overwrite
|
|
508
|
+
if _steps_signature(current_steps) == _steps_signature(desired_steps):
|
|
509
|
+
return _ordered_steps(current_steps)
|
|
510
|
+
suite = case.get("suite")
|
|
511
|
+
suite_id = suite["id"] if isinstance(suite, dict) else suite
|
|
512
|
+
payload = {
|
|
513
|
+
"name": case["name"], "project": self.cfg.project_id, "suite": suite_id,
|
|
514
|
+
"setup": case.get("setup", ""), "scenario": case.get("scenario", ""),
|
|
515
|
+
"expected": case.get("expected", ""), "teardown": case.get("teardown", ""),
|
|
516
|
+
"description": case.get("description", ""),
|
|
517
|
+
"attributes": case.get("attributes", {}),
|
|
518
|
+
"is_steps": True, "steps": desired_steps,
|
|
519
|
+
}
|
|
520
|
+
resp = self.session.put(f"{self.cfg.api}/cases/{case_id}/", json=payload,
|
|
521
|
+
timeout=self.cfg.timeout, verify=self.cfg.verify_ssl)
|
|
522
|
+
if resp.status_code >= 400:
|
|
523
|
+
log.error("TestY: failed to write steps to case %s: %s %s",
|
|
524
|
+
case_id, resp.status_code, resp.text[:300])
|
|
525
|
+
return []
|
|
526
|
+
action = "updated" if current_steps else "wrote"
|
|
527
|
+
log.info("TestY: %s %s step(s) into case %s",
|
|
528
|
+
action, len(payload["steps"]), case_id)
|
|
529
|
+
try:
|
|
530
|
+
updated_case = self._get(f"cases/{case_id}/")
|
|
531
|
+
except requests.HTTPError as exc:
|
|
532
|
+
log.warning("TestY: cannot re-read case %s after writing steps: %s", case_id, exc)
|
|
533
|
+
return []
|
|
534
|
+
return _ordered_steps(updated_case.get("steps") or [])
|
|
535
|
+
|
|
536
|
+
def _upload_attachments(self, paths: list[str]) -> list[int]:
|
|
537
|
+
ids: list[int] = []
|
|
538
|
+
for path in paths:
|
|
539
|
+
if not os.path.isfile(path):
|
|
540
|
+
log.warning("TestY: attachment not found, skipping: %s", path)
|
|
541
|
+
continue
|
|
542
|
+
content_type = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
543
|
+
with open(path, "rb") as fh:
|
|
544
|
+
resp = self.session.post(
|
|
545
|
+
f"{self.cfg.api}/attachments/",
|
|
546
|
+
data={"project": self.cfg.project_id},
|
|
547
|
+
files={"file": (os.path.basename(path), fh, content_type)},
|
|
548
|
+
timeout=self.cfg.timeout,
|
|
549
|
+
verify=self.cfg.verify_ssl,
|
|
550
|
+
)
|
|
551
|
+
if resp.status_code >= 400:
|
|
552
|
+
log.error("TestY: failed to upload %s: %s %s",
|
|
553
|
+
path, resp.status_code, resp.text[:300])
|
|
554
|
+
continue
|
|
555
|
+
body = resp.json()
|
|
556
|
+
obj = body[0] if isinstance(body, list) else body
|
|
557
|
+
ids.append(int(obj["id"]))
|
|
558
|
+
return ids
|