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,576 @@
1
+ import contextlib
2
+ import fcntl
3
+ import json
4
+ import os
5
+ import shutil
6
+ import signal
7
+ import time
8
+ import subprocess
9
+ import urllib.error
10
+ import urllib.request
11
+
12
+ from applib_ui.utils.log import logger
13
+ from applib_ui.utils.common import get_free_port
14
+
15
+ # tunnel-info 端口被占用、或隧道没建出来时,重建 agent 的重试次数
16
+ _TUNNEL_RESTART_RETRY = 2
17
+ # 等本设备隧道真正可用的上限(秒)
18
+ # go-ios 的 `tunnel start` 只保证 HTTP-API 起来,隧道是后台异步建的,
19
+ # 且首轮建失败后会退避 30s 再试,所以这里必须大于 30s,否则会在退避窗口内误判失败
20
+ _TUNNEL_READY_TIMEOUT = 40
21
+ # ---------------- tunnel-info 端口池 ----------------
22
+ # go-ios 的 tunnel-info 端口和 userspace 数据面端口是"连号"关系:
23
+ # 数据面端口 = tunnel-info-port + 1、+2、+3…(内部 portOffset 从 1 起,且只增不减)
24
+ # 所以这个端口绝不能从 get_free_port() 里取:它拿的是内核 ephemeral 端口
25
+ # (macOS net.inet.ip.portrange.first=49152),而且基本是连号下发,两台设备必然互相踩:
26
+ # A 的 tunnel-info=50518、B 的 tunnel-info=50517
27
+ # → B 的数据面要 listen 50518,正好是 A 的 HTTP 端口 → bind: address already in use
28
+ # 因此改成:每台设备固定占一个"窗口",窗口整体落在 ephemeral 范围之外,
29
+ # 窗口内 100 个端口预留给该设备的连号数据面端口,窗口之间互不重叠。
30
+ # 起始端口可用 APPLIB_UI_TUNNEL_PORT_BASE 调整。
31
+ _PORT_POOL_BASE = int(os.environ.get("APPLIB_UI_TUNNEL_PORT_BASE", "21800"))
32
+ _PORT_POOL_STRIDE = 100
33
+ _PORT_POOL_SIZE = 20
34
+ _PORT_REGISTRY = os.path.join(os.path.expanduser("~"), ".applib-ui", "tunnel_ports.json")
35
+ # 每台设备的 pair record 独立目录
36
+ # go-ios 默认把 pair record 写到当前工作目录,多个 tunnel 进程共用同一 CWD 会互相干扰,
37
+ # 这里按 udid 隔离(也顺带避开 macOS 26 对 default 路径的限制)
38
+ _PAIR_RECORD_ROOT = os.path.join(os.path.expanduser("~"), ".applib-ui", "go-ios")
39
+ # 子进程输出目录,按 udid 分开;tunnel/runwda 的失败原因都落在里面
40
+ _LOG_ROOT = os.path.join(os.path.expanduser("~"), ".applib-ui", "logs")
41
+
42
+
43
+ @contextlib.contextmanager
44
+ def _port_pool_lock():
45
+ """端口池注册表的跨进程互斥(multiprocessing 下多个子进程会同时分配)"""
46
+ os.makedirs(os.path.dirname(_PORT_REGISTRY), exist_ok=True)
47
+ with open(f"{_PORT_REGISTRY}.lock", "w") as lock:
48
+ fcntl.flock(lock, fcntl.LOCK_EX)
49
+ try:
50
+ yield
51
+ finally:
52
+ fcntl.flock(lock, fcntl.LOCK_UN)
53
+
54
+
55
+ def _read_port_registry() -> dict:
56
+ try:
57
+ with open(_PORT_REGISTRY) as f:
58
+ return json.load(f)
59
+ except (OSError, ValueError):
60
+ return {}
61
+
62
+
63
+ def _alloc_tunnel_port(udid: str, avoid: set = None) -> int:
64
+ """
65
+ 给设备分配一个固定的 tunnel-info 端口窗口首端口
66
+ @param udid: 设备udid
67
+ @param avoid: 需要避开的端口(重试换窗口时传入)
68
+ """
69
+ avoid = avoid or set()
70
+ with _port_pool_lock():
71
+ registry = _read_port_registry()
72
+ slot = registry.get(udid)
73
+ if slot is not None:
74
+ port = _PORT_POOL_BASE + slot * _PORT_POOL_STRIDE
75
+ if port not in avoid:
76
+ return port
77
+ used = set(registry.values())
78
+ for slot in range(_PORT_POOL_SIZE):
79
+ port = _PORT_POOL_BASE + slot * _PORT_POOL_STRIDE
80
+ if slot in used or port in avoid:
81
+ continue
82
+ registry[udid] = slot
83
+ with open(_PORT_REGISTRY, "w") as f:
84
+ json.dump(registry, f, indent=2)
85
+ return port
86
+ raise RuntimeError(
87
+ f"tunnel-info 端口池已用尽({_PORT_POOL_SIZE} 个窗口 × {_PORT_POOL_STRIDE}),"
88
+ f"可用 APPLIB_UI_TUNNEL_PORT_BASE 调整起始端口"
89
+ )
90
+
91
+
92
+ # ---------------- 子进程环境与进程查找 ----------------
93
+ def _child_env() -> dict:
94
+ """
95
+ 给 go-ios 子进程用的环境变量
96
+
97
+ go-ios 的 configureCLI 对**每条**命令都会跑 startAgentFromEnvironment():
98
+ 只要环境里有 ENABLE_GO_IOS_AGENT=user/kernel,它就会在默认端口(28100)上再起一个
99
+ **不带 --udid** 的 agent,那个 agent 会给所有已连设备建隧道,
100
+ 于是同一条 udid 会同时存在两条隧道;而且它的命令行里没有 --udid,按 udid 匹配根本杀不掉。
101
+ 框架自己管理 per-device agent,所以这几个变量必须从子进程环境里摘掉。
102
+ """
103
+ ignored = ("ENABLE_GO_IOS_AGENT", "GO_IOS_AGENT_HOST", "GO_IOS_AGENT_PORT", "GO_IOS_UDID")
104
+ dropped = [key for key in ignored if key in os.environ]
105
+ if dropped:
106
+ logger.debug(f"已从 go-ios 子进程环境里摘掉: {dropped}")
107
+ return {key: value for key, value in os.environ.items() if key not in ignored}
108
+
109
+
110
+ def _cmdlines(pattern: str) -> dict:
111
+ """
112
+ 按命令行找进程:pgrep -fl 一次就能拿到「pid + 完整命令行」,比 pgrep 再逐个 ps 少一堆 fork
113
+ @param pattern: pgrep -f 的正则(匹配整条命令行)
114
+ @return: {pid: 参数 token 列表}
115
+ """
116
+ output = subprocess.run(["pgrep", "-fl", pattern], capture_output=True, text=True).stdout
117
+ cmdlines = {}
118
+ for line in output.splitlines():
119
+ pid, _, cmdline = line.strip().partition(" ")
120
+ if pid.isdigit() and cmdline.strip():
121
+ cmdlines[int(pid)] = cmdline.split()
122
+ return cmdlines
123
+
124
+
125
+ def _pid_alive(pid: int) -> bool:
126
+ """
127
+ 进程是否还在
128
+ 注:僵尸进程这里也会返回 True(父进程还没收尸),所以调用方要先给自家子进程 poll() 收尸
129
+ """
130
+ try:
131
+ os.kill(pid, 0)
132
+ return True
133
+ except ProcessLookupError:
134
+ return False
135
+ except PermissionError:
136
+ return True # 存在,但不属于当前用户
137
+
138
+
139
+ def _cmd_udid(cmd: list):
140
+ """
141
+ 取命令行里的 --udid 值
142
+
143
+ 必须同时认两种写法:`--udid=xxx`(docopt 等号式,框架自己用)和 `--udid xxx`(空格式,
144
+ 手工敲的 / 别的工具起的常见这样)。只认等号式的话,残留的手工 agent 会被判定成
145
+ "不是本设备",于是清理时被跳过 —— 这正是"一个 udid 两条 tunnel 且杀不掉"的原因之一。
146
+ @param cmd: 命令行 token 列表
147
+ @return: udid 或 None
148
+ """
149
+ for index, token in enumerate(cmd):
150
+ if token.startswith("--udid="):
151
+ return token.split("=", 1)[1]
152
+ if token == "--udid" and index + 1 < len(cmd):
153
+ return cmd[index + 1]
154
+ return None
155
+
156
+
157
+ def tunnel_processes() -> dict:
158
+ """机器上所有 ios tunnel start 进程(含手工起的、不带 --udid 的全局 agent)"""
159
+ return {pid: cmd for pid, cmd in _cmdlines("ios tunnel start").items()
160
+ if "tunnel" in cmd and "start" in cmd}
161
+
162
+
163
+ def _kill_pids(pids: list, tag: str, timeout: float = 8, reap=None) -> bool:
164
+ """
165
+ 先 SIGTERM,超时再 SIGKILL,并确认进程真的退出了
166
+ @param pids: 目标进程
167
+ @param tag: 日志里的人类可读描述
168
+ @param timeout: 等 SIGTERM 生效的上限
169
+ @param reap: 可选,收尸回调(收自己起的子进程,避免僵尸被误判成还活着)
170
+ @return: 是否已清理干净
171
+ """
172
+ if not pids:
173
+ return True
174
+ logger.info(f"清理 {tag}: {pids}")
175
+
176
+ def signal_pid(pid, sig):
177
+ try:
178
+ os.kill(pid, sig)
179
+ except ProcessLookupError:
180
+ pass # 已经退了
181
+ except PermissionError:
182
+ logger.warning(f"没有权限结束进程 {pid}(如果是 sudo 起的,需要手工清理)")
183
+
184
+ for pid in pids:
185
+ signal_pid(pid, signal.SIGTERM)
186
+ alive = pids
187
+ deadline = time.time() + timeout
188
+ while alive and time.time() < deadline:
189
+ if reap is not None:
190
+ reap()
191
+ alive = [pid for pid in alive if _pid_alive(pid)]
192
+ time.sleep(0.2)
193
+ if alive:
194
+ logger.warning(f"{tag} 收到 SIGTERM 后未退出,补 SIGKILL: {alive}")
195
+ for pid in alive:
196
+ signal_pid(pid, signal.SIGKILL)
197
+ time.sleep(0.5)
198
+ if reap is not None:
199
+ reap()
200
+ alive = [pid for pid in alive if _pid_alive(pid)]
201
+ if alive:
202
+ logger.error(f"[✗] {tag} 未清理干净,仍在运行: {alive}")
203
+ return False
204
+ logger.info(f"[✓] 已清理 {tag}")
205
+ return True
206
+
207
+
208
+ def kill_all_tunnels(keep_udid: str = None) -> list:
209
+ """
210
+ 清掉机器上所有 tunnel agent(含手工起的、不带 --udid 的全局 agent)
211
+
212
+ 用途:重跑前把双 tunnel 清干净(`python -m applib_ui.core.ios.local_wda kill-all`)
213
+ @param keep_udid: 保留该设备的 agent,其余照杀
214
+ @return: 被清理的 pid 列表
215
+ """
216
+ killed = []
217
+ for pid, cmd in tunnel_processes().items():
218
+ udid = _cmd_udid(cmd)
219
+ if keep_udid is not None and udid == keep_udid:
220
+ logger.info(f"保留 {keep_udid} 的 tunnel agent(pid={pid})")
221
+ continue
222
+ _kill_pids([pid], f"tunnel agent(pid={pid}, udid={udid or '全局'})")
223
+ killed.append(pid)
224
+ return killed
225
+
226
+
227
+ class LocalWdaInit:
228
+ """
229
+ 本地wda初始化
230
+
231
+ 多真机并发说明:
232
+ go-ios 的 tunnel-info HTTP-API 是主机级资源,不随 udid 隔离。
233
+ 每台设备必须用不同的 --tunnel-info-port,且 runwda 必须用同一个端口查询隧道信息,
234
+ 否则 runwda 找不到自己的隧道。
235
+ (官方建议:one per device on its own --tunnel-info-port)
236
+ 另外这个端口只是"窗口首端口":go-ios 会用 port+1、port+2… 做 userspace 数据面监听,
237
+ 所以端口必须按窗口分配、且整体落在系统 ephemeral 范围之外,
238
+ 否则两台设备的连号区间会互相踩(详见 _PORT_POOL_BASE 处的注释)。
239
+
240
+ 就绪判定说明:
241
+ `ios tunnel start` 打印 "Tunnel server started" 只代表 HTTP-API 起来了,
242
+ 此时隧道可能还在异步建立(失败还会退避 30s 再重试)。
243
+ 而 `ios runwda` 在 /tunnel/<udid> 查不到隧道时既不等待也不报错,
244
+ 会静默退化成"没有隧道的设备",随后在连 testmanagerd 时失败退出,
245
+ 表现到外层就是 "wda启动失败"。
246
+ 所以启动 runwda 之前必须轮询 /tunnel/<udid>,不能靠 sleep 猜时间。
247
+ """
248
+
249
+ def __init__(self, udid: str):
250
+ """
251
+ @param udid: 设备udid
252
+ """
253
+ self.udid = udid
254
+ # tunnel-info 端口从固定窗口池里按 udid 分配(每台设备一个窗口,不会互相踩)
255
+ self.tunnel_info_port = None
256
+ # 本次已经失败过的窗口端口,重试时避开
257
+ self._failed_ports = set()
258
+ # 本实例起过的子进程,用于收尸(避免僵尸进程被 _pid_alive 误判成还活着)
259
+ self._children = []
260
+ # pair record 目录固定到 ~/.applib-ui/go-ios/<udid>,避免 go-ios 默认写入当前工作目录
261
+ self.pair_record_dir = os.path.join(_PAIR_RECORD_ROOT, udid)
262
+ # 进程日志目录固定到 ~/.applib-ui/logs/<udid>
263
+ self.log_dir = os.path.join(_LOG_ROOT, udid)
264
+
265
+ # ---------------- 命令构造 ----------------
266
+ def _tunnel_cmd(self, tunnel_info_port: int):
267
+ ios_path = shutil.which('ios')
268
+ return (f"{ios_path} tunnel start --userspace --udid={self.udid} "
269
+ f"--tunnel-info-port={tunnel_info_port} "
270
+ f"--pair-record-path={self.pair_record_dir}")
271
+
272
+ def _wda_cmd(self, tunnel_info_port: int):
273
+ ios_path = shutil.which('ios')
274
+ # --tunnel-info-port 必须与 tunnel 一致,否则 runwda 查不到本设备的隧道
275
+ return (f"{ios_path} runwda --udid={self.udid} "
276
+ f"--tunnel-info-port={tunnel_info_port} "
277
+ f"--bundleid=com.lute.WebDriverAgentRunner.xctrunner "
278
+ f"--testrunnerbundleid=com.lute.WebDriverAgentRunner.xctrunner "
279
+ f"--xctestconfig=WebDriverAgentRunner.xctest "
280
+ f"--env USE_PORT=8100 "
281
+ f"--env MJPEG_SERVER_PORT=9100 "
282
+ f"--nojson")
283
+
284
+ # ---------------- 日志与探针 ----------------
285
+ def _log_path(self, name: str) -> str:
286
+ return os.path.join(self.log_dir, f"{name}.log")
287
+
288
+ def _open_log(self, name: str):
289
+ """打开本设备某个子进程的日志文件,返回 (文件对象, 本次写入的起始偏移)"""
290
+ os.makedirs(self.log_dir, exist_ok=True)
291
+ path = self._log_path(name)
292
+ start = os.path.getsize(path) if os.path.exists(path) else 0
293
+ fd = open(path, "ab")
294
+ fd.write(f"\n{'=' * 20} {time.strftime('%Y-%m-%d %H:%M:%S')} {name} {'=' * 20}\n".encode())
295
+ fd.flush()
296
+ return fd, start
297
+
298
+ def _log_tail(self, name: str, start: int = 0, lines: int = 15) -> str:
299
+ """读取某次启动之后的日志末尾若干行,用来拼进异常信息"""
300
+ try:
301
+ with open(self._log_path(name), "rb") as f:
302
+ f.seek(start)
303
+ content = f.read().decode("utf-8", "ignore")
304
+ except OSError:
305
+ return ""
306
+ return "\n".join(content.splitlines()[-lines:])
307
+
308
+ @staticmethod
309
+ def _tunnel_api(tunnel_info_port: int, path: str, timeout: float = 2):
310
+ """
311
+ 访问 go-ios 的 tunnel-info HTTP-API
312
+ @return: (状态码, JSON);进程没起来或连不上时返回 (None, None)
313
+ """
314
+ url = f"http://127.0.0.1:{tunnel_info_port}{path}"
315
+ try:
316
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
317
+ body = resp.read()
318
+ return resp.status, json.loads(body) if body else None
319
+ except urllib.error.HTTPError as e:
320
+ # 404/503 都是正常语义(隧道还没建好 / 首轮设备更新没结束),不算连不上
321
+ return e.code, None
322
+ except Exception:
323
+ return None, None
324
+
325
+ def tunnel_alive(self, timeout: float = 3) -> bool:
326
+ """
327
+ 本设备的隧道在 agent 里是否还在(轻量探针,用于断链检测)
328
+ 链路断了以后没必要等元素查询卡到 HTTP 超时才反应
329
+ """
330
+ if self.tunnel_info_port is None:
331
+ return False
332
+ status, info = self._tunnel_api(self.tunnel_info_port, f"/tunnel/{self.udid}", timeout)
333
+ return status == 200 and bool(info and info.get("udid"))
334
+
335
+ def _wait_tunnel_ready(self, proc: subprocess.Popen, tunnel_info_port: int, start: int,
336
+ timeout: float = _TUNNEL_READY_TIMEOUT):
337
+ """
338
+ 等 tunnel 进程活着、HTTP-API 就绪、并且本设备的隧道确实建出来了
339
+ @param proc: tunnel 子进程
340
+ @param tunnel_info_port: tunnel-info 端口
341
+ @param start: 本次日志起始偏移,用于超时时截取日志
342
+ @param timeout: 等待上限(秒)
343
+ """
344
+ deadline = time.time() + timeout
345
+ api_ready = False
346
+ while time.time() < deadline:
347
+ if proc.poll() is not None:
348
+ raise RuntimeError(
349
+ f"tunnel进程已退出(returncode={proc.returncode}, "
350
+ f"tunnel-info-port={tunnel_info_port}),"
351
+ f"日志({self._log_path('tunnel')})末尾:\n{self._log_tail('tunnel', start)}"
352
+ )
353
+ if not api_ready:
354
+ # /ready 返回 200 只代表首轮设备更新跑完了,隧道仍可能在后台建
355
+ status, _ = self._tunnel_api(tunnel_info_port, "/ready")
356
+ api_ready = status == 200
357
+ if api_ready:
358
+ status, info = self._tunnel_api(tunnel_info_port, f"/tunnel/{self.udid}")
359
+ if status == 200 and info and info.get("udid"):
360
+ logger.info(f"隧道已就绪(tunnel-info-port={tunnel_info_port}): {info}")
361
+ return
362
+ time.sleep(0.5)
363
+ raise RuntimeError(
364
+ f"等待隧道就绪超时({timeout}s, tunnel-info-port={tunnel_info_port}),"
365
+ f"日志({self._log_path('tunnel')})末尾:\n{self._log_tail('tunnel', start)}"
366
+ )
367
+
368
+ # ---------------- 进程启动 ----------------
369
+ def _start_tunnel(self, tunnel_info_port: int):
370
+ # 起新的之前先清干净本设备残留的 agent(不限端口):
371
+ # 原来只在"当前端口被占用"时才清理,残留 agent 蹲在别的窗口(换窗口重试之后)就漏杀了,
372
+ # 于是同一个 udid 会同时存在两条隧道
373
+ self.kill_ios_tunnel()
374
+ tunner_cmd_str = self._tunnel_cmd(tunnel_info_port)
375
+ print(tunner_cmd_str)
376
+ log_fd, start = self._open_log("tunnel")
377
+ p1 = subprocess.Popen(
378
+ tunner_cmd_str.split(),
379
+ stdout=log_fd,
380
+ stderr=subprocess.STDOUT,
381
+ env=_child_env(),
382
+ )
383
+ self._children.append(p1)
384
+ # 子进程已持有 fd 副本,父进程这份直接关掉
385
+ log_fd.close()
386
+ try:
387
+ self._wait_tunnel_ready(p1, tunnel_info_port, start)
388
+ except RuntimeError:
389
+ p1.kill()
390
+ raise
391
+ logger.info(f"tunner启动成功(tunnel-info-port={tunnel_info_port}),日志: {self._log_path('tunnel')}")
392
+ return p1
393
+
394
+ def _start_wda(self, tunnel_info_port: int):
395
+ wda_cmd_str = self._wda_cmd(tunnel_info_port)
396
+ print(wda_cmd_str)
397
+ log_fd, start = self._open_log("runwda")
398
+ p2 = subprocess.Popen(
399
+ wda_cmd_str.split(),
400
+ stdout=log_fd,
401
+ stderr=subprocess.STDOUT,
402
+ env=_child_env(),
403
+ )
404
+ self._children.append(p2)
405
+ log_fd.close()
406
+ time.sleep(3)
407
+ if p2.poll() is not None:
408
+ raise RuntimeError(
409
+ f"wda启动失败(returncode={p2.returncode}, tunnel-info-port={tunnel_info_port}),"
410
+ f"日志({self._log_path('runwda')})末尾:\n{self._log_tail('runwda', start)}"
411
+ )
412
+ logger.info(f"wda启动成功,日志: {self._log_path('runwda')}")
413
+ return p2
414
+
415
+ def _start_relay(self):
416
+ """端口转发:设备侧 8100 -> 本机空闲端口"""
417
+ local_port = get_free_port()
418
+ tidevice_path = shutil.which("tidevice")
419
+ proxy_cmd_str = f'{tidevice_path} -u {self.udid} relay {local_port} 8100'
420
+ print(proxy_cmd_str)
421
+ log_fd, start = self._open_log("relay")
422
+ p3 = subprocess.Popen(
423
+ proxy_cmd_str.split(),
424
+ stdout=log_fd,
425
+ stderr=subprocess.STDOUT,
426
+ env=_child_env(),
427
+ )
428
+ self._children.append(p3)
429
+ log_fd.close()
430
+ time.sleep(3)
431
+ if p3.poll() is not None:
432
+ raise RuntimeError(
433
+ f"proxy启动失败(returncode={p3.returncode}),"
434
+ f"日志({self._log_path('relay')})末尾:\n{self._log_tail('relay', start)}"
435
+ )
436
+ logger.info(f"proxy启动成功,日志: {self._log_path('relay')}")
437
+ return p3, local_port
438
+
439
+ def run(self):
440
+ """
441
+ 启动 tunnel、wda、端口转发
442
+ @return: p1(tunnel), p2(wda), p3(relay), wda_url
443
+ """
444
+ p1 = p2 = p3 = None
445
+ os.makedirs(self.pair_record_dir, exist_ok=True)
446
+ try:
447
+ # 启动tunnel:端口从固定窗口池按 udid 分配(每设备一个窗口),失败就换窗口重建 agent
448
+ for attempt in range(_TUNNEL_RESTART_RETRY):
449
+ if self.tunnel_info_port is None:
450
+ self.tunnel_info_port = _alloc_tunnel_port(self.udid, self._failed_ports)
451
+ try:
452
+ p1 = self._start_tunnel(self.tunnel_info_port)
453
+ break
454
+ except RuntimeError as e:
455
+ self._failed_ports.add(self.tunnel_info_port)
456
+ if attempt == _TUNNEL_RESTART_RETRY - 1:
457
+ raise
458
+ logger.warning(f"{e}\n隧道未建起,换端口窗口重建 agent 重试({attempt + 1}/{_TUNNEL_RESTART_RETRY})")
459
+ self.tunnel_info_port = None
460
+ time.sleep(1)
461
+
462
+ # 启动wda:隧道已确认就绪,用与 tunnel 相同的 tunnel-info-port
463
+ p2 = self._start_wda(self.tunnel_info_port)
464
+
465
+ p3, local_port = self._start_relay()
466
+
467
+ wda_url = f'http://localhost:{local_port}'
468
+ logger.info(wda_url)
469
+ return p1, p2, p3, wda_url
470
+ except Exception as e:
471
+ # 控制台只打一行摘要,完整现场在 ~/.applib-ui/logs/<udid>/ 下的日志里
472
+ logger.error(f"wda初始化失败:{str(e).splitlines()[0]}")
473
+ # 三个进程都要尝试收掉:任何一个为 None 都不能让后面两个漏杀
474
+ for p in (p1, p2, p3):
475
+ try:
476
+ if p is not None:
477
+ p.kill()
478
+ except Exception:
479
+ pass
480
+ self._reap_children()
481
+ logger.info("wda清理完毕")
482
+ # 必须抛出原始异常:上层是 `p1, p2, p3, url = self._wda.run()`,
483
+ # 吞掉异常只会让上层报 unpack 失败,真正的失败原因就丢了
484
+ raise
485
+
486
+ # ---------------- 进程清理 ----------------
487
+ def _reap_children(self):
488
+ """给本实例起过的子进程收尸(poll 顺带 reap),否则僵尸进程会被当成还活着"""
489
+ self._children = [proc for proc in self._children if proc.poll() is None]
490
+
491
+ def _kill_pids(self, pids: list, name: str, timeout: float = 8) -> bool:
492
+ """清掉本设备的某类进程(先 SIGTERM,超时补 SIGKILL,确认退出后才算成功)"""
493
+ return _kill_pids(pids, f"{self.udid} 的{name}", timeout, reap=self._reap_children)
494
+
495
+ def _tunnel_agent_pids(self) -> list:
496
+ """本设备自己的 tunnel agent(--udid=X / --udid X 两种写法都认,含所有窗口端口)"""
497
+ return [pid for pid, cmd in tunnel_processes().items() if _cmd_udid(cmd) == self.udid]
498
+
499
+ @staticmethod
500
+ def _global_agent_pids() -> list:
501
+ """不带 udid 的全局 agent(go-ios 的 RunAgent 起的,会给所有设备建隧道)"""
502
+ return [pid for pid, cmd in tunnel_processes().items() if _cmd_udid(cmd) is None]
503
+
504
+ def kill_ios_tunnel(self) -> bool:
505
+ """
506
+ 清掉本设备所有 tunnel agent(不区分窗口端口)
507
+
508
+ 注意:go-ios 自己也可能起一个"不带 --udid"的全局 agent(见 _child_env 的说明),
509
+ 它会给所有设备建隧道,和框架的 per-device agent 一起造成"一个 udid 两条隧道";
510
+ 但它的命令行里没有 --udid,按 udid 匹配杀不到,也不该由框架代杀(可能是别的工具在用),
511
+ 所以这里只做告警。
512
+ """
513
+ global_pids = self._global_agent_pids()
514
+ if global_pids:
515
+ logger.warning(
516
+ f"检测到不带 --udid 的全局 tunnel agent: {global_pids},"
517
+ f"它会给所有已连设备建隧道,导致同一台设备出现两条隧道"
518
+ f"(常见原因是环境变量 ENABLE_GO_IOS_AGENT=user/kernel);"
519
+ f"它不是本设备专属进程,框架不代为清理,需要时请手工 kill"
520
+ )
521
+ return self._kill_pids(self._tunnel_agent_pids(), "tunnel")
522
+
523
+ def kill_ios_wda(self) -> bool:
524
+ """清掉本设备的 runwda(--udid=X / --udid X 两种写法都认)"""
525
+ pids = [pid for pid, cmd in _cmdlines("ios runwda").items()
526
+ if "runwda" in cmd and _cmd_udid(cmd) == self.udid]
527
+ return self._kill_pids(pids, "wda")
528
+
529
+ def kill_tidevice_relay(self) -> bool:
530
+ """清掉本设备的 WDA 端口转发(只清 8100 那条,别误杀录屏用的 9100)"""
531
+ pids = [pid for pid, cmd in _cmdlines(f"tidevice -u {self.udid} relay").items()
532
+ if "relay" in cmd and self.udid in cmd and "8100" in cmd]
533
+ return self._kill_pids(pids, "端口转发")
534
+
535
+ def kill_wda_server(self):
536
+ self.kill_ios_tunnel()
537
+ self.kill_ios_wda()
538
+ self.kill_tidevice_relay()
539
+
540
+ def restart_wda(self):
541
+ logger.info(f"重启设备{self.udid}的wda服务")
542
+ self.kill_wda_server()
543
+ # 复用已分配的 tunnel-info 端口,保证与后续 runwda 查询一致
544
+ return self.run()
545
+
546
+
547
+ def _print_tunnel_status():
548
+ procs = tunnel_processes()
549
+ if not procs:
550
+ print("当前没有 ios tunnel 进程")
551
+ return
552
+ print(f"当前有 {len(procs)} 个 ios tunnel 进程:")
553
+ for pid, cmd in procs.items():
554
+ print(f" pid={pid} udid={_cmd_udid(cmd) or '(全局,无 --udid)'}")
555
+ print(f" {' '.join(cmd)}")
556
+
557
+
558
+ if __name__ == '__main__':
559
+ import sys
560
+
561
+ usage = ("用法: python -m applib_ui.core.ios.local_wda <命令>\n"
562
+ " status 列出机器上所有 ios tunnel 进程(含手工起的)\n"
563
+ " kill-all [<udid>] 清掉所有 tunnel agent(给 udid 则保留该设备的)\n"
564
+ " restart <udid> 重建某台设备的 tunnel/wda/端口转发")
565
+ args = sys.argv[1:]
566
+ action = args[0] if args else "status"
567
+ if action == "status":
568
+ _print_tunnel_status()
569
+ elif action == "kill-all":
570
+ keep = args[1] if len(args) > 1 else None
571
+ killed = kill_all_tunnels(keep)
572
+ print(f"已清理 {len(killed)} 个 tunnel agent" + (f",保留 {keep}" if keep else ""))
573
+ elif action == "restart" and len(args) > 1:
574
+ LocalWdaInit(args[1]).restart_wda()
575
+ else:
576
+ print(usage)
@@ -0,0 +1,6 @@
1
+ class Page(object):
2
+ """页面基类,用于pom模式封装"""
3
+
4
+ def __init__(self, driver):
5
+ self.driver = driver
6
+