blob: da183595ae4e33e7d4d06c79026f19f5a013ef9a [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright (C) 2017 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 the tests built by make_tests.py."""
from __future__ import absolute_import
from __future__ import print_function
import argparse
import collections
import datetime
import logging
from pathlib import Path
import random
import shutil
import site
import subprocess
import sys
import time
from typing import (
Dict,
Iterable,
List,
Mapping,
Optional,
)
import ndk.ansi
import ndk.archive
import ndk.ext.subprocess
import ndk.notify
import ndk.paths
import ndk.test.buildtest.case
import ndk.test.builder
from ndk.test.devicetest.case import TestCase
from ndk.test.devicetest.scanner import ConfigFilter, enumerate_tests
from ndk.test.devices import (
Device,
DeviceFleet,
DeviceShardingGroup,
find_devices,
)
from ndk.test.filters import TestFilter
from ndk.test.printers import Printer, StdoutPrinter
from ndk.test.report import Report
from ndk.test.result import (
ExpectedFailure,
Failure,
Skipped,
Success,
TestResult,
UnexpectedSuccess,
)
from ndk.test.spec import BuildConfiguration, TestSpec
import ndk.test.ui
from ndk.timer import Timer
import ndk.ui
from ndk.workqueue import ShardingWorkQueue, Worker, WorkQueue
from .pythonenv import ensure_python_environment
AdbResult = tuple[int, str, str, str]
def logger() -> logging.Logger:
"""Returns the module logger."""
return logging.getLogger(__name__)
class TestRun:
"""A test case mapped to the device group it will run on."""
def __init__(self, test_case: TestCase, device_group: DeviceShardingGroup) -> None:
self.test_case = test_case
self.device_group = device_group
@property
def name(self) -> str:
return self.test_case.name
@property
def build_system(self) -> str:
return self.test_case.build_system
@property
def config(self) -> BuildConfiguration:
return self.test_case.config
def make_result(self, adb_result: AdbResult, device: Device) -> TestResult:
status, out, _, cmd = adb_result
result: TestResult
if status == 0:
result = Success(self)
else:
out = "\n".join([str(device), out])
result = Failure(self, out, cmd)
return self.fixup_xfail(result, device)
def fixup_xfail(self, result: TestResult, device: Device) -> TestResult:
config, bug = self.test_case.check_broken(device.config())
if config is not None:
assert bug is not None
if result.failed():
assert isinstance(result, Failure)
return ExpectedFailure(self, result.message, config, bug)
if result.passed():
return UnexpectedSuccess(self, config, bug)
raise ValueError("Test result must have either failed or passed.")
return result
def run(self, device: Device) -> TestResult:
config = self.test_case.check_unsupported(device.config())
if config is not None:
return Skipped(self, f"test unsupported for {config}")
return self.make_result(self.test_case.run(device), device)
def clear_test_directory(_worker: Worker, device: Device) -> None:
print(f"Clearing test directory on {device}")
cmd = ["rm", "-r", str(ndk.paths.DEVICE_TEST_BASE_DIR)]
logger().info('%s: shell_nocheck "%s"', device.name, cmd)
device.shell_nocheck(cmd)
def clear_test_directories(workqueue: WorkQueue, fleet: DeviceFleet) -> None:
for group in fleet.get_unique_device_groups():
for device in group.devices:
workqueue.add_task(clear_test_directory, device)
while not workqueue.finished():
workqueue.get_result()
def adb_has_feature(feature: str) -> bool:
cmd = ["adb", "host-features"]
logger().info('check_output "%s"', " ".join(cmd))
output = subprocess.check_output(cmd).decode("utf-8")
features_line = output.splitlines()[-1]
features = features_line.split(",")
return feature in features
def push_tests_to_device(
worker: Worker,
src_dir: Path,
dest_dir: Path,
config: BuildConfiguration,
device: Device,
use_sync: bool,
) -> None:
"""Pushes a directory to the given device.
Creates the parent directory on the device if needed.
Args:
worker: The worker performing the task.
src_dir: The directory to push.
dest_dir: The destination directory on the device. Note that when
pushing a directory, dest_dir will be the parent directory,
not the destination path.
config: The build configuration for the tests being pushed.
device: The device to push to.
use_sync: True if `adb push --sync` is supported.
"""
worker.status = f"Pushing {config} tests to {device}."
logger().info("%s: mkdir %s", device.name, dest_dir)
device.shell_nocheck(["mkdir", str(dest_dir)])
logger().info(
"%s: push%s %s %s",
device.name,
" --sync" if use_sync else "",
src_dir,
dest_dir,
)
device.push(str(src_dir), str(dest_dir), sync=use_sync)
# Tests that were built and bundled on Windows but pushed from Linux or macOS will
# not have execute permission by default. Since we don't know where the tests came
# from, chmod all the tests regardless.
device.shell(["chmod", "-R", "777", str(dest_dir)])
def finish_workqueue_with_ui(workqueue: WorkQueue) -> None:
console = ndk.ansi.get_console()
ui = ndk.ui.get_work_queue_ui(console, workqueue)
with ndk.ansi.disable_terminal_echo(sys.stdin):
with console.cursor_hide_context():
ui.draw()
while not workqueue.finished():
workqueue.get_result()
ui.draw()
ui.clear()
def push_tests_to_devices(
workqueue: WorkQueue,
test_dir: Path,
groups_for_config: Mapping[BuildConfiguration, Iterable[DeviceShardingGroup]],
use_sync: bool,
) -> None:
dest_dir = ndk.paths.DEVICE_TEST_BASE_DIR
for config, groups in groups_for_config.items():
src_dir = test_dir / str(config)
for group in groups:
for device in group.devices:
workqueue.add_task(
push_tests_to_device, src_dir, dest_dir, config, device, use_sync
)
finish_workqueue_with_ui(workqueue)
print("Finished pushing tests")
def run_test(worker: Worker, test: TestRun) -> TestResult:
device = worker.data[0]
worker.status = f"Running {test.name}"
return test.run(device)
def print_test_stats(
test_groups: Mapping[BuildConfiguration, Iterable[TestCase]]
) -> None:
test_stats: Dict[BuildConfiguration, Dict[str, List[TestCase]]] = {}
for config, tests in test_groups.items():
test_stats[config] = {}
for test in tests:
if test.build_system not in test_stats[config]:
test_stats[config][test.build_system] = []
test_stats[config][test.build_system].append(test)
for config, build_system_groups in test_stats.items():
print(f"Config {config}:")
for build_system, tests in build_system_groups.items():
print(f"\t{build_system}: {len(tests)} tests")
def verify_have_all_requested_devices(fleet: DeviceFleet) -> bool:
missing_configs = fleet.get_missing()
if missing_configs:
logger().warning(
"Missing device configurations: %s", ", ".join(missing_configs)
)
return False
return True
def find_configs_with_no_device(
groups_for_config: Mapping[BuildConfiguration, Iterable[DeviceShardingGroup]]
) -> List[BuildConfiguration]:
return [c for c, gs in groups_for_config.items() if not gs]
def match_configs_to_device_groups(
fleet: DeviceFleet, configs: Iterable[BuildConfiguration]
) -> Dict[BuildConfiguration, List[DeviceShardingGroup]]:
groups_for_config: Dict[BuildConfiguration, List[DeviceShardingGroup]] = {
config: [] for config in configs
}
for config in configs:
for group in fleet.get_unique_device_groups():
# All devices in the group are identical.
device = group.devices[0]
if not device.can_run_build_config(config):
continue
groups_for_config[config].append(group)
return groups_for_config
def pair_test_runs(
test_groups: Mapping[BuildConfiguration, Iterable[TestCase]],
groups_for_config: Mapping[BuildConfiguration, Iterable[DeviceShardingGroup]],
) -> List[TestRun]:
"""Creates a TestRun object for each device/test case pairing."""
test_runs = []
for config, test_cases in test_groups.items():
if not test_cases:
continue
for group in groups_for_config[config]:
test_runs.extend([TestRun(tc, group) for tc in test_cases])
return test_runs
def wait_for_results(
report: Report,
workqueue: ShardingWorkQueue[TestResult, DeviceShardingGroup],
printer: Printer,
) -> None:
console = ndk.ansi.get_console()
ui = ndk.test.ui.get_test_progress_ui(console, workqueue)
with ndk.ansi.disable_terminal_echo(sys.stdin):
with console.cursor_hide_context():
while not workqueue.finished():
results = workqueue.get_results()
verbose = logger().isEnabledFor(logging.INFO)
if verbose or any(r.failed() for r in results):
ui.clear()
for result in results:
suite = result.test.build_system
report.add_result(suite, result)
if verbose or result.failed():
printer.print_result(result)
ui.draw()
ui.clear()
def flake_filter(result: TestResult) -> bool:
if isinstance(result, UnexpectedSuccess):
# There are no flaky successes.
return False
assert isinstance(result, Failure)
# adb might return no text at all under high load.
if "Could not find exit status in shell output." in result.message:
return True
# These libc++ tests expect to complete in a specific amount of time,
# and commonly fail under high load.
name = result.test.name
if "libc++.libcxx/thread" in name or "libc++.std/thread" in name:
return True
return False
def restart_flaky_tests(
report: Report, workqueue: ShardingWorkQueue[TestResult, DeviceShardingGroup]
) -> None:
"""Finds and restarts any failing flaky tests."""
rerun_tests = report.remove_all_failing_flaky(flake_filter)
if rerun_tests:
cooldown = 10
logger().warning(
"Found %d flaky failures. Sleeping for %d seconds to let "
"devices recover.",
len(rerun_tests),
cooldown,
)
time.sleep(cooldown)
for flaky_report in rerun_tests:
logger().warning("Flaky test failure: %s", flaky_report.result)
group = flaky_report.result.test.device_group
workqueue.add_task(group, run_test, flaky_report.result.test)
def str_to_bool(s: str) -> bool:
if s == "true":
return True
if s == "false":
return False
raise ValueError(s)
def parse_args() -> argparse.Namespace:
doc = "https://android.googlesource.com/platform/ndk/+/master/docs/Testing.md"
parser = argparse.ArgumentParser(epilog="See {} for more information.".format(doc))
def PathArg(path: str) -> Path:
# Path.resolve() fails if the path doesn't exist. We want to resolve
# symlinks when possible, but not require that the path necessarily
# exist, because we will create it later.
return Path(path).expanduser().resolve(strict=False)
def ExistingPathArg(path: str) -> Path:
expanded_path = Path(path).expanduser()
if not expanded_path.exists():
raise argparse.ArgumentTypeError("{} does not exist".format(path))
return expanded_path.resolve(strict=True)
def ExistingDirectoryArg(path: str) -> Path:
expanded_path = Path(path).expanduser()
if not expanded_path.is_dir():
raise argparse.ArgumentTypeError("{} is not a directory".format(path))
return expanded_path.resolve(strict=True)
def ExistingFileArg(path: str) -> Path:
expanded_path = Path(path).expanduser()
if not expanded_path.is_file():
raise argparse.ArgumentTypeError("{} is not a file".format(path))
return expanded_path.resolve(strict=True)
parser.add_argument(
"--permissive-python-environment",
action="store_true",
help=(
"Disable strict Python path checking. This allows using a non-prebuilt "
"Python when one is not available."
),
)
config_options = parser.add_argument_group("Test Configuration Options")
config_options.add_argument(
"--filter", help="Only run tests that match the given pattern."
)
config_options.add_argument(
"--abi",
action="append",
choices=ndk.abis.ALL_ABIS,
help="Test only the given APIs.",
)
# The type ignore is needed because realpath is an overloaded function, and
# mypy is bad at those (it doesn't satisfy Callable[[str], AnyStr]).
config_options.add_argument(
"--config",
type=ExistingFileArg,
default=ndk.paths.ndk_path("qa_config.json"),
help="Path to the config file describing the test run.",
)
build_options = parser.add_argument_group("Build Options")
build_options.add_argument(
"--build-report",
type=PathArg,
help="Write the build report to the given path.",
)
build_exclusive_group = build_options.add_mutually_exclusive_group()
build_exclusive_group.add_argument(
"--rebuild", action="store_true", help="Build the tests before running."
)
build_exclusive_group.add_argument(
"--build-only", action="store_true", help="Builds the tests and exits."
)
build_options.add_argument(
"--clean", action="store_true", help="Remove the out directory before building."
)
build_options.add_argument(
"--package",
action="store_true",
help="Package the built tests. Requires --rebuild or --build-only.",
)
run_options = parser.add_argument_group("Test Run Options")
run_options.add_argument(
"--clean-device",
action="store_true",
help="Clear the device directories before syncing.",
)
run_options.add_argument(
"--require-all-devices",
action="store_true",
help="Abort if any devices specified by the config are not available.",
)
display_options = parser.add_argument_group("Display Options")
display_options.add_argument(
"--show-all",
action="store_true",
help="Show all test results, not just failures.",
)
display_options.add_argument(
"--show-test-stats",
action="store_true",
help="Print number of tests found for each configuration.",
)
display_options.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase log level. Defaults to logging.WARNING.",
)
parser.add_argument(
"--ndk",
type=ExistingPathArg,
default=ndk.paths.get_install_path(),
help="NDK to validate. Defaults to ../out/android-ndk-$RELEASE.",
)
parser.add_argument(
"--test-src",
type=ExistingDirectoryArg,
default=ndk.paths.ndk_path("tests"),
help="Path to test source directory. Defaults to ndk/tests.",
)
parser.add_argument(
"test_dir",
metavar="TEST_DIR",
type=PathArg,
nargs="?",
default=ndk.paths.path_in_out(Path("tests")),
help="Directory containing built tests.",
)
parser.add_argument(
"--dist-dir",
type=PathArg,
default=ndk.paths.get_dist_dir(),
help="Directory to store packaged tests. Defaults to $DIST_DIR or ../out/dist",
)
return parser.parse_args()
class Results:
def __init__(self) -> None:
self.success: Optional[bool] = None
self.failure_message: Optional[str] = None
self.times: Dict[str, datetime.timedelta] = collections.OrderedDict()
def passed(self) -> None:
if self.success is not None:
raise ValueError
self.success = True
def failed(self, message: Optional[str] = None) -> None:
if self.success is not None:
raise ValueError
self.success = False
self.failure_message = message
def add_timing_report(self, label: str, timer: Timer) -> None:
if label in self.times:
raise ValueError
assert timer.duration is not None
self.times[label] = timer.duration
def run_tests(args: argparse.Namespace) -> Results:
results = Results()
if not args.test_dir.exists():
if args.rebuild or args.build_only:
args.test_dir.mkdir(parents=True)
else:
sys.exit("Test output directory does not exist: {}".format(args.test_dir))
if args.package and not args.dist_dir.exists():
if args.rebuild or args.build_only:
args.dist_dir.mkdir(parents=True)
test_spec = TestSpec.load(args.config, abis=args.abi)
printer = StdoutPrinter(show_all=args.show_all)
if args.ndk.is_file():
# Unzip the NDK into out/ndk-zip.
if args.ndk.suffix == ".zip":
ndk_dir = ndk.paths.path_in_out(Path(args.ndk.stem))
if ndk_dir.exists():
shutil.rmtree(ndk_dir)
ndk_dir.mkdir(parents=True)
ndk.archive.unzip(args.ndk, ndk_dir)
contents = list(ndk_dir.iterdir())
assert len(contents) == 1
assert contents[0].is_dir()
# Windows paths, by default, are limited to 260 characters.
# Some of our deeply nested paths run up against this limitation.
# Therefore, after unzipping the NDK into something like
# out/android-ndk-8136140-windows-x86_64/android-ndk-r25-canary
# (61 characters) we rename it to out/ndk-zip (7 characters),
# shortening paths in the NDK by 54 characters.
short_path = ndk.paths.path_in_out(Path("ndk-zip"))
if short_path.exists():
shutil.rmtree(short_path)
contents[0].rename(short_path)
args.ndk = short_path
shutil.rmtree(ndk_dir)
else:
sys.exit("--ndk must be a directory or a .zip file: {}".format(args.ndk))
test_dist_dir = args.test_dir / "dist"
if args.build_only or args.rebuild:
build_timer = Timer()
with build_timer:
test_options = ndk.test.spec.TestOptions(
args.test_src,
args.ndk,
args.test_dir,
test_filter=args.filter,
clean=args.clean,
package_path=args.dist_dir / "ndk-tests" if args.package else None,
)
builder = ndk.test.builder.TestBuilder(test_spec, test_options, printer)
report = builder.build()
results.add_timing_report("Build", build_timer)
if report.num_tests == 0:
results.failed("Found no tests for filter {}.".format(args.filter))
return results
printer.print_summary(report)
if not report.successful:
results.failed()
return results
if args.build_only:
results.passed()
return results
test_filter = TestFilter.from_string(args.filter)
# dict of {BuildConfiguration: [Test]}
config_filter = ConfigFilter(test_spec)
test_discovery_timer = Timer()
with test_discovery_timer:
test_groups = enumerate_tests(
test_dist_dir,
args.test_src,
ndk.paths.DEVICE_TEST_BASE_DIR,
test_filter,
config_filter,
)
results.add_timing_report("Test discovery", test_discovery_timer)
if sum([len(tests) for tests in test_groups.values()]) == 0:
# As long as we *built* some tests, not having anything to run isn't a
# failure.
if args.rebuild:
results.passed()
else:
results.failed(
"Found no tests in {} for filter {}.".format(test_dist_dir, args.filter)
)
return results
if args.show_test_stats:
print_test_stats(test_groups)
# For finding devices, we have a list of devices we want to run on in our
# config file. If we did away with this list, we could instead run every
# test on every compatible device, but in the event of multiple similar
# devices, that's a lot of duplication. The list keeps us from running
# tests on android-24 and android-25, which don't have meaningful
# differences.
#
# The list also makes sure we don't miss any devices that we expect to run
# on.
#
# The other thing we need to verify is that each test we find is run at
# least once.
#
# Get the list of all devices. Prune this by the requested device
# configuration. For each requested configuration that was not found, print
# a warning. Then compare that list of devices against all our tests and
# make sure each test is claimed by at least one device. For each
# configuration that is unclaimed, print a warning.
workqueue = WorkQueue()
try:
device_discovery_timer = Timer()
with device_discovery_timer:
fleet = find_devices(test_spec.devices, workqueue)
results.add_timing_report("Device discovery", device_discovery_timer)
have_all_devices = verify_have_all_requested_devices(fleet)
if args.require_all_devices and not have_all_devices:
results.failed("Some requested devices were not available.")
return results
groups_for_config = match_configs_to_device_groups(fleet, test_groups.keys())
for config in find_configs_with_no_device(groups_for_config):
logger().warning("No device found for %s.", config)
report = Report()
clean_device_timer = Timer()
if args.clean_device:
with clean_device_timer:
clear_test_directories(workqueue, fleet)
results.add_timing_report("Clean device", clean_device_timer)
can_use_sync = adb_has_feature("push_sync")
push_timer = Timer()
with push_timer:
push_tests_to_devices(
workqueue, test_dist_dir, groups_for_config, can_use_sync
)
results.add_timing_report("Push", push_timer)
finally:
workqueue.terminate()
workqueue.join()
shard_queue: ShardingWorkQueue[TestResult, DeviceShardingGroup] = ShardingWorkQueue(
fleet.get_unique_device_groups(), 4
)
try:
# Need an input queue per device group, a single result queue, and a
# pool of threads per device.
# Shuffle the test runs to distribute the load more evenly. These are
# ordered by (build config, device, test), so most of the tests running
# at any given point in time are all running on the same device.
test_runs = pair_test_runs(test_groups, groups_for_config)
random.shuffle(test_runs)
test_run_timer = Timer()
with test_run_timer:
for test_run in test_runs:
shard_queue.add_task(test_run.device_group, run_test, test_run)
wait_for_results(report, shard_queue, printer)
restart_flaky_tests(report, shard_queue)
wait_for_results(report, shard_queue, printer)
results.add_timing_report("Run", test_run_timer)
printer.print_summary(report)
finally:
shard_queue.terminate()
shard_queue.join()
if report.successful:
results.passed()
else:
results.failed()
return results
def main() -> None:
args = parse_args()
ensure_python_environment(args.permissive_python_environment)
log_levels = [logging.WARNING, logging.INFO, logging.DEBUG]
verbosity = min(args.verbose, len(log_levels) - 1)
log_level = log_levels[verbosity]
logging.basicConfig(level=log_level)
python_packages = args.ndk / "python-packages"
site.addsitedir(python_packages)
total_timer = Timer()
with total_timer:
results = run_tests(args)
if results.success is None:
raise RuntimeError("run_tests returned without indicating success or failure.")
good = results.success
print("Finished {}".format("successfully" if good else "unsuccessfully"))
if (message := results.failure_message) is not None:
print(message)
for timer, duration in results.times.items():
print("{}: {}".format(timer, duration))
print("Total: {}".format(total_timer.duration))
subject = "NDK Testing {}!".format("Passed" if good else "Failed")
body = "Testing finished in {}".format(total_timer.duration)
ndk.notify.toast(subject, body)
sys.exit(not good)
if __name__ == "__main__":
main()