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 24 the test. 25 local_mobly_runner.py -m my_test_module -b -i 26 27 - Run a test module with specific Android devices. 28 local_mobly_runner.py -m my_test_module -s DEV00001,DEV00002 29 30 - Run a list of zipped Mobly packages (built from `python_test_host`) 31 local_mobly_runner.py -p test_pkg1,test_pkg2,test_pkg3 32 33Please run `local_mobly_runner.py -h` for a full list of options. 34""" 35 36import argparse 37import json 38import os 39from pathlib import Path 40import platform 41import shutil 42import subprocess 43import sys 44import tempfile 45from typing import List, Optional, Tuple 46import zipfile 47 48_LOCAL_SETUP_INSTRUCTIONS = ( 49 '\n\tcd <repo_root>; set -a; source build/envsetup.sh; set +a; lunch' 50 ' <target>' 51) 52_DEFAULT_MOBLY_LOGPATH = Path('/tmp/logs/mobly') 53_DEFAULT_TESTBED = 'LocalTestBed' 54 55_tempdirs = [] 56_tempfiles = [] 57 58 59def _padded_print(line: str) -> None: 60 print(f'\n-----{line}-----\n') 61 62 63def _parse_args() -> argparse.Namespace: 64 """Parses command line args.""" 65 parser = argparse.ArgumentParser( 66 formatter_class=argparse.RawDescriptionHelpFormatter, 67 description=__doc__) 68 group1 = parser.add_mutually_exclusive_group(required=True) 69 group1.add_argument( 70 '-m', '--module', help='The Android build module of the test to run.' 71 ) 72 group1.add_argument( 73 '-p', '--packages', 74 help='A comma-delimited list of test packages to run.' 75 ) 76 group1.add_argument( 77 '-t', 78 '--test_paths', 79 help=( 80 'A comma-delimited list of test paths to run directly. Implies ' 81 'the --novenv option.' 82 ), 83 ) 84 parser.add_argument( 85 '--tests', 86 nargs='+', 87 type=str, 88 metavar='TEST_CLASS[.TEST_CASE]', 89 help=( 90 'A list of test classes and optional tests to execute within the ' 91 'package or file. E.g. `--tests TestClassA TestClassB.test_b` ' 92 'would run all of test class TestClassA, but only test_b in ' 93 'TestClassB. This option cannot be used if multiple packages/test ' 94 'paths are specified.' 95 ), 96 ) 97 parser.add_argument( 98 '-b', 99 '--build', 100 action='store_true', 101 help='Build/rebuild the specified module. Requires the -m option.', 102 ) 103 parser.add_argument( 104 '-i', 105 '--install_apks', 106 action='store_true', 107 help=( 108 'Install all APKs associated with the module to all specified' 109 ' devices. Requires the -m or -p options.' 110 ), 111 ) 112 parser.add_argument( 113 '-s', 114 '--serials', 115 help=( 116 'Specify the devices to test with a comma-delimited list of device ' 117 'serials. If --config is also specified, this option will only be ' 118 'used to select the devices to install APKs.' 119 ), 120 ) 121 parser.add_argument( 122 '-c', '--config', help='Provide a custom Mobly config for the test.' 123 ) 124 parser.add_argument('-tb', '--test_bed', 125 default=_DEFAULT_TESTBED, 126 help='Select the testbed for the test. If left ' 127 f'unspecified, "{_DEFAULT_TESTBED}" will be ' 128 'selected by default.') 129 parser.add_argument('-lp', '--log_path', 130 help='Specify a path to store logs.') 131 parser.add_argument( 132 '--novenv', 133 action='store_true', 134 help=( 135 "Run directly in the host's system Python, without setting up a " 136 'virtualenv.' 137 ), 138 ) 139 args = parser.parse_args() 140 if args.build and not args.module: 141 parser.error('Option --build requires --module to be specified.') 142 if args.install_apks and not (args.module or args.packages): 143 parser.error('Option --install_apks requires --module or --packages.') 144 if args.tests is not None: 145 multiple_packages = (args.packages is not None 146 and len(args.packages.split(',')) > 1) 147 multiple_test_paths = (args.test_paths is not None 148 and len(args.test_paths.split(',')) > 1) 149 if multiple_packages or multiple_test_paths: 150 parser.error( 151 'Option --tests cannot be used if multiple --packages or ' 152 '--test_paths are specified.' 153 ) 154 155 args.novenv = args.novenv or (args.test_paths is not None) 156 return args 157 158 159def _build_module(module: str) -> None: 160 """Builds the specified module.""" 161 _padded_print(f'Building test module {module}.') 162 try: 163 subprocess.check_call(f'm -j {module}', shell=True, 164 executable='/bin/bash') 165 except subprocess.CalledProcessError as e: 166 if e.returncode == 127: 167 # `m` command not found 168 print( 169 '`m` command not found. Please set up your local environment ' 170 f'with {_LOCAL_SETUP_INSTRUCTIONS}.' 171 ) 172 else: 173 print(f'Failed to build module {module}.') 174 exit(1) 175 176 177def _get_module_artifacts(module: str) -> List[str]: 178 """Return the list of artifacts generated from a module.""" 179 try: 180 outmod_paths = ( 181 subprocess.check_output( 182 f'outmod {module}', shell=True, executable='/bin/bash' 183 ) 184 .decode('utf-8') 185 .splitlines() 186 ) 187 except subprocess.CalledProcessError as e: 188 if e.returncode == 127: 189 # `outmod` command not found 190 print( 191 '`outmod` command not found. Please set up your local ' 192 f'environment with {_LOCAL_SETUP_INSTRUCTIONS}.' 193 ) 194 if str(e.output).startswith('Could not find module'): 195 print( 196 f'Cannot find the build output of module {module}. Ensure that ' 197 'the module list is up-to-date with `refreshmod`.' 198 ) 199 exit(1) 200 201 for path in outmod_paths: 202 if not os.path.isfile(path): 203 print( 204 f'Declared file {path} does not exist. Please build your ' 205 'module with the -b option.' 206 ) 207 exit(1) 208 209 return outmod_paths 210 211 212def _resolve_test_resources( 213 args: argparse.Namespace, 214) -> Tuple[List[str], List[str], List[str]]: 215 """Resolve test resources from the given test module or package. 216 217 Args: 218 args: Parsed command-line args. 219 220 Returns: 221 Tuple of (mobly_bins, requirement_files, test_apks). 222 """ 223 _padded_print('Resolving test resources.') 224 mobly_bins = [] 225 requirements_files = [] 226 test_apks = [] 227 if args.test_paths: 228 mobly_bins.extend(args.test_paths.split(',')) 229 elif args.module: 230 print(f'Resolving test module {args.module}.') 231 for path in _get_module_artifacts(args.module): 232 if path.endswith(args.module): 233 mobly_bins.append(path) 234 if path.endswith('requirements.txt'): 235 requirements_files.append(path) 236 if path.endswith('.apk'): 237 test_apks.append(path) 238 elif args.packages: 239 unzip_root = tempfile.mkdtemp(prefix='mobly_unzip_') 240 _tempdirs.append(unzip_root) 241 for package in args.packages.split(','): 242 mobly_bins.append(os.path.abspath(package)) 243 unzip_dir = os.path.join(unzip_root, os.path.basename(package)) 244 print(f'Unzipping test package {package} to {unzip_dir}.') 245 os.makedirs(unzip_dir) 246 with zipfile.ZipFile(package) as zf: 247 zf.extractall(unzip_dir) 248 for path in os.listdir(unzip_dir): 249 path = os.path.join(unzip_dir, path) 250 if path.endswith('requirements.txt'): 251 requirements_files.append(path) 252 if path.endswith('.apk'): 253 test_apks.append(path) 254 else: 255 print('No tests specified. Aborting.') 256 exit(1) 257 return mobly_bins, requirements_files, test_apks 258 259 260def _setup_virtualenv(requirements_files: List[str]) -> str: 261 """Creates a virtualenv and install dependencies into it. 262 263 Args: 264 requirements_files: List of paths of requirements.txt files. 265 266 Returns: 267 Path to the virtualenv's Python interpreter. 268 """ 269 venv_dir = tempfile.mkdtemp(prefix='venv_') 270 _padded_print(f'Creating virtualenv at {venv_dir}.') 271 subprocess.check_call([sys.executable, '-m', 'venv', venv_dir]) 272 _tempdirs.append(venv_dir) 273 if platform.system() == 'Windows': 274 venv_executable = os.path.join(venv_dir, 'Scripts', 'python.exe') 275 else: 276 venv_executable = os.path.join(venv_dir, 'bin', 'python3') 277 278 # Install requirements 279 for requirements_file in requirements_files: 280 print(f'Installing dependencies from {requirements_file}.') 281 subprocess.check_call( 282 [venv_executable, '-m', 'pip', 'install', '-r', requirements_file] 283 ) 284 return venv_executable 285 286 287def _parse_adb_devices(lines: List[str]) -> List[str]: 288 """Parses result from 'adb devices' into a list of serials. 289 290 Derived from mobly.controllers.android_device. 291 """ 292 results = [] 293 for line in lines: 294 tokens = line.strip().split('\t') 295 if len(tokens) == 2 and tokens[1] == 'device': 296 results.append(tokens[0]) 297 return results 298 299 300def _install_apks( 301 apks: List[str], 302 serials: Optional[List[str]] = None, 303) -> None: 304 """Installs given APKS to specified devices. 305 306 If no serials specified, installs APKs on all attached devices. 307 308 Args: 309 apks: List of paths to APKs. 310 serials: List of device serials. 311 """ 312 _padded_print('Installing test APKs.') 313 if not serials: 314 adb_devices_out = ( 315 subprocess.check_output( 316 ['adb', 'devices'] 317 ).decode('utf-8').strip().splitlines() 318 ) 319 serials = _parse_adb_devices(adb_devices_out) 320 for apk in apks: 321 for serial in serials: 322 print(f'Installing {apk} on device {serial}.') 323 subprocess.check_call( 324 ['adb', '-s', serial, 'install', '-r', '-g', apk] 325 ) 326 327 328def _generate_mobly_config(serials: Optional[List[str]] = None) -> str: 329 """Generates a Mobly config for the provided device serials. 330 331 If no serials specified, generate a wildcard config (test loads all attached 332 devices). 333 334 Args: 335 serials: List of device serials. 336 337 Returns: 338 Path to the generated config. 339 """ 340 config = { 341 'TestBeds': [{ 342 'Name': _DEFAULT_TESTBED, 343 'Controllers': { 344 'AndroidDevice': serials if serials else '*', 345 }, 346 }] 347 } 348 _, config_path = tempfile.mkstemp(prefix='mobly_config_', suffix='.yaml') 349 _padded_print(f'Generating Mobly config at {config_path}.') 350 with open(config_path, 'w') as f: 351 json.dump(config, f) 352 _tempfiles.append(config_path) 353 return config_path 354 355 356def _run_mobly_tests( 357 python_executable: Optional[str], 358 mobly_bins: List[str], 359 tests: Optional[List[str]], 360 config: str, 361 test_bed: str, 362 log_path: Optional[str] 363) -> None: 364 """Runs the Mobly tests with the specified binary and config.""" 365 env = os.environ.copy() 366 base_log_path = _DEFAULT_MOBLY_LOGPATH 367 for mobly_bin in mobly_bins: 368 bin_name = os.path.basename(mobly_bin) 369 if log_path: 370 base_log_path = Path(log_path, bin_name) 371 env['MOBLY_LOGPATH'] = str(base_log_path) 372 cmd = [python_executable] if python_executable else [] 373 cmd += [mobly_bin, '-c', config, '-tb', test_bed] 374 if tests is not None: 375 cmd.append('--tests') 376 cmd += tests 377 _padded_print(f'Running Mobly test {bin_name}.') 378 print(f'Command: {cmd}\n') 379 subprocess.run(cmd, env=env) 380 # Save a copy of the config in the log directory. 381 latest_logs = base_log_path.joinpath(test_bed, 'latest') 382 if latest_logs.is_dir(): 383 shutil.copy2(config, latest_logs) 384 385 386def _clean_up() -> None: 387 """Cleans up temporary directories and files.""" 388 _padded_print('Cleaning up temporary directories/files.') 389 for td in _tempdirs: 390 shutil.rmtree(td, ignore_errors=True) 391 _tempdirs.clear() 392 for tf in _tempfiles: 393 os.remove(tf) 394 _tempfiles.clear() 395 396 397def main() -> None: 398 args = _parse_args() 399 400 # args.module is not supported in Windows 401 if args.module and platform.system() == 'Windows': 402 print('The --module option is not supported in Windows. Aborting.') 403 exit(1) 404 405 # Build the test module if requested by user 406 if args.build: 407 _build_module(args.module) 408 409 serials = args.serials.split(',') if args.serials else None 410 411 # Resolve test resources 412 mobly_bins, requirements_files, test_apks = _resolve_test_resources(args) 413 414 # Install test APKs, if necessary 415 if args.install_apks: 416 _install_apks(test_apks, serials) 417 418 # Set up the Python virtualenv, if necessary 419 python_executable = None 420 if args.novenv: 421 if args.test_paths is not None: 422 python_executable = sys.executable 423 else: 424 python_executable = _setup_virtualenv(requirements_files) 425 426 # Generate the Mobly config, if necessary 427 config = args.config or _generate_mobly_config(serials) 428 429 # Run the tests 430 _run_mobly_tests(python_executable, mobly_bins, args.tests, config, 431 args.test_bed, args.log_path) 432 433 # Clean up temporary dirs/files 434 _clean_up() 435 436 437if __name__ == '__main__': 438 main() 439