From 256ec4baf32012577fc5d63474f62d499454e49a Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 10:46:20 +0800 Subject: [PATCH 01/11] fix: get action log from file --- internal/config/config.go | 2 +- internal/version/VERSION | 2 +- uixt/android_driver_adb.go | 11 ++++++----- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index aba43f25..79afdcf0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,7 @@ const ( CaseFileName = "case.json" // $PWD/results/20060102150405/case.json // mobile device path - DeviceActionLogFilePath = "/sdcard/Android/data/io.appium.uiautomator2.server/files/hodor" + DeviceActionLogFilePath = "/storage/emulated/0/Download/" ) type Config struct { diff --git a/internal/version/VERSION b/internal/version/VERSION index db846138..77429189 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250728 +v5.0.0-250806 diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 0f54b9c3..813d5673 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -24,6 +24,7 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" + "github.com/httprunner/funplugin/myexec" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/utf7" @@ -706,17 +707,17 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { log.Error().Err(err).Msg("failed to close adb log writer") } pointRes := ConvertPoints(ad.Device.Logcat.logs) - - // 没有解析到打点日志,走兜底逻辑 + //将pointRes置为空数组 // 没有解析到打点日志,走兜底逻辑 if len(pointRes) == 0 { log.Info().Msg("action log is null, use action file >>>") actionLogDirPath := config.GetConfig().ActionLogDirPath() - logFilePathPrefix := fmt.Sprintf("%v/data", actionLogDirPath) files := []string{} - ad.Device.RunShellCommand("pull", config.DeviceActionLogFilePath, actionLogDirPath) + actionLogRegStr := `.*data_\d+\.txt` + // ad.Device.RunShellCommand("pull", config.DeviceActionLogFilePath, actionLogDirPath) + myexec.RunCommand("adb", "-s", ad.Device.Serial(), "pull", config.DeviceActionLogFilePath, actionLogDirPath) err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error { // 只是需要日志文件 - if ok := strings.Contains(path, logFilePathPrefix); ok { + if ok, _ := regexp.MatchString(actionLogRegStr, path); ok { files = append(files, path) } return nil From ae837ac8854a9fc96df9d73fc39e45a08da41c84 Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 10:49:22 +0800 Subject: [PATCH 02/11] fix: remove useless code --- uixt/android_driver_adb.go | 1 - 1 file changed, 1 deletion(-) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 813d5673..6aa961b4 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -713,7 +713,6 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { actionLogDirPath := config.GetConfig().ActionLogDirPath() files := []string{} actionLogRegStr := `.*data_\d+\.txt` - // ad.Device.RunShellCommand("pull", config.DeviceActionLogFilePath, actionLogDirPath) myexec.RunCommand("adb", "-s", ad.Device.Serial(), "pull", config.DeviceActionLogFilePath, actionLogDirPath) err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error { // 只是需要日志文件 From 72e0fe795dad53875a4c72eafca3cf6095a2fae0 Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 11:03:30 +0800 Subject: [PATCH 03/11] fix: remove useless note --- uixt/android_driver_adb.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 6aa961b4..290e68a9 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -707,7 +707,7 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { log.Error().Err(err).Msg("failed to close adb log writer") } pointRes := ConvertPoints(ad.Device.Logcat.logs) - //将pointRes置为空数组 // 没有解析到打点日志,走兜底逻辑 + // 没有解析到打点日志,走兜底逻辑 if len(pointRes) == 0 { log.Info().Msg("action log is null, use action file >>>") actionLogDirPath := config.GetConfig().ActionLogDirPath() From 721ed38c4c6f5b20edc93adb66899906ca40c7df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 6 Aug 2025 15:15:28 +0800 Subject: [PATCH 04/11] fix --- .../uitest/android_touch_simulator_test.go | 38 +++ examples/uitest/ios_touch_simulator_test.go | 204 ++++++++++++++ internal/simulation/device_config.go | 10 + internal/version/VERSION | 2 +- uixt/driver.go | 1 + uixt/ios_driver_wda.go | 266 ++++++++++++++++++ uixt/touch_simulator_test.go | 112 ++------ 7 files changed, 549 insertions(+), 84 deletions(-) create mode 100644 examples/uitest/ios_touch_simulator_test.go diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index cec638bb..e2afa7d8 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -602,3 +602,41 @@ func TestStepMultipleSIMActions(t *testing.T) { t.Logf("Successfully executed multiple SIM actions test") } + +func TestStepMultipleSIMAIOSctions(t *testing.T) { + // 创建包含多个SIM操作的测试用例 + testCase := &hrp.TestCase{ + Config: hrp.NewConfig("多个SIM操作组合测试").SetIOS(option.WithUDID("")), + TestSteps: []hrp.IStep{ + hrp.NewStep("组合SIM操作测试"). + Android(). + SIMClickAtPoint(0.3, 0.3). // 点击屏幕中心 + Sleep(1). // 等待1秒 + SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0). // 向上滑动 + Sleep(0.5). // 等待0.5秒 + SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0). // 在区域内向下滑动 + Sleep(0.5). // 等待0.5秒 + SIMSwipeFromPointToPoint(0.1, 0.2, 0.9, 0.5). // 从左到右滑动 + Sleep(0.5). // 等待0.5秒 + SIMInput("测试组合操作 Test Combination 123"), // 仿真输入 + }, + } + + // 运行测试用例 + err := testCase.Dump2JSON("TestStepMultipleSIMActions.json") + if err != nil { + t.Fatalf("Failed to dump test case: %v", err) + } + defer func() { + // 清理生成的文件 + _ = os.Remove("TestStepMultipleSIMActions.json") + }() + + // 执行测试用例 + err = hrp.NewRunner(t).Run(testCase) + if err != nil { + t.Errorf("Test case failed: %v", err) + } + + t.Logf("Successfully executed multiple SIM actions test") +} diff --git a/examples/uitest/ios_touch_simulator_test.go b/examples/uitest/ios_touch_simulator_test.go new file mode 100644 index 00000000..3f280609 --- /dev/null +++ b/examples/uitest/ios_touch_simulator_test.go @@ -0,0 +1,204 @@ +//go:build localtest + +package uitest + +import ( + "os" + "testing" + + hrp "github.com/httprunner/httprunner/v5" + "github.com/httprunner/httprunner/v5/uixt" + "github.com/httprunner/httprunner/v5/uixt/option" +) + +// TestIOSStepMultipleSIMActions tests multiple SIM actions in a step-like manner for iOS +func TestIOSStepMultipleSIMActions(t *testing.T) { + // 创建包含多个 iOS SIM 操作的测试用例 + testCase := &hrp.TestCase{ + Config: hrp.NewConfig("iOS多个SIM操作组合测试").SetIOS(option.WithUDID("")), + TestSteps: []hrp.IStep{ + hrp.NewStep("iOS组合SIM操作测试"). + IOS(). + SIMClickAtPoint(0.5, 0.5). // 点击屏幕中心 + Sleep(1). // 等待1秒 + SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0). // 向上滑动 + Sleep(0.5). // 等待0.5秒 + SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0). // 在区域内向上滑动 + Sleep(0.5). // 等待0.5秒 + SIMSwipeFromPointToPoint(0.1, 0.5, 0.9, 0.5). // 从左到右滑动 + Sleep(0.5). // 等待0.5秒 + SIMInput("iOS测试组合操作 iOS Test Combination 123"), // 仿真输入 + }, + } + + // 运行测试用例 + err := testCase.Dump2JSON("TestIOSStepMultipleSIMActions.json") + if err != nil { + t.Fatalf("Failed to dump test case: %v", err) + } + defer func() { + // 清理生成的文件 + _ = os.Remove("TestIOSStepMultipleSIMActions.json") + }() + + // 执行测试用例 + err = hrp.NewRunner(t).Run(testCase) + if err != nil { + t.Logf("Expected error (no iOS device): %v", err) + // 这是预期的错误,因为没有连接 iOS 设备 + if !containsString(err.Error(), "no attached ios devices") && + !containsString(err.Error(), "device general connection error") { + t.Errorf("Unexpected error: %v", err) + } + } + + t.Logf("Successfully executed multiple iOS SIM actions test (step level)") +} + +// TestIOSDriverDirectSIMFunctions tests iOS SIM functions directly via driver +func TestIOSDriverDirectSIMFunctions(t *testing.T) { + device, err := uixt.NewIOSDevice( + option.WithUDID(""), + ) + if err != nil { + t.Logf("Expected error (no iOS device): %v", err) + // 这是预期的错误,因为没有连接 iOS 设备 + if !containsString(err.Error(), "no attached ios devices") && + !containsString(err.Error(), "device general connection error") { + t.Errorf("Unexpected error: %v", err) + } + return + } + + driver, err := uixt.NewWDADriver(device) + if err != nil { + t.Logf("Expected error (cannot create driver): %v", err) + return + } + defer driver.TearDown() + + // 验证 WDADriver 实现了 SIMSupport 接口 + var iDriver uixt.IDriver = driver + simSupport, ok := iDriver.(uixt.SIMSupport) + if !ok { + t.Errorf("WDADriver does not implement SIMSupport interface") + return + } + _ = simSupport // 避免 unused 警告 + + t.Run("SIMClickAtPoint", func(t *testing.T) { + err := driver.SIMClickAtPoint(0.5, 0.5) + if err != nil { + t.Logf("SIMClickAtPoint error (expected if no device): %v", err) + } else { + t.Logf("Successfully executed SIMClickAtPoint at (0.5, 0.5)") + } + }) + + t.Run("SIMSwipeWithDirection", func(t *testing.T) { + err := driver.SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0) + if err != nil { + t.Logf("SIMSwipeWithDirection error (expected if no device): %v", err) + } else { + t.Logf("Successfully executed SIMSwipeWithDirection") + } + }) + + t.Run("SIMSwipeInArea", func(t *testing.T) { + err := driver.SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0) + if err != nil { + t.Logf("SIMSwipeInArea error (expected if no device): %v", err) + } else { + t.Logf("Successfully executed SIMSwipeInArea") + } + }) + + t.Run("SIMSwipeFromPointToPoint", func(t *testing.T) { + err := driver.SIMSwipeFromPointToPoint(0.1, 0.5, 0.9, 0.5) + if err != nil { + t.Logf("SIMSwipeFromPointToPoint error (expected if no device): %v", err) + } else { + t.Logf("Successfully executed SIMSwipeFromPointToPoint") + } + }) + + t.Run("SIMInput", func(t *testing.T) { + err := driver.SIMInput("iOS测试文本 Test iOS Input 123") + if err != nil { + t.Logf("SIMInput error (expected if no device): %v", err) + } else { + t.Logf("Successfully executed SIMInput") + } + }) +} + +// TestIOSMCPToolsIntegration tests iOS SIM functions via MCP tools (integration test) +func TestIOSMCPToolsIntegration(t *testing.T) { + // 这个测试验证 MCP 工具层是否正确支持 iOS SIM 功能 + device, err := uixt.NewIOSDevice( + option.WithUDID(""), + ) + if err != nil { + t.Logf("Expected error (no iOS device): %v", err) + // 验证错误类型 + if !containsString(err.Error(), "no attached ios devices") && + !containsString(err.Error(), "device general connection error") { + t.Errorf("Unexpected error: %v", err) + } + return + } + + // 需要先创建 WDADriver,然后创建 XTDriver + wdaDriver, err := uixt.NewWDADriver(device) + if err != nil { + t.Logf("Cannot create WDADriver: %v", err) + return + } + defer wdaDriver.TearDown() + + xtDriver, err := uixt.NewXTDriver(wdaDriver) + if err != nil { + t.Logf("Cannot create XTDriver: %v", err) + return + } + + // 验证 XTDriver 的底层驱动实现了 SIMSupport 接口 + if _, ok := xtDriver.IDriver.(uixt.SIMSupport); !ok { + t.Errorf("XTDriver's underlying driver does not implement SIMSupport interface") + return + } + + t.Logf("XTDriver's underlying driver correctly implements SIMSupport interface") + + // 简化测试 - 仅验证接口实现,因为 MCP 服务器的内部结构复杂 + simTools := []option.ActionName{ + option.ACTION_SIMClickAtPoint, + option.ACTION_SIMSwipeDirection, + option.ACTION_SIMSwipeInArea, + option.ACTION_SIMSwipeFromPointToPoint, + option.ACTION_SIMInput, + } + + // 验证这些工具确实存在于系统中 + t.Logf("Verified SIM tools: %v", simTools) + + t.Logf("iOS MCP tools integration test completed - all tools are registered") +} + +// Helper function to check if a string contains a substring +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || + (len(s) > len(substr) && + (s[:len(substr)] == substr || + s[len(s)-len(substr):] == substr || + findSubstring(s, substr)))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/simulation/device_config.go b/internal/simulation/device_config.go index 4197fe88..eb5a0812 100644 --- a/internal/simulation/device_config.go +++ b/internal/simulation/device_config.go @@ -139,6 +139,16 @@ func getDeviceConfig(deviceModel string) DeviceConfig { SizeMax: 225.0, } + // "Google" + case "iphone": + return DeviceConfig{ + DeviceID: 2, + PressureMin: 1, + PressureMax: 1, + SizeMin: 0.03, + SizeMax: 0.04, + } + // Default configuration for unknown devices default: return DeviceConfig{ diff --git a/internal/version/VERSION b/internal/version/VERSION index a36de551..77429189 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250802 +v5.0.0-250806 diff --git a/uixt/driver.go b/uixt/driver.go index dc21be00..ccc7ec7a 100644 --- a/uixt/driver.go +++ b/uixt/driver.go @@ -18,6 +18,7 @@ var ( // Ensure drivers implement SIMSupport interface _ SIMSupport = (*UIA2Driver)(nil) + _ SIMSupport = (*WDADriver)(nil) ) // current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver diff --git a/uixt/ios_driver_wda.go b/uixt/ios_driver_wda.go index 283c5a92..d62a069c 100644 --- a/uixt/ios_driver_wda.go +++ b/uixt/ios_driver_wda.go @@ -24,6 +24,7 @@ import ( "github.com/httprunner/httprunner/v5/internal/builtin" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/json" + "github.com/httprunner/httprunner/v5/internal/simulation" "github.com/httprunner/httprunner/v5/uixt/option" "github.com/httprunner/httprunner/v5/uixt/types" ) @@ -678,6 +679,13 @@ func (wd *WDADriver) TouchByEvents(events []types.TouchEvent, opts ...option.Act x, y = toX, toY } + if x, err = wd.toScale(x); err != nil { + return err + } + if y, err = wd.toScale(y); err != nil { + return err + } + var actionMap map[string]interface{} switch event.Action { @@ -743,6 +751,201 @@ func (wd *WDADriver) TouchByEvents(events []types.TouchEvent, opts ...option.Act return err } +// SIMSwipeWithDirection 向指定方向滑动任意距离 +// direction: 滑动方向 ("up", "down", "left", "right") +// fromX, fromY: 起始坐标 +// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 +func (wd *WDADriver) SIMSwipeWithDirection(direction string, fromX, fromY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error { + absStartX, absStartY, err := convertToAbsolutePoint(wd, fromX, fromY) + if err != nil { + return err + } + // 获取设备型号和配置参数 + deviceModel := "iphone" + deviceParams := simulation.GetRandomDeviceParams(deviceModel) + + log.Info().Str("direction", direction). + Float64("startX", absStartX).Float64("startY", absStartY). + Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance). + Str("deviceModel", deviceModel). + Int("deviceID", deviceParams.DeviceID). + Float64("pressure", deviceParams.Pressure). + Float64("size", deviceParams.Size). + Msg("WDADriver.SIMSwipeWithDirection") + + // 导入滑动仿真库 + simulator := simulation.NewSlideSimulatorAPI(nil) + + // 转换方向字符串为Direction类型 + var slideDirection simulation.Direction + switch direction { + case "up": + slideDirection = simulation.Up + case "down": + slideDirection = simulation.Down + case "left": + slideDirection = simulation.Left + case "right": + slideDirection = simulation.Right + default: + return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction) + } + + // 使用滑动仿真算法生成触摸事件序列 + events, err := simulator.GenerateSlideWithRandomDistance( + absStartX, absStartY, slideDirection, simMinDistance, simMaxDistance, + deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) + if err != nil { + return fmt.Errorf("generate slide events failed: %v", err) + } + + // 执行触摸事件序列 + return wd.TouchByEvents(events, opts...) +} + +// SIMSwipeInArea 在指定区域内向指定方向滑动任意距离 +// direction: 滑动方向 ("up", "down", "left", "right") +// simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY: 区域范围(相对坐标) +// simMinDistance, simMaxDistance: 距离范围,如果相等则为固定距离,否则为随机距离 +func (wd *WDADriver) SIMSwipeInArea(direction string, simAreaStartX, simAreaStartY, simAreaEndX, simAreaEndY, simMinDistance, simMaxDistance float64, opts ...option.ActionOption) error { + // 转换区域坐标为绝对坐标 + absAreaStartX, absAreaStartY, err := convertToAbsolutePoint(wd, simAreaStartX, simAreaStartY) + if err != nil { + return err + } + absAreaEndX, absAreaEndY, err := convertToAbsolutePoint(wd, simAreaEndX, simAreaEndY) + if err != nil { + return err + } + + // 确保区域坐标正确(start应该小于等于end) + if absAreaStartX > absAreaEndX { + absAreaStartX, absAreaEndX = absAreaEndX, absAreaStartX + } + if absAreaStartY > absAreaEndY { + absAreaStartY, absAreaEndY = absAreaEndY, absAreaStartY + } + + // 获取设备型号和配置参数 + deviceModel := "iphone" + deviceParams := simulation.GetRandomDeviceParams(deviceModel) + + log.Info().Str("direction", direction). + Float64("areaStartX", absAreaStartX).Float64("areaStartY", absAreaStartY). + Float64("areaEndX", absAreaEndX).Float64("areaEndY", absAreaEndY). + Float64("minDistance", simMinDistance).Float64("maxDistance", simMaxDistance). + Str("deviceModel", deviceModel). + Int("deviceID", deviceParams.DeviceID). + Float64("pressure", deviceParams.Pressure). + Float64("size", deviceParams.Size). + Msg("WDADriver.SIMSwipeInArea") + + // 导入滑动仿真库 + simulator := simulation.NewSlideSimulatorAPI(nil) + + // 转换方向字符串为Direction类型 + var slideDirection simulation.Direction + switch direction { + case "up": + slideDirection = simulation.Up + case "down": + slideDirection = simulation.Down + case "left": + slideDirection = simulation.Left + case "right": + slideDirection = simulation.Right + default: + return fmt.Errorf("invalid direction: %s, must be one of: up, down, left, right", direction) + } + + // 使用滑动仿真算法生成区域内滑动的触摸事件序列 + events, err := simulator.GenerateSlideInArea( + absAreaStartX, absAreaStartY, absAreaEndX, absAreaEndY, + slideDirection, simMinDistance, simMaxDistance, + deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) + if err != nil { + return fmt.Errorf("generate slide in area events failed: %v", err) + } + + // 执行触摸事件序列 + return wd.TouchByEvents(events, opts...) +} + +// SIMSwipeFromPointToPoint 指定起始点和结束点进行滑动 +// fromX, fromY: 起始坐标(相对坐标) +// toX, toY: 结束坐标(相对坐标) +func (wd *WDADriver) SIMSwipeFromPointToPoint(fromX, fromY, toX, toY float64, opts ...option.ActionOption) error { + // 转换起始点和结束点为绝对坐标 + absStartX, absStartY, err := convertToAbsolutePoint(wd, fromX, fromY) + if err != nil { + return err + } + absEndX, absEndY, err := convertToAbsolutePoint(wd, toX, toY) + if err != nil { + return err + } + + // 获取设备型号和配置参数 + deviceModel := "iphone" + deviceParams := simulation.GetRandomDeviceParams(deviceModel) + + log.Info().Float64("startX", absStartX).Float64("startY", absStartY). + Float64("endX", absEndX).Float64("endY", absEndY). + Str("deviceModel", deviceModel). + Int("deviceID", deviceParams.DeviceID). + Float64("pressure", deviceParams.Pressure). + Float64("size", deviceParams.Size). + Msg("WDADriver.SIMSwipeFromPointToPoint") + + // 导入滑动仿真库 + simulator := simulation.NewSlideSimulatorAPI(nil) + + // 使用滑动仿真算法生成点对点滑动的触摸事件序列 + events, err := simulator.GeneratePointToPointSlideEvents( + absStartX, absStartY, absEndX, absEndY, + deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) + if err != nil { + return fmt.Errorf("generate point to point slide events failed: %v", err) + } + + // 执行触摸事件序列 + return wd.TouchByEvents(events, opts...) +} + +// SIMClickAtPoint 点击相对坐标 +// x, y: 点击坐标(相对坐标) +func (wd *WDADriver) SIMClickAtPoint(x, y float64, opts ...option.ActionOption) error { + // 转换为绝对坐标 + absX, absY, err := convertToAbsolutePoint(wd, x, y) + if err != nil { + return err + } + + // 获取设备型号和配置参数 + deviceModel := "iphone" + deviceParams := simulation.GetRandomDeviceParams(deviceModel) + + log.Info().Float64("x", absX).Float64("y", absY). + Str("deviceModel", deviceModel). + Int("deviceID", deviceParams.DeviceID). + Float64("pressure", deviceParams.Pressure). + Float64("size", deviceParams.Size). + Msg("WDADriver.SIMClickAtPoint") + + // 导入点击仿真库 + clickSimulator := simulation.NewClickSimulatorAPI(nil) + + // 使用点击仿真算法生成触摸事件序列 + events, err := clickSimulator.GenerateClickEvents( + absX, absY, deviceParams.DeviceID, deviceParams.Pressure, deviceParams.Size) + if err != nil { + return fmt.Errorf("generate click events failed: %v", err) + } + + // 执行触摸事件序列 + return wd.TouchByEvents(events, opts...) +} + func (wd *WDADriver) SetPasteboard(contentType types.PasteboardType, content string) (err error) { // [[FBRoute POST:@"/wda/setPasteboard"] respondWithTarget:self action:@selector(handleSetPasteboard:)] data := map[string]interface{}{ @@ -784,6 +987,69 @@ func (wd *WDADriver) Input(text string, opts ...option.ActionOption) (err error) return } +// SIMInput 仿真输入函数,模拟人类分批输入行为 +// 将文本智能分割,英文单词和数字保持完整,中文按1-2个字符分割 +func (wd *WDADriver) SIMInput(text string, opts ...option.ActionOption) error { + log.Info().Str("text", text).Msg("WDADriver.SIMInput") + + if text == "" { + return nil + } + + // 创建输入仿真器(使用默认配置) + inputSimulator := simulation.NewInputSimulatorAPI(nil) + + // 生成输入片段(使用智能分割算法,所有参数使用默认值) + inputReq := simulation.InputRequest{ + Text: text, + // MinSegmentLen, MaxSegmentLen, MinDelayMs, MaxDelayMs 使用默认值 + } + + response := inputSimulator.GenerateInputSegments(inputReq) + if !response.Success { + return fmt.Errorf("failed to generate input segments: %s", response.Message) + } + + log.Info().Int("segments", response.Metrics.TotalSegments). + Int("totalDelayMs", response.Metrics.TotalDelayMs). + Int("estimatedTimeMs", response.Metrics.EstimatedTimeMs). + Msg("Input segments generated") + + // 逐个输入每个片段 + var segmentErrCnt int + for _, segment := range response.Segments { + // 使用Input进行输入(内部已包含Session.POST请求) + segmentErr := wd.Input(segment.Text, opts...) + if segmentErr != nil { + segmentErrCnt++ + log.Info().Err(segmentErr).Int("segmentErrCnt", segmentErrCnt). + Msg("segments err") + } + + log.Debug().Str("segment", segment.Text).Int("index", segment.Index). + Int("charLen", segment.CharLen).Msg("Successfully input segment") + + // 如果有延迟时间,则等待 + if segment.DelayMs > 0 { + time.Sleep(time.Duration(segment.DelayMs) * time.Millisecond) + + log.Debug().Int("delayMs", segment.DelayMs). + Msg("Delay between input segments") + } + } + if segmentErrCnt > 0 { + data := map[string]interface{}{"value": strings.Split(text, "")} + option.MergeOptions(data, opts...) + _, err := wd.Session.POST(data, "/wings/interaction/keys") + return err + } + log.Info().Int("totalSegments", response.Metrics.TotalSegments). + Int("actualDelayMs", response.Metrics.TotalDelayMs). + Msg("SIMInput completed successfully") + + return nil +} + func (wd *WDADriver) Backspace(count int, opts ...option.ActionOption) (err error) { log.Info().Int("count", count).Msg("WDADriver.Backspace") if count == 0 { diff --git a/uixt/touch_simulator_test.go b/uixt/touch_simulator_test.go index 4cdcb453..9cf2c1d9 100644 --- a/uixt/touch_simulator_test.go +++ b/uixt/touch_simulator_test.go @@ -66,7 +66,6 @@ func ParseTouchEvents(data string) ([]types.TouchEvent, error) { if event.Action, err = strconv.Atoi(parts[12]); err != nil { return nil, fmt.Errorf("invalid action: %v", err) } - events = append(events, event) } @@ -74,14 +73,14 @@ func ParseTouchEvents(data string) ([]types.TouchEvent, error) { } func TestAndroidTouchByEvents(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -142,28 +141,10 @@ func TestIOSTouchByEvents(t *testing.T) { driver := setupWDADriverExt(t) // Example touch event data as provided - touchEventData := `1752649131556,401.20703,1191.3164,2,1.0,0.03529412,457.20703,1359.3164,111586196,111586196,1,0,0 -1752649131595,402.913,1185.0792,2,1.0,0.039215688,458.913,1353.0792,111586196,111586236,1,0,2 -1752649131612,410.60825,1164.3806,2,1.0,0.03529412,466.60825,1332.3806,111586196,111586250,1,0,2 -1752649131629,437.7335,1093.1417,2,1.0,0.039215688,493.7335,1261.1417,111586196,111586270,1,0,2 -1752649131646,463.5786,1018.01746,2,1.0,0.039215688,519.5786,1186.0175,111586196,111586287,1,0,2 -1752649131662,487.56482,948.9773,2,1.0,0.03529412,543.5648,1116.9773,111586196,111586304,1,0,2 -1752649131679,511.81476,881.6183,2,1.0,0.039215688,567.81476,1049.6183,111586196,111586320,1,0,2 -1752649131696,543.4369,811.4982,2,1.0,0.03529412,599.4369,979.4982,111586196,111586337,1,0,2 -1752649131713,577.1632,747.4512,2,1.0,0.039215688,633.1632,915.4512,111586196,111586354,1,0,2 -1752649131729,610.1538,691.72034,2,1.0,0.03529412,666.1538,859.72034,111586196,111586370,1,0,2 -1752649131746,639.1683,642.6914,2,1.0,0.03529412,695.1683,810.6914,111586196,111586387,1,0,2 -1752649131763,658.9832,605.90857,2,1.0,0.03529412,714.9832,773.90857,111586196,111586404,1,0,2 -1752649131779,672.21954,581.1634,2,1.0,0.03529412,728.21954,749.1634,111586196,111586420,1,0,2 -1752649131796,680.7687,566.1778,2,1.0,0.03529412,736.7687,734.1778,111586196,111586434,1,0,2 -1752649131814,688.0894,554.2295,2,1.0,0.03529412,744.0894,722.2295,111586196,111586450,1,0,2 -1752649131830,694.542,544.7783,2,1.0,0.03529412,750.542,712.7783,111586196,111586466,1,0,2 -1752649131847,700.60645,537.2637,2,1.0,0.039215688,756.60645,705.2637,111586196,111586483,1,0,2 -1752649131863,705.08887,531.1406,2,1.0,0.039215688,761.08887,699.1406,111586196,111586500,1,0,2 -1752649131880,708.1211,527.8008,2,1.0,0.039215688,764.1211,695.8008,111586196,111586517,1,0,2 -1752649131897,709.43945,524.46094,2,1.0,0.039215688,765.43945,692.46094,111586196,111586533,1,0,2 -1752649131902,709.1758,523.34766,2,1.0,0.03529412,765.1758,691.34766,111586196,111586537,1,33554432,2 -1752649131907,709.1758,523.34766,2,1.0,0.03529412,765.1758,691.34766,111586196,111586546,1,0,1` + touchEventData := `1752649131556,401.20703,1191.3164,2,1.0,0.03529412,400.20703,400.3164,111586196,111586196,1,0,0 +1752649131595,402.913,1185.0792,2,1.0,0.039215688,300.913,300.0792,111586196,111586236,1,0,2 +1752649131612,410.60825,1164.3806,2,1.0,0.03529412,250.60825,250.3806,111586196,111586250,1,0,2 +1752649131907,709.1758,523.34766,2,1.0,0.03529412,200.1758,200.34766,111586196,111586546,1,0,1` // Parse touch events events, err := ParseTouchEvents(touchEventData) @@ -281,14 +262,14 @@ func TestTouchEventSequenceValidation(t *testing.T) { } func TestSwipeWithDirection(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -308,7 +289,7 @@ func TestSwipeWithDirection(t *testing.T) { direction: "up", startX: 0.5, startY: 0.5, - minDistance: 100.0, + minDistance: 500.0, maxDistance: 500.0, }, } @@ -332,50 +313,15 @@ func TestSwipeWithDirection(t *testing.T) { } } -func TestSwipeWithDirectionInvalidInputs(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), - ) - if err != nil { - t.Fatal(err) - } - - driver, err := NewUIA2Driver(device) - if err != nil { - t.Fatal(err) - } - defer driver.TearDown() - - // Test invalid direction - err = driver.SIMSwipeWithDirection("invalid", 500.0, 500.0, 100.0, 200.0) - if err == nil { - t.Error("Expected error for invalid direction, but got none") - } - - // Test invalid distance range (max < min) - err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 200.0, 100.0) - if err == nil { - t.Error("Expected error for invalid distance range, but got none") - } - - // Test zero distance - err = driver.SIMSwipeWithDirection("up", 500.0, 500.0, 0.0, 0.0) - if err == nil { - t.Error("Expected error for zero distance, but got none") - } - - t.Log("Invalid input validation tests passed") -} - func TestSwipeInArea(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -428,14 +374,14 @@ func TestSwipeInArea(t *testing.T) { } func TestSwipeFromPointToPoint(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -477,14 +423,14 @@ func TestSwipeFromPointToPoint(t *testing.T) { } func TestSwipeFromPointToPointInvalidInputs(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -506,14 +452,14 @@ func TestSwipeFromPointToPointInvalidInputs(t *testing.T) { } func TestClickAtPoint(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -546,14 +492,14 @@ func TestClickAtPoint(t *testing.T) { } func TestClickAtPointInvalidInputs(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } @@ -580,14 +526,14 @@ func TestClickAtPointInvalidInputs(t *testing.T) { } func TestSIMInput(t *testing.T) { - device, err := NewAndroidDevice( - option.WithSerialNumber(""), + device, err := NewIOSDevice( + option.WithUDID(""), ) if err != nil { t.Fatal(err) } - driver, err := NewUIA2Driver(device) + driver, err := NewWDADriver(device) if err != nil { t.Fatal(err) } From 62dd8e51c48cda2f0c08e7fa62cbb616ff0f2f2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 6 Aug 2025 15:21:34 +0800 Subject: [PATCH 05/11] fix test --- .../uitest/android_touch_simulator_test.go | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/examples/uitest/android_touch_simulator_test.go b/examples/uitest/android_touch_simulator_test.go index e2afa7d8..cec638bb 100644 --- a/examples/uitest/android_touch_simulator_test.go +++ b/examples/uitest/android_touch_simulator_test.go @@ -602,41 +602,3 @@ func TestStepMultipleSIMActions(t *testing.T) { t.Logf("Successfully executed multiple SIM actions test") } - -func TestStepMultipleSIMAIOSctions(t *testing.T) { - // 创建包含多个SIM操作的测试用例 - testCase := &hrp.TestCase{ - Config: hrp.NewConfig("多个SIM操作组合测试").SetIOS(option.WithUDID("")), - TestSteps: []hrp.IStep{ - hrp.NewStep("组合SIM操作测试"). - Android(). - SIMClickAtPoint(0.3, 0.3). // 点击屏幕中心 - Sleep(1). // 等待1秒 - SIMSwipeWithDirection("up", 0.5, 0.7, 200.0, 400.0). // 向上滑动 - Sleep(0.5). // 等待0.5秒 - SIMSwipeInArea("up", 0.2, 0.2, 0.6, 0.6, 350.0, 500.0). // 在区域内向下滑动 - Sleep(0.5). // 等待0.5秒 - SIMSwipeFromPointToPoint(0.1, 0.2, 0.9, 0.5). // 从左到右滑动 - Sleep(0.5). // 等待0.5秒 - SIMInput("测试组合操作 Test Combination 123"), // 仿真输入 - }, - } - - // 运行测试用例 - err := testCase.Dump2JSON("TestStepMultipleSIMActions.json") - if err != nil { - t.Fatalf("Failed to dump test case: %v", err) - } - defer func() { - // 清理生成的文件 - _ = os.Remove("TestStepMultipleSIMActions.json") - }() - - // 执行测试用例 - err = hrp.NewRunner(t).Run(testCase) - if err != nil { - t.Errorf("Test case failed: %v", err) - } - - t.Logf("Successfully executed multiple SIM actions test") -} From 206d3bc487341f662ca730e981f3b7935a9ba106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=BC=80=E5=85=83?= Date: Wed, 6 Aug 2025 15:27:52 +0800 Subject: [PATCH 06/11] fix android name --- uixt/touch_simulator_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uixt/touch_simulator_test.go b/uixt/touch_simulator_test.go index 9cf2c1d9..8e76c003 100644 --- a/uixt/touch_simulator_test.go +++ b/uixt/touch_simulator_test.go @@ -72,7 +72,7 @@ func ParseTouchEvents(data string) ([]types.TouchEvent, error) { return events, nil } -func TestAndroidTouchByEvents(t *testing.T) { +func TestIOSTouchByEvents(t *testing.T) { device, err := NewIOSDevice( option.WithUDID(""), ) From 42c4ffba8a5fccdc9ad3b081d1659414de33650a Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 15:57:06 +0800 Subject: [PATCH 07/11] fix: adb shell pull --- uixt/android_driver_adb.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 290e68a9..d29ab91c 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -24,7 +24,6 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin/myexec" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/utf7" @@ -708,12 +707,13 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { } pointRes := ConvertPoints(ad.Device.Logcat.logs) // 没有解析到打点日志,走兜底逻辑 + pointRes = []ExportPoint{} if len(pointRes) == 0 { log.Info().Msg("action log is null, use action file >>>") actionLogDirPath := config.GetConfig().ActionLogDirPath() files := []string{} actionLogRegStr := `.*data_\d+\.txt` - myexec.RunCommand("adb", "-s", ad.Device.Serial(), "pull", config.DeviceActionLogFilePath, actionLogDirPath) + ad.Device.PullFolder(config.DeviceActionLogFilePath, actionLogDirPath) err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error { // 只是需要日志文件 if ok, _ := regexp.MatchString(actionLogRegStr, path); ok { From 2e2f1d8b26ca28e1cf86000c4a2c730aba5eec65 Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 19:17:47 +0800 Subject: [PATCH 08/11] fix: pull folder --- uixt/android_driver_adb.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index d29ab91c..290e68a9 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -24,6 +24,7 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" + "github.com/httprunner/funplugin/myexec" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/utf7" @@ -707,13 +708,12 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { } pointRes := ConvertPoints(ad.Device.Logcat.logs) // 没有解析到打点日志,走兜底逻辑 - pointRes = []ExportPoint{} if len(pointRes) == 0 { log.Info().Msg("action log is null, use action file >>>") actionLogDirPath := config.GetConfig().ActionLogDirPath() files := []string{} actionLogRegStr := `.*data_\d+\.txt` - ad.Device.PullFolder(config.DeviceActionLogFilePath, actionLogDirPath) + myexec.RunCommand("adb", "-s", ad.Device.Serial(), "pull", config.DeviceActionLogFilePath, actionLogDirPath) err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error { // 只是需要日志文件 if ok, _ := regexp.MatchString(actionLogRegStr, path); ok { From 2060305808bc883118df3601f89dab9948db4384 Mon Sep 17 00:00:00 2001 From: "huangbin.beal" Date: Wed, 6 Aug 2025 19:39:05 +0800 Subject: [PATCH 09/11] fix: gadb pull --- uixt/android_driver_adb.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/uixt/android_driver_adb.go b/uixt/android_driver_adb.go index 290e68a9..f0670644 100644 --- a/uixt/android_driver_adb.go +++ b/uixt/android_driver_adb.go @@ -24,7 +24,6 @@ import ( "github.com/pkg/errors" "github.com/rs/zerolog/log" - "github.com/httprunner/funplugin/myexec" "github.com/httprunner/httprunner/v5/code" "github.com/httprunner/httprunner/v5/internal/config" "github.com/httprunner/httprunner/v5/internal/utf7" @@ -713,7 +712,7 @@ func (ad *ADBDriver) StopCaptureLog() (result interface{}, err error) { actionLogDirPath := config.GetConfig().ActionLogDirPath() files := []string{} actionLogRegStr := `.*data_\d+\.txt` - myexec.RunCommand("adb", "-s", ad.Device.Serial(), "pull", config.DeviceActionLogFilePath, actionLogDirPath) + ad.Device.PullFolder(config.DeviceActionLogFilePath, actionLogDirPath) err = filepath.Walk(actionLogDirPath, func(path string, info fs.FileInfo, err error) error { // 只是需要日志文件 if ok, _ := regexp.MatchString(actionLogRegStr, path); ok { From a921e7b7c2b623776d8bc4085c414fd9b70e1738 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Fri, 8 Aug 2025 17:23:49 +0800 Subject: [PATCH 10/11] fix: unittest --- internal/version/VERSION | 2 +- uixt/touch_simulator_test.go | 36 ------------------------------------ 2 files changed, 1 insertion(+), 37 deletions(-) diff --git a/internal/version/VERSION b/internal/version/VERSION index 77429189..3c159c22 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250806 +v5.0.0-250808 diff --git a/uixt/touch_simulator_test.go b/uixt/touch_simulator_test.go index 8e76c003..33109710 100644 --- a/uixt/touch_simulator_test.go +++ b/uixt/touch_simulator_test.go @@ -137,42 +137,6 @@ func TestIOSTouchByEvents(t *testing.T) { t.Logf("Successfully executed touch events: %d events processed", len(events)) } -func TestIOSTouchByEvents(t *testing.T) { - driver := setupWDADriverExt(t) - - // Example touch event data as provided - touchEventData := `1752649131556,401.20703,1191.3164,2,1.0,0.03529412,400.20703,400.3164,111586196,111586196,1,0,0 -1752649131595,402.913,1185.0792,2,1.0,0.039215688,300.913,300.0792,111586196,111586236,1,0,2 -1752649131612,410.60825,1164.3806,2,1.0,0.03529412,250.60825,250.3806,111586196,111586250,1,0,2 -1752649131907,709.1758,523.34766,2,1.0,0.03529412,200.1758,200.34766,111586196,111586546,1,0,1` - - // Parse touch events - events, err := ParseTouchEvents(touchEventData) - if err != nil { - t.Fatalf("ParseTouchEvents failed: %v", err) - } - - // Check first event - firstEvent := events[0] - if firstEvent.Action != 0 { // ACTION_DOWN - t.Errorf("Expected first event action to be 0 (ACTION_DOWN), got %d", firstEvent.Action) - } - - // Check last event - lastEvent := events[len(events)-1] - if lastEvent.Action != 1 { // ACTION_UP - t.Errorf("Expected last event action to be 1 (ACTION_UP), got %d", lastEvent.Action) - } - - // Use TouchByEvents with parsed events - err = driver.IDriver.(*WDADriver).TouchByEvents(events) - if err != nil { - t.Fatalf("TouchByEvents failed: %v", err) - } - - t.Logf("Successfully executed touch events: %d events processed", len(events)) -} - func TestTouchEventParsing(t *testing.T) { // Test single touch event parsing singleEventData := "1752646457403,456.78418,1574.0195,7,1.0,0.016666668,504.78418,1721.0195,924451292,924451292,1,0,0" From 56ba52ed31693a9c60ee76caf41e4b8d10163119 Mon Sep 17 00:00:00 2001 From: "lilong.129" Date: Sat, 9 Aug 2025 09:44:05 +0800 Subject: [PATCH 11/11] feat: enhance sleep functionality with start time support --- internal/version/VERSION | 2 +- uixt/driver_utils.go | 5 +- uixt/mcp_tools_utility.go | 87 +++++++++--- uixt/mcp_tools_utility_test.go | 240 +++++++++++++++++++++++++++++++++ 4 files changed, 309 insertions(+), 25 deletions(-) create mode 100644 uixt/mcp_tools_utility_test.go diff --git a/internal/version/VERSION b/internal/version/VERSION index 3c159c22..799592cc 100644 --- a/internal/version/VERSION +++ b/internal/version/VERSION @@ -1 +1 @@ -v5.0.0-250808 +v5.0.0-250809 diff --git a/uixt/driver_utils.go b/uixt/driver_utils.go index 7538297e..b643d582 100644 --- a/uixt/driver_utils.go +++ b/uixt/driver_utils.go @@ -284,8 +284,9 @@ func getSimulationDuration(params []float64) (milliseconds int64) { return 0 } -// sleepStrict sleeps strict duration with given params -// startTime is used to correct sleep duration caused by process time +// sleepStrict sleeps for strict duration with optional start time correction +// If startTime is zero, acts as normal context-aware sleep +// If startTime is provided, corrects sleep duration by subtracting elapsed time // ctx allows for cancellation during sleep func sleepStrict(ctx context.Context, startTime time.Time, strictMilliseconds int64) { var elapsed int64 diff --git a/uixt/mcp_tools_utility.go b/uixt/mcp_tools_utility.go index 4515edf4..79f49bac 100644 --- a/uixt/mcp_tools_utility.go +++ b/uixt/mcp_tools_utility.go @@ -15,7 +15,29 @@ import ( "github.com/httprunner/httprunner/v5/uixt/option" ) -// ToolSleep implements the sleep tool call. +// extractStartTimeMs extracts start_time_ms from MCP request arguments +// Returns time.Time (zero if not provided) and any conversion error +func extractStartTimeMs(request mcp.CallToolRequest) (time.Time, error) { + startTimeMs, ok := request.GetArguments()["start_time_ms"] + if !ok || startTimeMs == nil { + return time.Time{}, nil // Return zero time for normal sleep + } + + var ms int64 + switch v := startTimeMs.(type) { + case float64: + ms = int64(v) + case int64: + ms = v + case int: + ms = int64(v) + default: + return time.Time{}, fmt.Errorf("invalid start_time_ms type: %T", v) + } + + return time.UnixMilli(ms), nil +} + type ToolSleep struct { // Return data fields - these define the structure of data returned by this tool Seconds float64 `json:"seconds" desc:"Duration in seconds that was slept"` @@ -33,6 +55,7 @@ func (t *ToolSleep) Description() string { func (t *ToolSleep) Options() []mcp.ToolOption { return []mcp.ToolOption{ mcp.WithNumber("seconds", mcp.Description("Number of seconds to sleep")), + mcp.WithNumber("start_time_ms", mcp.Description("Start time as Unix milliseconds for strict sleep calculation")), } } @@ -70,16 +93,15 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc { return nil, fmt.Errorf("unsupported sleep duration type: %T", v) } - // Use context-aware sleep instead of blocking time.Sleep - select { - case <-time.After(duration): - // Normal completion - case <-ctx.Done(): - // Interrupted by context cancellation (interrupt signal, timeout, time limit) - log.Info().Msg("sleep interrupted by context cancellation") - // Don't return error - let the upper layer handle timeout/time limit logic + // Extract start_time_ms and use sleepStrict for unified sleep logic + startTime, err := extractStartTimeMs(request) + if err != nil { + return nil, err } + milliseconds := int64(actualSeconds * 1000) + sleepStrict(ctx, startTime, milliseconds) + message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds) returnData := ToolSleep{ Seconds: actualSeconds, @@ -91,9 +113,24 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc { } func (t *ToolSleep) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) { - arguments := map[string]any{ - "seconds": action.Params, + arguments := map[string]any{} + + var seconds float64 + if param, ok := action.Params.(json.Number); ok { + seconds, _ = param.Float64() + arguments["seconds"] = seconds + } else if param, ok := action.Params.(int64); ok { + seconds = float64(param) + arguments["seconds"] = seconds + } else if sleepConfig, ok := action.Params.(SleepConfig); ok { + // When startTime is provided, pass both seconds and startTime + seconds = sleepConfig.Seconds + arguments["seconds"] = seconds + arguments["start_time_ms"] = sleepConfig.StartTime.UnixMilli() + } else { + return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep params: %v", action.Params) } + return BuildMCPCallToolRequest(t.Name(), arguments, action), nil } @@ -115,6 +152,7 @@ func (t *ToolSleepMS) Description() string { func (t *ToolSleepMS) Options() []mcp.ToolOption { return []mcp.ToolOption{ mcp.WithNumber("milliseconds", mcp.Description("Number of milliseconds to sleep")), + mcp.WithNumber("start_time_ms", mcp.Description("Start time as Unix milliseconds for strict sleep calculation")), } } @@ -152,16 +190,14 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc { return nil, fmt.Errorf("unsupported sleep duration type: %T", v) } - // Use context-aware sleep instead of blocking time.Sleep - select { - case <-time.After(duration): - // Normal completion - case <-ctx.Done(): - // Interrupted by context cancellation (interrupt signal, timeout, time limit) - log.Info().Msg("sleep interrupted by context cancellation") - // Don't return error - let the upper layer handle timeout/time limit logic + // Extract start_time_ms and use sleepStrict for unified sleep logic + startTime, err := extractStartTimeMs(request) + if err != nil { + return nil, err } + sleepStrict(ctx, startTime, actualMilliseconds) + message := fmt.Sprintf("Successfully slept for %d milliseconds", actualMilliseconds) returnData := ToolSleepMS{ Milliseconds: actualMilliseconds, @@ -173,17 +209,24 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc { } func (t *ToolSleepMS) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) { + arguments := map[string]any{} + var milliseconds int64 if param, ok := action.Params.(json.Number); ok { milliseconds, _ = param.Int64() + arguments["milliseconds"] = milliseconds } else if param, ok := action.Params.(int64); ok { milliseconds = param + arguments["milliseconds"] = milliseconds + } else if sleepConfig, ok := action.Params.(SleepConfig); ok { + // When startTime is provided, pass both milliseconds and startTime + milliseconds = sleepConfig.Milliseconds + arguments["milliseconds"] = milliseconds + arguments["start_time_ms"] = sleepConfig.StartTime.UnixMilli() } else { return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep ms params: %v", action.Params) } - arguments := map[string]any{ - "milliseconds": milliseconds, - } + return BuildMCPCallToolRequest(t.Name(), arguments, action), nil } diff --git a/uixt/mcp_tools_utility_test.go b/uixt/mcp_tools_utility_test.go new file mode 100644 index 00000000..9e53ac2c --- /dev/null +++ b/uixt/mcp_tools_utility_test.go @@ -0,0 +1,240 @@ +package uixt + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/httprunner/httprunner/v5/uixt/option" +) + +func TestToolSleep_ConvertActionToCallToolRequest(t *testing.T) { + tool := &ToolSleep{} + + tests := []struct { + name string + action option.MobileAction + expectedArgs map[string]any + shouldError bool + }{ + { + name: "json.Number parameter", + action: option.MobileAction{ + Method: option.ACTION_Sleep, + Params: json.Number("3.5"), + }, + expectedArgs: map[string]any{"seconds": float64(3.5)}, + shouldError: false, + }, + { + name: "int64 parameter", + action: option.MobileAction{ + Method: option.ACTION_Sleep, + Params: int64(5), + }, + expectedArgs: map[string]any{"seconds": float64(5)}, + shouldError: false, + }, + { + name: "SleepConfig with startTime", + action: option.MobileAction{ + Method: option.ACTION_Sleep, + Params: SleepConfig{ + StartTime: time.UnixMilli(1691234567890), + Seconds: 2.5, + }, + }, + expectedArgs: map[string]any{ + "seconds": 2.5, + "start_time_ms": int64(1691234567890), + }, + shouldError: false, + }, + { + name: "invalid parameter type", + action: option.MobileAction{ + Method: option.ACTION_Sleep, + Params: "invalid", + }, + expectedArgs: nil, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request, err := tool.ConvertActionToCallToolRequest(tt.action) + + if tt.shouldError { + assert.Error(t, err) + } else { + require.NoError(t, err) + args := request.GetArguments() + for key, expectedValue := range tt.expectedArgs { + assert.Equal(t, expectedValue, args[key], "Argument %s mismatch", key) + } + } + }) + } +} + +func TestToolSleepMS_ConvertActionToCallToolRequest(t *testing.T) { + tool := &ToolSleepMS{} + + tests := []struct { + name string + action option.MobileAction + expectedArgs map[string]any + shouldError bool + }{ + { + name: "json.Number parameter", + action: option.MobileAction{ + Method: option.ACTION_SleepMS, + Params: json.Number("1500"), + }, + expectedArgs: map[string]any{"milliseconds": int64(1500)}, + shouldError: false, + }, + { + name: "int64 parameter", + action: option.MobileAction{ + Method: option.ACTION_SleepMS, + Params: int64(2000), + }, + expectedArgs: map[string]any{"milliseconds": int64(2000)}, + shouldError: false, + }, + { + name: "SleepConfig with startTime", + action: option.MobileAction{ + Method: option.ACTION_SleepMS, + Params: SleepConfig{ + StartTime: time.UnixMilli(1691234567890), + Milliseconds: 3000, + }, + }, + expectedArgs: map[string]any{ + "milliseconds": int64(3000), + "start_time_ms": int64(1691234567890), + }, + shouldError: false, + }, + { + name: "invalid parameter type", + action: option.MobileAction{ + Method: option.ACTION_SleepMS, + Params: "invalid", + }, + expectedArgs: nil, + shouldError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request, err := tool.ConvertActionToCallToolRequest(tt.action) + + if tt.shouldError { + assert.Error(t, err) + } else { + require.NoError(t, err) + args := request.GetArguments() + for key, expectedValue := range tt.expectedArgs { + assert.Equal(t, expectedValue, args[key], "Argument %s mismatch", key) + } + } + }) + } +} + +func TestSleepStrictTiming(t *testing.T) { + // Test that strict sleep properly adjusts for elapsed time + startTime := time.Now() + + // Simulate some processing time + time.Sleep(50 * time.Millisecond) + + ctx := context.Background() + + // Test sleepStrict with the start time + testStart := time.Now() + sleepStrict(ctx, startTime, 200) // 200ms total duration + actualElapsed := time.Since(testStart) + + // Should sleep approximately 150ms (200ms - 50ms already elapsed) + // Allow some tolerance for timing variations + expectedSleep := 150 * time.Millisecond + assert.Greater(t, actualElapsed, expectedSleep/2, "Sleep too short") + assert.Less(t, actualElapsed, expectedSleep*2, "Sleep too long") +} + +func TestSleepCancellation(t *testing.T) { + // Test that sleep respects context cancellation + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel after 50ms + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + start := time.Now() + sleepStrict(ctx, time.Time{}, 500) // Try to sleep 500ms + elapsed := time.Since(start) + + // Should be cancelled after ~50ms, not sleep full 500ms + assert.Less(t, elapsed, 200*time.Millisecond, "Sleep was not properly cancelled") +} + +func TestSleepStrictWithZeroTime(t *testing.T) { + // Test sleepStrict behaves like normal sleep when startTime is zero + ctx := context.Background() + + start := time.Now() + sleepStrict(ctx, time.Time{}, 100) // 100ms, no start time + elapsed := time.Since(start) + + // Should sleep full duration + expectedSleep := 100 * time.Millisecond + assert.Greater(t, elapsed, expectedSleep/2, "Sleep too short") + assert.Less(t, elapsed, expectedSleep*2, "Sleep too long") +} + +func TestSleepStrictWithPastStartTime(t *testing.T) { + // Test sleepStrict skips sleep when elapsed time exceeds duration + startTime := time.Now().Add(-300 * time.Millisecond) // 300ms ago + ctx := context.Background() + + start := time.Now() + sleepStrict(ctx, startTime, 200) // Want 200ms total, but 300ms already elapsed + elapsed := time.Since(start) + + // Should skip sleep entirely + assert.Less(t, elapsed, 50*time.Millisecond, "Should have skipped sleep") +} + +func TestJsonNumberHandling(t *testing.T) { + // Test that json.Number is correctly handled in different scenarios + + // Test float json.Number + floatNumber := json.Number("3.14") + floatVal, err := floatNumber.Float64() + assert.NoError(t, err) + assert.Equal(t, 3.14, floatVal) + + // Test int json.Number + intNumber := json.Number("1500") + intVal, err := intNumber.Int64() + assert.NoError(t, err) + assert.Equal(t, int64(1500), intVal) + + // Test invalid json.Number + invalidNumber := json.Number("invalid") + _, err = invalidNumber.Float64() + assert.Error(t, err) +}