applib-ui 0.0.1__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.
Files changed (55) hide show
  1. applib_ui/__init__.py +13 -0
  2. applib_ui/case.py +79 -0
  3. applib_ui/cli.py +75 -0
  4. applib_ui/core/__init__.py +0 -0
  5. applib_ui/core/android/__init__.py +16 -0
  6. applib_ui/core/android/case.py +8 -0
  7. applib_ui/core/android/driver.py +191 -0
  8. applib_ui/core/android/element.py +291 -0
  9. applib_ui/core/android/page.py +6 -0
  10. applib_ui/core/android/recorder.py +171 -0
  11. applib_ui/core/android/remote_adb.py +69 -0
  12. applib_ui/core/android/remote_driver.py +176 -0
  13. applib_ui/core/img/__init__.py +4 -0
  14. applib_ui/core/img/driver.py +43 -0
  15. applib_ui/core/img/element.py +73 -0
  16. applib_ui/core/ios/__init__.py +16 -0
  17. applib_ui/core/ios/case.py +8 -0
  18. applib_ui/core/ios/driver.py +290 -0
  19. applib_ui/core/ios/elem.py +222 -0
  20. applib_ui/core/ios/local_wda.py +576 -0
  21. applib_ui/core/ios/page.py +6 -0
  22. applib_ui/core/ios/recorder.py +202 -0
  23. applib_ui/core/ios/remote_driver.py +220 -0
  24. applib_ui/core/ios/remote_wda.py +54 -0
  25. applib_ui/core/ocr/__init__.py +5 -0
  26. applib_ui/core/ocr/driver.py +185 -0
  27. applib_ui/core/ocr/element.py +65 -0
  28. applib_ui/device.py +53 -0
  29. applib_ui/element.py +210 -0
  30. applib_ui/page.py +6 -0
  31. applib_ui/pytest_plugin.py +57 -0
  32. applib_ui/running/__init__.py +0 -0
  33. applib_ui/running/conf.py +14 -0
  34. applib_ui/running/conf.yml +19 -0
  35. applib_ui/running/runner.py +33 -0
  36. applib_ui/utils/__init__.py +1 -0
  37. applib_ui/utils/aes_util.py +47 -0
  38. applib_ui/utils/allure_util.py +213 -0
  39. applib_ui/utils/base64_util.py +21 -0
  40. applib_ui/utils/common.py +84 -0
  41. applib_ui/utils/config.py +135 -0
  42. applib_ui/utils/cv_util.py +110 -0
  43. applib_ui/utils/dingtalk.py +56 -0
  44. applib_ui/utils/excel_util.py +101 -0
  45. applib_ui/utils/log.py +12 -0
  46. applib_ui/utils/mail.py +47 -0
  47. applib_ui/utils/mysql_util.py +74 -0
  48. applib_ui/utils/nacos_util.py +52 -0
  49. applib_ui/utils/pytest_util.py +89 -0
  50. applib_ui/utils/swagger_util.py +195 -0
  51. applib_ui-0.0.1.dist-info/METADATA +170 -0
  52. applib_ui-0.0.1.dist-info/RECORD +55 -0
  53. applib_ui-0.0.1.dist-info/WHEEL +5 -0
  54. applib_ui-0.0.1.dist-info/entry_points.txt +5 -0
  55. applib_ui-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,171 @@
1
+ """
2
+ 基于本机 scrcpy 命令行的安卓录屏。
3
+ """
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import tempfile
9
+ import time
10
+
11
+ import allure
12
+
13
+ from applib_ui.utils.log import logger
14
+
15
+
16
+ def _find_scrcpy():
17
+ path = shutil.which("scrcpy")
18
+ if path:
19
+ return path
20
+ for candidate in ("/opt/homebrew/bin/scrcpy", "/usr/local/bin/scrcpy"):
21
+ if os.path.isfile(candidate):
22
+ return candidate
23
+ return None
24
+
25
+
26
+ class ScrcpyRecorder:
27
+ #: 启动失败后重启 scrcpy 的重试次数(adb 抖动、设备被占用等偶发问题)
28
+ START_RETRY = 2
29
+
30
+ def __init__(self, serial: str):
31
+ self.serial = serial
32
+ self.file_path = None
33
+ self.proc = None
34
+ self._log_file = None
35
+
36
+ def start(self, file_path: str, timeout: float = 10) -> bool:
37
+ """
38
+ 启动录屏:等录屏文件真正开始写数据才算成功,失败会重启 scrcpy 重试
39
+ @param file_path: 输出文件
40
+ @param timeout: 单次等待"文件开始写入"的上限
41
+ @return: 是否成功启动;最终失败只记日志不抛异常(录屏是辅助能力,不该让功能用例失败)
42
+ """
43
+ scrcpy_bin = _find_scrcpy()
44
+ if not scrcpy_bin:
45
+ raise FileNotFoundError(
46
+ "未找到 scrcpy,请先执行: brew install scrcpy"
47
+ )
48
+
49
+ self.file_path = file_path
50
+ base_cmd = [
51
+ scrcpy_bin,
52
+ "-s", self.serial,
53
+ "--record", file_path,
54
+ "--no-playback",
55
+ "--no-audio",
56
+ ]
57
+ last_error = ""
58
+ for attempt in range(1, self.START_RETRY + 1):
59
+ logger.info(f"开始录屏({attempt}/{self.START_RETRY}): {file_path}")
60
+ # 每次重试前清掉上一次的残留文件,否则"文件非空"的判断会被旧文件骗过去
61
+ if os.path.exists(file_path):
62
+ os.remove(file_path)
63
+ try:
64
+ self._launch(base_cmd, timeout)
65
+ logger.info(f"录屏已开始: {file_path}")
66
+ return True
67
+ except RuntimeError as e:
68
+ last_error = str(e)
69
+ logger.warning(f"第 {attempt}/{self.START_RETRY} 次启动录屏失败:{last_error}")
70
+ # 清掉半死的进程,避免多个 scrcpy 抢同一个设备
71
+ self._terminate(timeout=5)
72
+ if attempt < self.START_RETRY:
73
+ time.sleep(0.5)
74
+
75
+ logger.error(f"scrcpy 录屏没起来,本次不会生成录屏文件:{last_error}")
76
+ return False
77
+
78
+ def _launch(self, base_cmd, timeout: float):
79
+ """起 scrcpy 并等它真的开始写文件(含 --no-window 不被支持时的回退)"""
80
+ self.proc = self._popen(base_cmd + ["--no-window"])
81
+ time.sleep(0.3)
82
+ if self.proc.poll() is not None:
83
+ log = self._read_log()
84
+ if "unknown" in log.lower() or "unrecognized" in log.lower():
85
+ logger.warning("当前 scrcpy 不支持 --no-window,回退后再启动")
86
+ self.proc = self._popen(base_cmd)
87
+ else:
88
+ raise RuntimeError(f"scrcpy 启动失败: {log}")
89
+
90
+ deadline = time.time() + timeout
91
+ while time.time() < deadline:
92
+ if self.proc.poll() is not None:
93
+ raise RuntimeError(f"scrcpy 启动失败: {self._read_log()}")
94
+ if os.path.exists(self.file_path) and os.path.getsize(self.file_path) > 0:
95
+ return
96
+ time.sleep(0.2)
97
+ raise RuntimeError(f"等待录屏文件写入超时({timeout}s)")
98
+
99
+ def _popen(self, cmd):
100
+ self._close_log()
101
+ self._log_file = tempfile.TemporaryFile()
102
+ return subprocess.Popen(
103
+ cmd,
104
+ stdin=subprocess.DEVNULL,
105
+ stdout=self._log_file,
106
+ stderr=subprocess.STDOUT,
107
+ start_new_session=True,
108
+ )
109
+
110
+ def _read_log(self):
111
+ if self._log_file is None:
112
+ return ""
113
+ self._log_file.seek(0)
114
+ return self._log_file.read().decode("utf-8", errors="ignore").strip()
115
+
116
+ def _close_log(self):
117
+ if self._log_file is not None:
118
+ self._log_file.close()
119
+ self._log_file = None
120
+
121
+ def _signal_group(self, sig):
122
+ if self.proc is None:
123
+ return
124
+ try:
125
+ os.killpg(self.proc.pid, sig)
126
+ except ProcessLookupError:
127
+ pass
128
+
129
+ def _terminate(self, timeout: float = 15):
130
+ """SIGINT → SIGTERM → SIGKILL,逐级结束 scrcpy 进程组"""
131
+ if self.proc is None or self.proc.poll() is not None:
132
+ return
133
+ self._signal_group(signal.SIGINT)
134
+ try:
135
+ self.proc.wait(timeout=timeout)
136
+ return
137
+ except subprocess.TimeoutExpired:
138
+ logger.warning("scrcpy SIGINT 未退出,尝试 SIGTERM")
139
+ self._signal_group(signal.SIGTERM)
140
+ try:
141
+ self.proc.wait(timeout=5)
142
+ return
143
+ except subprocess.TimeoutExpired:
144
+ logger.error("scrcpy 停止超时,强制结束进程")
145
+ self._signal_group(signal.SIGKILL)
146
+ try:
147
+ self.proc.wait(timeout=5)
148
+ except subprocess.TimeoutExpired:
149
+ pass
150
+
151
+ def stop(self):
152
+ self._terminate(timeout=15)
153
+ if self.proc is not None and self.proc.poll() not in (0, None, -signal.SIGINT, -signal.SIGTERM):
154
+ logger.error(f"scrcpy 退出异常(returncode={self.proc.returncode}): {self._read_log()}")
155
+ # 关掉日志前留一份,便于定位"为什么没录上"
156
+ scrcpy_log = self._read_log()
157
+ self.proc = None
158
+ self._close_log()
159
+
160
+ if self.file_path and os.path.exists(self.file_path) and os.path.getsize(self.file_path) > 0:
161
+ logger.info(f"录屏保存至: {self.file_path}")
162
+ logger.info("录屏上传allure报告")
163
+ allure.attach.file(
164
+ self.file_path,
165
+ attachment_type=allure.attachment_type.MP4,
166
+ name=self.file_path,
167
+ )
168
+ return self.file_path
169
+
170
+ logger.error("录屏文件为空或不存在" + (f";scrcpy 日志: {scrcpy_log[-300:]}" if scrcpy_log else ""))
171
+ return None
@@ -0,0 +1,69 @@
1
+ import requests
2
+
3
+ from applib_ui.utils.common import get_free_port
4
+
5
+
6
+ class RemoteAdbInit:
7
+ """
8
+ 远端adb连接初始化
9
+ """
10
+
11
+ def __init__(self, serial: str, sonic_host: str, sonic_user: str, sonic_pwd: str):
12
+ self.serial = serial
13
+ self.host = sonic_host
14
+ # 登录获取登录态请求头
15
+ print('sonic登录,获取token')
16
+ body = {"userName": sonic_user, "password": sonic_pwd}
17
+ res = requests.post(self.host + '/server/api/controller/users/login', json=body)
18
+ print(res.text)
19
+ token = res.json()['data']
20
+ self.headers = {
21
+ 'sonictoken': token
22
+ }
23
+
24
+ def occupy_device(self):
25
+ print('占用设备')
26
+ try:
27
+ body = {
28
+ "udId": self.serial,
29
+ "sasRemotePort": get_free_port(),
30
+ }
31
+ res = requests.post(self.host + '/server/api/controller/devices/occupy', json=body, headers=self.headers)
32
+ print(res.text)
33
+ res_json = res.json()
34
+ data = res_json['data']
35
+ print(data)
36
+ # uia2 = data['uia2']
37
+ sas = data['sas']
38
+ adb_server = sas.split()[-1]
39
+ return adb_server
40
+ except Exception as e:
41
+ print(f'设备占用失败: \n + {str(e)}')
42
+
43
+ def release_device(self):
44
+ print('释放设备')
45
+ try:
46
+ query = {'udId': self.serial}
47
+ res = requests.get(self.host + '/server/api/controller/devices/release', params=query, headers=self.headers)
48
+ print(res.json())
49
+ except Exception as e:
50
+ print(f'设备释放失败:\n + {str(e)}')
51
+
52
+ def delete_offline_device(self):
53
+ print('删除离线设备')
54
+ try:
55
+ # 查询离线设备
56
+ res = requests.get(f'{self.host}/server/api/controller/devices/list?page=1&pageSize=12&status%5B%5D=DISCONNECTED', headers=self.headers)
57
+ print(res.text)
58
+ res_json = res.json()
59
+ device_list = res_json['data']['content']
60
+ # 删除离线设备
61
+ for device in device_list:
62
+ device_id = device['id']
63
+ res = requests.delete(f'{self.host}/server/api/controller/devices?id={device_id}', headers=self.headers)
64
+ print(res.text)
65
+ except Exception as e:
66
+ print(f"删除设备失败: {str(e)}")
67
+
68
+
69
+
@@ -0,0 +1,176 @@
1
+ import allure
2
+ import time
3
+ import uiautomator2 as u2
4
+
5
+ from adbutils import adb
6
+ from .remote_adb import RemoteAdbInit
7
+ from applib_ui.utils.log import logger
8
+ from applib_ui.utils.common import general_file_path
9
+
10
+
11
+ class RemoteDriver:
12
+ """
13
+ https://github.com/openatx/adbutils
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ serial: str,
19
+ sonic_host: str,
20
+ sonic_user: str,
21
+ sonic_pwd: str,
22
+ package: str = None
23
+ ):
24
+ """
25
+ @param serial: 通过adb devices或者关于手机-序列号获取
26
+ @param package: 通过adb shell pm list packages获取
27
+ """
28
+ logger.info(f'初始化远端设备连接: {serial}')
29
+ self.serial = serial
30
+ print('获取adb连接ip:port')
31
+ self.remote_adb = RemoteAdbInit(serial, sonic_host, sonic_user, sonic_pwd)
32
+ self.adb_server = self.remote_adb.occupy_device()
33
+ print(self.adb_server)
34
+ self.package = package
35
+ print('连接远端adb服务')
36
+ count = 15
37
+ while count > 0:
38
+ output = adb.connect(self.adb_server)
39
+ print(output)
40
+ if 'connected' in output:
41
+ logger.info('adb服务已就绪')
42
+ break
43
+ else:
44
+ logger.info('adb服务未就绪,1s后重试!')
45
+ time.sleep(1)
46
+ else:
47
+ # 释放设备
48
+ self.remote_adb.release_device()
49
+ raise KeyError('adb服务连接超时!')
50
+
51
+ device = adb.device(self.adb_server)
52
+ self.d = u2.connect(device)
53
+
54
+ def close(self):
55
+ print('释放远端设备连接')
56
+ # 释放设备
57
+ self.remote_adb.release_device()
58
+ # 断开adb连接
59
+ output = adb.disconnect(self.adb_server)
60
+ print(output)
61
+ # 等待3s
62
+ time.sleep(3)
63
+ # 删除设备
64
+ self.remote_adb.delete_offline_device()
65
+
66
+ def uninstall_app(self, package: str = None):
67
+ """
68
+ 卸载应用
69
+ @return:
70
+ """
71
+ if self.package is None:
72
+ if package is None:
73
+ raise KeyError('package不能为空')
74
+ else:
75
+ self.d.app_uninstall(package)
76
+ else:
77
+ self.d.app_uninstall(self.package)
78
+
79
+ def install_app(self, apk_url: str, package: str = None):
80
+ """
81
+ 安装应用
82
+ @return:
83
+ """
84
+ if self.package is None:
85
+ if package is None:
86
+ raise KeyError('package不能为空')
87
+ else:
88
+ self.d.app_install(package)
89
+ else:
90
+ self.d.app_install(self.package)
91
+ self.d.app_install(apk_url)
92
+
93
+ def start_app(self, package: str = None):
94
+ """
95
+ 启动应用
96
+ @return:
97
+ """
98
+ if self.package is None:
99
+ if package is None:
100
+ raise KeyError('package不能为空')
101
+ else:
102
+ self.d.app_start(package)
103
+ else:
104
+ self.d.app_start(self.package)
105
+
106
+ def stop_app(self, package: str = None):
107
+ """
108
+ 停止应用
109
+ @return:
110
+ """
111
+ if self.package is None:
112
+ if package is None:
113
+ raise KeyError('package不能为空')
114
+ else:
115
+ self.d.app_stop(package)
116
+ else:
117
+ self.d.app_stop(self.package)
118
+
119
+ def shot(self, file_name=None):
120
+ """
121
+ 截图
122
+ @param file_name:
123
+ @return:
124
+ """
125
+ file_path = general_file_path(file_name, self.serial)
126
+ logger.info(f"截图保存至: {file_path}")
127
+ self.d.screenshot(file_path)
128
+
129
+ logger.info("截图上传allure报告")
130
+ allure.attach.file(
131
+ file_path,
132
+ attachment_type=allure.attachment_type.PNG,
133
+ name=f"{file_path}",
134
+ )
135
+ return file_path
136
+
137
+ def click(self, x, y):
138
+ """
139
+ 点击坐标
140
+ @param x:
141
+ @param y:
142
+ @return:
143
+ """
144
+ self.d.click(x, y)
145
+
146
+ def input(self, *args, **kwargs):
147
+ """
148
+ 输入
149
+ @param args:
150
+ @param kwargs:
151
+ @return:
152
+ """
153
+ self.d.send_keys(*args, **kwargs)
154
+
155
+ def shell(self, *args, **kwargs):
156
+ """
157
+ 执行adb shell
158
+ @param args:
159
+ @param kwargs:
160
+ @return:
161
+ """
162
+ self.d.shell(*args, **kwargs)
163
+
164
+ def unlock(self):
165
+ """
166
+ 解锁设备
167
+ @return:
168
+ """
169
+ self.shell("input keyevent WAKEUP")
170
+ self.d.swipe(0.1, 0.9, 0.9, 0.1)
171
+
172
+
173
+
174
+
175
+
176
+
@@ -0,0 +1,4 @@
1
+ from .element import ImgElem
2
+
3
+
4
+ __all__ = ["ImgElem"]
@@ -0,0 +1,43 @@
1
+ # 'pip install opencv-python==4.6.0.66'
2
+ import os
3
+ import cv2
4
+ import numpy as np
5
+
6
+
7
+ class ImageDiscern:
8
+ def __init__(self, target_image, source_image, grade=0.8, gauss_num=111) -> None:
9
+ """__init__ [处理图片识别]
10
+ Args:
11
+ target_image (str): 被识别的目标图片
12
+ grade (float, optional): 分数. Defaults to 0.8.
13
+ gauss_num (int, optional): 过滤值. Defaults to 111.
14
+ """
15
+ self.source_image = source_image
16
+ self.target_image = target_image
17
+ if not os.path.exists(self.source_image):
18
+ raise FileNotFoundError(f"文件: {self.source_image} 不存在")
19
+ if not os.path.exists(self.target_image):
20
+ raise FileNotFoundError(f"文件: {self.target_image} 不存在")
21
+
22
+ self.grade = grade
23
+ self.gauss_num = gauss_num
24
+
25
+ # 降噪处理(高斯滤波)
26
+ def __coordinate(self, image):
27
+ return cv2.GaussianBlur(image, (self.gauss_num, self.gauss_num), 0)
28
+
29
+ # 获取坐标图片坐标
30
+ def get_coordinate(self):
31
+ # 这里不用imread,解决中文目录无法读取的问题
32
+ screen = cv2.imdecode(np.fromfile(self.source_image, dtype=np.uint8), 1)
33
+ target = cv2.imdecode(np.fromfile(self.target_image, dtype=np.uint8), 1)
34
+ result = cv2.matchTemplate(self.__coordinate(screen),
35
+ self.__coordinate(target),
36
+ cv2.TM_CCOEFF_NORMED)
37
+ min, max, min_loc, max_loc = cv2.minMaxLoc(result)
38
+ if max <= self.grade:
39
+ return None
40
+ else:
41
+ x = max_loc[0] + int(target.shape[1] / 2)
42
+ y = max_loc[1] + int(target.shape[0] / 2)
43
+ return (x, y)
@@ -0,0 +1,73 @@
1
+ # 'pip install opencv-python==4.6.0.66'
2
+ import time
3
+
4
+ from .driver import ImageDiscern
5
+ from applib_ui.utils.log import logger
6
+
7
+
8
+ class ImgElem(object):
9
+ """图像识别定位"""
10
+
11
+ def __init__(self, driver=None, file: str = None):
12
+ """
13
+
14
+ @param driver: 设备驱动
15
+ @param file: 图片文件路径
16
+ """
17
+ self.driver = driver
18
+ self.target_image = file
19
+ # if hasattr(self.driver, 'scale') is True:
20
+ # self.scale = self.driver.scale
21
+ # else:
22
+ # self.scale = None
23
+
24
+ def __get__(self, instance, owner):
25
+ if instance is None:
26
+ return None
27
+
28
+ self.driver = instance.driver
29
+ return self
30
+
31
+ def wait(self, timeout=1):
32
+ logger.info(f'图像识别判断: {self.target_image} 是否存在')
33
+ time.sleep(timeout)
34
+ source_image = self.driver.shot("图像识别中")
35
+ res = ImageDiscern(self.target_image, source_image).get_coordinate()
36
+ logger.debug(res)
37
+ # logger.error(self.scale)
38
+ # logger.error(self.driver.d.scale)
39
+ # logger.error(self.driver.d.screen_info())
40
+ if isinstance(res, tuple):
41
+ x, y = res[0], res[1]
42
+ try:
43
+ scale = self.driver.d.scale
44
+ except:
45
+ scale = None
46
+ if scale is not None:
47
+ """iphone的scale是3"""
48
+ x, y = int(x / scale), int(y / scale)
49
+ return x, y
50
+ else:
51
+ return False
52
+
53
+ def click_exists(self, timeout=3):
54
+ logger.info(f'图像如果存在则点击: {self.target_image}')
55
+ res = self.wait(timeout=timeout)
56
+ if res is not False:
57
+ self.driver.click(*res)
58
+ # logger.info(f"点击({res})")
59
+ else:
60
+ logger.info("图像不存在,不进行点击")
61
+
62
+ def click(self, retry=3, timeout=3):
63
+ logger.info(f'图像识别点击: {self.target_image}')
64
+ for i in range(retry):
65
+ logger.info(f'第{i + 1}次查找:')
66
+ res = self.wait(timeout=timeout)
67
+ if res is not False:
68
+ self.driver.click(*res)
69
+ logger.info(f"点击{res}")
70
+ break
71
+ else:
72
+ self.driver.shot(f'图像识别定位失败')
73
+ raise Exception('未识别到图片,无法进行点击')
@@ -0,0 +1,16 @@
1
+ """ios 平台接口,按需加载驱动依赖。"""
2
+
3
+ from importlib import import_module
4
+
5
+ __all__ = ['Elem', 'Page', 'Driver', 'RemoteDriver', 'TestCase']
6
+
7
+ _MODULES = {'Elem': 'elem', 'Page': 'page', 'Driver': 'driver',
8
+ 'RemoteDriver': 'remote_driver', 'TestCase': 'case'}
9
+
10
+
11
+ def __getattr__(name):
12
+ if name not in _MODULES:
13
+ raise AttributeError(f'module {__name__!r} has no attribute {name!r}')
14
+ value = getattr(import_module(f'.{_MODULES[name]}', __name__), name)
15
+ globals()[name] = value
16
+ return value
@@ -0,0 +1,8 @@
1
+ from ...case import TestCase as BaseTestCase
2
+
3
+
4
+ class TestCase(BaseTestCase):
5
+ """iOS pytest 测试基类。"""
6
+
7
+ _platform = 'ios'
8
+