diff --git a/android/pylib/android_commands.py b/android/pylib/android_commands.py index 6d5b0460e..4db8b705d 100644 --- a/android/pylib/android_commands.py +++ b/android/pylib/android_commands.py @@ -856,3 +856,38 @@ class AndroidCommands(object): usage_dict[key] += value return usage_dict, smaps + + def ProcessesUsingDevicePort(self, device_port): + """Lists the processes using the specified device port on loopback + interface. + + Args: + device_port: Port on device we want to check. + + Returns: + A list of (pid, process_name) tuples using the specified port. + """ + tcp_results = self.RunShellCommand('cat /proc/net/tcp', log_result=False) + tcp_address = "0100007F:%04X" % device_port + pids = [] + for single_connect in tcp_results: + connect_results = single_connect.split() + # Column 1 is the TCP port, and Column 9 is the inode of the socket + if connect_results[1] == tcp_address: + socket_inode = connect_results[9] + socket_name = 'socket:[%s]' % socket_inode + lsof_results = self.RunShellCommand('lsof', log_result=False) + for single_process in lsof_results: + process_results = single_process.split() + # Ignore the line if it has less than nine columns in it, which may + # be the case when a process stops while lsof is executing. + if len(process_results) <= 8: + continue + # Column 0 is the executable name + # Column 1 is the pid + # Column 8 is the Inode in use + if process_results[8] == socket_name: + pids.append( (int(process_results[1]), process_results[0]) ) + break + logging.info('PidsUsingDevicePort: %s', pids) + return pids diff --git a/android/pylib/apk_info.py b/android/pylib/apk_info.py new file mode 100644 index 000000000..7e8867570 --- /dev/null +++ b/android/pylib/apk_info.py @@ -0,0 +1,142 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Gathers information about APKs.""" + +import collections +import os +import re + +import cmd_helper + + +class ApkInfo(object): + """Helper class for inspecting APKs.""" + _PROGUARD_PATH = os.path.join(os.environ['ANDROID_SDK_ROOT'], + 'tools/proguard/bin/proguard.sh') + if not os.path.exists(_PROGUARD_PATH): + _PROGUARD_PATH = os.path.join(os.environ['ANDROID_BUILD_TOP'], + 'external/proguard/bin/proguard.sh') + _PROGUARD_CLASS_RE = re.compile(r'\s*?- Program class:\s*([\S]+)$') + _PROGUARD_METHOD_RE = re.compile(r'\s*?- Method:\s*(\S*)[(].*$') + _PROGUARD_ANNOTATION_RE = re.compile(r'\s*?- Annotation \[L(\S*);\]:$') + _PROGUARD_ANNOTATION_CONST_RE = re.compile(r'\s*?- Constant element value.*$') + _PROGUARD_ANNOTATION_VALUE_RE = re.compile(r'\s*?- \S+? \[(.*)\]$') + _AAPT_PACKAGE_NAME_RE = re.compile(r'package: .*name=\'(\S*)\'') + + def __init__(self, apk_path, jar_path): + if not os.path.exists(apk_path): + raise Exception('%s not found, please build it' % apk_path) + self._apk_path = apk_path + if not os.path.exists(jar_path): + raise Exception('%s not found, please build it' % jar_path) + self._jar_path = jar_path + self._annotation_map = collections.defaultdict(list) + self._test_methods = [] + self._Initialize() + + def _Initialize(self): + proguard_output = cmd_helper.GetCmdOutput([self._PROGUARD_PATH, + '-injars', self._jar_path, + '-dontshrink', + '-dontoptimize', + '-dontobfuscate', + '-dontpreverify', + '-dump', + ]).split('\n') + clazz = None + method = None + annotation = None + has_value = False + qualified_method = None + for line in proguard_output: + m = self._PROGUARD_CLASS_RE.match(line) + if m: + clazz = m.group(1).replace('/', '.') # Change package delim. + annotation = None + continue + m = self._PROGUARD_METHOD_RE.match(line) + if m: + method = m.group(1) + annotation = None + qualified_method = clazz + '#' + method + if method.startswith('test') and clazz.endswith('Test'): + self._test_methods += [qualified_method] + continue + m = self._PROGUARD_ANNOTATION_RE.match(line) + if m: + assert qualified_method + annotation = m.group(1).split('/')[-1] # Ignore the annotation package. + self._annotation_map[qualified_method].append(annotation) + has_value = False + continue + if annotation: + assert qualified_method + if not has_value: + m = self._PROGUARD_ANNOTATION_CONST_RE.match(line) + if m: + has_value = True + else: + m = self._PROGUARD_ANNOTATION_VALUE_RE.match(line) + if m: + value = m.group(1) + self._annotation_map[qualified_method].append( + annotation + ':' + value) + has_value = False + + def _GetAnnotationMap(self): + return self._annotation_map + + def _IsTestMethod(self, test): + class_name, method = test.split('#') + return class_name.endswith('Test') and method.startswith('test') + + def GetApkPath(self): + return self._apk_path + + def GetPackageName(self): + """Returns the package name of this APK.""" + aapt_output = cmd_helper.GetCmdOutput( + ['aapt', 'dump', 'badging', self._apk_path]).split('\n') + for line in aapt_output: + m = self._AAPT_PACKAGE_NAME_RE.match(line) + if m: + return m.group(1) + raise Exception('Failed to determine package name of %s' % self._apk_path) + + def GetTestAnnotations(self, test): + """Returns a list of all annotations for the given |test|. May be empty.""" + if not self._IsTestMethod(test): + return [] + return self._GetAnnotationMap()[test] + + def _AnnotationsMatchFilters(self, annotation_filter_list, annotations): + """Checks if annotations match any of the filters.""" + if not annotation_filter_list: + return True + for annotation_filter in annotation_filter_list: + filters = annotation_filter.split('=') + if len(filters) == 2: + key = filters[0] + value_list = filters[1].split(',') + for value in value_list: + if key + ':' + value in annotations: + return True + elif annotation_filter in annotations: + return True + return False + + def GetAnnotatedTests(self, annotation_filter_list): + """Returns a list of all tests that match the given annotation filters.""" + return [test for test, annotations in self._GetAnnotationMap().iteritems() + if self._IsTestMethod(test) and self._AnnotationsMatchFilters( + annotation_filter_list, annotations)] + + def GetTestMethods(self): + """Returns a list of all test methods in this apk as Class#testMethod.""" + return self._test_methods + + @staticmethod + def IsPythonDrivenTest(test): + return 'pythonDrivenTests' in test diff --git a/android/pylib/java_unittest_utils.py b/android/pylib/java_unittest_utils.py new file mode 100644 index 000000000..b5446dcf9 --- /dev/null +++ b/android/pylib/java_unittest_utils.py @@ -0,0 +1,27 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""This file is imported by python tests ran by run_python_tests.py.""" + +import os + +import android_commands +from run_java_tests import TestRunner + + +def _GetPackageName(fname): + """Extracts the package name from the test file path.""" + base_root = os.path.join('com', 'google', 'android') + dirname = os.path.dirname(fname) + package = dirname[dirname.rfind(base_root):] + return package.replace(os.sep, '.') + + +def RunJavaTest(fname, suite, test, ports_to_forward): + device = android_commands.GetAttachedDevices()[0] + package_name = _GetPackageName(fname) + test = package_name + '.' + suite + '#' + test + java_test_runner = TestRunner(False, device, [test], False, False, False, + False, 0, ports_to_forward) + return java_test_runner.Run() diff --git a/android/pylib/json_perf_parser.py b/android/pylib/json_perf_parser.py new file mode 100644 index 000000000..88ca3e0d8 --- /dev/null +++ b/android/pylib/json_perf_parser.py @@ -0,0 +1,160 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + + +"""A helper module for parsing JSON objects from perf tests results.""" + +import json + + +def GetAverageRunInfo(json_data, name): + """Summarizes TraceEvent JSON data for performance metrics. + + Example JSON Inputs (More tags can be added but these are required): + Measuring Duration: + [ + { "cat": "Java", + "ts": 10000000000, + "ph": "B", + "name": "TestTrace" + }, + { "cat": "Java", + "ts": 10000004000, + "ph": "E", + "name": "TestTrace" + }, + ... + ] + + Measuring Call Frequency (FPS): + [ + { "cat": "Java", + "ts": 10000000000, + "ph": "I", + "name": "TestTraceFPS" + }, + { "cat": "Java", + "ts": 10000004000, + "ph": "I", + "name": "TestTraceFPS" + }, + ... + ] + + Args: + json_data: A list of dictonaries each representing a JSON object. + name: The 'name' tag to filter on in the JSON file. + + Returns: + A dictionary of result data with the following tags: + min: The minimum value tracked. + max: The maximum value tracked. + average: The average of all the values tracked. + count: The number of times the category/name pair was tracked. + type: The type of tracking ('Instant' for instant tags and 'Span' for + begin/end tags. + category: The passed in category filter. + name: The passed in name filter. + data_points: A list of all of the times used to generate this data. + units: The units for the values being reported. + + Raises: + Exception: if entry contains invalid data. + """ + + def EntryFilter(entry): + return entry['cat'] == 'Java' and entry['name'] == name + filtered_entries = filter(EntryFilter, json_data) + + result = {} + + result['min'] = -1 + result['max'] = -1 + result['average'] = 0 + result['count'] = 0 + result['type'] = 'Unknown' + result['category'] = 'Java' + result['name'] = name + result['data_points'] = [] + result['units'] = '' + + total_sum = 0 + + last_val = 0 + val_type = None + for entry in filtered_entries: + if not val_type: + if 'mem' in entry: + val_type = 'mem' + + def GetVal(entry): + return entry['mem'] + + result['units'] = 'kb' + elif 'ts' in entry: + val_type = 'ts' + + def GetVal(entry): + return float(entry['ts']) / 1000.0 + + result['units'] = 'ms' + else: + raise Exception('Entry did not contain valid value info: %s' % entry) + + if not val_type in entry: + raise Exception('Entry did not contain expected value type "%s" ' + 'information: %s' % (val_type, entry)) + val = GetVal(entry) + if (entry['ph'] == 'B' and + (result['type'] == 'Unknown' or result['type'] == 'Span')): + result['type'] = 'Span' + last_val = val + elif ((entry['ph'] == 'E' and result['type'] == 'Span') or + (entry['ph'] == 'I' and (result['type'] == 'Unknown' or + result['type'] == 'Instant'))): + if last_val > 0: + delta = val - last_val + if result['min'] == -1 or result['min'] > delta: + result['min'] = delta + if result['max'] == -1 or result['max'] < delta: + result['max'] = delta + total_sum += delta + result['count'] += 1 + result['data_points'].append(delta) + if entry['ph'] == 'I': + result['type'] = 'Instant' + last_val = val + if result['count'] > 0: result['average'] = total_sum / result['count'] + + return result + + +def GetAverageRunInfoFromJSONString(json_string, name): + """Returns the results from GetAverageRunInfo using a JSON string. + + Args: + json_string: The string containing JSON. + name: The 'name' tag to filter on in the JSON file. + + Returns: + See GetAverageRunInfo Returns section. + """ + return GetAverageRunInfo(json.loads(json_string), name) + + +def GetAverageRunInfoFromFile(json_file, name): + """Returns the results from GetAverageRunInfo using a JSON file. + + Args: + json_file: The path to a JSON file. + name: The 'name' tag to filter on in the JSON file. + + Returns: + See GetAverageRunInfo Returns section. + """ + with open(json_file, 'r') as f: + data = f.read() + perf = json.loads(data) + + return GetAverageRunInfo(perf, name) diff --git a/android/pylib/python_test_base.py b/android/pylib/python_test_base.py new file mode 100644 index 000000000..69e5bbec5 --- /dev/null +++ b/android/pylib/python_test_base.py @@ -0,0 +1,177 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Base class for Android Python-driven tests. + +This test case is intended to serve as the base class for any Python-driven +tests. It is similar to the Python unitttest module in that the user's tests +inherit from this case and add their tests in that case. + +When a PythonTestBase object is instantiated, its purpose is to run only one of +its tests. The test runner gives it the name of the test the instance will +run. The test runner calls SetUp with the Android device ID which the test will +run against. The runner runs the test method itself, collecting the result, +and calls TearDown. + +Tests can basically do whatever they want in the test methods, such as call +Java tests using _RunJavaTests. Those methods have the advantage of massaging +the Java test results into Python test results. +""" + +import logging +import os +import time + +import android_commands +import apk_info +from run_java_tests import TestRunner +import test_options_parser +from test_result import SingleTestResult, TestResults, PYTHON + + +# aka the parent of com.google.android +BASE_ROOT = 'src' + os.sep + + +class PythonTestBase(object): + """Base class for Python-driven tests.""" + + def __init__(self, test_name): + # test_name must match one of the test methods defined on a subclass which + # inherits from this class. + # It's stored so we can do the attr lookup on demand, allowing this class + # to be pickled, a requirement for the multiprocessing module. + self.test_name = test_name + class_name = self.__class__.__name__ + self.qualified_name = class_name + '.' + self.test_name + self.ports_to_forward = [] + + def SetUp(self, device_id, shard_index): + self.shard_index = shard_index + self.device_id = device_id + self.adb = android_commands.AndroidCommands(self.device_id) + + def TearDown(self): + pass + + def Run(self): + logging.warning('Running Python-driven test: %s', self.test_name) + return getattr(self, self.test_name)() + + def _RunJavaTest(self, fname, suite, test): + """Runs a single Java test with a Java TestRunner. + + Args: + fname: filename for the test (e.g. foo/bar/baz/tests/FooTest.py) + suite: name of the Java test suite (e.g. FooTest) + test: name of the test method to run (e.g. testFooBar) + + Returns: + TestResults object with a single test result. + """ + test = self._ComposeFullTestName(fname, suite, test) + # Get a set of default options + options = test_options_parser.ParseInstrumentationArgs(['']) + apks = [apk_info.ApkInfo(options.test_apk_path, options.test_apk_jar_path)] + java_test_runner = TestRunner(options, self.device_id, [test], False, + self.shard_index, + apks, + self.ports_to_forward) + return java_test_runner.Run() + + def _RunJavaTests(self, fname, tests): + """Calls a list of tests and stops at the first test failure. + + This method iterates until either it encounters a non-passing test or it + exhausts the list of tests. Then it returns the appropriate Python result. + + Args: + fname: filename for the Python test + tests: a list of Java test names which will be run + + Returns: + A TestResults object containing a result for this Python test. + """ + start_ms = int(time.time()) * 1000 + + result = None + for test in tests: + # We're only running one test at a time, so this TestResults object will + # hold only one result. + suite, test_name = test.split('.') + result = self._RunJavaTest(fname, suite, test_name) + # A non-empty list means the test did not pass. + if result.GetAllBroken(): + break + + duration_ms = int(time.time()) * 1000 - start_ms + + # Do something with result. + return self._ProcessResults(result, start_ms, duration_ms) + + def _ProcessResults(self, result, start_ms, duration_ms): + """Translates a Java test result into a Python result for this test. + + The TestRunner class that we use under the covers will return a test result + for that specific Java test. However, to make reporting clearer, we have + this method to abstract that detail and instead report that as a failure of + this particular test case while still including the Java stack trace. + + Args: + result: TestResults with a single Java test result + start_ms: the time the test started + duration_ms: the length of the test + + Returns: + A TestResults object containing a result for this Python test. + """ + test_results = TestResults() + + # If our test is in broken, then it crashed/failed. + broken = result.GetAllBroken() + if broken: + # Since we have run only one test, take the first and only item. + single_result = broken[0] + + log = single_result.log + if not log: + log = 'No logging information.' + + short_error_msg = single_result.log.split('\n')[0] + # err_info is ostensibly for Sponge to consume; it's a short error + # message and a longer one. + err_info = (short_error_msg, log) + + python_result = SingleTestResult(self.qualified_name, start_ms, + duration_ms, + PYTHON, + log, + err_info) + + # Figure out where the test belonged. There's probably a cleaner way of + # doing this. + if single_result in result.crashed: + test_results.crashed = [python_result] + elif single_result in result.failed: + test_results.failed = [python_result] + elif single_result in result.unknown: + test_results.unknown = [python_result] + + else: + python_result = SingleTestResult(self.qualified_name, start_ms, + duration_ms, + PYTHON) + test_results.ok = [python_result] + + return test_results + + def _ComposeFullTestName(self, fname, suite, test): + package_name = self._GetPackageName(fname) + return package_name + '.' + suite + '#' + test + + def _GetPackageName(self, fname): + """Extracts the package name from the test file path.""" + dirname = os.path.dirname(fname) + package = dirname[dirname.rfind(BASE_ROOT) + len(BASE_ROOT):] + return package.replace(os.sep, '.') diff --git a/android/pylib/python_test_caller.py b/android/pylib/python_test_caller.py new file mode 100644 index 000000000..2cc5d7c22 --- /dev/null +++ b/android/pylib/python_test_caller.py @@ -0,0 +1,85 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Helper module for calling python-based tests.""" + + +import logging +import sys +import time + +from test_result import TestResults + + +def CallPythonTest(test, device_id, shard_index): + """Invokes a test function and translates Python exceptions into test results. + + This method invokes SetUp()/TearDown() on the test. It is intended to be + resilient to exceptions in SetUp(), the test itself, and TearDown(). Any + Python exception means the test is marked as failed, and the test result will + contain information about the exception. + + If SetUp() raises an exception, the test is not run. + + If TearDown() raises an exception, the test is treated as a failure. However, + if the test itself raised an exception beforehand, that stack trace will take + precedence whether or not TearDown() also raised an exception. + + shard_index is not applicable in single-device scenarios, when test execution + is serial rather than parallel. Tests can use this to bring up servers with + unique port numbers, for example. See also python_test_sharder. + + Args: + test: an object which is ostensibly a subclass of PythonTestBase. + device_id: device ID against which the test will run. + shard_index: index # of the shard on which this test is running + + Returns: + A TestResults object which contains any results produced by the test or, in + the case of a Python exception, the Python exception info. + """ + + start_date_ms = int(time.time()) * 1000 + failed = False + + try: + test.SetUp(device_id, shard_index) + except Exception: + failed = True + logging.exception( + 'Caught exception while trying to run SetUp() for test: ' + + test.qualified_name) + # Tests whose SetUp() method has failed are likely to fail, or at least + # yield invalid results. + exc_info = sys.exc_info() + return TestResults.FromPythonException(test.qualified_name, start_date_ms, + exc_info) + + try: + result = test.Run() + except Exception: + # Setting this lets TearDown() avoid stomping on our stack trace from Run() + # should TearDown() also raise an exception. + failed = True + logging.exception('Caught exception while trying to run test: ' + + test.qualified_name) + exc_info = sys.exc_info() + result = TestResults.FromPythonException(test.qualified_name, start_date_ms, + exc_info) + + try: + test.TearDown() + except Exception: + logging.exception( + 'Caught exception while trying run TearDown() for test: ' + + test.qualified_name) + if not failed: + # Don't stomp the error during the test if TearDown blows up. This is a + # trade-off: if the test fails, this will mask any problem with TearDown + # until the test is fixed. + exc_info = sys.exc_info() + result = TestResults.FromPythonException(test.qualified_name, + start_date_ms, exc_info) + + return result diff --git a/android/pylib/python_test_sharder.py b/android/pylib/python_test_sharder.py new file mode 100644 index 000000000..7bab235a8 --- /dev/null +++ b/android/pylib/python_test_sharder.py @@ -0,0 +1,200 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Takes care of sharding the python-drive tests in multiple devices.""" + +import logging +import multiprocessing + +from python_test_caller import CallPythonTest +from run_java_tests import FatalTestException +import sharded_tests_queue +from test_result import TestResults + + +def SetTestsContainer(tests_container): + """Sets PythonTestSharder as a top-level field. + + PythonTestSharder uses multiprocessing.Pool, which creates a pool of + processes. This is used to initialize each worker in the pool, ensuring that + each worker has access to this shared pool of tests. + + The multiprocessing module requires that this be a top-level method. + + Args: + tests_container: the container for all the tests. + """ + PythonTestSharder.tests_container = tests_container + + +def _DefaultRunnable(test_runner): + """A default runnable for a PythonTestRunner. + + Args: + test_runner: A PythonTestRunner which will run tests. + + Returns: + The test results. + """ + return test_runner.RunTests() + + +class PythonTestRunner(object): + """Thin wrapper around a list of PythonTestBase instances. + + This is meant to be a long-lived object which can run multiple Python tests + within its lifetime. Tests will receive the device_id and shard_index. + + The shard index affords the ability to create unique port numbers (e.g. + DEFAULT_PORT + shard_index) if the test so wishes. + """ + + def __init__(self, device_id, shard_index): + """Constructor. + + Args: + device_id: ID of the device which this test will talk to. + shard_index: shard index, used to create such as unique port numbers. + """ + self.device_id = device_id + self.shard_index = shard_index + + def RunTests(self): + """Runs tests from the shared pool of tests, aggregating results. + + Returns: + A list of test results for all of the tests which this runner executed. + """ + tests = PythonTestSharder.tests_container + + results = [] + for t in tests: + res = CallPythonTest(t, self.device_id, self.shard_index) + results.append(res) + + return TestResults.FromTestResults(results) + + +class PythonTestSharder(object): + """Runs Python tests in parallel on multiple devices. + + This is lifted more or less wholesale from BaseTestRunner. + + Under the covers, it creates a pool of long-lived PythonTestRunners, which + execute tests from the pool of tests. + + Args: + attached_devices: a list of device IDs attached to the host. + shard_retries: number of retries for any given test. + available_tests: a list of tests to run which subclass PythonTestBase. + + Returns: + An aggregated list of test results. + """ + tests_container = None + + def __init__(self, attached_devices, shard_retries, available_tests): + self.attached_devices = attached_devices + self.retries = shard_retries + self.tests = available_tests + + def _SetupSharding(self, tests): + """Creates the shared pool of tests and makes it available to test runners. + + Args: + tests: the list of tests which will be consumed by workers. + """ + SetTestsContainer(sharded_tests_queue.ShardedTestsQueue( + len(self.attached_devices), tests)) + + def RunShardedTests(self): + """Runs tests in parallel using a pool of workers. + + Returns: + A list of test results aggregated from all test runs. + """ + logging.warning('*' * 80) + logging.warning('Sharding in ' + str(len(self.attached_devices)) + + ' devices.') + logging.warning('Note that the output is not synchronized.') + logging.warning('Look for the "Final result" banner in the end.') + logging.warning('*' * 80) + all_passed = [] + test_results = TestResults() + tests_to_run = self.tests + for retry in xrange(self.retries): + logging.warning('Try %d of %d', retry + 1, self.retries) + self._SetupSharding(self.tests) + test_runners = self._MakeTestRunners(self.attached_devices) + logging.warning('Starting...') + pool = multiprocessing.Pool(len(self.attached_devices), + SetTestsContainer, + [PythonTestSharder.tests_container]) + + # List of TestResults objects from each test execution. + try: + results_lists = pool.map(_DefaultRunnable, test_runners) + except Exception: + logging.exception('Unable to run tests. Something with the ' + 'PythonTestRunners has gone wrong.') + raise FatalTestException('PythonTestRunners were unable to run tests.') + + test_results = TestResults.FromTestResults(results_lists) + # Accumulate passing results. + all_passed += test_results.ok + # If we have failed tests, map them to tests to retry. + failed_tests = test_results.GetAllBroken() + tests_to_run = self._GetTestsToRetry(self.tests, + failed_tests) + + # Bail out early if we have no more tests. This can happen if all tests + # pass before we're out of retries, for example. + if not tests_to_run: + break + + final_results = TestResults() + # all_passed has accumulated all passing test results. + # test_results will have the results from the most recent run, which could + # include a variety of failure modes (unknown, crashed, failed, etc). + final_results = test_results + final_results.ok = all_passed + + return final_results + + def _MakeTestRunners(self, attached_devices): + """Initialize and return a list of PythonTestRunners. + + Args: + attached_devices: list of device IDs attached to host. + + Returns: + A list of PythonTestRunners, one for each device. + """ + test_runners = [] + for index, device in enumerate(attached_devices): + logging.warning('*' * 80) + logging.warning('Creating shard %d for %s', index, device) + logging.warning('*' * 80) + # Bind the PythonTestRunner to a device & shard index. Give it the + # runnable which it will use to actually execute the tests. + test_runner = PythonTestRunner(device, index) + test_runners.append(test_runner) + + return test_runners + + def _GetTestsToRetry(self, available_tests, failed_tests): + """Infers a list of tests to retry from failed tests and available tests. + + Args: + available_tests: a list of tests which subclass PythonTestBase. + failed_tests: a list of SingleTestResults representing failed tests. + + Returns: + A list of test objects which correspond to test names found in + failed_tests, or an empty list if there is no correspondence. + """ + failed_test_names = map(lambda t: t.test_name, failed_tests) + tests_to_retry = [t for t in available_tests + if t.qualified_name in failed_test_names] + return tests_to_retry diff --git a/android/pylib/run_java_tests.py b/android/pylib/run_java_tests.py new file mode 100755 index 000000000..598a56a74 --- /dev/null +++ b/android/pylib/run_java_tests.py @@ -0,0 +1,590 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Runs the Java tests. See more information on run_instrumentation_tests.py.""" + +import fnmatch +import logging +import os +import re +import shutil +import sys +import time + +import android_commands +import apk_info +from base_test_runner import BaseTestRunner +from base_test_sharder import BaseTestSharder, SetTestsContainer +import cmd_helper +import constants +import errors +from forwarder import Forwarder +from json_perf_parser import GetAverageRunInfoFromJSONString +from perf_tests_helper import PrintPerfResult +import sharded_tests_queue +from test_result import JAVA, SingleTestResult, TestResults + + +_PERF_TEST_ANNOTATION = 'PerfTest' + + +class FatalTestException(Exception): + """A fatal test exception.""" + pass + + +def _TestNameToExpectation(test_name): + # A test name is a Package.Path.Class#testName; convert to what we use in + # the expectation file. + return '.'.join(test_name.replace('#', '.').split('.')[-2:]) + + +def FilterTests(test_names, pattern_list, inclusive): + """Filters |test_names| using a list of patterns. + + Args: + test_names: A list of test names. + pattern_list: A list of patterns. + inclusive: If True, returns the tests that match any pattern. if False, + returns the tests that do not match any pattern. + Returns: + A list of test names. + """ + ret = [] + for t in test_names: + has_match = False + for pattern in pattern_list: + has_match = has_match or fnmatch.fnmatch(_TestNameToExpectation(t), + pattern) + if has_match == inclusive: + ret += [t] + return ret + + +class TestRunner(BaseTestRunner): + """Responsible for running a series of tests connected to a single device.""" + + _DEVICE_DATA_DIR = '/data/local/tmp/chrome/test/data' + _EMMA_JAR = os.path.join(os.environ.get('ANDROID_BUILD_TOP', ''), + 'external/emma/lib/emma.jar') + _COVERAGE_MERGED_FILENAME = 'unittest_coverage.es' + _COVERAGE_WEB_ROOT_DIR = os.environ.get('EMMA_WEB_ROOTDIR') + _COVERAGE_FILENAME = 'coverage.ec' + _COVERAGE_RESULT_PATH = ('/data/data/com.google.android.apps.chrome/files/' + + _COVERAGE_FILENAME) + _COVERAGE_META_INFO_PATH = os.path.join(os.environ.get('ANDROID_BUILD_TOP', + ''), + 'out/target/common/obj/APPS', + 'Chrome_intermediates/coverage.em') + _HOSTMACHINE_PERF_OUTPUT_FILE = '/tmp/chrome-profile' + _DEVICE_PERF_OUTPUT_DIR = '/sdcard/Download/' + _DEVICE_PERF_OUTPUT_SEARCH_PREFIX = (_DEVICE_PERF_OUTPUT_DIR + + 'chrome-profile*') + _DEVICE_HAS_TEST_FILES = {} + + def __init__(self, options, device, tests_iter, coverage, shard_index, apks, + ports_to_forward): + """Create a new TestRunner. + + Args: + options: An options object with the following required attributes: + - install_apk: Re-installs the apk if opted. + - save_perf_json: Whether or not to save the JSON file from UI perf + tests. + - screenshot_failures: Take a screenshot for a test failure + - tool: Name of the Valgrind tool. + - wait_for_debugger: blocks until the debugger is connected. + device: Attached android device. + tests_iter: A list of tests to be run. + coverage: Collects coverage information if opted. + shard_index: shard # for this TestRunner, used to create unique port + numbers. + apks: A list of ApkInfo objects need to be installed. The first element + should be the tests apk, the rests could be the apks used in test. + The default is ChromeTest.apk. + ports_to_forward: A list of port numbers for which to set up forwarders. + Can be optionally requested by a test case. + Raises: + FatalTestException: if coverage metadata is not available. + """ + BaseTestRunner.__init__(self, device, options.tool, shard_index) + + if not apks: + apks = [apk_info.ApkInfo(options.test_apk_path, + options.test_apk_jar_path)] + + self.install_apk = options.install_apk + self.save_perf_json = options.save_perf_json + self.screenshot_failures = options.screenshot_failures + self.wait_for_debugger = options.wait_for_debugger + + self.tests_iter = tests_iter + self.coverage = coverage + self.apks = apks + self.test_apk = apks[0] + self.instrumentation_class_path = self.test_apk.GetPackageName() + self.ports_to_forward = ports_to_forward + + self.test_results = TestResults() + # List of forwarders created by this instance of TestRunner. + self.forwarders = [] + + if self.coverage: + if os.path.exists(TestRunner._COVERAGE_MERGED_FILENAME): + os.remove(TestRunner._COVERAGE_MERGED_FILENAME) + if not os.path.exists(TestRunner._COVERAGE_META_INFO_PATH): + raise FatalTestException('FATAL ERROR in ' + sys.argv[0] + + ' : Coverage meta info [' + + TestRunner._COVERAGE_META_INFO_PATH + + '] does not exist.') + if (not TestRunner._COVERAGE_WEB_ROOT_DIR or + not os.path.exists(TestRunner._COVERAGE_WEB_ROOT_DIR)): + raise FatalTestException('FATAL ERROR in ' + sys.argv[0] + + ' : Path specified in $EMMA_WEB_ROOTDIR [' + + TestRunner._COVERAGE_WEB_ROOT_DIR + + '] does not exist.') + + def _GetTestsIter(self): + if not self.tests_iter: + # multiprocessing.Queue can't be pickled across processes if we have it as + # a member set during constructor. Grab one here instead. + self.tests_iter = (BaseTestSharder.tests_container) + assert self.tests_iter + return self.tests_iter + + def CopyTestFilesOnce(self): + """Pushes the test data files to the device. Installs the apk if opted.""" + if TestRunner._DEVICE_HAS_TEST_FILES.get(self.device, False): + logging.warning('Already copied test files to device %s, skipping.', + self.device) + return + host_test_files_path = (constants.CHROME_DIR + + '/chrome/test/data/android/device_files') + if os.path.exists(host_test_files_path): + self.adb.PushIfNeeded(host_test_files_path, + TestRunner._DEVICE_DATA_DIR) + if self.install_apk: + # Install -r is not reliable, so uninstall it first. + for apk in self.apks: + self.adb.Adb().SendCommand('uninstall ' + apk.GetPackageName()) + self.adb.Adb().SendCommand('install ' + apk.GetApkPath()) + self.tool.CopyFiles() + TestRunner._DEVICE_HAS_TEST_FILES[self.device] = True + + def SaveCoverageData(self, test): + """Saves the Emma coverage data before it's overwritten by the next test. + + Args: + test: the test whose coverage data is collected. + """ + if not self.coverage: + return + if not self.adb.Adb().Pull(TestRunner._COVERAGE_RESULT_PATH, + constants.CHROME_DIR): + logging.error('ERROR: Unable to find file ' + + TestRunner._COVERAGE_RESULT_PATH + + ' on the device for test ' + test) + pulled_coverage_file = os.path.join(constants.CHROME_DIR, + TestRunner._COVERAGE_FILENAME) + if os.path.exists(TestRunner._COVERAGE_MERGED_FILENAME): + cmd = ['java', '-classpath', TestRunner._EMMA_JAR, 'emma', 'merge', + '-in', pulled_coverage_file, + '-in', TestRunner._COVERAGE_MERGED_FILENAME, + '-out', TestRunner._COVERAGE_MERGED_FILENAME] + cmd_helper.RunCmd(cmd) + else: + shutil.copy(pulled_coverage_file, + TestRunner._COVERAGE_MERGED_FILENAME) + os.remove(pulled_coverage_file) + + def GenerateCoverageReportIfNeeded(self): + """Uses the Emma to generate a coverage report and a html page.""" + if not self.coverage: + return + cmd = ['java', '-classpath', TestRunner._EMMA_JAR, + 'emma', 'report', '-r', 'html', + '-in', TestRunner._COVERAGE_MERGED_FILENAME, + '-in', TestRunner._COVERAGE_META_INFO_PATH] + cmd_helper.RunCmd(cmd) + new_dir = os.path.join(TestRunner._COVERAGE_WEB_ROOT_DIR, + time.strftime('Coverage_for_%Y_%m_%d_%a_%H:%M')) + shutil.copytree('coverage', new_dir) + + latest_dir = os.path.join(TestRunner._COVERAGE_WEB_ROOT_DIR, + 'Latest_Coverage_Run') + if os.path.exists(latest_dir): + shutil.rmtree(latest_dir) + os.mkdir(latest_dir) + webserver_new_index = os.path.join(new_dir, 'index.html') + webserver_new_files = os.path.join(new_dir, '_files') + webserver_latest_index = os.path.join(latest_dir, 'index.html') + webserver_latest_files = os.path.join(latest_dir, '_files') + # Setup new softlinks to last result. + os.symlink(webserver_new_index, webserver_latest_index) + os.symlink(webserver_new_files, webserver_latest_files) + cmd_helper.RunCmd(['chmod', '755', '-R', latest_dir, new_dir]) + + def _GetInstrumentationArgs(self): + ret = {} + if self.coverage: + ret['coverage'] = 'true' + if self.wait_for_debugger: + ret['debug'] = 'true' + return ret + + def _TakeScreenshot(self, test): + """Takes a screenshot from the device.""" + screenshot_tool = os.path.join(os.getenv('ANDROID_HOST_OUT'), 'bin', + 'screenshot2') + screenshot_path = os.path.join(constants.CHROME_DIR, + 'out_screenshots') + if not os.path.exists(screenshot_path): + os.mkdir(screenshot_path) + screenshot_name = os.path.join(screenshot_path, test + '.png') + logging.info('Taking screenshot named %s', screenshot_name) + cmd_helper.RunCmd([screenshot_tool, '-s', self.device, screenshot_name]) + + def SetUp(self): + """Sets up the test harness and device before all tests are run.""" + super(TestRunner, self).SetUp() + if self.adb.SetJavaAssertsEnabled(enable=True): + self.adb.Reboot(full_reboot=False) + + # We give different default value to launch HTTP server based on shard index + # because it may have race condition when multiple processes are trying to + # launch lighttpd with same port at same time. + # This line *must* come before the forwarding below, as it nukes all + # the other forwarders. A more comprehensive fix might be to pull the + # forwarder-killing line up to here, but that might violate assumptions + # implicit in other places. + self.LaunchTestHttpServer(os.path.join(constants.CHROME_DIR), + (constants.LIGHTTPD_RANDOM_PORT_FIRST + + self.shard_index)) + + if self.ports_to_forward: + for port in self.ports_to_forward: + self.forwarders.append( + Forwarder(self.adb, [(port, port)], self.tool, '127.0.0.1')) + self.CopyTestFilesOnce() + self.flags.AddFlags(['--enable-test-intents']) + + def TearDown(self): + """Cleans up the test harness and saves outstanding data from test run.""" + if self.forwarders: + for forwarder in self.forwarders: + forwarder.Close() + self.GenerateCoverageReportIfNeeded() + super(TestRunner, self).TearDown() + + def TestSetup(self, test): + """Sets up the test harness for running a particular test. + + Args: + test: The name of the test that will be run. + """ + self.SetupPerfMonitoringIfNeeded(test) + self._SetupIndividualTestTimeoutScale(test) + self.tool.SetupEnvironment() + + # Make sure the forwarder is still running. + self.RestartHttpServerForwarderIfNecessary() + + def _IsPerfTest(self, test): + """Determines whether a test is a performance test. + + Args: + test: The name of the test to be checked. + + Returns: + Whether the test is annotated as a performance test. + """ + return _PERF_TEST_ANNOTATION in self.test_apk.GetTestAnnotations(test) + + def SetupPerfMonitoringIfNeeded(self, test): + """Sets up performance monitoring if the specified test requires it. + + Args: + test: The name of the test to be run. + """ + if not self._IsPerfTest(test): + return + self.adb.Adb().SendCommand('shell rm ' + + TestRunner._DEVICE_PERF_OUTPUT_SEARCH_PREFIX) + self.adb.StartMonitoringLogcat() + + def TestTeardown(self, test, test_result): + """Cleans up the test harness after running a particular test. + + Depending on the options of this TestRunner this might handle coverage + tracking or performance tracking. This method will only be called if the + test passed. + + Args: + test: The name of the test that was just run. + test_result: result for this test. + """ + + self.tool.CleanUpEnvironment() + + # The logic below relies on the test passing. + if not test_result or test_result.GetStatusCode(): + return + + self.TearDownPerfMonitoring(test) + self.SaveCoverageData(test) + + def TearDownPerfMonitoring(self, test): + """Cleans up performance monitoring if the specified test required it. + + Args: + test: The name of the test that was just run. + Raises: + FatalTestException: if there's anything wrong with the perf data. + """ + if not self._IsPerfTest(test): + return + raw_test_name = test.split('#')[1] + + # Wait and grab annotation data so we can figure out which traces to parse + regex = self.adb.WaitForLogMatch(re.compile('\*\*PERFANNOTATION\(' + + raw_test_name + + '\)\:(.*)'), None) + + # If the test is set to run on a specific device type only (IE: only + # tablet or phone) and it is being run on the wrong device, the test + # just quits and does not do anything. The java test harness will still + # print the appropriate annotation for us, but will add --NORUN-- for + # us so we know to ignore the results. + # The --NORUN-- tag is managed by MainActivityTestBase.java + if regex.group(1) != '--NORUN--': + + # Obtain the relevant perf data. The data is dumped to a + # JSON formatted file. + json_string = self.adb.GetFileContents( + '/data/data/com.google.android.apps.chrome/files/PerfTestData.txt') + + if json_string: + json_string = '\n'.join(json_string) + else: + raise FatalTestException('Perf file does not exist or is empty') + + if self.save_perf_json: + json_local_file = '/tmp/chromium-android-perf-json-' + raw_test_name + with open(json_local_file, 'w') as f: + f.write(json_string) + logging.info('Saving Perf UI JSON from test ' + + test + ' to ' + json_local_file) + + raw_perf_data = regex.group(1).split(';') + + for raw_perf_set in raw_perf_data: + if raw_perf_set: + perf_set = raw_perf_set.split(',') + if len(perf_set) != 3: + raise FatalTestException('Unexpected number of tokens in ' + 'perf annotation string: ' + raw_perf_set) + + # Process the performance data + result = GetAverageRunInfoFromJSONString(json_string, perf_set[0]) + + PrintPerfResult(perf_set[1], perf_set[2], + [result['average']], result['units']) + + def _SetupIndividualTestTimeoutScale(self, test): + timeout_scale = self._GetIndividualTestTimeoutScale(test) + if timeout_scale == 1: + value = '""' + else: + value = '%f' % timeout_scale + self.adb.RunShellCommand('setprop chrome.timeout_scale %s' % value) + + def _GetIndividualTestTimeoutScale(self, test): + """Returns the timeout scale for the given |test|.""" + annotations = self.apks[0].GetTestAnnotations(test) + timeout_scale = 1 + if 'TimeoutScale' in annotations: + for annotation in annotations: + scale_match = re.match('TimeoutScale:([0-9]+)', annotation) + if scale_match: + timeout_scale = int(scale_match.group(1)) + if self.wait_for_debugger: + timeout_scale *= 100 + return timeout_scale + + def _GetIndividualTestTimeoutSecs(self, test): + """Returns the timeout in seconds for the given |test|.""" + annotations = self.apks[0].GetTestAnnotations(test) + if 'Manual' in annotations: + return 600 * 60 + if 'External' in annotations: + return 10 * 60 + if 'LargeTest' in annotations or _PERF_TEST_ANNOTATION in annotations: + return 5 * 60 + if 'MediumTest' in annotations: + return 3 * 60 + return 1 * 60 + + def RunTests(self): + """Runs the tests, generating the coverage if needed. + + Returns: + A TestResults object. + """ + instrumentation_path = (self.instrumentation_class_path + + '/android.test.InstrumentationTestRunner') + instrumentation_args = self._GetInstrumentationArgs() + for test in self._GetTestsIter(): + test_result = None + start_date_ms = None + try: + self.TestSetup(test) + start_date_ms = int(time.time()) * 1000 + args_with_filter = dict(instrumentation_args) + args_with_filter['class'] = test + # |test_results| is a list that should contain + # a single TestResult object. + logging.warn(args_with_filter) + (test_results, _) = self.adb.Adb().StartInstrumentation( + instrumentation_path=instrumentation_path, + instrumentation_args=args_with_filter, + timeout_time=(self._GetIndividualTestTimeoutSecs(test) * + self._GetIndividualTestTimeoutScale(test) * + self.tool.GetTimeoutScale())) + duration_ms = int(time.time()) * 1000 - start_date_ms + assert len(test_results) == 1 + test_result = test_results[0] + status_code = test_result.GetStatusCode() + if status_code: + log = test_result.GetFailureReason() + if not log: + log = 'No information.' + if self.screenshot_failures or log.find('INJECT_EVENTS perm') >= 0: + self._TakeScreenshot(test) + result = (log.split('\n')[0], log) + self.test_results.failed += [SingleTestResult(test, start_date_ms, + duration_ms, JAVA, log, + result)] + else: + result = [SingleTestResult(test, start_date_ms, duration_ms, JAVA)] + self.test_results.ok += result + # Catch exceptions thrown by StartInstrumentation(). + # See ../../third_party/android/testrunner/adb_interface.py + except (errors.WaitForResponseTimedOutError, + errors.DeviceUnresponsiveError, + errors.InstrumentationError), e: + if start_date_ms: + duration_ms = int(time.time()) * 1000 - start_date_ms + else: + start_date_ms = int(time.time()) * 1000 + duration_ms = 0 + message = str(e) + if not message: + message = 'No information.' + self.test_results.crashed += [SingleTestResult(test, start_date_ms, + duration_ms, + JAVA, message, + (message, message))] + test_result = None + self.TestTeardown(test, test_result) + return self.test_results + + +class TestSharder(BaseTestSharder): + """Responsible for sharding the tests on the connected devices.""" + + def __init__(self, attached_devices, options, tests, apks): + BaseTestSharder.__init__(self, attached_devices) + self.options = options + self.tests = tests + self.apks = apks + + def SetupSharding(self, tests): + """Called before starting the shards.""" + SetTestsContainer(sharded_tests_queue.ShardedTestsQueue( + len(self.attached_devices), tests)) + + def CreateShardedTestRunner(self, device, index): + """Creates a sharded test runner. + + Args: + device: Device serial where this shard will run. + index: Index of this device in the pool. + + Returns: + A TestRunner object. + """ + return TestRunner(self.options, device, None, False, index, self.apks, []) + + +def DispatchJavaTests(options, apks): + """Dispatches Java tests onto connected device(s). + + If possible, this method will attempt to shard the tests to + all connected devices. Otherwise, dispatch and run tests on one device. + + Args: + options: Command line options. + apks: list of APKs to use. + + Returns: + A TestResults object holding the results of the Java tests. + + Raises: + FatalTestException: when there's no attached the devices. + """ + test_apk = apks[0] + if options.annotation: + available_tests = test_apk.GetAnnotatedTests(options.annotation) + if len(options.annotation) == 1 and options.annotation[0] == 'SmallTest': + tests_without_annotation = [ + m for m in + test_apk.GetTestMethods() + if not test_apk.GetTestAnnotations(m) and + not apk_info.ApkInfo.IsPythonDrivenTest(m)] + if tests_without_annotation: + tests_without_annotation.sort() + logging.warning('The following tests do not contain any annotation. ' + 'Assuming "SmallTest":\n%s', + '\n'.join(tests_without_annotation)) + available_tests += tests_without_annotation + else: + available_tests = [m for m in test_apk.GetTestMethods() + if not apk_info.ApkInfo.IsPythonDrivenTest(m)] + coverage = os.environ.get('EMMA_INSTRUMENT') == 'true' + + tests = [] + if options.test_filter: + # |available_tests| are in adb instrument format: package.path.class#test. + filter_without_hash = options.test_filter.replace('#', '.') + tests = [t for t in available_tests + if filter_without_hash in t.replace('#', '.')] + else: + tests = available_tests + + if not tests: + logging.warning('No Java tests to run with current args.') + return TestResults() + + tests *= options.number_of_runs + + attached_devices = android_commands.GetAttachedDevices() + test_results = TestResults() + + if not attached_devices: + raise FatalTestException('You have no devices attached or visible!') + if options.device: + attached_devices = [options.device] + + logging.info('Will run: %s', str(tests)) + + if (len(attached_devices) > 1 and + not coverage and + not options.wait_for_debugger): + sharder = TestSharder(attached_devices, options, tests, apks) + test_results = sharder.RunShardedTests() + else: + runner = TestRunner(options, attached_devices[0], tests, coverage, 0, apks, + []) + test_results = runner.Run() + return test_results diff --git a/android/pylib/run_python_tests.py b/android/pylib/run_python_tests.py new file mode 100755 index 000000000..6556892a1 --- /dev/null +++ b/android/pylib/run_python_tests.py @@ -0,0 +1,225 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Runs the Python tests (relies on using the Java test runner).""" + +import logging +import os +import sys +import types + +import android_commands +import apk_info +import constants +import python_test_base +from python_test_caller import CallPythonTest +from python_test_sharder import PythonTestSharder +import run_java_tests +from run_java_tests import FatalTestException +from test_info_collection import TestInfoCollection +from test_result import TestResults + + +def _GetPythonFiles(root, files): + """Returns all files from |files| that end in 'Test.py'. + + Args: + root: A directory name with python files. + files: A list of file names. + + Returns: + A list with all Python driven test file paths. + """ + return [os.path.join(root, f) for f in files if f.endswith('Test.py')] + + +def _InferImportNameFromFile(python_file): + """Given a file, infer the import name for that file. + + Example: /usr/foo/bar/baz.py -> baz. + + Args: + python_file: path to the Python file, ostensibly to import later. + + Returns: + The module name for the given file. + """ + return os.path.splitext(os.path.basename(python_file))[0] + + +def DispatchPythonTests(options): + """Dispatches the Python tests. If there are multiple devices, use sharding. + + Args: + options: command line options. + + Returns: + A list of test results. + """ + + attached_devices = android_commands.GetAttachedDevices() + if not attached_devices: + raise FatalTestException('You have no devices attached or visible!') + if options.device: + attached_devices = [options.device] + + test_collection = TestInfoCollection() + all_tests = _GetAllTests(options.python_test_root, options.official_build) + test_collection.AddTests(all_tests) + test_names = [t.qualified_name for t in all_tests] + logging.debug('All available tests: ' + str(test_names)) + + available_tests = test_collection.GetAvailableTests( + options.annotation, options.test_filter) + + if not available_tests: + logging.warning('No Python tests to run with current args.') + return TestResults() + + available_tests *= options.number_of_runs + test_names = [t.qualified_name for t in available_tests] + logging.debug('Final list of tests to run: ' + str(test_names)) + + # Copy files to each device before running any tests. + for device_id in attached_devices: + logging.debug('Pushing files to device %s', device_id) + apks = [apk_info.ApkInfo(options.test_apk_path, options.test_apk_jar_path)] + test_files_copier = run_java_tests.TestRunner(options, device_id, + None, False, 0, apks, []) + test_files_copier.CopyTestFilesOnce() + + # Actually run the tests. + if (len(attached_devices) > 1 and + not options.wait_for_debugger): + logging.debug('Sharding Python tests.') + sharder = PythonTestSharder(attached_devices, options.shard_retries, + available_tests) + test_results = sharder.RunShardedTests() + else: + logging.debug('Running Python tests serially.') + test_results = _RunPythonTests(available_tests, attached_devices[0]) + + return test_results + + +def _RunPythonTests(tests_to_run, device_id): + """Runs a list of Python tests serially on one device and returns results. + + Args: + tests_to_run: a list of objects inheriting from PythonTestBase. + device_id: ID of the device to run tests on. + + Returns: + A list of test results, aggregated across all the tests run. + """ + # This is a list of TestResults objects. + results = [CallPythonTest(t, device_id, 0) for t in tests_to_run] + # Merge the list of TestResults into one TestResults. + return TestResults.FromTestResults(results) + + +def _GetTestModules(python_test_root, is_official_build): + """Retrieve a sorted list of pythonDrivenTests. + + Walks the location of pythonDrivenTests, imports them, and provides the list + of imported modules to the caller. + + Args: + python_test_root: the path to walk, looking for pythonDrivenTests + is_official_build: whether to run only those tests marked 'official' + + Returns: + A list of Python modules which may have zero or more tests. + """ + # By default run all python tests under pythonDrivenTests. + python_test_file_list = [] + for root, _, files in os.walk(python_test_root): + if (root.endswith('pythonDrivenTests') + or (is_official_build + and root.endswith('pythonDrivenTests/official'))): + python_test_file_list += _GetPythonFiles(root, files) + python_test_file_list.sort() + + test_module_list = [_GetModuleFromFile(test_file) + for test_file in python_test_file_list] + return test_module_list + + +def _GetModuleFromFile(python_file): + """Gets the module associated with a file by importing it. + + Args: + python_file: file to import + + Returns: + The module object. + """ + sys.path.append(os.path.dirname(python_file)) + import_name = _InferImportNameFromFile(python_file) + return __import__(import_name) + + +def _GetTestsFromClass(test_class): + """Create a list of test objects for each test method on this class. + + Test methods are methods on the class which begin with 'test'. + + Args: + test_class: class object which contains zero or more test methods. + + Returns: + A list of test objects, each of which is bound to one test. + """ + test_names = [m for m in dir(test_class) + if _IsTestMethod(m, test_class)] + return map(test_class, test_names) + + +def _GetTestClassesFromModule(test_module): + tests = [] + for name in dir(test_module): + attr = getattr(test_module, name) + if _IsTestClass(attr): + tests.extend(_GetTestsFromClass(attr)) + return tests + + +def _IsTestClass(test_class): + return (type(test_class) is types.TypeType and + issubclass(test_class, python_test_base.PythonTestBase) and + test_class is not python_test_base.PythonTestBase) + + +def _IsTestMethod(attrname, test_case_class): + """Checks whether this is a valid test method. + + Args: + attrname: the method name. + test_case_class: the test case class. + + Returns: + True if test_case_class.'attrname' is callable and it starts with 'test'; + False otherwise. + """ + attr = getattr(test_case_class, attrname) + return callable(attr) and attrname.startswith('test') + + +def _GetAllTests(test_root, is_official_build): + """Retrieve a list of Python test modules and their respective methods. + + Args: + test_root: path which contains Python-driven test files + is_official_build: whether this is an official build + + Returns: + List of test case objects for all available test methods. + """ + if not test_root: + return [] + all_tests = [] + test_module_list = _GetTestModules(test_root, is_official_build) + for module in test_module_list: + all_tests.extend(_GetTestClassesFromModule(module)) + return all_tests diff --git a/android/pylib/run_tests_helper.py b/android/pylib/run_tests_helper.py index 54bf91e0d..15e5d5381 100644 --- a/android/pylib/run_tests_helper.py +++ b/android/pylib/run_tests_helper.py @@ -4,19 +4,8 @@ """Helper functions common to native, java and python test runners.""" -import contextlib -import fcntl -import httplib import logging -import optparse import os -import re -import socket -import subprocess -import sys -import traceback - -import cmd_helper def GetExpectations(file_name): diff --git a/android/pylib/sharded_tests_queue.py b/android/pylib/sharded_tests_queue.py new file mode 100644 index 000000000..d2b377969 --- /dev/null +++ b/android/pylib/sharded_tests_queue.py @@ -0,0 +1,36 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + + +"""A module that contains a queue for running sharded tests.""" + +import multiprocessing + + +class ShardedTestsQueue(object): + """A queue for managing pending tests across different runners. + + This class should only be used when sharding. + + Attributes: + num_devices: an integer; the number of attached Android devices. + tests: a list of tests to be run. + tests_queue: if sharding, a JoinableQueue object that holds tests from + |tests|. Otherwise, a list holding tests. + results_queue: a Queue object to hold TestResults objects. + """ + _STOP_SENTINEL = 'STOP' # sentinel value for iter() + + def __init__(self, num_devices, tests): + assert num_devices > 1, 'At least two devices must be attached.' + self.num_devices = num_devices + self.tests_queue = multiprocessing.Queue() + for test in tests: + self.tests_queue.put(test) + for _ in xrange(self.num_devices): + self.tests_queue.put(ShardedTestsQueue._STOP_SENTINEL) + + def __iter__(self): + """Returns an iterator with the test cases.""" + return iter(self.tests_queue.get, ShardedTestsQueue._STOP_SENTINEL) diff --git a/android/pylib/test_info_collection.py b/android/pylib/test_info_collection.py new file mode 100644 index 000000000..fc4e80694 --- /dev/null +++ b/android/pylib/test_info_collection.py @@ -0,0 +1,137 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Module containing information about the python-driven tests.""" + +import logging +import os + +import tests_annotations + + +class TestInfo(object): + """An object containing and representing a test function, plus metadata.""" + + def __init__(self, runnable, set_up=None, tear_down=None): + # The actual test function/method. + self.runnable = runnable + # Qualified name of test function/method (e.g. FooModule.testBar). + self.qualified_name = self._GetQualifiedName(runnable) + # setUp and teardown functions, if any. + self.set_up = set_up + self.tear_down = tear_down + + def _GetQualifiedName(self, runnable): + """Helper method to infer a runnable's name and module name. + + Many filters and lists presuppose a format of module_name.testMethodName. + To make this easy on everyone, we use some reflection magic to infer this + name automatically. + + Args: + runnable: the test method to get the qualified name for + + Returns: + qualified name for this runnable, incl. module name and method name. + """ + runnable_name = runnable.__name__ + # See also tests_annotations. + module_name = os.path.splitext( + os.path.basename(runnable.__globals__['__file__']))[0] + return '.'.join([module_name, runnable_name]) + + def __str__(self): + return self.qualified_name + + +class TestInfoCollection(object): + """A collection of TestInfo objects which facilitates filtering.""" + + def __init__(self): + """Initialize a new TestInfoCollection.""" + # Master list of all valid tests. + self.all_tests = [] + + def AddTests(self, test_infos): + """Adds a set of tests to this collection. + + The user may then retrieve them, optionally according to criteria, via + GetAvailableTests(). + + Args: + test_infos: a list of TestInfos representing test functions/methods. + """ + self.all_tests = test_infos + + def GetAvailableTests(self, annotation, name_filter): + """Get a collection of TestInfos which match the supplied criteria. + + Args: + annotation: annotation which tests must match, if any + name_filter: name filter which tests must match, if any + + Returns: + List of available tests. + """ + available_tests = self.all_tests + + # Filter out tests which match neither the requested annotation, nor the + # requested name filter, if any. + available_tests = [t for t in available_tests if + self._AnnotationIncludesTest(t, annotation)] + if annotation and len(annotation) == 1 and annotation[0] == 'SmallTest': + tests_without_annotation = [ + t for t in self.all_tests if + not tests_annotations.AnnotatedFunctions.GetTestAnnotations( + t.qualified_name)] + test_names = [t.qualified_name for t in tests_without_annotation] + logging.warning('The following tests do not contain any annotation. ' + 'Assuming "SmallTest":\n%s', + '\n'.join(test_names)) + available_tests += tests_without_annotation + available_tests = [t for t in available_tests if + self._NameFilterIncludesTest(t, name_filter)] + + return available_tests + + def _AnnotationIncludesTest(self, test_info, annotation_filter_list): + """Checks whether a given test represented by test_info matches annotation. + + Args: + test_info: TestInfo object representing the test + annotation_filter_list: list of annotation filters to match (e.g. Smoke) + + Returns: + True if no annotation was supplied or the test matches; false otherwise. + """ + if not annotation_filter_list: + return True + for annotation_filter in annotation_filter_list: + filters = annotation_filter.split('=') + if len(filters) == 2: + key = filters[0] + value_list = filters[1].split(',') + for value in value_list: + if tests_annotations.AnnotatedFunctions.IsAnnotated( + key + ':' + value, test_info.qualified_name): + return True + elif tests_annotations.AnnotatedFunctions.IsAnnotated( + annotation_filter, test_info.qualified_name): + return True + return False + + def _NameFilterIncludesTest(self, test_info, name_filter): + """Checks whether a name filter matches a given test_info's method name. + + This is a case-sensitive, substring comparison: 'Foo' will match methods + Foo.testBar and Bar.testFoo. 'foo' would not match either. + + Args: + test_info: TestInfo object representing the test + name_filter: substring to check for in the qualified name of the test + + Returns: + True if no name filter supplied or it matches; False otherwise. + """ + return not name_filter or name_filter in test_info.qualified_name diff --git a/android/pylib/test_options_parser.py b/android/pylib/test_options_parser.py index 4b4deec3a..97e458042 100644 --- a/android/pylib/test_options_parser.py +++ b/android/pylib/test_options_parser.py @@ -4,8 +4,10 @@ """Parses options for the instrumentation tests.""" -import os +import constants import optparse +import os + def CreateTestRunnerOptionParser(usage=None, default_timeout=60): @@ -17,8 +19,7 @@ def CreateTestRunnerOptionParser(usage=None, default_timeout=60): default=default_timeout) option_parser.add_option('-c', dest='cleanup_test_files', help='Cleanup test files on the device after run', - action='store_true', - default=False) + action='store_true') option_parser.add_option('-v', '--verbose', dest='verbose_count', @@ -37,3 +38,76 @@ def CreateTestRunnerOptionParser(usage=None, default_timeout=60): help='Run the test under a tool ' '(use --tool help to list them)') return option_parser + + +def ParseInstrumentationArgs(args): + """Parse arguments and return options with defaults.""" + + option_parser = CreateTestRunnerOptionParser() + option_parser.add_option('-w', '--wait_debugger', dest='wait_for_debugger', + action='store_true', help='Wait for debugger.') + option_parser.add_option('-I', dest='install_apk', help='Install APK.', + action='store_true') + option_parser.add_option('-f', '--test_filter', + help='Test filter (if not fully qualified, ' + 'will run all matches).') + option_parser.add_option('-A', '--annotation', dest='annotation_str', + help=('Run only tests with any of the given ' + 'annotations. ' + 'An annotation can be either a key or a ' + 'key-values pair. ' + 'A test that has no annotation is ' + 'considered "SmallTest".')) + option_parser.add_option('-j', '--java_only', action='store_true', + help='Run only the Java tests.') + option_parser.add_option('-p', '--python_only', action='store_true', + help='Run only the Python tests.') + option_parser.add_option('-n', '--run_count', type='int', + dest='number_of_runs', default=1, + help=('How many times to run each test, regardless ' + 'of the result. (Default is 1)')) + option_parser.add_option('--test-apk', dest='test_apk', + help=('The name of the apk containing the tests ' + '(without the .apk extension).')) + option_parser.add_option('--screenshot', dest='screenshot_failures', + action='store_true', + help='Capture screenshots of test failures') + option_parser.add_option('--save-perf-json', action='store_true', + help='Saves the JSON file for each UI Perf test.') + option_parser.add_option('--shard_retries', type=int, default=1, + help=('Number of times to retry each failure when ' + 'sharding.')) + option_parser.add_option('--official-build', help='Run official build tests.') + option_parser.add_option('--device', + help='Serial number of device we should use.') + option_parser.add_option('--python_test_root', + help='Root of the python-driven tests.') + + options, args = option_parser.parse_args(args) + if len(args) > 1: + option_parser.error('Unknown argument:', args[1:]) + if options.java_only and options.python_only: + option_parser.error('Options java_only (-j) and python_only (-p) ' + 'are mutually exclusive') + + options.run_java_tests = True + options.run_python_tests = True + if options.java_only: + options.run_python_tests = False + elif options.python_only: + options.run_java_tests = False + + options.test_apk_path = os.path.join(constants.CHROME_DIR, + 'out', 'Release', + '%s.apk' % options.test_apk) + options.test_apk_jar_path = os.path.join(constants.CHROME_DIR, + 'out', 'Release', + '%s.jar' % options.test_apk) + if options.annotation_str: + options.annotation = options.annotation_str.split() + elif options.test_filter: + options.annotation = [] + else: + options.annotation = ['Smoke', 'SmallTest', 'MediumTest', 'LargeTest'] + + return options diff --git a/android/pylib/tests_annotations.py b/android/pylib/tests_annotations.py new file mode 100644 index 000000000..f2a183466 --- /dev/null +++ b/android/pylib/tests_annotations.py @@ -0,0 +1,89 @@ +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Annotations for python-driven tests.""" + +import os + + +class AnnotatedFunctions(object): + """A container for annotated methods.""" + _ANNOTATED = {} + + @staticmethod + def _AddFunction(annotation, function): + """Adds an annotated to function to our container. + + Args: + annotation: the annotation string. + function: the function. + Returns: + The function passed in. + """ + module_name = os.path.splitext(os.path.basename( + function.__globals__['__file__']))[0] + qualified_function_name = '.'.join([module_name, function.func_name]) + function_list = AnnotatedFunctions._ANNOTATED.get(annotation, []) + function_list.append(qualified_function_name) + AnnotatedFunctions._ANNOTATED[annotation] = function_list + return function + + @staticmethod + def IsAnnotated(annotation, qualified_function_name): + """True if function name (module.function) contains the annotation. + + Args: + annotation: the annotation string. + qualified_function_name: the qualified function name. + Returns: + True if module.function contains the annotation. + """ + return qualified_function_name in AnnotatedFunctions._ANNOTATED.get( + annotation, []) + + @staticmethod + def GetTestAnnotations(qualified_function_name): + """Returns a list containing all annotations for the given function. + + Args: + qualified_function_name: the qualified function name. + Returns: + List of all annotations for this function. + """ + return [annotation + for annotation, tests in AnnotatedFunctions._ANNOTATED.iteritems() + if qualified_function_name in tests] + + +# The following functions are annotations used for the python driven tests. +def Smoke(function): + return AnnotatedFunctions._AddFunction('Smoke', function) + + +def SmallTest(function): + return AnnotatedFunctions._AddFunction('SmallTest', function) + + +def MediumTest(function): + return AnnotatedFunctions._AddFunction('MediumTest', function) + + +def LargeTest(function): + return AnnotatedFunctions._AddFunction('LargeTest', function) + + +def FlakyTest(function): + return AnnotatedFunctions._AddFunction('FlakyTest', function) + + +def DisabledTest(function): + return AnnotatedFunctions._AddFunction('DisabledTest', function) + + +def Feature(feature_list): + def _AddFeatures(function): + for feature in feature_list: + AnnotatedFunctions._AddFunction('Feature' + feature, function) + return AnnotatedFunctions._AddFunction('Feature', function) + return _AddFeatures diff --git a/android/run_instrumentation_tests.py b/android/run_instrumentation_tests.py new file mode 100755 index 000000000..1a6e099df --- /dev/null +++ b/android/run_instrumentation_tests.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# +# Copyright (c) 2012 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +"""Runs both the Python and Java tests.""" + +import sys +import time + +from pylib import apk_info +from pylib import test_options_parser +from pylib import run_java_tests +from pylib import run_python_tests +from pylib import run_tests_helper +from pylib.test_result import TestResults + + +def SummarizeResults(java_results, python_results, annotation): + """Summarize the results from the various test types. + + Args: + java_results: a TestResults object with java test case results. + python_results: a TestResults object with python test case results. + annotation: the annotation used for these results. + + Returns: + A tuple (all_results, summary_string, num_failing) + """ + all_results = TestResults.FromTestResults([java_results, python_results]) + summary_string = all_results.LogFull('Instrumentation', annotation) + num_failing = (len(all_results.failed) + len(all_results.crashed) + + len(all_results.unknown)) + return all_results, summary_string, num_failing + + +def DispatchInstrumentationTests(options): + """Dispatches the Java and Python instrumentation tests, sharding if possible. + + Uses the logging module to print the combined final results and + summary of the Java and Python tests. If the java_only option is set, only + the Java tests run. If the python_only option is set, only the python tests + run. If neither are set, run both Java and Python tests. + + Args: + options: command-line options for running the Java and Python tests. + + Returns: + An integer representing the number of failing tests. + """ + start_date = int(time.time() * 1000) + java_results = TestResults() + python_results = TestResults() + + if options.run_java_tests: + java_results = run_java_tests.DispatchJavaTests( + options, + [apk_info.ApkInfo(options.test_apk_path, options.test_apk_jar_path)]) + if options.run_python_tests: + python_results = run_python_tests.DispatchPythonTests(options) + + all_results, summary_string, num_failing = SummarizeResults( + java_results, python_results, options.annotation) + return num_failing + + +def main(argv): + options = test_options_parser.ParseInstrumentationArgs(argv) + run_tests_helper.SetLogLevel(options.verbose_count) + return DispatchInstrumentationTests(options) + + +if __name__ == '__main__': + sys.exit(main(sys.argv))