#!/bin/sh # # Copyright (C) 2018 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. # # # This test script to be used by the build server. # It is supposed to be executed from trusty root directory # and expects the following environment variables: # """:" # Shell script (in docstring to appease pylint) # Find and invoke hermetic python3 interpreter . "`dirname $0`/envsetup.sh"; exec "$PY3" "$0" "$@" # Shell script end Run tests for a project. """ import argparse from enum import Enum import importlib import os import re import subprocess import sys import time from typing import Optional from trusty_build_config import PortType, TrustyCompositeTest, TrustyTest from trusty_build_config import TrustyAndroidTest, TrustyBuildConfig from trusty_build_config import TrustyHostTest, TrustyRebootCommand TEST_STATUS = Enum("TEST_STATUS", ["PASSED", "FAILED", "SKIPPED"]) class TestResult: """Stores results for a single test. Attributes: test: Name of the test. status: Test's integer return code, or None if this test was skipped. retried: True if this test was retried. """ test: str status: Optional[int] retried: bool def __init__(self, test: str, status: Optional[int], retried: bool): self.test = test self.status = status self.retried = retried def test_status(self) -> TEST_STATUS: if self.status is None: return TEST_STATUS.SKIPPED return TEST_STATUS.PASSED if self.status == 0 else TEST_STATUS.FAILED def failed(self) -> bool: return self.test_status() == TEST_STATUS.FAILED def __format__(self, _format_spec: str) -> str: return f"{self.test:s} returned {self.status:d}" class TestResults(object): """Stores test results. Attributes: project: Name of project that tests were run on. passed: True if all tests passed, False if one or more tests failed. passed_count: Number of tests passed. failed_count: Number of tests failed. flaked_count: Number of tests that failed then passed on second try. retried_count: Number of tests that were given a second try. test_results: List of tuples storing test name an status. """ def __init__(self, project): """Inits TestResults with project name and empty test results.""" self.project = project self.passed = True self.passed_count = 0 self.failed_count = 0 self.skipped_count = 0 self.flaked_count = 0 self.retried_count = 0 self.test_results = [] def add_result(self, result: TestResult): """Add a test result.""" self.test_results.append(result) if result.test_status() == TEST_STATUS.PASSED: self.passed_count += 1 if result.retried: self.flaked_count += 1 elif result.test_status() == TEST_STATUS.FAILED: self.failed_count += 1 self.passed = False elif result.test_status() == TEST_STATUS.SKIPPED: self.skipped_count += 1 if result.retried: self.retried_count += 1 def print_results(self, print_failed_only=False): """Print test results.""" if print_failed_only: if self.passed: return sys.stdout.flush() out = sys.stderr else: out = sys.stdout test_count = self.passed_count + self.failed_count + self.skipped_count test_attempted = self.passed_count + self.failed_count out.write( "\n" f"There were {test_count} defined for project {self.project}.\n" f"{test_attempted} ran and {self.skipped_count} were skipped." ) if test_count: for result in self.test_results: match (result.test_status(), result.retried, print_failed_only): case (TEST_STATUS.FAILED, _, _): out.write(f"[ FAILED ] {result.test}\n") case (TEST_STATUS.SKIPPED, _, False): out.write(f"[ SKIPPED ] {result.test}\n") case (TEST_STATUS.PASSED, retried, False): out.write(f"[ OK ] {result.test}\n") if retried: out.write( f"WARNING: {result.test} was re-run and " "passed on second try; it may be flaky\n" ) out.write( f"[==========] {test_count} tests ran for project " f"{self.project}.\n" ) if self.passed_count and not print_failed_only: out.write(f"[ PASSED ] {self.passed_count} tests.\n") if self.failed_count: out.write(f"[ FAILED ] {self.failed_count} tests.\n") if self.skipped_count: out.write(f"[ SKIPPED ] {self.skipped_count} tests.\n") if self.flaked_count > 0: out.write( f"WARNING: {self.flaked_count} tests passed when " "re-run which indicates that they may be flaky.\n" ) if self.retried_count == MAX_RETRIES: out.write( f"WARNING: hit MAX_RETRIES({MAX_RETRIES}) during " "testing after which point, no tests were retried.\n" ) class MultiProjectTestResults: """Stores results from testing multiple projects. Attributes: test_results: List containing the results for each project. failed_projects: List of projects with test failures. tests_passed: Count of test passes across all projects. tests_failed: Count of test failures across all projects. had_passes: Count of all projects with any test passes. had_failures: Count of all projects with any test failures. """ def __init__(self, test_results: list[TestResults]): self.test_results = test_results self.failed_projects = [] self.tests_passed = 0 self.tests_failed = 0 self.tests_skipped = 0 self.had_passes = 0 self.had_failures = 0 self.had_skip = 0 for result in self.test_results: if not result.passed: self.failed_projects.append(result.project) self.tests_passed += result.passed_count self.tests_failed += result.failed_count self.tests_skipped += result.skipped_count if result.passed_count: self.had_passes += 1 if result.failed_count: self.had_failures += 1 if result.skipped_count: self.had_skip += 1 def print_results(self): """Prints the test results to stdout and stderr.""" for test_result in self.test_results: test_result.print_results() sys.stdout.write("\n") if self.had_passes: sys.stdout.write( f"[ PASSED ] {self.tests_passed} tests in " f"{self.had_passes} projects.\n" ) if self.had_failures: sys.stdout.write( f"[ FAILED ] {self.tests_failed} tests in " f"{self.had_failures} projects.\n" ) sys.stdout.flush() if self.had_skip: sys.stdout.write( f"[ SKIPPED ] {self.tests_skipped} tests in " f"{self.had_skip} projects.\n" ) sys.stdout.flush() # Print the failed tests again to stderr as the build server will # store this in a separate file with a direct link from the build # status page. The full build long page on the build server, buffers # stdout and stderr and interleaves them at random. By printing # the summary to both stderr and stdout, we get at least one of them # at the bottom of that file. for test_result in self.test_results: test_result.print_results(print_failed_only=True) sys.stderr.write( f"[ FAILED ] {self.tests_failed,} tests in " f"{self.had_failures} projects.\n" ) def test_should_run(testname: str, test_filters: Optional[list[re.Pattern]]): """Check if test should run. Args: testname: Name of test to check. test_filters: Regex list that limits the tests to run. Returns: True if test_filters list is empty or None, True if testname matches any regex in test_filters, False otherwise. """ if not test_filters: return True for r in test_filters: if r.search(testname): return True return False def projects_to_test( build_config: TrustyBuildConfig, projects: list[str], test_filters: list[re.Pattern], run_disabled_tests: bool = False, ) -> list[str]: """Checks which projects have any of the specified tests. Args: build_config: TrustyBuildConfig object. projects: Names of the projects to search for active tests. test_filters: List that limits the tests to run. Projects without any tests that match a filter will be skipped. run_disabled_tests: Also run disabled tests from config file. Returns: A list of projects with tests that should be run """ def has_test(name: str): project = build_config.get_project(name) for test in project.tests: if not test.enabled and not run_disabled_tests: continue if test_should_run(test.name, test_filters): return True return False return [project for project in projects if has_test(project)] # Put a global cap on the number of retries to detect flaky tests such that we # do not risk increasing the time to try all tests substantially. This should be # fine since *most* tests are not flaky. # TODO: would it be better to put a cap on the time spent retrying tests? We may # not want to retry long running tests. MAX_RETRIES = 10 def run_tests( build_config: TrustyBuildConfig, root: os.PathLike, project: str, run_disabled_tests: bool = False, test_filters: Optional[list[re.Pattern]] = None, verbose: bool = False, debug_on_error: bool = False, emulator: bool = True, ) -> TestResults: """Run tests for a project. Args: build_config: TrustyBuildConfig object. root: Trusty build root output directory. project: Project name. run_disabled_tests: Also run disabled tests from config file. test_filters: Optional list that limits the tests to run. verbose: Enable debug output. debug_on_error: Wait for debugger connection on errors. Returns: TestResults object listing overall and detailed test results. """ project_config = build_config.get_project(project=project) project_root = f"{root}/build-{project}" test_results = TestResults(project) test_env = None test_runner = None def load_test_environment(): sys.path.append(project_root) try: if run := sys.modules.get("run"): if not run.__file__.startswith(project_root): # run module was imported for another project and needs # to be replaced with the one for the current project. run = importlib.reload(run) else: # first import in this interpreter instance, we use importlib # rather than a regular import statement since it avoids # linter warnings. run = importlib.import_module("run") sys.path.pop() except ImportError: return None return run def print_test_command(name, cmd: Optional[list[str]] = None): print() print("Running", name, "on", test_results.project) if cmd: print( "Command line:", " ".join([s.replace(" ", "\\ ") for s in cmd]) ) sys.stdout.flush() def run_test( test, parent_test: Optional[TrustyCompositeTest] = None, retry=True ) -> Optional[TestResult]: """Execute a single test and print out helpful information Returns: The results of running this test, or None for non-tests, like reboots or tests that don't work in this environment. """ nonlocal test_env, test_runner cmd = test.command[1:] disable_rpmb = True if "--disable_rpmb" in cmd else None test_start_time = time.time() if not emulator and not isinstance(test, TrustyHostTest): return None match test: case TrustyHostTest(): # append nice and expand path to command cmd = ["nice", f"{project_root}/{test.command[0]}"] + cmd print_test_command(test.name, cmd) cmd_status = subprocess.call(cmd) result = TestResult(test.name, cmd_status, False) case TrustyCompositeTest(): status_code: Optional[int] = 0 for subtest in test.sequence: subtest_result = run_test(subtest, test, retry) if subtest_result and subtest_result.failed(): status_code = subtest_result.status # fail the composite test with the same status code as # the first failing subtest break result = TestResult(test.name, status_code, False) case TrustyTest(): # Benchmark runs on QEMU are meaningless and take a lot of # CI time. One can still run the bootport test manually # if desired if test.port_type == PortType.BENCHMARK: return TestResult(test.name, None, False) else: if isinstance(test, TrustyAndroidTest): print_test_command(test.name, [test.shell_command]) else: # port tests are identified by their port name, # no command print_test_command(test.name) if not test_env: test_env = load_test_environment() if test_env: if not test_runner: test_runner = test_env.init( android=build_config.android, disable_rpmb=disable_rpmb, verbose=verbose, debug_on_error=debug_on_error, ) cmd_status = test_env.run_test(test_runner, cmd) result = TestResult(test.name, cmd_status, False) else: return TestResult(test.name, None, False) case TrustyRebootCommand() if parent_test: assert isinstance(parent_test, TrustyCompositeTest) if test_env: test_env.shutdown(test_runner) test_runner = None print("Shut down test environment on", test_results.project) # return early so we do not report the time to reboot or try to # add the reboot command to test results. return None case TrustyRebootCommand(): raise RuntimeError( "Reboot may only be used inside compositetest" ) case _: raise NotImplementedError(f"Don't know how to run {test.name}") elapsed = time.time() - test_start_time print( f"{result} after {elapsed:.3f} seconds") can_retry = retry and test_results.retried_count < MAX_RETRIES if result and result.failed() and can_retry: print( f"retrying potentially flaky test {test.name} on", test_results.project, ) # TODO: first retry the test without restarting the test # environment and if that fails, restart and then # retry if < MAX_RETRIES. if test_env: test_env.shutdown(test_runner) test_runner = None retried_result = run_test(test, parent_test, retry=False) # Know this is the kind of test that returns a status b/c it failed assert retried_result is not None retried_result.retried = True return retried_result else: # Test passed, was skipped, or we're not retrying it. return result # the retry mechanism is intended to allow a batch run of all tests to pass # even if a small handful of tests exhibit flaky behavior. If a test filter # was provided or debug on error is set, we are most likely not doing a # batch run (as is the case for presubmit testing) meaning that it is # not all that helpful to retry failing tests vs. finishing the run faster. retry = test_filters is None and not debug_on_error try: for test in project_config.tests: if not test.enabled and not run_disabled_tests: continue if not test_should_run(test.name, test_filters): continue if result := run_test(test, None, retry): test_results.add_result(result) finally: # finally is used here to make sure that we attempt to shutdown the # test environment no matter whether an exception was raised or not # and no matter what kind of test caused an exception to be raised. if test_env: test_env.shutdown(test_runner) # any saved exception from the try block will be re-raised here return test_results def test_projects( build_config: TrustyBuildConfig, root: os.PathLike, projects: list[str], run_disabled_tests: bool = False, test_filters: Optional[list[re.Pattern]] = None, verbose: bool = False, debug_on_error: bool = False, emulator: bool = True, ) -> MultiProjectTestResults: """Run tests for multiple project. Args: build_config: TrustyBuildConfig object. root: Trusty build root output directory. projects: Names of the projects to run tests for. run_disabled_tests: Also run disabled tests from config file. test_filters: Optional list that limits the tests to run. Projects without any tests that match a filter will be skipped. verbose: Enable debug output. debug_on_error: Wait for debugger connection on errors. Returns: MultiProjectTestResults listing overall and detailed test results. """ if test_filters: projects = projects_to_test( build_config, projects, test_filters, run_disabled_tests=run_disabled_tests, ) results = [] for project in projects: results.append( run_tests( build_config, root, project, run_disabled_tests=run_disabled_tests, test_filters=test_filters, verbose=verbose, debug_on_error=debug_on_error, emulator=emulator, ) ) return MultiProjectTestResults(results) def default_root() -> str: script_dir = os.path.dirname(os.path.abspath(__file__)) top = os.path.abspath(os.path.join(script_dir, "../../../../..")) return os.path.join(top, "build-root") def main(): parser = argparse.ArgumentParser() parser.add_argument( "project", type=str, nargs="+", help="Project(s) to test." ) parser.add_argument( "--build-root", type=str, default=default_root(), help="Root of intermediate build directory.", ) parser.add_argument( "--run_disabled_tests", help="Also run disabled tests from config file.", action="store_true", ) parser.add_argument( "--test", type=str, action="append", help="Only run tests that match the provided regexes.", ) parser.add_argument( "--verbose", help="Enable debug output.", action="store_true" ) parser.add_argument( "--debug_on_error", help="Wait for debugger connection on errors.", action="store_true", ) args = parser.parse_args() build_config = TrustyBuildConfig() test_filters = ( [re.compile(test) for test in args.test] if args.test else None ) test_results = test_projects( build_config, args.build_root, args.project, run_disabled_tests=args.run_disabled_tests, test_filters=test_filters, verbose=args.verbose, debug_on_error=args.debug_on_error, ) test_results.print_results() if test_results.failed_projects: sys.exit(1) if __name__ == "__main__": main()