chore: import upstream snapshot with attribution
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Pytest plugins for test logging optimization"""
|
||||
@@ -0,0 +1,846 @@
|
||||
"""
|
||||
Pytest plugin for conditional logging based on test outcome.
|
||||
|
||||
This plugin captures logs per test in memory buffers and only persists
|
||||
detailed logs for failed/xpass tests:
|
||||
- PASSED tests: Only metadata saved (nodeid, duration, timestamp)
|
||||
- FAILED tests: Full logs + error traceback + metadata
|
||||
- SKIPPED tests: Only metadata + skip reason
|
||||
- XFAIL tests: Only metadata + xfail reason (expected failures)
|
||||
- XPASS tests: Full logs + xfail reason (unexpected passes - need attention)
|
||||
|
||||
Generates two report formats:
|
||||
- test_report.json: AI-friendly structured format with complete test data
|
||||
- test_report.html: Human-readable HTML report with color-coded logs
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class LogBuffer:
|
||||
"""Captures logs per test in memory"""
|
||||
|
||||
def __init__(self, test_nodeid):
|
||||
self.test_nodeid = test_nodeid
|
||||
self.records = [] # List of LogRecord objects
|
||||
self.start_time = None
|
||||
self.end_time = None
|
||||
|
||||
def emit(self, record):
|
||||
"""Add a log record to the buffer - must be thread-safe"""
|
||||
# Make a copy of the record to avoid mutation issues
|
||||
try:
|
||||
self.records.append(record)
|
||||
except Exception:
|
||||
pass # Silently ignore errors during capture
|
||||
|
||||
def get_summary(self, report):
|
||||
"""Extract test name, duration, outcome"""
|
||||
duration = report.duration if hasattr(report, 'duration') else 0
|
||||
status = report.outcome.upper()
|
||||
return f"[{status}] {self.test_nodeid} ({duration:.2f}s)\n"
|
||||
|
||||
def get_full_logs(self, formatter):
|
||||
"""Format all captured records"""
|
||||
formatted_logs = []
|
||||
for record in self.records:
|
||||
try:
|
||||
formatted_logs.append(formatter.format(record))
|
||||
except Exception:
|
||||
pass # Skip records that can't be formatted
|
||||
return "\n".join(formatted_logs) if formatted_logs else "(No logs captured)"
|
||||
|
||||
def get_structured_logs(self):
|
||||
"""Get logs structured by level for JSON output"""
|
||||
structured = {
|
||||
'debug': [],
|
||||
'info': [],
|
||||
'warning': [],
|
||||
'error': [],
|
||||
'critical': []
|
||||
}
|
||||
|
||||
for record in self.records:
|
||||
try:
|
||||
level = record.levelname.lower()
|
||||
if level in structured:
|
||||
structured[level].append({
|
||||
'message': record.getMessage(),
|
||||
'timestamp': datetime.fromtimestamp(record.created).isoformat(),
|
||||
'location': f"{record.filename}:{record.lineno}",
|
||||
'logger': record.name
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return structured
|
||||
|
||||
|
||||
class ConditionalLogHandler(logging.Handler):
|
||||
"""Routes logs to per-test buffers"""
|
||||
|
||||
def __init__(self, log_config):
|
||||
super().__init__()
|
||||
self.current_test_buffer = None
|
||||
self.buffers = {} # test_nodeid -> LogBuffer
|
||||
self.log_config = log_config
|
||||
self.formatter = logging.Formatter(
|
||||
"[%(asctime)s - %(levelname)s - %(name)s]: %(message)s (%(filename)s:%(lineno)s)"
|
||||
)
|
||||
|
||||
# Initialize log files
|
||||
self.report_json = os.path.join(log_config.log_path, "test_report.json")
|
||||
self.report_html = os.path.join(log_config.log_path, "test_report.html")
|
||||
|
||||
# Track test statistics
|
||||
self.test_stats = {
|
||||
'passed': [],
|
||||
'failed': [],
|
||||
'skipped': [],
|
||||
'xfail': [], # Expected failures (tests marked xfail that failed as expected)
|
||||
'xpass': [], # Unexpected passes (tests marked xfail that passed unexpectedly)
|
||||
'start_time': None,
|
||||
'end_time': None
|
||||
}
|
||||
|
||||
# Worker mode support
|
||||
self.worker_id = ''
|
||||
self.is_worker = False
|
||||
|
||||
def emit(self, record):
|
||||
"""Capture log record into current test buffer - NO LOCKS to avoid deadlock"""
|
||||
# CRITICAL: No locks here to avoid deadlock with logging system
|
||||
try:
|
||||
if self.current_test_buffer:
|
||||
self.current_test_buffer.emit(record)
|
||||
except Exception:
|
||||
# Must silently ignore errors to avoid breaking logging system
|
||||
pass
|
||||
|
||||
def start_test(self, item):
|
||||
"""Create new buffer for this test"""
|
||||
buffer = LogBuffer(item.nodeid)
|
||||
buffer.start_time = datetime.now()
|
||||
self.buffers[item.nodeid] = buffer
|
||||
self.current_test_buffer = buffer
|
||||
|
||||
def end_test(self, item, report):
|
||||
"""Process test result and write logs"""
|
||||
buffer = self.buffers.get(item.nodeid)
|
||||
if buffer:
|
||||
buffer.end_time = datetime.now()
|
||||
|
||||
# Track statistics with structured data
|
||||
try:
|
||||
# Extract test location info
|
||||
test_parts = item.nodeid.split("::")
|
||||
file_path = test_parts[0] if test_parts else "unknown"
|
||||
test_class = test_parts[1] if len(test_parts) > 2 else None
|
||||
test_function = test_parts[-1] if test_parts else "unknown"
|
||||
|
||||
# Check for xfail/xpass first (wasxfail attribute indicates xfail marker)
|
||||
wasxfail = getattr(report, 'wasxfail', None)
|
||||
|
||||
if report.passed and wasxfail:
|
||||
# xpass: test was expected to fail but passed
|
||||
self.test_stats['xpass'].append({
|
||||
'nodeid': item.nodeid,
|
||||
'file': file_path,
|
||||
'class': test_class,
|
||||
'function': test_function,
|
||||
'duration': report.duration,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'xfail_reason': wasxfail,
|
||||
'logs': buffer.get_structured_logs()
|
||||
})
|
||||
elif report.skipped and wasxfail:
|
||||
# xfail: test was expected to fail and did fail (or was skipped due to xfail)
|
||||
self.test_stats['xfail'].append({
|
||||
'nodeid': item.nodeid,
|
||||
'file': file_path,
|
||||
'class': test_class,
|
||||
'function': test_function,
|
||||
'duration': report.duration,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'xfail_reason': wasxfail
|
||||
})
|
||||
elif report.passed:
|
||||
self.test_stats['passed'].append({
|
||||
'nodeid': item.nodeid,
|
||||
'file': file_path,
|
||||
'class': test_class,
|
||||
'function': test_function,
|
||||
'duration': report.duration,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
elif report.failed:
|
||||
# Extract error information
|
||||
error_info = self._extract_error_info(report)
|
||||
|
||||
self.test_stats['failed'].append({
|
||||
'nodeid': item.nodeid,
|
||||
'file': file_path,
|
||||
'class': test_class,
|
||||
'function': test_function,
|
||||
'duration': report.duration,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'error': error_info,
|
||||
'logs': buffer.get_structured_logs()
|
||||
})
|
||||
elif report.skipped:
|
||||
self.test_stats['skipped'].append({
|
||||
'nodeid': item.nodeid,
|
||||
'file': file_path,
|
||||
'class': test_class,
|
||||
'function': test_function,
|
||||
'duration': report.duration,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
'reason': str(report.longrepr[2]) if hasattr(report, 'longrepr') and len(report.longrepr) > 2 else 'Unknown'
|
||||
})
|
||||
except Exception:
|
||||
pass # Silently ignore write errors
|
||||
|
||||
# Clear buffer to free memory
|
||||
if item.nodeid in self.buffers:
|
||||
del self.buffers[item.nodeid]
|
||||
|
||||
# Clear current buffer if it matches
|
||||
if self.current_test_buffer is buffer:
|
||||
self.current_test_buffer = None
|
||||
|
||||
def _extract_error_info(self, report):
|
||||
"""Extract structured error information from test report"""
|
||||
error_info = {
|
||||
'type': 'Unknown',
|
||||
'message': '',
|
||||
'traceback': '',
|
||||
'line_number': None,
|
||||
'file': None
|
||||
}
|
||||
|
||||
try:
|
||||
if hasattr(report, 'longreprtext'):
|
||||
error_info['traceback'] = report.longreprtext
|
||||
|
||||
# Try to extract error type and message
|
||||
lines = report.longreprtext.split('\n')
|
||||
for line in lines:
|
||||
if line.startswith('E '):
|
||||
error_info['message'] = line[8:].strip()
|
||||
# Try to extract error type
|
||||
if ':' in error_info['message']:
|
||||
parts = error_info['message'].split(':', 1)
|
||||
error_info['type'] = parts[0].strip()
|
||||
break
|
||||
|
||||
# Try to extract file and line number from traceback
|
||||
for line in lines:
|
||||
if '.py:' in line and '>' in line:
|
||||
parts = line.split('.py:')
|
||||
if len(parts) >= 2:
|
||||
error_info['file'] = parts[0].split()[-1] + '.py'
|
||||
line_num = parts[1].split()[0]
|
||||
try:
|
||||
error_info['line_number'] = int(line_num)
|
||||
except:
|
||||
pass
|
||||
break
|
||||
|
||||
elif hasattr(report, 'longrepr'):
|
||||
error_info['traceback'] = str(report.longrepr)
|
||||
error_info['message'] = str(report.longrepr)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return error_info
|
||||
|
||||
def _save_worker_data(self, filepath):
|
||||
"""Save worker's test data to file for later merging"""
|
||||
try:
|
||||
# All data is already serializable (no report/buffer objects)
|
||||
serializable_stats = {
|
||||
'passed': self.test_stats['passed'],
|
||||
'skipped': self.test_stats['skipped'],
|
||||
'failed': self.test_stats['failed'],
|
||||
'xfail': self.test_stats['xfail'],
|
||||
'xpass': self.test_stats['xpass'],
|
||||
'start_time': self.test_stats['start_time'].isoformat() if self.test_stats['start_time'] else None,
|
||||
'end_time': self.test_stats['end_time'].isoformat() if self.test_stats['end_time'] else None
|
||||
}
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(serializable_stats, f)
|
||||
except Exception as e:
|
||||
print(f"Error saving worker data: {e}")
|
||||
|
||||
def _merge_worker_data(self):
|
||||
"""Merge data from all worker processes"""
|
||||
try:
|
||||
import glob
|
||||
|
||||
# Find all worker data files
|
||||
worker_files = glob.glob(os.path.join(self.log_config.log_path, ".worker_*_data.json"))
|
||||
|
||||
for worker_file in worker_files:
|
||||
try:
|
||||
with open(worker_file, 'r', encoding='utf-8') as f:
|
||||
worker_data = json.load(f)
|
||||
|
||||
# Merge data
|
||||
self.test_stats['passed'].extend(worker_data.get('passed', []))
|
||||
self.test_stats['skipped'].extend(worker_data.get('skipped', []))
|
||||
self.test_stats['failed'].extend(worker_data.get('failed', []))
|
||||
self.test_stats['xfail'].extend(worker_data.get('xfail', []))
|
||||
self.test_stats['xpass'].extend(worker_data.get('xpass', []))
|
||||
|
||||
# Delete worker file after merging
|
||||
os.remove(worker_file)
|
||||
except Exception as e:
|
||||
print(f"Error merging worker file {worker_file}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in merge_worker_data: {e}")
|
||||
|
||||
def generate_json_report(self):
|
||||
"""Generate AI-friendly JSON report"""
|
||||
try:
|
||||
total = (len(self.test_stats['passed']) + len(self.test_stats['failed']) +
|
||||
len(self.test_stats['skipped']) + len(self.test_stats['xfail']) +
|
||||
len(self.test_stats['xpass']))
|
||||
total_duration = (sum(t['duration'] for t in self.test_stats['passed']) +
|
||||
sum(t['duration'] for t in self.test_stats['failed']) +
|
||||
sum(t['duration'] for t in self.test_stats['skipped']) +
|
||||
sum(t['duration'] for t in self.test_stats['xfail']) +
|
||||
sum(t['duration'] for t in self.test_stats['xpass']))
|
||||
|
||||
# Build JSON structure optimized for AI analysis
|
||||
report_data = {
|
||||
'metadata': {
|
||||
'generated_at': datetime.now().isoformat(),
|
||||
'log_location': self.log_config.log_path,
|
||||
'total_tests': total,
|
||||
'total_duration_seconds': round(total_duration, 2),
|
||||
'start_time': self.test_stats['start_time'].isoformat() if self.test_stats['start_time'] else None,
|
||||
'end_time': self.test_stats['end_time'].isoformat() if self.test_stats['end_time'] else None
|
||||
},
|
||||
'summary': {
|
||||
'passed': {
|
||||
'count': len(self.test_stats['passed']),
|
||||
'percentage': round(len(self.test_stats['passed']) / total * 100, 1) if total > 0 else 0
|
||||
},
|
||||
'failed': {
|
||||
'count': len(self.test_stats['failed']),
|
||||
'percentage': round(len(self.test_stats['failed']) / total * 100, 1) if total > 0 else 0
|
||||
},
|
||||
'skipped': {
|
||||
'count': len(self.test_stats['skipped']),
|
||||
'percentage': round(len(self.test_stats['skipped']) / total * 100, 1) if total > 0 else 0
|
||||
},
|
||||
'xfail': {
|
||||
'count': len(self.test_stats['xfail']),
|
||||
'percentage': round(len(self.test_stats['xfail']) / total * 100, 1) if total > 0 else 0
|
||||
},
|
||||
'xpass': {
|
||||
'count': len(self.test_stats['xpass']),
|
||||
'percentage': round(len(self.test_stats['xpass']) / total * 100, 1) if total > 0 else 0
|
||||
}
|
||||
},
|
||||
'tests': {
|
||||
'passed': [
|
||||
{
|
||||
'id': t['nodeid'],
|
||||
'file': t['file'],
|
||||
'class': t['class'],
|
||||
'function': t['function'],
|
||||
'duration': round(t['duration'], 3),
|
||||
'timestamp': t['timestamp']
|
||||
}
|
||||
for t in self.test_stats['passed']
|
||||
],
|
||||
'failed': [
|
||||
{
|
||||
'id': t['nodeid'],
|
||||
'file': t['file'],
|
||||
'class': t['class'],
|
||||
'function': t['function'],
|
||||
'duration': round(t['duration'], 3),
|
||||
'timestamp': t['timestamp'],
|
||||
'error': {
|
||||
'type': t['error']['type'],
|
||||
'message': t['error']['message'],
|
||||
'file': t['error']['file'],
|
||||
'line': t['error']['line_number'],
|
||||
'traceback': t['error']['traceback']
|
||||
},
|
||||
'logs': {
|
||||
'debug': t['logs']['debug'],
|
||||
'info': t['logs']['info'],
|
||||
'warning': t['logs']['warning'],
|
||||
'error': t['logs']['error'],
|
||||
'critical': t['logs']['critical']
|
||||
}
|
||||
}
|
||||
for t in self.test_stats['failed']
|
||||
],
|
||||
'skipped': [
|
||||
{
|
||||
'id': t['nodeid'],
|
||||
'file': t['file'],
|
||||
'class': t['class'],
|
||||
'function': t['function'],
|
||||
'duration': round(t['duration'], 3),
|
||||
'timestamp': t['timestamp'],
|
||||
'reason': t['reason']
|
||||
}
|
||||
for t in self.test_stats['skipped']
|
||||
],
|
||||
'xfail': [
|
||||
{
|
||||
'id': t['nodeid'],
|
||||
'file': t['file'],
|
||||
'class': t['class'],
|
||||
'function': t['function'],
|
||||
'duration': round(t['duration'], 3),
|
||||
'timestamp': t['timestamp'],
|
||||
'xfail_reason': t['xfail_reason']
|
||||
}
|
||||
for t in self.test_stats['xfail']
|
||||
],
|
||||
'xpass': [
|
||||
{
|
||||
'id': t['nodeid'],
|
||||
'file': t['file'],
|
||||
'class': t['class'],
|
||||
'function': t['function'],
|
||||
'duration': round(t['duration'], 3),
|
||||
'timestamp': t['timestamp'],
|
||||
'xfail_reason': t['xfail_reason'],
|
||||
'logs': {
|
||||
'debug': t['logs']['debug'],
|
||||
'info': t['logs']['info'],
|
||||
'warning': t['logs']['warning'],
|
||||
'error': t['logs']['error'],
|
||||
'critical': t['logs']['critical']
|
||||
}
|
||||
}
|
||||
for t in self.test_stats['xpass']
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Write JSON report with pretty printing
|
||||
with open(self.report_json, 'w', encoding='utf-8') as f:
|
||||
json.dump(report_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating JSON report: {e}")
|
||||
|
||||
def generate_html_report(self):
|
||||
"""Generate HTML test report for humans"""
|
||||
try:
|
||||
total = (len(self.test_stats['passed']) + len(self.test_stats['failed']) +
|
||||
len(self.test_stats['skipped']) + len(self.test_stats['xfail']) +
|
||||
len(self.test_stats['xpass']))
|
||||
total_duration = (sum(t['duration'] for t in self.test_stats['passed']) +
|
||||
sum(t['duration'] for t in self.test_stats['failed']) +
|
||||
sum(t['duration'] for t in self.test_stats['skipped']) +
|
||||
sum(t['duration'] for t in self.test_stats['xfail']) +
|
||||
sum(t['duration'] for t in self.test_stats['xpass']))
|
||||
|
||||
passed_pct = (len(self.test_stats['passed'])/total*100) if total > 0 else 0
|
||||
failed_pct = (len(self.test_stats['failed'])/total*100) if total > 0 else 0
|
||||
skipped_pct = (len(self.test_stats['skipped'])/total*100) if total > 0 else 0
|
||||
xfail_pct = (len(self.test_stats['xfail'])/total*100) if total > 0 else 0
|
||||
xpass_pct = (len(self.test_stats['xpass'])/total*100) if total > 0 else 0
|
||||
|
||||
with open(self.report_html, 'w', encoding='utf-8') as f:
|
||||
# HTML header
|
||||
f.write("""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Milvus Python Test Report</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.summary-card {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.summary-card h3 { margin: 0 0 10px 0; color: #666; font-size: 14px; }
|
||||
.summary-card .value { font-size: 32px; font-weight: bold; }
|
||||
.passed { color: #10b981; }
|
||||
.failed { color: #ef4444; }
|
||||
.skipped { color: #f59e0b; }
|
||||
.xfail { color: #8b5cf6; }
|
||||
.xpass { color: #ec4899; }
|
||||
.test-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-section h2 {
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.test-item {
|
||||
padding: 10px;
|
||||
margin: 5px 0;
|
||||
border-left: 4px solid;
|
||||
background: #f9fafb;
|
||||
}
|
||||
.test-item.passed { border-color: #10b981; }
|
||||
.test-item.failed { border-color: #ef4444; }
|
||||
.test-item.skipped { border-color: #f59e0b; }
|
||||
.test-item.xfail { border-color: #8b5cf6; }
|
||||
.test-item.xpass { border-color: #ec4899; }
|
||||
.test-name { font-family: monospace; font-size: 13px; }
|
||||
.duration { color: #6b7280; font-size: 12px; }
|
||||
.error-box {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.logs-box {
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.log-entry { margin: 3px 0; }
|
||||
.log-debug { color: #6b7280; }
|
||||
.log-info { color: #3b82f6; }
|
||||
.log-warning { color: #f59e0b; }
|
||||
.log-error { color: #ef4444; }
|
||||
.log-critical { color: #dc2626; font-weight: bold; }
|
||||
details { margin: 10px 0; }
|
||||
summary { cursor: pointer; font-weight: 600; padding: 10px; background: #f9fafb; }
|
||||
summary:hover { background: #f3f4f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Milvus Python Test Report</h1>
|
||||
<p>Generated at: """ + datetime.now().strftime('%Y-%m-%d %H:%M:%S') + f"""</p>
|
||||
<p>Log location: {self.log_config.log_path}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-card">
|
||||
<h3>Total Tests</h3>
|
||||
<div class="value">{total}</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>Passed</h3>
|
||||
<div class="value passed">{len(self.test_stats['passed'])}</div>
|
||||
<div class="duration">{passed_pct:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>Failed</h3>
|
||||
<div class="value failed">{len(self.test_stats['failed'])}</div>
|
||||
<div class="duration">{failed_pct:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>Skipped</h3>
|
||||
<div class="value skipped">{len(self.test_stats['skipped'])}</div>
|
||||
<div class="duration">{skipped_pct:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>XFail</h3>
|
||||
<div class="value xfail">{len(self.test_stats['xfail'])}</div>
|
||||
<div class="duration">{xfail_pct:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>XPass</h3>
|
||||
<div class="value xpass">{len(self.test_stats['xpass'])}</div>
|
||||
<div class="duration">{xpass_pct:.1f}%</div>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<h3>Duration</h3>
|
||||
<div class="value">{total_duration:.2f}s</div>
|
||||
</div>
|
||||
</div>
|
||||
""")
|
||||
|
||||
# Failed tests section
|
||||
if self.test_stats['failed']:
|
||||
f.write(' <div class="test-section">\n')
|
||||
f.write(' <h2>❌ Failed Tests</h2>\n')
|
||||
for test_info in self.test_stats['failed']:
|
||||
f.write(f' <details open>\n')
|
||||
f.write(f' <summary class="test-item failed">\n')
|
||||
f.write(f' <span class="test-name">{test_info["nodeid"]}</span>\n')
|
||||
f.write(f' <span class="duration"> ({test_info["duration"]:.2f}s)</span>\n')
|
||||
f.write(f' </summary>\n')
|
||||
|
||||
# Error information - use data from test_info['error']
|
||||
error_info = test_info.get('error', {})
|
||||
if error_info:
|
||||
f.write(' <div class="error-box">\n')
|
||||
error_type = error_info.get('type', 'Unknown')
|
||||
error_msg = error_info.get('message', '')
|
||||
error_traceback = error_info.get('traceback', '')
|
||||
|
||||
# Format error display
|
||||
if error_traceback:
|
||||
error_text = error_traceback.replace('<', '<').replace('>', '>')
|
||||
else:
|
||||
error_text = f"{error_type}: {error_msg}".replace('<', '<').replace('>', '>')
|
||||
|
||||
f.write(f'{error_text}\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
# Captured logs - use data from test_info['logs']
|
||||
logs = test_info.get('logs', {})
|
||||
has_logs = any(logs.get(level, []) for level in ['debug', 'info', 'warning', 'error', 'critical'])
|
||||
|
||||
if has_logs:
|
||||
f.write(' <div class="logs-box">\n')
|
||||
f.write(' <strong>Captured Logs:</strong>\n')
|
||||
|
||||
for level in ['debug', 'info', 'warning', 'error', 'critical']:
|
||||
for log_entry in logs.get(level, []):
|
||||
msg = log_entry['message'].replace('<', '<').replace('>', '>')
|
||||
ts = log_entry['timestamp'].split('T')[1].split('.')[0]
|
||||
loc = log_entry['location']
|
||||
f.write(f' <div class="log-entry log-{level}">')
|
||||
f.write(f'[{ts} - {level.upper()}] {msg} ({loc})</div>\n')
|
||||
|
||||
f.write(' </div>\n')
|
||||
|
||||
f.write(' </details>\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
# XPass tests section (unexpected passes - need attention)
|
||||
if self.test_stats['xpass']:
|
||||
f.write(' <div class="test-section">\n')
|
||||
f.write(' <h2>⚠️ XPass Tests (Unexpected Passes)</h2>\n')
|
||||
for test_info in self.test_stats['xpass']:
|
||||
f.write(f' <details open>\n')
|
||||
f.write(f' <summary class="test-item xpass">\n')
|
||||
f.write(f' <span class="test-name">{test_info["nodeid"]}</span>\n')
|
||||
f.write(f' <span class="duration"> ({test_info["duration"]:.2f}s)</span>\n')
|
||||
f.write(f' </summary>\n')
|
||||
|
||||
# XFail reason
|
||||
xfail_reason = test_info.get('xfail_reason', 'Unknown')
|
||||
if xfail_reason:
|
||||
reason_text = str(xfail_reason).replace('<', '<').replace('>', '>')
|
||||
f.write(f' <div style="margin:10px 0;padding:10px;background:#fdf4ff;border:1px solid #ec4899;border-radius:4px;">\n')
|
||||
f.write(f' <strong>Expected to fail:</strong> {reason_text}\n')
|
||||
f.write(f' </div>\n')
|
||||
|
||||
# Captured logs
|
||||
logs = test_info.get('logs', {})
|
||||
has_logs = any(logs.get(level, []) for level in ['debug', 'info', 'warning', 'error', 'critical'])
|
||||
|
||||
if has_logs:
|
||||
f.write(' <div class="logs-box">\n')
|
||||
f.write(' <strong>Captured Logs:</strong>\n')
|
||||
|
||||
for level in ['debug', 'info', 'warning', 'error', 'critical']:
|
||||
for log_entry in logs.get(level, []):
|
||||
msg = log_entry['message'].replace('<', '<').replace('>', '>')
|
||||
ts = log_entry['timestamp'].split('T')[1].split('.')[0]
|
||||
loc = log_entry['location']
|
||||
f.write(f' <div class="log-entry log-{level}">')
|
||||
f.write(f'[{ts} - {level.upper()}] {msg} ({loc})</div>\n')
|
||||
|
||||
f.write(' </div>\n')
|
||||
|
||||
f.write(' </details>\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
# XFail tests section (expected failures)
|
||||
if self.test_stats['xfail']:
|
||||
f.write(' <div class="test-section">\n')
|
||||
f.write(' <h2>📋 XFail Tests (Expected Failures)</h2>\n')
|
||||
for test in self.test_stats['xfail']:
|
||||
f.write(f' <div class="test-item xfail">\n')
|
||||
f.write(f' <span class="test-name">{test["nodeid"]}</span>\n')
|
||||
f.write(f' <span class="duration"> ({test["duration"]:.2f}s)</span>\n')
|
||||
xfail_reason = test.get('xfail_reason', 'Unknown')
|
||||
if xfail_reason:
|
||||
reason_text = str(xfail_reason).replace('<', '<').replace('>', '>')
|
||||
f.write(f' <div style="margin-top:5px;color:#6b7280;font-size:12px;">Expected: {reason_text}</div>\n')
|
||||
f.write(f' </div>\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
# Passed tests section
|
||||
if self.test_stats['passed']:
|
||||
f.write(' <div class="test-section">\n')
|
||||
f.write(' <h2>✅ Passed Tests</h2>\n')
|
||||
for test in self.test_stats['passed']:
|
||||
f.write(f' <div class="test-item passed">\n')
|
||||
f.write(f' <span class="test-name">{test["nodeid"]}</span>\n')
|
||||
f.write(f' <span class="duration"> ({test["duration"]:.2f}s)</span>\n')
|
||||
f.write(f' </div>\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
# Skipped tests section
|
||||
if self.test_stats['skipped']:
|
||||
f.write(' <div class="test-section">\n')
|
||||
f.write(' <h2>⊘ Skipped Tests</h2>\n')
|
||||
for test in self.test_stats['skipped']:
|
||||
f.write(f' <div class="test-item skipped">\n')
|
||||
f.write(f' <span class="test-name">{test["nodeid"]}</span>\n')
|
||||
f.write(f' <span class="duration"> ({test["duration"]:.2f}s)</span>\n')
|
||||
if 'reason' in test:
|
||||
reason = test['reason'].replace('<', '<').replace('>', '>')
|
||||
f.write(f' <div style="margin-top:5px;color:#6b7280;font-size:12px;">Reason: {reason}</div>\n')
|
||||
f.write(f' </div>\n')
|
||||
f.write(' </div>\n')
|
||||
|
||||
f.write("""
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating HTML report: {e}")
|
||||
|
||||
def generate_report(self):
|
||||
"""Generate unified test report (JSON and HTML)"""
|
||||
# Generate JSON report for AI
|
||||
self.generate_json_report()
|
||||
|
||||
# Generate HTML report for humans
|
||||
self.generate_html_report()
|
||||
|
||||
|
||||
# Global handler instance
|
||||
_conditional_handler = None
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
"""Initialize the conditional log handler"""
|
||||
global _conditional_handler
|
||||
|
||||
# Only initialize if not already done
|
||||
if _conditional_handler is None:
|
||||
try:
|
||||
from config.log_config import log_config
|
||||
_conditional_handler = ConditionalLogHandler(log_config)
|
||||
_conditional_handler.setLevel(logging.DEBUG)
|
||||
|
||||
# Record start time
|
||||
_conditional_handler.test_stats['start_time'] = datetime.now()
|
||||
|
||||
# Detect if running in xdist worker mode
|
||||
worker_id = os.environ.get('PYTEST_XDIST_WORKER', '')
|
||||
_conditional_handler.worker_id = worker_id
|
||||
_conditional_handler.is_worker = bool(worker_id)
|
||||
|
||||
# Pre-create log files
|
||||
for log_file in [_conditional_handler.report_json,
|
||||
_conditional_handler.report_html]:
|
||||
if not os.path.exists(log_file):
|
||||
try:
|
||||
with open(log_file, 'w', encoding='utf-8') as f:
|
||||
pass # Create empty file
|
||||
except Exception:
|
||||
pass # Ignore errors
|
||||
|
||||
# Add handler to root logger
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(_conditional_handler)
|
||||
except Exception:
|
||||
# If plugin initialization fails, don't break pytest
|
||||
pass
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_setup(item):
|
||||
"""Hook before test setup"""
|
||||
global _conditional_handler
|
||||
if _conditional_handler:
|
||||
try:
|
||||
_conditional_handler.start_test(item)
|
||||
except Exception:
|
||||
pass # Don't break test execution
|
||||
yield
|
||||
|
||||
|
||||
@pytest.hookimpl(hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""Hook after each test phase (setup, call, teardown)"""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
# Only process after the main test execution (call phase)
|
||||
if report.when == "call":
|
||||
global _conditional_handler
|
||||
if _conditional_handler:
|
||||
try:
|
||||
_conditional_handler.end_test(item, report)
|
||||
except Exception:
|
||||
pass # Don't break test execution
|
||||
|
||||
|
||||
def pytest_sessionfinish(session, exitstatus):
|
||||
"""Hook at the end of test session"""
|
||||
global _conditional_handler
|
||||
if _conditional_handler:
|
||||
try:
|
||||
# Record end time
|
||||
_conditional_handler.test_stats['end_time'] = datetime.now()
|
||||
|
||||
if _conditional_handler.is_worker:
|
||||
# Worker mode: save data to temporary file
|
||||
worker_data_file = os.path.join(
|
||||
_conditional_handler.log_config.log_path,
|
||||
f".worker_{_conditional_handler.worker_id}_data.json"
|
||||
)
|
||||
_conditional_handler._save_worker_data(worker_data_file)
|
||||
else:
|
||||
# Main process or single process mode
|
||||
# Collect data from workers if any
|
||||
_conditional_handler._merge_worker_data()
|
||||
|
||||
# Generate unified report
|
||||
_conditional_handler.generate_report()
|
||||
|
||||
# Clear any remaining references
|
||||
_conditional_handler.current_test_buffer = None
|
||||
except Exception as e:
|
||||
print(f"Error in pytest_sessionfinish: {e}")
|
||||
Reference in New Issue
Block a user