474 lines
12 KiB
Python
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)
|