package hrp import ( "bufio" "encoding/base64" "encoding/json" "fmt" "html/template" "os" "path/filepath" "strings" "time" "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/uixt" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) // GenerateHTMLReportFromFiles is a convenience function to generate HTML report func GenerateHTMLReportFromFiles(summaryFile, logFile, outputFile string) error { generator, err := NewHTMLReportGenerator(summaryFile, logFile) if err != nil { return errors.Wrap(err, "failed to create HTML report generator") } err = generator.GenerateReport(outputFile) if err != nil { return errors.Wrap(err, "failed to generate HTML report") } return nil } // HTMLReportGenerator generates comprehensive HTML test reports type HTMLReportGenerator struct { SummaryFile string LogFile string SummaryData *Summary LogData []LogEntry ReportDir string SummaryContent string // Raw summary.json content for download LogContent string // Raw hrp.log content for download } // LogEntry represents a single log entry type LogEntry struct { Time string `json:"time"` Level string `json:"level"` Message string `json:"message"` Fields map[string]any `json:"-"` // Store all other fields } // NewHTMLReportGenerator creates a new HTML report generator func NewHTMLReportGenerator(summaryFile, logFile string) (*HTMLReportGenerator, error) { generator := &HTMLReportGenerator{ SummaryFile: summaryFile, LogFile: logFile, ReportDir: filepath.Dir(summaryFile), } // Load summary data if err := generator.loadSummaryData(); err != nil { return nil, fmt.Errorf("failed to load summary data: %w", err) } // Load log data if provided if logFile != "" { if err := generator.loadLogData(); err != nil { log.Warn().Err(err).Msg("failed to load log data, continuing without logs") } } return generator, nil } // loadSummaryData loads test summary data from JSON file func (g *HTMLReportGenerator) loadSummaryData() error { data, err := os.ReadFile(g.SummaryFile) if err != nil { return err } // Store raw content for download g.SummaryContent = string(data) g.SummaryData = &Summary{} return json.Unmarshal(data, g.SummaryData) } // loadLogData loads test log data from log file func (g *HTMLReportGenerator) loadLogData() error { if g.LogFile == "" || !builtin.FileExists(g.LogFile) { return nil } // Read raw log content for download logData, err := os.ReadFile(g.LogFile) if err != nil { return err } g.LogContent = string(logData) file, err := os.Open(g.LogFile) if err != nil { return err } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // First parse into a generic map to get all fields var rawEntry map[string]any if err := json.Unmarshal([]byte(line), &rawEntry); err != nil { // Skip invalid JSON lines continue } // Create LogEntry with basic fields logEntry := LogEntry{ Fields: make(map[string]any), } // Extract standard fields if time, ok := rawEntry["time"].(string); ok { logEntry.Time = time } if level, ok := rawEntry["level"].(string); ok { logEntry.Level = level } if message, ok := rawEntry["message"].(string); ok { logEntry.Message = message } // Store all other fields in Fields map for key, value := range rawEntry { if key != "time" && key != "level" && key != "message" { logEntry.Fields[key] = value } } g.LogData = append(g.LogData, logEntry) } return scanner.Err() } // getStepLogs filters log entries for a specific test step based on time range func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elapsed int64) []LogEntry { if len(g.LogData) == 0 { return nil } var stepLogs []LogEntry // startTime is in seconds, elapsed is in milliseconds // Calculate end time (startTime in seconds + elapsed in milliseconds converted to seconds) endTime := startTime + elapsed/1000 // Convert Unix timestamps to time.Time for comparison startTimeObj := time.Unix(startTime, 0) endTimeObj := time.Unix(endTime, 0) for _, logEntry := range g.LogData { // Parse log entry time logTime, err := g.parseLogTime(logEntry.Time) if err != nil { continue } // Check if log entry falls within step time range if (logTime.Equal(startTimeObj) || logTime.After(startTimeObj)) && (logTime.Equal(endTimeObj) || logTime.Before(endTimeObj)) { stepLogs = append(stepLogs, logEntry) } } return stepLogs } // parseLogTime parses various time formats from log entries func (g *HTMLReportGenerator) parseLogTime(timeStr string) (time.Time, error) { // Handle different time formats that might appear in logs formats := []string{ time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05.000Z07:00", "2006-01-02T15:04:05.000+08:00", "2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05+08:00", "2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", } // Replace common timezone formats timeStr = strings.ReplaceAll(timeStr, "Z", "+00:00") timeStr = strings.ReplaceAll(timeStr, "+0800", "+08:00") for _, format := range formats { if t, err := time.Parse(format, timeStr); err == nil { return t, nil } } return time.Time{}, fmt.Errorf("unable to parse time: %s", timeStr) } // encodeImageToBase64 encodes an image file to base64 string with compression func (g *HTMLReportGenerator) encodeImageToBase64(imagePath string) string { // Convert relative path to absolute path if !filepath.IsAbs(imagePath) { imagePath = filepath.Join(g.ReportDir, imagePath) } if !builtin.FileExists(imagePath) { log.Warn().Str("path", imagePath).Msg("image file not found") return "" } // Read and compress the image using the unified compression function // Enable resize with max width 800px for HTML reports compressedData, err := uixt.CompressImageFile(imagePath, true, 800) if err != nil { log.Warn().Err(err).Str("path", imagePath).Msg("failed to compress image, using original") // Fallback to original image if compression fails data, readErr := os.ReadFile(imagePath) if readErr != nil { log.Warn().Err(readErr).Str("path", imagePath).Msg("failed to read image file") return "" } return base64.StdEncoding.EncodeToString(data) } return base64.StdEncoding.EncodeToString(compressedData) } // formatDuration formats duration from milliseconds to human readable format func (g *HTMLReportGenerator) formatDuration(duration any) string { var durationMs float64 switch v := duration.(type) { case int64: durationMs = float64(v) case float64: durationMs = v case int: durationMs = float64(v) default: return "0ms" } if durationMs < 1000 { return fmt.Sprintf("%.0fms", durationMs) } else if durationMs < 60000 { return fmt.Sprintf("%.1fs", durationMs/1000) } else { minutes := int(durationMs / 60000) seconds := (durationMs - float64(minutes*60000)) / 1000 return fmt.Sprintf("%dm %.1fs", minutes, seconds) } } // getStepLogsForTemplate is a template function to get filtered logs for a step func (g *HTMLReportGenerator) getStepLogsForTemplate(step *StepResult) []LogEntry { if step == nil { return nil } return g.getStepLogs(step.Name, step.StartTime, step.Elapsed) } // calculateTotalActions calculates the total number of actions across all test cases func (g *HTMLReportGenerator) calculateTotalActions() int { total := 0 if g.SummaryData == nil || g.SummaryData.Details == nil { return total } for _, testCase := range g.SummaryData.Details { if testCase.Records == nil { continue } for _, step := range testCase.Records { if step.Actions != nil { total += len(step.Actions) } } } return total } // calculateTotalSubActions calculates the total number of sub-actions across all test cases func (g *HTMLReportGenerator) calculateTotalSubActions() int { total := 0 if g.SummaryData == nil || g.SummaryData.Details == nil { return total } for _, testCase := range g.SummaryData.Details { if testCase.Records == nil { continue } for _, step := range testCase.Records { if step.Actions != nil { for _, action := range step.Actions { if action.SubActions != nil { total += len(action.SubActions) } } } } } return total } // calculateTotalRequests calculates the total number of requests across all test cases func (g *HTMLReportGenerator) calculateTotalRequests() int { total := 0 if g.SummaryData == nil || g.SummaryData.Details == nil { return total } for _, testCase := range g.SummaryData.Details { if testCase.Records == nil { continue } for _, step := range testCase.Records { if step.Actions != nil { for _, action := range step.Actions { if action.SubActions != nil { for _, subAction := range action.SubActions { if subAction.Requests != nil { total += len(subAction.Requests) } } } } } } } return total } // calculateTotalScreenshots calculates the total number of screenshots across all test cases func (g *HTMLReportGenerator) calculateTotalScreenshots() int { total := 0 if g.SummaryData == nil || g.SummaryData.Details == nil { return total } for _, testCase := range g.SummaryData.Details { if testCase.Records == nil { continue } for _, step := range testCase.Records { // Count screenshots in actions if step.Actions != nil { for _, action := range step.Actions { if action.SubActions != nil { for _, subAction := range action.SubActions { if subAction.ScreenResults != nil { total += len(subAction.ScreenResults) } } } } } // Count screenshots in attachments if step.Attachments != nil { if attachments, ok := step.Attachments.(map[string]any); ok { if screenResults, exists := attachments["screen_results"]; exists { if screenResultsSlice, ok := screenResults.([]any); ok { total += len(screenResultsSlice) } } } } } } return total } // GenerateReport generates the complete HTML test report func (g *HTMLReportGenerator) GenerateReport(outputFile string) error { if outputFile == "" { outputFile = filepath.Join(g.ReportDir, "report.html") } // Create template functions funcMap := template.FuncMap{ "formatDuration": g.formatDuration, "encodeImageBase64": g.encodeImageToBase64, "getStepLogs": g.getStepLogsForTemplate, "calculateTotalActions": g.calculateTotalActions, "calculateTotalSubActions": g.calculateTotalSubActions, "calculateTotalRequests": g.calculateTotalRequests, "calculateTotalScreenshots": g.calculateTotalScreenshots, "getSummaryContentBase64": func() string { return base64.StdEncoding.EncodeToString([]byte(g.SummaryContent)) }, "getLogContentBase64": func() string { return base64.StdEncoding.EncodeToString([]byte(g.LogContent)) }, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "toJSON": func(v any) string { var buf strings.Builder encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) _ = encoder.Encode(v) result := buf.String() return strings.TrimSpace(result) }, "mul": func(a, b float64) float64 { return a * b }, "add": func(a, b int) int { return a + b }, "base": filepath.Base, "index": func(m map[string]any, key string) any { return m[key] }, } // Parse template tmpl, err := template.New("report").Funcs(funcMap).Parse(htmlTemplate) if err != nil { return fmt.Errorf("failed to parse template: %w", err) } // Create output file file, err := os.Create(outputFile) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } defer file.Close() // Execute template if err := tmpl.Execute(file, g.SummaryData); err != nil { return fmt.Errorf("failed to execute template: %w", err) } log.Info().Str("path", outputFile).Msg("HTML report generated successfully") return nil } // htmlTemplate contains the complete HTML template for test reports const htmlTemplate = ` HttpRunner Test Report

🚀 HttpRunner Test Report

Automated Testing Results
Start Time: {{.Time.StartAt.Format "2006-01-02 15:04:05"}}

📊 Test Summary

{{.Stat.TestCases.Total}}
Total Test Cases
{{.Stat.TestCases.Success}}
Passed
{{.Stat.TestCases.Fail}}
Failed
{{.Stat.TestSteps.Total}}
Total Steps
{{calculateTotalActions}}
Total Actions
{{calculateTotalSubActions}}
Total Sub-Actions
{{calculateTotalRequests}}
Total Requests
{{calculateTotalScreenshots}}
Total Screenshots
{{printf "%.1f" .Time.Duration}}s
Duration

🔧 Platform Information

HttpRunner Version
{{.Platform.HttprunnerVersion}}
Go Version
{{.Platform.GoVersion}}
Platform
{{.Platform.Platform}}
{{range $caseIndex, $testCase := .Details}}

📋 {{$testCase.Name}}

{{if $testCase.Success}}✓ PASS{{else}}✗ FAIL{{end}} {{printf "%.1f" $testCase.Time.Duration}}s
{{range $stepIndex, $step := $testCase.Records}}

{{add $stepIndex 1}} {{$step.Name}} {{if $step.Success}}✓ PASS{{else}}✗ FAIL{{end}} {{formatDuration $step.Elapsed}} ▼

{{$step.StepType}}
{{if $step.Actions}}

Actions

{{range $actionIndex, $action := $step.Actions}}
{{$action.Method}} {{formatDuration $action.Elapsed}} {{if $action.Error}}Error: {{$action.Error}}{{end}}
{{$action.Params}}
{{if $action.SubActions}}
{{range $subAction := $action.SubActions}}
{{$subAction.ActionName}} {{formatDuration $subAction.Elapsed}}
{{if $subAction.Thought}}
{{$subAction.Thought}}
{{end}} {{if $subAction.Arguments}}
Arguments: {{safeHTML (toJSON $subAction.Arguments)}}
{{end}} {{if $subAction.ModelName}}
🤖 Model: {{$subAction.ModelName}}
{{end}} {{if $subAction.Requests}}
{{range $request := $subAction.Requests}}
{{$request.RequestMethod}} {{$request.RequestUrl}} Status: {{$request.ResponseStatus}} {{formatDuration $request.ResponseDuration}}
{{if $request.RequestBody}}
Request: {{$request.RequestBody}}
{{end}} {{if $request.ResponseBody}}
Response: {{$request.ResponseBody}}
{{end}}
{{end}}
{{end}}
{{if $subAction.ScreenResults}}
📸 Screenshots
{{range $screenshot := $subAction.ScreenResults}} {{$base64Image := encodeImageBase64 $screenshot.ImagePath}} {{if $base64Image}}
{{base $screenshot.ImagePath}} {{if $screenshot.Resolution}} {{$screenshot.Resolution.Width}}x{{$screenshot.Resolution.Height}} {{end}}
Screenshot
{{end}} {{end}}
{{end}}
{{end}}
{{end}}
{{end}}
{{end}} {{if and $step.Data $step.Data.validators}}

Validators

{{range $validator := $step.Data.validators}}
{{$validator.check}} {{$validator.assert}} {{$validator.check_result}}
Expected: {{$validator.expect}}
{{if $validator.msg}}
{{$validator.msg}}
{{end}}
{{end}}
{{end}} {{if $step.Attachments}} {{$attachments := $step.Attachments}} {{if eq (printf "%T" $attachments) "map[string]interface {}"}} {{if index $attachments "screen_results"}}

Screenshots

{{range $screenshot := index $attachments "screen_results"}} {{$base64Image := encodeImageBase64 $screenshot.ImagePath}} {{if $base64Image}}
{{base $screenshot.ImagePath}} {{if $screenshot.Resolution}} {{$screenshot.Resolution.Width}}x{{$screenshot.Resolution.Height}} {{end}}
Screenshot
{{end}} {{end}}
{{end}} {{end}} {{end}} {{$stepLogs := getStepLogs $step}} {{if $stepLogs}}

📋 Step Logs ({{len $stepLogs}})

{{range $logEntry := $stepLogs}}
{{$logEntry.Time}} {{$logEntry.Level}} {{$logEntry.Message}} {{if $logEntry.Fields}} â–¼ {{end}}
{{if $logEntry.Fields}} {{end}}
{{end}}
{{end}}
{{end}}
{{end}}
`