mirror of
https://github.com/httprunner/httprunner.git
synced 2026-09-09 01:17:30 +08:00
feat: enhance HTML report with statistics and collapsible log fields
This commit is contained in:
@@ -1 +1 @@
|
|||||||
v5.0.0-beta-2506080934
|
v5.0.0-beta-2506081005
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ type LogEntry struct {
|
|||||||
Time string `json:"time"`
|
Time string `json:"time"`
|
||||||
Level string `json:"level"`
|
Level string `json:"level"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Data map[string]any `json:"data,omitempty"`
|
Fields map[string]any `json:"-"` // Store all other fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTMLReportGenerator creates a new HTML report generator
|
// NewHTMLReportGenerator creates a new HTML report generator
|
||||||
@@ -99,11 +99,36 @@ func (g *HTMLReportGenerator) loadLogData() error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var logEntry LogEntry
|
// First parse into a generic map to get all fields
|
||||||
if err := json.Unmarshal([]byte(line), &logEntry); err != nil {
|
var rawEntry map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(line), &rawEntry); err != nil {
|
||||||
// Skip invalid JSON lines
|
// Skip invalid JSON lines
|
||||||
continue
|
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)
|
g.LogData = append(g.LogData, logEntry)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +217,7 @@ func (g *HTMLReportGenerator) encodeImageToBase64(imagePath string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// formatDuration formats duration from milliseconds to human readable format
|
// formatDuration formats duration from milliseconds to human readable format
|
||||||
func (g *HTMLReportGenerator) formatDuration(duration interface{}) string {
|
func (g *HTMLReportGenerator) formatDuration(duration any) string {
|
||||||
var durationMs float64
|
var durationMs float64
|
||||||
|
|
||||||
switch v := duration.(type) {
|
switch v := duration.(type) {
|
||||||
@@ -225,6 +250,117 @@ func (g *HTMLReportGenerator) getStepLogsForTemplate(step *StepResult) []LogEntr
|
|||||||
return g.getStepLogs(step.Name, step.StartTime, step.Elapsed)
|
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 {
|
||||||
|
if action.SubActions != nil {
|
||||||
|
total += len(action.SubActions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateTotalRequests calculates the total number of requests across all test cases
|
||||||
|
func (g *HTMLReportGenerator) calculateTotalRequests() 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.SubActions != nil {
|
||||||
|
for _, subAction := range action.SubActions {
|
||||||
|
if subAction.Requests != nil {
|
||||||
|
total += len(subAction.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateTotalScreenshots calculates the total number of screenshots across all test cases
|
||||||
|
func (g *HTMLReportGenerator) calculateTotalScreenshots() 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 {
|
||||||
|
// Count screenshots in actions
|
||||||
|
if step.Actions != nil {
|
||||||
|
for _, action := range step.Actions {
|
||||||
|
if action.SubActions != nil {
|
||||||
|
for _, subAction := range action.SubActions {
|
||||||
|
if subAction.ScreenResults != nil {
|
||||||
|
total += len(subAction.ScreenResults)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Count screenshots in attachments
|
||||||
|
if step.Attachments != nil {
|
||||||
|
if attachments, ok := step.Attachments.(map[string]any); ok {
|
||||||
|
if screenResults, exists := attachments["screen_results"]; exists {
|
||||||
|
if screenResultsSlice, ok := screenResults.([]any); ok {
|
||||||
|
total += len(screenResultsSlice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
// GenerateReport generates the complete HTML test report
|
// GenerateReport generates the complete HTML test report
|
||||||
func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
|
func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
|
||||||
if outputFile == "" {
|
if outputFile == "" {
|
||||||
@@ -236,8 +372,12 @@ func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
|
|||||||
"formatDuration": g.formatDuration,
|
"formatDuration": g.formatDuration,
|
||||||
"encodeImageBase64": g.encodeImageToBase64,
|
"encodeImageBase64": g.encodeImageToBase64,
|
||||||
"getStepLogs": g.getStepLogsForTemplate,
|
"getStepLogs": g.getStepLogsForTemplate,
|
||||||
|
"calculateTotalActions": g.calculateTotalActions,
|
||||||
|
"calculateTotalSubActions": g.calculateTotalSubActions,
|
||||||
|
"calculateTotalRequests": g.calculateTotalRequests,
|
||||||
|
"calculateTotalScreenshots": g.calculateTotalScreenshots,
|
||||||
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
"safeHTML": func(s string) template.HTML { return template.HTML(s) },
|
||||||
"toJSON": func(v interface{}) string {
|
"toJSON": func(v any) string {
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
encoder := json.NewEncoder(&buf)
|
encoder := json.NewEncoder(&buf)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
@@ -248,7 +388,7 @@ func (g *HTMLReportGenerator) GenerateReport(outputFile string) error {
|
|||||||
"mul": func(a, b float64) float64 { return a * b },
|
"mul": func(a, b float64) float64 { return a * b },
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
"base": filepath.Base,
|
"base": filepath.Base,
|
||||||
"index": func(m map[string]any, key string) interface{} { return m[key] },
|
"index": func(m map[string]any, key string) any { return m[key] },
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse template
|
// Parse template
|
||||||
@@ -778,6 +918,12 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-header:hover {
|
||||||
|
background-color: rgba(0,0,0,0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
@@ -824,16 +970,35 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-data {
|
.log-toggle {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-toggle.rotated {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-fields {
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
border-left: 3px solid #dee2e6;
|
border-left: 3px solid #dee2e6;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
margin: 2px 0 2px 195px;
|
margin: 2px 0;
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
max-height: 80px;
|
max-height: 80px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-fields.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
padding: 0 6px;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
@@ -961,10 +1126,14 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-data {
|
.log-fields {
|
||||||
margin-left: 10px;
|
margin: 2px 0;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-fields.collapsed {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -978,10 +1147,6 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
<div class="summary">
|
<div class="summary">
|
||||||
<h2>📊 Test Summary</h2>
|
<h2>📊 Test Summary</h2>
|
||||||
<div class="summary-grid">
|
<div class="summary-grid">
|
||||||
<div class="summary-item {{if .Success}}success{{else}}failure{{end}}">
|
|
||||||
<div class="value">{{if .Success}}✓{{else}}✗{{end}}</div>
|
|
||||||
<div class="label">Overall Status</div>
|
|
||||||
</div>
|
|
||||||
<div class="summary-item">
|
<div class="summary-item">
|
||||||
<div class="value">{{.Stat.TestCases.Total}}</div>
|
<div class="value">{{.Stat.TestCases.Total}}</div>
|
||||||
<div class="label">Total Test Cases</div>
|
<div class="label">Total Test Cases</div>
|
||||||
@@ -998,6 +1163,22 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
<div class="value">{{.Stat.TestSteps.Total}}</div>
|
<div class="value">{{.Stat.TestSteps.Total}}</div>
|
||||||
<div class="label">Total Steps</div>
|
<div class="label">Total Steps</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="value">{{calculateTotalActions}}</div>
|
||||||
|
<div class="label">Total Actions</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="value">{{calculateTotalSubActions}}</div>
|
||||||
|
<div class="label">Total Sub-Actions</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="value">{{calculateTotalRequests}}</div>
|
||||||
|
<div class="label">Total Requests</div>
|
||||||
|
</div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<div class="value">{{calculateTotalScreenshots}}</div>
|
||||||
|
<div class="label">Total Screenshots</div>
|
||||||
|
</div>
|
||||||
<div class="summary-item">
|
<div class="summary-item">
|
||||||
<div class="value">{{printf "%.1f" .Time.Duration}}s</div>
|
<div class="value">{{printf "%.1f" .Time.Duration}}s</div>
|
||||||
<div class="label">Duration</div>
|
<div class="label">Duration</div>
|
||||||
@@ -1178,13 +1359,16 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
<div class="logs-container">
|
<div class="logs-container">
|
||||||
{{range $logEntry := $stepLogs}}
|
{{range $logEntry := $stepLogs}}
|
||||||
<div class="log-entry {{$logEntry.Level}}">
|
<div class="log-entry {{$logEntry.Level}}">
|
||||||
<div class="log-header">
|
<div class="log-header" {{if $logEntry.Fields}}onclick="toggleLogFields(this)"{{end}}>
|
||||||
<span class="log-time">{{$logEntry.Time}}</span>
|
<span class="log-time">{{$logEntry.Time}}</span>
|
||||||
<span class="log-level {{$logEntry.Level}}">{{$logEntry.Level}}</span>
|
<span class="log-level {{$logEntry.Level}}">{{$logEntry.Level}}</span>
|
||||||
<span class="log-message">{{$logEntry.Message}}</span>
|
<span class="log-message">{{$logEntry.Message}}</span>
|
||||||
|
{{if $logEntry.Fields}}
|
||||||
|
<span class="log-toggle">▼</span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if $logEntry.Data}}
|
{{if $logEntry.Fields}}
|
||||||
<div class="log-data">{{safeHTML (toJSON $logEntry.Data)}}</div>
|
<div class="log-fields collapsed">{{safeHTML (toJSON $logEntry.Fields)}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -1219,6 +1403,22 @@ const htmlTemplate = `<!DOCTYPE html>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleLogFields(headerElement) {
|
||||||
|
const logEntry = headerElement.parentElement;
|
||||||
|
const fieldsElement = logEntry.querySelector('.log-fields');
|
||||||
|
const toggleIcon = headerElement.querySelector('.log-toggle');
|
||||||
|
|
||||||
|
if (fieldsElement && toggleIcon) {
|
||||||
|
if (fieldsElement.classList.contains('collapsed')) {
|
||||||
|
fieldsElement.classList.remove('collapsed');
|
||||||
|
toggleIcon.classList.add('rotated');
|
||||||
|
} else {
|
||||||
|
fieldsElement.classList.add('collapsed');
|
||||||
|
toggleIcon.classList.remove('rotated');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openImageModal(src) {
|
function openImageModal(src) {
|
||||||
const modal = document.getElementById('imageModal');
|
const modal = document.getElementById('imageModal');
|
||||||
const modalImg = document.getElementById('modalImage');
|
const modalImg = document.getElementById('modalImage');
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ func (s *MCPServer4XTDriver) registerTool(tool ActionTool) {
|
|||||||
s.mcpTools = append(s.mcpTools, mcpTool)
|
s.mcpTools = append(s.mcpTools, mcpTool)
|
||||||
s.actionToolMap[tool.Name()] = tool
|
s.actionToolMap[tool.Name()] = tool
|
||||||
|
|
||||||
log.Debug().Str("name", toolName).Str("type", toolName).Msg("register tool")
|
log.Debug().Str("name", toolName).Msg("register tool")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionTool interface defines the contract for MCP tools
|
// ActionTool interface defines the contract for MCP tools
|
||||||
|
|||||||
Reference in New Issue
Block a user