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/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 } // LogEntry represents a single log entry type LogEntry struct { Time string `json:"time"` Level string `json:"level"` Message string `json:"message"` Data map[string]any `json:"data,omitempty"` } // 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 } 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 } 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 } var logEntry LogEntry if err := json.Unmarshal([]byte(line), &logEntry); err != nil { // Skip invalid JSON lines continue } 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 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 "" } data, err := os.ReadFile(imagePath) if err != nil { log.Warn().Err(err).Str("path", imagePath).Msg("failed to read image file") return "" } return base64.StdEncoding.EncodeToString(data) } // formatDuration formats duration from milliseconds to human readable format func (g *HTMLReportGenerator) formatDuration(duration interface{}) 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) } // 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, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "toJSON": func(v interface{}) 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) interface{} { 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 Version: {{.Platform.HttprunnerVersion}}
Go Version: {{.Platform.GoVersion}}
Platform: {{.Platform.Platform}}
Start Time: {{.Time.StartAt.Format "2006-01-02 15:04:05"}}