Skip to content

Commit

Permalink
remove xpath useless method (#976)
Browse files Browse the repository at this point in the history
* add RPCStackOverflowError
* remove useless code, fix find_last_match
* add support AND OR for xpath
* fix long_click, use version_compat to check apk version
* add more tests
  • Loading branch information
codeskyblue authored May 22, 2024
1 parent 64c6f2b commit 82ef1f9
Show file tree
Hide file tree
Showing 14 changed files with 396 additions and 354 deletions.
107 changes: 52 additions & 55 deletions XPATH.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ pip3 install -U uiautomator2
```

## 使用方法
目前该插件已经内置到uiautomator2中了,所以不需要plugin注册了。

### 简单用法

Expand Down Expand Up @@ -73,7 +72,7 @@ sl = d.xpath("@com.example:id/home_searchedit") # sl为XPathSelector对象

# 点击
sl.click()
sl.click(timeout=10) # 指定超时时间
sl.click(timeout=10) # 指定超时时间, 找不到抛出异常 XPathElementNotFoundError
sl.click_exists() # 存在即点击,返回是否点击成功
sl.click_exists(timeout=10) # 等待最多10s钟

Expand All @@ -94,7 +93,7 @@ el = sl.get(timeout=15)

# 修改默认的等待时间为15s
d.xpath.global_set("timeout", 15)
d.xpath.implicitly_wait(15) # 与上一行代码等价
d.xpath.implicitly_wait(15) # 与上一行代码等价 (TODO: Removed)

print(sl.exists) # 返回是否存在 (bool)
sl.get_last_match() # 获取上次匹配的XMLElement
Expand All @@ -111,15 +110,25 @@ for el in d.xpath('//android.widget.EditText').all():
print(el.elem) # 输出lxml解析出来的Node
print(el.text)

# 尚未测试的方法
# 点击位于控件包含坐标(50%, 50%)的方法
d.xpath("//*").position(0.5, 0.5).click()

# child操作
d.xpath('@android:id/list').child('/android.widget.TextView').click()
等价于 d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').all()
```

高级查找语法

> Added in version 3.1
```python
# 查找 text=NFC AND id=android:id/item
(d.xpath("NFC") & d.xpath("@android:id/item")).get()

# 查找 text=NFC OR id=android:id/item
(d.xpath("NFC") | d.xpath("App") | d.xpath("Content")).get()

# 复杂一点也支持
((d.xpath("NFC") | d.xpath("@android:id/item")) & d.xpath("//android.widget.TextView")).get()

### `XMLElement`的操作

```python
Expand Down Expand Up @@ -246,6 +255,40 @@ def main():
print("还可以继续滚动")
```

### `PageSource`对象
> Added in version 3.1
这个属于高级用法,但是这个对象也最初级,几乎所有的函数都依赖它。

什么是PageSource?

PageSource是从d.dump_hierarchy()的返回值初始化来的。主要用于通过XPATH完成元素的查找工作。

用法?

```python
source = d.xpath.get_page_source()

# find_elements 是核心方法
elements = source.find_elements('//android.widget.TextView') # List[XMLElement]
for el in elements:
print(el.text)

# 获取坐标后点击
x, y = elements[0].center()
d.click(x, y)

# 多种条件的查询写法
es1 = source.find_elements('//android.widget.TextView')
es2 = source.find_elements(XPath('@android:id/content').joinpath("//*"))

# 寻找是TextView但不属于id=android:id/content下的节点
els = set(es1) - set(es2)

# 寻找是TextView同事属于id=android:id/content下的节点
els = set(es1) & set(es2)
```

## XPath规则
为了写起脚本来更快,我们自定义了一些简化的xpath规则

Expand Down Expand Up @@ -276,20 +319,14 @@ def main():

`%知道%` 匹配包含`知道`的文本,相当于 `//*[contains(text(), '知道')]`

**~~规则5~~(目前该功能已移除)**

> 另外来自Selenium PageObjects
`$知道` 匹配 通过`d.xpath.global_set("alias", dict)` dict字典中的内容, 如果不存在将使用`知道`来匹配

**规则 Last**

会匹配text 和 description字段

`搜索` 相当于 XPath `//*[@text="搜索" or @content-desc="搜索" or @resource-id="搜索"]`

## 特殊说明
- 有时className中包含有`$`字符,这个字符在XML中是不合法的,所以全部替换成了`-`
- 有时className中包含有`$@#&`字符,这个字符在XML中是不合法的,所以全部替换成了`.`

## XPath的一些高级用法
```
Expand Down Expand Up @@ -317,44 +354,4 @@ def main():
- [XPath的一些高级用法-简书](https://www.jianshu.com/p/4fef4142b33f)
- [XPath Quicksheet](https://devhints.io/xpath)

如有其他资料,欢迎提[Issues](https://github.com/openatx/uiautomator2/issues/new)补充

## 废弃功能
**别名定义**`1.3.4`版本不再支持别名

这种写法有点类似selenium中的[PageObjects](https://selenium-python.readthedocs.io/page-objects.html)

```python
# 这里是Python3的写法,python2的string定义需要改成 u"菜单" 注意前的这个u
d.xpath.global_set("alias", {
"菜单": "@com.netease.cloudmusic:id/qh", # TODO(ssx): maybe we can support P("@com.netease.cloudmusic:id/qh", wait_timeout=2) someday
"设置": "//android.widget.TextView[@text='设置']",
})

# 这里需要 $ 开头
d.xpath("$菜单").click() # 等价于 d.xpath()
d.xpath("$设置").click()


d.xpath("$菜单").click()
# 等价于 d.xpath("@com.netease.cloudmusic:id/qh").click()

d.xpath("$小吃").click() # 在这里会直接跑出XPathError异常,因为并不存在 小吃 这个alias

# alias_strict 设置项
d.xpath.global_set("alias_strict", False) # 默认 True
d.xpath("$小吃").click() # 这里就会正常运行
# 等价于
d.xpath('//*[@text="小吃" or @content-desc="小吃"]').click()
```

# 调整xpath的日志级别
目前默认logging.INFO

调整方法

```python
import logging

d.xpath.logger.setLevel(logging.DEBUG)
```
如有其他资料,欢迎提[Issues](https://github.com/openatx/uiautomator2/issues/new)补充
11 changes: 10 additions & 1 deletion docs/2to3.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
## New
- Add function enable_pretty_logging
- Add class AdbShellError, HierarchyEmptyError, HTTPError
- Add d.xpath.get_page_source() -> PageSource

## Breaking changes (移除的功能)

Expand Down Expand Up @@ -47,7 +48,7 @@

### Function removes
- Remove function current_app, use app_current instead
- Remove function XPath.apply_watch_from_yaml
- Remove function d.xpath.apply_watch_from_yaml
- Remove function healcheck, 原来是未来恢复uiautomator服务用的
- Remove function service(name: str) -> Service, 原本是用于做atx-agent的服务管理
- Remove function app_icon() -> Image, 该函数依赖atx-agent
Expand All @@ -56,6 +57,14 @@
- Remove function set_new_command_timeout(timeout: int), 用不着了
- Remove function open_identify(), 打开一个比较明显的界面,这个函数出了点毛病,先下掉了

XPath (d.xpath) methods
- remove dump_hierarchy
- remove get_last_hierarchy
- remove add_event_listener
- remove send_click, send_longclick, send_swipe, send_text, take_screenshot
- remove when, run_watchers, watch_background, watch_stop, watch_clear, sleep_watch
- remove position method, usage like d.xpath(...).position(0.2, 0.2)

### Command remove
- Remove "uiautomator2 healthcheck"
- Remove "uiautomator2 identify"
Expand Down
11 changes: 10 additions & 1 deletion mobile_tests/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,13 @@ def call(self):
assert 2 == a.n



def test_is_version_compatiable():
assert utils.is_version_compatiable("1.0.0", "1.0.0")
assert utils.is_version_compatiable("1.0.0", "1.0.1")
assert utils.is_version_compatiable("1.0.0", "1.2.0")
assert utils.is_version_compatiable("1.0.1", "1.1.0")

assert not utils.is_version_compatiable("1.0.1", "2.1.0")
assert not utils.is_version_compatiable("1.3.1", "1.3.0")
assert not utils.is_version_compatiable("1.3.1", "1.2.0")
assert not utils.is_version_compatiable("1.3.1", "1.2.2")
20 changes: 3 additions & 17 deletions mobile_tests/test_xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,6 @@
import uiautomator2 as u2


def test_xpath_selector(dev: u2.Device):
sel1 = dev.xpath("/a")
print(str(sel1), type(str(sel1)))
assert str(sel1).endswith("=/a")
assert str(sel1.child("/b")).endswith("=/a/b")
assert str(sel1).endswith("=/a") # sel1 should not be changed
assert str(sel1.xpath("/b")).endswith("=/a|/b")
assert str(sel1.xpath(["/b", "/c"])).endswith("=/a|/b|/c")
assert sel1.position(0.1, 0.1) != sel1
assert sel1.fallback("click") != sel1
with pytest.raises(ValueError):
sel1.fallback("invalid-action")


def test_get_text(dev: u2.Device):
assert dev.xpath("App").get_text() == "App"

Expand Down Expand Up @@ -55,15 +41,15 @@ def test_element_all(dev: u2.Device):


def test_watcher(dev: u2.Device, request):
dev.xpath.when("App").click()
dev.xpath.watch_background(interval=1.0)
dev.watcher.when("App").click()
dev.watcher.start(interval=1.0)

event = threading.Event()

def _set_event(e):
e.set()

dev.xpath.when("Action Bar").call(partial(_set_event, event))
dev.watcher.when("Action Bar").call(partial(_set_event, event))
assert event.wait(5.0), "xpath not trigger callback"


Expand Down
40 changes: 38 additions & 2 deletions tests/test_xpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import pytest
from unittest.mock import Mock
from PIL import Image
from uiautomator2.xpath import XMLElement, XPathSelector, XPath, XPathElementNotFoundError, is_xpath_syntax_ok
from uiautomator2.xpath import XMLElement, XPath, XPathSelector, XPathEntry, XPathElementNotFoundError, convert_to_camel_case, is_xpath_syntax_ok, safe_xmlstr, str2bytes, strict_xpath


mock = Mock()
Expand All @@ -22,7 +22,21 @@
</hierarchy>
"""

x = XPath(mock)
x = XPathEntry(mock)


def test_safe_xmlstr():
for input, expect in [
('android.widget.TextView', 'android.widget.TextView'),
('test$123', 'test.123'),
('$@#&123.456$', '123.456'),
]:
assert safe_xmlstr(input) == expect


def test_str2bytes():
assert str2bytes(b'123') == b'123'
assert str2bytes('123') == b'123'


def test_is_xpath_syntax_ok():
Expand All @@ -32,6 +46,28 @@ def test_is_xpath_syntax_ok():
assert is_xpath_syntax_ok("//a[") is False


def test_convert_to_camel_case():
assert convert_to_camel_case("hello-world") == "helloWorld"


def test_strict_xpath():
for (input, expect) in [
("@n1", "//*[@resource-id='n1']"),
("//TextView", "//TextView"),
("//TextView[@text='n1']", "//TextView[@text='n1']"),
("(//TextView)[2]", "(//TextView)[2]"),
("//TextView/", "//TextView"), # test rstrip /
]:
assert strict_xpath(input) == expect


def test_XPath():
xp = XPath("//TextView")
assert xp == "//TextView"
assert xp.joinpath("/n1") == "//TextView/n1"



def test_xpath_selector():
assert isinstance(x("n1"), XPathSelector)
assert isinstance(x("//TextView"), XPathSelector)
Expand Down
20 changes: 12 additions & 8 deletions uiautomator2/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from uiautomator2 import xpath
from uiautomator2._proto import HTTP_TIMEOUT, SCROLL_STEPS, Direction
from uiautomator2._selector import Selector, UiObject
from uiautomator2.exceptions import AdbShellError, BaseException, DeviceError, HierarchyEmptyError, SessionBrokenError
from uiautomator2.exceptions import AdbShellError, BaseException, ConnectError, DeviceError, HierarchyEmptyError, SessionBrokenError
from uiautomator2.settings import Settings
from uiautomator2.swipe import SwipeExt
from uiautomator2.utils import list2cmdline
Expand Down Expand Up @@ -71,7 +71,10 @@ def _wait_for_device(self, timeout=10) -> adbutils.AdbDevice:
wait for device came online, if device is remote, reconnect every 1s
Returns:
adbutils.AdbDevice or None
adbutils.AdbDevice
Raises:
ConnectError
"""
for d in adbutils.adb.device_list():
if d.serial == self._serial:
Expand All @@ -98,7 +101,7 @@ def _wait_for_device(self, timeout=10) -> adbutils.AdbDevice:
except (adbutils.AdbError, adbutils.AdbTimeout):
continue
return adb.device(self._serial)
return None
raise ConnectError(f"device {self._serial} not online")

@property
def adb_device(self) -> adbutils.AdbDevice:
Expand Down Expand Up @@ -406,12 +409,13 @@ def double_click(self, x, y, duration=0.1):

def long_click(self, x, y, duration: float = .5):
'''long click at arbitrary coordinates.
Args:
duration (float): seconds of pressed
'''
x, y = self.pos_rel2abs(x, y)
with self._operation_delay("click"):
return self.touch.down(x, y).sleep(duration).up(x, y)
self.jsonrpc.click(x, y, int(duration*1000))

def swipe(self, fx, fy, tx, ty, duration: Optional[float] = None, steps: Optional[int] = None):
"""
Expand Down Expand Up @@ -901,9 +905,9 @@ def make_toast(self, text, duration=1.0):
return self.jsonrpc.makeToast(text, duration * 1000)

def unlock(self):
""" unlock screen """
""" unlock screen with swipe from left-bottom to right-top """
if not self.info['screenOn']:
self.press("power")
self.shell("input keyevent WAKEUP")
self.swipe(0.1, 0.9, 0.9, 0.1)


Expand Down Expand Up @@ -1033,8 +1037,8 @@ def watcher(self) -> Watcher:
return Watcher(self)

@cached_property
def xpath(self) -> xpath.XPath:
return xpath.XPath(self)
def xpath(self) -> xpath.XPathEntry:
return xpath.XPathEntry(self)

@cached_property
def image(self):
Expand Down
Loading

0 comments on commit 82ef1f9

Please sign in to comment.