test-reporting 1.0.0__py3-none-any.whl

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.
reporting/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """
2
+ Test Reporting System
3
+ Automatic test result collection, analysis, and dashboard generation.
4
+ """
5
+
6
+ __version__ = "1.0.0"
@@ -0,0 +1,109 @@
1
+ """
2
+ Failure Classification
3
+ Automatically categorizes test failures into different types.
4
+ """
5
+ import re
6
+ from typing import Optional, Dict, Any
7
+ from .config import ReportingConfig
8
+
9
+
10
+ class FailureClassifier:
11
+ """Classifies test failures into categories."""
12
+
13
+ FAILURE_TYPES = {
14
+ 'ASSERTION': 'Hard Failure - Assertion Error',
15
+ 'UI_TIMEOUT': 'Soft Failure - UI Timeout',
16
+ 'API_TIMEOUT': 'Soft Failure - API Timeout',
17
+ 'ENVIRONMENT': 'Environment Issue',
18
+ 'FLAKY': 'Flaky Test - Passed on Retry',
19
+ 'UNKNOWN': 'Unknown Error',
20
+ }
21
+
22
+ @staticmethod
23
+ def classify(
24
+ exception_type: str,
25
+ exception_message: str,
26
+ test_name: str = '',
27
+ duration_ms: float = 0,
28
+ retry_count: int = 0
29
+ ) -> Dict[str, Any]:
30
+ """
31
+ Classify a test failure.
32
+
33
+ Args:
34
+ exception_type: Type of exception (e.g., 'AssertionError')
35
+ exception_message: Exception message
36
+ test_name: Name of the test
37
+ duration_ms: Test duration in milliseconds
38
+ retry_count: Number of retries (0 if passed first time)
39
+
40
+ Returns:
41
+ Dict with classification info
42
+ """
43
+ failure_type = 'UNKNOWN'
44
+ is_soft_fail = False
45
+ category = 'functional'
46
+
47
+ # Check if passed on retry (flaky)
48
+ if retry_count > 0:
49
+ failure_type = 'FLAKY'
50
+ is_soft_fail = True
51
+ category = 'stability'
52
+
53
+ # Check for assertion errors (hard failures)
54
+ elif 'AssertionError' in exception_type or 'assert' in exception_message.lower():
55
+ failure_type = 'ASSERTION'
56
+ is_soft_fail = False
57
+ category = 'functional'
58
+
59
+ # Check for UI timeouts (soft failures)
60
+ elif any(pattern in exception_message for pattern in ReportingConfig.SOFT_FAIL_PATTERNS):
61
+ failure_type = 'UI_TIMEOUT'
62
+ is_soft_fail = True
63
+ category = 'performance'
64
+
65
+ # Check for environment issues
66
+ elif any(pattern in exception_message.lower() for pattern in ReportingConfig.ENVIRONMENT_FAIL_PATTERNS):
67
+ failure_type = 'ENVIRONMENT'
68
+ is_soft_fail = True
69
+ category = 'infrastructure'
70
+
71
+ # Check for API timeouts
72
+ elif 'timeout' in exception_message.lower() and duration_ms > ReportingConfig.TIMEOUT_API_MAX:
73
+ failure_type = 'API_TIMEOUT'
74
+ is_soft_fail = True
75
+ category = 'performance'
76
+
77
+ return {
78
+ 'failure_type': failure_type,
79
+ 'failure_description': FailureClassifier.FAILURE_TYPES.get(failure_type, 'Unknown'),
80
+ 'is_soft_fail': is_soft_fail,
81
+ 'category': category,
82
+ 'should_count_in_pass_rate': not is_soft_fail,
83
+ }
84
+
85
+ @staticmethod
86
+ def extract_timeout_value(error_message: str) -> Optional[int]:
87
+ """Extract timeout value from error message."""
88
+ # Look for patterns like "Timeout 30000ms exceeded"
89
+ match = re.search(r'Timeout (\d+)ms', error_message)
90
+ if match:
91
+ return int(match.group(1))
92
+
93
+ # Look for patterns like "timeout of 30000ms exceeded"
94
+ match = re.search(r'timeout of (\d+)ms', error_message)
95
+ if match:
96
+ return int(match.group(1))
97
+
98
+ return None
99
+
100
+ @staticmethod
101
+ def is_performance_issue(duration_ms: float, failure_type: str) -> bool:
102
+ """Check if failure is performance-related."""
103
+ if failure_type in ['UI_TIMEOUT', 'API_TIMEOUT']:
104
+ return True
105
+
106
+ if duration_ms > ReportingConfig.TIMEOUT_UI_WARNING:
107
+ return True
108
+
109
+ return False
reporting/cli.py ADDED
@@ -0,0 +1,209 @@
1
+ """
2
+ CLI tool for reporting system
3
+ """
4
+ import sys
5
+ import webbrowser
6
+ from pathlib import Path
7
+ from .dashboard_generator_v2 import EnhancedDashboardGenerator
8
+ from .overview_dashboard import OverviewDashboardGenerator
9
+ from .history_dashboard import HistoryDashboardGenerator
10
+ from .projects_dashboard import ProjectsDashboardGenerator
11
+ from .config import ReportingConfig
12
+
13
+
14
+ def generate_dashboard():
15
+ """Generate all dashboards (projects landing, overview, run-details, and history)."""
16
+ print("[Reporting] Generating dashboards...")
17
+
18
+ # Create config instance
19
+ config = ReportingConfig()
20
+ print(f"[Reporting] Project: {config.PROJECT_NAME}")
21
+ print(f"[Reporting] Database: {config.DB_PATH}")
22
+
23
+ # Generate projects landing page (index.html)
24
+ print("\n[Reporting] Generating projects landing page...")
25
+ projects_gen = ProjectsDashboardGenerator(config=config)
26
+ projects_path = projects_gen.generate()
27
+
28
+ # Generate overview dashboard (overview.html)
29
+ print("\n[Reporting] Generating overview dashboard...")
30
+ overview_gen = OverviewDashboardGenerator(config=config)
31
+ overview_path = overview_gen.generate(output_path=config.DASHBOARD_DIR / 'overview.html')
32
+
33
+ # Generate run-details dashboard
34
+ print("\n[Reporting] Generating run-details dashboard...")
35
+ details_gen = EnhancedDashboardGenerator(config=config)
36
+ details_path = details_gen.generate()
37
+
38
+ # Generate history dashboard
39
+ print("\n[Reporting] Generating test history dashboard...")
40
+ history_gen = HistoryDashboardGenerator(config=config)
41
+ history_path = history_gen.generate()
42
+
43
+ print(f"\n[Reporting] All dashboards generated successfully!")
44
+ print(f" Projects: {projects_path}")
45
+ print(f" Overview: {overview_path}")
46
+ print(f" Details: {details_path}")
47
+ print(f" History: {history_path}")
48
+
49
+ return projects_path
50
+
51
+
52
+ def open_dashboard():
53
+ """Generate and open overview dashboard in browser."""
54
+ overview_path = generate_dashboard()
55
+ print(f"\n[Reporting] Opening overview dashboard in browser...")
56
+ webbrowser.open(f"file:///{overview_path.absolute()}")
57
+
58
+
59
+ def show_stats():
60
+ """Show quick stats from latest run."""
61
+ import json
62
+
63
+ if not ReportingConfig.LATEST_JSON_PATH.exists():
64
+ print("[ERROR] No test data found. Run tests first.")
65
+ return
66
+
67
+ with open(ReportingConfig.LATEST_JSON_PATH, 'r') as f:
68
+ data = json.load(f)
69
+
70
+ metrics = data.get('metrics', {})
71
+ status = data.get('status', {})
72
+
73
+ print("\n" + "="*60)
74
+ print(f" TEST RESULTS SUMMARY")
75
+ print("="*60)
76
+ print(f"Build: #{data.get('build_number', 'N/A')}")
77
+ print(f"Suite: {data.get('suite_name', 'N/A')}")
78
+ print(f"Time: {data.get('timestamp', 'N/A')}")
79
+ print("-"*60)
80
+ print(f"Health Score: {metrics.get('health_score', 0)}/100")
81
+ print(f"Pass Rate: {metrics.get('pass_rate', 0):.1f}%")
82
+ print(f"Functional Pass Rate: {metrics.get('functional_pass_rate', 0):.1f}%")
83
+ print(f"Total Tests: {metrics.get('total_tests', 0)}")
84
+ print(f" Passed: {metrics.get('passed', 0)}")
85
+ print(f" Failed: {metrics.get('failed', 0)}")
86
+ print(f" Skipped: {metrics.get('skipped', 0)}")
87
+ print("="*60)
88
+
89
+ if data.get('top_failures'):
90
+ print("\nTop Failures:")
91
+ for i, failure in enumerate(data['top_failures'][:3], 1):
92
+ print(f"\n{i}. {failure['test_name']}")
93
+ print(f" File: {failure['file_name']}")
94
+ print(f" Type: {failure.get('failure_type', 'UNKNOWN')}")
95
+
96
+
97
+ def show_suites():
98
+ """Show statistics for all test suites."""
99
+ from .storage import TestResultStorage
100
+
101
+ storage = TestResultStorage()
102
+ suites = storage.get_suite_statistics()
103
+
104
+ if not suites:
105
+ print("[ERROR] No suite data found. Run test suites first.")
106
+ return
107
+
108
+ print("\n" + "="*80)
109
+ print(f" TEST SUITE STATISTICS")
110
+ print("="*80)
111
+
112
+ for suite in suites:
113
+ suite_name = suite['suite_name']
114
+ total_runs = suite['total_runs']
115
+ avg_duration = suite['avg_duration']
116
+
117
+ # Format duration
118
+ if avg_duration >= 60:
119
+ mins = int(avg_duration // 60)
120
+ secs = int(avg_duration % 60)
121
+ duration_str = f"{mins}m {secs}s"
122
+ else:
123
+ duration_str = f"{avg_duration:.1f}s"
124
+
125
+ print(f"\nSuite: {suite_name}")
126
+ print(f" Total Runs: {total_runs}")
127
+ print(f" Avg Pass Rate: {suite['avg_pass_rate']:.1f}%")
128
+ print(f" Avg Duration: {duration_str}")
129
+ print(f" Total Tests: {suite['total_tests']}")
130
+ print(f" Last Run: {suite['last_run']}")
131
+
132
+ print("\n" + "="*80)
133
+
134
+
135
+ def cleanup_old_data():
136
+ """Clean up old test data based on retention policy."""
137
+ from .storage import TestResultStorage
138
+
139
+ config = ReportingConfig()
140
+ storage = TestResultStorage(config=config)
141
+
142
+ print(f"\n[Reporting] Cleaning up data older than {config.RETENTION_DAYS} days...")
143
+ print(f"[Reporting] Database: {config.DB_PATH}")
144
+
145
+ # Get count before cleanup
146
+ import sqlite3
147
+ with sqlite3.connect(config.DB_PATH) as conn:
148
+ cursor = conn.execute("SELECT COUNT(*) FROM test_runs")
149
+ before_count = cursor.fetchone()[0]
150
+
151
+ cursor = conn.execute(f"SELECT COUNT(*) FROM test_runs WHERE timestamp < datetime('now', '-{config.RETENTION_DAYS} days')")
152
+ to_delete = cursor.fetchone()[0]
153
+
154
+ if to_delete == 0:
155
+ print(f"[Reporting] No data older than {config.RETENTION_DAYS} days found. Nothing to clean up.")
156
+ return
157
+
158
+ print(f"[Reporting] Found {to_delete} test runs to delete (out of {before_count} total)")
159
+
160
+ # Confirm deletion
161
+ response = input(f"[Reporting] Delete {to_delete} old test runs? (yes/no): ")
162
+ if response.lower() not in ['yes', 'y']:
163
+ print("[Reporting] Cleanup cancelled.")
164
+ return
165
+
166
+ # Perform cleanup
167
+ storage.cleanup_old_data()
168
+
169
+ # Get count after cleanup
170
+ with sqlite3.connect(config.DB_PATH) as conn:
171
+ cursor = conn.execute("SELECT COUNT(*) FROM test_runs")
172
+ after_count = cursor.fetchone()[0]
173
+
174
+ deleted = before_count - after_count
175
+ print(f"[Reporting] ✅ Cleanup complete! Deleted {deleted} test runs.")
176
+ print(f"[Reporting] Remaining: {after_count} test runs")
177
+
178
+
179
+ def main():
180
+ """Main CLI entry point."""
181
+ if len(sys.argv) < 2:
182
+ print("Usage: python -m reporting.cli <command>")
183
+ print("\nCommands:")
184
+ print(" generate - Generate dashboard HTML")
185
+ print(" open - Generate and open dashboard in browser")
186
+ print(" stats - Show quick stats from latest run")
187
+ print(" suites - Show statistics for all test suites")
188
+ print(" cleanup - Clean up old test data (based on retention_days)")
189
+ return
190
+
191
+ command = sys.argv[1]
192
+
193
+ if command == 'generate':
194
+ generate_dashboard()
195
+ elif command == 'open':
196
+ open_dashboard()
197
+ elif command == 'stats':
198
+ show_stats()
199
+ elif command == 'suites':
200
+ show_suites()
201
+ elif command == 'cleanup':
202
+ cleanup_old_data()
203
+ else:
204
+ print(f"Unknown command: {command}")
205
+ sys.exit(1)
206
+
207
+
208
+ if __name__ == '__main__':
209
+ main()
reporting/config.py ADDED
@@ -0,0 +1,114 @@
1
+ """
2
+ Reporting Configuration
3
+ """
4
+ import os
5
+ import configparser
6
+ from pathlib import Path
7
+
8
+
9
+ class ReportingConfig:
10
+ """Configuration for the reporting system."""
11
+
12
+ def __init__(self, config_file='reporting.ini'):
13
+ """Initialize config from file or environment variables."""
14
+ self.config = configparser.ConfigParser()
15
+
16
+ # Try to read config file from current directory
17
+ config_path = Path.cwd() / config_file
18
+ if config_path.exists():
19
+ self.config.read(config_path)
20
+ self._load_from_file()
21
+ else:
22
+ self._load_from_defaults()
23
+
24
+ def _load_from_file(self):
25
+ """Load configuration from ini file."""
26
+ # Project info
27
+ self.PROJECT_NAME = self.config.get('reporting', 'project_name',
28
+ fallback='test-automation-project')
29
+
30
+ # Paths - support shared database
31
+ db_path_str = self.config.get('reporting', 'db_path',
32
+ fallback='test_results.db')
33
+ self.DB_PATH = Path(db_path_str)
34
+
35
+ dashboard_dir_str = self.config.get('reporting', 'dashboard_dir',
36
+ fallback='dashboard')
37
+ self.DASHBOARD_DIR = Path(dashboard_dir_str)
38
+
39
+ # Other directories (project-specific)
40
+ base_dir = Path.cwd()
41
+ self.BASE_DIR = base_dir
42
+ self.REPORTS_DIR = base_dir / 'reports'
43
+ self.SCREENSHOTS_DIR = base_dir / 'screenshots'
44
+ self.TRACES_DIR = base_dir / 'traces'
45
+ self.LATEST_JSON_PATH = self.REPORTS_DIR / 'latest.json'
46
+
47
+ # Retention
48
+ self.RETENTION_DAYS = self.config.getint('reporting', 'retention_days',
49
+ fallback=365)
50
+
51
+ def _load_from_defaults(self):
52
+ """Load default configuration."""
53
+ # Project info
54
+ self.PROJECT_NAME = os.getenv('PROJECT_NAME', 'test-automation-project')
55
+
56
+ # Paths
57
+ self.BASE_DIR = Path(__file__).parent.parent
58
+ self.REPORTS_DIR = self.BASE_DIR / 'reports'
59
+ self.DASHBOARD_DIR = self.BASE_DIR / 'dashboard'
60
+ self.SCREENSHOTS_DIR = self.BASE_DIR / 'screenshots'
61
+ self.TRACES_DIR = self.BASE_DIR / 'traces'
62
+
63
+ # Database - default to local location
64
+ self.DB_PATH = Path('test_results.db')
65
+ self.LATEST_JSON_PATH = self.REPORTS_DIR / 'latest.json'
66
+
67
+ # Retention
68
+ self.RETENTION_DAYS = 365
69
+
70
+ # Timeout thresholds (milliseconds)
71
+ TIMEOUT_UI_WARNING = 30000 # Warn if UI action > 30s
72
+ TIMEOUT_UI_MAX = 60000 # Hard fail if > 60s
73
+ TIMEOUT_API_WARNING = 10000
74
+ TIMEOUT_API_MAX = 30000
75
+
76
+ # Failure classification
77
+ SOFT_FAIL_PATTERNS = [
78
+ 'TimeoutError',
79
+ 'timeout',
80
+ 'wait_for_selector',
81
+ 'wait_for_load_state',
82
+ 'Page.waitForSelector',
83
+ ]
84
+
85
+ ENVIRONMENT_FAIL_PATTERNS = [
86
+ 'connection',
87
+ 'network',
88
+ 'database',
89
+ 'ECONNREFUSED',
90
+ 'ETIMEDOUT',
91
+ ]
92
+
93
+ # Data retention
94
+ RETENTION_MAX_RUNS = 1000
95
+
96
+ # Dashboard settings
97
+ DASHBOARD_AUTO_REFRESH_SECONDS = 30
98
+ DASHBOARD_SHOW_SCREENSHOTS = True
99
+ DASHBOARD_SHOW_TRACES = True
100
+
101
+ @property
102
+ def DASHBOARD_TITLE(self):
103
+ """Generate dashboard title with project name."""
104
+ return f"{self.PROJECT_NAME} - Test Automation Dashboard"
105
+
106
+ def ensure_directories(self):
107
+ """Create necessary directories if they don't exist."""
108
+ self.REPORTS_DIR.mkdir(exist_ok=True, parents=True)
109
+ self.DASHBOARD_DIR.mkdir(exist_ok=True, parents=True)
110
+ self.SCREENSHOTS_DIR.mkdir(exist_ok=True, parents=True)
111
+ self.TRACES_DIR.mkdir(exist_ok=True, parents=True)
112
+
113
+ # Ensure shared database directory exists
114
+ self.DB_PATH.parent.mkdir(exist_ok=True, parents=True)