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 +6 -0
- reporting/classifier.py +109 -0
- reporting/cli.py +209 -0
- reporting/config.py +114 -0
- reporting/dashboard_generator.py +464 -0
- reporting/dashboard_generator_v2.py +819 -0
- reporting/history_dashboard.py +547 -0
- reporting/modern_design_system.py +393 -0
- reporting/overview_dashboard.py +1255 -0
- reporting/plugin.py +317 -0
- reporting/projects_dashboard.py +337 -0
- reporting/storage.py +1038 -0
- test_reporting-1.0.0.dist-info/METADATA +391 -0
- test_reporting-1.0.0.dist-info/RECORD +18 -0
- test_reporting-1.0.0.dist-info/WHEEL +5 -0
- test_reporting-1.0.0.dist-info/entry_points.txt +2 -0
- test_reporting-1.0.0.dist-info/licenses/LICENSE +21 -0
- test_reporting-1.0.0.dist-info/top_level.txt +1 -0
reporting/__init__.py
ADDED
reporting/classifier.py
ADDED
|
@@ -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)
|