Kea2-python 0.2.4__py3-none-any.whl → 0.3.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.

Potentially problematic release.


This version of Kea2-python might be problematic. Click here for more details.

kea2/report_merger.py ADDED
@@ -0,0 +1,651 @@
1
+ import json
2
+ import os
3
+ import re
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Dict, List, Optional, Union
7
+ from collections import defaultdict
8
+
9
+ from kea2.utils import getLogger
10
+
11
+ logger = getLogger(__name__)
12
+
13
+
14
+ class TestReportMerger:
15
+ """
16
+ Merge multiple test result directories into a single combined dataset
17
+ Only processes result_*.json and coverage.log files for the simplified template
18
+ """
19
+
20
+ def __init__(self):
21
+ self.merged_data = {}
22
+ self.result_dirs = []
23
+
24
+ def merge_reports(self, result_paths: List[Union[str, Path]], output_dir: Optional[Union[str, Path]] = None) -> Path:
25
+ """
26
+ Merge multiple test result directories
27
+
28
+ Args:
29
+ result_paths: List of paths to test result directories (res_* directories)
30
+ output_dir: Output directory for merged data (optional)
31
+
32
+ Returns:
33
+ Path to the merged data directory
34
+ """
35
+ try:
36
+ # Convert paths and validate
37
+ self.result_dirs = [Path(p).resolve() for p in result_paths]
38
+ self._validate_result_dirs()
39
+
40
+ # Setup output directory
41
+ timestamp = datetime.now().strftime("%Y%m%d%H_%M%S")
42
+ if output_dir is None:
43
+ output_dir = Path.cwd() / f"merged_report_{timestamp}"
44
+ else:
45
+ output_dir = Path(output_dir).resolve() / f"merged_report_{timestamp}"
46
+
47
+ output_dir.mkdir(parents=True, exist_ok=True)
48
+
49
+ logger.debug(f"Merging {len(self.result_dirs)} test result directories...")
50
+
51
+ # Merge different types of data
52
+ merged_property_stats = self._merge_property_results()
53
+ merged_coverage_data = self._merge_coverage_data()
54
+ merged_crash_anr_data = self._merge_crash_dump_data()
55
+
56
+ # Calculate final statistics
57
+ final_data = self._calculate_final_statistics(merged_property_stats, merged_coverage_data, merged_crash_anr_data)
58
+
59
+ # Add merge information to final data
60
+ final_data['merge_info'] = {
61
+ 'merge_timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
62
+ 'source_count': len(self.result_dirs),
63
+ 'source_directories': [str(Path(d).name) for d in self.result_dirs]
64
+ }
65
+
66
+ # Generate HTML report (now includes merge info)
67
+ report_file = self._generate_html_report(final_data, output_dir)
68
+
69
+ logger.debug(f"Reports generated successfully in: {output_dir}")
70
+ return output_dir
71
+
72
+ except Exception as e:
73
+ logger.error(f"Error merging test reports: {e}")
74
+ raise
75
+
76
+ def _validate_result_dirs(self):
77
+ """Validate that all result directories exist and contain required files"""
78
+ for result_dir in self.result_dirs:
79
+ if not result_dir.exists():
80
+ raise FileNotFoundError(f"Result directory does not exist: {result_dir}")
81
+
82
+ # Check for required files pattern
83
+ result_files = list(result_dir.glob("result_*.json"))
84
+ if not result_files:
85
+ raise FileNotFoundError(f"No result_*.json file found in: {result_dir}")
86
+
87
+ logger.debug(f"Validated result directory: {result_dir}")
88
+
89
+ def _merge_property_results(self) -> Dict[str, Dict]:
90
+ """
91
+ Merge property test results from all directories
92
+
93
+ Returns:
94
+ Merged property execution results
95
+ """
96
+ merged_results = defaultdict(lambda: {
97
+ "precond_satisfied": 0,
98
+ "executed": 0,
99
+ "fail": 0,
100
+ "error": 0
101
+ })
102
+
103
+ for result_dir in self.result_dirs:
104
+ result_files = list(result_dir.glob("result_*.json"))
105
+ if not result_files:
106
+ logger.warning(f"No result file found in {result_dir}")
107
+ continue
108
+
109
+ result_file = result_files[0] # Take the first (should be only one)
110
+
111
+ try:
112
+ with open(result_file, 'r', encoding='utf-8') as f:
113
+ test_results = json.load(f)
114
+
115
+ # Merge results for each property
116
+ for prop_name, prop_result in test_results.items():
117
+ for key in ["precond_satisfied", "executed", "fail", "error"]:
118
+ merged_results[prop_name][key] += prop_result.get(key, 0)
119
+
120
+ logger.debug(f"Merged results from: {result_file}")
121
+
122
+ except Exception as e:
123
+ logger.error(f"Error reading result file {result_file}: {e}")
124
+ continue
125
+
126
+ return dict(merged_results)
127
+
128
+ def _merge_coverage_data(self) -> Dict:
129
+ """
130
+ Merge coverage data from all directories
131
+
132
+ Returns:
133
+ Final merged coverage information
134
+ """
135
+ all_activities = set()
136
+ tested_activities = set()
137
+ activity_counts = defaultdict(int)
138
+ total_steps = 0
139
+
140
+ for result_dir in self.result_dirs:
141
+ # Find coverage log file
142
+ output_dirs = list(result_dir.glob("output_*"))
143
+ if not output_dirs:
144
+ logger.warning(f"No output directory found in {result_dir}")
145
+ continue
146
+
147
+ coverage_file = output_dirs[0] / "coverage.log"
148
+ if not coverage_file.exists():
149
+ logger.warning(f"No coverage.log found in {output_dirs[0]}")
150
+ continue
151
+
152
+ try:
153
+ # Read the last line of coverage.log to get final state
154
+ last_coverage = None
155
+ with open(coverage_file, 'r', encoding='utf-8') as f:
156
+ for line in f:
157
+ if line.strip():
158
+ last_coverage = json.loads(line)
159
+
160
+ if last_coverage:
161
+ # Collect all activities
162
+ all_activities.update(last_coverage.get("totalActivities", []))
163
+ tested_activities.update(last_coverage.get("testedActivities", []))
164
+
165
+ # Update activity counts (take maximum)
166
+ for activity, count in last_coverage.get("activityCountHistory", {}).items():
167
+ activity_counts[activity] += count
168
+
169
+ # Add steps count
170
+ total_steps += last_coverage.get("stepsCount", 0)
171
+
172
+ logger.debug(f"Merged coverage data from: {coverage_file}")
173
+
174
+ except Exception as e:
175
+ logger.error(f"Error reading coverage file {coverage_file}: {e}")
176
+ continue
177
+
178
+ # Calculate final coverage percentage (rounded to 2 decimal places)
179
+ coverage_percent = round((len(tested_activities) / len(all_activities) * 100), 2) if all_activities else 0.00
180
+
181
+ return {
182
+ "coverage_percent": coverage_percent,
183
+ "total_activities": list(all_activities),
184
+ "tested_activities": list(tested_activities),
185
+ "total_activities_count": len(all_activities),
186
+ "activity_count_history": dict(activity_counts),
187
+ "total_steps": total_steps
188
+ }
189
+
190
+ def _merge_crash_dump_data(self) -> Dict:
191
+ """
192
+ Merge crash and ANR data from all directories
193
+
194
+ Returns:
195
+ Dict containing merged crash and ANR events
196
+ """
197
+ all_crash_events = []
198
+ all_anr_events = []
199
+
200
+ for result_dir in self.result_dirs:
201
+ # Find crash dump log file
202
+ output_dirs = list(result_dir.glob("output_*"))
203
+ if not output_dirs:
204
+ logger.warning(f"No output directory found in {result_dir}")
205
+ continue
206
+
207
+ crash_dump_file = output_dirs[0] / "crash-dump.log"
208
+ if not crash_dump_file.exists():
209
+ logger.debug(f"No crash-dump.log found in {output_dirs[0]}")
210
+ continue
211
+
212
+ try:
213
+ # Parse crash and ANR events from this file
214
+ crash_events, anr_events = self._parse_crash_dump_file(crash_dump_file)
215
+ all_crash_events.extend(crash_events)
216
+ all_anr_events.extend(anr_events)
217
+
218
+ logger.debug(f"Merged {len(crash_events)} crash events and {len(anr_events)} ANR events from: {crash_dump_file}")
219
+
220
+ except Exception as e:
221
+ logger.error(f"Error reading crash dump file {crash_dump_file}: {e}")
222
+ continue
223
+
224
+ # Deduplicate events based on content and timestamp
225
+ unique_crash_events = self._deduplicate_crash_events(all_crash_events)
226
+ unique_anr_events = self._deduplicate_anr_events(all_anr_events)
227
+
228
+ logger.debug(f"Total unique crash events: {len(unique_crash_events)}, ANR events: {len(unique_anr_events)}")
229
+
230
+ return {
231
+ "crash_events": unique_crash_events,
232
+ "anr_events": unique_anr_events,
233
+ "total_crash_count": len(unique_crash_events),
234
+ "total_anr_count": len(unique_anr_events)
235
+ }
236
+
237
+ def _parse_crash_dump_file(self, crash_dump_file: Path) -> tuple[List[Dict], List[Dict]]:
238
+ """
239
+ Parse crash and ANR events from crash-dump.log file
240
+
241
+ Args:
242
+ crash_dump_file: Path to crash-dump.log file
243
+
244
+ Returns:
245
+ tuple: (crash_events, anr_events) - Lists of crash and ANR event dictionaries
246
+ """
247
+ crash_events = []
248
+ anr_events = []
249
+
250
+ try:
251
+ with open(crash_dump_file, "r", encoding="utf-8") as f:
252
+ content = f.read()
253
+
254
+ # Parse crash events
255
+ crash_events = self._parse_crash_events(content)
256
+
257
+ # Parse ANR events
258
+ anr_events = self._parse_anr_events(content)
259
+
260
+ except Exception as e:
261
+ logger.error(f"Error parsing crash dump file {crash_dump_file}: {e}")
262
+
263
+ return crash_events, anr_events
264
+
265
+ def _parse_crash_events(self, content: str) -> List[Dict]:
266
+ """
267
+ Parse crash events from crash-dump.log content
268
+
269
+ Args:
270
+ content: Content of crash-dump.log file
271
+
272
+ Returns:
273
+ List[Dict]: List of crash event dictionaries
274
+ """
275
+ crash_events = []
276
+
277
+ # Pattern to match crash blocks
278
+ crash_pattern = r'(\d{14})\ncrash:\n(.*?)\n// crash end'
279
+
280
+ for match in re.finditer(crash_pattern, content, re.DOTALL):
281
+ timestamp_str = match.group(1)
282
+ crash_content = match.group(2)
283
+
284
+ # Parse timestamp (format: YYYYMMDDHHMMSS)
285
+ try:
286
+ timestamp = datetime.strptime(timestamp_str, "%Y%m%d%H%M%S")
287
+ formatted_time = timestamp.strftime("%Y-%m-%d %H:%M:%S")
288
+ except ValueError:
289
+ formatted_time = timestamp_str
290
+
291
+ # Extract crash information
292
+ crash_info = self._extract_crash_info(crash_content)
293
+
294
+ crash_event = {
295
+ "time": formatted_time,
296
+ "exception_type": crash_info.get("exception_type", "Unknown"),
297
+ "process": crash_info.get("process", "Unknown"),
298
+ "stack_trace": crash_info.get("stack_trace", "")
299
+ }
300
+
301
+ crash_events.append(crash_event)
302
+
303
+ return crash_events
304
+
305
+ def _parse_anr_events(self, content: str) -> List[Dict]:
306
+ """
307
+ Parse ANR events from crash-dump.log content
308
+
309
+ Args:
310
+ content: Content of crash-dump.log file
311
+
312
+ Returns:
313
+ List[Dict]: List of ANR event dictionaries
314
+ """
315
+ anr_events = []
316
+
317
+ # Pattern to match ANR blocks
318
+ anr_pattern = r'(\d{14})\nanr:\n(.*?)\nanr end'
319
+
320
+ for match in re.finditer(anr_pattern, content, re.DOTALL):
321
+ timestamp_str = match.group(1)
322
+ anr_content = match.group(2)
323
+
324
+ # Parse timestamp (format: YYYYMMDDHHMMSS)
325
+ try:
326
+ timestamp = datetime.strptime(timestamp_str, "%Y%m%d%H%M%S")
327
+ formatted_time = timestamp.strftime("%Y-%m-%d %H:%M:%S")
328
+ except ValueError:
329
+ formatted_time = timestamp_str
330
+
331
+ # Extract ANR information
332
+ anr_info = self._extract_anr_info(anr_content)
333
+
334
+ anr_event = {
335
+ "time": formatted_time,
336
+ "reason": anr_info.get("reason", "Unknown"),
337
+ "process": anr_info.get("process", "Unknown"),
338
+ "trace": anr_info.get("trace", "")
339
+ }
340
+
341
+ anr_events.append(anr_event)
342
+
343
+ return anr_events
344
+
345
+ def _extract_crash_info(self, crash_content: str) -> Dict:
346
+ """
347
+ Extract crash information from crash content
348
+
349
+ Args:
350
+ crash_content: Content of a single crash block
351
+
352
+ Returns:
353
+ Dict: Extracted crash information
354
+ """
355
+ crash_info = {
356
+ "exception_type": "Unknown",
357
+ "process": "Unknown",
358
+ "stack_trace": ""
359
+ }
360
+
361
+ lines = crash_content.strip().split('\n')
362
+
363
+ for line in lines:
364
+ line = line.strip()
365
+
366
+ # Extract PID from CRASH line
367
+ if line.startswith("// CRASH:"):
368
+ # Pattern: // CRASH: process_name (pid xxxx) (dump time: ...)
369
+ pid_match = re.search(r'\(pid\s+(\d+)\)', line)
370
+ if pid_match:
371
+ crash_info["process"] = pid_match.group(1)
372
+
373
+ # Extract exception type from Long Msg line
374
+ elif line.startswith("// Long Msg:"):
375
+ # Pattern: // Long Msg: ExceptionType: message
376
+ exception_match = re.search(r'// Long Msg:\s+([^:]+)', line)
377
+ if exception_match:
378
+ crash_info["exception_type"] = exception_match.group(1).strip()
379
+
380
+ # Extract full stack trace (all lines starting with //)
381
+ stack_lines = []
382
+ for line in lines:
383
+ if line.startswith("//"):
384
+ # Remove the "// " prefix for cleaner display
385
+ clean_line = line[3:] if line.startswith("// ") else line[2:]
386
+ stack_lines.append(clean_line)
387
+
388
+ crash_info["stack_trace"] = '\n'.join(stack_lines)
389
+
390
+ return crash_info
391
+
392
+ def _extract_anr_info(self, anr_content: str) -> Dict:
393
+ """
394
+ Extract ANR information from ANR content
395
+
396
+ Args:
397
+ anr_content: Content of a single ANR block
398
+
399
+ Returns:
400
+ Dict: Extracted ANR information
401
+ """
402
+ anr_info = {
403
+ "reason": "Unknown",
404
+ "process": "Unknown",
405
+ "trace": ""
406
+ }
407
+
408
+ lines = anr_content.strip().split('\n')
409
+
410
+ for line in lines:
411
+ line = line.strip()
412
+
413
+ # Extract PID from ANR line
414
+ if line.startswith("// ANR:"):
415
+ # Pattern: // ANR: process_name (pid xxxx) (dump time: ...)
416
+ pid_match = re.search(r'\(pid\s+(\d+)\)', line)
417
+ if pid_match:
418
+ anr_info["process"] = pid_match.group(1)
419
+
420
+ # Extract reason from Reason line
421
+ elif line.startswith("Reason:"):
422
+ # Pattern: Reason: Input dispatching timed out (...)
423
+ reason_match = re.search(r'Reason:\s+(.+)', line)
424
+ if reason_match:
425
+ full_reason = reason_match.group(1).strip()
426
+ # Simplify the reason by extracting the main part before parentheses
427
+ simplified_reason = self._simplify_anr_reason(full_reason)
428
+ anr_info["reason"] = simplified_reason
429
+
430
+ # Store the full ANR trace content
431
+ anr_info["trace"] = anr_content
432
+
433
+ return anr_info
434
+
435
+ def _simplify_anr_reason(self, full_reason: str) -> str:
436
+ """
437
+ Simplify ANR reason by extracting the main part
438
+
439
+ Args:
440
+ full_reason: Full ANR reason string
441
+
442
+ Returns:
443
+ str: Simplified ANR reason
444
+ """
445
+ # Common ANR reason patterns to simplify
446
+ simplification_patterns = [
447
+ # Input dispatching timed out (details...) -> Input dispatching timed out
448
+ (r'^(Input dispatching timed out)\s*\(.*\).*$', r'\1'),
449
+ # Broadcast of Intent (details...) -> Broadcast timeout
450
+ (r'^Broadcast of Intent.*$', 'Broadcast timeout'),
451
+ # Service timeout -> Service timeout
452
+ (r'^Service.*timeout.*$', 'Service timeout'),
453
+ # ContentProvider timeout -> ContentProvider timeout
454
+ (r'^ContentProvider.*timeout.*$', 'ContentProvider timeout'),
455
+ ]
456
+
457
+ # Apply simplification patterns
458
+ for pattern, replacement in simplification_patterns:
459
+ match = re.match(pattern, full_reason, re.IGNORECASE)
460
+ if match:
461
+ if callable(replacement):
462
+ return replacement(match)
463
+ elif '\\1' in replacement:
464
+ return re.sub(pattern, replacement, full_reason, flags=re.IGNORECASE)
465
+ else:
466
+ return replacement
467
+
468
+ # If no pattern matches, try to extract the part before the first parenthesis
469
+ paren_match = re.match(r'^([^(]+)', full_reason)
470
+ if paren_match:
471
+ simplified = paren_match.group(1).strip()
472
+ # Remove trailing punctuation
473
+ simplified = re.sub(r'[.,;:]+$', '', simplified)
474
+ return simplified
475
+
476
+ # If all else fails, return the original but truncated
477
+ return full_reason[:50] + "..." if len(full_reason) > 50 else full_reason
478
+
479
+ def _deduplicate_crash_events(self, crash_events: List[Dict]) -> List[Dict]:
480
+ """
481
+ Deduplicate crash events based on exception type and stack trace
482
+
483
+ Args:
484
+ crash_events: List of crash events
485
+
486
+ Returns:
487
+ List[Dict]: Deduplicated crash events
488
+ """
489
+ seen_crashes = set()
490
+ unique_crashes = []
491
+
492
+ for crash in crash_events:
493
+ # Create a hash key based on exception type and first few lines of stack trace
494
+ exception_type = crash.get("exception_type", "")
495
+ stack_trace = crash.get("stack_trace", "")
496
+
497
+ # Use first 3 lines of stack trace for deduplication
498
+ stack_lines = stack_trace.split('\n')[:3]
499
+ crash_key = (exception_type, '\n'.join(stack_lines))
500
+
501
+ if crash_key not in seen_crashes:
502
+ seen_crashes.add(crash_key)
503
+ unique_crashes.append(crash)
504
+
505
+ return unique_crashes
506
+
507
+ def _deduplicate_anr_events(self, anr_events: List[Dict]) -> List[Dict]:
508
+ """
509
+ Deduplicate ANR events based on reason and process
510
+
511
+ Args:
512
+ anr_events: List of ANR events
513
+
514
+ Returns:
515
+ List[Dict]: Deduplicated ANR events
516
+ """
517
+ seen_anrs = set()
518
+ unique_anrs = []
519
+
520
+ for anr in anr_events:
521
+ # Create a hash key based on reason and process
522
+ reason = anr.get("reason", "")
523
+ process = anr.get("process", "")
524
+ anr_key = (reason, process)
525
+
526
+ if anr_key not in seen_anrs:
527
+ seen_anrs.add(anr_key)
528
+ unique_anrs.append(anr)
529
+
530
+ return unique_anrs
531
+
532
+ def _calculate_final_statistics(self, property_stats: Dict, coverage_data: Dict, crash_anr_data: Dict = None) -> Dict:
533
+ """
534
+ Calculate final statistics for template rendering
535
+
536
+ Note: Total bugs count only includes property test failures/errors,
537
+ not crashes or ANRs (which are tracked separately)
538
+
539
+ Args:
540
+ property_stats: Merged property statistics
541
+ coverage_data: Merged coverage data
542
+ crash_anr_data: Merged crash and ANR data (optional)
543
+
544
+ Returns:
545
+ Complete data for template rendering
546
+ """
547
+ # Calculate bug count from property failures
548
+ property_bugs_found = sum(1 for result in property_stats.values()
549
+ if result.get('fail', 0) > 0 or result.get('error', 0) > 0)
550
+
551
+ # Calculate property counts
552
+ all_properties_count = len(property_stats)
553
+ executed_properties_count = sum(1 for result in property_stats.values()
554
+ if result.get('executed', 0) > 0)
555
+
556
+ # Initialize crash/ANR data
557
+ crash_events = []
558
+ anr_events = []
559
+ total_crash_count = 0
560
+ total_anr_count = 0
561
+
562
+ if crash_anr_data:
563
+ crash_events = crash_anr_data.get('crash_events', [])
564
+ anr_events = crash_anr_data.get('anr_events', [])
565
+ total_crash_count = crash_anr_data.get('total_crash_count', 0)
566
+ total_anr_count = crash_anr_data.get('total_anr_count', 0)
567
+
568
+ # Calculate total bugs found (only property bugs, not including crashes/ANRs)
569
+ total_bugs_found = property_bugs_found
570
+
571
+ # Prepare final data
572
+ final_data = {
573
+ 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
574
+ 'bugs_found': total_bugs_found,
575
+ 'property_bugs_found': property_bugs_found,
576
+ 'all_properties_count': all_properties_count,
577
+ 'executed_properties_count': executed_properties_count,
578
+ 'property_stats': property_stats,
579
+ 'crash_events': crash_events,
580
+ 'anr_events': anr_events,
581
+ 'total_crash_count': total_crash_count,
582
+ 'total_anr_count': total_anr_count,
583
+ **coverage_data # Include all coverage data
584
+ }
585
+
586
+ return final_data
587
+
588
+ def get_merge_summary(self) -> Dict:
589
+ """
590
+ Get summary of the merge operation
591
+
592
+ Returns:
593
+ Dictionary containing merge summary information
594
+ """
595
+ if not self.result_dirs:
596
+ return {}
597
+
598
+ return {
599
+ "merged_directories": len(self.result_dirs),
600
+ "source_paths": [str(p) for p in self.result_dirs],
601
+ "merge_timestamp": datetime.now().isoformat()
602
+ }
603
+
604
+ def _generate_html_report(self, data: Dict, output_dir: Path) -> str:
605
+ """
606
+ Generate HTML report using the merged template
607
+
608
+ Args:
609
+ data: Final merged data
610
+ output_dir: Output directory
611
+
612
+ Returns:
613
+ Path to the generated HTML report
614
+ """
615
+ try:
616
+ from jinja2 import Environment, FileSystemLoader, PackageLoader, select_autoescape
617
+
618
+ # Set up Jinja2 environment
619
+ try:
620
+ jinja_env = Environment(
621
+ loader=PackageLoader("kea2", "templates"),
622
+ autoescape=select_autoescape(['html', 'xml'])
623
+ )
624
+ except (ImportError, ValueError):
625
+ # Fallback to file system loader
626
+ current_dir = Path(__file__).parent
627
+ templates_dir = current_dir / "templates"
628
+
629
+ jinja_env = Environment(
630
+ loader=FileSystemLoader(templates_dir),
631
+ autoescape=select_autoescape(['html', 'xml'])
632
+ )
633
+
634
+ # Render template
635
+ template = jinja_env.get_template("merged_bug_report_template.html")
636
+ html_content = template.render(**data)
637
+
638
+ # Save HTML report
639
+ report_file = output_dir / "merged_report.html"
640
+ with open(report_file, 'w', encoding='utf-8') as f:
641
+ f.write(html_content)
642
+
643
+ logger.debug(f"HTML report generated: {report_file}")
644
+ return str(report_file)
645
+
646
+ except Exception as e:
647
+ logger.error(f"Error generating HTML report: {e}")
648
+ raise
649
+
650
+
651
+