package uixt import ( "bytes" "context" "fmt" "net" "os/exec" "strings" "github.com/pkg/errors" "github.com/rs/zerolog/log" "github.com/httprunner/httprunner/v4/hrp/internal/code" "github.com/httprunner/httprunner/v4/hrp/internal/json" "github.com/httprunner/httprunner/v4/hrp/internal/myexec" "github.com/httprunner/httprunner/v4/hrp/pkg/gadb" ) var ( AdbServerHost = "localhost" AdbServerPort = gadb.AdbServerPort // 5037 UIA2ServerHost = "localhost" UIA2ServerPort = 6790 DeviceTempPath = "/data/local/tmp" ) const forwardToPrefix = "forward-to-" type AndroidDeviceOption func(*AndroidDevice) func WithSerialNumber(serial string) AndroidDeviceOption { return func(device *AndroidDevice) { device.SerialNumber = serial } } func WithUIA2(uia2On bool) AndroidDeviceOption { return func(device *AndroidDevice) { device.UIA2 = uia2On } } func WithUIA2IP(ip string) AndroidDeviceOption { return func(device *AndroidDevice) { device.UIA2IP = ip } } func WithUIA2Port(port int) AndroidDeviceOption { return func(device *AndroidDevice) { device.UIA2Port = port } } func WithAdbLogOn(logOn bool) AndroidDeviceOption { return func(device *AndroidDevice) { device.LogOn = logOn } } func GetAndroidDeviceOptions(dev *AndroidDevice) (deviceOptions []AndroidDeviceOption) { if dev.SerialNumber != "" { deviceOptions = append(deviceOptions, WithSerialNumber(dev.SerialNumber)) } if dev.UIA2 { deviceOptions = append(deviceOptions, WithUIA2(true)) } if dev.UIA2IP != "" { deviceOptions = append(deviceOptions, WithUIA2IP(dev.UIA2IP)) } if dev.UIA2Port != 0 { deviceOptions = append(deviceOptions, WithUIA2Port(dev.UIA2Port)) } if dev.LogOn { deviceOptions = append(deviceOptions, WithAdbLogOn(true)) } return } // uiautomator2 server must be started before // adb shell am instrument -w io.appium.uiautomator2.server.test/androidx.test.runner.AndroidJUnitRunner func NewAndroidDevice(options ...AndroidDeviceOption) (device *AndroidDevice, err error) { device = &AndroidDevice{ UIA2IP: UIA2ServerHost, UIA2Port: UIA2ServerPort, } for _, option := range options { option(device) } deviceList, err := GetAndroidDevices(device.SerialNumber) if err != nil { return nil, err } dev := deviceList[0] device.SerialNumber = dev.Serial() device.d = dev device.logcat = NewAdbLogcat(device.SerialNumber) log.Info().Str("serial", device.SerialNumber).Msg("select android device") return device, nil } func GetAndroidDevices(serial ...string) (devices []*gadb.Device, err error) { var adbClient gadb.Client if adbClient, err = gadb.NewClientWith(AdbServerHost, AdbServerPort); err != nil { return nil, err } if devices, err = adbClient.DeviceList(); err != nil { return nil, err } var deviceList []*gadb.Device // filter by serial for _, d := range devices { for _, s := range serial { if s != "" && s != d.Serial() { continue } deviceList = append(deviceList, d) } } if len(deviceList) == 0 { var err error if serial == nil || (len(serial) == 1 && serial[0] == "") { err = fmt.Errorf("no android device found") } else { err = fmt.Errorf("no android device found for serial %v", serial) } return nil, err } return deviceList, nil } type AndroidDevice struct { d *gadb.Device logcat *AdbLogcat SerialNumber string `json:"serial,omitempty" yaml:"serial,omitempty"` UIA2 bool `json:"uia2,omitempty" yaml:"uia2,omitempty"` // use uiautomator2 UIA2IP string `json:"uia2_ip,omitempty" yaml:"uia2_ip,omitempty"` // uiautomator2 server ip UIA2Port int `json:"uia2_port,omitempty" yaml:"uia2_port,omitempty"` // uiautomator2 server port LogOn bool `json:"log_on,omitempty" yaml:"log_on,omitempty"` } func (dev *AndroidDevice) UUID() string { return dev.SerialNumber } func (dev *AndroidDevice) LogEnabled() bool { return dev.LogOn } func (dev *AndroidDevice) NewDriver(options ...DriverOption) (driverExt *DriverExt, err error) { driverOptions := &DriverOptions{} for _, option := range options { option(driverOptions) } var driver WebDriver if dev.UIA2 { driver, err = dev.NewUSBDriver(driverOptions.capabilities) } else { driver, err = dev.NewAdbDriver() } if err != nil { return nil, errors.Wrap(err, "failed to init UIA driver") } driverExt, err = newDriverExt(dev, driver) if err != nil { return nil, err } err = driverExt.extendCV() if err != nil { return nil, errors.Wrap(code.MobileUIDriverError, fmt.Sprintf("extend OpenCV failed: %v", err)) } if dev.LogOn { err = driverExt.Driver.StartCaptureLog("hrp_adb_log") if err != nil { return nil, err } } return driverExt, nil } // NewUSBDriver creates new client via USB connected device, this will also start a new session. func (dev *AndroidDevice) NewUSBDriver(capabilities Capabilities) (driver WebDriver, err error) { var localPort int if localPort, err = getFreePort(); err != nil { return nil, errors.Wrap(code.AndroidDeviceConnectionError, fmt.Sprintf("get free port failed: %v", err)) } if err = dev.d.Forward(localPort, UIA2ServerPort); err != nil { return nil, errors.Wrap(code.AndroidDeviceConnectionError, fmt.Sprintf("forward port %d->%d failed: %v", localPort, UIA2ServerPort, err)) } rawURL := fmt.Sprintf("http://%s%d:%d/wd/hub", forwardToPrefix, localPort, UIA2ServerPort) uiaDriver, err := NewUIADriver(capabilities, rawURL) if err != nil { _ = dev.d.ForwardKill(localPort) return nil, errors.Wrap(code.AndroidDeviceConnectionError, err.Error()) } uiaDriver.adbClient = dev.d uiaDriver.logcat = dev.logcat return uiaDriver, nil } // NewHTTPDriver creates new remote HTTP client, this will also start a new session. func (dev *AndroidDevice) NewHTTPDriver(capabilities Capabilities) (driver WebDriver, err error) { rawURL := fmt.Sprintf("http://%s:%d/wd/hub", dev.UIA2IP, dev.UIA2Port) uiaDriver, err := NewUIADriver(capabilities, rawURL) if err != nil { return nil, err } uiaDriver.adbClient = dev.d uiaDriver.logcat = dev.logcat return uiaDriver, nil } func (dev *AndroidDevice) NewAdbDriver() (driver WebDriver, err error) { adbDriver := NewAdbDriver() adbDriver.adbClient = dev.d adbDriver.logcat = dev.logcat return adbDriver, nil } func (dev *AndroidDevice) StartPerf() error { // TODO return nil } func (dev *AndroidDevice) StopPerf() string { // TODO return "" } func (dev *AndroidDevice) StartPcap() error { // TODO return nil } func (dev *AndroidDevice) StopPcap() string { // TODO return "" } func getFreePort() (int, error) { addr, err := net.ResolveTCPAddr("tcp", "localhost:0") if err != nil { return 0, errors.Wrap(err, "resolve tcp addr failed") } l, err := net.ListenTCP("tcp", addr) if err != nil { return 0, errors.Wrap(err, "listen tcp addr failed") } defer func() { _ = l.Close() }() return l.Addr().(*net.TCPAddr).Port, nil } type AdbLogcat struct { serial string logBuffer *bytes.Buffer errs []error stopping chan struct{} done chan struct{} cmd *exec.Cmd } func NewAdbLogcat(serial string) *AdbLogcat { return &AdbLogcat{ serial: serial, logBuffer: new(bytes.Buffer), stopping: make(chan struct{}), done: make(chan struct{}), } } // CatchLogcatContext starts logcat with timeout context func (l *AdbLogcat) 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 *AdbLogcat) Stop() error { select { case <-l.stopping: default: close(l.stopping) <-l.done close(l.done) } return l.Errors() } func (l *AdbLogcat) 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 *AdbLogcat) CatchLogcat() (err error) { if l.cmd != nil { log.Warn().Msg("logcat already start") return nil } // clear logcat if err = myexec.RunCommand("adb", "-s", l.serial, "shell", "logcat", "-c"); err != nil { return } // start logcat l.cmd = myexec.Command("adb", "-s", l.serial, "logcat", "--format", "time", "-s", "iesqaMonitor:V") l.cmd.Stderr = l.logBuffer l.cmd.Stdout = l.logBuffer if err = l.cmd.Start(); err != nil { return } go func() { <-l.stopping if e := myexec.KillProcessesByGpid(l.cmd); e != nil { log.Error().Err(e).Msg("kill logcat process failed") } l.done <- struct{}{} }() return } func (l *AdbLogcat) BufferedLogcat() (err error) { // -d: dump the current buffered logcat result and exits cmd := myexec.Command("adb", "-s", l.serial, "logcat", "-d") 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, "ext") { idx := strings.Index(line, "{") if idx == -1 { continue } line = line[idx:] p := ExportPoint{} err := json.Unmarshal([]byte(line), &p) if err != nil { log.Error().Msg("failed to parse point data") continue } eps = append(eps, p) } } return } type UiSelectorHelper struct { value *bytes.Buffer } func NewUiSelectorHelper() UiSelectorHelper { return UiSelectorHelper{value: bytes.NewBufferString("new UiSelector()")} } func (s UiSelectorHelper) String() string { return s.value.String() + ";" } // Text Set the search criteria to match the visible text displayed // in a widget (for example, the text label to launch an app). // // The text for the element must match exactly with the string in your input // argument. Matching is case-sensitive. func (s UiSelectorHelper) Text(text string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.text("%s")`, text)) return s } // TextMatches Set the search criteria to match the visible text displayed in a layout // element, using a regular expression. // // The text in the widget must match exactly with the string in your // input argument. func (s UiSelectorHelper) TextMatches(regex string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.textMatches("%s")`, regex)) return s } // TextStartsWith Set the search criteria to match visible text in a widget that is // prefixed by the text parameter. // // The matching is case-insensitive. func (s UiSelectorHelper) TextStartsWith(text string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.textStartsWith("%s")`, text)) return s } // TextContains Set the search criteria to match the visible text in a widget // where the visible text must contain the string in your input argument. // // The matching is case-sensitive. func (s UiSelectorHelper) TextContains(text string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.textContains("%s")`, text)) return s } // ClassName Set the search criteria to match the class property // for a widget (for example, "android.widget.Button"). func (s UiSelectorHelper) ClassName(className string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.className("%s")`, className)) return s } // ClassNameMatches Set the search criteria to match the class property // for a widget, using a regular expression. func (s UiSelectorHelper) ClassNameMatches(regex string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.classNameMatches("%s")`, regex)) return s } // Description Set the search criteria to match the content-description // property for a widget. // // The content-description is typically used // by the Android Accessibility framework to // provide an audio prompt for the widget when // the widget is selected. The content-description // for the widget must match exactly // with the string in your input argument. // // Matching is case-sensitive. func (s UiSelectorHelper) Description(desc string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.description("%s")`, desc)) return s } // DescriptionMatches Set the search criteria to match the content-description // property for a widget. // // The content-description is typically used // by the Android Accessibility framework to // provide an audio prompt for the widget when // the widget is selected. The content-description // for the widget must match exactly // with the string in your input argument. func (s UiSelectorHelper) DescriptionMatches(regex string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.descriptionMatches("%s")`, regex)) return s } // DescriptionStartsWith Set the search criteria to match the content-description // property for a widget. // // The content-description is typically used // by the Android Accessibility framework to // provide an audio prompt for the widget when // the widget is selected. The content-description // for the widget must start // with the string in your input argument. // // Matching is case-insensitive. func (s UiSelectorHelper) DescriptionStartsWith(desc string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.descriptionStartsWith("%s")`, desc)) return s } // DescriptionContains Set the search criteria to match the content-description // property for a widget. // // The content-description is typically used // by the Android Accessibility framework to // provide an audio prompt for the widget when // the widget is selected. The content-description // for the widget must contain // the string in your input argument. // // Matching is case-insensitive. func (s UiSelectorHelper) DescriptionContains(desc string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.descriptionContains("%s")`, desc)) return s } // ResourceId Set the search criteria to match the given resource ID. func (s UiSelectorHelper) ResourceId(id string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.resourceId("%s")`, id)) return s } // ResourceIdMatches Set the search criteria to match the resource ID // of the widget, using a regular expression. func (s UiSelectorHelper) ResourceIdMatches(regex string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.resourceIdMatches("%s")`, regex)) return s } // Index Set the search criteria to match the widget by its node // index in the layout hierarchy. // // The index value must be 0 or greater. // // Using the index can be unreliable and should only // be used as a last resort for matching. Instead, // consider using the `Instance(int)` method. func (s UiSelectorHelper) Index(index int) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.index(%d)`, index)) return s } // Instance Set the search criteria to match the // widget by its instance number. // // The instance value must be 0 or greater, where // the first instance is 0. // // For example, to simulate a user click on // the third image that is enabled in a UI screen, you // could specify a a search criteria where the instance is // 2, the `className(String)` matches the image // widget class, and `enabled(boolean)` is true. // The code would look like this: // // `new UiSelector().className("android.widget.ImageView") // .enabled(true).instance(2);` func (s UiSelectorHelper) Instance(instance int) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.instance(%d)`, instance)) return s } // Enabled Set the search criteria to match widgets that are enabled. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Enabled(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.enabled(%t)`, b)) return s } // Focused Set the search criteria to match widgets that have focus. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Focused(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.focused(%t)`, b)) return s } // Focusable Set the search criteria to match widgets that are focusable. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Focusable(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.focusable(%t)`, b)) return s } // Scrollable Set the search criteria to match widgets that are scrollable. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Scrollable(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.scrollable(%t)`, b)) return s } // Selected Set the search criteria to match widgets that // are currently selected. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Selected(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.selected(%t)`, b)) return s } // Checked Set the search criteria to match widgets that // are currently checked (usually for checkboxes). // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Checked(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.checked(%t)`, b)) return s } // Checkable Set the search criteria to match widgets that are checkable. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Checkable(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.checkable(%t)`, b)) return s } // Clickable Set the search criteria to match widgets that are clickable. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) Clickable(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.clickable(%t)`, b)) return s } // LongClickable Set the search criteria to match widgets that are long-clickable. // // Typically, using this search criteria alone is not useful. // You should also include additional criteria, such as text, // content-description, or the class name for a widget. // // If no other search criteria is specified, and there is more // than one matching widget, the first widget in the tree // is selected. func (s UiSelectorHelper) LongClickable(b bool) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.longClickable(%t)`, b)) return s } // packageName Set the search criteria to match the package name // of the application that contains the widget. func (s UiSelectorHelper) packageName(name string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.packageName(%s)`, name)) return s } // PackageNameMatches Set the search criteria to match the package name // of the application that contains the widget. func (s UiSelectorHelper) PackageNameMatches(regex string) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.packageNameMatches(%s)`, regex)) return s } // ChildSelector Adds a child UiSelector criteria to this selector. // // Use this selector to narrow the search scope to // child widgets under a specific parent widget. func (s UiSelectorHelper) ChildSelector(selector UiSelectorHelper) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.childSelector(%s)`, selector.value.String())) return s } func (s UiSelectorHelper) PatternSelector(selector UiSelectorHelper) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.patternSelector(%s)`, selector.value.String())) return s } func (s UiSelectorHelper) ContainerSelector(selector UiSelectorHelper) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.containerSelector(%s)`, selector.value.String())) return s } // FromParent Adds a child UiSelector criteria to this selector which is used to // start search from the parent widget. // // Use this selector to narrow the search scope to // sibling widgets as well all child widgets under a parent. func (s UiSelectorHelper) FromParent(selector UiSelectorHelper) UiSelectorHelper { s.value.WriteString(fmt.Sprintf(`.fromParent(%s)`, selector.value.String())) return s }