1# Copyright 2021, 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"""Implementation of Atest's Bazel mode.
16
17Bazel mode runs tests using Bazel by generating a synthetic workspace that
18contains test targets. Using Bazel allows Atest to leverage features such as
19sandboxing, caching, and remote execution.
20"""
21# pylint: disable=missing-function-docstring
22# pylint: disable=missing-class-docstring
23# pylint: disable=too-many-lines
24
25from __future__ import annotations
26
27from abc import ABC, abstractmethod
28import argparse
29import atexit
30from collections import OrderedDict, defaultdict, deque
31from collections.abc import Iterable
32import contextlib
33import dataclasses
34import enum
35import functools
36import importlib.resources
37import logging
38import os
39import pathlib
40import re
41import shlex
42import shutil
43import subprocess
44import tempfile
45import time
46from types import MappingProxyType
47from typing import Any, Callable, Dict, IO, List, Set, Tuple
48import warnings
49from xml.etree import ElementTree as ET
50
51from atest import atest_configs
52from atest import atest_utils
53from atest import constants
54from atest import module_info
55from atest.atest_enum import DetectType, ExitCode
56from atest.metrics import metrics
57from atest.proto import file_md5_pb2
58from atest.test_finders import test_finder_base
59from atest.test_finders import test_info
60from atest.test_runners import atest_tf_test_runner as tfr
61from atest.test_runners import test_runner_base as trb
62from google.protobuf.message import DecodeError
63
64
65JDK_PACKAGE_NAME = 'prebuilts/robolectric_jdk'
66JDK_NAME = 'jdk'
67ROBOLECTRIC_CONFIG = 'build/make/core/robolectric_test_config_template.xml'
68
69BAZEL_TEST_LOGS_DIR_NAME = 'bazel-testlogs'
70TEST_OUTPUT_DIR_NAME = 'test.outputs'
71TEST_OUTPUT_ZIP_NAME = 'outputs.zip'
72
73_BAZEL_WORKSPACE_DIR = 'atest_bazel_workspace'
74_SUPPORTED_BAZEL_ARGS = MappingProxyType({
75    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--runs_per_test
76    constants.ITERATIONS: lambda arg_value: [
77        f'--runs_per_test={str(arg_value)}'
78    ],
79    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--flaky_test_attempts
80    constants.RETRY_ANY_FAILURE: lambda arg_value: [
81        f'--flaky_test_attempts={str(arg_value)}'
82    ],
83    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--test_output
84    constants.VERBOSE: (
85        lambda arg_value: ['--test_output=all'] if arg_value else []
86    ),
87    constants.BAZEL_ARG: lambda arg_value: [
88        item for sublist in arg_value for item in sublist
89    ],
90})
91
92# Maps Bazel configuration names to Soong variant names.
93_CONFIG_TO_VARIANT = {
94    'host': 'host',
95    'device': 'target',
96}
97
98
99class AbortRunException(Exception):
100  pass
101
102
103@enum.unique
104class Features(enum.Enum):
105  NULL_FEATURE = ('--null-feature', 'Enables a no-action feature.', True)
106  EXPERIMENTAL_DEVICE_DRIVEN_TEST = (
107      '--experimental-device-driven-test',
108      'Enables running device-driven tests in Bazel mode.',
109      True,
110  )
111  EXPERIMENTAL_REMOTE_AVD = (
112      '--experimental-remote-avd',
113      'Enables running device-driven tests in remote AVD.',
114      False,
115  )
116  EXPERIMENTAL_BES_PUBLISH = (
117      '--experimental-bes-publish',
118      'Upload test results via BES in Bazel mode.',
119      False,
120  )
121  EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES = (
122      '--experimental-java-runtime-dependencies',
123      (
124          'Mirrors Soong Java `libs` and `static_libs` as Bazel target '
125          'dependencies in the generated workspace. Tradefed test rules use '
126          'these dependencies to set up the execution environment and ensure '
127          'that all transitive runtime dependencies are present.'
128      ),
129      True,
130  )
131  EXPERIMENTAL_REMOTE = (
132      '--experimental-remote',
133      'Use Bazel remote execution and caching where supported.',
134      False,
135  )
136  EXPERIMENTAL_HOST_DRIVEN_TEST = (
137      '--experimental-host-driven-test',
138      'Enables running host-driven device tests in Bazel mode.',
139      True,
140  )
141  EXPERIMENTAL_ROBOLECTRIC_TEST = (
142      '--experimental-robolectric-test',
143      'Enables running Robolectric tests in Bazel mode.',
144      True,
145  )
146  NO_BAZEL_DETAILED_SUMMARY = (
147      '--no-bazel-detailed-summary',
148      'Disables printing detailed summary of Bazel test results.',
149      False,
150  )
151
152  def __init__(self, arg_flag, description, affects_workspace):
153    self._arg_flag = arg_flag
154    self._description = description
155    self.affects_workspace = affects_workspace
156
157  @property
158  def arg_flag(self):
159    return self._arg_flag
160
161  @property
162  def description(self):
163    return self._description
164
165
166def add_parser_arguments(parser: argparse.ArgumentParser, dest: str):
167  for _, member in Features.__members__.items():
168    parser.add_argument(
169        member.arg_flag,
170        action='append_const',
171        const=member,
172        dest=dest,
173        help=member.description,
174    )
175
176
177def get_bazel_workspace_dir() -> pathlib.Path:
178  return atest_utils.get_build_out_dir(_BAZEL_WORKSPACE_DIR)
179
180
181def generate_bazel_workspace(
182    mod_info: module_info.ModuleInfo, enabled_features: Set[Features] = None
183):
184  """Generate or update the Bazel workspace used for running tests."""
185
186  start = time.time()
187  src_root_path = pathlib.Path(os.environ.get(constants.ANDROID_BUILD_TOP))
188  workspace_path = get_bazel_workspace_dir()
189  resource_manager = ResourceManager(
190      src_root_path=src_root_path,
191      resource_root_path=_get_resource_root(),
192      product_out_path=pathlib.Path(
193          os.environ.get(constants.ANDROID_PRODUCT_OUT)
194      ),
195      md5_checksum_file_path=workspace_path.joinpath('workspace_md5_checksum'),
196  )
197  jdk_path = _read_robolectric_jdk_path(
198      resource_manager.get_src_file_path(ROBOLECTRIC_CONFIG, True)
199  )
200
201  workspace_generator = WorkspaceGenerator(
202      resource_manager=resource_manager,
203      workspace_out_path=workspace_path,
204      host_out_path=pathlib.Path(os.environ.get(constants.ANDROID_HOST_OUT)),
205      build_out_dir=atest_utils.get_build_out_dir(),
206      mod_info=mod_info,
207      jdk_path=jdk_path,
208      enabled_features=enabled_features,
209  )
210  workspace_generator.generate()
211
212  metrics.LocalDetectEvent(
213      detect_type=DetectType.BAZEL_WORKSPACE_GENERATE_TIME,
214      result=int(time.time() - start),
215  )
216
217
218def get_default_build_metadata():
219  return BuildMetadata(
220      atest_utils.get_manifest_branch(), atest_utils.get_build_target()
221  )
222
223
224class ResourceManager:
225  """Class for managing files required to generate a Bazel Workspace."""
226
227  def __init__(
228      self,
229      src_root_path: pathlib.Path,
230      resource_root_path: pathlib.Path,
231      product_out_path: pathlib.Path,
232      md5_checksum_file_path: pathlib.Path,
233  ):
234    self._root_type_to_path = {
235        file_md5_pb2.RootType.SRC_ROOT: src_root_path,
236        file_md5_pb2.RootType.RESOURCE_ROOT: resource_root_path,
237        file_md5_pb2.RootType.ABS_PATH: pathlib.Path(),
238        file_md5_pb2.RootType.PRODUCT_OUT: product_out_path,
239    }
240    self._md5_checksum_file = md5_checksum_file_path
241    self._file_checksum_list = file_md5_pb2.FileChecksumList()
242
243  def get_src_file_path(
244      self, rel_path: pathlib.Path = None, affects_workspace: bool = False
245  ) -> pathlib.Path:
246    """Get the abs file path from the relative path of source_root.
247
248    Args:
249        rel_path: A relative path of the source_root.
250        affects_workspace: A boolean of whether the file affects the workspace.
251
252    Returns:
253        A abs path of the file.
254    """
255    return self._get_file_path(
256        file_md5_pb2.RootType.SRC_ROOT, rel_path, affects_workspace
257    )
258
259  def get_resource_file_path(
260      self,
261      rel_path: pathlib.Path = None,
262      affects_workspace: bool = False,
263  ) -> pathlib.Path:
264    """Get the abs file path from the relative path of resource_root.
265
266    Args:
267        rel_path: A relative path of the resource_root.
268        affects_workspace: A boolean of whether the file affects the workspace.
269
270    Returns:
271        A abs path of the file.
272    """
273    return self._get_file_path(
274        file_md5_pb2.RootType.RESOURCE_ROOT, rel_path, affects_workspace
275    )
276
277  def get_product_out_file_path(
278      self, rel_path: pathlib.Path = None, affects_workspace: bool = False
279  ) -> pathlib.Path:
280    """Get the abs file path from the relative path of product out.
281
282    Args:
283        rel_path: A relative path to the product out.
284        affects_workspace: A boolean of whether the file affects the workspace.
285
286    Returns:
287        An abs path of the file.
288    """
289    return self._get_file_path(
290        file_md5_pb2.RootType.PRODUCT_OUT, rel_path, affects_workspace
291    )
292
293  def _get_file_path(
294      self,
295      root_type: file_md5_pb2.RootType,
296      rel_path: pathlib.Path,
297      affects_workspace: bool = True,
298  ) -> pathlib.Path:
299    abs_path = self._root_type_to_path[root_type].joinpath(
300        rel_path or pathlib.Path()
301    )
302
303    if not affects_workspace:
304      return abs_path
305
306    if abs_path.is_dir():
307      for file in abs_path.glob('**/*'):
308        self._register_file(root_type, file)
309    else:
310      self._register_file(root_type, abs_path)
311    return abs_path
312
313  def _register_file(
314      self, root_type: file_md5_pb2.RootType, abs_path: pathlib.Path
315  ):
316    if not abs_path.is_file():
317      logging.debug(' ignore %s: not a file.', abs_path)
318      return
319
320    rel_path = abs_path
321    if abs_path.is_relative_to(self._root_type_to_path[root_type]):
322      rel_path = abs_path.relative_to(self._root_type_to_path[root_type])
323
324    self._file_checksum_list.file_checksums.append(
325        file_md5_pb2.FileChecksum(
326            root_type=root_type,
327            rel_path=str(rel_path),
328            md5sum=atest_utils.md5sum(abs_path),
329        )
330    )
331
332  def register_file_with_abs_path(self, abs_path: pathlib.Path):
333    """Register a file which affects the workspace.
334
335    Args:
336        abs_path: A abs path of the file.
337    """
338    self._register_file(file_md5_pb2.RootType.ABS_PATH, abs_path)
339
340  def save_affects_files_md5(self):
341    with open(self._md5_checksum_file, 'wb') as f:
342      f.write(self._file_checksum_list.SerializeToString())
343
344  def check_affects_files_md5(self):
345    """Check all affect files are consistent with the actual MD5."""
346    if not self._md5_checksum_file.is_file():
347      return False
348
349    with open(self._md5_checksum_file, 'rb') as f:
350      file_md5_list = file_md5_pb2.FileChecksumList()
351
352      try:
353        file_md5_list.ParseFromString(f.read())
354      except DecodeError:
355        atest_utils.print_and_log_warning(
356            'Failed to parse the workspace md5 checksum file.'
357        )
358        return False
359
360      for file_md5 in file_md5_list.file_checksums:
361        abs_path = pathlib.Path(
362            self._root_type_to_path[file_md5.root_type]
363        ).joinpath(file_md5.rel_path)
364        if not abs_path.is_file():
365          return False
366        if atest_utils.md5sum(abs_path) != file_md5.md5sum:
367          return False
368      return True
369
370
371class WorkspaceGenerator:
372  """Class for generating a Bazel workspace."""
373
374  # pylint: disable=too-many-arguments
375  def __init__(
376      self,
377      resource_manager: ResourceManager,
378      workspace_out_path: pathlib.Path,
379      host_out_path: pathlib.Path,
380      build_out_dir: pathlib.Path,
381      mod_info: module_info.ModuleInfo,
382      jdk_path: pathlib.Path = None,
383      enabled_features: Set[Features] = None,
384  ):
385    """Initializes the generator.
386
387    Args:
388        workspace_out_path: Path where the workspace will be output.
389        host_out_path: Path of the ANDROID_HOST_OUT.
390        build_out_dir: Path of OUT_DIR
391        mod_info: ModuleInfo object.
392        enabled_features: Set of enabled features.
393    """
394    if (
395        enabled_features
396        and Features.EXPERIMENTAL_REMOTE_AVD in enabled_features
397        and Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST not in enabled_features
398    ):
399      raise ValueError(
400          'Cannot run remote device test because '
401          '"--experimental-device-driven-test" flag is'
402          ' not set.'
403      )
404    self.enabled_features = enabled_features or set()
405    self.resource_manager = resource_manager
406    self.workspace_out_path = workspace_out_path
407    self.host_out_path = host_out_path
408    self.build_out_dir = build_out_dir
409    self.mod_info = mod_info
410    self.path_to_package = {}
411    self.jdk_path = jdk_path
412
413  def generate(self):
414    """Generate a Bazel workspace.
415
416    If the workspace md5 checksum file doesn't exist or is stale, a new
417    workspace will be generated. Otherwise, the existing workspace will be
418    reused.
419    """
420    start = time.time()
421    enabled_features_file = self.workspace_out_path.joinpath(
422        'atest_bazel_mode_enabled_features'
423    )
424    enabled_features_file_contents = '\n'.join(
425        sorted(f.name for f in self.enabled_features if f.affects_workspace)
426    )
427
428    if self.workspace_out_path.exists():
429      # Update the file with the set of the currently enabled features to
430      # make sure that changes are detected in the workspace checksum.
431      enabled_features_file.write_text(enabled_features_file_contents)
432      if self.resource_manager.check_affects_files_md5():
433        return
434
435      # We raise an exception if rmtree fails to avoid leaving stale
436      # files in the workspace that could interfere with execution.
437      shutil.rmtree(self.workspace_out_path)
438
439    atest_utils.colorful_print('Generating Bazel workspace.\n', constants.RED)
440
441    self._add_test_module_targets()
442
443    self.workspace_out_path.mkdir(parents=True)
444    self._generate_artifacts()
445
446    # Note that we write the set of enabled features despite having written
447    # it above since the workspace no longer exists at this point.
448    enabled_features_file.write_text(enabled_features_file_contents)
449
450    self.resource_manager.get_product_out_file_path(
451        self.mod_info.mod_info_file_path.relative_to(
452            self.resource_manager.get_product_out_file_path()
453        ),
454        True,
455    )
456    self.resource_manager.register_file_with_abs_path(enabled_features_file)
457    self.resource_manager.save_affects_files_md5()
458    metrics.LocalDetectEvent(
459        detect_type=DetectType.FULL_GENERATE_BAZEL_WORKSPACE_TIME,
460        result=int(time.time() - start),
461    )
462
463  def _add_test_module_targets(self):
464    seen = set()
465
466    for name, info in self.mod_info.name_to_module_info.items():
467      # Ignore modules that have a 'host_cross_' prefix since they are
468      # duplicates of existing modules. For example,
469      # 'host_cross_aapt2_tests' is a duplicate of 'aapt2_tests'. We also
470      # ignore modules with a '_32' suffix since these also are redundant
471      # given that modules have both 32 and 64-bit variants built by
472      # default. See b/77288544#comment6 and b/23566667 for more context.
473      if name.endswith('_32') or name.startswith('host_cross_'):
474        continue
475
476      if (
477          Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in self.enabled_features
478          and self.mod_info.is_device_driven_test(info)
479      ):
480        self._resolve_dependencies(
481            self._add_device_test_target(info, False), seen
482        )
483
484      if self.mod_info.is_host_unit_test(info):
485        self._resolve_dependencies(self._add_deviceless_test_target(info), seen)
486      elif (
487          Features.EXPERIMENTAL_ROBOLECTRIC_TEST in self.enabled_features
488          and self.mod_info.is_modern_robolectric_test(info)
489      ):
490        self._resolve_dependencies(
491            self._add_tradefed_robolectric_test_target(info), seen
492        )
493      elif (
494          Features.EXPERIMENTAL_HOST_DRIVEN_TEST in self.enabled_features
495          and self.mod_info.is_host_driven_test(info)
496      ):
497        self._resolve_dependencies(
498            self._add_device_test_target(info, True), seen
499        )
500
501  def _resolve_dependencies(self, top_level_target: Target, seen: Set[Target]):
502
503    stack = [deque([top_level_target])]
504
505    while stack:
506      top = stack[-1]
507
508      if not top:
509        stack.pop()
510        continue
511
512      target = top.popleft()
513
514      # Note that we're relying on Python's default identity-based hash
515      # and equality methods. This is fine since we actually DO want
516      # reference-equality semantics for Target objects in this context.
517      if target in seen:
518        continue
519
520      seen.add(target)
521
522      next_top = deque()
523
524      for ref in target.dependencies():
525        info = ref.info or self._get_module_info(ref.name)
526        ref.set(self._add_prebuilt_target(info))
527        next_top.append(ref.target())
528
529      stack.append(next_top)
530
531  def _add_device_test_target(
532      self, info: Dict[str, Any], is_host_driven: bool
533  ) -> Target:
534    package_name = self._get_module_path(info)
535    name_suffix = 'host' if is_host_driven else 'device'
536    name = f'{info[constants.MODULE_INFO_ID]}_{name_suffix}'
537
538    def create():
539      return TestTarget.create_device_test_target(
540          name,
541          package_name,
542          info,
543          is_host_driven,
544      )
545
546    return self._add_target(package_name, name, create)
547
548  def _add_deviceless_test_target(self, info: Dict[str, Any]) -> Target:
549    package_name = self._get_module_path(info)
550    name = f'{info[constants.MODULE_INFO_ID]}_host'
551
552    def create():
553      return TestTarget.create_deviceless_test_target(
554          name,
555          package_name,
556          info,
557      )
558
559    return self._add_target(package_name, name, create)
560
561  def _add_tradefed_robolectric_test_target(
562      self, info: Dict[str, Any]
563  ) -> Target:
564    package_name = self._get_module_path(info)
565    name = f'{info[constants.MODULE_INFO_ID]}_host'
566
567    return self._add_target(
568        package_name,
569        name,
570        lambda: TestTarget.create_tradefed_robolectric_test_target(
571            name, package_name, info, f'//{JDK_PACKAGE_NAME}:{JDK_NAME}'
572        ),
573    )
574
575  def _add_prebuilt_target(self, info: Dict[str, Any]) -> Target:
576    package_name = self._get_module_path(info)
577    name = info[constants.MODULE_INFO_ID]
578
579    def create():
580      return SoongPrebuiltTarget.create(
581          self,
582          info,
583          package_name,
584      )
585
586    return self._add_target(package_name, name, create)
587
588  def _add_target(
589      self, package_path: str, target_name: str, create_fn: Callable
590  ) -> Target:
591
592    package = self.path_to_package.get(package_path)
593
594    if not package:
595      package = Package(package_path)
596      self.path_to_package[package_path] = package
597
598    target = package.get_target(target_name)
599
600    if target:
601      return target
602
603    target = create_fn()
604    package.add_target(target)
605
606    return target
607
608  def _get_module_info(self, module_name: str) -> Dict[str, Any]:
609    info = self.mod_info.get_module_info(module_name)
610
611    if not info:
612      raise LookupError(
613          f'Could not find module `{module_name}` in module_info file'
614      )
615
616    return info
617
618  def _get_module_path(self, info: Dict[str, Any]) -> str:
619    mod_path = info.get(constants.MODULE_PATH)
620
621    if len(mod_path) < 1:
622      module_name = info['module_name']
623      raise ValueError(f'Module `{module_name}` does not have any path')
624
625    if len(mod_path) > 1:
626      module_name = info['module_name']
627      # We usually have a single path but there are a few exceptions for
628      # modules like libLLVM_android and libclang_android.
629      # TODO(yangbill): Raise an exception for multiple paths once
630      # b/233581382 is resolved.
631      warnings.formatwarning = lambda msg, *args, **kwargs: f'{msg}\n'
632      warnings.warn(
633          f'Module `{module_name}` has more than one path: `{mod_path}`'
634      )
635
636    return mod_path[0]
637
638  def _generate_artifacts(self):
639    """Generate workspace files on disk."""
640
641    self._create_base_files()
642
643    self._add_workspace_resource(src='rules', dst='bazel/rules')
644    self._add_workspace_resource(src='configs', dst='bazel/configs')
645
646    if Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in self.enabled_features:
647      self._add_workspace_resource(src='device_def', dst='device_def')
648
649    self._add_bazel_bootstrap_files()
650
651    # Symlink to package with toolchain definitions.
652    self._symlink(src='prebuilts/build-tools', target='prebuilts/build-tools')
653
654    device_infra_path = 'vendor/google/tools/atest/device_infra'
655    if self.resource_manager.get_src_file_path(device_infra_path).exists():
656      self._symlink(src=device_infra_path, target=device_infra_path)
657
658    self._link_required_src_file_path('external/bazelbuild-rules_python')
659    self._link_required_src_file_path('external/bazelbuild-rules_java')
660
661    self._create_constants_file()
662
663    self._generate_robolectric_resources()
664
665    for package in self.path_to_package.values():
666      package.generate(self.workspace_out_path)
667
668  def _generate_robolectric_resources(self):
669    if not self.jdk_path:
670      return
671
672    self._generate_jdk_resources()
673    self._generate_android_all_resources()
674
675  def _generate_jdk_resources(self):
676    # TODO(b/265596946): Create the JDK toolchain instead of using
677    # a filegroup.
678    return self._add_target(
679        JDK_PACKAGE_NAME,
680        JDK_NAME,
681        lambda: FilegroupTarget(
682            JDK_PACKAGE_NAME,
683            JDK_NAME,
684            self.resource_manager.get_src_file_path(self.jdk_path),
685        ),
686    )
687
688  def _generate_android_all_resources(self):
689    package_name = 'android-all'
690    name = 'android-all'
691
692    return self._add_target(
693        package_name,
694        name,
695        lambda: FilegroupTarget(
696            package_name, name, self.host_out_path.joinpath(f'testcases/{name}')
697        ),
698    )
699
700  def _symlink(self, *, src, target):
701    """Create a symbolic link in workspace pointing to source file/dir.
702
703    Args:
704        src: A string of a relative path to root of Android source tree. This is
705          the source file/dir path for which the symbolic link will be created.
706        target: A string of a relative path to workspace root. This is the
707          target file/dir path where the symbolic link will be created.
708    """
709    symlink = self.workspace_out_path.joinpath(target)
710    symlink.parent.mkdir(parents=True, exist_ok=True)
711    symlink.symlink_to(self.resource_manager.get_src_file_path(src))
712
713  def _create_base_files(self):
714    self._add_workspace_resource(src='WORKSPACE', dst='WORKSPACE')
715    self._add_workspace_resource(src='bazelrc', dst='.bazelrc')
716
717    self.workspace_out_path.joinpath('BUILD.bazel').touch()
718
719  def _add_bazel_bootstrap_files(self):
720    self._symlink(
721        src='tools/asuite/atest/bazel/resources/bazel.sh', target='bazel.sh'
722    )
723    self._symlink(
724        src='prebuilts/jdk/jdk17/BUILD.bazel',
725        target='prebuilts/jdk/jdk17/BUILD.bazel',
726    )
727    self._symlink(
728        src='prebuilts/jdk/jdk17/linux-x86',
729        target='prebuilts/jdk/jdk17/linux-x86',
730    )
731    self._symlink(
732        src='prebuilts/bazel/linux-x86_64/bazel',
733        target='prebuilts/bazel/linux-x86_64/bazel',
734    )
735
736  def _add_workspace_resource(self, src, dst):
737    """Add resource to the given destination in workspace.
738
739    Args:
740        src: A string of a relative path to root of Bazel artifacts. This is the
741          source file/dir path that will be added to workspace.
742        dst: A string of a relative path to workspace root. This is the
743          destination file/dir path where the artifacts will be added.
744    """
745    src = self.resource_manager.get_resource_file_path(src, True)
746    dst = self.workspace_out_path.joinpath(dst)
747    dst.parent.mkdir(parents=True, exist_ok=True)
748
749    if src.is_file():
750      shutil.copy(src, dst)
751    else:
752      shutil.copytree(src, dst, ignore=shutil.ignore_patterns('__init__.py'))
753
754  def _create_constants_file(self):
755    def variable_name(target_name):
756      return re.sub(r'[.-]', '_', target_name) + '_label'
757
758    targets = []
759    seen = set()
760
761    for module_name in TestTarget.DEVICELESS_TEST_PREREQUISITES.union(
762        TestTarget.DEVICE_TEST_PREREQUISITES
763    ):
764      info = self.mod_info.get_module_info(module_name)
765      target = self._add_prebuilt_target(info)
766      self._resolve_dependencies(target, seen)
767      targets.append(target)
768
769    with self.workspace_out_path.joinpath('constants.bzl').open('w') as f:
770      writer = IndentWriter(f)
771      for target in targets:
772        writer.write_line(
773            '%s = "%s"'
774            % (variable_name(target.name()), target.qualified_name())
775        )
776
777  def _link_required_src_file_path(self, path):
778    if not self.resource_manager.get_src_file_path(path).exists():
779      raise RuntimeError(f'Path `{path}` does not exist in source tree.')
780
781    self._symlink(src=path, target=path)
782
783
784@functools.cache
785def _get_resource_root() -> pathlib.Path:
786  tmp_resource_dir = pathlib.Path(tempfile.mkdtemp())
787  atexit.register(lambda: shutil.rmtree(tmp_resource_dir))
788
789  def _extract_resources(
790      resource_path: pathlib.Path,
791      dst: pathlib.Path,
792      ignore_file_names: list[str] = None,
793  ):
794    resource = importlib.resources.files(resource_path.as_posix())
795    dst.mkdir(parents=True, exist_ok=True)
796    for child in resource.iterdir():
797      if child.is_file():
798        if child.name in ignore_file_names:
799          continue
800        with importlib.resources.as_file(child) as child_file:
801          shutil.copy(child_file, dst.joinpath(child.name))
802      elif child.is_dir():
803        _extract_resources(
804            resource_path.joinpath(child.name),
805            dst.joinpath(child.name),
806            ignore_file_names,
807        )
808      else:
809        atest_utils.print_and_log_warning(
810            'Ignoring unknown resource: %s', child
811        )
812
813  try:
814    _extract_resources(
815        pathlib.Path('atest/bazel/resources'),
816        tmp_resource_dir,
817        ignore_file_names=['__init__.py'],
818    )
819  except ModuleNotFoundError as e:
820    logging.debug(
821        'Bazel resource not found from package path, possible due to running'
822        ' atest from source. Returning resource source path instead: %s',
823        e,
824    )
825    return pathlib.Path(os.path.dirname(__file__)).joinpath('bazel/resources')
826
827  return tmp_resource_dir
828
829
830class Package:
831  """Class for generating an entire Package on disk."""
832
833  def __init__(self, path: str):
834    self.path = path
835    self.imports = defaultdict(set)
836    self.name_to_target = OrderedDict()
837
838  def add_target(self, target):
839    target_name = target.name()
840
841    if target_name in self.name_to_target:
842      raise ValueError(
843          f'Cannot add target `{target_name}` which already'
844          f' exists in package `{self.path}`'
845      )
846
847    self.name_to_target[target_name] = target
848
849    for i in target.required_imports():
850      self.imports[i.bzl_package].add(i.symbol)
851
852  def generate(self, workspace_out_path: pathlib.Path):
853    package_dir = workspace_out_path.joinpath(self.path)
854    package_dir.mkdir(parents=True, exist_ok=True)
855
856    self._create_filesystem_layout(package_dir)
857    self._write_build_file(package_dir)
858
859  def _create_filesystem_layout(self, package_dir: pathlib.Path):
860    for target in self.name_to_target.values():
861      target.create_filesystem_layout(package_dir)
862
863  def _write_build_file(self, package_dir: pathlib.Path):
864    with package_dir.joinpath('BUILD.bazel').open('w') as f:
865      f.write('package(default_visibility = ["//visibility:public"])\n')
866      f.write('\n')
867
868      for bzl_package, symbols in sorted(self.imports.items()):
869        symbols_text = ', '.join('"%s"' % s for s in sorted(symbols))
870        f.write(f'load("{bzl_package}", {symbols_text})\n')
871
872      for target in self.name_to_target.values():
873        f.write('\n')
874        target.write_to_build_file(f)
875
876  def get_target(self, target_name: str) -> Target:
877    return self.name_to_target.get(target_name, None)
878
879
880@dataclasses.dataclass(frozen=True)
881class Import:
882  bzl_package: str
883  symbol: str
884
885
886@dataclasses.dataclass(frozen=True)
887class Config:
888  name: str
889  out_path: pathlib.Path
890
891
892class ModuleRef:
893
894  @staticmethod
895  def for_info(info) -> ModuleRef:
896    return ModuleRef(info=info)
897
898  @staticmethod
899  def for_name(name) -> ModuleRef:
900    return ModuleRef(name=name)
901
902  def __init__(self, info=None, name=None):
903    self.info = info
904    self.name = name
905    self._target = None
906
907  def target(self) -> Target:
908    if not self._target:
909      target_name = self.info[constants.MODULE_INFO_ID]
910      raise ValueError(f'Target not set for ref `{target_name}`')
911
912    return self._target
913
914  def set(self, target):
915    self._target = target
916
917
918class Target(ABC):
919  """Abstract class for a Bazel target."""
920
921  @abstractmethod
922  def name(self) -> str:
923    pass
924
925  def package_name(self) -> str:
926    pass
927
928  def qualified_name(self) -> str:
929    return f'//{self.package_name()}:{self.name()}'
930
931  def required_imports(self) -> Set[Import]:
932    return set()
933
934  def supported_configs(self) -> Set[Config]:
935    return set()
936
937  def dependencies(self) -> List[ModuleRef]:
938    return []
939
940  def write_to_build_file(self, f: IO):
941    pass
942
943  def create_filesystem_layout(self, package_dir: pathlib.Path):
944    pass
945
946
947class FilegroupTarget(Target):
948
949  def __init__(
950      self, package_name: str, target_name: str, srcs_root: pathlib.Path
951  ):
952    self._package_name = package_name
953    self._target_name = target_name
954    self._srcs_root = srcs_root
955
956  def name(self) -> str:
957    return self._target_name
958
959  def package_name(self) -> str:
960    return self._package_name
961
962  def write_to_build_file(self, f: IO):
963    writer = IndentWriter(f)
964    build_file_writer = BuildFileWriter(writer)
965
966    writer.write_line('filegroup(')
967
968    with writer.indent():
969      build_file_writer.write_string_attribute('name', self._target_name)
970      build_file_writer.write_glob_attribute(
971          'srcs', [f'{self._target_name}_files/**']
972      )
973
974    writer.write_line(')')
975
976  def create_filesystem_layout(self, package_dir: pathlib.Path):
977    symlink = package_dir.joinpath(f'{self._target_name}_files')
978    symlink.symlink_to(self._srcs_root)
979
980
981class TestTarget(Target):
982  """Class for generating a test target."""
983
984  DEVICELESS_TEST_PREREQUISITES = frozenset({
985      'adb',
986      'atest-tradefed',
987      'atest_script_help.sh',
988      'atest_tradefed.sh',
989      'tradefed',
990      'tradefed-test-framework',
991      'bazel-result-reporter',
992  })
993
994  DEVICE_TEST_PREREQUISITES = frozenset(
995      DEVICELESS_TEST_PREREQUISITES.union(
996          frozenset({
997              'aapt',
998              'aapt2',
999              'compatibility-tradefed',
1000              'vts-core-tradefed-harness',
1001          })
1002      )
1003  )
1004
1005  @staticmethod
1006  def create_deviceless_test_target(
1007      name: str, package_name: str, info: Dict[str, Any]
1008  ):
1009    return TestTarget(
1010        package_name,
1011        'tradefed_deviceless_test',
1012        {
1013            'name': name,
1014            'test': ModuleRef.for_info(info),
1015            'module_name': info['module_name'],
1016            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1017        },
1018        TestTarget.DEVICELESS_TEST_PREREQUISITES,
1019    )
1020
1021  @staticmethod
1022  def create_device_test_target(
1023      name: str, package_name: str, info: Dict[str, Any], is_host_driven: bool
1024  ):
1025    rule = (
1026        'tradefed_host_driven_device_test'
1027        if is_host_driven
1028        else 'tradefed_device_driven_test'
1029    )
1030
1031    return TestTarget(
1032        package_name,
1033        rule,
1034        {
1035            'name': name,
1036            'test': ModuleRef.for_info(info),
1037            'module_name': info['module_name'],
1038            'suites': set(info.get(constants.MODULE_COMPATIBILITY_SUITES, [])),
1039            'tradefed_deps': list(
1040                map(
1041                    ModuleRef.for_name, info.get(constants.MODULE_HOST_DEPS, [])
1042                )
1043            ),
1044            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1045        },
1046        TestTarget.DEVICE_TEST_PREREQUISITES,
1047    )
1048
1049  @staticmethod
1050  def create_tradefed_robolectric_test_target(
1051      name: str, package_name: str, info: Dict[str, Any], jdk_label: str
1052  ):
1053    return TestTarget(
1054        package_name,
1055        'tradefed_robolectric_test',
1056        {
1057            'name': name,
1058            'test': ModuleRef.for_info(info),
1059            'module_name': info['module_name'],
1060            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1061            'jdk': jdk_label,
1062        },
1063        TestTarget.DEVICELESS_TEST_PREREQUISITES,
1064    )
1065
1066  def __init__(
1067      self,
1068      package_name: str,
1069      rule_name: str,
1070      attributes: Dict[str, Any],
1071      prerequisites=frozenset(),
1072  ):
1073    self._attributes = attributes
1074    self._package_name = package_name
1075    self._rule_name = rule_name
1076    self._prerequisites = prerequisites
1077
1078  def name(self) -> str:
1079    return self._attributes['name']
1080
1081  def package_name(self) -> str:
1082    return self._package_name
1083
1084  def required_imports(self) -> Set[Import]:
1085    return {Import('//bazel/rules:tradefed_test.bzl', self._rule_name)}
1086
1087  def dependencies(self) -> List[ModuleRef]:
1088    prerequisite_refs = map(ModuleRef.for_name, self._prerequisites)
1089
1090    declared_dep_refs = []
1091    for value in self._attributes.values():
1092      if isinstance(value, Iterable):
1093        declared_dep_refs.extend(
1094            [dep for dep in value if isinstance(dep, ModuleRef)]
1095        )
1096      elif isinstance(value, ModuleRef):
1097        declared_dep_refs.append(value)
1098
1099    return declared_dep_refs + list(prerequisite_refs)
1100
1101  def write_to_build_file(self, f: IO):
1102    prebuilt_target_name = self._attributes['test'].target().qualified_name()
1103    writer = IndentWriter(f)
1104    build_file_writer = BuildFileWriter(writer)
1105
1106    writer.write_line(f'{self._rule_name}(')
1107
1108    with writer.indent():
1109      build_file_writer.write_string_attribute('name', self._attributes['name'])
1110
1111      build_file_writer.write_string_attribute(
1112          'module_name', self._attributes['module_name']
1113      )
1114
1115      build_file_writer.write_string_attribute('test', prebuilt_target_name)
1116
1117      build_file_writer.write_label_list_attribute(
1118          'tradefed_deps', self._attributes.get('tradefed_deps')
1119      )
1120
1121      build_file_writer.write_string_list_attribute(
1122          'suites', sorted(self._attributes.get('suites', []))
1123      )
1124
1125      build_file_writer.write_string_list_attribute(
1126          'tags', sorted(self._attributes.get('tags', []))
1127      )
1128
1129      build_file_writer.write_label_attribute(
1130          'jdk', self._attributes.get('jdk', None)
1131      )
1132
1133    writer.write_line(')')
1134
1135
1136def _read_robolectric_jdk_path(
1137    test_xml_config_template: pathlib.Path,
1138) -> pathlib.Path:
1139  if not test_xml_config_template.is_file():
1140    return None
1141
1142  xml_root = ET.parse(test_xml_config_template).getroot()
1143  option = xml_root.find(".//option[@name='java-folder']")
1144  jdk_path = pathlib.Path(option.get('value', ''))
1145
1146  if not jdk_path.is_relative_to('prebuilts/jdk'):
1147    raise ValueError(
1148        f'Failed to get "java-folder" from `{test_xml_config_template}`'
1149    )
1150
1151  return jdk_path
1152
1153
1154class BuildFileWriter:
1155  """Class for writing BUILD files."""
1156
1157  def __init__(self, underlying: IndentWriter):
1158    self._underlying = underlying
1159
1160  def write_string_attribute(self, attribute_name, value):
1161    if value is None:
1162      return
1163
1164    self._underlying.write_line(f'{attribute_name} = "{value}",')
1165
1166  def write_label_attribute(self, attribute_name: str, label_name: str):
1167    if label_name is None:
1168      return
1169
1170    self._underlying.write_line(f'{attribute_name} = "{label_name}",')
1171
1172  def write_string_list_attribute(self, attribute_name, values):
1173    if not values:
1174      return
1175
1176    self._underlying.write_line(f'{attribute_name} = [')
1177
1178    with self._underlying.indent():
1179      for value in values:
1180        self._underlying.write_line(f'"{value}",')
1181
1182    self._underlying.write_line('],')
1183
1184  def write_label_list_attribute(
1185      self, attribute_name: str, modules: List[ModuleRef]
1186  ):
1187    if not modules:
1188      return
1189
1190    self._underlying.write_line(f'{attribute_name} = [')
1191
1192    with self._underlying.indent():
1193      for label in sorted(set(m.target().qualified_name() for m in modules)):
1194        self._underlying.write_line(f'"{label}",')
1195
1196    self._underlying.write_line('],')
1197
1198  def write_glob_attribute(self, attribute_name: str, patterns: List[str]):
1199    self._underlying.write_line(f'{attribute_name} = glob([')
1200
1201    with self._underlying.indent():
1202      for pattern in patterns:
1203        self._underlying.write_line(f'"{pattern}",')
1204
1205    self._underlying.write_line(']),')
1206
1207
1208@dataclasses.dataclass(frozen=True)
1209class Dependencies:
1210  static_dep_refs: List[ModuleRef]
1211  runtime_dep_refs: List[ModuleRef]
1212  data_dep_refs: List[ModuleRef]
1213  device_data_dep_refs: List[ModuleRef]
1214
1215
1216class SoongPrebuiltTarget(Target):
1217  """Class for generating a Soong prebuilt target on disk."""
1218
1219  @staticmethod
1220  def create(
1221      gen: WorkspaceGenerator, info: Dict[str, Any], package_name: str = ''
1222  ):
1223    module_name = info['module_name']
1224
1225    configs = [
1226        Config('host', gen.host_out_path),
1227        Config('device', gen.resource_manager.get_product_out_file_path()),
1228    ]
1229
1230    installed_paths = get_module_installed_paths(
1231        info, gen.resource_manager.get_src_file_path()
1232    )
1233    config_files = group_paths_by_config(configs, installed_paths)
1234
1235    # For test modules, we only create symbolic link to the 'testcases'
1236    # directory since the information in module-info is not accurate.
1237    if gen.mod_info.is_tradefed_testable_module(info):
1238      config_files = {
1239          c: [c.out_path.joinpath(f'testcases/{module_name}')]
1240          for c in config_files.keys()
1241      }
1242
1243    enabled_features = gen.enabled_features
1244
1245    return SoongPrebuiltTarget(
1246        info,
1247        package_name,
1248        config_files,
1249        Dependencies(
1250            static_dep_refs=find_static_dep_refs(
1251                gen.mod_info,
1252                info,
1253                configs,
1254                gen.resource_manager.get_src_file_path(),
1255                enabled_features,
1256            ),
1257            runtime_dep_refs=find_runtime_dep_refs(
1258                gen.mod_info,
1259                info,
1260                configs,
1261                gen.resource_manager.get_src_file_path(),
1262                enabled_features,
1263            ),
1264            data_dep_refs=find_data_dep_refs(
1265                gen.mod_info,
1266                info,
1267                configs,
1268                gen.resource_manager.get_src_file_path(),
1269            ),
1270            device_data_dep_refs=find_device_data_dep_refs(gen, info),
1271        ),
1272        [
1273            c
1274            for c in configs
1275            if c.name
1276            in map(str.lower, info.get(constants.MODULE_SUPPORTED_VARIANTS, []))
1277        ],
1278    )
1279
1280  def __init__(
1281      self,
1282      info: Dict[str, Any],
1283      package_name: str,
1284      config_files: Dict[Config, List[pathlib.Path]],
1285      deps: Dependencies,
1286      supported_configs: List[Config],
1287  ):
1288    self._target_name = info[constants.MODULE_INFO_ID]
1289    self._module_name = info[constants.MODULE_NAME]
1290    self._package_name = package_name
1291    self.config_files = config_files
1292    self.deps = deps
1293    self.suites = info.get(constants.MODULE_COMPATIBILITY_SUITES, [])
1294    self._supported_configs = supported_configs
1295
1296  def name(self) -> str:
1297    return self._target_name
1298
1299  def package_name(self) -> str:
1300    return self._package_name
1301
1302  def required_imports(self) -> Set[Import]:
1303    return {
1304        Import('//bazel/rules:soong_prebuilt.bzl', self._rule_name()),
1305    }
1306
1307  @functools.lru_cache(maxsize=128)
1308  def supported_configs(self) -> Set[Config]:
1309    # We deduce the supported configs from the installed paths since the
1310    # build exports incorrect metadata for some module types such as
1311    # Robolectric. The information exported from the build is only used if
1312    # the module does not have any installed paths.
1313    # TODO(b/232929584): Remove this once all modules correctly export the
1314    #  supported variants.
1315    supported_configs = set(self.config_files.keys())
1316    if supported_configs:
1317      return supported_configs
1318
1319    return self._supported_configs
1320
1321  def dependencies(self) -> List[ModuleRef]:
1322    all_deps = set(self.deps.runtime_dep_refs)
1323    all_deps.update(self.deps.data_dep_refs)
1324    all_deps.update(self.deps.device_data_dep_refs)
1325    all_deps.update(self.deps.static_dep_refs)
1326    return list(all_deps)
1327
1328  def write_to_build_file(self, f: IO):
1329    writer = IndentWriter(f)
1330    build_file_writer = BuildFileWriter(writer)
1331
1332    writer.write_line(f'{self._rule_name()}(')
1333
1334    with writer.indent():
1335      writer.write_line(f'name = "{self._target_name}",')
1336      writer.write_line(f'module_name = "{self._module_name}",')
1337      self._write_files_attribute(writer)
1338      self._write_deps_attribute(
1339          writer, 'static_deps', self.deps.static_dep_refs
1340      )
1341      self._write_deps_attribute(
1342          writer, 'runtime_deps', self.deps.runtime_dep_refs
1343      )
1344      self._write_deps_attribute(writer, 'data', self.deps.data_dep_refs)
1345
1346      build_file_writer.write_label_list_attribute(
1347          'device_data', self.deps.device_data_dep_refs
1348      )
1349      build_file_writer.write_string_list_attribute(
1350          'suites', sorted(self.suites)
1351      )
1352
1353    writer.write_line(')')
1354
1355  def create_filesystem_layout(self, package_dir: pathlib.Path):
1356    prebuilts_dir = package_dir.joinpath(self._target_name)
1357    prebuilts_dir.mkdir()
1358
1359    for config, files in self.config_files.items():
1360      config_prebuilts_dir = prebuilts_dir.joinpath(config.name)
1361      config_prebuilts_dir.mkdir()
1362
1363      for f in files:
1364        rel_path = f.relative_to(config.out_path)
1365        symlink = config_prebuilts_dir.joinpath(rel_path)
1366        symlink.parent.mkdir(parents=True, exist_ok=True)
1367        symlink.symlink_to(f)
1368
1369  def _rule_name(self):
1370    return (
1371        'soong_prebuilt' if self.config_files else 'soong_uninstalled_prebuilt'
1372    )
1373
1374  def _write_files_attribute(self, writer: IndentWriter):
1375    if not self.config_files:
1376      return
1377
1378    writer.write('files = ')
1379    write_config_select(
1380        writer,
1381        self.config_files,
1382        lambda c, _: writer.write(
1383            f'glob(["{self._target_name}/{c.name}/**/*"])'
1384        ),
1385    )
1386    writer.write_line(',')
1387
1388  def _write_deps_attribute(self, writer, attribute_name, module_refs):
1389    config_deps = filter_configs(
1390        group_targets_by_config(r.target() for r in module_refs),
1391        self.supported_configs(),
1392    )
1393
1394    if not config_deps:
1395      return
1396
1397    for config in self.supported_configs():
1398      config_deps.setdefault(config, [])
1399
1400    writer.write(f'{attribute_name} = ')
1401    write_config_select(
1402        writer,
1403        config_deps,
1404        lambda _, targets: write_target_list(writer, targets),
1405    )
1406    writer.write_line(',')
1407
1408
1409def group_paths_by_config(
1410    configs: List[Config], paths: List[pathlib.Path]
1411) -> Dict[Config, List[pathlib.Path]]:
1412
1413  config_files = defaultdict(list)
1414
1415  for f in paths:
1416    matching_configs = [c for c in configs if _is_relative_to(f, c.out_path)]
1417
1418    if not matching_configs:
1419      continue
1420
1421    # The path can only appear in ANDROID_HOST_OUT for host target or
1422    # ANDROID_PRODUCT_OUT, but cannot appear in both.
1423    if len(matching_configs) > 1:
1424      raise ValueError(
1425          f'Installed path `{f}` is not in'
1426          ' ANDROID_HOST_OUT or ANDROID_PRODUCT_OUT'
1427      )
1428
1429    config_files[matching_configs[0]].append(f)
1430
1431  return config_files
1432
1433
1434def group_targets_by_config(
1435    targets: List[Target],
1436) -> Dict[Config, List[Target]]:
1437
1438  config_to_targets = defaultdict(list)
1439
1440  for target in targets:
1441    for config in target.supported_configs():
1442      config_to_targets[config].append(target)
1443
1444  return config_to_targets
1445
1446
1447def filter_configs(
1448    config_dict: Dict[Config, Any],
1449    configs: Set[Config],
1450) -> Dict[Config, Any]:
1451  return {k: v for (k, v) in config_dict.items() if k in configs}
1452
1453
1454def _is_relative_to(path1: pathlib.Path, path2: pathlib.Path) -> bool:
1455  """Return True if the path is relative to another path or False."""
1456  # Note that this implementation is required because Path.is_relative_to only
1457  # exists starting with Python 3.9.
1458  try:
1459    path1.relative_to(path2)
1460    return True
1461  except ValueError:
1462    return False
1463
1464
1465def get_module_installed_paths(
1466    info: Dict[str, Any], src_root_path: pathlib.Path
1467) -> List[pathlib.Path]:
1468
1469  # Install paths in module-info are usually relative to the Android
1470  # source root ${ANDROID_BUILD_TOP}. When the output directory is
1471  # customized by the user however, the install paths are absolute.
1472  def resolve(install_path_string):
1473    install_path = pathlib.Path(install_path_string)
1474    if not install_path.expanduser().is_absolute():
1475      return src_root_path.joinpath(install_path)
1476    return install_path
1477
1478  return map(resolve, info.get(constants.MODULE_INSTALLED, []))
1479
1480
1481def find_runtime_dep_refs(
1482    mod_info: module_info.ModuleInfo,
1483    info: module_info.Module,
1484    configs: List[Config],
1485    src_root_path: pathlib.Path,
1486    enabled_features: List[Features],
1487) -> List[ModuleRef]:
1488  """Return module references for runtime dependencies."""
1489
1490  # We don't use the `dependencies` module-info field for shared libraries
1491  # since it's ambiguous and could generate more targets and pull in more
1492  # dependencies than necessary. In particular, libraries that support both
1493  # static and dynamic linking could end up becoming runtime dependencies
1494  # even though the build specifies static linking. For example, if a target
1495  # 'T' is statically linked to 'U' which supports both variants, the latter
1496  # still appears as a dependency. Since we can't tell, this would result in
1497  # the shared library variant of 'U' being added on the library path.
1498  libs = set()
1499  libs.update(info.get(constants.MODULE_SHARED_LIBS, []))
1500  libs.update(info.get(constants.MODULE_RUNTIME_DEPS, []))
1501
1502  if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES in enabled_features:
1503    libs.update(info.get(constants.MODULE_LIBS, []))
1504
1505  runtime_dep_refs = _find_module_refs(mod_info, configs, src_root_path, libs)
1506
1507  runtime_library_class = {'RLIB_LIBRARIES', 'DYLIB_LIBRARIES'}
1508  # We collect rlibs even though they are technically static libraries since
1509  # they could refer to dylibs which are required at runtime. Generating
1510  # Bazel targets for these intermediate modules keeps the generator simple
1511  # and preserves the shape (isomorphic) of the Soong structure making the
1512  # workspace easier to debug.
1513  for dep_name in info.get(constants.MODULE_DEPENDENCIES, []):
1514    dep_info = mod_info.get_module_info(dep_name)
1515    if not dep_info:
1516      continue
1517    if not runtime_library_class.intersection(
1518        dep_info.get(constants.MODULE_CLASS, [])
1519    ):
1520      continue
1521    runtime_dep_refs.append(ModuleRef.for_info(dep_info))
1522
1523  return runtime_dep_refs
1524
1525
1526def find_data_dep_refs(
1527    mod_info: module_info.ModuleInfo,
1528    info: module_info.Module,
1529    configs: List[Config],
1530    src_root_path: pathlib.Path,
1531) -> List[ModuleRef]:
1532  """Return module references for data dependencies."""
1533
1534  return _find_module_refs(
1535      mod_info, configs, src_root_path, info.get(constants.MODULE_DATA_DEPS, [])
1536  )
1537
1538
1539def find_device_data_dep_refs(
1540    gen: WorkspaceGenerator,
1541    info: module_info.Module,
1542) -> List[ModuleRef]:
1543  """Return module references for device data dependencies."""
1544
1545  return _find_module_refs(
1546      gen.mod_info,
1547      [Config('device', gen.resource_manager.get_product_out_file_path())],
1548      gen.resource_manager.get_src_file_path(),
1549      info.get(constants.MODULE_TARGET_DEPS, []),
1550  )
1551
1552
1553def find_static_dep_refs(
1554    mod_info: module_info.ModuleInfo,
1555    info: module_info.Module,
1556    configs: List[Config],
1557    src_root_path: pathlib.Path,
1558    enabled_features: List[Features],
1559) -> List[ModuleRef]:
1560  """Return module references for static libraries."""
1561
1562  if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES not in enabled_features:
1563    return []
1564
1565  static_libs = set()
1566  static_libs.update(info.get(constants.MODULE_STATIC_LIBS, []))
1567  static_libs.update(info.get(constants.MODULE_STATIC_DEPS, []))
1568
1569  return _find_module_refs(mod_info, configs, src_root_path, static_libs)
1570
1571
1572def _find_module_refs(
1573    mod_info: module_info.ModuleInfo,
1574    configs: List[Config],
1575    src_root_path: pathlib.Path,
1576    module_names: List[str],
1577) -> List[ModuleRef]:
1578  """Return module references for modules."""
1579
1580  module_refs = []
1581
1582  for name in module_names:
1583    info = mod_info.get_module_info(name)
1584    if not info:
1585      continue
1586
1587    installed_paths = get_module_installed_paths(info, src_root_path)
1588    config_files = group_paths_by_config(configs, installed_paths)
1589    if not config_files:
1590      continue
1591
1592    module_refs.append(ModuleRef.for_info(info))
1593
1594  return module_refs
1595
1596
1597class IndentWriter:
1598
1599  def __init__(self, f: IO):
1600    self._file = f
1601    self._indent_level = 0
1602    self._indent_string = 4 * ' '
1603    self._indent_next = True
1604
1605  def write_line(self, text: str = ''):
1606    if text:
1607      self.write(text)
1608
1609    self._file.write('\n')
1610    self._indent_next = True
1611
1612  def write(self, text):
1613    if self._indent_next:
1614      self._file.write(self._indent_string * self._indent_level)
1615      self._indent_next = False
1616
1617    self._file.write(text)
1618
1619  @contextlib.contextmanager
1620  def indent(self):
1621    self._indent_level += 1
1622    yield
1623    self._indent_level -= 1
1624
1625
1626def write_config_select(
1627    writer: IndentWriter,
1628    config_dict: Dict[Config, Any],
1629    write_value_fn: Callable,
1630):
1631  writer.write_line('select({')
1632
1633  with writer.indent():
1634    for config, value in sorted(config_dict.items(), key=lambda c: c[0].name):
1635
1636      writer.write(f'"//bazel/rules:{config.name}": ')
1637      write_value_fn(config, value)
1638      writer.write_line(',')
1639
1640  writer.write('})')
1641
1642
1643def write_target_list(writer: IndentWriter, targets: List[Target]):
1644  writer.write_line('[')
1645
1646  with writer.indent():
1647    for label in sorted(set(t.qualified_name() for t in targets)):
1648      writer.write_line(f'"{label}",')
1649
1650  writer.write(']')
1651
1652
1653def _decorate_find_method(mod_info, finder_method_func, host, enabled_features):
1654  """A finder_method decorator to override TestInfo properties."""
1655
1656  def use_bazel_runner(finder_obj, test_id):
1657    test_infos = finder_method_func(finder_obj, test_id)
1658    if not test_infos:
1659      return test_infos
1660    for tinfo in test_infos:
1661      m_info = mod_info.get_module_info(tinfo.test_name)
1662
1663      # TODO(b/262200630): Refactor the duplicated logic in
1664      # _decorate_find_method() and _add_test_module_targets() to
1665      # determine whether a test should run with Atest Bazel Mode.
1666
1667      # Only enable modern Robolectric tests since those are the only ones
1668      # TF currently supports.
1669      if mod_info.is_modern_robolectric_test(m_info):
1670        if Features.EXPERIMENTAL_ROBOLECTRIC_TEST in enabled_features:
1671          tinfo.test_runner = BazelTestRunner.NAME
1672        continue
1673
1674      # Only run device-driven tests in Bazel mode when '--host' is not
1675      # specified and the feature is enabled.
1676      if not host and mod_info.is_device_driven_test(m_info):
1677        if Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in enabled_features:
1678          tinfo.test_runner = BazelTestRunner.NAME
1679        continue
1680
1681      if mod_info.is_suite_in_compatibility_suites(
1682          'host-unit-tests', m_info
1683      ) or (
1684          Features.EXPERIMENTAL_HOST_DRIVEN_TEST in enabled_features
1685          and mod_info.is_host_driven_test(m_info)
1686      ):
1687        tinfo.test_runner = BazelTestRunner.NAME
1688    return test_infos
1689
1690  return use_bazel_runner
1691
1692
1693def create_new_finder(
1694    mod_info: module_info.ModuleInfo,
1695    finder: test_finder_base.TestFinderBase,
1696    host: bool,
1697    enabled_features: List[Features] = None,
1698):
1699  """Create new test_finder_base.Finder with decorated find_method.
1700
1701  Args:
1702    mod_info: ModuleInfo object.
1703    finder: Test Finder class.
1704    host: Whether to run the host variant.
1705    enabled_features: List of enabled features.
1706
1707  Returns:
1708      List of ordered find methods.
1709  """
1710  return test_finder_base.Finder(
1711      finder.test_finder_instance,
1712      _decorate_find_method(
1713          mod_info, finder.find_method, host, enabled_features or []
1714      ),
1715      finder.finder_info,
1716  )
1717
1718
1719class RunCommandError(subprocess.CalledProcessError):
1720  """CalledProcessError but including debug information when it fails."""
1721
1722  def __str__(self):
1723    return f'{super().__str__()}\nstdout={self.stdout}\n\nstderr={self.stderr}'
1724
1725
1726def default_run_command(args: List[str], cwd: pathlib.Path) -> str:
1727  result = subprocess.run(
1728      args=args,
1729      cwd=cwd,
1730      text=True,
1731      capture_output=True,
1732      check=False,
1733  )
1734  if result.returncode:
1735    # Provide a more detailed log message including stdout and stderr.
1736    raise RunCommandError(
1737        result.returncode, result.args, result.stdout, result.stderr
1738    )
1739  return result.stdout
1740
1741
1742@dataclasses.dataclass
1743class BuildMetadata:
1744  build_branch: str
1745  build_target: str
1746
1747
1748class BazelTestRunner(trb.TestRunnerBase):
1749  """Bazel Test Runner class."""
1750
1751  NAME = 'BazelTestRunner'
1752  EXECUTABLE = 'none'
1753
1754  # pylint: disable=redefined-outer-name
1755  # pylint: disable=too-many-arguments
1756  def __init__(
1757      self,
1758      results_dir,
1759      mod_info: module_info.ModuleInfo,
1760      extra_args: Dict[str, Any] = None,
1761      src_top: pathlib.Path = None,
1762      workspace_path: pathlib.Path = None,
1763      run_command: Callable = default_run_command,
1764      build_metadata: BuildMetadata = None,
1765      env: Dict[str, str] = None,
1766      generate_workspace_fn: Callable = generate_bazel_workspace,
1767      enabled_features: Set[str] = None,
1768      **kwargs,
1769  ):
1770    super().__init__(results_dir, **kwargs)
1771    self.mod_info = mod_info
1772    self.src_top = src_top or pathlib.Path(
1773        os.environ.get(constants.ANDROID_BUILD_TOP)
1774    )
1775    self.starlark_file = _get_resource_root().joinpath(
1776        'format_as_soong_module_name.cquery'
1777    )
1778
1779    self.bazel_workspace = workspace_path or get_bazel_workspace_dir()
1780    self.bazel_binary = self.bazel_workspace.joinpath('bazel.sh')
1781    self.run_command = run_command
1782    self._extra_args = extra_args or {}
1783    self.build_metadata = build_metadata or get_default_build_metadata()
1784    self.env = env or os.environ
1785    self._generate_workspace_fn = generate_workspace_fn
1786    self._enabled_features = (
1787        enabled_features
1788        if enabled_features is not None
1789        else atest_configs.GLOBAL_ARGS.bazel_mode_features
1790    )
1791
1792  # pylint: disable=unused-argument
1793  def run_tests(self, test_infos, extra_args, reporter):
1794    """Run the list of test_infos.
1795
1796    Args:
1797        test_infos: List of TestInfo.
1798        extra_args: Dict of extra args to add to test run.
1799        reporter: An instance of result_report.ResultReporter.
1800    """
1801    ret_code = ExitCode.SUCCESS
1802
1803    try:
1804      run_cmds = self.generate_run_commands(test_infos, extra_args)
1805    except AbortRunException as e:
1806      atest_utils.colorful_print(f'Stop running test(s): {e}', constants.RED)
1807      return ExitCode.ERROR
1808
1809    for run_cmd in run_cmds:
1810      subproc = self.run(run_cmd, output_to_stdout=True)
1811      ret_code |= self.wait_for_subprocess(subproc)
1812
1813    self.organize_test_logs(test_infos)
1814
1815    return ret_code
1816
1817  def organize_test_logs(self, test_infos: List[test_info.TestInfo]):
1818    for t_info in test_infos:
1819      test_output_dir, package_name, target_suffix = (
1820          self.retrieve_test_output_info(t_info)
1821      )
1822      if test_output_dir.joinpath(TEST_OUTPUT_ZIP_NAME).exists():
1823        # TEST_OUTPUT_ZIP file exist when BES uploading is enabled.
1824        # Showing the BES link to users instead of the local log.
1825        continue
1826
1827      # AtestExecutionInfo will find all log files in 'results_dir/log'
1828      # directory and generate an HTML file to display to users when
1829      # 'results_dir/log' directory exist.
1830      log_path = pathlib.Path(self.results_dir).joinpath(
1831          'log', f'{package_name}', f'{t_info.test_name}_{target_suffix}'
1832      )
1833      log_path.parent.mkdir(parents=True, exist_ok=True)
1834      if not log_path.is_symlink():
1835        log_path.symlink_to(test_output_dir)
1836
1837  def _get_feature_config_or_warn(self, feature, env_var_name):
1838    feature_config = self.env.get(env_var_name)
1839    if not feature_config:
1840      atest_utils.print_and_log_warning(
1841          'Ignoring `%s` because the `%s` environment variable is not set.',
1842          # pylint: disable=no-member
1843          feature,
1844          env_var_name,
1845      )
1846    return feature_config
1847
1848  def _get_bes_publish_args(self, feature: Features) -> List[str]:
1849    bes_publish_config = self._get_feature_config_or_warn(
1850        feature, 'ATEST_BAZEL_BES_PUBLISH_CONFIG'
1851    )
1852
1853    if not bes_publish_config:
1854      return []
1855
1856    branch = self.build_metadata.build_branch
1857    target = self.build_metadata.build_target
1858
1859    return [
1860        f'--config={bes_publish_config}',
1861        f'--build_metadata=ab_branch={branch}',
1862        f'--build_metadata=ab_target={target}',
1863    ]
1864
1865  def _get_remote_args(self, feature):
1866    remote_config = self._get_feature_config_or_warn(
1867        feature, 'ATEST_BAZEL_REMOTE_CONFIG'
1868    )
1869    if not remote_config:
1870      return []
1871    return [f'--config={remote_config}']
1872
1873  def _get_remote_avd_args(self, feature):
1874    remote_avd_config = self._get_feature_config_or_warn(
1875        feature, 'ATEST_BAZEL_REMOTE_AVD_CONFIG'
1876    )
1877    if not remote_avd_config:
1878      raise ValueError(
1879          'Cannot run remote device test because '
1880          'ATEST_BAZEL_REMOTE_AVD_CONFIG '
1881          'environment variable is not set.'
1882      )
1883    return [f'--config={remote_avd_config}']
1884
1885  def host_env_check(self):
1886    """Check that host env has everything we need.
1887
1888    We actually can assume the host env is fine because we have the same
1889    requirements that atest has. Update this to check for android env vars
1890    if that changes.
1891    """
1892
1893  def get_test_runner_build_reqs(self, test_infos) -> Set[str]:
1894    if not test_infos:
1895      return set()
1896
1897    self._generate_workspace_fn(
1898        self.mod_info,
1899        self._enabled_features,
1900    )
1901
1902    deps_expression = ' + '.join(
1903        sorted(self.test_info_target_label(i) for i in test_infos)
1904    )
1905
1906    with tempfile.NamedTemporaryFile() as query_file:
1907      with open(query_file.name, 'w', encoding='utf-8') as _query_file:
1908        _query_file.write(f'deps(tests({deps_expression}))')
1909
1910      query_args = [
1911          str(self.bazel_binary),
1912          'cquery',
1913          f'--query_file={query_file.name}',
1914          '--output=starlark',
1915          f'--starlark:file={self.starlark_file}',
1916      ]
1917
1918      output = self.run_command(query_args, self.bazel_workspace)
1919
1920    targets = set()
1921    robolectric_tests = set(
1922        filter(
1923            self._is_robolectric_test_suite,
1924            [test.test_name for test in test_infos],
1925        )
1926    )
1927
1928    modules_to_variant = _parse_cquery_output(output)
1929
1930    for module, variants in modules_to_variant.items():
1931
1932      # Skip specifying the build variant for Robolectric test modules
1933      # since they are special. Soong builds them with the `target`
1934      # variant although are installed as 'host' modules.
1935      if module in robolectric_tests:
1936        targets.add(module)
1937        continue
1938
1939      targets.add(_soong_target_for_variants(module, variants))
1940
1941    return targets
1942
1943  def _is_robolectric_test_suite(self, module_name: str) -> bool:
1944    return self.mod_info.is_robolectric_test_suite(
1945        self.mod_info.get_module_info(module_name)
1946    )
1947
1948  def test_info_target_label(self, test: test_info.TestInfo) -> str:
1949    module_name = test.test_name
1950    info = self.mod_info.get_module_info(module_name)
1951    package_name = info.get(constants.MODULE_PATH)[0]
1952    target_suffix = self.get_target_suffix(info)
1953
1954    return f'//{package_name}:{module_name}_{target_suffix}'
1955
1956  def retrieve_test_output_info(
1957      self, test_info: test_info.TestInfo
1958  ) -> Tuple[pathlib.Path, str, str]:
1959    """Return test output information.
1960
1961    Args:
1962        test_info (test_info.TestInfo): Information about the test.
1963
1964    Returns:
1965        Tuple[pathlib.Path, str, str]: A tuple containing the following
1966        elements:
1967            - test_output_dir (pathlib.Path): Absolute path of the test output
1968                folder.
1969            - package_name (str): Name of the package.
1970            - target_suffix (str): Target suffix.
1971    """
1972    module_name = test_info.test_name
1973    info = self.mod_info.get_module_info(module_name)
1974    package_name = info.get(constants.MODULE_PATH)[0]
1975    target_suffix = self.get_target_suffix(info)
1976
1977    test_output_dir = pathlib.Path(
1978        self.bazel_workspace,
1979        BAZEL_TEST_LOGS_DIR_NAME,
1980        package_name,
1981        f'{module_name}_{target_suffix}',
1982        TEST_OUTPUT_DIR_NAME,
1983    )
1984
1985    return test_output_dir, package_name, target_suffix
1986
1987  def get_target_suffix(self, info: Dict[str, Any]) -> str:
1988    """Return 'host' or 'device' accordingly to the variant of the test."""
1989    if not self._extra_args.get(
1990        constants.HOST, False
1991    ) and self.mod_info.is_device_driven_test(info):
1992      return 'device'
1993    return 'host'
1994
1995  @staticmethod
1996  def _get_bazel_feature_args(
1997      feature: Features, extra_args: Dict[str, Any], generator: Callable
1998  ) -> List[str]:
1999    if feature not in extra_args.get('BAZEL_MODE_FEATURES', []):
2000      return []
2001    return generator(feature)
2002
2003  # pylint: disable=unused-argument
2004  def generate_run_commands(self, test_infos, extra_args, port=None):
2005    """Generate a list of run commands from TestInfos.
2006
2007    Args:
2008        test_infos: A set of TestInfo instances.
2009        extra_args: A Dict of extra args to append.
2010        port: Optional. An int of the port number to send events to.
2011
2012    Returns:
2013        A list of run commands to run the tests.
2014    """
2015    startup_options = ''
2016    bazelrc = self.env.get('ATEST_BAZELRC')
2017
2018    if bazelrc:
2019      startup_options = f'--bazelrc={bazelrc}'
2020
2021    target_patterns = ' '.join(
2022        self.test_info_target_label(i) for i in test_infos
2023    )
2024
2025    bazel_args = parse_args(test_infos, extra_args)
2026
2027    # If BES is not enabled, use the option of
2028    # '--nozip_undeclared_test_outputs' to not compress the test outputs.
2029    # And the URL of test outputs will be printed in terminal.
2030    bazel_args.extend(
2031        self._get_bazel_feature_args(
2032            Features.EXPERIMENTAL_BES_PUBLISH,
2033            extra_args,
2034            self._get_bes_publish_args,
2035        )
2036        or ['--nozip_undeclared_test_outputs']
2037    )
2038    bazel_args.extend(
2039        self._get_bazel_feature_args(
2040            Features.EXPERIMENTAL_REMOTE, extra_args, self._get_remote_args
2041        )
2042    )
2043    bazel_args.extend(
2044        self._get_bazel_feature_args(
2045            Features.EXPERIMENTAL_REMOTE_AVD,
2046            extra_args,
2047            self._get_remote_avd_args,
2048        )
2049    )
2050
2051    # This is an alternative to shlex.join that doesn't exist in Python
2052    # versions < 3.8.
2053    bazel_args_str = ' '.join(shlex.quote(arg) for arg in bazel_args)
2054
2055    # Use 'cd' instead of setting the working directory in the subprocess
2056    # call for a working --dry-run command that users can run.
2057    return [
2058        f'cd {self.bazel_workspace} && '
2059        f'{self.bazel_binary} {startup_options} '
2060        f'test {target_patterns} {bazel_args_str}'
2061    ]
2062
2063
2064def parse_args(
2065    test_infos: List[test_info.TestInfo], extra_args: Dict[str, Any]
2066) -> Dict[str, Any]:
2067  """Parse commandline args and passes supported args to bazel.
2068
2069  Args:
2070      test_infos: A set of TestInfo instances.
2071      extra_args: A Dict of extra args to append.
2072
2073  Returns:
2074      A list of args to append to the run command.
2075  """
2076
2077  args_to_append = []
2078  # Make a copy of the `extra_args` dict to avoid modifying it for other
2079  # Atest runners.
2080  extra_args_copy = extra_args.copy()
2081
2082  # Remove the `--host` flag since we already pass that in the rule's
2083  # implementation.
2084  extra_args_copy.pop(constants.HOST, None)
2085
2086  # Remove the serial arg since Bazel mode does not support device tests and
2087  # the serial / -s arg conflicts with the TF null device option specified in
2088  # the rule implementation (-n).
2089  extra_args_copy.pop(constants.SERIAL, None)
2090
2091  # Map args to their native Bazel counterparts.
2092  for arg in _SUPPORTED_BAZEL_ARGS:
2093    if arg not in extra_args_copy:
2094      continue
2095    args_to_append.extend(_map_to_bazel_args(arg, extra_args_copy[arg]))
2096    # Remove the argument since we already mapped it to a Bazel option
2097    # and no longer need it mapped to a Tradefed argument below.
2098    del extra_args_copy[arg]
2099
2100  # TODO(b/215461642): Store the extra_args in the top-level object so
2101  # that we don't have to re-parse the extra args to get BAZEL_ARG again.
2102  tf_args, _ = tfr.extra_args_to_tf_args(extra_args_copy)
2103
2104  # Add ATest include filter argument to allow testcase filtering.
2105  tf_args.extend(tfr.get_include_filter(test_infos))
2106
2107  args_to_append.extend([f'--test_arg={i}' for i in tf_args])
2108
2109  # Disable test result caching when wait-for-debugger flag is set.
2110  if '--wait-for-debugger' in tf_args:
2111    # Remove the --cache_test_results flag if it's already set.
2112    args_to_append = [
2113        arg
2114        for arg in args_to_append
2115        if not arg.startswith('--cache_test_results')
2116    ]
2117    args_to_append.append('--cache_test_results=no')
2118
2119  # Default to --test_output=errors unless specified otherwise
2120  if not any(arg.startswith('--test_output=') for arg in args_to_append):
2121    args_to_append.append('--test_output=errors')
2122
2123  # Default to --test_summary=detailed unless specified otherwise, or if the
2124  # feature is disabled
2125  if not any(arg.startswith('--test_summary=') for arg in args_to_append) and (
2126      Features.NO_BAZEL_DETAILED_SUMMARY
2127      not in extra_args.get('BAZEL_MODE_FEATURES', [])
2128  ):
2129    args_to_append.append('--test_summary=detailed')
2130
2131  return args_to_append
2132
2133
2134def _map_to_bazel_args(arg: str, arg_value: Any) -> List[str]:
2135  return (
2136      _SUPPORTED_BAZEL_ARGS[arg](arg_value)
2137      if arg in _SUPPORTED_BAZEL_ARGS
2138      else []
2139  )
2140
2141
2142def _parse_cquery_output(output: str) -> Dict[str, Set[str]]:
2143  module_to_build_variants = defaultdict(set)
2144
2145  for line in filter(bool, map(str.strip, output.splitlines())):
2146    module_name, build_variant = line.split(':')
2147    module_to_build_variants[module_name].add(build_variant)
2148
2149  return module_to_build_variants
2150
2151
2152def _soong_target_for_variants(
2153    module_name: str, build_variants: Set[str]
2154) -> str:
2155
2156  if not build_variants:
2157    raise ValueError(
2158        f'Missing the build variants for module {module_name} in cquery output!'
2159    )
2160
2161  if len(build_variants) > 1:
2162    return module_name
2163
2164  return f'{module_name}-{_CONFIG_TO_VARIANT[list(build_variants)[0]]}'
2165