forked from genewildish/Mainline
feat(tests): Add acceptance tests and HTML report generator
This commit is contained in:
473
tests/acceptance_report.py
Normal file
473
tests/acceptance_report.py
Normal file
@@ -0,0 +1,473 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user