unplugged-system/tools/test/mobly_extensions/scripts/local_mobly_runner.py

373 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (C) 2023 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Script for running Android Gerrit-based Mobly tests locally.
Example:
- Run a test module.
local_mobly_runner.py -m my_test_module
- Run a test module. Build the module and install test APKs before running the test.
local_mobly_runner.py -m my_test_module -b -i
- Run a test module with specific Android devices.
local_mobly_runner.py -m my_test_module -s DEV00001,DEV00002
- Run a list of zipped Mobly packages (built from `python_test_host`)
local_mobly_runner.py -p test_pkg1,test_pkg2,test_pkg3
Please run `local_mobly_runner.py -h` for a full list of options.
"""
import argparse
import json
import os
import shutil
import subprocess
import sys
import tempfile
from typing import List, Optional, Tuple
import zipfile
_LOCAL_SETUP_INSTRUCTIONS = (
'\n\tcd <repo_root>; set -a; source build/envsetup.sh; set +a; lunch'
' <target>'
)
_tempdirs = []
_tempfiles = []
def _padded_print(line: str) -> None:
print(f'\n-----{line}-----\n')
def _parse_args() -> argparse.Namespace:
"""Parses command line args."""
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description=__doc__)
group1 = parser.add_mutually_exclusive_group(required=True)
group1.add_argument(
'-m', '--module', help='The Android build module of the test to run.'
)
group1.add_argument(
'-p', '--packages',
help='A comma-delimited list of test packages to run.'
)
group1.add_argument(
'-t',
'--test_paths',
help=(
'A comma-delimited list of test paths to run directly. Implies '
'the --novenv option.'
),
)
parser.add_argument(
'-b',
'--build',
action='store_true',
help='Build/rebuild the specified module. Requires the -m option.',
)
parser.add_argument(
'-i',
'--install_apks',
action='store_true',
help=(
'Install all APKs associated with the module to all specified'
' devices. Requires the -m or -p options.'
),
)
group2 = parser.add_mutually_exclusive_group()
group2.add_argument(
'-s',
'--serials',
help=(
'Specify the devices to test with a comma-delimited list of device '
'serials.'
),
)
group2.add_argument(
'-c', '--config', help='Provide a custom Mobly config for the test.'
)
parser.add_argument('-lp', '--log_path',
help='Specify a path to store logs.')
parser.add_argument(
'--novenv',
action='store_true',
help=(
"Run directly in the host's system Python, without setting up a "
'virtualenv.'
),
)
args = parser.parse_args()
if args.build and not args.module:
parser.error('Option --build requires --module to be specified.')
if args.install_apks and not (args.module or args.packages):
parser.error('Option --install_apks requires --module or --packages.')
args.novenv = args.novenv or (args.test_paths is not None)
return args
def _build_module(module: str) -> None:
"""Builds the specified module."""
_padded_print(f'Building test module {module}.')
try:
subprocess.check_call(f'm -j {module}', shell=True,
executable='/bin/bash')
except subprocess.CalledProcessError as e:
if e.returncode == 127:
# `m` command not found
print(
'`m` command not found. Please set up your local environment '
f'with {_LOCAL_SETUP_INSTRUCTIONS}.'
)
else:
print(f'Failed to build module {module}.')
exit(1)
def _get_module_artifacts(module: str) -> List[str]:
"""Return the list of artifacts generated from a module."""
try:
outmod_paths = (
subprocess.check_output(
f'outmod {module}', shell=True, executable='/bin/bash'
)
.decode('utf-8')
.splitlines()
)
except subprocess.CalledProcessError as e:
if e.returncode == 127:
# `outmod` command not found
print(
'`outmod` command not found. Please set up your local '
f'environment with {_LOCAL_SETUP_INSTRUCTIONS}.'
)
if str(e.output).startswith('Could not find module'):
print(
f'Cannot find the build output of module {module}. Ensure that '
'the module list is up-to-date with `refreshmod`.'
)
exit(1)
for path in outmod_paths:
if not os.path.isfile(path):
print(
f'Declared file {path} does not exist. Please build your '
'module with the -b option.'
)
exit(1)
return outmod_paths
def _resolve_test_resources(
args: argparse.Namespace,
) -> Tuple[List[str], List[str], List[str]]:
"""Resolve test resources from the given test module or package.
Args:
args: Parsed command-line args.
Returns:
Tuple of (mobly_bins, requirement_files, test_apks).
"""
mobly_bins = []
requirements_files = []
test_apks = []
if args.test_paths:
mobly_bins.extend(args.test_paths.split(','))
elif args.module:
for path in _get_module_artifacts(args.module):
if path.endswith(args.module):
mobly_bins.append(path)
if path.endswith('requirements.txt'):
requirements_files.append(path)
if path.endswith('.apk'):
test_apks.append(path)
elif args.packages:
unzip_root = tempfile.mkdtemp(prefix='mobly_unzip_')
_tempdirs.append(unzip_root)
for package in args.packages.split(','):
mobly_bins.append(package)
unzip_dir = os.path.join(unzip_root, os.path.basename(package))
print(f'Unzipping test package {package} to {unzip_dir}.')
os.makedirs(unzip_dir)
with zipfile.ZipFile(package) as zf:
zf.extractall(unzip_dir)
for path in os.listdir(unzip_dir):
path = os.path.join(unzip_dir, path)
if path.endswith('requirements.txt'):
requirements_files.append(path)
if path.endswith('.apk'):
test_apks.append(path)
else:
print('No tests specified. Aborting.')
exit(1)
return mobly_bins, requirements_files, test_apks
def _setup_virtualenv(requirements_files: List[str]) -> str:
"""Creates a virtualenv and install dependencies into it.
Args:
requirements_files: List of paths of requirements.txt files.
Returns:
Path to the virtualenv's Python interpreter.
"""
if not requirements_files:
print('No requirements.txt file found. Aborting.')
exit(1)
venv_dir = tempfile.mkdtemp(prefix='venv_')
_padded_print(f'Creating virtualenv at {venv_dir}.')
subprocess.check_call([sys.executable, '-m', 'venv', venv_dir])
_tempdirs.append(venv_dir)
venv_executable = os.path.join(venv_dir, 'bin/python3')
# Install requirements
for requirements_file in requirements_files:
print(f'Installing dependencies from {requirements_file}.')
subprocess.check_call(
[venv_executable, '-m', 'pip', 'install', '-r', requirements_file]
)
return venv_executable
def _install_apks(
apks: List[str],
serials: Optional[List[str]] = None,
) -> None:
"""Installs given APKS to specified devices.
If no serials specified, installs APKs on all attached devices.
Args:
apks: List of paths to APKs.
serials: List of device serials.
"""
_padded_print('Installing test APKs.')
if not serials:
serials = (
subprocess.check_output(
'adb devices | tail -n +2 | cut -f 1', shell=True
)
.decode('utf-8')
.strip()
.splitlines()
)
for apk in apks:
for serial in serials:
print(f'Installing {apk} on device {serial}.')
subprocess.check_call(
['adb', '-s', serial, 'install', '-r', '-g', apk]
)
def _generate_mobly_config(serials: Optional[List[str]] = None) -> str:
"""Generates a Mobly config for the provided device serials.
If no serials specified, generate a wildcard config (test loads all attached
devices).
Args:
serials: List of device serials.
Returns:
Path to the generated config.
"""
config = {
'TestBeds': [{
'Name': 'LocalTestBed',
'Controllers': {
'AndroidDevice': serials if serials else '*',
},
}]
}
_, config_path = tempfile.mkstemp(prefix='mobly_config_')
_padded_print(f'Generating Mobly config at {config_path}.')
with open(config_path, 'w') as f:
json.dump(config, f)
_tempfiles.append(config_path)
return config_path
def _run_mobly_tests(
python_executable: str,
mobly_bins: List[str],
config: str,
log_path: Optional[str] = None,
) -> None:
"""Runs the Mobly tests with the specified binary and config."""
env = os.environ.copy()
for mobly_bin in mobly_bins:
bin_name = os.path.basename(mobly_bin)
if log_path:
env['MOBLY_LOGPATH'] = os.path.join(log_path, bin_name)
cmd = [python_executable, mobly_bin, '-c', config]
_padded_print(f'Running Mobly test {bin_name}.')
print(f'Command: {cmd}\n')
subprocess.run(cmd, env=env)
def _clean_up() -> None:
"""Cleans up temporary directories and files."""
_padded_print('Cleaning up temporary directories/files.')
for td in _tempdirs:
shutil.rmtree(td, ignore_errors=True)
_tempdirs.clear()
for tf in _tempfiles:
os.remove(tf)
_tempfiles.clear()
def main() -> None:
args = _parse_args()
# Build the test module if requested by user
if args.build:
_build_module(args.module)
serials = args.serials.split(',') if args.serials else None
# Resolve test resources
mobly_bins, requirements_files, test_apks = _resolve_test_resources(args)
# Install test APKs, if necessary
if args.install_apks:
_install_apks(test_apks, serials)
# Set up the Python virtualenv, if necessary
python_executable = (
sys.executable if args.novenv else _setup_virtualenv(requirements_files)
)
# Generate the Mobly config, if necessary
config = args.config or _generate_mobly_config(serials)
# Run the tests
_run_mobly_tests(python_executable, mobly_bins, config, args.log_path)
# Clean up temporary dirs/files
_clean_up()
if __name__ == '__main__':
main()