Rewrite run-tests.sh in Python.
Giant shell scripts are difficult to maintain, and bash makes code
reuse clunky at best.
We already ship Python with the NDK, so this is actually more portable
than shipping a shell script.
The old test script will be left in place until we're sure the new
runner is sufficient.
Change-Id: I76325db6cd002a384ae6638b0575f1df2f6fc36a
diff --git a/tests/adb.py b/tests/adb.py
new file mode 100644
index 0000000..e9c28f2
--- /dev/null
+++ b/tests/adb.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2015 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.
+#
+"""ADB handling for NDK tests."""
+import os
+import re
+import subprocess
+
+
+def push(src, dst):
+ with open(os.devnull, 'wb') as dev_null:
+ subprocess.check_call(['adb', 'push', src, dst], stdout=dev_null,
+ stderr=dev_null)
+
+
+def shell(command):
+ # Work around the fact that adb doesn't return shell exit status.
+ p = subprocess.Popen(['adb', 'shell', command + '; echo $?'],
+ stdout=subprocess.PIPE)
+ out, _ = p.communicate()
+ if p.returncode != 0:
+ raise RuntimeError('adb shell failed')
+
+ out = re.split(r'[\r\n]+', out)
+ if out[-1] == '':
+ # Splitting 'foo\n' will return ['foo', '']. Lose the last element.
+ out = out[:-1]
+ result = int(out[-1])
+ out = out[:-1]
+ return result, out
+
+
+def get_prop(prop_name):
+ result, output = shell('getprop {}'.format(prop_name))
+ if result != 0:
+ raise RuntimeError('getprop failed:\n' + '\n'.join(output))
+ if len(output) != 1:
+ raise RuntimeError('Too many lines in getprop output:\n' +
+ '\n'.join(output))
+ value = output[0]
+ if not value.strip():
+ return None
+ return value
diff --git a/tests/ndk.py b/tests/ndk.py
new file mode 100644
index 0000000..c2b35c1
--- /dev/null
+++ b/tests/ndk.py
@@ -0,0 +1,53 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2015 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.
+#
+"""Interface to NDK build information."""
+import os
+import re
+import subprocess
+
+
+THIS_DIR = os.path.dirname(os.path.realpath(__file__))
+NDK_ROOT = os.path.realpath(os.path.join(THIS_DIR, '..'))
+
+
+def get_build_var(test_dir, var_name):
+ makefile = os.path.join(NDK_ROOT, 'build/core/build-local.mk')
+ cmd = ['make', '--no-print-dir', '-f', makefile, '-C', test_dir,
+ 'DUMP_{}'.format(var_name)]
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ out, _ = p.communicate()
+ if p.returncode != 0:
+ raise RuntimeError('Could not get build variable')
+ return out.strip().split('\n')[-1]
+
+
+def build(build_flags):
+ ndk_build_path = os.path.join(NDK_ROOT, 'ndk-build')
+ return subprocess.call([ndk_build_path] + build_flags)
+
+
+def expand_app_abi(abi):
+ all32 = ('armeabi', 'armeabi-v7a', 'mips', 'x86')
+ all64 = ('arm64-v8a', 'mips64', 'x86_64')
+ all_abis = all32 + all64
+ if abi == 'all':
+ return all_abis
+ elif abi == 'all32':
+ return all32
+ elif abi == 'all64':
+ return all64
+ return re.split(r'\s+', abi)
diff --git a/tests/run-all.py b/tests/run-all.py
new file mode 100644
index 0000000..07c11ce
--- /dev/null
+++ b/tests/run-all.py
@@ -0,0 +1,561 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2015 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.
+#
+"""Runs all NDK tests.
+
+TODO: Handle default ABI case.
+The old test script would build and test all ABIs listed in APP_ABI in a single
+invocation if an explicit ABI was not given. Currently this will fall down over
+that case. I've written most of the code to handle this, but I decided to
+factor that for-each up several levels because it was going to make the device
+tests rather messy and I haven't finished doing that yet.
+
+TODO: Handle explicit test lists from command line.
+The old test runner allowed specifying an exact list of tests to run with
+--tests. That seems like a useful thing to keep around, but I haven't ported it
+yet.
+"""
+from __future__ import print_function
+
+import argparse
+import contextlib
+import distutils.spawn
+import functools
+import glob
+import inspect
+import multiprocessing
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+import adb
+import ndk
+
+
+DEV_NULL = open(os.devnull, 'wb')
+
+
+SUPPORTED_ABIS = (
+ 'armeabi',
+ 'armeabi-v7a',
+ 'arm64-v8a',
+ 'mips',
+ 'mips64',
+ 'x86',
+ 'x86_64',
+)
+
+
+# TODO(danalbert): How much time do we actually save by not running these?
+LONG_TESTS = (
+ 'prebuild-stlport',
+ 'test-stlport',
+ 'test-gnustl-full',
+ 'test-stlport_shared-exception',
+ 'test-stlport_static-exception',
+ 'test-gnustl_shared-exception-full',
+ 'test-gnustl_static-exception-full',
+ 'test-googletest-full',
+ 'test-libc++-shared-full',
+ 'test-libc++-static-full',
+)
+
+
+@contextlib.contextmanager
+def cd(path):
+ curdir = os.getcwd()
+ os.chdir(path)
+ try:
+ yield
+ finally:
+ os.chdir(curdir)
+
+
+def get_jobs_arg():
+ return '-j{}'.format(multiprocessing.cpu_count() * 2)
+
+
+def color_string(string, color):
+ colors = {
+ 'green': '\033[92m',
+ 'red': '\033[91m',
+ 'yellow': '\033[93m',
+ }
+ end_color = '\033[0m'
+ return colors[color] + string + end_color
+
+
+class TestResult(object):
+ def __init__(self, test_name, passed):
+ self.passed = passed
+ self.test_name = test_name
+
+ def __repr__(self):
+ return self.to_string(colored=False)
+
+ def to_string(self, colored=False):
+ raise NotImplementedError
+
+
+class Failure(TestResult):
+ def __init__(self, test_name, message):
+ super(Failure, self).__init__(test_name, passed=False)
+ self.message = message
+
+ def to_string(self, colored=False):
+ label = color_string('FAIL', 'red') if colored else 'FAIL'
+ return '{} {}: {}'.format(label, self.test_name, self.message)
+
+
+class Success(TestResult):
+ def __init__(self, test_name):
+ super(Success, self).__init__(test_name, passed=True)
+
+ def to_string(self, colored=False):
+ label = color_string('PASS', 'green') if colored else 'PASS'
+ return '{} {}'.format(label, self.test_name)
+
+
+class Skipped(TestResult):
+ def __init__(self, test_name, reason):
+ super(Skipped, self).__init__(test_name, passed=False)
+ self.reason = reason
+
+ def to_string(self, colored=False):
+ label = color_string('SKIP', 'yellow') if colored else 'SKIP'
+ return '{} {}: {}'.format(label, self.test_name, self.reason)
+
+
+def test_is_disabled(test_dir, platform):
+ disable_file = os.path.join(test_dir, 'BROKEN_BUILD')
+ if os.path.isfile(disable_file):
+ if os.stat(disable_file).st_size == 0:
+ return True
+
+ # This might look like clang-3.6 and gcc-3.6 would overlap (not a
+ # problem today, but maybe when we hit clang-4.9), but clang is
+ # actually written as clang3.6 (with no hypen), so toolchain_version
+ # will end up being 'clang3.6'.
+ toolchain = ndk.get_build_var(test_dir, 'TARGET_TOOLCHAIN')
+ toolchain_version = toolchain.split('-')[-1]
+ with open(disable_file) as f:
+ contents = f.read()
+ broken_configs = re.split(r'\s+', contents)
+ if toolchain_version in broken_configs:
+ return True
+ if platform is not None and platform in broken_configs:
+ return True
+ return False
+
+
+def run_is_disabled(test_case, test_dir):
+ """Returns True if the test case is disabled.
+
+ There is no strict format for the BROKEN_RUN file; test cases are disabled
+ if their basename appears anywhere in the file.
+ """
+ disable_file = os.path.join(test_dir, 'BROKEN_RUN')
+ if not os.path.exists(disable_file):
+ return False
+ return subprocess.call(['grep', '-qw', test_case, disable_file]) == 0
+
+
+def should_skip_test(test_dir, abi, platform):
+ if test_is_disabled(test_dir, platform):
+ return 'disabled'
+ if abi is not None:
+ app_abi = ndk.get_build_var(test_dir, 'APP_ABI')
+ supported_abis = ndk.expand_app_abi(app_abi)
+ if abi not in supported_abis:
+ abi_string = ', '.join(supported_abis)
+ return 'incompatible ABI (requires {})'.format(abi_string)
+ return None
+
+
+def run_awk_test_case(out_dir, test_name, script, test_case, golden_out_path):
+ out_path = os.path.join(out_dir, os.path.basename(golden_out_path))
+
+ with open(test_case, 'r') as test_in, open(out_path, 'w') as out_file:
+ print('awk -f {} < {} > {}'.format(script, test_case, out_path))
+ rc = subprocess.call(['awk', '-f', script], stdin=test_in,
+ stdout=out_file)
+ if rc != 0:
+ return Failure(test_name, 'awk failed')
+
+ rc = subprocess.call(['cmp', out_path, golden_out_path], stdout=DEV_NULL,
+ stderr=DEV_NULL)
+ if rc == 0:
+ return Success(test_name)
+ else:
+ p = subprocess.Popen(['diff', '-buN', out_path, golden_out_path],
+ stdout=subprocess.PIPE, stderr=DEV_NULL)
+ out, _ = p.communicate()
+ if p.returncode != 0:
+ raise RuntimeError('Could not generate diff')
+ message = 'output does not match expected:\n\n' + out
+ return Failure(test_name, message)
+
+
+def run_awk_test(out_dir, test_dir):
+ test_name = '{}.awk'.format(os.path.basename(test_dir))
+ script = os.path.join(ndk.NDK_ROOT, 'build/awk', test_name)
+ if not os.path.isfile(script):
+ return [Failure(test_name, 'missing test script: {}'.format(script))]
+ results = []
+ for test_case in glob.glob(os.path.join(test_dir, '*.in')):
+ golden_path = re.sub(r'\.in$', '.out', test_case)
+ if not os.path.isfile(golden_path):
+ results.append(Failure(test_name,
+ 'missing output: {}'.format(golden_path)))
+ results.append(run_awk_test_case(out_dir, test_name, script, test_case,
+ golden_path))
+ return results
+
+
+def prep_build_dir(src_dir, out_dir):
+ if os.path.exists(out_dir):
+ shutil.rmtree(out_dir)
+ shutil.copytree(src_dir, out_dir)
+
+
+def run_build_sh_test(test_name, build_dir, test_dir, build_flags, abi,
+ platform):
+ android_mk = os.path.join(test_dir, 'jni/Android.mk')
+ application_mk = os.path.join(test_dir, 'jni/Application.mk')
+ if os.path.isfile(android_mk) and os.path.isfile(application_mk):
+ result = subprocess.call(['grep', '-q', 'import-module', android_mk])
+ if result != 0:
+ reason = should_skip_test(test_dir, abi, platform)
+ if reason is not None:
+ return Skipped(test_name, reason)
+
+ prep_build_dir(test_dir, build_dir)
+ with cd(build_dir):
+ build_cmd = ['sh', 'build.sh', get_jobs_arg()] + build_flags
+ if subprocess.call(build_cmd) == 0:
+ return Success(test_name)
+ else:
+ return Failure(test_name, 'build failed')
+
+
+def run_ndk_build_test(test_name, build_dir, test_dir, build_flags, abi,
+ platform):
+ reason = should_skip_test(test_dir, abi, platform)
+ if reason is not None:
+ return Skipped(test_name, reason)
+
+ prep_build_dir(test_dir, build_dir)
+ with cd(build_dir):
+ rc = ndk.build(build_flags + [get_jobs_arg()])
+ expect_failure = os.path.isfile(
+ os.path.join(test_dir, 'BUILD_SHOULD_FAIL'))
+ if rc == 0 and expect_failure:
+ return Failure(test_name, 'build should have failed')
+ elif rc != 0 and not expect_failure:
+ return Failure(test_name, 'build failed')
+ return Success(test_name)
+
+
+def run_build_test(out_dir, test_dir, build_flags, abi, platform):
+ test_name = os.path.basename(test_dir)
+ print('Running build test: {}'.format(test_name))
+
+ build_dir = os.path.join(out_dir, test_name)
+ if os.path.isfile(os.path.join(test_dir, 'build.sh')):
+ return [run_build_sh_test(test_name, build_dir, test_dir, build_flags,
+ abi, platform)]
+ else:
+ return [run_ndk_build_test(test_name, build_dir, test_dir, build_flags,
+ abi, platform)]
+
+
+def copy_test_to_device(build_dir, device_dir, abi):
+ abi_dir = os.path.join(build_dir, 'libs', abi)
+ if not os.path.isdir(abi_dir):
+ raise RuntimeError('No libraries for {}'.format(abi))
+
+ test_cases = []
+ for test_file in os.listdir(abi_dir):
+ if test_file in ('gdbserver', 'gdb.setup'):
+ continue
+
+ if not test_file.endswith('.so'):
+ test_cases.append(test_file)
+
+ # TODO(danalbert): Libs with the same name will clobber each other.
+ # This was the case with the old shell based script too. I'm trying not
+ # to change too much in the translation.
+ lib_path = os.path.join(abi_dir, test_file)
+ adb.push(lib_path, device_dir)
+
+ # TODO(danalbert): Sync data.
+ # The libc++ tests contain a DATA file that lists test names and their
+ # dependencies on file system data. These files need to be copied to
+ # the device.
+
+ if len(test_cases) == 0:
+ raise RuntimeError('Could not find any test executables.')
+
+ return test_cases
+
+
+def run_device_test(out_dir, test_dir, build_flags, abi, platform):
+ test_name = os.path.basename(test_dir)
+ build_dir = os.path.join(out_dir, test_name)
+ build_result = run_ndk_build_test(test_name, build_dir, test_dir,
+ build_flags, abi, platform)
+ if not build_result.passed:
+ return [build_result]
+
+ device_dir = os.path.join('/data/local/tmp/ndk-tests', test_name)
+
+ result, out = adb.shell('mkdir -p {}'.format(device_dir))
+ if result != 0:
+ raise RuntimeError('mkdir failed:\n' + '\n'.join(out))
+
+ results = []
+ try:
+ test_cases = copy_test_to_device(build_dir, device_dir, abi)
+ for case in test_cases:
+ case_name = '.'.join([test_name, case])
+ if run_is_disabled(case, test_dir):
+ results.append(Skipped(case_name, 'run disabled'))
+ continue
+
+ cmd = 'cd {} && LD_LIBRARY_PATH={} ./{}'.format(
+ device_dir, device_dir, case)
+ result, out = adb.shell(cmd)
+ if result == 0:
+ results.append(Success(case_name))
+ else:
+ results.append(Failure(case_name, '\n'.join(out)))
+ return results
+ finally:
+ adb.shell('rm -rf {}'.format(device_dir))
+
+
+def run_tests(out_dir, test_dir, test_func):
+ results = []
+ for dentry in os.listdir(test_dir):
+ path = os.path.join(test_dir, dentry)
+ if os.path.isdir(path):
+ try:
+ results.extend(test_func(out_dir, path))
+ except RuntimeError as ex:
+ results.append(Failure(os.path.basename(dentry), ex))
+ return results
+
+
+def get_test_device():
+ if distutils.spawn.find_executable('adb') is None:
+ raise RuntimeError('Could not find adb.')
+
+ p = subprocess.Popen(['adb', 'devices'], stdout=subprocess.PIPE)
+ out, _ = p.communicate()
+ if p.returncode != 0:
+ raise RuntimeError('Failed to get list of devices from adb.')
+
+ # The first line of `adb devices` just says "List of attached devices", so
+ # skip that.
+ devices = []
+ for line in out.split('\n')[1:]:
+ if not line.strip():
+ continue
+ if 'offline' in line:
+ continue
+
+ serial, _ = re.split(r'\s+', line, maxsplit=1)
+ devices.append(serial)
+
+ if len(devices) == 0:
+ raise RuntimeError('No devices detected.')
+
+ device = os.getenv('ANDROID_SERIAL')
+ if device is None and len(devices) == 1:
+ device = devices[0]
+
+ if device is not None and device not in devices:
+ raise RuntimeError('Device {} is not available.'.format(device))
+
+ # TODO(danalbert): Handle running against multiple devices in one pass.
+ if len(devices) > 1 and device is None:
+ raise RuntimeError('Multiple devices detected and ANDROID_SERIAL not '
+ 'set. Cannot continue.')
+
+ return device
+
+
+def get_device_abis():
+ abis = [adb.get_prop('ro.product.cpu.abi')]
+ abi2 = adb.get_prop('ro.product.cpu.abi2')
+ if abi2 is not None:
+ abis.append(abi2)
+ return abis
+
+
+def check_adb_works_or_die(abi):
+ # TODO(danalbert): Check that we can do anything with the device.
+ try:
+ device = get_test_device()
+ except RuntimeError as ex:
+ sys.exit('Error: {}'.format(ex))
+
+ if abi is not None and abi not in get_device_abis():
+ sys.exit('The test device ({}) does not support the requested ABI '
+ '({})'.format(device, abi))
+
+
+def is_valid_platform_version(version_string):
+ match = re.match(r'^android-(\d+)$', version_string)
+ if not match:
+ return False
+
+ # We don't support anything before Gingerbread.
+ version = int(match.group(1))
+ return version >= 9
+
+
+def android_platform_version(version_string):
+ if is_valid_platform_version(version_string):
+ return version_string
+ else:
+ raise argparse.ArgumentTypeError(
+ 'Platform version must match the format "android-VERSION", where '
+ 'VERSION >= 9.')
+
+
+class ArgParser(argparse.ArgumentParser):
+ def __init__(self):
+ super(ArgParser, self).__init__(
+ description=inspect.getdoc(sys.modules[__name__]))
+
+ self.add_argument(
+ '--abi', default=None, choices=SUPPORTED_ABIS,
+ help=('Run tests against the specified ABI. Defaults to the '
+ 'contents of APP_ABI in jni/Application.mk'))
+ self.add_argument(
+ '--platform', default=None, type=android_platform_version,
+ help=('Run tests against the specified platform version. Defaults '
+ 'to the contents of APP_PLATFORM in jni/Application.mk'))
+ self.add_argument(
+ '--show-commands', action='store_true',
+ help='Show build commands for each test.')
+ self.add_argument(
+ '--suite', default=None,
+ choices=('awk', 'build', 'device', 'samples'),
+ help=('Run only the chosen test suite.'))
+
+ self.add_argument(
+ '--quick', action='store_true', help='Skip long running tests.')
+ self.add_argument(
+ '--show-all', action='store_true',
+ help='Show all test results, not just failures.')
+
+
+def main():
+ os.chdir(os.path.dirname(os.path.realpath(__file__)))
+
+ args = ArgParser().parse_args()
+ ndk_build_flags = []
+ if args.abi is not None:
+ ndk_build_flags.append('APP_ABI={}'.format(args.abi))
+ if args.platform is not None:
+ ndk_build_flags.append('APP_PLATFORM={}'.format(args.platform))
+ if args.show_commands:
+ ndk_build_flags.append('V=1')
+
+ if not os.path.exists(os.path.join('../build/tools/prebuilt-common.sh')):
+ sys.exit('Error: Not run from a valid NDK.')
+
+ out_dir = 'out'
+ if os.path.exists(out_dir):
+ shutil.rmtree(out_dir)
+ os.makedirs(out_dir)
+
+ my_run_build_test = functools.partial(run_build_test,
+ build_flags=ndk_build_flags,
+ abi=args.abi,
+ platform=args.platform)
+
+ my_run_device_test = functools.partial(run_device_test,
+ build_flags=ndk_build_flags,
+ abi=args.abi,
+ platform=args.platform)
+
+ suites = ['awk', 'build', 'device', 'samples']
+ if args.suite:
+ suites = [args.suite]
+
+ # Do this early so we find any device issues now rather than after we've
+ # run all the build tests.
+ if 'device' in suites:
+ check_adb_works_or_die(args.abi)
+
+ os.environ['ANDROID_SERIAL'] = get_test_device()
+
+ results = {suite: [] for suite in suites}
+ if 'awk' in suites:
+ results['awk'] = run_tests(out_dir, 'awk', run_awk_test)
+ if 'build' in suites:
+ results['build'] = run_tests(out_dir, 'build', my_run_build_test)
+ if 'samples' in suites:
+ results['samples'] = run_tests(out_dir, '../samples',
+ my_run_build_test)
+ if 'device' in suites:
+ results['device'] = run_tests(out_dir, 'device', my_run_device_test)
+
+ num_tests = sum(len(s) for s in results.values())
+ zero_stats = {'pass': 0, 'skip': 0, 'fail': 0}
+ stats = {suite: dict(zero_stats) for suite in suites}
+ global_stats = dict(zero_stats)
+ for suite, test_results in results.items():
+ for result in test_results:
+ if isinstance(result, Failure):
+ stats[suite]['fail'] += 1
+ global_stats['fail'] += 1
+ if isinstance(result, Skipped):
+ stats[suite]['skip'] += 1
+ global_stats['skip'] += 1
+ if isinstance(result, Success):
+ stats[suite]['pass'] += 1
+ global_stats['pass'] += 1
+
+ def format_stats(num_tests, stats, use_color):
+ return '{pl} {p}/{t} {fl} {f}/{t} {sl} {s}/{t}'.format(
+ pl=color_string('PASS', 'green') if use_color else 'PASS',
+ fl=color_string('FAIL', 'red') if use_color else 'FAIL',
+ sl=color_string('SKIP', 'yellow') if use_color else 'SKIP',
+ p=stats['pass'], f=stats['fail'],
+ s=stats['skip'], t=num_tests)
+
+ use_color = sys.stdin.isatty()
+ print()
+ print(format_stats(num_tests, global_stats, use_color))
+ for suite, test_results in results.items():
+ stats_str = format_stats(len(test_results), stats[suite], use_color)
+ print()
+ print('{}: {}'.format(suite, stats_str))
+ for result in test_results:
+ if args.show_all or isinstance(result, Failure):
+ print(result.to_string(colored=use_color))
+
+ sys.exit(global_stats['fail'] == 0)
+
+
+if __name__ == '__main__':
+ main()