refactor: complete ActionOptions unification and pointer type optimization

This commit is contained in:
lilong.129
2025-05-27 13:34:12 +08:00
parent 7fb966b7ba
commit 404865ba6b
17 changed files with 838 additions and 1188 deletions

View File

@@ -7,7 +7,7 @@
"android": [
{
"serial": "$device",
"log_on": true,
"log_on": false,
"adb_server_host": "localhost",
"adb_server_port": 5037,
"uia2_ip": "localhost",

View File

@@ -1 +1 @@
v5.0.0-beta-2505271149
v5.0.0-beta-2505271334

View File

@@ -22,7 +22,7 @@ func (r *Router) foregroundAppHandler(c *gin.Context) {
}
func (r *Router) appInfoHandler(c *gin.Context) {
var req option.UnifiedActionRequest
var req option.ActionOptions
if err := c.ShouldBindQuery(&req); err != nil {
RenderErrorValidateRequest(c, err)
return

View File

@@ -39,7 +39,7 @@ func (r *Router) backspaceHandler(c *gin.Context) {
return
}
count := req.GetCount()
count := req.Count
if count == 0 {
count = 20
}
@@ -67,7 +67,7 @@ func (r *Router) keycodeHandler(c *gin.Context) {
}
// TODO FIXME
err = driver.IDriver.(*uixt.ADBDriver).
PressKeyCode(uixt.KeyCode(req.GetKeycode()), uixt.KMEmpty)
PressKeyCode(uixt.KeyCode(req.Keycode), uixt.KMEmpty)
if err != nil {
RenderError(c, err)
return

View File

@@ -7,8 +7,8 @@ import (
)
// processUnifiedRequest is a helper function to handle common request processing
func (r *Router) processUnifiedRequest(c *gin.Context, actionType option.ActionMethod) (*option.UnifiedActionRequest, error) {
var req option.UnifiedActionRequest
func (r *Router) processUnifiedRequest(c *gin.Context, actionType option.ActionName) (*option.ActionOptions, error) {
var req option.ActionOptions
// Bind JSON request
if err := c.ShouldBindJSON(&req); err != nil {
@@ -29,7 +29,7 @@ func (r *Router) processUnifiedRequest(c *gin.Context, actionType option.ActionM
}
// setRequestContextFromURL sets platform and serial from URL parameters
func setRequestContextFromURL(c *gin.Context, req *option.UnifiedActionRequest) {
func setRequestContextFromURL(c *gin.Context, req *option.ActionOptions) {
if req.Platform == "" {
req.Platform = c.Param("platform")
}
@@ -49,12 +49,11 @@ func (r *Router) tapHandler(c *gin.Context) {
return
}
// Use UnifiedActionRequest directly
if req.GetDuration() > 0 {
err = driver.Drag(req.GetX(), req.GetY(), req.GetX(), req.GetY(),
option.WithDuration(req.GetDuration()))
if req.Duration > 0 {
err = driver.Drag(req.X, req.Y, req.X, req.Y,
option.WithDuration(req.Duration))
} else {
err = driver.TapXY(req.GetX(), req.GetY())
err = driver.TapXY(req.X, req.Y)
}
if err != nil {
RenderError(c, err)
@@ -74,7 +73,7 @@ func (r *Router) rightClickHandler(c *gin.Context) {
return
}
err = driver.IDriver.(*uixt.BrowserDriver).
SecondaryClick(req.GetX(), req.GetY())
SecondaryClick(req.X, req.Y)
if err != nil {
RenderError(c, err)
return
@@ -117,7 +116,7 @@ func (r *Router) hoverHandler(c *gin.Context) {
}
err = driver.IDriver.(*uixt.BrowserDriver).
Hover(req.GetX(), req.GetY())
Hover(req.X, req.Y)
if err != nil {
RenderError(c, err)
@@ -139,7 +138,7 @@ func (r *Router) scrollHandler(c *gin.Context) {
}
err = driver.IDriver.(*uixt.BrowserDriver).
Scroll(req.GetDelta())
Scroll(req.Delta)
if err != nil {
RenderError(c, err)
@@ -159,7 +158,7 @@ func (r *Router) doubleTapHandler(c *gin.Context) {
return
}
err = driver.DoubleTap(req.GetX(), req.GetY())
err = driver.DoubleTap(req.X, req.Y)
if err != nil {
RenderError(c, err)
return
@@ -173,7 +172,7 @@ func (r *Router) dragHandler(c *gin.Context) {
return
}
duration := req.GetDuration()
duration := req.Duration
if duration == 0 {
duration = 1
}
@@ -182,9 +181,9 @@ func (r *Router) dragHandler(c *gin.Context) {
return
}
err = driver.Drag(req.GetFromX(), req.GetFromY(), req.GetToX(), req.GetToY(),
err = driver.Drag(req.FromX, req.FromY, req.ToX, req.ToY,
option.WithDuration(duration),
option.WithPressDuration(req.GetPressDuration()))
option.WithPressDuration(req.PressDuration))
if err != nil {
RenderError(c, err)
return
@@ -202,7 +201,7 @@ func (r *Router) inputHandler(c *gin.Context) {
if err != nil {
return
}
err = driver.Input(req.Text, option.WithFrequency(req.GetFrequency()))
err = driver.Input(req.Text, option.WithFrequency(req.Frequency))
if err != nil {
RenderError(c, err)
return

View File

@@ -18,17 +18,17 @@ func TestTapHandler(t *testing.T) {
tests := []struct {
name string
path string
req option.UnifiedActionRequest
req option.ActionOptions
wantStatus int
wantResp HttpResponse
}{
{
name: "tap abs xy",
path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"),
req: option.UnifiedActionRequest{
X: &[]float64{500}[0],
Y: &[]float64{800}[0],
Duration: &[]float64{0}[0],
req: option.ActionOptions{
X: 500.0,
Y: 800.0,
Duration: 0,
},
wantStatus: http.StatusOK,
wantResp: HttpResponse{
@@ -40,10 +40,10 @@ func TestTapHandler(t *testing.T) {
{
name: "tap relative xy",
path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"),
req: option.UnifiedActionRequest{
X: &[]float64{0.5}[0],
Y: &[]float64{0.6}[0],
Duration: &[]float64{0}[0],
req: option.ActionOptions{
X: 0.5,
Y: 0.6,
Duration: 0,
},
wantStatus: http.StatusOK,
wantResp: HttpResponse{

View File

@@ -67,7 +67,7 @@ func (s *StepMobile) Serial(serial string) *StepMobile {
return s
}
func (s *StepMobile) Log(actionName option.ActionMethod) *StepMobile {
func (s *StepMobile) Log(actionName option.ActionName) *StepMobile {
s.obj().Actions = append(s.obj().Actions, uixt.MobileAction{
Method: option.ACTION_LOG,
Params: actionName,
@@ -798,7 +798,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
// stat uixt action
if action.Method == option.ACTION_LOG {
log.Info().Interface("action", action.Params).Msg("stat uixt action")
actionMethod := option.ActionMethod(action.Params.(string))
actionMethod := option.ActionName(action.Params.(string))
s.summary.Stat.Actions[actionMethod]++
continue
}

View File

@@ -28,7 +28,7 @@ func NewSummary() *Summary {
Success: true,
Stat: &Stat{
TestSteps: TestStepStat{
Actions: make(map[option.ActionMethod]int),
Actions: make(map[option.ActionName]int),
},
},
Time: &TestCaseTime{
@@ -146,10 +146,10 @@ type TestCaseStat struct {
}
type TestStepStat struct {
Total int `json:"total" yaml:"total"`
Successes int `json:"successes" yaml:"successes"`
Failures int `json:"failures" yaml:"failures"`
Actions map[option.ActionMethod]int `json:"actions" yaml:"actions"` // record action stats
Total int `json:"total" yaml:"total"`
Successes int `json:"successes" yaml:"successes"`
Failures int `json:"failures" yaml:"failures"`
Actions map[option.ActionName]int `json:"actions" yaml:"actions"` // record action stats
}
type TestCaseTime struct {
@@ -167,7 +167,7 @@ func NewCaseSummary() *TestCaseSummary {
return &TestCaseSummary{
Success: true,
Stat: &TestStepStat{
Actions: make(map[option.ActionMethod]int),
Actions: make(map[option.ActionName]int),
},
Time: &TestCaseTime{
StartAt: time.Now(),

View File

@@ -5,7 +5,7 @@ import (
)
type MobileAction struct {
Method option.ActionMethod `json:"method,omitempty" yaml:"method,omitempty"`
Method option.ActionName `json:"method,omitempty" yaml:"method,omitempty"`
Params interface{} `json:"params,omitempty" yaml:"params,omitempty"`
Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"`
option.ActionOptions

View File

@@ -322,7 +322,7 @@ func compressImageBuffer(raw *bytes.Buffer) (compressed *bytes.Buffer, err error
}
// MarkUIOperation add operation mark for UI operation
func MarkUIOperation(driver IDriver, actionType option.ActionMethod, actionCoordinates []float64) error {
func MarkUIOperation(driver IDriver, actionType option.ActionName, actionCoordinates []float64) error {
if actionType == "" || len(actionCoordinates) == 0 {
return nil
}

View File

@@ -98,7 +98,7 @@ func preHandler_Drag(driver IDriver, options *option.ActionOptions, rawFomX, raw
return fromX, fromY, toX, toY, nil
}
func preHandler_Swipe(driver IDriver, actionType option.ActionMethod,
func preHandler_Swipe(driver IDriver, actionType option.ActionName,
options *option.ActionOptions, rawFomX, rawFromY, rawToX, rawToY float64) (
fromX, fromY, toX, toY float64, err error) {
@@ -118,7 +118,7 @@ func preHandler_Swipe(driver IDriver, actionType option.ActionMethod,
return fromX, fromY, toX, toY, nil
}
func postHandler(driver IDriver, actionType option.ActionMethod, options *option.ActionOptions) error {
func postHandler(driver IDriver, actionType option.ActionName, options *option.ActionOptions) error {
// save screenshot after action
if options.PostMarkOperation {
// get compressed screenshot buffer

File diff suppressed because it is too large Load Diff

View File

@@ -2,82 +2,87 @@ package option
import (
"context"
"fmt"
"math/rand/v2"
"reflect"
"strings"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/mark3labs/mcp-go/mcp"
"github.com/rs/zerolog/log"
)
type ActionMethod string
type ActionName string
const (
ACTION_LOG ActionMethod = "log"
ACTION_ListPackages ActionMethod = "list_packages"
ACTION_AppInstall ActionMethod = "app_install"
ACTION_AppUninstall ActionMethod = "app_uninstall"
ACTION_WebLoginNoneUI ActionMethod = "web_login_none_ui"
ACTION_AppClear ActionMethod = "app_clear"
ACTION_AppStart ActionMethod = "app_start"
ACTION_AppLaunch ActionMethod = "app_launch" // 启动 app 并堵塞等待 app 首屏加载完成
ACTION_AppTerminate ActionMethod = "app_terminate"
ACTION_AppStop ActionMethod = "app_stop"
ACTION_ScreenShot ActionMethod = "screenshot"
ACTION_GetScreenSize ActionMethod = "get_screen_size"
ACTION_Sleep ActionMethod = "sleep"
ACTION_SleepMS ActionMethod = "sleep_ms"
ACTION_SleepRandom ActionMethod = "sleep_random"
ACTION_SetIme ActionMethod = "set_ime"
ACTION_GetSource ActionMethod = "get_source"
ACTION_GetForegroundApp ActionMethod = "get_foreground_app"
ACTION_LOG ActionName = "log"
ACTION_ListPackages ActionName = "list_packages"
ACTION_AppInstall ActionName = "app_install"
ACTION_AppUninstall ActionName = "app_uninstall"
ACTION_WebLoginNoneUI ActionName = "web_login_none_ui"
ACTION_AppClear ActionName = "app_clear"
ACTION_AppStart ActionName = "app_start"
ACTION_AppLaunch ActionName = "app_launch" // 启动 app 并堵塞等待 app 首屏加载完成
ACTION_AppTerminate ActionName = "app_terminate"
ACTION_AppStop ActionName = "app_stop"
ACTION_ScreenShot ActionName = "screenshot"
ACTION_GetScreenSize ActionName = "get_screen_size"
ACTION_Sleep ActionName = "sleep"
ACTION_SleepMS ActionName = "sleep_ms"
ACTION_SleepRandom ActionName = "sleep_random"
ACTION_SetIme ActionName = "set_ime"
ACTION_GetSource ActionName = "get_source"
ACTION_GetForegroundApp ActionName = "get_foreground_app"
// UI handling
ACTION_Home ActionMethod = "home"
ACTION_Tap ActionMethod = "tap" // generic tap action
ACTION_TapXY ActionMethod = "tap_xy"
ACTION_TapAbsXY ActionMethod = "tap_abs_xy"
ACTION_TapByOCR ActionMethod = "tap_ocr"
ACTION_TapByCV ActionMethod = "tap_cv"
ACTION_DoubleTap ActionMethod = "double_tap" // generic double tap action
ACTION_DoubleTapXY ActionMethod = "double_tap_xy"
ACTION_Swipe ActionMethod = "swipe" // swipe by direction or coordinates
ACTION_SwipeDirection ActionMethod = "swipe_direction" // swipe by direction (up, down, left, right)
ACTION_SwipeCoordinate ActionMethod = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY)
ACTION_Drag ActionMethod = "drag"
ACTION_Input ActionMethod = "input"
ACTION_PressButton ActionMethod = "press_button"
ACTION_Back ActionMethod = "back"
ACTION_KeyCode ActionMethod = "keycode"
ACTION_Delete ActionMethod = "delete" // delete action
ACTION_Backspace ActionMethod = "backspace" // backspace action
ACTION_AIAction ActionMethod = "ai_action" // action with ai
ACTION_TapBySelector ActionMethod = "tap_by_selector"
ACTION_HoverBySelector ActionMethod = "hover_by_selector"
ACTION_Hover ActionMethod = "hover" // generic hover action
ACTION_RightClick ActionMethod = "right_click" // right click action
ACTION_WebCloseTab ActionMethod = "web_close_tab"
ACTION_SecondaryClick ActionMethod = "secondary_click"
ACTION_SecondaryClickBySelector ActionMethod = "secondary_click_by_selector"
ACTION_GetElementTextBySelector ActionMethod = "get_element_text_by_selector"
ACTION_Scroll ActionMethod = "scroll" // scroll action
ACTION_Upload ActionMethod = "upload" // upload action
ACTION_PushMedia ActionMethod = "push_media" // push media action
ACTION_CreateBrowser ActionMethod = "create_browser" // create browser action
ACTION_AppInfo ActionMethod = "app_info" // get app info action
ACTION_Home ActionName = "home"
ACTION_Tap ActionName = "tap" // generic tap action
ACTION_TapXY ActionName = "tap_xy"
ACTION_TapAbsXY ActionName = "tap_abs_xy"
ACTION_TapByOCR ActionName = "tap_ocr"
ACTION_TapByCV ActionName = "tap_cv"
ACTION_DoubleTap ActionName = "double_tap" // generic double tap action
ACTION_DoubleTapXY ActionName = "double_tap_xy"
ACTION_Swipe ActionName = "swipe" // swipe by direction or coordinates
ACTION_SwipeDirection ActionName = "swipe_direction" // swipe by direction (up, down, left, right)
ACTION_SwipeCoordinate ActionName = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY)
ACTION_Drag ActionName = "drag"
ACTION_Input ActionName = "input"
ACTION_PressButton ActionName = "press_button"
ACTION_Back ActionName = "back"
ACTION_KeyCode ActionName = "keycode"
ACTION_Delete ActionName = "delete" // delete action
ACTION_Backspace ActionName = "backspace" // backspace action
ACTION_AIAction ActionName = "ai_action" // action with ai
ACTION_TapBySelector ActionName = "tap_by_selector"
ACTION_HoverBySelector ActionName = "hover_by_selector"
ACTION_Hover ActionName = "hover" // generic hover action
ACTION_RightClick ActionName = "right_click" // right click action
ACTION_WebCloseTab ActionName = "web_close_tab"
ACTION_SecondaryClick ActionName = "secondary_click"
ACTION_SecondaryClickBySelector ActionName = "secondary_click_by_selector"
ACTION_GetElementTextBySelector ActionName = "get_element_text_by_selector"
ACTION_Scroll ActionName = "scroll" // scroll action
ACTION_Upload ActionName = "upload" // upload action
ACTION_PushMedia ActionName = "push_media" // push media action
ACTION_CreateBrowser ActionName = "create_browser" // create browser action
ACTION_AppInfo ActionName = "app_info" // get app info action
// device actions
ACTION_ListAvailableDevices ActionMethod = "list_available_devices"
ACTION_SelectDevice ActionMethod = "select_device"
ACTION_ListAvailableDevices ActionName = "list_available_devices"
ACTION_SelectDevice ActionName = "select_device"
// custom actions
ACTION_SwipeToTapApp ActionMethod = "swipe_to_tap_app" // swipe left & right to find app and tap
ACTION_SwipeToTapText ActionMethod = "swipe_to_tap_text" // swipe up & down to find text and tap
ACTION_SwipeToTapTexts ActionMethod = "swipe_to_tap_texts" // swipe up & down to find text and tap
ACTION_ClosePopups ActionMethod = "close_popups"
ACTION_EndToEndDelay ActionMethod = "live_e2e"
ACTION_InstallApp ActionMethod = "install_app"
ACTION_UninstallApp ActionMethod = "uninstall_app"
ACTION_DownloadApp ActionMethod = "download_app"
ACTION_Finished ActionMethod = "finished"
ACTION_SwipeToTapApp ActionName = "swipe_to_tap_app" // swipe left & right to find app and tap
ACTION_SwipeToTapText ActionName = "swipe_to_tap_text" // swipe up & down to find text and tap
ACTION_SwipeToTapTexts ActionName = "swipe_to_tap_texts" // swipe up & down to find text and tap
ACTION_ClosePopups ActionName = "close_popups"
ACTION_EndToEndDelay ActionName = "live_e2e"
ACTION_InstallApp ActionName = "install_app"
ACTION_UninstallApp ActionName = "uninstall_app"
ACTION_DownloadApp ActionName = "download_app"
ACTION_Finished ActionName = "finished"
)
const (
@@ -99,24 +104,79 @@ const (
)
type ActionOptions struct {
Context context.Context `json:"-" yaml:"-"`
// log
Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log
// Device targeting
Platform string `json:"platform,omitempty" yaml:"platform,omitempty" binding:"omitempty" desc:"Device platform: android/ios/browser"`
Serial string `json:"serial,omitempty" yaml:"serial,omitempty" binding:"omitempty" desc:"Device serial/udid/browser id"`
// control related
MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"` // max retry times
Interval float64 `json:"interval,omitempty" yaml:"interval,omitempty"` // interval between retries in seconds
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` // used to set duration in seconds
PressDuration float64 `json:"press_duration,omitempty" yaml:"press_duration,omitempty"` // used to set press duration in seconds
Steps int `json:"steps,omitempty" yaml:"steps,omitempty"` // used to set steps of action
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty"` // used by swipe to tap text or app
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` // TODO: wait timeout in seconds for mobile action
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty"`
// Common action parameters
X float64 `json:"x,omitempty" yaml:"x,omitempty" binding:"omitempty,min=0" desc:"X coordinate (0.0~1.0 for percent, or absolute pixel value)"`
Y float64 `json:"y,omitempty" yaml:"y,omitempty" binding:"omitempty,min=0" desc:"Y coordinate (0.0~1.0 for percent, or absolute pixel value)"`
FromX float64 `json:"from_x,omitempty" yaml:"from_x,omitempty" binding:"omitempty,min=0" desc:"Starting X coordinate"`
FromY float64 `json:"from_y,omitempty" yaml:"from_y,omitempty" binding:"omitempty,min=0" desc:"Starting Y coordinate"`
ToX float64 `json:"to_x,omitempty" yaml:"to_x,omitempty" binding:"omitempty,min=0" desc:"Ending X coordinate"`
ToY float64 `json:"to_y,omitempty" yaml:"to_y,omitempty" binding:"omitempty,min=0" desc:"Ending Y coordinate"`
Text string `json:"text,omitempty" yaml:"text,omitempty" desc:"Text content for input/search operations"`
// App/Package related
PackageName string `json:"packageName,omitempty" yaml:"packageName,omitempty" desc:"Package name of the app"`
AppName string `json:"appName,omitempty" yaml:"appName,omitempty" desc:"App name to find"`
AppUrl string `json:"appUrl,omitempty" yaml:"appUrl,omitempty" desc:"App URL for installation"`
MappingUrl string `json:"mappingUrl,omitempty" yaml:"mappingUrl,omitempty" desc:"Mapping URL for app installation"`
ResourceMappingUrl string `json:"resourceMappingUrl,omitempty" yaml:"resourceMappingUrl,omitempty" desc:"Resource mapping URL for app installation"`
// Web/Browser related
Selector string `json:"selector,omitempty" yaml:"selector,omitempty" desc:"CSS or XPath selector"`
TabIndex int `json:"tabIndex,omitempty" yaml:"tabIndex,omitempty" desc:"Browser tab index"`
PhoneNumber string `json:"phoneNumber,omitempty" yaml:"phoneNumber,omitempty" desc:"Phone number for login"`
Captcha string `json:"captcha,omitempty" yaml:"captcha,omitempty" desc:"Captcha code"`
Password string `json:"password,omitempty" yaml:"password,omitempty" desc:"Password for login"`
// Button/Key related
Button types.DeviceButton `json:"button,omitempty" yaml:"button,omitempty" desc:"Device button to press"`
Ime string `json:"ime,omitempty" yaml:"ime,omitempty" desc:"IME package name"`
Count int `json:"count,omitempty" yaml:"count,omitempty" desc:"Count for delete operations"`
Keycode int `json:"keycode,omitempty" yaml:"keycode,omitempty" desc:"Keycode for key press operations"`
// Image/CV related
ImagePath string `json:"imagePath,omitempty" yaml:"imagePath,omitempty" desc:"Path to reference image for CV recognition"`
// HTTP API specific fields
FileUrl string `json:"file_url,omitempty" yaml:"file_url,omitempty" desc:"File URL for upload operations"`
FileFormat string `json:"file_format,omitempty" yaml:"file_format,omitempty" desc:"File format for upload operations"`
ImageUrl string `json:"imageUrl,omitempty" yaml:"imageUrl,omitempty" desc:"Image URL for media operations"`
VideoUrl string `json:"videoUrl,omitempty" yaml:"videoUrl,omitempty" desc:"Video URL for media operations"`
Delta int `json:"delta,omitempty" yaml:"delta,omitempty" desc:"Delta value for scroll operations"`
Width int `json:"width,omitempty" yaml:"width,omitempty" desc:"Width for browser creation"`
Height int `json:"height,omitempty" yaml:"height,omitempty" desc:"Height for browser creation"`
// Array parameters
Texts []string `json:"texts,omitempty" yaml:"texts,omitempty" desc:"List of texts to search"`
Params []float64 `json:"params,omitempty" yaml:"params,omitempty" desc:"Generic parameter array"`
// AI related
Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty" desc:"AI action prompt"`
Content string `json:"content,omitempty" yaml:"content,omitempty" desc:"Content for finished action"`
// Time related
Seconds float64 `json:"seconds,omitempty" yaml:"seconds,omitempty" desc:"Sleep duration in seconds"`
Milliseconds int64 `json:"milliseconds,omitempty" yaml:"milliseconds,omitempty" desc:"Sleep duration in milliseconds"`
// Control options
Context context.Context `json:"-" yaml:"-"`
Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty" desc:"Action identifier for logging"`
MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty" desc:"Maximum retry times"`
Interval float64 `json:"interval,omitempty" yaml:"interval,omitempty" desc:"Interval between retries in seconds"`
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty" desc:"Action duration in seconds"`
PressDuration float64 `json:"press_duration,omitempty" yaml:"press_duration,omitempty" desc:"Press duration in seconds"`
Steps int `json:"steps,omitempty" yaml:"steps,omitempty" desc:"Number of steps for action"`
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty" desc:"Direction for swipe operations or custom coordinates"`
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty" desc:"Timeout in seconds"`
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty" desc:"Action frequency"`
ScreenOptions
// set custiom options such as textview, id, description
Custom map[string]interface{} `json:"custom,omitempty" yaml:"custom,omitempty"`
// Custom options
Custom map[string]interface{} `json:"custom,omitempty" yaml:"custom,omitempty" desc:"Custom options"`
}
func (o *ActionOptions) Options() []ActionOption {
@@ -433,3 +493,308 @@ func WithIgnoreNotFoundError(ignoreError bool) ActionOption {
o.IgnoreNotFoundError = ignoreError
}
}
// HTTP API direct usage methods
// ValidateForHTTPAPI validates the request for HTTP API usage
func (o *ActionOptions) ValidateForHTTPAPI(actionType ActionName) error {
// Basic validation - Platform and Serial are set from URL, so skip here
// They will be validated by setRequestContextFromURL
// Action-specific validation using a more efficient approach
return o.validateActionSpecificFields(actionType)
}
// validateActionSpecificFields performs action-specific field validation
func (o *ActionOptions) validateActionSpecificFields(actionType ActionName) error {
// Define validation rules for each action type using ActionMethod constants
validationRules := map[ActionName]func() error{
ACTION_Tap: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_TapXY: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_TapAbsXY: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_DoubleTap: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_DoubleTapXY: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_RightClick: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_SecondaryClick: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_Hover: func() error {
return o.requireFields("x and y coordinates", o.X != 0 && o.Y != 0)
},
ACTION_Drag: func() error {
return o.requireFields("fromX, fromY, toX, toY coordinates",
o.FromX != 0 && o.FromY != 0 && o.ToX != 0 && o.ToY != 0)
},
ACTION_SwipeCoordinate: func() error {
return o.requireFields("fromX, fromY, toX, toY coordinates",
o.FromX != 0 && o.FromY != 0 && o.ToX != 0 && o.ToY != 0)
},
ACTION_Swipe: func() error {
return o.requireFields("direction", o.Direction != nil && o.Direction != "")
},
ACTION_SwipeDirection: func() error {
return o.requireFields("direction", o.Direction != nil && o.Direction != "")
},
ACTION_Input: func() error {
return o.requireFields("text", o.Text != "")
},
ACTION_Delete: func() error {
// Count is optional, will use default if not provided
return nil
},
ACTION_Backspace: func() error {
// Count is optional, will use default if not provided
return nil
},
ACTION_KeyCode: func() error {
return o.requireFields("keycode", o.Keycode != 0)
},
ACTION_Scroll: func() error {
return o.requireFields("delta", o.Delta != 0)
},
ACTION_AppInfo: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_AppClear: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_AppLaunch: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_AppTerminate: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_AppUninstall: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_AppInstall: func() error {
return o.requireFields("appUrl", o.AppUrl != "")
},
ACTION_TapByOCR: func() error {
return o.requireFields("text", o.Text != "")
},
ACTION_SwipeToTapText: func() error {
return o.requireFields("text", o.Text != "")
},
ACTION_TapByCV: func() error {
return o.requireFields("imagePath", o.ImagePath != "")
},
ACTION_SwipeToTapApp: func() error {
return o.requireFields("appName", o.AppName != "")
},
ACTION_SwipeToTapTexts: func() error {
return o.requireFields("texts array", len(o.Texts) > 0)
},
ACTION_TapBySelector: func() error {
return o.requireFields("selector", o.Selector != "")
},
ACTION_HoverBySelector: func() error {
return o.requireFields("selector", o.Selector != "")
},
ACTION_SecondaryClickBySelector: func() error {
return o.requireFields("selector", o.Selector != "")
},
ACTION_WebCloseTab: func() error {
return o.requireFields("tabIndex", o.TabIndex != 0)
},
ACTION_WebLoginNoneUI: func() error {
if o.PackageName == "" || o.PhoneNumber == "" || o.Captcha == "" || o.Password == "" {
return fmt.Errorf("packageName, phoneNumber, captcha, and password are required for web_login_none_ui action")
}
return nil
},
ACTION_SetIme: func() error {
return o.requireFields("ime", o.Ime != "")
},
ACTION_GetSource: func() error {
return o.requireFields("packageName", o.PackageName != "")
},
ACTION_SleepMS: func() error {
return o.requireFields("milliseconds", o.Milliseconds != 0)
},
ACTION_SleepRandom: func() error {
return o.requireFields("params array", len(o.Params) > 0)
},
ACTION_AIAction: func() error {
return o.requireFields("prompt", o.Prompt != "")
},
ACTION_Finished: func() error {
return o.requireFields("content", o.Content != "")
},
ACTION_Upload: func() error {
if o.X == 0 || o.Y == 0 || o.FileUrl == "" {
return fmt.Errorf("x, y coordinates and fileUrl are required for upload action")
}
return nil
},
ACTION_PushMedia: func() error {
if o.ImageUrl == "" && o.VideoUrl == "" {
return fmt.Errorf("either imageUrl or videoUrl is required for push_media action")
}
return nil
},
ACTION_CreateBrowser: func() error {
return o.requireFields("timeout", o.Timeout != 0)
},
}
// Execute validation rule for the action type
if validator, exists := validationRules[actionType]; exists {
return validator()
}
// No specific validation needed for this action type
return nil
}
// requireFields is a helper function to generate consistent error messages
func (o *ActionOptions) requireFields(fieldDesc string, condition bool) error {
if !condition {
return fmt.Errorf("%s is required for this action", fieldDesc)
}
return nil
}
// GetMCPOptions generates MCP tool options for specific action types
func (o *ActionOptions) GetMCPOptions(actionType ActionName) []mcp.ToolOption {
// Define field mappings for different action types
fieldMappings := map[ActionName][]string{
ACTION_TapXY: {"platform", "serial", "x", "y", "duration"},
ACTION_TapAbsXY: {"platform", "serial", "x", "y", "duration"},
ACTION_TapByOCR: {"platform", "serial", "text", "ignoreNotFoundError", "maxRetryTimes", "index", "regex", "tapRandomRect"},
ACTION_TapByCV: {"platform", "serial", "ignoreNotFoundError", "maxRetryTimes", "index", "tapRandomRect"},
ACTION_DoubleTapXY: {"platform", "serial", "x", "y"},
ACTION_SwipeDirection: {"platform", "serial", "direction", "duration", "pressDuration"},
ACTION_SwipeCoordinate: {"platform", "serial", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Swipe: {"platform", "serial", "direction", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Drag: {"platform", "serial", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Input: {"platform", "serial", "text", "frequency"},
ACTION_AppLaunch: {"platform", "serial", "packageName"},
ACTION_AppTerminate: {"platform", "serial", "packageName"},
ACTION_AppInstall: {"platform", "serial", "appUrl", "packageName"},
ACTION_AppUninstall: {"platform", "serial", "packageName"},
ACTION_AppClear: {"platform", "serial", "packageName"},
ACTION_PressButton: {"platform", "serial", "button"},
ACTION_SwipeToTapApp: {"platform", "serial", "appName", "ignoreNotFoundError", "maxRetryTimes", "index"},
ACTION_SwipeToTapText: {"platform", "serial", "text", "ignoreNotFoundError", "maxRetryTimes", "index", "regex"},
ACTION_SwipeToTapTexts: {"platform", "serial", "texts", "ignoreNotFoundError", "maxRetryTimes", "index", "regex"},
ACTION_SecondaryClick: {"platform", "serial", "x", "y"},
ACTION_HoverBySelector: {"platform", "serial", "selector"},
ACTION_TapBySelector: {"platform", "serial", "selector"},
ACTION_SecondaryClickBySelector: {"platform", "serial", "selector"},
ACTION_WebCloseTab: {"platform", "serial", "tabIndex"},
ACTION_WebLoginNoneUI: {"platform", "serial", "packageName", "phoneNumber", "captcha", "password"},
ACTION_SetIme: {"platform", "serial", "ime"},
ACTION_GetSource: {"platform", "serial", "packageName"},
ACTION_Sleep: {"seconds"},
ACTION_SleepMS: {"platform", "serial", "milliseconds"},
ACTION_SleepRandom: {"platform", "serial", "params"},
ACTION_AIAction: {"platform", "serial", "prompt"},
ACTION_Finished: {"content"},
ACTION_ListAvailableDevices: {},
ACTION_SelectDevice: {"platform", "serial"},
ACTION_ScreenShot: {"platform", "serial"},
ACTION_GetScreenSize: {"platform", "serial"},
ACTION_Home: {"platform", "serial"},
ACTION_Back: {"platform", "serial"},
ACTION_ListPackages: {"platform", "serial"},
ACTION_ClosePopups: {"platform", "serial"},
}
fields := fieldMappings[actionType]
// Generate options for specified fields, or all fields if not mapped
return o.generateMCPOptionsForFields(fields)
}
// generateMCPOptionsForFields generates MCP options for specific fields
func (o *ActionOptions) generateMCPOptionsForFields(fields []string) []mcp.ToolOption {
options := make([]mcp.ToolOption, 0)
// If no fields are specified, return empty options (e.g., for ACTION_ListAvailableDevices)
if len(fields) == 0 {
return options
}
rType := reflect.TypeOf(*o)
// Process specific fields
fieldMap := make(map[string]reflect.StructField)
for i := 0; i < rType.NumField(); i++ {
field := rType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
name := strings.Split(jsonTag, ",")[0]
fieldMap[name] = field
}
}
for _, fieldName := range fields {
field, exists := fieldMap[fieldName]
if !exists {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
name := strings.Split(jsonTag, ",")[0]
binding := field.Tag.Get("binding")
required := strings.Contains(binding, "required")
desc := field.Tag.Get("desc")
// Handle pointer types
fieldType := field.Type
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
switch fieldType.Kind() {
case reflect.Float64, reflect.Float32, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if required {
options = append(options, mcp.WithNumber(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithNumber(name, mcp.Description(desc)))
}
case reflect.String:
if required {
options = append(options, mcp.WithString(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithString(name, mcp.Description(desc)))
}
case reflect.Bool:
if required {
options = append(options, mcp.WithBoolean(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithBoolean(name, mcp.Description(desc)))
}
case reflect.Slice:
if fieldType.Elem().Kind() == reflect.String || fieldType.Elem().Kind() == reflect.Float64 {
if required {
options = append(options, mcp.WithArray(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithArray(name, mcp.Description(desc)))
}
}
case reflect.Map, reflect.Interface:
// Skip map and interface types for now
continue
default:
log.Warn().Str("field_type", fieldType.String()).Msg("Unsupported field type")
}
}
return options
}

175
uixt/option/action_test.go Normal file
View File

@@ -0,0 +1,175 @@
package option
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUnifiedActionRequest_Options(t *testing.T) {
// Test TapXY request conversion
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
X: 0.5,
Y: 0.7,
Duration: 1.0,
MaxRetryTimes: 3,
ScreenOptions: ScreenOptions{
ScreenFilterOptions: ScreenFilterOptions{
Regex: true,
},
},
}
actionOpts := unifiedReq.Options()
assert.Equal(t, 1.0, unifiedReq.Duration)
assert.Equal(t, 3, unifiedReq.MaxRetryTimes)
assert.True(t, unifiedReq.Regex)
assert.NotEmpty(t, actionOpts)
}
func TestUnifiedActionRequest_GetMCPOptions(t *testing.T) {
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
}
// Test TapXY options
tapOptions := unifiedReq.GetMCPOptions(ACTION_TapXY)
assert.NotEmpty(t, tapOptions)
// Test TapByOCR options
ocrOptions := unifiedReq.GetMCPOptions(ACTION_TapByOCR)
assert.NotEmpty(t, ocrOptions)
// Test unknown action (should return empty options)
unknownOptions := unifiedReq.GetMCPOptions("unknown_action")
assert.Empty(t, unknownOptions)
}
func TestUnifiedActionRequest_SwipeDirection(t *testing.T) {
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
Direction: "up",
Duration: 2.0,
PressDuration: 0.5,
}
opts := unifiedReq.Options()
assert.Equal(t, "up", unifiedReq.Direction)
assert.Equal(t, 2.0, unifiedReq.Duration)
assert.Equal(t, 0.5, unifiedReq.PressDuration)
assert.NotEmpty(t, opts)
}
func TestUnifiedActionRequest_SwipeCoordinate(t *testing.T) {
params := []float64{0.2, 0.8, 0.2, 0.2}
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
Direction: params,
}
opts := unifiedReq.Options()
assert.Equal(t, params, unifiedReq.Direction)
assert.NotEmpty(t, opts)
}
func TestUnifiedActionRequest_ScreenOptions(t *testing.T) {
uiTypes := []string{"button", "text"}
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
ScreenOptions: ScreenOptions{
ScreenShotOptions: ScreenShotOptions{
ScreenShotWithOCR: true,
ScreenShotWithUpload: true,
ScreenShotWithUITypes: uiTypes,
},
},
}
opts := unifiedReq.Options()
assert.True(t, unifiedReq.ScreenShotWithOCR)
assert.True(t, unifiedReq.ScreenShotWithUpload)
assert.Equal(t, uiTypes, unifiedReq.ScreenShotWithUITypes)
assert.NotEmpty(t, opts)
}
func TestUnifiedActionRequest_NilPointerSafety(t *testing.T) {
// Test with nil pointers
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
// All pointer fields are nil
}
opts := unifiedReq.Options()
assert.Equal(t, 0, unifiedReq.MaxRetryTimes)
assert.Equal(t, 0.0, unifiedReq.Duration)
assert.Equal(t, 0.0, unifiedReq.PressDuration)
assert.False(t, unifiedReq.Regex)
assert.False(t, unifiedReq.TapRandomRect)
// When all fields are default values, Options() may return empty slice
// This is expected behavior
assert.NotNil(t, opts)
}
func TestUnifiedActionRequest_CustomOptions(t *testing.T) {
customData := map[string]interface{}{
"custom_key": "custom_value",
"number": 42,
}
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
Custom: customData,
}
opts := unifiedReq.Options()
assert.Equal(t, customData, unifiedReq.Custom)
assert.NotEmpty(t, opts)
}
func TestUnifiedActionRequest_BasicTypeFields(t *testing.T) {
// Test basic type fields (no longer pointers)
unifiedReq := &ActionOptions{
Platform: "android",
Serial: "device123",
Count: 5,
Keycode: 123,
Delta: 10,
Width: 800,
Height: 600,
Seconds: 2.5,
Milliseconds: 1500,
TabIndex: 3,
}
// Test direct field access (no need for Getter methods)
assert.Equal(t, 5, unifiedReq.Count)
assert.Equal(t, 123, unifiedReq.Keycode)
assert.Equal(t, 10, unifiedReq.Delta)
assert.Equal(t, 800, unifiedReq.Width)
assert.Equal(t, 600, unifiedReq.Height)
assert.Equal(t, 2.5, unifiedReq.Seconds)
assert.Equal(t, int64(1500), unifiedReq.Milliseconds)
assert.Equal(t, 3, unifiedReq.TabIndex)
// Test zero value detection
emptyReq := &ActionOptions{}
assert.Equal(t, 0, emptyReq.Count)
assert.Equal(t, 0, emptyReq.Keycode)
assert.Equal(t, 0, emptyReq.Delta)
assert.Equal(t, 0, emptyReq.Width)
assert.Equal(t, 0, emptyReq.Height)
assert.Equal(t, 0.0, emptyReq.Seconds)
assert.Equal(t, int64(0), emptyReq.Milliseconds)
assert.Equal(t, 0, emptyReq.TabIndex)
}

View File

@@ -1,752 +0,0 @@
package option
import (
"context"
"fmt"
"reflect"
"strings"
"github.com/httprunner/httprunner/v5/uixt/types"
"github.com/mark3labs/mcp-go/mcp"
"github.com/rs/zerolog/log"
)
// NewMCPOptions creates MCP tool options from a struct using reflection
// This function is kept for backward compatibility with existing code
// New code should use UnifiedActionRequest.GetMCPOptions() instead
func NewMCPOptions(t interface{}) (options []mcp.ToolOption) {
tType := reflect.TypeOf(t)
// Handle pointer type by getting the element type
if tType.Kind() == reflect.Ptr {
tType = tType.Elem()
}
// Ensure we have a struct type
if tType.Kind() != reflect.Struct {
log.Warn().Str("type", tType.String()).Msg("NewMCPOptions expects a struct or pointer to struct")
return options
}
for i := 0; i < tType.NumField(); i++ {
field := tType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
name := strings.Split(jsonTag, ",")[0]
binding := field.Tag.Get("binding")
required := strings.Contains(binding, "required")
desc := field.Tag.Get("desc")
fieldType := field.Type
// Handle pointer types
if fieldType.Kind() == reflect.Ptr {
fieldType = fieldType.Elem()
}
switch fieldType.Kind() {
case reflect.Float64, reflect.Float32, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if required {
options = append(options, mcp.WithNumber(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithNumber(name, mcp.Description(desc)))
}
case reflect.String:
if required {
options = append(options, mcp.WithString(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithString(name, mcp.Description(desc)))
}
case reflect.Bool:
if required {
options = append(options, mcp.WithBoolean(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithBoolean(name, mcp.Description(desc)))
}
case reflect.Slice:
// Handle slice types, especially []string and []float64
if field.Type.Elem().Kind() == reflect.String {
// Array of strings
if required {
options = append(options, mcp.WithArray(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithArray(name, mcp.Description(desc)))
}
} else if field.Type.Elem().Kind() == reflect.Float64 {
// Array of numbers
if required {
options = append(options, mcp.WithArray(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithArray(name, mcp.Description(desc)))
}
}
default:
log.Warn().Str("field_type", field.Type.String()).Msg("Unsupported field type")
}
}
return options
}
// UnifiedActionRequest represents a unified request structure that combines
// ActionOptions with specific action parameters
type UnifiedActionRequest struct {
// Device targeting
Platform string `json:"platform" binding:"omitempty" desc:"Device platform: android/ios/browser"`
Serial string `json:"serial" binding:"omitempty" desc:"Device serial/udid/browser id"`
// Common action parameters
X *float64 `json:"x,omitempty" binding:"omitempty,min=0" desc:"X coordinate (0.0~1.0 for percent, or absolute pixel value)"`
Y *float64 `json:"y,omitempty" binding:"omitempty,min=0" desc:"Y coordinate (0.0~1.0 for percent, or absolute pixel value)"`
FromX *float64 `json:"from_x,omitempty" binding:"omitempty,min=0" desc:"Starting X coordinate"`
FromY *float64 `json:"from_y,omitempty" binding:"omitempty,min=0" desc:"Starting Y coordinate"`
ToX *float64 `json:"to_x,omitempty" binding:"omitempty,min=0" desc:"Ending X coordinate"`
ToY *float64 `json:"to_y,omitempty" binding:"omitempty,min=0" desc:"Ending Y coordinate"`
Text string `json:"text,omitempty" desc:"Text content for input/search operations"`
Direction string `json:"direction,omitempty" desc:"Direction for swipe operations: up/down/left/right"`
// App/Package related
PackageName string `json:"packageName,omitempty" desc:"Package name of the app"`
AppName string `json:"appName,omitempty" desc:"App name to find"`
AppUrl string `json:"appUrl,omitempty" desc:"App URL for installation"`
MappingUrl string `json:"mappingUrl,omitempty" desc:"Mapping URL for app installation"`
ResourceMappingUrl string `json:"resourceMappingUrl,omitempty" desc:"Resource mapping URL for app installation"`
// Web/Browser related
Selector string `json:"selector,omitempty" desc:"CSS or XPath selector"`
TabIndex *int `json:"tabIndex,omitempty" desc:"Browser tab index"`
PhoneNumber string `json:"phoneNumber,omitempty" desc:"Phone number for login"`
Captcha string `json:"captcha,omitempty" desc:"Captcha code"`
Password string `json:"password,omitempty" desc:"Password for login"`
// Button/Key related
Button types.DeviceButton `json:"button,omitempty" desc:"Device button to press"`
Ime string `json:"ime,omitempty" desc:"IME package name"`
Count *int `json:"count,omitempty" desc:"Count for delete operations"`
Keycode *int `json:"keycode,omitempty" desc:"Keycode for key press operations"`
// Image/CV related
ImagePath string `json:"imagePath,omitempty" desc:"Path to reference image for CV recognition"`
// HTTP API specific fields
FileUrl string `json:"file_url,omitempty" desc:"File URL for upload operations"`
FileFormat string `json:"file_format,omitempty" desc:"File format for upload operations"`
ImageUrl string `json:"imageUrl,omitempty" desc:"Image URL for media operations"`
VideoUrl string `json:"videoUrl,omitempty" desc:"Video URL for media operations"`
Delta *int `json:"delta,omitempty" desc:"Delta value for scroll operations"`
Width *int `json:"width,omitempty" desc:"Width for browser creation"`
Height *int `json:"height,omitempty" desc:"Height for browser creation"`
// Array parameters
Texts []string `json:"texts,omitempty" desc:"List of texts to search"`
Params []float64 `json:"params,omitempty" desc:"Generic parameter array"`
// AI related
Prompt string `json:"prompt,omitempty" desc:"AI action prompt"`
Content string `json:"content,omitempty" desc:"Content for finished action"`
// Time related
Seconds *float64 `json:"seconds,omitempty" desc:"Sleep duration in seconds"`
Milliseconds *int64 `json:"milliseconds,omitempty" desc:"Sleep duration in milliseconds"`
// Control options (from ActionOptions)
Context context.Context `json:"-" yaml:"-"`
Identifier string `json:"identifier,omitempty" desc:"Action identifier for logging"`
MaxRetryTimes *int `json:"max_retry_times,omitempty" desc:"Maximum retry times"`
Interval *float64 `json:"interval,omitempty" desc:"Interval between retries in seconds"`
Duration *float64 `json:"duration,omitempty" desc:"Action duration in seconds"`
PressDuration *float64 `json:"press_duration,omitempty" desc:"Press duration in seconds"`
Steps *int `json:"steps,omitempty" desc:"Number of steps for action"`
Timeout *int `json:"timeout,omitempty" desc:"Timeout in seconds"`
Frequency *int `json:"frequency,omitempty" desc:"Action frequency"`
// Filter options (from ScreenFilterOptions)
Scope []float64 `json:"scope,omitempty" desc:"Screen scope [x1,y1,x2,y2] in percentage"`
AbsScope []int `json:"absScope,omitempty" desc:"Absolute screen scope [x1,y1,x2,y2] in pixels"`
Regex *bool `json:"regex,omitempty" desc:"Use regex to match text"`
TapOffset []int `json:"tap_offset,omitempty" desc:"Tap offset [x,y]"`
TapRandomRect *bool `json:"tap_random_rect,omitempty" desc:"Tap random point in rectangle"`
SwipeOffset []int `json:"swipe_offset,omitempty" desc:"Swipe offset [fromX,fromY,toX,toY]"`
OffsetRandomRange []int `json:"offset_random_range,omitempty" desc:"Random offset range [min,max]"`
Index *int `json:"index,omitempty" desc:"Element index when multiple matches found"`
MatchOne *bool `json:"match_one,omitempty" desc:"Match only one element"`
IgnoreNotFoundError *bool `json:"ignore_NotFoundError,omitempty" desc:"Ignore error if element not found"`
// Screenshot options (from ScreenShotOptions)
ScreenShotWithOCR *bool `json:"screenshot_with_ocr,omitempty" desc:"Take screenshot with OCR"`
ScreenShotWithUpload *bool `json:"screenshot_with_upload,omitempty" desc:"Upload screenshot"`
ScreenShotWithLiveType *bool `json:"screenshot_with_live_type,omitempty" desc:"Screenshot with live type"`
ScreenShotWithLivePopularity *bool `json:"screenshot_with_live_popularity,omitempty" desc:"Screenshot with live popularity"`
ScreenShotWithUITypes []string `json:"screenshot_with_ui_types,omitempty" desc:"Screenshot with UI types"`
ScreenShotWithClosePopups *bool `json:"screenshot_with_close_popups,omitempty" desc:"Close popups before screenshot"`
ScreenShotWithOCRCluster string `json:"screenshot_with_ocr_cluster,omitempty" desc:"OCR cluster for screenshot"`
ScreenShotFileName string `json:"screenshot_file_name,omitempty" desc:"Screenshot file name"`
// Screen record options (from ScreenRecordOptions)
ScreenRecordDuration *float64 `json:"screenrecord_duration,omitempty" desc:"Screen record duration"`
ScreenRecordWithAudio *bool `json:"screenrecord_with_audio,omitempty" desc:"Record with audio"`
ScreenRecordWithScrcpy *bool `json:"screenrecord_with_scrcpy,omitempty" desc:"Use scrcpy for recording"`
ScreenRecordPath string `json:"screenrecord_path,omitempty" desc:"Screen record output path"`
// Mark operation options (from MarkOperationOptions)
PreMarkOperation *bool `json:"pre_mark_operation,omitempty" desc:"Mark operation before action"`
PostMarkOperation *bool `json:"post_mark_operation,omitempty" desc:"Mark operation after action"`
// Custom options
Custom map[string]interface{} `json:"custom,omitempty" desc:"Custom options"`
}
// HTTP API direct usage methods
// GetX returns the X coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetX() float64 {
if r.X != nil {
return *r.X
}
return 0
}
// GetY returns the Y coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetY() float64 {
if r.Y != nil {
return *r.Y
}
return 0
}
// GetFromX returns the FromX coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetFromX() float64 {
if r.FromX != nil {
return *r.FromX
}
return 0
}
// GetFromY returns the FromY coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetFromY() float64 {
if r.FromY != nil {
return *r.FromY
}
return 0
}
// GetToX returns the ToX coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetToX() float64 {
if r.ToX != nil {
return *r.ToX
}
return 0
}
// GetToY returns the ToY coordinate value, handling nil pointer safely
func (r *UnifiedActionRequest) GetToY() float64 {
if r.ToY != nil {
return *r.ToY
}
return 0
}
// GetDuration returns the duration value, handling nil pointer safely
func (r *UnifiedActionRequest) GetDuration() float64 {
if r.Duration != nil {
return *r.Duration
}
return 0
}
// GetPressDuration returns the press duration value, handling nil pointer safely
func (r *UnifiedActionRequest) GetPressDuration() float64 {
if r.PressDuration != nil {
return *r.PressDuration
}
return 0
}
// GetCount returns the count value, handling nil pointer safely
func (r *UnifiedActionRequest) GetCount() int {
if r.Count != nil {
return *r.Count
}
return 0
}
// GetKeycode returns the keycode value, handling nil pointer safely
func (r *UnifiedActionRequest) GetKeycode() int {
if r.Keycode != nil {
return *r.Keycode
}
return 0
}
// GetFrequency returns the frequency value, handling nil pointer safely
func (r *UnifiedActionRequest) GetFrequency() int {
if r.Frequency != nil {
return *r.Frequency
}
return 0
}
// GetTabIndex returns the tab index value, handling nil pointer safely
func (r *UnifiedActionRequest) GetTabIndex() int {
if r.TabIndex != nil {
return *r.TabIndex
}
return 0
}
// GetDelta returns the delta value, handling nil pointer safely
func (r *UnifiedActionRequest) GetDelta() int {
if r.Delta != nil {
return *r.Delta
}
return 0
}
// GetWidth returns the width value, handling nil pointer safely
func (r *UnifiedActionRequest) GetWidth() int {
if r.Width != nil {
return *r.Width
}
return 0
}
// GetHeight returns the height value, handling nil pointer safely
func (r *UnifiedActionRequest) GetHeight() int {
if r.Height != nil {
return *r.Height
}
return 0
}
// GetTimeout returns the timeout value, handling nil pointer safely
func (r *UnifiedActionRequest) GetTimeout() int {
if r.Timeout != nil {
return *r.Timeout
}
return 0
}
// GetMilliseconds returns the milliseconds value, handling nil pointer safely
func (r *UnifiedActionRequest) GetMilliseconds() int64 {
if r.Milliseconds != nil {
return *r.Milliseconds
}
return 0
}
// ValidateForHTTPAPI validates the request for HTTP API usage
func (r *UnifiedActionRequest) ValidateForHTTPAPI(actionType ActionMethod) error {
// Basic validation - Platform and Serial are set from URL, so skip here
// They will be validated by setRequestContextFromURL
// Action-specific validation using a more efficient approach
return r.validateActionSpecificFields(actionType)
}
// validateActionSpecificFields performs action-specific field validation
func (r *UnifiedActionRequest) validateActionSpecificFields(actionType ActionMethod) error {
// Define validation rules for each action type using ActionMethod constants
validationRules := map[ActionMethod]func() error{
ACTION_Tap: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_TapXY: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_TapAbsXY: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_DoubleTap: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_DoubleTapXY: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_RightClick: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_SecondaryClick: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_Hover: func() error {
return r.requireFields("x and y coordinates", r.X != nil && r.Y != nil)
},
ACTION_Drag: func() error {
return r.requireFields("fromX, fromY, toX, toY coordinates",
r.FromX != nil && r.FromY != nil && r.ToX != nil && r.ToY != nil)
},
ACTION_SwipeCoordinate: func() error {
return r.requireFields("fromX, fromY, toX, toY coordinates",
r.FromX != nil && r.FromY != nil && r.ToX != nil && r.ToY != nil)
},
ACTION_Swipe: func() error {
return r.requireFields("direction", r.Direction != "")
},
ACTION_SwipeDirection: func() error {
return r.requireFields("direction", r.Direction != "")
},
ACTION_Input: func() error {
return r.requireFields("text", r.Text != "")
},
ACTION_Delete: func() error {
// Count is optional, will use default if not provided
return nil
},
ACTION_Backspace: func() error {
// Count is optional, will use default if not provided
return nil
},
ACTION_KeyCode: func() error {
return r.requireFields("keycode", r.Keycode != nil)
},
ACTION_Scroll: func() error {
return r.requireFields("delta", r.Delta != nil)
},
ACTION_AppInfo: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_AppClear: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_AppLaunch: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_AppTerminate: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_AppUninstall: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_AppInstall: func() error {
return r.requireFields("appUrl", r.AppUrl != "")
},
ACTION_TapByOCR: func() error {
return r.requireFields("text", r.Text != "")
},
ACTION_SwipeToTapText: func() error {
return r.requireFields("text", r.Text != "")
},
ACTION_TapByCV: func() error {
return r.requireFields("imagePath", r.ImagePath != "")
},
ACTION_SwipeToTapApp: func() error {
return r.requireFields("appName", r.AppName != "")
},
ACTION_SwipeToTapTexts: func() error {
return r.requireFields("texts array", len(r.Texts) > 0)
},
ACTION_TapBySelector: func() error {
return r.requireFields("selector", r.Selector != "")
},
ACTION_HoverBySelector: func() error {
return r.requireFields("selector", r.Selector != "")
},
ACTION_SecondaryClickBySelector: func() error {
return r.requireFields("selector", r.Selector != "")
},
ACTION_WebCloseTab: func() error {
return r.requireFields("tabIndex", r.TabIndex != nil)
},
ACTION_WebLoginNoneUI: func() error {
if r.PackageName == "" || r.PhoneNumber == "" || r.Captcha == "" || r.Password == "" {
return fmt.Errorf("packageName, phoneNumber, captcha, and password are required for web_login_none_ui action")
}
return nil
},
ACTION_SetIme: func() error {
return r.requireFields("ime", r.Ime != "")
},
ACTION_GetSource: func() error {
return r.requireFields("packageName", r.PackageName != "")
},
ACTION_SleepMS: func() error {
return r.requireFields("milliseconds", r.Milliseconds != nil)
},
ACTION_SleepRandom: func() error {
return r.requireFields("params array", len(r.Params) > 0)
},
ACTION_AIAction: func() error {
return r.requireFields("prompt", r.Prompt != "")
},
ACTION_Finished: func() error {
return r.requireFields("content", r.Content != "")
},
ACTION_Upload: func() error {
if r.X == nil || r.Y == nil || r.FileUrl == "" {
return fmt.Errorf("x, y coordinates and fileUrl are required for upload action")
}
return nil
},
ACTION_PushMedia: func() error {
if r.ImageUrl == "" && r.VideoUrl == "" {
return fmt.Errorf("either imageUrl or videoUrl is required for push_media action")
}
return nil
},
ACTION_CreateBrowser: func() error {
return r.requireFields("timeout", r.Timeout != nil)
},
}
// Execute validation rule for the action type
if validator, exists := validationRules[actionType]; exists {
return validator()
}
// No specific validation needed for this action type
return nil
}
// requireFields is a helper function to generate consistent error messages
func (r *UnifiedActionRequest) requireFields(fieldDesc string, condition bool) error {
if !condition {
return fmt.Errorf("%s is required for this action", fieldDesc)
}
return nil
}
// ToActionOptions converts UnifiedActionRequest to ActionOptions
func (r *UnifiedActionRequest) ToActionOptions() *ActionOptions {
opts := &ActionOptions{
Context: r.Context,
Identifier: r.Identifier,
Custom: r.Custom,
}
// Copy pointer values safely
if r.MaxRetryTimes != nil {
opts.MaxRetryTimes = *r.MaxRetryTimes
}
if r.Interval != nil {
opts.Interval = *r.Interval
}
if r.Duration != nil {
opts.Duration = *r.Duration
}
if r.PressDuration != nil {
opts.PressDuration = *r.PressDuration
}
if r.Steps != nil {
opts.Steps = *r.Steps
}
if r.Timeout != nil {
opts.Timeout = *r.Timeout
}
if r.Frequency != nil {
opts.Frequency = *r.Frequency
}
// Handle direction
if r.Direction != "" {
opts.Direction = r.Direction
} else if len(r.Params) == 4 {
opts.Direction = r.Params
}
// Copy filter options (ScreenFilterOptions)
opts.ScreenFilterOptions.Scope = r.Scope
opts.ScreenFilterOptions.AbsScope = r.AbsScope
if r.Regex != nil {
opts.ScreenFilterOptions.Regex = *r.Regex
}
opts.ScreenFilterOptions.TapOffset = r.TapOffset
if r.TapRandomRect != nil {
opts.ScreenFilterOptions.TapRandomRect = *r.TapRandomRect
}
opts.ScreenFilterOptions.SwipeOffset = r.SwipeOffset
opts.ScreenFilterOptions.OffsetRandomRange = r.OffsetRandomRange
if r.Index != nil {
opts.ScreenFilterOptions.Index = *r.Index
}
if r.MatchOne != nil {
opts.ScreenFilterOptions.MatchOne = *r.MatchOne
}
if r.IgnoreNotFoundError != nil {
opts.ScreenFilterOptions.IgnoreNotFoundError = *r.IgnoreNotFoundError
}
// Copy screenshot options (ScreenShotOptions)
if r.ScreenShotWithOCR != nil {
opts.ScreenShotOptions.ScreenShotWithOCR = *r.ScreenShotWithOCR
}
if r.ScreenShotWithUpload != nil {
opts.ScreenShotOptions.ScreenShotWithUpload = *r.ScreenShotWithUpload
}
if r.ScreenShotWithLiveType != nil {
opts.ScreenShotOptions.ScreenShotWithLiveType = *r.ScreenShotWithLiveType
}
if r.ScreenShotWithLivePopularity != nil {
opts.ScreenShotOptions.ScreenShotWithLivePopularity = *r.ScreenShotWithLivePopularity
}
opts.ScreenShotOptions.ScreenShotWithUITypes = r.ScreenShotWithUITypes
if r.ScreenShotWithClosePopups != nil {
opts.ScreenShotOptions.ScreenShotWithClosePopups = *r.ScreenShotWithClosePopups
}
opts.ScreenShotOptions.ScreenShotWithOCRCluster = r.ScreenShotWithOCRCluster
opts.ScreenShotOptions.ScreenShotFileName = r.ScreenShotFileName
// Copy screen record options (ScreenRecordOptions)
if r.ScreenRecordDuration != nil {
opts.ScreenRecordOptions.ScreenRecordDuration = *r.ScreenRecordDuration
}
if r.ScreenRecordWithAudio != nil {
opts.ScreenRecordOptions.ScreenRecordWithAudio = *r.ScreenRecordWithAudio
}
if r.ScreenRecordWithScrcpy != nil {
opts.ScreenRecordOptions.ScreenRecordWithScrcpy = *r.ScreenRecordWithScrcpy
}
opts.ScreenRecordOptions.ScreenRecordPath = r.ScreenRecordPath
// Copy mark operation options (MarkOperationOptions)
if r.PreMarkOperation != nil {
opts.MarkOperationOptions.PreMarkOperation = *r.PreMarkOperation
}
if r.PostMarkOperation != nil {
opts.MarkOperationOptions.PostMarkOperation = *r.PostMarkOperation
}
return opts
}
// GetMCPOptions generates MCP tool options for specific action types
func (r *UnifiedActionRequest) GetMCPOptions(actionType ActionMethod) []mcp.ToolOption {
// Define field mappings for different action types
fieldMappings := map[ActionMethod][]string{
ACTION_TapXY: {"platform", "serial", "x", "y", "duration"},
ACTION_TapAbsXY: {"platform", "serial", "x", "y", "duration"},
ACTION_TapByOCR: {"platform", "serial", "text", "ignoreNotFoundError", "maxRetryTimes", "index", "regex", "tapRandomRect"},
ACTION_TapByCV: {"platform", "serial", "ignoreNotFoundError", "maxRetryTimes", "index", "tapRandomRect"},
ACTION_DoubleTapXY: {"platform", "serial", "x", "y"},
ACTION_SwipeDirection: {"platform", "serial", "direction", "duration", "pressDuration"},
ACTION_SwipeCoordinate: {"platform", "serial", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Swipe: {"platform", "serial", "direction", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Drag: {"platform", "serial", "fromX", "fromY", "toX", "toY", "duration", "pressDuration"},
ACTION_Input: {"platform", "serial", "text", "frequency"},
ACTION_AppLaunch: {"platform", "serial", "packageName"},
ACTION_AppTerminate: {"platform", "serial", "packageName"},
ACTION_AppInstall: {"platform", "serial", "appUrl", "packageName"},
ACTION_AppUninstall: {"platform", "serial", "packageName"},
ACTION_AppClear: {"platform", "serial", "packageName"},
ACTION_PressButton: {"platform", "serial", "button"},
ACTION_SwipeToTapApp: {"platform", "serial", "appName", "ignoreNotFoundError", "maxRetryTimes", "index"},
ACTION_SwipeToTapText: {"platform", "serial", "text", "ignoreNotFoundError", "maxRetryTimes", "index", "regex"},
ACTION_SwipeToTapTexts: {"platform", "serial", "texts", "ignoreNotFoundError", "maxRetryTimes", "index", "regex"},
ACTION_SecondaryClick: {"platform", "serial", "x", "y"},
ACTION_HoverBySelector: {"platform", "serial", "selector"},
ACTION_TapBySelector: {"platform", "serial", "selector"},
ACTION_SecondaryClickBySelector: {"platform", "serial", "selector"},
ACTION_WebCloseTab: {"platform", "serial", "tabIndex"},
ACTION_WebLoginNoneUI: {"platform", "serial", "packageName", "phoneNumber", "captcha", "password"},
ACTION_SetIme: {"platform", "serial", "ime"},
ACTION_GetSource: {"platform", "serial", "packageName"},
ACTION_Sleep: {"seconds"},
ACTION_SleepMS: {"platform", "serial", "milliseconds"},
ACTION_SleepRandom: {"platform", "serial", "params"},
ACTION_AIAction: {"platform", "serial", "prompt"},
ACTION_Finished: {"content"},
ACTION_ListAvailableDevices: {},
ACTION_SelectDevice: {"platform", "serial"},
ACTION_ScreenShot: {"platform", "serial"},
ACTION_GetScreenSize: {"platform", "serial"},
ACTION_Home: {"platform", "serial"},
ACTION_Back: {"platform", "serial"},
ACTION_ListPackages: {"platform", "serial"},
ACTION_ClosePopups: {"platform", "serial"},
}
fields := fieldMappings[actionType]
if fields == nil {
// Fallback to all fields if not specifically mapped
return NewMCPOptions(*r)
}
// Generate options only for specified fields
return r.generateMCPOptionsForFields(fields)
}
// generateMCPOptionsForFields generates MCP options for specific fields
func (r *UnifiedActionRequest) generateMCPOptionsForFields(fields []string) []mcp.ToolOption {
options := make([]mcp.ToolOption, 0)
rType := reflect.TypeOf(*r)
rValue := reflect.ValueOf(*r)
fieldMap := make(map[string]reflect.StructField)
for i := 0; i < rType.NumField(); i++ {
field := rType.Field(i)
jsonTag := field.Tag.Get("json")
if jsonTag != "" && jsonTag != "-" {
name := strings.Split(jsonTag, ",")[0]
fieldMap[name] = field
}
}
for _, fieldName := range fields {
field, exists := fieldMap[fieldName]
if !exists {
continue
}
jsonTag := field.Tag.Get("json")
if jsonTag == "" || jsonTag == "-" {
continue
}
name := strings.Split(jsonTag, ",")[0]
binding := field.Tag.Get("binding")
required := strings.Contains(binding, "required")
desc := field.Tag.Get("desc")
// Check if field has a value
fieldValue := rValue.FieldByName(field.Name)
if !fieldValue.IsValid() {
continue
}
// Handle pointer types
fieldType := field.Type
isPointer := false
if fieldType.Kind() == reflect.Ptr {
isPointer = true
fieldType = fieldType.Elem()
}
// Skip nil pointer fields if not required
if isPointer && fieldValue.IsNil() && !required {
continue
}
switch fieldType.Kind() {
case reflect.Float64, reflect.Float32, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if required {
options = append(options, mcp.WithNumber(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithNumber(name, mcp.Description(desc)))
}
case reflect.String:
if required {
options = append(options, mcp.WithString(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithString(name, mcp.Description(desc)))
}
case reflect.Bool:
if required {
options = append(options, mcp.WithBoolean(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithBoolean(name, mcp.Description(desc)))
}
case reflect.Slice:
if fieldType.Elem().Kind() == reflect.String || fieldType.Elem().Kind() == reflect.Float64 {
if required {
options = append(options, mcp.WithArray(name, mcp.Required(), mcp.Description(desc)))
} else {
options = append(options, mcp.WithArray(name, mcp.Description(desc)))
}
}
case reflect.Map, reflect.Interface:
// Skip map and interface types for now
continue
default:
log.Warn().Str("field_type", fieldType.String()).Msg("Unsupported field type")
}
}
return options
}

View File

@@ -1,133 +0,0 @@
package option
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestUnifiedActionRequest_ToActionOptions(t *testing.T) {
// Test TapXY request conversion
x := 0.5
y := 0.7
duration := 1.0
maxRetryTimes := 3
regex := true
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
X: &x,
Y: &y,
Duration: &duration,
MaxRetryTimes: &maxRetryTimes,
Regex: &regex,
}
actionOpts := unifiedReq.ToActionOptions()
assert.Equal(t, 1.0, actionOpts.Duration)
assert.Equal(t, 3, actionOpts.MaxRetryTimes)
assert.True(t, actionOpts.Regex)
}
func TestUnifiedActionRequest_GetMCPOptions(t *testing.T) {
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
}
// Test TapXY options
tapOptions := unifiedReq.GetMCPOptions(ACTION_TapXY)
assert.NotEmpty(t, tapOptions)
// Test TapByOCR options
ocrOptions := unifiedReq.GetMCPOptions(ACTION_TapByOCR)
assert.NotEmpty(t, ocrOptions)
// Test unknown action (should fallback to all fields)
unknownOptions := unifiedReq.GetMCPOptions("unknown_action")
assert.NotEmpty(t, unknownOptions)
}
func TestUnifiedActionRequest_SwipeDirection(t *testing.T) {
duration := 2.0
pressDuration := 0.5
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
Direction: "up",
Duration: &duration,
PressDuration: &pressDuration,
}
actionOpts := unifiedReq.ToActionOptions()
assert.Equal(t, "up", actionOpts.Direction)
assert.Equal(t, 2.0, actionOpts.Duration)
assert.Equal(t, 0.5, actionOpts.PressDuration)
}
func TestUnifiedActionRequest_SwipeCoordinate(t *testing.T) {
params := []float64{0.2, 0.8, 0.2, 0.2}
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
Params: params,
}
actionOpts := unifiedReq.ToActionOptions()
assert.Equal(t, params, actionOpts.Direction)
}
func TestUnifiedActionRequest_ScreenOptions(t *testing.T) {
ocrEnabled := true
uploadEnabled := true
uiTypes := []string{"button", "text"}
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
ScreenShotWithOCR: &ocrEnabled,
ScreenShotWithUpload: &uploadEnabled,
ScreenShotWithUITypes: uiTypes,
}
actionOpts := unifiedReq.ToActionOptions()
assert.True(t, actionOpts.ScreenShotWithOCR)
assert.True(t, actionOpts.ScreenShotWithUpload)
assert.Equal(t, uiTypes, actionOpts.ScreenShotWithUITypes)
}
func TestUnifiedActionRequest_NilPointerSafety(t *testing.T) {
// Test with nil pointers
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
// All pointer fields are nil
}
actionOpts := unifiedReq.ToActionOptions()
assert.Equal(t, 0, actionOpts.MaxRetryTimes)
assert.Equal(t, 0.0, actionOpts.Duration)
assert.Equal(t, 0.0, actionOpts.PressDuration)
assert.False(t, actionOpts.Regex)
assert.False(t, actionOpts.TapRandomRect)
}
func TestUnifiedActionRequest_CustomOptions(t *testing.T) {
customData := map[string]interface{}{
"custom_key": "custom_value",
"number": 42,
}
unifiedReq := &UnifiedActionRequest{
Platform: "android",
Serial: "device123",
Custom: customData,
}
actionOpts := unifiedReq.ToActionOptions()
assert.Equal(t, customData, actionOpts.Custom)
}

View File

@@ -62,7 +62,7 @@ func (c *MCPClient4XTDriver) ListTools(ctx context.Context, req mcp.ListToolsReq
}
func (c *MCPClient4XTDriver) CallTool(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
actionTool := c.Server.GetToolByAction(option.ActionMethod(req.Params.Name))
actionTool := c.Server.GetToolByAction(option.ActionName(req.Params.Name))
if actionTool == nil {
return mcp.NewToolResultError(fmt.Sprintf("action %s for tool not found", req.Params.Name)), nil
}