qastudio-pytest 1.0.2__tar.gz → 1.0.3__tar.gz
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-1.0.2/src/qastudio_pytest.egg-info → qastudio_pytest-1.0.3}/PKG-INFO +31 -1
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/README.md +30 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/pyproject.toml +16 -2
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/__init__.py +1 -1
- qastudio_pytest-1.0.3/src/qastudio_pytest/api_client.py +379 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/models.py +6 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/plugin.py +139 -2
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3/src/qastudio_pytest.egg-info}/PKG-INFO +31 -1
- qastudio_pytest-1.0.2/src/qastudio_pytest/api_client.py +0 -205
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/LICENSE +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/setup.cfg +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/utils.py +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/SOURCES.txt +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/dependency_links.txt +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/entry_points.txt +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/requires.txt +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/top_level.txt +0 -0
- {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/tests/test_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qastudio-pytest
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: pytest plugin for QAStudio.dev test management platform
|
|
5
5
|
Author-email: QAStudio <ben@qastudio.dev>
|
|
6
6
|
License: MIT
|
|
@@ -243,6 +243,36 @@ test:
|
|
|
243
243
|
QASTUDIO_ENVIRONMENT: CI
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
+
## Examples
|
|
247
|
+
|
|
248
|
+
### Playwright Example
|
|
249
|
+
|
|
250
|
+
A complete Playwright test framework example is available in `examples/playwright_tests/`:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# Navigate to example directory
|
|
254
|
+
cd examples/playwright_tests
|
|
255
|
+
|
|
256
|
+
# Install dependencies
|
|
257
|
+
pip install -r requirements.txt
|
|
258
|
+
playwright install chromium
|
|
259
|
+
|
|
260
|
+
# Run the tests
|
|
261
|
+
./run_tests.sh
|
|
262
|
+
|
|
263
|
+
# Or run directly with pytest
|
|
264
|
+
pytest -v
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The example demonstrates:
|
|
268
|
+
- ✅ Testing the QAStudio.dev website
|
|
269
|
+
- ✅ Automatic screenshot capture
|
|
270
|
+
- ✅ Playwright trace recording (`.zip` files)
|
|
271
|
+
- ✅ Integration with qastudio-pytest reporter
|
|
272
|
+
- ✅ Test case linking with `@pytest.mark.qastudio_id()`
|
|
273
|
+
|
|
274
|
+
See [`examples/playwright_tests/README.md`](examples/playwright_tests/README.md) for detailed documentation.
|
|
275
|
+
|
|
246
276
|
## Development
|
|
247
277
|
|
|
248
278
|
```bash
|
|
@@ -206,6 +206,36 @@ test:
|
|
|
206
206
|
QASTUDIO_ENVIRONMENT: CI
|
|
207
207
|
```
|
|
208
208
|
|
|
209
|
+
## Examples
|
|
210
|
+
|
|
211
|
+
### Playwright Example
|
|
212
|
+
|
|
213
|
+
A complete Playwright test framework example is available in `examples/playwright_tests/`:
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
# Navigate to example directory
|
|
217
|
+
cd examples/playwright_tests
|
|
218
|
+
|
|
219
|
+
# Install dependencies
|
|
220
|
+
pip install -r requirements.txt
|
|
221
|
+
playwright install chromium
|
|
222
|
+
|
|
223
|
+
# Run the tests
|
|
224
|
+
./run_tests.sh
|
|
225
|
+
|
|
226
|
+
# Or run directly with pytest
|
|
227
|
+
pytest -v
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
The example demonstrates:
|
|
231
|
+
- ✅ Testing the QAStudio.dev website
|
|
232
|
+
- ✅ Automatic screenshot capture
|
|
233
|
+
- ✅ Playwright trace recording (`.zip` files)
|
|
234
|
+
- ✅ Integration with qastudio-pytest reporter
|
|
235
|
+
- ✅ Test case linking with `@pytest.mark.qastudio_id()`
|
|
236
|
+
|
|
237
|
+
See [`examples/playwright_tests/README.md`](examples/playwright_tests/README.md) for detailed documentation.
|
|
238
|
+
|
|
209
239
|
## Development
|
|
210
240
|
|
|
211
241
|
```bash
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qastudio-pytest"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.3"
|
|
8
8
|
description = "pytest plugin for QAStudio.dev test management platform"
|
|
9
9
|
authors = [{name = "QAStudio", email = "ben@qastudio.dev"}]
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -62,10 +62,24 @@ max-line-length = 100
|
|
|
62
62
|
extend-ignore = ["E203", "W503"]
|
|
63
63
|
|
|
64
64
|
[tool.mypy]
|
|
65
|
-
python_version = "1.0.
|
|
65
|
+
python_version = "1.0.3"
|
|
66
66
|
warn_return_any = true
|
|
67
67
|
warn_unused_configs = true
|
|
68
68
|
disallow_untyped_defs = true
|
|
69
|
+
exclude = [
|
|
70
|
+
"venv/",
|
|
71
|
+
".venv/",
|
|
72
|
+
"build/",
|
|
73
|
+
"dist/",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
# Ignore errors in installed packages
|
|
77
|
+
[[tool.mypy.overrides]]
|
|
78
|
+
module = [
|
|
79
|
+
"pytest.*",
|
|
80
|
+
"_pytest.*",
|
|
81
|
+
]
|
|
82
|
+
ignore_errors = true
|
|
69
83
|
|
|
70
84
|
[tool.pytest.ini_options]
|
|
71
85
|
testpaths = ["tests"]
|
|
@@ -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()
|
|
@@ -35,7 +35,9 @@ class TestResult:
|
|
|
35
35
|
file_path: Optional[str] = None
|
|
36
36
|
line_number: Optional[int] = None
|
|
37
37
|
attachments: Optional[List[Dict[str, Any]]] = None
|
|
38
|
+
attachment_paths: List[str] = field(default_factory=list)
|
|
38
39
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
result_id: Optional[str] = None # API result ID for uploading attachments
|
|
39
41
|
|
|
40
42
|
def to_dict(self) -> Dict[str, Any]:
|
|
41
43
|
"""Convert to dictionary for API submission."""
|
|
@@ -205,6 +207,8 @@ class ReporterConfig:
|
|
|
205
207
|
include_error_location: bool = True
|
|
206
208
|
include_test_steps: bool = True
|
|
207
209
|
include_console_output: bool = False
|
|
210
|
+
upload_attachments: bool = True
|
|
211
|
+
attachments_dir: Optional[str] = None
|
|
208
212
|
|
|
209
213
|
@classmethod
|
|
210
214
|
def from_pytest_config(cls, config: Any) -> "ReporterConfig":
|
|
@@ -259,4 +263,6 @@ class ReporterConfig:
|
|
|
259
263
|
include_error_location=get_option("qastudio_include_error_location", True),
|
|
260
264
|
include_test_steps=get_option("qastudio_include_test_steps", True),
|
|
261
265
|
include_console_output=get_option("qastudio_include_console_output", False),
|
|
266
|
+
upload_attachments=get_option("qastudio_upload_attachments", True),
|
|
267
|
+
attachments_dir=get_option("qastudio_attachments_dir"),
|
|
262
268
|
)
|
|
@@ -76,6 +76,11 @@ class QAStudioPlugin:
|
|
|
76
76
|
if report.when == "call":
|
|
77
77
|
try:
|
|
78
78
|
result = TestResult.from_pytest_report(item, report, self.config)
|
|
79
|
+
|
|
80
|
+
# Collect attachments if enabled
|
|
81
|
+
if self.config.upload_attachments:
|
|
82
|
+
result.attachment_paths = self._collect_attachments(item)
|
|
83
|
+
|
|
79
84
|
self.results.append(result)
|
|
80
85
|
|
|
81
86
|
# Update counters
|
|
@@ -142,7 +147,7 @@ class QAStudioPlugin:
|
|
|
142
147
|
self.api_client.close()
|
|
143
148
|
|
|
144
149
|
def _submit_results(self) -> None:
|
|
145
|
-
"""Submit test results in batches."""
|
|
150
|
+
"""Submit test results in batches and upload attachments."""
|
|
146
151
|
if not self.results:
|
|
147
152
|
self._log("No results to submit")
|
|
148
153
|
return
|
|
@@ -153,13 +158,122 @@ class QAStudioPlugin:
|
|
|
153
158
|
for i, batch in enumerate(batches, 1):
|
|
154
159
|
try:
|
|
155
160
|
self._log(f"Submitting batch {i}/{len(batches)} ({len(batch)} results)")
|
|
156
|
-
self.api_client.submit_test_results(
|
|
161
|
+
response = self.api_client.submit_test_results(
|
|
157
162
|
self.test_run_id, # type: ignore
|
|
158
163
|
batch,
|
|
159
164
|
)
|
|
165
|
+
|
|
166
|
+
# Store result IDs for attachment uploads
|
|
167
|
+
if response and "results" in response:
|
|
168
|
+
for j, result_data in enumerate(response["results"]):
|
|
169
|
+
if j < len(batch):
|
|
170
|
+
batch[j].result_id = result_data.get("id")
|
|
171
|
+
|
|
172
|
+
# Upload attachments if enabled
|
|
173
|
+
if self.config.upload_attachments:
|
|
174
|
+
for result in batch:
|
|
175
|
+
if result.result_id and result.attachment_paths:
|
|
176
|
+
self._upload_attachments(result)
|
|
177
|
+
|
|
160
178
|
except APIError as e:
|
|
161
179
|
self._handle_error(f"Failed to submit batch {i}", e)
|
|
162
180
|
|
|
181
|
+
def _collect_attachments(self, item: Any) -> List[str]:
|
|
182
|
+
"""
|
|
183
|
+
Collect attachment file paths for a test.
|
|
184
|
+
|
|
185
|
+
Looks for attachments in:
|
|
186
|
+
1. Custom attachments directory (if configured)
|
|
187
|
+
2. pytest-html plugin screenshots
|
|
188
|
+
3. Test fixtures that store attachment paths
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
item: pytest test item
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
List of file paths to attach
|
|
195
|
+
"""
|
|
196
|
+
import os
|
|
197
|
+
import glob
|
|
198
|
+
|
|
199
|
+
attachments = []
|
|
200
|
+
|
|
201
|
+
# Check if test has attachment paths stored in stash or fixtures
|
|
202
|
+
if hasattr(item, "stash"):
|
|
203
|
+
# pytest >= 7.0 uses stash API
|
|
204
|
+
from pytest import StashKey
|
|
205
|
+
|
|
206
|
+
attachment_key = StashKey[List[str]]()
|
|
207
|
+
stored_attachments = item.stash.get(attachment_key, [])
|
|
208
|
+
attachments.extend(stored_attachments)
|
|
209
|
+
|
|
210
|
+
# Check custom attachments directory
|
|
211
|
+
if self.config.attachments_dir:
|
|
212
|
+
test_name = item.name.replace("[", "_").replace("]", "_")
|
|
213
|
+
attachment_dir = os.path.join(self.config.attachments_dir, test_name)
|
|
214
|
+
|
|
215
|
+
if os.path.exists(attachment_dir):
|
|
216
|
+
# Find common attachment types
|
|
217
|
+
patterns = [
|
|
218
|
+
"*.png",
|
|
219
|
+
"*.jpg",
|
|
220
|
+
"*.jpeg",
|
|
221
|
+
"*.gif",
|
|
222
|
+
"*.mp4",
|
|
223
|
+
"*.webm",
|
|
224
|
+
"*.txt",
|
|
225
|
+
"*.log",
|
|
226
|
+
"*.zip",
|
|
227
|
+
]
|
|
228
|
+
for pattern in patterns:
|
|
229
|
+
files = glob.glob(os.path.join(attachment_dir, pattern))
|
|
230
|
+
attachments.extend(files)
|
|
231
|
+
|
|
232
|
+
return attachments
|
|
233
|
+
|
|
234
|
+
def _upload_attachments(self, result: TestResult) -> None:
|
|
235
|
+
"""
|
|
236
|
+
Upload attachments for a test result.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
result: TestResult with attachment_paths and result_id
|
|
240
|
+
"""
|
|
241
|
+
if not result.result_id or not result.attachment_paths:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
self._log(f"Uploading {len(result.attachment_paths)} attachment(s) for {result.title}")
|
|
245
|
+
|
|
246
|
+
for file_path in result.attachment_paths:
|
|
247
|
+
try:
|
|
248
|
+
# Determine attachment type from file extension
|
|
249
|
+
import os
|
|
250
|
+
|
|
251
|
+
ext = os.path.splitext(file_path)[1].lower()
|
|
252
|
+
filename = os.path.basename(file_path)
|
|
253
|
+
attachment_type = None
|
|
254
|
+
|
|
255
|
+
if ext in [".png", ".jpg", ".jpeg", ".gif"]:
|
|
256
|
+
attachment_type = "screenshot"
|
|
257
|
+
elif ext in [".mp4", ".webm", ".avi", ".mov"]:
|
|
258
|
+
attachment_type = "video"
|
|
259
|
+
elif ext in [".log", ".txt"]:
|
|
260
|
+
attachment_type = "log"
|
|
261
|
+
elif ext == ".zip" and "trace" in filename.lower():
|
|
262
|
+
attachment_type = "trace"
|
|
263
|
+
|
|
264
|
+
self.api_client.upload_attachment(
|
|
265
|
+
test_result_id=result.result_id,
|
|
266
|
+
file_path=file_path,
|
|
267
|
+
attachment_type=attachment_type,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
self._log(f" Uploaded: {os.path.basename(file_path)}")
|
|
271
|
+
|
|
272
|
+
except APIError as e:
|
|
273
|
+
self._handle_error(f"Failed to upload attachment {file_path}", e)
|
|
274
|
+
except Exception as e:
|
|
275
|
+
self._log(f" Error uploading {file_path}: {e}")
|
|
276
|
+
|
|
163
277
|
def _log(self, message: str) -> None:
|
|
164
278
|
"""Log message if verbose mode is enabled."""
|
|
165
279
|
if self.config.verbose:
|
|
@@ -269,6 +383,21 @@ def pytest_addoption(parser: Any) -> None:
|
|
|
269
383
|
help="Include console output (default: False)",
|
|
270
384
|
)
|
|
271
385
|
|
|
386
|
+
group.addoption(
|
|
387
|
+
"--qastudio-upload-attachments",
|
|
388
|
+
action="store_true",
|
|
389
|
+
dest="qastudio_upload_attachments",
|
|
390
|
+
default=True,
|
|
391
|
+
help="Upload test attachments (default: True)",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
group.addoption(
|
|
395
|
+
"--qastudio-attachments-dir",
|
|
396
|
+
action="store",
|
|
397
|
+
dest="qastudio_attachments_dir",
|
|
398
|
+
help="Directory containing test attachments",
|
|
399
|
+
)
|
|
400
|
+
|
|
272
401
|
# Add pytest.ini configuration
|
|
273
402
|
parser.addini("qastudio_api_url", "QAStudio.dev API URL")
|
|
274
403
|
parser.addini("qastudio_api_key", "QAStudio.dev API key")
|
|
@@ -299,6 +428,14 @@ def pytest_addoption(parser: Any) -> None:
|
|
|
299
428
|
"qastudio_include_console_output",
|
|
300
429
|
"Include console output (true/false)",
|
|
301
430
|
)
|
|
431
|
+
parser.addini(
|
|
432
|
+
"qastudio_upload_attachments",
|
|
433
|
+
"Upload test attachments (true/false)",
|
|
434
|
+
)
|
|
435
|
+
parser.addini(
|
|
436
|
+
"qastudio_attachments_dir",
|
|
437
|
+
"Directory containing test attachments",
|
|
438
|
+
)
|
|
302
439
|
|
|
303
440
|
|
|
304
441
|
def pytest_configure(config: Any) -> None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qastudio-pytest
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: pytest plugin for QAStudio.dev test management platform
|
|
5
5
|
Author-email: QAStudio <ben@qastudio.dev>
|
|
6
6
|
License: MIT
|
|
@@ -243,6 +243,36 @@ test:
|
|
|
243
243
|
QASTUDIO_ENVIRONMENT: CI
|
|
244
244
|
```
|
|
245
245
|
|
|
246
|
+
## Examples
|
|
247
|
+
|
|
248
|
+
### Playwright Example
|
|
249
|
+
|
|
250
|
+
A complete Playwright test framework example is available in `examples/playwright_tests/`:
|
|
251
|
+
|
|
252
|
+
```bash
|
|
253
|
+
# Navigate to example directory
|
|
254
|
+
cd examples/playwright_tests
|
|
255
|
+
|
|
256
|
+
# Install dependencies
|
|
257
|
+
pip install -r requirements.txt
|
|
258
|
+
playwright install chromium
|
|
259
|
+
|
|
260
|
+
# Run the tests
|
|
261
|
+
./run_tests.sh
|
|
262
|
+
|
|
263
|
+
# Or run directly with pytest
|
|
264
|
+
pytest -v
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The example demonstrates:
|
|
268
|
+
- ✅ Testing the QAStudio.dev website
|
|
269
|
+
- ✅ Automatic screenshot capture
|
|
270
|
+
- ✅ Playwright trace recording (`.zip` files)
|
|
271
|
+
- ✅ Integration with qastudio-pytest reporter
|
|
272
|
+
- ✅ Test case linking with `@pytest.mark.qastudio_id()`
|
|
273
|
+
|
|
274
|
+
See [`examples/playwright_tests/README.md`](examples/playwright_tests/README.md) for detailed documentation.
|
|
275
|
+
|
|
246
276
|
## Development
|
|
247
277
|
|
|
248
278
|
```bash
|
|
@@ -1,205 +0,0 @@
|
|
|
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", "/test-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(
|
|
159
|
-
"POST",
|
|
160
|
-
f"/test-runs/{test_run_id}/results",
|
|
161
|
-
json_data=data,
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
self._log(f"Successfully submitted {len(results)} results")
|
|
165
|
-
return response
|
|
166
|
-
|
|
167
|
-
def complete_test_run(
|
|
168
|
-
self,
|
|
169
|
-
test_run_id: str,
|
|
170
|
-
summary: TestRunSummary,
|
|
171
|
-
) -> Dict[str, Any]:
|
|
172
|
-
"""
|
|
173
|
-
Mark test run as complete with summary.
|
|
174
|
-
|
|
175
|
-
Args:
|
|
176
|
-
test_run_id: Test run ID
|
|
177
|
-
summary: Test run summary
|
|
178
|
-
|
|
179
|
-
Returns:
|
|
180
|
-
Response data
|
|
181
|
-
"""
|
|
182
|
-
self._log(f"Completing test run {test_run_id}")
|
|
183
|
-
|
|
184
|
-
data = {
|
|
185
|
-
"testRunId": test_run_id,
|
|
186
|
-
"summary": summary.to_dict(),
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
response = self._make_request(
|
|
190
|
-
"POST",
|
|
191
|
-
f"/test-runs/{test_run_id}/complete",
|
|
192
|
-
json_data=data,
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
self._log("Test run completed successfully")
|
|
196
|
-
return response
|
|
197
|
-
|
|
198
|
-
def _log(self, message: str) -> None:
|
|
199
|
-
"""Log message if verbose mode is enabled."""
|
|
200
|
-
if self.config.verbose:
|
|
201
|
-
print(f"[QAStudio] {message}")
|
|
202
|
-
|
|
203
|
-
def close(self) -> None:
|
|
204
|
-
"""Close the session."""
|
|
205
|
-
self.session.close()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|