diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 3d64e025..98590929 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -17,6 +17,7 @@ func addAllCommands() { cmd.RootCmd.AddCommand(cmd.CmdBuild) cmd.RootCmd.AddCommand(cmd.CmdConvert) cmd.RootCmd.AddCommand(cmd.CmdPytest) + cmd.RootCmd.AddCommand(cmd.CmdReport) cmd.RootCmd.AddCommand(cmd.CmdRun) cmd.RootCmd.AddCommand(cmd.CmdScaffold) cmd.RootCmd.AddCommand(cmd.CmdServer) diff --git a/cmd/report.go b/cmd/report.go new file mode 100644 index 00000000..2ee8829b --- /dev/null +++ b/cmd/report.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + hrp "github.com/httprunner/httprunner/v5" +) + +var CmdReport = &cobra.Command{ + Use: "report [result_folder]", + Short: "Generate HTML report from test results", + Long: `Generate report.html from test results in the specified folder. +The folder should contain summary.json and optionally hrp.log files. + +Examples: + $ hrp report results/20250607234602/ + $ hrp report /path/to/test/results/`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + resultFolder := args[0] + + // Construct file paths + summaryFile := filepath.Join(resultFolder, "summary.json") + logFile := filepath.Join(resultFolder, "hrp.log") + reportFile := filepath.Join(resultFolder, "report.html") + + // Generate HTML report + if err := hrp.GenerateHTMLReportFromFiles(summaryFile, logFile, reportFile); err != nil { + return fmt.Errorf("failed to generate HTML report: %w", err) + } + + log.Info().Str("report_file", reportFile).Msg("HTML report generated successfully") + return nil + }, +} diff --git a/internal/version/VERSION b/internal/version/VERSION index f78c8361..6dfa4af5 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506072359 +v5.0.0-beta-2506080923 diff --git a/report.go b/report.go new file mode 100644 index 00000000..98833858 --- /dev/null +++ b/report.go @@ -0,0 +1,1231 @@ +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 { b, _ := json.Marshal(v); return string(b) }, + "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"}}
+