Files
Mainline/tests/acceptance_report.py

474 lines
12 KiB
Python

"""
HTML Acceptance Test Report Generator
Generates HTML reports showing frame buffers from acceptance tests.
Uses NullDisplay to capture frames and renders them with monospace font.
"""
import html
from datetime import datetime
from pathlib import Path
from typing import Any
ANSI_256_TO_RGB = {
0: (0, 0, 0),
1: (128, 0, 0),
2: (0, 128, 0),
3: (128, 128, 0),
4: (0, 0, 128),
5: (128, 0, 128),
6: (0, 128, 128),
7: (192, 192, 192),
8: (128, 128, 128),
9: (255, 0, 0),
10: (0, 255, 0),
11: (255, 255, 0),
12: (0, 0, 255),
13: (255, 0, 255),
14: (0, 255, 255),
15: (255, 255, 255),
}
def ansi_to_rgb(color_code: int) -> tuple[int, int, int]:
"""Convert ANSI 256-color code to RGB tuple."""
if 0 <= color_code <= 15:
return ANSI_256_TO_RGB.get(color_code, (255, 255, 255))
elif 16 <= color_code <= 231:
color_code -= 16
r = (color_code // 36) * 51
g = ((color_code % 36) // 6) * 51
b = (color_code % 6) * 51
return (r, g, b)
elif 232 <= color_code <= 255:
gray = (color_code - 232) * 10 + 8
return (gray, gray, gray)
return (255, 255, 255)
def parse_ansi_line(line: str) -> list[dict[str, Any]]:
"""Parse a single line with ANSI escape codes into styled segments.
Returns list of dicts with 'text', 'fg', 'bg', 'bold' keys.
"""
import re
segments = []
current_fg = None
current_bg = None
current_bold = False
pos = 0
# Find all ANSI escape sequences
escape_pattern = re.compile(r"\x1b\[([0-9;]*)m")
while pos < len(line):
match = escape_pattern.search(line, pos)
if not match:
# Remaining text with current styling
if pos < len(line):
text = line[pos:]
if text:
segments.append(
{
"text": text,
"fg": current_fg,
"bg": current_bg,
"bold": current_bold,
}
)
break
# Add text before escape sequence
if match.start() > pos:
text = line[pos : match.start()]
if text:
segments.append(
{
"text": text,
"fg": current_fg,
"bg": current_bg,
"bold": current_bold,
}
)
# Parse escape sequence
codes = match.group(1).split(";") if match.group(1) else ["0"]
for code in codes:
code = code.strip()
if not code or code == "0":
current_fg = None
current_bg = None
current_bold = False
elif code == "1":
current_bold = True
elif code.isdigit():
code_int = int(code)
if 30 <= code_int <= 37:
current_fg = ansi_to_rgb(code_int - 30 + 8)
elif 90 <= code_int <= 97:
current_fg = ansi_to_rgb(code_int - 90)
elif code_int == 38:
current_fg = (255, 255, 255)
elif code_int == 39:
current_fg = None
pos = match.end()
return segments
def render_line_to_html(line: str) -> str:
"""Render a single terminal line to HTML with styling."""
import re
result = ""
pos = 0
current_fg = None
current_bg = None
current_bold = False
escape_pattern = re.compile(r"(\x1b\[[0-9;]*m)|(\x1b\[([0-9]+);([0-9]+)H)")
while pos < len(line):
match = escape_pattern.search(line, pos)
if not match:
# Remaining text
if pos < len(line):
text = html.escape(line[pos:])
if text:
style = _build_style(current_fg, current_bg, current_bold)
result += f"<span{style}>{text}</span>"
break
# Handle cursor positioning - just skip it for rendering
if match.group(2): # Cursor positioning \x1b[row;colH
pos = match.end()
continue
# Handle style codes
if match.group(1):
codes = match.group(1)[2:-1].split(";") if match.group(1) else ["0"]
for code in codes:
code = code.strip()
if not code or code == "0":
current_fg = None
current_bg = None
current_bold = False
elif code == "1":
current_bold = True
elif code.isdigit():
code_int = int(code)
if 30 <= code_int <= 37:
current_fg = ansi_to_rgb(code_int - 30 + 8)
elif 90 <= code_int <= 97:
current_fg = ansi_to_rgb(code_int - 90)
pos = match.end()
continue
pos = match.end()
# Handle remaining text without escape codes
if pos < len(line):
text = html.escape(line[pos:])
if text:
style = _build_style(current_fg, current_bg, current_bold)
result += f"<span{style}>{text}</span>"
return result or html.escape(line)
def _build_style(
fg: tuple[int, int, int] | None, bg: tuple[int, int, int] | None, bold: bool
) -> str:
"""Build CSS style string from color values."""
styles = []
if fg:
styles.append(f"color: rgb({fg[0]},{fg[1]},{fg[2]})")
if bg:
styles.append(f"background-color: rgb({bg[0]},{bg[1]},{bg[2]})")
if bold:
styles.append("font-weight: bold")
if not styles:
return ""
return f' style="{"; ".join(styles)}"'
def render_frame_to_html(frame: list[str], frame_number: int = 0) -> str:
"""Render a complete frame (list of lines) to HTML."""
html_lines = []
for i, line in enumerate(frame):
# Strip ANSI cursor positioning but preserve colors
clean_line = (
line.replace("\x1b[1;1H", "")
.replace("\x1b[2;1H", "")
.replace("\x1b[3;1H", "")
)
rendered = render_line_to_html(clean_line)
html_lines.append(f'<div class="frame-line" data-line="{i}">{rendered}</div>')
return f"""<div class="frame" id="frame-{frame_number}">
<div class="frame-header">Frame {frame_number} ({len(frame)} lines)</div>
<div class="frame-content">
{"".join(html_lines)}
</div>
</div>"""
def generate_test_report(
test_name: str,
frames: list[list[str]],
status: str = "PASS",
duration_ms: float = 0.0,
metadata: dict[str, Any] | None = None,
) -> str:
"""Generate HTML report for a single test."""
frames_html = ""
for i, frame in enumerate(frames):
frames_html += render_frame_to_html(frame, i)
metadata_html = ""
if metadata:
metadata_html = '<div class="metadata">'
for key, value in metadata.items():
metadata_html += f'<div class="meta-row"><span class="meta-key">{key}:</span> <span class="meta-value">{value}</span></div>'
metadata_html += "</div>"
status_class = "pass" if status == "PASS" else "fail"
return f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>{test_name} - Acceptance Test Report</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 20px;
}}
.test-report {{
max-width: 1200px;
margin: 0 auto;
}}
.test-header {{
background: #16213e;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.test-name {{
font-size: 24px;
font-weight: bold;
color: #fff;
}}
.status {{
padding: 8px 16px;
border-radius: 4px;
font-weight: bold;
}}
.status.pass {{
background: #28a745;
color: white;
}}
.status.fail {{
background: #dc3545;
color: white;
}}
.frame {{
background: #0f0f1a;
border: 1px solid #333;
border-radius: 4px;
margin-bottom: 20px;
overflow: hidden;
}}
.frame-header {{
background: #16213e;
padding: 10px 15px;
font-size: 14px;
color: #888;
border-bottom: 1px solid #333;
}}
.frame-content {{
padding: 15px;
font-family: 'Fira Code', 'Consolas', 'Monaco', monospace;
font-size: 13px;
line-height: 1.4;
white-space: pre;
overflow-x: auto;
}}
.frame-line {{
min-height: 1.4em;
}}
.metadata {{
background: #16213e;
padding: 15px;
border-radius: 4px;
margin-bottom: 20px;
}}
.meta-row {{
display: flex;
gap: 20px;
font-size: 14px;
}}
.meta-key {{
color: #888;
}}
.meta-value {{
color: #fff;
}}
.footer {{
text-align: center;
color: #666;
font-size: 12px;
margin-top: 40px;
}}
</style>
</head>
<body>
<div class="test-report">
<div class="test-header">
<div class="test-name">{test_name}</div>
<div class="status {status_class}">{status}</div>
</div>
{metadata_html}
{frames_html}
<div class="footer">
Generated: {datetime.now().isoformat()}
</div>
</div>
</body>
</html>"""
def save_report(
test_name: str,
frames: list[list[str]],
output_dir: str = "test-reports",
status: str = "PASS",
duration_ms: float = 0.0,
metadata: dict[str, Any] | None = None,
) -> str:
"""Save HTML report to disk and return the file path."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
# Sanitize test name for filename
safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in test_name)
filename = f"{safe_name}.html"
filepath = output_path / filename
html_content = generate_test_report(
test_name, frames, status, duration_ms, metadata
)
filepath.write_text(html_content)
return str(filepath)
def save_index_report(
reports: list[dict[str, Any]],
output_dir: str = "test-reports",
) -> str:
"""Generate an index HTML page linking to all test reports."""
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
rows = ""
for report in reports:
safe_name = "".join(
c if c.isalnum() or c in "-_" else "_" for c in report["test_name"]
)
filename = f"{safe_name}.html"
status_class = "pass" if report["status"] == "PASS" else "fail"
rows += f"""
<tr>
<td><a href="{filename}">{report["test_name"]}</a></td>
<td class="status {status_class}">{report["status"]}</td>
<td>{report.get("duration_ms", 0):.1f}ms</td>
<td>{report.get("frame_count", 0)}</td>
</tr>
"""
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Acceptance Test Reports</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #1a1a2e;
color: #eee;
margin: 0;
padding: 40px;
}}
h1 {{
color: #fff;
margin-bottom: 30px;
}}
table {{
width: 100%;
border-collapse: collapse;
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #333;
}}
th {{
background: #16213e;
color: #888;
font-weight: normal;
}}
a {{
color: #4dabf7;
text-decoration: none;
}}
a:hover {{
text-decoration: underline;
}}
.status {{
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
}}
.status.pass {{
background: #28a745;
color: white;
}}
.status.fail {{
background: #dc3545;
color: white;
}}
</style>
</head>
<body>
<h1>Acceptance Test Reports</h1>
<table>
<thead>
<tr>
<th>Test</th>
<th>Status</th>
<th>Duration</th>
<th>Frames</th>
</tr>
</thead>
<tbody>
{rows}
</tbody>
</table>
</body>
</html>"""
index_path = output_path / "index.html"
index_path.write_text(html)
return str(index_path)