blob: 39d5ba4d0021d2fcefeffcddfc26fc15d88527ba [file] [log] [blame]
# Lint as: python3
#
# Copyright 2020, 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.
"""Utilities for C-Suite integration tests."""
import argparse
import contextlib
import logging
import os
import pathlib
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
from typing import Sequence, Text
import zipfile
import csuite_test
# Export symbols to reduce the number of imports tests have to list.
TestCase = csuite_test.TestCase # pylint: disable=invalid-name
get_device_serial = csuite_test.get_device_serial
# Keep any created temporary directories for debugging test failures. The
# directories do not need explicit removal since they are created using the
# system's temporary-file facility.
_KEEP_TEMP_DIRS = False
class CSuiteHarness(contextlib.AbstractContextManager):
"""Interface class for interacting with the C-Suite harness.
WARNING: Explicitly clean up created instances or use as a context manager.
Not doing so will result in a ResourceWarning for the implicit cleanup which
confuses the TradeFed Python test output parser.
"""
def __init__(self):
self._suite_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite'))
logging.debug('Created harness directory: %s', self._suite_dir)
with zipfile.ZipFile(_get_standalone_zip_path(), 'r') as f:
f.extractall(self._suite_dir)
# Add owner-execute permission on scripts since zip does not preserve them.
self._launcher_binary = self._suite_dir.joinpath(
'android-csuite/tools/csuite-tradefed')
_add_owner_exec_permission(self._launcher_binary)
self._testcases_dir = self._suite_dir.joinpath('android-csuite/testcases')
def __exit__(self, unused_type, unused_value, unused_traceback):
self.cleanup()
def cleanup(self):
if _KEEP_TEMP_DIRS:
return
shutil.rmtree(self._suite_dir, ignore_errors=True)
def run_and_wait(self, flags: Sequence[Text]) -> subprocess.CompletedProcess:
"""Starts the Tradefed launcher and waits for it to complete."""
env = os.environ.copy()
# Unset environment variables that would cause the script to think it's in a
# build tree.
env.pop('ANDROID_BUILD_TOP', None)
env.pop('ANDROID_HOST_OUT', None)
# Unset environment variables that would cause TradeFed to find test configs
# other than the ones created by the test.
env.pop('ANDROID_HOST_OUT_TESTCASES', None)
env.pop('ANDROID_TARGET_OUT_TESTCASES', None)
# Unset environment variables that might cause the suite to pick up a
# connected device that wasn't explicitly specified.
env.pop('ANDROID_SERIAL', None)
# Unset environment variables that might cause the TradeFed to load classes
# that weren't included in the standalone suite zip.
env.pop('TF_GLOBAL_CONFIG', None)
# Ensure the process operates in standalone mode
env['LOCAL_MODE'] = "1"
# Set the environment variable that TradeFed requires to find test modules.
env['ANDROID_TARGET_OUT_TESTCASES'] = self._testcases_dir
jdk17_path = '/jdk/jdk17/linux-x86'
if os.path.isdir(jdk17_path):
env['JAVA_HOME'] = jdk17_path
java_path = jdk17_path + '/bin'
env['PATH'] = java_path + ':' + env['PATH']
return _run_command([self._launcher_binary] + flags, env=env)
class PackageRepository(contextlib.AbstractContextManager):
"""A file-system based APK repository for use in tests.
WARNING: Explicitly clean up created instances or use as a context manager.
Not doing so will result in a ResourceWarning for the implicit cleanup which
confuses the TradeFed Python test output parser.
"""
def __init__(self):
self._root_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite_apk_dir'))
logging.info('Created repository directory: %s', self._root_dir)
def __exit__(self, unused_type, unused_value, unused_traceback):
self.cleanup()
def cleanup(self):
if _KEEP_TEMP_DIRS:
return
shutil.rmtree(self._root_dir, ignore_errors=True)
def get_path(self) -> pathlib.Path:
"""Returns the path to the repository's root directory."""
return self._root_dir
def add_package_apks(self, package_name: Text,
apk_paths: Sequence[pathlib.Path]):
"""Adds the provided package APKs to the repository."""
apk_dir = self._root_dir.joinpath(package_name)
# Raises if the directory already exists.
apk_dir.mkdir()
for f in apk_paths:
shutil.copy(f, apk_dir)
class Adb:
"""Encapsulates adb functionality to simplify usage in tests.
Most methods in this class raise an exception if they fail to execute. This
behavior can be overridden by using the check parameter.
"""
def __init__(self,
adb_binary_path: pathlib.Path = None,
device_serial: Text = None):
self._args = [adb_binary_path or 'adb']
device_serial = device_serial or get_device_serial()
if device_serial:
self._args.extend(['-s', device_serial])
def shell(self,
args: Sequence[Text],
check: bool = None) -> subprocess.CompletedProcess:
"""Runs an adb shell command and waits for it to complete.
Note that the exit code of the returned object corresponds to that of
the adb command and not the command executed in the shell.
Args:
args: a sequence of program arguments to pass to the shell.
check: whether to raise if the process terminates with a non-zero exit
code.
Returns:
An object representing a process that has finished and that can be
queried.
"""
return self.run(['shell'] + args, check)
def run(self,
args: Sequence[Text],
check: bool = None) -> subprocess.CompletedProcess:
"""Runs an adb command and waits for it to complete."""
return _run_command(self._args + args, check=check)
def uninstall(self, package_name: Text, check: bool = None):
"""Uninstalls the specified package."""
self.run(['uninstall', package_name], check=check)
def list_packages(self) -> Sequence[Text]:
"""Lists packages installed on the device."""
p = self.shell(['pm', 'list', 'packages'])
return [l.split(':')[1] for l in p.stdout.splitlines()]
def _run_command(args, check=False, **kwargs) -> subprocess.CompletedProcess:
"""A wrapper for subprocess.run that overrides defaults and adds logging."""
env = kwargs.get('env', {})
# Log the command-line for debugging failed tests. Note that we convert
# tokens to strings for _shlex_join.
env_str = ['env', '-i'] + [f'{k}={v}' for k, v in env.items()]
args_str = [str(t) for t in args]
# Override some defaults. Note that 'check' deviates from this pattern to
# avoid getting warnings about using subprocess.run without an explicitly set
# `check` parameter.
kwargs.setdefault('capture_output', True)
kwargs.setdefault('universal_newlines', True)
logging.debug('Running command: %s', _shlex_join(env_str + args_str))
return subprocess.run(args, check=check, **kwargs)
def _add_owner_exec_permission(path: pathlib.Path):
path.chmod(path.stat().st_mode | stat.S_IEXEC)
def get_test_app_apks(app_module_name: Text) -> Sequence[pathlib.Path]:
"""Returns a test app's apk file paths."""
return [_get_test_file(app_module_name + '.apk')]
def _get_standalone_zip_path():
"""Returns the suite standalone zip file's path."""
return _get_test_file('csuite-standalone.zip')
def _get_test_file(name: Text) -> pathlib.Path:
test_dir = _get_test_dir()
test_file = test_dir.joinpath(name)
if not test_file.exists():
raise RuntimeError(f'Unable to find the file `{name}` in the test '
'execution dir `{test_dir}`; are you missing a data '
'dependency in the build module?')
return test_file
def _shlex_join(split_command: Sequence[Text]) -> Text:
"""Concatenate tokens and return a shell-escaped string."""
# This is an alternative to shlex.join that doesn't exist in Python versions
# < 3.8.
return ' '.join(shlex.quote(t) for t in split_command)
def _get_test_dir() -> pathlib.Path:
return pathlib.Path(__file__).parent
def main():
global _KEEP_TEMP_DIRS
parser = argparse.ArgumentParser(parents=[csuite_test.create_arg_parser()])
parser.add_argument(
'--log-level',
choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='WARNING',
help='sets the logging level threshold')
parser.add_argument(
'--keep-temp-dirs',
type=bool,
help='keeps any created temporary directories for debugging failures')
args, unittest_argv = parser.parse_known_args(sys.argv)
_KEEP_TEMP_DIRS = args.keep_temp_dirs
logging.basicConfig(level=getattr(logging, args.log_level))
csuite_test.run_tests(args, unittest_argv)