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
Generated: {{.Timestamp}}
Phases & Actions
| Name | Status | Duration |
{{range .Phases}}
| {{.Name}} |
{{.Status}} |
{{.Duration}} |
{{range .Actions}}
| {{.Name}} |
{{.Status}} |
{{.Duration}} |
{{if .Error}}| ERROR: {{.Error}} |
{{end}}
{{if .Output}}| {{.Output}} |
{{end}}
{{if .YAML}}YAML definition{{.YAML}} |
{{end}}
{{end}}
{{end}}
{{if .HasPerf}}
Performance
| Metric | Count | Mean | P50 | P90 | P99 | Min | Max |
{{range .Perf}}
| {{.Name}} | {{.Count}} | {{.Mean}} | {{.P50}} | {{.P90}} | {{.P99}} | {{.Min}} | {{.Max}} |
{{end}}
{{end}}
{{if .HasMetrics}}
{{range .Metrics}}
Metrics ({{.Name}})
| Metric | Value |
{{range .Entries}}
| {{.Name}} | {{.Value}} |
{{end}}
{{end}}
{{end}}
{{if .HasArtifacts}}
Artifacts
{{range .Artifacts}}
- {{.Agent}}: {{.Path}} ({{.Size}} bytes)
{{end}}
{{end}}
{{if .Error}}
Error
{{.Error}}
{{end}}
`)