diff --git a/examples/uitest/demo_android_douyin_test.go b/examples/uitest/demo_android_douyin_test.go index b43f8b9f..d07558ce 100644 --- a/examples/uitest/demo_android_douyin_test.go +++ b/examples/uitest/demo_android_douyin_test.go @@ -11,12 +11,12 @@ import ( func TestAndroidDouYinLive(t *testing.T) { testCase := &hrp.TestCase{ Config: hrp.NewConfig("通过 feed 头像进入抖音直播间"). - SetAndroid(hrp.WithAdbLogOn(true)), + SetAndroid(hrp.WithAdbLogOn(true), hrp.WithSerialNumber("2d06bf70")), TestSteps: []hrp.IStep{ hrp.NewStep("打开网页"). Android(). Home(). - AppTerminate("com.google.android.apps.chrome.Main").Sleep(1). // 关闭已运行的抖音,确保启动抖音后在「抖音」首页 + AppTerminate("com.google.android.apps.chrome.Main").Sleep(1). SwipeToTapApp("Chrome", hrp.WithMaxRetryTimes(5)).TapByOCR("搜索").Input("https://gtftask.bytedance.com/local-time").TapByOCR("前往").Sleep(5). Validate(). AssertOCRExists("1664", "网页打开失败"), diff --git a/hrp/internal/builtin/utils_unix.go b/hrp/internal/builtin/utils_unix.go index a23da061..4e5ff1ad 100644 --- a/hrp/internal/builtin/utils_unix.go +++ b/hrp/internal/builtin/utils_unix.go @@ -69,6 +69,11 @@ func ensurePython3Venv(venv string, packages ...string) (python3 string, err err return python3, nil } +func Command(name string, arg ...string) *exec.Cmd { + args := strings.Join(arg, " ") + return exec.Command("bash", "-c", name, args) +} + func ExecCommand(cmdName string, args ...string) error { cmd := exec.Command(cmdName, args...) log.Info().Str("cmd", cmd.String()).Msg("exec command") diff --git a/hrp/internal/builtin/utils_windows.go b/hrp/internal/builtin/utils_windows.go index 062b493b..52aea058 100644 --- a/hrp/internal/builtin/utils_windows.go +++ b/hrp/internal/builtin/utils_windows.go @@ -100,6 +100,11 @@ func ensurePython3Venv(venvDir string, packages ...string) (python3 string, err return python3, nil } +func Command(name string, arg ...string) *exec.Cmd { + args := strings.Join(arg, " ") + return exec.Command("cmd", "/c", name, args) +} + func ExecCommand(cmdName string, args ...string) error { // "cmd /c" carries out the command specified by string and then stops // refer: https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/cmd diff --git a/hrp/internal/uixt/android_device.go b/hrp/internal/uixt/android_device.go index 568adcdf..f58afb10 100644 --- a/hrp/internal/uixt/android_device.go +++ b/hrp/internal/uixt/android_device.go @@ -2,12 +2,20 @@ package uixt import ( "bytes" + "context" "fmt" "net" + "os/exec" "reflect" + "regexp" + "strconv" + "strings" + "syscall" "github.com/electricbubble/gadb" + "github.com/httprunner/httprunner/v4/hrp/internal/builtin" "github.com/pkg/errors" + "github.com/rs/zerolog/log" ) var ( @@ -19,6 +27,15 @@ var ( const forwardToPrefix = "forward-to-" +const ( + regexFloat = `[0-9\.]*` +) + +var ( + regexCompileSwipe = regexp.MustCompile(fmt.Sprintf(`timesec=(%s)\s*startX=(%s)\s*startY=(%s)\s*endX=(%s)\s*endY=(%s)`, regexFloat, regexFloat, regexFloat, regexFloat, regexFloat)) // parse ${var} or $var + regexCompileTap = regexp.MustCompile(fmt.Sprintf(`timesec=(%s)\s*x=(%s)\s*y=(%s)`, regexFloat, regexFloat, regexFloat)) // parse ${func1($a, $b)} // parse number +) + func InitUIAClient(device *AndroidDevice) (*DriverExt, error) { var deviceOptions []AndroidDeviceOption if device.SerialNumber != "" { @@ -51,10 +68,13 @@ func InitUIAClient(device *AndroidDevice) (*DriverExt, error) { } if device.LogOn { - // TODO + err = driverExt.Driver.StartCaptureLog("hrp_adb_log") + if err != nil { + return nil, err + } } - return driverExt, nil + return driverExt, err } type AndroidDeviceOption func(*AndroidDevice) @@ -106,6 +126,7 @@ func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, er device.SerialNumber = dev.Serial() device.d = dev + device.logcat = NewAdbLogcat(serialNumber) return device, nil } @@ -114,6 +135,7 @@ func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, er type AndroidDevice struct { d gadb.Device + logcat *DeviceLogcat SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"` IP string `json:"ip,omitempty" yaml:"ip,omitempty"` Port int `json:"port,omitempty" yaml:"port,omitempty"` @@ -152,6 +174,7 @@ func (dev *AndroidDevice) NewUSBDriver(capabilities Capabilities) (driver *uiaDr return nil, err } driver.adbDevice = dev.d + driver.logcat = dev.logcat driver.localPort = localPort return driver, nil @@ -182,6 +205,151 @@ func getFreePort() (int, error) { return l.Addr().(*net.TCPAddr).Port, nil } +type DeviceLogcat struct { + serial string + logBuffer *bytes.Buffer + errs []error + stopping chan struct{} + done chan struct{} + cmd *exec.Cmd +} + +func NewAdbLogcat(serial string) *DeviceLogcat { + return &DeviceLogcat{ + serial: serial, + logBuffer: new(bytes.Buffer), + stopping: make(chan struct{}), + done: make(chan struct{}), + } +} + +// CatchLogcatContext starts logcat with timeout context +func (l *DeviceLogcat) CatchLogcatContext(timeoutCtx context.Context) (err error) { + if err = l.CatchLogcat(); err != nil { + return + } + go func() { + select { + case <-timeoutCtx.Done(): + _ = l.Stop() + case <-l.stopping: + } + }() + return +} + +func (l *DeviceLogcat) Stop() error { + select { + case <-l.stopping: + default: + close(l.stopping) + <-l.done + close(l.done) + } + return l.Errors() +} + +func (l *DeviceLogcat) Errors() (err error) { + for _, e := range l.errs { + if err != nil { + err = fmt.Errorf("%v |[DeviceLogcatErr] %v", err, e) + } else { + err = fmt.Errorf("[DeviceLogcatErr] %v", e) + } + } + return +} + +func (l *DeviceLogcat) CatchLogcat() (err error) { + if l.cmd != nil { + err = fmt.Errorf("logcat already start") + } + cmdLine := fmt.Sprintf("adb -s %s logcat -c && adb -s %s logcat -v time -s iesqaMonitor:V", l.serial, l.serial) + l.cmd = builtin.Command(cmdLine) + l.cmd.Stderr = l.logBuffer + l.cmd.Stdout = l.logBuffer + l.cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err = l.cmd.Start(); err != nil { + return + } + go func() { + <-l.stopping + if e := syscall.Kill(-l.cmd.Process.Pid, syscall.SIGKILL); e != nil { + l.errs = append(l.errs, fmt.Errorf("kill logcat process err:%v", e)) + } + l.done <- struct{}{} + }() + return +} + +func (l *DeviceLogcat) BufferedLogcat() (err error) { + // -d: dump the current buffered logcat result and exits + cmdLine := fmt.Sprintf("adb -s %s logcat -d", l.serial) + cmd := builtin.Command(cmdLine) + cmd.Stdout = l.logBuffer + cmd.Stderr = l.logBuffer + if err = cmd.Run(); err != nil { + return + } + return +} + +type ExportPoint struct { + Start int `json:"start" yaml:"start"` + End int `json:"end" yaml:"end"` + From interface{} `json:"from" yaml:"from"` + To interface{} `json:"to" yaml:"to"` + Operation string `json:"operation" yaml:"operation"` + Ext string `json:"ext" yaml:"ext"` + RunTime int `json:"run_time,omitempty" yaml:"run_time,omitempty"` +} + +func ConvertPoints(data string) (eps []ExportPoint) { + lines := strings.Split(data, "\n") + for _, line := range lines { + if strings.Contains(line, "startX") { + matched := regexCompileSwipe.FindStringSubmatch(line) + if len(matched) != 6 { + log.Error().Msg("failed to parse point data") + continue + } + start, _ := strconv.Atoi(matched[1]) + fromX, _ := strconv.ParseFloat(matched[2], 64) + fromY, _ := strconv.ParseFloat(matched[3], 64) + toX, _ := strconv.ParseFloat(matched[4], 64) + toY, _ := strconv.ParseFloat(matched[5], 64) + p := ExportPoint{ + Start: start, + End: start, + From: []float64{fromX, fromY}, + To: []float64{toX, toY}, + Operation: "Gtf-Drag", + Ext: "", + } + eps = append(eps, p) + } else if strings.Contains(line, "x=") { + matched := regexCompileTap.FindStringSubmatch(line) + if len(matched) != 4 { + log.Error().Msg("failed to parse point data") + continue + } + start, _ := strconv.Atoi(matched[1]) + x, _ := strconv.ParseFloat(matched[2], 64) + y, _ := strconv.ParseFloat(matched[3], 64) + p := ExportPoint{ + Start: start, + End: start, + From: []float64{x, y}, + To: []float64{x, y}, + Operation: "Gtf-Tap", + Ext: "", + } + eps = append(eps, p) + } + } + return +} + type UiSelectorHelper struct { value *bytes.Buffer } diff --git a/hrp/internal/uixt/android_device_test.go b/hrp/internal/uixt/android_device_test.go new file mode 100644 index 00000000..4195a5ef --- /dev/null +++ b/hrp/internal/uixt/android_device_test.go @@ -0,0 +1,18 @@ +package uixt + +import ( + "fmt" + "testing" + + "github.com/httprunner/httprunner/v4/hrp/internal/json" +) + +func TestConvertPoints(t *testing.T) { + data := "09-29 15:02:08.379 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434928378\tstartX=720.000000\tstartY=1462.000000\tendX=1296.000000\tendY=1462.000000\n09-29 15:02:09.433 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434929432\tstartX=720.000000\tstartY=1462.000000\tendX=1296.000000\tendY=1462.000000\n09-29 15:02:10.452 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434930452\tstartX=720.000000\tstartY=1462.000000\tendX=1296.000000\tendY=1462.000000\n09-29 15:02:11.451 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434931450\tstartX=720.000000\tstartY=1462.000000\tendX=1296.000000\tendY=1462.000000\n09-29 15:02:12.491 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434932489\tstartX=720.000000\tstartY=1462.000000\tendX=1296.000000\tendY=1462.000000\n09-29 15:02:16.028 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434936027\tstartX=720.000000\tstartY=1462.000000\tendX=144.000000\tendY=1462.000000\n09-29 15:02:21.424 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434941423\tstartX=720.000000\tstartY=1462.000000\tendX=144.000000\tendY=1462.000000\n09-29 15:02:27.923 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434947922\tstartX=720.000000\tstartY=1462.000000\tendX=144.000000\tendY=1462.000000\n09-29 15:02:33.628 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434953628\tstartX=720.000000\tstartY=1462.000000\tendX=144.000000\tendY=1462.000000\n09-29 15:02:39.347 I/iesqaMonitor( 9938): [tap]\ttimesec=1664434959347\tx=1259.5y=1868.5" + eps := ConvertPoints(data) + if len(eps) != 10 { + t.Fatal() + } + jsons, _ := json.Marshal(eps) + println(fmt.Sprintf("%v", string(jsons))) +} diff --git a/hrp/internal/uixt/android_driver.go b/hrp/internal/uixt/android_driver.go index 858cb2d7..ad3ce2a9 100644 --- a/hrp/internal/uixt/android_driver.go +++ b/hrp/internal/uixt/android_driver.go @@ -22,6 +22,7 @@ type uiaDriver struct { Driver adbDevice gadb.Device + logcat *DeviceLogcat localPort int } @@ -237,10 +238,37 @@ func (ud *uiaDriver) PressBack() (err error) { } func (ud *uiaDriver) StartCamera() (err error) { - if _, err = ud.adbDevice.RunShellCommand("am", "start", "-a", "android.media.action.VIDEO_CAPTURE"); err != nil { + if _, err = ud.adbDevice.RunShellCommand("rm", "-r", "/sdcard/DCIM/Camera"); err != nil { return err } - return + time.Sleep(5 * time.Second) + var version string + if version, err = ud.adbDevice.RunShellCommand("getprop", "ro.build.version.release"); err != nil { + return err + } + if version == "11" || version == "12" { + if _, err = ud.adbDevice.RunShellCommand("am", "start", "-a", "android.media.action.STILL_IMAGE_CAMERA"); err != nil { + return err + } + time.Sleep(5 * time.Second) + if _, err = ud.adbDevice.RunShellCommand("input", "swipe", "750", "1000", "250", "1000"); err != nil { + return err + } + time.Sleep(5 * time.Second) + if _, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil { + return err + } + return + } else { + if _, err = ud.adbDevice.RunShellCommand("am", "start", "-a", "android.media.action.VIDEO_CAPTURE"); err != nil { + return err + } + time.Sleep(5 * time.Second) + if _, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil { + return err + } + return + } } func (ud *uiaDriver) StopCamera() (err error) { @@ -265,24 +293,6 @@ func (ud *uiaDriver) StopCamera() (err error) { return } -func (ud *uiaDriver) StartRecording() (err error) { - var res string - if res, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil { - return err - } - log.Info().Str("shell", res) - return -} - -func (ud *uiaDriver) StopRecording() (err error) { - var res string - if res, err = ud.adbDevice.RunShellCommand("input", "keyevent", "27"); err != nil { - return err - } - log.Info().Str("shell", res) - return -} - func (ud *uiaDriver) ActiveAppInfo() (info AppInfo, err error) { // TODO return info, errDriverNotImplemented @@ -675,6 +685,14 @@ func (ud *uiaDriver) SendKeys(text string, options ...DataOption) (err error) { return } +func (ud *uiaDriver) Input(text string, options ...DataOption) (err error) { + element, err := ud.FindElement(BySelector{ClassName: ElementType{EditText: true}}) + if err != nil { + return err + } + return element.SendKeys(text) +} + func (ud *uiaDriver) KeyboardDismiss(keyNames ...string) (err error) { // TODO return errDriverNotImplemented @@ -946,11 +964,18 @@ func (ud *uiaDriver) Wait(condition Condition) error { } func (ud *uiaDriver) StartCaptureLog(identifier ...string) (err error) { - // TODO + log.Info().Msg("start adb log recording") + err = ud.logcat.CatchLogcat() return } func (ud *uiaDriver) StopCaptureLog() (result interface{}, err error) { - // TODO - return + log.Info().Msg("stop adb log recording") + err = ud.logcat.Stop() + if err != nil { + log.Error().Err(err).Msg("failed to get adb log recording") + return "", err + } + content := ud.logcat.logBuffer.String() + return ConvertPoints(content), nil } diff --git a/hrp/internal/uixt/ext.go b/hrp/internal/uixt/ext.go index 93155a76..bf7b6422 100644 --- a/hrp/internal/uixt/ext.go +++ b/hrp/internal/uixt/ext.go @@ -469,9 +469,9 @@ func (dExt *DriverExt) DoAction(action MobileAction) error { "enable": true, "data": action.Identifier, }) - return dExt.Driver.SendKeys(param, option) + return dExt.Driver.Input(param, option) } - return dExt.Driver.SendKeys(param) + return dExt.Driver.Input(param) case CtlSleep: if param, ok := action.Params.(json.Number); ok { seconds, _ := param.Float64() @@ -500,10 +500,6 @@ func (dExt *DriverExt) DoAction(action MobileAction) error { return dExt.Driver.StartCamera() case CtlStopCamera: return dExt.Driver.StopCamera() - case RecordStart: - return dExt.Driver.StartRecording() - case RecordStop: - return dExt.Driver.StopRecording() } return nil } diff --git a/hrp/internal/uixt/interface.go b/hrp/internal/uixt/interface.go index cbc7c668..4922ba37 100644 --- a/hrp/internal/uixt/interface.go +++ b/hrp/internal/uixt/interface.go @@ -715,6 +715,7 @@ type ElementType struct { Tab bool `json:"XCUIElementTypeTab"` TouchBar bool `json:"XCUIElementTypeTouchBar"` StatusItem bool `json:"XCUIElementTypeStatusItem"` + EditText bool `json:"android.widget.EditText"` } // ProtectedResource A system resource that requires user authorization to access. @@ -884,9 +885,6 @@ type WebDriver interface { // StopCamera Stops the camera for recording StopCamera() error - StartRecording() error - StopRecording() error - // Tap Sends a tap event at the coordinate. Tap(x, y int, options ...DataOption) error TapFloat(x, y float64, options ...DataOption) error @@ -927,6 +925,9 @@ type WebDriver interface { // WithFrequency option can be used to set frequency of typing (letters per sec). The default value is 60 SendKeys(text string, options ...DataOption) error + // Input works like SendKeys + Input(text string, options ...DataOption) error + // KeyboardDismiss Tries to dismiss the on-screen keyboard KeyboardDismiss(keyNames ...string) error diff --git a/hrp/internal/uixt/ios_driver.go b/hrp/internal/uixt/ios_driver.go index ac4159f0..4e4e131f 100644 --- a/hrp/internal/uixt/ios_driver.go +++ b/hrp/internal/uixt/ios_driver.go @@ -527,6 +527,10 @@ func (wd *wdaDriver) SendKeys(text string, options ...DataOption) (err error) { return } +func (wd *wdaDriver) Input(text string, options ...DataOption) (err error) { + return wd.SendKeys(text, options...) +} + func (wd *wdaDriver) KeyboardDismiss(keyNames ...string) (err error) { // [[FBRoute POST:@"/wda/keyboard/dismiss"] respondWithTarget:self action:@selector(handleDismissKeyboardCommand:)] if len(keyNames) == 0 { @@ -575,16 +579,6 @@ func (wd *wdaDriver) StopCamera() (err error) { return nil } -func (wd *wdaDriver) StartRecording() (err error) { - // TODO - return errDriverNotImplemented -} - -func (wd *wdaDriver) StopRecording() (err error) { - // TODO - return errDriverNotImplemented -} - func (wd *wdaDriver) ExpectNotification(notifyName string, notifyType NotificationType, second ...int) (err error) { // [[FBRoute POST:@"/wda/expectNotification"] respondWithTarget:self action:@selector(handleExpectNotification:)] if len(second) == 0 { diff --git a/hrp/step_ios_ui.go b/hrp/step_ios_ui.go index e0a1a985..7b16b405 100644 --- a/hrp/step_ios_ui.go +++ b/hrp/step_ios_ui.go @@ -477,7 +477,7 @@ func (r *HRPRunner) initUIClient(device uixt.Device) (client *uixt.DriverExt, er uuid := device.UUID() // avoid duplicate init - if uuid == "" && len(r.uiClients) == 1 { + if uuid == "" && len(r.uiClients) > 0 { for _, v := range r.uiClients { return v, nil }