def find_clickable_siblings(self, e): info = layout.ElementInfo(e) if info.bounds(): # common.debug(u'\t\tFind sibling by bounds: {}'.format(info.bounds)) query = u"//*[@bounds='{0}']/following-sibling::*[@clickable='true']" \ u"| //*[@bounds='{0}']/preceding-sibling::*[@clickable='true']".format(info.bounds) elif info.res_id: # common.debug(u'\t\tFind sibling by res_id: {}'.format(info.res_id)) query = u"//*[@resource-id='{0}']/following-sibling::*[@clickable='true']" \ u"| //*[@resource-id='{0}']/preceding-sibling::*[@clickable='true']".format(info.res_id) elif info.text: # common.debug(u'\t\tFind sibling by text: {}'.format(info.text)) query = u"//*[@text='{0}']/following-sibling::*[@clickable='true']" \ u"| //*[@text='{0}']/preceding-sibling::*[@clickable='true']" \ u"| //*[text()='{0}']/following-sibling::*[@clickable='true']" \ u"| //*[text()='{0}']/preceding-sibling::*[@clickable='true']".format(info.text) elif info.desc(): # common.debug(u'\t\tFind sibling by content_desc: {}'.format(info.desc)) query = u"//*[@content-desc='{0}']/following-sibling::*[@clickable='true']" \ u"| //*[@content-desc='{0}']/preceding-sibling::*[@clickable='true']".format(info.desc) else: return [] elements = self.driver.find_elements_by_xpath(query) if elements: return elements else: return []
def tap_siblings(self, e): siblings = self.find_clickable_siblings(e) logger.debug(u'\t\tFind {} clickable siblings'.format(len(siblings))) tap_suc = False for s in siblings: ele_info = layout.ElementInfo(s) tap_suc = self.tap_test(s) if tap_suc: logger.debug(u'\t\tSibling click successful on {0}'.format(ele_info)) break return tap_suc
def tap_keyword(self, keyword, siblings_on=True, retry=3): try: tap_suc = False logger.debug(u'Try to click by keyword {0}'.format(keyword)) # Quick try elements = self.find_elements_by_keyword(keyword, clickable_only=False, exact=True) logger.debug(u'\tQuick try, find {0} elements'.format(len(elements))) for e in elements: if self.tap_test(e): return True # Comprehensive Try logger.debug(u'\tComprehensive try') elements = self.find_elements_by_keyword(keyword, clickable_only=False, sort_elements=True, text_max_len=32) if not elements: logger.debug(u'\tTry scroll') elements = self.find_elements_by_keyword(keyword, clickable_only=False, scroll=True, sort_elements=True, text_max_len=32) if not elements: logger.error(u'\tCannot find keyword "{0}"'.format(keyword)) return False for e in elements: logger.debug(u'\tTry tapping: {0}'.format(layout.ElementInfo(e))) tap_suc = self.tap_test(e) if tap_suc: break else: if siblings_on and not e.get_attribute('clickable') == 'true': logger.debug(u'\t\tTap failed, try siblings') tap_suc = self.tap_siblings(e) if tap_suc: break logger.debug(u'\tTap failed, try other matches') if not tap_suc: logger.error(u'Click by keyword "{0}" unsuccessful by all means'.format(keyword)) return tap_suc except NoSuchElementException: if retry > 0: retry = retry - 1 self.skip_irrelevant() return self.tap_keyword(keyword, siblings_on=siblings_on, retry=retry)
def _scan_attempt(self, path_keywords, result_path=None, last_matched=None, result_type='attribute', retry=0): """One scan attempt for exploration by level-based keywords scan :param path_keywords: 2-D keyword list, do recursive fuzzy match :param result_path: list storing deterministic result path :param last_matched: record last matched keywords, if explore failed, remove it from path_keywords :param result_type: attribute - result keywords are matched attribute of elements. pattern - result keywords are matched patterns defined in conf file. :param retry: retry flag for scrolling search :return: matched keyword list, or error message """ # fix "default parameter is mutable" warning if result_path is None: result_path = [] # Return condition: Every level of path_keywords is exhausted - level 0 DOES match # If dest_keywords is defined, double check for destination match if not path_keywords: if self._dest_keywords or self._dest_activities: if self._dest_keywords and self.wait_for_destination(self._dest_keywords, timeout=10): logger.info(u'Destination keywords matched. Path: ->%s' % u'->'.join(result_path)) return {'status': 'success', 'path': result_path, 'package': self.package} elif self._dest_activities and self.wait_for_activities(self._dest_activities, timeout=10): logger.info(u'Destination activities matched. Path: ->%s' % u'->'.join(result_path)) return {'status': 'success', 'path': result_path, 'package': self.package} else: logger.debug('Level 0 matched, but wrong destination') return {'status': 'deadend', 'last_turn': last_matched, 'path': result_path, 'package': self.package} else: logger.info('Level 0 keywords matched') return {'status': 'success', 'path': result_path, 'package': self.package} # 2D list: fuzzy keyword path level = 0 for kwline in path_keywords: # self.skip_irrelevant() logger.debug(u'Try level {0} keywords'.format(level)) for kw in kwline: elements = self.find_elements_by_keyword(kw, clickable_only=False, exact=False, text_max_len=32, sort_elements=True) if elements: logger.debug(u'\tMatched {0} elements'.format(len(elements))) for e in elements: e_info = layout.ElementInfo(e) e_location = e.location logger.debug(u'\t\tTap element {0}'.format(e_info)) if not self.tap_test(e): logger.warning(u'\t\tTap failed, try next element') continue else: last_matched = {'lv': level, 'kw': kw} if result_type == 'attribute': # Use matched element attribute as keyword for s in [e_info.text, e_info.res_id, e_info.desc]: if re.search(kw, s, re.I): kw = s logger.info( u'Level {0} matched\n\tKeyword: {1}, Position: {2}'.format(level, kw, e_location)) result_path.append(kw) # Recursion: with path_keywords level 0 - current level return self._scan_attempt(path_keywords[:level], result_path=result_path, last_matched=last_matched, result_type=result_type) level += 1 # Tried all remaining levels, none matching # scroll to end and retry if retry == 0: # very naive scrolling logger.debug(u'\tNothing found, try scrolling to end ...') self.scroll_down(swipe=3) return self._scan_attempt(path_keywords, result_path=result_path, last_matched=last_matched, result_type=result_type, retry=1) # scrolled and retried, return result else: if last_matched is None: return {'status': 'failed', 'path': result_path, 'package': self.package} else: return {'status': 'deadend', 'last_turn': last_matched, 'path': result_path, 'package': self.package}
def skip_irrelevant(self, initial=True, limit=None, detect_login=None): """ Skip irrelevant activities and dialogs like update and permission notifications Will call itself recursively until no irrelevant things found This function may be invoked frequently to prevent stuck """ if initial: self.rec_count = 0 if detect_login is None: detect_login = self.app_style == 'international' if limit is None: if self.SKIP_IRR_LIMIT: limit = self.SKIP_IRR_LIMIT else: limit = 20 elif self.rec_count >= self.SKIP_IRR_LIMIT: raise myexceptions.SkipIrrelevantExceedLimit else: self.rec_count += 1 try: cur_act = self.driver.current_activity if not self.is_in_app(): logger.error('!!! APP not running, raise exception ...') raise myexceptions.AppNotRunningException logger.debug(u"Try to check and skip irrelevant activities {1}, current activity: {0}" .format(cur_act, "(%d)" % self.rec_count if self.rec_count else "")) clickable = self.driver.find_elements_by_android_uiautomator('new UiSelector().clickable(true)') textedit = self.driver.find_elements_by_class_name('android.widget.EditText') logger.verbose(u'''Found: {0} clickable elements {1} TextEdit elements'''.format(len(clickable), len(textedit))) # Nothing can be clicked, wait or swipe if len(clickable) == 0 \ and len(textedit) == 0: if not self.waited: logger.debug(u'Seems to be in loading page, wait then try again ...') timeout = 6 source_before = self.page_source while timeout: sleep(1) source_after = self.page_source if difflib.SequenceMatcher(None, source_before, source_after).ratio() < 0.8: break source_before = source_after timeout -= 1 self.waited = 1 elif self.waited == 1: logger.debug(u'\ttap then wait ...') self.driver.tap([(self.window_size['width'] / 2, self.window_size['height'] / 2)]) sleep(1) self.waited = 2 elif self.waited > 1: logger.debug(u'\tswipe then wait ...') self.swipe_left() sleep(1) self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login) return # Welcome page skippable = self.contain_skip_text() get_sinks = lambda: self.driver.find_elements_by_xpath('//*[not(*)]') if not self.is_in_dialog() \ and ((skippable or (len(clickable) in range(1, 5) and len(textedit) == 0)) and len(get_sinks()) < 20): logger.debug(u'Seems to be in welcome page, try bypassing it') if detect_login and self.contain_login_text(): logger.debug(u'Find login keywords in welcome page, break skip_irrelevant') return if skippable: for kw in self.skipkw: for e in self.find_elements_by_keyword(kw): if self.tap_test(e, diff=0.98): self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login) return safe_clickable = self.find_safe_clickable_elements() if safe_clickable: to_tap = safe_clickable[-1] ele_info = layout.ElementInfo(to_tap) logger.debug(u'Tapped {0}'.format(ele_info)) if not self.tap_test(to_tap, diff=0.98): logger.warning(u'Tap failed: {0}, try swiping'.format(ele_info)) self.swipe_left() self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login) return # Dialog # TODO: decide cancel/ok by context if self.is_in_dialog() \ and len(clickable) in range(1, 5) \ and len(textedit) == 0: logger.debug(u'Seems to be a dialog, try bypassing it') if detect_login and self.contain_login_text(): logger.debug(u'Find login keywords in welcome page, break skip_irrelevant') return safe_clickable = self.find_safe_clickable_elements() if not safe_clickable: raise myexceptions.NoSafeClickableElement("Seems like a dialog that requiring update") source_before_tap = self.page_source to_tap = safe_clickable[-1] ele_info = layout.ElementInfo(to_tap) logger.debug(u'Tapped {0}'.format(ele_info)) self.tap(to_tap) if self.driver.current_activity == cur_act \ and difflib.SequenceMatcher(None, self.page_source.lower(), source_before_tap).ratio() > 0.95: logger.warning(u'Tap failed: {0}'.format(ele_info)) self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login) return # City list cities_pattern = u'(?:鞍山|安庆|安阳|安顺|北京|天津|上海|深圳|广州|成都|南京|重庆|杭州)市?' filtered = self.find_elements_by_keyword(cities_pattern, clickable_only=False, exact=True, scroll=False) if len(filtered) > 3: logger.debug(u'Seems to be a city select page, try bypassing it') to_tap = filtered[0] ele_info = layout.ElementInfo(to_tap) logger.debug(u'Tapped {0}'.format(ele_info)) self.tap(to_tap) if self.driver.current_activity == cur_act: logger.warning(u'Tap failed: {0}'.format(ele_info)) self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login) return except NoSuchElementException: self.skip_irrelevant(initial=False, limit=limit, detect_login=detect_login)
def find_elements_by_keyword(self, keyword, clickable_only=False, exact=False, scroll=False, text_max_len=0, sort_elements=False, use_uiautomator=True): """ Find elements where keyword matches one of {text, resource id, content description} :param keyword: The keyword to search for :param clickable_only: Only return clickable elements if set to True :param exact: When exact is True, return only the whole word match. Otherwise return partial match as well. :param scroll: Scroll and search or only search current screen :param text_max_len: Limit maximum text length of returned elements :param sort_elements: Sort returned elements by text length. The shorter, the higher priority. :param use_uiautomator: Whether use UIAutomator or use pure xpath for element searching. UIAutomator mode support regex, but cannot match other attributes like bounds. Pure XPath mode doesn't support regex, but will match all attributes. Also Pure XPath mode is supposed to be faster as it sends 1 request per search while UIAutomator mode sends 3. :return: list of element objects """ # TODO: make sure no path config use these keywords anymore and remove this deprecated part # ========================================================= xmax = self.window_size['width'] ymax = self.window_size['height'] corner_words = { 'TOP_LEFT_CORNER': [0, 200, 0, 200], 'TOP_RIGHT_CORNER': [xmax - 200, xmax, 0, 200], 'BOTTOM_RIGHT_CORNER': [xmax - 200, xmax, ymax - 200, ymax], 'BOTTOM_LEFT_CORNER': [0, 200, ymax - 200, ymax] } if keyword in corner_words: return self.find_clickable_elements_in_area(*corner_words[keyword]) # ========================================================== # =================== Use UIAutomator ====================== # ========================================================== if use_uiautomator: if exact: regex = u"(?i)^\\s*(?:{0})\\s*$".format(keyword) else: regex = u"(?i).*(?:{0}).*".format(keyword) queries = [ u'new UiSelector().textMatches("{0}")'.format(regex), u'new UiSelector().resourceIdMatches("{0}")'.format(regex), u'new UiSelector().descriptionMatches("{0}")'.format(regex) ] if clickable_only: queries = [q + u'.clickable(true)' for q in queries] # TODO: find a way to search all three attributes in one scroll # https://android.googlesource.com/platform/frameworks/testing/+/master/uiautomator/library/core-src/com/android/uiautomator/core/UiScrollable.java if scroll: queries = [u'new UiScrollable(new UiSelector().scrollable(true))' u'.setMaxSearchSwipes(3).scrollIntoView({0})'.format(q) for q in queries] elements = [] for q in queries: elements = self.driver.find_elements_by_android_uiautomator(q) # short cut in scroll mode (once found, return) to prevent too many times of scrolling. if elements: break else: q_combined = ';'.join(queries) elements = self.driver.find_elements_by_android_uiautomator(q_combined) # ========================================================== # ======================= Use XPath ======================== # ========================================================== # We can use one query to search for text and all attributes: # //*[text()[contains(,'keyword')] or @*[contains(.,'keyword')]] # Use translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') for case-insensitive in XPath1.0 # # !!! Appium doesnt support XPath2.0, thus doesnt support matches() and lower-case() # Case-insensitive: # //*[text()[matches(.,'keyword','i')] or @*[matches(.,'keyword','i')]] # Clickable only: # //*[text()[matches(.,'keyword','i')] or @*[matches(.,'keyword','i')] and @clickable='true'] else: keyword = keyword.lower() if exact: condition = u"{1}='{0}'" \ .format(keyword, "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')") else: condition = u"contains({1},'{0}')" \ .format(keyword, "translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz')") if clickable_only: query = u"//*[text()[{0}] or @*[{0}] and @clickable='true']".format(condition) else: query = u"//*[text()[{0}] or @*[{0}]]".format(condition) elements = self.driver.find_elements_by_xpath(query) if scroll: scrollables = self.driver.find_elements_by_xpath(u"//*[@scrollable='true']") if scrollables: scroll_cnt = 0 while not elements and scroll_cnt < 3: areas = [s.size['width'] * s.size['height'] for s in scrollables] i_max = areas.index(max(areas)) to_scroll = scrollables[i_max] x = to_scroll.location['x'] + to_scroll.size['width'] / 2 self.scroll_down(x=x) scroll_cnt += 1 elements = self.driver.find_elements_by_xpath(query) # ========================================================== # ===================== sort elements ====================== # ========================================================== if text_max_len or sort_elements: len_elements = [(len(layout.ElementInfo(e).text) if layout.ElementInfo(e).text else len(layout.ElementInfo(e).desc), e) for e in elements] # if elements have no texts, try content_desc if text_max_len: len_elements = [pair for pair in len_elements if pair[0] <= text_max_len] if sort_elements: len_elements.sort() elements = [e for (l, e) in len_elements] if elements: logger.verbose(u"max: {0}, sort: {1} - {2}".format(text_max_len, sort_elements, [p[0] for p in len_elements])) return elements