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 } // Parse JSON data first g.SummaryData = &Summary{} err = json.Unmarshal(data, g.SummaryData) if err != nil { return err } // Re-encode the summary data to ensure proper UTF-8 encoding for download // This fixes Chinese character encoding issues in legacy summary.json files buffer := new(strings.Builder) encoder := json.NewEncoder(buffer) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") err = encoder.Encode(g.SummaryData) if err != nil { // Fallback to original content if re-encoding fails g.SummaryContent = string(data) return nil } // Store the properly encoded content for download g.SummaryContent = strings.TrimSpace(buffer.String()) return nil } // 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 { // Count sub-actions from regular actions if action.SubActions != nil { total += len(action.SubActions) } // Count sub-actions from planning results if action.Plannings != nil { for _, planning := range action.Plannings { if planning.SubActions != nil { total += len(planning.SubActions) } } } } } } } return total } // calculateTotalPlannings calculates the total number of planning results across all test cases func (g *HTMLReportGenerator) calculateTotalPlannings() 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.Plannings != nil { total += len(action.Plannings) } } } } } return total } // calculateTotalUsage calculates the total token usage across all test cases func (g *HTMLReportGenerator) calculateTotalUsage() map[string]interface{} { totalUsage := map[string]interface{}{ "prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, } if g.SummaryData == nil || g.SummaryData.Details == nil { return totalUsage } for _, testCase := range g.SummaryData.Details { if testCase.Records == nil { continue } for _, step := range testCase.Records { if step.Actions == nil { continue } for _, action := range step.Actions { if action.Plannings == nil { continue } for _, planning := range action.Plannings { if planning.Usage == nil { continue } totalUsage["prompt_tokens"] = totalUsage["prompt_tokens"].(int) + planning.Usage.PromptTokens totalUsage["completion_tokens"] = totalUsage["completion_tokens"].(int) + planning.Usage.CompletionTokens totalUsage["total_tokens"] = totalUsage["total_tokens"].(int) + planning.Usage.TotalTokens } } } } return totalUsage } // 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, "calculateTotalPlannings": g.calculateTotalPlannings, "calculateTotalUsage": g.calculateTotalUsage, "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 }, "sub": func(a, b int) int { return a - b }, "lt": func(a, b int) bool { return a < b }, "gt": func(a, b int) bool { 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 with explicit UTF-8 handling file, err := os.OpenFile(outputFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("failed to create output file: %w", err) } defer file.Close() // Execute template (Go's html/template ensures UTF-8 encoding) if err := tmpl.Execute(file, g.SummaryData); err != nil { return fmt.Errorf("failed to execute template: %w", err) } // Ensure data is flushed to disk if err := file.Sync(); err != nil { return fmt.Errorf("failed to sync HTML report file: %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 = `