feat: enhance report generation by integrating session data and improving AI query display

This commit is contained in:
lilong.129
2025-06-20 17:38:36 +08:00
parent ed5d3127cb
commit 0c9dac95a1
6 changed files with 179 additions and 111 deletions

View File

@@ -1 +1 @@
v5.0.0-beta-2506192157
v5.0.0-beta-2506201738

232
report.go
View File

@@ -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
View File

@@ -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

View File

@@ -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) {

View File

@@ -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")
}
}

View File

@@ -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