diff --git a/ci/fireci/fireci/emulator.py b/ci/fireci/fireci/emulator.py index 831751b3f64..89edc4d65ca 100644 --- a/ci/fireci/fireci/emulator.py +++ b/ci/fireci/fireci/emulator.py @@ -12,19 +12,127 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import logging +import os +import signal +import subprocess +import time _logger = logging.getLogger('fireci.emulator') +EMULATOR_BINARY = 'emulator' +ADB_BINARY = 'adb' +EMULATOR_NAME = 'test' + +EMULATOR_FLAGS = ['-no-audio', '-no-window', '-skin', '768x1280'] + -# TODO(vkryachko): start/shutdown the emulator. class EmulatorHandler: + """ + Context manager that launches an android emulator for the duration of its execution. - def __init__(self, artifacts_dir): + As part of its run it: + * Launches the emulator + * Waits for it to boot + * Starts logcat to store on-device logs + * Produces stdout.log, stderr.log, logcat.log in the artifacts directory + """ + + def __init__( + self, + artifacts_dir, + *, + name=EMULATOR_NAME, + emulator_binary=EMULATOR_BINARY, + adb_binary=ADB_BINARY, + # for testing only + emulator_stdin=None, + wait_for_device_stdin=None, + logcat_stdin=None): self._artifacts_dir = artifacts_dir + log_dir = '{}_emulator'.format(name) + self._stdout = self._open(log_dir, 'stdout.log') + self._stderr = self._open(log_dir, 'stderr.log') + self._adb_log = self._open(log_dir, 'logcat.log') + self._name = name + + self._emulator_binary = emulator_binary + self._adb_binary = adb_binary + + self._emulator_stdin = emulator_stdin + self._wait_for_device_stdin = wait_for_device_stdin + self._logcat_stdin = logcat_stdin + def __enter__(self): - _logger.debug('Pretend to start the emulator(TODO)') + _logger.info('Starting avd "{}..."'.format(self._name)) + self._process = subprocess.Popen( + [self._emulator_binary, '-avd', self._name] + EMULATOR_FLAGS, + env=os.environ, + stdin=self._emulator_stdin, + stdout=self._stdout, + stderr=self._stderr) + try: + self._wait_for_boot(datetime.timedelta(minutes=5)) + except: + self._kill(self._process) + self._close_files() + raise + + self._logcat = subprocess.Popen( + [self._adb_binary, 'logcat'], + stdin=self._logcat_stdin, + stdout=self._adb_log, + ) def __exit__(self, exception_type, exception_value, traceback): - _logger.debug('Pretend to stop the emulator(TODO)') + _logger.info('Shutting down avd "{}"...'.format(self._name)) + self._kill(self._process) + _logger.info('Avd "{}" shut down.'.format(self._name)) + self._kill(self._logcat) + self._close_files() + + def _open(self, dirname, filename): + """Opens a file in a given directory, creates directory if required.""" + dirname = os.path.join(self._artifacts_dir, dirname) + if (not os.path.exists(dirname)): + os.makedirs(dirname) + return open(os.path.join(dirname, filename), 'w') + + def _wait_for_boot(self, timeout: datetime.timedelta): + _logger.info('Waiting for avd to boot...') + wait = subprocess.Popen( + [self._adb_binary, 'wait-for-device'], + stdin=self._wait_for_device_stdin, + stdout=self._stdout, + stderr=self._stderr, + ) + + start = datetime.datetime.now() + while self._process.poll() is None: + wait_exitcode = wait.poll() + if wait_exitcode is not None: + if wait_exitcode == 0: + _logger.info('Emulator booted successfully.') + return + raise RuntimeError("Waiting for emulator failed.") + + time.sleep(0.1) + now = datetime.datetime.now() + if now - start >= timeout: + self._kill(wait, sig=signal.SIGKILL) + raise RuntimeError("Emulator startup timed out.") + + self._kill(wait) + raise RuntimeError( + "Emulator failed to launch. See emulator logs for details.") + + def _kill(self, process, sig=signal.SIGTERM): + process.send_signal(sig) + process.wait() + + def _close_files(self): + for f in (self._stdout, self._stderr, self._adb_log): + if f is not None: + f.close() diff --git a/ci/fireci/fireci/internal.py b/ci/fireci/fireci/internal.py index 420bffcad95..84e88d9febc 100644 --- a/ci/fireci/fireci/internal.py +++ b/ci/fireci/fireci/internal.py @@ -51,12 +51,12 @@ def _artifact_handler(target_directory, artifact_patterns): @contextlib.contextmanager -def _emulator_handler(enabled, target_directory): +def _emulator_handler(enabled, *args, **kwargs): if not enabled: yield return - with emulator.EmulatorHandler(target_directory): + with emulator.EmulatorHandler(*args, **kwargs): yield @@ -71,14 +71,14 @@ class _CommonOptions: @click.option( '--artifact-target-dir', default='_artifacts', - help='Directory where artifacts will be symlinked to.', + help='Directory where artifacts will be copied to.', type=click.Path(dir_okay=True, resolve_path=True), ) @click.option( '--artifact-patterns', default=('**/build/test-results', '**/build/reports'), help= - 'Shell-style artifact patterns that are symlinked into `artifact-target-dir`.'\ + 'Shell-style artifact patterns that are copied into `artifact-target-dir`.'\ 'Can be specified multiple times.', multiple=True, type=str, @@ -89,6 +89,21 @@ class _CommonOptions: help='Specifies whether to start an Android emulator a command executes.', is_flag=True, ) +@click.option( + '--emulator-name', + default='test', + help='Specifies the AVD name to launch the emulator with.', +) +@click.option( + '--emulator-binary', + default='emulator', + help='Specifies the name/full path to the emulator binary.', +) +@click.option( + '--adb-binary', + default='adb', + help='Specifies the name/full path to the adb binary.', +) @_pass_options def main(options, **kwargs): """Main command group. @@ -120,7 +135,10 @@ def new_func(ctx, options, *args, **kwargs): with _artifact_handler(options.artifact_target_dir, options.artifact_patterns), _emulator_handler( options.with_emulator, - options.artifact_target_dir): + options.artifact_target_dir, + name=options.emulator_name, + emulator_binary=options.emulator_binary, + adb_binary=options.adb_binary): return ctx.invoke(f, *args, **kwargs) return functools.update_wrapper(new_func, f) diff --git a/ci/fireci/tests/emulator_test.py b/ci/fireci/tests/emulator_test.py new file mode 100644 index 00000000000..9d3a33ad0c2 --- /dev/null +++ b/ci/fireci/tests/emulator_test.py @@ -0,0 +1,148 @@ +# Copyright 2018 Google LLC +# +# 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. + +import io +import os +import pathlib +import threading +import time +import unittest + +from concurrent import futures + +from fireci.internal import _emulator_handler +from fireci.emulator import EMULATOR_FLAGS +from .fileutil import ( + Artifact, + create_artifacts, + in_tempdir, + with_env, +) +from . import scripts + + +class ProcessChannel: + """Write-only communication channel with a process through its stdin.""" + + def __init__(self, fd): + self._fd = fd + + def send(self, value, close=False): + os.write(self._fd, bytes(str(value), encoding='utf8')) + if close: + os.close(self._fd) + + +class EmulatorTests(unittest.TestCase): + executor = futures.ThreadPoolExecutor(max_workers=1) + + @in_tempdir + def test_emulator_when_not_requested_should_not_produce_logs(self): + with _emulator_handler(False): + pass + self.assertFalse(os.listdir(os.getcwd())) + + @in_tempdir + def test_emulator_when_emulator_fails_to_start_should_fail(self): + create_artifacts( + Artifact('emulator', content=scripts.waiting_for_status(), mode=0o744), + Artifact('adb', content=scripts.waiting_for_status(), mode=0o744), + ) + future, emulator, waiter, logcat = self._invoke_emulator( + './emulator', './adb') + + # emulator exits before wait-for-device + emulator.send(0, close=True) + + with self.assertRaisesRegex(RuntimeError, 'Emulator failed to launch'): + future.result(timeout=1) + + @in_tempdir + def test_emulator_when_wait_for_device_fails_should_fail(self): + create_artifacts( + Artifact('emulator', content=scripts.waiting_for_status(), mode=0o744), + Artifact('adb', content=scripts.waiting_for_status(), mode=0o744), + ) + + future, emulator, waiter, logcat = self._invoke_emulator( + './emulator', './adb') + + # wait-for-device fails + waiter.send(1, close=True) + + with self.assertRaisesRegex(RuntimeError, 'Waiting for emulator failed'): + future.result(timeout=1) + + @in_tempdir + def test_emulator_when_startup_succeeds_should_produce_expected_outputs(self): + create_artifacts( + Artifact('emulator', content=scripts.waiting_for_status(), mode=0o744), + Artifact('adb', content=scripts.waiting_for_status(), mode=0o744), + ) + + future, emulator, waiter, logcat = self._invoke_emulator( + './emulator', './adb') + + # wait-for-device succeeds + waiter.send(0, close=True) + + future.result(timeout=1) + + path = pathlib.Path('_artifacts') / 'test_emulator' + + stdout = path / 'stdout.log' + stderr = path / 'stderr.log' + logcat = path / 'logcat.log' + + for p in (stdout, stderr, logcat): + self.assertTrue(p.exists()) + self.assertTrue(p.is_file()) + + # both emulator and wait-for-device write to stdout.log + self._assert_file_contains( + stdout, './emulator -avd test {}\n./adb wait-for-device\n'.format( + ' '.join(EMULATOR_FLAGS))) + + # emulator writes to stderr.log + self._assert_file_contains(stderr, 'stderr\n' * 2) + + # logccat writes to logcat.log + self._assert_file_contains(logcat, './adb logcat\n') + + def _assert_file_contains(self, path, expected_contents): + with path.open() as f: + c = f.read() + self.assertEqual(c, expected_contents) + + def _invoke_emulator(self, emulator_binary, adb_binary): + emulator_stdin, emulator_channel = os.pipe() + wait_stdin, wait_channel = os.pipe() + logcat_stdin, logcat_channel = os.pipe() + + def handler(): + + with _emulator_handler( + True, + '_artifacts', + emulator_binary=emulator_binary, + adb_binary=adb_binary, + emulator_stdin=emulator_stdin, + wait_for_device_stdin=wait_stdin, + logcat_stdin=logcat_stdin, + ): + time.sleep(0.1) + + future = self.executor.submit(handler) + return future, ProcessChannel(emulator_channel), ProcessChannel( + wait_channel), ProcessChannel(logcat_channel) diff --git a/ci/fireci/tests/fileutil.py b/ci/fireci/tests/fileutil.py index d5dd370cb18..30becae00ac 100644 --- a/ci/fireci/tests/fileutil.py +++ b/ci/fireci/tests/fileutil.py @@ -58,3 +58,26 @@ def do_in_temp_dir(*args, **kwargs): shutil.rmtree(tempdir_path) return functools.update_wrapper(do_in_temp_dir, func) + + +def with_env(env, extend=True): + + def inner(func): + + def decorated(*args, **kwargs): + original_env = os.environ + new_env = original_env if extend else {} + expanded_env = { + os.path.expandvars(k): os.path.expandvars(v) + for (k, v) in env.items() + } + + os.environ = {**new_env, **expanded_env} + try: + func(*args, **kwargs) + finally: + os.environ = original_env + + return functools.update_wrapper(decorated, func) + + return inner diff --git a/ci/fireci/tests/integ_tests.py b/ci/fireci/tests/integ_tests.py index 34c42fc5bd4..6a3175773f6 100644 --- a/ci/fireci/tests/integ_tests.py +++ b/ci/fireci/tests/integ_tests.py @@ -79,7 +79,8 @@ def test_smoke_test_when_build_succeeds_and_tests_fails_should_fail(self): self.assertNotEqual(result.exit_code, 0) @in_tempdir - def test_smoke_test_when_build_succeeds_and_tests_succeed_should_succeed(self): + def test_smoke_test_when_build_succeeds_and_tests_succeed_should_succeed( + self): create_artifacts( Artifact('gradlew', content=scripts.with_exit(0), mode=0o744), Artifact('test-apps/gradlew', content=scripts.with_exit(0), mode=0o744), diff --git a/ci/fireci/tests/scripts.py b/ci/fireci/tests/scripts.py index 61e72ac10af..917cb36aa6b 100644 --- a/ci/fireci/tests/scripts.py +++ b/ci/fireci/tests/scripts.py @@ -58,3 +58,26 @@ def with_expected_arguments_and_artifacts(args, env, *artifacts): """Python script that checks its argv, environment and creates provided files/directories.""" arg_string = ', '.join(['"{}"'.format(arg) for arg in args]) return _SCRIPT_WITH_ARTIFACTS.format(arg_string, env, artifacts) + + +_SCRIPT_WAITING_FOR_STATUS = """\ +#!/bin/sh +'''true' +exec python3 -u "$0" "$@" +''' +import sys +print(' '.join(sys.argv), file=sys.stdout) +print('stderr', file=sys.stderr) +stdin = sys.stdin.read() +if stdin: + sys.exit(int(stdin)) +""" + + +def waiting_for_status(): + """Python script with unbuffered that: + * Prints argv to stdout + * Prints 'stderr to stderr + * Waits for exit status to be written to its stdin and exits with it. + """ + return _SCRIPT_WAITING_FOR_STATUS