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.
Files changed (18) hide show
  1. {qastudio_pytest-1.0.2/src/qastudio_pytest.egg-info → qastudio_pytest-1.0.3}/PKG-INFO +31 -1
  2. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/README.md +30 -0
  3. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/pyproject.toml +16 -2
  4. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/__init__.py +1 -1
  5. qastudio_pytest-1.0.3/src/qastudio_pytest/api_client.py +379 -0
  6. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/models.py +6 -0
  7. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/plugin.py +139 -2
  8. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3/src/qastudio_pytest.egg-info}/PKG-INFO +31 -1
  9. qastudio_pytest-1.0.2/src/qastudio_pytest/api_client.py +0 -205
  10. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/LICENSE +0 -0
  11. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/setup.cfg +0 -0
  12. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest/utils.py +0 -0
  13. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/SOURCES.txt +0 -0
  14. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/dependency_links.txt +0 -0
  15. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/entry_points.txt +0 -0
  16. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/requires.txt +0 -0
  17. {qastudio_pytest-1.0.2 → qastudio_pytest-1.0.3}/src/qastudio_pytest.egg-info/top_level.txt +0 -0
  18. {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.2
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.2"
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.2"
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"]
@@ -4,7 +4,7 @@ QAStudio pytest plugin for test management integration.
4
4
  This plugin automatically reports pytest test results to QAStudio.dev platform.
5
5
  """
6
6
 
7
- __version__ = "1.0.2"
7
+ __version__ = "1.0.3"
8
8
  __author__ = "QAStudio"
9
9
  __email__ = "support@qastudio.dev"
10
10
 
@@ -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.2
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