mirror of
https://github.com/httprunner/httprunner.git
synced 2026-09-06 16:07:45 +08:00
Merge branch 'merge-wings' into 'master'
Merge wings See merge request iesqa/httprunner!102
This commit is contained in:
@@ -1 +1 @@
|
|||||||
v5.0.0-beta-2506191048
|
v5.0.0-beta-2506211542
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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 服务类型
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user