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 = ` Wings Test Report

🚀 Wings Test Report

Start Time: {{.Time.StartAt.Format "2006-01-02 15:04:05"}}
📥 View & Download
{{if getCaseContentBase64}} {{end}}

📊 Test Summary

{{.Stat.TestCases.Success}}
Passed TestCases
{{.Stat.TestCases.Fail}}
Failed TestCases
{{.Stat.TestSteps.Total}}
Total Steps
{{calculateTotalActions}}
Total Actions
{{calculateTotalSubActions}}
Total Sub-Actions
{{calculateTotalPlannings}}
Total Plannings
{{printf "%.1f" .Time.Duration}}s
Duration
{{$usage := calculateTotalUsage}}
{{index $usage "prompt_tokens"}}
Input Tokens
{{index $usage "completion_tokens"}}
Output Tokens
{{index $usage "total_tokens"}}
Total Tokens

🔧 Platform Information

HttpRunner Version
{{.Platform.HttprunnerVersion}}
Go Version
{{.Platform.GoVersion}}
Platform
{{.Platform.Platform}}
{{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.Plannings}} {{range $planningIndex, $planning := $action.Plannings}}
🧠 Planning & Execution {{add $planningIndex 1}} {{formatDuration $planning.Elapsed}} {{if $planning.Error}}Error: {{$planning.Error}}{{end}}
{{$extractedThought := extractThought $planning.Content}} {{if or $planning.Thought $extractedThought}}
{{if $planning.Thought}} {{$planning.Thought}} {{else}} {{$extractedThought}} {{end}}
{{end}}
📸 ScreenShots {{formatDuration $planning.ScreenshotElapsed}}
{{if $planning.ScreenResult}} {{if $planning.ScreenResult.ImagePath}} {{$base64Image := encodeImageBase64 $planning.ScreenResult.ImagePath}} {{if $base64Image}} Planning Screenshot {{end}} {{end}} {{end}} {{if $planning.SubActions}} {{range $subAction := $planning.SubActions}} {{if $subAction.ScreenResults}} {{range $subScreenshot := $subAction.ScreenResults}} {{if $subScreenshot.ImagePath}} {{$base64Image := encodeImageBase64 $subScreenshot.ImagePath}} {{if $base64Image}} Sub-action Screenshot {{end}} {{end}} {{end}} {{end}} {{end}} {{end}}
🤖 Call Model & Parse Result {{formatDuration $planning.ModelCallElapsed}}
{{if $planning.ModelName}}
🤖 Model: {{$planning.ModelName}}
{{end}} {{if $planning.Usage}}
📊 Tokens: {{$planning.Usage.PromptTokens}} in / {{$planning.Usage.CompletionTokens}} out / {{$planning.Usage.TotalTokens}} total
{{end}} {{if $planning.ToolCallsCount}}
🔧 Tool Calls: {{$planning.ToolCallsCount}}
{{end}} {{if $planning.ActionNames}}
🎯 Actions: {{toJSONFormatted $planning.ActionNames}}
{{end}}
{{if $planning.SubActions}}
🎯 Actions ({{len $planning.SubActions}})
{{range $subAction := $planning.SubActions}}
{{$subAction.ActionName}} {{formatDuration $subAction.Elapsed}} {{if $subAction.Error}}{{else}}{{end}}
{{if $subAction.Arguments}}
{{toJSONFormatted $subAction.Arguments}}
{{end}} {{if $subAction.Requests}}
{{range $request := $subAction.Requests}}
{{$request.RequestMethod}} {{$request.RequestUrl}} {{$request.ResponseStatus}} {{formatDuration $request.ResponseDuration}}
{{if $request.RequestBody}}
Request: {{$request.RequestBody}}
{{end}} {{if $request.ResponseBody}}
Response: {{$request.ResponseBody}}
{{end}}
{{end}}
{{end}}
{{end}}
{{end}}
{{end}} {{end}} {{/* Enhanced AI Operations Display - using unified AIResult data structure */}} {{if or (eq $action.Method "ai_query") (eq $action.Method "ai_action") (eq $action.Method "ai_assert")}} {{if $action.AIResult}}
{{if eq $action.AIResult.Type "query"}} {{if $action.AIResult.QueryResult.Thought}}
{{$action.AIResult.QueryResult.Thought}}
{{end}} {{else if eq $action.AIResult.Type "action"}} {{if $action.AIResult.PlanningResult.Thought}}
{{$action.AIResult.PlanningResult.Thought}}
{{end}} {{else if eq $action.AIResult.Type "assert"}} {{if $action.AIResult.AssertionResult.Thought}}
{{$action.AIResult.AssertionResult.Thought}}
{{end}} {{end}}
{{if $action.AIResult.ImagePath}}
📸 {{title $action.AIResult.Type}} Screenshot {{if $action.AIResult.ScreenshotElapsed}} {{formatDuration $action.AIResult.ScreenshotElapsed}} {{end}}
{{$base64Image := encodeImageBase64 $action.AIResult.ImagePath}} {{if $base64Image}}
AI {{title $action.AIResult.Type}} Screenshot
{{end}}
{{end}}
🤖 AI {{title $action.AIResult.Type}} Analysis {{if $action.AIResult.ModelCallElapsed}} {{formatDuration $action.AIResult.ModelCallElapsed}} {{end}}
{{/* Model name and usage from specific result types */}} {{if eq $action.AIResult.Type "query"}} {{if $action.AIResult.QueryResult.ModelName}}
🤖 Model: {{$action.AIResult.QueryResult.ModelName}}
{{end}} {{if $action.AIResult.QueryResult.Usage}}
📊 Tokens: {{$action.AIResult.QueryResult.Usage.PromptTokens}} in / {{$action.AIResult.QueryResult.Usage.CompletionTokens}} out / {{$action.AIResult.QueryResult.Usage.TotalTokens}} total
{{end}} {{/* Display structured data for query results */}} {{if $action.AIResult.QueryResult.Data}}
📥 Structured Data:
{{toJSONFormatted $action.AIResult.QueryResult.Data}}
{{end}} {{else if eq $action.AIResult.Type "action"}} {{if $action.AIResult.PlanningResult.ModelName}}
🤖 Model: {{$action.AIResult.PlanningResult.ModelName}}
{{end}} {{if $action.AIResult.PlanningResult.Usage}}
📊 Tokens: {{$action.AIResult.PlanningResult.Usage.PromptTokens}} in / {{$action.AIResult.PlanningResult.Usage.CompletionTokens}} out / {{$action.AIResult.PlanningResult.Usage.TotalTokens}} total
{{end}} {{else if eq $action.AIResult.Type "assert"}} {{if $action.AIResult.AssertionResult.ModelName}}
🤖 Model: {{$action.AIResult.AssertionResult.ModelName}}
{{end}} {{if $action.AIResult.AssertionResult.Usage}}
📊 Tokens: {{$action.AIResult.AssertionResult.Usage.PromptTokens}} in / {{$action.AIResult.AssertionResult.Usage.CompletionTokens}} out / {{$action.AIResult.AssertionResult.Usage.TotalTokens}} total
{{end}} {{end}} {{if $action.AIResult.Resolution}}
📐 Resolution: {{$action.AIResult.Resolution.Width}}x{{$action.AIResult.Resolution.Height}}
{{end}} {{/* Display Content from specific result types */}} {{if eq $action.AIResult.Type "query"}} {{if $action.AIResult.QueryResult.Content}}
💬 {{title $action.AIResult.Type}} Result: {{$action.AIResult.QueryResult.Content}}
{{end}} {{else if eq $action.AIResult.Type "action"}} {{if $action.AIResult.PlanningResult.Content}}
💬 {{title $action.AIResult.Type}} Result: {{$action.AIResult.PlanningResult.Content}}
{{end}} {{else if eq $action.AIResult.Type "assert"}} {{if $action.AIResult.AssertionResult.Content}}
💬 {{title $action.AIResult.Type}} Result: {{$action.AIResult.AssertionResult.Content}}
{{end}} {{end}}
{{end}} {{end}} {{/* Handle SessionData: display requests and screen results for non-planning actions */}} {{if not $action.Plannings}} {{if or $action.Requests $action.ScreenResults}}
{{if $action.Requests}}
{{range $request := $action.Requests}}
{{$request.RequestMethod}} {{$request.RequestUrl}} {{$request.ResponseStatus}} {{formatDuration $request.ResponseDuration}}
{{if $request.RequestBody}}
Request: {{$request.RequestBody}}
{{end}} {{if $request.ResponseBody}}
Response: {{$request.ResponseBody}}
{{end}}
{{end}}
{{end}} {{if $action.ScreenResults}}
📸 Screen Results ({{len $action.ScreenResults}})
{{range $screenshot := $action.ScreenResults}} {{if $screenshot.ImagePath}} {{$base64Image := encodeImageBase64 $screenshot.ImagePath}} {{if $base64Image}}
{{base $screenshot.ImagePath}}
Screenshot
{{end}} {{end}} {{end}}
{{end}}
{{end}} {{end}}
{{end}}
{{end}} {{if and $step.Data $step.Data.validators}}

🔍 Validators

{{range $validatorIndex, $validator := $step.Data.validators}}
{{$validator.check}} - {{$validator.assert}} {{if eq $validator.check_result "pass"}}✓ PASS{{else}}✗ FAIL{{end}}
Expected: {{$validator.expect}}
{{if and $validator.msg (ne $validator.check_result "pass")}}
{{$validator.msg}}
{{end}} {{if $validator.ai_result}}
{{if $validator.ai_result.assertion_result.thought}}
{{$validator.ai_result.assertion_result.thought}}
{{end}}
{{if $validator.ai_result.image_path}}
📸 AI Assertion Screenshot {{if $validator.ai_result.screenshot_elapsed}} {{formatDuration $validator.ai_result.screenshot_elapsed}} {{end}}
{{$base64Image := encodeImageBase64 $validator.ai_result.image_path}} {{if $base64Image}}
AI Assertion Screenshot
{{end}}
{{end}}
🤖 AI Assertion Analysis {{if $validator.ai_result.model_call_elapsed}} {{formatDuration $validator.ai_result.model_call_elapsed}} {{end}}
{{if $validator.ai_result.assertion_result.model_name}}
🤖 Model: {{$validator.ai_result.assertion_result.model_name}}
{{end}} {{if $validator.ai_result.assertion_result.usage}}
📊 Tokens: {{$validator.ai_result.assertion_result.usage.PromptTokens}} in / {{$validator.ai_result.assertion_result.usage.CompletionTokens}} out / {{$validator.ai_result.assertion_result.usage.TotalTokens}} total
{{end}} {{if $validator.ai_result.resolution}}
📐 Resolution: {{$validator.ai_result.resolution.Width}}x{{$validator.ai_result.resolution.Height}}
{{end}} {{if $validator.ai_result.assertion_result.content}}
💬 Assertion Result: {{$validator.ai_result.assertion_result.content}}
{{end}}
{{end}}
{{end}}
{{end}} {{if $step.Attachments}} {{$attachments := $step.Attachments}} {{if eq (printf "%T" $attachments) "map[string]interface {}"}} {{if index $attachments "screen_results"}}

Attachment ScreenShots

{{range $screenshot := index $attachments "screen_results"}} {{$imagePath := ""}} {{if $screenshot.ImagePath}} {{$imagePath = $screenshot.ImagePath}} {{else if index $screenshot "image_path"}} {{$imagePath = index $screenshot "image_path"}} {{end}} {{if $imagePath}} {{$base64Image := encodeImageBase64 $imagePath}} {{if $base64Image}}
{{base $imagePath}}
Screenshot
{{end}} {{end}} {{end}}
{{end}} {{end}} {{end}} {{$stepLogs := getStepLogs $step}} {{if $stepLogs}}

📋 Step Logs ({{len $stepLogs}})

{{range $logEntry := $stepLogs}}
{{$logEntry.Time}} {{$logEntry.Level}} {{$logEntry.Message}} {{if $logEntry.Fields}} {{end}}
{{if $logEntry.Fields}} {{end}}
{{end}}
{{end}}
{{end}}
{{end}}

📋 Test Case JSON

✅ Copied!

            

📄 Summary JSON

✅ Copied!

            

📋 Log Content

✅ Copied!

            
`