Merge branch 'merge-wings' into 'master'

Merge wings

See merge request iesqa/httprunner!102
This commit is contained in:
李隆
2025-06-21 07:44:56 +00:00
16 changed files with 328 additions and 147 deletions

View File

@@ -1 +1 @@
v5.0.0-beta-2506191048
v5.0.0-beta-2506211542

284
report.go
View File

@@ -14,6 +14,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
@@ -88,6 +89,9 @@ func (g *HTMLReportGenerator) loadSummaryData() error {
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)
@@ -108,6 +112,37 @@ func (g *HTMLReportGenerator) loadSummaryData() error {
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) {
@@ -334,17 +369,16 @@ 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
// 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
})
@@ -932,10 +966,6 @@ const htmlTemplate = `<!DOCTYPE html>
line-height: 1.4;
}
.action-content {
display: block;
}
@@ -984,8 +1014,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-weight: bold;
}
.planning-three-columns {
display: flex;
gap: 20px;
@@ -1284,8 +1312,6 @@ const htmlTemplate = `<!DOCTYPE html>
}
}
.action-details {
display: flex;
align-items: center;
@@ -1331,8 +1357,6 @@ const htmlTemplate = `<!DOCTYPE html>
line-height: 1;
}
.arguments {
background: #f8f9fa;
border: 1px solid #dee2e6;
@@ -1343,8 +1367,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-size: 0.9em;
}
.screenshots-section {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px solid #28a745;
@@ -1938,8 +1960,6 @@ const htmlTemplate = `<!DOCTYPE html>
gap: 8px;
}
.logs-header {
flex-direction: column;
align-items: flex-start;
@@ -1956,8 +1976,6 @@ const htmlTemplate = `<!DOCTYPE html>
gap: 6px;
}
.screenshots-grid {
grid-template-columns: 1fr;
gap: 10px;
@@ -2050,7 +2068,27 @@ 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>
<body>
@@ -2144,8 +2182,6 @@ const htmlTemplate = `<!DOCTYPE html>
</div>
</div>
<div class="test-cases">
{{range $caseIndex, $testCase := .Details}}
<div class="test-case">
@@ -2307,97 +2343,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}}

View File

@@ -49,10 +49,9 @@ type UIXTConfig struct {
WDAPort int
WDAMjpegPort int
OSType string // platform
Serial string
PackageName string
LLMService option.LLMServiceType // LLM 服务类型
OSType string // platform
Serial string
LLMService option.LLMServiceType // LLM 服务类型
}
const (

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 string `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.Error()
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.Error()
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,11 +952,12 @@ 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.Elapsed = time.Since(actionStartTime).Milliseconds()
actionResult.SubActions = subActionResults
actionResult.SessionData = sessionData
stepResult.Actions = append(stepResult.Actions, actionResult)
if err != nil {
actionResult.Error = err.Error()
if !code.IsErrorPredefined(err) {
err = errors.Wrap(code.MobileUIDriverError, err.Error())
}

View File

@@ -63,6 +63,7 @@ func (s *vedemCVService) ReadFromPath(imagePath string, opts ...option.ActionOpt
func (s *vedemCVService) ReadFromBuffer(imageBuf *bytes.Buffer, opts ...option.ActionOption) (
imageResult *CVResult, err error) {
actionOptions := option.NewActionOptions(opts...)
log.Debug().Interface("options", actionOptions).Msg("vedem.ReadFromBuffer")
screenshotActions := actionOptions.List()
if len(screenshotActions) == 0 {
// skip

View File

@@ -384,6 +384,9 @@ func (dev *AndroidDevice) getPackageVersion(packageName string) (string, error)
}
func (dev *AndroidDevice) getPackagePath(packageName string) (string, error) {
if packageName == "" {
return "", errors.Wrap(code.InvalidParamError, "packageName is empty")
}
output, err := dev.Device.RunShellCommand("pm", "path", packageName)
if err != nil {
return "", errors.Wrap(err, "get package path failed")

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 > 1 && attempt >= options.MaxRetryTimes {
if options.MaxRetryTimes > 0 && attempt > options.MaxRetryTimes {
return allPlannings, errors.New("reached max retry times")
}
}

View File

@@ -136,7 +136,9 @@ func (dExt *XTDriver) createScreenshotWithSession(opts ...option.ActionOption) (
screenResult.Popup.ClosePoints = append(screenResult.Popup.ClosePoints, closeArea.Center())
}
}
logger.Str("imageUrl", screenResult.UploadedURL)
if screenResult.UploadedURL != "" {
logger.Str("imageUrl", screenResult.UploadedURL)
}
}
}

View File

@@ -10,6 +10,7 @@ import (
func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
log.Info().Str("text", text).Interface("options", actionOptions).Msg("TapByOCR")
if actionOptions.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName(fmt.Sprintf("tap_by_ocr_%s", text)))
}
@@ -36,7 +37,7 @@ func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
func (dExt *XTDriver) TapByCV(opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...)
log.Info().Interface("options", actionOptions).Msg("TapByCV")
uiResult, err := dExt.FindUIResult(opts...)
if err != nil {
if actionOptions.IgnoreNotFoundError {

View File

@@ -1,6 +1,7 @@
package uixt
import (
"context"
"crypto/md5"
"fmt"
"io"
@@ -274,7 +275,8 @@ func getSimulationDuration(params []float64) (milliseconds int64) {
// sleepStrict sleeps strict duration with given params
// startTime is used to correct sleep duration caused by process time
func sleepStrict(startTime time.Time, strictMilliseconds int64) {
// ctx allows for cancellation during sleep
func sleepStrict(ctx context.Context, startTime time.Time, strictMilliseconds int64) {
var elapsed int64
if !startTime.IsZero() {
elapsed = time.Since(startTime).Milliseconds()
@@ -294,7 +296,18 @@ func sleepStrict(startTime time.Time, strictMilliseconds int64) {
Int64("elapsed(ms)", elapsed).
Int64("strictSleep(ms)", strictMilliseconds).
Msg("sleep remaining duration time")
time.Sleep(time.Duration(dur) * time.Millisecond)
// Use context-aware sleep instead of blocking time.Sleep
select {
case <-time.After(time.Duration(dur) * time.Millisecond):
// Normal completion
log.Debug().Int64("duration_ms", dur).Msg("strict sleep completed normally")
case <-ctx.Done():
// Interrupted by context cancellation (e.g., CTRL+C)
log.Info().Int64("planned_duration_ms", dur).
Msg("strict sleep interrupted by context cancellation")
return
}
}
// global file lock

View File

@@ -1,6 +1,7 @@
package uixt
import (
"context"
"strings"
"testing"
"time"
@@ -30,8 +31,9 @@ func TestGetSimulationDuration(t *testing.T) {
}
func TestSleepStrict(t *testing.T) {
ctx := context.Background()
startTime := time.Now()
sleepStrict(startTime, 1230)
sleepStrict(ctx, startTime, 1230)
dur := time.Since(startTime).Milliseconds()
t.Log(dur)
if dur < 1230 || dur > 1300 {

View File

@@ -226,6 +226,68 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
if tempOptions.CVService != "" {
arguments["cv_service"] = tempOptions.CVService
}
// Add UI/CV related options
if len(tempOptions.ScreenShotWithUITypes) > 0 {
arguments["screenshot_with_ui_types"] = tempOptions.ScreenShotWithUITypes
}
if len(tempOptions.Scope) == 4 {
arguments["scope"] = tempOptions.Scope
}
if len(tempOptions.AbsScope) == 4 {
arguments["abs_scope"] = tempOptions.AbsScope
}
// Add other screenshot options
if tempOptions.ScreenShotWithOCR {
arguments["screenshot_with_ocr"] = true
}
if tempOptions.ScreenShotWithUpload {
arguments["screenshot_with_upload"] = true
}
if tempOptions.ScreenShotWithLiveType {
arguments["screenshot_with_live_type"] = true
}
if tempOptions.ScreenShotWithLivePopularity {
arguments["screenshot_with_live_popularity"] = true
}
if tempOptions.ScreenShotWithClosePopups {
arguments["screenshot_with_close_popups"] = true
}
if tempOptions.ScreenShotWithOCRCluster != "" {
arguments["screenshot_with_ocr_cluster"] = tempOptions.ScreenShotWithOCRCluster
}
if tempOptions.ScreenShotFileName != "" {
arguments["screenshot_file_name"] = tempOptions.ScreenShotFileName
}
// Add tap/swipe offset options
if len(tempOptions.TapOffset) == 2 {
arguments["tap_offset"] = tempOptions.TapOffset
}
if len(tempOptions.SwipeOffset) == 4 {
arguments["swipe_offset"] = tempOptions.SwipeOffset
}
if len(tempOptions.OffsetRandomRange) == 2 {
arguments["offset_random_range"] = tempOptions.OffsetRandomRange
}
// Add string options
if tempOptions.Text != "" {
arguments["text"] = tempOptions.Text
}
if tempOptions.ImagePath != "" {
arguments["image_path"] = tempOptions.ImagePath
}
if tempOptions.AppName != "" {
arguments["app_name"] = tempOptions.AppName
}
if tempOptions.PackageName != "" {
arguments["package_name"] = tempOptions.PackageName
}
if tempOptions.Selector != "" {
arguments["selector"] = tempOptions.Selector
}
}
func getFloat64ValueOrDefault(value float64, defaultValue float64) float64 {

View File

@@ -68,7 +68,15 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
}
time.Sleep(duration)
// Use context-aware sleep instead of blocking time.Sleep
select {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// Interrupted by context cancellation (e.g., CTRL+C)
log.Warn().Msg("sleep interrupted by cancellation")
return nil, fmt.Errorf("sleep interrupted: %w", ctx.Err())
}
message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
returnData := ToolSleep{
@@ -120,7 +128,18 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
// Sleep MS action logic
log.Info().Int64("milliseconds", unifiedReq.Milliseconds).Msg("sleeping in milliseconds")
time.Sleep(time.Duration(unifiedReq.Milliseconds) * time.Millisecond)
duration := time.Duration(unifiedReq.Milliseconds) * time.Millisecond
// Use context-aware sleep instead of blocking time.Sleep
select {
case <-time.After(duration):
// Normal completion
case <-ctx.Done():
// Interrupted by context cancellation (e.g., CTRL+C)
log.Warn().Msg("sleep interrupted by cancellation")
return nil, fmt.Errorf("sleep interrupted: %w", ctx.Err())
}
message := fmt.Sprintf("Successfully slept for %d milliseconds", unifiedReq.Milliseconds)
returnData := ToolSleepMS{Milliseconds: unifiedReq.Milliseconds}
@@ -170,8 +189,8 @@ func (t *ToolSleepRandom) Implement() server.ToolHandlerFunc {
return nil, err
}
// Sleep random action logic
sleepStrict(time.Now(), getSimulationDuration(unifiedReq.Params))
// Sleep random action logic with context support
sleepStrict(ctx, time.Now(), getSimulationDuration(unifiedReq.Params))
message := fmt.Sprintf("Successfully slept for random duration with params: %v", unifiedReq.Params)
returnData := ToolSleepRandom{Params: unifiedReq.Params}

View File

@@ -440,9 +440,6 @@ func NewActionOptions(opts ...ActionOption) *ActionOptions {
for _, option := range opts {
option(actionOptions)
}
if actionOptions.MaxRetryTimes == 0 {
actionOptions.MaxRetryTimes = 1
}
return actionOptions
}

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("execute action via MCP tool")
return []*SubActionResult{subActionResult}, nil
Msg("executed action via MCP tool")
return sessionData, nil
}
// NewDeviceWithDefault is a helper function to create a device with default options