# 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 Queue import re import subprocess import sys import threading import time from mopy.config import Config from mopy.paths import Paths THIS_DIR = os.path.dirname(os.path.abspath(__file__)) sys.path.append(os.path.join(THIS_DIR, '..', '..', '..', 'testing')) import xvfb sys.path.append(os.path.join(THIS_DIR, '..', '..', '..', 'tools', 'swarming_client', 'utils')) import subprocess42 # The DISPLAY ID number used for xvfb, incremented with each use. XVFB_DISPLAY_ID = 9 def run_apptest(config, shell, args, apptest, isolate): '''Run the apptest; optionally isolating fixtures across shell invocations. Returns the list of test fixtures run and the list of failed test fixtures. TODO(msw): Also return the list of DISABLED test fixtures. Args: config: The mopy.config.Config for the build. shell: The mopy.android.AndroidShell, if Android is the target platform. args: The arguments for the shell or apptest. apptest: The application test URL. isolate: True if the test fixtures should be run in isolation. ''' if not isolate: return _run_apptest_with_retry(config, shell, args, apptest) fixtures = _get_fixtures(config, shell, args, apptest) fixtures = [f for f in fixtures if not '.DISABLED_' in f] failed = [] for fixture in fixtures: arguments = args + ['--gtest_filter=%s' % fixture] failures = _run_apptest_with_retry(config, shell, arguments, apptest)[1] failed.extend(failures if failures != [apptest] else [fixture]) # Abort when 20 fixtures, or a tenth of the apptest fixtures, have failed. # base::TestLauncher does this for timeouts and unknown results. if len(failed) >= max(20, len(fixtures) / 10): print 'Too many failing fixtures (%d), exiting now.' % len(failed) return (fixtures, failed + [apptest + ' aborted for excessive failures.']) return (fixtures, failed) # TODO(msw): Determine proper test retry counts; allow configuration. def _run_apptest_with_retry(config, shell, args, apptest, retry_count=2): '''Runs an apptest, retrying on failure; returns the fixtures and failures.''' (tests, failed) = _run_apptest(config, shell, args, apptest) while failed and retry_count: print 'Retrying failed tests (%d attempts remaining)' % retry_count arguments = args # Retry only the failing fixtures if there is no existing filter specified. if (failed and ':'.join(failed) is not apptest and not any(a.startswith('--gtest_filter') for a in args)): arguments += ['--gtest_filter=%s' % ':'.join(failed)] failed = _run_apptest(config, shell, arguments, apptest)[1] retry_count -= 1 return (tests, failed) def _run_apptest(config, shell, args, apptest): '''Runs an apptest; returns the list of fixtures and the list of failures.''' command = _build_command_line(config, args, apptest) logging.getLogger().debug('Command: %s' % ' '.join(command)) start_time = time.time() try: out = _run_test_with_xvfb(config, shell, args, apptest) except Exception as e: _print_exception(command, e, int(round(1000 * (time.time() - start_time)))) return ([apptest], [apptest]) # Find all fixtures begun from gtest's '[ RUN ] ' output. tests = [x for x in out.split('\n') if x.find('[ RUN ] ') != -1] tests = [x.strip(' \t\n\r')[x.find('[ RUN ] ') + 13:] for x in tests] tests = tests or [apptest] # Fail on output with gtest's '[ FAILED ]' or a lack of '[ OK ]'. # The latter check ensures failure on broken command lines, hung output, etc. # Check output instead of exit codes because mojo shell always exits with 0. failed = [x for x in tests if (re.search('\[ FAILED \].*' + x, out) or not re.search('\[ OK \].*' + x, out))] ms = int(round(1000 * (time.time() - start_time))) if failed: _print_exception(command, out, ms) else: logging.getLogger().debug('Passed (in %d ms) with output:\n%s' % (ms, out)) return (tests, failed) def _get_fixtures(config, shell, args, apptest): '''Returns an apptest's 'Suite.Fixture' list via --gtest_list_tests output.''' arguments = args + ['--gtest_list_tests'] command = _build_command_line(config, arguments, apptest) logging.getLogger().debug('Command: %s' % ' '.join(command)) try: tests = _run_test_with_xvfb(config, shell, arguments, apptest) # Remove log lines from the output and ensure it matches known formatting. # Ignore empty fixture lists when the command line has a gtest filter flag. tests = re.sub('^(\[|WARNING: linker:).*\n', '', tests, flags=re.MULTILINE) if (not re.match('^(\w*\.\r?\n( \w*\r?\n)+)+', tests) and not [a for a in args if a.startswith('--gtest_filter')]): raise Exception('Unrecognized --gtest_list_tests output:\n%s' % tests) test_list = [] for line in tests.split('\n'): if not line: continue if line[0] != ' ': suite = line.strip() continue test_list.append(suite + line.strip()) logging.getLogger().debug('Tests for %s: %s' % (apptest, test_list)) return test_list except Exception as e: _print_exception(command, e) return [] def _print_exception(command_line, exception, milliseconds=None): '''Print a formatted exception raised from a failed command execution.''' details = (' (in %d ms)' % milliseconds) if milliseconds else '' if hasattr(exception, 'returncode'): details += ' (with exit code %d)' % exception.returncode print '\n[ FAILED ] Command%s: %s' % (details, ' '.join(command_line)) print 72 * '-' if hasattr(exception, 'output'): print exception.output print str(exception) print 72 * '-' def _build_command_line(config, args, apptest): '''Build the apptest command line. This value isn't executed on Android.''' not_list_tests = not '--gtest_list_tests' in args data_dir = ['--use-temporary-user-data-dir'] if not_list_tests else [] return Paths(config).mojo_runner + data_dir + args + [apptest] def _run_test_with_xvfb(config, shell, args, apptest): '''Run the test with xvfb; return the output or raise an exception.''' env = os.environ.copy() # Make sure gtest doesn't try to add color to the output. Color is done via # escape sequences which confuses the code that searches the gtest output. env['GTEST_COLOR'] = 'no' if (config.target_os != Config.OS_LINUX or '--gtest_list_tests' in args or not xvfb.should_start_xvfb(env)): return _run_test_with_timeout(config, shell, args, apptest, env) try: # Simply prepending xvfb.py to the command line precludes direct control of # test subprocesses, and prevents easily getting output when tests timeout. xvfb_proc = None openbox_proc = None global XVFB_DISPLAY_ID display_string = ':' + str(XVFB_DISPLAY_ID) (xvfb_proc, openbox_proc) = xvfb.start_xvfb(env, Paths(config).build_dir, display=display_string) XVFB_DISPLAY_ID = (XVFB_DISPLAY_ID + 1) % 50000 if not xvfb_proc or not xvfb_proc.pid: raise Exception('Xvfb failed to start; aborting test run.') if not openbox_proc or not openbox_proc.pid: raise Exception('Openbox failed to start; aborting test run.') logging.getLogger().debug('Running Xvfb %s (pid %d) and Openbox (pid %d).' % (display_string, xvfb_proc.pid, openbox_proc.pid)) return _run_test_with_timeout(config, shell, args, apptest, env) finally: xvfb.kill(xvfb_proc) xvfb.kill(openbox_proc) # TODO(msw): Determine proper test timeout durations (starting small). def _run_test_with_timeout(config, shell, args, apptest, env, seconds=10): '''Run the test with a timeout; return the output or raise an exception.''' if config.target_os == Config.OS_ANDROID: return _run_test_with_timeout_on_android(shell, args, apptest, seconds) output = '' error = [] command = _build_command_line(config, args, apptest) proc = subprocess42.Popen(command, detached=True, stdout=subprocess42.PIPE, stderr=subprocess42.STDOUT, env=env) try: output = proc.communicate(timeout=seconds)[0] or '' if proc.duration() > seconds: error.append('ERROR: Test timeout with duration: %s.' % proc.duration()) raise subprocess42.TimeoutExpired(proc.args, seconds, output, None) except subprocess42.TimeoutExpired as e: output = e.output or '' logging.getLogger().debug('Terminating the test for timeout.') error.append('ERROR: Test timeout after %d seconds.' % proc.duration()) proc.terminate() try: output += proc.communicate(timeout=30)[0] or '' except subprocess42.TimeoutExpired as e: output += e.output or '' logging.getLogger().debug('Test termination failed; attempting to kill.') proc.kill() try: output += proc.communicate(timeout=30)[0] or '' except subprocess42.TimeoutExpired as e: output += e.output or '' logging.getLogger().debug('Failed to kill the test process!') if proc.returncode: error.append('ERROR: Test exited with code: %d.' % proc.returncode) elif proc.returncode is None: error.append('ERROR: Failed to kill the test process!') if not output: error.append('ERROR: Test exited with no output.') elif output.startswith('This program contains tests'): error.append('ERROR: GTest printed help; check the command line.') if error: raise Exception(output + '\n'.join(error)) return output def _run_test_with_timeout_on_android(shell, args, apptest, seconds): '''Run the test with a timeout; return the output or raise an exception.''' assert shell result = Queue.Queue() thread = threading.Thread(target=_run_test_on_android, args=(shell, args, apptest, result)) thread.start() thread.join(seconds) timeout_exception = '' if thread.is_alive(): timeout_exception = '\nERROR: Test timeout after %d seconds.' % seconds logging.getLogger().debug('Killing the Android shell for timeout.') shell.kill() thread.join(seconds) if thread.is_alive(): raise Exception('ERROR: Failed to kill the test process!') if result.empty(): raise Exception('ERROR: Test exited with no output.') (output, exception) = result.get() exception += timeout_exception if exception: raise Exception('%s%s%s' % (output, '\n' if output else '', exception)) return output def _run_test_on_android(shell, args, apptest, result): '''Run the test on Android; put output and any exception in |result|.''' output = '' exception = '' try: (r, w) = os.pipe() with os.fdopen(r, 'r') as rf: with os.fdopen(w, 'w') as wf: arguments = args + [apptest] shell.StartActivity('MojoShellActivity', arguments, wf, wf.close) output = rf.read() except Exception as e: output += (e.output + '\n') if hasattr(e, 'output') else '' exception += str(e) result.put((output, exception))