blob: baa06a4c14129963f8f01f759d68058263dcb206 [file] [log] [blame]
# Copyright 2023, 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.
"""Mobly test runner."""
import argparse
import dataclasses
import datetime
import logging
import os
from pathlib import Path
import re
import shlex
import shutil
import subprocess
import tempfile
import time
from typing import Any, Dict, List, Optional, Set
import yaml
try:
from googleapiclient import errors, http
except ModuleNotFoundError as err:
logging.debug('Import error due to: %s', err)
from atest import atest_configs
from atest import atest_enum
from atest import atest_utils
from atest import constants
from atest import result_reporter
from atest.logstorage import logstorage_utils
from atest.metrics import metrics
from atest.test_finders import test_info
from atest.test_runners import test_runner_base
_ERROR_TEST_FILE_NOT_FOUND = (
'Required test file %s not found. If this is your first run, please ensure '
'that the build step is performed.'
)
_ERROR_NO_MOBLY_TEST_PKG = (
'No Mobly test package found. Ensure that the Mobly test module is '
'correctly configured.'
)
_ERROR_NO_TEST_SUMMARY = 'No Mobly test summary found.'
_ERROR_INVALID_TEST_SUMMARY = (
'Invalid Mobly test summary. Make sure that it contains a final "Summary" '
'section.'
)
_ERROR_INVALID_TESTPARAMS = (
'Invalid testparam values. Make sure that they follow the PARAM=VALUE '
'format.'
)
# TODO(b/287136126): Use host python once compatibility issue is resolved.
PYTHON_3_11 = 'python3.11'
FILE_REQUIREMENTS_TXT = 'requirements.txt'
FILE_SUFFIX_APK = '.apk'
CONFIG_KEY_TESTBEDS = 'TestBeds'
CONFIG_KEY_NAME = 'Name'
CONFIG_KEY_CONTROLLERS = 'Controllers'
CONFIG_KEY_TEST_PARAMS = 'TestParams'
CONFIG_KEY_FILES = 'files'
CONFIG_KEY_ANDROID_DEVICE = 'AndroidDevice'
CONFIG_KEY_MOBLY_PARAMS = 'MoblyParams'
CONFIG_KEY_LOG_PATH = 'LogPath'
LOCAL_TESTBED = 'LocalTestBed'
MOBLY_LOGS_DIR = 'mobly_logs'
CONFIG_FILE = 'mobly_config.yaml'
LATEST_DIR = 'latest'
TEST_SUMMARY_YAML = 'test_summary.yaml'
CVD_SERIAL_PATTERN = r'.+:([0-9]+)$'
SUMMARY_KEY_TYPE = 'Type'
SUMMARY_TYPE_RECORD = 'Record'
SUMMARY_KEY_TEST_CLASS = 'Test Class'
SUMMARY_KEY_TEST_NAME = 'Test Name'
SUMMARY_KEY_BEGIN_TIME = 'Begin Time'
SUMMARY_KEY_END_TIME = 'End Time'
SUMMARY_KEY_RESULT = 'Result'
SUMMARY_RESULT_PASS = 'PASS'
SUMMARY_RESULT_FAIL = 'FAIL'
SUMMARY_RESULT_SKIP = 'SKIP'
SUMMARY_RESULT_ERROR = 'ERROR'
SUMMARY_KEY_DETAILS = 'Details'
SUMMARY_KEY_STACKTRACE = 'Stacktrace'
TEST_STORAGE_PASS = 'pass'
TEST_STORAGE_FAIL = 'fail'
TEST_STORAGE_IGNORED = 'ignored'
TEST_STORAGE_ERROR = 'testError'
TEST_STORAGE_STATUS_UNSPECIFIED = 'testStatusUnspecified'
WORKUNIT_ATEST_MOBLY_RUNNER = 'ATEST_MOBLY_RUNNER'
WORKUNIT_ATEST_MOBLY_TEST_RUN = 'ATEST_MOBLY_TEST_RUN'
FILE_UPLOAD_RETRIES = 3
_MOBLY_RESULT_TO_RESULT_REPORTER_STATUS = {
SUMMARY_RESULT_PASS: test_runner_base.PASSED_STATUS,
SUMMARY_RESULT_FAIL: test_runner_base.FAILED_STATUS,
SUMMARY_RESULT_SKIP: test_runner_base.IGNORED_STATUS,
SUMMARY_RESULT_ERROR: test_runner_base.FAILED_STATUS,
}
_MOBLY_RESULT_TO_TEST_STORAGE_STATUS = {
SUMMARY_RESULT_PASS: TEST_STORAGE_PASS,
SUMMARY_RESULT_FAIL: TEST_STORAGE_FAIL,
SUMMARY_RESULT_SKIP: TEST_STORAGE_IGNORED,
SUMMARY_RESULT_ERROR: TEST_STORAGE_ERROR,
}
@dataclasses.dataclass
class MoblyTestFiles:
"""Data class representing required files for a Mobly test.
Attributes:
mobly_pkg: The executable Mobly test package. Main build output of
python_test_host.
requirements_txt: Optional file with name `requirements.txt` used to
declare pip dependencies.
test_apks: Files ending with `.apk`. APKs used by the test.
misc_data: All other files contained in the test target's `data`.
"""
mobly_pkg: str
requirements_txt: Optional[str]
test_apks: List[str]
misc_data: List[str]
@dataclasses.dataclass(frozen=True)
class RerunOptions:
"""Data class representing rerun options."""
iterations: int
rerun_until_failure: bool
retry_any_failure: bool
class MoblyTestRunnerError(Exception):
"""Errors encountered by the MoblyTestRunner."""
class MoblyResultUploader:
"""Uploader for Android Build test storage."""
def __init__(self, extra_args):
"""Set up the build client."""
self._build_client = None
self._legacy_client = None
self._legacy_result_id = None
self._test_results = {}
upload_start = time.monotonic()
creds, self._invocation = (
logstorage_utils.do_upload_flow(extra_args)
if logstorage_utils.is_upload_enabled(extra_args)
else (None, None)
)
self._root_workunit = None
self._current_workunit = None
if creds:
metrics.LocalDetectEvent(
detect_type=atest_enum.DetectType.UPLOAD_FLOW_MS,
result=int((time.monotonic() - upload_start) * 1000),
)
self._build_client = logstorage_utils.BuildClient(creds)
self._legacy_client = logstorage_utils.BuildClient(
creds,
api_version=constants.STORAGE_API_VERSION_LEGACY,
url=constants.DISCOVERY_SERVICE_LEGACY,
)
self._setup_root_workunit()
else:
logging.debug('Result upload is disabled.')
def _setup_root_workunit(self):
"""Create and populate fields for the root workunit."""
self._root_workunit = self._build_client.insert_work_unit(self._invocation)
self._root_workunit['type'] = WORKUNIT_ATEST_MOBLY_RUNNER
self._root_workunit['runCount'] = 0
@property
def enabled(self):
"""Returns True if the uploader is enabled."""
return self._build_client is not None
@property
def invocation(self):
"""The invocation of the current run."""
return self._invocation
@property
def current_workunit(self):
"""The workunit of the current iteration."""
return self._current_workunit
def start_new_workunit(self):
"""Create and start a new workunit for the iteration."""
if not self.enabled:
return
self._current_workunit = self._build_client.insert_work_unit(
self._invocation
)
self._current_workunit['type'] = WORKUNIT_ATEST_MOBLY_TEST_RUN
self._current_workunit['parentId'] = self._root_workunit['id']
def set_workunit_iteration_details(
self, iteration_num: int, rerun_options: RerunOptions
):
"""Set iteration-related fields in the current workunit.
Args:
iteration_num: Index of the current iteration.
rerun_options: Rerun options for the test.
"""
if not self.enabled:
return
details = {}
if rerun_options.retry_any_failure:
details['childAttemptNumber'] = iteration_num
else:
details['childRunNumber'] = iteration_num
self._current_workunit.update(details)
def _finalize_workunit(self, workunit: Dict[str, Any]):
"""Finalize the specified workunit."""
workunit['schedulerState'] = 'completed'
logging.debug('Finalizing workunit: %s', workunit)
self._build_client.client.workunit().update(
resourceId=workunit['id'], body=workunit
)
if workunit is not self._root_workunit:
self._root_workunit['runCount'] += 1
def finalize_current_workunit(self):
"""Finalize the workunit for the current iteration."""
if not self.enabled:
return
self._test_results.clear()
self._finalize_workunit(self._current_workunit)
self._current_workunit = None
def record_test_result(self, test_result):
"""Record a test result to be uploaded."""
test_identifier = test_result['testIdentifier']
class_method = f'{test_identifier["testClass"]}.{test_identifier["method"]}'
self._test_results[class_method] = test_result
def upload_test_results(self):
"""Bulk upload all recorded test results."""
if not (self.enabled and self._test_results):
return
response = (
self._build_client.client.testresult()
.bulkinsert(
invocationId=self._invocation['invocationId'],
body={'testResults': list(self._test_results.values())},
)
.execute()
)
logging.debug('Uploaded test results: %s', response)
def _upload_single_file(
self, path: str, base_dir: str, legacy_result_id: str
):
"""Upload a single test file to build storage."""
invocation_id = self._invocation['invocationId']
workunit_id = self._current_workunit['id']
name = os.path.relpath(path, base_dir)
metadata = {
'invocationId': invocation_id,
'workUnitId': workunit_id,
'name': name,
}
logging.debug('Uploading test artifact file %s', name)
try:
self._build_client.client.testartifact().update(
resourceId=name,
invocationId=invocation_id,
workUnitId=workunit_id,
body=metadata,
legacyTestResultId=legacy_result_id,
media_body=http.MediaFileUpload(path),
).execute(num_retries=FILE_UPLOAD_RETRIES)
except errors.HttpError as e:
logging.debug('Failed to upload file %s with error: %s', name, e)
def upload_test_artifacts(self, log_dir: str):
"""Upload test artifacts and associate them to the workunit.
Args:
log_dir: The directory of logs to upload.
"""
if not self.enabled:
return
# Use the legacy API to insert a test result and get a test result
# id, as it is required for test artifact upload.
res = (
self._legacy_client.client.testresult()
.insert(
buildId=self.invocation['primaryBuild']['buildId'],
target=self.invocation['primaryBuild']['buildTarget'],
attemptId='latest',
body={
'status': 'completePass',
},
)
.execute()
)
for root, _, file_names in os.walk(log_dir):
for file_name in file_names:
self._upload_single_file(
os.path.join(root, file_name), log_dir, res['id']
)
def finalize_invocation(self):
"""Set the root work unit and invocation as complete."""
if not self.enabled:
return
self._finalize_workunit(self._root_workunit)
self.invocation['runner'] = 'mobly'
self.invocation['schedulerState'] = 'completed'
logging.debug('Finalizing invocation: %s', self.invocation)
self._build_client.update_invocation(self.invocation)
self._build_client = None
def add_result_link(self, reporter: result_reporter.ResultReporter):
"""Add the invocation link to the result reporter.
Args:
reporter: The result reporter to add to.
"""
new_result_link = constants.RESULT_LINK % self._invocation['invocationId']
if isinstance(reporter.test_result_link, list):
reporter.test_result_link.append(new_result_link)
elif isinstance(reporter.test_result_link, str):
reporter.test_result_link = [reporter.test_result_link, new_result_link]
else:
reporter.test_result_link = [new_result_link]
class MoblyTestRunner(test_runner_base.TestRunnerBase):
"""Mobly test runner class."""
NAME: str = 'MoblyTestRunner'
# Unused placeholder value. Mobly tests will be run from Python virtualenv
EXECUTABLE: str = '.'
# Temporary files and directories used by the runner.
_temppaths: List[str] = []
def run_tests(
self,
test_infos: List[test_info.TestInfo],
extra_args: Dict[str, Any],
reporter: result_reporter.ResultReporter,
) -> int:
"""Runs the list of test_infos.
Should contain code for kicking off the test runs using
test_runner_base.run(). Results should be processed and printed
via the reporter passed in.
Args:
test_infos: List of TestInfo.
extra_args: Dict of extra args to add to test run.
reporter: An instance of result_report.ResultReporter.
Returns:
0 if tests succeed, non-zero otherwise.
"""
mobly_args = self._parse_custom_args(
extra_args.get(constants.CUSTOM_ARGS, [])
)
ret_code = atest_enum.ExitCode.SUCCESS
rerun_options = self._get_rerun_options(extra_args)
reporter.silent = False
uploader = MoblyResultUploader(extra_args)
for tinfo in test_infos:
try:
# Pre-test setup
test_files = self._get_test_files(tinfo)
py_executable = self._setup_python_env(test_files.requirements_txt)
serials = atest_configs.GLOBAL_ARGS.serial or self._get_cvd_serials()
if constants.DISABLE_INSTALL not in extra_args:
self._install_apks(test_files.test_apks, serials)
mobly_config = self._generate_mobly_config(
mobly_args, serials, test_files
)
# Generate command and run
test_cases = self._get_test_cases_from_spec(tinfo)
mobly_command = self._get_mobly_command(
py_executable,
test_files.mobly_pkg,
mobly_config,
test_cases,
mobly_args,
)
ret_code |= self._run_and_handle_results(
mobly_command, tinfo, rerun_options, mobly_args, reporter, uploader
)
finally:
self._cleanup()
if uploader.enabled:
uploader.finalize_invocation()
uploader.add_result_link(reporter)
return ret_code
def host_env_check(self) -> None:
"""Checks that host env has met requirements."""
def get_test_runner_build_reqs(
self, test_infos: List[test_info.TestInfo]
) -> Set[str]:
"""Returns a set of build targets required by the test runner."""
build_targets = set()
build_targets.update(test_runner_base.gather_build_targets(test_infos))
return build_targets
# pylint: disable=unused-argument
def generate_run_commands(
self,
test_infos: List[test_info.TestInfo],
extra_args: Dict[str, Any],
_port: Optional[int] = None,
) -> List[str]:
"""Generates a list of run commands from TestInfos.
Args:
test_infos: A set of TestInfo instances.
extra_args: A Dict of extra args to append.
_port: Unused.
Returns:
A list of run commands to run the tests.
"""
# TODO: to be implemented
return []
def _parse_custom_args(self, argv: list[str]) -> argparse.Namespace:
"""Parse custom CLI args into Mobly runner options."""
parser = argparse.ArgumentParser(prog='atest ... --')
parser.add_argument(
'--config',
help=(
'Path to a custom Mobly testbed config. Overrides all other '
'configuration options.'
),
)
parser.add_argument(
'--testbed',
help=(
'Selects the name of the testbed to use for the test. Only '
'use this option in conjunction with --config. Defaults to '
'"LocalTestBed".'
),
)
parser.add_argument(
'--testparam',
metavar='PARAM=VALUE',
help=(
'A test param for Mobly, specified in the format '
'"param=value". These values can then be accessed as '
'TestClass.user_params in the test. This option is '
'repeatable.'
),
action='append',
)
return parser.parse_args(argv)
def _get_rerun_options(self, extra_args: dict[str, Any]) -> RerunOptions:
"""Get rerun options from extra_args."""
iters = extra_args.get(constants.ITERATIONS, 1)
reruns = extra_args.get(constants.RERUN_UNTIL_FAILURE, 0)
retries = extra_args.get(constants.RETRY_ANY_FAILURE, 0)
return RerunOptions(
max(iters, reruns, retries), bool(reruns), bool(retries)
)
def _get_test_files(self, tinfo: test_info.TestInfo) -> MoblyTestFiles:
"""Gets test resource files from a given TestInfo."""
mobly_pkg = None
requirements_txt = None
test_apks = []
misc_data = []
logging.debug('Getting test resource files for %s', tinfo.test_name)
for path in tinfo.data.get(constants.MODULE_INSTALLED):
path_str = str(path.expanduser().absolute())
if not path.is_file():
raise MoblyTestRunnerError(_ERROR_TEST_FILE_NOT_FOUND % path_str)
if path.name == tinfo.test_name:
mobly_pkg = path_str
elif path.name == FILE_REQUIREMENTS_TXT:
requirements_txt = path_str
elif path.suffix == FILE_SUFFIX_APK:
test_apks.append(path_str)
else:
misc_data.append(path_str)
logging.debug('Found test resource file %s.', path_str)
if mobly_pkg is None:
raise MoblyTestRunnerError(_ERROR_NO_MOBLY_TEST_PKG)
return MoblyTestFiles(mobly_pkg, requirements_txt, test_apks, misc_data)
def _generate_mobly_config(
self,
mobly_args: argparse.Namespace,
serials: List[str],
test_files: MoblyTestFiles,
) -> str:
"""Creates a Mobly YAML config given the test parameters.
If --config is specified, use that file as the testbed config.
If --serial is specified, the test will use those specific devices,
otherwise it will use all ADB-connected devices.
For each --testparam specified in custom args, the test will add the
param as a key-value pair under the testbed config's 'TestParams'.
Values are limited to strings.
Test resource paths (e.g. APKs) will be added to 'files' under
'TestParams' so they could be accessed from the test script.
Also set the Mobly results dir to <atest_results>/mobly_logs.
Args:
mobly_args: Custom args for the Mobly runner.
serials: List of device serials.
test_files: Files used by the Mobly test.
Returns:
Path to the generated config.
"""
if mobly_args.config:
config_path = os.path.abspath(os.path.expanduser(mobly_args.config))
logging.debug('Using existing custom Mobly config at %s', config_path)
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
else:
local_testbed = {
CONFIG_KEY_NAME: LOCAL_TESTBED,
CONFIG_KEY_CONTROLLERS: {
CONFIG_KEY_ANDROID_DEVICE: serials if serials else '*',
},
CONFIG_KEY_TEST_PARAMS: {},
}
if mobly_args.testparam:
try:
local_testbed[CONFIG_KEY_TEST_PARAMS].update(
dict([param.split('=', 1) for param in mobly_args.testparam])
)
except ValueError as e:
raise MoblyTestRunnerError(_ERROR_INVALID_TESTPARAMS) from e
if test_files.test_apks or test_files.misc_data:
files = {}
files.update({
Path(test_apk).stem: [test_apk] for test_apk in test_files.test_apks
})
files.update({
Path(misc_file).name: [misc_file]
for misc_file in test_files.misc_data
})
local_testbed[CONFIG_KEY_TEST_PARAMS][CONFIG_KEY_FILES] = files
config = {
CONFIG_KEY_TESTBEDS: [local_testbed],
}
# Use ATest logs directory as the Mobly log path
log_path = os.path.join(self.results_dir, MOBLY_LOGS_DIR)
config[CONFIG_KEY_MOBLY_PARAMS] = {
CONFIG_KEY_LOG_PATH: log_path,
}
os.makedirs(log_path)
config_path = os.path.join(log_path, CONFIG_FILE)
logging.debug('Generating Mobly config at %s', config_path)
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(config, f, indent=4)
return config_path
def _setup_python_env(self, requirements_txt: Optional[str]) -> Optional[str]:
"""Sets up the local Python environment.
If a requirements_txt file exists, creates a Python virtualenv and
install dependencies. Otherwise, run the Mobly test binary directly.
Args:
requirements_txt: Path to the requirements.txt file, where the PyPI
dependencies are declared. None if no such file exists.
Returns:
The virtualenv executable, or None.
"""
if requirements_txt is None:
logging.debug(
'No requirements.txt file found. Running Mobly test package directly.'
)
return None
venv_dir = tempfile.mkdtemp(prefix='venv_')
logging.debug('Creating virtualenv at %s.', venv_dir)
subprocess.check_call([PYTHON_3_11, '-m', 'venv', venv_dir])
self._temppaths.append(venv_dir)
venv_executable = os.path.join(venv_dir, 'bin', 'python')
# Install requirements
logging.debug('Installing dependencies from %s.', requirements_txt)
cmd = [venv_executable, '-m', 'pip', 'install', '-r', requirements_txt]
subprocess.check_call(cmd)
return venv_executable
def _get_cvd_serials(self) -> List[str]:
"""Gets the serials of cvd devices available for the test.
Returns:
A list of device serials.
"""
if not (
atest_configs.GLOBAL_ARGS.acloud_create
or atest_configs.GLOBAL_ARGS.start_avd
):
return []
devices = atest_utils.get_adb_devices()
return [
device for device in devices if re.match(CVD_SERIAL_PATTERN, device)
]
def _install_apks(self, apks: List[str], serials: List[str]) -> None:
"""Installs test APKs to devices.
This can be toggled off by omitting the --install option.
If --serial is specified, the APK will be installed to those specific
devices, otherwise it will install to all ADB-connected devices.
Args:
apks: List of APK paths.
serials: List of device serials.
"""
serials = serials or atest_utils.get_adb_devices()
for apk in apks:
for serial in serials:
logging.debug('Installing APK %s to device %s.', apk, serial)
subprocess.check_call(['adb', '-s', serial, 'install', '-r', '-g', apk])
def _get_test_cases_from_spec(self, tinfo: test_info.TestInfo) -> List[str]:
"""Get the list of test cases to run from the user-specified filters.
Syntax for test_runner tests:
MODULE:.#TEST_CASE_1[,TEST_CASE_2,TEST_CASE_3...]
e.g.: `atest hello-world-test:.#test_hello,test_goodbye` ->
[test_hello, test_goodbye]
Syntax for suite_runner tests:
MODULE:TEST_CLASS#TEST_CASE_1[,TEST_CASE_2,TEST_CASE_3...]
e.g.: `atest hello-world-suite:HelloWorldTest#test_hello,test_goodbye`
-> [HelloWorldTest.test_hello, HelloWorldTest.test_goodbye]
Args:
tinfo: The TestInfo of the test.
Returns: List of test cases for the Mobly command.
"""
if not tinfo.data['filter']:
return []
(test_filter,) = tinfo.data['filter']
if test_filter.methods:
# If an actual class name is specified, assume this is a
# suite_runner test and use 'CLASS.METHOD' for the Mobly test
# selector.
if test_filter.class_name.isalnum():
return [
'%s.%s' % (test_filter.class_name, method)
for method in test_filter.methods
]
# If the class name is a placeholder character (like '.'), assume
# this is a test_runner test and use just 'METHOD' for the Mobly
# test selector.
return list(test_filter.methods)
return [test_filter.class_name]
def _get_mobly_command(
self,
py_executable: str,
mobly_pkg: str,
config_path: str,
test_cases: List[str],
mobly_args: argparse.ArgumentParser,
) -> List[str]:
"""Generates a single Mobly test command.
Args:
py_executable: Path to the Python executable.
mobly_pkg: Path to the Mobly test package.
config_path: Path to the Mobly config.
test_cases: List of test cases to run.
mobly_args: Custom args for the Mobly runner.
Returns:
The full Mobly test command.
"""
command = [py_executable] if py_executable is not None else []
command += [
mobly_pkg,
'-c',
config_path,
'--test_bed',
mobly_args.testbed or LOCAL_TESTBED,
]
if test_cases:
command += ['--tests', *test_cases]
return command
# pylint: disable=broad-except
# pylint: disable=too-many-arguments
def _run_and_handle_results(
self,
mobly_command: List[str],
tinfo: test_info.TestInfo,
rerun_options: RerunOptions,
mobly_args: argparse.ArgumentParser,
reporter: result_reporter.ResultReporter,
uploader: MoblyResultUploader,
) -> int:
"""Runs for the specified number of iterations and handles results.
Args:
mobly_command: Mobly command to run.
tinfo: The TestInfo of the test.
rerun_options: Rerun options for the test.
mobly_args: Custom args for the Mobly runner.
reporter: The ResultReporter for the test.
uploader: The MoblyResultUploader used to store results for upload.
Returns:
0 if tests succeed, non-zero otherwise.
"""
logging.debug(
'Running Mobly test %s for %d iteration(s). '
'rerun-until-failure: %s, retry-any-failure: %s.',
tinfo.test_name,
rerun_options.iterations,
rerun_options.rerun_until_failure,
rerun_options.retry_any_failure,
)
ret_code = atest_enum.ExitCode.SUCCESS
for iteration_num in range(rerun_options.iterations):
# Set up result reporter and uploader
reporter.runners.clear()
reporter.pre_test = None
uploader.start_new_workunit()
# Run the Mobly test command
curr_ret_code = self._run_mobly_command(mobly_command)
ret_code |= curr_ret_code
# Process results from generated summary file
latest_log_dir = os.path.join(
self.results_dir,
MOBLY_LOGS_DIR,
mobly_args.testbed or LOCAL_TESTBED,
LATEST_DIR,
)
summary_file = os.path.join(latest_log_dir, TEST_SUMMARY_YAML)
test_results = self._process_test_results_from_summary(
summary_file, tinfo, iteration_num, rerun_options.iterations, uploader
)
for test_result in test_results:
reporter.process_test_result(test_result)
reporter.set_current_iteration_summary(iteration_num)
try:
uploader.upload_test_results()
uploader.upload_test_artifacts(latest_log_dir)
uploader.set_workunit_iteration_details(iteration_num, rerun_options)
uploader.finalize_current_workunit()
except Exception as e:
logging.debug('Failed to upload test results. Error: %s', e)
# Break if run ending conditions are met
if (rerun_options.rerun_until_failure and curr_ret_code != 0) or (
rerun_options.retry_any_failure and curr_ret_code == 0
):
break
return ret_code
def _run_mobly_command(self, mobly_cmd: List[str]) -> int:
"""Runs the Mobly test command.
Args:
mobly_cmd: Mobly command to run.
Returns:
Return code of the Mobly command.
"""
proc = self.run(
shlex.join(mobly_cmd),
output_to_stdout=bool(atest_configs.GLOBAL_ARGS.verbose),
)
return self.wait_for_subprocess(proc)
# pylint: disable=too-many-locals
def _process_test_results_from_summary(
self,
summary_file: str,
tinfo: test_info.TestInfo,
iteration_num: int,
total_iterations: int,
uploader: MoblyResultUploader,
) -> List[test_runner_base.TestResult]:
"""Parses the Mobly summary file into test results for the ResultReporter
as well as the MoblyResultUploader.
Args:
summary_file: Path to the Mobly summary file.
tinfo: The TestInfo of the test.
iteration_num: The index of the current iteration.
total_iterations: The total number of iterations.
uploader: The MoblyResultUploader used to store results for upload.
"""
if not os.path.isfile(summary_file):
raise MoblyTestRunnerError(_ERROR_NO_TEST_SUMMARY)
# Find and parse 'Summary' section
logging.debug('Processing results from summary file %s.', summary_file)
with open(summary_file, 'r', encoding='utf-8') as f:
summary = list(yaml.safe_load_all(f))
# Populate test results
reported_results = []
records = [
entry
for entry in summary
if entry[SUMMARY_KEY_TYPE] == SUMMARY_TYPE_RECORD
]
for test_index, record in enumerate(records):
# Add result for result reporter
time_elapsed_ms = 0
if (
record.get(SUMMARY_KEY_END_TIME) is not None
and record.get(SUMMARY_KEY_BEGIN_TIME) is not None
):
time_elapsed_ms = (
record[SUMMARY_KEY_END_TIME] - record[SUMMARY_KEY_BEGIN_TIME]
)
test_run_name = record[SUMMARY_KEY_TEST_CLASS]
test_name = (
f'{record[SUMMARY_KEY_TEST_CLASS]}.{record[SUMMARY_KEY_TEST_NAME]}'
)
if total_iterations > 1:
test_run_name = f'{test_run_name} (#{iteration_num + 1})'
test_name = f'{test_name} (#{iteration_num + 1})'
reported_result = {
'runner_name': self.NAME,
'group_name': tinfo.test_name,
'test_run_name': test_run_name,
'test_name': test_name,
'status': get_result_reporter_status_from_mobly_result(
record[SUMMARY_KEY_RESULT]
),
'details': record[SUMMARY_KEY_STACKTRACE],
'test_count': test_index + 1,
'group_total': len(records),
'test_time': str(datetime.timedelta(milliseconds=time_elapsed_ms)),
# Below values are unused
'runner_total': None,
'additional_info': {},
}
reported_results.append(test_runner_base.TestResult(**reported_result))
# Add result for upload (if enabled)
if uploader.enabled:
uploaded_result = {
'invocationId': uploader.invocation['invocationId'],
'workUnitId': uploader.current_workunit['id'],
'testIdentifier': {
'module': tinfo.test_name,
'testClass': record[SUMMARY_KEY_TEST_CLASS],
'method': record[SUMMARY_KEY_TEST_NAME],
},
'testStatus': get_test_storage_status_from_mobly_result(
record[SUMMARY_KEY_RESULT]
),
'timing': {
'creationTimestamp': record[SUMMARY_KEY_BEGIN_TIME],
'completeTimestamp': record[SUMMARY_KEY_END_TIME],
},
}
if record[SUMMARY_KEY_RESULT] != SUMMARY_RESULT_PASS:
uploaded_result['debugInfo'] = {
'errorMessage': record[SUMMARY_KEY_DETAILS],
'trace': record[SUMMARY_KEY_STACKTRACE],
}
uploader.record_test_result(uploaded_result)
return reported_results
def _cleanup(self) -> None:
"""Cleans up temporary host files/directories."""
logging.debug('Cleaning up temporary dirs/files.')
for temppath in self._temppaths:
if os.path.isdir(temppath):
shutil.rmtree(temppath)
else:
os.remove(temppath)
self._temppaths.clear()
def get_result_reporter_status_from_mobly_result(result: str):
"""Maps Mobly result to a ResultReporter status."""
return _MOBLY_RESULT_TO_RESULT_REPORTER_STATUS.get(
result, test_runner_base.ERROR_STATUS
)
def get_test_storage_status_from_mobly_result(result: str):
"""Maps Mobly result to a test storage status."""
return _MOBLY_RESULT_TO_TEST_STORAGE_STATUS.get(
result, TEST_STORAGE_STATUS_UNSPECIFIED
)