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.
- {test_reporting-3.2.6/test_reporting.egg-info → test_reporting-3.2.8}/PKG-INFO +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/plugin.py +90 -4
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/storage.py +91 -4
- {test_reporting-3.2.6 → test_reporting-3.2.8}/setup.py +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.8/test_reporting.egg-info}/PKG-INFO +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.8}/LICENSE +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/README.md +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/__init__.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/classifier.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/cli.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/config.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/publisher.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/index.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/project.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/reporting/templates/run.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/setup.cfg +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/SOURCES.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/dependency_links.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/entry_points.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/requires.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.8}/test_reporting.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|