普通文本  |  840行  |  27.71 KB

#!/usr/bin/env python

# Copyright (C) 2016 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.

'''Main test suite execution script.'''
import argparse
import inspect
import logging
import os
import signal
import subprocess
import sys
import time
import collections
import xml.etree.ElementTree as ET

from config import Config
from tests.harness import util_constants
from tests.harness.exception import TestSuiteException, FailFastException
from tests.harness import UtilAndroid
from tests.harness import UtilBundle
from tests.harness import util_log
from tests.harness.util_functions import load_py_module
from tests.harness.decorators import deprecated

# For some reason pylint is not able to understand the class returned by
# from util_log.get_logger() and generates a lot of false warnings
#pylint: disable=maybe-no-member

EMU_PROC = None

def _parse_args():
    '''Parse the command line arguments.

    Returns:
        A namespace object that contains the options specified to run_tests on
        the command line.
    '''

    parser = argparse.ArgumentParser(description='Run the test suite.')

    parser.add_argument('--config', '-c',
                        metavar='path',
                        help='Path to a custom config file.')
    parser.add_argument('--device', '-d',
                        help='Specify the device id of the device to test on.')
    parser.add_argument('--test', '-t',
                        metavar='path',
                        help='Specify a specific test to run.')
    group = parser.add_mutually_exclusive_group()
    group.add_argument('--wimpy', '-w',
                        action='store_true',
                        default=None,
                        help='Test only a core subset of features.')
    group.add_argument('--app-types',
                        default=['java', 'cpp', 'jni'],
                        nargs='*',
                        help='Specify a list of Android app types against which'
                             ' to run the tests',
                        dest='bundle_types')
    parser.add_argument('--install-only',
                        action='store_true',
                        default=False,
                        help='It only runs the pre-run stage of the test suite.'
                             ' It installs the required APKs but does not '
                             'execute the tests.',
                        dest='install_only')
    parser.add_argument('--no-install', '-n',
                        action='store_true',
                        default=False,
                        help='Stop the test suite installing apks to device.',
                        dest='noinstall')
    parser.add_argument('--no-uninstall',
                        action='store_true',
                        default=False,
                        help='Stop the test suite uninstalling apks after '
                             'completion.',
                        dest='nouninstall')
    parser.add_argument('--print-to-stdout',
                        action='store_true',
                        default=False,
                        help='Print all logging information to standard out.',
                        dest='print_to_stdout')
    parser.add_argument('--verbose', '-v',
                        action='store_true',
                        default=None,
                        help='Store extra info in the log.')
    parser.add_argument('--fail-fast',
                        action='store_true',
                        default=False,
                        help='Exit the test suite immediately on the first failure.')
    parser.add_argument('--run-emu',
                        action='store_true',
                        default=None,
                        help='Spawn an emulator and run the test suite on that.'
                             ' Specify the emulator command line in the config'
                             ' file or with -emu-cmd.',
                        dest='run_emu')

    # Get the properties of the Config class and add a command line argument
    # for each.
    this_module = sys.modules[__name__]
    for member_name, member_obj in inspect.getmembers(Config):
        if (inspect.isdatadescriptor(member_obj) and
            member_name not in ['__weakref__', 'device', 'verbose']):

            # List type properties can take one or more arguments
            num_args = None
            if (isinstance(member_obj, property)
                and isinstance(member_obj.fget(Config), list)):
                num_args = '+'

            opt_name = member_name.replace('_', '-')

            setattr(this_module, opt_name, '')

            parser.add_argument('--' + opt_name,
                                nargs=num_args,
                                help=member_obj.__doc__,
                                dest=member_name)

    return parser.parse_args()


def _choice(first_choice, second_choice):
    '''Return first_choice if it is not None otherwise return second_choice.

    Args:
        first_choice: The first choice value.
        second_choice: The alternative value.

    Returns:
        The first argument if it is not None, and the second otherwise.
    '''
    return first_choice if first_choice else second_choice


class State(object):
    '''This class manages all objects required by the test suite.'''

    # pylint: disable=too-many-instance-attributes
    # Since this is a state class many attributes are expected.

    def __init__(self):
        '''State constructor.

        Raises:
            TestSuiteException: When unable to load config file.

            AssertionError: When assertions fail.
        '''

        # Parse the command line options
        args = _parse_args()

        # create a config instance
        if args.config:
            # use the user supplied
            config = State.load_user_configuration(args.config)
        else:
            # use the default configuration
            config = Config()

        # save the test blacklist
        self.blacklist = _choice(args.blacklist, config.blacklist)

        # Allow any of the command line arguments to override the
        # values in the config file.
        self.adb_path = _choice(args.adb_path, config.adb_path)

        self.host_port = int(_choice(args.host_port, config.host_port))

        self.device = _choice(args.device, config.device)

        self.user_specified_device = self.device

        self.device_port = int(_choice(args.device_port, config.device_port))

        self.lldb_server_path_device = _choice(args.lldb_server_path_device,
                                               config.lldb_server_path_device)

        self.lldb_server_path_host = _choice(args.lldb_server_path_host,
                                             config.lldb_server_path_host)

        self.aosp_product_path = _choice(args.aosp_product_path,
                                         config.aosp_product_path)

        self.log_file_path = _choice(args.log_file_path, config.log_file_path)

        self.results_file_path = _choice(args.results_file_path,
                                         config.results_file_path)

        self.lldb_path = _choice(args.lldb_path, config.lldb_path)
        self.print_to_stdout = args.print_to_stdout
        self.verbose = _choice(args.verbose, config.verbose)
        self.timeout = int(_choice(args.timeout, config.timeout))
        self.emu_cmd = _choice(args.emu_cmd, config.emu_cmd)
        self.run_emu = args.run_emu
        self.wimpy = args.wimpy
        self.bundle_types = args.bundle_types if not self.wimpy else ['java']
        self.fail_fast = args.fail_fast

        # validate the param "verbose"
        if not isinstance(self.verbose, bool):
            raise TestSuiteException('The parameter "verbose" should be a '
                                     'boolean: {0}'.format(self.verbose))

        # create result array
        self.results = dict()
        self.single_test = args.test

        # initialise the logging facility
        log_level = logging.INFO if not self.verbose else logging.DEBUG
        util_log.initialise("driver",
                            print_to_stdout=self.print_to_stdout,
                            level=log_level,
                            file_mode='w', # open for write
                            file_path=self.log_file_path
                            )
        log = util_log.get_logger()

        if self.run_emu and not self.emu_cmd:
            log.TestSuiteException('Need to specify --emu-cmd (or specify a'
                ' value in the config file) if using --run-emu.')

        # create a results file
        self.results_file = open(self.results_file_path, 'w')

        # create an android helper object
        self.android = UtilAndroid(self.adb_path,
                                   self.lldb_server_path_device,
                                   self.device)
        assert self.android

        # create a test bundle
        self.bundle = UtilBundle(self.android,
                                 self.aosp_product_path)
        assert self.bundle

        # save the no pushing option
        assert isinstance(args.noinstall, bool)
        self.noinstall = args.noinstall

        assert isinstance(args.nouninstall, bool)
        self.nouninstall = args.nouninstall

        # install only option
        assert type(args.install_only) is bool
        self.install_only = args.install_only
        if self.install_only:
            log.log_and_print('Option --install-only set. The test APKs will '
                              'be installed on the device but the tests will '
                              'not be executed.')
            if self.noinstall:
                raise TestSuiteException('Conflicting options given: '
                                         '--install-only and --no-install')

        # TCP port modifier which is used to increment the port number used for
        # each test case to avoid collisions.
        self.port_mod = 0

        # total number of test files that have been executed
        self.test_count = 0

    def get_android(self):
        '''Return the android ADB helper instance.

        Returns:
            The android ADB helper, instance of UtilAndroid.
        '''
        assert self.android
        return self.android

    def get_bundle(self):
        '''Return the test executable bundle.

        Returns:
            The test exectable collection, instance of UtilBundle.
        '''
        return self.bundle

    def add_result(self, name, app_type, result):
        '''Add a test result to the collection.

        Args:
            name: String name of the test that has executed.
            app_type: type of app i.e. java, jni, or cpp
            result: String result of the test, "pass", "fail", "error".
        '''
        key = (name, app_type)
        assert key not in self.results
        self.results[key] = result

    def get_single_test(self):
        '''Get the name of the single test to run.

        Returns:
            A string that is the name of the python file containing the test to
            be run. If all tests are to be run this returns None.
        '''
        return self.single_test

    @staticmethod
    def load_user_configuration(path):
        '''Load the test suite config from the give path.

        Instantiate the Config class found in the module at the given path.
        If no suitable class is available, it raises a TestSuiteException.

        Args:
            path: String location of the module.

        Returns:
            an instance of the Config class, defined in the module.

        Raises:
            TestSuiteException: when unable to import the module or when a
                                subclass of Config is not found inside it.
        '''

        # load the module
        config_module = load_py_module(path)
        if not config_module:
            raise TestSuiteException('Unable to import the module from "%s"'
                                     % (path))

        # look for a subclass of Config
        for name, value in inspect.getmembers(config_module):
            if (inspect.isclass(value)
                and name != 'Config'
                and issubclass(value, Config)):
                # that's our candidate
                return value()

        # otherwise there are no valid candidates
        raise TestSuiteException('The provided user configuration is not '
                                 'valid. The module must define a subclass '
                                 'of Config')


def _kill_emulator():
    ''' Kill the emulator process. '''
    global EMU_PROC
    if EMU_PROC:
        try:
            EMU_PROC.terminate()
        except OSError:
            # can't kill a dead proc
            log = util_log.get_logger()
            log.debug('Trying to kill an emulator but it is already dead.')


def _check_emulator_terminated():
    ''' Throw an exception if the emulator process has ended.

    Raises:
        TestSuiteException: If the emulator process has ended.
    '''
    global EMU_PROC
    assert EMU_PROC
    if EMU_PROC.poll():
        stdout, stderr = EMU_PROC.communicate()
        raise TestSuiteException('The emulator terminated with output:'
            '\nstderr: {0}\nstdout: {1}.'.format(stderr, stdout))


@deprecated()
def _launch_emulator(state):
    '''Launch the emulator and wait for it to boot.

    Args:
        emu_cmd: The command line to run the emulator.

    Raises:
        TestSuiteException: If an emulator already exists or the emulator
                            process terminated before we could connect to it, or
                            we failed to copy lldb-server to the emulator.
    '''
    global EMU_PROC
    android = state.android
    if state.user_specified_device:
        if android.device_with_substring_exists(state.user_specified_device):
            raise TestSuiteException(
                'A device with name {0} already exists.',
                state.user_specified_device)
    else:
        if android.device_with_substring_exists('emulator'):
            raise TestSuiteException('An emulator already exists.')

    assert state.emu_cmd
    EMU_PROC = subprocess.Popen(state.emu_cmd.split(),
                                stdout=None,
                                stderr=subprocess.STDOUT)

    log = util_log.get_logger()
    log.info('Launching emulator with command line {0}'.format(state.emu_cmd))

    tries_number = 180
    tries = tries_number
    found_device = False
    while not found_device:
        try:
            android.validate_device(False, 'emulator')
            found_device = True
        except TestSuiteException as ex:
            tries -= 1
            if tries == 0:
                # Avoid infinitely looping if the emulator won't boot
                log.warning(
                    'Giving up trying to validate device after {0} tries.'
                    .format(tries_number))
                raise ex
            _check_emulator_terminated()
            # wait a bit and try again, maybe it has now booted
            time.sleep(10)

    tries = 500
    while not android.is_booted():
        tries -= 1
        if tries == 0:
            # Avoid infinitely looping if the emulator won't boot
            raise TestSuiteException('The emulator has failed to boot.')
        _check_emulator_terminated()
        time.sleep(5)

    # Need to be root before we can push lldb-server
    android.adb_root()
    android.wait_for_device()

    # Push the lldb-server executable to the device.
    output = android.adb('push {0} {1}'.format(state.lldb_server_path_host,
                                               state.lldb_server_path_device))

    if 'failed to copy' in output or 'No such file or directory' in output:
        raise TestSuiteException(
            'unable to push lldb-server to the emulator: {0}.'
            .format(output))

    output = android.shell('chmod a+x {0}'
                           .format(state.lldb_server_path_device))

    if 'No such file or directory' in output:
        raise TestSuiteException('Failed to copy lldb-server to the emulator.')


def _restart_emulator(state):
    '''Kill the emulator and start a new instance.

    Args:
        state: Test suite state collection, instance of State.
    '''
    _kill_emulator()
    _launch_emulator(state)


def _run_test(state, name, bundle_type):
    '''Execute a single test case.

    Args:
        state: Test suite state collection, instance of State.
        name: String file name of the test to execute.
        bundle_type: string for the installed app type (cpp|jni|java)

    Raises:
        AssertionError: When assertion fails.
    '''
    assert isinstance(name, str)

    try:
        state.android.check_adb_alive()
    except TestSuiteException as expt:
        global EMU_PROC
        if EMU_PROC:
            _restart_emulator(state)
        else:
            raise expt

    log = util_log.get_logger()
    sys.stdout.write('Running {0}\r'.format(name))
    sys.stdout.flush()
    log.info('Running {0}'.format(name))

    run_tests_dir = os.path.dirname(os.path.realpath(__file__))
    run_test_path = os.path.join(run_tests_dir, 'tests', 'run_test.py')

    # Forward port for lldb-server on the device to our host
    hport = int(state.host_port) + state.port_mod
    dport = int(state.device_port) + state.port_mod
    state.android.forward_port(hport, dport)
    state.port_mod += 1

    log.debug('Giving up control to {0}...'.format(name))

    params = map(str, [
        sys.executable,
        run_test_path,
        name,
        state.log_file_path,
        state.adb_path,
        state.lldb_server_path_device,
        state.aosp_product_path,
        dport,
        state.android.get_device_id(),
        state.print_to_stdout,
        state.verbose,
        state.wimpy,
        state.timeout,
        bundle_type
    ])

    return_code = subprocess.call(params)
    state.test_count += 1
    state.android.remove_port_forwarding()
    log.seek_to_end()

    # report in sys.stdout the result
    success = return_code == util_constants.RC_TEST_OK
    status_handlers = collections.defaultdict(lambda: ('error', log.error), (
            (util_constants.RC_TEST_OK, ('pass', log.info)),
            (util_constants.RC_TEST_TIMEOUT, ('timeout', log.error)),
            (util_constants.RC_TEST_IGNORED, ('ignored', log.info)),
            (util_constants.RC_TEST_FAIL, ('fail', log.critical))
        )
    )
    status_name, status_logger = status_handlers[return_code]
    log.info('Running %s: %s', name, status_name.upper())
    status_logger("Test %r: %s", name, status_name)

    # Special case for ignored tests - just return now
    if return_code == util_constants.RC_TEST_IGNORED:
        return

    state.add_result(name, bundle_type, status_name)

    if state.fail_fast and not success:
        raise FailFastException(name)

    # print a running total pass rate
    passes = sum(1 for key, value in state.results.items() if value == 'pass')
    log.info('Current pass rate: %s of %s executed.', passes, len(state.results))


def _check_lldbserver_exists(state):
    '''Check lldb-server exists on the target device and it is executable.

    Raises:
        TestSuiteError: If lldb-server does not exist on the target.
    '''
    assert state

    message = 'Unable to verify valid lldb-server on target'

    android = state.get_android()
    assert android

    cmd = state.lldb_server_path_device
    out = android.shell(cmd, False)
    if not isinstance(out, str):
        raise TestSuiteException(message)
    if out.find('Usage:') < 0:
        raise TestSuiteException(message)


def _suite_pre_run(state):
    '''This function is executed before the test cases are run (setup).

    Args:
        state: Test suite state collection, instance of State.

    Return:
        True if the pre_run step completes without error.
        Checks made:
            - Validating that adb exists and runs.
            - Validating that a device is attached.
            - We have root access to the device.
            - All test binaries were pushed to the device.
            - The port for lldb-server was forwarded correctly.

    Raises:
        AssertionError: When assertions fail.
    '''
    assert state
    log = util_log.get_logger()

    try:
        android = state.get_android()
        bundle = state.get_bundle()
        assert android
        assert bundle

        # validate ADB helper class
        android.validate_adb()
        log.log_and_print('Located ADB')

        if state.run_emu:
            log.log_and_print('Launching emulator...')
            _launch_emulator(state)
            log.log_and_print('Started emulator ' + android.device)
        else:
            android.validate_device()
            log.log_and_print('Located device ' + android.device)

        if state.noinstall and not state.single_test:
            bundle.check_apps_installed(state.wimpy)

        # elevate to root user
        android.adb_root()
        android.wait_for_device()
        # check that lldb-server exists on device
        android.kill_servers()
        _check_lldbserver_exists(state)

        if not state.noinstall:
            # push all tests to the device
            log.log_and_print('Pushing all tests...')
            bundle.push_all()
            log.log_and_print('Pushed all tests')
        log.log_and_print('Pre run complete')

    except TestSuiteException as expt:
        log.exception('Test suite pre run failure')

        # Even if we are logging the error, it may be helpful and more
        # immediate to find out the error into the terminal
        log.log_and_print('ERROR: Unable to set up the test suite: %s\n'
                          % expt.message, logging.ERROR)

        return False
    return True


def _suite_post_run(state):
    '''This function is executed after the test cases have run (teardown).

    Args:
        state: Test suite state collection, instance of State.
    Returns:
        Number of failures
    '''
    log = util_log.get_logger()

    if not state.noinstall and not state.nouninstall:
        if state.wimpy:
            state.bundle.uninstall_all_apk()
        else:
            state.bundle.uninstall_all()
        log.log_and_print('Uninstalled/Deleted all tests')

    total = 0
    passes = 0
    failures = 0

    results = ET.Element('testsuite')
    results.attrib['name'] = 'LLDB RS Test Suite'

    for key, value in state.results.items():
        total += 1
        if value == 'pass':
            passes += 1
        else:
            failures += 1

        # test case name, followed by pass, failure or error elements
        testcase = ET.Element('testcase')
        testcase.attrib['name'] = "%s:%s" % key
        result_element = ET.Element(value)
        result_element.text = "%s:%s" % key
        testcase.append(result_element)
        results.append(testcase)

    assert passes + failures == total, 'Invalid test results status'
    if failures:
        log.log_and_print(
            'The following failures occurred:\n%s\n' %
            '\n'.join('failed: %s:%s' % test_spec
                for test_spec, result in state.results.items() if result != 'pass'
        ))

    log.log_and_print('{0} of {1} passed'.format(passes, total))
    if total:
        log.log_and_print('{0}% rate'.format((passes*100)/total))

    results.attrib['tests'] = str(total)
    state.results_file.write(ET.tostring(results, encoding='iso-8859-1'))

    return failures


def _discover_tests(state):
    '''Discover all tests in the tests directory.

    Returns:
        List of strings, test file names from the 'tests' directory.
    '''
    tests = []

    single_test = state.get_single_test()
    if single_test is None:
        file_dir = os.path.dirname(os.path.realpath(__file__))
        tests_dir = os.path.join(file_dir, 'tests')

        for sub_dir in os.listdir(tests_dir):
            current_test_dir = os.path.join(tests_dir, sub_dir)
            if os.path.isdir(current_test_dir):
                dir_name = os.path.basename(current_test_dir)

                if dir_name == 'harness':
                    continue

                for item in os.listdir(current_test_dir):
                    if (item.startswith('test')
                        and item.endswith('.py')
                        and not item in state.blacklist):
                        tests.append(item)
    else:
        if single_test.endswith('.py'):
            tests.append(single_test)
        else:
            tests.append(single_test + '.py')

    return tests


def _deduce_python_path(state):
    '''Try to deduce the PYTHONPATH environment variable via the LLDB binary.

    Args:
        state: Test suite state collection, instance of State.

    Returns:
        True if PYTHONPATH has been updated, False otherwise.

    Raises:
        TestSuiteException: If lldb path provided in the config or command line
                            is incorrect.
        AssertionError: If an assertion fails.
    '''

    lldb_path = state.lldb_path
    if not lldb_path:
        # lldb may not be provided in preference of a manual $PYTHONPATH
        return False

    params = [lldb_path, '-P']

    try:
        proc = subprocess.Popen(params, stdout=subprocess.PIPE)
    except OSError as err:
        error_string = 'Could not run lldb at %s: %s' % (lldb_path, str(err))
        raise TestSuiteException(error_string)

    stdout = proc.communicate()[0]
    if stdout:
        os.environ['PYTHONPATH'] = stdout.strip()
        return True

    return False


def main():
    '''The lldb-renderscript test suite entry point.'''
    log = None

    try:
        # parse the command line
        state = State()
        assert state

        # logging is initialised in State()
        log = util_log.get_logger()

        # if we can, set PYTHONPATH for lldb bindings
        if not _deduce_python_path(state):
            log.log_and_print('Unable to deduce PYTHONPATH', logging.WARN)

        # pre run step
        if not _suite_pre_run(state):
            raise TestSuiteException('Test suite pre-run step failed')
        # discover all tests and execute them
        tests = _discover_tests(state)
        log.log_and_print('Found {0} tests'.format(len(tests)))
        if state.install_only:
            log.log_and_print('Test applications installed. Terminating due to '
                              '--install-only option')
        else:
            # run the tests
            for bundle_type in state.bundle_types:
                log.info("Running bundle type '%s'", bundle_type)
                for item in tests:
                    _run_test(state, item, bundle_type)
                # post run step
            quit(0 if _suite_post_run(state) == 0 else 1)

    except AssertionError:
        if log:
            log.exception('Internal test suite error')

        print('Internal test suite error')
        quit(1)

    except FailFastException:
        log.exception('Early exit after first test failure')
        quit(1)

    except TestSuiteException as error:
        if log:
            log.exception('Test suite exception')

        print('{0}'.format(str(error)))
        quit(2)

    finally:
        _kill_emulator()
        logging.shutdown()

def signal_handler(_, _unused):
    '''Signal handler for SIGINT, caused by the user typing Ctrl-C.'''
    # pylint: disable=unused-argument
    # pylint: disable=protected-access
    print('Ctrl+C!')
    os._exit(1)


# execution trampoline
if __name__ == '__main__':
    signal.signal(signal.SIGINT, signal_handler)
    main()