diff options
author | msw <msw@chromium.org> | 2015-04-22 16:32:11 -0700 |
---|---|---|
committer | Commit bot <commit-bot@chromium.org> | 2015-04-22 23:33:17 +0000 |
commit | ad5ece3abccd3aeb6b2abd0d5b61d7036b342c8b (patch) | |
tree | be47dc70b934f63a61643f53570ddbabd400ddbf /mojo | |
parent | 7b9ba2e6b4f149845360a6e0f3fa4b0ac459e6be (diff) | |
download | chromium_src-ad5ece3abccd3aeb6b2abd0d5b61d7036b342c8b.zip chromium_src-ad5ece3abccd3aeb6b2abd0d5b61d7036b342c8b.tar.gz chromium_src-ad5ece3abccd3aeb6b2abd0d5b61d7036b342c8b.tar.bz2 |
Update apptest_runner.py from the Mojo repository.
This adds Android support and optional fixture isolation.
It also has many fixes/improvements over our old script.
It also supports dart apptests, but we don't need that.
Patch Set 3 is a straight copy of needed Mojo repo files.
Patch Set 4+ diverges as needed from the Mojo scripts.
(nix --args-for & devtools.py; mv android.py; GN is_debug hack)
TODO: Optionally flatten/nix dependencies and dart support.
TODO: Use the build_dir arg instead of rebuilding it from gn args.
BUG=479230
TEST=Bot continues passing apptests step.
R=ben@chromium.org
Review URL: https://codereview.chromium.org/996523003
Cr-Commit-Position: refs/heads/master@{#326399}
Diffstat (limited to 'mojo')
-rwxr-xr-x | mojo/tools/apptest_runner.py | 110 | ||||
-rw-r--r-- | mojo/tools/data/apptests | 29 | ||||
-rw-r--r-- | mojo/tools/gtest.py | 107 | ||||
-rw-r--r-- | mojo/tools/mopy/android.py | 503 | ||||
-rw-r--r-- | mojo/tools/mopy/config.py | 2 | ||||
-rw-r--r-- | mojo/tools/mopy/dart_apptest.py | 38 | ||||
-rw-r--r-- | mojo/tools/mopy/gn.py | 6 | ||||
-rw-r--r-- | mojo/tools/mopy/gtest.py | 132 | ||||
-rw-r--r-- | mojo/tools/mopy/log.py | 29 | ||||
-rw-r--r-- | mojo/tools/mopy/paths.py | 5 | ||||
-rw-r--r-- | mojo/tools/mopy/print_process_error.py | 22 | ||||
-rw-r--r-- | mojo/tools/mopy/test_util.py | 95 |
12 files changed, 734 insertions, 344 deletions
diff --git a/mojo/tools/apptest_runner.py b/mojo/tools/apptest_runner.py index 9ef7b7a..ddc0830 100755 --- a/mojo/tools/apptest_runner.py +++ b/mojo/tools/apptest_runner.py @@ -1,81 +1,91 @@ #!/usr/bin/env python -# Copyright 2015 The Chromium Authors. All rights reserved. +# Copyright 2014 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 test runner for gtest application tests.""" import argparse -import ast import logging -import os import sys -_logging = logging.getLogger() +from mopy import dart_apptest +from mopy import gtest +# TODO(msw): Mojo's script pulls in android.py via mojo/devtools/common/pylib. +from mopy.android import AndroidShell +from mopy.config import Config +from mopy.gn import ConfigForGNArgs, ParseGNConfig +from mopy.log import InitLogging +from mopy.paths import Paths -import gtest + +_logger = logging.getLogger() def main(): - logging.basicConfig() - # Uncomment to debug: - #_logging.setLevel(logging.DEBUG) + parser = argparse.ArgumentParser(description="A test runner for application " + "tests.") + + parser.add_argument("--verbose", help="be verbose (multiple times for more)", + default=0, dest="verbose_count", action="count") + parser.add_argument("test_list_file", type=file, + help="a file listing apptests to run") + parser.add_argument("build_dir", type=str, + help="the build output directory") + args = parser.parse_args() - parser = argparse.ArgumentParser(description='A test runner for gtest ' - 'application tests.') + InitLogging(args.verbose_count) + config = ConfigForGNArgs(ParseGNConfig(args.build_dir)) - parser.add_argument('apptest_list_file', type=file, - help='A file listing apptests to run.') - parser.add_argument('build_dir', type=str, - help='The build output directory.') - args = parser.parse_args() + _logger.debug("Test list file: %s", args.test_list_file) + execution_globals = {"config": config} + exec args.test_list_file in execution_globals + test_list = execution_globals["tests"] + _logger.debug("Test list: %s" % test_list) - apptest_list = ast.literal_eval(args.apptest_list_file.read()) - _logging.debug("Test list: %s" % apptest_list) + extra_args = [] + if config.target_os == Config.OS_ANDROID: + paths = Paths(config) + shell = AndroidShell(paths.target_mojo_shell_path, paths.build_dir, + paths.adb_path) + extra_args.extend(shell.PrepareShellRun(fixed_port=False)) + else: + shell = None gtest.set_color() - mojo_shell_path = os.path.join(args.build_dir, "mojo_shell") exit_code = 0 - for apptest_dict in apptest_list: - if apptest_dict.get("disabled"): - continue - - apptest = apptest_dict["test"] - apptest_args = apptest_dict.get("test-args", []) - shell_args = apptest_dict.get("shell-args", []) - - print "Running " + apptest + "...", + for test_dict in test_list: + test = test_dict["test"] + test_name = test_dict.get("name", test) + test_type = test_dict.get("type", "gtest") + test_args = test_dict.get("test-args", []) + shell_args = test_dict.get("shell-args", []) + extra_args + + _logger.info("Will start: %s" % test_name) + print "Running %s...." % test_name, sys.stdout.flush() - # List the apptest fixtures so they can be run independently for isolation. - # TODO(msw): Run some apptests without fixture isolation? - fixtures = gtest.get_fixtures(mojo_shell_path, apptest) - - if not fixtures: - print "Failed with no tests found." + if test_type == "dart": + apptest_result = dart_apptest.run_test(config, shell, test_dict, + shell_args, {test: test_args}) + elif test_type == "gtest": + apptest_result = gtest.run_fixtures(config, shell, test_dict, + test, False, + test_args, shell_args) + elif test_type == "gtest_isolated": + apptest_result = gtest.run_fixtures(config, shell, test_dict, + test, True, test_args, shell_args) + else: + apptest_result = "Invalid test type in %r" % test_dict + + if apptest_result != "Succeeded": exit_code = 1 - continue - - apptest_result = "Succeeded" - for fixture in fixtures: - args_for_apptest = " ".join(["--gtest_filter=" + fixture] + apptest_args) - - success = RunApptestInShell(mojo_shell_path, apptest, - shell_args + [args_for_apptest]) - - if not success: - apptest_result = "Failed test(s) in %r" % apptest_dict - exit_code = 1 - print apptest_result + _logger.info("Completed: %s" % test_name) return exit_code -def RunApptestInShell(mojo_shell_path, apptest, shell_args): - return gtest.run_test([mojo_shell_path, apptest] + shell_args) - - if __name__ == '__main__': sys.exit(main()) diff --git a/mojo/tools/data/apptests b/mojo/tools/data/apptests index 44ed41f..06ac22d 100644 --- a/mojo/tools/data/apptests +++ b/mojo/tools/data/apptests @@ -1,6 +1,30 @@ # This file contains a list of Mojo gtest unit tests. -# This must be a valid python dictionary. -[ +# +# This must be valid Python. It may use the |config| global that will be a +# mopy.config.Config object, and must set a |tests| global that will contain the +# list of tests to run. +# +# The entries in |tests| are dictionaries of the following form: +# { +# # Required URL for apptest. +# "test": "mojo:test_app_url", +# # Optional display name (otherwise the entry for "test" above is used). +# "name": "mojo:test_app_url (more details)", +# # Optional test type. Valid values: +# # * "gtest" (default) +# # * "gtest_isolated": like "gtest", but run with fixture isolation, +# # i.e., each test in a fresh mojo_shell) +# # * "dart". +# "type": "gtest", +# # Optional arguments for the apptest. +# "test-args": ["--an_arg", "another_arg"], +# # Optional arguments for the shell. +# "shell-args": ["--some-flag-for-the-shell", "--another-flag"], +# } +# +# TODO(vtl|msw): Add a way of specifying data dependencies. + +tests = [ { "test": "mojo:clipboard_apptests", }, @@ -17,6 +41,7 @@ #}, { "test": "mojo:view_manager_apptests", + "type": "gtest_isolated", "shell-args": ["--url-mappings=mojo:window_manager=mojo:test_window_manager"] }, { diff --git a/mojo/tools/gtest.py b/mojo/tools/gtest.py deleted file mode 100644 index d84c8e5..0000000 --- a/mojo/tools/gtest.py +++ /dev/null @@ -1,107 +0,0 @@ -# Copyright 2015 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. - -import logging -import os -import re -import subprocess -import sys - -_logging = logging.getLogger() - -def _print_process_error(command_line, error): - """Properly format an exception raised from a failed command execution.""" - - if command_line: - print 'Failed command: %r' % command_line - else: - print 'Failed command:' - print 72 * '-' - - if hasattr(error, 'returncode'): - print ' with exit code %d' % error.returncode - print 72 * '-' - - if hasattr(error, 'output'): - print error.output - else: - print error - print 72 * '-' - -def set_color(): - """Run gtests with color if we're on a TTY (and we're not being told - explicitly what to do).""" - if sys.stdout.isatty() and 'GTEST_COLOR' not in os.environ: - _logging.debug("Setting GTEST_COLOR=yes") - os.environ['GTEST_COLOR'] = 'yes' - - -def _try_command_line(command_line): - """Returns the output of a command line or an empty string on error.""" - _logging.debug("Running command line: %s" % command_line) - try: - return subprocess.check_output(command_line, stderr=subprocess.STDOUT) - except Exception as e: - _print_process_error(command_line, e) - return None - - -def run_test(command_line): - """Runs a command line and checks the output for signs of gtest failure.""" - output = _try_command_line(command_line) - # Fail on output with gtest's "[ FAILED ]" or a lack of "[ PASSED ]". - # The latter condition ensures failure on broken command lines or output. - # Check output instead of exit codes because mojo_shell always exits with 0. - if (output is None or - (output.find("[ FAILED ]") != -1 or output.find("[ PASSED ]") == -1)): - print "Failed test:" - _print_process_error(command_line, output) - return False - _logging.debug("Succeeded with output:\n%s" % output) - return True - - -def get_fixtures(mojo_shell, apptest): - """Returns the "Test.Fixture" list from an apptest using mojo_shell. - - Tests are listed by running the given apptest in mojo_shell and passing - --gtest_list_tests. The output is parsed and reformatted into a list like - [TestSuite.TestFixture, ... ] - An empty list is returned on failure, with errors logged. - """ - command = [mojo_shell, "--gtest_list_tests", apptest] - try: - list_output = subprocess.check_output(command, stderr=subprocess.STDOUT) - _logging.debug("Tests listed:\n%s" % list_output) - return _gtest_list_tests(list_output) - except Exception as e: - print "Failed to get test fixtures:" - _print_process_error(command, e) - return [] - - -def _gtest_list_tests(gtest_list_tests_output): - """Returns a list of strings formatted as TestSuite.TestFixture from the - output of running --gtest_list_tests on a GTEST application.""" - - # Remove log lines. - gtest_list_tests_output = ( - re.sub("^\[.*\n", "", gtest_list_tests_output, flags=re.MULTILINE)) - - if not re.match("^(\w*\.\r?\n( \w*\r?\n)+)+", gtest_list_tests_output): - raise Exception("Unrecognized --gtest_list_tests output:\n%s" % - gtest_list_tests_output) - - output_lines = gtest_list_tests_output.split('\n') - - test_list = [] - for line in output_lines: - if not line: - continue - if line[0] != ' ': - suite = line.strip() - continue - test_list.append(suite + line.strip()) - - return test_list diff --git a/mojo/tools/mopy/android.py b/mojo/tools/mopy/android.py index 6584b9f..374ebb08 100644 --- a/mojo/tools/mopy/android.py +++ b/mojo/tools/mopy/android.py @@ -3,8 +3,13 @@ # found in the LICENSE file. import atexit +import datetime +import email.utils +import hashlib +import itertools import json import logging +import math import os import os.path import random @@ -17,9 +22,6 @@ import urlparse import SimpleHTTPServer import SocketServer -from mopy.config import Config -from mopy.paths import Paths - # Tags used by the mojo shell application logs. LOGCAT_TAGS = [ @@ -31,11 +33,30 @@ LOGCAT_TAGS = [ 'chromium', ] -ADB_PATH = os.path.join(Paths().src_root, 'third_party', 'android_tools', 'sdk', - 'platform-tools', 'adb') - MOJO_SHELL_PACKAGE_NAME = 'org.chromium.mojo.shell' +MAPPING_PREFIX = '--map-origin=' + +DEFAULT_BASE_PORT = 31337 + +ZERO = datetime.timedelta(0) + +class UTC_TZINFO(datetime.tzinfo): + """UTC time zone representation.""" + + def utcoffset(self, _): + return ZERO + + def tzname(self, _): + return "UTC" + + def dst(self, _): + return ZERO + +UTC = UTC_TZINFO() + +_logger = logging.getLogger() + class _SilentTCPServer(SocketServer.TCPServer): """ @@ -58,6 +79,69 @@ def _GetHandlerClassForPath(base_path): |base_path| directory over http. """ + def __init__(self, *args, **kwargs): + self.etag = None + SimpleHTTPServer.SimpleHTTPRequestHandler.__init__(self, *args, **kwargs) + + def get_etag(self): + if self.etag: + return self.etag + + path = self.translate_path(self.path) + if not os.path.isfile(path): + return None + + sha256 = hashlib.sha256() + BLOCKSIZE = 65536 + with open(path, 'rb') as hashed: + buf = hashed.read(BLOCKSIZE) + while len(buf) > 0: + sha256.update(buf) + buf = hashed.read(BLOCKSIZE) + self.etag = '"%s"' % sha256.hexdigest() + return self.etag + + def send_head(self): + # Always close the connection after each request, as the keep alive + # support from SimpleHTTPServer doesn't like when the client requests to + # close the connection before downloading the full response content. + # pylint: disable=W0201 + self.close_connection = 1 + + path = self.translate_path(self.path) + if os.path.isfile(path): + # Handle If-None-Match + etag = self.get_etag() + if ('If-None-Match' in self.headers and + etag == self.headers['If-None-Match']): + self.send_response(304) + return None + + # Handle If-Modified-Since + if ('If-None-Match' not in self.headers and + 'If-Modified-Since' in self.headers): + last_modified = datetime.datetime.fromtimestamp( + math.floor(os.stat(path).st_mtime), tz=UTC) + ims = datetime.datetime( + *email.utils.parsedate(self.headers['If-Modified-Since'])[:6], + tzinfo=UTC) + if last_modified <= ims: + self.send_response(304) + return None + + return SimpleHTTPServer.SimpleHTTPRequestHandler.send_head(self) + + def end_headers(self): + path = self.translate_path(self.path) + + if os.path.isfile(path): + etag = self.get_etag() + if etag: + self.send_header('ETag', etag) + self.send_header('Cache-Control', 'must-revalidate') + + return SimpleHTTPServer.SimpleHTTPRequestHandler.end_headers(self) + def translate_path(self, path): path_from_current = ( SimpleHTTPServer.SimpleHTTPRequestHandler.translate_path(self, path)) @@ -69,9 +153,26 @@ def _GetHandlerClassForPath(base_path): """ pass + RequestHandler.protocol_version = 'HTTP/1.1' return RequestHandler +def _IsMapOrigin(arg): + """Returns whether arg is a --map-origin argument.""" + return arg.startswith(MAPPING_PREFIX) + + +def _Split(l, pred): + positive = [] + negative = [] + for v in l: + if pred(v): + positive.append(v) + else: + negative.append(v) + return (positive, negative) + + def _ExitIfNeeded(process): """ Exits |process| if it is still alive. @@ -80,186 +181,228 @@ def _ExitIfNeeded(process): process.kill() -def _ReadFifo(fifo_path, pipe, on_fifo_closed, max_attempts=5): - """ - Reads |fifo_path| on the device and write the contents to |pipe|. Calls - |on_fifo_closed| when the fifo is closed. This method will try to find the - path up to |max_attempts|, waiting 1 second between each attempt. If it cannot - find |fifo_path|, a exception will be raised. +class AndroidShell(object): + """ Allows to set up and run a given mojo shell binary on an Android device. + + Args: + shell_apk_path: path to the shell Android binary + local_dir: directory where locally build Mojo apps will be served, optional + adb_path: path to adb, optional if adb is in PATH + target_device: device to run on, if multiple devices are connected """ - def Run(): - def _WaitForFifo(): - command = [ADB_PATH, 'shell', 'test -e "%s"; echo $?' % fifo_path] - for _ in xrange(max_attempts): - if subprocess.check_output(command)[0] == '0': - return - time.sleep(1) + def __init__( + self, shell_apk_path, local_dir=None, adb_path="adb", target_device=None): + self.shell_apk_path = shell_apk_path + self.adb_path = adb_path + self.local_dir = local_dir + self.target_device = target_device + + def _CreateADBCommand(self, args): + adb_command = [self.adb_path] + if self.target_device: + adb_command.extend(['-s', self.target_device]) + adb_command.extend(args) + return adb_command + + def _ReadFifo(self, fifo_path, pipe, on_fifo_closed, max_attempts=5): + """ + Reads |fifo_path| on the device and write the contents to |pipe|. Calls + |on_fifo_closed| when the fifo is closed. This method will try to find the + path up to |max_attempts|, waiting 1 second between each attempt. If it + cannot find |fifo_path|, a exception will be raised. + """ + fifo_command = self._CreateADBCommand( + ['shell', 'test -e "%s"; echo $?' % fifo_path]) + + def Run(): + def _WaitForFifo(): + for _ in xrange(max_attempts): + if subprocess.check_output(fifo_command)[0] == '0': + return + time.sleep(1) + if on_fifo_closed: + on_fifo_closed() + raise Exception("Unable to find fifo.") + _WaitForFifo() + stdout_cat = subprocess.Popen(self._CreateADBCommand([ + 'shell', + 'cat', + fifo_path]), + stdout=pipe) + atexit.register(_ExitIfNeeded, stdout_cat) + stdout_cat.wait() if on_fifo_closed: on_fifo_closed() - raise Exception("Unable to find fifo.") - _WaitForFifo() - stdout_cat = subprocess.Popen([ADB_PATH, - 'shell', - 'cat', - fifo_path], - stdout=pipe) - atexit.register(_ExitIfNeeded, stdout_cat) - stdout_cat.wait() - if on_fifo_closed: - on_fifo_closed() - - thread = threading.Thread(target=Run, name="StdoutRedirector") - thread.start() - - -def _MapPort(device_port, host_port): - """ - Maps the device port to the host port. If |device_port| is 0, a random - available port is chosen. Returns the device port. - """ - def _FindAvailablePortOnDevice(): - opened = subprocess.check_output([ADB_PATH, 'shell', 'netstat']) - opened = [int(x.strip().split()[3].split(':')[1]) - for x in opened if x.startswith(' tcp')] - while True: - port = random.randint(4096, 16384) - if port not in opened: - return port - if device_port == 0: - device_port = _FindAvailablePortOnDevice() - subprocess.Popen([ADB_PATH, - "reverse", - "tcp:%d" % device_port, - "tcp:%d" % host_port]).wait() - def _UnmapPort(): - subprocess.Popen([ADB_PATH, "reverse", "--remove", "tcp:%d" % device_port]) - atexit.register(_UnmapPort) - return device_port - - -def StartHttpServerForDirectory(path): - """Starts an http server serving files from |path|. Returns the local url.""" - print 'starting http for', path - httpd = _SilentTCPServer(('127.0.0.1', 0), _GetHandlerClassForPath(path)) - atexit.register(httpd.shutdown) - - http_thread = threading.Thread(target=httpd.serve_forever) - http_thread.daemon = True - http_thread.start() - - print 'local port=', httpd.server_address[1] - return 'http://127.0.0.1:%d/' % _MapPort(0, httpd.server_address[1]) - - -def PrepareShellRun(config, origin=None): - """ Prepares for StartShell: runs adb as root and installs the apk. If no - --origin is specified, local http server will be set up to serve files from - the build directory along with port forwarding. - - Returns arguments that should be appended to shell argument list.""" - build_dir = Paths(config).build_dir - - subprocess.check_call([ADB_PATH, 'root']) - apk_path = os.path.join(build_dir, 'apks', 'MojoShell.apk') - subprocess.check_call( - [ADB_PATH, 'install', '-r', apk_path, '-i', MOJO_SHELL_PACKAGE_NAME]) - atexit.register(StopShell) - - extra_shell_args = [] - origin_url = origin if origin else StartHttpServerForDirectory(build_dir) - extra_shell_args.append("--origin=" + origin_url) - - return extra_shell_args - - -def _StartHttpServerForOriginMapping(mapping): - """If |mapping| points at a local file starts an http server to serve files - from the directory and returns the new mapping. - - This is intended to be called for every --map-origin value.""" - parts = mapping.split('=') - if len(parts) != 2: - return mapping - dest = parts[1] - # If the destination is a url, don't map it. - if urlparse.urlparse(dest)[0]: - return mapping - # Assume the destination is a local file. Start a local server that redirects - # to it. - localUrl = StartHttpServerForDirectory(dest) - print 'started server at %s for %s' % (dest, localUrl) - return parts[0] + '=' + localUrl - - -def _StartHttpServerForOriginMappings(arg): - """Calls _StartHttpServerForOriginMapping for every --map-origin argument.""" - mapping_prefix = '--map-origin=' - if not arg.startswith(mapping_prefix): - return arg - return mapping_prefix + ','.join([_StartHttpServerForOriginMapping(value) - for value in arg[len(mapping_prefix):].split(',')]) - - -def StartShell(arguments, stdout=None, on_application_stop=None): - """ - Starts the mojo shell, passing it the given arguments. - The |arguments| list must contain the "--origin=" arg from PrepareShellRun. - If |stdout| is not None, it should be a valid argument for subprocess.Popen. - """ - STDOUT_PIPE = "/data/data/%s/stdout.fifo" % MOJO_SHELL_PACKAGE_NAME - - cmd = [ADB_PATH, - 'shell', - 'am', - 'start', - '-W', - '-S', - '-a', 'android.intent.action.VIEW', - '-n', '%s/.MojoShellActivity' % MOJO_SHELL_PACKAGE_NAME] - - parameters = [] - if stdout or on_application_stop: - subprocess.check_call([ADB_PATH, 'shell', 'rm', STDOUT_PIPE]) - parameters.append('--fifo-path=%s' % STDOUT_PIPE) - _ReadFifo(STDOUT_PIPE, stdout, on_application_stop) - # The origin has to be specified whether it's local or external. - assert any("--origin=" in arg for arg in arguments) - parameters += [_StartHttpServerForOriginMappings(arg) for arg in arguments] - - if parameters: - encodedParameters = json.dumps(parameters) - cmd += [ '--es', 'encodedParameters', encodedParameters] - - with open(os.devnull, 'w') as devnull: - subprocess.Popen(cmd, stdout=devnull).wait() - - -def StopShell(): - """ - Stops the mojo shell. - """ - subprocess.check_call( - [ADB_PATH, 'shell', 'am', 'force-stop', MOJO_SHELL_PACKAGE_NAME]) + thread = threading.Thread(target=Run, name="StdoutRedirector") + thread.start() + def _MapPort(self, device_port, host_port): + """ + Maps the device port to the host port. If |device_port| is 0, a random + available port is chosen. Returns the device port. + """ + def _FindAvailablePortOnDevice(): + opened = subprocess.check_output( + self._CreateADBCommand(['shell', 'netstat'])) + opened = [int(x.strip().split()[3].split(':')[1]) + for x in opened if x.startswith(' tcp')] + while True: + port = random.randint(4096, 16384) + if port not in opened: + return port + if device_port == 0: + device_port = _FindAvailablePortOnDevice() + subprocess.Popen(self._CreateADBCommand([ + "reverse", + "tcp:%d" % device_port, + "tcp:%d" % host_port])).wait() + + unmap_command = self._CreateADBCommand(["reverse", "--remove", + "tcp:%d" % device_port]) + + def _UnmapPort(): + subprocess.Popen(unmap_command) + atexit.register(_UnmapPort) + return device_port + + def _StartHttpServerForDirectory(self, path, port=0): + """Starts an http server serving files from |path|. Returns the local + url.""" + assert path + print 'starting http for', path + httpd = _SilentTCPServer(('127.0.0.1', 0), _GetHandlerClassForPath(path)) + atexit.register(httpd.shutdown) + + http_thread = threading.Thread(target=httpd.serve_forever) + http_thread.daemon = True + http_thread.start() + + print 'local port=%d' % httpd.server_address[1] + return 'http://127.0.0.1:%d/' % self._MapPort(port, httpd.server_address[1]) + + def _StartHttpServerForOriginMapping(self, mapping, port): + """If |mapping| points at a local file starts an http server to serve files + from the directory and returns the new mapping. + + This is intended to be called for every --map-origin value.""" + parts = mapping.split('=') + if len(parts) != 2: + return mapping + dest = parts[1] + # If the destination is a url, don't map it. + if urlparse.urlparse(dest)[0]: + return mapping + # Assume the destination is a local file. Start a local server that + # redirects to it. + localUrl = self._StartHttpServerForDirectory(dest, port) + print 'started server at %s for %s' % (dest, localUrl) + return parts[0] + '=' + localUrl + + def _StartHttpServerForOriginMappings(self, map_parameters, fixed_port): + """Calls _StartHttpServerForOriginMapping for every --map-origin + argument.""" + if not map_parameters: + return [] + + original_values = list(itertools.chain( + *map(lambda x: x[len(MAPPING_PREFIX):].split(','), map_parameters))) + sorted(original_values) + result = [] + for i, value in enumerate(original_values): + result.append(self._StartHttpServerForOriginMapping( + value, DEFAULT_BASE_PORT + 1 + i if fixed_port else 0)) + return [MAPPING_PREFIX + ','.join(result)] + + def PrepareShellRun(self, origin=None, fixed_port=True): + """ Prepares for StartShell: runs adb as root and installs the apk. If no + --origin is specified, local http server will be set up to serve files from + the build directory along with port forwarding. + + Returns arguments that should be appended to shell argument list.""" + if 'cannot run as root' in subprocess.check_output( + self._CreateADBCommand(['root'])): + raise Exception("Unable to run adb as root.") + subprocess.check_call( + self._CreateADBCommand(['install', '-r', self.shell_apk_path, '-i', + MOJO_SHELL_PACKAGE_NAME])) + atexit.register(self.StopShell) + + extra_shell_args = [] + origin_url = origin if origin else self._StartHttpServerForDirectory( + self.local_dir, DEFAULT_BASE_PORT if fixed_port else 0) + extra_shell_args.append("--origin=" + origin_url) + + return extra_shell_args + + def StartShell(self, + arguments, + stdout=None, + on_application_stop=None, + fixed_port=True): + """ + Starts the mojo shell, passing it the given arguments. -def CleanLogs(): - """ - Cleans the logs on the device. - """ - subprocess.check_call([ADB_PATH, 'logcat', '-c']) + The |arguments| list must contain the "--origin=" arg from PrepareShellRun. + If |stdout| is not None, it should be a valid argument for subprocess.Popen. + """ + STDOUT_PIPE = "/data/data/%s/stdout.fifo" % MOJO_SHELL_PACKAGE_NAME + + cmd = self._CreateADBCommand([ + 'shell', + 'am', + 'start', + '-S', + '-a', 'android.intent.action.VIEW', + '-n', '%s/.MojoShellActivity' % MOJO_SHELL_PACKAGE_NAME]) + + parameters = [] + if stdout or on_application_stop: + subprocess.check_call(self._CreateADBCommand( + ['shell', 'rm', STDOUT_PIPE])) + parameters.append('--fifo-path=%s' % STDOUT_PIPE) + self._ReadFifo(STDOUT_PIPE, stdout, on_application_stop) + # The origin has to be specified whether it's local or external. + assert any("--origin=" in arg for arg in arguments) + + # Extract map-origin arguments. + map_parameters, other_parameters = _Split(arguments, _IsMapOrigin) + parameters += other_parameters + parameters += self._StartHttpServerForOriginMappings(map_parameters, + fixed_port) + + if parameters: + encodedParameters = json.dumps(parameters) + cmd += ['--es', 'encodedParameters', encodedParameters] + + with open(os.devnull, 'w') as devnull: + subprocess.Popen(cmd, stdout=devnull).wait() + + def StopShell(self): + """ + Stops the mojo shell. + """ + subprocess.check_call(self._CreateADBCommand(['shell', + 'am', + 'force-stop', + MOJO_SHELL_PACKAGE_NAME])) + def CleanLogs(self): + """ + Cleans the logs on the device. + """ + subprocess.check_call(self._CreateADBCommand(['logcat', '-c'])) -def ShowLogs(): - """ - Displays the log for the mojo shell. + def ShowLogs(self): + """ + Displays the log for the mojo shell. - Returns the process responsible for reading the logs. - """ - logcat = subprocess.Popen([ADB_PATH, - 'logcat', - '-s', - ' '.join(LOGCAT_TAGS)], - stdout=sys.stdout) - atexit.register(_ExitIfNeeded, logcat) - return logcat + Returns the process responsible for reading the logs. + """ + logcat = subprocess.Popen(self._CreateADBCommand([ + 'logcat', + '-s', + ' '.join(LOGCAT_TAGS)]), + stdout=sys.stdout) + atexit.register(_ExitIfNeeded, logcat) + return logcat diff --git a/mojo/tools/mopy/config.py b/mojo/tools/mopy/config.py index 7c8cadd..796a328 100644 --- a/mojo/tools/mopy/config.py +++ b/mojo/tools/mopy/config.py @@ -6,8 +6,6 @@ "defines" the schema and provides some wrappers.""" -import json -import os.path import platform import sys diff --git a/mojo/tools/mopy/dart_apptest.py b/mojo/tools/mopy/dart_apptest.py new file mode 100644 index 0000000..5e12b17 --- /dev/null +++ b/mojo/tools/mopy/dart_apptest.py @@ -0,0 +1,38 @@ +# Copyright 2014 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. + +import logging + +_logging = logging.getLogger() + +from mopy import test_util +from mopy.print_process_error import print_process_error + + +# TODO(erg): Support android, launched services and fixture isolation. +def run_test(config, shell, apptest_dict, shell_args, apps_and_args=None): + """Runs a command line and checks the output for signs of gtest failure. + + Args: + config: The mopy.config.Config object for the build. + shell_args: The arguments for mojo_shell. + apps_and_args: A Dict keyed by application URL associated to the + application's specific arguments. + """ + apps_and_args = apps_and_args or {} + output = test_util.try_run_test(config, shell, shell_args, apps_and_args) + # Fail on output with dart unittests' "FAIL:"/"ERROR:" or a lack of "PASS:". + # The latter condition ensures failure on broken command lines or output. + # Check output instead of exit codes because mojo_shell always exits with 0. + if (not output or + '\nFAIL: ' in output or + '\nERROR: ' in output or + '\nPASS: ' not in output): + print "Failed test:" + print_process_error( + test_util.build_command_line(config, shell_args, apps_and_args), + output) + return "Failed test(s) in %r" % apptest_dict + _logging.debug("Succeeded with output:\n%s" % output) + return "Succeeded" diff --git a/mojo/tools/mopy/gn.py b/mojo/tools/mopy/gn.py index 69ddff4..024e00f 100644 --- a/mojo/tools/mopy/gn.py +++ b/mojo/tools/mopy/gn.py @@ -132,4 +132,10 @@ def ParseGNConfig(build_dir): key = result.group(1) value = result.group(2) values[key] = ast.literal_eval(TRANSLATIONS.get(value, value)) + + # TODO(msw): Mojo's script uses GN's is_debug arg to determine the build dir. + # It should probably just use the argument build_dir instead. + if not "is_debug" in values: + values["is_debug"] = "Debug" in build_dir + return values diff --git a/mojo/tools/mopy/gtest.py b/mojo/tools/mopy/gtest.py new file mode 100644 index 0000000..1cde8ece --- /dev/null +++ b/mojo/tools/mopy/gtest.py @@ -0,0 +1,132 @@ +# Copyright 2014 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. + +import logging +import os +import re +import sys + +from mopy import test_util +from mopy.print_process_error import print_process_error + + +_logger = logging.getLogger() + + +def set_color(): + """Run gtests with color if we're on a TTY (and we're not being told + explicitly what to do).""" + if sys.stdout.isatty() and "GTEST_COLOR" not in os.environ: + _logger.debug("Setting GTEST_COLOR=yes") + os.environ["GTEST_COLOR"] = "yes" + +# TODO(vtl): The return value is bizarre. Should just make it either return +# True/False, or a list of failing fixtures. But the dart_apptest runner would +# also need to be updated in the same way. +def run_fixtures(config, shell, apptest_dict, apptest, isolate, test_args, + shell_args): + """Run the gtest fixtures in isolation.""" + + if not isolate: + if not RunApptestInShell(config, shell, apptest, test_args, shell_args): + return "Failed test(s) in %r" % apptest_dict + return "Succeeded" + + # List the apptest fixtures so they can be run independently for isolation. + fixtures = get_fixtures(config, shell, shell_args, apptest) + + if not fixtures: + return "Failed with no tests found." + + apptest_result = "Succeeded" + for fixture in fixtures: + apptest_args = test_args + ["--gtest_filter=%s" % fixture] + success = RunApptestInShell(config, shell, apptest, apptest_args, + shell_args) + + if not success: + apptest_result = "Failed test(s) in %r" % apptest_dict + + return apptest_result + + +def run_test(config, shell, shell_args, apps_and_args=None): + """Runs a command line and checks the output for signs of gtest failure. + + Args: + config: The mopy.config.Config object for the build. + shell_args: The arguments for mojo_shell. + apps_and_args: A Dict keyed by application URL associated to the + application's specific arguments. + """ + apps_and_args = apps_and_args or {} + output = test_util.try_run_test(config, shell, shell_args, apps_and_args) + # Fail on output with gtest's "[ FAILED ]" or a lack of "[ PASSED ]". + # The latter condition ensures failure on broken command lines or output. + # Check output instead of exit codes because mojo_shell always exits with 0. + if (output is None or + (output.find("[ FAILED ]") != -1 or output.find("[ PASSED ]") == -1)): + print "Failed test:" + print_process_error( + test_util.build_command_line(config, shell_args, apps_and_args), + output) + return False + _logger.debug("Succeeded with output:\n%s" % output) + return True + + +def get_fixtures(config, shell, shell_args, apptest): + """Returns the "Test.Fixture" list from an apptest using mojo_shell. + + Tests are listed by running the given apptest in mojo_shell and passing + --gtest_list_tests. The output is parsed and reformatted into a list like + [TestSuite.TestFixture, ... ] + An empty list is returned on failure, with errors logged. + + Args: + config: The mopy.config.Config object for the build. + apptest: The URL of the test application to run. + """ + try: + apps_and_args = {apptest: ["--gtest_list_tests"]} + list_output = test_util.run_test(config, shell, shell_args, apps_and_args) + _logger.debug("Tests listed:\n%s" % list_output) + return _gtest_list_tests(list_output) + except Exception as e: + print "Failed to get test fixtures:" + print_process_error( + test_util.build_command_line(config, shell_args, apps_and_args), e) + return [] + + +def _gtest_list_tests(gtest_list_tests_output): + """Returns a list of strings formatted as TestSuite.TestFixture from the + output of running --gtest_list_tests on a GTEST application.""" + + # Remove log lines. + gtest_list_tests_output = re.sub("^(\[|WARNING: linker:).*\n", + "", + gtest_list_tests_output, + flags=re.MULTILINE) + + if not re.match("^(\w*\.\r?\n( \w*\r?\n)+)+", gtest_list_tests_output): + raise Exception("Unrecognized --gtest_list_tests output:\n%s" % + gtest_list_tests_output) + + output_lines = gtest_list_tests_output.split("\n") + + test_list = [] + for line in output_lines: + if not line: + continue + if line[0] != " ": + suite = line.strip() + continue + test_list.append(suite + line.strip()) + + return test_list + + +def RunApptestInShell(config, shell, application, application_args, shell_args): + return run_test(config, shell, shell_args, {application: application_args}) diff --git a/mojo/tools/mopy/log.py b/mojo/tools/mopy/log.py new file mode 100644 index 0000000..af57232 --- /dev/null +++ b/mojo/tools/mopy/log.py @@ -0,0 +1,29 @@ +# Copyright 2015 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. + +"""Logging utilities, for use with the standard logging module.""" + + +import logging + + +def InitLogging(verbose_count): + """Ensures that the logger (obtained via logging.getLogger(), as usual) is + initialized, with the log level set as appropriate for |verbose_count| + instances of --verbose on the command line.""" + + assert(verbose_count >= 0) + if verbose_count == 0: + level = logging.WARNING + elif verbose_count == 1: + level = logging.INFO + else: # verbose_count >= 2 + level = logging.DEBUG + + logging.basicConfig(format="%(relativeCreated).3f:%(levelname)s:%(message)s") + logger = logging.getLogger() + logger.setLevel(level) + + logger.debug("Initialized logging: verbose_count=%d, level=%d" % + (verbose_count, level)) diff --git a/mojo/tools/mopy/paths.py b/mojo/tools/mopy/paths.py index 49e1c50..60181e8 100644 --- a/mojo/tools/mopy/paths.py +++ b/mojo/tools/mopy/paths.py @@ -16,6 +16,8 @@ class Paths(object): self.src_root = os.path.abspath(os.path.join(__file__, os.pardir, os.pardir, os.pardir, os.pardir)) self.mojo_dir = os.path.join(self.src_root, "mojo") + self.adb_path = os.path.join(self.src_root, 'third_party', 'android_tools', + 'sdk', 'platform-tools', 'adb') if config: self.build_dir = BuildDirectoryForConfig(config, self.src_root) @@ -25,13 +27,11 @@ class Paths(object): self.build_dir = None if self.build_dir is not None: - self.mojo_launcher_path = os.path.join(self.build_dir, "mojo_launcher") self.mojo_shell_path = os.path.join(self.build_dir, "mojo_shell") # TODO(vtl): Use the host OS here, since |config| may not be available. # In any case, if the target is Windows, but the host isn't, using # |os.path| isn't correct.... if Config.GetHostOS() == Config.OS_WINDOWS: - self.mojo_launcher_path += ".exe" self.mojo_shell_path += ".exe" if config and config.target_os == Config.OS_ANDROID: self.target_mojo_shell_path = os.path.join(self.build_dir, @@ -40,7 +40,6 @@ class Paths(object): else: self.target_mojo_shell_path = self.mojo_shell_path else: - self.mojo_launcher_path = None self.mojo_shell_path = None self.target_mojo_shell_path = None diff --git a/mojo/tools/mopy/print_process_error.py b/mojo/tools/mopy/print_process_error.py new file mode 100644 index 0000000..ec565d1 --- /dev/null +++ b/mojo/tools/mopy/print_process_error.py @@ -0,0 +1,22 @@ +# Copyright 2014 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. + +def print_process_error(command_line, error): + """Properly format an exception raised from a failed command execution.""" + + if command_line: + print 'Failed command: %r' % command_line + else: + print 'Failed command:' + print 72 * '-' + + if hasattr(error, 'returncode'): + print ' with exit code %d' % error.returncode + print 72 * '-' + + if hasattr(error, 'output'): + print error.output + else: + print error + print 72 * '-' diff --git a/mojo/tools/mopy/test_util.py b/mojo/tools/mopy/test_util.py new file mode 100644 index 0000000..4a4c128 --- /dev/null +++ b/mojo/tools/mopy/test_util.py @@ -0,0 +1,95 @@ +# Copyright 2015 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. + +import logging +import os +import subprocess +import time + +from mopy.config import Config +from mopy.paths import Paths +from mopy.print_process_error import print_process_error + + +_logger = logging.getLogger() + + +def build_shell_arguments(shell_args, apps_and_args=None): + """Build the list of arguments for the shell. |shell_args| are the base + arguments, |apps_and_args| is a dictionary that associates each application to + its specific arguments|. Each app included will be run by the shell. + """ + result = shell_args[:] + if apps_and_args: + # TODO(msw): Mojo's script uses --args-for; Chromium lacks support for that. + for app_and_args in apps_and_args.items(): + result += app_and_args[1] + result += apps_and_args.keys() + return result + + +def get_shell_executable(config): + paths = Paths(config=config) + if config.target_os == Config.OS_ANDROID: + return os.path.join(paths.src_root, "mojo", "tools", + "android_mojo_shell.py") + else: + return paths.mojo_shell_path + + +def build_command_line(config, shell_args, apps_and_args): + executable = get_shell_executable(config) + return "%s %s" % (executable, " ".join(["%r" % x for x in + build_shell_arguments( + shell_args, apps_and_args)])) + + +def run_test_android(shell, shell_args, apps_and_args): + """Run the given test on the single/default android device.""" + assert shell + (r, w) = os.pipe() + with os.fdopen(r, "r") as rf: + with os.fdopen(w, "w") as wf: + arguments = build_shell_arguments(shell_args, apps_and_args) + _logger.debug("Starting shell with arguments: %s" % arguments) + start_time = time.time() + # TODO(vtl): Do more logging in lower layers. + shell.StartShell(arguments, wf, wf.close, False) + rv = rf.read() + run_time = time.time() - start_time + _logger.debug("Shell completed") + # Only log if it took more than 3 seconds. + if run_time >= 3: + _logger.info("Shell test took %.3f seconds; arguments: %s" % + (run_time, arguments)) + return rv + + +def run_test(config, shell, shell_args, apps_and_args): + """Run the given test.""" + if (config.target_os == Config.OS_ANDROID): + return run_test_android(shell, shell_args, apps_and_args) + + executable = get_shell_executable(config) + command = ([executable] + build_shell_arguments(shell_args, apps_and_args)) + _logger.debug("Starting: %s" % " ".join(command)) + start_time = time.time() + rv = subprocess.check_output(command, stderr=subprocess.STDOUT) + run_time = time.time() - start_time + _logger.debug("Completed: %s" % " ".join(command)) + # Only log if it took more than 1 second. + if run_time >= 1: + _logger.info("Test took %.3f seconds: %s" % (run_time, " ".join(command))) + return rv + + +def try_run_test(config, shell, shell_args, apps_and_args): + """Returns the output of a command line or an empty string on error.""" + command_line = build_command_line(config, shell_args, apps_and_args) + _logger.debug("Running command line: %s" % command_line) + try: + return run_test(config, shell, shell_args, apps_and_args) + except Exception as e: + print_process_error(command_line, e) + return None |