package testrunner import ( "encoding/json" "fmt" "html/template" "os" "sort" "strings" "time" ) // WriteHTMLReport generates a single-file HTML report from a ScenarioResult. func WriteHTMLReport(result *ScenarioResult, path string) error { data := buildHTMLData(result) f, err := os.Create(path) if err != nil { return fmt.Errorf("create %s: %w", path, err) } defer f.Close() tmpl, err := template.New("report").Parse(htmlTemplate) if err != nil { return fmt.Errorf("parse template: %w", err) } return tmpl.Execute(f, data) } // htmlData is the template context. type htmlData struct { Name string Status string StatusCSS string Duration string Timestamp string Phases []htmlPhase Perf []htmlPerfEntry Metrics []htmlMetricsGroup Artifacts []ArtifactEntry Error string HasPerf bool HasMetrics bool HasArtifacts bool } type htmlPhase struct { Name string Status string StatusCSS string Duration string Actions []htmlAction } type htmlAction struct { Name string Status string StatusCSS string Duration string Output string Error string YAML string } type htmlPerfEntry struct { Name string Count int Mean string P50 string P90 string P99 string Min string Max string } type htmlMetricsGroup struct { Name string Entries []htmlMetricEntry } type htmlMetricEntry struct { Name string Value string } func buildHTMLData(result *ScenarioResult) htmlData { d := htmlData{ Name: result.Name, Status: string(result.Status), StatusCSS: statusCSS(result.Status), Duration: result.Duration.Round(time.Millisecond).String(), Timestamp: time.Now().Format(time.RFC3339), Error: result.Error, Artifacts: result.Artifacts, HasArtifacts: len(result.Artifacts) > 0, } for _, pr := range result.Phases { hp := htmlPhase{ Name: pr.Name, Status: string(pr.Status), StatusCSS: statusCSS(pr.Status), Duration: pr.Duration.Round(time.Millisecond).String(), } for _, ar := range pr.Actions { hp.Actions = append(hp.Actions, htmlAction{ Name: ar.Action, Status: string(ar.Status), StatusCSS: statusCSS(ar.Status), Duration: ar.Duration.Round(time.Millisecond).String(), Output: ar.Output, Error: ar.Error, YAML: ar.YAML, }) } d.Phases = append(d.Phases, hp) } // Extract perf stats from vars. if len(result.Vars) > 0 { d.Perf = extractHTMLPerf(result.Vars) d.HasPerf = len(d.Perf) > 0 d.Metrics = extractHTMLMetrics(result.Vars) d.HasMetrics = len(d.Metrics) > 0 } return d } func statusCSS(s ResultStatus) string { switch s { case StatusPass: return "pass" case StatusFail: return "fail" case StatusSkip: return "skip" default: return "" } } func extractHTMLPerf(vars map[string]string) []htmlPerfEntry { var entries []htmlPerfEntry for key, val := range vars { var ps PerfStats if err := json.Unmarshal([]byte(val), &ps); err == nil && ps.Count > 0 { entries = append(entries, perfToHTML(key, ps)) continue } if ps, ok := parsePerfStatsLine(val); ok { entries = append(entries, perfToHTML(key, ps)) } } sort.Slice(entries, func(i, j int) bool { return entries[i].Name < entries[j].Name }) return entries } func perfToHTML(name string, ps PerfStats) htmlPerfEntry { return htmlPerfEntry{ Name: name, Count: ps.Count, Mean: fmt.Sprintf("%.1f", ps.Mean), P50: fmt.Sprintf("%.1f", ps.P50), P90: fmt.Sprintf("%.1f", ps.P90), P99: fmt.Sprintf("%.1f", ps.P99), Min: fmt.Sprintf("%.1f", ps.Min), Max: fmt.Sprintf("%.1f", ps.Max), } } func extractHTMLMetrics(vars map[string]string) []htmlMetricsGroup { var groups []htmlMetricsGroup for key, val := range vars { var ms MetricsSample if err := json.Unmarshal([]byte(val), &ms); err != nil || len(ms.Metrics) == 0 { continue } label := key if ms.Target != "" { label = ms.Target } names := make([]string, 0, len(ms.Metrics)) for n := range ms.Metrics { names = append(names, n) } sort.Strings(names) g := htmlMetricsGroup{Name: label} for _, n := range names { g.Entries = append(g.Entries, htmlMetricEntry{ Name: n, Value: formatMetricValue(ms.Metrics[n]), }) } groups = append(groups, g) } sort.Slice(groups, func(i, j int) bool { return groups[i].Name < groups[j].Name }) return groups } var htmlTemplate = strings.TrimSpace(` {{.Name}} — Test Report

{{.Name}}

{{.Status}} {{.Duration}}
Generated: {{.Timestamp}}

Phases & Actions

{{range .Phases}} {{range .Actions}} {{if .Error}}{{end}} {{if .Output}}{{end}} {{if .YAML}}{{end}} {{end}} {{end}}
NameStatusDuration
{{.Name}} {{.Status}} {{.Duration}}
{{.Name}} {{.Status}} {{.Duration}}
ERROR: {{.Error}}
{{.Output}}
YAML definition
{{.YAML}}
{{if .HasPerf}}

Performance

{{range .Perf}} {{end}}
MetricCountMeanP50P90P99MinMax
{{.Name}}{{.Count}}{{.Mean}}{{.P50}}{{.P90}}{{.P99}}{{.Min}}{{.Max}}
{{end}} {{if .HasMetrics}} {{range .Metrics}}

Metrics ({{.Name}})

{{range .Entries}} {{end}}
MetricValue
{{.Name}}{{.Value}}
{{end}} {{end}} {{if .HasArtifacts}}

Artifacts

{{end}} {{if .Error}}

Error

{{.Error}}
{{end}} `)