"""HTML report generator for SynapBus E2E test results.""" from __future__ import annotations import html import json import os import time from typing import Any, Dict, List, Optional from .agent_runner import TestResult, AgentRun, ToolCall def _truncate(s: str, max_len: int = 500) -> str: if len(s) <= max_len: return s return s[:max_len] + "..." def _json_pretty(data: Any, max_len: int = 500) -> str: try: raw = json.dumps(data, indent=2, default=str) except (TypeError, ValueError): raw = str(data) return html.escape(_truncate(raw, max_len)) def _status_badge(status: str) -> str: colors = { "pass": "#10b981", "fail": "#ef4444", "error": "#f59e0b", } bg = colors.get(status, "#6b7280") return '{label}'.format( bg=bg, label=html.escape(status.upper())) def _bool_icon(passed: bool) -> str: if passed: return 'PASS' return 'FAIL' def _format_duration(seconds: float) -> str: if seconds < 1: return "{:.0f}ms".format(seconds * 1000) return "{:.1f}s".format(seconds) def _format_tokens(n: int) -> str: if n >= 1000: return "{:.1f}k".format(n / 1000) return str(n) def _estimate_cost(input_tokens: int, output_tokens: int, model: str) -> float: """Estimate cost in USD. Sonnet pricing: $3/M input, $15/M output.""" if "opus" in model: return (input_tokens * 15 + output_tokens * 75) / 1_000_000 if "haiku" in model: return (input_tokens * 0.25 + output_tokens * 1.25) / 1_000_000 # Default Sonnet return (input_tokens * 3 + output_tokens * 15) / 1_000_000 def generate_report( results: List[TestResult], model: str, base_url: str, total_duration: float, output_path: str, ) -> str: """Generate a self-contained HTML report file.""" passed = sum(1 for r in results if r.status == "pass") failed = sum(1 for r in results if r.status == "fail") errored = sum(1 for r in results if r.status == "error") total_input = sum(r.total_input_tokens for r in results) total_output = sum(r.total_output_tokens for r in results) total_cost = _estimate_cost(total_input, total_output, model) total_tool_calls = sum(len(tc) for r in results for run in r.runs for tc in [run.tool_calls]) scenarios_html = [] for idx, result in enumerate(results): scenarios_html.append(_render_scenario(result, idx, model)) report_html = TEMPLATE.format( timestamp=time.strftime("%Y-%m-%d %H:%M:%S"), model=html.escape(model), base_url=html.escape(base_url), total=len(results), passed=passed, failed=failed, errored=errored, total_duration=_format_duration(total_duration), total_input=_format_tokens(total_input), total_output=_format_tokens(total_output), total_cost="{:.4f}".format(total_cost), total_tool_calls=total_tool_calls, scenarios="\n".join(scenarios_html), pass_rate="{:.0f}".format(100 * passed / len(results)) if results else "0", ) with open(output_path, "w") as f: f.write(report_html) return output_path def _render_scenario(result: TestResult, idx: int, model: str) -> str: cost = _estimate_cost(result.total_input_tokens, result.total_output_tokens, model) # Render verifications verifications_html = "" if result.verifications: rows = [] for v in result.verifications: rows.append( '{icon}{check}{detail}'.format( icon=_bool_icon(v.get("passed", False)), check=html.escape(v.get("check", "")), detail=html.escape(_truncate(v.get("detail", ""), 200)), ) ) verifications_html = """

Verifications

{rows}
StatusCheckDetail
""".format(rows="\n".join(rows)) # Render agent runs runs_html_parts = [] for run in result.runs: runs_html_parts.append(_render_agent_run(run)) # Error section error_html = "" if result.error: error_html = """

Error

{error}
""".format(error=html.escape(result.error)) border_color = {"pass": "#10b981", "fail": "#ef4444", "error": "#f59e0b"}.get( result.status, "#6b7280") return """
{badge} {name}
{duration} {input_tokens} in / {output_tokens} out ${cost} {agent_count} agents

{description}

Agents: {agents}
{verifications} {error}

Agent Runs

{runs}
""".format( border_color=border_color, idx=idx, badge=_status_badge(result.status), name=html.escape(result.name), duration=_format_duration(result.duration), input_tokens=_format_tokens(result.total_input_tokens), output_tokens=_format_tokens(result.total_output_tokens), cost="{:.4f}".format(cost), agent_count=len(result.agents), description=html.escape(result.description), agents=", ".join(html.escape(a) for a in result.agents), verifications=verifications_html, error=error_html, runs="\n".join(runs_html_parts), ) def _render_agent_run(run: AgentRun) -> str: # Render tool calls tc_rows = [] for tc in run.tool_calls: status_cls = "tc-pass" if tc.success else "tc-fail" tc_id = "tc-{}-{}".format(id(tc), int(tc.timestamp * 1000)) tc_rows.append("""
{tool} {status} {duration}
""".format( status_cls=status_cls, tc_id=tc_id, tool=html.escape(tc.tool), status="OK" if tc.success else "ERR", duration=_format_duration(tc.duration), input=_json_pretty(tc.input), output=_json_pretty(tc.output), )) error_html = "" if run.error: error_html = '
Error: {}
'.format(html.escape(run.error)) return """
{agent_name} {duration} | {input_tokens} in / {output_tokens} out | {tc_count} tool calls
{error}
Prompts
System:
{system_prompt}
User:
{user_prompt}
Response
{response}
{tool_calls}
""".format( agent_name=html.escape(run.agent_name), duration=_format_duration(run.duration), input_tokens=_format_tokens(run.input_tokens), output_tokens=_format_tokens(run.output_tokens), tc_count=len(run.tool_calls), error=error_html, system_prompt=html.escape(_truncate(run.system_prompt, 300)), user_prompt=html.escape(_truncate(run.user_prompt, 500)), response=html.escape(_truncate(run.response_text, 800)), tool_calls="\n".join(tc_rows), ) TEMPLATE = """ SynapBus E2E Test Report

SynapBus E2E Test Report

Generated {timestamp} | Model: {model} | Server: {base_url}

{total}
Scenarios
{passed}
Passed
{failed}
Failed
{errored}
Errors
{total_duration}
Duration
{total_input} / {total_output}
Tokens In/Out
${total_cost}
Est. Cost
{total_tool_calls}
Tool Calls
{scenarios}
"""