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