From 9e589dec1697339ff1aca9bf110c160f722efb10 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Thu, 19 Jun 2025 14:46:56 +0800 Subject: [PATCH 1/6] feat: add initialization of nil fields in summary data to prevent template execution errors --- internal/version/VERSION | 2 +- report.go | 35 +++++++++++++++++++++++++++++++++++ runner_uixt.go | 7 +++---- uixt/android_device.go | 3 +++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 9b90e21e..07532b93 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506191048 +v5.0.0-beta-2506191446 diff --git a/report.go b/report.go index 5e803bcd..22b4d465 100644 --- a/report.go +++ b/report.go @@ -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) { diff --git a/runner_uixt.go b/runner_uixt.go index 87c934ae..36e6078a 100644 --- a/runner_uixt.go +++ b/runner_uixt.go @@ -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 ( diff --git a/uixt/android_device.go b/uixt/android_device.go index 52e2107d..e7cc774c 100644 --- a/uixt/android_device.go +++ b/uixt/android_device.go @@ -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") From ed5d3127cbb986d243d07e0444cfba3397c5bd34 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Thu, 19 Jun 2025 21:51:38 +0800 Subject: [PATCH 2/6] fix: add missing action options --- internal/version/VERSION | 2 +- uixt/ai/cv_vedem.go | 1 + uixt/driver_ext_ai.go | 2 +- uixt/driver_ext_screenshot.go | 4 ++- uixt/driver_ext_tap.go | 3 +- uixt/mcp_server.go | 62 +++++++++++++++++++++++++++++++++++ uixt/option/action.go | 3 -- uixt/sdk.go | 2 +- 8 files changed, 71 insertions(+), 8 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 07532b93..dddcc207 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506191446 +v5.0.0-beta-2506192157 diff --git a/uixt/ai/cv_vedem.go b/uixt/ai/cv_vedem.go index 532ea301..2cb4a833 100644 --- a/uixt/ai/cv_vedem.go +++ b/uixt/ai/cv_vedem.go @@ -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 diff --git a/uixt/driver_ext_ai.go b/uixt/driver_ext_ai.go index 72dc58f9..9c5c3e36 100644 --- a/uixt/driver_ext_ai.go +++ b/uixt/driver_ext_ai.go @@ -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") } } diff --git a/uixt/driver_ext_screenshot.go b/uixt/driver_ext_screenshot.go index ae3e4238..53ee9fa6 100644 --- a/uixt/driver_ext_screenshot.go +++ b/uixt/driver_ext_screenshot.go @@ -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) + } } } diff --git a/uixt/driver_ext_tap.go b/uixt/driver_ext_tap.go index dcb08753..0b36afcd 100644 --- a/uixt/driver_ext_tap.go +++ b/uixt/driver_ext_tap.go @@ -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 { diff --git a/uixt/mcp_server.go b/uixt/mcp_server.go index 72221448..dd30ec4b 100644 --- a/uixt/mcp_server.go +++ b/uixt/mcp_server.go @@ -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 { diff --git a/uixt/option/action.go b/uixt/option/action.go index 5007f61c..54107dad 100644 --- a/uixt/option/action.go +++ b/uixt/option/action.go @@ -440,9 +440,6 @@ func NewActionOptions(opts ...ActionOption) *ActionOptions { for _, option := range opts { option(actionOptions) } - if actionOptions.MaxRetryTimes == 0 { - actionOptions.MaxRetryTimes = 1 - } return actionOptions } diff --git a/uixt/sdk.go b/uixt/sdk.go index cdcbf65b..2a0752ed 100644 --- a/uixt/sdk.go +++ b/uixt/sdk.go @@ -160,7 +160,7 @@ func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAct subActionResult.SessionData = dExt.GetSession().GetData(true) // reset after getting data log.Debug().Str("tool", string(tool.Name())). - Msg("execute action via MCP tool") + Msg("executed action via MCP tool") return []*SubActionResult{subActionResult}, nil } From 0c9dac95a18b27cc16e4c89f0ef340e63a29b0f1 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 20 Jun 2025 17:38:36 +0800 Subject: [PATCH 3/6] feat: enhance report generation by integrating session data and improving AI query display --- internal/version/VERSION | 2 +- report.go | 232 ++++++++++++++++++++++++++------------- step.go | 10 +- step_ui.go | 15 ++- uixt/driver_ext_ai.go | 2 +- uixt/sdk.go | 29 ++--- 6 files changed, 179 insertions(+), 111 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index dddcc207..1ac4cd9e 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506192157 +v5.0.0-beta-2506201738 diff --git a/report.go b/report.go index 22b4d465..a1a0aed6 100644 --- a/report.go +++ b/report.go @@ -369,10 +369,6 @@ func (g *HTMLReportGenerator) calculateTotalActions() int { func (g *HTMLReportGenerator) calculateTotalSubActions() int { return g.iterateTestData(func(action *ActionResult) int { total := 0 - // Count sub-actions from regular actions - if action.SubActions != nil { - total += len(action.SubActions) - } // Count sub-actions from planning results if action.Plannings != nil { for _, planning := range action.Plannings { @@ -380,6 +376,8 @@ func (g *HTMLReportGenerator) calculateTotalSubActions() int { total += len(planning.SubActions) } } + } else { + total += 1 } return total }) @@ -1208,6 +1206,8 @@ const htmlTemplate = ` display: block; } + + .request-item-compact { background: #ffffff; border: 1px solid #e9ecef; @@ -2085,6 +2085,28 @@ const htmlTemplate = ` 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; + } + @@ -2342,97 +2364,153 @@ const htmlTemplate = ` - {{/* SubActions are now displayed in the right panel, so we don't show them here */}} + {{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"}} -
- -
- - {{$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"}} +
+ +
+ + {{$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}} - - {{if $queryResult}} -
{{$queryResult}}
- {{end}} + + {{if $queryResult}} +
{{$queryResult}}
+ {{end}} - -
- - {{if $queryScreenshot}} -
-
-
- 📸 Query Screenshot -
-
- {{$base64Image := encodeImageBase64 $queryScreenshot}} - {{if $base64Image}} -
-
- Query Screenshot -
-
- {{end}} -
-
+ +
+ + {{if $queryScreenshot}} +
+
+
+ 📸 Query Screenshot
- {{end}} - - -
-
-
- 🤖 AI Query -
-
- {{if $queryModel}} -
🤖 Model: {{$queryModel}}
- {{end}} - {{if $queryUsage}} -
{{$queryUsage}}
- {{end}} +
+ {{$base64Image := encodeImageBase64 $queryScreenshot}} + {{if $base64Image}} +
+
+ Query Screenshot
+ {{end}} +
+
+
+ {{end}} + + +
+
+
+ 🤖 AI Query +
+
+ {{if $queryModel}} +
🤖 Model: {{$queryModel}}
+ {{end}} + {{if $queryUsage}} +
{{$queryUsage}}
+ {{end}}
+
+
+ {{end}} + + {{/* Handle SessionData: display requests and screen results for non-planning actions */}} + {{if not $action.Plannings}} + {{if or $action.Requests $action.ScreenResults}} +
+ + {{if $action.Requests}} +
+ +
+ {{range $request := $action.Requests}} +
+
+ {{$request.RequestMethod}} + {{$request.RequestUrl}} + {{$request.ResponseStatus}} + {{formatDuration $request.ResponseDuration}} +
+ {{if $request.RequestBody}} +
Request: {{$request.RequestBody}}
+ {{end}} + {{if $request.ResponseBody}} +
Response: {{$request.ResponseBody}}
+ {{end}} +
+ {{end}} +
+
{{end}} + + + {{if $action.ScreenResults}} +
+
📸 Screen Results ({{len $action.ScreenResults}})
+
+ {{range $screenshot := $action.ScreenResults}} + {{if $screenshot.ImagePath}} + {{$base64Image := encodeImageBase64 $screenshot.ImagePath}} + {{if $base64Image}} +
+
+ {{base $screenshot.ImagePath}} + {{if $screenshot.Resolution}} + {{$screenshot.Resolution.Width}}x{{$screenshot.Resolution.Height}} + {{end}} +
+
+ Screenshot +
+
+ {{end}} + {{end}} + {{end}} +
+
+ {{end}} +
{{end}} {{end}} - {{/* Other SubActions (non-ai_query) are displayed in the Planning section's right panel to avoid duplication */}}
{{end}} diff --git a/step.go b/step.go index 143abe24..7f66708c 100644 --- a/step.go +++ b/step.go @@ -58,11 +58,11 @@ type TStep struct { // one step contains one or multiple actions type ActionResult struct { option.MobileAction `json:",inline"` - StartTime int64 `json:"start_time"` // action start time in millisecond(ms) - Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms) - Error error `json:"error"` // action execution result - Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions - SubActions []*uixt.SubActionResult `json:"sub_actions,omitempty"` // store sub-actions for other actions + StartTime int64 `json:"start_time"` // action start time in millisecond(ms) + Elapsed int64 `json:"elapsed_ms"` // action elapsed time(ms) + Error error `json:"error,omitempty"` // action execution result + Plannings []*uixt.PlanningExecutionResult `json:"plannings,omitempty"` // store planning results for start_to_goal actions, which contains multiple sub-actions + uixt.SessionData // store session data for other actions besides start_to_goal } // one testcase contains one or multiple steps diff --git a/step_ui.go b/step_ui.go index da922435..a1596eed 100644 --- a/step_ui.go +++ b/step_ui.go @@ -783,13 +783,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err }, StartTime: startTime.UnixMilli(), } - subActionResults, err1 := uiDriver.ExecuteAction( + sessionData, err1 := uiDriver.ExecuteAction( context.Background(), actionResult.MobileAction) if err1 != nil { + actionResult.Error = err1 log.Warn().Err(err1).Msg("get foreground app failed, ignore") } actionResult.Elapsed = time.Since(startTime).Milliseconds() - actionResult.SubActions = subActionResults + actionResult.SessionData = sessionData stepResult.Actions = append(stepResult.Actions, actionResult) } @@ -827,13 +828,14 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err }, StartTime: startTime.UnixMilli(), } - subActionResults, err2 := uiDriver.ExecuteAction( + sessionData, err2 := uiDriver.ExecuteAction( context.Background(), actionResult.MobileAction) if err2 != nil { + actionResult.Error = err2 log.Warn().Err(err2).Str("step", step.Name()).Msg("auto handle popup failed") } actionResult.Elapsed = time.Since(startTime).Milliseconds() - actionResult.SubActions = subActionResults + actionResult.SessionData = sessionData stepResult.Actions = append(stepResult.Actions, actionResult) } @@ -950,9 +952,10 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err } // handle other actions - subActionResults, err := uiDriver.ExecuteAction(ctx, action) + sessionData, err := uiDriver.ExecuteAction(ctx, action) + actionResult.Error = err actionResult.Elapsed = time.Since(actionStartTime).Milliseconds() - actionResult.SubActions = subActionResults + actionResult.SessionData = sessionData stepResult.Actions = append(stepResult.Actions, actionResult) if err != nil { if !code.IsErrorPredefined(err) { diff --git a/uixt/driver_ext_ai.go b/uixt/driver_ext_ai.go index 9c5c3e36..773e4c1c 100644 --- a/uixt/driver_ext_ai.go +++ b/uixt/driver_ext_ai.go @@ -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") } } diff --git a/uixt/sdk.go b/uixt/sdk.go index 2a0752ed..5b7bf67f 100644 --- a/uixt/sdk.go +++ b/uixt/sdk.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "strings" - "time" "github.com/httprunner/httprunner/v5/uixt/ai" "github.com/httprunner/httprunner/v5/uixt/option" @@ -113,34 +112,23 @@ func (c *MCPClient4XTDriver) GetToolByAction(actionName option.ActionName) Actio return c.Server.GetToolByAction(actionName) } -func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) ([]*SubActionResult, error) { - subActionStartTime := time.Now() - +func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAction) (SessionData, error) { // Find the corresponding tool for this action method tool := dExt.client.Server.GetToolByAction(action.Method) if tool == nil { - return nil, fmt.Errorf("no tool found for action method: %s", action.Method) + return SessionData{}, fmt.Errorf("no tool found for action method: %s", action.Method) } // Use the tool's own conversion method req, err := tool.ConvertActionToCallToolRequest(action) if err != nil { - return nil, fmt.Errorf("failed to convert action to MCP tool call: %w", err) - } - - // Create sub-action result - subActionResult := &SubActionResult{ - ActionName: string(action.Method), - Arguments: action.Params, - StartTime: subActionStartTime.UnixMilli(), + return SessionData{}, fmt.Errorf("failed to convert action to MCP tool call: %w", err) } // Execute via MCP tool result, err := dExt.client.CallTool(ctx, req) - subActionResult.Elapsed = time.Since(subActionStartTime).Milliseconds() if err != nil { - subActionResult.Error = err - return []*SubActionResult{subActionResult}, fmt.Errorf("MCP tool call failed: %w", err) + return SessionData{}, fmt.Errorf("MCP tool call failed: %w", err) } // Check if the tool execution had business logic errors @@ -152,16 +140,15 @@ func (dExt *XTDriver) ExecuteAction(ctx context.Context, action option.MobileAct errMsg = fmt.Sprintf("invoke tool %s failed", tool.Name()) } err := errors.New(errMsg) - subActionResult.Error = err - return []*SubActionResult{subActionResult}, err + return SessionData{}, err } - // For regular actions, collect session data and return single sub-action result - subActionResult.SessionData = dExt.GetSession().GetData(true) // reset after getting data + // For regular actions, collect session data and return it directly + sessionData := dExt.GetSession().GetData(true) // reset after getting data log.Debug().Str("tool", string(tool.Name())). Msg("executed action via MCP tool") - return []*SubActionResult{subActionResult}, nil + return sessionData, nil } // NewDeviceWithDefault is a helper function to create a device with default options From d2031cb0f24e3110e2c990770fc24c84f0916687 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 20 Jun 2025 19:12:27 +0800 Subject: [PATCH 4/6] refactor: add context support to sleep functions for improved cancellation handling --- internal/version/VERSION | 2 +- uixt/driver_utils.go | 17 +++++++++++++++-- uixt/driver_utils_test.go | 4 +++- uixt/mcp_tools_utility.go | 27 +++++++++++++++++++++++---- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 1ac4cd9e..2ad58c7b 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506201738 +v5.0.0-beta-2506201912 diff --git a/uixt/driver_utils.go b/uixt/driver_utils.go index 1818d746..787f6f9b 100644 --- a/uixt/driver_utils.go +++ b/uixt/driver_utils.go @@ -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 diff --git a/uixt/driver_utils_test.go b/uixt/driver_utils_test.go index 3a6311fa..ee81644c 100644 --- a/uixt/driver_utils_test.go +++ b/uixt/driver_utils_test.go @@ -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 { diff --git a/uixt/mcp_tools_utility.go b/uixt/mcp_tools_utility.go index 40699295..f7877394 100644 --- a/uixt/mcp_tools_utility.go +++ b/uixt/mcp_tools_utility.go @@ -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} From f4b60f4d86814b18afeebdeb6173dbd09fe1e460 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 20 Jun 2025 19:36:01 +0800 Subject: [PATCH 5/6] fix: update error handling in runStepMobileUI to store error messages as strings --- internal/version/VERSION | 2 +- step.go | 2 +- step_ui.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 2ad58c7b..6a80a320 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506201912 +v5.0.0-beta-2506201936 diff --git a/step.go b/step.go index 7f66708c..3b2c866f 100644 --- a/step.go +++ b/step.go @@ -60,7 +60,7 @@ 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,omitempty"` // 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, which contains multiple sub-actions uixt.SessionData // store session data for other actions besides start_to_goal } diff --git a/step_ui.go b/step_ui.go index a1596eed..426621cb 100644 --- a/step_ui.go +++ b/step_ui.go @@ -786,7 +786,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err sessionData, err1 := uiDriver.ExecuteAction( context.Background(), actionResult.MobileAction) if err1 != nil { - actionResult.Error = err1 + actionResult.Error = err1.Error() log.Warn().Err(err1).Msg("get foreground app failed, ignore") } actionResult.Elapsed = time.Since(startTime).Milliseconds() @@ -831,7 +831,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err sessionData, err2 := uiDriver.ExecuteAction( context.Background(), actionResult.MobileAction) if err2 != nil { - actionResult.Error = err2 + actionResult.Error = err2.Error() log.Warn().Err(err2).Str("step", step.Name()).Msg("auto handle popup failed") } actionResult.Elapsed = time.Since(startTime).Milliseconds() @@ -953,11 +953,11 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err // handle other actions sessionData, err := uiDriver.ExecuteAction(ctx, action) - actionResult.Error = err actionResult.Elapsed = time.Since(actionStartTime).Milliseconds() 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()) } From c802327e394e7ac5782a4e2de0d949c0fd90e232 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Sat, 21 Jun 2025 15:42:04 +0800 Subject: [PATCH 6/6] change: format --- internal/version/VERSION | 2 +- report.go | 25 ++----------------------- 2 files changed, 3 insertions(+), 24 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 6a80a320..392a87ad 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2506201936 +v5.0.0-beta-2506211542 diff --git a/report.go b/report.go index a1a0aed6..eb27c714 100644 --- a/report.go +++ b/report.go @@ -369,7 +369,7 @@ func (g *HTMLReportGenerator) calculateTotalActions() int { func (g *HTMLReportGenerator) calculateTotalSubActions() int { return g.iterateTestData(func(action *ActionResult) int { total := 0 - // 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 { @@ -377,6 +377,7 @@ func (g *HTMLReportGenerator) calculateTotalSubActions() int { } } } else { + // Count other actions total += 1 } return total @@ -965,10 +966,6 @@ const htmlTemplate = ` line-height: 1.4; } - - - - .action-content { display: block; } @@ -1017,8 +1014,6 @@ const htmlTemplate = ` font-weight: bold; } - - .planning-three-columns { display: flex; gap: 20px; @@ -1206,8 +1201,6 @@ const htmlTemplate = ` display: block; } - - .request-item-compact { background: #ffffff; border: 1px solid #e9ecef; @@ -1319,8 +1312,6 @@ const htmlTemplate = ` } } - - .action-details { display: flex; align-items: center; @@ -1366,8 +1357,6 @@ const htmlTemplate = ` line-height: 1; } - - .arguments { background: #f8f9fa; border: 1px solid #dee2e6; @@ -1378,8 +1367,6 @@ const htmlTemplate = ` font-size: 0.9em; } - - .screenshots-section { background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); border: 2px solid #28a745; @@ -1973,8 +1960,6 @@ const htmlTemplate = ` gap: 8px; } - - .logs-header { flex-direction: column; align-items: flex-start; @@ -1991,8 +1976,6 @@ const htmlTemplate = ` gap: 6px; } - - .screenshots-grid { grid-template-columns: 1fr; gap: 10px; @@ -2106,8 +2089,6 @@ const htmlTemplate = ` margin-top: 15px; margin-bottom: 15px; } - - @@ -2201,8 +2182,6 @@ const htmlTemplate = `
- -
{{range $caseIndex, $testCase := .Details}}