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
+1 -1
View File
@@ -1 +1 @@
v5.0.0-beta-2506191048 v5.0.0-beta-2506211542
+120 -28
View File
@@ -14,6 +14,7 @@ import (
"github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt" "github.com/httprunner/httprunner/v5/uixt"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
@@ -88,6 +89,9 @@ func (g *HTMLReportGenerator) loadSummaryData() error {
return err 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 // Re-encode the summary data to ensure proper UTF-8 encoding for download
// This fixes Chinese character encoding issues in legacy summary.json files // This fixes Chinese character encoding issues in legacy summary.json files
buffer := new(strings.Builder) buffer := new(strings.Builder)
@@ -108,6 +112,37 @@ func (g *HTMLReportGenerator) loadSummaryData() error {
return nil 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 // loadLogData loads test log data from log file
func (g *HTMLReportGenerator) loadLogData() error { func (g *HTMLReportGenerator) loadLogData() error {
if g.LogFile == "" || !builtin.FileExists(g.LogFile) { if g.LogFile == "" || !builtin.FileExists(g.LogFile) {
@@ -334,17 +369,16 @@ func (g *HTMLReportGenerator) calculateTotalActions() int {
func (g *HTMLReportGenerator) calculateTotalSubActions() int { func (g *HTMLReportGenerator) calculateTotalSubActions() int {
return g.iterateTestData(func(action *ActionResult) int { return g.iterateTestData(func(action *ActionResult) int {
total := 0 total := 0
// Count sub-actions from regular actions // Count sub-actions from start_to_goal results
if action.SubActions != nil {
total += len(action.SubActions)
}
// Count sub-actions from planning results
if action.Plannings != nil { if action.Plannings != nil {
for _, planning := range action.Plannings { for _, planning := range action.Plannings {
if planning.SubActions != nil { if planning.SubActions != nil {
total += len(planning.SubActions) total += len(planning.SubActions)
} }
} }
} else {
// Count other actions
total += 1
} }
return total return total
}) })
@@ -932,10 +966,6 @@ const htmlTemplate = `<!DOCTYPE html>
line-height: 1.4; line-height: 1.4;
} }
.action-content { .action-content {
display: block; display: block;
} }
@@ -984,8 +1014,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-weight: bold; font-weight: bold;
} }
.planning-three-columns { .planning-three-columns {
display: flex; display: flex;
gap: 20px; gap: 20px;
@@ -1284,8 +1312,6 @@ const htmlTemplate = `<!DOCTYPE html>
} }
} }
.action-details { .action-details {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1331,8 +1357,6 @@ const htmlTemplate = `<!DOCTYPE html>
line-height: 1; line-height: 1;
} }
.arguments { .arguments {
background: #f8f9fa; background: #f8f9fa;
border: 1px solid #dee2e6; border: 1px solid #dee2e6;
@@ -1343,8 +1367,6 @@ const htmlTemplate = `<!DOCTYPE html>
font-size: 0.9em; font-size: 0.9em;
} }
.screenshots-section { .screenshots-section {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 2px solid #28a745; border: 2px solid #28a745;
@@ -1938,8 +1960,6 @@ const htmlTemplate = `<!DOCTYPE html>
gap: 8px; gap: 8px;
} }
.logs-header { .logs-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -1956,8 +1976,6 @@ const htmlTemplate = `<!DOCTYPE html>
gap: 6px; gap: 6px;
} }
.screenshots-grid { .screenshots-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 10px; gap: 10px;
@@ -2050,7 +2068,27 @@ const htmlTemplate = `<!DOCTYPE html>
line-height: 1.4; 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> </style>
</head> </head>
<body> <body>
@@ -2144,8 +2182,6 @@ const htmlTemplate = `<!DOCTYPE html>
</div> </div>
</div> </div>
<div class="test-cases"> <div class="test-cases">
{{range $caseIndex, $testCase := .Details}} {{range $caseIndex, $testCase := .Details}}
<div class="test-case"> <div class="test-case">
@@ -2307,15 +2343,13 @@ const htmlTemplate = `<!DOCTYPE html>
</div> </div>
</div> </div>
{{/* SubActions are now displayed in the right panel, so we don't show them here */}}
</div> </div>
{{end}} {{end}}
{{end}} {{end}}
{{/* Handle special case: ai_query needs enhanced display even when not in planning */}} {{/* Handle special case: ai_query needs enhanced display even when not in planning */}}
{{if $action.SubActions}} {{if eq $action.Method "ai_query"}}
{{range $subAction := $action.SubActions}}
{{if eq $subAction.ActionName "ai_query"}}
<div class="sub-action-item"> <div class="sub-action-item">
<!-- Enhanced AI Query Display --> <!-- Enhanced AI Query Display -->
<div class="validator-ai-content"> <div class="validator-ai-content">
@@ -2395,9 +2429,67 @@ const htmlTemplate = `<!DOCTYPE html>
</div> </div>
</div> </div>
{{end}} {{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}}
{{end}} {{end}}
{{/* Other SubActions (non-ai_query) are displayed in the Planning section's right panel to avoid duplication */}}
</div> </div>
</div> </div>
{{end}} {{end}}
-1
View File
@@ -51,7 +51,6 @@ type UIXTConfig struct {
OSType string // platform OSType string // platform
Serial string Serial string
PackageName string
LLMService option.LLMServiceType // LLM 服务类型 LLMService option.LLMServiceType // LLM 服务类型
} }
+3 -3
View File
@@ -60,9 +60,9 @@ type ActionResult struct {
option.MobileAction `json:",inline"` option.MobileAction `json:",inline"`
StartTime int64 `json:"start_time"` // action start time in millisecond(ms) StartTime int64 `json:"start_time"` // action start time in millisecond(ms)
Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms) Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms)
Error error `json:"error"` // action execution result Error string `json:"error,omitempty"` // action execution result
Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions, which contains multiple sub-actions
SubActions []*uixt.SubActionResult `json:"sub_actions,omitempty"` // store sub-actions for other actions uixt.SessionData // store session data for other actions besides start_to_goal
} }
// one testcase contains one or multiple steps // one testcase contains one or multiple steps
+9 -6
View File
@@ -783,13 +783,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
}, },
StartTime: startTime.UnixMilli(), StartTime: startTime.UnixMilli(),
} }
subActionResults, err1 := uiDriver.ExecuteAction( sessionData, err1 := uiDriver.ExecuteAction(
context.Background(), actionResult.MobileAction) context.Background(), actionResult.MobileAction)
if err1 != nil { if err1 != nil {
actionResult.Error = err1.Error()
log.Warn().Err(err1).Msg("get foreground app failed, ignore") log.Warn().Err(err1).Msg("get foreground app failed, ignore")
} }
actionResult.Elapsed = time.Since(startTime).Milliseconds() actionResult.Elapsed = time.Since(startTime).Milliseconds()
actionResult.SubActions = subActionResults actionResult.SessionData = sessionData
stepResult.Actions = append(stepResult.Actions, actionResult) stepResult.Actions = append(stepResult.Actions, actionResult)
} }
@@ -827,13 +828,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
}, },
StartTime: startTime.UnixMilli(), StartTime: startTime.UnixMilli(),
} }
subActionResults, err2 := uiDriver.ExecuteAction( sessionData, err2 := uiDriver.ExecuteAction(
context.Background(), actionResult.MobileAction) context.Background(), actionResult.MobileAction)
if err2 != nil { if err2 != nil {
actionResult.Error = err2.Error()
log.Warn().Err(err2).Str("step", step.Name()).Msg("auto handle popup failed") log.Warn().Err(err2).Str("step", step.Name()).Msg("auto handle popup failed")
} }
actionResult.Elapsed = time.Since(startTime).Milliseconds() actionResult.Elapsed = time.Since(startTime).Milliseconds()
actionResult.SubActions = subActionResults actionResult.SessionData = sessionData
stepResult.Actions = append(stepResult.Actions, actionResult) stepResult.Actions = append(stepResult.Actions, actionResult)
} }
@@ -950,11 +952,12 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
} }
// handle other actions // handle other actions
subActionResults, err := uiDriver.ExecuteAction(ctx, action) sessionData, err := uiDriver.ExecuteAction(ctx, action)
actionResult.Elapsed = time.Since(actionStartTime).Milliseconds() actionResult.Elapsed = time.Since(actionStartTime).Milliseconds()
actionResult.SubActions = subActionResults actionResult.SessionData = sessionData
stepResult.Actions = append(stepResult.Actions, actionResult) stepResult.Actions = append(stepResult.Actions, actionResult)
if err != nil { if err != nil {
actionResult.Error = err.Error()
if !code.IsErrorPredefined(err) { if !code.IsErrorPredefined(err) {
err = errors.Wrap(code.MobileUIDriverError, err.Error()) err = errors.Wrap(code.MobileUIDriverError, err.Error())
} }
+1
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) ( func (s *vedemCVService) ReadFromBuffer(imageBuf *bytes.Buffer, opts ...option.ActionOption) (
imageResult *CVResult, err error) { imageResult *CVResult, err error) {
actionOptions := option.NewActionOptions(opts...) actionOptions := option.NewActionOptions(opts...)
log.Debug().Interface("options", actionOptions).Msg("vedem.ReadFromBuffer")
screenshotActions := actionOptions.List() screenshotActions := actionOptions.List()
if len(screenshotActions) == 0 { if len(screenshotActions) == 0 {
// skip // skip
+3
View File
@@ -384,6 +384,9 @@ func (dev *AndroidDevice) getPackageVersion(packageName string) (string, error)
} }
func (dev *AndroidDevice) getPackagePath(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) output, err := dev.Device.RunShellCommand("pm", "path", packageName)
if err != nil { if err != nil {
return "", errors.Wrap(err, "get package path failed") return "", errors.Wrap(err, "get package path failed")
+1 -1
View File
@@ -125,7 +125,7 @@ func (dExt *XTDriver) StartToGoal(ctx context.Context, prompt string, opts ...op
planningResult.Elapsed = time.Since(planningStartTime).Milliseconds() planningResult.Elapsed = time.Since(planningStartTime).Milliseconds()
allPlannings = append(allPlannings, planningResult) 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") return allPlannings, errors.New("reached max retry times")
} }
} }
+2
View File
@@ -136,9 +136,11 @@ func (dExt *XTDriver) createScreenshotWithSession(opts ...option.ActionOption) (
screenResult.Popup.ClosePoints = append(screenResult.Popup.ClosePoints, closeArea.Center()) screenResult.Popup.ClosePoints = append(screenResult.Popup.ClosePoints, closeArea.Center())
} }
} }
if screenResult.UploadedURL != "" {
logger.Str("imageUrl", screenResult.UploadedURL) logger.Str("imageUrl", screenResult.UploadedURL)
} }
} }
}
// save screen result to session // save screen result to session
session := dExt.GetSession() session := dExt.GetSession()
+2 -1
View File
@@ -10,6 +10,7 @@ import (
func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error { func (dExt *XTDriver) TapByOCR(text string, opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...) actionOptions := option.NewActionOptions(opts...)
log.Info().Str("text", text).Interface("options", actionOptions).Msg("TapByOCR")
if actionOptions.ScreenShotFileName == "" { if actionOptions.ScreenShotFileName == "" {
opts = append(opts, option.WithScreenShotFileName(fmt.Sprintf("tap_by_ocr_%s", text))) 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 { func (dExt *XTDriver) TapByCV(opts ...option.ActionOption) error {
actionOptions := option.NewActionOptions(opts...) actionOptions := option.NewActionOptions(opts...)
log.Info().Interface("options", actionOptions).Msg("TapByCV")
uiResult, err := dExt.FindUIResult(opts...) uiResult, err := dExt.FindUIResult(opts...)
if err != nil { if err != nil {
if actionOptions.IgnoreNotFoundError { if actionOptions.IgnoreNotFoundError {
+15 -2
View File
@@ -1,6 +1,7 @@
package uixt package uixt
import ( import (
"context"
"crypto/md5" "crypto/md5"
"fmt" "fmt"
"io" "io"
@@ -274,7 +275,8 @@ func getSimulationDuration(params []float64) (milliseconds int64) {
// sleepStrict sleeps strict duration with given params // sleepStrict sleeps strict duration with given params
// startTime is used to correct sleep duration caused by process time // 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 var elapsed int64
if !startTime.IsZero() { if !startTime.IsZero() {
elapsed = time.Since(startTime).Milliseconds() elapsed = time.Since(startTime).Milliseconds()
@@ -294,7 +296,18 @@ func sleepStrict(startTime time.Time, strictMilliseconds int64) {
Int64("elapsed(ms)", elapsed). Int64("elapsed(ms)", elapsed).
Int64("strictSleep(ms)", strictMilliseconds). Int64("strictSleep(ms)", strictMilliseconds).
Msg("sleep remaining duration time") 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 // global file lock
+3 -1
View File
@@ -1,6 +1,7 @@
package uixt package uixt
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -30,8 +31,9 @@ func TestGetSimulationDuration(t *testing.T) {
} }
func TestSleepStrict(t *testing.T) { func TestSleepStrict(t *testing.T) {
ctx := context.Background()
startTime := time.Now() startTime := time.Now()
sleepStrict(startTime, 1230) sleepStrict(ctx, startTime, 1230)
dur := time.Since(startTime).Milliseconds() dur := time.Since(startTime).Milliseconds()
t.Log(dur) t.Log(dur)
if dur < 1230 || dur > 1300 { if dur < 1230 || dur > 1300 {
+62
View File
@@ -226,6 +226,68 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
if tempOptions.CVService != "" { if tempOptions.CVService != "" {
arguments["cv_service"] = 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 { func getFloat64ValueOrDefault(value float64, defaultValue float64) float64 {
+23 -4
View File
@@ -68,7 +68,15 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
return nil, fmt.Errorf("unsupported sleep duration type: %T", v) 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) message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
returnData := ToolSleep{ returnData := ToolSleep{
@@ -120,7 +128,18 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
// Sleep MS action logic // Sleep MS action logic
log.Info().Int64("milliseconds", unifiedReq.Milliseconds).Msg("sleeping in milliseconds") 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) message := fmt.Sprintf("Successfully slept for %d milliseconds", unifiedReq.Milliseconds)
returnData := ToolSleepMS{Milliseconds: unifiedReq.Milliseconds} returnData := ToolSleepMS{Milliseconds: unifiedReq.Milliseconds}
@@ -170,8 +189,8 @@ func (t *ToolSleepRandom) Implement() server.ToolHandlerFunc {
return nil, err return nil, err
} }
// Sleep random action logic // Sleep random action logic with context support
sleepStrict(time.Now(), getSimulationDuration(unifiedReq.Params)) sleepStrict(ctx, time.Now(), getSimulationDuration(unifiedReq.Params))
message := fmt.Sprintf("Successfully slept for random duration with params: %v", unifiedReq.Params) message := fmt.Sprintf("Successfully slept for random duration with params: %v", unifiedReq.Params)
returnData := ToolSleepRandom{Params: unifiedReq.Params} returnData := ToolSleepRandom{Params: unifiedReq.Params}
-3
View File
@@ -440,9 +440,6 @@ func NewActionOptions(opts ...ActionOption) *ActionOptions {
for _, option := range opts { for _, option := range opts {
option(actionOptions) option(actionOptions)
} }
if actionOptions.MaxRetryTimes == 0 {
actionOptions.MaxRetryTimes = 1
}
return actionOptions return actionOptions
} }
+9 -22
View File
@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/httprunner/httprunner/v5/uixt/ai" "github.com/httprunner/httprunner/v5/uixt/ai"
"github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/option"
@@ -113,34 +112,23 @@ func (c *MCPClient4XTDriver) GetToolByAction(actionName option.ActionName) Actio
return c.Server.GetToolByAction(actionName) return c.Server.GetToolByAction(actionName)
} }
func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) ([]*SubActionResult, error) { func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) (SessionData, error) {
subActionStartTime := time.Now()
// Find the corresponding tool for this action method // Find the corresponding tool for this action method
tool := dExt.client.Server.GetToolByAction(action.Method) tool := dExt.client.Server.GetToolByAction(action.Method)
if tool == nil { 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 // Use the tool's own conversion method
req, err := tool.ConvertActionToCallToolRequest(action) req, err := tool.ConvertActionToCallToolRequest(action)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to convert action to MCP tool call: %w", err) return SessionData{}, 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(),
} }
// Execute via MCP tool // Execute via MCP tool
result, err := dExt.client.CallTool(ctx, req) result, err := dExt.client.CallTool(ctx, req)
subActionResult.Elapsed = time.Since(subActionStartTime).Milliseconds()
if err != nil { if err != nil {
subActionResult.Error = err return SessionData{}, fmt.Errorf("MCP tool call failed: %w", err)
return []*SubActionResult{subActionResult}, fmt.Errorf("MCP tool call failed: %w", err)
} }
// Check if the tool execution had business logic errors // 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()) errMsg = fmt.Sprintf("invoke tool %s failed", tool.Name())
} }
err := errors.New(errMsg) err := errors.New(errMsg)
subActionResult.Error = err return SessionData{}, err
return []*SubActionResult{subActionResult}, err
} }
// For regular actions, collect session data and return single sub-action result // For regular actions, collect session data and return it directly
subActionResult.SessionData = dExt.GetSession().GetData(true) // reset after getting data sessionData := dExt.GetSession().GetData(true) // reset after getting data
log.Debug().Str("tool", string(tool.Name())). log.Debug().Str("tool", string(tool.Name())).
Msg("execute action via MCP tool") 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 // NewDeviceWithDefault is a helper function to create a device with default options