uiautodev 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of uiautodev might be problematic. Click here for more details.
- uiautodev/__init__.py +12 -0
- uiautodev/__main__.py +10 -0
- uiautodev/app.py +92 -0
- uiautodev/appium_proxy.py +53 -0
- uiautodev/case.py +137 -0
- uiautodev/cli.py +171 -0
- uiautodev/command_proxy.py +154 -0
- uiautodev/command_types.py +89 -0
- uiautodev/driver/android.py +228 -0
- uiautodev/driver/appium.py +136 -0
- uiautodev/driver/base_driver.py +76 -0
- uiautodev/driver/ios.py +114 -0
- uiautodev/driver/mock.py +74 -0
- uiautodev/driver/udt/appium-uiautomator2-v5.12.4-light.apk +0 -0
- uiautodev/driver/udt/udt.py +259 -0
- uiautodev/exceptions.py +32 -0
- uiautodev/model.py +37 -0
- uiautodev/provider.py +76 -0
- uiautodev/router/device.py +104 -0
- uiautodev/router/xml.py +28 -0
- uiautodev/static/demo.html +34 -0
- uiautodev/utils/common.py +166 -0
- uiautodev/utils/exceptions.py +43 -0
- uiautodev/utils/usbmux.py +485 -0
- uiautodev-0.3.3.dist-info/LICENSE +21 -0
- uiautodev-0.3.3.dist-info/METADATA +56 -0
- uiautodev-0.3.3.dist-info/RECORD +29 -0
- uiautodev-0.3.3.dist-info/WHEEL +4 -0
- uiautodev-0.3.3.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Tue Mar 19 2024 10:19:27 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Request and Response
|
|
9
|
+
import enum
|
|
10
|
+
from typing import List, Optional, Union
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from uiautodev.model import Node
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# POST /api/v1/device/{serial}/command/{command}
|
|
18
|
+
class Command(str, enum.Enum):
|
|
19
|
+
TAP = "tap"
|
|
20
|
+
TAP_ELEMENT = "tapElement"
|
|
21
|
+
APP_INSTALL = "installApp"
|
|
22
|
+
APP_CURRENT = "currentApp"
|
|
23
|
+
APP_LAUNCH = "appLaunch"
|
|
24
|
+
APP_TERMINATE = "appTerminate"
|
|
25
|
+
|
|
26
|
+
GET_WINDOW_SIZE = "getWindowSize"
|
|
27
|
+
HOME = "home"
|
|
28
|
+
DUMP = "dump"
|
|
29
|
+
WAKE_UP = "wakeUp"
|
|
30
|
+
FIND_ELEMENTS = "findElements"
|
|
31
|
+
CLICK_ELEMENT = "clickElement"
|
|
32
|
+
|
|
33
|
+
LIST = "list"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TapRequest(BaseModel):
|
|
37
|
+
x: Union[int, float]
|
|
38
|
+
y: Union[int, float]
|
|
39
|
+
isPercent: bool = False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class InstallAppRequest(BaseModel):
|
|
43
|
+
url: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class InstallAppResponse(BaseModel):
|
|
47
|
+
success: bool
|
|
48
|
+
id: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CurrentAppResponse(BaseModel):
|
|
52
|
+
package: str
|
|
53
|
+
activity: Optional[str] = None
|
|
54
|
+
pid: Optional[int] = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AppLaunchRequest(BaseModel):
|
|
58
|
+
package: str
|
|
59
|
+
stop: bool = False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AppTerminateRequest(BaseModel):
|
|
63
|
+
package: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class WindowSizeResponse(BaseModel):
|
|
67
|
+
width: int
|
|
68
|
+
height: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class DumpResponse(BaseModel):
|
|
72
|
+
value: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class By(str, enum.Enum):
|
|
76
|
+
ID = "id"
|
|
77
|
+
TEXT = "text"
|
|
78
|
+
XPATH = "xpath"
|
|
79
|
+
CLASS_NAME = "className"
|
|
80
|
+
|
|
81
|
+
class FindElementRequest(BaseModel):
|
|
82
|
+
by: str
|
|
83
|
+
value: str
|
|
84
|
+
timeout: float = 10.0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class FindElementResponse(BaseModel):
|
|
88
|
+
count: int
|
|
89
|
+
value: List[Node]
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Fri Mar 01 2024 14:19:29 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import time
|
|
12
|
+
from functools import cached_property, partial
|
|
13
|
+
from typing import List, Tuple
|
|
14
|
+
from xml.etree import ElementTree
|
|
15
|
+
|
|
16
|
+
import adbutils
|
|
17
|
+
import requests
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
from uiautodev.command_types import CurrentAppResponse
|
|
21
|
+
from uiautodev.driver.base_driver import BaseDriver
|
|
22
|
+
from uiautodev.driver.udt.udt import UDT, UDTError
|
|
23
|
+
from uiautodev.exceptions import AndroidDriverException, RequestError
|
|
24
|
+
from uiautodev.model import Node, ShellResponse, WindowSize
|
|
25
|
+
from uiautodev.utils.common import fetch_through_socket
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
class AndroidDriver(BaseDriver):
|
|
30
|
+
def __init__(self, serial: str):
|
|
31
|
+
super().__init__(serial)
|
|
32
|
+
self.device = adbutils.device(serial)
|
|
33
|
+
self._try_dump_list = [
|
|
34
|
+
self._get_u2_hierarchy,
|
|
35
|
+
self._get_appium_hierarchy,
|
|
36
|
+
self._get_udt_dump_hierarchy,
|
|
37
|
+
self.device.dump_hierarchy,
|
|
38
|
+
self._get_u2_lib_hierarchy,
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
@cached_property
|
|
42
|
+
def udt(self) -> UDT:
|
|
43
|
+
return UDT(self.device)
|
|
44
|
+
|
|
45
|
+
def screenshot(self, id: int) -> Image.Image:
|
|
46
|
+
# TODO: support multi-display
|
|
47
|
+
if id > 0:
|
|
48
|
+
raise ValueError("multi-display is not supported yet")
|
|
49
|
+
try:
|
|
50
|
+
img = self.device.screenshot()
|
|
51
|
+
return img.convert("RGB")
|
|
52
|
+
except adbutils.AdbError as e:
|
|
53
|
+
logger.warning("screenshot error: %s", str(e))
|
|
54
|
+
return self.udt.screenshot()
|
|
55
|
+
|
|
56
|
+
def shell(self, command: str) -> ShellResponse:
|
|
57
|
+
try:
|
|
58
|
+
ret = self.device.shell2(command, rstrip=True, timeout=20)
|
|
59
|
+
if ret.returncode == 0:
|
|
60
|
+
return ShellResponse(output=ret.output, error=None)
|
|
61
|
+
else:
|
|
62
|
+
return ShellResponse(
|
|
63
|
+
output="", error=f"exit:{ret.returncode}, output:{ret.output}"
|
|
64
|
+
)
|
|
65
|
+
except Exception as e:
|
|
66
|
+
return ShellResponse(output="", error=f"adb error: {str(e)}")
|
|
67
|
+
|
|
68
|
+
def dump_hierarchy(self) -> Tuple[str, Node]:
|
|
69
|
+
"""returns xml string and hierarchy object"""
|
|
70
|
+
wsize = self.device.window_size()
|
|
71
|
+
logger.debug("window size: %s", wsize)
|
|
72
|
+
start = time.time()
|
|
73
|
+
xml_data = self._dump_hierarchy_raw()
|
|
74
|
+
logger.debug("dump_hierarchy cost: %s", time.time() - start)
|
|
75
|
+
|
|
76
|
+
return xml_data, parse_xml(
|
|
77
|
+
xml_data, WindowSize(width=wsize[0], height=wsize[1])
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def _dump_hierarchy_raw(self) -> str:
|
|
81
|
+
"""
|
|
82
|
+
uiautomator2 server is conflict with "uiautomator dump" command.
|
|
83
|
+
|
|
84
|
+
uiautomator dump errors:
|
|
85
|
+
- ERROR: could not get idle state.
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
for dump_func in self._try_dump_list[:]:
|
|
89
|
+
try:
|
|
90
|
+
logger.debug(f"try to dump with %s", dump_func.__name__)
|
|
91
|
+
result = dump_func()
|
|
92
|
+
logger.debug("dump success")
|
|
93
|
+
self._try_dump_list.remove(dump_func)
|
|
94
|
+
self._try_dump_list.insert(0, dump_func)
|
|
95
|
+
return result
|
|
96
|
+
except (
|
|
97
|
+
requests.RequestException,
|
|
98
|
+
AndroidDriverException,
|
|
99
|
+
UDTError,
|
|
100
|
+
adbutils.AdbError,
|
|
101
|
+
socket.timeout,
|
|
102
|
+
) as e:
|
|
103
|
+
logger.warning("dump error: %s", e)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.exception("unexpected dump error: %s", e)
|
|
106
|
+
raise AndroidDriverException("Failed to dump hierarchy")
|
|
107
|
+
|
|
108
|
+
def _get_u2_hierarchy(self) -> str:
|
|
109
|
+
import uiautomator2 as u2
|
|
110
|
+
d = u2.connect_usb(self.serial)
|
|
111
|
+
return d.dump_hierarchy()
|
|
112
|
+
# c = self.device.create_connection(adbutils.Network.TCP, 9008)
|
|
113
|
+
# try:
|
|
114
|
+
# compressed = False
|
|
115
|
+
# payload = {
|
|
116
|
+
# "jsonrpc": "2.0",
|
|
117
|
+
# "method": "dumpWindowHierarchy",
|
|
118
|
+
# "params": [compressed],
|
|
119
|
+
# "id": 1,
|
|
120
|
+
# }
|
|
121
|
+
# content = fetch_through_socket(
|
|
122
|
+
# c, "/jsonrpc/0", method="POST", json=payload, timeout=5
|
|
123
|
+
# )
|
|
124
|
+
# json_resp = json.loads(content)
|
|
125
|
+
# if "error" in json_resp:
|
|
126
|
+
# raise AndroidDriverException(json_resp["error"])
|
|
127
|
+
# return json_resp["result"]
|
|
128
|
+
# except adbutils.AdbError as e:
|
|
129
|
+
# raise AndroidDriverException(
|
|
130
|
+
# f"Failed to get hierarchy from u2 server: {str(e)}"
|
|
131
|
+
# )
|
|
132
|
+
# finally:
|
|
133
|
+
# c.close()
|
|
134
|
+
|
|
135
|
+
def _get_appium_hierarchy(self) -> str:
|
|
136
|
+
c = self.device.create_connection(adbutils.Network.TCP, 6790)
|
|
137
|
+
try:
|
|
138
|
+
content = fetch_through_socket(c, "/wd/hub/session/0/source", timeout=10)
|
|
139
|
+
return json.loads(content)["value"]
|
|
140
|
+
except (adbutils.AdbError, RequestError) as e:
|
|
141
|
+
raise AndroidDriverException(
|
|
142
|
+
f"Failed to get hierarchy from appium server: {str(e)}"
|
|
143
|
+
)
|
|
144
|
+
finally:
|
|
145
|
+
c.close()
|
|
146
|
+
|
|
147
|
+
def _get_udt_dump_hierarchy(self) -> str:
|
|
148
|
+
return self.udt.dump_hierarchy()
|
|
149
|
+
|
|
150
|
+
def _get_u2_lib_hierarchy(self) -> str:
|
|
151
|
+
try:
|
|
152
|
+
import uiautomator2 as u2
|
|
153
|
+
d = u2.connect_usb(self.serial)
|
|
154
|
+
return d.dump_hierarchy()
|
|
155
|
+
except ModuleNotFoundError:
|
|
156
|
+
raise AndroidDriverException("uiautomator2 lib not installed")
|
|
157
|
+
|
|
158
|
+
def tap(self, x: int, y: int):
|
|
159
|
+
self.device.click(x, y)
|
|
160
|
+
|
|
161
|
+
def window_size(self) -> Tuple[int, int]:
|
|
162
|
+
w, h = self.device.window_size()
|
|
163
|
+
return (w, h)
|
|
164
|
+
|
|
165
|
+
def app_install(self, app_path: str):
|
|
166
|
+
self.device.install(app_path)
|
|
167
|
+
|
|
168
|
+
def app_current(self) -> CurrentAppResponse:
|
|
169
|
+
info = self.device.app_current()
|
|
170
|
+
return CurrentAppResponse(
|
|
171
|
+
package=info.package, activity=info.activity, pid=info.pid
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
def app_launch(self, package: str):
|
|
175
|
+
if self.device.package_info(package) is None:
|
|
176
|
+
raise AndroidDriverException(f"App not installed: {package}")
|
|
177
|
+
self.device.app_start(package)
|
|
178
|
+
|
|
179
|
+
def app_terminate(self, package: str):
|
|
180
|
+
self.device.app_stop(package)
|
|
181
|
+
|
|
182
|
+
def home(self):
|
|
183
|
+
self.device.keyevent("HOME")
|
|
184
|
+
|
|
185
|
+
def wake_up(self):
|
|
186
|
+
self.device.keyevent("WAKEUP")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def parse_xml(xml_data: str, wsize: WindowSize) -> Node:
|
|
190
|
+
root = ElementTree.fromstring(xml_data)
|
|
191
|
+
return parse_xml_element(root, wsize)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_xml_element(
|
|
195
|
+
element, wsize: WindowSize, indexes: List[int] = [0]
|
|
196
|
+
) -> Node:
|
|
197
|
+
"""
|
|
198
|
+
Recursively parse an XML element into a dictionary format.
|
|
199
|
+
"""
|
|
200
|
+
name = element.tag
|
|
201
|
+
if name == "node":
|
|
202
|
+
name = element.attrib.get("class", "node")
|
|
203
|
+
bounds = None
|
|
204
|
+
# eg: bounds="[883,2222][1008,2265]"
|
|
205
|
+
if "bounds" in element.attrib:
|
|
206
|
+
bounds = element.attrib["bounds"]
|
|
207
|
+
bounds = list(map(int, re.findall(r"\d+", bounds)))
|
|
208
|
+
assert len(bounds) == 4
|
|
209
|
+
bounds = (
|
|
210
|
+
bounds[0] / wsize.width,
|
|
211
|
+
bounds[1] / wsize.height,
|
|
212
|
+
bounds[2] / wsize.width,
|
|
213
|
+
bounds[3] / wsize.height,
|
|
214
|
+
)
|
|
215
|
+
bounds = map(partial(round, ndigits=4), bounds)
|
|
216
|
+
elem = Node(
|
|
217
|
+
key="-".join(map(str, indexes)),
|
|
218
|
+
name=name,
|
|
219
|
+
bounds=bounds,
|
|
220
|
+
properties={key: element.attrib[key] for key in element.attrib},
|
|
221
|
+
children=[],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Construct xpath for children
|
|
225
|
+
for index, child in enumerate(element):
|
|
226
|
+
elem.children.append(parse_xml_element(child, wsize, indexes + [index]))
|
|
227
|
+
|
|
228
|
+
return elem
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Tue Mar 19 2024 15:51:59 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
from pprint import pprint
|
|
13
|
+
from typing import Tuple
|
|
14
|
+
|
|
15
|
+
import httpretty
|
|
16
|
+
import httpx
|
|
17
|
+
from appium import webdriver
|
|
18
|
+
from appium.options.android import UiAutomator2Options
|
|
19
|
+
from appium.options.ios import XCUITestOptions
|
|
20
|
+
from appium.webdriver.common.appiumby import AppiumBy as By
|
|
21
|
+
from PIL import Image
|
|
22
|
+
from selenium.webdriver.common.proxy import Proxy, ProxyType
|
|
23
|
+
|
|
24
|
+
from uiautodev.command_types import CurrentAppResponse
|
|
25
|
+
from uiautodev.driver.android import parse_xml
|
|
26
|
+
from uiautodev.driver.base_driver import BaseDriver
|
|
27
|
+
from uiautodev.exceptions import AppiumDriverException
|
|
28
|
+
from uiautodev.model import DeviceInfo, Node, ShellResponse, WindowSize
|
|
29
|
+
from uiautodev.provider import BaseProvider
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
class AppiumProvider(BaseProvider):
|
|
34
|
+
sessions = []
|
|
35
|
+
|
|
36
|
+
def __init__(self, command_executor: str = "http://localhost:4723/wd/hub"):
|
|
37
|
+
# command_executor = "http://localhost:4700"
|
|
38
|
+
# command_executor = "http://localhost:4720/wd/hub"
|
|
39
|
+
self.command_executor = command_executor.rstrip('/')
|
|
40
|
+
self.sessions.clear()
|
|
41
|
+
|
|
42
|
+
def list_devices(self) -> list[DeviceInfo]:
|
|
43
|
+
""" appium just return all session_ids """
|
|
44
|
+
response = httpx.get(f"{self.command_executor}/sessions", verify=False)
|
|
45
|
+
if response.status_code >= 400:
|
|
46
|
+
raise AppiumDriverException(f"Failed request to appium server: {self.command_executor} status: {response.status_code}")
|
|
47
|
+
ret = []
|
|
48
|
+
self.sessions = response.json()['value']
|
|
49
|
+
for item in self.sessions:
|
|
50
|
+
item['sessionId'] = item.pop('id')
|
|
51
|
+
print("Active sessionId", item['sessionId'])
|
|
52
|
+
serial = item['capabilities']['platformName'] + ':' + item['sessionId']
|
|
53
|
+
ret.append(DeviceInfo(
|
|
54
|
+
serial=serial,
|
|
55
|
+
model=item['capabilities']['deviceModel'],
|
|
56
|
+
name=item['capabilities']['deviceName'],
|
|
57
|
+
))
|
|
58
|
+
return ret
|
|
59
|
+
|
|
60
|
+
def get_device_driver(self, serial: str, session_id: str = None) -> BaseDriver:
|
|
61
|
+
""" TODO: attach to the existing session """
|
|
62
|
+
platform_name, session_id = serial.split(':', 1)
|
|
63
|
+
filtered_sessions = [session for session in self.sessions if session['sessionId'] == session_id]
|
|
64
|
+
if len(filtered_sessions) == 1:
|
|
65
|
+
session = filtered_sessions[0]
|
|
66
|
+
driver = self.attach_session(session)
|
|
67
|
+
return AppiumDriver(driver, is_attached=True)
|
|
68
|
+
else:
|
|
69
|
+
options = UiAutomator2Options() if platform_name == "Android" else XCUITestOptions()
|
|
70
|
+
driver = webdriver.Remote(self.command_executor, options=options)
|
|
71
|
+
return AppiumDriver(driver)
|
|
72
|
+
|
|
73
|
+
@httpretty.activate(allow_net_connect=False)
|
|
74
|
+
def attach_session(self, session: dict) -> webdriver.Remote:
|
|
75
|
+
"""
|
|
76
|
+
https://github.com/appium/python-client/issues/212
|
|
77
|
+
the author say it can't
|
|
78
|
+
"""
|
|
79
|
+
body = json.dumps({'value': session}, indent=4)
|
|
80
|
+
logger.debug("Mock response: POST /wd/hub/session", body)
|
|
81
|
+
httpretty.register_uri(httpretty.POST,
|
|
82
|
+
self.command_executor + '/session',
|
|
83
|
+
body=body,
|
|
84
|
+
headers={'Content-Type': 'application/json'})
|
|
85
|
+
options = UiAutomator2Options()# if platform_name == "Android" else XCUITestOptions()
|
|
86
|
+
driver = webdriver.Remote(command_executor=self.command_executor, strict_ssl=False, options=options)
|
|
87
|
+
return driver
|
|
88
|
+
|
|
89
|
+
def get_single_device_driver(self) -> BaseDriver:
|
|
90
|
+
devices = self.list_devices()
|
|
91
|
+
if len(devices) == 0:
|
|
92
|
+
return self.get_device_driver("Android:12345")
|
|
93
|
+
# raise AppiumDriverException("No device found")
|
|
94
|
+
return self.get_device_driver(devices[0].serial)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class AppiumDriver(BaseDriver):
|
|
98
|
+
def __init__(self, driver: webdriver.Remote, is_attached: bool = False):
|
|
99
|
+
self.driver = driver
|
|
100
|
+
self.is_attached = is_attached
|
|
101
|
+
|
|
102
|
+
# def __del__(self):
|
|
103
|
+
# if not self.is_attached:
|
|
104
|
+
# self.driver.quit()
|
|
105
|
+
|
|
106
|
+
def screenshot(self, id: int) -> Image:
|
|
107
|
+
png_data = self.driver.get_screenshot_as_png()
|
|
108
|
+
return Image.open(io.BytesIO(png_data))
|
|
109
|
+
|
|
110
|
+
def window_size(self) -> WindowSize:
|
|
111
|
+
size = self.driver.get_window_size()
|
|
112
|
+
return WindowSize(width=size["width"], height=size["height"])
|
|
113
|
+
|
|
114
|
+
def dump_hierarchy(self) -> Tuple[str, Node]:
|
|
115
|
+
source = self.driver.page_source
|
|
116
|
+
wsize = self.window_size()
|
|
117
|
+
return source, parse_xml(source, wsize)
|
|
118
|
+
|
|
119
|
+
def shell(self, command: str) -> ShellResponse:
|
|
120
|
+
# self.driver.execute_script(command)
|
|
121
|
+
raise NotImplementedError()
|
|
122
|
+
|
|
123
|
+
def tap(self, x: int, y: int):
|
|
124
|
+
self.driver.tap([(x, y)], 100)
|
|
125
|
+
print("Finished")
|
|
126
|
+
|
|
127
|
+
def app_install(self, app_path: str):
|
|
128
|
+
self.driver.install_app(app_path)
|
|
129
|
+
|
|
130
|
+
def app_current(self) -> CurrentAppResponse:
|
|
131
|
+
package = self.driver.current_package
|
|
132
|
+
activity = self.driver.current_activity
|
|
133
|
+
return CurrentAppResponse(package=package, activity=activity)
|
|
134
|
+
|
|
135
|
+
def home(self):
|
|
136
|
+
self.driver.press_keycode(3)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Fri Mar 01 2024 14:18:30 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
import abc
|
|
7
|
+
import enum
|
|
8
|
+
from typing import Tuple
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from uiautodev.command_types import CurrentAppResponse
|
|
14
|
+
from uiautodev.model import Node, ShellResponse, WindowSize
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BaseDriver(abc.ABC):
|
|
18
|
+
def __init__(self, serial: str):
|
|
19
|
+
self.serial = serial
|
|
20
|
+
|
|
21
|
+
@abc.abstractmethod
|
|
22
|
+
def screenshot(self, id: int) -> Image.Image:
|
|
23
|
+
"""Take a screenshot of the device
|
|
24
|
+
:param id: physical display ID to capture (normally: 0)
|
|
25
|
+
:return: PIL.Image.Image
|
|
26
|
+
"""
|
|
27
|
+
raise NotImplementedError()
|
|
28
|
+
|
|
29
|
+
@abc.abstractmethod
|
|
30
|
+
def dump_hierarchy(self) -> Tuple[str, Node]:
|
|
31
|
+
"""Dump the view hierarchy of the device
|
|
32
|
+
:return: xml_source, Hierarchy
|
|
33
|
+
"""
|
|
34
|
+
raise NotImplementedError()
|
|
35
|
+
|
|
36
|
+
def shell(self, command: str) -> ShellResponse:
|
|
37
|
+
"""Run a shell command on the device
|
|
38
|
+
:param command: shell command
|
|
39
|
+
:return: ShellResponse
|
|
40
|
+
"""
|
|
41
|
+
raise NotImplementedError()
|
|
42
|
+
|
|
43
|
+
def tap(self, x: int, y: int):
|
|
44
|
+
"""Tap on the screen
|
|
45
|
+
:param x: x coordinate
|
|
46
|
+
:param y: y coordinate
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError()
|
|
49
|
+
|
|
50
|
+
def window_size(self) -> WindowSize:
|
|
51
|
+
""" get window UI size """
|
|
52
|
+
raise NotImplementedError()
|
|
53
|
+
|
|
54
|
+
def app_install(self, app_path: str):
|
|
55
|
+
""" install app """
|
|
56
|
+
raise NotImplementedError()
|
|
57
|
+
|
|
58
|
+
def app_current(self) -> CurrentAppResponse:
|
|
59
|
+
""" get current app """
|
|
60
|
+
raise NotImplementedError()
|
|
61
|
+
|
|
62
|
+
def app_launch(self, package: str):
|
|
63
|
+
""" launch app """
|
|
64
|
+
raise NotImplementedError()
|
|
65
|
+
|
|
66
|
+
def app_terminate(self, package: str):
|
|
67
|
+
""" terminate app """
|
|
68
|
+
raise NotImplementedError()
|
|
69
|
+
|
|
70
|
+
def home(self):
|
|
71
|
+
""" press home button """
|
|
72
|
+
raise NotImplementedError()
|
|
73
|
+
|
|
74
|
+
def wake_up(self):
|
|
75
|
+
""" wake up the device """
|
|
76
|
+
raise NotImplementedError()
|
uiautodev/driver/ios.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
"""Created on Fri Mar 01 2024 14:35:46 by codeskyblue
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
import base64
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from functools import partial
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
from xml.etree import ElementTree
|
|
15
|
+
|
|
16
|
+
from PIL import Image
|
|
17
|
+
|
|
18
|
+
from uiautodev.command_types import CurrentAppResponse
|
|
19
|
+
from uiautodev.driver.base_driver import BaseDriver
|
|
20
|
+
from uiautodev.exceptions import IOSDriverException
|
|
21
|
+
from uiautodev.model import Node, WindowSize
|
|
22
|
+
from uiautodev.utils.usbmux import MuxDevice, select_device
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class IOSDriver(BaseDriver):
|
|
26
|
+
def __init__(self, serial: str):
|
|
27
|
+
""" serial is the udid of the ios device """
|
|
28
|
+
super().__init__(serial)
|
|
29
|
+
self.device = select_device(serial)
|
|
30
|
+
|
|
31
|
+
def _request(self, method: str, path: str, payload: Optional[dict] = None) -> bytes:
|
|
32
|
+
conn = self.device.make_http_connection(port=8100)
|
|
33
|
+
try:
|
|
34
|
+
if payload is None:
|
|
35
|
+
conn.request(method, path)
|
|
36
|
+
else:
|
|
37
|
+
conn.request(method, path, body=json.dumps(payload), headers={"Content-Type": "application/json"})
|
|
38
|
+
response = conn.getresponse()
|
|
39
|
+
if response.getcode() != 200:
|
|
40
|
+
raise IOSDriverException(f"Failed request to device, status: {response.getcode()}")
|
|
41
|
+
content = bytearray()
|
|
42
|
+
while chunk := response.read(4096):
|
|
43
|
+
content.extend(chunk)
|
|
44
|
+
return content
|
|
45
|
+
finally:
|
|
46
|
+
conn.close()
|
|
47
|
+
|
|
48
|
+
def _request_json(self, method: str, path: str) -> dict:
|
|
49
|
+
content = self._request(method, path)
|
|
50
|
+
return json.loads(content)
|
|
51
|
+
|
|
52
|
+
def _request_json_value(self, method: str, path: str) -> dict:
|
|
53
|
+
return self._request_json(method, path)["value"]
|
|
54
|
+
|
|
55
|
+
def status(self):
|
|
56
|
+
return self._request_json("GET", "/status")
|
|
57
|
+
|
|
58
|
+
def screenshot(self, id: int = 0) -> Image.Image:
|
|
59
|
+
png_base64 = self._request_json_value("GET", "/screenshot")
|
|
60
|
+
png_data = base64.b64decode(png_base64)
|
|
61
|
+
return Image.open(io.BytesIO(png_data))
|
|
62
|
+
|
|
63
|
+
def window_size(self):
|
|
64
|
+
return self._request_json_value("GET", "/window/size")
|
|
65
|
+
|
|
66
|
+
def dump_hierarchy(self) -> Tuple[str, Node]:
|
|
67
|
+
"""returns xml string and hierarchy object"""
|
|
68
|
+
xml_data = self._request_json_value("GET", "/source")
|
|
69
|
+
root = ElementTree.fromstring(xml_data)
|
|
70
|
+
return xml_data, parse_xml_element(root, WindowSize(width=1, height=1))
|
|
71
|
+
|
|
72
|
+
def tap(self, x: int, y: int):
|
|
73
|
+
self._request("POST", f"/wda/tap/0", {"x": x, "y": y})
|
|
74
|
+
|
|
75
|
+
def app_current(self) -> CurrentAppResponse:
|
|
76
|
+
# {'processArguments': {'env': {}, 'args': []}, 'name': '', 'pid': 32, 'bundleId': 'com.apple.springboard'}
|
|
77
|
+
value = self._request_json_value("GET", "/wda/activeAppInfo")
|
|
78
|
+
return CurrentAppResponse(package=value["bundleId"], pid=value["pid"])
|
|
79
|
+
|
|
80
|
+
def home(self):
|
|
81
|
+
self._request("POST", "/wda/homescreen")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def parse_xml_element(element, wsize: WindowSize, indexes: List[int]=[0]) -> Node:
|
|
85
|
+
"""
|
|
86
|
+
Recursively parse an XML element into a dictionary format.
|
|
87
|
+
# <XCUIElementTypeApplication type="XCUIElementTypeApplication" name="设置" label="设置" enabled="true" visible="true" accessible="false" x="0" y="0" width="414" height="896" index="0">
|
|
88
|
+
"""
|
|
89
|
+
if element.attrib.get("visible") == "false":
|
|
90
|
+
return None
|
|
91
|
+
if element.tag == "XCUIElementTypeApplication":
|
|
92
|
+
wsize = WindowSize(width=int(element.attrib["width"]), height=int(element.attrib["height"]))
|
|
93
|
+
x = int(element.attrib.get("x", 0))
|
|
94
|
+
y = int(element.attrib.get("y", 0))
|
|
95
|
+
width = int(element.attrib.get("width", 0))
|
|
96
|
+
height = int(element.attrib.get("height", 0))
|
|
97
|
+
bounds = (x / wsize.width, y / wsize.height, (x + width) / wsize.width, (y + height) / wsize.height)
|
|
98
|
+
bounds = list(map(partial(round, ndigits=4), bounds))
|
|
99
|
+
name = element.attrib.get("type", "XCUIElementTypeUnknown")
|
|
100
|
+
|
|
101
|
+
elem = Node(
|
|
102
|
+
key='-'.join(map(str, indexes)),
|
|
103
|
+
name=name,
|
|
104
|
+
bounds=bounds,
|
|
105
|
+
properties={key: element.attrib[key] for key in element.attrib},
|
|
106
|
+
children=[],
|
|
107
|
+
)
|
|
108
|
+
for index, child in enumerate(element):
|
|
109
|
+
child_elem = parse_xml_element(child, wsize, indexes+[index])
|
|
110
|
+
if child_elem:
|
|
111
|
+
elem.children.append(child_elem)
|
|
112
|
+
return elem
|
|
113
|
+
|
|
114
|
+
|