mirror of
https://github.com/httprunner/httprunner.git
synced 2026-05-06 20:32:44 +08:00
feat: enhance report generation by integrating session data and improving AI query display
This commit is contained in:
@@ -1 +1 @@
|
||||
v5.0.0-beta-2506192157
|
||||
v5.0.0-beta-2506201738
|
||||
|
||||
232
report.go
232
report.go
@@ -369,10 +369,6 @@ func (g *HTMLReportGenerator) calculateTotalActions() int {
|
||||
func (g *HTMLReportGenerator) calculateTotalSubActions() int {
|
||||
return g.iterateTestData(func(action *ActionResult) int {
|
||||
total := 0
|
||||
// Count sub-actions from regular actions
|
||||
if action.SubActions != nil {
|
||||
total += len(action.SubActions)
|
||||
}
|
||||
// Count sub-actions from planning results
|
||||
if action.Plannings != nil {
|
||||
for _, planning := range action.Plannings {
|
||||
@@ -380,6 +376,8 @@ func (g *HTMLReportGenerator) calculateTotalSubActions() int {
|
||||
total += len(planning.SubActions)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
total += 1
|
||||
}
|
||||
return total
|
||||
})
|
||||
@@ -1208,6 +1206,8 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.request-item-compact {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e9ecef;
|
||||
@@ -2085,6 +2085,28 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.action-session-data {
|
||||
margin-top: 15px;
|
||||
padding: 15px;
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.session-requests {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.session-screenshots {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.sub-action-item {
|
||||
margin-top: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
@@ -2342,97 +2364,153 @@ const htmlTemplate = `<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{/* SubActions are now displayed in the right panel, so we don't show them here */}}
|
||||
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* Handle special case: ai_query needs enhanced display even when not in planning */}}
|
||||
{{if $action.SubActions}}
|
||||
{{range $subAction := $action.SubActions}}
|
||||
{{if eq $subAction.ActionName "ai_query"}}
|
||||
<div class="sub-action-item">
|
||||
<!-- Enhanced AI Query Display -->
|
||||
<div class="validator-ai-content">
|
||||
<!-- Extract AI query details from step logs -->
|
||||
{{$stepLogs := getStepLogs $step}}
|
||||
{{$queryThought := ""}}
|
||||
{{$queryModel := ""}}
|
||||
{{$queryUsage := ""}}
|
||||
{{$queryScreenshot := ""}}
|
||||
{{$queryResult := ""}}
|
||||
{{range $logEntry := $stepLogs}}
|
||||
{{if and (eq $logEntry.Message "log response message") (index $logEntry.Fields "content")}}
|
||||
{{$content := index $logEntry.Fields "content"}}
|
||||
{{if $content}}
|
||||
{{$queryResult = $content}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "call model service for query") (index $logEntry.Fields "model")}}
|
||||
{{$queryModel = index $logEntry.Fields "model"}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "usage statistics") (index $logEntry.Fields "input_tokens")}}
|
||||
{{$inputTokens := index $logEntry.Fields "input_tokens"}}
|
||||
{{$outputTokens := index $logEntry.Fields "output_tokens"}}
|
||||
{{$totalTokens := index $logEntry.Fields "total_tokens"}}
|
||||
{{$queryUsage = printf "📊 Tokens: %v in / %v out / %v total" $inputTokens $outputTokens $totalTokens}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "log screenshot") (index $logEntry.Fields "imagePath")}}
|
||||
{{$queryScreenshot = index $logEntry.Fields "imagePath"}}
|
||||
{{end}}
|
||||
{{if eq $action.Method "ai_query"}}
|
||||
<div class="sub-action-item">
|
||||
<!-- Enhanced AI Query Display -->
|
||||
<div class="validator-ai-content">
|
||||
<!-- Extract AI query details from step logs -->
|
||||
{{$stepLogs := getStepLogs $step}}
|
||||
{{$queryThought := ""}}
|
||||
{{$queryModel := ""}}
|
||||
{{$queryUsage := ""}}
|
||||
{{$queryScreenshot := ""}}
|
||||
{{$queryResult := ""}}
|
||||
{{range $logEntry := $stepLogs}}
|
||||
{{if and (eq $logEntry.Message "log response message") (index $logEntry.Fields "content")}}
|
||||
{{$content := index $logEntry.Fields "content"}}
|
||||
{{if $content}}
|
||||
{{$queryResult = $content}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "call model service for query") (index $logEntry.Fields "model")}}
|
||||
{{$queryModel = index $logEntry.Fields "model"}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "usage statistics") (index $logEntry.Fields "input_tokens")}}
|
||||
{{$inputTokens := index $logEntry.Fields "input_tokens"}}
|
||||
{{$outputTokens := index $logEntry.Fields "output_tokens"}}
|
||||
{{$totalTokens := index $logEntry.Fields "total_tokens"}}
|
||||
{{$queryUsage = printf "📊 Tokens: %v in / %v out / %v total" $inputTokens $outputTokens $totalTokens}}
|
||||
{{end}}
|
||||
{{if and (eq $logEntry.Message "log screenshot") (index $logEntry.Fields "imagePath")}}
|
||||
{{$queryScreenshot = index $logEntry.Fields "imagePath"}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<!-- Display AI Query Result at the top -->
|
||||
{{if $queryResult}}
|
||||
<div class="thought">{{$queryResult}}</div>
|
||||
{{end}}
|
||||
<!-- Display AI Query Result at the top -->
|
||||
{{if $queryResult}}
|
||||
<div class="thought">{{$queryResult}}</div>
|
||||
{{end}}
|
||||
|
||||
<!-- AI Query Layout - similar to validator layout -->
|
||||
<div class="validator-ai-layout">
|
||||
<!-- Left column: Screenshot -->
|
||||
{{if $queryScreenshot}}
|
||||
<div class="validator-column-screenshot">
|
||||
<div class="validator-step-compact">
|
||||
<div class="step-header-compact">
|
||||
<span class="step-name">📸 Query Screenshot</span>
|
||||
</div>
|
||||
<div class="screenshot-display">
|
||||
{{$base64Image := encodeImageBase64 $queryScreenshot}}
|
||||
{{if $base64Image}}
|
||||
<div class="screenshot-item-compact">
|
||||
<div class="screenshot-image">
|
||||
<img src="data:image/jpeg;base64,{{$base64Image}}" alt="Query Screenshot" onclick="openImageModal(this.src)" />
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<!-- AI Query Layout - similar to validator layout -->
|
||||
<div class="validator-ai-layout">
|
||||
<!-- Left column: Screenshot -->
|
||||
{{if $queryScreenshot}}
|
||||
<div class="validator-column-screenshot">
|
||||
<div class="validator-step-compact">
|
||||
<div class="step-header-compact">
|
||||
<span class="step-name">📸 Query Screenshot</span>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Right column: AI Query -->
|
||||
<div class="validator-column-analysis">
|
||||
<div class="validator-step-compact">
|
||||
<div class="step-header-compact">
|
||||
<span class="step-name">🤖 AI Query</span>
|
||||
</div>
|
||||
<div class="validator-ai-details">
|
||||
{{if $queryModel}}
|
||||
<div class="model-info">🤖 Model: {{$queryModel}}</div>
|
||||
{{end}}
|
||||
{{if $queryUsage}}
|
||||
<div class="usage-info">{{$queryUsage}}</div>
|
||||
{{end}}
|
||||
<div class="screenshot-display">
|
||||
{{$base64Image := encodeImageBase64 $queryScreenshot}}
|
||||
{{if $base64Image}}
|
||||
<div class="screenshot-item-compact">
|
||||
<div class="screenshot-image">
|
||||
<img src="data:image/jpeg;base64,{{$base64Image}}" alt="Query Screenshot" onclick="openImageModal(this.src)" />
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Right column: AI Query -->
|
||||
<div class="validator-column-analysis">
|
||||
<div class="validator-step-compact">
|
||||
<div class="step-header-compact">
|
||||
<span class="step-name">🤖 AI Query</span>
|
||||
</div>
|
||||
<div class="validator-ai-details">
|
||||
{{if $queryModel}}
|
||||
<div class="model-info">🤖 Model: {{$queryModel}}</div>
|
||||
{{end}}
|
||||
{{if $queryUsage}}
|
||||
<div class="usage-info">{{$queryUsage}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{/* Handle SessionData: display requests and screen results for non-planning actions */}}
|
||||
{{if not $action.Plannings}}
|
||||
{{if or $action.Requests $action.ScreenResults}}
|
||||
<div class="action-session-data">
|
||||
<!-- Display requests if present -->
|
||||
{{if $action.Requests}}
|
||||
<div class="session-requests">
|
||||
<button class="requests-toggle-compact" onclick="toggleRequestsCompact(this)">
|
||||
📡 {{len $action.Requests}} request(s)
|
||||
</button>
|
||||
<div class="requests-content-compact">
|
||||
{{range $request := $action.Requests}}
|
||||
<div class="request-item-compact">
|
||||
<div class="request-header-compact">
|
||||
<span class="method">{{$request.RequestMethod}}</span>
|
||||
<span class="url-compact">{{$request.RequestUrl}}</span>
|
||||
<span class="status {{if $request.Success}}success{{else}}failure{{end}}">{{$request.ResponseStatus}}</span>
|
||||
<span class="duration">{{formatDuration $request.ResponseDuration}}</span>
|
||||
</div>
|
||||
{{if $request.RequestBody}}
|
||||
<div class="request-body-compact">Request: {{$request.RequestBody}}</div>
|
||||
{{end}}
|
||||
{{if $request.ResponseBody}}
|
||||
<div class="response-body-compact">Response: {{$request.ResponseBody}}</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- Display screen results if present -->
|
||||
{{if $action.ScreenResults}}
|
||||
<div class="session-screenshots">
|
||||
<h5 style="margin: 10px 0; color: #495057;">📸 Screen Results ({{len $action.ScreenResults}})</h5>
|
||||
<div class="screenshots-horizontal">
|
||||
{{range $screenshot := $action.ScreenResults}}
|
||||
{{if $screenshot.ImagePath}}
|
||||
{{$base64Image := encodeImageBase64 $screenshot.ImagePath}}
|
||||
{{if $base64Image}}
|
||||
<div class="screenshot-item small">
|
||||
<div class="screenshot-info">
|
||||
<span class="filename">{{base $screenshot.ImagePath}}</span>
|
||||
{{if $screenshot.Resolution}}
|
||||
<span class="resolution">{{$screenshot.Resolution.Width}}x{{$screenshot.Resolution.Height}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="screenshot-image">
|
||||
<img src="data:image/jpeg;base64,{{$base64Image}}" alt="Screenshot" onclick="openImageModal(this.src)" />
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
{{/* Other SubActions (non-ai_query) are displayed in the Planning section's right panel to avoid duplication */}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
10
step.go
10
step.go
@@ -58,11 +58,11 @@ type TStep struct {
|
||||
// one step contains one or multiple actions
|
||||
type ActionResult struct {
|
||||
option.MobileAction `json:",inline"`
|
||||
StartTime int64 `json:"start_time"` // action start time in millisecond(ms)
|
||||
Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms)
|
||||
Error error `json:"error"` // action execution result
|
||||
Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions
|
||||
SubActions []*uixt.SubActionResult `json:"sub_actions,omitempty"` // store sub-actions for other actions
|
||||
StartTime int64 `json:"start_time"` // action start time in millisecond(ms)
|
||||
Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms)
|
||||
Error error `json:"error,omitempty"` // action execution result
|
||||
Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions, which contains multiple sub-actions
|
||||
uixt.SessionData // store session data for other actions besides start_to_goal
|
||||
}
|
||||
|
||||
// one testcase contains one or multiple steps
|
||||
|
||||
15
step_ui.go
15
step_ui.go
@@ -783,13 +783,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
|
||||
},
|
||||
StartTime: startTime.UnixMilli(),
|
||||
}
|
||||
subActionResults, err1 := uiDriver.ExecuteAction(
|
||||
sessionData, err1 := uiDriver.ExecuteAction(
|
||||
context.Background(), actionResult.MobileAction)
|
||||
if err1 != nil {
|
||||
actionResult.Error = err1
|
||||
log.Warn().Err(err1).Msg("get foreground app failed, ignore")
|
||||
}
|
||||
actionResult.Elapsed = time.Since(startTime).Milliseconds()
|
||||
actionResult.SubActions = subActionResults
|
||||
actionResult.SessionData = sessionData
|
||||
stepResult.Actions = append(stepResult.Actions, actionResult)
|
||||
}
|
||||
|
||||
@@ -827,13 +828,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
|
||||
},
|
||||
StartTime: startTime.UnixMilli(),
|
||||
}
|
||||
subActionResults, err2 := uiDriver.ExecuteAction(
|
||||
sessionData, err2 := uiDriver.ExecuteAction(
|
||||
context.Background(), actionResult.MobileAction)
|
||||
if err2 != nil {
|
||||
actionResult.Error = err2
|
||||
log.Warn().Err(err2).Str("step", step.Name()).Msg("auto handle popup failed")
|
||||
}
|
||||
actionResult.Elapsed = time.Since(startTime).Milliseconds()
|
||||
actionResult.SubActions = subActionResults
|
||||
actionResult.SessionData = sessionData
|
||||
stepResult.Actions = append(stepResult.Actions, actionResult)
|
||||
}
|
||||
|
||||
@@ -950,9 +952,10 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
|
||||
}
|
||||
|
||||
// handle other actions
|
||||
subActionResults, err := uiDriver.ExecuteAction(ctx, action)
|
||||
sessionData, err := uiDriver.ExecuteAction(ctx, action)
|
||||
actionResult.Error = err
|
||||
actionResult.Elapsed = time.Since(actionStartTime).Milliseconds()
|
||||
actionResult.SubActions = subActionResults
|
||||
actionResult.SessionData = sessionData
|
||||
stepResult.Actions = append(stepResult.Actions, actionResult)
|
||||
if err != nil {
|
||||
if !code.IsErrorPredefined(err) {
|
||||
|
||||
@@ -125,7 +125,7 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
|
||||
planningResult.Elapsed = time.Since(planningStartTime).Milliseconds()
|
||||
allPlannings = append(allPlannings, planningResult)
|
||||
|
||||
if options.MaxRetryTimes > 0 && attempt >= options.MaxRetryTimes {
|
||||
if options.MaxRetryTimes > 0 && attempt > options.MaxRetryTimes {
|
||||
return allPlannings, errors.New("reached max retry times")
|
||||
}
|
||||
}
|
||||
|
||||
29
uixt/sdk.go
29
uixt/sdk.go
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/httprunner/httprunner/v5/uixt/ai"
|
||||
"github.com/httprunner/httprunner/v5/uixt/option"
|
||||
@@ -113,34 +112,23 @@ func (c *MCPClient4XTDriver) GetToolByAction(actionName option.ActionName) Actio
|
||||
return c.Server.GetToolByAction(actionName)
|
||||
}
|
||||
|
||||
func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) ([]*SubActionResult, error) {
|
||||
subActionStartTime := time.Now()
|
||||
|
||||
func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) (SessionData, error) {
|
||||
// Find the corresponding tool for this action method
|
||||
tool := dExt.client.Server.GetToolByAction(action.Method)
|
||||
if tool == nil {
|
||||
return nil, fmt.Errorf("no tool found for action method: %s", action.Method)
|
||||
return SessionData{}, fmt.Errorf("no tool found for action method: %s", action.Method)
|
||||
}
|
||||
|
||||
// Use the tool's own conversion method
|
||||
req, err := tool.ConvertActionToCallToolRequest(action)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert action to MCP tool call: %w", err)
|
||||
}
|
||||
|
||||
// Create sub-action result
|
||||
subActionResult := &SubActionResult{
|
||||
ActionName: string(action.Method),
|
||||
Arguments: action.Params,
|
||||
StartTime: subActionStartTime.UnixMilli(),
|
||||
return SessionData{}, fmt.Errorf("failed to convert action to MCP tool call: %w", err)
|
||||
}
|
||||
|
||||
// Execute via MCP tool
|
||||
result, err := dExt.client.CallTool(ctx, req)
|
||||
subActionResult.Elapsed = time.Since(subActionStartTime).Milliseconds()
|
||||
if err != nil {
|
||||
subActionResult.Error = err
|
||||
return []*SubActionResult{subActionResult}, fmt.Errorf("MCP tool call failed: %w", err)
|
||||
return SessionData{}, fmt.Errorf("MCP tool call failed: %w", err)
|
||||
}
|
||||
|
||||
// Check if the tool execution had business logic errors
|
||||
@@ -152,16 +140,15 @@ func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAct
|
||||
errMsg = fmt.Sprintf("invoke tool %s failed", tool.Name())
|
||||
}
|
||||
err := errors.New(errMsg)
|
||||
subActionResult.Error = err
|
||||
return []*SubActionResult{subActionResult}, err
|
||||
return SessionData{}, err
|
||||
}
|
||||
|
||||
// For regular actions, collect session data and return single sub-action result
|
||||
subActionResult.SessionData = dExt.GetSession().GetData(true) // reset after getting data
|
||||
// For regular actions, collect session data and return it directly
|
||||
sessionData := dExt.GetSession().GetData(true) // reset after getting data
|
||||
|
||||
log.Debug().Str("tool", string(tool.Name())).
|
||||
Msg("executed action via MCP tool")
|
||||
return []*SubActionResult{subActionResult}, nil
|
||||
return sessionData, nil
|
||||
}
|
||||
|
||||
// NewDeviceWithDefault is a helper function to create a device with default options
|
||||
|
||||
Reference in New Issue
Block a user