1#!/usr/bin/env python3 2 3# Copyright (C) 2023 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17"""Script for running Android Gerrit-based Mobly tests locally. 18 19Example: 20 - Run a test module. 21 local_mobly_runner.py -m my_test_module 22 23 - Run a test module. Build the module and install test APKs before running the test. 24 local_mobly_runner.py -m my_test_module -b -i 25 26 - Run a test module with specific Android devices. 27 local_mobly_runner.py -m my_test_module -s DEV00001,DEV00002 28 29 - Run a list of zipped Mobly packages (built from `python_test_host`) 30 local_mobly_runner.py -p test_pkg1,test_pkg2,test_pkg3 31 32Please run `local_mobly_runner.py -h` for a full list of options. 33""" 34 35import argparse 36import json 37import os 38import shutil 39import subprocess 40import sys 41import tempfile 42from typing import List, Optional, Tuple 43import zipfile 44 45_LOCAL_SETUP_INSTRUCTIONS = ( 46 '\n\tcd <repo_root>; set -a; source build/envsetup.sh; set +a; lunch' 47 ' <target>' 48) 49 50_tempdirs = [] 51_tempfiles = [] 52 53 54def _padded_print(line: str) -> None: 55 print(f'\n-----{line}-----\n') 56 57 58def _parse_args() -> argparse.Namespace: 59 """Parses command line args.""" 60 parser = argparse.ArgumentParser( 61 formatter_class=argparse.RawDescriptionHelpFormatter, 62 description=__doc__) 63 group1 = parser.add_mutually_exclusive_group(required=True) 64 group1.add_argument( 65 '-m', '--module', help='The Android build module of the test to run.' 66 ) 67 group1.add_argument( 68 '-p', '--packages', 69 help='A comma-delimited list of test packages to run.' 70 ) 71 group1.add_argument( 72 '-t', 73 '--test_paths', 74 help=( 75 'A comma-delimited list of test paths to run directly. Implies ' 76 'the --novenv option.' 77 ), 78 ) 79 parser.add_argument( 80 '-b', 81 '--build', 82 action='store_true', 83 help='Build/rebuild the specified module. Requires the -m option.', 84 ) 85 parser.add_argument( 86 '-i', 87 '--install_apks', 88 action='store_true', 89 help=( 90 'Install all APKs associated with the module to all specified' 91 ' devices. Requires the -m or -p options.' 92 ), 93 ) 94 group2 = parser.add_mutually_exclusive_group() 95 group2.add_argument( 96 '-s', 97 '--serials', 98 help=( 99 'Specify the devices to test with a comma-delimited list of device ' 100 'serials.' 101 ), 102 ) 103 group2.add_argument( 104 '-c', '--config', help='Provide a custom Mobly config for the test.' 105 ) 106 parser.add_argument('-lp', '--log_path', 107 help='Specify a path to store logs.') 108 parser.add_argument( 109 '--novenv', 110 action='store_true', 111 help=( 112 "Run directly in the host's system Python, without setting up a " 113 'virtualenv.' 114 ), 115 ) 116 args = parser.parse_args() 117 if args.build and not args.module: 118 parser.error('Option --build requires --module to be specified.') 119 if args.install_apks and not (args.module or args.packages): 120 parser.error('Option --install_apks requires --module or --packages.') 121 122 args.novenv = args.novenv or (args.test_paths is not None) 123 return args 124 125 126def _build_module(module: str) -> None: 127 """Builds the specified module.""" 128 _padded_print(f'Building test module {module}.') 129 try: 130 subprocess.check_call(f'm -j {module}', shell=True, 131 executable='/bin/bash') 132 except subprocess.CalledProcessError as e: 133 if e.returncode == 127: 134 # `m` command not found 135 print( 136 '`m` command not found. Please set up your local environment ' 137 f'with {_LOCAL_SETUP_INSTRUCTIONS}.' 138 ) 139 else: 140 print(f'Failed to build module {module}.') 141 exit(1) 142 143 144def _get_module_artifacts(module: str) -> List[str]: 145 """Return the list of artifacts generated from a module.""" 146 try: 147 outmod_paths = ( 148 subprocess.check_output( 149 f'outmod {module}', shell=True, executable='/bin/bash' 150 ) 151 .decode('utf-8') 152 .splitlines() 153 ) 154 except subprocess.CalledProcessError as e: 155 if e.returncode == 127: 156 # `outmod` command not found 157 print( 158 '`outmod` command not found. Please set up your local ' 159 f'environment with {_LOCAL_SETUP_INSTRUCTIONS}.' 160 ) 161 if str(e.output).startswith('Could not find module'): 162 print( 163 f'Cannot find the build output of module {module}. Ensure that ' 164 'the module list is up-to-date with `refreshmod`.' 165 ) 166 exit(1) 167 168 for path in outmod_paths: 169 if not os.path.isfile(path): 170 print( 171 f'Declared file {path} does not exist. Please build your ' 172 'module with the -b option.' 173 ) 174 exit(1) 175 176 return outmod_paths 177 178 179def _resolve_test_resources( 180 args: argparse.Namespace, 181) -> Tuple[List[str], List[str], List[str]]: 182 """Resolve test resources from the given test module or package. 183 184 Args: 185 args: Parsed command-line args. 186 187 Returns: 188 Tuple of (mobly_bins, requirement_files, test_apks). 189 """ 190 mobly_bins = [] 191 requirements_files = [] 192 test_apks = [] 193 if args.test_paths: 194 mobly_bins.extend(args.test_paths.split(',')) 195 elif args.module: 196 for path in _get_module_artifacts(args.module): 197 if path.endswith(args.module): 198 mobly_bins.append(path) 199 if path.endswith('requirements.txt'): 200 requirements_files.append(path) 201 if path.endswith('.apk'): 202 test_apks.append(path) 203 elif args.packages: 204 unzip_root = tempfile.mkdtemp(prefix='mobly_unzip_') 205 _tempdirs.append(unzip_root) 206 for package in args.packages.split(','): 207 mobly_bins.append(package) 208 unzip_dir = os.path.join(unzip_root, os.path.basename(package)) 209 print(f'Unzipping test package {package} to {unzip_dir}.') 210 os.makedirs(unzip_dir) 211 with zipfile.ZipFile(package) as zf: 212 zf.extractall(unzip_dir) 213 for path in os.listdir(unzip_dir): 214 path = os.path.join(unzip_dir, path) 215 if path.endswith('requirements.txt'): 216 requirements_files.append(path) 217 if path.endswith('.apk'): 218 test_apks.append(path) 219 else: 220 print('No tests specified. Aborting.') 221 exit(1) 222 return mobly_bins, requirements_files, test_apks 223 224 225def _setup_virtualenv(requirements_files: List[str]) -> str: 226 """Creates a virtualenv and install dependencies into it. 227 228 Args: 229 requirements_files: List of paths of requirements.txt files. 230 231 Returns: 232 Path to the virtualenv's Python interpreter. 233 """ 234 if not requirements_files: 235 print('No requirements.txt file found. Aborting.') 236 exit(1) 237 238 venv_dir = tempfile.mkdtemp(prefix='venv_') 239 _padded_print(f'Creating virtualenv at {venv_dir}.') 240 subprocess.check_call([sys.executable, '-m', 'venv', venv_dir]) 241 _tempdirs.append(venv_dir) 242 venv_executable = os.path.join(venv_dir, 'bin/python3') 243 244 # Install requirements 245 for requirements_file in requirements_files: 246 print(f'Installing dependencies from {requirements_file}.') 247 subprocess.check_call( 248 [venv_executable, '-m', 'pip', 'install', '-r', requirements_file] 249 ) 250 return venv_executable 251 252 253def _install_apks( 254 apks: List[str], 255 serials: Optional[List[str]] = None, 256) -> None: 257 """Installs given APKS to specified devices. 258 259 If no serials specified, installs APKs on all attached devices. 260 261 Args: 262 apks: List of paths to APKs. 263 serials: List of device serials. 264 """ 265 _padded_print('Installing test APKs.') 266 if not serials: 267 serials = ( 268 subprocess.check_output( 269 'adb devices | tail -n +2 | cut -f 1', shell=True 270 ) 271 .decode('utf-8') 272 .strip() 273 .splitlines() 274 ) 275 for apk in apks: 276 for serial in serials: 277 print(f'Installing {apk} on device {serial}.') 278 subprocess.check_call( 279 ['adb', '-s', serial, 'install', '-r', '-g', apk] 280 ) 281 282 283def _generate_mobly_config(serials: Optional[List[str]] = None) -> str: 284 """Generates a Mobly config for the provided device serials. 285 286 If no serials specified, generate a wildcard config (test loads all attached 287 devices). 288 289 Args: 290 serials: List of device serials. 291 292 Returns: 293 Path to the generated config. 294 """ 295 config = { 296 'TestBeds': [{ 297 'Name': 'LocalTestBed', 298 'Controllers': { 299 'AndroidDevice': serials if serials else '*', 300 }, 301 }] 302 } 303 _, config_path = tempfile.mkstemp(prefix='mobly_config_') 304 _padded_print(f'Generating Mobly config at {config_path}.') 305 with open(config_path, 'w') as f: 306 json.dump(config, f) 307 _tempfiles.append(config_path) 308 return config_path 309 310 311def _run_mobly_tests( 312 python_executable: str, 313 mobly_bins: List[str], 314 config: str, 315 log_path: Optional[str] = None, 316) -> None: 317 """Runs the Mobly tests with the specified binary and config.""" 318 env = os.environ.copy() 319 for mobly_bin in mobly_bins: 320 bin_name = os.path.basename(mobly_bin) 321 if log_path: 322 env['MOBLY_LOGPATH'] = os.path.join(log_path, bin_name) 323 cmd = [python_executable, mobly_bin, '-c', config] 324 _padded_print(f'Running Mobly test {bin_name}.') 325 print(f'Command: {cmd}\n') 326 subprocess.run(cmd, env=env) 327 328 329def _clean_up() -> None: 330 """Cleans up temporary directories and files.""" 331 _padded_print('Cleaning up temporary directories/files.') 332 for td in _tempdirs: 333 shutil.rmtree(td, ignore_errors=True) 334 _tempdirs.clear() 335 for tf in _tempfiles: 336 os.remove(tf) 337 _tempfiles.clear() 338 339 340def main() -> None: 341 args = _parse_args() 342 343 # Build the test module if requested by user 344 if args.build: 345 _build_module(args.module) 346 347 serials = args.serials.split(',') if args.serials else None 348 349 # Resolve test resources 350 mobly_bins, requirements_files, test_apks = _resolve_test_resources(args) 351 352 # Install test APKs, if necessary 353 if args.install_apks: 354 _install_apks(test_apks, serials) 355 356 # Set up the Python virtualenv, if necessary 357 python_executable = ( 358 sys.executable if args.novenv else _setup_virtualenv(requirements_files) 359 ) 360 361 # Generate the Mobly config, if necessary 362 config = args.config or _generate_mobly_config(serials) 363 364 # Run the tests 365 _run_mobly_tests(python_executable, mobly_bins, config, args.log_path) 366 367 # Clean up temporary dirs/files 368 _clean_up() 369 370 371if __name__ == '__main__': 372 main() 373