diff --git a/internal/version/VERSION b/internal/version/VERSION index a94c98fe..7ce94e90 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-beta-2505262310 +v5.0.0-beta-2505262313 diff --git a/uixt/option/migration_summary.md b/uixt/option/migration_summary.md new file mode 100644 index 00000000..8d1c8b69 --- /dev/null +++ b/uixt/option/migration_summary.md @@ -0,0 +1 @@ + diff --git a/uixt/option/unified_request.go b/uixt/option/unified_request.go new file mode 100644 index 00000000..441a8e77 --- /dev/null +++ b/uixt/option/unified_request.go @@ -0,0 +1,350 @@ +package option + +import ( + "context" + "reflect" + "strings" + + "github.com/httprunner/httprunner/v5/uixt/types" + "github.com/mark3labs/mcp-go/mcp" + "github.com/rs/zerolog/log" +) + +// UnifiedActionRequest represents a unified request structure that combines +// ActionOptions with specific action parameters +type UnifiedActionRequest struct { + // Device targeting + Platform string `json:"platform" binding:"required" desc:"Device platform: android/ios/browser"` + Serial string `json:"serial" binding:"required" desc:"Device serial/udid/browser id"` + + // Common action parameters + X *float64 `json:"x,omitempty" desc:"X coordinate (0.0~1.0 for percent, or absolute pixel value)"` + Y *float64 `json:"y,omitempty" desc:"Y coordinate (0.0~1.0 for percent, or absolute pixel value)"` + FromX *float64 `json:"fromX,omitempty" desc:"Starting X coordinate"` + FromY *float64 `json:"fromY,omitempty" desc:"Starting Y coordinate"` + ToX *float64 `json:"toX,omitempty" desc:"Ending X coordinate"` + ToY *float64 `json:"toY,omitempty" 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"` + + // 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"` + + // 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:"maxRetryTimes,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:"pressDuration,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:"tapOffset,omitempty" desc:"Tap offset [x,y]"` + TapRandomRect *bool `json:"tapRandomRect,omitempty" desc:"Tap random point in rectangle"` + SwipeOffset []int `json:"swipeOffset,omitempty" desc:"Swipe offset [fromX,fromY,toX,toY]"` + OffsetRandomRange []int `json:"offsetRandomRange,omitempty" desc:"Random offset range [min,max]"` + Index *int `json:"index,omitempty" desc:"Element index when multiple matches found"` + MatchOne *bool `json:"matchOne,omitempty" desc:"Match only one element"` + IgnoreNotFoundError *bool `json:"ignoreNotFoundError,omitempty" desc:"Ignore error if element not found"` + + // Screenshot options (from ScreenShotOptions) + ScreenShotWithOCR *bool `json:"screenshotWithOCR,omitempty" desc:"Take screenshot with OCR"` + ScreenShotWithUpload *bool `json:"screenshotWithUpload,omitempty" desc:"Upload screenshot"` + ScreenShotWithLiveType *bool `json:"screenshotWithLiveType,omitempty" desc:"Screenshot with live type"` + ScreenShotWithLivePopularity *bool `json:"screenshotWithLivePopularity,omitempty" desc:"Screenshot with live popularity"` + ScreenShotWithUITypes []string `json:"screenshotWithUITypes,omitempty" desc:"Screenshot with UI types"` + ScreenShotWithClosePopups *bool `json:"screenshotWithClosePopups,omitempty" desc:"Close popups before screenshot"` + ScreenShotWithOCRCluster string `json:"screenshotWithOCRCluster,omitempty" desc:"OCR cluster for screenshot"` + ScreenShotFileName string `json:"screenshotFileName,omitempty" desc:"Screenshot file name"` + + // Screen record options (from ScreenRecordOptions) + ScreenRecordDuration *float64 `json:"screenRecordDuration,omitempty" desc:"Screen record duration"` + ScreenRecordWithAudio *bool `json:"screenRecordWithAudio,omitempty" desc:"Record with audio"` + ScreenRecordWithScrcpy *bool `json:"screenRecordWithScrcpy,omitempty" desc:"Use scrcpy for recording"` + ScreenRecordPath string `json:"screenRecordPath,omitempty" desc:"Screen record output path"` + + // Mark operation options (from MarkOperationOptions) + PreMarkOperation *bool `json:"preMarkOperation,omitempty" desc:"Mark operation before action"` + PostMarkOperation *bool `json:"postMarkOperation,omitempty" desc:"Mark operation after action"` + + // Custom options + Custom map[string]interface{} `json:"custom,omitempty" desc:"Custom options"` +} + +// 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 + opts.Scope = r.Scope + opts.AbsScope = r.AbsScope + if r.Regex != nil { + opts.Regex = *r.Regex + } + opts.TapOffset = r.TapOffset + if r.TapRandomRect != nil { + opts.TapRandomRect = *r.TapRandomRect + } + opts.SwipeOffset = r.SwipeOffset + opts.OffsetRandomRange = r.OffsetRandomRange + if r.Index != nil { + opts.Index = *r.Index + } + if r.MatchOne != nil { + opts.MatchOne = *r.MatchOne + } + if r.IgnoreNotFoundError != nil { + opts.IgnoreNotFoundError = *r.IgnoreNotFoundError + } + + // Copy screenshot options + if r.ScreenShotWithOCR != nil { + opts.ScreenShotWithOCR = *r.ScreenShotWithOCR + } + if r.ScreenShotWithUpload != nil { + opts.ScreenShotWithUpload = *r.ScreenShotWithUpload + } + if r.ScreenShotWithLiveType != nil { + opts.ScreenShotWithLiveType = *r.ScreenShotWithLiveType + } + if r.ScreenShotWithLivePopularity != nil { + opts.ScreenShotWithLivePopularity = *r.ScreenShotWithLivePopularity + } + opts.ScreenShotWithUITypes = r.ScreenShotWithUITypes + if r.ScreenShotWithClosePopups != nil { + opts.ScreenShotWithClosePopups = *r.ScreenShotWithClosePopups + } + opts.ScreenShotWithOCRCluster = r.ScreenShotWithOCRCluster + opts.ScreenShotFileName = r.ScreenShotFileName + + // Copy screen record options + if r.ScreenRecordDuration != nil { + opts.ScreenRecordDuration = *r.ScreenRecordDuration + } + if r.ScreenRecordWithAudio != nil { + opts.ScreenRecordWithAudio = *r.ScreenRecordWithAudio + } + if r.ScreenRecordWithScrcpy != nil { + opts.ScreenRecordWithScrcpy = *r.ScreenRecordWithScrcpy + } + opts.ScreenRecordPath = r.ScreenRecordPath + + // Copy mark operation options + if r.PreMarkOperation != nil { + opts.PreMarkOperation = *r.PreMarkOperation + } + if r.PostMarkOperation != nil { + opts.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 +} diff --git a/uixt/option/unified_request_test.go b/uixt/option/unified_request_test.go new file mode 100644 index 00000000..972e2f19 --- /dev/null +++ b/uixt/option/unified_request_test.go @@ -0,0 +1,206 @@ +package option + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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: ®ex, + } + + 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 TestMigrationHelpers(t *testing.T) { + // Test TapRequest migration + oldTapReq := TapRequest{ + TargetDeviceRequest: TargetDeviceRequest{ + Platform: "android", + Serial: "device123", + }, + X: 0.5, + Y: 0.7, + Duration: 1.0, + } + + unifiedReq := MigrateTapRequestToUnified(oldTapReq) + require.NotNil(t, unifiedReq.X) + require.NotNil(t, unifiedReq.Y) + require.NotNil(t, unifiedReq.Duration) + assert.Equal(t, 0.5, *unifiedReq.X) + assert.Equal(t, 0.7, *unifiedReq.Y) + assert.Equal(t, 1.0, *unifiedReq.Duration) + assert.Equal(t, "android", unifiedReq.Platform) + assert.Equal(t, "device123", unifiedReq.Serial) + + // Test SwipeRequest migration + oldSwipeReq := SwipeRequest{ + TargetDeviceRequest: TargetDeviceRequest{ + Platform: "ios", + Serial: "device456", + }, + Direction: "up", + Duration: 2.0, + PressDuration: 0.5, + } + + unifiedSwipeReq := MigrateSwipeRequestToUnified(oldSwipeReq) + require.NotNil(t, unifiedSwipeReq.Duration) + require.NotNil(t, unifiedSwipeReq.PressDuration) + assert.Equal(t, "up", unifiedSwipeReq.Direction) + assert.Equal(t, 2.0, *unifiedSwipeReq.Duration) + assert.Equal(t, 0.5, *unifiedSwipeReq.PressDuration) + assert.Equal(t, "ios", unifiedSwipeReq.Platform) + assert.Equal(t, "device456", unifiedSwipeReq.Serial) + + // Test TapByOCRRequest migration + oldOCRReq := TapByOCRRequest{ + TargetDeviceRequest: TargetDeviceRequest{ + Platform: "android", + Serial: "device789", + }, + Text: "登录", + IgnoreNotFoundError: true, + MaxRetryTimes: 3, + Index: 1, + Regex: true, + TapRandomRect: false, + } + + unifiedOCRReq := MigrateTapByOCRRequestToUnified(oldOCRReq) + require.NotNil(t, unifiedOCRReq.IgnoreNotFoundError) + require.NotNil(t, unifiedOCRReq.MaxRetryTimes) + require.NotNil(t, unifiedOCRReq.Index) + require.NotNil(t, unifiedOCRReq.Regex) + require.NotNil(t, unifiedOCRReq.TapRandomRect) + assert.Equal(t, "登录", unifiedOCRReq.Text) + assert.True(t, *unifiedOCRReq.IgnoreNotFoundError) + assert.Equal(t, 3, *unifiedOCRReq.MaxRetryTimes) + assert.Equal(t, 1, *unifiedOCRReq.Index) + assert.True(t, *unifiedOCRReq.Regex) + assert.False(t, *unifiedOCRReq.TapRandomRect) + assert.Equal(t, "android", unifiedOCRReq.Platform) + assert.Equal(t, "device789", unifiedOCRReq.Serial) +} + +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) +}