test-reporting 3.2.6__tar.gz → 3.2.8__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 (21) hide show
  1. {test_reporting-3.2.6/test_reporting.egg-info → test_reporting-3.2.8}/PKG-INFO +1 -1
  2. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/plugin.py +90 -4
  3. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/storage.py +91 -4
  4. {test_reporting-3.2.6 → test_reporting-3.2.8}/setup.py +1 -1
  5. {test_reporting-3.2.6 → test_reporting-3.2.8/test_reporting.egg-info}/PKG-INFO +1 -1
  6. {test_reporting-3.2.6 → test_reporting-3.2.8}/LICENSE +0 -0
  7. {test_reporting-3.2.6 → test_reporting-3.2.8}/README.md +0 -0
  8. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/__init__.py +0 -0
  9. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/classifier.py +0 -0
  10. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/cli.py +0 -0
  11. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/config.py +0 -0
  12. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/publisher.py +0 -0
  13. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/index.html +0 -0
  14. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/project.html +0 -0
  15. {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/run.html +0 -0
  16. {test_reporting-3.2.6 → test_reporting-3.2.8}/setup.cfg +0 -0
  17. {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/SOURCES.txt +0 -0
  18. {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/dependency_links.txt +0 -0
  19. {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/entry_points.txt +0 -0
  20. {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/requires.txt +0 -0
  21. {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: test-reporting
3
- Version: 3.2.6
3
+ Version: 3.2.8
4
4
  Summary: Multi-project test reporting dashboard — collect results locally or push to S3
5
5
  Home-page: https://github.com/amahdy77/test-reporting.git
6
6
  Author: Ashfaqur Mahdy
@@ -5,9 +5,10 @@ Automatically collects test data during pytest execution.
5
5
  import os
6
6
  import json
7
7
  import logging
8
+ import uuid
8
9
  from datetime import datetime
9
10
  from pathlib import Path
10
- from typing import Dict, Any, List
11
+ from typing import Dict, Any, List, Optional
11
12
  import pytest
12
13
  from .config import ReportingConfig
13
14
  from .classifier import FailureClassifier
@@ -21,6 +22,15 @@ class TestReportingPlugin:
21
22
  def __init__(self):
22
23
  self.config = ReportingConfig()
23
24
  self.storage = TestResultStorage(config=self.config)
25
+
26
+ # Session ID for multi-run support
27
+ self.session_id = os.getenv('TEST_SESSION_ID', None)
28
+ self.append_mode = os.getenv('TEST_APPEND_MODE', 'false').lower() == 'true'
29
+ self.attempt_number = int(os.getenv('TEST_ATTEMPT_NUMBER', '1'))
30
+
31
+ # Debug logging
32
+ logging.info(f"[Reporting] Plugin initialized - Session ID: {self.session_id}, Append Mode: {self.append_mode}, Attempt: {self.attempt_number}")
33
+
24
34
  self.run_data = {
25
35
  'project_name': self.config.PROJECT_NAME,
26
36
  'timestamp': datetime.now().isoformat(),
@@ -33,10 +43,13 @@ class TestReportingPlugin:
33
43
  'failed': 0,
34
44
  'skipped': 0,
35
45
  'duration_seconds': 0,
46
+ 'session_id': self.session_id,
47
+ 'is_append_mode': self.append_mode,
36
48
  }
37
49
  self.run_id = None
38
50
  self.test_start_times = {}
39
51
  self.test_logs = {} # Store logs per test
52
+ self.existing_results = {} # Store existing test results in append mode
40
53
 
41
54
  @pytest.hookimpl(tryfirst=True)
42
55
  def pytest_sessionstart(self, session):
@@ -48,7 +61,22 @@ class TestReportingPlugin:
48
61
  first_arg = session.config.args[0]
49
62
  self.run_data['suite_name'] = Path(first_arg).stem
50
63
 
51
- logging.info(f"[Reporting] Starting test run: {self.run_data['suite_name']}")
64
+ # Handle append mode - find existing run or create session ID
65
+ if self.append_mode and self.session_id:
66
+ existing_run_id = self.storage.find_run_by_session_id(self.session_id)
67
+ if existing_run_id:
68
+ self.run_id = existing_run_id
69
+ self.existing_results = self.storage.get_existing_test_results(self.run_id)
70
+ logging.info(f"[Reporting] Appending to existing run ID: {self.run_id} (session: {self.session_id}, attempt: {self.attempt_number})")
71
+ else:
72
+ logging.info(f"[Reporting] Starting new multi-run session: {self.session_id} (attempt: {self.attempt_number})")
73
+ elif not self.session_id:
74
+ # Auto-generate session ID if not provided
75
+ self.session_id = str(uuid.uuid4())
76
+ self.run_data['session_id'] = self.session_id
77
+ logging.info(f"[Reporting] Starting test run: {self.run_data['suite_name']} (session: {self.session_id})")
78
+ else:
79
+ logging.info(f"[Reporting] Starting test run: {self.run_data['suite_name']}")
52
80
 
53
81
  @pytest.hookimpl(hookwrapper=True)
54
82
  def pytest_runtest_protocol(self, item, nextitem):
@@ -136,6 +164,9 @@ class TestReportingPlugin:
136
164
  # Get logs for this test
137
165
  test_log = self.test_logs.get(item.nodeid, '')
138
166
 
167
+ # Determine if this is a retry
168
+ is_retry = self.attempt_number > 1
169
+
139
170
  # Store test result
140
171
  test_result = {
141
172
  'project_name': self.config.PROJECT_NAME,
@@ -149,6 +180,8 @@ class TestReportingPlugin:
149
180
  'screenshot_path': screenshot_path,
150
181
  'trace_path': trace_path,
151
182
  'logs': test_log[:5000] if test_log else None, # Limit log size to 5000 chars
183
+ 'attempt_number': self.attempt_number,
184
+ 'is_retry': is_retry,
152
185
  }
153
186
 
154
187
  # Add failure classification if available
@@ -181,6 +214,10 @@ class TestReportingPlugin:
181
214
  @pytest.hookimpl(trylast=True)
182
215
  def pytest_sessionfinish(self, session, exitstatus):
183
216
  """Called at the end of the test session."""
217
+ # In append mode, merge with existing results
218
+ if self.append_mode and self.run_id and self.existing_results:
219
+ self._merge_with_existing_results()
220
+
184
221
  # Calculate final metrics
185
222
  total = self.run_data['total_tests']
186
223
 
@@ -210,10 +247,22 @@ class TestReportingPlugin:
210
247
  self.run_data['functional_pass_rate'] = 0
211
248
 
212
249
  # Save to database
213
- self.run_id = self.storage.save_test_run(self.run_data)
250
+ if self.append_mode and self.run_id:
251
+ # Update existing run
252
+ self.storage.update_run_metrics(self.run_id, self.run_data)
253
+ else:
254
+ # Create new run
255
+ self.run_id = self.storage.save_test_run(self.run_data)
214
256
 
215
- # Save individual test results
257
+ # Save individual test results (only new ones in append mode)
216
258
  for test_result in self.run_data['tests']:
259
+ # Skip if this test already exists and hasn't changed
260
+ if self.append_mode and test_result['full_name'] in self.existing_results:
261
+ existing = self.existing_results[test_result['full_name']]
262
+ # Only save if it's a new attempt
263
+ if test_result.get('attempt_number', 1) <= existing.get('attempt_number', 1):
264
+ continue
265
+
217
266
  result_id = self.storage.save_test_result(self.run_id, test_result)
218
267
 
219
268
  # TODO: Extract and save steps from logs
@@ -222,6 +271,8 @@ class TestReportingPlugin:
222
271
  self._save_latest_json()
223
272
 
224
273
  logging.info(f"[Reporting] Test run complete. Run ID: {self.run_id}")
274
+ if self.append_mode:
275
+ logging.info(f"[Reporting] Append mode: attempt {self.attempt_number}")
225
276
  logging.info(f"[Reporting] Pass Rate: {self.run_data['pass_rate']:.1f}%")
226
277
  logging.info(f"[Reporting] Functional Pass Rate: {self.run_data['functional_pass_rate']:.1f}%")
227
278
 
@@ -234,6 +285,41 @@ class TestReportingPlugin:
234
285
  except Exception as e:
235
286
  logging.warning(f"[Reporting] Auto-publish failed: {e}")
236
287
 
288
+ def _merge_with_existing_results(self):
289
+ """Merge current test results with existing results from previous attempts."""
290
+ # Create a map of current test results by full_name
291
+ current_tests = {t['full_name']: t for t in self.run_data['tests']}
292
+
293
+ # Add existing tests that weren't run in this attempt
294
+ for full_name, existing_test in self.existing_results.items():
295
+ if full_name not in current_tests:
296
+ # Convert DB result to test result format
297
+ test_result = {
298
+ 'project_name': existing_test['project_name'],
299
+ 'file_name': existing_test['file_name'],
300
+ 'test_name': existing_test['test_name'],
301
+ 'full_name': existing_test['full_name'],
302
+ 'status': existing_test['status'],
303
+ 'duration_seconds': existing_test['duration_seconds'],
304
+ 'error_message': existing_test.get('error_message'),
305
+ 'error_type': existing_test.get('error_type'),
306
+ 'screenshot_path': existing_test.get('screenshot_path'),
307
+ 'trace_path': existing_test.get('trace_path'),
308
+ 'logs': existing_test.get('logs'),
309
+ 'attempt_number': existing_test.get('attempt_number', 1),
310
+ 'is_retry': existing_test.get('is_retry', False),
311
+ 'failure_type': existing_test.get('failure_type'),
312
+ 'failure_category': existing_test.get('failure_category'),
313
+ 'is_soft_fail': existing_test.get('is_soft_fail', False),
314
+ }
315
+ self.run_data['tests'].append(test_result)
316
+
317
+ # Recalculate totals
318
+ self.run_data['total_tests'] = len(self.run_data['tests'])
319
+ self.run_data['passed'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'passed')
320
+ self.run_data['failed'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'failed')
321
+ self.run_data['skipped'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'skipped')
322
+
237
323
  def _save_latest_json(self):
238
324
  """Save latest run data as JSON for dashboard."""
239
325
  output = {
@@ -46,6 +46,8 @@ class TestResultStorage:
46
46
  duration_seconds REAL,
47
47
  pass_rate REAL,
48
48
  functional_pass_rate REAL,
49
+ session_id TEXT,
50
+ is_append_mode BOOLEAN DEFAULT 0,
49
51
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
50
52
  )
51
53
  ''')
@@ -68,6 +70,8 @@ class TestResultStorage:
68
70
  screenshot_path TEXT,
69
71
  trace_path TEXT,
70
72
  retry_count INTEGER DEFAULT 0,
73
+ attempt_number INTEGER DEFAULT 1,
74
+ is_retry BOOLEAN DEFAULT 0,
71
75
  logs TEXT,
72
76
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
73
77
  FOREIGN KEY (run_id) REFERENCES test_runs(id)
@@ -122,6 +126,34 @@ class TestResultStorage:
122
126
  except sqlite3.OperationalError:
123
127
  conn.execute('ALTER TABLE test_results ADD COLUMN project_name TEXT')
124
128
  print("[Reporting] Migration: Added 'project_name' column to test_results")
129
+
130
+ # Migration: Add session_id to test_runs
131
+ try:
132
+ conn.execute('SELECT session_id FROM test_runs LIMIT 1')
133
+ except sqlite3.OperationalError:
134
+ conn.execute('ALTER TABLE test_runs ADD COLUMN session_id TEXT')
135
+ print("[Reporting] Migration: Added 'session_id' column to test_runs")
136
+
137
+ # Migration: Add is_append_mode to test_runs
138
+ try:
139
+ conn.execute('SELECT is_append_mode FROM test_runs LIMIT 1')
140
+ except sqlite3.OperationalError:
141
+ conn.execute('ALTER TABLE test_runs ADD COLUMN is_append_mode BOOLEAN DEFAULT 0')
142
+ print("[Reporting] Migration: Added 'is_append_mode' column to test_runs")
143
+
144
+ # Migration: Add attempt_number to test_results
145
+ try:
146
+ conn.execute('SELECT attempt_number FROM test_results LIMIT 1')
147
+ except sqlite3.OperationalError:
148
+ conn.execute('ALTER TABLE test_results ADD COLUMN attempt_number INTEGER DEFAULT 1')
149
+ print("[Reporting] Migration: Added 'attempt_number' column to test_results")
150
+
151
+ # Migration: Add is_retry to test_results
152
+ try:
153
+ conn.execute('SELECT is_retry FROM test_results LIMIT 1')
154
+ except sqlite3.OperationalError:
155
+ conn.execute('ALTER TABLE test_results ADD COLUMN is_retry BOOLEAN DEFAULT 0')
156
+ print("[Reporting] Migration: Added 'is_retry' column to test_results")
125
157
 
126
158
  def save_test_run(self, run_data: Dict[str, Any]) -> int:
127
159
  """Save a test run and return the run ID."""
@@ -130,8 +162,9 @@ class TestResultStorage:
130
162
  INSERT INTO test_runs (
131
163
  project_name, timestamp, suite_name, build_number, build_url,
132
164
  total_tests, passed, failed, skipped,
133
- duration_seconds, pass_rate, functional_pass_rate
134
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
165
+ duration_seconds, pass_rate, functional_pass_rate,
166
+ session_id, is_append_mode
167
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
135
168
  ''', (
136
169
  run_data.get('project_name', self.config.PROJECT_NAME),
137
170
  run_data.get('timestamp', datetime.now().isoformat()),
@@ -145,6 +178,8 @@ class TestResultStorage:
145
178
  run_data.get('duration_seconds', 0),
146
179
  run_data.get('pass_rate', 0),
147
180
  run_data.get('functional_pass_rate', 0),
181
+ run_data.get('session_id'),
182
+ run_data.get('is_append_mode', False),
148
183
  ))
149
184
  conn.commit()
150
185
  return cursor.lastrowid
@@ -157,8 +192,8 @@ class TestResultStorage:
157
192
  run_id, project_name, file_name, test_name, full_name, status,
158
193
  failure_type, failure_category, is_soft_fail,
159
194
  duration_seconds, error_message, error_type,
160
- screenshot_path, trace_path, retry_count, logs
161
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195
+ screenshot_path, trace_path, retry_count, attempt_number, is_retry, logs
196
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
162
197
  ''', (
163
198
  run_id,
164
199
  result_data.get('project_name', self.config.PROJECT_NAME),
@@ -175,6 +210,8 @@ class TestResultStorage:
175
210
  result_data.get('screenshot_path'),
176
211
  result_data.get('trace_path'),
177
212
  result_data.get('retry_count', 0),
213
+ result_data.get('attempt_number', 1),
214
+ result_data.get('is_retry', False),
178
215
  result_data.get('logs'),
179
216
  ))
180
217
  conn.commit()
@@ -196,6 +233,56 @@ class TestResultStorage:
196
233
  ))
197
234
  conn.commit()
198
235
 
236
+ def find_run_by_session_id(self, session_id: str) -> Optional[int]:
237
+ """Find an existing run by session_id. Returns run_id or None."""
238
+ if not session_id:
239
+ return None
240
+
241
+ with sqlite3.connect(self.db_path) as conn:
242
+ cursor = conn.execute('''
243
+ SELECT id FROM test_runs
244
+ WHERE session_id = ?
245
+ ORDER BY created_at DESC
246
+ LIMIT 1
247
+ ''', (session_id,))
248
+ row = cursor.fetchone()
249
+ return row[0] if row else None
250
+
251
+ def update_run_metrics(self, run_id: int, run_data: Dict[str, Any]):
252
+ """Update metrics for an existing test run (used in append mode)."""
253
+ with sqlite3.connect(self.db_path) as conn:
254
+ conn.execute('''
255
+ UPDATE test_runs
256
+ SET total_tests = ?,
257
+ passed = ?,
258
+ failed = ?,
259
+ skipped = ?,
260
+ duration_seconds = ?,
261
+ pass_rate = ?,
262
+ functional_pass_rate = ?
263
+ WHERE id = ?
264
+ ''', (
265
+ run_data.get('total_tests', 0),
266
+ run_data.get('passed', 0),
267
+ run_data.get('failed', 0),
268
+ run_data.get('skipped', 0),
269
+ run_data.get('duration_seconds', 0),
270
+ run_data.get('pass_rate', 0),
271
+ run_data.get('functional_pass_rate', 0),
272
+ run_id,
273
+ ))
274
+ conn.commit()
275
+
276
+ def get_existing_test_results(self, run_id: int) -> Dict[str, Dict[str, Any]]:
277
+ """Get existing test results for a run, keyed by full_name."""
278
+ with sqlite3.connect(self.db_path) as conn:
279
+ conn.row_factory = sqlite3.Row
280
+ cursor = conn.execute('''
281
+ SELECT * FROM test_results
282
+ WHERE run_id = ?
283
+ ''', (run_id,))
284
+ return {row['full_name']: dict(row) for row in cursor.fetchall()}
285
+
199
286
  def get_all_runs_with_details(self, project_name: str = None) -> List[Dict[str, Any]]:
200
287
  """Get all test runs with test file details (no limit)."""
201
288
  with sqlite3.connect(self.db_path) as conn:
@@ -9,7 +9,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
9
9
 
10
10
  setup(
11
11
  name='test-reporting',
12
- version='3.2.6',
12
+ version='3.2.8',
13
13
  description='Multi-project test reporting dashboard — collect results locally or push to S3',
14
14
  long_description=long_description,
15
15
  long_description_content_type="text/markdown",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: test-reporting
3
- Version: 3.2.6
3
+ Version: 3.2.8
4
4
  Summary: Multi-project test reporting dashboard — collect results locally or push to S3
5
5
  Home-page: https://github.com/amahdy77/test-reporting.git
6
6
  Author: Ashfaqur Mahdy
File without changes
File without changes
File without changes