test-reporting 1.2.0__tar.gz → 1.2.2__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 (23) hide show
  1. {test_reporting-1.2.0 → test_reporting-1.2.2}/PKG-INFO +1 -1
  2. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/cli.py +4 -2
  3. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/dashboard_generator.py +84 -6
  4. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/dashboard_generator_v2.py +84 -6
  5. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/history_dashboard.py +2 -2
  6. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/overview_dashboard.py +89 -11
  7. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/projects_dashboard.py +77 -1
  8. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/storage.py +167 -79
  9. {test_reporting-1.2.0 → test_reporting-1.2.2}/setup.py +1 -1
  10. {test_reporting-1.2.0 → test_reporting-1.2.2}/test_reporting.egg-info/PKG-INFO +1 -1
  11. {test_reporting-1.2.0 → test_reporting-1.2.2}/LICENSE +0 -0
  12. {test_reporting-1.2.0 → test_reporting-1.2.2}/README.md +0 -0
  13. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/__init__.py +0 -0
  14. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/classifier.py +0 -0
  15. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/config.py +0 -0
  16. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/modern_design_system.py +0 -0
  17. {test_reporting-1.2.0 → test_reporting-1.2.2}/reporting/plugin.py +0 -0
  18. {test_reporting-1.2.0 → test_reporting-1.2.2}/setup.cfg +0 -0
  19. {test_reporting-1.2.0 → test_reporting-1.2.2}/test_reporting.egg-info/SOURCES.txt +0 -0
  20. {test_reporting-1.2.0 → test_reporting-1.2.2}/test_reporting.egg-info/dependency_links.txt +0 -0
  21. {test_reporting-1.2.0 → test_reporting-1.2.2}/test_reporting.egg-info/entry_points.txt +0 -0
  22. {test_reporting-1.2.0 → test_reporting-1.2.2}/test_reporting.egg-info/requires.txt +0 -0
  23. {test_reporting-1.2.0 → test_reporting-1.2.2}/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: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Beautiful multi-project test reporting dashboard system
5
5
  Home-page: https://github.com/amahdy77/test-reporting.git
6
6
  Author: Ashfaqur Mahdy
@@ -97,9 +97,11 @@ def show_stats():
97
97
  def show_suites():
98
98
  """Show statistics for all test suites."""
99
99
  from .storage import TestResultStorage
100
+ from .config import ReportingConfig
100
101
 
101
- storage = TestResultStorage()
102
- suites = storage.get_suite_statistics()
102
+ config = ReportingConfig()
103
+ storage = TestResultStorage(config=config)
104
+ suites = storage.get_suite_statistics(project_name=config.PROJECT_NAME)
103
105
 
104
106
  if not suites:
105
107
  print("[ERROR] No suite data found. Run test suites first.")
@@ -46,11 +46,11 @@ class DashboardGenerator:
46
46
 
47
47
  def _get_trend_data(self) -> List[Dict[str, Any]]:
48
48
  """Get trend data from database."""
49
- return self.storage.get_trend_data(days=30)
49
+ return self.storage.get_trend_data(days=30, project_name=self.config.PROJECT_NAME)
50
50
 
51
51
  def _get_failure_summary(self) -> Dict[str, Any]:
52
52
  """Get failure summary from database."""
53
- return self.storage.get_failure_summary(days=30)
53
+ return self.storage.get_failure_summary(days=30, project_name=self.config.PROJECT_NAME)
54
54
 
55
55
  def _generate_html(self, latest_run: Dict, trend_data: List, failure_summary: Dict) -> str:
56
56
  """Generate complete HTML dashboard that loads data dynamically."""
@@ -177,6 +177,47 @@ class DashboardGenerator:
177
177
  .metric-card.warning .value {{ color: #f59e0b; }}
178
178
  .metric-card.danger .value {{ color: #ef4444; }}
179
179
 
180
+ .metric-card .label {{
181
+ position: relative;
182
+ cursor: help;
183
+ }}
184
+
185
+ .tooltip {{
186
+ position: absolute;
187
+ background: #1f2937;
188
+ color: #f9fafb;
189
+ padding: 12px 16px;
190
+ border-radius: 8px;
191
+ font-size: 13px;
192
+ line-height: 1.5;
193
+ max-width: 300px;
194
+ z-index: 1000;
195
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
196
+ pointer-events: none;
197
+ opacity: 0;
198
+ transition: opacity 0.2s ease;
199
+ white-space: normal;
200
+ text-transform: none;
201
+ letter-spacing: normal;
202
+ font-weight: 400;
203
+ }}
204
+
205
+ .tooltip.show {{
206
+ opacity: 1;
207
+ }}
208
+
209
+ .tooltip::before {{
210
+ content: '';
211
+ position: absolute;
212
+ top: -6px;
213
+ left: 20px;
214
+ width: 0;
215
+ height: 0;
216
+ border-left: 6px solid transparent;
217
+ border-right: 6px solid transparent;
218
+ border-bottom: 6px solid #1f2937;
219
+ }}
220
+
180
221
  .charts-grid {{
181
222
  display: grid;
182
223
  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@@ -306,25 +347,25 @@ class DashboardGenerator:
306
347
 
307
348
  <div class="metrics-grid">
308
349
  <div class="metric-card ${{metrics.health_score >= 90 ? 'success' : metrics.health_score >= 70 ? 'warning' : 'danger'}}">
309
- <div class="label">Health Score</div>
350
+ <div class="label" data-tooltip="Based on Functional Pass Rate with penalties for soft failures (timeouts, flaky tests, environment issues). Calculation: Functional Pass Rate - (soft failures × 2, max -10 points)">Health Score</div>
310
351
  <div class="value">${{metrics.health_score || 0}}</div>
311
352
  <div class="trend">Overall system health</div>
312
353
  </div>
313
354
 
314
355
  <div class="metric-card success">
315
- <div class="label">Pass Rate</div>
356
+ <div class="label" data-tooltip="Percentage of all tests that passed. Calculation: (Passed Tests / Total Tests) × 100. Includes all test results regardless of failure type.">Pass Rate</div>
316
357
  <div class="value">${{metrics.pass_rate || 0}}%</div>
317
358
  <div class="trend">${{metrics.passed || 0}} passed, ${{metrics.failed || 0}} failed</div>
318
359
  </div>
319
360
 
320
361
  <div class="metric-card">
321
- <div class="label">Functional Pass Rate</div>
362
+ <div class="label" data-tooltip="Pass rate excluding soft failures (timeouts, flaky tests, environment issues). Calculation: (Total - Skipped - Hard Failures) / (Total - Skipped) × 100. Focuses on actual functional bugs.">Functional Pass Rate</div>
322
363
  <div class="value">${{metrics.functional_pass_rate || 0}}%</div>
323
364
  <div class="trend">Excluding timeouts</div>
324
365
  </div>
325
366
 
326
367
  <div class="metric-card">
327
- <div class="label">Total Tests</div>
368
+ <div class="label" data-tooltip="Total number of tests executed in this run. ✓ = Passed, ✗ = Failed, ⊘ = Skipped">Total Tests</div>
328
369
  <div class="value">${{metrics.total_tests || 0}}</div>
329
370
  <div class="trend">${{metrics.passed || 0}}✓ ${{metrics.failed || 0}}✗ ${{metrics.skipped || 0}}⊘</div>
330
371
  </div>
@@ -442,8 +483,45 @@ class DashboardGenerator:
442
483
  document.getElementById('failuresContent').innerHTML = html;
443
484
  }}
444
485
 
486
+ // Tooltip functionality
487
+ function initTooltips() {{
488
+ const labels = document.querySelectorAll('[data-tooltip]');
489
+
490
+ labels.forEach(label => {{
491
+ label.addEventListener('mouseenter', function(e) {{
492
+ const tooltipText = this.getAttribute('data-tooltip');
493
+ const tooltip = document.createElement('div');
494
+ tooltip.className = 'tooltip show';
495
+ tooltip.textContent = tooltipText;
496
+ tooltip.id = 'active-tooltip';
497
+
498
+ document.body.appendChild(tooltip);
499
+
500
+ const rect = this.getBoundingClientRect();
501
+ tooltip.style.left = rect.left + 'px';
502
+ tooltip.style.top = (rect.bottom + 10) + 'px';
503
+
504
+ // Adjust if tooltip goes off screen
505
+ const tooltipRect = tooltip.getBoundingClientRect();
506
+ if (tooltipRect.right > window.innerWidth) {{
507
+ tooltip.style.left = (window.innerWidth - tooltipRect.width - 20) + 'px';
508
+ }}
509
+ }});
510
+
511
+ label.addEventListener('mouseleave', function() {{
512
+ const tooltip = document.getElementById('active-tooltip');
513
+ if (tooltip) {{
514
+ tooltip.remove();
515
+ }}
516
+ }});
517
+ }});
518
+ }}
519
+
445
520
  // Render dashboard on load
446
521
  renderDashboard();
522
+
523
+ // Initialize tooltips after content is rendered
524
+ setTimeout(initTooltips, 100);
447
525
  </script>
448
526
  </body>
449
527
  </html>'''
@@ -40,7 +40,7 @@ class EnhancedDashboardGenerator:
40
40
  def _get_latest_run_with_tests(self) -> Dict[str, Any]:
41
41
  """Get latest run data with full test breakdown."""
42
42
  # Get latest run
43
- runs = self.storage.get_latest_runs(limit=1)
43
+ runs = self.storage.get_latest_runs(limit=1, project_name=self.config.PROJECT_NAME)
44
44
  if not runs:
45
45
  return {}
46
46
 
@@ -87,7 +87,7 @@ class EnhancedDashboardGenerator:
87
87
 
88
88
  def _get_trend_data(self) -> List[Dict[str, Any]]:
89
89
  """Get trend data from database."""
90
- return self.storage.get_trend_data(days=30)
90
+ return self.storage.get_trend_data(days=30, project_name=self.config.PROJECT_NAME)
91
91
 
92
92
  def _generate_html(self, latest_run_data: Dict, trend_data: List) -> str:
93
93
  """Generate complete HTML dashboard."""
@@ -262,6 +262,47 @@ class EnhancedDashboardGenerator:
262
262
  .metric-card.warning .value {{ color: #d97706; border-left-color: #f59e0b; }}
263
263
  .metric-card.danger .value {{ color: #dc2626; border-left-color: #ef4444; }}
264
264
 
265
+ .metric-card .label {{
266
+ position: relative;
267
+ cursor: help;
268
+ }}
269
+
270
+ .tooltip {{
271
+ position: absolute;
272
+ background: #1f2937;
273
+ color: #f9fafb;
274
+ padding: 12px 16px;
275
+ border-radius: 8px;
276
+ font-size: 13px;
277
+ line-height: 1.5;
278
+ max-width: 300px;
279
+ z-index: 1000;
280
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
281
+ pointer-events: none;
282
+ opacity: 0;
283
+ transition: opacity 0.2s ease;
284
+ white-space: normal;
285
+ text-transform: none;
286
+ letter-spacing: normal;
287
+ font-weight: 400;
288
+ }}
289
+
290
+ .tooltip.show {{
291
+ opacity: 1;
292
+ }}
293
+
294
+ .tooltip::before {{
295
+ content: '';
296
+ position: absolute;
297
+ top: -6px;
298
+ left: 20px;
299
+ width: 0;
300
+ height: 0;
301
+ border-left: 6px solid transparent;
302
+ border-right: 6px solid transparent;
303
+ border-bottom: 6px solid #1f2937;
304
+ }}
305
+
265
306
  .charts-grid {{
266
307
  display: grid;
267
308
  grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
@@ -545,25 +586,25 @@ class EnhancedDashboardGenerator:
545
586
 
546
587
  <div class="metrics-grid">
547
588
  <div class="metric-card ${{healthScore >= 90 ? 'success' : healthScore >= 70 ? 'warning' : 'danger'}}">
548
- <div class="label">Health Score</div>
589
+ <div class="label" data-tooltip="Based on Functional Pass Rate with penalties for soft failures (timeouts, flaky tests, environment issues). Calculation: Functional Pass Rate - (soft failures × 2, max -10 points)">Health Score</div>
549
590
  <div class="value">${{healthScore}}</div>
550
591
  <div class="trend">Overall system health</div>
551
592
  </div>
552
593
 
553
594
  <div class="metric-card success">
554
- <div class="label">Pass Rate</div>
595
+ <div class="label" data-tooltip="Percentage of all tests that passed. Calculation: (Passed Tests / Total Tests) × 100. Includes all test results regardless of failure type.">Pass Rate</div>
555
596
  <div class="value">${{passRate.toFixed(1)}}%</div>
556
597
  <div class="trend">${{run.passed || 0}} passed, ${{run.failed || 0}} failed</div>
557
598
  </div>
558
599
 
559
600
  <div class="metric-card">
560
- <div class="label">Functional Pass Rate</div>
601
+ <div class="label" data-tooltip="Pass rate excluding soft failures (timeouts, flaky tests, environment issues). Calculation: (Total - Skipped - Hard Failures) / (Total - Skipped) × 100. Focuses on actual functional bugs.">Functional Pass Rate</div>
561
602
  <div class="value">${{functionalPassRate.toFixed(1)}}%</div>
562
603
  <div class="trend">Excluding timeouts</div>
563
604
  </div>
564
605
 
565
606
  <div class="metric-card">
566
- <div class="label">Total Tests</div>
607
+ <div class="label" data-tooltip="Total number of tests executed in this run. ✓ = Passed, ✗ = Failed, ⊘ = Skipped">Total Tests</div>
567
608
  <div class="value">${{run.total_tests || 0}}</div>
568
609
  <div class="trend">${{run.passed || 0}}✓ ${{run.failed || 0}}✗ ${{run.skipped || 0}}⊘</div>
569
610
  </div>
@@ -797,8 +838,45 @@ class EnhancedDashboardGenerator:
797
838
  }}
798
839
  }}
799
840
 
841
+ // Tooltip functionality
842
+ function initTooltips() {{
843
+ const labels = document.querySelectorAll('[data-tooltip]');
844
+
845
+ labels.forEach(label => {{
846
+ label.addEventListener('mouseenter', function(e) {{
847
+ const tooltipText = this.getAttribute('data-tooltip');
848
+ const tooltip = document.createElement('div');
849
+ tooltip.className = 'tooltip show';
850
+ tooltip.textContent = tooltipText;
851
+ tooltip.id = 'active-tooltip';
852
+
853
+ document.body.appendChild(tooltip);
854
+
855
+ const rect = this.getBoundingClientRect();
856
+ tooltip.style.left = rect.left + 'px';
857
+ tooltip.style.top = (rect.bottom + 10) + 'px';
858
+
859
+ // Adjust if tooltip goes off screen
860
+ const tooltipRect = tooltip.getBoundingClientRect();
861
+ if (tooltipRect.right > window.innerWidth) {{
862
+ tooltip.style.left = (window.innerWidth - tooltipRect.width - 20) + 'px';
863
+ }}
864
+ }});
865
+
866
+ label.addEventListener('mouseleave', function() {{
867
+ const tooltip = document.getElementById('active-tooltip');
868
+ if (tooltip) {{
869
+ tooltip.remove();
870
+ }}
871
+ }});
872
+ }});
873
+ }}
874
+
800
875
  // Render dashboard on load
801
876
  renderDashboard();
877
+
878
+ // Initialize tooltips after content is rendered
879
+ setTimeout(initTooltips, 100);
802
880
  </script>
803
881
  </body>
804
882
  </html>'''
@@ -21,8 +21,8 @@ class HistoryDashboardGenerator:
21
21
  output_path = output_path or (self.config.DASHBOARD_DIR / 'history.html')
22
22
  output_path.parent.mkdir(parents=True, exist_ok=True)
23
23
 
24
- # Get ALL test runs (no limit)
25
- all_runs = self.storage.get_all_runs_with_details()
24
+ # Get ALL test runs (no limit) for current project
25
+ all_runs = self.storage.get_all_runs_with_details(project_name=self.config.PROJECT_NAME)
26
26
 
27
27
  # Generate HTML
28
28
  html = self._generate_html(all_runs)
@@ -43,13 +43,13 @@ class OverviewDashboardGenerator:
43
43
  # Top 10 Quick Win Metrics
44
44
  retry_analysis = self.storage.get_retry_analysis(project_name=filter_project)
45
45
  failure_types = self.storage.get_failure_type_breakdown(project_name=filter_project)
46
- time_to_recovery = self.storage.get_time_to_recovery(limit=10)
47
- performance_regressions = self.storage.get_performance_regressions()
48
- failure_correlations = self.storage.get_failure_correlations()
49
- error_messages = self.storage.get_error_messages(limit=20)
50
- test_age_info = self.storage.get_test_age_info()
51
- soft_fail_analysis = self.storage.get_soft_fail_analysis()
52
- step_failures = self.storage.get_step_failures(limit=15)
46
+ time_to_recovery = self.storage.get_time_to_recovery(limit=10, project_name=filter_project)
47
+ performance_regressions = self.storage.get_performance_regressions(project_name=filter_project)
48
+ failure_correlations = self.storage.get_failure_correlations(project_name=filter_project)
49
+ error_messages = self.storage.get_error_messages(limit=20, project_name=filter_project)
50
+ test_age_info = self.storage.get_test_age_info(project_name=filter_project)
51
+ soft_fail_analysis = self.storage.get_soft_fail_analysis(project_name=filter_project)
52
+ step_failures = self.storage.get_step_failures(limit=15, project_name=filter_project)
53
53
 
54
54
  # Generate HTML
55
55
  html = self._generate_html(
@@ -267,6 +267,47 @@ class OverviewDashboardGenerator:
267
267
  .metric-card.warning .value {{ color: #d97706; border-left-color: #f59e0b; }}
268
268
  .metric-card.danger .value {{ color: #dc2626; border-left-color: #ef4444; }}
269
269
 
270
+ .metric-card .label {{
271
+ position: relative;
272
+ cursor: help;
273
+ }}
274
+
275
+ .tooltip {{
276
+ position: absolute;
277
+ background: #1f2937;
278
+ color: #f9fafb;
279
+ padding: 12px 16px;
280
+ border-radius: 8px;
281
+ font-size: 13px;
282
+ line-height: 1.5;
283
+ max-width: 300px;
284
+ z-index: 1000;
285
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
286
+ pointer-events: none;
287
+ opacity: 0;
288
+ transition: opacity 0.2s ease;
289
+ white-space: normal;
290
+ text-transform: none;
291
+ letter-spacing: normal;
292
+ font-weight: 400;
293
+ }}
294
+
295
+ .tooltip.show {{
296
+ opacity: 1;
297
+ }}
298
+
299
+ .tooltip::before {{
300
+ content: '';
301
+ position: absolute;
302
+ top: -6px;
303
+ left: 20px;
304
+ width: 0;
305
+ height: 0;
306
+ border-left: 6px solid transparent;
307
+ border-right: 6px solid transparent;
308
+ border-bottom: 6px solid #1f2937;
309
+ }}
310
+
270
311
  .section {{
271
312
  background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
272
313
  padding: 32px;
@@ -537,25 +578,25 @@ class OverviewDashboardGenerator:
537
578
  const html = `
538
579
  <div class="metrics-grid">
539
580
  <div class="metric-card ${{healthClass}}">
540
- <div class="label">Health Score</div>
581
+ <div class="label" data-tooltip="Based on Functional Pass Rate with penalties for soft failures (timeouts, flaky tests, environment issues). Calculation: Functional Pass Rate - (soft failures × 2, max -10 points)">Health Score</div>
541
582
  <div class="value">${{healthScore}}/100</div>
542
583
  <div class="trend">${{healthScore >= 95 ? 'Excellent!' : healthScore >= 85 ? 'Good' : 'Needs attention'}}</div>
543
584
  </div>
544
585
 
545
586
  <div class="metric-card">
546
- <div class="label">Tests Passing</div>
587
+ <div class="label" data-tooltip="Average percentage of all tests that passed across all runs. Calculation: (Total Passed / Total Tests) × 100. Includes all test results regardless of failure type.">Tests Passing</div>
547
588
  <div class="value">${{health.avg_pass_rate}}%</div>
548
589
  <div class="trend">${{health.total_passed}} of ${{health.total_tests}} tests passed</div>
549
590
  </div>
550
591
 
551
592
  <div class="metric-card">
552
- <div class="label">Average Time</div>
593
+ <div class="label" data-tooltip="Average duration per test run. Calculated by averaging the total execution time across all test runs.">Average Time</div>
553
594
  <div class="value">${{durationText}}</div>
554
595
  <div class="trend">Per test run • ${{health.total_runs}} runs total</div>
555
596
  </div>
556
597
 
557
598
  <div class="metric-card">
558
- <div class="label">Test Runs</div>
599
+ <div class="label" data-tooltip="Total number of test runs recorded in the system. Each run represents a complete execution of the test suite.">Test Runs</div>
559
600
  <div class="value">${{health.total_runs}}</div>
560
601
  <div class="trend">${{health.total_tests}} tests executed</div>
561
602
  </div>
@@ -1275,9 +1316,46 @@ class OverviewDashboardGenerator:
1275
1316
  document.getElementById('stepFailuresContainer').innerHTML = html;
1276
1317
  }}
1277
1318
 
1319
+ // Tooltip functionality
1320
+ function initTooltips() {{
1321
+ const labels = document.querySelectorAll('[data-tooltip]');
1322
+
1323
+ labels.forEach(label => {{
1324
+ label.addEventListener('mouseenter', function(e) {{
1325
+ const tooltipText = this.getAttribute('data-tooltip');
1326
+ const tooltip = document.createElement('div');
1327
+ tooltip.className = 'tooltip show';
1328
+ tooltip.textContent = tooltipText;
1329
+ tooltip.id = 'active-tooltip';
1330
+
1331
+ document.body.appendChild(tooltip);
1332
+
1333
+ const rect = this.getBoundingClientRect();
1334
+ tooltip.style.left = rect.left + 'px';
1335
+ tooltip.style.top = (rect.bottom + 10) + 'px';
1336
+
1337
+ // Adjust if tooltip goes off screen
1338
+ const tooltipRect = tooltip.getBoundingClientRect();
1339
+ if (tooltipRect.right > window.innerWidth) {{
1340
+ tooltip.style.left = (window.innerWidth - tooltipRect.width - 20) + 'px';
1341
+ }}
1342
+ }});
1343
+
1344
+ label.addEventListener('mouseleave', function() {{
1345
+ const tooltip = document.getElementById('active-tooltip');
1346
+ if (tooltip) {{
1347
+ tooltip.remove();
1348
+ }}
1349
+ }});
1350
+ }});
1351
+ }}
1352
+
1278
1353
  // Update navigation and render dashboard on load
1279
1354
  updateNavigation();
1280
1355
  renderDashboard();
1356
+
1357
+ // Initialize tooltips after content is rendered
1358
+ setTimeout(initTooltips, 100);
1281
1359
  </script>
1282
1360
  </body>
1283
1361
  </html>'''
@@ -167,6 +167,45 @@ class ProjectsDashboardGenerator:
167
167
  .score-value.warning {{ color: #f59e0b; }}
168
168
  .score-value.critical {{ color: #ef4444; }}
169
169
 
170
+ .score-label {{
171
+ position: relative;
172
+ cursor: help;
173
+ }}
174
+
175
+ .tooltip {{
176
+ position: absolute;
177
+ background: #1f2937;
178
+ color: #f9fafb;
179
+ padding: 12px 16px;
180
+ border-radius: 8px;
181
+ font-size: 13px;
182
+ line-height: 1.5;
183
+ max-width: 300px;
184
+ z-index: 1000;
185
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4);
186
+ pointer-events: none;
187
+ opacity: 0;
188
+ transition: opacity 0.2s ease;
189
+ white-space: normal;
190
+ font-weight: 400;
191
+ }}
192
+
193
+ .tooltip.show {{
194
+ opacity: 1;
195
+ }}
196
+
197
+ .tooltip::before {{
198
+ content: '';
199
+ position: absolute;
200
+ top: -6px;
201
+ left: 20px;
202
+ width: 0;
203
+ height: 0;
204
+ border-left: 6px solid transparent;
205
+ border-right: 6px solid transparent;
206
+ border-bottom: 6px solid #1f2937;
207
+ }}
208
+
170
209
  .stats-grid {{
171
210
  display: grid;
172
211
  grid-template-columns: repeat(2, 1fr);
@@ -284,7 +323,7 @@ class ProjectsDashboardGenerator:
284
323
  </div>
285
324
 
286
325
  <div class="health-score">
287
- <span class="score-label">Health Score</span>
326
+ <span class="score-label" data-tooltip="Based on Functional Pass Rate with penalties for soft failures (timeouts, flaky tests, environment issues). Calculation: Functional Pass Rate - (soft failures × 2, max -10 points)">Health Score</span>
288
327
  <span class="score-value ${{healthClass}}">${{Math.round(passRate)}}%</span>
289
328
  </div>
290
329
 
@@ -326,8 +365,45 @@ class ProjectsDashboardGenerator:
326
365
  window.location.href = `overview.html?project=${{encodeURIComponent(projectName)}}`;
327
366
  }}
328
367
 
368
+ // Tooltip functionality
369
+ function initTooltips() {{
370
+ const labels = document.querySelectorAll('[data-tooltip]');
371
+
372
+ labels.forEach(label => {{
373
+ label.addEventListener('mouseenter', function(e) {{
374
+ const tooltipText = this.getAttribute('data-tooltip');
375
+ const tooltip = document.createElement('div');
376
+ tooltip.className = 'tooltip show';
377
+ tooltip.textContent = tooltipText;
378
+ tooltip.id = 'active-tooltip';
379
+
380
+ document.body.appendChild(tooltip);
381
+
382
+ const rect = this.getBoundingClientRect();
383
+ tooltip.style.left = rect.left + 'px';
384
+ tooltip.style.top = (rect.bottom + 10) + 'px';
385
+
386
+ // Adjust if tooltip goes off screen
387
+ const tooltipRect = tooltip.getBoundingClientRect();
388
+ if (tooltipRect.right > window.innerWidth) {{
389
+ tooltip.style.left = (window.innerWidth - tooltipRect.width - 20) + 'px';
390
+ }}
391
+ }});
392
+
393
+ label.addEventListener('mouseleave', function() {{
394
+ const tooltip = document.getElementById('active-tooltip');
395
+ if (tooltip) {{
396
+ tooltip.remove();
397
+ }}
398
+ }});
399
+ }});
400
+ }}
401
+
329
402
  // Render on load
330
403
  renderProjects();
404
+
405
+ // Initialize tooltips after content is rendered
406
+ setTimeout(initTooltips, 100);
331
407
  </script>
332
408
  </body>
333
409
  </html>'''
@@ -196,16 +196,23 @@ class TestResultStorage:
196
196
  ))
197
197
  conn.commit()
198
198
 
199
- def get_all_runs_with_details(self) -> List[Dict[str, Any]]:
199
+ def get_all_runs_with_details(self, project_name: str = None) -> List[Dict[str, Any]]:
200
200
  """Get all test runs with test file details (no limit)."""
201
201
  with sqlite3.connect(self.db_path) as conn:
202
202
  conn.row_factory = sqlite3.Row
203
203
 
204
+ where_clause = ""
205
+ params = []
206
+ if project_name:
207
+ where_clause = "WHERE project_name = ?"
208
+ params.append(project_name)
209
+
204
210
  # Get all runs
205
- runs_cursor = conn.execute('''
211
+ runs_cursor = conn.execute(f'''
206
212
  SELECT * FROM test_runs
213
+ {where_clause}
207
214
  ORDER BY timestamp DESC
208
- ''')
215
+ ''', params)
209
216
 
210
217
  runs = []
211
218
  for run_row in runs_cursor.fetchall():
@@ -266,11 +273,21 @@ class TestResultStorage:
266
273
  ''', (result_id,))
267
274
  return [dict(row) for row in cursor.fetchall()]
268
275
 
269
- def get_trend_data(self, days: int = 30) -> List[Dict[str, Any]]:
276
+ def get_trend_data(self, days: int = 30, project_name: str = None) -> List[Dict[str, Any]]:
270
277
  """Get trend data for the last N days."""
271
278
  with sqlite3.connect(self.db_path) as conn:
272
279
  conn.row_factory = sqlite3.Row
273
- cursor = conn.execute('''
280
+
281
+ where_clauses = ["timestamp >= datetime('now', '-' || ? || ' days')"]
282
+ params = [days]
283
+
284
+ if project_name:
285
+ where_clauses.append("project_name = ?")
286
+ params.append(project_name)
287
+
288
+ where_clause = " AND ".join(where_clauses)
289
+
290
+ cursor = conn.execute(f'''
274
291
  SELECT
275
292
  timestamp,
276
293
  pass_rate,
@@ -280,41 +297,51 @@ class TestResultStorage:
280
297
  failed,
281
298
  duration_seconds
282
299
  FROM test_runs
283
- WHERE timestamp >= datetime('now', '-' || ? || ' days')
300
+ WHERE {where_clause}
284
301
  ORDER BY timestamp ASC
285
- ''', (days,))
302
+ ''', params)
286
303
  return [dict(row) for row in cursor.fetchall()]
287
304
 
288
- def get_failure_summary(self, days: int = 30) -> Dict[str, Any]:
305
+ def get_failure_summary(self, days: int = 30, project_name: str = None) -> Dict[str, Any]:
289
306
  """Get failure summary for the last N days."""
290
307
  with sqlite3.connect(self.db_path) as conn:
291
308
  conn.row_factory = sqlite3.Row
292
309
 
310
+ where_clauses = [
311
+ "status = 'failed'",
312
+ "timestamp >= datetime('now', '-' || ? || ' days')"
313
+ ]
314
+ params = [days]
315
+
316
+ if project_name:
317
+ where_clauses.append("project_name = ?")
318
+ params.append(project_name)
319
+
320
+ where_clause = " AND ".join(where_clauses)
321
+
293
322
  # Get failures by type
294
- cursor = conn.execute('''
323
+ cursor = conn.execute(f'''
295
324
  SELECT
296
325
  failure_type,
297
326
  COUNT(*) as count
298
327
  FROM test_results
299
- WHERE status = 'failed'
300
- AND timestamp >= datetime('now', '-' || ? || ' days')
328
+ WHERE {where_clause}
301
329
  GROUP BY failure_type
302
330
  ORDER BY count DESC
303
- ''', (days,))
331
+ ''', params)
304
332
  by_type = {row['failure_type']: row['count'] for row in cursor.fetchall()}
305
333
 
306
334
  # Get failures by file
307
- cursor = conn.execute('''
335
+ cursor = conn.execute(f'''
308
336
  SELECT
309
337
  file_name,
310
338
  COUNT(*) as count
311
339
  FROM test_results
312
- WHERE status = 'failed'
313
- AND timestamp >= datetime('now', '-' || ? || ' days')
340
+ WHERE {where_clause}
314
341
  GROUP BY file_name
315
342
  ORDER BY count DESC
316
343
  LIMIT 10
317
- ''', (days,))
344
+ ''', params)
318
345
  by_file = [{'file': row['file_name'], 'count': row['count']} for row in cursor.fetchall()]
319
346
 
320
347
  return {
@@ -438,11 +465,21 @@ class TestResultStorage:
438
465
 
439
466
  return modules
440
467
 
441
- def get_suite_statistics(self) -> List[Dict[str, Any]]:
468
+ def get_suite_statistics(self, project_name: str = None) -> List[Dict[str, Any]]:
442
469
  """Get statistics per test suite."""
443
470
  with sqlite3.connect(self.db_path) as conn:
444
471
  conn.row_factory = sqlite3.Row
445
- cursor = conn.execute('''
472
+
473
+ where_clauses = ["suite_name IS NOT NULL", "suite_name != ''"]
474
+ params = []
475
+
476
+ if project_name:
477
+ where_clauses.append("project_name = ?")
478
+ params.append(project_name)
479
+
480
+ where_clause = " AND ".join(where_clauses)
481
+
482
+ cursor = conn.execute(f'''
446
483
  SELECT
447
484
  suite_name,
448
485
  COUNT(*) as total_runs,
@@ -453,10 +490,10 @@ class TestResultStorage:
453
490
  MIN(timestamp) as first_run,
454
491
  MAX(timestamp) as last_run
455
492
  FROM test_runs
456
- WHERE suite_name IS NOT NULL AND suite_name != ''
493
+ WHERE {where_clause}
457
494
  GROUP BY suite_name
458
495
  ORDER BY last_run DESC
459
- ''')
496
+ ''', params)
460
497
 
461
498
  suites = []
462
499
  for row in cursor.fetchall():
@@ -667,7 +704,7 @@ class TestResultStorage:
667
704
  WHERE rn = 1
668
705
  ORDER BY avg_duration DESC
669
706
  LIMIT ?
670
- ''', (limit,))
707
+ ''', params)
671
708
 
672
709
  slowest = []
673
710
  for row in cursor.fetchall():
@@ -829,11 +866,19 @@ class TestResultStorage:
829
866
  ''', params)
830
867
  return [dict(row) for row in cursor.fetchall()]
831
868
 
832
- def get_time_to_recovery(self, limit: int = 10) -> List[Dict[str, Any]]:
869
+ def get_time_to_recovery(self, limit: int = 10, project_name: str = None) -> List[Dict[str, Any]]:
833
870
  """Get time to recovery for tests that failed then passed."""
834
871
  with sqlite3.connect(self.db_path) as conn:
835
872
  conn.row_factory = sqlite3.Row
836
- cursor = conn.execute('''
873
+
874
+ project_filter = ""
875
+ params = []
876
+ if project_name:
877
+ project_filter = "AND tr.project_name = ?"
878
+ params.append(project_name)
879
+ params.append(limit)
880
+
881
+ cursor = conn.execute(f'''
837
882
  WITH failures AS (
838
883
  SELECT
839
884
  test_name,
@@ -844,7 +889,7 @@ class TestResultStorage:
844
889
  ROW_NUMBER() OVER (PARTITION BY test_name, file_name ORDER BY timestamp) as rn
845
890
  FROM test_results tr
846
891
  JOIN test_runs r ON tr.run_id = r.id
847
- WHERE status IN ('failed', 'passed')
892
+ WHERE status IN ('failed', 'passed') {project_filter}
848
893
  ),
849
894
  recovery_times AS (
850
895
  SELECT
@@ -865,55 +910,58 @@ class TestResultStorage:
865
910
  WHERE minutes_to_recover IS NOT NULL
866
911
  ORDER BY minutes_to_recover DESC
867
912
  LIMIT ?
868
- ''', (limit,))
913
+ ''', params)
869
914
  return [dict(row) for row in cursor.fetchall()]
870
915
 
871
- def get_error_messages(self, search: str = None, limit: int = 50) -> List[Dict[str, Any]]:
916
+ def get_error_messages(self, search: str = None, limit: int = 50, project_name: str = None) -> List[Dict[str, Any]]:
872
917
  """Get error messages with optional search."""
873
918
  with sqlite3.connect(self.db_path) as conn:
874
919
  conn.row_factory = sqlite3.Row
875
920
 
921
+ where_clauses = ["error_message IS NOT NULL"]
922
+ params = []
923
+
876
924
  if search:
877
- cursor = conn.execute('''
878
- SELECT
879
- test_name,
880
- file_name,
881
- error_message,
882
- error_type,
883
- COUNT(*) as occurrence_count,
884
- MAX(r.timestamp) as last_seen
885
- FROM test_results tr
886
- JOIN test_runs r ON tr.run_id = r.id
887
- WHERE error_message IS NOT NULL
888
- AND (error_message LIKE ? OR error_type LIKE ?)
889
- GROUP BY test_name, file_name, error_message, error_type
890
- ORDER BY occurrence_count DESC
891
- LIMIT ?
892
- ''', (f'%{search}%', f'%{search}%', limit))
893
- else:
894
- cursor = conn.execute('''
895
- SELECT
896
- test_name,
897
- file_name,
898
- error_message,
899
- error_type,
900
- COUNT(*) as occurrence_count,
901
- MAX(r.timestamp) as last_seen
902
- FROM test_results tr
903
- JOIN test_runs r ON tr.run_id = r.id
904
- WHERE error_message IS NOT NULL
905
- GROUP BY test_name, file_name, error_message, error_type
906
- ORDER BY occurrence_count DESC
907
- LIMIT ?
908
- ''', (limit,))
925
+ where_clauses.append("(error_message LIKE ? OR error_type LIKE ?)")
926
+ params.extend([f'%{search}%', f'%{search}%'])
927
+
928
+ if project_name:
929
+ where_clauses.append("tr.project_name = ?")
930
+ params.append(project_name)
931
+
932
+ params.append(limit)
933
+ where_clause = " AND ".join(where_clauses)
934
+
935
+ cursor = conn.execute(f'''
936
+ SELECT
937
+ test_name,
938
+ file_name,
939
+ error_message,
940
+ error_type,
941
+ COUNT(*) as occurrence_count,
942
+ MAX(r.timestamp) as last_seen
943
+ FROM test_results tr
944
+ JOIN test_runs r ON tr.run_id = r.id
945
+ WHERE {where_clause}
946
+ GROUP BY test_name, file_name, error_message, error_type
947
+ ORDER BY occurrence_count DESC
948
+ LIMIT ?
949
+ ''', params)
909
950
 
910
951
  return [dict(row) for row in cursor.fetchall()]
911
952
 
912
- def get_test_age_info(self) -> List[Dict[str, Any]]:
953
+ def get_test_age_info(self, project_name: str = None) -> List[Dict[str, Any]]:
913
954
  """Get test age information."""
914
955
  with sqlite3.connect(self.db_path) as conn:
915
956
  conn.row_factory = sqlite3.Row
916
- cursor = conn.execute('''
957
+
958
+ where_clause = ""
959
+ params = []
960
+ if project_name:
961
+ where_clause = "WHERE tr.project_name = ?"
962
+ params.append(project_name)
963
+
964
+ cursor = conn.execute(f'''
917
965
  SELECT
918
966
  test_name,
919
967
  file_name,
@@ -923,29 +971,42 @@ class TestResultStorage:
923
971
  CAST((julianday('now') - julianday(MAX(r.timestamp))) AS INTEGER) as days_since_last_run
924
972
  FROM test_results tr
925
973
  JOIN test_runs r ON tr.run_id = r.id
974
+ {where_clause}
926
975
  GROUP BY test_name, file_name
927
976
  ORDER BY days_since_last_run DESC
928
- ''')
977
+ ''', params)
929
978
  return [dict(row) for row in cursor.fetchall()]
930
979
 
931
- def get_soft_fail_analysis(self) -> Dict[str, Any]:
980
+ def get_soft_fail_analysis(self, project_name: str = None) -> Dict[str, Any]:
932
981
  """Get soft fail vs hard fail analysis."""
933
982
  with sqlite3.connect(self.db_path) as conn:
934
983
  conn.row_factory = sqlite3.Row
935
984
 
985
+ where_clauses = ["status = 'failed'"]
986
+ params = []
987
+ if project_name:
988
+ where_clauses.append("project_name = ?")
989
+ params.append(project_name)
990
+ where_clause = " AND ".join(where_clauses)
991
+
936
992
  # Overall stats
937
- cursor = conn.execute('''
993
+ cursor = conn.execute(f'''
938
994
  SELECT
939
995
  COUNT(*) as total_failures,
940
996
  SUM(CASE WHEN is_soft_fail = 1 THEN 1 ELSE 0 END) as soft_fails,
941
997
  SUM(CASE WHEN is_soft_fail = 0 OR is_soft_fail IS NULL THEN 1 ELSE 0 END) as hard_fails
942
998
  FROM test_results
943
- WHERE status = 'failed'
944
- ''')
999
+ WHERE {where_clause}
1000
+ ''', params)
945
1001
  overall = dict(cursor.fetchone())
946
1002
 
947
1003
  # Tests with most soft fails
948
- cursor = conn.execute('''
1004
+ where_clauses_soft = ["is_soft_fail = 1"]
1005
+ if project_name:
1006
+ where_clauses_soft.append("tr.project_name = ?")
1007
+ where_clause_soft = " AND ".join(where_clauses_soft)
1008
+
1009
+ cursor = conn.execute(f'''
949
1010
  SELECT
950
1011
  test_name,
951
1012
  file_name,
@@ -953,11 +1014,11 @@ class TestResultStorage:
953
1014
  MAX(r.timestamp) as last_soft_fail
954
1015
  FROM test_results tr
955
1016
  JOIN test_runs r ON tr.run_id = r.id
956
- WHERE is_soft_fail = 1
1017
+ WHERE {where_clause_soft}
957
1018
  GROUP BY test_name, file_name
958
1019
  ORDER BY soft_fail_count DESC
959
1020
  LIMIT 10
960
- ''')
1021
+ ''', params)
961
1022
  top_soft_fails = [dict(row) for row in cursor.fetchall()]
962
1023
 
963
1024
  return {
@@ -965,11 +1026,20 @@ class TestResultStorage:
965
1026
  'top_soft_fails': top_soft_fails
966
1027
  }
967
1028
 
968
- def get_step_failures(self, limit: int = 20) -> List[Dict[str, Any]]:
1029
+ def get_step_failures(self, limit: int = 20, project_name: str = None) -> List[Dict[str, Any]]:
969
1030
  """Get most common failing steps."""
970
1031
  with sqlite3.connect(self.db_path) as conn:
971
1032
  conn.row_factory = sqlite3.Row
972
- cursor = conn.execute('''
1033
+
1034
+ where_clauses = ["ts.status = 'failed'"]
1035
+ params = []
1036
+ if project_name:
1037
+ where_clauses.append("tr.project_name = ?")
1038
+ params.append(project_name)
1039
+ params.append(limit)
1040
+ where_clause = " AND ".join(where_clauses)
1041
+
1042
+ cursor = conn.execute(f'''
973
1043
  SELECT
974
1044
  ts.description,
975
1045
  COUNT(*) as failure_count,
@@ -977,18 +1047,26 @@ class TestResultStorage:
977
1047
  COUNT(DISTINCT tr.test_name) as affected_tests
978
1048
  FROM test_steps ts
979
1049
  JOIN test_results tr ON ts.result_id = tr.id
980
- WHERE ts.status = 'failed'
1050
+ WHERE {where_clause}
981
1051
  GROUP BY ts.description
982
1052
  ORDER BY failure_count DESC
983
1053
  LIMIT ?
984
- ''', (limit,))
1054
+ ''', params)
985
1055
  return [dict(row) for row in cursor.fetchall()]
986
1056
 
987
- def get_performance_regressions(self, threshold_percent: float = 20.0) -> List[Dict[str, Any]]:
1057
+ def get_performance_regressions(self, threshold_percent: float = 20.0, project_name: str = None) -> List[Dict[str, Any]]:
988
1058
  """Detect tests that are getting slower."""
989
1059
  with sqlite3.connect(self.db_path) as conn:
990
1060
  conn.row_factory = sqlite3.Row
991
- cursor = conn.execute('''
1061
+
1062
+ project_filter = ""
1063
+ params = []
1064
+ if project_name:
1065
+ project_filter = "AND tr.project_name = ?"
1066
+ params.append(project_name)
1067
+ params.append(threshold_percent)
1068
+
1069
+ cursor = conn.execute(f'''
992
1070
  WITH recent_avg AS (
993
1071
  SELECT
994
1072
  tr.test_name,
@@ -998,6 +1076,7 @@ class TestResultStorage:
998
1076
  JOIN test_runs r ON tr.run_id = r.id
999
1077
  WHERE r.timestamp >= datetime('now', '-7 days')
1000
1078
  AND tr.duration_seconds > 0
1079
+ {project_filter}
1001
1080
  GROUP BY tr.test_name, tr.file_name
1002
1081
  ),
1003
1082
  historical_avg AS (
@@ -1010,6 +1089,7 @@ class TestResultStorage:
1010
1089
  WHERE r.timestamp < datetime('now', '-7 days')
1011
1090
  AND r.timestamp >= datetime('now', '-30 days')
1012
1091
  AND tr.duration_seconds > 0
1092
+ {project_filter}
1013
1093
  GROUP BY tr.test_name, tr.file_name
1014
1094
  )
1015
1095
  SELECT
@@ -1022,18 +1102,26 @@ class TestResultStorage:
1022
1102
  JOIN historical_avg h ON r.test_name = h.test_name AND r.file_name = h.file_name
1023
1103
  WHERE ((r.recent_avg - h.historical_avg) / h.historical_avg) * 100 > ?
1024
1104
  ORDER BY percent_slower DESC
1025
- ''', (threshold_percent,))
1105
+ ''', params)
1026
1106
  return [dict(row) for row in cursor.fetchall()]
1027
1107
 
1028
- def get_failure_correlations(self, min_correlation: int = 3) -> List[Dict[str, Any]]:
1108
+ def get_failure_correlations(self, min_correlation: int = 3, project_name: str = None) -> List[Dict[str, Any]]:
1029
1109
  """Find tests that frequently fail together."""
1030
1110
  with sqlite3.connect(self.db_path) as conn:
1031
1111
  conn.row_factory = sqlite3.Row
1032
- cursor = conn.execute('''
1112
+
1113
+ where_clause = "status = 'failed'"
1114
+ params = []
1115
+ if project_name:
1116
+ where_clause += " AND project_name = ?"
1117
+ params.append(project_name)
1118
+ params.append(min_correlation)
1119
+
1120
+ cursor = conn.execute(f'''
1033
1121
  WITH failed_runs AS (
1034
1122
  SELECT DISTINCT run_id, test_name, file_name
1035
1123
  FROM test_results
1036
- WHERE status = 'failed'
1124
+ WHERE {where_clause}
1037
1125
  )
1038
1126
  SELECT
1039
1127
  f1.test_name as test1_name,
@@ -1048,7 +1136,7 @@ class TestResultStorage:
1048
1136
  HAVING fail_together_count >= ?
1049
1137
  ORDER BY fail_together_count DESC
1050
1138
  LIMIT 20
1051
- ''', (min_correlation,))
1139
+ ''', params)
1052
1140
  return [dict(row) for row in cursor.fetchall()]
1053
1141
 
1054
1142
  # ===== MULTI-PROJECT QUERIES =====
@@ -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='1.2.0',
12
+ version='1.2.2',
13
13
  description='Beautiful multi-project test reporting dashboard system',
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: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Beautiful multi-project test reporting dashboard system
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