1# Copyright 2023, The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14 15"""Mobly test runner.""" 16import argparse 17import dataclasses 18import datetime 19import logging 20import os 21from pathlib import Path 22import re 23import shlex 24import shutil 25import subprocess 26import tempfile 27import time 28from typing import Any, Dict, List, Optional, Set 29 30import yaml 31 32try: 33 from googleapiclient import errors, http 34except ModuleNotFoundError as err: 35 logging.debug('Import error due to: %s', err) 36 37from atest import atest_configs 38from atest import atest_enum 39from atest import atest_utils 40from atest import constants 41from atest import result_reporter 42 43from atest.logstorage import logstorage_utils 44from atest.metrics import metrics 45from atest.test_finders import test_info 46from atest.test_runners import test_runner_base 47 48 49_ERROR_TEST_FILE_NOT_FOUND = ( 50 'Required test file %s not found. If this is your first run, please ensure ' 51 'that the build step is performed.' 52) 53_ERROR_NO_MOBLY_TEST_PKG = ( 54 'No Mobly test package found. Ensure that the Mobly test module is ' 55 'correctly configured.' 56) 57_ERROR_NO_TEST_SUMMARY = 'No Mobly test summary found.' 58_ERROR_INVALID_TEST_SUMMARY = ( 59 'Invalid Mobly test summary. Make sure that it contains a final "Summary" ' 60 'section.' 61) 62_ERROR_INVALID_TESTPARAMS = ( 63 'Invalid testparam values. Make sure that they follow the PARAM=VALUE ' 64 'format.' 65) 66 67# TODO(b/287136126): Use host python once compatibility issue is resolved. 68PYTHON_3_11 = 'python3.11' 69 70FILE_REQUIREMENTS_TXT = 'requirements.txt' 71FILE_SUFFIX_APK = '.apk' 72 73CONFIG_KEY_TESTBEDS = 'TestBeds' 74CONFIG_KEY_NAME = 'Name' 75CONFIG_KEY_CONTROLLERS = 'Controllers' 76CONFIG_KEY_TEST_PARAMS = 'TestParams' 77CONFIG_KEY_FILES = 'files' 78CONFIG_KEY_ANDROID_DEVICE = 'AndroidDevice' 79CONFIG_KEY_MOBLY_PARAMS = 'MoblyParams' 80CONFIG_KEY_LOG_PATH = 'LogPath' 81LOCAL_TESTBED = 'LocalTestBed' 82MOBLY_LOGS_DIR = 'mobly_logs' 83CONFIG_FILE = 'mobly_config.yaml' 84LATEST_DIR = 'latest' 85TEST_SUMMARY_YAML = 'test_summary.yaml' 86 87CVD_SERIAL_PATTERN = r'.+:([0-9]+)$' 88 89SUMMARY_KEY_TYPE = 'Type' 90SUMMARY_TYPE_RECORD = 'Record' 91SUMMARY_KEY_TEST_CLASS = 'Test Class' 92SUMMARY_KEY_TEST_NAME = 'Test Name' 93SUMMARY_KEY_BEGIN_TIME = 'Begin Time' 94SUMMARY_KEY_END_TIME = 'End Time' 95SUMMARY_KEY_RESULT = 'Result' 96SUMMARY_RESULT_PASS = 'PASS' 97SUMMARY_RESULT_FAIL = 'FAIL' 98SUMMARY_RESULT_SKIP = 'SKIP' 99SUMMARY_RESULT_ERROR = 'ERROR' 100SUMMARY_KEY_DETAILS = 'Details' 101SUMMARY_KEY_STACKTRACE = 'Stacktrace' 102 103TEST_STORAGE_PASS = 'pass' 104TEST_STORAGE_FAIL = 'fail' 105TEST_STORAGE_IGNORED = 'ignored' 106TEST_STORAGE_ERROR = 'testError' 107TEST_STORAGE_STATUS_UNSPECIFIED = 'testStatusUnspecified' 108 109WORKUNIT_ATEST_MOBLY_RUNNER = 'ATEST_MOBLY_RUNNER' 110WORKUNIT_ATEST_MOBLY_TEST_RUN = 'ATEST_MOBLY_TEST_RUN' 111 112FILE_UPLOAD_RETRIES = 3 113 114_MOBLY_RESULT_TO_RESULT_REPORTER_STATUS = { 115 SUMMARY_RESULT_PASS: test_runner_base.PASSED_STATUS, 116 SUMMARY_RESULT_FAIL: test_runner_base.FAILED_STATUS, 117 SUMMARY_RESULT_SKIP: test_runner_base.IGNORED_STATUS, 118 SUMMARY_RESULT_ERROR: test_runner_base.FAILED_STATUS, 119} 120 121_MOBLY_RESULT_TO_TEST_STORAGE_STATUS = { 122 SUMMARY_RESULT_PASS: TEST_STORAGE_PASS, 123 SUMMARY_RESULT_FAIL: TEST_STORAGE_FAIL, 124 SUMMARY_RESULT_SKIP: TEST_STORAGE_IGNORED, 125 SUMMARY_RESULT_ERROR: TEST_STORAGE_ERROR, 126} 127 128 129@dataclasses.dataclass 130class MoblyTestFiles: 131 """Data class representing required files for a Mobly test. 132 133 Attributes: 134 mobly_pkg: The executable Mobly test package. Main build output of 135 python_test_host. 136 requirements_txt: Optional file with name `requirements.txt` used to 137 declare pip dependencies. 138 test_apks: Files ending with `.apk`. APKs used by the test. 139 misc_data: All other files contained in the test target's `data`. 140 """ 141 142 mobly_pkg: str 143 requirements_txt: Optional[str] 144 test_apks: List[str] 145 misc_data: List[str] 146 147 148@dataclasses.dataclass(frozen=True) 149class RerunOptions: 150 """Data class representing rerun options.""" 151 152 iterations: int 153 rerun_until_failure: bool 154 retry_any_failure: bool 155 156 157class MoblyTestRunnerError(Exception): 158 """Errors encountered by the MoblyTestRunner.""" 159 160 161class MoblyResultUploader: 162 """Uploader for Android Build test storage.""" 163 164 def __init__(self, extra_args): 165 """Set up the build client.""" 166 self._build_client = None 167 self._legacy_client = None 168 self._legacy_result_id = None 169 self._test_results = {} 170 171 upload_start = time.monotonic() 172 creds, self._invocation = ( 173 logstorage_utils.do_upload_flow(extra_args) 174 if logstorage_utils.is_upload_enabled(extra_args) 175 else (None, None) 176 ) 177 178 self._root_workunit = None 179 self._current_workunit = None 180 181 if creds: 182 metrics.LocalDetectEvent( 183 detect_type=atest_enum.DetectType.UPLOAD_FLOW_MS, 184 result=int((time.monotonic() - upload_start) * 1000), 185 ) 186 self._build_client = logstorage_utils.BuildClient(creds) 187 self._legacy_client = logstorage_utils.BuildClient( 188 creds, 189 api_version=constants.STORAGE_API_VERSION_LEGACY, 190 url=constants.DISCOVERY_SERVICE_LEGACY, 191 ) 192 self._setup_root_workunit() 193 else: 194 logging.debug('Result upload is disabled.') 195 196 def _setup_root_workunit(self): 197 """Create and populate fields for the root workunit.""" 198 self._root_workunit = self._build_client.insert_work_unit(self._invocation) 199 self._root_workunit['type'] = WORKUNIT_ATEST_MOBLY_RUNNER 200 self._root_workunit['runCount'] = 0 201 202 @property 203 def enabled(self): 204 """Returns True if the uploader is enabled.""" 205 return self._build_client is not None 206 207 @property 208 def invocation(self): 209 """The invocation of the current run.""" 210 return self._invocation 211 212 @property 213 def current_workunit(self): 214 """The workunit of the current iteration.""" 215 return self._current_workunit 216 217 def start_new_workunit(self): 218 """Create and start a new workunit for the iteration.""" 219 if not self.enabled: 220 return 221 self._current_workunit = self._build_client.insert_work_unit( 222 self._invocation 223 ) 224 self._current_workunit['type'] = WORKUNIT_ATEST_MOBLY_TEST_RUN 225 self._current_workunit['parentId'] = self._root_workunit['id'] 226 227 def set_workunit_iteration_details( 228 self, iteration_num: int, rerun_options: RerunOptions 229 ): 230 """Set iteration-related fields in the current workunit. 231 232 Args: 233 iteration_num: Index of the current iteration. 234 rerun_options: Rerun options for the test. 235 """ 236 if not self.enabled: 237 return 238 details = {} 239 if rerun_options.retry_any_failure: 240 details['childAttemptNumber'] = iteration_num 241 else: 242 details['childRunNumber'] = iteration_num 243 self._current_workunit.update(details) 244 245 def _finalize_workunit(self, workunit: Dict[str, Any]): 246 """Finalize the specified workunit.""" 247 workunit['schedulerState'] = 'completed' 248 logging.debug('Finalizing workunit: %s', workunit) 249 self._build_client.client.workunit().update( 250 resourceId=workunit['id'], body=workunit 251 ) 252 if workunit is not self._root_workunit: 253 self._root_workunit['runCount'] += 1 254 255 def finalize_current_workunit(self): 256 """Finalize the workunit for the current iteration.""" 257 if not self.enabled: 258 return 259 self._test_results.clear() 260 self._finalize_workunit(self._current_workunit) 261 self._current_workunit = None 262 263 def record_test_result(self, test_result): 264 """Record a test result to be uploaded.""" 265 test_identifier = test_result['testIdentifier'] 266 class_method = f'{test_identifier["testClass"]}.{test_identifier["method"]}' 267 self._test_results[class_method] = test_result 268 269 def upload_test_results(self): 270 """Bulk upload all recorded test results.""" 271 if not (self.enabled and self._test_results): 272 return 273 response = ( 274 self._build_client.client.testresult() 275 .bulkinsert( 276 invocationId=self._invocation['invocationId'], 277 body={'testResults': list(self._test_results.values())}, 278 ) 279 .execute() 280 ) 281 logging.debug('Uploaded test results: %s', response) 282 283 def _upload_single_file( 284 self, path: str, base_dir: str, legacy_result_id: str 285 ): 286 """Upload a single test file to build storage.""" 287 invocation_id = self._invocation['invocationId'] 288 workunit_id = self._current_workunit['id'] 289 name = os.path.relpath(path, base_dir) 290 metadata = { 291 'invocationId': invocation_id, 292 'workUnitId': workunit_id, 293 'name': name, 294 } 295 logging.debug('Uploading test artifact file %s', name) 296 try: 297 self._build_client.client.testartifact().update( 298 resourceId=name, 299 invocationId=invocation_id, 300 workUnitId=workunit_id, 301 body=metadata, 302 legacyTestResultId=legacy_result_id, 303 media_body=http.MediaFileUpload(path), 304 ).execute(num_retries=FILE_UPLOAD_RETRIES) 305 except errors.HttpError as e: 306 logging.debug('Failed to upload file %s with error: %s', name, e) 307 308 def upload_test_artifacts(self, log_dir: str): 309 """Upload test artifacts and associate them to the workunit. 310 311 Args: 312 log_dir: The directory of logs to upload. 313 """ 314 if not self.enabled: 315 return 316 # Use the legacy API to insert a test result and get a test result 317 # id, as it is required for test artifact upload. 318 res = ( 319 self._legacy_client.client.testresult() 320 .insert( 321 buildId=self.invocation['primaryBuild']['buildId'], 322 target=self.invocation['primaryBuild']['buildTarget'], 323 attemptId='latest', 324 body={ 325 'status': 'completePass', 326 }, 327 ) 328 .execute() 329 ) 330 331 for root, _, file_names in os.walk(log_dir): 332 for file_name in file_names: 333 self._upload_single_file( 334 os.path.join(root, file_name), log_dir, res['id'] 335 ) 336 337 def finalize_invocation(self): 338 """Set the root work unit and invocation as complete.""" 339 if not self.enabled: 340 return 341 self._finalize_workunit(self._root_workunit) 342 self.invocation['runner'] = 'mobly' 343 self.invocation['schedulerState'] = 'completed' 344 logging.debug('Finalizing invocation: %s', self.invocation) 345 self._build_client.update_invocation(self.invocation) 346 self._build_client = None 347 348 def add_result_link(self, reporter: result_reporter.ResultReporter): 349 """Add the invocation link to the result reporter. 350 351 Args: 352 reporter: The result reporter to add to. 353 """ 354 new_result_link = constants.RESULT_LINK % self._invocation['invocationId'] 355 if isinstance(reporter.test_result_link, list): 356 reporter.test_result_link.append(new_result_link) 357 elif isinstance(reporter.test_result_link, str): 358 reporter.test_result_link = [reporter.test_result_link, new_result_link] 359 else: 360 reporter.test_result_link = [new_result_link] 361 362 363class MoblyTestRunner(test_runner_base.TestRunnerBase): 364 """Mobly test runner class.""" 365 366 NAME: str = 'MoblyTestRunner' 367 # Unused placeholder value. Mobly tests will be run from Python virtualenv 368 EXECUTABLE: str = '.' 369 370 # Temporary files and directories used by the runner. 371 _temppaths: List[str] = [] 372 373 def run_tests( 374 self, 375 test_infos: List[test_info.TestInfo], 376 extra_args: Dict[str, Any], 377 reporter: result_reporter.ResultReporter, 378 ) -> int: 379 """Runs the list of test_infos. 380 381 Should contain code for kicking off the test runs using 382 test_runner_base.run(). Results should be processed and printed 383 via the reporter passed in. 384 385 Args: 386 test_infos: List of TestInfo. 387 extra_args: Dict of extra args to add to test run. 388 reporter: An instance of result_report.ResultReporter. 389 390 Returns: 391 0 if tests succeed, non-zero otherwise. 392 """ 393 mobly_args = self._parse_custom_args( 394 extra_args.get(constants.CUSTOM_ARGS, []) 395 ) 396 397 ret_code = atest_enum.ExitCode.SUCCESS 398 rerun_options = self._get_rerun_options(extra_args) 399 400 reporter.silent = False 401 uploader = MoblyResultUploader(extra_args) 402 403 for tinfo in test_infos: 404 try: 405 # Pre-test setup 406 test_files = self._get_test_files(tinfo) 407 py_executable = self._setup_python_env(test_files.requirements_txt) 408 serials = atest_configs.GLOBAL_ARGS.serial or self._get_cvd_serials() 409 if constants.DISABLE_INSTALL not in extra_args: 410 self._install_apks(test_files.test_apks, serials) 411 mobly_config = self._generate_mobly_config( 412 mobly_args, serials, test_files 413 ) 414 415 # Generate command and run 416 test_cases = self._get_test_cases_from_spec(tinfo) 417 mobly_command = self._get_mobly_command( 418 py_executable, 419 test_files.mobly_pkg, 420 mobly_config, 421 test_cases, 422 mobly_args, 423 ) 424 ret_code |= self._run_and_handle_results( 425 mobly_command, tinfo, rerun_options, mobly_args, reporter, uploader 426 ) 427 finally: 428 self._cleanup() 429 if uploader.enabled: 430 uploader.finalize_invocation() 431 uploader.add_result_link(reporter) 432 return ret_code 433 434 def host_env_check(self) -> None: 435 """Checks that host env has met requirements.""" 436 437 def get_test_runner_build_reqs( 438 self, test_infos: List[test_info.TestInfo] 439 ) -> Set[str]: 440 """Returns a set of build targets required by the test runner.""" 441 build_targets = set() 442 build_targets.update(test_runner_base.gather_build_targets(test_infos)) 443 return build_targets 444 445 # pylint: disable=unused-argument 446 def generate_run_commands( 447 self, 448 test_infos: List[test_info.TestInfo], 449 extra_args: Dict[str, Any], 450 _port: Optional[int] = None, 451 ) -> List[str]: 452 """Generates a list of run commands from TestInfos. 453 454 Args: 455 test_infos: A set of TestInfo instances. 456 extra_args: A Dict of extra args to append. 457 _port: Unused. 458 459 Returns: 460 A list of run commands to run the tests. 461 """ 462 # TODO: to be implemented 463 return [] 464 465 def _parse_custom_args(self, argv: list[str]) -> argparse.Namespace: 466 """Parse custom CLI args into Mobly runner options.""" 467 parser = argparse.ArgumentParser(prog='atest ... --') 468 parser.add_argument( 469 '--config', 470 help=( 471 'Path to a custom Mobly testbed config. Overrides all other ' 472 'configuration options.' 473 ), 474 ) 475 parser.add_argument( 476 '--testbed', 477 help=( 478 'Selects the name of the testbed to use for the test. Only ' 479 'use this option in conjunction with --config. Defaults to ' 480 '"LocalTestBed".' 481 ), 482 ) 483 parser.add_argument( 484 '--testparam', 485 metavar='PARAM=VALUE', 486 help=( 487 'A test param for Mobly, specified in the format ' 488 '"param=value". These values can then be accessed as ' 489 'TestClass.user_params in the test. This option is ' 490 'repeatable.' 491 ), 492 action='append', 493 ) 494 return parser.parse_args(argv) 495 496 def _get_rerun_options(self, extra_args: dict[str, Any]) -> RerunOptions: 497 """Get rerun options from extra_args.""" 498 iters = extra_args.get(constants.ITERATIONS, 1) 499 reruns = extra_args.get(constants.RERUN_UNTIL_FAILURE, 0) 500 retries = extra_args.get(constants.RETRY_ANY_FAILURE, 0) 501 return RerunOptions( 502 max(iters, reruns, retries), bool(reruns), bool(retries) 503 ) 504 505 def _get_test_files(self, tinfo: test_info.TestInfo) -> MoblyTestFiles: 506 """Gets test resource files from a given TestInfo.""" 507 mobly_pkg = None 508 requirements_txt = None 509 test_apks = [] 510 misc_data = [] 511 logging.debug('Getting test resource files for %s', tinfo.test_name) 512 for path in tinfo.data.get(constants.MODULE_INSTALLED): 513 path_str = str(path.expanduser().absolute()) 514 if not path.is_file(): 515 raise MoblyTestRunnerError(_ERROR_TEST_FILE_NOT_FOUND % path_str) 516 if path.name == tinfo.test_name: 517 mobly_pkg = path_str 518 elif path.name == FILE_REQUIREMENTS_TXT: 519 requirements_txt = path_str 520 elif path.suffix == FILE_SUFFIX_APK: 521 test_apks.append(path_str) 522 else: 523 misc_data.append(path_str) 524 logging.debug('Found test resource file %s.', path_str) 525 if mobly_pkg is None: 526 raise MoblyTestRunnerError(_ERROR_NO_MOBLY_TEST_PKG) 527 return MoblyTestFiles(mobly_pkg, requirements_txt, test_apks, misc_data) 528 529 def _generate_mobly_config( 530 self, 531 mobly_args: argparse.Namespace, 532 serials: List[str], 533 test_files: MoblyTestFiles, 534 ) -> str: 535 """Creates a Mobly YAML config given the test parameters. 536 537 If --config is specified, use that file as the testbed config. 538 539 If --serial is specified, the test will use those specific devices, 540 otherwise it will use all ADB-connected devices. 541 542 For each --testparam specified in custom args, the test will add the 543 param as a key-value pair under the testbed config's 'TestParams'. 544 Values are limited to strings. 545 546 Test resource paths (e.g. APKs) will be added to 'files' under 547 'TestParams' so they could be accessed from the test script. 548 549 Also set the Mobly results dir to <atest_results>/mobly_logs. 550 551 Args: 552 mobly_args: Custom args for the Mobly runner. 553 serials: List of device serials. 554 test_files: Files used by the Mobly test. 555 556 Returns: 557 Path to the generated config. 558 """ 559 if mobly_args.config: 560 config_path = os.path.abspath(os.path.expanduser(mobly_args.config)) 561 logging.debug('Using existing custom Mobly config at %s', config_path) 562 with open(config_path, encoding='utf-8') as f: 563 config = yaml.safe_load(f) 564 else: 565 local_testbed = { 566 CONFIG_KEY_NAME: LOCAL_TESTBED, 567 CONFIG_KEY_CONTROLLERS: { 568 CONFIG_KEY_ANDROID_DEVICE: serials if serials else '*', 569 }, 570 CONFIG_KEY_TEST_PARAMS: {}, 571 } 572 if mobly_args.testparam: 573 try: 574 local_testbed[CONFIG_KEY_TEST_PARAMS].update( 575 dict([param.split('=', 1) for param in mobly_args.testparam]) 576 ) 577 except ValueError as e: 578 raise MoblyTestRunnerError(_ERROR_INVALID_TESTPARAMS) from e 579 if test_files.test_apks or test_files.misc_data: 580 files = {} 581 files.update({ 582 Path(test_apk).stem: [test_apk] for test_apk in test_files.test_apks 583 }) 584 files.update({ 585 Path(misc_file).name: [misc_file] 586 for misc_file in test_files.misc_data 587 }) 588 local_testbed[CONFIG_KEY_TEST_PARAMS][CONFIG_KEY_FILES] = files 589 config = { 590 CONFIG_KEY_TESTBEDS: [local_testbed], 591 } 592 # Use ATest logs directory as the Mobly log path 593 log_path = os.path.join(self.results_dir, MOBLY_LOGS_DIR) 594 config[CONFIG_KEY_MOBLY_PARAMS] = { 595 CONFIG_KEY_LOG_PATH: log_path, 596 } 597 os.makedirs(log_path) 598 config_path = os.path.join(log_path, CONFIG_FILE) 599 logging.debug('Generating Mobly config at %s', config_path) 600 with open(config_path, 'w', encoding='utf-8') as f: 601 yaml.safe_dump(config, f, indent=4) 602 return config_path 603 604 def _setup_python_env(self, requirements_txt: Optional[str]) -> Optional[str]: 605 """Sets up the local Python environment. 606 607 If a requirements_txt file exists, creates a Python virtualenv and 608 install dependencies. Otherwise, run the Mobly test binary directly. 609 610 Args: 611 requirements_txt: Path to the requirements.txt file, where the PyPI 612 dependencies are declared. None if no such file exists. 613 614 Returns: 615 The virtualenv executable, or None. 616 """ 617 if requirements_txt is None: 618 logging.debug( 619 'No requirements.txt file found. Running Mobly test package directly.' 620 ) 621 return None 622 venv_dir = tempfile.mkdtemp(prefix='venv_') 623 logging.debug('Creating virtualenv at %s.', venv_dir) 624 subprocess.check_call([PYTHON_3_11, '-m', 'venv', venv_dir]) 625 self._temppaths.append(venv_dir) 626 venv_executable = os.path.join(venv_dir, 'bin', 'python') 627 628 # Install requirements 629 logging.debug('Installing dependencies from %s.', requirements_txt) 630 cmd = [venv_executable, '-m', 'pip', 'install', '-r', requirements_txt] 631 subprocess.check_call(cmd) 632 return venv_executable 633 634 def _get_cvd_serials(self) -> List[str]: 635 """Gets the serials of cvd devices available for the test. 636 637 Returns: 638 A list of device serials. 639 """ 640 if not ( 641 atest_configs.GLOBAL_ARGS.acloud_create 642 or atest_configs.GLOBAL_ARGS.start_avd 643 ): 644 return [] 645 devices = atest_utils.get_adb_devices() 646 return [ 647 device for device in devices if re.match(CVD_SERIAL_PATTERN, device) 648 ] 649 650 def _install_apks(self, apks: List[str], serials: List[str]) -> None: 651 """Installs test APKs to devices. 652 653 This can be toggled off by omitting the --install option. 654 655 If --serial is specified, the APK will be installed to those specific 656 devices, otherwise it will install to all ADB-connected devices. 657 658 Args: 659 apks: List of APK paths. 660 serials: List of device serials. 661 """ 662 serials = serials or atest_utils.get_adb_devices() 663 for apk in apks: 664 for serial in serials: 665 logging.debug('Installing APK %s to device %s.', apk, serial) 666 subprocess.check_call(['adb', '-s', serial, 'install', '-r', '-g', apk]) 667 668 def _get_test_cases_from_spec(self, tinfo: test_info.TestInfo) -> List[str]: 669 """Get the list of test cases to run from the user-specified filters. 670 671 Syntax for test_runner tests: 672 MODULE:.#TEST_CASE_1[,TEST_CASE_2,TEST_CASE_3...] 673 e.g.: `atest hello-world-test:.#test_hello,test_goodbye` -> 674 [test_hello, test_goodbye] 675 676 Syntax for suite_runner tests: 677 MODULE:TEST_CLASS#TEST_CASE_1[,TEST_CASE_2,TEST_CASE_3...] 678 e.g.: `atest hello-world-suite:HelloWorldTest#test_hello,test_goodbye` 679 -> [HelloWorldTest.test_hello, HelloWorldTest.test_goodbye] 680 681 Args: 682 tinfo: The TestInfo of the test. 683 684 Returns: List of test cases for the Mobly command. 685 """ 686 if not tinfo.data['filter']: 687 return [] 688 (test_filter,) = tinfo.data['filter'] 689 if test_filter.methods: 690 # If an actual class name is specified, assume this is a 691 # suite_runner test and use 'CLASS.METHOD' for the Mobly test 692 # selector. 693 if test_filter.class_name.isalnum(): 694 return [ 695 '%s.%s' % (test_filter.class_name, method) 696 for method in test_filter.methods 697 ] 698 # If the class name is a placeholder character (like '.'), assume 699 # this is a test_runner test and use just 'METHOD' for the Mobly 700 # test selector. 701 return list(test_filter.methods) 702 return [test_filter.class_name] 703 704 def _get_mobly_command( 705 self, 706 py_executable: str, 707 mobly_pkg: str, 708 config_path: str, 709 test_cases: List[str], 710 mobly_args: argparse.ArgumentParser, 711 ) -> List[str]: 712 """Generates a single Mobly test command. 713 714 Args: 715 py_executable: Path to the Python executable. 716 mobly_pkg: Path to the Mobly test package. 717 config_path: Path to the Mobly config. 718 test_cases: List of test cases to run. 719 mobly_args: Custom args for the Mobly runner. 720 721 Returns: 722 The full Mobly test command. 723 """ 724 command = [py_executable] if py_executable is not None else [] 725 command += [ 726 mobly_pkg, 727 '-c', 728 config_path, 729 '--test_bed', 730 mobly_args.testbed or LOCAL_TESTBED, 731 ] 732 if test_cases: 733 command += ['--tests', *test_cases] 734 return command 735 736 # pylint: disable=broad-except 737 # pylint: disable=too-many-arguments 738 def _run_and_handle_results( 739 self, 740 mobly_command: List[str], 741 tinfo: test_info.TestInfo, 742 rerun_options: RerunOptions, 743 mobly_args: argparse.ArgumentParser, 744 reporter: result_reporter.ResultReporter, 745 uploader: MoblyResultUploader, 746 ) -> int: 747 """Runs for the specified number of iterations and handles results. 748 749 Args: 750 mobly_command: Mobly command to run. 751 tinfo: The TestInfo of the test. 752 rerun_options: Rerun options for the test. 753 mobly_args: Custom args for the Mobly runner. 754 reporter: The ResultReporter for the test. 755 uploader: The MoblyResultUploader used to store results for upload. 756 757 Returns: 758 0 if tests succeed, non-zero otherwise. 759 """ 760 logging.debug( 761 'Running Mobly test %s for %d iteration(s). ' 762 'rerun-until-failure: %s, retry-any-failure: %s.', 763 tinfo.test_name, 764 rerun_options.iterations, 765 rerun_options.rerun_until_failure, 766 rerun_options.retry_any_failure, 767 ) 768 ret_code = atest_enum.ExitCode.SUCCESS 769 for iteration_num in range(rerun_options.iterations): 770 # Set up result reporter and uploader 771 reporter.runners.clear() 772 reporter.pre_test = None 773 uploader.start_new_workunit() 774 775 # Run the Mobly test command 776 curr_ret_code = self._run_mobly_command(mobly_command) 777 ret_code |= curr_ret_code 778 779 # Process results from generated summary file 780 latest_log_dir = os.path.join( 781 self.results_dir, 782 MOBLY_LOGS_DIR, 783 mobly_args.testbed or LOCAL_TESTBED, 784 LATEST_DIR, 785 ) 786 summary_file = os.path.join(latest_log_dir, TEST_SUMMARY_YAML) 787 test_results = self._process_test_results_from_summary( 788 summary_file, tinfo, iteration_num, rerun_options.iterations, uploader 789 ) 790 for test_result in test_results: 791 reporter.process_test_result(test_result) 792 reporter.set_current_iteration_summary(iteration_num) 793 try: 794 uploader.upload_test_results() 795 uploader.upload_test_artifacts(latest_log_dir) 796 uploader.set_workunit_iteration_details(iteration_num, rerun_options) 797 uploader.finalize_current_workunit() 798 except Exception as e: 799 logging.debug('Failed to upload test results. Error: %s', e) 800 801 # Break if run ending conditions are met 802 if (rerun_options.rerun_until_failure and curr_ret_code != 0) or ( 803 rerun_options.retry_any_failure and curr_ret_code == 0 804 ): 805 break 806 return ret_code 807 808 def _run_mobly_command(self, mobly_cmd: List[str]) -> int: 809 """Runs the Mobly test command. 810 811 Args: 812 mobly_cmd: Mobly command to run. 813 814 Returns: 815 Return code of the Mobly command. 816 """ 817 proc = self.run( 818 shlex.join(mobly_cmd), 819 output_to_stdout=bool(atest_configs.GLOBAL_ARGS.verbose), 820 ) 821 return self.wait_for_subprocess(proc) 822 823 # pylint: disable=too-many-locals 824 def _process_test_results_from_summary( 825 self, 826 summary_file: str, 827 tinfo: test_info.TestInfo, 828 iteration_num: int, 829 total_iterations: int, 830 uploader: MoblyResultUploader, 831 ) -> List[test_runner_base.TestResult]: 832 """Parses the Mobly summary file into test results for the ResultReporter 833 834 as well as the MoblyResultUploader. 835 836 Args: 837 summary_file: Path to the Mobly summary file. 838 tinfo: The TestInfo of the test. 839 iteration_num: The index of the current iteration. 840 total_iterations: The total number of iterations. 841 uploader: The MoblyResultUploader used to store results for upload. 842 """ 843 if not os.path.isfile(summary_file): 844 raise MoblyTestRunnerError(_ERROR_NO_TEST_SUMMARY) 845 846 # Find and parse 'Summary' section 847 logging.debug('Processing results from summary file %s.', summary_file) 848 with open(summary_file, 'r', encoding='utf-8') as f: 849 summary = list(yaml.safe_load_all(f)) 850 851 # Populate test results 852 reported_results = [] 853 records = [ 854 entry 855 for entry in summary 856 if entry[SUMMARY_KEY_TYPE] == SUMMARY_TYPE_RECORD 857 ] 858 for test_index, record in enumerate(records): 859 # Add result for result reporter 860 time_elapsed_ms = 0 861 if ( 862 record.get(SUMMARY_KEY_END_TIME) is not None 863 and record.get(SUMMARY_KEY_BEGIN_TIME) is not None 864 ): 865 time_elapsed_ms = ( 866 record[SUMMARY_KEY_END_TIME] - record[SUMMARY_KEY_BEGIN_TIME] 867 ) 868 test_run_name = record[SUMMARY_KEY_TEST_CLASS] 869 test_name = ( 870 f'{record[SUMMARY_KEY_TEST_CLASS]}.{record[SUMMARY_KEY_TEST_NAME]}' 871 ) 872 if total_iterations > 1: 873 test_run_name = f'{test_run_name} (#{iteration_num + 1})' 874 test_name = f'{test_name} (#{iteration_num + 1})' 875 reported_result = { 876 'runner_name': self.NAME, 877 'group_name': tinfo.test_name, 878 'test_run_name': test_run_name, 879 'test_name': test_name, 880 'status': get_result_reporter_status_from_mobly_result( 881 record[SUMMARY_KEY_RESULT] 882 ), 883 'details': record[SUMMARY_KEY_STACKTRACE], 884 'test_count': test_index + 1, 885 'group_total': len(records), 886 'test_time': str(datetime.timedelta(milliseconds=time_elapsed_ms)), 887 # Below values are unused 888 'runner_total': None, 889 'additional_info': {}, 890 } 891 reported_results.append(test_runner_base.TestResult(**reported_result)) 892 893 # Add result for upload (if enabled) 894 if uploader.enabled: 895 uploaded_result = { 896 'invocationId': uploader.invocation['invocationId'], 897 'workUnitId': uploader.current_workunit['id'], 898 'testIdentifier': { 899 'module': tinfo.test_name, 900 'testClass': record[SUMMARY_KEY_TEST_CLASS], 901 'method': record[SUMMARY_KEY_TEST_NAME], 902 }, 903 'testStatus': get_test_storage_status_from_mobly_result( 904 record[SUMMARY_KEY_RESULT] 905 ), 906 'timing': { 907 'creationTimestamp': record[SUMMARY_KEY_BEGIN_TIME], 908 'completeTimestamp': record[SUMMARY_KEY_END_TIME], 909 }, 910 } 911 if record[SUMMARY_KEY_RESULT] != SUMMARY_RESULT_PASS: 912 uploaded_result['debugInfo'] = { 913 'errorMessage': record[SUMMARY_KEY_DETAILS], 914 'trace': record[SUMMARY_KEY_STACKTRACE], 915 } 916 uploader.record_test_result(uploaded_result) 917 918 return reported_results 919 920 def _cleanup(self) -> None: 921 """Cleans up temporary host files/directories.""" 922 logging.debug('Cleaning up temporary dirs/files.') 923 for temppath in self._temppaths: 924 if os.path.isdir(temppath): 925 shutil.rmtree(temppath) 926 else: 927 os.remove(temppath) 928 self._temppaths.clear() 929 930 931def get_result_reporter_status_from_mobly_result(result: str): 932 """Maps Mobly result to a ResultReporter status.""" 933 return _MOBLY_RESULT_TO_RESULT_REPORTER_STATUS.get( 934 result, test_runner_base.ERROR_STATUS 935 ) 936 937 938def get_test_storage_status_from_mobly_result(result: str): 939 """Maps Mobly result to a test storage status.""" 940 return _MOBLY_RESULT_TO_TEST_STORAGE_STATUS.get( 941 result, TEST_STORAGE_STATUS_UNSPECIFIED 942 ) 943