Merge branch 'master' into session_refactor

This commit is contained in:
余泓铮
2025-08-15 11:21:52 +08:00
68 changed files with 2027 additions and 1531 deletions

View File

@@ -1,35 +1,19 @@
//go:build localtest
package ai
import (
"context"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/httprunner/httprunner/v5/internal/builtin"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// hasRequiredEnvVars checks if the required environment variables are set for testing
func hasRequiredEnvVars() bool {
// Check for OpenAI environment variables
if os.Getenv("OPENAI_BASE_URL") != "" && os.Getenv("OPENAI_API_KEY") != "" {
return true
}
// Check for GPT-4O specific environment variables
if os.Getenv("OPENAI_GPT_4O_BASE_URL") != "" && os.Getenv("OPENAI_GPT_4O_API_KEY") != "" {
return true
}
return false
}
func TestILLMServiceQuery(t *testing.T) {
// Skip test if required environment variables are not set
if !hasRequiredEnvVars() {
t.Skip("Skipping test: required environment variables not set")
}
// Create LLM service
service, err := NewLLMService(option.OPENAI_GPT_4O)
require.NoError(t, err)
@@ -96,11 +80,6 @@ func TestILLMServiceQuery(t *testing.T) {
}
func TestILLMServiceIntegration(t *testing.T) {
// Skip test if required environment variables are not set
if !hasRequiredEnvVars() {
t.Skip("Skipping test: required environment variables not set")
}
// Create LLM service
service, err := NewLLMService(option.OPENAI_GPT_4O)
require.NoError(t, err)

View File

@@ -482,9 +482,10 @@ func (w *WingsService) callWingsAPI(ctx context.Context, request WingsActionRequ
return nil, errors.Wrap(err, "HTTP request failed")
}
defer resp.Body.Close()
// resp X-Tt-Logid
logID := resp.Header.Get("X-Tt-Logid")
log.Info().Str("step_text", request.StepText).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)
if err != nil {

View File

@@ -706,17 +706,16 @@ 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)
// 没有解析到打点日志,走兜底逻辑
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.PullFolder(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

View File

@@ -18,6 +18,7 @@ var (
// Ensure drivers implement SIMSupport interface
_ SIMSupport = (*UIA2Driver)(nil)
_ SIMSupport = (*WDADriver)(nil)
)
// current implemeted driver: ADBDriver, UIA2Driver, WDADriver, HDCDriver

View File

@@ -100,8 +100,7 @@ func (dExt *XTDriver) SwipeToTapTexts(texts []string, opts ...option.ActionOptio
}
log.Info().Strs("texts", texts).Msg("swipe to tap texts")
opts = append(opts, option.WithMatchOne(true), option.WithRegex(true), option.WithInterval(1))
opts = append([]option.ActionOption{option.WithMatchOne(true), option.WithRegex(true), option.WithInterval(1)}, opts...)
// Remove identifier for swipe operations to avoid WDA/UIA2 logging
actionOptions := option.NewActionOptions(opts...)
actionOptions.Identifier = ""

View File

@@ -270,8 +270,10 @@ func (s *DriverSession) Request(method string, urlStr string, rawBody []byte, op
logger = log.Debug().Bool("success", true)
}
logger = logger.Str("logid", logid).Str("request_method", method).Str("request_url", rawURL).
Str("request_body", string(rawBody))
logger = logger.Str("logid", logid).Str("request_method", method).Str("request_url", rawURL)
if len(rawBody) < 1024 {
logger = logger.Str("request_body", string(rawBody))
}
if !driverResult.RequestTime.IsZero() {
logger = logger.Int64("request_time", driverResult.RequestTime.UnixMilli())
}

View File

@@ -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

116
uixt/image_utils.go Normal file
View File

@@ -0,0 +1,116 @@
package uixt
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/rs/zerolog/log"
)
// DetectAndRenameImageFile examines the file content to determine its media type
// and renames the file with the appropriate extension (.jpg, .png, .mp4, etc.)
func DetectAndRenameMediaFile(filePath string) (string, error) {
// Open the file
file, err := os.Open(filePath)
if err != nil {
return "", fmt.Errorf("failed to open file for type detection: %v", err)
}
defer file.Close()
// Read the first 512 bytes to detect content type
buffer := make([]byte, 512)
_, err = file.Read(buffer)
if err != nil && err != io.EOF {
return "", fmt.Errorf("failed to read file for type detection: %v", err)
}
// Reset file pointer
_, err = file.Seek(0, 0)
if err != nil {
return "", fmt.Errorf("failed to reset file pointer: %v", err)
}
// Detect content type
contentType := http.DetectContentType(buffer)
log.Info().Str("filePath", filePath).Str("contentType", contentType).Msg("Detected content type")
// Determine file extension based on content type
var extension string
switch {
// Image types
case strings.Contains(contentType, "image/jpeg"):
extension = ".jpg"
case strings.Contains(contentType, "image/png"):
extension = ".png"
case strings.Contains(contentType, "image/gif"):
extension = ".gif"
case strings.Contains(contentType, "image/webp"):
extension = ".webp"
case strings.Contains(contentType, "image/bmp"):
extension = ".bmp"
case strings.Contains(contentType, "image/tiff"):
extension = ".tiff"
case strings.Contains(contentType, "image/svg+xml"):
extension = ".svg"
// Video types
case strings.Contains(contentType, "video/mp4"):
extension = ".mp4"
case strings.Contains(contentType, "video/quicktime"):
extension = ".mov"
case strings.Contains(contentType, "video/x-msvideo"):
extension = ".avi"
case strings.Contains(contentType, "video/x-ms-wmv"):
extension = ".wmv"
case strings.Contains(contentType, "video/x-flv"):
extension = ".flv"
case strings.Contains(contentType, "video/webm"):
extension = ".webm"
case strings.Contains(contentType, "video/x-matroska"):
extension = ".mkv"
default:
// Check for general image or video types
if strings.Contains(contentType, "image/") {
extension = ".jpg" // Default for unknown image types
} else if strings.Contains(contentType, "video/") {
extension = ".mp4" // Default for unknown video types
} else {
// Try to determine from original file extension
origExt := strings.ToLower(filepath.Ext(filePath))
if origExt == ".mp4" || origExt == ".mov" || origExt == ".avi" ||
origExt == ".wmv" || origExt == ".flv" || origExt == ".webm" || origExt == ".mkv" {
extension = origExt
} else if origExt == ".jpg" || origExt == ".jpeg" || origExt == ".png" ||
origExt == ".gif" || origExt == ".webp" || origExt == ".bmp" ||
origExt == ".tiff" || origExt == ".svg" {
extension = origExt
} else {
return filePath, fmt.Errorf("not a recognized media type: %s", contentType)
}
}
}
// Create new file path with extension
dir := filepath.Dir(filePath)
base := filepath.Base(filePath)
newFilePath := filepath.Join(dir, base+extension)
// If the file already has the correct extension, just return it
if filePath == newFilePath {
return filePath, nil
}
// Rename the file
err = os.Rename(filePath, newFilePath)
if err != nil {
return "", fmt.Errorf("failed to rename file: %v", err)
}
log.Info().Str("oldPath", filePath).Str("newPath", newFilePath).Msg("Renamed image file with proper extension")
return newFilePath, nil
}

View File

@@ -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"
)
@@ -692,6 +693,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 {
@@ -757,6 +765,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{}{
@@ -798,6 +1001,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 {

View File

@@ -133,6 +133,10 @@ func (s *MCPServer4XTDriver) registerTools() {
s.registerTool(&ToolGetScreenSize{})
s.registerTool(&ToolGetSource{})
// Media Album Tools
s.registerTool(&ToolPushAlbums{})
s.registerTool(&ToolClearAlbums{})
// Utility Tools
s.registerTool(&ToolSleep{})
s.registerTool(&ToolSleepMS{})
@@ -297,7 +301,7 @@ func extractActionOptionsToArguments(actionOptions []option.ActionOption, argume
// Add tap/swipe offset options
if len(tempOptions.TapOffset) == 2 {
arguments["tap_offset"] = tempOptions.TapOffset
arguments["offset"] = tempOptions.TapOffset
}
if len(tempOptions.SwipeOffset) == 4 {
arguments["swipe_offset"] = tempOptions.SwipeOffset

View File

@@ -169,27 +169,106 @@ func TestIgnoreNotFoundErrorOption(t *testing.T) {
func TestExtractActionOptionsToArguments(t *testing.T) {
// Test the extractActionOptionsToArguments helper function
actionOptions := []option.ActionOption{
// Boolean options
option.WithIgnoreNotFoundError(true),
option.WithMaxRetryTimes(3),
option.WithIndex(2),
option.WithRegex(true),
option.WithTapRandomRect(false), // false should not be included
option.WithDuration(1.5),
option.WithAntiRisk(true),
option.WithPreMarkOperation(true),
option.WithResetHistory(true),
option.WithMatchOne(true),
// Numeric options
option.WithMaxRetryTimes(3),
option.WithIndex(2),
option.WithInterval(1.5),
option.WithSteps(10),
option.WithTimeout(30),
option.WithFrequency(5),
option.WithDuration(2.0),
option.WithPressDuration(1.5),
// Offset options (including the fixed offset field)
option.WithTapOffset(-300, 0),
option.WithSwipeOffset(1, 2, 3, 4),
option.WithOffsetRandomRange(-5, 5),
// Scope options
option.WithScope(0.1, 0.2, 0.9, 0.8),
option.WithAbsScope(100, 200, 900, 800),
// Screenshot options
option.WithScreenShotOCR(true),
option.WithScreenShotUpload(true),
option.WithScreenShotLiveType(true),
option.WithScreenShotLivePopularity(true),
option.WithScreenShotClosePopups(true),
option.WithScreenOCRCluster("test_cluster"),
option.WithScreenShotFileName("test.png"),
option.WithScreenShotUITypes("button", "input"),
// Direction option
option.WithDirection("up"),
// Identifier
option.WithIdentifier("test_id"),
}
arguments := make(map[string]any)
extractActionOptionsToArguments(actionOptions, arguments)
// Verify extracted options
// Verify boolean options (only true values should be included)
assert.Equal(t, true, arguments["ignore_NotFoundError"], "ignore_NotFoundError should be extracted")
assert.Equal(t, 3, arguments["max_retry_times"], "max_retry_times should be extracted")
assert.Equal(t, 2, arguments["index"], "index should be extracted")
assert.Equal(t, true, arguments["regex"], "regex should be extracted")
assert.Equal(t, 1.5, arguments["duration"], "duration should be extracted")
assert.Equal(t, true, arguments["anti_risk"], "anti_risk should be extracted")
assert.Equal(t, true, arguments["pre_mark_operation"], "pre_mark_operation should be extracted")
assert.Equal(t, true, arguments["reset_history"], "reset_history should be extracted")
assert.Equal(t, true, arguments["match_one"], "match_one should be extracted")
// tap_random_rect should not be included since it's false
_, exists := arguments["tap_random_rect"]
assert.False(t, exists, "tap_random_rect should not be included when false")
// Verify numeric options
assert.Equal(t, 3, arguments["max_retry_times"], "max_retry_times should be extracted")
assert.Equal(t, 2, arguments["index"], "index should be extracted")
assert.Equal(t, 1.5, arguments["interval"], "interval should be extracted")
assert.Equal(t, 10, arguments["steps"], "steps should be extracted")
assert.Equal(t, 30, arguments["timeout"], "timeout should be extracted")
assert.Equal(t, 5, arguments["frequency"], "frequency should be extracted")
assert.Equal(t, 2.0, arguments["duration"], "duration should be extracted")
assert.Equal(t, 1.5, arguments["press_duration"], "press_duration should be extracted")
// Verify offset options (including the critical 'offset' field that was fixed)
assert.Equal(t, []int{-300, 0}, arguments["offset"], "offset should be extracted (not tap_offset)")
assert.Equal(t, []int{1, 2, 3, 4}, arguments["swipe_offset"], "swipe_offset should be extracted")
assert.Equal(t, []int{-5, 5}, arguments["offset_random_range"], "offset_random_range should be extracted")
// Verify scope options (these are custom types, not raw slices)
assert.Equal(t, option.Scope([]float64{0.1, 0.2, 0.9, 0.8}), arguments["scope"], "scope should be extracted")
assert.Equal(t, option.AbsScope([]int{100, 200, 900, 800}), arguments["abs_scope"], "abs_scope should be extracted")
// Verify screenshot options
assert.Equal(t, true, arguments["screenshot_with_ocr"], "screenshot_with_ocr should be extracted")
assert.Equal(t, true, arguments["screenshot_with_upload"], "screenshot_with_upload should be extracted")
assert.Equal(t, true, arguments["screenshot_with_live_type"], "screenshot_with_live_type should be extracted")
assert.Equal(t, true, arguments["screenshot_with_live_popularity"], "screenshot_with_live_popularity should be extracted")
assert.Equal(t, true, arguments["screenshot_with_close_popups"], "screenshot_with_close_popups should be extracted")
assert.Equal(t, "test_cluster", arguments["screenshot_with_ocr_cluster"], "screenshot_with_ocr_cluster should be extracted")
assert.Equal(t, "test.png", arguments["screenshot_file_name"], "screenshot_file_name should be extracted")
assert.Equal(t, []string{"button", "input"}, arguments["screenshot_with_ui_types"], "screenshot_with_ui_types should be extracted")
// Verify identifier and direction (only fields that exist)
assert.Equal(t, "test_id", arguments["identifier"], "identifier should be extracted")
assert.Equal(t, "up", arguments["direction"], "direction should be extracted")
// Verify the critical fix: ensure "offset" is used instead of "tap_offset"
_, hasTapOffset := arguments["tap_offset"]
assert.False(t, hasTapOffset, "Should NOT contain 'tap_offset' field")
_, hasOffset := arguments["offset"]
assert.True(t, hasOffset, "Should contain 'offset' field")
t.Logf("Extracted %d arguments from ActionOptions", len(arguments))
}
// TestToolListAvailableDevices tests the ToolListAvailableDevices implementation

View File

@@ -3,6 +3,9 @@ package uixt
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/danielpaulus/go-ios/ios"
"github.com/mark3labs/mcp-go/mcp"
@@ -216,3 +219,198 @@ func (t *ToolScreenRecord) Implement() server.ToolHandlerFunc {
func (t *ToolScreenRecord) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
return BuildMCPCallToolRequest(t.Name(), map[string]any{}, action), nil
}
// ToolPushAlbums implements the push_albums tool call.
type ToolPushAlbums struct {
// Return data fields - these define the structure of data returned by this tool
FilePath string `json:"filePath" desc:"Path of the file that was pushed"`
FileUrl string `json:"fileUrl,omitempty" desc:"URL of the file that was downloaded and pushed (if applicable)"`
FileType string `json:"fileType" desc:"Type of the file that was pushed (image or video)"`
Cleared bool `json:"cleared,omitempty" desc:"Whether albums were cleared before pushing (if applicable)"`
}
func (t *ToolPushAlbums) Name() option.ActionName {
return option.ACTION_PushAlbums
}
func (t *ToolPushAlbums) Description() string {
return "Push a media file (image or video) to the device's gallery. For Android, this will push the file to the DCIM/Camera directory. For iOS, this will add the file to the photo album."
}
func (t *ToolPushAlbums) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithString("platform", mcp.Enum("android", "ios"), mcp.Description("The platform type of device to push media to")),
mcp.WithString("serial", mcp.Description("The device serial number or UDID")),
mcp.WithString("filePath", mcp.Description("Path to the local media file to push to the device")),
mcp.WithString("fileUrl", mcp.Description("URL of the media file to download and push to the device")),
mcp.WithBoolean("cleanup", mcp.Description("Whether to delete the downloaded file after pushing it to the device")),
mcp.WithBoolean("clearBefore", mcp.Description("Whether to clear albums before pushing (if applicable)")),
}
}
func (t *ToolPushAlbums) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
arguments := map[string]any{}
// Handle string param as fileUrl
if fileUrl, ok := action.Params.(string); ok && fileUrl != "" {
arguments["fileUrl"] = fileUrl
}
// Handle map params with fileUrl or filePath
if params, ok := action.Params.(map[string]interface{}); ok {
if fileUrl, ok := params["fileUrl"].(string); ok && fileUrl != "" {
arguments["fileUrl"] = fileUrl
}
if filePath, ok := params["filePath"].(string); ok && filePath != "" {
arguments["filePath"] = filePath
}
if cleanup, ok := params["cleanup"].(bool); ok {
arguments["cleanup"] = cleanup
}
if clearBefore, ok := params["clearBefore"].(bool); ok {
arguments["clearBefore"] = clearBefore
}
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
func (t *ToolPushAlbums) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
driverExt, err := setupXTDriver(ctx, request.GetArguments())
if err != nil {
return nil, err
}
// Get file path or URL
filePath, hasPath := request.GetArguments()["filePath"].(string)
fileUrl, hasUrl := request.GetArguments()["fileUrl"].(string)
cleanup, _ := request.GetArguments()["cleanup"].(bool)
clearBefore, _ := request.GetArguments()["clearBefore"].(bool)
// Check if we have either path or URL
if (!hasPath || filePath == "") && (!hasUrl || fileUrl == "") {
return nil, fmt.Errorf("either filePath or fileUrl is required")
}
// If we have a URL, download it
downloadedFile := false
fileType := "image" // Default file type
if hasUrl && fileUrl != "" {
log.Info().Str("fileUrl", fileUrl).Msg("Downloading media file from URL")
downloadedPath, err := DownloadFileByUrl(fileUrl)
if err != nil {
return nil, fmt.Errorf("failed to download media file from URL: %v", err)
}
// Detect file type and rename with proper extension
renamedPath, err := DetectAndRenameMediaFile(downloadedPath)
if err != nil {
log.Warn().Err(err).Str("path", downloadedPath).Msg("Failed to detect file type or rename file, using original file")
filePath = downloadedPath
} else {
filePath = renamedPath
// Determine if it's a video based on extension
ext := strings.ToLower(filepath.Ext(renamedPath))
if ext == ".mp4" || ext == ".mov" || ext == ".avi" || ext == ".wmv" || ext == ".flv" || ext == ".webm" || ext == ".mkv" {
fileType = "video"
}
}
downloadedFile = true
}
// Clear albums before pushing if requested
cleared := false
if clearBefore {
log.Info().Msg("Clearing albums before pushing new media file")
err := driverExt.IDriver.ClearImages()
if err != nil {
log.Warn().Err(err).Msg("Failed to clear albums before pushing, continuing anyway")
} else {
cleared = true
}
}
// Push the file to the device
err = driverExt.IDriver.PushImage(filePath)
if err != nil {
// If we downloaded the file and failed to push it, clean up
if downloadedFile && cleanup {
_ = os.Remove(filePath)
}
return nil, err
}
// Clean up downloaded file if requested
if downloadedFile && cleanup {
log.Info().Str("filePath", filePath).Msg("Cleaning up downloaded media file")
_ = os.Remove(filePath)
}
message := fmt.Sprintf("Successfully pushed %s to device", fileType)
returnData := ToolPushAlbums{
FilePath: filePath,
FileType: fileType,
Cleared: cleared,
}
// Include URL in response if it was used
if hasUrl && fileUrl != "" {
returnData.FileUrl = fileUrl
message = fmt.Sprintf("Successfully downloaded and pushed %s from %s to device", fileType, fileUrl)
}
// Add cleared info to message if applicable
if cleared {
message = fmt.Sprintf("%s (albums cleared before pushing)", message)
}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
// Old ToolPushImage implementation has been removed as part of the refactoring to ToolPushAlbums
// ToolClearAlbums implements the clear_albums tool call.
type ToolClearAlbums struct {
// Return data fields - these define the structure of data returned by this tool
Cleared bool `json:"cleared" desc:"Whether albums were cleared successfully"`
}
func (t *ToolClearAlbums) Name() option.ActionName {
return option.ACTION_ClearAlbums
}
func (t *ToolClearAlbums) Description() string {
return "Clear media files (images and videos) from the device's gallery. For Android, this will clear media from the DCIM/Camera directory. For iOS, this will clear media from the device's photo album."
}
func (t *ToolClearAlbums) Options() []mcp.ToolOption {
return []mcp.ToolOption{
mcp.WithString("platform", mcp.Enum("android", "ios"), mcp.Description("The platform type of device to clear media from")),
mcp.WithString("serial", mcp.Description("The device serial number or UDID")),
}
}
func (t *ToolClearAlbums) Implement() server.ToolHandlerFunc {
return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
driverExt, err := setupXTDriver(ctx, request.GetArguments())
if err != nil {
return nil, err
}
err = driverExt.IDriver.ClearImages()
if err != nil {
return nil, err
}
message := "Successfully cleared media files from device"
returnData := ToolClearAlbums{Cleared: true}
return NewMCPSuccessResponse(message, &returnData), nil
}
}
func (t *ToolClearAlbums) ConvertActionToCallToolRequest(action option.MobileAction) (mcp.CallToolRequest, error) {
return BuildMCPCallToolRequest(t.Name(), map[string]any{}, action), nil
}

View File

@@ -2,9 +2,7 @@ package uixt
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/mark3labs/mcp-go/mcp"
@@ -15,7 +13,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 +53,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")),
}
}
@@ -47,38 +68,21 @@ func (t *ToolSleep) Implement() server.ToolHandlerFunc {
// Sleep action logic
log.Info().Interface("seconds", seconds).Msg("sleeping")
var duration time.Duration
var actualSeconds float64
switch v := seconds.(type) {
case float64:
actualSeconds = v
duration = time.Duration(v*1000) * time.Millisecond
case int:
actualSeconds = float64(v)
duration = time.Duration(v) * time.Second
case int64:
actualSeconds = float64(v)
duration = time.Duration(v) * time.Second
case string:
s, err := builtin.ConvertToFloat64(v)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", v)
}
actualSeconds = s
duration = time.Duration(s*1000) * time.Millisecond
default:
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
// Use Interface2Float64 for unified type conversion
actualSeconds, err := builtin.Interface2Float64(seconds)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", seconds)
}
duration := time.Duration(actualSeconds) * time.Second
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
// 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
}
milliseconds := int64(actualSeconds * 1000)
sleepStrict(ctx, startTime, milliseconds)
message := fmt.Sprintf("Successfully slept for %v seconds", actualSeconds)
returnData := ToolSleep{
@@ -91,9 +95,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 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 {
// Use builtin.Interface2Float64 for unified parameter handling
var err error
seconds, err = builtin.Interface2Float64(action.Params)
if err != nil {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep params: %v", action.Params)
}
arguments["seconds"] = seconds
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}
@@ -115,6 +134,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")),
}
}
@@ -129,38 +149,21 @@ func (t *ToolSleepMS) Implement() server.ToolHandlerFunc {
// Sleep MS action logic
log.Info().Interface("milliseconds", milliseconds).Msg("sleeping in milliseconds")
var duration time.Duration
var actualMilliseconds int64
switch v := milliseconds.(type) {
case float64:
actualMilliseconds = int64(v)
duration = time.Duration(v) * time.Millisecond
case int:
actualMilliseconds = int64(v)
duration = time.Duration(v) * time.Millisecond
case int64:
actualMilliseconds = v
duration = time.Duration(v) * time.Millisecond
case string:
ms, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", v)
}
actualMilliseconds = ms
duration = time.Duration(ms) * time.Millisecond
default:
return nil, fmt.Errorf("unsupported sleep duration type: %T", v)
// Use Interface2Float64 for unified type conversion, then convert to int64
floatVal, err := builtin.Interface2Float64(milliseconds)
if err != nil {
return nil, fmt.Errorf("invalid sleep duration: %v", milliseconds)
}
actualMilliseconds := int64(floatVal)
duration := time.Duration(actualMilliseconds) * time.Millisecond
// Extract start_time_ms and use sleepStrict for unified sleep logic
startTime, err := extractStartTimeMs(request)
if err != nil {
return nil, err
}
// 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
}
sleepStrict(ctx, startTime, actualMilliseconds)
message := fmt.Sprintf("Successfully slept for %d milliseconds", actualMilliseconds)
returnData := ToolSleepMS{
@@ -173,17 +176,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()
} else if param, ok := action.Params.(int64); ok {
milliseconds = param
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,
// Use builtin.Interface2Float64 for unified parameter handling, then convert to int64
floatVal, err := builtin.Interface2Float64(action.Params)
if err != nil {
return mcp.CallToolRequest{}, fmt.Errorf("invalid sleep ms params: %v", action.Params)
}
milliseconds = int64(floatVal)
arguments["milliseconds"] = milliseconds
}
return BuildMCPCallToolRequest(t.Name(), arguments, action), nil
}

View File

@@ -0,0 +1,285 @@
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: "float64 parameter",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: float64(5.2),
},
expectedArgs: map[string]any{"seconds": float64(5.2)},
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,
},
{
name: "json.Number with integer value",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: json.Number("10"),
},
expectedArgs: map[string]any{"seconds": float64(10)},
shouldError: false,
},
{
name: "json.Number with decimal value",
action: option.MobileAction{
Method: option.ACTION_Sleep,
Params: json.Number("1.25"),
},
expectedArgs: map[string]any{"seconds": float64(1.25)},
shouldError: false,
},
}
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: "float64 parameter",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: float64(2500.7),
},
expectedArgs: map[string]any{"milliseconds": int64(2500)},
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: "json.Number with decimal value",
action: option.MobileAction{
Method: option.ACTION_SleepMS,
Params: json.Number("1234.56"),
},
expectedArgs: map[string]any{"milliseconds": int64(1234)},
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)
}

View File

@@ -98,6 +98,10 @@ const (
ACTION_ListAvailableDevices ActionName = "list_available_devices"
ACTION_SelectDevice ActionName = "select_device"
// album actions (images and videos)
ACTION_PushAlbums ActionName = "push_albums"
ACTION_ClearAlbums ActionName = "clear_albums"
// custom actions
ACTION_SwipeToTapApp ActionName = "swipe_to_tap_app" // swipe left & right to find app and tap
ACTION_SwipeToTapText ActionName = "swipe_to_tap_text" // swipe up & down to find text and tap

View File

@@ -66,22 +66,21 @@ 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)
}
return events, nil
}
func TestAndroidTouchByEvents(t *testing.T) {
device, err := NewAndroidDevice(
option.WithSerialNumber(""),
func TestIOSTouchByEvents(t *testing.T) {
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)
}
@@ -138,60 +137,6 @@ func TestAndroidTouchByEvents(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,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`
// 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"
@@ -281,14 +226,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 +253,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 +277,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 +338,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 +387,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 +416,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 +456,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 +490,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)
}