qastudio-pytest 1.0.4__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.
- qastudio_pytest/__init__.py +13 -0
- qastudio_pytest/api_client.py +379 -0
- qastudio_pytest/models.py +259 -0
- qastudio_pytest/plugin.py +497 -0
- qastudio_pytest/utils.py +284 -0
- qastudio_pytest-1.0.4.dist-info/METADATA +310 -0
- qastudio_pytest-1.0.4.dist-info/RECORD +11 -0
- qastudio_pytest-1.0.4.dist-info/WHEEL +5 -0
- qastudio_pytest-1.0.4.dist-info/entry_points.txt +2 -0
- qastudio_pytest-1.0.4.dist-info/licenses/LICENSE +21 -0
- qastudio_pytest-1.0.4.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QAStudio pytest plugin for test management integration.
|
|
3
|
+
|
|
4
|
+
This plugin automatically reports pytest test results to QAStudio.dev platform.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.4"
|
|
8
|
+
__author__ = "QAStudio"
|
|
9
|
+
__email__ = "support@qastudio.dev"
|
|
10
|
+
|
|
11
|
+
from .plugin import QAStudioPlugin
|
|
12
|
+
|
|
13
|
+
__all__ = ["QAStudioPlugin"]
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
"""API client for QAStudio.dev integration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
import requests
|
|
5
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
from urllib3.util.retry import Retry
|
|
7
|
+
|
|
8
|
+
from .models import ReporterConfig, TestResult, TestRunSummary
|
|
9
|
+
from .utils import sanitize_string
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class APIError(Exception):
|
|
13
|
+
"""Custom exception for API errors."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, status_code: int, message: str):
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
super().__init__(f"API Error {status_code}: {message}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class QAStudioAPIClient:
|
|
21
|
+
"""Client for communicating with QAStudio.dev API."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: ReporterConfig):
|
|
24
|
+
"""Initialize API client with configuration."""
|
|
25
|
+
self.config = config
|
|
26
|
+
self.session = self._create_session()
|
|
27
|
+
self.base_url = sanitize_string(config.api_url) or ""
|
|
28
|
+
self.api_key = sanitize_string(config.api_key) or ""
|
|
29
|
+
self.project_id = sanitize_string(config.project_id) or ""
|
|
30
|
+
|
|
31
|
+
def _create_session(self) -> requests.Session:
|
|
32
|
+
"""Create requests session with retry logic."""
|
|
33
|
+
session = requests.Session()
|
|
34
|
+
|
|
35
|
+
# Configure retry strategy
|
|
36
|
+
retry_strategy = Retry(
|
|
37
|
+
total=self.config.max_retries,
|
|
38
|
+
backoff_factor=1,
|
|
39
|
+
status_forcelist=[429, 500, 502, 503, 504],
|
|
40
|
+
allowed_methods=["HEAD", "GET", "POST", "PUT", "DELETE", "OPTIONS", "TRACE"],
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
44
|
+
session.mount("http://", adapter)
|
|
45
|
+
session.mount("https://", adapter)
|
|
46
|
+
|
|
47
|
+
return session
|
|
48
|
+
|
|
49
|
+
def _make_request(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
path: str,
|
|
53
|
+
json_data: Optional[Dict[str, Any]] = None,
|
|
54
|
+
) -> Dict[str, Any]:
|
|
55
|
+
"""
|
|
56
|
+
Make HTTP request to API with error handling.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
method: HTTP method
|
|
60
|
+
path: API endpoint path
|
|
61
|
+
json_data: JSON data to send
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Response JSON data
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
APIError: If request fails
|
|
68
|
+
"""
|
|
69
|
+
url = f"{self.base_url}{path}"
|
|
70
|
+
headers = {
|
|
71
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
"User-Agent": "qastudio-pytest/1.0.0",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
self._log(f"Making {method} request to {path}")
|
|
78
|
+
|
|
79
|
+
response = self.session.request(
|
|
80
|
+
method=method,
|
|
81
|
+
url=url,
|
|
82
|
+
json=json_data,
|
|
83
|
+
headers=headers,
|
|
84
|
+
timeout=self.config.timeout,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Raise for 4xx/5xx status codes
|
|
88
|
+
if not response.ok:
|
|
89
|
+
error_msg = response.text or response.reason
|
|
90
|
+
raise APIError(response.status_code, error_msg)
|
|
91
|
+
|
|
92
|
+
# Return JSON if present
|
|
93
|
+
if response.content:
|
|
94
|
+
json_response: Dict[str, Any] = response.json()
|
|
95
|
+
return json_response
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
except requests.exceptions.Timeout as e:
|
|
99
|
+
raise APIError(408, f"Request timeout: {str(e)}")
|
|
100
|
+
except requests.exceptions.ConnectionError as e:
|
|
101
|
+
raise APIError(503, f"Connection error: {str(e)}")
|
|
102
|
+
except requests.exceptions.RequestException as e:
|
|
103
|
+
raise APIError(500, f"Request failed: {str(e)}")
|
|
104
|
+
|
|
105
|
+
def create_test_run(
|
|
106
|
+
self,
|
|
107
|
+
name: str,
|
|
108
|
+
description: Optional[str] = None,
|
|
109
|
+
) -> Dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Create a new test run.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
name: Test run name
|
|
115
|
+
description: Optional description
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
Test run data with 'id' field
|
|
119
|
+
"""
|
|
120
|
+
self._log(f"Creating test run: {name}")
|
|
121
|
+
|
|
122
|
+
data = {
|
|
123
|
+
"projectId": self.project_id,
|
|
124
|
+
"name": name,
|
|
125
|
+
"environment": self.config.environment,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if description:
|
|
129
|
+
data["description"] = description
|
|
130
|
+
|
|
131
|
+
response = self._make_request("POST", "/runs", json_data=data)
|
|
132
|
+
|
|
133
|
+
self._log(f"Created test run with ID: {response.get('id')}")
|
|
134
|
+
return response
|
|
135
|
+
|
|
136
|
+
def submit_test_results(
|
|
137
|
+
self,
|
|
138
|
+
test_run_id: str,
|
|
139
|
+
results: List[TestResult],
|
|
140
|
+
) -> Dict[str, Any]:
|
|
141
|
+
"""
|
|
142
|
+
Submit test results to a test run.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
test_run_id: Test run ID
|
|
146
|
+
results: List of test results
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Response data
|
|
150
|
+
"""
|
|
151
|
+
self._log(f"Submitting {len(results)} test results to run {test_run_id}")
|
|
152
|
+
|
|
153
|
+
data = {
|
|
154
|
+
"testRunId": test_run_id,
|
|
155
|
+
"results": [result.to_dict() for result in results],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
response = self._make_request("POST", "/results", json_data=data)
|
|
159
|
+
|
|
160
|
+
self._log(f"Successfully submitted {len(results)} results")
|
|
161
|
+
return response
|
|
162
|
+
|
|
163
|
+
def complete_test_run(
|
|
164
|
+
self,
|
|
165
|
+
test_run_id: str,
|
|
166
|
+
summary: TestRunSummary,
|
|
167
|
+
) -> Dict[str, Any]:
|
|
168
|
+
"""
|
|
169
|
+
Mark test run as complete with summary.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
test_run_id: Test run ID
|
|
173
|
+
summary: Test run summary
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Response data
|
|
177
|
+
"""
|
|
178
|
+
self._log(f"Completing test run {test_run_id}")
|
|
179
|
+
|
|
180
|
+
data = {
|
|
181
|
+
"testRunId": test_run_id,
|
|
182
|
+
"summary": summary.to_dict(),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
response = self._make_request("POST", f"/runs/{test_run_id}/complete", json_data=data)
|
|
186
|
+
|
|
187
|
+
self._log("Test run completed successfully")
|
|
188
|
+
return response
|
|
189
|
+
|
|
190
|
+
def upload_attachment(
|
|
191
|
+
self,
|
|
192
|
+
test_result_id: str,
|
|
193
|
+
file_path: str,
|
|
194
|
+
attachment_type: Optional[str] = None,
|
|
195
|
+
) -> Dict[str, Any]:
|
|
196
|
+
"""
|
|
197
|
+
Upload an attachment file to a test result.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
test_result_id: Test result ID to attach file to
|
|
201
|
+
file_path: Path to file to upload
|
|
202
|
+
attachment_type: Optional type (e.g., 'screenshot', 'video', 'log')
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
Response data with attachment info
|
|
206
|
+
|
|
207
|
+
Raises:
|
|
208
|
+
APIError: If upload fails
|
|
209
|
+
"""
|
|
210
|
+
import os
|
|
211
|
+
import mimetypes
|
|
212
|
+
|
|
213
|
+
if not os.path.exists(file_path):
|
|
214
|
+
raise APIError(400, f"File not found: {file_path}")
|
|
215
|
+
|
|
216
|
+
filename = os.path.basename(file_path)
|
|
217
|
+
content_type = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
218
|
+
|
|
219
|
+
self._log(f"Uploading attachment: {filename} ({content_type})")
|
|
220
|
+
|
|
221
|
+
with open(file_path, "rb") as f:
|
|
222
|
+
file_data = f.read()
|
|
223
|
+
|
|
224
|
+
# Prepare multipart form data
|
|
225
|
+
fields = {
|
|
226
|
+
"testResultId": test_result_id,
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if attachment_type:
|
|
230
|
+
fields["type"] = attachment_type
|
|
231
|
+
|
|
232
|
+
return self._upload_multipart("/attachments", fields, filename, content_type, file_data)
|
|
233
|
+
|
|
234
|
+
def _upload_multipart(
|
|
235
|
+
self,
|
|
236
|
+
path: str,
|
|
237
|
+
fields: Dict[str, str],
|
|
238
|
+
filename: str,
|
|
239
|
+
content_type: str,
|
|
240
|
+
file_data: bytes,
|
|
241
|
+
) -> Dict[str, Any]:
|
|
242
|
+
"""
|
|
243
|
+
Upload multipart/form-data request with retry logic.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
path: API endpoint path
|
|
247
|
+
fields: Form fields
|
|
248
|
+
filename: Name of file being uploaded
|
|
249
|
+
content_type: MIME type of file
|
|
250
|
+
file_data: File content as bytes
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
Response JSON data
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
APIError: If request fails
|
|
257
|
+
"""
|
|
258
|
+
url = f"{self.base_url}{path}"
|
|
259
|
+
last_error: Optional[Exception] = None
|
|
260
|
+
|
|
261
|
+
for attempt in range(self.config.max_retries):
|
|
262
|
+
try:
|
|
263
|
+
if attempt > 0:
|
|
264
|
+
self._log(f"Retry attempt {attempt + 1}/{self.config.max_retries}")
|
|
265
|
+
import time
|
|
266
|
+
|
|
267
|
+
time.sleep(min(1 * (2**attempt), 10)) # Exponential backoff
|
|
268
|
+
|
|
269
|
+
return self._make_multipart_request(url, fields, filename, content_type, file_data)
|
|
270
|
+
|
|
271
|
+
except APIError as e:
|
|
272
|
+
last_error = e
|
|
273
|
+
self._log(f"Upload failed (attempt {attempt + 1}/{self.config.max_retries}): {e}")
|
|
274
|
+
|
|
275
|
+
# Don't retry on 4xx errors (client errors)
|
|
276
|
+
if 400 <= e.status_code < 500:
|
|
277
|
+
raise
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
last_error = e
|
|
281
|
+
self._log(f"Upload failed (attempt {attempt + 1}/{self.config.max_retries}): {e}")
|
|
282
|
+
|
|
283
|
+
if last_error:
|
|
284
|
+
raise last_error
|
|
285
|
+
raise APIError(500, "Upload failed after all retries")
|
|
286
|
+
|
|
287
|
+
def _make_multipart_request(
|
|
288
|
+
self,
|
|
289
|
+
url: str,
|
|
290
|
+
fields: Dict[str, str],
|
|
291
|
+
filename: str,
|
|
292
|
+
content_type: str,
|
|
293
|
+
file_data: bytes,
|
|
294
|
+
) -> Dict[str, Any]:
|
|
295
|
+
"""
|
|
296
|
+
Make a multipart/form-data HTTP request.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
url: Full URL to request
|
|
300
|
+
fields: Form fields (non-file)
|
|
301
|
+
filename: Name of file being uploaded
|
|
302
|
+
content_type: MIME type of file
|
|
303
|
+
file_data: File content as bytes
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
Response JSON data
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
APIError: If request fails
|
|
310
|
+
"""
|
|
311
|
+
import uuid
|
|
312
|
+
|
|
313
|
+
# Generate boundary
|
|
314
|
+
boundary = f"----FormBoundary{uuid.uuid4().hex}"
|
|
315
|
+
|
|
316
|
+
# Build multipart body
|
|
317
|
+
body_parts = []
|
|
318
|
+
|
|
319
|
+
# Add form fields
|
|
320
|
+
for key, value in fields.items():
|
|
321
|
+
body_parts.append(f"--{boundary}\r\n".encode())
|
|
322
|
+
body_parts.append(f'Content-Disposition: form-data; name="{key}"\r\n\r\n'.encode())
|
|
323
|
+
body_parts.append(f"{value}\r\n".encode())
|
|
324
|
+
|
|
325
|
+
# Add file field
|
|
326
|
+
body_parts.append(f"--{boundary}\r\n".encode())
|
|
327
|
+
body_parts.append(
|
|
328
|
+
f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'.encode()
|
|
329
|
+
)
|
|
330
|
+
body_parts.append(f"Content-Type: {content_type}\r\n\r\n".encode())
|
|
331
|
+
body_parts.append(file_data)
|
|
332
|
+
body_parts.append(b"\r\n")
|
|
333
|
+
|
|
334
|
+
# Add closing boundary
|
|
335
|
+
body_parts.append(f"--{boundary}--\r\n".encode())
|
|
336
|
+
|
|
337
|
+
body = b"".join(body_parts)
|
|
338
|
+
|
|
339
|
+
headers = {
|
|
340
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
341
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
342
|
+
"User-Agent": "qastudio-pytest/1.0.0",
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
response = self.session.request(
|
|
347
|
+
method="POST",
|
|
348
|
+
url=url,
|
|
349
|
+
data=body,
|
|
350
|
+
headers=headers,
|
|
351
|
+
timeout=self.config.timeout,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
# Raise for 4xx/5xx status codes
|
|
355
|
+
if not response.ok:
|
|
356
|
+
error_msg = response.text or response.reason
|
|
357
|
+
raise APIError(response.status_code, error_msg)
|
|
358
|
+
|
|
359
|
+
# Return JSON if present
|
|
360
|
+
if response.content:
|
|
361
|
+
json_response: Dict[str, Any] = response.json()
|
|
362
|
+
return json_response
|
|
363
|
+
return {}
|
|
364
|
+
|
|
365
|
+
except requests.exceptions.Timeout as e:
|
|
366
|
+
raise APIError(408, f"Request timeout: {str(e)}")
|
|
367
|
+
except requests.exceptions.ConnectionError as e:
|
|
368
|
+
raise APIError(503, f"Connection error: {str(e)}")
|
|
369
|
+
except requests.exceptions.RequestException as e:
|
|
370
|
+
raise APIError(500, f"Request failed: {str(e)}")
|
|
371
|
+
|
|
372
|
+
def _log(self, message: str) -> None:
|
|
373
|
+
"""Log message if verbose mode is enabled."""
|
|
374
|
+
if self.config.verbose:
|
|
375
|
+
print(f"[QAStudio] {message}")
|
|
376
|
+
|
|
377
|
+
def close(self) -> None:
|
|
378
|
+
"""Close the session."""
|
|
379
|
+
self.session.close()
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Data models for QAStudio.dev API integration."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestStatus(Enum):
|
|
10
|
+
"""Test execution status."""
|
|
11
|
+
|
|
12
|
+
PASSED = "passed"
|
|
13
|
+
FAILED = "failed"
|
|
14
|
+
SKIPPED = "skipped"
|
|
15
|
+
ERROR = "error"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class TestResult:
|
|
20
|
+
"""Test result data to send to QAStudio.dev."""
|
|
21
|
+
|
|
22
|
+
test_case_id: Optional[str]
|
|
23
|
+
title: str
|
|
24
|
+
full_title: str
|
|
25
|
+
status: TestStatus
|
|
26
|
+
duration: float # in seconds
|
|
27
|
+
error: Optional[str] = None
|
|
28
|
+
stack_trace: Optional[str] = None
|
|
29
|
+
error_snippet: Optional[str] = None
|
|
30
|
+
error_location: Optional[Dict[str, Any]] = None
|
|
31
|
+
steps: Optional[List[Dict[str, Any]]] = None
|
|
32
|
+
console_output: Optional[Dict[str, str]] = None
|
|
33
|
+
start_time: Optional[str] = None
|
|
34
|
+
end_time: Optional[str] = None
|
|
35
|
+
file_path: Optional[str] = None
|
|
36
|
+
line_number: Optional[int] = None
|
|
37
|
+
attachments: Optional[List[Dict[str, Any]]] = None
|
|
38
|
+
attachment_paths: List[str] = field(default_factory=list)
|
|
39
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
result_id: Optional[str] = None # API result ID for uploading attachments
|
|
41
|
+
|
|
42
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
43
|
+
"""Convert to dictionary for API submission."""
|
|
44
|
+
result: Dict[str, Any] = {
|
|
45
|
+
"title": self.title,
|
|
46
|
+
"status": self.status.value,
|
|
47
|
+
"duration": int(self.duration * 1000), # Convert to milliseconds
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Add optional fields that the API expects
|
|
51
|
+
if self.full_title:
|
|
52
|
+
result["fullTitle"] = self.full_title
|
|
53
|
+
|
|
54
|
+
if self.error:
|
|
55
|
+
result["errorMessage"] = self.error
|
|
56
|
+
|
|
57
|
+
if self.stack_trace:
|
|
58
|
+
result["stackTrace"] = self.stack_trace
|
|
59
|
+
|
|
60
|
+
# Add attachments array if present
|
|
61
|
+
if self.attachments:
|
|
62
|
+
result["attachments"] = self.attachments
|
|
63
|
+
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_pytest_report(
|
|
68
|
+
cls, item: Any, report: Any, config: Optional["ReporterConfig"] = None
|
|
69
|
+
) -> "TestResult":
|
|
70
|
+
"""Create TestResult from pytest item and report."""
|
|
71
|
+
from .utils import (
|
|
72
|
+
extract_test_case_id,
|
|
73
|
+
get_full_test_name,
|
|
74
|
+
extract_error_snippet,
|
|
75
|
+
extract_error_location,
|
|
76
|
+
extract_console_output,
|
|
77
|
+
extract_test_steps,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# Extract test case ID from markers or test name
|
|
81
|
+
test_case_id = extract_test_case_id(item)
|
|
82
|
+
|
|
83
|
+
# Determine status
|
|
84
|
+
if report.passed:
|
|
85
|
+
status = TestStatus.PASSED
|
|
86
|
+
elif report.failed:
|
|
87
|
+
status = TestStatus.FAILED if report.when == "call" else TestStatus.ERROR
|
|
88
|
+
elif report.skipped:
|
|
89
|
+
status = TestStatus.SKIPPED
|
|
90
|
+
else:
|
|
91
|
+
status = TestStatus.ERROR
|
|
92
|
+
|
|
93
|
+
# Extract error information
|
|
94
|
+
error = None
|
|
95
|
+
stack_trace = None
|
|
96
|
+
error_snippet = None
|
|
97
|
+
error_location = None
|
|
98
|
+
if report.longrepr:
|
|
99
|
+
if hasattr(report.longrepr, "reprcrash"):
|
|
100
|
+
error = str(report.longrepr.reprcrash.message)
|
|
101
|
+
if hasattr(report.longrepr, "reprtraceback"):
|
|
102
|
+
stack_trace = str(report.longrepr.reprtraceback)
|
|
103
|
+
elif isinstance(report.longrepr, tuple):
|
|
104
|
+
# For skipped tests
|
|
105
|
+
error = str(report.longrepr[2])
|
|
106
|
+
else:
|
|
107
|
+
error = str(report.longrepr)
|
|
108
|
+
|
|
109
|
+
# Extract error snippet and location if enabled
|
|
110
|
+
if config:
|
|
111
|
+
if config.include_error_snippet:
|
|
112
|
+
error_snippet = extract_error_snippet(report)
|
|
113
|
+
if config.include_error_location:
|
|
114
|
+
error_location = extract_error_location(report)
|
|
115
|
+
|
|
116
|
+
# Extract metadata from markers
|
|
117
|
+
metadata = {}
|
|
118
|
+
for marker in item.iter_markers():
|
|
119
|
+
if marker.name.startswith("qastudio_"):
|
|
120
|
+
key = marker.name.replace("qastudio_", "")
|
|
121
|
+
if key != "id": # Already extracted as test_case_id
|
|
122
|
+
metadata[key] = marker.args[0] if marker.args else True
|
|
123
|
+
|
|
124
|
+
# Get location information
|
|
125
|
+
file_path = str(item.fspath) if hasattr(item, "fspath") else None
|
|
126
|
+
line_number = item.location[1] if hasattr(item, "location") else None
|
|
127
|
+
|
|
128
|
+
# Extract console output if enabled
|
|
129
|
+
console_output = None
|
|
130
|
+
if config and config.include_console_output:
|
|
131
|
+
console_output = extract_console_output(report)
|
|
132
|
+
|
|
133
|
+
# Extract test steps if enabled
|
|
134
|
+
steps = None
|
|
135
|
+
if config and config.include_test_steps:
|
|
136
|
+
steps = extract_test_steps(report)
|
|
137
|
+
|
|
138
|
+
return cls(
|
|
139
|
+
test_case_id=test_case_id,
|
|
140
|
+
title=item.name,
|
|
141
|
+
full_title=get_full_test_name(item),
|
|
142
|
+
status=status,
|
|
143
|
+
duration=report.duration,
|
|
144
|
+
error=error,
|
|
145
|
+
stack_trace=stack_trace,
|
|
146
|
+
error_snippet=error_snippet,
|
|
147
|
+
error_location=error_location,
|
|
148
|
+
steps=steps,
|
|
149
|
+
console_output=console_output,
|
|
150
|
+
start_time=datetime.now().isoformat(),
|
|
151
|
+
end_time=datetime.now().isoformat(),
|
|
152
|
+
file_path=file_path,
|
|
153
|
+
line_number=line_number,
|
|
154
|
+
metadata=metadata,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass
|
|
159
|
+
class TestRunSummary:
|
|
160
|
+
"""Summary of test run results."""
|
|
161
|
+
|
|
162
|
+
total: int
|
|
163
|
+
passed: int
|
|
164
|
+
failed: int
|
|
165
|
+
skipped: int
|
|
166
|
+
errors: int
|
|
167
|
+
duration: float # in seconds
|
|
168
|
+
|
|
169
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
170
|
+
"""Convert to dictionary for API submission."""
|
|
171
|
+
return {
|
|
172
|
+
"total": self.total,
|
|
173
|
+
"passed": self.passed,
|
|
174
|
+
"failed": self.failed,
|
|
175
|
+
"skipped": self.skipped + self.errors,
|
|
176
|
+
"duration": int(self.duration * 1000), # Convert to milliseconds
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass
|
|
181
|
+
class ReporterConfig:
|
|
182
|
+
"""Configuration for QAStudio reporter."""
|
|
183
|
+
|
|
184
|
+
api_url: str
|
|
185
|
+
api_key: str
|
|
186
|
+
project_id: str
|
|
187
|
+
environment: str = "default"
|
|
188
|
+
test_run_id: Optional[str] = None
|
|
189
|
+
test_run_name: Optional[str] = None
|
|
190
|
+
test_run_description: Optional[str] = None
|
|
191
|
+
create_test_run: bool = True
|
|
192
|
+
batch_size: int = 10
|
|
193
|
+
silent: bool = True
|
|
194
|
+
verbose: bool = False
|
|
195
|
+
max_retries: int = 3
|
|
196
|
+
timeout: int = 30
|
|
197
|
+
include_error_snippet: bool = True
|
|
198
|
+
include_error_location: bool = True
|
|
199
|
+
include_test_steps: bool = True
|
|
200
|
+
include_console_output: bool = False
|
|
201
|
+
upload_attachments: bool = True
|
|
202
|
+
attachments_dir: Optional[str] = None
|
|
203
|
+
|
|
204
|
+
@classmethod
|
|
205
|
+
def from_pytest_config(cls, config: Any) -> "ReporterConfig":
|
|
206
|
+
"""Create ReporterConfig from pytest config."""
|
|
207
|
+
import os
|
|
208
|
+
|
|
209
|
+
def get_option(name: str, default: Any = None) -> Any:
|
|
210
|
+
"""Get option from pytest config, environment, or default."""
|
|
211
|
+
# Try pytest config first
|
|
212
|
+
value = config.getini(name)
|
|
213
|
+
if value:
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
# Try command line option
|
|
217
|
+
cli_name = f"--{name.replace('_', '-')}"
|
|
218
|
+
value = config.getoption(cli_name, default=None)
|
|
219
|
+
if value is not None:
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
# Try environment variable
|
|
223
|
+
env_name = name.upper()
|
|
224
|
+
value = os.environ.get(env_name)
|
|
225
|
+
if value is not None:
|
|
226
|
+
# Convert string booleans
|
|
227
|
+
if value.lower() in ("true", "1", "yes"):
|
|
228
|
+
return True
|
|
229
|
+
elif value.lower() in ("false", "0", "no"):
|
|
230
|
+
return False
|
|
231
|
+
# Convert string numbers
|
|
232
|
+
try:
|
|
233
|
+
return int(value)
|
|
234
|
+
except ValueError:
|
|
235
|
+
return value
|
|
236
|
+
|
|
237
|
+
return default
|
|
238
|
+
|
|
239
|
+
return cls(
|
|
240
|
+
api_url=get_option("qastudio_api_url", "https://qastudio.dev/api"),
|
|
241
|
+
api_key=get_option("qastudio_api_key"),
|
|
242
|
+
project_id=get_option("qastudio_project_id"),
|
|
243
|
+
environment=get_option("qastudio_environment", "default"),
|
|
244
|
+
test_run_id=get_option("qastudio_test_run_id"),
|
|
245
|
+
test_run_name=get_option("qastudio_test_run_name"),
|
|
246
|
+
test_run_description=get_option("qastudio_test_run_description"),
|
|
247
|
+
create_test_run=get_option("qastudio_create_test_run", True),
|
|
248
|
+
batch_size=get_option("qastudio_batch_size", 10),
|
|
249
|
+
silent=get_option("qastudio_silent", True),
|
|
250
|
+
verbose=get_option("qastudio_verbose", False),
|
|
251
|
+
max_retries=get_option("qastudio_max_retries", 3),
|
|
252
|
+
timeout=get_option("qastudio_timeout", 30),
|
|
253
|
+
include_error_snippet=get_option("qastudio_include_error_snippet", True),
|
|
254
|
+
include_error_location=get_option("qastudio_include_error_location", True),
|
|
255
|
+
include_test_steps=get_option("qastudio_include_test_steps", True),
|
|
256
|
+
include_console_output=get_option("qastudio_include_console_output", False),
|
|
257
|
+
upload_attachments=get_option("qastudio_upload_attachments", True),
|
|
258
|
+
attachments_dir=get_option("qastudio_attachments_dir"),
|
|
259
|
+
)
|