package hrp import ( "bufio" "encoding/base64" "encoding/json" "fmt" "html/template" "os" "path/filepath" "sort" "strings" "time" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/uixt" "github.com/httprunner/httprunner/v5/uixt/option" ) // 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 CaseContent string // Raw case.json content for display } // 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 LogIndex int `json:"-"` // Original index to maintain order for same timestamps } // 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") } } // Load case.json data if exists if err := generator.loadCaseData(); err != nil { log.Warn().Err(err).Msg("failed to load case data, continuing without case display") } 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 } // Initialize nil fields to prevent template execution errors g.initializeSummaryFields() // 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 } // initializeSummaryFields initializes nil fields in SummaryData to prevent template execution errors func (g *HTMLReportGenerator) initializeSummaryFields() { if g.SummaryData == nil { g.SummaryData = &Summary{} } // Initialize Stat if nil if g.SummaryData.Stat == nil { g.SummaryData.Stat = &Stat{} // Initialize TestSteps.Actions map if needed if g.SummaryData.Stat.TestSteps.Actions == nil { g.SummaryData.Stat.TestSteps.Actions = make(map[option.ActionName]int) } } // Initialize Platform if nil if g.SummaryData.Platform == nil { g.SummaryData.Platform = &Platform{} } // Initialize Time if nil if g.SummaryData.Time == nil { g.SummaryData.Time = &TestCaseTime{} } // Initialize Details if nil if g.SummaryData.Details == nil { g.SummaryData.Details = []*TestCaseSummary{} } } // 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) logIndex := 0 // Track original order 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), LogIndex: logIndex, // Store original order } logIndex++ // 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() } // loadCaseData loads test case data from case.json file func (g *HTMLReportGenerator) loadCaseData() error { caseFile := filepath.Join(g.ReportDir, "case.json") if !builtin.FileExists(caseFile) { return nil // case.json is optional } data, err := os.ReadFile(caseFile) if err != nil { return err } // Store the case content for display g.CaseContent = string(data) return nil } // getStepLogs filters log entries for a specific test step using prefix matching and time range filtering func (g *HTMLReportGenerator) getStepLogs(stepName string, startTime int64, elapsed int64) []LogEntry { if len(g.LogData) == 0 { return nil } var stepLogs []LogEntry var inCurrentStep bool = false // Calculate step end time (startTime + elapsed, both in milliseconds) endTime := startTime + elapsed // Convert step times to time.Time for comparison // The startTime from step result is in milliseconds timestamp stepStartTime := time.UnixMilli(startTime) stepEndTime := time.UnixMilli(endTime) // Use step start/end markers with prefix matching for precise boundaries for _, logEntry := range g.LogData { // Parse log entry timestamp for time range validation logTime, timeParseErr := g.parseLogTime(logEntry.Time) // Check for step boundaries to control inclusion if logEntry.Message == RUN_STEP_START { if stepFieldValue, exists := logEntry.Fields["step"].(string); exists { // use prefix matching for parameterized steps if strings.HasPrefix(stepName, stepFieldValue) { inCurrentStep = true stepLogs = append(stepLogs, logEntry) continue } else if inCurrentStep { // This is a different step starting, we're done with current step break } } } if logEntry.Message == RUN_STEP_END { if stepFieldValue, exists := logEntry.Fields["step"].(string); exists { // use prefix matching for parameterized steps if strings.HasPrefix(stepName, stepFieldValue) { stepLogs = append(stepLogs, logEntry) inCurrentStep = false break // End of current step, stop processing } } } // Include logs when we're in the current step AND within the time range if inCurrentStep { // Apply time range filtering if time parsing succeeded if timeParseErr == nil { // Only include logs within the step time range if (logTime.Equal(stepStartTime) || logTime.After(stepStartTime)) && (logTime.Equal(stepEndTime) || logTime.Before(stepEndTime)) { stepLogs = append(stepLogs, logEntry) } } else { // If time parsing failed, include all logs in the step boundary stepLogs = append(stepLogs, logEntry) } } } // Sort logs by original index to maintain chronological order sort.Slice(stepLogs, func(i, j int) bool { return stepLogs[i].LogIndex < stepLogs[j].LogIndex }) 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 with quality 50 compressedData, err := uixt.CompressImageFile(imagePath, 50) 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 { return g.iterateTestData(func(action *ActionResult) int { return 1 // Count each action }) } // calculateTotalSubActions calculates the total number of sub-actions across all test cases func (g *HTMLReportGenerator) calculateTotalSubActions() int { return g.iterateTestData(func(action *ActionResult) int { total := 0 // Count sub-actions from start_to_goal results if action.Plannings != nil { for _, planning := range action.Plannings { if planning.SubActions != nil { total += len(planning.SubActions) } } } else { // Count other actions total += 1 } return total }) } // calculateTotalPlannings calculates the total number of planning results across all test cases func (g *HTMLReportGenerator) calculateTotalPlannings() int { return g.iterateTestData(func(action *ActionResult) int { if action.Plannings != nil { return len(action.Plannings) } return 0 }) } // 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 { // Calculate planning usage if action.Plannings != nil { for _, planning := range action.Plannings { if planning.Usage != nil { 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 } } } // Calculate AI operations usage (ai_query, ai_action, ai_assert) if action.AIResult != nil { var usage *map[string]interface{} switch action.AIResult.Type { case "query": if action.AIResult.QueryResult != nil && action.AIResult.QueryResult.Usage != nil { usage = &map[string]interface{}{ "prompt_tokens": action.AIResult.QueryResult.Usage.PromptTokens, "completion_tokens": action.AIResult.QueryResult.Usage.CompletionTokens, "total_tokens": action.AIResult.QueryResult.Usage.TotalTokens, } } case "action": if action.AIResult.PlanningResult != nil && action.AIResult.PlanningResult.Usage != nil { usage = &map[string]interface{}{ "prompt_tokens": action.AIResult.PlanningResult.Usage.PromptTokens, "completion_tokens": action.AIResult.PlanningResult.Usage.CompletionTokens, "total_tokens": action.AIResult.PlanningResult.Usage.TotalTokens, } } case "assert": if action.AIResult.AssertionResult != nil && action.AIResult.AssertionResult.Usage != nil { usage = &map[string]interface{}{ "prompt_tokens": action.AIResult.AssertionResult.Usage.PromptTokens, "completion_tokens": action.AIResult.AssertionResult.Usage.CompletionTokens, "total_tokens": action.AIResult.AssertionResult.Usage.TotalTokens, } } } if usage != nil { totalUsage["prompt_tokens"] = totalUsage["prompt_tokens"].(int) + (*usage)["prompt_tokens"].(int) totalUsage["completion_tokens"] = totalUsage["completion_tokens"].(int) + (*usage)["completion_tokens"].(int) totalUsage["total_tokens"] = totalUsage["total_tokens"].(int) + (*usage)["total_tokens"].(int) } } } } } return totalUsage } // iterateTestData is a helper function that iterates through all actions and applies a counting function func (g *HTMLReportGenerator) iterateTestData(countFunc func(*ActionResult) int) 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 { total += countFunc(action) } } } } 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, "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)) }, "getCaseContentBase64": func() string { return base64.StdEncoding.EncodeToString([]byte(g.CaseContent)) }, "safeHTML": func(s string) template.HTML { return template.HTML(s) }, "toJSONFormatted": func(v any) string { var buf strings.Builder encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") _ = encoder.Encode(v) result := strings.TrimSpace(buf.String()) return result }, "add": func(a, b int) int { return a + b }, "base": filepath.Base, "index": func(m map[string]any, key string) any { return m[key] }, "title": func(s string) string { if s == "" { return "" } return strings.ToUpper(s[:1]) + s[1:] }, "extractThought": func(content string) string { if content == "" { return "" } // Try to parse as JSON to extract thought field var data map[string]interface{} if err := json.Unmarshal([]byte(content), &data); err == nil { if thought, ok := data["thought"].(string); ok && thought != "" { return thought } } // If not JSON or no thought field, return original content return content }, "formatBodyContent": func(content string) string { // Try to parse as JSON to format var data interface{} if err := json.Unmarshal([]byte(content), &data); err == nil { var buf strings.Builder encoder := json.NewEncoder(&buf) encoder.SetEscapeHTML(false) encoder.SetIndent("", " ") _ = encoder.Encode(data) return strings.TrimSpace(buf.String()) } // If not JSON, return original content return content }, } // 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 = `