test-reporting 3.2.6__tar.gz → 3.2.7__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.7}/PKG-INFO +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/plugin.py +87 -4
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/storage.py +91 -4
- {test_reporting-3.2.6 → test_reporting-3.2.7}/setup.py +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.7/test_reporting.egg-info}/PKG-INFO +1 -1
- {test_reporting-3.2.6 → test_reporting-3.2.7}/LICENSE +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/README.md +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/__init__.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/classifier.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/cli.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/config.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/publisher.py +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/templates/index.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/templates/project.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/reporting/templates/run.html +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/setup.cfg +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/test_reporting.egg-info/SOURCES.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/test_reporting.egg-info/dependency_links.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/test_reporting.egg-info/entry_points.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/test_reporting.egg-info/requires.txt +0 -0
- {test_reporting-3.2.6 → test_reporting-3.2.7}/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,12 @@ 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
|
+
|
|
24
31
|
self.run_data = {
|
|
25
32
|
'project_name': self.config.PROJECT_NAME,
|
|
26
33
|
'timestamp': datetime.now().isoformat(),
|
|
@@ -33,10 +40,13 @@ class TestReportingPlugin:
|
|
|
33
40
|
'failed': 0,
|
|
34
41
|
'skipped': 0,
|
|
35
42
|
'duration_seconds': 0,
|
|
43
|
+
'session_id': self.session_id,
|
|
44
|
+
'is_append_mode': self.append_mode,
|
|
36
45
|
}
|
|
37
46
|
self.run_id = None
|
|
38
47
|
self.test_start_times = {}
|
|
39
48
|
self.test_logs = {} # Store logs per test
|
|
49
|
+
self.existing_results = {} # Store existing test results in append mode
|
|
40
50
|
|
|
41
51
|
@pytest.hookimpl(tryfirst=True)
|
|
42
52
|
def pytest_sessionstart(self, session):
|
|
@@ -48,7 +58,22 @@ class TestReportingPlugin:
|
|
|
48
58
|
first_arg = session.config.args[0]
|
|
49
59
|
self.run_data['suite_name'] = Path(first_arg).stem
|
|
50
60
|
|
|
51
|
-
|
|
61
|
+
# Handle append mode - find existing run or create session ID
|
|
62
|
+
if self.append_mode and self.session_id:
|
|
63
|
+
existing_run_id = self.storage.find_run_by_session_id(self.session_id)
|
|
64
|
+
if existing_run_id:
|
|
65
|
+
self.run_id = existing_run_id
|
|
66
|
+
self.existing_results = self.storage.get_existing_test_results(self.run_id)
|
|
67
|
+
logging.info(f"[Reporting] Appending to existing run ID: {self.run_id} (session: {self.session_id}, attempt: {self.attempt_number})")
|
|
68
|
+
else:
|
|
69
|
+
logging.info(f"[Reporting] Starting new multi-run session: {self.session_id} (attempt: {self.attempt_number})")
|
|
70
|
+
elif not self.session_id:
|
|
71
|
+
# Auto-generate session ID if not provided
|
|
72
|
+
self.session_id = str(uuid.uuid4())
|
|
73
|
+
self.run_data['session_id'] = self.session_id
|
|
74
|
+
logging.info(f"[Reporting] Starting test run: {self.run_data['suite_name']} (session: {self.session_id})")
|
|
75
|
+
else:
|
|
76
|
+
logging.info(f"[Reporting] Starting test run: {self.run_data['suite_name']}")
|
|
52
77
|
|
|
53
78
|
@pytest.hookimpl(hookwrapper=True)
|
|
54
79
|
def pytest_runtest_protocol(self, item, nextitem):
|
|
@@ -136,6 +161,9 @@ class TestReportingPlugin:
|
|
|
136
161
|
# Get logs for this test
|
|
137
162
|
test_log = self.test_logs.get(item.nodeid, '')
|
|
138
163
|
|
|
164
|
+
# Determine if this is a retry
|
|
165
|
+
is_retry = self.attempt_number > 1
|
|
166
|
+
|
|
139
167
|
# Store test result
|
|
140
168
|
test_result = {
|
|
141
169
|
'project_name': self.config.PROJECT_NAME,
|
|
@@ -149,6 +177,8 @@ class TestReportingPlugin:
|
|
|
149
177
|
'screenshot_path': screenshot_path,
|
|
150
178
|
'trace_path': trace_path,
|
|
151
179
|
'logs': test_log[:5000] if test_log else None, # Limit log size to 5000 chars
|
|
180
|
+
'attempt_number': self.attempt_number,
|
|
181
|
+
'is_retry': is_retry,
|
|
152
182
|
}
|
|
153
183
|
|
|
154
184
|
# Add failure classification if available
|
|
@@ -181,6 +211,10 @@ class TestReportingPlugin:
|
|
|
181
211
|
@pytest.hookimpl(trylast=True)
|
|
182
212
|
def pytest_sessionfinish(self, session, exitstatus):
|
|
183
213
|
"""Called at the end of the test session."""
|
|
214
|
+
# In append mode, merge with existing results
|
|
215
|
+
if self.append_mode and self.run_id and self.existing_results:
|
|
216
|
+
self._merge_with_existing_results()
|
|
217
|
+
|
|
184
218
|
# Calculate final metrics
|
|
185
219
|
total = self.run_data['total_tests']
|
|
186
220
|
|
|
@@ -210,10 +244,22 @@ class TestReportingPlugin:
|
|
|
210
244
|
self.run_data['functional_pass_rate'] = 0
|
|
211
245
|
|
|
212
246
|
# Save to database
|
|
213
|
-
self.
|
|
247
|
+
if self.append_mode and self.run_id:
|
|
248
|
+
# Update existing run
|
|
249
|
+
self.storage.update_run_metrics(self.run_id, self.run_data)
|
|
250
|
+
else:
|
|
251
|
+
# Create new run
|
|
252
|
+
self.run_id = self.storage.save_test_run(self.run_data)
|
|
214
253
|
|
|
215
|
-
# Save individual test results
|
|
254
|
+
# Save individual test results (only new ones in append mode)
|
|
216
255
|
for test_result in self.run_data['tests']:
|
|
256
|
+
# Skip if this test already exists and hasn't changed
|
|
257
|
+
if self.append_mode and test_result['full_name'] in self.existing_results:
|
|
258
|
+
existing = self.existing_results[test_result['full_name']]
|
|
259
|
+
# Only save if it's a new attempt
|
|
260
|
+
if test_result.get('attempt_number', 1) <= existing.get('attempt_number', 1):
|
|
261
|
+
continue
|
|
262
|
+
|
|
217
263
|
result_id = self.storage.save_test_result(self.run_id, test_result)
|
|
218
264
|
|
|
219
265
|
# TODO: Extract and save steps from logs
|
|
@@ -222,6 +268,8 @@ class TestReportingPlugin:
|
|
|
222
268
|
self._save_latest_json()
|
|
223
269
|
|
|
224
270
|
logging.info(f"[Reporting] Test run complete. Run ID: {self.run_id}")
|
|
271
|
+
if self.append_mode:
|
|
272
|
+
logging.info(f"[Reporting] Append mode: attempt {self.attempt_number}")
|
|
225
273
|
logging.info(f"[Reporting] Pass Rate: {self.run_data['pass_rate']:.1f}%")
|
|
226
274
|
logging.info(f"[Reporting] Functional Pass Rate: {self.run_data['functional_pass_rate']:.1f}%")
|
|
227
275
|
|
|
@@ -234,6 +282,41 @@ class TestReportingPlugin:
|
|
|
234
282
|
except Exception as e:
|
|
235
283
|
logging.warning(f"[Reporting] Auto-publish failed: {e}")
|
|
236
284
|
|
|
285
|
+
def _merge_with_existing_results(self):
|
|
286
|
+
"""Merge current test results with existing results from previous attempts."""
|
|
287
|
+
# Create a map of current test results by full_name
|
|
288
|
+
current_tests = {t['full_name']: t for t in self.run_data['tests']}
|
|
289
|
+
|
|
290
|
+
# Add existing tests that weren't run in this attempt
|
|
291
|
+
for full_name, existing_test in self.existing_results.items():
|
|
292
|
+
if full_name not in current_tests:
|
|
293
|
+
# Convert DB result to test result format
|
|
294
|
+
test_result = {
|
|
295
|
+
'project_name': existing_test['project_name'],
|
|
296
|
+
'file_name': existing_test['file_name'],
|
|
297
|
+
'test_name': existing_test['test_name'],
|
|
298
|
+
'full_name': existing_test['full_name'],
|
|
299
|
+
'status': existing_test['status'],
|
|
300
|
+
'duration_seconds': existing_test['duration_seconds'],
|
|
301
|
+
'error_message': existing_test.get('error_message'),
|
|
302
|
+
'error_type': existing_test.get('error_type'),
|
|
303
|
+
'screenshot_path': existing_test.get('screenshot_path'),
|
|
304
|
+
'trace_path': existing_test.get('trace_path'),
|
|
305
|
+
'logs': existing_test.get('logs'),
|
|
306
|
+
'attempt_number': existing_test.get('attempt_number', 1),
|
|
307
|
+
'is_retry': existing_test.get('is_retry', False),
|
|
308
|
+
'failure_type': existing_test.get('failure_type'),
|
|
309
|
+
'failure_category': existing_test.get('failure_category'),
|
|
310
|
+
'is_soft_fail': existing_test.get('is_soft_fail', False),
|
|
311
|
+
}
|
|
312
|
+
self.run_data['tests'].append(test_result)
|
|
313
|
+
|
|
314
|
+
# Recalculate totals
|
|
315
|
+
self.run_data['total_tests'] = len(self.run_data['tests'])
|
|
316
|
+
self.run_data['passed'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'passed')
|
|
317
|
+
self.run_data['failed'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'failed')
|
|
318
|
+
self.run_data['skipped'] = sum(1 for t in self.run_data['tests'] if t['status'] == 'skipped')
|
|
319
|
+
|
|
237
320
|
def _save_latest_json(self):
|
|
238
321
|
"""Save latest run data as JSON for dashboard."""
|
|
239
322
|
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.7',
|
|
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
|