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
+1 -1
View File
@@ -7,7 +7,7 @@
"android": [ "android": [
{ {
"serial": "$device", "serial": "$device",
"log_on": true, "log_on": false,
"adb_server_host": "localhost", "adb_server_host": "localhost",
"adb_server_port": 5037, "adb_server_port": 5037,
"uia2_ip": "localhost", "uia2_ip": "localhost",
+1 -1
View File
@@ -1 +1 @@
v5.0.0-beta-2505271149 v5.0.0-beta-2505271334
+1 -1
View File
@@ -22,7 +22,7 @@ func (r *Router) foregroundAppHandler(c *gin.Context) {
} }
func (r *Router) appInfoHandler(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 { if err := c.ShouldBindQuery(&req); err != nil {
RenderErrorValidateRequest(c, err) RenderErrorValidateRequest(c, err)
return return
+2 -2
View File
@@ -39,7 +39,7 @@ func (r *Router) backspaceHandler(c *gin.Context) {
return return
} }
count := req.GetCount() count := req.Count
if count == 0 { if count == 0 {
count = 20 count = 20
} }
@@ -67,7 +67,7 @@ func (r *Router) keycodeHandler(c *gin.Context) {
} }
// TODO FIXME // TODO FIXME
err = driver.IDriver.(*uixt.ADBDriver). err = driver.IDriver.(*uixt.ADBDriver).
PressKeyCode(uixt.KeyCode(req.GetKeycode()), uixt.KMEmpty) PressKeyCode(uixt.KeyCode(req.Keycode), uixt.KMEmpty)
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
return return
+15 -16
View File
@@ -7,8 +7,8 @@ import (
) )
// processUnifiedRequest is a helper function to handle common request processing // processUnifiedRequest is a helper function to handle common request processing
func (r *Router) processUnifiedRequest(c *gin.Context, actionType option.ActionMethod) (*option.UnifiedActionRequest, error) { func (r *Router) processUnifiedRequest(c *gin.Context, actionType option.ActionName) (*option.ActionOptions, error) {
var req option.UnifiedActionRequest var req option.ActionOptions
// Bind JSON request // Bind JSON request
if err := c.ShouldBindJSON(&req); err != nil { 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 // 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 == "" { if req.Platform == "" {
req.Platform = c.Param("platform") req.Platform = c.Param("platform")
} }
@@ -49,12 +49,11 @@ func (r *Router) tapHandler(c *gin.Context) {
return return
} }
// Use UnifiedActionRequest directly if req.Duration > 0 {
if req.GetDuration() > 0 { err = driver.Drag(req.X, req.Y, req.X, req.Y,
err = driver.Drag(req.GetX(), req.GetY(), req.GetX(), req.GetY(), option.WithDuration(req.Duration))
option.WithDuration(req.GetDuration()))
} else { } else {
err = driver.TapXY(req.GetX(), req.GetY()) err = driver.TapXY(req.X, req.Y)
} }
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
@@ -74,7 +73,7 @@ func (r *Router) rightClickHandler(c *gin.Context) {
return return
} }
err = driver.IDriver.(*uixt.BrowserDriver). err = driver.IDriver.(*uixt.BrowserDriver).
SecondaryClick(req.GetX(), req.GetY()) SecondaryClick(req.X, req.Y)
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
return return
@@ -117,7 +116,7 @@ func (r *Router) hoverHandler(c *gin.Context) {
} }
err = driver.IDriver.(*uixt.BrowserDriver). err = driver.IDriver.(*uixt.BrowserDriver).
Hover(req.GetX(), req.GetY()) Hover(req.X, req.Y)
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
@@ -139,7 +138,7 @@ func (r *Router) scrollHandler(c *gin.Context) {
} }
err = driver.IDriver.(*uixt.BrowserDriver). err = driver.IDriver.(*uixt.BrowserDriver).
Scroll(req.GetDelta()) Scroll(req.Delta)
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
@@ -159,7 +158,7 @@ func (r *Router) doubleTapHandler(c *gin.Context) {
return return
} }
err = driver.DoubleTap(req.GetX(), req.GetY()) err = driver.DoubleTap(req.X, req.Y)
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
return return
@@ -173,7 +172,7 @@ func (r *Router) dragHandler(c *gin.Context) {
return return
} }
duration := req.GetDuration() duration := req.Duration
if duration == 0 { if duration == 0 {
duration = 1 duration = 1
} }
@@ -182,9 +181,9 @@ func (r *Router) dragHandler(c *gin.Context) {
return 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.WithDuration(duration),
option.WithPressDuration(req.GetPressDuration())) option.WithPressDuration(req.PressDuration))
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
return return
@@ -202,7 +201,7 @@ func (r *Router) inputHandler(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
err = driver.Input(req.Text, option.WithFrequency(req.GetFrequency())) err = driver.Input(req.Text, option.WithFrequency(req.Frequency))
if err != nil { if err != nil {
RenderError(c, err) RenderError(c, err)
return return
+9 -9
View File
@@ -18,17 +18,17 @@ func TestTapHandler(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
path string path string
req option.UnifiedActionRequest req option.ActionOptions
wantStatus int wantStatus int
wantResp HttpResponse wantResp HttpResponse
}{ }{
{ {
name: "tap abs xy", name: "tap abs xy",
path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"), path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"),
req: option.UnifiedActionRequest{ req: option.ActionOptions{
X: &[]float64{500}[0], X: 500.0,
Y: &[]float64{800}[0], Y: 800.0,
Duration: &[]float64{0}[0], Duration: 0,
}, },
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: HttpResponse{ wantResp: HttpResponse{
@@ -40,10 +40,10 @@ func TestTapHandler(t *testing.T) {
{ {
name: "tap relative xy", name: "tap relative xy",
path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"), path: fmt.Sprintf("/api/v1/android/%s/ui/tap", "4622ca24"),
req: option.UnifiedActionRequest{ req: option.ActionOptions{
X: &[]float64{0.5}[0], X: 0.5,
Y: &[]float64{0.6}[0], Y: 0.6,
Duration: &[]float64{0}[0], Duration: 0,
}, },
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: HttpResponse{ wantResp: HttpResponse{
+2 -2
View File
@@ -67,7 +67,7 @@ func (s *StepMobile) Serial(serial string) *StepMobile {
return s 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{ s.obj().Actions = append(s.obj().Actions, uixt.MobileAction{
Method: option.ACTION_LOG, Method: option.ACTION_LOG,
Params: actionName, Params: actionName,
@@ -798,7 +798,7 @@ func runStepMobileUI(s *SessionRunner, step IStep) (stepResult *StepResult, err
// stat uixt action // stat uixt action
if action.Method == option.ACTION_LOG { if action.Method == option.ACTION_LOG {
log.Info().Interface("action", action.Params).Msg("stat uixt action") 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]++ s.summary.Stat.Actions[actionMethod]++
continue continue
} }
+6 -6
View File
@@ -28,7 +28,7 @@ func NewSummary() *Summary {
Success: true, Success: true,
Stat: &Stat{ Stat: &Stat{
TestSteps: TestStepStat{ TestSteps: TestStepStat{
Actions: make(map[option.ActionMethod]int), Actions: make(map[option.ActionName]int),
}, },
}, },
Time: &TestCaseTime{ Time: &TestCaseTime{
@@ -146,10 +146,10 @@ type TestCaseStat struct {
} }
type TestStepStat struct { type TestStepStat struct {
Total int `json:"total" yaml:"total"` Total int `json:"total" yaml:"total"`
Successes int `json:"successes" yaml:"successes"` Successes int `json:"successes" yaml:"successes"`
Failures int `json:"failures" yaml:"failures"` Failures int `json:"failures" yaml:"failures"`
Actions map[option.ActionMethod]int `json:"actions" yaml:"actions"` // record action stats Actions map[option.ActionName]int `json:"actions" yaml:"actions"` // record action stats
} }
type TestCaseTime struct { type TestCaseTime struct {
@@ -167,7 +167,7 @@ func NewCaseSummary() *TestCaseSummary {
return &TestCaseSummary{ return &TestCaseSummary{
Success: true, Success: true,
Stat: &TestStepStat{ Stat: &TestStepStat{
Actions: make(map[option.ActionMethod]int), Actions: make(map[option.ActionName]int),
}, },
Time: &TestCaseTime{ Time: &TestCaseTime{
StartAt: time.Now(), StartAt: time.Now(),
+1 -1
View File
@@ -5,7 +5,7 @@ import (
) )
type MobileAction struct { 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"` Params interface{} `json:"params,omitempty" yaml:"params,omitempty"`
Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"` Options *option.ActionOptions `json:"options,omitempty" yaml:"options,omitempty"`
option.ActionOptions option.ActionOptions
+1 -1
View File
@@ -322,7 +322,7 @@ func compressImageBuffer(raw *bytes.Buffer) (compressed *bytes.Buffer, err error
} }
// MarkUIOperation add operation mark for UI operation // 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 { if actionType == "" || len(actionCoordinates) == 0 {
return nil return nil
} }
+2 -2
View File
@@ -98,7 +98,7 @@ func preHandler_Drag(driver IDriver, options *option.ActionOptions, rawFomX, raw
return fromX, fromY, toX, toY, nil 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) ( options *option.ActionOptions, rawFomX, rawFromY, rawToX, rawToY float64) (
fromX, fromY, toX, toY float64, err error) { 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 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 // save screenshot after action
if options.PostMarkOperation { if options.PostMarkOperation {
// get compressed screenshot buffer // get compressed screenshot buffer
+180 -184
View File
File diff suppressed because it is too large Load Diff
+441 -76
View File
@@ -2,82 +2,87 @@ package option
import ( import (
"context" "context"
"fmt"
"math/rand/v2" "math/rand/v2"
"reflect"
"strings"
"github.com/httprunner/httprunner/v5/internal/builtin" "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" "github.com/rs/zerolog/log"
) )
type ActionMethod string type ActionName string
const ( const (
ACTION_LOG ActionMethod = "log" ACTION_LOG ActionName = "log"
ACTION_ListPackages ActionMethod = "list_packages" ACTION_ListPackages ActionName = "list_packages"
ACTION_AppInstall ActionMethod = "app_install" ACTION_AppInstall ActionName = "app_install"
ACTION_AppUninstall ActionMethod = "app_uninstall" ACTION_AppUninstall ActionName = "app_uninstall"
ACTION_WebLoginNoneUI ActionMethod = "web_login_none_ui" ACTION_WebLoginNoneUI ActionName = "web_login_none_ui"
ACTION_AppClear ActionMethod = "app_clear" ACTION_AppClear ActionName = "app_clear"
ACTION_AppStart ActionMethod = "app_start" ACTION_AppStart ActionName = "app_start"
ACTION_AppLaunch ActionMethod = "app_launch" // 启动 app 并堵塞等待 app 首屏加载完成 ACTION_AppLaunch ActionName = "app_launch" // 启动 app 并堵塞等待 app 首屏加载完成
ACTION_AppTerminate ActionMethod = "app_terminate" ACTION_AppTerminate ActionName = "app_terminate"
ACTION_AppStop ActionMethod = "app_stop" ACTION_AppStop ActionName = "app_stop"
ACTION_ScreenShot ActionMethod = "screenshot" ACTION_ScreenShot ActionName = "screenshot"
ACTION_GetScreenSize ActionMethod = "get_screen_size" ACTION_GetScreenSize ActionName = "get_screen_size"
ACTION_Sleep ActionMethod = "sleep" ACTION_Sleep ActionName = "sleep"
ACTION_SleepMS ActionMethod = "sleep_ms" ACTION_SleepMS ActionName = "sleep_ms"
ACTION_SleepRandom ActionMethod = "sleep_random" ACTION_SleepRandom ActionName = "sleep_random"
ACTION_SetIme ActionMethod = "set_ime" ACTION_SetIme ActionName = "set_ime"
ACTION_GetSource ActionMethod = "get_source" ACTION_GetSource ActionName = "get_source"
ACTION_GetForegroundApp ActionMethod = "get_foreground_app" ACTION_GetForegroundApp ActionName = "get_foreground_app"
// UI handling // UI handling
ACTION_Home ActionMethod = "home" ACTION_Home ActionName = "home"
ACTION_Tap ActionMethod = "tap" // generic tap action ACTION_Tap ActionName = "tap" // generic tap action
ACTION_TapXY ActionMethod = "tap_xy" ACTION_TapXY ActionName = "tap_xy"
ACTION_TapAbsXY ActionMethod = "tap_abs_xy" ACTION_TapAbsXY ActionName = "tap_abs_xy"
ACTION_TapByOCR ActionMethod = "tap_ocr" ACTION_TapByOCR ActionName = "tap_ocr"
ACTION_TapByCV ActionMethod = "tap_cv" ACTION_TapByCV ActionName = "tap_cv"
ACTION_DoubleTap ActionMethod = "double_tap" // generic double tap action ACTION_DoubleTap ActionName = "double_tap" // generic double tap action
ACTION_DoubleTapXY ActionMethod = "double_tap_xy" ACTION_DoubleTapXY ActionName = "double_tap_xy"
ACTION_Swipe ActionMethod = "swipe" // swipe by direction or coordinates ACTION_Swipe ActionName = "swipe" // swipe by direction or coordinates
ACTION_SwipeDirection ActionMethod = "swipe_direction" // swipe by direction (up, down, left, right) ACTION_SwipeDirection ActionName = "swipe_direction" // swipe by direction (up, down, left, right)
ACTION_SwipeCoordinate ActionMethod = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY) ACTION_SwipeCoordinate ActionName = "swipe_coordinate" // swipe by coordinates (fromX, fromY, toX, toY)
ACTION_Drag ActionMethod = "drag" ACTION_Drag ActionName = "drag"
ACTION_Input ActionMethod = "input" ACTION_Input ActionName = "input"
ACTION_PressButton ActionMethod = "press_button" ACTION_PressButton ActionName = "press_button"
ACTION_Back ActionMethod = "back" ACTION_Back ActionName = "back"
ACTION_KeyCode ActionMethod = "keycode" ACTION_KeyCode ActionName = "keycode"
ACTION_Delete ActionMethod = "delete" // delete action ACTION_Delete ActionName = "delete" // delete action
ACTION_Backspace ActionMethod = "backspace" // backspace action ACTION_Backspace ActionName = "backspace" // backspace action
ACTION_AIAction ActionMethod = "ai_action" // action with ai ACTION_AIAction ActionName = "ai_action" // action with ai
ACTION_TapBySelector ActionMethod = "tap_by_selector" ACTION_TapBySelector ActionName = "tap_by_selector"
ACTION_HoverBySelector ActionMethod = "hover_by_selector" ACTION_HoverBySelector ActionName = "hover_by_selector"
ACTION_Hover ActionMethod = "hover" // generic hover action ACTION_Hover ActionName = "hover" // generic hover action
ACTION_RightClick ActionMethod = "right_click" // right click action ACTION_RightClick ActionName = "right_click" // right click action
ACTION_WebCloseTab ActionMethod = "web_close_tab" ACTION_WebCloseTab ActionName = "web_close_tab"
ACTION_SecondaryClick ActionMethod = "secondary_click" ACTION_SecondaryClick ActionName = "secondary_click"
ACTION_SecondaryClickBySelector ActionMethod = "secondary_click_by_selector" ACTION_SecondaryClickBySelector ActionName = "secondary_click_by_selector"
ACTION_GetElementTextBySelector ActionMethod = "get_element_text_by_selector" ACTION_GetElementTextBySelector ActionName = "get_element_text_by_selector"
ACTION_Scroll ActionMethod = "scroll" // scroll action ACTION_Scroll ActionName = "scroll" // scroll action
ACTION_Upload ActionMethod = "upload" // upload action ACTION_Upload ActionName = "upload" // upload action
ACTION_PushMedia ActionMethod = "push_media" // push media action ACTION_PushMedia ActionName = "push_media" // push media action
ACTION_CreateBrowser ActionMethod = "create_browser" // create browser action ACTION_CreateBrowser ActionName = "create_browser" // create browser action
ACTION_AppInfo ActionMethod = "app_info" // get app info action ACTION_AppInfo ActionName = "app_info" // get app info action
// device actions // device actions
ACTION_ListAvailableDevices ActionMethod = "list_available_devices" ACTION_ListAvailableDevices ActionName = "list_available_devices"
ACTION_SelectDevice ActionMethod = "select_device" ACTION_SelectDevice ActionName = "select_device"
// custom actions // custom actions
ACTION_SwipeToTapApp ActionMethod = "swipe_to_tap_app" // swipe left & right to find app and tap ACTION_SwipeToTapApp ActionName = "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_SwipeToTapText ActionName = "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_SwipeToTapTexts ActionName = "swipe_to_tap_texts" // swipe up & down to find text and tap
ACTION_ClosePopups ActionMethod = "close_popups" ACTION_ClosePopups ActionName = "close_popups"
ACTION_EndToEndDelay ActionMethod = "live_e2e" ACTION_EndToEndDelay ActionName = "live_e2e"
ACTION_InstallApp ActionMethod = "install_app" ACTION_InstallApp ActionName = "install_app"
ACTION_UninstallApp ActionMethod = "uninstall_app" ACTION_UninstallApp ActionName = "uninstall_app"
ACTION_DownloadApp ActionMethod = "download_app" ACTION_DownloadApp ActionName = "download_app"
ACTION_Finished ActionMethod = "finished" ACTION_Finished ActionName = "finished"
) )
const ( const (
@@ -99,24 +104,79 @@ const (
) )
type ActionOptions struct { type ActionOptions struct {
Context context.Context `json:"-" yaml:"-"` // Device targeting
// log Platform string `json:"platform,omitempty" yaml:"platform,omitempty" binding:"omitempty" desc:"Device platform: android/ios/browser"`
Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log Serial string `json:"serial,omitempty" yaml:"serial,omitempty" binding:"omitempty" desc:"Device serial/udid/browser id"`
// control related // Common action parameters
MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"` // max retry times 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)"`
Interval float64 `json:"interval,omitempty" yaml:"interval,omitempty"` // interval between retries in seconds 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)"`
Duration float64 `json:"duration,omitempty" yaml:"duration,omitempty"` // used to set duration in seconds FromX float64 `json:"from_x,omitempty" yaml:"from_x,omitempty" binding:"omitempty,min=0" desc:"Starting X coordinate"`
PressDuration float64 `json:"press_duration,omitempty" yaml:"press_duration,omitempty"` // used to set press duration in seconds FromY float64 `json:"from_y,omitempty" yaml:"from_y,omitempty" binding:"omitempty,min=0" desc:"Starting Y coordinate"`
Steps int `json:"steps,omitempty" yaml:"steps,omitempty"` // used to set steps of action ToX float64 `json:"to_x,omitempty" yaml:"to_x,omitempty" binding:"omitempty,min=0" desc:"Ending X coordinate"`
Direction interface{} `json:"direction,omitempty" yaml:"direction,omitempty"` // used by swipe to tap text or app ToY float64 `json:"to_y,omitempty" yaml:"to_y,omitempty" binding:"omitempty,min=0" desc:"Ending Y coordinate"`
Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` // TODO: wait timeout in seconds for mobile action Text string `json:"text,omitempty" yaml:"text,omitempty" desc:"Text content for input/search operations"`
Frequency int `json:"frequency,omitempty" yaml:"frequency,omitempty"`
// 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 ScreenOptions
// set custiom options such as textview, id, description // Custom options
Custom map[string]interface{} `json:"custom,omitempty" yaml:"custom,omitempty"` Custom map[string]interface{} `json:"custom,omitempty" yaml:"custom,omitempty" desc:"Custom options"`
} }
func (o *ActionOptions) Options() []ActionOption { func (o *ActionOptions) Options() []ActionOption {
@@ -433,3 +493,308 @@ func WithIgnoreNotFoundError(ignoreError bool) ActionOption {
o.IgnoreNotFoundError = ignoreError 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
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)
}
-752
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
}
-133
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)
}
+1 -1
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) { 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 { if actionTool == nil {
return mcp.NewToolResultError(fmt.Sprintf("action %s for tool not found", req.Params.Name)), nil return mcp.NewToolResultError(fmt.Sprintf("action %s for tool not found", req.Params.Name)), nil
} }