diff --git a/hrp/config.go b/hrp/config.go index 1884368b..99ebcbad 100644 --- a/hrp/config.go +++ b/hrp/config.go @@ -5,6 +5,7 @@ import ( "time" "github.com/httprunner/httprunner/v4/hrp/internal/builtin" + "github.com/httprunner/httprunner/v4/hrp/internal/uixt" ) // NewConfig returns a new constructed testcase config with specified testcase name. @@ -29,7 +30,8 @@ type TConfig struct { ParametersSetting *TParamsConfig `json:"parameters_setting,omitempty" yaml:"parameters_setting,omitempty"` ThinkTimeSetting *ThinkTimeConfig `json:"think_time,omitempty" yaml:"think_time,omitempty"` WebSocketSetting *WebSocketConfig `json:"websocket,omitempty" yaml:"websocket,omitempty"` - IOS []*WDAOptions `json:"ios,omitempty" yaml:"ios,omitempty"` + IOS []*uixt.WDAOptions `json:"ios,omitempty" yaml:"ios,omitempty"` + Android []*uixt.UIAOptions `json:"android,omitempty" yaml:"android,omitempty"` Timeout float64 `json:"timeout,omitempty" yaml:"timeout,omitempty"` // global timeout in seconds Export []string `json:"export,omitempty" yaml:"export,omitempty"` Weight int `json:"weight,omitempty" yaml:"weight,omitempty"` @@ -100,8 +102,8 @@ func (c *TConfig) SetWebSocket(times, interval, timeout, size int64) *TConfig { return c } -func (c *TConfig) SetIOS(options ...WDAOption) *TConfig { - wdaOptions := &WDAOptions{} +func (c *TConfig) SetIOS(options ...uixt.WDAOption) *TConfig { + wdaOptions := &uixt.WDAOptions{} for _, option := range options { option(wdaOptions) } diff --git a/hrp/internal/uixt/android.go b/hrp/internal/uixt/android.go new file mode 100644 index 00000000..64c90179 --- /dev/null +++ b/hrp/internal/uixt/android.go @@ -0,0 +1 @@ +package uixt diff --git a/hrp/internal/uixt/drag.go b/hrp/internal/uixt/drag.go index 0fc7fa64..510b5915 100644 --- a/hrp/internal/uixt/drag.go +++ b/hrp/internal/uixt/drag.go @@ -27,6 +27,6 @@ func (dExt *DriverExt) DragOffsetFloat(pathname string, toX, toY, xOffset, yOffs fromX := x + width*xOffset fromY := y + height*yOffset - return dExt.WebDriver.DragFloat(fromX, fromY, toX, toY, + return dExt.Driver.DragFloat(fromX, fromY, toX, toY, gwda.WithPressDuration(pressForDuration[0])) } diff --git a/hrp/internal/uixt/ext.go b/hrp/internal/uixt/ext.go index d27d3815..76ffc417 100644 --- a/hrp/internal/uixt/ext.go +++ b/hrp/internal/uixt/ext.go @@ -2,6 +2,7 @@ package uixt import ( "bytes" + "encoding/json" "fmt" "image" "image/jpeg" @@ -12,12 +13,90 @@ import ( "os" "path/filepath" "strings" + "time" "github.com/electricbubble/gwda" "github.com/pkg/errors" "github.com/rs/zerolog/log" ) +type MobileMethod string + +const ( + AppInstall MobileMethod = "install" + AppUninstall MobileMethod = "uninstall" + AppStart MobileMethod = "app_start" + AppLaunch MobileMethod = "app_launch" // 等待 app 打开并堵塞到 app 首屏加载完成,可以传入 app 的启动参数、环境变量 + AppLaunchUnattached MobileMethod = "app_launch_unattached" // 只负责通知打开 app,不堵塞等待,不可传入启动参数 + AppTerminate MobileMethod = "app_terminate" + AppStop MobileMethod = "app_stop" + CtlScreenShot MobileMethod = "screenshot" + CtlSleep MobileMethod = "sleep" + CtlStartCamera MobileMethod = "camera_start" // alias for app_launch camera + CtlStopCamera MobileMethod = "camera_stop" // alias for app_terminate camera + RecordStart MobileMethod = "record_start" + RecordStop MobileMethod = "record_stop" + + // UI validation + SelectorName string = "ui_name" + SelectorLabel string = "ui_label" + SelectorOCR string = "ui_ocr" + SelectorImage string = "ui_image" + AssertionExists string = "exists" + AssertionNotExists string = "not_exists" + + // UI handling + ACTION_Home MobileMethod = "home" + ACTION_TapXY MobileMethod = "tap_xy" + ACTION_TapByOCR MobileMethod = "tap_ocr" + ACTION_TapByCV MobileMethod = "tap_cv" + ACTION_Tap MobileMethod = "tap" + ACTION_DoubleTapXY MobileMethod = "double_tap_xy" + ACTION_DoubleTap MobileMethod = "double_tap" + ACTION_Swipe MobileMethod = "swipe" + ACTION_Input MobileMethod = "input" + + // custom actions + ACTION_SwipeToTapApp MobileMethod = "swipe_to_tap_app" // swipe left & right to find app and tap + ACTION_SwipeToTapText MobileMethod = "swipe_to_tap_text" // swipe up & down to find text and tap +) + +type MobileAction struct { + Method MobileMethod `json:"method,omitempty" yaml:"method,omitempty"` + Params interface{} `json:"params,omitempty" yaml:"params,omitempty"` + + Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log + MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"` // max retry times + Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` // TODO: wait timeout in seconds for mobile action + IgnoreNotFoundError bool `json:"ignore_NotFoundError,omitempty" yaml:"ignore_NotFoundError,omitempty"` // ignore error if target element not found +} + +type ActionOption func(o *MobileAction) + +func WithIdentifier(identifier string) ActionOption { + return func(o *MobileAction) { + o.Identifier = identifier + } +} + +func WithMaxRetryTimes(maxRetryTimes int) ActionOption { + return func(o *MobileAction) { + o.MaxRetryTimes = maxRetryTimes + } +} + +func WithTimeout(timeout int) ActionOption { + return func(o *MobileAction) { + o.Timeout = timeout + } +} + +func WithIgnoreNotFoundError(ignoreError bool) ActionOption { + return func(o *MobileAction) { + o.IgnoreNotFoundError = ignoreError + } +} + // TemplateMatchMode is the type of the template matching operation. type TemplateMatchMode int @@ -41,27 +120,29 @@ func WithThreshold(threshold float64) CVOption { } type DriverExt struct { - gwda.WebDriver + Driver gwda.WebDriver windowSize gwda.Size frame *bytes.Buffer doneMjpegStream chan bool scale float64 host string + StartTime time.Time // used to associate screenshots name + ScreenShots []string // save screenshots path CVArgs } func extend(driver gwda.WebDriver) (dExt *DriverExt, err error) { - dExt = &DriverExt{WebDriver: driver} + dExt = &DriverExt{Driver: driver} dExt.doneMjpegStream = make(chan bool, 1) // get device window size - dExt.windowSize, err = dExt.WebDriver.WindowSize() + dExt.windowSize, err = dExt.Driver.WindowSize() if err != nil { return nil, errors.Wrap(err, "failed to get windows size") } - if dExt.scale, err = dExt.Scale(); err != nil { + if dExt.scale, err = dExt.Driver.Scale(); err != nil { return nil, err } @@ -129,7 +210,7 @@ func (dExt *DriverExt) takeScreenShot() (raw *bytes.Buffer, err error) { if dExt.frame != nil { return dExt.frame, nil } - if raw, err = dExt.WebDriver.Screenshot(); err != nil { + if raw, err = dExt.Driver.Screenshot(); err != nil { log.Error().Err(err).Msgf("screenshot failed: %v", err) return nil, err } @@ -206,7 +287,7 @@ func (dExt *DriverExt) FindUIElement(param string) (ele gwda.WebElement, err err } } - return dExt.WebDriver.FindElement(selector) + return dExt.Driver.FindElement(selector) } func (dExt *DriverExt) FindUIRectInUIKit(search string) (x, y, width, height float64, err error) { @@ -225,18 +306,18 @@ func (dExt *DriverExt) MappingToRectInUIKit(rect image.Rectangle) (x, y, width, } func (dExt *DriverExt) PerformTouchActions(touchActions *gwda.TouchActions) error { - return dExt.PerformAppiumTouchActions(touchActions) + return dExt.Driver.PerformAppiumTouchActions(touchActions) } func (dExt *DriverExt) PerformActions(actions *gwda.W3CActions) error { - return dExt.PerformW3CActions(actions) + return dExt.Driver.PerformW3CActions(actions) } func (dExt *DriverExt) IsNameExist(name string) bool { selector := gwda.BySelector{ LinkText: gwda.NewElementAttribute().WithName(name), } - _, err := dExt.FindElement(selector) + _, err := dExt.Driver.FindElement(selector) return err == nil } @@ -244,7 +325,7 @@ func (dExt *DriverExt) IsLabelExist(label string) bool { selector := gwda.BySelector{ LinkText: gwda.NewElementAttribute().WithLabel(label), } - _, err := dExt.FindElement(selector) + _, err := dExt.Driver.FindElement(selector) return err == nil } @@ -257,3 +338,225 @@ func (dExt *DriverExt) IsImageExist(text string) bool { _, _, _, _, err := dExt.FindImageRectInUIKit(text) return err == nil } + +var errActionNotImplemented = errors.New("UI action not implemented") + +func (dExt *DriverExt) DoAction(action MobileAction) error { + log.Info().Str("method", string(action.Method)).Interface("params", action.Params).Msg("start iOS UI action") + + switch action.Method { + case AppInstall: + // TODO + return errActionNotImplemented + case AppLaunch: + if bundleId, ok := action.Params.(string); ok { + return dExt.Driver.AppLaunch(bundleId) + } + return fmt.Errorf("invalid %s params, should be bundleId(string), got %v", + AppLaunch, action.Params) + case AppLaunchUnattached: + if bundleId, ok := action.Params.(string); ok { + return dExt.Driver.AppLaunchUnattached(bundleId) + } + return fmt.Errorf("invalid %s params, should be bundleId(string), got %v", + AppLaunchUnattached, action.Params) + case ACTION_SwipeToTapApp: + if appName, ok := action.Params.(string); ok { + var x, y, width, height float64 + findApp := func(d *DriverExt) error { + var err error + x, y, width, height, err = d.FindTextByOCR(appName) + return err + } + foundAppAction := func(d *DriverExt) error { + // click app to launch + return d.Driver.TapFloat(x+width*0.5, y+height*0.5-20) + } + + // go to home screen + if err := dExt.Driver.Homescreen(); err != nil { + return errors.Wrap(err, "go to home screen failed") + } + + // swipe to first screen + for i := 0; i < 5; i++ { + dExt.SwipeRight() + } + + // default to retry 5 times + if action.MaxRetryTimes == 0 { + action.MaxRetryTimes = 5 + } + // swipe next screen until app found + return dExt.SwipeUntil("left", findApp, foundAppAction, action.MaxRetryTimes) + } + return fmt.Errorf("invalid %s params, should be app name(string), got %v", + ACTION_SwipeToTapApp, action.Params) + case ACTION_SwipeToTapText: + if text, ok := action.Params.(string); ok { + var x, y, width, height float64 + findText := func(d *DriverExt) error { + var err error + x, y, width, height, err = d.FindTextByOCR(text) + return err + } + foundTextAction := func(d *DriverExt) error { + // tap text + return d.Driver.TapFloat(x+width*0.5, y+height*0.5) + } + + // default to retry 10 times + if action.MaxRetryTimes == 0 { + action.MaxRetryTimes = 10 + } + // swipe until live room found + return dExt.SwipeUntil("up", findText, foundTextAction, action.MaxRetryTimes) + } + return fmt.Errorf("invalid %s params, should be app text(string), got %v", + ACTION_SwipeToTapText, action.Params) + case AppTerminate: + if bundleId, ok := action.Params.(string); ok { + success, err := dExt.Driver.AppTerminate(bundleId) + if err != nil { + return errors.Wrap(err, "failed to terminate app") + } + if !success { + log.Warn().Str("bundleId", bundleId).Msg("app was not running") + } + return nil + } + return fmt.Errorf("app_terminate params should be bundleId(string), got %v", action.Params) + case ACTION_Home: + return dExt.Driver.Homescreen() + case ACTION_TapXY: + if location, ok := action.Params.([]float64); ok { + // relative x,y of window size: [0.5, 0.5] + if len(location) != 2 { + return fmt.Errorf("invalid tap location params: %v", location) + } + return dExt.TapXY(location[0], location[1], action.Identifier) + } + return fmt.Errorf("invalid %s params: %v", ACTION_TapXY, action.Params) + case ACTION_Tap: + if param, ok := action.Params.(string); ok { + return dExt.Tap(param, action.Identifier, action.IgnoreNotFoundError) + } + return fmt.Errorf("invalid %s params: %v", ACTION_Tap, action.Params) + case ACTION_TapByOCR: + if ocrText, ok := action.Params.(string); ok { + return dExt.TapByOCR(ocrText, action.Identifier, action.IgnoreNotFoundError) + } + return fmt.Errorf("invalid %s params: %v", ACTION_TapByOCR, action.Params) + case ACTION_TapByCV: + if imagePath, ok := action.Params.(string); ok { + return dExt.TapByCV(imagePath, action.Identifier, action.IgnoreNotFoundError) + } + return fmt.Errorf("invalid %s params: %v", ACTION_TapByCV, action.Params) + case ACTION_DoubleTapXY: + if location, ok := action.Params.([]float64); ok { + // relative x,y of window size: [0.5, 0.5] + if len(location) != 2 { + return fmt.Errorf("invalid tap location params: %v", location) + } + return dExt.DoubleTapXY(location[0], location[1]) + } + return fmt.Errorf("invalid %s params: %v", ACTION_DoubleTapXY, action.Params) + case ACTION_DoubleTap: + if param, ok := action.Params.(string); ok { + return dExt.DoubleTap(param) + } + return fmt.Errorf("invalid %s params: %v", ACTION_DoubleTap, action.Params) + case ACTION_Swipe: + if positions, ok := action.Params.([]float64); ok { + // relative fromX, fromY, toX, toY of window size: [0.5, 0.9, 0.5, 0.1] + if len(positions) != 4 { + return fmt.Errorf("invalid swipe params [fromX, fromY, toX, toY]: %v", positions) + } + return dExt.SwipeRelative( + positions[0], positions[1], positions[2], positions[3], action.Identifier) + } + if direction, ok := action.Params.(string); ok { + return dExt.SwipeTo(direction, action.Identifier) + } + return fmt.Errorf("invalid %s params: %v", ACTION_Swipe, action.Params) + case ACTION_Input: + // input text on current active element + // append \n to send text with enter + // send \b\b\b to delete 3 chars + param := fmt.Sprintf("%v", action.Params) + return dExt.Driver.SendKeys(param) + case CtlSleep: + if param, ok := action.Params.(json.Number); ok { + seconds, _ := param.Float64() + time.Sleep(time.Duration(seconds*1000) * time.Millisecond) + return nil + } else if param, ok := action.Params.(float64); ok { + time.Sleep(time.Duration(param*1000) * time.Millisecond) + return nil + } + return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params) + case CtlScreenShot: + // take snapshot + log.Info().Msg("take snapshot for current screen") + screenshotPath, err := dExt.ScreenShot(fmt.Sprintf("%d_screenshot_%d", + dExt.StartTime.Unix(), time.Now().Unix())) + if err != nil { + return errors.Wrap(err, "take screenshot failed") + } + log.Info().Str("path", screenshotPath).Msg("take screenshot") + dExt.ScreenShots = append(dExt.ScreenShots, screenshotPath) + return err + case CtlStartCamera: + // start camera, alias for app_launch com.apple.camera + return dExt.Driver.AppLaunch("com.apple.camera") + case CtlStopCamera: + // stop camera, alias for app_terminate com.apple.camera + success, err := dExt.Driver.AppTerminate("com.apple.camera") + if err != nil { + return errors.Wrap(err, "failed to terminate camera") + } + if !success { + log.Warn().Msg("camera was not running") + } + return nil + } + return nil +} + +func (dExt *DriverExt) DoValidation(check, assert, expected string, message ...string) bool { + var exists bool + if assert == AssertionExists { + exists = true + } else { + exists = false + } + var result bool + switch check { + case SelectorName: + result = (dExt.IsNameExist(expected) == exists) + case SelectorLabel: + result = (dExt.IsLabelExist(expected) == exists) + case SelectorOCR: + result = (dExt.IsOCRExist(expected) == exists) + case SelectorImage: + result = (dExt.IsImageExist(expected) == exists) + } + + if !result { + if message == nil { + message = []string{""} + } + log.Error(). + Str("assert", assert). + Str("expect", expected). + Str("msg", message[0]). + Msg("validate UI failed") + return false + } + + log.Info(). + Str("assert", assert). + Str("expect", expected). + Msg("validate UI success") + return true +} diff --git a/hrp/internal/uixt/interface.go b/hrp/internal/uixt/interface.go new file mode 100644 index 00000000..b788c6e1 --- /dev/null +++ b/hrp/internal/uixt/interface.go @@ -0,0 +1,4 @@ +package uixt + +type WebDriver interface { +} diff --git a/hrp/internal/uixt/init.go b/hrp/internal/uixt/ios.go similarity index 93% rename from hrp/internal/uixt/init.go rename to hrp/internal/uixt/ios.go index 4c6b39e7..d79caf27 100644 --- a/hrp/internal/uixt/init.go +++ b/hrp/internal/uixt/ios.go @@ -28,6 +28,10 @@ const ( dismissAlertButtonSelector = "**/XCUIElementTypeButton[`label IN {'不允许','暂不'}`]" ) +type Options interface { + UUID() string +} + type WDAOptions struct { UDID string `json:"udid,omitempty" yaml:"udid,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` @@ -35,6 +39,10 @@ type WDAOptions struct { LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"` } +func (o WDAOptions) UUID() string { + return o.UDID +} + type WDAOption func(*WDAOptions) func WithUDID(udid string) WDAOption { @@ -100,7 +108,7 @@ func InitWDAClient(options *WDAOptions) (*DriverExt, error) { if err != nil { return nil, errors.Wrap(err, "failed to extend gwda.WebDriver") } - settings, err := driverExt.SetAppiumSettings(map[string]interface{}{ + settings, err := driverExt.Driver.SetAppiumSettings(map[string]interface{}{ "snapshotMaxDepth": snapshotMaxDepth, "acceptAlertButtonSelector": acceptAlertButtonSelector, }) @@ -111,7 +119,7 @@ func InitWDAClient(options *WDAOptions) (*DriverExt, error) { driverExt.host = fmt.Sprintf("http://127.0.0.1:%d", targetDevice.Port) if options.LogOn { - err = driverExt.StartWDALog("hrp_wda_log") + err = driverExt.StartLogRecording("hrp_wda_log") if err != nil { return nil, err } @@ -125,7 +133,7 @@ type wdaResponse struct { SessionID string `json:"sessionId"` } -func (dExt *DriverExt) StartWDALog(identifier string) error { +func (dExt *DriverExt) StartLogRecording(identifier string) error { log.Info().Msg("start WDA log recording") data := map[string]interface{}{"action": "start", "type": 2, "identifier": identifier} _, err := dExt.triggerWDALog(data) @@ -136,7 +144,7 @@ func (dExt *DriverExt) StartWDALog(identifier string) error { return nil } -func (dExt *DriverExt) GetWDALog() (string, error) { +func (dExt *DriverExt) GetLogs() (string, error) { log.Info().Msg("stop WDA log recording") data := map[string]interface{}{"action": "stop"} reply, err := dExt.triggerWDALog(data) diff --git a/hrp/internal/uixt/ocr_test.go b/hrp/internal/uixt/ocr_test.go index 928b39a2..922c9e0b 100644 --- a/hrp/internal/uixt/ocr_test.go +++ b/hrp/internal/uixt/ocr_test.go @@ -14,5 +14,5 @@ func TestDriverExtOCR(t *testing.T) { checkErr(t, err) t.Logf("x: %v, y: %v, width: %v, height: %v", x, y, width, height) - driverExt.WebDriver.TapFloat(x+width*0.5, y+height*0.5-20) + driverExt.Driver.TapFloat(x+width*0.5, y+height*0.5-20) } diff --git a/hrp/internal/uixt/opencv_on.go b/hrp/internal/uixt/opencv_on.go index 81afe225..326e9277 100644 --- a/hrp/internal/uixt/opencv_on.go +++ b/hrp/internal/uixt/opencv_on.go @@ -69,7 +69,7 @@ func (dExt *DriverExt) Debug(dm DebugMode) { func (dExt *DriverExt) OnlyOnceThreshold(threshold float64) (newExt *DriverExt) { newExt = new(DriverExt) - newExt.WebDriver = dExt.WebDriver + newExt.Driver = dExt.Driver newExt.scale = dExt.scale newExt.matchMode = dExt.matchMode newExt.threshold = threshold @@ -78,7 +78,7 @@ func (dExt *DriverExt) OnlyOnceThreshold(threshold float64) (newExt *DriverExt) func (dExt *DriverExt) OnlyOnceMatchMode(matchMode TemplateMatchMode) (newExt *DriverExt) { newExt = new(DriverExt) - newExt.WebDriver = dExt.WebDriver + newExt.Driver = dExt.Driver newExt.scale = dExt.scale newExt.matchMode = matchMode newExt.threshold = dExt.threshold diff --git a/hrp/internal/uixt/swipe.go b/hrp/internal/uixt/swipe.go index 5454682a..4d29e05b 100644 --- a/hrp/internal/uixt/swipe.go +++ b/hrp/internal/uixt/swipe.go @@ -2,6 +2,7 @@ package uixt import ( "fmt" + "time" "github.com/electricbubble/gwda" "github.com/rs/zerolog/log" @@ -32,9 +33,9 @@ func (dExt *DriverExt) SwipeRelative(fromX, fromY, toX, toY float64, identifier "enable": true, "data": identifier[0], }) - dExt.WebDriver.SwipeFloat(fromX, fromY, toX, toY, option) + dExt.Driver.SwipeFloat(fromX, fromY, toX, toY, option) } - return dExt.WebDriver.SwipeFloat(fromX, fromY, toX, toY) + return dExt.Driver.SwipeFloat(fromX, fromY, toX, toY) } func (dExt *DriverExt) SwipeTo(direction string, identifier ...string) (err error) { @@ -82,6 +83,8 @@ func (dExt *DriverExt) SwipeUntil(direction string, condition FindCondition, act if err := dExt.SwipeTo(direction); err != nil { log.Error().Err(err).Msgf("swipe %s failed", direction) } + // wait for swipe done + time.Sleep(500 * time.Millisecond) } return fmt.Errorf("swipe %s %d times, match condition failed", direction, maxTimes) } diff --git a/hrp/internal/uixt/swipe_test.go b/hrp/internal/uixt/swipe_test.go index 9f4390d6..cb25b76c 100644 --- a/hrp/internal/uixt/swipe_test.go +++ b/hrp/internal/uixt/swipe_test.go @@ -16,10 +16,10 @@ func TestSwipeUntil(t *testing.T) { } foundAppAction := func(d *DriverExt) error { // click app, launch douyin - return d.TapFloat(x+width*0.5, y+height*0.5-20) + return d.Driver.TapFloat(x+width*0.5, y+height*0.5-20) } - driverExt.Homescreen() + driverExt.Driver.Homescreen() // swipe to first screen for i := 0; i < 5; i++ { @@ -37,7 +37,7 @@ func TestSwipeUntil(t *testing.T) { } foundLiveAction := func(d *DriverExt) error { // enter live room - return d.TapFloat(x+width*0.5, y+height*0.5) + return d.Driver.TapFloat(x+width*0.5, y+height*0.5) } // swipe until live room found diff --git a/hrp/internal/uixt/tap.go b/hrp/internal/uixt/tap.go index 9a522825..ac6c51fc 100644 --- a/hrp/internal/uixt/tap.go +++ b/hrp/internal/uixt/tap.go @@ -12,9 +12,9 @@ func (dExt *DriverExt) tapFloat(x, y float64, identifier string) error { "enable": true, "data": identifier, }) - return dExt.WebDriver.TapFloat(x, y, option) + return dExt.Driver.TapFloat(x, y, option) } - return dExt.WebDriver.TapFloat(x, y) + return dExt.Driver.TapFloat(x, y) } func (dExt *DriverExt) TapXY(x, y float64, identifier string) error { @@ -83,7 +83,7 @@ func (dExt *DriverExt) DoubleTapXY(x, y float64) error { x = x * float64(dExt.windowSize.Width) y = y * float64(dExt.windowSize.Height) - return dExt.WebDriver.DoubleTapFloat(x, y) + return dExt.Driver.DoubleTapFloat(x, y) } func (dExt *DriverExt) DoubleTap(param string) (err error) { @@ -102,7 +102,7 @@ func (dExt *DriverExt) DoubleTapOffset(param string, xOffset, yOffset float64) ( return err } - return dExt.WebDriver.DoubleTapFloat(x+width*xOffset, y+height*yOffset) + return dExt.Driver.DoubleTapFloat(x+width*xOffset, y+height*yOffset) } // TapWithNumber sends one or more taps diff --git a/hrp/internal/uixt/touch.go b/hrp/internal/uixt/touch.go index 6c06ae81..fe455507 100644 --- a/hrp/internal/uixt/touch.go +++ b/hrp/internal/uixt/touch.go @@ -13,7 +13,7 @@ func (dExt *DriverExt) ForceTouchOffset(pathname string, pressure, xOffset, yOff return err } - return dExt.ForceTouchFloat(x+width*xOffset, y+height*yOffset, pressure, duration[0]) + return dExt.Driver.ForceTouchFloat(x+width*xOffset, y+height*yOffset, pressure, duration[0]) } func (dExt *DriverExt) TouchAndHold(pathname string, duration ...float64) (err error) { @@ -29,5 +29,5 @@ func (dExt *DriverExt) TouchAndHoldOffset(pathname string, xOffset, yOffset floa return err } - return dExt.TouchAndHoldFloat(x+width*xOffset, y+height*yOffset, duration[0]) + return dExt.Driver.TouchAndHoldFloat(x+width*xOffset, y+height*yOffset, duration[0]) } diff --git a/hrp/response.go b/hrp/response.go index 42c7e9e1..33fe48e7 100644 --- a/hrp/response.go +++ b/hrp/response.go @@ -16,6 +16,7 @@ import ( "github.com/httprunner/httprunner/v4/hrp/internal/builtin" "github.com/httprunner/httprunner/v4/hrp/internal/json" + "github.com/httprunner/httprunner/v4/hrp/internal/uixt" ) var fieldTags = []string{"proto", "status_code", "headers", "cookies", "body", textExtractorSubRegexp} @@ -272,3 +273,38 @@ func (v *responseObject) searchRegexp(expr string) interface{} { log.Error().Str("expr", expr).Msg("search regexp failed") return expr } + +func validateUI(ud *uixt.DriverExt, iValidators []interface{}) (validateResults []*ValidationResult, err error) { + for _, iValidator := range iValidators { + validator, ok := iValidator.(Validator) + if !ok { + return nil, errors.New("validator type error") + } + + validataResult := &ValidationResult{ + Validator: validator, + CheckResult: "fail", + } + + // parse check value + if !strings.HasPrefix(validator.Check, "ui_") { + validataResult.CheckResult = "skip" + log.Warn().Interface("validator", validator).Msg("skip validator") + validateResults = append(validateResults, validataResult) + continue + } + + expected, ok := validator.Expect.(string) + if !ok { + return nil, errors.New("validator expect should be string") + } + + if !ud.DoValidation(validator.Check, validator.Assert, expected, validator.Message) { + return validateResults, errors.New("step validation failed") + } + + validataResult.CheckResult = "pass" + validateResults = append(validateResults, validataResult) + } + return validateResults, nil +} diff --git a/hrp/runner.go b/hrp/runner.go index a34783fa..dfb02551 100644 --- a/hrp/runner.go +++ b/hrp/runner.go @@ -20,6 +20,7 @@ import ( "github.com/httprunner/httprunner/v4/hrp/internal/builtin" "github.com/httprunner/httprunner/v4/hrp/internal/sdk" + "github.com/httprunner/httprunner/v4/hrp/internal/uixt" ) // Run starts to run API test with default configs. @@ -71,7 +72,7 @@ type HRPRunner struct { httpClient *http.Client http2Client *http.Client wsDialer *websocket.Dialer - wdaClients map[string]*uiDriver // wda client used for iOS UI automation, key is udid + uiClients map[string]*uixt.DriverExt // UI automation clients for iOS and Android, key is udid/serial } // SetClientTransport configures transport of http client for high concurrency load testing @@ -384,13 +385,19 @@ func (r *testCaseRunner) parseConfig() error { } r.parametersIterator = parametersIterator - // init iOS WDA clients + // init iOS/Android clients for _, iosDeviceConfig := range r.parsedConfig.IOS { - _, err := r.hrpRunner.InitWDAClient(iosDeviceConfig) + _, err := r.hrpRunner.initUIClient(iosDeviceConfig) if err != nil { return errors.Wrap(err, "init iOS WDA client failed") } } + for _, androidDeviceConfig := range r.parsedConfig.Android { + _, err := r.hrpRunner.initUIClient(androidDeviceConfig) + if err != nil { + return errors.Wrap(err, "init Android UIAutomator client failed") + } + } return nil } diff --git a/hrp/session.go b/hrp/session.go index 359981b7..d640f2f2 100644 --- a/hrp/session.go +++ b/hrp/session.go @@ -164,8 +164,8 @@ func (r *SessionRunner) GetSummary() *TestCaseSummary { caseSummary.InOut.ConfigVars = r.parsedConfig.Variables logs := make(map[string]string) - for udid, client := range r.hrpRunner.wdaClients { - log, err := client.GetWDALog() + for udid, client := range r.hrpRunner.uiClients { + log, err := client.GetLogs() if err != nil { logs[udid] = err.Error() } else { diff --git a/hrp/step.go b/hrp/step.go index 02a43262..f3585e40 100644 --- a/hrp/step.go +++ b/hrp/step.go @@ -1,5 +1,7 @@ package hrp +import "github.com/httprunner/httprunner/v4/hrp/internal/uixt" + type StepType string const ( @@ -14,83 +16,13 @@ const ( stepTypeIOS StepType = "ios" ) -type MobileMethod string - -const ( - appInstall MobileMethod = "install" - appUninstall MobileMethod = "uninstall" - appStart MobileMethod = "app_start" - appLaunch MobileMethod = "app_launch" // 等待 app 打开并堵塞到 app 首屏加载完成,可以传入 app 的启动参数、环境变量 - appLaunchUnattached MobileMethod = "app_launch_unattached" // 只负责通知打开 app,不堵塞等待,不可传入启动参数 - appTerminate MobileMethod = "app_terminate" - appStop MobileMethod = "app_stop" - ctlScreenShot MobileMethod = "screenshot" - ctlSleep MobileMethod = "sleep" - ctlStartCamera MobileMethod = "camera_start" // alias for app_launch camera - ctlStopCamera MobileMethod = "camera_stop" // alias for app_terminate camera - recordStart MobileMethod = "record_start" - recordStop MobileMethod = "record_stop" - - // UI handling - uiHome MobileMethod = "home" - uiTapXY MobileMethod = "tap_xy" - uiTapByOCR MobileMethod = "tap_ocr" - uiTapByCV MobileMethod = "tap_cv" - uiTap MobileMethod = "tap" - uiDoubleTapXY MobileMethod = "double_tap_xy" - uiDoubleTap MobileMethod = "double_tap" - uiSwipe MobileMethod = "swipe" - uiInput MobileMethod = "input" - - // UI validation - uiSelectorName string = "ui_name" - uiSelectorLabel string = "ui_label" - uiSelectorOCR string = "ui_ocr" - uiSelectorImage string = "ui_image" - assertionExists string = "exists" - assertionNotExists string = "not_exists" - - // custom actions - swipeToTapApp MobileMethod = "swipe_to_tap_app" // swipe left & right to find app and tap - swipeToTapText MobileMethod = "swipe_to_tap_text" // swipe up & down to find text and tap +var ( + WithIdentifier = uixt.WithIdentifier + WithMaxRetryTimes = uixt.WithMaxRetryTimes + WithTimeout = uixt.WithTimeout + WithIgnoreNotFoundError = uixt.WithIgnoreNotFoundError ) -type MobileAction struct { - Method MobileMethod `json:"method,omitempty" yaml:"method,omitempty"` - Params interface{} `json:"params,omitempty" yaml:"params,omitempty"` - - Identifier string `json:"identifier,omitempty" yaml:"identifier,omitempty"` // used to identify the action in log - MaxRetryTimes int `json:"max_retry_times,omitempty" yaml:"max_retry_times,omitempty"` // max retry times - Timeout int `json:"timeout,omitempty" yaml:"timeout,omitempty"` // TODO: wait timeout in seconds for mobile action - IgnoreNotFoundError bool `json:"ignore_NotFoundError,omitempty" yaml:"ignore_NotFoundError,omitempty"` // ignore error if target element not found -} - -type ActionOption func(o *MobileAction) - -func WithIdentifier(identifier string) ActionOption { - return func(o *MobileAction) { - o.Identifier = identifier - } -} - -func WithMaxRetryTimes(maxRetryTimes int) ActionOption { - return func(o *MobileAction) { - o.MaxRetryTimes = maxRetryTimes - } -} - -func WithTimeout(timeout int) ActionOption { - return func(o *MobileAction) { - o.Timeout = timeout - } -} - -func WithIgnoreNotFoundError(ignoreError bool) ActionOption { - return func(o *MobileAction) { - o.IgnoreNotFoundError = ignoreError - } -} - type StepResult struct { Name string `json:"name" yaml:"name"` // step name StepType StepType `json:"step_type" yaml:"step_type"` // step type, testcase/request/transaction/rendezvous diff --git a/hrp/step_android_ui.go b/hrp/step_android_ui.go index abc1a9be..b6f44c97 100644 --- a/hrp/step_android_ui.go +++ b/hrp/step_android_ui.go @@ -1,11 +1,17 @@ package hrp -import "fmt" +import ( + "fmt" + "time" + + "github.com/httprunner/httprunner/v4/hrp/internal/uixt" + "github.com/rs/zerolog/log" +) type AndroidStep struct { - MobileAction - Serial string `json:"serial,omitempty" yaml:"serial,omitempty"` - Actions []MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"` + uixt.UIAOptions `yaml:",inline"` // inline refers to https://pkg.go.dev/gopkg.in/yaml.v3#Marshal + uixt.MobileAction + Actions []uixt.MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"` } // StepAndroid implements IStep interface. @@ -14,117 +20,117 @@ type StepAndroid struct { } func (s *StepAndroid) Serial(serial string) *StepAndroid { - s.step.Android.Serial = serial + s.step.Android.SerialNumber = serial return &StepAndroid{step: s.step} } func (s *StepAndroid) InstallApp(path string) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: appInstall, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.AppInstall, Params: path, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) StartAppByIntent(activity string) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: appStart, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.AppStart, Params: activity, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) StartCamera() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: ctlStartCamera, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.CtlStartCamera, Params: nil, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) StopCamera() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: ctlStopCamera, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.CtlStopCamera, Params: nil, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) StartRecording() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: recordStart, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.RecordStart, Params: nil, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) StopRecording() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: recordStop, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.RecordStop, Params: nil, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) Tap(params interface{}) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiTap, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Tap, Params: params, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) DoubleTap(params interface{}) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiDoubleTap, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_DoubleTap, Params: params, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) Swipe(sx, sy, ex, ey int) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiSwipe, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: []int{sx, sy, ex, ey}, }) return &StepAndroid{step: s.step} } func (s *StepAndroid) SwipeUp() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiSwipe, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "up", }) return &StepAndroid{step: s.step} } func (s *StepAndroid) SwipeDown() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiSwipe, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "down", }) return &StepAndroid{step: s.step} } func (s *StepAndroid) SwipeLeft() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiSwipe, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "left", }) return &StepAndroid{step: s.step} } func (s *StepAndroid) SwipeRight() *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiSwipe, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "right", }) return &StepAndroid{step: s.step} } func (s *StepAndroid) Input(text string) *StepAndroid { - s.step.Android.Actions = append(s.step.Android.Actions, MobileAction{ - Method: uiInput, + s.step.Android.Actions = append(s.step.Android.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Input, Params: text, }) return &StepAndroid{step: s.step} @@ -160,8 +166,8 @@ type StepAndroidValidation struct { func (s *StepAndroidValidation) AssertNameExists(expectedName string, msg ...string) *StepAndroidValidation { v := Validator{ - Check: uiSelectorName, - Assert: assertionExists, + Check: uixt.SelectorName, + Assert: uixt.AssertionExists, Expect: expectedName, } if len(msg) > 0 { @@ -175,8 +181,8 @@ func (s *StepAndroidValidation) AssertNameExists(expectedName string, msg ...str func (s *StepAndroidValidation) AssertNameNotExists(expectedName string, msg ...string) *StepAndroidValidation { v := Validator{ - Check: uiSelectorName, - Assert: assertionNotExists, + Check: uixt.SelectorName, + Assert: uixt.AssertionNotExists, Expect: expectedName, } if len(msg) > 0 { @@ -204,12 +210,83 @@ func (s *StepAndroidValidation) Run(r *SessionRunner) (*StepResult, error) { return runStepAndroid(r, s.step) } -func runStepAndroid(r *SessionRunner, step *TStep) (stepResult *StepResult, err error) { +func runStepAndroid(s *SessionRunner, step *TStep) (stepResult *StepResult, err error) { stepResult = &StepResult{ Name: step.Name, StepType: stepTypeAndroid, Success: false, ContentSize: 0, } + screenshots := make([]string, 0) + + // init uiaClient driver + uiaClient, err := s.hrpRunner.initUIClient(&step.Android.UIAOptions) + if err != nil { + return + } + uiaClient.StartTime = s.startTime + + defer func() { + attachments := make(map[string]interface{}) + if err != nil { + attachments["error"] = err.Error() + } + + // save attachments + screenshots = append(screenshots, uiaClient.ScreenShots...) + attachments["screenshots"] = screenshots + stepResult.Attachments = attachments + + // update summary + s.summary.Records = append(s.summary.Records, stepResult) + s.summary.Stat.Total += 1 + if stepResult.Success { + s.summary.Stat.Successes += 1 + } else { + s.summary.Stat.Failures += 1 + // update summary result to failed + s.summary.Success = false + } + }() + + // prepare actions + var actions []uixt.MobileAction + if step.Android.Actions == nil { + actions = []uixt.MobileAction{ + { + Method: step.Android.Method, + Params: step.Android.Params, + }, + } + } else { + actions = step.Android.Actions + } + + // run actions + for _, action := range actions { + if err := uiaClient.DoAction(action); err != nil { + return stepResult, err + } + } + + // take snapshot + screenshotPath, err := uiaClient.ScreenShot( + fmt.Sprintf("%d_validate_%d", uiaClient.StartTime.Unix(), time.Now().Unix())) + if err != nil { + log.Warn().Err(err).Str("step", step.Name).Msg("take screenshot failed") + } else { + log.Info().Str("path", screenshotPath).Msg("take screenshot before validation") + screenshots = append(screenshots, screenshotPath) + } + + // validate + validateResults, err := validateUI(uiaClient, step.Validators) + if err != nil { + return + } + sessionData := newSessionData() + sessionData.Validators = validateResults + stepResult.Data = sessionData + stepResult.Success = true return stepResult, nil } diff --git a/hrp/step_ios_ui.go b/hrp/step_ios_ui.go index 836e1049..7a6a4327 100644 --- a/hrp/step_ios_ui.go +++ b/hrp/step_ios_ui.go @@ -1,22 +1,14 @@ package hrp import ( - "encoding/json" "fmt" - "strings" "time" - "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v4/hrp/internal/uixt" ) -type ( - WDAOptions = uixt.WDAOptions - WDAOption = uixt.WDAOption -) - var ( WithUDID = uixt.WithUDID WithPort = uixt.WithPort @@ -25,9 +17,9 @@ var ( ) type IOSStep struct { - WDAOptions `yaml:",inline"` // inline refers to https://pkg.go.dev/gopkg.in/yaml.v3#Marshal - MobileAction `yaml:",inline"` - Actions []MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"` + uixt.WDAOptions `yaml:",inline"` // inline refers to https://pkg.go.dev/gopkg.in/yaml.v3#Marshal + uixt.MobileAction `yaml:",inline"` + Actions []uixt.MobileAction `json:"actions,omitempty" yaml:"actions,omitempty"` } // StepIOS implements IStep interface. @@ -41,49 +33,49 @@ func (s *StepIOS) UDID(udid string) *StepIOS { } func (s *StepIOS) InstallApp(path string) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: appInstall, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.AppInstall, Params: path, }) return s } func (s *StepIOS) AppLaunch(bundleId string) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: appLaunch, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.AppLaunch, Params: bundleId, }) return s } func (s *StepIOS) AppLaunchUnattached(bundleId string) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: appLaunchUnattached, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.AppLaunchUnattached, Params: bundleId, }) return s } func (s *StepIOS) AppTerminate(bundleId string) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: appTerminate, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.AppTerminate, Params: bundleId, }) return s } func (s *StepIOS) Home() *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: uiHome, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Home, Params: nil, }) return &StepIOS{step: s.step} } // TapXY taps the point {X,Y}, X & Y is percentage of coordinates -func (s *StepIOS) TapXY(x, y float64, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiTapXY, +func (s *StepIOS) TapXY(x, y float64, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_TapXY, Params: []float64{x, y}, } for _, option := range options { @@ -94,9 +86,9 @@ func (s *StepIOS) TapXY(x, y float64, options ...ActionOption) *StepIOS { } // Tap taps on the target element -func (s *StepIOS) Tap(params string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiTap, +func (s *StepIOS) Tap(params string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Tap, Params: params, } for _, option := range options { @@ -107,9 +99,9 @@ func (s *StepIOS) Tap(params string, options ...ActionOption) *StepIOS { } // Tap taps on the target element by OCR recognition -func (s *StepIOS) TapByOCR(ocrText string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiTapByOCR, +func (s *StepIOS) TapByOCR(ocrText string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_TapByOCR, Params: ocrText, } for _, option := range options { @@ -120,9 +112,9 @@ func (s *StepIOS) TapByOCR(ocrText string, options ...ActionOption) *StepIOS { } // Tap taps on the target element by CV recognition -func (s *StepIOS) TapByCV(imagePath string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiTapByCV, +func (s *StepIOS) TapByCV(imagePath string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_TapByCV, Params: imagePath, } for _, option := range options { @@ -134,16 +126,16 @@ func (s *StepIOS) TapByCV(imagePath string, options ...ActionOption) *StepIOS { // DoubleTapXY double taps the point {X,Y}, X & Y is percentage of coordinates func (s *StepIOS) DoubleTapXY(x, y float64) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: uiDoubleTapXY, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.ACTION_DoubleTapXY, Params: []float64{x, y}, }) return &StepIOS{step: s.step} } -func (s *StepIOS) DoubleTap(params string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiDoubleTap, +func (s *StepIOS) DoubleTap(params string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_DoubleTap, Params: params, } for _, option := range options { @@ -153,9 +145,9 @@ func (s *StepIOS) DoubleTap(params string, options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) Swipe(sx, sy, ex, ey int, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiSwipe, +func (s *StepIOS) Swipe(sx, sy, ex, ey int, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: []int{sx, sy, ex, ey}, } for _, option := range options { @@ -165,9 +157,9 @@ func (s *StepIOS) Swipe(sx, sy, ex, ey int, options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeUp(options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiSwipe, +func (s *StepIOS) SwipeUp(options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "up", } for _, option := range options { @@ -177,9 +169,9 @@ func (s *StepIOS) SwipeUp(options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeDown(options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiSwipe, +func (s *StepIOS) SwipeDown(options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "down", } for _, option := range options { @@ -189,9 +181,9 @@ func (s *StepIOS) SwipeDown(options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeLeft(options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiSwipe, +func (s *StepIOS) SwipeLeft(options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "left", } for _, option := range options { @@ -201,9 +193,9 @@ func (s *StepIOS) SwipeLeft(options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeRight(options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: uiSwipe, +func (s *StepIOS) SwipeRight(options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_Swipe, Params: "right", } for _, option := range options { @@ -213,9 +205,9 @@ func (s *StepIOS) SwipeRight(options ...ActionOption) *StepIOS { return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeToTapApp(appName string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: swipeToTapApp, +func (s *StepIOS) SwipeToTapApp(appName string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_SwipeToTapApp, Params: appName, } for _, option := range options { @@ -225,9 +217,9 @@ func (s *StepIOS) SwipeToTapApp(appName string, options ...ActionOption) *StepIO return &StepIOS{step: s.step} } -func (s *StepIOS) SwipeToTapText(text string, options ...ActionOption) *StepIOS { - action := MobileAction{ - Method: swipeToTapText, +func (s *StepIOS) SwipeToTapText(text string, options ...uixt.ActionOption) *StepIOS { + action := uixt.MobileAction{ + Method: uixt.ACTION_SwipeToTapText, Params: text, } for _, option := range options { @@ -238,8 +230,8 @@ func (s *StepIOS) SwipeToTapText(text string, options ...ActionOption) *StepIOS } func (s *StepIOS) Input(text string) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: uiInput, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.ACTION_Input, Params: text, }) return &StepIOS{step: s.step} @@ -268,32 +260,32 @@ func (s *StepIOS) Times(n int) *StepIOS { // Sleep specify sleep seconds after last action func (s *StepIOS) Sleep(n float64) *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: ctlSleep, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.CtlSleep, Params: n, }) return &StepIOS{step: s.step} } func (s *StepIOS) ScreenShot() *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: ctlScreenShot, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.CtlScreenShot, Params: nil, }) return &StepIOS{step: s.step} } func (s *StepIOS) StartCamera() *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: ctlStartCamera, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.CtlStartCamera, Params: nil, }) return &StepIOS{step: s.step} } func (s *StepIOS) StopCamera() *StepIOS { - s.step.IOS.Actions = append(s.step.IOS.Actions, MobileAction{ - Method: ctlStopCamera, + s.step.IOS.Actions = append(s.step.IOS.Actions, uixt.MobileAction{ + Method: uixt.CtlStopCamera, Params: nil, }) return &StepIOS{step: s.step} @@ -329,8 +321,8 @@ type StepIOSValidation struct { func (s *StepIOSValidation) AssertNameExists(expectedName string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorName, - Assert: assertionExists, + Check: uixt.SelectorName, + Assert: uixt.AssertionExists, Expect: expectedName, } if len(msg) > 0 { @@ -344,8 +336,8 @@ func (s *StepIOSValidation) AssertNameExists(expectedName string, msg ...string) func (s *StepIOSValidation) AssertNameNotExists(expectedName string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorName, - Assert: assertionNotExists, + Check: uixt.SelectorName, + Assert: uixt.AssertionNotExists, Expect: expectedName, } if len(msg) > 0 { @@ -359,8 +351,8 @@ func (s *StepIOSValidation) AssertNameNotExists(expectedName string, msg ...stri func (s *StepIOSValidation) AssertLabelExists(expectedLabel string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorLabel, - Assert: assertionExists, + Check: uixt.SelectorLabel, + Assert: uixt.AssertionExists, Expect: expectedLabel, } if len(msg) > 0 { @@ -374,8 +366,8 @@ func (s *StepIOSValidation) AssertLabelExists(expectedLabel string, msg ...strin func (s *StepIOSValidation) AssertLabelNotExists(expectedLabel string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorLabel, - Assert: assertionNotExists, + Check: uixt.SelectorLabel, + Assert: uixt.AssertionNotExists, Expect: expectedLabel, } if len(msg) > 0 { @@ -389,8 +381,8 @@ func (s *StepIOSValidation) AssertLabelNotExists(expectedLabel string, msg ...st func (s *StepIOSValidation) AssertOCRExists(expectedText string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorOCR, - Assert: assertionExists, + Check: uixt.SelectorOCR, + Assert: uixt.AssertionExists, Expect: expectedText, } if len(msg) > 0 { @@ -404,8 +396,8 @@ func (s *StepIOSValidation) AssertOCRExists(expectedText string, msg ...string) func (s *StepIOSValidation) AssertOCRNotExists(expectedText string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorOCR, - Assert: assertionNotExists, + Check: uixt.SelectorOCR, + Assert: uixt.AssertionNotExists, Expect: expectedText, } if len(msg) > 0 { @@ -419,8 +411,8 @@ func (s *StepIOSValidation) AssertOCRNotExists(expectedText string, msg ...strin func (s *StepIOSValidation) AssertImageExists(expectedImagePath string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorImage, - Assert: assertionExists, + Check: uixt.SelectorImage, + Assert: uixt.AssertionExists, Expect: expectedImagePath, } if len(msg) > 0 { @@ -434,8 +426,8 @@ func (s *StepIOSValidation) AssertImageExists(expectedImagePath string, msg ...s func (s *StepIOSValidation) AssertImageNotExists(expectedImagePath string, msg ...string) *StepIOSValidation { v := Validator{ - Check: uiSelectorImage, - Assert: assertionNotExists, + Check: uixt.SelectorImage, + Assert: uixt.AssertionNotExists, Expect: expectedImagePath, } if len(msg) > 0 { @@ -463,35 +455,37 @@ func (s *StepIOSValidation) Run(r *SessionRunner) (*StepResult, error) { return runStepIOS(r, s.step) } -func (r *HRPRunner) InitWDAClient(options *WDAOptions) (client *uiDriver, err error) { +func (r *HRPRunner) initUIClient(options uixt.Options) (client *uixt.DriverExt, err error) { + uuid := options.UUID() + // avoid duplicate init - if options.UDID == "" && len(r.wdaClients) == 1 { - for _, v := range r.wdaClients { + if uuid == "" && len(r.uiClients) == 1 { + for _, v := range r.uiClients { return v, nil } } // avoid duplicate init - if options.UDID != "" { - if client, ok := r.wdaClients[options.UDID]; ok { + if uuid != "" { + if client, ok := r.uiClients[uuid]; ok { return client, nil } } - driverExt, err := uixt.InitWDAClient(options) + if wdaOptions, ok := options.(*uixt.WDAOptions); ok { + client, err = uixt.InitWDAClient(wdaOptions) + } else if uiaOptions, ok := options.(*uixt.UIAOptions); ok { + client, err = uixt.InitUIAClient(uiaOptions) + } if err != nil { return nil, err } - client = &uiDriver{ - DriverExt: *driverExt, - } - // cache wda client - if r.wdaClients == nil { - r.wdaClients = make(map[string]*uiDriver) + if r.uiClients == nil { + r.uiClients = make(map[string]*uixt.DriverExt) } - r.wdaClients[options.UDID] = client + r.uiClients[uuid] = client return client, nil } @@ -506,11 +500,11 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro screenshots := make([]string, 0) // init wdaClient driver - wdaClient, err := s.hrpRunner.InitWDAClient(&step.IOS.WDAOptions) + wdaClient, err := s.hrpRunner.initUIClient(&step.IOS.WDAOptions) if err != nil { return } - wdaClient.startTime = s.startTime + wdaClient.StartTime = s.startTime defer func() { attachments := make(map[string]interface{}) @@ -519,7 +513,7 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro } // save attachments - screenshots = append(screenshots, wdaClient.screenShots...) + screenshots = append(screenshots, wdaClient.ScreenShots...) attachments["screenshots"] = screenshots stepResult.Attachments = attachments @@ -536,9 +530,9 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro }() // prepare actions - var actions []MobileAction + var actions []uixt.MobileAction if step.IOS.Actions == nil { - actions = []MobileAction{ + actions = []uixt.MobileAction{ { Method: step.IOS.Method, Params: step.IOS.Params, @@ -550,14 +544,14 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro // run actions for _, action := range actions { - if err := wdaClient.doAction(action); err != nil { + if err := wdaClient.DoAction(action); err != nil { return stepResult, err } } // take snapshot - screenshotPath, err := wdaClient.DriverExt.ScreenShot( - fmt.Sprintf("%d_validate_%d", wdaClient.startTime.Unix(), time.Now().Unix())) + screenshotPath, err := wdaClient.ScreenShot( + fmt.Sprintf("%d_validate_%d", wdaClient.StartTime.Unix(), time.Now().Unix())) if err != nil { log.Warn().Err(err).Str("step", step.Name).Msg("take screenshot failed") } else { @@ -566,7 +560,7 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro } // validate - validateResults, err := wdaClient.doValidation(step.Validators) + validateResults, err := validateUI(wdaClient, step.Validators) if err != nil { return } @@ -576,257 +570,3 @@ func runStepIOS(s *SessionRunner, step *TStep) (stepResult *StepResult, err erro stepResult.Success = true return stepResult, nil } - -var errActionNotImplemented = errors.New("UI action not implemented") - -type uiDriver struct { - uixt.DriverExt - - startTime time.Time // used to associate screenshots name - screenShots []string // save screenshots path -} - -func (ud *uiDriver) doAction(action MobileAction) error { - log.Info().Str("method", string(action.Method)).Interface("params", action.Params).Msg("start iOS UI action") - - switch action.Method { - case appInstall: - // TODO - return errActionNotImplemented - case appLaunch: - if bundleId, ok := action.Params.(string); ok { - return ud.AppLaunch(bundleId) - } - return fmt.Errorf("invalid %s params, should be bundleId(string), got %v", - appLaunch, action.Params) - case appLaunchUnattached: - if bundleId, ok := action.Params.(string); ok { - return ud.AppLaunchUnattached(bundleId) - } - return fmt.Errorf("invalid %s params, should be bundleId(string), got %v", - appLaunchUnattached, action.Params) - case swipeToTapApp: - if appName, ok := action.Params.(string); ok { - var x, y, width, height float64 - findApp := func(d *uixt.DriverExt) error { - var err error - x, y, width, height, err = d.FindTextByOCR(appName) - return err - } - foundAppAction := func(d *uixt.DriverExt) error { - // click app to launch - return d.TapFloat(x+width*0.5, y+height*0.5-20) - } - - // go to home screen - if err := ud.WebDriver.Homescreen(); err != nil { - return errors.Wrap(err, "go to home screen failed") - } - - // swipe to first screen - for i := 0; i < 5; i++ { - ud.SwipeRight() - } - - // default to retry 5 times - if action.MaxRetryTimes == 0 { - action.MaxRetryTimes = 5 - } - // swipe next screen until app found - return ud.SwipeUntil("left", findApp, foundAppAction, action.MaxRetryTimes) - } - return fmt.Errorf("invalid %s params, should be app name(string), got %v", - swipeToTapApp, action.Params) - case swipeToTapText: - if text, ok := action.Params.(string); ok { - var x, y, width, height float64 - findText := func(d *uixt.DriverExt) error { - var err error - x, y, width, height, err = d.FindTextByOCR(text) - return err - } - foundTextAction := func(d *uixt.DriverExt) error { - // tap text - return d.TapFloat(x+width*0.5, y+height*0.5) - } - - // default to retry 10 times - if action.MaxRetryTimes == 0 { - action.MaxRetryTimes = 10 - } - // swipe until live room found - return ud.SwipeUntil("up", findText, foundTextAction, action.MaxRetryTimes) - } - return fmt.Errorf("invalid %s params, should be app text(string), got %v", - swipeToTapText, action.Params) - case appTerminate: - if bundleId, ok := action.Params.(string); ok { - success, err := ud.AppTerminate(bundleId) - if err != nil { - return errors.Wrap(err, "failed to terminate app") - } - if !success { - log.Warn().Str("bundleId", bundleId).Msg("app was not running") - } - return nil - } - return fmt.Errorf("app_terminate params should be bundleId(string), got %v", action.Params) - case uiHome: - return ud.Homescreen() - case uiTapXY: - if location, ok := action.Params.([]float64); ok { - // relative x,y of window size: [0.5, 0.5] - if len(location) != 2 { - return fmt.Errorf("invalid tap location params: %v", location) - } - return ud.TapXY(location[0], location[1], action.Identifier) - } - return fmt.Errorf("invalid %s params: %v", uiTapXY, action.Params) - case uiTap: - if param, ok := action.Params.(string); ok { - return ud.Tap(param, action.Identifier, action.IgnoreNotFoundError) - } - return fmt.Errorf("invalid %s params: %v", uiTap, action.Params) - case uiTapByOCR: - if ocrText, ok := action.Params.(string); ok { - return ud.TapByOCR(ocrText, action.Identifier, action.IgnoreNotFoundError) - } - return fmt.Errorf("invalid %s params: %v", uiTapByOCR, action.Params) - case uiTapByCV: - if imagePath, ok := action.Params.(string); ok { - return ud.TapByCV(imagePath, action.Identifier, action.IgnoreNotFoundError) - } - return fmt.Errorf("invalid %s params: %v", uiTapByCV, action.Params) - case uiDoubleTapXY: - if location, ok := action.Params.([]float64); ok { - // relative x,y of window size: [0.5, 0.5] - if len(location) != 2 { - return fmt.Errorf("invalid tap location params: %v", location) - } - return ud.DoubleTapXY(location[0], location[1]) - } - return fmt.Errorf("invalid %s params: %v", uiDoubleTapXY, action.Params) - case uiDoubleTap: - if param, ok := action.Params.(string); ok { - return ud.DoubleTap(param) - } - return fmt.Errorf("invalid %s params: %v", uiDoubleTap, action.Params) - case uiSwipe: - if positions, ok := action.Params.([]float64); ok { - // relative fromX, fromY, toX, toY of window size: [0.5, 0.9, 0.5, 0.1] - if len(positions) != 4 { - return fmt.Errorf("invalid swipe params [fromX, fromY, toX, toY]: %v", positions) - } - return ud.SwipeRelative( - positions[0], positions[1], positions[2], positions[3], action.Identifier) - } - if direction, ok := action.Params.(string); ok { - return ud.SwipeTo(direction, action.Identifier) - } - return fmt.Errorf("invalid %s params: %v", uiSwipe, action.Params) - case uiInput: - // input text on current active element - // append \n to send text with enter - // send \b\b\b to delete 3 chars - param := fmt.Sprintf("%v", action.Params) - return ud.SendKeys(param) - case ctlSleep: - if param, ok := action.Params.(json.Number); ok { - seconds, _ := param.Float64() - time.Sleep(time.Duration(seconds*1000) * time.Millisecond) - return nil - } else if param, ok := action.Params.(float64); ok { - time.Sleep(time.Duration(param*1000) * time.Millisecond) - return nil - } - return fmt.Errorf("invalid sleep params: %v(%T)", action.Params, action.Params) - case ctlScreenShot: - // take snapshot - log.Info().Msg("take snapshot for current screen") - screenshotPath, err := ud.ScreenShot(fmt.Sprintf("%d_screenshot_%d", - ud.startTime.Unix(), time.Now().Unix())) - if err != nil { - return errors.Wrap(err, "take screenshot failed") - } - log.Info().Str("path", screenshotPath).Msg("take screenshot") - ud.screenShots = append(ud.screenShots, screenshotPath) - return err - case ctlStartCamera: - // start camera, alias for app_launch com.apple.camera - return ud.AppLaunch("com.apple.camera") - case ctlStopCamera: - // stop camera, alias for app_terminate com.apple.camera - success, err := ud.AppTerminate("com.apple.camera") - if err != nil { - return errors.Wrap(err, "failed to terminate camera") - } - if !success { - log.Warn().Msg("camera was not running") - } - return nil - } - return nil -} - -func (ud *uiDriver) doValidation(iValidators []interface{}) (validateResults []*ValidationResult, err error) { - for _, iValidator := range iValidators { - validator, ok := iValidator.(Validator) - if !ok { - return nil, errors.New("validator type error") - } - - validataResult := &ValidationResult{ - Validator: validator, - CheckResult: "fail", - } - - // parse check value - if !strings.HasPrefix(validator.Check, "ui_") { - validataResult.CheckResult = "skip" - log.Warn().Interface("validator", validator).Msg("skip validator") - validateResults = append(validateResults, validataResult) - continue - } - - expected, ok := validator.Expect.(string) - if !ok { - return nil, errors.New("validator expect should be string") - } - - var exists bool - if validator.Assert == assertionExists { - exists = true - } else { - exists = false - } - var result bool - switch validator.Check { - case uiSelectorName: - result = (ud.IsNameExist(expected) == exists) - case uiSelectorLabel: - result = (ud.IsLabelExist(expected) == exists) - case uiSelectorOCR: - result = (ud.IsOCRExist(expected) == exists) - case uiSelectorImage: - result = (ud.IsImageExist(expected) == exists) - } - - if result { - log.Info(). - Str("assert", validator.Assert). - Str("expect", expected). - Msg("validate UI success") - validataResult.CheckResult = "pass" - validateResults = append(validateResults, validataResult) - } else { - log.Error(). - Str("assert", validator.Assert). - Str("expect", expected). - Str("msg", validator.Message). - Msg("validate UI failed") - validateResults = append(validateResults, validataResult) - return validateResults, errors.New("step validation failed") - } - } - return validateResults, nil -}