diff --git a/CLAUDE.md b/CLAUDE.md index 2e996b2d..3453d11b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,10 @@ The framework supports both Go and Python plugins: - Internal utilities in `internal/` - Examples in `examples/` +### Code Standards +- All code comments must be written in English +- All documentation must be written in Chinese + ### Dependencies - Go 1.23+ required - Uses Cobra for CLI @@ -126,7 +130,3 @@ The framework supports both Go and Python plugins: - Static linking for deployment - Version info embedded via ldflags - Cross-platform builds supported - -### Code Standards -- All code comments must be written in English -- All documentation must be written in Chinese diff --git a/runner_uixt.go b/runner_uixt.go index 61968309..d036f955 100644 --- a/runner_uixt.go +++ b/runner_uixt.go @@ -35,24 +35,31 @@ type UIXTRunner struct { } type UIXTConfig struct { - uixt.DriverCacheConfig + uixt.DriverCacheConfig // includes Platform, Serial, AIOptions - Ctx context.Context - Cancel context.CancelFunc - JSONCase ITestCase - UIA2 bool // UIAutomator2(Android) - LogOn bool // 开启打点日志 + // Runtime context + Ctx context.Context + Cancel context.CancelFunc `json:"-"` + + // Test case configuration + JSONCase ITestCase + + // Device specific options + UIA2 bool // UIAutomator2(Android) + LogOn bool // 开启打点日志 + WDAPort int // iOS WebDriverAgent port + WDAMjpegPort int // iOS WebDriverAgent MJPEG port + + // Agent behavior configuration Timeout int // seconds AbortErrors []error // abort errors MaxRestartAppCount int // max app restart count MaxRetryCount int // max retry count - WDAPort int - WDAMjpegPort int - - OSType string // platform - Serial string - LLMService option.LLMServiceType // LLM 服务类型 + // Backward compatibility fields - legacy API support + OSType string // deprecated: use Platform from DriverCacheConfig + Serial string // deprecated: use Serial from DriverCacheConfig + LLMService option.LLMServiceType // deprecated: use AIOptions from DriverCacheConfig } const ( @@ -83,7 +90,7 @@ func NewUIXTRunner(configs *UIXTConfig) (runner *UIXTRunner, err error) { } config.SetAIOptions(configs.AIOptions...) - switch configs.OSType { + switch configs.Platform { case "ios": port, err := configs.getWDALocalPort(configs.Serial) if err != nil { @@ -123,7 +130,7 @@ func NewUIXTRunner(configs *UIXTConfig) (runner *UIXTRunner, err error) { ) default: // default to android - configs.OSType = "android" + configs.Platform = "android" config.SetAndroid( option.WithSerialNumber(configs.Serial), option.WithUIA2(configs.UIA2), @@ -144,11 +151,10 @@ func NewUIXTRunner(configs *UIXTConfig) (runner *UIXTRunner, err error) { } sessionRunner := caseRunner.NewSession() - driverCacheConfig := uixt.DriverCacheConfig{ - Platform: configs.OSType, - Serial: configs.Serial, - AIOptions: config.AIOptions.Options(), - } + // Use configs directly as it inherits DriverCacheConfig + driverCacheConfig := configs.DriverCacheConfig + driverCacheConfig.AIOptions = config.AIOptions.Options() + dExt, err := uixt.GetOrCreateXTDriver(driverCacheConfig) if err != nil { return nil, errors.Wrap(err, "get driver failed") @@ -181,6 +187,19 @@ func NewUIXTRunner(configs *UIXTConfig) (runner *UIXTRunner, err error) { } func (configs *UIXTConfig) addDefault() { + // Handle backward compatibility - sync legacy fields to embedded DriverCacheConfig + if configs.OSType != "" && configs.Platform == "" { + configs.Platform = configs.OSType + } + if configs.Serial != "" && configs.DriverCacheConfig.Serial == "" { + configs.DriverCacheConfig.Serial = configs.Serial + } + if configs.LLMService != "" && len(configs.AIOptions) == 0 { + configs.AIOptions = []option.AIServiceOption{ + option.WithLLMService(configs.LLMService), + } + } + if configs.Ctx == nil { configs.Ctx = context.Background() } diff --git a/uixt/ai/wings_service.go b/uixt/ai/wings_service.go index 4424f98a..ad67cf29 100644 --- a/uixt/ai/wings_service.go +++ b/uixt/ai/wings_service.go @@ -472,7 +472,10 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ defer resp.Body.Close() logID := resp.Header.Get("X-Tt-Logid") - log.Info().Str("step_text", request.StepText).Str("image_url", request.DeviceInfos[0].NowImageUrl).Str("log_id", logID).Str("biz_id", request.BizId).Str("url", w.apiURL).Msg("call wings api") + log.Info().Str("step_text", request.StepText). + Str("image_url", request.DeviceInfos[0].NowImageUrl). + Str("log_id", logID).Str("biz_id", request.BizId). + Str("url", w.apiURL).Msg("call wings api") // Read response body responseBody, err := io.ReadAll(resp.Body) diff --git a/uixt/mcp_server_test.go b/uixt/mcp_server_test.go index 45212116..5464d7e9 100644 --- a/uixt/mcp_server_test.go +++ b/uixt/mcp_server_test.go @@ -1851,3 +1851,149 @@ func TestNewMCPErrorResponse(t *testing.T) { result := NewMCPErrorResponse("Test error message") assert.NotNil(t, result) } + +// TestParseActionOptions tests core functionality of parseActionOptions function +func TestParseActionOptions(t *testing.T) { + testCases := []struct { + name string + arguments map[string]any + expectErr bool + validate func(t *testing.T, opts *option.ActionOptions) + }{ + { + name: "empty_arguments", + arguments: map[string]any{}, + expectErr: false, + validate: func(t *testing.T, opts *option.ActionOptions) { + assert.Equal(t, "", opts.Platform) + assert.Equal(t, "", opts.Serial) + assert.Equal(t, 0.0, opts.X) + assert.Equal(t, 0.0, opts.Y) + }, + }, + { + name: "basic_fields", + arguments: map[string]any{ + "platform": "android", + "serial": "device123", + "x": 100.5, + "y": 200.7, + "text": "Hello World", + }, + expectErr: false, + validate: func(t *testing.T, opts *option.ActionOptions) { + assert.Equal(t, "android", opts.Platform) + assert.Equal(t, "device123", opts.Serial) + assert.Equal(t, 100.5, opts.X) + assert.Equal(t, 200.7, opts.Y) + assert.Equal(t, "Hello World", opts.Text) + }, + }, + { + name: "complete_nested_fields", + arguments: map[string]any{ + "platform": "ios", + "serial": "ios_device", + "screenshot_with_ocr": true, + "screenshot_with_upload": true, + "screenshot_with_live_type": true, + "screenshot_with_live_popularity": true, + "screenshot_with_base64": true, + "screenshot_with_ui_types": []string{"button", "input", "text"}, + "screenshot_with_close_popups": true, + "screenshot_with_ocr_cluster": "test_cluster", + "screenshot_file_name": "test.png", + "screenrecord_duration": 30.5, + "screenrecord_with_audio": true, + "screenrecord_with_scrcpy": true, + "screenrecord_path": "/tmp/record.mp4", + "scope": []float64{0.1, 0.2, 0.9, 0.8}, + "abs_scope": []int{100, 200, 900, 800}, + "regex": true, + "offset": []int{5, 10}, + "tap_random_rect": true, + "swipe_offset": []int{1, 2, 3, 4}, + "offset_random_range": []int{-5, 5}, + "index": 2, + "match_one": true, + "ignore_NotFoundError": true, + "pre_mark_operation": true, + "post_mark_operation": false, + "max_retry_times": 5, + "timeout": 30, + "custom": map[string]any{ + "test_key": "test_value", + "nested_data": map[string]any{"key": "value"}, + }, + }, + expectErr: false, + validate: func(t *testing.T, opts *option.ActionOptions) { + assert.Equal(t, "ios", opts.Platform) + assert.Equal(t, "ios_device", opts.Serial) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithOCR) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithUpload) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithLiveType) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithLivePopularity) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithBase64) + assert.Equal(t, []string{"button", "input", "text"}, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithUITypes) + assert.True(t, opts.ScreenOptions.ScreenShotOptions.ScreenShotWithClosePopups) + assert.Equal(t, "test_cluster", opts.ScreenOptions.ScreenShotOptions.ScreenShotWithOCRCluster) + assert.Equal(t, "test.png", opts.ScreenOptions.ScreenShotOptions.ScreenShotFileName) + assert.Equal(t, 30.5, opts.ScreenOptions.ScreenRecordOptions.ScreenRecordDuration) + assert.True(t, opts.ScreenOptions.ScreenRecordOptions.ScreenRecordWithAudio) + assert.True(t, opts.ScreenOptions.ScreenRecordOptions.ScreenRecordWithScrcpy) + assert.Equal(t, "/tmp/record.mp4", opts.ScreenOptions.ScreenRecordOptions.ScreenRecordPath) + assert.Equal(t, []float64{0.1, 0.2, 0.9, 0.8}, []float64(opts.ScreenOptions.ScreenFilterOptions.Scope)) + assert.Equal(t, []int{100, 200, 900, 800}, []int(opts.ScreenOptions.ScreenFilterOptions.AbsScope)) + assert.True(t, opts.ScreenOptions.ScreenFilterOptions.Regex) + assert.Equal(t, []int{5, 10}, opts.ScreenOptions.ScreenFilterOptions.TapOffset) + assert.True(t, opts.ScreenOptions.ScreenFilterOptions.TapRandomRect) + assert.Equal(t, []int{1, 2, 3, 4}, opts.ScreenOptions.ScreenFilterOptions.SwipeOffset) + assert.Equal(t, []int{-5, 5}, opts.ScreenOptions.ScreenFilterOptions.OffsetRandomRange) + assert.Equal(t, 2, opts.ScreenOptions.ScreenFilterOptions.Index) + assert.True(t, opts.ScreenOptions.ScreenFilterOptions.MatchOne) + assert.True(t, opts.ScreenOptions.ScreenFilterOptions.IgnoreNotFoundError) + assert.True(t, opts.ScreenOptions.MarkOperationOptions.PreMarkOperation) + assert.False(t, opts.ScreenOptions.MarkOperationOptions.PostMarkOperation) + assert.Equal(t, 5, opts.MaxRetryTimes) + assert.Equal(t, 30, opts.Timeout) + assert.Equal(t, "test_value", opts.Custom["test_key"]) + nestedData, ok := opts.Custom["nested_data"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "value", nestedData["key"]) + }, + }, + { + name: "error_case_non_serializable", + arguments: map[string]any{ + "platform": "android", + "invalid": make(chan int), + }, + expectErr: true, + }, + { + name: "error_case_invalid_type", + arguments: map[string]any{ + "x": "not_a_number", + }, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, err := parseActionOptions(tc.arguments) + + if tc.expectErr { + assert.Error(t, err) + assert.Nil(t, result) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + if tc.validate != nil { + tc.validate(t, result) + } + } + }) + } +} diff --git a/uixt/sdk.go b/uixt/sdk.go index dc16a67a..73fe27ef 100644 --- a/uixt/sdk.go +++ b/uixt/sdk.go @@ -24,6 +24,7 @@ func NewXTDriver(driver IDriver, opts ...option.AIServiceOption) (*XTDriver, err services: services, loadedMCPClients: make(map[string]client.MCPClient), } + log.Info().Interface("services", services).Msg("init XTDriver with AI services") var err error @@ -32,17 +33,21 @@ func NewXTDriver(driver IDriver, opts ...option.AIServiceOption) (*XTDriver, err // Use advanced LLM service configuration if provided driverExt.LLMService, err = ai.NewLLMServiceWithOptionConfig(services.LLMConfig) if err != nil { - log.Warn().Err(err).Msg("init llm service with config failed") + log.Warn().Err(err).Interface("service", services.LLMConfig). + Msg("init llm service with advanced config failed") } else { - log.Info().Msg("LLM service initialized with advanced config") + log.Info().Interface("service", services.LLMConfig). + Msg("LLM service initialized with advanced config") } } else if services.LLMService != "" { // Use simple LLM service configuration if provided driverExt.LLMService, err = ai.NewLLMService(services.LLMService) if err != nil { - log.Warn().Err(err).Msg("init llm service failed") + log.Warn().Err(err).Str("service", string(services.LLMService)). + Msg("init llm service with simple config failed") } else { - log.Info().Msg("LLM service initialized with simple config") + log.Info().Str("service", string(services.LLMService)). + Msg("LLM service initialized with simple config") } } else { // Use Wings service as fallback @@ -50,7 +55,7 @@ func NewXTDriver(driver IDriver, opts ...option.AIServiceOption) (*XTDriver, err if err != nil { log.Warn().Err(err).Msg("init Wings service failed") } else { - log.Info().Msg("Wings service initialized") + log.Info().Msg("Wings service initialized as fallback") } }