""" 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"{text}" 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"{text}" 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'
| Test | Status | Duration | Frames |
|---|