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/assets/monkeyq.jar +0 -0
- kea2/bug_report_generator.py +267 -7
- kea2/cli.py +71 -2
- kea2/report_merger.py +651 -0
- kea2/templates/bug_report_template.html +1583 -68
- kea2/templates/merged_bug_report_template.html +2547 -0
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/METADATA +10 -3
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/RECORD +12 -10
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/WHEEL +0 -0
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/entry_points.txt +0 -0
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/licenses/LICENSE +0 -0
- {kea2_python-0.2.4.dist-info → kea2_python-0.3.0.dist-info}/top_level.txt +0 -0
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
|
+
|