├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── device-spec.json.template ├── doc ├── API.md ├── STF-CONNECT.md └── STF-RECORD.md ├── setup.py ├── stf-utils.ini.template ├── stf_utils ├── __init__.py ├── common │ ├── __init__.py │ ├── adb.py │ ├── exceptions.py │ └── stfapi.py ├── config │ ├── __init__.py │ └── config.py ├── stf_connect │ ├── __init__.py │ ├── client.py │ └── stf_connect.py └── stf_record │ ├── __init__.py │ ├── protocol.py │ └── stf_record.py ├── test-requirements.txt └── tests ├── __init__.py ├── data ├── __init__.py ├── get_all_devices.json ├── get_device_x86.json ├── get_user_devices.json └── remote_connect.json ├── helpers.py └── test_stf_connect_client.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | 64 | # User-specific stuff: 65 | .idea/ 66 | .idea/workspace.xml 67 | .idea/tasks.xml 68 | .idea/dictionaries 69 | .idea/vcs.xml 70 | .idea/jsLibraryMappings.xml 71 | 72 | # Sensitive or high-churn files: 73 | .idea/dataSources.ids 74 | .idea/dataSources.xml 75 | .idea/dataSources.local.xml 76 | .idea/sqlDataSources.xml 77 | .idea/dynamic.xml 78 | .idea/uiDesigner.xml 79 | 80 | 81 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.5" 5 | install: 6 | - pip install -r test-requirements.txt 7 | - python setup.py install 8 | script: 9 | lode_runner -vs tests/ --with-xunit --with-coverage --cover-erase --cover-package=stf_utils --cover-xml 10 | after_success: 11 | - codecov 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 2GIS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/2gis/stf-utils.svg?branch=master)](https://travis-ci.org/2gis/stf-utils) 2 | [![codecov](https://codecov.io/gh/2gis/stf-utils/branch/master/graph/badge.svg)](https://codecov.io/gh/2gis/stf-utils) 3 | 4 | 5 | 6 | # stf-utils 7 | Python utilities for [STF](https://github.com/openstf/stf). 8 | 9 | ### Features: 10 | * [Connect Android devices](doc/STF-CONNECT.md) from your STF instance with one simple command. Useful if you want to use STF with some automation tools (like Appium). 11 | * [Record videos](doc/STF-RECORD.md) of your automated tests passing. 12 | * [Write your own](doc/API.md) python apps using STF API client implementation. 13 | 14 | ### Prerequisites: 15 | - Your [STF](https://github.com/openstf/stf) instance is ready and you are using v2.0 or above. 16 | -------------------------------------------------------------------------------- /device-spec.json.template: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "group_name": "alfa", 4 | "amount": "10", 5 | "min_sdk": "16", 6 | "max_sdk": "23", 7 | "specs": { 8 | "abi": "x86", 9 | "model": "ANY", 10 | "platform": "Android", 11 | "serial": "ANY", 12 | "manufacturer": "ANY", 13 | "provider_name": "ANY" 14 | } 15 | }, 16 | { 17 | "group_name": "beta", 18 | "amount": "2", 19 | "specs": { 20 | "abi": "ANY", 21 | "model": "ANY", 22 | "platform": "Android", 23 | "serial": "ANY", 24 | "manufacturer": "ANY", 25 | "provider_name": "ANY", 26 | "version": "4.1.2" 27 | } 28 | } 29 | ] 30 | 31 | -------------------------------------------------------------------------------- /doc/API.md: -------------------------------------------------------------------------------- 1 | ## STF API client 2 | 3 | [stf_utils/common/stfapi.py](../stf_utils/common/stfapi.py) - Python API for [STF API](https://github.com/openstf/stf/blob/master/doc/API.md) 4 | 5 | [stf_utils/stf_connect/client.py](../stf_utils/stf_connect/client.py) - Python client for [STF API](https://github.com/openstf/stf/blob/master/doc/API.md) 6 | 7 | Sorry, no documentation here, yet. 8 | -------------------------------------------------------------------------------- /doc/STF-CONNECT.md: -------------------------------------------------------------------------------- 1 | 2 | ## Requirements: 3 | * Your [STF](https://github.com/openstf/stf) instance is ready and you are using v2.0 or above. 4 | * 2.7 <= python <= 3.5 5 | * adb 6 | 7 | ## Quick start: 8 | 1. Install stf-utils: 9 | 10 | ```shell 11 | pip install git+https://github.com/2gis/stf-utils.git 12 | ``` 13 | 14 | 1. Generate OAuth token in web-interface as described [here](https://github.com/openstf/stf/blob/master/doc/API.md#authentication). 15 | 2. Create config file `stf-utils.ini`: 16 | 17 | ``` 18 | host = http:// 19 | oauth_token = 20 | ``` 21 | 22 | 2. Specify devices you want to connect by creating `device-spec.json` file (examples below) 23 | 2. Run `stf-connect` 24 | 25 | `stf-connect` will connect and bind devices from your STF instance specified in `device-spec.json` file. 26 | 27 | If some device for some reason will disconnect, `stf-connect` will try to reconnect it again. 28 | 29 | To release devices binded by `stf-connect`, type CTRL+C or kill `stf-connect` process. 30 | 31 | ## Examples of `device-spec.json` file: 32 | ##### Connect all available devices 33 | ```json 34 | [ 35 | { 36 | "group_name": "all_devices", 37 | "amount": "999" 38 | } 39 | ] 40 | ``` 41 | ##### Connect one specific device by its serialno (device-spec.json example) 42 | ```json 43 | [ 44 | { 45 | "group_name": "device_by_serial_no", 46 | "amount": "1", 47 | "specs": { 48 | "serial": "" 49 | } 50 | } 51 | ] 52 | ``` 53 | ##### Connect five armeabi-v7a devices 54 | ```json 55 | [ 56 | { 57 | "group_name": "device_by_serial_no", 58 | "amount": "5", 59 | "specs": { 60 | "serial": "ANY", 61 | "abi": "armeabi-v7a" 62 | } 63 | } 64 | ] 65 | ``` 66 | 67 | 68 | ### Advanced usage: Use one spec.json for different cases 69 | You can specify several groups in your `device-spec.json` file and control which groups to connect with `--groups` argument for `stf-connect`. 70 | 71 | For example, you `spec.json` looks like: 72 | ```json 73 | [ 74 | { 75 | "group_name": "x86_Android_6", 76 | "amount": "8", 77 | "min_sdk": "23", 78 | "specs": { 79 | "serial": "ANY", 80 | "abi": "x86" 81 | } 82 | }, 83 | { 84 | "group_name": "unit_test_devices", 85 | "amount": "100", 86 | "specs": { 87 | "platform": "Android", 88 | "serial": "ANY", 89 | "manufacturer": "ANY", 90 | "provider_name": "ANY" 91 | } 92 | }, 93 | { 94 | "group_name": "the_magnificent_armeabi-v7a_seven", 95 | "amount": "7", 96 | "specs": { 97 | "abi": "armeabi-v7a" 98 | } 99 | } 100 | ] 101 | ``` 102 | then, if you want only last two groups, you should run: 103 | ```shell 104 | stf-connect --groups the_magnificent_armeabi-v7a_seven,unit_test_devices 105 | ``` 106 | -------------------------------------------------------------------------------- /doc/STF-RECORD.md: -------------------------------------------------------------------------------- 1 | ## Description: 2 | 3 | Connect to "screen streaming service", provided by STF, save screenshots of your Android device, and create a video. 4 | 5 | ## Save screenshots 6 | - run: 7 | ``` 8 | stf-record --serial="emulator-5555" --dir='test_dir' 9 | ``` 10 | or 11 | ``` 12 | stf-record --ws="127.0.0.1:9000" --dir='test_dir' 13 | ``` 14 | - options: 15 | ``` 16 | --serial - Device serial for automatic getting websocket url for saving screenshots 17 | --ws - WebSocket URL with ws:// or not (required!) 18 | --dir - custom directory for saving screenshots from device (default: ./images ) 19 | --log-level - change log level for utility (default: INFO) 20 | --resolution - change resolution of images from device (default: as is) 21 | ``` 22 | 23 | ## Create video 24 | convert images to .webm video format by ffmpeg (tested on ffmpeg version 7:3.0.0+git1~trusty from [trusty-media repo](https://launchpad.net/~mc3man/+archive/ubuntu/trusty-media)) 25 | - install ffmpeg 26 | ``` 27 | sudo add-apt-repository --yes ppa:mc3man/trusty-media 28 | sudo apt-get update 29 | sudo apt-get install -y ffmpeg 30 | ``` 31 | - convert 32 | ``` 33 | cd 34 | ffmpeg -f concat -i input.txt output.webm 35 | ``` 36 | 37 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from setuptools import setup, find_packages 3 | 4 | find_packages() 5 | setup( 6 | name='stf-utils', 7 | maintainer='2GIS', 8 | maintainer_email='autoqa@2gis.ru', 9 | packages=find_packages(), 10 | package_data={ 11 | 'stf_utils': [ 12 | 'config/*.ini' 13 | ], 14 | }, 15 | version='0.1.8', 16 | entry_points={ 17 | 'console_scripts': [ 18 | 'stf-connect = stf_utils.stf_connect.stf_connect:run', 19 | 'stf-record = stf_utils.stf_record.stf_record:run', 20 | ], 21 | }, 22 | install_requires=[ 23 | 'six==1.10.0', 24 | 'requests==2.10.0', 25 | 'asyncio==3.4.3', 26 | 'autobahn==0.13.0', 27 | ], 28 | license='MIT', 29 | description='', 30 | long_description='', 31 | url='https://github.com/2gis/stf-utils' 32 | ) 33 | 34 | -------------------------------------------------------------------------------- /stf-utils.ini.template: -------------------------------------------------------------------------------- 1 | [main] 2 | host = localhost 3 | oauth_token = 4 | device_spec = device-spec.json 5 | devices_file_path = connected_devices.txt 6 | shutdown_emulator_on_disconnect = True 7 | -------------------------------------------------------------------------------- /stf_utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | 4 | 5 | def init_console_logging(level): 6 | logging.basicConfig(level=getattr(logging, level.upper())) 7 | -------------------------------------------------------------------------------- /stf_utils/common/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/stf-utils/844c38145a82614872b35d9771dde45e7d7d083c/stf_utils/common/__init__.py -------------------------------------------------------------------------------- /stf_utils/common/adb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import logging 4 | import time 5 | from threading import Timer 6 | from stf_utils.common.exceptions import ADBException 7 | 8 | log = logging.getLogger(__name__) 9 | WAIT_FOR_CONNECT = 5 10 | ADB_TIMEOUT = 10 11 | 12 | 13 | def connect(connect_url): 14 | log.debug("Trying to establish ADB connection with %s" % connect_url) 15 | command = ["connect", connect_url] 16 | stdout, stderr = _exec_adb(command) 17 | start_time = time.time() 18 | while True: 19 | time.sleep(1) 20 | if device_is_ready(connect_url): 21 | log.debug("ADB connection with %s successfully established. " 22 | "Stdout %s. Stderr %s." % (connect_url, stdout, stderr)) 23 | break 24 | elif time.time() - start_time > WAIT_FOR_CONNECT: 25 | raise ADBException("Failed to establish ADB connection with %s. " 26 | "Stdout %s. Stderr %s." % (connect_url, stdout, stderr)) 27 | 28 | 29 | def disconnect(connect_url): 30 | log.debug("Closing ADB connection with %s" % connect_url) 31 | command = ["disconnect", connect_url] 32 | stdout, stderr = _exec_adb(command) 33 | log.debug("ADB disconnect for %s executed. " 34 | "Stdout %s. Stderr %s." % (connect_url, stdout, stderr)) 35 | 36 | 37 | def device_is_ready(device_adb_name): 38 | state, stderr = get_state(device_adb_name) 39 | 40 | if isinstance(state, bytes): 41 | state = state.decode("utf8") 42 | 43 | if "device" not in state: 44 | log.debug("Device %s isn't ready and his status is %s" % (device_adb_name, state)) 45 | return False 46 | else: 47 | log.debug("Device %s is ready" % device_adb_name) 48 | return True 49 | 50 | 51 | def get_state(device_adb_name): 52 | command = "-s %s get-state" % device_adb_name 53 | stdout, stderr = _exec_adb(command.split()) 54 | log.debug("ADB getting state of device %s. Stdout %s. Stderr %s." % (device_adb_name, stdout, stderr)) 55 | return stdout, stderr 56 | 57 | 58 | def echo_ping(device_adb_name): 59 | command = "-s %s shell" % device_adb_name 60 | shell_command = "echo 'ping'" 61 | stdout, stderr = _exec_adb(command.split() + [shell_command]) 62 | log.debug("Echo 'ping' by ADB for device %s. Stdout %s. Stderr %s." % (device_adb_name, 63 | str(stdout).replace("\n", ""), 64 | str(stderr).replace("\n", ""))) 65 | return stdout, stderr 66 | 67 | 68 | def shutdown_emulator(connect_url): 69 | emulator_shell = '-s %s shell' % connect_url 70 | shutdown_command = "reboot -p" 71 | stdout, stderr = _exec_adb(emulator_shell.split() + [shutdown_command]) 72 | log.debug("ADB shutdown emulator %s. Stdout %s. Stderr %s" % (connect_url, stdout, stderr)) 73 | 74 | 75 | def _exec_adb(params): 76 | command = ['adb'] + params 77 | log.debug("Executing adb command: %s" % command) 78 | process = subprocess.Popen( 79 | command, 80 | stdout=subprocess.PIPE, 81 | stderr=subprocess.PIPE, 82 | env=os.environ.copy() 83 | ) 84 | timer = Timer(ADB_TIMEOUT, _kill_process, [process]) 85 | try: 86 | timer.start() 87 | return process.communicate() 88 | finally: 89 | timer.cancel() 90 | 91 | 92 | def _kill_process(process): 93 | log.error("Execution timeout expired. Kill %s process." % process.pid) 94 | process.kill() 95 | -------------------------------------------------------------------------------- /stf_utils/common/exceptions.py: -------------------------------------------------------------------------------- 1 | class APIException(Exception): 2 | pass 3 | 4 | 5 | class ADBException(Exception): 6 | pass 7 | 8 | -------------------------------------------------------------------------------- /stf_utils/common/stfapi.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import re 5 | import requests 6 | import six 7 | from stf_utils.common.exceptions import APIException 8 | 9 | log = logging.getLogger("requests") 10 | log.setLevel(logging.WARNING) 11 | re_path_template = re.compile('{\w+}') 12 | 13 | 14 | def bind_method(**config): 15 | class SmartphoneTestingFarmAPIMethod(object): 16 | path = config['path'] 17 | method = config.get('method', 'GET') 18 | accepts_parameters = config.get("accepts_parameters", []) 19 | headers = config.get("headers") 20 | 21 | def __init__(self, api, *args, **kwargs): 22 | self.api = api 23 | self.parameters = {} 24 | self._build_parameters(args, kwargs) 25 | self._build_path() 26 | 27 | def _build_parameters(self, args, kwargs): 28 | for index, value in enumerate(args): 29 | if value is None: 30 | continue 31 | try: 32 | self.parameters[self.accepts_parameters[index]] = value 33 | except IndexError: 34 | raise APIException("Too many arguments supplied") 35 | 36 | for key, value in six.iteritems(kwargs): 37 | if value is None: 38 | continue 39 | if key in self.parameters: 40 | raise APIException("Parameter {} already supplied".format(key)) 41 | self.parameters[key] = value 42 | 43 | def _build_path(self): 44 | for variable in re_path_template.findall(self.path): 45 | name = variable.strip('{}') 46 | try: 47 | value = self.parameters[name] 48 | except KeyError: 49 | raise APIException('No parameter value found for path variable: {}'.format(name)) 50 | del self.parameters[name] 51 | self.path = self.path.replace(variable, value) 52 | 53 | def _prepare_headers(self): 54 | auth_header = { 55 | "Authorization": "Bearer {0}".format(self.api.oauth_token) 56 | } 57 | if self.headers is not None: 58 | auth_header.update(self.headers) 59 | return auth_header 60 | 61 | def _prepare_request(self): 62 | method = self.method 63 | url = "{0}{1}".format(self.api.api_url, self.path) 64 | headers = { 65 | "Authorization": "Bearer {0}".format(self.api.oauth_token) 66 | } 67 | if self.headers is not None: 68 | headers.update(self.headers) 69 | data = json.dumps(self.parameters) 70 | return method, url, headers, data 71 | 72 | def execute(self): 73 | method, url, headers, data = self._prepare_request() 74 | response = requests.request( 75 | method=method, 76 | url=url, 77 | headers=headers, 78 | data=data 79 | ) 80 | if response.status_code != 200: 81 | if response.status_code == 403: 82 | log.warning("Forbidden! {}".format(response.json())) 83 | else: 84 | raise APIException("Request Error: {}",format(response.json())) 85 | return response 86 | 87 | def _call(api, *args, **kwargs): 88 | method = SmartphoneTestingFarmAPIMethod(api, *args, **kwargs) 89 | return method.execute() 90 | 91 | return _call 92 | 93 | 94 | class SmartphoneTestingFarmAPI(object): 95 | """ 96 | Bindings for OpenSTF API: 97 | https://github.com/openstf/stf/blob/2.0.0/doc/API.md 98 | """ 99 | def __init__(self, host, common_api_path, oauth_token): 100 | self.host = host 101 | self.common_api_path = common_api_path 102 | self.oauth_token = oauth_token 103 | self.api_url = "{0}{1}".format(self.host, self.common_api_path) 104 | 105 | get_all_devices = bind_method( 106 | path="/devices" 107 | ) 108 | 109 | get_device = bind_method( 110 | path="/devices/{serial}", 111 | accepts_parameters=["serial"] 112 | ) 113 | 114 | get_user_info = bind_method( 115 | path="/user" 116 | ) 117 | 118 | get_my_devices = bind_method( 119 | path="/user/devices" 120 | ) 121 | 122 | add_device = bind_method( 123 | method="post", 124 | path="/user/devices", 125 | headers={ 126 | "Content-Type": "application/json" 127 | }, 128 | accepts_parameters=["serial"] 129 | ) 130 | 131 | delete_device = bind_method( 132 | method="delete", 133 | path="/user/devices/{serial}" 134 | ) 135 | 136 | remote_connect = bind_method( 137 | method="post", 138 | path="/user/devices/{serial}/remoteConnect", 139 | headers={ 140 | "Content-Type": "application/json" 141 | } 142 | ) 143 | 144 | remote_disconnect = bind_method( 145 | method="delete", 146 | path="/user/devices/{serial}/remoteConnect", 147 | accepts_parameters=["serial"] 148 | ) 149 | 150 | -------------------------------------------------------------------------------- /stf_utils/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/stf-utils/844c38145a82614872b35d9771dde45e7d7d083c/stf_utils/config/__init__.py -------------------------------------------------------------------------------- /stf_utils/config/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import ast 5 | 6 | try: 7 | import ConfigParser 8 | except ImportError: 9 | import configparser as ConfigParser 10 | 11 | 12 | class Config(object): 13 | main = { 14 | "host": "", 15 | "oauth_token": "", 16 | "device_spec": "", 17 | "devices_file_path": "", 18 | "shutdown_emulator_on_disconnect": "", 19 | } 20 | 21 | def __init__(self, config_path): 22 | """ 23 | :type path: str 24 | """ 25 | if not os.path.exists(config_path): 26 | raise FileNotFoundError(config_path) 27 | 28 | self.add_config_file(config_path) 29 | 30 | def add_config_file(self, path_to_file): 31 | """ 32 | :type path_to_file: str 33 | """ 34 | parser = ConfigParser.ConfigParser() 35 | parser.optionxform = str 36 | parser.read(path_to_file) 37 | sections = parser.sections() 38 | for section in sections: 39 | params = parser.items(section) 40 | section = section.lower() 41 | d = {} 42 | for param in params: 43 | key, value = param 44 | try: 45 | value = ast.literal_eval(value) 46 | except (ValueError, SyntaxError): 47 | pass 48 | d[key] = value 49 | if hasattr(self, section): 50 | getattr(self, section).update(d) 51 | else: 52 | setattr(self, section, d) 53 | 54 | -------------------------------------------------------------------------------- /stf_utils/stf_connect/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /stf_utils/stf_connect/client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import six 4 | import threading 5 | import json 6 | import os 7 | import time 8 | import collections 9 | import logging 10 | from random import shuffle 11 | 12 | from stf_utils.common.stfapi import SmartphoneTestingFarmAPI 13 | from stf_utils.common import adb 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class Device: 19 | serial = None 20 | ready = None 21 | present = None 22 | owner = None 23 | remote_connect_url = None 24 | 25 | def __init__(self, **entries): 26 | self.dict = entries 27 | self.__dict__.update(entries) 28 | 29 | def __str__(self): 30 | return "%s(%s)" % (self.serial, self.remote_connect_url) 31 | 32 | def __repr__(self): 33 | return "%s(%s)" % (self.serial, self.remote_connect_url) 34 | 35 | 36 | class SmartphoneTestingFarmClient(SmartphoneTestingFarmAPI): 37 | def __init__(self, host, common_api_path, oauth_token, device_spec, shutdown_emulator_on_disconnect, 38 | devices_file_path, with_adb=True): 39 | super(SmartphoneTestingFarmClient, self).__init__(host, common_api_path, oauth_token) 40 | self.device_groups = [] 41 | self.device_spec = device_spec 42 | self.devices_file_path = devices_file_path 43 | self.shutdown_emulator_on_disconnect = shutdown_emulator_on_disconnect 44 | self._set_up_device_groups() 45 | self.all_devices_are_connected = False 46 | self.with_adb = with_adb 47 | 48 | def get_wanted_amount(self, group): 49 | amount = group.get("wanted_amount") 50 | if amount == 0: 51 | devices = self.usable_devices 52 | return len(self._filter_devices(devices, group)) 53 | return amount 54 | 55 | def _set_up_device_groups(self): 56 | for wanted_device_group in self.device_spec: 57 | self.device_groups.append( 58 | { 59 | "group_name": wanted_device_group.get("group_name"), 60 | "wanted_amount": int(wanted_device_group.get("amount", 0)), 61 | "specs": wanted_device_group.get("specs", {}), 62 | "min_sdk": int(wanted_device_group.get("min_sdk", 1)), 63 | "max_sdk": int(wanted_device_group.get("max_sdk", 99)), 64 | "added_devices": [], 65 | "connected_devices": [] 66 | } 67 | ) 68 | log.debug("Wanted device groups: {}".format(self.device_groups)) 69 | 70 | def connect_devices(self): 71 | self.all_devices_are_connected = True 72 | for device_group in self.device_groups: 73 | wanted_amount, actual_amount = self.get_amounts(device_group) 74 | if actual_amount < wanted_amount: 75 | self.all_devices_are_connected = False 76 | appropriate_devices = self._filter_devices(self.available_devices, device_group) 77 | shuffle(appropriate_devices) 78 | devices_to_connect = appropriate_devices[:wanted_amount - actual_amount] 79 | self._connect_added_devices(devices_to_connect, device_group) 80 | 81 | def get_amounts(self, device_group): 82 | wanted_amount = self.get_wanted_amount(device_group) 83 | connected_devices = device_group.get("connected_devices") 84 | actual_amount = len(connected_devices) 85 | log.info("Group: %s. Wanted Amount: %s. Actual Amount (%s): %s" 86 | % (device_group.get("group_name"), 87 | wanted_amount, actual_amount, 88 | connected_devices)) 89 | return wanted_amount, actual_amount 90 | 91 | def connected_devices_check(self): 92 | for device_group in self.device_groups: 93 | for device in device_group.get("connected_devices"): 94 | if not adb.device_is_ready(device.remote_connect_url): 95 | log.warning("ADB connection with device {} was lost. " 96 | "We'll try to connect a new one.".format(device)) 97 | self._delete_device_from_group(device, device_group) 98 | self._disconnect_device(device) 99 | log.warning("Still connected {} in group '{}'".format( 100 | device_group.get("connected_devices"), device_group.get("group_name"))) 101 | else: 102 | adb.echo_ping(device.remote_connect_url) 103 | 104 | def _connect_added_devices(self, devices_to_add, device_group): 105 | for device in devices_to_add: 106 | try: 107 | log.info("Trying to connect %s from group %s..." % (device, device_group.get("group_name"))) 108 | self._add_device_to_group(device, device_group) 109 | self._connect_device_to_group(device, device_group) 110 | self._add_device_to_file(device) 111 | log.info("%s was connected and ready for use" % device) 112 | except Exception: 113 | log.exception("Error connecting for %s" % device) 114 | self._delete_device_from_group(device, device_group) 115 | self._disconnect_device(device) 116 | 117 | def _add_device_to_group(self, device, device_group): 118 | self.add_device(serial=device.serial) 119 | device_group.get("added_devices").append(device) 120 | 121 | def _adb_connect(self, device): 122 | try: 123 | adb.connect(device.remote_connect_url) 124 | except TypeError: 125 | raise Exception("Error during connecting device by ADB connect for %s" % device) 126 | except OSError: 127 | raise Exception("ADB Connection Error during connection for %s" % device) 128 | 129 | def _connect_device_to_group(self, device, device_group): 130 | resp = self.remote_connect(serial=device.serial) 131 | content = resp.json() 132 | device.remote_connect_url = content.get("remoteConnectUrl") 133 | log.info("Got remote connect url %s for connect by adb for %s" % (device.remote_connect_url, device.serial)) 134 | if self.with_adb: 135 | self._adb_connect(device) 136 | device_group.get("connected_devices").append(device) 137 | log.debug("%s was added to connected devices list" % device) 138 | 139 | def close_all(self): 140 | log.info("Disconnecting all devices...") 141 | self._disconnect_all() 142 | self._delete_all() 143 | 144 | def _add_device_to_file(self, device): 145 | try: 146 | with open(self.devices_file_path, 'a+') as mapping_file: 147 | json_mapping = json.dumps({ 148 | "adb_url": device.remote_connect_url, 149 | "serial": device.serial 150 | }) 151 | mapping_file.write("{0}\n".format(json_mapping)) 152 | except OSError: 153 | log.exception("Can't open file {} for {}".format(self.devices_file_path, device)) 154 | 155 | def _delete_device_from_group(self, device_for_delete, device_group): 156 | lists = ["connected_devices", "added_devices"] 157 | try: 158 | self.all_devices_are_connected = False 159 | for _list in lists: 160 | self._delete_device_from_devices_list(device_for_delete, device_group, _list) 161 | 162 | log.debug("Deleted %s from lists %s" % (device_for_delete, lists)) 163 | except Exception: 164 | log.exception("Error deleting %s" % device_for_delete) 165 | 166 | def _delete_device_from_devices_list(self, device_for_delete, device_group, device_list): 167 | try: 168 | for device in device_group.get(device_list): 169 | if device_for_delete.serial == device.serial: 170 | index = device_group.get(device_list).index(device) 171 | device_group.get(device_list).pop(index) 172 | except Exception: 173 | log.exception("Deleting %s from list %s was failed" % (device_for_delete, device_list)) 174 | 175 | def _delete_all(self): 176 | if os.path.exists(self.devices_file_path): 177 | try: 178 | os.remove(self.devices_file_path) 179 | except OSError: 180 | log.exception("Can't remove file {0}".format(self.devices_file_path)) 181 | 182 | for device_group in self.device_groups: 183 | log.debug("Deleting devices from group %s" % device_group.get("added_devices")) 184 | for device in device_group.get("added_devices"): 185 | self._delete_device_from_group(device, device_group) 186 | 187 | def _disconnect_all(self): 188 | for device_group in self.device_groups: 189 | while device_group.get("connected_devices"): 190 | device = device_group.get("connected_devices").pop() 191 | self._disconnect_device(device) 192 | 193 | def remote_disconnect(self, device): 194 | try: 195 | super(SmartphoneTestingFarmClient, self).remote_disconnect(device.serial) 196 | except Exception: 197 | log.exception("Error during disconnect %s from stf" % device) 198 | 199 | def delete_device(self, device): 200 | try: 201 | super(SmartphoneTestingFarmClient, self).delete_device(serial=device.serial) 202 | except Exception: 203 | log.exception("Error during deleting %s from stf" % device) 204 | 205 | def _adb_disconnect(self, device): 206 | try: 207 | if adb.device_is_ready(device.remote_connect_url): 208 | if self.shutdown_emulator_on_disconnect and device.serial.startswith('emulator'): 209 | adb.shutdown_emulator(device.remote_connect_url) 210 | log.info("Device %s has been shutdown" % device) 211 | return 212 | else: 213 | log.info("Device %s has been disconnected" % device) 214 | adb.disconnect(device.remote_connect_url) 215 | except Exception: 216 | log.exception("Error during disconnect by ADB for %s" % device) 217 | 218 | def _disconnect_device(self, device): 219 | if self.with_adb: 220 | self._adb_disconnect(device) 221 | 222 | if not (self.shutdown_emulator_on_disconnect and device.serial.startswith('emulator')): 223 | self.remote_disconnect(device) 224 | self.delete_device(device) 225 | log.info("Device %s has been released" % device) 226 | 227 | def get_all_devices(self): 228 | try: 229 | resp = super(SmartphoneTestingFarmClient, self).get_all_devices() 230 | content = resp.json() 231 | return [Device(**device) for device in content.get("devices")] 232 | except Exception: 233 | log.exception("Getting devices list was failed") 234 | return [] 235 | 236 | def _get_device_state(self, serial): 237 | time.sleep(0.1) # don't ddos api =) 238 | try: 239 | response = self.get_device(serial=serial) 240 | return response.json().get("device", {}) 241 | except Exception: 242 | log.exception("Device {} get state failed".format(self)) 243 | 244 | def is_device_available(self, serial): 245 | state = self._get_device_state(serial) 246 | if state.get("present") and state.get("ready") and not state.get("owner"): 247 | log.debug("{} is available".format(serial)) 248 | return True 249 | return False 250 | 251 | def is_device_usable(self, serial): 252 | state = self._get_device_state(serial) 253 | if state.get("present") and state.get("ready"): 254 | log.debug("{} is usable".format(serial)) 255 | return True 256 | return False 257 | 258 | @property 259 | def available_devices(self): 260 | res = list(filter(lambda d: d.present and d.ready and not d.owner, self.get_all_devices())) 261 | log.info("Available devices for connect: {}".format(res)) 262 | return res 263 | 264 | @property 265 | def usable_devices(self): 266 | res = list(filter(lambda d: d.present and d.ready, self.get_all_devices())) 267 | log.info("Usable devices: {}".format(res)) 268 | return res 269 | 270 | def _flatten_spec(self, d, parent_key='', sep='_'): 271 | items = [] 272 | for k, v in d.items(): 273 | new_key = parent_key + sep + k if parent_key else k 274 | if isinstance(v, collections.MutableMapping): 275 | items.extend(self._flatten_spec(v, new_key, sep=sep).items()) 276 | else: 277 | items.append((new_key, v)) 278 | return dict(items) 279 | 280 | def _filter_devices(self, devices, specification): 281 | filtered_devices = [] 282 | for device in devices: 283 | flatten_device = self._flatten_spec(device.dict) 284 | min_sdk = specification.get("min_sdk") 285 | max_sdk = specification.get("max_sdk") 286 | correct_sdk_version_list = {i for i in range(min_sdk, max_sdk + 1)} 287 | device_is_appropriate = True 288 | 289 | if int(flatten_device.get("sdk")) not in correct_sdk_version_list: 290 | device_is_appropriate = False 291 | continue 292 | 293 | for key, value in six.iteritems(specification.get("specs")): 294 | if value not in {flatten_device.get(key), "ANY"}: 295 | device_is_appropriate = False 296 | break 297 | 298 | if device_is_appropriate: 299 | filtered_devices.append(device) 300 | return filtered_devices 301 | 302 | 303 | class CommonPollThread(threading.Thread): 304 | def __init__(self, stf_client, poll_period=3): 305 | super(CommonPollThread, self).__init__() 306 | self._running = threading.Event() 307 | self.stf_client = stf_client 308 | self.poll_period = poll_period 309 | self.func = None 310 | 311 | def run(self): 312 | log.debug("Starting %s..." % str(self)) 313 | last_run_time = 0 314 | while self.running: 315 | if time.time() - last_run_time >= self.poll_period: 316 | if self.func: 317 | self.func() 318 | last_run_time = time.time() 319 | time.sleep(0.1) 320 | 321 | def start(self): 322 | log.debug("Starting {}...".format(self)) 323 | self._running.set() 324 | super(CommonPollThread, self).start() 325 | 326 | def stop(self): 327 | log.debug("Stopping {}...".format(self)) 328 | self._running.clear() 329 | self.join() 330 | 331 | @property 332 | def running(self): 333 | return self._running.isSet() 334 | 335 | 336 | class STFDevicesConnector(CommonPollThread): 337 | def __init__(self, stf_client, poll_period=3): 338 | super(STFDevicesConnector, self).__init__(stf_client, poll_period) 339 | self.func = self.try_connect_required_devices 340 | 341 | def try_connect_required_devices(self): 342 | if not self.stf_client.all_devices_are_connected: 343 | self.stf_client.connect_devices() 344 | 345 | 346 | class STFConnectedDevicesWatcher(CommonPollThread): 347 | def __init__(self, stf_client, poll_period=5): 348 | super(STFConnectedDevicesWatcher, self).__init__(stf_client, poll_period) 349 | self.func = self.stf_client.connected_devices_check 350 | -------------------------------------------------------------------------------- /stf_utils/stf_connect/stf_connect.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import argparse 5 | import json 6 | import logging 7 | import signal 8 | import sys 9 | import time 10 | 11 | from stf_utils import init_console_logging 12 | from stf_utils.config.config import Config 13 | from stf_utils.stf_connect.client import SmartphoneTestingFarmClient, STFDevicesConnector, STFConnectedDevicesWatcher 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | DEFAULT_CONFIG_PATH = os.path.abspath(os.path.join(os.curdir, "stf-utils.ini")) 18 | 19 | 20 | def register_signal_handler(handler, exit_code=0): 21 | def exit_gracefully(signum, frame): 22 | handler() 23 | sys.exit(exit_code) 24 | 25 | signal.signal(signal.SIGINT, exit_gracefully) 26 | signal.signal(signal.SIGTERM, exit_gracefully) 27 | 28 | 29 | class STFConnect: 30 | def __init__(self, config, device_spec, connect_and_stop=None): 31 | self.client = SmartphoneTestingFarmClient( 32 | host=config.main.get("host"), 33 | common_api_path="/api/v1", 34 | oauth_token=config.main.get("oauth_token"), 35 | device_spec=device_spec, 36 | devices_file_path=config.main.get("devices_file_path"), 37 | shutdown_emulator_on_disconnect=config.main.get("shutdown_emulator_on_disconnect"), 38 | with_adb=(not bool(connect_and_stop)) 39 | ) 40 | self.connect_and_stop = bool(connect_and_stop) 41 | if self.connect_and_stop: 42 | self.connect_timeout = int(connect_and_stop) 43 | self.connector = STFDevicesConnector(self.client) 44 | self.watcher = STFConnectedDevicesWatcher(self.client) 45 | 46 | exit_code = 1 if self.connect_and_stop else 0 47 | register_signal_handler(self.stop, exit_code) 48 | 49 | def run(self): 50 | log.info("Starting device connect service...") 51 | if self.connect_and_stop: 52 | self._connect_devices() 53 | else: 54 | self._run_forever() 55 | 56 | def _run_forever(self): 57 | self._start_workers() 58 | while True: 59 | time.sleep(1) 60 | 61 | def _connect_devices(self): 62 | timeout = self.connect_timeout 63 | start = time.time() 64 | while time.time() < start + timeout: 65 | if not self.client.all_devices_are_connected: 66 | self.client.connect_devices() 67 | else: 68 | log.info("All devices are connected") 69 | break 70 | time.sleep(0.2) 71 | else: 72 | log.info("Timeout connecting devices {}".format(timeout)) 73 | self.client.close_all() 74 | exit(1) 75 | 76 | def _start_workers(self): 77 | self.watcher.start() 78 | self.connector.start() 79 | 80 | def _stop_workers(self): 81 | if self.connector and self.connector.running: 82 | self.connector.stop() 83 | if self.watcher and self.watcher.running: 84 | self.watcher.stop() 85 | 86 | def stop(self): 87 | log.info("Stopping connect service...") 88 | self._stop_workers() 89 | 90 | log.debug("Stopping main thread...") 91 | self.client.close_all() 92 | 93 | 94 | def get_spec(device_spec_path, groups=None): 95 | with open(device_spec_path) as f: 96 | device_spec = json.load(f) 97 | 98 | if groups: 99 | log.info("Working only with specified groups: {0}".format(groups)) 100 | specified_groups = groups.split(",") 101 | return [group for group in device_spec if group.get("group_name") in specified_groups] 102 | 103 | return device_spec 104 | 105 | 106 | def parse_args(): 107 | parser = argparse.ArgumentParser( 108 | description="Utility for connecting " 109 | "devices from STF" 110 | ) 111 | parser.add_argument( 112 | "-g", "--groups", 113 | help="Device groups defined in spec file to connect" 114 | ) 115 | parser.add_argument( 116 | "-l", "--log-level", default="INFO", 117 | help="Log level (default: INFO)" 118 | ) 119 | parser.add_argument( 120 | "-c", "--config", default=DEFAULT_CONFIG_PATH, 121 | help="Path to config file (default: stf-utils.ini from current directory)", 122 | ) 123 | parser.add_argument( 124 | "--connect-and-stop", 125 | type=int, nargs="?", const=600, default=None, metavar="TIMEOUT", 126 | help="Connect devices and stop with no disconnect. " 127 | "Optional value: timeout in seconds. " 128 | "Defaults to 600 if no value was passed" 129 | ) 130 | return parser.parse_args() 131 | 132 | 133 | def run(): 134 | args = parse_args() 135 | init_console_logging(args.log_level) 136 | 137 | try: 138 | config = Config(args.config) 139 | except FileNotFoundError: 140 | log.error("File \"{}\" doesn\'t exist".format(args.config)) 141 | exit(1) 142 | 143 | device_spec = get_spec(config.main.get("device_spec"), args.groups) 144 | 145 | stf_connect = STFConnect(config, device_spec, args.connect_and_stop) 146 | stf_connect.run() 147 | 148 | 149 | if __name__ == "__main__": 150 | run() 151 | -------------------------------------------------------------------------------- /stf_utils/stf_record/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/stf-utils/844c38145a82614872b35d9771dde45e7d7d083c/stf_utils/stf_record/__init__.py -------------------------------------------------------------------------------- /stf_utils/stf_record/protocol.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | from autobahn.asyncio.websocket import WebSocketClientProtocol 4 | 5 | log = logging.getLogger(__name__) 6 | 7 | 8 | class STFRecordProtocol(WebSocketClientProtocol): 9 | img_directory = None 10 | address = None 11 | resolution = None 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self.first_msg_timestamp = None 16 | self.previous_msg_timestamp = None 17 | self.current_msg_timestamp = None 18 | 19 | def _construct_img_filename(self): 20 | img_filename = "{0}.jpg".format( 21 | self.current_msg_timestamp - self.first_msg_timestamp 22 | ) 23 | return img_filename 24 | 25 | @staticmethod 26 | def _write_image_file(img_filename, binary_data): 27 | with open(img_filename, 'bw+') as file: 28 | log.debug('Writing image data to file {0}'.format(file.name)) 29 | file.write(binary_data) 30 | 31 | def _write_metadata(self, img_filename): 32 | metadata_filename = "{0}/input.txt".format(self.img_directory) 33 | m_file = open(metadata_filename, 'a') 34 | log.debug('Appending image metadata to file {0}'.format(m_file.name)) 35 | if self.previous_msg_timestamp is not None: 36 | duration = self.current_msg_timestamp - self.previous_msg_timestamp 37 | m_file.write("duration {0}\n".format(duration)) 38 | m_file.write("file '{0}'\n".format(img_filename)) 39 | m_file.close() 40 | 41 | def save_data_and_metadata(self, binary_data): 42 | img_filename = self._construct_img_filename() 43 | self._write_image_file("{0}/{1}".format(self.img_directory, img_filename), binary_data) 44 | self._write_metadata(img_filename) 45 | 46 | def onOpen(self): 47 | log.debug('Starting receive binary data') 48 | if self.resolution: 49 | self.sendMessage(self.resolution.encode('ascii')) 50 | self.sendMessage('on'.encode('ascii')) 51 | 52 | def onMessage(self, payload, isBinary): 53 | if isBinary: 54 | self.current_msg_timestamp = time.time() 55 | if self.previous_msg_timestamp is None: 56 | self.first_msg_timestamp = self.current_msg_timestamp 57 | self.save_data_and_metadata(payload) 58 | self.previous_msg_timestamp = self.current_msg_timestamp 59 | 60 | def onClose(self, wasClean, code, reason): 61 | log.debug('Disconnecting {0} ...'.format(self.address)) 62 | self.sendMessage('off'.encode('ascii')) 63 | -------------------------------------------------------------------------------- /stf_utils/stf_record/stf_record.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import argparse 4 | import asyncio 5 | import json 6 | import logging 7 | import signal 8 | 9 | from autobahn.asyncio.websocket import WebSocketClientFactory 10 | 11 | import functools 12 | import os 13 | 14 | from stf_utils import init_console_logging 15 | from stf_utils.common.stfapi import SmartphoneTestingFarmAPI 16 | from stf_utils.config.config import Config 17 | from stf_utils.stf_record.protocol import STFRecordProtocol 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | def gracefully_exit(loop): 23 | log.info("Stopping loop...") 24 | loop.stop() 25 | 26 | 27 | def wsfactory(address, directory, resolution, keep_old_data): 28 | loop = asyncio.get_event_loop() 29 | gracefully_exit_handler = functools.partial(gracefully_exit, loop) 30 | loop.add_signal_handler(signal.SIGTERM, gracefully_exit_handler) 31 | loop.add_signal_handler(signal.SIGINT, gracefully_exit_handler) 32 | 33 | directory = create_directory_if_not_exists(directory) 34 | if not keep_old_data: 35 | remove_all_data(directory) 36 | 37 | factory = WebSocketClientFactory("ws://{0}".format(address)) 38 | factory.protocol = STFRecordProtocol 39 | factory.protocol.img_directory = directory 40 | factory.protocol.address = address 41 | factory.protocol.resolution = resolution 42 | 43 | coro = loop.create_connection( 44 | factory, address.split(":")[0], address.split(":")[1] 45 | ) 46 | log.info("Connecting to {0} ...".format(address)) 47 | loop.run_until_complete(coro) 48 | try: 49 | loop.run_forever() 50 | finally: 51 | loop.close() 52 | 53 | 54 | def create_directory_if_not_exists(directory): 55 | directory = os.path.abspath(directory) 56 | log.debug("Using directory \"{0}\" for storing images".format(directory)) 57 | if not os.path.exists(directory): 58 | os.makedirs(directory) 59 | return directory 60 | 61 | 62 | def remove_all_data(directory): 63 | if directory and os.path.exists(directory): 64 | for file in os.listdir(directory): 65 | if file.endswith(".txt") or file.endswith(".jpg"): 66 | try: 67 | os.remove("{0}/{1}".format(directory, file)) 68 | log.debug("File {0}/{1} was deleted".format(directory, file)) 69 | except Exception as e: 70 | log.debug("Error during deleting file {0}/{1}: {2}".format(directory, file, str(e))) 71 | 72 | 73 | def _get_device_serial(adb_connect_url, connected_devices_file_path): 74 | device_serial = None 75 | with open(connected_devices_file_path, "r") as devices_file: 76 | for line in devices_file.readlines(): 77 | line = json.loads(line) 78 | log.debug("Finding device serial of device connected as {0} in {1}".format( 79 | adb_connect_url, 80 | connected_devices_file_path 81 | )) 82 | if line.get("adb_url") == adb_connect_url: 83 | log.debug("Found device serial {0} for device connected as {1}".format( 84 | line.get("serial"), 85 | adb_connect_url) 86 | ) 87 | device_serial = line.get("serial") 88 | break 89 | else: 90 | log.warning("No matching device serial found for device name {0}".format(adb_connect_url)) 91 | return device_serial 92 | 93 | 94 | def run(): 95 | def get_ws_url(api, args): 96 | if args["adb_connect_url"]: 97 | connected_devices_file_path = config.main.get("devices_file_path") 98 | args["serial"] = _get_device_serial(args["adb_connect_url"], connected_devices_file_path) 99 | 100 | if args["serial"]: 101 | device_props = api.get_device(args["serial"]) 102 | props_json = device_props.json() 103 | args["ws"] = props_json.get("device").get("display").get("url") 104 | log.debug("Got websocket url {0} by device serial {1} from stf API".format(args["ws"], args["serial"])) 105 | 106 | address = args["ws"].split("ws://")[-1] 107 | return address 108 | 109 | parser = argparse.ArgumentParser( 110 | description="Utility for saving screenshots " 111 | "from devices with openstf minicap" 112 | ) 113 | generic_display_id_group = parser.add_mutually_exclusive_group(required=True) 114 | generic_display_id_group.add_argument( 115 | "-s", "--serial", help="Device serial" 116 | ) 117 | generic_display_id_group.add_argument( 118 | "-w", "--ws", help="WebSocket URL" 119 | ) 120 | generic_display_id_group.add_argument( 121 | "-a", "--adb-connect-url", help="URL used to remote debug with adb connect, e.g. :" 122 | ) 123 | parser.add_argument( 124 | "-d", "--dir", help="Directory for images", default="images" 125 | ) 126 | parser.add_argument( 127 | "-r", "--resolution", help="Resolution of images" 128 | ) 129 | parser.add_argument( 130 | "-l", "--log-level", help="Log level (default: INFO)", default="INFO" 131 | ) 132 | parser.add_argument( 133 | "-k", "--keep-old-data", help="Do not clean old data from directory", action="store_true", default=False 134 | ) 135 | parser.add_argument( 136 | "-c", "--config", help="Path to config file", default="stf-utils.ini" 137 | ) 138 | 139 | args = vars(parser.parse_args()) 140 | init_console_logging(args["log_level"]) 141 | 142 | try: 143 | config = Config(args["config"]) 144 | except FileNotFoundError: 145 | log.error("File \"{}\" doesn\'t exist".format(args["config"])) 146 | exit(1) 147 | 148 | api = SmartphoneTestingFarmAPI( 149 | host=config.main.get("host"), 150 | common_api_path="/api/v1", 151 | oauth_token=config.main.get("oauth_token") 152 | ) 153 | 154 | wsfactory( 155 | directory=args["dir"], 156 | resolution=args["resolution"], 157 | address=get_ws_url(api, args), 158 | keep_old_data=args["keep_old_data"] 159 | ) 160 | 161 | if __name__ == "__main__": 162 | run() 163 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | mock==2.0.0 2 | git+https://github.com/2gis/lode_runner 3 | codecov==1.6.3 -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/stf-utils/844c38145a82614872b35d9771dde45e7d7d083c/tests/__init__.py -------------------------------------------------------------------------------- /tests/data/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/2gis/stf-utils/844c38145a82614872b35d9771dde45e7d7d083c/tests/data/__init__.py -------------------------------------------------------------------------------- /tests/data/get_all_devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "success":true, 3 | "devices":[ 4 | { 5 | "abi":"x86", 6 | "airplaneMode":false, 7 | "battery":{ 8 | "health":"good", 9 | "level":100, 10 | "scale":100, 11 | "source":"usb", 12 | "status":"full", 13 | "temp":27.3, 14 | "voltage":4.338 15 | }, 16 | "browser":{ 17 | "apps":[ 18 | { 19 | "id":"com.asus.browser/com.android.browser.BrowserActivity", 20 | "name":"Browser", 21 | "selected":false, 22 | "system":true, 23 | "type":"asus-browser", 24 | "developer":"ASUS" 25 | }, 26 | { 27 | "id":"com.android.chrome/com.google.android.apps.chrome.Main", 28 | "name":"Chrome", 29 | "selected":false, 30 | "system":true, 31 | "type":"chrome", 32 | "developer":"Google Inc." 33 | } 34 | ], 35 | "selected":false 36 | }, 37 | "channel":"+mdBOXqxY+1DsgFEDX75mSFmv4s=", 38 | "createdAt":"2016-04-08T11:46:15.127Z", 39 | "display":{ 40 | "density":2, 41 | "fps":60.000003814697266, 42 | "height":1920, 43 | "id":0, 44 | "rotation":0, 45 | "secure":true, 46 | "size":7.043910980224609, 47 | "url":"ws://10.0.0.1:7404", 48 | "width":1200, 49 | "xdpi":320, 50 | "ydpi":322 51 | }, 52 | "manufacturer":"ASUS", 53 | "model":"K007", 54 | "network":{ 55 | "connected":true, 56 | "failover":false, 57 | "roaming":false, 58 | "subtype":"", 59 | "type":"WIFI" 60 | }, 61 | "operator":null, 62 | "owner":null, 63 | "phone":{ 64 | "iccid":null, 65 | "imei":null, 66 | "network":"UNKNOWN", 67 | "phoneNumber":null 68 | }, 69 | "platform":"Android", 70 | "presenceChangedAt":"2016-06-30T03:17:04.978Z", 71 | "present":true, 72 | "product":"WW_K007", 73 | "provider":{ 74 | "channel":"DOBsHgmuQ/un+ZTTSvdMcQ==", 75 | "name":"stf-node01" 76 | }, 77 | "ready":true, 78 | "remoteConnect":false, 79 | "remoteConnectUrl":null, 80 | "reverseForwards":[ 81 | 82 | ], 83 | "sdk":"21", 84 | "serial":"000000000000", 85 | "status":3, 86 | "statusChangedAt":"2016-06-30T03:17:04.912Z", 87 | "version":"5.0.1", 88 | "using":false 89 | }, 90 | { 91 | "abi":"arm64-v8a", 92 | "airplaneMode":false, 93 | "battery":{ 94 | "health":"good", 95 | "level":100, 96 | "scale":100, 97 | "source":"usb", 98 | "status":"charging", 99 | "temp":28.7, 100 | "voltage":4.352 101 | }, 102 | "browser":{ 103 | "apps":[ 104 | { 105 | "id":"com.android.chrome/com.google.android.apps.chrome.Main", 106 | "name":"Chrome", 107 | "selected":true, 108 | "system":true, 109 | "type":"chrome", 110 | "developer":"Google Inc." 111 | } 112 | ], 113 | "selected":true 114 | }, 115 | "channel":"qNhlM3eB52Yzc6jskqBtnb3i9CA=", 116 | "createdAt":"2016-04-21T04:45:42.694Z", 117 | "display":{ 118 | "density":2.625, 119 | "fps":60, 120 | "height":1920, 121 | "id":0, 122 | "rotation":0, 123 | "secure":true, 124 | "size":5.200733661651611, 125 | "url":"ws://10.0.0.1:7696", 126 | "width":1080, 127 | "xdpi":422.0299987792969, 128 | "ydpi":424.0690002441406, 129 | "inches":5.2 130 | }, 131 | "manufacturer":"LGE", 132 | "model":"Nexus 5X", 133 | "network":{ 134 | "connected":true, 135 | "failover":false, 136 | "roaming":false, 137 | "subtype":"", 138 | "type":"WIFI" 139 | }, 140 | "notes":"", 141 | "operator":null, 142 | "owner":null, 143 | "phone":{ 144 | "iccid":null, 145 | "imei":"353627074976377", 146 | "network":"UNKNOWN", 147 | "phoneNumber":null 148 | }, 149 | "platform":"Android", 150 | "presenceChangedAt":"2016-07-01T14:32:03.081Z", 151 | "present":true, 152 | "product":"bullhead", 153 | "provider":{ 154 | "channel":"BZNRh6YBR9KMk0b/vu2NnQ==", 155 | "name":"stf-node01" 156 | }, 157 | "ready":true, 158 | "remoteConnect":false, 159 | "remoteConnectUrl":null, 160 | "reverseForwards":[ 161 | 162 | ], 163 | "sdk":"23", 164 | "serial":"00ec0ea6616e95c8", 165 | "status":3, 166 | "statusChangedAt":"2016-07-01T14:31:52.256Z", 167 | "version":"6.0.1", 168 | "name":"Nexus 5X", 169 | "releasedAt":"2015-10-21T15:00:00.000Z", 170 | "image":"Nexus_5X.jpg", 171 | "cpu":{ 172 | "cores":6, 173 | "freq":1.8, 174 | "name":"Qualcomm Snapdragon 808 MSM8992" 175 | }, 176 | "memory":{ 177 | "ram":2048, 178 | "rom":32768 179 | }, 180 | "using":false 181 | }, 182 | { 183 | "abi":"armeabi-v7a", 184 | "airplaneMode":false, 185 | "battery":{ 186 | "health":"good", 187 | "level":84, 188 | "scale":100, 189 | "source":"usb", 190 | "status":"charging", 191 | "temp":32.9, 192 | "voltage":4.155 193 | }, 194 | "browser":{ 195 | "apps":[ 196 | { 197 | "id":"com.android.chrome/com.google.android.apps.chrome.Main", 198 | "name":"Chrome", 199 | "selected":true, 200 | "system":true, 201 | "type":"chrome", 202 | "developer":"Google Inc." 203 | } 204 | ], 205 | "selected":true 206 | }, 207 | "channel":"3kHMgKFTxu1EsR/SxBh0u1BFqtM=", 208 | "createdAt":"2016-02-10T11:03:16.215Z", 209 | "display":{ 210 | "density":3, 211 | "fps":60, 212 | "height":1920, 213 | "id":0, 214 | "rotation":0, 215 | "secure":true, 216 | "size":4.971247673034668, 217 | "url":"ws://10.0.0.1:7468", 218 | "width":1080, 219 | "xdpi":442.45098876953125, 220 | "ydpi":443.3450012207031, 221 | "inches":5 222 | }, 223 | "manufacturer":"LGE", 224 | "model":"Nexus 5", 225 | "network":{ 226 | "connected":true, 227 | "failover":false, 228 | "roaming":false, 229 | "subtype":"", 230 | "type":"WIFI" 231 | }, 232 | "operator":"MTS RUS", 233 | "owner":null, 234 | "phone":{ 235 | "iccid":"89701015538941240607", 236 | "imei":"353490060165418", 237 | "network":"LTE", 238 | "phoneNumber":null 239 | }, 240 | "platform":"Android", 241 | "presenceChangedAt":"2016-05-18T08:28:41.762Z", 242 | "present":false, 243 | "product":"hammerhead", 244 | "provider":{ 245 | "channel":"iECWvXk3QHSLnjKr7k61yg==", 246 | "name":"stf-node01" 247 | }, 248 | "ready":false, 249 | "remoteConnect":false, 250 | "remoteConnectUrl":null, 251 | "reverseForwards":[ 252 | 253 | ], 254 | "sdk":"23", 255 | "serial":"0759396d00d215ba", 256 | "status":1, 257 | "statusChangedAt":"2016-05-18T08:28:22.104Z", 258 | "version":"6.0.1", 259 | "name":"Nexus 5", 260 | "releasedAt":"2013-11-14T15:00:00.000Z", 261 | "image":"Nexus_5.jpg", 262 | "cpu":{ 263 | "cores":4, 264 | "freq":2.26, 265 | "name":"Qualcomm Snapdragon 800 MSM8974" 266 | }, 267 | "memory":{ 268 | "ram":2048, 269 | "rom":32768 270 | }, 271 | "using":false 272 | } 273 | ] 274 | } -------------------------------------------------------------------------------- /tests/data/get_device_x86.json: -------------------------------------------------------------------------------- 1 | { 2 | "success":true, 3 | "device":{ 4 | "abi":"x86", 5 | "airplaneMode":false, 6 | "battery":{ 7 | "health":"good", 8 | "level":100, 9 | "scale":100, 10 | "source":"usb", 11 | "status":"full", 12 | "temp":27.6, 13 | "voltage":4.337 14 | }, 15 | "browser":{ 16 | "apps":[ 17 | { 18 | "id":"com.asus.browser/com.android.browser.BrowserActivity", 19 | "name":"Browser", 20 | "selected":false, 21 | "system":true, 22 | "type":"asus-browser", 23 | "developer":"ASUS" 24 | }, 25 | { 26 | "id":"com.android.chrome/com.google.android.apps.chrome.Main", 27 | "name":"Chrome", 28 | "selected":false, 29 | "system":true, 30 | "type":"chrome", 31 | "developer":"Google Inc." 32 | } 33 | ], 34 | "selected":false 35 | }, 36 | "channel":"+mdBOXqxY+1DsgFEDX75mSFmv4s=", 37 | "createdAt":"2016-04-08T11:46:15.127Z", 38 | "display":{ 39 | "density":2, 40 | "fps":60.000003814697266, 41 | "height":1920, 42 | "id":0, 43 | "rotation":0, 44 | "secure":true, 45 | "size":7.043910980224609, 46 | "url":"ws://10.0.0.1:7404", 47 | "width":1200, 48 | "xdpi":320, 49 | "ydpi":322 50 | }, 51 | "manufacturer":"ASUS", 52 | "model":"K007", 53 | "network":{ 54 | "connected":true, 55 | "failover":false, 56 | "roaming":false, 57 | "subtype":"", 58 | "type":"WIFI" 59 | }, 60 | "operator":null, 61 | "owner":null, 62 | "phone":{ 63 | "iccid":null, 64 | "imei":null, 65 | "network":"UNKNOWN", 66 | "phoneNumber":null 67 | }, 68 | "platform":"Android", 69 | "presenceChangedAt":"2016-06-30T03:17:04.978Z", 70 | "present":true, 71 | "product":"WW_K007", 72 | "provider":{ 73 | "channel":"DOBsHgmuQ/un+ZTTSvdMcQ==", 74 | "name":"stf-node01" 75 | }, 76 | "ready":true, 77 | "remoteConnect":false, 78 | "remoteConnectUrl":null, 79 | "reverseForwards":[ 80 | 81 | ], 82 | "sdk":"21", 83 | "serial":"000000000000", 84 | "status":3, 85 | "statusChangedAt":"2016-06-30T03:17:04.912Z", 86 | "version":"5.0.1", 87 | "using":false 88 | } 89 | } -------------------------------------------------------------------------------- /tests/data/get_user_devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "success": true, 3 | "devices": [ ] 4 | } -------------------------------------------------------------------------------- /tests/data/remote_connect.json: -------------------------------------------------------------------------------- 1 | { 2 | "remoteConnectUrl": "http://10.0.0.1:7575" 3 | } -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import time 4 | 5 | 6 | def get_response_from_file(filename): 7 | with open('%s/data/%s' % (os.path.dirname(os.path.abspath(__file__)), filename)) as f: 8 | all_devices = f.read() 9 | return json.loads(all_devices) 10 | 11 | 12 | def wait_for(condition, timeout=5): 13 | start = time.time() 14 | while not condition() and time.time() - start < timeout: 15 | time.sleep(0.1) 16 | 17 | return condition() -------------------------------------------------------------------------------- /tests/test_stf_connect_client.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from lode_runner import dataprovider 4 | from unittest import TestCase 5 | 6 | try: 7 | from mock import patch, Mock 8 | except ImportError: 9 | from unittest.mock import patch, Mock 10 | 11 | from tests.helpers import get_response_from_file, wait_for 12 | from stf_utils.stf_connect.client import SmartphoneTestingFarmClient, STFConnectedDevicesWatcher 13 | 14 | 15 | class TestSmartphoneTestingFarmClient(TestCase): 16 | def setUp(self): 17 | super(TestSmartphoneTestingFarmClient, self).setUp() 18 | self.watcher = None 19 | get_all_devices = get_response_from_file('get_all_devices.json') 20 | get_device = get_response_from_file('get_device_x86.json') 21 | remote_connect = get_response_from_file('remote_connect.json') 22 | 23 | self.all_devices_mock = Mock(return_value=Mock(json=Mock(return_value=get_all_devices))) 24 | self.get_device_mock = Mock(return_value=Mock(json=Mock(return_value=get_device))) 25 | self.remote_connect_mock = Mock(return_value=Mock(json=Mock(return_value=remote_connect))) 26 | 27 | def tearDown(self): 28 | if self.watcher: 29 | self.watcher.stop() 30 | 31 | @dataprovider([ 32 | [ 33 | { 34 | "group_name": "alfa", 35 | "amount": "1", 36 | "min_sdk": "16", 37 | "max_sdk": "23", 38 | "specs": {"abi": "x86", "platform": "Android"} 39 | } 40 | ] 41 | ]) 42 | def test_connect_devices(self, device_spec): 43 | """ 44 | - set config with 1 device 45 | - try to connect devices 46 | 47 | Expected: 1 device connected and 1 device in connected_devices list 48 | 49 | - stop stf-connect 50 | 51 | Expected: 0 devices connected and lists of devices was empty 52 | """ 53 | with patch( 54 | 'stf_utils.common.stfapi.SmartphoneTestingFarmAPI.get_all_devices', self.all_devices_mock, 55 | ), patch( 56 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.get_device', self.get_device_mock, 57 | ), patch( 58 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.add_device', Mock(), 59 | ), patch( 60 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.remote_connect', self.remote_connect_mock, 61 | ), patch( 62 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.delete_device', Mock(), 63 | ), patch( 64 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.remote_disconnect', Mock(), 65 | ), patch( 66 | 'stf_utils.common.adb.device_is_ready', Mock(return_value=True) 67 | ), patch( 68 | 'stf_utils.common.adb.connect', Mock(return_value=True) 69 | ), patch( 70 | 'stf_utils.common.adb.disconnect', Mock(return_value=True) 71 | ): 72 | stf = SmartphoneTestingFarmClient( 73 | host="http://host.domain", 74 | common_api_path="/api/v1", 75 | oauth_token="test token", 76 | device_spec=device_spec, 77 | devices_file_path="./devices", 78 | shutdown_emulator_on_disconnect=True 79 | ) 80 | stf.connect_devices() 81 | 82 | wait_for(lambda: self.assertTrue(stf.shutdown_emulator_on_disconnect)) 83 | wait_for(lambda: self.assertEqual(len(stf.device_groups[0].get("added_devices")), int(device_spec[0].get("amount")))) 84 | wait_for(lambda: self.assertEqual(len(stf.device_groups[0].get("connected_devices")), int(device_spec[0].get("amount")))) 85 | 86 | stf.close_all() 87 | 88 | wait_for(lambda: self.assertEqual(len(stf.device_groups[0].get("added_devices")), 0)) 89 | wait_for(lambda: self.assertEqual(len(stf.device_groups[0].get("connected_devices")), 0)) 90 | 91 | @dataprovider([ 92 | [ 93 | { 94 | "group_name": "alfa", 95 | "amount": "1", 96 | "specs": {"abi": "x86"} 97 | } 98 | ] 99 | ]) 100 | def test_connect_new_device_after_device_lost(self, device_spec): 101 | """ 102 | - set config with 1 device 103 | - try to connect devices 104 | 105 | Expected: 1 device connected and 1 device in connected_devices list 106 | 107 | - start devices watcher 108 | - got 'False' in device_is_ready method (connected device is not available) 109 | 110 | Expected: 0 devices connected and lists of devices was empty 111 | (device was removed from stf-connect and device by adb was disconnected) 112 | 113 | - try to connect available devices 114 | 115 | Expected: 1 device connected and 1 device in connected_devices list 116 | """ 117 | def raise_exception(): 118 | raise Exception('something ugly happened in adb connect') 119 | 120 | with patch( 121 | 'stf_utils.common.stfapi.SmartphoneTestingFarmAPI.get_all_devices', self.all_devices_mock, 122 | ), patch( 123 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.get_device', self.get_device_mock, 124 | ), patch( 125 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.add_device', Mock(), 126 | ), patch( 127 | 'stf_utils.stf_connect.client.SmartphoneTestingFarmClient.remote_connect', self.remote_connect_mock, 128 | ), patch( 129 | 'stf_utils.common.adb.device_is_ready', Mock(side_effect=[False, True, True]) 130 | ), patch( 131 | 'stf_utils.common.adb.connect', Mock(side_effect=[True, raise_exception, True]) 132 | ), patch( 133 | 'stf_utils.common.adb.disconnect', Mock(return_value=True) 134 | ): 135 | stf = SmartphoneTestingFarmClient( 136 | host="http://host.domain", 137 | common_api_path="/api/v1", 138 | oauth_token="test token", 139 | device_spec=device_spec, 140 | devices_file_path="./devices", 141 | shutdown_emulator_on_disconnect=True 142 | ) 143 | stf.connect_devices() 144 | 145 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("added_devices")) == int(device_spec[0].get("amount")))) 146 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("connected_devices")) == int(device_spec[0].get("amount")))) 147 | 148 | self.watcher = STFConnectedDevicesWatcher(stf) 149 | self.watcher.start() 150 | 151 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("added_devices")) == 0)) 152 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("connected_devices")) == 0)) 153 | 154 | stf.connect_devices() 155 | 156 | self.assertTrue(wait_for(lambda: stf.shutdown_emulator_on_disconnect)) 157 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("added_devices")) == int(device_spec[0].get("amount")))) 158 | self.assertTrue(wait_for(lambda: len(stf.device_groups[0].get("connected_devices")) == int(device_spec[0].get("amount")))) 159 | --------------------------------------------------------------------------------