# coding: utf-8 # # require: python >= 3.6 import base64 import json import os import subprocess import time from collections import namedtuple from concurrent.futures import ThreadPoolExecutor from functools import partial from typing import Callable import tornado import requests from logzero import logger from tornado import gen, httpclient, locks from tornado.concurrent import run_on_executor from tornado.ioloop import IOLoop from freeport import freeport DeviceEvent = namedtuple('DeviceEvent', ['present', 'udid']) def runcommand(*args) -> str: try: output = subprocess.check_output(args) return output.strip().decode('utf-8') except (subprocess.CalledProcessError, FileNotFoundError): return "" except Exception as e: logger.warning("unknown error: %s", e) return "" def list_devices(): udids = runcommand('idevice_id', '-l').splitlines() return udids def udid2name(udid: str) -> str: return runcommand("idevicename", "-u", udid) def udid2product(udid): """ See also: https://www.theiphonewiki.com/wiki/Models """ pt = runcommand("ideviceinfo", "--udid", udid, "--key", "ProductType") models = { "iPhone5,1": "iPhone 5", "iPhone5,2": "iPhone 5", "iPhone5,3": "iPhone 5c", "iPhone5,4": "iPhone 5c", "iPhone6,1": "iPhone 5s", "iPhone6,2": "iPhone 5s", "iPhone7,1": "iPhone 6 Plus", "iPhone7,2": "iPhone 6", "iPhone8,1": "iPhone 6s", "iPhone8,2": "iPhone 6s Plus", "iPhone8,4": "iPhone SE", "iPhone9,1": "iPhone 7", # Global "iPhone9,2": "iPhone 7 Plus", # Global "iPhone9,3": "iPhone 7", # GSM "iPhone9,4": "iPhone 7 Plus", # GSM "iPhone10,1": "iPhone 8", # Global "iPhone10,2": "iPhone 8 Plus", # Global "iPhone10,3": "iPhone X", # Global "iPhone10,4": "iPhone 8", # GSM "iPhone10,5": "iPhone 8 Plus", # GSM "iPhone10,6": "iPhone X", # GSM "iPhone11,8": "iPhone XR", "iPhone11,2": "iPhone XS", "iPhone11,6": "iPhone XS Max", "iPhone11,8": "iPhone XR", # simulator "i386": "iPhone Simulator", "x86_64": "iPhone Simulator", } return models.get(pt, "Unknown") class Tracker(): executor = ThreadPoolExecutor(4) def __init__(self): self._lasts = [] @run_on_executor(executor='executor') def list_devices(self): return list_devices() @gen.coroutine def update(self): """ Wired, can not use async here """ lasts = self._lasts currs = yield self.list_devices() gones = set(lasts).difference(currs) # 離線 backs = set(currs).difference(lasts) # 在線 self._lasts = currs raise gen.Return((backs, gones)) async def track_devices(self): while True: backs, gones = await self.update() for udid in backs: yield DeviceEvent(True, udid) for udid in gones: yield DeviceEvent(False, udid) await gen.sleep(1) def track_devices(): t = Tracker() return t.track_devices() async def nop_callback(*args, **kwargs): pass class WDADevice(object): """ Example usage: lock = locks.Lock() # xcodebuild test is not support parallel run async def callback(device: WDADevice, status, info=None): pass d = WDADevice("xxxxxx-udid-xxxxx", lock, callback) d.start() await d.stop() """ status_preparing = "preparing" status_ready = "ready" status_fatal = "fatal" def __init__(self, udid: str, lock: locks.Lock, callback): """ Args: callback: function (str, dict) -> None Example callback: callback("update", {"ip": "1.2.3.4"}) """ self.__udid = udid self.name = udid2name(udid) self.product = udid2product(udid) self.wda_directory = "./ATX-WebDriverAgent" self._procs = [] self._wda_proxy_port = None self._wda_proxy_proc = None self._lock = lock # only allow one xcodebuild test run self._finished = locks.Event() self._stop = locks.Event() self._callback = partial(callback, self) or nop_callback @property def udid(self) -> str: return self.__udid @property def public_port(self): return self._wda_proxy_port def __repr__(self): return "[{udid}:{name}-{product}]".format(udid=self.udid[:5] + ".." + self.udid[-2:], name=self.name, product=self.product) def __str__(self): return repr(self) def start(self): """ start wda process and keep it running, until wda stopped too many times or stop() called """ self._stop.clear() IOLoop.current().spawn_callback(self.run_wda_forever) async def stop(self): """ stop wda process """ if self._stop.is_set(): raise RuntimeError(self, "WDADevice is already stopped") self._stop.set() # no need await logger.debug("%s waiting for wda stopped ...", self) await self._finished.wait() logger.debug("%s wda stopped!", self) self._finished.clear() async def run_wda_forever(self): """ Args: callback """ wda_fail_cnt = 0 while not self._stop.is_set(): await self._callback(self.status_preparing) start = time.time() ok = await self.run_webdriveragent() if not ok: self.destroy() wda_fail_cnt += 1 if wda_fail_cnt > 3: logger.error("%s Run WDA failed. -_-!", self) break if time.time() - start < 3.0: logger.error("%s WDA unable to start", self) break logger.warning("%s wda started failed, retry after 10s", self) if not await self._sleep(10): break continue wda_fail_cnt = 0 logger.info("%s wda lanuched", self) # wda_status() result stored in __wda_info await self._callback(self.status_ready, self.__wda_info) await self.watch_wda_status() await self._callback(self.status_fatal) self.destroy() # destroy twice to make sure no process left self._finished.set() # no need await def destroy(self): logger.debug("terminate wda processes") for p in self._procs: p.terminate() self._procs = [] async def _sleep(self, timeout: float): """ return false when sleep stopped by _stop(Event) """ try: timeout_timestamp = IOLoop.current().time() + timeout await self._stop.wait(timeout_timestamp) # wired usage return False except tornado.util.TimeoutError: return True async def watch_wda_status(self): """ check WebDriverAgent all the time """ # check wda_status every 30s fail_cnt = 0 last_ip = self.device_ip while not self._stop.is_set(): if await self.wda_status(): if fail_cnt != 0: logger.info("wda ping recovered") fail_cnt = 0 if last_ip != self.device_ip: last_ip = self.device_ip await self._callback(self.status_ready, self.__wda_info) await self._sleep(60) logger.debug("%s is fine", self) else: fail_cnt += 1 logger.warning("%s wda ping error: %d", self, fail_cnt) if fail_cnt > 3: logger.warning("ping wda fail too many times, restart wda") break await self._sleep(10) self.destroy() @property def device_ip(self): """ get current device ip """ if not self.__wda_info: return None try: return self.__wda_info['value']['ios']['ip'] except IndexError: return None async def run_webdriveragent(self) -> bool: """ UDID=$(idevice_id -l) UDID=${UDID:?} xcodebuild -project WebDriverAgent.xcodeproj \ -scheme WebDriverAgentRunner WebDriverAgentRunner id=$(idevice_id -l) test Raises: RuntimeError """ if self._procs: self.destroy() # hotfix #raise RuntimeError("should call destroy before run_webdriveragent", self._procs) async with self._lock: # holding lock, because multi wda run will raise error # Testing failed: # WebDriverAgentRunner-Runner.app encountered an error (Failed to install or launch the test # runner. (Underlying error: Only directories may be uploaded. Please try again with a directory # containing items to upload to the application_s sandbox.)) cmd = [ 'xcodebuild', '-project', os.path.join(self.wda_directory, 'WebDriverAgent.xcodeproj'), '-scheme', 'WebDriverAgentRunner', "-destination", 'id=' + self.udid, 'test' ] if os.getenv("TMQ") == "true": cmd = ['tinstruments', '-u', self.udid, 'xcuitest'] self.run_background( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) # cwd='Appium-WebDriverAgent') self._wda_port = freeport.get() self._mjpeg_port = freeport.get() self.run_background( ["./iproxy.sh", str(self._wda_port), "8100", self.udid], silent=True) self.run_background( ["./iproxy.sh", str(self._mjpeg_port), "9100", self.udid], silent=True) self.restart_wda_proxy() return await self.wait_until_ready() def run_background(self, *args, **kwargs): if kwargs.pop("silent", False): kwargs['stdout'] = subprocess.DEVNULL kwargs['stderr'] = subprocess.DEVNULL logger.debug("exec: %s", subprocess.list2cmdline(args[0])) p = subprocess.Popen(*args, **kwargs) self._procs.append(p) def restart_wda_proxy(self): if self._wda_proxy_proc: self._wda_proxy_proc.terminate() self._wda_proxy_port = freeport.get() logger.debug("restart wdaproxy with port: %d", self._wda_proxy_port) self._wda_proxy_proc = subprocess.Popen([ "node", "wdaproxy.js", "-p", str(self._wda_proxy_port), "--wda-url", "http://localhost:{}".format(self._wda_port), "--mjpeg-url", "http://localhost:{}".format(self._mjpeg_port)], stdout=subprocess.DEVNULL) # yapf: disable async def wait_until_ready(self, timeout: float = 60.0) -> bool: """ Returns: bool """ deadline = time.time() + timeout while time.time() < deadline and not self._stop.is_set(): quited = any([p.poll() is not None for p in self._procs]) if quited: logger.warning("%s process quit %s", self, [(p.pid, p.poll()) for p in self._procs]) return False if await self.wda_status(): return True await self._sleep(1) return False async def restart_wda(self): self.destroy() return await self.run_webdriveragent() @property def wda_device_url(self): return "http://localhost:{}".format(self._wda_port) async def wda_status(self): """ Returns: dict or None """ try: request = httpclient.HTTPRequest(self.wda_device_url + "/status", connect_timeout=3, request_timeout=15) client = httpclient.AsyncHTTPClient() resp = await client.fetch(request) info = json.loads(resp.body) self.__wda_info = info return info except httpclient.HTTPError as e: logger.debug("%s request wda/status error: %s", self, e) return None except (ConnectionResetError, ConnectionRefusedError): logger.debug("%s waiting for wda", self) return None except Exception as e: logger.warning("%s ping wda unknown error: %s %s", self, type(e), e) return None async def wda_screenshot_ok(self): """ Check if screenshot is working Returns: bool """ try: request = httpclient.HTTPRequest(self.wda_device_url + "/screenshot", connect_timeout=3, request_timeout=15) client = httpclient.AsyncHTTPClient() resp = await client.fetch(request) data = json.loads(resp.body) raw_png_data = base64.b64decode(data['value']) png_header = b"\x89PNG\r\n\x1a\n" if not raw_png_data.startswith(png_header): return False return True except Exception as e: logger.warning("%s wda screenshot error: %s", self, e) return False async def wda_session_ok(self): """ check if session create ok """ info = await self.wda_status() if not info: return False #if not info.get("sessionId"): # the latest wda /status has no sessionId # return False return True async def is_wda_alive(self): logger.debug("%s check /status", self) if not await self.wda_session_ok(): return False logger.debug("%s check /screenshot", self) if not await self.wda_screenshot_ok(): return False return True async def wda_healthcheck(self): client = httpclient.AsyncHTTPClient() if not await self.is_wda_alive(): logger.warning("%s check failed -_-!", self) await self._callback(self.status_preparing) if not await self.restart_wda(): logger.warning("%s wda recover in healthcheck failed", self) return else: logger.debug("%s all check passed ^_^", self) await client.fetch(self.wda_device_url + "/wda/healthcheck") if __name__ == "__main__": # main() pass