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 Test Report + + + +
+
+

🚀 HttpRunner Test Report

+
Automated Testing Results
+
+ +
+

📊 Test Summary

+
+
+
{{if .Success}}✓{{else}}✗{{end}}
+
Overall Status
+
+
+
{{.Stat.TestCases.Total}}
+
Total Test Cases
+
+
+
{{.Stat.TestCases.Success}}
+
Passed
+
+
+
{{.Stat.TestCases.Fail}}
+
Failed
+
+
+
{{.Stat.TestSteps.Total}}
+
Total Steps
+
+
+
{{printf "%.1f" .Time.Duration}}s
+
Duration
+
+
+ +
+

🔧 Platform Information

+

HttpRunner Version: {{.Platform.HttprunnerVersion}}

+

Go Version: {{.Platform.GoVersion}}

+

Platform: {{.Platform.Platform}}

+

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

+
+
+ +
+ + +
+ +
+ {{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: {{toJSON $subAction.Arguments}}
+ {{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}} +
+ {{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}}{{if $step.Attachments.ScreenResults}} +
+

Screenshots

+ {{range $screenshot := $step.Attachments.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}} + + + {{$stepLogs := getStepLogs $step}} + {{if $stepLogs}} +
+

📋 Step Logs

+
+ {{range $logEntry := $stepLogs}} +
+
+ {{$logEntry.Time}} + {{$logEntry.Level}} +
+
{{$logEntry.Message}}
+ {{if $logEntry.Data}} +
{{toJSON $logEntry.Data}}
+ {{end}} +
+ {{end}} +
+
+ {{end}} +
+
+ {{end}} +
+ {{end}} +
+
+ + + + + + +` diff --git a/summary.go b/summary.go index 821f67f9..4227201e 100644 --- a/summary.go +++ b/summary.go @@ -1,11 +1,8 @@ package hrp import ( - "bufio" _ "embed" "fmt" - "html/template" - "os" "path/filepath" "runtime" "time" @@ -94,27 +91,19 @@ func (s *Summary) GenHTMLReport() error { return err } + // Find summary.json and hrp.log files + summaryPath := filepath.Join(reportsDir, "summary.json") + logPath := filepath.Join(reportsDir, "hrp.log") reportPath := filepath.Join(reportsDir, "report.html") - file, err := os.Open(reportPath) - if err != nil { - log.Error().Err(err).Msg("open file failed") - return err + + // Check if summary.json exists, if not create it first + if !builtin.FileExists(summaryPath) { + if _, err := s.GenSummary(); err != nil { + return fmt.Errorf("failed to generate summary.json: %w", err) + } } - defer file.Close() - writer := bufio.NewWriter(file) - tmpl := template.Must(template.New("report").Parse(reportTemplate)) - err = tmpl.Execute(writer, s) - if err != nil { - log.Error().Err(err).Msg("execute applies a parsed template to the specified data object failed") - return err - } - err = writer.Flush() - if err == nil { - log.Info().Str("path", reportPath).Msg("generate HTML report") - } else { - log.Error().Str("path", reportPath).Msg("generate HTML report failed") - } - return err + + return GenerateHTMLReportFromFiles(summaryPath, logPath, reportPath) } func (s *Summary) GenSummary() (path string, err error) { @@ -131,9 +120,6 @@ func (s *Summary) GenSummary() (path string, err error) { return path, nil } -//go:embed internal/scaffold/templates/report/template.html -var reportTemplate string - type Stat struct { TestCases TestCaseStat `json:"testcases" yaml:"testcases"` TestSteps TestStepStat `json:"teststeps" yaml:"teststeps"`